@rig-c/parts 0.0.0-stage → 1.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +433 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +150 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- package/workflows/seethrough.json +68 -0
package/src/check.ts
ADDED
|
@@ -0,0 +1,2942 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The check stage: build a rig through rig-c, gate it under both
|
|
3
|
+
* profiles, render its idle, and measure the two things the gate cannot see —
|
|
4
|
+
* whether the setup pose IS the flat stack of parts (the seam), and whether the
|
|
5
|
+
* idle ends where it began (the loop).
|
|
6
|
+
*
|
|
7
|
+
* ⭐ **This is the one stage that drives a subprocess, and it does not own the
|
|
8
|
+
* spawn.** Every rigc step — build, validate, render — is rig-c's own CLI,
|
|
9
|
+
* never a re-implementation of it, because rigc's gate is the only oracle
|
|
10
|
+
* behind a skeleton (CLAUDE.md, "rig-c's gate is not optional here
|
|
11
|
+
* either"): the spine-core round trip where the runtime resolves beside rigc,
|
|
12
|
+
* rigc's own validator where it does not, and `check.json`'s `rigc_entry` says
|
|
13
|
+
* which ({@link readRigcEntry}). But `src/` is held to no child
|
|
14
|
+
* processes (`TY06`, with `src/comfy/` its one named exception), so the process
|
|
15
|
+
* is injected: `runCheck` takes a {@link RigcRunner}, `cli.ts` hands it one
|
|
16
|
+
* that spawns the binary {@link findRigc} located, and the selftest hands it
|
|
17
|
+
* the same. What this module does with the file system is read inputs and write
|
|
18
|
+
* the outputs listed below; it never reads the clock or the network.
|
|
19
|
+
*
|
|
20
|
+
* Input — a rig directory holding what the rig stage writes:
|
|
21
|
+
* `rig.json`, `motion.json` (with an `idle` animation), `parts.json`, and
|
|
22
|
+
* `parts/<name>.png` for every part (the last two may sit in a directory of
|
|
23
|
+
* their own, `partsHome`). It is a rig SPEC, which `rigc build` compiles, not
|
|
24
|
+
* a compiled skeleton. Since issue #77 the `idle` and `parts.json` may be
|
|
25
|
+
* missing — a rig merged from several rig-parts outputs has neither — and
|
|
26
|
+
* every line that reads the missing one says SKIP with the reason; the
|
|
27
|
+
* painting (`--source`) adds {@link SOURCE_LINE}. The inputs are read only;
|
|
28
|
+
* everything is written under the output directory (the idle's outputs only
|
|
29
|
+
* when there is an idle):
|
|
30
|
+
*
|
|
31
|
+
* | output | what |
|
|
32
|
+
* | --- | --- |
|
|
33
|
+
* | `build/` | `rigc build --profile spine-html --pack --page-edges free --pack-shape polygon` (or `pot`, `rect`, when the caller names them; {@link PAGE_EDGES}, {@link PACK_SHAPES}) — the packed atlas is the final artifact |
|
|
34
|
+
* | `gate_spine-html.txt` | that build's gate lines, verbatim — the one gate: rigc's `build` runs it once over the compile and once over the packed pages on disk, and `spine-html` holds every rule `spine` measures (selftest `CH09`) |
|
|
35
|
+
* | `idle_frames/` | `rigc render --animation idle --fps 12 --max 640 --geometry`: `frames.json` + `idle/f*.png` + `idle/geometry.json` (each mesh's skinned vertices, which `TEXTURE_STRETCH` reads, every attachment's posed triangles, which `TIP_OVER_ROOT` and both halves of `STILL_REGIONS_DARK` read since issues #118 and #123, and every bone's world transform, which the face half reads its head bone from) |
|
|
36
|
+
* | `contact.png` | rigc's own contact sheet of that render, copied out |
|
|
37
|
+
* | `motion_heat.png` | frame 0 in grey with each pixel's largest change across the idle in red |
|
|
38
|
+
* | `check.json` | the figures below and `PASS` |
|
|
39
|
+
* | `requirements/` | under `--requirements` only (issue #93): `as-declared/<animation>/`, and per follow `released/<name>/` and `full/<name>/` — rigc renders with `--geometry` ({@link measureRequirements}) |
|
|
40
|
+
*
|
|
41
|
+
* The acceptance bars are the reference implementation's (`check_rig.py`):
|
|
42
|
+
* both gate summaries `0 failed`; seam mean |d| <= 1.0 of 255 and at most 50
|
|
43
|
+
* pixels differing by more than 40; loop max |d| == 0.
|
|
44
|
+
*
|
|
45
|
+
* Beside them, six judgement lines (issue #11, and #31 for the sixth) measure
|
|
46
|
+
* what used to be left to an eye — `BREATH_VISIBLE`, `BLINK_NO_HOLE`,
|
|
47
|
+
* `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH` — from
|
|
48
|
+
* rigc renders of the idle (the whole rig, or a part alone through `--slot`),
|
|
49
|
+
* from the setup still with the blink held shut, from `motion.json` read as
|
|
50
|
+
* sines, or from the skinned mesh vertices the idle render's `--geometry`
|
|
51
|
+
* writes beside its frames (rig-c {@link RIGC_GEOMETRY_VERSION} or later,
|
|
52
|
+
* which `check` asks `rigc --version` for before it builds anything). Each reports SKIP,
|
|
53
|
+
* with the reason, when the rig has nothing for it to read. The instruments
|
|
54
|
+
* are `src/instruments.ts`; the bars are below and in AUTHORING §7. After
|
|
55
|
+
* them, one REPORTED line with no bar, `RECOMPOSITE_HOLES` (issue #25): the
|
|
56
|
+
* uncovered holes `assemble` recorded in `parts.json`'s `recomposite` block,
|
|
57
|
+
* copied into `check.json` because the seam cannot see them — it compares the
|
|
58
|
+
* setup pose with the flat stack of parts, and a pixel no part holds is
|
|
59
|
+
* missing from both. The
|
|
60
|
+
* part-alone renders and the shut-eye still are scratch (`_isolated/`,
|
|
61
|
+
* `_still/`), removed before the stage returns.
|
|
62
|
+
*
|
|
63
|
+
* Where this port deliberately differs from the reference, each written where
|
|
64
|
+
* it applies below: a gate summary is read with a pattern rather than the
|
|
65
|
+
* substring `"0 failed"` (which `"10 failed"` contains); a gate with no summary
|
|
66
|
+
* line is not green (Python's `all()` of nothing is `True`); the seam render's
|
|
67
|
+
* `--max` is the rig canvas's longest side rather than the literal 1216 (which
|
|
68
|
+
* was that for every rig it ever saw); the grey is read from rigc's
|
|
69
|
+
* `frames.json` rather than typed; the crop-to-world map comes from the rig's
|
|
70
|
+
* own stage box through `src/coords.ts` rather than an open-coded `-W/2`; and
|
|
71
|
+
* the last idle frame is refused unless it sits at t = duration.
|
|
72
|
+
*/
|
|
73
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
74
|
+
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
75
|
+
import { measureRecomposite, type RecompositeFigures, recompositeRecord, sourceInRig } from './assemble.ts';
|
|
76
|
+
import { cropToSpineY } from './coords.ts';
|
|
77
|
+
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
78
|
+
import { framesInside, IDLE_FPS } from './motion.ts';
|
|
79
|
+
import {
|
|
80
|
+
blinkFigures,
|
|
81
|
+
type GeometryPose,
|
|
82
|
+
type MeshRest,
|
|
83
|
+
blinkTracks,
|
|
84
|
+
type ArtMask,
|
|
85
|
+
type BoneTrack,
|
|
86
|
+
type BoneWorld,
|
|
87
|
+
boxLabel,
|
|
88
|
+
crossingOf,
|
|
89
|
+
type FrameMap,
|
|
90
|
+
chainFigures,
|
|
91
|
+
EYE_TAGS,
|
|
92
|
+
eyeBones,
|
|
93
|
+
FACE_FEATURE_TAGS,
|
|
94
|
+
FACE_TAGS,
|
|
95
|
+
FEET_TAGS,
|
|
96
|
+
frameBox,
|
|
97
|
+
heatField,
|
|
98
|
+
partsTagged,
|
|
99
|
+
type PixelBox,
|
|
100
|
+
type PosedTriangles,
|
|
101
|
+
regionHeat,
|
|
102
|
+
partWorldBox,
|
|
103
|
+
type RigRegion,
|
|
104
|
+
setupToFrame,
|
|
105
|
+
stillReading,
|
|
106
|
+
stillTolerance,
|
|
107
|
+
stretchFigures,
|
|
108
|
+
stretchSeverity,
|
|
109
|
+
sweptBox,
|
|
110
|
+
SWING_TAGS,
|
|
111
|
+
topmostIndex,
|
|
112
|
+
TORSO_TAGS,
|
|
113
|
+
halfTravels,
|
|
114
|
+
type WorldBox,
|
|
115
|
+
} from './instruments.ts';
|
|
116
|
+
import { type PartRecord, type PartsFile, readParts } from './parts.ts';
|
|
117
|
+
import {
|
|
118
|
+
aimLine,
|
|
119
|
+
contactLine,
|
|
120
|
+
declareConsumerDriven,
|
|
121
|
+
followLine,
|
|
122
|
+
forceMix,
|
|
123
|
+
mutedLine,
|
|
124
|
+
mutedOnly,
|
|
125
|
+
placeTargets,
|
|
126
|
+
type Poses,
|
|
127
|
+
rangeLine,
|
|
128
|
+
readRequirements,
|
|
129
|
+
type RequirementLine,
|
|
130
|
+
type RequirementsFile,
|
|
131
|
+
requirementProblem,
|
|
132
|
+
type RequirementsSummary,
|
|
133
|
+
requirementsSummary,
|
|
134
|
+
resolveRequirements,
|
|
135
|
+
rigFacts,
|
|
136
|
+
placedArt,
|
|
137
|
+
type PosedAttachment,
|
|
138
|
+
resolveSeams,
|
|
139
|
+
type SeamFrame,
|
|
140
|
+
seamLine,
|
|
141
|
+
type SeamPair,
|
|
142
|
+
stretchRequirementLine,
|
|
143
|
+
} from './requirements.ts';
|
|
144
|
+
import { alphaComposite } from './raster/composite.ts';
|
|
145
|
+
import { readPng, writePng } from './raster/png.ts';
|
|
146
|
+
import { newFloatImage, newRaster, type Raster } from './raster/types.ts';
|
|
147
|
+
import type { BaseTag } from './tags.ts';
|
|
148
|
+
import { type ScaleTranslate, warpAffine } from './raster/warp.ts';
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// the bars
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/** Seam: mean max-channel |d| over the frame, in levels of 255, at most this. */
|
|
155
|
+
export const SEAM_MEAN_BAR = 1.0;
|
|
156
|
+
/**
|
|
157
|
+
* Seam: pixels whose max-channel |d| exceeds {@link SEAM_PX_LEVEL}, at most
|
|
158
|
+
* this many **rig** pixels — the frame count divided by s², s the frame scale
|
|
159
|
+
* the setup-pose still renders at (issue #216). The frame holds s² pixels per
|
|
160
|
+
* rig pixel, so a stack that parts from the render over a fixed area of the
|
|
161
|
+
* rig reads s² times as many frame pixels: on the public sample a part moved
|
|
162
|
+
* 2 rig px read 400, 891 and 1,560 frame px over 40 at s = 1.0, 1.5 and 2.0,
|
|
163
|
+
* and 400, 396 and 390 divided by s²; the uncut sample 3, 3 and 7 frame px
|
|
164
|
+
* [measured for issue #216, a scratch run, not tracked]. At s = 1 the bar is
|
|
165
|
+
* the reference's frame count.
|
|
166
|
+
*/
|
|
167
|
+
export const SEAM_PX_BAR = 50;
|
|
168
|
+
export const SEAM_PX_LEVEL = 40;
|
|
169
|
+
/** Seam: the second, reported-only level. */
|
|
170
|
+
export const SEAM_PX_LEVEL_HIGH = 80;
|
|
171
|
+
/** Loop: frame 0 vs the frame at t = duration, largest |d|, exactly this. */
|
|
172
|
+
export const LOOP_MAX_BAR = 0;
|
|
173
|
+
|
|
174
|
+
/** The idle render the reference makes, and the one issue #1's loop is encoded from. Defined beside the blink it has to see (`src/motion.ts`). */
|
|
175
|
+
export { IDLE_FPS };
|
|
176
|
+
export const IDLE_MAX_PX = 640;
|
|
177
|
+
/** The throwaway setup-pose animation: one key at 0 and one at this time, rendered at this rate. */
|
|
178
|
+
const STILL_DURATION = 0.1;
|
|
179
|
+
const STILL_FPS = 10;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The judgement lines' bars (issue #11). Each is derived from the two public
|
|
183
|
+
* examples by one stated rule, and AUTHORING §7 quotes what each example
|
|
184
|
+
* measures beside it: a floor is half the weaker example's figure, a ceiling
|
|
185
|
+
* twice the worse one's, so the weaker example clears every bar by a factor of
|
|
186
|
+
* two; a bar the model itself fixes (a still part does not move, a lag is
|
|
187
|
+
* above 0, a hole is 0 pixels) is that value, not a margin. The still regions'
|
|
188
|
+
* bar is the model's too — nothing moves — read up to the arithmetic's own
|
|
189
|
+
* rounding (`stillTolerance` in `src/instruments.ts`, issue #123).
|
|
190
|
+
*/
|
|
191
|
+
export const BREATH_TORSO_MEAN_FLOOR = 3.809;
|
|
192
|
+
export const BREATH_FEET_MAX_CEILING = 0;
|
|
193
|
+
export const BLINK_HOLE_BAR = 0;
|
|
194
|
+
/** The reported colour-patch level: the seam's own "differs by more than 40". */
|
|
195
|
+
export const BLINK_PATCH_LEVEL = SEAM_PX_LEVEL;
|
|
196
|
+
/** The smallest lag the reading resolves, in cycles: phases are written to three places. */
|
|
197
|
+
export const CHAIN_LAG_MIN_STEP = 0.001;
|
|
198
|
+
export const TIP_RATIO_FLOOR = 1.4725;
|
|
199
|
+
/**
|
|
200
|
+
* `TEXTURE_STRETCH`'s ceiling on max(ratio, 1/ratio) (issue #31): the worse
|
|
201
|
+
* example's figure (demo, 1.388) squared. The ceiling rule is "twice the worse
|
|
202
|
+
* one's", and it is applied to the quantity whose zero means "nothing
|
|
203
|
+
* happened", as it is for heat: for an edge ratio that is |ln r|, so twice
|
|
204
|
+
* |ln 1.388| is ln(1.388²). Doubling the ratio itself (2.776) was rejected —
|
|
205
|
+
* its zero is not at 1, and it would pass an edge drawn at 2.7 times its rest
|
|
206
|
+
* length, the mutant the line exists to catch included.
|
|
207
|
+
*/
|
|
208
|
+
export const TEXTURE_STRETCH_CEILING = 1.926544;
|
|
209
|
+
|
|
210
|
+
/** The first rig-c whose `render` writes `geometry.json` (`--geometry`, rig-c 1.4.0's changelog). */
|
|
211
|
+
export const RIGC_GEOMETRY_VERSION = '1.4.0';
|
|
212
|
+
/** The geometry export's file name inside a frame set directory, and the format tag it carries. */
|
|
213
|
+
export const GEOMETRY_FILE = 'geometry.json';
|
|
214
|
+
export const GEOMETRY_SPEC = 'rigc-geometry/1';
|
|
215
|
+
|
|
216
|
+
/** Issue #2's yardstick, measured on the Spine example export `spineboy.png` (alpha > 0). */
|
|
217
|
+
export const SPINEBOY_YARDSTICK = '1024x256, 40 region(s), 45.8% opaque (alpha > 0)';
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* What a packed page's edges may be: rig-c's `build --pack --page-edges`,
|
|
221
|
+
* handed to it verbatim (the flag is in rig-c's CLI since 1.4.0, the
|
|
222
|
+
* floor `check` already requires; 1.5.0 made `free` search every width).
|
|
223
|
+
* `pot` is a power of two on both edges; `free` is the least-area page the
|
|
224
|
+
* placement needs, at the cost rigc's help states: region attachments sample
|
|
225
|
+
* within 1 LSB of the loose build rather than exactly.
|
|
226
|
+
*
|
|
227
|
+
* `free` is the default here although rigc's own default is `pot`: the packed
|
|
228
|
+
* page is this package's final artifact, and on the two public examples `free`
|
|
229
|
+
* takes the page from 1024x2048 to 967x1338 (covered 56.3 % -> 91.2 %) and
|
|
230
|
+
* from 512x2048 to 479x1166 (49.7 % -> 93.4 %), rigc 1.5.1, 2.0.3, 2.1.3 and 2.10.1 (`rect`) alike. The cost was
|
|
231
|
+
* measured on the same two builds. Each of the 49 idle frames differs from the
|
|
232
|
+
* `pot` build's, by at most 1 level in any channel (demo 2,373 px, sample 651
|
|
233
|
+
* px over all 49). On sample that moves STILL_REGIONS_DARK's reported
|
|
234
|
+
* screen-space face mean from 11.475 to 11.472; no bar or status moves. The
|
|
235
|
+
* atlas rigc writes says `filter: Linear, Linear` and has no `repeat` line, so
|
|
236
|
+
* a consumer that mipmaps or repeats the page itself is the one that wants
|
|
237
|
+
* `pot`. AUTHORING §5, *Page edges*, holds the tables.
|
|
238
|
+
*/
|
|
239
|
+
export const PAGE_EDGES = ['pot', 'free'] as const;
|
|
240
|
+
export type PageEdges = (typeof PAGE_EDGES)[number];
|
|
241
|
+
export const DEFAULT_PAGE_EDGES: PageEdges = 'free';
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* What two packed rectangles may share: rig-c's `build --pack
|
|
245
|
+
* --pack-shape`, handed to it verbatim (in rig-c's CLI since 2.1.0; this
|
|
246
|
+
* package's range starts at 2.15.0). `rect` keeps every region's cell apart;
|
|
247
|
+
* `polygon` packs a region that only meshes draw by its emitted hull, so a
|
|
248
|
+
* neighbour may sit inside its rectangle where the hull is not, with the
|
|
249
|
+
* padding kept between footprints — a region attachment stays its rectangle
|
|
250
|
+
* (rigc's help).
|
|
251
|
+
*
|
|
252
|
+
* `polygon` is the default here although rigc's own default is `rect`, for the
|
|
253
|
+
* reason {@link DEFAULT_PAGE_EDGES} is `free`: the packed page is this
|
|
254
|
+
* package's final artifact, rigc gates the footprints on the pages on disk
|
|
255
|
+
* (`A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP`), and the cost is the class already
|
|
256
|
+
* accepted for `free`. Measured with rig-c 2.1.3, and again with 2.10.1,
|
|
257
|
+
* whose pages, atlases and idle frames are byte-identical to it (the full entry,
|
|
258
|
+
* spine-core 4.3.13 beside it), `rig-parts build` on the two public examples
|
|
259
|
+
* under `--page-edges free`: `polygon` takes demo's page from 967x1338 (91.2 %
|
|
260
|
+
* covered) to 922x1348 (95.0 %), 3.9 % less area, and sample's from 479x1166
|
|
261
|
+
* (93.4 %) to 477x1151 (95.0 %), 1.7 % less. Each of the 49 idle frames at 12
|
|
262
|
+
* fps differs from the `rect` build's by at most 1 level in any channel (demo
|
|
263
|
+
* 2,594 px, sample 740 px over all 49), and no figure in `check.json` but
|
|
264
|
+
* `pack_mode` moves. Under `--page-edges pot` both shapes give the same page
|
|
265
|
+
* size and the same frames. AUTHORING §5, *Pack shape*, holds the tables.
|
|
266
|
+
* `--pack-shape rect` writes the page and atlas rigc 2.0.3 wrote for 0.7.0
|
|
267
|
+
* (measured byte for byte on both examples; selftest `CK53` holds rect to
|
|
268
|
+
* rigc's own rect page).
|
|
269
|
+
*/
|
|
270
|
+
export const PACK_SHAPES = ['rect', 'polygon'] as const;
|
|
271
|
+
export type PackShape = (typeof PACK_SHAPES)[number];
|
|
272
|
+
export const DEFAULT_PACK_SHAPE: PackShape = 'polygon';
|
|
273
|
+
|
|
274
|
+
/** How a packed build packs: its `--page-edges` and its `--pack-shape`, each handed to rigc verbatim. */
|
|
275
|
+
export interface PackMode {
|
|
276
|
+
pageEdges: PageEdges;
|
|
277
|
+
packShape: PackShape;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Both defaults, {@link DEFAULT_PAGE_EDGES} and {@link DEFAULT_PACK_SHAPE}. */
|
|
281
|
+
export const DEFAULT_PACK_MODE: PackMode = { pageEdges: DEFAULT_PAGE_EDGES, packShape: DEFAULT_PACK_SHAPE };
|
|
282
|
+
|
|
283
|
+
/** The packed build's rigc arguments after `--out <dir>`, as every stage that packs passes them. */
|
|
284
|
+
export function packedBuildArgs(mode: PackMode): string[] {
|
|
285
|
+
return ['--profile', 'spine-html', '--pack', '--page-edges', mode.pageEdges, '--pack-shape', mode.packShape];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The packed build as a label: the rigc command line that was run, after `rigc`. */
|
|
289
|
+
export function packedBuildLabel(mode: PackMode): string {
|
|
290
|
+
return `build ${packedBuildArgs(mode).join(' ')}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// the rigc process, injected
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
export interface RigcCall {
|
|
298
|
+
/** Exit status; a process that did not start is reported by the runner as a non-zero status with the reason in `out`. */
|
|
299
|
+
status: number;
|
|
300
|
+
/** stdout then stderr. */
|
|
301
|
+
out: string;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export type RigcRunner = (args: readonly string[]) => RigcCall;
|
|
305
|
+
|
|
306
|
+
/** A line in which a rigc run that stopped says why: Bun's own `error: …`, rigc's `rigc <command>: …` refusals, a `usage: …` line. */
|
|
307
|
+
const CAUSE = /^(error: |rigc[\w -]*: |usage: )/;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* What a rigc run that exited non-zero said about why, for a refusal whose own
|
|
311
|
+
* filter (FAIL lines, the assertion summary) matched nothing: every line that
|
|
312
|
+
* names a cause, or, when none does, the last three non-empty lines. Measured
|
|
313
|
+
* against rig-c 2.0.3: its `cli.ts` run by path without
|
|
314
|
+
* `@esotericsoftware/spine-core` beside it dies at import with one
|
|
315
|
+
* `error: Cannot find module '@esotericsoftware/spine-core' from …` line, and its
|
|
316
|
+
* core entry refuses `validate` with one `rigc validate: …` line, neither of
|
|
317
|
+
* which a gate filter reads. The last lines and not the first are the fallback
|
|
318
|
+
* because `rigc build` prints its header and inputs before it can stop. Empty
|
|
319
|
+
* only when the run printed nothing at all.
|
|
320
|
+
*/
|
|
321
|
+
export function causeLines(out: string): string[] {
|
|
322
|
+
const lines = out.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
323
|
+
const named = lines.filter((l) => CAUSE.test(l));
|
|
324
|
+
return named.length > 0 ? named : lines.slice(-3);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The two entries rig-c's launcher (`bin/rigc.cjs`, rig-c 2.0.0 and later) runs. */
|
|
328
|
+
export const RIGC_ENTRIES = ['cli.ts', 'cli_core.ts'] as const;
|
|
329
|
+
export type RigcEntry = (typeof RIGC_ENTRIES)[number];
|
|
330
|
+
|
|
331
|
+
/** The first rig-c whose `--version` names the entry that ran (its launcher's stderr line; rig-c 2.0.0's changelog, issue #1061 there). */
|
|
332
|
+
export const RIGC_ENTRY_VERSION = '2.0.0';
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Which rigc entry gated the build, as `check.json` records it: `cli.ts`, the
|
|
336
|
+
* spine-core round trip, with the runtime's version; `cli_core.ts`, rigc's own
|
|
337
|
+
* validator over its model document, with no runtime (`spine_core` null).
|
|
338
|
+
*/
|
|
339
|
+
export interface RigcEntryRecord {
|
|
340
|
+
entry: RigcEntry;
|
|
341
|
+
spine_core: string | null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The launcher's two `entry:` lines, whole, as rig-c 2.0.3's `bin/rigc.cjs` writes them. */
|
|
345
|
+
const ENTRY_FULL = /^entry: cli\.ts — @esotericsoftware\/spine-core (\S+) present$/;
|
|
346
|
+
const ENTRY_CORE = /^entry: cli_core\.ts — @esotericsoftware\/spine-core absent — /;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The entry a `rigc --version` run names. A run with no `entry:` line is a
|
|
350
|
+
* rigc older than {@link RIGC_ENTRY_VERSION}, or not rigc's launcher, and is
|
|
351
|
+
* refused `CHECK_RIGC_ENTRY`; an `entry:` line in neither form above is refused
|
|
352
|
+
* `CHECK_RIGC_ENTRY_READS`, naming the line — a third entry is not read as
|
|
353
|
+
* either of the two.
|
|
354
|
+
*/
|
|
355
|
+
export function readRigcEntry(versionOut: string): RigcEntryRecord {
|
|
356
|
+
const lines = versionOut.split('\n').map((l) => l.trim());
|
|
357
|
+
const line = lines.find((l) => l.startsWith('entry:'));
|
|
358
|
+
if (line === undefined) {
|
|
359
|
+
refuseIfAny([
|
|
360
|
+
{
|
|
361
|
+
code: 'CHECK_RIGC_ENTRY',
|
|
362
|
+
object: '`rigc --version`',
|
|
363
|
+
detail: `printed ${JSON.stringify(versionOut.trim())} and no \`entry:\` line; rig-c ${RIGC_ENTRY_VERSION} or later is required — its launcher names the entry that ran, and check.json records which one gated the build (\`bun install\` puts this package's own rig-c at node_modules/.bin/rigc)`,
|
|
364
|
+
},
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
const at = line as string;
|
|
368
|
+
const full = ENTRY_FULL.exec(at);
|
|
369
|
+
if (full !== null) return { entry: 'cli.ts', spine_core: full[1] };
|
|
370
|
+
if (ENTRY_CORE.test(at)) return { entry: 'cli_core.ts', spine_core: null };
|
|
371
|
+
refuseIfAny([
|
|
372
|
+
{
|
|
373
|
+
code: 'CHECK_RIGC_ENTRY_READS',
|
|
374
|
+
object: `rigc's entry line ${JSON.stringify(at)}`,
|
|
375
|
+
detail: 'reads as neither "entry: cli.ts — @esotericsoftware/spine-core <version> present" nor "entry: cli_core.ts — @esotericsoftware/spine-core absent — …"; one of the two is required',
|
|
376
|
+
},
|
|
377
|
+
]);
|
|
378
|
+
throw new Error('unreachable');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Where the `rigc` binary is: `node_modules/.bin/rigc` in `from` or any
|
|
383
|
+
* directory above it (a clone has it beside the package; an install has it in
|
|
384
|
+
* the consumer's `node_modules/.bin`), then every directory on `PATH`. A miss
|
|
385
|
+
* is refused naming every place looked.
|
|
386
|
+
*/
|
|
387
|
+
export function findRigc(from: string, pathVar: string): string {
|
|
388
|
+
const looked: string[] = [];
|
|
389
|
+
let dir = resolve(from);
|
|
390
|
+
for (;;) {
|
|
391
|
+
const bin = join(dir, 'node_modules', '.bin', 'rigc');
|
|
392
|
+
looked.push(bin);
|
|
393
|
+
if (existsSync(bin)) return bin;
|
|
394
|
+
const up = dirname(dir);
|
|
395
|
+
if (up === dir) break;
|
|
396
|
+
dir = up;
|
|
397
|
+
}
|
|
398
|
+
for (const p of pathVar.split(delimiter).filter((s) => s !== '')) {
|
|
399
|
+
const bin = join(p, 'rigc');
|
|
400
|
+
if (existsSync(bin)) return bin;
|
|
401
|
+
}
|
|
402
|
+
refuseIfAny([
|
|
403
|
+
{
|
|
404
|
+
code: 'CHECK_RIGC_PRESENT',
|
|
405
|
+
object: 'the rig-c CLI `rigc`',
|
|
406
|
+
detail: `found at none of ${looked.length} node_modules/.bin/rigc path(s) from ${resolve(from)} up (nearest ${looked[0]}) nor on PATH; rig-c is this package's dependency — \`bun install\` puts it at node_modules/.bin/rigc`,
|
|
407
|
+
},
|
|
408
|
+
]);
|
|
409
|
+
return '';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// inputs
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
export interface StageBox {
|
|
417
|
+
x: number;
|
|
418
|
+
y: number;
|
|
419
|
+
width: number;
|
|
420
|
+
height: number;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** What `--parts` is, said once for the refusals and the help. */
|
|
424
|
+
export const PARTS_HOME_SENTENCE = '--parts names the directory holding parts.json and parts/, not parts/ itself (after build, that is build\'s --out, whose rig is <out>/rig); without --parts it is --rig';
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* What `check` does without `--parts` when the rig directory holds no
|
|
428
|
+
* `parts.json` (issue #77), said once for the help and the SKIP lines.
|
|
429
|
+
*/
|
|
430
|
+
export const NO_PARTS_SENTENCE = 'without --parts, a rig directory holding no parts.json is measured without one: the seam and every line that reads parts.json say SKIP, by name — unless the directory above --rig holds parts.json (build\'s layout), which is refused naming it';
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* What the painting `--source` names is compared with (issue #77), for the
|
|
434
|
+
* refusals and the help: assemble's own resample of it onto the rig canvas.
|
|
435
|
+
*/
|
|
436
|
+
export const SOURCE_SENTENCE = '--source names the painting the rig was made from: the stage size itself, or any size assemble takes to the stage (width and height each the painting\'s times one rig_scale, truncated — parts.json\'s scale_rig_per_source when parts.json is read), resampled onto the stage as assemble resamples it (lanczos3, alpha dropped)';
|
|
437
|
+
|
|
438
|
+
/** The painting `--source` names, read and checked against the stage before anything is built. */
|
|
439
|
+
export interface SourceInput {
|
|
440
|
+
path: string;
|
|
441
|
+
painting: Raster;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export interface CheckInputs {
|
|
445
|
+
rigDir: string;
|
|
446
|
+
rigPath: string;
|
|
447
|
+
motionPath: string;
|
|
448
|
+
partsPath: string;
|
|
449
|
+
partsDir: string;
|
|
450
|
+
rig: Record<string, unknown>;
|
|
451
|
+
motion: Record<string, unknown>;
|
|
452
|
+
/** Null when the rig directory holds no `parts.json` and no `--parts` was named (issue #77); {@link noParts} says why. */
|
|
453
|
+
parts: PartsFile | null;
|
|
454
|
+
/** Why `parts` is null, as the SKIP lines say it; null when `parts.json` was read. */
|
|
455
|
+
noParts: string | null;
|
|
456
|
+
stage: StageBox;
|
|
457
|
+
rootBone: string;
|
|
458
|
+
/** Null when `motion.json` declares no `idle` animation (issue #77); {@link noIdle} says why. */
|
|
459
|
+
idleDuration: number | null;
|
|
460
|
+
/** Why `idleDuration` is null, as the SKIP lines say it; null when there is an idle. */
|
|
461
|
+
noIdle: string | null;
|
|
462
|
+
/** The painting `--source` named, or null without `--source`. */
|
|
463
|
+
source: SourceInput | null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** The inputs every line that reads both the parts and the idle takes: both present. */
|
|
467
|
+
export type FullInputs = CheckInputs & { parts: PartsFile; idleDuration: number };
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Whether a painting `w`x`h` can be the one assemble made a `W`x`H` rig canvas
|
|
471
|
+
* from: assemble's `checkGeometry` makes the canvas `trunc(w * S)` x
|
|
472
|
+
* `trunc(h * S)` for `rig_scale` S, so with S known (parts.json's
|
|
473
|
+
* `scale_rig_per_source`) both must come out; with S unknown some S must
|
|
474
|
+
* satisfy both, which is `W h < (H + 1) w` and `H w < (W + 1) h` in integers.
|
|
475
|
+
* The same size is S = 1. Null when it can; otherwise the refusal's detail.
|
|
476
|
+
*/
|
|
477
|
+
export function sourceSizeProblem(w: number, h: number, stage: StageBox, scale: number | null): string | null {
|
|
478
|
+
const { width: W, height: H } = stage;
|
|
479
|
+
const whole = (v: number): boolean => Number.isInteger(v) && v > 0;
|
|
480
|
+
if (!whole(W) || !whole(H)) return `the stage (rig.json's "skeleton") is ${W}x${H}; whole positive pixel counts are required to compare it with a painting pixel for pixel`;
|
|
481
|
+
if (scale !== null) {
|
|
482
|
+
if (Math.trunc(w * scale) === W && Math.trunc(h * scale) === H) return null;
|
|
483
|
+
return `is ${w}x${h}, which parts.json's scale_rig_per_source ${scale} takes to ${Math.trunc(w * scale)}x${Math.trunc(h * scale)}; the stage is ${W}x${H}, so the painting assemble was given is required`;
|
|
484
|
+
}
|
|
485
|
+
if (W * h < (H + 1) * w && H * w < (W + 1) * h) return null;
|
|
486
|
+
return `is ${w}x${h}; no single rig_scale takes it to the ${W}x${H} stage (assemble truncates width and height times the same scale), so the painting is not this rig's — ${SOURCE_SENTENCE}`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
490
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function readJson(path: string, problems: Problem[]): Record<string, unknown> | null {
|
|
494
|
+
if (!existsSync(path)) {
|
|
495
|
+
problems.push({ code: 'CHECK_INPUT_PRESENT', object: path, detail: 'no such file; the rig directory holds rig.json, motion.json, parts.json and parts/' });
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const v: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
500
|
+
if (isRecord(v)) return v;
|
|
501
|
+
problems.push({ code: 'CHECK_INPUT_IS_JSON', object: path, detail: `holds ${JSON.stringify(v)?.slice(0, 40)}; an object is required` });
|
|
502
|
+
} catch (err) {
|
|
503
|
+
problems.push({ code: 'CHECK_INPUT_IS_JSON', object: path, detail: `does not parse as JSON: ${(err as Error).message}` });
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Read and cross-check the rig directory. Every problem is collected before
|
|
510
|
+
* one refusal. `partsHome` is the directory holding `parts.json` and `parts/`
|
|
511
|
+
* when they do not sit beside `rig.json` — `build` keeps the parts at the top
|
|
512
|
+
* of its output and the rig under `rig/`; absent, it is the rig directory, and
|
|
513
|
+
* there (issue #77) a `parts.json` may be missing: the rig is then measured
|
|
514
|
+
* without parts ({@link NO_PARTS_SENTENCE}). A named `partsHome` with no
|
|
515
|
+
* `parts.json` is refused, as is a missing one whose directory's parent holds
|
|
516
|
+
* `parts.json` — build's layout, where the miss is `--parts` left off. An
|
|
517
|
+
* `idle` may be missing too; an `idle` that is there must have a positive
|
|
518
|
+
* duration. `sourcePath` is `--source`, read and held to the stage here, so a
|
|
519
|
+
* painting that is not this rig's is refused before anything is built.
|
|
520
|
+
*/
|
|
521
|
+
export function readCheckInputs(rigDir: string, partsHome?: string, sourcePath?: string): CheckInputs {
|
|
522
|
+
const dir = resolve(rigDir);
|
|
523
|
+
const named = partsHome !== undefined;
|
|
524
|
+
const home = resolve(partsHome ?? rigDir);
|
|
525
|
+
const problems: Problem[] = [];
|
|
526
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
527
|
+
refuseIfAny([{ code: 'CHECK_INPUT_PRESENT', object: dir, detail: 'is not a directory; --rig names the directory holding rig.json and motion.json (and parts.json and parts/, or --parts names where they are)' }]);
|
|
528
|
+
}
|
|
529
|
+
const rigPath = join(dir, 'rig.json');
|
|
530
|
+
const motionPath = join(dir, 'motion.json');
|
|
531
|
+
const partsPath = join(home, 'parts.json');
|
|
532
|
+
const partsDir = join(home, 'parts');
|
|
533
|
+
if (home !== dir && (!existsSync(home) || !statSync(home).isDirectory())) {
|
|
534
|
+
refuseIfAny([{ code: 'CHECK_INPUT_PRESENT', object: home, detail: `is not a directory; ${PARTS_HOME_SENTENCE}` }]);
|
|
535
|
+
}
|
|
536
|
+
const rig = readJson(rigPath, problems);
|
|
537
|
+
const motion = readJson(motionPath, problems);
|
|
538
|
+
let parts: PartsFile | null = null;
|
|
539
|
+
let noParts: string | null = null;
|
|
540
|
+
if (!existsSync(partsPath)) {
|
|
541
|
+
// The usual miss is passing parts/ itself, or leaving --parts off after build; the parent is then named.
|
|
542
|
+
const parent = dirname(home);
|
|
543
|
+
const parentHolds = existsSync(join(parent, 'parts.json'));
|
|
544
|
+
if (named || parentHolds) {
|
|
545
|
+
const hint = parentHolds ? ` — ${parent} holds parts.json, so --parts ${parent} is the directory meant` : '';
|
|
546
|
+
problems.push({
|
|
547
|
+
code: 'CHECK_INPUT_PRESENT',
|
|
548
|
+
object: partsPath,
|
|
549
|
+
detail: `no such file; ${PARTS_HOME_SENTENCE} (the seam check composites the parts parts.json lists)${hint}`,
|
|
550
|
+
});
|
|
551
|
+
} else noParts = `no parts.json: ${partsPath} does not exist and no --parts was named, so there are no parts to composite and no See-through tags to choose regions by`;
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
try {
|
|
555
|
+
parts = readParts(partsPath);
|
|
556
|
+
} catch (err) {
|
|
557
|
+
if (err instanceof PartsError) problems.push(...err.problems);
|
|
558
|
+
else throw err;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (parts !== null) {
|
|
562
|
+
for (const p of parts.parts) {
|
|
563
|
+
const png = join(partsDir, `${p.name}.png`);
|
|
564
|
+
if (!existsSync(png)) {
|
|
565
|
+
problems.push({ code: 'CHECK_PART_PNG_PRESENT', object: `part "${p.name}"`, detail: `${png} does not exist; parts.json lists it` });
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const im = readPng(png);
|
|
569
|
+
if (im.width !== p.w || im.height !== p.h) {
|
|
570
|
+
problems.push({ code: 'CHECK_PART_PNG_MATCHES_BOX', object: `part "${p.name}"`, detail: `${png} is ${im.width}x${im.height}; parts.json's box is ${p.w}x${p.h}` });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
let stage: StageBox | null = null;
|
|
575
|
+
let rootBone: string | null = null;
|
|
576
|
+
if (rig !== null) {
|
|
577
|
+
const s = rig.skeleton;
|
|
578
|
+
const nums = isRecord(s) && ['x', 'y', 'width', 'height'].every((k) => typeof s[k] === 'number' && Number.isFinite(s[k]));
|
|
579
|
+
if (!nums) {
|
|
580
|
+
problems.push({ code: 'CHECK_RIG_STAGE_PRESENT', object: `${rigPath} field "skeleton"`, detail: `is ${JSON.stringify(s)}; the stage box { x, y, width, height } is required — it is where parts.json's crop pixels sit in Spine's world` });
|
|
581
|
+
} else {
|
|
582
|
+
stage = { x: s.x as number, y: s.y as number, width: s.width as number, height: s.height as number };
|
|
583
|
+
if (parts !== null && (stage.width !== parts.rig_size[0] || stage.height !== parts.rig_size[1])) {
|
|
584
|
+
problems.push({
|
|
585
|
+
code: 'CHECK_RIG_STAGE_IS_THE_CANVAS',
|
|
586
|
+
object: `${rigPath} field "skeleton"`,
|
|
587
|
+
detail: `is ${stage.width}x${stage.height}; parts.json's rig_size is ${parts.rig_size[0]}x${parts.rig_size[1]}, and the seam compares the two pixel for pixel`,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const slots = new Set((Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => s.name));
|
|
592
|
+
if (parts !== null) {
|
|
593
|
+
for (const p of parts.parts) {
|
|
594
|
+
if (!slots.has(p.name)) {
|
|
595
|
+
problems.push({
|
|
596
|
+
code: 'CHECK_PART_SLOT_PRESENT',
|
|
597
|
+
object: `part "${p.name}"`,
|
|
598
|
+
detail: `${rigPath} has no slot named "${p.name}"; the rig stage draws every part by the slot of its own name, and the judgement lines render a part alone by that slot`,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const bones = rig.bones;
|
|
604
|
+
const first: unknown = Array.isArray(bones) ? bones[0] : undefined;
|
|
605
|
+
if (isRecord(first) && typeof first.name === 'string') rootBone = first.name;
|
|
606
|
+
else problems.push({ code: 'CHECK_RIG_ROOT_BONE', object: `${rigPath} field "bones"`, detail: 'has no first bone with a name; the setup-pose render keys the root bone' });
|
|
607
|
+
}
|
|
608
|
+
let idleDuration: number | null = null;
|
|
609
|
+
let noIdle: string | null = null;
|
|
610
|
+
if (motion !== null) {
|
|
611
|
+
const anims = motion.animations;
|
|
612
|
+
const idle = isRecord(anims) ? anims.idle : undefined;
|
|
613
|
+
if (idle === undefined) {
|
|
614
|
+
noIdle = `no idle: ${motionPath} field "animations" holds ${isRecord(anims) ? `[${Object.keys(anims).join(', ')}]` : JSON.stringify(anims) ?? 'nothing'} and no "idle", so there is no loop to close and no idle frame to read`;
|
|
615
|
+
} else if (!isRecord(idle)) {
|
|
616
|
+
problems.push({ code: 'CHECK_IDLE_PRESENT', object: `${motionPath} animation "idle"`, detail: `is ${JSON.stringify(idle)}; an animation object is required — the loop is measured on it` });
|
|
617
|
+
} else if (!(typeof idle.duration === 'number' && idle.duration > 0)) {
|
|
618
|
+
problems.push({ code: 'CHECK_IDLE_PRESENT', object: `${motionPath} animation "idle" field "duration"`, detail: `is ${JSON.stringify(idle.duration)}; a positive number of seconds is required` });
|
|
619
|
+
} else idleDuration = idle.duration;
|
|
620
|
+
}
|
|
621
|
+
let source: SourceInput | null = null;
|
|
622
|
+
if (sourcePath !== undefined) {
|
|
623
|
+
const path = resolve(sourcePath);
|
|
624
|
+
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
625
|
+
problems.push({ code: 'CHECK_INPUT_PRESENT', object: path, detail: `no such file; ${SOURCE_SENTENCE}` });
|
|
626
|
+
} else {
|
|
627
|
+
try {
|
|
628
|
+
source = { path, painting: readPng(path) };
|
|
629
|
+
} catch (err) {
|
|
630
|
+
problems.push({ code: 'CHECK_SOURCE_PNG', object: path, detail: `does not read as a PNG: ${(err as Error).message.split('\n')[0]}; ${SOURCE_SENTENCE}` });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (source !== null && stage !== null) {
|
|
634
|
+
const why = sourceSizeProblem(source.painting.width, source.painting.height, stage, parts === null ? null : parts.scale_rig_per_source);
|
|
635
|
+
if (why !== null) problems.push({ code: 'CHECK_SOURCE_SIZE', object: `--source ${path}`, detail: why });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
refuseIfAny(problems);
|
|
639
|
+
return {
|
|
640
|
+
rigDir: dir,
|
|
641
|
+
rigPath,
|
|
642
|
+
motionPath,
|
|
643
|
+
partsPath,
|
|
644
|
+
partsDir,
|
|
645
|
+
rig: rig as Record<string, unknown>,
|
|
646
|
+
motion: motion as Record<string, unknown>,
|
|
647
|
+
parts,
|
|
648
|
+
noParts,
|
|
649
|
+
stage: stage as StageBox,
|
|
650
|
+
rootBone: rootBone as string,
|
|
651
|
+
idleDuration,
|
|
652
|
+
noIdle,
|
|
653
|
+
source,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
// gate lines
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* The `here:` line rig-c's core entry (`cli_core.ts`, 2.0.0 and later)
|
|
663
|
+
* prints after its assertion summary, saying which rules ran and that the
|
|
664
|
+
* spine-core round trip did not; `cli.ts` prints none.
|
|
665
|
+
*/
|
|
666
|
+
const HERE_LINE = /^\.\.\s+here: /;
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* `check_rig.py`'s filter for the build: summary, stats and pack lines, and
|
|
670
|
+
* anything that failed — and, under rigc's core entry, its `here:` line, so the
|
|
671
|
+
* gate file says which validator ran.
|
|
672
|
+
*/
|
|
673
|
+
export function buildGateLines(out: string): string[] {
|
|
674
|
+
return out
|
|
675
|
+
.split('\n')
|
|
676
|
+
.filter((l) => (l.trim().startsWith('..') && (l.includes(' assertions: ') || l.includes('pages=') || l.includes('pack:') || HERE_LINE.test(l.trim()))) || l.includes('FAIL') || l.includes('compile error'));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The rules a rigc run measured — every `PASS` and `FAIL` line's rule name, in
|
|
681
|
+
* the order printed, each once. A SKIP, a PROF line and a summary are not
|
|
682
|
+
* measurements. The selftest's `CH09` compares a build's set with
|
|
683
|
+
* `rigc validate --profile spine`'s on the same build.
|
|
684
|
+
*/
|
|
685
|
+
export function measuredRules(out: string): string[] {
|
|
686
|
+
const seen: string[] = [];
|
|
687
|
+
for (const m of out.matchAll(/^ {2}(?:PASS|FAIL) {2}([A-Z][A-Z0-9_]*)/gm)) if (!seen.includes(m[1])) seen.push(m[1]);
|
|
688
|
+
return seen;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const SUMMARY = /\((\d+) passed, (\d+) failed\)/;
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Green = rigc exited 0, at least one summary line, and every summary line says
|
|
695
|
+
* `0 failed`. Read with a pattern: the reference's substring test `"0 failed"`
|
|
696
|
+
* is also true of `"10 failed"`, and its `all()` over no summary line is true.
|
|
697
|
+
*/
|
|
698
|
+
export function gateGreen(status: number, lines: readonly string[]): boolean {
|
|
699
|
+
const summaries = lines.filter((l) => l.includes(' assertions: '));
|
|
700
|
+
return status === 0 && summaries.length > 0 && summaries.every((l) => SUMMARY.exec(l)?.[2] === '0');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export interface PackLine {
|
|
704
|
+
line: string;
|
|
705
|
+
page: string;
|
|
706
|
+
width: number;
|
|
707
|
+
height: number;
|
|
708
|
+
regions: number;
|
|
709
|
+
coveredPct: number;
|
|
710
|
+
padding: number;
|
|
711
|
+
/** `free` when the line carries `, page edges free` after the padding, `pot` when it does not — rig-c prints the clause exactly when it packed under `--page-edges free` (its `src/cli/shared.ts`, the pack line). */
|
|
712
|
+
pageEdges: PageEdges;
|
|
713
|
+
/**
|
|
714
|
+
* The line's last clause, `, shape rect` or `, shape polygon`: rig-c
|
|
715
|
+
* 2.1 names the `--pack-shape` it packed under on every pack line, its
|
|
716
|
+
* default included. A line with no shape clause is the 1.5.1–2.0.3 form,
|
|
717
|
+
* from a rigc that had no other shape, and reads as `rect`.
|
|
718
|
+
*/
|
|
719
|
+
packShape: PackShape;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** rigc's pack line, whole: the line is this and nothing else, or it is refused. */
|
|
723
|
+
const PACK = /^pack: (\S+) (\d+)x(\d+), (\d+) region\(s\), (\d+(?:\.\d+)?)% covered, padding (\d+)(, page edges free)?(?:, shape (rect|polygon))?$/;
|
|
724
|
+
|
|
725
|
+
/** A pack line that names its shape: the rig-c 2.1 form. */
|
|
726
|
+
const PACK_SHAPE_CLAUSE = /, shape (?:rect|polygon)$/;
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Every pack line among the gate lines — a line that reads `pack:` once its
|
|
730
|
+
* `..` gutter is taken off. Two forms are read, each by name: rig-c
|
|
731
|
+
* 2.1's, which ends `, shape rect` or `, shape polygon`, and the 1.5.1–2.0.3
|
|
732
|
+
* form, which has no shape clause and reads as `rect`; in both,
|
|
733
|
+
* `, page edges free` sits between the padding and the shape or is absent. Any
|
|
734
|
+
* other line (an unknown shape, an unknown suffix, a missing field, the clauses
|
|
735
|
+
* out of order) is refused, `CHECK_PACK_LINE_READS`, naming the line and the
|
|
736
|
+
* form required: a line half-read would lose the field that changed.
|
|
737
|
+
*/
|
|
738
|
+
export function parsePackLines(lines: readonly string[]): PackLine[] {
|
|
739
|
+
const out: PackLine[] = [];
|
|
740
|
+
const problems: Problem[] = [];
|
|
741
|
+
for (const l of lines) {
|
|
742
|
+
const text = l.trim().replace(/^\.\.\s+/, '');
|
|
743
|
+
if (!text.startsWith('pack:')) continue;
|
|
744
|
+
const m = PACK.exec(text);
|
|
745
|
+
if (m === null) {
|
|
746
|
+
problems.push({
|
|
747
|
+
code: 'CHECK_PACK_LINE_READS',
|
|
748
|
+
object: `rigc's pack line ${JSON.stringify(text)}`,
|
|
749
|
+
detail: 'does not read as "pack: <page> <W>x<H>, <N> region(s), <P>% covered, padding <D>", then ", page edges free" or nothing, then ", shape rect", ", shape polygon" or nothing; that form is required',
|
|
750
|
+
});
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
out.push({
|
|
754
|
+
line: text,
|
|
755
|
+
page: m[1],
|
|
756
|
+
width: Number(m[2]),
|
|
757
|
+
height: Number(m[3]),
|
|
758
|
+
regions: Number(m[4]),
|
|
759
|
+
coveredPct: Number(m[5]),
|
|
760
|
+
padding: Number(m[6]),
|
|
761
|
+
pageEdges: m[7] === undefined ? 'pot' : 'free',
|
|
762
|
+
packShape: m[8] === 'polygon' ? 'polygon' : 'rect',
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
refuseIfAny(problems);
|
|
766
|
+
return out;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function powerOfTwo(n: number): boolean {
|
|
770
|
+
return Number.isInteger(n) && n > 0 && (n & (n - 1)) === 0;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Where a pack line disagrees with the pack mode the build was given. The
|
|
775
|
+
* line's own page-edges clause must say the `--page-edges` passed, and a `pot`
|
|
776
|
+
* page must be a power of two on both edges, as rigc's help defines it,
|
|
777
|
+
* `CHECK_PACK_PAGE_EDGES`. By the same rule the line's shape must be the
|
|
778
|
+
* `--pack-shape` passed, `CHECK_PACK_SHAPE` — a line with no shape clause
|
|
779
|
+
* reads as `rect`, so under `polygon` it disagrees. That is the case that
|
|
780
|
+
* matters: rigc 2.0.3 takes `--pack-shape` without a word, packs by
|
|
781
|
+
* rectangles and prints the form with no shape clause (measured), so a rigc
|
|
782
|
+
* older than 2.1.0 first on `PATH` is refused here rather than shipping a
|
|
783
|
+
* rect page as a polygon one. One problem per disagreement; empty is
|
|
784
|
+
* agreement.
|
|
785
|
+
*/
|
|
786
|
+
export function packEdgeProblems(pack: readonly PackLine[], mode: PackMode): Problem[] {
|
|
787
|
+
const edges = mode.pageEdges;
|
|
788
|
+
const problems: Problem[] = [];
|
|
789
|
+
for (const p of pack) {
|
|
790
|
+
const object = `packed page ${p.page}`;
|
|
791
|
+
if (p.pageEdges !== edges) {
|
|
792
|
+
problems.push({
|
|
793
|
+
code: 'CHECK_PACK_PAGE_EDGES',
|
|
794
|
+
object,
|
|
795
|
+
detail: `rigc's pack line says page edges ${p.pageEdges} (${p.pageEdges === 'free' ? 'it carries ", page edges free"' : 'no ", page edges free" clause'}); the build was run with --page-edges ${edges}, so page edges ${edges} is required`,
|
|
796
|
+
});
|
|
797
|
+
} else if (edges === 'pot' && !(powerOfTwo(p.width) && powerOfTwo(p.height))) {
|
|
798
|
+
problems.push({ code: 'CHECK_PACK_PAGE_EDGES', object, detail: `is ${p.width}x${p.height}; the build was run with --page-edges pot, so a power of two on both edges is required` });
|
|
799
|
+
}
|
|
800
|
+
if (p.packShape !== mode.packShape) {
|
|
801
|
+
problems.push({
|
|
802
|
+
code: 'CHECK_PACK_SHAPE',
|
|
803
|
+
object,
|
|
804
|
+
detail: `rigc's pack line says shape ${p.packShape} (${PACK_SHAPE_CLAUSE.test(p.line) ? `it ends ", shape ${p.packShape}"` : 'no ", shape" clause: the 1.5.1-2.0.3 form, read as rect'}); the build was run with --pack-shape ${mode.packShape}, so shape ${mode.packShape} is required`,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return problems;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Share of a page's pixels with alpha above 0 — the measure issue #2's spineboy figure is in. rigc's "covered" is region rectangles, not pixels. */
|
|
812
|
+
export function opaqueShare(r: Raster): number {
|
|
813
|
+
let n = 0;
|
|
814
|
+
for (let i = 3; i < r.data.length; i += 4) if (r.data[i] > 0) n++;
|
|
815
|
+
return n / (r.width * r.height);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ---------------------------------------------------------------------------
|
|
819
|
+
// rigc's frames
|
|
820
|
+
// ---------------------------------------------------------------------------
|
|
821
|
+
|
|
822
|
+
export interface Viewport {
|
|
823
|
+
x: number;
|
|
824
|
+
y: number;
|
|
825
|
+
width: number;
|
|
826
|
+
height: number;
|
|
827
|
+
scale: number;
|
|
828
|
+
pixelWidth: number;
|
|
829
|
+
pixelHeight: number;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export interface FrameSet {
|
|
833
|
+
/** The directory holding the frames. */
|
|
834
|
+
dir: string;
|
|
835
|
+
animation: string | null;
|
|
836
|
+
fps: number;
|
|
837
|
+
sampled: number;
|
|
838
|
+
written: number;
|
|
839
|
+
stride: number;
|
|
840
|
+
/** The last sampled frame's time, as rigc records it. */
|
|
841
|
+
duration: number;
|
|
842
|
+
background: [number, number, number, number];
|
|
843
|
+
viewport: Viewport;
|
|
844
|
+
/** `f<index>.png`, in index order. */
|
|
845
|
+
frames: Array<{ name: string; index: number; image: Raster }>;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function sidecarProblem(object: string, detail: string): never {
|
|
849
|
+
refuseIfAny([{ code: 'FRAMES_SIDECAR', object, detail }]);
|
|
850
|
+
throw new Error('unreachable');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* A frame set as `rigc render` wrote it: either the `--out` directory
|
|
855
|
+
* (`frames.json` plus one set directory) or a set directory whose parent holds
|
|
856
|
+
* `frames.json`. Every figure is read off the sidecar; none is assumed.
|
|
857
|
+
*/
|
|
858
|
+
export function readFrameSet(path: string): FrameSet {
|
|
859
|
+
const dir = resolve(path);
|
|
860
|
+
let sidecarPath: string;
|
|
861
|
+
let setName: string | null;
|
|
862
|
+
if (existsSync(join(dir, 'frames.json'))) {
|
|
863
|
+
sidecarPath = join(dir, 'frames.json');
|
|
864
|
+
setName = null;
|
|
865
|
+
} else if (existsSync(join(dirname(dir), 'frames.json'))) {
|
|
866
|
+
sidecarPath = join(dirname(dir), 'frames.json');
|
|
867
|
+
setName = basename(dir);
|
|
868
|
+
} else {
|
|
869
|
+
return sidecarProblem(dir, `neither ${join(dir, 'frames.json')} nor ${join(dirname(dir), 'frames.json')} exists; the fps and the frame count are read from the frames.json \`rigc render\` writes`);
|
|
870
|
+
}
|
|
871
|
+
let raw: unknown;
|
|
872
|
+
try {
|
|
873
|
+
raw = JSON.parse(readFileSync(sidecarPath, 'utf8'));
|
|
874
|
+
} catch (err) {
|
|
875
|
+
return sidecarProblem(sidecarPath, `does not parse as JSON: ${(err as Error).message}`);
|
|
876
|
+
}
|
|
877
|
+
if (!isRecord(raw) || !Array.isArray(raw.sets) || !isRecord(raw.viewport) || !Array.isArray(raw.background)) {
|
|
878
|
+
return sidecarProblem(sidecarPath, 'lacks "sets", "viewport" or "background"; a rigc-frames/1 sidecar carries all three');
|
|
879
|
+
}
|
|
880
|
+
const sets = raw.sets.filter(isRecord);
|
|
881
|
+
let set: Record<string, unknown> | undefined;
|
|
882
|
+
if (setName === null) {
|
|
883
|
+
if (sets.length !== 1) return sidecarProblem(sidecarPath, `holds ${sets.length} set(s) [${sets.map((s) => String(s.dir)).join(', ')}]; name one set directory when there is not exactly one`);
|
|
884
|
+
set = sets[0];
|
|
885
|
+
} else {
|
|
886
|
+
set = sets.find((s) => s.dir === setName);
|
|
887
|
+
if (set === undefined) return sidecarProblem(sidecarPath, `has no set "${setName}"; it has [${sets.map((s) => String(s.dir)).join(', ')}]`);
|
|
888
|
+
}
|
|
889
|
+
const num = (o: Record<string, unknown>, k: string, at: string): number => {
|
|
890
|
+
const v = o[k];
|
|
891
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return sidecarProblem(sidecarPath, `${at} field "${k}" is ${JSON.stringify(v)}; a number is required`);
|
|
892
|
+
return v;
|
|
893
|
+
};
|
|
894
|
+
const vpRaw = raw.viewport;
|
|
895
|
+
const viewport: Viewport = {
|
|
896
|
+
x: num(vpRaw, 'x', 'viewport'),
|
|
897
|
+
y: num(vpRaw, 'y', 'viewport'),
|
|
898
|
+
width: num(vpRaw, 'width', 'viewport'),
|
|
899
|
+
height: num(vpRaw, 'height', 'viewport'),
|
|
900
|
+
scale: num(vpRaw, 'scale', 'viewport'),
|
|
901
|
+
pixelWidth: num(vpRaw, 'pixelWidth', 'viewport'),
|
|
902
|
+
pixelHeight: num(vpRaw, 'pixelHeight', 'viewport'),
|
|
903
|
+
};
|
|
904
|
+
const bg = raw.background;
|
|
905
|
+
if (bg.length !== 4 || !bg.every((v) => Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 255)) {
|
|
906
|
+
return sidecarProblem(sidecarPath, `"background" is ${JSON.stringify(bg)}; four 8-bit channels are required`);
|
|
907
|
+
}
|
|
908
|
+
const setDir = join(dirname(sidecarPath), String(set.dir));
|
|
909
|
+
if (!existsSync(setDir)) return sidecarProblem(setDir, 'does not exist; frames.json names it');
|
|
910
|
+
const frames = readdirSync(setDir)
|
|
911
|
+
.map((name) => ({ name, m: /^f(\d+)\.png$/.exec(name) }))
|
|
912
|
+
.filter((e) => e.m !== null)
|
|
913
|
+
.map((e) => ({ name: e.name, index: Number((e.m as RegExpExecArray)[1]) }))
|
|
914
|
+
.sort((a, b) => a.index - b.index)
|
|
915
|
+
.map((e) => ({ ...e, image: readPng(join(setDir, e.name)) }));
|
|
916
|
+
const written = num(set, 'written', `set "${String(set.dir)}"`);
|
|
917
|
+
if (frames.length !== written) return sidecarProblem(setDir, `holds ${frames.length} f*.png frame(s); frames.json says ${written} were written`);
|
|
918
|
+
return {
|
|
919
|
+
dir: setDir,
|
|
920
|
+
animation: typeof set.animation === 'string' ? set.animation : null,
|
|
921
|
+
fps: num(set, 'fps', `set "${String(set.dir)}"`),
|
|
922
|
+
sampled: num(set, 'sampled', `set "${String(set.dir)}"`),
|
|
923
|
+
written,
|
|
924
|
+
stride: num(set, 'stride', `set "${String(set.dir)}"`),
|
|
925
|
+
duration: num(set, 'duration', `set "${String(set.dir)}"`),
|
|
926
|
+
background: bg as [number, number, number, number],
|
|
927
|
+
viewport,
|
|
928
|
+
frames,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ---------------------------------------------------------------------------
|
|
933
|
+
// the keyed loop: physics left out (issue #183)
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
935
|
+
|
|
936
|
+
/** A physics constraint the loop's frames leave out, as the `loop:` physics line names it. */
|
|
937
|
+
export interface LoopPhysics {
|
|
938
|
+
name: string;
|
|
939
|
+
bone: string;
|
|
940
|
+
/** Where it was declared: rig.json's `constraints`, or motion.json's `physics` table. */
|
|
941
|
+
in: 'rig.json' | 'motion.json';
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* The rig spec and motion spec with every physics constraint left out, and
|
|
946
|
+
* the constraints it left out; null when there is none, so a rig without one
|
|
947
|
+
* is measured from the very build and render it always was.
|
|
948
|
+
*
|
|
949
|
+
* rig-c's render resets physics at frame 0 and steps it by 1/fps (its
|
|
950
|
+
* `spinePoser`), so a constraint still moving at the idle's duration makes
|
|
951
|
+
* the last frame differ from the first by what the simulation is doing, not by
|
|
952
|
+
* what the keys say. `CHECK_LOOP_CLOSES` measures the keys: a rig-c
|
|
953
|
+
* `constraints` entry of `type` `"physics"` and every entry of motion.json's
|
|
954
|
+
* `physics` table are dropped, with what names them only to activate or key
|
|
955
|
+
* them — a skin's `physics` list, and an animation track whose target is a
|
|
956
|
+
* `physics` constraint. Nothing else is touched, and nothing here judges the
|
|
957
|
+
* constraints: the line that names them reports.
|
|
958
|
+
*/
|
|
959
|
+
export function withoutPhysics(rig: Record<string, unknown>, motion: Record<string, unknown>): { rig: Record<string, unknown>; motion: Record<string, unknown>; physics: LoopPhysics[] } | null {
|
|
960
|
+
const physics: LoopPhysics[] = [];
|
|
961
|
+
const constraints = Array.isArray(rig.constraints) ? rig.constraints : null;
|
|
962
|
+
if (constraints !== null) {
|
|
963
|
+
for (const c of constraints) if (isRecord(c) && c.type === 'physics') physics.push({ name: String(c.name), bone: String(c.bone), in: 'rig.json' });
|
|
964
|
+
}
|
|
965
|
+
const table = isRecord(motion.physics) ? motion.physics : null;
|
|
966
|
+
if (table !== null) for (const [name, e] of Object.entries(table)) physics.push({ name, bone: isRecord(e) ? String(e.bone) : '', in: 'motion.json' });
|
|
967
|
+
if (physics.length === 0) return null;
|
|
968
|
+
const r: Record<string, unknown> = { ...rig };
|
|
969
|
+
if (constraints !== null) {
|
|
970
|
+
const kept = constraints.filter((c) => !(isRecord(c) && c.type === 'physics'));
|
|
971
|
+
if (kept.length > 0) r.constraints = kept;
|
|
972
|
+
else delete r.constraints;
|
|
973
|
+
}
|
|
974
|
+
if (isRecord(rig.skins)) {
|
|
975
|
+
r.skins = Object.fromEntries(Object.entries(rig.skins).map(([skin, entry]) => {
|
|
976
|
+
if (!isRecord(entry) || !('physics' in entry)) return [skin, entry];
|
|
977
|
+
return [skin, Object.fromEntries(Object.entries(entry).filter(([k]) => k !== 'physics'))];
|
|
978
|
+
}));
|
|
979
|
+
}
|
|
980
|
+
const m: Record<string, unknown> = { ...motion };
|
|
981
|
+
delete m.physics;
|
|
982
|
+
if (isRecord(motion.animations)) {
|
|
983
|
+
m.animations = Object.fromEntries(Object.entries(motion.animations).map(([name, a]) => {
|
|
984
|
+
if (!isRecord(a) || !Array.isArray(a.tracks)) return [name, a];
|
|
985
|
+
return [name, { ...a, tracks: a.tracks.filter((t) => !(isRecord(t) && 'physics' in t)) }];
|
|
986
|
+
}));
|
|
987
|
+
}
|
|
988
|
+
return { rig: r, motion: m, physics };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** The one line naming what {@link withoutPhysics} left out of the loop's frames: reported, not judged. */
|
|
992
|
+
export function loopPhysicsLine(physics: readonly LoopPhysics[]): string {
|
|
993
|
+
return `loop physics: ${physics.length} physics constraint(s) left out of the loop's frames, reported, not judged — ${physics.map((p) => `"${p.name}" on bone "${p.bone}" (${p.in})`).join(', ')}`;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// ---------------------------------------------------------------------------
|
|
997
|
+
// pixel measurements
|
|
998
|
+
// ---------------------------------------------------------------------------
|
|
999
|
+
|
|
1000
|
+
/** Largest per-channel |a - b| over RGB (alpha dropped, as PIL's `convert("RGB")` does), and the first pixel where it is. */
|
|
1001
|
+
export function maxRgbDiff(a: Raster, b: Raster): { max: number; x: number; y: number } {
|
|
1002
|
+
let max = 0;
|
|
1003
|
+
let at = 0;
|
|
1004
|
+
for (let p = 0; p < a.width * a.height; p++) {
|
|
1005
|
+
for (let c = 0; c < 3; c++) {
|
|
1006
|
+
const d = Math.abs(a.data[p * 4 + c] - b.data[p * 4 + c]);
|
|
1007
|
+
if (d > max) {
|
|
1008
|
+
max = d;
|
|
1009
|
+
at = p;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return { max, x: at % a.width, y: Math.floor(at / a.width) };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* The motion heat map: frame 0 as grey, blended 0.6 toward a red channel
|
|
1018
|
+
* holding each pixel's largest per-channel change from frame 0 across all
|
|
1019
|
+
* frames. The grey is PIL's `convert("L")` (`(19595 R + 38470 G + 7471 B +
|
|
1020
|
+
* 0x8000) >> 16`) and the blend is PIL's `Image.blend` (`in1 + alpha * (in2 -
|
|
1021
|
+
* in1)` with alpha as float32, fused, truncated) — the reference's two calls,
|
|
1022
|
+
* measured byte-exact against the reference's own heat maps (ORACLE_check.md).
|
|
1023
|
+
*/
|
|
1024
|
+
export function motionHeat(frames: readonly Raster[]): Raster {
|
|
1025
|
+
const f0 = frames[0];
|
|
1026
|
+
const n = f0.width * f0.height;
|
|
1027
|
+
const d = heatField(frames);
|
|
1028
|
+
const alpha = Math.fround(0.6);
|
|
1029
|
+
// One rounding, to float32, of the exact `in1 + alpha * (in2 - in1)`: the
|
|
1030
|
+
// build of Pillow 12.2.0 measured here contracts the expression to a fused
|
|
1031
|
+
// multiply-add. Rounding the product first (two roundings) disagreed with it
|
|
1032
|
+
// on 33 of 256 values of in1 at in2 = 0; this form agrees on all 65,536
|
|
1033
|
+
// (in1, in2) pairs. The product and sum are exact in float64, so one fround
|
|
1034
|
+
// IS the fused result.
|
|
1035
|
+
const blend = (a: number, b: number): number => Math.trunc(Math.fround(a + alpha * (b - a)));
|
|
1036
|
+
const out = newRaster(f0.width, f0.height);
|
|
1037
|
+
for (let p = 0; p < n; p++) {
|
|
1038
|
+
const l = (f0.data[p * 4] * 19595 + f0.data[p * 4 + 1] * 38470 + f0.data[p * 4 + 2] * 7471 + 0x8000) >>> 16;
|
|
1039
|
+
out.data[p * 4] = blend(l, d[p]);
|
|
1040
|
+
out.data[p * 4 + 1] = blend(l, 0);
|
|
1041
|
+
out.data[p * 4 + 2] = blend(l, 0);
|
|
1042
|
+
out.data[p * 4 + 3] = 255;
|
|
1043
|
+
}
|
|
1044
|
+
return out;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
export interface SeamFigures {
|
|
1048
|
+
mean: number;
|
|
1049
|
+
over40: number;
|
|
1050
|
+
over80: number;
|
|
1051
|
+
/** The frame pixels {@link over40} counts, by index into the frame, ascending; each one's max-channel |d| is in {@link countedDiff}. */
|
|
1052
|
+
counted: number[];
|
|
1053
|
+
countedDiff: number[];
|
|
1054
|
+
/** The rig-to-frame map the composite was warped by ({@link rigToFrameMap}): frame = (sx u + tx, sy v + ty), pixel index to pixel index, as float32 values. */
|
|
1055
|
+
map: { sx: number; sy: number; tx: number; ty: number };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* The flat stack of parts over the background, in rig pixels: PIL's
|
|
1060
|
+
* `alpha_composite` of every part at its `x, y`, in parts.json order, over an
|
|
1061
|
+
* opaque canvas of the render's own background colour.
|
|
1062
|
+
*/
|
|
1063
|
+
export function flatComposite(parts: PartsFile, partsDir: string, background: readonly [number, number, number, number]): Raster {
|
|
1064
|
+
const [W, H] = parts.rig_size;
|
|
1065
|
+
let canvas = newRaster(W, H);
|
|
1066
|
+
for (let i = 0; i < W * H; i++) canvas.data.set(background, i * 4);
|
|
1067
|
+
for (const p of parts.parts) canvas = alphaComposite(canvas, readPng(join(partsDir, `${p.name}.png`)), p.x, p.y);
|
|
1068
|
+
return canvas;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* The map from rig (crop) pixel indices to frame pixel indices that the seam
|
|
1073
|
+
* and the source line warp by: frame = (sx u + tx, sy v + ty).
|
|
1074
|
+
*
|
|
1075
|
+
* Crop pixel (u, v) covers world x from stage.x + u to stage.x + u + 1 (y
|
|
1076
|
+
* through `cropToSpineY`, the one door), and world (wx, wy) is the frame's
|
|
1077
|
+
* continuous position ((wx - vp.x) s, (vp.y + vp.height - wy) s). rig-c's
|
|
1078
|
+
* renderer samples at pixel centres (`x + 0.5`), and cv2's `warpAffine` (which
|
|
1079
|
+
* `src/raster/warp.ts` reproduces) maps index to index with no half-pixel
|
|
1080
|
+
* offset, so the centre of crop pixel u, at continuous u + 0.5, lands at frame
|
|
1081
|
+
* index s (u + 0.5) + t - 0.5 = s u + t + 0.5 (s - 1) (issue #209). Measured
|
|
1082
|
+
* through rig-c's own `render`: a one-pixel column and row on a rig rendered
|
|
1083
|
+
* at s = 1.0069, 1.3889, 1.5046 and 2.0023 sit within 0.042 frame px of this
|
|
1084
|
+
* map on both axes and 0.499 px off the map without the 0.5 (s - 1) term at
|
|
1085
|
+
* s = 2.0023 [measured for issue #209, a scratch rig, not tracked]. At s = 1
|
|
1086
|
+
* the term is 0 and the map is the reference's.
|
|
1087
|
+
*
|
|
1088
|
+
* The reference hands cv2 the matrix as float32 (`np.float32([[...]])`), and
|
|
1089
|
+
* that quantisation moves a sample row across a 1/32-pixel step: in float64 one
|
|
1090
|
+
* row of one rig's 1216-row frame (C1 in ORACLE_check.md) sampled differently
|
|
1091
|
+
* and the unrounded mean drifted 3.1e-4. The map is rounded to float32 the same
|
|
1092
|
+
* way. ⚠️ The reference has no centre term, so from issue #209 this map
|
|
1093
|
+
* mirrors the reference — and ORACLE_check.md's figures are matched — at
|
|
1094
|
+
* s = 1 only; above or below it the reference laid the composite 0.5 (s - 1)
|
|
1095
|
+
* frame px off the render on each axis.
|
|
1096
|
+
*
|
|
1097
|
+
* `centres` false drops the term: the reference's map, kept so the `check`
|
|
1098
|
+
* suite can plant it (issue #209). Every caller in `src/` leaves it true.
|
|
1099
|
+
*/
|
|
1100
|
+
export function rigToFrameMap(stage: StageBox, rigH: number, vp: Viewport, centres = true): ScaleTranslate {
|
|
1101
|
+
const s = vp.scale;
|
|
1102
|
+
const f = Math.fround;
|
|
1103
|
+
const c = centres ? 0.5 * (s - 1) : 0;
|
|
1104
|
+
return { sx: f(s), sy: f(s), tx: f((stage.x - vp.x) * s + c), ty: f((vp.y + vp.height - (stage.y + cropToSpineY(0, rigH))) * s + c) };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* {@link rigToFrameMap} turned round, from frame pixel indices to rig pixel
|
|
1109
|
+
* indices: u = (frame - t - 0.5 (s - 1)) / s, rounded to float32 as the
|
|
1110
|
+
* forward map is. The source line warps the frame onto the stage by it.
|
|
1111
|
+
*/
|
|
1112
|
+
export function frameToRigMap(stage: StageBox, rigH: number, vp: Viewport): ScaleTranslate {
|
|
1113
|
+
const s = vp.scale;
|
|
1114
|
+
const f = Math.fround;
|
|
1115
|
+
const c = 0.5 * (s - 1);
|
|
1116
|
+
return { sx: f(1 / s), sy: f(1 / s), tx: f(vp.x - stage.x - c / s), ty: f(stage.y + cropToSpineY(0, rigH) - (vp.y + vp.height) - c / s) };
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* The seam figures: the setup-pose frame vs the flat composite carried onto the
|
|
1121
|
+
* frame's pixel grid.
|
|
1122
|
+
*
|
|
1123
|
+
* The map is {@link rigToFrameMap}: a scale by s and a translate, from rig
|
|
1124
|
+
* pixel indices to frame pixel indices, centre to centre. It is warped with
|
|
1125
|
+
* `src/raster/warp.ts` (cv2's bilinear `warpAffine`, which is also what the
|
|
1126
|
+
* reference's `INTER_AREA` flag gets from cv2 — see that file) in float32.
|
|
1127
|
+
* cv2's `borderValue` is the background; this warp's border is 0, so the
|
|
1128
|
+
* composite is warped as `(pixel - background)` and the background added back,
|
|
1129
|
+
* which is the same linear sum. The per-pixel figure is the largest channel
|
|
1130
|
+
* |d|, as in the reference.
|
|
1131
|
+
*
|
|
1132
|
+
* `map` is for the controls alone (the `check` suite plants the map without
|
|
1133
|
+
* the centre term, issue #209); every caller in `src/` leaves it to the default.
|
|
1134
|
+
*/
|
|
1135
|
+
export function seamFigures(
|
|
1136
|
+
composite: Raster,
|
|
1137
|
+
frame: Raster,
|
|
1138
|
+
stage: StageBox,
|
|
1139
|
+
vp: Viewport,
|
|
1140
|
+
background: readonly [number, number, number, number],
|
|
1141
|
+
map: ScaleTranslate = rigToFrameMap(stage, composite.height, vp),
|
|
1142
|
+
): SeamFigures {
|
|
1143
|
+
const [W, H] = [composite.width, composite.height];
|
|
1144
|
+
const src = newFloatImage(W, H, 3);
|
|
1145
|
+
for (let p = 0; p < W * H; p++) for (let c = 0; c < 3; c++) src.data[p * 3 + c] = composite.data[p * 4 + c] - background[c];
|
|
1146
|
+
const warped = warpAffine(src, map, vp.pixelWidth, vp.pixelHeight, 'bilinear');
|
|
1147
|
+
if (frame.width !== vp.pixelWidth || frame.height !== vp.pixelHeight) {
|
|
1148
|
+
refuseIfAny([{ code: 'CHECK_SEAM_FRAME_SIZE', object: 'the setup-pose frame', detail: `is ${frame.width}x${frame.height}; frames.json's viewport is ${vp.pixelWidth}x${vp.pixelHeight}` }]);
|
|
1149
|
+
}
|
|
1150
|
+
let sum = 0;
|
|
1151
|
+
let over40 = 0;
|
|
1152
|
+
let over80 = 0;
|
|
1153
|
+
const counted: number[] = [];
|
|
1154
|
+
const countedDiff: number[] = [];
|
|
1155
|
+
const n = vp.pixelWidth * vp.pixelHeight;
|
|
1156
|
+
for (let p = 0; p < n; p++) {
|
|
1157
|
+
let m = 0;
|
|
1158
|
+
for (let c = 0; c < 3; c++) {
|
|
1159
|
+
const w = Math.fround(warped.data[p * 3 + c] + background[c]);
|
|
1160
|
+
const d = Math.abs(w - frame.data[p * 4 + c]);
|
|
1161
|
+
if (d > m) m = d;
|
|
1162
|
+
}
|
|
1163
|
+
sum += m;
|
|
1164
|
+
if (m > SEAM_PX_LEVEL) {
|
|
1165
|
+
over40++;
|
|
1166
|
+
counted.push(p);
|
|
1167
|
+
countedDiff.push(m);
|
|
1168
|
+
}
|
|
1169
|
+
if (m > SEAM_PX_LEVEL_HIGH) over80++;
|
|
1170
|
+
}
|
|
1171
|
+
return { mean: sum / n, over40, over80, counted, countedDiff, map };
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Where `check --keep-stills` leaves the setup-pose still under `--out`
|
|
1176
|
+
* (issue #209): rigc's own render directory as `render` wrote it —
|
|
1177
|
+
* `frames.json` (the viewport the seam and the source line map by, and the
|
|
1178
|
+
* background) and the set directory beside it — so a reader compares against
|
|
1179
|
+
* the frame the bars read rather than rebuilding it. Removed at the start of
|
|
1180
|
+
* every run, so it is on disk only after a run that asked for it.
|
|
1181
|
+
*/
|
|
1182
|
+
export const STILL_DIR = 'setup_still';
|
|
1183
|
+
|
|
1184
|
+
/** Where `check` writes the seam pictures, under `--out` (issue #202): one per row of {@link SeamPairRow}, named by its rank and its two parts. */
|
|
1185
|
+
export const SEAM_DIR = 'seam';
|
|
1186
|
+
|
|
1187
|
+
/** What a seam row names in place of a part when the counted pixel's sample reads fewer than two parts' art. */
|
|
1188
|
+
export const SEAM_NO_ART = '(no art)';
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* One row of `check.json`'s `seam_pairs` (issue #202): the pixels the seam bar
|
|
1192
|
+
* counts (max-channel |d| over {@link SEAM_PX_LEVEL}) that one pair of parts
|
|
1193
|
+
* answers for, on the setup-pose still — the one frame the bar reads.
|
|
1194
|
+
*
|
|
1195
|
+
* A counted frame pixel is answered for by the parts whose art (alpha above
|
|
1196
|
+
* 0) lies under the composite's own sample of it: the frame pixel's centre
|
|
1197
|
+
* carried back to rig pixels by the inverse of {@link SeamFigures.map}, and
|
|
1198
|
+
* the 2x2 rig pixels the bilinear warp reads there (`floor(u)`, `floor(u) +
|
|
1199
|
+
* 1` by `floor(v)`, `floor(v) + 1`, those inside the rig). Of those parts the
|
|
1200
|
+
* two drawn last (parts.json's order is the draw order) are the pair, lower
|
|
1201
|
+
* first; where only one part's art lies there it is named first and the
|
|
1202
|
+
* second is {@link SEAM_NO_ART}, and where none does both are.
|
|
1203
|
+
* `frame_box` is the counted pixels' bounding box in frame pixels, inclusive,
|
|
1204
|
+
* `[x0, y0, x1, y1]`; `rig_box` the bounding box of the rig pixels those
|
|
1205
|
+
* samples read, in the rig pixels of `parts.json`, `cuts[].polygon` and the
|
|
1206
|
+
* patches.
|
|
1207
|
+
*
|
|
1208
|
+
* `cut` is written only when the two parts come from one layer (the same
|
|
1209
|
+
* parts.json `from`, as a cut's base and piece do, `assemble.cuts`): `from`
|
|
1210
|
+
* is that layer, and `differing_copies_px` how many of the row's counted
|
|
1211
|
+
* pixels have, among the rig pixels their sample reads, one that both parts
|
|
1212
|
+
* hold (alpha above 0 in each — a cut's band) in a different colour (any of
|
|
1213
|
+
* R, G, B unequal). It is what the seam bar sees of a cut's band (issue #202):
|
|
1214
|
+
* the band is not excluded, because the two copies are not one drawing — the
|
|
1215
|
+
* piece shows the painting where it is seen and the base keeps the layer's
|
|
1216
|
+
* colour under it — and the upper part's resampled edge shows the lower
|
|
1217
|
+
* part's copy. The pair's other counted pixels on a cut line are the same
|
|
1218
|
+
* resampling where the art changes colour across the line; this count does
|
|
1219
|
+
* not name them.
|
|
1220
|
+
*/
|
|
1221
|
+
export interface SeamPairRow {
|
|
1222
|
+
parts: [string, string];
|
|
1223
|
+
px_over_40: number;
|
|
1224
|
+
px_over_80: number;
|
|
1225
|
+
frame_box: [number, number, number, number];
|
|
1226
|
+
rig_box: [number, number, number, number];
|
|
1227
|
+
cut?: { from: string; differing_copies_px: number };
|
|
1228
|
+
picture: string;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** A part's art in rig pixels: where it is, its image and the layer it comes from (parts.json's `from`). */
|
|
1232
|
+
export interface PlacedAlpha {
|
|
1233
|
+
name: string;
|
|
1234
|
+
from: string;
|
|
1235
|
+
x: number;
|
|
1236
|
+
y: number;
|
|
1237
|
+
image: Raster;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Split the seam's counted pixels by the pair of parts that answers for each
|
|
1242
|
+
* ({@link SeamPairRow}), worst first: by `px_over_40` descending, ties by the
|
|
1243
|
+
* pair's names in draw order. `parts` is parts.json's list in its order, each
|
|
1244
|
+
* with its image as assembled.
|
|
1245
|
+
*/
|
|
1246
|
+
export function seamPairRows(seam: SeamFigures, parts: readonly PlacedAlpha[], rigSize: readonly [number, number], vp: Viewport): Array<{ row: SeamPairRow; pixels: number[] }> {
|
|
1247
|
+
const [W, H] = rigSize;
|
|
1248
|
+
// Each rig pixel's two topmost parts with art there (-1 for none): the top two of a union of pixels are among each pixel's own top two.
|
|
1249
|
+
const top = new Int32Array(W * H).fill(-1);
|
|
1250
|
+
const next = new Int32Array(W * H).fill(-1);
|
|
1251
|
+
parts.forEach((part, k) => {
|
|
1252
|
+
const img = part.image;
|
|
1253
|
+
for (let j = 0; j < img.height; j++) {
|
|
1254
|
+
const v = part.y + j;
|
|
1255
|
+
if (v < 0 || v >= H) continue;
|
|
1256
|
+
for (let i = 0; i < img.width; i++) {
|
|
1257
|
+
const u = part.x + i;
|
|
1258
|
+
if (u < 0 || u >= W || img.data[(j * img.width + i) * 4 + 3] === 0) continue;
|
|
1259
|
+
const q = v * W + u;
|
|
1260
|
+
next[q] = top[q];
|
|
1261
|
+
top[q] = k;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
const { sx, sy, tx, ty } = seam.map;
|
|
1266
|
+
// A part's pixel at rig (u, v) as an offset into its image, or -1 where it holds no art.
|
|
1267
|
+
const at = (k: number, u: number, v: number): number => {
|
|
1268
|
+
const part = parts[k];
|
|
1269
|
+
const i = u - part.x;
|
|
1270
|
+
const j = v - part.y;
|
|
1271
|
+
if (i < 0 || j < 0 || i >= part.image.width || j >= part.image.height) return -1;
|
|
1272
|
+
const o = (j * part.image.width + i) * 4;
|
|
1273
|
+
return part.image.data[o + 3] === 0 ? -1 : o;
|
|
1274
|
+
};
|
|
1275
|
+
const differ = (a: number, b: number, u: number, v: number): boolean => {
|
|
1276
|
+
const oa = at(a, u, v);
|
|
1277
|
+
const ob = at(b, u, v);
|
|
1278
|
+
if (oa < 0 || ob < 0) return false;
|
|
1279
|
+
const da = parts[a].image.data;
|
|
1280
|
+
const db = parts[b].image.data;
|
|
1281
|
+
return da[oa] !== db[ob] || da[oa + 1] !== db[ob + 1] || da[oa + 2] !== db[ob + 2];
|
|
1282
|
+
};
|
|
1283
|
+
interface Acc { a: number; b: number; over40: number; over80: number; f: [number, number, number, number]; r: [number, number, number, number]; pixels: number[]; differing: number }
|
|
1284
|
+
const byPair = new Map<string, Acc>();
|
|
1285
|
+
seam.counted.forEach((p, idx) => {
|
|
1286
|
+
const fx = p % vp.pixelWidth;
|
|
1287
|
+
const fy = Math.floor(p / vp.pixelWidth);
|
|
1288
|
+
const u0 = Math.floor((fx - tx) / sx);
|
|
1289
|
+
const v0 = Math.floor((fy - ty) / sy);
|
|
1290
|
+
let first = -1;
|
|
1291
|
+
let second = -1;
|
|
1292
|
+
const read: Array<[number, number]> = [];
|
|
1293
|
+
let rx0 = Infinity;
|
|
1294
|
+
let ry0 = Infinity;
|
|
1295
|
+
let rx1 = -Infinity;
|
|
1296
|
+
let ry1 = -Infinity;
|
|
1297
|
+
for (let dv = 0; dv < 2; dv++) {
|
|
1298
|
+
for (let du = 0; du < 2; du++) {
|
|
1299
|
+
const u = u0 + du;
|
|
1300
|
+
const v = v0 + dv;
|
|
1301
|
+
if (u < 0 || u >= W || v < 0 || v >= H) continue;
|
|
1302
|
+
read.push([u, v]);
|
|
1303
|
+
rx0 = Math.min(rx0, u);
|
|
1304
|
+
ry0 = Math.min(ry0, v);
|
|
1305
|
+
rx1 = Math.max(rx1, u);
|
|
1306
|
+
ry1 = Math.max(ry1, v);
|
|
1307
|
+
for (const k of [top[v * W + u], next[v * W + u]]) {
|
|
1308
|
+
if (k < 0 || k === first || k === second) continue;
|
|
1309
|
+
if (k > first) {
|
|
1310
|
+
second = first;
|
|
1311
|
+
first = k;
|
|
1312
|
+
} else if (k > second) second = k;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
// A sample wholly outside the rig reads no rig pixel; its rig box is the nearest rig pixel.
|
|
1317
|
+
if (rx1 < rx0) {
|
|
1318
|
+
rx0 = rx1 = Math.min(Math.max(u0, 0), W - 1);
|
|
1319
|
+
ry0 = ry1 = Math.min(Math.max(v0, 0), H - 1);
|
|
1320
|
+
}
|
|
1321
|
+
const a = second;
|
|
1322
|
+
const b = first;
|
|
1323
|
+
const key = `${a},${b}`;
|
|
1324
|
+
let acc = byPair.get(key);
|
|
1325
|
+
if (acc === undefined) {
|
|
1326
|
+
acc = { a, b, over40: 0, over80: 0, f: [fx, fy, fx, fy], r: [rx0, ry0, rx1, ry1], pixels: [], differing: 0 };
|
|
1327
|
+
byPair.set(key, acc);
|
|
1328
|
+
}
|
|
1329
|
+
if (a >= 0 && parts[a].from === parts[b].from && read.some(([u, v]) => differ(a, b, u, v))) acc.differing++;
|
|
1330
|
+
acc.over40++;
|
|
1331
|
+
acc.pixels.push(p);
|
|
1332
|
+
if (seam.countedDiff[idx] > SEAM_PX_LEVEL_HIGH) acc.over80++;
|
|
1333
|
+
acc.f = [Math.min(acc.f[0], fx), Math.min(acc.f[1], fy), Math.max(acc.f[2], fx), Math.max(acc.f[3], fy)];
|
|
1334
|
+
acc.r = [Math.min(acc.r[0], rx0), Math.min(acc.r[1], ry0), Math.max(acc.r[2], rx1), Math.max(acc.r[3], ry1)];
|
|
1335
|
+
});
|
|
1336
|
+
const name = (k: number): string => (k < 0 ? SEAM_NO_ART : parts[k].name);
|
|
1337
|
+
const sorted = [...byPair.values()].sort((x, y) => y.over40 - x.over40 || x.b - y.b || x.a - y.a);
|
|
1338
|
+
const width = String(sorted.length).length;
|
|
1339
|
+
return sorted.map((acc, i) => {
|
|
1340
|
+
// A part over no other art is named first, then SEAM_NO_ART.
|
|
1341
|
+
const pair: [string, string] = acc.a < 0 ? [name(acc.b), SEAM_NO_ART] : [name(acc.a), name(acc.b)];
|
|
1342
|
+
const file = `${String(i + 1).padStart(Math.max(2, width), '0')}_${pair.map((n) => (n === SEAM_NO_ART ? 'no-art' : n)).join('_')}.png`;
|
|
1343
|
+
const cut = acc.a >= 0 && parts[acc.a].from === parts[acc.b].from ? { cut: { from: parts[acc.a].from, differing_copies_px: acc.differing } } : {};
|
|
1344
|
+
return { row: { parts: pair, px_over_40: acc.over40, px_over_80: acc.over80, frame_box: acc.f, rig_box: acc.r, ...cut, picture: `${SEAM_DIR}/${file}` }, pixels: acc.pixels };
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** The marks a seam picture draws: the counted pixels of its pair, and the box round them. */
|
|
1349
|
+
export const SEAM_MARK: readonly [number, number, number] = [255, 0, 255];
|
|
1350
|
+
export const SEAM_BOX: readonly [number, number, number] = [0, 255, 255];
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* A seam row's picture: the setup-pose frame with the pair's counted pixels
|
|
1354
|
+
* painted {@link SEAM_MARK} and the rectangle one pixel outside its
|
|
1355
|
+
* `frame_box` drawn {@link SEAM_BOX} where it falls inside the frame. Every
|
|
1356
|
+
* other pixel is the frame's own, opaque. `pixels` are the row's counted
|
|
1357
|
+
* pixels, by index into the frame.
|
|
1358
|
+
*/
|
|
1359
|
+
export function seamPicture(frame: Raster, row: SeamPairRow, pixels: readonly number[]): Raster {
|
|
1360
|
+
const out = newRaster(frame.width, frame.height);
|
|
1361
|
+
out.data.set(frame.data);
|
|
1362
|
+
for (let p = 0; p < frame.width * frame.height; p++) out.data[p * 4 + 3] = 255;
|
|
1363
|
+
const paint = (x: number, y: number, rgb: readonly [number, number, number]): void => {
|
|
1364
|
+
if (x < 0 || y < 0 || x >= frame.width || y >= frame.height) return;
|
|
1365
|
+
out.data.set(rgb, (y * frame.width + x) * 4);
|
|
1366
|
+
};
|
|
1367
|
+
const [x0, y0, x1, y1] = row.frame_box;
|
|
1368
|
+
for (let x = x0 - 1; x <= x1 + 1; x++) {
|
|
1369
|
+
paint(x, y0 - 1, SEAM_BOX);
|
|
1370
|
+
paint(x, y1 + 1, SEAM_BOX);
|
|
1371
|
+
}
|
|
1372
|
+
for (let y = y0 - 1; y <= y1 + 1; y++) {
|
|
1373
|
+
paint(x0 - 1, y, SEAM_BOX);
|
|
1374
|
+
paint(x1 + 1, y, SEAM_BOX);
|
|
1375
|
+
}
|
|
1376
|
+
for (const p of pixels) paint(p % frame.width, Math.floor(p / frame.width), SEAM_MARK);
|
|
1377
|
+
return out;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** Python's `round(x, 3)` for the figures written: to three places, on the value's exact decimal expansion. */
|
|
1381
|
+
function round3(x: number): number {
|
|
1382
|
+
return Number(x.toFixed(3));
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** Python's `round(x, 6)` for the frame scale written: to six places, on the value's exact decimal expansion. */
|
|
1386
|
+
function round6(x: number): number {
|
|
1387
|
+
return Number(x.toFixed(6));
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** The seam's count in rig pixels (issue #216): the frame pixels over {@link SEAM_PX_LEVEL} divided by the square of the frame scale. */
|
|
1391
|
+
export function seamRigPx(over40: number, scale: number): number {
|
|
1392
|
+
return over40 / (scale * scale);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** {@link seamRigPx} as the console and the refusal print it, to two places. */
|
|
1396
|
+
export function seamRigPxText(over40: number, scale: number): string {
|
|
1397
|
+
return seamRigPx(over40, scale).toFixed(2);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* The seam bar, on the figures as check.json writes them: the mean (rounded
|
|
1402
|
+
* to three places) over {@link SEAM_MEAN_BAR}, or more than
|
|
1403
|
+
* {@link SEAM_PX_BAR} rig pixels over {@link SEAM_PX_LEVEL} — the frame count
|
|
1404
|
+
* over the square of the written frame scale (issue #216).
|
|
1405
|
+
*/
|
|
1406
|
+
function seamFails(mean: number, over40: number, scale: number): boolean {
|
|
1407
|
+
return mean > SEAM_MEAN_BAR || seamRigPx(over40, scale) > SEAM_PX_BAR;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/** A seam row as the console prints it: the pair, the frame, the counts, both boxes and the picture. */
|
|
1411
|
+
export function seamPairText(row: SeamPairRow): string {
|
|
1412
|
+
const q = (n: string): string => (n === SEAM_NO_ART ? n : `"${n}"`);
|
|
1413
|
+
const box = (b: readonly number[]): string => `x ${b[0]}..${b[2]}, y ${b[1]}..${b[3]}`;
|
|
1414
|
+
const cut =
|
|
1415
|
+
row.cut === undefined
|
|
1416
|
+
? ''
|
|
1417
|
+
: `; one layer cut in two (both from ${row.cut.from}): ${row.cut.differing_copies_px} of these px read a pixel both parts hold in different colours. Along a cut line the render draws the upper part's resampled edge over the lower part's own resampled copy, so it parts from the flat stack where the two copies differ and where the art changes colour across the line; a wider cuts[].overlap moves neither, a polygon edge through art of one colour on both sides, in both copies, does`;
|
|
1418
|
+
return `${q(row.parts[0])} / ${q(row.parts[1])} at the setup pose: ${row.px_over_40} px over ${SEAM_PX_LEVEL} (${row.px_over_80} over ${SEAM_PX_LEVEL_HIGH}), frame box ${box(row.frame_box)}, rig box ${box(row.rig_box)}, picture ${row.picture}${cut}`;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ---------------------------------------------------------------------------
|
|
1422
|
+
// the setup pose against the painting (issue #77)
|
|
1423
|
+
// ---------------------------------------------------------------------------
|
|
1424
|
+
|
|
1425
|
+
/** The line `--source` adds: the setup pose against the painting, with assemble's recomposite figures. */
|
|
1426
|
+
export const SOURCE_LINE = 'SETUP_POSE_VS_SOURCE';
|
|
1427
|
+
|
|
1428
|
+
/** The name the setup pose's coverage goes by where assemble's measure names a part. */
|
|
1429
|
+
export const SETUP_POSE_COVER = '(setup pose)';
|
|
1430
|
+
|
|
1431
|
+
/** The throwaway rig whose every slot is tinted black, rendered beside the setup-pose still: the coverage the opaque render does not carry. */
|
|
1432
|
+
const BLACK_RIG = 'rig_black.json';
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* The rig with every slot tinted black (`color` `000000` with the slot's own
|
|
1436
|
+
* alpha kept, and `dark` `000000` where the slot has one), so the setup pose
|
|
1437
|
+
* drawn from it is the render's background times what the art lets through.
|
|
1438
|
+
*/
|
|
1439
|
+
export function blackRig(rig: Record<string, unknown>): Record<string, unknown> {
|
|
1440
|
+
const slots = (Array.isArray(rig.slots) ? rig.slots : []).map((s) => {
|
|
1441
|
+
if (!isRecord(s)) return s;
|
|
1442
|
+
const alpha = typeof s.color === 'string' && s.color.length === 8 ? s.color.slice(6) : 'ff';
|
|
1443
|
+
return { ...s, color: `000000${alpha}`, ...(s.dark === undefined ? {} : { dark: '000000' }) };
|
|
1444
|
+
});
|
|
1445
|
+
return { ...rig, slots };
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* The setup pose against the painting with `assemble`'s recomposite figures,
|
|
1450
|
+
* by `assemble`'s own function (`measureRecomposite`): mean |d| and the share
|
|
1451
|
+
* within 8 over the mean channel, error pixels over 40 by the max channel, the
|
|
1452
|
+
* uncovered ones where nothing has alpha above 128, and their 8-connected
|
|
1453
|
+
* holes — over the stage's W x H pixels, which is assemble's population.
|
|
1454
|
+
*
|
|
1455
|
+
* What stands in for assemble's two inputs, because the setup pose is what is
|
|
1456
|
+
* measured and the parts are not read:
|
|
1457
|
+
* - **The recomposite.** assemble composites onto opaque white; rigc renders
|
|
1458
|
+
* onto its opaque `background` (232 grey, rig-c's `BACKGROUND`), so the
|
|
1459
|
+
* frame carries no alpha. A second still of the same pose, every slot tinted
|
|
1460
|
+
* black ({@link blackRig}), is the background times what the art lets
|
|
1461
|
+
* through, channel by channel: `black = bg (1 - a)`. The pose over white is
|
|
1462
|
+
* then `open + (255 - bg) black / bg` — exact for every slot drawn with the
|
|
1463
|
+
* normal blend, which is every slot rig-parts writes.
|
|
1464
|
+
* - **The coverage.** assemble's "a part has alpha above 128" becomes "the
|
|
1465
|
+
* setup pose has alpha above 128", the alpha read as `255 (1 - black / bg)`
|
|
1466
|
+
* on the channel that lets most through. Where two translucent parts overlap
|
|
1467
|
+
* the stack's alpha exceeds either part's, so a pixel uncovered here is
|
|
1468
|
+
* uncovered in assemble's reading too, never the reverse.
|
|
1469
|
+
*
|
|
1470
|
+
* Both are carried from the frame's grid onto the stage's with the seam's map
|
|
1471
|
+
* turned round ({@link frameToRigMap}, pixel centre to pixel centre, issue
|
|
1472
|
+
* #209), through the same bilinear `warpAffine` in float32, the border reading
|
|
1473
|
+
* as no art; then rounded to 8 bits, as assemble's composite is.
|
|
1474
|
+
*/
|
|
1475
|
+
export function sourceFigures(open: Raster, black: Raster, vp: Viewport, background: readonly [number, number, number, number], stage: StageBox, srcr: Raster): RecompositeFigures {
|
|
1476
|
+
const problems: Problem[] = [];
|
|
1477
|
+
for (const [label, f] of [['the setup-pose frame', open], ['the black-tinted setup-pose frame', black]] as const) {
|
|
1478
|
+
if (f.width !== vp.pixelWidth || f.height !== vp.pixelHeight) problems.push({ code: 'CHECK_SOURCE_GRID', object: label, detail: `is ${f.width}x${f.height}; frames.json's viewport is ${vp.pixelWidth}x${vp.pixelHeight}` });
|
|
1479
|
+
}
|
|
1480
|
+
if (background[3] !== 255 || background.slice(0, 3).some((c) => c === 0)) {
|
|
1481
|
+
problems.push({ code: 'CHECK_SOURCE_GRID', object: 'the render background', detail: `is ${JSON.stringify(background)}; an opaque background with no channel at 0 is required — the coverage is read as how much of it the black-tinted pose lets through` });
|
|
1482
|
+
}
|
|
1483
|
+
if (srcr.width !== stage.width || srcr.height !== stage.height) problems.push({ code: 'CHECK_SOURCE_GRID', object: 'the painting on the rig canvas', detail: `is ${srcr.width}x${srcr.height}; the stage is ${stage.width}x${stage.height}` });
|
|
1484
|
+
refuseIfAny(problems);
|
|
1485
|
+
const [W, H] = [stage.width, stage.height];
|
|
1486
|
+
const n = vp.pixelWidth * vp.pixelHeight;
|
|
1487
|
+
// Ink = 255 - the pose over white, so the warp's border 0 reads as white paper; channel 3 is the coverage alpha.
|
|
1488
|
+
const ink = newFloatImage(vp.pixelWidth, vp.pixelHeight, 4);
|
|
1489
|
+
for (let p = 0; p < n; p++) {
|
|
1490
|
+
let through = 0;
|
|
1491
|
+
for (let c = 0; c < 3; c++) {
|
|
1492
|
+
const t = black.data[p * 4 + c] / background[c];
|
|
1493
|
+
if (t > through) through = t;
|
|
1494
|
+
ink.data[p * 4 + c] = 255 - (open.data[p * 4 + c] + (255 - background[c]) * t);
|
|
1495
|
+
}
|
|
1496
|
+
ink.data[p * 4 + 3] = 255 * (1 - Math.min(1, through));
|
|
1497
|
+
}
|
|
1498
|
+
// The seam's map turned round, centre to centre (issue #209).
|
|
1499
|
+
const warped = warpAffine(ink, frameToRigMap(stage, H, vp), W, H, 'bilinear');
|
|
1500
|
+
const to8 = (v: number): number => Math.min(255, Math.max(0, Math.round(v)));
|
|
1501
|
+
const can = newRaster(W, H);
|
|
1502
|
+
const cover = newRaster(W, H);
|
|
1503
|
+
for (let p = 0; p < W * H; p++) {
|
|
1504
|
+
for (let c = 0; c < 3; c++) can.data[p * 4 + c] = to8(255 - warped.data[p * 4 + c]);
|
|
1505
|
+
can.data[p * 4 + 3] = 255;
|
|
1506
|
+
cover.data[p * 4 + 3] = to8(warped.data[p * 4 + 3]);
|
|
1507
|
+
}
|
|
1508
|
+
const record: PartRecord = { name: SETUP_POSE_COVER, from: SETUP_POSE_COVER, x: 0, y: 0, w: W, h: H, opaque_px: 0, projected_core_px: 0, source_px_taken: 0, refused_drift_px: 0, merged_px: 0, seam_override_px: 0 };
|
|
1509
|
+
return measureRecomposite(can, srcr, [{ record, image: cover }]);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/** {@link sourceFigures} as the line `check.json` carries: assemble's `recomposite` block's figures and limits, the largest hole's box, and what was compared. */
|
|
1513
|
+
export function sourceLine(fig: RecompositeFigures, painting: Raster, stage: StageBox, vp: Viewport): ReportedLine {
|
|
1514
|
+
const r = recompositeRecord(fig);
|
|
1515
|
+
return {
|
|
1516
|
+
status: 'REPORTED',
|
|
1517
|
+
painting_px: `${painting.width}x${painting.height}`,
|
|
1518
|
+
stage_px: `${stage.width}x${stage.height}`,
|
|
1519
|
+
render_px: `${vp.pixelWidth}x${vp.pixelHeight}`,
|
|
1520
|
+
render_scale: Number(vp.scale.toFixed(4)),
|
|
1521
|
+
mean_abs: r.mean_abs,
|
|
1522
|
+
within_limit: r.within_limit,
|
|
1523
|
+
within_share: r.within_share,
|
|
1524
|
+
error_limit: r.error_limit,
|
|
1525
|
+
error_px: r.error_px,
|
|
1526
|
+
covered_alpha: r.covered_alpha,
|
|
1527
|
+
uncovered_error_px: r.uncovered_error_px,
|
|
1528
|
+
hole_count: r.hole_count,
|
|
1529
|
+
largest: r.holes.length === 0 ? null : { px: r.holes[0].px, box: boxText(r.holes[0]) },
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// ---------------------------------------------------------------------------
|
|
1534
|
+
// the stage
|
|
1535
|
+
// ---------------------------------------------------------------------------
|
|
1536
|
+
|
|
1537
|
+
/** One judgement line (issue #11): its status, the figures it measured, and the bars it held them to — or why it measured nothing. */
|
|
1538
|
+
export type JudgementLine = { status: 'PASS' | 'FAIL'; [figure: string]: unknown } | { status: 'SKIP'; reason: string };
|
|
1539
|
+
|
|
1540
|
+
/** The judgement lines, in the order check.json and the console carry them. */
|
|
1541
|
+
export const JUDGEMENT_LINES = ['BREATH_VISIBLE', 'BLINK_NO_HOLE', 'CHAIN_LAG', 'TIP_OVER_ROOT', 'STILL_REGIONS_DARK', 'TEXTURE_STRETCH'] as const;
|
|
1542
|
+
export type JudgementName = (typeof JUDGEMENT_LINES)[number];
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* A line with no bar (issue #25): it reports figures and can never be a FAIL,
|
|
1546
|
+
* so `PASS` does not read it. SKIP, with the reason, when there is nothing to
|
|
1547
|
+
* report from.
|
|
1548
|
+
*/
|
|
1549
|
+
export type ReportedLine = { status: 'REPORTED'; [figure: string]: unknown } | { status: 'SKIP'; reason: string };
|
|
1550
|
+
|
|
1551
|
+
/** The reported lines, after the judgement lines, in the order check.json and the console carry them. */
|
|
1552
|
+
export const REPORTED_LINES = ['RECOMPOSITE_HOLES'] as const;
|
|
1553
|
+
|
|
1554
|
+
/** check.json — the reference's fields in its order, then the judgement lines, then PASS. */
|
|
1555
|
+
export interface CheckFigures {
|
|
1556
|
+
gate_spine_html_green: boolean;
|
|
1557
|
+
/** Which rigc entry gated the build ({@link readRigcEntry}): provenance, not a bar. */
|
|
1558
|
+
rigc_entry: RigcEntryRecord;
|
|
1559
|
+
/** How the artifact's page was packed: the `--page-edges` and `--pack-shape` rigc was handed, which its pack line was read to agree with ({@link packEdgeProblems}). Provenance, not a bar. */
|
|
1560
|
+
pack_mode: { page_edges: PageEdges; pack_shape: PackShape };
|
|
1561
|
+
/** Null when the rig has no idle (issue #77); `skipped.loop` then says why. */
|
|
1562
|
+
loop_max_diff: number | null;
|
|
1563
|
+
/**
|
|
1564
|
+
* Written only when the rig declares a physics constraint (issue #183):
|
|
1565
|
+
* each one {@link withoutPhysics} left out of the frames `loop_max_diff` was
|
|
1566
|
+
* read from. Reported, not judged; a rig without one writes the keys it always wrote.
|
|
1567
|
+
*/
|
|
1568
|
+
loop_physics?: LoopPhysics[];
|
|
1569
|
+
/** The seam figures: null when there is no parts.json (issue #77); `skipped.seam` then says why. The two counts are frame pixels. */
|
|
1570
|
+
seam_mean: number | null;
|
|
1571
|
+
seam_px_over_40: number | null;
|
|
1572
|
+
seam_px_over_80: number | null;
|
|
1573
|
+
/**
|
|
1574
|
+
* The frame scale s the setup-pose still rendered at (rigc's viewport
|
|
1575
|
+
* scale, to six places), issue #216: the seam bar reads `seam_px_over_40`
|
|
1576
|
+
* divided by its square ({@link seamRigPx}). Null with the other three.
|
|
1577
|
+
*/
|
|
1578
|
+
seam_frame_scale: number | null;
|
|
1579
|
+
/**
|
|
1580
|
+
* Written only when the seam bar fails, or when it was asked for (`check
|
|
1581
|
+
* --seam-pairs`), issue #202: the counted pixels split by the pair of parts
|
|
1582
|
+
* that answers for each, worst first ({@link SeamPairRow}), each with its
|
|
1583
|
+
* picture under {@link SEAM_DIR}. A passing run not asked for it writes the
|
|
1584
|
+
* keys it always wrote.
|
|
1585
|
+
*/
|
|
1586
|
+
seam_pairs?: SeamPairRow[];
|
|
1587
|
+
BREATH_VISIBLE: JudgementLine;
|
|
1588
|
+
BLINK_NO_HOLE: JudgementLine;
|
|
1589
|
+
CHAIN_LAG: JudgementLine;
|
|
1590
|
+
TIP_OVER_ROOT: JudgementLine;
|
|
1591
|
+
STILL_REGIONS_DARK: JudgementLine;
|
|
1592
|
+
TEXTURE_STRETCH: JudgementLine;
|
|
1593
|
+
RECOMPOSITE_HOLES: ReportedLine;
|
|
1594
|
+
/** Written only under `--source` (issue #77): the setup pose against the painting, REPORTED. */
|
|
1595
|
+
SETUP_POSE_VS_SOURCE?: ReportedLine;
|
|
1596
|
+
/**
|
|
1597
|
+
* Written only when the loop or the seam measured nothing (issue #77): the
|
|
1598
|
+
* reason for each, by the name its console line goes by. The judgement and
|
|
1599
|
+
* reported lines carry their own SKIP; these two bars have figures, not a
|
|
1600
|
+
* status, so their reasons sit here, and a run that measured both writes
|
|
1601
|
+
* the keys it always wrote.
|
|
1602
|
+
*/
|
|
1603
|
+
skipped?: { loop?: string; seam?: string };
|
|
1604
|
+
/** Written only under `--requirements` (issue #93): the file's fps, the summary, and one line per declared requirement, by name. */
|
|
1605
|
+
requirements?: RequirementsBlock;
|
|
1606
|
+
PASS: boolean;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/** `check.json`'s `requirements` block: the fps every named animation was sampled at, the summary, and each line by its name, in the file's order. */
|
|
1610
|
+
export interface RequirementsBlock {
|
|
1611
|
+
fps: number;
|
|
1612
|
+
summary: RequirementsSummary;
|
|
1613
|
+
lines: Record<string, RequirementLine>;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** The bars `PASS` reads, in the order the summary counts them: the gate, the reference's two, then the judgement lines. */
|
|
1617
|
+
export const BARS = ['gate', 'loop', 'seam', ...JUDGEMENT_LINES] as const;
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* The bars a character's own check may have failed and `compose --accept`
|
|
1621
|
+
* still take (issue #203): every bar of {@link BARS} but the gate, which is
|
|
1622
|
+
* rig-c's and never accepted.
|
|
1623
|
+
*/
|
|
1624
|
+
export const ACCEPTABLE_BARS: readonly string[] = BARS.filter((b) => b !== 'gate');
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* The bars a `check.json`, as `check` writes it, says were not met, in
|
|
1628
|
+
* {@link BARS} order: the gate when `gate_spine_html_green` is not true, the
|
|
1629
|
+
* loop when `loop_max_diff` is a number other than {@link LOOP_MAX_BAR}, the
|
|
1630
|
+
* seam when its figures fail {@link seamFails}, and each judgement line whose
|
|
1631
|
+
* status is `FAIL`. A bar that measured nothing (null figures, `SKIP`) is not
|
|
1632
|
+
* one of them. Read off the file rather than its problems, because the file is
|
|
1633
|
+
* all a finished build leaves (issue #203).
|
|
1634
|
+
*/
|
|
1635
|
+
export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
|
|
1636
|
+
const out: string[] = [];
|
|
1637
|
+
if (fig.gate_spine_html_green !== true) out.push('gate');
|
|
1638
|
+
if (typeof fig.loop_max_diff === 'number' && fig.loop_max_diff !== LOOP_MAX_BAR) out.push('loop');
|
|
1639
|
+
// A check.json written before issue #216 has no seam_frame_scale; it is read by the frame count, the bar it was written under.
|
|
1640
|
+
const scale = typeof fig.seam_frame_scale === 'number' ? fig.seam_frame_scale : 1;
|
|
1641
|
+
if (typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) out.push('seam');
|
|
1642
|
+
for (const n of JUDGEMENT_LINES) {
|
|
1643
|
+
const l = fig[n];
|
|
1644
|
+
if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
|
|
1645
|
+
}
|
|
1646
|
+
return out;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** The declared requirements a `check.json` says were not PASS (FAIL or NOT MEASURABLE), by name, in the file's order; none without a `requirements` block. */
|
|
1650
|
+
export function requirementsNotPass(fig: Readonly<Record<string, unknown>>): string[] {
|
|
1651
|
+
const block = fig.requirements;
|
|
1652
|
+
if (typeof block !== 'object' || block === null) return [];
|
|
1653
|
+
const lines = (block as { lines?: unknown }).lines;
|
|
1654
|
+
if (typeof lines !== 'object' || lines === null) return [];
|
|
1655
|
+
return Object.entries(lines as Record<string, unknown>)
|
|
1656
|
+
.filter(([, l]) => typeof l !== 'object' || l === null || (l as { status?: unknown }).status !== 'PASS')
|
|
1657
|
+
.map(([n]) => n);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
export interface CheckReport {
|
|
1661
|
+
figures: CheckFigures;
|
|
1662
|
+
gateHtml: string[];
|
|
1663
|
+
pack: PackLine[];
|
|
1664
|
+
/** Opaque share (alpha > 0) of each packed page, by page file name. */
|
|
1665
|
+
packOpaque: Array<{ page: string; share: number }>;
|
|
1666
|
+
/** Null when the rig has no idle. */
|
|
1667
|
+
idle: { frames: number; fps: number; duration: number; lastIndex: number; loopAt: { x: number; y: number } } | null;
|
|
1668
|
+
/** The physics line ({@link loopPhysicsLine}); null when the rig declares no physics constraint or has no idle. */
|
|
1669
|
+
loopPhysics: string | null;
|
|
1670
|
+
/** The setup-pose still's grid; null when nothing read the still (no parts.json and no `--source`). */
|
|
1671
|
+
seamViewport: Viewport | null;
|
|
1672
|
+
/** Every bar in {@link BARS} that measured, and every one that did not with its reason. */
|
|
1673
|
+
bars: { measured: string[]; skipped: Array<{ bar: string; reason: string }> };
|
|
1674
|
+
/** Every bar that was not met, one problem each; empty on PASS. */
|
|
1675
|
+
problems: Problem[];
|
|
1676
|
+
written: string[];
|
|
1677
|
+
/** Under `--requirements` only (issue #93): what `check.json`'s `requirements` block holds; null without the flag. */
|
|
1678
|
+
requirements: RequirementsBlock | null;
|
|
1679
|
+
/**
|
|
1680
|
+
* The animations `motion.json` holds beside the idle (issue #183), in its
|
|
1681
|
+
* order: built and gated with the rig, measured by nothing here — every bar
|
|
1682
|
+
* reads the idle. Empty when the idle is alone.
|
|
1683
|
+
*/
|
|
1684
|
+
besideIdle: string[];
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* The one line `check` prints when `motion.json` holds animations beside the
|
|
1689
|
+
* idle (issue #183), or null when it holds none, so a rig of the idle alone
|
|
1690
|
+
* prints what it printed before.
|
|
1691
|
+
*/
|
|
1692
|
+
export function besideIdleLine(names: readonly string[]): string | null {
|
|
1693
|
+
if (names.length === 0) return null;
|
|
1694
|
+
return `beside the idle: ${names.length} animation(s), ${names.join(', ')} — built and gated by rigc with the rig, not measured: every bar here reads the idle`;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* One `CHECK_RIGC_GREEN` per line that says why `rigc <what>` was not green:
|
|
1699
|
+
* its FAIL and compile-error lines, or, when there are none, {@link causeLines}.
|
|
1700
|
+
* A run that printed nothing is still one problem, never none.
|
|
1701
|
+
*/
|
|
1702
|
+
export function rigcFailed(what: string, call: RigcCall, lines: readonly string[]): Problem[] {
|
|
1703
|
+
const named = lines.filter((l) => l.includes('FAIL') || l.includes('compile error'));
|
|
1704
|
+
const quoted = named.length > 0 ? named.map((l) => l.trim()) : causeLines(call.out);
|
|
1705
|
+
const object = `\`rigc ${what}\``;
|
|
1706
|
+
if (quoted.length === 0) return [{ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}, and it printed nothing` }];
|
|
1707
|
+
return quoted.map((l) => ({ code: 'CHECK_RIGC_GREEN', object, detail: `exit ${call.status}: ${l}` }));
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* Run the whole check. Refuses (throws a PartsError) when the inputs are
|
|
1712
|
+
* unreadable or a rigc step that everything after it depends on is red — the
|
|
1713
|
+
* gate file is written first, so the refusal's evidence is on disk. Otherwise
|
|
1714
|
+
* it measures everything it can read, writes every output including
|
|
1715
|
+
* `check.json`, and returns the report; `report.problems` names each bar that
|
|
1716
|
+
* was not met, and `figures.PASS` is true exactly when it is empty. `mode`
|
|
1717
|
+
* reaches rigc's `--page-edges` and `--pack-shape` verbatim, a pack line that
|
|
1718
|
+
* disagrees with either is refused (`CHECK_PACK_PAGE_EDGES`,
|
|
1719
|
+
* `CHECK_PACK_SHAPE`), and `check.json` records it as `pack_mode`.
|
|
1720
|
+
*
|
|
1721
|
+
* What it can read (issue #77): the gate always runs, on whatever the spec
|
|
1722
|
+
* declares. Without `parts.json` ({@link readCheckInputs}) the seam, the four
|
|
1723
|
+
* judgement lines that choose regions by tag and `RECOMPOSITE_HOLES` say SKIP
|
|
1724
|
+
* with the reason; without an `idle` the loop and every line that reads idle
|
|
1725
|
+
* frames say SKIP, and no idle is rendered (no `idle_frames/`, `contact.png`
|
|
1726
|
+
* or `motion_heat.png`). `source` (`--source`) adds {@link SOURCE_LINE}.
|
|
1727
|
+
*
|
|
1728
|
+
* `requirements` (`--requirements`, issue #93) is read with the rig directory
|
|
1729
|
+
* and resolved against it before anything is built ({@link readRequirements},
|
|
1730
|
+
* {@link resolveRequirements}); after every line above, each declared
|
|
1731
|
+
* requirement is measured ({@link measureRequirements}) into `requirements/`,
|
|
1732
|
+
* `check.json` gains its `requirements` block, and every FAIL and NOT
|
|
1733
|
+
* MEASURABLE is a problem. Without it nothing here reads, writes or prints
|
|
1734
|
+
* anything it did not before.
|
|
1735
|
+
*/
|
|
1736
|
+
export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome?: string, mode: PackMode = DEFAULT_PACK_MODE, source?: string, requirements?: string, seamPairsAsked = false, keepStills = false): CheckReport {
|
|
1737
|
+
let inp: CheckInputs;
|
|
1738
|
+
let reqFile: RequirementsFile | null = null;
|
|
1739
|
+
let seams: Map<string, SeamPair[]> | null = null;
|
|
1740
|
+
if (requirements === undefined) inp = readCheckInputs(rigDir, partsHome, source);
|
|
1741
|
+
else {
|
|
1742
|
+
// Both readers collect every problem; one refusal names both sets.
|
|
1743
|
+
const refused: Problem[] = [];
|
|
1744
|
+
const take = <T>(read: () => T): T | null => {
|
|
1745
|
+
try {
|
|
1746
|
+
return read();
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
if (!(err instanceof PartsError)) throw err;
|
|
1749
|
+
refused.push(...err.problems);
|
|
1750
|
+
return null;
|
|
1751
|
+
}
|
|
1752
|
+
};
|
|
1753
|
+
const got = take(() => readCheckInputs(rigDir, partsHome, source));
|
|
1754
|
+
reqFile = take(() => readRequirements(requirements));
|
|
1755
|
+
refuseIfAny(refused);
|
|
1756
|
+
inp = got as CheckInputs;
|
|
1757
|
+
const file = reqFile as RequirementsFile;
|
|
1758
|
+
const ready = inp;
|
|
1759
|
+
take(() => resolveRequirements(file, ready.rig, ready.motion, ready.rootBone));
|
|
1760
|
+
// A seam's pairs are read off parts.json's art (issue #111); both resolvers' problems are named in one refusal.
|
|
1761
|
+
if (file.requirements.some((r) => r.kind === 'seam')) {
|
|
1762
|
+
const parts = ready.parts;
|
|
1763
|
+
seams = take(() => resolveSeams(file, parts === null ? null : parts.parts.map((p) => p.name), ready.noParts, (n) => {
|
|
1764
|
+
const rec = (parts as PartsFile).parts.find((p) => p.name === n) as PartRecord;
|
|
1765
|
+
return placedArt(rec.x, rec.y, readPng(join(ready.partsDir, `${n}.png`)));
|
|
1766
|
+
}));
|
|
1767
|
+
}
|
|
1768
|
+
refuseIfAny(refused);
|
|
1769
|
+
}
|
|
1770
|
+
const rigcEntry = readRigcEntry(requireRigcVersion(rigc));
|
|
1771
|
+
const out = resolve(outDir);
|
|
1772
|
+
mkdirSync(out, { recursive: true });
|
|
1773
|
+
const buildDir = join(out, 'build');
|
|
1774
|
+
const idleDir = join(out, 'idle_frames');
|
|
1775
|
+
const stillDir = join(out, '_still');
|
|
1776
|
+
const isoDir = join(out, '_isolated');
|
|
1777
|
+
const loopDir = join(out, '_loop');
|
|
1778
|
+
const seamDir = join(out, SEAM_DIR);
|
|
1779
|
+
const keptDir = join(out, STILL_DIR);
|
|
1780
|
+
for (const d of [buildDir, idleDir, stillDir, isoDir, loopDir, seamDir, keptDir]) rmSync(d, { recursive: true, force: true });
|
|
1781
|
+
const written: string[] = [];
|
|
1782
|
+
const write = (name: string, data: string): void => {
|
|
1783
|
+
writeFileSync(join(out, name), data);
|
|
1784
|
+
written.push(name);
|
|
1785
|
+
};
|
|
1786
|
+
|
|
1787
|
+
// 1. build, packed, under the policy profile
|
|
1788
|
+
const build = rigc(['build', '--rig', inp.rigPath, '--motion', inp.motionPath, '--out', buildDir, ...packedBuildArgs(mode)]);
|
|
1789
|
+
const gateHtml = buildGateLines(build.out);
|
|
1790
|
+
write('gate_spine-html.txt', `${gateHtml.join('\n')}\n`);
|
|
1791
|
+
if (build.status !== 0) refuseIfAny(rigcFailed(packedBuildLabel(mode), build, gateHtml));
|
|
1792
|
+
const pack = parsePackLines(gateHtml);
|
|
1793
|
+
refuseIfAny(packEdgeProblems(pack, mode));
|
|
1794
|
+
const packOpaque = pack.filter((p) => existsSync(join(buildDir, p.page))).map((p) => ({ page: p.page, share: opaqueShare(readPng(join(buildDir, p.page))) }));
|
|
1795
|
+
|
|
1796
|
+
// 2. no second gate: `rigc validate --profile spine` of this build measured no rule the build's own two passes
|
|
1797
|
+
// had not (14 of the build's 23 on the demo, rigc 1.5.1 and 2.0.3 alike; selftest CH09 holds it), and on rigc's
|
|
1798
|
+
// core entry — an install without spine-core — `validate` is refused outright
|
|
1799
|
+
const htmlGreen = gateGreen(build.status, gateHtml);
|
|
1800
|
+
|
|
1801
|
+
// What each line reads, and why it cannot when it cannot (issue #77).
|
|
1802
|
+
const full: FullInputs | null = inp.parts !== null && inp.idleDuration !== null ? { ...inp, parts: inp.parts, idleDuration: inp.idleDuration } : null;
|
|
1803
|
+
const lacking = [inp.noParts, inp.noIdle].filter((r): r is string => r !== null).join('; ');
|
|
1804
|
+
const problems: Problem[] = [];
|
|
1805
|
+
|
|
1806
|
+
// 3. the idle, the loop, the heat — only when there is an idle
|
|
1807
|
+
let idle: FrameSet | null = null;
|
|
1808
|
+
let loop: { max: number; x: number; y: number } | null = null;
|
|
1809
|
+
let loopSet: FrameSet | null = null;
|
|
1810
|
+
let loopPhysics: LoopPhysics[] | null = null;
|
|
1811
|
+
if (inp.idleDuration !== null) {
|
|
1812
|
+
const render = rigc(['render', '--candidate', buildDir, '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--geometry', '--out', idleDir]);
|
|
1813
|
+
if (render.status !== 0) refuseIfAny(rigcFailed('render --animation idle --geometry', render, render.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
1814
|
+
idle = readFrameSet(idleDir);
|
|
1815
|
+
const last = idle.frames[idle.frames.length - 1];
|
|
1816
|
+
if (idle.stride !== 1 || idle.written !== idle.sampled || idle.duration !== inp.idleDuration || last.index !== idle.sampled - 1) {
|
|
1817
|
+
refuseIfAny([
|
|
1818
|
+
{
|
|
1819
|
+
code: 'CHECK_LOOP_LAST_FRAME_AT_DURATION',
|
|
1820
|
+
object: `idle frame ${last.name}`,
|
|
1821
|
+
detail: `rigc sampled ${idle.sampled} frame(s), wrote ${idle.written} at stride ${idle.stride}, the last at t = ${idle.duration}s; the loop compares frame 0 with the frame at the idle's duration, ${inp.idleDuration}s, so a duration that is a whole number of 1/${IDLE_FPS} s ticks and every frame written are required`,
|
|
1822
|
+
},
|
|
1823
|
+
]);
|
|
1824
|
+
}
|
|
1825
|
+
const contact = join(idle.dir, 'contact.png');
|
|
1826
|
+
if (existsSync(contact)) {
|
|
1827
|
+
copyFileSync(contact, join(out, 'contact.png'));
|
|
1828
|
+
written.push('contact.png');
|
|
1829
|
+
}
|
|
1830
|
+
loop = maxRgbDiff(idle.frames[0].image, last.image);
|
|
1831
|
+
loopSet = idle;
|
|
1832
|
+
// The keyed loop (issue #183): with a physics constraint, the same rig and motion with every one left out, built
|
|
1833
|
+
// the same way and rendered the same way, is what frame 0 and the frame at the duration are read from.
|
|
1834
|
+
const stripped = withoutPhysics(inp.rig, inp.motion);
|
|
1835
|
+
if (stripped !== null) {
|
|
1836
|
+
loopPhysics = stripped.physics;
|
|
1837
|
+
try {
|
|
1838
|
+
mkdirSync(loopDir, { recursive: true });
|
|
1839
|
+
const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
|
|
1840
|
+
writeFileSync(join(loopDir, 'rig.json'), JSON.stringify({ ...stripped.rig, images }));
|
|
1841
|
+
writeFileSync(join(loopDir, 'motion.json'), JSON.stringify(stripped.motion));
|
|
1842
|
+
const what = 'the idle with its physics constraint(s) left out';
|
|
1843
|
+
const lb = rigc(['build', '--rig', join(loopDir, 'rig.json'), '--motion', join(loopDir, 'motion.json'), '--out', join(loopDir, 'build'), ...packedBuildArgs(mode)]);
|
|
1844
|
+
if (lb.status !== 0) refuseIfAny(rigcFailed(`${packedBuildLabel(mode)} (${what})`, lb, buildGateLines(lb.out)));
|
|
1845
|
+
const lr = rigc(['render', '--candidate', join(loopDir, 'build'), '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--out', join(loopDir, 'frames')]);
|
|
1846
|
+
if (lr.status !== 0) refuseIfAny(rigcFailed(`render --animation idle (${what})`, lr, lr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
1847
|
+
const kept = readFrameSet(join(loopDir, 'frames'));
|
|
1848
|
+
const keptLast = kept.frames[kept.frames.length - 1];
|
|
1849
|
+
if (kept.stride !== 1 || kept.written !== kept.sampled || keptLast.index !== last.index) {
|
|
1850
|
+
refuseIfAny([
|
|
1851
|
+
{
|
|
1852
|
+
code: 'CHECK_LOOP_LAST_FRAME_AT_DURATION',
|
|
1853
|
+
object: `idle frame ${keptLast.name} (${what})`,
|
|
1854
|
+
detail: `rigc sampled ${kept.sampled} frame(s), wrote ${kept.written} at stride ${kept.stride}; the idle as written ends at ${last.name}, and the loop compares frame 0 with that frame`,
|
|
1855
|
+
},
|
|
1856
|
+
]);
|
|
1857
|
+
}
|
|
1858
|
+
loop = maxRgbDiff(kept.frames[0].image, keptLast.image);
|
|
1859
|
+
loopSet = kept;
|
|
1860
|
+
} finally {
|
|
1861
|
+
rmSync(loopDir, { recursive: true, force: true });
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
const idleImages = idle.frames.map((f) => f.image);
|
|
1865
|
+
writePng(join(out, 'motion_heat.png'), motionHeat(idleImages));
|
|
1866
|
+
written.push('motion_heat.png');
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// 3b. the parts a judgement reads alone: each rendered by its own slots, on the whole rig's grid
|
|
1870
|
+
const isolated = (label: string, ps: readonly PartRecord[]): FrameSet => {
|
|
1871
|
+
const dir = join(isoDir, label);
|
|
1872
|
+
const r = rigc(['render', '--candidate', buildDir, '--animation', 'idle', '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--slot', ps.map((p) => p.name).join(','), '--out', dir]);
|
|
1873
|
+
if (r.status !== 0) refuseIfAny(rigcFailed(`render --animation idle --slot ${ps.map((p) => p.name).join(',')}`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
1874
|
+
return readFrameSet(dir);
|
|
1875
|
+
};
|
|
1876
|
+
let breath: JudgementLine = skip(lacking);
|
|
1877
|
+
let tip: JudgementLine = skip(lacking);
|
|
1878
|
+
if (full !== null) {
|
|
1879
|
+
try {
|
|
1880
|
+
breath = breathLine(full, isolated, problems);
|
|
1881
|
+
if (idle !== null) tip = tipLine(full, join(idle.dir, GEOMETRY_FILE), idle.written, problems);
|
|
1882
|
+
} finally {
|
|
1883
|
+
rmSync(isoDir, { recursive: true, force: true });
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
let chain: JudgementLine = skip(inp.noIdle ?? '');
|
|
1887
|
+
let stretch: JudgementLine = skip(inp.noIdle ?? '');
|
|
1888
|
+
let still: JudgementLine = skip(lacking);
|
|
1889
|
+
if (idle !== null) {
|
|
1890
|
+
chain = chainLine(inp, problems);
|
|
1891
|
+
// One reader for the one geometry file: the stretch reads its meshes, the still regions every slot and the head bone.
|
|
1892
|
+
const geometryPath = join(idle.dir, GEOMETRY_FILE);
|
|
1893
|
+
const geometry = readGeometry(geometryPath, idle.written);
|
|
1894
|
+
stretch = stretchLine(geometry, geometryPath, problems);
|
|
1895
|
+
if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems);
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// 4. the setup pose as a one-key throwaway animation — the seam reads it against the parts, the source line against
|
|
1899
|
+
// the painting — and, beside it, the same pose with the eyes shut
|
|
1900
|
+
let seam: SeamFigures | null = null;
|
|
1901
|
+
let seamPairs: SeamPairRow[] | null = null;
|
|
1902
|
+
let seamViewport: Viewport | null = null;
|
|
1903
|
+
let blink: JudgementLine = skip(lacking);
|
|
1904
|
+
let sourceRead: ReportedLine | null = null;
|
|
1905
|
+
if (inp.parts !== null || inp.source !== null) {
|
|
1906
|
+
try {
|
|
1907
|
+
mkdirSync(stillDir, { recursive: true });
|
|
1908
|
+
const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
|
|
1909
|
+
writeFileSync(join(stillDir, 'rig.json'), JSON.stringify({ ...inp.rig, images }));
|
|
1910
|
+
const eyes = full === null ? [] : partsTagged(full.parts, EYE_TAGS);
|
|
1911
|
+
const slotBone = new Map((Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => [String(sl.name), String(sl.bone)]));
|
|
1912
|
+
const shut = full === null ? [] : blinkTracks(inp.motion, eyeBones(inp.rig, eyes.map((p) => slotBone.get(p.name) ?? '')));
|
|
1913
|
+
const animations: Record<string, unknown> = {
|
|
1914
|
+
still: { duration: STILL_DURATION, loop: false, tracks: [{ bone: inp.rootBone, property: 'rotate', keys: [{ t: 0, v: [0] }, { t: STILL_DURATION, v: [0] }] }] },
|
|
1915
|
+
};
|
|
1916
|
+
if (eyes.length > 0 && shut.length > 0) {
|
|
1917
|
+
animations[BLINK_ANIMATION] = {
|
|
1918
|
+
duration: STILL_DURATION,
|
|
1919
|
+
loop: false,
|
|
1920
|
+
tracks: shut.map((b) => ({ ...b.target, property: 'scaley', keys: [{ t: 0, v: [b.closed] }, { t: STILL_DURATION, v: [b.closed] }] })),
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
writeFileSync(join(stillDir, 'motion.json'), JSON.stringify({ ...inp.motion, animations }));
|
|
1924
|
+
const sb = rigc(['build', '--rig', join(stillDir, 'rig.json'), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'build'), '--profile', 'spine']);
|
|
1925
|
+
if (sb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still)', sb, buildGateLines(sb.out)));
|
|
1926
|
+
// The stage is the parts' canvas wherever parts.json is read (CHECK_RIG_STAGE_IS_THE_CANVAS), so this is its longest side.
|
|
1927
|
+
const maxSide = Math.ceil(Math.max(inp.stage.width, inp.stage.height));
|
|
1928
|
+
const sr = rigc(['render', '--candidate', join(stillDir, 'build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(stillDir, 'render')]);
|
|
1929
|
+
if (sr.status !== 0) refuseIfAny(rigcFailed('render (the setup-pose still)', sr, sr.out.split('\n').filter((l) => l.includes('FAIL'))));
|
|
1930
|
+
const stillSet = readFrameSet(join(stillDir, 'render'));
|
|
1931
|
+
seamViewport = stillSet.viewport;
|
|
1932
|
+
if (keepStills) cpSync(join(stillDir, 'render'), keptDir, { recursive: true });
|
|
1933
|
+
if (inp.parts !== null) {
|
|
1934
|
+
const composite = flatComposite(inp.parts, inp.partsDir, stillSet.background);
|
|
1935
|
+
seam = seamFigures(composite, stillSet.frames[0].image, inp.stage, stillSet.viewport, stillSet.background);
|
|
1936
|
+
if (seamPairsAsked || seamFails(round3(seam.mean), seam.over40, round6(stillSet.viewport.scale))) {
|
|
1937
|
+
const placed = inp.parts.parts.map((p) => ({ name: p.name, from: p.from, x: p.x, y: p.y, image: readPng(join(inp.partsDir, `${p.name}.png`)) }));
|
|
1938
|
+
const split = seamPairRows(seam, placed, inp.parts.rig_size, stillSet.viewport);
|
|
1939
|
+
mkdirSync(seamDir, { recursive: true });
|
|
1940
|
+
for (const { row, pixels } of split) writePng(join(out, row.picture), seamPicture(stillSet.frames[0].image, row, pixels));
|
|
1941
|
+
seamPairs = split.map((x) => x.row);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
if (inp.source !== null) {
|
|
1945
|
+
// The same pose with every slot tinted black, built with the same animations so rigc fits the same grid.
|
|
1946
|
+
writeFileSync(join(stillDir, BLACK_RIG), JSON.stringify(blackRig({ ...inp.rig, images })));
|
|
1947
|
+
const kb = rigc(['build', '--rig', join(stillDir, BLACK_RIG), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'black_build'), '--profile', 'spine']);
|
|
1948
|
+
if (kb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still, every slot tinted black)', kb, buildGateLines(kb.out)));
|
|
1949
|
+
const kr = rigc(['render', '--candidate', join(stillDir, 'black_build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(stillDir, 'black')]);
|
|
1950
|
+
if (kr.status !== 0) refuseIfAny(rigcFailed('render (the setup-pose still, every slot tinted black)', kr, kr.out.split('\n').filter((l) => l.includes('FAIL'))));
|
|
1951
|
+
const blackSet = readFrameSet(join(stillDir, 'black'));
|
|
1952
|
+
if (!sameViewport(stillSet.viewport, blackSet.viewport) || stillSet.background.join(',') !== blackSet.background.join(',')) {
|
|
1953
|
+
refuseIfAny([
|
|
1954
|
+
{
|
|
1955
|
+
code: 'CHECK_SOURCE_GRID',
|
|
1956
|
+
object: 'the black-tinted setup-pose still',
|
|
1957
|
+
detail: `is on ${JSON.stringify(blackSet.viewport)} over ${JSON.stringify(blackSet.background)}; the setup-pose still is on ${JSON.stringify(stillSet.viewport)} over ${JSON.stringify(stillSet.background)}, and the coverage is read pixel for pixel between the two`,
|
|
1958
|
+
},
|
|
1959
|
+
]);
|
|
1960
|
+
}
|
|
1961
|
+
const srcr = sourceInRig(inp.source.painting, inp.stage.width, inp.stage.height);
|
|
1962
|
+
const fig = sourceFigures(stillSet.frames[0].image, blackSet.frames[0].image, stillSet.viewport, stillSet.background, inp.stage, srcr);
|
|
1963
|
+
sourceRead = sourceLine(fig, inp.source.painting, inp.stage, stillSet.viewport);
|
|
1964
|
+
}
|
|
1965
|
+
if (full !== null) {
|
|
1966
|
+
const H = full.parts.rig_size[1];
|
|
1967
|
+
if (eyes.length === 0) blink = skip(`no part comes from a See-through ${tagWords(EYE_TAGS)} layer, so there is no eye to look behind`);
|
|
1968
|
+
else if (shut.length === 0) blink = skip(`no idle "scaley" track on the bones of ${quoteParts(eyes)} goes below its first key, so the idle has no blink`);
|
|
1969
|
+
else {
|
|
1970
|
+
const br = rigc(['render', '--candidate', join(stillDir, 'build'), '--animation', BLINK_ANIMATION, '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(stillDir, 'blink')]);
|
|
1971
|
+
if (br.status !== 0) refuseIfAny(rigcFailed(`render (the setup pose with the eyes shut, "${BLINK_ANIMATION}")`, br, br.out.split('\n').filter((l) => l.includes('FAIL'))));
|
|
1972
|
+
blink = blinkLine(full, eyes, shut, stillSet, readFrameSet(join(stillDir, 'blink')), idle as FrameSet, H, problems);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
} finally {
|
|
1976
|
+
rmSync(stillDir, { recursive: true, force: true });
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// 5. the scene's declared requirements (issue #93), only under --requirements
|
|
1981
|
+
const reqProblems: Problem[] = [];
|
|
1982
|
+
const reqBlock = reqFile === null ? null : measureRequirements(inp, reqFile, buildDir, out, rigc, reqProblems, seams ?? new Map());
|
|
1983
|
+
|
|
1984
|
+
const skipped: { loop?: string; seam?: string } = {};
|
|
1985
|
+
if (inp.noIdle !== null) skipped.loop = inp.noIdle;
|
|
1986
|
+
if (inp.noParts !== null) skipped.seam = inp.noParts;
|
|
1987
|
+
const figures: CheckFigures = {
|
|
1988
|
+
gate_spine_html_green: htmlGreen,
|
|
1989
|
+
rigc_entry: rigcEntry,
|
|
1990
|
+
pack_mode: { page_edges: mode.pageEdges, pack_shape: mode.packShape },
|
|
1991
|
+
loop_max_diff: loop === null ? null : loop.max,
|
|
1992
|
+
...(loopPhysics === null ? {} : { loop_physics: loopPhysics }),
|
|
1993
|
+
seam_mean: seam === null ? null : round3(seam.mean),
|
|
1994
|
+
seam_px_over_40: seam === null ? null : seam.over40,
|
|
1995
|
+
seam_px_over_80: seam === null ? null : seam.over80,
|
|
1996
|
+
seam_frame_scale: seam === null || seamViewport === null ? null : round6(seamViewport.scale),
|
|
1997
|
+
...(seamPairs === null ? {} : { seam_pairs: seamPairs }),
|
|
1998
|
+
BREATH_VISIBLE: breath,
|
|
1999
|
+
BLINK_NO_HOLE: blink,
|
|
2000
|
+
CHAIN_LAG: chain,
|
|
2001
|
+
TIP_OVER_ROOT: tip,
|
|
2002
|
+
STILL_REGIONS_DARK: still,
|
|
2003
|
+
TEXTURE_STRETCH: stretch,
|
|
2004
|
+
RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
|
|
2005
|
+
...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
|
|
2006
|
+
...(Object.keys(skipped).length === 0 ? {} : { skipped }),
|
|
2007
|
+
...(reqBlock === null ? {} : { requirements: reqBlock }),
|
|
2008
|
+
PASS: false,
|
|
2009
|
+
};
|
|
2010
|
+
const barProblems: Problem[] = [];
|
|
2011
|
+
if (!htmlGreen) barProblems.push(...rigcFailed(packedBuildLabel(mode), build, gateHtml));
|
|
2012
|
+
if (loop !== null && idle !== null && loopSet !== null && loop.max !== LOOP_MAX_BAR) {
|
|
2013
|
+
const last = idle.frames[idle.frames.length - 1];
|
|
2014
|
+
barProblems.push({
|
|
2015
|
+
code: 'CHECK_LOOP_CLOSES',
|
|
2016
|
+
object: `idle frame f0000.png vs ${last.name} (t = ${idle.duration}s)${loopPhysics === null ? '' : ', its physics constraint(s) left out'}`,
|
|
2017
|
+
detail: `max |d| ${loop.max}/255, first at pixel ${loop.x},${loop.y} of ${loopSet.viewport.pixelWidth}x${loopSet.viewport.pixelHeight}; ${LOOP_MAX_BAR} is required — the idle's last key must equal its first`,
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
if (figures.seam_mean !== null && figures.seam_px_over_40 !== null && figures.seam_frame_scale !== null && seamFails(figures.seam_mean, figures.seam_px_over_40, figures.seam_frame_scale)) {
|
|
2021
|
+
const worst = seamPairs === null || seamPairs.length === 0 ? null : seamPairs[0];
|
|
2022
|
+
barProblems.push({
|
|
2023
|
+
code: 'CHECK_SEAM_WITHIN_BAR',
|
|
2024
|
+
object: 'the setup-pose render vs the flat composite of parts/',
|
|
2025
|
+
detail:
|
|
2026
|
+
`mean |d| ${figures.seam_mean}/255 and ${figures.seam_px_over_40} frame px over ${SEAM_PX_LEVEL} (${figures.seam_px_over_80} over ${SEAM_PX_LEVEL_HIGH}) at frame scale ${figures.seam_frame_scale}, ${seamRigPxText(figures.seam_px_over_40, figures.seam_frame_scale)} rig px (the count / s²); mean <= ${SEAM_MEAN_BAR.toFixed(1)} and <= ${SEAM_PX_BAR} rig px over ${SEAM_PX_LEVEL} are required` +
|
|
2027
|
+
(worst === null
|
|
2028
|
+
? `; no pixel is over ${SEAM_PX_LEVEL}, so the mean alone fails and there is no pair to name`
|
|
2029
|
+
: `; worst of ${(seamPairs as SeamPairRow[]).length} pair(s): ${seamPairText(worst)} — every pair is a \`seam pair\` line above and a row of check.json's seam_pairs`),
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
const rank = (q: Problem): number => JUDGEMENT_LINES.findIndex((n) => q.code === `CHECK_${n}`);
|
|
2033
|
+
problems.sort((a, b) => rank(a) - rank(b));
|
|
2034
|
+
problems.unshift(...barProblems);
|
|
2035
|
+
problems.push(...reqProblems);
|
|
2036
|
+
figures.PASS = problems.length === 0;
|
|
2037
|
+
write('check.json', `${JSON.stringify(figures, null, 1)}\n`);
|
|
2038
|
+
const bars: CheckReport['bars'] = { measured: ['gate'], skipped: [] };
|
|
2039
|
+
const tally = (bar: string, reason: string | null): void => {
|
|
2040
|
+
if (reason === null) bars.measured.push(bar);
|
|
2041
|
+
else bars.skipped.push({ bar, reason });
|
|
2042
|
+
};
|
|
2043
|
+
tally('loop', skipped.loop ?? null);
|
|
2044
|
+
tally('seam', skipped.seam ?? null);
|
|
2045
|
+
for (const n of JUDGEMENT_LINES) {
|
|
2046
|
+
const l = figures[n];
|
|
2047
|
+
tally(n, l.status === 'SKIP' ? l.reason : null);
|
|
2048
|
+
}
|
|
2049
|
+
return {
|
|
2050
|
+
figures,
|
|
2051
|
+
gateHtml,
|
|
2052
|
+
pack,
|
|
2053
|
+
packOpaque,
|
|
2054
|
+
idle: idle === null || loop === null ? null : { frames: idle.frames.length, fps: idle.fps, duration: idle.duration, lastIndex: idle.frames[idle.frames.length - 1].index, loopAt: { x: loop.x, y: loop.y } },
|
|
2055
|
+
loopPhysics: loopPhysics === null ? null : loopPhysicsLine(loopPhysics),
|
|
2056
|
+
seamViewport,
|
|
2057
|
+
bars,
|
|
2058
|
+
problems,
|
|
2059
|
+
written,
|
|
2060
|
+
requirements: reqBlock,
|
|
2061
|
+
besideIdle: isRecord(inp.motion.animations) ? Object.keys(inp.motion.animations).filter((n) => n !== 'idle') : [],
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// ---------------------------------------------------------------------------
|
|
2066
|
+
// the scene's declared requirements (issue #93)
|
|
2067
|
+
// ---------------------------------------------------------------------------
|
|
2068
|
+
|
|
2069
|
+
/** Where `check` writes the requirement renders, under `--out`: `as-declared/<animation>/`, `released/<name>/`, `full/<name>/`. */
|
|
2070
|
+
export const REQUIREMENTS_DIR = 'requirements';
|
|
2071
|
+
|
|
2072
|
+
/** The throwaway copies' specs and builds, under `--out`; removed before the stage returns. */
|
|
2073
|
+
const REQUIREMENTS_SCRATCH = '_requirements';
|
|
2074
|
+
|
|
2075
|
+
/**
|
|
2076
|
+
* Measure every requirement of a file {@link resolveRequirements} has passed.
|
|
2077
|
+
* Every pose is rig-c's: each animation a requirement names is rendered
|
|
2078
|
+
* once with `--geometry` at the file's fps, from the rig under test's own
|
|
2079
|
+
* build — or, when the file places scene targets for it, from a copy with
|
|
2080
|
+
* them written in ({@link placeTargets}, built `--profile spine`, as the setup
|
|
2081
|
+
* still is) — into `requirements/as-declared/<animation>/`. Each `follow` adds
|
|
2082
|
+
* two copies, released and full ({@link forceMix}), the scene targets placed
|
|
2083
|
+
* on both, rendered into `requirements/released/<name>/` and
|
|
2084
|
+
* `requirements/full/<name>/`. The released copy's constraint is muted
|
|
2085
|
+
* throughout, which rig-c's A47 (ik) or A48 (transform) refuses unless
|
|
2086
|
+
* the rig declares the mix consumer-driven: when the gate refuses that copy
|
|
2087
|
+
* for that constraint and nothing else ({@link mutedOnly}), the copy — never
|
|
2088
|
+
* the rig under test — declares it in `invariants.consumerDrivenMix`, rigc's
|
|
2089
|
+
* own door, and the line says so. A copy rigc refuses otherwise is refused,
|
|
2090
|
+
* `CHECK_RIGC_GREEN`, quoting rigc. Lines go into the returned block; each
|
|
2091
|
+
* FAIL and NOT MEASURABLE into `problems`.
|
|
2092
|
+
*/
|
|
2093
|
+
export function measureRequirements(inp: CheckInputs, file: RequirementsFile, buildDir: string, out: string, rigc: RigcRunner, problems: Problem[], seams: ReadonlyMap<string, readonly SeamPair[]> = new Map()): RequirementsBlock {
|
|
2094
|
+
const reqDir = join(out, REQUIREMENTS_DIR);
|
|
2095
|
+
const scratch = join(out, REQUIREMENTS_SCRATCH);
|
|
2096
|
+
for (const d of [reqDir, scratch]) rmSync(d, { recursive: true, force: true });
|
|
2097
|
+
mkdirSync(reqDir, { recursive: true });
|
|
2098
|
+
mkdirSync(scratch, { recursive: true });
|
|
2099
|
+
const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
|
|
2100
|
+
const base = { ...inp.rig, images };
|
|
2101
|
+
let copies = 0;
|
|
2102
|
+
const buildCopy = (rig: Record<string, unknown>, motion: Record<string, unknown>): { dir: string; call: RigcCall } => {
|
|
2103
|
+
const dir = join(scratch, `copy${copies++}`);
|
|
2104
|
+
mkdirSync(dir, { recursive: true });
|
|
2105
|
+
writeFileSync(join(dir, 'rig.json'), JSON.stringify(rig));
|
|
2106
|
+
writeFileSync(join(dir, 'motion.json'), JSON.stringify(motion));
|
|
2107
|
+
return { dir: join(dir, 'build'), call: rigc(['build', '--rig', join(dir, 'rig.json'), '--motion', join(dir, 'motion.json'), '--out', join(dir, 'build'), '--profile', 'spine']) };
|
|
2108
|
+
};
|
|
2109
|
+
const green = (b: { dir: string; call: RigcCall }, what: string): string => {
|
|
2110
|
+
if (b.call.status !== 0) refuseIfAny(rigcFailed(`build (${what})`, b.call, buildGateLines(b.call.out)));
|
|
2111
|
+
return b.dir;
|
|
2112
|
+
};
|
|
2113
|
+
const posesOf = (candidate: string, animation: string, dir: string, label: string): { poses: Poses; geo: IdleGeometry; path: string } => {
|
|
2114
|
+
const r = rigc(['render', '--candidate', candidate, '--animation', animation, '--fps', String(file.fps), '--max', String(IDLE_MAX_PX), '--geometry', '--out', dir]);
|
|
2115
|
+
if (r.status !== 0) refuseIfAny(rigcFailed(`render --animation ${animation} --geometry (${label})`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
2116
|
+
const set = readFrameSet(dir);
|
|
2117
|
+
if (set.written !== set.sampled || set.stride !== 1) {
|
|
2118
|
+
refuseIfAny([{ code: 'CHECK_REQUIREMENTS_FRAMES', object: `${dir} (${label})`, detail: `rigc sampled ${set.sampled} frame(s) and wrote ${set.written} at stride ${set.stride}; every sampled frame is required, since a requirement holds over all of them` }]);
|
|
2119
|
+
}
|
|
2120
|
+
const path = join(set.dir, GEOMETRY_FILE);
|
|
2121
|
+
const geo = readGeometry(path, set.written);
|
|
2122
|
+
if (geo === null) return geometryProblem(path, 'does not exist; `rigc render --geometry` writes it, and every requirement is measured from it');
|
|
2123
|
+
const cache = new Map<string, { setup: BoneWorld; frames: BoneWorld[] }>();
|
|
2124
|
+
const bone = (name: string): { setup: BoneWorld; frames: BoneWorld[] } => {
|
|
2125
|
+
const hit = cache.get(name);
|
|
2126
|
+
if (hit !== undefined) return hit;
|
|
2127
|
+
const t = boneTrackOf(geo, path, set, name);
|
|
2128
|
+
if (typeof t === 'string') return geometryProblem(path, t);
|
|
2129
|
+
const v = { setup: t.setup, frames: t.frames };
|
|
2130
|
+
cache.set(name, v);
|
|
2131
|
+
return v;
|
|
2132
|
+
};
|
|
2133
|
+
const root = boneTrackOf(geo, path, set, inp.rootBone);
|
|
2134
|
+
if (typeof root === 'string') return geometryProblem(path, root);
|
|
2135
|
+
return { poses: { label, indices: geo.frames.map((f) => f.index), times: root.times, bone }, geo, path };
|
|
2136
|
+
};
|
|
2137
|
+
|
|
2138
|
+
const lines: Record<string, RequirementLine> = {};
|
|
2139
|
+
try {
|
|
2140
|
+
const placed = new Map<string, { rig: Record<string, unknown>; motion: Record<string, unknown> }>();
|
|
2141
|
+
const placedFor = (animation: string): { rig: Record<string, unknown>; motion: Record<string, unknown> } => {
|
|
2142
|
+
let p = placed.get(animation);
|
|
2143
|
+
if (p === undefined) {
|
|
2144
|
+
p = placeTargets(base, inp.motion, file.targets, animation, inp.stage);
|
|
2145
|
+
placed.set(animation, p);
|
|
2146
|
+
}
|
|
2147
|
+
return p;
|
|
2148
|
+
};
|
|
2149
|
+
const declared = new Map<string, { poses: Poses; geo: IdleGeometry; path: string }>();
|
|
2150
|
+
for (const r of file.requirements) {
|
|
2151
|
+
if (declared.has(r.animation)) continue;
|
|
2152
|
+
const scene = file.targets.some((t) => t.animation === r.animation);
|
|
2153
|
+
const label = scene ? `as declared, the scene targets of "${r.animation}" placed` : 'as declared';
|
|
2154
|
+
const candidate = scene ? green(buildCopy(placedFor(r.animation).rig, placedFor(r.animation).motion), `the rig ${label}`) : buildDir;
|
|
2155
|
+
declared.set(r.animation, posesOf(candidate, r.animation, join(reqDir, 'as-declared', r.animation), label));
|
|
2156
|
+
}
|
|
2157
|
+
for (const r of file.requirements) {
|
|
2158
|
+
const d = declared.get(r.animation) as { poses: Poses; geo: IdleGeometry; path: string };
|
|
2159
|
+
const scene = placedFor(r.animation);
|
|
2160
|
+
const facts = rigFacts(scene.rig, inp.stage);
|
|
2161
|
+
let line: RequirementLine;
|
|
2162
|
+
if (r.kind === 'contact') line = contactLine(r, d.poses, facts);
|
|
2163
|
+
else if (r.kind === 'aim') line = aimLine(r, d.poses, facts);
|
|
2164
|
+
else if (r.kind === 'range') line = rangeLine(r, d.poses, facts);
|
|
2165
|
+
else if (r.kind === 'stretch') line = stretchRequirementLine(r, d.geo.meshes, d.geo.frames, d.poses);
|
|
2166
|
+
else if (r.kind === 'seam') {
|
|
2167
|
+
const shown = attachmentPoses(d.path, d.poses.indices.length);
|
|
2168
|
+
const frames: SeamFrame[] = shown.map((f) => ({ part: f.get(r.part) ?? null, neighbour: f.get(r.neighbour) ?? null }));
|
|
2169
|
+
line = seamLine(r, seams.get(r.name) ?? [], frames, d.poses, inp.stage);
|
|
2170
|
+
} else {
|
|
2171
|
+
const rel = forceMix(scene.rig, scene.motion, r, 0);
|
|
2172
|
+
let relBuild = buildCopy(rel.rig, rel.motion);
|
|
2173
|
+
let door: string | null = null;
|
|
2174
|
+
if (relBuild.call.status !== 0 && mutedOnly(buildGateLines(relBuild.call.out).filter((l) => l.includes('FAIL')).map((l) => l.trim()), r)) {
|
|
2175
|
+
relBuild = buildCopy(declareConsumerDriven(rel.rig, r), rel.motion);
|
|
2176
|
+
door = `${mutedLine(r).rule} refused the released copy (its mix is 0 throughout), so that copy alone declares ${r.constraint_type} constraint "${r.constraint}" in invariants.consumerDrivenMix`;
|
|
2177
|
+
}
|
|
2178
|
+
const released = posesOf(green(relBuild, `the released copy of "${r.name}": ${r.constraint_type} constraint "${r.constraint}" ${r.property} mix forced to 0`), r.animation, join(reqDir, 'released', r.name), 'released');
|
|
2179
|
+
const fullCopy = forceMix(scene.rig, scene.motion, r, 1);
|
|
2180
|
+
const full = posesOf(green(buildCopy(fullCopy.rig, fullCopy.motion), `the full copy of "${r.name}": ${r.constraint_type} constraint "${r.constraint}" ${r.property} mix forced to 1`), r.animation, join(reqDir, 'full', r.name), 'full');
|
|
2181
|
+
const same = (a: Poses, b: Poses): boolean => a.times.length === b.times.length && a.times.every((t, i) => t === b.times[i] && a.indices[i] === b.indices[i]);
|
|
2182
|
+
if (!same(d.poses, released.poses) || !same(d.poses, full.poses)) {
|
|
2183
|
+
refuseIfAny([{ code: 'CHECK_REQUIREMENTS_FRAMES', object: `requirement "${r.name}"`, detail: `the as-declared, released and full renders of "${r.animation}" sample frames at [${d.poses.times.join(', ')}], [${released.poses.times.join(', ')}] and [${full.poses.times.join(', ')}] s; the follow is read frame by frame across the three, so the same frames are required` }]);
|
|
2184
|
+
}
|
|
2185
|
+
line = { ...followLine(r, d.poses, released.poses, full.poses), released_copy: door ?? 'built as forced: rig-c\'s gate passed it with no further declaration' };
|
|
2186
|
+
}
|
|
2187
|
+
lines[r.name] = line;
|
|
2188
|
+
const p = requirementProblem(r, line);
|
|
2189
|
+
if (p !== null) problems.push(p);
|
|
2190
|
+
}
|
|
2191
|
+
} finally {
|
|
2192
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
2193
|
+
}
|
|
2194
|
+
return { fps: file.fps, summary: requirementsSummary(file, new Map(Object.entries(lines))), lines };
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// ---------------------------------------------------------------------------
|
|
2198
|
+
// the judgement lines (issue #11)
|
|
2199
|
+
// ---------------------------------------------------------------------------
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* `RECOMPOSITE_HOLES`: `parts.json`'s `recomposite` block as a line — the
|
|
2203
|
+
* uncovered error pixels, how many holes they make, and the largest hole's box
|
|
2204
|
+
* and bordering parts. Read, not measured: the block is what assemble measured
|
|
2205
|
+
* on the painting, which check never sees. REPORTED, never FAIL, because no
|
|
2206
|
+
* bar for a hole is derivable by the rule the judgement bars follow: both
|
|
2207
|
+
* public examples hold hundreds of holes of a few dozen pixels along part
|
|
2208
|
+
* edges (453 on demo, the largest 70 px; 259 on sample, the largest 94 px),
|
|
2209
|
+
* and whether a hole matters depends on where it is — which the box and the
|
|
2210
|
+
* bordering parts say and a count cannot (AUTHORING §7).
|
|
2211
|
+
*/
|
|
2212
|
+
export function holesLine(parts: PartsFile): ReportedLine {
|
|
2213
|
+
const r = parts.recomposite;
|
|
2214
|
+
if (r === undefined) return { status: 'SKIP', reason: 'parts.json has no "recomposite" block (a parts.json the reference wrote has none); re-run assemble, which writes it' };
|
|
2215
|
+
return {
|
|
2216
|
+
status: 'REPORTED',
|
|
2217
|
+
error_px: r.error_px,
|
|
2218
|
+
uncovered_error_px: r.uncovered_error_px,
|
|
2219
|
+
hole_count: r.hole_count,
|
|
2220
|
+
largest: r.holes.length === 0 ? null : { px: r.holes[0].px, box: boxText(r.holes[0]), borders: r.holes[0].borders.map((b) => `${b.part} ${b.px} px`) },
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function boxText(h: { x: number; y: number; w: number; h: number }): string {
|
|
2225
|
+
return `${h.x},${h.y} ${h.w}x${h.h}`;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/** The throwaway animation that holds the blink tracks at their closed value, rendered beside the setup-pose still. */
|
|
2229
|
+
export const BLINK_ANIMATION = 'blink_shut';
|
|
2230
|
+
|
|
2231
|
+
function skip(reason: string): JudgementLine {
|
|
2232
|
+
return { status: 'SKIP', reason };
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function tagWords(tags: readonly BaseTag[]): string {
|
|
2236
|
+
return tags.map((t) => `"${t}"`).join(' or ');
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
function quoteParts(ps: readonly PartRecord[]): string {
|
|
2240
|
+
return ps.map((p) => `"${p.name}"`).join(', ');
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
type Isolate = (label: string, ps: readonly PartRecord[]) => FrameSet;
|
|
2244
|
+
|
|
2245
|
+
/** (1) The torso moves while the feet do not: each rendered alone, heat over its own box. */
|
|
2246
|
+
function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[]): JudgementLine {
|
|
2247
|
+
const torso = partsTagged(inp.parts, TORSO_TAGS);
|
|
2248
|
+
const feet = partsTagged(inp.parts, FEET_TAGS);
|
|
2249
|
+
if (torso.length === 0) return skip(`no part comes from a See-through ${tagWords(TORSO_TAGS)} layer, so there is no torso to see breathe`);
|
|
2250
|
+
if (feet.length === 0) return skip(`no part comes from a See-through ${tagWords(FEET_TAGS)} layer, so there are no feet to hold still`);
|
|
2251
|
+
const H = inp.parts.rig_size[1];
|
|
2252
|
+
const ts = isolated('torso', torso);
|
|
2253
|
+
const fs = isolated('feet', feet);
|
|
2254
|
+
const tb = frameBox(torso, H, inp.stage, ts.viewport);
|
|
2255
|
+
const fb = frameBox(feet, H, inp.stage, fs.viewport);
|
|
2256
|
+
if (tb === null || fb === null) return skip(`the ${tb === null ? 'torso' : 'feet'} box falls outside the idle frame`);
|
|
2257
|
+
const th = regionHeat(heatField(ts.frames.map((f) => f.image)), ts.viewport.pixelWidth, tb);
|
|
2258
|
+
const fh = regionHeat(heatField(fs.frames.map((f) => f.image)), fs.viewport.pixelWidth, fb);
|
|
2259
|
+
const mean = round3(th.mean);
|
|
2260
|
+
const ok = mean >= BREATH_TORSO_MEAN_FLOOR && fh.max <= BREATH_FEET_MAX_CEILING;
|
|
2261
|
+
if (mean < BREATH_TORSO_MEAN_FLOOR) {
|
|
2262
|
+
problems.push({
|
|
2263
|
+
code: 'CHECK_BREATH_VISIBLE',
|
|
2264
|
+
object: `torso ${quoteParts(torso)} rendered alone, frame box ${boxLabel(tb)}`,
|
|
2265
|
+
detail: `heat mean ${mean}/255 over the idle; >= ${BREATH_TORSO_MEAN_FLOOR} is required — the chest's breath track moves too little, or the torso is not weighted to the bone it keys`,
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
if (fh.max > BREATH_FEET_MAX_CEILING) {
|
|
2269
|
+
problems.push({
|
|
2270
|
+
code: 'CHECK_BREATH_VISIBLE',
|
|
2271
|
+
object: `feet ${quoteParts(feet)} rendered alone, frame box ${boxLabel(fb)}`,
|
|
2272
|
+
detail: `heat max ${fh.max}/255 over the idle; <= ${BREATH_FEET_MAX_CEILING} is required — the feet move: their slot rides, or their mesh is weighted to, a bone the idle keys`,
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
return {
|
|
2276
|
+
status: ok ? 'PASS' : 'FAIL',
|
|
2277
|
+
torso_parts: torso.map((p) => p.name),
|
|
2278
|
+
torso_heat_mean: mean,
|
|
2279
|
+
torso_floor: BREATH_TORSO_MEAN_FLOOR,
|
|
2280
|
+
feet_parts: feet.map((p) => p.name),
|
|
2281
|
+
feet_heat_max: fh.max,
|
|
2282
|
+
feet_ceiling: BREATH_FEET_MAX_CEILING,
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
/**
|
|
2287
|
+
* What `TIP_OVER_ROOT` is measured with, written into its line (issue #118):
|
|
2288
|
+
* the idle's posed geometry, not a render of the part. Until #118 the line
|
|
2289
|
+
* read rendered pixels — figures in frame px under `root_px`/`tip_px` — so an
|
|
2290
|
+
* old and a new check.json are told apart by this key and by the units in
|
|
2291
|
+
* the field names.
|
|
2292
|
+
*/
|
|
2293
|
+
export const TIP_INSTRUMENT = 'geometry';
|
|
2294
|
+
|
|
2295
|
+
/**
|
|
2296
|
+
* (4) Each swinging part's tip travels further than its root: how far the art
|
|
2297
|
+
* in the lower half of the part's box travels against the upper half — the
|
|
2298
|
+
* centroid of the art's area inside each fixed half, through the posed mesh
|
|
2299
|
+
* ({@link halfTravels}) — read off the idle's `geometry.json`. Until issue
|
|
2300
|
+
* #118 the halves were read off the part rendered alone at `--max 640`, a
|
|
2301
|
+
* binary-coverage centroid per half; every root half on the three examples
|
|
2302
|
+
* travels under a frame pixel, where that centroid scatters by up to 0.3 px
|
|
2303
|
+
* with the grid, so the ratio was set by the grid.
|
|
2304
|
+
*/
|
|
2305
|
+
function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[]): JudgementLine {
|
|
2306
|
+
const swing = partsTagged(inp.parts, SWING_TAGS);
|
|
2307
|
+
if (swing.length === 0) return skip(`no part comes from a See-through ${tagWords(SWING_TAGS)} layer, so there is no hem or sleeve to swing`);
|
|
2308
|
+
const poses = attachmentPoses(geometryPath, frames);
|
|
2309
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
2310
|
+
let measured = 0;
|
|
2311
|
+
let failed = false;
|
|
2312
|
+
for (const p of swing) {
|
|
2313
|
+
const art = placedArt(p.x, p.y, readPng(join(inp.partsDir, `${p.name}.png`)));
|
|
2314
|
+
const shown = poses.map((f) => f.get(p.name) ?? null);
|
|
2315
|
+
const t = halfTravels(art, p, inp.stage, shown);
|
|
2316
|
+
if (t.root === null || t.tip === null) {
|
|
2317
|
+
rows.push({ part: p.name, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${t.root === null ? 'upper' : 'lower'} half of its box holds no art its attachment draws` });
|
|
2318
|
+
continue;
|
|
2319
|
+
}
|
|
2320
|
+
measured++;
|
|
2321
|
+
const ratio = t.root > 0 ? round3(t.tip / t.root) : null;
|
|
2322
|
+
const ok = ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR;
|
|
2323
|
+
rows.push({ part: p.name, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
|
|
2324
|
+
if (!ok) {
|
|
2325
|
+
failed = true;
|
|
2326
|
+
problems.push({
|
|
2327
|
+
code: 'CHECK_TIP_OVER_ROOT',
|
|
2328
|
+
object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry`,
|
|
2329
|
+
detail: `the art in its lower half travels ${round3(t.tip)} rig px and in its upper half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain`,
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
if (measured === 0) return { status: 'SKIP', reason: `none of ${quoteParts(swing)} could be measured: ${rows.map((r) => `${String(r.part)}: ${String(r.unmeasured)}`).join('; ')}` };
|
|
2334
|
+
return { status: failed ? 'FAIL' : 'PASS', instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/**
|
|
2338
|
+
* The bone the face is measured in the frame of (issue #33): the bone every
|
|
2339
|
+
* `face`-tagged part's slot rides in rig.json — which is the bone the config's
|
|
2340
|
+
* `regions.<part>` (or, for a mesh, its first segment) names for it, carried
|
|
2341
|
+
* into the rig spec by the rig stage. Never a bone found by its name.
|
|
2342
|
+
*/
|
|
2343
|
+
export type HeadBone = { bone: string } | { none: string };
|
|
2344
|
+
|
|
2345
|
+
export function headBoneOf(inp: Pick<FullInputs, 'rig' | 'parts'>): HeadBone {
|
|
2346
|
+
const face = partsTagged(inp.parts, FACE_TAGS);
|
|
2347
|
+
if (face.length === 0) return { none: `no part comes from a See-through ${tagWords(FACE_TAGS)} layer` };
|
|
2348
|
+
const slotBone = new Map((Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => [String(sl.name), typeof sl.bone === 'string' ? sl.bone : null]));
|
|
2349
|
+
const bones = [...new Set(face.map((p) => slotBone.get(p.name) ?? null))];
|
|
2350
|
+
if (bones.length === 1 && bones[0] !== null) return { bone: bones[0] };
|
|
2351
|
+
return { none: `the face parts ride ${face.map((p) => `"${p.name}" on ${JSON.stringify(slotBone.get(p.name) ?? null)}`).join(', ')}; the head frame is one bone's, so they must share a slot bone` };
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/**
|
|
2355
|
+
* One bone's world transform at the setup pose and on every frame of a set,
|
|
2356
|
+
* off the geometry {@link readGeometry} read beside those frames. A string
|
|
2357
|
+
* (the reason) when the set has no geometry file; a `CHECK_GEOMETRY_FILE`
|
|
2358
|
+
* refusal when the file disagrees with the frames it sits beside in what the
|
|
2359
|
+
* face half needs and the stretch does not read: the viewport (the transforms
|
|
2360
|
+
* would be carried onto another grid), the frame indices, the bone on the
|
|
2361
|
+
* setup pose and on every frame with six finite numbers, and each frame's time.
|
|
2362
|
+
*/
|
|
2363
|
+
export function boneTrackOf(geo: IdleGeometry | null, path: string, set: FrameSet, bone: string): BoneTrack | string {
|
|
2364
|
+
if (geo === null) return `${path} does not exist, so no bone's per-frame transform is known (rigc render writes it with --geometry)`;
|
|
2365
|
+
const gv = geo.viewport;
|
|
2366
|
+
const vp = set.viewport;
|
|
2367
|
+
const keys: Array<keyof Viewport> = ['x', 'y', 'width', 'height', 'scale', 'pixelWidth', 'pixelHeight'];
|
|
2368
|
+
const off = keys.filter((k) => gv?.[k] !== vp[k]);
|
|
2369
|
+
if (off.length > 0) return geometryProblem(path, `viewport ${off.map((k) => `${k} ${JSON.stringify(gv?.[k])}`).join(', ')}; the frames.json beside it says ${off.map((k) => `${k} ${vp[k]}`).join(', ')} — the transforms would be carried onto another grid`);
|
|
2370
|
+
const world = (bones: unknown, at: string): BoneWorld => {
|
|
2371
|
+
const b = Array.isArray(bones) ? bones.filter(isRecord).find((x) => x.name === bone) : undefined;
|
|
2372
|
+
if (b === undefined) return geometryProblem(path, `${at} has no bone "${bone}"`);
|
|
2373
|
+
const f = ['a', 'b', 'c', 'd', 'worldX', 'worldY'] as const;
|
|
2374
|
+
const bad = f.filter((k) => typeof b[k] !== 'number' || !Number.isFinite(b[k]));
|
|
2375
|
+
if (bad.length > 0) return geometryProblem(path, `${at} bone "${bone}" field(s) ${bad.map((k) => `"${k}" ${JSON.stringify(b[k])}`).join(', ')}; finite numbers are required`);
|
|
2376
|
+
return { a: b.a as number, b: b.b as number, c: b.c as number, d: b.d as number, worldX: b.worldX as number, worldY: b.worldY as number };
|
|
2377
|
+
};
|
|
2378
|
+
if (geo.frames.length !== set.frames.length || geo.frames.some((f, i) => f.index !== set.frames[i].index)) {
|
|
2379
|
+
return geometryProblem(path, `holds frame(s) [${geo.frames.map((f) => String(f.index)).join(', ')}]; the set holds [${set.frames.map((f) => f.index).join(', ')}]`);
|
|
2380
|
+
}
|
|
2381
|
+
const badTime = geo.times.findIndex((t) => typeof t !== 'number' || !Number.isFinite(t));
|
|
2382
|
+
if (badTime >= 0) return geometryProblem(path, `frame ${String(geo.frames[badTime].index)} field "time" is ${JSON.stringify(geo.times[badTime])}; a number of seconds is required`);
|
|
2383
|
+
return { bone, setup: world(geo.setupBones, 'setup'), frames: geo.frameBones.map((bs, i) => world(bs, `frame ${String(geo.frames[i].index)}`)), times: geo.times as number[] };
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/** {@link readGeometry} then {@link boneTrackOf}, for a frame set's own `geometry.json`. */
|
|
2387
|
+
export function readBoneTrack(set: FrameSet, bone: string): BoneTrack | string {
|
|
2388
|
+
const path = join(set.dir, GEOMETRY_FILE);
|
|
2389
|
+
return boneTrackOf(readGeometry(path, set.written), path, set, bone);
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
/**
|
|
2393
|
+
* What `STILL_REGIONS_DARK` is measured with, written into each half (issue
|
|
2394
|
+
* #123): the idle's posed geometry, not its frames. Until #123 the face half
|
|
2395
|
+
* compared a heat mean in the head's frame with twice the resampler's own and
|
|
2396
|
+
* the feet half a screen-space heat mean with a ceiling, both read off the
|
|
2397
|
+
* idle's pixels, so an old and a new check.json are told apart by this key.
|
|
2398
|
+
*/
|
|
2399
|
+
export const STILL_INSTRUMENT = 'geometry';
|
|
2400
|
+
|
|
2401
|
+
/** Four significant figures: a figure that may be a rounding residue near 1e-12 keeps its size rather than reading 0. */
|
|
2402
|
+
function sig4(x: number): number {
|
|
2403
|
+
return x === 0 ? 0 : Number(x.toPrecision(4));
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/**
|
|
2407
|
+
* The slots the idle moves on purpose, by slot rather than by bone: every slot
|
|
2408
|
+
* a `deform` or `sequence` entry or a slot-targeted track of the idle names.
|
|
2409
|
+
* A still-set candidate rides the head bone alone, so a bone track cannot move
|
|
2410
|
+
* it relative to that bone; what can is a key on the slot itself.
|
|
2411
|
+
*/
|
|
2412
|
+
function idleDrivenSlots(motion: Record<string, unknown>): Set<string> {
|
|
2413
|
+
const anims = isRecord(motion.animations) ? motion.animations : {};
|
|
2414
|
+
const idle = isRecord(anims.idle) ? anims.idle : {};
|
|
2415
|
+
const named = new Set<string>();
|
|
2416
|
+
for (const key of ['deform', 'sequence', 'tracks']) {
|
|
2417
|
+
const list = idle[key];
|
|
2418
|
+
if (!Array.isArray(list)) continue;
|
|
2419
|
+
for (const t of list) if (isRecord(t) && typeof t.slot === 'string') named.add(t.slot);
|
|
2420
|
+
}
|
|
2421
|
+
return named;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/**
|
|
2425
|
+
* Whether every attachment the default skin gives a slot is carried by one
|
|
2426
|
+
* bone alone: a region or an unweighted mesh on a slot of that bone, or a mesh
|
|
2427
|
+
* whose every weight names it.
|
|
2428
|
+
*/
|
|
2429
|
+
function ridesOnly(rig: Record<string, unknown>, slot: string, slotBone: string | undefined, bone: string): boolean {
|
|
2430
|
+
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
2431
|
+
const skin = isRecord(skins.default) ? skins.default : {};
|
|
2432
|
+
const atts = isRecord(skin[slot]) ? Object.values(skin[slot]) : [];
|
|
2433
|
+
if (atts.length === 0) return false;
|
|
2434
|
+
return atts.every((a) => {
|
|
2435
|
+
if (!isRecord(a)) return false;
|
|
2436
|
+
const type = a.type ?? 'region';
|
|
2437
|
+
if (type === 'region') return slotBone === bone;
|
|
2438
|
+
if (type !== 'mesh') return false;
|
|
2439
|
+
if (!Array.isArray(a.weights)) return slotBone === bone;
|
|
2440
|
+
return a.weights.every((v) => Array.isArray(v) && v.length > 0 && v.every((w) => isRecord(w) && w.bone === bone));
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
/** One half's figures (issue #123): the still set's displacement in the reference frame, its tolerance, and the reported crossings. */
|
|
2445
|
+
interface StillHalf {
|
|
2446
|
+
figures: Record<string, unknown>;
|
|
2447
|
+
over: Array<{ slot: string; max: number; frame: number }>;
|
|
2448
|
+
rms: number;
|
|
2449
|
+
tolerance: number;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/**
|
|
2453
|
+
* (7) Nothing moves that should hold still (issue #123), read off the idle's
|
|
2454
|
+
* posed geometry. The face half asks whether the face and the parts drawn
|
|
2455
|
+
* over it that the head carries keep their place relative to the head bone —
|
|
2456
|
+
* the bone the face's slot rides — over the idle; the feet half whether the
|
|
2457
|
+
* feet keep theirs on the screen (they ride the root, which the idle does not
|
|
2458
|
+
* key, so the screen is their own frame).
|
|
2459
|
+
*
|
|
2460
|
+
* The face's still set, derived from the rig and the motion, never from a
|
|
2461
|
+
* list: the `face` slots, and every slot drawn after the first of them whose
|
|
2462
|
+
* art overlaps a face part's art at the setup pose, whose every attachment the
|
|
2463
|
+
* head bone alone carries ({@link ridesOnly}), and which the idle does not key
|
|
2464
|
+
* by slot ({@link idleDrivenSlots}, reported as `driven_slots`). The features
|
|
2465
|
+
* the blink and the brows move ride bones of their own, so they are not in it.
|
|
2466
|
+
* The feet's still set is the `footwear` slots.
|
|
2467
|
+
*
|
|
2468
|
+
* Each still slot's art is carried through its posed attachment back into the
|
|
2469
|
+
* reference frame on every idle frame and compared with its setup position
|
|
2470
|
+
* ({@link stillReading}); the figure is the largest displacement, with the RMS
|
|
2471
|
+
* beside it, and the bar is the arithmetic's own tolerance
|
|
2472
|
+
* ({@link stillTolerance}) — nothing may move. How far another slot's art
|
|
2473
|
+
* swings into the region (`face` on top at the setup pose, clear of where the
|
|
2474
|
+
* features go; `footwear` on top) is reported per slot, `crossing`, with no
|
|
2475
|
+
* bar ({@link crossingOf}). No render grid enters either half.
|
|
2476
|
+
*/
|
|
2477
|
+
export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | null, geometryPath: string, problems: Problem[]): JudgementLine {
|
|
2478
|
+
const face = partsTagged(inp.parts, FACE_TAGS);
|
|
2479
|
+
const feet = partsTagged(inp.parts, FEET_TAGS);
|
|
2480
|
+
if (face.length === 0 && feet.length === 0) return skip(`no part comes from a See-through ${tagWords([...FACE_TAGS, ...FEET_TAGS])} layer, so there is no still region to read`);
|
|
2481
|
+
if (geo === null) return skip(`${geometryPath} does not exist; \`rigc render --geometry\` (rig-c ${RIGC_GEOMETRY_VERSION} or later) writes it, and the still regions are read off it`);
|
|
2482
|
+
const H = inp.parts.rig_size[1];
|
|
2483
|
+
const W = inp.parts.rig_size[0];
|
|
2484
|
+
const poses = attachmentPoses(geometryPath, idle.written);
|
|
2485
|
+
const top = topmostIndex(inp.parts, (i) => readPng(join(inp.partsDir, `${inp.parts.parts[i].name}.png`)));
|
|
2486
|
+
const index = new Map(inp.parts.parts.map((p, i) => [p.name, i]));
|
|
2487
|
+
const record = new Map(inp.parts.parts.map((p) => [p.name, p]));
|
|
2488
|
+
const slots = (Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => ({ name: String(sl.name), bone: typeof sl.bone === 'string' ? sl.bone : undefined }));
|
|
2489
|
+
const slotBone = new Map(slots.map((sl) => [sl.name, sl.bone]));
|
|
2490
|
+
const arts = new Map<string, ArtMask>();
|
|
2491
|
+
const artOf = (name: string): ArtMask => {
|
|
2492
|
+
let a = arts.get(name);
|
|
2493
|
+
if (a === undefined) {
|
|
2494
|
+
const p = record.get(name) as PartRecord;
|
|
2495
|
+
a = placedArt(p.x, p.y, readPng(join(inp.partsDir, `${name}.png`)));
|
|
2496
|
+
arts.set(name, a);
|
|
2497
|
+
}
|
|
2498
|
+
return a;
|
|
2499
|
+
};
|
|
2500
|
+
const shownOf = (slot: string): Array<PosedTriangles | null> => poses.map((f) => f.get(slot) ?? null);
|
|
2501
|
+
const frameIndex = (k: number | null): number | null => (k === null ? null : idle.frames[k].index);
|
|
2502
|
+
|
|
2503
|
+
/** The still set measured in one frame, and every later slot's crossing into `region`. */
|
|
2504
|
+
const half = (still: readonly string[], firstSlot: number, maps: readonly FrameMap[], entryMax: number, coordFloor: number, region: RigRegion): StillHalf => {
|
|
2505
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
2506
|
+
const over: StillHalf['over'] = [];
|
|
2507
|
+
let worst: { slot: string; max: number; frame: number | null } | null = null;
|
|
2508
|
+
let sumSq = 0;
|
|
2509
|
+
let weight = 0;
|
|
2510
|
+
let coordMax = coordFloor;
|
|
2511
|
+
const readings = still.map((slot) => ({ slot, r: stillReading(artOf(slot), inp.stage, shownOf(slot), maps) }));
|
|
2512
|
+
for (const { r } of readings) coordMax = Math.max(coordMax, r.coordMax);
|
|
2513
|
+
const tolerance = stillTolerance(coordMax, entryMax);
|
|
2514
|
+
for (const { slot, r } of readings) {
|
|
2515
|
+
if (r.max === null) {
|
|
2516
|
+
rows.push({ slot, unmeasured: 'no idle frame shows it' });
|
|
2517
|
+
continue;
|
|
2518
|
+
}
|
|
2519
|
+
sumSq += r.sumSq;
|
|
2520
|
+
weight += r.weight;
|
|
2521
|
+
rows.push({ slot, max_rig_px: sig4(r.max), rms_rig_px: sig4(r.weight > 0 ? Math.sqrt(r.sumSq / r.weight) : 0) });
|
|
2522
|
+
if (worst === null || r.max > worst.max) worst = { slot, max: r.max, frame: frameIndex(r.frame) };
|
|
2523
|
+
if (r.max > tolerance) over.push({ slot, max: r.max, frame: frameIndex(r.frame) as number });
|
|
2524
|
+
}
|
|
2525
|
+
const crossing: Array<Record<string, unknown>> = [];
|
|
2526
|
+
for (const sl of slots.slice(firstSlot + 1)) {
|
|
2527
|
+
if (still.includes(sl.name) || !record.has(sl.name)) continue;
|
|
2528
|
+
const c = crossingOf(artOf(sl.name), inp.stage, shownOf(sl.name), maps, region);
|
|
2529
|
+
// Art that only touches the region within the arithmetic's tolerance has not entered it.
|
|
2530
|
+
if (c !== null && c.depth > tolerance) crossing.push({ slot: sl.name, depth_rig_px: round3(c.depth), area_rig_px2: round3(c.area), frame: frameIndex(c.frame) });
|
|
2531
|
+
}
|
|
2532
|
+
const rms = weight > 0 ? Math.sqrt(sumSq / weight) : 0;
|
|
2533
|
+
return {
|
|
2534
|
+
figures: {
|
|
2535
|
+
instrument: STILL_INSTRUMENT,
|
|
2536
|
+
still_slots: rows,
|
|
2537
|
+
max_rig_px: worst === null ? null : sig4(worst.max),
|
|
2538
|
+
// Where the largest displacement is, only when it is a motion: below the tolerance it is rounding, and its place is noise.
|
|
2539
|
+
at: worst === null || worst.max <= tolerance ? null : { slot: worst.slot, frame: worst.frame },
|
|
2540
|
+
rms_rig_px: sig4(rms),
|
|
2541
|
+
tolerance_rig_px: sig4(tolerance),
|
|
2542
|
+
crossing,
|
|
2543
|
+
},
|
|
2544
|
+
over,
|
|
2545
|
+
rms,
|
|
2546
|
+
tolerance,
|
|
2547
|
+
};
|
|
2548
|
+
};
|
|
2549
|
+
const regionOf = (idx: ReadonlySet<number>, exclude: readonly WorldBox[]): RigRegion => {
|
|
2550
|
+
const data = new Uint8Array(W * H);
|
|
2551
|
+
for (let v = 0; v < H; v++) {
|
|
2552
|
+
for (let u = 0; u < W; u++) {
|
|
2553
|
+
if (!idx.has(top[v * W + u])) continue;
|
|
2554
|
+
const x0 = inp.stage.x + u;
|
|
2555
|
+
const y1 = inp.stage.y + cropToSpineY(v, H);
|
|
2556
|
+
if (exclude.some((e) => x0 + 1 > e.x0 && x0 < e.x1 && y1 > e.y0 && y1 - 1 < e.y1)) continue;
|
|
2557
|
+
data[v * W + u] = 1;
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
return { width: W, height: H, data };
|
|
2561
|
+
};
|
|
2562
|
+
const firstOf = (names: readonly string[]): number => Math.min(...names.map((n) => slots.findIndex((sl) => sl.name === n)).filter((i) => i >= 0));
|
|
2563
|
+
|
|
2564
|
+
const faceHalf = (): Record<string, unknown> | string => {
|
|
2565
|
+
if (face.length === 0) return `no part comes from a See-through ${tagWords(FACE_TAGS)} layer`;
|
|
2566
|
+
const head = headBoneOf(inp);
|
|
2567
|
+
if (!('bone' in head)) return `no head bone to measure it in: ${head.none}`;
|
|
2568
|
+
const track = boneTrackOf(geo, geometryPath, idle, head.bone);
|
|
2569
|
+
if (typeof track === 'string') return track;
|
|
2570
|
+
const maps: FrameMap[] = [];
|
|
2571
|
+
for (const f of track.frames) {
|
|
2572
|
+
const m = setupToFrame(track.setup, f);
|
|
2573
|
+
if (m === null) return `the head bone "${head.bone}" has a singular setup transform, so there is no head frame to carry the face into`;
|
|
2574
|
+
maps.push(m);
|
|
2575
|
+
}
|
|
2576
|
+
const entries = [track.setup, ...track.frames];
|
|
2577
|
+
const entryMax = Math.max(...entries.map((b) => Math.max(Math.abs(b.a), Math.abs(b.b), Math.abs(b.c), Math.abs(b.d))));
|
|
2578
|
+
const coordFloor = Math.max(...entries.map((b) => Math.max(Math.abs(b.worldX), Math.abs(b.worldY))));
|
|
2579
|
+
// The features are left out of the region where they go in the head's frame, not only where they sit at rest.
|
|
2580
|
+
const features = partsTagged(inp.parts, FACE_FEATURE_TAGS);
|
|
2581
|
+
const swept: WorldBox[] = [];
|
|
2582
|
+
for (const p of features) {
|
|
2583
|
+
const ft = boneTrackOf(geo, geometryPath, idle, slotBone.get(p.name) ?? '');
|
|
2584
|
+
if (typeof ft === 'string') return `no track for feature "${p.name}"'s bone: ${ft}`;
|
|
2585
|
+
const sb = sweptBox(partWorldBox(p, H, inp.stage), ft, track);
|
|
2586
|
+
if (sb === null) return `feature "${p.name}"'s bone or the head bone "${head.bone}" has a singular transform on a frame, so where the feature goes in the head's frame is not known`;
|
|
2587
|
+
swept.push(sb);
|
|
2588
|
+
}
|
|
2589
|
+
const faceNames = face.map((p) => p.name);
|
|
2590
|
+
const first = firstOf(faceNames);
|
|
2591
|
+
const faceArt = faceNames.map(artOf);
|
|
2592
|
+
const overFace = (name: string): boolean => {
|
|
2593
|
+
const a = artOf(name);
|
|
2594
|
+
return faceArt.some((f) => {
|
|
2595
|
+
for (let y = Math.max(a.y, f.y); y < Math.min(a.y + a.height, f.y + f.height); y++) {
|
|
2596
|
+
for (let x = Math.max(a.x, f.x); x < Math.min(a.x + a.width, f.x + f.width); x++) if (a.data[(y - a.y) * a.width + x - a.x] === 1 && f.data[(y - f.y) * f.width + x - f.x] === 1) return true;
|
|
2597
|
+
}
|
|
2598
|
+
return false;
|
|
2599
|
+
});
|
|
2600
|
+
};
|
|
2601
|
+
const drivenNames = idleDrivenSlots(inp.motion);
|
|
2602
|
+
const driven: string[] = [];
|
|
2603
|
+
const still = [...faceNames];
|
|
2604
|
+
for (const sl of slots.slice(first + 1)) {
|
|
2605
|
+
if (still.includes(sl.name) || !record.has(sl.name) || !ridesOnly(inp.rig, sl.name, sl.bone, head.bone) || !overFace(sl.name)) continue;
|
|
2606
|
+
if (drivenNames.has(sl.name)) driven.push(sl.name);
|
|
2607
|
+
else still.push(sl.name);
|
|
2608
|
+
}
|
|
2609
|
+
const h = half(still, first, maps, entryMax, coordFloor, regionOf(new Set(face.map((p) => index.get(p.name) as number)), swept));
|
|
2610
|
+
for (const o of h.over) {
|
|
2611
|
+
problems.push({
|
|
2612
|
+
code: 'CHECK_STILL_REGIONS_DARK',
|
|
2613
|
+
object: `slot "${o.slot}" of the face's still set (${still.map((n) => `"${n}"`).join(', ')}), in the frame of its head bone "${head.bone}"`,
|
|
2614
|
+
detail: `its art moves ${sig4(o.max)} rig px relative to that bone at idle frame ${o.frame} (RMS over the still set ${sig4(h.rms)}); at most ${sig4(h.tolerance)} rig px is required — the arithmetic's own rounding, so nothing may move — a mesh over the face weighted to a bone the head does not carry, or the face mesh deformed by another bone`,
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
return { parts: faceNames, head_bone: head.bone, ...h.figures, driven_slots: driven };
|
|
2618
|
+
};
|
|
2619
|
+
|
|
2620
|
+
const feetHalf = (): Record<string, unknown> | string => {
|
|
2621
|
+
if (feet.length === 0) return `no part comes from a See-through ${tagWords(FEET_TAGS)} layer`;
|
|
2622
|
+
const names = feet.map((p) => p.name);
|
|
2623
|
+
const h = half(names, firstOf(names), poses.map(() => null), 1, 0, regionOf(new Set(feet.map((p) => index.get(p.name) as number)), []));
|
|
2624
|
+
for (const o of h.over) {
|
|
2625
|
+
problems.push({
|
|
2626
|
+
code: 'CHECK_STILL_REGIONS_DARK',
|
|
2627
|
+
object: `slot "${o.slot}" of the feet (${names.map((n) => `"${n}"`).join(', ')}), in screen space`,
|
|
2628
|
+
detail: `its art moves ${sig4(o.max)} rig px at idle frame ${o.frame} (RMS over the feet ${sig4(h.rms)}); at most ${sig4(h.tolerance)} rig px is required — the arithmetic's own rounding — something that should hold still moves: a mesh weighted to a swinging bone, or a part that rides the wrong bone`,
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
return { parts: names, ...h.figures };
|
|
2632
|
+
};
|
|
2633
|
+
|
|
2634
|
+
const f = faceHalf();
|
|
2635
|
+
const t = feetHalf();
|
|
2636
|
+
if (typeof f === 'string' && typeof t === 'string') return skip(`face: ${f}; feet: ${t}`);
|
|
2637
|
+
const failed = problems.some((q) => q.code === 'CHECK_STILL_REGIONS_DARK');
|
|
2638
|
+
return { status: failed ? 'FAIL' : 'PASS', face: typeof f === 'string' ? { unmeasured: f } : f, feet: typeof t === 'string' ? { unmeasured: t } : t };
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
function fmt3(x: number): string {
|
|
2642
|
+
return x.toFixed(3);
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
/** (3) Down every chain the phase lags and the amplitude does not shrink, read off motion.json's rotate tracks. */
|
|
2646
|
+
export function chainLine(inp: Pick<CheckInputs, 'rig' | 'motion'>, problems: Problem[]): JudgementLine {
|
|
2647
|
+
const cf = chainFigures(inp.rig, inp.motion);
|
|
2648
|
+
const long = cf.chains.filter((c) => c.length >= 2);
|
|
2649
|
+
if (cf.edges.length === 0 && long.length === 0) {
|
|
2650
|
+
return skip(`no idle "rotate" track sits under another at the same period${cf.unread.length > 0 ? ` (unread, not sampled sines: ${cf.unread.join(', ')})` : ''}, so there is no chain to lag`);
|
|
2651
|
+
}
|
|
2652
|
+
const violations: string[] = [];
|
|
2653
|
+
for (const e of cf.edges) {
|
|
2654
|
+
if (round3(e.step) < CHAIN_LAG_MIN_STEP) {
|
|
2655
|
+
const v = `"${e.child}" follows its keyed ancestor "${e.parent}" by ${fmt3(e.step)} cycle(s)`;
|
|
2656
|
+
violations.push(v);
|
|
2657
|
+
problems.push({ code: 'CHECK_CHAIN_LAG', object: `bone "${e.child}" under "${e.parent}"`, detail: `phase step ${fmt3(e.step)} cycle(s); >= ${CHAIN_LAG_MIN_STEP} is required — a link must lag the one above it (a larger "phase", or a positive "lag" on the chain track)` });
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
for (const c of long) {
|
|
2661
|
+
for (let i = 1; i < c.length; i++) {
|
|
2662
|
+
const a = round3(c[i - 1].reading.amp);
|
|
2663
|
+
const b = round3(c[i].reading.amp);
|
|
2664
|
+
if (b < a) {
|
|
2665
|
+
violations.push(`"${c[i].bone}" swings ${fmt3(b)} under "${c[i - 1].bone}"'s ${fmt3(a)}`);
|
|
2666
|
+
problems.push({ code: 'CHECK_CHAIN_LAG', object: `bone "${c[i].bone}" in the chain from "${c[0].bone}"`, detail: `amplitude ${fmt3(b)} below the link above's ${fmt3(a)}; non-decreasing down the chain is required — "amps" grow toward the tip` });
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
const minStep = cf.edges.length === 0 ? null : round3(Math.min(...cf.edges.map((e) => e.step)));
|
|
2671
|
+
return {
|
|
2672
|
+
status: violations.length === 0 ? 'PASS' : 'FAIL',
|
|
2673
|
+
chains: long.map((c) => `${c.map((l) => l.bone).join(' > ')}: period ${round3(c[0].reading.period)}s, phase ${c.map((l) => fmt3(l.reading.phase)).join(' ')}, amp ${c.map((l) => fmt3(l.reading.amp)).join(' ')}`),
|
|
2674
|
+
lags: cf.edges.map((e) => `${e.parent} > ${e.child} ${e.step >= 0 ? '+' : ''}${fmt3(e.step)}`),
|
|
2675
|
+
min_step: minStep,
|
|
2676
|
+
step_floor: CHAIN_LAG_MIN_STEP,
|
|
2677
|
+
other_period: cf.otherPeriod.map((e) => `${e.parent} > ${e.child}`),
|
|
2678
|
+
unread: cf.unread,
|
|
2679
|
+
first_violation: violations[0] ?? null,
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
function sameViewport(a: Viewport, b: Viewport): boolean {
|
|
2684
|
+
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height && a.scale === b.scale && a.pixelWidth === b.pixelWidth && a.pixelHeight === b.pixelHeight;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
/** (2) The setup pose with the blink tracks held at their closed value, against the setup pose, inside the eye box. */
|
|
2688
|
+
function blinkLine(
|
|
2689
|
+
inp: CheckInputs,
|
|
2690
|
+
eyes: readonly PartRecord[],
|
|
2691
|
+
shut: ReturnType<typeof blinkTracks>,
|
|
2692
|
+
open: FrameSet,
|
|
2693
|
+
closed: FrameSet,
|
|
2694
|
+
idle: FrameSet,
|
|
2695
|
+
H: number,
|
|
2696
|
+
problems: Problem[],
|
|
2697
|
+
): JudgementLine {
|
|
2698
|
+
if (!sameViewport(open.viewport, closed.viewport)) return skip(`the closed-eye render's grid ${JSON.stringify(closed.viewport)} is not the setup still's ${JSON.stringify(open.viewport)}, so the two cannot be compared pixel for pixel`);
|
|
2699
|
+
const box = frameBox(eyes, H, inp.stage, open.viewport);
|
|
2700
|
+
if (box === null) return skip('the eye box falls outside the setup-pose frame');
|
|
2701
|
+
const b = blinkFigures(open.frames[0].image, closed.frames[0].image, open.background, box, BLINK_PATCH_LEVEL);
|
|
2702
|
+
const from = Math.max(...shut.map((s) => s.window[0]));
|
|
2703
|
+
const to = Math.min(...shut.map((s) => s.window[1]));
|
|
2704
|
+
const frames = framesInside(from, to, idle.fps, idle.sampled);
|
|
2705
|
+
if (b.holePx > BLINK_HOLE_BAR) {
|
|
2706
|
+
problems.push({
|
|
2707
|
+
code: 'CHECK_BLINK_NO_HOLE',
|
|
2708
|
+
object: `the eye box ${boxLabel(box)} (${quoteParts(eyes)}) with the eyes shut`,
|
|
2709
|
+
detail: `${b.holePx} px show the background where the open eye had art; ${BLINK_HOLE_BAR} is required — the layer under the eye has no art there (See-through left it empty), so a closed eye opens a hole`,
|
|
2710
|
+
});
|
|
2711
|
+
}
|
|
2712
|
+
return {
|
|
2713
|
+
status: b.holePx > BLINK_HOLE_BAR ? 'FAIL' : 'PASS',
|
|
2714
|
+
eye_parts: eyes.map((p) => p.name),
|
|
2715
|
+
closed: shut.map((s) => `${'bone' in s.target ? `bone ${s.target.bone}` : `group ${s.target.group}`} scaley ${s.closed} from ${s.window[0]}s to ${s.window[1]}s`),
|
|
2716
|
+
idle_frames_closed: frames,
|
|
2717
|
+
box_px: b.px,
|
|
2718
|
+
hole_px: b.holePx,
|
|
2719
|
+
hole_bar: BLINK_HOLE_BAR,
|
|
2720
|
+
patch_max: b.patchMax,
|
|
2721
|
+
patch_px_over_40: b.patchOver,
|
|
2722
|
+
};
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
// ---------------------------------------------------------------------------
|
|
2726
|
+
// texture stretch (issue #31): rigc's geometry export
|
|
2727
|
+
// ---------------------------------------------------------------------------
|
|
2728
|
+
|
|
2729
|
+
/** `major.minor.patch` of a `rigc --version` line, or null when it holds none. */
|
|
2730
|
+
export function parseRigcVersion(out: string): [number, number, number] | null {
|
|
2731
|
+
const m = /^\s*(\d+)\.(\d+)\.(\d+)/.exec(out);
|
|
2732
|
+
return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
/**
|
|
2736
|
+
* Refuse, before anything is built, a rigc older than
|
|
2737
|
+
* {@link RIGC_GEOMETRY_VERSION}: its `render` has no `--geometry`, so the idle
|
|
2738
|
+
* render would fail on the flag after the build had run, and
|
|
2739
|
+
* the refusal would name a flag rather than the version that lacks it.
|
|
2740
|
+
* Returns what `--version` printed, stdout then stderr, which is where
|
|
2741
|
+
* {@link readRigcEntry} reads the entry.
|
|
2742
|
+
*/
|
|
2743
|
+
export function requireRigcVersion(rigc: RigcRunner): string {
|
|
2744
|
+
const v = rigc(['--version']);
|
|
2745
|
+
const got = v.status === 0 ? parseRigcVersion(v.out) : null;
|
|
2746
|
+
const need = parseRigcVersion(RIGC_GEOMETRY_VERSION) as [number, number, number];
|
|
2747
|
+
const below = got !== null && (got[0] - need[0] || got[1] - need[1] || got[2] - need[2]) < 0;
|
|
2748
|
+
if (got === null || below) {
|
|
2749
|
+
refuseIfAny([
|
|
2750
|
+
{
|
|
2751
|
+
code: 'CHECK_RIGC_VERSION',
|
|
2752
|
+
object: '`rigc --version`',
|
|
2753
|
+
detail: `${got === null ? `exit ${v.status}, printed ${JSON.stringify(v.out.trim().split('\n')[0] ?? '')} — no version` : `is ${got.join('.')}`}; rig-c ${RIGC_GEOMETRY_VERSION} or later is required — its \`render --geometry\` writes the skinned mesh vertices TEXTURE_STRETCH is measured from (\`bun install\` puts this package's own rig-c at node_modules/.bin/rigc)`,
|
|
2754
|
+
},
|
|
2755
|
+
]);
|
|
2756
|
+
}
|
|
2757
|
+
return v.out;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
export interface IdleGeometry {
|
|
2761
|
+
meshes: MeshRest[];
|
|
2762
|
+
frames: GeometryPose[];
|
|
2763
|
+
/** The rest, carried as read for {@link boneTrackOf}, which checks what it uses: the viewport, the setup pose's and each frame's `bones`, each frame's `time`. */
|
|
2764
|
+
viewport: Record<string, unknown> | null;
|
|
2765
|
+
setupBones: unknown;
|
|
2766
|
+
frameBones: unknown[];
|
|
2767
|
+
times: unknown[];
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
function geometryProblem(object: string, detail: string): never {
|
|
2771
|
+
refuseIfAny([{ code: 'CHECK_GEOMETRY_FILE', object, detail }]);
|
|
2772
|
+
throw new Error('unreachable');
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
function numberArray(v: unknown): v is number[] {
|
|
2776
|
+
return Array.isArray(v) && v.every((x) => typeof x === 'number' && Number.isFinite(x));
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
/**
|
|
2780
|
+
* The mesh half of a `rigc-geometry/1` file: every `rest` entry of kind
|
|
2781
|
+
* `mesh`, and each frame's attachments. Null when the file does not exist
|
|
2782
|
+
* (the line then says SKIP, naming it); a file that exists and does not hold
|
|
2783
|
+
* `frames` frames of well-formed vertices is refused naming what is wrong,
|
|
2784
|
+
* because a figure read off a half-written export would be a wrong number
|
|
2785
|
+
* printed as a measurement.
|
|
2786
|
+
*/
|
|
2787
|
+
export function readGeometry(path: string, frames: number): IdleGeometry | null {
|
|
2788
|
+
if (!existsSync(path)) return null;
|
|
2789
|
+
let raw: unknown;
|
|
2790
|
+
try {
|
|
2791
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
2792
|
+
} catch (err) {
|
|
2793
|
+
return geometryProblem(path, `does not parse as JSON: ${(err as Error).message}`);
|
|
2794
|
+
}
|
|
2795
|
+
if (!isRecord(raw) || raw.spec !== GEOMETRY_SPEC) return geometryProblem(path, `"spec" is ${JSON.stringify(isRecord(raw) ? raw.spec : raw)?.slice(0, 40)}; ${JSON.stringify(GEOMETRY_SPEC)} is required`);
|
|
2796
|
+
if (!Array.isArray(raw.rest) || !Array.isArray(raw.frames)) return geometryProblem(path, 'lacks "rest" or "frames"; a rigc-geometry/1 file carries both');
|
|
2797
|
+
if (raw.frames.length !== frames) return geometryProblem(path, `holds ${raw.frames.length} frame(s); the frame set beside it wrote ${frames}`);
|
|
2798
|
+
const meshes: MeshRest[] = [];
|
|
2799
|
+
for (const [i, r] of raw.rest.entries()) {
|
|
2800
|
+
if (!isRecord(r) || r.kind !== 'mesh') continue;
|
|
2801
|
+
const at = `rest[${i}] (slot ${JSON.stringify(r.slot)}, attachment ${JSON.stringify(r.attachment)})`;
|
|
2802
|
+
if (typeof r.slot !== 'string' || typeof r.attachment !== 'string') return geometryProblem(path, `${at} has no string "slot" and "attachment"`);
|
|
2803
|
+
if (!numberArray(r.vertices) || r.vertices.length % 2 !== 0) return geometryProblem(path, `${at} "vertices" is not an even-length array of finite numbers`);
|
|
2804
|
+
const n = r.vertices.length / 2;
|
|
2805
|
+
const tri = r.triangles;
|
|
2806
|
+
if (!numberArray(tri) || tri.length % 3 !== 0 || !tri.every((x) => Number.isInteger(x) && x >= 0 && x < n)) return geometryProblem(path, `${at} "triangles" is not index triplets into its ${n} vertices`);
|
|
2807
|
+
meshes.push({ slot: r.slot, attachment: r.attachment, vertices: r.vertices, triangles: tri });
|
|
2808
|
+
}
|
|
2809
|
+
const want = new Map(meshes.map((m) => [`${m.slot}\u0000${m.attachment}`, m.vertices.length]));
|
|
2810
|
+
const poses: GeometryPose[] = [];
|
|
2811
|
+
for (const [i, f] of raw.frames.entries()) {
|
|
2812
|
+
if (!isRecord(f) || typeof f.index !== 'number' || !Array.isArray(f.attachments)) return geometryProblem(path, `frames[${i}] has no "index" or "attachments"`);
|
|
2813
|
+
const attachments: GeometryPose['attachments'][number][] = [];
|
|
2814
|
+
for (const a of f.attachments) {
|
|
2815
|
+
if (!isRecord(a) || typeof a.slot !== 'string' || typeof a.attachment !== 'string') continue;
|
|
2816
|
+
const n = want.get(`${a.slot}\u0000${a.attachment}`);
|
|
2817
|
+
if (n === undefined) continue;
|
|
2818
|
+
if (!numberArray(a.vertices) || a.vertices.length !== n) {
|
|
2819
|
+
return geometryProblem(path, `frame ${f.index} mesh "${a.slot}" attachment "${a.attachment}" has ${Array.isArray(a.vertices) ? a.vertices.length : 'no'} vertex number(s); its rest entry has ${n}`);
|
|
2820
|
+
}
|
|
2821
|
+
attachments.push({ slot: a.slot, attachment: a.attachment, vertices: a.vertices });
|
|
2822
|
+
}
|
|
2823
|
+
poses.push({ index: f.index, attachments });
|
|
2824
|
+
}
|
|
2825
|
+
return {
|
|
2826
|
+
meshes,
|
|
2827
|
+
frames: poses,
|
|
2828
|
+
viewport: isRecord(raw.viewport) ? raw.viewport : null,
|
|
2829
|
+
setupBones: isRecord(raw.setup) ? raw.setup.bones : undefined,
|
|
2830
|
+
frameBones: raw.frames.map((f) => (isRecord(f) ? f.bones : undefined)),
|
|
2831
|
+
times: raw.frames.map((f) => (isRecord(f) ? f.time : undefined)),
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
/**
|
|
2836
|
+
* Every attachment a `rigc-geometry/1` file shows, frame by frame, keyed by
|
|
2837
|
+
* its slot: the posed vertices with the rest vertices and triangles of the
|
|
2838
|
+
* same slot and attachment — regions and meshes alike, which a seam
|
|
2839
|
+
* requirement reads (issue #111). Read only for a `seam` requirement, so a
|
|
2840
|
+
* run without one reads the file exactly as before ({@link readGeometry}).
|
|
2841
|
+
* A file that is not a whole export of `frames` frames — a rest entry without
|
|
2842
|
+
* well-formed vertices and triangles, a frame showing an attachment with no
|
|
2843
|
+
* rest entry or another vertex count — is refused naming what is wrong.
|
|
2844
|
+
*/
|
|
2845
|
+
export function attachmentPoses(path: string, frames: number): Array<Map<string, PosedAttachment>> {
|
|
2846
|
+
let raw: unknown;
|
|
2847
|
+
try {
|
|
2848
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
2849
|
+
} catch (err) {
|
|
2850
|
+
return geometryProblem(path, `does not read as JSON: ${(err as Error).message}`);
|
|
2851
|
+
}
|
|
2852
|
+
if (!isRecord(raw) || !Array.isArray(raw.rest) || !Array.isArray(raw.frames)) return geometryProblem(path, 'lacks "rest" or "frames"; a rigc-geometry/1 file carries both');
|
|
2853
|
+
if (raw.frames.length !== frames) return geometryProblem(path, `holds ${raw.frames.length} frame(s); the frame set beside it wrote ${frames}`);
|
|
2854
|
+
const rest = new Map<string, { vertices: number[]; triangles: number[] }>();
|
|
2855
|
+
for (const [i, r] of raw.rest.entries()) {
|
|
2856
|
+
if (!isRecord(r) || typeof r.slot !== 'string' || typeof r.attachment !== 'string') return geometryProblem(path, `rest[${i}] has no string "slot" and "attachment"`);
|
|
2857
|
+
const at = `rest[${i}] (slot ${JSON.stringify(r.slot)}, attachment ${JSON.stringify(r.attachment)})`;
|
|
2858
|
+
if (!numberArray(r.vertices) || r.vertices.length % 2 !== 0) return geometryProblem(path, `${at} "vertices" is not an even-length array of finite numbers`);
|
|
2859
|
+
const n = r.vertices.length / 2;
|
|
2860
|
+
const tri = r.triangles;
|
|
2861
|
+
if (!numberArray(tri) || tri.length % 3 !== 0 || !tri.every((x) => Number.isInteger(x) && x >= 0 && x < n)) return geometryProblem(path, `${at} "triangles" is not index triplets into its ${n} vertices`);
|
|
2862
|
+
rest.set(`${r.slot}\u0000${r.attachment}`, { vertices: r.vertices, triangles: tri });
|
|
2863
|
+
}
|
|
2864
|
+
return raw.frames.map((f, i) => {
|
|
2865
|
+
if (!isRecord(f) || !Array.isArray(f.attachments)) return geometryProblem(path, `frames[${i}] has no "attachments"`);
|
|
2866
|
+
const shown = new Map<string, PosedAttachment>();
|
|
2867
|
+
for (const a of f.attachments) {
|
|
2868
|
+
if (!isRecord(a) || typeof a.slot !== 'string' || typeof a.attachment !== 'string') return geometryProblem(path, `frames[${i}] holds an attachment with no string "slot" and "attachment"`);
|
|
2869
|
+
const r = rest.get(`${a.slot}\u0000${a.attachment}`);
|
|
2870
|
+
if (r === undefined) return geometryProblem(path, `frames[${i}] shows slot "${a.slot}" attachment "${a.attachment}", which "rest" has no entry for`);
|
|
2871
|
+
if (!numberArray(a.vertices) || a.vertices.length !== r.vertices.length) return geometryProblem(path, `frames[${i}] slot "${a.slot}" attachment "${a.attachment}" has ${Array.isArray(a.vertices) ? a.vertices.length : 'no'} vertex number(s); its rest entry has ${r.vertices.length}`);
|
|
2872
|
+
shown.set(a.slot, { rest: r.vertices, posed: a.vertices, triangles: r.triangles });
|
|
2873
|
+
}
|
|
2874
|
+
return shown;
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
function stretchAtText(s: { triangle: number; vertices: readonly number[]; edge: readonly number[]; frame: number }): string {
|
|
2879
|
+
return `triangle ${s.triangle} (vertices ${s.vertices.join(' ')}), edge ${s.edge.join('-')}, idle frame ${s.frame}`;
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
/**
|
|
2883
|
+
* (6) No visible texture stretch: for every mesh triangle, each edge's length
|
|
2884
|
+
* in each idle frame over its rest length (the setup pose's bones, no deform —
|
|
2885
|
+
* which on both examples is the art's own proportions, the attachment's `uvs`
|
|
2886
|
+
* times its size, to 2.4e-5). The figure is the rig's worst max(ratio,
|
|
2887
|
+
* 1/ratio); each mesh's largest and smallest ratio, and where, are the detail.
|
|
2888
|
+
* SKIP when the render wrote no geometry file or the rig draws no mesh.
|
|
2889
|
+
*/
|
|
2890
|
+
export function stretchLine(geo: IdleGeometry | null, path: string, problems: Problem[]): JudgementLine {
|
|
2891
|
+
if (geo === null) return skip(`${path} does not exist; \`rigc render --geometry\` (rig-c ${RIGC_GEOMETRY_VERSION} or later) writes it, and the skinned mesh vertices are read from it`);
|
|
2892
|
+
if (geo.meshes.length === 0) return skip('the rig draws no mesh attachment (every slot is a region), so there is no triangle to stretch');
|
|
2893
|
+
const figures = stretchFigures(geo.meshes, geo.frames);
|
|
2894
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
2895
|
+
let worst: { mesh: (typeof figures)[number]; at: NonNullable<(typeof figures)[number]['max']>; severity: number } | null = null;
|
|
2896
|
+
let maxAll: number | null = null;
|
|
2897
|
+
let minAll: number | null = null;
|
|
2898
|
+
let failed = false;
|
|
2899
|
+
for (const m of figures) {
|
|
2900
|
+
if (m.degenerate.length > 0) {
|
|
2901
|
+
failed = true;
|
|
2902
|
+
problems.push({
|
|
2903
|
+
code: 'CHECK_TEXTURE_STRETCH',
|
|
2904
|
+
object: `mesh "${m.slot}" attachment "${m.attachment}"`,
|
|
2905
|
+
detail: `${m.degenerate.length} rest edge(s) of length 0, the first triangle ${m.degenerate[0].triangle} edge ${m.degenerate[0].edge.join('-')}; no stretch ratio can be read off an edge with no rest length — two of the mesh's vertices coincide at the setup pose`,
|
|
2906
|
+
});
|
|
2907
|
+
}
|
|
2908
|
+
if (m.max === null || m.min === null) {
|
|
2909
|
+
rows.push({ slot: m.slot, triangles: m.triangles, unmeasured: 'no idle frame shows this attachment' });
|
|
2910
|
+
continue;
|
|
2911
|
+
}
|
|
2912
|
+
const sMax = stretchSeverity(m.max.ratio);
|
|
2913
|
+
const sMin = stretchSeverity(m.min.ratio);
|
|
2914
|
+
const at = sMin > sMax ? m.min : m.max;
|
|
2915
|
+
const severity = round3(Math.max(sMax, sMin));
|
|
2916
|
+
if (worst === null || severity > worst.severity) worst = { mesh: m, at, severity };
|
|
2917
|
+
maxAll = maxAll === null ? m.max.ratio : Math.max(maxAll, m.max.ratio);
|
|
2918
|
+
minAll = minAll === null ? m.min.ratio : Math.min(minAll, m.min.ratio);
|
|
2919
|
+
rows.push({ slot: m.slot, triangles: m.triangles, max_ratio: round3(m.max.ratio), max_at: stretchAtText(m.max), min_ratio: round3(m.min.ratio), min_at: stretchAtText(m.min), severity });
|
|
2920
|
+
if (severity > TEXTURE_STRETCH_CEILING) {
|
|
2921
|
+
failed = true;
|
|
2922
|
+
problems.push({
|
|
2923
|
+
code: 'CHECK_TEXTURE_STRETCH',
|
|
2924
|
+
object: `mesh "${m.slot}" ${stretchAtText(at)}`,
|
|
2925
|
+
detail: `the edge is ${round3(at.ratio)} times its rest length (max(ratio, 1/ratio) ${severity}); <= ${TEXTURE_STRETCH_CEILING} is required — the texture on it is ${at.ratio >= 1 ? 'stretched' : 'squeezed'}: the bones this mesh is weighted to move apart (amplitudes too large down a chain), or a vertex blends bones that move against each other`,
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
if (worst === null) return { status: 'SKIP', reason: `no idle frame shows any of the rig's ${figures.length} mesh(es): ${figures.map((m) => `"${m.slot}"`).join(', ')}` };
|
|
2930
|
+
return {
|
|
2931
|
+
status: failed ? 'FAIL' : 'PASS',
|
|
2932
|
+
meshes: figures.length,
|
|
2933
|
+
triangles: figures.reduce((n, m) => n + m.triangles, 0),
|
|
2934
|
+
frames: geo.frames.length,
|
|
2935
|
+
severity: worst.severity,
|
|
2936
|
+
severity_ceiling: TEXTURE_STRETCH_CEILING,
|
|
2937
|
+
worst: { slot: worst.mesh.slot, triangle: worst.at.triangle, vertices: worst.at.vertices, edge: worst.at.edge, frame: worst.at.frame, ratio: round3(worst.at.ratio) },
|
|
2938
|
+
max_ratio: round3(maxAll as number),
|
|
2939
|
+
min_ratio: round3(minAll as number),
|
|
2940
|
+
per_mesh: rows,
|
|
2941
|
+
};
|
|
2942
|
+
}
|