@rig-c/parts 0.0.0-stage → 1.5.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/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +433 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +150 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- package/workflows/seethrough.json +68 -0
package/src/scene.ts
ADDED
|
@@ -0,0 +1,1414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `compose` (issue #74): several finished single-character builds bound into
|
|
3
|
+
* one rig, with an optional background plate and a declared draw order
|
|
4
|
+
* between the characters' slots.
|
|
5
|
+
*
|
|
6
|
+
* scene.json (spine-parts-scene/1) + each character's green `build` --out
|
|
7
|
+
* -> rig/{rig.json, motion.json, images/}, scene.json, check/
|
|
8
|
+
*
|
|
9
|
+
* ⭐ **The definitions are #74's, settled before this was written** (its
|
|
10
|
+
* comment "Settled before implementation"): the scene file, what refuses, how
|
|
11
|
+
* the result is gated, and what does not move. Nothing here infers an order,
|
|
12
|
+
* an overlap, a scale or a person; the order is the author's, and the plate's
|
|
13
|
+
* provenance is recorded and judged by nothing.
|
|
14
|
+
*
|
|
15
|
+
* What is composed, from each character's `rig/rig.json`, `rig/motion.json`,
|
|
16
|
+
* `rig/images/`, `parts.json` and `check/check.json`:
|
|
17
|
+
*
|
|
18
|
+
* - **Names, totally.** Every bone, slot, attachment (skin entry), constraint
|
|
19
|
+
* (its name and every bone or slot it names), `invariants.detached` bone,
|
|
20
|
+
* track, group and easing of character `<id>` is renamed `<id>:<name>`
|
|
21
|
+
* ({@link prefixed}); `root` is the one shared root. A name resolves to
|
|
22
|
+
* exactly one character, and nothing resolves across characters without its
|
|
23
|
+
* prefix: a constraint naming a bone its own build does not declare is
|
|
24
|
+
* refused (`SCENE_NAME_PREFIXED` when another character has it,
|
|
25
|
+
* `SCENE_NAME_RESOLVES` when none does).
|
|
26
|
+
* - **Images are the one name that cannot carry `:`.** rig-c names an
|
|
27
|
+
* atlas region by its PNG's basename, and an atlas region line holding a
|
|
28
|
+
* colon is read as a `key: value` line — measured through rig-c 2.15.0
|
|
29
|
+
* on the demo example renamed `demo:<name>` with images `demo:<file>`:
|
|
30
|
+
* `A07_ATLAS_TEXT_SHAPE` fails once per region, while the same rig with
|
|
31
|
+
* images under a `demo/` folder gates green (and draws the page byte for
|
|
32
|
+
* byte) but keeps the bare basename as the region, so two characters' `face`
|
|
33
|
+
* would collide. So an image is `<id>.<file>` ({@link imageName}); a
|
|
34
|
+
* composed image name that two files would take is refused
|
|
35
|
+
* (`SCENE_IMAGE_NAME_FREE`).
|
|
36
|
+
* - **The offset, once.** A character's `offset` places its rig px on the
|
|
37
|
+
* canvas by a translation (its rig px are the canvas's px at scale 1, so
|
|
38
|
+
* every character must share one `rig_scale`). The translation is applied
|
|
39
|
+
* to what the character places in `root`'s own frame — its first-level
|
|
40
|
+
* bones (the bones whose parent is `root`), and a region on a slot `root`
|
|
41
|
+
* carries or a mesh weight bound to `root` (both examples' `shoes` ride
|
|
42
|
+
* `root`) — and nowhere else: every other offset, weight bind and region
|
|
43
|
+
* is local to a bone the shift already moved. In Spine's axes it is the
|
|
44
|
+
* character's stage corner carried to the canvas's ({@link rootShift}),
|
|
45
|
+
* through `src/coords.ts`. The root is shared, so a character whose idle
|
|
46
|
+
* keys it, or whose constraint names it, is refused (`SCENE_ROOT_SHARED`):
|
|
47
|
+
* it would move every character.
|
|
48
|
+
* - **The scale, with the offset (issue #197).** `characters[].scale`, a
|
|
49
|
+
* positive number and 1 when absent, sizes the character about its stage's
|
|
50
|
+
* top-left corner: a point p of its root frame lands at `scale * p + shift`
|
|
51
|
+
* ({@link rootShift}). Every length it carries is scaled exactly where
|
|
52
|
+
* Spine's own loader multiplies by its `scale` (`SkeletonJson.js`): bone
|
|
53
|
+
* `x`, `y`, `length`; region `x`, `y` (and its drawn size, through
|
|
54
|
+
* `scaleX`/`scaleY`); each weight's bind `x`, `y`; the constraints' lengths
|
|
55
|
+
* ({@link scaledConstraint}); the translate keys and their curves'
|
|
56
|
+
* values. Spine also scales the skeleton's `referenceScale`, which the
|
|
57
|
+
* composed skeleton holds once for every character, so a physics
|
|
58
|
+
* constraint with a nonzero `wind` or `gravity` cannot be scaled per
|
|
59
|
+
* character and is refused (`SCENE_SCALE_EXACT`), as is a track property
|
|
60
|
+
* this module does not know to be a length or not. A mesh's `width` and
|
|
61
|
+
* `height` (its image's, nonessential) stay. At scale 1 nothing is
|
|
62
|
+
* computed: the bytes are the ones written before the field existed.
|
|
63
|
+
* - **The order.** `order` lists character ids (all of that character's
|
|
64
|
+
* slots not named elsewhere, in its own order) and prefixed slots; each
|
|
65
|
+
* slot is drawn exactly once. The plate, if any, is drawn first.
|
|
66
|
+
* - **The plate**: a region on the bone `plate` under `root`, its image at
|
|
67
|
+
* canvas (0, 0), the canvas's size exactly. Its `provenance` (`observed`,
|
|
68
|
+
* `generated`, `unknown`) is copied into `scene.json` and nothing reads it.
|
|
69
|
+
* - **The idle**: one `idle` holding every character's tracks, which needs
|
|
70
|
+
* every character's idle to be the same length (`SCENE_DURATION_AGREES`).
|
|
71
|
+
* - **A failing bar, by name (issue #203).** A build whose own check is not
|
|
72
|
+
* green is refused (`SCENE_BUILD_GREEN`) unless every bar it failed is named
|
|
73
|
+
* in `compose --accept` or the character's `accept` — the two lists are
|
|
74
|
+
* joined, the command line's applying to every character. The gate, a
|
|
75
|
+
* declared requirement and a check.json that names no failing bar are never
|
|
76
|
+
* accepted; a name that is not one of {@link ACCEPTABLE_BARS} is refused
|
|
77
|
+
* (`SCENE_ACCEPT_KNOWN`). What was accepted is printed and written into
|
|
78
|
+
* `scene.json`, and the composed rig's own check measures it again: an
|
|
79
|
+
* accepted failure is for looking at the scene, never a green.
|
|
80
|
+
*
|
|
81
|
+
* Every refusal is collected and thrown at once. A key a build's rig or
|
|
82
|
+
* motion carries that this module does not know is refused rather than
|
|
83
|
+
* copied blind (`SCENE_BUILD_FIELD_KNOWN`): `compose` reads what `build`
|
|
84
|
+
* writes, and a rig written by something else is `check`'s to measure (#77),
|
|
85
|
+
* not this module's to merge.
|
|
86
|
+
*
|
|
87
|
+
* Pure in the sense `src/` is held to: it reads files and returns values; no
|
|
88
|
+
* clock, no randomness, no network, no child process. The rigc gate and the
|
|
89
|
+
* check are run by {@link composeStage} through an injected `RigcRunner`.
|
|
90
|
+
*/
|
|
91
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
92
|
+
import { dirname, join, resolve } from 'node:path';
|
|
93
|
+
import { checkStage, gateThroughRigc, type Log } from './build.ts';
|
|
94
|
+
import { ACCEPTABLE_BARS, BARS, barsNotMet, type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackMode, requirementsNotPass, type RigcRunner } from './check.ts';
|
|
95
|
+
import { CONSTRAINT_BONE_FIELDS, ROOT_BONE } from './config.ts';
|
|
96
|
+
import { cropToSpineY } from './coords.ts';
|
|
97
|
+
import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
|
|
98
|
+
import type { MotionSpec } from './motion.ts';
|
|
99
|
+
import { readPng } from './raster/png.ts';
|
|
100
|
+
import { readRequirements, type RequirementsFile } from './requirements.ts';
|
|
101
|
+
import { OFFSET_PLACES, rigJsonText, type RigSpec } from './rig.ts';
|
|
102
|
+
import { pyRound } from './round.ts';
|
|
103
|
+
|
|
104
|
+
/** The scene file's `spec`. */
|
|
105
|
+
export const SCENE_SPEC = 'spine-parts-scene/1';
|
|
106
|
+
|
|
107
|
+
/** `scene.json`'s `spec`: what `compose` writes beside its outputs. */
|
|
108
|
+
export const SCENE_REPORT_SPEC = 'spine-parts-scene-report/1';
|
|
109
|
+
|
|
110
|
+
/** The file `compose` writes beside `rig/` and `check/`. */
|
|
111
|
+
export const SCENE_REPORT_FILE = 'scene.json';
|
|
112
|
+
|
|
113
|
+
/** Between a character's id and a name it owns. */
|
|
114
|
+
export const PREFIX_SEP = ':';
|
|
115
|
+
|
|
116
|
+
/** Between a character's id and an image file it owns — see the module comment for why not {@link PREFIX_SEP}. */
|
|
117
|
+
export const IMAGE_SEP = '.';
|
|
118
|
+
|
|
119
|
+
/** The plate's bone, slot and attachment name, and its image file. */
|
|
120
|
+
export const PLATE = 'plate';
|
|
121
|
+
export const PLATE_IMAGE = 'plate.png';
|
|
122
|
+
|
|
123
|
+
/** What the author says the plate's pixels are. Recorded; judged by nothing. */
|
|
124
|
+
export const PROVENANCES = ['observed', 'generated', 'unknown'] as const;
|
|
125
|
+
export type Provenance = (typeof PROVENANCES)[number];
|
|
126
|
+
|
|
127
|
+
/** Every path `compose` writes under `--out`, relative — and so every path it clears first. */
|
|
128
|
+
export const COMPOSE_OWNS: readonly string[] = ['rig', 'check', SCENE_REPORT_FILE];
|
|
129
|
+
|
|
130
|
+
/** `<id>:<name>`. */
|
|
131
|
+
export function prefixed(id: string, name: string): string {
|
|
132
|
+
return `${id}${PREFIX_SEP}${name}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `<id>.<file>`. */
|
|
136
|
+
export function imageName(id: string, file: string): string {
|
|
137
|
+
return `${id}${IMAGE_SEP}${file}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// the scene file
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
export interface SceneCharacter {
|
|
145
|
+
id: string;
|
|
146
|
+
/** As written, and resolved against the scene file's directory. */
|
|
147
|
+
build: string;
|
|
148
|
+
buildDir: string;
|
|
149
|
+
offset: [number, number];
|
|
150
|
+
/** {@link SceneCharacter.scale}: the character's size on the canvas, 1 (as built) when the file leaves it out (issue #197). */
|
|
151
|
+
scale: number;
|
|
152
|
+
/** The bars this character's own check may have failed and still be taken (issue #203), as the file lists them; empty when it lists none. */
|
|
153
|
+
accept: string[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ScenePlate {
|
|
157
|
+
image: string;
|
|
158
|
+
imagePath: string;
|
|
159
|
+
provenance: Provenance;
|
|
160
|
+
note: string | null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface SceneFile {
|
|
164
|
+
path: string;
|
|
165
|
+
canvas: { width: number; height: number };
|
|
166
|
+
plate: ScenePlate | null;
|
|
167
|
+
characters: SceneCharacter[];
|
|
168
|
+
order: string[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
172
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function show(v: unknown): string {
|
|
176
|
+
const s = JSON.stringify(v);
|
|
177
|
+
return s === undefined ? String(v) : s.length > 80 ? `${s.slice(0, 77)}...` : s;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** An annotation (`note`, `*_note`, a string) or a project's record (`x-…`, any value): read by nothing, as in the config and the requirements file. */
|
|
181
|
+
function freeKey(key: string, value: unknown): boolean {
|
|
182
|
+
if (key.length > 2 && key.startsWith('x-')) return true;
|
|
183
|
+
return (key === 'note' || key.endsWith('_note')) && typeof value === 'string';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Why `id` cannot be a character's id, or null. Empty, or holding the
|
|
188
|
+
* {@link PREFIX_SEP} — settled in #74: a prefixed name splits at its first
|
|
189
|
+
* colon, so an id holding one would read as another. And, because an id
|
|
190
|
+
* begins an image file's name ({@link imageName}), a `/` or `\` or a leading
|
|
191
|
+
* `.` — the rule `parts.json` holds a part name to.
|
|
192
|
+
*/
|
|
193
|
+
export function idProblem(id: string): string | null {
|
|
194
|
+
if (id === '') return 'is empty; an id names its character in every prefixed name, so one is required';
|
|
195
|
+
if (id.includes(PREFIX_SEP)) return `holds "${PREFIX_SEP}", which separates an id from the name it prefixes, so "${id}${PREFIX_SEP}x" would not read back as one id and one name`;
|
|
196
|
+
if (/[\\/]/.test(id) || id.startsWith('.')) return `begins every image file of its character ("${id}${IMAGE_SEP}<file>.png"), so a "/", a "\\" or a leading "." is refused, as in a part name`;
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `SCENE_ACCEPT_KNOWN` for every name of an accept list (`--accept`, or a
|
|
202
|
+
* character's `accept`) that is not one of {@link ACCEPTABLE_BARS}: the gate
|
|
203
|
+
* is named as rig-c's and never accepted, anything else as no bar `check`
|
|
204
|
+
* holds a build to (issue #203).
|
|
205
|
+
*/
|
|
206
|
+
export function acceptProblems(names: readonly string[], where: string): Problem[] {
|
|
207
|
+
const out: Problem[] = [];
|
|
208
|
+
names.forEach((n, i) => {
|
|
209
|
+
if (ACCEPTABLE_BARS.includes(n)) return;
|
|
210
|
+
const at = `${where}[${i}]`;
|
|
211
|
+
if (n === 'gate') out.push({ code: 'SCENE_ACCEPT_KNOWN', object: at, detail: `is "gate", rig-c's gate, which is never accepted — a build rig-c refused is not a rig; one of ${ACCEPTABLE_BARS.join(', ')} is required` });
|
|
212
|
+
else out.push({ code: 'SCENE_ACCEPT_KNOWN', object: at, detail: `is ${show(n)}, which names no bar check holds a build to; one of ${ACCEPTABLE_BARS.join(', ')} (as check's summary names them) is required` });
|
|
213
|
+
});
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read and shape-check a scene file. Every problem is collected before one
|
|
219
|
+
* refusal: the file, `spec`, `canvas` (two positive integers), `plate`
|
|
220
|
+
* (optional: `image`, `provenance`, an optional `note`), `characters` (at
|
|
221
|
+
* least one; each `id`, `build`, `offset`), `order` (a list of strings), and
|
|
222
|
+
* any key the format does not read. Resolving the ids and slots `order`
|
|
223
|
+
* names is {@link composeScene}'s, which has the builds.
|
|
224
|
+
*/
|
|
225
|
+
export function loadScene(path: string): SceneFile {
|
|
226
|
+
const at = resolve(path);
|
|
227
|
+
if (!existsSync(at) || !statSync(at).isFile()) refuseIfAny([{ code: 'SCENE_FILE', object: at, detail: `no such file; a ${SCENE_SPEC} file is required` }]);
|
|
228
|
+
let raw: unknown;
|
|
229
|
+
try {
|
|
230
|
+
raw = JSON.parse(readFileSync(at, 'utf8'));
|
|
231
|
+
} catch (err) {
|
|
232
|
+
refuseIfAny([{ code: 'SCENE_FILE', object: at, detail: `${(err as Error).message}; a JSON object is required` }]);
|
|
233
|
+
}
|
|
234
|
+
if (!isRecord(raw)) refuseIfAny([{ code: 'SCENE_FILE', object: at, detail: `is ${show(raw)}; a JSON object is required` }]);
|
|
235
|
+
const o = raw as Record<string, unknown>;
|
|
236
|
+
const base = dirname(at);
|
|
237
|
+
const problems: Problem[] = [];
|
|
238
|
+
const fail = (code: string, object: string, detail: string): void => {
|
|
239
|
+
problems.push({ code, object, detail });
|
|
240
|
+
};
|
|
241
|
+
const known = (obj: Record<string, unknown>, where: string, keys: readonly string[]): void => {
|
|
242
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
243
|
+
if (!keys.includes(k) && !freeKey(k, v)) fail('SCENE_KEY_KNOWN', `${where}.${k}`, `is a key ${SCENE_SPEC} does not read; it reads ${keys.join(', ')} (and "note", "…_note" strings and "x-…" records, which nothing reads)`);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
known(o, 'scene', ['spec', 'canvas', 'plate', 'characters', 'order']);
|
|
247
|
+
if (o.spec !== SCENE_SPEC) fail('SCENE_SPEC', 'scene.spec', `is ${show(o.spec)}; "${SCENE_SPEC}" is required`);
|
|
248
|
+
|
|
249
|
+
// canvas
|
|
250
|
+
let canvas = { width: 0, height: 0 };
|
|
251
|
+
if (!('canvas' in o)) fail('SCENE_FIELD_PRESENT', 'scene.canvas', 'is absent and required; {"width": W, "height": H}, the shared stage in canvas px');
|
|
252
|
+
else if (!isRecord(o.canvas)) fail('SCENE_FIELD_TYPE', 'scene.canvas', `is ${show(o.canvas)}; {"width": W, "height": H} is required`);
|
|
253
|
+
else {
|
|
254
|
+
known(o.canvas, 'scene.canvas', ['width', 'height']);
|
|
255
|
+
for (const k of ['width', 'height'] as const) {
|
|
256
|
+
const v = o.canvas[k];
|
|
257
|
+
if (!(k in o.canvas)) fail('SCENE_FIELD_PRESENT', `scene.canvas.${k}`, 'is absent and required; a positive integer of canvas px');
|
|
258
|
+
else if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) fail('SCENE_FIELD_TYPE', `scene.canvas.${k}`, `is ${show(v)}; a positive integer of canvas px is required`);
|
|
259
|
+
else canvas = { ...canvas, [k]: v };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// plate
|
|
264
|
+
let plate: ScenePlate | null = null;
|
|
265
|
+
if ('plate' in o) {
|
|
266
|
+
const p = o.plate;
|
|
267
|
+
if (!isRecord(p)) fail('SCENE_FIELD_TYPE', 'scene.plate', `is ${show(p)}; {"image": "<png>", "provenance": ${PROVENANCES.map((x) => `"${x}"`).join(' | ')}} is required when the key is present`);
|
|
268
|
+
else {
|
|
269
|
+
known(p, 'scene.plate', ['image', 'provenance', 'note']);
|
|
270
|
+
const image = p.image;
|
|
271
|
+
const prov = p.provenance;
|
|
272
|
+
if (!('image' in p)) fail('SCENE_FIELD_PRESENT', 'scene.plate.image', 'is absent and required; the plate PNG, relative to the scene file');
|
|
273
|
+
else if (typeof image !== 'string' || image === '') fail('SCENE_FIELD_TYPE', 'scene.plate.image', `is ${show(image)}; a path is required`);
|
|
274
|
+
if (!('provenance' in p)) fail('SCENE_FIELD_PRESENT', 'scene.plate.provenance', `is absent and required; one of ${PROVENANCES.join(', ')} — what the plate's pixels are, which the author states and nothing judges`);
|
|
275
|
+
else if (typeof prov !== 'string' || !(PROVENANCES as readonly string[]).includes(prov)) fail('SCENE_PLATE_PROVENANCE', 'scene.plate.provenance', `is ${show(prov)}; one of ${PROVENANCES.join(', ')} is required`);
|
|
276
|
+
if (typeof image === 'string' && image !== '' && typeof prov === 'string' && (PROVENANCES as readonly string[]).includes(prov)) {
|
|
277
|
+
plate = { image, imagePath: resolve(base, image), provenance: prov as Provenance, note: typeof p.note === 'string' ? p.note : null };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// characters
|
|
283
|
+
const characters: SceneCharacter[] = [];
|
|
284
|
+
if (!('characters' in o)) fail('SCENE_FIELD_PRESENT', 'scene.characters', 'is absent and required; a list of {"id", "build", "offset"}, at least one');
|
|
285
|
+
else if (!Array.isArray(o.characters) || o.characters.length === 0) fail('SCENE_FIELD_TYPE', 'scene.characters', `is ${show(o.characters)}; a list of at least one {"id", "build", "offset"} is required`);
|
|
286
|
+
else {
|
|
287
|
+
const seen = new Map<string, number>();
|
|
288
|
+
o.characters.forEach((c, i) => {
|
|
289
|
+
const where = `scene.characters[${i}]`;
|
|
290
|
+
if (!isRecord(c)) {
|
|
291
|
+
fail('SCENE_FIELD_TYPE', where, `is ${show(c)}; {"id", "build", "offset"} is required`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
known(c, where, ['id', 'build', 'offset', 'scale', 'accept']);
|
|
295
|
+
let ok = true;
|
|
296
|
+
for (const k of ['id', 'build', 'offset']) {
|
|
297
|
+
if (!(k in c)) {
|
|
298
|
+
fail('SCENE_FIELD_PRESENT', `${where}.${k}`, `is absent and required${k === 'offset' ? '; [x, y], the translation from the build\'s rig px to canvas px — (0, 0) is written, never assumed' : ''}`);
|
|
299
|
+
ok = false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const id = c.id;
|
|
303
|
+
if ('id' in c && typeof id !== 'string') {
|
|
304
|
+
fail('SCENE_FIELD_TYPE', `${where}.id`, `is ${show(id)}; a string is required`);
|
|
305
|
+
ok = false;
|
|
306
|
+
} else if (typeof id === 'string') {
|
|
307
|
+
const why = idProblem(id);
|
|
308
|
+
if (why !== null) {
|
|
309
|
+
fail('SCENE_ID_FORM', `${where}.id`, `"${id}" ${why}`);
|
|
310
|
+
ok = false;
|
|
311
|
+
}
|
|
312
|
+
const first = seen.get(id);
|
|
313
|
+
if (first !== undefined) {
|
|
314
|
+
fail('SCENE_ID_UNIQUE', `${where}.id`, `is "${id}", the id of scene.characters[${first}] too; an id is the prefix that keeps a name to one character, so each must be unique`);
|
|
315
|
+
ok = false;
|
|
316
|
+
} else seen.set(id, i);
|
|
317
|
+
}
|
|
318
|
+
if ('build' in c && (typeof c.build !== 'string' || c.build === '')) {
|
|
319
|
+
fail('SCENE_FIELD_TYPE', `${where}.build`, `is ${show(c.build)}; the directory a green \`rig-parts build\` wrote (its --out), relative to the scene file, is required`);
|
|
320
|
+
ok = false;
|
|
321
|
+
}
|
|
322
|
+
const off = c.offset;
|
|
323
|
+
if ('offset' in c && !(Array.isArray(off) && off.length === 2 && off.every((v) => typeof v === 'number' && Number.isFinite(v)))) {
|
|
324
|
+
fail('SCENE_FIELD_TYPE', `${where}.offset`, `is ${show(off)}; [x, y], two finite numbers of canvas px, is required`);
|
|
325
|
+
ok = false;
|
|
326
|
+
}
|
|
327
|
+
const sc = c.scale;
|
|
328
|
+
if ('scale' in c && !(typeof sc === 'number' && Number.isFinite(sc) && sc > 0)) {
|
|
329
|
+
fail('SCENE_FIELD_TYPE', `${where}.scale`, `is ${show(sc)}; a positive finite number is required — the character's size on the canvas about its stage's top-left corner, 1 drawing it as built (the default when the key is absent)`);
|
|
330
|
+
ok = false;
|
|
331
|
+
}
|
|
332
|
+
const acc = c.accept;
|
|
333
|
+
if ('accept' in c) {
|
|
334
|
+
if (!(Array.isArray(acc) && acc.every((v) => typeof v === 'string'))) {
|
|
335
|
+
fail('SCENE_FIELD_TYPE', `${where}.accept`, `is ${show(acc)}; a list of bar names is required — the bars this character's own check may fail and still be composed, one of ${ACCEPTABLE_BARS.join(', ')} each`);
|
|
336
|
+
ok = false;
|
|
337
|
+
} else {
|
|
338
|
+
const named = acceptProblems(acc as string[], `${where}.accept`);
|
|
339
|
+
problems.push(...named);
|
|
340
|
+
if (named.length > 0) ok = false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (ok) characters.push({ id: id as string, build: c.build as string, buildDir: resolve(base, c.build as string), offset: [(off as number[])[0], (off as number[])[1]], scale: 'scale' in c ? (sc as number) : 1, accept: 'accept' in c ? [...(acc as string[])] : [] });
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// order
|
|
348
|
+
const order: string[] = [];
|
|
349
|
+
if (!('order' in o)) fail('SCENE_FIELD_PRESENT', 'scene.order', 'is absent and required; the declared draw order, back to front: character ids and "<id>:<slot>" entries, every slot once — nothing infers it');
|
|
350
|
+
else if (!Array.isArray(o.order)) fail('SCENE_FIELD_TYPE', 'scene.order', `is ${show(o.order)}; a list of strings is required`);
|
|
351
|
+
else {
|
|
352
|
+
o.order.forEach((e, i) => {
|
|
353
|
+
if (typeof e !== 'string' || e === '') fail('SCENE_FIELD_TYPE', `scene.order[${i}]`, `is ${show(e)}; a character id or "<id>${PREFIX_SEP}<slot>" is required`);
|
|
354
|
+
else order.push(e);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
refuseIfAny(problems);
|
|
358
|
+
return { path: at, canvas, plate, characters, order };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// a character's build
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
/** What `compose` reads from one character's `build --out`. */
|
|
366
|
+
export interface CharacterBuild {
|
|
367
|
+
id: string;
|
|
368
|
+
dir: string;
|
|
369
|
+
rig: RigSpec;
|
|
370
|
+
motion: MotionSpec;
|
|
371
|
+
/** `parts.json`'s `rig_size`, `scale_rig_per_source`, and the union of its part boxes (rig px, [x0, y0, x1, y1], x1 and y1 exclusive). */
|
|
372
|
+
rigSize: [number, number];
|
|
373
|
+
rigScale: number;
|
|
374
|
+
artBox: [number, number, number, number];
|
|
375
|
+
/** Every image the build's skin names, as `rig/images/<file>` holds it, in the order the skin names them. */
|
|
376
|
+
images: Array<[string, Uint8Array]>;
|
|
377
|
+
/** The bars its own check failed that were accepted (issue #203), in check's order; empty for a green build. */
|
|
378
|
+
accepted: string[];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const BONE_KEYS = ['name', 'parent', 'length', 'rotation', 'x', 'y'];
|
|
382
|
+
const SLOT_KEYS = ['name', 'bone', 'attachment'];
|
|
383
|
+
const ATTACHMENT_KEYS = ['type', 'image', 'width', 'height', 'uvs', 'triangles', 'hull', 'weights', 'x', 'y', 'rotation'];
|
|
384
|
+
const RIG_KEYS = ['spec', 'name', 'images', 'skeleton', 'bones', 'slots', 'skins', 'constraints', 'invariants'];
|
|
385
|
+
const INVARIANT_KEYS = ['idleDrivesMeshes', 'detached'];
|
|
386
|
+
const MOTION_KEYS = ['spec', 'archetype', 'cut', 'easings', 'groups', 'animations'];
|
|
387
|
+
const IDLE_KEYS = ['duration', 'loop', 'note', 'tracks'];
|
|
388
|
+
const TRACK_KEYS = ['bone', 'group', 'property', 'keys'];
|
|
389
|
+
const KEY_KEYS = ['t', 'v', 'curve', 'ease'];
|
|
390
|
+
|
|
391
|
+
/** One animation of a motion.json: the idle, or one beside it as the file `motion.animations_from` named it (issue #183). */
|
|
392
|
+
type SceneAnimation = { duration: number; loop?: boolean; note?: string; tracks: MotionSpec['animations']['idle']['tracks'] };
|
|
393
|
+
|
|
394
|
+
/** Every animation a motion.json holds, the idle first, in its own order. */
|
|
395
|
+
function animationsOf(motion: MotionSpec): Record<string, SceneAnimation> {
|
|
396
|
+
return motion.animations as unknown as Record<string, SceneAnimation>;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function readJson(path: string, what: string, problems: Problem[]): unknown {
|
|
400
|
+
if (!existsSync(path)) {
|
|
401
|
+
problems.push({ code: 'SCENE_BUILD_PRESENT', object: path, detail: `no such file; ${what}` });
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
406
|
+
} catch (err) {
|
|
407
|
+
problems.push({ code: 'SCENE_BUILD_PRESENT', object: path, detail: `${(err as Error).message}; ${what}` });
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Read one character's build, collecting every problem: the files `build`
|
|
414
|
+
* writes (`SCENE_BUILD_PRESENT`), its own check green (`SCENE_BUILD_GREEN`:
|
|
415
|
+
* `check/check.json` with `PASS` and `gate_spine_html_green` both true — a
|
|
416
|
+
* build stopped at check has a check.json saying so), and every key of its
|
|
417
|
+
* rig and motion one this module knows (`SCENE_BUILD_FIELD_KNOWN`).
|
|
418
|
+
*
|
|
419
|
+
* `accept` (issue #203) is `compose --accept`, joined with the character's own
|
|
420
|
+
* `accept`: a check that is not green is taken when every bar it failed is
|
|
421
|
+
* named in that join, its gate green and every declared requirement PASS;
|
|
422
|
+
* otherwise it is `SCENE_BUILD_GREEN`, naming the bars not accepted. Without
|
|
423
|
+
* an accept list the refusal reads as it always did.
|
|
424
|
+
*/
|
|
425
|
+
export function readCharacterBuild(c: SceneCharacter, problems: Problem[], accept: readonly string[] = []): CharacterBuild | null {
|
|
426
|
+
const before = problems.length;
|
|
427
|
+
const dir = c.buildDir;
|
|
428
|
+
const who = `character "${c.id}" (${c.build})`;
|
|
429
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
430
|
+
problems.push({ code: 'SCENE_BUILD_PRESENT', object: who, detail: `${dir} is not a directory; the --out of a green \`rig-parts build\` is required` });
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const check = readJson(join(dir, 'check', 'check.json'), "the build's own check; a build that ran its check writes it", problems);
|
|
434
|
+
const joined = new Set([...accept, ...c.accept]);
|
|
435
|
+
let accepted: string[] = [];
|
|
436
|
+
if (check !== undefined) {
|
|
437
|
+
const ck = isRecord(check) ? check : {};
|
|
438
|
+
if ((ck.PASS !== true || ck.gate_spine_html_green !== true) && joined.size > 0) {
|
|
439
|
+
const failed = barsNotMet(ck);
|
|
440
|
+
const reqs = requirementsNotPass(ck);
|
|
441
|
+
const not = failed.filter((b) => !joined.has(b));
|
|
442
|
+
if (not.length === 0 && reqs.length === 0 && failed.length > 0) accepted = failed;
|
|
443
|
+
else {
|
|
444
|
+
const why = [
|
|
445
|
+
...(not.length === 0 ? [] : [`${not.join(', ')} ${not.length === 1 ? 'is' : 'are'} not accepted${not.includes('gate') ? ' (the gate never is)' : ''}`]),
|
|
446
|
+
...(reqs.length === 0 ? [] : [`declared requirement(s) ${reqs.map((r) => `"${r}"`).join(', ')} not PASS, which no accept list takes`]),
|
|
447
|
+
...(failed.length === 0 && reqs.length === 0 ? ['it names no failing bar, so there is nothing an accept list could name'] : []),
|
|
448
|
+
];
|
|
449
|
+
problems.push({
|
|
450
|
+
code: 'SCENE_BUILD_GREEN',
|
|
451
|
+
object: who,
|
|
452
|
+
detail: `check/check.json says PASS ${show(ck.PASS)}, gate_spine_html_green ${show(ck.gate_spine_html_green)}, the bar(s) not met ${failed.length === 0 ? 'none' : failed.join(', ')}; accepted ${[...joined].join(', ')}, and ${why.join('; ')} — a build whose own check fails only accepted bars is required`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
} else if (ck.PASS !== true || ck.gate_spine_html_green !== true) {
|
|
456
|
+
problems.push({
|
|
457
|
+
code: 'SCENE_BUILD_GREEN',
|
|
458
|
+
object: who,
|
|
459
|
+
detail: `check/check.json says PASS ${show(ck.PASS)}, gate_spine_html_green ${show(ck.gate_spine_html_green)}; a build whose own check is green on both is required — compose binds finished characters, it does not finish them`,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const parts = readJson(join(dir, 'parts.json'), "assemble's parts.json, which holds the rig_scale and the art's place", problems);
|
|
464
|
+
const rigRaw = readJson(join(dir, 'rig', 'rig.json'), "the rig stage's rig.json", problems);
|
|
465
|
+
const motionRaw = readJson(join(dir, 'rig', 'motion.json'), "the rig stage's motion.json", problems);
|
|
466
|
+
const unknown = (where: string, obj: unknown, keys: readonly string[]): void => {
|
|
467
|
+
if (!isRecord(obj)) {
|
|
468
|
+
problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} ${where}`, detail: `is ${show(obj)}; an object is required` });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
for (const k of Object.keys(obj)) {
|
|
472
|
+
if (!keys.includes(k)) problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} ${where}.${k}`, detail: `is a key compose does not know how to prefix or place (it knows ${keys.join(', ')}); what build writes is composed, and a key nobody prefixes could name a bone of the wrong character` });
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
let rigScale = Number.NaN;
|
|
476
|
+
let rigSize: [number, number] = [0, 0];
|
|
477
|
+
let artBox: [number, number, number, number] = [0, 0, 0, 0];
|
|
478
|
+
if (parts !== undefined) {
|
|
479
|
+
const p = isRecord(parts) ? parts : {};
|
|
480
|
+
const list = Array.isArray(p.parts) ? (p.parts as unknown[]).filter(isRecord) : [];
|
|
481
|
+
if (typeof p.scale_rig_per_source !== 'number' || !Array.isArray(p.rig_size) || list.length === 0) {
|
|
482
|
+
problems.push({ code: 'SCENE_BUILD_PRESENT', object: `${who} parts.json`, detail: 'needs scale_rig_per_source, rig_size and at least one part; assemble writes all three' });
|
|
483
|
+
} else {
|
|
484
|
+
rigScale = p.scale_rig_per_source;
|
|
485
|
+
rigSize = [Number(p.rig_size[0]), Number(p.rig_size[1])];
|
|
486
|
+
const xs = list.map((q) => Number(q.x));
|
|
487
|
+
const ys = list.map((q) => Number(q.y));
|
|
488
|
+
artBox = [Math.min(...xs), Math.min(...ys), Math.max(...list.map((q) => Number(q.x) + Number(q.w))), Math.max(...list.map((q) => Number(q.y) + Number(q.h)))];
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const images: Array<[string, Uint8Array]> = [];
|
|
492
|
+
if (rigRaw !== undefined) {
|
|
493
|
+
unknown('rig.json', rigRaw, RIG_KEYS);
|
|
494
|
+
const r = rigRaw as Record<string, unknown>;
|
|
495
|
+
if (isRecord(r)) {
|
|
496
|
+
const bones = Array.isArray(r.bones) ? r.bones : [];
|
|
497
|
+
bones.forEach((b, i) => unknown(`rig.json bones[${i}]`, b, BONE_KEYS));
|
|
498
|
+
const root = bones[0] as Record<string, unknown> | undefined;
|
|
499
|
+
if (root === undefined || root.name !== ROOT_BONE || root.x !== 0 || root.y !== 0 || Object.keys(root).length !== 3) {
|
|
500
|
+
problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} rig.json bones[0]`, detail: `is ${show(root)}; {"name": "${ROOT_BONE}", "x": 0, "y": 0}, as the rig stage writes it, is required — the composed root is shared, so a character's root must carry no transform of its own` });
|
|
501
|
+
}
|
|
502
|
+
(Array.isArray(r.slots) ? r.slots : []).forEach((s, i) => unknown(`rig.json slots[${i}]`, s, SLOT_KEYS));
|
|
503
|
+
if (isRecord(r.invariants)) unknown('rig.json invariants', r.invariants, INVARIANT_KEYS);
|
|
504
|
+
const skins = isRecord(r.skins) ? r.skins : {};
|
|
505
|
+
unknown('rig.json skins', skins, ['default']);
|
|
506
|
+
const seen = new Set<string>();
|
|
507
|
+
for (const [slot, atts] of Object.entries(isRecord(skins.default) ? skins.default : {})) {
|
|
508
|
+
for (const [name, att] of Object.entries(isRecord(atts) ? atts : {})) {
|
|
509
|
+
unknown(`rig.json skins.default.${slot}.${name}`, att, ATTACHMENT_KEYS);
|
|
510
|
+
const file = isRecord(att) ? att.image : undefined;
|
|
511
|
+
if (typeof file !== 'string' || seen.has(file)) continue;
|
|
512
|
+
seen.add(file);
|
|
513
|
+
const png = join(dir, 'rig', 'images', file);
|
|
514
|
+
if (!existsSync(png)) problems.push({ code: 'SCENE_BUILD_PRESENT', object: `${who} skins.default.${slot}.${name}`, detail: `names the image "${file}", and ${png} does not exist` });
|
|
515
|
+
else images.push([file, new Uint8Array(readFileSync(png))]);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (motionRaw !== undefined) {
|
|
521
|
+
unknown('motion.json', motionRaw, MOTION_KEYS);
|
|
522
|
+
const m = motionRaw as Record<string, unknown>;
|
|
523
|
+
if (isRecord(m)) {
|
|
524
|
+
const anims = isRecord(m.animations) ? m.animations : {};
|
|
525
|
+
// Every animation beside the idle (motion.animations_from, issue #183) is read with the idle's own key lists,
|
|
526
|
+
// so each name it carries is one compose prefixes; its loop is the file's, not the rig stage's.
|
|
527
|
+
for (const [n, a] of Object.entries(anims)) {
|
|
528
|
+
if (n === 'idle') continue;
|
|
529
|
+
unknown(`motion.json animations.${n}`, a, IDLE_KEYS);
|
|
530
|
+
const tracks = isRecord(a) && Array.isArray(a.tracks) ? a.tracks : [];
|
|
531
|
+
tracks.forEach((t, i) => {
|
|
532
|
+
unknown(`motion.json animations.${n}.tracks[${i}]`, t, TRACK_KEYS);
|
|
533
|
+
const keys = isRecord(t) && Array.isArray(t.keys) ? t.keys : [];
|
|
534
|
+
keys.forEach((k, j) => unknown(`motion.json animations.${n}.tracks[${i}].keys[${j}]`, k, KEY_KEYS));
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
if (isRecord(anims.idle)) {
|
|
538
|
+
unknown('motion.json animations.idle', anims.idle, IDLE_KEYS);
|
|
539
|
+
if (anims.idle.loop !== true) problems.push({ code: 'SCENE_BUILD_FIELD_KNOWN', object: `${who} motion.json animations.idle.loop`, detail: `is ${show(anims.idle.loop)}; the rig stage writes true` });
|
|
540
|
+
const tracks = Array.isArray(anims.idle.tracks) ? anims.idle.tracks : [];
|
|
541
|
+
tracks.forEach((t, i) => {
|
|
542
|
+
unknown(`motion.json animations.idle.tracks[${i}]`, t, TRACK_KEYS);
|
|
543
|
+
const keys = isRecord(t) && Array.isArray(t.keys) ? t.keys : [];
|
|
544
|
+
keys.forEach((k, j) => unknown(`motion.json animations.idle.tracks[${i}].keys[${j}]`, k, KEY_KEYS));
|
|
545
|
+
});
|
|
546
|
+
} else problems.push({ code: 'SCENE_BUILD_PRESENT', object: `${who} motion.json animations.idle`, detail: 'is absent; the rig stage writes one idle, which the scene holds every character\'s tracks in' });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (problems.length > before) return null;
|
|
550
|
+
return { id: c.id, dir, rig: rigRaw as RigSpec, motion: motionRaw as MotionSpec, rigSize, rigScale, artBox, images, accepted };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// composing
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The translation, in Spine's axes, that carries a character's first-level
|
|
559
|
+
* bones from its own stage to the canvas: its stage's top-left corner
|
|
560
|
+
* (`skeleton.x`, `skeleton.y + height`) is placed at canvas px `offset`,
|
|
561
|
+
* whose Spine point is (`canvas.x + offset[0]`, `cropToSpineY(offset[1], H)`).
|
|
562
|
+
* Zero for a character at (0, 0) on a canvas of its own stage's size.
|
|
563
|
+
*/
|
|
564
|
+
export function rootShift(stage: RigSpec['skeleton'], scene: RigSpec['skeleton'], offset: readonly [number, number], scale = 1): [number, number] {
|
|
565
|
+
// At scale 1 the expressions are the ones this function always computed (1 * v is v in float64, but the code says so).
|
|
566
|
+
if (scale === 1) {
|
|
567
|
+
const dx = scene.x + offset[0] - stage.x;
|
|
568
|
+
const dy = scene.y + cropToSpineY(offset[1], scene.height) - (stage.y + cropToSpineY(0, stage.height));
|
|
569
|
+
return [dx, dy];
|
|
570
|
+
}
|
|
571
|
+
// A point p of the character's root frame lands at scale * p + shift, so its stage corner c lands at scale * c +
|
|
572
|
+
// shift, which must be the canvas point of offset: shift = corner on the canvas - scale * c.
|
|
573
|
+
const dx = scene.x + offset[0] - scale * stage.x;
|
|
574
|
+
const dy = scene.y + cropToSpineY(offset[1], scene.height) - scale * (stage.y + cropToSpineY(0, stage.height));
|
|
575
|
+
return [dx, dy];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
// a character's scale (issue #197)
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Spine's own defaults for the two scaled fields whose default is not 0 —
|
|
584
|
+
* `SkeletonJson.js` (spine-core 4.3), `getValue(constraintMap, "limit", 5000)`
|
|
585
|
+
* on a physics constraint, `getValue(toEntry, "max", 1)` on a transform
|
|
586
|
+
* constraint's `to` entry, and `getValue(constraintMap, "scale", 1)` on a
|
|
587
|
+
* slider. An absent field loads as the default times the loader's scale (or
|
|
588
|
+
* over it), so a scaled character writes that product: leaving the key out
|
|
589
|
+
* would load the unscaled default.
|
|
590
|
+
*/
|
|
591
|
+
export const SPINE_PHYSICS_LIMIT_DEFAULT = 5000;
|
|
592
|
+
export const SPINE_TRANSFORM_TO_MAX_DEFAULT = 1;
|
|
593
|
+
export const SPINE_SLIDER_SCALE_DEFAULT = 1;
|
|
594
|
+
|
|
595
|
+
/** A bone track's properties whose values are lengths (`readTimeline1/2(..., scale)` in `SkeletonJson.js`), and the ones that are not. */
|
|
596
|
+
export const SCALED_TRACK_PROPERTIES: readonly string[] = ['translate', 'translatex', 'translatey'];
|
|
597
|
+
export const UNSCALED_TRACK_PROPERTIES: readonly string[] = ['rotate', 'scale', 'scalex', 'scaley', 'shear', 'shearx', 'sheary', 'inherit'];
|
|
598
|
+
|
|
599
|
+
/** The two properties a transform constraint or a slider reads or drives in length (`propertyScale` in `SkeletonJson.js`). */
|
|
600
|
+
function lengthProperty(name: string): boolean {
|
|
601
|
+
return name === 'x' || name === 'y';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** A value scaled by `s`, at the places the rig stage writes an offset to; `s` 1 leaves the number as written. */
|
|
605
|
+
function scaledBy(v: number, s: number): number {
|
|
606
|
+
if (s === 1) return v;
|
|
607
|
+
const r = pyRound(v * s, OFFSET_PLACES);
|
|
608
|
+
return r === 0 ? 0 : r;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** A nonzero `wind` or `gravity` on a physics constraint: Spine multiplies it by the skeleton-wide `referenceScale` (translation) and by bone length over it (rotation), so no per-constraint value reproduces a scaled character. */
|
|
612
|
+
function physicsForce(con: Record<string, unknown>): string[] {
|
|
613
|
+
return ['wind', 'gravity'].filter((k) => typeof con[k] === 'number' && con[k] !== 0);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Why a character at `scale` other than 1 cannot be written exactly, each a
|
|
618
|
+
* `SCENE_SCALE_EXACT` problem: a track whose property this module does not
|
|
619
|
+
* know to be a length or not, a key whose `v` is neither a list of numbers
|
|
620
|
+
* nor a map of them, and a physics constraint carrying a wind or a gravity
|
|
621
|
+
* (see {@link physicsForce}). Nothing when `scale` is 1.
|
|
622
|
+
*/
|
|
623
|
+
export function scaleProblems(c: SceneCharacter, b: CharacterBuild): Problem[] {
|
|
624
|
+
if (c.scale === 1) return [];
|
|
625
|
+
const out: Problem[] = [];
|
|
626
|
+
const who = `character "${c.id}" at scale ${c.scale}`;
|
|
627
|
+
for (const [an, anim] of Object.entries(animationsOf(b.motion))) {
|
|
628
|
+
anim.tracks.forEach((t, k) => {
|
|
629
|
+
const where = `${who} motion.json animations.${an}.tracks[${k}]`;
|
|
630
|
+
const prop = String(t.property);
|
|
631
|
+
if (!SCALED_TRACK_PROPERTIES.includes(prop) && !UNSCALED_TRACK_PROPERTIES.includes(prop)) {
|
|
632
|
+
out.push({ code: 'SCENE_SCALE_EXACT', object: `${where}.property`, detail: `is ${show(t.property)}, which compose does not know to be a length or not; it scales ${SCALED_TRACK_PROPERTIES.join(', ')} and leaves ${UNSCALED_TRACK_PROPERTIES.join(', ')} — compose this character at scale 1, or rebuild it at another assemble.rig_scale` });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (!SCALED_TRACK_PROPERTIES.includes(prop)) return;
|
|
636
|
+
(t.keys as unknown as Array<Record<string, unknown>>).forEach((key, j) => {
|
|
637
|
+
const v = key.v;
|
|
638
|
+
const numbers = (x: unknown): boolean => Array.isArray(x) && x.every((n) => typeof n === 'number');
|
|
639
|
+
if (!numbers(v) && !(isRecord(v) && Object.values(v).every(numbers))) {
|
|
640
|
+
out.push({ code: 'SCENE_SCALE_EXACT', object: `${where}.keys[${j}].v`, detail: `is ${show(v)}; a ${prop} key's value is a length, which compose scales in a list of numbers or a map of member to list, and this is neither` });
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
(b.rig.constraints ?? []).forEach((con, k) => {
|
|
646
|
+
const forces = con.type === 'physics' ? physicsForce(con as Record<string, unknown>) : [];
|
|
647
|
+
if (forces.length > 0) {
|
|
648
|
+
out.push({
|
|
649
|
+
code: 'SCENE_SCALE_EXACT',
|
|
650
|
+
object: `${who} rig.json constraints[${k}] (physics "${String(con.name)}")`,
|
|
651
|
+
detail: `declares ${forces.map((f) => `${f} ${String((con as Record<string, unknown>)[f])}`).join(' and ')}; Spine applies it times the skeleton's referenceScale to the bone's position and times its length over referenceScale to its rotation, and the composed skeleton has one referenceScale for every character, so no value written here draws the character at ${c.scale} — compose it at scale 1, or rebuild it at another assemble.rig_scale`,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
return out;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* A constraint of a character at scale `s`, its every length scaled as
|
|
660
|
+
* Spine's own loader scales a skeleton (`SkeletonJson.readSkeletonData`, the
|
|
661
|
+
* `* scale` sites): an ik's `softness`; a transform's `x`, `y`, and in its
|
|
662
|
+
* `properties` a length `from`'s `offset`, a length `to`'s `offset` and
|
|
663
|
+
* `max`, and each `to`'s `scale` by the to-unit over the from-unit; a path's
|
|
664
|
+
* `position` under `positionMode` Fixed and its `spacing` under `spacingMode`
|
|
665
|
+
* Length (the default) or Fixed; a physics `limit`; a slider driven by a
|
|
666
|
+
* bone's `x` or `y`: its `from` scaled and its `scale` divided. An absent
|
|
667
|
+
* field whose Spine default is not 0 is written as that default scaled. Every
|
|
668
|
+
* other field — mixes, degrees, ratios, physics rates — is not a length and
|
|
669
|
+
* is copied. `s` 1 returns `con` as given.
|
|
670
|
+
*/
|
|
671
|
+
export function scaledConstraint(con: Record<string, unknown>, s: number): Record<string, unknown> {
|
|
672
|
+
if (s === 1) return con;
|
|
673
|
+
const out: Record<string, unknown> = { ...con };
|
|
674
|
+
const len = (k: string): void => {
|
|
675
|
+
if (typeof out[k] === 'number') out[k] = scaledBy(out[k] as number, s);
|
|
676
|
+
};
|
|
677
|
+
const lower = (v: unknown, fallback: string): string => (typeof v === 'string' ? v : fallback).toLowerCase();
|
|
678
|
+
switch (con.type) {
|
|
679
|
+
case 'ik':
|
|
680
|
+
len('softness');
|
|
681
|
+
break;
|
|
682
|
+
case 'transform': {
|
|
683
|
+
len('x');
|
|
684
|
+
len('y');
|
|
685
|
+
if (isRecord(con.properties)) {
|
|
686
|
+
const props: Record<string, unknown> = {};
|
|
687
|
+
for (const [from, entry] of Object.entries(con.properties)) {
|
|
688
|
+
if (!isRecord(entry)) {
|
|
689
|
+
props[from] = entry;
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
const fs = lengthProperty(from) ? s : 1;
|
|
693
|
+
const e: Record<string, unknown> = { ...entry };
|
|
694
|
+
if (typeof e.offset === 'number') e.offset = scaledBy(e.offset, fs);
|
|
695
|
+
if (isRecord(entry.to)) {
|
|
696
|
+
const tos: Record<string, unknown> = {};
|
|
697
|
+
for (const [to, te] of Object.entries(entry.to)) {
|
|
698
|
+
if (!isRecord(te)) {
|
|
699
|
+
tos[to] = te;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
const ts = lengthProperty(to) ? s : 1;
|
|
703
|
+
const t: Record<string, unknown> = { ...te };
|
|
704
|
+
if (typeof t.offset === 'number') t.offset = scaledBy(t.offset, ts);
|
|
705
|
+
if (ts !== 1) t.max = scaledBy(typeof t.max === 'number' ? t.max : SPINE_TRANSFORM_TO_MAX_DEFAULT, ts);
|
|
706
|
+
if (ts !== fs) t.scale = scaledBy(typeof t.scale === 'number' ? t.scale : 1, ts / fs);
|
|
707
|
+
tos[to] = t;
|
|
708
|
+
}
|
|
709
|
+
e.to = tos;
|
|
710
|
+
}
|
|
711
|
+
props[from] = e;
|
|
712
|
+
}
|
|
713
|
+
out.properties = props;
|
|
714
|
+
}
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
case 'path':
|
|
718
|
+
if (lower(con.positionMode, 'percent') === 'fixed') len('position');
|
|
719
|
+
if (['length', 'fixed'].includes(lower(con.spacingMode, 'length'))) len('spacing');
|
|
720
|
+
break;
|
|
721
|
+
case 'physics':
|
|
722
|
+
out.limit = scaledBy(typeof con.limit === 'number' ? con.limit : SPINE_PHYSICS_LIMIT_DEFAULT, s);
|
|
723
|
+
break;
|
|
724
|
+
case 'slider':
|
|
725
|
+
if (typeof con.bone === 'string' && typeof con.property === 'string' && lengthProperty(con.property)) {
|
|
726
|
+
len('from');
|
|
727
|
+
out.scale = scaledBy(typeof con.scale === 'number' ? con.scale : SPINE_SLIDER_SCALE_DEFAULT, 1 / s);
|
|
728
|
+
}
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
return out;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** A bone track's keys at scale `s`: a length property's values, and its curve's value control points, scaled; any other track as given. */
|
|
735
|
+
function scaledTrack<T extends { property: string; keys: unknown[] }>(t: T, s: number): T {
|
|
736
|
+
if (s === 1 || !SCALED_TRACK_PROPERTIES.includes(t.property)) return t;
|
|
737
|
+
const list = (v: unknown): unknown => (Array.isArray(v) ? v.map((n) => scaledBy(n as number, s)) : v);
|
|
738
|
+
const keys = (t.keys as Array<Record<string, unknown>>).map((k) => {
|
|
739
|
+
const out: Record<string, unknown> = { ...k, v: isRecord(k.v) ? Object.fromEntries(Object.entries(k.v).map(([m, x]) => [m, list(x)])) : list(k.v) };
|
|
740
|
+
// A raw curve is four numbers per channel, (time, value, time, value): the values are at the odd places.
|
|
741
|
+
if (Array.isArray(k.curve)) out.curve = (k.curve as number[]).map((n, i) => (i % 2 === 1 ? scaledBy(n, s) : n));
|
|
742
|
+
return out;
|
|
743
|
+
});
|
|
744
|
+
return { ...t, keys };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** The composed rig's stage: the canvas, x measured from its centre and y up from its bottom, as the rig stage writes a character's. */
|
|
748
|
+
export function sceneStage(canvas: SceneFile['canvas']): RigSpec['skeleton'] {
|
|
749
|
+
return { x: -canvas.width / 2, y: 0, width: canvas.width, height: canvas.height };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** One character's row of `scene.json`. */
|
|
753
|
+
export interface SceneCharacterReport {
|
|
754
|
+
id: string;
|
|
755
|
+
build: string;
|
|
756
|
+
offset: [number, number];
|
|
757
|
+
/** {@link SceneCharacter.scale}; written only when it is not 1, so a scene that declares none writes the bytes it wrote before the field existed. */
|
|
758
|
+
scale?: number;
|
|
759
|
+
rig_size: [number, number];
|
|
760
|
+
rig_scale: number;
|
|
761
|
+
/** The union of the character's part boxes, placed: canvas px, [x0, y0, x1, y1], x1 and y1 exclusive. */
|
|
762
|
+
bounds: [number, number, number, number];
|
|
763
|
+
/** {@link rootShift}: what was added to the x and y of everything the character places in root's frame (after its scale), in Spine units (canvas px, y up). */
|
|
764
|
+
shift: [number, number];
|
|
765
|
+
/** What the shift was added to: the first-level bones, the slots on `root` whose regions moved, and the count of mesh weights bound to `root`. */
|
|
766
|
+
shifted: { bones: string[]; root_regions: string[]; root_weights: number };
|
|
767
|
+
bones: number;
|
|
768
|
+
slots: number;
|
|
769
|
+
tracks: number;
|
|
770
|
+
constraints: number;
|
|
771
|
+
idle_duration: number;
|
|
772
|
+
/** The bars the character's own check failed that `--accept` or its `accept` took (issue #203); written only when there are any, so a scene of green builds writes the bytes it wrote before the field existed. */
|
|
773
|
+
accepted?: string[];
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export interface SceneReport {
|
|
777
|
+
spec: typeof SCENE_REPORT_SPEC;
|
|
778
|
+
scene: string;
|
|
779
|
+
canvas: { width: number; height: number };
|
|
780
|
+
rig_name: string;
|
|
781
|
+
pack_mode: { page_edges: string; pack_shape: string };
|
|
782
|
+
plate: { image: string; provenance: Provenance; note: string | null; bone: string; slot: string; size: [number, number]; judged_by: 'nothing' } | null;
|
|
783
|
+
characters: SceneCharacterReport[];
|
|
784
|
+
order: { declared: string[]; slots: string[]; judged_by: 'nothing' };
|
|
785
|
+
idle_duration: number;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export interface ComposedScene {
|
|
789
|
+
rig: RigSpec;
|
|
790
|
+
motion: MotionSpec;
|
|
791
|
+
/** Every image file of the composed rig, by its composed name: the plate's first, then each character's. */
|
|
792
|
+
images: Array<[string, Uint8Array]>;
|
|
793
|
+
report: SceneReport;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** The plate image's bytes and size, read by the caller. */
|
|
797
|
+
export interface PlateImage {
|
|
798
|
+
bytes: Uint8Array;
|
|
799
|
+
width: number;
|
|
800
|
+
height: number;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
type Attachment = Record<string, unknown>;
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Compose the scene from its characters' builds. `builds` holds every
|
|
807
|
+
* character the scene names, in its order, or null where its build could not
|
|
808
|
+
* be read (its problems already collected in `problems`): what can be checked
|
|
809
|
+
* without it — the order's ids, the other characters — still is, so one run
|
|
810
|
+
* names everything. Throws one PartsError holding `problems` and every
|
|
811
|
+
* problem found here.
|
|
812
|
+
*/
|
|
813
|
+
export function composeScene(
|
|
814
|
+
scene: SceneFile,
|
|
815
|
+
builds: ReadonlyArray<CharacterBuild | null>,
|
|
816
|
+
plate: PlateImage | null,
|
|
817
|
+
problems: Problem[],
|
|
818
|
+
mode: PackMode = { pageEdges: DEFAULT_PAGE_EDGES, packShape: DEFAULT_PACK_SHAPE },
|
|
819
|
+
requirements: RequirementsFile | null = null,
|
|
820
|
+
): ComposedScene {
|
|
821
|
+
const fail = (code: string, object: string, detail: string): void => {
|
|
822
|
+
problems.push({ code, object, detail });
|
|
823
|
+
};
|
|
824
|
+
const { canvas } = scene;
|
|
825
|
+
const stage = sceneStage(canvas);
|
|
826
|
+
const ready = builds.filter((b): b is CharacterBuild => b !== null);
|
|
827
|
+
const byId = new Map(ready.map((b) => [b.id, b]));
|
|
828
|
+
const ids = scene.characters.map((c) => c.id);
|
|
829
|
+
|
|
830
|
+
// ---- the plate ----------------------------------------------------------
|
|
831
|
+
if (scene.plate !== null && plate !== null && (plate.width !== canvas.width || plate.height !== canvas.height)) {
|
|
832
|
+
fail('SCENE_PLATE_SIZE', `scene.plate.image (${scene.plate.image})`, `is ${plate.width}x${plate.height}; the canvas's size, ${canvas.width}x${canvas.height}, is required — the plate is drawn at canvas (0, 0) and is never scaled`);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ---- one rig_scale, one idle length -------------------------------------
|
|
836
|
+
const scales = ready.map((b) => `"${b.id}" ${b.rigScale}`);
|
|
837
|
+
if (new Set(ready.map((b) => b.rigScale)).size > 1) {
|
|
838
|
+
fail('SCENE_RIG_SCALE_AGREES', 'scene.characters', `were built at assemble.rig_scale ${scales.join(', ')} (parts.json's scale_rig_per_source); one rig_scale is required — offsets are canvas px and a character's rig px are canvas px at scale 1, and a character's scale (characters[].scale) sizes it on the canvas but does not make two rig_scales one`);
|
|
839
|
+
}
|
|
840
|
+
const durations = ready.map((b) => b.motion.animations.idle.duration);
|
|
841
|
+
if (new Set(durations).size > 1) {
|
|
842
|
+
fail('SCENE_DURATION_AGREES', 'scene.characters', `have idles of ${ready.map((b, i) => `"${b.id}" ${durations[i]} s`).join(', ')}; one idle holds every character's tracks, so one duration is required — rebuild with one motion.duration`);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// ---- placed bounds --------------------------------------------------------
|
|
846
|
+
const rows: SceneCharacterReport[] = [];
|
|
847
|
+
scene.characters.forEach((c, i) => {
|
|
848
|
+
const b = byId.get(c.id);
|
|
849
|
+
if (b === undefined) return;
|
|
850
|
+
const [x0, y0, x1, y1] = b.artBox;
|
|
851
|
+
const s = c.scale;
|
|
852
|
+
const bounds: [number, number, number, number] = [scaledBy(x0, s) + c.offset[0], scaledBy(y0, s) + c.offset[1], scaledBy(x1, s) + c.offset[0], scaledBy(y1, s) + c.offset[1]];
|
|
853
|
+
if (bounds[0] < 0 || bounds[1] < 0 || bounds[2] > canvas.width || bounds[3] > canvas.height) {
|
|
854
|
+
fail(
|
|
855
|
+
'SCENE_CHARACTER_INSIDE_CANVAS',
|
|
856
|
+
`scene.characters[${i}] "${c.id}"`,
|
|
857
|
+
`its parts' boxes (rig px [${x0}, ${y0}, ${x1}, ${y1}]) at offset [${c.offset.join(', ')}]${s === 1 ? '' : ` and scale ${s}`} cover canvas px [${bounds.join(', ')}], which leaves the ${canvas.width}x${canvas.height} canvas; 0 <= x0, 0 <= y0, x1 <= ${canvas.width} and y1 <= ${canvas.height} are required`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
problems.push(...scaleProblems(c, b));
|
|
861
|
+
const shift = rootShift(b.rig.skeleton, stage, c.offset, s);
|
|
862
|
+
rows.push({
|
|
863
|
+
id: c.id,
|
|
864
|
+
build: c.build,
|
|
865
|
+
offset: [...c.offset],
|
|
866
|
+
...(s === 1 ? {} : { scale: s }),
|
|
867
|
+
rig_size: [...b.rigSize],
|
|
868
|
+
rig_scale: b.rigScale,
|
|
869
|
+
bounds,
|
|
870
|
+
shift,
|
|
871
|
+
shifted: {
|
|
872
|
+
bones: b.rig.bones.filter((x) => x.parent === ROOT_BONE).map((x) => prefixed(c.id, x.name)),
|
|
873
|
+
root_regions: b.rig.slots.filter((s) => s.bone === ROOT_BONE && Object.values(b.rig.skins.default[s.name] ?? {}).some((a) => !('weights' in a))).map((s) => prefixed(c.id, s.name)),
|
|
874
|
+
root_weights: Object.values(b.rig.skins.default).flatMap((atts) => Object.values(atts)).reduce((n, a) => n + ('weights' in a ? a.weights.flat().filter((w) => w.bone === ROOT_BONE).length : 0), 0),
|
|
875
|
+
},
|
|
876
|
+
bones: b.rig.bones.length - 1,
|
|
877
|
+
slots: b.rig.slots.length,
|
|
878
|
+
tracks: b.motion.animations.idle.tracks.length,
|
|
879
|
+
constraints: b.rig.constraints?.length ?? 0,
|
|
880
|
+
idle_duration: b.motion.animations.idle.duration,
|
|
881
|
+
...(b.accepted.length === 0 ? {} : { accepted: [...b.accepted] }),
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// ---- the order ------------------------------------------------------------
|
|
886
|
+
const slotsOf = new Map(ready.map((b) => [b.id, b.rig.slots.map((s) => s.name)]));
|
|
887
|
+
const explicit = new Map<string, number>();
|
|
888
|
+
const idAt = new Map<string, number>();
|
|
889
|
+
scene.order.forEach((e, i) => {
|
|
890
|
+
const at = `scene.order[${i}]`;
|
|
891
|
+
const cut = e.indexOf(PREFIX_SEP);
|
|
892
|
+
if (cut < 0) {
|
|
893
|
+
if (!ids.includes(e)) {
|
|
894
|
+
fail('SCENE_ORDER_RESOLVES', at, `is "${e}", which names no character; the ids are ${ids.map((x) => `"${x}"`).join(', ')}, and a slot is written "<id>${PREFIX_SEP}<slot>"`);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const first = idAt.get(e);
|
|
898
|
+
if (first !== undefined) fail('SCENE_ORDER_ONCE', at, `names the character "${e}" again (first at scene.order[${first}]); its slots would be drawn twice — each slot is drawn exactly once`);
|
|
899
|
+
else idAt.set(e, i);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const id = e.slice(0, cut);
|
|
903
|
+
const slot = e.slice(cut + 1);
|
|
904
|
+
if (!ids.includes(id)) {
|
|
905
|
+
fail('SCENE_ORDER_RESOLVES', at, `is "${e}", whose id "${id}" names no character; the ids are ${ids.map((x) => `"${x}"`).join(', ')}`);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const own = slotsOf.get(id);
|
|
909
|
+
if (own === undefined) return;
|
|
910
|
+
if (!own.includes(slot)) {
|
|
911
|
+
fail('SCENE_ORDER_RESOLVES', at, `is "${e}"; character "${id}" has no slot "${slot}" — its slots are ${own.map((s) => `"${s}"`).join(', ')}`);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const first = explicit.get(e);
|
|
915
|
+
if (first !== undefined) fail('SCENE_ORDER_ONCE', at, `names the slot "${e}" again (first at scene.order[${first}]); each slot is drawn exactly once`);
|
|
916
|
+
else explicit.set(e, i);
|
|
917
|
+
});
|
|
918
|
+
for (const id of ids) {
|
|
919
|
+
const own = slotsOf.get(id);
|
|
920
|
+
if (own === undefined || idAt.has(id)) continue;
|
|
921
|
+
const missing = own.filter((s) => !explicit.has(prefixed(id, s)));
|
|
922
|
+
if (missing.length > 0) {
|
|
923
|
+
fail('SCENE_ORDER_COMPLETE', `scene.order (character "${id}")`, `names ${missing.length} of the ${own.length} slot(s) of "${id}" nowhere: ${missing.map((s) => `"${prefixed(id, s)}"`).join(', ')}; every slot is drawn exactly once — name "${id}" (its remaining slots, in its own order) or each slot`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
const drawn: string[] = [];
|
|
927
|
+
for (const e of scene.order) {
|
|
928
|
+
if (e.includes(PREFIX_SEP)) {
|
|
929
|
+
if (explicit.has(e)) drawn.push(e);
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
for (const s of slotsOf.get(e) ?? []) if (!explicit.has(prefixed(e, s))) drawn.push(prefixed(e, s));
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ---- names: within a character, every name its own ------------------------
|
|
936
|
+
const bonesOf = new Map(ready.map((b) => [b.id, new Set(b.rig.bones.map((x) => x.name))]));
|
|
937
|
+
const owners = (name: string, own: string): string[] => ready.filter((b) => b.id !== own && (bonesOf.get(b.id)?.has(name) ?? false)).map((b) => b.id);
|
|
938
|
+
const ownBone = (id: string, name: unknown, where: string): void => {
|
|
939
|
+
if (typeof name !== 'string') {
|
|
940
|
+
fail('SCENE_NAME_RESOLVES', where, `is ${show(name)}; a bone name is required`);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (bonesOf.get(id)?.has(name) ?? false) return;
|
|
944
|
+
const other = owners(name, id);
|
|
945
|
+
if (other.length > 0) {
|
|
946
|
+
fail('SCENE_NAME_PREFIXED', where, `names the bone "${name}", which character "${id}" does not declare and ${other.map((x) => `"${x}"`).join(', ')} does${other.length > 1 ? ' each' : ''}; a name crosses characters only with its prefix — ${other.map((x) => `"${prefixed(x, name)}"`).join(' or ')} — and a build's constraint may name only its own bones`);
|
|
947
|
+
} else fail('SCENE_NAME_RESOLVES', where, `names the bone "${name}", which no character of the scene declares`);
|
|
948
|
+
};
|
|
949
|
+
for (const b of ready) {
|
|
950
|
+
(b.rig.constraints ?? []).forEach((con, k) => {
|
|
951
|
+
const type = con.type as keyof typeof CONSTRAINT_BONE_FIELDS;
|
|
952
|
+
const where = `character "${b.id}" rig.json constraints[${k}] (${String(con.type)} "${String(con.name)}")`;
|
|
953
|
+
for (const [field, arity] of CONSTRAINT_BONE_FIELDS[type] ?? []) {
|
|
954
|
+
if (!(field in con)) continue;
|
|
955
|
+
const v = con[field];
|
|
956
|
+
if (arity === 'one') ownBone(b.id, v, `${where}.${field}`);
|
|
957
|
+
else if (Array.isArray(v)) v.forEach((n, j) => ownBone(b.id, n, `${where}.${field}[${j}]`));
|
|
958
|
+
else ownBone(b.id, v, `${where}.${field}`);
|
|
959
|
+
}
|
|
960
|
+
if (type === 'path' && typeof con.slot === 'string' && !(slotsOf.get(b.id) ?? []).includes(con.slot)) {
|
|
961
|
+
fail('SCENE_NAME_RESOLVES', `${where}.slot`, `names the slot "${con.slot}", which character "${b.id}" does not declare`);
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
for (const [k, d] of (b.rig.invariants?.detached ?? []).entries()) {
|
|
965
|
+
ownBone(b.id, d.bone, `character "${b.id}" rig.json invariants.detached[${k}].bone`);
|
|
966
|
+
ownBone(b.id, d.notUnder, `character "${b.id}" rig.json invariants.detached[${k}].notUnder`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
// The root is shared: a character that drives it would move every character.
|
|
970
|
+
for (const b of ready) {
|
|
971
|
+
const where = `character "${b.id}"`;
|
|
972
|
+
for (const [an, anim] of Object.entries(animationsOf(b.motion))) anim.tracks.forEach((t, k) => {
|
|
973
|
+
const tr = t as unknown as Record<string, unknown>;
|
|
974
|
+
const members = typeof tr.group === 'string' ? (b.motion.groups[tr.group] ?? []) : [];
|
|
975
|
+
if (tr.bone === ROOT_BONE || members.includes(ROOT_BONE)) {
|
|
976
|
+
fail('SCENE_ROOT_SHARED', `${where} motion.json animations.${an}.tracks[${k}]`, `keys "${ROOT_BONE}"${typeof tr.group === 'string' ? ` through the group "${tr.group}"` : ''} (${String(tr.property)}); the scene's root is shared by every character, so a key on it would move them all — key a bone of the character's own`);
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
(b.rig.constraints ?? []).forEach((con, k) => {
|
|
980
|
+
const named = (CONSTRAINT_BONE_FIELDS[con.type as keyof typeof CONSTRAINT_BONE_FIELDS] ?? []).flatMap(([field]) => {
|
|
981
|
+
const v = con[field];
|
|
982
|
+
return Array.isArray(v) ? v : [v];
|
|
983
|
+
});
|
|
984
|
+
if (named.includes(ROOT_BONE)) fail('SCENE_ROOT_SHARED', `${where} rig.json constraints[${k}] (${String(con.type)} "${String(con.name)}")`, `names "${ROOT_BONE}", which the scene shares by every character; a constraint on it, or following it, would reach every character — name a bone of the character's own`);
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
const whys = [...new Set(ready.map((b) => b.rig.invariants?.idleDrivesMeshes?.why).filter((w): w is string => w !== undefined))];
|
|
988
|
+
if (whys.length > 1) {
|
|
989
|
+
fail('SCENE_INVARIANT_AGREES', 'invariants.idleDrivesMeshes', `is declared with ${whys.length} different whys across the characters (${whys.map(show).join(', ')}); the composed rig declares it once, so one why is required`);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ---- image names ------------------------------------------------------------
|
|
993
|
+
const taken = new Map<string, string>();
|
|
994
|
+
if (scene.plate !== null) taken.set(PLATE_IMAGE, 'the plate');
|
|
995
|
+
for (const b of ready) {
|
|
996
|
+
for (const [file] of b.images) {
|
|
997
|
+
const n = imageName(b.id, file);
|
|
998
|
+
const who = taken.get(n);
|
|
999
|
+
if (who !== undefined) fail('SCENE_IMAGE_NAME_FREE', `character "${b.id}" image "${file}"`, `composes to the image "${n}", which ${who} already takes; rig-c names an atlas region by its PNG's basename, so two files may not share one — rename an id`);
|
|
1000
|
+
else taken.set(n, `character "${b.id}" image "${file}"`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (requirements !== null) problems.push(...requirementPrefixProblems(requirements, ready, scene.plate !== null));
|
|
1004
|
+
refuseIfAny(problems);
|
|
1005
|
+
|
|
1006
|
+
// ---- the rig ----------------------------------------------------------------
|
|
1007
|
+
const P = (id: string, n: string): string => (n === ROOT_BONE ? n : prefixed(id, n));
|
|
1008
|
+
// A first-level bone's x or y plus its character's shift, at the places the rig stage writes an offset to
|
|
1009
|
+
// (OFFSET_PLACES); a shift of 0 leaves the number as written, so a character at its own stage moves no byte.
|
|
1010
|
+
const shifted = (v: number, d: number): number => {
|
|
1011
|
+
if (d === 0) return v;
|
|
1012
|
+
const r = pyRound(v + d, OFFSET_PLACES);
|
|
1013
|
+
return r === 0 ? 0 : r;
|
|
1014
|
+
};
|
|
1015
|
+
// A point of root's own frame at a character's scale s (issue #197): s * v + d, rounded once; at s 1 exactly shifted().
|
|
1016
|
+
const placed = (v: number, d: number, s: number): number => {
|
|
1017
|
+
if (s === 1) return shifted(v, d);
|
|
1018
|
+
const r = pyRound(s * v + d, OFFSET_PLACES);
|
|
1019
|
+
return r === 0 ? 0 : r;
|
|
1020
|
+
};
|
|
1021
|
+
const bones: RigSpec['bones'] = [{ name: ROOT_BONE, x: 0, y: 0 }];
|
|
1022
|
+
const slotDefs = new Map<string, RigSpec['slots'][number]>();
|
|
1023
|
+
const skin: Record<string, Record<string, Attachment>> = {};
|
|
1024
|
+
const images: Array<[string, Uint8Array]> = [];
|
|
1025
|
+
if (scene.plate !== null && plate !== null) {
|
|
1026
|
+
bones.push({ name: PLATE, parent: ROOT_BONE, x: 0, y: 0 });
|
|
1027
|
+
// The image's centre, canvas (W/2, H/2), in the plate bone's frame (the root's, unturned).
|
|
1028
|
+
skin[PLATE] = { [PLATE]: { image: PLATE_IMAGE, x: stage.x + canvas.width / 2, y: cropToSpineY(canvas.height / 2, canvas.height) } };
|
|
1029
|
+
images.push([PLATE_IMAGE, plate.bytes]);
|
|
1030
|
+
}
|
|
1031
|
+
const constraints: Array<Record<string, unknown>> = [];
|
|
1032
|
+
const detached: NonNullable<NonNullable<RigSpec['invariants']>['detached']> = [];
|
|
1033
|
+
scene.characters.forEach((c) => {
|
|
1034
|
+
const b = byId.get(c.id) as CharacterBuild;
|
|
1035
|
+
const id = c.id;
|
|
1036
|
+
const s = c.scale;
|
|
1037
|
+
const [dx, dy] = rootShift(b.rig.skeleton, stage, c.offset, s);
|
|
1038
|
+
// At a scale other than 1 every length the character carries is scaled (issue #197): each bone's x, y and length,
|
|
1039
|
+
// each region's x, y (and its drawn size, by scaleX/scaleY), each weight's bind x, y, the constraints' lengths
|
|
1040
|
+
// (scaledConstraint) and the translate keys (scaledTrack); what sits in root's own frame is then shifted.
|
|
1041
|
+
for (const bone of b.rig.bones) {
|
|
1042
|
+
if (bone.name === ROOT_BONE) continue;
|
|
1043
|
+
const first = bone.parent === ROOT_BONE;
|
|
1044
|
+
const sized = s === 1 ? {} : { x: scaledBy(bone.x, s), y: scaledBy(bone.y, s), ...(bone.length === undefined ? {} : { length: scaledBy(bone.length, s) }) };
|
|
1045
|
+
bones.push({ ...bone, name: P(id, bone.name), ...(bone.parent === undefined ? {} : { parent: P(id, bone.parent) }), ...sized, ...(first ? { x: placed(bone.x, dx, s), y: placed(bone.y, dy, s) } : {}) });
|
|
1046
|
+
}
|
|
1047
|
+
for (const s of b.rig.slots) slotDefs.set(prefixed(id, s.name), { ...s, name: prefixed(id, s.name), bone: P(id, s.bone), attachment: prefixed(id, s.attachment) });
|
|
1048
|
+
// Everything the character places in root's own frame moves with it: a region on a slot root carries, and a
|
|
1049
|
+
// weight bound to root. Every other offset is local to a bone the shift already moved.
|
|
1050
|
+
const slotBone = new Map(b.rig.slots.map((s) => [s.name, s.bone]));
|
|
1051
|
+
for (const [slot, atts] of Object.entries(b.rig.skins.default)) {
|
|
1052
|
+
const out: Record<string, Attachment> = {};
|
|
1053
|
+
const onRoot = slotBone.get(slot) === ROOT_BONE;
|
|
1054
|
+
for (const [name, att] of Object.entries(atts as unknown as Record<string, Attachment>)) {
|
|
1055
|
+
const a: Attachment = { ...att, image: imageName(id, att.image as string) };
|
|
1056
|
+
if (Array.isArray(att.weights)) {
|
|
1057
|
+
a.weights = (att.weights as Array<Array<Record<string, unknown>>>).map((v) =>
|
|
1058
|
+
v.map((w) =>
|
|
1059
|
+
w.bone === ROOT_BONE
|
|
1060
|
+
? { ...w, x: placed(w.x as number, dx, s), y: placed(w.y as number, dy, s) }
|
|
1061
|
+
: { ...w, bone: P(id, w.bone as string), ...(s === 1 ? {} : { x: scaledBy(w.x as number, s), y: scaledBy(w.y as number, s) }) },
|
|
1062
|
+
),
|
|
1063
|
+
);
|
|
1064
|
+
} else if (onRoot) {
|
|
1065
|
+
a.x = placed(att.x as number, dx, s);
|
|
1066
|
+
a.y = placed(att.y as number, dy, s);
|
|
1067
|
+
} else if (s !== 1) {
|
|
1068
|
+
if (typeof att.x === 'number') a.x = scaledBy(att.x, s);
|
|
1069
|
+
if (typeof att.y === 'number') a.y = scaledBy(att.y, s);
|
|
1070
|
+
}
|
|
1071
|
+
// A region's drawn size: rigc reads its width and height off the PNG, so the scale rides on scaleX/scaleY, which
|
|
1072
|
+
// a build never writes (ATTACHMENT_KEYS); a mesh's width and height are its image's, nonessential, and stay.
|
|
1073
|
+
if (s !== 1 && !Array.isArray(att.weights)) {
|
|
1074
|
+
a.scaleX = s;
|
|
1075
|
+
a.scaleY = s;
|
|
1076
|
+
}
|
|
1077
|
+
out[prefixed(id, name)] = a;
|
|
1078
|
+
}
|
|
1079
|
+
skin[prefixed(id, slot)] = out;
|
|
1080
|
+
}
|
|
1081
|
+
for (const [file, bytes] of b.images) images.push([imageName(id, file), bytes]);
|
|
1082
|
+
for (const con of b.rig.constraints ?? []) {
|
|
1083
|
+
const type = con.type as keyof typeof CONSTRAINT_BONE_FIELDS;
|
|
1084
|
+
const out: Record<string, unknown> = { ...scaledConstraint(con as Record<string, unknown>, s), name: prefixed(id, String(con.name)) };
|
|
1085
|
+
for (const [field, arity] of CONSTRAINT_BONE_FIELDS[type] ?? []) {
|
|
1086
|
+
if (!(field in con)) continue;
|
|
1087
|
+
out[field] = arity === 'one' ? P(id, con[field] as string) : (con[field] as string[]).map((n) => P(id, n));
|
|
1088
|
+
}
|
|
1089
|
+
if (type === 'path' && typeof con.slot === 'string') out.slot = prefixed(id, con.slot);
|
|
1090
|
+
constraints.push(out);
|
|
1091
|
+
}
|
|
1092
|
+
for (const d of b.rig.invariants?.detached ?? []) detached.push({ ...d, bone: P(id, d.bone), notUnder: P(id, d.notUnder) });
|
|
1093
|
+
});
|
|
1094
|
+
const slots: RigSpec['slots'] = [];
|
|
1095
|
+
if (scene.plate !== null && plate !== null) slots.push({ name: PLATE, bone: PLATE, attachment: PLATE });
|
|
1096
|
+
for (const s of drawn) slots.push(slotDefs.get(s) as RigSpec['slots'][number]);
|
|
1097
|
+
|
|
1098
|
+
const name = ids.join('+');
|
|
1099
|
+
const rig: RigSpec = { spec: 'rigc-rig/1', name, images: 'images', skeleton: stage, bones, slots, skins: { default: skin as unknown as RigSpec['skins']['default'] } };
|
|
1100
|
+
if (constraints.length > 0) rig.constraints = constraints;
|
|
1101
|
+
const invariants: NonNullable<RigSpec['invariants']> = {};
|
|
1102
|
+
if (whys.length === 1) invariants.idleDrivesMeshes = { why: whys[0] };
|
|
1103
|
+
if (detached.length > 0) invariants.detached = detached;
|
|
1104
|
+
if (Object.keys(invariants).length > 0) rig.invariants = invariants;
|
|
1105
|
+
|
|
1106
|
+
// ---- the motion ---------------------------------------------------------------
|
|
1107
|
+
const easings: MotionSpec['easings'] = {};
|
|
1108
|
+
const groups: MotionSpec['groups'] = {};
|
|
1109
|
+
const tracks: MotionSpec['animations']['idle']['tracks'] = [];
|
|
1110
|
+
// Every animation beside a character's idle rides along under its character's prefix (issue #183), its tracks
|
|
1111
|
+
// prefixed as the idle's are and nothing else changed: the scene plays the idle, and schedules none of these.
|
|
1112
|
+
const beside: Record<string, SceneAnimation> = {};
|
|
1113
|
+
const notes = new Set(ready.map((b) => b.motion.animations.idle.note));
|
|
1114
|
+
for (const c of scene.characters) {
|
|
1115
|
+
const b = byId.get(c.id) as CharacterBuild;
|
|
1116
|
+
const id = c.id;
|
|
1117
|
+
for (const [k, v] of Object.entries(b.motion.easings)) easings[prefixed(id, k)] = v;
|
|
1118
|
+
for (const [k, v] of Object.entries(b.motion.groups)) groups[prefixed(id, k)] = v.map((n) => P(id, n));
|
|
1119
|
+
const prefixTrack = (t: MotionSpec['animations']['idle']['tracks'][number]): MotionSpec['animations']['idle']['tracks'][number] => {
|
|
1120
|
+
const tr = { ...t } as Record<string, unknown>;
|
|
1121
|
+
if (typeof tr.bone === 'string') tr.bone = P(id, tr.bone);
|
|
1122
|
+
if (typeof tr.group === 'string') tr.group = prefixed(id, tr.group);
|
|
1123
|
+
tr.keys = (t.keys as unknown as Array<Record<string, unknown>>).map((k) => (typeof k.ease === 'string' ? { ...k, ease: prefixed(id, k.ease) } : k));
|
|
1124
|
+
return tr as unknown as MotionSpec['animations']['idle']['tracks'][number];
|
|
1125
|
+
};
|
|
1126
|
+
for (const t of b.motion.animations.idle.tracks) tracks.push(scaledTrack(prefixTrack(t), c.scale));
|
|
1127
|
+
for (const [n, a] of Object.entries(animationsOf(b.motion))) if (n !== 'idle') beside[prefixed(id, n)] = { ...a, tracks: a.tracks.map((t) => scaledTrack(prefixTrack(t), c.scale)) };
|
|
1128
|
+
}
|
|
1129
|
+
const note = notes.size === 1 ? [...notes][0] : ready.map((b) => `${b.id}: ${b.motion.animations.idle.note}`).join(' | ');
|
|
1130
|
+
const motion: MotionSpec = { spec: 'rigc-motion/1', archetype: name, cut: name, easings, groups, animations: { idle: { duration: durations[0], loop: true, note, tracks }, ...beside } };
|
|
1131
|
+
|
|
1132
|
+
const report: SceneReport = {
|
|
1133
|
+
spec: SCENE_REPORT_SPEC,
|
|
1134
|
+
scene: scene.path,
|
|
1135
|
+
canvas: { ...canvas },
|
|
1136
|
+
rig_name: name,
|
|
1137
|
+
pack_mode: { page_edges: mode.pageEdges, pack_shape: mode.packShape },
|
|
1138
|
+
plate: scene.plate === null || plate === null ? null : { image: scene.plate.image, provenance: scene.plate.provenance, note: scene.plate.note, bone: PLATE, slot: PLATE, size: [plate.width, plate.height], judged_by: 'nothing' },
|
|
1139
|
+
characters: rows,
|
|
1140
|
+
order: { declared: [...scene.order], slots: slots.map((s) => s.name), judged_by: 'nothing' },
|
|
1141
|
+
idle_duration: durations[0],
|
|
1142
|
+
};
|
|
1143
|
+
return { rig, motion, images, report };
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// ---------------------------------------------------------------------------
|
|
1147
|
+
// requirements across characters
|
|
1148
|
+
// ---------------------------------------------------------------------------
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* `SCENE_NAME_PREFIXED` for a `--requirements` file read against the composed
|
|
1152
|
+
* rig: a bone, constraint, slot or attachment the file names that the composed
|
|
1153
|
+
* rig does not hold, but that some character holds under that bare name, is
|
|
1154
|
+
* refused naming the prefixed names it could have meant. Any other miss is
|
|
1155
|
+
* left to `check`'s own `REQUIREMENTS_RESOLVES`, which names it in its words.
|
|
1156
|
+
*/
|
|
1157
|
+
export function requirementPrefixProblems(file: RequirementsFile, builds: readonly CharacterBuild[], plate: boolean): Problem[] {
|
|
1158
|
+
const out: Problem[] = [];
|
|
1159
|
+
const bare = {
|
|
1160
|
+
bone: (b: CharacterBuild) => b.rig.bones.map((x) => x.name).filter((n) => n !== ROOT_BONE),
|
|
1161
|
+
constraint: (b: CharacterBuild) => (b.rig.constraints ?? []).map((c) => String(c.name)),
|
|
1162
|
+
slot: (b: CharacterBuild) => b.rig.slots.map((s) => s.name),
|
|
1163
|
+
attachment: (b: CharacterBuild) => Object.values(b.rig.skins.default).flatMap((a) => Object.keys(a)),
|
|
1164
|
+
} as const;
|
|
1165
|
+
// What the composed rig will hold, by kind: each character's names under its prefix, and the shared root and plate.
|
|
1166
|
+
const own = (kind: keyof typeof bare): Set<string> => new Set([...builds.flatMap((b) => bare[kind](b).map((n) => prefixed(b.id, n))), ...(kind === 'bone' ? [ROOT_BONE] : []), ...(plate && kind !== 'constraint' ? [PLATE] : [])]);
|
|
1167
|
+
const kinds = {
|
|
1168
|
+
bone: { have: own('bone'), bare: bare.bone },
|
|
1169
|
+
constraint: { have: own('constraint'), bare: bare.constraint },
|
|
1170
|
+
slot: { have: own('slot'), bare: bare.slot },
|
|
1171
|
+
attachment: { have: own('attachment'), bare: bare.attachment },
|
|
1172
|
+
} as const;
|
|
1173
|
+
const look = (kind: keyof typeof kinds, name: unknown, where: string): void => {
|
|
1174
|
+
if (typeof name !== 'string' || kinds[kind].have.has(name)) return;
|
|
1175
|
+
const who = builds.filter((b) => kinds[kind].bare(b).includes(name)).map((b) => b.id);
|
|
1176
|
+
if (who.length === 0) return;
|
|
1177
|
+
out.push({
|
|
1178
|
+
code: 'SCENE_NAME_PREFIXED',
|
|
1179
|
+
object: `${file.path} ${where}`,
|
|
1180
|
+
detail: `names the ${kind} "${name}", which the composed rig holds only with a character's prefix: ${who.map((x) => `"${prefixed(x, name)}"`).join(' or ')}; a name crosses characters only with its prefix`,
|
|
1181
|
+
});
|
|
1182
|
+
};
|
|
1183
|
+
file.targets.forEach((t, i) => look('bone', t.bone, `targets[${i}].bone`));
|
|
1184
|
+
for (const r of file.requirements) {
|
|
1185
|
+
const at = `requirement "${r.name}"`;
|
|
1186
|
+
if ('bone' in r) look('bone', r.bone, `${at} bone`);
|
|
1187
|
+
if ('target' in r && 'bone' in r.target) look('bone', r.target.bone, `${at} target.bone`);
|
|
1188
|
+
if (r.kind === 'follow') look('constraint', r.constraint, `${at} constraint`);
|
|
1189
|
+
if (r.kind === 'stretch') {
|
|
1190
|
+
look('slot', r.slot, `${at} slot`);
|
|
1191
|
+
look('attachment', r.attachment, `${at} attachment`);
|
|
1192
|
+
}
|
|
1193
|
+
if (r.kind === 'seam') {
|
|
1194
|
+
look('slot', r.part, `${at} part`);
|
|
1195
|
+
look('slot', r.neighbour, `${at} neighbour`);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return out;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// ---------------------------------------------------------------------------
|
|
1202
|
+
// what is left unprefixed
|
|
1203
|
+
// ---------------------------------------------------------------------------
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Every name in a composed rig and motion that is neither `root`, the plate's
|
|
1207
|
+
* (when `plate`), nor prefixed by one of `ids` — the grep the prefix-totality
|
|
1208
|
+
* control runs over what `compose` wrote. Each entry is `<where> "<name>"`.
|
|
1209
|
+
* Images are held to `<id>.` the same way.
|
|
1210
|
+
*/
|
|
1211
|
+
export function unprefixedNames(rig: RigSpec, motion: MotionSpec, ids: readonly string[], plate: boolean): string[] {
|
|
1212
|
+
const bad: string[] = [];
|
|
1213
|
+
const ok = (n: string, allowPlate: boolean): boolean => n === ROOT_BONE || (allowPlate && plate && n === PLATE) || ids.some((id) => n.startsWith(`${id}${PREFIX_SEP}`) && n.length > id.length + 1);
|
|
1214
|
+
const name = (where: string, n: unknown, allowPlate = true): void => {
|
|
1215
|
+
if (typeof n !== 'string' || !ok(n, allowPlate)) bad.push(`${where} ${show(n)}`);
|
|
1216
|
+
};
|
|
1217
|
+
for (const b of rig.bones) {
|
|
1218
|
+
name('bone', b.name);
|
|
1219
|
+
if (b.parent !== undefined) name(`bone ${b.name} parent`, b.parent);
|
|
1220
|
+
}
|
|
1221
|
+
for (const s of rig.slots) {
|
|
1222
|
+
name('slot', s.name);
|
|
1223
|
+
name(`slot ${s.name} bone`, s.bone);
|
|
1224
|
+
name(`slot ${s.name} attachment`, s.attachment);
|
|
1225
|
+
}
|
|
1226
|
+
for (const [slot, atts] of Object.entries(rig.skins.default)) {
|
|
1227
|
+
name('skin slot', slot);
|
|
1228
|
+
for (const [a, att] of Object.entries(atts)) {
|
|
1229
|
+
name(`skin ${slot} attachment`, a);
|
|
1230
|
+
const img = (att as unknown as Attachment).image;
|
|
1231
|
+
if (typeof img !== 'string' || !((plate && img === PLATE_IMAGE) || ids.some((id) => img.startsWith(`${id}${IMAGE_SEP}`)))) bad.push(`skin ${slot} ${a} image ${show(img)}`);
|
|
1232
|
+
const w = (att as unknown as Attachment).weights;
|
|
1233
|
+
if (Array.isArray(w)) for (const v of w as Array<Array<{ bone: string }>>) for (const e of v) name(`skin ${slot} ${a} weight bone`, e.bone, false);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
for (const c of rig.constraints ?? []) {
|
|
1237
|
+
name('constraint', c.name, false);
|
|
1238
|
+
for (const [field, arity] of CONSTRAINT_BONE_FIELDS[c.type as keyof typeof CONSTRAINT_BONE_FIELDS] ?? []) {
|
|
1239
|
+
const v = c[field];
|
|
1240
|
+
if (arity === 'one') name(`constraint ${String(c.name)} ${field}`, v, false);
|
|
1241
|
+
else if (Array.isArray(v)) for (const n of v) name(`constraint ${String(c.name)} ${field}`, n, false);
|
|
1242
|
+
}
|
|
1243
|
+
if (c.type === 'path') name(`constraint ${String(c.name)} slot`, c.slot, false);
|
|
1244
|
+
}
|
|
1245
|
+
for (const d of rig.invariants?.detached ?? []) {
|
|
1246
|
+
name('invariants.detached bone', d.bone, false);
|
|
1247
|
+
name('invariants.detached notUnder', d.notUnder, false);
|
|
1248
|
+
}
|
|
1249
|
+
for (const [k, v] of Object.entries(motion.easings)) name('easing', k, false);
|
|
1250
|
+
for (const [g, members] of Object.entries(motion.groups)) {
|
|
1251
|
+
name('group', g, false);
|
|
1252
|
+
for (const m of members) name(`group ${g} member`, m, false);
|
|
1253
|
+
}
|
|
1254
|
+
for (const t of Object.values(animationsOf(motion)).flatMap((a) => a.tracks)) {
|
|
1255
|
+
const tr = t as unknown as Record<string, unknown>;
|
|
1256
|
+
if ('bone' in tr) name('track bone', tr.bone, false);
|
|
1257
|
+
if ('group' in tr) name('track group', tr.group, false);
|
|
1258
|
+
for (const k of (tr.keys as Array<Record<string, unknown>>) ?? []) if (typeof k.ease === 'string') name('key ease', k.ease, false);
|
|
1259
|
+
}
|
|
1260
|
+
return bad;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// ---------------------------------------------------------------------------
|
|
1264
|
+
// the stage
|
|
1265
|
+
// ---------------------------------------------------------------------------
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Everything before the gate, from the files: the scene, every character's
|
|
1269
|
+
* build (with `accept`, `compose --accept`, issue #203), the plate and the
|
|
1270
|
+
* `--requirements` file, each read with its problems collected, then
|
|
1271
|
+
* {@link composeScene} — one refusal naming every problem the
|
|
1272
|
+
* files hold. Pure but for the reads.
|
|
1273
|
+
*/
|
|
1274
|
+
export function composeFromFiles(scenePath: string, requirementsPath?: string, mode: PackMode = { pageEdges: DEFAULT_PAGE_EDGES, packShape: DEFAULT_PACK_SHAPE }, accept: readonly string[] = []): ComposedScene {
|
|
1275
|
+
const scene = loadScene(scenePath);
|
|
1276
|
+
const problems: Problem[] = acceptProblems(accept, '--accept');
|
|
1277
|
+
const builds = scene.characters.map((c) => readCharacterBuild(c, problems, accept));
|
|
1278
|
+
let plate: PlateImage | null = null;
|
|
1279
|
+
if (scene.plate !== null) {
|
|
1280
|
+
const where = `scene.plate.image (${scene.plate.image})`;
|
|
1281
|
+
if (!existsSync(scene.plate.imagePath)) problems.push({ code: 'SCENE_PLATE_PRESENT', object: where, detail: `${scene.plate.imagePath} does not exist; a PNG of the canvas's size is required` });
|
|
1282
|
+
else {
|
|
1283
|
+
try {
|
|
1284
|
+
const r = readPng(scene.plate.imagePath);
|
|
1285
|
+
plate = { bytes: new Uint8Array(readFileSync(scene.plate.imagePath)), width: r.width, height: r.height };
|
|
1286
|
+
} catch (err) {
|
|
1287
|
+
problems.push({ code: 'SCENE_PLATE_PRESENT', object: where, detail: `${(err as Error).message}; a PNG is required` });
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
let req: RequirementsFile | null = null;
|
|
1292
|
+
if (requirementsPath !== undefined) {
|
|
1293
|
+
try {
|
|
1294
|
+
req = readRequirements(requirementsPath);
|
|
1295
|
+
} catch (err) {
|
|
1296
|
+
if (!(err instanceof PartsError)) throw err;
|
|
1297
|
+
problems.push(...err.problems);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
return composeScene(scene, builds, plate, problems, mode, req);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
export interface ComposeInput {
|
|
1304
|
+
scene: string;
|
|
1305
|
+
out: string;
|
|
1306
|
+
requirements?: string;
|
|
1307
|
+
/** `--accept`: the bars every character's own check may fail and still be composed (issue #203). */
|
|
1308
|
+
accept?: readonly string[];
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
export interface ComposeRunners {
|
|
1312
|
+
rig: RigcRunner;
|
|
1313
|
+
check: RigcRunner;
|
|
1314
|
+
checkBin: string;
|
|
1315
|
+
/** A directory the gate may stage in; emptied before and after. */
|
|
1316
|
+
scratch: string;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
export type ComposeStage = 'compose' | 'check';
|
|
1320
|
+
|
|
1321
|
+
export interface ComposeResult {
|
|
1322
|
+
stoppedAt: ComposeStage | null;
|
|
1323
|
+
composed: ComposedScene | null;
|
|
1324
|
+
check: CheckReport | null;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Load the scene and every build, compose, gate the result through rig-c
|
|
1329
|
+
* exactly as `rig` and `build` gate theirs ({@link gateThroughRigc}: `build
|
|
1330
|
+
* --profile spine-html --pack`, the compile and the packed pages on disk),
|
|
1331
|
+
* write `rig/` and `scene.json` only when that is green, then run `check` over
|
|
1332
|
+
* the composed rig (it reads a rig it did not build, #77; `--requirements`
|
|
1333
|
+
* forwarded, #93). Lines go to `log` under `[compose]` and `[check]`, as
|
|
1334
|
+
* `build` prints its stages. The paths {@link COMPOSE_OWNS} names are cleared
|
|
1335
|
+
* first. A crash that is not a refusal is rethrown.
|
|
1336
|
+
*/
|
|
1337
|
+
export function composeStage(input: ComposeInput, run: ComposeRunners, log: Log): ComposeResult {
|
|
1338
|
+
const prefix = (stage: ComposeStage): Log => (line) => log(`[${stage}] ${line}`);
|
|
1339
|
+
const say = prefix('compose');
|
|
1340
|
+
const refused = (stage: ComposeStage, err: unknown, composed: ComposedScene | null = null): ComposeResult => {
|
|
1341
|
+
if (!(err instanceof PartsError)) throw err;
|
|
1342
|
+
const p = prefix(stage);
|
|
1343
|
+
for (const x of err.problems) p(` FAIL ${problemLine(x)}`);
|
|
1344
|
+
p(`refused: ${err.problems.length} problem(s)`);
|
|
1345
|
+
log(`compose: stopped at ${stage}; nothing ${stage === 'compose' ? 'was written' : 'after the check was written'}`);
|
|
1346
|
+
return { stoppedAt: stage, composed, check: null };
|
|
1347
|
+
};
|
|
1348
|
+
mkdirSync(input.out, { recursive: true });
|
|
1349
|
+
for (const p of COMPOSE_OWNS) rmSync(join(input.out, p), { recursive: true, force: true });
|
|
1350
|
+
const mode: PackMode = { pageEdges: DEFAULT_PAGE_EDGES, packShape: DEFAULT_PACK_SHAPE };
|
|
1351
|
+
log(`rig-parts compose: ${input.scene} -> ${input.out}, page edges ${mode.pageEdges}, pack shape ${mode.packShape}`);
|
|
1352
|
+
let composed: ComposedScene;
|
|
1353
|
+
try {
|
|
1354
|
+
composed = composeFromFiles(input.scene, input.requirements, mode, input.accept ?? []);
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
return refused('compose', err);
|
|
1357
|
+
}
|
|
1358
|
+
const r = composed.report;
|
|
1359
|
+
r.scene = input.scene;
|
|
1360
|
+
say(`${r.characters.length} character(s) on a ${r.canvas.width}x${r.canvas.height} canvas, rig "${r.rig_name}"${r.plate === null ? ', no plate' : `, plate ${r.plate.image} (provenance ${r.plate.provenance}, judged by nothing)`}`);
|
|
1361
|
+
for (const c of r.characters) {
|
|
1362
|
+
say(` ${c.id}: ${c.build} at offset [${c.offset.join(', ')}], scale ${c.scale ?? 1}, bounds [${c.bounds.join(', ')}], shift [${c.shift.join(', ')}] on ${c.shifted.bones.length} first-level bone(s), ${c.shifted.root_regions.length} region(s) on root and ${c.shifted.root_weights} weight(s) bound to root; ${c.bones} bone(s), ${c.slots} slot(s), ${c.tracks} track(s), ${c.constraints} constraint(s), rig_scale ${c.rig_scale}, idle ${c.idle_duration} s${c.accepted === undefined ? '' : `; its own check FAILED ${c.accepted.join(', ')}, accepted — the scene is for looking, not shipping`}`);
|
|
1363
|
+
}
|
|
1364
|
+
say(` order (declared, judged by nothing): ${r.order.declared.join(', ')} -> ${r.order.slots.length} slot(s), back to front`);
|
|
1365
|
+
const texts: Array<[string, string]> = [
|
|
1366
|
+
['rig.json', rigJsonText(composed.rig)],
|
|
1367
|
+
['motion.json', rigJsonText(composed.motion)],
|
|
1368
|
+
];
|
|
1369
|
+
const gate = gateThroughRigc(composed.images, texts, run.rig, run.scratch, mode);
|
|
1370
|
+
for (const g of gate) {
|
|
1371
|
+
say(` rigc ${g.label}: exit ${g.status}`);
|
|
1372
|
+
for (const l of g.lines) say(` ${l.trim()}`);
|
|
1373
|
+
}
|
|
1374
|
+
const red = gate.filter((g) => g.status !== 0);
|
|
1375
|
+
if (red.length > 0) {
|
|
1376
|
+
return refused(
|
|
1377
|
+
'compose',
|
|
1378
|
+
new PartsError(
|
|
1379
|
+
red.map((g) => ({
|
|
1380
|
+
code: 'COMPOSE_RIGC_GREEN',
|
|
1381
|
+
object: `rigc ${g.label}`,
|
|
1382
|
+
detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ', and it printed nothing'}; exit 0 is required before anything is written, and nothing was`,
|
|
1383
|
+
})),
|
|
1384
|
+
),
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
const rigDir = join(input.out, 'rig');
|
|
1388
|
+
mkdirSync(join(rigDir, 'images'), { recursive: true });
|
|
1389
|
+
for (const [file, bytes] of composed.images) writeFileSync(join(rigDir, 'images', file), bytes);
|
|
1390
|
+
for (const [file, text] of texts) writeFileSync(join(rigDir, file), text);
|
|
1391
|
+
writeFileSync(join(input.out, SCENE_REPORT_FILE), rigJsonText(r));
|
|
1392
|
+
say(`wrote ${join(rigDir, 'rig.json')}, motion.json and ${composed.images.length} image(s) under ${join(rigDir, 'images')}, and ${join(input.out, SCENE_REPORT_FILE)}`);
|
|
1393
|
+
let report: CheckReport;
|
|
1394
|
+
try {
|
|
1395
|
+
report = checkStage({ rig: rigDir, out: join(input.out, 'check'), pageEdges: mode.pageEdges, packShape: mode.packShape, ...(input.requirements === undefined ? {} : { requirements: input.requirements }) }, run.check, run.checkBin, prefix('check'));
|
|
1396
|
+
} catch (err) {
|
|
1397
|
+
return refused('check', err, composed);
|
|
1398
|
+
}
|
|
1399
|
+
// Issue #203: a character taken with an accepted failing bar never makes the scene green, even where the composed
|
|
1400
|
+
// rig's check cannot measure that bar (no parts.json: the seam and four judgement lines say SKIP).
|
|
1401
|
+
const taken = r.characters.filter((c) => c.accepted !== undefined).map((c) => `"${c.id}" (${(c.accepted as string[]).join(', ')})`);
|
|
1402
|
+
const acceptedLine = taken.length === 0 ? '' : `; accepted failing bar(s) of ${taken.join(', ')} — not green, whatever the composed check reads`;
|
|
1403
|
+
if (!report.figures.PASS) {
|
|
1404
|
+
log(`compose: stopped at check; ${report.problems.length} bar(s) or requirement(s) not met (the composed rig and scene.json are written, as build writes its rig before its check)${acceptedLine}`);
|
|
1405
|
+
return { stoppedAt: 'check', composed, check: report };
|
|
1406
|
+
}
|
|
1407
|
+
if (taken.length > 0) {
|
|
1408
|
+
log(`compose: stopped at check; the composed rig's check is PASS${acceptedLine} (the composed rig and scene.json are written; the scene is for looking, not shipping)`);
|
|
1409
|
+
return { stoppedAt: 'check', composed, check: report };
|
|
1410
|
+
}
|
|
1411
|
+
log(`compose: PASS — ${report.bars.measured.length} of ${BARS.length} bar(s) measured; the packed atlas under ${join(input.out, 'check', 'build')} is the artifact`);
|
|
1412
|
+
return { stoppedAt: null, composed, check: report };
|
|
1413
|
+
}
|
|
1414
|
+
|