spine-rigc 0.25.6 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compile.ts CHANGED
@@ -41,6 +41,7 @@ import { parseJsonWithPosition } from './json-position.ts';
41
41
  import { nearMisses } from './keys.ts';
42
42
  import { parseMotionSpec } from './motion.ts';
43
43
  import {
44
+ constraintAt,
44
45
  declaresNoStage,
45
46
  parseRigSpec,
46
47
  RIG_FROM_PROPERTIES,
@@ -55,6 +56,7 @@ import {
55
56
  type RigBoundingBoxAttachment,
56
57
  type RigClippingAttachment,
57
58
  type RigEvent,
59
+ type RigLinkedMeshAttachment,
58
60
  type RigMeshAttachment,
59
61
  type RigMeshBinding,
60
62
  type RigPathAttachment,
@@ -131,6 +133,7 @@ import type {
131
133
  SpineClippingAttachment,
132
134
  SpineConstraint,
133
135
  SpineEvent,
136
+ SpineLinkedMeshAttachment,
134
137
  SpineMeshAttachment,
135
138
  SpinePathAttachment,
136
139
  SpineRegionAttachment,
@@ -735,6 +738,26 @@ function rgbaHex(v: number[]): string {
735
738
  return v.map(channelHex).join('');
736
739
  }
737
740
 
741
+ /**
742
+ * A two-colour key's seven channels, as the pair of hex strings the format
743
+ * carries: `light` is `rrggbbaa`, `dark` is `rrggbb`.
744
+ *
745
+ * ⚠️ **Seven and not eight**, and the asymmetry is the format's rather than a
746
+ * simplification here. `RGBA2Timeline.setFrame(frame, time, r, g, b, a, r2, g2,
747
+ * b2)` stores three dark channels and no fourth, and `SkeletonJson`'s `rgba2`
748
+ * branch reads `Color.fromString(keyMap.dark)` into a colour whose alpha it then
749
+ * never passes on. `Color.setFromString` does fill one — `a = hex.length !== 8 ?
750
+ * 1 : …` — so a `dark` written with eight digits loads without complaint and the
751
+ * eighth pair is dropped one line later. Emitting six is emitting what is read.
752
+ *
753
+ * The channel ORDER is the format's too, and it is what a curve array indexes
754
+ * by: `readCurve(…, 0..3, light r/g/b/a)` then `readCurve(…, 4..6, dark r/g/b)`.
755
+ */
756
+ function rgba2Hex(v: number[]): { light: string; dark: string } {
757
+ if (v.length !== 7) throw new CompileError(`rgba2 value needs 7 channels, got ${v.length}`);
758
+ return { light: v.slice(0, 4).map(channelHex).join(''), dark: v.slice(4, 7).map(channelHex).join('') };
759
+ }
760
+
738
761
  /**
739
762
  * One timeline a `MotionValueTrack` can name: the JSON fields a key carries, the
740
763
  * per-key default the parser uses for each, and — where the runtime has one — the
@@ -893,12 +916,21 @@ const SLIDER_TRACKS: Record<string, ValueTrackShape> = {
893
916
  * the branch below that writes its keys — so an entry added here without a
894
917
  * branch to write it is an entry emitted in some other timeline's shape, which
895
918
  * is the defect this table closed rather than a new affordance. The format has
896
- * four more (`rgb`, `alpha`, `rgba2`, `rgb2`); rigc emits none of them, and
897
- * `A12_NO_DARK_COLOR` refuses the last two outright.
919
+ * three more (`rgb`, `alpha`, `rgb2`) and rigc emits none of them.
920
+ *
921
+ * 🎨 `rgba2` joined in issue #690, and what it adds is the half of the two-colour
922
+ * tint that moves: a slot's `dark` has been an emitted setup field all along
923
+ * (`RigSlot.dark`, step 4 below), with no way to key it. 🚫 Both halves are still
924
+ * refused by `A12_NO_DARK_COLOR`, and that is not a contradiction — A12 is filed
925
+ * `renderer` in `ASSERTION_KIND`, so it states what ONE renderer ignores rather
926
+ * than what is wrong. The `spine` profile `build` runs reports it `PROF` and
927
+ * never applies it; `--profile spine-html` is where it fires. Emitting a
928
+ * construct one consumer drops is exactly what a profile is for.
898
929
  */
899
- export const SLOT_TRACKS: Record<string, 'attachment' | 'rgba'> = {
930
+ export const SLOT_TRACKS: Record<string, 'attachment' | 'rgba' | 'rgba2'> = {
900
931
  attachment: 'attachment',
901
932
  rgba: 'rgba',
933
+ rgba2: 'rgba2',
902
934
  };
903
935
 
904
936
  /**
@@ -1988,6 +2020,8 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
1988
2020
  }
1989
2021
  return table;
1990
2022
  };
2023
+ /** Linked meshes to resolve once every skin exists — see `resolveLinkedMeshes`. */
2024
+ const pendingLinks: PendingLink[] = [];
1991
2025
  tableFor('default'); // rigc always emits a default skin, even when it is empty
1992
2026
  // ...and every skin the rig declares, for the same reason: a skin can now carry
1993
2027
  // `bones`/constraint lists with no attachments at all, and a skin that only
@@ -2158,6 +2192,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2158
2192
  imagesDir,
2159
2193
  depths: attachmentDepths,
2160
2194
  slotNames: new Set(rig.slots.map((s) => s.name)),
2195
+ links: pendingLinks,
2161
2196
  });
2162
2197
  // The name is put on AFTER the builder rather than inside it: five
2163
2198
  // builders write five shapes, the rule is one rule, and a rule that has
@@ -2172,6 +2207,10 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2172
2207
  tableFor(skinName)[rigSlot.name] = perSlot;
2173
2208
  }
2174
2209
  }
2210
+ // Every skin is built, so every `source` can now be looked up — and until this
2211
+ // line a linked mesh is the one construct in a rig spec whose name has not been
2212
+ // resolved yet.
2213
+ resolveLinkedMeshes(pendingLinks, skinTables);
2175
2214
  // 📐 The implicit budget of 0 is a statement about rigc's own GENERATORS:
