spine-rigc 1.7.0 → 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.
package/src/render.ts CHANGED
@@ -83,9 +83,10 @@
83
83
  * `./render_core.ts`, and this file keeps the runtime for the export's poser,
84
84
  * the export's atlas pages and texture substitution — see *which poser* below.
85
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
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
89
90
  * — `src/compile.ts` must stay independent of the runtime so the compiler and
90
91
  * the gate are not checking each other's assumptions — and this file is neither.
91
92
  * It also imports `tools/plate.ts` for the PNG codec, which is dependency-free.
@@ -111,8 +112,19 @@ import { dirname, join, resolve } from 'node:path';
111
112
  import { Plate, readPlate, type RGBA } from '../tools/plate.ts';
112
113
  import { pageFootprint, parseAtlasText } from './atlas.ts';
113
114
  import { CoreInputError } from './core/index.ts';
115
+ import { computeUvs } from './core/uvs.ts';
114
116
  import { MODEL_DOCUMENT_FILE } from './model.ts';
115
- import { clipSourceOf, corePoser, coreSkinRoster, inactiveBoneSnapshot, SlotSubsetError, subsetOver, unposedBones, type SubsetRoster } 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';
116
128
  import { walkTimelines } from './timelines.ts';
117
129
 
118
130
  /** Opaque, and light: both of rung 3's parts are dark slate, so is every ground. */
@@ -759,6 +771,14 @@ export function posableFromText(skeletonText: string, atlasText: string, atlasDi
759
771
  // nineteen built corpus rows and the twelve editor exports (the PR of #1014
760
772
  // carries the counts). A Spine export, `--poser spine` and a fallback the
761
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.
762
782
 
763
783
  /** What `render` and `check` read off a candidate's skeleton besides its pose. */
764
784
  export interface SkeletonFacts {
@@ -859,13 +879,35 @@ function skeletonDurations(root: JsonObject): Array<{ name: string; duration: nu
859
879
  });
860
880
  }
861
881
 
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> {
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> {
864
884
  const pages = new Map<string, Plate>();
865
- for (const page of parseAtlasText(atlasText).pages) pages.set(page.name, readPlate(join(atlasDir, page.name)));
885
+ for (const name of names) pages.set(name, readPlate(join(atlasDir, name)));
866
886
  return pages;
867
887
  }
868
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
+
869
911
  /**
870
912
  * Posing an input through spine-core when the runtime cannot be reached —
871
913
  * refused naming the input and why it needs the runtime (issue #1014).
@@ -900,9 +942,13 @@ export interface Candidate {
900
942
 
901
943
  /**
902
944
  * 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,
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,
906
952
  * `--poser spine`, a candidate handed over as text (`paths` null, `unplaced`
907
953
  * the reason) — is loaded through spine-core as `posableFromText` always
908
954
  * loaded it. The choice's spine-core poser stays unloaded until something
@@ -913,31 +959,66 @@ export interface Candidate {
913
959
  * refuse before it.
914
960
  */
915
961
  export function loadCandidate(
916
- input: { skeletonText: string; atlasText: string; atlasDir: string; label: string },
962
+ input: { skeletonText: string; atlasText: string | null; atlasDir: string; label: string },
917
963
  paths: { skeleton: string; atlas: string } | null,
918
964
  forced: PoserName | undefined,
919
965
  options: { make?: MakeCorePoser; unplaced?: string } = {},
920
966
  ): Candidate {
921
967
  let data: SkeletonData | null = null;
922
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
+ };
923
974
  const spineData = (): SkeletonData => {
924
975
  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));
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));
927
980
  }
928
981
  return data;
929
982
  };
930
- choice =
983
+ const chosen =
931
984
  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) };
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);
935
994
  requireSpineRuntime(input.label, choice.why);
936
- const posable = posableFromText(input.skeletonText, input.atlasText, input.atlasDir);
995
+ const posable = posableFromText(input.skeletonText, text, input.atlasDir);
937
996
  data = posable.data;
938
997
  return { choice, facts: spineFacts(posable.data), pages: posable.pages };
939
998
  }
940
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
+
941
1022
  // ---------------------------------------------------------------------------
