@rig-c/core 2.41.0 → 2.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/docs/AUTHORING.md +60 -14
- package/package.json +1 -1
- package/src/cli/core_commands.ts +36 -5
- package/src/cli/shared.ts +24 -5
- package/src/inspect.ts +889 -216
package/src/inspect.ts
CHANGED
|
@@ -25,13 +25,27 @@
|
|
|
25
25
|
* refused naming the two grid times either side rather than posed some other
|
|
26
26
|
* way — a second recipe for one moment would be a second opinion about the pose.
|
|
27
27
|
*
|
|
28
|
+
* ⭐ **What a bone moves is read where it is written** (cut 2). Mesh edges and
|
|
29
|
+
* hulls are the topology the poser's own rest table states (`Poser.rest`, the
|
|
30
|
+
* table `render --geometry` writes), drawn through the posed vertices the frame
|
|
31
|
+
* carries; a bone's weights are read off the build's `skeleton.json`, the file
|
|
32
|
+
* both posers derive their skinning from, never re-derived from a pose.
|
|
33
|
+
*
|
|
34
|
+
* ⭐ **Two builds share one transform** (`--compare`). Both are posed at the
|
|
35
|
+
* same times; the world box is the union of what each would be framed to (or
|
|
36
|
+
* the one a viewport pins), so a mark that sits on a different pixel in the two
|
|
37
|
+
* halves sits at a different place in the two rigs.
|
|
38
|
+
*
|
|
28
39
|
* Links nothing from the runtime: the poser is handed in.
|
|
29
40
|
*/
|
|
41
|
+
import { createHash } from 'node:crypto';
|
|
30
42
|
import { drawText, GLYPH_H, textWidth } from '../tools/font5x7.ts';
|
|
31
43
|
import { Plate, type RGBA } from '../tools/plate.ts';
|
|
32
44
|
import { lineGap, worldAxisDegrees } from './assertions/bodies/a51.ts';
|
|
33
45
|
import {
|
|
34
46
|
BACKGROUND,
|
|
47
|
+
type AttachmentPose,
|
|
48
|
+
type AttachmentRest,
|
|
35
49
|
type BoneSnapshot,
|
|
36
50
|
type Candidate,
|
|
37
51
|
type Frame,
|
|
@@ -54,10 +68,13 @@ import {
|
|
|
54
68
|
/** The legend's format tag. */
|
|
55
69
|
export const INSPECT_SPEC = 'rigc-inspect/1';
|
|
56
70
|
|
|
57
|
-
/** Every word `--show` takes, in the order the picture draws them (bottom first). */
|
|
58
|
-
export const SHOW_WORDS = ['art', 'grid', 'declared', 'bones', 'axes', 'names'] as const;
|
|
71
|
+
/** Every word `--show` takes, in the order the picture draws them (bottom first). `weights` is spelled `weights:<bone>`. */
|
|
72
|
+
export const SHOW_WORDS = ['art', 'grid', 'weights', 'mesh', 'hull', 'declared', 'bones', 'axes', 'slots', 'names'] as const;
|
|
59
73
|
export type ShowWord = (typeof SHOW_WORDS)[number];
|
|
60
74
|
|
|
75
|
+
/** The words as a refusal lists them — `weights` with the bone it needs. */
|
|
76
|
+
export const SHOW_SPELLINGS: readonly string[] = SHOW_WORDS.map((w) => (w === 'weights' ? 'weights:<bone>' : w));
|
|
77
|
+
|
|
61
78
|
/** What `--show` draws when it is not given. */
|
|
62
79
|
export const DEFAULT_SHOW: readonly ShowWord[] = ['art', 'bones'];
|
|
63
80
|
|
|
@@ -84,21 +101,53 @@ export const AXIS_PIXELS = 24;
|
|
|
84
101
|
/** `--dim`: every art pixel is moved toward white, keeping this share of its distance from white. */
|
|
85
102
|
export const DIM_KEEP = 0.4;
|
|
86
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The weight heat's colour ramp: piecewise linear between these stops, each
|
|
106
|
+
* channel rounded to the nearest whole value. A pixel whose weight is below
|
|
107
|
+
* `WEIGHT_FLOOR` is not painted, so the art shows where the bone does not reach.
|
|
108
|
+
*/
|
|
109
|
+
export const WEIGHT_RAMP: ReadonlyArray<{ weight: number; rgba: readonly [number, number, number, number] }> = [
|
|
110
|
+
{ weight: 0, rgba: [40, 80, 230, 255] },
|
|
111
|
+
{ weight: 0.5, rgba: [250, 205, 0, 255] },
|
|
112
|
+
{ weight: 1, rgba: [215, 20, 20, 255] },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** Below this weight a heat pixel is not painted. Not a rounding of any weight: it only keeps a weight of 0 that a barycentric sum spelled as 1e-17 off the picture. */
|
|
116
|
+
export const WEIGHT_FLOOR = 1e-6;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* How many lines down a label is moved, one text line at a time, looking for a
|
|
120
|
+
* place no earlier label holds. Past this it stays where it began and the
|
|
121
|
+
* legend says it overlaps.
|
|
122
|
+
*/
|
|
123
|
+
export const LABEL_TRIES = 16;
|
|
124
|
+
|
|
87
125
|
/** What each colour the picture draws means — the legend repeats this table. */
|
|
88
126
|
export const INSPECT_COLOURS = {
|
|
89
127
|
bone: { rgba: [0, 110, 255, 255], meaning: "a bone's line, from its origin to its tip (origin + length along its world x axis)" },
|
|
90
128
|
origin: { rgba: [235, 0, 190, 255], meaning: "a bone's origin: a filled 3x3 square centred on the origin's pixel" },
|
|
91
129
|
tip: { rgba: [255, 140, 0, 255], meaning: "a bone's tip: a hollow 3x3 square centred on the tip's pixel" },
|
|
92
130
|
zero: { rgba: [255, 210, 0, 255], meaning: 'a bone of length 0: a hollow 5x5 square around its origin, which is also its tip' },
|
|
93
|
-
name: {
|
|
131
|
+
name: {
|
|
132
|
+
rgba: [25, 25, 25, 255],
|
|
133
|
+
meaning:
|
|
134
|
+
"a name label, starting 4 px right of and 3 px below its origin's pixel: one per pixel, so bones whose origins share a pixel " +
|
|
135
|
+
'are one label, the first by declaration order and "+N" for the rest (the legend\'s labels list every bone in it); a label ' +
|
|
136
|
+
'that would overprint an earlier one is moved down a line at a time, with a leader back to its origin',
|
|
137
|
+
},
|
|
138
|
+
leader: { rgba: [120, 120, 120, 255], meaning: "a label's leader, from its anchor pixel to the label — drawn where the label was moved to clear an earlier one" },
|
|
94
139
|
axisX: { rgba: [215, 30, 30, 255], meaning: `a bone's world x axis from its origin, ${AXIS_PIXELS} px long at a world scale of 1` },
|
|
95
140
|
axisY: { rgba: [20, 160, 40, 255], meaning: `a bone's world y axis from its origin, ${AXIS_PIXELS} px long at a world scale of 1` },
|
|
96
141
|
grid: { rgba: [150, 150, 150, 255], meaning: 'a grid line at a whole multiple of the step, in world units, with its value at the edge' },
|
|
97
142
|
gridZero: { rgba: [70, 70, 70, 255], meaning: 'the grid line at world x = 0 or y = 0' },
|
|
98
143
|
declared: { rgba: [0, 190, 190, 255], meaning: 'an invariants.axes line, through its two bones\' origins, across the whole tile' },
|
|
99
144
|
held: { rgba: [0, 95, 95, 255], meaning: 'the line of a bone an invariants.axes entry holds to its line, in place of the bone colour' },
|
|
100
|
-
|
|
101
|
-
|
|
145
|
+
mesh: { rgba: [90, 30, 160, 255], meaning: "a mesh's triangle edge between two posed vertices — meshes only; a region is not drawn" },
|
|
146
|
+
hull: { rgba: [0, 150, 90, 255], meaning: "a mesh's hull, vertex 0 to 1 to … to hull−1 and back, drawn over its triangle edges — meshes only" },
|
|
147
|
+
slot: { rgba: [150, 0, 120, 255], meaning: "a slot's place in the posed draw order (0 is drawn first), from the centre of its attachment's box, on a white box" },
|
|
148
|
+
slotBack: { rgba: [255, 255, 255, 255], meaning: 'the box under a slot number' },
|
|
149
|
+
label: { rgba: [...SHEET_LABEL], meaning: "a tile's build and poser (with --compare), time and frame (on a sheet), at its bottom-left corner" },
|
|
150
|
+
rule: { rgba: [...SHEET_RULE], meaning: 'the gap between two tiles' },
|
|
102
151
|
} as const satisfies Record<string, { rgba: readonly number[]; meaning: string }>;
|
|
103
152
|
|
|
104
153
|
type ColourName = keyof typeof INSPECT_COLOURS;
|
|
@@ -113,9 +162,21 @@ export type InspectFrame =
|
|
|
113
162
|
| { kind: 'zoom'; target: string; pad: number | null }
|
|
114
163
|
| { kind: 'viewport'; x: number; y: number; width: number; height: number };
|
|
115
164
|
|
|
165
|
+
/** `--bone` / `--hide-bone`: which bones the bone views draw, by glob. */
|
|
166
|
+
export interface BoneFilter {
|
|
167
|
+
/** Draw only the bones some pattern matches; absent draws every bone. */
|
|
168
|
+
include?: readonly string[];
|
|
169
|
+
/** Draw none of the bones a pattern matches — applied after `include`. */
|
|
170
|
+
exclude?: readonly string[];
|
|
171
|
+
}
|
|
172
|
+
|
|
116
173
|
/** One `rigc inspect` invocation, its flags read but not yet checked against the build. */
|
|
117
174
|
export interface InspectRequest {
|
|
118
175
|
show: readonly ShowWord[];
|
|
176
|
+
/** The bone `weights:<bone>` names — present exactly when `show` holds `weights`. */
|
|
177
|
+
weightsBone?: string;
|
|
178
|
+
/** Which bones `bones`, `axes` and `names` draw; absent is every one. */
|
|
179
|
+
boneFilter?: BoneFilter;
|
|
119
180
|
dim: boolean;
|
|
120
181
|
/** `null` is the setup pose. */
|
|
121
182
|
animation: string | null;
|
|
@@ -129,12 +190,18 @@ export interface InspectRequest {
|
|
|
129
190
|
|
|
130
191
|
/** What the picture is made from besides the poser: the build's own texts. */
|
|
131
192
|
export interface InspectTexts {
|
|
132
|
-
/** `skeleton.json` — read for each bone's `length`, which no pose carries. */
|
|
193
|
+
/** `skeleton.json` — read for each bone's `length`, which no pose carries, and for each attachment's weights. */
|
|
133
194
|
skeletonText: string;
|
|
134
195
|
/** The `skeleton.model.json` that is this skeleton's, or `undefined` — what `declared` reads `rig.axes` from. */
|
|
135
196
|
modelText: string | undefined;
|
|
136
197
|
}
|
|
137
198
|
|
|
199
|
+
/** The second build `--compare` draws beside the first. */
|
|
200
|
+
export interface InspectCompare {
|
|
201
|
+
candidate: Candidate;
|
|
202
|
+
texts: InspectTexts;
|
|
203
|
+
}
|
|
204
|
+
|
|
138
205
|
/**
|
|
139
206
|
* A planted defect, for the suite's mutants only (`IN` controls in
|
|
140
207
|
* `selftest.ts`). Each one is a way the picture or the legend can be wrong that
|
|
@@ -151,6 +218,22 @@ export interface InspectPlant {
|
|
|
151
218
|
unflipped?: boolean;
|
|
152
219
|
/** Zoom to this bone, whatever the request named. */
|
|
153
220
|
zoomBone?: string;
|
|
221
|
+
/** Draw every mesh edge this many pixels off the vertices the legend states. */
|
|
222
|
+
meshShift?: [number, number];
|
|
223
|
+
/** Paint (and state) this bone's weights, whatever `weights:<bone>` named. */
|
|
224
|
+
weightsOf?: string;
|
|
225
|
+
/** Number the slots in the reverse of the posed draw order. */
|
|
226
|
+
slotsReversed?: boolean;
|
|
227
|
+
/** Draw every triangle edge in the hull's colour. */
|
|
228
|
+
hullAllEdges?: boolean;
|
|
229
|
+
/** One label per bone, stacked under a shared pixel as cut 1 printed them. */
|
|
230
|
+
noFold?: boolean;
|
|
231
|
+
/** Place every label where it begins, over whatever is there. */
|
|
232
|
+
noAvoid?: boolean;
|
|
233
|
+
/** Draw every bone whatever `--bone`/`--hide-bone` say. */
|
|
234
|
+
ignoreFilter?: boolean;
|
|
235
|
+
/** Draw the first build in both halves of a `--compare`. */
|
|
236
|
+
compareFirstTwice?: boolean;
|
|
154
237
|
}
|
|
155
238
|
|
|
156
239
|
/** A point in both spaces. */
|
|
@@ -169,7 +252,7 @@ export interface LegendBone {
|
|
|
169
252
|
length: number;
|
|
170
253
|
/** `length` is 0: origin and tip are one point, marked by the `zero` square. */
|
|
171
254
|
zeroLength: boolean;
|
|
172
|
-
/** `null` when the bone is drawn; otherwise why it is not (its world matrix is 0). */
|
|
255
|
+
/** `null` when the bone is drawn; otherwise why it is not (its world matrix is 0, or a bone filter left it out). */
|
|
173
256
|
undrawn: string | null;
|
|
174
257
|
origin: LegendPoint;
|
|
175
258
|
tip: LegendPoint;
|
|
@@ -177,6 +260,62 @@ export interface LegendBone {
|
|
|
177
260
|
axes?: { x: LegendPoint; y: LegendPoint };
|
|
178
261
|
}
|
|
179
262
|
|
|
263
|
+
/** A box of picture pixels: columns `x .. x+width-1`, rows `y .. y+height-1`. */
|
|
264
|
+
export interface PixelBox {
|
|
265
|
+
x: number;
|
|
266
|
+
y: number;
|
|
267
|
+
width: number;
|
|
268
|
+
height: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** One name label in one tile. */
|
|
272
|
+
export interface LegendLabel {
|
|
273
|
+
text: string;
|
|
274
|
+
/** Every bone the label stands for, in declaration order — the first is the one named, the rest are the "+N". */
|
|
275
|
+
bones: string[];
|
|
276
|
+
/** The origin pixel the bones share. */
|
|
277
|
+
anchor: [number, number];
|
|
278
|
+
/** Where the text is drawn. */
|
|
279
|
+
box: PixelBox;
|
|
280
|
+
/** It was moved down to clear an earlier label, and a leader joins it to `anchor`. */
|
|
281
|
+
moved: boolean;
|
|
282
|
+
/** No place within `LABEL_TRIES` lines was clear, so it stays where it began, over an earlier label. */
|
|
283
|
+
overlaps: boolean;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** One region or mesh the frame shows, when `mesh`, `hull` or `weights` is drawn. */
|
|
287
|
+
export interface LegendMesh {
|
|
288
|
+
slot: string;
|
|
289
|
+
attachment: string;
|
|
290
|
+
kind: 'region' | 'mesh';
|
|
291
|
+
vertexCount: number;
|
|
292
|
+
triangleCount: number;
|
|
293
|
+
/** A mesh's hull vertex count (vertices 0..hull−1); `null` for a region. */
|
|
294
|
+
hull: number | null;
|
|
295
|
+
/** Its unique triangle edges, each `[i, j]` with `i < j`, in the order the triangles first name them. */
|
|
296
|
+
edges: Array<[number, number]>;
|
|
297
|
+
/** Posed vertices, `x, y` per vertex: world units, and the picture's pixels. */
|
|
298
|
+
world: number[];
|
|
299
|
+
pixel: number[];
|
|
300
|
+
/** With `weights:<bone>`: that bone's weight at each vertex. */
|
|
301
|
+
weights?: number[];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** One slot that shows a region or mesh at this tile's pose. */
|
|
305
|
+
export interface LegendSlot {
|
|
306
|
+
/** Its place in the posed draw order among the slots that show a region or mesh — 0 is drawn first. */
|
|
307
|
+
order: number;
|
|
308
|
+
slot: string;
|
|
309
|
+
attachment: string;
|
|
310
|
+
bone: string;
|
|
311
|
+
/** The centre of its posed vertices' box, where its number is drawn from. */
|
|
312
|
+
anchor: LegendPoint;
|
|
313
|
+
/** Where the number is drawn: the white box under it, text 1 px inside. */
|
|
314
|
+
box: PixelBox;
|
|
315
|
+
moved: boolean;
|
|
316
|
+
overlaps: boolean;
|
|
317
|
+
}
|
|
318
|
+
|
|
180
319
|
/** One `invariants.axes` entry in one tile. */
|
|
181
320
|
export interface LegendDeclared {
|
|
182
321
|
name: string;
|
|
@@ -192,6 +331,8 @@ export interface LegendDeclared {
|
|
|
192
331
|
}
|
|
193
332
|
|
|
194
333
|
export interface LegendTile {
|
|
334
|
+
/** With `--compare`: which build this tile is — `A` is `--candidate`, `B` is `--compare`. */
|
|
335
|
+
build?: 'A' | 'B';
|
|
195
336
|
/** `null` for the setup pose. */
|
|
196
337
|
animation: string | null;
|
|
197
338
|
/** The frame of `render --fps <fps>` this pose is, and its time `frame / fps`. */
|
|
@@ -201,15 +342,21 @@ export interface LegendTile {
|
|
|
201
342
|
box: { x: number; y: number; width: number; height: number };
|
|
202
343
|
/** The tile's transform. `px = x0 + (x - minX)·scale`, `py = y0 + (maxY - y)·scale`; back: `x = minX + (px - x0)/scale`, `y = maxY - (py - y0)/scale`. */
|
|
203
344
|
transform: { minX: number; maxY: number; scale: number; x0: number; y0: number };
|
|
345
|
+
/** On a sheet or a compare: the tile's label and the box it is drawn on. */
|
|
346
|
+
label?: { text: string; box: PixelBox };
|
|
204
347
|
bones: LegendBone[];
|
|
348
|
+
labels?: LegendLabel[];
|
|
349
|
+
meshes?: LegendMesh[];
|
|
350
|
+
slots?: LegendSlot[];
|
|
205
351
|
declared?: LegendDeclared[];
|
|
206
352
|
grid?: { step: number; x: number[]; y: number[] };
|
|
207
353
|
}
|
|
208
354
|
|
|
209
355
|
export interface InspectLegend {
|
|
210
356
|
spec: typeof INSPECT_SPEC;
|
|
357
|
+
/** The poser that posed `--candidate`; with `--compare`, `builds` names both. */
|
|
211
358
|
poser: 'core' | 'spine';
|
|
212
|
-
show:
|
|
359
|
+
show: string[];
|
|
213
360
|
dim: boolean;
|
|
214
361
|
skin?: string;
|
|
215
362
|
animation: string | null;
|
|
@@ -218,8 +365,14 @@ export interface InspectLegend {
|
|
|
218
365
|
| { kind: 'shot'; note: string }
|
|
219
366
|
| { kind: 'zoom'; bone?: string; slot?: string; pad: number; padFrom: 'stated' | 'default' }
|
|
220
367
|
| { kind: 'viewport' };
|
|
368
|
+
/** `--bone` / `--hide-bone` as given — each bone they leave out says so in its `undrawn`. */
|
|
369
|
+
boneFilter?: { include: string[] | null; exclude: string[] | null };
|
|
370
|
+
/** With `weights:<bone>`: the bone, the ramp a weight is painted with, and how a pixel's weight is read. */
|
|
371
|
+
weights?: { bone: string; ramp: Array<{ weight: number; rgba: number[] }>; floor: number; reading: string };
|
|
372
|
+
/** With `--compare`: each build, the poser that posed it and its `skeleton.json`'s sha256; a tile names its own in `build`. */
|
|
373
|
+
builds?: Array<{ build: 'A' | 'B'; poser: 'core' | 'spine'; skeletonSha256: string }>;
|
|
221
374
|
coordinates: { world: string; image: string; toPixel: string; toWorld: string };
|
|
222
|
-
/** The world box every tile is a picture of, and its pixels — one for the whole picture. */
|
|
375
|
+
/** The world box every tile is a picture of, and its pixels — one for the whole picture, both builds of a compare included. */
|
|
223
376
|
viewport: { minX: number; minY: number; maxX: number; maxY: number; scale: number; width: number; height: number };
|
|
224
377
|
size: { width: number; height: number };
|
|
225
378
|
colours: Record<ColourName, { rgba: number[]; meaning: string }>;
|
|
@@ -231,6 +384,8 @@ export interface InspectResult {
|
|
|
231
384
|
plate: Plate;
|
|
232
385
|
legend: InspectLegend;
|
|
233
386
|
poserNote: string;
|
|
387
|
+
/** With `--compare`: the second build's poser line. */
|
|
388
|
+
comparePoserNote?: string;
|
|
234
389
|
/** Lines worth printing beside the picture: what a view drew nothing for, and why. */
|
|
235
390
|
notes: string[];
|
|
236
391
|
}
|
|
@@ -239,20 +394,58 @@ export interface InspectResult {
|
|
|
239
394
|
// reading the request against the build
|
|
240
395
|
// ---------------------------------------------------------------------------
|
|
241
396
|
|
|
242
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
397
|
+
/**
|
|
398
|
+
* `--show`'s words, refused by name — a word this command does not draw lists
|
|
399
|
+
* every one it does — and the bone `weights:<bone>` names. The bone is checked
|
|
400
|
+
* against the build later, where its roster is known.
|
|
401
|
+
*/
|
|
402
|
+
export function readShow(raw: string | undefined): { show: ShowWord[]; weightsBone?: string } {
|
|
403
|
+
if (raw === undefined) return { show: [...DEFAULT_SHOW] };
|
|
245
404
|
const words = raw.split(',').map((w) => w.trim()).filter((w) => w !== '');
|
|
246
|
-
if (words.length === 0) throw new InspectError(`--show ${JSON.stringify(raw)} names no view; it takes [${
|
|
405
|
+
if (words.length === 0) throw new InspectError(`--show ${JSON.stringify(raw)} names no view; it takes [${SHOW_SPELLINGS.join(', ')}]`);
|
|
247
406
|
const out: ShowWord[] = [];
|
|
248
|
-
|
|
407
|
+
let weightsBone: string | undefined;
|
|
408
|
+
for (const spelled of words) {
|
|
409
|
+
let word = spelled;
|
|
410
|
+
if (spelled === 'weights' || spelled.startsWith('weights:')) {
|
|
411
|
+
const bone = spelled.slice('weights:'.length);
|
|
412
|
+
if (spelled === 'weights' || bone === '') {
|
|
413
|
+
throw new InspectError(`--show ${JSON.stringify(spelled)} needs the bone whose weights it paints: weights:<bone>`);
|
|
414
|
+
}
|
|
415
|
+
word = 'weights';
|
|
416
|
+
weightsBone = bone;
|
|
417
|
+
}
|
|
249
418
|
const known = SHOW_WORDS.find((w) => w === word);
|
|
250
|
-
if (known === undefined) throw new InspectError(`--show ${JSON.stringify(
|
|
419
|
+
if (known === undefined) throw new InspectError(`--show ${JSON.stringify(spelled)} is not a view inspect draws; it draws [${SHOW_SPELLINGS.join(', ')}]`);
|
|
251
420
|
if (out.includes(known)) throw new InspectError(`--show names ${JSON.stringify(word)} twice`);
|
|
252
421
|
out.push(known);
|
|
253
422
|
}
|
|
254
423
|
// In the drawing order, so two spellings of one set are one picture and one legend.
|
|
255
|
-
return SHOW_WORDS.filter((w) => out.includes(w));
|
|
424
|
+
return { show: SHOW_WORDS.filter((w) => out.includes(w)), ...(weightsBone === undefined ? {} : { weightsBone }) };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** `--show` as the legend spells it: the words in drawing order, `weights` with its bone. */
|
|
428
|
+
function showSpelled(request: InspectRequest, weightsBone: string | undefined): string[] {
|
|
429
|
+
return request.show.map((w) => (w === 'weights' ? `weights:${weightsBone ?? ''}` : w));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* `--bone` / `--hide-bone`'s patterns: comma-separated globs, `*` any run of
|
|
434
|
+
* characters and `?` one, matched against a bone's whole name. Checked against
|
|
435
|
+
* the build later, where a pattern that matches no bone is refused.
|
|
436
|
+
*/
|
|
437
|
+
export function readBonePatterns(flag: string, raw: string | undefined): string[] | undefined {
|
|
438
|
+
if (raw === undefined) return undefined;
|
|
439
|
+
const patterns = raw.split(',').map((p) => p.trim());
|
|
440
|
+
if (patterns.some((p) => p === '')) throw new InspectError(`--${flag} ${JSON.stringify(raw)}: an empty pattern; it takes globs, comma-separated, e.g. --${flag} 'bang_*,*_ctl'`);
|
|
441
|
+
return patterns;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** A glob as the regular expression it means: `*` any run, `?` one character, everything else itself, the whole name. */
|
|
445
|
+
export function globMatcher(pattern: string): (name: string) => boolean {
|
|
446
|
+
const body = [...pattern].map((c) => (c === '*' ? '.*' : c === '?' ? '.' : c.replace(/[.+^${}()|[\]\\/]/g, '\\$&'))).join('');
|
|
447
|
+
const re = new RegExp(`^${body}$`, 'su');
|
|
448
|
+
return (name) => re.test(name);
|
|
256
449
|
}
|
|
257
450
|
|
|
258
451
|
/** `--at`'s seconds. Their grid frames are checked against the animation later, where its length is known. */
|
|
@@ -399,6 +592,190 @@ function multiples(lo: number, hi: number, step: number): number[] {
|
|
|
399
592
|
return out;
|
|
400
593
|
}
|
|
401
594
|
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
// what a bone moves: the build's weights, the poser's topology
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
/** Per vertex, the bones that move it and by how much. */
|
|
600
|
+
export type VertexWeights = Array<Array<[string, number]>>;
|
|
601
|
+
|
|
602
|
+
type SkinFileAttachment = Record<string, unknown>;
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Each region's and mesh's per-vertex bone weights, read off the build's
|
|
606
|
+
* `skeleton.json` — the file both posers derive their skinning from — under
|
|
607
|
+
* `skin` and then the default skin, which is the order the runtime resolves an
|
|
608
|
+
* attachment in. A weighted mesh's runs name bones by their index in the file's
|
|
609
|
+
* `bones` array; an unweighted mesh and a region are moved whole by their slot's
|
|
610
|
+
* bone. A linked mesh reads its parent's. What cannot be read is answered with
|
|
611
|
+
* the sentence saying why, for the caller to refuse by.
|
|
612
|
+
*/
|
|
613
|
+
export function weightsReader(skeletonText: string, skin: string | undefined): (slot: string, attachment: string) => VertexWeights | string {
|
|
614
|
+
const doc = JSON.parse(skeletonText) as {
|
|
615
|
+
bones?: Array<{ name?: unknown }>;
|
|
616
|
+
slots?: Array<{ name?: unknown; bone?: unknown }>;
|
|
617
|
+
skins?: unknown;
|
|
618
|
+
};
|
|
619
|
+
const boneNames = (doc.bones ?? []).map((b) => (typeof b.name === 'string' ? b.name : ''));
|
|
620
|
+
const slotBone = new Map<string, string>();
|
|
621
|
+
for (const s of doc.slots ?? []) if (typeof s.name === 'string' && typeof s.bone === 'string') slotBone.set(s.name, s.bone);
|
|
622
|
+
const skins = Array.isArray(doc.skins) ? (doc.skins as Array<{ name?: unknown; attachments?: Record<string, Record<string, SkinFileAttachment>> }>) : [];
|
|
623
|
+
const find = (skinName: string, slot: string, attachment: string): SkinFileAttachment | undefined => {
|
|
624
|
+
const entries = skins.find((k) => k.name === skinName)?.attachments?.[slot];
|
|
625
|
+
if (entries === undefined) return undefined;
|
|
626
|
+
// Keyed by its name; an entry may also rename itself with a `name` field, which is what the posers report.
|
|
627
|
+
return entries[attachment] ?? Object.values(entries).find((a) => a.name === attachment);
|
|
628
|
+
};
|
|
629
|
+
const order = skin === undefined ? ['default'] : skin === 'default' ? ['default'] : [skin, 'default'];
|
|
630
|
+
return (slot, attachment) => {
|
|
631
|
+
let found: SkinFileAttachment | undefined;
|
|
632
|
+
for (const k of order) {
|
|
633
|
+
found = find(k, slot, attachment);
|
|
634
|
+
if (found !== undefined) break;
|
|
635
|
+
}
|
|
636
|
+
const where = `slot "${slot}" attachment "${attachment}"`;
|
|
637
|
+
if (found === undefined) return `${where}: skeleton.json has no such attachment under skin ${order.map((k) => JSON.stringify(k)).join(' or ')}`;
|
|
638
|
+
const bone = slotBone.get(slot);
|
|
639
|
+
if (bone === undefined) return `${where}: skeleton.json declares no slot "${slot}" with a bone`;
|
|
640
|
+
const type = typeof found.type === 'string' ? found.type : 'region';
|
|
641
|
+
if (type === 'region') return [[[bone, 1]], [[bone, 1]], [[bone, 1]], [[bone, 1]]];
|
|
642
|
+
let mesh = found;
|
|
643
|
+
if (type === 'linkedmesh') {
|
|
644
|
+
const parentSkin = typeof found.skin === 'string' ? found.skin : 'default';
|
|
645
|
+
const parent = typeof found.parent === 'string' ? find(parentSkin, slot, found.parent) : undefined;
|
|
646
|
+
if (parent === undefined) return `${where}: its parent mesh ${JSON.stringify(found.parent)} is not in skin "${parentSkin}"`;
|
|
647
|
+
mesh = parent;
|
|
648
|
+
} else if (type !== 'mesh') {
|
|
649
|
+
return `${where} is a ${type}, which poses no vertices`;
|
|
650
|
+
}
|
|
651
|
+
const uvs = mesh.uvs;
|
|
652
|
+
const vertices = mesh.vertices;
|
|
653
|
+
if (!Array.isArray(uvs) || !Array.isArray(vertices)) return `${where}: its mesh states no uvs or vertices`;
|
|
654
|
+
const count = uvs.length / 2;
|
|
655
|
+
if (vertices.length === uvs.length) return Array.from({ length: count }, (): Array<[string, number]> => [[bone, 1]]);
|
|
656
|
+
const out: VertexWeights = [];
|
|
657
|
+
let i = 0;
|
|
658
|
+
for (let v = 0; v < count; v++) {
|
|
659
|
+
const n = Number(vertices[i++]);
|
|
660
|
+
const run: Array<[string, number]> = [];
|
|
661
|
+
for (let k = 0; k < n; k++, i += 4) {
|
|
662
|
+
const name = boneNames[Number(vertices[i])];
|
|
663
|
+
if (name === undefined) return `${where}: vertex ${v} names bone index ${String(vertices[i])}, and skeleton.json has ${boneNames.length} bones`;
|
|
664
|
+
run.push([name, Number(vertices[i + 3])]);
|
|
665
|
+
}
|
|
666
|
+
out.push(run);
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** A triangle list's unique edges, `[i, j]` with `i < j`, in the order the triangles first name them. */
|
|
673
|
+
export function edgesOf(triangles: ArrayLike<number>): Array<[number, number]> {
|
|
674
|
+
const seen = new Set<string>();
|
|
675
|
+
const out: Array<[number, number]> = [];
|
|
676
|
+
for (let t = 0; t + 2 < triangles.length; t += 3) {
|
|
677
|
+
const [a, b, c] = [triangles[t], triangles[t + 1], triangles[t + 2]];
|
|
678
|
+
for (const [p, q] of [[a, b], [b, c], [c, a]]) {
|
|
679
|
+
const e: [number, number] = p < q ? [p, q] : [q, p];
|
|
680
|
+
const key = `${e[0]},${e[1]}`;
|
|
681
|
+
if (seen.has(key)) continue;
|
|
682
|
+
seen.add(key);
|
|
683
|
+
out.push(e);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return out;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** The ramp's colour at `weight` (clamped to 0..1): linear between the two stops either side, each channel rounded. */
|
|
690
|
+
export function rampColour(weight: number): RGBA {
|
|
691
|
+
const w = Math.min(1, Math.max(0, weight));
|
|
692
|
+
for (let i = 0; i + 1 < WEIGHT_RAMP.length; i++) {
|
|
693
|
+
const lo = WEIGHT_RAMP[i];
|
|
694
|
+
const hi = WEIGHT_RAMP[i + 1];
|
|
695
|
+
if (w > hi.weight) continue;
|
|
696
|
+
const t = (w - lo.weight) / (hi.weight - lo.weight);
|
|
697
|
+
return [0, 1, 2, 3].map((c) => Math.round(lo.rgba[c] + (hi.rgba[c] - lo.rgba[c]) * t)) as RGBA;
|
|
698
|
+
}
|
|
699
|
+
return [...WEIGHT_RAMP[WEIGHT_RAMP.length - 1].rgba] as RGBA;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The heat over one triangle: every pixel whose centre the triangle holds,
|
|
704
|
+
* painted the ramp's colour at the barycentric blend of its corners' weights —
|
|
705
|
+
* so a pixel whose centre is a vertex is that vertex's weight exactly.
|
|
706
|
+
*/
|
|
707
|
+
function heatTriangle(plate: Plate, p: ReadonlyArray<[number, number]>, w: readonly number[]): void {
|
|
708
|
+
const [[x0, y0], [x1, y1], [x2, y2]] = p;
|
|
709
|
+
const area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
|
|
710
|
+
if (area === 0 || w.every((v) => v < WEIGHT_FLOOR)) return;
|
|
711
|
+
const left = Math.max(0, Math.floor(Math.min(x0, x1, x2)));
|
|
712
|
+
const right = Math.min(plate.width - 1, Math.floor(Math.max(x0, x1, x2)));
|
|
713
|
+
const top = Math.max(0, Math.floor(Math.min(y0, y1, y2)));
|
|
714
|
+
const bottom = Math.min(plate.height - 1, Math.floor(Math.max(y0, y1, y2)));
|
|
715
|
+
// A pixel centre ON a vertex or an edge is inside: posed vertices carry float noise of ~1e-7 world units, which
|
|
716
|
+
// must not leave the pixel under a vertex unpainted. In barycentric units, so it is the same share of every triangle.
|
|
717
|
+
const inside = -1e-6;
|
|
718
|
+
for (let py = top; py <= bottom; py++) {
|
|
719
|
+
for (let px = left; px <= right; px++) {
|
|
720
|
+
const cx = px + 0.5;
|
|
721
|
+
const cy = py + 0.5;
|
|
722
|
+
const b0 = ((x1 - cx) * (y2 - cy) - (x2 - cx) * (y1 - cy)) / area;
|
|
723
|
+
const b1 = ((x2 - cx) * (y0 - cy) - (x0 - cx) * (y2 - cy)) / area;
|
|
724
|
+
const b2 = 1 - b0 - b1;
|
|
725
|
+
if (b0 < inside || b1 < inside || b2 < inside) continue;
|
|
726
|
+
const weight = b0 * w[0] + b1 * w[1] + b2 * w[2];
|
|
727
|
+
if (weight < WEIGHT_FLOOR) continue;
|
|
728
|
+
put(plate, px, py, rampColour(weight));
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Two pixel boxes overlap, or sit with no clear pixel between them. */
|
|
734
|
+
function crowds(a: PixelBox, b: PixelBox): boolean {
|
|
735
|
+
return a.x < b.x + b.width + 1 && b.x < a.x + a.width + 1 && a.y < b.y + b.height + 1 && b.y < a.y + a.height + 1;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Where a label goes: `first`, or the first place below it, a text line at a
|
|
740
|
+
* time, that no earlier label crowds — in the order labels are placed, which is
|
|
741
|
+
* declaration order, so one build and one request place every label alike.
|
|
742
|
+
*/
|
|
743
|
+
function placeLabel(first: PixelBox, taken: PixelBox[], avoid: boolean): { box: PixelBox; moved: boolean; overlaps: boolean } {
|
|
744
|
+
if (avoid) {
|
|
745
|
+
for (let step = 0; step <= LABEL_TRIES; step++) {
|
|
746
|
+
const box = { ...first, y: first.y + step * (GLYPH_H + 2) };
|
|
747
|
+
if (!taken.some((t) => crowds(t, box))) {
|
|
748
|
+
taken.push(box);
|
|
749
|
+
return { box, moved: step > 0, overlaps: false };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const overlaps = taken.some((t) => crowds(t, first));
|
|
754
|
+
taken.push(first);
|
|
755
|
+
return { box: first, moved: false, overlaps };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** `--bone` / `--hide-bone` against one build's bones: the bones they leave out, each with why. A pattern matching no bone is refused naming the bones there are. */
|
|
759
|
+
function filterOut(bones: ReadonlyArray<{ name: string }>, filter: BoneFilter | undefined, apply: boolean): Map<string, string> {
|
|
760
|
+
const out = new Map<string, string>();
|
|
761
|
+
if (filter === undefined) return out;
|
|
762
|
+
const names = bones.map((b) => b.name);
|
|
763
|
+
const matchers = (flag: string, patterns: readonly string[]): Array<(name: string) => boolean> =>
|
|
764
|
+
patterns.map((p) => {
|
|
765
|
+
const m = globMatcher(p);
|
|
766
|
+
if (!names.some(m)) throw new InspectError(`--${flag} ${JSON.stringify(p)} matches no bone; the bones are [${names.join(', ')}]`);
|
|
767
|
+
return m;
|
|
768
|
+
});
|
|
769
|
+
const include = filter.include === undefined ? null : matchers('bone', filter.include);
|
|
770
|
+
const exclude = filter.exclude === undefined ? null : matchers('hide-bone', filter.exclude);
|
|
771
|
+
if (!apply) return out;
|
|
772
|
+
for (const name of names) {
|
|
773
|
+
if (include !== null && !include.some((m) => m(name))) out.set(name, `not matched by --bone ${filter.include?.join(',')}`);
|
|
774
|
+
else if (exclude !== null && exclude.some((m) => m(name))) out.set(name, `left out by --hide-bone ${filter.exclude?.join(',')}`);
|
|
775
|
+
}
|
|
776
|
+
return out;
|
|
777
|
+
}
|
|
778
|
+
|
|
402
779
|
// ---------------------------------------------------------------------------
|
|
403
780
|
// the view
|
|
404
781
|
// ---------------------------------------------------------------------------
|
|
@@ -421,13 +798,15 @@ function posesOf(poser: Poser, request: InspectRequest, needAttachments: boolean
|
|
|
421
798
|
return indices.map((index) => ({ frame: frames[index], index }));
|
|
422
799
|
}
|
|
423
800
|
|
|
801
|
+
type WorldBox = [number, number, number, number];
|
|
802
|
+
|
|
424
803
|
/** The world box of what `--zoom` names, over every pose of the picture. */
|
|
425
804
|
function zoomBox(
|
|
426
805
|
poser: Poser,
|
|
427
806
|
poses: ReadonlyArray<{ frame: Frame }>,
|
|
428
807
|
target: string,
|
|
429
808
|
lengths: Map<string, number>,
|
|
430
|
-
): { box:
|
|
809
|
+
): { box: WorldBox; bone?: string; slot?: string } {
|
|
431
810
|
const isBone = poser.bones.some((b) => b.name === target);
|
|
432
811
|
const isSlot = poser.slots.some((s) => s.name === target);
|
|
433
812
|
if (isBone && isSlot) {
|
|
@@ -451,7 +830,7 @@ function zoomBox(
|
|
|
451
830
|
`slots: [${poser.slots.map((s) => s.name).join(', ')}]`,
|
|
452
831
|
);
|
|
453
832
|
}
|
|
454
|
-
const box:
|
|
833
|
+
const box: WorldBox = [Infinity, Infinity, -Infinity, -Infinity];
|
|
455
834
|
const take = (x: number, y: number): void => {
|
|
456
835
|
box[0] = Math.min(box[0], x);
|
|
457
836
|
box[1] = Math.min(box[1], y);
|
|
@@ -483,40 +862,115 @@ function zoomBox(
|
|
|
483
862
|
return kind === 'bone' ? { box, bone: name } : { box, slot: name };
|
|
484
863
|
}
|
|
485
864
|
|
|
865
|
+
/** One build as the drawing reads it: everything taken off its poser while the poser ran, so drawing poses nothing. */
|
|
866
|
+
interface Gathered {
|
|
867
|
+
tag: 'A' | 'B';
|
|
868
|
+
poser: 'core' | 'spine';
|
|
869
|
+
note: string;
|
|
870
|
+
bones: ReadonlyArray<{ name: string; parent: string | null }>;
|
|
871
|
+
slots: ReadonlyArray<{ name: string; bone: string }>;
|
|
872
|
+
poses: Array<{ frame: Frame; index: number }>;
|
|
873
|
+
/** `render`'s framing of the build — taken only when the picture is framed as a shot. */
|
|
874
|
+
shot: Viewport | null;
|
|
875
|
+
zoom: { box: WorldBox; bone?: string; slot?: string } | null;
|
|
876
|
+
/** The rest table's topology for every attachment a pose shows, by `slot\0attachment` — taken only when a mesh view is drawn. */
|
|
877
|
+
rest: Map<string, AttachmentRest> | null;
|
|
878
|
+
pages: Map<string, Plate>;
|
|
879
|
+
lengths: Map<string, number>;
|
|
880
|
+
axes: readonly DeclaredAxis[];
|
|
881
|
+
weights: ((slot: string, attachment: string) => VertexWeights | string) | null;
|
|
882
|
+
sha: string;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights'];
|
|
886
|
+
const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
|
|
887
|
+
|
|
888
|
+
/** A refusal about the second build says so: the same sentence, under the name of the build it is about. */
|
|
889
|
+
function aboutBuild<T>(tag: 'A' | 'B', run: () => T): T {
|
|
890
|
+
if (tag === 'A') return run();
|
|
891
|
+
try {
|
|
892
|
+
return run();
|
|
893
|
+
} catch (err) {
|
|
894
|
+
if (err instanceof InspectError) throw new InspectError(`the --compare build: ${err.message}`);
|
|
895
|
+
throw err;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Everything a picture needs off one build, posed through the poser `render` would pose it with. */
|
|
900
|
+
function gather(tag: 'A' | 'B', candidate: Candidate, texts: InspectTexts, request: InspectRequest, plant: InspectPlant): Gathered {
|
|
901
|
+
return aboutBuild(tag, () => {
|
|
902
|
+
const { facts, pages } = candidate;
|
|
903
|
+
if (request.skin !== undefined && !facts.skins.includes(request.skin)) {
|
|
904
|
+
throw new InspectError(`no skin ${JSON.stringify(request.skin)} in this skeleton; it declares [${facts.skins.join(', ') || 'none'}]`);
|
|
905
|
+
}
|
|
906
|
+
const axes = request.show.includes('declared') ? declaredAxes(texts.modelText) : [];
|
|
907
|
+
const lengths = boneLengths(texts.skeletonText);
|
|
908
|
+
const meshViews = request.show.some((w) => MESH_VIEWS.includes(w));
|
|
909
|
+
const needAttachments = request.frame.kind === 'zoom' || meshViews || request.show.includes('slots');
|
|
910
|
+
const posed = throughPoser(candidate.choice, (poser, roster) => {
|
|
911
|
+
const poses = posesOf(poser, request, needAttachments);
|
|
912
|
+
const shot =
|
|
913
|
+
request.frame.kind === 'shot' ? framingViewport(poser, request.maxSide, request.skin === undefined ? undefined : { skin: request.skin }, roster) : null;
|
|
914
|
+
if (request.frame.kind === 'shot' && shot === null) {
|
|
915
|
+
throw new InspectError('this skeleton poses no drawable attachment in any animation or in its setup pose — there is nothing to frame; name a box with --viewport');
|
|
916
|
+
}
|
|
917
|
+
const zoom = request.frame.kind === 'zoom' ? zoomBox(poser, poses, plant.zoomBone ?? request.frame.target, lengths) : null;
|
|
918
|
+
const rest = meshViews
|
|
919
|
+
? new Map(poser.rest(request.skin, poses.map((p) => p.frame.attachments ?? [])).map((r) => [restKey(r.slot, r.attachment), r]))
|
|
920
|
+
: null;
|
|
921
|
+
return { poses, shot, zoom, rest, bones: poser.bones, slots: poser.slots };
|
|
922
|
+
});
|
|
923
|
+
return {
|
|
924
|
+
tag,
|
|
925
|
+
poser: posed.poser,
|
|
926
|
+
note: posed.note,
|
|
927
|
+
...posed.value,
|
|
928
|
+
pages,
|
|
929
|
+
lengths,
|
|
930
|
+
axes,
|
|
931
|
+
weights: request.show.includes('weights') ? weightsReader(texts.skeletonText, request.skin) : null,
|
|
932
|
+
sha: createHash('sha256').update(texts.skeletonText).digest('hex'),
|
|
933
|
+
};
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
486
937
|
/**
|
|
487
938
|
* The picture and its legend, posed through `candidate`'s chosen poser — the
|
|
488
|
-
* one `render` would pose it with (`throughPoser`), named in the result
|
|
939
|
+
* one `render` would pose it with (`throughPoser`), named in the result — and,
|
|
940
|
+
* with `compare`, the same view of a second build beside it on one transform.
|
|
489
941
|
*/
|
|
490
|
-
export function inspectCandidate(candidate: Candidate, texts: InspectTexts, request: InspectRequest, plant: InspectPlant = {}): InspectResult {
|
|
491
|
-
const { facts, pages } = candidate;
|
|
492
|
-
if (request.skin !== undefined && !facts.skins.includes(request.skin)) {
|
|
493
|
-
throw new InspectError(`no skin ${JSON.stringify(request.skin)} in this skeleton; it declares [${facts.skins.join(', ') || 'none'}]`);
|
|
494
|
-
}
|
|
942
|
+
export function inspectCandidate(candidate: Candidate, texts: InspectTexts, request: InspectRequest, plant: InspectPlant = {}, compare?: InspectCompare): InspectResult {
|
|
495
943
|
if (request.animation === null && request.times.length > 0) {
|
|
496
944
|
throw new InspectError('--at needs --animation <name>: the setup pose has no time. Without --at the picture is the setup pose');
|
|
497
945
|
}
|
|
498
946
|
if (request.animation !== null && request.times.length === 0) {
|
|
499
947
|
throw new InspectError(`--animation ${request.animation} needs --at <s[,s…]>: the times of it to draw, on the ${request.fps} fps grid render draws it on`);
|
|
500
948
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
949
|
+
if (request.show.includes('weights') !== (request.weightsBone !== undefined)) {
|
|
950
|
+
throw new InspectError('--show weights needs the bone whose weights it paints: weights:<bone>');
|
|
951
|
+
}
|
|
952
|
+
const a = gather('A', candidate, texts, request, plant);
|
|
953
|
+
const builds = [a];
|
|
954
|
+
if (compare !== undefined) builds.push(plant.compareFirstTwice ? { ...a, tag: 'B' } : gather('B', compare.candidate, compare.texts, request, plant));
|
|
955
|
+
const drawn = view(builds, request, plant);
|
|
956
|
+
return { ...drawn, poserNote: a.note, ...(compare === undefined ? {} : { comparePoserNote: builds[1].note }) };
|
|
505
957
|
}
|
|
506
958
|
|
|
507
|
-
function view(
|
|
508
|
-
poser: Poser,
|
|
509
|
-
roster: SkinRoster,
|
|
510
|
-
pages: Map<string, Plate>,
|
|
511
|
-
lengths: Map<string, number>,
|
|
512
|
-
axes: readonly DeclaredAxis[],
|
|
513
|
-
request: InspectRequest,
|
|
514
|
-
plant: InspectPlant,
|
|
515
|
-
): Omit<InspectResult, 'poserNote'> {
|
|
959
|
+
function view(builds: readonly Gathered[], request: InspectRequest, plant: InspectPlant): Omit<InspectResult, 'poserNote' | 'comparePoserNote'> {
|
|
516
960
|
const notes: string[] = [];
|
|
517
|
-
const
|
|
518
|
-
const
|
|
519
|
-
|
|
961
|
+
const compare = builds.length > 1;
|
|
962
|
+
const weightsBone = plant.weightsOf ?? request.weightsBone;
|
|
963
|
+
if (weightsBone !== undefined) {
|
|
964
|
+
for (const b of builds) {
|
|
965
|
+
if (!b.bones.some((bone) => bone.name === weightsBone)) {
|
|
966
|
+
aboutBuild(b.tag, () => {
|
|
967
|
+
throw new InspectError(`--show weights:${weightsBone}: no bone of that name; the bones are [${b.bones.map((bone) => bone.name).join(', ')}]`);
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const filtered = builds.map((b) => aboutBuild(b.tag, () => filterOut(b.bones, request.boneFilter, plant.ignoreFilter !== true)));
|
|
973
|
+
// The world box and its pixels: one for every tile of both builds, so a sheet's tiles and a compare's halves overlay.
|
|
520
974
|
let viewport: Viewport;
|
|
521
975
|
let framing: InspectLegend['framing'];
|
|
522
976
|
if (request.frame.kind === 'viewport') {
|
|
@@ -524,9 +978,11 @@ function view(
|
|
|
524
978
|
viewport = viewportFor(f.x, f.y, f.x + f.width, f.y + f.height, request.maxSide);
|
|
525
979
|
framing = { kind: 'viewport' };
|
|
526
980
|
} else if (request.frame.kind === 'zoom') {
|
|
527
|
-
const
|
|
528
|
-
const
|
|
529
|
-
|
|
981
|
+
const found = builds.map((b) => b.zoom).filter((z): z is NonNullable<Gathered['zoom']> => z !== null);
|
|
982
|
+
const [x0, y0, x1, y1] = found.reduce<WorldBox>(
|
|
983
|
+
(u, z) => [Math.min(u[0], z.box[0]), Math.min(u[1], z.box[1]), Math.max(u[2], z.box[2]), Math.max(u[3], z.box[3])],
|
|
984
|
+
[Infinity, Infinity, -Infinity, -Infinity],
|
|
985
|
+
);
|
|
530
986
|
const longer = Math.max(x1 - x0, y1 - y0);
|
|
531
987
|
const pad = request.frame.pad ?? longer * DEFAULT_ZOOM_PAD_SHARE;
|
|
532
988
|
if (longer + 2 * pad === 0) {
|
|
@@ -536,195 +992,87 @@ function view(
|
|
|
536
992
|
);
|
|
537
993
|
}
|
|
538
994
|
viewport = viewportFor(x0 - pad, y0 - pad, x1 + pad, y1 + pad, request.maxSide);
|
|
539
|
-
|
|
995
|
+
const first = found[0];
|
|
996
|
+
framing = { kind: 'zoom', ...(first?.bone === undefined ? {} : { bone: first.bone }), ...(first?.slot === undefined ? {} : { slot: first.slot }), pad, padFrom: request.frame.pad === null ? 'default' : 'stated' };
|
|
540
997
|
} else {
|
|
541
|
-
const
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
|
|
998
|
+
const shots = builds.map((b) => b.shot).filter((v): v is Viewport => v !== null);
|
|
999
|
+
if (!compare) {
|
|
1000
|
+
viewport = shots[0];
|
|
1001
|
+
framing = { kind: 'shot', note: "render's own framing: every animation's posed box at 60 fps, padded — the grid render draws this build on at the same --max" };
|
|
1002
|
+
} else {
|
|
1003
|
+
viewport = viewportFor(
|
|
1004
|
+
Math.min(...shots.map((v) => v.minX)),
|
|
1005
|
+
Math.min(...shots.map((v) => v.minY)),
|
|
1006
|
+
Math.max(...shots.map((v) => v.maxX)),
|
|
1007
|
+
Math.max(...shots.map((v) => v.maxY)),
|
|
1008
|
+
request.maxSide,
|
|
1009
|
+
);
|
|
1010
|
+
framing = {
|
|
1011
|
+
kind: 'shot',
|
|
1012
|
+
note:
|
|
1013
|
+
"the union of render's own framing of each build (every animation's posed box at 60 fps, padded), so both halves share one " +
|
|
1014
|
+
"transform — a half is render's frame of its build only where the two boxes agree",
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
545
1017
|
}
|
|
546
1018
|
const { width, height, scale } = viewport;
|
|
547
|
-
const
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
const
|
|
1019
|
+
const count = builds[0].poses.length;
|
|
1020
|
+
const sheet = count > 1;
|
|
1021
|
+
// One build: tiles left to right, eight to a row. Two: each time's pair side by side, four pairs to a row.
|
|
1022
|
+
const pairs = compare ? Math.min(SHEET_COLUMNS / 2, count) : 0;
|
|
1023
|
+
const columns = compare ? pairs * 2 : sheet ? Math.min(SHEET_COLUMNS, count) : 1;
|
|
1024
|
+
const rows = compare ? Math.ceil(count / pairs) : Math.ceil(count / columns);
|
|
1025
|
+
const gap = sheet || compare ? SHEET_GAP : 0;
|
|
551
1026
|
const plate = new Plate(columns * (width + gap) + gap, rows * (height + gap) + gap);
|
|
552
1027
|
for (let i = 0; i < plate.data.length; i += 4) plate.data.set(colour('rule'), i);
|
|
553
|
-
const [shiftX, shiftY] = plant.overlayShift ?? [0, 0];
|
|
554
|
-
const [lieX, lieY] = plant.legendShift ?? [0, 0];
|
|
555
1028
|
const tiles: LegendTile[] = [];
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
if (request.dim) {
|
|
570
|
-
for (let p = 0; p < tile.data.length; p += 4) {
|
|
571
|
-
for (let c = 0; c < 3; c++) tile.data[p + c] = Math.round(255 - (255 - tile.data[p + c]) * DIM_KEEP);
|
|
572
|
-
}
|
|
1029
|
+
const weighted = builds.map(() => false);
|
|
1030
|
+
let anyMesh = false;
|
|
1031
|
+
for (let k = 0; k < count; k++) {
|
|
1032
|
+
for (const [bi, build] of builds.entries()) {
|
|
1033
|
+
const column = compare ? (k % pairs) * 2 + bi : k % columns;
|
|
1034
|
+
const row = compare ? Math.floor(k / pairs) : Math.floor(k / columns);
|
|
1035
|
+
const x0 = gap + column * (width + gap);
|
|
1036
|
+
const y0 = gap + row * (height + gap);
|
|
1037
|
+
const drawnTile = drawTile(build, build.poses[k], filtered[bi], { x0, y0, viewport, sheet, compare, weightsBone, first: k === 0 && bi === 0 }, request, plant, notes);
|
|
1038
|
+
if (drawnTile.weighted) weighted[bi] = true;
|
|
1039
|
+
if (drawnTile.anyMesh) anyMesh = true;
|
|
1040
|
+
for (let y = 0; y < height; y++) plate.data.set(drawnTile.tile.data.subarray(y * width * 4, (y + 1) * width * 4), ((y0 + y) * plate.width + x0) * 4);
|
|
1041
|
+
tiles.push(drawnTile.legend);
|
|
573
1042
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
animation: request.animation,
|
|
579
|
-
frame: index,
|
|
580
|
-
time: request.animation === null ? 0 : index / request.fps,
|
|
581
|
-
box: { x: x0, y: y0, width, height },
|
|
582
|
-
transform: { minX: viewport.minX, maxY: viewport.maxY, scale, x0, y0 },
|
|
583
|
-
bones: [],
|
|
584
|
-
};
|
|
585
|
-
if (request.show.includes('grid')) {
|
|
586
|
-
const step = plant.gridInPixels ? gridStep(scale) / scale : gridStep(scale);
|
|
587
|
-
const xs = multiples(viewport.minX, viewport.maxX, step);
|
|
588
|
-
const ys = multiples(viewport.minY, viewport.maxY, step);
|
|
589
|
-
for (const gx of xs) {
|
|
590
|
-
const col = Math.floor(tx(toPixel(gx, 0)[0]));
|
|
591
|
-
for (let y = 0; y < height; y++) put(tile, col, y, colour(gx === 0 ? 'gridZero' : 'grid'));
|
|
592
|
-
}
|
|
593
|
-
for (const gy of ys) {
|
|
594
|
-
const row = Math.floor(ty(toPixel(0, gy)[1]));
|
|
595
|
-
for (let x = 0; x < width; x++) put(tile, x, row, colour(gy === 0 ? 'gridZero' : 'grid'));
|
|
596
|
-
}
|
|
597
|
-
for (const gx of xs) text(tile, gridLabel(gx, step), Math.floor(tx(toPixel(gx, 0)[0])) + 2, 2, colour('gridZero'));
|
|
598
|
-
for (const gy of ys) text(tile, gridLabel(gy, step), 2, Math.floor(ty(toPixel(0, gy)[1])) + 2, colour('gridZero'));
|
|
599
|
-
legendTile.grid = { step, x: xs, y: ys };
|
|
600
|
-
}
|
|
601
|
-
const snapshots = new Map<string, BoneSnapshot>((frame.bones ?? []).map((b) => [b.name, b]));
|
|
602
|
-
const isPosed = (b: BoneSnapshot | undefined): b is BoneSnapshot => b !== undefined && !(b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0);
|
|
603
|
-
const held = new Set<string>();
|
|
604
|
-
if (request.show.includes('declared')) {
|
|
605
|
-
legendTile.declared = [];
|
|
606
|
-
for (const axis of axes) {
|
|
607
|
-
const a = snapshots.get(axis.through[0]);
|
|
608
|
-
const b = snapshots.get(axis.through[1]);
|
|
609
|
-
const entry: LegendDeclared = { name: axis.name, through: axis.through, tolerance: axis.tolerance, degrees: null, why: null, from: null, to: null, bones: [] };
|
|
610
|
-
if (!isPosed(a) || !isPosed(b)) {
|
|
611
|
-
entry.why = `${!isPosed(a) ? axis.through[0] : axis.through[1]} is not posed at this time (its world matrix is 0), so the line has no point there`;
|
|
612
|
-
} else if (a.worldX === b.worldX && a.worldY === b.worldY) {
|
|
613
|
-
entry.why = `"${axis.through[0]}" and "${axis.through[1]}" share the world origin (${a.worldX}, ${a.worldY}), so they draw no line`;
|
|
614
|
-
} else {
|
|
615
|
-
const degrees = (Math.atan2(b.worldY - a.worldY, b.worldX - a.worldX) * 180) / Math.PI;
|
|
616
|
-
entry.degrees = degrees;
|
|
617
|
-
entry.from = point(a.worldX, a.worldY);
|
|
618
|
-
entry.to = point(b.worldX, b.worldY);
|
|
619
|
-
// Across the whole tile: extend the line past both ends by the tile's diagonal, in world units.
|
|
620
|
-
const reach = Math.hypot(viewport.maxX - viewport.minX, viewport.maxY - viewport.minY);
|
|
621
|
-
const ux = (b.worldX - a.worldX) / Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
|
|
622
|
-
const uy = (b.worldY - a.worldY) / Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
|
|
623
|
-
const [p0x, p0y] = toPixel(a.worldX - ux * reach, a.worldY - uy * reach);
|
|
624
|
-
const [p1x, p1y] = toPixel(a.worldX + ux * reach, a.worldY + uy * reach);
|
|
625
|
-
line(tile, tx(p0x) + shiftX, ty(p0y) + shiftY, tx(p1x) + shiftX, ty(p1y) + shiftY, colour('declared'));
|
|
626
|
-
for (const name of axis.bones) {
|
|
627
|
-
const s = snapshots.get(name);
|
|
628
|
-
if (!isPosed(s) || (s.a === 0 && s.c === 0)) {
|
|
629
|
-
entry.bones.push({ name, degrees: null, off: null });
|
|
630
|
-
continue;
|
|
631
|
-
}
|
|
632
|
-
const found = worldAxisDegrees(s);
|
|
633
|
-
entry.bones.push({ name, degrees: found, off: lineGap(found, degrees) });
|
|
634
|
-
held.add(name);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
if (entry.why !== null && i === 0) notes.push(`declared: axis "${axis.name}" — ${entry.why}`);
|
|
638
|
-
legendTile.declared.push(entry);
|
|
639
|
-
}
|
|
640
|
-
if (axes.length === 0 && i === 0) notes.push('declared: the rig declares no invariants.axes — nothing drawn for it');
|
|
641
|
-
}
|
|
642
|
-
// Every bone, in declaration order — drawn or not, so the legend names each.
|
|
643
|
-
const drawn: Array<{ bone: LegendBone; o: [number, number]; t: [number, number] }> = [];
|
|
644
|
-
for (const declared of poser.bones) {
|
|
645
|
-
const s = snapshots.get(declared.name);
|
|
646
|
-
const length = lengths.get(declared.name) ?? 0;
|
|
647
|
-
const posedBone = isPosed(s);
|
|
648
|
-
const ox = s?.worldX ?? 0;
|
|
649
|
-
const oy = s?.worldY ?? 0;
|
|
650
|
-
const bone: LegendBone = {
|
|
651
|
-
name: declared.name,
|
|
652
|
-
parent: declared.parent,
|
|
653
|
-
length,
|
|
654
|
-
zeroLength: length === 0,
|
|
655
|
-
undrawn: posedBone ? null : 'its world matrix is 0 at this time — unposed by the skin, or a scale of 0',
|
|
656
|
-
origin: point(ox, oy),
|
|
657
|
-
tip: point(ox + length * (s?.a ?? 0), oy + length * (s?.c ?? 0)),
|
|
658
|
-
};
|
|
659
|
-
if (posedBone && request.show.includes('axes')) {
|
|
660
|
-
const unit = AXIS_PIXELS / scale;
|
|
661
|
-
bone.axes = { x: point(ox + s.a * unit, oy + s.c * unit), y: point(ox + s.b * unit, oy + s.d * unit) };
|
|
662
|
-
}
|
|
663
|
-
legendTile.bones.push(bone);
|
|
664
|
-
if (posedBone) drawn.push({ bone, o: toPixel(ox, oy), t: toPixel(bone.tip.world[0], bone.tip.world[1]) });
|
|
665
|
-
}
|
|
666
|
-
// Lines, then axes, then names, then the marks — so a mark is never covered.
|
|
667
|
-
const at = (p: [number, number]): [number, number] => [tx(p[0]) + shiftX, ty(p[1]) + shiftY];
|
|
668
|
-
if (request.show.includes('bones')) {
|
|
669
|
-
for (const { bone, o, t } of drawn) {
|
|
670
|
-
if (bone.zeroLength) continue;
|
|
671
|
-
const [ax, ay] = at(o);
|
|
672
|
-
const [bx, by] = at(t);
|
|
673
|
-
line(tile, ax, ay, bx, by, colour(held.has(bone.name) ? 'held' : 'bone'));
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
if (request.show.includes('axes')) {
|
|
677
|
-
for (const { bone, o } of drawn) {
|
|
678
|
-
if (bone.axes === undefined) continue;
|
|
679
|
-
const [ax, ay] = at(o);
|
|
680
|
-
for (const [end, name] of [[bone.axes.y, 'axisY'], [bone.axes.x, 'axisX']] as const) {
|
|
681
|
-
const [ex, ey] = at(toPixel(end.world[0], end.world[1]));
|
|
682
|
-
line(tile, ax, ay, ex, ey, colour(name));
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
if (request.show.includes('names')) {
|
|
687
|
-
// Bones whose origins share a pixel — a `_ctl` parent and its child, a chain's joint — list their names one per
|
|
688
|
-
// line under it, in declaration order, rather than printing them over each other.
|
|
689
|
-
const below = new Map<string, number>();
|
|
690
|
-
for (const { bone, o } of drawn) {
|
|
691
|
-
const [ax, ay] = at(o);
|
|
692
|
-
const key = `${Math.floor(ax)},${Math.floor(ay)}`;
|
|
693
|
-
const line = below.get(key) ?? 0;
|
|
694
|
-
below.set(key, line + 1);
|
|
695
|
-
text(tile, bone.name, Math.floor(ax) + 4, Math.floor(ay) + 3 + line * (GLYPH_H + 2), colour('name'));
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
if (request.show.includes('bones')) {
|
|
699
|
-
for (const { bone, t } of drawn) {
|
|
700
|
-
if (bone.zeroLength) continue;
|
|
701
|
-
const [bx, by] = at(t);
|
|
702
|
-
square(tile, Math.floor(bx), Math.floor(by), 1, true, colour('tip'));
|
|
703
|
-
}
|
|
704
|
-
for (const { bone, o } of drawn) {
|
|
705
|
-
const [ax, ay] = at(o);
|
|
706
|
-
if (bone.zeroLength) square(tile, Math.floor(ax), Math.floor(ay), 2, true, colour('zero'));
|
|
707
|
-
square(tile, Math.floor(ax), Math.floor(ay), 1, false, colour('origin'));
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
if (sheet) {
|
|
711
|
-
const label = request.animation === null ? 'setup' : `t=${legendTile.time.toFixed(3)} f${index}`;
|
|
712
|
-
const ly = height - GLYPH_H - 2;
|
|
713
|
-
for (let y = ly - 1; y < ly + GLYPH_H + 1; y++) for (let x = 1; x < textWidth(label, 1) + 3; x++) put(tile, x, y, BACKGROUND);
|
|
714
|
-
text(tile, label, 2, ly, colour('label'));
|
|
1043
|
+
}
|
|
1044
|
+
if (weightsBone !== undefined) {
|
|
1045
|
+
for (const [bi, b] of builds.entries()) {
|
|
1046
|
+
if (!weighted[bi]) notes.push(`weights: bone "${weightsBone}" weights no vertex of any region or mesh ${compare ? `build ${b.tag} ` : ''}shows here — nothing painted`);
|
|
715
1047
|
}
|
|
716
|
-
for (let y = 0; y < height; y++) plate.data.set(tile.data.subarray(y * width * 4, (y + 1) * width * 4), ((y0 + y) * plate.width + x0) * 4);
|
|
717
|
-
tiles.push(legendTile);
|
|
718
1048
|
}
|
|
1049
|
+
if ((request.show.includes('mesh') || request.show.includes('hull')) && !anyMesh) notes.push('mesh/hull: no slot shows a mesh here — nothing drawn for it');
|
|
719
1050
|
const legend: InspectLegend = {
|
|
720
1051
|
spec: INSPECT_SPEC,
|
|
721
|
-
poser:
|
|
722
|
-
show:
|
|
1052
|
+
poser: builds[0].poser,
|
|
1053
|
+
show: showSpelled(request, weightsBone),
|
|
723
1054
|
dim: request.dim,
|
|
724
1055
|
...(request.skin === undefined ? {} : { skin: request.skin }),
|
|
725
1056
|
animation: request.animation,
|
|
726
1057
|
fps: request.fps,
|
|
727
1058
|
framing,
|
|
1059
|
+
...(request.boneFilter === undefined
|
|
1060
|
+
? {}
|
|
1061
|
+
: { boneFilter: { include: request.boneFilter.include === undefined ? null : [...request.boneFilter.include], exclude: request.boneFilter.exclude === undefined ? null : [...request.boneFilter.exclude] } }),
|
|
1062
|
+
...(weightsBone === undefined
|
|
1063
|
+
? {}
|
|
1064
|
+
: {
|
|
1065
|
+
weights: {
|
|
1066
|
+
bone: weightsBone,
|
|
1067
|
+
ramp: WEIGHT_RAMP.map((s) => ({ weight: s.weight, rgba: [...s.rgba] })),
|
|
1068
|
+
floor: WEIGHT_FLOOR,
|
|
1069
|
+
reading:
|
|
1070
|
+
"a pixel is painted when its centre lies in a triangle of a region or mesh: the ramp's colour at the barycentric blend of " +
|
|
1071
|
+
"the triangle's corners' weights for the bone, each channel rounded, linear between the stops; below floor it is not painted. " +
|
|
1072
|
+
"Each vertex's weight is in its tile's meshes[].weights, read off skeleton.json",
|
|
1073
|
+
},
|
|
1074
|
+
}),
|
|
1075
|
+
...(compare ? { builds: builds.map((b) => ({ build: b.tag, poser: b.poser, skeletonSha256: b.sha })) } : {}),
|
|
728
1076
|
coordinates: {
|
|
729
1077
|
world: 'spine world: y up, world units',
|
|
730
1078
|
image: 'the picture: y down, from its top-left corner; pixel (i, j) covers [i, i+1) x [j, j+1), so a position\'s pixel is its floor',
|
|
@@ -739,6 +1087,331 @@ function view(
|
|
|
739
1087
|
return { plate, legend, notes };
|
|
740
1088
|
}
|
|
741
1089
|
|
|
1090
|
+
interface TilePlace {
|
|
1091
|
+
x0: number;
|
|
1092
|
+
y0: number;
|
|
1093
|
+
viewport: Viewport;
|
|
1094
|
+
sheet: boolean;
|
|
1095
|
+
compare: boolean;
|
|
1096
|
+
weightsBone: string | undefined;
|
|
1097
|
+
/** The picture's first tile — the one whose notes are printed, so a sheet says a thing once. */
|
|
1098
|
+
first: boolean;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** One tile: one build at one pose, drawn on its own plate in tile pixels, and its legend in picture pixels. */
|
|
1102
|
+
function drawTile(
|
|
1103
|
+
build: Gathered,
|
|
1104
|
+
pose: { frame: Frame; index: number },
|
|
1105
|
+
filtered: Map<string, string>,
|
|
1106
|
+
place: TilePlace,
|
|
1107
|
+
request: InspectRequest,
|
|
1108
|
+
plant: InspectPlant,
|
|
1109
|
+
notes: string[],
|
|
1110
|
+
): { tile: Plate; legend: LegendTile; weighted: boolean; anyMesh: boolean } {
|
|
1111
|
+
const { frame, index } = pose;
|
|
1112
|
+
const { x0, y0, viewport } = place;
|
|
1113
|
+
const { width, height, scale } = viewport;
|
|
1114
|
+
const first = place.first;
|
|
1115
|
+
const [shiftX, shiftY] = plant.overlayShift ?? [0, 0];
|
|
1116
|
+
const [lieX, lieY] = plant.legendShift ?? [0, 0];
|
|
1117
|
+
// ⭐ The one projection both the picture and the legend read: continuous pixels in the whole picture.
|
|
1118
|
+
const toPixel = (wx: number, wy: number): [number, number] => [
|
|
1119
|
+
x0 + (wx - viewport.minX) * scale,
|
|
1120
|
+
y0 + (plant.unflipped ? wy - viewport.minY : viewport.maxY - wy) * scale,
|
|
1121
|
+
];
|
|
1122
|
+
const point = (wx: number, wy: number): LegendPoint => {
|
|
1123
|
+
const [px, py] = toPixel(wx, wy);
|
|
1124
|
+
return { world: [wx, wy], pixel: [px + lieX, py + lieY] };
|
|
1125
|
+
};
|
|
1126
|
+
const tile = request.show.includes('art') ? renderFrame(frame, build.pages, viewport, BACKGROUND) : filled(width, height, BACKGROUND);
|
|
1127
|
+
if (request.dim) {
|
|
1128
|
+
for (let p = 0; p < tile.data.length; p += 4) {
|
|
1129
|
+
for (let c = 0; c < 3; c++) tile.data[p + c] = Math.round(255 - (255 - tile.data[p + c]) * DIM_KEEP);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
// Drawn on the tile in tile pixels; `tx`/`ty` take a picture pixel back to the tile's.
|
|
1133
|
+
const tx = (px: number): number => px - x0;
|
|
1134
|
+
const ty = (py: number): number => py - y0;
|
|
1135
|
+
const legendTile: LegendTile = {
|
|
1136
|
+
...(place.compare ? { build: build.tag } : {}),
|
|
1137
|
+
animation: request.animation,
|
|
1138
|
+
frame: index,
|
|
1139
|
+
time: request.animation === null ? 0 : index / request.fps,
|
|
1140
|
+
box: { x: x0, y: y0, width, height },
|
|
1141
|
+
transform: { minX: viewport.minX, maxY: viewport.maxY, scale, x0, y0 },
|
|
1142
|
+
bones: [],
|
|
1143
|
+
};
|
|
1144
|
+
if (request.show.includes('grid')) {
|
|
1145
|
+
const step = plant.gridInPixels ? gridStep(scale) / scale : gridStep(scale);
|
|
1146
|
+
const xs = multiples(viewport.minX, viewport.maxX, step);
|
|
1147
|
+
const ys = multiples(viewport.minY, viewport.maxY, step);
|
|
1148
|
+
for (const gx of xs) {
|
|
1149
|
+
const col = Math.floor(tx(toPixel(gx, 0)[0]));
|
|
1150
|
+
for (let y = 0; y < height; y++) put(tile, col, y, colour(gx === 0 ? 'gridZero' : 'grid'));
|
|
1151
|
+
}
|
|
1152
|
+
for (const gy of ys) {
|
|
1153
|
+
const row = Math.floor(ty(toPixel(0, gy)[1]));
|
|
1154
|
+
for (let x = 0; x < width; x++) put(tile, x, row, colour(gy === 0 ? 'gridZero' : 'grid'));
|
|
1155
|
+
}
|
|
1156
|
+
for (const gx of xs) text(tile, gridLabel(gx, step), Math.floor(tx(toPixel(gx, 0)[0])) + 2, 2, colour('gridZero'));
|
|
1157
|
+
for (const gy of ys) text(tile, gridLabel(gy, step), 2, Math.floor(ty(toPixel(0, gy)[1])) + 2, colour('gridZero'));
|
|
1158
|
+
legendTile.grid = { step, x: xs, y: ys };
|
|
1159
|
+
}
|
|
1160
|
+
// ── what a bone moves: weights under the edges, the edges under the hull ──
|
|
1161
|
+
let weighted = false;
|
|
1162
|
+
let anyMesh = false;
|
|
1163
|
+
if (build.rest !== null) {
|
|
1164
|
+
legendTile.meshes = [];
|
|
1165
|
+
const [mx, my] = plant.meshShift ?? [0, 0];
|
|
1166
|
+
for (const shown of frame.attachments ?? []) {
|
|
1167
|
+
const rest = build.rest.get(restKey(shown.slot, shown.attachment));
|
|
1168
|
+
const vertexCount = shown.vertices.length / 2;
|
|
1169
|
+
if (rest === undefined || rest.vertices.length !== shown.vertices.length) {
|
|
1170
|
+
throw new Error(
|
|
1171
|
+
`slot "${shown.slot}" attachment "${shown.attachment}": the rest table ${rest === undefined ? 'has no entry for it' : `states ${rest.vertices.length / 2} vertices`}, the pose ${vertexCount} — the poser disagrees with itself`,
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
const pixel: number[] = [];
|
|
1175
|
+
for (let v = 0; v < shown.vertices.length; v += 2) pixel.push(...toPixel(shown.vertices[v], shown.vertices[v + 1]));
|
|
1176
|
+
const at = (v: number): [number, number] => [tx(pixel[2 * v]), ty(pixel[2 * v + 1])];
|
|
1177
|
+
const edges = edgesOf(rest.triangles);
|
|
1178
|
+
const entry: LegendMesh = {
|
|
1179
|
+
slot: shown.slot,
|
|
1180
|
+
attachment: shown.attachment,
|
|
1181
|
+
kind: rest.kind,
|
|
1182
|
+
vertexCount,
|
|
1183
|
+
triangleCount: rest.triangles.length / 3,
|
|
1184
|
+
hull: rest.kind === 'mesh' ? (rest.hull ?? null) : null,
|
|
1185
|
+
edges,
|
|
1186
|
+
world: [...shown.vertices],
|
|
1187
|
+
pixel,
|
|
1188
|
+
};
|
|
1189
|
+
if (place.weightsBone !== undefined && build.weights !== null) {
|
|
1190
|
+
const read = build.weights(shown.slot, shown.attachment);
|
|
1191
|
+
if (typeof read === 'string') throw new InspectError(`--show weights:${place.weightsBone}: ${read}`);
|
|
1192
|
+
if (read.length !== vertexCount) {
|
|
1193
|
+
throw new InspectError(`--show weights:${place.weightsBone}: slot "${shown.slot}" attachment "${shown.attachment}" — skeleton.json weights ${read.length} vertices, the pose has ${vertexCount}`);
|
|
1194
|
+
}
|
|
1195
|
+
const bone = place.weightsBone;
|
|
1196
|
+
const weights = read.map((run) => run.reduce((sum, [name, w]) => (name === bone ? sum + w : sum), 0));
|
|
1197
|
+
entry.weights = weights;
|
|
1198
|
+
if (weights.some((w) => w >= WEIGHT_FLOOR)) weighted = true;
|
|
1199
|
+
for (let t = 0; t + 2 < rest.triangles.length; t += 3) {
|
|
1200
|
+
const corners = [rest.triangles[t], rest.triangles[t + 1], rest.triangles[t + 2]];
|
|
1201
|
+
heatTriangle(tile, corners.map(at), corners.map((v) => weights[v]));
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
legendTile.meshes.push(entry);
|
|
1205
|
+
}
|
|
1206
|
+
for (const entry of legendTile.meshes) {
|
|
1207
|
+
if (entry.kind !== 'mesh') continue;
|
|
1208
|
+
anyMesh = true;
|
|
1209
|
+
const at = (v: number): [number, number] => [tx(entry.pixel[2 * v]) + mx, ty(entry.pixel[2 * v + 1]) + my];
|
|
1210
|
+
if (request.show.includes('mesh')) {
|
|
1211
|
+
for (const [p, q] of entry.edges) line(tile, ...at(p), ...at(q), colour('mesh'));
|
|
1212
|
+
}
|
|
1213
|
+
if (request.show.includes('hull')) {
|
|
1214
|
+
const hull = entry.hull ?? 0;
|
|
1215
|
+
const ring: Array<[number, number]> = plant.hullAllEdges ? entry.edges : Array.from({ length: hull }, (_, i): [number, number] => [i, (i + 1) % hull]);
|
|
1216
|
+
for (const [p, q] of ring) line(tile, ...at(p), ...at(q), colour('hull'));
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
const snapshots = new Map<string, BoneSnapshot>((frame.bones ?? []).map((b) => [b.name, b]));
|
|
1221
|
+
const isPosed = (b: BoneSnapshot | undefined): b is BoneSnapshot => b !== undefined && !(b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0);
|
|
1222
|
+
const held = new Set<string>();
|
|
1223
|
+
if (request.show.includes('declared')) {
|
|
1224
|
+
legendTile.declared = [];
|
|
1225
|
+
for (const axis of build.axes) {
|
|
1226
|
+
const a = snapshots.get(axis.through[0]);
|
|
1227
|
+
const b = snapshots.get(axis.through[1]);
|
|
1228
|
+
const entry: LegendDeclared = { name: axis.name, through: axis.through, tolerance: axis.tolerance, degrees: null, why: null, from: null, to: null, bones: [] };
|
|
1229
|
+
if (!isPosed(a) || !isPosed(b)) {
|
|
1230
|
+
entry.why = `${!isPosed(a) ? axis.through[0] : axis.through[1]} is not posed at this time (its world matrix is 0), so the line has no point there`;
|
|
1231
|
+
} else if (a.worldX === b.worldX && a.worldY === b.worldY) {
|
|
1232
|
+
entry.why = `"${axis.through[0]}" and "${axis.through[1]}" share the world origin (${a.worldX}, ${a.worldY}), so they draw no line`;
|
|
1233
|
+
} else {
|
|
1234
|
+
const degrees = (Math.atan2(b.worldY - a.worldY, b.worldX - a.worldX) * 180) / Math.PI;
|
|
1235
|
+
entry.degrees = degrees;
|
|
1236
|
+
entry.from = point(a.worldX, a.worldY);
|
|
1237
|
+
entry.to = point(b.worldX, b.worldY);
|
|
1238
|
+
// Across the whole tile: extend the line past both ends by the tile's diagonal, in world units.
|
|
1239
|
+
const reach = Math.hypot(viewport.maxX - viewport.minX, viewport.maxY - viewport.minY);
|
|
1240
|
+
const ux = (b.worldX - a.worldX) / Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
|
|
1241
|
+
const uy = (b.worldY - a.worldY) / Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
|
|
1242
|
+
const [p0x, p0y] = toPixel(a.worldX - ux * reach, a.worldY - uy * reach);
|
|
1243
|
+
const [p1x, p1y] = toPixel(a.worldX + ux * reach, a.worldY + uy * reach);
|
|
1244
|
+
line(tile, tx(p0x) + shiftX, ty(p0y) + shiftY, tx(p1x) + shiftX, ty(p1y) + shiftY, colour('declared'));
|
|
1245
|
+
for (const name of axis.bones) {
|
|
1246
|
+
const s = snapshots.get(name);
|
|
1247
|
+
if (!isPosed(s) || (s.a === 0 && s.c === 0)) {
|
|
1248
|
+
entry.bones.push({ name, degrees: null, off: null });
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const found = worldAxisDegrees(s);
|
|
1252
|
+
entry.bones.push({ name, degrees: found, off: lineGap(found, degrees) });
|
|
1253
|
+
held.add(name);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
if (entry.why !== null && first) notes.push(`declared: axis "${axis.name}" — ${entry.why}`);
|
|
1257
|
+
legendTile.declared.push(entry);
|
|
1258
|
+
}
|
|
1259
|
+
if (build.axes.length === 0 && first) notes.push('declared: the rig declares no invariants.axes — nothing drawn for it');
|
|
1260
|
+
}
|
|
1261
|
+
// Every bone, in declaration order — drawn or not, so the legend names each.
|
|
1262
|
+
const drawn: Array<{ bone: LegendBone; o: [number, number]; t: [number, number] }> = [];
|
|
1263
|
+
for (const declared of build.bones) {
|
|
1264
|
+
const s = snapshots.get(declared.name);
|
|
1265
|
+
const length = build.lengths.get(declared.name) ?? 0;
|
|
1266
|
+
const posedBone = isPosed(s);
|
|
1267
|
+
const left = filtered.get(declared.name);
|
|
1268
|
+
const ox = s?.worldX ?? 0;
|
|
1269
|
+
const oy = s?.worldY ?? 0;
|
|
1270
|
+
const bone: LegendBone = {
|
|
1271
|
+
name: declared.name,
|
|
1272
|
+
parent: declared.parent,
|
|
1273
|
+
length,
|
|
1274
|
+
zeroLength: length === 0,
|
|
1275
|
+
undrawn: !posedBone ? 'its world matrix is 0 at this time — unposed by the skin, or a scale of 0' : (left ?? null),
|
|
1276
|
+
origin: point(ox, oy),
|
|
1277
|
+
tip: point(ox + length * (s?.a ?? 0), oy + length * (s?.c ?? 0)),
|
|
1278
|
+
};
|
|
1279
|
+
const shows = posedBone && left === undefined;
|
|
1280
|
+
if (shows && request.show.includes('axes')) {
|
|
1281
|
+
const unit = AXIS_PIXELS / scale;
|
|
1282
|
+
bone.axes = { x: point(ox + s.a * unit, oy + s.c * unit), y: point(ox + s.b * unit, oy + s.d * unit) };
|
|
1283
|
+
}
|
|
1284
|
+
legendTile.bones.push(bone);
|
|
1285
|
+
if (shows) drawn.push({ bone, o: toPixel(ox, oy), t: toPixel(bone.tip.world[0], bone.tip.world[1]) });
|
|
1286
|
+
}
|
|
1287
|
+
// Lines, then axes, then slot numbers and names, then the marks — so a mark is never covered.
|
|
1288
|
+
const at = (p: [number, number]): [number, number] => [tx(p[0]) + shiftX, ty(p[1]) + shiftY];
|
|
1289
|
+
if (request.show.includes('bones')) {
|
|
1290
|
+
for (const { bone, o, t } of drawn) {
|
|
1291
|
+
if (bone.zeroLength) continue;
|
|
1292
|
+
const [ax, ay] = at(o);
|
|
1293
|
+
const [bx, by] = at(t);
|
|
1294
|
+
line(tile, ax, ay, bx, by, colour(held.has(bone.name) ? 'held' : 'bone'));
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (request.show.includes('axes')) {
|
|
1298
|
+
for (const { bone, o } of drawn) {
|
|
1299
|
+
if (bone.axes === undefined) continue;
|
|
1300
|
+
const [ax, ay] = at(o);
|
|
1301
|
+
for (const [end, name] of [[bone.axes.y, 'axisY'], [bone.axes.x, 'axisX']] as const) {
|
|
1302
|
+
const [ex, ey] = at(toPixel(end.world[0], end.world[1]));
|
|
1303
|
+
line(tile, ax, ay, ex, ey, colour(name));
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
// ── labels: slot numbers, then names, placed in one pass so neither overprints the other ──
|
|
1308
|
+
const taken: PixelBox[] = [];
|
|
1309
|
+
const avoid = plant.noAvoid !== true;
|
|
1310
|
+
const toPicture = (b: PixelBox): PixelBox => ({ x: b.x + x0, y: b.y + y0, width: b.width, height: b.height });
|
|
1311
|
+
const writes: Array<() => void> = [];
|
|
1312
|
+
const leader = (ax: number, ay: number, box: PixelBox): void => line(tile, ax + 0.5, ay + 0.5, box.x - 0.5, box.y + Math.floor(box.height / 2) + 0.5, colour('leader'));
|
|
1313
|
+
if (request.show.includes('slots')) {
|
|
1314
|
+
legendTile.slots = [];
|
|
1315
|
+
const shown = frame.attachments ?? [];
|
|
1316
|
+
for (const [i, att] of shown.entries()) {
|
|
1317
|
+
const order = plant.slotsReversed ? shown.length - 1 - i : i;
|
|
1318
|
+
let minX = Infinity;
|
|
1319
|
+
let minY = Infinity;
|
|
1320
|
+
let maxX = -Infinity;
|
|
1321
|
+
let maxY = -Infinity;
|
|
1322
|
+
for (let v = 0; v + 1 < att.vertices.length; v += 2) {
|
|
1323
|
+
minX = Math.min(minX, att.vertices[v]);
|
|
1324
|
+
maxX = Math.max(maxX, att.vertices[v]);
|
|
1325
|
+
minY = Math.min(minY, att.vertices[v + 1]);
|
|
1326
|
+
maxY = Math.max(maxY, att.vertices[v + 1]);
|
|
1327
|
+
}
|
|
1328
|
+
const anchor = point((minX + maxX) / 2, (minY + maxY) / 2);
|
|
1329
|
+
const [apx, apy] = toPixel((minX + maxX) / 2, (minY + maxY) / 2);
|
|
1330
|
+
const [ax, ay] = [Math.floor(tx(apx)), Math.floor(ty(apy))];
|
|
1331
|
+
const label = String(order);
|
|
1332
|
+
const w = textWidth(label, 1) + 2;
|
|
1333
|
+
const h = GLYPH_H + 2;
|
|
1334
|
+
const placed = placeLabel({ x: ax - Math.floor(w / 2), y: ay - Math.floor(h / 2), width: w, height: h }, taken, avoid);
|
|
1335
|
+
writes.push(() => {
|
|
1336
|
+
if (placed.moved) leader(ax, ay, placed.box);
|
|
1337
|
+
});
|
|
1338
|
+
writes.push(() => {
|
|
1339
|
+
for (let y = placed.box.y; y < placed.box.y + h; y++) for (let x = placed.box.x; x < placed.box.x + w; x++) put(tile, x, y, colour('slotBack'));
|
|
1340
|
+
text(tile, label, placed.box.x + 1, placed.box.y + 1, colour('slot'));
|
|
1341
|
+
});
|
|
1342
|
+
const slotBone = build.slots.find((s) => s.name === att.slot)?.bone;
|
|
1343
|
+
if (slotBone === undefined) throw new Error(`slot "${att.slot}" shows an attachment and is not in the poser's slot list — the poser disagrees with itself`);
|
|
1344
|
+
legendTile.slots.push({
|
|
1345
|
+
order,
|
|
1346
|
+
slot: att.slot,
|
|
1347
|
+
attachment: att.attachment,
|
|
1348
|
+
bone: slotBone,
|
|
1349
|
+
anchor,
|
|
1350
|
+
box: toPicture(placed.box),
|
|
1351
|
+
moved: placed.moved,
|
|
1352
|
+
overlaps: placed.overlaps,
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
legendTile.slots.sort((p, q) => p.order - q.order);
|
|
1356
|
+
}
|
|
1357
|
+
if (request.show.includes('names')) {
|
|
1358
|
+
// ⭐ One label per pixel. What overprints is two labels starting on one pixel — a `_ctl` and its child, a chain's
|
|
1359
|
+
// joint — and the pixel is the unit of that, not the world origin: two origins 0.3 px apart overprint as surely as
|
|
1360
|
+
// two that coincide, and two that coincide always share a pixel.
|
|
1361
|
+
legendTile.labels = [];
|
|
1362
|
+
const groups = new Map<string, { anchor: [number, number]; bones: string[] }>();
|
|
1363
|
+
for (const { bone, o } of drawn) {
|
|
1364
|
+
const [ax, ay] = at(o);
|
|
1365
|
+
const anchor: [number, number] = [Math.floor(ax), Math.floor(ay)];
|
|
1366
|
+
const key = plant.noFold ? `${anchor[0]},${anchor[1]},${bone.name}` : `${anchor[0]},${anchor[1]}`;
|
|
1367
|
+
const group = groups.get(key) ?? { anchor, bones: [] };
|
|
1368
|
+
group.bones.push(bone.name);
|
|
1369
|
+
groups.set(key, group);
|
|
1370
|
+
}
|
|
1371
|
+
for (const { anchor, bones } of groups.values()) {
|
|
1372
|
+
const label = bones.length > 1 ? `${bones[0]} +${bones.length - 1}` : bones[0];
|
|
1373
|
+
const placed = placeLabel({ x: anchor[0] + 4, y: anchor[1] + 3, width: textWidth(label, 1), height: GLYPH_H }, taken, avoid);
|
|
1374
|
+
writes.push(() => {
|
|
1375
|
+
if (placed.moved) leader(anchor[0], anchor[1], placed.box);
|
|
1376
|
+
});
|
|
1377
|
+
writes.push(() => text(tile, label, placed.box.x, placed.box.y, colour('name')));
|
|
1378
|
+
legendTile.labels.push({
|
|
1379
|
+
text: label,
|
|
1380
|
+
bones,
|
|
1381
|
+
anchor: [anchor[0] + x0, anchor[1] + y0],
|
|
1382
|
+
box: toPicture(placed.box),
|
|
1383
|
+
moved: placed.moved,
|
|
1384
|
+
overlaps: placed.overlaps,
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
// Every leader first, then every label, so no leader crosses a label's text.
|
|
1389
|
+
for (const w of writes.filter((_, i) => i % 2 === 0)) w();
|
|
1390
|
+
for (const w of writes.filter((_, i) => i % 2 === 1)) w();
|
|
1391
|
+
if (request.show.includes('bones')) {
|
|
1392
|
+
for (const { bone, t } of drawn) {
|
|
1393
|
+
if (bone.zeroLength) continue;
|
|
1394
|
+
const [bx, by] = at(t);
|
|
1395
|
+
square(tile, Math.floor(bx), Math.floor(by), 1, true, colour('tip'));
|
|
1396
|
+
}
|
|
1397
|
+
for (const { bone, o } of drawn) {
|
|
1398
|
+
const [ax, ay] = at(o);
|
|
1399
|
+
if (bone.zeroLength) square(tile, Math.floor(ax), Math.floor(ay), 2, true, colour('zero'));
|
|
1400
|
+
square(tile, Math.floor(ax), Math.floor(ay), 1, false, colour('origin'));
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
if (place.sheet || place.compare) {
|
|
1404
|
+
const when = request.animation === null ? 'setup' : `t=${legendTile.time.toFixed(3)} f${index}`;
|
|
1405
|
+
const label = place.compare ? `${build.tag} ${build.poser}${place.sheet ? ` ${when}` : ''}` : when;
|
|
1406
|
+
const ly = height - GLYPH_H - 2;
|
|
1407
|
+
const box: PixelBox = { x: 1, y: ly - 1, width: textWidth(label, 1) + 2, height: GLYPH_H + 2 };
|
|
1408
|
+
for (let y = box.y; y < box.y + box.height; y++) for (let x = box.x; x < box.x + box.width; x++) put(tile, x, y, BACKGROUND);
|
|
1409
|
+
text(tile, label, 2, ly, colour('label'));
|
|
1410
|
+
legendTile.label = { text: label, box: toPicture(box) };
|
|
1411
|
+
}
|
|
1412
|
+
return { tile, legend: legendTile, weighted, anyMesh };
|
|
1413
|
+
}
|
|
1414
|
+
|
|
742
1415
|
function filled(width: number, height: number, rgba: RGBA): Plate {
|
|
743
1416
|
const plate = new Plate(width, height);
|
|
744
1417
|
for (let i = 0; i < plate.data.length; i += 4) plate.data.set(rgba, i);
|