spine-rigc 0.35.0 → 0.36.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/diff.ts CHANGED
@@ -683,6 +683,12 @@ interface AttachmentFact {
683
683
  edgesPresent: boolean | null;
684
684
  /** `<width>x<height>` as stated, or `unstated` — see `attachments.region_size`. */
685
685
  size: string;
686
+ /**
687
+ * The name the runtime gives the attachment: its stated `name`, else its
688
+ * placeholder key (`getValue(map, "name", placeholder)`, `SkeletonJson.js:526`)
689
+ * — see `attachments.runtime_name`.
690
+ */
691
+ runtimeName: string;
686
692
  }
687
693
 
688
694
  function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, AttachmentFact> } {
@@ -718,6 +724,7 @@ function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, A
718
724
  // "has edges" means.
719
725
  edgesPresent: type === 'mesh' ? 'edges' in att : null,
720
726
  size: num(att.width) !== null && num(att.height) !== null ? `${num(att.width)}x${num(att.height)}` : 'unstated',
727
+ runtimeName: str(att.name) ?? attName,
721
728
  });
722
729
  }
723
730
  }
@@ -820,9 +827,61 @@ function diffAttachments(c: Json, r: Json): DiffSection {
820
827
  bm,
821
828
  (x, y) => x.edgesPresent === y.edgesPresent,
822
829
  ),
830
+ // ── issue #796 ───────────────────────────────────────────────────────────
831
+ //
832
+ // The name the RUNTIME gives each attachment — `name` if stated, else the
833
+ // placeholder key — agreed per skin/slot/placeholder. Every measure above is
834
+ // keyed by the placeholder, so a rebuild that renamed every attachment while
835
+ // keeping every key read 1.000 across this whole section: that is what a
836
+ // rebuild did on two production rigs (a stated name respelled as `path`, and
837
+ // `<skin>/<placeholder>` composed over a name the file never stated), and it
838
+ // took a pose oracle comparing `slot.attachment.name` to see it.
839
+ //
840
+ // 🚫 Reported rather than in the mean, by the same test as `mesh_edges`: a
841
+ // name draws no pixel, so no reading of the frames could decide it. It is
842
+ // still a value a consumer reads — `slot.attachment.name` — which is why it
843
+ // is measured at all.
844
+ //
845
+ // ⚠️ Over the keys BOTH sides hold, not over the larger roster, and that is
846
+ // the one departure from `agreement` here. A key one side lacks is already
847
+ // `attachments.names` and `attachments.count`; scored again here it would
848
+ // move this measure on every rename of a key and every dropped attachment,
849
+ // and "the same key answers to another name" — the only thing nothing else
850
+ // reads — would be one term among many. The denominator this reports is the
851
+ // number of keys compared, so a report over few shared keys says so.
852
+ runtimeNames(a.byKey, b.byKey),
823
853
  ]);
824
854
  }
825
855
 
