spine-rigc 0.22.2 → 0.23.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,
@@ -1363,6 +1364,13 @@ function resolveFromAtlas(
1363
1364
  * own, only a rectangle of somebody's page. `extractRegion` lifts the drawing
1364
1365
  * back out, so the generator sees the same grid either way and its output does
1365
1366
  * not depend on how the art was delivered.
1367
+ *
1368
+ * ⭐ *Either way* was not true for a pack that turns a region until issue #570:
1369
+ * `extractRegion` refused a `rotate: 90`, so how the art was delivered decided
1370
+ * whether a measurement could be taken at all — on a foreign pack, where turning
1371
+ * is the norm rather than the exception. It now transcribes the runtime's own
1372
+ * mapping for all four rotations, which is what makes the sentence above a
1373
+ * description rather than an aspiration.
1366
1374
  */
1367
1375
  function partPlate(img: CompiledImage): Plate {
1368
1376
  const page = readPlate(img.absPath);
@@ -1433,15 +1441,34 @@ export function compile(opts: CompileOptions): CompileResult {
1433
1441
  // The stage. The rig may state it outright (a foreign skeleton has no crop);
1434
1442
  // otherwise the manifest's crop is it. With neither there is nothing to
1435
1443
  // 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) {
1444
+ //
1445
+ // ⭐ The third state is the rig saying there is no stage at all — `width` and
1446
+ // `height` stated `null` (issue #578). That is a claim rather than a silence,
1447
+ // so it beats the manifest's crop the way a stated number already does, and
1448
+ // the header below emits none of the four fields. Omitting them is still the
1449
+ // refusal underneath: a transcriber who has no stage to copy can now say so,
1450
+ // and one who simply has not looked still cannot.
1451
+ const noStage = declaresNoStage(rig.skeleton);
1452
+ const stageWidth = noStage ? undefined : (rig.skeleton?.width ?? manifest?.crop.w);
1453
+ const stageHeight = noStage ? undefined : (rig.skeleton?.height ?? manifest?.crop.h);
1454
+ if (!noStage && (stageWidth === undefined || stageHeight === undefined)) {
1439
1455
  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',
1456
+ 'no stage size: give the rig spec a `skeleton.width`/`skeleton.height`, or compile against a cut manifest whose `crop` states them, ' +
1457
+ 'or state `"width": null, "height": null` for a skeleton that declares no stage',
1441
1458
  );
1442
1459
  }
1443
- /** Crop height, for the y-down -> y-up flip. Only manifest data uses it. */
1444
- const cropH = manifest?.crop.h ?? stageHeight;
1460
+ /**
1461
+ * Crop height, for the y-down -> y-up flip. Only manifest data uses it, and
1462
+ * `cropPointOf` refuses every bone that asks for manifest data when there is
1463
+ * no manifest — so the two states that can be read here both have one, or a
1464
+ * stage.
1465
+ *
1466
+ * ⚠️ `NaN` for the third, unreachable state (no manifest and a rig that
1467
+ * declares no stage) rather than 0. A plausible number is the failure this
1468
+ * compiler exists to refuse; if the guard above it ever moved, a NaN
1469
+ * coordinate is a named failure and an origin-flipped bone is not.
1470
+ */
1471
+ const cropH = manifest?.crop.h ?? stageHeight ?? Number.NaN;
1445
1472
  const imagesDir = opts.imagesDir !== undefined ? resolve(opts.imagesDir) : resolve(dirname(rigPath), rig.images ?? '.');
1446
1473
 
1447
1474
  // -- 1. gather images ------------------------------------------------------
@@ -1806,16 +1833,29 @@ export function compile(opts: CompileOptions): CompileResult {
1806
1833
  for (const rigSlot of rig.slots) {
1807
1834
  const part = partBySlot.get(rigSlot.name);
1808
1835
  const names = slotAttachments.get(rigSlot.name) ?? rigAttachmentNames.get(rigSlot.name) ?? [];
1809
- if (!names.length) continue;
1836
+ // 🔑 A slot nothing fills is EMITTED, empty — it is not dropped (issue #575).
1837
+ // A `continue` stood here instead, and what it bought was the format's own
1838
+ // silence: the emitted array came back a slot short with no line saying
1839
+ // which, and every slot after it moved down one. That index is what a
1840
+ // `drawOrder` key's offsets are counted against and what an index-keyed
1841
+ // consumer splits on, so the drop is not a smaller file, it is a different
1842
+ // rig. Two production exports declaring 53 and 61 slots built green at 51
1843
+ // and 57 and read 0.962 / 0.934 against the file they were transcribed from.
1844
+ //
1845
+ // The shape emitted here is the editor's own: `SkeletonJson`'s slot reader
1846
+ // takes `attachment` with a `null` default, so a slot with no `attachment`
1847
+ // key is a slot that shows nothing — 34 of the 52 slots in the official
1848
+ // `spineboy-pro` export omit the key.
1849
+ const empty = names.length === 0;
1810
1850
 
1811
1851
  const setup = motion.setup?.[rigSlot.name];
1812
1852
  // ⚠️ 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.
1853
+ // move: this loop used to `continue` past a slot with no attachments before
1854
+ // reaching here, so the #293 shapes — `"lid_l": null` and, far worse,
1855
+ // `"lid_l": "plate"`, which reads `.attachment` off a string as `undefined`
1856
+ // and hides the slot in silence — stayed GREEN for exactly the slots a
1857
+ // reader is most likely to be halfway through wiring up. Everything from
1858
+ // here down is the half that needs the rig in front of it.
1819
1859
  if (setup !== undefined && rigSlot.attachment !== undefined) {
1820
1860
  throw new CompileError(
1821
1861
  `slot "${rigSlot.name}" has a setup attachment in the rig spec AND in the motion spec; the setup pose has one author`,
@@ -1824,6 +1864,11 @@ export function compile(opts: CompileOptions): CompileResult {
1824
1864
  let setupAttachment: string | null;
1825
1865
  if (setup !== undefined) setupAttachment = setup.attachment ?? null;
1826
1866
  else if (rigSlot.attachment !== undefined) setupAttachment = rigSlot.attachment;
1867
+ // Nothing fills the slot, so there is nothing to choose between and nothing
1868
+ // to guess: the setup pose of an empty slot is "show nothing", which is the
1869
+ // one value the format can express for it. The refusal below stays exactly
1870
+ // where it was for a slot that HAS attachments and states no setup pose.
1871
+ else if (empty) setupAttachment = null;
1827
1872
  else {
1828
1873
  throw new CompileError(
1829
1874
  `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 +1876,11 @@ export function compile(opts: CompileOptions): CompileResult {
1831
1876
  }
1832
1877
  if (setupAttachment !== null && !names.includes(setupAttachment)) {
1833
1878
  throw new CompileError(
1834
- `setup attachment "${setupAttachment}" for slot "${rigSlot.name}" is not one of [${names.join(', ')}]`,
1879
+ empty
1880
+ ? `the setup pose shows attachment "${setupAttachment}" on slot "${rigSlot.name}", which no skin and no ` +
1881
+ 'manifest part fills — the slot is emitted empty, so there is no such attachment to show. Give the ' +
1882
+ 'slot an attachment, or state the setup pose as null'
1883
+ : `setup attachment "${setupAttachment}" for slot "${rigSlot.name}" is not one of [${names.join(', ')}]`,
1835
1884
  );
1836
1885
  }
1837
1886
  if (setup?.color && rigSlot.color !== undefined) {
@@ -1844,6 +1893,11 @@ export function compile(opts: CompileOptions): CompileResult {
1844
1893
  if (rigSlot.dark !== undefined) slot.dark = rigSlot.dark;
1845
1894
  if (rigSlot.blend !== undefined) slot.blend = rigSlot.blend;
1846
1895
  slots.push(slot);
1896
+ // ...and nothing below this line has anything to build. An empty entry in a
1897
+ // skin's attachment table would be rigc writing a key the editor does not,
1898
+ // so the skins array is left exactly as it was before #575 for every slot
1899
+ // that IS filled, and gains nothing for one that is not.
1900
+ if (empty) continue;
1847
1901
 
1848
1902
  if (part) {
1849
1903
  const perSlot: Record<string, SpineAttachment> = {};
@@ -2288,13 +2342,19 @@ export function compile(opts: CompileOptions): CompileResult {
2288
2342
  });
2289
2343
 
2290
2344
  // -- 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
- };
2345
+ //
2346
+ // The stage is four fields or none of them. `x`/`y` are the origin of the box
2347
+ // `width`/`height` give an extent to, so a header carrying an origin for a box
2348
+ // it does not declare would be a shape no export has — and the key ORDER here
2349
+ // is the editor's own, which is what keeps a staged build byte-identical to
2350
+ // what it emitted before the stage could be declared absent (issue #578).
2351
+ const header: SpineSkeletonJson['skeleton'] = { spine: SPINE_VERSION };
2352
+ if (stageWidth !== undefined && stageHeight !== undefined) {
2353
+ header.x = rig.skeleton?.x ?? 0;
2354
+ header.y = rig.skeleton?.y ?? 0;
2355
+ header.width = stageWidth;
2356
+ header.height = stageHeight;
2357
+ }
2298
2358
  if (rig.skeleton?.fps !== undefined) header.fps = rig.skeleton.fps;
2299
2359
  if (rig.skeleton?.referenceScale !== undefined) header.referenceScale = rig.skeleton.referenceScale;
2300
2360
  const imagesPath = skeletonImagesPath(rig.skeleton?.images, opts, outDir, partDirs);
@@ -2796,6 +2856,39 @@ interface AttachmentContext {
2796
2856
  slotNames: Set<string>;
2797
2857
  }
2798
2858
 
2859
+ /**
2860
+ * The seven `type` values `readAttachment` has a branch for (`:540-651`), and
2861
+ * the five rigc emits.
2862
+ *
2863
+ * ⚠️ The lists are separate because the refusals are separate. A `point` is a
2864
+ * name the format HAS and rigc has not built; `sequence` is not a type at all —
2865
+ * it is a key on a region or a mesh (SPEC_COVERAGE part 1-6) — and telling an
2866
+ * author that it "is in the Spine 4.3 format and rigc does not emit it yet"
2867
+ * promises work that will never be done, on a spelling that is simply wrong.
2868
+ * One message said exactly that about every string it did not recognise.
2869
+ */
2870
+ const SPINE_ATTACHMENT_TYPES = ['region', 'mesh', 'linkedmesh', 'boundingbox', 'path', 'point', 'clipping'] as const;
2871
+ const EMITTED_ATTACHMENT_TYPES = ['region', 'mesh', 'boundingbox', 'clipping', 'path'] as const;
2872
+
2873
+ /**
2874
+ * What each deferred type is and what it would carry — `docs/SPEC_COVERAGE.md`
2875
+ * part 1-6's two rows, restated where the refusal can print them.
2876
+ *
2877
+ * ⭐ The construct, not just its name. "attachment type X is in the Spine 4.3
2878
+ * format and rigc does not emit it yet" tells an author who already knows what a
2879
+ * linked mesh is that rigc will not do it, and tells an author who does not know
2880
+ * nothing at all — and the second is the reader this repository writes for.
2881
+ */
2882
+ const DEFERRED_ATTACHMENTS: Record<string, string> = {
2883
+ linkedmesh:
2884
+ 'a mesh that takes its geometry from another mesh instead of stating any — a region/mesh head, then ' +
2885
+ '"source" (the attachment it links to, and the key that MAKES it linked), "slot" and "skin" naming where ' +
2886
+ 'that source lives, and "timelines" (default true) for whether it follows the source\'s deform keys',
2887
+ point:
2888
+ 'a position and an angle with no geometry at all — "x", "y", "rotation" and "color", posed by its bone and ' +
2889
+ 'drawn by nothing; what reads it is game code asking where a muzzle or a hand is',
2890
+ };
2891
+
2799
2892
  /**
2800
2893
  * Build one attachment a rig spec authored, as opposed to one a manifest part
2801
2894
  * produced.
@@ -2804,6 +2897,18 @@ interface AttachmentContext {
2804
2897
  * attachment type it does not know is to return null and drop it
2805
2898
  * (`SkeletonJson.ts:653`), so passing an unimplemented type through would produce
2806
2899
  * a skeleton missing an attachment nobody was told about.
2900
+ *
2901
+ * 🚨 **`?? 'region'` is not the parser's default, and that gap was the one real
2902
+ * fall-through here** (issue #577). `getValue(map, "type", "region")` returns the
2903
+ * default only when the key is **missing** (`SkeletonJson.ts:527`, `getValue` at
2904
+ * `:1390`), so `"type": null` is a type the parser HAS and matches no case: the
2905
+ * switch falls off the end, `readAttachment` returns null, and the attachment
2906
+ * disappears. `att.type ?? 'region'` read that same map as a region and compiled
2907
+ * one, which is the compiler inventing a value the spec did not state. Measured
2908
+ * before the repair: `{"type": null, "image": "marker.png"}` compiled and gated
2909
+ * green; `{"type": null}` was refused as *a region needs width and height*, which
2910
+ * is the message the card for this issue quoted and the reason it read as a
2911
+ * `linkedmesh` fault. `"type": "linkedmesh"` itself was always refused by name.
2807
2912
  */
2808
2913
  function buildRigAttachment(
2809
2914
  att: RigAttachment,
@@ -2811,18 +2916,55 @@ function buildRigAttachment(
2811
2916
  where: string,
2812
2917
  ctx: AttachmentContext,
2813
2918
  ): SpineAttachment {
2814
- const type = att.type ?? 'region';
2919
+ const stated = (att as { type?: unknown }).type;
2920
+ if (stated !== undefined && typeof stated !== 'string') {
2921
+ throw new CompileError(
2922
+ `${where}: "type" is ${JSON.stringify(stated) ?? String(stated)}, which is not a name. An attachment's type ` +
2923
+ `is one of ${SPINE_ATTACHMENT_TYPES.join(', ')}, or the key is absent and reads as "region". ` +
2924
+ 'PRESENT-and-null is not absent: `getValue(map, "type", "region")` takes the default only when the key is ' +
2925
+ 'missing (`SkeletonJson.ts:527`), so this map matches no case, `readAttachment` returns null (`:653`), and ' +
2926
+ 'the attachment is dropped from the skeleton without a word. Remove the key, or name a type.',
2927
+ );
2928
+ }
2929
+ const type = stated ?? 'region';
2930
+ // A linked mesh in the format's OTHER spelling. `type: "mesh"` and `type:
2931
+ // "linkedmesh"` share one branch and the `source` key is what decides between
2932
+ // them (`:568-569`, `:582`; SPEC_COVERAGE part 1-6) — so a mesh carrying
2933
+ // `source` is a linked mesh whatever its `type` says, and refusing it as "two
2934
+ // keys this compiler does not read: source, skin" sent the author to delete
2935
+ // the one key that made it linked.
2936
+ if (type === 'mesh' && (att as { source?: unknown }).source !== undefined) {
2937
+ throw new NotImplementedError(deferredAttachmentRefusal('linkedmesh', where, ' (a mesh carrying "source" is one)'));
2938
+ }
2815
2939
  if (type === 'region') return buildRigRegion(att as RigRegionAttachment, placeholder, where, ctx);
2816
2940
  if (type === 'mesh') return buildRigMesh(att as RigMeshAttachment, placeholder, where, ctx);
2817
2941
  if (type === 'boundingbox') return buildRigBoundingBox(att as RigBoundingBoxAttachment, where, ctx);
2818
2942
  if (type === 'clipping') return buildRigClipping(att as RigClippingAttachment, where, ctx);
2819
2943
  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.',
2944
+ if (DEFERRED_ATTACHMENTS[type] !== undefined) throw new NotImplementedError(deferredAttachmentRefusal(type, where, ''));
2945
+ const near = nearMisses(type, SPINE_ATTACHMENT_TYPES);
2946
+ throw new CompileError(
2947
+ `${where}: attachment type "${type}" is not one of the ${SPINE_ATTACHMENT_TYPES.length} the Spine 4.3 format ` +
2948
+ `defines (${SPINE_ATTACHMENT_TYPES.join(', ')}). ` +
2949
+ (near.length ? `Did you mean ${near.map((n) => JSON.stringify(n)).join(' or ')}? ` : '') +
2950
+ 'The parser matches no case for it, `readAttachment` returns null (`SkeletonJson.ts:653`), and the ' +
2951
+ 'attachment is dropped from the skeleton without a word.' +
2952
+ // The one near-miss worth naming outright, because it is a real word in
2953
+ // this format standing one level up from where it was written.
2954
+ (type === 'sequence'
2955
+ ? ' "sequence" is a KEY on a region or a mesh rather than a type of its own (docs/SPEC_COVERAGE.md part 1-6).'
2956
+ : ''),
2957
+ );
2958
+ }
2959
+
2960
+ /** The refusal for a type the format has and rigc has not built. */
2961
+ function deferredAttachmentRefusal(type: string, where: string, how: string): string {
2962
+ return (
2963
+ `${where}: this attachment is a "${type}"${how} — ${DEFERRED_ATTACHMENTS[type]}. ` +
2964
+ `rigc does not emit it yet, deliberately: it emits ${EMITTED_ATTACHMENT_TYPES.join(', ')}, and neither a ` +
2965
+ 'point nor a linked mesh appears anywhere in the benchmark corpus (docs/SPEC_COVERAGE.md parts 3-1 and 4-2), ' +
2966
+ 'so neither is on the ladder\'s critical path. docs/SPEC_COVERAGE.md part 1-6 is the row this sentence reads ' +
2967
+ 'from, and it is what an implementation would have to carry.'
2826
2968
  );
2827
2969
  }
2828
2970
 
@@ -3191,6 +3333,45 @@ function atlasedImage(image: string, where: string, ctx: AttachmentContext): Com
3191
3333
  );
3192
3334
  }
3193
3335
 
3336
+ /**
3337
+ * The atlas region this attachment resolves its art from — **one rule for every
3338
+ * attachment kind that has art**, which is region and mesh (the parser's own two
3339
+ * `getValue(map, "path", name)` sites, `:541` and `:570`).
3340
+ *
3341
+ * `path` defaults to the attachment's NAME, not to the placeholder, so a
3342
+ * placeholder called anything other than its PNG's basename resolves a region no
3343
+ * atlas has. Stating it is therefore not decoration: without it the loader
3344
+ * throws `Region not found in atlas`, which `A00_ROUNDTRIP_PARSE` reports in the
3345
+ * parser's own words.
3346
+ *
3347
+ * 🚨 The tree answered this in three different ways until issue #577, and
3348
+ * `docs/AUTHORING.md` §3.4 documented only one of them — *"rigc sets it for you
3349
+ * when the PNG basename differs from the placeholder"*. A region derived it, the
3350
+ * `contour` and `grid` generators derived it (one of them with a comment reading
3351
+ * "Same rule a region attachment follows"), and an authored mesh and the
3352
+ * `ring`/`ribbon` generators wrote `path` only when the spec stated one.
3353
+ * Measured on the probe rig: a region with `image: hair_short.png` under
3354
+ * placeholder `hair` gated green with `"path": "hair_short"`; the same image on
3355
+ * an authored mesh emitted no `path` and failed `A00_ROUNDTRIP_PARSE: threw:
3356
+ * Region not found in atlas: hair (attachment: hair)`. The asymmetry had already
3357
+ * been paid for by hand — `selftest.ts`'s own `TIMELINE_RIG` restates
3358
+ * `path: "block"` and `path: "marker"` on two authored meshes for no other
3359
+ * reason.
3360
+ *
3361
+ * ⭐ Deriving is the reading that was already written down, in the doc and in
3362
+ * two of the five emit sites. The alternative — document the asymmetry and
3363
+ * refuse a mesh whose basename differs without a `path` — was rejected because
3364
+ * it makes a hand-kept exception out of a rule the format applies to both kinds
3365
+ * through one line of parser, and it would have had to contradict §3.4 rather
3366
+ * than satisfy it.
3367
+ */
3368
+ function attachmentPath(att: { path?: string; image?: string }, placeholder: string): string | undefined {
3369
+ if (att.path !== undefined) return att.path;
3370
+ if (att.image === undefined) return undefined;
3371
+ const region = basename(att.image, '.png');
3372
+ return region === placeholder ? undefined : region;
3373
+ }
3374
+
3194
3375
  function buildRigRegion(
3195
3376
  att: RigRegionAttachment,
3196
3377
  placeholder: string,
@@ -3237,9 +3418,8 @@ function buildRigRegion(
3237
3418
  );
3238
3419
  }
3239
3420
  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;
3421
+ const path = attachmentPath(att, placeholder);
3422
+ if (path !== undefined) out.path = path;
3243
3423
  if (att.x !== undefined) out.x = r6(att.x);
3244
3424
  if (att.y !== undefined) out.y = r6(att.y);
3245
3425
  if (att.rotation !== undefined) out.rotation = r6(att.rotation);
@@ -3300,6 +3480,16 @@ function encodeNamedWeights(weights: RigMeshBinding[][], where: string, ctx: Att
3300
3480
  * a part with no art at all (0 of 0 pixels is not a percentage). Neither is an
3301
3481
  * error here — a mesh with no image is ordinary data, and an all-transparent part
3302
3482
  * is somebody else's assertion to make.
3483
+ *
3484
+ * 🚨 The asymmetry was a promise this function could not keep, and nothing here
3485
+ * said so (issue #570). `partPlate` three frames down called `extractRegion`,
3486
+ * which refused a region a foreign pack had turned — so an authored mesh naming
3487
+ * an `image` under `--atlas-in` ENDED the build, by a refusal raised inside a
3488
+ * measurement, on 36 of 42 atlases of the pack the card was filed from. The
3489
+ * repair was to make the refusal unnecessary rather than to catch it: reading a
3490
+ * turned region is a transcription of `MeshAttachment.computeUVs` and is now
3491
+ * what `extractRegion` does, so there is no unmeasurable case left for this
3492
+ * function to report and no catch here to keep reachable.
3303
3493
  */
3304
3494
  function measureAuthoredFit(att: RigMeshAttachment, ctx: AttachmentContext): MeshFitReport | null {
3305
3495
  if (att.image === undefined || att.uvs === undefined || att.triangles === undefined) return null;
@@ -3466,7 +3656,8 @@ function buildRigMesh(
3466
3656
  width: r6(width),
3467
3657
  height: r6(height),
3468
3658
  };
3469
- if (att.path !== undefined) out.path = att.path;
3659
+ const path = attachmentPath(att, placeholder);
3660
+ if (path !== undefined) out.path = path;
3470
3661
  if (att.color !== undefined) out.color = att.color;
3471
3662
  // Register it as `authored`: geometry rigc did not build and whose topology it
3472
3663
  // therefore gets to assume nothing about. The generator-topology assertions
@@ -3557,7 +3748,8 @@ function buildGeneratedMesh(
3557
3748
  width: r6(w),
3558
3749
  height: r6(h),
3559
3750
  };
3560
- if (att.path !== undefined) out.path = att.path;
3751
+ const path = attachmentPath(att, placeholder);
3752
+ if (path !== undefined) out.path = path;
3561
3753
  if (att.color !== undefined) out.color = att.color;
3562
3754
  return out;
3563
3755
  }
@@ -4035,8 +4227,8 @@ function buildGridAttachment(
4035
4227
  width: r6(w),
4036
4228
  height: r6(h),
4037
4229
  };
4038
- if (att.path !== undefined) out.path = att.path;
4039
- else if (img.region !== placeholder) out.path = img.region;
4230
+ const path = attachmentPath(att, placeholder);
4231
+ if (path !== undefined) out.path = path;
4040
4232
  if (att.color !== undefined) out.color = att.color;
4041
4233
  return out;
4042
4234
  }
@@ -4174,9 +4366,11 @@ function buildContourAttachment(
4174
4366
  };
4175
4367
  // Same rule a region attachment follows: the atlas region is the PNG's
4176
4368
  // 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;
4369
+ // the loader resolves nothing. Stated once in `attachmentPath` since #577 —
4370
+ // this comment used to be the rule's only statement, beside four emit sites
4371
+ // that disagreed with it.
4372
+ const path = attachmentPath(att, placeholder);
4373
+ if (path !== undefined) out.path = path;
4180
4374
  if (att.color !== undefined) out.color = att.color;
4181
4375
  return out;
4182
4376
  }
@@ -5647,12 +5841,15 @@ function deformGeometryOf(
5647
5841
  * pair too long, or aimed at the wrong attachment, loses its tail and
5648
5842
  * deforms part of the mesh correctly. That is the worst possible failure
5649
5843
  * 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
5844
+ * 2. **`fromVertex` where a vertex is not one pair.** See below.
5845
+ * 3. **A key that carries both a run and no room for one**, or a non-finite
5654
5846
  * offset — a NaN in the deform array propagates into world vertices.
5655
5847
  *
5848
+ * ⛔ What is **not** refused, and was until issue #576: an odd `offset` or an odd
5849
+ * run length. Both are raw copies at raw indices in both readers, both are what a
5850
+ * trimmed editor delta looks like, and neither has a second spelling — see the
5851
+ * clause in the loop below and the one in `deformStart`.
5852
+ *
5656
5853
  * `fromVertex` is rigc's own field and the reason it exists is issue #89's
5657
5854
  * observation: a deform key is the only key in the format whose meaning depends
5658
5855
  * on the attachment it is attached to, and an author reasons in vertices while
@@ -5811,11 +6008,31 @@ function compileDeformTrack(
5811
6008
  'or omit it entirely for "back to the setup pose"',
5812
6009
  );
5813
6010
  }
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
- }
6011
+ // ⛔ No parity clause here, and its absence is the rule (issue #576).
6012
+ //
6013
+ // A run is copied, not decoded. `SkeletonJson`'s deform branch does
6014
+ // `Utils.arrayCopy(verticesValue, 0, deform, start, verticesValue.length)`
6015
+ // with `start` the key's own `offset`, and `SkeletonBinary` reads a count and
6016
+ // a start and fills `for (let v = start; v < end; v++) deform[v] = ...`.
6017
+ // Neither has any pair arithmetic to be misaligned against, so an ODD run is
6018
+ // legal, deterministic data: it writes the x and y of one vertex and the x of
6019
+ // the next, and that next y stays at its setup value.
6020
+ //
6021
+ // 🔒 The reason this cannot be "refused in THIS spec anyway" is that there is
6022
+ // no other spelling of it. Padding a `0` to make the run even is a different
6023
+ // animation wherever the setup y it lands on is non-zero, so a spec that
6024
+ // refuses an odd run is a spec no transcription of such a file can be written
6025
+ // in — and one is in this repository's own example corpus (`spineboy-pro`,
6026
+ // `hoverboard` / `hoverboard-board`: `offset: 1` and 147 numbers into a
6027
+ // 148-long array, the whole delta minus the leading zero the editor trimmed).
6028
+ //
6029
+ // What replaces it is the bound the runtime really has, below: the run has to
6030
+ // FIT. That one is the quiet defect — a copy past the end of a `Float32Array`
6031
+ // is a no-op in JavaScript — and it is unaffected by where a run starts or
6032
+ // how long it is. `A35_DEFORM_KEYS_FIT_THE_ATTACHMENT` measures the same
6033
+ // bound on the emitted file and has had no parity clause since issue #262;
6034
+ // until this was removed the two halves of rigc disagreed about what the
6035
+ // format holds, and the half that refused was the authoring half.
5819
6036
  for (const n of run) {
5820
6037
  if (typeof n !== 'number' || !Number.isFinite(n)) {
5821
6038
  throw new CompileError(`${where} (t=${key.t}): "vertices" holds a non-finite value ${JSON.stringify(n)}`);
@@ -5884,10 +6101,10 @@ function expandDeformToInfluences(displacements: number[], geometry: DeformGeome
5884
6101
  /**
5885
6102
  * Where in the deform array this key's run begins.
5886
6103
  *
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.
6104
+ * `offset` is that index outright — any index the array holds, odd ones included
6105
+ * (issue #576). `fromVertex` is a vertex index, and turning one into the other is
6106
+ * exact only where a vertex occupies exactly one pair — which is every vertex of
6107
+ * an unweighted attachment and only the single-bone vertices of a weighted one.
5891
6108
  */
5892
6109
  function deformStart(
5893
6110
  key: MotionDeformTrack['keys'][number],
@@ -5901,12 +6118,16 @@ function deformStart(
5901
6118
  `${where} (t=${key.t}): offset is ${JSON.stringify(key.offset)}; it is an index into the deform array, so a whole number ≥ 0`,
5902
6119
  );
5903
6120
  }
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
- }
6121
+ // ⛔ An odd `offset` is not refused either, and it went the same way as the
6122
+ // run's own length (issue #576). The refusal that stood here read an odd
6123
+ // start as a `fromVertex` typed into the wrong field — a real mistake, but
6124
+ // this caught exactly the half of it whose index happens to be odd:
6125
+ // `offset: 4` meant as vertex 4 is the same mistake, lands on vertex 2, and
6126
+ // was always accepted. What it did refuse was every faithful transcription of
6127
+ // a trimmed editor run, one of which ships in `examples/` — `spineboy-pro`'s
6128
+ // `hoverboard-board` starts at 1. A rule that filters one parity of a
6129
+ // confusion it cannot see, at the price of a construct the format holds, is
6130
+ // the wrong instrument; `A35` dropped the same clause in issue #262.
5910
6131
  return key.offset;
5911
6132
  }
5912
6133
  if (key.fromVertex === undefined) return 0;
@@ -5914,7 +6135,12 @@ function deformStart(
5914
6135
  if (!Number.isInteger(from) || from < 0) {
5915
6136
  throw new CompileError(`${where} (t=${key.t}): fromVertex is ${JSON.stringify(from)}; it is a vertex index, so a whole number ≥ 0`);
5916
6137
  }
5917
- const covered = runLength / 2;
6138
+ // ⌈⌉ rather than ÷, because an odd run REACHES a last vertex without covering
6139
+ // it: its final number is that vertex's x and the y beside it stays at setup
6140
+ // (issue #576). The bound below is about which vertices the run reaches, so the
6141
+ // half-reached one counts — and on a weighted attachment its influence count is
6142
+ // checked with the rest.
6143
+ const covered = Math.ceil(runLength / 2);
5918
6144
  if (from + covered > geometry.vertexCount) {
5919
6145
  throw new CompileError(
5920
6146
  `${where} (t=${key.t}): fromVertex ${from} plus ${covered} vertex offset(s) runs to vertex ${from + covered}, ` +