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,303 @@
1
+ /**
2
+ * A39, the body (issue #1025, cut 4c-3 of step 4c of #380): a deform keeps
3
+ * every triangle's winding.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the deform
6
+ * survey as a fact (`../facts/deform_survey.ts`), where it called
7
+ * `surveyDeformKeys` on the loaded skeleton itself, and the rig info as an
8
+ * argument. The four sentences it prints a dial, a tie and a frame with moved
9
+ * with it, unchanged. The argument for the rule — the frame, why `archetype`,
10
+ * the keys it reads no winding off, the spans — is above its `check` call,
11
+ * which stays in `validate()`.
12
+ */
13
+ import type { Verdicts } from '../harness.ts';
14
+ import type { DeformSurveyFacts } from '../facts/deform_survey.ts';
15
+ import type { RigInfo } from '../../types.ts';
16
+ import { unreachableWhy, type DeformDialDispute, type DeformDialTie, type DeformReach, type DialSpan } from '../../deformsurvey.ts';
17
+
18
+ /**
19
+ * The clause A39 puts after an animation's name when the frame it measured is
20
+ * not the track (issue #407).
21
+ *
22
+ * Empty on the track, which is what every animation no slider applies gets — so
23
+ * a message about a rig with no sliders in it reads exactly as it always has.
24
+ */
25
+ function frameClause(reach: DeformReach): string {
26
+ return reach.kind === 'slider' ? ` (applied by slider "${reach.slider}", not played on a track)` : '';
27
+ }
28
+
29
+ /**
30
+ * A dial's reach as A39's stats line spells it, or `none` when it can select no
31
+ * part of its animation at all.
32
+ *
33
+ * Six decimals because a key time has six: a reach whose end is printed coarser
34
+ * than the times it is compared against cannot be read against them.
35
+ */
36
+ function dialSpanText(span: DialSpan | null): string {
37
+ return span === null ? 'none' : `${span.lo.toFixed(6)}..${span.hi.toFixed(6)}s`;
38
+ }
39
+
40
+ /**
41
+ * One disputed dial on A39's stats line — both answers, both reaches, and the
42
+ * frames the artifact's answer could not have posed (issue #427).
43
+ *
44
+ * ⭐ **`outside:` is always there, `none` included.** The comparison it reports is
45
+ * what decides whether a disagreement changed anything the survey measured, and a
46
+ * comparison that came out equal must not look like one nobody made. It is the
47
+ * difference between "both answers pose the same frames, so the disagreement is a
48
+ * fact about rigc and not about this rig" and "these key times were surveyed
49
+ * through a field the skeleton does not name and no settable value of the one it
50
+ * does reaches them".
51
+ *
52
+ * ⚠️ No spaces anywhere in it: the stats line is `k=v` pairs joined by spaces, and
53
+ * a value with a space in it turns one reading into two.
54
+ */
55
+ function dialDisputeText(dispute: DeformDialDispute): string {
56
+ const stated = dispute.statedResponse === null ? 'unmeasured' : dispute.statedResponse.toExponential(3);
57
+ return (
58
+ `${dispute.slider}|artifact:${dispute.bone}.${dispute.stated}@${stated}` +
59
+ `|reaches:${dialSpanText(dispute.statedReach)}` +
60
+ `|probe:${dispute.bone}.${dispute.drive}@${dispute.driveResponse.toExponential(3)}` +
61
+ `|reaches:${dialSpanText(dispute.driveReach)}` +
62
+ `|outside:${dispute.outside.length === 0 ? 'none' : dispute.outside.map((t) => `${t.toFixed(6)}s`).join('+')}`
63
+ );
64
+ }
65
+
66
+ /** One tied dial on the same line, in the shape that cannot be read as a dispute. */
67
+ function dialTieText(tie: DeformDialTie): string {
68
+ const rivals = tie.rivals.map((r) => `${tie.bone}.${r.field}@${r.response.toExponential(3)}`).join('+');
69
+ return `${tie.slider}|artifact:${tie.bone}.${tie.drive}@${tie.driveResponse.toExponential(3)}|tied:${rivals}`;
70
+ }
71
+
72
+ export function a39DeformKeepsTriangleWinding({ fail, skip, stats }: Verdicts, facts: DeformSurveyFacts, rig: RigInfo | undefined): void {
73
+ if (!rig) {
74
+ return skip(
75
+ 'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
76
+ 'no rig info (validating a bare directory), so the rig cannot say which slots fold on purpose',
77
+ );
78
+ }
79
+ const survey = facts.survey(new Set(rig.deformMayFold));
80
+ // 🚨 Which dial was turned, when rigc's two halves did not simply agree
81
+ // about that — and BEFORE any of the returns below, because every one of
82
+ // them is a run that posed frames through this dial (issue #427).
83
+ //
84
+ // The verdict used to live in `DeformReach.label`, which `explain` prints
85
+ // and nothing else does, so a `build`-only run — the normal loop, and the
86
+ // one an agent that cannot see the rig actually runs — never learned that
87
+ // the artifact and the probe named different fields.
88
+ //
89
+ // ⛔ Not a refusal, and the measurement rather than taste is why (#427).
90
+ // The frames this survey posed were each checked against `SliderPose.time`
91
+ // by the runtime itself, so a disagreement cannot make it pose one that
92
+ // does not happen; and the field it drives is the largest response the
93
+ // probe found, so it cannot make it miss one that does. What a
94
+ // disagreement CAN do is leave the rig naming a property no settable value
95
+ // of turns far enough — which is what `outside` measures and what an
96
+ // author can act on. A refusal would refuse a rig spine-core poses
97
+ // correctly at every key, with no edit that would make it green.
98
+ //
99
+ // 🔒 A tie is not a disagreement and gets a line that cannot be read as
100
+ // one: there the probe named no field, the artifact broke the tie, and the
101
+ // parent-45° geometry that reaches it is legitimate.
102
+ if (survey.dialTies.length) {
103
+ stats.deformDialsTied = survey.dialTies.length;
104
+ stats.deformDialTied = survey.dialTies.map(dialTieText).join(',');
105
+ }
106
+ if (survey.dialDisputes.length) {
107
+ stats.deformDialsDisagreed = survey.dialDisputes.length;
108
+ stats.deformDialDisagreed = survey.dialDisputes.map(dialDisputeText).join(',');
109
+ }
110
+ /** A key this rule is refusing, by the triple that identifies it. */
111
+ const refusedKey = new Set<string>();
112
+ for (const key of survey.keys) {
113
+ // ⭐ The one thing this rule cannot say about a key that draws nothing.
114
+ // Its own message below states the harm as "draws its texture
115
+ // backwards", and that sentence is false when no pixel of the mesh
116
+ // lands at this time — the slot has faded to alpha 0, or shows another
117
+ // attachment. So the key is measured, reported and not gated (issue
118
+ // #401). Per key and per time: the SAME slot folding at full alpha in
119
+ // another animation, or at another key, is refused as before, which is
120
+ // what makes this a measurement rather than a second `deformMayFold`.
121
+ if (key.draw.blank !== null) continue;
122
+ // And the one thing it cannot say about a key at a time no dial selects
123
+ // (issue #407): the frame posed is not this key's, so its geometry
124
+ // belongs to some other time and a winding read off it would be a
125
+ // measurement of the wrong thing. Named below, on the stats line.
126
+ if (key.dial?.unreachable === true) continue;
127
+ if (key.reversed.length === 0) continue;
128
+ refusedKey.add(`${key.animation} ${key.slot} ${key.attachment} ${key.key}`);
129
+ const shown = key.reversed
130
+ .slice(0, 4)
131
+ .map((r) => `${r.triangle} [${r.ids.join(',')}] ${r.before.toFixed(3)} -> ${r.after.toFixed(3)}px²`);
132
+ const more = key.reversed.length > shown.length ? `, and ${key.reversed.length - shown.length} more` : '';
133
+ fail(
134
+ 'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
135
+ // ⚠️ The frame is in the message whenever it is not the track, because
136
+ // the same key can be refused in one frame and passed over in another
137
+ // — two sliders applying one animation are two frames — and a message
138
+ // that named only the key would be ambiguous about which (issue #407).
139
+ `animation "${key.animation}"${frameClause(key.reach)} deform ${key.slot}/${key.attachment} ` +
140
+ `key ${key.key} (t=${key.time}s): ` +
141
+ `${key.reversed.length} of ${key.triangles} triangle(s) reverse winding — triangle ${shown.join('; triangle ')}` +
142
+ `${more}. The mesh has turned inside out there and draws its texture backwards` +
143
+ // The alpha is in the message whenever it is not full, because the
144
+ // one thing that would make this key exempt is alpha exactly 0 and
145
+ // an author who has already faded the part half out needs to be
146
+ // told that half is not none (issue #401).
147
+ (key.draw.alpha === 1
148
+ ? ''
149
+ : ` at alpha ${key.draw.alpha.toFixed(4)} — visible at that strength, and only alpha exactly 0 draws ` +
150
+ 'no pixels at all') +
151
+ '. Fix the key\'s ' +
152
+ 'offsets in the motion spec\'s deform timeline (a projection past its fold angle is the usual ' +
153
+ 'cause — docs/FACE.md §4.2 has the closed form), or, if this slot folds on purpose, declare it ' +
154
+ `in the rig spec as invariants.deformMayFold: [{ "slot": "${key.slot}", "why": … }]`,
155
+ );
156
+ }
157
+ // --- and the folds no key lands on (issue #403) ------------------------
158
+ let spanFolds = 0;
159
+ for (const span of survey.spans) {
160
+ if (span.fold === null) continue;
161
+ // Suppressed when a bounding key already says it: one defect, one
162
+ // message. The span check is here for what the keys cannot see.
163
+ const bounded = `${span.animation} ${span.slot} ${span.attachment} `;
164
+ if (refusedKey.has(bounded + span.fromKey) || refusedKey.has(bounded + span.toKey)) continue;
165
+ spanFolds++;
166
+ const at = span.fold;
167
+ const shown = at.measure.reversed
168
+ .slice(0, 4)
169
+ .map((r) => `${r.triangle} [${r.ids.join(',')}] ${r.before.toFixed(3)} -> ${r.after.toFixed(3)}px²`);
170
+ const more =
171
+ at.measure.reversed.length > shown.length ? `, and ${at.measure.reversed.length - shown.length} more` : '';
172
+ // A stepped segment interpolates NOTHING — it holds the earlier key's
173
+ // geometry across the whole span — so saying "the runtime interpolates"
174
+ // there would be telling the author to look for a defect in the wrong
175
+ // place. What changed across a stepped span is what the slot DRAWS.
176
+ const held = span.curve === 'stepped';
177
+ fail(
178
+ 'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
179
+ `animation "${span.animation}"${frameClause(span.reach)} deform ${span.slot}/${span.attachment} ` +
180
+ `BETWEEN key ${span.fromKey} ` +
181
+ `(t=${span.fromTime}s) and key ${span.toKey} (t=${span.toTime}s), at t=${at.time.toFixed(6)}s` +
182
+ (held ? ' (a stepped segment)' : ` — ${(at.percent * 100).toFixed(1)}% of the way from one to the other`) +
183
+ `: ${at.measure.reversed.length} of ${at.measure.triangles} triangle(s) reverse winding — ` +
184
+ `triangle ${shown.join('; triangle ')}${more}. NO KEY LANDS THERE: ` +
185
+ (held
186
+ ? `a stepped segment interpolates nothing, it HOLDS key ${span.fromKey}'s geometry across the whole ` +
187
+ 'span — so the fold is that key\'s and what changes here is what the slot draws'
188
+ : 'the runtime interpolates between the two keys, and the mesh is inside out for part of the way') +
189
+ ', drawing its texture backwards' +
190
+ (at.measure.draw.alpha === 1
191
+ ? ''
192
+ : ` at alpha ${at.measure.draw.alpha.toFixed(4)} — visible at that strength, and only alpha exactly 0 ` +
193
+ 'draws no pixels at all') +
194
+ '. ' +
195
+ (held
196
+ ? `Fix key ${span.fromKey}'s offsets, or keep the slot drawing nothing for as long as it holds them`
197
+ : 'Add a key inside the span so the geometry the runtime passes through is geometry you wrote, or ' +
198
+ "move the two keys' offsets closer together (a projection past its fold angle is the usual cause " +
199
+ '— docs/FACE.md §4.2 has the closed form)') +
200
+ (at.measure.draw.alpha === 1
201
+ ? ''
202
+ : '; if the part is being faded out over this turn, land the alpha-0 key BEFORE the folding key ' +
203
+ 'rather than on it, so every frame that folds is a frame that draws nothing (docs/FACE.md §9.2)') +
204
+ `, or, if this slot folds on purpose, declare it in the rig spec as invariants.deformMayFold: ` +
205
+ `[{ "slot": "${span.slot}", "why": … }]`,
206
+ );
207
+ }
208
+ if (survey.timelines === 0) {
209
+ return skip('A39_DEFORM_KEEPS_TRIANGLE_WINDING', 'no animation carries a deform timeline');
210
+ }
211
+ if (survey.keys.length === 0) {
212
+ const why = [
213
+ survey.exempted.length ? `the rig declares ${survey.exempted.join(', ')} as deformMayFold` : '',
214
+ survey.notAMesh.length ? `${survey.notAMesh.join(', ')} deform an attachment with no triangles` : '',
215
+ ].filter(Boolean);
216
+ return skip(
217
+ 'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
218
+ `no deform timeline here has a winding to keep: ${why.join('; ') || 'every mesh keyed has no triangles'}`,
219
+ );
220
+ }
221
+ // ⚠️ Silence is not a pass. A key passed over because nothing of it is
222
+ // drawn has to be visible on a green run too — this is the only surface
223
+ // `validate` has, and `explain`'s DEFORM block prints the whole sentence
224
+ // beside the key's own figures.
225
+ // ⚠️ Unreachable first and `blank` second, in the survey's own order, so
226
+ // the two counts partition the ungated keys instead of double-counting a
227
+ // key that is both.
228
+ const unreachable = survey.keys.filter((k) => k.dial?.unreachable === true);
229
+ const blank = survey.keys.filter((k) => k.dial?.unreachable !== true && k.draw.blank !== null);
230
+ const ungated = blank.length + unreachable.length;
231
+ const name = (k: (typeof survey.keys)[number]): string =>
232
+ `${k.animation}/${k.slot}/${k.attachment}#${k.key}:${k.draw.showsThisMesh ? 'alpha0' : 'notShown'}`;
233
+ // ⚠️ `&& spanFolds === 0` because a rig whose every key draws nothing can
234
+ // still fold at a time between two of them that DOES draw — that is issue
235
+ // #403's own case, and a SKIP printed over a refusal would be this rule
236
+ // reporting "nothing to measure" about the thing it just measured.
237
+ if (ungated === survey.keys.length && spanFolds === 0) {
238
+ const first = blank[0] ?? unreachable[0];
239
+ return skip(
240
+ 'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
241
+ `no deform key here is measurable in the frame its animation is reached in — ` +
242
+ `${first.animation} ${first.slot}/${first.attachment} key ${first.key}: ` +
243
+ `${first.draw.blank ?? unreachableWhy(first)}` +
244
+ (survey.keys.length > 1 ? `, and ${survey.keys.length - 1} more key(s) like it` : '') +
245
+ (unreachable.length
246
+ ? `. ${unreachable.length} of them at a time no dial selects, which is a rig defect this rule does ` +
247
+ 'not refuse and does not pass over in silence either'
248
+ : '') +
249
+ (survey.spans.length
250
+ ? `. The ${survey.spans.length} span(s) between them were scanned too and none folds where anything ` +
251
+ 'is drawn'
252
+ : ''),
253
+ );
254
+ }
255
+ stats.deformKeysMeasured = survey.keys.length - ungated;
256
+ stats.deformTrianglesMeasured = survey.trianglesMeasured;
257
+ stats.deformTrianglesCollapsed = survey.collapsed;
258
+ // Which frame each animation was posed in (issue #407) — printed only when
259
+ // a slider chose one, because on every other rig it says "a track" about
260
+ // every animation and a stats line that never varies is not a reading.
261
+ const frames = [...new Map(survey.keys.map((k) => [`${k.animation}/${k.reach.slider ?? 'track'}`, k])).values()];
262
+ if (frames.some((k) => k.reach.kind === 'slider')) {
263
+ stats.deformFrames = frames
264
+ .map((k) => `${k.animation}:${k.reach.kind === 'slider' ? `slider/${k.reach.slider}` : 'track'}`)
265
+ .join(',');
266
+ }
267
+ if (blank.length) {
268
+ stats.deformKeysNotDrawn = blank.length;
269
+ stats.deformNotDrawn = blank.map(name).join(',');
270
+ if (survey.notDrawnReversed) stats.deformNotDrawnReversed = survey.notDrawnReversed;
271
+ }
272
+ // 🚨 A key at a time no dial can select is NOT a pass and NOT a refusal —
273
+ // it is a rig whose slider cannot reach its own animation's key, named
274
+ // here so a green run cannot be read as having measured it (issue #407).
275
+ if (unreachable.length) {
276
+ stats.deformKeysUnreachable = unreachable.length;
277
+ stats.deformUnreachable = unreachable
278
+ .map((k) => `${k.animation}/${k.slot}/${k.attachment}#${k.key}@${k.dial?.applied.toFixed(6) ?? '?'}`)
279
+ .join(',');
280
+ if (survey.notReachableReversed) stats.deformUnreachableReversed = survey.notReachableReversed;
281
+ }
282
+ if (survey.exempted.length) stats.deformFoldExempt = survey.exempted.join(',');
283
+ // ⚠️ The between-keys scan on the stats line, on a GREEN run too (issue
284
+ // #403). `deformSpansScanned` is the positive control an agent can read —
285
+ // a scan that ran and found nothing has to be distinguishable from a scan
286
+ // that never ran — and `deformSpanProbes` is what it cost: 0 on a rig the
287
+ // closed form flags nothing in, one posed measurement per flagged window
288
+ // otherwise.
289
+ stats.deformSpansScanned = survey.spans.length;
290
+ // ⚠️ And the ones it could NOT scan, for the same reason the line above
291
+ // exists: a span bounded by a key at a time no dial selects would be
292
+ // solved over two poses of some other time, so it is skipped — and a skip
293
+ // nobody can see is the silence this whole surface is against (#407).
294
+ if (survey.spansNotScanned) stats.deformSpansNotScanned = survey.spansNotScanned;
295
+ if (survey.spanProbes) stats.deformSpanProbes = survey.spanProbes;
296
+ if (survey.spansNotDrawn) stats.deformSpansNotDrawn = survey.spansNotDrawn;
297
+ // A prediction nothing reproduced. Never a refusal — that would be the
298
+ // false red issues #44 and #262 already cost this file — and never a
299
+ // silence either: the one case that reaches it is a weighted mesh whose
300
+ // bones move across the span, where the closed form's fixed-pose
301
+ // assumption is the thing that did not hold.
302
+ if (survey.spansUnconfirmed) stats.deformSpansUnconfirmed = survey.spansUnconfirmed;
303
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * A40, the body (issue #1025, cut 4c-5 of step 4c of #380): two sliders on
3
+ * one property, and the later one erases the other.
4
+ *
5
+ * The argument — why this is `validity`, the three shapes excluded
6
+ * structurally (authority below 1, a skin switch, different properties), the
7
+ * second clause and the events clause removed — stands above the `check` call
8
+ * in `validate()`. What a timeline does with `add` is the one fact here that
9
+ * is behaviour rather than a record: it is posed, and the body asks for it
10
+ * (`behaviour`) only for the later timeline of a shared property, once per
11
+ * timeline.
12
+ *
13
+ * Moved out of `src/validate.ts` whole: the sliders, their setup mix, their
14
+ * `additive` flag, the skins that list them, the animations they apply and the
15
+ * properties those animations' timelines register are the rig's, and a
16
+ * document's records state each; the class a timeline's additive application
17
+ * falls in is the core's computation over the document
18
+ * (`src/core/additive.ts`), held to the runtime's probe by the core suite's
19
+ * `CO27`. The words the sentences print are the facts' own: the runtime class
20
+ * a timeline loads as, the property's name, the target as the sentence names
21
+ * it.
22
+ */
23
+ import type { Verdicts } from '../harness.ts';
24
+ import type { ConstraintFacts } from '../facts/constraints.ts';
25
+ import type { AddBehaviour, SliderCompositionFacts, SliderFact, SliderTimelineFact } from '../facts/slider_composition.ts';
26
+ import { switchedOn } from '../constraint_words.ts';
27
+
28
+ export function a40SlidersComposeOnASharedTarget({ fail, skip, stats }: Verdicts, facts: SliderCompositionFacts, constraints: ConstraintFacts): void {
29
+ const sliders = facts.sliders;
30
+ if (sliders.length < 2) {
31
+ return skip(
32
+ 'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
33
+ `the skeleton declares ${sliders.length} slider constraint${sliders.length === 1 ? '' : 's'}, and one slider has nothing to compose with`,
34
+ );
35
+ }
36
+ // Clause 1 of the "could this be correct?" list above the check.
37
+ // "Keyed at all" rather than "keyed live", and on purpose: this clause asks
38
+ // whether the mix can MOVE from its setup value, so any mix timeline
39
+ // disqualifies it — the question `keyedBy` answered here, through the one
40
+ // reading `switchedOn` gives (`../constraint_words.ts`, over the same facts
41
+ // the moved constraint bodies read — issue #1025), and unchanged by issue
42
+ // #752.
43
+ const mixKeyed = switchedOn(constraints, (timeline) => timeline.kind === 'slider' && timeline.word === 'mix', 1, () => true);
44
+ const authoritative = sliders.filter((s) => s.mix >= 1 && !mixKeyed.has(s.index));
45
+ if (authoritative.length < 2) {
46
+ return skip(
47
+ 'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
48
+ `${authoritative.length} of the ${sliders.length} slider constraints apply at full authority; below mix 1 an ` +
49
+ 'apply is a lerp from the current pose rather than an overwrite, so what the others do to a shared property is a weighting',
50
+ );
51
+ }
52
+ /** Which skins switch a slider on, or null when it is active under every skin. */
53
+ const skinsOf = new Map<SliderFact, Set<string> | null>();
54
+ for (const slider of authoritative) skinsOf.set(slider, slider.skinRequired ? new Set(slider.skins) : null);
55
+ /** Clause 2: can these two ever run in the same frame? */
56
+ const canOverlap = (a: SliderFact, b: SliderFact): boolean => {
57
+ const skinsA = skinsOf.get(a) ?? null;
58
+ const skinsB = skinsOf.get(b) ?? null;
59
+ if (!skinsA || !skinsB) return true;
60
+ for (const name of skinsA) {
61
+ if (skinsB.has(name)) return true;
62
+ }
63
+ return false;
64
+ };
65
+ type User = { slider: SliderFact; timeline: SliderTimelineFact; animation: string; at: number; property: string; names: string };
66
+ /** property id -> the sliders whose animation keys it, in constraints-array order. */
67
+ const byProperty = new Map<string, User[]>();
68
+ for (const slider of authoritative) {
69
+ const seen = new Set<string>();
70
+ const animation = slider.animation;
71
+ if (animation === null) continue;
72
+ animation.timelines.forEach((timeline, at) => {
73
+ for (const { id, property, names } of timeline.properties) {
74
+ if (seen.has(id)) continue;
75
+ seen.add(id);
76
+ byProperty.set(id, [...(byProperty.get(id) ?? []), { slider, timeline, animation: animation.name, at, property, names }]);
77
+ }
78
+ });
79
+ }
80
+ /** Posed once per timeline, because the answer is the class's and the rigs that reach here share timelines. */
81
+ const behaviour = new Map<string, AddBehaviour>();
82
+ const addBehaviourOf = (user: User): AddBehaviour => {
83
+ const key = `${user.animation}\u0000${user.at}`;
84
+ const known = behaviour.get(key);
85
+ if (known !== undefined) return known;
86
+ const measured = facts.behaviour(user.animation, user.at);
87
+ behaviour.set(key, measured);
88
+ return measured;
89
+ };
90
+ let shared = 0;
91
+ for (const [, users] of byProperty) {
92
+ if (users.length < 2) continue;
93
+ shared++;
94
+ const at = (slider: SliderFact): string => `"${slider.name}" (constraints[${slider.index}], additive: ${String(slider.additive)})`;
95
+ const chain = users.map((u) => at(u.slider)).join(', ');
96
+ for (let j = 1; j < users.length; j++) {
97
+ const later = users[j];
98
+ const composes = addBehaviourOf(later);
99
+ if (composes === 'accumulates' && later.slider.additive) continue;
100
+ // Nothing a second slider could take away: applied with the arguments
101
+ // a slider passes — `firedEvents` null among them — this timeline
102
+ // moves no pose at all.
103
+ if (composes === 'inert') continue;
104
+ const erased = users.slice(0, j).filter((e) => canOverlap(e.slider, later.slider));
105
+ if (!erased.length) continue;
106
+ const erasedNames = `${erased.map((e) => `"${e.slider.name}"`).join(', ')} contribute${erased.length === 1 ? 's' : ''}`;
107
+ const why =
108
+ composes === 'accumulates'
109
+ ? `slider "${later.slider.name}" applies animation "${later.slider.animation?.name}" with additive false, and at ` +
110
+ 'mix 1 a non-additive apply writes the value outright (`getRelativeValue` returns `setup + value`, ' +
111
+ '`getAbsoluteValue` returns `value`) rather than adding to the pose it found. Set `"additive": true` on ' +
112
+ `slider "${later.slider.name}" in the rig spec — rigc will not choose that flag for you — or key this ` +
113
+ `property from one slider only. [measured] \`${later.timeline.runtimeClass}.apply\` posed twice with ` +
114
+ '`add` set accumulates, so that flag is the repair here'
115
+ : `the ${later.property} timeline they share writes its value outright whatever the ` +
116
+ `flags say — [measured] \`${later.timeline.runtimeClass}.apply\` posed twice with \`add\` set left the ` +
117
+ 'same value there rather than adding to it — so `"additive": true` would NOT compose these. Key this ' +
118
+ 'property from one slider only, or move both edits into the one animation a single slider applies';
119
+ fail(
120
+ 'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
121
+ `${later.names} is keyed by the animations of ${users.length} sliders — ${chain} — and every ` +
122
+ `one of them applies at mix 1. Today ${at(later.slider)} wins that property and ${erasedNames} ` +
123
+ `nothing to it: ${why}.`,
124
+ );
125
+ }
126
+ }
127
+ stats.sliderSharedTargets = shared;
128
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A42, the body (issue #1025, step 4c of #380): a constraint a slider's
3
+ * animation keys updates after that slider.
4
+ *
5
+ * `Skeleton.updateCache` walks the `constraints` array in order and each
6
+ * constraint's `sort` pushes itself as it is reached — `Slider.sort` included,
7
+ * which pushes bones and never a constraint — so the array IS the update order,
8
+ * for every kind. Every constraint then opens its `update` by reading its own
9
+ * applied pose, so a slider that keys any property of a constraint is read by
10
+ * that constraint only when it comes LATER in the array; written the other way
11
+ * round the value lands in a pose whose only reader has already run, and
12
+ * `Posed.resetConstrained` puts the pose back before the next frame (the
13
+ * argument and its measurements sit above the `check` call in `validate()`).
14
+ *
15
+ * Moved out of `src/validate.ts` whole: the array's order, the slider's
16
+ * animation and the constraint timelines it holds — which constraint each
17
+ * names, what it keys, and whom a physics timeline naming none reaches — are
18
+ * the rig's, and a document's records state each. The words the sentence
19
+ * prints are the facts' own: what a timeline keys (`word`, the motion spec's
20
+ * word for it) and the runtime class whose `update` reads it (`runtimeClass`).
21
+ */
22
+ import type { Verdicts } from '../harness.ts';
23
+ import type { ConstraintFacts } from '../facts/constraints.ts';
24
+
25
+ export function a42DrivenConstraintsUpdateAfterTheirDriver({ fail, skip, stats }: Verdicts, facts: ConstraintFacts): void {
26
+ const sliders = facts.constraints.flatMap((c, index) => (c.slider === undefined ? [] : [{ name: c.name, slider: c.slider, index }]));
27
+ if (!sliders.length) {
28
+ return skip('A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER', 'the skeleton declares no slider constraint');
29
+ }
30
+ let pairs = 0;
31
+ let resets = 0;
32
+ for (const driver of sliders) {
33
+ const driverIndex = driver.index;
34
+ const animationName = driver.slider.animation?.name;
35
+ for (const timeline of facts.timelines) {
36
+ if (animationName === undefined || timeline.animation !== animationName) continue;
37
+ if (timeline.kind === 'physics' && timeline.word === 'reset') {
38
+ resets++;
39
+ continue;
40
+ }
41
+ const word = timeline.word;
42
+ const everyPhysics = timeline.constraint < 0;
43
+ // A physics timeline whose animation names no constraint reaches every
44
+ // ACTIVE physics constraint whose own data declares that property
45
+ // global — the timeline's `reach`, the one reading of it A23 and A34
46
+ // share (issue #726).
47
+ for (const drivenIndex of timeline.reach) {
48
+ const driven = facts.constraints[drivenIndex];
49
+ if (driven === undefined) continue;
50
+ pairs++;
51
+ if (drivenIndex > driverIndex) continue;
52
+ const kind = { word: driven.kind, runtime: driven.runtimeClass };
53
+ const animation = `animation "${animationName}"`;
54
+ const reference = `\`${kind.word}.${driven.name}${word === kind.word ? '' : `.${word}`}\``;
55
+ fail(
56
+ 'A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER',
57
+ drivenIndex === driverIndex
58
+ ? `slider "${driver.name}" (constraints[${driverIndex}]) keys its own \`${word}\` in ${animation}. ` +
59
+ '`Slider.update` reads `appliedPose.' +
60
+ `${word}\` as the ${word === 'mix' ? 'alpha it applies that animation with' : 'time it applies that animation at'}, ` +
61
+ 'before the animation runs, so the key is written after its only reader and `Posed.resetConstrained` ' +
62
+ `puts the pose back before the next frame${
63
+ word === 'mix'
64
+ ? ' — and at `mix` 0 `update` returns before applying anything at all, so the key that would raise it is unreachable'
65
+ : ''
66
+ }. Key ${reference} from a slider EARLIER in \`constraints\`, or state the ` +
67
+ `\`${word}\` this slider should start at in the rig spec`
68
+ : `slider "${driver.name}" (constraints[${driverIndex}]) keys ${
69
+ word === kind.word ? `the \`${word}\` timeline` : `\`${word}\``
70
+ } of ${kind.word} constraint ` +
71
+ `"${driven.name}" (constraints[${drivenIndex}]) in ${animation}${
72
+ everyPhysics ? ' — the timeline names no constraint, which the runtime reads as every physics constraint declaring that property global —' : ''
73
+ }, and "${driven.name}" updates FIRST. The \`constraints\` array is the update order ` +
74
+ '(`Skeleton.updateCache` walks it and each constraint\'s `sort` pushes itself as it is reached) and ' +
75
+ `\`${kind.runtime}.update\` reads its own \`appliedPose\` before applying anything, so that key is ` +
76
+ 'written after the only read of it and `Posed.resetConstrained` discards it before the next frame: ' +
77
+ `what "${driven.name}" drives is dead at every reading of "${driver.name}"'s dial, although its pose ` +
78
+ `still holds the number. Move "${driver.name}" before "${driven.name}" in \`constraints\`, or key ` +
79
+ `${reference} from a slider that already is`,
80
+ );
81
+ }
82
+ }
83
+ }
84
+ if (pairs === 0) {
85
+ return skip(
86
+ 'A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER',
87
+ `no animation applied by one of the ${sliders.length} slider constraint${sliders.length === 1 ? '' : 's'} keys a ` +
88
+ `property of a constraint, so no slider here drives a constraint${
89
+ resets === 0
90
+ ? ''
91
+ : ` — the ${resets} \`physics\` \`reset\` key(s) they do carry are not a pose write, and [measured] a slider ` +
92
+ 'applies its animation at one instant, so `PhysicsConstraintResetTimeline` never fires from one in either array order'
93
+ }`,
94
+ );
95
+ }
96
+ stats.sliderDrivenConstraints = pairs;
97
+ }