spine-rigc 1.5.3 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compile.ts CHANGED
@@ -41,7 +41,6 @@ import { parseJsonWithPosition } from './json-position.ts';
41
41
  import { dottedPath, nearMisses, refuseNumbersTheFileCannotCarry, refuseValuesOfTheWrongType, refuseValuesOutsideTheirSet } from './keys.ts';
42
42
  import type { ShapeVisit } from './keys.ts';
43
43
  import { MANIFEST_ENUMS, MANIFEST_TYPES } from './types.ts';
44
- import { inEditorKeyOrder, withoutParserDefaults } from './keyorder.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
@@ -2537,7 +2555,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2537
2555
  const axisSpineDeg = manifest?.axis ? screenToSpineDegrees(manifest.axis.deg) : null;
2538
2556
  const partBySlot = new Map(parts.map((part) => [part.slot, part]));
2539
2557
 
2540
- const bones: SpineBone[] = [];
2558
+ const bones: ModelBone[] = [];
2541
2559
  for (const spec of rig.bones) {
2542
2560
  bones.push(buildBone(spec, bones, { rig, manifest, cropH, axisSpineDeg, partBySlot }));
2543
2561
  }
@@ -2563,9 +2581,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2563
2581
  // -- 4. slots + skins ------------------------------------------------------
2564
2582
  // Draw order IS the slots array order. No separate field,
2565
2583
  // and the rig's array is that order.
2566
- const slots: SpineSlot[] = [];
2567
- const skinTables = new Map<string, Record<string, Record<string, SpineAttachment>>>();
2568
- 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 => {
2569
2589
  let table = skinTables.get(skinName);
2570
2590
  if (!table) {
2571
2591
  table = {};
@@ -2703,11 +2723,13 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2703
2723
  if (setup?.color && rigSlot.color !== undefined) {
2704
2724
  throw new CompileError(`slot "${rigSlot.name}" has a setup colour in the rig spec AND in the motion spec`);
2705
2725
  }
2706
- const slot: SpineSlot = { name: rigSlot.name, bone: rigSlot.bone };
2707
- 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 };
2708
2729
  if (setup?.color) slot.color = rgbaHex(setup.color);
2709
2730
  else if (rigSlot.color !== undefined) slot.color = rigSlot.color;
2710
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).
2711
2733
  if (rigSlot.blend !== undefined) slot.blend = rigSlot.blend;
2712
2734
  slots.push(slot);
2713
2735
  // ...and nothing below this line has anything to build. An empty entry in a
@@ -2717,7 +2739,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2717
2739
  if (empty) continue;
2718
2740
 
2719
2741
  if (part) {
2720
- const perSlot: Record<string, SpineAttachment> = {};
2742
+ const perSlot: Record<string, SkinTableEntry> = {};
2721
2743
  const mesh = part.mesh ? buildMesh(part, manifest!, bones, transforms, rigSlot.bone) : null;
2722
2744
  for (const name of names) {
2723
2745
  const img = images.find((im) => im.region === name);
@@ -2751,7 +2773,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2751
2773
  for (const skinName of skinNames) {
2752
2774
  const placeholders = skinParts.get(skinName)!.attachments[rigSlot.name];
2753
2775
  if (!placeholders) continue;
2754
- const perSlot: Record<string, SpineAttachment> = {};
2776
+ const perSlot: Record<string, SkinTableEntry> = {};
2755
2777
  for (const [placeholder, att] of Object.entries(placeholders)) {
2756
2778
  const where = `skin "${skinName}" slot "${rigSlot.name}" attachment "${placeholder}"`;
2757
2779
  const built = buildRigAttachment(att, placeholder, where, {
@@ -2766,6 +2788,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2766
2788
  imagesDir,
2767
2789
  depths: attachmentDepths,
2768
2790
  slotNames: new Set(rig.slots.map((s) => s.name)),
2791
+ atlasRegions: atlasIn === null ? null : atlasIn.byName,
2769
2792
  links: pendingLinks,
2770
2793
  });
2771
2794
  // The name is put on AFTER the builder rather than inside it: six
@@ -2815,7 +2838,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2815
2838
  // (structure), then the motion spec's physics table (tuning). A name in both is
2816
2839
  // refused: `mix` timelines resolve by name, and two constraints answering to
2817
2840
  // one name is a timeline driving something nobody chose.
2818
- const constraints: SpineConstraint[] = [];
2841
+ const constraints: ModelConstraint[] = [];
2819
2842
  const physicsReport: CompileResult['physics'] = [];
2820
2843
  // Deform keys that stated a model instead of a run (issue #294). Declared here
2821
2844
  // rather than inside the animation loop because `explain` reports it across
@@ -2851,8 +2874,8 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2851
2874
  constraintNamesOfKind.set(type, [...(constraintNamesOfKind.get(type) ?? []), name]);
2852
2875
  };
2853
2876
  /**
2854
- * ik constraint -> the booleans it declares that the timeline format would
2855
- * 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.
2856
2879
  *
2857
2880
  * 🚨 `SkeletonJson` reads `bendPositive`, `compress` and `stretch` in TWO
2858
2881
  * places with the same defaults: once on the constraint (`:155`) and once on
@@ -2861,19 +2884,23 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2861
2884
  * `bendPositive: false` and an `ik` timeline that keys only `mix` produce a
2862
2885
  * constraint that bends the other way for the whole animation, with the field
2863
2886
  * still in the file and inert: four builds differing only in these flags posed
2864
- * one pose. What goes in this map is only the values that DIFFER from the
2865
- * per-key default, because a rig that says nothing and a key that says nothing
2866
- * 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.
2867
2893
  */
2868
- const ikRigFlags = new Map<string, Record<string, boolean>>();
2894
+ const ikFlagsInEffect = new Map<string, Record<string, boolean>>();
2869
2895
  // Which slots can actually show a path, for the path constraint's own check.
2870
- // Read off the emitted skin tables rather than the spec, so it answers the
2871
- // 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.)
2872
2899
  const pathSlots = new Map<string, string[]>();
2873
2900
  for (const [skinName, table] of skinTables) {
2874
2901
  for (const [slotName, perSlot] of Object.entries(table)) {
2875
2902
  for (const att of Object.values(perSlot)) {
2876
- if ((att as { type?: string }).type !== 'path') continue;
2903
+ if (att.kind !== 'path') continue;
2877
2904
  pathSlots.set(slotName, [...(pathSlots.get(slotName) ?? []), skinName]);
2878
2905
  break;
2879
2906
  }
@@ -2893,12 +2920,15 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2893
2920
  constraints.push(buildRigConstraint(spec, constraintCtx));
2894
2921
  declareConstraint(spec.name, spec.type);
2895
2922
  if (spec.type === 'ik') {
2896
- 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> = {};
2897
2927
  for (const flag of CONSTRAINT_TIMELINES.ik.flags) {
2898
2928
  const declared = spec[flag.field];
2899
- if (typeof declared === 'boolean' && declared !== flag.dflt) carried[flag.field] = declared;
2929
+ inEffect[flag.field] = typeof declared === 'boolean' ? declared : flag.dflt;
2900
2930
  }
2901
- if (Object.keys(carried).length) ikRigFlags.set(spec.name, carried);
2931
+ ikFlagsInEffect.set(spec.name, inEffect);
2902
2932
  }
2903
2933
  }
2904
2934
  withMotionSource(() => {
@@ -2916,17 +2946,17 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2916
2946
  if (!boneNames.has(spec.bone)) {
2917
2947
  throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
2918
2948
  }
2919
- const entry: SpineConstraint = {
2920
- name,
2921
- type: 'physics',
2922
- bone: spec.bone,
2923
- };
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 };
2924
2954
  const components: string[] = [];
2925
2955
  for (const comp of PHYSICS_COMPONENTS) {
2926
2956
  const v = spec[comp];
2927
- if (v === undefined || v === 0) continue;
2957
+ if (v === undefined) continue;
2928
2958
  entry[comp] = f32(v);
2929
- components.push(comp);
2959
+ if (v !== 0) components.push(comp);
2930
2960
  }
2931
2961
  if (!components.length) {
2932
2962
  // The parser is happy with this and the constraint does nothing at all.
@@ -2935,9 +2965,9 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2935
2965
  `physics constraint "${name}" drives no component — set at least one of ${PHYSICS_COMPONENTS.join('/')}`,
2936
2966
  );
2937
2967
  }
2938
- for (const [param, dflt] of PHYSICS_PARAMS) {
2968
+ for (const [param] of PHYSICS_PARAMS) {
2939
2969
  const v = spec[param as keyof typeof spec] as number | undefined;
2940
- if (v === undefined || v === dflt) continue;
2970
+ if (v === undefined) continue;
2941
2971
  entry[param] = f32(v);
2942
2972
  }
2943
2973
  constraints.push(entry);
@@ -2983,12 +3013,15 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2983
3013
  // the refusal below reports as the skins it looked in, so it is read from the
2984
3014
  // map rather than restated.
2985
3015
  const attachmentIndex: SlotAttachmentIndex = { bySlot: attachmentsBySlot, searched: [...skinTables.keys()] };
2986
- 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>();
2987
3019
  const declaredDurations: Record<string, number> = {};
2988
3020
  const slotNames = new Set(slots.map((s) => s.name));
2989
- // Which slots an `rgba2` timeline may be keyed on: the ones the EMITTED file
2990
- // gives a `dark`, for the same reason `attachmentIndex` is built off the
2991
- // 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,
2992
3025
  // not on the spec that produced it. A slot whose `dark` never reached the
2993
3026
  // artifact is a slot the runtime allocates no dark colour for (issue #690).
2994
3027
  const darkSlots = new Set(slots.filter((s) => s.dark !== undefined).map((s) => s.name));
@@ -2997,13 +3030,24 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2997
3030
  checkMotionGroups(motion);
2998
3031
  for (const [animName, anim] of Object.entries(motion.animations)) {
2999
3032
  declaredDurations[animName] = anim.duration;
3000
- const slotTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
3001
- const boneTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
3002
- /** One table per constraint family, keyed the way the file is. */
3003
- const familyTimelines: Record<ConstraintTrackFamily, Record<string, Record<string, SpineTimelineKey[]>>> = {
3004
- physics: {},
3005
- path: {},
3006
- 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;
3007
3051
  };
3008
3052
  const claimed = new Set<string>();
3009
3053
  let compiledDuration = 0;
@@ -3031,7 +3075,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3031
3075
  );
3032
3076
  }
3033
3077
  if (everyGlobal) {
3034
- const physics = constraints.filter((one) => one.type === 'physics');
3078
+ const physics = constraints.filter((one) => one.kind === 'physics');
3035
3079
  const flag = `${track.property}Global`;
3036
3080
  const reached =
3037
3081
  track.property === 'reset'
@@ -3103,7 +3147,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3103
3147
  // the fault is the pair, not a key.
3104
3148
  if (family === null && !isBoneTrack) {
3105
3149
  const mine = SLOT_COLOR_CHANNELS[track.property] ?? [];
3106
- for (const other of Object.keys(slotTimelines[target] ?? {})) {
3150
+ for (const other of slotTimelines.get(target)?.keys() ?? []) {
3107
3151
  const shared = (SLOT_COLOR_CHANNELS[other] ?? []).filter((channel) => mine.includes(channel));
3108
3152
  if (shared.length === 0) continue;
3109
3153
  throw new CompileError(
@@ -3133,11 +3177,12 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3133
3177
  : isBoneTrack
3134
3178
  ? compileValueTrack(resolved, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
3135
3179
  : compileTrack(resolved, motion, animName, anim.duration, target, shift, attachmentIndex, darkSlots);
3136
- for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
3137
- // Under the empty name, which is the file's own spelling of it.
3138
- if (family !== null) (familyTimelines[family][everyGlobal ? '' : target] ??= {})[track.property] = keys;
3139
- else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
3140
- 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);
3141
3186
  });
3142
3187
  }
3143
3188
 
@@ -3148,9 +3193,9 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3148
3193
  // The target is resolved by name AND by type: `findConstraint(name,
3149
3194
  // IkConstraintData)` misses a transform constraint of the same name and the
3150
3195
  // parser throws in the consumer's process, so the mismatch is named here.
3151
- const constraintTimelines: Record<'ik' | 'transform', Record<string, SpineTimelineKey[]>> = {
3152
- ik: {},
3153
- transform: {},
3196
+ const constraintTimelines: Record<'ik' | 'transform', Map<string, ModelKey[]>> = {
3197
+ ik: new Map(),
3198
+ transform: new Map(),
3154
3199
  };
3155
3200
  for (const group of ['ik', 'transform'] as const) {
3156
3201
  // The array, the entries and their `constraint` names are shapes, so
@@ -3175,7 +3220,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3175
3220
  : `the rig declares no ${group} constraint at all`),
3176
3221
  );
3177
3222
  }
3178
- if (constraintTimelines[group][name]) {
3223
+ if (constraintTimelines[group].has(name)) {
3179
3224
  throw new CompileError(
3180
3225
  `animation "${animName}" has two ${group} timelines on constraint "${name}"; ` +
3181
3226
  'the group holds one timeline per constraint, so merge them into one',
@@ -3190,18 +3235,25 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3190
3235
  // The ik table, asked only by the ik group: `leg` may also be a
3191
3236
  // transform constraint, and a transform key set carries no flag for
3192
3237
  // the rig's booleans to be stamped onto (issue #692).
3193
- group === 'ik' ? (ikRigFlags.get(name) ?? {}) : {},
3238
+ group === 'ik' ? (ikFlagsInEffect.get(name) ?? {}) : {},
3194
3239
  );
3195
- for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
3196
- constraintTimelines[group][name] = keys;
3240
+ for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time);
3241
+ constraintTimelines[group].set(name, keys);
3197
3242
  }
3198
3243
  }
3199
3244
 
3200
3245
  // -- deform timelines: keyed on a skin/slot/attachment triple ----------
3201
- // Four deep, because the format is: skin -> slot -> attachment -> timeline
3202
- // name -> keys. `deform` is one of two timeline names an attachment can
3203
- // carry (the other is `sequence`), which is why the level exists at all.
3204
- 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
+ };
3205
3257
  const deformTracks: MotionDeformTrack[] = anim.deform ?? [];
3206
3258
  for (const track of deformTracks) {
3207
3259
  const skinName = track.skin ?? 'default';
@@ -3226,7 +3278,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3226
3278
  `(it has: ${Object.keys(perSlot).join(', ')})`,
3227
3279
  );
3228
3280
  }
3229
- if (deformTimelines[skinName]?.[track.slot]?.[track.attachment]) {
3281
+ if (deformTimelines.get(skinName)?.get(track.slot)?.has(track.attachment)) {
3230
3282
  throw new CompileError(`${at}: two deform timelines on one attachment; merge them into one`);
3231
3283
  }
3232
3284
  const keys = compileDeformTrack(
@@ -3235,13 +3287,13 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3235
3287
  animName,
3236
3288
  anim.duration,
3237
3289
  {
3238
- ...deformGeometryOf(attachment, at, bones, transforms),
3290
+ ...deformGeometryOf(attachment, at, transforms),
3239
3291
  depth: attachmentDepths.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
3240
3292
  },
3241
3293
  deformTransforms,
3242
3294
  );
3243
- for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
3244
- ((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 });
3245
3297
  }
3246
3298
 
3247
3299
  // -- sequence timelines: the other attachment timeline (issue #729) -----
@@ -3273,22 +3325,22 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3273
3325
  );
3274
3326
  }
