spine-rigc 1.5.2 → 1.6.1

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
@@ -38,10 +38,9 @@ import { parseJsonWithPosition } from './json-position.ts';
38
38
  // The "did you mean" list on a missing atlas region, from the one implementation
39
39
  // of it — the same search serves `refuseUnknownKeys`, and a second copy here with
40
40
  // a threshold edited is how such a pair drifts apart.
41
- import { dottedPath, nearMisses, refuseNumbersTheFileCannotCarry, refuseValuesOfTheWrongType } from './keys.ts';
41
+ import { dottedPath, nearMisses, refuseNumbersTheFileCannotCarry, refuseValuesOfTheWrongType, refuseValuesOutsideTheirSet } from './keys.ts';
42
42
  import type { ShapeVisit } from './keys.ts';
43
- import { MANIFEST_TYPES } from './types.ts';
44
- import { inEditorKeyOrder, withoutParserDefaults } from './keyorder.ts';
43
+ import { MANIFEST_ENUMS, MANIFEST_TYPES } from './types.ts';
45
44
  import { EVERY_GLOBAL_PHYSICS, parseMotionSpec } from './motion.ts';
46
45
  import {
47
46
  BONE_INHERIT_KNOWN,
@@ -79,7 +78,7 @@ import {
79
78
  buildRibbonMesh,
80
79
  buildRingMesh,
81
80
  checkHullOrder,
82
- encodeWeightedVertices,
81
+ bindWeightedVertices,
83
82
  formatWalk,
84
83
  measureAuthoredMeshFit,
85
84
  meshEdges,
@@ -128,6 +127,34 @@ import {
128
127
  TransformError,
129
128
  type BoneTransform,
130
129
  } from './transform.ts';
130
+ import { emitSkeleton, PHYSICS_PARAMS, UNSTATED_REFERENCE_SCALE, type SkeletonHeader } from './emit_spine.ts';
131
+ import {
132
+ isModelVertexAttachment,
133
+ type CarriedFromCompileResult,
134
+ type CompiledAnimation,
135
+ type ModelAttachmentTimelines,
136
+ type ModelBinding,
137
+ type ModelBone,
138
+ type ModelBoundingBoxAttachment,
139
+ type ModelClippingAttachment,
140
+ type ModelConstraint,
141
+ type ModelConstraintKind,
142
+ type ModelEvent,
143
+ type ModelKey,
144
+ type ModelLinkedMeshAttachment,
145
+ type ModelMeshAttachment,
146
+ type ModelPathAttachment,
147
+ type ModelAtlasRect,
148
+ type ModelRegionAttachment,
149
+ type ModelRegionSequence,
150
+ type ModelSequence,
151
+ type ModelSkin,
152
+ type ModelSlot,
153
+ type ModelTimelines,
154
+ type ModelVertices,
155
+ type SkinTable,
156
+ type SkinTableEntry,
157
+ } from './model.ts';
131
158
  import type {
132
159
  CompileResult,
133
160
  CompiledImage,
@@ -147,20 +174,6 @@ import type {
147
174
  MotionValueKey,
148
175
  MotionValueTrack,
149
176
  RigInfo,
150
- SpineAttachment,
151
- SpineBone,
152
- SpineBoundingBoxAttachment,
153
- SpineClippingAttachment,
154
- SpineConstraint,
155
- SpineEvent,
156
- SpineLinkedMeshAttachment,
157
- SpineMeshAttachment,
158
- SpinePathAttachment,
159
- SpineRegionAttachment,
160
- SpineSequence,
161
- SpineSkeletonJson,
162
- SpineSlot,
163
- SpineTimelineKey,
164
177
  } from './types.ts';
165
178
 
166
179
  export { CompileError, NotImplementedError };
@@ -292,11 +305,15 @@ const FRAME = 1 / 60;
292
305
  *
293
306
  * The comparator is hand-rolled and locale-independent, which `A18` requires:
294
307
  * `localeCompare` would make the emitted bytes a property of the machine.
308
+ *
309
+ * Handed to the Spine emitter as its `AnimationOrder` (issue #921): it takes
310
+ * the model's names and `emitAnimations` keys the object in the order this
311
+ * returns, so the refusal below is raised there, inside `compile`, in these
312
+ * words. Exported so the selftest hands it to the emitter the way the assembly
313
+ * does (`MA03`, `MA08`).
295
314
  */
296
- function editorAnimationOrder<T>(animations: Record<string, T>): Record<string, T> {
297
- const ordered: Record<string, T> = {};
298
- for (const name of editorNamesInOrder(Object.keys(animations), 'animations')) ordered[name] = animations[name];
299
- return ordered;
315
+ export function editorAnimationOrder(names: readonly string[]): string[] {
316
+ return editorNamesInOrder(names, 'animations');
300
317
  }
301
318
 
302
319
  /**
@@ -374,8 +391,12 @@ const DEFAULT_SKIN = 'default';
374
391
  * ⇒ Every skin name in this tree is lower-case ASCII without digits, so no
375
392
  * emitted byte moves and no rig is refused. What moves is the claim, and what
376
393
  * stops being refused is a rig with capitals, folders or digits in its skins.
394
+ *
395
+ * Exported, with `editorSlotKeyOrder`, because the Spine emitter applies both
396
+ * (`emitSkins` takes them as its `EditorOrder`, issue #919) and the selftest
397
+ * hands them to it the way the assembly does (`MS07`, `MS08`).
377
398
  */
378
- function editorSkinOrder<T extends { name: string }>(skins: readonly T[]): T[] {
399
+ export function editorSkinOrder<T extends { name: string }>(skins: readonly T[]): T[] {
379
400
  const pinned = skins.filter((skin) => skin.name === DEFAULT_SKIN);
380
401
  const rest = skins.filter((skin) => skin.name !== DEFAULT_SKIN);
381
402
  // Ranked rather than looked up by name: two skins answering to one name are a
@@ -421,7 +442,7 @@ function editorSkinOrder<T extends { name: string }>(skins: readonly T[]): T[] {
421
442
  * attachment name holding `/` is the common case the folder question would
422
443
  * have to answer first.
423
444
  */
424
- function editorSlotKeyOrder<T>(attachments: Record<string, T>): Record<string, T> {
445
+ export function editorSlotKeyOrder<T>(attachments: Record<string, T>): Record<string, T> {
425
446
  const names = Object.keys(attachments);
426
447
  if (names.some((name) => name.includes(NAME_FOLDER))) return attachments;
427
448
  for (let i = 0; i < names.length; i++) {
@@ -839,8 +860,14 @@ const FLOAT32_BITS = new Uint32Array(FLOAT32_SCRATCH.buffer);
839
860
  * `137.5`. What this does NOT do is snap a double's residue to zero: `r6`'s grid
840
861
  * was absolute and swallowed anything under 5e-7, a float's is relative, so a
841
862
  * residue of 1e-15 is a float in its own right and is emitted as one.
863
+ *
864
+ * 🔒 Exported for one reader: `MX01` in `selftest.ts` defines "on the float32
865
+ * grid" as a fixed point of this function, `f32(x) === x`, and holds every
866
+ * number the model document spells to it or to the six-decimal grid (issue
867
+ * #942). `Math.fround(x) === x` is not that test: this writes `0.2`, whose
868
+ * float is 0.20000000298023224, so it would refuse the emitter's own output.
842
869
  */
843
- function f32(n: number): number {
870
+ export function f32(n: number): number {
844
871
  const f = Math.fround(n);
845
872
  if (f === 0 || !Number.isFinite(f)) return f === 0 ? 0 : f;
846
873
  const magnitude = Math.abs(f);
@@ -1187,7 +1214,7 @@ const BONE_TRACKS: Record<string, ValueTrackShape> = {
1187
1214
  * `:1062` and only `mix` reassigns it (`:1090`), so an `inertia` key that omits
1188
1215
  * `value` reads **0** — not the 0.5 that `:306` gives a constraint that states
1189
1216
  * no `inertia`. Two different tables of defaults sit forty lines apart in one
1190
- * file (`PHYSICS_PARAMS` above holds the other one), and reading the setup
1217
+ * file (`PHYSICS_PARAMS` in `src/emit_spine.ts` holds the other one), and reading the setup
1191
1218
  * column into this one would emit a `damping` timeline whose omitted keys mean
1192
1219
  * 0.85 to the author and 0 to the runtime. Nothing here depends on the number,
1193
1220
  * because `compileValueTrack` writes every field explicitly — it is recorded
@@ -1418,24 +1445,15 @@ const CONSTRAINT_TIMELINES: Record<'ik' | 'transform', ConstraintTimelineShape>
1418
1445
  };
1419
1446
 
1420
1447
  /**
1421
- * Physics constraint fields and their parser defaults (SkeletonJson.js:295-319).
1448
+ * The five components a physics constraint drives. The parameters and their
1449
+ * parser defaults are `PHYSICS_PARAMS`, which moved to `src/emit_spine.ts` with
1450
+ * the omission it decides (issue #919).
1422
1451
  *
1423
1452
  * `PHYSICS_COMPONENTS` is exported for `ingest.ts`, which omits a constraint that
1424
1453
  * drives none of them (issue #731) and must read "drives" off the same five
1425
1454
  * names this module refuses an empty constraint with, not off a copy.
1426
1455
  */
1427
1456
  export const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
1428
- const PHYSICS_PARAMS: Array<[string, number]> = [
1429
- ['inertia', 0.5],
1430
- ['strength', 100],
1431
- ['damping', 0.85],
1432
- ['mass', 1],
1433
- ['wind', 0],
1434
- ['gravity', 0],
1435
- ['mix', 1],
1436
- ['fps', 60],
1437
- ['limit', 5000],
1438
- ];
1439
1457
 
1440
1458
  // ---------------------------------------------------------------------------
1441
1459
  // curves
@@ -2062,7 +2080,13 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2062
2080
  // same rule over the other file whose numbers reach the skeleton.
2063
2081
  // Its values of the wrong type first (issue #890): `crop.h: "806"` built
2064
2082
  // green, the string coerced by whatever arithmetic read it.
2065
- if (manifest !== null && manifestPath !== null) refuseValuesOfTheWrongType(manifestShapeVisits(manifest), MANIFEST_TYPES, manifestPath);
2083
+ if (manifest !== null && manifestPath !== null) {
2084
+ const visits = manifestShapeVisits(manifest);
2085
+ refuseValuesOfTheWrongType(visits, MANIFEST_TYPES, manifestPath);
2086
+ // Then a name outside a stated set (issue #900): `mesh.kind: "foo"` was
2087
+ // read as a ribbon by one reader and a ring by two others.
2088
+ refuseValuesOutsideTheirSet(visits, MANIFEST_TYPES, MANIFEST_ENUMS, manifestPath);
2089
+ }
2066
2090
  if (manifest !== null && manifestPath !== null) refuseNumbersTheFileCannotCarry(manifest, manifestPath, dottedPath);
2067
2091
 
2068
2092
  // The rig spec names its own path in every message `parseRigSpec` throws (its
@@ -2531,7 +2555,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2531
2555
  const axisSpineDeg = manifest?.axis ? screenToSpineDegrees(manifest.axis.deg) : null;
2532
2556
  const partBySlot = new Map(parts.map((part) => [part.slot, part]));
2533
2557
 
2534
- const bones: SpineBone[] = [];
2558
+ const bones: ModelBone[] = [];
2535
2559
  for (const spec of rig.bones) {
2536
2560
  bones.push(buildBone(spec, bones, { rig, manifest, cropH, axisSpineDeg, partBySlot }));
2537
2561
  }
@@ -2557,9 +2581,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2557
2581
  // -- 4. slots + skins ------------------------------------------------------
2558
2582
  // Draw order IS the slots array order. No separate field,
2559
2583
  // and the rig's array is that order.
2560
- const slots: SpineSlot[] = [];
2561
- const skinTables = new Map<string, Record<string, Record<string, SpineAttachment>>>();
2562
- const tableFor = (skinName: string): Record<string, Record<string, SpineAttachment>> => {
2584
+ const slots: ModelSlot[] = [];
2585
+ // Every attachment is a model record (`SkinTableEntry`); the emitter turns the
2586
+ // tables into Spine's at assembly, and `CompiledModel.skins` holds them.
2587
+ const skinTables = new Map<string, SkinTable>();
2588
+ const tableFor = (skinName: string): SkinTable => {
2563
2589
  let table = skinTables.get(skinName);
2564
2590
  if (!table) {
2565
2591
  table = {};
@@ -2697,11 +2723,13 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2697
2723
  if (setup?.color && rigSlot.color !== undefined) {
2698
2724
  throw new CompileError(`slot "${rigSlot.name}" has a setup colour in the rig spec AND in the motion spec`);
2699
2725
  }
2700
- const slot: SpineSlot = { name: rigSlot.name, bone: rigSlot.bone };
2701
- if (setupAttachment !== null) slot.attachment = setupAttachment;
2726
+ // The setup attachment is held as the value it is, `null` included; leaving
2727
+ // the key out for `null` is the emitter's (`emitSlots`).
2728
+ const slot: ModelSlot = { name: rigSlot.name, bone: rigSlot.bone, setup: setupAttachment };
2702
2729
  if (setup?.color) slot.color = rgbaHex(setup.color);
2703
2730
  else if (rigSlot.color !== undefined) slot.color = rigSlot.color;
2704
2731
  if (rigSlot.dark !== undefined) slot.dark = rigSlot.dark;
2732
+ // As stated: `parseRigSpec` refused every spelling the runtime would read as no mode (issue #946).
2705
2733
  if (rigSlot.blend !== undefined) slot.blend = rigSlot.blend;
2706
2734
  slots.push(slot);
2707
2735
  // ...and nothing below this line has anything to build. An empty entry in a
@@ -2711,7 +2739,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2711
2739
  if (empty) continue;
2712
2740
 
2713
2741
  if (part) {
2714
- const perSlot: Record<string, SpineAttachment> = {};
2742
+ const perSlot: Record<string, SkinTableEntry> = {};
2715
2743
  const mesh = part.mesh ? buildMesh(part, manifest!, bones, transforms, rigSlot.bone) : null;
2716
2744
  for (const name of names) {
2717
2745
  const img = images.find((im) => im.region === name);
@@ -2745,7 +2773,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2745
2773
  for (const skinName of skinNames) {
2746
2774
  const placeholders = skinParts.get(skinName)!.attachments[rigSlot.name];
2747
2775
  if (!placeholders) continue;
2748
- const perSlot: Record<string, SpineAttachment> = {};
2776
+ const perSlot: Record<string, SkinTableEntry> = {};
2749
2777
  for (const [placeholder, att] of Object.entries(placeholders)) {
2750
2778
  const where = `skin "${skinName}" slot "${rigSlot.name}" attachment "${placeholder}"`;
2751
2779
  const built = buildRigAttachment(att, placeholder, where, {
@@ -2760,6 +2788,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2760
2788
  imagesDir,
2761
2789
  depths: attachmentDepths,
2762
2790
  slotNames: new Set(rig.slots.map((s) => s.name)),
2791
+ atlasRegions: atlasIn === null ? null : atlasIn.byName,
2763
2792
  links: pendingLinks,
2764
2793
  });
2765
2794
  // The name is put on AFTER the builder rather than inside it: six
@@ -2809,7 +2838,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2809
2838
  // (structure), then the motion spec's physics table (tuning). A name in both is
2810
2839
  // refused: `mix` timelines resolve by name, and two constraints answering to
2811
2840
  // one name is a timeline driving something nobody chose.
2812
- const constraints: SpineConstraint[] = [];
2841
+ const constraints: ModelConstraint[] = [];
2813
2842
  const physicsReport: CompileResult['physics'] = [];
2814
2843
  // Deform keys that stated a model instead of a run (issue #294). Declared here
2815
2844
  // rather than inside the animation loop because `explain` reports it across
@@ -2845,8 +2874,8 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2845
2874
  constraintNamesOfKind.set(type, [...(constraintNamesOfKind.get(type) ?? []), name]);
2846
2875
  };
2847
2876
  /**
2848
- * ik constraint -> the booleans it declares that the timeline format would
2849
- * otherwise take away from it. Issue #273.
2877
+ * ik constraint -> its three booleans IN EFFECT: the value it declares, or
2878
+ * the parser's default where it declares none. Issue #273.
2850
2879
  *
2851
2880
  * 🚨 `SkeletonJson` reads `bendPositive`, `compress` and `stretch` in TWO
2852
2881
  * places with the same defaults: once on the constraint (`:155`) and once on
@@ -2855,19 +2884,23 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2855
2884
  * `bendPositive: false` and an `ik` timeline that keys only `mix` produce a
2856
2885
  * constraint that bends the other way for the whole animation, with the field
2857
2886
  * still in the file and inert: four builds differing only in these flags posed
2858
- * one pose. What goes in this map is only the values that DIFFER from the
2859
- * per-key default, because a rig that says nothing and a key that says nothing
2860
- * already agree and there is nothing to carry.
2887
+ * one pose. So a key that states no flag takes the constraint's from this
2888
+ * map, and the model's ik key holds all three flags in effect (`ModelKey`).
2889
+ * Which of them the file carries is the Spine emitter's: only those that
2890
+ * differ from the per-key default (`emitAnimations`, issue #921), which is
2891
+ * what this map held before — a rig that says nothing and a key that says
2892
+ * nothing already agree, so nothing is written for them.
2861
2893
  */
2862
- const ikRigFlags = new Map<string, Record<string, boolean>>();
2894
+ const ikFlagsInEffect = new Map<string, Record<string, boolean>>();
2863
2895
  // Which slots can actually show a path, for the path constraint's own check.
2864
- // Read off the emitted skin tables rather than the spec, so it answers the
2865
- // question the runtime asks: is there an attachment of that type on that slot?
2896
+ // Read off the skin tables — the model records the file is emitted from —
2897
+ // rather than the spec, so it answers the question the runtime asks: is
2898
+ // there an attachment of that kind on that slot? (By `kind` since issue #919.)
2866
2899
  const pathSlots = new Map<string, string[]>();
2867
2900
  for (const [skinName, table] of skinTables) {
2868
2901
  for (const [slotName, perSlot] of Object.entries(table)) {
2869
2902
  for (const att of Object.values(perSlot)) {
2870
- if ((att as { type?: string }).type !== 'path') continue;
2903
+ if (att.kind !== 'path') continue;
2871
2904
  pathSlots.set(slotName, [...(pathSlots.get(slotName) ?? []), skinName]);
2872
2905
  break;
2873
2906
  }
@@ -2887,12 +2920,15 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2887
2920
  constraints.push(buildRigConstraint(spec, constraintCtx));
2888
2921
  declareConstraint(spec.name, spec.type);
2889
2922
  if (spec.type === 'ik') {
2890
- const carried: Record<string, boolean> = {};
2923
+ // `flag.dflt` is the per-key default, and the constraint's own parser
2924
+ // default is the same value for each of the three
2925
+ // (`PARSER_DEFAULTS['ik constraint']` and `['ik key']` in keyorder.ts).
2926
+ const inEffect: Record<string, boolean> = {};
2891
2927
  for (const flag of CONSTRAINT_TIMELINES.ik.flags) {
2892
2928
  const declared = spec[flag.field];
2893
- if (typeof declared === 'boolean' && declared !== flag.dflt) carried[flag.field] = declared;
2929
+ inEffect[flag.field] = typeof declared === 'boolean' ? declared : flag.dflt;
2894
2930
  }
2895
- if (Object.keys(carried).length) ikRigFlags.set(spec.name, carried);
2931
+ ikFlagsInEffect.set(spec.name, inEffect);
2896
2932
  }
2897
2933
  }
2898
2934
  withMotionSource(() => {
@@ -2910,17 +2946,17 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2910
2946
  if (!boneNames.has(spec.bone)) {
2911
2947
  throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
2912
2948
  }
2913
- const entry: SpineConstraint = {
2914
- name,
2915
- type: 'physics',
2916
- bone: spec.bone,
2917
- };
2949
+ // Every component and parameter the table states is held, 0 and the
2950
+ // parser's default included; which of them Spine leaves out is the
2951
+ // emitter's (`emitConstraint`). `components` is the report's list of the
2952
+ // ones that DRIVE, as it always was.
2953
+ const entry: ModelConstraint = { kind: 'physics', name, declaredIn: 'motion', bone: spec.bone };
2918
2954
  const components: string[] = [];
2919
2955
  for (const comp of PHYSICS_COMPONENTS) {
2920
2956
  const v = spec[comp];
2921
- if (v === undefined || v === 0) continue;
2957
+ if (v === undefined) continue;
2922
2958
  entry[comp] = f32(v);
2923
- components.push(comp);
2959
+ if (v !== 0) components.push(comp);
2924
2960
  }
2925
2961
  if (!components.length) {
2926
2962
  // The parser is happy with this and the constraint does nothing at all.
@@ -2929,9 +2965,9 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2929
2965
  `physics constraint "${name}" drives no component — set at least one of ${PHYSICS_COMPONENTS.join('/')}`,
2930
2966
  );
2931
2967
  }
2932
- for (const [param, dflt] of PHYSICS_PARAMS) {
2968
+ for (const [param] of PHYSICS_PARAMS) {
2933
2969
  const v = spec[param as keyof typeof spec] as number | undefined;
2934
- if (v === undefined || v === dflt) continue;
2970
+ if (v === undefined) continue;
2935
2971
  entry[param] = f32(v);
2936
2972
  }
2937
2973
  constraints.push(entry);
@@ -2977,12 +3013,15 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2977
3013
  // the refusal below reports as the skins it looked in, so it is read from the
2978
3014
  // map rather than restated.
2979
3015
  const attachmentIndex: SlotAttachmentIndex = { bySlot: attachmentsBySlot, searched: [...skinTables.keys()] };
2980
- const animations: SpineSkeletonJson['animations'] = {};
3016
+ // The model's animations, in the motion spec's order; `emitAnimations` keys
3017
+ // them in the editor's at assembly.
3018
+ const animations = new Map<string, CompiledAnimation>();
2981
3019
  const declaredDurations: Record<string, number> = {};
2982
3020
  const slotNames = new Set(slots.map((s) => s.name));
2983
- // Which slots an `rgba2` timeline may be keyed on: the ones the EMITTED file
2984
- // gives a `dark`, for the same reason `attachmentIndex` is built off the
2985
- // emitted skins — what the runtime does with a timeline depends on the file,
3021
+ // Which slots an `rgba2` timeline may be keyed on: the model slots with a
3022
+ // `dark` — the slots the file is emitted from, which `emitSlots` writes each
3023
+ // `dark` of (issue #919) — for the same reason `attachmentIndex` is built off
3024
+ // the skin tables: what the runtime does with a timeline depends on the file,
2986
3025
  // not on the spec that produced it. A slot whose `dark` never reached the
2987
3026
  // artifact is a slot the runtime allocates no dark colour for (issue #690).
2988
3027
  const darkSlots = new Set(slots.filter((s) => s.dark !== undefined).map((s) => s.name));
@@ -2991,13 +3030,24 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2991
3030
  checkMotionGroups(motion);
2992
3031
  for (const [animName, anim] of Object.entries(motion.animations)) {
2993
3032
  declaredDurations[animName] = anim.duration;
2994
- const slotTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
2995
- const boneTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
2996
- /** One table per constraint family, keyed the way the file is. */
2997
- const familyTimelines: Record<ConstraintTrackFamily, Record<string, Record<string, SpineTimelineKey[]>>> = {
2998
- physics: {},
2999
- path: {},
3000
- slider: {},
3033
+ const slotTimelines = new Map<string, ModelTimelines>();
3034
+ const boneTimelines = new Map<string, ModelTimelines>();
3035
+ /**
3036
+ * One table per constraint family, target -> timelines. The physics
3037
+ * timeline that names no constraint is filed under its own target,
3038
+ * `EVERY_GLOBAL_PHYSICS`, in the position its track holds among the
3039
+ * named ones (`CompiledAnimation`).
3040
+ */
3041
+ const familyTimelines: Record<ConstraintTrackFamily, Map<string, ModelTimelines>> = {
3042
+ physics: new Map(),
3043
+ path: new Map(),
3044
+ slider: new Map(),
3045
+ };
3046
+ /** `targets.get(target)`, made on first use — the map keeps the order targets are first keyed in. */
3047
+ const timelinesOf = (targets: Map<string, ModelTimelines>, target: string): ModelTimelines => {
3048
+ let timelines = targets.get(target);
3049
+ if (timelines === undefined) targets.set(target, (timelines = new Map()));
3050
+ return timelines;
3001
3051
  };
3002
3052
  const claimed = new Set<string>();
3003
3053
  let compiledDuration = 0;
@@ -3025,7 +3075,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3025
3075
  );
3026
3076
  }
3027
3077
  if (everyGlobal) {
3028
- const physics = constraints.filter((one) => one.type === 'physics');
3078
+ const physics = constraints.filter((one) => one.kind === 'physics');
3029
3079
  const flag = `${track.property}Global`;
3030
3080
  const reached =
3031
3081
  track.property === 'reset'
@@ -3097,7 +3147,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3097
3147
  // the fault is the pair, not a key.
3098
3148
  if (family === null && !isBoneTrack) {
3099
3149
  const mine = SLOT_COLOR_CHANNELS[track.property] ?? [];
3100
- for (const other of Object.keys(slotTimelines[target] ?? {})) {
3150
+ for (const other of slotTimelines.get(target)?.keys() ?? []) {
3101
3151
  const shared = (SLOT_COLOR_CHANNELS[other] ?? []).filter((channel) => mine.includes(channel));
3102
3152
  if (shared.length === 0) continue;
3103
3153
  throw new CompileError(
@@ -3127,11 +3177,12 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3127
3177
  : isBoneTrack
3128
3178
  ? compileValueTrack(resolved, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
3129
3179
  : compileTrack(resolved, motion, animName, anim.duration, target, shift, attachmentIndex, darkSlots);
3130
- for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
3131
- // Under the empty name, which is the file's own spelling of it.
3132
- if (family !== null) (familyTimelines[family][everyGlobal ? '' : target] ??= {})[track.property] = keys;
3133
- else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
3134
- else (slotTimelines[target] ??= {})[track.property] = keys;
3180
+ for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time);
3181
+ // The every-global physics timeline under its own target, `*`
3182
+ // (`everyGlobal`); the empty name is the Spine emitter's spelling.
3183
+ if (family !== null) timelinesOf(familyTimelines[family], target).set(track.property, keys);
3184
+ else if (isBoneTrack) timelinesOf(boneTimelines, target).set(track.property, keys);
3185
+ else timelinesOf(slotTimelines, target).set(track.property, keys);
3135
3186
  });
3136
3187
  }
3137
3188
 
@@ -3142,9 +3193,9 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3142
3193
  // The target is resolved by name AND by type: `findConstraint(name,
3143
3194
  // IkConstraintData)` misses a transform constraint of the same name and the
3144
3195
  // parser throws in the consumer's process, so the mismatch is named here.
3145
- const constraintTimelines: Record<'ik' | 'transform', Record<string, SpineTimelineKey[]>> = {
3146
- ik: {},
3147
- transform: {},
3196
+ const constraintTimelines: Record<'ik' | 'transform', Map<string, ModelKey[]>> = {
3197
+ ik: new Map(),
3198
+ transform: new Map(),
3148
3199
  };
3149
3200
  for (const group of ['ik', 'transform'] as const) {
3150
3201
  // The array, the entries and their `constraint` names are shapes, so
@@ -3169,7 +3220,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3169
3220
  : `the rig declares no ${group} constraint at all`),
3170
3221
  );
3171
3222
  }
3172
- if (constraintTimelines[group][name]) {
3223
+ if (constraintTimelines[group].has(name)) {
3173
3224
  throw new CompileError(
3174
3225
  `animation "${animName}" has two ${group} timelines on constraint "${name}"; ` +
3175
3226
  'the group holds one timeline per constraint, so merge them into one',
@@ -3184,18 +3235,25 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3184
3235
  // The ik table, asked only by the ik group: `leg` may also be a
3185
3236
  // transform constraint, and a transform key set carries no flag for
3186
3237
  // the rig's booleans to be stamped onto (issue #692).
3187
- group === 'ik' ? (ikRigFlags.get(name) ?? {}) : {},
3238
+ group === 'ik' ? (ikFlagsInEffect.get(name) ?? {}) : {},
3188
3239
  );
3189
- for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
3190
- constraintTimelines[group][name] = keys;
3240
+ for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time);
3241
+ constraintTimelines[group].set(name, keys);
3191
3242
  }
3192
3243
  }
3193
3244
 
3194
3245
  // -- deform timelines: keyed on a skin/slot/attachment triple ----------
3195
- // Four deep, because the format is: skin -> slot -> attachment -> timeline
3196
- // name -> keys. `deform` is one of two timeline names an attachment can
3197
- // carry (the other is `sequence`), which is why the level exists at all.
3198
- const deformTimelines: Record<string, Record<string, Record<string, Record<string, SpineTimelineKey[]>>>> = {};
3246
+ // skin -> slot -> attachment -> its `deform` and `sequence` keys: the two
3247
+ // timelines an attachment can carry, which is why the last level exists.
3248
+ const deformTimelines = new Map<string, Map<string, Map<string, ModelAttachmentTimelines>>>();
3249
+ /** The triple's entry, made on first use in the order triples are first keyed. */
3250
+ const attachmentTimelinesOf = (skin: string, slot: string): Map<string, ModelAttachmentTimelines> => {
3251
+ let bySlot = deformTimelines.get(skin);
3252
+ if (bySlot === undefined) deformTimelines.set(skin, (bySlot = new Map()));
3253
+ let byAttachment = bySlot.get(slot);
3254
+ if (byAttachment === undefined) bySlot.set(slot, (byAttachment = new Map()));
3255
+ return byAttachment;
3256
+ };
3199
3257
  const deformTracks: MotionDeformTrack[] = anim.deform ?? [];
3200
3258
  for (const track of deformTracks) {
3201
3259
  const skinName = track.skin ?? 'default';
@@ -3220,7 +3278,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3220
3278
  `(it has: ${Object.keys(perSlot).join(', ')})`,
3221
3279
  );
3222
3280
  }
3223
- if (deformTimelines[skinName]?.[track.slot]?.[track.attachment]) {
3281
+ if (deformTimelines.get(skinName)?.get(track.slot)?.has(track.attachment)) {
3224
3282
  throw new CompileError(`${at}: two deform timelines on one attachment; merge them into one`);
3225
3283
  }
3226
3284
  const keys = compileDeformTrack(
@@ -3229,13 +3287,13 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3229
3287
  animName,
3230
3288
  anim.duration,
3231
3289
  {
3232
- ...deformGeometryOf(attachment, at, bones, transforms),
3290
+ ...deformGeometryOf(attachment, at, transforms),
3233
3291
  depth: attachmentDepths.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
3234
3292
  },
3235
3293
  deformTransforms,
3236
3294
  );
3237
- for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
3238
- ((deformTimelines[skinName] ??= {})[track.slot] ??= {})[track.attachment] = { deform: keys };
3295
+ for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time);
3296
+ attachmentTimelinesOf(skinName, track.slot).set(track.attachment, { deform: keys });
3239
3297
  }
3240
3298
 
3241
3299
  // -- sequence timelines: the other attachment timeline (issue #729) -----
@@ -3267,22 +3325,22 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3267
3325
  );
3268
3326
  }
3269
3327
  const keys = compileSequenceTrack(track, anim.duration, attachment, at);
3270
- for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
3271
- const slot = ((deformTimelines[skinName] ??= {})[track.slot] ??= {});
3272
- if (slot[track.attachment]?.sequence) {
3328
+ for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time);
3329
+ const slot = attachmentTimelinesOf(skinName, track.slot);
3330
+ if (slot.get(track.attachment)?.sequence) {
3273
3331
  throw new CompileError(`${at}: two sequence timelines on one attachment; merge them into one`);
3274
3332
  }
3275
- slot[track.attachment] = { ...(slot[track.attachment] ?? {}), sequence: keys };
3333
+ slot.set(track.attachment, { ...(slot.get(track.attachment) ?? {}), sequence: keys });
3276
3334
  }
3277
3335
 
3278
- const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
3279
- if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
3336
+ const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : [];
3337
+ for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time);
3280
3338
 
3281
- const eventKeys = anim.events ? compileEvents(anim.events, animName, anim.duration, rig.events ?? {}) : null;
3339
+ const eventKeys = anim.events ? compileEvents(anim.events, animName, anim.duration, rig.events ?? {}) : [];
3282
3340
  // An event timeline counts towards the animation's length the same as any
3283
3341
  // other: `readAnimation` takes the duration from the longest timeline it
3284
3342
  // built, and `EventTimeline.getDuration()` is its last frame like the rest.
3285
- if (eventKeys) for (const key of eventKeys) compiledDuration = Math.max(compiledDuration, key.time as number);
3343
+ for (const key of eventKeys) compiledDuration = Math.max(compiledDuration, key.time);
3286
3344
 
3287
3345
  // Rule 4: the declared duration is verified, because skeleton JSON does not
3288
3346
  // carry one — the loader takes the max key time.
@@ -3298,133 +3356,101 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3298
3356
  `animation "${animName}" declares duration ${anim.duration}s but its last key is at ${compiledDuration}s`,
3299
3357
  );
3300
3358
  }
3301
- // Group order is `readAnimation`'s own reading order, so an emitted file
3302
- // diffs cleanly against an editor export. Each line is conditional, which
3303
- // is what keeps a spec that uses none of the new groups byte-identical.
3304
- animations[animName] = {};
3305
- if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
3306
- if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
3307
- if (Object.keys(constraintTimelines.ik).length) animations[animName].ik = constraintTimelines.ik;
3308
- if (Object.keys(constraintTimelines.transform).length) {
3309
- animations[animName].transform = constraintTimelines.transform;
3310
- }
3311
- if (Object.keys(familyTimelines.path).length) animations[animName].path = familyTimelines.path;
3312
- if (Object.keys(familyTimelines.physics).length) animations[animName].physics = familyTimelines.physics;
3313
- if (Object.keys(familyTimelines.slider).length) animations[animName].slider = familyTimelines.slider;
3314
- if (Object.keys(deformTimelines).length) animations[animName].attachments = deformTimelines;
3315
- if (drawOrder) animations[animName].drawOrder = drawOrder;
3316
- if (eventKeys) animations[animName].events = eventKeys;
3359
+ // The model's record of the animation. Which groups the file carries, in
3360
+ // what order and under what spelling is the Spine emitter's
3361
+ // (`emitAnimation`: `readAnimation`'s reading order, each group only when
3362
+ // non-empty).
3363
+ animations.set(animName, {
3364
+ duration: anim.duration,
3365
+ bones: boneTimelines,
3366
+ slots: slotTimelines,
3367
+ constraints: {
3368
+ ik: constraintTimelines.ik,
3369
+ transform: constraintTimelines.transform,
3370
+ path: familyTimelines.path,
3371
+ physics: familyTimelines.physics,
3372
+ slider: familyTimelines.slider,
3373
+ },
3374
+ attachments: deformTimelines,
3375
+ drawOrder,
3376
+ events: eventKeys,
3377
+ });
3317
3378
  }
3318
3379
  });
3319
3380
 
3320
3381
  // -- 6. assemble -----------------------------------------------------------
3321
3382
  //
3322
- // The stage is four fields or none of them. `x`/`y` are the origin of the box
3383
+ // What the skeleton carries that the model does not hold: the header. The
3384
+ // stage is four fields or none of them — `x`/`y` are the origin of the box
3323
3385
  // `width`/`height` give an extent to, so a header carrying an origin for a box
3324
- // it does not declare would be a shape no export has — and the key ORDER here
3325
- // is the editor's own, which is what keeps a staged build byte-identical to
3326
- // what it emitted before the stage could be declared absent (issue #578).
3327
- const header: SpineSkeletonJson['skeleton'] = { spine: SPINE_VERSION };
3328
- if (stageWidth !== undefined && stageHeight !== undefined) {
3329
- header.x = rig.skeleton?.x ?? 0;
3330
- header.y = rig.skeleton?.y ?? 0;
3331
- header.width = stageWidth;
3332
- header.height = stageHeight;
3333
- }
3334
- if (rig.skeleton?.fps !== undefined) header.fps = rig.skeleton.fps;
3335
- if (rig.skeleton?.referenceScale !== undefined) header.referenceScale = rig.skeleton.referenceScale;
3336
- const imagesPath = skeletonImagesPath(rig.skeleton?.images, opts, outDir, partDirs);
3337
- if (imagesPath !== undefined) header.images = imagesPath;
3338
- if (rig.skeleton?.audio !== undefined) header.audio = rig.skeleton.audio;
3339
-
3340
- // Event definitions. Emitted in the order the rig spec declares them — object
3341
- // key order is the spec's, not a set's, so A18 stays a contract.
3342
- const events: Record<string, SpineEvent> = {};
3386
+ // it does not declare would be a shape no export has (issue #578).
3387
+ const header: SkeletonHeader = {
3388
+ spine: SPINE_VERSION,
3389
+ stage:
3390
+ stageWidth !== undefined && stageHeight !== undefined
3391
+ ? { x: rig.skeleton?.x ?? 0, y: rig.skeleton?.y ?? 0, width: stageWidth, height: stageHeight }
3392
+ : null,
3393
+ fps: rig.skeleton?.fps,
3394
+ images: skeletonImagesPath(rig.skeleton?.images, opts, outDir, partDirs),
3395
+ audio: rig.skeleton?.audio,
3396
+ };
3397
+
3398
+ // Event definitions, in the order the rig spec declares them — the map's
3399
+ // insertion order is the spec's, not a set's, so A18 stays a contract.
3400
+ const events = new Map<string, ModelEvent>();
3343
3401
  for (const [name, def] of Object.entries(rig.events ?? {})) {
3344
- const entry: SpineEvent = {};
3402
+ const entry: ModelEvent = {};
3345
3403
  if (def.int !== undefined) entry.int = def.int;
3346
3404
  if (def.float !== undefined) entry.float = f32(def.float);
3347
3405
  if (def.string !== undefined) entry.string = def.string;
3348
3406
  if (def.audio !== undefined) entry.audio = def.audio;
3349
3407
  if (def.volume !== undefined) entry.volume = f32(def.volume);
3350
3408
  if (def.balance !== undefined) entry.balance = f32(def.balance);
3351
- events[name] = entry;
3409
+ events.set(name, entry);
3352
3410
  }
3411
+ // Every skin with what it activates and its table, in the spec's order: the
3412
+ // model's record of it. `splitRigSkin`'s lists, or none for a skin the spec
3413
+ // does not declare (a manifest's `default`).
3414
+ const skins: ModelSkin[] = [...skinTables].map(([name, attachments]) => {
3415
+ const parts = skinParts.get(name);
3416
+ return {
3417
+ name,
3418
+ bones: parts?.bones ?? [],
3419
+ constraints: Object.fromEntries(RIG_SKIN_CONSTRAINT_KEYS.map((key) => [key, parts?.constraints[key] ?? []])) as ModelSkin['constraints'],
3420
+ attachments,
3421
+ };
3422
+ });
3353
3423
 
3354
- const skeleton: SpineSkeletonJson = {
3355
- skeleton: header,
3356
- bones,
3357
- slots,
3358
- // A skin entry is `name`, then whatever it activates, then `attachments` —
3359
- // `readSkeletonData`'s own order (`:372-443`). Every member list is a
3360
- // conditional spread, so a rig that declares none emits the two-key entry it
3361
- // always did, byte for byte.
3362
- //
3363
- // Ordered the way `animations` is and for the same reason — the editor
3364
- // rewrites this array and the binary half addresses it by ordinal — with the
3365
- // sort applied HERE rather than to `skinTables`, so everything upstream (the
3366
- // path-slot table, every refusal that lists skins) still reads the order the
3367
- // rig spec declared. See `editorSkinOrder`.
3368
- skins: editorSkinOrder(
3369
- [...skinTables.entries()].map(([name, attachments]) => {
3370
- const parts = skinParts.get(name);
3371
- return {
3372
- name,
3373
- ...(parts?.bones.length ? { bones: parts.bones } : {}),
3374
- ...Object.fromEntries(
3375
- RIG_SKIN_CONSTRAINT_KEYS.filter((key) => parts?.constraints[key].length).map((key) => [
3376
- key,
3377
- parts!.constraints[key],
3378
- ]),
3379
- ),
3380
- attachments: editorSlotKeyOrder(attachments),
3381
- };
3382
- }),
3383
- ),
3384
- // Between `skins` and `animations`, which is where the editor writes it. A
3385
- // conditional spread rather than an assignment after the literal, so the key
3386
- // lands in that position instead of at the end.
3387
- ...(Object.keys(events).length ? { events } : {}),
3388
- // Keyed in the editor's own order rather than the motion spec's, because a
3389
- // slider's reference to an animation is an ordinal in the format and the
3390
- // editor re-sorts this object — see `editorAnimationOrder`. The sort is
3391
- // applied HERE and not to the loop above, so what the compiler reads, the
3392
- // order it reports durations in, and which animation a CompileError names
3393
- // first are all still the spec's own; only the emitted key order moves.
3394
- animations: editorAnimationOrder(animations),
3395
- };
3396
- if (constraints.length) skeleton.constraints = constraints;
3424
+ // The skeleton: the Spine emitter's one entry (issue #922), which writes every
3425
+ // section from the model, in the order and under the spellings the file has,
3426
+ // and runs the parser-default and key-order passes on the finished object.
3427
+ // What it is handed besides the model is what the model does not hold: the
3428
+ // header above, and the editor's orders, which stay here (see `editorSkinOrder` and
3429
+ // `editorAnimationOrder`): the sorts are applied at emission, so everything
3430
+ // this function reads, and the model's `skins` and `animations`, keep the
3431
+ // spec's own order.
3432
+ // The skeleton's reference scale, which wind and gravity act over (issue
3433
+ // #958): the rig's stated number as it is, or the value the parser reads a
3434
+ // header stating none as. The model holds it; the emitter writes it into the
3435
+ // header, where the parser-default pass drops it at 100 as before.
3436
+ const referenceScale = rig.skeleton?.referenceScale ?? UNSTATED_REFERENCE_SCALE;
3437
+ const skeleton = emitSkeleton(
3438
+ { referenceScale, bones, slots, skins, constraints, events, animations },
3439
+ header,
3440
+ { skins: editorSkinOrder, slotKeys: editorSlotKeyOrder, animations: editorAnimationOrder },
3441
+ );
3397
3442
 
3398
3443
  for (const slot of slots) {
3399
3444
  if (!boneNames.has(slot.bone)) throw new CompileError(`slot "${slot.name}" has no bone`);
3400
3445
  }
3401
3446
 
3402
- // Every object's keys in the order the editor writes them, per kind, from the
3403
- // one table that says so (`src/keyorder.ts`, issue #716) — applied here, once,
3404
- // to the finished object and before the text exists, so the constructors above
3405
- // are free to build in whatever order reads best and none of them states the
3406
- // order a second time. It moves positions and nothing else: no key is added,
3407
- // dropped or re-valued, and what the gate and `A18` read is this object's text.
3408
- //
3409
- // Before it, and on the same finished object: every key whose value is the
3410
- // one the 4.3 parser reads in its absence is left out, the way the editor
3411
- // leaves it out (issue #716 tranche 3). One pass beside the other rather than
3412
- // folded into it, because each has its own table and the selftest plants a
3413
- // wrong row into each on its own; both walk the object through
3414
- // `forEachKindedObject`, so what a kind is stays said in one place. The spec
3415
- // is untouched — an author may still write `x: 0`, and it is still the record
3416
- // of what was written — and what the parser loads does not move (`S103`).
3417
- inEditorKeyOrder(withoutParserDefaults(skeleton));
3418
-
3419
- return {
3420
- skeleton,
3421
- skeletonText: `${JSON.stringify(skeleton, null, 2)}\n`,
3422
- atlasText,
3447
+ // The fields the model carries as `CompileResult` carries them, by reference:
3448
+ // one object, so the result and its model cannot say two different things.
3449
+ const carried: CarriedFromCompileResult = {
3423
3450
  images,
3424
3451
  pageGrids: atlasIn === null ? [] : [...atlasIn.grids].map(([page, grid]) => ({ page, said: grid.said })),
3425
3452
  droppedStates,
3426
3453
  absentParts,
3427
- declaredDurations,
3428
3454
  meshBones: [...meshBones],
3429
3455
  meshes,
3430
3456
  physics: physicsReport,
@@ -3432,6 +3458,14 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3432
3458
  trackDerivations,
3433
3459
  rig: buildRigInfo(rig, bones, meshes, manifest, images),
3434
3460
  };
3461
+ return {
3462
+ skeleton,
3463
+ skeletonText: `${JSON.stringify(skeleton, null, 2)}\n`,
3464
+ atlasText,
3465
+ declaredDurations,
3466
+ ...carried,
3467
+ model: { referenceScale, bones, setupWorld: transforms, slots, skins, constraints, events, animations, ...carried },
3468
+ };
3435
3469
  }
3436
3470
 
3437
3471
  // ---------------------------------------------------------------------------
@@ -3447,7 +3481,7 @@ interface BoneContext {
3447
3481
  }
3448
3482
 
3449
3483
  /**
3450
- * One rig bone -> one emitted bone.
3484
+ * One rig bone -> one model bone (`ModelBone`); `emitBones` writes it as Spine's.
3451
3485
  *
3452
3486
  * 🔑 A field is emitted exactly when the spec declared it. That is not Spine's
3453
3487
  * own exporter convention (it omits anything equal to the default) and the
@@ -3455,8 +3489,8 @@ interface BoneContext {
3455
3489
  * deciding emission from the arithmetic rather than from the author's text makes
3456
3490
  * the file depend on a rounding.
3457
3491
  */
3458
- function buildBone(spec: RigBone, soFar: SpineBone[], ctx: BoneContext): SpineBone {
3459
- const bone: SpineBone = { name: spec.name };
3492
+ function buildBone(spec: RigBone, soFar: ModelBone[], ctx: BoneContext): ModelBone {
3493
+ const bone: ModelBone = { name: spec.name };
3460
3494
  if (spec.parent !== undefined) bone.parent = spec.parent;
3461
3495
  if (spec.length !== undefined) bone.length = f32(spec.length);
3462
3496
 
@@ -3487,10 +3521,13 @@ function buildBone(spec: RigBone, soFar: SpineBone[], ctx: BoneContext): SpineBo
3487
3521
  if (spec.scaleY !== undefined) bone.scaleY = f32(spec.scaleY);
3488
3522
  if (spec.shearX !== undefined) bone.shearX = f32(spec.shearX);
3489
3523
  if (spec.shearY !== undefined) bone.shearY = f32(spec.shearY);
3490
- if (spec.inherit !== undefined) bone.inherit = spec.inherit;
3491
- if (spec.skin !== undefined) bone.skin = spec.skin;
3492
- if (spec.color !== undefined) bone.color = spec.color;
3493
- if (spec.icon !== undefined) bone.icon = spec.icon;
3524
+ if (spec.inherit !== undefined) bone.inheritMode = spec.inherit;
3525
+ if (spec.skin !== undefined) bone.skinRequired = spec.skin;
3526
+ if (spec.color !== undefined || spec.icon !== undefined) {
3527
+ bone.editor = {};
3528
+ if (spec.color !== undefined) bone.editor.color = spec.color;
3529
+ if (spec.icon !== undefined) bone.editor.icon = spec.icon;
3530
+ }
3494
3531
  return bone;
3495
3532
  }
3496
3533
 
@@ -3615,9 +3652,9 @@ function rotationOf(spec: RigBone, ctx: BoneContext): number | null {
3615
3652
  * with a name was in that set, so for those types the position is the same
3616
3653
  * choice rather than a second measurement.
3617
3654
  */
3618
- function withStatedName(att: SpineAttachment, stated: RigAttachment): SpineAttachment {
3655
+ function withStatedName(att: SkinTableEntry, stated: RigAttachment): SkinTableEntry {
3619
3656
  const name = (stated as { name?: string }).name;
3620
- return name === undefined ? att : ({ name, ...att } as SpineAttachment);
3657
+ return name === undefined ? att : { name, ...att };
3621
3658
  }
3622
3659
 
3623
3660
  /**
@@ -3728,7 +3765,7 @@ function refuseDefaultSkinContests(skinNames: readonly string[], skinParts: Map<
3728
3765
 
3729
3766
  interface AttachmentContext {
3730
3767
  images: CompiledImage[];
3731
- bones: SpineBone[];
3768
+ bones: ModelBone[];
3732
3769
  transforms: Map<string, BoneTransform>;
3733
3770
  meshBones: Set<string>;
3734
3771
  meshes: CompileResult['meshes'];
@@ -3755,6 +3792,12 @@ interface AttachmentContext {
3755
3792
  depths: Map<string, number[]>;
3756
3793
  /** Every slot the rig declares — a clipping attachment's `end` resolves here. */
3757
3794
  slotNames: Set<string>;
3795
+ /**
3796
+ * The `--atlas-in` pack's regions by trimmed name, first match — the map
3797
+ * `resolveFromAtlas` reads — or `null` on the loose route, where the regions
3798
+ * are `images`. A region record's rectangle is looked up here (`regionAtlasRect`).
3799
+ */
3800
+ atlasRegions: ReadonlyMap<string, { region: AtlasRegion }> | null;
3758
3801
  /**
3759
3802
  * Linked meshes whose `source` is still unresolved — `resolveLinkedMeshes`
3760
3803
  * empties it once every skin has been built.
@@ -3844,7 +3887,7 @@ function buildRigAttachment(
3844
3887
  placeholder: string,
3845
3888
  where: string,
3846
3889
  ctx: AttachmentContext,
3847
- ): SpineAttachment {
3890
+ ): SkinTableEntry {
3848
3891
  const stated = (att as { type?: unknown }).type;
3849
3892
  if (stated !== undefined && typeof stated !== 'string') {
3850
3893
  throw new CompileError(
@@ -3913,7 +3956,8 @@ function deferredAttachmentRefusal(type: string, where: string, how: string): st
3913
3956
  }
3914
3957
 
3915
3958
  /**
3916
- * Encode the polygon a bounding box or a clipping attachment carries.
3959
+ * The polygon a bounding box, a clipping attachment or a path carries, as the
3960
+ * model's `ModelVertices`.
3917
3961
  *
3918
3962
  * 🚨 `vertexCount` is required, and everything else here is a cross-check of it.
3919
3963
  * The parser reads `map.vertexCount << 1` and hands that to `readVertices` as the
@@ -3923,12 +3967,12 @@ function deferredAttachmentRefusal(type: string, where: string, how: string): st
3923
3967
  * whatever that garbage produced. It loads. It draws nothing (a bounding box
3924
3968
  * never did) and clips nothing, or clips the wrong shape, in complete silence.
3925
3969
  *
3926
- * The two encodings are the mesh's — `encodeNamedWeights` is the same function —
3970
+ * The two encodings are the mesh's — `bindNamedWeights` is the same function —
3927
3971
  * because they are the same field with the same trap: `readVertices` decides
3928
3972
  * weighted vs unweighted by a length comparison alone, and a coincidental match
3929
- * reads weight data as coordinates.
3973
+ * reads weight data as coordinates. The model says which one it is outright.
3930
3974
  */
3931
- function buildVertexGeometry(att: RigVertexGeometry, where: string, ctx: AttachmentContext): number[] {
3975
+ function buildVertexGeometry(att: RigVertexGeometry, where: string, ctx: AttachmentContext): ModelVertices {
3932
3976
  const count = att.vertexCount;
3933
3977
  if (typeof count !== 'number' || !Number.isInteger(count) || count < 3) {
3934
3978
  throw new CompileError(
@@ -3948,7 +3992,7 @@ function buildVertexGeometry(att: RigVertexGeometry, where: string, ctx: Attachm
3948
3992
  if (att.weights.length !== count) {
3949
3993
  throw new CompileError(`${where}: weights cover ${att.weights.length} vertices but vertexCount is ${count}`);
3950
3994
  }
3951
- return encodeNamedWeights(att.weights, where, ctx);
3995
+ return { weighted: true, bindings: bindNamedWeights(att.weights, where, ctx) };
3952
3996
  }
3953
3997
  const raw = att.vertices;
3954
3998
  if (!raw || raw.length === 0) {
@@ -3967,48 +4011,89 @@ function buildVertexGeometry(att: RigVertexGeometry, where: string, ctx: Attachm
3967
4011
  }
3968
4012
  // A raw run still has to decode to exactly `vertexCount` vertices, or the
3969
4013
  // count and the polygon disagree and the parser believes the count.
3970
- let decoded = 0;
3971
- for (let i = 0; i < raw.length; decoded++) {
3972
- const bones = raw[i++];
3973
- if (!Number.isInteger(bones) || bones < 1) {
3974
- throw new CompileError(`${where}: the raw weighted run has a bone count of ${String(bones)} at index ${i - 1}`);
3975
- }
3976
- i += bones * 4;
3977
- if (i > raw.length) {
3978
- throw new CompileError(
3979
- `${where}: the raw weighted run is truncated — vertex ${decoded} claims ${bones} bone(s) and the array ends first`,
3980
- );
3981
- }
3982
- }
3983
- if (decoded !== count) {
3984
- throw new CompileError(`${where}: the raw weighted run decodes to ${decoded} vertices but vertexCount is ${count}`);
3985
- }
3986
- // Register the bones it binds so the mesh-bone reports stay complete.
3987
- for (let i = 0; i < raw.length; ) {
3988
- const bones = raw[i++];
3989
- for (let k = 0; k < bones; k++, i += 4) {
3990
- const bone = ctx.bones[raw[i]];
3991
- if (bone) ctx.meshBones.add(bone.name);
3992
- }
3993
- }
4014
+ checkRawRunShape(raw, count, `vertexCount is ${count}`, where);
3994
4015
  }
3995
4016
  for (const n of raw) {
3996
4017
  if (!Number.isFinite(n)) throw new CompileError(`${where}: the vertex array holds a non-finite value ${String(n)}`);
3997
4018
  }
3998
- return raw.map(f32);
4019
+ if (unweighted) return { weighted: false, xy: raw.map(f32) };
4020
+ const bindings = bindRawRun(raw.map(f32), ctx.bones, where);
4021
+ // Register the bones it binds so the mesh-bone reports stay complete.
4022
+ for (const vertex of bindings) for (const binding of vertex) ctx.meshBones.add(binding.bone);
4023
+ return { weighted: true, bindings };
4024
+ }
4025
+
4026
+ /**
4027
+ * Refuse a raw weighted run whose shape is not `vertices` vertices of
4028
+ * `boneCount, (boneIndex, x, y, weight) × boneCount` each. `countSays` is the
4029
+ * clause naming where the expected count came from.
4030
+ */
4031
+ function checkRawRunShape(raw: readonly number[], vertices: number, countSays: string, where: string): void {
4032
+ let decoded = 0;
4033
+ for (let i = 0; i < raw.length; decoded++) {
4034
+ const bones = raw[i++];
4035
+ if (!Number.isInteger(bones) || bones < 1) {
4036
+ throw new CompileError(`${where}: the raw weighted run has a bone count of ${String(bones)} at index ${i - 1}`);
4037
+ }
4038
+ i += bones * 4;
4039
+ if (i > raw.length) {
4040
+ throw new CompileError(
4041
+ `${where}: the raw weighted run is truncated — vertex ${decoded} claims ${bones} bone(s) and the array ends first`,
4042
+ );
4043
+ }
4044
+ }
4045
+ if (decoded !== vertices) {
4046
+ throw new CompileError(`${where}: the raw weighted run decodes to ${decoded} vertices but ${countSays}`);
4047
+ }
4048
+ }
4049
+
4050
+ /**
4051
+ * Decode an authored raw run (`"boneIndexing": "raw"`) to bindings by name, ONCE,
4052
+ * at intake, against the model's bone order — the order the emitter writes the
4053
+ * indexes back in, so the emitted run is the run the spec stated.
4054
+ *
4055
+ * The shape is already checked (`checkRawRunShape`). An index that names no bone
4056
+ * — past the end of the list, negative, fractional — is refused here with the
4057
+ * sentence the path-length measurement used to raise for it; before issue #917
4058
+ * every other reader of the run skipped it at compile time and left it to the
4059
+ * gate (`A20` on a mesh, `A33` on the other three).
4060
+ */
4061
+ function bindRawRun(run: readonly number[], bones: readonly ModelBone[], where: string): ModelBinding[][] {
4062
+ const out: ModelBinding[][] = [];
4063
+ for (let i = 0; i < run.length; ) {
4064
+ const count = run[i++];
4065
+ const vertex: ModelBinding[] = [];
4066
+ for (let k = 0; k < count; k++, i += 4) {
4067
+ const bone = bones[run[i]];
4068
+ if (bone === undefined) {
4069
+ throw new CompileError(`${where}: vertex ${out.length} binds bone index ${run[i]}, which is not in the bone list`);
4070
+ }
4071
+ vertex.push({ bone: bone.name, x: run[i + 1], y: run[i + 2], weight: run[i + 3] });
4072
+ }
4073
+ out.push(vertex);
4074
+ }
4075
+ return out;
4076
+ }
4077
+
4078
+ /** Every number of a weighted binding list on the float32 grid — what `.map(f32)` did to the run. */
4079
+ function bindingsOnF32(vertices: Extract<ModelVertices, { weighted: true }>): Extract<ModelVertices, { weighted: true }> {
4080
+ return {
4081
+ weighted: true,
4082
+ bindings: vertices.bindings.map((vertex) => vertex.map((b) => ({ bone: b.bone, x: f32(b.x), y: f32(b.y), weight: f32(b.weight) }))),
4083
+ };
3999
4084
  }
4000
4085
 
4001
4086
  function buildRigBoundingBox(
4002
4087
  att: RigBoundingBoxAttachment,
4003
4088
  where: string,
4004
4089
  ctx: AttachmentContext,
4005
- ): SpineBoundingBoxAttachment {
4006
- const out: SpineBoundingBoxAttachment = {
4007
- type: 'boundingbox',
4090
+ ): ModelBoundingBoxAttachment {
4091
+ const out: ModelBoundingBoxAttachment = {
4092
+ kind: 'boundingbox',
4008
4093
  vertexCount: att.vertexCount,
4009
4094
  vertices: buildVertexGeometry(att, where, ctx),
4010
4095
  };
4011
- if (att.color !== undefined) out.color = att.color;
4096
+ if (att.color !== undefined) out.editorColor = att.color;
4012
4097
  return out;
4013
4098
  }
4014
4099
 
@@ -4016,7 +4101,7 @@ function buildRigClipping(
4016
4101
  att: RigClippingAttachment,
4017
4102
  where: string,
4018
4103
  ctx: AttachmentContext,
4019
- ): SpineClippingAttachment {
4104
+ ): ModelClippingAttachment {
4020
4105
  if (att.end !== undefined) {
4021
4106
  // `skeletonData.findSlot` returns null on a miss and the parser assigns that
4022
4107
  // null (`:626-627`), so a typo does not fail — the clip simply never ends and
@@ -4028,57 +4113,50 @@ function buildRigClipping(
4028
4113
  );
4029
4114
  }
4030
4115
  }
4031
- // Field order is the editor's here — `end`, `convex`, `inverse` before the
4032
- // geometry — via conditional spreads, because a key assigned after the literal
4033
- // lands at the end instead. Order carries no meaning in JSON; it is read by
4034
- // people, and this file's diff against a reference is read a lot.
4035
- const out: SpineClippingAttachment = {
4036
- type: 'clipping',
4116
+ // Field order is the emitter's (`emitClipping`); a present key here is one the
4117
+ // spec declared.
4118
+ const out: ModelClippingAttachment = {
4119
+ kind: 'clipping',
4037
4120
  ...(att.end !== undefined ? { end: att.end } : {}),
4038
4121
  ...(att.convex !== undefined ? { convex: att.convex } : {}),
4039
4122
  ...(att.inverse !== undefined ? { inverse: att.inverse } : {}),
4040
4123
  vertexCount: att.vertexCount,
4041
4124
  vertices: buildVertexGeometry(att, where, ctx),
4042
4125
  };
4043
- if (att.color !== undefined) out.color = att.color;
4126
+ if (att.color !== undefined) out.editorColor = att.color;
4044
4127
  return out;
4045
4128
  }
4046
4129
 
4047
4130
  /**
4048
- * Setup-pose world position of every vertex of an emitted vertex run.
4131
+ * Setup-pose world position of every vertex of a model vertex attachment.
4049
4132
  *
4050
4133
  * The two encodings again, and the same split `readVertices` makes: an unweighted
4051
- * run is one `x, y` in the SLOT BONE's space; a weighted one is
4052
- * `boneCount, (boneIndex, bindX, bindY, weight) × n` per vertex, and the vertex
4053
- * is the weighted sum of each influence's bind point taken to world through its
4054
- * own bone. This is `VertexAttachment.computeWorldVertices` at setup, restated in
4055
- * the compiler because the compiler must not link the runtime.
4134
+ * attachment is one `x, y` in the SLOT BONE's space; a weighted one binds each
4135
+ * vertex to its bones by name, and the vertex is the weighted sum of each
4136
+ * influence's bind point taken to world through its own bone. This is
4137
+ * `VertexAttachment.computeWorldVertices` at setup, restated in the compiler
4138
+ * because the compiler must not link the runtime.
4056
4139
  */
4057
4140
  function setupWorldVertices(
4058
- vertices: number[],
4059
- vertexCount: number,
4141
+ vertices: ModelVertices,
4060
4142
  anchor: BoneTransform,
4061
- bones: SpineBone[],
4062
4143
  transforms: Map<string, BoneTransform>,
4063
4144
  where: string,
4064
4145
  ): Array<[number, number]> {
4065
4146
  const out: Array<[number, number]> = [];
4066
- if (vertices.length === vertexCount * 2) {
4067
- for (let i = 0; i < vertices.length; i += 2) out.push(toWorld(anchor, vertices[i], vertices[i + 1]));
4147
+ if (!vertices.weighted) {
4148
+ for (let i = 0; i < vertices.xy.length; i += 2) out.push(toWorld(anchor, vertices.xy[i], vertices.xy[i + 1]));
4068
4149
  return out;
4069
4150
  }
4070
- for (let i = 0; i < vertices.length; ) {
4071
- const count = vertices[i++];
4151
+ for (const vertex of vertices.bindings) {
4072
4152
  let x = 0;
4073
4153
  let y = 0;
4074
- for (let n = 0; n < count; n++, i += 4) {
4075
- const bone = bones[vertices[i]];
4076
- const m = bone === undefined ? undefined : transforms.get(bone.name);
4077
- if (!m) throw new CompileError(`${where}: vertex ${out.length} binds bone index ${vertices[i]}, which is not in the bone list`);
4078
- const [wx, wy] = toWorld(m, vertices[i + 1], vertices[i + 2]);
4079
- const weight = vertices[i + 3];
4080
- x += wx * weight;
4081
- y += wy * weight;
4154
+ for (const binding of vertex) {
4155
+ const m = transforms.get(binding.bone);
4156
+ if (!m) throw new CompileError(`${where}: vertex ${out.length} binds bone "${binding.bone}", which has no setup transform`);
4157
+ const [wx, wy] = toWorld(m, binding.x, binding.y);
4158
+ x += wx * binding.weight;
4159
+ y += wy * binding.weight;
4082
4160
  }
4083
4161
  out.push([x, y]);
4084
4162
  }
@@ -4107,8 +4185,11 @@ function setupWorldVertices(
4107
4185
  * prefix, so the entries the runtime reads are the same numbers either way; the
4108
4186
  * trailing one is read by nothing, and is written so a rebuild is the file the
4109
4187
  * editor writes rather than one entry short of it.
4188
+ *
4189
+ * Exported, with `pathCurveLengths`, for the selftest alone: `MV07` measures a
4190
+ * decode of the emitted run through the two and compares.
4110
4191
  */
4111
- function pathChain(points: Array<[number, number]>, closed: boolean): Array<[number, number]> {
4192
+ export function pathChain(points: Array<[number, number]>, closed: boolean): Array<[number, number]> {
4112
4193
  if (!closed) return points.slice(1, points.length - 1);
4113
4194
  return [...points.slice(1), points[0], points[1]];
4114
4195
  }
@@ -4175,7 +4256,7 @@ function pathChain(points: Array<[number, number]>, closed: boolean): Array<[num
4175
4256
  * running total carried across curves rather than restarted. Each of those is a
4176
4257
  * place where a more accurate line would emit a different file.
4177
4258
  */
4178
- function pathCurveLengths(chain: Array<[number, number]>): number[] {
4259
+ export function pathCurveLengths(chain: Array<[number, number]>): number[] {
4179
4260
  const out: number[] = [];
4180
4261
  let total = 0;
4181
4262
  for (let c = 0; c + 3 < chain.length; c += 3) {
@@ -4229,7 +4310,7 @@ function pathCurveLengths(chain: Array<[number, number]>): number[] {
4229
4310
  * 3. **An omitted one is measured**, `vertexCount / 3` entries over the closed
4230
4311
  * chain (`pathChain`), on the unconstrained setup pose.
4231
4312
  */
4232
- function buildRigPath(att: RigPathAttachment, where: string, ctx: AttachmentContext): SpinePathAttachment {
4313
+ function buildRigPath(att: RigPathAttachment, where: string, ctx: AttachmentContext): ModelPathAttachment {
4233
4314
  const vertices = buildVertexGeometry(att, where, ctx);
4234
4315
  const count = att.vertexCount;
4235
4316
  const closed = att.closed === true;
@@ -4254,7 +4335,7 @@ function buildRigPath(att: RigPathAttachment, where: string, ctx: AttachmentCont
4254
4335
  } else {
4255
4336
  const anchor = ctx.transforms.get(ctx.anchorBone);
4256
4337
  if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
4257
- const points = setupWorldVertices(vertices, count, anchor, ctx.bones, ctx.transforms, where);
4338
+ const points = setupWorldVertices(vertices, anchor, ctx.transforms, where);
4258
4339
  lengths = pathCurveLengths(pathChain(points, true));
4259
4340
  // The total the runtime reads is the last CURVE's entry, `lengths[vertexCount
4260
4341
  // / 3 - (closed ? 1 : 2)]` (`PathConstraint.js:204-206`) — on an open path
@@ -4267,17 +4348,17 @@ function buildRigPath(att: RigPathAttachment, where: string, ctx: AttachmentCont
4267
4348
  );
4268
4349
  }
4269
4350
  }
4270
- // Field order is the parser's reading order (`:606-623`), and each optional key
4271
- // is present exactly when the spec declared it — the rule the whole rig spec
4272
- // follows, so Spine's own defaults stand for the rest.
4351
+ // Each optional key is present exactly when the spec declared it — the rule
4352
+ // the whole rig spec follows, so Spine's own defaults stand for the rest. The
4353
+ // field order (the parser's reading order, `:606-623`) is `emitPath`'s.
4273
4354
  return {
4274
- type: 'path',
4355
+ kind: 'path',
4275
4356
  ...(att.closed !== undefined ? { closed: att.closed } : {}),
4276
4357
  ...(att.constantSpeed !== undefined ? { constantSpeed: att.constantSpeed } : {}),
4277
4358
  vertexCount: count,
4278
4359
  vertices,
4279
4360
  lengths: lengths.map(f32),
4280
- ...(att.color !== undefined ? { color: att.color } : {}),
4361
+ ...(att.color !== undefined ? { editorColor: att.color } : {}),
4281
4362
  };
4282
4363
  }
4283
4364
 
@@ -4406,8 +4487,9 @@ function attachmentPath(att: { name?: string; path?: string; image?: string }, p
4406
4487
  }
4407
4488
 
4408
4489
  /**
4409
- * A mesh attachment's `path` and `color`, in that order — the two keys every
4410
- * mesh constructor spreads right after `type` (issue #791).
4490
+ * A mesh attachment's `path` and `color` — the two values every mesh builder
4491
+ * spreads into its record, and which `emitMesh` (`src/emit_spine.ts`) writes
4492
+ * right after `type` (issue #791; the position moved to the emitter with #917).
4411
4493
  *
4412
4494
  * 🔬 The editor writes a mesh `type, path, color, uvs, …`: right after `type`,
4413
4495
  * `path` before `color` — measured on a production set (#791), not on
@@ -4416,16 +4498,17 @@ function attachmentPath(att: { name?: string; path?: string; image?: string }, p
4416
4498
  * `path` before `color` is the order the two measured positions leave, not a
4417
4499
  * third measurement. The twelve exports carry neither key on a mesh, which is
4418
4500
  * why `EDITOR_KEY_ORDER`'s `mesh attachment` row does not list them: an unlisted
4419
- * key keeps its constructor's index, so the position is stated HERE, and the
4420
- * row stays what the public exports derive.
4501
+ * key keeps its constructor's index, so the position is stated in the one
4502
+ * constructor that writes the bytes, `emitMesh`, and the row stays what the
4503
+ * public exports derive.
4421
4504
  *
4422
4505
  * ⚠️ Not a linked mesh's, and not a region's. Neither was in the measured set —
4423
4506
  * a region's `path` stays where `buildRigRegion` puts it, a linked mesh's
4424
4507
  * keys where `buildRigLinkedMesh` does — because the analogy to a mesh is
4425
4508
  * obvious and it is not a measurement.
4426
4509
  */
4427
- function meshTextureKeys(att: { name?: string; path?: string; image?: string; color?: string }, placeholder: string): Pick<SpineMeshAttachment, 'path' | 'color'> {
4428
- const out: Pick<SpineMeshAttachment, 'path' | 'color'> = {};
4510
+ function meshTextureKeys(att: { name?: string; path?: string; image?: string; color?: string }, placeholder: string): Pick<ModelMeshAttachment, 'path' | 'color'> {
4511
+ const out: Pick<ModelMeshAttachment, 'path' | 'color'> = {};
4429
4512
  const path = attachmentPath(att, placeholder);
4430
4513
  if (path !== undefined) out.path = path;
4431
4514
  if (att.color !== undefined) out.color = att.color;
@@ -4449,9 +4532,9 @@ function sequenceFrameRegion(stem: string, seq: RigSequence, i: number): string
4449
4532
  return `${stem}${'0'.repeat(Math.max(0, (seq.digits ?? 0) - frame.length))}${frame}`;
4450
4533
  }
4451
4534
 
4452
- /** The emitted `sequence` block: the four fields exactly as the spec stated them. */
4453
- function emitSequence(seq: RigSequence): SpineSequence {
4454
- const out: SpineSequence = { count: seq.count };
4535
+ /** The model's `sequence` record: the four fields exactly as the spec stated them. */
4536
+ function buildSequence(seq: RigSequence): ModelSequence {
4537
+ const out: ModelSequence = { count: seq.count };
4455
4538
  if (seq.start !== undefined) out.start = seq.start;
4456
4539
  if (seq.digits !== undefined) out.digits = seq.digits;
4457
4540
  if (seq.setup !== undefined) out.setup = seq.setup;
@@ -4539,10 +4622,11 @@ function buildRigRegion(
4539
4622
  placeholder: string,
4540
4623
  where: string,
4541
4624
  ctx: AttachmentContext,
4542
- ): SpineRegionAttachment {
4625
+ ): ModelRegionAttachment {
4543
4626
  if (att.sequence !== undefined) {
4544
- const size = sequenceFrameSize(sequenceFrames(att, placeholder, where, ctx), att, where);
4545
- const out: SpineRegionAttachment = { width: f32(size.width!), height: f32(size.height!) };
4627
+ const frames = sequenceFrames(att, placeholder, where, ctx);
4628
+ const size = sequenceFrameSize(frames, att, where);
4629
+ const out: ModelRegionAttachment = { kind: 'region', width: f32(size.width!), height: f32(size.height!) };
4546
4630
  if (att.path !== undefined) out.path = att.path;
4547
4631
  if (att.x !== undefined) out.x = f32(att.x);
4548
4632
  if (att.y !== undefined) out.y = f32(att.y);
@@ -4550,7 +4634,9 @@ function buildRigRegion(
4550
4634
  if (att.scaleX !== undefined) out.scaleX = f32(att.scaleX);
4551
4635
  if (att.scaleY !== undefined) out.scaleY = f32(att.scaleY);
4552
4636
  if (att.color !== undefined) out.color = att.color;
4553
- out.sequence = emitSequence(att.sequence);
4637
+ // Each frame draws through its own rectangle (`Sequence.apply`), in frame order.
4638
+ const sequence: ModelRegionSequence = { ...buildSequence(att.sequence), atlas: frames.map(imageAtlasRect) };
4639
+ out.sequence = sequence;
4554
4640
  return out;
4555
4641
  }
4556
4642
  const img = att.image === undefined ? null : atlasedImage(att.image, where, ctx);
@@ -4592,7 +4678,7 @@ function buildRigRegion(
4592
4678
  `${where}: a region needs width and height — give them, or give an "image" and rigc will measure the PNG`,
4593
4679
  );
4594
4680
  }
4595
- const out: SpineRegionAttachment = { width: f32(width), height: f32(height) };
4681
+ const out: ModelRegionAttachment = { kind: 'region', width: f32(width), height: f32(height) };
4596
4682
  const path = attachmentPath(att, placeholder);
4597
4683
  if (path !== undefined) out.path = path;
4598
4684
  if (att.x !== undefined) out.x = f32(att.x);
@@ -4601,41 +4687,89 @@ function buildRigRegion(
4601
4687
  if (att.scaleX !== undefined) out.scaleX = f32(att.scaleX);
4602
4688
  if (att.scaleY !== undefined) out.scaleY = f32(att.scaleY);
4603
4689
  if (att.color !== undefined) out.color = att.color;
4690
+ // The region the runtime asks the atlas for: `path`, else the name
4691
+ // (`getValue(map, "path", name)`), which is the stated `name`, else the placeholder.
4692
+ out.atlas = regionAtlasRect(path ?? att.name ?? placeholder, ctx.images, ctx.atlasRegions);
4604
4693
  return out;
4605
4694
  }
4606
4695
 
4607
4696
  /**
4608
- * Resolve an authored mesh's by-name weights into Spine's index run.
4697
+ * The rectangle a region draws through, as the atlas states it — the six
4698
+ * fields `ModelAtlasRect` holds, copied, nothing derived.
4699
+ */
4700
+ function atlasRectOf(region: AtlasRegion): ModelAtlasRect {
4701
+ return {
4702
+ width: region.width,
4703
+ height: region.height,
4704
+ offsetX: region.offsetX,
4705
+ offsetY: region.offsetY,
4706
+ originalWidth: region.originalWidth,
4707
+ originalHeight: region.originalHeight,
4708
+ };
4709
+ }
4710
+
4711
+ /**
4712
+ * The rectangle an atlased image draws through: the pack's region under
4713
+ * `--atlas-in`, else the loose PNG as its own page, which is exactly the region
4714
+ * `buildAtlasText` writes for it — kept size and original size the PNG's,
4715
+ * offsets 0.
4716
+ */
4717
+ function imageAtlasRect(img: CompiledImage): ModelAtlasRect {
4718
+ if (img.atlas !== undefined) return atlasRectOf(img.atlas);
4719
+ return { width: img.width, height: img.height, offsetX: 0, offsetY: 0, originalWidth: img.width, originalHeight: img.height };
4720
+ }
4721
+
4722
+ /**
4723
+ * The rectangle of the region named `name` in the build's one atlas source —
4724
+ * the `--atlas-in` pack when there is one, else the images atlased from loose
4725
+ * PNGs — or `null` when that source has no region of the name (issue #935).
4609
4726
  *
4610
- * 🚨 This is the whole point of the `weights` form. The run is
4727
+ * `null` is the build saying it has no source, never a guessed trim of 0: the
4728
+ * atlas this build emits is that same source, so the region is missing from it
4729
+ * too, and `A08_REGION_NAMES_MATCH_ATTACHMENTS` refuses the build by the name.
4730
+ */
4731
+ function regionAtlasRect(
4732
+ name: string,
4733
+ images: readonly CompiledImage[],
4734
+ atlasRegions: ReadonlyMap<string, { region: AtlasRegion }> | null,
4735
+ ): ModelAtlasRect | null {
4736
+ if (atlasRegions !== null) {
4737
+ const found = atlasRegions.get(name);
4738
+ return found === undefined ? null : atlasRectOf(found.region);
4739
+ }
4740
+ const img = images.find((im) => im.region === name);
4741
+ return img === undefined ? null : imageAtlasRect(img);
4742
+ }
4743
+
4744
+ /**
4745
+ * Resolve an authored attachment's by-name weights into the model's bindings.
4746
+ *
4747
+ * 🚨 This is the whole point of the `weights` form. Spine's run is
4611
4748
  * `boneCount, (boneIndex, bindX, bindY, weight) x n` per vertex and those
4612
4749
  * indices are positions in the emitted bone array — a thing the rig spec never
4613
- * writes. Resolving them here, from names, is what makes "insert a bone" a
4750
+ * writes. Keeping the NAME here, and letting the emitter write the index from
4751
+ * the model's bone order (`emitVertices`), is what makes "insert a bone" a
4614
4752
  * renumbering rather than a rebinding: the names still point at the same bones,
4615
4753
  * so the emitted indices move and the mesh does not.
4616
4754
  *
4617
4755
  * An unknown name is a `CompileError`, the same as a bone's `parent`, a slot's
4618
4756
  * `bone` or a constraint's `target`. The alternative — the raw form — cannot
4619
- * refuse anything, because an index has no name to be wrong.
4757
+ * refuse a rebind, because an index has no name to be wrong.
4620
4758
  */
4621
- function encodeNamedWeights(weights: RigMeshBinding[][], where: string, ctx: AttachmentContext): number[] {
4622
- const out: number[] = [];
4623
- weights.forEach((vertex, i) => {
4759
+ function bindNamedWeights(weights: RigMeshBinding[][], where: string, ctx: AttachmentContext): ModelBinding[][] {
4760
+ return weights.map((vertex, i) => {
4624
4761
  if (!Array.isArray(vertex) || vertex.length === 0) {
4625
4762
  throw new CompileError(`${where}: vertex ${i} has no bone bindings; a weighted vertex names at least one bone`);
4626
4763
  }
4627
- out.push(vertex.length);
4628
- for (const binding of vertex) {
4629
- const index = ctx.bones.findIndex((b) => b.name === binding.bone);
4630
- if (index < 0) {
4764
+ return vertex.map((binding) => {
4765
+ if (!ctx.bones.some((b) => b.name === binding.bone)) {
4631
4766
  throw new CompileError(
4632
4767
  `${where}: vertex ${i} binds bone ${JSON.stringify(binding.bone)}, which the rig does not declare as a bone`,
4633
4768
  );
4634
4769
  }
4635
- out.push(index, f32(binding.x), f32(binding.y), f32(binding.weight));
4636
- }
4770
+ return { bone: binding.bone, x: f32(binding.x), y: f32(binding.y), weight: f32(binding.weight) };
4771
+ });
4637
4772
  });
4638
- return out;
4639
4773
  }
4640
4774
 
4641
4775
  /**
@@ -4780,7 +4914,7 @@ function buildRigMesh(
4780
4914
  placeholder: string,
4781
4915
  where: string,
4782
4916
  ctx: AttachmentContext,
4783
- ): SpineMeshAttachment {
4917
+ ): ModelMeshAttachment {
4784
4918
  const authored =
4785
4919
  att.uvs !== undefined || att.triangles !== undefined || att.vertices !== undefined || att.weights !== undefined;
4786
4920
  if (authored && att.generator) {
@@ -4796,7 +4930,7 @@ function buildRigMesh(
4796
4930
  throw new CompileError(`${where}: an authored mesh needs uvs, triangles and vertices or weights (or a "generator")`);
4797
4931
  }
4798
4932
  const uvCount = att.uvs.length;
4799
- let vertices: number[];
4933
+ let vertices: ModelVertices;
4800
4934
  let boundBones: string[] = [];
4801
4935
  if (att.weights) {
4802
4936
  if (att.boneIndexing === 'raw') {
@@ -4807,7 +4941,7 @@ function buildRigMesh(
4807
4941
  `${where}: weights cover ${att.weights.length} vertices but there are ${uvCount / 2} uv pairs`,
4808
4942
  );
4809
4943
  }
4810
- vertices = encodeNamedWeights(att.weights, where, ctx);
4944
+ vertices = { weighted: true, bindings: bindNamedWeights(att.weights, where, ctx) };
4811
4945
  boundBones = [...new Set(att.weights.flat().map((b) => b.bone))];
4812
4946
  } else {
4813
4947
  const raw = att.vertices!;
@@ -4822,17 +4956,17 @@ function buildRigMesh(
4822
4956
  'or say "boneIndexing": "raw" on this attachment to keep the index form deliberately.',
4823
4957
  );
4824
4958
  }
4825
- vertices = raw.map(f32);
4826
4959
  if (weighted) {
4827
- const names = new Set<string>();
4828
- for (let i = 0; i < raw.length; ) {
4829
- const n = raw[i++];
4830
- for (let k = 0; k < n; k++, i += 4) {
4831
- const bone = ctx.bones[raw[i]];
4832
- if (bone) names.add(bone.name);
4833
- }
4834
- }
4835
- boundBones = [...names];
4960
+ // Decoded to names once, here, against the model's bone order — which is
4961
+ // the order the emitter writes the indexes back in, so the emitted run is
4962
+ // the run the spec stated. A run the model cannot hold is refused by name
4963
+ // rather than left to `A20` at the gate (issue #917).
4964
+ checkRawRunShape(raw, uvCount / 2, `there are ${uvCount / 2} uv pairs`, where);
4965
+ const bindings = bindRawRun(raw.map(f32), ctx.bones, where);
4966
+ vertices = { weighted: true, bindings };
4967
+ boundBones = [...new Set(bindings.flat().map((b) => b.bone))];
4968
+ } else {
4969
+ vertices = { weighted: false, xy: raw.map(f32) };
4836
4970
  }
4837
4971
  }
4838
4972
  const { hull, edges } = authoredHullAndEdges(att, uvCount / 2, att.triangles, where);
@@ -4852,8 +4986,8 @@ function buildRigMesh(
4852
4986
  `${where}: a mesh needs width and height — give them, or give an "image" and rigc will measure the PNG`,
4853
4987
  );
4854
4988
  }
4855
- const out: SpineMeshAttachment = {
4856
- type: 'mesh',
4989
+ const out: ModelMeshAttachment = {
4990
+ kind: 'mesh',
4857
4991
  ...meshTextureKeys(att, placeholder),
4858
4992
  uvs: att.uvs.map(f32),
4859
4993
  triangles: att.triangles,
@@ -4863,7 +4997,7 @@ function buildRigMesh(
4863
4997
  width: f32(width),
4864
4998
  height: f32(height),
4865
4999
  };
4866
- if (att.sequence !== undefined) out.sequence = emitSequence(att.sequence);
5000
+ if (att.sequence !== undefined) out.sequence = buildSequence(att.sequence);
4867
5001
  // Register it as `authored`: geometry rigc did not build and whose topology it
4868
5002
  // therefore gets to assume nothing about. The generator-topology assertions
4869
5003
  // read this and skip rather than measuring a ring that was never a ring.
@@ -4916,7 +5050,7 @@ function buildRigLinkedMesh(
4916
5050
  placeholder: string,
4917
5051
  where: string,
4918
5052
  ctx: AttachmentContext,
4919
- ): SpineLinkedMeshAttachment {
5053
+ ): ModelLinkedMeshAttachment {
4920
5054
  // The mesh keys the parser does not reach on this branch. Named one by one,
4921
5055
  // because "remove what does not belong" is not an instruction an author can
4922
5056
  // act on and the remedy for each of these is the same single sentence.
@@ -4968,17 +5102,23 @@ function buildRigLinkedMesh(
4968
5102
  `${where}: a linked mesh needs width and height — give them, or give an "image" and rigc will measure the PNG`,
4969
5103
  );
4970
5104
  }
4971
- const out: SpineLinkedMeshAttachment = { type: 'linkedmesh', source, width: f32(width), height: f32(height) };
5105
+ // The link in full — skin, slot and `timelines` as the parser resolves them.
5106
+ // Writing each only where it differs from the parser's own default (a
5107
+ // `timelines: true` or a `skin` of "default" would be a byte the editor's own
5108
+ // export does not carry) is the emitter's (`emitLinkedMesh`).
5109
+ const out: ModelLinkedMeshAttachment = {
5110
+ kind: 'linkedmesh',
5111
+ source,
5112
+ skin: att.skin ?? 'default',
5113
+ slot: att.slot ?? ctx.slotName,
5114
+ timelines: att.timelines !== false,
5115
+ width: f32(width),
5116
+ height: f32(height),
5117
+ };
4972
5118
  const path = attachmentPath(att, placeholder);
4973
5119
  if (path !== undefined) out.path = path;
4974
- // Only where they differ from the parser's own defaults. Writing `timelines:
4975
- // true`, or a `skin` of "default", would be a byte the editor's own export
4976
- // does not carry.
4977
- if (att.slot !== undefined && att.slot !== ctx.slotName) out.slot = att.slot;
4978
- if (att.skin !== undefined && att.skin !== 'default') out.skin = att.skin;
4979
- if (att.timelines === false) out.timelines = false;
4980
5120
  if (att.color !== undefined) out.color = att.color;
4981
- if (att.sequence !== undefined) out.sequence = emitSequence(att.sequence);
5121
+ if (att.sequence !== undefined) out.sequence = buildSequence(att.sequence);
4982
5122
  // 🚫 NOT registered in `ctx.meshes`, and that is a decision rather than an
4983
5123
  // omission. `meshKinds` is keyed by SLOT and the commonest linked mesh shares
4984
5124
  // its source's slot from another skin, so an entry here would overwrite the
@@ -5005,7 +5145,7 @@ function buildRigLinkedMesh(
5005
5145
  */
5006
5146
  function resolveLinkedMeshes(
5007
5147
  links: readonly PendingLink[],
5008
- tables: Map<string, Record<string, Record<string, SpineAttachment>>>,
5148
+ tables: ReadonlyMap<string, SkinTable>,
5009
5149
  ): void {
5010
5150
  const skinNames = [...tables.keys()];
5011
5151
  for (const link of links) {
@@ -5046,7 +5186,7 @@ function resolveLinkedMeshes(
5046
5186
  "Left to the round trip this is the runtime's `Source mesh not found`.",
5047
5187
  );
5048
5188
  }
5049
- const type = (found as { type?: string }).type ?? 'region';
5189
+ const type = found.kind;
5050
5190
  if (type === 'linkedmesh') {
5051
5191
  throw new CompileError(
5052
5192
  `${link.where}: "source" is ${JSON.stringify(link.source)}, which is itself a linked mesh, and a chain of ` +
@@ -5081,25 +5221,24 @@ function buildGeneratedMesh(
5081
5221
  placeholder: string,
5082
5222
  where: string,
5083
5223
  ctx: AttachmentContext,
5084
- ): SpineMeshAttachment {
5224
+ ): ModelMeshAttachment {
5085
5225
  if (generator.kind === 'contour') return buildContourAttachment(att, generator, placeholder, where, ctx);
5086
5226
  if (generator.kind === 'grid') return buildGridAttachment(att, generator, placeholder, where, ctx);
5087
5227
  if (generator.kind === 'segments') return buildSegmentsAttachment(att, generator, placeholder, where, ctx);
5088
5228
  const controls = generator.kind === 'ring' ? generator.controls : generator.chain;
5089
- // Resolving the bone list and the setup transform is one step with two named
5090
- // refusals, because the ring's control angles need the transform and the encode
5091
- // needs the index — and the two must refuse a bone the rig lacks in the same
5229
+ // Resolving the bone and its setup transform is one step with two named
5230
+ // refusals, because the ring's control angles need the transform and the bind
5231
+ // needs the bone — and the two must refuse a bone the rig lacks in the same
5092
5232
  // words whichever of them asks for it first.
5093
- const resolve = (name: string): { index: number; transform: BoneTransform } => {
5094
- const index = ctx.bones.findIndex((b) => b.name === name);
5095
- if (index < 0) throw new CompileError(`${where}: mesh bone "${name}" is not in the rig's bone list`);
5233
+ const resolve = (name: string): BoneTransform => {
5234
+ if (!ctx.bones.some((b) => b.name === name)) throw new CompileError(`${where}: mesh bone "${name}" is not in the rig's bone list`);
5096
5235
  const transform = ctx.transforms.get(name);
5097
5236
  if (!transform) throw new CompileError(`${where}: no setup transform for mesh bone "${name}"`);
5098
- return { index, transform };
5237
+ return transform;
5099
5238
  };
5100
5239
  const refFor = (name: string): MeshBoneRef => {
5101
- const { index, transform } = resolve(name);
5102
- return { index, toBind: (wx, wy) => toBoneLocal(transform, wx, wy) };
5240
+ const transform = resolve(name);
5241
+ return { name, toBind: (wx, wy) => toBoneLocal(transform, wx, wy) };
5103
5242
  };
5104
5243
  // The generator works in part-local pixels, y down. Without a manifest there is
5105
5244
  // no crop to flip against, so the part window is centred on its own slot bone.
@@ -5123,7 +5262,7 @@ function buildGeneratedMesh(
5123
5262
  size: generator.size,
5124
5263
  bias: generator.bias,
5125
5264
  controlAngles: ringControlAngles(controls, generator.center, (name) => {
5126
- const { transform } = resolve(name);
5265
+ const transform = resolve(name);
5127
5266
  return toPartLocal(transform.worldX, transform.worldY);
5128
5267
  }),
5129
5268
  });
@@ -5131,7 +5270,7 @@ function buildGeneratedMesh(
5131
5270
  if (err instanceof MeshError) throw new CompileError(`${where}: ${err.message}`);
5132
5271
  throw err;
5133
5272
  }
5134
- const vertices = encodeWeightedVertices(
5273
+ const vertices = bindWeightedVertices(
5135
5274
  geometry,
5136
5275
  // The world point is an INTERMEDIATE — `toBind` makes it local and the
5137
5276
  // result is what is emitted, through `f32` below — so it carries the double.
@@ -5150,12 +5289,12 @@ function buildGeneratedMesh(
5150
5289
  triangles: geometry.triangles.length / 3,
5151
5290
  bones: [ctx.anchorBone, ...controls],
5152
5291
  });
5153
- const out: SpineMeshAttachment = {
5154
- type: 'mesh',
5292
+ const out: ModelMeshAttachment = {
5293
+ kind: 'mesh',
5155
5294
  ...meshTextureKeys(att, placeholder),
5156
5295
  uvs: geometry.uvs.map(f32),
5157
5296
  triangles: geometry.triangles,
5158
- vertices: vertices.map(f32),
5297
+ vertices: bindingsOnF32(vertices),
5159
5298
  ...generatedHullAndEdges(geometry, where),
5160
5299
  width: f32(w),
5161
5300
  height: f32(h),
@@ -5231,7 +5370,7 @@ function sampleMeshDepth(
5231
5370
  triangles: ReadonlyArray<number>,
5232
5371
  /**
5233
5372
  * A part-local pixel to the BIND space this mesh's vertices are emitted in —
5234
- * the same composition `encodeWeightedVertices` applies, handed in rather than
5373
+ * the same composition `bindWeightedVertices` applies, handed in rather than
5235
5374
  * rebuilt so the two cannot diverge.
5236
5375
  *
5237
5376
  * 🚨 The ceiling has to be taken in bind space and in no other. A deform
@@ -5391,13 +5530,12 @@ function sampleMeshDepth(
5391
5530
  };
5392
5531
  }
5393
5532
 
5394
- /** One bone of the rig as `encodeWeightedVertices` wants it. */
5533
+ /** One bone of the rig as `bindWeightedVertices` wants it: by name, with its setup inverse. */
5395
5534
  function meshBoneRef(name: string, where: string, ctx: AttachmentContext): MeshBoneRef {
5396
5535
  const transform = ctx.transforms.get(name);
5397
5536
  if (!transform) throw new CompileError(`${where}: bone "${name}" has no setup transform`);
5398
- const index = ctx.bones.findIndex((b) => b.name === name);
5399
- if (index < 0) throw new CompileError(`${where}: bone "${name}" is not in the rig's bone list`);
5400
- return { index, toBind: (wx, wy) => toBoneLocal(transform, wx, wy) };
5537
+ if (!ctx.bones.some((b) => b.name === name)) throw new CompileError(`${where}: bone "${name}" is not in the rig's bone list`);
5538
+ return { name, toBind: (wx, wy) => toBoneLocal(transform, wx, wy) };
5401
5539
  }
5402
5540
 
5403
5541
  /**
@@ -5602,7 +5740,7 @@ function buildGridAttachment(
5602
5740
  placeholder: string,
5603
5741
  where: string,
5604
5742
  ctx: AttachmentContext,
5605
- ): SpineMeshAttachment {
5743
+ ): ModelMeshAttachment {
5606
5744
  if (att.image === undefined) {
5607
5745
  throw new CompileError(
5608
5746
  `${where}: a "grid" generator lays its lattice over the part's own window, so the attachment needs an ` +
@@ -5663,8 +5801,9 @@ function buildGridAttachment(
5663
5801
  }
5664
5802
  const anchor = ctx.transforms.get(ctx.anchorBone);
5665
5803
  if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
5666
- const index = ctx.bones.findIndex((b) => b.name === ctx.anchorBone);
5667
- if (index < 0) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" is not in the rig's bone list`);
5804
+ if (!ctx.bones.some((b) => b.name === ctx.anchorBone)) {
5805
+ throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" is not in the rig's bone list`);
5806
+ }
5668
5807
  // Sampled BEFORE the encode, because `bind` rewrites the weights the encode
5669
5808
  // then writes out.
5670
5809
  const depth =
@@ -5691,11 +5830,11 @@ function buildGridAttachment(
5691
5830
  ? undefined
5692
5831
  : softRegionWeights(generator.soft, geometry.points, sheetGridOf(img, plate), where, ctx);
5693
5832
  if (bound) geometry = { ...geometry, weights: bound.weights };
5694
- const vertices = encodeWeightedVertices(
5833
+ const vertices = bindWeightedVertices(
5695
5834
  geometry,
5696
5835
  (px, py) => [anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt],
5697
5836
  {
5698
- anchor: { index, toBind: (wx, wy) => toBoneLocal(anchor, wx, wy) },
5837
+ anchor: { name: ctx.anchorBone, toBind: (wx, wy) => toBoneLocal(anchor, wx, wy) },
5699
5838
  controls: bound ? [meshBoneRef(bound.bone, where, ctx)] : [],
5700
5839
  },
5701
5840
  );
@@ -5715,12 +5854,12 @@ function buildGridAttachment(
5715
5854
  ? undefined
5716
5855
  : { mask: bound.mask, digest: bound.digest, bone: bound.bone, carried: bound.carried, ramped: bound.ramped },
5717
5856
  });
5718
- const out: SpineMeshAttachment = {
5719
- type: 'mesh',
5857
+ const out: ModelMeshAttachment = {
5858
+ kind: 'mesh',
5720
5859
  ...meshTextureKeys(att, placeholder),
5721
5860
  uvs: geometry.uvs.map(f32),
5722
5861
  triangles: geometry.triangles,
5723
- vertices: vertices.map(f32),
5862
+ vertices: bindingsOnF32(vertices),
5724
5863
  ...generatedHullAndEdges(geometry, where),
5725
5864
  width: f32(w),
5726
5865
  height: f32(h),
@@ -5772,7 +5911,7 @@ function buildSegmentsAttachment(
5772
5911
  placeholder: string,
5773
5912
  where: string,
5774
5913
  ctx: AttachmentContext,
5775
- ): SpineMeshAttachment {
5914
+ ): ModelMeshAttachment {
5776
5915
  if (att.image === undefined) {
5777
5916
  throw new CompileError(
5778
5917
  `${where}: a "segments" generator lays its lattice over the part's own alpha, so the attachment needs an ` +
@@ -5999,7 +6138,7 @@ function buildSegmentsAttachment(
5999
6138
  weights,
6000
6139
  hullVertices: lattice.hullVertices,
6001
6140
  };
6002
- const vertices = encodeWeightedVertices(geometry, place, {
6141
+ const vertices = bindWeightedVertices(geometry, place, {
6003
6142
  anchor: meshBoneRef(ctx.anchorBone, where, ctx),
6004
6143
  controls: refs,
6005
6144
  });
@@ -6046,11 +6185,11 @@ function buildSegmentsAttachment(
6046
6185
  },
6047
6186
  });
6048
6187
  return {
6049
- type: 'mesh',
6188
+ kind: 'mesh',
6050
6189
  ...meshTextureKeys(att, placeholder),
6051
6190
  uvs: geometry.uvs.map(f32),
6052
6191
  triangles: geometry.triangles,
6053
- vertices: vertices.map(f32),
6192
+ vertices: bindingsOnF32(vertices),
6054
6193
  ...generatedHullAndEdges(geometry, where),
6055
6194
  width: f32(w),
6056
6195
  height: f32(h),
@@ -6088,7 +6227,7 @@ function buildContourAttachment(
6088
6227
  placeholder: string,
6089
6228
  where: string,
6090
6229
  ctx: AttachmentContext,
6091
- ): SpineMeshAttachment {
6230
+ ): ModelMeshAttachment {
6092
6231
  if (att.image === undefined) {
6093
6232
  throw new CompileError(
6094
6233
  `${where}: a "contour" generator traces the part's own alpha, so the attachment needs an "image" — ` +
@@ -6161,12 +6300,13 @@ function buildContourAttachment(
6161
6300
  }
6162
6301
  const anchor = ctx.transforms.get(ctx.anchorBone);
6163
6302
  if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
6164
- const index = ctx.bones.findIndex((b) => b.name === ctx.anchorBone);
6165
- if (index < 0) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" is not in the rig's bone list`);
6166
- const vertices = encodeWeightedVertices(
6303
+ if (!ctx.bones.some((b) => b.name === ctx.anchorBone)) {
6304
+ throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" is not in the rig's bone list`);
6305
+ }
6306
+ const vertices = bindWeightedVertices(
6167
6307
  geometry,
6168
6308
  (px, py) => [anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt],
6169
- { anchor: { index, toBind: (wx, wy) => toBoneLocal(anchor, wx, wy) }, controls: [] },
6309
+ { anchor: { name: ctx.anchorBone, toBind: (wx, wy) => toBoneLocal(anchor, wx, wy) }, controls: [] },
6170
6310
  );
6171
6311
  ctx.meshBones.add(ctx.anchorBone);
6172
6312
  // The vertices are on the TRACED grid — the plate's — and the depth map is
@@ -6211,12 +6351,12 @@ function buildContourAttachment(
6211
6351
  // this comment used to be the rule's only statement, beside four emit sites
6212
6352
  // that disagreed with it — and spread right after `type` by
6213
6353
  // `meshTextureKeys` since #791.
6214
- const out: SpineMeshAttachment = {
6215
- type: 'mesh',
6354
+ const out: ModelMeshAttachment = {
6355
+ kind: 'mesh',
6216
6356
  ...meshTextureKeys(att, placeholder),
6217
6357
  uvs: geometry.uvs.map(f32),
6218
6358
  triangles: geometry.triangles,
6219
- vertices: vertices.map(f32),
6359
+ vertices: bindingsOnF32(vertices),
6220
6360
  ...generatedHullAndEdges(geometry, where),
6221
6361
  width: f32(w),
6222
6362
  height: f32(h),
@@ -6270,7 +6410,7 @@ interface ConstraintContext {
6270
6410
  * type matches no case is dropped with no error and no `default:` branch, so an
6271
6411
  * unimplemented type is refused here by name rather than emitted and lost.
6272
6412
  */
6273
- function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): SpineConstraint {
6413
+ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): ModelConstraint {
6274
6414
  const where = `rig constraint "${spec.name}"`;
6275
6415
  const boneNames = ctx.boneNames;
6276
6416
  const needBone = (name: unknown, field: string): string => {
@@ -6303,7 +6443,11 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6303
6443
  }
6304
6444
  return value;
6305
6445
  };
6306
- const out: SpineConstraint = { name: spec.name, type: spec.type };
6446
+ // The constraint's fields under their spec names; `made` puts the kind the
6447
+ // branch has established and the name on them. `name, type` first and each
6448
+ // kind's field order are the emitter's (`emitConstraint`).
6449
+ const out: Record<string, unknown> = {};
6450
+ const made = (kind: ModelConstraintKind): ModelConstraint => ({ kind, name: spec.name, declaredIn: 'rig', ...out });
6307
6451
  const copy = (fields: readonly string[]) => {
6308
6452
  for (const field of fields) {
6309
6453
  const v = spec[field];
@@ -6327,7 +6471,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6327
6471
  // see `RIG_SCALE_Y_MODES`.
6328
6472
  if (spec.scaleY !== undefined) out.scaleY = needEnum(spec.scaleY, 'scaleY', RIG_SCALE_Y_MODES);
6329
6473
  copy(['mix', 'softness', 'bendPositive', 'compress', 'stretch', 'skin']);
6330
- return out;
6474
+ return made('ik');
6331
6475
  }
6332
6476
  if (spec.type === 'transform') {
6333
6477
  out.bones = boneList();
@@ -6366,7 +6510,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6366
6510
  'mixShearY',
6367
6511
  'skin',
6368
6512
  ]);
6369
- return out;
6513
+ return made('transform');
6370
6514
  }
6371
6515
  if (spec.type === 'path') {
6372
6516
  out.bones = boneList();
@@ -6400,7 +6544,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6400
6544
  if (spec[field] !== undefined) out[field] = f32(needNumber(spec[field], field));
6401
6545
  }
6402
6546
  copy(['skin']);
6403
- return out;
6547
+ return made('path');
6404
6548
  }
6405
6549
  if (spec.type === 'slider') {
6406
6550
  // ⭐ The one field in a rig spec that points at the MOTION spec. It is
@@ -6792,7 +6936,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6792
6936
  }
6793
6937
  }
6794
6938
  copy(['skin']);
6795
- return out;
6939
+ return made('slider');
6796
6940
  }
6797
6941
  if (spec.type === 'physics') {
6798
6942
  out.bone = needBone(spec.bone, 'bone');
@@ -6823,7 +6967,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6823
6967
  'mixGlobal',
6824
6968
  'skin',
6825
6969
  ]);
6826
- return out;
6970
+ return made('physics');
6827
6971
  }
6828
6972
  // Every type 4.3 has is implemented, so this is now only reachable from a typo
6829
6973
  // — and a typo is exactly what the parser drops in silence (no `default:`
@@ -6846,7 +6990,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6846
6990
  */
6847
6991
  function buildRigInfo(
6848
6992
  rig: RigSpec,
6849
- bones: SpineBone[],
6993
+ bones: ModelBone[],
6850
6994
  meshes: CompileResult['meshes'],
6851
6995
  manifest: FaceManifest | null,
6852
6996
  images: CompiledImage[],
@@ -6959,26 +7103,23 @@ function checkAxisSelfConsistency(manifest: FaceManifest): void {
6959
7103
  * without it every slot hanging off a rotated axis bone would render tilted by
6960
7104
  * the cut's axis angle.
6961
7105
  *
6962
- * On an unrotated bone sitting at its window centre both terms are zero and the
6963
- * fields are omitted, which is why a formation with no axis emits the same
6964
- * bytes it always did.
7106
+ * On an unrotated bone sitting at its window centre both terms are zero, and the
7107
+ * emitter leaves a zero out, which is why a formation with no axis emits the
7108
+ * same bytes it always did.
6965
7109
  */
6966
7110
  function placeRegion(
6967
7111
  part: FaceManifestPart,
6968
7112
  manifest: FaceManifest,
6969
7113
  bone: BoneTransform,
6970
7114
  img: CompiledImage,
6971
- ): SpineRegionAttachment {
7115
+ ): ModelRegionAttachment {
6972
7116
  const win = partWindow(part, manifest);
6973
7117
  // width/height are NOT optional: omitting them loads as NaN with no error.
6974
7118
  // The compiler fills them from the PNG.
6975
- const att: SpineRegionAttachment = { width: img.width, height: img.height };
6976
7119
  const [ax, ay] = toBoneLocal(bone, win.x + win.w / 2, cropToSpineY(win.y + win.h / 2, manifest.crop.h)).map(f32);
6977
- if (ax !== 0) att.x = ax;
6978
- if (ay !== 0) att.y = ay;
6979
7120
  const rotation = f32(normaliseDegrees(-bone.worldRotation));
6980
- if (rotation !== 0) att.rotation = rotation;
6981
- return att;
7121
+ // All three held, 0 included: leaving a 0 out is the emitter's (`emitRegion`).
7122
+ return { kind: 'region', width: img.width, height: img.height, x: ax, y: ay, rotation, atlas: imageAtlasRect(img) };
6982
7123
  }
6983
7124
 
6984
7125
  /**
@@ -6992,10 +7133,10 @@ function placeRegion(
6992
7133
  function buildMesh(
6993
7134
  part: FaceManifestPart,
6994
7135
  manifest: FaceManifest,
6995
- bones: SpineBone[],
7136
+ bones: ModelBone[],
6996
7137
  transforms: Map<string, BoneTransform>,
6997
7138
  anchorName: string,
6998
- ): { attachment: SpineMeshAttachment; kind: 'ring' | 'ribbon' } {
7139
+ ): { attachment: ModelMeshAttachment; kind: 'ring' | 'ribbon' } {
6999
7140
  const spec = part.mesh!;
7000
7141
  const kind = spec.kind ?? 'ring';
7001
7142
  const win = partWindow(part, manifest);
@@ -7003,11 +7144,10 @@ function buildMesh(
7003
7144
  const controls = meshControlBones(part);
7004
7145
 
7005
7146
  const refFor = (name: string): MeshBoneRef => {
7006
- const index = bones.findIndex((b) => b.name === name);
7007
- if (index < 0) throw new CompileError(`internal: mesh bone "${name}" is not in the bone list`);
7147
+ if (!bones.some((b) => b.name === name)) throw new CompileError(`internal: mesh bone "${name}" is not in the bone list`);
7008
7148
  const m = transforms.get(name);
7009
7149
  if (!m) throw new CompileError(`internal: no setup transform for mesh bone "${name}"`);
7010
- return { index, toBind: (wx, wy) => toBoneLocal(m, wx, wy) };
7150
+ return { name, toBind: (wx, wy) => toBoneLocal(m, wx, wy) };
7011
7151
  };
7012
7152
 
7013
7153
  let geometry;
@@ -7047,7 +7187,7 @@ function buildMesh(
7047
7187
  throw err;
7048
7188
  }
7049
7189
 
7050
- const vertices = encodeWeightedVertices(
7190
+ const vertices = bindWeightedVertices(
7051
7191
  geometry,
7052
7192
  (px, py) => [win.x + px, cropToSpineY(win.y + py, cropH)],
7053
7193
  { anchor: refFor(anchorName), controls: controls.map(refFor) },
@@ -7056,10 +7196,10 @@ function buildMesh(
7056
7196
  return {
7057
7197
  kind: geometry.kind,
7058
7198
  attachment: {
7059
- type: 'mesh',
7199
+ kind: 'mesh',
7060
7200
  uvs: geometry.uvs.map(f32),
7061
7201
  triangles: geometry.triangles,
7062
- vertices: vertices.map(f32),
7202
+ vertices: bindingsOnF32(vertices),
7063
7203
  ...generatedHullAndEdges(geometry, `slot "${part.slot}" mesh`),
7064
7204
  width: win.w,
7065
7205
  height: win.h,
@@ -7123,13 +7263,13 @@ function compileValueTrack(
7123
7263
  shift: number,
7124
7264
  shapes: Record<string, ValueTrackShape>,
7125
7265
  kind: string,
7126
- ): SpineTimelineKey[] {
7266
+ ): ModelKey[] {
7127
7267
  const shape = shapes[track.property];
7128
7268
  if (!shape) throw new CompileError(`animation "${animName}": ${kind} "${target}" has no property "${track.property}"`);
7129
7269
  const where = `animation "${animName}" ${kind} "${target}" ${track.property}`;
7130
7270
  if (!track.keys.length) throw new CompileError(`${where}: no keys`);
7131
7271
 
7132
- const out: SpineTimelineKey[] = [];
7272
+ const out: ModelKey[] = [];
7133
7273
  for (let i = 0; i < track.keys.length; i++) {
7134
7274
  const key = track.keys[i];
7135
7275
  const next = track.keys[i + 1];
@@ -7169,7 +7309,7 @@ function compileValueTrack(
7169
7309
  if (!Array.isArray(key.v) || key.v.length !== shape.fields.length) {
7170
7310
  throw new CompileError(`${where}: key value must be an array of ${shape.fields.length} number(s)`);
7171
7311
  }
7172
- const entry: SpineTimelineKey = { time };
7312
+ const entry: ModelKey = { time };
7173
7313
  shape.fields.forEach((field, c) => {
7174
7314
  const v = key.v as number[];
7175
7315
  if (!Number.isFinite(v[c])) throw new CompileError(`${where}: non-finite value ${String(v[c])}`);
@@ -7234,7 +7374,8 @@ function compileValueTrack(
7234
7374
  * than loading wrong. The author states a set of moves; the array order in
7235
7375
  * the file is the parser's requirement and not a decision, so rigc sorts
7236
7376
  * rather than making every caller remember. Deterministic: the key is the
7237
- * emitted slot index.
7377
+ * emitted slot index. The model holds the moves as stated and the Spine
7378
+ * emitter sorts them (`emitDrawOrderKey`, issue #921).
7238
7379
  * 2. **One slot per key.** Two entries for the same slot means two writes at
7239
7380
  * one cursor position; the second silently wins and the first slot's place
7240
7381
  * is left to the unchanged-fill.
@@ -7254,13 +7395,13 @@ function compileDrawOrder(
7254
7395
  keys: MotionDrawOrderKey[],
7255
7396
  animName: string,
7256
7397
  duration: number,
7257
- slots: SpineSlot[],
7258
- ): SpineTimelineKey[] {
7398
+ slots: readonly ModelSlot[],
7399
+ ): ModelKey[] {
7259
7400
  const where = `animation "${animName}" drawOrder`;
7260
7401
  if (!keys.length) throw new CompileError(`${where}: no keys`);
7261
7402
  const indexOf = new Map(slots.map((s, i) => [s.name, i]));
7262
7403
 
7263
- const out: SpineTimelineKey[] = [];
7404
+ const out: ModelKey[] = [];
7264
7405
  for (let i = 0; i < keys.length; i++) {
7265
7406
  const key = keys[i];
7266
7407
  const time = keyTime(key.t);
@@ -7276,7 +7417,7 @@ function compileDrawOrder(
7276
7417
  continue;
7277
7418
  }
7278
7419
  const seen = new Set<string>();
7279
- const entries: Array<{ slot: string; offset: number; index: number }> = [];
7420
+ const entries: Array<{ slot: string; offset: number }> = [];
7280
7421
  for (const off of key.offsets) {
7281
7422
  const index = indexOf.get(off.slot);
7282
7423
  if (index === undefined) {
@@ -7296,10 +7437,11 @@ function compileDrawOrder(
7296
7437
  );
7297
7438
  }
7298
7439
  seen.add(off.slot);
7299
- entries.push({ slot: off.slot, offset: off.offset, index });
7440
+ entries.push({ slot: off.slot, offset: off.offset });
7300
7441
  }
7301
- entries.sort((a, b) => a.index - b.index);
7302
- out.push({ time, offsets: entries.map((e) => ({ slot: e.slot, offset: e.offset })) });
7442
+ // In the order the spec states the moves: the setup-order sort the parser
7443
+ // needs is the Spine emitter's (`emitDrawOrderKey`, issue #921).
7444
+ out.push({ time, offsets: entries });
7303
7445
  }
7304
7446
  return out;
7305
7447
  }
@@ -7334,11 +7476,11 @@ function compileEvents(
7334
7476
  animName: string,
7335
7477
  duration: number,
7336
7478
  events: Record<string, RigEvent>,
7337
- ): SpineTimelineKey[] {
7479
+ ): ModelKey[] {
7338
7480
  const where = `animation "${animName}" events`;
7339
7481
  if (!keys.length) throw new CompileError(`${where}: no keys`);
7340
7482
 
7341
- const out: SpineTimelineKey[] = [];
7483
+ const out: ModelKey[] = [];
7342
7484
  for (let i = 0; i < keys.length; i++) {
7343
7485
  const key = keys[i];
7344
7486
  if (typeof key.name !== 'string' || key.name.length === 0) {
@@ -7361,7 +7503,7 @@ function compileEvents(
7361
7503
  }
7362
7504
  checkKeyTime(where, time, key.t, duration);
7363
7505
 
7364
- const entry: SpineTimelineKey = { time, name: key.name };
7506
+ const entry: ModelKey = { time, name: key.name };
7365
7507
  if (key.int !== undefined) {
7366
7508
  if (!Number.isInteger(key.int)) {
7367
7509
  throw new CompileError(`${where} at t=${key.t}: int ${String(key.int)} is not an integer`);
@@ -7448,9 +7590,9 @@ function compileConstraintTrack(
7448
7590
  motion: MotionSpec,
7449
7591
  animName: string,
7450
7592
  duration: number,
7451
- /** Non-default ik booleans the rig declared, by field. Empty for `transform`. */
7593
+ /** The ik constraint's three booleans in effect, by field. Empty for `transform`. */
7452
7594
  rigFlags: Record<string, boolean>,
7453
- ): SpineTimelineKey[] {
7595
+ ): ModelKey[] {
7454
7596
  const shape = CONSTRAINT_TIMELINES[group];
7455
7597
  const article = group === 'ik' ? 'an' : 'a';
7456
7598
  const where = `animation "${animName}" ${group} constraint "${track.constraint}"`;
@@ -7499,7 +7641,7 @@ function compileConstraintTrack(
7499
7641
  return inherited === undefined ? channel.dflt : (inherited as number);
7500
7642
  };
7501
7643
 
7502
- const out: SpineTimelineKey[] = [];
7644
+ const out: ModelKey[] = [];
7503
7645
  for (let i = 0; i < keys.length; i++) {
7504
7646
  const key = keys[i];
7505
7647
  const next = keys[i + 1];
@@ -7509,7 +7651,7 @@ function compileConstraintTrack(
7509
7651
  }
7510
7652
  checkKeyTime(where, time, key.t, duration);
7511
7653
 
7512
- const entry: SpineTimelineKey = { time };
7654
+ const entry: ModelKey = { time };
7513
7655
  for (const channel of shape.channels) {
7514
7656
  const v = read(key, channel.field);
7515
7657
  if (v === undefined) continue;
@@ -7529,10 +7671,10 @@ function compileConstraintTrack(
7529
7671
  for (const flag of shape.flags) {
7530
7672
  const v = read(key, flag.field);
7531
7673
  if (v === undefined) {
7532
- // The rig's value, stamped on this key because nothing else will carry
7533
- // it there. Absent from `rigFlags` means the rig's value IS the per-key
7534
- // default, so omitting the field says the same thing and the emitted
7535
- // bytes do not move.
7674
+ // The constraint's flag in effect, held on this key because the parser
7675
+ // reads the flag per key and does not inherit it. The model key holds
7676
+ // every flag in effect; the Spine emitter writes one only where it
7677
+ // differs from the per-key default (`emitAnimations`, issue #921).
7536
7678
  const carried = rigFlags[flag.field];
7537
7679
  if (carried !== undefined) entry[flag.field] = carried;
7538
7680
  continue;
@@ -7594,7 +7736,7 @@ function defaultOf(shape: ConstraintTimelineShape, field: string): number | bool
7594
7736
  * Same shape, two meanings, and picking the wrong one writes a run that silently
7595
7737
  * lands on the wrong vertices.
7596
7738
  */
7597
- interface DeformGeometry {
7739
+ export interface DeformGeometry {
7598
7740
  weighted: boolean;
7599
7741
  /** How long the array the key edits is. */
7600
7742
  deformLength: number;
@@ -7641,7 +7783,7 @@ interface DeformGeometry {
7641
7783
  /**
7642
7784
  * Per-vertex `z`, when this attachment's generator named a depth map.
7643
7785
  *
7644
- * Not measured from the emitted attachment like everything else here — there
7786
+ * Not measured from the attachment like everything else here — there
7645
7787
  * is nowhere in the format to measure it FROM, which is the whole reason the
7646
7788
  * sampler keeps it beside the skeleton instead of in it. Filled in by the
7647
7789
  * caller, which is where the skin, slot and attachment names live.
@@ -7650,25 +7792,35 @@ interface DeformGeometry {
7650
7792
  }
7651
7793
 
7652
7794
  /**
7653
- * Measure one emitted attachment's deform array.
7795
+ * Measure one attachment's deform array, off its model record (issue #917): the
7796
+ * bindings name their bones, so each influence's setup transform is looked up by
7797
+ * name and no index into any bone array is read.
7654
7798
  *
7655
7799
  * A region attachment is refused rather than measured: it has no `vertices` at
7656
7800
  * all, so `attachment.vertices.length` throws inside the parser — one of the very
7657
7801
  * few places this format fails loudly, and it fails in the consumer's process.
7802
+ * A linked mesh is refused the same way, by its type.
7803
+ *
7804
+ * Exported for the selftest alone: `MV06` holds it to the decoder of the emitted
7805
+ * run it replaced, to the bit.
7658
7806
  */
7659
- function deformGeometryOf(
7660
- att: SpineAttachment,
7807
+ export function deformGeometryOf(
7808
+ att: SkinTableEntry,
7661
7809
  where: string,
7662
- /** The emitted bone array, which a weight run's `boneIndex` indexes into. */
7663
- rigBones: SpineBone[],
7664
- /** Their setup world transforms, by name. */
7810
+ /** Every bone's setup world transform, by name. */
7665
7811
  transforms: Map<string, BoneTransform>,
7666
7812
  ): DeformGeometry {
7667
- const type = (att as { type?: string }).type ?? 'region';
7813
+ const type = att.kind;
7668
7814
  let worldVerticesLength: number;
7669
- if (type === 'mesh') {
7670
- worldVerticesLength = (att as SpineMeshAttachment).uvs.length;
7671
- } else if (type === 'boundingbox' || type === 'clipping' || type === 'path') {
7815
+ if (!isModelVertexAttachment(att)) {
7816
+ throw new CompileError(
7817
+ `${where}: a deform timeline keys the vertices of an attachment, and this one is a "${type}" — ` +
7818
+ 'it has no vertex array to deform. Deformable types: mesh, boundingbox, clipping, path.',
7819
+ );
7820
+ }
7821
+ if (att.kind === 'mesh') {
7822
+ worldVerticesLength = att.uvs.length;
7823
+ } else {
7672
7824
  // ⭐ A `path` reaches this line as of issue #696, and it is the SAME line the
7673
7825
  // other two vertex-and-no-triangles types take: the array the parser sizes
7674
7826
  // is `vertexCount * 2`, the two encodings below are the mesh's own, and a
@@ -7697,16 +7849,11 @@ function deformGeometryOf(
7697
7849
  // file twice. `docs/AUTHORING.md` §4.11 states the `constantSpeed: false`
7698
7850
  // reading instead, which is the honest home for it: a fact about the format
7699
7851
  // the author is choosing, not a fault rigc can measure.
7700
- worldVerticesLength = (att as SpineBoundingBoxAttachment | SpinePathAttachment).vertexCount * 2;
7701
- } else {
7702
- throw new CompileError(
7703
- `${where}: a deform timeline keys the vertices of an attachment, and this one is a "${type}" — ` +
7704
- 'it has no vertex array to deform. Deformable types: mesh, boundingbox, clipping, path.',
7705
- );
7852
+ worldVerticesLength = att.vertexCount * 2;
7706
7853
  }
7707
- const vertices = (att as SpineMeshAttachment).vertices ?? [];
7708
- const weighted = vertices.length !== worldVerticesLength;
7709
- if (!weighted) {
7854
+ const vertices = att.vertices;
7855
+ const weighted = vertices.weighted;
7856
+ if (!vertices.weighted) {
7710
7857
  return {
7711
7858
  weighted,
7712
7859
  deformLength: worldVerticesLength,
@@ -7714,41 +7861,37 @@ function deformGeometryOf(
7714
7861
  boneCounts: null,
7715
7862
  // An unweighted attachment IS its own space: the array is one `x, y` per
7716
7863
  // vertex in the slot bone's space, which is the space the offsets are in.
7717
- setup: vertices.slice(),
7864
+ setup: vertices.xy.slice(),
7718
7865
  influenceBones: null,
7719
7866
  setupWhy: null,
7720
- // Filled in by the caller; `deformGeometryOf` reads the emitted
7721
- // attachment, and the depth is deliberately not in it.
7867
+ // Filled in by the caller; `deformGeometryOf` reads the attachment's
7868
+ // record, and the depth is deliberately not in it.
7722
7869
  depth: null,
7723
7870
  };
7724
7871
  }
7725
- // Walk the weight run for the per-vertex influence counts. The run's own shape
7726
- // is already assured by the attachment builders and by A33/A04; this only
7727
- // counts, and a malformed run stops rather than producing a plausible number.
7872
+ // Walk the bindings for the per-vertex influence counts. Their shape is
7873
+ // already assured by the attachment builders and by A33/A04; this only counts,
7874
+ // and a vertex with no binding stops rather than producing a plausible number
7875
+ // — at the index its count would hold in the emitted run, as it was worded
7876
+ // when this walked the run.
7728
7877
  const boneCounts: number[] = [];
7729
7878
  const bindSpace: number[] = [];
7730
- const bones = new Set<number>();
7731
- /** `boneIndex, bindX, bindY, weight` per influence, in deform-array order. */
7732
- const influenceRun: number[] = [];
7733
- for (let i = 0; i < vertices.length; ) {
7734
- const n = vertices[i++];
7735
- if (!Number.isInteger(n) || n < 1) {
7736
- throw new CompileError(`${where}: the attachment's weighted vertex run has a bone count of ${String(n)} at index ${i - 1}`);
7879
+ const bones = new Set<string>();
7880
+ for (let v = 0, at = 0; v < vertices.bindings.length; v++) {
7881
+ const vertex = vertices.bindings[v];
7882
+ const n = vertex.length;
7883
+ if (n < 1) {
7884
+ throw new CompileError(`${where}: the attachment's weighted vertex run has a bone count of ${String(n)} at index ${at}`);
7737
7885
  }
7738
7886
  if (n === 1) {
7739
- bones.add(vertices[i]);
7740
- bindSpace.push(vertices[i + 1], vertices[i + 2]);
7741
- }
7742
- const from = i;
7743
- i += n * 4;
7744
- if (i > vertices.length) {
7745
- throw new CompileError(`${where}: the attachment's weighted vertex run is truncated at vertex ${boneCounts.length}`);
7887
+ bones.add(vertex[0].bone);
7888
+ bindSpace.push(vertex[0].x, vertex[0].y);
7746
7889
  }
7747
- for (let k = from; k < i; k++) influenceRun.push(vertices[k]);
7748
7890
  boneCounts.push(n);
7891
+ at += 1 + n * 4;
7749
7892
  }
7750
7893
  // ⚠️ The influence count is the SUM of the per-vertex counts, not a division
7751
- // of the JSON array's length. The emitted array is `boneCount` followed by
7894
+ // of the emitted JSON array's length. The emitted array is `boneCount` followed by
7752
7895
  // `boneIndex, x, y, weight` per influence — five numbers for a single-bone
7753
7896
  // vertex, not three — so `vertices.length / 3` overstated the deform array by
7754
7897
  // two thirds on a one-bone-per-vertex mesh (`gallery/flex`'s 77-vertex leaf
@@ -7786,17 +7929,15 @@ function deformGeometryOf(
7786
7929
  const setupWorld: number[] = [];
7787
7930
  const influenceBones: BoneTransform[] = [];
7788
7931
  let openWeights: string | null = null;
7789
- for (let v = 0, k = 0; v < boneCounts.length; v++) {
7932
+ for (let v = 0; v < boneCounts.length; v++) {
7790
7933
  let wx = 0;
7791
7934
  let wy = 0;
7792
7935
  let sum = 0;
7793
- for (let n = 0; n < boneCounts[v]; n++, k++) {
7794
- const index = influenceRun[4 * k];
7795
- const bone = rigBones[index];
7796
- const m = bone === undefined ? undefined : transforms.get(bone.name);
7797
- if (!m) throw new CompileError(`${where}: vertex ${v} binds bone index ${index}, which is not in the bone list`);
7798
- const weight = influenceRun[4 * k + 3];
7799
- const [x, y] = toWorld(m, influenceRun[4 * k + 1], influenceRun[4 * k + 2]);
7936
+ for (const binding of vertices.bindings[v]) {
7937
+ const m = transforms.get(binding.bone);
7938
+ if (!m) throw new CompileError(`${where}: vertex ${v} binds bone "${binding.bone}", which has no setup transform`);
7939
+ const weight = binding.weight;
7940
+ const [x, y] = toWorld(m, binding.x, binding.y);
7800
7941
  wx += x * weight;
7801
7942
  wy += y * weight;
7802
7943
  sum += weight;
@@ -7891,11 +8032,11 @@ function deformGeometryOf(
7891
8032
  function compileSequenceTrack(
7892
8033
  track: MotionSequenceTrack,
7893
8034
  duration: number,
7894
- attachment: SpineAttachment,
8035
+ attachment: SkinTableEntry,
7895
8036
  where: string,
7896
- ): SpineTimelineKey[] {
7897
- const type = (attachment as { type?: string }).type ?? 'region';
7898
- const series = (attachment as { sequence?: SpineSequence }).sequence;
8037
+ ): ModelKey[] {
8038
+ const series =
8039
+ attachment.kind === 'region' || attachment.kind === 'mesh' || attachment.kind === 'linkedmesh' ? attachment.sequence : undefined;
7899
8040
  if (series === undefined) {
7900
8041
  throw new CompileError(
7901
8042
  `${where}: attachment "${track.attachment}" carries no "sequence" block, so there is no series to step. The ` +
@@ -7904,8 +8045,8 @@ function compileSequenceTrack(
7904
8045
  'attachment a "sequence" in the rig spec, or remove the track.',
7905
8046
  );
7906
8047
  }
7907
- if (type === 'linkedmesh' && (attachment as SpineLinkedMeshAttachment).timelines !== false) {
7908
- const source = (attachment as SpineLinkedMeshAttachment).source;
8048
+ if (attachment.kind === 'linkedmesh' && attachment.timelines) {
8049
+ const source = attachment.source;
7909
8050
  throw new CompileError(
7910
8051
  `${where}: attachment "${track.attachment}" is a linked mesh that plays its source's timelines ("timelines" ` +
7911
8052
  `is not false), so its \`timelineAttachment\` is "${source}" (\`SkeletonJson.js:437-448\`) and ` +
@@ -7915,7 +8056,7 @@ function compileSequenceTrack(
7915
8056
  );
7916
8057
  }
7917
8058
  if (track.keys.length === 0) throw new CompileError(`${where}: no keys`);
7918
- const out: SpineTimelineKey[] = [];
8059
+ const out: ModelKey[] = [];
7919
8060
  for (let i = 0; i < track.keys.length; i++) {
7920
8061
  const key = track.keys[i];
7921
8062
  const time = keyTime(key.t);
@@ -7930,7 +8071,7 @@ function compileSequenceTrack(
7930
8071
  'frame it does not name. `index` is 0-based.',
7931
8072
  );
7932
8073
  }
7933
- const entry: SpineTimelineKey = { time };
8074
+ const entry: ModelKey = { time };
7934
8075
  if (key.mode !== undefined) entry.mode = key.mode;
7935
8076
  if (key.index !== undefined) entry.index = key.index;
7936
8077
  if (key.delay !== undefined) entry.delay = f32(key.delay);
@@ -7946,7 +8087,7 @@ function compileDeformTrack(
7946
8087
  duration: number,
7947
8088
  geometry: DeformGeometry,
7948
8089
  generated: CompileResult['deformTransforms'],
7949
- ): SpineTimelineKey[] {
8090
+ ): ModelKey[] {
7950
8091
  const skin = track.skin ?? 'default';
7951
8092
  const where = `animation "${animName}" deform ${skin}/${track.slot}/${track.attachment}`;
7952
8093
  const keys = track.keys;
@@ -7955,7 +8096,7 @@ function compileDeformTrack(
7955
8096
  // skips the timeline, which is assertion A34's silent case.
7956
8097
  if (keys.length === 0) throw new CompileError(`${where}: no keys`);
7957
8098
 
7958
- const out: SpineTimelineKey[] = [];
8099
+ const out: ModelKey[] = [];
7959
8100
  for (let i = 0; i < keys.length; i++) {
7960
8101
  const key = keys[i];
7961
8102
  const time = keyTime(key.t);
@@ -8114,7 +8255,7 @@ function compileDeformTrack(
8114
8255
  'The parser copies into a Float32Array, so everything past the end is dropped without a word.',
8115
8256
  );
8116
8257
  }
8117
- const entry: SpineTimelineKey = { time };
8258
+ const entry: ModelKey = { time };
8118
8259
  // `offset` defaults to 0 in the parser and the editor omits it there, so an
8119
8260
  // authored 0, an authored `fromVertex: 0` and an absent start all emit the
8120
8261
  // same bytes — one meaning, one file.
@@ -8242,15 +8383,15 @@ function deformStart(
8242
8383
  * than to a coordinate, and a raw curve is four numbers whose value axis is 0..1.
8243
8384
  */
8244
8385
  function deformKeyCurve(
8245
- entry: SpineTimelineKey,
8386
+ entry: ModelKey,
8246
8387
  key: MotionDeformTrack['keys'][number],
8247
8388
  keys: MotionDeformTrack['keys'],
8248
8389
  index: number,
8249
8390
  motion: MotionSpec,
8250
8391
  where: string,
8251
8392
  /** The next key AS EMITTED, or undefined on the last key. */
8252
- next: SpineTimelineKey | undefined,
8253
- ): SpineTimelineKey {
8393
+ next: ModelKey | undefined,
8394
+ ): ModelKey {
8254
8395
  if (key.ease !== undefined && key.curve !== undefined) {
8255
8396
  throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
8256
8397
  }
@@ -8282,8 +8423,8 @@ function deformKeyCurve(
8282
8423
  * are three spellings of one geometry, and the comparison expands both keys onto
8283
8424
  * the same index range before reading them. The runs are already `f32`'d.
8284
8425
  */
8285
- function sameDeform(a: SpineTimelineKey, b: SpineTimelineKey): boolean {
8286
- const runOf = (k: SpineTimelineKey): { start: number; run: number[] } => ({
8426
+ function sameDeform(a: ModelKey, b: ModelKey): boolean {
8427
+ const runOf = (k: ModelKey): { start: number; run: number[] } => ({
8287
8428
  start: typeof k.offset === 'number' ? k.offset : 0,
8288
8429
  run: Array.isArray(k.vertices) ? (k.vertices as number[]) : [],
8289
8430
  });
@@ -8462,7 +8603,7 @@ function resolveMemberTrack(
8462
8603
  track: MotionTrack,
8463
8604
  animName: string,
8464
8605
  targets: readonly string[],
8465
- bones: readonly SpineBone[],
8606
+ bones: readonly ModelBone[],
8466
8607
  derivations: CompileResult['trackDerivations'],
8467
8608
  ): Map<string, MotionValueTrack> {
8468
8609
  const carriesMap = track.keys.some((key) => isMemberValues(key.v));
@@ -8622,7 +8763,7 @@ function compileTrack(
8622
8763
  shift: number,
8623
8764
  attachments: SlotAttachmentIndex,
8624
8765
  darkSlots: ReadonlySet<string>,
8625
- ): SpineTimelineKey[] {
8766
+ ): ModelKey[] {
8626
8767
  const where = `animation "${animName}" slot "${target}" ${track.property}`;
8627
8768
  // Before any key is shaped, and before the empty-track refusal: a property
8628
8769
  // the emitter has no branch for is the fault, and a track that names one has
@@ -8661,7 +8802,7 @@ function compileTrack(
8661
8802
  }
8662
8803
  if (!track.keys.length) throw new CompileError(`${where}: no keys`);
8663
8804
 
8664
- const out: SpineTimelineKey[] = [];
8805
+ const out: ModelKey[] = [];
8665
8806
  for (let i = 0; i < track.keys.length; i++) {
8666
8807
  const key = track.keys[i];
8667
8808
  const next = track.keys[i + 1];
@@ -8724,7 +8865,7 @@ function compileTrack(
8724
8865
  'the posed alpha to that range after interpolating, so a value outside it is one no pose ever holds',
8725
8866
  );
8726
8867
  }
8727
- const entry: SpineTimelineKey = { time, ...colourOf(key.v) };
8868
+ const entry: ModelKey = { time, ...colourOf(key.v) };
8728
8869
  if (key.ease !== undefined && key.curve !== undefined) {
8729
8870
  throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
8730
8871
  }