spine-rigc 2.0.2 → 2.1.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/NOTICE.md +2 -2
- package/README.md +9 -7
- package/docs/AUTHORING.md +104 -14
- package/docs/FACE.md +1 -1
- package/docs/INGEST.md +35 -26
- package/package.json +1 -1
- package/src/assertions/bodies/a06.ts +19 -53
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/emitted/index.ts +1 -1
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/kinds.ts +6 -3
- package/src/assertions/model/index.ts +3 -1
- package/src/assertions/reasons.ts +2 -0
- package/src/atlas.ts +712 -11
- package/src/cli/shared.ts +55 -22
- package/src/compile.ts +4 -1
- package/src/diff.ts +327 -5
- package/src/validate.ts +98 -7
- package/tools/editor_roundtrip.ts +75 -1
package/src/validate.ts
CHANGED
|
@@ -102,6 +102,7 @@ import { a22MeshUvsInUnitRange } from './assertions/bodies/a22.ts';
|
|
|
102
102
|
import { a38SkinMembersAreSkinRequired } from './assertions/bodies/a38.ts';
|
|
103
103
|
import { a06AtlasPageSizeMatchesPng } from './assertions/bodies/a06.ts';
|
|
104
104
|
import { a19OverlayPngsHaveAlpha } from './assertions/bodies/a19.ts';
|
|
105
|
+
import { a49PackedFootprintsDoNotOverlap } from './assertions/bodies/a49.ts';
|
|
105
106
|
import { a27RegionNameMatchesPageFilename } from './assertions/bodies/a27.ts';
|
|
106
107
|
import type { MeshEntry as MeshAttachmentEntry, MeshFacts } from './assertions/facts/mesh_attachments.ts';
|
|
107
108
|
import type { ClipEnd, PolygonEntry, PolygonFacts } from './assertions/facts/vertex_polygons.ts';
|
|
@@ -3200,6 +3201,13 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3200
3201
|
// that does not ask — the reader opted in, and the message is where they find
|
|
3201
3202
|
// out what they opted into.
|
|
3202
3203
|
check('A19_OVERLAY_PNGS_HAVE_ALPHA', () => a19OverlayPngsHaveAlpha(verdicts, { atlas }, spineStage(skeletonData), { regionAttachments }, input));
|
|
3204
|
+
// Two regions on one page whose rectangles overlap and whose footprints do
|
|
3205
|
+
// too — a mesh's hull, else the rectangle (issue #1099). The clause A06 held
|
|
3206
|
+
// about two rectangles over the same texels, read over what each region
|
|
3207
|
+
// draws; the argument and the footprint are `./assertions/bodies/a49.ts` and
|
|
3208
|
+
// `./assertions/footprints.ts`. The meshes are the loaded skeleton's, and
|
|
3209
|
+
// where it did not load every footprint is the rectangle A06 read.
|
|
3210
|
+
check('A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP', () => a49PackedFootprintsDoNotOverlap(verdicts, { atlas }, spineRegionJoins(input.atlasText, raw), skeletonData === null ? null : loadedMeshFacts()));
|
|
3203
3211
|
|
|
3204
3212
|
// -------------------------------------------------------------------------
|
|
3205
3213
|
// Archetype assertions — the invariants the RIG declares about itself.
|
|
@@ -3466,10 +3474,70 @@ const NOT_A_VALUE_IN_THE_FILE: Record<string, string> = {
|
|
|
3466
3474
|
index: 'the array position this walk already iterates by name',
|
|
3467
3475
|
timelineIds: 'derived from the timelines',
|
|
3468
3476
|
timelineSlots: 'derived from the skin',
|
|
3469
|
-
properties: 'derived from the constraint',
|
|
3470
3477
|
propertyIds: 'carries an attachment id, which is a counter (see above)',
|
|
3471
3478
|
};
|
|
3472
3479
|
|
|
3480
|
+
/**
|
|
3481
|
+
* The keys above that ARE a value the file carries on these owners, by the
|
|
3482
|
+
* owner's class name (issue #1084).
|
|
3483
|
+
*
|
|
3484
|
+
* The skip list is keyed by field name alone, and four of its names are also
|
|
3485
|
+
* the name of a field the parser reads off the file on a different class. Each
|
|
3486
|
+
* owner below was found by walking the parsed form of the twelve editor exports
|
|
3487
|
+
* and the seven gallery builds for every class carrying a key the list names,
|
|
3488
|
+
* and each field was planted and posed before it was let back in:
|
|
3489
|
+
*
|
|
3490
|
+
* - `a`, `b` — a `Color`'s alpha and blue channels, which share their names with
|
|
3491
|
+
* two entries of a bone's world matrix. Every slot colour, dark colour, bone
|
|
3492
|
+
* colour and attachment colour lost half its channels to that: a slot's alpha
|
|
3493
|
+
* `ff` → `80` moved no value at all, and posed, it changes every slot row.
|
|
3494
|
+
* - `local` — a slider's `local` flag, which shares its name with a bone pose's
|
|
3495
|
+
* derived `local`. Flipped on `gallery/look`'s `yaw`, it moves the posed bones.
|
|
3496
|
+
* - `offsets` — a transform constraint's six offsets (`rotation`, `x`, `y`,
|
|
3497
|
+
* `scaleX`, `scaleY`, `shearY` in the file), which share their name with a
|
|
3498
|
+
* sequence's region offsets. A `y` offset moved on `6-arcs-pro`'s `tail`
|
|
3499
|
+
* moved no value and moves the posed bones.
|
|
3500
|
+
*
|
|
3501
|
+
* A fifth entry, `properties` (*derived from the constraint*), is gone rather
|
|
3502
|
+
* than excepted: the one class measured carrying it is the transform constraint,
|
|
3503
|
+
* where it is the file's from/to map, so the reason was not true of anything.
|
|
3504
|
+
*
|
|
3505
|
+
* ⚠️ An exception rather than a rewrite of the list: the list's design — a
|
|
3506
|
+
* field the parser starts reading is compared the day it starts — is right,
|
|
3507
|
+
* and what was wrong was that a name stood for every class carrying it.
|
|
3508
|
+
*/
|
|
3509
|
+
const VALUE_ON_THESE_OWNERS: Record<string, readonly string[]> = {
|
|
3510
|
+
a: ['Color'],
|
|
3511
|
+
b: ['Color'],
|
|
3512
|
+
local: ['SliderData'],
|
|
3513
|
+
offsets: ['TransformConstraintData'],
|
|
3514
|
+
};
|
|
3515
|
+
|
|
3516
|
+
/** Whether the walk leaves `key` out on an object of class `owner`. */
|
|
3517
|
+
function notAValue(owner: string, key: string): boolean {
|
|
3518
|
+
return key in NOT_A_VALUE_IN_THE_FILE && !(VALUE_ON_THESE_OWNERS[key] ?? []).includes(owner);
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
/**
|
|
3522
|
+
* The class an object was parsed into when that class is one of several sharing
|
|
3523
|
+
* a shape — it extends another class — or `null`.
|
|
3524
|
+
*
|
|
3525
|
+
* Reflection reads fields, and two classes with the same fields are the same
|
|
3526
|
+
* object to it: a slider reading `rotate` holds a `FromRotate` and one reading
|
|
3527
|
+
* `x` a `FromX`, both `{ offset, to }`, so moving a slider to another property
|
|
3528
|
+
* moved no value (issue #1084). Measured over the parsed corpus, the only
|
|
3529
|
+
* unnamed objects the walk expands whose class extends another are the
|
|
3530
|
+
* transform and slider property classes (`From*`, `To*`); a pose, a colour and a
|
|
3531
|
+
* sequence are classes of their own, so this adds nothing under them.
|
|
3532
|
+
*/
|
|
3533
|
+
function sharedShapeKind(obj: object): string | null {
|
|
3534
|
+
const proto = Object.getPrototypeOf(obj) as object | null;
|
|
3535
|
+
if (proto === null || proto === Object.prototype) return null;
|
|
3536
|
+
const parent = Object.getPrototypeOf(proto) as object | null;
|
|
3537
|
+
if (parent === null || parent === Object.prototype) return null;
|
|
3538
|
+
return (obj.constructor as { name?: string } | undefined)?.name ?? null;
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3473
3541
|
/** How deep a chain of unnamed objects may go before the walk says so and stops. */
|
|
3474
3542
|
const VALUE_WALK_DEPTH = 10;
|
|
3475
3543
|
|
|
@@ -3501,7 +3569,24 @@ function pushValue(value: unknown, path: string, out: SkeletonValue[], depth: nu
|
|
|
3501
3569
|
if (typeof value === 'function') return;
|
|
3502
3570
|
if (ArrayBuffer.isView(value) || Array.isArray(value)) {
|
|
3503
3571
|
const list = value as ArrayLike<unknown>;
|
|
3504
|
-
|
|
3572
|
+
// A list whose every entry is an unnamed object of its own shared-shape
|
|
3573
|
+
// class — a transform's `properties` and each one's `to` — is keyed by those
|
|
3574
|
+
// classes rather than by position: the file writes them as an object keyed
|
|
3575
|
+
// by property name, any writer may key it in its own order, and the
|
|
3576
|
+
// parser's list follows that order. By position, the same map written
|
|
3577
|
+
// backwards would read as every property moved (issue #1084).
|
|
3578
|
+
const kinds: string[] = [];
|
|
3579
|
+
for (let i = 0; i < list.length; i++) {
|
|
3580
|
+
const entry = list[i];
|
|
3581
|
+
const kind =
|
|
3582
|
+
typeof entry === 'object' && entry !== null && typeof (entry as Record<string, unknown>).name !== 'string'
|
|
3583
|
+
? sharedShapeKind(entry)
|
|
3584
|
+
: null;
|
|
3585
|
+
if (kind === null || kinds.includes(kind)) break;
|
|
3586
|
+
kinds.push(kind);
|
|
3587
|
+
}
|
|
3588
|
+
const byKind = list.length > 0 && kinds.length === list.length;
|
|
3589
|
+
for (let i = 0; i < list.length; i++) pushValue(list[i], `${path}/${byKind ? kinds[i] : i}`, out, depth + 1);
|
|
3505
3590
|
return;
|
|
3506
3591
|
}
|
|
3507
3592
|
if (typeof value !== 'object') return;
|
|
@@ -3514,8 +3599,12 @@ function pushValue(value: unknown, path: string, out: SkeletonValue[], depth: nu
|
|
|
3514
3599
|
out.push({ path, value: '(deeper than the walk goes)' });
|
|
3515
3600
|
return;
|
|
3516
3601
|
}
|
|
3602
|
+
const owner = (obj.constructor as { name?: string } | undefined)?.name ?? '';
|
|
3603
|
+
// The class, where it is the one thing the fields cannot say — see `sharedShapeKind`.
|
|
3604
|
+
const kind = depth > 0 ? sharedShapeKind(obj) : null;
|
|
3605
|
+
if (kind !== null) out.push({ path: `${path}/kind`, value: kind });
|
|
3517
3606
|
for (const key of Object.keys(obj).sort()) {
|
|
3518
|
-
if (key
|
|
3607
|
+
if (notAValue(owner, key)) continue;
|
|
3519
3608
|
pushValue(obj[key], `${path}/${key}`, out, depth + 1);
|
|
3520
3609
|
}
|
|
3521
3610
|
}
|
|
@@ -3570,7 +3659,9 @@ function timelineKey(timeline: Timeline, data: ReturnType<SkeletonJson['readSkel
|
|
|
3570
3659
|
* and setup attachment, every attachment in every skin (a region's offsets,
|
|
3571
3660
|
* rotation, scale and size; a mesh's vertices, weights, `regionUVs`,
|
|
3572
3661
|
* triangles, hull and edges; a bounding box's, path's and clipping shape's
|
|
3573
|
-
* vertices), every constraint's pose and
|
|
3662
|
+
* vertices), every constraint's pose, flags and modes — a transform's offsets
|
|
3663
|
+
* and its property map, each entry by its class, and a slider's property by its
|
|
3664
|
+
* class (issue #1084) — every event's payload, and for
|
|
3574
3665
|
* every timeline every frame — time and values, from the runtime's own
|
|
3575
3666
|
* `getFrameEntries()` — its curve type and Bezier samples, and its deform
|
|
3576
3667
|
* vertices, attachment names, draw orders and event payloads.
|
|
@@ -3605,7 +3696,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
|
|
|
3605
3696
|
const at = `bones/${bone.name}`;
|
|
3606
3697
|
const rec = bone as unknown as Record<string, unknown>;
|
|
3607
3698
|
for (const key of Object.keys(rec).sort()) {
|
|
3608
|
-
if (key
|
|
3699
|
+
if (notAValue('BoneData', key) || key === 'name') continue;
|
|
3609
3700
|
pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
|
|
3610
3701
|
}
|
|
3611
3702
|
}
|
|
@@ -3613,7 +3704,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
|
|
|
3613
3704
|
const at = `slots/${slot.name}`;
|
|
3614
3705
|
const rec = slot as unknown as Record<string, unknown>;
|
|
3615
3706
|
for (const key of Object.keys(rec).sort()) {
|
|
3616
|
-
if (key
|
|
3707
|
+
if (notAValue('SlotData', key) || key === 'name') continue;
|
|
3617
3708
|
pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
|
|
3618
3709
|
}
|
|
3619
3710
|
}
|
|
@@ -3681,7 +3772,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
|
|
|
3681
3772
|
}
|
|
3682
3773
|
const rec = timeline as unknown as Record<string, unknown>;
|
|
3683
3774
|
for (const field of Object.keys(rec).sort()) {
|
|
3684
|
-
if (
|
|
3775
|
+
if (notAValue(timeline.constructor?.name ?? '', field) || field === 'frames') continue;
|
|
3685
3776
|
// The owner is in the path already; comparing the index as well would
|
|
3686
3777
|
// report one reordering twice, in a measure that is not about order.
|
|
3687
3778
|
if (field === 'boneIndex' || field === 'slotIndex' || field === 'constraintIndex') continue;
|
|
@@ -308,6 +308,61 @@ interface Ran {
|
|
|
308
308
|
timedOut: boolean;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
/**
|
|
312
|
+
* The indices of the lines in one stream of editor output that carry the
|
|
313
|
+
* licence holder, and nothing else (issue #1082).
|
|
314
|
+
*
|
|
315
|
+
* Measured on a licensed 4.3 editor on macOS 2026-10-02, through this tool, on
|
|
316
|
+
* four failing calls. Every one exited 1, wrote everything to stdout and nothing
|
|
317
|
+
* to stderr, and printed one of two shapes:
|
|
318
|
+
*
|
|
319
|
+
* an input path that does not exist (import, and export) — refused by the
|
|
320
|
+
* LAUNCHER before the editor starts, and no licence line at all:
|
|
321
|
+
*
|
|
322
|
+
* the launcher's banner, the copyright line, the operating system
|
|
323
|
+
* (blank) `Parameter: --input <path>` (blank)
|
|
324
|
+
* `ERROR: Input path does not exist:`
|
|
325
|
+
* the path
|
|
326
|
+
*
|
|
327
|
+
* a file the editor cannot read (a skeleton JSON naming a parent bone it does
|
|
328
|
+
* not declare; a `.spine` that is not a project) — the editor starts, names
|
|
329
|
+
* itself and its licence holder, and then the failure:
|
|
330
|
+
*
|
|
331
|
+
* the launcher's banner, the copyright line, the operating system
|
|
332
|
+
* `Starting: Spine <x.y.z> Professional`
|
|
333
|
+
* `Spine <x.y.z> Professional`
|
|
334
|
+
* `Licensed to: <name> <<e-mail>>` <- ONE line, withheld
|
|
335
|
+
* import: `Project import: …`, `ERROR: Unable to import skeleton.`, then
|
|
336
|
+
* export: `ERROR: Unable to export.`, then
|
|
337
|
+
* `[error] …` lines, a stack, and `Cause: …` lines
|
|
338
|
+
*
|
|
339
|
+
* 🚨 So on a failure the block is one line, not the three `--version` prints
|
|
340
|
+
* (`Licensed to:` alone, then the name, then the e-mail — issue #1077), and
|
|
341
|
+
* the lines straight after it are the editor's own error: a rule that took
|
|
342
|
+
* "the header and the two lines after it" would have quoted the licence holder
|
|
343
|
+
* not at all and the reason for the failure not at all either.
|
|
344
|
+
*
|
|
345
|
+
* ⚠️ Both shapes are held, each by what bounds it. A header with a value on the
|
|
346
|
+
* same line is that one line. A bare header takes the two lines after it only
|
|
347
|
+
* when the second of them is an e-mail (`@`), which is the `--version` shape;
|
|
348
|
+
* anything else after a bare header is the editor talking and is quoted.
|
|
349
|
+
*/
|
|
350
|
+
function licenceLines(lines: readonly string[]): Set<number> {
|
|
351
|
+
const withheld = new Set<number>();
|
|
352
|
+
for (let i = 0; i < lines.length; i++) {
|
|
353
|
+
const header = /^[ \t]*Licensed to:(.*)$/.exec(lines[i]);
|
|
354
|
+
if (header === null) continue;
|
|
355
|
+
withheld.add(i);
|
|
356
|
+
if (header[1].trim() !== '') continue;
|
|
357
|
+
if (i + 2 < lines.length && lines[i + 2].includes('@')) {
|
|
358
|
+
withheld.add(i + 1);
|
|
359
|
+
withheld.add(i + 2);
|
|
360
|
+
i += 2;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return withheld;
|
|
364
|
+
}
|
|
365
|
+
|
|
311
366
|
/**
|
|
312
367
|
* Everything the editor printed on a step that did not do what it was for.
|
|
313
368
|
*
|
|
@@ -336,6 +391,11 @@ interface Ran {
|
|
|
336
391
|
* softening the verdict. What it prints is the editor's own words, quoted and
|
|
337
392
|
* attributed to the stream they came off, and never rewritten — a harness that
|
|
338
393
|
* summarised them would be the same defect with a smaller radius.
|
|
394
|
+
*
|
|
395
|
+
* 🚨 With one exception, which is not the editor's words but the licence
|
|
396
|
+
* holder's (issue #1082): the licence line, which `licenceLines` names and
|
|
397
|
+
* which is replaced by one line saying it was withheld. Every other line is
|
|
398
|
+
* quoted as printed, in order.
|
|
339
399
|
*/
|
|
340
400
|
function editorSaid(ran: Ran): string[] {
|
|
341
401
|
const lines: string[] = [];
|
|
@@ -346,7 +406,21 @@ function editorSaid(ran: Ran): string[] {
|
|
|
346
406
|
const body = text.replace(/\s+$/, '');
|
|
347
407
|
if (body === '') continue;
|
|
348
408
|
lines.push(` the editor's ${stream}:`);
|
|
349
|
-
|
|
409
|
+
const printed = body.split('\n');
|
|
410
|
+
const withheld = licenceLines(printed);
|
|
411
|
+
for (let i = 0; i < printed.length; i++) {
|
|
412
|
+
if (!withheld.has(i)) {
|
|
413
|
+
lines.push(` | ${printed[i]}`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
// One note per run of withheld lines, in the place they were printed, so
|
|
417
|
+
// the quotation still reads in the editor's order and the omission is
|
|
418
|
+
// stated rather than left to look like an editor that said less.
|
|
419
|
+
let span = 1;
|
|
420
|
+
while (withheld.has(i + span)) span++;
|
|
421
|
+
lines.push(` ~ ${span} line(s) withheld here: the editor's licence block names the licence holder (issue #1082)`);
|
|
422
|
+
i += span - 1;
|
|
423
|
+
}
|
|
350
424
|
}
|
|
351
425
|
// Silence is a finding too, and it has to be stated rather than left to look
|
|
352
426
|
// like a harness that forgot to print. The card above is what an unstated one
|