@rig-c/core 2.41.0 → 2.43.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 +8 -3
- package/docs/AUTHORING.md +75 -14
- package/package.json +1 -1
- package/src/cli/core_commands.ts +43 -5
- package/src/cli/shared.ts +36 -5
- package/src/inspect.ts +941 -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,35 @@ 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
|
+
|
|
173
|
+
/**
|
|
174
|
+
* `--overlay-slot` / `--hide-overlay-slot`: which slots the per-slot views
|
|
175
|
+
* (`mesh`, `hull`, `weights:<bone>`, `slots`) draw, by glob. The art is not
|
|
176
|
+
* filtered — it is what the geometry is read against.
|
|
177
|
+
*/
|
|
178
|
+
export interface SlotFilter {
|
|
179
|
+
/** Draw only the slots some pattern matches; absent draws every slot. */
|
|
180
|
+
include?: readonly string[];
|
|
181
|
+
/** Draw none of the slots a pattern matches — applied after `include`. */
|
|
182
|
+
exclude?: readonly string[];
|
|
183
|
+
}
|
|
184
|
+
|
|
116
185
|
/** One `rigc inspect` invocation, its flags read but not yet checked against the build. */
|
|
117
186
|
export interface InspectRequest {
|
|
118
187
|
show: readonly ShowWord[];
|
|
188
|
+
/** The bone `weights:<bone>` names — present exactly when `show` holds `weights`. */
|
|
189
|
+
weightsBone?: string;
|
|
190
|
+
/** Which bones `bones`, `axes` and `names` draw; absent is every one. */
|
|
191
|
+
boneFilter?: BoneFilter;
|
|
192
|
+
/** Which slots `mesh`, `hull`, `weights` and `slots` draw; absent is every one. */
|
|
193
|
+
slotFilter?: SlotFilter;
|
|
119
194
|
dim: boolean;
|
|
120
195
|
/** `null` is the setup pose. */
|
|
121
196
|
animation: string | null;
|
|
@@ -129,12 +204,18 @@ export interface InspectRequest {
|
|
|
129
204
|
|
|
130
205
|
/** What the picture is made from besides the poser: the build's own texts. */
|
|
131
206
|
export interface InspectTexts {
|
|
132
|
-
/** `skeleton.json` — read for each bone's `length`, which no pose carries. */
|
|
207
|
+
/** `skeleton.json` — read for each bone's `length`, which no pose carries, and for each attachment's weights. */
|
|
133
208
|
skeletonText: string;
|
|
134
209
|
/** The `skeleton.model.json` that is this skeleton's, or `undefined` — what `declared` reads `rig.axes` from. */
|
|
135
210
|
modelText: string | undefined;
|
|
136
211
|
}
|
|
137
212
|
|
|
213
|
+
/** The second build `--compare` draws beside the first. */
|
|
214
|
+
export interface InspectCompare {
|
|
215
|
+
candidate: Candidate;
|
|
216
|
+
texts: InspectTexts;
|
|
217
|
+
}
|
|
218
|
+
|
|
138
219
|
/**
|
|
139
220
|
* A planted defect, for the suite's mutants only (`IN` controls in
|
|
140
221
|
* `selftest.ts`). Each one is a way the picture or the legend can be wrong that
|
|
@@ -151,6 +232,24 @@ export interface InspectPlant {
|
|
|
151
232
|
unflipped?: boolean;
|
|
152
233
|
/** Zoom to this bone, whatever the request named. */
|
|
153
234
|
zoomBone?: string;
|
|
235
|
+
/** Draw every mesh edge this many pixels off the vertices the legend states. */
|
|
236
|
+
meshShift?: [number, number];
|
|
237
|
+
/** Paint (and state) this bone's weights, whatever `weights:<bone>` named. */
|
|
238
|
+
weightsOf?: string;
|
|
239
|
+
/** Number the slots in the reverse of the posed draw order. */
|
|
240
|
+
slotsReversed?: boolean;
|
|
241
|
+
/** Draw every triangle edge in the hull's colour. */
|
|
242
|
+
hullAllEdges?: boolean;
|
|
243
|
+
/** One label per bone, stacked under a shared pixel as cut 1 printed them. */
|
|
244
|
+
noFold?: boolean;
|
|
245
|
+
/** Place every label where it begins, over whatever is there. */
|
|
246
|
+
noAvoid?: boolean;
|
|
247
|
+
/** Draw every bone whatever `--bone`/`--hide-bone` say. */
|
|
248
|
+
ignoreFilter?: boolean;
|
|
249
|
+
/** Draw every slot's geometry whatever `--overlay-slot`/`--hide-overlay-slot` say. */
|
|
250
|
+
ignoreSlotFilter?: boolean;
|
|
251
|
+
/** Draw the first build in both halves of a `--compare`. */
|
|
252
|
+
compareFirstTwice?: boolean;
|
|
154
253
|
}
|
|
155
254
|
|
|
156
255
|
/** A point in both spaces. */
|
|
@@ -169,7 +268,7 @@ export interface LegendBone {
|
|
|
169
268
|
length: number;
|
|
170
269
|
/** `length` is 0: origin and tip are one point, marked by the `zero` square. */
|
|
171
270
|
zeroLength: boolean;
|
|
172
|
-
/** `null` when the bone is drawn; otherwise why it is not (its world matrix is 0). */
|
|
271
|
+
/** `null` when the bone is drawn; otherwise why it is not (its world matrix is 0, or a bone filter left it out). */
|
|
173
272
|
undrawn: string | null;
|
|
174
273
|
origin: LegendPoint;
|
|
175
274
|
tip: LegendPoint;
|
|
@@ -177,6 +276,62 @@ export interface LegendBone {
|
|
|
177
276
|
axes?: { x: LegendPoint; y: LegendPoint };
|
|
178
277
|
}
|
|
179
278
|
|
|
279
|
+
/** A box of picture pixels: columns `x .. x+width-1`, rows `y .. y+height-1`. */
|
|
280
|
+
export interface PixelBox {
|
|
281
|
+
x: number;
|
|
282
|
+
y: number;
|
|
283
|
+
width: number;
|
|
284
|
+
height: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** One name label in one tile. */
|
|
288
|
+
export interface LegendLabel {
|
|
289
|
+
text: string;
|
|
290
|
+
/** Every bone the label stands for, in declaration order — the first is the one named, the rest are the "+N". */
|
|
291
|
+
bones: string[];
|
|
292
|
+
/** The origin pixel the bones share. */
|
|
293
|
+
anchor: [number, number];
|
|
294
|
+
/** Where the text is drawn. */
|
|
295
|
+
box: PixelBox;
|
|
296
|
+
/** It was moved down to clear an earlier label, and a leader joins it to `anchor`. */
|
|
297
|
+
moved: boolean;
|
|
298
|
+
/** No place within `LABEL_TRIES` lines was clear, so it stays where it began, over an earlier label. */
|
|
299
|
+
overlaps: boolean;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** One region or mesh the frame shows, when `mesh`, `hull` or `weights` is drawn — and, with a slot filter, that it keeps. */
|
|
303
|
+
export interface LegendMesh {
|
|
304
|
+
slot: string;
|
|
305
|
+
attachment: string;
|
|
306
|
+
kind: 'region' | 'mesh';
|
|
307
|
+
vertexCount: number;
|
|
308
|
+
triangleCount: number;
|
|
309
|
+
/** A mesh's hull vertex count (vertices 0..hull−1); `null` for a region. */
|
|
310
|
+
hull: number | null;
|
|
311
|
+
/** Its unique triangle edges, each `[i, j]` with `i < j`, in the order the triangles first name them. */
|
|
312
|
+
edges: Array<[number, number]>;
|
|
313
|
+
/** Posed vertices, `x, y` per vertex: world units, and the picture's pixels. */
|
|
314
|
+
world: number[];
|
|
315
|
+
pixel: number[];
|
|
316
|
+
/** With `weights:<bone>`: that bone's weight at each vertex. */
|
|
317
|
+
weights?: number[];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** One slot that shows a region or mesh at this tile's pose. */
|
|
321
|
+
export interface LegendSlot {
|
|
322
|
+
/** Its place in the posed draw order among the slots that show a region or mesh — 0 is drawn first. */
|
|
323
|
+
order: number;
|
|
324
|
+
slot: string;
|
|
325
|
+
attachment: string;
|
|
326
|
+
bone: string;
|
|
327
|
+
/** The centre of its posed vertices' box, where its number is drawn from. */
|
|
328
|
+
anchor: LegendPoint;
|
|
329
|
+
/** Where the number is drawn: the white box under it, text 1 px inside. */
|
|
330
|
+
box: PixelBox;
|
|
331
|
+
moved: boolean;
|
|
332
|
+
overlaps: boolean;
|
|
333
|
+
}
|
|
334
|
+
|
|
180
335
|
/** One `invariants.axes` entry in one tile. */
|
|
181
336
|
export interface LegendDeclared {
|
|
182
337
|
name: string;
|
|
@@ -192,6 +347,8 @@ export interface LegendDeclared {
|
|
|
192
347
|
}
|
|
193
348
|
|
|
194
349
|
export interface LegendTile {
|
|
350
|
+
/** With `--compare`: which build this tile is — `A` is `--candidate`, `B` is `--compare`. */
|
|
351
|
+
build?: 'A' | 'B';
|
|
195
352
|
/** `null` for the setup pose. */
|
|
196
353
|
animation: string | null;
|
|
197
354
|
/** The frame of `render --fps <fps>` this pose is, and its time `frame / fps`. */
|
|
@@ -201,15 +358,21 @@ export interface LegendTile {
|
|
|
201
358
|
box: { x: number; y: number; width: number; height: number };
|
|
202
359
|
/** 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
360
|
transform: { minX: number; maxY: number; scale: number; x0: number; y0: number };
|
|
361
|
+
/** On a sheet or a compare: the tile's label and the box it is drawn on. */
|
|
362
|
+
label?: { text: string; box: PixelBox };
|
|
204
363
|
bones: LegendBone[];
|
|
364
|
+
labels?: LegendLabel[];
|
|
365
|
+
meshes?: LegendMesh[];
|
|
366
|
+
slots?: LegendSlot[];
|
|
205
367
|
declared?: LegendDeclared[];
|
|
206
368
|
grid?: { step: number; x: number[]; y: number[] };
|
|
207
369
|
}
|
|
208
370
|
|
|
209
371
|
export interface InspectLegend {
|
|
210
372
|
spec: typeof INSPECT_SPEC;
|
|
373
|
+
/** The poser that posed `--candidate`; with `--compare`, `builds` names both. */
|
|
211
374
|
poser: 'core' | 'spine';
|
|
212
|
-
show:
|
|
375
|
+
show: string[];
|
|
213
376
|
dim: boolean;
|
|
214
377
|
skin?: string;
|
|
215
378
|
animation: string | null;
|
|
@@ -218,8 +381,16 @@ export interface InspectLegend {
|
|
|
218
381
|
| { kind: 'shot'; note: string }
|
|
219
382
|
| { kind: 'zoom'; bone?: string; slot?: string; pad: number; padFrom: 'stated' | 'default' }
|
|
220
383
|
| { kind: 'viewport' };
|
|
384
|
+
/** `--bone` / `--hide-bone` as given — each bone they leave out says so in its `undrawn`. */
|
|
385
|
+
boneFilter?: { include: string[] | null; exclude: string[] | null };
|
|
386
|
+
/** `--overlay-slot` / `--hide-overlay-slot` as given — `meshes[]` and `slots[]` then list only the slots they keep. */
|
|
387
|
+
slotFilter?: { include: string[] | null; exclude: string[] | null };
|
|
388
|
+
/** With `weights:<bone>`: the bone, the ramp a weight is painted with, and how a pixel's weight is read. */
|
|
389
|
+
weights?: { bone: string; ramp: Array<{ weight: number; rgba: number[] }>; floor: number; reading: string };
|
|
390
|
+
/** With `--compare`: each build, the poser that posed it and its `skeleton.json`'s sha256; a tile names its own in `build`. */
|
|
391
|
+
builds?: Array<{ build: 'A' | 'B'; poser: 'core' | 'spine'; skeletonSha256: string }>;
|
|
221
392
|
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. */
|
|
393
|
+
/** The world box every tile is a picture of, and its pixels — one for the whole picture, both builds of a compare included. */
|
|
223
394
|
viewport: { minX: number; minY: number; maxX: number; maxY: number; scale: number; width: number; height: number };
|
|
224
395
|
size: { width: number; height: number };
|
|
225
396
|
colours: Record<ColourName, { rgba: number[]; meaning: string }>;
|
|
@@ -231,6 +402,8 @@ export interface InspectResult {
|
|
|
231
402
|
plate: Plate;
|
|
232
403
|
legend: InspectLegend;
|
|
233
404
|
poserNote: string;
|
|
405
|
+
/** With `--compare`: the second build's poser line. */
|
|
406
|
+
comparePoserNote?: string;
|
|
234
407
|
/** Lines worth printing beside the picture: what a view drew nothing for, and why. */
|
|
235
408
|
notes: string[];
|
|
236
409
|
}
|
|
@@ -239,20 +412,58 @@ export interface InspectResult {
|
|
|
239
412
|
// reading the request against the build
|
|
240
413
|
// ---------------------------------------------------------------------------
|
|
241
414
|
|
|
242
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
415
|
+
/**
|
|
416
|
+
* `--show`'s words, refused by name — a word this command does not draw lists
|
|
417
|
+
* every one it does — and the bone `weights:<bone>` names. The bone is checked
|
|
418
|
+
* against the build later, where its roster is known.
|
|
419
|
+
*/
|
|
420
|
+
export function readShow(raw: string | undefined): { show: ShowWord[]; weightsBone?: string } {
|
|
421
|
+
if (raw === undefined) return { show: [...DEFAULT_SHOW] };
|
|
245
422
|
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 [${
|
|
423
|
+
if (words.length === 0) throw new InspectError(`--show ${JSON.stringify(raw)} names no view; it takes [${SHOW_SPELLINGS.join(', ')}]`);
|
|
247
424
|
const out: ShowWord[] = [];
|
|
248
|
-
|
|
425
|
+
let weightsBone: string | undefined;
|
|
426
|
+
for (const spelled of words) {
|
|
427
|
+
let word = spelled;
|
|
428
|
+
if (spelled === 'weights' || spelled.startsWith('weights:')) {
|
|
429
|
+
const bone = spelled.slice('weights:'.length);
|
|
430
|
+
if (spelled === 'weights' || bone === '') {
|
|
431
|
+
throw new InspectError(`--show ${JSON.stringify(spelled)} needs the bone whose weights it paints: weights:<bone>`);
|
|
432
|
+
}
|
|
433
|
+
word = 'weights';
|
|
434
|
+
weightsBone = bone;
|
|
435
|
+
}
|
|
249
436
|
const known = SHOW_WORDS.find((w) => w === word);
|
|
250
|
-
if (known === undefined) throw new InspectError(`--show ${JSON.stringify(
|
|
437
|
+
if (known === undefined) throw new InspectError(`--show ${JSON.stringify(spelled)} is not a view inspect draws; it draws [${SHOW_SPELLINGS.join(', ')}]`);
|
|
251
438
|
if (out.includes(known)) throw new InspectError(`--show names ${JSON.stringify(word)} twice`);
|
|
252
439
|
out.push(known);
|
|
253
440
|
}
|
|
254
441
|
// 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));
|
|
442
|
+
return { show: SHOW_WORDS.filter((w) => out.includes(w)), ...(weightsBone === undefined ? {} : { weightsBone }) };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** `--show` as the legend spells it: the words in drawing order, `weights` with its bone. */
|
|
446
|
+
function showSpelled(request: InspectRequest, weightsBone: string | undefined): string[] {
|
|
447
|
+
return request.show.map((w) => (w === 'weights' ? `weights:${weightsBone ?? ''}` : w));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* `--bone` / `--hide-bone`'s patterns: comma-separated globs, `*` any run of
|
|
452
|
+
* characters and `?` one, matched against a bone's whole name. Checked against
|
|
453
|
+
* the build later, where a pattern that matches no bone is refused.
|
|
454
|
+
*/
|
|
455
|
+
export function readBonePatterns(flag: string, raw: string | undefined, example = 'bang_*,*_ctl'): string[] | undefined {
|
|
456
|
+
if (raw === undefined) return undefined;
|
|
457
|
+
const patterns = raw.split(',').map((p) => p.trim());
|
|
458
|
+
if (patterns.some((p) => p === '')) throw new InspectError(`--${flag} ${JSON.stringify(raw)}: an empty pattern; it takes globs, comma-separated, e.g. --${flag} '${example}'`);
|
|
459
|
+
return patterns;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** A glob as the regular expression it means: `*` any run, `?` one character, everything else itself, the whole name. */
|
|
463
|
+
export function globMatcher(pattern: string): (name: string) => boolean {
|
|
464
|
+
const body = [...pattern].map((c) => (c === '*' ? '.*' : c === '?' ? '.' : c.replace(/[.+^${}()|[\]\\/]/g, '\\$&'))).join('');
|
|
465
|
+
const re = new RegExp(`^${body}$`, 'su');
|
|
466
|
+
return (name) => re.test(name);
|
|
256
467
|
}
|
|
257
468
|
|
|
258
469
|
/** `--at`'s seconds. Their grid frames are checked against the animation later, where its length is known. */
|
|
@@ -399,6 +610,215 @@ function multiples(lo: number, hi: number, step: number): number[] {
|
|
|
399
610
|
return out;
|
|
400
611
|
}
|
|
401
612
|
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
// what a bone moves: the build's weights, the poser's topology
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
/** Per vertex, the bones that move it and by how much. */
|
|
618
|
+
export type VertexWeights = Array<Array<[string, number]>>;
|
|
619
|
+
|
|
620
|
+
type SkinFileAttachment = Record<string, unknown>;
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Each region's and mesh's per-vertex bone weights, read off the build's
|
|
624
|
+
* `skeleton.json` — the file both posers derive their skinning from — under
|
|
625
|
+
* `skin` and then the default skin, which is the order the runtime resolves an
|
|
626
|
+
* attachment in. A weighted mesh's runs name bones by their index in the file's
|
|
627
|
+
* `bones` array; an unweighted mesh and a region are moved whole by their slot's
|
|
628
|
+
* bone. A linked mesh reads its parent's. What cannot be read is answered with
|
|
629
|
+
* the sentence saying why, for the caller to refuse by.
|
|
630
|
+
*/
|
|
631
|
+
export function weightsReader(skeletonText: string, skin: string | undefined): (slot: string, attachment: string) => VertexWeights | string {
|
|
632
|
+
const doc = JSON.parse(skeletonText) as {
|
|
633
|
+
bones?: Array<{ name?: unknown }>;
|
|
634
|
+
slots?: Array<{ name?: unknown; bone?: unknown }>;
|
|
635
|
+
skins?: unknown;
|
|
636
|
+
};
|
|
637
|
+
const boneNames = (doc.bones ?? []).map((b) => (typeof b.name === 'string' ? b.name : ''));
|
|
638
|
+
const slotBone = new Map<string, string>();
|
|
639
|
+
for (const s of doc.slots ?? []) if (typeof s.name === 'string' && typeof s.bone === 'string') slotBone.set(s.name, s.bone);
|
|
640
|
+
const skins = Array.isArray(doc.skins) ? (doc.skins as Array<{ name?: unknown; attachments?: Record<string, Record<string, SkinFileAttachment>> }>) : [];
|
|
641
|
+
const find = (skinName: string, slot: string, attachment: string): SkinFileAttachment | undefined => {
|
|
642
|
+
const entries = skins.find((k) => k.name === skinName)?.attachments?.[slot];
|
|
643
|
+
if (entries === undefined) return undefined;
|
|
644
|
+
// Keyed by its name; an entry may also rename itself with a `name` field, which is what the posers report.
|
|
645
|
+
return entries[attachment] ?? Object.values(entries).find((a) => a.name === attachment);
|
|
646
|
+
};
|
|
647
|
+
const order = skin === undefined ? ['default'] : skin === 'default' ? ['default'] : [skin, 'default'];
|
|
648
|
+
return (slot, attachment) => {
|
|
649
|
+
let found: SkinFileAttachment | undefined;
|
|
650
|
+
for (const k of order) {
|
|
651
|
+
found = find(k, slot, attachment);
|
|
652
|
+
if (found !== undefined) break;
|
|
653
|
+
}
|
|
654
|
+
const where = `slot "${slot}" attachment "${attachment}"`;
|
|
655
|
+
if (found === undefined) return `${where}: skeleton.json has no such attachment under skin ${order.map((k) => JSON.stringify(k)).join(' or ')}`;
|
|
656
|
+
const bone = slotBone.get(slot);
|
|
657
|
+
if (bone === undefined) return `${where}: skeleton.json declares no slot "${slot}" with a bone`;
|
|
658
|
+
const type = typeof found.type === 'string' ? found.type : 'region';
|
|
659
|
+
if (type === 'region') return [[[bone, 1]], [[bone, 1]], [[bone, 1]], [[bone, 1]]];
|
|
660
|
+
let mesh = found;
|
|
661
|
+
if (type === 'linkedmesh') {
|
|
662
|
+
const parentSkin = typeof found.skin === 'string' ? found.skin : 'default';
|
|
663
|
+
const parent = typeof found.parent === 'string' ? find(parentSkin, slot, found.parent) : undefined;
|
|
664
|
+
if (parent === undefined) return `${where}: its parent mesh ${JSON.stringify(found.parent)} is not in skin "${parentSkin}"`;
|
|
665
|
+
mesh = parent;
|
|
666
|
+
} else if (type !== 'mesh') {
|
|
667
|
+
return `${where} is a ${type}, which poses no vertices`;
|
|
668
|
+
}
|
|
669
|
+
const uvs = mesh.uvs;
|
|
670
|
+
const vertices = mesh.vertices;
|
|
671
|
+
if (!Array.isArray(uvs) || !Array.isArray(vertices)) return `${where}: its mesh states no uvs or vertices`;
|
|
672
|
+
const count = uvs.length / 2;
|
|
673
|
+
if (vertices.length === uvs.length) return Array.from({ length: count }, (): Array<[string, number]> => [[bone, 1]]);
|
|
674
|
+
const out: VertexWeights = [];
|
|
675
|
+
let i = 0;
|
|
676
|
+
for (let v = 0; v < count; v++) {
|
|
677
|
+
const n = Number(vertices[i++]);
|
|
678
|
+
const run: Array<[string, number]> = [];
|
|
679
|
+
for (let k = 0; k < n; k++, i += 4) {
|
|
680
|
+
const name = boneNames[Number(vertices[i])];
|
|
681
|
+
if (name === undefined) return `${where}: vertex ${v} names bone index ${String(vertices[i])}, and skeleton.json has ${boneNames.length} bones`;
|
|
682
|
+
run.push([name, Number(vertices[i + 3])]);
|
|
683
|
+
}
|
|
684
|
+
out.push(run);
|
|
685
|
+
}
|
|
686
|
+
return out;
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** A triangle list's unique edges, `[i, j]` with `i < j`, in the order the triangles first name them. */
|
|
691
|
+
export function edgesOf(triangles: ArrayLike<number>): Array<[number, number]> {
|
|
692
|
+
const seen = new Set<string>();
|
|
693
|
+
const out: Array<[number, number]> = [];
|
|
694
|
+
for (let t = 0; t + 2 < triangles.length; t += 3) {
|
|
695
|
+
const [a, b, c] = [triangles[t], triangles[t + 1], triangles[t + 2]];
|
|
696
|
+
for (const [p, q] of [[a, b], [b, c], [c, a]]) {
|
|
697
|
+
const e: [number, number] = p < q ? [p, q] : [q, p];
|
|
698
|
+
const key = `${e[0]},${e[1]}`;
|
|
699
|
+
if (seen.has(key)) continue;
|
|
700
|
+
seen.add(key);
|
|
701
|
+
out.push(e);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return out;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** The ramp's colour at `weight` (clamped to 0..1): linear between the two stops either side, each channel rounded. */
|
|
708
|
+
export function rampColour(weight: number): RGBA {
|
|
709
|
+
const w = Math.min(1, Math.max(0, weight));
|
|
710
|
+
for (let i = 0; i + 1 < WEIGHT_RAMP.length; i++) {
|
|
711
|
+
const lo = WEIGHT_RAMP[i];
|
|
712
|
+
const hi = WEIGHT_RAMP[i + 1];
|
|
713
|
+
if (w > hi.weight) continue;
|
|
714
|
+
const t = (w - lo.weight) / (hi.weight - lo.weight);
|
|
715
|
+
return [0, 1, 2, 3].map((c) => Math.round(lo.rgba[c] + (hi.rgba[c] - lo.rgba[c]) * t)) as RGBA;
|
|
716
|
+
}
|
|
717
|
+
return [...WEIGHT_RAMP[WEIGHT_RAMP.length - 1].rgba] as RGBA;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* The heat over one triangle: every pixel whose centre the triangle holds,
|
|
722
|
+
* painted the ramp's colour at the barycentric blend of its corners' weights —
|
|
723
|
+
* so a pixel whose centre is a vertex is that vertex's weight exactly.
|
|
724
|
+
*/
|
|
725
|
+
function heatTriangle(plate: Plate, p: ReadonlyArray<[number, number]>, w: readonly number[]): void {
|
|
726
|
+
const [[x0, y0], [x1, y1], [x2, y2]] = p;
|
|
727
|
+
const area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
|
|
728
|
+
if (area === 0 || w.every((v) => v < WEIGHT_FLOOR)) return;
|
|
729
|
+
const left = Math.max(0, Math.floor(Math.min(x0, x1, x2)));
|
|
730
|
+
const right = Math.min(plate.width - 1, Math.floor(Math.max(x0, x1, x2)));
|
|
731
|
+
const top = Math.max(0, Math.floor(Math.min(y0, y1, y2)));
|
|
732
|
+
const bottom = Math.min(plate.height - 1, Math.floor(Math.max(y0, y1, y2)));
|
|
733
|
+
// A pixel centre ON a vertex or an edge is inside: posed vertices carry float noise of ~1e-7 world units, which
|
|
734
|
+
// must not leave the pixel under a vertex unpainted. In barycentric units, so it is the same share of every triangle.
|
|
735
|
+
const inside = -1e-6;
|
|
736
|
+
for (let py = top; py <= bottom; py++) {
|
|
737
|
+
for (let px = left; px <= right; px++) {
|
|
738
|
+
const cx = px + 0.5;
|
|
739
|
+
const cy = py + 0.5;
|
|
740
|
+
const b0 = ((x1 - cx) * (y2 - cy) - (x2 - cx) * (y1 - cy)) / area;
|
|
741
|
+
const b1 = ((x2 - cx) * (y0 - cy) - (x0 - cx) * (y2 - cy)) / area;
|
|
742
|
+
const b2 = 1 - b0 - b1;
|
|
743
|
+
if (b0 < inside || b1 < inside || b2 < inside) continue;
|
|
744
|
+
const weight = b0 * w[0] + b1 * w[1] + b2 * w[2];
|
|
745
|
+
if (weight < WEIGHT_FLOOR) continue;
|
|
746
|
+
put(plate, px, py, rampColour(weight));
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Two pixel boxes overlap, or sit with no clear pixel between them. */
|
|
752
|
+
function crowds(a: PixelBox, b: PixelBox): boolean {
|
|
753
|
+
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;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Where a label goes: `first`, or the first place below it, a text line at a
|
|
758
|
+
* time, that no earlier label crowds — in the order labels are placed, which is
|
|
759
|
+
* declaration order, so one build and one request place every label alike.
|
|
760
|
+
*/
|
|
761
|
+
function placeLabel(first: PixelBox, taken: PixelBox[], avoid: boolean): { box: PixelBox; moved: boolean; overlaps: boolean } {
|
|
762
|
+
if (avoid) {
|
|
763
|
+
for (let step = 0; step <= LABEL_TRIES; step++) {
|
|
764
|
+
const box = { ...first, y: first.y + step * (GLYPH_H + 2) };
|
|
765
|
+
if (!taken.some((t) => crowds(t, box))) {
|
|
766
|
+
taken.push(box);
|
|
767
|
+
return { box, moved: step > 0, overlaps: false };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
const overlaps = taken.some((t) => crowds(t, first));
|
|
772
|
+
taken.push(first);
|
|
773
|
+
return { box: first, moved: false, overlaps };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** `--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. */
|
|
777
|
+
function filterOut(bones: ReadonlyArray<{ name: string }>, filter: BoneFilter | undefined, apply: boolean): Map<string, string> {
|
|
778
|
+
const out = new Map<string, string>();
|
|
779
|
+
if (filter === undefined) return out;
|
|
780
|
+
const names = bones.map((b) => b.name);
|
|
781
|
+
const matchers = (flag: string, patterns: readonly string[]): Array<(name: string) => boolean> =>
|
|
782
|
+
patterns.map((p) => {
|
|
783
|
+
const m = globMatcher(p);
|
|
784
|
+
if (!names.some(m)) throw new InspectError(`--${flag} ${JSON.stringify(p)} matches no bone; the bones are [${names.join(', ')}]`);
|
|
785
|
+
return m;
|
|
786
|
+
});
|
|
787
|
+
const include = filter.include === undefined ? null : matchers('bone', filter.include);
|
|
788
|
+
const exclude = filter.exclude === undefined ? null : matchers('hide-bone', filter.exclude);
|
|
789
|
+
if (!apply) return out;
|
|
790
|
+
for (const name of names) {
|
|
791
|
+
if (include !== null && !include.some((m) => m(name))) out.set(name, `not matched by --bone ${filter.include?.join(',')}`);
|
|
792
|
+
else if (exclude !== null && exclude.some((m) => m(name))) out.set(name, `left out by --hide-bone ${filter.exclude?.join(',')}`);
|
|
793
|
+
}
|
|
794
|
+
return out;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* `--overlay-slot` / `--hide-overlay-slot` against one build's slots: the slots
|
|
799
|
+
* whose geometry is not drawn. A pattern matching no slot is refused naming the
|
|
800
|
+
* slots there are — checked even when `apply` is off, so a mutant that ignores
|
|
801
|
+
* the filter still refuses a typo.
|
|
802
|
+
*/
|
|
803
|
+
function slotsLeftOut(slots: ReadonlyArray<{ name: string }>, filter: SlotFilter | undefined, apply: boolean): Set<string> {
|
|
804
|
+
const out = new Set<string>();
|
|
805
|
+
if (filter === undefined) return out;
|
|
806
|
+
const names = slots.map((s) => s.name);
|
|
807
|
+
const matchers = (flag: string, patterns: readonly string[]): Array<(name: string) => boolean> =>
|
|
808
|
+
patterns.map((p) => {
|
|
809
|
+
const m = globMatcher(p);
|
|
810
|
+
if (!names.some(m)) throw new InspectError(`--${flag} ${JSON.stringify(p)} matches no slot; the slots are [${names.join(', ')}]`);
|
|
811
|
+
return m;
|
|
812
|
+
});
|
|
813
|
+
const include = filter.include === undefined ? null : matchers('overlay-slot', filter.include);
|
|
814
|
+
const exclude = filter.exclude === undefined ? null : matchers('hide-overlay-slot', filter.exclude);
|
|
815
|
+
if (!apply) return out;
|
|
816
|
+
for (const name of names) {
|
|
817
|
+
if ((include !== null && !include.some((m) => m(name))) || (exclude !== null && exclude.some((m) => m(name)))) out.add(name);
|
|
818
|
+
}
|
|
819
|
+
return out;
|
|
820
|
+
}
|
|
821
|
+
|
|
402
822
|
// ---------------------------------------------------------------------------
|
|
403
823
|
// the view
|
|
404
824
|
// ---------------------------------------------------------------------------
|
|
@@ -421,13 +841,15 @@ function posesOf(poser: Poser, request: InspectRequest, needAttachments: boolean
|
|
|
421
841
|
return indices.map((index) => ({ frame: frames[index], index }));
|
|
422
842
|
}
|
|
423
843
|
|
|
844
|
+
type WorldBox = [number, number, number, number];
|
|
845
|
+
|
|
424
846
|
/** The world box of what `--zoom` names, over every pose of the picture. */
|
|
425
847
|
function zoomBox(
|
|
426
848
|
poser: Poser,
|
|
427
849
|
poses: ReadonlyArray<{ frame: Frame }>,
|
|
428
850
|
target: string,
|
|
429
851
|
lengths: Map<string, number>,
|
|
430
|
-
): { box:
|
|
852
|
+
): { box: WorldBox; bone?: string; slot?: string } {
|
|
431
853
|
const isBone = poser.bones.some((b) => b.name === target);
|
|
432
854
|
const isSlot = poser.slots.some((s) => s.name === target);
|
|
433
855
|
if (isBone && isSlot) {
|
|
@@ -451,7 +873,7 @@ function zoomBox(
|
|
|
451
873
|
`slots: [${poser.slots.map((s) => s.name).join(', ')}]`,
|
|
452
874
|
);
|
|
453
875
|
}
|
|
454
|
-
const box:
|
|
876
|
+
const box: WorldBox = [Infinity, Infinity, -Infinity, -Infinity];
|
|
455
877
|
const take = (x: number, y: number): void => {
|
|
456
878
|
box[0] = Math.min(box[0], x);
|
|
457
879
|
box[1] = Math.min(box[1], y);
|
|
@@ -483,40 +905,116 @@ function zoomBox(
|
|
|
483
905
|
return kind === 'bone' ? { box, bone: name } : { box, slot: name };
|
|
484
906
|
}
|
|
485
907
|
|
|
908
|
+
/** One build as the drawing reads it: everything taken off its poser while the poser ran, so drawing poses nothing. */
|
|
909
|
+
interface Gathered {
|
|
910
|
+
tag: 'A' | 'B';
|
|
911
|
+
poser: 'core' | 'spine';
|
|
912
|
+
note: string;
|
|
913
|
+
bones: ReadonlyArray<{ name: string; parent: string | null }>;
|
|
914
|
+
slots: ReadonlyArray<{ name: string; bone: string }>;
|
|
915
|
+
poses: Array<{ frame: Frame; index: number }>;
|
|
916
|
+
/** `render`'s framing of the build — taken only when the picture is framed as a shot. */
|
|
917
|
+
shot: Viewport | null;
|
|
918
|
+
zoom: { box: WorldBox; bone?: string; slot?: string } | null;
|
|
919
|
+
/** The rest table's topology for every attachment a pose shows, by `slot\0attachment` — taken only when a mesh view is drawn. */
|
|
920
|
+
rest: Map<string, AttachmentRest> | null;
|
|
921
|
+
pages: Map<string, Plate>;
|
|
922
|
+
lengths: Map<string, number>;
|
|
923
|
+
axes: readonly DeclaredAxis[];
|
|
924
|
+
weights: ((slot: string, attachment: string) => VertexWeights | string) | null;
|
|
925
|
+
sha: string;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights'];
|
|
929
|
+
const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
|
|
930
|
+
|
|
931
|
+
/** A refusal about the second build says so: the same sentence, under the name of the build it is about. */
|
|
932
|
+
function aboutBuild<T>(tag: 'A' | 'B', run: () => T): T {
|
|
933
|
+
if (tag === 'A') return run();
|
|
934
|
+
try {
|
|
935
|
+
return run();
|
|
936
|
+
} catch (err) {
|
|
937
|
+
if (err instanceof InspectError) throw new InspectError(`the --compare build: ${err.message}`);
|
|
938
|
+
throw err;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Everything a picture needs off one build, posed through the poser `render` would pose it with. */
|
|
943
|
+
function gather(tag: 'A' | 'B', candidate: Candidate, texts: InspectTexts, request: InspectRequest, plant: InspectPlant): Gathered {
|
|
944
|
+
return aboutBuild(tag, () => {
|
|
945
|
+
const { facts, pages } = candidate;
|
|
946
|
+
if (request.skin !== undefined && !facts.skins.includes(request.skin)) {
|
|
947
|
+
throw new InspectError(`no skin ${JSON.stringify(request.skin)} in this skeleton; it declares [${facts.skins.join(', ') || 'none'}]`);
|
|
948
|
+
}
|
|
949
|
+
const axes = request.show.includes('declared') ? declaredAxes(texts.modelText) : [];
|
|
950
|
+
const lengths = boneLengths(texts.skeletonText);
|
|
951
|
+
const meshViews = request.show.some((w) => MESH_VIEWS.includes(w));
|
|
952
|
+
const needAttachments = request.frame.kind === 'zoom' || meshViews || request.show.includes('slots');
|
|
953
|
+
const posed = throughPoser(candidate.choice, (poser, roster) => {
|
|
954
|
+
const poses = posesOf(poser, request, needAttachments);
|
|
955
|
+
const shot =
|
|
956
|
+
request.frame.kind === 'shot' ? framingViewport(poser, request.maxSide, request.skin === undefined ? undefined : { skin: request.skin }, roster) : null;
|
|
957
|
+
if (request.frame.kind === 'shot' && shot === null) {
|
|
958
|
+
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');
|
|
959
|
+
}
|
|
960
|
+
const zoom = request.frame.kind === 'zoom' ? zoomBox(poser, poses, plant.zoomBone ?? request.frame.target, lengths) : null;
|
|
961
|
+
const rest = meshViews
|
|
962
|
+
? new Map(poser.rest(request.skin, poses.map((p) => p.frame.attachments ?? [])).map((r) => [restKey(r.slot, r.attachment), r]))
|
|
963
|
+
: null;
|
|
964
|
+
return { poses, shot, zoom, rest, bones: poser.bones, slots: poser.slots };
|
|
965
|
+
});
|
|
966
|
+
return {
|
|
967
|
+
tag,
|
|
968
|
+
poser: posed.poser,
|
|
969
|
+
note: posed.note,
|
|
970
|
+
...posed.value,
|
|
971
|
+
pages,
|
|
972
|
+
lengths,
|
|
973
|
+
axes,
|
|
974
|
+
weights: request.show.includes('weights') ? weightsReader(texts.skeletonText, request.skin) : null,
|
|
975
|
+
sha: createHash('sha256').update(texts.skeletonText).digest('hex'),
|
|
976
|
+
};
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
486
980
|
/**
|
|
487
981
|
* The picture and its legend, posed through `candidate`'s chosen poser — the
|
|
488
|
-
* one `render` would pose it with (`throughPoser`), named in the result
|
|
982
|
+
* one `render` would pose it with (`throughPoser`), named in the result — and,
|
|
983
|
+
* with `compare`, the same view of a second build beside it on one transform.
|
|
489
984
|
*/
|
|
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
|
-
}
|
|
985
|
+
export function inspectCandidate(candidate: Candidate, texts: InspectTexts, request: InspectRequest, plant: InspectPlant = {}, compare?: InspectCompare): InspectResult {
|
|
495
986
|
if (request.animation === null && request.times.length > 0) {
|
|
496
987
|
throw new InspectError('--at needs --animation <name>: the setup pose has no time. Without --at the picture is the setup pose');
|
|
497
988
|
}
|
|
498
989
|
if (request.animation !== null && request.times.length === 0) {
|
|
499
990
|
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
991
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
992
|
+
if (request.show.includes('weights') !== (request.weightsBone !== undefined)) {
|
|
993
|
+
throw new InspectError('--show weights needs the bone whose weights it paints: weights:<bone>');
|
|
994
|
+
}
|
|
995
|
+
const a = gather('A', candidate, texts, request, plant);
|
|
996
|
+
const builds = [a];
|
|
997
|
+
if (compare !== undefined) builds.push(plant.compareFirstTwice ? { ...a, tag: 'B' } : gather('B', compare.candidate, compare.texts, request, plant));
|
|
998
|
+
const drawn = view(builds, request, plant);
|
|
999
|
+
return { ...drawn, poserNote: a.note, ...(compare === undefined ? {} : { comparePoserNote: builds[1].note }) };
|
|
505
1000
|
}
|
|
506
1001
|
|
|
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'> {
|
|
1002
|
+
function view(builds: readonly Gathered[], request: InspectRequest, plant: InspectPlant): Omit<InspectResult, 'poserNote' | 'comparePoserNote'> {
|
|
516
1003
|
const notes: string[] = [];
|
|
517
|
-
const
|
|
518
|
-
const
|
|
519
|
-
|
|
1004
|
+
const compare = builds.length > 1;
|
|
1005
|
+
const weightsBone = plant.weightsOf ?? request.weightsBone;
|
|
1006
|
+
if (weightsBone !== undefined) {
|
|
1007
|
+
for (const b of builds) {
|
|
1008
|
+
if (!b.bones.some((bone) => bone.name === weightsBone)) {
|
|
1009
|
+
aboutBuild(b.tag, () => {
|
|
1010
|
+
throw new InspectError(`--show weights:${weightsBone}: no bone of that name; the bones are [${b.bones.map((bone) => bone.name).join(', ')}]`);
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
const filtered = builds.map((b) => aboutBuild(b.tag, () => filterOut(b.bones, request.boneFilter, plant.ignoreFilter !== true)));
|
|
1016
|
+
const slotsOut = builds.map((b) => aboutBuild(b.tag, () => slotsLeftOut(b.slots, request.slotFilter, plant.ignoreSlotFilter !== true)));
|
|
1017
|
+
// 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
1018
|
let viewport: Viewport;
|
|
521
1019
|
let framing: InspectLegend['framing'];
|
|
522
1020
|
if (request.frame.kind === 'viewport') {
|
|
@@ -524,9 +1022,11 @@ function view(
|
|
|
524
1022
|
viewport = viewportFor(f.x, f.y, f.x + f.width, f.y + f.height, request.maxSide);
|
|
525
1023
|
framing = { kind: 'viewport' };
|
|
526
1024
|
} else if (request.frame.kind === 'zoom') {
|
|
527
|
-
const
|
|
528
|
-
const
|
|
529
|
-
|
|
1025
|
+
const found = builds.map((b) => b.zoom).filter((z): z is NonNullable<Gathered['zoom']> => z !== null);
|
|
1026
|
+
const [x0, y0, x1, y1] = found.reduce<WorldBox>(
|
|
1027
|
+
(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])],
|
|
1028
|
+
[Infinity, Infinity, -Infinity, -Infinity],
|
|
1029
|
+
);
|
|
530
1030
|
const longer = Math.max(x1 - x0, y1 - y0);
|
|
531
1031
|
const pad = request.frame.pad ?? longer * DEFAULT_ZOOM_PAD_SHARE;
|
|
532
1032
|
if (longer + 2 * pad === 0) {
|
|
@@ -536,195 +1036,91 @@ function view(
|
|
|
536
1036
|
);
|
|
537
1037
|
}
|
|
538
1038
|
viewport = viewportFor(x0 - pad, y0 - pad, x1 + pad, y1 + pad, request.maxSide);
|
|
539
|
-
|
|
1039
|
+
const first = found[0];
|
|
1040
|
+
framing = { kind: 'zoom', ...(first?.bone === undefined ? {} : { bone: first.bone }), ...(first?.slot === undefined ? {} : { slot: first.slot }), pad, padFrom: request.frame.pad === null ? 'default' : 'stated' };
|
|
540
1041
|
} else {
|
|
541
|
-
const
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
|
|
1042
|
+
const shots = builds.map((b) => b.shot).filter((v): v is Viewport => v !== null);
|
|
1043
|
+
if (!compare) {
|
|
1044
|
+
viewport = shots[0];
|
|
1045
|
+
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" };
|
|
1046
|
+
} else {
|
|
1047
|
+
viewport = viewportFor(
|
|
1048
|
+
Math.min(...shots.map((v) => v.minX)),
|
|
1049
|
+
Math.min(...shots.map((v) => v.minY)),
|
|
1050
|
+
Math.max(...shots.map((v) => v.maxX)),
|
|
1051
|
+
Math.max(...shots.map((v) => v.maxY)),
|
|
1052
|
+
request.maxSide,
|
|
1053
|
+
);
|
|
1054
|
+
framing = {
|
|
1055
|
+
kind: 'shot',
|
|
1056
|
+
note:
|
|
1057
|
+
"the union of render's own framing of each build (every animation's posed box at 60 fps, padded), so both halves share one " +
|
|
1058
|
+
"transform — a half is render's frame of its build only where the two boxes agree",
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
545
1061
|
}
|
|
546
1062
|
const { width, height, scale } = viewport;
|
|
547
|
-
const
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
const
|
|
1063
|
+
const count = builds[0].poses.length;
|
|
1064
|
+
const sheet = count > 1;
|
|
1065
|
+
// One build: tiles left to right, eight to a row. Two: each time's pair side by side, four pairs to a row.
|
|
1066
|
+
const pairs = compare ? Math.min(SHEET_COLUMNS / 2, count) : 0;
|
|
1067
|
+
const columns = compare ? pairs * 2 : sheet ? Math.min(SHEET_COLUMNS, count) : 1;
|
|
1068
|
+
const rows = compare ? Math.ceil(count / pairs) : Math.ceil(count / columns);
|
|
1069
|
+
const gap = sheet || compare ? SHEET_GAP : 0;
|
|
551
1070
|
const plate = new Plate(columns * (width + gap) + gap, rows * (height + gap) + gap);
|
|
552
1071
|
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
1072
|
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
|
-
}
|
|
573
|
-
}
|
|
574
|
-
// Drawn on the tile in tile pixels; `tx`/`ty` take a picture pixel back to the tile's.
|
|
575
|
-
const tx = (px: number): number => px - x0;
|
|
576
|
-
const ty = (py: number): number => py - y0;
|
|
577
|
-
const legendTile: LegendTile = {
|
|
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
|
-
}
|
|
1073
|
+
const weighted = builds.map(() => false);
|
|
1074
|
+
let anyMesh = false;
|
|
1075
|
+
for (let k = 0; k < count; k++) {
|
|
1076
|
+
for (const [bi, build] of builds.entries()) {
|
|
1077
|
+
const column = compare ? (k % pairs) * 2 + bi : k % columns;
|
|
1078
|
+
const row = compare ? Math.floor(k / pairs) : Math.floor(k / columns);
|
|
1079
|
+
const x0 = gap + column * (width + gap);
|
|
1080
|
+
const y0 = gap + row * (height + gap);
|
|
1081
|
+
const drawnTile = drawTile(build, build.poses[k], filtered[bi], slotsOut[bi], { x0, y0, viewport, sheet, compare, weightsBone, first: k === 0 && bi === 0 }, request, plant, notes);
|
|
1082
|
+
if (drawnTile.weighted) weighted[bi] = true;
|
|
1083
|
+
if (drawnTile.anyMesh) anyMesh = true;
|
|
1084
|
+
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);
|
|
1085
|
+
tiles.push(drawnTile.legend);
|
|
675
1086
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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'));
|
|
1087
|
+
}
|
|
1088
|
+
// With a slot filter the sentence says the filter is part of why: the same build unfiltered may paint.
|
|
1089
|
+
if (weightsBone !== undefined) {
|
|
1090
|
+
for (const [bi, b] of builds.entries()) {
|
|
1091
|
+
if (!weighted[bi]) notes.push(`weights: bone "${weightsBone}" weights no vertex of any region or mesh${request.slotFilter === undefined ? '' : ' of the slots the filter keeps'} ${compare ? `build ${b.tag} ` : ''}shows here — nothing painted`);
|
|
715
1092
|
}
|
|
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
1093
|
}
|
|
1094
|
+
if ((request.show.includes('mesh') || request.show.includes('hull')) && !anyMesh) notes.push(`mesh/hull: no slot${request.slotFilter === undefined ? '' : ' the filter keeps'} shows a mesh here — nothing drawn for it`);
|
|
719
1095
|
const legend: InspectLegend = {
|
|
720
1096
|
spec: INSPECT_SPEC,
|
|
721
|
-
poser:
|
|
722
|
-
show:
|
|
1097
|
+
poser: builds[0].poser,
|
|
1098
|
+
show: showSpelled(request, weightsBone),
|
|
723
1099
|
dim: request.dim,
|
|
724
1100
|
...(request.skin === undefined ? {} : { skin: request.skin }),
|
|
725
1101
|
animation: request.animation,
|
|
726
1102
|
fps: request.fps,
|
|
727
1103
|
framing,
|
|
1104
|
+
...(request.boneFilter === undefined
|
|
1105
|
+
? {}
|
|
1106
|
+
: { boneFilter: { include: request.boneFilter.include === undefined ? null : [...request.boneFilter.include], exclude: request.boneFilter.exclude === undefined ? null : [...request.boneFilter.exclude] } }),
|
|
1107
|
+
...(request.slotFilter === undefined
|
|
1108
|
+
? {}
|
|
1109
|
+
: { slotFilter: { include: request.slotFilter.include === undefined ? null : [...request.slotFilter.include], exclude: request.slotFilter.exclude === undefined ? null : [...request.slotFilter.exclude] } }),
|
|
1110
|
+
...(weightsBone === undefined
|
|
1111
|
+
? {}
|
|
1112
|
+
: {
|
|
1113
|
+
weights: {
|
|
1114
|
+
bone: weightsBone,
|
|
1115
|
+
ramp: WEIGHT_RAMP.map((s) => ({ weight: s.weight, rgba: [...s.rgba] })),
|
|
1116
|
+
floor: WEIGHT_FLOOR,
|
|
1117
|
+
reading:
|
|
1118
|
+
"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 " +
|
|
1119
|
+
"the triangle's corners' weights for the bone, each channel rounded, linear between the stops; below floor it is not painted. " +
|
|
1120
|
+
"Each vertex's weight is in its tile's meshes[].weights, read off skeleton.json",
|
|
1121
|
+
},
|
|
1122
|
+
}),
|
|
1123
|
+
...(compare ? { builds: builds.map((b) => ({ build: b.tag, poser: b.poser, skeletonSha256: b.sha })) } : {}),
|
|
728
1124
|
coordinates: {
|
|
729
1125
|
world: 'spine world: y up, world units',
|
|
730
1126
|
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 +1135,335 @@ function view(
|
|
|
739
1135
|
return { plate, legend, notes };
|
|
740
1136
|
}
|
|
741
1137
|
|
|
1138
|
+
interface TilePlace {
|
|
1139
|
+
x0: number;
|
|
1140
|
+
y0: number;
|
|
1141
|
+
viewport: Viewport;
|
|
1142
|
+
sheet: boolean;
|
|
1143
|
+
compare: boolean;
|
|
1144
|
+
weightsBone: string | undefined;
|
|
1145
|
+
/** The picture's first tile — the one whose notes are printed, so a sheet says a thing once. */
|
|
1146
|
+
first: boolean;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** One tile: one build at one pose, drawn on its own plate in tile pixels, and its legend in picture pixels. */
|
|
1150
|
+
function drawTile(
|
|
1151
|
+
build: Gathered,
|
|
1152
|
+
pose: { frame: Frame; index: number },
|
|
1153
|
+
filtered: Map<string, string>,
|
|
1154
|
+
slotsOut: ReadonlySet<string>,
|
|
1155
|
+
place: TilePlace,
|
|
1156
|
+
request: InspectRequest,
|
|
1157
|
+
plant: InspectPlant,
|
|
1158
|
+
notes: string[],
|
|
1159
|
+
): { tile: Plate; legend: LegendTile; weighted: boolean; anyMesh: boolean } {
|
|
1160
|
+
const { frame, index } = pose;
|
|
1161
|
+
const { x0, y0, viewport } = place;
|
|
1162
|
+
const { width, height, scale } = viewport;
|
|
1163
|
+
const first = place.first;
|
|
1164
|
+
const [shiftX, shiftY] = plant.overlayShift ?? [0, 0];
|
|
1165
|
+
const [lieX, lieY] = plant.legendShift ?? [0, 0];
|
|
1166
|
+
// ⭐ The one projection both the picture and the legend read: continuous pixels in the whole picture.
|
|
1167
|
+
const toPixel = (wx: number, wy: number): [number, number] => [
|
|
1168
|
+
x0 + (wx - viewport.minX) * scale,
|
|
1169
|
+
y0 + (plant.unflipped ? wy - viewport.minY : viewport.maxY - wy) * scale,
|
|
1170
|
+
];
|
|
1171
|
+
const point = (wx: number, wy: number): LegendPoint => {
|
|
1172
|
+
const [px, py] = toPixel(wx, wy);
|
|
1173
|
+
return { world: [wx, wy], pixel: [px + lieX, py + lieY] };
|
|
1174
|
+
};
|
|
1175
|
+
const tile = request.show.includes('art') ? renderFrame(frame, build.pages, viewport, BACKGROUND) : filled(width, height, BACKGROUND);
|
|
1176
|
+
if (request.dim) {
|
|
1177
|
+
for (let p = 0; p < tile.data.length; p += 4) {
|
|
1178
|
+
for (let c = 0; c < 3; c++) tile.data[p + c] = Math.round(255 - (255 - tile.data[p + c]) * DIM_KEEP);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
// Drawn on the tile in tile pixels; `tx`/`ty` take a picture pixel back to the tile's.
|
|
1182
|
+
const tx = (px: number): number => px - x0;
|
|
1183
|
+
const ty = (py: number): number => py - y0;
|
|
1184
|
+
const legendTile: LegendTile = {
|
|
1185
|
+
...(place.compare ? { build: build.tag } : {}),
|
|
1186
|
+
animation: request.animation,
|
|
1187
|
+
frame: index,
|
|
1188
|
+
time: request.animation === null ? 0 : index / request.fps,
|
|
1189
|
+
box: { x: x0, y: y0, width, height },
|
|
1190
|
+
transform: { minX: viewport.minX, maxY: viewport.maxY, scale, x0, y0 },
|
|
1191
|
+
bones: [],
|
|
1192
|
+
};
|
|
1193
|
+
if (request.show.includes('grid')) {
|
|
1194
|
+
const step = plant.gridInPixels ? gridStep(scale) / scale : gridStep(scale);
|
|
1195
|
+
const xs = multiples(viewport.minX, viewport.maxX, step);
|
|
1196
|
+
const ys = multiples(viewport.minY, viewport.maxY, step);
|
|
1197
|
+
for (const gx of xs) {
|
|
1198
|
+
const col = Math.floor(tx(toPixel(gx, 0)[0]));
|
|
1199
|
+
for (let y = 0; y < height; y++) put(tile, col, y, colour(gx === 0 ? 'gridZero' : 'grid'));
|
|
1200
|
+
}
|
|
1201
|
+
for (const gy of ys) {
|
|
1202
|
+
const row = Math.floor(ty(toPixel(0, gy)[1]));
|
|
1203
|
+
for (let x = 0; x < width; x++) put(tile, x, row, colour(gy === 0 ? 'gridZero' : 'grid'));
|
|
1204
|
+
}
|
|
1205
|
+
for (const gx of xs) text(tile, gridLabel(gx, step), Math.floor(tx(toPixel(gx, 0)[0])) + 2, 2, colour('gridZero'));
|
|
1206
|
+
for (const gy of ys) text(tile, gridLabel(gy, step), 2, Math.floor(ty(toPixel(0, gy)[1])) + 2, colour('gridZero'));
|
|
1207
|
+
legendTile.grid = { step, x: xs, y: ys };
|
|
1208
|
+
}
|
|
1209
|
+
// ── what a bone moves: weights under the edges, the edges under the hull ──
|
|
1210
|
+
let weighted = false;
|
|
1211
|
+
let anyMesh = false;
|
|
1212
|
+
if (build.rest !== null) {
|
|
1213
|
+
legendTile.meshes = [];
|
|
1214
|
+
const [mx, my] = plant.meshShift ?? [0, 0];
|
|
1215
|
+
for (const shown of frame.attachments ?? []) {
|
|
1216
|
+
if (slotsOut.has(shown.slot)) continue;
|
|
1217
|
+
const rest = build.rest.get(restKey(shown.slot, shown.attachment));
|
|
1218
|
+
const vertexCount = shown.vertices.length / 2;
|
|
1219
|
+
if (rest === undefined || rest.vertices.length !== shown.vertices.length) {
|
|
1220
|
+
throw new Error(
|
|
1221
|
+
`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`,
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
const pixel: number[] = [];
|
|
1225
|
+
for (let v = 0; v < shown.vertices.length; v += 2) pixel.push(...toPixel(shown.vertices[v], shown.vertices[v + 1]));
|
|
1226
|
+
const at = (v: number): [number, number] => [tx(pixel[2 * v]), ty(pixel[2 * v + 1])];
|
|
1227
|
+
const edges = edgesOf(rest.triangles);
|
|
1228
|
+
const entry: LegendMesh = {
|
|
1229
|
+
slot: shown.slot,
|
|
1230
|
+
attachment: shown.attachment,
|
|
1231
|
+
kind: rest.kind,
|
|
1232
|
+
vertexCount,
|
|
1233
|
+
triangleCount: rest.triangles.length / 3,
|
|
1234
|
+
hull: rest.kind === 'mesh' ? (rest.hull ?? null) : null,
|
|
1235
|
+
edges,
|
|
1236
|
+
world: [...shown.vertices],
|
|
1237
|
+
pixel,
|
|
1238
|
+
};
|
|
1239
|
+
if (place.weightsBone !== undefined && build.weights !== null) {
|
|
1240
|
+
const read = build.weights(shown.slot, shown.attachment);
|
|
1241
|
+
if (typeof read === 'string') throw new InspectError(`--show weights:${place.weightsBone}: ${read}`);
|
|
1242
|
+
if (read.length !== vertexCount) {
|
|
1243
|
+
throw new InspectError(`--show weights:${place.weightsBone}: slot "${shown.slot}" attachment "${shown.attachment}" — skeleton.json weights ${read.length} vertices, the pose has ${vertexCount}`);
|
|
1244
|
+
}
|
|
1245
|
+
const bone = place.weightsBone;
|
|
1246
|
+
const weights = read.map((run) => run.reduce((sum, [name, w]) => (name === bone ? sum + w : sum), 0));
|
|
1247
|
+
entry.weights = weights;
|
|
1248
|
+
if (weights.some((w) => w >= WEIGHT_FLOOR)) weighted = true;
|
|
1249
|
+
for (let t = 0; t + 2 < rest.triangles.length; t += 3) {
|
|
1250
|
+
const corners = [rest.triangles[t], rest.triangles[t + 1], rest.triangles[t + 2]];
|
|
1251
|
+
heatTriangle(tile, corners.map(at), corners.map((v) => weights[v]));
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
legendTile.meshes.push(entry);
|
|
1255
|
+
}
|
|
1256
|
+
for (const entry of legendTile.meshes) {
|
|
1257
|
+
if (entry.kind !== 'mesh') continue;
|
|
1258
|
+
anyMesh = true;
|
|
1259
|
+
const at = (v: number): [number, number] => [tx(entry.pixel[2 * v]) + mx, ty(entry.pixel[2 * v + 1]) + my];
|
|
1260
|
+
if (request.show.includes('mesh')) {
|
|
1261
|
+
for (const [p, q] of entry.edges) line(tile, ...at(p), ...at(q), colour('mesh'));
|
|
1262
|
+
}
|
|
1263
|
+
if (request.show.includes('hull')) {
|
|
1264
|
+
const hull = entry.hull ?? 0;
|
|
1265
|
+
const ring: Array<[number, number]> = plant.hullAllEdges ? entry.edges : Array.from({ length: hull }, (_, i): [number, number] => [i, (i + 1) % hull]);
|
|
1266
|
+
for (const [p, q] of ring) line(tile, ...at(p), ...at(q), colour('hull'));
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
const snapshots = new Map<string, BoneSnapshot>((frame.bones ?? []).map((b) => [b.name, b]));
|
|
1271
|
+
const isPosed = (b: BoneSnapshot | undefined): b is BoneSnapshot => b !== undefined && !(b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0);
|
|
1272
|
+
const held = new Set<string>();
|
|
1273
|
+
if (request.show.includes('declared')) {
|
|
1274
|
+
legendTile.declared = [];
|
|
1275
|
+
for (const axis of build.axes) {
|
|
1276
|
+
const a = snapshots.get(axis.through[0]);
|
|
1277
|
+
const b = snapshots.get(axis.through[1]);
|
|
1278
|
+
const entry: LegendDeclared = { name: axis.name, through: axis.through, tolerance: axis.tolerance, degrees: null, why: null, from: null, to: null, bones: [] };
|
|
1279
|
+
if (!isPosed(a) || !isPosed(b)) {
|
|
1280
|
+
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`;
|
|
1281
|
+
} else if (a.worldX === b.worldX && a.worldY === b.worldY) {
|
|
1282
|
+
entry.why = `"${axis.through[0]}" and "${axis.through[1]}" share the world origin (${a.worldX}, ${a.worldY}), so they draw no line`;
|
|
1283
|
+
} else {
|
|
1284
|
+
const degrees = (Math.atan2(b.worldY - a.worldY, b.worldX - a.worldX) * 180) / Math.PI;
|
|
1285
|
+
entry.degrees = degrees;
|
|
1286
|
+
entry.from = point(a.worldX, a.worldY);
|
|
1287
|
+
entry.to = point(b.worldX, b.worldY);
|
|
1288
|
+
// Across the whole tile: extend the line past both ends by the tile's diagonal, in world units.
|
|
1289
|
+
const reach = Math.hypot(viewport.maxX - viewport.minX, viewport.maxY - viewport.minY);
|
|
1290
|
+
const ux = (b.worldX - a.worldX) / Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
|
|
1291
|
+
const uy = (b.worldY - a.worldY) / Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
|
|
1292
|
+
const [p0x, p0y] = toPixel(a.worldX - ux * reach, a.worldY - uy * reach);
|
|
1293
|
+
const [p1x, p1y] = toPixel(a.worldX + ux * reach, a.worldY + uy * reach);
|
|
1294
|
+
line(tile, tx(p0x) + shiftX, ty(p0y) + shiftY, tx(p1x) + shiftX, ty(p1y) + shiftY, colour('declared'));
|
|
1295
|
+
for (const name of axis.bones) {
|
|
1296
|
+
const s = snapshots.get(name);
|
|
1297
|
+
if (!isPosed(s) || (s.a === 0 && s.c === 0)) {
|
|
1298
|
+
entry.bones.push({ name, degrees: null, off: null });
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
const found = worldAxisDegrees(s);
|
|
1302
|
+
entry.bones.push({ name, degrees: found, off: lineGap(found, degrees) });
|
|
1303
|
+
held.add(name);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (entry.why !== null && first) notes.push(`declared: axis "${axis.name}" — ${entry.why}`);
|
|
1307
|
+
legendTile.declared.push(entry);
|
|
1308
|
+
}
|
|
1309
|
+
if (build.axes.length === 0 && first) notes.push('declared: the rig declares no invariants.axes — nothing drawn for it');
|
|
1310
|
+
}
|
|
1311
|
+
// Every bone, in declaration order — drawn or not, so the legend names each.
|
|
1312
|
+
const drawn: Array<{ bone: LegendBone; o: [number, number]; t: [number, number] }> = [];
|
|
1313
|
+
for (const declared of build.bones) {
|
|
1314
|
+
const s = snapshots.get(declared.name);
|
|
1315
|
+
const length = build.lengths.get(declared.name) ?? 0;
|
|
1316
|
+
const posedBone = isPosed(s);
|
|
1317
|
+
const left = filtered.get(declared.name);
|
|
1318
|
+
const ox = s?.worldX ?? 0;
|
|
1319
|
+
const oy = s?.worldY ?? 0;
|
|
1320
|
+
const bone: LegendBone = {
|
|
1321
|
+
name: declared.name,
|
|
1322
|
+
parent: declared.parent,
|
|
1323
|
+
length,
|
|
1324
|
+
zeroLength: length === 0,
|
|
1325
|
+
undrawn: !posedBone ? 'its world matrix is 0 at this time — unposed by the skin, or a scale of 0' : (left ?? null),
|
|
1326
|
+
origin: point(ox, oy),
|
|
1327
|
+
tip: point(ox + length * (s?.a ?? 0), oy + length * (s?.c ?? 0)),
|
|
1328
|
+
};
|
|
1329
|
+
const shows = posedBone && left === undefined;
|
|
1330
|
+
if (shows && request.show.includes('axes')) {
|
|
1331
|
+
const unit = AXIS_PIXELS / scale;
|
|
1332
|
+
bone.axes = { x: point(ox + s.a * unit, oy + s.c * unit), y: point(ox + s.b * unit, oy + s.d * unit) };
|
|
1333
|
+
}
|
|
1334
|
+
legendTile.bones.push(bone);
|
|
1335
|
+
if (shows) drawn.push({ bone, o: toPixel(ox, oy), t: toPixel(bone.tip.world[0], bone.tip.world[1]) });
|
|
1336
|
+
}
|
|
1337
|
+
// Lines, then axes, then slot numbers and names, then the marks — so a mark is never covered.
|
|
1338
|
+
const at = (p: [number, number]): [number, number] => [tx(p[0]) + shiftX, ty(p[1]) + shiftY];
|
|
1339
|
+
if (request.show.includes('bones')) {
|
|
1340
|
+
for (const { bone, o, t } of drawn) {
|
|
1341
|
+
if (bone.zeroLength) continue;
|
|
1342
|
+
const [ax, ay] = at(o);
|
|
1343
|
+
const [bx, by] = at(t);
|
|
1344
|
+
line(tile, ax, ay, bx, by, colour(held.has(bone.name) ? 'held' : 'bone'));
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
if (request.show.includes('axes')) {
|
|
1348
|
+
for (const { bone, o } of drawn) {
|
|
1349
|
+
if (bone.axes === undefined) continue;
|
|
1350
|
+
const [ax, ay] = at(o);
|
|
1351
|
+
for (const [end, name] of [[bone.axes.y, 'axisY'], [bone.axes.x, 'axisX']] as const) {
|
|
1352
|
+
const [ex, ey] = at(toPixel(end.world[0], end.world[1]));
|
|
1353
|
+
line(tile, ax, ay, ex, ey, colour(name));
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// ── labels: slot numbers, then names, placed in one pass so neither overprints the other ──
|
|
1358
|
+
const taken: PixelBox[] = [];
|
|
1359
|
+
const avoid = plant.noAvoid !== true;
|
|
1360
|
+
const toPicture = (b: PixelBox): PixelBox => ({ x: b.x + x0, y: b.y + y0, width: b.width, height: b.height });
|
|
1361
|
+
const writes: Array<() => void> = [];
|
|
1362
|
+
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'));
|
|
1363
|
+
if (request.show.includes('slots')) {
|
|
1364
|
+
legendTile.slots = [];
|
|
1365
|
+
const shown = frame.attachments ?? [];
|
|
1366
|
+
for (const [i, att] of shown.entries()) {
|
|
1367
|
+
// A left-out slot keeps its place: a number is the slot's place in the whole draw order, filtered or not.
|
|
1368
|
+
if (slotsOut.has(att.slot)) continue;
|
|
1369
|
+
const order = plant.slotsReversed ? shown.length - 1 - i : i;
|
|
1370
|
+
let minX = Infinity;
|
|
1371
|
+
let minY = Infinity;
|
|
1372
|
+
let maxX = -Infinity;
|
|
1373
|
+
let maxY = -Infinity;
|
|
1374
|
+
for (let v = 0; v + 1 < att.vertices.length; v += 2) {
|
|
1375
|
+
minX = Math.min(minX, att.vertices[v]);
|
|
1376
|
+
maxX = Math.max(maxX, att.vertices[v]);
|
|
1377
|
+
minY = Math.min(minY, att.vertices[v + 1]);
|
|
1378
|
+
maxY = Math.max(maxY, att.vertices[v + 1]);
|
|
1379
|
+
}
|
|
1380
|
+
const anchor = point((minX + maxX) / 2, (minY + maxY) / 2);
|
|
1381
|
+
const [apx, apy] = toPixel((minX + maxX) / 2, (minY + maxY) / 2);
|
|
1382
|
+
const [ax, ay] = [Math.floor(tx(apx)), Math.floor(ty(apy))];
|
|
1383
|
+
const label = String(order);
|
|
1384
|
+
const w = textWidth(label, 1) + 2;
|
|
1385
|
+
const h = GLYPH_H + 2;
|
|
1386
|
+
const placed = placeLabel({ x: ax - Math.floor(w / 2), y: ay - Math.floor(h / 2), width: w, height: h }, taken, avoid);
|
|
1387
|
+
writes.push(() => {
|
|
1388
|
+
if (placed.moved) leader(ax, ay, placed.box);
|
|
1389
|
+
});
|
|
1390
|
+
writes.push(() => {
|
|
1391
|
+
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'));
|
|
1392
|
+
text(tile, label, placed.box.x + 1, placed.box.y + 1, colour('slot'));
|
|
1393
|
+
});
|
|
1394
|
+
const slotBone = build.slots.find((s) => s.name === att.slot)?.bone;
|
|
1395
|
+
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`);
|
|
1396
|
+
legendTile.slots.push({
|
|
1397
|
+
order,
|
|
1398
|
+
slot: att.slot,
|
|
1399
|
+
attachment: att.attachment,
|
|
1400
|
+
bone: slotBone,
|
|
1401
|
+
anchor,
|
|
1402
|
+
box: toPicture(placed.box),
|
|
1403
|
+
moved: placed.moved,
|
|
1404
|
+
overlaps: placed.overlaps,
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
legendTile.slots.sort((p, q) => p.order - q.order);
|
|
1408
|
+
}
|
|
1409
|
+
if (request.show.includes('names')) {
|
|
1410
|
+
// ⭐ One label per pixel. What overprints is two labels starting on one pixel — a `_ctl` and its child, a chain's
|
|
1411
|
+
// joint — and the pixel is the unit of that, not the world origin: two origins 0.3 px apart overprint as surely as
|
|
1412
|
+
// two that coincide, and two that coincide always share a pixel.
|
|
1413
|
+
legendTile.labels = [];
|
|
1414
|
+
const groups = new Map<string, { anchor: [number, number]; bones: string[] }>();
|
|
1415
|
+
for (const { bone, o } of drawn) {
|
|
1416
|
+
const [ax, ay] = at(o);
|
|
1417
|
+
const anchor: [number, number] = [Math.floor(ax), Math.floor(ay)];
|
|
1418
|
+
const key = plant.noFold ? `${anchor[0]},${anchor[1]},${bone.name}` : `${anchor[0]},${anchor[1]}`;
|
|
1419
|
+
const group = groups.get(key) ?? { anchor, bones: [] };
|
|
1420
|
+
group.bones.push(bone.name);
|
|
1421
|
+
groups.set(key, group);
|
|
1422
|
+
}
|
|
1423
|
+
for (const { anchor, bones } of groups.values()) {
|
|
1424
|
+
const label = bones.length > 1 ? `${bones[0]} +${bones.length - 1}` : bones[0];
|
|
1425
|
+
const placed = placeLabel({ x: anchor[0] + 4, y: anchor[1] + 3, width: textWidth(label, 1), height: GLYPH_H }, taken, avoid);
|
|
1426
|
+
writes.push(() => {
|
|
1427
|
+
if (placed.moved) leader(anchor[0], anchor[1], placed.box);
|
|
1428
|
+
});
|
|
1429
|
+
writes.push(() => text(tile, label, placed.box.x, placed.box.y, colour('name')));
|
|
1430
|
+
legendTile.labels.push({
|
|
1431
|
+
text: label,
|
|
1432
|
+
bones,
|
|
1433
|
+
anchor: [anchor[0] + x0, anchor[1] + y0],
|
|
1434
|
+
box: toPicture(placed.box),
|
|
1435
|
+
moved: placed.moved,
|
|
1436
|
+
overlaps: placed.overlaps,
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
// Every leader first, then every label, so no leader crosses a label's text.
|
|
1441
|
+
for (const w of writes.filter((_, i) => i % 2 === 0)) w();
|
|
1442
|
+
for (const w of writes.filter((_, i) => i % 2 === 1)) w();
|
|
1443
|
+
if (request.show.includes('bones')) {
|
|
1444
|
+
for (const { bone, t } of drawn) {
|
|
1445
|
+
if (bone.zeroLength) continue;
|
|
1446
|
+
const [bx, by] = at(t);
|
|
1447
|
+
square(tile, Math.floor(bx), Math.floor(by), 1, true, colour('tip'));
|
|
1448
|
+
}
|
|
1449
|
+
for (const { bone, o } of drawn) {
|
|
1450
|
+
const [ax, ay] = at(o);
|
|
1451
|
+
if (bone.zeroLength) square(tile, Math.floor(ax), Math.floor(ay), 2, true, colour('zero'));
|
|
1452
|
+
square(tile, Math.floor(ax), Math.floor(ay), 1, false, colour('origin'));
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
if (place.sheet || place.compare) {
|
|
1456
|
+
const when = request.animation === null ? 'setup' : `t=${legendTile.time.toFixed(3)} f${index}`;
|
|
1457
|
+
const label = place.compare ? `${build.tag} ${build.poser}${place.sheet ? ` ${when}` : ''}` : when;
|
|
1458
|
+
const ly = height - GLYPH_H - 2;
|
|
1459
|
+
const box: PixelBox = { x: 1, y: ly - 1, width: textWidth(label, 1) + 2, height: GLYPH_H + 2 };
|
|
1460
|
+
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);
|
|
1461
|
+
text(tile, label, 2, ly, colour('label'));
|
|
1462
|
+
legendTile.label = { text: label, box: toPicture(box) };
|
|
1463
|
+
}
|
|
1464
|
+
return { tile, legend: legendTile, weighted, anyMesh };
|
|
1465
|
+
}
|
|
1466
|
+
|
|
742
1467
|
function filled(width: number, height: number, rgba: RGBA): Plate {
|
|
743
1468
|
const plate = new Plate(width, height);
|
|
744
1469
|
for (let i = 0; i < plate.data.length; i += 4) plate.data.set(rgba, i);
|