942
1023
  // the posing seam (issue #965, step 3a of #380)
943
1024
  // ---------------------------------------------------------------------------
@@ -962,9 +1043,10 @@ export function loadCandidate(
962
1043
  // The framing samples are not a fourth entry: `framingViewport` is the same
963
1044
  // animation entry at `FRAMING_FPS` with the clip off.
964
1045
  //
965
- // 🔒 What stays spine-core's and outside the seam, deliberately: the atlas
966
- // (`posableFromText`'s pages, `substituteTexture`'s region lookup — step 3c of
967
- // #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
968
1050
  // skeleton it stepped itself (A10), so the round trip keeps its spine-core
969
1051
  // entry whatever poses the renders.
970
1052
 
@@ -1308,13 +1390,16 @@ function posersOver(
1308
1390
  function choosePosers(
1309
1391
  skeletonPath: string,
1310
1392
  atlasPath: string,
1393
+ /** The atlas file's text, or `null` when there is no file at `atlasPath` (issue #1020). */
1394
+ atlasText: string | null,
1311
1395
  forced: PoserName | undefined,
1312
1396
  spineData: () => SkeletonData,
1313
1397
  make: MakeCorePoser,
1314
- ): PoserChoice {
1398
+ ): { choice: PoserChoice; document: CoreDocumentFacts | null } {
1315
1399
  const dir = dirname(resolve(skeletonPath));
1316
1400
  const modelPath = join(dir, MODEL_DOCUMENT_FILE);
1317
1401
  let core: { poser: Poser; roster: SkinRoster } | null = null;
1402
+ let document: CoreDocumentFacts | null = null;
1318
1403
  let why: string;
1319
1404
  if (forced === 'spine') why = '--poser spine';
1320
1405
  else if (!existsSync(modelPath)) why = `no ${MODEL_DOCUMENT_FILE} beside ${resolve(skeletonPath)} — a Spine export, not a rigc build`;
@@ -1326,12 +1411,18 @@ function choosePosers(
1326
1411
  try {
1327
1412
  const modelText = readFileSync(modelPath, 'utf8');
1328
1413
  const bytes = readFileSync(skeletonPath);
1329
- const candidate = make(modelText, readFileSync(atlasPath, 'utf8'), modelPath, { path: resolve(skeletonPath), bytes });
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 });
1330
1416
  const rosters = skeletonRosters(bytes.toString('utf8'));
1331
1417
  const differs = rosterDifference(candidate, rosters);
1332
1418
  if (differs === null) {
1333
- core = { poser: candidate, roster: coreSkinRoster(modelText, modelPath, rosters.skins) };
1334
- 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;
1335
1426
  } else why = `${modelPath} does not describe ${resolve(skeletonPath)}: ${differs}`;
1336
1427
  } catch (err) {
1337
1428
  if (!(err instanceof CoreInputError)) throw err;
@@ -1339,7 +1430,7 @@ function choosePosers(
1339
1430
  why = `the core refused ${err.message.startsWith(`${modelPath}: `) ? err.message : `${modelPath}: ${err.message}`}`;
1340
1431
  }
1341
1432
  }
1342
- return posersOver(forced, core, why, spineData);
1433
+ return { choice: posersOver(forced, core, why, spineData), document: core === null ? null : document };
1343
1434
  }
1344
1435
 
1345
1436
  /**
@@ -1367,7 +1458,7 @@ export function candidatePosers(
1367
1458
  /** What builds the core poser: `corePoser` — the suite's `RC02` passes a planted copy, and nothing else passes any. */
1368
1459
  make: MakeCorePoser = corePoser,
1369
1460
  ): PoserChoice {
1370
- return refuseUnchosen(choosePosers(skeletonPath, atlasPath, forced, () => data, make));
1461
+ return refuseUnchosen(choosePosers(skeletonPath, atlasPath, readFileSync(atlasPath, 'utf8'), forced, () => data, make).choice);
1371
1462
  }
1372
1463
 
