@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
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a scene requires of a rig's motion, declared in a file `check` is given
|
|
3
|
+
* (`--requirements`, issue #93, increment 2 of #87) and measured from the
|
|
4
|
+
* world transforms rig-c's `render --geometry` writes.
|
|
5
|
+
*
|
|
6
|
+
* ⭐ **The definitions are #87's, settled before this was written** (its
|
|
7
|
+
* comment "Settled before implementation"); nothing here chooses a bar, a
|
|
8
|
+
* default or a solver. Every bar is the author's, a missing one is refused by
|
|
9
|
+
* name, and every pose is rig-c's.
|
|
10
|
+
*
|
|
11
|
+
* Four outcomes, never merged:
|
|
12
|
+
*
|
|
13
|
+
* | outcome | when | what it does |
|
|
14
|
+
* | --- | --- | --- |
|
|
15
|
+
* | not declared | the file states no requirement of that kind | the summary names the kind; not a pass, not a skip |
|
|
16
|
+
* | refused | a bone, constraint, mesh or animation does not resolve, a field or bar is missing, a `follow` names a property its constraint does not drive | refused by name before anything is built ({@link readRequirements}, {@link resolveRequirements}) |
|
|
17
|
+
* | not measurable | it reads and the rig builds, and the quantity is undefined in the frames | `NOT MEASURABLE — <why>`, with the figures; the run does not PASS |
|
|
18
|
+
* | measured | otherwise | `PASS` or `FAIL` against the author's bar, with the figures and the worst frame |
|
|
19
|
+
*
|
|
20
|
+
* The five kinds and what each measures, per sampled frame of the named
|
|
21
|
+
* animation at the file's `fps` (Spine world units are stage px: the stage
|
|
22
|
+
* box maps to the world by a translation and the y flip alone, `src/coords.ts`):
|
|
23
|
+
*
|
|
24
|
+
* - `contact` — the distance between a bone's tip or origin and a target (a
|
|
25
|
+
* bone's tip or origin, or a stage point); the largest, against `within_px`.
|
|
26
|
+
* - `follow` — #87's three-pose measurement ({@link followLine}).
|
|
27
|
+
* - `aim` — the angle between a bone's axis and the line from its origin to
|
|
28
|
+
* a target; the largest, against `within_degrees`. The distance is not judged.
|
|
29
|
+
* - `range` — the bone's world rotation less its parent's (the world's, for the
|
|
30
|
+
* root), less the same at the setup pose, as a signed shortest angle; the
|
|
31
|
+
* least and the greatest, against `[lo_degrees, hi_degrees]`.
|
|
32
|
+
* - `stretch` — `TEXTURE_STRETCH`'s own per-mesh measure ({@link stretchFigures},
|
|
33
|
+
* imported, not rewritten): the mesh's worst max(ratio, 1/ratio) over the
|
|
34
|
+
* frames, against `within_ratio`.
|
|
35
|
+
* - `seam` (issue #111) — the seam between two parts' art: the pairs of
|
|
36
|
+
* 4-adjacent stage pixels where an art pixel of `part` meets an art pixel of
|
|
37
|
+
* `neighbour` that is not `part`'s ({@link seamPairs}, read off `parts.json`'s
|
|
38
|
+
* art at the setup pose), and per frame each pair's opening, the distance
|
|
39
|
+
* between the two sides' displacements from rest, each read off its
|
|
40
|
+
* attachment's rest and posed geometry ({@link seamLine}); the largest, its
|
|
41
|
+
* pair and its frame, against `within_px`.
|
|
42
|
+
*
|
|
43
|
+
* This module is pure: it reads the file, resolves it against the rig spec and
|
|
44
|
+
* motion it is handed, writes the throwaway copies' specs as values, and
|
|
45
|
+
* measures tracks it is handed. `src/check.ts` builds and renders the copies
|
|
46
|
+
* through rig-c and reads their `geometry.json`.
|
|
47
|
+
*/
|
|
48
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
49
|
+
import { resolve } from 'node:path';
|
|
50
|
+
import { CompileError } from 'rig-c/src/errors.ts';
|
|
51
|
+
import { splitRigSkin, type RigSkin } from 'rig-c/src/rig.ts';
|
|
52
|
+
import { cropToSpineY } from './coords.ts';
|
|
53
|
+
import type { Problem } from './errors.ts';
|
|
54
|
+
import { refuseIfAny } from './errors.ts';
|
|
55
|
+
import { type BoneWorld, type GeometryPose, type MeshRest, stretchFigures, stretchSeverity } from './instruments.ts';
|
|
56
|
+
import { ART_ALPHA } from './mesh.ts';
|
|
57
|
+
import { alphaAbove, type Raster } from './raster/types.ts';
|
|
58
|
+
|
|
59
|
+
/** The file's `spec`. */
|
|
60
|
+
export const REQUIREMENTS_SPEC = 'spine-parts-requirements/1';
|
|
61
|
+
|
|
62
|
+
/** The five kinds, in the order the summary names them. */
|
|
63
|
+
export const REQUIREMENT_KINDS = ['contact', 'follow', 'aim', 'range', 'stretch', 'seam'] as const;
|
|
64
|
+
export type RequirementKind = (typeof REQUIREMENT_KINDS)[number];
|
|
65
|
+
|
|
66
|
+
/** A line's status: the three outcomes a declared requirement can have once it is read. */
|
|
67
|
+
export type RequirementStatus = 'PASS' | 'FAIL' | 'NOT MEASURABLE';
|
|
68
|
+
|
|
69
|
+
/** Which point of a bone: its origin, or its tip (the origin plus `length` along the bone's x axis). */
|
|
70
|
+
export type BonePoint = 'origin' | 'tip';
|
|
71
|
+
|
|
72
|
+
/** What a contact or an aim is held against: a point of a bone, or a fixed stage point (stage px, y down). */
|
|
73
|
+
export type TargetRef = { bone: string; point: BonePoint } | { stage: [number, number] };
|
|
74
|
+
|
|
75
|
+
interface RequirementCommon {
|
|
76
|
+
name: string;
|
|
77
|
+
animation: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ContactRequirement extends RequirementCommon {
|
|
81
|
+
kind: 'contact';
|
|
82
|
+
bone: string;
|
|
83
|
+
point: BonePoint;
|
|
84
|
+
target: TargetRef;
|
|
85
|
+
within_px: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface FollowRequirement extends RequirementCommon {
|
|
89
|
+
kind: 'follow';
|
|
90
|
+
constraint: string;
|
|
91
|
+
constraint_type: 'ik' | 'transform';
|
|
92
|
+
bone: string;
|
|
93
|
+
property: 'rotate' | 'translate';
|
|
94
|
+
fraction: number;
|
|
95
|
+
tolerance: number;
|
|
96
|
+
/** In the property's unit: degrees for `rotate`, stage px for `translate`. */
|
|
97
|
+
least_drive: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface AimRequirement extends RequirementCommon {
|
|
101
|
+
kind: 'aim';
|
|
102
|
+
bone: string;
|
|
103
|
+
target: TargetRef;
|
|
104
|
+
within_degrees: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface RangeRequirement extends RequirementCommon {
|
|
108
|
+
kind: 'range';
|
|
109
|
+
bone: string;
|
|
110
|
+
lo_degrees: number;
|
|
111
|
+
hi_degrees: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface StretchRequirement extends RequirementCommon {
|
|
115
|
+
kind: 'stretch';
|
|
116
|
+
slot: string;
|
|
117
|
+
attachment: string;
|
|
118
|
+
within_ratio: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Issue #111: the seam between two parts' art stays within `within_px` over the animation ({@link seamLine}). `part` and `neighbour` are part names, which are their slots' names. */
|
|
122
|
+
export interface SeamRequirement extends RequirementCommon {
|
|
123
|
+
kind: 'seam';
|
|
124
|
+
part: string;
|
|
125
|
+
neighbour: string;
|
|
126
|
+
within_px: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type Requirement = ContactRequirement | FollowRequirement | AimRequirement | RangeRequirement | StretchRequirement | SeamRequirement;
|
|
130
|
+
|
|
131
|
+
/** One stage point of a scene target, at a time in seconds (null for a fixed point). */
|
|
132
|
+
export interface TargetPoint {
|
|
133
|
+
t: number | null;
|
|
134
|
+
x: number;
|
|
135
|
+
y: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A scene target: a root-parented bone placed at a stage point, or at stage points at stated times, while one animation is measured. */
|
|
139
|
+
export interface SceneTarget {
|
|
140
|
+
bone: string;
|
|
141
|
+
animation: string;
|
|
142
|
+
points: TargetPoint[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface RequirementsFile {
|
|
146
|
+
path: string;
|
|
147
|
+
fps: number;
|
|
148
|
+
targets: SceneTarget[];
|
|
149
|
+
requirements: Requirement[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** What `check --requirements` and `build --requirements` name, said once for the help and the refusals. */
|
|
153
|
+
export const REQUIREMENTS_SENTENCE = `--requirements names a ${REQUIREMENTS_SPEC} file: the scene's declared requirements of the rig's motion (contact, follow, aim, range, stretch, seam), each with its animation and the author's own bar, measured from rig-c's render --geometry at the file's fps`;
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// reading the file
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
160
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function finite(v: unknown): v is number {
|
|
164
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** An annotation (`note`, `*_note`, a string) or a project's record (`x-…`, any value): read by nothing, as in the config. */
|
|
168
|
+
function freeKey(key: string, value: unknown): boolean {
|
|
169
|
+
if (key.length > 2 && key.startsWith('x-')) return true;
|
|
170
|
+
return (key === 'note' || key.endsWith('_note')) && typeof value === 'string';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The fields each kind declares, in the order they are read; every one is required. */
|
|
174
|
+
const KIND_FIELDS: Record<RequirementKind, readonly string[]> = {
|
|
175
|
+
contact: ['bone', 'point', 'target', 'within_px'],
|
|
176
|
+
follow: ['constraint', 'constraint_type', 'bone', 'property', 'fraction', 'tolerance', 'least_drive'],
|
|
177
|
+
aim: ['bone', 'target', 'within_degrees'],
|
|
178
|
+
range: ['bone', 'lo_degrees', 'hi_degrees'],
|
|
179
|
+
stretch: ['slot', 'attachment', 'within_ratio'],
|
|
180
|
+
seam: ['part', 'neighbour', 'within_px'],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Read and shape-check a requirements file. Every problem is collected before
|
|
187
|
+
* one refusal: the file, `spec`, `fps` (required, a positive number: no
|
|
188
|
+
* default), `targets` (optional) and `requirements` (required, possibly empty),
|
|
189
|
+
* each entry's every field and bar by name, a key the format does not read,
|
|
190
|
+
* and a name used twice. Resolving the names against the rig is
|
|
191
|
+
* {@link resolveRequirements}'s.
|
|
192
|
+
*/
|
|
193
|
+
export function readRequirements(path: string): RequirementsFile {
|
|
194
|
+
const at = resolve(path);
|
|
195
|
+
const problems: Problem[] = [];
|
|
196
|
+
const fileProblem = (detail: string): never => {
|
|
197
|
+
refuseIfAny([{ code: 'REQUIREMENTS_FILE', object: at, detail }]);
|
|
198
|
+
throw new Error('unreachable');
|
|
199
|
+
};
|
|
200
|
+
if (!existsSync(at) || !statSync(at).isFile()) return fileProblem(`no such file; ${REQUIREMENTS_SENTENCE}`);
|
|
201
|
+
let raw: unknown;
|
|
202
|
+
try {
|
|
203
|
+
raw = JSON.parse(readFileSync(at, 'utf8'));
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return fileProblem(`does not parse as JSON: ${(err as Error).message}`);
|
|
206
|
+
}
|
|
207
|
+
if (!isRecord(raw)) return fileProblem(`holds ${JSON.stringify(raw)?.slice(0, 40)}; an object is required`);
|
|
208
|
+
const field = (object: string, detail: string): void => {
|
|
209
|
+
problems.push({ code: 'REQUIREMENTS_FIELD', object, detail });
|
|
210
|
+
};
|
|
211
|
+
if (raw.spec !== REQUIREMENTS_SPEC) field(`${at} field "spec"`, `is ${JSON.stringify(raw.spec)}; ${JSON.stringify(REQUIREMENTS_SPEC)} is required`);
|
|
212
|
+
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements; note or *_note as a string; x-<name> as a record)');
|
|
213
|
+
let fps = NaN;
|
|
214
|
+
if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
|
|
215
|
+
else fps = raw.fps;
|
|
216
|
+
|
|
217
|
+
const requirements: Requirement[] = [];
|
|
218
|
+
if (!Array.isArray(raw.requirements)) field(`${at} field "requirements"`, `is ${JSON.stringify(raw.requirements)}; an array of requirements is required (an empty one declares none)`);
|
|
219
|
+
else {
|
|
220
|
+
const seen = new Set<string>();
|
|
221
|
+
raw.requirements.forEach((r, i) => {
|
|
222
|
+
const where = `requirements[${i}]`;
|
|
223
|
+
if (!isRecord(r)) {
|
|
224
|
+
field(where, `is ${JSON.stringify(r)?.slice(0, 40)}; an object is required`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const name = typeof r.name === 'string' && NAME.test(r.name) ? r.name : null;
|
|
228
|
+
const label = name === null ? where : `${where} "${name}"`;
|
|
229
|
+
if (name === null) field(`${where} field "name"`, `is ${JSON.stringify(r.name)}; a name of letters, digits and underscores, starting with a letter, is required — it names the line`);
|
|
230
|
+
else if (seen.has(name)) field(`${where} field "name"`, `"${name}" is used by an earlier requirement; each line is named once`);
|
|
231
|
+
else seen.add(name);
|
|
232
|
+
const kind = (REQUIREMENT_KINDS as readonly string[]).includes(r.kind as string) ? (r.kind as RequirementKind) : null;
|
|
233
|
+
if (kind === null) field(`${label} field "kind"`, `is ${JSON.stringify(r.kind)}; one of ${REQUIREMENT_KINDS.join(', ')} is required`);
|
|
234
|
+
if (typeof r.animation !== 'string' || r.animation.length === 0) field(`${label} field "animation"`, `is ${JSON.stringify(r.animation)}; the name of the animation it is measured over is required`);
|
|
235
|
+
if (kind === null) return;
|
|
236
|
+
const known = ['name', 'kind', 'animation', ...KIND_FIELDS[kind]];
|
|
237
|
+
for (const k of Object.keys(r)) if (!known.includes(k) && !freeKey(k, r[k])) field(`${label} field "${k}"`, `is not a field a ${kind} requirement reads (${KIND_FIELDS[kind].join(', ')})`);
|
|
238
|
+
const before = problems.length;
|
|
239
|
+
const str = (k: string): string => {
|
|
240
|
+
const v = r[k];
|
|
241
|
+
if (typeof v !== 'string' || v.length === 0) field(`${label} field "${k}"`, `is ${JSON.stringify(v)}; a name is required`);
|
|
242
|
+
return typeof v === 'string' ? v : '';
|
|
243
|
+
};
|
|
244
|
+
const bar = (k: string, rule: (v: number) => boolean, what: string): number => {
|
|
245
|
+
const v = r[k];
|
|
246
|
+
if (!finite(v) || !rule(v)) field(`${label} field "${k}"`, `is ${JSON.stringify(v)}; ${what} is required — the bar is the author's, and there is no default`);
|
|
247
|
+
return finite(v) ? v : NaN;
|
|
248
|
+
};
|
|
249
|
+
const point = (k: string, v: unknown): BonePoint => {
|
|
250
|
+
if (v !== 'origin' && v !== 'tip') field(`${label} field "${k}"`, `is ${JSON.stringify(v)}; "origin" or "tip" is required`);
|
|
251
|
+
return v === 'tip' ? 'tip' : 'origin';
|
|
252
|
+
};
|
|
253
|
+
const target = (): TargetRef => {
|
|
254
|
+
const t = r.target;
|
|
255
|
+
if (isRecord(t) && 'stage' in t && !('bone' in t)) {
|
|
256
|
+
const s = t.stage;
|
|
257
|
+
if (!(Array.isArray(s) && s.length === 2 && s.every(finite)) || Object.keys(t).length !== 1) field(`${label} field "target"`, `is ${JSON.stringify(t)}; { "stage": [x, y] } in stage px (y down) is required for a stage point`);
|
|
258
|
+
return { stage: Array.isArray(s) && s.length === 2 && s.every(finite) ? [s[0] as number, s[1] as number] : [NaN, NaN] };
|
|
259
|
+
}
|
|
260
|
+
if (isRecord(t) && typeof t.bone === 'string' && t.bone.length > 0 && Object.keys(t).every((k) => k === 'bone' || k === 'point')) return { bone: t.bone, point: point('target.point', t.point) };
|
|
261
|
+
field(`${label} field "target"`, `is ${JSON.stringify(t)}; { "bone": <name>, "point": "origin" | "tip" } or { "stage": [x, y] } is required`);
|
|
262
|
+
return { stage: [NaN, NaN] };
|
|
263
|
+
};
|
|
264
|
+
const common = { name: name ?? '', animation: typeof r.animation === 'string' ? r.animation : '' };
|
|
265
|
+
let req: Requirement;
|
|
266
|
+
if (kind === 'contact') {
|
|
267
|
+
req = { ...common, kind, bone: str('bone'), point: point('point', r.point), target: target(), within_px: bar('within_px', (v) => v >= 0, 'a distance in stage px, 0 or more,') };
|
|
268
|
+
} else if (kind === 'follow') {
|
|
269
|
+
const type = r.constraint_type;
|
|
270
|
+
if (type !== 'ik' && type !== 'transform') field(`${label} field "constraint_type"`, `is ${JSON.stringify(type)}; "ik" or "transform" is required — a constraint resolves by name AND type, as in rig-c`);
|
|
271
|
+
const prop = r.property;
|
|
272
|
+
if (prop !== 'rotate' && prop !== 'translate') field(`${label} field "property"`, `is ${JSON.stringify(prop)}; "rotate" or "translate" is required`);
|
|
273
|
+
req = {
|
|
274
|
+
...common,
|
|
275
|
+
kind,
|
|
276
|
+
constraint: str('constraint'),
|
|
277
|
+
constraint_type: type === 'transform' ? 'transform' : 'ik',
|
|
278
|
+
bone: str('bone'),
|
|
279
|
+
property: prop === 'translate' ? 'translate' : 'rotate',
|
|
280
|
+
fraction: bar('fraction', () => true, 'a number, the fraction of the drive the bone takes,'),
|
|
281
|
+
tolerance: bar('tolerance', (v) => v >= 0, 'a number 0 or more'),
|
|
282
|
+
least_drive: bar('least_drive', (v) => v > 0, 'a number above 0 (degrees for rotate, stage px for translate): at 0 a frame where the constraint asks nothing would count'),
|
|
283
|
+
};
|
|
284
|
+
} else if (kind === 'aim') {
|
|
285
|
+
req = { ...common, kind, bone: str('bone'), target: target(), within_degrees: bar('within_degrees', (v) => v >= 0 && v <= 180, 'an angle in degrees from 0 to 180') };
|
|
286
|
+
} else if (kind === 'range') {
|
|
287
|
+
const lo = bar('lo_degrees', () => true, 'an angle in degrees');
|
|
288
|
+
const hi = bar('hi_degrees', () => true, 'an angle in degrees');
|
|
289
|
+
if (finite(lo) && finite(hi) && lo > hi) field(`${label} fields "lo_degrees", "hi_degrees"`, `are ${lo} and ${hi}; lo_degrees <= hi_degrees is required`);
|
|
290
|
+
req = { ...common, kind, bone: str('bone'), lo_degrees: lo, hi_degrees: hi };
|
|
291
|
+
} else if (kind === 'stretch') {
|
|
292
|
+
req = { ...common, kind, slot: str('slot'), attachment: str('attachment'), within_ratio: bar('within_ratio', (v) => v >= 1, 'a ratio 1 or more (the figure is max(ratio, 1/ratio), never below 1)') };
|
|
293
|
+
} else {
|
|
294
|
+
const part = str('part');
|
|
295
|
+
const neighbour = str('neighbour');
|
|
296
|
+
if (part.length > 0 && part === neighbour) field(`${label} fields "part", "neighbour"`, `both name "${part}"; two different parts are required — a seam is where one part's art meets another's`);
|
|
297
|
+
req = { ...common, kind, part, neighbour, within_px: bar('within_px', (v) => v >= 0, 'a distance in stage px, 0 or more,') };
|
|
298
|
+
}
|
|
299
|
+
if (problems.length === before && name !== null) requirements.push(req);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const targets: SceneTarget[] = [];
|
|
304
|
+
if (raw.targets !== undefined) {
|
|
305
|
+
if (!Array.isArray(raw.targets)) field(`${at} field "targets"`, `is ${JSON.stringify(raw.targets)}; an array of scene targets is required when the field is given`);
|
|
306
|
+
else {
|
|
307
|
+
raw.targets.forEach((t, i) => {
|
|
308
|
+
const where = `targets[${i}]`;
|
|
309
|
+
if (!isRecord(t)) {
|
|
310
|
+
field(where, `is ${JSON.stringify(t)?.slice(0, 40)}; an object is required`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const before = problems.length;
|
|
314
|
+
for (const k of Object.keys(t)) if (!['bone', 'animation', 'at', 'keys'].includes(k) && !freeKey(k, t[k])) field(`${where} field "${k}"`, 'is not a field a scene target reads (bone, animation, and at or keys)');
|
|
315
|
+
if (typeof t.bone !== 'string' || t.bone.length === 0) field(`${where} field "bone"`, `is ${JSON.stringify(t.bone)}; the name of a bone whose parent is the root is required`);
|
|
316
|
+
if (typeof t.animation !== 'string' || t.animation.length === 0) field(`${where} field "animation"`, `is ${JSON.stringify(t.animation)}; the animation it is placed for is required`);
|
|
317
|
+
const pair = (v: unknown): v is [number, number] => Array.isArray(v) && v.length === 2 && v.every(finite);
|
|
318
|
+
const points: TargetPoint[] = [];
|
|
319
|
+
if (('at' in t) === ('keys' in t)) field(where, `has ${'at' in t ? 'both "at" and "keys"' : 'neither "at" nor "keys"'}; exactly one is required — "at": [x, y], a fixed stage point, or "keys": [{ "t": <s>, "at": [x, y] }, …]`);
|
|
320
|
+
else if ('at' in t) {
|
|
321
|
+
if (!pair(t.at)) field(`${where} field "at"`, `is ${JSON.stringify(t.at)}; [x, y] in stage px (y down) is required`);
|
|
322
|
+
else points.push({ t: null, x: t.at[0], y: t.at[1] });
|
|
323
|
+
} else if (!Array.isArray(t.keys) || t.keys.length === 0) field(`${where} field "keys"`, `is ${JSON.stringify(t.keys)}; at least one { "t": <s>, "at": [x, y] } is required`);
|
|
324
|
+
else {
|
|
325
|
+
t.keys.forEach((k, j) => {
|
|
326
|
+
if (!isRecord(k) || !finite(k.t) || !pair(k.at) || Object.keys(k).length !== 2) {
|
|
327
|
+
field(`${where} field "keys[${j}]"`, `is ${JSON.stringify(k)}; { "t": <seconds>, "at": [x, y] } is required`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const prev = points[points.length - 1];
|
|
331
|
+
if (prev !== undefined && !((k.t as number) > (prev.t as number))) field(`${where} field "keys[${j}].t"`, `is ${k.t}, not after the key before it (${prev.t}); times strictly increasing are required`);
|
|
332
|
+
points.push({ t: k.t as number, x: k.at[0], y: k.at[1] });
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
if (problems.length === before) targets.push({ bone: t.bone as string, animation: t.animation as string, points });
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
refuseIfAny(problems);
|
|
340
|
+
return { path: at, fps, targets, requirements };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// resolving against the rig
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
/** A file-system-safe animation name: it names a directory under `<out>/requirements/`. */
|
|
348
|
+
function safeDirName(n: string): boolean {
|
|
349
|
+
return n.length > 0 && n !== '.' && n !== '..' && !/[/\\\0]/.test(n);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function bonesOf(rig: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
353
|
+
return (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function constraintsOf(rig: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
357
|
+
return (Array.isArray(rig.constraints) ? rig.constraints : []).filter(isRecord);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function animationsOf(motion: Record<string, unknown>): Record<string, unknown> {
|
|
361
|
+
return isRecord(motion.animations) ? motion.animations : {};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The `to` property names a transform constraint declares, over every `properties` entry. */
|
|
365
|
+
function transformDrives(c: Record<string, unknown>): Set<string> {
|
|
366
|
+
const out = new Set<string>();
|
|
367
|
+
if (isRecord(c.properties)) for (const p of Object.values(c.properties)) if (isRecord(p) && isRecord(p.to)) for (const k of Object.keys(p.to)) out.add(k);
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The property names a bone track moves the bone's translation by. */
|
|
372
|
+
const TRANSLATE_PROPERTIES = ['translate', 'translatex', 'translatey'];
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Resolve every name the file uses against the rig spec and its motion,
|
|
376
|
+
* collecting every problem before one refusal — this runs before anything is
|
|
377
|
+
* built. Refused, by name: an animation the motion does not declare (or whose
|
|
378
|
+
* name cannot be a directory); a bone, a constraint of the declared type, a
|
|
379
|
+
* slot or a mesh attachment the rig does not declare; a `follow` whose bone the
|
|
380
|
+
* constraint does not constrain or whose property it does not drive (an `ik`
|
|
381
|
+
* drives `rotate` only; a `transform` drives `rotate` when a `to` names
|
|
382
|
+
* `rotate`, `translate` when one names `x` or `y`); a scene target whose bone
|
|
383
|
+
* is the root or is not parented to it (a stage point is not a local offset
|
|
384
|
+
* under a moving parent), whose root is not at rest at the origin or is keyed
|
|
385
|
+
* in that animation, whose bone a group translates there, whose keys fall
|
|
386
|
+
* outside the animation, that names a bone and animation twice, or that no
|
|
387
|
+
* requirement measures.
|
|
388
|
+
*/
|
|
389
|
+
export function resolveRequirements(file: RequirementsFile, rig: Record<string, unknown>, motion: Record<string, unknown>, rootBone: string): void {
|
|
390
|
+
const problems: Problem[] = [];
|
|
391
|
+
const unresolved = (object: string, detail: string): void => {
|
|
392
|
+
problems.push({ code: 'REQUIREMENTS_RESOLVES', object, detail });
|
|
393
|
+
};
|
|
394
|
+
const bones = bonesOf(rig);
|
|
395
|
+
const boneNames = bones.map((b) => b.name).filter((n): n is string => typeof n === 'string');
|
|
396
|
+
const anims = animationsOf(motion);
|
|
397
|
+
const animNames = Object.keys(anims);
|
|
398
|
+
const listed = (xs: readonly string[]): string => (xs.length === 0 ? 'none' : xs.map((x) => `"${x}"`).join(', '));
|
|
399
|
+
const animation = (object: string, a: string): boolean => {
|
|
400
|
+
if (!(a in anims) || !isRecord(anims[a])) {
|
|
401
|
+
unresolved(object, `names animation "${a}", which motion.json does not declare; it declares ${listed(animNames)}`);
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
if (!safeDirName(a)) {
|
|
405
|
+
unresolved(object, `names animation ${JSON.stringify(a)}, whose name cannot name the directory its frames are written into (no "/", "\\", ".", "..")`);
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
return true;
|
|
409
|
+
};
|
|
410
|
+
const bone = (object: string, b: string): boolean => {
|
|
411
|
+
if (boneNames.includes(b)) return true;
|
|
412
|
+
unresolved(object, `names bone "${b}", which rig.json does not declare; it declares ${listed(boneNames)}`);
|
|
413
|
+
return false;
|
|
414
|
+
};
|
|
415
|
+
const target = (object: string, t: TargetRef): void => {
|
|
416
|
+
if ('bone' in t) bone(`${object} target`, t.bone);
|
|
417
|
+
};
|
|
418
|
+
for (const r of file.requirements) {
|
|
419
|
+
const object = `requirement "${r.name}" (${r.kind})`;
|
|
420
|
+
animation(object, r.animation);
|
|
421
|
+
if (r.kind === 'contact') {
|
|
422
|
+
bone(object, r.bone);
|
|
423
|
+
target(object, r.target);
|
|
424
|
+
} else if (r.kind === 'aim') {
|
|
425
|
+
bone(object, r.bone);
|
|
426
|
+
target(object, r.target);
|
|
427
|
+
} else if (r.kind === 'range') bone(object, r.bone);
|
|
428
|
+
else if (r.kind === 'seam') {
|
|
429
|
+
const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => s.name);
|
|
430
|
+
for (const [field, name] of [['part', r.part], ['neighbour', r.neighbour]] as const) {
|
|
431
|
+
if (!slots.includes(name)) unresolved(object, `names ${field} "${name}", which rig.json declares no slot for; a part is drawn by the slot of its name`);
|
|
432
|
+
}
|
|
433
|
+
} else if (r.kind === 'follow') {
|
|
434
|
+
bone(object, r.bone);
|
|
435
|
+
const c = constraintsOf(rig).find((x) => x.name === r.constraint && x.type === r.constraint_type);
|
|
436
|
+
if (c === undefined) {
|
|
437
|
+
const declared = constraintsOf(rig)
|
|
438
|
+
.filter((x) => x.type === 'ik' || x.type === 'transform')
|
|
439
|
+
.map((x) => `${String(x.type)} "${String(x.name)}"`);
|
|
440
|
+
unresolved(object, `names ${r.constraint_type} constraint "${r.constraint}", which rig.json does not declare; it declares ${declared.length === 0 ? 'no ik or transform constraint' : declared.join(', ')}`);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const constrained = (Array.isArray(c.bones) ? c.bones : []).filter((b): b is string => typeof b === 'string');
|
|
444
|
+
if (!constrained.includes(r.bone)) {
|
|
445
|
+
problems.push({ code: 'REQUIREMENTS_CONSTRAINT_DRIVES', object, detail: `names bone "${r.bone}", which ${r.constraint_type} constraint "${r.constraint}" does not constrain (its bones: ${listed(constrained)}); a follow measures what the constraint asks of one of its own bones` });
|
|
446
|
+
}
|
|
447
|
+
if (r.constraint_type === 'ik' && r.property !== 'rotate') {
|
|
448
|
+
problems.push({ code: 'REQUIREMENTS_CONSTRAINT_DRIVES', object, detail: `names property "${r.property}" of ik constraint "${r.constraint}"; an ik drives rotate only` });
|
|
449
|
+
}
|
|
450
|
+
if (r.constraint_type === 'transform') {
|
|
451
|
+
const drives = transformDrives(c);
|
|
452
|
+
const ok = r.property === 'rotate' ? drives.has('rotate') : drives.has('x') || drives.has('y');
|
|
453
|
+
if (!ok) {
|
|
454
|
+
problems.push({
|
|
455
|
+
code: 'REQUIREMENTS_CONSTRAINT_DRIVES',
|
|
456
|
+
object,
|
|
457
|
+
detail: `names property "${r.property}" of transform constraint "${r.constraint}", whose properties drive ${listed([...drives].sort())}; ${r.property === 'rotate' ? 'a "to" naming rotate' : 'a "to" naming x or y'} is required — a mix is read only for a property the constraint drives`,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => s.name);
|
|
463
|
+
if (!slots.includes(r.slot)) {
|
|
464
|
+
unresolved(object, `names slot "${r.slot}", which rig.json does not declare`);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const meshes = meshAttachmentsOf(rig, r.slot);
|
|
468
|
+
if (typeof meshes === 'string') problems.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: meshes });
|
|
469
|
+
else if (!meshes.includes(r.attachment)) unresolved(object, `names mesh attachment "${r.attachment}" on slot "${r.slot}", which no skin in rig.json declares; its mesh attachments are ${listed(meshes)}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const root = bones.find((b) => b.name === rootBone);
|
|
474
|
+
const measured = new Set(file.requirements.map((r) => r.animation));
|
|
475
|
+
const placed = new Set<string>();
|
|
476
|
+
file.targets.forEach((t, i) => {
|
|
477
|
+
const object = `targets[${i}] (bone "${t.bone}", animation "${t.animation}")`;
|
|
478
|
+
const okBone = bone(object, t.bone);
|
|
479
|
+
const okAnim = animation(object, t.animation);
|
|
480
|
+
const key = `${t.bone}\u0000${t.animation}`;
|
|
481
|
+
if (placed.has(key)) problems.push({ code: 'REQUIREMENTS_TARGET', object, detail: 'places the same bone for the same animation as an earlier target; one placement per bone and animation is required' });
|
|
482
|
+
placed.add(key);
|
|
483
|
+
if (okAnim && !measured.has(t.animation)) problems.push({ code: 'REQUIREMENTS_TARGET', object, detail: `no requirement is measured over animation "${t.animation}", so the placement would move nothing that is read` });
|
|
484
|
+
if (!okBone) return;
|
|
485
|
+
const b = bones.find((x) => x.name === t.bone) as Record<string, unknown>;
|
|
486
|
+
if (t.bone === rootBone) problems.push({ code: 'REQUIREMENTS_TARGET_PARENT', object, detail: `is the root; a scene target is a bone whose parent is the root "${rootBone}"` });
|
|
487
|
+
else if (b.parent !== rootBone) {
|
|
488
|
+
problems.push({ code: 'REQUIREMENTS_TARGET_PARENT', object, detail: `has parent ${JSON.stringify(b.parent)}; the root "${rootBone}" is required — a stage point is not a local offset under a moving parent` });
|
|
489
|
+
}
|
|
490
|
+
const rest: Record<string, number> = { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1, shearX: 0, shearY: 0 };
|
|
491
|
+
const off = root === undefined ? [] : Object.keys(rest).filter((k) => k in root && root[k] !== rest[k]);
|
|
492
|
+
if (off.length > 0) {
|
|
493
|
+
problems.push({ code: 'REQUIREMENTS_TARGET_PARENT', object, detail: `the root "${rootBone}" is not at rest at the origin (${off.map((k) => `${k} ${JSON.stringify(root?.[k])}`).join(', ')}); a stage point is placed as a root-local position only under a root at rest` });
|
|
494
|
+
}
|
|
495
|
+
if (!okAnim) return;
|
|
496
|
+
const a = anims[t.animation] as Record<string, unknown>;
|
|
497
|
+
const tracks = (Array.isArray(a.tracks) ? a.tracks : []).filter(isRecord);
|
|
498
|
+
if (tracks.some((tr) => tr.bone === rootBone)) problems.push({ code: 'REQUIREMENTS_TARGET_PARENT', object, detail: `animation "${t.animation}" keys the root "${rootBone}"; a stage point is placed as a root-local position only under a root the animation does not move` });
|
|
499
|
+
const groups = isRecord(motion.groups) ? motion.groups : {};
|
|
500
|
+
for (const tr of tracks) {
|
|
501
|
+
if (typeof tr.group !== 'string' || !TRANSLATE_PROPERTIES.includes(String(tr.property))) continue;
|
|
502
|
+
const members = groups[tr.group];
|
|
503
|
+
if (Array.isArray(members) && members.includes(t.bone)) {
|
|
504
|
+
problems.push({ code: 'REQUIREMENTS_TARGET', object, detail: `animation "${t.animation}" moves it by group "${tr.group}" ${String(tr.property)}; a placed target's translation is the scene's, so a group track over it is required not to key it` });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const duration = a.duration;
|
|
508
|
+
for (const p of t.points) {
|
|
509
|
+
if (p.t !== null && finite(duration) && (p.t < 0 || p.t > duration)) problems.push({ code: 'REQUIREMENTS_TARGET', object, detail: `has a key at t = ${p.t}s; animation "${t.animation}" runs from 0 to ${duration}s, and a key inside it is required` });
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
refuseIfAny(problems);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** The mesh attachment names a slot holds in any skin, through rig-c's own skin reader; a string (the refusal's detail) when a skin does not read. */
|
|
516
|
+
function meshAttachmentsOf(rig: Record<string, unknown>, slot: string): string[] | string {
|
|
517
|
+
const out = new Set<string>();
|
|
518
|
+
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
519
|
+
for (const [name, skin] of Object.entries(skins)) {
|
|
520
|
+
let atts: Record<string, Record<string, unknown>>;
|
|
521
|
+
try {
|
|
522
|
+
atts = splitRigSkin(skin as RigSkin, `rig.json skin "${name}"`).attachments as Record<string, Record<string, unknown>>;
|
|
523
|
+
} catch (err) {
|
|
524
|
+
if (err instanceof CompileError) return `rig-c's skin reader refuses rig.json skin "${name}": ${err.message}`;
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
const held = atts[slot];
|
|
528
|
+
if (!isRecord(held)) continue;
|
|
529
|
+
for (const [att, v] of Object.entries(held)) if (isRecord(v) && v.type === 'mesh') out.add(att);
|
|
530
|
+
}
|
|
531
|
+
return [...out].sort();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// the throwaway copies
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
function clone<T>(v: T): T {
|
|
539
|
+
return JSON.parse(JSON.stringify(v)) as T;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** A stage point (stage px, y down) as a Spine world point, through the stage box and the one y door. */
|
|
543
|
+
export function stageToWorld(p: readonly [number, number], stage: { x: number; y: number; height: number }): [number, number] {
|
|
544
|
+
return [stage.x + p[0], stage.y + cropToSpineY(p[1], stage.height)];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The rig spec and motion with each scene target of `animation` written in:
|
|
549
|
+
* the bone's setup position is its first point (a root at rest at the origin,
|
|
550
|
+
* which {@link resolveRequirements} holds, makes a root-local position the
|
|
551
|
+
* world point), every track of that animation that keys its translation
|
|
552
|
+
* directly is dropped, and, for timed points, one `translate` track keys every
|
|
553
|
+
* point relative to the first — linear between keys, and outside them the
|
|
554
|
+
* first point (the setup) before and the last after. The inputs are not
|
|
555
|
+
* altered.
|
|
556
|
+
*/
|
|
557
|
+
export function placeTargets(rig: Record<string, unknown>, motion: Record<string, unknown>, targets: readonly SceneTarget[], animation: string, stage: { x: number; y: number; height: number }): { rig: Record<string, unknown>; motion: Record<string, unknown> } {
|
|
558
|
+
const r = clone(rig);
|
|
559
|
+
const m = clone(motion);
|
|
560
|
+
const mine = targets.filter((t) => t.animation === animation);
|
|
561
|
+
if (mine.length === 0) return { rig: r, motion: m };
|
|
562
|
+
const a = animationsOf(m)[animation] as Record<string, unknown>;
|
|
563
|
+
let tracks = (Array.isArray(a.tracks) ? a.tracks : []).filter(isRecord);
|
|
564
|
+
for (const t of mine) {
|
|
565
|
+
const [x0, y0] = stageToWorld([t.points[0].x, t.points[0].y], stage);
|
|
566
|
+
const b = bonesOf(r).find((x) => x.name === t.bone) as Record<string, unknown>;
|
|
567
|
+
b.x = x0;
|
|
568
|
+
b.y = y0;
|
|
569
|
+
tracks = tracks.filter((tr) => !(tr.bone === t.bone && TRANSLATE_PROPERTIES.includes(String(tr.property))));
|
|
570
|
+
if (t.points[0].t !== null) {
|
|
571
|
+
tracks.push({
|
|
572
|
+
bone: t.bone,
|
|
573
|
+
property: 'translate',
|
|
574
|
+
keys: t.points.map((p) => {
|
|
575
|
+
const [x, y] = stageToWorld([p.x, p.y], stage);
|
|
576
|
+
return { t: p.t, v: [x - x0, y - y0] };
|
|
577
|
+
}),
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
a.tracks = tracks;
|
|
582
|
+
return { rig: r, motion: m };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** The mix fields of a follow's property and the curve channel each sits in: an ik's `mix` (channel 0); a transform's `mixRotate` (0), or `mixX` and `mixY` (1, 2). */
|
|
586
|
+
export function mixFields(f: Pick<FollowRequirement, 'constraint_type' | 'property'>): Array<{ field: string; channel: number }> {
|
|
587
|
+
if (f.constraint_type === 'ik') return [{ field: 'mix', channel: 0 }];
|
|
588
|
+
return f.property === 'rotate'
|
|
589
|
+
? [{ field: 'mixRotate', channel: 0 }]
|
|
590
|
+
: [
|
|
591
|
+
{ field: 'mixX', channel: 1 },
|
|
592
|
+
{ field: 'mixY', channel: 2 },
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* #87's released (`value` 0) or full (`value` 1) copy of a follow's rig and
|
|
598
|
+
* motion: the constraint's mix for that property forced to `value` in the rig
|
|
599
|
+
* spec, and every key of that mix in the animation replaced by `value`, its
|
|
600
|
+
* curve channel's two value numbers with it — a timeline that holds `value`
|
|
601
|
+
* throughout, which is what removing the keys under a setup mix of `value`
|
|
602
|
+
* poses, while every other field those keys state (softness, bend, the other
|
|
603
|
+
* mixes) stays as written, so the copy differs from the rig in that one mix
|
|
604
|
+
* only. Other animations are not touched. The full copy also drops the
|
|
605
|
+
* constraint from `invariants.consumerDrivenMix` if the rig declares it there:
|
|
606
|
+
* at 1 it rests live, and rig-c refuses a declaration that exempts nothing.
|
|
607
|
+
*/
|
|
608
|
+
export function forceMix(rig: Record<string, unknown>, motion: Record<string, unknown>, f: FollowRequirement, value: 0 | 1): { rig: Record<string, unknown>; motion: Record<string, unknown> } {
|
|
609
|
+
const r = clone(rig);
|
|
610
|
+
const m = clone(motion);
|
|
611
|
+
const fields = mixFields(f);
|
|
612
|
+
const c = constraintsOf(r).find((x) => x.name === f.constraint && x.type === f.constraint_type) as Record<string, unknown>;
|
|
613
|
+
for (const { field } of fields) c[field] = value;
|
|
614
|
+
const a = animationsOf(m)[f.animation];
|
|
615
|
+
if (isRecord(a)) {
|
|
616
|
+
const group = f.constraint_type === 'ik' ? a.ik : a.transform;
|
|
617
|
+
for (const track of (Array.isArray(group) ? group : []).filter(isRecord)) {
|
|
618
|
+
if (track.constraint !== f.constraint) continue;
|
|
619
|
+
for (const key of (Array.isArray(track.keys) ? track.keys : []).filter(isRecord)) {
|
|
620
|
+
for (const { field, channel } of fields) {
|
|
621
|
+
key[field] = value;
|
|
622
|
+
if (Array.isArray(key.curve)) {
|
|
623
|
+
if (4 * channel + 3 < key.curve.length) {
|
|
624
|
+
key.curve[4 * channel + 1] = value;
|
|
625
|
+
key.curve[4 * channel + 3] = value;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (value === 1 && isRecord(r.invariants) && Array.isArray(r.invariants.consumerDrivenMix)) {
|
|
633
|
+
const kept = r.invariants.consumerDrivenMix.filter((e) => !(isRecord(e) && e.constraint === f.constraint && e.type === f.constraint_type));
|
|
634
|
+
if (kept.length === 0) delete r.invariants.consumerDrivenMix;
|
|
635
|
+
else r.invariants.consumerDrivenMix = kept;
|
|
636
|
+
}
|
|
637
|
+
return { rig: r, motion: m };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Why the released copy declares its constraint consumer-driven, written into the copy's `invariants.consumerDrivenMix`. */
|
|
641
|
+
export function releasedWhy(f: FollowRequirement): string {
|
|
642
|
+
return `rig-parts check --requirements: the released pose of follow requirement "${f.name}" forces this mix to 0 on a throwaway copy; the rig under test is not altered`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* The released copy with its constraint declared consumer-driven — rig-c's
|
|
647
|
+
* door for a constraint muted throughout (`A47`/`A48`), which the released pose
|
|
648
|
+
* is by construction. Used only when rigc's gate refused the released copy for
|
|
649
|
+
* that constraint alone ({@link mutedOnly}).
|
|
650
|
+
*/
|
|
651
|
+
export function declareConsumerDriven(rig: Record<string, unknown>, f: FollowRequirement): Record<string, unknown> {
|
|
652
|
+
const r = clone(rig);
|
|
653
|
+
const inv = isRecord(r.invariants) ? r.invariants : {};
|
|
654
|
+
const list = Array.isArray(inv.consumerDrivenMix) ? inv.consumerDrivenMix : [];
|
|
655
|
+
r.invariants = { ...inv, consumerDrivenMix: [...list, { constraint: f.constraint, type: f.constraint_type, why: releasedWhy(f) }] };
|
|
656
|
+
return r;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** rig-c's rule a muted-throughout constraint of each type fails, and the words its line names the constraint with. */
|
|
660
|
+
export function mutedLine(f: Pick<FollowRequirement, 'constraint' | 'constraint_type'>): { rule: string; names: string } {
|
|
661
|
+
return f.constraint_type === 'ik'
|
|
662
|
+
? { rule: 'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT', names: `ik constraint "${f.constraint}" has mix` }
|
|
663
|
+
: { rule: 'A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT', names: `transform constraint "${f.constraint}" drives` };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Whether a red build's FAIL lines are all, and only, the muted-throughout refusal of the follow's own constraint. */
|
|
667
|
+
export function mutedOnly(failLines: readonly string[], f: Pick<FollowRequirement, 'constraint' | 'constraint_type'>): boolean {
|
|
668
|
+
const { rule, names } = mutedLine(f);
|
|
669
|
+
return failLines.length > 0 && failLines.every((l) => l.includes(`${rule}: ${names}`));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
// measuring
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
|
|
676
|
+
/** One posed render of an animation: each frame's index and time, and a bone's world transform by name (setup and per frame). */
|
|
677
|
+
export interface Poses {
|
|
678
|
+
/** What it is, for the lines: "as declared", "released", "full". */
|
|
679
|
+
label: string;
|
|
680
|
+
indices: number[];
|
|
681
|
+
times: number[];
|
|
682
|
+
bone: (name: string) => { setup: BoneWorld; frames: BoneWorld[] };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** What a measure reads beside the poses: each bone's `length` and parent from the rig spec, and the stage box. */
|
|
686
|
+
export interface RigFacts {
|
|
687
|
+
lengths: Map<string, number>;
|
|
688
|
+
parents: Map<string, string | null>;
|
|
689
|
+
stage: { x: number; y: number; height: number };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** The bone facts a measure reads, off a rig spec: `length` (absent is 0, as Spine reads it) and `parent`. */
|
|
693
|
+
export function rigFacts(rig: Record<string, unknown>, stage: { x: number; y: number; height: number }): RigFacts {
|
|
694
|
+
const lengths = new Map<string, number>();
|
|
695
|
+
const parents = new Map<string, string | null>();
|
|
696
|
+
for (const b of bonesOf(rig)) {
|
|
697
|
+
if (typeof b.name !== 'string') continue;
|
|
698
|
+
lengths.set(b.name, finite(b.length) ? b.length : 0);
|
|
699
|
+
parents.set(b.name, typeof b.parent === 'string' ? b.parent : null);
|
|
700
|
+
}
|
|
701
|
+
return { lengths, parents, stage };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** One requirement's line: its kind, animation and status, then its figures (and, NOT MEASURABLE, the reason first). */
|
|
705
|
+
export type RequirementLine = { kind: RequirementKind; animation: string; status: RequirementStatus; [figure: string]: unknown };
|
|
706
|
+
|
|
707
|
+
const DEG = 180 / Math.PI;
|
|
708
|
+
|
|
709
|
+
/** Six decimals: what a line prints and check.json holds. The comparison with the bar reads the unrounded figure. */
|
|
710
|
+
export function round6(x: number): number {
|
|
711
|
+
const r = Math.round(x * 1e6) / 1e6;
|
|
712
|
+
return r === 0 ? 0 : r;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** `a − b` in degrees as the signed shortest angle, in (−180, 180]. */
|
|
716
|
+
export function shortestDegrees(a: number, b: number): number {
|
|
717
|
+
let d = (a - b) % 360;
|
|
718
|
+
if (d > 180) d -= 360;
|
|
719
|
+
if (d <= -180) d += 360;
|
|
720
|
+
return d;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** A bone's world rotation, `atan2(c, a)`, in degrees. */
|
|
724
|
+
export function worldRotation(w: BoneWorld): number {
|
|
725
|
+
return Math.atan2(w.c, w.a) * DEG;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function frameText(p: Poses, i: number): string {
|
|
729
|
+
return `frame ${p.indices[i]} (t = ${round6(p.times[i])}s)`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function pointOf(w: BoneWorld, point: BonePoint, length: number): [number, number] {
|
|
733
|
+
return point === 'origin' ? [w.worldX, w.worldY] : [w.worldX + w.a * length, w.worldY + w.c * length];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function targetText(t: TargetRef): string {
|
|
737
|
+
return 'bone' in t ? `bone "${t.bone}" ${t.point}` : `stage point ${t.stage[0]},${t.stage[1]}`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Where a target is on frame `i`, or the reason it has no point (a tip on a bone of length 0). */
|
|
741
|
+
function targetAt(t: TargetRef, p: Poses, facts: RigFacts): ((i: number) => [number, number]) | string {
|
|
742
|
+
if ('stage' in t) {
|
|
743
|
+
const w = stageToWorld(t.stage, facts.stage);
|
|
744
|
+
return () => w;
|
|
745
|
+
}
|
|
746
|
+
const len = facts.lengths.get(t.bone) ?? 0;
|
|
747
|
+
if (t.point === 'tip' && len === 0) return `the target is the tip of bone "${t.bone}", whose length is 0, so it has no tip`;
|
|
748
|
+
const track = p.bone(t.bone);
|
|
749
|
+
return (i) => pointOf(track.frames[i], t.point, len);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function notMeasurable(kind: RequirementKind, animation: string, reason: string, figures: Record<string, unknown> = {}): RequirementLine {
|
|
753
|
+
return { kind, animation, status: 'NOT MEASURABLE', reason, ...figures };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** `contact`: the largest distance between the bone's point and the target over the frames, and its frame. */
|
|
757
|
+
export function contactLine(r: ContactRequirement, p: Poses, facts: RigFacts): RequirementLine {
|
|
758
|
+
const len = facts.lengths.get(r.bone) ?? 0;
|
|
759
|
+
if (r.point === 'tip' && len === 0) return notMeasurable('contact', r.animation, `bone "${r.bone}" has length 0, so it has no tip`, { length: 0 });
|
|
760
|
+
const at = targetAt(r.target, p, facts);
|
|
761
|
+
if (typeof at === 'string') return notMeasurable('contact', r.animation, at);
|
|
762
|
+
const track = p.bone(r.bone);
|
|
763
|
+
let worst = -1;
|
|
764
|
+
let wi = 0;
|
|
765
|
+
for (let i = 0; i < p.times.length; i++) {
|
|
766
|
+
const a = pointOf(track.frames[i], r.point, len);
|
|
767
|
+
const b = at(i);
|
|
768
|
+
const d = Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
769
|
+
if (d > worst) {
|
|
770
|
+
worst = d;
|
|
771
|
+
wi = i;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
kind: 'contact',
|
|
776
|
+
animation: r.animation,
|
|
777
|
+
status: worst <= r.within_px ? 'PASS' : 'FAIL',
|
|
778
|
+
of: `bone "${r.bone}" ${r.point}`,
|
|
779
|
+
against: targetText(r.target),
|
|
780
|
+
frames: p.times.length,
|
|
781
|
+
largest_px: round6(worst),
|
|
782
|
+
at: frameText(p, wi),
|
|
783
|
+
within_px: r.within_px,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** `aim`: the angle between the bone's axis and the line from its origin to the target, the largest over the frames that have a line. */
|
|
788
|
+
export function aimLine(r: AimRequirement, p: Poses, facts: RigFacts): RequirementLine {
|
|
789
|
+
const len = facts.lengths.get(r.bone) ?? 0;
|
|
790
|
+
if (len === 0) return notMeasurable('aim', r.animation, `bone "${r.bone}" has length 0, so it has no axis to aim`, { length: 0 });
|
|
791
|
+
const at = targetAt(r.target, p, facts);
|
|
792
|
+
if (typeof at === 'string') return notMeasurable('aim', r.animation, at);
|
|
793
|
+
const track = p.bone(r.bone);
|
|
794
|
+
let worst = -1;
|
|
795
|
+
let wi = 0;
|
|
796
|
+
const unmeasured: number[] = [];
|
|
797
|
+
for (let i = 0; i < p.times.length; i++) {
|
|
798
|
+
const w = track.frames[i];
|
|
799
|
+
const b = at(i);
|
|
800
|
+
const lx = b[0] - w.worldX;
|
|
801
|
+
const ly = b[1] - w.worldY;
|
|
802
|
+
if ((lx === 0 && ly === 0) || (w.a === 0 && w.c === 0)) {
|
|
803
|
+
unmeasured.push(p.indices[i]);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
const angle = Math.atan2(Math.abs(w.a * ly - w.c * lx), w.a * lx + w.c * ly) * DEG;
|
|
807
|
+
if (angle > worst) {
|
|
808
|
+
worst = angle;
|
|
809
|
+
wi = i;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (worst < 0) return notMeasurable('aim', r.animation, `on every one of the ${p.times.length} frame(s) the target sits on the bone's origin (or the bone has no axis), so there is no line to aim along`, { frames: p.times.length });
|
|
813
|
+
return {
|
|
814
|
+
kind: 'aim',
|
|
815
|
+
animation: r.animation,
|
|
816
|
+
status: worst <= r.within_degrees ? 'PASS' : 'FAIL',
|
|
817
|
+
of: `bone "${r.bone}" axis`,
|
|
818
|
+
against: targetText(r.target),
|
|
819
|
+
frames: p.times.length - unmeasured.length,
|
|
820
|
+
frames_not_measurable: unmeasured,
|
|
821
|
+
largest_degrees: round6(worst),
|
|
822
|
+
at: frameText(p, wi),
|
|
823
|
+
within_degrees: r.within_degrees,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** `range`: the bone's rotation relative to its parent (the world, for the root) less the same at the setup pose, signed shortest, the least and the greatest. */
|
|
828
|
+
export function rangeLine(r: RangeRequirement, p: Poses, facts: RigFacts): RequirementLine {
|
|
829
|
+
const parent = facts.parents.get(r.bone) ?? null;
|
|
830
|
+
const track = p.bone(r.bone);
|
|
831
|
+
const ptrack = parent === null ? null : p.bone(parent);
|
|
832
|
+
const rel = (w: BoneWorld, pw: BoneWorld | null): number => (pw === null ? worldRotation(w) : shortestDegrees(worldRotation(w), worldRotation(pw)));
|
|
833
|
+
const setup = rel(track.setup, ptrack?.setup ?? null);
|
|
834
|
+
let lo = Infinity;
|
|
835
|
+
let hi = -Infinity;
|
|
836
|
+
let li = 0;
|
|
837
|
+
let hiI = 0;
|
|
838
|
+
for (let i = 0; i < p.times.length; i++) {
|
|
839
|
+
const v = shortestDegrees(rel(track.frames[i], ptrack?.frames[i] ?? null), setup);
|
|
840
|
+
if (v < lo) {
|
|
841
|
+
lo = v;
|
|
842
|
+
li = i;
|
|
843
|
+
}
|
|
844
|
+
if (v > hi) {
|
|
845
|
+
hi = v;
|
|
846
|
+
hiI = i;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return {
|
|
850
|
+
kind: 'range',
|
|
851
|
+
animation: r.animation,
|
|
852
|
+
status: lo >= r.lo_degrees && hi <= r.hi_degrees ? 'PASS' : 'FAIL',
|
|
853
|
+
of: `bone "${r.bone}" relative to ${parent === null ? 'the world' : `its parent "${parent}"`}, from its setup pose`,
|
|
854
|
+
frames: p.times.length,
|
|
855
|
+
least_degrees: round6(lo),
|
|
856
|
+
least_at: frameText(p, li),
|
|
857
|
+
greatest_degrees: round6(hi),
|
|
858
|
+
greatest_at: frameText(p, hiI),
|
|
859
|
+
range_degrees: [r.lo_degrees, r.hi_degrees],
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* `follow`, #87's definition to the letter. `declared`, `released` and `full`
|
|
865
|
+
* are the same frames posed three ways (the rig as written; the constraint's
|
|
866
|
+
* mix for the property forced to 0, its keys removed; forced to 1, keys
|
|
867
|
+
* removed). For `translate` A is the bone's world origin; for `rotate`,
|
|
868
|
+
* `atan2(c, a)` in degrees, every difference a signed shortest angle. Per
|
|
869
|
+
* frame the drive is d = A1 − A0; a frame counts when |d| ≥ `least_drive`;
|
|
870
|
+
* F = Σ⟨A − A0, d⟩ / Σ|d|² over the frames that count; PASS when
|
|
871
|
+
* |F − fraction| ≤ tolerance. The line carries the frames that counted, the
|
|
872
|
+
* largest drive and its frame, the largest residual |A − A0 − F·d| and its
|
|
873
|
+
* frame, and, for rotate, a note when the largest drive exceeds 90°. NOT
|
|
874
|
+
* MEASURABLE when no frame reaches the least drive, printing the largest drive
|
|
875
|
+
* there was.
|
|
876
|
+
*/
|
|
877
|
+
export function followLine(r: FollowRequirement, declared: Poses, released: Poses, full: Poses): RequirementLine {
|
|
878
|
+
const A = declared.bone(r.bone).frames;
|
|
879
|
+
const A0 = released.bone(r.bone).frames;
|
|
880
|
+
const A1 = full.bone(r.bone).frames;
|
|
881
|
+
const rotate = r.property === 'rotate';
|
|
882
|
+
const diff = (x: BoneWorld, y: BoneWorld): number[] => (rotate ? [shortestDegrees(worldRotation(x), worldRotation(y))] : [x.worldX - y.worldX, x.worldY - y.worldY]);
|
|
883
|
+
const dot = (u: number[], v: number[]): number => u.reduce((s, x, i) => s + x * v[i], 0);
|
|
884
|
+
const n = declared.times.length;
|
|
885
|
+
const drive: number[][] = [];
|
|
886
|
+
const took: number[][] = [];
|
|
887
|
+
let maxDrive = -1;
|
|
888
|
+
let maxDriveAt = 0;
|
|
889
|
+
for (let i = 0; i < n; i++) {
|
|
890
|
+
const d = diff(A1[i], A0[i]);
|
|
891
|
+
drive.push(d);
|
|
892
|
+
took.push(diff(A[i], A0[i]));
|
|
893
|
+
const m = Math.sqrt(dot(d, d));
|
|
894
|
+
if (m > maxDrive) {
|
|
895
|
+
maxDrive = m;
|
|
896
|
+
maxDriveAt = i;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
const counted = drive.map((d, i) => (Math.sqrt(dot(d, d)) >= r.least_drive ? i : -1)).filter((i) => i >= 0);
|
|
900
|
+
const unit = rotate ? 'degrees' : 'px';
|
|
901
|
+
const what = { of: `bone "${r.bone}" ${r.property} under ${r.constraint_type} constraint "${r.constraint}"`, frames: n };
|
|
902
|
+
const driveFig = { [`largest_drive_${unit}`]: round6(maxDrive), largest_drive_at: frameText(declared, maxDriveAt) };
|
|
903
|
+
const note = rotate && maxDrive > 90 ? { note: `the largest drive exceeds 90 degrees: every difference is the signed shortest angle, so a drive beyond 180 degrees is not told from its complement` } : {};
|
|
904
|
+
if (counted.length === 0) {
|
|
905
|
+
return notMeasurable('follow', r.animation, `no frame's drive reaches the least drive ${r.least_drive} ${unit}: the constraint never asks the bone for that much`, { ...what, ...driveFig, [`least_drive_${unit}`]: r.least_drive, ...note });
|
|
906
|
+
}
|
|
907
|
+
let num = 0;
|
|
908
|
+
let den = 0;
|
|
909
|
+
for (const i of counted) {
|
|
910
|
+
num += dot(took[i], drive[i]);
|
|
911
|
+
den += dot(drive[i], drive[i]);
|
|
912
|
+
}
|
|
913
|
+
const F = num / den;
|
|
914
|
+
let maxRes = -1;
|
|
915
|
+
let maxResAt = counted[0];
|
|
916
|
+
for (const i of counted) {
|
|
917
|
+
const e = took[i].map((x, k) => x - F * drive[i][k]);
|
|
918
|
+
const res = Math.sqrt(dot(e, e));
|
|
919
|
+
if (res > maxRes) {
|
|
920
|
+
maxRes = res;
|
|
921
|
+
maxResAt = i;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return {
|
|
925
|
+
kind: 'follow',
|
|
926
|
+
animation: r.animation,
|
|
927
|
+
status: Math.abs(F - r.fraction) <= r.tolerance ? 'PASS' : 'FAIL',
|
|
928
|
+
...what,
|
|
929
|
+
frames_counted: counted.length,
|
|
930
|
+
fraction_measured: round6(F),
|
|
931
|
+
fraction: r.fraction,
|
|
932
|
+
tolerance: r.tolerance,
|
|
933
|
+
[`least_drive_${unit}`]: r.least_drive,
|
|
934
|
+
...driveFig,
|
|
935
|
+
[`largest_residual_${unit}`]: round6(maxRes),
|
|
936
|
+
largest_residual_at: frameText(declared, maxResAt),
|
|
937
|
+
...note,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** `stretch`: `TEXTURE_STRETCH`'s per-mesh figure for one mesh over the named animation's frames — its worst max(ratio, 1/ratio) and where. */
|
|
942
|
+
export function stretchRequirementLine(r: StretchRequirement, meshes: readonly MeshRest[], frames: readonly GeometryPose[], p: Poses): RequirementLine {
|
|
943
|
+
const mesh = meshes.find((m) => m.slot === r.slot && m.attachment === r.attachment);
|
|
944
|
+
if (mesh === undefined) return notMeasurable('stretch', r.animation, `geometry.json's rest pose holds no mesh "${r.attachment}" on slot "${r.slot}", so no frame shows it`);
|
|
945
|
+
const [m] = stretchFigures([mesh], frames);
|
|
946
|
+
if (m.degenerate.length > 0) {
|
|
947
|
+
return notMeasurable('stretch', r.animation, `${m.degenerate.length} rest edge(s) of length 0, the first triangle ${m.degenerate[0].triangle} edge ${m.degenerate[0].edge.join('-')}: no ratio can be read off an edge with no rest length`, { triangles: m.triangles });
|
|
948
|
+
}
|
|
949
|
+
if (m.max === null || m.min === null) return notMeasurable('stretch', r.animation, `none of the ${p.times.length} frame(s) shows mesh "${r.attachment}" on slot "${r.slot}"`, { triangles: m.triangles });
|
|
950
|
+
const sMax = stretchSeverity(m.max.ratio);
|
|
951
|
+
const sMin = stretchSeverity(m.min.ratio);
|
|
952
|
+
const at = sMin > sMax ? m.min : m.max;
|
|
953
|
+
const severity = Math.max(sMax, sMin);
|
|
954
|
+
const fi = p.indices.indexOf(at.frame);
|
|
955
|
+
return {
|
|
956
|
+
kind: 'stretch',
|
|
957
|
+
animation: r.animation,
|
|
958
|
+
status: severity <= r.within_ratio ? 'PASS' : 'FAIL',
|
|
959
|
+
of: `mesh "${r.attachment}" on slot "${r.slot}"`,
|
|
960
|
+
frames: m.frames,
|
|
961
|
+
triangles: m.triangles,
|
|
962
|
+
severity: round6(severity),
|
|
963
|
+
worst: `triangle ${at.triangle} (vertices ${at.vertices.join(' ')}), edge ${at.edge.join('-')}, ratio ${round6(at.ratio)}`,
|
|
964
|
+
at: fi < 0 ? `frame ${at.frame}` : frameText(p, fi),
|
|
965
|
+
max_ratio: round6(m.max.ratio),
|
|
966
|
+
min_ratio: round6(m.min.ratio),
|
|
967
|
+
within_ratio: r.within_ratio,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ---------------------------------------------------------------------------
|
|
972
|
+
// the seam between two parts (issue #111)
|
|
973
|
+
// ---------------------------------------------------------------------------
|
|
974
|
+
|
|
975
|
+
/** A part's art on the stage: its box's top-left in stage px and, per pixel of the box, 1 where its alpha is above {@link ART_ALPHA}. */
|
|
976
|
+
export interface PlacedArt {
|
|
977
|
+
x: number;
|
|
978
|
+
y: number;
|
|
979
|
+
width: number;
|
|
980
|
+
height: number;
|
|
981
|
+
data: Uint8Array;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** The art of a part image placed at `x, y`: alpha above {@link ART_ALPHA}, the threshold every mesh is built from (`src/mesh.ts`). */
|
|
985
|
+
export function placedArt(x: number, y: number, img: Raster): PlacedArt {
|
|
986
|
+
const m = alphaAbove(img, ART_ALPHA);
|
|
987
|
+
return { x, y, width: m.width, height: m.height, data: m.data };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function artAt(m: PlacedArt, sx: number, sy: number): boolean {
|
|
991
|
+
const x = sx - m.x;
|
|
992
|
+
const y = sy - m.y;
|
|
993
|
+
return x >= 0 && y >= 0 && x < m.width && y < m.height && m.data[y * m.width + x] === 1;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** One seam pair: `[ax, ay, bx, by]`, stage px — a an art pixel of the part, b of the neighbour. */
|
|
997
|
+
export type SeamPair = readonly [number, number, number, number];
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* The seam between `part` and `neighbour` at the setup pose: every pair
|
|
1001
|
+
* (a, b) of 4-adjacent stage pixels with a an art pixel of `part` and b an art
|
|
1002
|
+
* pixel of `neighbour` that is not an art pixel of `part`. Where the two
|
|
1003
|
+
* overlap, the pixels both hold are not a seam: the part covers them. Order:
|
|
1004
|
+
* a row-major over the part's box, then right, down, left, up. This is the
|
|
1005
|
+
* definition `tools/real_compare.ts` measured #109's seam with (`seamPairs`).
|
|
1006
|
+
*/
|
|
1007
|
+
export function seamPairs(part: PlacedArt, neighbour: PlacedArt): SeamPair[] {
|
|
1008
|
+
const out: SeamPair[] = [];
|
|
1009
|
+
const steps: ReadonlyArray<readonly [number, number]> = [
|
|
1010
|
+
[1, 0],
|
|
1011
|
+
[0, 1],
|
|
1012
|
+
[-1, 0],
|
|
1013
|
+
[0, -1],
|
|
1014
|
+
];
|
|
1015
|
+
for (let y = 0; y < part.height; y++) {
|
|
1016
|
+
for (let x = 0; x < part.width; x++) {
|
|
1017
|
+
if (part.data[y * part.width + x] !== 1) continue;
|
|
1018
|
+
const ax = part.x + x;
|
|
1019
|
+
const ay = part.y + y;
|
|
1020
|
+
for (const [dx, dy] of steps) {
|
|
1021
|
+
if (artAt(neighbour, ax + dx, ay + dy) && !artAt(part, ax + dx, ay + dy)) out.push([ax, ay, ax + dx, ay + dy]);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return out;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* The seam pairs of every `seam` requirement, keyed by its name, or one
|
|
1030
|
+
* refusal naming every problem, before anything is built. The pairs are read
|
|
1031
|
+
* off `parts.json`'s art, so with no `parts.json` there is no seam to read
|
|
1032
|
+
* (`REQUIREMENTS_SEAM`, quoting why there is none); a part `parts.json` does not
|
|
1033
|
+
* list is unresolved (`REQUIREMENTS_RESOLVES`); and two parts whose art does
|
|
1034
|
+
* not meet at the setup pose share no seam (`REQUIREMENTS_SEAM`). `art` is
|
|
1035
|
+
* asked only for parts `parts.json` lists.
|
|
1036
|
+
*/
|
|
1037
|
+
export function resolveSeams(file: RequirementsFile, partNames: readonly string[] | null, noParts: string | null, art: (part: string) => PlacedArt): Map<string, SeamPair[]> {
|
|
1038
|
+
const problems: Problem[] = [];
|
|
1039
|
+
const out = new Map<string, SeamPair[]>();
|
|
1040
|
+
const cache = new Map<string, PlacedArt>();
|
|
1041
|
+
const artOf = (n: string): PlacedArt => {
|
|
1042
|
+
const hit = cache.get(n);
|
|
1043
|
+
if (hit !== undefined) return hit;
|
|
1044
|
+
const a = art(n);
|
|
1045
|
+
cache.set(n, a);
|
|
1046
|
+
return a;
|
|
1047
|
+
};
|
|
1048
|
+
for (const r of file.requirements) {
|
|
1049
|
+
if (r.kind !== 'seam') continue;
|
|
1050
|
+
const object = `requirement "${r.name}" (seam)`;
|
|
1051
|
+
if (partNames === null) {
|
|
1052
|
+
problems.push({ code: 'REQUIREMENTS_SEAM', object, detail: `a seam's pixels are read off parts.json's art at the setup pose, and none was read: ${noParts ?? 'no parts.json'}` });
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const missing = [r.part, r.neighbour].filter((n) => !partNames.includes(n));
|
|
1056
|
+
for (const n of missing) problems.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: `names part "${n}", which parts.json does not list; it lists ${partNames.map((x) => `"${x}"`).join(', ') || 'none'}` });
|
|
1057
|
+
if (missing.length > 0) continue;
|
|
1058
|
+
const pairs = seamPairs(artOf(r.part), artOf(r.neighbour));
|
|
1059
|
+
if (pairs.length === 0) {
|
|
1060
|
+
problems.push({
|
|
1061
|
+
code: 'REQUIREMENTS_SEAM',
|
|
1062
|
+
object,
|
|
1063
|
+
detail: `no art pixel of "${r.part}" is 4-adjacent to an art pixel of "${r.neighbour}" that is not "${r.part}"'s (alpha above ${ART_ALPHA}, at the setup pose): the two parts share no seam to hold`,
|
|
1064
|
+
});
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
out.set(r.name, pairs);
|
|
1068
|
+
}
|
|
1069
|
+
refuseIfAny(problems);
|
|
1070
|
+
return out;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** One attachment on one frame: its rest vertices (the setup pose's bones, no deform), its posed vertices (world, y up) and its triangles — what `rigc-geometry/1` writes for a region (two triangles) and a mesh alike. */
|
|
1074
|
+
export interface PosedAttachment {
|
|
1075
|
+
rest: readonly number[];
|
|
1076
|
+
posed: readonly number[];
|
|
1077
|
+
triangles: readonly number[];
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* A displacement reader for one attachment: a world point's displacement from
|
|
1082
|
+
* rest — the rest triangle holding it (the first in triangle order where two
|
|
1083
|
+
* share an edge) and the same barycentric mix of that triangle's posed
|
|
1084
|
+
* vertices, less the point — or null when no rest triangle holds it.
|
|
1085
|
+
*/
|
|
1086
|
+
export function displacementOf(a: PosedAttachment): (w: readonly [number, number]) => [number, number] | null {
|
|
1087
|
+
const cell = 8;
|
|
1088
|
+
const buckets = new Map<string, number[]>();
|
|
1089
|
+
const X = (i: number): number => a.rest[2 * i];
|
|
1090
|
+
const Y = (i: number): number => a.rest[2 * i + 1];
|
|
1091
|
+
for (let t = 0; t < a.triangles.length / 3; t++) {
|
|
1092
|
+
const ix = [a.triangles[3 * t], a.triangles[3 * t + 1], a.triangles[3 * t + 2]];
|
|
1093
|
+
const [i0, i1] = [Math.floor(Math.min(...ix.map(X)) / cell), Math.floor(Math.max(...ix.map(X)) / cell)];
|
|
1094
|
+
const [j0, j1] = [Math.floor(Math.min(...ix.map(Y)) / cell), Math.floor(Math.max(...ix.map(Y)) / cell)];
|
|
1095
|
+
for (let j = j0; j <= j1; j++) {
|
|
1096
|
+
for (let i = i0; i <= i1; i++) {
|
|
1097
|
+
const list = buckets.get(`${i},${j}`);
|
|
1098
|
+
if (list === undefined) buckets.set(`${i},${j}`, [t]);
|
|
1099
|
+
else list.push(t);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
// (v - u) x (w - u): twice the signed area of u, v, w.
|
|
1104
|
+
const cross = (ux: number, uy: number, vx: number, vy: number, wx: number, wy: number): number => (vx - ux) * (wy - uy) - (vy - uy) * (wx - ux);
|
|
1105
|
+
return (w) => {
|
|
1106
|
+
for (const t of buckets.get(`${Math.floor(w[0] / cell)},${Math.floor(w[1] / cell)}`) ?? []) {
|
|
1107
|
+
const [p, q, r] = [a.triangles[3 * t], a.triangles[3 * t + 1], a.triangles[3 * t + 2]];
|
|
1108
|
+
const area = cross(X(p), Y(p), X(q), Y(q), X(r), Y(r));
|
|
1109
|
+
const lp = cross(X(q), Y(q), X(r), Y(r), w[0], w[1]) / area;
|
|
1110
|
+
const lq = cross(X(r), Y(r), X(p), Y(p), w[0], w[1]) / area;
|
|
1111
|
+
const lr = cross(X(p), Y(p), X(q), Y(q), w[0], w[1]) / area;
|
|
1112
|
+
if (lp >= 0 && lq >= 0 && lr >= 0) {
|
|
1113
|
+
const x = lp * a.posed[2 * p] + lq * a.posed[2 * q] + lr * a.posed[2 * r];
|
|
1114
|
+
const y = lp * a.posed[2 * p + 1] + lq * a.posed[2 * q + 1] + lr * a.posed[2 * r + 1];
|
|
1115
|
+
return [x - w[0], y - w[1]];
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return null;
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** One frame of a seam: the attachment the part's slot and the neighbour's slot show on it, or null where the slot shows none. */
|
|
1123
|
+
export interface SeamFrame {
|
|
1124
|
+
part: PosedAttachment | null;
|
|
1125
|
+
neighbour: PosedAttachment | null;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* `seam` (issue #111): per frame, each seam pair's opening — the distance
|
|
1130
|
+
* between the part's displacement at a's centre and the neighbour's at b's
|
|
1131
|
+
* centre ({@link displacementOf}), stage px: how far the two sides of the seam
|
|
1132
|
+
* moved apart or across — and the largest over every pair and frame, with its
|
|
1133
|
+
* pair and its frame. A pair is not read on a frame where either side's pixel
|
|
1134
|
+
* lies outside its attachment's rest geometry or its slot shows nothing
|
|
1135
|
+
* (counted, never read as 0); a frame on which no pair reads is not
|
|
1136
|
+
* measurable, and with none left the requirement is. The opening is an upper
|
|
1137
|
+
* bound on any gap: what shows there depends on what is drawn under the seam.
|
|
1138
|
+
* Stage pixel centres go to the world through the stage box and the one y door
|
|
1139
|
+
* ({@link stageToWorld}).
|
|
1140
|
+
*/
|
|
1141
|
+
export function seamLine(r: SeamRequirement, pairs: readonly SeamPair[], frames: readonly SeamFrame[], p: Poses, stage: { x: number; y: number; height: number }): RequirementLine {
|
|
1142
|
+
const wa = pairs.map(([ax, ay]) => stageToWorld([ax + 0.5, ay + 0.5], stage));
|
|
1143
|
+
const wb = pairs.map(([, , bx, by]) => stageToWorld([bx + 0.5, by + 0.5], stage));
|
|
1144
|
+
let worst = -1;
|
|
1145
|
+
let wi = 0;
|
|
1146
|
+
let wp = 0;
|
|
1147
|
+
let unread = 0;
|
|
1148
|
+
const unmeasured: number[] = [];
|
|
1149
|
+
for (let i = 0; i < p.times.length; i++) {
|
|
1150
|
+
const f = frames[i];
|
|
1151
|
+
const dP = f.part === null ? null : displacementOf(f.part);
|
|
1152
|
+
const dN = f.neighbour === null ? null : displacementOf(f.neighbour);
|
|
1153
|
+
let read = 0;
|
|
1154
|
+
for (let k = 0; k < pairs.length; k++) {
|
|
1155
|
+
const da = dP === null ? null : dP(wa[k]);
|
|
1156
|
+
const db = da === null || dN === null ? null : dN(wb[k]);
|
|
1157
|
+
if (da === null || db === null) {
|
|
1158
|
+
unread++;
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
read++;
|
|
1162
|
+
const o = Math.hypot(da[0] - db[0], da[1] - db[1]);
|
|
1163
|
+
if (o > worst) {
|
|
1164
|
+
worst = o;
|
|
1165
|
+
wi = i;
|
|
1166
|
+
wp = k;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (read === 0) unmeasured.push(p.indices[i]);
|
|
1170
|
+
}
|
|
1171
|
+
const of = `the seam between part "${r.part}" and part "${r.neighbour}"`;
|
|
1172
|
+
if (worst < 0) {
|
|
1173
|
+
return notMeasurable('seam', r.animation, `on every one of the ${p.times.length} frame(s) no seam pair reads: each pair's pixel lies outside its attachment's rest geometry, or its slot shows nothing`, { of, pairs: pairs.length, frames: p.times.length });
|
|
1174
|
+
}
|
|
1175
|
+
const [ax, ay, bx, by] = pairs[wp];
|
|
1176
|
+
return {
|
|
1177
|
+
kind: 'seam',
|
|
1178
|
+
animation: r.animation,
|
|
1179
|
+
status: worst <= r.within_px ? 'PASS' : 'FAIL',
|
|
1180
|
+
of,
|
|
1181
|
+
pairs: pairs.length,
|
|
1182
|
+
frames: p.times.length - unmeasured.length,
|
|
1183
|
+
frames_not_measurable: unmeasured,
|
|
1184
|
+
pair_frames_unread: unread,
|
|
1185
|
+
largest_px: round6(worst),
|
|
1186
|
+
at: frameText(p, wi),
|
|
1187
|
+
pair: `"${r.part}" pixel ${ax},${ay} against "${r.neighbour}" pixel ${bx},${by}`,
|
|
1188
|
+
within_px: r.within_px,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** The bar a line was held to, as its FAIL detail states it. */
|
|
1193
|
+
function barText(r: Requirement): string {
|
|
1194
|
+
if (r.kind === 'contact' || r.kind === 'seam') return `<= ${r.within_px} px is required`;
|
|
1195
|
+
if (r.kind === 'aim') return `<= ${r.within_degrees} degrees is required`;
|
|
1196
|
+
if (r.kind === 'range') return `[${r.lo_degrees}, ${r.hi_degrees}] degrees is required`;
|
|
1197
|
+
if (r.kind === 'stretch') return `<= ${r.within_ratio} is required`;
|
|
1198
|
+
return `${r.fraction} ± ${r.tolerance} is required`;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** The figures a FAIL detail quotes, before its bar. */
|
|
1202
|
+
function failText(r: Requirement, l: RequirementLine): string {
|
|
1203
|
+
if (r.kind === 'contact') return `largest distance ${String(l.largest_px)} px at ${String(l.at)}, between ${String(l.of)} and ${String(l.against)}`;
|
|
1204
|
+
if (r.kind === 'aim') return `largest angle ${String(l.largest_degrees)} degrees at ${String(l.at)}, between ${String(l.of)} and the line to ${String(l.against)}`;
|
|
1205
|
+
if (r.kind === 'range') return `least ${String(l.least_degrees)} degrees at ${String(l.least_at)}, greatest ${String(l.greatest_degrees)} at ${String(l.greatest_at)}`;
|
|
1206
|
+
if (r.kind === 'stretch') return `max(ratio, 1/ratio) ${String(l.severity)} at ${String(l.at)}, ${String(l.worst)}`;
|
|
1207
|
+
if (r.kind === 'seam') return `largest opening ${String(l.largest_px)} px at ${String(l.at)}, ${String(l.pair)}`;
|
|
1208
|
+
return `measured fraction ${String(l.fraction_measured)} over ${String(l.frames_counted)} frame(s) that reach the least drive`;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** The problem a FAIL or a NOT MEASURABLE line puts in the report; null for PASS. */
|
|
1212
|
+
export function requirementProblem(r: Requirement, l: RequirementLine): Problem | null {
|
|
1213
|
+
const object = `requirement "${r.name}" (${r.kind}, animation "${r.animation}")`;
|
|
1214
|
+
if (l.status === 'PASS') return null;
|
|
1215
|
+
if (l.status === 'NOT MEASURABLE') return { code: 'CHECK_REQUIREMENT_MEASURABLE', object, detail: `NOT MEASURABLE — ${String(l.reason)}; a declared requirement that was not measured is not green` };
|
|
1216
|
+
return { code: 'CHECK_REQUIREMENT_MET', object, detail: `${failText(r, l)}; ${barText(r)}` };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** The summary: how many were declared, measured (PASS, FAIL) and not measurable, and the kinds the file did not declare. */
|
|
1220
|
+
export interface RequirementsSummary {
|
|
1221
|
+
declared: number;
|
|
1222
|
+
measured: number;
|
|
1223
|
+
pass: number;
|
|
1224
|
+
fail: number;
|
|
1225
|
+
not_measurable: number;
|
|
1226
|
+
not_declared: RequirementKind[];
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
export function requirementsSummary(file: RequirementsFile, lines: ReadonlyMap<string, RequirementLine>): RequirementsSummary {
|
|
1230
|
+
const all = [...lines.values()];
|
|
1231
|
+
const pass = all.filter((l) => l.status === 'PASS').length;
|
|
1232
|
+
const fail = all.filter((l) => l.status === 'FAIL').length;
|
|
1233
|
+
const declaredKinds = new Set(file.requirements.map((r) => r.kind));
|
|
1234
|
+
return { declared: file.requirements.length, measured: pass + fail, pass, fail, not_measurable: all.length - pass - fail, not_declared: REQUIREMENT_KINDS.filter((k) => !declaredKinds.has(k)) };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** The summary as the console prints it. */
|
|
1238
|
+
export function summaryText(s: RequirementsSummary): string {
|
|
1239
|
+
return `requirements: ${s.declared} declared — ${s.measured} measured (${s.pass} PASS, ${s.fail} FAIL), ${s.not_measurable} NOT MEASURABLE; not declared: ${s.not_declared.length === 0 ? 'none' : s.not_declared.join(', ')}`;
|
|
1240
|
+
}
|