856
+ /**
857
+ * `attachments.runtime_name`: of the skin/slot/placeholder keys both sides
858
+ * hold, how many answer to the same runtime name — see the note at its call.
859
+ */
860
+ function runtimeNames(a: Map<string, AttachmentFact>, b: Map<string, AttachmentFact>): DiffMeasure {
861
+ let shared = 0;
862
+ let agree = 0;
863
+ const differ: string[] = [];
864
+ for (const [key, fact] of a) {
865
+ const other = b.get(key);
866
+ if (other === undefined) continue;
867
+ shared++;
868
+ if (fact.runtimeName === other.runtimeName) agree++;
869
+ else if (differ.length < 3) differ.push(`${key} "${fact.runtimeName}" vs "${other.runtimeName}"`);
870
+ }
871
+ const missed = shared - agree;
872
+ return measure(
873
+ 'attachments.runtime_name',
874
+ 'each attachment both sides hold answers to the same name at runtime (`name` if stated, else its placeholder)',
875
+ agree,
876
+ shared,
877
+ shared === 0
878
+ ? 'no skin/slot/placeholder key is on both sides, so no name was compared'
879
+ : missed === 0
880
+ ? undefined
881
+ : `${missed} renamed: ${differ.join('; ')}${missed > differ.length ? `; …and ${missed - differ.length} more` : ''}`,
882
+ );
883
+ }
884
+
826
885
  interface ConstraintFact {
827
886
  type: string;
828
887
  /** Every bone or slot the constraint names, sorted — its wiring. */
package/src/ingest.ts CHANGED
@@ -47,8 +47,6 @@
47
47
  * spec.
48
48
  */
49
49
  import {
50
- composeSkinAttachmentName,
51
- DEFAULT_SKIN,
52
50
  PHYSICS_COMPONENTS,
53
51
  SLOT_TRACKS as EMITTED_SLOT_TRACKS,
54
52
  SPINE_VERSION,
@@ -681,34 +679,6 @@ function hexToRgb(hex: string): number[] {
681
679
  return [0, 2, 4].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255);
682
680
  }
683
681
 
684
- /**
685
- * Which placeholders more than one skin fills, per slot.
686
- *
687
- * 🔒 Inverts `contestedPlaceholders` + `nameSkinAttachment`: rigc writes an
688
- * attachment `name` exactly where a placeholder is contested, and the rig spec
689
- * has no field for one. So the name is DROPPED and re-derived — which is right
690
- * where the contest survives the round trip, and a loss anywhere else. This is
691
- * what lets that difference be reported rather than assumed.
692
- */
693
- function contestedPlaceholders(skins: readonly unknown[]): Map<string, Set<string>> {
694
- const fillers = new Map<string, Map<string, number>>();
695
- for (const skin of skins) {
696
- for (const [slot, placeholders] of objEntries(obj(skin).attachments)) {
697
- const perSlot = fillers.get(slot) ?? new Map<string, number>();
698
- for (const placeholder of Object.keys(placeholders)) {
699
- perSlot.set(placeholder, (perSlot.get(placeholder) ?? 0) + 1);
700
- }
701
- fillers.set(slot, perSlot);
702
- }
703
- }
704
- const contested = new Map<string, Set<string>>();
705
- for (const [slot, perSlot] of fillers) {
706
- const shared = new Set([...perSlot].filter(([, count]) => count > 1).map(([placeholder]) => placeholder));
707
- if (shared.size) contested.set(slot, shared);
708
- }
709
- return contested;
710
- }
711
-
712
682
  /**
713
683
  * Read a skeleton, write the two specs that rebuild it.
714
684
  *
@@ -791,7 +761,6 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
791
761
  });
792
762
 
793
763
  // -- skins and attachments ------------------------------------------------
794
- const contested = contestedPlaceholders(arr(root.skins));
795
764
  const skins: JsonObject = {};
796
765
  for (const skin of arr(root.skins)) {
797
766
  const entry = obj(skin);
@@ -801,8 +770,6 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
801
770
  for (const [placeholder, att] of Object.entries(placeholders)) {
802
771
  perSlot[placeholder] = ingestAttachment(obj(att), placeholder, boneNames, opts, note, {
803
772
  where: `skin "${nameOf(entry)}" slot "${slot}" attachment "${placeholder}"`,
804
- contested: contested.get(slot)?.has(placeholder) === true,
805
- skin: nameOf(entry),
806
773
  });
807
774
  }
808
775
  table[slot] = perSlot;
@@ -1331,7 +1298,7 @@ function ingestAttachment(
1331
1298
  boneNames: readonly string[],
1332
1299
  opts: IngestOptions,
1333
1300
  note: Note,
1334
- at: { where: string; contested: boolean; skin: string },
1301
+ at: { where: string },
1335
1302
  ): JsonObject {
1336
1303
  // `readAttachment` reads no `type` as `region` (`SkeletonJson:539`), and so
1337
1304
  // does `checkRigSpecKeys`. Both defaults are the parser's, not a guess.
@@ -1347,125 +1314,26 @@ function ingestAttachment(
1347
1314
  // construct the file does not contain (issue #691).
1348
1315
  const linked = (type === 'mesh' || type === 'linkedmesh') && typeof att.source === 'string' && att.source.length > 0;
1349
1316
 
1350
- // The three types the parser gives a texture `path` to, which is exactly the
1351
- // three that call `carryArt` below. `readAttachment` reaches
1352
- // `getValue(map, "path", name)` in the `region` branch (`SkeletonJson.js:529`)
1353
- // and in the shared `mesh`/`linkedmesh` branch (`:560`); `boundingbox`,
1354
- // `path`, `point` and `clipping` are constructed from the name alone and
1355
- // resolve no region at all.
1317
+ // The three types the parser gives a texture `path` to — and reads a
1318
+ // `sequence` on. `readAttachment` reaches `getValue(map, "path", name)` in the
1319
+ // `region` branch (`SkeletonJson.js:529`) and in the shared `mesh`/`linkedmesh`
1320
+ // branch (`:560`); `boundingbox`, `path`, `point` and `clipping` are
1321
+ // constructed from the name alone and resolve no region at all.
1356
1322
  const resolvesRegion = linked || type === 'region' || type === 'mesh';
1357
1323
 
1358
- /**
1359
- * The atlas region a dropped `name` was carrying, which the rebuild has to
1360
- * state as `path` or lose.
1361
- *
1362
- * 🚨 **The name is not bookkeeping — it is the region key** (issue #742).
1363
- * `readAttachment` reads `name = getValue(map, "name", placeholder)` and then
1364
- * `path = getValue(map, "path", name)`, so `path` defaults to the NAME and not
1365
- * to the placeholder. A source that states a `name` and no `path` therefore
1366
- * draws the region that name spells; rigc drops the name (the rig spec has no
1367
- * field for one — see below) and used to write no `path` either, so the
1368
- * rebuild asked the atlas for the PLACEHOLDER. Measured on a forged export
1369
- * whose three attachments state `name` and no `path`: the source loads against
1370
- * its own pack and the rebuild failed `A08_REGION_NAMES_MATCH_ATTACHMENTS`
1371
- * three times and `A00_ROUNDTRIP_PARSE` with it.
1372
- *
1373
- * ⚠️ A name EQUAL to the placeholder resolves the same region either way, so
1374
- * nothing is written for it — the rebuild would otherwise spell a field the
1375
- * source did not, and `A18`'s reference is the source file.
1376
- *
1377
- * 🔑 This is not `at.contested`'s business, and that is the half the card
1378
- * nearly missed. Where a placeholder is contested rigc composes a name of its
1379
- * own and `nameSkinAttachment` pins `path` at the PLACEHOLDER — so a contested
1380
- * source naming its own regions lost them with no finding at all, which is
1381
- * quieter than the uncontested case rather than safer.
1382
- */
1383
- const keptPath =
1384
- att.path === undefined && resolvesRegion && typeof att.name === 'string' && att.name !== placeholder
1385
- ? att.name
1386
- : undefined;
1387
-
1388
- /** What became of the atlas region once the name is gone — the same four shapes wherever the name is lost. */
1389
- const regionFate =
1390
- keptPath !== undefined
1391
- ? `That name was also the ATLAS REGION this attachment resolves, because \`path\` defaults to the name and ` +
1392
- `not to the placeholder (\`SkeletonJson.js:526\`, \`:529\`, \`:560\`) — so the region key is KEPT as ` +
1393
- `"path": ${JSON.stringify(keptPath)} and the name alone is lost`
1394
- : att.path !== undefined
1395
- ? `The region key is the source's own \`path\` ${JSON.stringify(att.path)}, carried unchanged, so the ` +
1396
- 'name alone is lost'
1397
- : resolvesRegion
1398
- ? at.contested
1399
- ? 'The region is the placeholder\'s either way — the compiler pins a composed name\'s `path` at the ' +
1400
- 'placeholder — so the name alone is lost'
1401
- : 'The name is the placeholder itself, so the rebuild resolves the same atlas region and no `path` is ' +
1402
- 'written for it'
1403
- : `An attachment of type ${JSON.stringify(type)} resolves no atlas region — the parser builds it from ` +
1404
- 'the name alone — so there is no region key to keep and the name is the whole loss';
1405
-
1406
- // `name` is DERIVED by rigc — `composeSkinAttachmentName` writes one exactly
1407
- // where a placeholder is contested — so the rig spec has no field for it and
1408
- // it is dropped. Where one skin fills the placeholder every stated name is a
1409
- // loss, because the rebuild writes none.
1410
- if (att.name !== undefined && !at.contested) {
1411
- note(
1412
- 'lossy',
1413
- 'ATTACHMENT_NAME',
1414
- at.where,
1415
- `the attachment states name ${JSON.stringify(att.name)} and only ONE skin fills this placeholder, so rigc ` +
1416
- 'writes no name on the rebuild — it composes "<skin>/<placeholder>" only for a contested placeholder (#541). ' +
1417
- regionFate,
1418
- );
1419
- }
1420
-
1421
- /**
1422
- * 🚨 Where a placeholder IS contested the name is re-derived, and re-derived is
1423
- * not the same as kept (issue #746). The rebuild's attachment answers to what
1424
- * `composeSkinAttachmentName` returns; the source's answers to what it states,
1425
- * or to its placeholder where it states nothing (`readAttachment`'s
1426
- * `getValue(map, "name", placeholder)`, `SkeletonJson.js:526`). Only where
1427
- * those two strings differ is anything renamed, so only there is a line.
1428
- *
1429
- * ⚠️ Comparing is the whole rule, and its cheap neighbour was measured and
1430
- * rejected on #742: firing whenever a contested entry states a name puts a
1431
- * `LOSS` on every contested round trip of rigc's own emits, which state
1432
- * exactly the composed name and rebuild byte for byte.
1433
- *
1434
- * ⚠️ A contested placeholder the DEFAULT skin fills gets no line here: the
1435
- * compiler composes nothing for it — `refuseDefaultSkinContest` refuses the
1436
- * rebuild by name — so a line claiming a composed name would state one no
1437
- * build will ever emit.
1438
- */
1439
- const composed = composeSkinAttachmentName(at.skin, placeholder, at.contested);
1440
- const sourceName = att.name === undefined ? placeholder : att.name;
1441
- if (composed !== null && at.skin !== DEFAULT_SKIN && sourceName !== composed) {
1442
- note(
1443
- 'lossy',
1444
- 'ATTACHMENT_NAME',
1445
- at.where,
1446
- (att.name === undefined
1447
- ? `the attachment states no name, so the source names it by its placeholder ${JSON.stringify(placeholder)} ` +
1448
- '(`SkeletonJson.js:526`)'
1449
- : `the attachment states name ${JSON.stringify(att.name)}`) +
1450
- `, and more than one skin fills this placeholder, so rigc composes the name ${JSON.stringify(composed)} ` +
1451
- `on the rebuild (#541) — the rig spec has no field for a name, so the rebuilt attachment answers to ` +
1452
- `${JSON.stringify(composed)} where the source's answered to ${JSON.stringify(sourceName)}. ` +
1453
- regionFate,
1454
- );
1455
- }
1456
-
1457
1324
  /** The texture side, which the skeleton does not encode. Inverts `buildRigRegion`'s tail. */
1458
1325
  const carryArt = (): void => {
1459
1326
  if (att.path !== undefined) out.path = att.path;
1460
- else if (keptPath !== undefined) out.path = keptPath;
1461
1327
  // `buildRigRegion` writes `path` when the image basename differs from the
1462
- // placeholder, so naming the image after the region this attachment RESOLVES
1463
- // reproduces the same `path` decision AND the same atlas region name. Read
1464
- // off `out.path` rather than off `att.path`, so that the region a dropped
1465
- // `name` was carrying names the loose PNG too — otherwise `path` and `image`
1466
- // would disagree and `attachmentPath` would emit the first while the atlas
1467
- // was packed under the second.
1468
- if (opts.art === 'loose') out.image = `${out.path === undefined ? placeholder : String(out.path)}.png`;
1328
+ // name the attachment carries — its stated `name`, else the placeholder —
1329
+ // so naming the image after the region this attachment RESOLVES
1330
+ // (`path ?? name ?? placeholder`, the parser's own defaults at
1331
+ // `SkeletonJson.js:526`, `:529`, `:560`) reproduces the same `path`
1332
+ // decision AND the same atlas region name.
1333
+ if (opts.art === 'loose') {
1334
+ const region = att.path ?? (typeof att.name === 'string' ? att.name : placeholder);
1335
+ out.image = `${String(region)}.png`;
1336
+ }
1469
1337
  if (att.width !== undefined) out.width = att.width;
1470
1338
  if (att.height !== undefined) out.height = att.height;
1471
1339
  };
@@ -1592,7 +1460,17 @@ function ingestAttachment(
1592
1460
  );
1593
1461
  }