3275
3327
  const keys = compileSequenceTrack(track, anim.duration, attachment, at);
3276
- for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
3277
- const slot = ((deformTimelines[skinName] ??= {})[track.slot] ??= {});
3278
- 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) {
3279
3331
  throw new CompileError(`${at}: two sequence timelines on one attachment; merge them into one`);
3280
3332
  }
3281
- slot[track.attachment] = { ...(slot[track.attachment] ?? {}), sequence: keys };
3333
+ slot.set(track.attachment, { ...(slot.get(track.attachment) ?? {}), sequence: keys });
3282
3334
  }
3283
3335
 
3284
- const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
3285
- 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);
3286
3338
 
3287
- 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 ?? {}) : [];
3288
3340
  // An event timeline counts towards the animation's length the same as any
3289
3341
  // other: `readAnimation` takes the duration from the longest timeline it
3290
3342
  // built, and `EventTimeline.getDuration()` is its last frame like the rest.
3291
- 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);
3292
3344
 
3293
3345
  // Rule 4: the declared duration is verified, because skeleton JSON does not
3294
3346
  // carry one — the loader takes the max key time.
@@ -3304,133 +3356,101 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3304
3356
  `animation "${animName}" declares duration ${anim.duration}s but its last key is at ${compiledDuration}s`,
3305
3357
  );
3306
3358
  }
3307
- // Group order is `readAnimation`'s own reading order, so an emitted file
3308
- // diffs cleanly against an editor export. Each line is conditional, which
3309
- // is what keeps a spec that uses none of the new groups byte-identical.
3310
- animations[animName] = {};
3311
- if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
3312
- if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
3313
- if (Object.keys(constraintTimelines.ik).length) animations[animName].ik = constraintTimelines.ik;
3314
- if (Object.keys(constraintTimelines.transform).length) {
3315
- animations[animName].transform = constraintTimelines.transform;
3316
- }
3317
- if (Object.keys(familyTimelines.path).length) animations[animName].path = familyTimelines.path;
3318
- if (Object.keys(familyTimelines.physics).length) animations[animName].physics = familyTimelines.physics;
3319
- if (Object.keys(familyTimelines.slider).length) animations[animName].slider = familyTimelines.slider;
3320
- if (Object.keys(deformTimelines).length) animations[animName].attachments = deformTimelines;
3321
- if (drawOrder) animations[animName].drawOrder = drawOrder;
3322
- 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
+ });
3323
3378
  }
3324
3379
  });
3325
3380
 
3326
3381
  // -- 6. assemble -----------------------------------------------------------
3327
3382
  //
3328
- // 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
3329
3385
  // `width`/`height` give an extent to, so a header carrying an origin for a box
3330
- // it does not declare would be a shape no export has — and the key ORDER here
3331
- // is the editor's own, which is what keeps a staged build byte-identical to
3332
- // what it emitted before the stage could be declared absent (issue #578).
3333
- const header: SpineSkeletonJson['skeleton'] = { spine: SPINE_VERSION };
3334
- if (stageWidth !== undefined && stageHeight !== undefined) {
3335
- header.x = rig.skeleton?.x ?? 0;
3336
- header.y = rig.skeleton?.y ?? 0;
3337
- header.width = stageWidth;
3338
- header.height = stageHeight;
3339
- }
3340
- if (rig.skeleton?.fps !== undefined) header.fps = rig.skeleton.fps;
3341
- if (rig.skeleton?.referenceScale !== undefined) header.referenceScale = rig.skeleton.referenceScale;
3342
- const imagesPath = skeletonImagesPath(rig.skeleton?.images, opts, outDir, partDirs);
3343
- if (imagesPath !== undefined) header.images = imagesPath;
3344
- if (rig.skeleton?.audio !== undefined) header.audio = rig.skeleton.audio;
3345
-
3346
- // Event definitions. Emitted in the order the rig spec declares them — object
3347
- // key order is the spec's, not a set's, so A18 stays a contract.
3348
- 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>();
3349
3401
  for (const [name, def] of Object.entries(rig.events ?? {})) {
3350
- const entry: SpineEvent = {};
3402
+ const entry: ModelEvent = {};
3351
3403
  if (def.int !== undefined) entry.int = def.int;
3352
3404
  if (def.float !== undefined) entry.float = f32(def.float);
3353
3405
  if (def.string !== undefined) entry.string = def.string;
3354
3406
  if (def.audio !== undefined) entry.audio = def.audio;
3355
3407
  if (def.volume !== undefined) entry.volume = f32(def.volume);
3356
3408
  if (def.balance !== undefined) entry.balance = f32(def.balance);
3357
- events[name] = entry;
3409
+ events.set(name, entry);
3358
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
+ });
3359
3423
 
3360
- const skeleton: SpineSkeletonJson = {
3361
- skeleton: header,
3362
- bones,
3363
- slots,
3364
- // A skin entry is `name`, then whatever it activates, then `attachments` —
3365
- // `readSkeletonData`'s own order (`:372-443`). Every member list is a
3366
- // conditional spread, so a rig that declares none emits the two-key entry it
3367
- // always did, byte for byte.
3368
- //
3369
- // Ordered the way `animations` is and for the same reason — the editor
3370
- // rewrites this array and the binary half addresses it by ordinal — with the
3371
- // sort applied HERE rather than to `skinTables`, so everything upstream (the
3372
- // path-slot table, every refusal that lists skins) still reads the order the
3373
- // rig spec declared. See `editorSkinOrder`.
3374
- skins: editorSkinOrder(
3375
- [...skinTables.entries()].map(([name, attachments]) => {
3376
- const parts = skinParts.get(name);
3377
- return {
3378
- name,
3379
- ...(parts?.bones.length ? { bones: parts.bones } : {}),
3380
- ...Object.fromEntries(
3381
- RIG_SKIN_CONSTRAINT_KEYS.filter((key) => parts?.constraints[key].length).map((key) => [
3382
- key,
3383
- parts!.constraints[key],
3384
- ]),
3385
- ),
3386
- attachments: editorSlotKeyOrder(attachments),
3387
- };
3388
- }),
3389
- ),
3390
- // Between `skins` and `animations`, which is where the editor writes it. A
3391
- // conditional spread rather than an assignment after the literal, so the key
3392
- // lands in that position instead of at the end.
3393
- ...(Object.keys(events).length ? { events } : {}),
3394
- // Keyed in the editor's own order rather than the motion spec's, because a
3395
- // slider's reference to an animation is an ordinal in the format and the
3396
- // editor re-sorts this object — see `editorAnimationOrder`. The sort is
3397
- // applied HERE and not to the loop above, so what the compiler reads, the
3398
- // order it reports durations in, and which animation a CompileError names
3399
- // first are all still the spec's own; only the emitted key order moves.
3400
- animations: editorAnimationOrder(animations),
3401
- };
3402
- 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
+ );
3403
3442
 
3404
3443
  for (const slot of slots) {
3405
3444
  if (!boneNames.has(slot.bone)) throw new CompileError(`slot "${slot.name}" has no bone`);
3406
3445
  }
3407
3446
 
3408
- // Every object's keys in the order the editor writes them, per kind, from the
3409
- // one table that says so (`src/keyorder.ts`, issue #716) — applied here, once,
3410
- // to the finished object and before the text exists, so the constructors above
3411
- // are free to build in whatever order reads best and none of them states the
3412
- // order a second time. It moves positions and nothing else: no key is added,
3413
- // dropped or re-valued, and what the gate and `A18` read is this object's text.
3414
- //
3415
- // Before it, and on the same finished object: every key whose value is the
3416
- // one the 4.3 parser reads in its absence is left out, the way the editor
3417
- // leaves it out (issue #716 tranche 3). One pass beside the other rather than
3418
- // folded into it, because each has its own table and the selftest plants a
3419
- // wrong row into each on its own; both walk the object through
3420
- // `forEachKindedObject`, so what a kind is stays said in one place. The spec
3421
- // is untouched — an author may still write `x: 0`, and it is still the record
3422
- // of what was written — and what the parser loads does not move (`S103`).
3423
- inEditorKeyOrder(withoutParserDefaults(skeleton));
3424
-
3425
- return {
3426
- skeleton,
3427
- skeletonText: `${JSON.stringify(skeleton, null, 2)}\n`,
3428
- 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 = {
3429
3450
  images,
3430
3451
  pageGrids: atlasIn === null ? [] : [...atlasIn.grids].map(([page, grid]) => ({ page, said: grid.said })),
3431
3452
  droppedStates,
3432
3453
  absentParts,
3433
- declaredDurations,
3434
3454
  meshBones: [...meshBones],
3435
3455
  meshes,
3436
3456
  physics: physicsReport,
@@ -3438,6 +3458,14 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3438
3458
  trackDerivations,
3439
3459
  rig: buildRigInfo(rig, bones, meshes, manifest, images),
3440
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
+ };
3441
3469
  }
