spine-rigc 1.6.2 → 1.8.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.
Files changed (39) hide show
  1. package/cli.ts +166 -60
  2. package/docs/AUTHORING.md +25 -16
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a03.ts +27 -0
  5. package/src/assertions/bodies/a11.ts +15 -0
  6. package/src/assertions/bodies/a17.ts +26 -0
  7. package/src/assertions/bodies/a45.ts +172 -0
  8. package/src/assertions/facts/atlas_pages.ts +19 -0
  9. package/src/assertions/facts/skin_entries.ts +35 -0
  10. package/src/assertions/facts/slot_colour.ts +43 -0
  11. package/src/assertions/harness.ts +109 -0
  12. package/src/assertions/kinds.ts +102 -0
  13. package/src/assertions/model/atlas_pages.ts +15 -0
  14. package/src/assertions/model/index.ts +130 -0
  15. package/src/assertions/model/parse.ts +136 -0
  16. package/src/assertions/model/skin_entries.ts +58 -0
  17. package/src/assertions/model/slot_colour.ts +80 -0
  18. package/src/assertions/reasons.ts +127 -0
  19. package/src/assertions/values.ts +39 -0
  20. package/src/atlas.ts +26 -16
  21. package/src/check.ts +70 -47
  22. package/src/compile.ts +118 -93
  23. package/src/core/constraints_path.ts +60 -38
  24. package/src/core/constraints_physics.ts +22 -4
  25. package/src/core/index.ts +102 -15
  26. package/src/core/uvs.ts +25 -5
  27. package/src/core/world.ts +80 -24
  28. package/src/deformmeasure.ts +383 -157
  29. package/src/deformreport.ts +3 -0
  30. package/src/deformstructure.ts +335 -0
  31. package/src/emit.ts +17 -6
  32. package/src/ingest.ts +18 -13
  33. package/src/keyorder.ts +2 -2
  34. package/src/model.ts +114 -13
  35. package/src/render.ts +520 -59
  36. package/src/render_core.ts +178 -21
  37. package/src/transform.ts +113 -128
  38. package/src/types.ts +6 -5
  39. package/src/validate.ts +200 -490
package/src/render.ts CHANGED
@@ -81,7 +81,12 @@
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 its model
87
+ * document and the skeleton's own JSON (`loadCandidate`), and since issue
88
+ * #1020 its pages and a `--texture-from` atlas through rigc's own reader, so
89
+ * spine-core is reached only where it poses an export or a fallback.) The rule that matters is unchanged
85
90
  * — `src/compile.ts` must stay independent of the runtime so the compiler and
86
91
  * the gate are not checking each other's assumptions — and this file is neither.
87
92
  * It also imports `tools/plate.ts` for the PNG codec, which is dependency-free.
