spine-rigc 1.9.1 → 1.9.2

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
@@ -126,6 +126,7 @@ import {
126
126
  type SubsetRoster,
127
127
  } from './render_core.ts';
128
128
  import { walkTimelines } from './timelines.ts';
129
+ import { firstNonFinite, type PosedVertices, type WorldTransform } from './nonfinite.ts';
129
130
 
130
131
  /** Opaque, and light: both of rung 3's parts are dark slate, so is every ground. */
131
132
  export const BACKGROUND: RGBA = [232, 232, 232, 255];
@@ -891,10 +892,20 @@ function skeletonDurations(root: JsonObject): Array<{ name: string; duration: nu
891
892
  });
892
893
  }
893
894
 
894
- /** Every page named, read from `atlasDir` by that name — the images a candidate the core poses is drawn from. */
895
- function pagesNamed(names: readonly string[], atlasDir: string): Map<string, Plate> {
895
+ /**
896
+ * Every page named, read from `atlasDir` by that name — the images a candidate
897
+ * is drawn from, whichever poser draws it. A page that is not there is
898
+ * `absent`'s refusal, handed the page's absolute path (issues #1033, #1042):
899
+ * left to `readPlate`, it surfaced as an ENOENT and a stack. A page that is
900
+ * there and is not a PNG says so itself (`readPlate`).
901
+ */
902
+ function pagesAt(names: readonly string[], atlasDir: string, absent: (page: string) => Error): Map<string, Plate> {
896
903
  const pages = new Map<string, Plate>();
897
- for (const name of names) pages.set(name, readPlate(join(atlasDir, name)));
904
+ for (const name of names) {
905
+ const path = join(atlasDir, name);
906
+ if (!existsSync(path)) throw absent(resolve(path));
907
+ pages.set(name, readPlate(path));
908
+ }
898
909
  return pages;
899
910
  }
900
911
 
@@ -959,6 +970,11 @@ function requireSpineRuntime(label: string, why: string): void {
959
970
  * `cli.ts` refuses it as an invocation (exit 2, nothing written), as it
960
971
  * refuses a missing atlas on an export: the files the command was pointed at
961
972
  * have to change, not the rig.
973
+ *
974
+ * Since issue #1042 it is also `bonedist`'s (and `bench --bones`'), on either
975
+ * side (`loadPosedSkeleton`), and the refusal for a page a candidate is drawn
976
+ * from that is not there on every poser's path — the core's included, where a
977
+ * build moved whole away from where it was built died on `readPlate`'s ENOENT.
962
978
  */
963
979
  export class CandidatePairError extends Error {}
964
980
 
@@ -976,24 +992,102 @@ function notOneBuild(dir: string): string {
976
992
  );
977
993
  }
978
994
 
979
- /** The refusal for a skeleton the runtime could not load against its atlas: `runtime` is the runtime's own message. */
980
- function pairRefusal(label: string, paths: { skeleton: string; atlas: string } | null, why: string, runtime: string): CandidatePairError {
995
+ /**
996
+ * The refusal for a skeleton the runtime could not load against its atlas:
997
+ * `runtime` is the runtime's own message, and `does` what spine-core was
998
+ * loading it to do — `draws` for `render` and `check`, `poses` for a command
999
+ * that reads the posed bones and draws nothing (`bonedist`, issue #1042).
1000
+ */
1001
+ function pairRefusal(
1002
+ label: string,
1003
+ paths: { skeleton: string; atlas: string } | null,
1004
+ why: string,
1005
+ runtime: string,
1006
+ does: 'draws' | 'poses' = 'draws',
1007
+ ): CandidatePairError {
981
1008
  const { atlas, build } = pairPlace(paths);
982
- const head = `${label} does not load against ${atlas}: spine-core draws this pair (${why}) and could not resolve it — ${JSON.stringify(runtime)}. `;
1009
+ const head = `${label} does not load against ${atlas}: spine-core ${does} this pair (${why}) and could not resolve it — ${JSON.stringify(runtime)}. `;
983
1010
  return new CandidatePairError(head + (build === null ? 'The skeleton and the atlas are not one pair: the atlas has to be the one the skeleton was exported or built with' : notOneBuild(build)));
984
1011
  }
985
1012
 
986
1013
  /**
987
- * The refusal for a page the atlas spine-core draws through names and that is
988
- * not there (issue #1033) — on a rigc build, typically an atlas copied from
989
- * another build's directory, whose page paths are relative to that one.
1014
+ * The refusal for a page a candidate is drawn from that is not there — the
1015
+ * page's path is relative to the directory of the file that names it, so the
1016
+ * file was written somewhere else. What the last clause may claim depends on
1017
+ * what is known about the directory:
1018
+ *
1019
+ * - an export (no model document): only that the page has to be there;
1020
+ * - a rigc build whose files the core REFUSED (issue #1033): an atlas copied
1021
+ * from another build's directory, and the directory is not one build — the
1022
+ * core's own reason, on the poser line's words, says so;
1023
+ * - a rigc build the core ACCEPTED, or one `--poser spine` never asked the
1024
+ * core about (issue #1042): the build was moved or copied away from where it
1025
+ * was built. "Not one build" would be a guess there, and on a build moved
1026
+ * whole — measured — a false one.
1027
+ *
1028
+ * `by` is the file that names the page (`null`: the atlas), `draws` what is
1029
+ * drawn from it.
990
1030
  */
991
- function pageRefusal(page: string, paths: { skeleton: string; atlas: string } | null, why: string): CandidatePairError {
1031
+ function pageRefusal(
1032
+ page: string,
1033
+ paths: { skeleton: string; atlas: string } | null,
1034
+ why: string,
1035
+ reading: { by: string | null; draws: string; refusedBuild: boolean },
1036
+ ): CandidatePairError {
992
1037
  const { atlas, build } = pairPlace(paths);
993
- const head = `nothing at ${page}: ${atlas} names it as a page, and spine-core draws this pair through that atlas (${why}). A page path is relative to the atlas's own directory`;
994
- return new CandidatePairError(
995
- head + (build === null ? ', and the page has to be there' : `, so an atlas copied from another build's directory names pages that are not here. ${notOneBuild(build)}`),
996
- );
1038
+ // The poser line's reason, unless it is only the document's path — which the sentence has just named.
1039
+ const because = why === reading.by ? '' : ` (${why})`;
1040
+ const head =
1041
+ `nothing at ${page}: ${reading.by ?? atlas} names it as a page, and ${reading.draws}${because}. ` +
1042
+ `A page path is relative to the ${reading.by === null ? "atlas's own" : "build's"} directory`;
1043
+ const tail =
1044
+ build === null
1045
+ ? ', and the page has to be there'
1046
+ : reading.refusedBuild
1047
+ ? `, so an atlas copied from another build's directory names pages that are not here. ${notOneBuild(build)}`
1048
+ : `, so a build moved or copied away from the directory it was built in names pages that are not here — ` +
1049
+ 'build it again where it is, or build it with --copy-images, which writes its pages beside it';
1050
+ return new CandidatePairError(head + tail);
1051
+ }
1052
+
1053
+ /**
1054
+ * The one place a candidate's skeleton is handed to the runtime's parser
1055
+ * against its atlas (issues #1033, #1042): a pair it cannot load is `refuse`'s
1056
+ * refusal, given the runtime's own message, rather than the runtime's throw
1057
+ * and a stack. The JSON is parsed outside the catch: a file that is not JSON
1058
+ * is not something the runtime said, and the CLI refuses it by name before it
1059
+ * gets here (`readSkeletonText`).
1060
+ */
1061
+ function spineSkeletonData(skeletonText: string, atlasText: string, refuse: (runtime: string) => Error): SkeletonData {
1062
+ const json = JSON.parse(skeletonText);
1063
+ const reader = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(atlasText)));
1064
+ try {
1065
+ return reader.readSkeletonData(json);
1066
+ } catch (err) {
1067
+ throw refuse(err instanceof Error ? err.message : String(err));
1068
+ }
1069
+ }
1070
+
1071
+ /**
1072
+ * A skeleton a user named, posed through spine-core for what its bones do —
1073
+ * `bonedist`'s two sides (issue #1042). A pair that does not load is refused
1074
+ * by name (`CandidatePairError`, `pairRefusal` with `poses`), `why` saying
1075
+ * which side and what the pose is read for.
1076
+ *
1077
+ * ⭐ The skeleton data alone, and no page: `bonedist` reads bone world
1078
+ * transforms and samples no pixel, and the atlas's regions — which the
1079
+ * runtime resolves every attachment against — are in the atlas text. Reading
1080
+ * the page PNGs as `loadPosable` does made a pair whose pages are elsewhere
1081
+ * die on an ENOENT for images the command never looks at; the figures are the
1082
+ * same with or without them (the PR of #1042 measures it).
1083
+ *
1084
+ * `loadPosable` and `posableFromText` stay the unguarded loaders, for pairs
1085
+ * the caller wrote itself (the tools and the selftest), where the runtime's
1086
+ * own message is what the caller reads.
1087
+ */
1088
+ export function loadPosedSkeleton(skeletonPath: string, atlasPath: string, why: string): SkeletonData {
1089
+ const paths = { skeleton: skeletonPath, atlas: atlasPath };
1090
+ return spineSkeletonData(readFileSync(skeletonPath, 'utf8'), readFileSync(atlasPath, 'utf8'), (runtime) => pairRefusal(skeletonPath, paths, why, runtime, 'poses'));
997
1091
  }
998
1092
 
999
1093
  /** A candidate as `render` and `check` read it: the posers, the facts and the pages (issue #1014). */
@@ -1034,23 +1128,15 @@ export function loadCandidate(
1034
1128
  if (input.atlasText === null) throw atlasAbsent(paths?.atlas ?? '(no atlas)', input.label, why);
1035
1129
  return input.atlasText;
1036
1130
  };
1037
- // The one place this function hands the pair to the runtime's parser. A pair it cannot load — a skeleton.json
1038
- // or an atlas from another build beside this one's document — is refused by name rather than surfacing the
1039
- // runtime's own throw and stack (issue #1033). The JSON is parsed outside the catch: a file that is not JSON is
1040
- // not something the runtime said.
1131
+ // A pair the runtime cannot load — a skeleton.json or an atlas from another build beside this one's document — is
1132
+ // refused by name rather than surfacing the runtime's own throw and stack (issue #1033); `spineSkeletonData` is
1133
+ // where the catch is.
1041
1134
  // Under `--poser core` the runtime is loaded only for the facts the flags are checked against, and nothing would be
1042
1135
  // drawn through it: the refusal that is true there is the flag's, the one `refuseUnchosen` would have said next.
1043
1136
  const refusing = (refusal: CandidatePairError): Error =>
1044
1137
  forced === 'core' && choice !== null && choice.core === null ? new PoserChoiceError(`--poser core: ${choice.why}`) : refusal;
1045
- const spineLoad = (text: string, why: string): SkeletonData => {
1046
- const json = JSON.parse(input.skeletonText);
1047
- const reader = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(text)));
1048
- try {
1049
- return reader.readSkeletonData(json);
1050
- } catch (err) {
1051
- throw refusing(pairRefusal(input.label, paths, why, err instanceof Error ? err.message : String(err)));
1052
- }
1053
- };
1138
+ const spineLoad = (text: string, why: string): SkeletonData =>
1139
+ spineSkeletonData(input.skeletonText, text, (runtime) => refusing(pairRefusal(input.label, paths, why, runtime)));
1054
1140
  const spineData = (): SkeletonData => {
1055
1141
  if (data === null) {
1056
1142
  const why = choice === null || choice.core !== null ? 'a fallback from the core poser' : choice.why;
@@ -1067,20 +1153,29 @@ export function loadCandidate(
1067
1153
  choice = chosen.choice;
1068
1154
  if (choice.core !== null && chosen.document !== null) {
1069
1155
  // A rigc-compiled/2 document names its pages; a /1 document is posed only with its atlas beside it, which names them (`choosePosers`).
1070
- const names = chosen.document.pageNames ?? parseAtlasText(atlasText(choice.why)).pages.map((page) => page.name);
1071
- return { choice, facts: coreFacts(input.skeletonText, input.atlasText, choice.core, chosen.document), pages: pagesNamed(names, input.atlasDir) };
1156
+ const named = chosen.document.pageNames;
1157
+ const names = named ?? parseAtlasText(atlasText(choice.why)).pages.map((page) => page.name);
1158
+ // A page the build names that is not here is a build moved away from where it was built (issue #1042): the
1159
+ // core accepted these files as one build, so the refusal does not say they are not one.
1160
+ const by = named === null || paths === null ? null : join(dirname(resolve(paths.skeleton)), MODEL_DOCUMENT_FILE);
1161
+ const reading = { by, draws: 'the core draws this build from it', refusedBuild: false };
1162
+ const accepted = choice.why;
1163
+ const pages = pagesAt(names, input.atlasDir, (page) => pageRefusal(page, paths, accepted, reading));
1164
+ return { choice, facts: coreFacts(input.skeletonText, input.atlasText, choice.core, chosen.document), pages };
1072
1165
  }
1073
1166
  const text = atlasText(choice.why);
1074
1167
  requireSpineRuntime(input.label, choice.why);
1075
1168
  // `posableFromText`'s two steps, in its order — every page the atlas declares, then the skeleton — each refusing
1076
1169
  // by name where the pair is not one: a page that is not there (`pageRefusal`), a skeleton the runtime cannot load
1077
- // against the atlas (`spineLoad`). A page that is there and is not a PNG says so itself (`readPlate`).
1078
- const pages = new Map<string, Plate>();
1079
- for (const page of new TextureAtlas(text).pages) {
1080
- const path = join(input.atlasDir, page.name);
1081
- if (!existsSync(path)) throw refusing(pageRefusal(resolve(path), paths, choice.why));
1082
- pages.set(page.name, readPlate(path));
1083
- }
1170
+ // against the atlas (`spineLoad`). The directory is called "not one build" only where the core refused its files —
1171
+ // `--poser spine` never asked the core, and a build moved whole is one build whose pages are elsewhere.
1172
+ const reading = { by: null, draws: 'spine-core draws this pair through that atlas', refusedBuild: forced !== 'spine' };
1173
+ const why = choice.why;
1174
+ const pages = pagesAt(
1175
+ new TextureAtlas(text).pages.map((page) => page.name),
1176
+ input.atlasDir,
1177
+ (page) => refusing(pageRefusal(page, paths, why, reading)),
1178
+ );
1084
1179
  data = spineLoad(text, choice.why);
1085
1180
  return { choice, facts: spineFacts(data, text), pages };
1086
1181
  }
@@ -1142,8 +1237,8 @@ function coreFacts(skeletonText: string, atlasText: string | null, poser: Poser,
1142
1237
  // 🔒 What stays spine-core's and outside the seam, deliberately: an export's
1143
1238
  // atlas (`posableFromText`'s pages, and `substituteTexture`'s region lookup on
1144
1239
  // anything spine-core poses — a rigc build the core poses reads both through
1145
- // rigc's own reader since issue #1020) and `nonFiniteOfPosed`, which `validate.ts` calls on a spine-core
1146
- // skeleton it stepped itself (A10), so the round trip keeps its spine-core
1240
+ // rigc's own reader since issue #1020) and `posedNumbersOf`, which `validate.ts` calls on a spine-core
1241
+ // skeleton it stepped itself (A10's runtime supplier), so the round trip keeps its spine-core
1147
1242
  // entry whatever poses the renders.
1148
1243
 
1149
1244
  /** What a sampler hands a posed frame's draw walk — `PoseOptions` with the subset already resolved. */
@@ -2055,66 +2150,36 @@ function restOf(data: SkeletonData, skin: string | undefined, shown: readonly At
2055
2150
  return out;
2056
2151
  }
2057
2152
 
2058
- /** The bone fields a world transform is made of — what `firstNonFinite` reads off a bone. */
2059
- type WorldTransform = Pick<GeometryBone, 'name' | 'a' | 'b' | 'c' | 'd' | 'worldX' | 'worldY'>;
2060
-
2061
- /** The fields of an attachment entry `firstNonFinite` reads — a frame's `AttachmentPose` or a rest entry. */
2062
- type PosedVertices = Pick<AttachmentPose, 'slot' | 'attachment' | 'vertices'>;
2063
-
2064
2153
  /**
2065
- * The sentence for the first number at `where` that is not finite — bones before
2066
- * vertices, since a bone that overflowed is the cause and its vertices the
2067
- * symptom — or `null` when every number there is finite.
2068
- */
2069
- function firstNonFinite(where: string, entries: readonly PosedVertices[], bones: readonly WorldTransform[]): string | null {
2070
- for (const bone of bones) {
2071
- for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY'] as const) {
2072
- if (!Number.isFinite(bone[field])) {
2073
- return `${where}: bone ${JSON.stringify(bone.name)} has ${field} ${String(bone[field])}; a world transform is finite`;
2074
- }
2075
- }
2076
- }
2077
- for (const entry of entries) {
2078
- const bad = entry.vertices.findIndex((value) => !Number.isFinite(value));
2079
- if (bad === -1) continue;
2080
- return (
2081
- `${where}: slot ${JSON.stringify(entry.slot)} attachment ${JSON.stringify(entry.attachment)} vertex ` +
2082
- `${Math.floor(bad / 2)} has ${bad % 2 === 0 ? 'x' : 'y'} ${String(entry.vertices[bad])}; a posed vertex is finite`
2083
- );
2084
- }
2085
- return null;
2086
- }
2087
-
2088
- /**
2089
- * The same sentence for one skeleton as it stands posed now — its bones, then
2090
- * the vertices of every region and mesh its slots show, in draw order — or
2091
- * `null` when every one of those numbers is finite.
2154
+ * One skeleton as it stands posed now, as the numbers `firstNonFinite`
2155
+ * (`./nonfinite.ts`) reads: every bone's six world-transform terms, in skeleton
2156
+ * order, then the world vertices of every region and mesh its slots show, in
2157
+ * draw order — the runtime's reading of each (`worldVerticesOf`).
2092
2158
  *
2093
- * ⭐ This is how `A10_NO_NAN_AFTER_STEPPING` reads each pose it steps (issue
2094
- * #882), so the gate and the renderer hold one definition of "not finite": the
2095
- * six terms `firstNonFinite` reads off a bone, and the vertices the runtime
2096
- * computes from them. Before it A10 read the world POSITION alone, and a bone at
2097
- * `rotation: 1e309` — finite position, NaN `a`, `b`, `c`, `d` — was gated green
2098
- * and then refused here. The vertices are read too because a bone can be finite
2099
- * and still carry one that is not: a two-bone `scaleX` chain of 1e154 × 1e154
2159
+ * ⭐ This is how `A10_NO_NAN_AFTER_STEPPING` reads each pose spine-core steps
2160
+ * (issue #882; its runtime supplier in `validate.ts` since issue #1025), so the
2161
+ * gate and the renderer hold one definition of "not finite": the six terms
2162
+ * `firstNonFinite` reads off a bone, and the vertices the runtime computes from
2163
+ * them. Before #882 A10 read the world POSITION alone, and a bone at `rotation:
2164
+ * 1e309` — finite position, NaN `a`, `b`, `c`, `d` — was gated green and then
2165
+ * refused here. The vertices are read too because a bone can be finite and
2166
+ * still carry one that is not: a two-bone `scaleX` chain of 1e154 × 1e154
2100
2167
  * leaves the child's `a` at 1e308, finite, and every vertex of its attachment
2101
- * past the largest double. Only computed once every bone is finite, so a
2102
- * broken bone is named as the cause rather than through its vertices.
2168
+ * past the largest double. `firstNonFinite` reads the bones first, so a broken
2169
+ * bone is named as the cause rather than through its vertices.
2103
2170
  */
2104
- export function nonFiniteOfPosed(where: string, skeleton: Skeleton): string | null {
2171
+ export function posedNumbersOf(skeleton: Skeleton): { bones: WorldTransform[]; drawn: PosedVertices[] } {
2105
2172
  const bones: WorldTransform[] = skeleton.bones.map((bone) => {
2106
2173
  const { a, b, c, d, worldX, worldY } = bone.appliedPose;
2107
2174
  return { name: bone.data.name, a, b, c, d, worldX, worldY };
2108
2175
  });
2109
- const found = firstNonFinite(where, [], bones);
2110
- if (found !== null) return found;
2111
- const entries: PosedVertices[] = [];
2176
+ const drawn: PosedVertices[] = [];
2112
2177
  for (const slot of skeleton.drawOrder.appliedPose) {
2113
2178
  const attachment = slot.appliedPose.attachment;
2114
2179
  if (!(attachment instanceof MeshAttachment) && !(attachment instanceof RegionAttachment)) continue;
2115
- entries.push({ slot: slot.data.name, attachment: attachment.name, vertices: worldVerticesOf(skeleton, slot, attachment) });
2180
+ drawn.push({ slot: slot.data.name, attachment: attachment.name, vertices: worldVerticesOf(skeleton, slot, attachment) });
2116
2181
  }
2117
- return firstNonFinite(where, entries, []);
2182
+ return { bones, drawn };
2118
2183
  }
2119
2184
 
2120
2185
  /**
@@ -282,6 +282,11 @@ interface CoreInput {
282
282
  * Exported for the model side of the validator (issue #1025), which poses a
283
283
  * slot the way `validate()` does — a fresh skeleton, no skin set — and must
284
284
  * resolve that state by this rule rather than by a copy of it.
285
+ *
286
+ * ⚠️ The first refusal — skins declared and none of them `default` — is issue
287
+ * #1051: the model side's A10, which always poses, fails such a rig the round
288
+ * trip passes; `tools/verdict_gate.ts` and the selftest's `VF02` count those
289
+ * line sets apart as this documented class rather than as a difference.
285
290
  */
286
291
  export function noSkinView(doc: CompiledDocument): CompiledDocument {
287
292
  if (doc.skins.length === 0) return underSkin(doc, CORE_ALL_SKINS);