3442
3470
 
3443
3471
  // ---------------------------------------------------------------------------
@@ -3453,7 +3481,7 @@ interface BoneContext {
3453
3481
  }
3454
3482
 
3455
3483
  /**
3456
- * One rig bone -> one emitted bone.
3484
+ * One rig bone -> one model bone (`ModelBone`); `emitBones` writes it as Spine's.
3457
3485
  *
3458
3486
  * 🔑 A field is emitted exactly when the spec declared it. That is not Spine's
3459
3487
  * own exporter convention (it omits anything equal to the default) and the
@@ -3461,8 +3489,8 @@ interface BoneContext {
3461
3489
  * deciding emission from the arithmetic rather than from the author's text makes
3462
3490
  * the file depend on a rounding.
3463
3491
  */
3464
- function buildBone(spec: RigBone, soFar: SpineBone[], ctx: BoneContext): SpineBone {
3465
- const bone: SpineBone = { name: spec.name };
3492
+ function buildBone(spec: RigBone, soFar: ModelBone[], ctx: BoneContext): ModelBone {
3493
+ const bone: ModelBone = { name: spec.name };
3466
3494
  if (spec.parent !== undefined) bone.parent = spec.parent;
3467
3495
  if (spec.length !== undefined) bone.length = f32(spec.length);
3468
3496
 
@@ -3493,10 +3521,13 @@ function buildBone(spec: RigBone, soFar: SpineBone[], ctx: BoneContext): SpineBo
3493
3521
  if (spec.scaleY !== undefined) bone.scaleY = f32(spec.scaleY);
3494
3522
  if (spec.shearX !== undefined) bone.shearX = f32(spec.shearX);
3495
3523
  if (spec.shearY !== undefined) bone.shearY = f32(spec.shearY);
3496
- if (spec.inherit !== undefined) bone.inherit = spec.inherit;
3497
- if (spec.skin !== undefined) bone.skin = spec.skin;
3498
- if (spec.color !== undefined) bone.color = spec.color;
3499
- 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
+ }
3500
3531
  return bone;
3501
3532
  }
3502
3533
 