1373
1464
  /**
@@ -2094,7 +2185,7 @@ function artUvsOf(attachment: MeshAttachment | RegionAttachment, region: Texture
2094
2185
  * sequence packs several regions under one name and `findRegion` returns only the
2095
2186
  * first of them.
2096
2187
  */
2097
- function regionKey(region: TextureAtlasRegion): string {
2188
+ function regionKey(region: { name: string; index: number }): string {
2098
2189
  return `${region.name.trim()}#${region.index}`;
2099
2190
  }
2100
2191
 
@@ -2104,28 +2195,86 @@ function regionKey(region: TextureAtlasRegion): string {
2104
2195
  */
2105
2196
  export const SUBSTITUTE_PAGE = 'texture-from:';
2106
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
+
2107
2214
  /** An atlas whose texels can stand in for another's — see `substituteTexture`. */
2108
2215
  export interface TextureSubstitution {
2109
2216
  /** Prefixed page name → the page, ready to merge into a render's page map. */
2110
2217
  pages: Map<string, Plate>;
2111
2218
  /** The atlas's regions, by the key both sides agree on — see `regionKey`. */
2112
- regions: Map<string, TextureAtlasRegion>;
2219
+ regions: Map<string, SubstituteRegion>;
2113
2220
  /** Every `scale:` the atlas text declares, in the order the pages declare them. */
2114
2221
  scales: number[];
2115
2222
  }
2116
2223
 
2117
- /** Load an atlas and its pages as a substitution source. */
2118
- export function textureSubstitutionFromText(atlasText: string, atlasDir: string): TextureSubstitution {
2119
- 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
+ }
2120
2271
  const pages = new Map<string, Plate>();
2121
- for (const page of atlas.pages) {
2122
- if (page.name.startsWith(SUBSTITUTE_PAGE)) {
2123
- 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)}`);
2124
2275
  }
2125
- pages.set(SUBSTITUTE_PAGE + page.name, readPlate(join(atlasDir, page.name)));
2276
+ pages.set(SUBSTITUTE_PAGE + name, readPlate(join(atlasDir, name)));
2126
2277
  }
2127
- const regions = new Map<string, TextureAtlasRegion>();
2128
- for (const region of atlas.regions) regions.set(regionKey(region), region);
2129
2278
  return { pages, regions, scales: atlasScales(atlasText) };
2130
2279
  }
2131
2280
 
@@ -2171,7 +2320,7 @@ export function atlasScales(atlasText: string): number[] {
2171
2320
  * rectangle; two of them had it wrong at 270 (issue #579), so it is one function
2172
2321
  * now and this is one of its callers.
2173
2322
  */
2174
- function windowOf(region: TextureAtlasRegion): UvWindow {
2323
+ function windowOf(region: SubstituteRegion): UvWindow {
2175
2324
  const rect = pageFootprint(region);
2176
2325
  const page = region.page;
2177
2326
  return {
@@ -2227,17 +2376,16 @@ export function substituteTexture(
2227
2376
  pieces.push(piece);
2228
2377
  continue;
2229
2378
  }
2230
- const uvs = new Array<number>(texture.artUvs.length).fill(0);
2231
- // `spine-core`'s own mapping from the drawing's coordinates into a page's,
2232
- // which is where `rotate:` (all four of them) and the trim offsets are
2233
- // handled. Calling it rather than repeating it is what keeps this from being
2234
- // a second opinion about the atlas format.
2235
- 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);
2236
2385
  // A clipped piece's source map is re-seated the same way, through the drawing's own coordinates (`Mesh.source`).
2237
2386
  if (piece.kind === 'mesh' && piece.source !== undefined) {
2238
2387
  if (texture.sourceArtUvs === undefined) throw new Error(`slot "${piece.slot}": a clipped piece carries no original-art UVs for its source triangles`);
2239
- const sourceUvs = new Array<number>(texture.sourceArtUvs.length).fill(0);
2240
- MeshAttachment.computeUVs(region, texture.sourceArtUvs, sourceUvs);
2388
+ const sourceUvs = region.pageUvs(texture.sourceArtUvs);
2241
2389
  pieces.push({ ...piece, page: SUBSTITUTE_PAGE + region.page.name, uvs, uvWindow: windowOf(region), source: { world: piece.source.world, uvs: sourceUvs } });
2242
2390
  continue;
2243
2391
  }
@@ -10,8 +10,8 @@
10
10
  * its raw entry to the renderer's shapes.
11
11
  *
12
12
  * ⭐ Why a module of its own rather than a second block in `src/render.ts`.
13
- * `render.ts` links spine-core (the atlas pages, texture substitution and
14
- * `spinePoser`), so a core poser written there would share an import list
13
+ * `render.ts` links spine-core (an export's atlas pages and texture
14
+ * substitution, and `spinePoser`), so a core poser written there would share an import list
15
15
  * with the runtime it replaces — and "the core poser imports nothing from
16
16
  * spine-core" could then only be said, not read off the file. Here it can be:
17
17
  * the value imports below are the core, the atlas reader and nothing else, and
@@ -278,8 +278,12 @@ interface CoreInput {
278
278
  * The document posed with no skin set — spine-core's initial state, every slot
279
279
  * resolved through the default skin alone — or refused by name where that
280
280
  * equivalence was not measured (the header's *The skin*).
281
+ *
282
+ * Exported for the model side of the validator (issue #1025), which poses a
283
+ * slot the way `validate()` does — a fresh skeleton, no skin set — and must
284
+ * resolve that state by this rule rather than by a copy of it.
281
285
  */
282
- function noSkinView(doc: CompiledDocument): CompiledDocument {
286
+ export function noSkinView(doc: CompiledDocument): CompiledDocument {
283
287
  if (doc.skins.length === 0) return underSkin(doc, CORE_ALL_SKINS);
284
288
  const fallback = doc.skins.find((k) => k.name === CORE_DEFAULT_SKIN);
285
289
  if (fallback === undefined) {
@@ -662,18 +666,74 @@ export function corePoser(modelText: string, atlasText: string, where = 'skeleto
662
666
  *
663
667
  * Measured against the runtime's reading (`skinRosterOf`, a fresh skeleton's
664
668
  * `active` under each skin and under none) on every rigc build the tree
665
- * carries: the same bones, under every skin. The document is read on the first
666
- * question, so a render that frames nothing under a skin never parses it twice.
669
+ * carries: the same bones, under every skin. Since issue #1020 it is handed the
670
+ * document `coreDocumentFacts` read for the run's other facts, so the roster
671
+ * costs no reading of its own; a skin's view is still built only when a
672
+ * question is asked of it.
667
673
  */
668
- export function coreSkinRoster(modelText: string, where: string, skins: readonly string[]): SkinRoster {
669
- let doc: CompiledDocument | null = null;
674
+ function coreSkinRoster(doc: CompiledDocument, skins: readonly string[]): SkinRoster {
670
675
  return {
671
676
  skins,
672
677
  unposedUnder: (skin) => {
673
- doc ??= readModel(modelText, where);
674
678
  const view = skin === undefined ? noSkinView(doc) : underSkin(doc, skin);
675
679
  const active = activeBones(view);
676
680
  return unposedBones(view.bones.map((b) => ({ name: b.name, parent: b.parent ?? null, active: active.has(b.name) })));
677
681
  },
678
682
  };
679
683
  }
684
+
685
+ /**
686
+ * What `render` and `check` read off a rigc build's model document besides the
687
+ * pose (issue #1020) — so a build the core poses draws with its
688
+ * `skeleton.atlas` gone, and reads off `skeleton.json` only what the document
689
+ * does not state.
690
+ */
691
+ export interface CoreDocumentFacts {
692
+ /** The document's spec: `rigc-compiled/2` states where each region sits on its page, `rigc-compiled/1` does not. */
693
+ spec: string;
694
+ /**
695
+ * Every page the `pages` section states, by name, in file order — the
696
+ * images the draw samples, read by these names; `null` for a
697
+ * `rigc-compiled/1` document, whose pages only its atlas names.
698
+ */
699
+ pageNames: readonly string[] | null;
700
+ /** The skin roster behind the core poser (`coreSkinRoster`), over this one reading. */
701
+ roster: SkinRoster;
702
+ /**
703
+ * The slot subset's roster (`subsetOver`): the slots in the document's draw
704
+ * order, its default skin, and the skins the document files a slot's
705
+ * attachments under. A refusal lists those skins, and the order it lists
706
+ * them in is the Spine file's, which the document does not hold
707
+ * (`./core/index.ts`, *Several skins filling one placeholder*) — so they are
708
+ * put in `skins`' order, the Spine file's own list.
709
+ */
710
+ subset: SubsetRoster;
711
+ }
712
+
713
+ /**
714
+ * The document's facts for `render` and `check` (`CoreDocumentFacts`), read
715
+ * once. `skins` is the Spine file's skin list, in its order — the one fact
716
+ * here the document does not state. Refused by `CoreInputError` where
717
+ * `readModel` refuses the document.
718
+ */
719
+ export function coreDocumentFacts(modelText: string, where: string, skins: readonly string[]): CoreDocumentFacts {
720
+ const doc = readModel(modelText, where);
721
+ const rank = (name: string): number => {
722
+ const at = skins.indexOf(name);
723
+ return at < 0 ? skins.length : at;
724
+ };
725
+ return {
726
+ spec: doc.spec,
727
+ pageNames: doc.pages === null ? null : doc.pages.map((page) => page.name),
728
+ roster: coreSkinRoster(doc, skins),
729
+ subset: {
730
+ declared: doc.slots.map((s) => s.name),
731
+ carriers: (slot) =>
732
+ doc.skins
733
+ .filter((k) => Object.keys(k.attachments[slot] ?? {}).length > 0)
734
+ .map((k) => k.name)
735
+ .sort((a, b) => rank(a) - rank(b)),
736
+ defaultSkin: doc.skins.some((k) => k.name === CORE_DEFAULT_SKIN) ? CORE_DEFAULT_SKIN : null,
737
+ },
738
+ };
739
+ }
package/src/transform.ts CHANGED
@@ -33,13 +33,38 @@
33
33
  * ⭐ **`computeWorldTransforms` is the core's evaluator** (`worldTransforms` in
34
34
  * [`src/core/world.ts`](core/world.ts), issue #1015), whose every rule — the five
35
35
  * inherit modes, the collapsed parent axis, the operation orders — was measured
36
- * against the runtime's pose dump and is held there by the core suite. This file
37
- * hands it the compiler's arithmetic (`COMPILER_ARITHMETIC`, below) and keeps
38
- * what is the compiler's own: the coordinate contract, the refusals by name, the
39
- * world rotation a region cancels, and the inverses.
36
+ * against the runtime's pose dump and is held there by the core suite. Since
37
+ * issue #1021 it runs under the runtime's own arithmetic (`RUNTIME_ARITHMETIC`,
38
+ * the evaluator's default), so the matrices every point is bound through are the
39
+ * ones the runtime poses the build with, to the bit. This file keeps what is the
40
+ * compiler's own: the coordinate contract, the refusals by name, the world
41
+ * rotation a region cancels, and the inverses.
42
+ *
43
+ * ⚠️ **Why the runtime's arithmetic and not the textbook's** (issue #1021). A
44
+ * point is bound by inverting a setup matrix; the runtime then poses it through
45
+ * its own. The two used to differ in the last bits — `Math.PI` against the
46
+ * runtime's 3.1415927, `[cos, −sin, sin, cos]` against the y column taken at
47
+ * `rotation + 90`, radians to degrees by division against multiplication — and
48
+ * that is not noise below the file's precision: an unrotated root reads
49
+ * `b = −2.3e-8` in the runtime, so a point 256 units above its bone was bound
50
+ * 5.9e-6 off before the float32 spelling was even applied. Measured from inside
51
+ * `compile` (every authored world position `toBoneLocal` was handed) against
52
+ * spine-core's pose of the emitted build, the old arithmetic put `gallery/look`'s
53
+ * 312 bound points up to 8.6e-5 from where they were authored (RMS 5.1e-5) and
54
+ * `gallery/flex`'s 258 up to 2.3e-5; the runtime's puts every one of them at the
55
+ * float32 floor — the distance the float32 spelling of the exact inverse alone
56
+ * accounts for (look 8.1e-6, flex 4.1e-6). `CO24` holds it.
57
+ *
58
+ * 🔸 **The rule has a second half: what is MEASURED reads the exact frame.**
59
+ * The runtime's arithmetic is for what is bound — the matrices a point is
60
+ * inverted through. Anything the compiler measures of the authored rig and
61
+ * reports, compares against a threshold, sorts by or refuses on is a statement
62
+ * about the spec, and reads `computeExactFrameTransforms` (below): a depth turn
63
+ * ceiling, a ring's control angles, a `segments` mesh's segments and falloff.
64
+ * An angle the author stated as 90 has to print as 90.
40
65
  */
41
66
  import type { ModelBone } from './model.ts';
42
- import { modeMatrix, worldTransforms, type CoreWorld, type WorldArithmetic } from './core/world.ts';
67
+ import { modeMatrix, RUNTIME_ARITHMETIC, worldTransforms, type CoreWorld, type WorldArithmetic } from './core/world.ts';
43
68
 
44
69
  /**
45
70
  * What `computeWorldTransforms` reads off a bone — a structural type, so both a
@@ -78,26 +103,6 @@ export interface BoneTransform {
78
103
 
79
104
  export class TransformError extends Error {}
80
105
 
81
- const DEG = Math.PI / 180;
82
-
83
- /**
84
- * The arithmetic every figure rigc emits was computed with, handed to the core's
85
- * evaluator (issue #1015). It differs from the runtime's in the last bit and in
86
- * no matrix: degrees to radians with `Math.PI` rather than the runtime's
87
- * 3.1415927, radians back to degrees by dividing by that factor, and a bone with
88
- * the default scale and no shear framed as `lb = -sin`, `ld = cos` rather than
89
- * `cos`/`sin` of `rot + 90°`. One bit can move a float32 spelling in the file, so
90
- * this is a parameter and not a rewrite: measured with the runtime's arithmetic
91
- * instead, 2 of the 19 public recipes moved by at most 4e-5. Whether the compiler
92
- * should bind with the runtime's constant is a question of its own, not settled
93
- * here.
94
- */
95
- const COMPILER_ARITHMETIC: WorldArithmetic = {
96
- radiansPerDegree: DEG,
97
- degreesOf: (radians) => radians / DEG,
98
- rotationOnlyFrame: true,
99
- };
100
-
101
106
  /**
102
107
  * Crop pixels (y down, origin top-left) -> Spine world (y up, origin at the
103
108
  * bottom-left of the crop).
@@ -113,6 +118,20 @@ export function cropToSpineY(cropY: number, cropHeight: number): number {
113
118
  return cropHeight - cropY;
114
119
  }
115
120
 
121
+ /**
122
+ * The textbook's arithmetic — `Math.PI`, a bone with the default scale and no
123
+ * shear framed as `[cos, −sin, sin, cos]`, radians to degrees by dividing by
124
+ * the degree factor — in which an unrotated bone's frame is the identity
125
+ * exactly. Every point the compiler binds goes through the runtime's instead
126
+ * (the header's ⚠️); this is what `computeExactFrameTransforms` evaluates
127
+ * under, and nothing else.
128
+ */
129
+ const EXACT_FRAME_ARITHMETIC: WorldArithmetic = {
130
+ radiansPerDegree: Math.PI / 180,
131
+ degreesOf: (radians) => radians / (Math.PI / 180),
132
+ rotationOnlyFrame: true,
133
+ };
134
+
116
135
  /**
117
136
  * World transform of every bone, in declaration order.
118
137
  *
@@ -120,14 +139,44 @@ export function cropToSpineY(cropY: number, cropHeight: number): number {
120
139
  * requires (it resolves `parent` by name against the bones already read), so a
121
140
  * violation here is a violation there.
122
141
  *
123
- * The matrices and origins are the core's (`worldTransforms`, under
124
- * `COMPILER_ARITHMETIC`). What is decided here is what the core does not say:
142
+ * The matrices and origins are the core's (`worldTransforms`, under the
143
+ * runtime's arithmetic — the header's ⚠️). What is decided here is what the core does not say:
125
144
  * an undeclared parent and an `inherit` no mode answers to are refused by name
126
145
  * as `TransformError`, a bone whose `parent` is empty is a root as it always was
127
146
  * here, and `worldRotation` is read off the matrix — a root with the default
128
- * scale and no shear keeps its stated rotation, unwrapped.
147
+ * scale and no shear keeps its stated rotation, unwrapped. The angle is turned
148
+ * into degrees as the runtime turns one (`RUNTIME_DEG`, by multiplying), so
149
+ * the rotation a region writes to cancel it is read back by the runtime as
150
+ * that angle.
129
151
  */
130
152
  export function computeWorldTransforms(bones: readonly PosableBone[]): Map<string, BoneTransform> {
153
+ return evaluate(bones, RUNTIME_ARITHMETIC);
154
+ }
155
+
156
+ /**
157
+ * The same bones under `EXACT_FRAME_ARITHMETIC`: the frame the compiler
158
+ * MEASURES the authored rig in, and binds nothing through (issue #1021) — a
159
+ * mesh's depth turn ceiling (`sampleMeshDepth` in `compile.ts`), a ring's
160
+ * control angles (`ringControlAngles`, which orders the split and refuses a
161
+ * tie by printing the angle), and a `segments` mesh's segments, the distances
162
+ * its falloff and `minWeight` read and its shared-origin refusal. It differs from
163
+ * `computeWorldTransforms` only in the last bits, and that is exactly why it is
164
+ * kept (issue #1021): the ceiling is a statement about the authored mesh, and
165
+ * its reader treats an axis whose area term is exactly zero as one that cannot
166
+ * fold. The runtime frames an unrotated bone with `b = −2.3e-8`, so in its
167
+ * bind space a sheet whose depth rises linearly in x — which cannot fold a
168
+ * pitch at any angle — reads a pitch ceiling of 89.99997°, and `gallery/look`'s
169
+ * two hair locks gained four such ceilings with their counts (`TC03`, `TC06`
170
+ * went red on it). A control angle read through the runtime's frame printed
171
+ * an authored 90 as 89.99999734089101 in its tie refusal (`RF46`). On the 19
172
+ * recipes the only bytes this keeps from moving are look's ceilings; no
173
+ * emitted position goes through it.
174
+ */
175
+ export function computeExactFrameTransforms(bones: readonly PosableBone[]): Map<string, BoneTransform> {
176
+ return evaluate(bones, EXACT_FRAME_ARITHMETIC);
177
+ }
178
+
179
+ function evaluate(bones: readonly PosableBone[], arithmetic: WorldArithmetic): Map<string, BoneTransform> {
131
180
  const model: ModelBone[] = [];
132
181
  const declared = new Set<string>();
133
182
  for (const bone of bones) {
@@ -149,12 +198,12 @@ export function computeWorldTransforms(bones: readonly PosableBone[]): Map<strin
149
198
  });
150
199
  declared.add(bone.name);
151
200
  }
152
- const worlds = worldTransforms(model, null, modeMatrix, COMPILER_ARITHMETIC);
201
+ const worlds = worldTransforms(model, null, modeMatrix, arithmetic);
153
202
  const out = new Map<string, BoneTransform>();
154
203
  for (const bone of model) {
155
204
  const w = worlds.get(bone.name) as CoreWorld;
156
205
  const plain = (bone.scaleX ?? 1) === 1 && (bone.scaleY ?? 1) === 1 && (bone.shearX ?? 0) === 0 && (bone.shearY ?? 0) === 0;
157
- const worldRotation = bone.parent === undefined && plain ? (bone.rotation ?? 0) : (Math.atan2(w.c, w.a) / DEG + 360) % 360;
206
+ const worldRotation = bone.parent === undefined && plain ? (bone.rotation ?? 0) : (arithmetic.degreesOf(Math.atan2(w.c, w.a)) + 360) % 360;
158
207
  out.set(bone.name, { a: w.a, b: w.b, c: w.c, d: w.d, worldX: w.worldX, worldY: w.worldY, worldRotation });
159
208
  }
160
209
  return out;