spine-rigc 0.18.0 → 0.19.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.
package/src/validate.ts CHANGED
@@ -45,7 +45,14 @@ import {
45
45
  // A19 needs the DECODED page, not its header, to measure one region's own
46
46
  // rectangle on a shared page.
47
47
  import { readPlate } from '../tools/plate.ts';
48
- import { surveyDeformKeys, unreachableWhy, type DeformReach } from './deformmeasure.ts';
48
+ import {
49
+ surveyDeformKeys,
50
+ unreachableWhy,
51
+ type DeformDialDispute,
52
+ type DeformDialTie,
53
+ type DeformReach,
54
+ type DialSpan,
55
+ } from './deformmeasure.ts';
49
56
  import { colourTypeName, readPngInfo } from './png.ts';
50
57
  import { CHANNELS_BY_KIND, KEY_TIME_EPSILON, walkTimelines } from './timelines.ts';
51
58
  import type { RigInfo } from './types.ts';
@@ -238,6 +245,49 @@ function frameClause(reach: DeformReach): string {
238
245
  return reach.kind === 'slider' ? ` (applied by slider "${reach.slider}", not played on a track)` : '';
239
246
  }
240
247
 
248
+ /**
249
+ * A dial's reach as A39's stats line spells it, or `none` when it can select no
250
+ * part of its animation at all.
251
+ *
252
+ * Six decimals because a key time has six: a reach whose end is printed coarser
253
+ * than the times it is compared against cannot be read against them.
254
+ */
255
+ function dialSpanText(span: DialSpan | null): string {
256
+ return span === null ? 'none' : `${span.lo.toFixed(6)}..${span.hi.toFixed(6)}s`;
257
+ }
258
+
259
+ /**
260
+ * One disputed dial on A39's stats line — both answers, both reaches, and the
261
+ * frames the artifact's answer could not have posed (issue #427).
262
+ *
263
+ * ⭐ **`outside:` is always there, `none` included.** The comparison it reports is
264
+ * what decides whether a disagreement changed anything the survey measured, and a
265
+ * comparison that came out equal must not look like one nobody made. It is the
266
+ * difference between "both answers pose the same frames, so the disagreement is a
267
+ * fact about rigc and not about this rig" and "these key times were surveyed
268
+ * through a field the skeleton does not name and no settable value of the one it
269
+ * does reaches them".
270
+ *
271
+ * ⚠️ No spaces anywhere in it: the stats line is `k=v` pairs joined by spaces, and
272
+ * a value with a space in it turns one reading into two.
273
+ */
274
+ function dialDisputeText(dispute: DeformDialDispute): string {
275
+ const stated = dispute.statedResponse === null ? 'unmeasured' : dispute.statedResponse.toExponential(3);
276
+ return (
277
+ `${dispute.slider}|artifact:${dispute.bone}.${dispute.stated}@${stated}` +
278
+ `|reaches:${dialSpanText(dispute.statedReach)}` +
279
+ `|probe:${dispute.bone}.${dispute.drive}@${dispute.driveResponse.toExponential(3)}` +
280
+ `|reaches:${dialSpanText(dispute.driveReach)}` +
281
+ `|outside:${dispute.outside.length === 0 ? 'none' : dispute.outside.map((t) => `${t.toFixed(6)}s`).join('+')}`
282
+ );
283
+ }
284
+
285
+ /** One tied dial on the same line, in the shape that cannot be read as a dispute. */
286
+ function dialTieText(tie: DeformDialTie): string {
287
+ const rivals = tie.rivals.map((r) => `${tie.bone}.${r.field}@${r.response.toExponential(3)}`).join('+');
288
+ return `${tie.slider}|artifact:${tie.bone}.${tie.drive}@${tie.driveResponse.toExponential(3)}|tied:${rivals}`;
289
+ }
290
+
241
291
  const FRAME = 1 / 60;
242
292
  const STEP_FRAMES = 120;
243
293
 
@@ -1668,6 +1718,36 @@ export function validate(input: ValidateInput): ValidateReport {
1668
1718
  );
1669
1719
  }
1670
1720
  const survey = surveyDeformKeys(data, new Set(input.rig.deformMayFold));
1721
+ // 🚨 Which dial was turned, when rigc's two halves did not simply agree
1722
+ // about that — and BEFORE any of the returns below, because every one of
1723
+ // them is a run that posed frames through this dial (issue #427).
1724
+ //
1725
+ // The verdict used to live in `DeformReach.label`, which `explain` prints
1726
+ // and nothing else does, so a `build`-only run — the normal loop, and the
1727
+ // one an agent that cannot see the rig actually runs — never learned that
1728
+ // the artifact and the probe named different fields.
1729
+ //
1730
+ // ⛔ Not a refusal, and the measurement rather than taste is why (#427).
1731
+ // The frames this survey posed were each checked against `SliderPose.time`
1732
+ // by the runtime itself, so a disagreement cannot make it pose one that
1733
+ // does not happen; and the field it drives is the largest response the
1734
+ // probe found, so it cannot make it miss one that does. What a
1735
+ // disagreement CAN do is leave the rig naming a property no settable value
1736
+ // of turns far enough — which is what `outside` measures and what an
1737
+ // author can act on. A refusal would refuse a rig spine-core poses
1738
+ // correctly at every key, with no edit that would make it green.
1739
+ //
1740
+ // 🔒 A tie is not a disagreement and gets a line that cannot be read as
1741
+ // one: there the probe named no field, the artifact broke the tie, and the
1742
+ // parent-45° geometry that reaches it is legitimate.
1743
+ if (survey.dialTies.length) {
1744
+ stats.deformDialsTied = survey.dialTies.length;
1745
+ stats.deformDialTied = survey.dialTies.map(dialTieText).join(',');
1746
+ }
1747
+ if (survey.dialDisputes.length) {
1748
+ stats.deformDialsDisagreed = survey.dialDisputes.length;
1749
+ stats.deformDialDisagreed = survey.dialDisputes.map(dialDisputeText).join(',');
1750
+ }
1671
1751
  /** A key this rule is refusing, by the triple that identifies it. */
1672
1752
  const refusedKey = new Set<string>();
1673
1753
  for (const key of survey.keys) {