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