spine-rigc 0.30.0 → 0.32.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
@@ -39,8 +39,9 @@ import { parseJsonWithPosition } from './json-position.ts';
39
39
  // of it — the same search serves `refuseUnknownKeys`, and a second copy here with
40
40
  // a threshold edited is how such a pair drifts apart.
41
41
  import { nearMisses } from './keys.ts';
42
- import { parseMotionSpec } from './motion.ts';
42
+ import { EVERY_GLOBAL_PHYSICS, parseMotionSpec } from './motion.ts';
43
43
  import {
44
+ BONE_INHERIT_KNOWN,
44
45
  constraintAt,
45
46
  declaresNoStage,
46
47
  parseRigSpec,
@@ -50,6 +51,7 @@ import {
50
51
  RIG_PATH_SPACING_MODES,
51
52
  RIG_SCALE_Y_MODES,
52
53
  RIG_SKIN_CONSTRAINT_KEYS,
54
+ resolveBoneInherit,
53
55
  splitRigSkin,
54
56
  type RigAttachment,
55
57
  type RigBone,
@@ -61,6 +63,7 @@ import {
61
63
  type RigMeshBinding,
62
64
  type RigPathAttachment,
63
65
  type RigRegionAttachment,
66
+ type RigSequence,
64
67
  type RigSkinParts,
65
68
  type RigSpec,
66
69
  type RigVertexGeometry,
@@ -89,6 +92,8 @@ import { Plate, readPlate } from '../tools/plate.ts';
89
92
  import {
90
93
  extractRegion,
91
94
  pageFootprint,
95
+ pageGridSaid,
96
+ pageGridSentence,
92
97
  parseAtlasText,
93
98
  rewritePageNames,
94
99
  writeAtlasText,
@@ -128,6 +133,7 @@ import type {
128
133
  MotionEventKey,
129
134
  MotionIkTrack,
130
135
  MotionMemberValues,
136
+ MotionSequenceTrack,
131
137
  MotionSpec,
132
138
  MotionTrack,
133
139
  MotionTransformTrack,
@@ -144,6 +150,7 @@ import type {
144
150
  SpineMeshAttachment,
145
151
  SpinePathAttachment,
146
152
  SpineRegionAttachment,
153
+ SpineSequence,
147
154
  SpineSkeletonJson,
148
155
  SpineSlot,
149
156
  SpineTimelineKey,
@@ -310,8 +317,14 @@ export function editorNamesInOrder(names: readonly string[], collection: 'animat
310
317
  return [...names].sort((a, b) => verdicts.get(a)?.get(b) ?? 0);
311
318
  }
312
319
 
313
- /** The skin the editor keeps at index 0 whatever its name sorts as. */
314
- const DEFAULT_SKIN = 'default';
320
+ /**
321
+ * The skin the editor keeps at index 0 whatever its name sorts as.
322
+ *
323
+ * Exported for `ingest`, which has to know that a contested placeholder the
324
+ * default skin fills is refused rather than named (`refuseDefaultSkinContest`),
325
+ * and has no business spelling the name a second time (issue #746).
326
+ */
327
+ export const DEFAULT_SKIN = 'default';
315
328
 
316
329
  /**
317
330
  * The order the emitted `skins` array is written in: **`default` first, then the
@@ -864,6 +877,17 @@ interface ValueTrackShape {
864
877
  fields: string[];
865
878
  identity: number[];
866
879
  bounds?: Array<PhysicsPoseRule | null>;
880
+ /**
881
+ * A timeline whose key holds one NAMED value instead of numbers — the JSON
882
+ * field it is written as, and the resolver that turns a spelling into the
883
+ * name the file carries (or `undefined` for one the runtime cannot resolve).
884
+ *
885
+ * ⚠️ Such a timeline is stepped by the format rather than by choice: its
886
+ * reader builds no curve at all, so an `ease` or a `curve` on its key would be
887
+ * accepted by the parser and never read. `compileValueTrack` refuses both.
888
+ * One entry has it — the bone's `inherit` (issue #733).
889
+ */
890
+ named?: { field: string; resolve: (value: unknown) => string | undefined; known: string };
867
891
  }
868
892
 
869
893
  /**
@@ -898,6 +922,12 @@ const BONE_TRACKS: Record<string, ValueTrackShape> = {
898
922
  shearx: { fields: ['value'], identity: [0] },
899
923
  sheary: { fields: ['value'], identity: [0] },
900
924
  rotate: { fields: ['value'], identity: [0] },
925
+ // The eleventh case of the runtime's bone switch, and the one that keys no
926
+ // number: `InheritTimeline` sets `pose.inherit` at the key and
927
+ // `updateWorldTransform` reads it that frame. Resolved by the SAME call the
928
+ // setup field is (`resolveBoneInherit`), so the five a key may name are the
929
+ // five a bone may rest in and a refusal prints the one list (issue #733).
930
+ inherit: { fields: [], identity: [], named: { field: 'inherit', resolve: resolveBoneInherit, known: BONE_INHERIT_KNOWN } },
901
931
  };
902
932
 
903
933
  /**
@@ -1505,6 +1535,11 @@ interface AtlasSource {
1505
1535
  parsed: ParsedAtlas;
1506
1536
  /** Trimmed region name -> the region, first occurrence wins (as `findRegion` does). */
1507
1537
  byName: Map<string, { region: AtlasRegion; page: ParsedAtlas['pages'][number] }>;
1538
+ /**
1539
+ * Page name -> what its file is against what the atlas declares, for every
1540
+ * page on disk whose PNG is not the declared size, in page order (issue #750).
1541
+ */
1542
+ grids: Map<string, NonNullable<CompiledImage['pageGrid']>>;
1508
1543
  }
1509
1544
 
1510
1545
  function readAtlasIn(path: string): AtlasSource {
@@ -1527,12 +1562,28 @@ function readAtlasIn(path: string): AtlasSource {
1527
1562
  // one sentence, rather than one region at a time or at `A06` after the whole
1528
1563
  // compile. A page that is not on disk is left to the region that sits on it,
1529
1564
  // which already names it; saying so twice would be two refusals of one fact.
1565
+ //
1566
+ // 📐 **What each page MEASURES, read in the same pass and not refused here**
1567
+ // (issue #750). A page whose PNG is not the size the atlas declares is a
1568
+ // file every runtime still draws — the region mapping is a fraction of the
1569
+ // declared size — and the only readers it breaks are the ones that address
1570
+ // it in texels. Refusing it here would refuse every pack that has one,
1571
+ // including a pack of region attachments nothing reads a texel of, and on
1572
+ // `build` it would replace `A06`'s verdict with a compile error for the same
1573
+ // fact: `A06` already refuses the pack, names the ratio and prints the
1574
+ // `scale:` repair, before anything is written. So the page is RECORDED, as
1575
+ // `pageGridSentence` states it, and the readers that would take a figure off
1576
+ // its texels decline to on the part that asks (`partTexels` below).
1530
1577
  const unread: string[] = [];
1578
+ const grids: AtlasSource['grids'] = new Map();
1531
1579
  for (const page of parsed.pages) {
1532
1580
  const abs = resolve(dirname(path), page.name);
1533
1581
  if (!existsSync(abs)) continue;
1534
- const { problem } = readPngHeader(abs);
1582
+ const { info, problem } = readPngHeader(abs);
1535
1583
  if (problem !== null) unread.push(`page "${page.name}": ${problem}`);
1584
+ if (info === null) continue;
1585
+ const sentence = pageGridSentence(page, info, page.regions);
1586
+ if (sentence !== null) grids.set(page.name, { said: pageGridSaid(page, info), sentence });
1536
1587
  }
1537
1588
  if (unread.length > 0) {
1538
1589
  throw new CompileError(
@@ -1540,7 +1591,7 @@ function readAtlasIn(path: string): AtlasSource {
1540
1591
  `nothing was compiled against the pack — ${unread.join('; ')}`,
1541
1592
  );
1542
1593
  }
1543
- return { path, dir: dirname(path), parsed, byName };
1594
+ return { path, dir: dirname(path), parsed, byName, grids };
1544
1595
  }
1545
1596
 
1546
1597
  /**
@@ -1634,6 +1685,7 @@ function resolveFromAtlas(
1634
1685
  const info = readPngInfo(absPath);
1635
1686
  const page = relative(outDir, absPath).split('\\').join('/');
1636
1687
  const scale = found.page.scale;
1688
+ const grid = atlas.grids.get(found.page.name);
1637
1689
  return {
1638
1690
  region,
1639
1691
  page,
@@ -1647,6 +1699,7 @@ function resolveFromAtlas(
1647
1699
  isBase,
1648
1700
  atlas: found.region,
1649
1701
  ...(scale === 1 ? {} : { atlasScale: scale }),
1702
+ ...(grid === undefined ? {} : { pageGrid: grid }),
1650
1703
  };
1651
1704
  }
1652
1705
 
@@ -1827,8 +1880,17 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
1827
1880
  /** The pre-packed atlas `--atlas-in` named, parsed once, or null. */
1828
1881
  const atlasIn = opts.atlasInPath === undefined ? null : readAtlasIn(resolve(opts.atlasInPath));
1829
1882
 
1830
- const addImage = (relPath: string, baseDir: string, isBase: boolean): CompiledImage => {
1831
- const region = basename(relPath, '.png');
1883
+ const addImage = (
1884
+ relPath: string,
1885
+ baseDir: string,
1886
+ isBase: boolean,
1887
+ region: string = basename(relPath, '.png'),
1888
+ /**
1889
+ * The directory this part counts as named in, for `skeletonImagesPath`, or
1890
+ * `null` for none. Default: the file's own directory, as it always was.
1891
+ */
1892
+ partDir: string | null = dirname(resolve(baseDir, relPath)),
1893
+ ): CompiledImage => {
1832
1894
  if (seenRegions.has(region)) {
1833
1895
  throw new CompileError(`duplicate region name "${region}" (${relPath})`);
1834
1896
  }
@@ -1839,7 +1901,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
1839
1901
  : resolveFromAtlas(relPath, region, isBase, outDir, atlasIn);
1840
1902
  seenRegions.add(region);
1841
1903
  regionSource.set(region, loosePath);
1842
- partDirs.add(dirname(loosePath));
1904
+ if (partDir !== null) partDirs.add(partDir);
1843
1905
  images.push(img);
1844
1906
  return img;
1845
1907
  };
@@ -1885,6 +1947,63 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
1885
1947
  );
1886
1948
  };
1887
1949
 
1950
+ /**
1951
+ * Measure and atlas every frame of one attachment's `sequence` — by NAME, the
1952
+ * way the loader will ask for them (issue #729).
1953
+ *
1954
+ * Frame `i` is the region `sequenceFrameRegion(stem, …, i)`; on the loose
1955
+ * route its pixels are the PNG of that name under the images directory, and
1956
+ * under `--atlas-in` they are the pack's region of that name. A frame that is
1957
+ * not there is refused with its number and the name looked for: the loader's
1958
+ * own miss is `Region not found in atlas: <name> (attachment: <name>)`, which
1959
+ * says neither that the region was frame 3 of a series nor which series.
1960
+ *
1961
+ * ⚠️ The region is the frame's FULL name, not a basename — an editor names a
1962
+ * series in an images subfolder `fx/flame_0001`, and that string is what the
1963
+ * loader looks up. A frame another attachment already atlased from the same
1964
+ * file is the same region on purpose, as `addSkinImage` allows for one PNG.
1965
+ */
1966
+ const addSequenceFrames = (stem: string, seq: RigSequence, where: string): void => {
1967
+ for (let i = 0; i < seq.count; i++) {
1968
+ const region = sequenceFrameRegion(stem, seq, i);
1969
+ const relPath = `${region}.png`;
1970
+ const wanted = resolve(imagesDir, relPath);
1971
+ const frame = `frame ${i} of ${seq.count} (number ${(seq.start ?? 1) + i})`;
1972
+ if (atlasIn === null && !existsSync(wanted)) {
1973
+ throw new CompileError(
1974
+ `${where}: sequence ${frame} is the region "${region}", and there is no PNG for it at ${wanted}. A ` +
1975
+ `sequence's frames are the regions "<stem><start + i>", zero-padded to "digits" — here stem ` +
1976
+ `${JSON.stringify(stem)}, start ${seq.start ?? 1}, digits ${seq.digits ?? 0} — and the compiler draws ` +
1977
+ 'no frame in place of another. Add the file, or state the "count" the series really has.',
1978
+ );
1979
+ }
1980
+ if (atlasIn !== null && !atlasIn.byName.has(region)) {
1981
+ const near = nearMisses(region, atlasIn.byName.keys());
1982
+ throw new CompileError(
1983
+ `${where}: sequence ${frame} is the region "${region}", which the atlas at ${atlasIn.path} does not have. ` +
1984
+ (near.length ? `Did you mean ${near.map((n) => JSON.stringify(n)).join(', ')}? ` : '') +
1985
+ `A sequence's frames are the regions "<stem><start + i>", zero-padded to "digits" — here stem ` +
1986
+ `${JSON.stringify(stem)}, start ${seq.start ?? 1}, digits ${seq.digits ?? 0} — and the compiler draws ` +
1987
+ 'no frame in place of another.',
1988
+ );
1989
+ }
1990
+ const already = regionSource.get(region);
1991
+ if (already === wanted) continue;
1992
+ if (already !== undefined) {
1993
+ throw new CompileError(
1994
+ `${where}: sequence ${frame} is the region "${region}", and the art already atlased under that name is ` +
1995
+ `${already}, not ${wanted}. One region name is one set of pixels, so this frame would draw the other ` +
1996
+ "file's. Rename one of them.",
1997
+ );
1998
+ }
1999
+ // The directory the frame NAMES are relative to — the images root, not
2000
+ // the frame's own subfolder, because the region name carries the folder —
2001
+ // and only on the loose route: under `--atlas-in` the spec names no file
2002
+ // for a frame, where an `image` is still a file the spec names.
2003
+ addImage(relPath, imagesDir, false, region, atlasIn === null ? imagesDir : null);
2004
+ }
2005
+ };
2006
+
1888
2007
  // A manifest may name a part the cut does not carry. A formation can declare
1889
2008
  // more slots than any one cut fills, and a cut that shares a sprite with the
1890
2009
  // scene around it has no plate of its own to point at — the manifest then
@@ -2072,6 +2191,19 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2072
2191
  if (typeof image === 'string') {
2073
2192
  addSkinImage(image, `skin "${skinName}" slot "${slotName}" attachment "${placeholder}"`);
2074
2193
  }
2194
+ // `parseRigSpec` has refused a sequence on any kind but these three and
2195
+ // proved its shape, so what is left is whether its frames exist. The stem
2196
+ // is the region `path` the attachment resolves through, which with no
2197
+ // `path` stated is the placeholder (`nameSkinAttachment` pins exactly that
2198
+ // where a placeholder is contested).
2199
+ const sequence = (att as RigRegionAttachment).sequence;
2200
+ if (sequence !== undefined) {
2201
+ addSequenceFrames(
2202
+ (att as RigRegionAttachment).path ?? placeholder,
2203
+ sequence,
2204
+ `skin "${skinName}" slot "${slotName}" attachment "${placeholder}"`,
2205
+ );
2206
+ }
2075
2207
  }
2076
2208
  rigAttachmentNames.set(slotName, names);
2077
2209
  }
@@ -2395,6 +2527,16 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2395
2527
  /** kind -> the names it declares, for the "the rig declares: …" half of one. */
2396
2528
  const constraintNamesOfKind = new Map<string, string[]>();
2397
2529
  const declareConstraint = (name: string, type: string): void => {
2530
+ // Reserved, because a track's `"physics": "*"` is the timeline that names no
2531
+ // constraint (issue #726): a physics constraint of that name is one no track
2532
+ // could key by name without meaning every global one instead.
2533
+ if (type === 'physics' && name === EVERY_GLOBAL_PHYSICS) {
2534
+ throw new CompileError(
2535
+ `physics constraint "${EVERY_GLOBAL_PHYSICS}": the name is reserved — a motion spec's \`"physics": ` +
2536
+ `"${EVERY_GLOBAL_PHYSICS}"\` is the timeline that names no constraint and drives every physics constraint ` +
2537
+ 'declaring the keyed property global, so a constraint called that could not be keyed by name. Rename it',
2538
+ );
2539
+ }
2398
2540
  constraintDeclared.add(constraintAt(type, name));
2399
2541
  constraintKinds.set(name, [...(constraintKinds.get(name) ?? []), type]);
2400
2542
  constraintNamesOfKind.set(type, [...(constraintNamesOfKind.get(type) ?? []), name]);
@@ -2568,7 +2710,40 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2568
2710
  const perMember = resolveMemberTrack(track, animName, targets, bones, trackDerivations);
2569
2711
  targets.forEach((target, index) => {
2570
2712
  const resolved = perMember.get(target)!;
2571
- if (family !== null) {
2713
+ // The timeline that names no constraint (issue #726). Resolved against
2714
+ // the constraints' own `…Global` flags rather than a name, and refused
2715
+ // when it reaches none — the runtime would walk every physics
2716
+ // constraint, skip each one, and the file would parse and do nothing.
2717
+ const everyGlobal = family === 'physics' && target === EVERY_GLOBAL_PHYSICS;
2718
+ if (everyGlobal && track.physics === undefined) {
2719
+ throw new CompileError(
2720
+ `animation "${animName}": group "${String(track.group)}" lists "${EVERY_GLOBAL_PHYSICS}", which is not a ` +
2721
+ `constraint but the target that names none — write it as the track's \`"physics": "${EVERY_GLOBAL_PHYSICS}"\``,
2722
+ );
2723
+ }
2724
+ if (everyGlobal) {
2725
+ const physics = constraints.filter((one) => one.type === 'physics');
2726
+ const flag = `${track.property}Global`;
2727
+ const reached =
2728
+ track.property === 'reset'
2729
+ ? physics
2730
+ : physics.filter((one) => one[flag] === true);
2731
+ if (reached.length === 0) {
2732
+ throw new CompileError(
2733
+ `animation "${animName}" keys physics "${EVERY_GLOBAL_PHYSICS}" ${track.property}, the timeline that ` +
2734
+ `names no constraint and drives every physics constraint ` +
2735
+ (track.property === 'reset' ? 'the rig has' : `declaring "${flag}": true`) +
2736
+ ', and ' +
2737
+ (physics.length === 0
2738
+ ? 'the rig declares no physics constraint'
2739
+ : `none of ${physics.map((one) => `"${one.name}"`).join(', ')} does`) +
2740
+ ' — it would parse and move nothing. ' +
2741
+ (track.property === 'reset'
2742
+ ? 'Declare a physics constraint, or remove the track'
2743
+ : `Set "${flag}": true on the constraints it is for, or key one by name`),
2744
+ );
2745
+ }
2746
+ } else if (family !== null) {
2572
2747
  const label = CONSTRAINT_TRACK_FAMILIES[family].label;
2573
2748
  // Resolved by name AND by type in the parser
2574
2749
  // (`findConstraint(name, PathConstraintData)`), which returns null on
@@ -2650,7 +2825,8 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2650
2825
  ? compileValueTrack(resolved, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
2651
2826
  : compileTrack(resolved, motion, animName, anim.duration, target, shift, attachmentIndex, darkSlots);
2652
2827
  for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
2653
- if (family !== null) (familyTimelines[family][target] ??= {})[track.property] = keys;
2828
+ // Under the empty name, which is the file's own spelling of it.
2829
+ if (family !== null) (familyTimelines[family][everyGlobal ? '' : target] ??= {})[track.property] = keys;
2654
2830
  else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
2655
2831
  else (slotTimelines[target] ??= {})[track.property] = keys;
2656
2832
  });
@@ -2759,6 +2935,43 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2759
2935
  ((deformTimelines[skinName] ??= {})[track.slot] ??= {})[track.attachment] = { deform: keys };
2760
2936
  }
2761
2937
 
2938
+ // -- sequence timelines: the other attachment timeline (issue #729) -----
2939
+ // Same triple, same table: `readAnimation` reads both names out of one
2940
+ // attachment map, and one attachment may carry both. `deform` is written
2941
+ // first where both exist, which is the order the parser tests them in.
2942
+ const sequenceTracks: MotionSequenceTrack[] = anim.sequence ?? [];
2943
+ for (const track of sequenceTracks) {
2944
+ const skinName = track.skin ?? 'default';
2945
+ const at = `animation "${animName}" sequence ${skinName}/${String(track.slot)}/${String(track.attachment)}`;
2946
+ const table = skinTables.get(skinName);
2947
+ if (!table) {
2948
+ throw new CompileError(
2949
+ `${at}: this rig emits no skin called "${skinName}" (it emits: ${[...skinTables.keys()].join(', ')})`,
2950
+ );
2951
+ }
2952
+ const perSlot = table[track.slot];
2953
+ if (!perSlot) {
2954
+ throw new CompileError(
2955
+ `${at}: skin "${skinName}" gives slot "${String(track.slot)}" no attachments` +
2956
+ (slotNames.has(track.slot) ? '' : ', and this rig does not declare that slot at all'),
2957
+ );
2958
+ }
2959
+ const attachment = perSlot[track.attachment];
2960
+ if (!attachment) {
2961
+ throw new CompileError(
2962
+ `${at}: slot "${track.slot}" in skin "${skinName}" has no attachment "${String(track.attachment)}" ` +
2963
+ `(it has: ${Object.keys(perSlot).join(', ')})`,
2964
+ );
2965
+ }
2966
+ const keys = compileSequenceTrack(track, anim.duration, attachment, at);
2967
+ for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
2968
+ const slot = ((deformTimelines[skinName] ??= {})[track.slot] ??= {});
2969
+ if (slot[track.attachment]?.sequence) {
2970
+ throw new CompileError(`${at}: two sequence timelines on one attachment; merge them into one`);
2971
+ }
2972
+ slot[track.attachment] = { ...(slot[track.attachment] ?? {}), sequence: keys };
2973
+ }
2974
+
2762
2975
  const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
2763
2976
  if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
2764
2977
 
@@ -2887,6 +3100,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2887
3100
  skeletonText: `${JSON.stringify(skeleton, null, 2)}\n`,
2888
3101
  atlasText,
2889
3102
  images,
3103
+ pageGrids: atlasIn === null ? [] : [...atlasIn.grids].map(([page, grid]) => ({ page, said: grid.said })),
2890
3104
  droppedStates,
2891
3105
  absentParts,
2892
3106
  declaredDurations,
@@ -3060,8 +3274,14 @@ function skinAttachmentName(skinName: string, placeholder: string): string {
3060
3274
  * By the time either caller runs, a contested placeholder the **default** skin
3061
3275
  * fills has already been refused — see `refuseDefaultSkinContest` — so every
3062
3276
  * entry this composes for is a named skin's.
3277
+ *
3278
+ * 🔒 A third caller reads it from outside: `ingest` compares the name a source
3279
+ * states against the one this returns, and reports the rename where the two
3280
+ * differ (issue #746). It calls this rather than `skinAttachmentName` so that
3281
+ * WHETHER a name is composed is read off the same line as WHAT it is — the
3282
+ * separator and the contest test are each stated once, here.
3063
3283
  */
3064
- function composeSkinAttachmentName(skinName: string, placeholder: string, contested: boolean): string | null {
3284
+ export function composeSkinAttachmentName(skinName: string, placeholder: string, contested: boolean): string | null {
3065
3285
  return contested ? skinAttachmentName(skinName, placeholder) : null;
3066
3286
  }
3067
3287
 
@@ -3867,12 +4087,120 @@ function attachmentPath(att: { path?: string; image?: string }, placeholder: str
3867
4087
  return region === placeholder ? undefined : region;
3868
4088
  }
3869
4089
 
4090
+ /**
4091
+ * The atlas region frame `i` of a sequence resolves to — `Sequence.getPath`
4092
+ * (`Sequence.js:124-132`) transcribed: the stem, then `start + i` left-padded
4093
+ * with zeros to `digits`. `start` and `digits` take the parser's own defaults
4094
+ * (`readSequence`: 1 and 0), which are the format's, not a guess.
4095
+ *
4096
+ * ⚠️ A second transcription of the rule exists in `validate.ts`
4097
+ * (`attachmentRegionLookups`, the walk `A08` joins the atlas with), and that is
4098
+ * deliberate: this module links no runtime, and the two are held to the loader
4099
+ * independently — the one by `A08`, both by `A00`'s round trip, which asks the
4100
+ * runtime's own `getPath` for every frame.
4101
+ */
4102
+ function sequenceFrameRegion(stem: string, seq: RigSequence, i: number): string {
4103
+ const frame = String((seq.start ?? 1) + i);
4104
+ return `${stem}${'0'.repeat(Math.max(0, (seq.digits ?? 0) - frame.length))}${frame}`;
4105
+ }
4106
+
4107
+ /** The emitted `sequence` block: the four fields exactly as the spec stated them. */
4108
+ function emitSequence(seq: RigSequence): SpineSequence {
4109
+ const out: SpineSequence = { count: seq.count };
4110
+ if (seq.start !== undefined) out.start = seq.start;
4111
+ if (seq.digits !== undefined) out.digits = seq.digits;
4112
+ if (seq.setup !== undefined) out.setup = seq.setup;
4113
+ return out;
4114
+ }
4115
+
4116
+ /**
4117
+ * What a sequence attachment's frames measure, for the `width`/`height` a spec
4118
+ * leaves out — or the refusal when they do not agree.
4119
+ *
4120
+ * An attachment has ONE size and every frame is mapped into it
4121
+ * (`RegionAttachment.computeUVs(regions[i], … width, height …)` for each `i` in
4122
+ * `Sequence.update`), so an omitted size is derivable only when every frame
4123
+ * measures the same: that is the one number the frames state. Frames of
4124
+ * different sizes state several, and picking one — the first, the setup frame,
4125
+ * the largest — would be the compiler choosing a value the spec did not.
4126
+ * Under `--atlas-in` a stated size that disagrees with a frame is refused as it
4127
+ * is for a single region, for the same reason: the pack's rectangle is fixed.
4128
+ */
4129
+ function sequenceFrameSize(
4130
+ frames: readonly CompiledImage[],
4131
+ stated: { width?: number; height?: number },
4132
+ where: string,
4133
+ ): { width?: number; height?: number } {
4134
+ const out: { width?: number; height?: number } = {};
4135
+ for (const field of ['width', 'height'] as const) {
4136
+ const sizes = [...new Set(frames.map((img) => img[field]))];
4137
+ if (stated[field] !== undefined) {
4138
+ const packed = frames.find((img) => img.atlas !== undefined && img[field] !== stated[field]);
4139
+ if (packed !== undefined) {
4140
+ throw new CompileError(
4141
+ `${where}: the spec says ${field} ${stated[field]} and sequence frame "${packed.region}" of the imported ` +
4142
+ `atlas is ${packed[field]}; a packed frame's rectangle is fixed, so the two would produce a quad the ` +
4143
+ 'pack cannot fill',
4144
+ );
4145
+ }
4146
+ out[field] = stated[field];
4147
+ } else if (sizes.length === 1) {
4148
+ out[field] = sizes[0];
4149
+ } else {
4150
+ throw new CompileError(
4151
+ `${where}: the ${frames.length} frames of this sequence measure ${sizes.join(', ')} in ${field} ` +
4152
+ `(${frames.map((img) => `"${img.region}" ${img[field]}`).join(', ')}), and an attachment has one ` +
4153
+ `${field} every frame is drawn into. State "${field}" — the frames do not agree on one`,
4154
+ );
4155
+ }
4156
+ }
4157
+ return out;
4158
+ }
4159
+
4160
+ /** Every frame of `att`'s sequence, already atlased by the gather pass. */
4161
+ function sequenceFrames(att: { path?: string; sequence?: RigSequence }, placeholder: string, where: string, ctx: AttachmentContext): CompiledImage[] {
4162
+ const seq = att.sequence!;
4163
+ const stem = att.path ?? placeholder;
4164
+ const frames: CompiledImage[] = [];
4165
+ for (let i = 0; i < seq.count; i++) {
4166
+ // By the frame's FULL region name — `atlasedImage` takes a basename, and a
4167
+ // series in an images subfolder is named `fx/flame_0001`.
4168
+ const region = sequenceFrameRegion(stem, seq, i);
4169
+ const img = ctx.images.find((im) => im.region === region);
4170
+ if (img === undefined) {
4171
+ // Unreachable while the gather pass atlases every frame of every sequence
4172
+ // or refuses the missing one by name; stated so that a gather that stops
4173
+ // doing so is a sentence rather than a quad with no region.
4174
+ throw new CompileError(
4175
+ `${where}: sequence frame ${i} is the region "${region}", which was never added to the atlas — every frame ` +
4176
+ 'of a sequence is atlased or refused by name before any attachment is built, so reaching this means rigc ' +
4177
+ 'skipped one',
4178
+ );
4179
+ }
4180
+ frames.push(img);
4181
+ }
4182
+ return frames;
4183
+ }
4184
+
3870
4185
  function buildRigRegion(
3871
4186
  att: RigRegionAttachment,
3872
4187
  placeholder: string,
3873
4188
  where: string,
3874
4189
  ctx: AttachmentContext,
3875
4190
  ): SpineRegionAttachment {
4191
+ if (att.sequence !== undefined) {
4192
+ const size = sequenceFrameSize(sequenceFrames(att, placeholder, where, ctx), att, where);
4193
+ const out: SpineRegionAttachment = { width: r6(size.width!), height: r6(size.height!) };
4194
+ if (att.path !== undefined) out.path = att.path;
4195
+ if (att.x !== undefined) out.x = r6(att.x);
4196
+ if (att.y !== undefined) out.y = r6(att.y);
4197
+ if (att.rotation !== undefined) out.rotation = r6(att.rotation);
4198
+ if (att.scaleX !== undefined) out.scaleX = r6(att.scaleX);
4199
+ if (att.scaleY !== undefined) out.scaleY = r6(att.scaleY);
4200
+ if (att.color !== undefined) out.color = att.color;
4201
+ out.sequence = emitSequence(att.sequence);
4202
+ return out;
4203
+ }
3876
4204
  const img = att.image === undefined ? null : atlasedImage(att.image, where, ctx);
3877
4205
  // ⭐ An IMPORTED region's size is not a default the spec may override. On the
3878
4206
  // loose path `att.width` and the PNG's width are two legitimate numbers — "draw
@@ -3986,11 +4314,20 @@ function encodeNamedWeights(weights: RigMeshBinding[][], where: string, ctx: Att
3986
4314
  * what `extractRegion` does, so there is no unmeasurable case left for this
3987
4315
  * function to report and no catch here to keep reachable.
3988
4316
  */
3989
- function measureAuthoredFit(att: RigMeshAttachment, ctx: AttachmentContext): MeshFitReport | null {
4317
+ function measureAuthoredFit(
4318
+ att: RigMeshAttachment,
4319
+ ctx: AttachmentContext,
4320
+ ): MeshFitReport | { withheld: string } | null {
3990
4321
  if (att.image === undefined || att.uvs === undefined || att.triangles === undefined) return null;
3991
4322
  const region = basename(att.image, '.png');
3992
4323
  const img = ctx.images.find((im) => im.region === region);
3993
4324
  if (!img) return null;
4325
+ // A third case, and neither of the two above (issue #750): there IS art to
4326
+ // compare against, and its texels cannot be located, because its page's file
4327
+ // is not the size the atlas declares. Withheld and said, rather than measured
4328
+ // over whatever the declared coordinates land on — on a half-resolution pack
4329
+ // that read 68.49% where the page it was packed from reads 100.00%.
4330
+ if (img.pageGrid !== undefined) return { withheld: img.pageGrid.said };
3994
4331
  const plate = partPlate(img);
3995
4332
  const alpha = new Uint8Array(plate.width * plate.height);
3996
4333
  for (let i = 0; i < alpha.length; i++) alpha[i] = plate.data[i * 4 + 3];
@@ -4134,8 +4471,9 @@ function buildRigMesh(
4134
4471
  // spec stated, and the editor shows whatever is written here as the image's
4135
4472
  // dimensions (it showed 32x32, its missing-image placeholder, for a 0x0 mesh).
4136
4473
  const img = att.image === undefined ? undefined : atlasedImage(att.image, where, ctx);
4137
- const width = att.width ?? img?.width;
4138
- const height = att.height ?? img?.height;
4474
+ const seqSize = att.sequence === undefined ? null : sequenceFrameSize(sequenceFrames(att, placeholder, where, ctx), att, where);
4475
+ const width = seqSize !== null ? seqSize.width : (att.width ?? img?.width);
4476
+ const height = seqSize !== null ? seqSize.height : (att.height ?? img?.height);
4139
4477
  if (width === undefined || height === undefined) {
4140
4478
  throw new CompileError(
4141
4479
  `${where}: a mesh needs width and height — give them, or give an "image" and rigc will measure the PNG`,
@@ -4154,12 +4492,14 @@ function buildRigMesh(
4154
4492
  const path = attachmentPath(att, placeholder);
4155
4493
  if (path !== undefined) out.path = path;
4156
4494
  if (att.color !== undefined) out.color = att.color;
4495
+ if (att.sequence !== undefined) out.sequence = emitSequence(att.sequence);
4157
4496
  // Register it as `authored`: geometry rigc did not build and whose topology it
4158
4497
  // therefore gets to assume nothing about. The generator-topology assertions
4159
4498
  // read this and skip rather than measuring a ring that was never a ring.
4160
4499
  ctx.meshBones.add(ctx.anchorBone);
4161
4500
  for (const name of boundBones) ctx.meshBones.add(name);
4162
- const fit = measureAuthoredFit(att, ctx);
4501
+ const measured = measureAuthoredFit(att, ctx);
4502
+ const fit = measured === null || 'withheld' in measured ? null : measured;
4163
4503
  ctx.meshes.push({
4164
4504
  slot: ctx.slotName,
4165
4505
  kind: 'authored',
@@ -4169,6 +4509,7 @@ function buildRigMesh(
4169
4509
  bones: boundBones.length ? boundBones : [ctx.anchorBone],
4170
4510
  coverage: fit === null ? undefined : r6(fit.coverage),
4171
4511
  overshoot: fit?.overshoot,
4512
+ ...(measured !== null && 'withheld' in measured ? { fitWithheld: measured.withheld } : {}),
4172
4513
  });
4173
4514
  return out;
4174
4515
  }
@@ -4248,8 +4589,9 @@ function buildRigLinkedMesh(
4248
4589
  // The art side is a mesh's, unchanged: a link draws its OWN region, which is
4249
4590
  // the reason the type exists — one triangulation, one outfit's pixels each.
4250
4591
  const img = att.image === undefined ? undefined : atlasedImage(att.image, where, ctx);
4251
- const width = att.width ?? img?.width;
4252
- const height = att.height ?? img?.height;
4592
+ const seqSize = att.sequence === undefined ? null : sequenceFrameSize(sequenceFrames(att, placeholder, where, ctx), att, where);
4593
+ const width = seqSize !== null ? seqSize.width : (att.width ?? img?.width);
4594
+ const height = seqSize !== null ? seqSize.height : (att.height ?? img?.height);
4253
4595
  if (width === undefined || height === undefined) {
4254
4596
  throw new CompileError(
4255
4597
  `${where}: a linked mesh needs width and height — give them, or give an "image" and rigc will measure the PNG`,
@@ -4265,6 +4607,7 @@ function buildRigLinkedMesh(
4265
4607
  if (att.skin !== undefined && att.skin !== 'default') out.skin = att.skin;
4266
4608
  if (att.timelines === false) out.timelines = false;
4267
4609
  if (att.color !== undefined) out.color = att.color;
4610
+ if (att.sequence !== undefined) out.sequence = emitSequence(att.sequence);
4268
4611
  // 🚫 NOT registered in `ctx.meshes`, and that is a decision rather than an
4269
4612
  // omission. `meshKinds` is keyed by SLOT and the commonest linked mesh shares
4270
4613
  // its source's slot from another skin, so an entry here would overwrite the
@@ -4526,8 +4869,13 @@ function sampleMeshDepth(
4526
4869
  *
4527
4870
  * The two are different questions and the header says why. This one has no
4528
4871
  * refusal behind it: it is counted, reported, and left to the author.
4872
+ *
4873
+ * `unlocated` in its place when the part's texels cannot be located — a
4874
+ * region lifted off a page whose file is not its declared size (issue #750),
4875
+ * carrying `pageGridSaid`'s clause — and the count is then `null` with that
4876
+ * clause beside it: withheld, rather than taken off another part of the page.
4529
4877
  */
4530
- partAlpha: Uint8Array,
4878
+ partAlpha: Uint8Array | { unlocated: string },
4531
4879
  partWidth: number,
4532
4880
  partHeight: number,
4533
4881
  where: string,
@@ -4587,7 +4935,7 @@ function sampleMeshDepth(
4587
4935
  // Zero, not a threshold: "the part image draws nothing here" is a fact
4588
4936
  // about the file, and any other cut-off would be rigc deciding how faint
4589
4937
  // a texel has to be before it stops counting as art.
4590
- if (partAlpha[at] === 0) drawn = false;
4938
+ if (partAlpha instanceof Uint8Array && partAlpha[at] === 0) drawn = false;
4591
4939
  }
4592
4940
  if (!opaqueEverywhere && !covered) uncovered.push(v);
4593
4941
  if (!drawn) undrawn++;
@@ -4632,7 +4980,7 @@ function sampleMeshDepth(
4632
4980
  zScale: spec.zScale,
4633
4981
  tone,
4634
4982
  range: [r6(lo), r6(hi)],
4635
- undrawn,
4983
+ ...(partAlpha instanceof Uint8Array ? { undrawn } : { undrawn: null, unlocated: partAlpha.unlocated }),
4636
4984
  ceiling: turnCeiling(
4637
4985
  points.map(([px, py]) => toBind(px, py)),
4638
4986
  z,
@@ -4870,7 +5218,11 @@ function buildGridAttachment(
4870
5218
  geometry.points,
4871
5219
  geometry.triangles,
4872
5220
  (px, py) => toBoneLocal(anchor, anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt),
4873
- plateAlpha(plate),
5221
+ // The lattice's geometry takes nothing off the texels — its window is
5222
+ // the plate's size, which is the atlas's own `offsets` — so a page
5223
+ // whose file is not its declared size costs a grid only this one
5224
+ // count, and the count is what is withheld (issue #750).
5225
+ img.pageGrid === undefined ? plateAlpha(plate) : { unlocated: img.pageGrid.said },
4874
5226
  plate.width,
4875
5227
  plate.height,
4876
5228
  where,
@@ -4959,6 +5311,24 @@ function buildContourAttachment(
4959
5311
  );
4960
5312
  }
4961
5313
  const img = atlasedImage(att.image, where, ctx);
5314
+ // 🔒 **A trace needs the part's texels, and on a page that is not its
5315
+ // declared size the lift does not have them** (issue #750). `extractRegion`
5316
+ // addresses the page at the coordinates the atlas states; on a file of any
5317
+ // other size those are a different part of the picture — measured on a
5318
+ // half-resolution pack, the lift came back with no pixel above alpha 0 and
5319
+ // the build refused with "there is no silhouette to trace", a true sentence
5320
+ // about the wrong texels. Unlike an authored mesh's fit, which is a
5321
+ // measurement and is withheld, this outline IS the geometry: there is no mesh
5322
+ // to build without it, and the compiler invents none. So it is refused, with
5323
+ // `A06`'s own sentence, which names the ratio and the repair — on `build`
5324
+ // this refusal arrives before the gate, where `A06` would have said it.
5325
+ if (img.pageGrid !== undefined) {
5326
+ throw new CompileError(
5327
+ `${where}: a "contour" generator traces the part's own alpha, and "${att.image}" is lifted off a packed ` +
5328
+ 'page at the coordinates the atlas states, which on this file are not where its texels are — so there is ' +
5329
+ `no silhouette here to trace, only another part of the page. ${img.pageGrid.sentence}`,
5330
+ );
5331
+ }
4962
5332
  // ⚠️ Nothing here reads the PNG's colour type. `hasAlpha` answers "where does
4963
5333
  // this file keep its alpha", not "is any pixel of it transparent" — a tRNS
4964
5334
  // chunk is real transparency (issue #215) and an all-255 alpha channel is
@@ -5968,6 +6338,26 @@ function compileValueTrack(
5968
6338
  throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
5969
6339
  }
5970
6340
  checkKeyTime(where, time, key.t, duration);
6341
+ // A named-value timeline (`inherit`): the key's `v` is one name, stepped by
6342
+ // the format. The name is resolved rather than copied, and what is WRITTEN
6343
+ // is the resolver's spelling — so `NoScale`, which the runtime also reads,
6344
+ // emits as the `noScale` an editor writes, and a spelling the runtime
6345
+ // cannot resolve never reaches the file at all.
6346
+ if (shape.named !== undefined) {
6347
+ if (key.ease !== undefined || key.curve !== undefined) {
6348
+ throw new CompileError(
6349
+ `${where} key at t=${key.t} carries ${key.ease !== undefined ? `an easing ("${key.ease}")` : 'a curve'}, and ` +
6350
+ `this timeline is stepped by the format — its reader builds no curve, so the mode changes AT the key and ` +
6351
+ 'holds until the next one. Remove it',
6352
+ );
6353
+ }
6354
+ const mode = shape.named.resolve(key.v);
6355
+ if (mode === undefined) {
6356
+ throw new CompileError(`${where} key at t=${key.t} names mode ${JSON.stringify(key.v)}; ${shape.named.known}`);
6357
+ }
6358
+ out.push({ time, [shape.named.field]: mode });
6359
+ continue;
6360
+ }
5971
6361
  // A no-field timeline (`reset`) is an event: the key IS the value, so it
5972
6362
  // carries none. Anything else must match the field count exactly.
5973
6363
  if (shape.fields.length === 0) {
@@ -6674,6 +7064,81 @@ function deformGeometryOf(
6674
7064
  * nothing guessed. What survives is the one case the identity does not cover: a
6675
7065
  * vertex whose weights do not close at 1.
6676
7066
  */
7067
+ /**
7068
+ * One sequence timeline — `animations.<a>.attachments.<skin>.<slot>.<attachment>.sequence`
7069
+ * — keyed on an attachment that carries a numbered series (issue #729).
7070
+ *
7071
+ * The key's own shape (`mode` among the seven, a whole `index`, a delay the
7072
+ * parser will not divide by zero) is `parseMotionSpec`'s; what is refused here
7073
+ * needs the emitted attachment:
7074
+ *
7075
+ * - an attachment with no `sequence` block. The parser gives every region and
7076
+ * mesh a one-region series (`readSequence(null)` is `new Sequence(1,
7077
+ * false)`), so the timeline loads and every mode shows that one region —
7078
+ * measured: a `loop` key on a plain region showed it at every time.
7079
+ * - an `index` at or past the series' `count`. `Sequence.resolveIndex` clamps
7080
+ * it to the last frame (measured: `hold` at index 5 of 4 showed frame 4).
7081
+ * - a linked mesh that plays its source's timelines (`timelines` absent or
7082
+ * true). Its `timelineAttachment` is the SOURCE (`SkeletonJson.js:437-448`),
7083
+ * and `SequenceTimeline.applyToSlot` returns unless the slot's attachment's
7084
+ * `timelineAttachment` is the one the timeline was built for — so a key
7085
+ * aimed at the link itself is applied to nothing.
7086
+ *
7087
+ * Fields are emitted exactly as stated: an omitted `mode` is the parser's
7088
+ * `"hold"`, an omitted `index` its 0, an omitted `delay` the previous key's —
7089
+ * none of which the compiler writes for the spec.
7090
+ */
7091
+ function compileSequenceTrack(
7092
+ track: MotionSequenceTrack,
7093
+ duration: number,
7094
+ attachment: SpineAttachment,
7095
+ where: string,
7096
+ ): SpineTimelineKey[] {
7097
+ const type = (attachment as { type?: string }).type ?? 'region';
7098
+ const series = (attachment as { sequence?: SpineSequence }).sequence;
7099
+ if (series === undefined) {
7100
+ throw new CompileError(
7101
+ `${where}: attachment "${track.attachment}" carries no "sequence" block, so there is no series to step. The ` +
7102
+ 'parser gives every region and mesh a series of ONE region (`readSequence(null)` is `new Sequence(1, ' +
7103
+ 'false)`), so this timeline would load and show that region under every mode at every time. Give the ' +
7104
+ 'attachment a "sequence" in the rig spec, or remove the track.',
7105
+ );
7106
+ }
7107
+ if (type === 'linkedmesh' && (attachment as SpineLinkedMeshAttachment).timelines !== false) {
7108
+ const source = (attachment as SpineLinkedMeshAttachment).source;
7109
+ throw new CompileError(
7110
+ `${where}: attachment "${track.attachment}" is a linked mesh that plays its source's timelines ("timelines" ` +
7111
+ `is not false), so its \`timelineAttachment\` is "${source}" (\`SkeletonJson.js:437-448\`) and ` +
7112
+ '`SequenceTimeline` applies only where the slot\'s attachment\'s `timelineAttachment` is the one it was ' +
7113
+ `built for — a key aimed at the link is applied to nothing. Key "${source}" instead (the link steps its own ` +
7114
+ 'series by it), or set "timelines": false on the link.',
7115
+ );
7116
+ }
7117
+ if (track.keys.length === 0) throw new CompileError(`${where}: no keys`);
7118
+ const out: SpineTimelineKey[] = [];
7119
+ for (let i = 0; i < track.keys.length; i++) {
7120
+ const key = track.keys[i];
7121
+ const time = keyTime(key.t);
7122
+ if (i > 0 && time <= (out[i - 1].time as number)) {
7123
+ throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
7124
+ }
7125
+ checkKeyTime(where, time, key.t, duration);
7126
+ if (key.index !== undefined && key.index >= series.count) {
7127
+ throw new CompileError(
7128
+ `${where} (t=${key.t}): index ${key.index} is past the end of a ${series.count}-frame series (frames 0 to ` +
7129
+ `${series.count - 1}). \`Sequence.resolveIndex\` clamps it to the last frame, so the key would start on a ` +
7130
+ 'frame it does not name. `index` is 0-based.',
7131
+ );
7132
+ }
7133
+ const entry: SpineTimelineKey = { time };
7134
+ if (key.mode !== undefined) entry.mode = key.mode;
7135
+ if (key.index !== undefined) entry.index = key.index;
7136
+ if (key.delay !== undefined) entry.delay = r6(key.delay);
7137
+ out.push(entry);
7138
+ }
7139
+ return out;
7140
+ }
7141
+
6677
7142
  function compileDeformTrack(
6678
7143
  track: MotionDeformTrack,
6679
7144
  motion: MotionSpec,