spine-rigc 0.20.2 → 0.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.
package/src/compile.ts CHANGED
@@ -35,6 +35,10 @@ import {
35
35
  } from './depth.ts';
36
36
  import { CompileError, NotImplementedError } from './errors.ts';
37
37
  import { parseJsonWithPosition } from './json-position.ts';
38
+ // The "did you mean" list on a missing atlas region, from the one implementation
39
+ // of it — the same search serves `refuseUnknownKeys`, and a second copy here with
40
+ // a threshold edited is how such a pair drifts apart.
41
+ import { nearMisses } from './keys.ts';
38
42
  import { parseMotionSpec } from './motion.ts';
39
43
  import {
40
44
  parseRigSpec,
@@ -42,6 +46,7 @@ import {
42
46
  RIG_PATH_POSITION_MODES,
43
47
  RIG_PATH_ROTATE_MODES,
44
48
  RIG_PATH_SPACING_MODES,
49
+ RIG_SCALE_Y_MODES,
45
50
  RIG_SKIN_CONSTRAINT_KEYS,
46
51
  splitRigSkin,
47
52
  type RigAttachment,
@@ -136,6 +141,335 @@ export const SPINE_VERSION = '4.3.13';
136
141
 
137
142
  const FRAME = 1 / 60;
138
143
 
144
+ /**
145
+ * The order the emitted `animations` object is keyed in: **the Spine editor's own
146
+ * comparator, as far as anybody has measured it** — natural and case-insensitive
147
+ * (#539) — with every name set refused on which one of that comparator's four
148
+ * UNMEASURED choices could decide a pair.
149
+ *
150
+ * ⚠️ This emitted **codepoint** order until issue #543, and the refusal was the
151
+ * thing that made codepoint agree with the editor: every pair the two could order
152
+ * differently was a `CompileError`. That is sound and it over-refuses by
153
+ * construction, because codepoint is not a member of the family it is being
154
+ * defended against — it is neither natural nor case-insensitive — so `Turn`
155
+ * against `sweep` and `turn10` against `turn2`, **the two pairs the editor was
156
+ * directly measured on**, were refused rather than emitted in the order the
157
+ * editor was measured returning. Sorting by a member of the measured family
158
+ * instead moves the refusal onto what is actually unmeasured, and moves no byte:
159
+ * on every set the codepoint rule accepted, the two orders are the same (that is
160
+ * what the rule guaranteed), which is why this change is invisible to every rig
161
+ * in the tree.
162
+ *
163
+ * ## Why the emitter has an opinion about this at all
164
+ *
165
+ * A `slider` constraint names the animation it applies, and in JSON that is a
166
+ * name on both sides. The **binary** format is where the same reference is an
167
+ * ordinal — `SkeletonBinary.js`: `constraint.animation = animations[readInt()]`
168
+ * — and an editor whose own model holds that ordinal reads the name at import
169
+ * and writes back whatever now stands at that position. Round-tripped through a
170
+ * licensed editor (data version 4.3.26), `gallery/look` went in as
171
+ * `turn, tilt, sweep` with `yaw -> "turn"` and came back as `sweep, tilt, turn`
172
+ * with **`yaw -> "sweep"`** (issue #535). Nothing in the returned file says so;
173
+ * it parses, it validates, and it applies the wrong animation.
174
+ *
175
+ * ⭐ The second slider is the control that names the mechanism rather than a
176
+ * second victim: `tilt` survived because it sat at index 1 in *both* orderings
177
+ * and index 1 is called `tilt` in both.
178
+ *
179
+ * ⇒ Emitting in the editor's own order makes its re-sort a no-op, so no index
180
+ * moves and no reference is repointed. Measured on the same rig through the
181
+ * same editor: mean absolute error over the re-rendered frames fell from
182
+ * 10.4655 / 8.4961 / 8.7140 to 0.3035 / 0.0769 / 0.0588, and `yaw -> "turn"`
183
+ * came back intact.
184
+ *
185
+ * ## What the editor's comparator is, and which parts of it are known
186
+ *
187
+ * ⚠️ This comment used to say that codepoint order "is what every editor-authored
188
+ * file on hand is in", and that sentence was false when it was written. The
189
+ * counterexample ships with the tests: `examples/spineboy/export/spineboy-pro.json`
190
+ * keys `portal-flare9` **before** `portal-flare10` — in its default skin's
191
+ * attachment map and in two of `portal`'s timeline maps — and no codepoint sort
192
+ * produces that.
193
+ *
194
+ * 🔢 The survey behind that, stated so it can be re-taken rather than believed:
195
+ * over the 12 skeletons in `examples/`, take **every object the format keys by a
196
+ * NAME that carries more than one key** — `animations` and `events`; each skin's
197
+ * `attachments` map and each per-slot map inside it; each animation's `bones`,
198
+ * `slots`, `ik`, `transform`, `path`, `physics`, `events`, and its deform
199
+ * `attachments` at **all three** of its levels (skin, then slot, then attachment
200
+ * name). Arrays are excluded because the editor leaves them alone. That is
201
+ * **105** collections, of which **102** are consistent with a codepoint sort and
202
+ * **3** are not; all 12 natural comparators and all 8 `Intl.Collator`
203
+ * configurations tried reproduce every one of the 105.
204
+ *
205
+ * ⚠️ The deform clause is the whole of what makes it 105 rather than 104, and it
206
+ * is one collection: `animations.hoverboard.attachments.default` in
207
+ * `spineboy-pro.json`, whose four keys are slot names. Counting the skin level
208
+ * of a deform block and stopping there needs an exception the sentence above
209
+ * cannot state — every level of it is keyed by a name, and the editor re-keys
210
+ * each one — so the rule is "every level", and that collection is in.
211
+ *
212
+ * The editor was then measured directly (issue #539). Two rigs, each varying one
213
+ * axis: `Turn, sweep, wave` came back `sweep, Turn, wave`, and
214
+ * `turn10, turn2, zoom` came back `turn2, turn10, zoom`. The intersection leaves
215
+ * one hypothesis — **natural order, case-insensitive**.
216
+ *
217
+ * ⚠️ "Natural, case-insensitive" is a **family** of comparators rather than one.
218
+ * Leading zeros (`turn01` against `turn1`), a pure case tie (`Turn` against
219
+ * `turn`), whether a digit run sorts before a word, and what a separator is worth
220
+ * are each a free choice, and **the editor's answers to them are not measured**.
221
+ * Writing a comparator that sorts every name set means choosing all four, and a
222
+ * chosen-but-unmeasured comparator is exactly how #537 landed.
223
+ *
224
+ * ⇒ 🔑 **So the family is never asked to sort a pair one of those four decides.**
225
+ * `measuredOrder` returns a verdict only where every member of the family must
226
+ * agree, and `refuseNamesTheEditorCouldKeyDifferently` stops the build on any pair
227
+ * where it cannot — which means the four choices below are **unobservable in the
228
+ * output**, and the claim the emit makes is the one #542 established, widened:
229
+ * *on this name set, every comparator consistent with what has been measured
230
+ * produces this order.* Checkable inside rigc, with no editor and no oracle.
231
+ *
232
+ * 🔒 The emitted key order is still the one claim in the emitter no gate can see.
233
+ * spine-core reads back everything else rigc writes; it does not sort. What
234
+ * replaces an oracle is the quantifier: the order is not *a* comparator's answer,
235
+ * it is the answer they all give.
236
+ *
237
+ * ⭐ The two-sided result is on editor-written data. Sorting each of the 105
238
+ * collections above by `measuredOrder` reproduces **105 of 105** — including the
239
+ * three no codepoint sort can — and refuses **none** of them. The codepoint rule
240
+ * reproduced 102 and refused those same 3.
241
+ *
242
+ * The family is hand-rolled and locale-independent, which `A18` requires:
243
+ * `localeCompare` would make the emitted bytes a property of the machine.
244
+ */
245
+ function editorAnimationOrder<T>(animations: Record<string, T>): Record<string, T> {
246
+ const names = Object.keys(animations);
247
+ // ⭐ The sort is the check's own OUTPUT rather than a second reading of the same
248
+ // names: the refusal walks every pair and hands back the verdict it certified
249
+ // for each, so "the order rigc emits" and "the order rigc checked" cannot drift
250
+ // into two readings the way a shared comparator still can.
251
+ const verdicts = refuseNamesTheEditorCouldKeyDifferently(names);
252
+ // Nested rather than keyed on a joined string: any character this could join
253
+ // on is one an animation name is allowed to contain, and two pairs that
254
+ // collided would silently share one verdict.
255
+ names.sort((a, b) => verdicts.get(a)?.get(b) ?? 0);
256
+ const ordered: Record<string, T> = {};
257
+ for (const name of names) ordered[name] = animations[name];
258
+ return ordered;
259
+ }
260
+
261
+ /**
262
+ * The characters whose relative order every member of the measured family agrees
263
+ * on: the digits and, once case is folded, the lower-case ASCII letters.
264
+ * Everything else — `-`, `_`, a space, an accented letter — is worth something
265
+ * different to a collator that ignores punctuation than to one that does not, so
266
+ * a pair those decide is not settled.
267
+ */
268
+ const SETTLED_CHARS = /[0-9a-z]/;
269
+
270
+ /** Maximal runs of digits and of non-digits, which is what "natural" compares. */
271
+ const runsOf = (name: string): string[] => name.match(/\d+|\D+/g) ?? [];
272
+
273
+ const codepoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
274
+
275
+ /** What made a pair's order a matter of opinion, and what the author has to do. */
276
+ interface Ambiguity {
277
+ kind: 'case' | 'number' | 'separator';
278
+ because: string;
279
+ repair: string;
280
+ }
281
+
282
+ /**
283
+ * How the editor's comparator orders these two names — or, when one of its four
284
+ * unmeasured choices is what decides them, what that choice is and what the
285
+ * author has to do about it.
286
+ *
287
+ * ## It is a certificate, not a hazard list
288
+ *
289
+ * Issue #539 proposed checking for "two names differing only in case, or sharing
290
+ * a prefix followed by digit runs of unequal length". **That list misses the rig
291
+ * that produced the measurement**: `Turn` and `sweep` differ in much more than
292
+ * case and carry no digits, and they are the pair the editor reordered. A list of
293
+ * hazards is open-ended — one was missing the day it was written — so what is
294
+ * implemented is the other direction: a verdict is returned only where the
295
+ * position that decides the pair is one **every** comparator consistent with the
296
+ * measurement must read the same way, and everything else stops the build.
297
+ *
298
+ * ## The four choices this returns an `Ambiguity` for, and why each is free
299
+ *
300
+ * - **case** (`Turn` against `turn`) — the two fold together, so only a tie-break
301
+ * separates them and nobody has measured which way it breaks.
302
+ * - **number**, one number written twice (`turn01` against `turn1`) — the runs are
303
+ * numerically equal, so again only a tie-break separates them: shorter-first,
304
+ * longer-first and lexicographic are all real implementations.
305
+ * - **number**, a digit run against a word (`1turn` against `turn`) — comparators
306
+ * differ on whether a number sorts before a word.
307
+ * - **separator** — the deciding character is neither a letter nor a digit. This
308
+ * one covers two adversaries at once: a collator that treats `-` or a space as
309
+ * ignorable, and the plain fact that `_` sits *between* `Z` and `a`, so
310
+ * `x.toUpperCase()` and `x.toLowerCase()` order `wave_x` against `wavea`
311
+ * oppositely. Neither name needs a capital in it for that to bite.
312
+ *
313
+ * ⚠️ There used to be a fifth and a sixth, and both were artefacts of emitting
314
+ * codepoint rather than facts about the editor (issue #543). A pair folding the
315
+ * other way (`Turn` against `sweep`) and two digit runs of unequal width
316
+ * (`turn10` against `turn2`) are exactly the two pairs the editor **was**
317
+ * measured on, and every comparator in the family orders them the same way — so
318
+ * they are now emitted in that order rather than refused. `flare1` against
319
+ * `flare10`, the over-refusal #542 named and accepted, goes with them.
320
+ *
321
+ * ## What it was tested against
322
+ *
323
+ * 22 comparators — 12 hand-rolled naturals (fold up/down × three leading-zero
324
+ * tie-breaks × digits-before/after words), 2 plain case-insensitive ones, and 8
325
+ * `Intl.Collator`s (`numeric: true`, four sensitivities × `ignorePunctuation`) —
326
+ * over every pair of names up to 4 characters from `{a B 0 1 2 - _ space}`:
327
+ * **10,948,860 pairs**. Three figures come off that bank and each is load-bearing:
328
+ *
329
+ * - **0 escapes**, where an escape is a pair some comparator orders differently
330
+ * from the verdict returned here. Measured over the **20 natural** members —
331
+ * the 12 hand-rolled and the 8 collators, all of which read a digit run as a
332
+ * number. The 2 plain case-insensitive comparators do dissent (291,684 pairs),
333
+ * and they are the two #539's own measurement refutes: a comparator that reads
334
+ * `turn10` as text cannot return `turn2, turn10`.
335
+ * - **0 pairs move.** On every pair the codepoint rule accepted, this returns the
336
+ * codepoint order — which is why no emitted byte in the tree changes.
337
+ * - the refusal covers **9,140,115** pairs where the codepoint rule covered
338
+ * 10,218,136: 1,078,021 pairs are now built instead of renamed.
339
+ *
340
+ * ⭐ And the two-sided result is on real data: across the 105 editor-written
341
+ * collections the survey above defines, sorting by this reproduces all 105 and
342
+ * refuses none. No false positive, no false negative, against names the editor
343
+ * itself wrote.
344
+ */
345
+ function measuredOrder(a: string, b: string): number | Ambiguity {
346
+ const caseRepair = 'rename one of them so they differ by more than letter case';
347
+ const zerosRepair = 'write the number one way — rename so the digit run has a single spelling, with leading zeros or without';
348
+ const wordRepair = 'rename so a run of digits never has to be compared against a word';
349
+ const separatorRepair = 'rename so the first character that differs is a letter or a digit';
350
+ const la = a.toLowerCase();
351
+ const lb = b.toLowerCase();
352
+ if (la === lb) {
353
+ return {
354
+ kind: 'case',
355
+ because: `they are one name in two cases, and which of them the editor puts first is not measured`,
356
+ repair: caseRepair,
357
+ };
358
+ }
359
+ let i = 0;
360
+ while (i < la.length && i < lb.length && la[i] === lb[i]) i++;
361
+ if (i === la.length || i === lb.length) {
362
+ const longer = la.length > lb.length ? a : b;
363
+ const rest = (la.length > lb.length ? la : lb).slice(i);
364
+ if (!SETTLED_CHARS.test(rest)) {
365
+ return {
366
+ kind: 'separator',
367
+ because:
368
+ `"${longer}" is the other name followed by ${JSON.stringify(rest)}, which a comparator that ignores ` +
369
+ 'punctuation reads as the same name',
370
+ repair: separatorRepair,
371
+ };
372
+ }
373
+ } else if (!SETTLED_CHARS.test(la[i]) || !SETTLED_CHARS.test(lb[i])) {
374
+ const deciding = SETTLED_CHARS.test(la[i]) ? lb[i] : la[i];
375
+ return {
376
+ kind: 'separator',
377
+ because:
378
+ `the first character that differs is ${JSON.stringify(deciding)}, which is neither a letter nor a digit, ` +
379
+ 'and what that is worth is a property of the comparator',
380
+ repair: separatorRepair,
381
+ };
382
+ }
383
+ const ra = runsOf(la);
384
+ const rb = runsOf(lb);
385
+ for (let k = 0; k < Math.min(ra.length, rb.length); k++) {
386
+ const x = ra[k];
387
+ const y = rb[k];
388
+ if (x === y) continue;
389
+ const xIsDigits = /^\d/.test(x);
390
+ const yIsDigits = /^\d/.test(y);
391
+ if (xIsDigits && yIsDigits) {
392
+ const nx = BigInt(x);
393
+ const ny = BigInt(y);
394
+ if (nx === ny) {
395
+ return {
396
+ kind: 'number',
397
+ because: `"${x}" and "${y}" are the same number written two ways, so only a tie-break separates them`,
398
+ repair: zerosRepair,
399
+ };
400
+ }
401
+ return nx < ny ? -1 : 1;
402
+ }
403
+ if (xIsDigits !== yIsDigits) {
404
+ return {
405
+ kind: 'number',
406
+ because:
407
+ `one has the digits "${xIsDigits ? x : y}" where the other has "${xIsDigits ? y : x}", and comparators ` +
408
+ 'differ on whether a number sorts before a word',
409
+ repair: wordRepair,
410
+ };
411
+ }
412
+ return codepoint(x, y);
413
+ }
414
+ // One name's runs are a prefix of the other's — `wave` against `wave1`. Every
415
+ // comparator puts the shorter first; `la === lb` above already took the case
416
+ // where neither is longer.
417
+ return ra.length < rb.length ? -1 : 1;
418
+ }
419
+
420
+ /** How many pairs a refusal spells out before it starts counting them instead. */
421
+ const PAIRS_SPELLED_OUT = 8;
422
+
423
+ /**
424
+ * Refuse a set of animation names the editor could key in an order rigc did not
425
+ * emit, and hand back the verdict for every pair that survived — the check that
426
+ * lets `editorAnimationOrder` sort by the measured family without choosing a
427
+ * member of it.
428
+ *
429
+ * It is deliberately **not** conditional on anything: not on the rig declaring a
430
+ * slider, and not on the rig declaring the editor as a consumer. A slider is what
431
+ * makes the difference bite today and `invariants.editorRoundTrip` is how a rig
432
+ * says the editor is downstream, but the emitted order is a claim about the
433
+ * editor either way, and a check that only ran for some rigs would make the claim
434
+ * hold for some and not others with nothing in the file saying which — adding the
435
+ * slider, or the declaration, would then be the edit that refuses a rig that built
436
+ * yesterday. What issue #543 changed is the size of what is claimed, not who it is
437
+ * claimed for.
438
+ */
439
+ function refuseNamesTheEditorCouldKeyDifferently(names: readonly string[]): Map<string, Map<string, number>> {
440
+ const verdicts = new Map<string, Map<string, number>>();
441
+ const put = (x: string, y: string, v: number): void => {
442
+ const row = verdicts.get(x) ?? new Map<string, number>();
443
+ row.set(y, v);
444
+ verdicts.set(x, row);
445
+ };
446
+ const found: string[] = [];
447
+ for (let i = 0; i < names.length; i++) {
448
+ put(names[i], names[i], 0);
449
+ for (let j = i + 1; j < names.length; j++) {
450
+ const verdict = measuredOrder(names[i], names[j]);
451
+ if (typeof verdict === 'number') {
452
+ put(names[i], names[j], verdict);
453
+ put(names[j], names[i], -verdict);
454
+ } else {
455
+ found.push(`"${names[i]}" / "${names[j]}" (${verdict.kind}) — ${verdict.because}; ${verdict.repair}`);
456
+ }
457
+ }
458
+ }
459
+ if (!found.length) return verdicts;
460
+ const spelled = found.slice(0, PAIRS_SPELLED_OUT);
461
+ throw new CompileError(
462
+ `${found.length} pair(s) of animation names have no one order: rigc keys the emitted "animations" object in ` +
463
+ "the Spine editor's own comparator, which is natural and case-insensitive (#539) — but four of that " +
464
+ 'comparator\'s choices have never been measured (a pure case tie, one number written two ways, a run of ' +
465
+ 'digits against a word, and what a separator is worth), and each pair below is decided by one of them. A ' +
466
+ "slider's animation is an ORDINAL in the format's binary half, and an editor that keys these differently " +
467
+ 'repoints every slider whose animation moves index — silently, in a file that still parses (#535). ' +
468
+ `${spelled.join('. ')}` +
469
+ (found.length > spelled.length ? `. …and ${found.length - spelled.length} more pair(s)` : ''),
470
+ );
471
+ }
472
+
139
473
  // ---------------------------------------------------------------------------
140
474
  // number formatting — deterministic, and free of "-0"
141
475
  // ---------------------------------------------------------------------------
@@ -751,44 +1085,6 @@ function readAtlasIn(path: string): AtlasSource {
751
1085
  return { path, dir: dirname(path), parsed, byName };
752
1086
  }
753
1087
 
754
- /**
755
- * How far apart two names are, for the "did you mean" list on a missing region.
756
- *
757
- * Plain Levenshtein. An atlas has tens of regions and this runs once per
758
- * refusal, so the O(n*m) table is free and a cheaper heuristic (shared prefix,
759
- * substring) would miss the commonest real case — a transposition or one wrong
760
- * character in a hand-typed `image`.
761
- */
762
- function nameDistance(a: string, b: string): number {
763
- const rows = a.length + 1;
764
- const cols = b.length + 1;
765
- let previous = new Array<number>(cols);
766
- for (let j = 0; j < cols; j++) previous[j] = j;
767
- for (let i = 1; i < rows; i++) {
768
- const current = new Array<number>(cols);
769
- current[0] = i;
770
- for (let j = 1; j < cols; j++) {
771
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
772
- current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
773
- }
774
- previous = current;
775
- }
776
- return previous[cols - 1];
777
- }
778
-
779
- /** Up to five region names closest to the one that was not found. */
780
- function nearMisses(wanted: string, known: Iterable<string>): string[] {
781
- const scored: Array<{ name: string; d: number }> = [];
782
- for (const name of known) {
783
- const d = nameDistance(wanted.toLowerCase(), name.toLowerCase());
784
- // Half the name's length, floored at 2: "leg" must not suggest "arm", and a
785
- // long name may still be recognisable through several typos.
786
- if (d <= Math.max(2, Math.floor(wanted.length / 2))) scored.push({ name, d });
787
- }
788
- scored.sort((x, y) => x.d - y.d || (x.name < y.name ? -1 : 1));
789
- return scored.slice(0, 5).map((s) => s.name);
790
- }
791
-
792
1088
  /**
793
1089
  * One part resolved out of a region of a pre-packed atlas — CLI `--atlas-in`.
794
1090
  *
@@ -1810,7 +2106,13 @@ export function compile(opts: CompileOptions): CompileResult {
1810
2106
  // conditional spread rather than an assignment after the literal, so the key
1811
2107
  // lands in that position instead of at the end.
1812
2108
  ...(Object.keys(events).length ? { events } : {}),
1813
- animations,
2109
+ // Keyed in the editor's own order rather than the motion spec's, because a
2110
+ // slider's reference to an animation is an ordinal in the format and the
2111
+ // editor re-sorts this object — see `editorAnimationOrder`. The sort is
2112
+ // applied HERE and not to the loop above, so what the compiler reads, the
2113
+ // order it reports durations in, and which animation a CompileError names
2114
+ // first are all still the spec's own; only the emitted key order moves.
2115
+ animations: editorAnimationOrder(animations),
1814
2116
  };
1815
2117
  if (constraints.length) skeleton.constraints = constraints;
1816
2118
 
@@ -3421,7 +3723,12 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
3421
3723
  if (spec.type === 'ik') {
3422
3724
  out.bones = boneList();
3423
3725
  out.target = needBone(spec.target, 'target');
3424
- copy(['scaleY', 'mix', 'softness', 'bendPositive', 'compress', 'stretch', 'skin']);
3726
+ // `scaleY` is the `ScaleYMode` enum, not a number or a flag: the parser runs
3727
+ // it through `Utils.enumValue` (`:150`), which resolves an unknown name to
3728
+ // `undefined` and assigns it without a word. Out of `copy` for that reason —
3729
+ // see `RIG_SCALE_Y_MODES`.
3730
+ if (spec.scaleY !== undefined) out.scaleY = needEnum(spec.scaleY, 'scaleY', RIG_SCALE_Y_MODES);
3731
+ copy(['mix', 'softness', 'bendPositive', 'compress', 'stretch', 'skin']);
3425
3732
  return out;
3426
3733
  }
3427
3734
  if (spec.type === 'transform') {
@@ -3807,13 +4114,15 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
3807
4114
  }
3808
4115
  if (spec.type === 'physics') {
3809
4116
  out.bone = needBone(spec.bone, 'bone');
4117
+ // The same `ScaleYMode` enum an ik constraint carries, under the same key
4118
+ // and through the same silent `Utils.enumValue` (`:301`).
4119
+ if (spec.scaleY !== undefined) out.scaleY = needEnum(spec.scaleY, 'scaleY', RIG_SCALE_Y_MODES);
3810
4120
  copy([
3811
4121
  'x',
3812
4122
  'y',
3813
4123
  'rotate',
3814
4124
  'scaleX',
3815
4125
  'shearX',
3816
- 'scaleY',
3817
4126
  'limit',
3818
4127
  'fps',
3819
4128
  'inertia',
@@ -3916,6 +4225,7 @@ function buildRigInfo(
3916
4225
  meshKinds,
3917
4226
  meshSoftBones,
3918
4227
  deformMayFold,
4228
+ editorRoundTrip: rig.invariants?.editorRoundTrip === true,
3919
4229
  meshSlotBudget: rig.invariants?.meshSlots ?? null,
3920
4230
  meshTriangleBudget: rig.invariants?.meshTriangles ?? null,
3921
4231
  contactDepth,
package/src/keys.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The other half of "the compiler never invents a value that is not in the
3
+ * spec": **the compiler never discards a value that is in the spec.**
4
+ *
5
+ * `src/compile.ts` reads an input object by naming the fields it knows — a
6
+ * literal list walked by a `copy` helper, or a run of `if (spec.x !== undefined)`
7
+ * lines. Either spelling asks the same question, *what does the emitter want?*,
8
+ * and neither ever asks the opposite one, *what does this file actually say?* So
9
+ * a key outside the known set was never looked at, never mentioned and never
10
+ * emitted, and the build exited 0 with every assertion green (issue #545).
11
+ *
12
+ * That is the input-side twin of the silence the whole tool exists to remove.
13
+ * A missing number is already a `CompileError` naming the field; an extra one
14
+ * was nothing at all — and the extra one is the worse of the two, because an
15
+ * invented value at least appears in the output where somebody can see it.
16
+ *
17
+ * ## What this module is, and what it is not
18
+ *
19
+ * It is one refusal and the near-miss search that makes the refusal a repair. It
20
+ * is **not** a schema: the known-key sets live beside the shapes they describe
21
+ * (`RIG_KEYS` in [`rig.ts`](rig.ts), `MOTION_KEYS` in [`motion.ts`](motion.ts)),
22
+ * because a set of key names two files away from its interface is a set that
23
+ * drifts from it. `KEY01` in `selftest.ts` derives each set from the interface's
24
+ * own source text and compares, so the pair cannot drift in silence, and `KEY02`
25
+ * refuses a declared key that occurs nowhere else in the tree — which is the
26
+ * exact shape `scaleYMode` had.
27
+ *
28
+ * ## Why the refusal is at parse time rather than at emit time
29
+ *
30
+ * The alternative considered was to record the keys the emitter actually
31
+ * touches and subtract them afterwards — no table at all, and therefore no
32
+ * drift. It is rejected for two measured reasons:
33
+ *
34
+ * - **It answers a different question.** `buildRigMesh` returns at
35
+ * `if (att.generator)` before reading `width`, `hull` or `edges`, so a
36
+ * recorder would refuse `"width"` on a generated mesh and accept it on an
37
+ * authored one. That makes the accepted key set a property of the file's own
38
+ * values rather than of the format, and the format's key set is what an
39
+ * agent authoring against it has to be told.
40
+ * - **It cannot reach what the emitter never visits.** The emit loop walks the
41
+ * rig's slots and skips one with no attachments before it reads `setup` —
42
+ * the blind spot issue #293 was lost in for three weeks and `parseMotionSpec`
43
+ * was written to remove. A recorder rebuilds it.
44
+ */
45
+ import { CompileError } from './errors.ts';
46
+
47
+ /**
48
+ * Levenshtein distance. Only ever called on the losing side of a refusal, so the
49
+ * O(n*m) table is free and a cheaper heuristic (shared prefix, substring) would
50
+ * miss the commonest real case — a transposition or one wrong character in a
51
+ * hand-typed name.
52
+ */
53
+ export function nameDistance(a: string, b: string): number {
54
+ const rows = a.length + 1;
55
+ const cols = b.length + 1;
56
+ let previous = new Array<number>(cols);
57
+ for (let j = 0; j < cols; j++) previous[j] = j;
58
+ for (let i = 1; i < rows; i++) {
59
+ const current = new Array<number>(cols);
60
+ current[0] = i;
61
+ for (let j = 1; j < cols; j++) {
62
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
63
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
64
+ }
65
+ previous = current;
66
+ }
67
+ return previous[cols - 1];
68
+ }
69
+
70
+ /** Up to five known names closest to the one that was not found. */
71
+ export function nearMisses(wanted: string, known: Iterable<string>): string[] {
72
+ const scored: Array<{ name: string; d: number }> = [];
73
+ for (const name of known) {
74
+ const d = nameDistance(wanted.toLowerCase(), name.toLowerCase());
75
+ // Half the name's length, floored at 2: "leg" must not suggest "arm", and a
76
+ // long name may still be recognisable through several typos.
77
+ if (d <= Math.max(2, Math.floor(wanted.length / 2))) scored.push({ name, d });
78
+ }
79
+ scored.sort((x, y) => x.d - y.d || (x.name < y.name ? -1 : 1));
80
+ return scored.slice(0, 5).map((s) => s.name);
81
+ }
82
+
83
+ /**
84
+ * Refuse every key of `node` that `known` does not carry.
85
+ *
86
+ * ⭐ **Every** one of them, in one message, rather than the first. The four keys
87
+ * issue #545 planted into a single constraint were four separate mistakes an
88
+ * author had made in one place, and a refusal that names one of them buys three
89
+ * more round trips through a compile that is not cheap.
90
+ *
91
+ * ⭐ The near miss is what makes this a repair rather than a lecture. Both sides
92
+ * are lower-cased before the distance is taken, so `ROTATE` is distance 0 from
93
+ * `rotate` and comes back first — a real key in the wrong case is the commonest
94
+ * of these and the one an author is least likely to spot by re-reading.
95
+ *
96
+ * `known` may be empty (a shape with no fields at all); the message then says so
97
+ * rather than printing `known: ` with nothing after it.
98
+ */
99
+ export function refuseUnknownKeys(
100
+ node: Record<string, unknown>,
101
+ known: readonly string[],
102
+ where: string,
103
+ what: string,
104
+ ): void {
105
+ const strays = Object.keys(node).filter((key) => !known.includes(key));
106
+ if (strays.length === 0) return;
107
+ const named = strays.map((key) => {
108
+ const near = nearMisses(key, known);
109
+ return near.length === 0 ? `"${key}"` : `"${key}" (did you mean ${near.map((n) => `"${n}"`).join(', ')}?)`;
110
+ });
111
+ throw new CompileError(
112
+ `${where}: ${what} has ${strays.length === 1 ? 'a key' : `${strays.length} keys`} this compiler does not read: ` +
113
+ `${named.join(', ')}. Nothing reads such a key, so it would be dropped from the emitted skeleton in silence — ` +
114
+ 'fix the spelling or remove it. ' +
115
+ (known.length === 0 ? 'This shape has no fields at all.' : `Known here: ${[...known].sort().join(', ')}.`),
116
+ );
117
+ }