@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/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: { rgba: [25, 25, 25, 255], meaning: "a bone's name, starting 4 px right of and 3 px below its origin's pixel; bones sharing that pixel list their names one per line under it, in declaration order" },
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
- label: { rgba: [...SHEET_LABEL], meaning: "a sheet tile's time and frame, at its bottom-left corner" },
101
- rule: { rgba: [...SHEET_RULE], meaning: 'the gap between two tiles of a sheet' },
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: ShowWord[];
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
- /** `--show`'s words, refused by name — a word this command does not draw lists every one it does. */
243
- export function readShowWords(raw: string | undefined): ShowWord[] {
244
- if (raw === undefined) return [...DEFAULT_SHOW];
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 [${SHOW_WORDS.join(', ')}]`);
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
- for (const word of words) {
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(word)} is not a view inspect draws; it draws [${SHOW_WORDS.join(', ')}]`);
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: [number, number, number, number]; bone?: string; slot?: string } {
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: [number, number, number, number] = [Infinity, Infinity, -Infinity, -Infinity];
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
- const axes = request.show.includes('declared') ? declaredAxes(texts.modelText) : [];
502
- const lengths = boneLengths(texts.skeletonText);
503
- const posed = throughPoser(candidate.choice, (poser, roster) => view(poser, roster, pages, lengths, axes, request, plant));
504
- return { ...posed.value, legend: { ...posed.value.legend, poser: posed.poser }, poserNote: posed.note };
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 zooming = request.frame.kind === 'zoom';
518
- const poses = posesOf(poser, request, zooming);
519
- // The world box and its pixels: one for every tile, so a sheet's tiles overlay.
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 target = plant.zoomBone ?? request.frame.target;
528
- const found = zoomBox(poser, poses, target, lengths);
529
- const [x0, y0, x1, y1] = found.box;
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
- framing = { kind: 'zoom', ...(found.bone === undefined ? {} : { bone: found.bone }), ...(found.slot === undefined ? {} : { slot: found.slot }), pad, padFrom: request.frame.pad === null ? 'default' : 'stated' };
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 shot = framingViewport(poser, request.maxSide, request.skin === undefined ? undefined : { skin: request.skin }, roster);
542
- if (shot === null) 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');
543
- viewport = shot;
544
- 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" };
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 sheet = poses.length > 1;
548
- const columns = sheet ? Math.min(SHEET_COLUMNS, poses.length) : 1;
549
- const rows = Math.ceil(poses.length / columns);
550
- const gap = sheet ? SHEET_GAP : 0;
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
- for (const [i, { frame, index }] of poses.entries()) {
557
- const x0 = gap + (i % columns) * (width + gap);
558
- const y0 = gap + Math.floor(i / columns) * (height + gap);
559
- // ⭐ The one projection both the picture and the legend read: continuous pixels in the whole picture.
560
- const toPixel = (wx: number, wy: number): [number, number] => [
561
- x0 + (wx - viewport.minX) * scale,
562
- y0 + (plant.unflipped ? wy - viewport.minY : viewport.maxY - wy) * scale,
563
- ];
564
- const point = (wx: number, wy: number): LegendPoint => {
565
- const [px, py] = toPixel(wx, wy);
566
- return { world: [wx, wy], pixel: [px + lieX, py + lieY] };
567
- };
568
- const tile = request.show.includes('art') ? renderFrame(frame, pages, viewport, BACKGROUND) : filled(width, height, BACKGROUND);
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
- // 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
- }
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: 'core',
722
- show: [...request.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);