rig-c 0.0.0-stage → 2.20.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 (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. package/tools/png_probe.mjs +72 -0
@@ -0,0 +1,1188 @@
1
+ /**
2
+ * Two triangulations of one attachment compared in motion — the pose/motion
3
+ * half of the contract in docs/MESH_REDUCTION.md (issue #1221, stage C1:
4
+ * issue #1230).
5
+ *
6
+ * One operation lives here, `compareMeshesInMotion`: a reference build and one
7
+ * or more candidate builds of the same rig — the same rig with one
8
+ * attachment's mesh replaced (P8) — posed through rigc's own core over each
9
+ * build's model document, every candidate compared with the reference on the
10
+ * attachment's region UV square (§3), into one `mesh-quality-report/1`
11
+ * document with `operation: 'compare'` (`writeMeshQualityReport` writes its
12
+ * text; no second format).
13
+ *
14
+ * ## What it is for
15
+ *
16
+ * A reduced mesh is fewer vertices carrying the same art. Whether it still
17
+ * moves like the mesh it came from is a question no geometry row answers: a
18
+ * bend needs vertices where it bends, and a mesh without them passes every
19
+ * coverage bound at the setup pose and folds the art at the first key. So the
20
+ * candidate and the reference are posed on one schedule, and the art they
21
+ * carry — the same texel, named by its UV, which no deform and no repack moves
22
+ * — is compared in world units at every frame.
23
+ *
24
+ * ## The comparison, as implemented
25
+ *
26
+ * - **Poser.** `src/core/` over the model document (`readModel`), the walk
27
+ * `src/render_core.ts` poses an animation with — `poseRawAnimationEach`,
28
+ * the reset taken at the animation (`'animation'`) — and `poseRawSetup` for
29
+ * the setup pose; a skin is posed as `underSkin`, no skin as `underNoSkin`
30
+ * (what `noSkinView` in `src/render_core.ts` is). Nothing here links
31
+ * spine-core (`CUR07`), and the report says which poser ran (`poser`, P2).
32
+ * - **Equality (correction 5).** Every input of the two model documents is
33
+ * compared, leaf by leaf, except the allowlist `ALLOWED` states; the first
34
+ * difference refuses the call (`COMPARE_INPUTS_DIFFER`) naming its path and
35
+ * both values.
36
+ * - **The domain (correction 4).** The centre of every art pixel of the mask
37
+ * at the final threshold, carried to UV as `((x + 0.5) / w, (y + 0.5) / h)`,
38
+ * plus every reference hull vertex's UV. Each sample is carried by the UV
39
+ * triangle that contains it — barycentric, each coordinate at least `−1e-9`,
40
+ * the predicate epsilon of `src/mesh.ts` — in the reference and in each
41
+ * candidate, never by vertex index. A UV triangle inside the A39 area band
42
+ * (`areaBand` over the UVs) carries nothing. Two carriers are one hit only
43
+ * when the sample lies on a vertex or an edge both share (the same vertex
44
+ * indices carry it in each); any other pair is `COMPARE_UV_CARRIER_NOT_UNIQUE`.
45
+ * A sample no triangle carries is counted and listed, never dropped silently.
46
+ * - **The distance.** At each frame the carried point is the barycentric
47
+ * combination of the carrying triangle's posed world vertices — the raw
48
+ * pose's own doubles, weighted vertices through their bones. The row is the
49
+ * largest distance between the candidate's and the reference's carried point
50
+ * over the finite sample set and the frames, in world units. Alignment is by
51
+ * construction (one rig, one schedule); nothing is fitted.
52
+ * - **Stretch and inversion.** Each candidate triangle's map from its setup
53
+ * world triangle to its posed one: `stretchSingularValues` (`src/areaband.ts`),
54
+ * and A39's rule for a reversal — the band over the setup and the posed
55
+ * areas, a triangle with no setup area skipped, one collapsed onto zero not
56
+ * reversed. A slot `invariants.deformMayFold` names is exempt from the count's
57
+ * bound and its folds are listed.
58
+ * - **The reference (P8, §3 *Independent evidence*).** Each build's art fit at
59
+ * the setup pose is measured under its own bounds — the reference under
60
+ * `referenceArtFit`, each candidate under `candidateArtFit` — and a reference
61
+ * whose coverage, overshoot or undercut fails its bound is refused (`COMPARE_REFERENCE_FAILS`), never
62
+ * compared against. (b), the gate on each build, is where it runs: `build`
63
+ * writes the model document only after every assertion passed, and this
64
+ * module, handed the document's text, does not run it a second time.
65
+ * - **Schedule (P10, P11).** Frames by `FrameRef.id`; physics reset at time 0
66
+ * on every walk and stepped at the declared `dt` to each frame (the oracle's
67
+ * `stepSchedule` rule: from the last frame, steps of `dt` while before the
68
+ * next, then to it), the same steps for the reference and every candidate. A
69
+ * nonzero `warmupSteps` is refused, never run as zero.
70
+ *
71
+ * ## What it never does
72
+ *
73
+ * - Invent a bound, a frame or a split: rows with no bound are `undeclared`,
74
+ * the frames are the caller's, selection membership is the caller's.
75
+ * - Pose through spine-core, or reach the compiler.
76
+ * - Put a time, a path or a machine in the document.
77
+ */
78
+ import { artOf, MeshReductionError, r6 } from './mesh.ts';
79
+ import {
80
+ measureMeshQuality,
81
+ MESH_QUALITY_REPORT_SPEC,
82
+ type ArtFitBounds,
83
+ type ArtInput,
84
+ type AttachmentRef,
85
+ type CandidateReport,
86
+ type EffectiveSettings,
87
+ type EvidenceSection,
88
+ type FrameRef,
89
+ type MeasureRow,
90
+ type MeshCounts,
91
+ type MeshQualityReport,
92
+ type MotionBounds,
93
+ type MotionReading,
94
+ type MotionRowDetail,
95
+ type MotionSchedule,
96
+ type ScheduleUsed,
97
+ type SourceMesh,
98
+ type WorstSample,
99
+ } from './meshquality.ts';
100
+ import { areaBand, stretchSingularValues, triangleAreas } from './areaband.ts';
101
+ import { CoreInputError, readModel, underNoSkin, underSkin, type CompiledDocument } from './core/index.ts';
102
+ import { poseRawAnimationEach, poseRawSetup, type RawPose } from './core/raw.ts';
103
+ import { sampleTime } from './core/animation.ts';
104
+ import { CORE_DEFAULT_SKIN } from './core/skins.ts';
105
+ import { readVersion } from './package_meta.ts';
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // the input — §3, with what the contract left undefined defined here
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * One build compared — the reference or a candidate. The contract's
113
+ * revision 2 names three paths (`modelPath`, `skeletonPath`, `atlasPath`);
114
+ * stage C1 takes the model document's TEXT instead, because the core poses the
115
+ * document and nothing else (the Spine pair beside it is never read), and a
116
+ * module under `src/` that opened paths would tie the call to a disk layout.
117
+ */
118
+ export interface BuiltCandidate {
119
+ /** Correction 1: the caller's identity for the build, unique within one call, echoed in its report. */
120
+ id: string;
121
+ /** The build's `skeleton.model.json`, as `build` wrote it. */
122
+ model: string;
123
+ }
124
+
125
+ /** One attachment compared: its own art, threshold, frame and sample floors (correction 1, P9). */
126
+ export interface CompareAttachment {
127
+ attachment: AttachmentRef;
128
+ art: ArtInput;
129
+ /** P4: the final acceptance threshold. Samples and the setup art fit are taken at it. */
130
+ finalThreshold: 1;
131
+ /** P9: the fewest art samples the attachment's local-deformation row is taken over. */
132
+ minArtSamples: number;
133
+ /** Regions, part-local drawing px, y down; each region's samples are those whose UV falls in its closed polygon. */
134
+ regions: Array<{ name: string; polygon: Array<[number, number]>; minArtSamples: number }>;
135
+ }
136
+
137
+ /**
138
+ * Everything `compareMeshesInMotion` reads. §3's interface, with three fields
139
+ * it did not have, each one the agreed text requires an input for:
140
+ * `motionRequired` (P6), `perFrame` (P7's opt-in), and `schedule` may be null
141
+ * (P6: no motion supplied leaves `motion` null rather than an empty pass).
142
+ */
143
+ export interface MotionComparisonInput {
144
+ /** P8: the unreduced, independently gated source build. */
145
+ reference: BuiltCandidate;
146
+ candidates: BuiltCandidate[];
147
+ attachments: CompareAttachment[];
148
+ referenceArtFit: ArtFitBounds;
149
+ candidateArtFit: ArtFitBounds;
150
+ schedule: MotionSchedule | null;
151
+ bounds: MotionBounds;
152
+ motionRequired: boolean;
153
+ perFrame: boolean;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // fixed tolerances — the tree's own
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /** `pointInTriangle`'s epsilon in `src/mesh.ts`, applied to each barycentric coordinate. */
161
+ const CONTAINS = 1e-9;
162
+
163
+ /** §3 *Independent evidence* (a): the rows a reference is refused on — its art fit against its own mask, and only that. */
164
+ const REFERENCE_ART_FIT: readonly string[] = ['MQ_COVERAGE', 'MQ_OVERSHOOT', 'MQ_UNDERCUT'];
165
+
166
+ /** The predicate epsilon of `segmentsMeet` and `prunePolygon` (`src/mesh.ts`), for a region's closed boundary. */
167
+ const ON_BOUNDARY = 1e-9;
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // refusals
171
+ // ---------------------------------------------------------------------------
172
+
173
+ function refuse(code: string, message: string): never {
174
+ throw new MeshReductionError(code, message);
175
+ }
176
+
177
+ function nameOf(ref: AttachmentRef): string {
178
+ return `${ref.skin ?? '(no skin)'}/${ref.slot}/${ref.attachment}`;
179
+ }
180
+
181
+ function isObject(value: unknown): value is Record<string, unknown> {
182
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
183
+ }
184
+
185
+ function isFiniteNumber(value: unknown): value is number {
186
+ return typeof value === 'number' && Number.isFinite(value);
187
+ }
188
+
189
+ function missing(field: string, found: unknown, required: string): never {
190
+ refuse('COMPARE_INPUT_MISSING', `${field} is ${JSON.stringify(found)}; required ${required} — no field has a default`);
191
+ }
192
+
193
+ function validateFit(field: string, f: unknown): void {
194
+ if (!isObject(f) || !isFiniteNumber(f.minCoverage) || f.minCoverage < 0 || f.minCoverage > 1 || !isFiniteNumber(f.maxOvershoot) || f.maxOvershoot < 0 || !isFiniteNumber(f.maxUndercut) || f.maxUndercut < 0) {
195
+ missing(field, f, '{ minCoverage in 0..1, maxOvershoot >= 0, maxUndercut >= 0 }');
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Everything the comparison refuses before a document is read, by code. The
201
+ * art itself (mask, threshold, frame) is `measureMeshQuality`'s to refuse, by
202
+ * its own codes, when the setup art fit is taken.
203
+ */
204
+ function validateInput(input: MotionComparisonInput): void {
205
+ if (!isObject(input)) missing('the input', input, 'a MotionComparisonInput object');
206
+ const builds = [input.reference, ...(Array.isArray(input.candidates) ? input.candidates : [])];
207
+ if (!Array.isArray(input.candidates) || input.candidates.length === 0) missing('candidates', input.candidates, 'a non-empty list of builds');
208
+ const ids = new Map<string, number>();
209
+ builds.forEach((b, i) => {
210
+ const who = i === 0 ? 'reference' : `candidates[${i - 1}]`;
211
+ if (!isObject(b) || typeof b.id !== 'string' || b.id === '' || typeof b.model !== 'string') missing(who, b, '{ id: a non-empty string, model: the model document text }');
212
+ const before = ids.get(b.id);
213
+ if (before !== undefined) {
214
+ refuse('COMPARE_INPUT_MISSING', `${who} has the id "${b.id}" that ${before === 0 ? 'reference' : `candidates[${before - 1}]`} has; required every build's id unique within one call (correction 1)`);
215
+ }
216
+ ids.set(b.id, i);
217
+ });
218
+ if (!Array.isArray(input.attachments) || input.attachments.length === 0) missing('attachments', input.attachments, 'a non-empty list of the attachments compared');
219
+ const seen = new Set<string>();
220
+ input.attachments.forEach((a, i) => {
221
+ const at = `attachments[${i}]`;
222
+ if (!isObject(a)) missing(at, a, 'a CompareAttachment object');
223
+ const ref = a.attachment;
224
+ if (!isObject(ref) || typeof ref.slot !== 'string' || ref.slot === '' || typeof ref.attachment !== 'string' || ref.attachment === '' || !(ref.skin === null || typeof ref.skin === 'string')) {
225
+ missing(`${at}.attachment`, ref, '{ skin: string | null, slot: string, attachment: string }');
226
+ }
227
+ const key = nameOf(ref);
228
+ if (seen.has(key)) refuse('COMPARE_INPUT_MISSING', `${at}: attachment ${key} is listed twice; required each attachment once`);
229
+ seen.add(key);
230
+ if (a.finalThreshold !== 1) missing(`${at}.finalThreshold`, a.finalThreshold, '1 — P4: the final acceptance is measured at alpha >= 1');
231
+ if (!Number.isInteger(a.minArtSamples) || a.minArtSamples < 1) missing(`${at}.minArtSamples`, a.minArtSamples, 'a whole number >= 1 (P9)');
232
+ if (!Array.isArray(a.regions)) missing(`${at}.regions`, a.regions, 'a list of regions (empty when none)');
233
+ const names = new Set<string>();
234
+ a.regions.forEach((r, k) => {
235
+ const rat = `${at}.regions[${k}]`;
236
+ if (!isObject(r) || typeof r.name !== 'string' || r.name === '') missing(rat, r, '{ name: a non-empty string, polygon, minArtSamples }');
237
+ if (names.has(r.name)) refuse('COMPARE_INPUT_MISSING', `${rat}: region "${r.name}" is named twice; required each region once`);
238
+ names.add(r.name);
239
+ if (!Array.isArray(r.polygon) || r.polygon.length < 3 || !r.polygon.every((p) => Array.isArray(p) && p.length === 2 && isFiniteNumber(p[0]) && isFiniteNumber(p[1]))) {
240
+ missing(`${rat}.polygon`, r.polygon, 'at least three finite [x, y] points, part-local drawing px');
241
+ }
242
+ if (!Number.isInteger(r.minArtSamples) || r.minArtSamples < 1) missing(`${rat}.minArtSamples`, r.minArtSamples, 'a whole number >= 1 (P9)');
243
+ });
244
+ });
245
+ const skins = new Set(input.attachments.map((a) => a.attachment.skin));
246
+ if (skins.size > 1) {
247
+ refuse('COMPARE_INPUT_MISSING', `attachments name ${skins.size} skins (${[...skins].map((s) => s ?? '(no skin)').join(', ')}); required one skin per call — each skin is posed as its own view, so compare each in its own call`);
248
+ }
249
+ validateFit('referenceArtFit', input.referenceArtFit);
250
+ validateFit('candidateArtFit', input.candidateArtFit);
251
+ const b = input.bounds;
252
+ if (!isObject(b) || !isFiniteNumber(b.maxLocalDeformation) || b.maxLocalDeformation < 0) missing('bounds', b, '{ maxLocalDeformation: a finite world distance >= 0, maxStretch?, minStretch? }');
253
+ if (b.maxStretch !== undefined && (!isFiniteNumber(b.maxStretch) || b.maxStretch < 0)) missing('bounds.maxStretch', b.maxStretch, 'a finite ratio >= 0, or the field left out');
254
+ if (b.minStretch !== undefined && (!isFiniteNumber(b.minStretch) || b.minStretch < 0)) missing('bounds.minStretch', b.minStretch, 'a finite ratio >= 0, or the field left out');
255
+ if (typeof input.motionRequired !== 'boolean') missing('motionRequired', input.motionRequired, 'true or false (P6)');
256
+ if (typeof input.perFrame !== 'boolean') missing('perFrame', input.perFrame, 'true or false (P7)');
257
+ const s = input.schedule;
258
+ if (s === null) return;
259
+ if (!isObject(s)) missing('schedule', s, 'a MotionSchedule, or null for no motion');
260
+ const physics = s.physics;
261
+ if (!isObject(physics) || (physics.mode !== 'none' && physics.mode !== 'step')) missing('schedule.physics', physics, "{ mode: 'none' } or { mode: 'step', dt, warmupSteps: 0 }");
262
+ if (physics.mode === 'step') {
263
+ if (!isFiniteNumber(physics.dt) || physics.dt <= 0) missing('schedule.physics.dt', physics.dt, 'a finite step > 0, in seconds (P10)');
264
+ const warmup: unknown = physics.warmupSteps;
265
+ if (warmup !== 0) {
266
+ refuse(
267
+ 'COMPARE_WARMUP_UNSUPPORTED',
268
+ `schedule.physics.warmupSteps is ${JSON.stringify(warmup)}; required 0 — P10: no warm-up exists in the tree, so physics resets at time 0 and steps from there, and a nonzero warm-up is refused rather than run as zero`,
269
+ );
270
+ }
271
+ }
272
+ if (!Array.isArray(s.phases) || s.phases.length === 0 || !s.phases.every((p) => p === 'grid' || p === 'irr') || new Set(s.phases).size !== s.phases.length) {
273
+ missing('schedule.phases', s.phases, "a non-empty list of distinct phases, each 'grid' or 'irr'");
274
+ }
275
+ if (!Array.isArray(s.frames)) missing('schedule.frames', s.frames, "a list of 'setup', { animation, times } and { animation, fps }");
276
+ s.frames.forEach((f, i) => {
277
+ const at = `schedule.frames[${i}]`;
278
+ if (f === 'setup') return;
279
+ if (!isObject(f) || typeof f.animation !== 'string' || f.animation === '') missing(at, f, "'setup', { animation, times: [...] } or { animation, fps }");
280
+ if ('times' in f) {
281
+ if (!Array.isArray(f.times) || f.times.length === 0 || !f.times.every((t) => isFiniteNumber(t) && t >= 0)) missing(`${at}.times`, f.times, 'a non-empty list of finite times >= 0, in seconds');
282
+ } else if (!isFiniteNumber(f.fps) || f.fps <= 0) missing(`${at}.fps`, (f as { fps?: unknown }).fps, 'a finite rate > 0');
283
+ });
284
+ if (!Array.isArray(s.selection) || !s.selection.every((id) => typeof id === 'string')) missing('schedule.selection', s.selection, 'a list of frame ids (P11; empty when no frame chose a candidate)');
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // correction 5 — every input not on the allowlist equal
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /** One step of a path into a document: a key, or an array index with the element's `name` when it has one. */
292
+ type Step = { key: string } | { index: number; name: string | null; element: unknown };
293
+
294
+ function pathText(path: readonly Step[]): string {
295
+ let out = '';
296
+ for (const s of path) {
297
+ if ('key' in s) out += out === '' ? s.key : `.${s.key}`;
298
+ else out += s.name === null ? `[${s.index}]` : `[${JSON.stringify(s.name)}]`;
299
+ }
300
+ return out;
301
+ }
302
+
303
+ function shown(value: unknown): string {
304
+ if (value === undefined) return 'nothing (the field is absent)';
305
+ const text = JSON.stringify(value);
306
+ return text.length > 160 ? `${text.slice(0, 157)}...` : text;
307
+ }
308
+
309
+ /** The skin-table skin an attachment reference resolves through: its own, or the default skin under no skin. */
310
+ function tableSkin(ref: AttachmentRef): string {
311
+ return ref.skin ?? CORE_DEFAULT_SKIN;
312
+ }
313
+
314
+ const keyOf = (s: Step | undefined): string | null => (s !== undefined && 'key' in s ? s.key : null);
315
+ const nameAt = (s: Step | undefined): string | null => (s !== undefined && 'index' in s ? s.name : null);
316
+
317
+ /**
318
+ * The allowlist (correction 5, §3), as paths into the model document. Exactly:
319
+ * (a) the compared attachments' `uvs`, `triangles`, `vertices` (positions and
320
+ * bindings), `hull` and `edges`; (b) what follows from them by topology — a
321
+ * deform key's run (every field but its `time` and `curve`), a `transform`
322
+ * deform key's report (`deformTransforms`, every field but the key's identity
323
+ * and time), the slot's `meshes` entry (every field but `slot` and
324
+ * `attachments`), the bones the meshes bind (`meshBones`, and each physics
325
+ * constraint's `drivesMesh`), and the slot's `rig.meshKinds`,
326
+ * `rig.meshDeclaredBones` and `rig.meshSoftBones`; (c) atlas layout — `pages` —
327
+ * which the comparison never reads, since it takes each mesh's region UVs from
328
+ * the document's own attachment (the raw pose's `uvs`), so the conversion from
329
+ * page UVs back to region UVs holds by construction; and the digest of the
330
+ * Spine file written beside the document (`spine.sha256`), which changes with
331
+ * every byte of the mesh. Nothing else may differ.
332
+ */
333
+ function allowed(path: readonly Step[], compared: readonly AttachmentRef[]): boolean {
334
+ const top = keyOf(path[0]);
335
+ const slots = new Set(compared.map((r) => r.slot));
336
+ const isCompared = (skin: string | null, slot: string | null, att: string | null): boolean => compared.some((r) => tableSkin(r) === skin && r.slot === slot && r.attachment === att);
337
+ if (top === 'pages' || top === 'meshBones') return true;
338
+ if (top === 'spine' && keyOf(path[1]) === 'sha256') return true;
339
+ if (top === 'skins' && path.length >= 6 && keyOf(path[2]) === 'attachments') {
340
+ return isCompared(nameAt(path[1]), keyOf(path[3]), keyOf(path[4])) && ['uvs', 'triangles', 'vertices', 'hull', 'edges'].includes(keyOf(path[5]) ?? '');
341
+ }
342
+ if (top === 'animations' && path.length >= 11 && keyOf(path[2]) === 'attachments' && keyOf(path[8]) === 'deform') {
343
+ const field = keyOf(path[10]);
344
+ return isCompared(nameAt(path[3]), nameAt(path[5]), nameAt(path[7])) && field !== null && field !== 'time' && field !== 'curve';
345
+ }
346
+ if (top === 'deformTransforms' && path.length >= 3) {
347
+ const step = path[1];
348
+ const e = 'index' in step ? step.element : undefined;
349
+ if (!isObject(e)) return false;
350
+ const field = keyOf(path[2]) ?? '';
351
+ return isCompared(typeof e.skin === 'string' ? e.skin : null, typeof e.slot === 'string' ? e.slot : null, typeof e.attachment === 'string' ? e.attachment : null) && !['animation', 'skin', 'slot', 'attachment', 'time'].includes(field);
352
+ }
353
+ if (top === 'meshes' && path.length >= 3) {
354
+ const step = path[1];
355
+ const e = 'index' in step ? step.element : undefined;
356
+ const field = keyOf(path[2]) ?? '';
357
+ return isObject(e) && typeof e.slot === 'string' && slots.has(e.slot) && field !== 'slot' && field !== 'attachments';
358
+ }
359
+ if (top === 'physics' && keyOf(path[2]) === 'drivesMesh') return true;
360
+ if (top === 'rig' && path.length >= 3 && ['meshKinds', 'meshDeclaredBones', 'meshSoftBones'].includes(keyOf(path[1]) ?? '')) return slots.has(keyOf(path[2]) ?? '');
361
+ return false;
362
+ }
363
+
364
+ /** The first leaf at which two documents differ outside the allowlist, in the reference's key order; null when none does. */
365
+ function firstDifference(a: unknown, b: unknown, path: Step[], compared: readonly AttachmentRef[]): { path: string; reference: unknown; candidate: unknown } | null {
366
+ if (path.length > 0 && allowed(path, compared)) return null;
367
+ if (Array.isArray(a) && Array.isArray(b)) {
368
+ if (a.length !== b.length) return { path: `${pathText(path)}.length`, reference: a.length, candidate: b.length };
369
+ for (let i = 0; i < a.length; i++) {
370
+ const e = a[i];
371
+ const name = isObject(e) && typeof e.name === 'string' ? e.name : null;
372
+ const d = firstDifference(e, b[i], [...path, { index: i, name, element: e }], compared);
373
+ if (d !== null) return d;
374
+ }
375
+ return null;
376
+ }
377
+ if (isObject(a) && isObject(b)) {
378
+ const keys = [...Object.keys(a), ...Object.keys(b).filter((k) => !(k in a))];
379
+ for (const k of keys) {
380
+ const d = firstDifference(a[k], b[k], [...path, { key: k }], compared);
381
+ if (d !== null) return d;
382
+ }
383
+ return null;
384
+ }
385
+ if (Object.is(a, b) || (typeof a === 'number' && a === b)) return null;
386
+ return { path: pathText(path), reference: a, candidate: b };
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // the schedule — P10, P11
391
+ // ---------------------------------------------------------------------------
392
+
393
+ interface Walk {
394
+ animation: string;
395
+ phase: 'grid' | 'irr' | null;
396
+ /** The deltas the raw walk takes after its reset pose. */
397
+ deltas: number[];
398
+ }
399
+
400
+ interface Planned {
401
+ ref: FrameRef;
402
+ /** Index into the walks, or null for the setup pose. */
403
+ walk: number | null;
404
+ /** The pose of that walk the frame is (0 = the reset pose at time 0). */
405
+ pose: number;
406
+ }
407
+
408
+ /** A time as a frame id names it: on the `r6` grid. */
409
+ const idTime = (t: number): number => r6(t);
410
+
411
+ /**
412
+ * The frames and walks a schedule names, in walk order: `setup` first where
413
+ * listed, then each entry's frames in the order given — a rate under each
414
+ * phase in `phases` order, explicit times once. A rate walks
415
+ * `count = round(duration × fps)` intervals (the render's count,
416
+ * `sampleAnimation` in `src/render_shared.ts`): `grid` takes `count + 1`
417
+ * samples at `sampleTime('grid', …)` — `i / fps` on a whole number of frames —
418
+ * and `irr` takes `count` at `sampleTime('irr', …)`, each a frame interval's
419
+ * `IRR_OFFSET` past its grid sample (`src/core/animation.ts`). Explicit times
420
+ * have no phase: their id spells `explicit` where a phase would be.
421
+ */
422
+ function planSchedule(schedule: MotionSchedule, doc: CompiledDocument, physicsConstraints: number): { frames: Planned[]; walks: Walk[]; walkSteps: number[] } {
423
+ const selection = new Set(schedule.selection);
424
+ const physics = schedule.physics;
425
+ if (physics.mode === 'none' && physicsConstraints > 0) {
426
+ refuse('COMPARE_INPUT_MISSING', `schedule.physics.mode is "none" and the reference declares ${physicsConstraints} physics constraint(s); required { mode: 'step', dt, warmupSteps: 0 } — P10: mode and dt are required whenever a physics constraint is active`);
427
+ }
428
+ const frames: Planned[] = [];
429
+ const walks: Walk[] = [];
430
+ const walkSteps: number[] = [];
431
+ const ids = new Set<string>();
432
+ const role = (id: string): FrameRef['role'] => (id === 'setup' ? 'baseline' : selection.has(id) ? 'selection' : 'held-out');
433
+ const add = (ref: Omit<FrameRef, 'role'>, walk: number | null, pose: number): void => {
434
+ if (ids.has(ref.id)) refuse('COMPARE_INPUT_MISSING', `schedule.frames names the frame "${ref.id}" twice; required every frame id once (P11: a frame is named by one stable id)`);
435
+ ids.add(ref.id);
436
+ frames.push({ ref: { ...ref, role: role(ref.id) }, walk, pose });
437
+ };
438
+ const walkTo = (animation: string, phase: 'grid' | 'irr' | null, targets: Array<{ time: number; index: number }>): void => {
439
+ const sorted = [...new Set(targets.map((t) => t.time))].sort((a, b) => a - b);
440
+ // Absolute step times from the reset at 0: the oracle's `stepSchedule` rule under physics, one jump per frame without.
441
+ const absolute: number[] = [];
442
+ const poseAt = new Map<number, number>();
443
+ let now = 0;
444
+ for (const t of sorted) {
445
+ if (t > now) {
446
+ if (physics.mode === 'step') {
447
+ const from = now;
448
+ for (let k = 1; from + k * physics.dt < t; k++) absolute.push(from + k * physics.dt);
449
+ }
450
+ absolute.push(t);
451
+ now = t;
452
+ }
453
+ poseAt.set(t, t === 0 ? 0 : absolute.length);
454
+ }
455
+ const deltas = absolute.map((s, i) => s - (i === 0 ? 0 : absolute[i - 1]));
456
+ const w = walks.length;
457
+ walks.push({ animation, phase, deltas });
458
+ walkSteps.push(deltas.length);
459
+ for (const t of targets) {
460
+ const time = idTime(t.time);
461
+ add({ id: `${animation}@${phase ?? 'explicit'}@${time}`, animation, phase, time, index: t.index }, w, poseAt.get(t.time) ?? 0);
462
+ }
463
+ };
464
+ schedule.frames.forEach((f, i) => {
465
+ if (f === 'setup') {
466
+ add({ id: 'setup', animation: null, phase: null, time: null, index: null }, null, 0);
467
+ return;
468
+ }
469
+ const anim = doc.animations.find((a) => a.name === f.animation);
470
+ if (anim === undefined) missing(`schedule.frames[${i}].animation`, f.animation, `one of the reference's animations [${doc.animations.map((a) => a.name).join(', ')}]`);
471
+ const d = anim.timelines.duration;
472
+ if ('times' in f) {
473
+ const late = f.times.find((t) => t > d);
474
+ if (late !== undefined) missing(`schedule.frames[${i}].times`, late, `a time at most the animation's duration ${d} — past it the track holds, and the id would name a time that was not posed`);
475
+ walkTo(f.animation, null, f.times.map((time, index) => ({ time, index })));
476
+ return;
477
+ }
478
+ const count = Math.round(d * f.fps);
479
+ for (const phase of schedule.phases) {
480
+ const n = phase === 'grid' ? count + 1 : count;
481
+ const targets: Array<{ time: number; index: number }> = [];
482
+ for (let k = 0; k < n; k++) targets.push({ time: sampleTime(phase, d, k, n), index: k });
483
+ if (targets.length > 0) walkTo(f.animation, phase, targets);
484
+ }
485
+ });
486
+ const unscheduled = schedule.selection.filter((id) => !ids.has(id));
487
+ if (unscheduled.length > 0) {
488
+ refuse('COMPARE_INPUT_MISSING', `schedule.selection names ${unscheduled.map((id) => `"${id}"`).join(', ')}, which the schedule does not walk; required every selection id to be a scheduled frame's id (P11: rigc invents no split, and a selection it cannot place would be read as held out)`);
489
+ }
490
+ return { frames, walks, walkSteps };
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // posing — rigc's core, the same steps for every build
495
+ // ---------------------------------------------------------------------------
496
+
497
+ /** One build posed at every planned frame: the compared attachments' world vertices (null where the slot drew something else). */
498
+ interface Posed {
499
+ setup: Array<number[] | null>;
500
+ /** Per planned frame, per attachment. */
501
+ frames: Array<Array<number[] | null>>;
502
+ /** Each bone's setup world 2×2, by name. */
503
+ setupBones: Map<string, [number, number, number, number]>;
504
+ }
505
+
506
+ function viewOf(doc: CompiledDocument, skin: string | null): CompiledDocument {
507
+ return skin === null ? underNoSkin(doc) : underSkin(doc, skin);
508
+ }
509
+
510
+ /** The compared attachment's world vertices in one pose, or null when its slot shows something else or nothing. */
511
+ function drawnOf(pose: RawPose, ref: AttachmentRef): number[] | null {
512
+ const shownRow = pose.shown.find((s) => s.slot === ref.slot);
513
+ if (shownRow === undefined || shownRow.placeholder !== ref.attachment || shownRow.skin !== tableSkin(ref) || shownRow.geometry.kind !== 'mesh') return null;
514
+ const row = pose.drawn.find((d) => d.slot === ref.slot);
515
+ return row === undefined ? null : row.vertices;
516
+ }
517
+
518
+ function poseBuild(doc: CompiledDocument, refs: readonly AttachmentRef[], frames: readonly Planned[], walks: readonly Walk[]): Posed | { refused: string } {
519
+ try {
520
+ const view = viewOf(doc, refs[0].skin);
521
+ const setupPose = poseRawSetup(view);
522
+ const setup = refs.map((r) => drawnOf(setupPose, r));
523
+ const setupBones = new Map(setupPose.bones.map((b): [string, [number, number, number, number]] => [b.name, [b.a, b.b, b.c, b.d]]));
524
+ const out: Array<Array<number[] | null>> = frames.map(() => refs.map(() => null));
525
+ frames.forEach((f, i) => {
526
+ if (f.walk === null) out[i] = setup;
527
+ });
528
+ walks.forEach((w, wi) => {
529
+ const wanted = new Map<number, number[]>();
530
+ frames.forEach((f, i) => {
531
+ if (f.walk !== wi) return;
532
+ const list = wanted.get(f.pose) ?? [];
533
+ list.push(i);
534
+ wanted.set(f.pose, list);
535
+ });
536
+ poseRawAnimationEach(view, w.animation, w.deltas, {}, 'animation', (pose, index) => {
537
+ const at = wanted.get(index);
538
+ if (at === undefined) return;
539
+ const drawn = refs.map((r) => drawnOf(pose, r));
540
+ for (const i of at) out[i] = drawn;
541
+ });
542
+ });
543
+ return { setup, frames: out, setupBones };
544
+ } catch (err) {
545
+ if (err instanceof CoreInputError) return { refused: `rigc's core does not pose this build: ${err.message}` };
546
+ throw err;
547
+ }
548
+ }
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // §3 — the samples and their carriers
552
+ // ---------------------------------------------------------------------------
553
+
554
+ interface Sample {
555
+ uv: [number, number];
556
+ /** Part-local drawing px, y down — where a region's polygon is read. */
557
+ px: [number, number];
558
+ /** An art pixel of the mask grid, or a reference hull vertex. */
559
+ pixel: [number, number] | null;
560
+ vertex: number | null;
561
+ }
562
+
563
+ interface Carrier {
564
+ triangle: number;
565
+ corners: [number, number, number];
566
+ bary: [number, number, number];
567
+ }
568
+
569
+ interface MeshOf {
570
+ uvs: number[];
571
+ triangles: number[];
572
+ hull: number;
573
+ weights: SourceMesh['weights'];
574
+ }
575
+
576
+ /**
577
+ * Each sample's carrier in one mesh's UV triangulation — §3 and correction 4.
578
+ * Throws `COMPARE_UV_CARRIER_NOT_UNIQUE` for a sample two triangles carry
579
+ * other than across a vertex or edge they share.
580
+ */
581
+ export function uvCarriers(uvs: readonly number[], triangles: readonly number[], samples: ReadonlyArray<{ uv: [number, number] }>, who: string): Array<Carrier | null> {
582
+ const areas = triangleAreas(uvs, triangles);
583
+ const band = areaBand(areas, uvs);
584
+ const live: number[] = [];
585
+ const boxes: number[] = [];
586
+ for (let t = 0; t < areas.length; t++) {
587
+ if (Math.abs(areas[t]) <= band) continue;
588
+ live.push(t);
589
+ const xs = [uvs[triangles[t * 3] * 2], uvs[triangles[t * 3 + 1] * 2], uvs[triangles[t * 3 + 2] * 2]];
590
+ const ys = [uvs[triangles[t * 3] * 2 + 1], uvs[triangles[t * 3 + 1] * 2 + 1], uvs[triangles[t * 3 + 2] * 2 + 1]];
591
+ boxes.push(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys));
592
+ }
593
+ return samples.map((s) => {
594
+ const [px, py] = s.uv;
595
+ const hits: Carrier[] = [];
596
+ live.forEach((t, k) => {
597
+ const slack = 1e-9;
598
+ if (px < boxes[k * 4] - slack || py < boxes[k * 4 + 1] - slack || px > boxes[k * 4 + 2] + slack || py > boxes[k * 4 + 3] + slack) return;
599
+ const i0 = triangles[t * 3];
600
+ const i1 = triangles[t * 3 + 1];
601
+ const i2 = triangles[t * 3 + 2];
602
+ const ax = uvs[i0 * 2];
603
+ const ay = uvs[i0 * 2 + 1];
604
+ const bx = uvs[i1 * 2] - ax;
605
+ const by = uvs[i1 * 2 + 1] - ay;
606
+ const cx = uvs[i2 * 2] - ax;
607
+ const cy = uvs[i2 * 2 + 1] - ay;
608
+ const qx = px - ax;
609
+ const qy = py - ay;
610
+ const det = bx * cy - cx * by;
611
+ const l1 = (qx * cy - cx * qy) / det;
612
+ const l2 = (bx * qy - qx * by) / det;
613
+ const l0 = 1 - l1 - l2;
614
+ if (l0 < -CONTAINS || l1 < -CONTAINS || l2 < -CONTAINS) return;
615
+ hits.push({ triangle: t, corners: [i0, i1, i2], bary: [l0, l1, l2] });
616
+ });
617
+ if (hits.length === 0) return null;
618
+ if (hits.length === 1) return hits[0];
619
+ // Several carriers are one hit only across a vertex or an edge they all share: the sample's support — the corners
620
+ // it does not sit at zero weight on — is then the same vertex indices in each, and the carried point is theirs.
621
+ const support = (c: Carrier): string =>
622
+ c.corners
623
+ .filter((_v, k) => c.bary[k] > CONTAINS)
624
+ .sort((a, b) => a - b)
625
+ .join(',');
626
+ const first = support(hits[0]);
627
+ const shared = first.split(',').length <= 2 && hits.every((c) => support(c) === first);
628
+ if (!shared) {
629
+ refuse(
630
+ 'COMPARE_UV_CARRIER_NOT_UNIQUE',
631
+ `${who}: the sample at uv (${px}, ${py}) lies in ${hits.length} UV triangles — ${hits.map((c) => `triangle ${c.triangle} (vertices ${c.corners.join(', ')})`).join(' and ')} — that do not meet there at a shared vertex or edge; required one carrier per sample (correction 4: overlapping or folded UV triangles would carry it by an arbitrary one)`,
632
+ );
633
+ }
634
+ return hits[0];
635
+ });
636
+ }
637
+
638
+ /** The point a carrier puts the sample at over one pose's world vertices. */
639
+ function carried(c: Carrier, world: readonly number[]): [number, number] {
640
+ const [i0, i1, i2] = c.corners;
641
+ const [l0, l1, l2] = c.bary;
642
+ return [l0 * world[i0 * 2] + l1 * world[i1 * 2] + l2 * world[i2 * 2], l0 * world[i0 * 2 + 1] + l1 * world[i1 * 2 + 1] + l2 * world[i2 * 2 + 1]];
643
+ }
644
+
645
+ /** In or on a closed polygon: on an edge within `ON_BOUNDARY` px, else by an even-odd ray cast. */
646
+ function inClosedPolygon(p: readonly [number, number], poly: ReadonlyArray<readonly [number, number]>): boolean {
647
+ for (let i = 0; i < poly.length; i++) {
648
+ const a = poly[i];
649
+ const b = poly[(i + 1) % poly.length];
650
+ const dx = b[0] - a[0];
651
+ const dy = b[1] - a[1];
652
+ const len = dx * dx + dy * dy;
653
+ const t = len === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len));
654
+ if (Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t) <= ON_BOUNDARY) return true;
655
+ }
656
+ let inside = false;
657
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
658
+ const [xi, yi] = poly[i];
659
+ const [xj, yj] = poly[j];
660
+ if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
661
+ }
662
+ return inside;
663
+ }
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // rows
667
+ // ---------------------------------------------------------------------------
668
+
669
+ interface Built {
670
+ row: MeasureRow;
671
+ required: boolean;
672
+ }
673
+
674
+ /** One frame's reading of one row: the frame (by plan index), its value and where it was taken. */
675
+ interface Reading {
676
+ frame: number;
677
+ value: number;
678
+ at: WorstSample['at'];
679
+ }
680
+
681
+ function judged(value: number, bound: { op: '<=' | '>='; value: number }): 'pass' | 'fail' {
682
+ return (bound.op === '<=' ? value <= bound.value : value >= bound.value) ? 'pass' : 'fail';
683
+ }
684
+
685
+ function extreme(readings: readonly Reading[], dir: 'max' | 'min'): Reading | null {
686
+ let best: Reading | null = null;
687
+ for (const r of readings) if (best === null || (dir === 'max' ? r.value > best.value : r.value < best.value)) best = r;
688
+ return best;
689
+ }
690
+
691
+ interface RowPlan {
692
+ code: string;
693
+ attachment: AttachmentRef;
694
+ region: string | null;
695
+ unit: MeasureRow['unit'];
696
+ dir: 'max' | 'min';
697
+ bound: MeasureRow['bound'];
698
+ /** Whether a value of 0 (max rows) has nothing worse to point at — distance and count rows. */
699
+ zeroIsIdeal: boolean;
700
+ }
701
+
702
+ /**
703
+ * A motion row from its per-frame readings: the worst over every frame (§4,
704
+ * *Transition in time*: the worst over the phases), then the same taken per
705
+ * phase and per role, each against the row's bound.
706
+ */
707
+ function motionRow(plan: RowPlan, readings: readonly Reading[], frames: readonly Planned[], schedule: MotionSchedule, notDrawn: readonly string[], extra: Partial<MotionRowDetail>): Built {
708
+ const object = { attachment: plan.attachment, region: plan.region };
709
+ const reading = (group: readonly Reading[]): MotionReading => {
710
+ const w = extreme(group, plan.dir);
711
+ if (w === null) return { value: null, state: 'not-measurable', frame: null };
712
+ const value = r6(w.value);
713
+ return { value, state: plan.bound === null ? 'undeclared' : judged(value, plan.bound), frame: frames[w.frame].ref.id };
714
+ };
715
+ const phases: Array<'grid' | 'irr' | null> = [...schedule.phases];
716
+ if (frames.some((f) => f.ref.phase === null)) phases.push(null);
717
+ const byPhase = phases.map((phase) => ({ phase, ...reading(readings.filter((r) => frames[r.frame].ref.phase === phase)) }));
718
+ const real = byPhase.filter((p) => p.phase !== null && p.value !== null);
719
+ const passing = real.find((p) => p.state === 'pass');
720
+ const failing = real.find((p) => p.state === 'fail');
721
+ const phasesDisagree =
722
+ passing !== undefined && failing !== undefined && passing.frame !== null && failing.frame !== null
723
+ ? {
724
+ pass: passing.frame,
725
+ fail: failing.frame,
726
+ sentence: `${plan.code} passes under phase ${passing.phase} (worst at ${passing.frame}) and fails under phase ${failing.phase} (worst at ${failing.frame}); the row is the worst over both`,
727
+ }
728
+ : null;
729
+ const byRoleOf = (role: FrameRef['role']): MotionReading | null => {
730
+ const group = readings.filter((r) => frames[r.frame].ref.role === role);
731
+ return frames.some((f) => f.ref.role === role) ? reading(group) : null;
732
+ };
733
+ const detail: MotionRowDetail = {
734
+ frames: { measured: readings.length, notDrawn: [...notDrawn] },
735
+ byPhase,
736
+ phasesDisagree,
737
+ byRole: { baseline: byRoleOf('baseline'), selection: byRoleOf('selection'), heldOut: byRoleOf('held-out') },
738
+ ...extra,
739
+ };
740
+ const w = extreme(readings, plan.dir);
741
+ if (w === null) {
742
+ return {
743
+ row: { code: plan.code, object, state: 'not-measurable', value: null, bound: null, unit: plan.unit, worst: null, reason: `${nameOf(plan.attachment)}: no scheduled frame draws the attachment, so there is nothing to measure`, motion: detail },
744
+ required: plan.bound !== null,
745
+ };
746
+ }
747
+ const value = r6(w.value);
748
+ const worst: WorstSample = plan.zeroIsIdeal && value === 0 ? { at: {} } : { at: w.at, frame: frames[w.frame].ref };
749
+ return {
750
+ row: { code: plan.code, object, state: plan.bound === null ? 'undeclared' : judged(value, plan.bound), value, bound: plan.bound, unit: plan.unit, worst, reason: null, motion: detail },
751
+ required: plan.bound !== null,
752
+ };
753
+ }
754
+
755
+ function unmeasured(plan: RowPlan, reason: string, extra: Partial<MeasureRow> = {}): Built {
756
+ return {
757
+ row: { code: plan.code, object: { attachment: plan.attachment, region: plan.region }, state: 'not-measurable', value: null, bound: null, unit: plan.unit, worst: null, reason, ...extra },
758
+ required: plan.bound !== null,
759
+ };
760
+ }
761
+
762
+ /** §2's section rule: `measured` is pass + fail; `fail` when a required row failed, `pass` only when every required row passed and one row did. */
763
+ function sectionOf(built: readonly Built[]): EvidenceSection {
764
+ const summary = { pass: 0, fail: 0, undeclared: 0, refused: 0, notMeasurable: 0, measured: 0 };
765
+ for (const { row } of built) {
766
+ if (row.state === 'pass') summary.pass++;
767
+ else if (row.state === 'fail') summary.fail++;
768
+ else if (row.state === 'undeclared') summary.undeclared++;
769
+ else if (row.state === 'refused') summary.refused++;
770
+ else summary.notMeasurable++;
771
+ }
772
+ summary.measured = summary.pass + summary.fail;
773
+ const required = built.filter((b) => b.required);
774
+ let verdict: EvidenceSection['verdict'];
775
+ if (required.some((b) => b.row.state === 'fail')) verdict = 'fail';
776
+ else if (required.every((b) => b.row.state === 'pass') && summary.pass > 0) verdict = 'pass';
777
+ else verdict = 'not-measured';
778
+ return { rows: built.map((b) => b.row), summary, verdict };
779
+ }
780
+
781
+ /** Two sections joined — one per attachment, in skeleton order — under §2's section rule over their verdicts. */
782
+ function joinSections(sections: readonly EvidenceSection[]): EvidenceSection {
783
+ const summary = { pass: 0, fail: 0, undeclared: 0, refused: 0, notMeasurable: 0, measured: 0 };
784
+ for (const s of sections) for (const k of Object.keys(summary) as Array<keyof typeof summary>) summary[k] += s.summary[k];
785
+ const verdict: EvidenceSection['verdict'] = sections.some((s) => s.verdict === 'fail') ? 'fail' : sections.every((s) => s.verdict === 'pass') ? 'pass' : 'not-measured';
786
+ return { rows: sections.flatMap((s) => s.rows), summary, verdict };
787
+ }
788
+
789
+ const ROW_ORDER = (a: MeasureRow, b: MeasureRow): number => {
790
+ if (a.code !== b.code) return a.code < b.code ? -1 : 1;
791
+ const ra = a.object.region;
792
+ const rb = b.object.region;
793
+ if (ra === rb) return 0;
794
+ if (ra === null) return -1;
795
+ if (rb === null) return 1;
796
+ return ra < rb ? -1 : 1;
797
+ };
798
+
799
+ // ---------------------------------------------------------------------------
800
+ // the operation
801
+ // ---------------------------------------------------------------------------
802
+
803
+ interface Build {
804
+ id: string;
805
+ doc: CompiledDocument;
806
+ raw: unknown;
807
+ meshes: MeshOf[];
808
+ }
809
+
810
+ function readBuild(b: BuiltCandidate, who: string, refs: readonly AttachmentRef[]): Build {
811
+ let doc: CompiledDocument;
812
+ try {
813
+ doc = readModel(b.model, `${who} "${b.id}"`);
814
+ } catch (err) {
815
+ if (err instanceof CoreInputError) refuse('COMPARE_INPUT_MISSING', `${who} "${b.id}": the model document cannot be read — ${err.message}; required a skeleton.model.json as build writes it`);
816
+ throw err;
817
+ }
818
+ const raw: unknown = JSON.parse(b.model);
819
+ const meshes = refs.map((ref): MeshOf => {
820
+ const skin = doc.skins.find((k) => k.name === tableSkin(ref));
821
+ const record = skin?.attachments[ref.slot]?.[ref.attachment];
822
+ const g = record?.geometry;
823
+ if (g === undefined || g.kind !== 'mesh') {
824
+ refuse('COMPARE_INPUT_MISSING', `${who} "${b.id}": attachment ${nameOf(ref)} is ${record === undefined ? 'not in the document' : `a ${record.kind}, not a mesh`}; required a mesh attachment under skin "${tableSkin(ref)}" (a linked mesh is compared through its source)`);
825
+ }
826
+ if (g.hull === undefined) refuse('COMPARE_INPUT_MISSING', `${who} "${b.id}": mesh ${nameOf(ref)} states no hull; required the hull the build derives`);
827
+ const weights = g.vertices.weighted ? g.vertices.bindings.map((v) => v.map((e) => ({ bone: e.bone, weight: e.weight }))) : null;
828
+ return { uvs: [...g.uvs], triangles: [...g.triangles], hull: g.hull, weights };
829
+ });
830
+ return { id: b.id, doc, raw, meshes };
831
+ }
832
+
833
+ /** The slots `rig.deformMayFold` names in a raw document — read off the document, refused by name when it does not state them. */
834
+ function foldingSlots(raw: unknown, who: string): Set<string> {
835
+ const rig = isObject(raw) ? raw.rig : undefined;
836
+ const list = isObject(rig) ? rig.deformMayFold : undefined;
837
+ if (!Array.isArray(list) || !list.every((s) => typeof s === 'string')) missing(`${who}'s rig.deformMayFold`, list, 'the list of slot names the build records (src/rig.ts, invariants.deformMayFold)');
838
+ return new Set(list as string[]);
839
+ }
840
+
841
+ /**
842
+ * Compare one or more candidate builds with a reference build in motion — §3
843
+ * and §4's motion rows — and measure each build's art fit at the setup pose
844
+ * (§3, *Independent evidence* (a)), into one `mesh-quality-report/1` with
845
+ * `operation: 'compare'`. Throws a `MeshReductionError` for an input it
846
+ * refuses (`COMPARE_INPUT_MISSING`, `COMPARE_INPUTS_DIFFER`,
847
+ * `COMPARE_WARMUP_UNSUPPORTED`, `COMPARE_UV_CARRIER_NOT_UNIQUE`,
848
+ * `COMPARE_REFERENCE_FAILS` for a reference whose coverage, overshoot or
849
+ * undercut at the setup pose fails `referenceArtFit`, and `measureMeshQuality`'s own codes for the art).
850
+ */
851
+ export function compareMeshesInMotion(input: MotionComparisonInput): MeshQualityReport {
852
+ validateInput(input);
853
+ const refs = input.attachments.map((a) => a.attachment);
854
+ const reference = readBuild(input.reference, 'reference', refs);
855
+ const candidates = input.candidates.map((c, i) => readBuild(c, `candidates[${i}]`, refs));
856
+ for (const c of candidates) {
857
+ const d = firstDifference(reference.raw, c.raw, [], refs);
858
+ if (d !== null) {
859
+ refuse(
860
+ 'COMPARE_INPUTS_DIFFER',
861
+ `candidate "${c.id}" differs from reference "${reference.id}" at ${d.path}: the reference has ${shown(d.reference)} and the candidate has ${shown(d.candidate)}; required every input outside the allowlist equal (correction 5) — the allowlist is the compared mesh's vertices, UVs, triangles, hull, edges and bindings, what follows from them by topology, and atlas layout`,
862
+ );
863
+ }
864
+ }
865
+ // The slots in skeleton order fix the order attachments are reported in (§2).
866
+ const slotRank = new Map(reference.doc.slots.map((s, i) => [s.name, i]));
867
+ const order = input.attachments.map((_a, i) => i).sort((p, q) => (slotRank.get(refs[p].slot) ?? 0) - (slotRank.get(refs[q].slot) ?? 0) || (refs[p].attachment < refs[q].attachment ? -1 : refs[p].attachment > refs[q].attachment ? 1 : 0));
868
+ for (const ref of refs) {
869
+ if (!slotRank.has(ref.slot)) missing(`attachment ${nameOf(ref)}'s slot`, ref.slot, `one of the reference's slots [${[...slotRank.keys()].join(', ')}]`);
870
+ }
871
+
872
+ // --- the setup art fit, each build under its own bounds (§3 (a)) ----------------------------
873
+ const geometryOf = (b: Build, fit: ArtFitBounds): { geometry: EvidenceSection | null; counts: MeshCounts | null } => {
874
+ const sections: EvidenceSection[] = [];
875
+ const counts: MeshCounts[] = [];
876
+ for (const i of order) {
877
+ const a = input.attachments[i];
878
+ const m = b.meshes[i];
879
+ const frame = a.art.frame;
880
+ const points: Array<[number, number]> = [];
881
+ for (let k = 0; k + 1 < m.uvs.length; k += 2) points.push([m.uvs[k] * frame.width, m.uvs[k + 1] * frame.height]);
882
+ const measured = measureMeshQuality({
883
+ id: b.id,
884
+ attachment: a.attachment,
885
+ art: { mask: a.art.mask, threshold: a.finalThreshold, frame },
886
+ source: { points, uvs: m.uvs, triangles: m.triangles, hull: m.hull, weights: m.weights },
887
+ targets: { artFit: fit, maxBoundaryDeviation: null, regions: [] },
888
+ referenceHull: null,
889
+ minArtSamples: a.minArtSamples,
890
+ regionArtSamples: [],
891
+ protect: null,
892
+ influences: null,
893
+ boneOrder: null,
894
+ preset: null,
895
+ });
896
+ const c = measured.candidates[0];
897
+ if (c.geometry === null || c.counts === null) return { geometry: null, counts: null };
898
+ sections.push(c.geometry);
899
+ counts.push(c.counts);
900
+ }
901
+ const total: MeshCounts = {
902
+ boundaryVertices: counts.reduce((s, c) => s + c.boundaryVertices, 0),
903
+ interiorVertices: counts.reduce((s, c) => s + c.interiorVertices, 0),
904
+ triangles: counts.reduce((s, c) => s + c.triangles, 0),
905
+ bindings: counts.reduce((s, c) => s + c.bindings, 0),
906
+ maxInfluences: counts.reduce((s, c) => Math.max(s, c.maxInfluences), 0),
907
+ };
908
+ return { geometry: joinSections(sections), counts: total };
909
+ };
910
+ const refGeometry = geometryOf(reference, input.referenceArtFit);
911
+ // §3 *Independent evidence* (a), P8: a reference that fails its own art fit — coverage, overshoot or undercut against
912
+ // its own mask under `referenceArtFit` — is refused AS A REFERENCE: a deviation from a reference that does not carry
913
+ // its own art is not evidence, so no candidate is measured against it. (a) is those three rows and no other: winding
914
+ // is (b)'s (A39, the build's own gate), and a row the reference could not measure (an attachment under its sample
915
+ // floor, P9) leaves its section not-measured and the reference not accepted, which the report already says.
916
+ const artFitFailed = (refGeometry.geometry?.rows ?? []).filter((r) => r.state === 'fail' && REFERENCE_ART_FIT.includes(r.code));
917
+ if (artFitFailed.length > 0) {
918
+ refuse(
919
+ 'COMPARE_REFERENCE_FAILS',
920
+ `reference "${reference.id}" fails its own art fit at the setup pose under referenceArtFit — ${artFitFailed
921
+ .map((r) => `${r.code} of ${nameOf(r.object.attachment)}${r.object.region === null ? '' : ` region "${r.object.region}"`} is ${r.value}${r.bound === null ? '' : `, required ${r.bound.op} ${r.bound.value}`}`)
922
+ .join('; ')}; required the reference's coverage, overshoot and undercut within referenceArtFit (P8 and §3, Independent evidence (a): a deviation from a reference that does not carry its own art is not evidence)`,
923
+ );
924
+ }
925
+ const candGeometry = candidates.map((c) => geometryOf(c, input.candidateArtFit));
926
+
927
+ // --- motion ---------------------------------------------------------------------------------
928
+ const schedule = input.schedule;
929
+ type Motion = { section: EvidenceSection & { schedule: ScheduleUsed }; perFrame: NonNullable<CandidateReport['perFrame']> } | null;
930
+ let motionOf: (b: Build, isReference: boolean) => Motion = () => null;
931
+ if (schedule !== null) {
932
+ const physicsConstraints = reference.doc.constraints.filter((c) => c.kind === 'physics').length;
933
+ const { frames, walks, walkSteps } = planSchedule(schedule, reference.doc, physicsConstraints);
934
+ const folding = foldingSlots(reference.raw, `reference "${reference.id}"`);
935
+ const scheduleUsed: ScheduleUsed = {
936
+ ...schedule,
937
+ walked: frames.map((f) => f.ref),
938
+ roles: (['baseline', 'selection', 'held-out'] as const).filter((r) => frames.some((f) => f.ref.role === r)),
939
+ heldOutClaim: frames.some((f) => f.ref.role === 'held-out'),
940
+ reset: 'physics reset at time 0',
941
+ walks: walks.map((w, i) => ({ animation: w.animation, phase: w.phase, steps: walkSteps[i] })),
942
+ };
943
+ // The samples, per attachment: art pixel centres at the final threshold, then the reference hull's UVs (§3).
944
+ const samplesOf = order.map((i) => {
945
+ const a = input.attachments[i];
946
+ const { mask, frame } = a.art;
947
+ const bits = artOf(mask, a.finalThreshold);
948
+ const out: Sample[] = [];
949
+ for (let y = 0; y < mask.height; y++) {
950
+ for (let x = 0; x < mask.width; x++) {
951
+ if (!bits[y * mask.width + x]) continue;
952
+ const uv: [number, number] = [(x + 0.5) / mask.width, (y + 0.5) / mask.height];
953
+ out.push({ uv, px: [uv[0] * frame.width, uv[1] * frame.height], pixel: [x, y], vertex: null });
954
+ }
955
+ }
956
+ const art = out.length;
957
+ const m = reference.meshes[i];
958
+ for (let v = 0; v < m.hull; v++) {
959
+ const uv: [number, number] = [m.uvs[v * 2], m.uvs[v * 2 + 1]];
960
+ out.push({ uv, px: [uv[0] * frame.width, uv[1] * frame.height], pixel: null, vertex: v });
961
+ }
962
+ return { samples: out, art };
963
+ });
964
+ const refCarriers = order.map((i, k) => uvCarriers(reference.meshes[i].uvs, reference.meshes[i].triangles, samplesOf[k].samples, `reference "${reference.id}", attachment ${nameOf(refs[i])}`));
965
+ const posedRef = poseBuild(reference.doc, order.map((i) => refs[i]), frames, walks);
966
+
967
+ motionOf = (b: Build, isReference: boolean) => {
968
+ const carriers = isReference ? refCarriers : order.map((i, k) => uvCarriers(b.meshes[i].uvs, b.meshes[i].triangles, samplesOf[k].samples, `candidate "${b.id}", attachment ${nameOf(refs[i])}`));
969
+ const posed = isReference ? posedRef : poseBuild(b.doc, order.map((i) => refs[i]), frames, walks);
970
+ const built: Built[] = [];
971
+ const perFrame: NonNullable<CandidateReport['perFrame']> = [];
972
+ order.forEach((i, k) => {
973
+ const a = input.attachments[i];
974
+ const ref = refs[i];
975
+ const mesh = b.meshes[i];
976
+ const plans = {
977
+ local: { code: 'MQ_LOCAL_DEFORMATION', attachment: ref, region: null, unit: 'world', dir: 'max', bound: { op: '<=', value: input.bounds.maxLocalDeformation }, zeroIsIdeal: true } as RowPlan,
978
+ stretch: { code: 'MQ_STRETCH', attachment: ref, region: null, unit: 'ratio', dir: 'max', bound: input.bounds.maxStretch === undefined ? null : { op: '<=', value: input.bounds.maxStretch }, zeroIsIdeal: false } as RowPlan,
979
+ squash: { code: 'MQ_SQUASH', attachment: ref, region: null, unit: 'ratio', dir: 'min', bound: input.bounds.minStretch === undefined ? null : { op: '>=', value: input.bounds.minStretch }, zeroIsIdeal: false } as RowPlan,
980
+ inversion: { code: 'MQ_INVERSION', attachment: ref, region: null, unit: 'count', dir: 'max', bound: folding.has(ref.slot) ? null : { op: '<=', value: 0 }, zeroIsIdeal: true } as RowPlan,
981
+ };
982
+ const regionPlans = a.regions.map((r): RowPlan => ({ ...plans.local, region: r.name }));
983
+ const sampling = (region: string | null, count: number) => ({ domain: `art pixel centres at alpha >= ${a.finalThreshold} and the reference hull's UVs, on the region UV square${region === null ? '' : `, inside region "${region}"`}`, count });
984
+ const { samples, art } = samplesOf[k];
985
+ const inRegion = a.regions.map((r) => samples.map((s) => inClosedPolygon(s.px, r.polygon)));
986
+ const artIn = (mask: readonly boolean[] | null): number => samples.reduce((n, s, j) => n + (s.pixel !== null && (mask === null || mask[j]) ? 1 : 0), 0);
987
+ // P2: a build the core does not pose leaves every motion row not measurable, by its words — never approximated.
988
+ if ('refused' in posed || 'refused' in posedRef) {
989
+ const reason = 'refused' in posedRef ? `reference "${reference.id}": ${posedRef.refused}` : 'refused' in posed ? posed.refused : '';
990
+ built.push(unmeasured(plans.local, reason, { art: { threshold: a.finalThreshold, connectivity: null, samples: art }, sampling: sampling(null, samples.length) }));
991
+ for (const p of [plans.stretch, plans.squash, plans.inversion]) built.push(unmeasured(p, reason));
992
+ regionPlans.forEach((p, r) => built.push(unmeasured(p, reason, { art: { threshold: a.finalThreshold, connectivity: null, samples: artIn(inRegion[r]) }, sampling: sampling(p.region, inRegion[r].filter(Boolean).length) })));
993
+ return;
994
+ }
995
+ const refPosed = posedRef;
996
+ const candCarrier = carriers[k];
997
+ const refCarrier = refCarriers[k];
998
+ const both = samples.map((_s, j) => refCarrier[j] !== null && candCarrier[j] !== null);
999
+ const uncarried = {
1000
+ reference: samples.filter((_s, j) => refCarrier[j] === null).map((s) => s.uv),
1001
+ candidate: samples.filter((_s, j) => candCarrier[j] === null).map((s) => s.uv),
1002
+ };
1003
+ const notDrawn: string[] = [];
1004
+ const local: Reading[] = [];
1005
+ const localByRegion: Reading[][] = a.regions.map(() => []);
1006
+ const stretch: Reading[] = [];
1007
+ const squash: Reading[] = [];
1008
+ const inversion: Reading[] = [];
1009
+ const folds: Array<{ triangle: number; frame: string }> = [];
1010
+ const setupWorld = posed.setup[k];
1011
+ const setupAreas = setupWorld === null ? [] : triangleAreas(setupWorld, mesh.triangles);
1012
+ const setupBand = setupWorld === null ? 0 : areaBand(setupAreas, setupWorld);
1013
+ const degenerateAtSetup = setupAreas.filter((x) => Math.abs(x) <= setupBand).length;
1014
+ frames.forEach((f, fi) => {
1015
+ const cw = posed.frames[fi][k];
1016
+ const rw = refPosed.frames[fi][k];
1017
+ if (cw === null || rw === null) {
1018
+ notDrawn.push(f.ref.id);
1019
+ return;
1020
+ }
1021
+ // Local deformation: the carried points, sample by sample.
1022
+ let worst = -1;
1023
+ let worstAt = -1;
1024
+ const regionWorst = a.regions.map(() => ({ d: -1, j: -1 }));
1025
+ for (let j = 0; j < samples.length; j++) {
1026
+ if (!both[j]) continue;
1027
+ const p = carried(refCarrier[j]!, rw);
1028
+ const q = carried(candCarrier[j]!, cw);
1029
+ const d = Math.hypot(q[0] - p[0], q[1] - p[1]);
1030
+ if (d > worst) {
1031
+ worst = d;
1032
+ worstAt = j;
1033
+ }
1034
+ for (let r = 0; r < a.regions.length; r++) {
1035
+ if (inRegion[r][j] && d > regionWorst[r].d) regionWorst[r] = { d, j };
1036
+ }
1037
+ }
1038
+ const atOf = (j: number): WorstSample['at'] => {
1039
+ const s = samples[j];
1040
+ return s.pixel !== null ? { pixel: s.pixel, uv: s.uv } : { vertex: s.vertex ?? undefined, uv: s.uv };
1041
+ };
1042
+ if (worstAt >= 0) local.push({ frame: fi, value: worst, at: atOf(worstAt) });
1043
+ regionWorst.forEach((rw2, r) => {
1044
+ if (rw2.j >= 0) localByRegion[r].push({ frame: fi, value: rw2.d, at: atOf(rw2.j) });
1045
+ });
1046
+ // Stretch, squash and reversal: each triangle's map from its setup world triangle to this frame's, A39's band.
1047
+ if (setupWorld === null) return;
1048
+ const after = triangleAreas(cw, mesh.triangles);
1049
+ const band = areaBand(setupAreas, setupWorld, cw);
1050
+ let hi: Reading | null = null;
1051
+ let lo: Reading | null = null;
1052
+ let reversed = 0;
1053
+ let firstReversed = -1;
1054
+ for (let t = 0; t < setupAreas.length; t++) {
1055
+ if (Math.abs(setupAreas[t]) <= band) continue;
1056
+ const sv = stretchSingularValues(setupWorld, cw, mesh.triangles, t);
1057
+ if (sv !== null) {
1058
+ if (hi === null || sv.max > hi.value) hi = { frame: fi, value: sv.max, at: { triangle: t } };
1059
+ if (lo === null || sv.min < lo.value) lo = { frame: fi, value: sv.min, at: { triangle: t } };
1060
+ }
1061
+ if (Math.abs(after[t]) <= band) continue;
1062
+ if (Math.sign(setupAreas[t]) !== Math.sign(after[t])) {
1063
+ reversed++;
1064
+ if (firstReversed === -1) firstReversed = t;
1065
+ if (folding.has(ref.slot)) folds.push({ triangle: t, frame: f.ref.id });
1066
+ }
1067
+ }
1068
+ if (hi !== null) stretch.push(hi);
1069
+ if (lo !== null) squash.push(lo);
1070
+ inversion.push({ frame: fi, value: reversed, at: firstReversed === -1 ? {} : { triangle: firstReversed } });
1071
+ });
1072
+ // The declared setup map of the slot bone — correction 4's units, never a single ratio.
1073
+ const slotBone = b.doc.slots.find((s) => s.name === ref.slot)?.bone ?? '';
1074
+ const m2 = posed.setupBones.get(slotBone);
1075
+ const sv = m2 === undefined ? null : stretchSingularValues([0, 0, 1, 0, 0, 1], [0, 0, m2[0], m2[2], m2[1], m2[3]], [0, 1, 2], 0);
1076
+ const setupMap = m2 === undefined || sv === null ? undefined : { bone: slotBone, linear: m2.map(r6) as [number, number, number, number], singularScales: [r6(sv.max), r6(sv.min)] as [number, number] };
1077
+ const art_ = { threshold: a.finalThreshold, connectivity: null, samples: art };
1078
+ if (art < a.minArtSamples) {
1079
+ built.push(
1080
+ unmeasured(plans.local, `attachment ${nameOf(ref)} has ${art} art sample(s) at alpha >= ${a.finalThreshold}; required at least ${a.minArtSamples} (minArtSamples, P9) — the reference hull's UVs are samples and do not count towards it`, {
1081
+ art: art_,
1082
+ sampling: sampling(null, samples.length),
1083
+ }),
1084
+ );
1085
+ } else {
1086
+ const row = motionRow(plans.local, local, frames, schedule, notDrawn, { setupMap, uncarried });
1087
+ row.row.art = art_;
1088
+ row.row.sampling = sampling(null, samples.length);
1089
+ if (row.row.state === 'not-measurable' && notDrawn.length < frames.length) row.row.reason = `${nameOf(ref)}: no sample is carried by both the reference's and this build's UV triangles`;
1090
+ built.push(row);
1091
+ }
1092
+ regionPlans.forEach((p, r) => {
1093
+ const n = artIn(inRegion[r]);
1094
+ const regionArt = { threshold: a.finalThreshold, connectivity: null, samples: n };
1095
+ const floor = a.regions[r].minArtSamples;
1096
+ const samp = sampling(p.region, inRegion[r].filter(Boolean).length);
1097
+ if (n < floor) {
1098
+ built.push(unmeasured(p, `region "${p.region}" of ${nameOf(ref)} holds ${n} art sample(s) at alpha >= ${a.finalThreshold}; required at least ${floor} (its minArtSamples, P9) — hull samples do not count towards it`, { art: regionArt, sampling: samp }));
1099
+ return;
1100
+ }
1101
+ const row = motionRow(p, localByRegion[r], frames, schedule, notDrawn, { setupMap });
1102
+ row.row.art = regionArt;
1103
+ row.row.sampling = samp;
1104
+ built.push(row);
1105
+ });
1106
+ if (setupWorld === null) {
1107
+ // No setup shape to measure a map from: the slot shows something else, or nothing, at the setup pose.
1108
+ const why = `${nameOf(ref)}: the slot does not show the attachment at the setup pose, so no triangle has a setup shape to measure stretch or reversal from`;
1109
+ for (const p of [plans.stretch, plans.squash, plans.inversion]) built.push(unmeasured(p, why));
1110
+ } else {
1111
+ built.push(motionRow(plans.stretch, stretch, frames, schedule, notDrawn, { degenerateAtSetup }));
1112
+ built.push(motionRow(plans.squash, squash, frames, schedule, notDrawn, { degenerateAtSetup }));
1113
+ // A slot `deformMayFold` names: the row holds no bound (A39 exempts it), keeps its count rather than zeroing it, and lists every fold.
1114
+ built.push(motionRow(plans.inversion, inversion, frames, schedule, notDrawn, folding.has(ref.slot) ? { folds, degenerateAtSetup } : { degenerateAtSetup }));
1115
+ }
1116
+ // P7's opt-in table: each attachment-level row's value at every frame walked, null where none was taken.
1117
+ const label = (code: string): string => (order.length > 1 ? `${code}[${nameOf(ref)}]` : code);
1118
+ const table: Array<[string, Reading[]]> = [
1119
+ ['MQ_INVERSION', inversion],
1120
+ ['MQ_LOCAL_DEFORMATION', art < a.minArtSamples ? [] : local],
1121
+ ['MQ_SQUASH', squash],
1122
+ ['MQ_STRETCH', stretch],
1123
+ ];
1124
+ for (const [code, readings] of table) {
1125
+ const at = new Map(readings.map((r) => [r.frame, r.value]));
1126
+ frames.forEach((f, fi) => {
1127
+ const v = at.get(fi);
1128
+ perFrame.push({ code: label(code), frame: f.ref.id, value: v === undefined ? null : r6(v) });
1129
+ });
1130
+ }
1131
+ });
1132
+ // Rows by attachment in skeleton order (built so), then code, then region.
1133
+ const grouped: Built[] = [];
1134
+ for (let k = 0; k < order.length; k++) {
1135
+ const ref = refs[order[k]];
1136
+ grouped.push(...built.filter((x) => x.row.object.attachment === ref).sort((p, q) => ROW_ORDER(p.row, q.row)));
1137
+ }
1138
+ return { section: { ...sectionOf(grouped), schedule: scheduleUsed }, perFrame };
1139
+ };
1140
+ }
1141
+ const referenceMotion = motionOf(reference, true);
1142
+ const candidateMotions = candidates.map((c) => motionOf(c, false));
1143
+ const reportOf = (b: Build, geometry: { geometry: EvidenceSection | null; counts: MeshCounts | null }, measured: Motion): CandidateReport => {
1144
+ const motion = measured === null ? null : measured.section;
1145
+ // P6: the geometry verdict pass (every required row measured and passing), and — when motion is required — the motion verdict pass.
1146
+ const accepted = geometry.geometry !== null && geometry.geometry.verdict === 'pass' && (!input.motionRequired || (motion !== null && motion.verdict === 'pass'));
1147
+ const out: CandidateReport = { id: b.id, counts: geometry.counts, geometry: geometry.geometry, motion, accepted };
1148
+ if (input.perFrame && measured !== null) out.perFrame = measured.perFrame;
1149
+ return out;
1150
+ };
1151
+ const effective: EffectiveSettings = {
1152
+ preset: null,
1153
+ attachments: order.map((i) => {
1154
+ const a = input.attachments[i];
1155
+ return {
1156
+ attachment: a.attachment,
1157
+ threshold: a.art.threshold,
1158
+ finalThreshold: a.finalThreshold,
1159
+ frame: a.art.frame,
1160
+ maskSize: [a.art.mask.width, a.art.mask.height] as [number, number],
1161
+ minArtSamples: a.minArtSamples,
1162
+ regions: a.regions.map((r) => ({ name: r.name, minArtSamples: r.minArtSamples, polygon: r.polygon })),
1163
+ };
1164
+ }),
1165
+ sourceBounds: null,
1166
+ referenceArtFit: input.referenceArtFit,
1167
+ candidateArtFit: input.candidateArtFit,
1168
+ targets: null,
1169
+ referenceHull: null,
1170
+ motionBounds: input.bounds,
1171
+ protect: null,
1172
+ influences: null,
1173
+ boneOrder: null,
1174
+ schedule,
1175
+ budget: null,
1176
+ };
1177
+ return {
1178
+ spec: MESH_QUALITY_REPORT_SPEC,
1179
+ operation: 'compare',
1180
+ effective,
1181
+ poser: schedule === null ? null : { kind: 'core', rigcVersion: readVersion() },
1182
+ motionRequired: input.motionRequired,
1183
+ sourceCounts: refGeometry.counts,
1184
+ reference: reportOf(reference, refGeometry, referenceMotion),
1185
+ candidates: candidates.map((c, i) => reportOf(c, candGeometry[i], candidateMotions[i])),
1186
+ termination: null,
1187
+ };
1188
+ }