spine-rigc 0.28.0 → 0.30.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/cli.ts +10 -0
- package/docs/AUTHORING.md +290 -105
- package/docs/INGEST.md +15 -2
- package/docs/SPEC_COVERAGE.md +5 -4
- package/package.json +1 -1
- package/src/compile.ts +480 -308
- package/src/ingest.ts +248 -22
- package/src/png.ts +162 -7
- package/src/rig.ts +32 -21
- package/src/timelines.ts +101 -13
- package/src/types.ts +13 -7
- package/src/validate.ts +541 -28
- package/tools/editor_roundtrip.ts +1 -1
- package/tools/plate.ts +6 -0
package/src/compile.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
21
21
|
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
-
import { readPngInfo } from './png.ts';
|
|
22
|
+
import { readPngHeader, readPngInfo } from './png.ts';
|
|
23
23
|
import {
|
|
24
24
|
DEPTH_TONE_IDENTITY,
|
|
25
25
|
DepthError,
|
|
@@ -95,7 +95,14 @@ import {
|
|
|
95
95
|
type AtlasRegion,
|
|
96
96
|
type ParsedAtlas,
|
|
97
97
|
} from './atlas.ts';
|
|
98
|
-
import {
|
|
98
|
+
import {
|
|
99
|
+
KEY_TIME_EPSILON,
|
|
100
|
+
physicsKeyRefusal,
|
|
101
|
+
physicsRuleFor,
|
|
102
|
+
SLOT_COLOR_CHANNELS,
|
|
103
|
+
type PhysicsPoseRule,
|
|
104
|
+
type SlotColorChannel,
|
|
105
|
+
} from './timelines.ts';
|
|
99
106
|
import { evaluateDeformTransform } from './deformgen.ts';
|
|
100
107
|
import { evaluateTrackDerive, TRACK_DERIVE_PROJECTIONS, type TrackDeriveMember } from './trackgen.ts';
|
|
101
108
|
import {
|
|
@@ -151,22 +158,20 @@ const FRAME = 1 / 60;
|
|
|
151
158
|
|
|
152
159
|
/**
|
|
153
160
|
* The order the emitted `animations` object is keyed in: **the Spine editor's own
|
|
154
|
-
* comparator,
|
|
155
|
-
* (#
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* ⚠️ This emitted **codepoint** order until issue #543
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* what the rule guaranteed), which is why this change is invisible to every rig
|
|
169
|
-
* in the tree.
|
|
161
|
+
* comparator, read off five JSON round trips through a licensed 4.3.26 editor**
|
|
162
|
+
* (issue #728) — with a name set refused only where those round trips leave the
|
|
163
|
+
* pair's order open.
|
|
164
|
+
*
|
|
165
|
+
* ⚠️ This emitted **codepoint** order until issue #543 and a *family* of natural
|
|
166
|
+
* case-insensitive comparators until #728, and both refusals were the thing that
|
|
167
|
+
* made the emitted order agree with the editor: every pair the candidates could
|
|
168
|
+
* order differently was a `CompileError`. That is sound and it over-refuses by
|
|
169
|
+
* construction, because the quantifier stands in for a measurement — so a rig
|
|
170
|
+
* with a folder in a name, an accent, a capital or a space in it was refused
|
|
171
|
+
* rather than emitted in the order the editor returns. Measuring the comparator
|
|
172
|
+
* instead moves the refusal onto what is still open, and moves no byte: on every
|
|
173
|
+
* set the old rule accepted, the two orders are the same, which is why this is
|
|
174
|
+
* invisible to every rig in the tree.
|
|
170
175
|
*
|
|
171
176
|
* ## Why the emitter has an opinion about this at all
|
|
172
177
|
*
|
|
@@ -220,52 +225,91 @@ const FRAME = 1 / 60;
|
|
|
220
225
|
* The editor was then measured directly (issue #539). Two rigs, each varying one
|
|
221
226
|
* axis: `Turn, sweep, wave` came back `sweep, Turn, wave`, and
|
|
222
227
|
* `turn10, turn2, zoom` came back `turn2, turn10, zoom`. The intersection leaves
|
|
223
|
-
* one hypothesis — **natural order, case-insensitive
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
228
|
+
* one hypothesis — **natural order, case-insensitive** — and that is a *family*
|
|
229
|
+
* of comparators rather than one, which is why #543 refused every pair the family
|
|
230
|
+
* could disagree about rather than choosing a member of it.
|
|
231
|
+
*
|
|
232
|
+
* ⇒ 🔑 **Issue #728 replaced the quantifier with an oracle.** Five name sets went
|
|
233
|
+
* through the licensed 4.3.26 editor as JSON and came back as JSON, and they are
|
|
234
|
+
* in the tree: `fixtures/editor-order/probe{1..5}.{in,out}.json`. They are the
|
|
235
|
+
* only source of what follows, and nothing here is typed from memory —
|
|
236
|
+
* `editorNameOrder` reproduces every one of the ten orders they hold (five skin
|
|
237
|
+
* lists, five animation lists), which the selftest re-derives from the files.
|
|
238
|
+
*
|
|
239
|
+
* What the ten orders establish, each clause with the pair that shows it:
|
|
240
|
+
*
|
|
241
|
+
* - **Case is folded, and folded UP.** `_x` comes back after `z` (`_` is 0x5F —
|
|
242
|
+
* above `Z`, below `a`), and `ä, é, ß` in that order (0xC4 < 0xC9 < 0xDF). The
|
|
243
|
+
* fold is per character and only where the character has a single-character
|
|
244
|
+
* upper case: `ß` is returned at 0xDF, which is where it sorts unfolded, and
|
|
245
|
+
* **not** among the `S`s — so `toUpperCase()` on the whole string, which writes
|
|
246
|
+
* `SS`, is refuted.
|
|
247
|
+
* - **A run of digits compares as a number**, at any position and after any
|
|
248
|
+
* script: `x2 < x10`, `2 < 10`, `中2 < 中10`.
|
|
249
|
+
* - **Two runs that are the same number are deferred, not decided.** `a01` comes
|
|
250
|
+
* back before `a1b`, so the comparison walked past the equal runs; and `a1`
|
|
251
|
+
* before `a01`, so the deferred difference — leading zeros later — is what
|
|
252
|
+
* breaks the tie at the end.
|
|
253
|
+
* - **A space is skipped for the comparison** and the name with fewer of them
|
|
254
|
+
* wins the tie: `bc < b c = B C < b c`. The tie-break ORDER is measured too:
|
|
255
|
+
* `a 1` comes back before `a01`, which fewer-spaces-first would reverse.
|
|
256
|
+
* - **Everything else is code point order after folding**, and punctuation is not
|
|
257
|
+
* ignorable: `a-1, a.1, a1 … a1b, a_1` places three separate marks by their own
|
|
258
|
+
* code points, which no punctuation-ignoring collator produces.
|
|
259
|
+
* - **A remaining tie keeps FILE ORDER** (`turn, Turn, TURN`; `b c, B C`; `e, E`;
|
|
260
|
+
* `x2, X2`; `Mango, mango`). ⭐ So a pair the comparator cannot separate is
|
|
261
|
+
* *known* rather than ambiguous: the emitted order is the spec's own
|
|
262
|
+
* declaration order and the editor keeps it, which is why a case-only pair is
|
|
263
|
+
* no longer refused.
|
|
264
|
+
* - **Folders**: at the root, leaves then folders; inside a folder, sub-folders
|
|
265
|
+
* then leaves; siblings at every level by the rule above. `h` comes back before
|
|
266
|
+
* `f/sub1/…`, and `f/sub1/x` before `f/leafA`.
|
|
267
|
+
* - **`skins` and `animations` came back in the same order** — probes 4 and 5
|
|
268
|
+
* carried one name list as both collections, and both came back identically.
|
|
269
|
+
* One comparator, measured, which is what lets the two share one certificate.
|
|
239
270
|
*
|
|
240
271
|
* 🔒 The emitted key order is still the one claim in the emitter no gate can see.
|
|
241
272
|
* spine-core reads back everything else rigc writes; it does not sort. What
|
|
242
|
-
* replaces an oracle is
|
|
243
|
-
* it is the answer they all give.
|
|
273
|
+
* replaces an oracle here is a stored one: the editor's own outputs, in the tree.
|
|
244
274
|
*
|
|
245
275
|
* ⭐ The two-sided result is on editor-written data. Sorting each of the 105
|
|
246
|
-
* collections above by `
|
|
247
|
-
* three no codepoint sort can — and refuses **none** of them.
|
|
248
|
-
* reproduced 102 and refused those same 3.
|
|
276
|
+
* collections above by `editorNameOrder` reproduces **105 of 105** — including
|
|
277
|
+
* the three no codepoint sort can — and refuses **none** of them.
|
|
249
278
|
*
|
|
250
|
-
* The
|
|
279
|
+
* The comparator is hand-rolled and locale-independent, which `A18` requires:
|
|
251
280
|
* `localeCompare` would make the emitted bytes a property of the machine.
|
|
252
281
|
*/
|
|
253
282
|
function editorAnimationOrder<T>(animations: Record<string, T>): Record<string, T> {
|
|
254
|
-
const names = Object.keys(animations);
|
|
255
|
-
// ⭐ The sort is the check's own OUTPUT rather than a second reading of the same
|
|
256
|
-
// names: the refusal walks every pair and hands back the verdict it certified
|
|
257
|
-
// for each, so "the order rigc emits" and "the order rigc checked" cannot drift
|
|
258
|
-
// into two readings the way a shared comparator still can.
|
|
259
|
-
const verdicts = refuseNamesTheEditorCouldKeyDifferently(names, ANIMATION_ORDER);
|
|
260
|
-
// Nested rather than keyed on a joined string: any character this could join
|
|
261
|
-
// on is one an animation name is allowed to contain, and two pairs that
|
|
262
|
-
// collided would silently share one verdict.
|
|
263
|
-
names.sort((a, b) => verdicts.get(a)?.get(b) ?? 0);
|
|
264
283
|
const ordered: Record<string, T> = {};
|
|
265
|
-
for (const name of
|
|
284
|
+
for (const name of editorNamesInOrder(Object.keys(animations), 'animations')) ordered[name] = animations[name];
|
|
266
285
|
return ordered;
|
|
267
286
|
}
|
|
268
287
|
|
|
288
|
+
/**
|
|
289
|
+
* The order the editor writes a list of skin or animation names in, refusing by
|
|
290
|
+
* name every pair the five round trips leave open.
|
|
291
|
+
*
|
|
292
|
+
* ⭐ The sort is the refusal's own OUTPUT rather than a second reading of the
|
|
293
|
+
* same names: the walk visits every pair and hands back the verdict it certified
|
|
294
|
+
* for each, so "the order rigc emits" and "the order rigc checked" cannot drift
|
|
295
|
+
* into two readings the way a shared comparator still can. The verdict map is
|
|
296
|
+
* nested rather than keyed on a joined string — any character it could join on is
|
|
297
|
+
* one a name is allowed to contain, and two pairs that collided would silently
|
|
298
|
+
* share one verdict.
|
|
299
|
+
*
|
|
300
|
+
* 🔒 Exported because it is what the stored probes are re-derived against. Both
|
|
301
|
+
* emitters are this function with a collection's own sentence attached, so a
|
|
302
|
+
* control that sorts a probe's input list with it is measuring the emit rather
|
|
303
|
+
* than a second implementation of it.
|
|
304
|
+
*/
|
|
305
|
+
export function editorNamesInOrder(names: readonly string[], collection: 'animations' | 'skins'): string[] {
|
|
306
|
+
const verdicts = refuseNamesTheEditorCouldKeyDifferently(
|
|
307
|
+
names,
|
|
308
|
+
collection === 'skins' ? SKIN_ORDER : ANIMATION_ORDER,
|
|
309
|
+
);
|
|
310
|
+
return [...names].sort((a, b) => verdicts.get(a)?.get(b) ?? 0);
|
|
311
|
+
}
|
|
312
|
+
|
|
269
313
|
/** The skin the editor keeps at index 0 whatever its name sorts as. */
|
|
270
314
|
const DEFAULT_SKIN = 'default';
|
|
271
315
|
|
|
@@ -292,252 +336,237 @@ const DEFAULT_SKIN = 'default';
|
|
|
292
336
|
* ## What is measured, and it is two separate facts
|
|
293
337
|
*
|
|
294
338
|
* - **`default` is pinned, not sorted.** `alpha` sorts before `default` under
|
|
295
|
-
* every candidate comparator and came back *after* it.
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* it
|
|
308
|
-
* the same way is exactly the shape of the inference that put `skins` on the
|
|
309
|
-
* safe side of the list in the first place. `measuredSkinOrder` therefore
|
|
310
|
-
* certifies a pair only where the natural case-insensitive family **and plain
|
|
311
|
-
* codepoint** agree, which is the pre-#543 rule — correct here for the reason
|
|
312
|
-
* it was too strong there: for animations, codepoint had been *refuted*; for
|
|
313
|
-
* skins, nothing has refuted anything.
|
|
339
|
+
* every candidate comparator and came back *after* it (#541). The stored
|
|
340
|
+
* probes say it again on names nothing else could explain: probe 1 declares the
|
|
341
|
+
* skins `2` and `10` and probe 3 declares `A`, each of which folds to a
|
|
342
|
+
* character below `D`, and all three came back **after** `default`. `default`
|
|
343
|
+
* is a fixed name in the format — the runtime's own `defaultSkin` is index 0 —
|
|
344
|
+
* so a skin renamed away from it is an ordinary name and sorts as one.
|
|
345
|
+
* - **The rest are ordered by the same comparator `animations` uses.** ⚠️ This
|
|
346
|
+
* was the open question until #728, and the answer is measured rather than
|
|
347
|
+
* inferred: probes 4 and 5 carried one name list as both collections and both
|
|
348
|
+
* came back in one order. `measuredSkinOrder`, which certified a skin pair only
|
|
349
|
+
* where the natural family and plain codepoint agreed — because nothing had
|
|
350
|
+
* ruled codepoint out for skins — is retired by that, together with the two
|
|
351
|
+
* refusals it alone raised.
|
|
314
352
|
*
|
|
315
353
|
* ⇒ Every skin name in this tree is lower-case ASCII without digits, so no
|
|
316
|
-
* emitted byte moves and no rig is refused. What moves is the claim
|
|
354
|
+
* emitted byte moves and no rig is refused. What moves is the claim, and what
|
|
355
|
+
* stops being refused is a rig with capitals, folders or digits in its skins.
|
|
317
356
|
*/
|
|
318
357
|
function editorSkinOrder<T extends { name: string }>(skins: readonly T[]): T[] {
|
|
319
358
|
const pinned = skins.filter((skin) => skin.name === DEFAULT_SKIN);
|
|
320
359
|
const rest = skins.filter((skin) => skin.name !== DEFAULT_SKIN);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
);
|
|
325
|
-
rest.sort((a, b) =>
|
|
326
|
-
return [...pinned, ...rest];
|
|
360
|
+
// Ranked rather than looked up by name: two skins answering to one name are a
|
|
361
|
+
// defect another check names, and a lookup would silently drop one of them.
|
|
362
|
+
// Equal ranks leave the pair where the spec put it, which is what a tie means.
|
|
363
|
+
const rank = new Map(editorNamesInOrder(rest.map((skin) => skin.name), 'skins').map((name, at) => [name, at]));
|
|
364
|
+
return [...pinned, ...[...rest].sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))];
|
|
327
365
|
}
|
|
328
366
|
|
|
329
|
-
/**
|
|
330
|
-
|
|
331
|
-
* on: the digits and, once case is folded, the lower-case ASCII letters.
|
|
332
|
-
* Everything else — `-`, `_`, a space, an accented letter — is worth something
|
|
333
|
-
* different to a collator that ignores punctuation than to one that does not, so
|
|
334
|
-
* a pair those decide is not settled.
|
|
335
|
-
*/
|
|
336
|
-
const SETTLED_CHARS = /[0-9a-z]/;
|
|
367
|
+
/** What separates a folder from what it holds, in a skin or an animation name. */
|
|
368
|
+
const NAME_FOLDER = '/';
|
|
337
369
|
|
|
338
|
-
/**
|
|
339
|
-
const
|
|
370
|
+
/** An ASCII digit — the characters a run of which the editor reads as a number. */
|
|
371
|
+
const DIGIT = /[0-9]/;
|
|
340
372
|
|
|
341
|
-
|
|
373
|
+
/** The one whitespace character the round trips measured the editor skipping. */
|
|
374
|
+
const MEASURED_SPACE = / /;
|
|
375
|
+
|
|
376
|
+
/** The reading the round trips leave standing beside it: skip every whitespace. */
|
|
377
|
+
const WIDER_SPACE = /\s/;
|
|
342
378
|
|
|
343
379
|
/** What made a pair's order a matter of opinion, and what the author has to do. */
|
|
344
380
|
interface Ambiguity {
|
|
345
|
-
kind: '
|
|
381
|
+
kind: 'number' | 'separator' | 'folder';
|
|
346
382
|
because: string;
|
|
347
383
|
repair: string;
|
|
348
384
|
}
|
|
349
385
|
|
|
350
386
|
/**
|
|
351
|
-
*
|
|
352
|
-
* unmeasured choices is what decides them, what that choice is and what the
|
|
353
|
-
* author has to do about it.
|
|
387
|
+
* One name folded the way the round trips returned it, as code points.
|
|
354
388
|
*
|
|
355
|
-
*
|
|
389
|
+
* ⚠️ Per character and UP, and only where the character has a single-character
|
|
390
|
+
* upper case. `'ß'.toUpperCase()` is `'SS'`, and the editor returned `ß` at its
|
|
391
|
+
* own code point rather than among the `S`s — so whole-string `toUpperCase` is
|
|
392
|
+
* refuted and this leaves such a character alone.
|
|
393
|
+
*/
|
|
394
|
+
function foldUp(name: string): string[] {
|
|
395
|
+
const out: string[] = [];
|
|
396
|
+
for (const char of name) {
|
|
397
|
+
const up = char.toUpperCase();
|
|
398
|
+
out.push([...up].length === 1 ? up : char);
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** What one reading of the comparison skipped, and what it had left to compare. */
|
|
404
|
+
interface ReadName {
|
|
405
|
+
chars: string[];
|
|
406
|
+
skipped: number;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function readName(name: string, skip: RegExp): ReadName {
|
|
410
|
+
const chars: string[] = [];
|
|
411
|
+
let skipped = 0;
|
|
412
|
+
for (const char of foldUp(name)) {
|
|
413
|
+
if (skip.test(char)) skipped++;
|
|
414
|
+
else chars.push(char);
|
|
415
|
+
}
|
|
416
|
+
return { chars, skipped };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The editor's order for two names that hold no folder separator, under ONE
|
|
421
|
+
* reading of which characters the comparison skips.
|
|
422
|
+
*
|
|
423
|
+
* `'deferred'` is the one thing this cannot answer: two names whose only
|
|
424
|
+
* difference is several digit runs that are each the same number written two
|
|
425
|
+
* ways, pointing opposite ways. The round trips measured the deferral (`a01`
|
|
426
|
+
* before `a1b`) and the direction (`a1` before `a01`) over ONE such run, and a
|
|
427
|
+
* comparator that keeps the first deferred difference and one that keeps the
|
|
428
|
+
* last are both consistent with that.
|
|
429
|
+
*/
|
|
430
|
+
function compareLeaf(a: string, b: string, skip: RegExp): number | 'deferred' {
|
|
431
|
+
const ra = readName(a, skip);
|
|
432
|
+
const rb = readName(b, skip);
|
|
433
|
+
const deferred: number[] = [];
|
|
434
|
+
let i = 0;
|
|
435
|
+
let j = 0;
|
|
436
|
+
while (i < ra.chars.length && j < rb.chars.length) {
|
|
437
|
+
const x = ra.chars[i];
|
|
438
|
+
const y = rb.chars[j];
|
|
439
|
+
if (DIGIT.test(x) && DIGIT.test(y)) {
|
|
440
|
+
let ei = i;
|
|
441
|
+
while (ei < ra.chars.length && DIGIT.test(ra.chars[ei])) ei++;
|
|
442
|
+
let ej = j;
|
|
443
|
+
while (ej < rb.chars.length && DIGIT.test(rb.chars[ej])) ej++;
|
|
444
|
+
const runA = ra.chars.slice(i, ei).join('');
|
|
445
|
+
const runB = rb.chars.slice(j, ej).join('');
|
|
446
|
+
const na = BigInt(runA);
|
|
447
|
+
const nb = BigInt(runB);
|
|
448
|
+
if (na !== nb) return na < nb ? -1 : 1;
|
|
449
|
+
// One number written two ways. The editor walked PAST it — `a01` came back
|
|
450
|
+
// before `a1b`, which deciding here would reverse — and settled it at the
|
|
451
|
+
// end, leading zeros later.
|
|
452
|
+
if (runA.length !== runB.length) deferred.push(runA.length < runB.length ? -1 : 1);
|
|
453
|
+
i = ei;
|
|
454
|
+
j = ej;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (x !== y) return (x.codePointAt(0) ?? 0) < (y.codePointAt(0) ?? 0) ? -1 : 1;
|
|
458
|
+
i++;
|
|
459
|
+
j++;
|
|
460
|
+
}
|
|
461
|
+
if (i < ra.chars.length) return 1;
|
|
462
|
+
if (j < rb.chars.length) return -1;
|
|
463
|
+
// The two tie-breaks, in the order the round trips put them: `a 1` came back
|
|
464
|
+
// before `a01`, which the skipped-character count alone would reverse.
|
|
465
|
+
if (deferred.length > 0) {
|
|
466
|
+
if (deferred.some((one) => one !== deferred[0])) return 'deferred';
|
|
467
|
+
return deferred[0];
|
|
468
|
+
}
|
|
469
|
+
if (ra.skipped !== rb.skipped) return ra.skipped < rb.skipped ? -1 : 1;
|
|
470
|
+
return 0;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* How the editor orders two names that sit in one folder — or what is still open
|
|
475
|
+
* about the pair, and what the author has to do about it.
|
|
356
476
|
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* hazards is open-ended — one was missing the day it was written — so what is
|
|
362
|
-
* implemented is the other direction: a verdict is returned only where the
|
|
363
|
-
* position that decides the pair is one **every** comparator consistent with the
|
|
364
|
-
* measurement must read the same way, and everything else stops the build.
|
|
365
|
-
*
|
|
366
|
-
* ## The four choices this returns an `Ambiguity` for, and why each is free
|
|
367
|
-
*
|
|
368
|
-
* - **case** (`Turn` against `turn`) — the two fold together, so only a tie-break
|
|
369
|
-
* separates them and nobody has measured which way it breaks.
|
|
370
|
-
* - **number**, one number written twice (`turn01` against `turn1`) — the runs are
|
|
371
|
-
* numerically equal, so again only a tie-break separates them: shorter-first,
|
|
372
|
-
* longer-first and lexicographic are all real implementations.
|
|
373
|
-
* - **number**, a digit run against a word (`1turn` against `turn`) — comparators
|
|
374
|
-
* differ on whether a number sorts before a word.
|
|
375
|
-
* - **separator** — the deciding character is neither a letter nor a digit. This
|
|
376
|
-
* one covers two adversaries at once: a collator that treats `-` or a space as
|
|
377
|
-
* ignorable, and the plain fact that `_` sits *between* `Z` and `a`, so
|
|
378
|
-
* `x.toUpperCase()` and `x.toLowerCase()` order `wave_x` against `wavea`
|
|
379
|
-
* oppositely. Neither name needs a capital in it for that to bite.
|
|
380
|
-
*
|
|
381
|
-
* ⚠️ There used to be a fifth and a sixth, and both were artefacts of emitting
|
|
382
|
-
* codepoint rather than facts about the editor (issue #543). A pair folding the
|
|
383
|
-
* other way (`Turn` against `sweep`) and two digit runs of unequal width
|
|
384
|
-
* (`turn10` against `turn2`) are exactly the two pairs the editor **was**
|
|
385
|
-
* measured on, and every comparator in the family orders them the same way — so
|
|
386
|
-
* they are now emitted in that order rather than refused. `flare1` against
|
|
387
|
-
* `flare10`, the over-refusal #542 named and accepted, goes with them.
|
|
388
|
-
*
|
|
389
|
-
* ## What it was tested against
|
|
390
|
-
*
|
|
391
|
-
* 22 comparators — 12 hand-rolled naturals (fold up/down × three leading-zero
|
|
392
|
-
* tie-breaks × digits-before/after words), 2 plain case-insensitive ones, and 8
|
|
393
|
-
* `Intl.Collator`s (`numeric: true`, four sensitivities × `ignorePunctuation`) —
|
|
394
|
-
* over every pair of names up to 4 characters from `{a B 0 1 2 - _ space}`:
|
|
395
|
-
* **10,948,860 pairs**. Three figures come off that bank and each is load-bearing:
|
|
396
|
-
*
|
|
397
|
-
* - **0 escapes**, where an escape is a pair some comparator orders differently
|
|
398
|
-
* from the verdict returned here. Measured over the **20 natural** members —
|
|
399
|
-
* the 12 hand-rolled and the 8 collators, all of which read a digit run as a
|
|
400
|
-
* number. The 2 plain case-insensitive comparators do dissent (291,684 pairs),
|
|
401
|
-
* and they are the two #539's own measurement refutes: a comparator that reads
|
|
402
|
-
* `turn10` as text cannot return `turn2, turn10`.
|
|
403
|
-
* - **0 pairs move.** On every pair the codepoint rule accepted, this returns the
|
|
404
|
-
* codepoint order — which is why no emitted byte in the tree changes.
|
|
405
|
-
* - the refusal covers **9,140,115** pairs where the codepoint rule covered
|
|
406
|
-
* 10,218,136: 1,078,021 pairs are now built instead of renamed.
|
|
407
|
-
*
|
|
408
|
-
* ⭐ And the two-sided result is on real data: across the 105 editor-written
|
|
409
|
-
* collections the survey above defines, sorting by this reproduces all 105 and
|
|
410
|
-
* refuses none. No false positive, no false negative, against names the editor
|
|
411
|
-
* itself wrote.
|
|
477
|
+
* Two readings are compared rather than one chosen, which is the one thing #543's
|
|
478
|
+
* shape got right and is kept: the probes measured the space and nothing else
|
|
479
|
+
* about whitespace, so a pair a tab or a no-break space decides is a pair two
|
|
480
|
+
* readings of the same measurement order differently.
|
|
412
481
|
*/
|
|
413
|
-
function
|
|
414
|
-
const
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
const separatorRepair = 'rename so the first character that differs is a letter or a digit';
|
|
418
|
-
const la = a.toLowerCase();
|
|
419
|
-
const lb = b.toLowerCase();
|
|
420
|
-
if (la === lb) {
|
|
482
|
+
function leafOrder(a: string, b: string): number | Ambiguity {
|
|
483
|
+
const measured = compareLeaf(a, b, MEASURED_SPACE);
|
|
484
|
+
const wider = compareLeaf(a, b, WIDER_SPACE);
|
|
485
|
+
if (measured === 'deferred' || wider === 'deferred') {
|
|
421
486
|
return {
|
|
422
|
-
kind: '
|
|
423
|
-
because:
|
|
424
|
-
|
|
487
|
+
kind: 'number',
|
|
488
|
+
because:
|
|
489
|
+
`"${a}" and "${b}" hold more than one digit run that is the same number written two ways, and those runs ` +
|
|
490
|
+
'point opposite ways — the round trips measured the editor deferring one such run and never two',
|
|
491
|
+
repair: 'write each number one way — rename so no digit run in either name has a second spelling',
|
|
425
492
|
};
|
|
426
493
|
}
|
|
427
|
-
|
|
428
|
-
while (i < la.length && i < lb.length && la[i] === lb[i]) i++;
|
|
429
|
-
if (i === la.length || i === lb.length) {
|
|
430
|
-
const longer = la.length > lb.length ? a : b;
|
|
431
|
-
const rest = (la.length > lb.length ? la : lb).slice(i);
|
|
432
|
-
if (!SETTLED_CHARS.test(rest)) {
|
|
433
|
-
return {
|
|
434
|
-
kind: 'separator',
|
|
435
|
-
because:
|
|
436
|
-
`"${longer}" is the other name followed by ${JSON.stringify(rest)}, which a comparator that ignores ` +
|
|
437
|
-
'punctuation reads as the same name',
|
|
438
|
-
repair: separatorRepair,
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
} else if (!SETTLED_CHARS.test(la[i]) || !SETTLED_CHARS.test(lb[i])) {
|
|
442
|
-
const deciding = SETTLED_CHARS.test(la[i]) ? lb[i] : la[i];
|
|
494
|
+
if (measured !== wider) {
|
|
443
495
|
return {
|
|
444
496
|
kind: 'separator',
|
|
445
497
|
because:
|
|
446
|
-
`the
|
|
447
|
-
'
|
|
448
|
-
repair:
|
|
498
|
+
`the order of "${a}" and "${b}" turns on a whitespace character that is not a space, and the round trips ` +
|
|
499
|
+
'measured the editor skipping the space alone',
|
|
500
|
+
repair: 'rename so the only whitespace in either name is a space',
|
|
449
501
|
};
|
|
450
502
|
}
|
|
451
|
-
|
|
452
|
-
const rb = runsOf(lb);
|
|
453
|
-
for (let k = 0; k < Math.min(ra.length, rb.length); k++) {
|
|
454
|
-
const x = ra[k];
|
|
455
|
-
const y = rb[k];
|
|
456
|
-
if (x === y) continue;
|
|
457
|
-
const xIsDigits = /^\d/.test(x);
|
|
458
|
-
const yIsDigits = /^\d/.test(y);
|
|
459
|
-
if (xIsDigits && yIsDigits) {
|
|
460
|
-
const nx = BigInt(x);
|
|
461
|
-
const ny = BigInt(y);
|
|
462
|
-
if (nx === ny) {
|
|
463
|
-
return {
|
|
464
|
-
kind: 'number',
|
|
465
|
-
because: `"${x}" and "${y}" are the same number written two ways, so only a tie-break separates them`,
|
|
466
|
-
repair: zerosRepair,
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
return nx < ny ? -1 : 1;
|
|
470
|
-
}
|
|
471
|
-
if (xIsDigits !== yIsDigits) {
|
|
472
|
-
return {
|
|
473
|
-
kind: 'number',
|
|
474
|
-
because:
|
|
475
|
-
`one has the digits "${xIsDigits ? x : y}" where the other has "${xIsDigits ? y : x}", and comparators ` +
|
|
476
|
-
'differ on whether a number sorts before a word',
|
|
477
|
-
repair: wordRepair,
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
return codepoint(x, y);
|
|
481
|
-
}
|
|
482
|
-
// One name's runs are a prefix of the other's — `wave` against `wave1`. Every
|
|
483
|
-
// comparator puts the shorter first; `la === lb` above already took the case
|
|
484
|
-
// where neither is longer.
|
|
485
|
-
return ra.length < rb.length ? -1 : 1;
|
|
503
|
+
return measured;
|
|
486
504
|
}
|
|
487
505
|
|
|
488
506
|
/**
|
|
489
|
-
* How the editor orders two
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
* ##
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
507
|
+
* How the editor orders two skin or animation names, folders and all — or what
|
|
508
|
+
* the five round trips leave open about the pair.
|
|
509
|
+
*
|
|
510
|
+
* ## It is a certificate, not a hazard list
|
|
511
|
+
*
|
|
512
|
+
* Issue #539 proposed checking for "two names differing only in case, or sharing
|
|
513
|
+
* a prefix followed by digit runs of unequal length". **That list misses the rig
|
|
514
|
+
* that produced the measurement**: `Turn` and `sweep` differ in much more than
|
|
515
|
+
* case and carry no digits, and they are the pair the editor reordered. A list of
|
|
516
|
+
* hazards is open-ended — one was missing the day it was written — so what is
|
|
517
|
+
* implemented is the other direction: an order is returned where the round trips
|
|
518
|
+
* settle the pair, and everything else stops the build.
|
|
519
|
+
*
|
|
520
|
+
* ## The two things they do not settle, and why each is genuinely open
|
|
521
|
+
*
|
|
522
|
+
* - **number** — several digit runs that are each one number written two ways,
|
|
523
|
+
* disagreeing. `compareLeaf` says why.
|
|
524
|
+
* - **separator** — a whitespace character that is not a space. `leafOrder` says
|
|
525
|
+
* why.
|
|
526
|
+
* - **folder** — two sibling FOLDERS whose names the comparator cannot separate.
|
|
527
|
+
* A tie between two leaves is settled (file order), but a tie between two
|
|
528
|
+
* folders is not the same fact: the editor holds folders as objects and keeps
|
|
529
|
+
* each one's entries together, and a pairwise verdict of "file order" cannot
|
|
530
|
+
* express that. No round trip carried two folders one fold apart.
|
|
531
|
+
*
|
|
532
|
+
* ⚠️ What is deliberately NOT refused is a character no probe carried. The
|
|
533
|
+
* alternative would be an allowlist of characters kept by hand — the shape this
|
|
534
|
+
* file already rejects — and the competing reading it would be defending against,
|
|
535
|
+
* a collator that treats punctuation as ignorable, is refuted three times over by
|
|
536
|
+
* probe 4: `a-1`, `a.1` and `a_1` came back placed by `-`, `.` and `_` at their
|
|
537
|
+
* own code points, on either side of the digits.
|
|
517
538
|
*/
|
|
518
|
-
function
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
539
|
+
function editorNameOrder(a: string, b: string): number | Ambiguity {
|
|
540
|
+
const pa = a.split(NAME_FOLDER);
|
|
541
|
+
const pb = b.split(NAME_FOLDER);
|
|
542
|
+
const depth = Math.min(pa.length, pb.length);
|
|
543
|
+
let level = 0;
|
|
544
|
+
while (level < depth && pa[level] === pb[level]) level++;
|
|
545
|
+
if (level === depth) {
|
|
546
|
+
// One name's whole path is the start of the other's: at that level one is a
|
|
547
|
+
// LEAF and the other is the FOLDER of the same name. `x` against `x/y`.
|
|
548
|
+
if (pa.length === pb.length) return 0;
|
|
549
|
+
const shorterLeads = depth === 1;
|
|
550
|
+
return (pa.length < pb.length) === shorterLeads ? -1 : 1;
|
|
551
|
+
}
|
|
552
|
+
// Both sit in the same parent. Which side of the parent they sit on comes
|
|
553
|
+
// first, and only then their names: at the root leaves lead, inside a folder
|
|
554
|
+
// sub-folders do.
|
|
555
|
+
const aIsLeaf = level === pa.length - 1;
|
|
556
|
+
const bIsLeaf = level === pb.length - 1;
|
|
557
|
+
const leavesLead = level === 0;
|
|
558
|
+
if (aIsLeaf !== bIsLeaf) return aIsLeaf === leavesLead ? -1 : 1;
|
|
559
|
+
const verdict = leafOrder(pa[level], pb[level]);
|
|
560
|
+
if (typeof verdict !== 'number' || verdict !== 0) return verdict;
|
|
561
|
+
if (aIsLeaf) return 0;
|
|
562
|
+
return {
|
|
563
|
+
kind: 'folder',
|
|
564
|
+
because:
|
|
565
|
+
`"${a}" and "${b}" sit in the sibling folders "${pa[level]}" and "${pb[level]}", which the comparator ` +
|
|
566
|
+
'leaves in one place — and a tie is kept in file order, which orders two entries but does not say the ' +
|
|
567
|
+
"editor keeps either folder's entries together",
|
|
568
|
+
repair: 'rename one of the two folders so they differ by more than letter case, spacing or a leading zero',
|
|
569
|
+
};
|
|
541
570
|
}
|
|
542
571
|
|
|
543
572
|
/** How many pairs a refusal spells out before it starts counting them instead. */
|
|
@@ -545,43 +574,45 @@ const PAIRS_SPELLED_OUT = 8;
|
|
|
545
574
|
|
|
546
575
|
/**
|
|
547
576
|
* What one collection's order refusal has to say that the others' do not: what
|
|
548
|
-
* it is counting pairs of,
|
|
549
|
-
* and what an order rigc got wrong would cost.
|
|
577
|
+
* it is counting pairs of, and what an order rigc got wrong would cost.
|
|
550
578
|
*
|
|
551
579
|
* Two collections share the walk below because they share the defect — a
|
|
552
580
|
* name-keyed collection whose ORDINAL is a reference in the format's binary half
|
|
553
|
-
* — and they must not share the sentence, because the
|
|
554
|
-
*
|
|
581
|
+
* — and they must not share the sentence, because the stakes are different and a
|
|
582
|
+
* reader acts on both.
|
|
583
|
+
*
|
|
584
|
+
* ⚠️ They DO share the certificate, and that is a measurement rather than a
|
|
585
|
+
* convenience: `order` was a field here until issue #728, because `skins` and
|
|
586
|
+
* `animations` had been measured to different depths. Probes 4 and 5 carried one
|
|
587
|
+
* name list as both collections and both came back in one order, so a second
|
|
588
|
+
* comparator would now be a second reading of one measured fact.
|
|
555
589
|
*/
|
|
556
590
|
interface OrderedCollection {
|
|
557
591
|
/** Plural, for "N pair(s) of …": `animation names`, `skin names`. */
|
|
558
592
|
noun: string;
|
|
559
|
-
/**
|
|
560
|
-
order: (a: string, b: string) => number | Ambiguity;
|
|
561
|
-
/** Why rigc has an opinion, and what the family leaves open. Ends on a space. */
|
|
593
|
+
/** Why rigc has an opinion, and what the probes leave open. Ends on a space. */
|
|
562
594
|
why: string;
|
|
563
595
|
}
|
|
564
596
|
|
|
565
597
|
const ANIMATION_ORDER: OrderedCollection = {
|
|
566
598
|
noun: 'animation names',
|
|
567
|
-
order: measuredOrder,
|
|
568
599
|
why:
|
|
569
600
|
'rigc keys the emitted "animations" object in ' +
|
|
570
|
-
"the Spine editor's own comparator,
|
|
571
|
-
|
|
572
|
-
'
|
|
601
|
+
"the Spine editor's own comparator, read off five round trips through a licensed 4.3.26 editor (#728) — " +
|
|
602
|
+
'case folded upward, digit runs read as numbers, spaces skipped, leaves before folders at the root and ' +
|
|
603
|
+
'folders before leaves inside one, and a remaining tie left in the order this spec declares. Each pair ' +
|
|
604
|
+
'below is one those round trips leave open. A ' +
|
|
573
605
|
"slider's animation is an ORDINAL in the format's binary half, and an editor that keys these differently " +
|
|
574
606
|
'repoints every slider whose animation moves index — silently, in a file that still parses (#535). ',
|
|
575
607
|
};
|
|
576
608
|
|
|
577
609
|
const SKIN_ORDER: OrderedCollection = {
|
|
578
610
|
noun: 'skin names',
|
|
579
|
-
order: measuredSkinOrder,
|
|
580
611
|
why:
|
|
581
|
-
'rigc writes the emitted "skins" array with "default" first and the rest in the
|
|
582
|
-
'
|
|
583
|
-
|
|
584
|
-
|
|
612
|
+
'rigc writes the emitted "skins" array with "default" first and the rest in the same comparator the ' +
|
|
613
|
+
'"animations" object is keyed in — one comparator, measured on round trips that carried one name list as ' +
|
|
614
|
+
'both collections (#728) — and each pair below is one those round trips leave open. A skin is an ORDINAL ' +
|
|
615
|
+
"in the format's binary half — " +
|
|
585
616
|
'`skins[readInt()]` for an attachment timeline, `skins[skinIndex]` for a linked mesh — so an editor that ' +
|
|
586
617
|
'writes them in another order repoints every such reference, silently, in a file that still parses. ',
|
|
587
618
|
};
|
|
@@ -604,9 +635,10 @@ const SKIN_ORDER: OrderedCollection = {
|
|
|
604
635
|
*
|
|
605
636
|
* ⚠️ `what` is a parameter and not a second copy of this walk because issue #541
|
|
606
637
|
* found the same defect in a second collection, and a copied loop is how the two
|
|
607
|
-
* come to check different things.
|
|
608
|
-
*
|
|
609
|
-
*
|
|
638
|
+
* come to check different things. It carries the SENTENCE and no longer the
|
|
639
|
+
* comparator: #728 measured that one comparator orders both collections, so the
|
|
640
|
+
* certificate below is one function and there is nowhere left for two readings of
|
|
641
|
+
* it to diverge.
|
|
610
642
|
*/
|
|
611
643
|
function refuseNamesTheEditorCouldKeyDifferently(
|
|
612
644
|
names: readonly string[],
|
|
@@ -622,7 +654,7 @@ function refuseNamesTheEditorCouldKeyDifferently(
|
|
|
622
654
|
for (let i = 0; i < names.length; i++) {
|
|
623
655
|
put(names[i], names[i], 0);
|
|
624
656
|
for (let j = i + 1; j < names.length; j++) {
|
|
625
|
-
const verdict =
|
|
657
|
+
const verdict = editorNameOrder(names[i], names[j]);
|
|
626
658
|
if (typeof verdict === 'number') {
|
|
627
659
|
put(names[i], names[j], verdict);
|
|
628
660
|
put(names[j], names[i], -verdict);
|
|
@@ -758,6 +790,60 @@ function rgba2Hex(v: number[]): { light: string; dark: string } {
|
|
|
758
790
|
return { light: v.slice(0, 4).map(channelHex).join(''), dark: v.slice(4, 7).map(channelHex).join('') };
|
|
759
791
|
}
|
|
760
792
|
|
|
793
|
+
/** The five slot colour timelines, which are every `SLOT_TRACKS` shape but `attachment`. */
|
|
794
|
+
type SlotColourShape = 'rgba' | 'rgb' | 'alpha' | 'rgba2' | 'rgb2';
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* How one colour key is written, per timeline: how many channels its `v`
|
|
798
|
+
* carries, how the refusal spells that `v`, and the JSON fields the emitted key
|
|
799
|
+
* states them as.
|
|
800
|
+
*
|
|
801
|
+
* ⭐ **Every row is the parser's own reading, and the `v` is always the
|
|
802
|
+
* channels in the order `readCurve` indexes a curve array by** — which is what
|
|
803
|
+
* keeps a key and its raw curve parallel on every shape:
|
|
804
|
+
*
|
|
805
|
+
* - `rgba` → `color: "rrggbbaa"`, four channels (`RGBATimeline`, `frames << 2`).
|
|
806
|
+
* - `rgb` → `color: "rrggbb"`, three. `SkeletonJson`'s `rgb` branch reads the
|
|
807
|
+
* same `Color.fromString(keyMap.color)` and hands `setFrame` r, g and b alone,
|
|
808
|
+
* so an alpha pair written there would be read into a colour and dropped one
|
|
809
|
+
* line later — six digits is emitting what is read.
|
|
810
|
+
* - `alpha` → `value: a`, one channel, read by `readTimeline1` with a per-key
|
|
811
|
+
* default of **0**. It is a number rather than hex because the format stores
|
|
812
|
+
* it as one, so it is not quantised onto a byte the way the four hex shapes
|
|
813
|
+
* are.
|
|
814
|
+
* - `rgba2` → `light: "rrggbbaa"`, `dark: "rrggbb"`, seven (see `rgba2Hex`).
|
|
815
|
+
* - `rgb2` → `light: "rrggbb"`, `dark: "rrggbb"`, six: `RGB2Timeline.setFrame`
|
|
816
|
+
* takes r g b and r2 g2 b2, and neither colour's alpha.
|
|
817
|
+
*
|
|
818
|
+
* The count is checked before anything is written, with one sentence for all
|
|
819
|
+
* five (`rgba value needs 4 channels, got 3`), because `rgbaHex` and `rgba2Hex`
|
|
820
|
+
* already said exactly that for their two and a family whose members refuse in
|
|
821
|
+
* different words is a family an author has to learn twice.
|
|
822
|
+
*/
|
|
823
|
+
const COLOUR_KEYS: Record<SlotColourShape, { channels: number; spelling: string; write: (v: number[]) => Record<string, string | number> }> = {
|
|
824
|
+
rgba: { channels: 4, spelling: '[r,g,b,a]', write: (v) => ({ color: rgbaHex(v) }) },
|
|
825
|
+
rgb: { channels: 3, spelling: '[r,g,b]', write: (v) => ({ color: v.map(channelHex).join('') }) },
|
|
826
|
+
alpha: { channels: 1, spelling: '[a]', write: (v) => ({ value: r6(v[0]) }) },
|
|
827
|
+
rgba2: { channels: 7, spelling: '[lr,lg,lb,la,dr,dg,db]', write: (v) => rgba2Hex(v) },
|
|
828
|
+
rgb2: {
|
|
829
|
+
channels: 6,
|
|
830
|
+
spelling: '[lr,lg,lb,dr,dg,db]',
|
|
831
|
+
write: (v) => ({ light: v.slice(0, 3).map(channelHex).join(''), dark: v.slice(3, 6).map(channelHex).join('') }),
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
/** The colour of one key, as its timeline writes it — after the channel count the whole family refuses in one sentence. */
|
|
836
|
+
function colourKey(shape: SlotColourShape, v: number[]): Record<string, string | number> {
|
|
837
|
+
const { channels, write } = COLOUR_KEYS[shape];
|
|
838
|
+
if (v.length !== channels) {
|
|
839
|
+
throw new CompileError(`${shape} value needs ${channels} channel${channels === 1 ? '' : 's'}, got ${v.length}`);
|
|
840
|
+
}
|
|
841
|
+
return write(v);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** A slot colour channel as a sentence names it. */
|
|
845
|
+
const CHANNEL_WORDS: Record<SlotColorChannel, string> = { rgb: 'light rgb', alpha: 'alpha', dark: 'dark colour' };
|
|
846
|
+
|
|
761
847
|
/**
|
|
762
848
|
* One timeline a `MotionValueTrack` can name: the JSON fields a key carries, the
|
|
763
849
|
* per-key default the parser uses for each, and — where the runtime has one — the
|
|
@@ -915,8 +1001,18 @@ const SLIDER_TRACKS: Record<string, ValueTrackShape> = {
|
|
|
915
1001
|
* ⚠️ The KEY is the timeline name as the file carries it, and the VALUE names
|
|
916
1002
|
* the branch below that writes its keys — so an entry added here without a
|
|
917
1003
|
* branch to write it is an entry emitted in some other timeline's shape, which
|
|
918
|
-
* is the defect this table closed rather than a new affordance.
|
|
919
|
-
*
|
|
1004
|
+
* is the defect this table closed rather than a new affordance.
|
|
1005
|
+
*
|
|
1006
|
+
* 🎨 `rgb`, `alpha` and `rgb2` joined in issue #730, which makes this the
|
|
1007
|
+
* format's whole slot switch (`SkeletonJson.readAnimation`'s six cases, in its
|
|
1008
|
+
* order). They are the SEPARABLE colour timelines — each poses a subset of the
|
|
1009
|
+
* slot's channels and leaves the rest where they were (`SLOT_COLOR_CHANNELS`)
|
|
1010
|
+
* — and they are not spellings of `rgba`. Folding an `rgb` and an `alpha` with
|
|
1011
|
+
* their own key times into one `rgba` would have to state each channel at the
|
|
1012
|
+
* other's key times, which is a value nobody keyed; and a lone `alpha` has no
|
|
1013
|
+
* `rgba` spelling at all, because an `rgba` key poses the light rgb too. So
|
|
1014
|
+
* each is emitted as itself, and two tracks of one slot that pose a shared
|
|
1015
|
+
* channel are refused rather than layered (see the claim check in `compile`).
|
|
920
1016
|
*
|
|
921
1017
|
* 🎨 `rgba2` joined in issue #690, and what it adds is the half of the two-colour
|
|
922
1018
|
* tint that moves: a slot's `dark` has been an emitted setup field all along
|
|
@@ -927,10 +1023,13 @@ const SLIDER_TRACKS: Record<string, ValueTrackShape> = {
|
|
|
927
1023
|
* never applies it; `--profile spine-html` is where it fires. Emitting a
|
|
928
1024
|
* construct one consumer drops is exactly what a profile is for.
|
|
929
1025
|
*/
|
|
930
|
-
export const SLOT_TRACKS: Record<string, 'attachment' |
|
|
1026
|
+
export const SLOT_TRACKS: Record<string, 'attachment' | SlotColourShape> = {
|
|
931
1027
|
attachment: 'attachment',
|
|
932
1028
|
rgba: 'rgba',
|
|
1029
|
+
rgb: 'rgb',
|
|
1030
|
+
alpha: 'alpha',
|
|
933
1031
|
rgba2: 'rgba2',
|
|
1032
|
+
rgb2: 'rgb2',
|
|
934
1033
|
};
|
|
935
1034
|
|
|
936
1035
|
/**
|
|
@@ -1043,8 +1142,14 @@ const CONSTRAINT_TIMELINES: Record<'ik' | 'transform', ConstraintTimelineShape>
|
|
|
1043
1142
|
},
|
|
1044
1143
|
};
|
|
1045
1144
|
|
|
1046
|
-
/**
|
|
1047
|
-
|
|
1145
|
+
/**
|
|
1146
|
+
* Physics constraint fields and their parser defaults (SkeletonJson.js:295-319).
|
|
1147
|
+
*
|
|
1148
|
+
* `PHYSICS_COMPONENTS` is exported for `ingest.ts`, which omits a constraint that
|
|
1149
|
+
* drives none of them (issue #731) and must read "drives" off the same five
|
|
1150
|
+
* names this module refuses an empty constraint with, not off a copy.
|
|
1151
|
+
*/
|
|
1152
|
+
export const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
|
|
1048
1153
|
const PHYSICS_PARAMS: Array<[string, number]> = [
|
|
1049
1154
|
['inertia', 0.5],
|
|
1050
1155
|
['strength', 100],
|
|
@@ -1379,7 +1484,12 @@ function fromLoosePng(
|
|
|
1379
1484
|
// the rig. The validator's messages are the UI, and so are these.
|
|
1380
1485
|
throw new CompileError(`image "${relPath}" is not on disk at ${absPath}`);
|
|
1381
1486
|
}
|
|
1382
|
-
|
|
1487
|
+
// A loose part IS a page of the atlas this route emits, so it is refused the
|
|
1488
|
+
// way a pack's page is (issue #732): by the one reader's sentence, as a
|
|
1489
|
+
// CompileError, rather than as that reader's throw with a stack under it.
|
|
1490
|
+
const header = readPngHeader(absPath);
|
|
1491
|
+
if (header.problem !== null) throw new CompileError(`image "${relPath}": ${header.problem}`);
|
|
1492
|
+
const info = header.info;
|
|
1383
1493
|
// Page name is the PNG path *relative to the atlas file*, so the viewer
|
|
1384
1494
|
// resolves it the way every Spine consumer does: against the atlas URL.
|
|
1385
1495
|
// The PNGs are not copied: they pass through untouched, and the atlas points
|
|
@@ -1411,6 +1521,25 @@ function readAtlasIn(path: string): AtlasSource {
|
|
|
1411
1521
|
}
|
|
1412
1522
|
}
|
|
1413
1523
|
if (byName.size === 0) throw new CompileError(`--atlas-in ${path} declares no regions`);
|
|
1524
|
+
// 🔒 **What each page IS, where the pack is opened** (issue #732). Every page
|
|
1525
|
+
// the pack names reaches the gate — the atlas this build emits is the pack's
|
|
1526
|
+
// own text — so a page no reader can open is refused here, all of them in
|
|
1527
|
+
// one sentence, rather than one region at a time or at `A06` after the whole
|
|
1528
|
+
// compile. A page that is not on disk is left to the region that sits on it,
|
|
1529
|
+
// which already names it; saying so twice would be two refusals of one fact.
|
|
1530
|
+
const unread: string[] = [];
|
|
1531
|
+
for (const page of parsed.pages) {
|
|
1532
|
+
const abs = resolve(dirname(path), page.name);
|
|
1533
|
+
if (!existsSync(abs)) continue;
|
|
1534
|
+
const { problem } = readPngHeader(abs);
|
|
1535
|
+
if (problem !== null) unread.push(`page "${page.name}": ${problem}`);
|
|
1536
|
+
}
|
|
1537
|
+
if (unread.length > 0) {
|
|
1538
|
+
throw new CompileError(
|
|
1539
|
+
`--atlas-in ${path}: ${unread.length} of its ${parsed.pages.length} page(s) cannot be read as PNG, and ` +
|
|
1540
|
+
`nothing was compiled against the pack — ${unread.join('; ')}`,
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1414
1543
|
return { path, dir: dirname(path), parsed, byName };
|
|
1415
1544
|
}
|
|
1416
1545
|
|
|
@@ -2479,6 +2608,30 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
2479
2608
|
);
|
|
2480
2609
|
}
|
|
2481
2610
|
claimed.add(claim);
|
|
2611
|
+
// Two colour timelines of one slot that pose a shared channel are not
|
|
2612
|
+
// two layers of it: each poses its channels at every time, the setup
|
|
2613
|
+
// value before its first key included, so the one the file states
|
|
2614
|
+
// later overwrites the other everywhere (`SLOT_COLOR_CHANNELS`). The
|
|
2615
|
+
// claim above cannot see it — `rgba` and `alpha` are two properties —
|
|
2616
|
+
// and before issue #730 nothing could reach it, because `rgba` and
|
|
2617
|
+
// `rgba2` were the only two and a slot keying both was already a
|
|
2618
|
+
// slot keying its light colour twice. Raised before any key is shaped:
|
|
2619
|
+
// the fault is the pair, not a key.
|
|
2620
|
+
if (family === null && !isBoneTrack) {
|
|
2621
|
+
const mine = SLOT_COLOR_CHANNELS[track.property] ?? [];
|
|
2622
|
+
for (const other of Object.keys(slotTimelines[target] ?? {})) {
|
|
2623
|
+
const shared = (SLOT_COLOR_CHANNELS[other] ?? []).filter((channel) => mine.includes(channel));
|
|
2624
|
+
if (shared.length === 0) continue;
|
|
2625
|
+
throw new CompileError(
|
|
2626
|
+
`animation "${animName}" slot "${target}": tracks "${other}" and "${track.property}" both key the slot's ` +
|
|
2627
|
+
`${shared.map((channel) => CHANNEL_WORDS[channel]).join(' and ')} — a colour timeline poses its ` +
|
|
2628
|
+
'channels at every time, its setup value before its first key included, so the one the file states ' +
|
|
2629
|
+
'later overwrites the other everywhere and the first one\'s keys there are read by nothing. Key each ' +
|
|
2630
|
+
'channel once: "rgb" and "alpha" move the light colour\'s two halves on their own key times, "rgba" ' +
|
|
2631
|
+
'moves them together, and "rgb2" / "rgba2" add the dark colour',
|
|
2632
|
+
);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2482
2635
|
|
|
2483
2636
|
const shift = (track.lag ?? 0) + (track.stagger ?? 0) * index;
|
|
2484
2637
|
const keys =
|
|
@@ -7227,12 +7380,18 @@ function compileTrack(
|
|
|
7227
7380
|
// …')` inside `RGBA2Timeline.apply1`. That is a crash in the consumer's
|
|
7228
7381
|
// process, from a file every parser accepts — the exact silence this compiler
|
|
7229
7382
|
// exists to convert into a name (issue #690).
|
|
7230
|
-
|
|
7383
|
+
//
|
|
7384
|
+
// `rgb2` is the same pairing and was measured the same way before it joined
|
|
7385
|
+
// this refusal (issue #730): the forged file loads, `setupPose.darkColor` is
|
|
7386
|
+
// `null`, and `state.apply` throws `TypeError: null is not an object
|
|
7387
|
+
// (evaluating 'dark.r = …')` inside `RGB2Timeline.apply1`. The way out it
|
|
7388
|
+
// names is its own light-only sibling, which for `rgb2` is `rgb`.
|
|
7389
|
+
if ((shape === 'rgba2' || shape === 'rgb2') && !darkSlots.has(target)) {
|
|
7231
7390
|
throw new CompileError(
|
|
7232
|
-
`${where}: slot "${target}" declares no setup "dark", and an "
|
|
7391
|
+
`${where}: slot "${target}" declares no setup "dark", and an "${shape}" timeline poses a slot's dark colour — ` +
|
|
7233
7392
|
'the runtime allocates one only for a slot whose setup pose has it, so applying this animation throws ' +
|
|
7234
7393
|
`instead of tinting. Give slot "${target}" a \`dark\` in the rig spec (the colour it holds at rest), or ` +
|
|
7235
|
-
|
|
7394
|
+
`key "${shape === 'rgba2' ? 'rgba' : 'rgb'}" if only the light colour moves`,
|
|
7236
7395
|
);
|
|
7237
7396
|
}
|
|
7238
7397
|
if (!track.keys.length) throw new CompileError(`${where}: no keys`);
|
|
@@ -7273,21 +7432,33 @@ function compileTrack(
|
|
|
7273
7432
|
continue;
|
|
7274
7433
|
}
|
|
7275
7434
|
|
|
7276
|
-
//
|
|
7277
|
-
//
|
|
7278
|
-
//
|
|
7435
|
+
// The five colour shapes `SLOT_TRACKS` names, and between them the only ways
|
|
7436
|
+
// left to reach here: `attachment` returned above and a property the table
|
|
7437
|
+
// does not carry was refused before any key was shaped.
|
|
7279
7438
|
//
|
|
7280
|
-
// ⚠️ The
|
|
7281
|
-
// `
|
|
7282
|
-
// which FIELDS the emitted key spells them as (`color`,
|
|
7283
|
-
// and therefore how long a raw curve array
|
|
7284
|
-
// ease/curve exclusivity, the stepped case, the
|
|
7285
|
-
// refusals — is one rule, and a second copy of it is
|
|
7286
|
-
// in step.
|
|
7287
|
-
const spelling
|
|
7288
|
-
const
|
|
7289
|
-
const colourOf = (v: number[]): Record<string, string> => (shape === 'rgba2' ? rgba2Hex(v) : { color: rgbaHex(v) });
|
|
7439
|
+
// ⚠️ The five differ in three places and nowhere else, so they are read out
|
|
7440
|
+
// of `COLOUR_KEYS` rather than forked into five loops: how many channels a
|
|
7441
|
+
// `v` carries, which FIELDS the emitted key spells them as (`color`,
|
|
7442
|
+
// `value`, or `light` + `dark`), and therefore how long a raw curve array
|
|
7443
|
+
// is. Everything else — the ease/curve exclusivity, the stepped case, the
|
|
7444
|
+
// hold test, the last-key refusals — is one rule, and a second copy of it is
|
|
7445
|
+
// a second thing to keep in step.
|
|
7446
|
+
const { spelling, channels } = COLOUR_KEYS[shape];
|
|
7447
|
+
const colourOf = (v: number[]): Record<string, string | number> => colourKey(shape, v);
|
|
7290
7448
|
if (!Array.isArray(key.v)) throw new CompileError(`${where}: ${shape} key value must be ${spelling}`);
|
|
7449
|
+
if (shape === 'alpha' && key.v.length === 1 && !(Number.isFinite(key.v[0]) && key.v[0] >= 0 && key.v[0] <= 1)) {
|
|
7450
|
+
// The one colour shape written as a number rather than a byte, so the one
|
|
7451
|
+
// that is not clamped onto 0..1 by `channelHex` on the way out. The
|
|
7452
|
+
// runtime clamps the POSED alpha (`AlphaTimeline.apply`: `color.a = a < 0
|
|
7453
|
+
// ? 0 : a > 1 ? 1 : a`) but interpolates the stored value first, so a key
|
|
7454
|
+
// of 1.5 reaches 1 at a different time from a key of 1 — a curve nobody
|
|
7455
|
+
// keyed, from a value no pose ever holds. Refused rather than clamped:
|
|
7456
|
+
// clamping is choosing the value.
|
|
7457
|
+
throw new CompileError(
|
|
7458
|
+
`${where}: key at t=${key.t} is ${String(key.v[0])}; an alpha is a number from 0 to 1 — the runtime clamps ` +
|
|
7459
|
+
'the posed alpha to that range after interpolating, so a value outside it is one no pose ever holds',
|
|
7460
|
+
);
|
|
7461
|
+
}
|
|
7291
7462
|
const entry: SpineTimelineKey = { time, ...colourOf(key.v) };
|
|
7292
7463
|
if (key.ease !== undefined && key.curve !== undefined) {
|
|
7293
7464
|
throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
|
|
@@ -7309,8 +7480,9 @@ function compileTrack(
|
|
|
7309
7480
|
}
|
|
7310
7481
|
const t2 = keyTime(next.t + shift);
|
|
7311
7482
|
// 4 numbers per channel, in the format's own channel order — 16 for
|
|
7312
|
-
// `rgba` (r g b a),
|
|
7313
|
-
// is the order `readCurve` indexes them in)
|
|
7483
|
+
// `rgba` (r g b a), 12 for `rgb`, 4 for `alpha`, 28 for `rgba2` (light r
|
|
7484
|
+
// g b a, then dark r g b, which is the order `readCurve` indexes them in)
|
|
7485
|
+
// and 24 for `rgb2`. Short arrays become NaN
|
|
7314
7486
|
// curves with no error. The hold test reads the emitted hex rather than
|
|
7315
7487
|
// the authored floats: two colours that quantise to one byte are one
|
|
7316
7488
|
// colour in the file, and for `rgba2` that has to be true of BOTH — a key
|