1594
1462
  }
1595
- return out;
1463
+ // 🔑 The attachment's own `name`, carried VERBATIM wherever the source states
1464
+ // one — and nowhere else (issue #796). It is the runtime's `Attachment.name`
1465
+ // (`getValue(map, "name", placeholder)`, `SkeletonJson.js:526`) and, with no
1466
+ // `path`, the region the attachment draws; the rig spec has a field for it
1467
+ // and `compile` writes exactly what that field states, so nothing about it is
1468
+ // lost and nothing is re-derived. A name equal to its placeholder is carried
1469
+ // too: the source spelled it, and the rebuild is held to the source's text.
1470
+ // Put right after `type`, the order `RIG_KEYS` gives the field.
1471
+ if (att.name === undefined) return out;
1472
+ const { type: carriedType, ...rest } = out;
1473
+ return carriedType === undefined ? { name: att.name, ...rest } : { type: carriedType, name: att.name, ...rest };
1596
1474
  }
1597
1475
 
1598
1476
  /**
package/src/rig.ts CHANGED
@@ -575,9 +575,36 @@ export type RigMeshGenerator = RigRingGenerator | RigRibbonGenerator | RigContou
575
575
  /** The four `kind` names a generator may carry, and the order `RIG_KEYS` takes them in. */