@@ -3621,9 +3652,9 @@ function rotationOf(spec: RigBone, ctx: BoneContext): number | null {
3621
3652
  * with a name was in that set, so for those types the position is the same
3622
3653
  * choice rather than a second measurement.
3623
3654
  */
3624
- function withStatedName(att: SpineAttachment, stated: RigAttachment): SpineAttachment {
3655
+ function withStatedName(att: SkinTableEntry, stated: RigAttachment): SkinTableEntry {
3625
3656
  const name = (stated as { name?: string }).name;
3626
- return name === undefined ? att : ({ name, ...att } as SpineAttachment);
3657
+ return name === undefined ? att : { name, ...att };
3627
3658
  }
3628
3659
 
3629
3660
  /**
@@ -3734,7 +3765,7 @@ function refuseDefaultSkinContests(skinNames: readonly string[], skinParts: Map<
3734
3765
 
3735
3766
  interface AttachmentContext {
3736
3767
  images: CompiledImage[];
3737
- bones: SpineBone[];
3768
+ bones: ModelBone[];
3738
3769
  transforms: Map<string, BoneTransform>;
3739
3770
  meshBones: Set<string>;
3740
3771
  meshes: CompileResult['meshes'];
@@ -3761,6 +3792,12 @@ interface AttachmentContext {
3761
3792
  depths: Map<string, number[]>;
3762
3793
  /** Every slot the rig declares — a clipping attachment's `end` resolves here. */
3763
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;
3764
3801
  /**
3765
3802
  * Linked meshes whose `source` is still unresolved — `resolveLinkedMeshes`
3766
3803
  * empties it once every skin has been built.
@@ -3850,7 +3887,7 @@ function buildRigAttachment(
3850
3887
  placeholder: string,
3851
3888
  where: string,
3852
3889
  ctx: AttachmentContext,
3853
- ): SpineAttachment {
3890
+ ): SkinTableEntry {
3854
3891
  const stated = (att as { type?: unknown }).type;
3855
3892
  if (stated !== undefined && typeof stated !== 'string') {
3856
3893
  throw new CompileError(
@@ -3919,7 +3956,8 @@ function deferredAttachmentRefusal(type: string, where: string, how: string): st
3919
3956
  }
3920
3957
 
3921
3958
  /**
3922
- * 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`.
3923
3961
  *
3924
3962
  * 🚨 `vertexCount` is required, and everything else here is a cross-check of it.
3925
3963
  * The parser reads `map.vertexCount << 1` and hands that to `readVertices` as the
@@ -3929,12 +3967,12 @@ function deferredAttachmentRefusal(type: string, where: string, how: string): st
3929
3967
  * whatever that garbage produced. It loads. It draws nothing (a bounding box
3930
3968
  * never did) and clips nothing, or clips the wrong shape, in complete silence.
3931
3969
  *
3932
- * 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 —
3933
3971
  * because they are the same field with the same trap: `readVertices` decides
3934
3972
  * weighted vs unweighted by a length comparison alone, and a coincidental match
3935
- * reads weight data as coordinates.
3973
+ * reads weight data as coordinates. The model says which one it is outright.
3936
3974
  */
3937
- function buildVertexGeometry(att: RigVertexGeometry, where: string, ctx: AttachmentContext): number[] {
3975
+ function buildVertexGeometry(att: RigVertexGeometry, where: string, ctx: AttachmentContext): ModelVertices {
3938
3976
  const count = att.vertexCount;
3939
3977
  if (typeof count !== 'number' || !Number.isInteger(count) || count < 3) {
3940
3978
  throw new CompileError(
@@ -3954,7 +3992,7 @@ function buildVertexGeometry(att: RigVertexGeometry, where: string, ctx: Attachm
3954
3992
  if (att.weights.length !== count) {
3955
3993
  throw new CompileError(`${where}: weights cover ${att.weights.length} vertices but vertexCount is ${count}`);
3956
3994
  }
3957
- return encodeNamedWeights(att.weights, where, ctx);
3995
+ return { weighted: true, bindings: bindNamedWeights(att.weights, where, ctx) };
3958
3996
  }
3959
3997
  const raw = att.vertices;
3960
3998
  if (!raw || raw.length === 0) {
@@ -3973,48 +4011,89 @@ function buildVertexGeometry(att: RigVertexGeometry, where: string, ctx: Attachm
3973
4011
  }
3974
4012
  // A raw run still has to decode to exactly `vertexCount` vertices, or the
3975
4013
  // count and the polygon disagree and the parser believes the count.
3976
- let decoded = 0;
3977
- for (let i = 0; i < raw.length; decoded++) {
3978
- const bones = raw[i++];
3979
- if (!Number.isInteger(bones) || bones < 1) {
3980
- throw new CompileError(`${where}: the raw weighted run has a bone count of ${String(bones)} at index ${i - 1}`);
3981
- }
3982
- i += bones * 4;
3983
- if (i > raw.length) {
3984
- throw new CompileError(
3985
- `${where}: the raw weighted run is truncated — vertex ${decoded} claims ${bones} bone(s) and the array ends first`,
3986
- );
3987
- }
3988
- }
3989
- if (decoded !== count) {
3990
- throw new CompileError(`${where}: the raw weighted run decodes to ${decoded} vertices but vertexCount is ${count}`);
3991
- }
3992
- // Register the bones it binds so the mesh-bone reports stay complete.
3993
- for (let i = 0; i < raw.length; ) {
3994
- const bones = raw[i++];
3995
- for (let k = 0; k < bones; k++, i += 4) {
3996
- const bone = ctx.bones[raw[i]];
3997
- if (bone) ctx.meshBones.add(bone.name);
3998
- }
3999
- }
4014
+ checkRawRunShape(raw, count, `vertexCount is ${count}`, where);
4000
4015
  }
4001
4016
  for (const n of raw) {
4002
4017
  if (!Number.isFinite(n)) throw new CompileError(`${where}: the vertex array holds a non-finite value ${String(n)}`);
4003
4018
  }
4004
- 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
+ };
4005
4084
  }
4006
4085
 
4007
4086
  function buildRigBoundingBox(
4008
4087
  att: RigBoundingBoxAttachment,
4009
4088
  where: string,
4010
4089
  ctx: AttachmentContext,
4011
- ): SpineBoundingBoxAttachment {
4012
- const out: SpineBoundingBoxAttachment = {
4013
- type: 'boundingbox',
4090
+ ): ModelBoundingBoxAttachment {
4091
+ const out: ModelBoundingBoxAttachment = {
4092
+ kind: 'boundingbox',
4014
4093
  vertexCount: att.vertexCount,
4015
4094
  vertices: buildVertexGeometry(att, where, ctx),
4016
4095
  };
4017
- if (att.color !== undefined) out.color = att.color;
4096
+ if (att.color !== undefined) out.editorColor = att.color;
4018
4097
  return out;
4019
4098
  }
4020
4099
 
@@ -4022,7 +4101,7 @@ function buildRigClipping(
4022
4101
  att: RigClippingAttachment,
4023
4102
  where: string,
4024
4103
  ctx: AttachmentContext,
4025
- ): SpineClippingAttachment {
4104
+ ): ModelClippingAttachment {
4026
4105
  if (att.end !== undefined) {
4027
4106
  // `skeletonData.findSlot` returns null on a miss and the parser assigns that
4028
4107
  // null (`:626-627`), so a typo does not fail — the clip simply never ends and
@@ -4034,57 +4113,50 @@ function buildRigClipping(
4034
4113
  );
4035
4114
  }
4036
4115
  }
4037
- // Field order is the editor's here — `end`, `convex`, `inverse` before the
4038
- // geometry — via conditional spreads, because a key assigned after the literal
4039
- // lands at the end instead. Order carries no meaning in JSON; it is read by
4040
- // people, and this file's diff against a reference is read a lot.
4041
- const out: SpineClippingAttachment = {
4042
- 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',
4043
4120
  ...(att.end !== undefined ? { end: att.end } : {}),
4044
4121
  ...(att.convex !== undefined ? { convex: att.convex } : {}),
4045
4122
  ...(att.inverse !== undefined ? { inverse: att.inverse } : {}),
4046
4123
  vertexCount: att.vertexCount,
4047
4124
  vertices: buildVertexGeometry(att, where, ctx),
4048
4125
  };
4049
- if (att.color !== undefined) out.color = att.color;
4126
+ if (att.color !== undefined) out.editorColor = att.color;
4050
4127
  return out;
4051
4128
  }
4052
4129
 
4053
4130
  /**
4054
- * 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.
4055
4132
  *
4056
4133
  * The two encodings again, and the same split `readVertices` makes: an unweighted
4057
- * run is one `x, y` in the SLOT BONE's space; a weighted one is
4058
- * `boneCount, (boneIndex, bindX, bindY, weight) × n` per vertex, and the vertex
4059
- * is the weighted sum of each influence's bind point taken to world through its
4060
- * own bone. This is `VertexAttachment.computeWorldVertices` at setup, restated in
4061
- * 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.
4062
4139
  */
4063
4140
  function setupWorldVertices(
4064
- vertices: number[],
4065
- vertexCount: number,
4141
+ vertices: ModelVertices,
4066
4142
  anchor: BoneTransform,
4067
- bones: SpineBone[],
4068
4143
  transforms: Map<string, BoneTransform>,
4069
4144
  where: string,
4070
4145
  ): Array<[number, number]> {
4071
4146
  const out: Array<[number, number]> = [];
4072
- if (vertices.length === vertexCount * 2) {
4073
- 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]));
4074
4149
  return out;
4075
4150
  }
4076
- for (let i = 0; i < vertices.length; ) {
4077
- const count = vertices[i++];
4151
+ for (const vertex of vertices.bindings) {
4078
4152
  let x = 0;
4079
4153
  let y = 0;
4080
- for (let n = 0; n < count; n++, i += 4) {
4081
- const bone = bones[vertices[i]];
4082
- const m = bone === undefined ? undefined : transforms.get(bone.name);
4083
- if (!m) throw new CompileError(`${where}: vertex ${out.length} binds bone index ${vertices[i]}, which is not in the bone list`);
4084
- const [wx, wy] = toWorld(m, vertices[i + 1], vertices[i + 2]);
4085
- const weight = vertices[i + 3];
4086
- x += wx * weight;
4087
- 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;
4088
4160
  }
4089
4161
  out.push([x, y]);
4090
4162
  }
@@ -4113,8 +4185,11 @@ function setupWorldVertices(
4113
4185
  * prefix, so the entries the runtime reads are the same numbers either way; the
4114
4186
  * trailing one is read by nothing, and is written so a rebuild is the file the
4115
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.
4116
4191
  */
4117
- function pathChain(points: Array<[number, number]>, closed: boolean): Array<[number, number]> {
4192
+ export function pathChain(points: Array<[number, number]>, closed: boolean): Array<[number, number]> {
4118
4193
  if (!closed) return points.slice(1, points.length - 1);
4119
4194
  return [...points.slice(1), points[0], points[1]];
4120
4195
  }
@@ -4181,7 +4256,7 @@ function pathChain(points: Array<[number, number]>, closed: boolean): Array<[num
4181
4256
  * running total carried across curves rather than restarted. Each of those is a
4182
4257
  * place where a more accurate line would emit a different file.
4183
4258
  */
4184
- function pathCurveLengths(chain: Array<[number, number]>): number[] {
4259
+ export function pathCurveLengths(chain: Array<[number, number]>): number[] {
4185
4260
  const out: number[] = [];
4186
4261
  let total = 0;
4187
4262
  for (let c = 0; c + 3 < chain.length; c += 3) {
@@ -4235,7 +4310,7 @@ function pathCurveLengths(chain: Array<[number, number]>): number[] {
4235
4310
  * 3. **An omitted one is measured**, `vertexCount / 3` entries over the closed
4236
4311
  * chain (`pathChain`), on the unconstrained setup pose.
4237
4312
  */
4238
- function buildRigPath(att: RigPathAttachment, where: string, ctx: AttachmentContext): SpinePathAttachment {
4313
+ function buildRigPath(att: RigPathAttachment, where: string, ctx: AttachmentContext): ModelPathAttachment {
4239
4314
  const vertices = buildVertexGeometry(att, where, ctx);
4240
4315
  const count = att.vertexCount;
4241
4316
  const closed = att.closed === true;
@@ -4260,7 +4335,7 @@ function buildRigPath(att: RigPathAttachment, where: string, ctx: AttachmentCont
4260
4335
  } else {
4261
4336
  const anchor = ctx.transforms.get(ctx.anchorBone);
4262
4337
  if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
4263
- const points = setupWorldVertices(vertices, count, anchor, ctx.bones, ctx.transforms, where);
4338
+ const points = setupWorldVertices(vertices, anchor, ctx.transforms, where);
4264
4339
  lengths = pathCurveLengths(pathChain(points, true));
4265
4340
  // The total the runtime reads is the last CURVE's entry, `lengths[vertexCount
4266
4341
  // / 3 - (closed ? 1 : 2)]` (`PathConstraint.js:204-206`) — on an open path
@@ -4273,17 +4348,17 @@ function buildRigPath(att: RigPathAttachment, where: string, ctx: AttachmentCont
4273
4348
  );
4274
4349
  }
4275
4350
  }
4276
- // Field order is the parser's reading order (`:606-623`), and each optional key
4277
- // is present exactly when the spec declared it — the rule the whole rig spec
4278
- // 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.
4279
4354
  return {
4280
- type: 'path',
4355
+ kind: 'path',
4281
4356
  ...(att.closed !== undefined ? { closed: att.closed } : {}),
4282
4357
  ...(att.constantSpeed !== undefined ? { constantSpeed: att.constantSpeed } : {}),
4283
4358
  vertexCount: count,
4284
4359
  vertices,
4285
4360
  lengths: lengths.map(f32),
4286
- ...(att.color !== undefined ? { color: att.color } : {}),
4361
+ ...(att.color !== undefined ? { editorColor: att.color } : {}),
4287
4362
  };
4288
4363
  }
4289
4364
 
@@ -4412,8 +4487,9 @@ function attachmentPath(att: { name?: string; path?: string; image?: string }, p
4412
4487
  }
4413
4488
 
4414
4489
  /**
4415
- * A mesh attachment's `path` and `color`, in that order — the two keys every
4416
- * 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).
4417
4493
  *
4418
4494
  * 🔬 The editor writes a mesh `type, path, color, uvs, …`: right after `type`,
4419
4495
  * `path` before `color` — measured on a production set (#791), not on
@@ -4422,16 +4498,17 @@ function attachmentPath(att: { name?: string; path?: string; image?: string }, p
4422
4498
  * `path` before `color` is the order the two measured positions leave, not a
4423
4499
  * third measurement. The twelve exports carry neither key on a mesh, which is
4424
4500
  * why `EDITOR_KEY_ORDER`'s `mesh attachment` row does not list them: an unlisted
4425
- * key keeps its constructor's index, so the position is stated HERE, and the
4426
- * 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.
4427
4504
  *
4428
4505
  * ⚠️ Not a linked mesh's, and not a region's. Neither was in the measured set —
4429
4506
  * a region's `path` stays where `buildRigRegion` puts it, a linked mesh's
4430
4507
  * keys where `buildRigLinkedMesh` does — because the analogy to a mesh is
4431
4508
  * obvious and it is not a measurement.
4432
4509
  */
4433
- function meshTextureKeys(att: { name?: string; path?: string; image?: string; color?: string }, placeholder: string): Pick<SpineMeshAttachment, 'path' | 'color'> {
4434
- 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'> = {};
4435
4512
  const path = attachmentPath(att, placeholder);
4436
4513
  if (path !== undefined) out.path = path;
4437
4514
  if (att.color !== undefined) out.color = att.color;
@@ -4455,9 +4532,9 @@ function sequenceFrameRegion(stem: string, seq: RigSequence, i: number): string
4455
4532
  return `${stem}${'0'.repeat(Math.max(0, (seq.digits ?? 0) - frame.length))}${frame}`;
4456
4533
  }
4457
4534
 
4458
- /** The emitted `sequence` block: the four fields exactly as the spec stated them. */
4459
- function emitSequence(seq: RigSequence): SpineSequence {
4460
- 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 };
4461
4538
  if (seq.start !== undefined) out.start = seq.start;
4462
4539
  if (seq.digits !== undefined) out.digits = seq.digits;
4463
4540
  if (seq.setup !== undefined) out.setup = seq.setup;
@@ -4545,10 +4622,11 @@ function buildRigRegion(
4545
4622
  placeholder: string,
4546
4623
  where: string,
4547
4624
  ctx: AttachmentContext,
4548
- ): SpineRegionAttachment {
4625
+ ): ModelRegionAttachment {
4549
4626
  if (att.sequence !== undefined) {
4550
- const size = sequenceFrameSize(sequenceFrames(att, placeholder, where, ctx), att, where);
4551
- 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!) };
4552
4630
  if (att.path !== undefined) out.path = att.path;
4553
4631
  if (att.x !== undefined) out.x = f32(att.x);
4554
4632
  if (att.y !== undefined) out.y = f32(att.y);
@@ -4556,7 +4634,9 @@ function buildRigRegion(
4556
4634
  if (att.scaleX !== undefined) out.scaleX = f32(att.scaleX);
4557
4635
  if (att.scaleY !== undefined) out.scaleY = f32(att.scaleY);
4558
4636
  if (att.color !== undefined) out.color = att.color;
4559
- 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;
4560
4640
  return out;
4561
4641
  }
4562
4642
  const img = att.image === undefined ? null : atlasedImage(att.image, where, ctx);
@@ -4598,7 +4678,7 @@ function buildRigRegion(
4598
4678
  `${where}: a region needs width and height — give them, or give an "image" and rigc will measure the PNG`,
4599
4679
  );
4600
4680
  }
4601
- const out: SpineRegionAttachment = { width: f32(width), height: f32(height) };
4681
+ const out: ModelRegionAttachment = { kind: 'region', width: f32(width), height: f32(height) };
4602
4682
  const path = attachmentPath(att, placeholder);
4603
4683
  if (path !== undefined) out.path = path;
4604
4684
  if (att.x !== undefined) out.x = f32(att.x);
@@ -4607,41 +4687,89 @@ function buildRigRegion(
4607
4687
  if (att.scaleX !== undefined) out.scaleX = f32(att.scaleX);
4608
4688
  if (att.scaleY !== undefined) out.scaleY = f32(att.scaleY);
4609
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);
4610
4693
  return out;
4611
4694
  }
4612
4695
 
4613
4696
  /**
4614
- * 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).
4615
4726
  *
4616
- * 🚨 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
4617
4748
  * `boneCount, (boneIndex, bindX, bindY, weight) x n` per vertex and those
4618
4749
  * indices are positions in the emitted bone array — a thing the rig spec never
4619
- * 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
4620
4752
  * renumbering rather than a rebinding: the names still point at the same bones,
4621
4753
  * so the emitted indices move and the mesh does not.
4622
4754
  *
4623
4755
  * An unknown name is a `CompileError`, the same as a bone's `parent`, a slot's
4624
4756
  * `bone` or a constraint's `target`. The alternative — the raw form — cannot
4625
- * refuse anything, because an index has no name to be wrong.
4757
+ * refuse a rebind, because an index has no name to be wrong.
4626
4758
  */
4627
- function encodeNamedWeights(weights: RigMeshBinding[][], where: string, ctx: AttachmentContext): number[] {
4628
- const out: number[] = [];
4629
- weights.forEach((vertex, i) => {
4759
+ function bindNamedWeights(weights: RigMeshBinding[][], where: string, ctx: AttachmentContext): ModelBinding[][] {
4760
+ return weights.map((vertex, i) => {
4630
4761
  if (!Array.isArray(vertex) || vertex.length === 0) {
4631
4762
  throw new CompileError(`${where}: vertex ${i} has no bone bindings; a weighted vertex names at least one bone`);
4632
4763
  }
4633
- out.push(vertex.length);
4634
- for (const binding of vertex) {
4635
- const index = ctx.bones.findIndex((b) => b.name === binding.bone);
4636
- if (index < 0) {
4764
+ return vertex.map((binding) => {
4765
+ if (!ctx.bones.some((b) => b.name === binding.bone)) {
4637
4766
  throw new CompileError(
4638
4767
  `${where}: vertex ${i} binds bone ${JSON.stringify(binding.bone)}, which the rig does not declare as a bone`,
4639
4768
  );
4640
4769
  }
4641
- out.push(index, f32(binding.x), f32(binding.y), f32(binding.weight));
4642
- }
4770
+ return { bone: binding.bone, x: f32(binding.x), y: f32(binding.y), weight: f32(binding.weight) };
4771
+ });
4643
4772
  });
4644
- return out;
4645
4773
  }
4646
4774
 
4647
4775
  /**
@@ -4786,7 +4914,7 @@ function buildRigMesh(
4786
4914
  placeholder: string,
4787
4915
  where: string,
4788
4916
  ctx: AttachmentContext,
4789
- ): SpineMeshAttachment {
4917
+ ): ModelMeshAttachment {
4790
4918
  const authored =
4791
4919
  att.uvs !== undefined || att.triangles !== undefined || att.vertices !== undefined || att.weights !== undefined;
4792
4920
  if (authored && att.generator) {
@@ -4802,7 +4930,7 @@ function buildRigMesh(
4802
4930
  throw new CompileError(`${where}: an authored mesh needs uvs, triangles and vertices or weights (or a "generator")`);
4803
4931
  }
4804
4932
  const uvCount = att.uvs.length;
4805
- let vertices: number[];
4933
+ let vertices: ModelVertices;
4806
4934
  let boundBones: string[] = [];
4807
4935
  if (att.weights) {
4808
4936
  if (att.boneIndexing === 'raw') {
@@ -4813,7 +4941,7 @@ function buildRigMesh(
4813
4941
  `${where}: weights cover ${att.weights.length} vertices but there are ${uvCount / 2} uv pairs`,
4814
4942
  );
4815
4943
  }
4816
- vertices = encodeNamedWeights(att.weights, where, ctx);
4944
+ vertices = { weighted: true, bindings: bindNamedWeights(att.weights, where, ctx) };
4817
4945
  boundBones = [...new Set(att.weights.flat().map((b) => b.bone))];
4818
4946
  } else {
4819
4947
  const raw = att.vertices!;
@@ -4828,17 +4956,17 @@ function buildRigMesh(
4828
4956
  'or say "boneIndexing": "raw" on this attachment to keep the index form deliberately.',
4829
4957
  );
4830
4958
  }
4831
- vertices = raw.map(f32);
4832
4959
  if (weighted) {
4833
- const names = new Set<string>();
4834
- for (let i = 0; i < raw.length; ) {
4835
- const n = raw[i++];
4836
- for (let k = 0; k < n; k++, i += 4) {
4837
- const bone = ctx.bones[raw[i]];
4838
- if (bone) names.add(bone.name);
4839
- }
4840
- }
4841
- 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) };
4842
4970
  }
4843
4971
  }
4844
4972
  const { hull, edges } = authoredHullAndEdges(att, uvCount / 2, att.triangles, where);
@@ -4858,8 +4986,8 @@ function buildRigMesh(
4858
4986
  `${where}: a mesh needs width and height — give them, or give an "image" and rigc will measure the PNG`,
4859
4987
  );
4860
4988
  }
4861
- const out: SpineMeshAttachment = {
4862
- type: 'mesh',
4989
+ const out: ModelMeshAttachment = {
4990
+ kind: 'mesh',
4863
4991
  ...meshTextureKeys(att, placeholder),
4864
4992
  uvs: att.uvs.map(f32),
4865
4993
  triangles: att.triangles,
@@ -4869,7 +4997,7 @@ function buildRigMesh(
4869
4997
  width: f32(width),
4870
4998
  height: f32(height),
4871
4999
  };
4872
- if (att.sequence !== undefined) out.sequence = emitSequence(att.sequence);
5000
+ if (att.sequence !== undefined) out.sequence = buildSequence(att.sequence);
4873
5001
  // Register it as `authored`: geometry rigc did not build and whose topology it
4874
5002
  // therefore gets to assume nothing about. The generator-topology assertions
4875
5003
  // read this and skip rather than measuring a ring that was never a ring.
@@ -4922,7 +5050,7 @@ function buildRigLinkedMesh(
4922
5050
  placeholder: string,
4923
5051
  where: string,
4924
5052
  ctx: AttachmentContext,
4925
- ): SpineLinkedMeshAttachment {
5053
+ ): ModelLinkedMeshAttachment {
4926
5054
  // The mesh keys the parser does not reach on this branch. Named one by one,
4927
5055
  // because "remove what does not belong" is not an instruction an author can
4928
5056
  // act on and the remedy for each of these is the same single sentence.
@@ -4974,17 +5102,23 @@ function buildRigLinkedMesh(
4974
5102
  `${where}: a linked mesh needs width and height — give them, or give an "image" and rigc will measure the PNG`,
4975
5103
  );
4976
5104
  }
4977
- 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
+ };
4978
5118
  const path = attachmentPath(att, placeholder);
4979
5119
  if (path !== undefined) out.path = path;
4980
- // Only where they differ from the parser's own defaults. Writing `timelines:
4981
- // true`, or a `skin` of "default", would be a byte the editor's own export
4982
- // does not carry.
4983
- if (att.slot !== undefined && att.slot !== ctx.slotName) out.slot = att.slot;
4984
- if (att.skin !== undefined && att.skin !== 'default') out.skin = att.skin;
4985
- if (att.timelines === false) out.timelines = false;
4986
5120
  if (att.color !== undefined) out.color = att.color;
4987
- if (att.sequence !== undefined) out.sequence = emitSequence(att.sequence);
5121
+ if (att.sequence !== undefined) out.sequence = buildSequence(att.sequence);
4988
5122
  // 🚫 NOT registered in `ctx.meshes`, and that is a decision rather than an
4989
5123
  // omission. `meshKinds` is keyed by SLOT and the commonest linked mesh shares
4990
5124
  // its source's slot from another skin, so an entry here would overwrite the
@@ -5011,7 +5145,7 @@ function buildRigLinkedMesh(
5011
5145
  */
5012
5146
  function resolveLinkedMeshes(
5013
5147
  links: readonly PendingLink[],
5014
- tables: Map<string, Record<string, Record<string, SpineAttachment>>>,
5148
+ tables: ReadonlyMap<string, SkinTable>,
5015
5149
  ): void {
5016
5150
  const skinNames = [...tables.keys()];
5017
5151
  for (const link of links) {
@@ -5052,7 +5186,7 @@ function resolveLinkedMeshes(
5052
5186
  "Left to the round trip this is the runtime's `Source mesh not found`.",
5053
5187
  );
5054
5188
  }
5055
- const type = (found as { type?: string }).type ?? 'region';
5189
+ const type = found.kind;
5056
5190
  if (type === 'linkedmesh') {
5057
5191
  throw new CompileError(
5058
5192
  `${link.where}: "source" is ${JSON.stringify(link.source)}, which is itself a linked mesh, and a chain of ` +
@@ -5087,25 +5221,24 @@ function buildGeneratedMesh(
5087
5221
  placeholder: string,
5088
5222
  where: string,
5089
5223
  ctx: AttachmentContext,
5090
- ): SpineMeshAttachment {
5224
+ ): ModelMeshAttachment {
5091
5225
  if (generator.kind === 'contour') return buildContourAttachment(att, generator, placeholder, where, ctx);
5092
5226
  if (generator.kind === 'grid') return buildGridAttachment(att, generator, placeholder, where, ctx);
5093
5227
  if (generator.kind === 'segments') return buildSegmentsAttachment(att, generator, placeholder, where, ctx);
5094
5228
  const controls = generator.kind === 'ring' ? generator.controls : generator.chain;
5095
- // Resolving the bone list and the setup transform is one step with two named
5096
- // refusals, because the ring's control angles need the transform and the encode
5097
- // 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
5098
5232
  // words whichever of them asks for it first.
5099
- const resolve = (name: string): { index: number; transform: BoneTransform } => {
5100
- const index = ctx.bones.findIndex((b) => b.name === name);
5101
- 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`);
5102
5235
  const transform = ctx.transforms.get(name);
5103
5236
  if (!transform) throw new CompileError(`${where}: no setup transform for mesh bone "${name}"`);
5104
- return { index, transform };
5237
+ return transform;
5105
5238
  };
5106
5239
  const refFor = (name: string): MeshBoneRef => {
5107
- const { index, transform } = resolve(name);
5108
- return { index, toBind: (wx, wy) => toBoneLocal(transform, wx, wy) };
5240
+ const transform = resolve(name);
5241
+ return { name, toBind: (wx, wy) => toBoneLocal(transform, wx, wy) };
5109
5242
  };
5110
5243
  // The generator works in part-local pixels, y down. Without a manifest there is
5111
5244
  // no crop to flip against, so the part window is centred on its own slot bone.
@@ -5129,7 +5262,7 @@ function buildGeneratedMesh(
5129
5262
  size: generator.size,
5130
5263
  bias: generator.bias,
5131
5264
  controlAngles: ringControlAngles(controls, generator.center, (name) => {
5132
- const { transform } = resolve(name);
5265
+ const transform = resolve(name);
5133
5266
  return toPartLocal(transform.worldX, transform.worldY);
5134
5267
  }),
5135
5268
  });
@@ -5137,7 +5270,7 @@ function buildGeneratedMesh(
5137
5270
  if (err instanceof MeshError) throw new CompileError(`${where}: ${err.message}`);
5138
5271
  throw err;
5139
5272
  }
5140
- const vertices = encodeWeightedVertices(
5273
+ const vertices = bindWeightedVertices(
5141
5274
  geometry,
5142
5275
  // The world point is an INTERMEDIATE — `toBind` makes it local and the
5143
5276
  // result is what is emitted, through `f32` below — so it carries the double.
@@ -5156,12 +5289,12 @@ function buildGeneratedMesh(
5156
5289
  triangles: geometry.triangles.length / 3,
5157
5290
  bones: [ctx.anchorBone, ...controls],
5158
5291
  });
5159
- const out: SpineMeshAttachment = {
5160
- type: 'mesh',
5292
+ const out: ModelMeshAttachment = {
5293
+ kind: 'mesh',
5161
5294
  ...meshTextureKeys(att, placeholder),
5162
5295
  uvs: geometry.uvs.map(f32),
5163
5296
  triangles: geometry.triangles,
5164
- vertices: vertices.map(f32),
5297
+ vertices: bindingsOnF32(vertices),
5165
5298
  ...generatedHullAndEdges(geometry, where),
5166
5299
  width: f32(w),
5167
5300
  height: f32(h),
@@ -5237,7 +5370,7 @@ function sampleMeshDepth(
5237
5370
  triangles: ReadonlyArray<number>,
5238
5371
  /**
5239
5372
  * A part-local pixel to the BIND space this mesh's vertices are emitted in —
5240
- * the same composition `encodeWeightedVertices` applies, handed in rather than
5373
+ * the same composition `bindWeightedVertices` applies, handed in rather than
5241
5374
  * rebuilt so the two cannot diverge.
5242
5375
  *
5243
5376
  * 🚨 The ceiling has to be taken in bind space and in no other. A deform
@@ -5397,13 +5530,12 @@ function sampleMeshDepth(
5397
5530
  };
5398
5531
  }
5399
5532
 
5400
- /** 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. */
5401
5534
  function meshBoneRef(name: string, where: string, ctx: AttachmentContext): MeshBoneRef {
5402
5535
  const transform = ctx.transforms.get(name);
5403
5536
  if (!transform) throw new CompileError(`${where}: bone "${name}" has no setup transform`);
5404
- const index = ctx.bones.findIndex((b) => b.name === name);
5405
- if (index < 0) throw new CompileError(`${where}: bone "${name}" is not in the rig's bone list`);
5406
- 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) };
5407
5539
  }
5408
5540
 
5409
5541
  /**
@@ -5608,7 +5740,7 @@ function buildGridAttachment(
5608
5740
  placeholder: string,
5609
5741
  where: string,
5610
5742
  ctx: AttachmentContext,
5611
- ): SpineMeshAttachment {
5743
+ ): ModelMeshAttachment {
5612
5744
  if (att.image === undefined) {
5613
5745
  throw new CompileError(
5614
5746
  `${where}: a "grid" generator lays its lattice over the part's own window, so the attachment needs an ` +
@@ -5669,8 +5801,9 @@ function buildGridAttachment(
5669
5801
  }
5670
5802
  const anchor = ctx.transforms.get(ctx.anchorBone);
5671
5803
  if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
5672
- const index = ctx.bones.findIndex((b) => b.name === ctx.anchorBone);
5673
- 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
+ }
5674
5807
  // Sampled BEFORE the encode, because `bind` rewrites the weights the encode
5675
5808
  // then writes out.
5676
5809
  const depth =
@@ -5697,11 +5830,11 @@ function buildGridAttachment(
5697
5830
  ? undefined
5698
5831
  : softRegionWeights(generator.soft, geometry.points, sheetGridOf(img, plate), where, ctx);
5699
5832
  if (bound) geometry = { ...geometry, weights: bound.weights };
5700
- const vertices = encodeWeightedVertices(
5833
+ const vertices = bindWeightedVertices(
5701
5834
  geometry,
5702
5835
  (px, py) => [anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt],
5703
5836
  {
5704
- anchor: { index, toBind: (wx, wy) => toBoneLocal(anchor, wx, wy) },
5837
+ anchor: { name: ctx.anchorBone, toBind: (wx, wy) => toBoneLocal(anchor, wx, wy) },
5705
5838
  controls: bound ? [meshBoneRef(bound.bone, where, ctx)] : [],
5706
5839
  },
5707
5840
  );
@@ -5721,12 +5854,12 @@ function buildGridAttachment(
5721
5854
  ? undefined
5722
5855
  : { mask: bound.mask, digest: bound.digest, bone: bound.bone, carried: bound.carried, ramped: bound.ramped },
5723
5856
  });
5724
- const out: SpineMeshAttachment = {
5725
- type: 'mesh',
5857
+ const out: ModelMeshAttachment = {
5858
+ kind: 'mesh',
5726
5859
  ...meshTextureKeys(att, placeholder),
5727
5860
  uvs: geometry.uvs.map(f32),
5728
5861
  triangles: geometry.triangles,
5729
- vertices: vertices.map(f32),
5862
+ vertices: bindingsOnF32(vertices),
5730
5863
  ...generatedHullAndEdges(geometry, where),
5731
5864
  width: f32(w),
5732
5865
  height: f32(h),
@@ -5778,7 +5911,7 @@ function buildSegmentsAttachment(
5778
5911
  placeholder: string,
5779
5912
  where: string,
5780
5913
  ctx: AttachmentContext,
5781
- ): SpineMeshAttachment {
5914
+ ): ModelMeshAttachment {
5782
5915
  if (att.image === undefined) {
5783
5916
  throw new CompileError(
5784
5917
  `${where}: a "segments" generator lays its lattice over the part's own alpha, so the attachment needs an ` +
@@ -6005,7 +6138,7 @@ function buildSegmentsAttachment(
6005
6138
  weights,
6006
6139
  hullVertices: lattice.hullVertices,
6007
6140
  };
6008
- const vertices = encodeWeightedVertices(geometry, place, {
6141
+ const vertices = bindWeightedVertices(geometry, place, {
6009
6142
  anchor: meshBoneRef(ctx.anchorBone, where, ctx),
6010
6143
  controls: refs,
6011
6144
  });
@@ -6052,11 +6185,11 @@ function buildSegmentsAttachment(
6052
6185
  },
6053
6186
  });
6054
6187
  return {
6055
- type: 'mesh',
6188
+ kind: 'mesh',
6056
6189
  ...meshTextureKeys(att, placeholder),
6057
6190
  uvs: geometry.uvs.map(f32),
6058
6191
  triangles: geometry.triangles,
6059
- vertices: vertices.map(f32),
6192
+ vertices: bindingsOnF32(vertices),
6060
6193
  ...generatedHullAndEdges(geometry, where),
6061
6194
  width: f32(w),
6062
6195
  height: f32(h),
@@ -6094,7 +6227,7 @@ function buildContourAttachment(
6094
6227
  placeholder: string,
6095
6228
  where: string,
6096
6229
  ctx: AttachmentContext,
6097
- ): SpineMeshAttachment {
6230
+ ): ModelMeshAttachment {
6098
6231
  if (att.image === undefined) {
6099
6232
  throw new CompileError(
6100
6233
  `${where}: a "contour" generator traces the part's own alpha, so the attachment needs an "image" — ` +
@@ -6167,12 +6300,13 @@ function buildContourAttachment(
6167
6300
  }
6168
6301
  const anchor = ctx.transforms.get(ctx.anchorBone);
6169
6302
  if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
6170
- const index = ctx.bones.findIndex((b) => b.name === ctx.anchorBone);
6171
- if (index < 0) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" is not in the rig's bone list`);
6172
- 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(
6173
6307
  geometry,
6174
6308
  (px, py) => [anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt],
6175
- { anchor: { index, toBind: (wx, wy) => toBoneLocal(anchor, wx, wy) }, controls: [] },
6309
+ { anchor: { name: ctx.anchorBone, toBind: (wx, wy) => toBoneLocal(anchor, wx, wy) }, controls: [] },
6176
6310
  );
6177
6311
  ctx.meshBones.add(ctx.anchorBone);
6178
6312
  // The vertices are on the TRACED grid — the plate's — and the depth map is
@@ -6217,12 +6351,12 @@ function buildContourAttachment(
6217
6351
  // this comment used to be the rule's only statement, beside four emit sites
6218
6352
  // that disagreed with it — and spread right after `type` by
6219
6353
  // `meshTextureKeys` since #791.
6220
- const out: SpineMeshAttachment = {
6221
- type: 'mesh',
6354
+ const out: ModelMeshAttachment = {
6355
+ kind: 'mesh',
6222
6356
  ...meshTextureKeys(att, placeholder),
6223
6357
  uvs: geometry.uvs.map(f32),
6224
6358
  triangles: geometry.triangles,
6225
- vertices: vertices.map(f32),
6359
+ vertices: bindingsOnF32(vertices),
6226
6360
  ...generatedHullAndEdges(geometry, where),
6227
6361
  width: f32(w),
6228
6362
  height: f32(h),
@@ -6276,7 +6410,7 @@ interface ConstraintContext {
6276
6410
  * type matches no case is dropped with no error and no `default:` branch, so an
6277
6411
  * unimplemented type is refused here by name rather than emitted and lost.
6278
6412
  */
6279
- function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): SpineConstraint {
6413
+ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): ModelConstraint {
6280
6414
  const where = `rig constraint "${spec.name}"`;
6281
6415
  const boneNames = ctx.boneNames;
6282
6416
  const needBone = (name: unknown, field: string): string => {
@@ -6309,7 +6443,11 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6309
6443
  }
6310
6444
  return value;
6311
6445
  };
6312
- 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 });
6313
6451
  const copy = (fields: readonly string[]) => {
6314
6452
  for (const field of fields) {
6315
6453
  const v = spec[field];
@@ -6333,7 +6471,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6333
6471
  // see `RIG_SCALE_Y_MODES`.
6334
6472
  if (spec.scaleY !== undefined) out.scaleY = needEnum(spec.scaleY, 'scaleY', RIG_SCALE_Y_MODES);
6335
6473
  copy(['mix', 'softness', 'bendPositive', 'compress', 'stretch', 'skin']);
6336
- return out;
6474
+ return made('ik');
6337
6475
  }
6338
6476
  if (spec.type === 'transform') {
6339
6477
  out.bones = boneList();
@@ -6372,7 +6510,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6372
6510
  'mixShearY',
6373
6511
  'skin',
6374
6512
  ]);
6375
- return out;
6513
+ return made('transform');
6376
6514
  }
6377
6515
  if (spec.type === 'path') {
6378
6516
  out.bones = boneList();
@@ -6406,7 +6544,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6406
6544
  if (spec[field] !== undefined) out[field] = f32(needNumber(spec[field], field));
6407
6545
  }
6408
6546
  copy(['skin']);
6409
- return out;
6547
+ return made('path');
6410
6548
  }
6411
6549
  if (spec.type === 'slider') {
6412
6550
  // ⭐ The one field in a rig spec that points at the MOTION spec. It is
@@ -6798,7 +6936,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6798
6936
  }
6799
6937
  }
6800
6938
  copy(['skin']);
6801
- return out;
6939
+ return made('slider');
6802
6940
  }
6803
6941
  if (spec.type === 'physics') {
6804
6942
  out.bone = needBone(spec.bone, 'bone');
@@ -6829,7 +6967,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6829
6967
  'mixGlobal',
6830
6968
  'skin',
6831
6969
  ]);
6832
- return out;
6970
+ return made('physics');
6833
6971
  }
6834
6972
  // Every type 4.3 has is implemented, so this is now only reachable from a typo
6835
6973
  // — and a typo is exactly what the parser drops in silence (no `default:`
@@ -6852,7 +6990,7 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
6852
6990
  */
6853
6991
  function buildRigInfo(
6854
6992
  rig: RigSpec,
6855
- bones: SpineBone[],
6993
+ bones: ModelBone[],
6856
6994
  meshes: CompileResult['meshes'],
6857
6995
  manifest: FaceManifest | null,
6858
6996
  images: CompiledImage[],
@@ -6965,26 +7103,23 @@ function checkAxisSelfConsistency(manifest: FaceManifest): void {
6965
7103
  * without it every slot hanging off a rotated axis bone would render tilted by
6966
7104
  * the cut's axis angle.
6967
7105
  *
6968
- * On an unrotated bone sitting at its window centre both terms are zero and the
6969
- * fields are omitted, which is why a formation with no axis emits the same
6970
- * 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.
6971
7109
  */
6972
7110
  function placeRegion(
6973
7111
  part: FaceManifestPart,
6974
7112
  manifest: FaceManifest,
6975
7113
  bone: BoneTransform,
6976
7114
  img: CompiledImage,
6977
- ): SpineRegionAttachment {
7115
+ ): ModelRegionAttachment {
6978
7116
  const win = partWindow(part, manifest);
6979
7117
  // width/height are NOT optional: omitting them loads as NaN with no error.
6980
7118
  // The compiler fills them from the PNG.
6981
- const att: SpineRegionAttachment = { width: img.width, height: img.height };
6982
7119
  const [ax, ay] = toBoneLocal(bone, win.x + win.w / 2, cropToSpineY(win.y + win.h / 2, manifest.crop.h)).map(f32);
6983
- if (ax !== 0) att.x = ax;
6984
- if (ay !== 0) att.y = ay;
6985
7120
  const rotation = f32(normaliseDegrees(-bone.worldRotation));
6986
- if (rotation !== 0) att.rotation = rotation;
6987
- 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) };
6988
7123
  }
6989
7124
 
6990
7125
  /**
@@ -6998,10 +7133,10 @@ function placeRegion(
6998
7133
  function buildMesh(
6999
7134
  part: FaceManifestPart,
7000
7135
  manifest: FaceManifest,
7001
- bones: SpineBone[],
7136
+ bones: ModelBone[],
7002
7137
  transforms: Map<string, BoneTransform>,
7003
7138
  anchorName: string,
7004
- ): { attachment: SpineMeshAttachment; kind: 'ring' | 'ribbon' } {
7139
+ ): { attachment: ModelMeshAttachment; kind: 'ring' | 'ribbon' } {
7005
7140
  const spec = part.mesh!;
7006
7141
  const kind = spec.kind ?? 'ring';
7007
7142
  const win = partWindow(part, manifest);
@@ -7009,11 +7144,10 @@ function buildMesh(
7009
7144
  const controls = meshControlBones(part);
7010
7145
 
7011
7146
  const refFor = (name: string): MeshBoneRef => {
7012
- const index = bones.findIndex((b) => b.name === name);
7013
- 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`);
7014
7148
  const m = transforms.get(name);
7015
7149
  if (!m) throw new CompileError(`internal: no setup transform for mesh bone "${name}"`);
7016
- return { index, toBind: (wx, wy) => toBoneLocal(m, wx, wy) };
7150
+ return { name, toBind: (wx, wy) => toBoneLocal(m, wx, wy) };
7017
7151
  };
7018
7152
 
7019
7153
  let geometry;
@@ -7053,7 +7187,7 @@ function buildMesh(
7053
7187
  throw err;
7054
7188
  }
7055
7189
 
7056
- const vertices = encodeWeightedVertices(
7190
+ const vertices = bindWeightedVertices(
7057
7191
  geometry,
7058
7192
  (px, py) => [win.x + px, cropToSpineY(win.y + py, cropH)],
7059
7193
  { anchor: refFor(anchorName), controls: controls.map(refFor) },
@@ -7062,10 +7196,10 @@ function buildMesh(
7062
7196
  return {
7063
7197
  kind: geometry.kind,
7064
7198
  attachment: {
7065
- type: 'mesh',
7199
+ kind: 'mesh',
7066
7200
  uvs: geometry.uvs.map(f32),
7067
7201
  triangles: geometry.triangles,
7068
- vertices: vertices.map(f32),
7202
+ vertices: bindingsOnF32(vertices),
7069
7203
  ...generatedHullAndEdges(geometry, `slot "${part.slot}" mesh`),
7070
7204
  width: win.w,
7071
7205
  height: win.h,
@@ -7129,13 +7263,13 @@ function compileValueTrack(
7129
7263
  shift: number,
7130
7264
  shapes: Record<string, ValueTrackShape>,
7131
7265
  kind: string,
7132
- ): SpineTimelineKey[] {
7266
+ ): ModelKey[] {
7133
7267
  const shape = shapes[track.property];
7134
7268
  if (!shape) throw new CompileError(`animation "${animName}": ${kind} "${target}" has no property "${track.property}"`);
7135
7269
  const where = `animation "${animName}" ${kind} "${target}" ${track.property}`;
7136
7270
  if (!track.keys.length) throw new CompileError(`${where}: no keys`);
7137
7271
 
7138
- const out: SpineTimelineKey[] = [];
7272
+ const out: ModelKey[] = [];
7139
7273
  for (let i = 0; i < track.keys.length; i++) {
7140
7274
  const key = track.keys[i];
7141
7275
  const next = track.keys[i + 1];
@@ -7175,7 +7309,7 @@ function compileValueTrack(
7175
7309
  if (!Array.isArray(key.v) || key.v.length !== shape.fields.length) {
7176
7310
  throw new CompileError(`${where}: key value must be an array of ${shape.fields.length} number(s)`);
7177
7311
  }
7178
- const entry: SpineTimelineKey = { time };
7312
+ const entry: ModelKey = { time };
7179
7313
  shape.fields.forEach((field, c) => {
7180
7314
  const v = key.v as number[];
7181
7315
  if (!Number.isFinite(v[c])) throw new CompileError(`${where}: non-finite value ${String(v[c])}`);
@@ -7240,7 +7374,8 @@ function compileValueTrack(
7240
7374
  * than loading wrong. The author states a set of moves; the array order in
7241
7375
  * the file is the parser's requirement and not a decision, so rigc sorts
7242
7376
  * rather than making every caller remember. Deterministic: the key is the
7243
- * emitted slot index.
7377
+ * emitted slot index. The model holds the moves as stated and the Spine
7378
+ * emitter sorts them (`emitDrawOrderKey`, issue #921).
7244
7379
  * 2. **One slot per key.** Two entries for the same slot means two writes at
7245
7380
  * one cursor position; the second silently wins and the first slot's place
7246
7381
  * is left to the unchanged-fill.
@@ -7260,13 +7395,13 @@ function compileDrawOrder(
7260
7395
  keys: MotionDrawOrderKey[],
7261
7396
  animName: string,
7262
7397
  duration: number,
7263
- slots: SpineSlot[],
7264
- ): SpineTimelineKey[] {
7398
+ slots: readonly ModelSlot[],
7399
+ ): ModelKey[] {
7265
7400
  const where = `animation "${animName}" drawOrder`;
7266
7401
  if (!keys.length) throw new CompileError(`${where}: no keys`);
7267
7402
  const indexOf = new Map(slots.map((s, i) => [s.name, i]));
7268
7403
 
7269
- const out: SpineTimelineKey[] = [];
7404
+ const out: ModelKey[] = [];
7270
7405
  for (let i = 0; i < keys.length; i++) {
7271
7406
  const key = keys[i];
7272
7407
  const time = keyTime(key.t);
@@ -7282,7 +7417,7 @@ function compileDrawOrder(
7282
7417
  continue;
7283
7418
  }
7284
7419
  const seen = new Set<string>();
7285
- const entries: Array<{ slot: string; offset: number; index: number }> = [];
7420
+ const entries: Array<{ slot: string; offset: number }> = [];
7286
7421
  for (const off of key.offsets) {
7287
7422
  const index = indexOf.get(off.slot);
7288
7423
  if (index === undefined) {
@@ -7302,10 +7437,11 @@ function compileDrawOrder(
7302
7437
  );
7303
7438
  }
7304
7439
  seen.add(off.slot);
7305
- entries.push({ slot: off.slot, offset: off.offset, index });
7440
+ entries.push({ slot: off.slot, offset: off.offset });
7306
7441
  }
7307
- entries.sort((a, b) => a.index - b.index);
7308
- 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 });
7309
7445
  }
7310
7446
  return out;
7311
7447
  }
@@ -7340,11 +7476,11 @@ function compileEvents(
7340
7476
  animName: string,
7341
7477
  duration: number,
7342
7478
  events: Record<string, RigEvent>,
7343
- ): SpineTimelineKey[] {
7479
+ ): ModelKey[] {
7344
7480
  const where = `animation "${animName}" events`;
7345
7481
  if (!keys.length) throw new CompileError(`${where}: no keys`);
7346
7482
 
7347
- const out: SpineTimelineKey[] = [];
7483
+ const out: ModelKey[] = [];
7348
7484
  for (let i = 0; i < keys.length; i++) {
7349
7485
  const key = keys[i];
7350
7486
  if (typeof key.name !== 'string' || key.name.length === 0) {
@@ -7367,7 +7503,7 @@ function compileEvents(
7367
7503
  }
7368
7504
  checkKeyTime(where, time, key.t, duration);
7369
7505
 
7370
- const entry: SpineTimelineKey = { time, name: key.name };
7506
+ const entry: ModelKey = { time, name: key.name };
7371
7507
  if (key.int !== undefined) {
7372
7508
  if (!Number.isInteger(key.int)) {
7373
7509
  throw new CompileError(`${where} at t=${key.t}: int ${String(key.int)} is not an integer`);
@@ -7454,9 +7590,9 @@ function compileConstraintTrack(
7454
7590
  motion: MotionSpec,
7455
7591
  animName: string,
7456
7592
  duration: number,
7457
- /** 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`. */
7458
7594
  rigFlags: Record<string, boolean>,
7459
- ): SpineTimelineKey[] {
7595
+ ): ModelKey[] {
7460
7596
  const shape = CONSTRAINT_TIMELINES[group];
7461
7597
  const article = group === 'ik' ? 'an' : 'a';
7462
7598
  const where = `animation "${animName}" ${group} constraint "${track.constraint}"`;
@@ -7505,7 +7641,7 @@ function compileConstraintTrack(
7505
7641
  return inherited === undefined ? channel.dflt : (inherited as number);
7506
7642
  };
7507
7643
 
7508
- const out: SpineTimelineKey[] = [];
7644
+ const out: ModelKey[] = [];
7509
7645
  for (let i = 0; i < keys.length; i++) {
7510
7646
  const key = keys[i];
7511
7647
  const next = keys[i + 1];
@@ -7515,7 +7651,7 @@ function compileConstraintTrack(
7515
7651
  }
7516
7652
  checkKeyTime(where, time, key.t, duration);
7517
7653
 
7518
- const entry: SpineTimelineKey = { time };
7654
+ const entry: ModelKey = { time };
7519
7655
  for (const channel of shape.channels) {
7520
7656
  const v = read(key, channel.field);
7521
7657
  if (v === undefined) continue;
@@ -7535,10 +7671,10 @@ function compileConstraintTrack(
7535
7671
  for (const flag of shape.flags) {
7536
7672
  const v = read(key, flag.field);
7537
7673
  if (v === undefined) {
7538
- // The rig's value, stamped on this key because nothing else will carry
7539
- // it there. Absent from `rigFlags` means the rig's value IS the per-key
7540
- // default, so omitting the field says the same thing and the emitted
7541
- // 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).
7542
7678
  const carried = rigFlags[flag.field];
7543
7679
  if (carried !== undefined) entry[flag.field] = carried;
7544
7680
  continue;
@@ -7600,7 +7736,7 @@ function defaultOf(shape: ConstraintTimelineShape, field: string): number | bool
7600
7736
  * Same shape, two meanings, and picking the wrong one writes a run that silently
7601
7737
  * lands on the wrong vertices.
7602
7738
  */
7603
- interface DeformGeometry {
7739
+ export interface DeformGeometry {
7604
7740
  weighted: boolean;
7605
7741
  /** How long the array the key edits is. */
7606
7742
  deformLength: number;
@@ -7647,7 +7783,7 @@ interface DeformGeometry {
7647
7783
  /**
7648
7784
  * Per-vertex `z`, when this attachment's generator named a depth map.
7649
7785
  *
7650
- * Not measured from the emitted attachment like everything else here — there
7786
+ * Not measured from the attachment like everything else here — there
7651
7787
  * is nowhere in the format to measure it FROM, which is the whole reason the
7652
7788
  * sampler keeps it beside the skeleton instead of in it. Filled in by the
7653
7789
  * caller, which is where the skin, slot and attachment names live.
@@ -7656,25 +7792,35 @@ interface DeformGeometry {
7656
7792
  }
7657
7793
 
7658
7794
  /**
7659
- * 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.
7660
7798
  *
7661
7799
  * A region attachment is refused rather than measured: it has no `vertices` at
7662
7800
  * all, so `attachment.vertices.length` throws inside the parser — one of the very
7663
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.
7664
7806
  */
7665
- function deformGeometryOf(
7666
- att: SpineAttachment,
7807
+ export function deformGeometryOf(
7808
+ att: SkinTableEntry,
7667
7809
  where: string,
7668
- /** The emitted bone array, which a weight run's `boneIndex` indexes into. */
7669
- rigBones: SpineBone[],
7670
- /** Their setup world transforms, by name. */
7810
+ /** Every bone's setup world transform, by name. */
7671
7811
  transforms: Map<string, BoneTransform>,
7672
7812
  ): DeformGeometry {
7673
- const type = (att as { type?: string }).type ?? 'region';
7813
+ const type = att.kind;
7674
7814
  let worldVerticesLength: number;
7675
- if (type === 'mesh') {
7676
- worldVerticesLength = (att as SpineMeshAttachment).uvs.length;
7677
- } 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 {
7678
7824
  // ⭐ A `path` reaches this line as of issue #696, and it is the SAME line the
7679
7825
  // other two vertex-and-no-triangles types take: the array the parser sizes
7680
7826
  // is `vertexCount * 2`, the two encodings below are the mesh's own, and a
@@ -7703,16 +7849,11 @@ function deformGeometryOf(
7703
7849
  // file twice. `docs/AUTHORING.md` §4.11 states the `constantSpeed: false`
7704
7850
  // reading instead, which is the honest home for it: a fact about the format
7705
7851
  // the author is choosing, not a fault rigc can measure.
7706
- worldVerticesLength = (att as SpineBoundingBoxAttachment | SpinePathAttachment).vertexCount * 2;
7707
- } else {
7708
- throw new CompileError(
7709
- `${where}: a deform timeline keys the vertices of an attachment, and this one is a "${type}" — ` +
7710
- 'it has no vertex array to deform. Deformable types: mesh, boundingbox, clipping, path.',
7711
- );
7852
+ worldVerticesLength = att.vertexCount * 2;
7712
7853
  }
7713
- const vertices = (att as SpineMeshAttachment).vertices ?? [];
7714
- const weighted = vertices.length !== worldVerticesLength;
7715
- if (!weighted) {
7854
+ const vertices = att.vertices;
7855
+ const weighted = vertices.weighted;
7856
+ if (!vertices.weighted) {
7716
7857
  return {
7717
7858
  weighted,
7718
7859
  deformLength: worldVerticesLength,
@@ -7720,41 +7861,37 @@ function deformGeometryOf(
7720
7861
  boneCounts: null,
7721
7862
  // An unweighted attachment IS its own space: the array is one `x, y` per
7722
7863
  // vertex in the slot bone's space, which is the space the offsets are in.
7723
- setup: vertices.slice(),
7864
+ setup: vertices.xy.slice(),
7724
7865
  influenceBones: null,
7725
7866
  setupWhy: null,
7726
- // Filled in by the caller; `deformGeometryOf` reads the emitted
7727
- // 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.
7728
7869
  depth: null,
7729
7870
  };
7730
7871
  }
7731
- // Walk the weight run for the per-vertex influence counts. The run's own shape
7732
- // is already assured by the attachment builders and by A33/A04; this only
7733
- // 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.
7734
7877
  const boneCounts: number[] = [];
7735
7878
  const bindSpace: number[] = [];
7736
- const bones = new Set<number>();
7737
- /** `boneIndex, bindX, bindY, weight` per influence, in deform-array order. */
7738
- const influenceRun: number[] = [];
7739
- for (let i = 0; i < vertices.length; ) {
7740
- const n = vertices[i++];
7741
- if (!Number.isInteger(n) || n < 1) {
7742
- 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}`);
7743
7885
  }
7744
7886
  if (n === 1) {
7745
- bones.add(vertices[i]);
7746
- bindSpace.push(vertices[i + 1], vertices[i + 2]);
7747
- }
7748
- const from = i;
7749
- i += n * 4;
7750
- if (i > vertices.length) {
7751
- 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);
7752
7889
  }
7753
- for (let k = from; k < i; k++) influenceRun.push(vertices[k]);
7754
7890
  boneCounts.push(n);
7891
+ at += 1 + n * 4;
7755
7892
  }
7756
7893
  // ⚠️ The influence count is the SUM of the per-vertex counts, not a division
7757
- // 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
7758
7895
  // `boneIndex, x, y, weight` per influence — five numbers for a single-bone
7759
7896
  // vertex, not three — so `vertices.length / 3` overstated the deform array by
7760
7897
  // two thirds on a one-bone-per-vertex mesh (`gallery/flex`'s 77-vertex leaf
@@ -7792,17 +7929,15 @@ function deformGeometryOf(
7792
7929
  const setupWorld: number[] = [];
7793
7930
  const influenceBones: BoneTransform[] = [];
7794
7931
  let openWeights: string | null = null;
7795
- for (let v = 0, k = 0; v < boneCounts.length; v++) {
7932
+ for (let v = 0; v < boneCounts.length; v++) {
7796
7933
  let wx = 0;
7797
7934
  let wy = 0;
7798
7935
  let sum = 0;
7799
- for (let n = 0; n < boneCounts[v]; n++, k++) {
7800
- const index = influenceRun[4 * k];
7801
- const bone = rigBones[index];
7802
- const m = bone === undefined ? undefined : transforms.get(bone.name);
7803
- if (!m) throw new CompileError(`${where}: vertex ${v} binds bone index ${index}, which is not in the bone list`);
7804
- const weight = influenceRun[4 * k + 3];
7805
- 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);
7806
7941
  wx += x * weight;
7807
7942
  wy += y * weight;
7808
7943
  sum += weight;
@@ -7897,11 +8032,11 @@ function deformGeometryOf(
7897
8032
  function compileSequenceTrack(
7898
8033
  track: MotionSequenceTrack,
7899
8034
  duration: number,
7900
- attachment: SpineAttachment,
8035
+ attachment: SkinTableEntry,
7901
8036
  where: string,
7902
- ): SpineTimelineKey[] {
7903
- const type = (attachment as { type?: string }).type ?? 'region';
7904
- 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;
7905
8040
  if (series === undefined) {
7906
8041
  throw new CompileError(
7907
8042
  `${where}: attachment "${track.attachment}" carries no "sequence" block, so there is no series to step. The ` +
@@ -7910,8 +8045,8 @@ function compileSequenceTrack(
7910
8045
  'attachment a "sequence" in the rig spec, or remove the track.',
7911
8046
  );
7912
8047
  }
7913
- if (type === 'linkedmesh' && (attachment as SpineLinkedMeshAttachment).timelines !== false) {
7914
- const source = (attachment as SpineLinkedMeshAttachment).source;
8048
+ if (attachment.kind === 'linkedmesh' && attachment.timelines) {
8049
+ const source = attachment.source;
7915
8050
  throw new CompileError(
7916
8051
  `${where}: attachment "${track.attachment}" is a linked mesh that plays its source's timelines ("timelines" ` +
7917
8052
  `is not false), so its \`timelineAttachment\` is "${source}" (\`SkeletonJson.js:437-448\`) and ` +
@@ -7921,7 +8056,7 @@ function compileSequenceTrack(
7921
8056
  );
7922
8057
  }
7923
8058
  if (track.keys.length === 0) throw new CompileError(`${where}: no keys`);
7924
- const out: SpineTimelineKey[] = [];
8059
+ const out: ModelKey[] = [];
7925
8060
  for (let i = 0; i < track.keys.length; i++) {
7926
8061
  const key = track.keys[i];
7927
8062
  const time = keyTime(key.t);
@@ -7936,7 +8071,7 @@ function compileSequenceTrack(
7936
8071
  'frame it does not name. `index` is 0-based.',
7937
8072
  );
7938
8073
  }
7939
- const entry: SpineTimelineKey = { time };
8074
+ const entry: ModelKey = { time };
7940
8075
  if (key.mode !== undefined) entry.mode = key.mode;
7941
8076
  if (key.index !== undefined) entry.index = key.index;
7942
8077
  if (key.delay !== undefined) entry.delay = f32(key.delay);
@@ -7952,7 +8087,7 @@ function compileDeformTrack(
7952
8087
  duration: number,
7953
8088
  geometry: DeformGeometry,
7954
8089
  generated: CompileResult['deformTransforms'],
7955
- ): SpineTimelineKey[] {
8090
+ ): ModelKey[] {
7956
8091
  const skin = track.skin ?? 'default';
7957
8092
  const where = `animation "${animName}" deform ${skin}/${track.slot}/${track.attachment}`;
7958
8093
  const keys = track.keys;
@@ -7961,7 +8096,7 @@ function compileDeformTrack(
7961
8096
  // skips the timeline, which is assertion A34's silent case.
7962
8097
  if (keys.length === 0) throw new CompileError(`${where}: no keys`);
7963
8098
 
7964
- const out: SpineTimelineKey[] = [];
8099
+ const out: ModelKey[] = [];
7965
8100
  for (let i = 0; i < keys.length; i++) {
7966
8101
  const key = keys[i];
7967
8102
  const time = keyTime(key.t);
@@ -8120,7 +8255,7 @@ function compileDeformTrack(
8120
8255
  'The parser copies into a Float32Array, so everything past the end is dropped without a word.',
8121
8256
  );
8122
8257
  }
8123
- const entry: SpineTimelineKey = { time };
8258
+ const entry: ModelKey = { time };
8124
8259
  // `offset` defaults to 0 in the parser and the editor omits it there, so an
8125
8260
  // authored 0, an authored `fromVertex: 0` and an absent start all emit the
8126
8261
  // same bytes — one meaning, one file.
@@ -8248,15 +8383,15 @@ function deformStart(
8248
8383
  * than to a coordinate, and a raw curve is four numbers whose value axis is 0..1.
8249
8384
  */
8250
8385
  function deformKeyCurve(
8251
- entry: SpineTimelineKey,
8386
+ entry: ModelKey,
8252
8387
  key: MotionDeformTrack['keys'][number],
8253
8388
  keys: MotionDeformTrack['keys'],
8254
8389
  index: number,
8255
8390
  motion: MotionSpec,
8256
8391
  where: string,
8257
8392
  /** The next key AS EMITTED, or undefined on the last key. */
8258
- next: SpineTimelineKey | undefined,
8259
- ): SpineTimelineKey {
8393
+ next: ModelKey | undefined,
8394
+ ): ModelKey {
8260
8395
  if (key.ease !== undefined && key.curve !== undefined) {
8261
8396
  throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
8262
8397
  }
@@ -8288,8 +8423,8 @@ function deformKeyCurve(
8288
8423
  * are three spellings of one geometry, and the comparison expands both keys onto
8289
8424
  * the same index range before reading them. The runs are already `f32`'d.
8290
8425
  */
8291
- function sameDeform(a: SpineTimelineKey, b: SpineTimelineKey): boolean {
8292
- 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[] } => ({
8293
8428
  start: typeof k.offset === 'number' ? k.offset : 0,
8294
8429
  run: Array.isArray(k.vertices) ? (k.vertices as number[]) : [],
8295
8430
  });
@@ -8468,7 +8603,7 @@ function resolveMemberTrack(
8468
8603
  track: MotionTrack,
8469
8604
  animName: string,
8470
8605
  targets: readonly string[],
8471
- bones: readonly SpineBone[],
8606
+ bones: readonly ModelBone[],
8472
8607
  derivations: CompileResult['trackDerivations'],
8473
8608
  ): Map<string, MotionValueTrack> {
8474
8609
  const carriesMap = track.keys.some((key) => isMemberValues(key.v));
@@ -8628,7 +8763,7 @@ function compileTrack(
8628
8763
  shift: number,
8629
8764
  attachments: SlotAttachmentIndex,
8630
8765
  darkSlots: ReadonlySet<string>,
8631
- ): SpineTimelineKey[] {
8766
+ ): ModelKey[] {
8632
8767
  const where = `animation "${animName}" slot "${target}" ${track.property}`;
8633
8768
  // Before any key is shaped, and before the empty-track refusal: a property
8634
8769
  // the emitter has no branch for is the fault, and a track that names one has
@@ -8667,7 +8802,7 @@ function compileTrack(
8667
8802
  }
8668
8803
  if (!track.keys.length) throw new CompileError(`${where}: no keys`);
8669
8804
 
8670
- const out: SpineTimelineKey[] = [];
8805
+ const out: ModelKey[] = [];
8671
8806
  for (let i = 0; i < track.keys.length; i++) {
8672
8807
  const key = track.keys[i];
8673
8808
  const next = track.keys[i + 1];
@@ -8730,7 +8865,7 @@ function compileTrack(
8730
8865
  'the posed alpha to that range after interpolating, so a value outside it is one no pose ever holds',
8731
8866
  );
8732
8867
  }
8733
- const entry: SpineTimelineKey = { time, ...colourOf(key.v) };
8868
+ const entry: ModelKey = { time, ...colourOf(key.v) };
8734
8869
  if (key.ease !== undefined && key.curve !== undefined) {
8735
8870
  throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
8736
8871
  }