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/cli.ts +39 -17
- package/docs/AUTHORING.md +1 -1
- package/package.json +1 -1
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/index.ts +21 -2
- package/src/assertions/model/slider_composition.ts +191 -0
- package/src/assertions/model/stepped_poses.ts +89 -0
- package/src/bonedist.ts +18 -6
- package/src/core/additive.ts +468 -0
- package/src/core/constraints.ts +12 -2
- package/src/core/constraints_physics.ts +31 -8
- package/src/core/raw.ts +77 -16
- package/src/core/walk.ts +202 -0
- package/src/nonfinite.ts +54 -0
- package/src/render.ts +151 -86
- package/src/render_core.ts +5 -0
- package/src/validate.ts +432 -457
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
|
-
/**
|
|
895
|
-
|
|
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)
|
|
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
|
-
/**
|
|
980
|
-
|
|
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
|
|
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
|
|
988
|
-
*
|
|
989
|
-
*
|
|
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(
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
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
|
-
|
|
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
|
|
1071
|
-
|
|
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`).
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
pages.
|
|
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 `
|
|
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
|
-
*
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
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
|
|
2094
|
-
* #882
|
|
2095
|
-
*
|
|
2096
|
-
*
|
|
2097
|
-
*
|
|
2098
|
-
*
|
|
2099
|
-
*
|
|
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.
|
|
2102
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
2180
|
+
drawn.push({ slot: slot.data.name, attachment: attachment.name, vertices: worldVerticesOf(skeleton, slot, attachment) });
|
|
2116
2181
|
}
|
|
2117
|
-
return
|
|
2182
|
+
return { bones, drawn };
|
|
2118
2183
|
}
|
|
2119
2184
|
|
|
2120
2185
|
/**
|
package/src/render_core.ts
CHANGED
|
@@ -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);
|