spine-rigc 0.20.3 → 0.22.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 +7 -1
- package/docs/AUTHORING.md +275 -43
- package/docs/FACE.md +53 -7
- package/docs/SPEC_COVERAGE.md +9 -0
- package/package.json +2 -1
- package/src/compile.ts +618 -171
- package/src/keys.ts +117 -0
- package/src/motion.ts +143 -15
- package/src/rig.ts +445 -117
- package/src/types.ts +97 -8
- package/src/validate.ts +106 -1
- package/tools/editor_roundtrip.ts +99 -12
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,
|
|
@@ -53,6 +58,7 @@ import {
|
|
|
53
58
|
type RigMeshBinding,
|
|
54
59
|
type RigPathAttachment,
|
|
55
60
|
type RigRegionAttachment,
|
|
61
|
+
type RigSkinParts,
|
|
56
62
|
type RigSpec,
|
|
57
63
|
type RigVertexGeometry,
|
|
58
64
|
type RigDepthMap,
|
|
@@ -137,8 +143,23 @@ export const SPINE_VERSION = '4.3.13';
|
|
|
137
143
|
const FRAME = 1 / 60;
|
|
138
144
|
|
|
139
145
|
/**
|
|
140
|
-
* The order the emitted `animations` object is keyed in: **
|
|
141
|
-
*
|
|
146
|
+
* The order the emitted `animations` object is keyed in: **the Spine editor's own
|
|
147
|
+
* comparator, as far as anybody has measured it** — natural and case-insensitive
|
|
148
|
+
* (#539) — with every name set refused on which one of that comparator's four
|
|
149
|
+
* UNMEASURED choices could decide a pair.
|
|
150
|
+
*
|
|
151
|
+
* ⚠️ This emitted **codepoint** order until issue #543, and the refusal was the
|
|
152
|
+
* thing that made codepoint agree with the editor: every pair the two could order
|
|
153
|
+
* differently was a `CompileError`. That is sound and it over-refuses by
|
|
154
|
+
* construction, because codepoint is not a member of the family it is being
|
|
155
|
+
* defended against — it is neither natural nor case-insensitive — so `Turn`
|
|
156
|
+
* against `sweep` and `turn10` against `turn2`, **the two pairs the editor was
|
|
157
|
+
* directly measured on**, were refused rather than emitted in the order the
|
|
158
|
+
* editor was measured returning. Sorting by a member of the measured family
|
|
159
|
+
* instead moves the refusal onto what is actually unmeasured, and moves no byte:
|
|
160
|
+
* on every set the codepoint rule accepted, the two orders are the same (that is
|
|
161
|
+
* what the rule guaranteed), which is why this change is invisible to every rig
|
|
162
|
+
* in the tree.
|
|
142
163
|
*
|
|
143
164
|
* ## Why the emitter has an opinion about this at all
|
|
144
165
|
*
|
|
@@ -162,8 +183,7 @@ const FRAME = 1 / 60;
|
|
|
162
183
|
* 10.4655 / 8.4961 / 8.7140 to 0.3035 / 0.0769 / 0.0588, and `yaw -> "turn"`
|
|
163
184
|
* came back intact.
|
|
164
185
|
*
|
|
165
|
-
* ##
|
|
166
|
-
* is emitted
|
|
186
|
+
* ## What the editor's comparator is, and which parts of it are known
|
|
167
187
|
*
|
|
168
188
|
* ⚠️ This comment used to say that codepoint order "is what every editor-authored
|
|
169
189
|
* file on hand is in", and that sentence was false when it was written. The
|
|
@@ -195,47 +215,122 @@ const FRAME = 1 / 60;
|
|
|
195
215
|
* `turn10, turn2, zoom` came back `turn2, turn10, zoom`. The intersection leaves
|
|
196
216
|
* one hypothesis — **natural order, case-insensitive**.
|
|
197
217
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
218
|
+
* ⚠️ "Natural, case-insensitive" is a **family** of comparators rather than one.
|
|
219
|
+
* Leading zeros (`turn01` against `turn1`), a pure case tie (`Turn` against
|
|
220
|
+
* `turn`), whether a digit run sorts before a word, and what a separator is worth
|
|
221
|
+
* are each a free choice, and **the editor's answers to them are not measured**.
|
|
222
|
+
* Writing a comparator that sorts every name set means choosing all four, and a
|
|
203
223
|
* chosen-but-unmeasured comparator is exactly how #537 landed.
|
|
204
224
|
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
225
|
+
* ⇒ 🔑 **So the family is never asked to sort a pair one of those four decides.**
|
|
226
|
+
* `measuredOrder` returns a verdict only where every member of the family must
|
|
227
|
+
* agree, and `refuseNamesTheEditorCouldKeyDifferently` stops the build on any pair
|
|
228
|
+
* where it cannot — which means the four choices below are **unobservable in the
|
|
229
|
+
* output**, and the claim the emit makes is the one #542 established, widened:
|
|
230
|
+
* *on this name set, every comparator consistent with what has been measured
|
|
231
|
+
* produces this order.* Checkable inside rigc, with no editor and no oracle.
|
|
232
|
+
*
|
|
233
|
+
* 🔒 The emitted key order is still the one claim in the emitter no gate can see.
|
|
234
|
+
* spine-core reads back everything else rigc writes; it does not sort. What
|
|
235
|
+
* replaces an oracle is the quantifier: the order is not *a* comparator's answer,
|
|
236
|
+
* it is the answer they all give.
|
|
237
|
+
*
|
|
238
|
+
* ⭐ The two-sided result is on editor-written data. Sorting each of the 105
|
|
239
|
+
* collections above by `measuredOrder` reproduces **105 of 105** — including the
|
|
240
|
+
* three no codepoint sort can — and refuses **none** of them. The codepoint rule
|
|
241
|
+
* reproduced 102 and refused those same 3.
|
|
242
|
+
*
|
|
243
|
+
* The family is hand-rolled and locale-independent, which `A18` requires:
|
|
244
|
+
* `localeCompare` would make the emitted bytes a property of the machine.
|
|
216
245
|
*/
|
|
217
246
|
function editorAnimationOrder<T>(animations: Record<string, T>): Record<string, T> {
|
|
218
|
-
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
247
|
+
const names = Object.keys(animations);
|
|
248
|
+
// ⭐ The sort is the check's own OUTPUT rather than a second reading of the same
|
|
249
|
+
// names: the refusal walks every pair and hands back the verdict it certified
|
|
250
|
+
// for each, so "the order rigc emits" and "the order rigc checked" cannot drift
|
|
251
|
+
// into two readings the way a shared comparator still can.
|
|
252
|
+
const verdicts = refuseNamesTheEditorCouldKeyDifferently(names, ANIMATION_ORDER);
|
|
253
|
+
// Nested rather than keyed on a joined string: any character this could join
|
|
254
|
+
// on is one an animation name is allowed to contain, and two pairs that
|
|
255
|
+
// collided would silently share one verdict.
|
|
256
|
+
names.sort((a, b) => verdicts.get(a)?.get(b) ?? 0);
|
|
222
257
|
const ordered: Record<string, T> = {};
|
|
223
258
|
for (const name of names) ordered[name] = animations[name];
|
|
224
259
|
return ordered;
|
|
225
260
|
}
|
|
226
261
|
|
|
262
|
+
/** The skin the editor keeps at index 0 whatever its name sorts as. */
|
|
263
|
+
const DEFAULT_SKIN = 'default';
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The order the emitted `skins` array is written in: **`default` first, then the
|
|
267
|
+
* rest in the order the editor was measured returning them** — with every pair
|
|
268
|
+
* refused on which a comparator consistent with that measurement could disagree.
|
|
269
|
+
*
|
|
270
|
+
* ## Why the emitter has an opinion about this at all
|
|
271
|
+
*
|
|
272
|
+
* The same reason it has one about `animations` (#535), in the collection nobody
|
|
273
|
+
* had checked. `SkeletonBinary` addresses skins by ORDINAL — `skins[readInt()]`
|
|
274
|
+
* for an attachment timeline, `skins[linkedMesh.skinIndex]` for a linked mesh —
|
|
275
|
+
* so an editor that writes this array in another order repoints every such
|
|
276
|
+
* reference, silently, in a file that still parses.
|
|
277
|
+
*
|
|
278
|
+
* ⚠️ `src/types.ts` said for two releases that `skins` was one of the arrays an
|
|
279
|
+
* editor leaves alone, and #544 softened that to *unmeasured* rather than
|
|
280
|
+
* retracting it. Issue #541 measured it: a rig built `default, zulu, mike,
|
|
281
|
+
* alpha` exported `default, alpha, mike, zulu`. Three arrays — `bones` (30),
|
|
282
|
+
* `slots` (24), `constraints` (3) — had come back element for element, and the
|
|
283
|
+
* belief was a generalisation off those three.
|
|
284
|
+
*
|
|
285
|
+
* ## What is measured, and it is two separate facts
|
|
286
|
+
*
|
|
287
|
+
* - **`default` is pinned, not sorted.** `alpha` sorts before `default` under
|
|
288
|
+
* every candidate comparator and came back *after* it. That is the whole
|
|
289
|
+
* evidence, and it is decisive: the runtime's own `defaultSkin` is index 0.
|
|
290
|
+
* - **The rest came back `alpha, mike, zulu`.** ⚠️ Which separates nothing.
|
|
291
|
+
* Those three names are lower-case ASCII with no digits and no separators, so
|
|
292
|
+
* codepoint, case-folded, natural, and every collator agree on them. The
|
|
293
|
+
* editor's skin comparator is **not established**, and a rig with `Zulu`,
|
|
294
|
+
* `mike10`, `mike2` in it is what would establish it.
|
|
295
|
+
*
|
|
296
|
+
* 🔑 So this deliberately does NOT reuse `measuredOrder` alone. #539 measured
|
|
297
|
+
* the editor's comparator for `animations` and `events` — natural, and
|
|
298
|
+
* case-insensitive — and #543 narrowed the animation refusal onto what is left
|
|
299
|
+
* unmeasured *inside that family*. None of that is a measurement about skins:
|
|
300
|
+
* it is one program, and the inference that one program sorts two collections
|
|
301
|
+
* the same way is exactly the shape of the inference that put `skins` on the
|
|
302
|
+
* safe side of the list in the first place. `measuredSkinOrder` therefore
|
|
303
|
+
* certifies a pair only where the natural case-insensitive family **and plain
|
|
304
|
+
* codepoint** agree, which is the pre-#543 rule — correct here for the reason
|
|
305
|
+
* it was too strong there: for animations, codepoint had been *refuted*; for
|
|
306
|
+
* skins, nothing has refuted anything.
|
|
307
|
+
*
|
|
308
|
+
* ⇒ Every skin name in this tree is lower-case ASCII without digits, so no
|
|
309
|
+
* emitted byte moves and no rig is refused. What moves is the claim.
|
|
310
|
+
*/
|
|
311
|
+
function editorSkinOrder<T extends { name: string }>(skins: readonly T[]): T[] {
|
|
312
|
+
const pinned = skins.filter((skin) => skin.name === DEFAULT_SKIN);
|
|
313
|
+
const rest = skins.filter((skin) => skin.name !== DEFAULT_SKIN);
|
|
314
|
+
const verdicts = refuseNamesTheEditorCouldKeyDifferently(
|
|
315
|
+
rest.map((skin) => skin.name),
|
|
316
|
+
SKIN_ORDER,
|
|
317
|
+
);
|
|
318
|
+
rest.sort((a, b) => verdicts.get(a.name)?.get(b.name) ?? 0);
|
|
319
|
+
return [...pinned, ...rest];
|
|
320
|
+
}
|
|
321
|
+
|
|
227
322
|
/**
|
|
228
|
-
* The characters whose relative order every
|
|
229
|
-
* digits and, once case is folded, the lower-case ASCII letters.
|
|
230
|
-
* — `-`, `_`, a space, an accented letter — is worth something
|
|
231
|
-
*
|
|
323
|
+
* The characters whose relative order every member of the measured family agrees
|
|
324
|
+
* on: the digits and, once case is folded, the lower-case ASCII letters.
|
|
325
|
+
* Everything else — `-`, `_`, a space, an accented letter — is worth something
|
|
326
|
+
* different to a collator that ignores punctuation than to one that does not, so
|
|
327
|
+
* a pair those decide is not settled.
|
|
232
328
|
*/
|
|
233
329
|
const SETTLED_CHARS = /[0-9a-z]/;
|
|
234
330
|
|
|
235
331
|
/** Maximal runs of digits and of non-digits, which is what "natural" compares. */
|
|
236
332
|
const runsOf = (name: string): string[] => name.match(/\d+|\D+/g) ?? [];
|
|
237
333
|
|
|
238
|
-
const sign = (n: number): number => (n < 0 ? -1 : n > 0 ? 1 : 0);
|
|
239
334
|
const codepoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
240
335
|
|
|
241
336
|
/** What made a pair's order a matter of opinion, and what the author has to do. */
|
|
@@ -246,58 +341,72 @@ interface Ambiguity {
|
|
|
246
341
|
}
|
|
247
342
|
|
|
248
343
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
344
|
+
* How the editor's comparator orders these two names — or, when one of its four
|
|
345
|
+
* unmeasured choices is what decides them, what that choice is and what the
|
|
346
|
+
* author has to do about it.
|
|
251
347
|
*
|
|
252
|
-
* ##
|
|
348
|
+
* ## It is a certificate, not a hazard list
|
|
253
349
|
*
|
|
254
350
|
* Issue #539 proposed checking for "two names differing only in case, or sharing
|
|
255
351
|
* a prefix followed by digit runs of unequal length". **That list misses the rig
|
|
256
352
|
* that produced the measurement**: `Turn` and `sweep` differ in much more than
|
|
257
353
|
* case and carry no digits, and they are the pair the editor reordered. A list of
|
|
258
354
|
* hazards is open-ended — one was missing the day it was written — so what is
|
|
259
|
-
* implemented is the other direction: a
|
|
260
|
-
* decides
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* - **case**
|
|
266
|
-
*
|
|
267
|
-
* - **number
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
355
|
+
* implemented is the other direction: a verdict is returned only where the
|
|
356
|
+
* position that decides the pair is one **every** comparator consistent with the
|
|
357
|
+
* measurement must read the same way, and everything else stops the build.
|
|
358
|
+
*
|
|
359
|
+
* ## The four choices this returns an `Ambiguity` for, and why each is free
|
|
360
|
+
*
|
|
361
|
+
* - **case** (`Turn` against `turn`) — the two fold together, so only a tie-break
|
|
362
|
+
* separates them and nobody has measured which way it breaks.
|
|
363
|
+
* - **number**, one number written twice (`turn01` against `turn1`) — the runs are
|
|
364
|
+
* numerically equal, so again only a tie-break separates them: shorter-first,
|
|
365
|
+
* longer-first and lexicographic are all real implementations.
|
|
366
|
+
* - **number**, a digit run against a word (`1turn` against `turn`) — comparators
|
|
367
|
+
* differ on whether a number sorts before a word.
|
|
271
368
|
* - **separator** — the deciding character is neither a letter nor a digit. This
|
|
272
369
|
* one covers two adversaries at once: a collator that treats `-` or a space as
|
|
273
370
|
* ignorable, and the plain fact that `_` sits *between* `Z` and `a`, so
|
|
274
371
|
* `x.toUpperCase()` and `x.toLowerCase()` order `wave_x` against `wavea`
|
|
275
372
|
* oppositely. Neither name needs a capital in it for that to bite.
|
|
276
373
|
*
|
|
374
|
+
* ⚠️ There used to be a fifth and a sixth, and both were artefacts of emitting
|
|
375
|
+
* codepoint rather than facts about the editor (issue #543). A pair folding the
|
|
376
|
+
* other way (`Turn` against `sweep`) and two digit runs of unequal width
|
|
377
|
+
* (`turn10` against `turn2`) are exactly the two pairs the editor **was**
|
|
378
|
+
* measured on, and every comparator in the family orders them the same way — so
|
|
379
|
+
* they are now emitted in that order rather than refused. `flare1` against
|
|
380
|
+
* `flare10`, the over-refusal #542 named and accepted, goes with them.
|
|
381
|
+
*
|
|
277
382
|
* ## What it was tested against
|
|
278
383
|
*
|
|
279
384
|
* 22 comparators — 12 hand-rolled naturals (fold up/down × three leading-zero
|
|
280
|
-
* tie-breaks × digits-before
|
|
385
|
+
* tie-breaks × digits-before/after words), 2 plain case-insensitive ones, and 8
|
|
281
386
|
* `Intl.Collator`s (`numeric: true`, four sensitivities × `ignorePunctuation`) —
|
|
282
387
|
* over every pair of names up to 4 characters from `{a B 0 1 2 - _ space}`:
|
|
283
|
-
* **10,948,860 pairs
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
388
|
+
* **10,948,860 pairs**. Three figures come off that bank and each is load-bearing:
|
|
389
|
+
*
|
|
390
|
+
* - **0 escapes**, where an escape is a pair some comparator orders differently
|
|
391
|
+
* from the verdict returned here. Measured over the **20 natural** members —
|
|
392
|
+
* the 12 hand-rolled and the 8 collators, all of which read a digit run as a
|
|
393
|
+
* number. The 2 plain case-insensitive comparators do dissent (291,684 pairs),
|
|
394
|
+
* and they are the two #539's own measurement refutes: a comparator that reads
|
|
395
|
+
* `turn10` as text cannot return `turn2, turn10`.
|
|
396
|
+
* - **0 pairs move.** On every pair the codepoint rule accepted, this returns the
|
|
397
|
+
* codepoint order — which is why no emitted byte in the tree changes.
|
|
398
|
+
* - the refusal covers **9,140,115** pairs where the codepoint rule covered
|
|
399
|
+
* 10,218,136: 1,078,021 pairs are now built instead of renamed.
|
|
400
|
+
*
|
|
401
|
+
* ⭐ And the two-sided result is on real data: across the 105 editor-written
|
|
402
|
+
* collections the survey above defines, sorting by this reproduces all 105 and
|
|
403
|
+
* refuses none. No false positive, no false negative, against names the editor
|
|
404
|
+
* itself wrote.
|
|
297
405
|
*/
|
|
298
|
-
function
|
|
299
|
-
const caseRepair = 'rename one of them so
|
|
300
|
-
const
|
|
406
|
+
function measuredOrder(a: string, b: string): number | Ambiguity {
|
|
407
|
+
const caseRepair = 'rename one of them so they differ by more than letter case';
|
|
408
|
+
const zerosRepair = 'write the number one way — rename so the digit run has a single spelling, with leading zeros or without';
|
|
409
|
+
const wordRepair = 'rename so a run of digits never has to be compared against a word';
|
|
301
410
|
const separatorRepair = 'rename so the first character that differs is a letter or a digit';
|
|
302
411
|
const la = a.toLowerCase();
|
|
303
412
|
const lb = b.toLowerCase();
|
|
@@ -332,13 +441,6 @@ function orderTurnsOnTheComparator(a: string, b: string): Ambiguity | null {
|
|
|
332
441
|
repair: separatorRepair,
|
|
333
442
|
};
|
|
334
443
|
}
|
|
335
|
-
if (sign(codepoint(a, b)) !== sign(codepoint(la, lb))) {
|
|
336
|
-
return {
|
|
337
|
-
kind: 'case',
|
|
338
|
-
because: `codepoint puts "${a}" first only because of letter case; folded, "${b}" comes first`,
|
|
339
|
-
repair: caseRepair,
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
444
|
const ra = runsOf(la);
|
|
343
445
|
const rb = runsOf(lb);
|
|
344
446
|
for (let k = 0; k < Math.min(ra.length, rb.length); k++) {
|
|
@@ -348,21 +450,16 @@ function orderTurnsOnTheComparator(a: string, b: string): Ambiguity | null {
|
|
|
348
450
|
const xIsDigits = /^\d/.test(x);
|
|
349
451
|
const yIsDigits = /^\d/.test(y);
|
|
350
452
|
if (xIsDigits && yIsDigits) {
|
|
351
|
-
|
|
453
|
+
const nx = BigInt(x);
|
|
454
|
+
const ny = BigInt(y);
|
|
455
|
+
if (nx === ny) {
|
|
352
456
|
return {
|
|
353
457
|
kind: 'number',
|
|
354
458
|
because: `"${x}" and "${y}" are the same number written two ways, so only a tie-break separates them`,
|
|
355
|
-
repair:
|
|
459
|
+
repair: zerosRepair,
|
|
356
460
|
};
|
|
357
461
|
}
|
|
358
|
-
|
|
359
|
-
return {
|
|
360
|
-
kind: 'number',
|
|
361
|
-
because:
|
|
362
|
-
`"${x}" and "${y}" are runs of digits of different widths, so reading them as text and reading them as ` +
|
|
363
|
-
`the numbers ${BigInt(x)} and ${BigInt(y)} can disagree`,
|
|
364
|
-
repair: numberRepair,
|
|
365
|
-
};
|
|
462
|
+
return nx < ny ? -1 : 1;
|
|
366
463
|
}
|
|
367
464
|
if (xIsDigits !== yIsDigits) {
|
|
368
465
|
return {
|
|
@@ -370,44 +467,167 @@ function orderTurnsOnTheComparator(a: string, b: string): Ambiguity | null {
|
|
|
370
467
|
because:
|
|
371
468
|
`one has the digits "${xIsDigits ? x : y}" where the other has "${xIsDigits ? y : x}", and comparators ` +
|
|
372
469
|
'differ on whether a number sorts before a word',
|
|
373
|
-
repair:
|
|
470
|
+
repair: wordRepair,
|
|
374
471
|
};
|
|
375
472
|
}
|
|
376
|
-
return
|
|
473
|
+
return codepoint(x, y);
|
|
377
474
|
}
|
|
378
|
-
|
|
475
|
+
// One name's runs are a prefix of the other's — `wave` against `wave1`. Every
|
|
476
|
+
// comparator puts the shorter first; `la === lb` above already took the case
|
|
477
|
+
// where neither is longer.
|
|
478
|
+
return ra.length < rb.length ? -1 : 1;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* How the editor orders two SKIN names — or what makes the pair a matter of
|
|
483
|
+
* opinion, under a family wider than `measuredOrder`'s by exactly one member.
|
|
484
|
+
*
|
|
485
|
+
* ## The extra member is plain codepoint, and it is here because nothing ruled
|
|
486
|
+
* it out
|
|
487
|
+
*
|
|
488
|
+
* #539 put two name sets through the editor and read them back, and what those
|
|
489
|
+
* two sets refute is that the editor sorts ANIMATIONS by codepoint: `Turn`
|
|
490
|
+
* before `sweep` and `turn10` before `turn2` are the codepoint answers, and the
|
|
491
|
+
* editor gave the other one both times. #543 is built on that refutation — it
|
|
492
|
+
* sorts by the natural, case-insensitive family and refuses only that family's
|
|
493
|
+
* four unmeasured choices.
|
|
494
|
+
*
|
|
495
|
+
* ⚠️ **The skins measurement refutes nothing.** `alpha, mike, zulu` is the
|
|
496
|
+
* answer every candidate gives, so codepoint is still standing for this
|
|
497
|
+
* collection. Carrying #543's narrowing over would be assuming that one editor
|
|
498
|
+
* sorts two collections by one comparator — plausible, unmeasured, and the same
|
|
499
|
+
* move that made `skins` "an array the editor leaves alone" for two releases.
|
|
500
|
+
*
|
|
501
|
+
* ⇒ A verdict is returned only where `measuredOrder` certifies the pair **and**
|
|
502
|
+
* codepoint agrees with it. On the pairs where they differ, the disagreement is
|
|
503
|
+
* itself the explanation: either folding the two reverses them, or reading a
|
|
504
|
+
* digit run as a number does, and the editor's choice between those readings is
|
|
505
|
+
* measured for animation names and not for skin names.
|
|
506
|
+
*
|
|
507
|
+
* 🔒 It is deliberately a *narrowing of `measuredOrder`* rather than a second
|
|
508
|
+
* comparator: one certificate, one place a family member is decided, and no way
|
|
509
|
+
* for the two to come to disagree about what "settled" means.
|
|
510
|
+
*/
|
|
511
|
+
function measuredSkinOrder(a: string, b: string): number | Ambiguity {
|
|
512
|
+
const verdict = measuredOrder(a, b);
|
|
513
|
+
if (typeof verdict !== 'number' || codepoint(a, b) === verdict) return verdict;
|
|
514
|
+
// `measuredOrder` and codepoint can only part company where folding or a digit
|
|
515
|
+
// run decides the pair — everything else it already refuses. Which of the two
|
|
516
|
+
// it is, is what the author has to read.
|
|
517
|
+
const foldingDecides = codepoint(a.toLowerCase(), b.toLowerCase()) !== codepoint(a, b);
|
|
518
|
+
return foldingDecides
|
|
519
|
+
? {
|
|
520
|
+
kind: 'case',
|
|
521
|
+
because:
|
|
522
|
+
`folded to one case "${a}" and "${b}" order the other way round, so whether the editor folds SKIN ` +
|
|
523
|
+
'names decides this pair — and only its ANIMATION and EVENT names have been measured folded (#539)',
|
|
524
|
+
repair: 'rename one of them so their order does not turn on letter case',
|
|
525
|
+
}
|
|
526
|
+
: {
|
|
527
|
+
kind: 'number',
|
|
528
|
+
because:
|
|
529
|
+
`read as numbers the digit runs in "${a}" and "${b}" order the other way round from the same runs read ` +
|
|
530
|
+
'as text, so whether the editor sorts SKIN names naturally decides this pair — and only its ANIMATION ' +
|
|
531
|
+
'and EVENT names have been measured sorted naturally (#539)',
|
|
532
|
+
repair: 'pad the digit runs to the same width, or rename so no number decides the order',
|
|
533
|
+
};
|
|
379
534
|
}
|
|
380
535
|
|
|
381
536
|
/** How many pairs a refusal spells out before it starts counting them instead. */
|
|
382
537
|
const PAIRS_SPELLED_OUT = 8;
|
|
383
538
|
|
|
384
539
|
/**
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
* Adding the slider is then the edit that refuses a rig that built yesterday.
|
|
540
|
+
* What one collection's order refusal has to say that the others' do not: what
|
|
541
|
+
* it is counting pairs of, which comparator family it certified them against,
|
|
542
|
+
* and what an order rigc got wrong would cost.
|
|
543
|
+
*
|
|
544
|
+
* Two collections share the walk below because they share the defect — a
|
|
545
|
+
* name-keyed collection whose ORDINAL is a reference in the format's binary half
|
|
546
|
+
* — and they must not share the sentence, because the family and the stakes are
|
|
547
|
+
* different and a reader acts on both.
|
|
394
548
|
*/
|
|
395
|
-
|
|
549
|
+
interface OrderedCollection {
|
|
550
|
+
/** Plural, for "N pair(s) of …": `animation names`, `skin names`. */
|
|
551
|
+
noun: string;
|
|
552
|
+
/** The certificate. A number is an order; an `Ambiguity` stops the build. */
|
|
553
|
+
order: (a: string, b: string) => number | Ambiguity;
|
|
554
|
+
/** Why rigc has an opinion, and what the family leaves open. Ends on a space. */
|
|
555
|
+
why: string;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const ANIMATION_ORDER: OrderedCollection = {
|
|
559
|
+
noun: 'animation names',
|
|
560
|
+
order: measuredOrder,
|
|
561
|
+
why:
|
|
562
|
+
'rigc keys the emitted "animations" object in ' +
|
|
563
|
+
"the Spine editor's own comparator, which is natural and case-insensitive (#539) — but four of that " +
|
|
564
|
+
"comparator's choices have never been measured (a pure case tie, one number written two ways, a run of " +
|
|
565
|
+
'digits against a word, and what a separator is worth), and each pair below is decided by one of them. A ' +
|
|
566
|
+
"slider's animation is an ORDINAL in the format's binary half, and an editor that keys these differently " +
|
|
567
|
+
'repoints every slider whose animation moves index — silently, in a file that still parses (#535). ',
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const SKIN_ORDER: OrderedCollection = {
|
|
571
|
+
noun: 'skin names',
|
|
572
|
+
order: measuredSkinOrder,
|
|
573
|
+
why:
|
|
574
|
+
'rigc writes the emitted "skins" array with "default" first and the rest in the order the editor was ' +
|
|
575
|
+
'measured returning them (#541) — but that measurement was taken on `alpha, mike, zulu`, which every ' +
|
|
576
|
+
"candidate comparator orders the same way, so the editor's skin comparator is not established and each " +
|
|
577
|
+
'pair below is one the candidates disagree about. A skin is an ORDINAL in the format\'s binary half — ' +
|
|
578
|
+
'`skins[readInt()]` for an attachment timeline, `skins[skinIndex]` for a linked mesh — so an editor that ' +
|
|
579
|
+
'writes them in another order repoints every such reference, silently, in a file that still parses. ',
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Refuse a set of names the editor could write in an order rigc did not emit,
|
|
584
|
+
* and hand back the verdict for every pair that survived — the check that lets
|
|
585
|
+
* `editorAnimationOrder` and `editorSkinOrder` sort by a measured family without
|
|
586
|
+
* choosing a member of it.
|
|
587
|
+
*
|
|
588
|
+
* It is deliberately **not** conditional on anything: not on the rig declaring a
|
|
589
|
+
* slider, and not on the rig declaring the editor as a consumer. A slider is what
|
|
590
|
+
* makes the difference bite today and `invariants.editorRoundTrip` is how a rig
|
|
591
|
+
* says the editor is downstream, but the emitted order is a claim about the
|
|
592
|
+
* editor either way, and a check that only ran for some rigs would make the claim
|
|
593
|
+
* hold for some and not others with nothing in the file saying which — adding the
|
|
594
|
+
* slider, or the declaration, would then be the edit that refuses a rig that built
|
|
595
|
+
* yesterday. What issue #543 changed is the size of what is claimed, not who it is
|
|
596
|
+
* claimed for.
|
|
597
|
+
*
|
|
598
|
+
* ⚠️ `what` is a parameter and not a second copy of this walk because issue #541
|
|
599
|
+
* found the same defect in a second collection, and a copied loop is how the two
|
|
600
|
+
* come to check different things. What may NOT be shared is the family: `skins`
|
|
601
|
+
* and `animations` have been measured to different depths, and
|
|
602
|
+
* `OrderedCollection.order` is where each says which.
|
|
603
|
+
*/
|
|
604
|
+
function refuseNamesTheEditorCouldKeyDifferently(
|
|
605
|
+
names: readonly string[],
|
|
606
|
+
what: OrderedCollection,
|
|
607
|
+
): Map<string, Map<string, number>> {
|
|
608
|
+
const verdicts = new Map<string, Map<string, number>>();
|
|
609
|
+
const put = (x: string, y: string, v: number): void => {
|
|
610
|
+
const row = verdicts.get(x) ?? new Map<string, number>();
|
|
611
|
+
row.set(y, v);
|
|
612
|
+
verdicts.set(x, row);
|
|
613
|
+
};
|
|
396
614
|
const found: string[] = [];
|
|
397
|
-
for (let i = 0; i <
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
615
|
+
for (let i = 0; i < names.length; i++) {
|
|
616
|
+
put(names[i], names[i], 0);
|
|
617
|
+
for (let j = i + 1; j < names.length; j++) {
|
|
618
|
+
const verdict = what.order(names[i], names[j]);
|
|
619
|
+
if (typeof verdict === 'number') {
|
|
620
|
+
put(names[i], names[j], verdict);
|
|
621
|
+
put(names[j], names[i], -verdict);
|
|
622
|
+
} else {
|
|
623
|
+
found.push(`"${names[i]}" / "${names[j]}" (${verdict.kind}) — ${verdict.because}; ${verdict.repair}`);
|
|
624
|
+
}
|
|
401
625
|
}
|
|
402
626
|
}
|
|
403
|
-
if (!found.length) return;
|
|
627
|
+
if (!found.length) return verdicts;
|
|
404
628
|
const spelled = found.slice(0, PAIRS_SPELLED_OUT);
|
|
405
629
|
throw new CompileError(
|
|
406
|
-
`${found.length} pair(s) of
|
|
407
|
-
'codepoint-ascending, which is the Spine editor\'s own order only for names whose order does not turn on ' +
|
|
408
|
-
'case, on a number, or on a separator. The editor sorts natural and case-insensitive (#539), a slider\'s ' +
|
|
409
|
-
'animation is an ORDINAL in the format\'s binary half, and an editor that keys these differently repoints ' +
|
|
410
|
-
'every slider whose animation moves index — silently, in a file that still parses (#535). ' +
|
|
630
|
+
`${found.length} pair(s) of ${what.noun} have no one order: ${what.why}` +
|
|
411
631
|
`${spelled.join('. ')}` +
|
|
412
632
|
(found.length > spelled.length ? `. …and ${found.length - spelled.length} more pair(s)` : ''),
|
|
413
633
|
);
|
|
@@ -1028,44 +1248,6 @@ function readAtlasIn(path: string): AtlasSource {
|
|
|
1028
1248
|
return { path, dir: dirname(path), parsed, byName };
|
|
1029
1249
|
}
|
|
1030
1250
|
|
|
1031
|
-
/**
|
|
1032
|
-
* How far apart two names are, for the "did you mean" list on a missing region.
|
|
1033
|
-
*
|
|
1034
|
-
* Plain Levenshtein. An atlas has tens of regions and this runs once per
|
|
1035
|
-
* refusal, so the O(n*m) table is free and a cheaper heuristic (shared prefix,
|
|
1036
|
-
* substring) would miss the commonest real case — a transposition or one wrong
|
|
1037
|
-
* character in a hand-typed `image`.
|
|
1038
|
-
*/
|
|
1039
|
-
function nameDistance(a: string, b: string): number {
|
|
1040
|
-
const rows = a.length + 1;
|
|
1041
|
-
const cols = b.length + 1;
|
|
1042
|
-
let previous = new Array<number>(cols);
|
|
1043
|
-
for (let j = 0; j < cols; j++) previous[j] = j;
|
|
1044
|
-
for (let i = 1; i < rows; i++) {
|
|
1045
|
-
const current = new Array<number>(cols);
|
|
1046
|
-
current[0] = i;
|
|
1047
|
-
for (let j = 1; j < cols; j++) {
|
|
1048
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
1049
|
-
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
|
|
1050
|
-
}
|
|
1051
|
-
previous = current;
|
|
1052
|
-
}
|
|
1053
|
-
return previous[cols - 1];
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
/** Up to five region names closest to the one that was not found. */
|
|
1057
|
-
function nearMisses(wanted: string, known: Iterable<string>): string[] {
|
|
1058
|
-
const scored: Array<{ name: string; d: number }> = [];
|
|
1059
|
-
for (const name of known) {
|
|
1060
|
-
const d = nameDistance(wanted.toLowerCase(), name.toLowerCase());
|
|
1061
|
-
// Half the name's length, floored at 2: "leg" must not suggest "arm", and a
|
|
1062
|
-
// long name may still be recognisable through several typos.
|
|
1063
|
-
if (d <= Math.max(2, Math.floor(wanted.length / 2))) scored.push({ name, d });
|
|
1064
|
-
}
|
|
1065
|
-
scored.sort((x, y) => x.d - y.d || (x.name < y.name ? -1 : 1));
|
|
1066
|
-
return scored.slice(0, 5).map((s) => s.name);
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
1251
|
/**
|
|
1070
1252
|
* One part resolved out of a region of a pre-packed atlas — CLI `--atlas-in`.
|
|
1071
1253
|
*
|
|
@@ -1267,6 +1449,16 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
1267
1449
|
const images: CompiledImage[] = [];
|
|
1268
1450
|
const droppedStates: CompileResult['droppedStates'] = [];
|
|
1269
1451
|
const seenRegions = new Set<string>();
|
|
1452
|
+
/**
|
|
1453
|
+
* Region name -> the absolute path of the file whose pixels that region holds.
|
|
1454
|
+
*
|
|
1455
|
+
* ⭐ A region is named by its PNG's **basename**, so two files in two
|
|
1456
|
+
* directories can want one name, and the loser draws the winner's art with
|
|
1457
|
+
* nothing said (issue #555). `seenRegions` answers "is this name taken"; this
|
|
1458
|
+
* answers "by what", which is the half a refusal has to name — and it is the
|
|
1459
|
+
* same record that lets one PNG legitimately serve two skins.
|
|
1460
|
+
*/
|
|
1461
|
+
const regionSource = new Map<string, string>();
|
|
1270
1462
|
/**
|
|
1271
1463
|
* Every directory the spec names a part PNG in — `skeletonImagesPath` reads it.
|
|
1272
1464
|
* Recorded from the spec's own path whether or not the pixels came from there:
|
|
@@ -1289,11 +1481,53 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
1289
1481
|
? fromLoosePng(relPath, loosePath, region, isBase, outDir)
|
|
1290
1482
|
: resolveFromAtlas(relPath, region, isBase, outDir, atlasIn);
|
|
1291
1483
|
seenRegions.add(region);
|
|
1484
|
+
regionSource.set(region, loosePath);
|
|
1292
1485
|
partDirs.add(dirname(loosePath));
|
|
1293
1486
|
images.push(img);
|
|
1294
1487
|
return img;
|
|
1295
1488
|
};
|
|
1296
1489
|
|
|
1490
|
+
/**
|
|
1491
|
+
* Measure and atlas the art ONE skin attachment names — once per file, not
|
|
1492
|
+
* once per placeholder (issue #555).
|
|
1493
|
+
*
|
|
1494
|
+
* 🚨 The defect this replaces. The gather loop below deduplicated the slot's
|
|
1495
|
+
* attachment NAMES, and the call to `addImage` stood behind that `continue`,
|
|
1496
|
+
* so a placeholder two skins fill reached the atlas with the FIRST skin's art
|
|
1497
|
+
* and every later skin's PNG was never opened. Measured on a two-skin rig
|
|
1498
|
+
* before the repair: without stated sizes the compile refused with *a region
|
|
1499
|
+
* needs width and height — give them, or give an "image" and rigc will measure
|
|
1500
|
+
* the PNG*, which is the message telling the author to give the image they
|
|
1501
|
+
* gave; with sizes stated it built and `A00_ROUNDTRIP_PARSE` refused the
|
|
1502
|
+
* skeleton with *Region not found in atlas: patch_b*. Neither names the cause,
|
|
1503
|
+
* and both are the doctrine's own second bullet inverted — a miss refused
|
|
1504
|
+
* under somebody else's name.
|
|
1505
|
+
*
|
|
1506
|
+
* ⚠️ Two skins may legitimately share one PNG, which is why this is not simply
|
|
1507
|
+
* `addImage` unguarded: `addImage` refuses any repeat of a region name, and a
|
|
1508
|
+
* placeholder both skins point at the SAME file is one region on purpose. The
|
|
1509
|
+
* distinction is the FILE, so that is what is compared — and two different
|
|
1510
|
+
* files whose basenames collide are refused here by name rather than aliased,
|
|
1511
|
+
* because the loser silently draws the winner's pixels at the winner's size
|
|
1512
|
+
* and the whole gate stays green (measured: 15 assertions passed on exactly
|
|
1513
|
+
* that rig).
|
|
1514
|
+
*/
|
|
1515
|
+
const addSkinImage = (relPath: string, where: string): void => {
|
|
1516
|
+
const region = basename(relPath, '.png');
|
|
1517
|
+
const already = regionSource.get(region);
|
|
1518
|
+
if (already === undefined) {
|
|
1519
|
+
addImage(relPath, imagesDir, false);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const wanted = resolve(imagesDir, relPath);
|
|
1523
|
+
if (already === wanted) return; // one file, two attachments: one region, deliberately
|
|
1524
|
+
throw new CompileError(
|
|
1525
|
+
`${where}: "${relPath}" and the art already atlased as region "${region}" are two different files — ` +
|
|
1526
|
+
`${wanted} and ${already}. An atlas region is named by its PNG's basename, so only one of the two can ` +
|
|
1527
|
+
'hold that name and this attachment would draw the other file\'s pixels. Rename one of the PNGs.',
|
|
1528
|
+
);
|
|
1529
|
+
};
|
|
1530
|
+
|
|
1297
1531
|
// A manifest may name a part the cut does not carry. A formation can declare
|
|
1298
1532
|
// more slots than any one cut fills, and a cut that shares a sprite with the
|
|
1299
1533
|
// scene around it has no plate of its own to point at — the manifest then
|
|
@@ -1471,16 +1705,25 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
1471
1705
|
}
|
|
1472
1706
|
const names = rigAttachmentNames.get(slotName) ?? [];
|
|
1473
1707
|
for (const [placeholder, att] of Object.entries(placeholders)) {
|
|
1474
|
-
|
|
1475
|
-
|
|
1708
|
+
// Two concerns, two conditions. The list is the slot's PLACEHOLDER list
|
|
1709
|
+
// and a placeholder several skins fill belongs in it once; the art is
|
|
1710
|
+
// per ATTACHMENT, and there are as many of those as there are skins
|
|
1711
|
+
// filling it. Standing behind one `continue`, the second was the first's
|
|
1712
|
+
// arithmetic (issue #555).
|
|
1713
|
+
if (!names.includes(placeholder)) names.push(placeholder);
|
|
1476
1714
|
const image = (att as RigRegionAttachment).image;
|
|
1477
|
-
if (typeof image === 'string'
|
|
1478
|
-
|
|
1715
|
+
if (typeof image === 'string') {
|
|
1716
|
+
addSkinImage(image, `skin "${skinName}" slot "${slotName}" attachment "${placeholder}"`);
|
|
1479
1717
|
}
|
|
1480
1718
|
}
|
|
1481
1719
|
rigAttachmentNames.set(slotName, names);
|
|
1482
1720
|
}
|
|
1483
1721
|
}
|
|
1722
|
+
// Which (slot, placeholder) pairs more than one skin fills — the pairs whose
|
|
1723
|
+
// entries have to carry an attachment `name` of their own. Computed here, off
|
|
1724
|
+
// the normalised skin table, so the slot loop below reads a decision rather
|
|
1725
|
+
// than re-deriving one per attachment.
|
|
1726
|
+
const contested = contestedPlaceholders(skinNames, skinParts);
|
|
1484
1727
|
|
|
1485
1728
|
// -- 2. atlas --------------------------------------------------------------
|
|
1486
1729
|
//
|
|
@@ -1638,9 +1881,10 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
1638
1881
|
const placeholders = skinParts.get(skinName)!.attachments[rigSlot.name];
|
|
1639
1882
|
if (!placeholders) continue;
|
|
1640
1883
|
const perSlot: Record<string, SpineAttachment> = {};
|
|
1884
|
+
const shared = contested.get(rigSlot.name);
|
|
1641
1885
|
for (const [placeholder, att] of Object.entries(placeholders)) {
|
|
1642
1886
|
const where = `skin "${skinName}" slot "${rigSlot.name}" attachment "${placeholder}"`;
|
|
1643
|
-
|
|
1887
|
+
const built = buildRigAttachment(att, placeholder, where, {
|
|
1644
1888
|
images,
|
|
1645
1889
|
bones,
|
|
1646
1890
|
transforms,
|
|
@@ -1653,6 +1897,12 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
1653
1897
|
depths: attachmentDepths,
|
|
1654
1898
|
slotNames: new Set(rig.slots.map((s) => s.name)),
|
|
1655
1899
|
});
|
|
1900
|
+
// The name is put on AFTER the builder rather than inside it: five
|
|
1901
|
+
// builders write five shapes, the rule is one rule, and a rule that has
|
|
1902
|
+
// to be remembered in five places is a rule that will be kept in four.
|
|
1903
|
+
perSlot[placeholder] = shared?.has(placeholder)
|
|
1904
|
+
? nameSkinAttachment(built, skinAttachmentName(skinName, placeholder), placeholder)
|
|
1905
|
+
: built;
|
|
1656
1906
|
}
|
|
1657
1907
|
tableFor(skinName)[rigSlot.name] = perSlot;
|
|
1658
1908
|
}
|
|
@@ -2069,20 +2319,28 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
2069
2319
|
// `readSkeletonData`'s own order (`:372-443`). Every member list is a
|
|
2070
2320
|
// conditional spread, so a rig that declares none emits the two-key entry it
|
|
2071
2321
|
// always did, byte for byte.
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2322
|
+
//
|
|
2323
|
+
// Ordered the way `animations` is and for the same reason — the editor
|
|
2324
|
+
// rewrites this array and the binary half addresses it by ordinal — with the
|
|
2325
|
+
// sort applied HERE rather than to `skinTables`, so everything upstream (the
|
|
2326
|
+
// path-slot table, every refusal that lists skins) still reads the order the
|
|
2327
|
+
// rig spec declared. See `editorSkinOrder`.
|
|
2328
|
+
skins: editorSkinOrder(
|
|
2329
|
+
[...skinTables.entries()].map(([name, attachments]) => {
|
|
2330
|
+
const parts = skinParts.get(name);
|
|
2331
|
+
return {
|
|
2332
|
+
name,
|
|
2333
|
+
...(parts?.bones.length ? { bones: parts.bones } : {}),
|
|
2334
|
+
...Object.fromEntries(
|
|
2335
|
+
RIG_SKIN_CONSTRAINT_KEYS.filter((key) => parts?.constraints[key].length).map((key) => [
|
|
2336
|
+
key,
|
|
2337
|
+
parts!.constraints[key],
|
|
2338
|
+
]),
|
|
2339
|
+
),
|
|
2340
|
+
attachments,
|
|
2341
|
+
};
|
|
2342
|
+
}),
|
|
2343
|
+
),
|
|
2086
2344
|
// Between `skins` and `animations`, which is where the editor writes it. A
|
|
2087
2345
|
// conditional spread rather than an assignment after the literal, so the key
|
|
2088
2346
|
// lands in that position instead of at the end.
|
|
@@ -2244,6 +2502,154 @@ function rotationOf(spec: RigBone, ctx: BoneContext): number | null {
|
|
|
2244
2502
|
return spec.rotation ?? null;
|
|
2245
2503
|
}
|
|
2246
2504
|
|
|
2505
|
+
// ---------------------------------------------------------------------------
|
|
2506
|
+
// attachment names, where one placeholder holds several attachments
|
|
2507
|
+
// ---------------------------------------------------------------------------
|
|
2508
|
+
|
|
2509
|
+
/**
|
|
2510
|
+
* What separates a skin's name from a placeholder's inside an attachment name.
|
|
2511
|
+
*
|
|
2512
|
+
* ⚠️ A separator is the one part of this that could collide with a name somebody
|
|
2513
|
+
* wrote, so it is measured rather than picked: across the **160** distinct
|
|
2514
|
+
* placeholder names and **159** distinct atlas region names in `examples/` and
|
|
2515
|
+
* `gallery/` — every editor-authored name this repository has — `/` occurs in
|
|
2516
|
+
* **0**, while `-` occurs in 85 / 86 and `_` in 37 / 37. It is also the character
|
|
2517
|
+
* the format already has a structure for, since an attachment's name doubles as
|
|
2518
|
+
* its texture path and a path is what `/` separates.
|
|
2519
|
+
*
|
|
2520
|
+
* 🔒 And the choice is not load-bearing anyway, which is the point of stating it
|
|
2521
|
+
* this way: `contestedPlaceholders` refuses the build if the name it composes is
|
|
2522
|
+
* one some other placeholder in the same slot already answers to. A separator
|
|
2523
|
+
* nobody uses makes that refusal rare; the refusal is what makes it safe.
|
|
2524
|
+
*/
|
|
2525
|
+
const SKIN_ATTACHMENT_SEPARATOR = '/';
|
|
2526
|
+
|
|
2527
|
+
function skinAttachmentName(skinName: string, placeholder: string): string {
|
|
2528
|
+
return `${skinName}${SKIN_ATTACHMENT_SEPARATOR}${placeholder}`;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
/**
|
|
2532
|
+
* Which `(slot, placeholder)` pairs more than one skin fills — and, on the way,
|
|
2533
|
+
* the refusal that keeps the composed names from colliding with authored ones.
|
|
2534
|
+
*
|
|
2535
|
+
* ## The defect this exists for
|
|
2536
|
+
*
|
|
2537
|
+
* Two skins putting different art under one placeholder is what a skin IS, and
|
|
2538
|
+
* until issue #541 rigc emitted both entries with no `name`, which makes the
|
|
2539
|
+
* placeholder the name of both (`SkeletonJson.ts:526`). spine-core is happy —
|
|
2540
|
+
* its skin table is keyed by placeholder, so the two never meet. The Spine
|
|
2541
|
+
* editor refuses the whole import, and says exactly why:
|
|
2542
|
+
*
|
|
2543
|
+
* ERROR: Unable to import skeleton.
|
|
2544
|
+
* [error] Error reading skeleton: skins
|
|
2545
|
+
* Cause: [error] Error reading attachment: patch (MOw)
|
|
2546
|
+
* Cause: [error] Multiple attachments have the same name: patch patch
|
|
2547
|
+
*
|
|
2548
|
+
* Bisected on the emitted file: four skins REFUSED, deform timelines removed
|
|
2549
|
+
* REFUSED, `default` + one skin REFUSED, `default` alone IMPORTS, the second skin
|
|
2550
|
+
* given a distinct placeholder IMPORTS, and the same placeholder with **each
|
|
2551
|
+
* entry given its own `name`** IMPORTS — all four skins. So it is neither the
|
|
2552
|
+
* skin count nor the timelines; it is one name over several attachments.
|
|
2553
|
+
*
|
|
2554
|
+
* ## Only the contested pairs are named, and that is the whole rule
|
|
2555
|
+
*
|
|
2556
|
+
* A placeholder one skin fills keeps the emitted shape it has always had: no
|
|
2557
|
+
* `name`, no `path` it did not already carry. Every rig in this tree declares
|
|
2558
|
+
* exactly one skin, so **no emitted byte in the tree moves** — and a
|
|
2559
|
+
* multi-skin rig whose skins use distinct placeholders does not move either,
|
|
2560
|
+
* because nothing there is ambiguous to begin with.
|
|
2561
|
+
*
|
|
2562
|
+
* ⚠️ The scope of the editor's uniqueness rule is **not** skeleton-wide, and the
|
|
2563
|
+
* corpus proves it rather than a hypothesis doing so: `spineboy-pro.json`, which
|
|
2564
|
+
* the editor wrote, gives the name `head` to a region in slot `head` and to a
|
|
2565
|
+
* bounding box in slot `head-bb`, and names one `hoverglow-small` across eight
|
|
2566
|
+
* slots. What #541 refused was one slot. Composing from the skin makes the names
|
|
2567
|
+
* unique within the slot, which satisfies that scope and every narrower one;
|
|
2568
|
+
* nothing here claims to know which of them the editor actually applies, and an
|
|
2569
|
+
* assertion that policed the emitted artifact would have to.
|
|
2570
|
+
*
|
|
2571
|
+
* ## Why a composed name is not the compiler inventing a value
|
|
2572
|
+
*
|
|
2573
|
+
* rigc has always decided this attachment's name — it decided it was the
|
|
2574
|
+
* placeholder, silently, and that decision is the defect. What changes is the
|
|
2575
|
+
* derivation, not who makes it, and the new one is a function of two names the
|
|
2576
|
+
* spec wrote. Nothing is read off the art, and `path` — the field that says
|
|
2577
|
+
* which texture to draw — stays exactly what the spec stated or what the
|
|
2578
|
+
* attachment already resolved to.
|
|
2579
|
+
*/
|
|
2580
|
+
function contestedPlaceholders(
|
|
2581
|
+
skinNames: readonly string[],
|
|
2582
|
+
skinParts: Map<string, RigSkinParts>,
|
|
2583
|
+
): Map<string, Set<string>> {
|
|
2584
|
+
/** slot -> placeholder -> the skins that fill it, in declaration order. */
|
|
2585
|
+
const fillers = new Map<string, Map<string, string[]>>();
|
|
2586
|
+
for (const skinName of skinNames) {
|
|
2587
|
+
for (const [slotName, placeholders] of Object.entries(skinParts.get(skinName)!.attachments)) {
|
|
2588
|
+
const perSlot = fillers.get(slotName) ?? new Map<string, string[]>();
|
|
2589
|
+
for (const placeholder of Object.keys(placeholders)) {
|
|
2590
|
+
perSlot.set(placeholder, [...(perSlot.get(placeholder) ?? []), skinName]);
|
|
2591
|
+
}
|
|
2592
|
+
fillers.set(slotName, perSlot);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
const contested = new Map<string, Set<string>>();
|
|
2596
|
+
const collisions: string[] = [];
|
|
2597
|
+
for (const [slotName, perSlot] of fillers) {
|
|
2598
|
+
const shared = new Set([...perSlot].filter(([, skins]) => skins.length > 1).map(([placeholder]) => placeholder));
|
|
2599
|
+
if (shared.size) contested.set(slotName, shared);
|
|
2600
|
+
/** Emitted attachment name -> the first entry that claimed it. */
|
|
2601
|
+
const claimed = new Map<string, string>();
|
|
2602
|
+
for (const [placeholder, skins] of perSlot) {
|
|
2603
|
+
for (const skinName of skins) {
|
|
2604
|
+
const name = shared.has(placeholder) ? skinAttachmentName(skinName, placeholder) : placeholder;
|
|
2605
|
+
const site = `skin "${skinName}" placeholder "${placeholder}"`;
|
|
2606
|
+
const taken = claimed.get(name);
|
|
2607
|
+
if (taken === undefined) claimed.set(name, site);
|
|
2608
|
+
else collisions.push(`slot "${slotName}": ${taken} and ${site} would both be named "${name}"`);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
if (collisions.length) {
|
|
2613
|
+
throw new CompileError(
|
|
2614
|
+
`${collisions.length} attachment name collision(s): a placeholder that more than one skin fills is emitted ` +
|
|
2615
|
+
`with the name "<skin>${SKIN_ATTACHMENT_SEPARATOR}<placeholder>", because the Spine editor refuses an import ` +
|
|
2616
|
+
'in which one slot holds two attachments of one name (#541) — and here that composed name is one another ' +
|
|
2617
|
+
'entry in the same slot already answers to. Rename the placeholder or the skin so the two differ. ' +
|
|
2618
|
+
`${collisions.join('. ')}`,
|
|
2619
|
+
);
|
|
2620
|
+
}
|
|
2621
|
+
return contested;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
/**
|
|
2625
|
+
* Give one attachment its own `name`, and pin the texture `path` that name would
|
|
2626
|
+
* otherwise have taken with it.
|
|
2627
|
+
*
|
|
2628
|
+
* 🚨 The second half is the whole hazard. `readAttachment` reads
|
|
2629
|
+
* `const name = getValue(map, "name", placeholder)` and then
|
|
2630
|
+
* `const path = getValue(map, "path", name)` (`SkeletonJson.ts:526-529`, and
|
|
2631
|
+
* again at `:559` for a mesh) — so `path` defaults to the NAME, not to the
|
|
2632
|
+
* placeholder. Writing a name and leaving `path` alone silently repoints the
|
|
2633
|
+
* attachment's texture lookup at a region no atlas has. Restating `path` at what
|
|
2634
|
+
* the attachment already resolved to makes the name change invisible to
|
|
2635
|
+
* everything but the editor's own uniqueness rule, which is the only thing it is
|
|
2636
|
+
* for.
|
|
2637
|
+
*
|
|
2638
|
+
* ⚠️ `region` and `mesh` are exactly the two types that read `path`; the polygon
|
|
2639
|
+
* types (`boundingbox`, `clipping`, `path`) have no texture and get the name
|
|
2640
|
+
* alone. The list is the parser's own two `getValue(map, "path", …)` sites
|
|
2641
|
+
* rather than a judgement about which attachments "have art".
|
|
2642
|
+
*/
|
|
2643
|
+
function nameSkinAttachment(att: SpineAttachment, name: string, placeholder: string): SpineAttachment {
|
|
2644
|
+
const kind = (att as { type?: string }).type ?? 'region';
|
|
2645
|
+
// Key order is the parser's reading order — `name`, then `path`, then the rest
|
|
2646
|
+
// as the builder wrote it — for the same reason every other emitted object
|
|
2647
|
+
// follows it: the file is read by people and diffed against references.
|
|
2648
|
+
if (kind !== 'region' && kind !== 'mesh') return { name, ...att };
|
|
2649
|
+
const { path, ...rest } = att as SpineRegionAttachment | SpineMeshAttachment;
|
|
2650
|
+
return { name, path: path ?? placeholder, ...rest } as SpineAttachment;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2247
2653
|
// ---------------------------------------------------------------------------
|
|
2248
2654
|
// rig-declared attachments
|
|
2249
2655
|
// ---------------------------------------------------------------------------
|
|
@@ -2613,13 +3019,50 @@ function buildRigPath(att: RigPathAttachment, where: string, ctx: AttachmentCont
|
|
|
2613
3019
|
};
|
|
2614
3020
|
}
|
|
2615
3021
|
|
|
3022
|
+
/**
|
|
3023
|
+
* The compiled image an attachment's `image` names — or a refusal that names the
|
|
3024
|
+
* file and says what is actually wrong with it.
|
|
3025
|
+
*
|
|
3026
|
+
* 🚨 This exists because of what the four call sites used to do instead, which
|
|
3027
|
+
* was nothing (issue #555). `ctx.images.find(...)` returning `undefined` fell
|
|
3028
|
+
* through to the size branch, so an attachment that named a PNG rigc had not
|
|
3029
|
+
* atlased was refused with *a region needs width and height — give them, or give
|
|
3030
|
+
* an "image" and rigc will measure the PNG*: the remedy sentence handed to the
|
|
3031
|
+
* one author who had already done both halves of it. The two mesh generators
|
|
3032
|
+
* said `no compiled image for "x.png"`, which names the file and not the fault.
|
|
3033
|
+
*
|
|
3034
|
+
* ⭐ What the message may not do is guess at the spec. Every image a skin
|
|
3035
|
+
* attachment names is measured and atlased, one per file, so an attachment
|
|
3036
|
+
* standing in front of a region that does not exist is rigc having skipped a
|
|
3037
|
+
* measurement — the author cannot repair it by writing anything. Saying so is
|
|
3038
|
+
* the whole difference between a message that ends a session and one that starts
|
|
3039
|
+
* a bug report, and it is why this refusal reads as an invariant rather than as
|
|
3040
|
+
* advice. The near-miss list is `resolveFromAtlas`'s, for the case where the
|
|
3041
|
+
* spec did misspell a name in a way something upstream let through.
|
|
3042
|
+
*/
|
|
3043
|
+
function atlasedImage(image: string, where: string, ctx: AttachmentContext): CompiledImage {
|
|
3044
|
+
const region = basename(image, '.png');
|
|
3045
|
+
const img = ctx.images.find((im) => im.region === region);
|
|
3046
|
+
if (img) return img;
|
|
3047
|
+
const near = nearMisses(region, ctx.images.map((im) => im.region));
|
|
3048
|
+
const built = ctx.images.map((im) => im.region).sort();
|
|
3049
|
+
throw new CompileError(
|
|
3050
|
+
`${where}: the image "${image}" was never added to the atlas, so there is no region "${region}" for this ` +
|
|
3051
|
+
'attachment to draw and no measurement of it to take a size from. Every image an attachment names is ' +
|
|
3052
|
+
'measured and atlased — one per file, whichever skin names it (issue #555) — so reaching this means rigc ' +
|
|
3053
|
+
'skipped one, not that the spec left anything out. ' +
|
|
3054
|
+
(near.length ? `The nearest region(s) built are ${near.map((n) => JSON.stringify(n)).join(', ')}. ` : '') +
|
|
3055
|
+
`The atlas holds ${built.length} region(s): ${built.join(', ')}`,
|
|
3056
|
+
);
|
|
3057
|
+
}
|
|
3058
|
+
|
|
2616
3059
|
function buildRigRegion(
|
|
2617
3060
|
att: RigRegionAttachment,
|
|
2618
3061
|
placeholder: string,
|
|
2619
3062
|
where: string,
|
|
2620
3063
|
ctx: AttachmentContext,
|
|
2621
3064
|
): SpineRegionAttachment {
|
|
2622
|
-
const img = att.image === undefined ? null :
|
|
3065
|
+
const img = att.image === undefined ? null : atlasedImage(att.image, where, ctx);
|
|
2623
3066
|
// ⭐ An IMPORTED region's size is not a default the spec may override. On the
|
|
2624
3067
|
// loose path `att.width` and the PNG's width are two legitimate numbers — "draw
|
|
2625
3068
|
// this drawing at this size" is a scale, and the region covers its page either
|
|
@@ -2870,7 +3313,7 @@ function buildRigMesh(
|
|
|
2870
3313
|
// nor measurable it is a refusal, as it is for a region: 0 is not a size the
|
|
2871
3314
|
// spec stated, and the editor shows whatever is written here as the image's
|
|
2872
3315
|
// dimensions (it showed 32x32, its missing-image placeholder, for a 0x0 mesh).
|
|
2873
|
-
const img = att.image === undefined ? undefined :
|
|
3316
|
+
const img = att.image === undefined ? undefined : atlasedImage(att.image, where, ctx);
|
|
2874
3317
|
const width = att.width ?? img?.width;
|
|
2875
3318
|
const height = att.height ?? img?.height;
|
|
2876
3319
|
if (width === undefined || height === undefined) {
|
|
@@ -3377,9 +3820,7 @@ function buildGridAttachment(
|
|
|
3377
3820
|
);
|
|
3378
3821
|
}
|
|
3379
3822
|
|
|
3380
|
-
const
|
|
3381
|
-
const img = ctx.images.find((im) => im.region === region);
|
|
3382
|
-
if (!img) throw new CompileError(`${where}: no compiled image for "${att.image}"`);
|
|
3823
|
+
const img = atlasedImage(att.image, where, ctx);
|
|
3383
3824
|
const plate = partPlate(img);
|
|
3384
3825
|
|
|
3385
3826
|
let geometry;
|
|
@@ -3460,7 +3901,7 @@ function buildGridAttachment(
|
|
|
3460
3901
|
height: r6(h),
|
|
3461
3902
|
};
|
|
3462
3903
|
if (att.path !== undefined) out.path = att.path;
|
|
3463
|
-
else if (region !== placeholder) out.path = region;
|
|
3904
|
+
else if (img.region !== placeholder) out.path = img.region;
|
|
3464
3905
|
if (att.color !== undefined) out.color = att.color;
|
|
3465
3906
|
return out;
|
|
3466
3907
|
}
|
|
@@ -3503,9 +3944,7 @@ function buildContourAttachment(
|
|
|
3503
3944
|
'there is nothing else here that says which pixels to measure',
|
|
3504
3945
|
);
|
|
3505
3946
|
}
|
|
3506
|
-
const
|
|
3507
|
-
const img = ctx.images.find((im) => im.region === region);
|
|
3508
|
-
if (!img) throw new CompileError(`${where}: no compiled image for "${att.image}"`);
|
|
3947
|
+
const img = atlasedImage(att.image, where, ctx);
|
|
3509
3948
|
// ⚠️ Nothing here reads the PNG's colour type. `hasAlpha` answers "where does
|
|
3510
3949
|
// this file keep its alpha", not "is any pixel of it transparent" — a tRNS
|
|
3511
3950
|
// chunk is real transparency (issue #215) and an all-255 alpha channel is
|
|
@@ -3602,7 +4041,7 @@ function buildContourAttachment(
|
|
|
3602
4041
|
// basename, so a placeholder named anything else needs `path` written down or
|
|
3603
4042
|
// the loader resolves nothing.
|
|
3604
4043
|
if (att.path !== undefined) out.path = att.path;
|
|
3605
|
-
else if (region !== placeholder) out.path = region;
|
|
4044
|
+
else if (img.region !== placeholder) out.path = img.region;
|
|
3606
4045
|
if (att.color !== undefined) out.color = att.color;
|
|
3607
4046
|
return out;
|
|
3608
4047
|
}
|
|
@@ -3704,7 +4143,12 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
|
|
|
3704
4143
|
if (spec.type === 'ik') {
|
|
3705
4144
|
out.bones = boneList();
|
|
3706
4145
|
out.target = needBone(spec.target, 'target');
|
|
3707
|
-
|
|
4146
|
+
// `scaleY` is the `ScaleYMode` enum, not a number or a flag: the parser runs
|
|
4147
|
+
// it through `Utils.enumValue` (`:150`), which resolves an unknown name to
|
|
4148
|
+
// `undefined` and assigns it without a word. Out of `copy` for that reason —
|
|
4149
|
+
// see `RIG_SCALE_Y_MODES`.
|
|
4150
|
+
if (spec.scaleY !== undefined) out.scaleY = needEnum(spec.scaleY, 'scaleY', RIG_SCALE_Y_MODES);
|
|
4151
|
+
copy(['mix', 'softness', 'bendPositive', 'compress', 'stretch', 'skin']);
|
|
3708
4152
|
return out;
|
|
3709
4153
|
}
|
|
3710
4154
|
if (spec.type === 'transform') {
|
|
@@ -4090,13 +4534,15 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
|
|
|
4090
4534
|
}
|
|
4091
4535
|
if (spec.type === 'physics') {
|
|
4092
4536
|
out.bone = needBone(spec.bone, 'bone');
|
|
4537
|
+
// The same `ScaleYMode` enum an ik constraint carries, under the same key
|
|
4538
|
+
// and through the same silent `Utils.enumValue` (`:301`).
|
|
4539
|
+
if (spec.scaleY !== undefined) out.scaleY = needEnum(spec.scaleY, 'scaleY', RIG_SCALE_Y_MODES);
|
|
4093
4540
|
copy([
|
|
4094
4541
|
'x',
|
|
4095
4542
|
'y',
|
|
4096
4543
|
'rotate',
|
|
4097
4544
|
'scaleX',
|
|
4098
4545
|
'shearX',
|
|
4099
|
-
'scaleY',
|
|
4100
4546
|
'limit',
|
|
4101
4547
|
'fps',
|
|
4102
4548
|
'inertia',
|
|
@@ -4199,6 +4645,7 @@ function buildRigInfo(
|
|
|
4199
4645
|
meshKinds,
|
|
4200
4646
|
meshSoftBones,
|
|
4201
4647
|
deformMayFold,
|
|
4648
|
+
editorRoundTrip: rig.invariants?.editorRoundTrip === true,
|
|
4202
4649
|
meshSlotBudget: rig.invariants?.meshSlots ?? null,
|
|
4203
4650
|
meshTriangleBudget: rig.invariants?.meshTriangles ?? null,
|
|
4204
4651
|
contactDepth,
|