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/README.md +4 -4
- package/docs/AUTHORING.md +208 -2
- package/docs/FACE.md +77 -6
- package/package.json +1 -1
- package/src/compile.ts +351 -41
- package/src/keys.ts +117 -0
- package/src/motion.ts +143 -15
- package/src/rig.ts +445 -117
- package/src/types.ts +70 -2
- package/src/validate.ts +106 -1
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|