spine-rigc 0.22.2 → 0.24.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/compile.ts CHANGED
@@ -41,6 +41,7 @@ import { parseJsonWithPosition } from './json-position.ts';
41
41
  import { nearMisses } from './keys.ts';
42
42
  import { parseMotionSpec } from './motion.ts';
43
43
  import {
44
+ declaresNoStage,
44
45
  parseRigSpec,
45
46
  RIG_FROM_PROPERTIES,
46
47
  RIG_PATH_POSITION_MODES,
@@ -84,13 +85,14 @@ import {
84
85
  import { Plate, readPlate } from '../tools/plate.ts';
85
86
  import {
86
87
  extractRegion,
88
+ pageFootprint,
87
89
  parseAtlasText,
88
90
  rewritePageNames,
89
91
  writeAtlasText,
90
92
  type AtlasRegion,
91
93
  type ParsedAtlas,
92
94
  } from './atlas.ts';
93
- import { KEY_TIME_EPSILON } from './timelines.ts';
95
+ import { KEY_TIME_EPSILON, physicsKeyRefusal, physicsRuleFor, type PhysicsPoseRule } from './timelines.ts';
94
96
  import { evaluateDeformTransform } from './deformgen.ts';
95
97
  import { evaluateTrackDerive, TRACK_DERIVE_PROJECTIONS, type TrackDeriveMember } from './trackgen.ts';
96
98
  import {
@@ -731,6 +733,28 @@ function rgbaHex(v: number[]): string {
731
733
  return v.map(channelHex).join('');
732
734
  }
733
735
 
736
+ /**
737
+ * One timeline a `MotionValueTrack` can name: the JSON fields a key carries, the
738
+ * per-key default the parser uses for each, and — where the runtime has one — the
739
+ * bound each field is held to.
740
+ *
741
+ * ⚠️ `bounds` is parallel to `fields`, not keyed by name, because a shape's
742
+ * channels are positional everywhere else in this file (a curve array indexes by
743
+ * channel) and a second addressing scheme for one table is how the two go out of
744
+ * step. `null` in a slot is "the runtime bounds this field nowhere", which is the
745
+ * value for every field of every shape but four of `PHYSICS_TRACKS`'.
746
+ *
747
+ * This is the `compileValueTrack` equivalent of `ConstraintTimelineShape.range`,
748
+ * and the reason it took a change rather than a table entry is that the
749
+ * ik/transform pair never shared this type: they carry named fields per key and
750
+ * go through `compileConstraintTrack`, where `range` already lived (issue #610).
751
+ */
752
+ interface ValueTrackShape {
753
+ fields: string[];
754
+ identity: number[];
755
+ bounds?: Array<PhysicsPoseRule | null>;
756
+ }
757
+
734
758
  /**
735
759
  * Bone timeline shapes: which JSON fields a key carries, and their defaults.
736
760
  *
@@ -738,7 +762,7 @@ function rgbaHex(v: number[]): string {
738
762
  * setup value, and `scale` defaults to 1 while `translate` defaults to 0. Emit
739
763
  * `x: 0` on a scale key and the bone collapses to nothing, silently.
740
764
  */
741
- const BONE_TRACKS: Record<string, { fields: string[]; identity: number[] }> = {
765
+ const BONE_TRACKS: Record<string, ValueTrackShape> = {
742
766
  translate: { fields: ['x', 'y'], identity: [0, 0] },
743
767
  translatex: { fields: ['value'], identity: [0] },
744
768
  translatey: { fields: ['value'], identity: [0] },
@@ -752,13 +776,45 @@ const BONE_TRACKS: Record<string, { fields: string[]; identity: number[] }> = {
752
776
  };
753
777
 
754
778
  /**
755
- * Physics timelines. `mix` is the constraint's authority; `reset` is an event
756
- * with no value — one key at the entry frame stops the constraint from flying
757
- * in from whatever pose the previous animation left — solved in DATA rather
758
- * than in caller glue.
779
+ * Physics timelines — the eight `SkeletonJson`'s physics branch reads
780
+ * (`SkeletonJson.js:1063-1094`), in the order it reads them. `mix` is the
781
+ * constraint's authority; `reset` is an event with no value — one key at the
782
+ * entry frame stops the constraint from flying in from whatever pose the
783
+ * previous animation left — solved in DATA rather than in caller glue.
784
+ *
785
+ * 🚨 **`identity` here is the PER-KEY default, and for six of the eight it is
786
+ * not the constraint's own default.** The parser sets `defaultValue = 0` at
787
+ * `:1062` and only `mix` reassigns it (`:1090`), so an `inertia` key that omits
788
+ * `value` reads **0** — not the 0.5 that `:306` gives a constraint that states
789
+ * no `inertia`. Two different tables of defaults sit forty lines apart in one
790
+ * file (`PHYSICS_PARAMS` above holds the other one), and reading the setup
791
+ * column into this one would emit a `damping` timeline whose omitted keys mean
792
+ * 0.85 to the author and 0 to the runtime. Nothing here depends on the number,
793
+ * because `compileValueTrack` writes every field explicitly — it is recorded
794
+ * because a reader checking rigc against the parser will trip over it.
795
+ *
796
+ * ⚠️ `mass` is the one whose keyed value is not what the runtime stores:
797
+ * `PhysicsConstraintMassTimeline.set` is `pose.massInverse = 1 / value`
798
+ * (`Animation.js:2132-2145`, "The timeline values are not inverted"), so the
799
+ * key states a mass and the pose holds its reciprocal. A `mass` key of 0 is an
800
+ * infinite `massInverse`, and `bounds` below is what refuses it here.
801
+ *
802
+ * ⭐ **`bounds` is not a second opinion about the same numbers.** Each entry is
803
+ * a `PHYSICS_POSE_RULES` row — the one table `A23` judges a setup pose with —
804
+ * and the rule is applied to the POSE FIELD the key becomes rather than to the
805
+ * key, so `mass` is judged as `1 / value` on both sides of the tool. Four of the
806
+ * seven have a rule and three deliberately do not: the runtime bounds `inertia`,
807
+ * `wind` and `gravity` nowhere, and inventing one would refuse correct data
808
+ * (issue #610 — the corpus keys `wind` negative on all 48 of its wind keys).
759
809
  */
760
- const PHYSICS_TRACKS: Record<string, { fields: string[]; identity: number[] }> = {
761
- mix: { fields: ['value'], identity: [1] },
810
+ const PHYSICS_TRACKS: Record<string, ValueTrackShape> = {
811
+ inertia: { fields: ['value'], identity: [0], bounds: [physicsRuleFor('inertia') ?? null] },
812
+ strength: { fields: ['value'], identity: [0], bounds: [physicsRuleFor('strength') ?? null] },
813
+ damping: { fields: ['value'], identity: [0], bounds: [physicsRuleFor('damping') ?? null] },
814
+ mass: { fields: ['value'], identity: [0], bounds: [physicsRuleFor('mass') ?? null] },
815
+ wind: { fields: ['value'], identity: [0], bounds: [physicsRuleFor('wind') ?? null] },
816
+ gravity: { fields: ['value'], identity: [0], bounds: [physicsRuleFor('gravity') ?? null] },
817
+ mix: { fields: ['value'], identity: [1], bounds: [physicsRuleFor('mix') ?? null] },
762
818
  reset: { fields: [], identity: [] },
763
819
  };
764
820
 
@@ -776,7 +832,7 @@ const PHYSICS_TRACKS: Record<string, { fields: string[]; identity: number[] }> =
776
832
  * out — `compileValueTrack` never omits a field, which is what keeps "the author
777
833
  * wrote mixY" and "mixY happened to equal mixX" from emitting the same file.
778
834
  */
779
- const PATH_TRACKS: Record<string, { fields: string[]; identity: number[] }> = {
835
+ const PATH_TRACKS: Record<string, ValueTrackShape> = {
780
836
  position: { fields: ['value'], identity: [0] },
781
837
  spacing: { fields: ['value'], identity: [0] },
782
838
  mix: { fields: ['mixRotate', 'mixX', 'mixY'], identity: [1, 1, 1] },
@@ -792,7 +848,7 @@ const PATH_TRACKS: Record<string, { fields: string[]; identity: number[] }> = {
792
848
  * a copy of a neighbour, and a reader checking rigc against the parser will trip
793
849
  * over it.
794
850
  */
795
- const SLIDER_TRACKS: Record<string, { fields: string[]; identity: number[] }> = {
851
+ const SLIDER_TRACKS: Record<string, ValueTrackShape> = {
796
852
  time: { fields: ['value'], identity: [1] },
797
853
  mix: { fields: ['value'], identity: [1] },
798
854
  };
@@ -1150,8 +1206,38 @@ function skeletonImagesPath(
1150
1206
  if (declared !== undefined) return declared;
1151
1207
  if (partDirs.size !== 1) return undefined;
1152
1208
  const [partsDir] = partDirs;
1153
- const rel = relative(outDir, partsDir).split('\\').join('/');
1154
- if (rel === '') return self;
1209
+ const spelled = relativeImagesPath(outDir, partsDir);
1210
+ // `./` is the one spelling the editor collapses to nothing (see above), so
1211
+ // "the parts are in this very directory" is said the way the editor keeps.
1212
+ // That substitution is the ONLY thing this path does that a rig spec's own
1213
+ // `images` does not, which is why the rest of the spelling is one function.
1214
+ return spelled === './' ? self : spelled;
1215
+ }
1216
+
1217
+ /**
1218
+ * An images directory spelled as a path from the directory of the file that
1219
+ * names it — the one convention this repository has for saying where the parts
1220
+ * are, in one function rather than in each of its callers (issue #595).
1221
+ *
1222
+ * Three rules, and each is load-bearing: POSIX separators, so two checkouts of
1223
+ * one tree emit one file and `A18` holds; a trailing `/`, as the format page's
1224
+ * own example (`"./images/"`) spells it; and `./` on a descendant, so the value
1225
+ * reads as a relative path rather than as a bare name.
1226
+ *
1227
+ * ⚠️ `./` for "the very directory the file is in" is returned rather than
1228
+ * decided here, because who reads the field differs. `skeletonImagesPath`
1229
+ * replaces it — the editor was measured collapsing a literal `./` to no path at
1230
+ * all (issue #370) — while a rig spec's own `images` is read by rigc, which
1231
+ * resolves `./` against the spec's own directory and finds the parts.
1232
+ *
1233
+ * Both callers pass two ABSOLUTE directories, and `relative` reads neither of
1234
+ * them off disk — which is what lets `cli.ts` spell a rig spec's `images` for
1235
+ * `ingest` here, rather than `src/ingest.ts` growing a notion of an output
1236
+ * directory it otherwise has none of.
1237
+ */
1238
+ export function relativeImagesPath(from: string, to: string): string {
1239
+ const rel = relative(from, to).split('\\').join('/');
1240
+ if (rel === '') return './';
1155
1241
  return rel.startsWith('..') ? `${rel}/` : `./${rel}/`;
1156
1242
  }
1157
1243
 
@@ -1321,9 +1407,9 @@ function resolveFromAtlas(
1321
1407
  `region "${region}" sits on page "${found.page.name}" of ${atlas.path}, which is not on disk at ${absPath}`,
1322
1408
  );
1323
1409
  }
1324
- const turned = found.region.degrees === 90 || found.region.degrees === 270;
1325
- const rectW = turned ? found.region.height : found.region.width;
1326
- const rectH = turned ? found.region.width : found.region.height;
1410
+ // The rectangle ON THE PAGE, which at a quarter turn is not the one `bounds:`
1411
+ // states — `pageFootprint` owns that derivation for every reader of it.
1412
+ const { width: rectW, height: rectH } = pageFootprint(found.region);
1327
1413
  if (
1328
1414
  found.region.x < 0 ||
1329
1415
  found.region.y < 0 ||
@@ -1363,6 +1449,13 @@ function resolveFromAtlas(
1363
1449
  * own, only a rectangle of somebody's page. `extractRegion` lifts the drawing
1364
1450
  * back out, so the generator sees the same grid either way and its output does
1365
1451
  * not depend on how the art was delivered.
1452
+ *
1453
+ * ⭐ *Either way* was not true for a pack that turns a region until issue #570:
1454
+ * `extractRegion` refused a `rotate: 90`, so how the art was delivered decided
1455
+ * whether a measurement could be taken at all — on a foreign pack, where turning
1456
+ * is the norm rather than the exception. It now transcribes the runtime's own
1457
+ * mapping for all four rotations, which is what makes the sentence above a
1458
+ * description rather than an aspiration.
1366
1459
  */
1367
1460
  function partPlate(img: CompiledImage): Plate {
1368
1461
  const page = readPlate(img.absPath);
@@ -1433,15 +1526,34 @@ export function compile(opts: CompileOptions): CompileResult {
1433
1526
  // The stage. The rig may state it outright (a foreign skeleton has no crop);
1434
1527
  // otherwise the manifest's crop is it. With neither there is nothing to
1435
1528
  // measure a full-frame mesh against, so the compile stops rather than guess.
1436
- const stageWidth = rig.skeleton?.width ?? manifest?.crop.w;
1437
- const stageHeight = rig.skeleton?.height ?? manifest?.crop.h;
1438
- if (stageWidth === undefined || stageHeight === undefined) {
1529
+ //
1530
+ // ⭐ The third state is the rig saying there is no stage at all — `width` and
1531
+ // `height` stated `null` (issue #578). That is a claim rather than a silence,
1532
+ // so it beats the manifest's crop the way a stated number already does, and
1533
+ // the header below emits none of the four fields. Omitting them is still the
1534
+ // refusal underneath: a transcriber who has no stage to copy can now say so,
1535
+ // and one who simply has not looked still cannot.
1536
+ const noStage = declaresNoStage(rig.skeleton);
1537
+ const stageWidth = noStage ? undefined : (rig.skeleton?.width ?? manifest?.crop.w);
1538
+ const stageHeight = noStage ? undefined : (rig.skeleton?.height ?? manifest?.crop.h);
1539
+ if (!noStage && (stageWidth === undefined || stageHeight === undefined)) {
1439
1540
  throw new CompileError(
1440
- 'no stage size: give the rig spec a `skeleton.width`/`skeleton.height`, or compile against a cut manifest whose `crop` states them',
1541
+ 'no stage size: give the rig spec a `skeleton.width`/`skeleton.height`, or compile against a cut manifest whose `crop` states them, ' +
1542
+ 'or state `"width": null, "height": null` for a skeleton that declares no stage',
1441
1543
  );
1442
1544
  }
1443
- /** Crop height, for the y-down -> y-up flip. Only manifest data uses it. */
1444
- const cropH = manifest?.crop.h ?? stageHeight;
1545
+ /**
1546
+ * Crop height, for the y-down -> y-up flip. Only manifest data uses it, and
1547
+ * `cropPointOf` refuses every bone that asks for manifest data when there is
1548
+ * no manifest — so the two states that can be read here both have one, or a
1549
+ * stage.
1550
+ *
1551
+ * ⚠️ `NaN` for the third, unreachable state (no manifest and a rig that
1552
+ * declares no stage) rather than 0. A plausible number is the failure this
1553
+ * compiler exists to refuse; if the guard above it ever moved, a NaN
1554
+ * coordinate is a named failure and an origin-flipped bone is not.
1555
+ */
1556
+ const cropH = manifest?.crop.h ?? stageHeight ?? Number.NaN;
1445
1557
  const imagesDir = opts.imagesDir !== undefined ? resolve(opts.imagesDir) : resolve(dirname(rigPath), rig.images ?? '.');
1446
1558
 
1447
1559
  // -- 1. gather images ------------------------------------------------------
@@ -1806,16 +1918,29 @@ export function compile(opts: CompileOptions): CompileResult {
1806
1918
  for (const rigSlot of rig.slots) {
1807
1919
  const part = partBySlot.get(rigSlot.name);
1808
1920
  const names = slotAttachments.get(rigSlot.name) ?? rigAttachmentNames.get(rigSlot.name) ?? [];
1809
- if (!names.length) continue;
1921
+ // 🔑 A slot nothing fills is EMITTED, empty — it is not dropped (issue #575).
1922
+ // A `continue` stood here instead, and what it bought was the format's own
1923
+ // silence: the emitted array came back a slot short with no line saying
1924
+ // which, and every slot after it moved down one. That index is what a
1925
+ // `drawOrder` key's offsets are counted against and what an index-keyed
1926
+ // consumer splits on, so the drop is not a smaller file, it is a different
1927
+ // rig. Two production exports declaring 53 and 61 slots built green at 51
1928
+ // and 57 and read 0.962 / 0.934 against the file they were transcribed from.
1929
+ //
1930
+ // The shape emitted here is the editor's own: `SkeletonJson`'s slot reader
1931
+ // takes `attachment` with a `null` default, so a slot with no `attachment`
1932
+ // key is a slot that shows nothing — 34 of the 52 slots in the official
1933
+ // `spineboy-pro` export omit the key.
1934
+ const empty = names.length === 0;
1810
1935
 
1811
1936
  const setup = motion.setup?.[rigSlot.name];
1812
1937
  // ⚠️ The entry's SHAPE is `parseMotionSpec`'s now (issue #307), and it had to
1813
- // move: this loop walks the RIG's slots and `continue`s past one with no
1814
- // attachments a few lines above, so the #293 shapes — `"lid_l": null` and,
1815
- // far worse, `"lid_l": "plate"`, which reads `.attachment` off a string as
1816
- // `undefined` and hides the slot in silence — stayed GREEN for exactly the
1817
- // slots a reader is most likely to be halfway through wiring up. Everything
1818
- // from here down is the half that needs the rig in front of it.
1938
+ // move: this loop used to `continue` past a slot with no attachments before
1939
+ // reaching here, so the #293 shapes — `"lid_l": null` and, far worse,
1940
+ // `"lid_l": "plate"`, which reads `.attachment` off a string as `undefined`
1941
+ // and hides the slot in silence — stayed GREEN for exactly the slots a
1942
+ // reader is most likely to be halfway through wiring up. Everything from
1943
+ // here down is the half that needs the rig in front of it.
1819
1944
  if (setup !== undefined && rigSlot.attachment !== undefined) {
1820
1945
  throw new CompileError(
1821
1946
  `slot "${rigSlot.name}" has a setup attachment in the rig spec AND in the motion spec; the setup pose has one author`,
@@ -1824,6 +1949,11 @@ export function compile(opts: CompileOptions): CompileResult {
1824
1949
  let setupAttachment: string | null;
1825
1950
  if (setup !== undefined) setupAttachment = setup.attachment ?? null;
1826
1951
  else if (rigSlot.attachment !== undefined) setupAttachment = rigSlot.attachment;
1952
+ // Nothing fills the slot, so there is nothing to choose between and nothing
1953
+ // to guess: the setup pose of an empty slot is "show nothing", which is the
1954
+ // one value the format can express for it. The refusal below stays exactly
1955
+ // where it was for a slot that HAS attachments and states no setup pose.
1956
+ else if (empty) setupAttachment = null;
1827
1957
  else {
1828
1958
  throw new CompileError(
1829
1959
  `no setup pose for slot "${rigSlot.name}": give the motion spec a \`setup\` entry or the rig slot an \`attachment\` — the compiler will not guess one`,
@@ -1831,7 +1961,11 @@ export function compile(opts: CompileOptions): CompileResult {
1831
1961
  }
1832
1962
  if (setupAttachment !== null && !names.includes(setupAttachment)) {
1833
1963
  throw new CompileError(
1834
- `setup attachment "${setupAttachment}" for slot "${rigSlot.name}" is not one of [${names.join(', ')}]`,
1964
+ empty
1965
+ ? `the setup pose shows attachment "${setupAttachment}" on slot "${rigSlot.name}", which no skin and no ` +
1966
+ 'manifest part fills — the slot is emitted empty, so there is no such attachment to show. Give the ' +
1967
+ 'slot an attachment, or state the setup pose as null'
1968
+ : `setup attachment "${setupAttachment}" for slot "${rigSlot.name}" is not one of [${names.join(', ')}]`,
1835
1969
  );
1836
1970
  }
1837
1971
  if (setup?.color && rigSlot.color !== undefined) {
@@ -1844,6 +1978,11 @@ export function compile(opts: CompileOptions): CompileResult {
1844
1978
  if (rigSlot.dark !== undefined) slot.dark = rigSlot.dark;
1845
1979
  if (rigSlot.blend !== undefined) slot.blend = rigSlot.blend;
1846
1980
  slots.push(slot);
1981
+ // ...and nothing below this line has anything to build. An empty entry in a
1982
+ // skin's attachment table would be rigc writing a key the editor does not,
1983
+ // so the skins array is left exactly as it was before #575 for every slot
1984
+ // that IS filled, and gains nothing for one that is not.
1985
+ if (empty) continue;
1847
1986
 
1848
1987
  if (part) {
1849
1988
  const perSlot: Record<string, SpineAttachment> = {};
@@ -2288,13 +2427,19 @@ export function compile(opts: CompileOptions): CompileResult {
2288
2427
  });
2289
2428
 
2290
2429
  // -- 6. assemble -----------------------------------------------------------
2291
- const header: SpineSkeletonJson['skeleton'] = {
2292
- spine: SPINE_VERSION,
2293
- x: rig.skeleton?.x ?? 0,
2294
- y: rig.skeleton?.y ?? 0,
2295
- width: stageWidth,
2296
- height: stageHeight,
2297
- };
2430
+ //
2431
+ // The stage is four fields or none of them. `x`/`y` are the origin of the box
2432
+ // `width`/`height` give an extent to, so a header carrying an origin for a box
2433
+ // it does not declare would be a shape no export has — and the key ORDER here
2434
+ // is the editor's own, which is what keeps a staged build byte-identical to
2435
+ // what it emitted before the stage could be declared absent (issue #578).
2436
+ const header: SpineSkeletonJson['skeleton'] = { spine: SPINE_VERSION };
2437
+ if (stageWidth !== undefined && stageHeight !== undefined) {
2438
+ header.x = rig.skeleton?.x ?? 0;
2439
+ header.y = rig.skeleton?.y ?? 0;
2440
+ header.width = stageWidth;
2441
+ header.height = stageHeight;
2442
+ }
2298
2443
  if (rig.skeleton?.fps !== undefined) header.fps = rig.skeleton.fps;
2299
2444
  if (rig.skeleton?.referenceScale !== undefined) header.referenceScale = rig.skeleton.referenceScale;
2300
2445
  const imagesPath = skeletonImagesPath(rig.skeleton?.images, opts, outDir, partDirs);
@@ -2796,6 +2941,39 @@ interface AttachmentContext {
2796
2941
  slotNames: Set<string>;
2797
2942
  }
2798
2943
 
2944
+ /**
2945
+ * The seven `type` values `readAttachment` has a branch for (`:540-651`), and
2946
+ * the five rigc emits.
2947
+ *
2948
+ * ⚠️ The lists are separate because the refusals are separate. A `point` is a
2949
+ * name the format HAS and rigc has not built; `sequence` is not a type at all —
2950
+ * it is a key on a region or a mesh (SPEC_COVERAGE part 1-6) — and telling an
2951
+ * author that it "is in the Spine 4.3 format and rigc does not emit it yet"
2952
+ * promises work that will never be done, on a spelling that is simply wrong.
2953
+ * One message said exactly that about every string it did not recognise.
2954
+ */
2955
+ const SPINE_ATTACHMENT_TYPES = ['region', 'mesh', 'linkedmesh', 'boundingbox', 'path', 'point', 'clipping'] as const;
2956
+ const EMITTED_ATTACHMENT_TYPES = ['region', 'mesh', 'boundingbox', 'clipping', 'path'] as const;
2957
+
2958
+ /**
2959
+ * What each deferred type is and what it would carry — `docs/SPEC_COVERAGE.md`
2960
+ * part 1-6's two rows, restated where the refusal can print them.
2961
+ *
2962
+ * ⭐ The construct, not just its name. "attachment type X is in the Spine 4.3
2963
+ * format and rigc does not emit it yet" tells an author who already knows what a
2964
+ * linked mesh is that rigc will not do it, and tells an author who does not know
2965
+ * nothing at all — and the second is the reader this repository writes for.
2966
+ */
2967
+ const DEFERRED_ATTACHMENTS: Record<string, string> = {
2968
+ linkedmesh:
2969
+ 'a mesh that takes its geometry from another mesh instead of stating any — a region/mesh head, then ' +
2970
+ '"source" (the attachment it links to, and the key that MAKES it linked), "slot" and "skin" naming where ' +
2971
+ 'that source lives, and "timelines" (default true) for whether it follows the source\'s deform keys',
2972
+ point:
2973
+ 'a position and an angle with no geometry at all — "x", "y", "rotation" and "color", posed by its bone and ' +
2974
+ 'drawn by nothing; what reads it is game code asking where a muzzle or a hand is',
2975
+ };
2976
+
2799
2977
  /**
2800
2978
  * Build one attachment a rig spec authored, as opposed to one a manifest part
2801
2979
  * produced.
@@ -2804,6 +2982,18 @@ interface AttachmentContext {
2804
2982
  * attachment type it does not know is to return null and drop it
2805
2983
  * (`SkeletonJson.ts:653`), so passing an unimplemented type through would produce
2806
2984
  * a skeleton missing an attachment nobody was told about.
2985
+ *
2986
+ * 🚨 **`?? 'region'` is not the parser's default, and that gap was the one real
2987
+ * fall-through here** (issue #577). `getValue(map, "type", "region")` returns the
2988
+ * default only when the key is **missing** (`SkeletonJson.ts:527`, `getValue` at
2989
+ * `:1390`), so `"type": null` is a type the parser HAS and matches no case: the
2990
+ * switch falls off the end, `readAttachment` returns null, and the attachment
2991
+ * disappears. `att.type ?? 'region'` read that same map as a region and compiled
2992
+ * one, which is the compiler inventing a value the spec did not state. Measured
2993
+ * before the repair: `{"type": null, "image": "marker.png"}` compiled and gated
2994
+ * green; `{"type": null}` was refused as *a region needs width and height*, which
2995
+ * is the message the card for this issue quoted and the reason it read as a
2996
+ * `linkedmesh` fault. `"type": "linkedmesh"` itself was always refused by name.
2807
2997
  */
2808
2998
  function buildRigAttachment(
2809
2999
  att: RigAttachment,
@@ -2811,18 +3001,55 @@ function buildRigAttachment(
2811
3001
  where: string,
2812
3002
  ctx: AttachmentContext,
2813
3003
  ): SpineAttachment {
2814
- const type = att.type ?? 'region';
3004
+ const stated = (att as { type?: unknown }).type;
3005
+ if (stated !== undefined && typeof stated !== 'string') {
3006
+ throw new CompileError(
3007
+ `${where}: "type" is ${JSON.stringify(stated) ?? String(stated)}, which is not a name. An attachment's type ` +
3008
+ `is one of ${SPINE_ATTACHMENT_TYPES.join(', ')}, or the key is absent and reads as "region". ` +
3009
+ 'PRESENT-and-null is not absent: `getValue(map, "type", "region")` takes the default only when the key is ' +
3010
+ 'missing (`SkeletonJson.ts:527`), so this map matches no case, `readAttachment` returns null (`:653`), and ' +
3011
+ 'the attachment is dropped from the skeleton without a word. Remove the key, or name a type.',
3012
+ );
3013
+ }
3014
+ const type = stated ?? 'region';
3015
+ // A linked mesh in the format's OTHER spelling. `type: "mesh"` and `type:
3016
+ // "linkedmesh"` share one branch and the `source` key is what decides between
3017
+ // them (`:568-569`, `:582`; SPEC_COVERAGE part 1-6) — so a mesh carrying
3018
+ // `source` is a linked mesh whatever its `type` says, and refusing it as "two
3019
+ // keys this compiler does not read: source, skin" sent the author to delete
3020
+ // the one key that made it linked.
3021
+ if (type === 'mesh' && (att as { source?: unknown }).source !== undefined) {
3022
+ throw new NotImplementedError(deferredAttachmentRefusal('linkedmesh', where, ' (a mesh carrying "source" is one)'));
3023
+ }
2815
3024
  if (type === 'region') return buildRigRegion(att as RigRegionAttachment, placeholder, where, ctx);
2816
3025
  if (type === 'mesh') return buildRigMesh(att as RigMeshAttachment, placeholder, where, ctx);
2817
3026
  if (type === 'boundingbox') return buildRigBoundingBox(att as RigBoundingBoxAttachment, where, ctx);
2818
3027
  if (type === 'clipping') return buildRigClipping(att as RigClippingAttachment, where, ctx);
2819
3028
  if (type === 'path') return buildRigPath(att as RigPathAttachment, where, ctx);
2820
- throw new NotImplementedError(
2821
- `${where}: attachment type "${String(type)}" is in the Spine 4.3 format and rigc does not emit it yet. ` +
2822
- 'Implemented: region, mesh, boundingbox, clipping, path. ' +
2823
- 'point and linkedmesh are deliberately deferred: neither appears anywhere in the benchmark ' +
2824
- 'corpus (docs/SPEC_COVERAGE.md parts 3-1 and 4-2), so neither is on the ladder\'s critical path. ' +
2825
- 'docs/SPEC_COVERAGE.md part 1-6 lists what each type would have to carry.',
3029
+ if (DEFERRED_ATTACHMENTS[type] !== undefined) throw new NotImplementedError(deferredAttachmentRefusal(type, where, ''));
3030
+ const near = nearMisses(type, SPINE_ATTACHMENT_TYPES);
3031
+ throw new CompileError(
3032
+ `${where}: attachment type "${type}" is not one of the ${SPINE_ATTACHMENT_TYPES.length} the Spine 4.3 format ` +
3033
+ `defines (${SPINE_ATTACHMENT_TYPES.join(', ')}). ` +
3034
+ (near.length ? `Did you mean ${near.map((n) => JSON.stringify(n)).join(' or ')}? ` : '') +
3035
+ 'The parser matches no case for it, `readAttachment` returns null (`SkeletonJson.ts:653`), and the ' +
3036
+ 'attachment is dropped from the skeleton without a word.' +
3037
+ // The one near-miss worth naming outright, because it is a real word in
3038
+ // this format standing one level up from where it was written.
3039
+ (type === 'sequence'
3040
+ ? ' "sequence" is a KEY on a region or a mesh rather than a type of its own (docs/SPEC_COVERAGE.md part 1-6).'
3041
+ : ''),
3042
+ );
3043
+ }
3044
+
3045
+ /** The refusal for a type the format has and rigc has not built. */
3046
+ function deferredAttachmentRefusal(type: string, where: string, how: string): string {
3047
+ return (
3048
+ `${where}: this attachment is a "${type}"${how} — ${DEFERRED_ATTACHMENTS[type]}. ` +
3049
+ `rigc does not emit it yet, deliberately: it emits ${EMITTED_ATTACHMENT_TYPES.join(', ')}, and neither a ` +
3050
+ 'point nor a linked mesh appears anywhere in the benchmark corpus (docs/SPEC_COVERAGE.md parts 3-1 and 4-2), ' +
3051
+ 'so neither is on the ladder\'s critical path. docs/SPEC_COVERAGE.md part 1-6 is the row this sentence reads ' +
3052
+ 'from, and it is what an implementation would have to carry.'
2826
3053
  );
2827
3054
  }
2828
3055
 
@@ -3191,6 +3418,45 @@ function atlasedImage(image: string, where: string, ctx: AttachmentContext): Com
3191
3418
  );
3192
3419
  }
3193
3420
 
3421
+ /**
3422
+ * The atlas region this attachment resolves its art from — **one rule for every
3423
+ * attachment kind that has art**, which is region and mesh (the parser's own two
3424
+ * `getValue(map, "path", name)` sites, `:541` and `:570`).
3425
+ *
3426
+ * `path` defaults to the attachment's NAME, not to the placeholder, so a
3427
+ * placeholder called anything other than its PNG's basename resolves a region no
3428
+ * atlas has. Stating it is therefore not decoration: without it the loader
3429
+ * throws `Region not found in atlas`, which `A00_ROUNDTRIP_PARSE` reports in the
3430
+ * parser's own words.
3431
+ *
3432
+ * 🚨 The tree answered this in three different ways until issue #577, and
3433
+ * `docs/AUTHORING.md` §3.4 documented only one of them — *"rigc sets it for you
3434
+ * when the PNG basename differs from the placeholder"*. A region derived it, the
3435
+ * `contour` and `grid` generators derived it (one of them with a comment reading
3436
+ * "Same rule a region attachment follows"), and an authored mesh and the
3437
+ * `ring`/`ribbon` generators wrote `path` only when the spec stated one.
3438
+ * Measured on the probe rig: a region with `image: hair_short.png` under
3439
+ * placeholder `hair` gated green with `"path": "hair_short"`; the same image on
3440
+ * an authored mesh emitted no `path` and failed `A00_ROUNDTRIP_PARSE: threw:
3441
+ * Region not found in atlas: hair (attachment: hair)`. The asymmetry had already
3442
+ * been paid for by hand — `selftest.ts`'s own `TIMELINE_RIG` restates
3443
+ * `path: "block"` and `path: "marker"` on two authored meshes for no other
3444
+ * reason.
3445
+ *
3446
+ * ⭐ Deriving is the reading that was already written down, in the doc and in
3447
+ * two of the five emit sites. The alternative — document the asymmetry and
3448
+ * refuse a mesh whose basename differs without a `path` — was rejected because
3449
+ * it makes a hand-kept exception out of a rule the format applies to both kinds
3450
+ * through one line of parser, and it would have had to contradict §3.4 rather
3451
+ * than satisfy it.
3452
+ */
3453
+ function attachmentPath(att: { path?: string; image?: string }, placeholder: string): string | undefined {
3454
+ if (att.path !== undefined) return att.path;
3455
+ if (att.image === undefined) return undefined;
3456
+ const region = basename(att.image, '.png');
3457
+ return region === placeholder ? undefined : region;
3458
+ }
3459
+
3194
3460
  function buildRigRegion(
3195
3461
  att: RigRegionAttachment,
3196
3462
  placeholder: string,
@@ -3237,9 +3503,8 @@ function buildRigRegion(
3237
3503
  );
3238
3504
  }
3239
3505
  const out: SpineRegionAttachment = { width: r6(width), height: r6(height) };
3240
- const region = att.image === undefined ? undefined : basename(att.image, '.png');
3241
- if (att.path !== undefined) out.path = att.path;
3242
- else if (region !== undefined && region !== placeholder) out.path = region;
3506
+ const path = attachmentPath(att, placeholder);
3507
+ if (path !== undefined) out.path = path;
3243
3508
  if (att.x !== undefined) out.x = r6(att.x);
3244
3509
  if (att.y !== undefined) out.y = r6(att.y);
3245
3510
  if (att.rotation !== undefined) out.rotation = r6(att.rotation);
@@ -3300,6 +3565,16 @@ function encodeNamedWeights(weights: RigMeshBinding[][], where: string, ctx: Att
3300
3565
  * a part with no art at all (0 of 0 pixels is not a percentage). Neither is an
3301
3566
  * error here — a mesh with no image is ordinary data, and an all-transparent part
3302
3567
  * is somebody else's assertion to make.
3568
+ *
3569
+ * 🚨 The asymmetry was a promise this function could not keep, and nothing here
3570
+ * said so (issue #570). `partPlate` three frames down called `extractRegion`,
3571
+ * which refused a region a foreign pack had turned — so an authored mesh naming
3572
+ * an `image` under `--atlas-in` ENDED the build, by a refusal raised inside a
3573
+ * measurement, on 36 of 42 atlases of the pack the card was filed from. The
3574
+ * repair was to make the refusal unnecessary rather than to catch it: reading a
3575
+ * turned region is a transcription of `MeshAttachment.computeUVs` and is now
3576
+ * what `extractRegion` does, so there is no unmeasurable case left for this
3577
+ * function to report and no catch here to keep reachable.
3303
3578
  */
3304
3579
  function measureAuthoredFit(att: RigMeshAttachment, ctx: AttachmentContext): MeshFitReport | null {
3305
3580
  if (att.image === undefined || att.uvs === undefined || att.triangles === undefined) return null;
@@ -3466,7 +3741,8 @@ function buildRigMesh(
3466
3741
  width: r6(width),
3467
3742
  height: r6(height),
3468
3743
  };
3469
- if (att.path !== undefined) out.path = att.path;
3744
+ const path = attachmentPath(att, placeholder);
3745
+ if (path !== undefined) out.path = path;
3470
3746
  if (att.color !== undefined) out.color = att.color;
3471
3747
  // Register it as `authored`: geometry rigc did not build and whose topology it
3472
3748
  // therefore gets to assume nothing about. The generator-topology assertions
@@ -3557,7 +3833,8 @@ function buildGeneratedMesh(
3557
3833
  width: r6(w),
3558
3834
  height: r6(h),
3559
3835
  };
3560
- if (att.path !== undefined) out.path = att.path;
3836
+ const path = attachmentPath(att, placeholder);
3837
+ if (path !== undefined) out.path = path;
3561
3838
  if (att.color !== undefined) out.color = att.color;
3562
3839
  return out;
3563
3840
  }
@@ -4035,8 +4312,8 @@ function buildGridAttachment(
4035
4312
  width: r6(w),
4036
4313
  height: r6(h),
4037
4314
  };
4038
- if (att.path !== undefined) out.path = att.path;
4039
- else if (img.region !== placeholder) out.path = img.region;
4315
+ const path = attachmentPath(att, placeholder);
4316
+ if (path !== undefined) out.path = path;
4040
4317
  if (att.color !== undefined) out.color = att.color;
4041
4318
  return out;
4042
4319
  }
@@ -4174,9 +4451,11 @@ function buildContourAttachment(
4174
4451
  };
4175
4452
  // Same rule a region attachment follows: the atlas region is the PNG's
4176
4453
  // basename, so a placeholder named anything else needs `path` written down or
4177
- // the loader resolves nothing.
4178
- if (att.path !== undefined) out.path = att.path;
4179
- else if (img.region !== placeholder) out.path = img.region;
4454
+ // the loader resolves nothing. Stated once in `attachmentPath` since #577 —
4455
+ // this comment used to be the rule's only statement, beside four emit sites
4456
+ // that disagreed with it.
4457
+ const path = attachmentPath(att, placeholder);
4458
+ if (path !== undefined) out.path = path;
4180
4459
  if (att.color !== undefined) out.color = att.color;
4181
4460
  return out;
4182
4461
  }
@@ -4990,7 +5269,7 @@ function compileValueTrack(
4990
5269
  duration: number,
4991
5270
  target: string,
4992
5271
  shift: number,
4993
- shapes: Record<string, { fields: string[]; identity: number[] }>,
5272
+ shapes: Record<string, ValueTrackShape>,
4994
5273
  kind: string,
4995
5274
  ): SpineTimelineKey[] {
4996
5275
  const shape = shapes[track.property];
@@ -5022,6 +5301,23 @@ function compileValueTrack(
5022
5301
  shape.fields.forEach((field, c) => {
5023
5302
  const v = key.v as number[];
5024
5303
  if (!Number.isFinite(v[c])) throw new CompileError(`${where}: non-finite value ${String(v[c])}`);
5304
+ // The bound, where the runtime has one. Refused here rather than left to
5305
+ // `A23` because this is a spec somebody wrote and the key is the thing to
5306
+ // change — the same division `compileConstraintTrack`'s `range` makes for
5307
+ // an ik mix, and the same criterion `A23` applies to a file rigc did not
5308
+ // write (issue #610). Note the value is judged BEFORE `r6`: a number
5309
+ // rounding onto a bound would be refused for the rounding rather than for
5310
+ // what the author wrote.
5311
+ const bound = shape.bounds?.[c];
5312
+ if (bound) {
5313
+ const refusal = physicsKeyRefusal(bound, v[c]);
5314
+ // `where` already ends in the property, so the JSON field is named only
5315
+ // on a shape that has more than one of them — otherwise the message
5316
+ // reads "… mass: value is 0", which says the property twice and the
5317
+ // second time under the wrong name.
5318
+ const at = shape.fields.length > 1 ? `${field} at t=${key.t}` : `key at t=${key.t}`;
5319
+ if (refusal !== null) throw new CompileError(`${where} ${at} is ${refusal}`);
5320
+ }
5025
5321
  entry[field] = r6(v[c]);
5026
5322
  });
5027
5323
  if (key.ease !== undefined && key.curve !== undefined) {
@@ -5647,12 +5943,15 @@ function deformGeometryOf(
5647
5943
  * pair too long, or aimed at the wrong attachment, loses its tail and
5648
5944
  * deforms part of the mesh correctly. That is the worst possible failure
5649
5945
  * shape: it looks almost right.
5650
- * 2. **An odd `offset`, or an odd run length.** The array is `x, y` pairs; an
5651
- * odd index puts every x of the run on a y and vice versa. It loads.
5652
- * 3. **`fromVertex` where a vertex is not one pair.** See below.
5653
- * 4. **A key that carries both a run and no room for one**, or a non-finite
5946
+ * 2. **`fromVertex` where a vertex is not one pair.** See below.
5947
+ * 3. **A key that carries both a run and no room for one**, or a non-finite
5654
5948
  * offset — a NaN in the deform array propagates into world vertices.
5655
5949
  *
5950
+ * ⛔ What is **not** refused, and was until issue #576: an odd `offset` or an odd
5951
+ * run length. Both are raw copies at raw indices in both readers, both are what a
5952
+ * trimmed editor delta looks like, and neither has a second spelling — see the
5953
+ * clause in the loop below and the one in `deformStart`.
5954
+ *
5656
5955
  * `fromVertex` is rigc's own field and the reason it exists is issue #89's
5657
5956
  * observation: a deform key is the only key in the format whose meaning depends
5658
5957
  * on the attachment it is attached to, and an author reasons in vertices while
@@ -5811,11 +6110,31 @@ function compileDeformTrack(
5811
6110
  'or omit it entirely for "back to the setup pose"',
5812
6111
  );
5813
6112
  }
5814
- if (run.length % 2 !== 0) {
5815
- throw new CompileError(
5816
- `${where} (t=${key.t}): "vertices" holds ${run.length} numbers; the deform array is x, y PAIRS, so a run has an even length`,
5817
- );
5818
- }
6113
+ // ⛔ No parity clause here, and its absence is the rule (issue #576).
6114
+ //
6115
+ // A run is copied, not decoded. `SkeletonJson`'s deform branch does
6116
+ // `Utils.arrayCopy(verticesValue, 0, deform, start, verticesValue.length)`
6117
+ // with `start` the key's own `offset`, and `SkeletonBinary` reads a count and
6118
+ // a start and fills `for (let v = start; v < end; v++) deform[v] = ...`.
6119
+ // Neither has any pair arithmetic to be misaligned against, so an ODD run is
6120
+ // legal, deterministic data: it writes the x and y of one vertex and the x of
6121
+ // the next, and that next y stays at its setup value.
6122
+ //
6123
+ // 🔒 The reason this cannot be "refused in THIS spec anyway" is that there is
6124
+ // no other spelling of it. Padding a `0` to make the run even is a different
6125
+ // animation wherever the setup y it lands on is non-zero, so a spec that
6126
+ // refuses an odd run is a spec no transcription of such a file can be written
6127
+ // in — and one is in this repository's own example corpus (`spineboy-pro`,
6128
+ // `hoverboard` / `hoverboard-board`: `offset: 1` and 147 numbers into a
6129
+ // 148-long array, the whole delta minus the leading zero the editor trimmed).
6130
+ //
6131
+ // What replaces it is the bound the runtime really has, below: the run has to
6132
+ // FIT. That one is the quiet defect — a copy past the end of a `Float32Array`
6133
+ // is a no-op in JavaScript — and it is unaffected by where a run starts or
6134
+ // how long it is. `A35_DEFORM_KEYS_FIT_THE_ATTACHMENT` measures the same
6135
+ // bound on the emitted file and has had no parity clause since issue #262;
6136
+ // until this was removed the two halves of rigc disagreed about what the
6137
+ // format holds, and the half that refused was the authoring half.
5819
6138
  for (const n of run) {
5820
6139
  if (typeof n !== 'number' || !Number.isFinite(n)) {
5821
6140
  throw new CompileError(`${where} (t=${key.t}): "vertices" holds a non-finite value ${JSON.stringify(n)}`);
@@ -5884,10 +6203,10 @@ function expandDeformToInfluences(displacements: number[], geometry: DeformGeome
5884
6203
  /**
5885
6204
  * Where in the deform array this key's run begins.
5886
6205
  *
5887
- * `offset` is that index outright. `fromVertex` is a vertex index, and turning
5888
- * one into the other is exact only where a vertex occupies exactly one pair —
5889
- * which is every vertex of an unweighted attachment and only the single-bone
5890
- * vertices of a weighted one.
6206
+ * `offset` is that index outright — any index the array holds, odd ones included
6207
+ * (issue #576). `fromVertex` is a vertex index, and turning one into the other is
6208
+ * exact only where a vertex occupies exactly one pair — which is every vertex of
6209
+ * an unweighted attachment and only the single-bone vertices of a weighted one.
5891
6210
  */
5892
6211
  function deformStart(
5893
6212
  key: MotionDeformTrack['keys'][number],
@@ -5901,12 +6220,16 @@ function deformStart(
5901
6220
  `${where} (t=${key.t}): offset is ${JSON.stringify(key.offset)}; it is an index into the deform array, so a whole number ≥ 0`,
5902
6221
  );
5903
6222
  }
5904
- if (key.offset % 2 !== 0) {
5905
- throw new CompileError(
5906
- `${where} (t=${key.t}): offset ${key.offset} is odd. The deform array is x, y pairs, so an odd start puts ` +
5907
- "every x of this run on a y — it loads, and the mesh tears. Use an even index, or say which vertex you meant with \"fromVertex\".",
5908
- );
5909
- }
6223
+ // ⛔ An odd `offset` is not refused either, and it went the same way as the
6224
+ // run's own length (issue #576). The refusal that stood here read an odd
6225
+ // start as a `fromVertex` typed into the wrong field — a real mistake, but
6226
+ // this caught exactly the half of it whose index happens to be odd:
6227
+ // `offset: 4` meant as vertex 4 is the same mistake, lands on vertex 2, and
6228
+ // was always accepted. What it did refuse was every faithful transcription of
6229
+ // a trimmed editor run, one of which ships in `examples/` — `spineboy-pro`'s
6230
+ // `hoverboard-board` starts at 1. A rule that filters one parity of a
6231
+ // confusion it cannot see, at the price of a construct the format holds, is
6232
+ // the wrong instrument; `A35` dropped the same clause in issue #262.
5910
6233
  return key.offset;
5911
6234
  }
5912
6235
  if (key.fromVertex === undefined) return 0;
@@ -5914,7 +6237,12 @@ function deformStart(
5914
6237
  if (!Number.isInteger(from) || from < 0) {
5915
6238
  throw new CompileError(`${where} (t=${key.t}): fromVertex is ${JSON.stringify(from)}; it is a vertex index, so a whole number ≥ 0`);
5916
6239
  }
5917
- const covered = runLength / 2;
6240
+ // ⌈⌉ rather than ÷, because an odd run REACHES a last vertex without covering
6241
+ // it: its final number is that vertex's x and the y beside it stays at setup
6242
+ // (issue #576). The bound below is about which vertices the run reaches, so the
6243
+ // half-reached one counts — and on a weighted attachment its influence count is
6244
+ // checked with the rest.
6245
+ const covered = Math.ceil(runLength / 2);
5918
6246
  if (from + covered > geometry.vertexCount) {
5919
6247
  throw new CompileError(
5920
6248
  `${where} (t=${key.t}): fromVertex ${from} plus ${covered} vertex offset(s) runs to vertex ${from + covered}, ` +