@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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
@@ -0,0 +1,447 @@
1
+ /**
2
+ * The motion gate of the automatic mesh mode (issue #126, item 3): a reduced
3
+ * mesh is written only when it moves like the mesh it was reduced from, on the
4
+ * rig's own idle, measured by rig-c's `compareMeshesInMotion`
5
+ * (`rig-c/meshcompare`, 2.29.x) and held to the bounds the author wrote in
6
+ * `meshes.<part>.auto.motion` (`AutoMotionSpec`, `src/config.ts`).
7
+ *
8
+ * ## What is compared
9
+ *
10
+ * - **The reference** is the rig with the part's unreduced, gated source mesh
11
+ * — the alpha >= 1 contour mesh `autoSource` returns, with the triangles and
12
+ * UVs `reduceMesh` was handed and its source weights, bound exactly as the
13
+ * reduced mesh is ({@link AutoMotionCase.reference}). **The candidate** is the
14
+ * rig the stage writes, with the reduced mesh. The two are one `RigSpec` with
15
+ * that one attachment swapped and nothing else; rig-c's allowlist
16
+ * (`COMPARE_INPUTS_DIFFER`) refuses anything else, and its refusal is carried
17
+ * here by code.
18
+ * - Both go through rig-c's `build` under the same gate — the candidate
19
+ * packed, as the stage writes it, the reference compiled without packing
20
+ * (issue #135: the comparison allowlists atlas layout, and nothing reads a
21
+ * reference's pages) — and the comparison reads each build's
22
+ * `skeleton.model.json` as text. The builds run in the rig stage
23
+ * (`src/build.ts`), the one place this package runs rigc; this module is
24
+ * handed the two texts and stays pure.
25
+ *
26
+ * ## The schedule ({@link idleSchedule})
27
+ *
28
+ * The idle exactly as `check` renders it (`rigc render --animation idle --fps
29
+ * IDLE_FPS`): the animation `idle` at {@link IDLE_FPS} fps over its whole
30
+ * duration, physics reset at time 0 and stepped by `1 / IDLE_FPS` — the
31
+ * render's step (rig-c's `render_core`) — with `warmupSteps: 0`, the only
32
+ * value rig-c accepts (P10). Two phases: `grid`, the frames the render
33
+ * draws, and `irr`, each frame interval's `IRR_OFFSET` past them, which the
34
+ * render never selects. **Nothing here chooses a candidate by motion** — the
35
+ * candidate is the one `reduceMesh` returned on geometry alone (item 2) — so
36
+ * `selection` is empty and every frame is held out from selection by
37
+ * construction. The one place a candidate is chosen by motion is the
38
+ * acceptance loop (`src/autoreplay.ts`), which runs only after this gate
39
+ * refused the full result: it chooses on the `grid` frames and declares them
40
+ * `selection` in its final comparison, so its irr frames are the held-out ones.
41
+ *
42
+ * ## Stimulus ({@link motionStimulus})
43
+ *
44
+ * A comparison over frames that do not deform the part reads 0 and would pass
45
+ * over nothing. Every bone the part binds moving by one affine map (a key on
46
+ * an ancestor they share, or on the one bone a single-bone part binds) carries
47
+ * every sample of both meshes to the same point — a barycentric combination
48
+ * commutes with an affine map — so it is not a stimulus. The part is
49
+ * stimulated when the idle keys, with changing values, a bone that is (or is
50
+ * an ancestor of) some of the bound bones but not all of them; otherwise it is
51
+ * refused `AUTO_MESH_NO_STIMULUS` before anything is compiled. Constraints are
52
+ * not read: a part only a constraint would move is refused, never passed —
53
+ * unless its author declares the motion (below).
54
+ *
55
+ * ## A declared stimulus ({@link stimulusAnimations}, issue #196)
56
+ *
57
+ * A part only a physics constraint moves (a tie, a strand, a hem) has no idle
58
+ * key that deforms it. Its author may declare `meshes.<part>.auto.stimulus`:
59
+ * `{ chain, degrees }`, or a list of them. Each declaration is two poses —
60
+ * every link of the chain rotated by +degrees, and by −degrees, about its own
61
+ * joint (a rotate key of that value on the link, which Spine adds to the setup
62
+ * rotation; the links inherit, so the chain curls) — written as two one-key
63
+ * animations of duration 0 into the motion of the builds the comparison reads
64
+ * (the reference's and a candidate's built from the written rig with them
65
+ * added; never into the motion the stage writes), and walked at the explicit
66
+ * time 0: right after the physics reset, so the pose is the declared bend
67
+ * itself and no physics step has moved it. Those frames are appended to every
68
+ * schedule the gate and its acceptance loop walk; where a schedule declares a
69
+ * selection (the search's), they are selection — read to choose — and
70
+ * otherwise held out as every frame is. The idle is walked as before, so where
71
+ * idle keys also deform the part both are measured. A declared link counts as
72
+ * a stimulus by the rule above exactly as a keyed bone does; nothing is
73
+ * derived from the constraint.
74
+ *
75
+ * ## Acceptance ({@link motionVerdict})
76
+ *
77
+ * `motionRequired: true`. The part is kept only when the candidate's
78
+ * `accepted` is true (its geometry section and its motion section both
79
+ * `pass`). Otherwise `AUTO_MESH_MOTION` names every gated row that did not
80
+ * pass with its value, bound and worst frame; a refusal by rig-c of the
81
+ * comparison's input — `COMPARE_REFERENCE_FAILS` included — is
82
+ * `AUTO_MESH_MOTION_INPUT` carrying its code.
83
+ *
84
+ * Pure: no clock, no randomness, nothing read or written.
85
+ */
86
+ import { type AcceptedOperation, MeshReductionError, type AlphaMask, type ArtFitBounds, type MeasureRow, type MeshQualityReport, type MotionAmplitude, type MotionBounds, type MotionSchedule, writeMeshQualityReport } from 'rig-c/mesh';
87
+ import { compareMeshesInMotion, type MotionComparisonInput } from 'rig-c/meshcompare';
88
+ import type { AutoMotionSpec } from './config.ts';
89
+ import type { Problem } from './errors.ts';
90
+ import { IDLE_FPS, type MotionSpec, type MotionTrack } from './motion.ts';
91
+ import type { AutoMeshReport, MeshAttachment, RigBone } from './rig.ts';
92
+
93
+ /** The animation the motion gate walks: the idle, the one animation the rig stage writes and `check` renders. */
94
+ export const MOTION_ANIMATION = 'idle';
95
+
96
+ /** The reference's and the candidate's ids in the comparison's report. */
97
+ export const REFERENCE_ID = 'source';
98
+ export const CANDIDATE_ID = 'reduced';
99
+
100
+ /** The `why` written into `invariants.deformMayFold` for a part whose `motion.deformMayFold` is true. */
101
+ export const DEFORM_MAY_FOLD_WHY = 'rig-parts: the author set meshes.<part>.auto.motion.deformMayFold, so the motion gate lists this slot\'s folds rather than refusing them';
102
+
103
+ /**
104
+ * The schedule of the motion gate: the idle as `check` renders it (module
105
+ * header). `warmupSteps` is the literal 0 — no other value is ever sent.
106
+ */
107
+ export function idleSchedule(): MotionSchedule {
108
+ return {
109
+ frames: [{ animation: MOTION_ANIMATION, fps: IDLE_FPS }],
110
+ phases: ['grid', 'irr'],
111
+ physics: { mode: 'step', dt: 1 / IDLE_FPS, warmupSteps: 0 },
112
+ selection: [],
113
+ };
114
+ }
115
+
116
+ /** Everything the rig stage needs to run one automatic part's motion gate, built by `buildRig` beside the part's attachment. */
117
+ export interface AutoMotionCase {
118
+ part: string;
119
+ /** `config.meshes.<part>.auto`, the object every refusal names. */
120
+ object: string;
121
+ /** The author's bounds; absent only on a spec built in code past the loader, which the stage refuses by name. */
122
+ motion: AutoMotionSpec | undefined;
123
+ /** The unreduced source as the rig writes a mesh: the reference's attachment. */
124
+ reference: MeshAttachment;
125
+ /** The padded part image's alpha, the art both builds are measured against. */
126
+ mask: AlphaMask;
127
+ /** What the source and the result were each held to by `reduceMesh`: the reference's and the candidate's art fit here. */
128
+ sourceBounds: ArtFitBounds;
129
+ artFit: ArtFitBounds;
130
+ minArtSamples: number;
131
+ /** Each declared region as rig-c held density on it, part-local px, with its art sample floor. */
132
+ regions: Array<{ name: string; polygon: Array<[number, number]>; minArtSamples: number }>;
133
+ /** The bones the reference's weights bind, sorted. */
134
+ boundBones: string[];
135
+ /** The chains the mesh's segments name, in their order: what a declared stimulus may bend (issue #196). */
136
+ segmentChains: string[];
137
+ /** The author's `auto.stimulus`, each chain's links resolved, as a list; null when none was declared. */
138
+ stimulus: StimulusPose[] | null;
139
+ /**
140
+ * The motion amplitude the reduction was handed (`src/autoamplitude.ts`), handed to the comparison too (rig-c
141
+ * 2.29.0, rigc#1291), or null when its derivation stopped — then the comparison's input carries no such key.
142
+ */
143
+ amplitude: MotionAmplitude | null;
144
+ /** What the acceptance loop replays when the full result fails the gate (`src/autoreplay.ts`). */
145
+ search: AutoSearch;
146
+ }
147
+
148
+ /** One replayed step as the rig would write it: its attachment and its row, and the candidates its replay tried. */
149
+ export interface ReplayCandidate {
150
+ attachment: MeshAttachment;
151
+ row: AutoMeshReport;
152
+ candidatesTried: number;
153
+ }
154
+
155
+ /** The full run's accepted operations and a replay to any of them (`stopAfterAccepted`, rig-c 2.24.0; counted in operations from 2.25.0), each held to the full result's acceptance. */
156
+ export interface AutoSearch {
157
+ /** The full run's `changes.acceptedAt`, one entry per accepted operation: N = its length. */
158
+ acceptedAt: AcceptedOperation[];
159
+ /** The refinement's insertions, the first I accepted operations (`refinementSteps`, found by `kind`). */
160
+ inserted: number;
161
+ /** The step replayed and accepted on geometry, or the problem that refuses it. Pure. */
162
+ replay: (step: number) => ReplayCandidate | Problem;
163
+ }
164
+
165
+ /** One declared bend (issue #196): the chain, its links root first, and the degrees each link turns, both signs measured. */
166
+ export interface StimulusPose {
167
+ chain: string;
168
+ degrees: number;
169
+ links: string[];
170
+ }
171
+
172
+ /** The two animations one declared bend is walked as: the chain bent by +degrees, then by −degrees. */
173
+ export function stimulusAnimationNames(s: StimulusPose): [string, string] {
174
+ return [`stimulus ${s.chain} +${s.degrees}`, `stimulus ${s.chain} -${s.degrees}`];
175
+ }
176
+
177
+ /** What every stimulus animation says of itself in `motion.json`'s note field. */
178
+ export const STIMULUS_NOTE = 'rig-parts: a declared stimulus (meshes.<part>.auto.stimulus) — every link of the chain turned by the declared degrees about its joint, walked at time 0 by the motion gate only; never written';
179
+
180
+ /**
181
+ * The animations of every declared bend, by name: each one key at time 0 on
182
+ * every link of the chain, `rotate` by +degrees or −degrees, duration 0. Only
183
+ * the comparison's builds carry them ({@link stimulusMotion}).
184
+ */
185
+ export function stimulusAnimations(stimulus: readonly StimulusPose[]): Record<string, { duration: number; note: string; tracks: MotionTrack[] }> {
186
+ const out: Record<string, { duration: number; note: string; tracks: MotionTrack[] }> = {};
187
+ for (const s of stimulus) {
188
+ const [plus, minus] = stimulusAnimationNames(s);
189
+ for (const [name, v] of [[plus, s.degrees], [minus, -s.degrees]] as const) {
190
+ out[name] = { duration: 0, note: STIMULUS_NOTE, tracks: s.links.map((bone) => ({ bone, property: 'rotate', keys: [{ t: 0, v: [v] }] })) };
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * The motion a comparison build reads for one part: the motion as written
198
+ * (`motion.json`'s parsed object) with that part's stimulus animations added
199
+ * after every animation it already has. A name already taken is refused by
200
+ * name — an animation of the author's is never replaced. With no stimulus,
201
+ * the motion as given.
202
+ */
203
+ export function stimulusMotion(c: AutoMotionCase, written: Record<string, unknown>): { motion: Record<string, unknown> } | { problem: Problem } {
204
+ if (c.stimulus === null) return { motion: written };
205
+ const animations = (written.animations ?? {}) as Record<string, unknown>;
206
+ const added = stimulusAnimations(c.stimulus);
207
+ const taken = Object.keys(added).filter((n) => n in animations);
208
+ if (taken.length > 0) {
209
+ return {
210
+ problem: {
211
+ code: 'AUTO_MESH_MOTION_INPUT',
212
+ object: `${c.object}.stimulus`,
213
+ detail: `walks the animation(s) ${taken.map((n) => `"${n}"`).join(', ')}, and the motion already has an animation of that name; a stimulus is added beside the motion's animations and never replaces one — rename the animation in config.motion.animations_from`,
214
+ },
215
+ };
216
+ }
217
+ return { motion: { ...written, animations: { ...animations, ...added } } };
218
+ }
219
+
220
+ /** The frame id rig-c gives an explicit time 0 of `animation` (`<animation>@explicit@0`, `planSchedule` in rig-c/meshcompare). */
221
+ export function explicitFrameId(animation: string): string {
222
+ return `${animation}@explicit@0`;
223
+ }
224
+
225
+ /**
226
+ * `schedule` with the declared bends walked after it: each stimulus animation
227
+ * at time 0. Where the schedule declares a selection (the acceptance loop's),
228
+ * their frames join it — they are read to choose; otherwise every frame stays
229
+ * held out. With no stimulus, the schedule as given.
230
+ */
231
+ export function withStimulus(schedule: MotionSchedule, stimulus: readonly StimulusPose[] | null): MotionSchedule {
232
+ if (stimulus === null) return schedule;
233
+ const names = stimulus.flatMap((s) => stimulusAnimationNames(s));
234
+ return {
235
+ ...schedule,
236
+ frames: [...schedule.frames, ...names.map((animation) => ({ animation, times: [0] }))],
237
+ selection: schedule.selection.length === 0 ? schedule.selection : [...schedule.selection, ...names.map(explicitFrameId)],
238
+ };
239
+ }
240
+
241
+ /** The bones the idle keys with changing values: each track's bone, and every member of a group a track keys. */
242
+ export function movingKeyedBones(motion: MotionSpec): string[] {
243
+ const out = new Set<string>();
244
+ for (const t of motion.animations.idle.tracks) {
245
+ const first = JSON.stringify(t.keys[0]?.v ?? null);
246
+ if (t.keys.every((k) => JSON.stringify(k.v) === first)) continue;
247
+ if (t.bone !== undefined) out.add(t.bone);
248
+ if (t.group !== undefined) for (const b of motion.groups[t.group] ?? []) out.add(b);
249
+ }
250
+ return [...out].sort();
251
+ }
252
+
253
+ /**
254
+ * Whether the idle can deform a part bound to `bound` (module header,
255
+ * *Stimulus*): the keyed bones that do, or none. A keyed bone deforms the part
256
+ * when it is, or is an ancestor of, at least one bound bone and not all of
257
+ * them: then those bones move by its key and the others do not. A key that
258
+ * reaches every bound bone (their shared ancestor, the one bone of a
259
+ * single-bone part, or a bound bone every other one hangs from) moves them all
260
+ * by one affine map, which carries both meshes' samples alike. `declared` is
261
+ * the links a declared stimulus turns (issue #196), read by the same rule as
262
+ * a moving key; `keyed` is returned as the idle's own.
263
+ */
264
+ export function motionStimulus(bones: readonly RigBone[], motion: MotionSpec, bound: readonly string[], declared: readonly string[] = []): { by: string[]; keyed: string[] } {
265
+ const parent = new Map(bones.map((b) => [b.name, b.parent ?? null]));
266
+ const keyed = movingKeyedBones(motion);
267
+ const movers = [...new Set([...keyed, ...declared])].sort();
268
+ const under = (bone: string, k: string): boolean => {
269
+ for (let b: string | null = bone; b !== null; b = parent.get(b) ?? null) if (b === k) return true;
270
+ return false;
271
+ };
272
+ const by = movers.filter((k) => {
273
+ const reached = bound.filter((b) => under(b, k)).length;
274
+ return reached > 0 && reached < bound.length;
275
+ });
276
+ return { by, keyed };
277
+ }
278
+
279
+ /**
280
+ * The refusal of a part neither the idle nor a declared stimulus can deform:
281
+ * which bones had no motion against each other, and the field that declares
282
+ * one (issue #196). `keyed` is the idle's moving keys ({@link motionStimulus}'s
283
+ * `keyed`); the declared links are read off the case.
284
+ */
285
+ export function noStimulusProblem(c: AutoMotionCase, keyed: readonly string[]): Problem {
286
+ const declared = c.stimulus === null ? '' : ` and the declared stimulus turns [${c.stimulus.flatMap((s) => s.links).join(', ')}]`;
287
+ const chains = c.segmentChains.length > 0 ? `a chain its segments name: ${c.segmentChains.join(', ')}` : 'its segments name no chain, so name one there first';
288
+ return {
289
+ code: 'AUTO_MESH_NO_STIMULUS',
290
+ object: c.object,
291
+ detail:
292
+ `the part binds [${c.boundBones.join(', ')}] and the idle keys [${keyed.join(', ') || 'nothing'}] with changing values${declared}; none of those moves some bound bones against the others, so the bones [${c.boundBones.join(', ')}] have no motion against each other, no frame deforms the part and a comparison would read 0 over nothing — ` +
293
+ `missing stimulus is not a PASS. Declare ${c.object}.stimulus ({ "chain", "degrees" }: ${chains}; degrees the largest bend the physics is expected to reach), put the part in contour or grid mode, or key a bone it binds (a chain link it is weighted to)`,
294
+ };
295
+ }
296
+
297
+ /** The comparison's whole input for one part: the two documents, the art, the author's bounds and {@link idleSchedule}. */
298
+ export function motionInput(c: AutoMotionCase, motion: AutoMotionSpec, reference: string, candidate: string, schedule: MotionSchedule = idleSchedule()): MotionComparisonInput {
299
+ const bounds: MotionBounds = {
300
+ maxLocalDeformation: motion.maxLocalDeformation,
301
+ ...(motion.maxStretch === undefined ? {} : { maxStretch: motion.maxStretch }),
302
+ ...(motion.minStretch === undefined ? {} : { minStretch: motion.minStretch }),
303
+ };
304
+ return {
305
+ reference: { id: REFERENCE_ID, model: reference },
306
+ candidates: [{ id: CANDIDATE_ID, model: candidate }],
307
+ attachments: [
308
+ {
309
+ attachment: { skin: null, slot: c.part, attachment: c.part },
310
+ art: { mask: c.mask, threshold: 1, frame: { space: 'part-local-drawing-px-y-down', width: c.mask.width, height: c.mask.height, pageScale: 1, conversion: 'texels = px * pageScale' } },
311
+ finalThreshold: 1,
312
+ minArtSamples: c.minArtSamples,
313
+ regions: c.regions.map((r) => ({ name: r.name, polygon: r.polygon.map(([x, y]) => [x, y] as [number, number]), minArtSamples: r.minArtSamples })),
314
+ },
315
+ ],
316
+ referenceArtFit: c.sourceBounds,
317
+ candidateArtFit: c.artFit,
318
+ schedule,
319
+ bounds,
320
+ motionRequired: true,
321
+ perFrame: false,
322
+ // rig-c 2.29.0: read by the setup measurement of each build only — MQ_DEFORM_LOAD measured there, no verdict moved.
323
+ ...(c.amplitude === null ? {} : { motionAmplitude: c.amplitude }),
324
+ };
325
+ }
326
+
327
+ /** One comparison: rig-c's report, or its refusal of the input carried by code. */
328
+ export function runComparison(object: string, input: MotionComparisonInput): MeshQualityReport | Problem {
329
+ try {
330
+ return compareMeshesInMotion(input);
331
+ } catch (err) {
332
+ if (!(err instanceof MeshReductionError)) throw err;
333
+ return { code: 'AUTO_MESH_MOTION_INPUT', object, detail: `rig-c's compareMeshesInMotion refused the comparison's input (${err.code}): ${err.message}; nothing is built in the part's place` };
334
+ }
335
+ }
336
+
337
+ /** A gated row in one phrase: code, region, state, value against its bound, the worst frame. */
338
+ export function motionRowText(r: MeasureRow): string {
339
+ const region = r.object.region === null ? '' : ` [region "${r.object.region}"]`;
340
+ const at = r.worst?.frame?.id;
341
+ if (r.state === 'pass' || r.state === 'fail' || r.state === 'undeclared') {
342
+ return `${r.code}${region} ${r.state} ${r.value}${r.bound === null ? ' (no bound)' : ` against ${r.bound.op} ${r.bound.value}`}${at === undefined ? '' : ` at ${at}`}`;
343
+ }
344
+ return `${r.code}${region} ${r.state}: ${r.reason ?? ''}`;
345
+ }
346
+
347
+ /** The candidate's rows with a declared bound that did not pass, geometry then motion. */
348
+ export function failingRows(report: MeshQualityReport): MeasureRow[] {
349
+ const c = report.candidates[0];
350
+ const rows = [...(c?.geometry?.rows ?? []), ...(c?.motion?.rows ?? [])];
351
+ return rows.filter((r) => r.bound !== null && r.state !== 'pass');
352
+ }
353
+
354
+ /** The verdict: null when the candidate is accepted, else the problem that refuses the part. */
355
+ export function motionVerdict(object: string, report: MeshQualityReport): Problem | null {
356
+ const c = report.candidates[0];
357
+ if (c !== undefined && c.accepted) return null;
358
+ const failing = failingRows(report);
359
+ const verdicts = `geometry ${c?.geometry?.verdict ?? 'absent'}, motion ${c?.motion?.verdict ?? 'absent'}`;
360
+ return {
361
+ code: 'AUTO_MESH_MOTION',
362
+ object,
363
+ detail:
364
+ `the reduced mesh against its unreduced source on the idle (${verdicts}) is not accepted: ${failing.length > 0 ? failing.map(motionRowText).join('; ') : 'a required motion row was not measured'}; ` +
365
+ 'every declared bound passing in motion is required (motionRequired), and nothing is built in its place',
366
+ };
367
+ }
368
+
369
+ /** One motion row as `mesh_report.json` carries it. */
370
+ export interface MotionResidual {
371
+ code: string;
372
+ region: string | null;
373
+ state: MeasureRow['state'];
374
+ value: number | null;
375
+ bound: { op: '<=' | '>='; value: number } | null;
376
+ unit: MeasureRow['unit'];
377
+ worst_frame: string | null;
378
+ samples: number | null;
379
+ art_samples: number | null;
380
+ }
381
+
382
+ /** What replaces `deformation: "unmeasured…"` once the comparison has run and accepted the part. */
383
+ export interface MotionDeformation {
384
+ verdict: 'pass' | 'fail' | 'not-measured';
385
+ bounds: { maxLocalDeformation: number; maxStretch: number | null; minStretch: number | null; deformMayFold: boolean };
386
+ rows: MotionResidual[];
387
+ schedule: {
388
+ animation: string;
389
+ fps: number;
390
+ phases: Array<'grid' | 'irr'>;
391
+ physics: MotionSchedule['physics'];
392
+ frames: { grid: number; irr: number };
393
+ held_out: boolean;
394
+ selection: string[];
395
+ };
396
+ }
397
+
398
+ export function motionDeformation(motion: AutoMotionSpec, report: MeshQualityReport): MotionDeformation {
399
+ const c = report.candidates[0];
400
+ const section = c?.motion ?? null;
401
+ const rows: MotionResidual[] = (section?.rows ?? []).map((r) => ({
402
+ code: r.code,
403
+ region: r.object.region,
404
+ state: r.state,
405
+ value: r.value,
406
+ bound: r.bound,
407
+ unit: r.unit,
408
+ worst_frame: r.worst?.frame?.id ?? null,
409
+ samples: r.sampling?.count ?? null,
410
+ art_samples: r.art?.samples ?? null,
411
+ }));
412
+ const walked = section?.schedule.walked ?? [];
413
+ const s = idleSchedule();
414
+ return {
415
+ verdict: section?.verdict ?? 'not-measured',
416
+ bounds: { maxLocalDeformation: motion.maxLocalDeformation, maxStretch: motion.maxStretch ?? null, minStretch: motion.minStretch ?? null, deformMayFold: motion.deformMayFold ?? false },
417
+ rows,
418
+ schedule: {
419
+ animation: MOTION_ANIMATION,
420
+ fps: IDLE_FPS,
421
+ phases: [...s.phases],
422
+ physics: s.physics,
423
+ frames: { grid: walked.filter((f) => f.phase === 'grid').length, irr: walked.filter((f) => f.phase === 'irr').length },
424
+ held_out: section?.schedule.heldOutClaim ?? false,
425
+ selection: [...(section?.schedule.selection ?? [])],
426
+ },
427
+ };
428
+ }
429
+
430
+ /** The attachment-level local-deformation row, the one the build line prints. */
431
+ export function localRow(report: MeshQualityReport): MeasureRow | null {
432
+ return report.candidates[0]?.motion?.rows.find((r) => r.code === 'MQ_LOCAL_DEFORMATION' && r.object.region === null) ?? null;
433
+ }
434
+
435
+ /** The build line's motion clause: `motion <value> <= <bound> at <frame>`. */
436
+ export function motionClause(report: MeshQualityReport): string {
437
+ const r = localRow(report);
438
+ if (r === null) return 'motion: no local-deformation row';
439
+ if (r.value === null || r.bound === null) return `motion ${r.state}: ${r.reason ?? ''}`;
440
+ const at = r.worst?.frame?.id ?? 'every frame (0 points at nothing)';
441
+ return `motion ${r.value} ${r.state === 'pass' ? r.bound.op : r.bound.op === '<=' ? '>' : '<'} ${r.bound.value} at ${at}`;
442
+ }
443
+
444
+ /** The comparison's whole document, as rig-c writes it, parsed so it can sit inside `mesh_report.json`. */
445
+ export function motionDocument(report: MeshQualityReport): unknown {
446
+ return JSON.parse(writeMeshQualityReport(report));
447
+ }