rig-c 0.0.0-stage → 2.21.0

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 +1191 -0
  185. package/src/meshquality.ts +2051 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1444 -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,643 @@
1
+ /**
2
+ * bonedist — the ladder's **stage 3**: per-frame, per-bone world-transform
3
+ * distance between two skeletons.
4
+ *
5
+ * ⭐ Why it exists, and what the two instruments beside it cannot see.
6
+ * `docs/LADDER.md`'s *How a rung is scored* names three stages. Stage 2,
7
+ * [`diff.ts`](diff.ts), compares what the two files *say* — and two structurally
8
+ * identical rigs can pose completely differently, because a rotation of 30° and
9
+ * a rotation of 300° are one key each. Stage 3 is the measure for that, and
10
+ * until this file it did not exist: *"None of it exists. Do not report a
11
+ * per-frame figure until it does"*
12
+ * ([issue #8](https://github.com/firejune/rigc/issues/8)).
13
+ *
14
+ * `rigc check` is the other neighbour and it is **not** this. It measures a
15
+ * candidate against **pictures** — the rendered reference frames — which is
16
+ * what lets it run inside an authoring loop. Four things follow from that and
17
+ * every one of them is a hole this file fills:
18
+ *
19
+ * 1. **Bones, not slots.** `check`'s unit is a drawn slot, so a bone that
20
+ * drives nothing visible — a control bone, a parent in a chain — has no
21
+ * footprint and is invisible to it.
22
+ * 2. **A transform, not a footprint.** `check` reports a centroid and a bbox;
23
+ * rotation is only inferred through the bbox, and scale and shear are not
24
+ * separated from either.
25
+ * 3. **A supplied correspondence.** `check`'s matcher pairs a slot to whatever
26
+ * reference blob is nearest and says so when that is a guess. Stage 3 wants
27
+ * the pairing given, because a candidate is free to use its own names and no
28
+ * derivation of the mapping could be anything but a guess.
29
+ * 4. **Two skeletons, not a render.** Everything below the render scale is
30
+ * invisible to `check` (0.117 px per unit on rung 3), and the reference
31
+ * frames carry a resampling residual of their own.
32
+ *
33
+ * 🔒 **So this reads the reference skeleton, and is therefore a `bench`-side
34
+ * instrument, subject to the honesty rule.** `check` deliberately is not. A run
35
+ * that reaches for this file is at the finish line, not in the loop.
36
+ *
37
+ * ## Every convention, in one place
38
+ *
39
+ * A distance is meaningless without them, so `boneDistLines` prints this list
40
+ * beside every figure and `BoneDistReport.conventions` carries it into the JSON.
41
+ *
42
+ * - **Position** — each bone's world origin taken **relative to its own
43
+ * skeleton's root** and divided by **its own skeleton's size**, then the
44
+ * Euclidean distance between the two. Unit: *skeleton sizes*. Size is the
45
+ * greatest root-to-bone distance in that skeleton's **setup pose**. Two-sided
46
+ * normalisation on purpose: a candidate is authored in its own coordinate
47
+ * system and under the honesty rule could not be authored in any other, so a
48
+ * different origin or a different unit is not an error and this absorbs both,
49
+ * exactly as `check`'s fitted similarity does for pixels. ⚠️ What it does
50
+ * **not** absorb is a globally *rotated* rig — that arrives as a constant
51
+ * rotation on every bone, which is the diagnosis rather than a defect of the
52
+ * measure.
53
+ * - **Rotation** — the absolute difference of `getWorldRotationX()`, wrapped to
54
+ * ±180. Unit: degrees. Unaffected by either normalisation.
55
+ * - **Scale** — `max(|ΔscaleX|, |ΔscaleY|)` over `getWorldScaleX/Y()`.
56
+ * Dimensionless, and already a ratio, so nothing is normalised.
57
+ * - **Linear** — `max` of `|Δa|, |Δb|, |Δc|, |Δd|` over the world matrix's
58
+ * linear part. Dimensionless and **complete**: rotation, scale *and shear* all
59
+ * live in those four numbers, so nothing hides in it. It is reported *beside*
60
+ * rotation and scale rather than instead of them, because the pair a reader
61
+ * can act on is the decomposition and the number that cannot be gamed is the
62
+ * matrix.
63
+ * - **Frames** — both sides sampled from t=0 at one rate over **their own**
64
+ * durations, compared index by index over the shorter of the two. Each
65
+ * animation states both frame counts and both durations, so a candidate that
66
+ * runs long is visible as that rather than as a pose error.
67
+ * - **Aggregate** — per bone: the mean and the worst over the compared frames.
68
+ * Per animation: the mean of the bone means, and the single worst (bone,
69
+ * frame). 🚫 Nothing is combined **across** the four quantities and there is
70
+ * no score, for the reason [`diff.ts`](diff.ts) opens with: a rig with the
71
+ * right positions and the wrong rotations and a rig with the right rotations
72
+ * and the wrong positions call for opposite fixes.
73
+ *
74
+ * 🚫 **It gates nothing.** `docs/GATE.md` states the clauses and none of them
75
+ * reads a figure from here. This is a reported instrument, and adding it changed
76
+ * no threshold and no recorded figure.
77
+ */
78
+ import { readFileSync } from 'node:fs';
79
+ import {
80
+ loadPosedSkeleton,
81
+ PROTOCOL_FPS,
82
+ sampleAnimation,
83
+ sampleSetupPose,
84
+ type BoneSnapshot,
85
+ type Posable,
86
+ } from './render.ts';
87
+ import { BONEDIST_SPEC, IDENTITY_CORRESPONDENCE } from './correspondence.ts';
88
+
89
+ export class BoneDistError extends Error {}
90
+
91
+ // `BONEDIST_SPEC` and `IDENTITY_CORRESPONDENCE` are `./correspondence.ts`'s since issue #1052: the CLI's help names
92
+ // both, and an entry that links nothing of the runtime prints that help. Re-exported here.
93
+ export { BONEDIST_SPEC, IDENTITY_CORRESPONDENCE };
94
+
95
+ /** How many bones the report's per-bone table shows before it says "and N more". */
96
+ export const BONE_TABLE_ROWS = 8;
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // the correspondence — an input, never a derivation
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Which candidate bone is which reference bone, and which shot is which shot.
104
+ *
105
+ * ⭐ An **input**. A candidate is entitled to its own vocabulary
106
+ * (`docs/LADDER.md`, the honesty rule), so any mapping this file worked out for
107
+ * itself would be a guess dressed as a measurement — and a wrong guess reads as
108
+ * a rig that poses wrongly, which is the one conclusion a stage-3 figure is for.
109
+ */
110
+ export interface Correspondence {
111
+ /** Where it came from: a path, or `identity`. Printed with every figure. */
112
+ source: string;
113
+ /** candidate bone name -> reference bone name. */
114
+ bones: Map<string, string>;
115
+ /** candidate animation name -> reference animation name, or null for by-name. */
116
+ animations: Map<string, string> | null;
117
+ }
118
+
119
+ function isObj(v: unknown): v is Record<string, unknown> {
120
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
121
+ }
122
+
123
+ function stringMap(v: unknown, what: string, path: string): Map<string, string> {
124
+ if (!isObj(v)) throw new BoneDistError(`${path}: \`${what}\` must be an object of "candidate": "reference" pairs`);
125
+ const out = new Map<string, string>();
126
+ for (const [from, to] of Object.entries(v)) {
127
+ if (typeof to !== 'string') {
128
+ throw new BoneDistError(`${path}: \`${what}.${from}\` must be a string naming a reference ${what.slice(0, -1)}`);
129
+ }
130
+ out.set(from, to);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Read a correspondence file, or build the identity one.
137
+ *
138
+ * The identity correspondence is a **named** alternative and not a default,
139
+ * because "the names happen to match" is a fact about a transcription and not
140
+ * about a rig authored from a brief. The report says which it used, so a figure
141
+ * can never be read as though a mapping had been supplied when none was.
142
+ */
143
+ export function readCorrespondence(source: string, candidateBones: string[]): Correspondence {
144
+ if (source === IDENTITY_CORRESPONDENCE) {
145
+ return { source, bones: new Map(candidateBones.map((n) => [n, n])), animations: null };
146
+ }
147
+ const parsed: unknown = JSON.parse(readFileSync(source, 'utf8'));
148
+ if (!isObj(parsed)) throw new BoneDistError(`${source}: expected a JSON object`);
149
+ if (parsed.spec !== undefined && parsed.spec !== BONEDIST_SPEC) {
150
+ throw new BoneDistError(`${source}: \`spec\` is ${JSON.stringify(parsed.spec)}, expected ${JSON.stringify(BONEDIST_SPEC)}`);
151
+ }
152
+ if (parsed.bones === undefined) {
153
+ throw new BoneDistError(
154
+ `${source}: no \`bones\` — a correspondence file states { "spec": "${BONEDIST_SPEC}", "bones": { "<candidate bone>": ` +
155
+ '"<reference bone>" }, "animations"?: { "<candidate animation>": "<reference animation>" } }',
156
+ );
157
+ }
158
+ return {
159
+ source,
160
+ bones: stringMap(parsed.bones, 'bones', source),
161
+ animations: parsed.animations === undefined ? null : stringMap(parsed.animations, 'animations', source),
162
+ };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // size — what a position is measured in
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /** The scale one skeleton's positions are expressed in, and where it came from. */
170
+ export interface SkeletonSize {
171
+ /** The root bone the positions are taken relative to. */
172
+ root: string;
173
+ /** Greatest root-to-bone distance in the setup pose, in the rig's own units. */
174
+ size: number;
175
+ /** The bone that set it — the report names it so the figure can be checked. */
176
+ farthest: string | null;
177
+ /**
178
+ * Set when `size` came out 0 — every bone sits on the root, so there is no
179
+ * length in the rig to divide by. Positions are then reported in **raw units**
180
+ * and this says so, rather than a division by zero arriving as `Infinity`
181
+ * three tables later.
182
+ */
183
+ degenerate: boolean;
184
+ }
185
+
186
+ function skeletonSize(posable: Pick<Posable, 'data'>): SkeletonSize {
187
+ const setup = sampleSetupPose(posable.data, { bones: true }).at(0);
188
+ const bones = setup?.bones ?? [];
189
+ const rootData = posable.data.bones.find((b) => b.parent === null) ?? posable.data.bones.at(0);
190
+ const rootName = rootData?.name ?? '(none)';
191
+ const root = bones.find((b) => b.name === rootName);
192
+ if (!root) return { root: rootName, size: 0, farthest: null, degenerate: true };
193
+ let size = 0;
194
+ let farthest: string | null = null;
195
+ for (const bone of bones) {
196
+ const distance = Math.hypot(bone.worldX - root.worldX, bone.worldY - root.worldY);
197
+ if (distance > size) {
198
+ size = distance;
199
+ farthest = bone.name;
200
+ }
201
+ }
202
+ return { root: rootName, size, farthest, degenerate: size === 0 };
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // the four distances
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /** To (-180, 180]. */
210
+ function wrapDegrees(degrees: number): number {
211
+ let d = degrees % 360;
212
+ if (d > 180) d -= 360;
213
+ if (d <= -180) d += 360;
214
+ return d;
215
+ }
216
+
217
+ /** The four quantities, for one bone pair in one frame. See the conventions above. */
218
+ export interface BoneDelta {
219
+ position: number;
220
+ rotation: number;
221
+ scale: number;
222
+ linear: number;
223
+ }
224
+
225
+ /** Every field of `BoneDelta`, so a caller cannot iterate three of the four. */
226
+ export const BONE_QUANTITIES = ['position', 'rotation', 'scale', 'linear'] as const;
227
+
228
+ export type BoneQuantity = (typeof BONE_QUANTITIES)[number];
229
+
230
+ function deltaOf(
231
+ candidate: BoneSnapshot,
232
+ candidateRoot: BoneSnapshot,
233
+ candidateSize: SkeletonSize,
234
+ reference: BoneSnapshot,
235
+ referenceRoot: BoneSnapshot,
236
+ referenceSize: SkeletonSize,
237
+ ): BoneDelta {
238
+ // Raw units when either side has no length to divide by, which the report
239
+ // says out loud rather than dividing and printing an Infinity.
240
+ const cScale = candidateSize.degenerate || referenceSize.degenerate ? 1 : candidateSize.size;
241
+ const rScale = candidateSize.degenerate || referenceSize.degenerate ? 1 : referenceSize.size;
242
+ const cx = (candidate.worldX - candidateRoot.worldX) / cScale;
243
+ const cy = (candidate.worldY - candidateRoot.worldY) / cScale;
244
+ const rx = (reference.worldX - referenceRoot.worldX) / rScale;
245
+ const ry = (reference.worldY - referenceRoot.worldY) / rScale;
246
+ return {
247
+ position: Math.hypot(cx - rx, cy - ry),
248
+ rotation: Math.abs(wrapDegrees(candidate.rotationX - reference.rotationX)),
249
+ scale: Math.max(Math.abs(candidate.scaleX - reference.scaleX), Math.abs(candidate.scaleY - reference.scaleY)),
250
+ linear: Math.max(
251
+ Math.abs(candidate.a - reference.a),
252
+ Math.abs(candidate.b - reference.b),
253
+ Math.abs(candidate.c - reference.c),
254
+ Math.abs(candidate.d - reference.d),
255
+ ),
256
+ };
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // the report
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /** A mean and a worst over frames, with the frame the worst landed on. */
264
+ export interface Extremes {
265
+ mean: number;
266
+ worst: number;
267
+ worstFrame: number;
268
+ }
269
+
270
+ export interface BoneDistBone {
271
+ candidate: string;
272
+ reference: string;
273
+ /** Frames this pair was compared over. */
274
+ frames: number;
275
+ position: Extremes;
276
+ rotation: Extremes;
277
+ scale: Extremes;
278
+ linear: Extremes;
279
+ }
280
+
281
+ /** The worst single reading of one quantity, and where it was. */
282
+ export interface WorstReading {
283
+ value: number;
284
+ bone: string;
285
+ frame: number;
286
+ }
287
+
288
+ export interface BoneDistAnimation {
289
+ candidate: string;
290
+ reference: string;
291
+ fps: number;
292
+ /** Frames compared — the shorter of the two sampled counts. */
293
+ compared: number;
294
+ candidateFrames: number;
295
+ referenceFrames: number;
296
+ candidateDuration: number;
297
+ referenceDuration: number;
298
+ bones: BoneDistBone[];
299
+ /** The mean of the per-bone means, per quantity. */
300
+ means: Record<BoneQuantity, number>;
301
+ /** The single worst (bone, frame) reading, per quantity. */
302
+ worst: Record<BoneQuantity, WorstReading>;
303
+ }
304
+
305
+ export interface BoneDistSide {
306
+ skeleton: string;
307
+ atlas: string;
308
+ size: SkeletonSize;
309
+ bones: number;
310
+ animations: string[];
311
+ }
312
+
313
+ export interface BoneDistReport {
314
+ spec: string;
315
+ fps: number;
316
+ candidate: BoneDistSide;
317
+ reference: BoneDistSide;
318
+ correspondence: {
319
+ source: string;
320
+ /** Pairs that resolved to a bone on both sides — what the figures are over. */
321
+ pairs: number;
322
+ /** Named on one side and absent on the other. Reported, never guessed at. */
323
+ candidateUnmatched: string[];
324
+ referenceUnpaired: string[];
325
+ /** How the shots were paired: by the file's `animations`, or by name. */
326
+ animations: 'declared' | 'by-name';
327
+ candidateAnimationsUnmatched: string[];
328
+ referenceAnimationsUnpaired: string[];
329
+ };
330
+ animations: BoneDistAnimation[];
331
+ /** The worst reading of each quantity over every animation. */
332
+ worst: Record<BoneQuantity, WorstReading & { animation: string }>;
333
+ /** The conventions every figure above was measured under, verbatim. */
334
+ conventions: string[];
335
+ }
336
+
337
+ export interface BoneDistOptions {
338
+ candidateSkeleton: string;
339
+ candidateAtlas: string;
340
+ /**
341
+ * Where the candidate's atlas pages are. Not read since issue #1042: no
342
+ * figure here samples a pixel, so no page is opened, and a pair whose pages
343
+ * are elsewhere measures the same. Kept optional so a caller that passes it
344
+ * still compiles.
345
+ */
346
+ candidateAtlasDir?: string;
347
+ referenceSkeleton: string;
348
+ referenceAtlas: string;
349
+ /** The reference's, as `candidateAtlasDir` — not read. */
350
+ referenceAtlasDir?: string;
351
+ /** A correspondence file path, or `identity`. */
352
+ bones: string;
353
+ fps?: number;
354
+ }
355
+
356
+ /**
357
+ * The conventions, as the report carries them. One list, quoted by the console
358
+ * report and by the JSON, so the two can never state different ones.
359
+ */
360
+ export function boneDistConventions(fps: number): string[] {
361
+ return [
362
+ 'position — each bone\'s world origin RELATIVE TO ITS OWN SKELETON\'S ROOT, divided by that ' +
363
+ "skeleton's OWN size (greatest root-to-bone distance in its setup pose), then the Euclidean " +
364
+ 'distance between the two. Unit: skeleton sizes. A different origin or a different unit is ' +
365
+ 'absorbed; a globally ROTATED rig is not, and arrives as a constant rotation on every bone.',
366
+ 'position, second clause — the size is a property of the WHOLE rig, so a candidate that moves the bone ' +
367
+ 'setting its size renormalises every position figure and the worst reading can land on a bone that did not ' +
368
+ 'move. That is why the header names the bone the size came from: read the two sizes first, and where they ' +
369
+ 'differ read the position column as a comparison of two rigs rather than as a per-bone error.',
370
+ 'rotation — |Δ getWorldRotationX()|, wrapped to ±180. Unit: degrees.',
371
+ 'scale — max(|Δ getWorldScaleX()|, |Δ getWorldScaleY()|). Dimensionless.',
372
+ 'linear — max(|Δa|, |Δb|, |Δc|, |Δd|) over the world matrix\'s linear part. Dimensionless and ' +
373
+ 'COMPLETE: rotation, scale and shear all live in those four numbers.',
374
+ `frames — both sides sampled from t=0 at ${fps} fps over THEIR OWN durations, compared index by ` +
375
+ 'index over the shorter of the two. Every animation states both counts and both durations.',
376
+ 'frames, second clause — a STEPPED key sitting on a sampled time is a knife edge, and a difference in the last ' +
377
+ 'digit of that key time puts the two sides on opposite sides of the step. It reads as a whole step of ' +
378
+ 'difference on one bone at one frame while every other reading stays on the floor, and it VANISHES at a ' +
379
+ 'neighbouring rate. Before reading such a spike as a pose defect, re-run at another --fps: a real one is ' +
380
+ 'still there and a boundary one is not.',
381
+ 'aggregate — per bone: mean and worst over the compared frames. Per animation: the mean of the ' +
382
+ 'bone means, and the single worst (bone, frame). Nothing is combined across the four ' +
383
+ 'quantities, and there is no score.',
384
+ ];
385
+ }
386
+
387
+ const NO_READING: WorstReading = { value: 0, bone: '(none)', frame: -1 };
388
+
389
+ function extremesOf(values: number[]): Extremes {
390
+ if (values.length === 0) return { mean: 0, worst: 0, worstFrame: -1 };
391
+ let worst = -1;
392
+ let worstFrame = -1;
393
+ let total = 0;
394
+ for (let i = 0; i < values.length; i++) {
395
+ total += values[i];
396
+ if (values[i] > worst) {
397
+ worst = values[i];
398
+ worstFrame = i;
399
+ }
400
+ }
401
+ return { mean: total / values.length, worst, worstFrame };
402
+ }
403
+
404
+ export function boneDistance(options: BoneDistOptions): BoneDistReport {
405
+ const fps = options.fps ?? PROTOCOL_FPS;
406
+ if (!Number.isFinite(fps) || fps <= 0) throw new BoneDistError('fps must be a positive number');
407
+ // Each side through spine-core, refused by name where its skeleton does not load against its atlas (issue #1042),
408
+ // and without its page images: nothing here samples a pixel (`loadPosedSkeleton`).
409
+ const posedFor = (side: 'candidate' | 'reference', skeleton: string, atlas: string): Pick<Posable, 'data'> => ({
410
+ data: loadPosedSkeleton(skeleton, atlas, `bonedist's ${side}: every bone's world transform is read off spine-core's pose of it`),
411
+ });
412
+ const candidate = posedFor('candidate', options.candidateSkeleton, options.candidateAtlas);
413
+ const reference = posedFor('reference', options.referenceSkeleton, options.referenceAtlas);
414
+
415
+ const candidateBoneNames = candidate.data.bones.map((b) => b.name);
416
+ const referenceBoneNames = new Set(reference.data.bones.map((b) => b.name));
417
+ const correspondence = readCorrespondence(options.bones, candidateBoneNames);
418
+
419
+ // A pair only counts when both ends exist. The rest is reported by name: a
420
+ // correspondence naming a bone neither rig declares is a defect in the input,
421
+ // and silently dropping it would let a figure be read over half a rig.
422
+ const candidateSet = new Set(candidateBoneNames);
423
+ const pairs: Array<{ candidate: string; reference: string }> = [];
424
+ const candidateUnmatched: string[] = [];
425
+ for (const [from, to] of correspondence.bones) {
426
+ if (!candidateSet.has(from) || !referenceBoneNames.has(to)) {
427
+ candidateUnmatched.push(referenceBoneNames.has(to) ? `${from} (no such candidate bone)` : `${from} -> ${to} (no such reference bone)`);
428
+ continue;
429
+ }
430
+ pairs.push({ candidate: from, reference: to });
431
+ }
432
+ const paired = new Set(pairs.map((p) => p.reference));
433
+ const referenceUnpaired = [...referenceBoneNames].filter((n) => !paired.has(n));
434
+
435
+ const candidateSize = skeletonSize(candidate);
436
+ const referenceSize = skeletonSize(reference);
437
+
438
+ const candidateAnimations = candidate.data.animations.map((a) => a.name);
439
+ const referenceAnimations = new Set(reference.data.animations.map((a) => a.name));
440
+ const shots: Array<{ candidate: string; reference: string }> = [];
441
+ const candidateAnimationsUnmatched: string[] = [];
442
+ for (const name of candidateAnimations) {
443
+ const want = correspondence.animations?.get(name) ?? name;
444
+ if (referenceAnimations.has(want)) shots.push({ candidate: name, reference: want });
445
+ else candidateAnimationsUnmatched.push(correspondence.animations?.has(name) ? `${name} -> ${want}` : name);
446
+ }
447
+ const pairedShots = new Set(shots.map((s) => s.reference));
448
+ const referenceAnimationsUnpaired = [...referenceAnimations].filter((n) => !pairedShots.has(n));
449
+
450
+ const animations: BoneDistAnimation[] = [];
451
+ const worst: Record<BoneQuantity, WorstReading & { animation: string }> = {
452
+ position: { ...NO_READING, animation: '(none)' },
453
+ rotation: { ...NO_READING, animation: '(none)' },
454
+ scale: { ...NO_READING, animation: '(none)' },
455
+ linear: { ...NO_READING, animation: '(none)' },
456
+ };
457
+
458
+ for (const shot of shots) {
459
+ const candidateFrames = sampleAnimation(candidate.data, shot.candidate, fps, { bones: true });
460
+ const referenceFrames = sampleAnimation(reference.data, shot.reference, fps, { bones: true });
461
+ const compared = Math.min(candidateFrames.length, referenceFrames.length);
462
+ const bones: BoneDistBone[] = [];
463
+ for (const pair of pairs) {
464
+ const series: Record<BoneQuantity, number[]> = { position: [], rotation: [], scale: [], linear: [] };
465
+ for (let i = 0; i < compared; i++) {
466
+ const cb = candidateFrames[i].bones ?? [];
467
+ const rb = referenceFrames[i].bones ?? [];
468
+ const cRoot = cb.find((b) => b.name === candidateSize.root);
469
+ const rRoot = rb.find((b) => b.name === referenceSize.root);
470
+ const c = cb.find((b) => b.name === pair.candidate);
471
+ const r = rb.find((b) => b.name === pair.reference);
472
+ if (!c || !r || !cRoot || !rRoot) continue;
473
+ const delta = deltaOf(c, cRoot, candidateSize, r, rRoot, referenceSize);
474
+ for (const q of BONE_QUANTITIES) series[q].push(delta[q]);
475
+ }
476
+ bones.push({
477
+ candidate: pair.candidate,
478
+ reference: pair.reference,
479
+ frames: series.position.length,
480
+ position: extremesOf(series.position),
481
+ rotation: extremesOf(series.rotation),
482
+ scale: extremesOf(series.scale),
483
+ linear: extremesOf(series.linear),
484
+ });
485
+ }
486
+
487
+ const means: Record<BoneQuantity, number> = { position: 0, rotation: 0, scale: 0, linear: 0 };
488
+ const shotWorst: Record<BoneQuantity, WorstReading> = {
489
+ position: { ...NO_READING },
490
+ rotation: { ...NO_READING },
491
+ scale: { ...NO_READING },
492
+ linear: { ...NO_READING },
493
+ };
494
+ for (const q of BONE_QUANTITIES) {
495
+ means[q] = bones.length === 0 ? 0 : bones.reduce((s, b) => s + b[q].mean, 0) / bones.length;
496
+ for (const bone of bones) {
497
+ if (bone.frames === 0 || bone[q].worst <= shotWorst[q].value) continue;
498
+ shotWorst[q] = { value: bone[q].worst, bone: bone.candidate, frame: bone[q].worstFrame };
499
+ }
500
+ if (shotWorst[q].value > worst[q].value) worst[q] = { ...shotWorst[q], animation: shot.candidate };
501
+ }
502
+
503
+ animations.push({
504
+ candidate: shot.candidate,
505
+ reference: shot.reference,
506
+ fps,
507
+ compared,
508
+ candidateFrames: candidateFrames.length,
509
+ referenceFrames: referenceFrames.length,
510
+ candidateDuration: candidateFrames.at(-1)?.time ?? 0,
511
+ referenceDuration: referenceFrames.at(-1)?.time ?? 0,
512
+ bones,
513
+ means,
514
+ worst: shotWorst,
515
+ });
516
+ }
517
+
518
+ return {
519
+ spec: BONEDIST_SPEC,
520
+ fps,
521
+ candidate: {
522
+ skeleton: options.candidateSkeleton,
523
+ atlas: options.candidateAtlas,
524
+ size: candidateSize,
525
+ bones: candidateBoneNames.length,
526
+ animations: candidateAnimations,
527
+ },
528
+ reference: {
529
+ skeleton: options.referenceSkeleton,
530
+ atlas: options.referenceAtlas,
531
+ size: referenceSize,
532
+ bones: referenceBoneNames.size,
533
+ animations: [...referenceAnimations],
534
+ },
535
+ correspondence: {
536
+ source: correspondence.source,
537
+ pairs: pairs.length,
538
+ candidateUnmatched,
539
+ referenceUnpaired,
540
+ animations: correspondence.animations === null ? 'by-name' : 'declared',
541
+ candidateAnimationsUnmatched,
542
+ referenceAnimationsUnpaired,
543
+ },
544
+ animations,
545
+ worst,
546
+ conventions: boneDistConventions(fps),
547
+ };
548
+ }
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // the human report
552
+ // ---------------------------------------------------------------------------
553
+
554
+ /** Positions are in skeleton sizes and run small; the others are not. */
555
+ const PLACES: Record<BoneQuantity, number> = { position: 6, rotation: 4, scale: 6, linear: 6 };
556
+
557
+ function fmt(q: BoneQuantity, n: number): string {
558
+ return n.toFixed(PLACES[q]);
559
+ }
560
+
561
+ function sizeLine(label: string, size: SkeletonSize): string {
562
+ const how = size.degenerate
563
+ ? 'every bone sits on the root, so there is no length to divide by — POSITIONS ARE IN RAW UNITS'
564
+ : `root \`${size.root}\` -> \`${size.farthest}\` in the setup pose`;
565
+ return ` ${label.padEnd(11)}size ${size.size.toFixed(3)} (${how})`;
566
+ }
567
+
568
+ export function boneDistLines(report: BoneDistReport, opts?: { allBones?: boolean }): string[] {
569
+ const lines: string[] = [];
570
+ lines.push(` candidate ${report.candidate.skeleton}`);
571
+ lines.push(` .. atlas ${report.candidate.atlas}`);
572
+ lines.push(` reference ${report.reference.skeleton}`);
573
+ lines.push(` .. atlas ${report.reference.atlas}`);
574
+ const c = report.correspondence;
575
+ lines.push(
576
+ ` bones ${c.source} — ${c.pairs} pair(s) over ${report.candidate.bones} candidate and ` +
577
+ `${report.reference.bones} reference bone(s)`,
578
+ );
579
+ if (c.candidateUnmatched.length > 0) lines.push(` ⚠️ unmatched ${c.candidateUnmatched.join(', ')}`);
580
+ if (c.referenceUnpaired.length > 0) {
581
+ lines.push(` ⚠️ unpaired ${c.referenceUnpaired.length} reference bone(s) no pair names: ${c.referenceUnpaired.join(', ')}`);
582
+ }
583
+ lines.push(` shots paired ${c.animations}; ${report.animations.length} compared`);
584
+ if (c.candidateAnimationsUnmatched.length > 0) {
585
+ lines.push(` ⚠️ no reference animation for: ${c.candidateAnimationsUnmatched.join(', ')}`);
586
+ }
587
+ if (c.referenceAnimationsUnpaired.length > 0) {
588
+ lines.push(` ⚠️ reference animation nothing is paired with: ${c.referenceAnimationsUnpaired.join(', ')}`);
589
+ }
590
+ lines.push(` sampling ${report.fps} fps`);
591
+ lines.push(sizeLine('candidate', report.candidate.size));
592
+ lines.push(sizeLine('reference', report.reference.size));
593
+ lines.push('');
594
+ lines.push(' conventions — every figure below was measured under these');
595
+ for (const line of report.conventions) lines.push(` · ${line}`);
596
+ lines.push('');
597
+
598
+ for (const anim of report.animations) {
599
+ const lengths =
600
+ anim.candidateFrames === anim.referenceFrames
601
+ ? `${anim.compared} frame(s)`
602
+ : `${anim.compared} frame(s) compared of ${anim.candidateFrames} candidate / ${anim.referenceFrames} reference ` +
603
+ '⚠️ the two shots are not the same length';
604
+ lines.push(
605
+ ` ${anim.candidate}${anim.candidate === anim.reference ? '' : ` vs ${anim.reference}`} ${lengths}, ` +
606
+ `${anim.candidateDuration.toFixed(3)}s vs ${anim.referenceDuration.toFixed(3)}s`,
607
+ );
608
+ for (const q of BONE_QUANTITIES) {
609
+ const w = anim.worst[q];
610
+ const where = w.frame < 0 ? 'nothing compared' : `bone \`${w.bone}\`, frame ${w.frame}`;
611
+ lines.push(` ${q.padEnd(9)} mean ${fmt(q, anim.means[q]).padEnd(11)} worst ${fmt(q, w.value).padEnd(11)} (${where})`);
612
+ }
613
+ const rows = [...anim.bones].sort((x, y) => y.position.worst - x.position.worst);
614
+ const shown = opts?.allBones ? rows : rows.slice(0, BONE_TABLE_ROWS);
615
+ if (shown.length > 0) {
616
+ lines.push(` per bone, worst position first${opts?.allBones ? '' : ` (${shown.length} of ${rows.length}; --all-bones for every row)`}`);
617
+ lines.push(` ${'bone'.padEnd(24)} ${'position'.padEnd(21)} ${'rotation'.padEnd(19)} ${'scale'.padEnd(21)} linear`);
618
+ for (const bone of shown) {
619
+ const name = bone.candidate === bone.reference ? bone.candidate : `${bone.candidate}->${bone.reference}`;
620
+ lines.push(
621
+ ` ${name.slice(0, 24).padEnd(24)} ` +
622
+ `${`${fmt('position', bone.position.mean)}/${fmt('position', bone.position.worst)}`.padEnd(21)} ` +
623
+ `${`${fmt('rotation', bone.rotation.mean)}/${fmt('rotation', bone.rotation.worst)}`.padEnd(19)} ` +
624
+ `${`${fmt('scale', bone.scale.mean)}/${fmt('scale', bone.scale.worst)}`.padEnd(21)} ` +
625
+ `${fmt('linear', bone.linear.mean)}/${fmt('linear', bone.linear.worst)}`,
626
+ );
627
+ }
628
+ lines.push(' (mean/worst per cell)');
629
+ }
630
+ lines.push('');
631
+ }
632
+
633
+ lines.push(' worst over every compared frame of every shot');
634
+ for (const q of BONE_QUANTITIES) {
635
+ const w = report.worst[q];
636
+ const where = w.frame < 0 ? 'nothing compared' : `\`${w.animation}\` bone \`${w.bone}\` frame ${w.frame}`;
637
+ lines.push(` ${q.padEnd(9)} ${fmt(q, w.value).padEnd(11)} (${where})`);
638
+ }
639
+ lines.push('');
640
+ lines.push(' There is no score and no threshold: the four quantities answer different questions and');
641
+ lines.push(' a mean of them would answer none. `docs/GATE.md` reads nothing from this table.');
642
+ return lines;
643
+ }