2176
2215
  // geometry rigc built is geometry rigc will not ship unmeasured, and
2177
2216
  // `A13_MESH_BUDGET` has nothing to measure a generated mesh against until the
@@ -2215,12 +2254,22 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2215
2254
  const deformTransforms: CompileResult['deformTransforms'] = [];
2216
2255
  /** Group-track keys whose per-member values were stated or derived (issue #295). */
2217
2256
  const trackDerivations: CompileResult['trackDerivations'] = [];
2218
- const constraintNames = new Set<string>();
2219
2257
  // `ik` and `transform` timelines resolve their target by name AND by type —
2220
2258
  // `findConstraint(name, IkConstraintData)` returns null for a transform
2221
- // constraint of the same name and the parser then throws. Keeping the type
2222
- // beside the name is what lets the refusal say which of the two it is.
2223
- const constraintTypes = new Map<string, string>();
2259
+ // constraint of the same name and the parser then throws. So the table this
2260
+ // compiler resolves against is keyed by BOTH (issue #692): a name alone is not
2261
+ // a constraint in this format, and a rig may declare `leg` once per kind.
2262
+ /** `<kind> constraint "<name>"` -> declared. The key is the format's namespace. */
2263
+ const constraintDeclared = new Set<string>();
2264
+ /** name -> the kinds that declare it, for the refusal that has to say which. */
2265
+ const constraintKinds = new Map<string, string[]>();
2266
+ /** kind -> the names it declares, for the "the rig declares: …" half of one. */
2267
+ const constraintNamesOfKind = new Map<string, string[]>();
2268
+ const declareConstraint = (name: string, type: string): void => {
2269
+ constraintDeclared.add(constraintAt(type, name));
2270
+ constraintKinds.set(name, [...(constraintKinds.get(name) ?? []), type]);
2271
+ constraintNamesOfKind.set(type, [...(constraintNamesOfKind.get(type) ?? []), name]);
2272
+ };
2224
2273
  /**
2225
2274
  * ik constraint -> the booleans it declares that the timeline format would
2226
2275
  * otherwise take away from it. Issue #273.
@@ -2262,8 +2311,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2262
2311
  // wrote in it and the union above is a claim about a correct one.
2263
2312
  for (const spec of (rig.constraints ?? []) as unknown as RigConstraintInput[]) {
2264
2313
  constraints.push(buildRigConstraint(spec, constraintCtx));
2265
- constraintNames.add(spec.name);
2266
- constraintTypes.set(spec.name, spec.type);
2314
+ declareConstraint(spec.name, spec.type);
2267
2315
  if (spec.type === 'ik') {
2268
2316
  const carried: Record<string, boolean> = {};
2269
2317
  for (const flag of CONSTRAINT_TIMELINES.ik.flags) {
@@ -2275,11 +2323,16 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2275
2323
  }
2276
2324
  withMotionSource(() => {
2277
2325
  for (const [name, spec] of Object.entries(motion.physics ?? {})) {
2278
- if (constraintNames.has(name)) {
2279
- throw new CompileError(`constraint "${name}" is declared in both the rig spec and the motion spec's physics table`);
2326
+ // Per kind, like everything else about a constraint name: a rig-declared
2327
+ // `ik` and a tuned `physics` constraint of one name are two objects, and
2328
+ // two PHYSICS constraints of one name are the pair no timeline can tell
2329
+ // apart.
2330
+ if (constraintDeclared.has(constraintAt('physics', name))) {
2331
+ throw new CompileError(
2332
+ `physics constraint "${name}" is declared in both the rig spec and the motion spec's physics table`,
2333
+ );
2280
2334
  }
2281
- constraintNames.add(name);
2282
- constraintTypes.set(name, 'physics');
2335
+ declareConstraint(name, 'physics');
2283
2336
  if (!boneNames.has(spec.bone)) {
2284
2337
  throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
2285
2338
  }
@@ -2353,6 +2406,12 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2353
2406
  const animations: SpineSkeletonJson['animations'] = {};
2354
2407
  const declaredDurations: Record<string, number> = {};
2355
2408
  const slotNames = new Set(slots.map((s) => s.name));
2409
+ // Which slots an `rgba2` timeline may be keyed on: the ones the EMITTED file
2410
+ // gives a `dark`, for the same reason `attachmentIndex` is built off the
2411
+ // emitted skins — what the runtime does with a timeline depends on the file,
2412
+ // not on the spec that produced it. A slot whose `dark` never reached the
2413
+ // artifact is a slot the runtime allocates no dark colour for (issue #690).
2414
+ const darkSlots = new Set(slots.filter((s) => s.dark !== undefined).map((s) => s.name));
2356
2415
 
2357
2416
  withMotionSource(() => {
2358
2417
  checkMotionGroups(motion);
@@ -2382,22 +2441,24 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2382
2441
  const resolved = perMember.get(target)!;
2383
2442
  if (family !== null) {
2384
2443
  const label = CONSTRAINT_TRACK_FAMILIES[family].label;
2385
- if (!constraintNames.has(target)) {
2386
- const known = [...constraintTypes.entries()].filter(([, t]) => t === family).map(([n]) => n);
2387
- throw new CompileError(
2388
- `animation "${animName}" keys unknown ${label} "${target}"` +
2389
- (known.length ? ` (the rig declares: ${known.join(', ')})` : `, and the rig declares no ${family} constraint at all`),
2390
- );
2391
- }
2392
2444
  // Resolved by name AND by type in the parser
2393
2445
  // (`findConstraint(name, PathConstraintData)`), which returns null on
2394
2446
  // a type mismatch and makes `readAnimation` throw in the consumer's
2395
- // process. Named here instead, where the motion file can be named too.
2396
- const declared = constraintTypes.get(target);
2397
- if (declared !== family) {
2447
+ // process. Named here instead, where the motion file can be named too
2448
+ // — and looked up the same way, so a rig that declares `leg` under two
2449
+ // kinds sends each track to its own constraint.
2450
+ if (!constraintDeclared.has(constraintAt(family, target))) {
2451
+ const kinds = constraintKinds.get(target) ?? [];
2452
+ if (kinds.length > 0) {
2453
+ throw new CompileError(
2454
+ `animation "${animName}" keys "${target}" as a ${label}, but the rig declares it as a "${kinds.join('"/"')}" ` +
2455
+ 'constraint — a timeline group resolves its target by name AND type, misses, and the loader throws',
2456
+ );
2457
+ }
2458
+ const known = constraintNamesOfKind.get(family) ?? [];
2398
2459
  throw new CompileError(
2399
- `animation "${animName}" keys "${target}" as a ${label}, but the rig declares it as a "${declared}" ` +
2400
- 'constraint — a timeline group resolves its target by name AND type, misses, and the loader throws',
2460
+ `animation "${animName}" keys unknown ${label} "${target}"` +
2461
+ (known.length ? ` (the rig declares: ${known.join(', ')})` : `, and the rig declares no ${family} constraint at all`),
2401
2462
  );
2402
2463
  }
2403
2464
  } else if (isBoneTrack) {
@@ -2407,10 +2468,14 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2407
2468
  } else if (!slotNames.has(target)) {
2408
2469
  throw new CompileError(`animation "${animName}" targets unknown slot "${target}"`);
2409
2470
  }
2410
- const claim = `${target}.${track.property}`;
2471
+ // A timeline is a kind, a name and a property — `physics.leg.mix` and
2472
+ // `path.leg.mix` are two timelines on two constraints, so the claim
2473
+ // carries the family the target was resolved in (issue #692). The
2474
+ // sentence still names the pair the author wrote.
2475
+ const claim = `${family ?? (isBoneTrack ? 'bone' : 'slot')} ${target}.${track.property}`;
2411
2476
  if (claimed.has(claim)) {
2412
2477
  throw new CompileError(
2413
- `animation "${animName}" has two tracks on ${claim}; merge them into one track`,
2478
+ `animation "${animName}" has two tracks on ${target}.${track.property}; merge them into one track`,
2414
2479
  );
2415
2480
  }
2416
2481
  claimed.add(claim);
@@ -2430,7 +2495,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2430
2495
  )
2431
2496
  : isBoneTrack
2432
2497
  ? compileValueTrack(resolved, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
2433
- : compileTrack(resolved, motion, animName, anim.duration, target, shift, attachmentIndex);
2498
+ : compileTrack(resolved, motion, animName, anim.duration, target, shift, attachmentIndex, darkSlots);
2434
2499
  for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
2435
2500
  if (family !== null) (familyTimelines[family][target] ??= {})[track.property] = keys;
2436
2501
  else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
@@ -2456,9 +2521,15 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2456
2521
  const tracks: Array<MotionIkTrack | MotionTransformTrack> = anim[group] ?? [];
2457
2522
  for (const track of tracks) {
2458
2523
  const name = track.constraint;
2459
- const type = constraintTypes.get(name);
2460
- if (type === undefined) {
2461
- const known = [...constraintTypes.entries()].filter(([, t]) => t === group).map(([n]) => n);
2524
+ if (!constraintDeclared.has(constraintAt(group, name))) {
2525
+ const kinds = constraintKinds.get(name) ?? [];
2526
+ if (kinds.length > 0) {
2527
+ throw new CompileError(
2528
+ `animation "${animName}" keys "${name}" as ${group === 'ik' ? 'an' : 'a'} ${group} constraint, but the rig declares it as a ` +
2529
+ `"${kinds.join('"/"')}" constraint — the parser looks a timeline's target up by name AND type, misses, and throws`,
2530
+ );
2531
+ }
2532
+ const known = constraintNamesOfKind.get(group) ?? [];
2462
2533
  throw new CompileError(
2463
2534
  `animation "${animName}" keys unknown ${group} constraint "${name}"; ` +
2464
2535
  (known.length
@@ -2466,12 +2537,6 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2466
2537
  : `the rig declares no ${group} constraint at all`),
2467
2538
  );
2468
2539
  }
2469
- if (type !== group) {
2470
- throw new CompileError(
2471
- `animation "${animName}" keys "${name}" as ${group === 'ik' ? 'an' : 'a'} ${group} constraint, but the rig declares it as a ` +
2472
- `"${type}" constraint — the parser looks a timeline's target up by name AND type, misses, and throws`,
2473
- );
2474
- }
2475
2540
  if (constraintTimelines[group][name]) {
2476
2541
  throw new CompileError(
2477
2542
  `animation "${animName}" has two ${group} timelines on constraint "${name}"; ` +
@@ -2484,7 +2549,10 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2484
2549
  motion,
2485
2550
  animName,
2486
2551
  anim.duration,
2487
- ikRigFlags.get(name) ?? {},
2552
+ // The ik table, asked only by the ik group: `leg` may also be a
2553
+ // transform constraint, and a transform key set carries no flag for
2554
+ // the rig's booleans to be stamped onto (issue #692).
2555
+ group === 'ik' ? (ikRigFlags.get(name) ?? {}) : {},
2488
2556
  );
2489
2557
  for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
2490
2558
  constraintTimelines[group][name] = keys;
@@ -3055,8 +3123,14 @@ function nameSkinAttachment(att: SpineAttachment, name: string, placeholder: str
3055
3123
  // Key order is the parser's reading order — `name`, then `path`, then the rest
3056
3124
  // as the builder wrote it — for the same reason every other emitted object
3057
3125
  // follows it: the file is read by people and diffed against references.
3058
- if (kind !== 'region' && kind !== 'mesh') return { name, ...att };
3059
- const { path, ...rest } = att as SpineRegionAttachment | SpineMeshAttachment;
3126
+ //
3127
+ // ⚠️ The three kinds here are the three the parser gives a texture `path` to,
3128
+ // and `linkedmesh` is one of them (`SkeletonJson.ts:541`, `:570` — the mesh
3129
+ // branch is shared). Leaving it out would write a `name` and no `path`, and
3130
+ // `path` defaults to `name`, so a contested link would resolve the region
3131
+ // "<skin>/<placeholder>", which no atlas holds.
3132
+ if (kind !== 'region' && kind !== 'mesh' && kind !== 'linkedmesh') return { name, ...att };
3133
+ const { path, ...rest } = att as SpineRegionAttachment | SpineMeshAttachment | SpineLinkedMeshAttachment;
3060
3134
  return { name, path: path ?? placeholder, ...rest } as SpineAttachment;
3061
3135
  }
3062
3136
 
@@ -3093,11 +3167,37 @@ interface AttachmentContext {
3093
3167
  depths: Map<string, number[]>;
3094
3168
  /** Every slot the rig declares — a clipping attachment's `end` resolves here. */