@@ -105,10 +110,22 @@ import {
105
110
  import { existsSync, readFileSync } from 'node:fs';
106
111
  import { dirname, join, resolve } from 'node:path';
107
112
  import { Plate, readPlate, type RGBA } from '../tools/plate.ts';
108
- import { pageFootprint } from './atlas.ts';
113
+ import { pageFootprint, parseAtlasText } from './atlas.ts';
109
114
  import { CoreInputError } from './core/index.ts';
115
+ import { computeUvs } from './core/uvs.ts';
110
116
  import { MODEL_DOCUMENT_FILE } from './model.ts';
111
- import { clipSourceOf, corePoser, inactiveBoneSnapshot, SlotSubsetError, subsetOver, unposedBones } from './render_core.ts';
117
+ import {
118
+ clipSourceOf,
119
+ coreDocumentFacts,
120
+ corePoser,
121
+ inactiveBoneSnapshot,
122
+ SlotSubsetError,
123
+ subsetOver,
124
+ unposedBones,
125
+ type CoreDocumentFacts,
126
+ type SubsetRoster,
127
+ } from './render_core.ts';
128
+ import { walkTimelines } from './timelines.ts';
112
129
 
113
130
  /** Opaque, and light: both of rung 3's parts are dark slate, so is every ground. */
114
131
  export const BACKGROUND: RGBA = [232, 232, 232, 255];
@@ -734,6 +751,274 @@ export function posableFromText(skeletonText: string, atlasText: string, atlasDi
734
751
  return { data, pages };
735
752
  }
736
753
 
754
+ // ---------------------------------------------------------------------------
755
+ // what a render and a check read off a candidate besides its pose (issue #1014)
756
+ // ---------------------------------------------------------------------------
757
+ //
758
+ // ⭐ `render` and `check` read a handful of facts off the candidate before and
759
+ // beside the pose: the animation and skin names their flags are checked
760
+ // against, the slot subset's roster, whether a stage is declared, the bone
761
+ // tree `check` draws its chains from, the skin roster the framing reads, and
762
+ // the atlas pages the rasteriser samples. Until issue #1014 every one of them
763
+ // came off the skeleton spine-core had parsed, so a rigc build the core poses
764
+ // still loaded and ran the runtime before its poser was chosen. Now the choice
765
+ // comes first (`loadCandidate`), and a build the core poses reads each fact
766
+ // where it is written: the names, the tree, the subset's roster and the stage
767
+ // off the skeleton's own JSON (`skeletonFacts`), the pages off rigc's atlas
768
+ // reader, and the skin roster off the model document (`coreSkinRoster` in
769
+ // `./render_core.ts`). Every one of those readings was measured equal to the
770
+ // spine-core reading it replaces on every input the tree carries — the
771
+ // nineteen built corpus rows and the twelve editor exports (the PR of #1014
772
+ // carries the counts). A Spine export, `--poser spine` and a fallback the
773
+ // poser line names load spine-core as before and read every fact off it.
774
+ //
775
+ // Since issue #1020 a build the core poses reads what its model document
776
+ // states off the document (`coreFacts`): the bone tree, the slot list and the
777
+ // subset's roster, and the page images by the names its `pages` section gives
778
+ // — so with a `rigc-compiled/2` document the atlas file is not needed at all.
779
+ // What only `skeleton.json` holds is still read there: the order the
780
+ // animations and skins are listed in (the emitter's, not the model's) and the
781
+ // stage.
782
+
783
+ /** What `render` and `check` read off a candidate's skeleton besides its pose. */
784
+ export interface SkeletonFacts {
785
+ /** Every animation name, in the skeleton's own order. */
786
+ readonly animations: readonly string[];
787
+ /** Every skin name, in the skeleton's own order. */
788
+ readonly skins: readonly string[];
789
+ /** Whether the header states a numeric `width` and `height` — a setup stage (issue #714). */
790
+ readonly declaresStage: boolean;
791
+ /** Every bone in declaration order, and its parent's name. */
792
+ readonly bones: ReadonlyArray<{ name: string; parent: string | null }>;
793
+ /** Every slot in declaration order, and the bone it hangs from. */
794
+ readonly slots: ReadonlyArray<{ name: string; bone: string }>;
795
+ /** `slotSubsetOf` over this skeleton — refused by `SlotSubsetError`. */
796
+ subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
797
+ }
798
+
799
+ /** The facts as spine-core loaded them — an export's reading, and every candidate's before issue #1014. */
800
+ export function spineFacts(data: SkeletonData): SkeletonFacts {
801
+ return {
802
+ animations: data.animations.map((a) => a.name),
803
+ skins: data.skins.map((s) => s.name),
804
+ // `SkeletonJson` copies both header fields across unconditionally, so an omitted extent is `undefined` here, not 0.
805
+ declaresStage: typeof data.width === 'number' && typeof data.height === 'number',
806
+ bones: data.bones.map((bone) => ({ name: bone.name, parent: bone.parent === null ? null : bone.parent.name })),
807
+ slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
808
+ subset: (opts, skin) => slotSubsetOf(data, opts, skin),
809
+ };
810
+ }
811
+
812
+ type JsonObject = Record<string, unknown>;
813
+
814
+ /** A JSON value as an object, or an empty one where it is not one. */
815
+ function objectOf(value: unknown): JsonObject {
816
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonObject) : {};
817
+ }
818
+
819
+ /** A JSON value as a list of objects, or an empty list where it is not one. */
820
+ function objectsOf(value: unknown): JsonObject[] {
821
+ return Array.isArray(value) ? value.map(objectOf) : [];
822
+ }
823
+
824
+ /**
825
+ * The same facts off the skeleton's own JSON — a candidate the core poses
826
+ * (issue #1014), whose skeleton spine-core never loads.
827
+ *
828
+ * Each is the file's own statement, read in the file's order: the animation
829
+ * names are the keys of `animations` (the order the parser iterates them), the
830
+ * skins `skins[].name` (the default skin the one named `default`), a slot's
831
+ * carriers the skins whose `attachments` give it at least one entry, the tree
832
+ * `bones[]` and `slots[]`, and the stage `skeleton.width`/`height`. The core
833
+ * poses only a rigc build whose `skeleton.json` hashes to the digest its model
834
+ * document records, so the file read here is the one `build` wrote and the
835
+ * gate round-tripped.
836
+ */
837
+ export function skeletonFacts(skeletonText: string): SkeletonFacts {
838
+ const root = objectOf(JSON.parse(skeletonText));
839
+ const skins = objectsOf(root.skins);
840
+ const slots = objectsOf(root.slots).map((slot) => ({ name: String(slot.name), bone: String(slot.bone) }));
841
+ const header = objectOf(root.skeleton);
842
+ const roster: SubsetRoster = {
843
+ declared: slots.map((slot) => slot.name),
844
+ carriers: (slot) => skins.filter((skin) => Object.keys(objectOf(objectOf(skin.attachments)[slot])).length > 0).map((skin) => String(skin.name)),
845
+ defaultSkin: skins.some((skin) => skin.name === 'default') ? 'default' : null,
846
+ };
847
+ return {
848
+ animations: Object.keys(objectOf(root.animations)),
849
+ skins: skins.map((skin) => String(skin.name)),
850
+ declaresStage: typeof header.width === 'number' && typeof header.height === 'number',
851
+ bones: objectsOf(root.bones).map((bone) => ({ name: String(bone.name), parent: typeof bone.parent === 'string' ? bone.parent : null })),
852
+ slots,
853
+ subset: (opts, skin) => subsetOver(roster, opts, skin),
854
+ };
855
+ }
856
+
857
+ /**
858
+ * Each animation's duration off the skeleton's own JSON, in the file's order —
859
+ * what `rosterDifference` holds a model document's durations to without
860
+ * loading the skeleton through spine-core (issue #1014).
861
+ *
862
+ * ⚠️ Skeleton JSON carries no duration, so this is the parser's derivation of
863
+ * one, and it is stated as measured rather than as read: the LAST key time of
864
+ * each timeline, the largest of them, held as a float32 — equal to
865
+ * spine-core's `Animation.duration` on every animation of every input the tree
866
+ * carries (the PR of #1014). The timelines are the ones `walkTimelines` walks,
867
+ * the walk `A05` and `A12` stand on, so a group it did not descend would be
868
+ * missing from both.
869
+ */
870
+ function skeletonDurations(root: JsonObject): Array<{ name: string; duration: number }> {
871
+ return Object.entries(objectOf(root.animations)).map(([name, animation]) => {
872
+ let duration = 0;
873
+ walkTimelines({ animations: { [name]: animation } }, (_path, _kind, _timeline, keys) => {
874
+ if (keys.length === 0) return;
875
+ const last = objectOf(keys[keys.length - 1]);
876
+ duration = Math.max(duration, Math.fround(typeof last.time === 'number' ? last.time : 0));
877
+ });
878
+ return { name, duration };
879
+ });
880
+ }
881
+
882
+ /** Every page named, read from `atlasDir` by that name — the images a candidate the core poses is drawn from. */
883
+ function pagesNamed(names: readonly string[], atlasDir: string): Map<string, Plate> {
884
+ const pages = new Map<string, Plate>();
885
+ for (const name of names) pages.set(name, readPlate(join(atlasDir, name)));
886
+ return pages;
887
+ }
888
+
889
+ /**
890
+ * A candidate with no atlas beside it that has to be read through one —
891
+ * refused naming the file and why (issue #1020).
892
+ *
893
+ * A rigc build whose model document states where each region sits on its page
894
+ * (`rigc-compiled/2`, the `pages` section) is drawn by the core without its
895
+ * atlas. Everything else reads one: a Spine export, `--poser spine`, a
896
+ * `rigc-compiled/1` document, and a build the core refuses and spine-core
897
+ * draws instead. A class of its own so `cli.ts` refuses it as an invocation
898
+ * (exit 2, nothing written), as it refuses a missing atlas on an export.
899
+ */
900
+ export class CandidateAtlasError extends Error {}
901
+
902
+ /** The refusal for a candidate that has to be read through an atlas it does not have. */
903
+ function atlasAbsent(atlasPath: string, label: string, why: string): CandidateAtlasError {
904
+ return new CandidateAtlasError(
905
+ `nothing at ${atlasPath}: ${label} is drawn through its atlas (${why}). ` +
906
+ 'Only a rigc build whose skeleton.model.json is a rigc-compiled/2 document, posed by the core, is drawn ' +
907
+ 'without one — it states where each region sits on its page',
908
+ );
909
+ }
910
+
911
+ /**
912
+ * Posing an input through spine-core when the runtime cannot be reached —
913
+ * refused naming the input and why it needs the runtime (issue #1014).
914
+ *
915
+ * A Spine export, `--poser spine` and a fallback the poser line names are what
916
+ * spine-core is loaded for; a rigc build the core poses reads nothing through
917
+ * it. So the one run that can meet a runtime it cannot use is one of those
918
+ * three, and it says so rather than surfacing whatever the first access threw.
919
+ */
920
+ export class SpineRuntimeError extends Error {}
921
+
922
+ /** Touch the runtime once, before anything is parsed through it, and refuse by name when it cannot be used. */
923
+ function requireSpineRuntime(label: string, why: string): void {
924
+ try {
925
+ // A property read on the class the load starts from: no runtime code runs, and a runtime that cannot be used throws here.
926
+ void TextureAtlas.prototype;
927
+ } catch (err) {
928
+ throw new SpineRuntimeError(
929
+ `${label} is posed through spine-core (${why}), and the runtime could not be used: ${(err as Error).message}. ` +
930
+ 'spine-core is what poses a Spine export, --poser spine and a fallback the poser line names; ' +
931
+ 'a rigc build the core poses reads nothing through it',
932
+ );
933
+ }
934
+ }
935
+
936
+ /** A candidate as `render` and `check` read it: the posers, the facts and the pages (issue #1014). */
937
+ export interface Candidate {
938
+ choice: PoserChoice;
939
+ facts: SkeletonFacts;
940
+ pages: Map<string, Plate>;
941
+ }
942
+
943
+ /**
944
+ * Load a candidate for `render` or `check`, choosing its poser FIRST (issue
945
+ * #1014): a rigc build the core poses reads its facts off its model document
946
+ * and its own JSON (`coreFacts`), its page images by the names the
947
+ * document's `pages` gives (a `rigc-compiled/1` document's, by its atlas's)
948
+ * and its skin roster off the document, and spine-core is never loaded for
949
+ * it; `input.atlasText` is `null` where the atlas file is not there, which only
950
+ * a `rigc-compiled/2` document the core poses is drawn without (issue #1020 —
951
+ * anything else is refused, `CandidateAtlasError`); anything else — a Spine export,
952
+ * `--poser spine`, a candidate handed over as text (`paths` null, `unplaced`
953
+ * the reason) — is loaded through spine-core as `posableFromText` always
954
+ * loaded it. The choice's spine-core poser stays unloaded until something
955
+ * reads it, which on a rigc build is a fallback the poser line names.
956
+ *
957
+ * `--poser core` on an input that cannot carry it is NOT refused here: the
958
+ * caller says it where it always has (`refuseUnchosen`), after the flags that
959
+ * refuse before it.
960
+ */
961
+ export function loadCandidate(
962
+ input: { skeletonText: string; atlasText: string | null; atlasDir: string; label: string },
963
+ paths: { skeleton: string; atlas: string } | null,
964
+ forced: PoserName | undefined,
965
+ options: { make?: MakeCorePoser; unplaced?: string } = {},
966
+ ): Candidate {
967
+ let data: SkeletonData | null = null;
968
+ let choice: PoserChoice | null = null;
969
+ // `null` is an atlas file that is not there (issue #1020): a candidate the core draws from its document needs none, and anything read through one is refused naming the file before the runtime is touched.
970
+ const atlasText = (why: string): string => {
971
+ if (input.atlasText === null) throw atlasAbsent(paths?.atlas ?? '(no atlas)', input.label, why);
972
+ return input.atlasText;
973
+ };
974
+ const spineData = (): SkeletonData => {
975
+ if (data === null) {
976
+ const why = choice === null || choice.core !== null ? 'a fallback from the core poser' : choice.why;
977
+ const text = atlasText(why);
978
+ requireSpineRuntime(input.label, why);
979
+ data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(text))).readSkeletonData(JSON.parse(input.skeletonText));
980
+ }
981
+ return data;
982
+ };
983
+ const chosen =
984
+ paths === null
985
+ ? { choice: posersOver(forced, null, forced === 'spine' ? '--poser spine' : (options.unplaced ?? 'no path to find a model document beside'), spineData), document: null }
986
+ : choosePosers(paths.skeleton, paths.atlas, input.atlasText, forced, spineData, options.make ?? corePoser);
987
+ choice = chosen.choice;
988
+ if (choice.core !== null && chosen.document !== null) {
989
+ // A rigc-compiled/2 document names its pages; a /1 document is posed only with its atlas beside it, which names them (`choosePosers`).
990
+ const names = chosen.document.pageNames ?? parseAtlasText(atlasText(choice.why)).pages.map((page) => page.name);
991
+ return { choice, facts: coreFacts(input.skeletonText, choice.core, chosen.document), pages: pagesNamed(names, input.atlasDir) };
992
+ }
993
+ const text = atlasText(choice.why);
994
+ requireSpineRuntime(input.label, choice.why);
995
+ const posable = posableFromText(input.skeletonText, text, input.atlasDir);
996
+ data = posable.data;
997
+ return { choice, facts: spineFacts(posable.data), pages: posable.pages };
998
+ }
999
+
1000
+ /**
1001
+ * A candidate's facts when the core poses it (issue #1020): what the model
1002
+ * document states, read from it — the bone tree and the slot list (the core
1003
+ * poser's own, which `rosterDifference` held to the Spine file's before the
1004
+ * core was chosen) and the slot subset's roster (`coreDocumentFacts`) — and,
1005
+ * off the skeleton's own JSON, what it does not: the order the animations and
1006
+ * the skins are listed in (the Spine file's — the emitter's order, not the
1007
+ * model's) and whether a stage is declared (the header's `width`/`height`,
1008
+ * which the document has no field for).
1009
+ */
1010
+ function coreFacts(skeletonText: string, poser: Poser, document: CoreDocumentFacts): SkeletonFacts {
1011
+ const spine = skeletonFacts(skeletonText);
1012
+ return {
1013
+ animations: spine.animations,
1014
+ skins: spine.skins,
1015
+ declaresStage: spine.declaresStage,
1016
+ bones: poser.bones,
1017
+ slots: poser.slots,
1018
+ subset: (opts, skin) => subsetOver(document.subset, opts, skin),
1019
+ };
1020
+ }
1021
+
737
1022
  // ---------------------------------------------------------------------------
