spine-rigc 1.6.1 → 1.7.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/render.ts CHANGED
@@ -81,7 +81,11 @@
81
81
  * is no honest way to render one without it. (Since issue #968 that holds for a
82
82
  * Spine export: a rigc build is posed by rigc's own core through
83
83
  * `./render_core.ts`, and this file keeps the runtime for the export's poser,
84
- * the atlas pages and texture substitution — see *which poser* below.) The rule that matters is unchanged
84
+ * the export's atlas pages and texture substitution — see *which poser* below.
85
+ * Since issue #1014 a rigc build the core poses touches none of it: what a
86
+ * render reads off the candidate besides the pose comes from the skeleton's
87
+ * own JSON and rigc's atlas reader (`loadCandidate`), and spine-core is reached
88
+ * only where it poses or substitutes.) The rule that matters is unchanged
85
89
  * — `src/compile.ts` must stay independent of the runtime so the compiler and
86
90
  * the gate are not checking each other's assumptions — and this file is neither.
87
91
  * It also imports `tools/plate.ts` for the PNG codec, which is dependency-free.
@@ -105,10 +109,11 @@ import {
105
109
  import { existsSync, readFileSync } from 'node:fs';
106
110
  import { dirname, join, resolve } from 'node:path';
107
111
  import { Plate, readPlate, type RGBA } from '../tools/plate.ts';
108
- import { pageFootprint } from './atlas.ts';
112
+ import { pageFootprint, parseAtlasText } from './atlas.ts';
109
113
  import { CoreInputError } from './core/index.ts';
110
114
  import { MODEL_DOCUMENT_FILE } from './model.ts';
111
- import { clipSourceOf, corePoser, inactiveBoneSnapshot, SlotSubsetError, subsetOver, unposedBones } from './render_core.ts';
115
+ import { clipSourceOf, corePoser, coreSkinRoster, inactiveBoneSnapshot, SlotSubsetError, subsetOver, unposedBones, type SubsetRoster } from './render_core.ts';
116
+ import { walkTimelines } from './timelines.ts';
112
117
 
113
118
  /** Opaque, and light: both of rung 3's parts are dark slate, so is every ground. */
114
119
  export const BACKGROUND: RGBA = [232, 232, 232, 255];
@@ -734,6 +739,205 @@ export function posableFromText(skeletonText: string, atlasText: string, atlasDi
734
739
  return { data, pages };
735
740
  }
736
741
 
742
+ // ---------------------------------------------------------------------------
743
+ // what a render and a check read off a candidate besides its pose (issue #1014)
744
+ // ---------------------------------------------------------------------------
745
+ //
746
+ // ⭐ `render` and `check` read a handful of facts off the candidate before and
747
+ // beside the pose: the animation and skin names their flags are checked
748
+ // against, the slot subset's roster, whether a stage is declared, the bone
749
+ // tree `check` draws its chains from, the skin roster the framing reads, and
750
+ // the atlas pages the rasteriser samples. Until issue #1014 every one of them
751
+ // came off the skeleton spine-core had parsed, so a rigc build the core poses
752
+ // still loaded and ran the runtime before its poser was chosen. Now the choice
753
+ // comes first (`loadCandidate`), and a build the core poses reads each fact
754
+ // where it is written: the names, the tree, the subset's roster and the stage
755
+ // off the skeleton's own JSON (`skeletonFacts`), the pages off rigc's atlas
756
+ // reader, and the skin roster off the model document (`coreSkinRoster` in
757
+ // `./render_core.ts`). Every one of those readings was measured equal to the
758
+ // spine-core reading it replaces on every input the tree carries — the
759
+ // nineteen built corpus rows and the twelve editor exports (the PR of #1014
760
+ // carries the counts). A Spine export, `--poser spine` and a fallback the
761
+ // poser line names load spine-core as before and read every fact off it.
762
+
763
+ /** What `render` and `check` read off a candidate's skeleton besides its pose. */
764
+ export interface SkeletonFacts {
765
+ /** Every animation name, in the skeleton's own order. */
766
+ readonly animations: readonly string[];
767
+ /** Every skin name, in the skeleton's own order. */
768
+ readonly skins: readonly string[];
769
+ /** Whether the header states a numeric `width` and `height` — a setup stage (issue #714). */
770
+ readonly declaresStage: boolean;
771
+ /** Every bone in declaration order, and its parent's name. */
772
+ readonly bones: ReadonlyArray<{ name: string; parent: string | null }>;
773
+ /** Every slot in declaration order, and the bone it hangs from. */
774
+ readonly slots: ReadonlyArray<{ name: string; bone: string }>;
775
+ /** `slotSubsetOf` over this skeleton — refused by `SlotSubsetError`. */
776
+ subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
777
+ }
778
+
779
+ /** The facts as spine-core loaded them — an export's reading, and every candidate's before issue #1014. */
780
+ export function spineFacts(data: SkeletonData): SkeletonFacts {
781
+ return {
782
+ animations: data.animations.map((a) => a.name),
783
+ skins: data.skins.map((s) => s.name),
784
+ // `SkeletonJson` copies both header fields across unconditionally, so an omitted extent is `undefined` here, not 0.
785
+ declaresStage: typeof data.width === 'number' && typeof data.height === 'number',
786
+ bones: data.bones.map((bone) => ({ name: bone.name, parent: bone.parent === null ? null : bone.parent.name })),
787
+ slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
788
+ subset: (opts, skin) => slotSubsetOf(data, opts, skin),
789
+ };
790
+ }
791
+
792
+ type JsonObject = Record<string, unknown>;
793
+
794
+ /** A JSON value as an object, or an empty one where it is not one. */
795
+ function objectOf(value: unknown): JsonObject {
796
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonObject) : {};
797
+ }
798
+
799
+ /** A JSON value as a list of objects, or an empty list where it is not one. */
800
+ function objectsOf(value: unknown): JsonObject[] {
801
+ return Array.isArray(value) ? value.map(objectOf) : [];
802
+ }
803
+
804
+ /**
805
+ * The same facts off the skeleton's own JSON — a candidate the core poses
806
+ * (issue #1014), whose skeleton spine-core never loads.
807
+ *
808
+ * Each is the file's own statement, read in the file's order: the animation
809
+ * names are the keys of `animations` (the order the parser iterates them), the
810
+ * skins `skins[].name` (the default skin the one named `default`), a slot's
811
+ * carriers the skins whose `attachments` give it at least one entry, the tree
812
+ * `bones[]` and `slots[]`, and the stage `skeleton.width`/`height`. The core
813
+ * poses only a rigc build whose `skeleton.json` hashes to the digest its model
814
+ * document records, so the file read here is the one `build` wrote and the
815
+ * gate round-tripped.
816
+ */
817
+ export function skeletonFacts(skeletonText: string): SkeletonFacts {
818
+ const root = objectOf(JSON.parse(skeletonText));
819
+ const skins = objectsOf(root.skins);
820
+ const slots = objectsOf(root.slots).map((slot) => ({ name: String(slot.name), bone: String(slot.bone) }));
821
+ const header = objectOf(root.skeleton);
822
+ const roster: SubsetRoster = {
823
+ declared: slots.map((slot) => slot.name),
824
+ carriers: (slot) => skins.filter((skin) => Object.keys(objectOf(objectOf(skin.attachments)[slot])).length > 0).map((skin) => String(skin.name)),
825
+ defaultSkin: skins.some((skin) => skin.name === 'default') ? 'default' : null,
826
+ };
827
+ return {
828
+ animations: Object.keys(objectOf(root.animations)),
829
+ skins: skins.map((skin) => String(skin.name)),
830
+ declaresStage: typeof header.width === 'number' && typeof header.height === 'number',
831
+ bones: objectsOf(root.bones).map((bone) => ({ name: String(bone.name), parent: typeof bone.parent === 'string' ? bone.parent : null })),
832
+ slots,
833
+ subset: (opts, skin) => subsetOver(roster, opts, skin),
834
+ };
835
+ }
836
+
837
+ /**
838
+ * Each animation's duration off the skeleton's own JSON, in the file's order —
839
+ * what `rosterDifference` holds a model document's durations to without
840
+ * loading the skeleton through spine-core (issue #1014).
841
+ *
842
+ * ⚠️ Skeleton JSON carries no duration, so this is the parser's derivation of
843
+ * one, and it is stated as measured rather than as read: the LAST key time of
844
+ * each timeline, the largest of them, held as a float32 — equal to
845
+ * spine-core's `Animation.duration` on every animation of every input the tree
846
+ * carries (the PR of #1014). The timelines are the ones `walkTimelines` walks,
847
+ * the walk `A05` and `A12` stand on, so a group it did not descend would be
848
+ * missing from both.
849
+ */
850
+ function skeletonDurations(root: JsonObject): Array<{ name: string; duration: number }> {
851
+ return Object.entries(objectOf(root.animations)).map(([name, animation]) => {
852
+ let duration = 0;
853
+ walkTimelines({ animations: { [name]: animation } }, (_path, _kind, _timeline, keys) => {
854
+ if (keys.length === 0) return;
855
+ const last = objectOf(keys[keys.length - 1]);
856
+ duration = Math.max(duration, Math.fround(typeof last.time === 'number' ? last.time : 0));
857
+ });
858
+ return { name, duration };
859
+ });
860
+ }
861
+
862
+ /** Every page an atlas declares, read by rigc's own atlas reader — the pages a candidate the core poses is drawn from. */
863
+ function atlasPagesOf(atlasText: string, atlasDir: string): Map<string, Plate> {
864
+ const pages = new Map<string, Plate>();
865
+ for (const page of parseAtlasText(atlasText).pages) pages.set(page.name, readPlate(join(atlasDir, page.name)));
866
+ return pages;
867
+ }
868
+
869
+ /**
870
+ * Posing an input through spine-core when the runtime cannot be reached —
871
+ * refused naming the input and why it needs the runtime (issue #1014).
872
+ *
873
+ * A Spine export, `--poser spine` and a fallback the poser line names are what
874
+ * spine-core is loaded for; a rigc build the core poses reads nothing through
875
+ * it. So the one run that can meet a runtime it cannot use is one of those
876
+ * three, and it says so rather than surfacing whatever the first access threw.
877
+ */
878
+ export class SpineRuntimeError extends Error {}
879
+
880
+ /** Touch the runtime once, before anything is parsed through it, and refuse by name when it cannot be used. */
881
+ function requireSpineRuntime(label: string, why: string): void {
882
+ try {
883
+ // A property read on the class the load starts from: no runtime code runs, and a runtime that cannot be used throws here.
884
+ void TextureAtlas.prototype;
885
+ } catch (err) {
886
+ throw new SpineRuntimeError(
887
+ `${label} is posed through spine-core (${why}), and the runtime could not be used: ${(err as Error).message}. ` +
888
+ 'spine-core is what poses a Spine export, --poser spine and a fallback the poser line names; ' +
889
+ 'a rigc build the core poses reads nothing through it',
890
+ );
891
+ }
892
+ }
893
+
894
+ /** A candidate as `render` and `check` read it: the posers, the facts and the pages (issue #1014). */
895
+ export interface Candidate {
896
+ choice: PoserChoice;
897
+ facts: SkeletonFacts;
898
+ pages: Map<string, Plate>;
899
+ }
900
+
901
+ /**
902
+ * Load a candidate for `render` or `check`, choosing its poser FIRST (issue
903
+ * #1014): a rigc build the core poses reads its facts off its own JSON, its
904
+ * pages off rigc's atlas reader and its skin roster off the model document,
905
+ * and spine-core is never loaded for it; anything else — a Spine export,
906
+ * `--poser spine`, a candidate handed over as text (`paths` null, `unplaced`
907
+ * the reason) — is loaded through spine-core as `posableFromText` always
908
+ * loaded it. The choice's spine-core poser stays unloaded until something
909
+ * reads it, which on a rigc build is a fallback the poser line names.
910
+ *
911
+ * `--poser core` on an input that cannot carry it is NOT refused here: the
912
+ * caller says it where it always has (`refuseUnchosen`), after the flags that
913
+ * refuse before it.
914
+ */
915
+ export function loadCandidate(
916
+ input: { skeletonText: string; atlasText: string; atlasDir: string; label: string },
917
+ paths: { skeleton: string; atlas: string } | null,
918
+ forced: PoserName | undefined,
919
+ options: { make?: MakeCorePoser; unplaced?: string } = {},
920
+ ): Candidate {
921
+ let data: SkeletonData | null = null;
922
+ let choice: PoserChoice | null = null;
923
+ const spineData = (): SkeletonData => {
924
+ if (data === null) {
925
+ requireSpineRuntime(input.label, choice === null || choice.core !== null ? 'a fallback from the core poser' : choice.why);
926
+ data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(input.atlasText))).readSkeletonData(JSON.parse(input.skeletonText));
927
+ }
928
+ return data;
929
+ };
930
+ choice =
931
+ paths === null
932
+ ? posersOver(forced, null, forced === 'spine' ? '--poser spine' : (options.unplaced ?? 'no path to find a model document beside'), spineData)
933
+ : choosePosers(paths.skeleton, paths.atlas, forced, spineData, options.make ?? corePoser);
934
+ if (choice.core !== null) return { choice, facts: skeletonFacts(input.skeletonText), pages: atlasPagesOf(input.atlasText, input.atlasDir) };
935
+ requireSpineRuntime(input.label, choice.why);
936
+ const posable = posableFromText(input.skeletonText, input.atlasText, input.atlasDir);
937
+ data = posable.data;
938
+ return { choice, facts: spineFacts(posable.data), pages: posable.pages };
939
+ }
940
+
737
941
  // ---------------------------------------------------------------------------