3095
3169
  slotNames: Set<string>;
3170
+ /**
3171
+ * Linked meshes whose `source` is still unresolved — `resolveLinkedMeshes`
3172
+ * empties it once every skin has been built.
3173
+ *
3174
+ * ⭐ Deferred for the reason spine-core defers its own (`this.linkedMeshes`,
3175
+ * `SkeletonJson.js:56` filled at `:581` and drained at `:427-449`): a link may
3176
+ * name a source in a skin or a slot this loop has not reached yet, so resolving
3177
+ * it in place would refuse a correct rig on nothing but declaration order.
3178
+ */
3179
+ links: PendingLink[];
3180
+ }
3181
+
3182
+ /** One linked mesh, with everything a refusal has to name once the skins exist. */
3183
+ interface PendingLink {
3184
+ where: string;
3185
+ /** What it asked for, the parser's defaults already applied. */
3186
+ skin: string;
3187
+ slot: string;
3188
+ source: string;
3189
+ /**
3190
+ * Whether the author wrote `skin`/`slot` or is taking the parser's default —
3191
+ * which is the half of the refusal that says where a name came from, and the
3192
+ * one an author who wrote neither key needs most.
3193
+ */
3194
+ skinStated: boolean;
3195
+ slotStated: boolean;
3096
3196
  }
