@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/structure.ts
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A structural comparison of two skeletons (issue #85).
|
|
3
|
+
*
|
|
4
|
+
* `propose --compare` (`compare()` in `src/propose.ts`) is a distance table:
|
|
5
|
+
* per bone two `BoneEntry` lists share by name, the distance between their
|
|
6
|
+
* origins. A changed parent, a changed tip and a renamed bone are invisible
|
|
7
|
+
* to it, by its own definition. This module reads the rest — parents, tips,
|
|
8
|
+
* lengths, directions, required bones and roles — through an explicit bone
|
|
9
|
+
* map, and `rig-parts compare` prints it. `compare()` is not touched.
|
|
10
|
+
*
|
|
11
|
+
* ## The inputs, told apart by what they state
|
|
12
|
+
*
|
|
13
|
+
* - a **rig.json**: an object whose `spec` is `rigc-rig/1`, read by
|
|
14
|
+
* rig-c's own `parseRigSpec` (its refusal is quoted, not rewritten);
|
|
15
|
+
* - a **config**: an object with a `key`, read by the full config loader;
|
|
16
|
+
* - a **proposal**: an object with `bones` and no `key` or `spec`, as
|
|
17
|
+
* `propose` writes it, read by `parseProposalSections` (the loader's rules
|
|
18
|
+
* for bones, meshes, regions and motion).
|
|
19
|
+
*
|
|
20
|
+
* Anything else is refused by name. A file's name is never read.
|
|
21
|
+
*
|
|
22
|
+
* ## One normal form, by stated rules
|
|
23
|
+
*
|
|
24
|
+
* Per bone: its name, its parent, its origin and — where it has one — its
|
|
25
|
+
* tip, in one frame with x right and y down.
|
|
26
|
+
*
|
|
27
|
+
* - From a config or a proposal: the bones as written, in rig px, y down.
|
|
28
|
+
* A chain `c` is the links `c0 .. cn`; link k's tip is the next point, the
|
|
29
|
+
* last link's the chain's `tip`. A single bone's tip is its `tip`, when it
|
|
30
|
+
* states one. `root` is every rig's bone and no config's: it is in the form,
|
|
31
|
+
* with no position, because a config states none for it.
|
|
32
|
+
* - From a rig.json: each bone's world position through rigc's exact frame
|
|
33
|
+
* transforms (`src/coords.ts`), and its tip the origin carried `length`
|
|
34
|
+
* along the bone's own x axis (`toWorld(frame, length, 0)`). A rig that
|
|
35
|
+
* states a stage (`skeleton.width` and `skeleton.height`, numbers) is brought
|
|
36
|
+
* into rig px through it: rigc defines the stage as the working area the art
|
|
37
|
+
* was painted in, with `x`/`y` its bottom-left corner in the world (default
|
|
38
|
+
* 0, rigc's own default), so a world point is the rig pixel
|
|
39
|
+
* `(wx - stage.x, cropToSpineY(wy - stage.y, stage.height))` — the y flip
|
|
40
|
+
* through its one door. A rig with no stage stays in its world, y turned
|
|
41
|
+
* down through the same door at height 0.
|
|
42
|
+
* - A bone whose tip is its origin has no tip: its tip, length and direction
|
|
43
|
+
* say SKIP with that reason, never 0. A rig bone with `from` takes its
|
|
44
|
+
* position from a cut manifest, which is not read here: it and everything
|
|
45
|
+
* under it have no position, said so.
|
|
46
|
+
*
|
|
47
|
+
* ## Frames: stated, never fitted
|
|
48
|
+
*
|
|
49
|
+
* Two sides in rig px are in one frame — unless both state a rig scale and
|
|
50
|
+
* the two differ. A rig with no stage is in a frame nothing here relates to
|
|
51
|
+
* rig px. Where the frames are not related the geometric rows — origin, tip,
|
|
52
|
+
* the length difference, direction — say SKIP "frames not related"; parents,
|
|
53
|
+
* required bones and roles still run. A bone map may declare the frame — a
|
|
54
|
+
* scale and an offset the author wrote, `right = scale * left + offset` in the
|
|
55
|
+
* two normal forms — and then that is the relation. Nothing is fitted.
|
|
56
|
+
*
|
|
57
|
+
* ## Pairing: equal names, or the map, never resemblance
|
|
58
|
+
*
|
|
59
|
+
* With no map a bone pairs with the bone of the same name on the other side.
|
|
60
|
+
* A map (`spine-parts-bonemap/1`) that lists `pairs` is the whole pairing:
|
|
61
|
+
* a bone in no pair is listed as unmapped by name.
|
|
62
|
+
*
|
|
63
|
+
* ## What fails
|
|
64
|
+
*
|
|
65
|
+
* A bone the map's `required` list names that is not present on both sides
|
|
66
|
+
* through the pairing. Every other row is a figure: no bar on a distance, an
|
|
67
|
+
* angle or a role count is set here.
|
|
68
|
+
*
|
|
69
|
+
* Pure but for reading the three files it is handed.
|
|
70
|
+
*/
|
|
71
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
72
|
+
import { CompileError } from 'rig-c/src/errors.ts';
|
|
73
|
+
import { parseRigSpec, RIG_SPEC_VERSION, type RigBone as RigcBone, type RigSpec as RigcSpec, splitRigSkin } from 'rig-c/src/rig.ts';
|
|
74
|
+
import { type ConfigConstraint, CONSTRAINT_FOLLOWS, parseConfig, parseProposalSections, type Point, ROOT_BONE, type SkeletonSections, weightsABone } from './config.ts';
|
|
75
|
+
import { computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toWorld } from './coords.ts';
|
|
76
|
+
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// the normal form
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export type SkeletonForm = 'config' | 'proposal' | 'rig';
|
|
83
|
+
|
|
84
|
+
/** The frame a side's normal form is in. `rule` says how it was reached, for the printout. */
|
|
85
|
+
export type SideFrame = { kind: 'rig-px'; rule: string; rigScale: number | null } | { kind: 'world'; rule: string };
|
|
86
|
+
|
|
87
|
+
export interface NormalBone {
|
|
88
|
+
name: string;
|
|
89
|
+
parent: string | null;
|
|
90
|
+
/** x right, y down, in the side's frame; null when the file states no position, with why. */
|
|
91
|
+
origin: Point | null;
|
|
92
|
+
originWhy: string | null;
|
|
93
|
+
/** Null when the bone has no tip — none stated, or length 0 — with why. */
|
|
94
|
+
tip: Point | null;
|
|
95
|
+
tipWhy: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface Skeleton {
|
|
99
|
+
form: SkeletonForm;
|
|
100
|
+
/** How the printout names the file's content: `config "demo"`, `proposal`, `rig.json "demo_painting"`. */
|
|
101
|
+
label: string;
|
|
102
|
+
frame: SideFrame;
|
|
103
|
+
/** In the file's order, every parent before its children. */
|
|
104
|
+
bones: NormalBone[];
|
|
105
|
+
byName: Map<string, NormalBone>;
|
|
106
|
+
roles: Map<string, RoleOf>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// roles (exported: issue #86 reads the same derivation)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A bone's role, read off the spec and never off its name.
|
|
115
|
+
*
|
|
116
|
+
* - `target`: a constraint names it as the bone it follows — an ik
|
|
117
|
+
* constraint's `target`, a transform constraint's `source` (rigc: "4.2
|
|
118
|
+
* called this target") — in a rig spec's `constraints` or a config's.
|
|
119
|
+
* - `deforms`: something drawn is bound to it — a mesh weight, a mesh segment
|
|
120
|
+
* (a config's `meshes.<part>.segments`, a chain naming each link), a region
|
|
121
|
+
* (a config's `regions`, a `motion.blink.still` piece; a rig's region
|
|
122
|
+
* attachment on a slot), or an unweighted mesh on a slot.
|
|
123
|
+
* - `control`: binds nothing, and is keyed (a config's `motion`) or parents
|
|
124
|
+
* other bones.
|
|
125
|
+
* - `unclassified`: what the spec does not settle, with why — a bone that
|
|
126
|
+
* binds nothing, is keyed by nothing and parents nothing; a rig bone that
|
|
127
|
+
* binds nothing and parents nothing (a rig.json carries no keys); any bone
|
|
128
|
+
* not otherwise classified in a rig carrying an attachment whose bones are
|
|
129
|
+
* not read here (a linked mesh, a generator mesh, raw-indexed weights).
|
|
130
|
+
*
|
|
131
|
+
* Precedence is the list's order: a target that is also bound is a target.
|
|
132
|
+
*/
|
|
133
|
+
export type BoneRole = 'target' | 'deforms' | 'control' | 'unclassified';
|
|
134
|
+
|
|
135
|
+
export const BONE_ROLES: readonly BoneRole[] = ['target', 'deforms', 'control', 'unclassified'];
|
|
136
|
+
|
|
137
|
+
export interface RoleOf {
|
|
138
|
+
role: BoneRole;
|
|
139
|
+
/** What in the spec made it so — or, for `unclassified`, what the spec leaves open. */
|
|
140
|
+
why: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function push(m: Map<string, string[]>, key: string, what: string): void {
|
|
144
|
+
const list = m.get(key);
|
|
145
|
+
if (list === undefined) m.set(key, [what]);
|
|
146
|
+
else if (!list.includes(what)) list.push(what);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function firstAnd(list: readonly string[]): string {
|
|
150
|
+
return list.length === 1 ? list[0] : `${list[0]} (+${list.length - 1} more)`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The roles of a config's or a proposal's bones, `root` included. A config's
|
|
155
|
+
* `constraints` (issue #92) are read as {@link rigRoles} reads a rig spec's:
|
|
156
|
+
* the bone an ik follows (`target`) or a transform reads (`source`) is a
|
|
157
|
+
* `target`, first in precedence. The loader has resolved every name; a
|
|
158
|
+
* proposal carries no constraints.
|
|
159
|
+
*/
|
|
160
|
+
export function configRoles(s: SkeletonSections & { constraints?: readonly ConfigConstraint[] }): Map<string, RoleOf> {
|
|
161
|
+
const names: string[] = [ROOT_BONE];
|
|
162
|
+
const chains = new Map<string, string[]>();
|
|
163
|
+
const children = new Map<string, string[]>();
|
|
164
|
+
for (const e of s.bones) {
|
|
165
|
+
if ('chain' in e) {
|
|
166
|
+
const links = e.points.map((_, k) => `${e.chain}${k}`);
|
|
167
|
+
chains.set(e.chain, links);
|
|
168
|
+
links.forEach((l, k) => {
|
|
169
|
+
names.push(l);
|
|
170
|
+
push(children, k === 0 ? e.parent : links[k - 1], l);
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
names.push(e.name);
|
|
174
|
+
push(children, e.parent, e.name);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const bound = new Map<string, string[]>();
|
|
178
|
+
for (const [part, m] of Object.entries(s.meshes)) {
|
|
179
|
+
m.segments.forEach((sp, i) => {
|
|
180
|
+
const at = `meshes.${part}.segments[${i}]`;
|
|
181
|
+
if (typeof sp === 'string') for (const b of chains.get(sp) ?? [sp]) push(bound, b, at);
|
|
182
|
+
else push(bound, sp[0], at);
|
|
183
|
+
});
|
|
184
|
+
// A contour mesh's region bone is weighted to, like a segment's (issue #84).
|
|
185
|
+
if ('contour' in m) (m.contour.regions ?? []).forEach((rg, i) => push(bound, rg.bone, `meshes.${part}.contour.regions[${i}]`));
|
|
186
|
+
if ('auto' in m) (m.auto.regions ?? []).forEach((rg, i) => {
|
|
187
|
+
// A density-only region binds no bone (issue #155).
|
|
188
|
+
if (weightsABone(rg)) push(bound, rg.bone, `meshes.${part}.auto.regions[${i}]`);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
for (const [part, bone] of Object.entries(s.regions)) push(bound, bone, `regions.${part}`);
|
|
192
|
+
for (const [part, st] of Object.entries(s.motion.blink?.still ?? {})) push(bound, st.bone, `motion.blink.still.${part}`);
|
|
193
|
+
const keyed = new Map<string, string[]>();
|
|
194
|
+
s.motion.tracks.forEach((t, i) => {
|
|
195
|
+
if ('chain' in t) for (const l of chains.get(t.chain) ?? []) push(keyed, l, `motion.tracks[${i}]`);
|
|
196
|
+
else push(keyed, t.bone, `motion.tracks[${i}]`);
|
|
197
|
+
});
|
|
198
|
+
s.motion.blink?.eyes.forEach((b, i) => push(keyed, b, `motion.blink.eyes[${i}]`));
|
|
199
|
+
s.motion.blink?.brows?.forEach((b, i) => push(keyed, b, `motion.blink.brows[${i}]`));
|
|
200
|
+
const targets = new Map<string, string[]>();
|
|
201
|
+
for (const c of s.constraints ?? []) {
|
|
202
|
+
const field = CONSTRAINT_FOLLOWS[c.type];
|
|
203
|
+
const followed = field === undefined ? undefined : c[field];
|
|
204
|
+
if (typeof followed !== 'string') continue;
|
|
205
|
+
push(targets, followed, field === 'target' ? `the target of ik constraint "${c.name}"` : `the source of transform constraint "${c.name}" (4.2's target)`);
|
|
206
|
+
}
|
|
207
|
+
const out = new Map<string, RoleOf>();
|
|
208
|
+
for (const n of names) {
|
|
209
|
+
const t = targets.get(n);
|
|
210
|
+
const b = bound.get(n);
|
|
211
|
+
const k = keyed.get(n);
|
|
212
|
+
const c = children.get(n);
|
|
213
|
+
if (t !== undefined) out.set(n, { role: 'target', why: firstAnd(t) });
|
|
214
|
+
else if (b !== undefined) out.set(n, { role: 'deforms', why: `bound by ${firstAnd(b)}` });
|
|
215
|
+
else if (k !== undefined) out.set(n, { role: 'control', why: `binds nothing; keyed by ${firstAnd(k)}` });
|
|
216
|
+
else if (c !== undefined) out.set(n, { role: 'control', why: `binds nothing; parents ${firstAnd(c)}` });
|
|
217
|
+
else out.set(n, { role: 'unclassified', why: 'binds nothing, is keyed by nothing and parents nothing' });
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The roles of a rig spec's bones. Every name a constraint, a slot or a
|
|
224
|
+
* weight gives is resolved against the bones, and a miss is refused by name
|
|
225
|
+
* (`STRUCTURE_NAME_RESOLVES`) — the one check made here; the rest of the
|
|
226
|
+
* spec's validity is rigc's, at its build.
|
|
227
|
+
*/
|
|
228
|
+
export function rigRoles(rig: RigcSpec, where = 'rig.json'): Map<string, RoleOf> {
|
|
229
|
+
const problems: Problem[] = [];
|
|
230
|
+
const bones = new Set(rig.bones.map((b) => b.name));
|
|
231
|
+
const resolve = (name: string, object: string): boolean => {
|
|
232
|
+
if (bones.has(name)) return true;
|
|
233
|
+
problems.push({ code: 'STRUCTURE_NAME_RESOLVES', object: `${where} ${object}`, detail: `names the bone "${name}", which the rig's bones do not declare` });
|
|
234
|
+
return false;
|
|
235
|
+
};
|
|
236
|
+
const targets = new Map<string, string[]>();
|
|
237
|
+
for (const c of rig.constraints ?? []) {
|
|
238
|
+
if (c.type === 'ik' && resolve(c.target, `ik constraint "${c.name}".target`)) push(targets, c.target, `the target of ik constraint "${c.name}"`);
|
|
239
|
+
if (c.type === 'transform' && resolve(c.source, `transform constraint "${c.name}".source`)) push(targets, c.source, `the source of transform constraint "${c.name}" (4.2's target)`);
|
|
240
|
+
}
|
|
241
|
+
const slotBone = new Map(rig.slots.map((s) => [s.name, s.bone]));
|
|
242
|
+
const bound = new Map<string, string[]>();
|
|
243
|
+
const unread: string[] = [];
|
|
244
|
+
for (const [skin, entry] of Object.entries(rig.skins ?? {})) {
|
|
245
|
+
for (const [slot, atts] of Object.entries(splitRigSkin(entry, `${where} skin "${skin}"`).attachments)) {
|
|
246
|
+
const bone = slotBone.get(slot);
|
|
247
|
+
if (bone === undefined) {
|
|
248
|
+
problems.push({ code: 'STRUCTURE_NAME_RESOLVES', object: `${where} skin "${skin}" slot "${slot}"`, detail: `names the slot "${slot}", which the rig's slots do not declare` });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
for (const [name, att] of Object.entries(atts)) {
|
|
252
|
+
const at = `skin "${skin}" slot "${slot}" attachment "${name}"`;
|
|
253
|
+
if (att.type === undefined || att.type === 'region') push(bound, bone, `the region ${at}`);
|
|
254
|
+
else if (att.type === 'mesh') {
|
|
255
|
+
if (att.weights !== undefined) {
|
|
256
|
+
att.weights.forEach((v, i) => v.forEach((w, j) => {
|
|
257
|
+
if (resolve(w.bone, `${at}.weights[${i}][${j}].bone`)) push(bound, w.bone, `the weights of mesh ${at}`);
|
|
258
|
+
}));
|
|
259
|
+
} else if (att.generator !== undefined) unread.push(`the generator mesh ${at}`);
|
|
260
|
+
else if (att.boneIndexing === 'raw') unread.push(`the raw-indexed mesh ${at}`);
|
|
261
|
+
else push(bound, bone, `the unweighted mesh ${at}`);
|
|
262
|
+
} else if (att.type === 'linkedmesh') unread.push(`the linked mesh ${at}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
refuseIfAny(problems);
|
|
267
|
+
const children = new Map<string, string[]>();
|
|
268
|
+
for (const b of rig.bones) if (b.parent !== undefined) push(children, b.parent, b.name);
|
|
269
|
+
const out = new Map<string, RoleOf>();
|
|
270
|
+
for (const b of rig.bones) {
|
|
271
|
+
const n = b.name;
|
|
272
|
+
const t = targets.get(n);
|
|
273
|
+
const d = bound.get(n);
|
|
274
|
+
const c = children.get(n);
|
|
275
|
+
if (t !== undefined) out.set(n, { role: 'target', why: firstAnd(t) });
|
|
276
|
+
else if (d !== undefined) out.set(n, { role: 'deforms', why: `bound by ${firstAnd(d)}` });
|
|
277
|
+
else if (unread.length > 0) out.set(n, { role: 'unclassified', why: `binds nothing read here, and the rig carries ${firstAnd(unread)}, whose bones are not read` });
|
|
278
|
+
else if (c !== undefined) out.set(n, { role: 'control', why: `binds nothing; parents ${firstAnd(c)}` });
|
|
279
|
+
else out.set(n, { role: 'unclassified', why: 'binds nothing and parents nothing; a rig.json carries no keys (they are in its motion spec), so whether it is keyed is not read' });
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// reading a skeleton
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
function same(a: Point, b: Point): boolean {
|
|
289
|
+
return a[0] === b[0] && a[1] === b[1];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function configSkeleton(s: SkeletonSections & { constraints?: readonly ConfigConstraint[] }, form: 'config' | 'proposal', label: string, rigScale: number | null): Skeleton {
|
|
293
|
+
const bones: NormalBone[] = [
|
|
294
|
+
{
|
|
295
|
+
name: ROOT_BONE,
|
|
296
|
+
parent: null,
|
|
297
|
+
origin: null,
|
|
298
|
+
originWhy: `the ${form} states no position for "${ROOT_BONE}" (the rig stage stands it at the bottom centre of the parts' canvas, whose size the ${form} does not state)`,
|
|
299
|
+
tip: null,
|
|
300
|
+
tipWhy: `the ${form} states no position for "${ROOT_BONE}"`,
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
const add = (name: string, parent: string, origin: Point, tip: Point | undefined, none: string): void => {
|
|
304
|
+
const o: Point = [origin[0], origin[1]];
|
|
305
|
+
if (tip === undefined) bones.push({ name, parent, origin: o, originWhy: null, tip: null, tipWhy: none });
|
|
306
|
+
else if (same(origin, tip)) bones.push({ name, parent, origin: o, originWhy: null, tip: null, tipWhy: `its tip is its origin, [${tip[0]}, ${tip[1]}]: length 0` });
|
|
307
|
+
else bones.push({ name, parent, origin: o, originWhy: null, tip: [tip[0], tip[1]], tipWhy: null });
|
|
308
|
+
};
|
|
309
|
+
for (const e of s.bones) {
|
|
310
|
+
if ('chain' in e) e.points.forEach((q, k) => add(`${e.chain}${k}`, k === 0 ? e.parent : `${e.chain}${k - 1}`, q, k + 1 < e.points.length ? e.points[k + 1] : e.tip, ''));
|
|
311
|
+
else add(e.name, e.parent, e.at, e.tip, 'states no tip');
|
|
312
|
+
}
|
|
313
|
+
const rule = form === 'config' ? `rig px, y down, as the config writes them${rigScale === null ? '' : ` (rig_scale ${rigScale})`}` : 'rig px, y down, as the proposal writes them (a proposal states no rig_scale)';
|
|
314
|
+
return { form, label, frame: { kind: 'rig-px', rule, rigScale }, bones, byName: new Map(bones.map((b) => [b.name, b])), roles: configRoles(s) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
interface Stage {
|
|
318
|
+
x: number;
|
|
319
|
+
y: number;
|
|
320
|
+
width: number;
|
|
321
|
+
height: number;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function rigSkeleton(rig: RigcSpec, where: string): Skeleton {
|
|
325
|
+
const roles = rigRoles(rig, where);
|
|
326
|
+
const h = rig.skeleton;
|
|
327
|
+
const stage: Stage | null = typeof h?.width === 'number' && typeof h.height === 'number' ? { x: h.x ?? 0, y: h.y ?? 0, width: h.width, height: h.height } : null;
|
|
328
|
+
const frame: SideFrame =
|
|
329
|
+
stage === null
|
|
330
|
+
? {
|
|
331
|
+
kind: 'world',
|
|
332
|
+
rule:
|
|
333
|
+
h?.width === null
|
|
334
|
+
? 'its world, y turned down: the rig declares no stage (skeleton width and height null), so nothing relates it to rig px'
|
|
335
|
+
: 'its world, y turned down: the rig states no stage (no skeleton.width and skeleton.height; rigc would take one from a cut manifest, which is not read here), so nothing relates it to rig px',
|
|
336
|
+
}
|
|
337
|
+
: {
|
|
338
|
+
kind: 'rig-px',
|
|
339
|
+
rule: `rig px, y down, through the stage (skeleton x ${stage.x}, y ${stage.y}, ${stage.width}x${stage.height}): x - (${stage.x}), cropToSpineY(y - (${stage.y}), ${stage.height})`,
|
|
340
|
+
rigScale: null,
|
|
341
|
+
};
|
|
342
|
+
const toNormal = (wx: number, wy: number): Point => (stage === null ? [wx, cropToSpineY(wy, 0)] : [wx - stage.x, cropToSpineY(wy - stage.y, stage.height)]);
|
|
343
|
+
const world = computeExactFrameTransforms(rig.bones);
|
|
344
|
+
const unstated = new Map<string, string>();
|
|
345
|
+
const bones: NormalBone[] = rig.bones.map((b: RigcBone) => {
|
|
346
|
+
const parent = b.parent ?? null;
|
|
347
|
+
const above = parent === null ? undefined : unstated.get(parent);
|
|
348
|
+
const why = b.from !== undefined ? 'takes its position from a cut manifest (bone.from), which is not read here' : above !== undefined ? `sits under "${parent}", whose position is not stated here` : undefined;
|
|
349
|
+
if (why !== undefined) {
|
|
350
|
+
unstated.set(b.name, why);
|
|
351
|
+
return { name: b.name, parent, origin: null, originWhy: why, tip: null, tipWhy: why };
|
|
352
|
+
}
|
|
353
|
+
const m = world.get(b.name);
|
|
354
|
+
if (m === undefined) throw new Error(`structure: rigc's frame transforms hold no "${b.name}"`);
|
|
355
|
+
const origin = toNormal(m.worldX, m.worldY);
|
|
356
|
+
const length = b.length ?? 0;
|
|
357
|
+
if (length === 0) {
|
|
358
|
+
return { name: b.name, parent, origin, originWhy: null, tip: null, tipWhy: b.length === undefined ? 'states no length (rigc\'s default, 0)' : 'length 0' };
|
|
359
|
+
}
|
|
360
|
+
const [tx, ty] = toWorld(m, length, 0);
|
|
361
|
+
const tip = toNormal(tx, ty);
|
|
362
|
+
if (same(origin, tip)) return { name: b.name, parent, origin, originWhy: null, tip: null, tipWhy: `length ${length} carries its tip nowhere in the world (its frame is scaled to 0)` };
|
|
363
|
+
return { name: b.name, parent, origin, originWhy: null, tip, tipWhy: null };
|
|
364
|
+
});
|
|
365
|
+
return { form: 'rig', label: `rig.json "${rig.name}"`, frame, bones, byName: new Map(bones.map((b) => [b.name, b])), roles };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
369
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A parsed file as a skeleton, told apart by what it states (see the module
|
|
374
|
+
* comment). `where` names the file in a refusal. Throws one `PartsError`
|
|
375
|
+
* with every problem.
|
|
376
|
+
*/
|
|
377
|
+
export function readSkeleton(raw: unknown, where: string): Skeleton {
|
|
378
|
+
if (!isObject(raw)) {
|
|
379
|
+
throw new PartsError([{ code: 'STRUCTURE_FORM', object: where, detail: `is ${Array.isArray(raw) ? 'an array' : JSON.stringify(raw)}; a rig.json, a config or a proposal (a JSON object) is required` }]);
|
|
380
|
+
}
|
|
381
|
+
if ('spec' in raw) {
|
|
382
|
+
if (raw.spec !== RIG_SPEC_VERSION) {
|
|
383
|
+
throw new PartsError([{ code: 'STRUCTURE_FORM', object: `${where} spec`, detail: `is ${JSON.stringify(raw.spec)}; a skeleton file that states a spec is a rig.json, "${RIG_SPEC_VERSION}" (a config or a proposal states none)` }]);
|
|
384
|
+
}
|
|
385
|
+
let rig: RigcSpec;
|
|
386
|
+
try {
|
|
387
|
+
rig = parseRigSpec(raw, where);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
if (err instanceof CompileError) throw new PartsError([{ code: 'STRUCTURE_RIG_SPEC', object: where, detail: `rig-c's rig spec reader refuses it: ${err.message}` }]);
|
|
390
|
+
throw err;
|
|
391
|
+
}
|
|
392
|
+
return rigSkeleton(rig, where);
|
|
393
|
+
}
|
|
394
|
+
if ('key' in raw) {
|
|
395
|
+
try {
|
|
396
|
+
const cfg = parseConfig(raw);
|
|
397
|
+
return configSkeleton(cfg, 'config', `config "${cfg.key}"`, cfg.assemble.rig_scale);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
if (err instanceof PartsError) throw new PartsError(err.problems.map((p) => ({ ...p, object: `${where} ${p.object}` })));
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if ('bones' in raw) {
|
|
404
|
+
try {
|
|
405
|
+
return configSkeleton(parseProposalSections(raw), 'proposal', 'proposal', null);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
if (err instanceof PartsError) throw new PartsError(err.problems.map((p) => ({ ...p, object: `${where} ${p.object}` })));
|
|
408
|
+
throw err;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
throw new PartsError([
|
|
412
|
+
{ code: 'STRUCTURE_FORM', object: where, detail: `states none of "spec" (a rig.json), "key" (a config) or "bones" (a proposal); its keys are ${JSON.stringify(Object.keys(raw))}` },
|
|
413
|
+
]);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// the bone map
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
export const BONEMAP_SPEC = 'spine-parts-bonemap/1';
|
|
421
|
+
|
|
422
|
+
const BONEMAP_KEYS = ['spec', 'pairs', 'required', 'frame', 'note'] as const;
|
|
423
|
+
|
|
424
|
+
/** A frame the map's author declared: a left point lands at `scale * p + offset` in the right's normal form. */
|
|
425
|
+
export interface DeclaredFrame {
|
|
426
|
+
scale: number;
|
|
427
|
+
offset: Point;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface BoneMap {
|
|
431
|
+
/** Null: no `pairs`, so bones pair by equal name. */
|
|
432
|
+
pairs: Array<[string, string]> | null;
|
|
433
|
+
/** Left names that must be present on both sides through the pairing. */
|
|
434
|
+
required: string[];
|
|
435
|
+
frame: DeclaredFrame | null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The map file. Every problem is collected and thrown once. Each pair's left
|
|
440
|
+
* name must be a left bone and its right name a right bone (a name on the
|
|
441
|
+
* other side only is named as such); each name pairs once. `required` names
|
|
442
|
+
* left bones — one that is absent is the comparison's FAIL, not the map's
|
|
443
|
+
* refusal. With a side that did not read (`null`), its names are not
|
|
444
|
+
* resolved; the shape is still checked.
|
|
445
|
+
*/
|
|
446
|
+
export function parseBoneMap(raw: unknown, where: string, left: Skeleton | null, right: Skeleton | null): BoneMap {
|
|
447
|
+
const problems: Problem[] = [];
|
|
448
|
+
const fail = (object: string, detail: string, code = 'STRUCTURE_MAP_FIELD'): void => {
|
|
449
|
+
problems.push({ code, object: `${where} ${object}`, detail });
|
|
450
|
+
};
|
|
451
|
+
if (!isObject(raw)) {
|
|
452
|
+
fail('', `is ${JSON.stringify(raw)}; a JSON object with "spec": "${BONEMAP_SPEC}" is required`);
|
|
453
|
+
throw new PartsError(problems);
|
|
454
|
+
}
|
|
455
|
+
for (const k of Object.keys(raw)) {
|
|
456
|
+
if (!(BONEMAP_KEYS as readonly string[]).includes(k)) fail(k, `is not a field of a bone map; known: ${BONEMAP_KEYS.join(', ')}`, 'STRUCTURE_MAP_KEY_KNOWN');
|
|
457
|
+
}
|
|
458
|
+
if (raw.spec !== BONEMAP_SPEC) fail('spec', `is ${raw.spec === undefined ? 'absent' : JSON.stringify(raw.spec)}; "${BONEMAP_SPEC}" is required`);
|
|
459
|
+
if ('note' in raw && typeof raw.note !== 'string') fail('note', `is ${JSON.stringify(raw.note)}; a string is required`);
|
|
460
|
+
let pairs: Array<[string, string]> | null = null;
|
|
461
|
+
if ('pairs' in raw) {
|
|
462
|
+
if (!Array.isArray(raw.pairs)) fail('pairs', `is ${JSON.stringify(raw.pairs)}; an array of [left name, right name] is required`);
|
|
463
|
+
else {
|
|
464
|
+
pairs = [];
|
|
465
|
+
const seen: [Map<string, number>, Map<string, number>] = [new Map(), new Map()];
|
|
466
|
+
(raw.pairs as unknown[]).forEach((p, i) => {
|
|
467
|
+
if (!(Array.isArray(p) && p.length === 2 && typeof p[0] === 'string' && typeof p[1] === 'string')) {
|
|
468
|
+
fail(`pairs[${i}]`, `is ${JSON.stringify(p)}; [left name, right name], two strings, is required`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const pair: [string, string] = [p[0], p[1]];
|
|
472
|
+
([0, 1] as const).forEach((s) => {
|
|
473
|
+
const side = s === 0 ? 'left' : 'right';
|
|
474
|
+
const own = s === 0 ? left : right;
|
|
475
|
+
const other = s === 0 ? right : left;
|
|
476
|
+
const name = pair[s];
|
|
477
|
+
if (own !== null && !own.byName.has(name)) {
|
|
478
|
+
const there = other !== null && other.byName.has(name) ? `; it is a bone of the ${s === 0 ? 'right' : 'left'} side, and a pair is [left, right]` : '; it is not a bone of either side';
|
|
479
|
+
fail(`pairs[${i}][${s}]`, `names "${name}", which is not a bone of the ${side} skeleton (${own.label})${there}`, 'STRUCTURE_MAP_RESOLVES');
|
|
480
|
+
}
|
|
481
|
+
const first = seen[s].get(name);
|
|
482
|
+
if (first !== undefined) fail(`pairs[${i}][${s}]`, `names the ${side} bone "${name}", which pairs[${first}] already pairs; each bone pairs once`, 'STRUCTURE_MAP_ONE_TO_ONE');
|
|
483
|
+
else seen[s].set(name, i);
|
|
484
|
+
});
|
|
485
|
+
pairs?.push(pair);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const required: string[] = [];
|
|
490
|
+
if ('required' in raw) {
|
|
491
|
+
if (!Array.isArray(raw.required)) fail('required', `is ${JSON.stringify(raw.required)}; an array of left bone names is required`);
|
|
492
|
+
else {
|
|
493
|
+
(raw.required as unknown[]).forEach((n, i) => {
|
|
494
|
+
if (typeof n !== 'string' || n === '') fail(`required[${i}]`, `is ${JSON.stringify(n)}; a bone name is required`);
|
|
495
|
+
else if (required.includes(n)) fail(`required[${i}]`, `names "${n}" a second time`);
|
|
496
|
+
else required.push(n);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
let frame: DeclaredFrame | null = null;
|
|
501
|
+
if ('frame' in raw) {
|
|
502
|
+
const f = raw.frame;
|
|
503
|
+
if (!isObject(f)) fail('frame', `is ${JSON.stringify(f)}; {"scale": <number above 0>, "offset": [x, y]} is required`);
|
|
504
|
+
else {
|
|
505
|
+
for (const k of Object.keys(f)) if (k !== 'scale' && k !== 'offset') fail(`frame.${k}`, 'is not a field of a declared frame; known: scale, offset', 'STRUCTURE_MAP_KEY_KNOWN');
|
|
506
|
+
const scaleOk = typeof f.scale === 'number' && Number.isFinite(f.scale) && f.scale > 0;
|
|
507
|
+
if (!scaleOk) fail('frame.scale', `is ${f.scale === undefined ? 'absent' : JSON.stringify(f.scale)}; a number above 0, written by the map's author, is required — none is assumed`);
|
|
508
|
+
const o = f.offset;
|
|
509
|
+
const offsetOk = Array.isArray(o) && o.length === 2 && o.every((v) => typeof v === 'number' && Number.isFinite(v));
|
|
510
|
+
if (!offsetOk) fail('frame.offset', `is ${o === undefined ? 'absent' : JSON.stringify(o)}; [x, y], two numbers written by the map's author, is required — none is assumed`);
|
|
511
|
+
if (scaleOk && offsetOk) frame = { scale: f.scale as number, offset: [(o as number[])[0], (o as number[])[1]] };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
refuseIfAny(problems);
|
|
515
|
+
return { pairs, required, frame };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// the comparison
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
export type FrameRelation = { kind: 'shared'; why: string } | { kind: 'declared'; scale: number; offset: Point; why: string } | { kind: 'none'; why: string };
|
|
523
|
+
|
|
524
|
+
export const FRAMES_NOT_RELATED = 'frames not related';
|
|
525
|
+
|
|
526
|
+
/** How two sides' frames are related: by the map's declaration, by both being rig px, or not at all. */
|
|
527
|
+
export function relateFrames(left: Skeleton, right: Skeleton, declared: DeclaredFrame | null): FrameRelation {
|
|
528
|
+
if (declared !== null) {
|
|
529
|
+
return { kind: 'declared', scale: declared.scale, offset: declared.offset, why: `declared by the map: right = ${declared.scale} * left + [${declared.offset[0]}, ${declared.offset[1]}]` };
|
|
530
|
+
}
|
|
531
|
+
const l = left.frame;
|
|
532
|
+
const r = right.frame;
|
|
533
|
+
if (l.kind === 'rig-px' && r.kind === 'rig-px') {
|
|
534
|
+
if (l.rigScale !== null && r.rigScale !== null && l.rigScale !== r.rigScale) {
|
|
535
|
+
return { kind: 'none', why: `${FRAMES_NOT_RELATED}: both are rig px, at rig_scale ${l.rigScale} and ${r.rigScale}, so their pixels are not one size, and the map declares no frame` };
|
|
536
|
+
}
|
|
537
|
+
return { kind: 'shared', why: 'related: both are rig px, y down' };
|
|
538
|
+
}
|
|
539
|
+
const which = l.kind === 'world' && r.kind === 'world' ? 'both sides are each in their own world' : `the ${l.kind === 'world' ? 'left' : 'right'} side is in its world, the other in rig px`;
|
|
540
|
+
return { kind: 'none', why: `${FRAMES_NOT_RELATED}: ${which}, no rule the files state relates them, and the map declares no frame` };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** A figure, or SKIP with why. */
|
|
544
|
+
export type Figure = { value: number } | { skip: string };
|
|
545
|
+
|
|
546
|
+
export type ParentVerdict =
|
|
547
|
+
| { kind: 'roots' }
|
|
548
|
+
| { kind: 'same'; left: string; right: string }
|
|
549
|
+
/** The left parent pairs with the right bone's ancestor at `depth` (2 = grandparent); `between` are right bones in no pair. */
|
|
550
|
+
| { kind: 'inserted-right'; left: string; right: string; depth: number; between: string[] }
|
|
551
|
+
/** The right parent pairs with the left bone's ancestor at `depth`; `between` are left bones in no pair. */
|
|
552
|
+
| { kind: 'inserted-left'; left: string; right: string; depth: number; between: string[] }
|
|
553
|
+
/** `mapped` is the left parent through the pairing (null when it pairs with nothing); `right` the right parent. */
|
|
554
|
+
| { kind: 'different'; left: string | null; mapped: string | null; right: string | null }
|
|
555
|
+
| { kind: 'not-mapped'; left: string };
|
|
556
|
+
|
|
557
|
+
export interface PairRow {
|
|
558
|
+
left: string;
|
|
559
|
+
right: string;
|
|
560
|
+
origin: Figure;
|
|
561
|
+
parent: ParentVerdict;
|
|
562
|
+
tip: Figure;
|
|
563
|
+
/** Each side's length in its own units (null with no tip), and right minus left through the frame. */
|
|
564
|
+
length: { left: number | null; right: number | null; difference: Figure };
|
|
565
|
+
/** Right's direction minus left's, degrees in (-180, 180], positive clockwise as drawn (y down). */
|
|
566
|
+
direction: Figure;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export interface RequiredMiss {
|
|
570
|
+
bone: string;
|
|
571
|
+
why: string;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export interface StructureComparison {
|
|
575
|
+
left: Skeleton;
|
|
576
|
+
right: Skeleton;
|
|
577
|
+
relation: FrameRelation;
|
|
578
|
+
pairing: 'name' | 'map';
|
|
579
|
+
rows: PairRow[];
|
|
580
|
+
unmappedLeft: string[];
|
|
581
|
+
unmappedRight: string[];
|
|
582
|
+
required: string[];
|
|
583
|
+
missing: RequiredMiss[];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function sideReasons(l: string | null, r: string | null): string | null {
|
|
587
|
+
const parts = [l === null ? null : `left: ${l}`, r === null ? null : `right: ${r}`].filter((s): s is string => s !== null);
|
|
588
|
+
return parts.length === 0 ? null : parts.join('; ');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function parentVerdict(l: NormalBone, r: NormalBone, left: Skeleton, right: Skeleton, fwd: Map<string, string>, inv: Map<string, string>): ParentVerdict {
|
|
592
|
+
const lp = l.parent;
|
|
593
|
+
const rp = r.parent;
|
|
594
|
+
if (lp === null && rp === null) return { kind: 'roots' };
|
|
595
|
+
if (lp === null || rp === null) return { kind: 'different', left: lp, mapped: lp === null ? null : (fwd.get(lp) ?? null), right: rp };
|
|
596
|
+
const m = fwd.get(lp);
|
|
597
|
+
if (m === rp) return { kind: 'same', left: lp, right: rp };
|
|
598
|
+
// A bone inserted between, on either side: walk up from the parent over
|
|
599
|
+
// bones in no pair; the first paired ancestor must be the other's parent.
|
|
600
|
+
const insertedUnder = (start: string, side: Skeleton, paired: Map<string, string>, want: string): { depth: number; between: string[] } | null => {
|
|
601
|
+
const between: string[] = [];
|
|
602
|
+
let at: string | null = start;
|
|
603
|
+
while (at !== null && !paired.has(at)) {
|
|
604
|
+
between.push(at);
|
|
605
|
+
at = side.byName.get(at)?.parent ?? null;
|
|
606
|
+
}
|
|
607
|
+
return at === want && between.length > 0 ? { depth: between.length + 1, between } : null;
|
|
608
|
+
};
|
|
609
|
+
if (m !== undefined) {
|
|
610
|
+
const ins = insertedUnder(rp, right, inv, m);
|
|
611
|
+
if (ins !== null) return { kind: 'inserted-right', left: lp, right: m, ...ins };
|
|
612
|
+
}
|
|
613
|
+
const mi = inv.get(rp);
|
|
614
|
+
if (mi !== undefined) {
|
|
615
|
+
const ins = insertedUnder(lp, left, fwd, mi);
|
|
616
|
+
if (ins !== null) return { kind: 'inserted-left', left: mi, right: rp, ...ins };
|
|
617
|
+
}
|
|
618
|
+
if (m === undefined) return { kind: 'not-mapped', left: lp };
|
|
619
|
+
return { kind: 'different', left: lp, mapped: m, right: rp };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function direction(o: Point, t: Point): number {
|
|
623
|
+
return (Math.atan2(t[1] - o[1], t[0] - o[0]) * 180) / Math.PI;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function pairRow(l: NormalBone, r: NormalBone, left: Skeleton, right: Skeleton, rel: FrameRelation, fwd: Map<string, string>, inv: Map<string, string>): PairRow {
|
|
627
|
+
const scale = rel.kind === 'declared' ? rel.scale : 1;
|
|
628
|
+
const offset: Point = rel.kind === 'declared' ? rel.offset : [0, 0];
|
|
629
|
+
const carry = (p: Point): Point => [scale * p[0] + offset[0], scale * p[1] + offset[1]];
|
|
630
|
+
const geometric = (lw: string | null, rw: string | null): string | null => {
|
|
631
|
+
const sides = sideReasons(lw, rw);
|
|
632
|
+
if (rel.kind === 'none') return sides === null ? FRAMES_NOT_RELATED : `${FRAMES_NOT_RELATED}; ${sides}`;
|
|
633
|
+
return sides;
|
|
634
|
+
};
|
|
635
|
+
const originWhy = geometric(l.originWhy, r.originWhy);
|
|
636
|
+
const origin: Figure = originWhy !== null || l.origin === null || r.origin === null ? { skip: originWhy ?? '' } : { value: Math.hypot(carry(l.origin)[0] - r.origin[0], carry(l.origin)[1] - r.origin[1]) };
|
|
637
|
+
const tipWhy = geometric(l.tipWhy, r.tipWhy);
|
|
638
|
+
const lo = l.origin;
|
|
639
|
+
const ro = r.origin;
|
|
640
|
+
const lt = l.tip;
|
|
641
|
+
const rt = r.tip;
|
|
642
|
+
const both = lo !== null && ro !== null && lt !== null && rt !== null;
|
|
643
|
+
const tip: Figure = tipWhy !== null || !both ? { skip: tipWhy ?? '' } : { value: Math.hypot(carry(lt)[0] - rt[0], carry(lt)[1] - rt[1]) };
|
|
644
|
+
const ll = lo !== null && lt !== null ? Math.hypot(lt[0] - lo[0], lt[1] - lo[1]) : null;
|
|
645
|
+
const rl = ro !== null && rt !== null ? Math.hypot(rt[0] - ro[0], rt[1] - ro[1]) : null;
|
|
646
|
+
const difference: Figure = tipWhy !== null || ll === null || rl === null ? { skip: tipWhy ?? '' } : { value: rl - scale * ll };
|
|
647
|
+
const dir: Figure = tipWhy !== null || !both ? { skip: tipWhy ?? '' } : { value: normaliseDegrees(direction(ro, rt) - direction(lo, lt)) };
|
|
648
|
+
return { left: l.name, right: r.name, origin, parent: parentVerdict(l, r, left, right, fwd, inv), tip, length: { left: ll, right: rl, difference }, direction: dir };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** The comparison of two skeletons through a map (or equal names), every row a figure or a SKIP with why. */
|
|
652
|
+
export function compareStructure(left: Skeleton, right: Skeleton, map: BoneMap | null): StructureComparison {
|
|
653
|
+
const relation = relateFrames(left, right, map?.frame ?? null);
|
|
654
|
+
const pairs: Array<[string, string]> = map?.pairs ?? left.bones.filter((b) => right.byName.has(b.name)).map((b): [string, string] => [b.name, b.name]);
|
|
655
|
+
const fwd = new Map(pairs);
|
|
656
|
+
const inv = new Map(pairs.map(([a, b]) => [b, a]));
|
|
657
|
+
// Rows in the left file's order, so a reader walks the left skeleton top down.
|
|
658
|
+
const rows = left.bones
|
|
659
|
+
.filter((b) => fwd.has(b.name))
|
|
660
|
+
.map((b) => pairRow(b, right.byName.get(fwd.get(b.name) as string) as NormalBone, left, right, relation, fwd, inv));
|
|
661
|
+
const required = map?.required ?? [];
|
|
662
|
+
const missing: RequiredMiss[] = [];
|
|
663
|
+
for (const n of required) {
|
|
664
|
+
if (!left.byName.has(n)) missing.push({ bone: n, why: `is not a bone of the left skeleton (${left.label})` });
|
|
665
|
+
else if (!fwd.has(n)) missing.push({ bone: n, why: map === null || map.pairs === null ?`is not a bone of the right skeleton (${right.label}), and with no pairs in the map only an equal name pairs` : 'is a bone of the left skeleton, and the map pairs it with nothing' });
|
|
666
|
+
else if (!right.byName.has(fwd.get(n) as string)) missing.push({ bone: n, why: `pairs with "${fwd.get(n)}", which is not a bone of the right skeleton (${right.label})` });
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
left,
|
|
670
|
+
right,
|
|
671
|
+
relation,
|
|
672
|
+
pairing: map === null || map.pairs === null ? 'name' : 'map',
|
|
673
|
+
rows,
|
|
674
|
+
unmappedLeft: left.bones.filter((b) => !fwd.has(b.name)).map((b) => b.name),
|
|
675
|
+
unmappedRight: right.bones.filter((b) => !inv.has(b.name)).map((b) => b.name),
|
|
676
|
+
required,
|
|
677
|
+
missing,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** The roles a skeleton carries, counted, in `BONE_ROLES` order. */
|
|
682
|
+
export function roleCounts(s: Skeleton): Record<BoneRole, number> {
|
|
683
|
+
const out: Record<BoneRole, number> = { target: 0, deforms: 0, control: 0, unclassified: 0 };
|
|
684
|
+
for (const r of s.roles.values()) out[r.role]++;
|
|
685
|
+
return out;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
// the printout
|
|
690
|
+
// ---------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
/** Three places; a figure, not a rounding of a bar — every figure is printed and none is judged. */
|
|
693
|
+
function f3(v: number): string {
|
|
694
|
+
return v.toFixed(3);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function signed(v: number): string {
|
|
698
|
+
const s = v.toFixed(3);
|
|
699
|
+
return s.startsWith('-') ? s : `+${s}`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function figure(f: Figure, unit: string, sign = false): string {
|
|
703
|
+
return 'value' in f ? `${sign ? signed(f.value) : f3(f.value)} ${unit}` : `SKIP (${f.skip})`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function parentText(p: ParentVerdict): string {
|
|
707
|
+
switch (p.kind) {
|
|
708
|
+
case 'roots':
|
|
709
|
+
return 'parent: both roots';
|
|
710
|
+
case 'same':
|
|
711
|
+
return p.left === p.right ? `parent same: ${p.left}` : `parent same: ${p.left} -> ${p.right}`;
|
|
712
|
+
case 'inserted-right':
|
|
713
|
+
return `parent ${p.left}${p.left === p.right ? '' : ` -> ${p.right}`} is the right's ancestor at depth ${p.depth}, ${p.between.join(', ')} between (in no pair)`;
|
|
714
|
+
case 'inserted-left':
|
|
715
|
+
return `parent ${p.right}${p.left === p.right ? '' : ` (left ${p.left})`} is the left's ancestor at depth ${p.depth}, ${p.between.join(', ')} between (in no pair)`;
|
|
716
|
+
case 'different':
|
|
717
|
+
return `parent DIFFERENT: left ${p.left ?? '(none: a root)'}${p.left !== null && p.mapped !== null && p.mapped !== p.left ? ` -> ${p.mapped}` : ''}, right ${p.right ?? '(none: a root)'}`;
|
|
718
|
+
case 'not-mapped':
|
|
719
|
+
return `parent NOT MAPPED: the left parent ${p.left} is in no pair`;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function rowLine(r: PairRow): string {
|
|
724
|
+
const name = r.left === r.right ? r.left : `${r.left} -> ${r.right}`;
|
|
725
|
+
const cells = [`origin ${figure(r.origin, 'px')}`, parentText(r.parent)];
|
|
726
|
+
const tipSkip = 'skip' in r.tip && 'skip' in r.direction && 'skip' in r.length.difference && r.length.left === null && r.length.right === null;
|
|
727
|
+
if (tipSkip && 'skip' in r.tip) cells.push(`tip, length, direction SKIP (${r.tip.skip})`);
|
|
728
|
+
else {
|
|
729
|
+
cells.push(`tip ${figure(r.tip, 'px')}`);
|
|
730
|
+
const ll = r.length.left === null ? '-' : f3(r.length.left);
|
|
731
|
+
const rl = r.length.right === null ? '-' : f3(r.length.right);
|
|
732
|
+
cells.push(`length ${ll} -> ${rl}, difference ${figure(r.length.difference, 'px', true)}`);
|
|
733
|
+
cells.push(`direction ${figure(r.direction, 'deg', true)}`);
|
|
734
|
+
}
|
|
735
|
+
return ` ${name}: ${cells.join(' · ')}`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function summary(label: string, rows: PairRow[], pick: (r: PairRow) => Figure, unit: string, abs: boolean): string {
|
|
739
|
+
let n = 0;
|
|
740
|
+
let skip = 0;
|
|
741
|
+
let max = -1;
|
|
742
|
+
let at = '';
|
|
743
|
+
for (const r of rows) {
|
|
744
|
+
const f = pick(r);
|
|
745
|
+
if ('skip' in f) {
|
|
746
|
+
skip++;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
n++;
|
|
750
|
+
const v = abs ? Math.abs(f.value) : f.value;
|
|
751
|
+
if (v > max) {
|
|
752
|
+
max = v;
|
|
753
|
+
at = r.left === r.right ? r.left : `${r.left} -> ${r.right}`;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return ` ${label}: ${n} measured${n > 0 ? `, max ${abs ? '|difference| ' : ''}${f3(max)} ${unit} (${at})` : ''}, ${skip} SKIP`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function roleLine(side: 'left' | 'right', s: Skeleton): string[] {
|
|
760
|
+
const c = roleCounts(s);
|
|
761
|
+
const out = [` roles, ${side} (${s.label}): ${BONE_ROLES.map((r) => `${c[r]} ${r}`).join(', ')}`];
|
|
762
|
+
for (const [n, r] of s.roles) if (r.role === 'unclassified') out.push(` unclassified ${n}: ${r.why}`);
|
|
763
|
+
return out;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** The comparison as printed lines; the CLI adds the FAIL lines for missing required bones. */
|
|
767
|
+
export function structureLines(c: StructureComparison): string[] {
|
|
768
|
+
const out: string[] = [];
|
|
769
|
+
out.push(` left: ${c.left.label}, ${c.left.bones.length} bone(s); ${c.left.frame.rule}`);
|
|
770
|
+
out.push(` right: ${c.right.label}, ${c.right.bones.length} bone(s); ${c.right.frame.rule}`);
|
|
771
|
+
out.push(` frames: ${c.relation.why}${c.relation.kind === 'none' ? '; origin, tip, the length difference and direction say SKIP, and parents, required bones and roles still run' : ''}`);
|
|
772
|
+
out.push(c.pairing === 'name' ? ` pairing: by equal name, ${c.rows.length} pair(s); nothing pairs by resemblance` : ` pairing: by the map's pairs only, ${c.rows.length} pair(s); a bone in no pair is listed below`);
|
|
773
|
+
for (const r of c.rows) out.push(rowLine(r));
|
|
774
|
+
out.push(summary('origin', c.rows, (r) => r.origin, 'px', false));
|
|
775
|
+
const k = (kind: ParentVerdict['kind']): number => c.rows.filter((r) => r.parent.kind === kind).length;
|
|
776
|
+
out.push(
|
|
777
|
+
` parent: ${k('same')} same, ${k('inserted-right') + k('inserted-left')} with a bone between (in no pair), ${k('different')} DIFFERENT, ${k('not-mapped')} NOT MAPPED, ${k('roots')} both roots`,
|
|
778
|
+
);
|
|
779
|
+
out.push(summary('tip', c.rows, (r) => r.tip, 'px', false));
|
|
780
|
+
out.push(summary('length', c.rows, (r) => r.length.difference, 'px', true));
|
|
781
|
+
out.push(summary('direction', c.rows, (r) => r.direction, 'deg', true));
|
|
782
|
+
out.push(` unmapped on the left (${c.unmappedLeft.length}): ${c.unmappedLeft.join(', ') || '-'}`);
|
|
783
|
+
out.push(` unmapped on the right (${c.unmappedRight.length}): ${c.unmappedRight.join(', ') || '-'}`);
|
|
784
|
+
out.push(
|
|
785
|
+
c.required.length === 0
|
|
786
|
+
? ' required: none named (a map\'s "required" lists them)'
|
|
787
|
+
: c.missing.length === 0
|
|
788
|
+
? ` required: ${c.required.length} named, every one present on both sides through the pairing`
|
|
789
|
+
: ` required: ${c.required.length} named, ${c.missing.length} missing (FAIL lines below)`,
|
|
790
|
+
);
|
|
791
|
+
out.push(...roleLine('left', c.left), ...roleLine('right', c.right));
|
|
792
|
+
const lc = roleCounts(c.left);
|
|
793
|
+
const rc = roleCounts(c.right);
|
|
794
|
+
const differ = lc.control !== rc.control || lc.target !== rc.target;
|
|
795
|
+
out.push(` controls: left ${lc.control}, right ${rc.control}; targets: left ${lc.target}, right ${rc.target} — ${differ ? 'the counts differ; reported, not failed' : 'the counts agree'}`);
|
|
796
|
+
return out;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** The FAIL problems of a comparison: one per required bone missing. */
|
|
800
|
+
export function requiredProblems(c: StructureComparison, mapWhere: string): Problem[] {
|
|
801
|
+
return c.missing.map((m) => ({ code: 'STRUCTURE_REQUIRED_PRESENT', object: `bone "${m.bone}"`, detail: `is required by ${mapWhere}, and ${m.why}; it must be present on both sides through the pairing` }));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
// the files
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
function readJson(path: string, problems: Problem[]): unknown {
|
|
809
|
+
if (!existsSync(path)) {
|
|
810
|
+
problems.push({ code: 'STRUCTURE_FILE_PRESENT', object: path, detail: 'no such file' });
|
|
811
|
+
return undefined;
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
815
|
+
} catch (err) {
|
|
816
|
+
problems.push({ code: 'STRUCTURE_FILE_IS_JSON', object: path, detail: `does not parse as JSON: ${(err as Error).message}` });
|
|
817
|
+
return undefined;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Read the two skeletons and the map, collecting every problem across the
|
|
823
|
+
* three files, and compare. Throws one `PartsError` naming them all.
|
|
824
|
+
*/
|
|
825
|
+
export function loadComparison(leftPath: string, rightPath: string, mapPath: string | null): StructureComparison {
|
|
826
|
+
const problems: Problem[] = [];
|
|
827
|
+
const side = (path: string): Skeleton | null => {
|
|
828
|
+
const before = problems.length;
|
|
829
|
+
const raw = readJson(path, problems);
|
|
830
|
+
if (problems.length > before) return null;
|
|
831
|
+
try {
|
|
832
|
+
return readSkeleton(raw, path);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
if (err instanceof PartsError) {
|
|
835
|
+
problems.push(...err.problems);
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
throw err;
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
const left = side(leftPath);
|
|
842
|
+
const right = side(rightPath);
|
|
843
|
+
let map: BoneMap | null = null;
|
|
844
|
+
if (mapPath !== null) {
|
|
845
|
+
const before = problems.length;
|
|
846
|
+
const raw = readJson(mapPath, problems);
|
|
847
|
+
if (problems.length === before) {
|
|
848
|
+
try {
|
|
849
|
+
map = parseBoneMap(raw, mapPath, left, right);
|
|
850
|
+
} catch (err) {
|
|
851
|
+
if (err instanceof PartsError) problems.push(...err.problems);
|
|
852
|
+
else throw err;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
refuseIfAny(problems);
|
|
857
|
+
return compareStructure(left as Skeleton, right as Skeleton, map);
|
|
858
|
+
}
|