576
576
  export const RIG_GENERATOR_KINDS = ['ring', 'ribbon', 'contour', 'grid'] as const;
577
577
 
578
+ /**
579
+ * The attachment's own NAME, as distinct from the placeholder key it is filed
580
+ * under — one field, the same on every attachment type (issue #796).
581
+ *
582
+ * `readAttachment` reads `name = getValue(map, "name", placeholder)`
583
+ * (`SkeletonJson.js:526`) for every type, so this is the runtime's
584
+ * `Attachment.name` — what a consumer reads off `slot.attachment.name` — and,
585
+ * on the three types that draw, the default of `path` (`:529`, `:560`). Absent,
586
+ * the runtime names the attachment by its placeholder.
587
+ *
588
+ * 🔑 Nothing in the format resolves BY it. A skin's table, a slot's setup
589
+ * `attachment`, an attachment or deform timeline and a linked mesh's `source`
590
+ * are all keyed by the placeholder (`:415-418`, `:433`, `:1140`) — measured: a
591
+ * link whose `source` spells its source's name rather than its key throws
592
+ * `Source mesh not found`. So two skins may give one placeholder two
593
+ * attachments of one name, and the runtime keeps both.
594
+ *
595
+ * ⭐ rigc writes this field exactly when the spec states it and never derives
596
+ * one. Until #796 it composed `<skin>/<placeholder>` for a placeholder several
597
+ * skins fill, and a transcribed name had no field to live in — so a rebuild of
598
+ * an editor export answered to other names than its source did, which a
599
+ * consumer reading `attachment.name` can see and no gate could.
600
+ */
601
+ export type RigAttachmentName = string;
602
+
578
603
  /** `SkeletonJson.ts:540-559`. `type` defaults to `region` (`:539`). */