738
1023
  // the posing seam (issue #965, step 3a of #380)
739
1024
  // ---------------------------------------------------------------------------
@@ -758,9 +1043,10 @@ export function posableFromText(skeletonText: string, atlasText: string, atlasDi
758
1043
  // The framing samples are not a fourth entry: `framingViewport` is the same
759
1044
  // animation entry at `FRAMING_FPS` with the clip off.
760
1045
  //
761
- // 🔒 What stays spine-core's and outside the seam, deliberately: the atlas
762
- // (`posableFromText`'s pages, `substituteTexture`'s region lookup — step 3c of
763
- // #380) and `nonFiniteOfPosed`, which `validate.ts` calls on a spine-core
1046
+ // 🔒 What stays spine-core's and outside the seam, deliberately: an export's
1047
+ // atlas (`posableFromText`'s pages, and `substituteTexture`'s region lookup on
1048
+ // anything spine-core poses — a rigc build the core poses reads both through
1049
+ // rigc's own reader since issue #1020) and `nonFiniteOfPosed`, which `validate.ts` calls on a spine-core
764
1050
  // skeleton it stepped itself (A10), so the round trip keeps its spine-core
765
1051
  // entry whatever poses the renders.
766
1052
 
@@ -819,8 +1105,19 @@ export interface Poser {
819
1105
  /** What every sampler takes: a poser, or spine-core's parsed skeleton, which is posed through `spinePoser`. */
820
1106
  export type PoseSource = Poser | SkeletonData;
821
1107
 
1108
+ /**
1109
+ * Whether a pose source is a `Poser` — told by the seam's own entries rather
1110
+ * than by `instanceof SkeletonData`, which reads the runtime's class and so
1111
+ * reached spine-core on every sample a rigc build took through the core
1112
+ * (issue #1014). A parsed skeleton carries none of the three.
1113
+ */
1114
+ function isPoser(source: PoseSource): source is Poser {
1115
+ const seam = source as Partial<Poser>;
1116
+ return typeof seam.setup === 'function' && typeof seam.animation === 'function' && typeof seam.rest === 'function';
1117
+ }
1118
+
822
1119
  function poserOf(source: PoseSource): Poser {
823
- return source instanceof SkeletonData ? spinePoser(source) : source;
1120
+ return isPoser(source) ? source : spinePoser(source);
824
1121
  }
825
1122
 
826
1123
  /** A sampler's `PoseOptions` as a draw walk reads them, the subset resolved once, on the first frame posed. */
@@ -998,7 +1295,43 @@ export interface PoserChoice {
998
1295
  core: Poser | null;
999
1296
  /** For a core poser, the document it poses; otherwise why there is none. */
1000
1297
  why: string;
1001
- spine: Poser;
1298
+ /**
1299
+ * spine-core's poser over the same skeleton — loaded the first time it is
1300
+ * read (issue #1014), which on a rigc build the core poses is a fallback the
1301
+ * poser line names, or never.
1302
+ */
1303
+ readonly spine: Poser;
1304
+ /**
1305
+ * The skin roster behind `poser` (`SkinRoster`): the model document's for
1306
+ * the core poser (`coreSkinRoster`), the parsed skeleton's for spine-core's
1307
+ * (`skinRosterOf`) — so a render the core poses reads its framing's roster
1308
+ * without loading the runtime (issue #1014).
1309
+ */
1310
+ rosterOf(poser: Poser): SkinRoster;
1311
+ }
1312
+
1313
+ /** What builds the core poser — `corePoser`, or a planted copy the suite passes (`RC02`, `CH01`). */
1314
+ export type MakeCorePoser = (modelText: string, atlasText: string, where: string, skeleton: { path: string; bytes: Uint8Array }) => Poser;
1315
+
1316
+ /** A skeleton's three rosters, as `rosterDifference` compares them. */
1317
+ interface SkeletonRosters {
1318
+ bones: ReadonlyArray<{ name: string; parent: string | null }>;
1319
+ slots: ReadonlyArray<{ name: string; bone: string }>;
1320
+ animations: ReadonlyArray<{ name: string; duration: number }>;
1321
+ /** Every skin name, in the skeleton's order — the roster `coreSkinRoster` names skins in. */
1322
+ skins: readonly string[];
1323
+ }
1324
+
1325
+ /**
1326
+ * The rosters off the skeleton's own JSON (issue #1014): its bones, slots and
1327
+ * skins as `skeletonFacts` reads them, and its animations' durations as
1328
+ * `skeletonDurations` derives them. Before #1014 these were spine-core's parse
1329
+ * of the same file; the two were measured equal on every input the tree
1330
+ * carries.
1331
+ */
1332
+ function skeletonRosters(skeletonText: string): SkeletonRosters {
1333
+ const facts = skeletonFacts(skeletonText);
1334
+ return { bones: facts.bones, slots: facts.slots, animations: skeletonDurations(objectOf(JSON.parse(skeletonText))), skins: facts.skins };
1002
1335
  }
1003
1336
 
1004
1337
  /**
@@ -1006,39 +1339,67 @@ export interface PoserChoice {
1006
1339
  * sit beside, or `null` — a document from another build would pose another
1007
1340
  * rig in the same files' name, so it is refused rather than drawn.
1008
1341
  */
1009
- function rosterDifference(core: Poser, data: SkeletonData): string | null {
1342
+ function rosterDifference(core: Poser, skeleton: SkeletonRosters): string | null {
1010
1343
  const bones = (list: ReadonlyArray<{ name: string; parent: string | null }>): string => list.map((b) => `${b.name}<${b.parent ?? ''}`).join('|');
1011
1344
  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';
1345
+ if (bones(core.bones) !== bones(skeleton.bones)) return 'the bones (names, parents or order) differ';
1346
+ if (slots(core.slots) !== slots(skeleton.slots)) return 'the slots (names, bones or draw order) differ';
1015
1347
  const animations = (list: ReadonlyArray<{ name: string; duration: number }>): string =>
1016
1348
  list.map((a) => `${a.name}=${a.duration}`).sort().join('|');
1017
- if (animations(core.animations) !== animations(spine.animations)) return 'the animations (names or durations) differ';
1349
+ if (animations(core.animations) !== animations(skeleton.animations)) return 'the animations (names or durations) differ';
1018
1350
  return null;
1019
1351
  }
1020
1352
 
1021
1353
  /**
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`).
1354
+ * A `PoserChoice` over a core poser (or none) and a skeleton spine-core loads
1355
+ * only when something reads its side — `spine`, or the roster behind it.
1029
1356
  */
1030
- export function candidatePosers(
1031
- data: SkeletonData,
1357
+ function posersOver(
1358
+ forced: PoserName | undefined,
1359
+ core: { poser: Poser; roster: SkinRoster } | null,
1360
+ why: string,
1361
+ spineData: () => SkeletonData,
1362
+ ): PoserChoice {
1363
+ let spine: Poser | null = null;
1364
+ let spineRoster: SkinRoster | null = null;
1365
+ return {
1366
+ forced,
1367
+ core: core === null ? null : core.poser,
1368
+ why,
1369
+ get spine(): Poser {
1370
+ spine ??= spinePoser(spineData());
1371
+ return spine;
1372
+ },
1373
+ rosterOf: (poser) => {
1374
+ if (core !== null && poser === core.poser) return core.roster;
1375
+ spineRoster ??= skinRosterOf(spineData());
1376
+ return spineRoster;
1377
+ },
1378
+ };
1379
+ }
1380
+
1381
+ /**
1382
+ * The choice `candidatePosers` and `loadCandidate` share, refusing nothing:
1383
+ * the core poser when `skeleton.model.json` sits beside the skeleton, the
1384
+ * skeleton's bytes hash to the digest the document records (`spine.sha256`:
1385
+ * it is the file that build wrote, not one edited after it), the atlas is the
1386
+ * one beside it too, the core reads both and the document's rosters are the
1387
+ * skeleton's — read off the skeleton's own JSON (`skeletonRosters`), so
1388
+ * choosing loads nothing through spine-core (issue #1014).
1389
+ */
1390
+ function choosePosers(
1032
1391
  skeletonPath: string,
1033
1392
  atlasPath: string,
1393
+ /** The atlas file's text, or `null` when there is no file at `atlasPath` (issue #1020). */
1394
+ atlasText: string | null,
1034
1395
  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,
1037
- ): PoserChoice {
1038
- const spine = spinePoser(data);
1396
+ spineData: () => SkeletonData,
1397
+ make: MakeCorePoser,
1398
+ ): { choice: PoserChoice; document: CoreDocumentFacts | null } {
1039
1399
  const dir = dirname(resolve(skeletonPath));
1040
1400
  const modelPath = join(dir, MODEL_DOCUMENT_FILE);
1041
- let core: Poser | null = null;
1401
+ let core: { poser: Poser; roster: SkinRoster } | null = null;
1402
+ let document: CoreDocumentFacts | null = null;
1042
1403
  let why: string;
1043
1404
  if (forced === 'spine') why = '--poser spine';
1044
1405
  else if (!existsSync(modelPath)) why = `no ${MODEL_DOCUMENT_FILE} beside ${resolve(skeletonPath)} — a Spine export, not a rigc build`;
@@ -1048,11 +1409,20 @@ export function candidatePosers(
1048
1409
  "atlas's, and the core would pose them against another one's pages";
1049
1410
  } else {
1050
1411
  try {
1051
- const candidate = make(readFileSync(modelPath, 'utf8'), readFileSync(atlasPath, 'utf8'), modelPath, { path: resolve(skeletonPath), bytes: readFileSync(skeletonPath) });
1052
- const differs = rosterDifference(candidate, data);
1412
+ const modelText = readFileSync(modelPath, 'utf8');
1413
+ const bytes = readFileSync(skeletonPath);
1414
+ // No atlas is `''`: a rigc-compiled/2 document draws from its own `pages`, and a /1 document is refused by name (`placementOf`). An atlas that is there is held to the document's `pages` (#1016) and refused, naming the first difference, when it is not the one the build wrote.
1415
+ const candidate = make(modelText, atlasText ?? '', modelPath, { path: resolve(skeletonPath), bytes });
1416
+ const rosters = skeletonRosters(bytes.toString('utf8'));
1417
+ const differs = rosterDifference(candidate, rosters);
1053
1418
  if (differs === null) {
1054
- core = candidate;
1055
- why = modelPath;
1419
+ document = coreDocumentFacts(modelText, modelPath, rosters.skins);
1420
+ core = { poser: candidate, roster: document.roster };
1421
+ // The poser line names where the placement came from when it is not the document's own (issue #1020): a /1 document states none, and the core reads it from the atlas beside it.
1422
+ why =
1423
+ document.pageNames === null
1424
+ ? `${modelPath} — a ${document.spec} document, which does not state where each region sits on its page: that is read from ${resolve(atlasPath)}`
1425
+ : modelPath;
1056
1426
  } else why = `${modelPath} does not describe ${resolve(skeletonPath)}: ${differs}`;
1057
1427
  } catch (err) {
1058
1428
  if (!(err instanceof CoreInputError)) throw err;
@@ -1060,8 +1430,35 @@ export function candidatePosers(
1060
1430
  why = `the core refused ${err.message.startsWith(`${modelPath}: `) ? err.message : `${modelPath}: ${err.message}`}`;
1061
1431
  }
1062
1432
  }
1063
- if (forced === 'core' && core === null) throw new PoserChoiceError(`--poser core: ${why}`);
1064
- return { forced, core, why, spine };
1433
+ return { choice: posersOver(forced, core, why, spineData), document: core === null ? null : document };
1434
+ }
1435
+
1436
+ /**
1437
+ * `--poser core` on an input that cannot carry it, refused by name
1438
+ * (`PoserChoiceError`) — said by the caller where its refusals have always
1439
+ * stood, after the flags that refuse before it. Returns the choice otherwise.
1440
+ */
1441
+ export function refuseUnchosen(choice: PoserChoice): PoserChoice {
1442
+ if (choice.forced === 'core' && choice.core === null) throw new PoserChoiceError(`--poser core: ${choice.why}`);
1443
+ return choice;
1444
+ }
1445
+
1446
+ /**
1447
+ * Both posers for the skeleton at `skeletonPath` drawn through the atlas at
1448
+ * `atlasPath`, with spine-core's parse of it already in hand (`data`) — the
1449
+ * choice `loadCandidate` makes, for a caller that loaded the pair itself.
1450
+ * `forced` is `--poser`; `core` on an input that cannot carry it is refused by
1451
+ * name (`PoserChoiceError`).
1452
+ */
1453
+ export function candidatePosers(
1454
+ data: SkeletonData,
1455
+ skeletonPath: string,
1456
+ atlasPath: string,
1457
+ forced: PoserName | undefined,
1458
+ /** What builds the core poser: `corePoser` — the suite's `RC02` passes a planted copy, and nothing else passes any. */
1459
+ make: MakeCorePoser = corePoser,
1460
+ ): PoserChoice {
1461
+ return refuseUnchosen(choosePosers(skeletonPath, atlasPath, readFileSync(atlasPath, 'utf8'), forced, () => data, make).choice);
1065
1462
  }
1066
1463
 
1067
1464
  /**
@@ -1070,19 +1467,22 @@ export function candidatePosers(
1070
1467
  * (`CoreInputError`, e.g. a clip polygon that is not simple), in
1071
1468
  * which case `run` starts again from nothing on spine-core and `note` names the
1072
1469
  * refusal. Under `--poser core` that refusal is a `PoserChoiceError` instead.
1073
- * `run` must write nothing: a fallback re-runs it whole.
1470
+ * `run` must write nothing: a fallback re-runs it whole. It is handed the
1471
+ * skin roster behind the poser it runs (`PoserChoice.rosterOf`), so a run
1472
+ * through the core reads nothing through spine-core (issue #1014).
1074
1473
  */
1075
- export function throughPoser<T>(choice: PoserChoice, run: (poser: Poser) => T): { value: T; poser: PoserName; note: string } {
1474
+ export function throughPoser<T>(choice: PoserChoice, run: (poser: Poser, roster: SkinRoster) => T): { value: T; poser: PoserName; note: string } {
1475
+ const through = (poser: Poser): T => run(poser, choice.rosterOf(poser));
1076
1476
  if (choice.core !== null) {
1077
1477
  try {
1078
- return { value: run(choice.core), poser: 'core', note: `rigc core — ${choice.why}` };
1478
+ return { value: through(choice.core), poser: 'core', note: `rigc core — ${choice.why}` };
1079
1479
  } catch (err) {
1080
1480
  if (!(err instanceof CoreInputError)) throw err;
1081
1481
  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}` };
1482
+ return { value: through(choice.spine), poser: 'spine', note: `spine-core — the core refused this input: ${err.message}` };
1083
1483
  }
1084
1484
  }
1085
- return { value: run(choice.spine), poser: 'spine', note: `spine-core — ${choice.why}` };
1485
+ return { value: through(choice.spine), poser: 'spine', note: `spine-core — ${choice.why}` };
1086
1486
  }
1087
1487
 
1088
1488
  /**
@@ -1785,7 +2185,7 @@ function artUvsOf(attachment: MeshAttachment | RegionAttachment, region: Texture
1785
2185
  * sequence packs several regions under one name and `findRegion` returns only the
1786
2186
  * first of them.
1787
2187
  */
1788
- function regionKey(region: TextureAtlasRegion): string {
2188
+ function regionKey(region: { name: string; index: number }): string {
1789
2189
  return `${region.name.trim()}#${region.index}`;
1790
2190
  }
1791
2191
 
@@ -1795,28 +2195,86 @@ function regionKey(region: TextureAtlasRegion): string {
1795
2195
  */
1796
2196
  export const SUBSTITUTE_PAGE = 'texture-from:';
1797
2197
 
2198
+ /**
2199
+ * One region of a substituting atlas, as `substituteTexture` reads it: where
2200
+ * it sits on which page, and the mapping from the drawing's own coordinates
2201
+ * onto it (`MeshAttachment.computeUVs`'s job).
2202
+ */
2203
+ export interface SubstituteRegion {
2204
+ page: { name: string; width: number; height: number };
2205
+ x: number;
2206
+ y: number;
2207
+ width: number;
2208
+ height: number;
2209
+ degrees: number;
2210
+ /** Art-space UVs (`u, v` per vertex) mapped onto this region of its page, in doubles. */
2211
+ pageUvs(art: readonly number[]): number[];
2212
+ }
2213
+
1798
2214
  /** An atlas whose texels can stand in for another's — see `substituteTexture`. */
1799
2215
  export interface TextureSubstitution {
1800
2216
  /** Prefixed page name → the page, ready to merge into a render's page map. */
1801
2217
  pages: Map<string, Plate>;
1802
2218
  /** The atlas's regions, by the key both sides agree on — see `regionKey`. */
1803
- regions: Map<string, TextureAtlasRegion>;
2219
+ regions: Map<string, SubstituteRegion>;
1804
2220
  /** Every `scale:` the atlas text declares, in the order the pages declare them. */
1805
2221
  scales: number[];
1806
2222
  }
1807
2223
 
1808
- /** Load an atlas and its pages as a substitution source. */
1809
- export function textureSubstitutionFromText(atlasText: string, atlasDir: string): TextureSubstitution {
1810
- const atlas = new TextureAtlas(atlasText);
2224
+ /**
2225
+ * Who reads a substituting atlas (issue #1020): `rigc` — `parseAtlasText` and
2226
+ * `./core/uvs.ts`'s `computeUvs`, for a candidate the core poses, so a rigc
2227
+ * build's `check --texture-from` reads nothing through spine-core — or
2228
+ * `spine`, the runtime's `TextureAtlas` and `MeshAttachment.computeUVs`, for
2229
+ * everything spine-core poses (an export keeps the reading it always had).
2230
+ *
2231
+ * The two were measured to agree: the region list (`TextureAtlas.regions` is
2232
+ * file order, and so is `parseAtlasText`'s, the pairing the selftest holds on
2233
+ * every corpus atlas) and the mapping (`computeUvs`, bit for bit in doubles
2234
+ * over 6,000 calls — its header). The PR of #1020 carries the substituted
2235
+ * frames, pixel for pixel, on the corpus rows that exercise it.
2236
+ */
2237
+ export type SubstitutionReader = 'rigc' | 'spine';
2238
+
2239
+ /** Load an atlas and its pages as a substitution source, read by `reader` — see `SubstitutionReader`. */
2240
+ export function textureSubstitutionFromText(atlasText: string, atlasDir: string, reader: SubstitutionReader = 'spine'): TextureSubstitution {
2241
+ const regions = new Map<string, SubstituteRegion>();
2242
+ const names: string[] = [];
2243
+ if (reader === 'rigc') {
2244
+ for (const page of parseAtlasText(atlasText).pages) {
2245
+ names.push(page.name);
2246
+ const at = { name: page.name, width: page.width, height: page.height };
2247
+ for (const region of page.regions) {
2248
+ regions.set(regionKey(region), { page: at, x: region.x, y: region.y, width: region.width, height: region.height, degrees: region.degrees, pageUvs: (art) => computeUvs(region, at, art) });
2249
+ }
2250
+ }
2251
+ } else {
2252
+ const atlas = new TextureAtlas(atlasText);
2253
+ for (const page of atlas.pages) names.push(page.name);
2254
+ for (const region of atlas.regions) {
2255
+ const page = { name: region.page.name, width: region.page.width, height: region.page.height };
2256
+ regions.set(regionKey(region), {
2257
+ page,
2258
+ x: region.x,
2259
+ y: region.y,
2260
+ width: region.width,
2261
+ height: region.height,
2262
+ degrees: region.degrees,
2263
+ pageUvs: (art) => {
2264
+ const uvs = new Array<number>(art.length).fill(0);
2265
+ MeshAttachment.computeUVs(region, [...art], uvs);
2266
+ return uvs;
2267
+ },
2268
+ });
2269
+ }
2270
+ }
1811
2271
  const pages = new Map<string, Plate>();
1812
- for (const page of atlas.pages) {
1813
- if (page.name.startsWith(SUBSTITUTE_PAGE)) {
1814
- throw new Error(`atlas page "${page.name}" starts with the reserved prefix ${JSON.stringify(SUBSTITUTE_PAGE)}`);
2272
+ for (const name of names) {
2273
+ if (name.startsWith(SUBSTITUTE_PAGE)) {
2274
+ throw new Error(`atlas page "${name}" starts with the reserved prefix ${JSON.stringify(SUBSTITUTE_PAGE)}`);
1815
2275
  }
1816
- pages.set(SUBSTITUTE_PAGE + page.name, readPlate(join(atlasDir, page.name)));
2276
+ pages.set(SUBSTITUTE_PAGE + name, readPlate(join(atlasDir, name)));
1817
2277
  }
1818
- const regions = new Map<string, TextureAtlasRegion>();
1819
- for (const region of atlas.regions) regions.set(regionKey(region), region);
1820
2278
  return { pages, regions, scales: atlasScales(atlasText) };
1821
2279
  }
1822
2280
 
@@ -1862,7 +2320,7 @@ export function atlasScales(atlasText: string): number[] {
1862
2320
  * rectangle; two of them had it wrong at 270 (issue #579), so it is one function
1863
2321
  * now and this is one of its callers.
1864
2322
  */
1865
- function windowOf(region: TextureAtlasRegion): UvWindow {
2323
+ function windowOf(region: SubstituteRegion): UvWindow {
1866
2324
  const rect = pageFootprint(region);
1867
2325
  const page = region.page;
1868
2326
  return {
@@ -1918,17 +2376,16 @@ export function substituteTexture(
1918
2376
  pieces.push(piece);
1919
2377
  continue;
1920
2378
  }
1921
- const uvs = new Array<number>(texture.artUvs.length).fill(0);
1922
- // `spine-core`'s own mapping from the drawing's coordinates into a page's,
1923
- // which is where `rotate:` (all four of them) and the trim offsets are
1924
- // handled. Calling it rather than repeating it is what keeps this from being
1925
- // a second opinion about the atlas format.
1926
- MeshAttachment.computeUVs(region, texture.artUvs, uvs);
2379
+ // The mapping from the drawing's coordinates into a page's, which is where
2380
+ // `rotate:` (all four of them) and the trim offsets are handled: spine-core's
2381
+ // own `MeshAttachment.computeUVs`, or `./core/uvs.ts`'s `computeUvs`, measured
2382
+ // equal to it bit for bit — never a third opinion about the atlas format
2383
+ // (`SubstitutionReader`).
2384
+ const uvs = region.pageUvs(texture.artUvs);
1927
2385
  // A clipped piece's source map is re-seated the same way, through the drawing's own coordinates (`Mesh.source`).
1928
2386
  if (piece.kind === 'mesh' && piece.source !== undefined) {
1929
2387
  if (texture.sourceArtUvs === undefined) throw new Error(`slot "${piece.slot}": a clipped piece carries no original-art UVs for its source triangles`);
1930
- const sourceUvs = new Array<number>(texture.sourceArtUvs.length).fill(0);
1931
- MeshAttachment.computeUVs(region, texture.sourceArtUvs, sourceUvs);
2388
+ const sourceUvs = region.pageUvs(texture.sourceArtUvs);
1932
2389
  pieces.push({ ...piece, page: SUBSTITUTE_PAGE + region.page.name, uvs, uvWindow: windowOf(region), source: { world: piece.source.world, uvs: sourceUvs } });
1933
2390
  continue;
1934
2391
  }
@@ -2135,6 +2592,10 @@ export class UnframeablePoseError extends GeometryError {}
2135
2592
  * structure the unframeable sentence names a skin from (issue #997). Not a
2136
2593
  * poser's: the `Poser` seam carries no skin roster, and both posers pose the one
2137
2594
  * Spine file this is read from (the core's document is bound to it by hash).
2595
+ * Two readings stand behind it — the runtime's (`skinRosterOf`) and the model
2596
+ * document's (`coreSkinRoster` in `./render_core.ts`, issue #1014), measured
2597
+ * to name the same bones under every skin — and `PoserChoice.rosterOf` hands
2598
+ * each poser its own.
2138
2599
  */
2139
2600
  export interface SkinRoster {
2140
2601
  /** Every skin, in declaration order. */
@@ -2162,7 +2623,7 @@ export function skinRosterOf(data: SkeletonData): SkinRoster {
2162
2623
 
2163
2624
  /** The roster behind a pose source: its own when it is Spine data, else the one the caller passed. */
2164
2625
  function rosterBehind(source: PoseSource, roster: SkinRoster | undefined): SkinRoster | undefined {
2165
- return source instanceof SkeletonData ? skinRosterOf(source) : roster;
2626
+ return isPoser(source) ? roster : skinRosterOf(source);
2166
2627
  }
2167
2628
 
2168
2629
  /**