3097
3197
 
3098
3198
  /**
3099
3199
  * The seven `type` values `readAttachment` has a branch for (`:540-651`), and
3100
- * the five rigc emits.
3200
+ * the six rigc emits.
3101
3201
  *
3102
3202
  * ⚠️ The lists are separate because the refusals are separate. A `point` is a
3103
3203
  * name the format HAS and rigc has not built; `sequence` is not a type at all —
@@ -3107,22 +3207,24 @@ interface AttachmentContext {
3107
3207
  * One message said exactly that about every string it did not recognise.
3108
3208
  */
3109
3209
  const SPINE_ATTACHMENT_TYPES = ['region', 'mesh', 'linkedmesh', 'boundingbox', 'path', 'point', 'clipping'] as const;
3110
- const EMITTED_ATTACHMENT_TYPES = ['region', 'mesh', 'boundingbox', 'clipping', 'path'] as const;
3210
+ const EMITTED_ATTACHMENT_TYPES = ['region', 'mesh', 'linkedmesh', 'boundingbox', 'clipping', 'path'] as const;
3111
3211
 
3112
3212
  /**
3113
3213
  * What each deferred type is and what it would carry — `docs/SPEC_COVERAGE.md`
3114
- * part 1-6's two rows, restated where the refusal can print them.
3214
+ * part 1-6's row, restated where the refusal can print it.
3115
3215
  *
3116
3216
  * ⭐ The construct, not just its name. "attachment type X is in the Spine 4.3
3117
3217
  * format and rigc does not emit it yet" tells an author who already knows what a
3118
- * linked mesh is that rigc will not do it, and tells an author who does not know
3218
+ * point is that rigc will not do it, and tells an author who does not know
3119
3219
  * nothing at all — and the second is the reader this repository writes for.
3220
+ *
3221
+ * ⚠️ `linkedmesh` was the other entry until issue #691 emitted it. The sentence
3222
+ * below no longer says *"neither a point nor a linked mesh appears anywhere in
3223
+ * the benchmark corpus"*, because half of that is now a statement about a type
3224
+ * rigc writes, and a refusal that argues from a construct it has since built is
3225
+ * a refusal nobody can act on.
3120
3226
  */
