rig-c 0.0.0-stage → 2.20.4
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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1188 -0
- package/src/meshquality.ts +2042 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1425 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
|
@@ -0,0 +1,2958 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a render is, whichever poser draws it — everything `./render.ts` held
|
|
3
|
+
* that does not link spine-core (issue #1052, step 4e of #380).
|
|
4
|
+
*
|
|
5
|
+
* Moved unchanged: the frame-set contract (`frames.json`, the contact sheet),
|
|
6
|
+
* the posing seam and its samplers, the candidate a render or a check loads
|
|
7
|
+
* and its poser choice, the geometry export, texture substitution, the framing
|
|
8
|
+
* and the rasteriser. `./render.ts` keeps what names the runtime — spine-core's
|
|
9
|
+
* implementation of the seam, loading a Spine export, the atlas class — and
|
|
10
|
+
* re-exports every name below that it exported before, so a dependant's import
|
|
11
|
+
* resolves where it always did. See `./render.ts`'s header for what the
|
|
12
|
+
* rasteriser draws and the conventions it owns; they are this file's now.
|
|
13
|
+
*
|
|
14
|
+
* ⭐ Where a function here reaches an input only the runtime can read — a
|
|
15
|
+
* Spine export, `--poser spine`, a fallback the poser line names, a skeleton
|
|
16
|
+
* spine-core already parsed — it asks the seam (`./spine_side.ts`) for the
|
|
17
|
+
* Spine side rather than importing it. The local functions under *the Spine
|
|
18
|
+
* side, through the seam* below carry the names the bodies always called, so
|
|
19
|
+
* the bodies read as they did; `./render.ts` registers the implementations
|
|
20
|
+
* when it is loaded. An entry that never loads it links nothing of the
|
|
21
|
+
* runtime here, and such an input is refused by name (`SpineRuntimeError`).
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { dirname, join, resolve } from 'node:path';
|
|
25
|
+
import { Plate, readPlate, type RGBA } from '../tools/plate.ts';
|
|
26
|
+
import { pageFootprint, parseAtlasText } from './atlas.ts';
|
|
27
|
+
import { CoreInputError } from './core/index.ts';
|
|
28
|
+
import { computeUvs } from './core/uvs.ts';
|
|
29
|
+
import { ATLAS_SCALE_LINE, MODEL_DOCUMENT_FILE } from './model.ts';
|
|
30
|
+
import {
|
|
31
|
+
coreDocumentFacts,
|
|
32
|
+
corePoser,
|
|
33
|
+
SlotSubsetError,
|
|
34
|
+
subsetOver,
|
|
35
|
+
type CoreDocumentFacts,
|
|
36
|
+
type SubsetRoster,
|
|
37
|
+
} from './render_core.ts';
|
|
38
|
+
import { spinePosingFor, type SpineSkeletonData } from './spine_side.ts';
|
|
39
|
+
import { walkTimelines } from './timelines.ts';
|
|
40
|
+
import { firstNonFinite, type PosedVertices, type WorldTransform } from './nonfinite.ts';
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// the Spine side, through the seam (issue #1052)
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
//
|
|
46
|
+
// ⭐ The names `./render.ts` gives these, so the bodies below call what they
|
|
47
|
+
// always called; each asks the seam for the side `./render.ts` registered and
|
|
48
|
+
// is refused by name (`SpineRuntimeError`) where nothing did. The first call
|
|
49
|
+
// on every path that reaches the runtime is `requireSpineRuntime`, so the
|
|
50
|
+
// refusal names the input and why it needed the runtime, as #1014 says it.
|
|
51
|
+
|
|
52
|
+
/** A skeleton spine-core parsed — opaque here (`SpineSkeletonData`); `./render.ts` reads it as the runtime's `SkeletonData`. */
|
|
53
|
+
type SkeletonData = SpineSkeletonData;
|
|
54
|
+
|
|
55
|
+
/** What the seam's refusals name a parsed skeleton handed straight to a sampler as. */
|
|
56
|
+
const PARSED_SKELETON = 'a skeleton spine-core parsed';
|
|
57
|
+
const PARSED_WHY = 'handed over already parsed';
|
|
58
|
+
|
|
59
|
+
/** Touch the runtime once, before anything is parsed through it, and refuse by name when it cannot be used. */
|
|
60
|
+
function requireSpineRuntime(label: string, why: string): void {
|
|
61
|
+
spinePosingFor(label, why).requireRuntime(label, why);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The skeleton parsed against its atlas through the runtime, a pair it cannot load being `refuse`'s refusal. */
|
|
65
|
+
function spineSkeletonData(skeletonText: string, atlasText: string, refuse: (runtime: string) => Error): SkeletonData {
|
|
66
|
+
return spinePosingFor(PARSED_SKELETON, 'loaded through its atlas').skeletonData(skeletonText, atlasText, refuse);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The facts as spine-core loaded them. */
|
|
70
|
+
function spineFacts(data: SkeletonData, atlasText: string | null): SkeletonFacts {
|
|
71
|
+
return spinePosingFor(PARSED_SKELETON, PARSED_WHY).facts(data, atlasText);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** spine-core's poser over a parsed skeleton. */
|
|
75
|
+
function spinePoser(data: SkeletonData): Poser {
|
|
76
|
+
return spinePosingFor(PARSED_SKELETON, PARSED_WHY).poser(data);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The skin roster of a parsed skeleton, as the runtime flags it. */
|
|
80
|
+
function skinRosterOf(data: SkeletonData): SkinRoster {
|
|
81
|
+
return spinePosingFor(PARSED_SKELETON, PARSED_WHY).skinRoster(data);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The page names an atlas declares, as the runtime's atlas reader reads them. */
|
|
85
|
+
function atlasPageNames(atlasText: string): string[] {
|
|
86
|
+
return spinePosingFor('an atlas', "read through spine-core's atlas reader").atlasPageNames(atlasText);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** An atlas's pages and regions as the runtime reads them, for a substitution. */
|
|
90
|
+
function spineSubstitution(atlasText: string): { pages: string[]; regions: Map<string, SubstituteRegion> } {
|
|
91
|
+
return spinePosingFor('a --texture-from atlas', "read through spine-core's atlas reader, for a candidate spine-core poses").substitution(atlasText);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `./render.ts`'s choice of posers over a skeleton it parsed itself (`candidatePosers`). */
|
|
95
|
+
export { choosePosers, pairRefusal, regionKey };
|
|
96
|
+
|
|
97
|
+
/** Opaque, and light: both of rung 3's parts are dark slate, so is every ground. */
|
|
98
|
+
export const BACKGROUND: RGBA = [232, 232, 232, 255];
|
|
99
|
+
/** Padding around the union bounding box, as a fraction of its long side. */
|
|
100
|
+
export const PAD = 0.04;
|
|
101
|
+
/**
|
|
102
|
+
* Directory a skeleton with no animation writes its one frame into.
|
|
103
|
+
*
|
|
104
|
+
* It cannot collide with an animation's directory, because an animation named
|
|
105
|
+
* `setup` would have to live in a skeleton that has at least one animation, and
|
|
106
|
+
* this name is only ever used when there are none.
|
|
107
|
+
*/
|
|
108
|
+
export const SETUP_POSE_DIR = 'setup';
|
|
109
|
+
/**
|
|
110
|
+
* The sampling rate the ladder's briefs are written against.
|
|
111
|
+
*
|
|
112
|
+
* It is a constant rather than a bare `12` in the default because it is also the
|
|
113
|
+
* rate at which the directory name says nothing: a rung rendered at the protocol
|
|
114
|
+
* rate writes `<animation>/`, and any other rate writes `<animation>@<fps>fps/`.
|
|
115
|
+
*/
|
|
116
|
+
export const PROTOCOL_FPS = 12;
|
|
117
|
+
/** The rate the framing box is measured at, whatever `--fps` writes frames at. */
|
|
118
|
+
export const FRAMING_FPS = 60;
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// the frame-set sidecar
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
//
|
|
124
|
+
// ⭐ A rendered frame set is a picture of a world box, and the box used to be
|
|
125
|
+
// nowhere. That cost two things. An author measuring a distance in pixels had no
|
|
126
|
+
// way to turn it into the units a rig is authored in except by finding something
|
|
127
|
+
// of a known size in the shot; and nothing could render a SECOND skeleton onto
|
|
128
|
+
// the same pixel grid, because the grid was a number that existed only inside one
|
|
129
|
+
// run of `render_reference.ts`. `frames.json` writes it down.
|
|
130
|
+
|
|
131
|
+
/** The sidecar's file name and format tag. */
|
|
132
|
+
export const FRAMES_SIDECAR = 'frames.json';
|
|
133
|
+
export const FRAMES_SPEC = 'rigc-frames/1';
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The contact sheet beside a frame set, and the one number its layout needs.
|
|
137
|
+
*
|
|
138
|
+
* ⭐ A sheet is **part of the frame set**, not an illustration of it: a long shot
|
|
139
|
+
* commits a couple of stills and folds every sampled frame into one PNG, so for
|
|
140
|
+
* such a set the sheet is the only picture of the 309 frames in between, and
|
|
141
|
+
* `check` compares against its tiles (issue #36). That makes the layout a
|
|
142
|
+
* contract between two programs — `bench/render_reference.ts` writes the grid and
|
|
143
|
+
* `src/check.ts` reads it — so the column count lives here rather than in either.
|
|
144
|
+
*
|
|
145
|
+
* The tile SIZE is deliberately not here. It is a `--tile` choice per run, and a
|
|
146
|
+
* reader can measure it exactly off the sheet's own dimensions given the frame
|
|
147
|
+
* count and the column count (`check`'s `sheetGeometry` does), so recording it
|
|
148
|
+
* would be a second definition of something already written down in pixels.
|
|
149
|
+
*/
|
|
150
|
+
export const SHEET_COLUMNS = 8;
|
|
151
|
+
/** The sheet's file name inside a frame directory. */
|
|
152
|
+
export const SHEET_FILE = 'contact.png';
|
|
153
|
+
/** One pixel of rule between tiles, and one around the outside. */
|
|
154
|
+
export const SHEET_GAP = 1;
|
|
155
|
+
/** Default long side of one contact-sheet tile, in pixels. */
|
|
156
|
+
export const SHEET_TILE = 128;
|
|
157
|
+
/** The rule between tiles, and the frame number drawn in each. */
|
|
158
|
+
export const SHEET_RULE: RGBA = [176, 176, 176, 255];
|
|
159
|
+
export const SHEET_LABEL: RGBA = [96, 96, 96, 255];
|
|
160
|
+
|
|
161
|
+
/** One rendered frame directory: which animation, at what rate, and what is on disk. */
|
|
162
|
+
export interface FrameSet {
|
|
163
|
+
/** Directory name under the skeleton root — `heavy`, or `heavy@24fps`. */
|
|
164
|
+
dir: string;
|
|
165
|
+
/** The animation these frames show, or `null` for a skeleton with none. */
|
|
166
|
+
animation: string | null;
|
|
167
|
+
fps: number;
|
|
168
|
+
/** How many frames the animation sampled to at this rate. */
|
|
169
|
+
sampled: number;
|
|
170
|
+
/** How many were actually written (a stride writes fewer). */
|
|
171
|
+
written: number;
|
|
172
|
+
stride: number;
|
|
173
|
+
/**
|
|
174
|
+
* The last sampled frame's time, in seconds.
|
|
175
|
+
*
|
|
176
|
+
* ⚠️ Which indices are on disk is deliberately NOT recorded here. The
|
|
177
|
+
* directory is the only author of that fact, and a second copy of it in this
|
|
178
|
+
* file could only ever be the stale one.
|
|
179
|
+
*/
|
|
180
|
+
duration: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface FramesSidecar {
|
|
184
|
+
spec: string;
|
|
185
|
+
example?: string;
|
|
186
|
+
rung?: string;
|
|
187
|
+
skeleton?: string;
|
|
188
|
+
/**
|
|
189
|
+
* The skin these frames were posed under, when one was asked for (issue #571).
|
|
190
|
+
*
|
|
191
|
+
* ⭐ **Absent is not `"default"`.** A render with no skin sets none — every
|
|
192
|
+
* slot resolves through `SkeletonData.defaultSkin` alone — and a frame set
|
|
193
|
+
* written before this field existed says nothing either, so the two are the
|
|
194
|
+
* same fact on disk and the field is omitted for both. That is what keeps
|
|
195
|
+
* every frame set in this repository byte-identical across this change, and it
|
|
196
|
+
* is why `check` can refuse a mismatch it can SEE (`skin` present and
|
|
197
|
+
* different, or present where the run asked for none) and can only NOTE the
|
|
198
|
+
* one it cannot (`skin` absent while the run asked for one).
|
|
199
|
+
*/
|
|
200
|
+
skin?: string;
|
|
201
|
+
/**
|
|
202
|
+
* The slots these frames draw, when `render --slot` narrowed them to a subset
|
|
203
|
+
* (issue #835) — in the skeleton's draw order, whatever order they were named in.
|
|
204
|
+
*
|
|
205
|
+
* ⭐ Absent on a render of every slot, for the reason `skin` is: that is what
|
|
206
|
+
* every frame set written before this field existed says too, so the whole-rig
|
|
207
|
+
* render stays byte-identical and the key's presence is the claim. A frame set
|
|
208
|
+
* carrying this or `hidden` is a picture of PART of the rig, and `check` refuses
|
|
209
|
+
* it as a reference by name rather than scoring a whole candidate against it.
|
|
210
|
+
*/
|
|
211
|
+
slots?: string[];
|
|
212
|
+
/** The slots these frames leave out, when `render --hide` named them — see `slots`. */
|
|
213
|
+
hidden?: string[];
|
|
214
|
+
/** The colour the frames were cleared to, straight RGBA 0..255. */
|
|
215
|
+
background: RGBA;
|
|
216
|
+
viewport: {
|
|
217
|
+
/** World box, y up, matching Spine's own coordinates. */
|
|
218
|
+
x: number;
|
|
219
|
+
y: number;
|
|
220
|
+
width: number;
|
|
221
|
+
height: number;
|
|
222
|
+
/** Frame pixels per world unit. */
|
|
223
|
+
scale: number;
|
|
224
|
+
pixelWidth: number;
|
|
225
|
+
pixelHeight: number;
|
|
226
|
+
};
|
|
227
|
+
sets: FrameSet[];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// posing
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
/** What every drawable has in common, whatever shape it is. */
|
|
235
|
+
export interface PieceCommon {
|
|
236
|
+
/**
|
|
237
|
+
* World-space vertex positions, `x, y` per vertex.
|
|
238
|
+
*
|
|
239
|
+
* ⭐ The one field the framing code reads, and the reason it is spelled the
|
|
240
|
+
* same on both shapes: a union over "every posed point" is a loop over this
|
|
241
|
+
* array in steps of two, and it does not need to know whether four numbers are
|
|
242
|
+
* a rectangle's corners or two hundred are a mesh's hull.
|
|
243
|
+
*/
|
|
244
|
+
world: number[];
|
|
245
|
+
/** Slot colour x attachment colour, straight alpha, 0..1. */
|
|
246
|
+
tint: [number, number, number, number];
|
|
247
|
+
/**
|
|
248
|
+
* The slot's **dark** colour, 0..1 — the other half of Spine's two-colour
|
|
249
|
+
* tint, and absent on every slot that does not carry one.
|
|
250
|
+
*
|
|
251
|
+
* ⚠️ Absent rather than black, and that is the whole of why it is optional.
|
|
252
|
+
* `(0, 0, 0)` is a real dark colour and the identity of the blend, so the two
|
|
253
|
+
* spellings paint the same pixels — but the runtime distinguishes them
|
|
254
|
+
* (`SlotPose.darkColor` is `null` for a slot with no `dark`, and `Slot`'s
|
|
255
|
+
* constructor never allocates one), and a piece that carried a black default
|
|
256
|
+
* would take the two-colour path for every slot in every frame this
|
|
257
|
+
* repository renders. `undefined` is what keeps the arithmetic below off the
|
|
258
|
+
* ordinary case (issue #690).
|
|
259
|
+
*
|
|
260
|
+
* Three channels, not four: the format writes `dark` as `rrggbb` and
|
|
261
|
+
* `RGBA2Timeline` stores three dark channels. The alpha a shader reads on the
|
|
262
|
+
* dark colour is not a colour at all — see `tintChannel`.
|
|
263
|
+
*/
|
|
264
|
+
dark?: [number, number, number];
|
|
265
|
+
/** The slot this was drawn for — what per-slot tracking is keyed by. */
|
|
266
|
+
slot: string;
|
|
267
|
+
/** The atlas page name this samples, so a multi-page atlas resolves. */
|
|
268
|
+
page: string;
|
|
269
|
+
/**
|
|
270
|
+
* What a **texture-only** substitution needs to re-seat this piece on another
|
|
271
|
+
* atlas — see `PieceTexture`.
|
|
272
|
+
*
|
|
273
|
+
* Absent unless `piecesOf` was asked for it, because it is a second copy of the
|
|
274
|
+
* UVs and every posed frame of every set is held in memory at once.
|
|
275
|
+
*/
|
|
276
|
+
texture?: PieceTexture;
|
|
277
|
+
/**
|
|
278
|
+
* The page-UV rectangle this piece may sample, and no further — see `UvWindow`.
|
|
279
|
+
*
|
|
280
|
+
* Absent on a piece posed from its own atlas: its UVs cover its own region's
|
|
281
|
+
* rectangle exactly, so there is nothing to fence off. It is set by
|
|
282
|
+
* `substituteTexture`, where the piece's geometry spans an area of the original
|
|
283
|
+
* drawing that the substituting atlas may have trimmed away.
|
|
284
|
+
*/
|
|
285
|
+
uvWindow?: UvWindow;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* One piece's texture coordinates in the **original drawing's** own space, plus
|
|
290
|
+
* the name of the region it came from.
|
|
291
|
+
*
|
|
292
|
+
* ## Why original-art space and not the page's
|
|
293
|
+
*
|
|
294
|
+
* Page UVs are useless for substitution: they name texels in *this* atlas, and
|
|
295
|
+
* two atlases pack the same drawing at different places, at different scales, and
|
|
296
|
+
* possibly rotated or trimmed. What survives a repack is the position **within the
|
|
297
|
+
* drawing** — the coordinate an artist would point at — so that is the space a
|
|
298
|
+
* substitution goes through. `(0, 0)` is the untrimmed drawing's top-left corner
|
|
299
|
+
* and `(1, 1)` its bottom-right, which is the convention `spine-core`'s own
|
|
300
|
+
* `MeshAttachment.computeUVs` reads its `regionUVs` in; going through it is what
|
|
301
|
+
* lets `substituteTexture` reuse the runtime's rotation and trim arithmetic
|
|
302
|
+
* instead of holding a second opinion about it.
|
|
303
|
+
*/
|
|
304
|
+
export interface PieceTexture {
|
|
305
|
+
/** The atlas region this piece samples, by the name its atlas gives it. */
|
|
306
|
+
region: string;
|
|
307
|
+
/** Original-art coordinates, `u, v` per vertex, parallel to `uvs`. */
|
|
308
|
+
artUvs: number[];
|
|
309
|
+
/**
|
|
310
|
+
* A clipped piece only (`Mesh.source`): the original-art UVs of each drawn
|
|
311
|
+
* triangle's SOURCE triangle, six numbers per drawn triangle, parallel to
|
|
312
|
+
* `Mesh.source.uvs` — what `substituteTexture` re-seats the source map with.
|
|
313
|
+
*/
|
|
314
|
+
sourceArtUvs?: number[];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** A page-UV rectangle outside which a piece samples nothing. */
|
|
318
|
+
export interface UvWindow {
|
|
319
|
+
u0: number;
|
|
320
|
+
v0: number;
|
|
321
|
+
u1: number;
|
|
322
|
+
v1: number;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Options for `piecesOf` and the samplers that call it. */
|
|
326
|
+
export interface PoseOptions {
|
|
327
|
+
/** Also record each piece's original-art UVs — see `PieceTexture`. */
|
|
328
|
+
texture?: boolean;
|
|
329
|
+
/**
|
|
330
|
+
* Also record every bone's world transform — see `BoneSnapshot` and
|
|
331
|
+
* `Frame.bones`. Off by default: nothing that draws needs it, and the
|
|
332
|
+
* one instrument that does (`bonedist.ts`) needs it on every frame.
|
|
333
|
+
*/
|
|
334
|
+
bones?: boolean;
|
|
335
|
+
/**
|
|
336
|
+
* Also record every slot's attachment geometry, whole — see `AttachmentPose`
|
|
337
|
+
* and `Frame.attachments` (issue #864). Off by default for `bones`' reason:
|
|
338
|
+
* nothing that draws reads it, and `render --geometry` is the one caller.
|
|
339
|
+
*
|
|
340
|
+
* ⭐ **It is not filtered by `slots`/`hidden` and not cut by a clip.** Those
|
|
341
|
+
* are statements about which pixels are drawn; the geometry is a statement
|
|
342
|
+
* about where the pose put each attachment, and it is the same pose whatever
|
|
343
|
+
* a picture of it leaves out.
|
|
344
|
+
*/
|
|
345
|
+
geometry?: boolean;
|
|
346
|
+
/**
|
|
347
|
+
* Pose under this skin, by the name the skeleton declares for it.
|
|
348
|
+
*
|
|
349
|
+
* ⭐ Absent means **no skin is set at all**, which is spine-core's own initial
|
|
350
|
+
* state (`Skeleton.skin` is null) and resolves every slot through
|
|
351
|
+
* `SkeletonData.defaultSkin` alone. That is not the same claim as "the default
|
|
352
|
+
* skin was chosen": it is the absence of a choice, and the two are spelled
|
|
353
|
+
* differently everywhere this travels — the frames sidecar omits the field
|
|
354
|
+
* rather than writing `"default"` into it (issue #571).
|
|
355
|
+
*
|
|
356
|
+
* ⚠️ Read by the SAMPLERS, never by `piecesOf`, which is handed a skeleton
|
|
357
|
+
* somebody else already posed; handing it one is refused by name rather than
|
|
358
|
+
* ignored, because a skin quietly dropped here is exactly the silence this
|
|
359
|
+
* whole flag exists to remove.
|
|
360
|
+
*/
|
|
361
|
+
skin?: string;
|
|
362
|
+
/**
|
|
363
|
+
* Draw only these slots, by name (issue #835). `hidden` is the same statement
|
|
364
|
+
* the other way round, and the two together are refused.
|
|
365
|
+
*
|
|
366
|
+
* ⭐ **It is a filter on what is DRAWN, never on what is framed.**
|
|
367
|
+
* `framingViewport` takes both off before it samples, so a frame with `head`
|
|
368
|
+
* hidden sits on exactly the pixel grid of the frame with it and the two
|
|
369
|
+
* overlay — which is the whole use of the picture: *which part is this pixel*
|
|
370
|
+
* is answered by the difference between two frames of one grid, and a subset
|
|
371
|
+
* re-framed to its own extent would have no second frame to differ from.
|
|
372
|
+
*
|
|
373
|
+
* ⚠️ Applied in `piecesOf`, where the pieces are collected, and resolved there
|
|
374
|
+
* against the posed skeleton's own slots and skin — see `slotSubsetOf` — so a
|
|
375
|
+
* name that draws nothing is refused by name rather than quietly matching no
|
|
376
|
+
* piece.
|
|
377
|
+
*/
|
|
378
|
+
slots?: string[];
|
|
379
|
+
/** Draw every slot but these — see `slots`. */
|
|
380
|
+
hidden?: string[];
|
|
381
|
+
/**
|
|
382
|
+
* Pose every attachment whole, with no clipping attachment applied — set by
|
|
383
|
+
* `framingViewport` and by nothing that draws.
|
|
384
|
+
*
|
|
385
|
+
* ⭐ **The framing box counts what a clip removes**, for the reason it counts
|
|
386
|
+
* what `--slot`/`--hide` leave out: the box is a property of the shot, and a
|
|
387
|
+
* clip is a statement about which pixels of it are drawn. Framed on the
|
|
388
|
+
* clipped geometry, a rig's viewport would move the moment a clip is added or
|
|
389
|
+
* keyed, and every frame set already on disk for it — `frames.json`'s world
|
|
390
|
+
* box, the grid `check` compares on — would stop describing the frames a
|
|
391
|
+
* second render writes.
|
|
392
|
+
*/
|
|
393
|
+
unclipped?: boolean;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Why a slot subset cannot be drawn — `./render_core.ts` declares it, so the
|
|
398
|
+
* spine-core poser and the core poser throw one class (issue #968).
|
|
399
|
+
*/
|
|
400
|
+
export { SlotSubsetError };
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A slot subset resolved against a skeleton: which half was asked for, and the
|
|
404
|
+
* names in the skeleton's **draw order** rather than the order they were typed.
|
|
405
|
+
*
|
|
406
|
+
* Draw order because the names are a set and the sidecar records them: `--hide
|
|
407
|
+
* b,a` and `--hide a,b` are one picture, and a sidecar whose bytes depended on
|
|
408
|
+
* the spelling would make two identical frame sets differ.
|
|
409
|
+
*/
|
|
410
|
+
export interface SlotSubset {
|
|
411
|
+
mode: 'slots' | 'hidden';
|
|
412
|
+
names: string[];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* One bone's world transform in one posed frame.
|
|
417
|
+
*
|
|
418
|
+
* ⚠️ Read off `spine-core`'s own `BonePose` and derived by its own routines —
|
|
419
|
+
* `getWorldRotationX`, `getWorldScaleX` and friends — rather than recomputed
|
|
420
|
+
* from `a b c d` here. A second opinion about what a bone's world rotation *is*
|
|
421
|
+
* is exactly what an instrument comparing two skeletons must not carry: it
|
|
422
|
+
* would show up as a difference between the two rigs.
|
|
423
|
+
*/
|
|
424
|
+
export interface BoneSnapshot {
|
|
425
|
+
name: string;
|
|
426
|
+
/** World origin. */
|
|
427
|
+
worldX: number;
|
|
428
|
+
worldY: number;
|
|
429
|
+
/**
|
|
430
|
+
* The world matrix's linear part, `[a b][c d]`. **Complete**: rotation, scale
|
|
431
|
+
* and shear all live in these four numbers, and they are dimensionless — they
|
|
432
|
+
* map a local offset to a world offset, both in world units.
|
|
433
|
+
*/
|
|
434
|
+
a: number;
|
|
435
|
+
b: number;
|
|
436
|
+
c: number;
|
|
437
|
+
d: number;
|
|
438
|
+
/** The direction the bone points, in degrees CCW. */
|
|
439
|
+
rotationX: number;
|
|
440
|
+
/** The y axis's own direction — the pair with `rotationX` is where shear shows. */
|
|
441
|
+
rotationY: number;
|
|
442
|
+
/** Magnitudes, always positive. */
|
|
443
|
+
scaleX: number;
|
|
444
|
+
scaleY: number;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export interface Quad extends PieceCommon {
|
|
448
|
+
kind: 'region';
|
|
449
|
+
/** World-space corners, in spine-core's region order: bl, ul, ur, br (verified against computeWorldVertices — the 2026-09-03 run reconstructed this from measurement after the old comment cost it days). */
|
|
450
|
+
world: number[];
|
|
451
|
+
/** Page UVs for the same four corners. */
|
|
452
|
+
uvs: ArrayLike<number>;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* A posed mesh attachment: world vertices, page UVs, and the triangulation.
|
|
457
|
+
*
|
|
458
|
+
* The vertices arrive from `MeshAttachment.computeWorldVertices`, which is the
|
|
459
|
+
* runtime's own routine and therefore the only place the weighting and deform
|
|
460
|
+
* arithmetic lives. Reimplementing either here would give `check` a second
|
|
461
|
+
* opinion about where a vertex is, and a second opinion is exactly what a gate
|
|
462
|
+
* must not have.
|
|
463
|
+
*/
|
|
464
|
+
export interface Mesh extends PieceCommon {
|
|
465
|
+
kind: 'mesh';
|
|
466
|
+
/** Page UVs, `u, v` per vertex, parallel to `world`. */
|
|
467
|
+
uvs: ArrayLike<number>;
|
|
468
|
+
/** Vertex index triplets. */
|
|
469
|
+
triangles: ArrayLike<number>;
|
|
470
|
+
/**
|
|
471
|
+
* A piece a clip cut only: for each drawn triangle, in triangle order, the
|
|
472
|
+
* SOURCE triangle it was cut from — its three world corners and their page
|
|
473
|
+
* UVs, six numbers each per drawn triangle (`ClipSource`). The rasteriser
|
|
474
|
+
* samples such a triangle's pixels at the source triangle's affine UV map,
|
|
475
|
+
* so the picture does not depend on which convex pieces the clipper cut
|
|
476
|
+
* (issue #964). Absent on every piece no clip cut, whose path is unchanged.
|
|
477
|
+
*/
|
|
478
|
+
source?: ClipSource;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** The source triangles of a clipped piece's drawn triangles — see `Mesh.source`. */
|
|
482
|
+
export interface ClipSource {
|
|
483
|
+
/** Three world corners (`x, y` each) per drawn triangle. */
|
|
484
|
+
world: number[];
|
|
485
|
+
/** The page UVs of those corners, parallel to `world`. */
|
|
486
|
+
uvs: number[];
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** One drawable in a posed frame. */
|
|
490
|
+
export type Piece = Quad | Mesh;
|
|
491
|
+
|
|
492
|
+
export interface Frame {
|
|
493
|
+
/** Index within the sampled sequence — the number in `f0000.png`. */
|
|
494
|
+
index: number;
|
|
495
|
+
time: number;
|
|
496
|
+
/**
|
|
497
|
+
* Everything the frame draws, in draw order.
|
|
498
|
+
*
|
|
499
|
+
* Named `pieces` rather than `quads` since meshes joined it: a mesh is not a
|
|
500
|
+
* quad, and a field that says otherwise is the kind of name a reader trusts
|
|
501
|
+
* and then indexes `world[6]` through.
|
|
502
|
+
*/
|
|
503
|
+
pieces: Piece[];
|
|
504
|
+
/**
|
|
505
|
+
* Every bone's world transform at this frame — present only when
|
|
506
|
+
* `PoseOptions.bones` asked for it, so a renderer neither pays for it nor
|
|
507
|
+
* sees a field it would have to ignore.
|
|
508
|
+
*
|
|
509
|
+
* ⭐ It rides on `Frame` rather than being sampled by a loop of its own so
|
|
510
|
+
* that the ladder's stage 3 and the reference frames step a skeleton through
|
|
511
|
+
* **one** recipe. `sampleAnimation`'s stepping order — `state.update`,
|
|
512
|
+
* `state.apply`, `skeleton.update`, `updateWorldTransform(Physics.update)`,
|
|
513
|
+
* and `Physics.reset` on the first frame alone — is a sequence two
|
|
514
|
+
* implementations would drift on, and a per-frame pose comparison that
|
|
515
|
+
* drifted from the renderer would report the drift as a difference between
|
|
516
|
+
* the two rigs.
|
|
517
|
+
*/
|
|
518
|
+
bones?: BoneSnapshot[];
|
|
519
|
+
/**
|
|
520
|
+
* Every slot's attachment as the pose left it, in draw order — present only
|
|
521
|
+
* when `PoseOptions.geometry` asked for it (issue #864).
|
|
522
|
+
*
|
|
523
|
+
* ⚠️ Not `pieces` again. A piece is what gets DRAWN: `--slot`/`--hide` remove
|
|
524
|
+
* pieces and a clip replaces one with the clipper's own triangle list, whose
|
|
525
|
+
* vertices are not the attachment's and are not numbered like them. A
|
|
526
|
+
* consumer comparing a triangle's edges across frames needs vertex `i` to be
|
|
527
|
+
* the same vertex in every frame, so these are the attachment's own vertices,
|
|
528
|
+
* whole, for every slot that shows a region or a mesh.
|
|
529
|
+
*
|
|
530
|
+
* Read off the same skeleton at the same step as `pieces` and `bones`, which
|
|
531
|
+
* is what puts it on render's frame grid by construction rather than by a
|
|
532
|
+
* second derivation of it.
|
|
533
|
+
*/
|
|
534
|
+
attachments?: AttachmentPose[];
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* One slot's region or mesh attachment in one posed frame (issue #864).
|
|
539
|
+
*
|
|
540
|
+
* `vertices` come from the runtime's own `computeWorldVertices` over the whole
|
|
541
|
+
* attachment — the call `pieceOf` makes, through the one helper both share —
|
|
542
|
+
* so skinning and deform live in spine-core and nowhere here.
|
|
543
|
+
*/
|
|
544
|
+
export interface AttachmentPose {
|
|
545
|
+
slot: string;
|
|
546
|
+
/** The attachment's own name, which is what a deform or attachment timeline keys. */
|
|
547
|
+
attachment: string;
|
|
548
|
+
/**
|
|
549
|
+
* World positions, `x, y` per vertex, **y up**. A region's four corners are in
|
|
550
|
+
* spine-core's order — bottom-left, top-left, top-right, bottom-right — and a
|
|
551
|
+
* mesh's vertices in the attachment's own order, so index `i` names the same
|
|
552
|
+
* vertex in every frame.
|
|
553
|
+
*/
|
|
554
|
+
vertices: number[];
|
|
555
|
+
/** Slot colour x attachment colour, straight alpha, 0..1 — the piece's `tint`, by the same arithmetic. */
|
|
556
|
+
color: [number, number, number, number];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Where the world sits in a frame: the four world numbers plus the scale. */
|
|
560
|
+
export interface Viewport {
|
|
561
|
+
minX: number;
|
|
562
|
+
minY: number;
|
|
563
|
+
maxX: number;
|
|
564
|
+
maxY: number;
|
|
565
|
+
/** Frame pixels per world unit. */
|
|
566
|
+
scale: number;
|
|
567
|
+
/** Frame size in pixels. */
|
|
568
|
+
width: number;
|
|
569
|
+
height: number;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
// what a render and a check read off a candidate besides its pose (issue #1014)
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
//
|
|
576
|
+
// ⭐ `render` and `check` read a handful of facts off the candidate before and
|
|
577
|
+
// beside the pose: the animation and skin names their flags are checked
|
|
578
|
+
// against, the slot subset's roster, whether a stage is declared, the bone
|
|
579
|
+
// tree `check` draws its chains from, the skin roster the framing reads, and
|
|
580
|
+
// the atlas pages the rasteriser samples. Until issue #1014 every one of them
|
|
581
|
+
// came off the skeleton spine-core had parsed, so a rigc build the core poses
|
|
582
|
+
// still loaded and ran the runtime before its poser was chosen. Now the choice
|
|
583
|
+
// comes first (`loadCandidate`), and a build the core poses reads each fact
|
|
584
|
+
// where it is written: the names, the tree, the subset's roster and the stage
|
|
585
|
+
// off the skeleton's own JSON (`skeletonFacts`), the pages off rigc's atlas
|
|
586
|
+
// reader, and the skin roster off the model document (`coreSkinRoster` in
|
|
587
|
+
// `./render_core.ts`). Every one of those readings was measured equal to the
|
|
588
|
+
// spine-core reading it replaces on every input the tree carries — the
|
|
589
|
+
// nineteen built corpus rows and the twelve editor exports (the PR of #1014
|
|
590
|
+
// carries the counts). A Spine export, `--poser spine` and a fallback the
|
|
591
|
+
// poser line names load spine-core as before and read every fact off it.
|
|
592
|
+
//
|
|
593
|
+
// Since issue #1020 a build the core poses reads what its model document
|
|
594
|
+
// states off the document (`coreFacts`): the bone tree, the slot list and the
|
|
595
|
+
// subset's roster, and the page images by the names its `pages` section gives
|
|
596
|
+
// — so with a `rigc-compiled/2` document the atlas file is not needed at all.
|
|
597
|
+
// Since issue #1026 a `rigc-compiled/3` document also states what only the
|
|
598
|
+
// Spine files held — the order the animations and skins are listed in (the
|
|
599
|
+
// emitter's, not the model's), the stage, and each page's `scale:` line — so
|
|
600
|
+
// a build the core poses reads every fact off its document. A `/2` or `/1`
|
|
601
|
+
// document's reader still takes those off `skeleton.json` and the atlas, and
|
|
602
|
+
// the poser line says so (`unstatedClause`).
|
|
603
|
+
|
|
604
|
+
/** What `render` and `check` read off a candidate's skeleton besides its pose. */
|
|
605
|
+
export interface SkeletonFacts {
|
|
606
|
+
/** Every animation name, in the skeleton's own order. */
|
|
607
|
+
readonly animations: readonly string[];
|
|
608
|
+
/** Every skin name, in the skeleton's own order. */
|
|
609
|
+
readonly skins: readonly string[];
|
|
610
|
+
/** Whether the header states a numeric `width` and `height` — a setup stage (issue #714). */
|
|
611
|
+
readonly declaresStage: boolean;
|
|
612
|
+
/** Every bone in declaration order, and its parent's name. */
|
|
613
|
+
readonly bones: ReadonlyArray<{ name: string; parent: string | null }>;
|
|
614
|
+
/** Every slot in declaration order, and the bone it hangs from. */
|
|
615
|
+
readonly slots: ReadonlyArray<{ name: string; bone: string }>;
|
|
616
|
+
/** `slotSubsetOf` over this skeleton — refused by `SlotSubsetError`. */
|
|
617
|
+
subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
|
|
618
|
+
/**
|
|
619
|
+
* The `scale:` lines the candidate's atlas declares (`atlasScales`), or, for
|
|
620
|
+
* a build posed from a `rigc-compiled/3` document, the ones its pages state
|
|
621
|
+
* (issue #1026); `null` where neither was read — a `/2` or `/1` build drawn
|
|
622
|
+
* with its atlas gone (issue #1020). `check`'s texture note reads it.
|
|
623
|
+
*/
|
|
624
|
+
readonly atlasScales: readonly number[] | null;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
type JsonObject = Record<string, unknown>;
|
|
628
|
+
|
|
629
|
+
/** A JSON value as an object, or an empty one where it is not one. */
|
|
630
|
+
function objectOf(value: unknown): JsonObject {
|
|
631
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonObject) : {};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** A JSON value as a list of objects, or an empty list where it is not one. */
|
|
635
|
+
function objectsOf(value: unknown): JsonObject[] {
|
|
636
|
+
return Array.isArray(value) ? value.map(objectOf) : [];
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* The same facts off the skeleton's own JSON — a candidate the core poses
|
|
641
|
+
* (issue #1014), whose skeleton spine-core never loads.
|
|
642
|
+
*
|
|
643
|
+
* Each is the file's own statement, read in the file's order: the animation
|
|
644
|
+
* names are the keys of `animations` (the order the parser iterates them), the
|
|
645
|
+
* skins `skins[].name` (the default skin the one named `default`), a slot's
|
|
646
|
+
* carriers the skins whose `attachments` give it at least one entry, the tree
|
|
647
|
+
* `bones[]` and `slots[]`, and the stage `skeleton.width`/`height`. The core
|
|
648
|
+
* poses only a rigc build whose `skeleton.json` hashes to the digest its model
|
|
649
|
+
* document records, so the file read here is the one `build` wrote and the
|
|
650
|
+
* gate round-tripped.
|
|
651
|
+
*/
|
|
652
|
+
export function skeletonFacts(skeletonText: string, atlasText: string | null): SkeletonFacts {
|
|
653
|
+
const root = objectOf(JSON.parse(skeletonText));
|
|
654
|
+
const skins = objectsOf(root.skins);
|
|
655
|
+
const slots = objectsOf(root.slots).map((slot) => ({ name: String(slot.name), bone: String(slot.bone) }));
|
|
656
|
+
const header = objectOf(root.skeleton);
|
|
657
|
+
const roster: SubsetRoster = {
|
|
658
|
+
declared: slots.map((slot) => slot.name),
|
|
659
|
+
carriers: (slot) => skins.filter((skin) => Object.keys(objectOf(objectOf(skin.attachments)[slot])).length > 0).map((skin) => String(skin.name)),
|
|
660
|
+
defaultSkin: skins.some((skin) => skin.name === 'default') ? 'default' : null,
|
|
661
|
+
};
|
|
662
|
+
return {
|
|
663
|
+
atlasScales: atlasText === null ? null : atlasScales(atlasText),
|
|
664
|
+
animations: Object.keys(objectOf(root.animations)),
|
|
665
|
+
skins: skins.map((skin) => String(skin.name)),
|
|
666
|
+
declaresStage: typeof header.width === 'number' && typeof header.height === 'number',
|
|
667
|
+
bones: objectsOf(root.bones).map((bone) => ({ name: String(bone.name), parent: typeof bone.parent === 'string' ? bone.parent : null })),
|
|
668
|
+
slots,
|
|
669
|
+
subset: (opts, skin) => subsetOver(roster, opts, skin),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Each animation's duration off the skeleton's own JSON, in the file's order —
|
|
675
|
+
* what `rosterDifference` holds a model document's durations to without
|
|
676
|
+
* loading the skeleton through spine-core (issue #1014).
|
|
677
|
+
*
|
|
678
|
+
* ⚠️ Skeleton JSON carries no duration, so this is the parser's derivation of
|
|
679
|
+
* one, and it is stated as measured rather than as read: the LAST key time of
|
|
680
|
+
* each timeline, the largest of them, held as a float32 — equal to
|
|
681
|
+
* spine-core's `Animation.duration` on every animation of every input the tree
|
|
682
|
+
* carries (the PR of #1014). The timelines are the ones `walkTimelines` walks,
|
|
683
|
+
* the walk `A05` and `A12` stand on, so a group it did not descend would be
|
|
684
|
+
* missing from both.
|
|
685
|
+
*/
|
|
686
|
+
function skeletonDurations(root: JsonObject): Array<{ name: string; duration: number }> {
|
|
687
|
+
return Object.entries(objectOf(root.animations)).map(([name, animation]) => {
|
|
688
|
+
let duration = 0;
|
|
689
|
+
walkTimelines({ animations: { [name]: animation } }, (_path, _kind, _timeline, keys) => {
|
|
690
|
+
if (keys.length === 0) return;
|
|
691
|
+
const last = objectOf(keys[keys.length - 1]);
|
|
692
|
+
duration = Math.max(duration, Math.fround(typeof last.time === 'number' ? last.time : 0));
|
|
693
|
+
});
|
|
694
|
+
return { name, duration };
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Every page named, read from `atlasDir` by that name — the images a candidate
|
|
700
|
+
* is drawn from, whichever poser draws it. A page that is not there is
|
|
701
|
+
* `absent`'s refusal, handed the page's absolute path (issues #1033, #1042):
|
|
702
|
+
* left to `readPlate`, it surfaced as an ENOENT and a stack. A page that is
|
|
703
|
+
* there and is not a PNG says so itself (`readPlate`).
|
|
704
|
+
*/
|
|
705
|
+
function pagesAt(names: readonly string[], atlasDir: string, absent: (page: string) => Error): Map<string, Plate> {
|
|
706
|
+
const pages = new Map<string, Plate>();
|
|
707
|
+
for (const name of names) {
|
|
708
|
+
const path = join(atlasDir, name);
|
|
709
|
+
if (!existsSync(path)) throw absent(resolve(path));
|
|
710
|
+
pages.set(name, readPlate(path));
|
|
711
|
+
}
|
|
712
|
+
return pages;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* A candidate with no atlas beside it that has to be read through one —
|
|
717
|
+
* refused naming the file and why (issue #1020).
|
|
718
|
+
*
|
|
719
|
+
* A rigc build whose model document states where each region sits on its page
|
|
720
|
+
* (`rigc-compiled/2`, the `pages` section) is drawn by the core without its
|
|
721
|
+
* atlas. Everything else reads one: a Spine export, `--poser spine`, a
|
|
722
|
+
* `rigc-compiled/1` document, and a build the core refuses and spine-core
|
|
723
|
+
* draws instead. A class of its own so `cli.ts` refuses it as an invocation
|
|
724
|
+
* (exit 2, nothing written), as it refuses a missing atlas on an export.
|
|
725
|
+
*/
|
|
726
|
+
export class CandidateAtlasError extends Error {}
|
|
727
|
+
|
|
728
|
+
/** The refusal for a candidate that has to be read through an atlas it does not have. */
|
|
729
|
+
function atlasAbsent(atlasPath: string, label: string, why: string): CandidateAtlasError {
|
|
730
|
+
return new CandidateAtlasError(
|
|
731
|
+
`nothing at ${atlasPath}: ${label} is drawn through its atlas (${why}). ` +
|
|
732
|
+
'Only a rigc build whose skeleton.model.json is a rigc-compiled/2 or /3 document, posed by the core, is drawn ' +
|
|
733
|
+
'without one — it states where each region sits on its page',
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* A candidate whose skeleton spine-core cannot load against the atlas beside
|
|
739
|
+
* it — refused naming both files, why the runtime was drawing them, and what
|
|
740
|
+
* the runtime could not resolve, in its own words (issue #1033).
|
|
741
|
+
*
|
|
742
|
+
* On a rigc build this is a directory whose files are not one build: a
|
|
743
|
+
* `skeleton.json` or a `skeleton.atlas` from another build put beside this
|
|
744
|
+
* one's `skeleton.model.json`. The core refuses the pair first (the digest, or
|
|
745
|
+
* the atlas's pages, on the poser line's reason), the fallback hands it to
|
|
746
|
+
* spine-core, and the runtime stops at the first name it cannot resolve —
|
|
747
|
+
* which surfaced as its own uncaught error and a stack. A class of its own so
|
|
748
|
+
* `cli.ts` refuses it as an invocation (exit 2, nothing written), as it
|
|
749
|
+
* refuses a missing atlas on an export: the files the command was pointed at
|
|
750
|
+
* have to change, not the rig.
|
|
751
|
+
*
|
|
752
|
+
* Since issue #1042 it is also `bonedist`'s (and `bench --bones`'), on either
|
|
753
|
+
* side (`loadPosedSkeleton`), and the refusal for a page a candidate is drawn
|
|
754
|
+
* from that is not there on every poser's path — the core's included, where a
|
|
755
|
+
* build moved whole away from where it was built died on `readPlate`'s ENOENT.
|
|
756
|
+
*/
|
|
757
|
+
export class CandidatePairError extends Error {}
|
|
758
|
+
|
|
759
|
+
/** Where a candidate's files sit, as the refusals below name them: the atlas, and the directory when it holds a model document. */
|
|
760
|
+
function pairPlace(paths: { skeleton: string; atlas: string } | null): { atlas: string; build: string | null } {
|
|
761
|
+
const dir = paths === null ? null : dirname(resolve(paths.skeleton));
|
|
762
|
+
return { atlas: paths === null ? 'the atlas handed over with it' : resolve(paths.atlas), build: dir !== null && existsSync(join(dir, MODEL_DOCUMENT_FILE)) ? dir : null };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** What a directory whose files are not one build is told to do. */
|
|
766
|
+
function notOneBuild(dir: string): string {
|
|
767
|
+
return (
|
|
768
|
+
`${dir} is not one build: ${MODEL_DOCUMENT_FILE}, skeleton.json and skeleton.atlas are written together by one \`rigc build\`, ` +
|
|
769
|
+
"and these are not one build's — build it again, or put that build's own files back beside each other"
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* The refusal for a skeleton the runtime could not load against its atlas:
|
|
775
|
+
* `runtime` is the runtime's own message, and `does` what spine-core was
|
|
776
|
+
* loading it to do — `draws` for `render` and `check`, `poses` for a command
|
|
777
|
+
* that reads the posed bones and draws nothing (`bonedist`, issue #1042).
|
|
778
|
+
*/
|
|
779
|
+
function pairRefusal(
|
|
780
|
+
label: string,
|
|
781
|
+
paths: { skeleton: string; atlas: string } | null,
|
|
782
|
+
why: string,
|
|
783
|
+
runtime: string,
|
|
784
|
+
does: 'draws' | 'poses' = 'draws',
|
|
785
|
+
): CandidatePairError {
|
|
786
|
+
const { atlas, build } = pairPlace(paths);
|
|
787
|
+
const head = `${label} does not load against ${atlas}: spine-core ${does} this pair (${why}) and could not resolve it — ${JSON.stringify(runtime)}. `;
|
|
788
|
+
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)));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* The refusal for a page a candidate is drawn from that is not there — the
|
|
793
|
+
* page's path is relative to the directory of the file that names it, so the
|
|
794
|
+
* file was written somewhere else. What the last clause may claim depends on
|
|
795
|
+
* what is known about the directory:
|
|
796
|
+
*
|
|
797
|
+
* - an export (no model document): only that the page has to be there;
|
|
798
|
+
* - a rigc build whose files the core REFUSED (issue #1033): an atlas copied
|
|
799
|
+
* from another build's directory, and the directory is not one build — the
|
|
800
|
+
* core's own reason, on the poser line's words, says so;
|
|
801
|
+
* - a rigc build the core ACCEPTED, or one `--poser spine` never asked the
|
|
802
|
+
* core about (issue #1042): the build was moved or copied away from where it
|
|
803
|
+
* was built. "Not one build" would be a guess there, and on a build moved
|
|
804
|
+
* whole — measured — a false one.
|
|
805
|
+
*
|
|
806
|
+
* `by` is the file that names the page (`null`: the atlas), `draws` what is
|
|
807
|
+
* drawn from it.
|
|
808
|
+
*/
|
|
809
|
+
function pageRefusal(
|
|
810
|
+
page: string,
|
|
811
|
+
paths: { skeleton: string; atlas: string } | null,
|
|
812
|
+
why: string,
|
|
813
|
+
reading: { by: string | null; draws: string; refusedBuild: boolean },
|
|
814
|
+
): CandidatePairError {
|
|
815
|
+
const { atlas, build } = pairPlace(paths);
|
|
816
|
+
// The poser line's reason, unless it is only the document's path — which the sentence has just named.
|
|
817
|
+
const because = why === reading.by ? '' : ` (${why})`;
|
|
818
|
+
const head =
|
|
819
|
+
`nothing at ${page}: ${reading.by ?? atlas} names it as a page, and ${reading.draws}${because}. ` +
|
|
820
|
+
`A page path is relative to the ${reading.by === null ? "atlas's own" : "build's"} directory`;
|
|
821
|
+
const tail =
|
|
822
|
+
build === null
|
|
823
|
+
? ', and the page has to be there'
|
|
824
|
+
: reading.refusedBuild
|
|
825
|
+
? `, so an atlas copied from another build's directory names pages that are not here. ${notOneBuild(build)}`
|
|
826
|
+
: `, so a build moved or copied away from the directory it was built in names pages that are not here — ` +
|
|
827
|
+
'build it again where it is, or build it with --copy-images, which writes its pages beside it';
|
|
828
|
+
return new CandidatePairError(head + tail);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** A candidate as `render` and `check` read it: the posers, the facts and the pages (issue #1014). */
|
|
832
|
+
export interface Candidate {
|
|
833
|
+
choice: PoserChoice;
|
|
834
|
+
facts: SkeletonFacts;
|
|
835
|
+
pages: Map<string, Plate>;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Load a candidate for `render` or `check`, choosing its poser FIRST (issue
|
|
840
|
+
* #1014): a rigc build the core poses reads its facts off its model document
|
|
841
|
+
* and its own JSON (`coreFacts`), its page images by the names the
|
|
842
|
+
* document's `pages` gives (a `rigc-compiled/1` document's, by its atlas's)
|
|
843
|
+
* and its skin roster off the document, and spine-core is never loaded for
|
|
844
|
+
* it; `input.atlasText` is `null` where the atlas file is not there, which only
|
|
845
|
+
* a `rigc-compiled/2` document the core poses is drawn without (issue #1020 —
|
|
846
|
+
* anything else is refused, `CandidateAtlasError`); anything else — a Spine export,
|
|
847
|
+
* `--poser spine`, a candidate handed over as text (`paths` null, `unplaced`
|
|
848
|
+
* the reason) — is loaded through spine-core as `posableFromText` always
|
|
849
|
+
* loaded it. The choice's spine-core poser stays unloaded until something
|
|
850
|
+
* reads it, which on a rigc build is a fallback the poser line names.
|
|
851
|
+
*
|
|
852
|
+
* `--poser core` on an input that cannot carry it is NOT refused here: the
|
|
853
|
+
* caller says it where it always has (`refuseUnchosen`), after the flags that
|
|
854
|
+
* refuse before it.
|
|
855
|
+
*/
|
|
856
|
+
export function loadCandidate(
|
|
857
|
+
input: { skeletonText: string; atlasText: string | null; atlasDir: string; label: string },
|
|
858
|
+
paths: { skeleton: string; atlas: string } | null,
|
|
859
|
+
forced: PoserName | undefined,
|
|
860
|
+
options: { make?: MakeCorePoser; unplaced?: string } = {},
|
|
861
|
+
): Candidate {
|
|
862
|
+
let data: SkeletonData | null = null;
|
|
863
|
+
let choice: PoserChoice | null = null;
|
|
864
|
+
// `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.
|
|
865
|
+
const atlasText = (why: string): string => {
|
|
866
|
+
if (input.atlasText === null) throw atlasAbsent(paths?.atlas ?? '(no atlas)', input.label, why);
|
|
867
|
+
return input.atlasText;
|
|
868
|
+
};
|
|
869
|
+
// A pair the runtime cannot load — a skeleton.json or an atlas from another build beside this one's document — is
|
|
870
|
+
// refused by name rather than surfacing the runtime's own throw and stack (issue #1033); `spineSkeletonData` is
|
|
871
|
+
// where the catch is.
|
|
872
|
+
// Under `--poser core` the runtime is loaded only for the facts the flags are checked against, and nothing would be
|
|
873
|
+
// drawn through it: the refusal that is true there is the flag's, the one `refuseUnchosen` would have said next.
|
|
874
|
+
const refusing = (refusal: CandidatePairError): Error =>
|
|
875
|
+
forced === 'core' && choice !== null && choice.core === null ? new PoserChoiceError(`--poser core: ${choice.why}`) : refusal;
|
|
876
|
+
const spineLoad = (text: string, why: string): SkeletonData =>
|
|
877
|
+
spineSkeletonData(input.skeletonText, text, (runtime) => refusing(pairRefusal(input.label, paths, why, runtime)));
|
|
878
|
+
const spineData = (): SkeletonData => {
|
|
879
|
+
if (data === null) {
|
|
880
|
+
const why = choice === null || choice.core !== null ? 'a fallback from the core poser' : choice.why;
|
|
881
|
+
const text = atlasText(why);
|
|
882
|
+
requireSpineRuntime(input.label, why);
|
|
883
|
+
data = spineLoad(text, why);
|
|
884
|
+
}
|
|
885
|
+
return data;
|
|
886
|
+
};
|
|
887
|
+
const chosen =
|
|
888
|
+
paths === null
|
|
889
|
+
? { choice: posersOver(forced, null, forced === 'spine' ? '--poser spine' : (options.unplaced ?? 'no path to find a model document beside'), spineData), document: null }
|
|
890
|
+
: choosePosers(paths.skeleton, paths.atlas, input.atlasText, forced, spineData, options.make ?? corePoser);
|
|
891
|
+
choice = chosen.choice;
|
|
892
|
+
if (choice.core !== null && chosen.document !== null) {
|
|
893
|
+
// A rigc-compiled/2 document names its pages; a /1 document is posed only with its atlas beside it, which names them (`choosePosers`).
|
|
894
|
+
const named = chosen.document.pageNames;
|
|
895
|
+
const names = named ?? parseAtlasText(atlasText(choice.why)).pages.map((page) => page.name);
|
|
896
|
+
// A page the build names that is not here is a build moved away from where it was built (issue #1042): the
|
|
897
|
+
// core accepted these files as one build, so the refusal does not say they are not one.
|
|
898
|
+
const by = named === null || paths === null ? null : join(dirname(resolve(paths.skeleton)), MODEL_DOCUMENT_FILE);
|
|
899
|
+
const reading = { by, draws: 'the core draws this build from it', refusedBuild: false };
|
|
900
|
+
const accepted = choice.why;
|
|
901
|
+
const pages = pagesAt(names, input.atlasDir, (page) => pageRefusal(page, paths, accepted, reading));
|
|
902
|
+
return { choice, facts: coreFacts(input.skeletonText, input.atlasText, choice.core, chosen.document), pages };
|
|
903
|
+
}
|
|
904
|
+
const text = atlasText(choice.why);
|
|
905
|
+
requireSpineRuntime(input.label, choice.why);
|
|
906
|
+
// `posableFromText`'s two steps, in its order — every page the atlas declares, then the skeleton — each refusing
|
|
907
|
+
// by name where the pair is not one: a page that is not there (`pageRefusal`), a skeleton the runtime cannot load
|
|
908
|
+
// against the atlas (`spineLoad`). The directory is called "not one build" only where the core refused its files —
|
|
909
|
+
// `--poser spine` never asked the core, and a build moved whole is one build whose pages are elsewhere.
|
|
910
|
+
const reading = { by: null, draws: 'spine-core draws this pair through that atlas', refusedBuild: forced !== 'spine' };
|
|
911
|
+
const why = choice.why;
|
|
912
|
+
const pages = pagesAt(
|
|
913
|
+
atlasPageNames(text),
|
|
914
|
+
input.atlasDir,
|
|
915
|
+
(page) => refusing(pageRefusal(page, paths, why, reading)),
|
|
916
|
+
);
|
|
917
|
+
data = spineLoad(text, choice.why);
|
|
918
|
+
return { choice, facts: spineFacts(data, text), pages };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* A candidate's facts when the core poses it (issue #1020): what the model
|
|
923
|
+
* document states, read from it — the bone tree and the slot list (the core
|
|
924
|
+
* poser's own, which `rosterDifference` held to the Spine file's before the
|
|
925
|
+
* core was chosen) and the slot subset's roster (`coreDocumentFacts`). Since
|
|
926
|
+
* issue #1026 a `rigc-compiled/3` document states the rest too — the order
|
|
927
|
+
* the animations and skins are listed in (the editor's, `editorOrder`),
|
|
928
|
+
* whether a stage is declared (`stage`) and its pages' `scale:` lines — and
|
|
929
|
+
* nothing is read off `skeleton.json` or the atlas. A `/2` or `/1` document
|
|
930
|
+
* states none of those, so they are read where they were before: the orders
|
|
931
|
+
* and the stage off the skeleton's own JSON, the scale lines off the atlas
|
|
932
|
+
* (`null` with it gone) — and the poser line says so (`unstatedClause`).
|
|
933
|
+
*/
|
|
934
|
+
function coreFacts(skeletonText: string, atlasText: string | null, poser: Poser, document: CoreDocumentFacts): SkeletonFacts {
|
|
935
|
+
const unstated = (): Pick<SkeletonFacts, 'atlasScales' | 'animations' | 'skins' | 'declaresStage'> => {
|
|
936
|
+
const spine = skeletonFacts(skeletonText, atlasText);
|
|
937
|
+
return { atlasScales: spine.atlasScales, animations: spine.animations, skins: spine.skins, declaresStage: spine.declaresStage };
|
|
938
|
+
};
|
|
939
|
+
const read = document.stated === null ? unstated() : { ...document.stated, atlasScales: document.stated.scales };
|
|
940
|
+
return {
|
|
941
|
+
atlasScales: read.atlasScales,
|
|
942
|
+
animations: read.animations,
|
|
943
|
+
skins: read.skins,
|
|
944
|
+
declaresStage: read.declaresStage,
|
|
945
|
+
bones: poser.bones,
|
|
946
|
+
slots: poser.slots,
|
|
947
|
+
subset: (opts, skin) => subsetOver(document.subset, opts, skin),
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ---------------------------------------------------------------------------
|
|
952
|
+
// the posing seam (issue #965, step 3a of #380)
|
|
953
|
+
// ---------------------------------------------------------------------------
|
|
954
|
+
//
|
|
955
|
+
// ⭐ Everything this file reads off a posed skeleton goes through one
|
|
956
|
+
// interface, `Poser`, and the samplers below are written against it alone. The
|
|
957
|
+
// implementation behind it today is spine-core's (`spinePoser`, further down);
|
|
958
|
+
// the core-backed poser of issue #968 is a SECOND implementation of the same
|
|
959
|
+
// interface, handed to the same samplers, rather than a rewrite of them. What
|
|
960
|
+
// the interface fixes is exactly what a consumer of a posed frame reads:
|
|
961
|
+
//
|
|
962
|
+
// - the setup pose, under a skin or under none;
|
|
963
|
+
// - an animation's frames at `i/fps` for `i = 0..count`, one continuous
|
|
964
|
+
// trajectory stepped once per frame (the stepping recipe is the
|
|
965
|
+
// implementation's; the schedule — which `i`, what `count`, what time a
|
|
966
|
+
// frame is filed under — is the sampler's, and stays here);
|
|
967
|
+
// - per posed frame: the pieces in draw order (world vertices, page UVs,
|
|
968
|
+
// triangles, tint, dark, page, clip output), the bone snapshots, and every
|
|
969
|
+
// slot's whole attachment geometry;
|
|
970
|
+
// - the rest table, and the names a geometry file and a slot subset read.
|
|
971
|
+
//
|
|
972
|
+
// The framing samples are not a fourth entry: `framingViewport` is the same
|
|
973
|
+
// animation entry at `FRAMING_FPS` with the clip off.
|
|
974
|
+
//
|
|
975
|
+
// 🔒 What stays spine-core's and outside the seam, deliberately: an export's
|
|
976
|
+
// atlas (`posableFromText`'s pages, and `substituteTexture`'s region lookup on
|
|
977
|
+
// anything spine-core poses — a rigc build the core poses reads both through
|
|
978
|
+
// rigc's own reader since issue #1020) and `posedNumbersOf`, which `validate.ts` calls on a spine-core
|
|
979
|
+
// skeleton it stepped itself (A10's runtime supplier), so the round trip keeps its spine-core
|
|
980
|
+
// entry whatever poses the renders.
|
|
981
|
+
|
|
982
|
+
/** What a sampler hands a posed frame's draw walk — `PoseOptions` with the subset already resolved. */
|
|
983
|
+
export interface DrawOptions {
|
|
984
|
+
/** The slots drawn, resolved against the posed skeleton (`slotSubsetOf`); `undefined` draws every slot. */
|
|
985
|
+
subset: SlotSubset | undefined;
|
|
986
|
+
/** Every attachment whole, no clip applied — the framing box's reading. */
|
|
987
|
+
unclipped: boolean;
|
|
988
|
+
/** Also record each piece's original-art UVs — see `PieceTexture`. */
|
|
989
|
+
texture: boolean;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* One posed moment, readable only while the `Poser` call that produced it is
|
|
994
|
+
* running: an implementation may step one skeleton in place, so a reader takes
|
|
995
|
+
* what it needs before the next frame is posed.
|
|
996
|
+
*/
|
|
997
|
+
export interface Posed {
|
|
998
|
+
/** The drawables in draw order — see `piecesOf` for the walk and the clip. */
|
|
999
|
+
pieces(draw: DrawOptions): Piece[];
|
|
1000
|
+
/** Every bone's world transform, in the skeleton's declaration order. */
|
|
1001
|
+
bones(): BoneSnapshot[];
|
|
1002
|
+
/** Every slot's region or mesh attachment, whole, in the posed draw order. */
|
|
1003
|
+
attachments(): AttachmentPose[];
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* The posing seam: one skeleton, posed on demand.
|
|
1008
|
+
*
|
|
1009
|
+
* Named for what it does rather than for the runtime behind it, because the
|
|
1010
|
+
* point of the name is that there are two: `spinePoser` today, the core's at
|
|
1011
|
+
* #968. Every sampler, the framing, the geometry export and the non-finite
|
|
1012
|
+
* sentence take one (or a `SkeletonData`, which they wrap in `spinePoser`).
|
|
1013
|
+
*/
|
|
1014
|
+
export interface Poser {
|
|
1015
|
+
/** Every animation, in declaration order, with its duration in seconds. */
|
|
1016
|
+
readonly animations: ReadonlyArray<{ name: string; duration: number }>;
|
|
1017
|
+
/** Every bone in declaration order, and its parent's name. */
|
|
1018
|
+
readonly bones: ReadonlyArray<{ name: string; parent: string | null }>;
|
|
1019
|
+
/** Every slot in declaration order, and the bone it hangs from. */
|
|
1020
|
+
readonly slots: ReadonlyArray<{ name: string; bone: string }>;
|
|
1021
|
+
/** `slotSubsetOf` against this skeleton posed under `skin` — refused by `SlotSubsetError`. */
|
|
1022
|
+
subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
|
|
1023
|
+
/** The setup pose under `skin` (absent: no skin set at all). */
|
|
1024
|
+
setup(skin: string | undefined): Posed;
|
|
1025
|
+
/**
|
|
1026
|
+
* Animation `name` under `skin`, not looping: `visit(i, posed)` for every
|
|
1027
|
+
* `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
|
|
1028
|
+
*/
|
|
1029
|
+
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void): void;
|
|
1030
|
+
/** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
|
|
1031
|
+
rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* What every sampler takes: a poser, or spine-core's parsed skeleton, which is
|
|
1036
|
+
* posed through `spinePoser` — the seam's (`./render.ts` declares the same
|
|
1037
|
+
* union over the runtime's own `SkeletonData`).
|
|
1038
|
+
*/
|
|
1039
|
+
export type PoseSource = Poser | SkeletonData;
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Whether a pose source is a `Poser` — told by the seam's own entries rather
|
|
1043
|
+
* than by `instanceof SkeletonData`, which reads the runtime's class and so
|
|
1044
|
+
* reached spine-core on every sample a rigc build took through the core
|
|
1045
|
+
* (issue #1014). A parsed skeleton carries none of the three.
|
|
1046
|
+
*/
|
|
1047
|
+
function isPoser(source: PoseSource): source is Poser {
|
|
1048
|
+
const seam = source as Partial<Poser>;
|
|
1049
|
+
return typeof seam.setup === 'function' && typeof seam.animation === 'function' && typeof seam.rest === 'function';
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function poserOf(source: PoseSource): Poser {
|
|
1053
|
+
return isPoser(source) ? source : spinePoser(source);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** A sampler's `PoseOptions` as a draw walk reads them, the subset resolved once, on the first frame posed. */
|
|
1057
|
+
function drawResolver(poser: Poser, opts: PoseOptions | undefined): () => DrawOptions {
|
|
1058
|
+
let draw: DrawOptions | undefined;
|
|
1059
|
+
return () => {
|
|
1060
|
+
draw ??= { subset: poser.subset(opts, opts?.skin), unclipped: opts?.unclipped === true, texture: opts?.texture === true };
|
|
1061
|
+
return draw;
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** One frame off one posed moment, with what `opts` asked to record beside the pieces. */
|
|
1066
|
+
function frameOf(index: number, time: number, posed: Posed, draw: DrawOptions, opts: PoseOptions | undefined): Frame {
|
|
1067
|
+
return {
|
|
1068
|
+
index,
|
|
1069
|
+
time,
|
|
1070
|
+
pieces: posed.pieces(draw),
|
|
1071
|
+
...(opts?.bones ? { bones: posed.bones() } : {}),
|
|
1072
|
+
...(opts?.geometry ? { attachments: posed.attachments() } : {}),
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Sample one animation at a fixed rate and collect the posed pieces per frame.
|
|
1078
|
+
*
|
|
1079
|
+
* Frame `i` is the pose at `i/fps`, for `i = 0..round(duration·fps)` — the
|
|
1080
|
+
* schedule is this function's; how a pose reaches `i/fps` is the poser's.
|
|
1081
|
+
*/
|
|
1082
|
+
export function sampleAnimation(source: PoseSource, name: string, fps: number, opts?: PoseOptions): Frame[] {
|
|
1083
|
+
const poser = poserOf(source);
|
|
1084
|
+
const animation = poser.animations.find((a) => a.name === name);
|
|
1085
|
+
if (!animation) {
|
|
1086
|
+
throw new Error(
|
|
1087
|
+
`no animation "${name}" in this skeleton; it has [${poser.animations.map((a) => a.name).join(', ') || 'none'}]`,
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
const step = 1 / fps;
|
|
1091
|
+
const count = Math.round(animation.duration * fps);
|
|
1092
|
+
const draw = drawResolver(poser, opts);
|
|
1093
|
+
const frames: Frame[] = [];
|
|
1094
|
+
poser.animation(name, opts?.skin, fps, count, (i, posed) => frames.push(frameOf(i, i * step, posed, draw(), opts)));
|
|
1095
|
+
return frames;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* The setup pose as a single frame — what a skeleton with **no animation at all**
|
|
1100
|
+
* looks like.
|
|
1101
|
+
*
|
|
1102
|
+
* ⭐ Not a degenerate case to be tolerated: a static rig is a deliverable. The
|
|
1103
|
+
* ladder's first rung ships one (`1-weight-and-mass`'s second export), and its
|
|
1104
|
+
* whole content is the setup pose.
|
|
1105
|
+
*/
|
|
1106
|
+
export function sampleSetupPose(source: PoseSource, opts?: PoseOptions): Frame[] {
|
|
1107
|
+
const poser = poserOf(source);
|
|
1108
|
+
const posed = poser.setup(opts?.skin);
|
|
1109
|
+
return [frameOf(0, 0, posed, drawResolver(poser, opts)(), opts)];
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Every animation of one skeleton at one rate, keyed by the name its frames are
|
|
1114
|
+
* filed under. A skeleton with no animation at all contributes its setup pose
|
|
1115
|
+
* under `SETUP_POSE_DIR`.
|
|
1116
|
+
*/
|
|
1117
|
+
export function sampleAll(source: PoseSource, fps: number, opts?: PoseOptions): Map<string, Frame[]> {
|
|
1118
|
+
const poser = poserOf(source);
|
|
1119
|
+
const out = new Map<string, Frame[]>();
|
|
1120
|
+
if (poser.animations.length === 0) out.set(SETUP_POSE_DIR, sampleSetupPose(poser, opts));
|
|
1121
|
+
else for (const animation of poser.animations) out.set(animation.name, sampleAnimation(poser, animation.name, fps, opts));
|
|
1122
|
+
return out;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// ---------------------------------------------------------------------------
|
|
1126
|
+
// which poser a render poses through (issue #968, step 3d of #380)
|
|
1127
|
+
// ---------------------------------------------------------------------------
|
|
1128
|
+
//
|
|
1129
|
+
// ⭐ Two implementations of the seam, chosen by what the input carries and
|
|
1130
|
+
// never guessed: a rigc build writes `skeleton.model.json` beside the Spine
|
|
1131
|
+
// pair, and that document is what the core poses (`./render_core.ts`); a
|
|
1132
|
+
// Spine export (`bench/reference/*`, `examples/*/export/*`) has none and
|
|
1133
|
+
// poses through spine-core. The choice, and the reason for it, is returned
|
|
1134
|
+
// beside the result so the caller can say it (`render` prints it as its
|
|
1135
|
+
// `poser` line) — a render that fell back without saying so would be a
|
|
1136
|
+
// second opinion about the pose that nobody could see.
|
|
1137
|
+
|
|
1138
|
+
/** Which implementation of the seam posed a render: rigc's own core, or spine-core. */
|
|
1139
|
+
export type PoserName = 'core' | 'spine';
|
|
1140
|
+
|
|
1141
|
+
/** The `--poser` spellings, in the order the usage lists them. */
|
|
1142
|
+
export const POSER_NAMES: readonly PoserName[] = ['core', 'spine'];
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* A `--poser` the input cannot carry — `--poser core` on a Spine export, or on
|
|
1146
|
+
* a build whose document the core refuses. A class of its own so `cli.ts`
|
|
1147
|
+
* refuses it as a usage error (exit 2, nothing written).
|
|
1148
|
+
*/
|
|
1149
|
+
export class PoserChoiceError extends Error {}
|
|
1150
|
+
|
|
1151
|
+
/** Both posers for one input, and why the core one is or is not there. */
|
|
1152
|
+
export interface PoserChoice {
|
|
1153
|
+
/** What `--poser` asked for, or `undefined` for the input's own choice. */
|
|
1154
|
+
forced: PoserName | undefined;
|
|
1155
|
+
/** The core poser, or `null` when the input cannot carry it (`why`). */
|
|
1156
|
+
core: Poser | null;
|
|
1157
|
+
/** For a core poser, the document it poses; otherwise why there is none. */
|
|
1158
|
+
why: string;
|
|
1159
|
+
/**
|
|
1160
|
+
* spine-core's poser over the same skeleton — loaded the first time it is
|
|
1161
|
+
* read (issue #1014), which on a rigc build the core poses is a fallback the
|
|
1162
|
+
* poser line names, or never.
|
|
1163
|
+
*/
|
|
1164
|
+
readonly spine: Poser;
|
|
1165
|
+
/**
|
|
1166
|
+
* The skin roster behind `poser` (`SkinRoster`): the model document's for
|
|
1167
|
+
* the core poser (`coreSkinRoster`), the parsed skeleton's for spine-core's
|
|
1168
|
+
* (`skinRosterOf`) — so a render the core poses reads its framing's roster
|
|
1169
|
+
* without loading the runtime (issue #1014).
|
|
1170
|
+
*/
|
|
1171
|
+
rosterOf(poser: Poser): SkinRoster;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** What builds the core poser — `corePoser`, or a planted copy the suite passes (`RC02`, `CH01`). */
|
|
1175
|
+
export type MakeCorePoser = (modelText: string, atlasText: string, where: string, skeleton: { path: string; bytes: Uint8Array }) => Poser;
|
|
1176
|
+
|
|
1177
|
+
/** A skeleton's three rosters, as `rosterDifference` compares them. */
|
|
1178
|
+
interface SkeletonRosters {
|
|
1179
|
+
bones: ReadonlyArray<{ name: string; parent: string | null }>;
|
|
1180
|
+
slots: ReadonlyArray<{ name: string; bone: string }>;
|
|
1181
|
+
animations: ReadonlyArray<{ name: string; duration: number }>;
|
|
1182
|
+
/** Every skin name, in the skeleton's order — the roster `coreSkinRoster` names skins in. */
|
|
1183
|
+
skins: readonly string[];
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* The rosters off the skeleton's own JSON (issue #1014): its bones, slots and
|
|
1188
|
+
* skins as `skeletonFacts` reads them, and its animations' durations as
|
|
1189
|
+
* `skeletonDurations` derives them. Before #1014 these were spine-core's parse
|
|
1190
|
+
* of the same file; the two were measured equal on every input the tree
|
|
1191
|
+
* carries.
|
|
1192
|
+
*/
|
|
1193
|
+
function skeletonRosters(skeletonText: string): SkeletonRosters {
|
|
1194
|
+
const facts = skeletonFacts(skeletonText, null);
|
|
1195
|
+
return { bones: facts.bones, slots: facts.slots, animations: skeletonDurations(objectOf(JSON.parse(skeletonText))), skins: facts.skins };
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* The first way a model document's rosters differ from the Spine skeleton they
|
|
1200
|
+
* sit beside, or `null` — a document from another build would pose another
|
|
1201
|
+
* rig in the same files' name, so it is refused rather than drawn.
|
|
1202
|
+
*/
|
|
1203
|
+
function rosterDifference(core: Poser, skeleton: SkeletonRosters): string | null {
|
|
1204
|
+
const bones = (list: ReadonlyArray<{ name: string; parent: string | null }>): string => list.map((b) => `${b.name}<${b.parent ?? ''}`).join('|');
|
|
1205
|
+
const slots = (list: ReadonlyArray<{ name: string; bone: string }>): string => list.map((x) => `${x.name}@${x.bone}`).join('|');
|
|
1206
|
+
if (bones(core.bones) !== bones(skeleton.bones)) return 'the bones (names, parents or order) differ';
|
|
1207
|
+
if (slots(core.slots) !== slots(skeleton.slots)) return 'the slots (names, bones or draw order) differ';
|
|
1208
|
+
const animations = (list: ReadonlyArray<{ name: string; duration: number }>): string =>
|
|
1209
|
+
list.map((a) => `${a.name}=${a.duration}`).sort().join('|');
|
|
1210
|
+
if (animations(core.animations) !== animations(skeleton.animations)) return 'the animations (names or durations) differ';
|
|
1211
|
+
return null;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* A `PoserChoice` over a core poser (or none) and a skeleton spine-core loads
|
|
1216
|
+
* only when something reads its side — `spine`, or the roster behind it.
|
|
1217
|
+
*/
|
|
1218
|
+
function posersOver(
|
|
1219
|
+
forced: PoserName | undefined,
|
|
1220
|
+
core: { poser: Poser; roster: SkinRoster } | null,
|
|
1221
|
+
why: string,
|
|
1222
|
+
spineData: () => SkeletonData,
|
|
1223
|
+
): PoserChoice {
|
|
1224
|
+
let spine: Poser | null = null;
|
|
1225
|
+
let spineRoster: SkinRoster | null = null;
|
|
1226
|
+
return {
|
|
1227
|
+
forced,
|
|
1228
|
+
core: core === null ? null : core.poser,
|
|
1229
|
+
why,
|
|
1230
|
+
get spine(): Poser {
|
|
1231
|
+
spine ??= spinePoser(spineData());
|
|
1232
|
+
return spine;
|
|
1233
|
+
},
|
|
1234
|
+
rosterOf: (poser) => {
|
|
1235
|
+
if (core !== null && poser === core.poser) return core.roster;
|
|
1236
|
+
spineRoster ??= skinRosterOf(spineData());
|
|
1237
|
+
return spineRoster;
|
|
1238
|
+
},
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* The choice `candidatePosers` and `loadCandidate` share, refusing nothing:
|
|
1244
|
+
* the core poser when `skeleton.model.json` sits beside the skeleton, the
|
|
1245
|
+
* skeleton's bytes hash to the digest the document records (`spine.sha256`:
|
|
1246
|
+
* it is the file that build wrote, not one edited after it), the atlas is the
|
|
1247
|
+
* one beside it too, the core reads both and the document's rosters are the
|
|
1248
|
+
* skeleton's — read off the skeleton's own JSON (`skeletonRosters`), so
|
|
1249
|
+
* choosing loads nothing through spine-core (issue #1014).
|
|
1250
|
+
*/
|
|
1251
|
+
function choosePosers(
|
|
1252
|
+
skeletonPath: string,
|
|
1253
|
+
atlasPath: string,
|
|
1254
|
+
/** The atlas file's text, or `null` when there is no file at `atlasPath` (issue #1020). */
|
|
1255
|
+
atlasText: string | null,
|
|
1256
|
+
forced: PoserName | undefined,
|
|
1257
|
+
spineData: () => SkeletonData,
|
|
1258
|
+
make: MakeCorePoser,
|
|
1259
|
+
): { choice: PoserChoice; document: CoreDocumentFacts | null } {
|
|
1260
|
+
const dir = dirname(resolve(skeletonPath));
|
|
1261
|
+
const modelPath = join(dir, MODEL_DOCUMENT_FILE);
|
|
1262
|
+
let core: { poser: Poser; roster: SkinRoster } | null = null;
|
|
1263
|
+
let document: CoreDocumentFacts | null = null;
|
|
1264
|
+
let why: string;
|
|
1265
|
+
if (forced === 'spine') why = '--poser spine';
|
|
1266
|
+
else if (!existsSync(modelPath)) why = `no ${MODEL_DOCUMENT_FILE} beside ${resolve(skeletonPath)} — a Spine export, not a rigc build`;
|
|
1267
|
+
else if (dirname(resolve(atlasPath)) !== dir) {
|
|
1268
|
+
why =
|
|
1269
|
+
`the atlas ${resolve(atlasPath)} is not beside ${modelPath}: the document's region trims are its own build's ` +
|
|
1270
|
+
"atlas's, and the core would pose them against another one's pages";
|
|
1271
|
+
} else {
|
|
1272
|
+
try {
|
|
1273
|
+
const modelText = readFileSync(modelPath, 'utf8');
|
|
1274
|
+
const bytes = readFileSync(skeletonPath);
|
|
1275
|
+
// 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.
|
|
1276
|
+
const candidate = make(modelText, atlasText ?? '', modelPath, { path: resolve(skeletonPath), bytes });
|
|
1277
|
+
const rosters = skeletonRosters(bytes.toString('utf8'));
|
|
1278
|
+
const differs = rosterDifference(candidate, rosters);
|
|
1279
|
+
if (differs === null) {
|
|
1280
|
+
document = coreDocumentFacts(modelText, modelPath, rosters.skins);
|
|
1281
|
+
core = { poser: candidate, roster: document.roster };
|
|
1282
|
+
// 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.
|
|
1283
|
+
// And, since issue #1026, where the orders, the stage and the scale lines came from when the document does not state them: a /2 or /1 document's are read off the files beside it.
|
|
1284
|
+
const unstated = document.stated === null ? unstatedClause(resolve(skeletonPath), atlasText === null ? null : resolve(atlasPath)) : '';
|
|
1285
|
+
why =
|
|
1286
|
+
document.pageNames === null
|
|
1287
|
+
? `${modelPath} — a ${document.spec} document, which does not state where each region sits on its page: that is read from ${resolve(atlasPath)}; nor ${unstated}`
|
|
1288
|
+
: document.stated === null
|
|
1289
|
+
? `${modelPath} — a ${document.spec} document, which does not state ${unstated}`
|
|
1290
|
+
: modelPath;
|
|
1291
|
+
} else why = `${modelPath} does not describe ${resolve(skeletonPath)}: ${differs}`;
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
if (!(err instanceof CoreInputError)) throw err;
|
|
1294
|
+
// The reader names the document itself (`readModel`'s `where`), so a message that already starts with its path is not given it twice.
|
|
1295
|
+
why = `the core refused ${err.message.startsWith(`${modelPath}: `) ? err.message : `${modelPath}: ${err.message}`}`;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return { choice: posersOver(forced, core, why, spineData), document: core === null ? null : document };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* What a `rigc-compiled/2` or `/1` document does not state and a render or a
|
|
1303
|
+
* check reads off the files beside it instead (issue #1026), as the poser line
|
|
1304
|
+
* says it: the order the skeleton lists its skins and animations in and its
|
|
1305
|
+
* stage, off `skeleton`, and its pages' `scale:` lines, off `atlas` — or not
|
|
1306
|
+
* at all where no atlas is beside it (issue #1020).
|
|
1307
|
+
*/
|
|
1308
|
+
function unstatedClause(skeleton: string, atlas: string | null): string {
|
|
1309
|
+
return (
|
|
1310
|
+
`the order its skins and animations are listed in or its stage, read from ${skeleton}, ` +
|
|
1311
|
+
`or its pages' scale: lines, ${atlas === null ? 'not read — no atlas is beside it' : `read from ${atlas}`}`
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* `--poser core` on an input that cannot carry it, refused by name
|
|
1317
|
+
* (`PoserChoiceError`) — said by the caller where its refusals have always
|
|
1318
|
+
* stood, after the flags that refuse before it. Returns the choice otherwise.
|
|
1319
|
+
*/
|
|
1320
|
+
export function refuseUnchosen(choice: PoserChoice): PoserChoice {
|
|
1321
|
+
if (choice.forced === 'core' && choice.core === null) throw new PoserChoiceError(`--poser core: ${choice.why}`);
|
|
1322
|
+
return choice;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* `run` through the chosen poser: the core one when there is one, and
|
|
1327
|
+
* spine-core otherwise — or when the core refuses the input partway
|
|
1328
|
+
* (`CoreInputError`, e.g. a clip polygon that is not simple), in
|
|
1329
|
+
* which case `run` starts again from nothing on spine-core and `note` names the
|
|
1330
|
+
* refusal. Under `--poser core` that refusal is a `PoserChoiceError` instead.
|
|
1331
|
+
* `run` must write nothing: a fallback re-runs it whole. It is handed the
|
|
1332
|
+
* skin roster behind the poser it runs (`PoserChoice.rosterOf`), so a run
|
|
1333
|
+
* through the core reads nothing through spine-core (issue #1014).
|
|
1334
|
+
*/
|
|
1335
|
+
export function throughPoser<T>(choice: PoserChoice, run: (poser: Poser, roster: SkinRoster) => T): { value: T; poser: PoserName; note: string } {
|
|
1336
|
+
const through = (poser: Poser): T => run(poser, choice.rosterOf(poser));
|
|
1337
|
+
if (choice.core !== null) {
|
|
1338
|
+
try {
|
|
1339
|
+
return { value: through(choice.core), poser: 'core', note: `rigc core — ${choice.why}` };
|
|
1340
|
+
} catch (err) {
|
|
1341
|
+
if (!(err instanceof CoreInputError)) throw err;
|
|
1342
|
+
if (choice.forced === 'core') throw new PoserChoiceError(`--poser core: the core refused this input: ${err.message}`);
|
|
1343
|
+
return { value: through(choice.spine), poser: 'spine', note: `spine-core — the core refused this input: ${err.message}` };
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
return { value: through(choice.spine), poser: 'spine', note: `spine-core — ${choice.why}` };
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// ---------------------------------------------------------------------------
|
|
1350
|
+
// the geometry export — `render --geometry` (issue #864)
|
|
1351
|
+
// ---------------------------------------------------------------------------
|
|
1352
|
+
//
|
|
1353
|
+
// ⭐ A frame set is pixels, and two judgements a consumer that does not link
|
|
1354
|
+
// spine-core wants to make are not about pixels: how far a mesh triangle is
|
|
1355
|
+
// stretched over its rest shape, and whether a region holds still in its own
|
|
1356
|
+
// bone's frame. Both need the numbers the pose was drawn FROM. So `render` writes
|
|
1357
|
+
// them beside the pictures, off the very `Frame`s it drew — one call, one frame
|
|
1358
|
+
// grid, one viewport — rather than a second command re-deriving any of the three.
|
|
1359
|
+
|
|
1360
|
+
/** The export's file name inside an animation's frame directory, and its format tag. */
|
|
1361
|
+
export const GEOMETRY_FILE = 'geometry.json';
|
|
1362
|
+
export const GEOMETRY_SPEC = 'rigc-geometry/1';
|
|
1363
|
+
/** Stated in the file, so a reader cannot take the numbers for frame pixels. */
|
|
1364
|
+
export const GEOMETRY_COORDINATES = 'spine world, y up, world units';
|
|
1365
|
+
|
|
1366
|
+
/** A geometry file's frame: `Frame` reduced to the numbers the export promises. */
|
|
1367
|
+
export interface GeometryFrame {
|
|
1368
|
+
index: number;
|
|
1369
|
+
time: number;
|
|
1370
|
+
bones: GeometryBone[];
|
|
1371
|
+
attachments: AttachmentPose[];
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** One bone's world transform: `world = [a b; c d]·local + (worldX, worldY)`. */
|
|
1375
|
+
export interface GeometryBone {
|
|
1376
|
+
name: string;
|
|
1377
|
+
a: number;
|
|
1378
|
+
b: number;
|
|
1379
|
+
c: number;
|
|
1380
|
+
d: number;
|
|
1381
|
+
worldX: number;
|
|
1382
|
+
worldY: number;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* One attachment's rest geometry and its topology — the half of a stretch ratio
|
|
1387
|
+
* no frame carries.
|
|
1388
|
+
*
|
|
1389
|
+
* ⭐ **Rest is the setup pose's bones with no deform**, taken for every
|
|
1390
|
+
* attachment any frame of the file shows — including one the setup pose does not
|
|
1391
|
+
* show, which a slot only swaps to later. For an attachment the setup pose does
|
|
1392
|
+
* show, these vertices are the `setup` entry's own, bit for bit: both come off
|
|
1393
|
+
* one skeleton posed by `setupPosed`.
|
|
1394
|
+
*/
|
|
1395
|
+
export interface AttachmentRest {
|
|
1396
|
+
slot: string;
|
|
1397
|
+
attachment: string;
|
|
1398
|
+
kind: 'region' | 'mesh';
|
|
1399
|
+
vertices: number[];
|
|
1400
|
+
/** Vertex index triplets. A region's are the runtime's own `0 1 2 2 3 0`. */
|
|
1401
|
+
triangles: number[];
|
|
1402
|
+
/** A mesh's hull vertex count — the first `hull` vertices, as the format's `hull` field counts them. */
|
|
1403
|
+
hull?: number;
|
|
1404
|
+
/** A mesh's `uvs`, `u, v` per vertex over the untrimmed drawing, y down — the attachment's own, not a page's. */
|
|
1405
|
+
uvs?: number[];
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
export interface GeometryFile {
|
|
1409
|
+
spec: string;
|
|
1410
|
+
coordinates: string;
|
|
1411
|
+
/** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
|
|
1412
|
+
animation: string | null;
|
|
1413
|
+
skin?: string;
|
|
1414
|
+
fps: number;
|
|
1415
|
+
/** The box the PNG frames beside this file were drawn over — `frames.json`'s own `viewport`. */
|
|
1416
|
+
viewport: FramesSidecar['viewport'];
|
|
1417
|
+
/** Every bone in the skeleton's declaration order, and its parent's name. */
|
|
1418
|
+
bones: Array<{ name: string; parent: string | null }>;
|
|
1419
|
+
/**
|
|
1420
|
+
* Every slot in the skeleton's declaration order, and the bone it hangs from —
|
|
1421
|
+
* which is the bone whose frame "still in its own bone's frame" is read in.
|
|
1422
|
+
*/
|
|
1423
|
+
slots: Array<{ name: string; bone: string }>;
|
|
1424
|
+
rest: AttachmentRest[];
|
|
1425
|
+
/** The setup pose, sampled the way `sampleSetupPose` samples it. */
|
|
1426
|
+
setup: Omit<GeometryFrame, 'index' | 'time'>;
|
|
1427
|
+
frames: GeometryFrame[];
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* A pose holding a number that is not finite — refused by the bone or the vertex
|
|
1432
|
+
* that holds it, and the value, rather than drawn, framed or written.
|
|
1433
|
+
*
|
|
1434
|
+
* Two callers throw it, with one sentence between them (`nonFiniteSentence`):
|
|
1435
|
+
* the geometry export, because `JSON.stringify` writes `NaN` and `Infinity` as
|
|
1436
|
+
* `null` and a consumer would read a hole in the geometry as a vertex at
|
|
1437
|
+
* nothing; and `framingViewport`, because a box over an infinite vertex is no
|
|
1438
|
+
* box at all (issue #873). Before that second caller the framing answered `null`
|
|
1439
|
+
* for it, which `render` prints as "posed no drawable attachment" — true of a
|
|
1440
|
+
* skeleton that draws nothing and false of this one, whose attachment is there
|
|
1441
|
+
* and posed to a number no picture can hold.
|
|
1442
|
+
*/
|
|
1443
|
+
export class GeometryError extends Error {}
|
|
1444
|
+
|
|
1445
|
+
/** `frames.json`'s viewport block for `v` — the one spelling both files use. */
|
|
1446
|
+
export function sidecarViewport(v: Viewport): FramesSidecar['viewport'] {
|
|
1447
|
+
return {
|
|
1448
|
+
x: v.minX,
|
|
1449
|
+
y: v.minY,
|
|
1450
|
+
width: v.maxX - v.minX,
|
|
1451
|
+
height: v.maxY - v.minY,
|
|
1452
|
+
scale: v.scale,
|
|
1453
|
+
pixelWidth: v.width,
|
|
1454
|
+
pixelHeight: v.height,
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* The geometry file for one frame set — `frames` exactly as a sampler returned
|
|
1460
|
+
* them with `{ bones: true, geometry: true }`, which is what makes its grid the
|
|
1461
|
+
* frame set's own.
|
|
1462
|
+
*
|
|
1463
|
+
* Refused by `GeometryError`, naming the frame, the slot, the attachment and the
|
|
1464
|
+
* vertex (or the bone), when any number in it is not finite.
|
|
1465
|
+
*/
|
|
1466
|
+
export function geometryFileOf(
|
|
1467
|
+
source: PoseSource,
|
|
1468
|
+
animation: string | null,
|
|
1469
|
+
fps: number,
|
|
1470
|
+
frames: Frame[],
|
|
1471
|
+
viewport: Viewport,
|
|
1472
|
+
skin: string | undefined,
|
|
1473
|
+
): GeometryFile {
|
|
1474
|
+
const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
|
|
1475
|
+
if (frame.bones === undefined || frame.attachments === undefined) {
|
|
1476
|
+
throw new Error(`${where} was sampled without { bones: true, geometry: true }; the geometry export needs both`);
|
|
1477
|
+
}
|
|
1478
|
+
return {
|
|
1479
|
+
bones: frame.bones.map(({ name, a, b, c, d, worldX, worldY }) => ({ name, a, b, c, d, worldX, worldY })),
|
|
1480
|
+
attachments: frame.attachments,
|
|
1481
|
+
};
|
|
1482
|
+
};
|
|
1483
|
+
const posed = frames.map((frame) => ({
|
|
1484
|
+
index: frame.index,
|
|
1485
|
+
time: frame.time,
|
|
1486
|
+
...geometryFrame(frame, `frame ${frame.index}`),
|
|
1487
|
+
}));
|
|
1488
|
+
const poser = poserOf(source);
|
|
1489
|
+
const setupFrame = sampleSetupPose(poser, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true })[0];
|
|
1490
|
+
const setup = geometryFrame(setupFrame, 'the setup pose');
|
|
1491
|
+
const file: GeometryFile = {
|
|
1492
|
+
spec: GEOMETRY_SPEC,
|
|
1493
|
+
coordinates: GEOMETRY_COORDINATES,
|
|
1494
|
+
animation,
|
|
1495
|
+
...(skin === undefined ? {} : { skin }),
|
|
1496
|
+
fps,
|
|
1497
|
+
viewport: sidecarViewport(viewport),
|
|
1498
|
+
bones: poser.bones.map(({ name, parent }) => ({ name, parent })),
|
|
1499
|
+
slots: poser.slots.map(({ name, bone }) => ({ name, bone })),
|
|
1500
|
+
rest: poser.rest(skin, [setup.attachments, ...posed.map((frame) => frame.attachments)]),
|
|
1501
|
+
setup,
|
|
1502
|
+
frames: posed,
|
|
1503
|
+
};
|
|
1504
|
+
refuseNonFinite(file);
|
|
1505
|
+
return file;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* How a sampled frame is named in that sentence: the animation, the frame's
|
|
1510
|
+
* index at the rate it was sampled and its time — or the bare index for the one
|
|
1511
|
+
* frame of a skeleton with no animation, whose setup pose is checked first.
|
|
1512
|
+
*/
|
|
1513
|
+
function frameWhere(animation: string | null, index: number, time: number, fps: number): string {
|
|
1514
|
+
if (animation === null) return `frame ${index}`;
|
|
1515
|
+
return `animation ${JSON.stringify(animation)} frame ${index} at ${fps} fps (t=${time.toFixed(4)}s)`;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** One sampled frame's numbers, under the name the sentence gives it. */
|
|
1519
|
+
interface NamedPose {
|
|
1520
|
+
where: string;
|
|
1521
|
+
attachments: readonly PosedVertices[];
|
|
1522
|
+
bones: readonly WorldTransform[];
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* ⭐ **The one derivation of the non-finite sentence** (issue #873): the setup
|
|
1527
|
+
* pose, then every frame in order, then the rest table. The export and the
|
|
1528
|
+
* framing both reach it, so `render` and `render --geometry` refuse one planted
|
|
1529
|
+
* overflow in the same words.
|
|
1530
|
+
*
|
|
1531
|
+
* The setup pose first because a setup bone that overflowed is named there as
|
|
1532
|
+
* the BONE, and the rest table — the setup's bones with no deform — would name
|
|
1533
|
+
* the same fault one step on, as a vertex. Rest is still checked, last: it holds
|
|
1534
|
+
* attachments the setup pose does not show.
|
|
1535
|
+
*/
|
|
1536
|
+
function nonFiniteSentence(
|
|
1537
|
+
setup: Pick<NamedPose, 'attachments' | 'bones'>,
|
|
1538
|
+
frames: readonly NamedPose[],
|
|
1539
|
+
rest: readonly PosedVertices[],
|
|
1540
|
+
): string | null {
|
|
1541
|
+
const first = firstNonFinite('the setup pose', setup.attachments, setup.bones);
|
|
1542
|
+
if (first !== null) return first;
|
|
1543
|
+
for (const frame of frames) {
|
|
1544
|
+
const found = firstNonFinite(frame.where, frame.attachments, frame.bones);
|
|
1545
|
+
if (found !== null) return found;
|
|
1546
|
+
}
|
|
1547
|
+
return firstNonFinite('the rest table', rest, []);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/** Throw a `GeometryError` at the first number in `file` that is not finite, naming where it sits. */
|
|
1551
|
+
function refuseNonFinite(file: GeometryFile): void {
|
|
1552
|
+
const sentence = nonFiniteSentence(
|
|
1553
|
+
file.setup,
|
|
1554
|
+
file.frames.map((frame) => ({ ...frame, where: frameWhere(file.animation, frame.index, frame.time, file.fps) })),
|
|
1555
|
+
file.rest,
|
|
1556
|
+
);
|
|
1557
|
+
if (sentence !== null) throw new GeometryError(sentence);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* Where a skeleton's pose is not finite, as the sentence the geometry export
|
|
1562
|
+
* refuses on — or `null` when every bone and vertex of it is finite.
|
|
1563
|
+
*
|
|
1564
|
+
* Each set is sampled at its own rate, with its bones and whole attachments —
|
|
1565
|
+
* `animation: null` is the setup pose alone — so the frame it names is a frame of
|
|
1566
|
+
* the caller's own grid. It samples again rather than taking the caller's
|
|
1567
|
+
* frames, because a caller that only draws sampled no bones; it is run only
|
|
1568
|
+
* once the caller has found a number that is not finite, so a finite pose never
|
|
1569
|
+
* pays for it.
|
|
1570
|
+
*/
|
|
1571
|
+
export function nonFinitePoseOf(
|
|
1572
|
+
source: PoseSource,
|
|
1573
|
+
skin: string | undefined,
|
|
1574
|
+
sets: ReadonlyArray<{ animation: string | null; fps: number }>,
|
|
1575
|
+
): string | null {
|
|
1576
|
+
const poser = poserOf(source);
|
|
1577
|
+
const opts: PoseOptions = { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true };
|
|
1578
|
+
// Every attachment list the frames showed, for the rest table's roster.
|
|
1579
|
+
const shown: AttachmentPose[][] = [];
|
|
1580
|
+
const named = (frame: Frame, where: string): NamedPose => {
|
|
1581
|
+
if (frame.bones === undefined || frame.attachments === undefined) {
|
|
1582
|
+
throw new Error(`${where} was sampled without { bones: true, geometry: true }`);
|
|
1583
|
+
}
|
|
1584
|
+
shown.push(frame.attachments);
|
|
1585
|
+
return { where, attachments: frame.attachments, bones: frame.bones };
|
|
1586
|
+
};
|
|
1587
|
+
const setup = named(sampleSetupPose(poser, opts)[0], 'the setup pose');
|
|
1588
|
+
const frames = sets.flatMap(({ animation, fps }) =>
|
|
1589
|
+
animation === null
|
|
1590
|
+
? []
|
|
1591
|
+
: sampleAnimation(poser, animation, fps, opts).map((frame) =>
|
|
1592
|
+
named(frame, frameWhere(animation, frame.index, frame.time, fps)),
|
|
1593
|
+
),
|
|
1594
|
+
);
|
|
1595
|
+
// Not read unless the setup pose and every frame are finite: `restOf` poses
|
|
1596
|
+
// what they show, and would be posing the same overflow a third time.
|
|
1597
|
+
const found = nonFiniteSentence(setup, frames, []);
|
|
1598
|
+
if (found !== null) return found;
|
|
1599
|
+
return nonFiniteSentence({ attachments: [], bones: [] }, [], poser.rest(skin, shown));
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* The file's text: a JSON object whose header fields sit one per line and whose
|
|
1604
|
+
* `rest` entries and `frames` sit one per line each, so a diff of two exports
|
|
1605
|
+
* names the frame that moved.
|
|
1606
|
+
*
|
|
1607
|
+
* Numbers are `JSON.stringify`'s, which is how every JSON this tree writes prints
|
|
1608
|
+
* them — the shortest decimal that reads back as the same double, fixed by the
|
|
1609
|
+
* language rather than a locale — so a vertex in the file IS the runtime's
|
|
1610
|
+
* vertex, not a rounding of it.
|
|
1611
|
+
*/
|
|
1612
|
+
export function geometryText(file: GeometryFile): string {
|
|
1613
|
+
const lines: string[] = [];
|
|
1614
|
+
const entries = Object.entries(file);
|
|
1615
|
+
entries.forEach(([key, value], i) => {
|
|
1616
|
+
const comma = i + 1 < entries.length ? ',' : '';
|
|
1617
|
+
if ((key === 'rest' || key === 'frames') && Array.isArray(value)) {
|
|
1618
|
+
if (value.length === 0) {
|
|
1619
|
+
lines.push(` ${JSON.stringify(key)}: []${comma}`);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
lines.push(` ${JSON.stringify(key)}: [`);
|
|
1623
|
+
value.forEach((item, j) => lines.push(` ${JSON.stringify(item)}${j + 1 < value.length ? ',' : ''}`));
|
|
1624
|
+
lines.push(` ]${comma}`);
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)}${comma}`);
|
|
1628
|
+
});
|
|
1629
|
+
return `{\n${lines.join('\n')}\n}\n`;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* The name two atlases have to agree on for a substitution to find a region.
|
|
1634
|
+
*
|
|
1635
|
+
* Trimmed, because `TextureAtlas` names a region after the raw line it was read
|
|
1636
|
+
* from — so the same region in a file written with CRLF and one without would be
|
|
1637
|
+
* two different strings, and a substitution would report every region unmatched
|
|
1638
|
+
* for a reason that is invisible in both files. The index is folded in because a
|
|
1639
|
+
* sequence packs several regions under one name and `findRegion` returns only the
|
|
1640
|
+
* first of them.
|
|
1641
|
+
*/
|
|
1642
|
+
function regionKey(region: { name: string; index: number }): string {
|
|
1643
|
+
return `${region.name.trim()}#${region.index}`;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* Page names of a substituting atlas carry this prefix, so an own page and a
|
|
1648
|
+
* substituted page that happen to share a filename cannot be taken for each other.
|
|
1649
|
+
*/
|
|
1650
|
+
export const SUBSTITUTE_PAGE = 'texture-from:';
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* One region of a substituting atlas, as `substituteTexture` reads it: where
|
|
1654
|
+
* it sits on which page, and the mapping from the drawing's own coordinates
|
|
1655
|
+
* onto it (`MeshAttachment.computeUVs`'s job).
|
|
1656
|
+
*/
|
|
1657
|
+
export interface SubstituteRegion {
|
|
1658
|
+
page: { name: string; width: number; height: number };
|
|
1659
|
+
x: number;
|
|
1660
|
+
y: number;
|
|
1661
|
+
width: number;
|
|
1662
|
+
height: number;
|
|
1663
|
+
degrees: number;
|
|
1664
|
+
/** Art-space UVs (`u, v` per vertex) mapped onto this region of its page, in doubles. */
|
|
1665
|
+
pageUvs(art: readonly number[]): number[];
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/** An atlas whose texels can stand in for another's — see `substituteTexture`. */
|
|
1669
|
+
export interface TextureSubstitution {
|
|
1670
|
+
/** Prefixed page name → the page, ready to merge into a render's page map. */
|
|
1671
|
+
pages: Map<string, Plate>;
|
|
1672
|
+
/** The atlas's regions, by the key both sides agree on — see `regionKey`. */
|
|
1673
|
+
regions: Map<string, SubstituteRegion>;
|
|
1674
|
+
/** Every `scale:` the atlas text declares, in the order the pages declare them. */
|
|
1675
|
+
scales: number[];
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* Who reads a substituting atlas (issue #1020): `rigc` — `parseAtlasText` and
|
|
1680
|
+
* `./core/uvs.ts`'s `computeUvs`, for a candidate the core poses, so a rigc
|
|
1681
|
+
* build's `check --texture-from` reads nothing through spine-core — or
|
|
1682
|
+
* `spine`, the runtime's `TextureAtlas` and `MeshAttachment.computeUVs`, for
|
|
1683
|
+
* everything spine-core poses (an export keeps the reading it always had).
|
|
1684
|
+
*
|
|
1685
|
+
* The two were measured to agree: the region list (`TextureAtlas.regions` is
|
|
1686
|
+
* file order, and so is `parseAtlasText`'s, the pairing the selftest holds on
|
|
1687
|
+
* every corpus atlas) and the mapping (`computeUvs`, bit for bit in doubles
|
|
1688
|
+
* over 6,000 calls — its header). The PR of #1020 carries the substituted
|
|
1689
|
+
* frames, pixel for pixel, on the corpus rows that exercise it.
|
|
1690
|
+
*/
|
|
1691
|
+
export type SubstitutionReader = 'rigc' | 'spine';
|
|
1692
|
+
|
|
1693
|
+
/** Load an atlas and its pages as a substitution source, read by `reader` — see `SubstitutionReader`. */
|
|
1694
|
+
export function textureSubstitutionFromText(atlasText: string, atlasDir: string, reader: SubstitutionReader = 'spine'): TextureSubstitution {
|
|
1695
|
+
const regions = new Map<string, SubstituteRegion>();
|
|
1696
|
+
const names: string[] = [];
|
|
1697
|
+
if (reader === 'rigc') {
|
|
1698
|
+
for (const page of parseAtlasText(atlasText).pages) {
|
|
1699
|
+
names.push(page.name);
|
|
1700
|
+
const at = { name: page.name, width: page.width, height: page.height };
|
|
1701
|
+
for (const region of page.regions) {
|
|
1702
|
+
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) });
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
} else {
|
|
1706
|
+
// spine-core's `TextureAtlas` and `MeshAttachment.computeUVs`, through the seam (`spineSubstitution`, `./render.ts`).
|
|
1707
|
+
const read = spineSubstitution(atlasText);
|
|
1708
|
+
names.push(...read.pages);
|
|
1709
|
+
for (const [key, region] of read.regions) regions.set(key, region);
|
|
1710
|
+
}
|
|
1711
|
+
const pages = new Map<string, Plate>();
|
|
1712
|
+
for (const name of names) {
|
|
1713
|
+
if (name.startsWith(SUBSTITUTE_PAGE)) {
|
|
1714
|
+
throw new Error(`atlas page "${name}" starts with the reserved prefix ${JSON.stringify(SUBSTITUTE_PAGE)}`);
|
|
1715
|
+
}
|
|
1716
|
+
pages.set(SUBSTITUTE_PAGE + name, readPlate(join(atlasDir, name)));
|
|
1717
|
+
}
|
|
1718
|
+
return { pages, regions, scales: atlasScales(atlasText) };
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* The `scale:` values an atlas text declares, read off the text.
|
|
1723
|
+
*
|
|
1724
|
+
* ⚠️ Off the text, and reluctantly: `TextureAtlas` drops the field (its page
|
|
1725
|
+
* reader silently ignores every key it has no handler for), because `scale:` is
|
|
1726
|
+
* an instruction to whoever *imports* the pack — "the artwork was this much
|
|
1727
|
+
* bigger than these texels" — and a runtime has nothing to do with it. It is
|
|
1728
|
+
* nevertheless the one line that says a pack is coarser than the drawing it came
|
|
1729
|
+
* from, which is exactly the fact a reader of an MAE needs (issue #171), so it is
|
|
1730
|
+
* read here rather than left unreported.
|
|
1731
|
+
*
|
|
1732
|
+
* Narrow on purpose: an indented `scale:` line inside a page block, and nothing
|
|
1733
|
+
* else. It is not a second parser for the format and must not grow into one.
|
|
1734
|
+
* The line's pattern is `ATLAS_SCALE_LINE` (`src/model.ts`), which the model
|
|
1735
|
+
* document reads a page's own `scale:` with (issue #1026) — one pattern, so the
|
|
1736
|
+
* figure `check` reports off an atlas and off a document cannot drift apart.
|
|
1737
|
+
*/
|
|
1738
|
+
export function atlasScales(atlasText: string): number[] {
|
|
1739
|
+
const out: number[] = [];
|
|
1740
|
+
for (const line of atlasText.split(/\r\n|\r|\n/)) {
|
|
1741
|
+
const m = ATLAS_SCALE_LINE.exec(line);
|
|
1742
|
+
if (!m) continue;
|
|
1743
|
+
const value = Number(m[1]);
|
|
1744
|
+
if (Number.isFinite(value)) out.push(value);
|
|
1745
|
+
}
|
|
1746
|
+
return out;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/**
|
|
1750
|
+
* The page rectangle a region occupies, as UVs — the fence `substituteTexture`
|
|
1751
|
+
* puts around a substituted piece.
|
|
1752
|
+
*
|
|
1753
|
+
* ⚠️ Derived from `region.x/y/width/height` and its rotation rather than read off
|
|
1754
|
+
* `region.u2/v2`, and that is not fastidiousness: `TextureAtlas` transposes a
|
|
1755
|
+
* rotated region's rectangle when computing `u2/v2` **only at `degrees === 90`**,
|
|
1756
|
+
* so at 180 and 270 those two numbers describe a rectangle the page does not have.
|
|
1757
|
+
* (The same gap is why `RegionAttachment.computeUVs` draws a 270-packed region
|
|
1758
|
+
* wrong, which is what `--atlas` was measuring on rung 7 — issue #199.)
|
|
1759
|
+
* `region.u/v` are always `x/pageWidth, y/pageHeight` and are used as they are; the
|
|
1760
|
+
* size is `pageFootprint`'s, which is the region's own transposed for a quarter
|
|
1761
|
+
* turn — what the atlas format means by `bounds` on a rotated region. That
|
|
1762
|
+
* derivation was written out here, and in three other places that wanted the same
|
|
1763
|
+
* rectangle; two of them had it wrong at 270 (issue #579), so it is one function
|
|
1764
|
+
* now and this is one of its callers.
|
|
1765
|
+
*/
|
|
1766
|
+
function windowOf(region: SubstituteRegion): UvWindow {
|
|
1767
|
+
const rect = pageFootprint(region);
|
|
1768
|
+
const page = region.page;
|
|
1769
|
+
return {
|
|
1770
|
+
u0: region.x / page.width,
|
|
1771
|
+
v0: region.y / page.height,
|
|
1772
|
+
u1: (region.x + rect.width) / page.width,
|
|
1773
|
+
v1: (region.y + rect.height) / page.height,
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* The same posed frame, drawn from another atlas's **texels only**.
|
|
1779
|
+
*
|
|
1780
|
+
* ## 🔒 What is and is not substituted, and why that is the whole point
|
|
1781
|
+
*
|
|
1782
|
+
* `world` is copied across untouched — every vertex, both shapes — so the
|
|
1783
|
+
* substituted frame draws the candidate's own geometry and nothing else. Only
|
|
1784
|
+
* `page` and `uvs` change, and they change through the drawing's own coordinates
|
|
1785
|
+
* (`PieceTexture`), so the same point of the artwork lands at the same world
|
|
1786
|
+
* position on both sides. What is left between the two renders is a difference of
|
|
1787
|
+
* **texels**: the same shapes, in the same places, filtered from a different
|
|
1788
|
+
* source.
|
|
1789
|
+
*
|
|
1790
|
+
* That is what `rigc check --atlas <the frames' own atlas>` was being used for and
|
|
1791
|
+
* is not: pointing `--atlas` at another atlas re-loads the skeleton against it, and
|
|
1792
|
+
* a region attachment's quad is derived from the region rectangle, so a `rotate:`
|
|
1793
|
+
* or a trim in the substituting pack moves the geometry too. Measured on rung 7,
|
|
1794
|
+
* whose pack is `rotate: 270` and trimmed: that swap sends the reported MAE **up**
|
|
1795
|
+
* on every set, which a texture floor cannot do — a coarser texture can only
|
|
1796
|
+
* explain error, never add it (issue #199).
|
|
1797
|
+
*
|
|
1798
|
+
* ## The window
|
|
1799
|
+
*
|
|
1800
|
+
* A trimmed pack keeps only the drawing's opaque sub-rectangle, while the
|
|
1801
|
+
* candidate's own quad spans the whole drawing. Art-space coordinates outside what
|
|
1802
|
+
* the pack kept map to page texels **belonging to whatever was packed next door**,
|
|
1803
|
+
* so the substituted piece is fenced to its own rectangle (`UvWindow`) and draws
|
|
1804
|
+
* nothing outside it. That is the faithful answer rather than a convenience: a
|
|
1805
|
+
* packer trims only fully transparent border, so outside the rectangle the drawing
|
|
1806
|
+
* *is* empty.
|
|
1807
|
+
*/
|
|
1808
|
+
export function substituteTexture(
|
|
1809
|
+
frame: Frame,
|
|
1810
|
+
into: TextureSubstitution,
|
|
1811
|
+
): { frame: Frame; unmatched: string[] } {
|
|
1812
|
+
const unmatched: string[] = [];
|
|
1813
|
+
const pieces: Piece[] = [];
|
|
1814
|
+
for (const piece of frame.pieces) {
|
|
1815
|
+
const texture = piece.texture;
|
|
1816
|
+
const region = texture ? (into.regions.get(texture.region) ?? null) : null;
|
|
1817
|
+
if (!texture || !region) {
|
|
1818
|
+
unmatched.push(texture ? texture.region : piece.slot);
|
|
1819
|
+
pieces.push(piece);
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
// The mapping from the drawing's coordinates into a page's, which is where
|
|
1823
|
+
// `rotate:` (all four of them) and the trim offsets are handled: spine-core's
|
|
1824
|
+
// own `MeshAttachment.computeUVs`, or `./core/uvs.ts`'s `computeUvs`, measured
|
|
1825
|
+
// equal to it bit for bit — never a third opinion about the atlas format
|
|
1826
|
+
// (`SubstitutionReader`).
|
|
1827
|
+
const uvs = region.pageUvs(texture.artUvs);
|
|
1828
|
+
// A clipped piece's source map is re-seated the same way, through the drawing's own coordinates (`Mesh.source`).
|
|
1829
|
+
if (piece.kind === 'mesh' && piece.source !== undefined) {
|
|
1830
|
+
if (texture.sourceArtUvs === undefined) throw new Error(`slot "${piece.slot}": a clipped piece carries no original-art UVs for its source triangles`);
|
|
1831
|
+
const sourceUvs = region.pageUvs(texture.sourceArtUvs);
|
|
1832
|
+
pieces.push({ ...piece, page: SUBSTITUTE_PAGE + region.page.name, uvs, uvWindow: windowOf(region), source: { world: piece.source.world, uvs: sourceUvs } });
|
|
1833
|
+
continue;
|
|
1834
|
+
}
|
|
1835
|
+
pieces.push({ ...piece, page: SUBSTITUTE_PAGE + region.page.name, uvs, uvWindow: windowOf(region) });
|
|
1836
|
+
}
|
|
1837
|
+
return { frame: { ...frame, pieces }, unmatched };
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// ---------------------------------------------------------------------------
|
|
1841
|
+
// framing
|
|
1842
|
+
// ---------------------------------------------------------------------------
|
|
1843
|
+
|
|
1844
|
+
/**
|
|
1845
|
+
* The world-space box every posed vertex of these frames fits inside.
|
|
1846
|
+
*
|
|
1847
|
+
* `world.length` rather than a literal 8: a region contributes its four corners
|
|
1848
|
+
* and a mesh every one of its vertices, and the loop does not need to know which
|
|
1849
|
+
* it is holding.
|
|
1850
|
+
*/
|
|
1851
|
+
export function unionBounds(frameSets: Iterable<Frame[]>): { minX: number; minY: number; maxX: number; maxY: number } {
|
|
1852
|
+
let minX = Infinity;
|
|
1853
|
+
let minY = Infinity;
|
|
1854
|
+
let maxX = -Infinity;
|
|
1855
|
+
let maxY = -Infinity;
|
|
1856
|
+
for (const frames of frameSets) {
|
|
1857
|
+
for (const frame of frames) {
|
|
1858
|
+
for (const piece of frame.pieces) {
|
|
1859
|
+
for (let i = 0; i < piece.world.length; i += 2) {
|
|
1860
|
+
minX = Math.min(minX, piece.world[i]);
|
|
1861
|
+
maxX = Math.max(maxX, piece.world[i]);
|
|
1862
|
+
minY = Math.min(minY, piece.world[i + 1]);
|
|
1863
|
+
maxY = Math.max(maxY, piece.world[i + 1]);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return { minX, minY, maxX, maxY };
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* The opaque sub-rectangle of one quad's region, in the quad's own `(s, t)`.
|
|
1873
|
+
*
|
|
1874
|
+
* `(0,0)` is the region's bottom-left corner and `(1,1)` its top-right, so a trim
|
|
1875
|
+
* of `{0, 0, 1, 1}` is a region whose art fills it and anything smaller is the
|
|
1876
|
+
* transparent margin the art was exported with.
|
|
1877
|
+
*/
|
|
1878
|
+
export interface RegionTrim {
|
|
1879
|
+
minS: number;
|
|
1880
|
+
minT: number;
|
|
1881
|
+
maxS: number;
|
|
1882
|
+
maxT: number;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* Where a quad's artwork actually is, as opposed to where its rectangle is.
|
|
1887
|
+
*
|
|
1888
|
+
* ⭐ This is what stops an invisible margin from being able to move anything. A
|
|
1889
|
+
* region attachment's quad is the whole PNG, transparent border included, so two
|
|
1890
|
+
* exports of the same drawing with different margins pose to different quads and
|
|
1891
|
+
* frame themselves differently — which is how rung 5 reported MAE 39.00 for a rig
|
|
1892
|
+
* whose every key was right (issue #34). Trimming to the opaque texels makes the
|
|
1893
|
+
* box a property of the drawing.
|
|
1894
|
+
*
|
|
1895
|
+
* Alpha above zero rather than the rasteriser's coverage threshold, deliberately:
|
|
1896
|
+
* this is the box that has to CONTAIN the drawing, and a box that is a texel too
|
|
1897
|
+
* generous costs nothing while one that is a texel short clips.
|
|
1898
|
+
*
|
|
1899
|
+
* `cache` is keyed by page and region rectangle, because a scan per quad per frame
|
|
1900
|
+
* would be a scan per quad per frame.
|
|
1901
|
+
*/
|
|
1902
|
+
export function regionTrim(page: Plate, quad: Quad, cache: Map<string, RegionTrim | null>): RegionTrim | null {
|
|
1903
|
+
const [ubr, vbr, ubl, vbl, uul, vul] = [quad.uvs[0], quad.uvs[1], quad.uvs[2], quad.uvs[3], quad.uvs[4], quad.uvs[5]];
|
|
1904
|
+
const key = `${quad.page}|${ubr},${vbr},${ubl},${vbl},${uul},${vul}`;
|
|
1905
|
+
const seen = cache.get(key);
|
|
1906
|
+
if (seen !== undefined) return seen;
|
|
1907
|
+
|
|
1908
|
+
const ox = ubl * page.width;
|
|
1909
|
+
const oy = vbl * page.height;
|
|
1910
|
+
const ex = [(ubr - ubl) * page.width, (vbr - vbl) * page.height];
|
|
1911
|
+
const ey = [(uul - ubl) * page.width, (vul - vbl) * page.height];
|
|
1912
|
+
const det = ex[0] * ey[1] - ex[1] * ey[0];
|
|
1913
|
+
if (Math.abs(det) < 1e-9) {
|
|
1914
|
+
cache.set(key, null);
|
|
1915
|
+
return null;
|
|
1916
|
+
}
|
|
1917
|
+
const corners = [
|
|
1918
|
+
[ox, oy],
|
|
1919
|
+
[ox + ex[0], oy + ex[1]],
|
|
1920
|
+
[ox + ey[0], oy + ey[1]],
|
|
1921
|
+
[ox + ex[0] + ey[0], oy + ex[1] + ey[1]],
|
|
1922
|
+
];
|
|
1923
|
+
const x0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
|
|
1924
|
+
const x1 = Math.min(page.width - 1, Math.ceil(Math.max(...corners.map((c) => c[0]))));
|
|
1925
|
+
const y0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
|
|
1926
|
+
const y1 = Math.min(page.height - 1, Math.ceil(Math.max(...corners.map((c) => c[1]))));
|
|
1927
|
+
|
|
1928
|
+
let minS = Infinity;
|
|
1929
|
+
let minT = Infinity;
|
|
1930
|
+
let maxS = -Infinity;
|
|
1931
|
+
let maxT = -Infinity;
|
|
1932
|
+
for (let y = y0; y <= y1; y++) {
|
|
1933
|
+
for (let x = x0; x <= x1; x++) {
|
|
1934
|
+
if (page.get(x, y)[3] === 0) continue;
|
|
1935
|
+
const rx = x + 0.5 - ox;
|
|
1936
|
+
const ry = y + 0.5 - oy;
|
|
1937
|
+
const s = (rx * ey[1] - ry * ey[0]) / det;
|
|
1938
|
+
const t = (ex[0] * ry - ex[1] * rx) / det;
|
|
1939
|
+
if (s < 0 || s > 1 || t < 0 || t > 1) continue;
|
|
1940
|
+
if (s < minS) minS = s;
|
|
1941
|
+
if (s > maxS) maxS = s;
|
|
1942
|
+
if (t < minT) minT = t;
|
|
1943
|
+
if (t > maxT) maxT = t;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
const trim = Number.isFinite(minS) ? { minS, minT, maxS, maxT } : null;
|
|
1947
|
+
cache.set(key, trim);
|
|
1948
|
+
return trim;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
/**
|
|
1952
|
+
* The world box every piece's **artwork** fits inside, over these frames.
|
|
1953
|
+
*
|
|
1954
|
+
* The same union as `unionBounds`, taken over the trimmed rectangles instead of
|
|
1955
|
+
* the quads. It is a starting box for `check`'s framing and nothing more — the
|
|
1956
|
+
* framing itself is fitted on rendered pixels — but the start has to be free of
|
|
1957
|
+
* transparent margins too, or the path the fit takes still depends on them.
|
|
1958
|
+
*
|
|
1959
|
+
* ⚠️ **A mesh contributes its raw vertices and is not trimmed.** The trim exists
|
|
1960
|
+
* because a region attachment's quad is the whole PNG, transparent border and
|
|
1961
|
+
* all, so its corners sit where no pixel is. A mesh's hull is authored *onto the
|
|
1962
|
+
* drawing* — that is what makes it a mesh — so its vertices already are where the
|
|
1963
|
+
* artwork is, and there is no rectangle to invert a margin out of. Passing a
|
|
1964
|
+
* triangle fan through the rectangle trim would not be a better estimate of the
|
|
1965
|
+
* same box; it would be a different box, computed from a rectangle the mesh does
|
|
1966
|
+
* not have.
|
|
1967
|
+
*/
|
|
1968
|
+
export function trimmedUnionBounds(
|
|
1969
|
+
frameSets: Iterable<Frame[]>,
|
|
1970
|
+
pages: Map<string, Plate>,
|
|
1971
|
+
): { minX: number; minY: number; maxX: number; maxY: number } {
|
|
1972
|
+
const cache = new Map<string, RegionTrim | null>();
|
|
1973
|
+
let minX = Infinity;
|
|
1974
|
+
let minY = Infinity;
|
|
1975
|
+
let maxX = -Infinity;
|
|
1976
|
+
let maxY = -Infinity;
|
|
1977
|
+
const see = (x: number, y: number): void => {
|
|
1978
|
+
if (x < minX) minX = x;
|
|
1979
|
+
if (x > maxX) maxX = x;
|
|
1980
|
+
if (y < minY) minY = y;
|
|
1981
|
+
if (y > maxY) maxY = y;
|
|
1982
|
+
};
|
|
1983
|
+
for (const frames of frameSets) {
|
|
1984
|
+
for (const frame of frames) {
|
|
1985
|
+
for (const piece of frame.pieces) {
|
|
1986
|
+
if (piece.kind === 'mesh') {
|
|
1987
|
+
for (let i = 0; i < piece.world.length; i += 2) see(piece.world[i], piece.world[i + 1]);
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
const quad = piece;
|
|
1991
|
+
const [brx, bry, blx, bly, ulx, uly] = quad.world;
|
|
1992
|
+
const trim = regionTrim(pageFor(pages, quad), quad, cache);
|
|
1993
|
+
if (!trim) {
|
|
1994
|
+
for (let i = 0; i < 8; i += 2) see(quad.world[i], quad.world[i + 1]);
|
|
1995
|
+
continue;
|
|
1996
|
+
}
|
|
1997
|
+
const ex = [brx - blx, bry - bly];
|
|
1998
|
+
const ey = [ulx - blx, uly - bly];
|
|
1999
|
+
for (const [s, t] of [
|
|
2000
|
+
[trim.minS, trim.minT],
|
|
2001
|
+
[trim.maxS, trim.minT],
|
|
2002
|
+
[trim.minS, trim.maxT],
|
|
2003
|
+
[trim.maxS, trim.maxT],
|
|
2004
|
+
]) {
|
|
2005
|
+
see(blx + s * ex[0] + t * ey[0], bly + s * ex[1] + t * ey[1]);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
return { minX, minY, maxX, maxY };
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* A pose that drew vertices and cannot be framed, because every one of them sits
|
|
2015
|
+
* at one point (issue #997) — refused by the slots that drew, the bone each hangs
|
|
2016
|
+
* from and, where those bones are unposed, the skins that would pose them.
|
|
2017
|
+
*
|
|
2018
|
+
* A `GeometryError`, because it is the same family as the non-finite pose of
|
|
2019
|
+
* #873: a picture that has no size is as unwritable as a vertex at Infinity. It
|
|
2020
|
+
* is a class of its own because the reader's next step is different — there the
|
|
2021
|
+
* rig posed a number no picture can hold, here the invocation posed the rig under
|
|
2022
|
+
* a skin that leaves every drawn bone without a world transform, and `--skin` is
|
|
2023
|
+
* usually the whole fix — so `cli.ts` exits 2 on it, as it does on the other
|
|
2024
|
+
* framing refusal, *nothing to draw*.
|
|
2025
|
+
*
|
|
2026
|
+
* Before it the framing answered a viewport over the one point: `maxSide / 0` is
|
|
2027
|
+
* Infinity, `0 · Infinity` is NaN, and `render` wrote a 0×0 frame set with exit
|
|
2028
|
+
* 0, its summary saying `NaNxNaNpx` and `frames.json` a viewport of width 0 and
|
|
2029
|
+
* scale `null`.
|
|
2030
|
+
*/
|
|
2031
|
+
export class UnframeablePoseError extends GeometryError {}
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* Which bones a skin leaves unposed, and which skins there are — the rig
|
|
2035
|
+
* structure the unframeable sentence names a skin from (issue #997). Not a
|
|
2036
|
+
* poser's: the `Poser` seam carries no skin roster, and both posers pose the one
|
|
2037
|
+
* Spine file this is read from (the core's document is bound to it by hash).
|
|
2038
|
+
* Two readings stand behind it — the runtime's (`skinRosterOf`) and the model
|
|
2039
|
+
* document's (`coreSkinRoster` in `./render_core.ts`, issue #1014), measured
|
|
2040
|
+
* to name the same bones under every skin — and `PoserChoice.rosterOf` hands
|
|
2041
|
+
* each poser its own.
|
|
2042
|
+
*/
|
|
2043
|
+
export interface SkinRoster {
|
|
2044
|
+
/** Every skin, in declaration order. */
|
|
2045
|
+
readonly skins: readonly string[];
|
|
2046
|
+
/** The bones left unposed under `skin` (absent: no skin set) — inactive, or below an inactive bone. */
|
|
2047
|
+
unposedUnder(skin: string | undefined): Set<string>;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/** The roster behind a pose source: its own when it is Spine data, else the one the caller passed. */
|
|
2051
|
+
function rosterBehind(source: PoseSource, roster: SkinRoster | undefined): SkinRoster | undefined {
|
|
2052
|
+
return isPoser(source) ? roster : skinRosterOf(source);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/**
|
|
2056
|
+
* ⭐ **The one derivation of "a drawn slot on an unposed bone"** (issues #997,
|
|
2057
|
+
* #1000): the names of the slots of `slots` whose bone `roster` says `skin`
|
|
2058
|
+
* leaves unposed — inactive itself, or below an inactive bone. Both posers draw
|
|
2059
|
+
* such a slot through the zero matrix, so it draws no pixel and every vertex of
|
|
2060
|
+
* it sits at the origin. `framingViewport` takes these slots off the box
|
|
2061
|
+
* (#1000) and `unframeableSentence` refuses a pose that drew nothing else
|
|
2062
|
+
* (#997), from this one set.
|
|
2063
|
+
*
|
|
2064
|
+
* Empty without a roster: a bare `Poser` carries no skin structure, and what
|
|
2065
|
+
* cannot be read is not guessed — every slot then counts, as it did before.
|
|
2066
|
+
*/
|
|
2067
|
+
export function slotsOnUnposedBones(
|
|
2068
|
+
slots: ReadonlyArray<{ name: string; bone: string }>,
|
|
2069
|
+
skin: string | undefined,
|
|
2070
|
+
roster: SkinRoster | undefined,
|
|
2071
|
+
): Set<string> {
|
|
2072
|
+
if (roster === undefined) return new Set();
|
|
2073
|
+
const unposed = roster.unposedUnder(skin);
|
|
2074
|
+
return new Set(slots.filter((slot) => unposed.has(slot.bone)).map((slot) => slot.name));
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/** How many drawn slots a refusal names before it says how many more there are. */
|
|
2078
|
+
const UNFRAMEABLE_NAMED = 3;
|
|
2079
|
+
|
|
2080
|
+
/**
|
|
2081
|
+
* ⭐ **The one derivation of the unframeable sentence** (issue #997): `null`
|
|
2082
|
+
* when the vertices of `frameSets` span a box with extent, and otherwise the
|
|
2083
|
+
* sentence `UnframeablePoseError` carries. `framingViewport` and `check` both
|
|
2084
|
+
* reach it, so `render`, `render --geometry`, `check` and a library caller
|
|
2085
|
+
* refuse one rig in the same words.
|
|
2086
|
+
*
|
|
2087
|
+
* Two sentences, told apart by what the reader must change:
|
|
2088
|
+
*
|
|
2089
|
+
* - **every drawn slot hangs from a bone the skin leaves unposed** — inactive
|
|
2090
|
+
* itself or below an inactive bone (`unposedBones`). Both posers draw such a
|
|
2091
|
+
* slot through the zero matrix, so every vertex lands on the origin. Each
|
|
2092
|
+
* bone is named with the skins that pose it, read off the runtime's own
|
|
2093
|
+
* `active` flag under each skin rather than restating Spine's rule here.
|
|
2094
|
+
* - otherwise **every drawn vertex sits at one point** — the bones posed, and
|
|
2095
|
+
* collapsed their attachments (a world scale of 0 does).
|
|
2096
|
+
*
|
|
2097
|
+
* `roster` is the skin structure of the Spine file the poser poses
|
|
2098
|
+
* (`skinRosterOf`), which is what says whether a bone is unposed
|
|
2099
|
+
* (`slotsOnUnposedBones`, the set the framing box leaves out) and which skins
|
|
2100
|
+
* pose it. Without it the first sentence cannot be told from the second, and
|
|
2101
|
+
* the second is what is said.
|
|
2102
|
+
*
|
|
2103
|
+
* Distinct from *nothing to draw*: that is a skeleton that posed no vertex at
|
|
2104
|
+
* all, and its fix is art; this one posed vertices, and they have no place.
|
|
2105
|
+
*/
|
|
2106
|
+
export function unframeableSentence(
|
|
2107
|
+
frameSets: ReadonlyArray<readonly Frame[]>,
|
|
2108
|
+
slots: ReadonlyArray<{ name: string; bone: string }>,
|
|
2109
|
+
skin: string | undefined,
|
|
2110
|
+
roster: SkinRoster | undefined,
|
|
2111
|
+
): string | null {
|
|
2112
|
+
const box = unionBounds(frameSets.map((frames) => [...frames]));
|
|
2113
|
+
if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) return null;
|
|
2114
|
+
if (box.maxX - box.minX !== 0 || box.maxY - box.minY !== 0) return null;
|
|
2115
|
+
const drew = new Set<string>();
|
|
2116
|
+
for (const frames of frameSets) {
|
|
2117
|
+
for (const frame of frames) for (const piece of frame.pieces) if (piece.world.length > 0) drew.add(piece.slot);
|
|
2118
|
+
}
|
|
2119
|
+
// Declaration order, so the sentence does not depend on which frame drew first.
|
|
2120
|
+
const drawn = slots.filter((slot) => drew.has(slot.name));
|
|
2121
|
+
const under = skin === undefined ? 'under no skin' : `under skin ${JSON.stringify(skin)}`;
|
|
2122
|
+
const counted = `${drawn.length} drawn slot${drawn.length === 1 ? '' : 's'}`;
|
|
2123
|
+
const more = drawn.length > UNFRAMEABLE_NAMED ? `; and ${drawn.length - UNFRAMEABLE_NAMED} more` : '';
|
|
2124
|
+
const named = (describe: (slot: { name: string; bone: string }) => string): string =>
|
|
2125
|
+
drawn.slice(0, UNFRAMEABLE_NAMED).map(describe).join('; ') + more;
|
|
2126
|
+
if (roster !== undefined) {
|
|
2127
|
+
const offBox = slotsOnUnposedBones(slots, skin, roster);
|
|
2128
|
+
if (drawn.length > 0 && drawn.every((slot) => offBox.has(slot.name))) {
|
|
2129
|
+
const bySkin = roster.skins.map((name) => ({ name, unposed: roster.unposedUnder(name) }));
|
|
2130
|
+
const posers = (bone: string): string => {
|
|
2131
|
+
const names = bySkin.filter((k) => !k.unposed.has(bone)).map((k) => JSON.stringify(k.name));
|
|
2132
|
+
if (names.length === 0) return 'which no skin poses';
|
|
2133
|
+
return `which skin${names.length === 1 ? '' : 's'} ${names.join(', ')} pose${names.length === 1 ? 's' : ''}`;
|
|
2134
|
+
};
|
|
2135
|
+
return (
|
|
2136
|
+
`${under}, every drawn slot hangs from a bone that skin leaves unposed — ${counted}: ` +
|
|
2137
|
+
named((slot) => `slot ${JSON.stringify(slot.name)} on bone ${JSON.stringify(slot.bone)}, ${posers(slot.bone)}`) +
|
|
2138
|
+
' — so no drawn vertex has a world transform and the frame would be 0x0: pose it under a skin that poses ' +
|
|
2139
|
+
'those bones (`--skin`), or name the bones in the skin it is posed under'
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
return (
|
|
2144
|
+
`${under}, every drawn vertex sits at the one point (${box.minX}, ${box.minY}) — ${counted}: ` +
|
|
2145
|
+
named((slot) => `slot ${JSON.stringify(slot.name)} on bone ${JSON.stringify(slot.bone)}`) +
|
|
2146
|
+
' — so the posed box has no extent and the frame would be 0x0: the bones those slots hang from collapse ' +
|
|
2147
|
+
'every vertex onto it (a world scale of 0 does)'
|
|
2148
|
+
);
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
/** A box being widened over vertices: `minX`…`maxY`, empty at ±Infinity. */
|
|
2152
|
+
interface FramingBox {
|
|
2153
|
+
minX: number;
|
|
2154
|
+
minY: number;
|
|
2155
|
+
maxX: number;
|
|
2156
|
+
maxY: number;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/** `box` widened over the `[x, y, …]` pairs of `world` — `unionBounds`'s arithmetic, in its order of reading. */
|
|
2160
|
+
function extendBox(box: FramingBox, world: ArrayLike<number>): void {
|
|
2161
|
+
for (let i = 0; i < world.length; i += 2) {
|
|
2162
|
+
box.minX = Math.min(box.minX, world[i]);
|
|
2163
|
+
box.maxX = Math.max(box.maxX, world[i]);
|
|
2164
|
+
box.minY = Math.min(box.minY, world[i + 1]);
|
|
2165
|
+
box.maxY = Math.max(box.maxY, world[i + 1]);
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* How `framingViewport` is handed its frame sets: `sample` called for each of
|
|
2171
|
+
* `animations` (`null` the setup pose of a skeleton with none), the sets
|
|
2172
|
+
* yielded in that order. The framing reads each set whole before it asks for
|
|
2173
|
+
* the next.
|
|
2174
|
+
*/
|
|
2175
|
+
export type FramingSets = (sample: (animation: string | null) => Frame[], animations: ReadonlyArray<string | null>) => Iterable<Frame[]>;
|
|
2176
|
+
|
|
2177
|
+
/** Each set sampled only when the framing asks for it, so the one before it is no longer held (issue #1180). */
|
|
2178
|
+
export const ONE_SET_AT_A_TIME: FramingSets = function* (sample, animations) {
|
|
2179
|
+
for (const animation of animations) yield sample(animation);
|
|
2180
|
+
};
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* The viewport a skeleton is framed to: its union box at `FRAMING_FPS`, padded,
|
|
2184
|
+
* scaled so the long side is `maxSide` pixels.
|
|
2185
|
+
*
|
|
2186
|
+
* Measuring the box densely and once makes the framing a property of the SHOT,
|
|
2187
|
+
* so every rate of one skeleton lands on the same pixels.
|
|
2188
|
+
*
|
|
2189
|
+
* `null` means the skeleton posed no vertex at all — nothing to draw. A pose
|
|
2190
|
+
* holding a vertex at Infinity or NaN is refused by a `GeometryError` naming the
|
|
2191
|
+
* bone or vertex and its value (issue #873), never answered `null`. A pose whose
|
|
2192
|
+
* every vertex sits at one point is refused by an `UnframeablePoseError`
|
|
2193
|
+
* (`unframeableSentence`, issue #997), never framed at a scale of Infinity.
|
|
2194
|
+
*
|
|
2195
|
+
* The box is over the slots that POSE (issue #1000): a drawn slot whose bone
|
|
2196
|
+
* the skin leaves unposed (`slotsOnUnposedBones`) draws no pixel — both posers
|
|
2197
|
+
* put it through the zero matrix — so its vertices at the origin are not part
|
|
2198
|
+
* of the shot, and counting them framed every frame of a rig that carries one
|
|
2199
|
+
* to a point nothing is drawn at (256x94 where the posed slot alone gives
|
|
2200
|
+
* 256x55). When every drawn slot is such a slot there is no posed box at all,
|
|
2201
|
+
* and that is #997's refusal, never an empty or invented one.
|
|
2202
|
+
*
|
|
2203
|
+
* `roster` is the skin roster behind a `Poser` source (`skinRosterOf`) — what
|
|
2204
|
+
* says which bones the skin leaves unposed, and lets the refusal name the skins
|
|
2205
|
+
* that pose one. Spine data as the source is its own. A bare `Poser` with no
|
|
2206
|
+
* roster cannot tell an unposed bone from a posed one, so every drawn slot
|
|
2207
|
+
* counts there; every CLI caller passes the roster, so both posers frame alike.
|
|
2208
|
+
*
|
|
2209
|
+
* ⭐ One animation's frames at a time (issue #1180). The box is a minimum and
|
|
2210
|
+
* a maximum per axis, which no order of reading changes, so each animation's
|
|
2211
|
+
* frames are read into a box per slot and released before the next animation
|
|
2212
|
+
* is sampled; the box over the slots that pose is the union of theirs. Held
|
|
2213
|
+
* all at once — every animation at 60 fps — they were the largest single
|
|
2214
|
+
* holder of a render's heap: on the production rig whose core-poser render
|
|
2215
|
+
* peaked highest, 961 MiB retained at the framing's high-water under the core
|
|
2216
|
+
* poser and 293 MiB under spine-core's. `RC42` reads that no animation's
|
|
2217
|
+
* frames are read once the next one is sampled; `sets` is a plant's way in.
|
|
2218
|
+
*/
|
|
2219
|
+
export function framingViewport(
|
|
2220
|
+
source: PoseSource,
|
|
2221
|
+
maxSide: number,
|
|
2222
|
+
opts?: PoseOptions,
|
|
2223
|
+
rosterGiven?: SkinRoster,
|
|
2224
|
+
sets: FramingSets = ONE_SET_AT_A_TIME,
|
|
2225
|
+
): Viewport | null {
|
|
2226
|
+
const poser = poserOf(source);
|
|
2227
|
+
// The skin belongs here as much as in the frames: the union box is over the
|
|
2228
|
+
// attachments that POSE, and two skins fill a slot with art of different sizes
|
|
2229
|
+
// in different places. Framing one skin's shot with another skin's box would
|
|
2230
|
+
// put the difference between two skins into every measurement taken in it.
|
|
2231
|
+
//
|
|
2232
|
+
// ⭐ A slot subset is the opposite case, and is taken off (issue #835): what
|
|
2233
|
+
// `--slot`/`--hide` leave out still counts toward the box, so a frame with a
|
|
2234
|
+
// part hidden lands on the pixel grid of the frame with it and the two overlay.
|
|
2235
|
+
// A subset framed to its own extent would move every pixel it kept.
|
|
2236
|
+
//
|
|
2237
|
+
// A clip is taken off for the same reason (issue #844): what it removes still
|
|
2238
|
+
// counts toward the box, so adding or keying a mask moves no pixel it leaves
|
|
2239
|
+
// drawn — see `PoseOptions.unclipped`.
|
|
2240
|
+
// Neither the bone snapshots nor the geometry export frame anything, and
|
|
2241
|
+
// both would be taken at `FRAMING_FPS` for every animation only to be dropped.
|
|
2242
|
+
const { slots: _drawn, hidden: _hidden, bones: _bones, geometry: _geometry, ...whole } = opts ?? {};
|
|
2243
|
+
const framed: PoseOptions = { ...whole, unclipped: true };
|
|
2244
|
+
const sample = (animation: string | null): Frame[] =>
|
|
2245
|
+
animation === null ? sampleSetupPose(poser, framed) : sampleAnimation(poser, animation, FRAMING_FPS, framed);
|
|
2246
|
+
const animations: Array<string | null> = poser.animations.length === 0 ? [null] : poser.animations.map((a) => a.name);
|
|
2247
|
+
// Per slot, in the order a slot first drew: the box over its vertices and whether it drew one. Min and max select, so the
|
|
2248
|
+
// union over any grouping of the same vertices is the same four numbers — NaN and the sign of a zero included.
|
|
2249
|
+
const bySlot = new Map<string, { box: FramingBox; drew: boolean }>();
|
|
2250
|
+
for (const frames of sets(sample, animations)) {
|
|
2251
|
+
for (const frame of frames) {
|
|
2252
|
+
for (const piece of frame.pieces) {
|
|
2253
|
+
let slot = bySlot.get(piece.slot);
|
|
2254
|
+
if (slot === undefined) {
|
|
2255
|
+
slot = { box: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, drew: false };
|
|
2256
|
+
bySlot.set(piece.slot, slot);
|
|
2257
|
+
}
|
|
2258
|
+
if (piece.world.length > 0) slot.drew = true;
|
|
2259
|
+
extendBox(slot.box, piece.world);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
// ⚠️ Two different reasons a box is not finite, told apart here and nowhere
|
|
2264
|
+
// else (issue #873). A skeleton that posed no vertex at all has nothing to
|
|
2265
|
+
// draw, and that is `null`. One that posed a vertex at Infinity or NaN has a
|
|
2266
|
+
// drawable attachment in a place no box can hold — so it is refused by the
|
|
2267
|
+
// bone or vertex and its value, in the geometry export's own sentence. Before
|
|
2268
|
+
// this, the first case's `null` covered both, and a single overflowing bone
|
|
2269
|
+
// among finite ones reached neither: its box was finite on one side, and
|
|
2270
|
+
// `render` wrote a NaN-by-NaN frame set with exit 0.
|
|
2271
|
+
if (![...bySlot.values()].some((slot) => slot.drew)) return null;
|
|
2272
|
+
// ⭐ The box is over the slots that pose (issue #1000). A drawn slot on a bone
|
|
2273
|
+
// the skin leaves unposed is taken off — it draws no pixel — unless nothing
|
|
2274
|
+
// else drew, in which case every slot is kept so the pose reaches #997's
|
|
2275
|
+
// refusal below exactly as it did before this: the same inputs, the same
|
|
2276
|
+
// sentence, never an empty box framed to something.
|
|
2277
|
+
const roster = rosterBehind(source, rosterGiven);
|
|
2278
|
+
const offBox = slotsOnUnposedBones(poser.slots, framed.skin, roster);
|
|
2279
|
+
const posed = [...bySlot].filter(([name]) => !offBox.has(name));
|
|
2280
|
+
const posedOnly = posed.some(([, slot]) => slot.drew);
|
|
2281
|
+
const box: FramingBox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
|
2282
|
+
for (const [, slot] of posedOnly ? posed : [...bySlot]) {
|
|
2283
|
+
box.minX = Math.min(box.minX, slot.box.minX);
|
|
2284
|
+
box.maxX = Math.max(box.maxX, slot.box.maxX);
|
|
2285
|
+
box.minY = Math.min(box.minY, slot.box.minY);
|
|
2286
|
+
box.maxY = Math.max(box.maxY, slot.box.maxY);
|
|
2287
|
+
}
|
|
2288
|
+
if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) {
|
|
2289
|
+
const found = nonFinitePoseOf(
|
|
2290
|
+
poser,
|
|
2291
|
+
framed.skin,
|
|
2292
|
+
poser.animations.length === 0
|
|
2293
|
+
? [{ animation: null, fps: FRAMING_FPS }]
|
|
2294
|
+
: poser.animations.map((a) => ({ animation: a.name, fps: FRAMING_FPS })),
|
|
2295
|
+
);
|
|
2296
|
+
// Reaching here with nothing found would mean the pieces and the whole
|
|
2297
|
+
// attachments disagree about one pose — a defect here, said as one.
|
|
2298
|
+
if (found === null) {
|
|
2299
|
+
throw new Error(
|
|
2300
|
+
`the framing box is not finite (${box.minX}, ${box.minY}, ${box.maxX}, ${box.maxY}) and no bone or ` +
|
|
2301
|
+
'vertex of the pose is — the drawn pieces and the attachments were posed differently',
|
|
2302
|
+
);
|
|
2303
|
+
}
|
|
2304
|
+
throw new GeometryError(found);
|
|
2305
|
+
}
|
|
2306
|
+
// A finite box over one point (issue #997): every drawn slot on a bone the
|
|
2307
|
+
// skin leaves unposed, or every vertex collapsed. Framed, it is a scale of
|
|
2308
|
+
// Infinity and a frame of NaN by NaN pixels, written as 0x0 with exit 0.
|
|
2309
|
+
// Only a box with no extent can be refused, and only then are the frames
|
|
2310
|
+
// sampled again to be read whole — by the sentence's one derivation.
|
|
2311
|
+
if (box.maxX - box.minX === 0 && box.maxY - box.minY === 0) {
|
|
2312
|
+
const whole = animations.map(sample);
|
|
2313
|
+
const boxed = posedOnly ? whole.map((frames) => frames.map((frame) => ({ ...frame, pieces: frame.pieces.filter((piece) => !offBox.has(piece.slot)) }))) : whole;
|
|
2314
|
+
const unframeable = unframeableSentence(boxed, poser.slots, framed.skin, roster);
|
|
2315
|
+
if (unframeable !== null) throw new UnframeablePoseError(unframeable);
|
|
2316
|
+
}
|
|
2317
|
+
const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
|
|
2318
|
+
return viewportFor(box.minX - pad, box.minY - pad, box.maxX + pad, box.maxY + pad, maxSide);
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/** A viewport over an explicit world box, scaled so its long side is `maxSide`. */
|
|
2322
|
+
export function viewportFor(minX: number, minY: number, maxX: number, maxY: number, maxSide: number): Viewport {
|
|
2323
|
+
const scale = maxSide / Math.max(maxX - minX, maxY - minY);
|
|
2324
|
+
return {
|
|
2325
|
+
minX,
|
|
2326
|
+
minY,
|
|
2327
|
+
maxX,
|
|
2328
|
+
maxY,
|
|
2329
|
+
scale,
|
|
2330
|
+
width: Math.max(1, Math.round((maxX - minX) * scale)),
|
|
2331
|
+
height: Math.max(1, Math.round((maxY - minY) * scale)),
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
/**
|
|
2336
|
+
* A viewport over an explicit world box whose pixel size is already known.
|
|
2337
|
+
*
|
|
2338
|
+
* This is the shape `check` needs: the frames on disk fix the pixel size, and
|
|
2339
|
+
* re-deriving it from the box would round to a different integer and silently
|
|
2340
|
+
* shift every measurement by up to half a pixel.
|
|
2341
|
+
*/
|
|
2342
|
+
export function viewportOfSize(
|
|
2343
|
+
minX: number,
|
|
2344
|
+
minY: number,
|
|
2345
|
+
width: number,
|
|
2346
|
+
height: number,
|
|
2347
|
+
scale: number,
|
|
2348
|
+
pixelWidth: number,
|
|
2349
|
+
pixelHeight: number,
|
|
2350
|
+
): Viewport {
|
|
2351
|
+
return { minX, minY, maxX: minX + width, maxY: minY + height, scale, width: pixelWidth, height: pixelHeight };
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/** World (y up) to frame pixels (y down). The only place that conversion lives. */
|
|
2355
|
+
export function projector(v: Viewport): (wx: number, wy: number) => [number, number] {
|
|
2356
|
+
return (wx, wy) => [(wx - v.minX) * v.scale, (v.maxY - wy) * v.scale];
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
// ---------------------------------------------------------------------------
|
|
2360
|
+
// rasterising
|
|
2361
|
+
// ---------------------------------------------------------------------------
|
|
2362
|
+
|
|
2363
|
+
/**
|
|
2364
|
+
* One colour channel of one texel, tinted — the whole of what a slot's colours
|
|
2365
|
+
* do to a pixel.
|
|
2366
|
+
*
|
|
2367
|
+
* With no dark colour this is the multiply it always was, to the bit: `dark`
|
|
2368
|
+
* absent returns `sample * light` and nothing else, which is why every frame in
|
|
2369
|
+
* this repository renders byte for byte as it did before two-colour tinting
|
|
2370
|
+
* existed.
|
|
2371
|
+
*
|
|
2372
|
+
* With one, the light colour multiplies the texel and the dark colour fills in
|
|
2373
|
+
* what the texel leaves behind, so a black region can be tinted to any colour
|
|
2374
|
+
* while its bright parts keep the light tint. [official] — spine-ts's own
|
|
2375
|
+
* two-colour fragment shader, `spine-ts/spine-webgl/src/Shader.ts`
|
|
2376
|
+
* (`newTwoColoredTextured`), read at branch `4.3` of `EsotericSoftware/spine-runtimes`:
|
|
2377
|
+
*
|
|
2378
|
+
* gl_FragColor.a = texColor.a * v_light.a;
|
|
2379
|
+
* gl_FragColor.rgb = ((texColor.a - 1.0) * v_dark.a + 1.0 - texColor.rgb) * v_dark.rgb
|
|
2380
|
+
* + texColor.rgb * v_light.rgb;
|
|
2381
|
+
*
|
|
2382
|
+
* ⚠️ `v_dark.a` in that line is **not a colour channel** — it is the
|
|
2383
|
+
* premultiplied-alpha flag, which is why the dark colour is six hex digits in
|
|
2384
|
+
* the file and four bytes on the vertex. `SkeletonRendererCore` packs it as such:
|
|
2385
|
+
* `darkColor = 0xff000000 | …` on the `pma` branch and `darkColor = (r << 16) |
|
|
2386
|
+
* (g << 8) | b` — alpha byte **zero** — on the other. This rasteriser composites
|
|
2387
|
+
* **straight** alpha (see `premultiplied` below), so the flag is 0 and the shader
|
|
2388
|
+
* reduces to the two terms this function computes. Reading `dark.a` out of the
|
|
2389
|
+
* file here would be reading a flag as a colour.
|
|
2390
|
+
*
|
|
2391
|
+
* The clamp is on the dark path only, for the same reason: `sample * light` is
|
|
2392
|
+
* already inside the range whenever `light` is, and a clamp on that path would
|
|
2393
|
+
* be a change to pixels nothing asked to change.
|
|
2394
|
+
*/
|
|
2395
|
+
function tintChannel(sample: number, light: number, dark: number | undefined): number {
|
|
2396
|
+
if (dark === undefined) return sample * light;
|
|
2397
|
+
const mixed = sample * light + (255 - sample) * dark;
|
|
2398
|
+
return mixed < 0 ? 0 : mixed > 255 ? 255 : mixed;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
/**
|
|
2402
|
+
* Walk the destination pixels one affine quad covers, sampling the page.
|
|
2403
|
+
*
|
|
2404
|
+
* The quad is an affine image of the region's rectangle, so a destination pixel
|
|
2405
|
+
* maps back to a (s, t) inside it by inverting one 2x2 — no perspective divide,
|
|
2406
|
+
* no triangle split. `emit` is called for every covered pixel whose composited
|
|
2407
|
+
* alpha clears the coverage threshold, which is what makes "draw it" and
|
|
2408
|
+
* "measure where it landed" the same traversal rather than two that can drift.
|
|
2409
|
+
*/
|
|
2410
|
+
export function rasteriseQuad(
|
|
2411
|
+
page: Plate,
|
|
2412
|
+
quad: Quad,
|
|
2413
|
+
project: (wx: number, wy: number) => [number, number],
|
|
2414
|
+
clip: { width: number; height: number },
|
|
2415
|
+
emit: (px: number, py: number, r: number, g: number, b: number, a: number) => void,
|
|
2416
|
+
): void {
|
|
2417
|
+
// spine-core's region order is br, bl, ul, ur.
|
|
2418
|
+
const [brx, bry, blx, bly, ulx, uly] = quad.world;
|
|
2419
|
+
const bl = project(blx, bly);
|
|
2420
|
+
const br = project(brx, bry);
|
|
2421
|
+
const ul = project(ulx, uly);
|
|
2422
|
+
const ex = [br[0] - bl[0], br[1] - bl[1]];
|
|
2423
|
+
const ey = [ul[0] - bl[0], ul[1] - bl[1]];
|
|
2424
|
+
const det = ex[0] * ey[1] - ex[1] * ey[0];
|
|
2425
|
+
if (Math.abs(det) < 1e-9) return; // degenerate: zero scale, nothing to draw
|
|
2426
|
+
const [ubr, vbr, ubl, vbl, uul, vul] = [quad.uvs[0], quad.uvs[1], quad.uvs[2], quad.uvs[3], quad.uvs[4], quad.uvs[5]];
|
|
2427
|
+
|
|
2428
|
+
const corners = [bl, br, ul, [br[0] + ey[0], br[1] + ey[1]]];
|
|
2429
|
+
const minX = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
|
|
2430
|
+
const maxX = Math.min(clip.width - 1, Math.ceil(Math.max(...corners.map((c) => c[0]))));
|
|
2431
|
+
const minY = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
|
|
2432
|
+
const maxY = Math.min(clip.height - 1, Math.ceil(Math.max(...corners.map((c) => c[1]))));
|
|
2433
|
+
|
|
2434
|
+
for (let py = minY; py <= maxY; py++) {
|
|
2435
|
+
for (let px = minX; px <= maxX; px++) {
|
|
2436
|
+
const rx = px + 0.5 - bl[0];
|
|
2437
|
+
const ry = py + 0.5 - bl[1];
|
|
2438
|
+
const s = (rx * ey[1] - ry * ey[0]) / det;
|
|
2439
|
+
const t = (ex[0] * ry - ex[1] * rx) / det;
|
|
2440
|
+
if (s < 0 || s > 1 || t < 0 || t > 1) continue;
|
|
2441
|
+
const u = ubl + s * (ubr - ubl) + t * (uul - ubl);
|
|
2442
|
+
const v = vbl + s * (vbr - vbl) + t * (vul - vbl);
|
|
2443
|
+
if (outsideWindow(quad.uvWindow, u, v)) continue;
|
|
2444
|
+
const sample = bilinear(page, u * page.width - 0.5, v * page.height - 0.5);
|
|
2445
|
+
const alpha = sample[3] * quad.tint[3];
|
|
2446
|
+
if (alpha <= 0.5) continue;
|
|
2447
|
+
emit(
|
|
2448
|
+
px,
|
|
2449
|
+
py,
|
|
2450
|
+
Math.round(tintChannel(sample[0], quad.tint[0], quad.dark?.[0])),
|
|
2451
|
+
Math.round(tintChannel(sample[1], quad.tint[1], quad.dark?.[1])),
|
|
2452
|
+
Math.round(tintChannel(sample[2], quad.tint[2], quad.dark?.[2])),
|
|
2453
|
+
Math.round(alpha),
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
/** A destination pixel and the straight-alpha colour a piece put there. */
|
|
2460
|
+
export type EmitPixel = (px: number, py: number, r: number, g: number, b: number, a: number) => void;
|
|
2461
|
+
|
|
2462
|
+
/**
|
|
2463
|
+
* Slack on a `UvWindow`'s edges, in page UVs.
|
|
2464
|
+
*
|
|
2465
|
+
* The window's bounds *are* the region rectangle's own UVs, and a piece's
|
|
2466
|
+
* interpolated UV reaches them exactly at its edge — so the test has to admit
|
|
2467
|
+
* equality, and a bare `<` would drop a boundary pixel whenever the arithmetic
|
|
2468
|
+
* lands a bit under. A billionth of a page is far below a texel and far above the
|
|
2469
|
+
* error of two multiplies.
|
|
2470
|
+
*/
|
|
2471
|
+
const WINDOW_SLACK = 1e-9;
|
|
2472
|
+
|
|
2473
|
+
/**
|
|
2474
|
+
* Is this texel outside the rectangle its piece is allowed to sample?
|
|
2475
|
+
*
|
|
2476
|
+
* `undefined` is the ordinary case — a piece posed from its own atlas has no
|
|
2477
|
+
* window — and answers `false` without arithmetic, which keeps this off the cost
|
|
2478
|
+
* of every reference frame ever rendered.
|
|
2479
|
+
*/
|
|
2480
|
+
function outsideWindow(window: UvWindow | undefined, u: number, v: number): boolean {
|
|
2481
|
+
if (window === undefined) return false;
|
|
2482
|
+
return (
|
|
2483
|
+
u < window.u0 - WINDOW_SLACK ||
|
|
2484
|
+
u > window.u1 + WINDOW_SLACK ||
|
|
2485
|
+
v < window.v0 - WINDOW_SLACK ||
|
|
2486
|
+
v > window.v1 + WINDOW_SLACK
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/**
|
|
2491
|
+
* Is this edge a top or a left one, for the winding `rasteriseMesh` normalises to?
|
|
2492
|
+
*
|
|
2493
|
+
* Derived rather than copied, because the answer depends on the sign convention
|
|
2494
|
+
* of the edge function and the direction of y. With `edge(p) = dx·(py−y0) −
|
|
2495
|
+
* dy·(px−x0)` and y pointing **down**, the triangle `(0,0) → (1,0) → (0,1)` has
|
|
2496
|
+
* positive area, and its horizontal edge `(0,0) → (1,0)` — `dx > 0`, `dy = 0` —
|
|
2497
|
+
* is the one along its top. Its `(0,1) → (0,0)` edge — `dy < 0`, going up — is
|
|
2498
|
+
* the one down its left.
|
|
2499
|
+
*
|
|
2500
|
+
* What actually makes the rule watertight needs neither of those facts: the two
|
|
2501
|
+
* triangles sharing an edge traverse it in opposite directions, so `dy < 0` holds
|
|
2502
|
+
* for exactly one of them, and when `dy` is 0 for both, `dx > 0` holds for
|
|
2503
|
+
* exactly one. Every shared edge is therefore claimed once. Getting the
|
|
2504
|
+
* orientation right on top of that is what keeps the classic meaning — a pixel
|
|
2505
|
+
* centre on a boundary belongs to the triangle below-right of it.
|
|
2506
|
+
*/
|
|
2507
|
+
function isTopLeftEdge(dx: number, dy: number): boolean {
|
|
2508
|
+
return dy < 0 || (dy === 0 && dx > 0);
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
/**
|
|
2512
|
+
* Walk the destination pixels one posed mesh covers, sampling the page.
|
|
2513
|
+
*
|
|
2514
|
+
* Each triangle is filled independently with barycentric UV interpolation and no
|
|
2515
|
+
* perspective divide — a Spine mesh is a flat 2D deformation, so its UVs are
|
|
2516
|
+
* affine in screen space and there is no `w` to divide by. The winding is
|
|
2517
|
+
* normalised per triangle (a mesh's triangles are not guaranteed to agree, and a
|
|
2518
|
+
* bone with negative scale flips them all anyway), and the top-left rule then
|
|
2519
|
+
* makes every interior edge belong to exactly one of the two triangles that
|
|
2520
|
+
* share it.
|
|
2521
|
+
*
|
|
2522
|
+
* `emit` has the same contract as `rasteriseQuad`'s — every covered pixel whose
|
|
2523
|
+
* composited alpha clears the same 0.5 threshold — so "draw it" and "measure
|
|
2524
|
+
* where it landed" stay one traversal for meshes exactly as they are for regions.
|
|
2525
|
+
*/
|
|
2526
|
+
export function rasteriseMesh(
|
|
2527
|
+
page: Plate,
|
|
2528
|
+
mesh: Mesh,
|
|
2529
|
+
project: (wx: number, wy: number) => [number, number],
|
|
2530
|
+
clip: { width: number; height: number },
|
|
2531
|
+
emit: EmitPixel,
|
|
2532
|
+
): void {
|
|
2533
|
+
const count = mesh.world.length / 2;
|
|
2534
|
+
// Project once per vertex, not once per triangle: an interior vertex of a
|
|
2535
|
+
// 40-vertex hull belongs to half a dozen triangles, and projecting it six times
|
|
2536
|
+
// invites six answers the moment anything about `project` stops being exact.
|
|
2537
|
+
const px = new Float64Array(count);
|
|
2538
|
+
const py = new Float64Array(count);
|
|
2539
|
+
for (let i = 0; i < count; i++) {
|
|
2540
|
+
const [x, y] = project(mesh.world[i * 2], mesh.world[i * 2 + 1]);
|
|
2541
|
+
px[i] = x;
|
|
2542
|
+
py[i] = y;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
for (let t = 0; t + 2 < mesh.triangles.length; t += 3) {
|
|
2546
|
+
let i0 = mesh.triangles[t];
|
|
2547
|
+
let i1 = mesh.triangles[t + 1];
|
|
2548
|
+
const i2 = mesh.triangles[t + 2];
|
|
2549
|
+
let area = (px[i1] - px[i0]) * (py[i2] - py[i0]) - (py[i1] - py[i0]) * (px[i2] - px[i0]);
|
|
2550
|
+
if (area === 0) continue; // degenerate: a zero-height triangle covers nothing
|
|
2551
|
+
if (area < 0) {
|
|
2552
|
+
const swap = i0;
|
|
2553
|
+
i0 = i1;
|
|
2554
|
+
i1 = swap;
|
|
2555
|
+
area = -area;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
const x0 = px[i0];
|
|
2559
|
+
const y0 = py[i0];
|
|
2560
|
+
const x1 = px[i1];
|
|
2561
|
+
const y1 = py[i1];
|
|
2562
|
+
const x2 = px[i2];
|
|
2563
|
+
const y2 = py[i2];
|
|
2564
|
+
const minX = Math.max(0, Math.floor(Math.min(x0, x1, x2)));
|
|
2565
|
+
const maxX = Math.min(clip.width - 1, Math.ceil(Math.max(x0, x1, x2)));
|
|
2566
|
+
const minY = Math.max(0, Math.floor(Math.min(y0, y1, y2)));
|
|
2567
|
+
const maxY = Math.min(clip.height - 1, Math.ceil(Math.max(y0, y1, y2)));
|
|
2568
|
+
if (maxX < minX || maxY < minY) continue;
|
|
2569
|
+
|
|
2570
|
+
// Edge `k` is the one opposite vertex `k`, so its edge function IS the
|
|
2571
|
+
// unnormalised barycentric weight of that vertex.
|
|
2572
|
+
const topLeft0 = isTopLeftEdge(x2 - x1, y2 - y1);
|
|
2573
|
+
const topLeft1 = isTopLeftEdge(x0 - x2, y0 - y2);
|
|
2574
|
+
const topLeft2 = isTopLeftEdge(x1 - x0, y1 - y0);
|
|
2575
|
+
|
|
2576
|
+
const u0 = mesh.uvs[i0 * 2];
|
|
2577
|
+
const v0 = mesh.uvs[i0 * 2 + 1];
|
|
2578
|
+
const u1 = mesh.uvs[i1 * 2];
|
|
2579
|
+
const v1 = mesh.uvs[i1 * 2 + 1];
|
|
2580
|
+
const u2 = mesh.uvs[i2 * 2];
|
|
2581
|
+
const v2 = mesh.uvs[i2 * 2 + 1];
|
|
2582
|
+
|
|
2583
|
+
// A clipped piece (`Mesh.source`, issue #964): the UV of a pixel is the SOURCE triangle's affine map, in doubles from its own
|
|
2584
|
+
// projected corners and page UVs, so which convex pieces the clipper cut changes no pixel. Coverage is still this triangle's.
|
|
2585
|
+
const src = mesh.source === undefined ? null : sourceMap(mesh.source, t / 3, project);
|
|
2586
|
+
|
|
2587
|
+
for (let y = minY; y <= maxY; y++) {
|
|
2588
|
+
const sy = y + 0.5;
|
|
2589
|
+
for (let x = minX; x <= maxX; x++) {
|
|
2590
|
+
const sx = x + 0.5;
|
|
2591
|
+
const w0 = (x2 - x1) * (sy - y1) - (y2 - y1) * (sx - x1);
|
|
2592
|
+
if (topLeft0 ? w0 < 0 : w0 <= 0) continue;
|
|
2593
|
+
const w1 = (x0 - x2) * (sy - y2) - (y0 - y2) * (sx - x2);
|
|
2594
|
+
if (topLeft1 ? w1 < 0 : w1 <= 0) continue;
|
|
2595
|
+
const w2 = (x1 - x0) * (sy - y0) - (y1 - y0) * (sx - x0);
|
|
2596
|
+
if (topLeft2 ? w2 < 0 : w2 <= 0) continue;
|
|
2597
|
+
|
|
2598
|
+
let u: number;
|
|
2599
|
+
let v: number;
|
|
2600
|
+
if (src === null) {
|
|
2601
|
+
const b0 = w0 / area;
|
|
2602
|
+
const b1 = w1 / area;
|
|
2603
|
+
const b2 = w2 / area;
|
|
2604
|
+
u = b0 * u0 + b1 * u1 + b2 * u2;
|
|
2605
|
+
v = b0 * v0 + b1 * v1 + b2 * v2;
|
|
2606
|
+
} else [u, v] = src(sx, sy);
|
|
2607
|
+
if (outsideWindow(mesh.uvWindow, u, v)) continue;
|
|
2608
|
+
const sample = bilinear(page, u * page.width - 0.5, v * page.height - 0.5);
|
|
2609
|
+
const alpha = sample[3] * mesh.tint[3];
|
|
2610
|
+
if (alpha <= 0.5) continue;
|
|
2611
|
+
emit(
|
|
2612
|
+
x,
|
|
2613
|
+
y,
|
|
2614
|
+
Math.round(tintChannel(sample[0], mesh.tint[0], mesh.dark?.[0])),
|
|
2615
|
+
Math.round(tintChannel(sample[1], mesh.tint[1], mesh.dark?.[1])),
|
|
2616
|
+
Math.round(tintChannel(sample[2], mesh.tint[2], mesh.dark?.[2])),
|
|
2617
|
+
Math.round(alpha),
|
|
2618
|
+
);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
/**
|
|
2625
|
+
* The UV map of a clipped piece's drawn triangle `t`: its source triangle's
|
|
2626
|
+
* affine map (`Mesh.source`), from the three projected source corners and their
|
|
2627
|
+
* page UVs, in doubles — the barycentric weights of the pixel centre in the
|
|
2628
|
+
* projected source triangle times the corners' UVs. The same for every
|
|
2629
|
+
* decomposition of the source triangle, which is the point (issue #964).
|
|
2630
|
+
*/
|
|
2631
|
+
function sourceMap(source: ClipSource, t: number, project: (wx: number, wy: number) => [number, number]): (sx: number, sy: number) => [number, number] {
|
|
2632
|
+
const o = t * 6;
|
|
2633
|
+
const [ax, ay] = project(source.world[o], source.world[o + 1]);
|
|
2634
|
+
const [bx, by] = project(source.world[o + 2], source.world[o + 3]);
|
|
2635
|
+
const [cx, cy] = project(source.world[o + 4], source.world[o + 5]);
|
|
2636
|
+
const uv = source.uvs;
|
|
2637
|
+
const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
|
|
2638
|
+
return (sx, sy) => {
|
|
2639
|
+
const wa = ((cx - bx) * (sy - by) - (cy - by) * (sx - bx)) / area;
|
|
2640
|
+
const wb = ((ax - cx) * (sy - cy) - (ay - cy) * (sx - cx)) / area;
|
|
2641
|
+
const wc = ((bx - ax) * (sy - ay) - (by - ay) * (sx - ax)) / area;
|
|
2642
|
+
return [wa * uv[o] + wb * uv[o + 2] + wc * uv[o + 4], wa * uv[o + 1] + wb * uv[o + 3] + wc * uv[o + 5]];
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/**
|
|
2647
|
+
* Rasterise whichever shape this piece is.
|
|
2648
|
+
*
|
|
2649
|
+
* ⭐ Every caller that used to reach for `rasteriseQuad` goes through here, so
|
|
2650
|
+
* "what counts as a covered pixel" has one definition for both shapes — which is
|
|
2651
|
+
* what lets `frameGeometry`, the framing box and the drawn frame agree about a
|
|
2652
|
+
* mesh without any of them knowing what a triangle is.
|
|
2653
|
+
*/
|
|
2654
|
+
export function rasterisePiece(
|
|
2655
|
+
page: Plate,
|
|
2656
|
+
piece: Piece,
|
|
2657
|
+
project: (wx: number, wy: number) => [number, number],
|
|
2658
|
+
clip: { width: number; height: number },
|
|
2659
|
+
emit: EmitPixel,
|
|
2660
|
+
): void {
|
|
2661
|
+
if (piece.kind === 'mesh') rasteriseMesh(page, piece, project, clip, emit);
|
|
2662
|
+
else rasteriseQuad(page, piece, project, clip, emit);
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
/** Blit one piece onto the plate, source-over. */
|
|
2666
|
+
export function blitPiece(
|
|
2667
|
+
dst: Plate,
|
|
2668
|
+
page: Plate,
|
|
2669
|
+
piece: Piece,
|
|
2670
|
+
project: (wx: number, wy: number) => [number, number],
|
|
2671
|
+
): void {
|
|
2672
|
+
rasterisePiece(page, piece, project, dst, (px, py, r, g, b, a) => dst.blend(px, py, [r, g, b, a]));
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
/** The four texels one bilinear tap reads, and the fractions between them. */
|
|
2676
|
+
interface Taps {
|
|
2677
|
+
c00: RGBA;
|
|
2678
|
+
c10: RGBA;
|
|
2679
|
+
c01: RGBA;
|
|
2680
|
+
c11: RGBA;
|
|
2681
|
+
fx: number;
|
|
2682
|
+
fy: number;
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
/**
|
|
2686
|
+
* The four texels around `(x, y)`, CLAMPED at the page edge.
|
|
2687
|
+
*
|
|
2688
|
+
* ⚠️ The clamp is load-bearing beyond this function: `src/atlas.ts` sizes the
|
|
2689
|
+
* gutter between packed regions against the fact that one tap reaches exactly one
|
|
2690
|
+
* texel, and `gallery/portrait`'s lid runs its art flush to its own window
|
|
2691
|
+
* because a clamped tap has no transparent neighbour to reach into. Widening the
|
|
2692
|
+
* tap is not a local change.
|
|
2693
|
+
*/
|
|
2694
|
+
function taps(page: Plate, x: number, y: number): Taps {
|
|
2695
|
+
const x0 = Math.floor(x);
|
|
2696
|
+
const y0 = Math.floor(y);
|
|
2697
|
+
const at = (ix: number, iy: number): RGBA => {
|
|
2698
|
+
const cx = Math.max(0, Math.min(page.width - 1, ix));
|
|
2699
|
+
const cy = Math.max(0, Math.min(page.height - 1, iy));
|
|
2700
|
+
return page.get(cx, cy);
|
|
2701
|
+
};
|
|
2702
|
+
return { c00: at(x0, y0), c10: at(x0 + 1, y0), c01: at(x0, y0 + 1), c11: at(x0 + 1, y0 + 1), fx: x - x0, fy: y - y0 };
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
/** One channel of a bilinear tap: lerp along x on both rows, then between them. */
|
|
2706
|
+
function lerpTap(v00: number, v10: number, v01: number, v11: number, fx: number, fy: number): number {
|
|
2707
|
+
const top = v00 + (v10 - v00) * fx;
|
|
2708
|
+
const bottom = v01 + (v11 - v01) * fx;
|
|
2709
|
+
return top + (bottom - top) * fy;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
/**
|
|
2713
|
+
* Sample a straight-alpha page bilinearly — interpolating in PREMULTIPLIED space.
|
|
2714
|
+
*
|
|
2715
|
+
* ⭐ **Why the premultiply.** The source is straight alpha, so a transparent
|
|
2716
|
+
* texel beside the art is `(0, 0, 0, 0)`: its colour is not a colour, it is the
|
|
2717
|
+
* absence of one. Averaging R, G and B against it pulls the sample toward black
|
|
2718
|
+
* while alpha only drops part of the way, and the difference between those two
|
|
2719
|
+
* rates IS a dark rim, one pixel wide, drawn over whatever is behind the part.
|
|
2720
|
+
* Weighting each colour by its own alpha and dividing the sum back out gives the
|
|
2721
|
+
* transparent texel no vote in the colour, which is the whole of the fix: two
|
|
2722
|
+
* parts of one colour, overlapping, come out that colour. Measured before the
|
|
2723
|
+
* fix at −60/255 between two parts sharing one flat field, and −31/255 down
|
|
2724
|
+
* `gallery/portrait`'s forehead — issue #292.
|
|
2725
|
+
*
|
|
2726
|
+
* ⭐ **Why alpha is computed the old way, and why equal alpha short-circuits.**
|
|
2727
|
+
* `rasteriseQuad` and `rasteriseMesh` gate coverage on `alpha > 0.5`, so the
|
|
2728
|
+
* alpha arithmetic decides WHICH pixels are drawn — and through `frameGeometry`,
|
|
2729
|
+
* the framing box every reference frame was rendered inside. `lerpTap` on the
|
|
2730
|
+
* alpha channel is therefore the original expression, unchanged, not an
|
|
2731
|
+
* algebraically equal rearrangement: equal-but-rearranged is a last-bit
|
|
2732
|
+
* difference, and a last bit either side of 0.5 is a pixel.
|
|
2733
|
+
*
|
|
2734
|
+
* For the same reason the equal-alpha case returns early. When all four taps
|
|
2735
|
+
* carry one alpha, premultiplying by it and dividing it back out is the identity
|
|
2736
|
+
* — so the straight path is not an approximation there, it is the same number,
|
|
2737
|
+
* and taking it reproduces the five committed rungs BIT for bit rather than
|
|
2738
|
+
* merely closely. What moves is exactly the mixed-alpha tap: the edges, where the
|
|
2739
|
+
* rim was.
|
|
2740
|
+
*/
|
|
2741
|
+
export function bilinear(page: Plate, x: number, y: number): [number, number, number, number] {
|
|
2742
|
+
const { c00, c10, c01, c11, fx, fy } = taps(page, x, y);
|
|
2743
|
+
const a = lerpTap(c00[3], c10[3], c01[3], c11[3], fx, fy);
|
|
2744
|
+
if (c00[3] === c10[3] && c00[3] === c01[3] && c00[3] === c11[3]) {
|
|
2745
|
+
return [
|
|
2746
|
+
lerpTap(c00[0], c10[0], c01[0], c11[0], fx, fy),
|
|
2747
|
+
lerpTap(c00[1], c10[1], c01[1], c11[1], fx, fy),
|
|
2748
|
+
lerpTap(c00[2], c10[2], c01[2], c11[2], fx, fy),
|
|
2749
|
+
a,
|
|
2750
|
+
];
|
|
2751
|
+
}
|
|
2752
|
+
// Every tap is transparent in some proportion that sums to nothing: there is no
|
|
2753
|
+
// colour to recover and no pixel to draw (both callers gate on alpha anyway).
|
|
2754
|
+
if (a <= 0) return [0, 0, 0, 0];
|
|
2755
|
+
const out: [number, number, number, number] = [0, 0, 0, a];
|
|
2756
|
+
for (let c = 0; c < 3; c++) {
|
|
2757
|
+
const pm = lerpTap(c00[c] * c00[3], c10[c] * c10[3], c01[c] * c01[3], c11[c] * c11[3], fx, fy);
|
|
2758
|
+
// Bounded by 255 in exact arithmetic — the weighted mean of the taps' colours
|
|
2759
|
+
// cannot exceed their maximum — so the clamp absorbs float error only. It is
|
|
2760
|
+
// here rather than trusted because `Plate`'s store is a `Uint8Array`, which
|
|
2761
|
+
// WRAPS: 256 would land as a black pixel in the brightest part of the art.
|
|
2762
|
+
out[c] = Math.min(255, pm / a);
|
|
2763
|
+
}
|
|
2764
|
+
return out;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
/**
|
|
2768
|
+
* The same tap, each channel interpolated independently — the arithmetic
|
|
2769
|
+
* `bilinear` used until #292, kept as the CONTROL that fix is measured against.
|
|
2770
|
+
*
|
|
2771
|
+
* 🚫 **Nothing in `src/` calls this, and nothing in `src/` should.** It had one
|
|
2772
|
+
* production caller until #306: `src/pose.ts`'s `errBilinear`, on the argument
|
|
2773
|
+
* that `materialPlate`'s fourth channel is a material mask rather than opacity.
|
|
2774
|
+
* That argument was wrong in the direction that mattered — the mask is exactly
|
|
2775
|
+
* the weight the colour wanted, because a texel with no material carries the
|
|
2776
|
+
* background's colour and not the part's — so `errBilinear` now takes
|
|
2777
|
+
* `bilinear` too, and the only importer left is `selftest.ts`.
|
|
2778
|
+
*
|
|
2779
|
+
* ⭐ It lives here rather than in the suite so the control shares `taps` — the
|
|
2780
|
+
* edge clamp above — with the sampler it is a control for. A hand copy in the
|
|
2781
|
+
* test file would drift from it silently, and then `SM01` would be comparing the
|
|
2782
|
+
* fix against something that is not what the renderer used to do.
|
|
2783
|
+
* `SM08_NO_PRODUCTION_MODULE_READS_THE_STRAIGHT_TAP` is what keeps the first
|
|
2784
|
+
* paragraph true rather than merely written down.
|
|
2785
|
+
*/
|
|
2786
|
+
export function bilinearChannels(page: Plate, x: number, y: number): [number, number, number, number] {
|
|
2787
|
+
const { c00, c10, c01, c11, fx, fy } = taps(page, x, y);
|
|
2788
|
+
const out: [number, number, number, number] = [0, 0, 0, 0];
|
|
2789
|
+
for (let c = 0; c < 4; c++) out[c] = lerpTap(c00[c], c10[c], c01[c], c11[c], fx, fy);
|
|
2790
|
+
return out;
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
export function fill(plate: Plate, colour: RGBA): void {
|
|
2794
|
+
for (let y = 0; y < plate.height; y++) for (let x = 0; x < plate.width; x++) plate.set(x, y, colour);
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
/** Look a page up by name, with a failure that names what the atlas did declare. */
|
|
2798
|
+
export function pageFor(pages: Map<string, Plate>, piece: Piece): Plate {
|
|
2799
|
+
const page = pages.get(piece.page);
|
|
2800
|
+
if (!page) {
|
|
2801
|
+
throw new Error(
|
|
2802
|
+
`slot "${piece.slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`,
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
return page;
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
/** One frame, composited over `background`, at the viewport's pixel size. */
|
|
2809
|
+
export function renderFrame(frame: Frame, pages: Map<string, Plate>, viewport: Viewport, background: RGBA): Plate {
|
|
2810
|
+
const plate = new Plate(viewport.width, viewport.height);
|
|
2811
|
+
fill(plate, background);
|
|
2812
|
+
const project = projector(viewport);
|
|
2813
|
+
for (const piece of frame.pieces) blitPiece(plate, pageFor(pages, piece), piece, project);
|
|
2814
|
+
return plate;
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
/**
|
|
2818
|
+
* Every frame of one animation as one labelled grid, row major.
|
|
2819
|
+
*
|
|
2820
|
+
* Not decoration: rung 3's subject is *spacing* — how far a thing travels
|
|
2821
|
+
* between two consecutive frames — and that is a comparison across frames. A
|
|
2822
|
+
* reader flipping through 65 separate files is comparing against memory.
|
|
2823
|
+
*
|
|
2824
|
+
* ⭐ It lives here rather than beside either caller because the layout is a
|
|
2825
|
+
* CONTRACT: `bench/render_reference.ts` writes the grid, `rigc render` writes the
|
|
2826
|
+
* same grid for a user's own build, and `src/check.ts` reads a sheet's tiles back
|
|
2827
|
+
* out of it (issue #36). Three programs reading one geometry is one definition or
|
|
2828
|
+
* it is a bug waiting for the day two of them are edited apart.
|
|
2829
|
+
*/
|
|
2830
|
+
export function contactSheet(frames: Frame[], pages: Map<string, Plate>, viewport: Viewport, tile: number): Plate {
|
|
2831
|
+
const tileScale = tile / Math.max(viewport.width, viewport.height);
|
|
2832
|
+
const tileW = Math.max(1, Math.round(viewport.width * tileScale));
|
|
2833
|
+
const tileH = Math.max(1, Math.round(viewport.height * tileScale));
|
|
2834
|
+
const columns = Math.min(SHEET_COLUMNS, frames.length);
|
|
2835
|
+
const rows = Math.ceil(frames.length / columns);
|
|
2836
|
+
const sheet = new Plate(columns * (tileW + SHEET_GAP) + SHEET_GAP, rows * (tileH + SHEET_GAP) + SHEET_GAP);
|
|
2837
|
+
fill(sheet, SHEET_RULE);
|
|
2838
|
+
const base = projector(viewport);
|
|
2839
|
+
frames.forEach((frame, i) => {
|
|
2840
|
+
const col = i % columns;
|
|
2841
|
+
const row = Math.floor(i / columns);
|
|
2842
|
+
const ox = col * (tileW + SHEET_GAP) + SHEET_GAP;
|
|
2843
|
+
const oy = row * (tileH + SHEET_GAP) + SHEET_GAP;
|
|
2844
|
+
const plate = new Plate(tileW, tileH);
|
|
2845
|
+
fill(plate, BACKGROUND);
|
|
2846
|
+
const project = (wx: number, wy: number): [number, number] => {
|
|
2847
|
+
const [px, py] = base(wx, wy);
|
|
2848
|
+
return [px * tileScale, py * tileScale];
|
|
2849
|
+
};
|
|
2850
|
+
for (const piece of frame.pieces) blitPiece(plate, pageFor(pages, piece), piece, project);
|
|
2851
|
+
plate.text(String(i), 2, 2, 1, SHEET_LABEL);
|
|
2852
|
+
for (let y = 0; y < tileH; y++) for (let x = 0; x < tileW; x++) sheet.set(ox + x, oy + y, plate.get(x, y));
|
|
2853
|
+
});
|
|
2854
|
+
return sheet;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
/** Where one thing landed in a frame, in frame pixels. */
|
|
2858
|
+
export interface Footprint {
|
|
2859
|
+
/** Alpha-weighted count of covered pixels. 0 means nothing was drawn. */
|
|
2860
|
+
pixels: number;
|
|
2861
|
+
cx: number;
|
|
2862
|
+
cy: number;
|
|
2863
|
+
minX: number;
|
|
2864
|
+
minY: number;
|
|
2865
|
+
maxX: number;
|
|
2866
|
+
maxY: number;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
export const EMPTY_FOOTPRINT: Footprint = { pixels: 0, cx: 0, cy: 0, minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
2870
|
+
|
|
2871
|
+
/** Where a frame's pixels went: the coverage mask, and each slot's own footprint. */
|
|
2872
|
+
export interface FrameGeometry {
|
|
2873
|
+
/** 1 where any piece drew, in `viewport.width * viewport.height` row-major order. */
|
|
2874
|
+
coverage: Uint8Array;
|
|
2875
|
+
footprints: Map<string, Footprint>;
|
|
2876
|
+
/**
|
|
2877
|
+
* Which owner drew each pixel last, or `-1` — `null` unless `owners` was given.
|
|
2878
|
+
*
|
|
2879
|
+
* "Last" is the composite's own rule: pieces arrive in draw order, so the owner
|
|
2880
|
+
* left in a pixel is the one you would see there. That is deliberately the
|
|
2881
|
+
* opposite of `footprints`, which measures each slot on its own pixels
|
|
2882
|
+
* *ignoring* what covers it — a footprint answers "where is this part", and
|
|
2883
|
+
* this mask answers "whose part is this pixel", and only the second one can be
|
|
2884
|
+
* a partition.
|
|
2885
|
+
*/
|
|
2886
|
+
owner: Int32Array | null;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
/**
|
|
2890
|
+
* Rasterise one frame for measurement rather than for looking at: which pixels
|
|
2891
|
+
* it covers, and where each slot landed.
|
|
2892
|
+
*
|
|
2893
|
+
* ⚠️ A slot's footprint is measured on the pixels **that slot draws**, ignoring
|
|
2894
|
+
* what is drawn over it. That is deliberate. A slot hidden behind another still
|
|
2895
|
+
* has a position, and it is the position the rig gives it; measuring it on the
|
|
2896
|
+
* composite would report the occluder's geometry instead and call the rig wrong
|
|
2897
|
+
* for being covered up. What the composite costs is on the reference side, where
|
|
2898
|
+
* an occluded part merges into its occluder's component — and that is what the
|
|
2899
|
+
* matcher reports as ambiguity rather than as drift.
|
|
2900
|
+
*/
|
|
2901
|
+
export function frameGeometry(
|
|
2902
|
+
frame: Frame,
|
|
2903
|
+
pages: Map<string, Plate>,
|
|
2904
|
+
viewport: Viewport,
|
|
2905
|
+
/** Slot name → owner id, when the caller also wants the per-pixel owner mask. */
|
|
2906
|
+
owners?: Map<string, number>,
|
|
2907
|
+
): FrameGeometry {
|
|
2908
|
+
const coverage = new Uint8Array(viewport.width * viewport.height);
|
|
2909
|
+
const owner = owners === undefined ? null : new Int32Array(viewport.width * viewport.height).fill(-1);
|
|
2910
|
+
const footprints = new Map<string, Footprint>();
|
|
2911
|
+
const project = projector(viewport);
|
|
2912
|
+
for (const piece of frame.pieces) {
|
|
2913
|
+
const owned = owners === undefined ? -1 : (owners.get(piece.slot) ?? -1);
|
|
2914
|
+
let weight = 0;
|
|
2915
|
+
let sx = 0;
|
|
2916
|
+
let sy = 0;
|
|
2917
|
+
let minX = Infinity;
|
|
2918
|
+
let minY = Infinity;
|
|
2919
|
+
let maxX = -Infinity;
|
|
2920
|
+
let maxY = -Infinity;
|
|
2921
|
+
rasterisePiece(pageFor(pages, piece), piece, project, viewport, (px, py, _r, _g, _b, a) => {
|
|
2922
|
+
coverage[py * viewport.width + px] = 1;
|
|
2923
|
+
if (owner !== null && owned >= 0) owner[py * viewport.width + px] = owned;
|
|
2924
|
+
const w = a / 255;
|
|
2925
|
+
weight += w;
|
|
2926
|
+
sx += (px + 0.5) * w;
|
|
2927
|
+
sy += (py + 0.5) * w;
|
|
2928
|
+
if (px < minX) minX = px;
|
|
2929
|
+
if (px > maxX) maxX = px;
|
|
2930
|
+
if (py < minY) minY = py;
|
|
2931
|
+
if (py > maxY) maxY = py;
|
|
2932
|
+
});
|
|
2933
|
+
const previous = footprints.get(piece.slot);
|
|
2934
|
+
const here: Footprint =
|
|
2935
|
+
weight === 0
|
|
2936
|
+
? EMPTY_FOOTPRINT
|
|
2937
|
+
: { pixels: weight, cx: sx / weight, cy: sy / weight, minX, minY, maxX: maxX + 1, maxY: maxY + 1 };
|
|
2938
|
+
// A slot shows one attachment at a time, so this only merges when a caller
|
|
2939
|
+
// hands us a frame with two pieces on one slot; merging is still the honest
|
|
2940
|
+
// answer, and it keeps the map keyed by slot the way the report reads it.
|
|
2941
|
+
footprints.set(piece.slot, previous && previous.pixels > 0 ? mergeFootprints(previous, here) : here);
|
|
2942
|
+
}
|
|
2943
|
+
return { coverage, footprints, owner };
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
function mergeFootprints(a: Footprint, b: Footprint): Footprint {
|
|
2947
|
+
if (b.pixels === 0) return a;
|
|
2948
|
+
const pixels = a.pixels + b.pixels;
|
|
2949
|
+
return {
|
|
2950
|
+
pixels,
|
|
2951
|
+
cx: (a.cx * a.pixels + b.cx * b.pixels) / pixels,
|
|
2952
|
+
cy: (a.cy * a.pixels + b.cy * b.pixels) / pixels,
|
|
2953
|
+
minX: Math.min(a.minX, b.minX),
|
|
2954
|
+
minY: Math.min(a.minY, b.minY),
|
|
2955
|
+
maxX: Math.max(a.maxX, b.maxX),
|
|
2956
|
+
maxY: Math.max(a.maxY, b.maxY),
|
|
2957
|
+
};
|
|
2958
|
+
}
|