579
604
  export interface RigRegionAttachment {
580
605
  type?: 'region';
606
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
607
+ name?: RigAttachmentName;
581
608
  /** The atlas region to resolve. Defaults to the attachment's own name. */
582
609
  path?: string;
583
610
  /**
@@ -675,6 +702,8 @@ export interface RigMeshBinding {
675
702
 
676
703
  export interface RigMeshAttachment {
677
704
  type: 'mesh';
705
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
706
+ name?: RigAttachmentName;
678
707
  path?: string;
679
708
  image?: string;
680
709
  /** Its length defines `worldVerticesLength`; required with authored geometry. */
@@ -735,8 +764,11 @@ export interface RigMeshAttachment {
735
764
  * under in its skin — and not its `name`.** The resolution is
736
765
  * `skin.getAttachment(sourceSlotIndex, source)` (`SkeletonJson.js:433`), and a
737
766
  * skin's table is keyed by the JSON key `readSkin` iterated (`:415-418`), so a
738
- * contested placeholder rigc gives an attachment `name` of `<skin>/<placeholder>`
739
- * is still found under the placeholder alone.
767
+ * source that states a `name` of its own is still found under its placeholder
768
+ * alone — and a `source` spelling that name throws `Source mesh not found`.
769
+ * Two skins each filling the source's placeholder are told apart by `skin`,
770
+ * never by a name (issue #796, which retired #541's reading that a link
771
+ * resolves its source by name).
740
772
  *
741
773
  * 🚨 **A linked mesh states no geometry of its own, and the parser is silent
742
774
  * about one that does.** The `source` branch returns before `readVertices`
@@ -756,6 +788,8 @@ export interface RigMeshAttachment {
756
788
  */
757
789
  export interface RigLinkedMeshAttachment {
758
790
  type: 'linkedmesh';
791
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
792
+ name?: RigAttachmentName;
759
793
  /** The art this link draws, exactly as a mesh's: its own region. */
760
794
  path?: string;
761
795
  image?: string;
@@ -853,6 +887,8 @@ export interface RigVertexGeometry {
853
887
  */
854
888
  export interface RigBoundingBoxAttachment extends RigVertexGeometry {
855
889
  type: 'boundingbox';
890
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
891
+ name?: RigAttachmentName;
856
892
  }
857
893
 
858
894
  /**
@@ -869,6 +905,8 @@ export interface RigBoundingBoxAttachment extends RigVertexGeometry {
869
905
  */
870
906
  export interface RigClippingAttachment extends RigVertexGeometry {
871
907
  type: 'clipping';
908
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
909
+ name?: RigAttachmentName;
872
910
  /**
873
911
  * The last slot this clip applies to, by name. Absent leaves `endSlot` null,
874
912
  * which is the parser's own encoding for "clip everything after this one".
@@ -915,6 +953,8 @@ export interface RigClippingAttachment extends RigVertexGeometry {
915
953
  */
916
954
  export interface RigPathAttachment extends RigVertexGeometry {
917
955
  type: 'path';
956
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
957
+ name?: RigAttachmentName;
918
958
  /** Default false. When true the last knot joins the first. */
919
959
  closed?: boolean;
920
960
  /**
@@ -1672,21 +1712,21 @@ export const RIG_KEYS = {
1672
1712
  ],
1673
1713
  RigTransformProperty: ['offset', 'to'],
1674
1714
  RigTransformTo: ['offset', 'max', 'scale'],
1675
- RigRegionAttachment: ['type', 'path', 'image', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'width', 'height', 'color', 'sequence'],
1715
+ RigRegionAttachment: ['type', 'name', 'path', 'image', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'width', 'height', 'color', 'sequence'],
1676
1716
  RigMeshAttachment: [
1677
- 'type', 'path', 'image', 'uvs', 'triangles', 'vertices', 'weights', 'boneIndexing', 'hull', 'edges',
1717
+ 'type', 'name', 'path', 'image', 'uvs', 'triangles', 'vertices', 'weights', 'boneIndexing', 'hull', 'edges',
1678
1718
  'width', 'height', 'color', 'generator', 'sequence',
1679
1719
  ],
1680
1720
  RigLinkedMeshAttachment: [
1681
- 'type', 'path', 'image', 'source', 'slot', 'skin', 'timelines', 'width', 'height', 'color', 'sequence',
1721
+ 'type', 'name', 'path', 'image', 'source', 'slot', 'skin', 'timelines', 'width', 'height', 'color', 'sequence',
1682
1722
  // Declared so the refusal can name them — see `RigLinkedMeshAttachment`.
1683
1723
  'uvs', 'triangles', 'vertices', 'weights', 'boneIndexing', 'hull', 'edges', 'generator',
1684
1724
  ],
1685
1725
  RigMeshBinding: ['bone', 'x', 'y', 'weight'],
1686
1726
  RigSequence: ['count', 'start', 'digits', 'setup'],
1687
- RigBoundingBoxAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type'],
1688
- RigClippingAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'end', 'convex', 'inverse'],
1689
- RigPathAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'closed', 'constantSpeed', 'lengths'],
1727
+ RigBoundingBoxAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name'],
1728
+ RigClippingAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name', 'end', 'convex', 'inverse'],
1729
+ RigPathAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name', 'closed', 'constantSpeed', 'lengths'],
1690
1730
  RigRingGenerator: ['kind', 'hull', 'center', 'inner', 'size', 'bias', 'controls'],
1691
1731
  RigRibbonGenerator: ['kind', 'size', 'rows', 'chain'],
1692
1732
  RigContourGenerator: ['kind', 'tolerance', 'margin', 'maxVertices', 'alpha', 'depth', 'soft'],
package/src/timelines.ts CHANGED
@@ -494,12 +494,25 @@ export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
494
494
  timeline: 'damping',
495
495
  field: 'damping',
496
496
  toPose: (v) => v,
497
- poseOk: (v) => v > 0 && v < 1,
497
+ poseOk: (v) => v >= 0 && v <= 1,
498
498
  keyOk: null,
499
499
  inertAtSetup: false,
500
500
  outside: null,
501
- states: 'inside (0, 1)',
502
- statesKeyed: 'inside (0, 1)',
501
+ states: 'inside [0, 1]',
502
+ statesKeyed: 'inside [0, 1]',
503
+ // 🔑 The interval is CLOSED (issue #794). `1 ** x` is 1 and `0 ** x` is 0
504
+ // for every positive exponent, so both ends are finite at every `fps` and
505
+ // both are rigs somebody can mean: 1 holds the jiggle, 0 follows the bone
506
+ // with no overshoot. [measured] through spine-core on the generated physics
507
+ // fixture, 120 steps from `Physics.reset` at 60, 45 and 30 fps under a
508
+ // displacing animation, as a setup value and as a key: every pose value
509
+ // finite, the velocity never 0 at 1 and 0 after every step at 0. And 1 is
510
+ // not only a choice: it is 4.2's own parser default for an omitted
511
+ // `damping` (`SkeletonJson.js:242` in 4.2.120,
512
+ // `getValue(constraintMap, "damping", 1)`; 4.3.13 reads 0.85 at `:308`), so a
513
+ // rig migrated from 4.2 that keys "the default" writes 1 and the editor
514
+ // exports it as it is. The open interval this replaced refused that rig.
515
+ //
503
516
  // ⚠️ The frame-rate half is why the bound cannot be checked by playing a rig
504
517
  // at 60 fps (issue #748): `step` is `1 / fps`, the constraint's own rate, so
505
518
  // the exponent is exactly 1 there and a negative damping only flips the
@@ -508,11 +521,13 @@ export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
508
521
  // steps of the key at 45 and at 120 (exponents 1.3333 and 0.5).
509
522
  why:
510
523
  'the per-step decay is `damping ** (60 * step)`, with `step` = 1 / the constraint\'s `fps`, and every velocity ' +
511
- 'is multiplied by it (`PhysicsConstraint.js:114,148,158,163,210,222,227`), so 1 never decays, above 1 ' +
512
- 'diverges, and 0 kills the velocity outright, at every rate. Below 0 the result depends on `fps`: where ' +
513
- '`60 / fps` is a whole number a negative base stays finite — at 60 fps it is the velocity\'s sign flipped each ' +
514
- 'step, which can look like a jiggle settling — and at any other rate it is a negative number raised to a ' +
515
- 'fractional power, which is NaN (`(-0.5) ** (60 / 45)`), so a rig tried only at 60 fps never shows the failure',
524
+ 'is multiplied by it (`PhysicsConstraint.js:114,148,158,163,210,222,227`), so above 1 every velocity grows on ' +
525
+ 'every step and the offset diverges, at every rate. Below 0 the result depends on `fps`: where `60 / fps` is a ' +
526
+ 'whole number a negative base stays finite — at 60 fps it is the velocity\'s sign flipped each step, which can ' +
527
+ 'look like a jiggle settling — and at any other rate it is a negative number raised to a fractional power, ' +
528
+ 'which is NaN (`(-0.5) ** (60 / 45)`), so a rig tried only at 60 fps never shows the failure. The two ends are ' +
529
+ 'values the runtime plays at every rate: at 1 the velocity never decays, so the jiggle holds for as long as it ' +
530
+ 'runs, and at 0 every velocity is zeroed on every step, so the offset follows the bone with no overshoot',
516
531
  },
517
532
  ];
518
533
 
package/src/types.ts CHANGED
@@ -281,7 +281,7 @@ export type BoneProperty =
281
281
  * export; `PHYSICS_TRACKS` in `compile.ts` carries the argument.
282
282
  *
283
283
  * 🔒 **Four of them have a compile-time range** (issue #610): `mass` must be
284
- * `> 0`, `damping` strictly inside `(0, 1)`, and `mix` and `strength` `0` or
284
+ * `> 0`, `damping` inside the closed `[0, 1]`, and `mix` and `strength` `0` or
285
285
  * more. The bounds are `PHYSICS_POSE_RULES` in `src/timelines.ts` and they are
286
286
  * the runtime's, not a policy — `inertia`, `wind`, `gravity` and the top of
287
287
  * `mix` are bounded nowhere, because the runtime documents nothing for the first
@@ -826,18 +826,19 @@ export interface SpineSlot {
826
826
  *
827
827
  * `readAttachment` reads `const name = getValue(map, "name", placeholder)`
828
828
  * (`SkeletonJson.ts:526`), so an absent field means "the placeholder is also the
829
- * name" — which is what rigc emitted for every attachment until issue #541, and
830
- * what makes several skins' entries under one placeholder **several attachments
831
- * with one name**. spine-core does not care; the Spine editor refuses the import
832
- * outright, naming the section, the attachment and the rule.
829
+ * name". Nothing in the format resolves by it — skins, setup attachments,
830
+ * attachment and deform timelines and a linked mesh's `source` are keyed by the
831
+ * placeholder — so several skins' entries under one placeholder may carry one
832
+ * name, which is what the editor itself exports (issue #796).
833
833
  *
834
- * 🚨 Writing it moves a second field with it. For the two types that carry
834
+ * 🚨 It moves a second field's default with it. For the three types that carry
835
835
  * texture art, `path` defaults to **`name`**, not to the placeholder
836
- * (`:529`, `:559`), so an attachment given a name and no path resolves its region
837
- * at the new name and the atlas lookup misses. `nameSkinAttachment` in
838
- * `compile.ts` is the one place that writes either, and it always writes both.
836
+ * (`:529`, `:559`), so an attachment given a name and no path resolves the region
837
+ * that name spells. rigc emits it exactly when the rig spec states it
838
+ * (`RigAttachmentName` in `rig.ts`); from #541 to #796 it composed
839
+ * `<skin>/<placeholder>` for a placeholder several skins fill, and that is gone.
839
840
  *
840
- * ⚠️ And the **`default` skin may never be one of the skins sharing that
841
+ * ⚠️ And the **`default` skin may never be one of the skins sharing a
841
842
  * placeholder** — a fact about the editor rather than about the format (issue
842
843
  * #567, Spine 4.3.26, round trips 7 and 8), and a `CompileError` rather than a
843
844
  * spelling. The editor's named skins hold *skin placeholders*, a key holding a
@@ -848,8 +849,7 @@ export interface SpineSlot {
848
849
  * makes that attachment's name collide with the named skins' placeholder of the
849
850
  * same name, which the editor refuses at import (trip 8). Both spellings are
850
851
  * measured, so there is no third; `refuseDefaultSkinContest` in `compile.ts` is
851
- * where that lives, and `composeSkinAttachmentName` beside it decides the name
852
- * for the skins that are left.
852
+ * where that lives.
853
853
  */
854
854
  export interface SpineRegionAttachment {
855
855
  name?: string;
@@ -888,7 +888,9 @@ export interface SpineMeshAttachment {
888
888
  type: 'mesh';
889
889
  /** See `SpineRegionAttachment.name` — and it takes `path` with it. */
890
890
  name?: string;
891
+ /** `path` and `color` are written right after `type` — `compile.ts`'s `meshTextureKeys` says where that is measured. */
891
892
  path?: string;
893
+ color?: string;
892
894
  uvs: number[];
893
895
  triangles: number[];
894
896
  /** Weighted encoding: boneCount, (boneIndex, bindX, bindY, weight)*n, repeated. */
@@ -910,7 +912,6 @@ export interface SpineMeshAttachment {
910
912
  /** Nonessential, but they make the mesh budget assertions readable. */
911
913
  width: number;
912
914
  height: number;
913
- color?: string;
914
915
  sequence?: SpineSequence;
915
916
  }
916
917
 
package/src/validate.ts CHANGED
@@ -570,7 +570,11 @@ const SETUP_POSE_SAYS: Record<
570
570
  // back and below 0 one that is pushed away, and the row's `why` — the key's
571
571
  // sentence — quotes the second from the same object.
572
572
  strength: (pose, _animations, rule) => `has strength ${pose.strength}; ${physicsOutsideSays(rule, pose.strength)}`,
573
- damping: (pose) => `has damping ${pose.damping}; outside (0,1) it never settles`,
573
+ // The bound is read off the row, so the setup pose and a key cannot state two
574
+ // intervals; the reason is the two ways out, since the ends are inside (#794).
575
+ damping: (pose, _animations, rule) =>
576
+ `has damping ${pose.damping}; must be ${rule.states} — above 1 every velocity grows on every step, and below 0 ` +
577
+ 'it is NaN at any fps where 60 / fps is not whole',
574
578
  };
575
579
 
576
580
  /**
@@ -3090,8 +3094,10 @@ export function validate(input: ValidateInput): ValidateReport {
3090
3094
  //
3091
3095
  // Every failure mode here is silent. The five component fields default to
3092
3096
  // 0, so a constraint can drive nothing at all; `mix` 0 mutes it; `mass` 0
3093
- // becomes an infinite massInverse; and `damping` >= 1 never settles, which
3094
- // on a mesh-driving bone means the canvas re-rasterises forever.
3097
+ // becomes an infinite massInverse; and `damping` above 1 never settles, which
3098
+ // on a mesh-driving bone means the canvas re-rasterises forever. 1 itself is
3099
+ // inside the bound since issue #794: it never decays, which is finite and a
3100
+ // choice, and what it costs a consumer's frame is the consumer's to judge.
3095
3101
  //
3096
3102
  // 🔑 **Two arms, one criterion.** The second arm below reads every physics
3097
3103
  // TIMELINE key, and it is written against `PHYSICS_POSE_RULES` — the table
@@ -533,7 +533,13 @@ export function diffSummaryLines(reportPath: string): string[] {
533
533
  function checkFigures(reportPath: string): string[] {
534
534
  if (!existsSync(reportPath)) return ['(no check report was written, so nothing was read from one)'];
535
535
  interface Anim {
536
- animation: string;
536
+ /**
537
+ * `null` on the row `check` writes for the SETUP pose (`dir: "setup"`) — which
538
+ * is the only row a rig with no animations gets. Read as a string, that report
539
+ * threw at step 5 on every animation-less build (issue #796's two editor fixtures).
540
+ */
541
+ animation: string | null;
542
+ dir?: string;
537
543
  compared: number;
538
544
  meanMae: number;
539
545
  worstMae: number;
@@ -546,7 +552,7 @@ function checkFigures(reportPath: string): string[] {
546
552
  if (animations.length === 0) return ['(the check report carried no animations — read it before believing this run)'];
547
553
  return animations.map(
548
554
  (a) =>
549
- ` ${a.animation.padEnd(12)} ${a.compared} frame(s) mean MAE ${a.meanMae.toFixed(4)} worst ${a.worstMae.toFixed(4)}` +
555
+ ` ${(a.animation ?? `(${a.dir ?? 'setup'})`).padEnd(12)} ${a.compared} frame(s) mean MAE ${a.meanMae.toFixed(4)} worst ${a.worstMae.toFixed(4)}` +
550
556
  ` worst drift ${a.worstDrift.toFixed(3)}px${a.worstDriftSlot ? ` on ${a.worstDriftSlot} @ f${a.worstDriftFrame}` : ''}`,
551
557
  );
552
558
  }
@@ -844,7 +850,7 @@ function main(): void {
844
850
  {
845
851
  interface ImagesProbe {
846
852
  skeleton?: { images?: string };
847
- skins?: Array<{ attachments?: Record<string, Record<string, { type?: string; path?: string }>> }>;
853
+ skins?: Array<{ attachments?: Record<string, Record<string, { type?: string; name?: string; path?: string }>> }>;
848
854
  }
849
855
  const probe = JSON.parse(readFileSync(source, 'utf8')) as ImagesProbe;
850
856
  const declared = probe.skeleton?.images;
@@ -858,7 +864,10 @@ function main(): void {
858
864
  for (const slot of Object.values(skin.attachments ?? {})) {
859
865
  for (const [placeholder, att] of Object.entries(slot)) {
860
866
  if (att.type !== undefined && att.type !== 'mesh') continue;
861
- wanted.add(att.path ?? placeholder);
867
+ // The parser's own defaults: `path`, else the attachment's `name`,
868
+ // else its placeholder (`SkeletonJson.js:526`, `:529`, `:560`) — a
869
+ // stated `name` is the file the editor looks for (issue #796).
870
+ wanted.add(att.path ?? att.name ?? placeholder);
862
871
  }
863
872
  }
864
873
  }