738
942
  // the posing seam (issue #965, step 3a of #380)
739
943
  // ---------------------------------------------------------------------------
@@ -819,8 +1023,19 @@ export interface Poser {
819
1023
  /** What every sampler takes: a poser, or spine-core's parsed skeleton, which is posed through `spinePoser`. */
820
1024
  export type PoseSource = Poser | SkeletonData;
821
1025
 
1026
+ /**
1027
+ * Whether a pose source is a `Poser` — told by the seam's own entries rather
1028
+ * than by `instanceof SkeletonData`, which reads the runtime's class and so
1029
+ * reached spine-core on every sample a rigc build took through the core
1030
+ * (issue #1014). A parsed skeleton carries none of the three.
1031
+ */
1032
+ function isPoser(source: PoseSource): source is Poser {
1033
+ const seam = source as Partial<Poser>;
1034
+ return typeof seam.setup === 'function' && typeof seam.animation === 'function' && typeof seam.rest === 'function';
1035
+ }
1036
+
822
1037
  function poserOf(source: PoseSource): Poser {
823
- return source instanceof SkeletonData ? spinePoser(source) : source;
1038
+ return isPoser(source) ? source : spinePoser(source);
824
1039
  }
825
1040
 
826
1041
  /** A sampler's `PoseOptions` as a draw walk reads them, the subset resolved once, on the first frame posed. */
@@ -998,7 +1213,43 @@ export interface PoserChoice {
998
1213
  core: Poser | null;
999
1214
  /** For a core poser, the document it poses; otherwise why there is none. */
1000
1215
  why: string;
1001
- spine: Poser;
1216
+ /**
1217
+ * spine-core's poser over the same skeleton — loaded the first time it is
1218
+ * read (issue #1014), which on a rigc build the core poses is a fallback the
1219
+ * poser line names, or never.
1220
+ */
1221
+ readonly spine: Poser;
1222
+ /**
1223
+ * The skin roster behind `poser` (`SkinRoster`): the model document's for
1224
+ * the core poser (`coreSkinRoster`), the parsed skeleton's for spine-core's
1225
+ * (`skinRosterOf`) — so a render the core poses reads its framing's roster
1226
+ * without loading the runtime (issue #1014).
1227
+ */
1228
+ rosterOf(poser: Poser): SkinRoster;
1229
+ }
1230
+
1231
+ /** What builds the core poser — `corePoser`, or a planted copy the suite passes (`RC02`, `CH01`). */
1232
+ export type MakeCorePoser = (modelText: string, atlasText: string, where: string, skeleton: { path: string; bytes: Uint8Array }) => Poser;
1233
+
1234
+ /** A skeleton's three rosters, as `rosterDifference` compares them. */
1235
+ interface SkeletonRosters {
1236
+ bones: ReadonlyArray<{ name: string; parent: string | null }>;
1237
+ slots: ReadonlyArray<{ name: string; bone: string }>;
1238
+ animations: ReadonlyArray<{ name: string; duration: number }>;
1239
+ /** Every skin name, in the skeleton's order — the roster `coreSkinRoster` names skins in. */
1240
+ skins: readonly string[];
1241
+ }
1242
+
1243
+ /**
1244
+ * The rosters off the skeleton's own JSON (issue #1014): its bones, slots and
1245
+ * skins as `skeletonFacts` reads them, and its animations' durations as
1246
+ * `skeletonDurations` derives them. Before #1014 these were spine-core's parse
1247
+ * of the same file; the two were measured equal on every input the tree
1248
+ * carries.
1249
+ */
1250
+ function skeletonRosters(skeletonText: string): SkeletonRosters {
1251
+ const facts = skeletonFacts(skeletonText);
1252
+ return { bones: facts.bones, slots: facts.slots, animations: skeletonDurations(objectOf(JSON.parse(skeletonText))), skins: facts.skins };
1002
1253
  }
1003
1254
 
1004
1255
  /**
@@ -1006,39 +1257,64 @@ export interface PoserChoice {
1006
1257
  * sit beside, or `null` — a document from another build would pose another
1007
1258
  * rig in the same files' name, so it is refused rather than drawn.
1008
1259
  */
1009
- function rosterDifference(core: Poser, data: SkeletonData): string | null {
1260
+ function rosterDifference(core: Poser, skeleton: SkeletonRosters): string | null {
1010
1261
  const bones = (list: ReadonlyArray<{ name: string; parent: string | null }>): string => list.map((b) => `${b.name}<${b.parent ?? ''}`).join('|');
1011
1262
  const slots = (list: ReadonlyArray<{ name: string; bone: string }>): string => list.map((x) => `${x.name}@${x.bone}`).join('|');
1012
- const spine = spinePoser(data);
1013
- if (bones(core.bones) !== bones(spine.bones)) return 'the bones (names, parents or order) differ';
1014
- if (slots(core.slots) !== slots(spine.slots)) return 'the slots (names, bones or draw order) differ';
1263
+ if (bones(core.bones) !== bones(skeleton.bones)) return 'the bones (names, parents or order) differ';
1264
+ if (slots(core.slots) !== slots(skeleton.slots)) return 'the slots (names, bones or draw order) differ';
1015
1265
  const animations = (list: ReadonlyArray<{ name: string; duration: number }>): string =>
1016
1266
  list.map((a) => `${a.name}=${a.duration}`).sort().join('|');
1017
- if (animations(core.animations) !== animations(spine.animations)) return 'the animations (names or durations) differ';
1267
+ if (animations(core.animations) !== animations(skeleton.animations)) return 'the animations (names or durations) differ';
1018
1268
  return null;
1019
1269
  }
1020
1270
 
1021
1271
  /**
1022
- * Both posers for the skeleton at `skeletonPath` drawn through the atlas at
1023
- * `atlasPath` — the core one when `skeleton.model.json` sits beside the
1024
- * skeleton, the skeleton's bytes hash to the digest the document records
1025
- * (`spine.sha256`: it is the file that build wrote, not one edited after it),
1026
- * the atlas is the one beside it too, the core reads both and the document's
1027
- * rosters are the skeleton's. `forced` is `--poser`; `core` on an
1028
- * input that cannot carry it is refused by name (`PoserChoiceError`).
1272
+ * A `PoserChoice` over a core poser (or none) and a skeleton spine-core loads
1273
+ * only when something reads its side — `spine`, or the roster behind it.
1029
1274
  */
1030
- export function candidatePosers(
1031
- data: SkeletonData,
1275
+ function posersOver(
1276
+ forced: PoserName | undefined,
1277
+ core: { poser: Poser; roster: SkinRoster } | null,
1278
+ why: string,
1279
+ spineData: () => SkeletonData,
1280
+ ): PoserChoice {
1281
+ let spine: Poser | null = null;
1282
+ let spineRoster: SkinRoster | null = null;
1283
+ return {
1284
+ forced,
1285
+ core: core === null ? null : core.poser,
1286
+ why,
1287
+ get spine(): Poser {
1288
+ spine ??= spinePoser(spineData());
1289
+ return spine;
1290
+ },
1291
+ rosterOf: (poser) => {
1292
+ if (core !== null && poser === core.poser) return core.roster;
1293
+ spineRoster ??= skinRosterOf(spineData());
1294
+ return spineRoster;
1295
+ },
1296
+ };
1297
+ }
1298
+
1299
+ /**
1300
+ * The choice `candidatePosers` and `loadCandidate` share, refusing nothing:
1301
+ * the core poser when `skeleton.model.json` sits beside the skeleton, the
1302
+ * skeleton's bytes hash to the digest the document records (`spine.sha256`:
1303
+ * it is the file that build wrote, not one edited after it), the atlas is the
1304
+ * one beside it too, the core reads both and the document's rosters are the
1305
+ * skeleton's — read off the skeleton's own JSON (`skeletonRosters`), so
1306
+ * choosing loads nothing through spine-core (issue #1014).
1307
+ */
1308
+ function choosePosers(
1032
1309
  skeletonPath: string,
1033
1310
  atlasPath: string,
1034
1311
  forced: PoserName | undefined,
1035
- /** What builds the core poser: `corePoser` — the suite's `RC02` passes a planted copy, and nothing else passes any. */
1036
- make: (modelText: string, atlasText: string, where: string, skeleton: { path: string; bytes: Uint8Array }) => Poser = corePoser,
1312
+ spineData: () => SkeletonData,
1313
+ make: MakeCorePoser,
1037
1314
  ): PoserChoice {
1038
- const spine = spinePoser(data);
1039
1315
  const dir = dirname(resolve(skeletonPath));
1040
1316
  const modelPath = join(dir, MODEL_DOCUMENT_FILE);
1041
- let core: Poser | null = null;
1317
+ let core: { poser: Poser; roster: SkinRoster } | null = null;
1042
1318
  let why: string;
1043
1319
  if (forced === 'spine') why = '--poser spine';
1044
1320
  else if (!existsSync(modelPath)) why = `no ${MODEL_DOCUMENT_FILE} beside ${resolve(skeletonPath)} — a Spine export, not a rigc build`;
@@ -1048,10 +1324,13 @@ export function candidatePosers(
1048
1324
  "atlas's, and the core would pose them against another one's pages";
1049
1325
  } else {
1050
1326
  try {
1051
- const candidate = make(readFileSync(modelPath, 'utf8'), readFileSync(atlasPath, 'utf8'), modelPath, { path: resolve(skeletonPath), bytes: readFileSync(skeletonPath) });
1052
- const differs = rosterDifference(candidate, data);
1327
+ const modelText = readFileSync(modelPath, 'utf8');
1328
+ const bytes = readFileSync(skeletonPath);
1329
+ const candidate = make(modelText, readFileSync(atlasPath, 'utf8'), modelPath, { path: resolve(skeletonPath), bytes });
1330
+ const rosters = skeletonRosters(bytes.toString('utf8'));
1331
+ const differs = rosterDifference(candidate, rosters);
1053
1332
  if (differs === null) {
1054
- core = candidate;
1333
+ core = { poser: candidate, roster: coreSkinRoster(modelText, modelPath, rosters.skins) };
1055
1334
  why = modelPath;
1056
1335
  } else why = `${modelPath} does not describe ${resolve(skeletonPath)}: ${differs}`;
1057
1336
  } catch (err) {
@@ -1060,8 +1339,35 @@ export function candidatePosers(
1060
1339
  why = `the core refused ${err.message.startsWith(`${modelPath}: `) ? err.message : `${modelPath}: ${err.message}`}`;
1061
1340
  }
1062
1341
  }
1063
- if (forced === 'core' && core === null) throw new PoserChoiceError(`--poser core: ${why}`);
1064
- return { forced, core, why, spine };
1342
+ return posersOver(forced, core, why, spineData);
1343
+ }
1344
+
1345
+ /**
1346
+ * `--poser core` on an input that cannot carry it, refused by name
1347
+ * (`PoserChoiceError`) — said by the caller where its refusals have always
1348
+ * stood, after the flags that refuse before it. Returns the choice otherwise.
1349
+ */
1350
+ export function refuseUnchosen(choice: PoserChoice): PoserChoice {
1351
+ if (choice.forced === 'core' && choice.core === null) throw new PoserChoiceError(`--poser core: ${choice.why}`);
1352
+ return choice;
1353
+ }
1354
+
1355
+ /**
1356
+ * Both posers for the skeleton at `skeletonPath` drawn through the atlas at
1357
+ * `atlasPath`, with spine-core's parse of it already in hand (`data`) — the
1358
+ * choice `loadCandidate` makes, for a caller that loaded the pair itself.
1359
+ * `forced` is `--poser`; `core` on an input that cannot carry it is refused by
1360
+ * name (`PoserChoiceError`).
1361
+ */
1362
+ export function candidatePosers(
1363
+ data: SkeletonData,
1364
+ skeletonPath: string,
1365
+ atlasPath: string,
1366
+ forced: PoserName | undefined,
1367
+ /** What builds the core poser: `corePoser` — the suite's `RC02` passes a planted copy, and nothing else passes any. */
1368
+ make: MakeCorePoser = corePoser,
1369
+ ): PoserChoice {
1370
+ return refuseUnchosen(choosePosers(skeletonPath, atlasPath, forced, () => data, make));
1065
1371
  }
1066
1372
 
1067
1373
  /**
@@ -1070,19 +1376,22 @@ export function candidatePosers(
1070
1376
  * (`CoreInputError`, e.g. a clip polygon that is not simple), in
1071
1377
  * which case `run` starts again from nothing on spine-core and `note` names the
1072
1378
  * refusal. Under `--poser core` that refusal is a `PoserChoiceError` instead.
1073
- * `run` must write nothing: a fallback re-runs it whole.
1379
+ * `run` must write nothing: a fallback re-runs it whole. It is handed the
1380
+ * skin roster behind the poser it runs (`PoserChoice.rosterOf`), so a run
1381
+ * through the core reads nothing through spine-core (issue #1014).
1074
1382
  */
1075
- export function throughPoser<T>(choice: PoserChoice, run: (poser: Poser) => T): { value: T; poser: PoserName; note: string } {
1383
+ export function throughPoser<T>(choice: PoserChoice, run: (poser: Poser, roster: SkinRoster) => T): { value: T; poser: PoserName; note: string } {
1384
+ const through = (poser: Poser): T => run(poser, choice.rosterOf(poser));
1076
1385
  if (choice.core !== null) {
1077
1386
  try {
1078
- return { value: run(choice.core), poser: 'core', note: `rigc core — ${choice.why}` };
1387
+ return { value: through(choice.core), poser: 'core', note: `rigc core — ${choice.why}` };
1079
1388
  } catch (err) {
1080
1389
  if (!(err instanceof CoreInputError)) throw err;
1081
1390
  if (choice.forced === 'core') throw new PoserChoiceError(`--poser core: the core refused this input: ${err.message}`);
1082
- return { value: run(choice.spine), poser: 'spine', note: `spine-core — the core refused this input: ${err.message}` };
1391
+ return { value: through(choice.spine), poser: 'spine', note: `spine-core — the core refused this input: ${err.message}` };
1083
1392
  }
1084
1393
  }
1085
- return { value: run(choice.spine), poser: 'spine', note: `spine-core — ${choice.why}` };
1394
+ return { value: through(choice.spine), poser: 'spine', note: `spine-core — ${choice.why}` };
1086
1395
  }
1087
1396
 
1088
1397
  /**
@@ -2135,6 +2444,10 @@ export class UnframeablePoseError extends GeometryError {}
2135
2444
  * structure the unframeable sentence names a skin from (issue #997). Not a
2136
2445
  * poser's: the `Poser` seam carries no skin roster, and both posers pose the one
2137
2446
  * Spine file this is read from (the core's document is bound to it by hash).
2447
+ * Two readings stand behind it — the runtime's (`skinRosterOf`) and the model
2448
+ * document's (`coreSkinRoster` in `./render_core.ts`, issue #1014), measured
2449
+ * to name the same bones under every skin — and `PoserChoice.rosterOf` hands
2450
+ * each poser its own.
2138
2451
  */
2139
2452
  export interface SkinRoster {
2140
2453
  /** Every skin, in declaration order. */
@@ -2147,7 +2460,8 @@ export interface SkinRoster {
2147
2460
  * The skin roster of a parsed Spine file, as the runtime flags it —
2148
2461
  * `unposedBones` over a fresh skeleton's `active` under each skin, so Spine's
2149
2462
  * activation rule is the runtime's and is not restated here. Nothing is posed
2150
- * until a question is asked, and only the refusal asks one.
2463
+ * until a question is asked: the framing asks one (the skin it frames under,
2464
+ * `slotsOnUnposedBones`) and the refusal asks one per skin.
2151
2465
  */
2152
2466
  export function skinRosterOf(data: SkeletonData): SkinRoster {
2153
2467
  return {
@@ -2161,7 +2475,29 @@ export function skinRosterOf(data: SkeletonData): SkinRoster {
2161
2475
 
2162
2476
  /** The roster behind a pose source: its own when it is Spine data, else the one the caller passed. */
2163
2477
  function rosterBehind(source: PoseSource, roster: SkinRoster | undefined): SkinRoster | undefined {
2164
- return source instanceof SkeletonData ? skinRosterOf(source) : roster;
2478
+ return isPoser(source) ? roster : skinRosterOf(source);
2479
+ }
2480
+
2481
+ /**
2482
+ * ⭐ **The one derivation of "a drawn slot on an unposed bone"** (issues #997,
2483
+ * #1000): the names of the slots of `slots` whose bone `roster` says `skin`
2484
+ * leaves unposed — inactive itself, or below an inactive bone. Both posers draw
2485
+ * such a slot through the zero matrix, so it draws no pixel and every vertex of
2486
+ * it sits at the origin. `framingViewport` takes these slots off the box
2487
+ * (#1000) and `unframeableSentence` refuses a pose that drew nothing else
2488
+ * (#997), from this one set.
2489
+ *
2490
+ * Empty without a roster: a bare `Poser` carries no skin structure, and what
2491
+ * cannot be read is not guessed — every slot then counts, as it did before.
2492
+ */
2493
+ export function slotsOnUnposedBones(
2494
+ slots: ReadonlyArray<{ name: string; bone: string }>,
2495
+ skin: string | undefined,
2496
+ roster: SkinRoster | undefined,
2497
+ ): Set<string> {
2498
+ if (roster === undefined) return new Set();
2499
+ const unposed = roster.unposedUnder(skin);
2500
+ return new Set(slots.filter((slot) => unposed.has(slot.bone)).map((slot) => slot.name));
2165
2501
  }
2166
2502
 
2167
2503
  /** How many drawn slots a refusal names before it says how many more there are. */
@@ -2185,9 +2521,10 @@ const UNFRAMEABLE_NAMED = 3;
2185
2521
  * collapsed their attachments (a world scale of 0 does).
2186
2522
  *
2187
2523
  * `roster` is the skin structure of the Spine file the poser poses
2188
- * (`skinRosterOf`), which is what says whether a bone is unposed and which skins
2189
- * pose it — read only on this refusal's path. Without it the first sentence
2190
- * cannot be told from the second, and the second is what is said.
2524
+ * (`skinRosterOf`), which is what says whether a bone is unposed
2525
+ * (`slotsOnUnposedBones`, the set the framing box leaves out) and which skins
2526
+ * pose it. Without it the first sentence cannot be told from the second, and
2527
+ * the second is what is said.
2191
2528
  *
2192
2529
  * Distinct from *nothing to draw*: that is a skeleton that posed no vertex at
2193
2530
  * all, and its fix is art; this one posed vertices, and they have no place.
@@ -2213,8 +2550,8 @@ export function unframeableSentence(
2213
2550
  const named = (describe: (slot: { name: string; bone: string }) => string): string =>
2214
2551
  drawn.slice(0, UNFRAMEABLE_NAMED).map(describe).join('; ') + more;
2215
2552
  if (roster !== undefined) {
2216
- const unposed = roster.unposedUnder(skin);
2217
- if (drawn.length > 0 && drawn.every((slot) => unposed.has(slot.bone))) {
2553
+ const offBox = slotsOnUnposedBones(slots, skin, roster);
2554
+ if (drawn.length > 0 && drawn.every((slot) => offBox.has(slot.name))) {
2218
2555
  const bySkin = roster.skins.map((name) => ({ name, unposed: roster.unposedUnder(name) }));
2219
2556
  const posers = (bone: string): string => {
2220
2557
  const names = bySkin.filter((k) => !k.unposed.has(bone)).map((k) => JSON.stringify(k.name));
@@ -2250,11 +2587,21 @@ export function unframeableSentence(
2250
2587
  * every vertex sits at one point is refused by an `UnframeablePoseError`
2251
2588
  * (`unframeableSentence`, issue #997), never framed at a scale of Infinity.
2252
2589
  *
2590
+ * The box is over the slots that POSE (issue #1000): a drawn slot whose bone
2591
+ * the skin leaves unposed (`slotsOnUnposedBones`) draws no pixel — both posers
2592
+ * put it through the zero matrix — so its vertices at the origin are not part
2593
+ * of the shot, and counting them framed every frame of a rig that carries one
2594
+ * to a point nothing is drawn at (256x94 where the posed slot alone gives
2595
+ * 256x55). When every drawn slot is such a slot there is no posed box at all,
2596
+ * and that is #997's refusal, never an empty or invented one.
2597
+ *
2253
2598
  * `roster` is the skin roster behind a `Poser` source (`skinRosterOf`) — what
2254
- * lets that refusal name the skins that pose an unposed bone. Spine data as the
2255
- * source is its own.
2599
+ * says which bones the skin leaves unposed, and lets the refusal name the skins
2600
+ * that pose one. Spine data as the source is its own. A bare `Poser` with no
2601
+ * roster cannot tell an unposed bone from a posed one, so every drawn slot
2602
+ * counts there; every CLI caller passes the roster, so both posers frame alike.
2256
2603
  */
2257
- export function framingViewport(source: PoseSource, maxSide: number, opts?: PoseOptions, roster?: SkinRoster): Viewport | null {
2604
+ export function framingViewport(source: PoseSource, maxSide: number, opts?: PoseOptions, rosterGiven?: SkinRoster): Viewport | null {
2258
2605
  const poser = poserOf(source);
2259
2606
  // The skin belongs here as much as in the frames: the union box is over the
2260
2607
  // attachments that POSE, and two skins fill a slot with art of different sizes
@@ -2285,9 +2632,20 @@ export function framingViewport(source: PoseSource, maxSide: number, opts?: Pose
2285
2632
  // this, the first case's `null` covered both, and a single overflowing bone
2286
2633
  // among finite ones reached neither: its box was finite on one side, and
2287
2634
  // `render` wrote a NaN-by-NaN frame set with exit 0.
2288
- const posedAny = sets.some((frames) => frames.some((frame) => frame.pieces.some((piece) => piece.world.length > 0)));
2289
- if (!posedAny) return null;
2290
- const box = unionBounds(sets);
2635
+ const drewAny = (frameSets: ReadonlyArray<readonly Frame[]>): boolean =>
2636
+ frameSets.some((frames) => frames.some((frame) => frame.pieces.some((piece) => piece.world.length > 0)));
2637
+ if (!drewAny(sets)) return null;
2638
+ // ⭐ The box is over the slots that pose (issue #1000). A drawn slot on a bone
2639
+ // the skin leaves unposed is taken off — it draws no pixel — unless nothing
2640
+ // else drew, in which case every set is kept whole so the pose reaches #997's
2641
+ // refusal below exactly as it did before this: the same inputs, the same
2642
+ // sentence, never an empty box framed to something.
2643
+ const roster = rosterBehind(source, rosterGiven);
2644
+ const offBox = slotsOnUnposedBones(poser.slots, framed.skin, roster);
2645
+ const posedSets =
2646
+ offBox.size === 0 ? sets : sets.map((frames) => frames.map((frame) => ({ ...frame, pieces: frame.pieces.filter((piece) => !offBox.has(piece.slot)) })));
2647
+ const boxed = drewAny(posedSets) ? posedSets : sets;
2648
+ const box = unionBounds(boxed);
2291
2649
  if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) {
2292
2650
  const found = nonFinitePoseOf(
2293
2651
  poser,
@@ -2309,7 +2667,7 @@ export function framingViewport(source: PoseSource, maxSide: number, opts?: Pose
2309
2667
  // A finite box over one point (issue #997): every drawn slot on a bone the
2310
2668
  // skin leaves unposed, or every vertex collapsed. Framed, it is a scale of
2311
2669
  // Infinity and a frame of NaN by NaN pixels, written as 0x0 with exit 0.
2312
- const unframeable = unframeableSentence(sets, poser.slots, framed.skin, rosterBehind(source, roster));
2670
+ const unframeable = unframeableSentence(boxed, poser.slots, framed.skin, roster);
2313
2671
  if (unframeable !== null) throw new UnframeablePoseError(unframeable);
2314
2672
  const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
2315
2673
  return viewportFor(box.minX - pad, box.minY - pad, box.maxX + pad, box.maxY + pad, maxSide);