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/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
- for (let i = 0; i < list.length; i++) pushValue(list[i], `${path}/${i}`, out, depth + 1);
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 in NOT_A_VALUE_IN_THE_FILE) continue;
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 flags, every event's payload, and for
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 in NOT_A_VALUE_IN_THE_FILE || key === 'name') continue;
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 in NOT_A_VALUE_IN_THE_FILE || key === 'name') continue;
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 (field in NOT_A_VALUE_IN_THE_FILE || field === 'frames') continue;
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
- for (const line of body.split('\n')) lines.push(` | ${line}`);
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