3121
3227
  const DEFERRED_ATTACHMENTS: Record<string, string> = {
3122
- linkedmesh:
3123
- 'a mesh that takes its geometry from another mesh instead of stating any — a region/mesh head, then ' +
3124
- '"source" (the attachment it links to, and the key that MAKES it linked), "slot" and "skin" naming where ' +
3125
- 'that source lives, and "timelines" (default true) for whether it follows the source\'s deform keys',
3126
3228
  point:
3127
3229
  'a position and an angle with no geometry at all — "x", "y", "rotation" and "color", posed by its bone and ' +
3128
3230
  'drawn by nothing; what reads it is game code asking where a muzzle or a hand is',
@@ -3171,9 +3273,10 @@ function buildRigAttachment(
3171
3273
  // them (`:568-569`, `:582`; SPEC_COVERAGE part 1-6) — so a mesh carrying
3172
3274
  // `source` is a linked mesh whatever its `type` says, and refusing it as "two
3173
3275
  // keys this compiler does not read: source, skin" sent the author to delete
3174
- // the one key that made it linked.
3175
- if (type === 'mesh' && (att as { source?: unknown }).source !== undefined) {
3176
- throw new NotImplementedError(deferredAttachmentRefusal('linkedmesh', where, ' (a mesh carrying "source" is one)'));
3276
+ // the one key that made it linked. Both spellings reach one builder for the
3277
+ // same reason they reach one parser branch.
3278
+ if (type === 'linkedmesh' || (type === 'mesh' && (att as { source?: unknown }).source !== undefined)) {
3279
+ return buildRigLinkedMesh(att as RigLinkedMeshAttachment, placeholder, where, ctx);
3177
3280
  }
3178
3281
  if (type === 'region') return buildRigRegion(att as RigRegionAttachment, placeholder, where, ctx);
3179
3282
  if (type === 'mesh') return buildRigMesh(att as RigMeshAttachment, placeholder, where, ctx);
@@ -3200,9 +3303,9 @@ function buildRigAttachment(
3200
3303
  function deferredAttachmentRefusal(type: string, where: string, how: string): string {
3201
3304
  return (
3202
3305
  `${where}: this attachment is a "${type}"${how} — ${DEFERRED_ATTACHMENTS[type]}. ` +
3203
- `rigc does not emit it yet, deliberately: it emits ${EMITTED_ATTACHMENT_TYPES.join(', ')}, and neither a ` +
3204
- 'point nor a linked mesh appears anywhere in the benchmark corpus (docs/SPEC_COVERAGE.md parts 3-1 and 4-2), ' +
3205
- 'so neither is on the ladder\'s critical path. docs/SPEC_COVERAGE.md part 1-6 is the row this sentence reads ' +
3306
+ `rigc does not emit it yet, deliberately: it emits ${EMITTED_ATTACHMENT_TYPES.join(', ')}, and a point ` +
3307
+ 'appears nowhere in the benchmark corpus (docs/SPEC_COVERAGE.md parts 3-1 and 4-2), so it is not on the ' +
3308
+ "ladder's critical path. docs/SPEC_COVERAGE.md part 1-6 is the row this sentence reads " +
3206
3309
  'from, and it is what an implementation would have to carry.'
3207
3310
  );
3208
3311
  }
@@ -3917,6 +4020,178 @@ function buildRigMesh(
3917
4020
  return out;
3918
4021
  }
3919
4022
 
4023
+ /**
4024
+ * A mesh that borrows another mesh's geometry. `type: "linkedmesh"`, or
4025
+ * `type: "mesh"` carrying `source` — one parser branch, one builder.
4026
+ *
4027
+ * 🚨 **Everything this refuses, the parser reads in silence**, which is the whole
4028
+ * reason the refusals exist. Measured on forged skeletons through spine-core
4029
+ * 4.3.13:
4030
+ *
4031
+ * - Geometry beside `source`: the branch returns at `:586`, BEFORE
4032
+ * `readVertices`. A link declaring 5 uvs, 3 triangles, `hull: 5` and
4033
+ * `edges: [0, 2]` beside a 4-vertex source loaded with the source's 8-long
4034
+ * `worldVerticesLength`, 6 triangles, `hullLength` 8 and 10 edges. Nothing
4035
+ * the author wrote was read and nothing said so.
4036
+ * - A chain — a link whose source is itself a link — resolves in the order
4037
+ * `linkedMeshes` was FILLED, which is the order the skins' JSON keys are
4038
+ * iterated in. Source-first, the chained link loaded the full geometry; with
4039
+ * the two keys swapped in the same file it loaded `worldVerticesLength` 0,
4040
+ * 0 triangles, 0 bones and a 0x0 size, silently. A construct whose meaning
4041
+ * depends on key order is a construct rigc will not write.
4042
+ * - A link to itself is that chain at length one, and loads the same nothing.
4043
+ *
4044
+ * What is NOT refused here is `source` naming something in a skin or a slot this
4045
+ * builder has not reached yet: that is `resolveLinkedMeshes`' job, and doing it
4046
+ * here would refuse a correct rig on declaration order — the very thing the
4047
+ * chain measurement above condemns.
4048
+ */
4049
+ function buildRigLinkedMesh(
4050
+ att: RigLinkedMeshAttachment,
4051
+ placeholder: string,
4052
+ where: string,
4053
+ ctx: AttachmentContext,
4054
+ ): SpineLinkedMeshAttachment {
4055
+ // The mesh keys the parser does not reach on this branch. Named one by one,
4056
+ // because "remove what does not belong" is not an instruction an author can
4057
+ // act on and the remedy for each of these is the same single sentence.
4058
+ const geometry = (['uvs', 'triangles', 'vertices', 'weights', 'boneIndexing', 'hull', 'edges', 'generator'] as const)
4059
+ .filter((key) => att[key] !== undefined);
4060
+ if (geometry.length > 0) {
4061
+ throw new CompileError(
4062
+ `${where}: a linked mesh states ${geometry.map((k) => JSON.stringify(k)).join(', ')}, and a linked mesh has ` +
4063
+ 'no geometry of its own — it draws the geometry of the attachment "source" names. The parser returns from ' +
4064
+ 'the `source` branch before `readVertices` (`SkeletonJson.ts:582-586`), so these keys are read by nothing ' +
4065
+ "at all: a link declaring 5 uvs beside a 4-vertex source loads the SOURCE's 4 vertices and says nothing. " +
4066
+ `Remove ${geometry.length === 1 ? 'it' : 'them'}, or remove "source" and author this as a mesh of its own.`,
4067
+ );
4068
+ }
4069
+ const source = att.source;
4070
+ if (typeof source !== 'string' || source.length === 0) {
4071
+ throw new CompileError(
4072
+ `${where}: a linked mesh needs "source" — the PLACEHOLDER of the mesh whose geometry it draws (the key that ` +
4073
+ `attachment is filed under in its skin, not its "name"), and this one states ${JSON.stringify(source) ?? String(source)}. ` +
4074
+ '"source" is what MAKES a mesh linked: `getValue(map, "source", null)` is falsy-tested ' +
4075
+ '(`SkeletonJson.ts:582`), so an absent or empty one is read as an ordinary mesh, whose `uvs` this ' +
4076
+ 'attachment does not have — the parser dereferences `map.uvs.length` and throws.',
4077
+ );
4078
+ }
4079
+ if (att.slot !== undefined && !ctx.slotNames.has(att.slot)) {
4080
+ throw new CompileError(
4081
+ `${where}: "slot" is ${JSON.stringify(att.slot)}, which the rig does not declare as a slot. It names where ` +
4082
+ 'the source lives and is resolved through `skeletonData.findSlot` (`SkeletonJson.ts:575`), which throws ' +
4083
+ `\`Source mesh slot not found\` on a miss. The rig's slots are ${[...ctx.slotNames].sort().map((s) => JSON.stringify(s)).join(', ')}. ` +
4084
+ "Leave it out and the source is looked for in this attachment's own slot, which is the parser's default.",
4085
+ );
4086
+ }
4087
+ ctx.links.push({
4088
+ where,
4089
+ skin: att.skin ?? 'default',
4090
+ slot: att.slot ?? ctx.slotName,
4091
+ source,
4092
+ skinStated: att.skin !== undefined,
4093
+ slotStated: att.slot !== undefined,
4094
+ });
4095
+ // The art side is a mesh's, unchanged: a link draws its OWN region, which is
4096
+ // the reason the type exists — one triangulation, one outfit's pixels each.
4097
+ const img = att.image === undefined ? undefined : atlasedImage(att.image, where, ctx);
4098
+ const width = att.width ?? img?.width;
4099
+ const height = att.height ?? img?.height;
4100
+ if (width === undefined || height === undefined) {
4101
+ throw new CompileError(
4102
+ `${where}: a linked mesh needs width and height — give them, or give an "image" and rigc will measure the PNG`,
4103
+ );
4104
+ }
4105
+ const out: SpineLinkedMeshAttachment = { type: 'linkedmesh', source, width: r6(width), height: r6(height) };
4106
+ const path = attachmentPath(att, placeholder);
4107
+ if (path !== undefined) out.path = path;
4108
+ // Only where they differ from the parser's own defaults. Writing `timelines:
4109
+ // true`, or a `skin` of "default", would be a byte the editor's own export
4110
+ // does not carry.
4111
+ if (att.slot !== undefined && att.slot !== ctx.slotName) out.slot = att.slot;
4112
+ if (att.skin !== undefined && att.skin !== 'default') out.skin = att.skin;
4113
+ if (att.timelines === false) out.timelines = false;
4114
+ if (att.color !== undefined) out.color = att.color;
4115
+ // 🚫 NOT registered in `ctx.meshes`, and that is a decision rather than an
4116
+ // omission. `meshKinds` is keyed by SLOT and the commonest linked mesh shares
4117
+ // its source's slot from another skin, so an entry here would overwrite the
4118
+ // source's own `ring`/`ribbon`/`contour` kind and silence `A21_MESH_RIM_PINNED`
4119
+ // on the mesh rigc actually built — a gate turned off by a feature, which is
4120
+ // the failure mode issue #44 is about, pointed the other way. The validator
4121
+ // tells a link apart from the ARTIFACT instead — off the file's own `source`
4122
+ // keys, joined to the loaded attachment by (skin, slot, placeholder) — which
4123
+ // is the archetype rule's own words: an assertion reads the rig, never a name.
4124
+ return out;
4125
+ }
4126
+
4127
+ /**
4128
+ * Resolve every `source` against the skins that were built, and refuse a miss by
4129
+ * name.
4130
+ *
4131
+ * Three of the four refusals are one per throw the runtime would have made
4132
+ * (`SkeletonJson.js:427-436`) — `Skin not found`, `Source mesh slot not found`,
4133
+ * `Source mesh not found`. Those are thrown `Error`s, so left to the round trip
4134
+ * they arrive as `A00_ROUNDTRIP_PARSE`'s report of the runtime's sentence, which
4135
+ * names neither the attachment that asked nor the skin and slot it searched. The
4136
+ * fourth has no runtime throw behind it at all: a source that is not a mesh is
4137
+ * read field by field off whatever was found, and `undefined` is not an error.
4138
+ */
4139
+ function resolveLinkedMeshes(
4140
+ links: readonly PendingLink[],
4141
+ tables: Map<string, Record<string, Record<string, SpineAttachment>>>,
4142
+ ): void {
4143
+ const skinNames = [...tables.keys()];
4144
+ for (const link of links) {
4145
+ // ⚠️ Only a STATED `skin` can miss here, and it is worth saying why rather
4146
+ // than leaving the other half to look like a branch nothing reaches: rigc
4147
+ // always emits a `default` skin, empty if it has to (`tableFor('default')`),
4148
+ // so the parser's own default always resolves to a table. An omitted `skin`
4149
+ // therefore fails one line down, at the source, and the sentence there is
4150
+ // what names the trap.
4151
+ const table = tables.get(link.skin);
4152
+ if (table === undefined) {
4153
+ throw new CompileError(
4154
+ `${link.where}: "skin" is ${JSON.stringify(link.skin)}, and the rig declares no such skin. The rig's skins ` +
4155
+ `are ${skinNames.map((s) => JSON.stringify(s)).join(', ')}. ` +
4156
+ "Left to the round trip this is the runtime's `Skin not found`, which names neither this attachment nor " +
4157
+ 'where it was looking.',
4158
+ );
4159
+ }
4160
+ const slot = table[link.slot];
4161
+ const held = slot === undefined ? [] : Object.keys(slot).sort();
4162
+ const found = slot?.[link.source];
4163
+ if (found === undefined) {
4164
+ throw new CompileError(
4165
+ `${link.where}: "source" is ${JSON.stringify(link.source)}, and skin ${JSON.stringify(link.skin)}` +
4166
+ `${link.skinStated ? '' : ' (the default skin, because no "skin" was stated — never the skin this attachment is written in)'}` +
4167
+ ` slot ${JSON.stringify(link.slot)}${link.slotStated ? '' : ' (this attachment\'s own slot, because no "slot" was stated)'} ` +
4168
+ `holds ${held.length === 0 ? 'no attachment at all' : `${held.length}: ${held.map((k) => JSON.stringify(k)).join(', ')}`}. ` +
4169
+ '"source" is the PLACEHOLDER the source is filed under — `skin.getAttachment(slotIndex, source)` ' +
4170
+ "(`SkeletonJson.ts:433`), whose table is keyed by the JSON key, not by the attachment's `name`. " +
4171
+ "Left to the round trip this is the runtime's `Source mesh not found`.",
4172
+ );
4173
+ }
4174
+ const type = (found as { type?: string }).type ?? 'region';
4175
+ if (type === 'linkedmesh') {
4176
+ throw new CompileError(
4177
+ `${link.where}: "source" is ${JSON.stringify(link.source)}, which is itself a linked mesh, and a chain of ` +
4178
+ 'them is refused. Measured through spine-core: the runtime resolves links in the order they were read, ' +
4179
+ "so a link whose source is a link loads the source's geometry when the source comes first in the file " +
4180
+ 'and loads NOTHING — `worldVerticesLength` 0, 0 triangles, a 0x0 size — when the two are swapped, in ' +
4181
+ 'silence either way. Point "source" at the mesh itself.',
4182
+ );
4183
+ }
4184
+ if (type !== 'mesh') {
4185
+ throw new CompileError(
4186
+ `${link.where}: "source" is ${JSON.stringify(link.source)}, which is a ${JSON.stringify(type)} attachment ` +
4187
+ "and not a mesh. A linked mesh takes another MESH's `uvs`, `triangles`, `vertices`, `hull` and `edges` " +
4188
+ '(`MeshAttachment.setSourceMesh`); the runtime casts whatever it finds and reads those fields off it, ' +
4189
+ 'which on any other type is `undefined` and no error.',
4190
+ );
4191
+ }
4192
+ }
4193
+ }
4194
+
3920
4195
  /**
3921
4196
  * Invoke a `src/mesh.ts` builder from rig-spec data.
3922
4197
  *
@@ -6050,22 +6325,40 @@ function deformGeometryOf(
6050
6325
  let worldVerticesLength: number;
6051
6326
  if (type === 'mesh') {
6052
6327
  worldVerticesLength = (att as SpineMeshAttachment).uvs.length;
6053
- } else if (type === 'boundingbox' || type === 'clipping') {
6054
- worldVerticesLength = (att as SpineBoundingBoxAttachment).vertexCount * 2;
6055
- } else if (type === 'path') {
6056
- // The one type that HAS a vertex array and is still refused here. Deforming
6057
- // a path is a real idiom — an animated track — but it also invalidates the
6058
- // measured `lengths` that a `constantSpeed: false` traversal reads, so it is
6059
- // a feature with a rule attached rather than one line of plumbing.
6060
- throw new NotImplementedError(
6061
- `${where}: a path attachment does have a vertex array, and rigc does not key it yet — a deformed path ` +
6062
- 'changes the arc lengths its `lengths` array records, which only `constantSpeed: false` reads. ' +
6063
- 'Move the curve by posing the bones its vertices are bound to.',
6064
- );
6328
+ } else if (type === 'boundingbox' || type === 'clipping' || type === 'path') {
6329
+ // ⭐ A `path` reaches this line as of issue #696, and it is the SAME line the
6330
+ // other two vertex-and-no-triangles types take: the array the parser sizes
6331
+ // is `vertexCount * 2`, the two encodings below are the mesh's own, and a
6332
+ // path's vertices are its control points.
6333
+ //
6334
+ // ⚠️ What stood here was a `NotImplementedError` — *a path attachment does
6335
+ // have a vertex array, and rigc does not key it yet* — whose stated reason
6336
+ // was that a deformed path invalidates the `lengths` the attachment carries.
6337
+ // The reason is measured and it does not belong to rigc:
6338
+ //
6339
+ // - `lengths` is a field of the ATTACHMENT. The format has nowhere to put
6340
+ // a per-key length, so no export of any tool carries a re-measured one;
6341
+ // the editor's own is the setup measurement, which is what
6342
+ // `pathCurveLengths` reproduces digit for digit.
6343
+ // - `PathConstraint.computeWorldPositions` reads that field only under
6344
+ // `constantSpeed: false` (`PathConstraint.js:205`). Under the parser's
6345
+ // default, `true`, it re-measures the curve from the posed world
6346
+ // vertices every frame — and those come through
6347
+ // `VertexAttachment.computeWorldVertices`, which uses
6348
+ // `slot.appliedPose.deform` when the array is non-empty
6349
+ // (`attachments/Attachment.js:97-115`). So the constraint follows the
6350
+ // DEFORMED spline, and the stale field is not read at all.
6351
+ //
6352
+ // ⇒ refusing it was refusing correct data for behaviour the format has and
6353
+ // every runtime shares — the shape issues #44 and #262 already cost this
6354
+ // file twice. `docs/AUTHORING.md` §4.11 states the `constantSpeed: false`
6355
+ // reading instead, which is the honest home for it: a fact about the format
6356
+ // the author is choosing, not a fault rigc can measure.
6357
+ worldVerticesLength = (att as SpineBoundingBoxAttachment | SpinePathAttachment).vertexCount * 2;
6065
6358
  } else {
6066
6359
  throw new CompileError(
6067
6360
  `${where}: a deform timeline keys the vertices of an attachment, and this one is a "${type}" — ` +
6068
- 'it has no vertex array to deform. Deformable types: mesh, boundingbox, clipping.',
6361
+ 'it has no vertex array to deform. Deformable types: mesh, boundingbox, clipping, path.',
6069
6362
  );
6070
6363
  }
6071
6364
  const vertices = (att as SpineMeshAttachment).vertices ?? [];
@@ -6910,6 +7203,7 @@ function compileTrack(
6910
7203
  target: string,
6911
7204
  shift: number,
6912
7205
  attachments: SlotAttachmentIndex,
7206
+ darkSlots: ReadonlySet<string>,
6913
7207
  ): SpineTimelineKey[] {
6914
7208
  const where = `animation "${animName}" slot "${target}" ${track.property}`;
6915
7209
  // Before any key is shaped, and before the empty-track refusal: a property
@@ -6922,6 +7216,25 @@ function compileTrack(
6922
7216
  `(it has: ${Object.keys(SLOT_TRACKS).join(', ')})`,
6923
7217
  );
6924
7218
  }
7219
+ // 🚨 Raised here, with the target and before the keys, for the same reason as
7220
+ // the refusal above: the fault is in the pairing of timeline and slot, not in
7221
+ // any key. A two-colour timeline poses `SlotPose.darkColor`, and the runtime
7222
+ // creates that object only for a slot whose setup pose HAS one — `Slot`'s
7223
+ // constructor reads `if (data.setupPose.darkColor != null)` before allocating
7224
+ // it. Measured on the emitted file with the refusal removed: `SkeletonJson`
7225
+ // loads it in silence, `SlotData.setupPose.darkColor` is `null`, and the first
7226
+ // `state.apply` throws `TypeError: null is not an object (evaluating 'dark.r =
7227
+ // …')` inside `RGBA2Timeline.apply1`. That is a crash in the consumer's
7228
+ // process, from a file every parser accepts — the exact silence this compiler
7229
+ // exists to convert into a name (issue #690).
7230
+ if (shape === 'rgba2' && !darkSlots.has(target)) {
7231
+ throw new CompileError(
7232
+ `${where}: slot "${target}" declares no setup "dark", and an "rgba2" timeline poses a slot's dark colour — ` +
7233
+ 'the runtime allocates one only for a slot whose setup pose has it, so applying this animation throws ' +
7234
+ `instead of tinting. Give slot "${target}" a \`dark\` in the rig spec (the colour it holds at rest), or ` +
7235
+ 'key "rgba" if only the light colour moves',
7236
+ );
7237
+ }
6925
7238
  if (!track.keys.length) throw new CompileError(`${where}: no keys`);
6926
7239
 
6927
7240
  const out: SpineTimelineKey[] = [];
@@ -6960,16 +7273,28 @@ function compileTrack(
6960
7273
  continue;
6961
7274
  }
6962
7275
 
6963
- // rgba — the other shape `SLOT_TRACKS` names, and now the only way to reach
6964
- // this branch: a property the table does not carry was refused above.
6965
- if (!Array.isArray(key.v)) throw new CompileError(`${where}: rgba key value must be [r,g,b,a]`);
6966
- const entry: SpineTimelineKey = { time, color: rgbaHex(key.v) };
7276
+ // rgba / rgba2 — the two colour shapes `SLOT_TRACKS` names, and between them
7277
+ // the only ways left to reach here: `attachment` returned above and a
7278
+ // property the table does not carry was refused before any key was shaped.
7279
+ //
7280
+ // ⚠️ The two differ in three places and nowhere else, so they are read out of
7281
+ // `shape` rather than forked into two loops: how many channels a `v` carries,
7282
+ // which FIELDS the emitted key spells them as (`color`, or `light` + `dark`),
7283
+ // and therefore how long a raw curve array is. Everything else — the
7284
+ // ease/curve exclusivity, the stepped case, the hold test, the last-key
7285
+ // refusals — is one rule, and a second copy of it is a second thing to keep
7286
+ // in step.
7287
+ const spelling = shape === 'rgba2' ? '[lr,lg,lb,la,dr,dg,db]' : '[r,g,b,a]';
7288
+ const channels = shape === 'rgba2' ? 7 : 4;
7289
+ const colourOf = (v: number[]): Record<string, string> => (shape === 'rgba2' ? rgba2Hex(v) : { color: rgbaHex(v) });
7290
+ if (!Array.isArray(key.v)) throw new CompileError(`${where}: ${shape} key value must be ${spelling}`);
7291
+ const entry: SpineTimelineKey = { time, ...colourOf(key.v) };
6967
7292
  if (key.ease !== undefined && key.curve !== undefined) {
6968
7293
  throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
6969
7294
  }
6970
7295
  if (key.curve !== undefined) {
6971
7296
  if (!next) throw new CompileError(`${where}: last key carries a curve but has nothing to ease to`);
6972
- entry.curve = rawCurve(key.curve, 4, where, String(key.t));
7297
+ entry.curve = rawCurve(key.curve, channels, where, String(key.t));
6973
7298
  out.push(entry);
6974
7299
  continue;
6975
7300
  }
@@ -6980,14 +7305,18 @@ function compileTrack(
6980
7305
  const handles = motion.easings?.[key.ease];
6981
7306
  if (!handles) throw new CompileError(`${where}: unknown easing "${key.ease}"`);
6982
7307
  if (!Array.isArray(next.v)) {
6983
- throw new CompileError(`${where}: rgba key value must be [r,g,b,a]`);
7308
+ throw new CompileError(`${where}: ${shape} key value must be ${spelling}`);
6984
7309
  }
6985
7310
  const t2 = keyTime(next.t + shift);
6986
- // 4 numbers per channel, r g b a — 16 in total. Short arrays become NaN
7311
+ // 4 numbers per channel, in the format's own channel order — 16 for
7312
+ // `rgba` (r g b a), 28 for `rgba2` (light r g b a, then dark r g b, which
7313
+ // is the order `readCurve` indexes them in). Short arrays become NaN
6987
7314
  // curves with no error. The hold test reads the emitted hex rather than
6988
7315
  // the authored floats: two colours that quantise to one byte are one
6989
- // colour in the file.
6990
- entry.curve = easingCurve(handles, time, t2, key.v, next.v, rgbaHex(key.v) === rgbaHex(next.v));
7316
+ // colour in the file, and for `rgba2` that has to be true of BOTH — a key
7317
+ // whose light holds while its dark moves is not a hold.
7318
+ const held = JSON.stringify(colourOf(key.v)) === JSON.stringify(colourOf(next.v));
7319
+ entry.curve = easingCurve(handles, time, t2, key.v, next.v, held);
6991
7320
  }
6992
7321
  } else if (key.ease && !next) {
6993
7322
  throw new CompileError(`${where}: last key carries an easing but has nothing to ease to`);