@rig-c/core 2.40.1 → 2.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/docs/AUTHORING.md +169 -2
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +5 -0
- package/src/cli/core_commands.ts +116 -2
- package/src/cli/shared.ts +77 -2
- package/src/inspect.ts +1424 -0
package/src/inspect.ts
ADDED
|
@@ -0,0 +1,1424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rigc inspect` — a picture of a compiled build made for an agent to read,
|
|
3
|
+
* and the legend that turns a pixel of it back into a number (issue #1357).
|
|
4
|
+
*
|
|
5
|
+
* ⭐ **The picture is a question about geometry, and the legend is its answer
|
|
6
|
+
* key.** `render` draws what the rig looks like; this draws what the rig IS at
|
|
7
|
+
* one moment — where each bone starts and ends, which way its axes point, where
|
|
8
|
+
* the world's units fall, and the line a rig declares some bones to lie along —
|
|
9
|
+
* over the same art on the same pixel grid. Without the legend the picture is
|
|
10
|
+
* for a person: an agent that reads "the elbow is at pixel 412, 180" needs the
|
|
11
|
+
* transform to say where that is in the rig spec, and every drawn bone's pixel
|
|
12
|
+
* origin and tip to say which mark is which. So the legend states the transform
|
|
13
|
+
* exactly, in both directions, and every mark the picture carries.
|
|
14
|
+
*
|
|
15
|
+
* 🔒 **It poses through what `render` poses the candidate with, and says
|
|
16
|
+
* which.** The caller hands over the candidate `loadCandidate` read and runs
|
|
17
|
+
* this through `throughPoser`, the seam `render` runs — so a rigc build is posed
|
|
18
|
+
* by rigc's own core and an export by spine-core, a fallback is named, and the
|
|
19
|
+
* art of a picture with nothing drawn over it is `render`'s frame at the same
|
|
20
|
+
* time and the same `--max`, pixel for pixel (`IN09`).
|
|
21
|
+
*
|
|
22
|
+
* ⭐ **A time is a frame of `render`'s grid.** The poser steps one continuous
|
|
23
|
+
* trajectory at `1/fps` (physics included), so the pose "at t" is the one
|
|
24
|
+
* `render --fps <fps>` wrote as frame `t·fps`, and a time off that grid is
|
|
25
|
+
* refused naming the two grid times either side rather than posed some other
|
|
26
|
+
* way — a second recipe for one moment would be a second opinion about the pose.
|
|
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
|
+
*
|
|
39
|
+
* Links nothing from the runtime: the poser is handed in.
|
|
40
|
+
*/
|
|
41
|
+
import { createHash } from 'node:crypto';
|
|
42
|
+
import { drawText, GLYPH_H, textWidth } from '../tools/font5x7.ts';
|
|
43
|
+
import { Plate, type RGBA } from '../tools/plate.ts';
|
|
44
|
+
import { lineGap, worldAxisDegrees } from './assertions/bodies/a51.ts';
|
|
45
|
+
import {
|
|
46
|
+
BACKGROUND,
|
|
47
|
+
type AttachmentPose,
|
|
48
|
+
type AttachmentRest,
|
|
49
|
+
type BoneSnapshot,
|
|
50
|
+
type Candidate,
|
|
51
|
+
type Frame,
|
|
52
|
+
framingViewport,
|
|
53
|
+
type Poser,
|
|
54
|
+
type PoseOptions,
|
|
55
|
+
renderFrame,
|
|
56
|
+
sampleAnimation,
|
|
57
|
+
sampleSetupPose,
|
|
58
|
+
SHEET_COLUMNS,
|
|
59
|
+
SHEET_GAP,
|
|
60
|
+
SHEET_LABEL,
|
|
61
|
+
SHEET_RULE,
|
|
62
|
+
type SkinRoster,
|
|
63
|
+
throughPoser,
|
|
64
|
+
type Viewport,
|
|
65
|
+
viewportFor,
|
|
66
|
+
} from './render_shared.ts';
|
|
67
|
+
|
|
68
|
+
/** The legend's format tag. */
|
|
69
|
+
export const INSPECT_SPEC = 'rigc-inspect/1';
|
|
70
|
+
|
|
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;
|
|
73
|
+
export type ShowWord = (typeof SHOW_WORDS)[number];
|
|
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
|
+
|
|
78
|
+
/** What `--show` draws when it is not given. */
|
|
79
|
+
export const DEFAULT_SHOW: readonly ShowWord[] = ['art', 'bones'];
|
|
80
|
+
|
|
81
|
+
/** The frame rate a time is read on when `--fps` is not given — `render`'s own default, so the two grids are one. */
|
|
82
|
+
export const INSPECT_DEFAULT_FPS = 12;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How far from a whole frame `t·fps` may be and still name that frame, in
|
|
86
|
+
* frames. Not a tolerance on the pose: it absorbs the binary spelling of a
|
|
87
|
+
* decimal time (`0.1 · 12` is `1.2000000000000002`), and the frame posed is the
|
|
88
|
+
* whole one, filed under `index / fps` in the legend.
|
|
89
|
+
*/
|
|
90
|
+
export const ON_GRID_FRAMES = 1e-6;
|
|
91
|
+
|
|
92
|
+
/** A zoom with no pad stated is padded by this share of the zoomed box's longer side, on every side. */
|
|
93
|
+
export const DEFAULT_ZOOM_PAD_SHARE = 0.25;
|
|
94
|
+
|
|
95
|
+
/** The grid's step is the smallest 1, 2 or 5 × 10ⁿ world units at least this many pixels apart. */
|
|
96
|
+
export const GRID_MIN_PIXELS = 32;
|
|
97
|
+
|
|
98
|
+
/** An axis is drawn this many pixels long for a bone whose world scale on it is 1 — longer or shorter as it scales. */
|
|
99
|
+
export const AXIS_PIXELS = 24;
|
|
100
|
+
|
|
101
|
+
/** `--dim`: every art pixel is moved toward white, keeping this share of its distance from white. */
|
|
102
|
+
export const DIM_KEEP = 0.4;
|
|
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
|
+
|
|
125
|
+
/** What each colour the picture draws means — the legend repeats this table. */
|
|
126
|
+
export const INSPECT_COLOURS = {
|
|
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)" },
|
|
128
|
+
origin: { rgba: [235, 0, 190, 255], meaning: "a bone's origin: a filled 3x3 square centred on the origin's pixel" },
|
|
129
|
+
tip: { rgba: [255, 140, 0, 255], meaning: "a bone's tip: a hollow 3x3 square centred on the tip's pixel" },
|
|
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' },
|
|
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" },
|
|
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` },
|
|
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` },
|
|
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' },
|
|
142
|
+
gridZero: { rgba: [70, 70, 70, 255], meaning: 'the grid line at world x = 0 or y = 0' },
|
|
143
|
+
declared: { rgba: [0, 190, 190, 255], meaning: 'an invariants.axes line, through its two bones\' origins, across the whole tile' },
|
|
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' },
|
|
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' },
|
|
151
|
+
} as const satisfies Record<string, { rgba: readonly number[]; meaning: string }>;
|
|
152
|
+
|
|
153
|
+
type ColourName = keyof typeof INSPECT_COLOURS;
|
|
154
|
+
const colour = (name: ColourName): RGBA => [...INSPECT_COLOURS[name].rgba] as RGBA;
|
|
155
|
+
|
|
156
|
+
/** A refusal of the invocation: a name the build does not have, a time off the grid, a box that cannot be drawn. Exit 2, nothing written. */
|
|
157
|
+
export class InspectError extends Error {}
|
|
158
|
+
|
|
159
|
+
/** What to frame a picture to. */
|
|
160
|
+
export type InspectFrame =
|
|
161
|
+
| { kind: 'shot' }
|
|
162
|
+
| { kind: 'zoom'; target: string; pad: number | null }
|
|
163
|
+
| { kind: 'viewport'; x: number; y: number; width: number; height: number };
|
|
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
|
+
/** One `rigc inspect` invocation, its flags read but not yet checked against the build. */
|
|
174
|
+
export interface InspectRequest {
|
|
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;
|
|
180
|
+
dim: boolean;
|
|
181
|
+
/** `null` is the setup pose. */
|
|
182
|
+
animation: string | null;
|
|
183
|
+
/** Seconds, in the order given; empty with no animation. */
|
|
184
|
+
times: readonly number[];
|
|
185
|
+
fps: number;
|
|
186
|
+
skin: string | undefined;
|
|
187
|
+
frame: InspectFrame;
|
|
188
|
+
maxSide: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** What the picture is made from besides the poser: the build's own texts. */
|
|
192
|
+
export interface InspectTexts {
|
|
193
|
+
/** `skeleton.json` — read for each bone's `length`, which no pose carries, and for each attachment's weights. */
|
|
194
|
+
skeletonText: string;
|
|
195
|
+
/** The `skeleton.model.json` that is this skeleton's, or `undefined` — what `declared` reads `rig.axes` from. */
|
|
196
|
+
modelText: string | undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The second build `--compare` draws beside the first. */
|
|
200
|
+
export interface InspectCompare {
|
|
201
|
+
candidate: Candidate;
|
|
202
|
+
texts: InspectTexts;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A planted defect, for the suite's mutants only (`IN` controls in
|
|
207
|
+
* `selftest.ts`). Each one is a way the picture or the legend can be wrong that
|
|
208
|
+
* a control has to catch by name; production passes none.
|
|
209
|
+
*/
|
|
210
|
+
export interface InspectPlant {
|
|
211
|
+
/** Draw every bone mark this many pixels off where the legend says it is. */
|
|
212
|
+
overlayShift?: [number, number];
|
|
213
|
+
/** State every bone mark this many pixels off where the picture draws it. */
|
|
214
|
+
legendShift?: [number, number];
|
|
215
|
+
/** Read the grid step as pixels rather than world units. */
|
|
216
|
+
gridInPixels?: boolean;
|
|
217
|
+
/** Map world y to image y without turning it over. */
|
|
218
|
+
unflipped?: boolean;
|
|
219
|
+
/** Zoom to this bone, whatever the request named. */
|
|
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;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A point in both spaces. */
|
|
240
|
+
export interface LegendPoint {
|
|
241
|
+
/** Spine world, y up, world units. */
|
|
242
|
+
world: [number, number];
|
|
243
|
+
/** The picture's pixels, y down, from its top-left corner; pixel (i, j) covers [i, i+1) x [j, j+1). */
|
|
244
|
+
pixel: [number, number];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** One bone in one tile. */
|
|
248
|
+
export interface LegendBone {
|
|
249
|
+
name: string;
|
|
250
|
+
parent: string | null;
|
|
251
|
+
/** The skeleton's `length` for it, world units (0 where the file states none). */
|
|
252
|
+
length: number;
|
|
253
|
+
/** `length` is 0: origin and tip are one point, marked by the `zero` square. */
|
|
254
|
+
zeroLength: boolean;
|
|
255
|
+
/** `null` when the bone is drawn; otherwise why it is not (its world matrix is 0, or a bone filter left it out). */
|
|
256
|
+
undrawn: string | null;
|
|
257
|
+
origin: LegendPoint;
|
|
258
|
+
tip: LegendPoint;
|
|
259
|
+
/** The ends of its drawn world x and y axes — present when `axes` is shown and the bone is drawn. */
|
|
260
|
+
axes?: { x: LegendPoint; y: LegendPoint };
|
|
261
|
+
}
|
|
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
|
+
|
|
319
|
+
/** One `invariants.axes` entry in one tile. */
|
|
320
|
+
export interface LegendDeclared {
|
|
321
|
+
name: string;
|
|
322
|
+
through: [string, string];
|
|
323
|
+
tolerance: number;
|
|
324
|
+
/** The line's direction in world degrees (CCW from +x), or `null` when the two origins coincide (`why`). */
|
|
325
|
+
degrees: number | null;
|
|
326
|
+
why: string | null;
|
|
327
|
+
from: LegendPoint | null;
|
|
328
|
+
to: LegendPoint | null;
|
|
329
|
+
/** Each held bone's world x axis, and how far off the line it lies, as A51 measures it, at this tile's pose. */
|
|
330
|
+
bones: Array<{ name: string; degrees: number | null; off: number | null }>;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface LegendTile {
|
|
334
|
+
/** With `--compare`: which build this tile is — `A` is `--candidate`, `B` is `--compare`. */
|
|
335
|
+
build?: 'A' | 'B';
|
|
336
|
+
/** `null` for the setup pose. */
|
|
337
|
+
animation: string | null;
|
|
338
|
+
/** The frame of `render --fps <fps>` this pose is, and its time `frame / fps`. */
|
|
339
|
+
frame: number;
|
|
340
|
+
time: number;
|
|
341
|
+
/** Where the tile sits in the picture: its top-left pixel and its size. */
|
|
342
|
+
box: { x: number; y: number; width: number; height: number };
|
|
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`. */
|
|
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 };
|
|
347
|
+
bones: LegendBone[];
|
|
348
|
+
labels?: LegendLabel[];
|
|
349
|
+
meshes?: LegendMesh[];
|
|
350
|
+
slots?: LegendSlot[];
|
|
351
|
+
declared?: LegendDeclared[];
|
|
352
|
+
grid?: { step: number; x: number[]; y: number[] };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface InspectLegend {
|
|
356
|
+
spec: typeof INSPECT_SPEC;
|
|
357
|
+
/** The poser that posed `--candidate`; with `--compare`, `builds` names both. */
|
|
358
|
+
poser: 'core' | 'spine';
|
|
359
|
+
show: string[];
|
|
360
|
+
dim: boolean;
|
|
361
|
+
skin?: string;
|
|
362
|
+
animation: string | null;
|
|
363
|
+
fps: number;
|
|
364
|
+
framing:
|
|
365
|
+
| { kind: 'shot'; note: string }
|
|
366
|
+
| { kind: 'zoom'; bone?: string; slot?: string; pad: number; padFrom: 'stated' | 'default' }
|
|
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 }>;
|
|
374
|
+
coordinates: { world: string; image: string; toPixel: string; toWorld: string };
|
|
375
|
+
/** The world box every tile is a picture of, and its pixels — one for the whole picture, both builds of a compare included. */
|
|
376
|
+
viewport: { minX: number; minY: number; maxX: number; maxY: number; scale: number; width: number; height: number };
|
|
377
|
+
size: { width: number; height: number };
|
|
378
|
+
colours: Record<ColourName, { rgba: number[]; meaning: string }>;
|
|
379
|
+
tiles: LegendTile[];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The picture and its legend, and which poser posed it and why. */
|
|
383
|
+
export interface InspectResult {
|
|
384
|
+
plate: Plate;
|
|
385
|
+
legend: InspectLegend;
|
|
386
|
+
poserNote: string;
|
|
387
|
+
/** With `--compare`: the second build's poser line. */
|
|
388
|
+
comparePoserNote?: string;
|
|
389
|
+
/** Lines worth printing beside the picture: what a view drew nothing for, and why. */
|
|
390
|
+
notes: string[];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// reading the request against the build
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
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] };
|
|
404
|
+
const words = raw.split(',').map((w) => w.trim()).filter((w) => w !== '');
|
|
405
|
+
if (words.length === 0) throw new InspectError(`--show ${JSON.stringify(raw)} names no view; it takes [${SHOW_SPELLINGS.join(', ')}]`);
|
|
406
|
+
const out: ShowWord[] = [];
|
|
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
|
+
}
|
|
418
|
+
const known = SHOW_WORDS.find((w) => w === word);
|
|
419
|
+
if (known === undefined) throw new InspectError(`--show ${JSON.stringify(spelled)} is not a view inspect draws; it draws [${SHOW_SPELLINGS.join(', ')}]`);
|
|
420
|
+
if (out.includes(known)) throw new InspectError(`--show names ${JSON.stringify(word)} twice`);
|
|
421
|
+
out.push(known);
|
|
422
|
+
}
|
|
423
|
+
// In the drawing order, so two spellings of one set are one picture and one legend.
|
|
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);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** `--at`'s seconds. Their grid frames are checked against the animation later, where its length is known. */
|
|
452
|
+
export function readTimes(raw: string | undefined): number[] {
|
|
453
|
+
if (raw === undefined) return [];
|
|
454
|
+
const parts = raw.split(',').map((s) => s.trim());
|
|
455
|
+
const times = parts.map(Number);
|
|
456
|
+
const bad = parts.find((s, i) => s === '' || !Number.isFinite(times[i]) || times[i] < 0);
|
|
457
|
+
if (bad !== undefined) throw new InspectError(`--at ${JSON.stringify(raw)}: ${JSON.stringify(bad)} is not a time in seconds of at least 0`);
|
|
458
|
+
return times;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** `--zoom <name>[:<pad>]`: the name, and the pad in world units when one is stated. */
|
|
462
|
+
export function readZoom(raw: string): { target: string; pad: number | null } {
|
|
463
|
+
const colon = raw.lastIndexOf(':');
|
|
464
|
+
if (colon > 0) {
|
|
465
|
+
const tail = raw.slice(colon + 1);
|
|
466
|
+
const pad = Number(tail);
|
|
467
|
+
if (tail.trim() !== '' && Number.isFinite(pad)) {
|
|
468
|
+
if (pad < 0) throw new InspectError(`--zoom ${JSON.stringify(raw)}: the pad ${tail} is below 0; it is world units on every side`);
|
|
469
|
+
return { target: raw.slice(0, colon), pad };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (raw === '') throw new InspectError('--zoom needs a bone or slot name: --zoom <name>[:<pad>]');
|
|
473
|
+
return { target: raw, pad: null };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The grid frame `t` names at `fps`, or the refusal naming the two either side. */
|
|
477
|
+
function gridFrame(t: number, fps: number, duration: number, animation: string): number {
|
|
478
|
+
const exact = t * fps;
|
|
479
|
+
const frame = Math.round(exact);
|
|
480
|
+
const last = Math.round(duration * fps);
|
|
481
|
+
if (Math.abs(exact - frame) > ON_GRID_FRAMES) {
|
|
482
|
+
const below = Math.floor(exact);
|
|
483
|
+
throw new InspectError(
|
|
484
|
+
`--at ${t} is not on the ${fps} fps frame grid render draws "${animation}" on: the frames either side are ` +
|
|
485
|
+
`${below} (${(below / fps).toFixed(4)}s) and ${below + 1} (${((below + 1) / fps).toFixed(4)}s). Name one of them, or pass --fps for another grid`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
if (frame > last) {
|
|
489
|
+
throw new InspectError(
|
|
490
|
+
`--at ${t} is past the end of "${animation}": it lasts ${duration}s, frame ${last} (${(last / fps).toFixed(4)}s) at ${fps} fps`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return frame;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Each bone's `length` off the skeleton file — Spine's default is 0, which is what a file that states none means. */
|
|
497
|
+
export function boneLengths(skeletonText: string): Map<string, number> {
|
|
498
|
+
const parsed = JSON.parse(skeletonText) as { bones?: Array<{ name?: unknown; length?: unknown }> };
|
|
499
|
+
const out = new Map<string, number>();
|
|
500
|
+
for (const bone of parsed.bones ?? []) {
|
|
501
|
+
if (typeof bone.name !== 'string') continue;
|
|
502
|
+
out.set(bone.name, typeof bone.length === 'number' ? bone.length : 0);
|
|
503
|
+
}
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** One `invariants.axes` entry, as the model document's `rig.axes` spells it. */
|
|
508
|
+
export interface DeclaredAxis {
|
|
509
|
+
name: string;
|
|
510
|
+
through: [string, string];
|
|
511
|
+
bones: string[];
|
|
512
|
+
tolerance: number;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** `rig.axes` off the build's model document, or the refusal saying why `declared` has nothing to read. */
|
|
516
|
+
export function declaredAxes(modelText: string | undefined): DeclaredAxis[] {
|
|
517
|
+
if (modelText === undefined) {
|
|
518
|
+
throw new InspectError(
|
|
519
|
+
'--show declared reads invariants.axes from the skeleton.model.json `build` writes beside a skeleton, and there is none ' +
|
|
520
|
+
'here whose spine.sha256 is this skeleton\'s — a Spine export states no rig invariants',
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const doc = JSON.parse(modelText) as { rig?: { axes?: unknown } };
|
|
524
|
+
const axes = doc.rig?.axes;
|
|
525
|
+
if (!Array.isArray(axes)) {
|
|
526
|
+
throw new InspectError(
|
|
527
|
+
'--show declared reads rig.axes from the skeleton.model.json beside this skeleton, and that document states no rig.axes — ' +
|
|
528
|
+
'it was written by a rigc older than invariants.axes; build it again',
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
return axes as DeclaredAxis[];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// drawing
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
/** One pixel, set outright; off the tile is nothing. */
|
|
539
|
+
function put(plate: Plate, x: number, y: number, rgba: RGBA): void {
|
|
540
|
+
if (x < 0 || y < 0 || x >= plate.width || y >= plate.height) return;
|
|
541
|
+
const i = (y * plate.width + x) * 4;
|
|
542
|
+
plate.data[i] = rgba[0];
|
|
543
|
+
plate.data[i + 1] = rgba[1];
|
|
544
|
+
plate.data[i + 2] = rgba[2];
|
|
545
|
+
plate.data[i + 3] = rgba[3];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** A one-pixel line between two continuous pixel positions: every pixel whose square a dense walk along it enters. */
|
|
549
|
+
function line(plate: Plate, x0: number, y0: number, x1: number, y1: number, rgba: RGBA): void {
|
|
550
|
+
const steps = Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)) * 2) + 1;
|
|
551
|
+
for (let s = 0; s <= steps; s++) {
|
|
552
|
+
const u = s / steps;
|
|
553
|
+
put(plate, Math.floor(x0 + (x1 - x0) * u), Math.floor(y0 + (y1 - y0) * u), rgba);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** A square of side `2r + 1` centred on pixel (cx, cy): filled, or its border only. */
|
|
558
|
+
function square(plate: Plate, cx: number, cy: number, r: number, hollow: boolean, rgba: RGBA): void {
|
|
559
|
+
for (let dy = -r; dy <= r; dy++) {
|
|
560
|
+
for (let dx = -r; dx <= r; dx++) {
|
|
561
|
+
if (hollow && Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
|
|
562
|
+
put(plate, cx + dx, cy + dy, rgba);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function text(plate: Plate, value: string, x: number, y: number, rgba: RGBA): void {
|
|
568
|
+
drawText(value, x, y, 1, (px, py) => put(plate, px, py, rgba));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** The grid step: the smallest 1, 2 or 5 × 10ⁿ world units whose lines land at least `GRID_MIN_PIXELS` apart. */
|
|
572
|
+
export function gridStep(scale: number): number {
|
|
573
|
+
const least = GRID_MIN_PIXELS / scale;
|
|
574
|
+
let power = 10 ** Math.floor(Math.log10(least));
|
|
575
|
+
for (;;) {
|
|
576
|
+
for (const m of [1, 2, 5]) if (m * power >= least) return m * power;
|
|
577
|
+
power *= 10;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** A grid value as its label prints it: as many decimals as the step has, no more. */
|
|
582
|
+
function gridLabel(value: number, step: number): string {
|
|
583
|
+
const decimals = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
|
|
584
|
+
const shown = value.toFixed(decimals);
|
|
585
|
+
return /^-0(\.0*)?$/.test(shown) ? shown.slice(1) : shown;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Whole multiples of `step` from `lo` to `hi`, inclusive. */
|
|
589
|
+
function multiples(lo: number, hi: number, step: number): number[] {
|
|
590
|
+
const out: number[] = [];
|
|
591
|
+
for (let k = Math.ceil(lo / step); k * step <= hi; k++) out.push(k * step === 0 ? 0 : k * step);
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
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
|
+
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
// the view
|
|
781
|
+
// ---------------------------------------------------------------------------
|
|
782
|
+
|
|
783
|
+
/** The poses one picture is of: the frames, in the order the request named their times. */
|
|
784
|
+
function posesOf(poser: Poser, request: InspectRequest, needAttachments: boolean): Array<{ frame: Frame; index: number }> {
|
|
785
|
+
const opts: PoseOptions = { bones: true, ...(needAttachments ? { geometry: true } : {}), ...(request.skin === undefined ? {} : { skin: request.skin }) };
|
|
786
|
+
if (request.animation === null) return [{ frame: sampleSetupPose(poser, opts)[0], index: 0 }];
|
|
787
|
+
const animation = poser.animations.find((a) => a.name === request.animation);
|
|
788
|
+
if (animation === undefined) {
|
|
789
|
+
throw new InspectError(`no animation ${JSON.stringify(request.animation)} in this skeleton; it has [${poser.animations.map((a) => a.name).join(', ') || 'none'}]`);
|
|
790
|
+
}
|
|
791
|
+
const indices = request.times.map((t) => gridFrame(t, request.fps, animation.duration, animation.name));
|
|
792
|
+
const seen = new Set<number>();
|
|
793
|
+
for (const [i, index] of indices.entries()) {
|
|
794
|
+
if (seen.has(index)) throw new InspectError(`--at names frame ${index} (${(index / request.fps).toFixed(4)}s) twice — at ${request.times[i]}`);
|
|
795
|
+
seen.add(index);
|
|
796
|
+
}
|
|
797
|
+
const frames = sampleAnimation(poser, animation.name, request.fps, opts);
|
|
798
|
+
return indices.map((index) => ({ frame: frames[index], index }));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
type WorldBox = [number, number, number, number];
|
|
802
|
+
|
|
803
|
+
/** The world box of what `--zoom` names, over every pose of the picture. */
|
|
804
|
+
function zoomBox(
|
|
805
|
+
poser: Poser,
|
|
806
|
+
poses: ReadonlyArray<{ frame: Frame }>,
|
|
807
|
+
target: string,
|
|
808
|
+
lengths: Map<string, number>,
|
|
809
|
+
): { box: WorldBox; bone?: string; slot?: string } {
|
|
810
|
+
const isBone = poser.bones.some((b) => b.name === target);
|
|
811
|
+
const isSlot = poser.slots.some((s) => s.name === target);
|
|
812
|
+
if (isBone && isSlot) {
|
|
813
|
+
// A rig names a slot after its bone often; which box is meant is the caller's to say, not a guess.
|
|
814
|
+
throw new InspectError(
|
|
815
|
+
`--zoom ${JSON.stringify(target)} names both a bone and a slot; say which with --zoom bone:${target} or --zoom slot:${target}`,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
let kind: 'bone' | 'slot' | null = isBone ? 'bone' : isSlot ? 'slot' : null;
|
|
819
|
+
let name = target;
|
|
820
|
+
for (const prefix of ['bone', 'slot'] as const) {
|
|
821
|
+
if (kind === null && target.startsWith(`${prefix}:`)) {
|
|
822
|
+
name = target.slice(prefix.length + 1);
|
|
823
|
+
const has = prefix === 'bone' ? poser.bones.some((b) => b.name === name) : poser.slots.some((s) => s.name === name);
|
|
824
|
+
if (has) kind = prefix;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (kind === null) {
|
|
828
|
+
throw new InspectError(
|
|
829
|
+
`--zoom ${JSON.stringify(target)}: no bone or slot of that name. Bones: [${poser.bones.map((b) => b.name).join(', ')}]; ` +
|
|
830
|
+
`slots: [${poser.slots.map((s) => s.name).join(', ')}]`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
const box: WorldBox = [Infinity, Infinity, -Infinity, -Infinity];
|
|
834
|
+
const take = (x: number, y: number): void => {
|
|
835
|
+
box[0] = Math.min(box[0], x);
|
|
836
|
+
box[1] = Math.min(box[1], y);
|
|
837
|
+
box[2] = Math.max(box[2], x);
|
|
838
|
+
box[3] = Math.max(box[3], y);
|
|
839
|
+
};
|
|
840
|
+
for (const { frame } of poses) {
|
|
841
|
+
if (kind === 'bone') {
|
|
842
|
+
const b = frame.bones?.find((s) => s.name === name);
|
|
843
|
+
if (b === undefined) continue;
|
|
844
|
+
if (b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0) continue;
|
|
845
|
+
const length = lengths.get(name) ?? 0;
|
|
846
|
+
take(b.worldX, b.worldY);
|
|
847
|
+
take(b.worldX + length * b.a, b.worldY + length * b.c);
|
|
848
|
+
} else {
|
|
849
|
+
for (const shown of frame.attachments ?? []) {
|
|
850
|
+
if (shown.slot !== name) continue;
|
|
851
|
+
for (let i = 0; i + 1 < shown.vertices.length; i += 2) take(shown.vertices[i], shown.vertices[i + 1]);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (!box.every(Number.isFinite)) {
|
|
856
|
+
throw new InspectError(
|
|
857
|
+
kind === 'bone'
|
|
858
|
+
? `--zoom ${JSON.stringify(target)}: bone "${name}" is not posed at any time of this picture (its world matrix is 0 — unposed by the skin, or a scale of 0)`
|
|
859
|
+
: `--zoom ${JSON.stringify(target)}: slot "${name}" shows no region or mesh at any time of this picture`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
return kind === 'bone' ? { box, bone: name } : { box, slot: name };
|
|
863
|
+
}
|
|
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
|
+
|
|
937
|
+
/**
|
|
938
|
+
* The picture and its legend, posed through `candidate`'s chosen poser — the
|
|
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.
|
|
941
|
+
*/
|
|
942
|
+
export function inspectCandidate(candidate: Candidate, texts: InspectTexts, request: InspectRequest, plant: InspectPlant = {}, compare?: InspectCompare): InspectResult {
|
|
943
|
+
if (request.animation === null && request.times.length > 0) {
|
|
944
|
+
throw new InspectError('--at needs --animation <name>: the setup pose has no time. Without --at the picture is the setup pose');
|
|
945
|
+
}
|
|
946
|
+
if (request.animation !== null && request.times.length === 0) {
|
|
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`);
|
|
948
|
+
}
|
|
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 }) };
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function view(builds: readonly Gathered[], request: InspectRequest, plant: InspectPlant): Omit<InspectResult, 'poserNote' | 'comparePoserNote'> {
|
|
960
|
+
const notes: string[] = [];
|
|
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.
|
|
974
|
+
let viewport: Viewport;
|
|
975
|
+
let framing: InspectLegend['framing'];
|
|
976
|
+
if (request.frame.kind === 'viewport') {
|
|
977
|
+
const f = request.frame;
|
|
978
|
+
viewport = viewportFor(f.x, f.y, f.x + f.width, f.y + f.height, request.maxSide);
|
|
979
|
+
framing = { kind: 'viewport' };
|
|
980
|
+
} else if (request.frame.kind === 'zoom') {
|
|
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
|
+
);
|
|
986
|
+
const longer = Math.max(x1 - x0, y1 - y0);
|
|
987
|
+
const pad = request.frame.pad ?? longer * DEFAULT_ZOOM_PAD_SHARE;
|
|
988
|
+
if (longer + 2 * pad === 0) {
|
|
989
|
+
throw new InspectError(
|
|
990
|
+
`--zoom ${JSON.stringify(request.frame.target)}: what it names is one point, (${x0}, ${y0}), at every time of this picture, so ` +
|
|
991
|
+
`the box has no size — state a pad in world units: --zoom ${request.frame.target}:<pad>`,
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
viewport = viewportFor(x0 - pad, y0 - pad, x1 + pad, y1 + pad, request.maxSide);
|
|
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' };
|
|
997
|
+
} else {
|
|
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
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
const { width, height, scale } = viewport;
|
|
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;
|
|
1026
|
+
const plate = new Plate(columns * (width + gap) + gap, rows * (height + gap) + gap);
|
|
1027
|
+
for (let i = 0; i < plate.data.length; i += 4) plate.data.set(colour('rule'), i);
|
|
1028
|
+
const tiles: LegendTile[] = [];
|
|
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);
|
|
1042
|
+
}
|
|
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`);
|
|
1047
|
+
}
|
|
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');
|
|
1050
|
+
const legend: InspectLegend = {
|
|
1051
|
+
spec: INSPECT_SPEC,
|
|
1052
|
+
poser: builds[0].poser,
|
|
1053
|
+
show: showSpelled(request, weightsBone),
|
|
1054
|
+
dim: request.dim,
|
|
1055
|
+
...(request.skin === undefined ? {} : { skin: request.skin }),
|
|
1056
|
+
animation: request.animation,
|
|
1057
|
+
fps: request.fps,
|
|
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 })) } : {}),
|
|
1076
|
+
coordinates: {
|
|
1077
|
+
world: 'spine world: y up, world units',
|
|
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',
|
|
1079
|
+
toPixel: 'px = x0 + (x - minX) * scale; py = y0 + (maxY - y) * scale — each tile\'s own transform',
|
|
1080
|
+
toWorld: 'x = minX + (px - x0) / scale; y = maxY - (py - y0) / scale',
|
|
1081
|
+
},
|
|
1082
|
+
viewport: { minX: viewport.minX, minY: viewport.minY, maxX: viewport.maxX, maxY: viewport.maxY, scale, width, height },
|
|
1083
|
+
size: { width: plate.width, height: plate.height },
|
|
1084
|
+
colours: Object.fromEntries(Object.entries(INSPECT_COLOURS).map(([k, v]) => [k, { rgba: [...v.rgba], meaning: v.meaning }])) as InspectLegend['colours'],
|
|
1085
|
+
tiles,
|
|
1086
|
+
};
|
|
1087
|
+
return { plate, legend, notes };
|
|
1088
|
+
}
|
|
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
|
+
|
|
1415
|
+
function filled(width: number, height: number, rgba: RGBA): Plate {
|
|
1416
|
+
const plate = new Plate(width, height);
|
|
1417
|
+
for (let i = 0; i < plate.data.length; i += 4) plate.data.set(rgba, i);
|
|
1418
|
+
return plate;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** The legend as its file holds it: two-space JSON and a newline, so two runs over one build are one byte sequence. */
|
|
1422
|
+
export function legendText(legend: InspectLegend): string {
|
|
1423
|
+
return `${JSON.stringify(legend, null, 2)}\n`;
|
|
1424
|
+
}
|