spine-rigc 2.42.0 → 2.44.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 +3 -1
- package/docs/AUTHORING.md +31 -3
- package/package.json +1 -1
- package/src/cli/core_commands.ts +7 -0
- package/src/cli/shared.ts +16 -2
- package/src/inspect.ts +328 -9
package/README.md
CHANGED
|
@@ -412,11 +412,13 @@ point*, *is this axis held*. It draws the build's art, framed as `render` frames
|
|
|
412
412
|
and over it any of `bones` (origin to tip, a bone of length 0 marked as one),
|
|
413
413
|
`names` (one label per pixel), `axes` (each bone's world x and y), `mesh` and `hull`
|
|
414
414
|
(each mesh's edges and outline), `weights:<bone>` (what that bone moves, as a heat),
|
|
415
|
+
`density` (each part's triangles per area against the figure's, on a log ramp),
|
|
415
416
|
`slots` (the draw order at the pose), `grid` (in world units) and `declared`
|
|
416
417
|
(each `invariants.axes` line and the bones held to it), at the setup pose, at
|
|
417
418
|
`--animation <name> --at <s[,s…]>` — one picture, or a sheet — or zoomed with
|
|
418
419
|
`--zoom <bone|slot>[:pad]` or `--viewport x,y,w,h`; `--bone`/`--hide-bone` pick the
|
|
419
|
-
bones by glob,
|
|
420
|
+
bones by glob, `--overlay-slot`/`--hide-overlay-slot` the slots whose mesh, hull,
|
|
421
|
+
weights, density and number are drawn over the whole painting, and `--compare <dir>` draws a second build beside the first on one
|
|
420
422
|
transform, to see what a change moved. Beside the PNG it writes a legend:
|
|
421
423
|
the world-to-pixel transform both ways, what each colour means, and every bone's
|
|
422
424
|
origin and tip in world units and in pixels, so a position read off the picture goes
|
package/docs/AUTHORING.md
CHANGED
|
@@ -10094,6 +10094,8 @@ pixel (`IN09`).
|
|
|
10094
10094
|
| Is this axis declared, and held? | `--show art,declared,bones` | each `invariants.axes` line (§3.7) through its two bones' origins, drawn across the whole tile, and the bones it holds drawn in their own colour; the legend states each held bone's world x axis and how far off the line it lies, as `A51` measures it, at the pose drawn |
|
|
10095
10095
|
| Did it break during the move? | any of the above `--animation <name> --at <s,s,…>` | one tile per time, left to right on one grid, each labelled with its time and frame |
|
|
10096
10096
|
| What does this bone move? | `--show art,mesh,weights:<bone> --dim` | the bone's weight over every region and mesh it moves, as a heat: a pixel whose centre lies in a triangle is painted the ramp's colour at the blend of its corners' weights — blue near 0, yellow at 0.5, red at 1 (the stops are in the legend) — and a weight of 0 is not painted, so the art shows where the bone does not reach. A region and an unweighted mesh are moved whole by their slot's bone. The weights are read off `skeleton.json`, the file both posers skin from; a bone that weights nothing paints nothing and the run says so on a line of its own |
|
|
10097
|
+
| Does this part's edge follow that bone? | `--show art,weights:<bone>,mesh --overlay-slot <part> --zoom slot:<part>` | the part's own triangles and the bone's weight over them alone, over the whole painting. Where slots stack — a neck under the face, the hair, a collar — every mesh crossing the box is drawn and the heat is the union of every slot the bone moves, so the part's edge has no pixel of its own until the others are left out. The legend's `meshes[]` gives the part's weight for the bone at every vertex, so "does the top row follow the head" is read as numbers as well as colour |
|
|
10098
|
+
| Is this rig's density where its motion is? | `--show art,density --dim` | each region and mesh filled by its **ratio** — its triangles per world unit² over the figure's — on one ramp, log-scaled and centred on 1×: grey is as dense as the figure, pink to magenta denser (4×, 16×), light to dark green sparser (1/4×, 1/16×), clamped past the end stops (the stops are in the legend). A part's area is its posed mesh's own, the sum of its triangles' areas at the pose drawn, not the image's box; a region is two triangles over its quad. Where parts overlap the pixel is the part drawn last, as in the art, so a part under others is read with `--overlay-slot <part>`. The figure's totals are always over every slot the pose shows — the filter picks what is drawn and listed, never what the figure is — and the legend's `density` gives each slot's attachment, kind, vertices, triangles, hull, area, triangles per area, ratio and the bones it is weighted to, in draw order. A measurement, not a verdict: no bar is drawn, and `A13_MESH_BUDGET` stays the one gate on counts |
|
|
10097
10099
|
| How is this part triangulated? | `--show art,mesh,hull --zoom slot:<slot>` | each mesh's triangle edges, and over them its hull — vertex 0 to 1 to … to `hull − 1` and back. Regions are not drawn: a region is not a mesh. The legend lists every vertex in world units and in pixels |
|
|
10098
10100
|
| Which is drawn over which? | `--show art,slots` | each slot that shows a region or mesh numbered by its place in the draw order **at the pose drawn** (0 is drawn first), on a white box at the centre of its attachment's box — so a `drawOrder` key (§4.7) shows from the first frame `render` draws it on |
|
|
10099
10101
|
| Did this change move anything? | `--compare <dir>` beside any view | the same view of a second build beside the first, at the same times, on **one** world box and one transform, each half labelled `A`/`B` with the poser that posed it. A mark on a different pixel in the two halves is at a different place in the two rigs |
|
|
@@ -10128,7 +10130,7 @@ pixel (`IN09`).
|
|
|
10128
10130
|
- `--dim` fades the art toward white (each channel keeps 0.4 of its distance from
|
|
10129
10131
|
white) so what is drawn over it reads.
|
|
10130
10132
|
|
|
10131
|
-
### 13.3 Labels, and which bones are drawn
|
|
10133
|
+
### 13.3 Labels, and which bones and slots are drawn
|
|
10132
10134
|
|
|
10133
10135
|
- **One label per pixel.** Bones whose origins land on one pixel — a `_ctl` bone and
|
|
10134
10136
|
its identity child, a chain's joint — are one label: the first by declaration
|
|
@@ -10149,6 +10151,17 @@ pixel (`IN09`).
|
|
|
10149
10151
|
the shell. The filter is on `bones`, `axes` and `names`; every bone stays in the
|
|
10150
10152
|
legend, and a left-out one says which flag left it out in `undrawn`. A pattern
|
|
10151
10153
|
that matches no bone is refused naming the bones there are.
|
|
10154
|
+
- **`--overlay-slot <glob[,glob…]>`** draws the per-slot views — `mesh`, `hull`,
|
|
10155
|
+
`weights:<bone>`, `density` and `slots` — only for the slots a pattern matches, and
|
|
10156
|
+
**`--hide-overlay-slot <glob[,glob…]>`** for none of the ones it matches, applied
|
|
10157
|
+
after `--overlay-slot`; the patterns are globs, as `--bone`'s are. The **art is not
|
|
10158
|
+
filtered**: the painting is what the edge is read against, and `--dim` already
|
|
10159
|
+
quiets it, and neither is the figure a `density` is measured against. A slot left
|
|
10160
|
+
out is absent from `meshes[]`, `slots[]` and the density rows, and a slot kept
|
|
10161
|
+
keeps its number — its place in the whole draw order, not among the slots drawn. A
|
|
10162
|
+
pattern that matches no slot is refused naming the slots there are. It is not
|
|
10163
|
+
`--slot`: on `render` that flag draws a subset of the *art*, by exact name, which is
|
|
10164
|
+
the opposite of what this keeps whole.
|
|
10152
10165
|
|
|
10153
10166
|
### 13.4 The legend
|
|
10154
10167
|
|
|
@@ -10171,7 +10184,15 @@ pixel (`IN09`).
|
|
|
10171
10184
|
value; with `declared`, each entry's line in degrees, its two points, and each held
|
|
10172
10185
|
bone's angle and how far off the line it is. With `names`, `labels[]`: each label's
|
|
10173
10186
|
`text`, the `bones` it stands for, its `anchor` pixel, the `box` it is drawn in,
|
|
10174
|
-
and whether it was `moved` or `overlaps`. With `
|
|
10187
|
+
and whether it was `moved` or `overlaps`. With `density`, `density`: the
|
|
10188
|
+
`figure` at this tile's pose — `slots`, `triangles`, `area` (world units²) and
|
|
10189
|
+
`trianglesPerArea`, over every slot whose attachment is a region or mesh — and
|
|
10190
|
+
`slots[]`, one row per kept slot in draw order: `order`, `slot`, `attachment`,
|
|
10191
|
+
`kind`, `alpha`, `vertices`, `triangles`, `hull`, `area`, `trianglesPerArea`,
|
|
10192
|
+
`ratio`, the `rgba` it is painted, `bones` (how many bones it is weighted to —
|
|
10193
|
+
a region's or an unweighted mesh's is its slot's one) and `boneNames`. A part
|
|
10194
|
+
whose posed area is 0 has `trianglesPerArea` and `ratio` `null` and says why in
|
|
10195
|
+
`why`; it is not painted. With `mesh`, `hull`, `weights` or `density`,
|
|
10175
10196
|
`meshes[]`: every region and mesh the pose shows, in draw order, with its `kind`,
|
|
10176
10197
|
`vertexCount`, `triangleCount`, `hull` (a mesh's hull vertex count), its unique
|
|
10177
10198
|
triangle `edges` as vertex pairs, every posed vertex in `world` and `pixel`, and
|
|
@@ -10185,7 +10206,12 @@ pixel (`IN09`).
|
|
|
10185
10206
|
was stated, or `viewport`).
|
|
10186
10207
|
- `weights` — with `weights:<bone>`: the bone, the ramp's stops as RGBA, the floor
|
|
10187
10208
|
below which nothing is painted, and how a pixel's weight is read.
|
|
10209
|
+
- `density` — with `density`: the ramp's stops (`ratio`, RGBA), the `scale` it is
|
|
10210
|
+
read on (log₂ of the ratio, clamped at the end stops), and in words what `area`,
|
|
10211
|
+
the figure's `totals` and the `ratio` are and how a pixel is painted.
|
|
10188
10212
|
- `boneFilter` — `--bone` and `--hide-bone` as given.
|
|
10213
|
+
- `slotFilter` — `--overlay-slot` and `--hide-overlay-slot` as given; with it,
|
|
10214
|
+
`meshes[]` and `slots[]` list only the slots it keeps.
|
|
10189
10215
|
- `builds` — with `--compare`: each build (`A`, `B`), the poser that posed it, and
|
|
10190
10216
|
its `skeleton.json`'s sha256. `poser` is build `A`'s.
|
|
10191
10217
|
|
|
@@ -10201,7 +10227,9 @@ or past the end, `--at` without `--animation` and the reverse, `--zoom` beside
|
|
|
10201
10227
|
`--viewport`, `--show declared` on a skeleton with no `skeleton.model.json`
|
|
10202
10228
|
beside it whose `spine.sha256` is its own — a Spine export states no rig invariants —
|
|
10203
10229
|
`weights` with no bone or with a bone the build lacks (the bones listed), a
|
|
10204
|
-
`--bone`/`--hide-bone` pattern that matches no bone (the bones listed),
|
|
10230
|
+
`--bone`/`--hide-bone` pattern that matches no bone (the bones listed), an
|
|
10231
|
+
`--overlay-slot`/`--hide-overlay-slot` pattern that matches no slot (the slots
|
|
10232
|
+
listed), and, with
|
|
10205
10233
|
`--compare`, any of these that holds of the second build, said as `the --compare
|
|
10206
10234
|
build: …`.
|
|
10207
10235
|
A rig that declares no `invariants.axes` is not refused: the picture draws nothing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.44.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -680,6 +680,12 @@ export function cmdInspect(flags: Record<string, string>): void {
|
|
|
680
680
|
const include = readBonePatterns('bone', flags.bone);
|
|
681
681
|
const exclude = readBonePatterns('hide-bone', flags['hide-bone']);
|
|
682
682
|
const boneFilter = include === undefined && exclude === undefined ? undefined : { ...(include === undefined ? {} : { include }), ...(exclude === undefined ? {} : { exclude }) };
|
|
683
|
+
const slotInclude = readBonePatterns('overlay-slot', flags['overlay-slot'], 'neck,hair_*');
|
|
684
|
+
const slotExclude = readBonePatterns('hide-overlay-slot', flags['hide-overlay-slot'], 'neck,hair_*');
|
|
685
|
+
const slotFilter =
|
|
686
|
+
slotInclude === undefined && slotExclude === undefined
|
|
687
|
+
? undefined
|
|
688
|
+
: { ...(slotInclude === undefined ? {} : { include: slotInclude }), ...(slotExclude === undefined ? {} : { exclude: slotExclude }) };
|
|
683
689
|
const times = readTimes(flags.at);
|
|
684
690
|
const fps = readPositiveNumber(flags, 'fps', INSPECT_DEFAULT_FPS, 1);
|
|
685
691
|
const maxSide = readPositiveNumber(flags, 'max', 256, 16);
|
|
@@ -733,6 +739,7 @@ export function cmdInspect(flags: Record<string, string>): void {
|
|
|
733
739
|
show,
|
|
734
740
|
...(weightsBone === undefined ? {} : { weightsBone }),
|
|
735
741
|
...(boneFilter === undefined ? {} : { boneFilter }),
|
|
742
|
+
...(slotFilter === undefined ? {} : { slotFilter }),
|
|
736
743
|
dim: flags.dim !== undefined,
|
|
737
744
|
animation,
|
|
738
745
|
times,
|
package/src/cli/shared.ts
CHANGED
|
@@ -1064,7 +1064,9 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1064
1064
|
max: 'longest side of a rendered frame, in pixels (default 256)',
|
|
1065
1065
|
show:
|
|
1066
1066
|
`what \`inspect\` draws, comma-separated, from [${SHOW_SPELLINGS.join(', ')}] (default \`${DEFAULT_SHOW.join(',')}\`): art = the ` +
|
|
1067
|
-
"build's own frame; grid = lines at whole multiples of a step in world units, labelled;
|
|
1067
|
+
"build's own frame; grid = lines at whole multiples of a step in world units, labelled; density = each region and mesh " +
|
|
1068
|
+
"filled by its triangles per world unit² over the figure's, on a log ramp centred on 1x that the legend states, with the " +
|
|
1069
|
+
"figure's totals over every slot and each slot's row in the legend; weights:<bone> = that bone's " +
|
|
1068
1070
|
'weight over every region and mesh it moves, as a heat on a ramp the legend states; mesh = each mesh\'s triangle edges; ' +
|
|
1069
1071
|
"hull = each mesh's hull outline; declared = each invariants.axes " +
|
|
1070
1072
|
"line through its two bones' origins, its held bones drawn in their own colour; bones = each bone from origin to tip, " +
|
|
@@ -1088,6 +1090,15 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1088
1090
|
'hide-bone':
|
|
1089
1091
|
"draw none of the bones these globs match — `--bone` the other way round, applied after it, so `--bone 'bang_*' " +
|
|
1090
1092
|
"--hide-bone '*_ctl'` is the fringe chains without their control bones",
|
|
1093
|
+
'overlay-slot':
|
|
1094
|
+
"draw the per-slot views — mesh, hull, weights:<bone>, density and slots — only for the slots these globs match (`*` any run of " +
|
|
1095
|
+
"characters, `?` one, the whole name; comma-separated); the art is every slot's still, since it is what the geometry is read " +
|
|
1096
|
+
"against. Not `--slot`: that flag draws a subset of the art on `render`, by exact name. The legend's meshes[], slots[] and " +
|
|
1097
|
+
"density rows list only the slots kept — the figure's density totals stay over every slot —, a slot number stays its place in " +
|
|
1098
|
+
'the whole draw order, and a pattern that matches no slot is refused naming the slots there are',
|
|
1099
|
+
'hide-overlay-slot':
|
|
1100
|
+
"draw none of the per-slot views for the slots these globs match — `--overlay-slot` the other way round, applied after it, " +
|
|
1101
|
+
"so `--overlay-slot 'hair_*' --hide-overlay-slot hair_back` is the hair but its back layer",
|
|
1091
1102
|
compare:
|
|
1092
1103
|
'a second build to draw beside --candidate, the same view at the same times on one world box and one transform — the union ' +
|
|
1093
1104
|
'of both framings unless --zoom or --viewport pins it — each half labelled with its build and the poser that posed it. A ' +
|
|
@@ -1194,6 +1205,8 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
1194
1205
|
zoom: '<name>[:<pad>]',
|
|
1195
1206
|
bone: '<glob[,glob…]>',
|
|
1196
1207
|
'hide-bone': '<glob[,glob…]>',
|
|
1208
|
+
'overlay-slot': '<glob[,glob…]>',
|
|
1209
|
+
'hide-overlay-slot': '<glob[,glob…]>',
|
|
1197
1210
|
compare: '<dir|skeleton.json>',
|
|
1198
1211
|
record: '<result.json>',
|
|
1199
1212
|
ballot: '<ballot.html>',
|
|
@@ -1582,9 +1595,10 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1582
1595
|
'rigc inspect … --animation <name> --at <s[,s…]> [--fps 12] [--skin <name>] (one time, or a sheet of several on one grid)',
|
|
1583
1596
|
'rigc inspect … --zoom <bone|slot>[:<pad>] | --viewport <x,y,w,h> (a box, in world units)',
|
|
1584
1597
|
"rigc inspect … --bone 'bang_*' --hide-bone '*_ctl' (which bones bones, axes and names draw)",
|
|
1598
|
+
'rigc inspect … --overlay-slot neck --hide-overlay-slot <glob> (which slots mesh, hull, weights and slots draw; the art stays whole)',
|
|
1585
1599
|
'rigc inspect … --compare <dir | skeleton.json> (a second build beside the first, on one transform)',
|
|
1586
1600
|
],
|
|
1587
|
-
flags: ['candidate', 'atlas', 'show', 'dim', 'animation', 'at', 'fps', 'skin', 'zoom', 'viewport', 'bone', 'hide-bone', 'compare', 'max', 'poser', 'out'],
|
|
1601
|
+
flags: ['candidate', 'atlas', 'show', 'dim', 'animation', 'at', 'fps', 'skin', 'zoom', 'viewport', 'bone', 'hide-bone', 'overlay-slot', 'hide-overlay-slot', 'compare', 'max', 'poser', 'out'],
|
|
1588
1602
|
overrides: {
|
|
1589
1603
|
animation: { meaning: 'the animation --at reads its times from (default: the setup pose, with no --at)' },
|
|
1590
1604
|
fps: { meaning: `the frame grid --at is read on, which is the grid \`render --fps\` draws (default ${INSPECT_DEFAULT_FPS})` },
|
package/src/inspect.ts
CHANGED
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
* the one a viewport pins), so a mark that sits on a different pixel in the two
|
|
37
37
|
* halves sits at a different place in the two rigs.
|
|
38
38
|
*
|
|
39
|
+
* ⭐ **Density is a measurement, never a verdict** (issue #1371). A part's
|
|
40
|
+
* triangles over its posed area, against the figure's — the triangles and area
|
|
41
|
+
* of every slot the pose shows, whatever a slot filter keeps. No bar, no
|
|
42
|
+
* budget: `A13_MESH_BUDGET` is the one gate on counts, and this only says where
|
|
43
|
+
* the triangles are.
|
|
44
|
+
*
|
|
39
45
|
* Links nothing from the runtime: the poser is handed in.
|
|
40
46
|
*/
|
|
41
47
|
import { createHash } from 'node:crypto';
|
|
@@ -69,7 +75,7 @@ import {
|
|
|
69
75
|
export const INSPECT_SPEC = 'rigc-inspect/1';
|
|
70
76
|
|
|
71
77
|
/** 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;
|
|
78
|
+
export const SHOW_WORDS = ['art', 'grid', 'density', 'weights', 'mesh', 'hull', 'declared', 'bones', 'axes', 'slots', 'names'] as const;
|
|
73
79
|
export type ShowWord = (typeof SHOW_WORDS)[number];
|
|
74
80
|
|
|
75
81
|
/** The words as a refusal lists them — `weights` with the bone it needs. */
|
|
@@ -112,6 +118,32 @@ export const WEIGHT_RAMP: ReadonlyArray<{ weight: number; rgba: readonly [number
|
|
|
112
118
|
{ weight: 1, rgba: [215, 20, 20, 255] },
|
|
113
119
|
];
|
|
114
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The density ramp (issue #1371): a part's ratio — its triangles per world
|
|
123
|
+
* unit² over the figure's — painted at its colour here, interpolated **in log₂
|
|
124
|
+
* of the ratio** between the two stops either side, each channel rounded, and
|
|
125
|
+
* clamped to the end stops outside 1/16..16. Log because the ratio is a
|
|
126
|
+
* quotient: 4× and 1/4× are the same distance from the figure, and a linear
|
|
127
|
+
* ramp would spend half its colours between 8× and 16×.
|
|
128
|
+
*
|
|
129
|
+
* ⭐ **1× is a mid grey, neither hue, and not the background.** A part as dense
|
|
130
|
+
* as the figure reads as neutral, and is still visibly a part (the background is
|
|
131
|
+
* `[232, 232, 232]`, `--dim`'s art is paler still).
|
|
132
|
+
*
|
|
133
|
+
* ⭐ **Green to magenta, never blue to red** — ColorBrewer's PiYG ends with
|
|
134
|
+
* the light stops between them. The weights ramp says blue is 0 and red is 1,
|
|
135
|
+
* and an agent may read the two pictures side by side; a density ramp that ran
|
|
136
|
+
* blue to red would put "sparse" and "weight 0" in one colour. Neither end is a
|
|
137
|
+
* mark colour either: the mesh edge and hull drawn over a fill stay readable.
|
|
138
|
+
*/
|
|
139
|
+
export const DENSITY_RAMP: ReadonlyArray<{ ratio: number; rgba: readonly [number, number, number, number] }> = [
|
|
140
|
+
{ ratio: 1 / 16, rgba: [39, 100, 25, 255] },
|
|
141
|
+
{ ratio: 1 / 4, rgba: [161, 215, 106, 255] },
|
|
142
|
+
{ ratio: 1, rgba: [185, 185, 185, 255] },
|
|
143
|
+
{ ratio: 4, rgba: [233, 163, 201, 255] },
|
|
144
|
+
{ ratio: 16, rgba: [142, 1, 82, 255] },
|
|
145
|
+
];
|
|
146
|
+
|
|
115
147
|
/** 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
148
|
export const WEIGHT_FLOOR = 1e-6;
|
|
117
149
|
|
|
@@ -170,6 +202,19 @@ export interface BoneFilter {
|
|
|
170
202
|
exclude?: readonly string[];
|
|
171
203
|
}
|
|
172
204
|
|
|
205
|
+
/**
|
|
206
|
+
* `--overlay-slot` / `--hide-overlay-slot`: which slots the per-slot views
|
|
207
|
+
* (`mesh`, `hull`, `weights:<bone>`, `density`, `slots`) draw, by glob. The art
|
|
208
|
+
* is not filtered — it is what the geometry is read against — and neither is the
|
|
209
|
+
* figure a density is measured against.
|
|
210
|
+
*/
|
|
211
|
+
export interface SlotFilter {
|
|
212
|
+
/** Draw only the slots some pattern matches; absent draws every slot. */
|
|
213
|
+
include?: readonly string[];
|
|
214
|
+
/** Draw none of the slots a pattern matches — applied after `include`. */
|
|
215
|
+
exclude?: readonly string[];
|
|
216
|
+
}
|
|
217
|
+
|
|
173
218
|
/** One `rigc inspect` invocation, its flags read but not yet checked against the build. */
|
|
174
219
|
export interface InspectRequest {
|
|
175
220
|
show: readonly ShowWord[];
|
|
@@ -177,6 +222,8 @@ export interface InspectRequest {
|
|
|
177
222
|
weightsBone?: string;
|
|
178
223
|
/** Which bones `bones`, `axes` and `names` draw; absent is every one. */
|
|
179
224
|
boneFilter?: BoneFilter;
|
|
225
|
+
/** Which slots `mesh`, `hull`, `weights`, `density` and `slots` draw; absent is every one. */
|
|
226
|
+
slotFilter?: SlotFilter;
|
|
180
227
|
dim: boolean;
|
|
181
228
|
/** `null` is the setup pose. */
|
|
182
229
|
animation: string | null;
|
|
@@ -232,8 +279,18 @@ export interface InspectPlant {
|
|
|
232
279
|
noAvoid?: boolean;
|
|
233
280
|
/** Draw every bone whatever `--bone`/`--hide-bone` say. */
|
|
234
281
|
ignoreFilter?: boolean;
|
|
282
|
+
/** Draw every slot's geometry whatever `--overlay-slot`/`--hide-overlay-slot` say. */
|
|
283
|
+
ignoreSlotFilter?: boolean;
|
|
235
284
|
/** Draw the first build in both halves of a `--compare`. */
|
|
236
285
|
compareFirstTwice?: boolean;
|
|
286
|
+
/** Density: take a part's area as its posed vertices' bounding box — the image's box, for an unrotated region — rather than its triangles'. */
|
|
287
|
+
densityAreaOfBox?: boolean;
|
|
288
|
+
/** Density: state and paint the figure's density over the part's, upside down. */
|
|
289
|
+
densityRatioInverted?: boolean;
|
|
290
|
+
/** Density: interpolate the ramp linearly in the ratio, not in its log. */
|
|
291
|
+
densityLinearRamp?: boolean;
|
|
292
|
+
/** Density: take the figure's totals over the slots the filter keeps, not over every slot. */
|
|
293
|
+
densityTotalsFiltered?: boolean;
|
|
237
294
|
}
|
|
238
295
|
|
|
239
296
|
/** A point in both spaces. */
|
|
@@ -283,7 +340,7 @@ export interface LegendLabel {
|
|
|
283
340
|
overlaps: boolean;
|
|
284
341
|
}
|
|
285
342
|
|
|
286
|
-
/** One region or mesh the frame shows, when `mesh`, `hull` or `
|
|
343
|
+
/** One region or mesh the frame shows, when `mesh`, `hull`, `weights` or `density` is drawn — and, with a slot filter, that it keeps. */
|
|
287
344
|
export interface LegendMesh {
|
|
288
345
|
slot: string;
|
|
289
346
|
attachment: string;
|
|
@@ -330,6 +387,43 @@ export interface LegendDeclared {
|
|
|
330
387
|
bones: Array<{ name: string; degrees: number | null; off: number | null }>;
|
|
331
388
|
}
|
|
332
389
|
|
|
390
|
+
/** One slot's row of the density table: one region or mesh the pose shows. */
|
|
391
|
+
export interface LegendDensitySlot {
|
|
392
|
+
/** Its place in the posed draw order (0 is drawn first) — the number `slots` draws, filtered or not. */
|
|
393
|
+
order: number;
|
|
394
|
+
slot: string;
|
|
395
|
+
attachment: string;
|
|
396
|
+
kind: 'region' | 'mesh';
|
|
397
|
+
/** The slot colour × attachment colour alpha at this pose: a part at 0 is in the totals and draws nothing. */
|
|
398
|
+
alpha: number;
|
|
399
|
+
vertices: number;
|
|
400
|
+
triangles: number;
|
|
401
|
+
/** A mesh's hull vertex count; `null` for a region. */
|
|
402
|
+
hull: number | null;
|
|
403
|
+
/** The posed area: the sum of its triangles' absolute areas, world units². */
|
|
404
|
+
area: number;
|
|
405
|
+
/** `triangles / area`, per world unit²; `null` with `why` when the area is 0. */
|
|
406
|
+
trianglesPerArea: number | null;
|
|
407
|
+
/** `trianglesPerArea` over the figure's: the number to read. `null` with `why`. */
|
|
408
|
+
ratio: number | null;
|
|
409
|
+
/** The colour the part is painted, or `null` when it is not (no ratio, or a slot filter left it out of the picture — then it is not listed). */
|
|
410
|
+
rgba: number[] | null;
|
|
411
|
+
/** How many bones its vertices are weighted to (weight above 0) — a region's or an unweighted mesh's is its slot's one bone. */
|
|
412
|
+
bones: number;
|
|
413
|
+
/** Those bones, in the order the skeleton declares them. */
|
|
414
|
+
boneNames: string[];
|
|
415
|
+
/** `null`, or why `trianglesPerArea` or `ratio` has no value. */
|
|
416
|
+
why: string | null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The figure's density at one tile's pose, and each kept slot's row. */
|
|
420
|
+
export interface LegendDensity {
|
|
421
|
+
/** Over EVERY slot whose attachment at this pose is a region or mesh, whatever a slot filter keeps. */
|
|
422
|
+
figure: { slots: number; triangles: number; area: number; trianglesPerArea: number | null; why: string | null };
|
|
423
|
+
/** The slots the filter keeps, in the posed draw order. */
|
|
424
|
+
slots: LegendDensitySlot[];
|
|
425
|
+
}
|
|
426
|
+
|
|
333
427
|
export interface LegendTile {
|
|
334
428
|
/** With `--compare`: which build this tile is — `A` is `--candidate`, `B` is `--compare`. */
|
|
335
429
|
build?: 'A' | 'B';
|
|
@@ -348,6 +442,8 @@ export interface LegendTile {
|
|
|
348
442
|
labels?: LegendLabel[];
|
|
349
443
|
meshes?: LegendMesh[];
|
|
350
444
|
slots?: LegendSlot[];
|
|
445
|
+
/** With `density`: the figure's totals and every kept slot's row. */
|
|
446
|
+
density?: LegendDensity;
|
|
351
447
|
declared?: LegendDeclared[];
|
|
352
448
|
grid?: { step: number; x: number[]; y: number[] };
|
|
353
449
|
}
|
|
@@ -367,8 +463,12 @@ export interface InspectLegend {
|
|
|
367
463
|
| { kind: 'viewport' };
|
|
368
464
|
/** `--bone` / `--hide-bone` as given — each bone they leave out says so in its `undrawn`. */
|
|
369
465
|
boneFilter?: { include: string[] | null; exclude: string[] | null };
|
|
466
|
+
/** `--overlay-slot` / `--hide-overlay-slot` as given — `meshes[]` and `slots[]` then list only the slots they keep. */
|
|
467
|
+
slotFilter?: { include: string[] | null; exclude: string[] | null };
|
|
370
468
|
/** With `weights:<bone>`: the bone, the ramp a weight is painted with, and how a pixel's weight is read. */
|
|
371
469
|
weights?: { bone: string; ramp: Array<{ weight: number; rgba: number[] }>; floor: number; reading: string };
|
|
470
|
+
/** With `density`: the ramp, its scale, and how area, totals and ratio are read. */
|
|
471
|
+
density?: { ramp: Array<{ ratio: number; rgba: number[] }>; scale: string; area: string; totals: string; ratio: string; reading: string };
|
|
372
472
|
/** With `--compare`: each build, the poser that posed it and its `skeleton.json`'s sha256; a tile names its own in `build`. */
|
|
373
473
|
builds?: Array<{ build: 'A' | 'B'; poser: 'core' | 'spine'; skeletonSha256: string }>;
|
|
374
474
|
coordinates: { world: string; image: string; toPixel: string; toWorld: string };
|
|
@@ -434,10 +534,10 @@ function showSpelled(request: InspectRequest, weightsBone: string | undefined):
|
|
|
434
534
|
* characters and `?` one, matched against a bone's whole name. Checked against
|
|
435
535
|
* the build later, where a pattern that matches no bone is refused.
|
|
436
536
|
*/
|
|
437
|
-
export function readBonePatterns(flag: string, raw: string | undefined): string[] | undefined {
|
|
537
|
+
export function readBonePatterns(flag: string, raw: string | undefined, example = 'bang_*,*_ctl'): string[] | undefined {
|
|
438
538
|
if (raw === undefined) return undefined;
|
|
439
539
|
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} '
|
|
540
|
+
if (patterns.some((p) => p === '')) throw new InspectError(`--${flag} ${JSON.stringify(raw)}: an empty pattern; it takes globs, comma-separated, e.g. --${flag} '${example}'`);
|
|
441
541
|
return patterns;
|
|
442
542
|
}
|
|
443
543
|
|
|
@@ -730,6 +830,136 @@ function heatTriangle(plate: Plate, p: ReadonlyArray<[number, number]>, w: reado
|
|
|
730
830
|
}
|
|
731
831
|
}
|
|
732
832
|
|
|
833
|
+
/**
|
|
834
|
+
* The density ramp's colour at `ratio`: interpolated in log₂ of the ratio
|
|
835
|
+
* between the two stops either side, each channel rounded, clamped to the end
|
|
836
|
+
* stops. `linear` is the mutant's reading — interpolated in the ratio itself.
|
|
837
|
+
*/
|
|
838
|
+
export function densityColour(ratio: number, linear = false): RGBA {
|
|
839
|
+
const at = (r: number): number => (linear ? r : Math.log2(r));
|
|
840
|
+
const first = DENSITY_RAMP[0];
|
|
841
|
+
const last = DENSITY_RAMP[DENSITY_RAMP.length - 1];
|
|
842
|
+
if (ratio <= first.ratio) return [...first.rgba] as RGBA;
|
|
843
|
+
if (ratio >= last.ratio) return [...last.rgba] as RGBA;
|
|
844
|
+
for (let i = 0; i + 1 < DENSITY_RAMP.length; i++) {
|
|
845
|
+
const lo = DENSITY_RAMP[i];
|
|
846
|
+
const hi = DENSITY_RAMP[i + 1];
|
|
847
|
+
if (ratio > hi.ratio) continue;
|
|
848
|
+
const t = (at(ratio) - at(lo.ratio)) / (at(hi.ratio) - at(lo.ratio));
|
|
849
|
+
return [0, 1, 2, 3].map((c) => Math.round(lo.rgba[c] + (hi.rgba[c] - lo.rgba[c]) * t)) as RGBA;
|
|
850
|
+
}
|
|
851
|
+
return [...last.rgba] as RGBA;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** A posed attachment's area: the sum of its triangles' absolute areas, in the units its vertices are in. */
|
|
855
|
+
export function trianglesArea(vertices: readonly number[], triangles: readonly number[]): number {
|
|
856
|
+
let sum = 0;
|
|
857
|
+
for (let t = 0; t + 2 < triangles.length; t += 3) {
|
|
858
|
+
const [a, b, c] = [triangles[t] * 2, triangles[t + 1] * 2, triangles[t + 2] * 2];
|
|
859
|
+
sum += Math.abs((vertices[b] - vertices[a]) * (vertices[c + 1] - vertices[a + 1]) - (vertices[c] - vertices[a]) * (vertices[b + 1] - vertices[a + 1])) / 2;
|
|
860
|
+
}
|
|
861
|
+
return sum;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** The bounding box of the vertices, as an area — the density mutant's wrong reading of a part's size. */
|
|
865
|
+
function boxArea(vertices: readonly number[]): number {
|
|
866
|
+
const xs = vertices.filter((_, i) => i % 2 === 0);
|
|
867
|
+
const ys = vertices.filter((_, i) => i % 2 === 1);
|
|
868
|
+
return (Math.max(...xs) - Math.min(...xs)) * (Math.max(...ys) - Math.min(...ys));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Every pixel whose centre the triangle holds, painted one colour — the heat's inside test. */
|
|
872
|
+
function fillTriangle(plate: Plate, p: ReadonlyArray<[number, number]>, rgba: RGBA): void {
|
|
873
|
+
const [[x0, y0], [x1, y1], [x2, y2]] = p;
|
|
874
|
+
const area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
|
|
875
|
+
if (area === 0) return;
|
|
876
|
+
const left = Math.max(0, Math.floor(Math.min(x0, x1, x2)));
|
|
877
|
+
const right = Math.min(plate.width - 1, Math.floor(Math.max(x0, x1, x2)));
|
|
878
|
+
const top = Math.max(0, Math.floor(Math.min(y0, y1, y2)));
|
|
879
|
+
const bottom = Math.min(plate.height - 1, Math.floor(Math.max(y0, y1, y2)));
|
|
880
|
+
const inside = -1e-6;
|
|
881
|
+
for (let py = top; py <= bottom; py++) {
|
|
882
|
+
for (let px = left; px <= right; px++) {
|
|
883
|
+
const cx = px + 0.5;
|
|
884
|
+
const cy = py + 0.5;
|
|
885
|
+
const b0 = ((x1 - cx) * (y2 - cy) - (x2 - cx) * (y1 - cy)) / area;
|
|
886
|
+
const b1 = ((x2 - cx) * (y0 - cy) - (x0 - cx) * (y2 - cy)) / area;
|
|
887
|
+
if (b0 < inside || b1 < inside || 1 - b0 - b1 < inside) continue;
|
|
888
|
+
put(plate, px, py, rgba);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* The density table at one pose: every region and mesh the pose shows, in the
|
|
895
|
+
* posed draw order, with the figure's totals over all of them — the slot filter
|
|
896
|
+
* decides which rows are listed and painted, never what the figure is. Read,
|
|
897
|
+
* never filled in: a part whose posed area is 0 has no density and says why,
|
|
898
|
+
* and a figure with no area has none either.
|
|
899
|
+
*/
|
|
900
|
+
function densityAt(
|
|
901
|
+
attachments: readonly AttachmentPose[],
|
|
902
|
+
rest: ReadonlyMap<string, AttachmentRest>,
|
|
903
|
+
weights: (slot: string, attachment: string) => VertexWeights | string,
|
|
904
|
+
boneOrder: readonly string[],
|
|
905
|
+
slotsOut: ReadonlySet<string>,
|
|
906
|
+
plant: InspectPlant,
|
|
907
|
+
): LegendDensity {
|
|
908
|
+
const rows: Array<LegendDensitySlot & { kept: boolean }> = [];
|
|
909
|
+
for (const [order, shown] of attachments.entries()) {
|
|
910
|
+
const r = rest.get(restKey(shown.slot, shown.attachment));
|
|
911
|
+
if (r === undefined) throw new Error(`slot "${shown.slot}" attachment "${shown.attachment}": the rest table has no entry for it — the poser disagrees with itself`);
|
|
912
|
+
const read = weights(shown.slot, shown.attachment);
|
|
913
|
+
if (typeof read === 'string') throw new InspectError(`--show density: ${read}`);
|
|
914
|
+
const named = new Set<string>();
|
|
915
|
+
for (const run of read) for (const [bone, w] of run) if (w > 0) named.add(bone);
|
|
916
|
+
const boneNames = boneOrder.filter((b) => named.has(b));
|
|
917
|
+
const triangles = r.triangles.length / 3;
|
|
918
|
+
const area = plant.densityAreaOfBox ? boxArea(shown.vertices) : trianglesArea(shown.vertices, r.triangles);
|
|
919
|
+
rows.push({
|
|
920
|
+
order,
|
|
921
|
+
slot: shown.slot,
|
|
922
|
+
attachment: shown.attachment,
|
|
923
|
+
kind: r.kind,
|
|
924
|
+
alpha: shown.color[3],
|
|
925
|
+
vertices: shown.vertices.length / 2,
|
|
926
|
+
triangles,
|
|
927
|
+
hull: r.kind === 'mesh' ? (r.hull ?? null) : null,
|
|
928
|
+
area,
|
|
929
|
+
trianglesPerArea: area > 0 ? triangles / area : null,
|
|
930
|
+
ratio: null,
|
|
931
|
+
rgba: null,
|
|
932
|
+
bones: boneNames.length,
|
|
933
|
+
boneNames,
|
|
934
|
+
why: area > 0 ? null : 'its posed area is 0 — every triangle collapsed to a line or a point at this pose (a scale of 0 does), so it covers nothing and has no density',
|
|
935
|
+
kept: !slotsOut.has(shown.slot),
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
const over = plant.densityTotalsFiltered ? rows.filter((r) => r.kept) : rows;
|
|
939
|
+
const triangles = over.reduce((n, r) => n + r.triangles, 0);
|
|
940
|
+
const area = over.reduce((n, r) => n + r.area, 0);
|
|
941
|
+
const figurePerArea = area > 0 ? triangles / area : null;
|
|
942
|
+
const figure = {
|
|
943
|
+
slots: over.length,
|
|
944
|
+
triangles,
|
|
945
|
+
area,
|
|
946
|
+
trianglesPerArea: figurePerArea,
|
|
947
|
+
why: figurePerArea !== null ? null : over.length === 0 ? 'the pose shows no region or mesh' : "every part's posed area is 0, so the figure covers nothing and has no density",
|
|
948
|
+
};
|
|
949
|
+
const slots: LegendDensitySlot[] = [];
|
|
950
|
+
for (const { kept, ...row } of rows) {
|
|
951
|
+
if (!kept) continue;
|
|
952
|
+
if (row.trianglesPerArea !== null && figurePerArea !== null) {
|
|
953
|
+
row.ratio = plant.densityRatioInverted ? figurePerArea / row.trianglesPerArea : row.trianglesPerArea / figurePerArea;
|
|
954
|
+
row.rgba = [...densityColour(row.ratio, plant.densityLinearRamp === true)];
|
|
955
|
+
} else if (row.why === null) {
|
|
956
|
+
row.why = `the figure has no density: ${figure.why ?? ''}`;
|
|
957
|
+
}
|
|
958
|
+
slots.push(row);
|
|
959
|
+
}
|
|
960
|
+
return { figure, slots };
|
|
961
|
+
}
|
|
962
|
+
|
|
733
963
|
/** Two pixel boxes overlap, or sit with no clear pixel between them. */
|
|
734
964
|
function crowds(a: PixelBox, b: PixelBox): boolean {
|
|
735
965
|
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;
|
|
@@ -776,6 +1006,31 @@ function filterOut(bones: ReadonlyArray<{ name: string }>, filter: BoneFilter |
|
|
|
776
1006
|
return out;
|
|
777
1007
|
}
|
|
778
1008
|
|
|
1009
|
+
/**
|
|
1010
|
+
* `--overlay-slot` / `--hide-overlay-slot` against one build's slots: the slots
|
|
1011
|
+
* whose geometry is not drawn. A pattern matching no slot is refused naming the
|
|
1012
|
+
* slots there are — checked even when `apply` is off, so a mutant that ignores
|
|
1013
|
+
* the filter still refuses a typo.
|
|
1014
|
+
*/
|
|
1015
|
+
function slotsLeftOut(slots: ReadonlyArray<{ name: string }>, filter: SlotFilter | undefined, apply: boolean): Set<string> {
|
|
1016
|
+
const out = new Set<string>();
|
|
1017
|
+
if (filter === undefined) return out;
|
|
1018
|
+
const names = slots.map((s) => s.name);
|
|
1019
|
+
const matchers = (flag: string, patterns: readonly string[]): Array<(name: string) => boolean> =>
|
|
1020
|
+
patterns.map((p) => {
|
|
1021
|
+
const m = globMatcher(p);
|
|
1022
|
+
if (!names.some(m)) throw new InspectError(`--${flag} ${JSON.stringify(p)} matches no slot; the slots are [${names.join(', ')}]`);
|
|
1023
|
+
return m;
|
|
1024
|
+
});
|
|
1025
|
+
const include = filter.include === undefined ? null : matchers('overlay-slot', filter.include);
|
|
1026
|
+
const exclude = filter.exclude === undefined ? null : matchers('hide-overlay-slot', filter.exclude);
|
|
1027
|
+
if (!apply) return out;
|
|
1028
|
+
for (const name of names) {
|
|
1029
|
+
if ((include !== null && !include.some((m) => m(name))) || (exclude !== null && exclude.some((m) => m(name)))) out.add(name);
|
|
1030
|
+
}
|
|
1031
|
+
return out;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
779
1034
|
// ---------------------------------------------------------------------------
|
|
780
1035
|
// the view
|
|
781
1036
|
// ---------------------------------------------------------------------------
|
|
@@ -882,7 +1137,7 @@ interface Gathered {
|
|
|
882
1137
|
sha: string;
|
|
883
1138
|
}
|
|
884
1139
|
|
|
885
|
-
const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights'];
|
|
1140
|
+
const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights', 'density'];
|
|
886
1141
|
const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
|
|
887
1142
|
|
|
888
1143
|
/** A refusal about the second build says so: the same sentence, under the name of the build it is about. */
|
|
@@ -928,7 +1183,7 @@ function gather(tag: 'A' | 'B', candidate: Candidate, texts: InspectTexts, reque
|
|
|
928
1183
|
pages,
|
|
929
1184
|
lengths,
|
|
930
1185
|
axes,
|
|
931
|
-
weights: request.show.includes('weights') ? weightsReader(texts.skeletonText, request.skin) : null,
|
|
1186
|
+
weights: request.show.includes('weights') || request.show.includes('density') ? weightsReader(texts.skeletonText, request.skin) : null,
|
|
932
1187
|
sha: createHash('sha256').update(texts.skeletonText).digest('hex'),
|
|
933
1188
|
};
|
|
934
1189
|
});
|
|
@@ -970,6 +1225,7 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
|
|
|
970
1225
|
}
|
|
971
1226
|
}
|
|
972
1227
|
const filtered = builds.map((b) => aboutBuild(b.tag, () => filterOut(b.bones, request.boneFilter, plant.ignoreFilter !== true)));
|
|
1228
|
+
const slotsOut = builds.map((b) => aboutBuild(b.tag, () => slotsLeftOut(b.slots, request.slotFilter, plant.ignoreSlotFilter !== true)));
|
|
973
1229
|
// 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
1230
|
let viewport: Viewport;
|
|
975
1231
|
let framing: InspectLegend['framing'];
|
|
@@ -1034,19 +1290,20 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
|
|
|
1034
1290
|
const row = compare ? Math.floor(k / pairs) : Math.floor(k / columns);
|
|
1035
1291
|
const x0 = gap + column * (width + gap);
|
|
1036
1292
|
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);
|
|
1293
|
+
const drawnTile = drawTile(build, build.poses[k], filtered[bi], slotsOut[bi], { x0, y0, viewport, sheet, compare, weightsBone, first: k === 0 && bi === 0 }, request, plant, notes);
|
|
1038
1294
|
if (drawnTile.weighted) weighted[bi] = true;
|
|
1039
1295
|
if (drawnTile.anyMesh) anyMesh = true;
|
|
1040
1296
|
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
1297
|
tiles.push(drawnTile.legend);
|
|
1042
1298
|
}
|
|
1043
1299
|
}
|
|
1300
|
+
// With a slot filter the sentence says the filter is part of why: the same build unfiltered may paint.
|
|
1044
1301
|
if (weightsBone !== undefined) {
|
|
1045
1302
|
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`);
|
|
1303
|
+
if (!weighted[bi]) notes.push(`weights: bone "${weightsBone}" weights no vertex of any region or mesh${request.slotFilter === undefined ? '' : ' of the slots the filter keeps'} ${compare ? `build ${b.tag} ` : ''}shows here — nothing painted`);
|
|
1047
1304
|
}
|
|
1048
1305
|
}
|
|
1049
|
-
if ((request.show.includes('mesh') || request.show.includes('hull')) && !anyMesh) notes.push(
|
|
1306
|
+
if ((request.show.includes('mesh') || request.show.includes('hull')) && !anyMesh) notes.push(`mesh/hull: no slot${request.slotFilter === undefined ? '' : ' the filter keeps'} shows a mesh here — nothing drawn for it`);
|
|
1050
1307
|
const legend: InspectLegend = {
|
|
1051
1308
|
spec: INSPECT_SPEC,
|
|
1052
1309
|
poser: builds[0].poser,
|
|
@@ -1059,6 +1316,9 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
|
|
|
1059
1316
|
...(request.boneFilter === undefined
|
|
1060
1317
|
? {}
|
|
1061
1318
|
: { boneFilter: { include: request.boneFilter.include === undefined ? null : [...request.boneFilter.include], exclude: request.boneFilter.exclude === undefined ? null : [...request.boneFilter.exclude] } }),
|
|
1319
|
+
...(request.slotFilter === undefined
|
|
1320
|
+
? {}
|
|
1321
|
+
: { slotFilter: { include: request.slotFilter.include === undefined ? null : [...request.slotFilter.include], exclude: request.slotFilter.exclude === undefined ? null : [...request.slotFilter.exclude] } }),
|
|
1062
1322
|
...(weightsBone === undefined
|
|
1063
1323
|
? {}
|
|
1064
1324
|
: {
|
|
@@ -1072,6 +1332,30 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
|
|
|
1072
1332
|
"Each vertex's weight is in its tile's meshes[].weights, read off skeleton.json",
|
|
1073
1333
|
},
|
|
1074
1334
|
}),
|
|
1335
|
+
...(request.show.includes('density')
|
|
1336
|
+
? {
|
|
1337
|
+
density: {
|
|
1338
|
+
ramp: DENSITY_RAMP.map((s) => ({ ratio: s.ratio, rgba: [...s.rgba] })),
|
|
1339
|
+
scale:
|
|
1340
|
+
"log2: a ratio's colour is interpolated in log2(ratio) between the two stops either side, each channel rounded; " +
|
|
1341
|
+
`at or below ${DENSITY_RAMP[0].ratio} and at or above ${DENSITY_RAMP[DENSITY_RAMP.length - 1].ratio} it is the end stop's. ` +
|
|
1342
|
+
'1, as dense as the figure, is the neutral grey',
|
|
1343
|
+
area:
|
|
1344
|
+
"a part's area is its posed mesh's own: the sum of its triangles' absolute areas in world units², through the vertices " +
|
|
1345
|
+
"the pose carries — not the image's box and not its alpha. A region is two triangles over its quad",
|
|
1346
|
+
totals:
|
|
1347
|
+
"the figure's are over EVERY slot whose attachment at the pose is a region or mesh — whatever --overlay-slot and " +
|
|
1348
|
+
'--hide-overlay-slot keep, whatever its alpha, wherever it lies against the box drawn',
|
|
1349
|
+
ratio:
|
|
1350
|
+
"a part's triangles per world unit² over the figure's (Σ triangles / Σ area): 2 is twice as dense as the figure, 0.5 " +
|
|
1351
|
+
"half. null where a part's posed area is 0, with why",
|
|
1352
|
+
reading:
|
|
1353
|
+
"each kept part is filled with its ratio's colour — every pixel whose centre lies in one of its triangles — in the " +
|
|
1354
|
+
"posed draw order, so where parts overlap the pixel is the part drawn last, as in the art. Each tile's density " +
|
|
1355
|
+
"states the figure and every kept part's row, in draw order",
|
|
1356
|
+
},
|
|
1357
|
+
}
|
|
1358
|
+
: {}),
|
|
1075
1359
|
...(compare ? { builds: builds.map((b) => ({ build: b.tag, poser: b.poser, skeletonSha256: b.sha })) } : {}),
|
|
1076
1360
|
coordinates: {
|
|
1077
1361
|
world: 'spine world: y up, world units',
|
|
@@ -1103,6 +1387,7 @@ function drawTile(
|
|
|
1103
1387
|
build: Gathered,
|
|
1104
1388
|
pose: { frame: Frame; index: number },
|
|
1105
1389
|
filtered: Map<string, string>,
|
|
1390
|
+
slotsOut: ReadonlySet<string>,
|
|
1106
1391
|
place: TilePlace,
|
|
1107
1392
|
request: InspectRequest,
|
|
1108
1393
|
plant: InspectPlant,
|
|
@@ -1160,10 +1445,42 @@ function drawTile(
|
|
|
1160
1445
|
// ── what a bone moves: weights under the edges, the edges under the hull ──
|
|
1161
1446
|
let weighted = false;
|
|
1162
1447
|
let anyMesh = false;
|
|
1448
|
+
// ── density: each kept part filled with its ratio's colour, under the heat and the edges ──
|
|
1449
|
+
if (build.rest !== null && build.weights !== null && request.show.includes('density')) {
|
|
1450
|
+
const shownAll = frame.attachments ?? [];
|
|
1451
|
+
const density = densityAt(shownAll, build.rest, build.weights, build.bones.map((b) => b.name), slotsOut, plant);
|
|
1452
|
+
legendTile.density = density;
|
|
1453
|
+
for (const row of density.slots) {
|
|
1454
|
+
const shown = shownAll[row.order];
|
|
1455
|
+
const rest = build.rest.get(restKey(shown.slot, shown.attachment));
|
|
1456
|
+
if (rest === undefined || row.rgba === null) continue;
|
|
1457
|
+
const rgba = row.rgba as RGBA;
|
|
1458
|
+
const at = (v: number): [number, number] => {
|
|
1459
|
+
const [px, py] = toPixel(shown.vertices[2 * v], shown.vertices[2 * v + 1]);
|
|
1460
|
+
return [tx(px), ty(py)];
|
|
1461
|
+
};
|
|
1462
|
+
for (let t = 0; t + 2 < rest.triangles.length; t += 3) fillTriangle(tile, [at(rest.triangles[t]), at(rest.triangles[t + 1]), at(rest.triangles[t + 2])], rgba);
|
|
1463
|
+
}
|
|
1464
|
+
if (first) {
|
|
1465
|
+
const f = density.figure;
|
|
1466
|
+
const fmt = (n: number): string => String(Number(n.toPrecision(4)));
|
|
1467
|
+
if (f.trianglesPerArea === null) notes.push(`density: ${f.why ?? ''} — nothing painted`);
|
|
1468
|
+
else {
|
|
1469
|
+
const by = density.slots.filter((r) => r.ratio !== null).sort((p, q) => (q.ratio ?? 0) - (p.ratio ?? 0) || p.order - q.order);
|
|
1470
|
+
const at = (r: LegendDensitySlot): string => `"${r.slot}" at ${fmt(r.ratio ?? 0)}x`;
|
|
1471
|
+
const ends =
|
|
1472
|
+
by.length === 0 ? '' : by.length === 1 ? `; the one listed, ${at(by[0])}` : `; of the ${density.slots.length} listed, the densest ${at(by[0])}, the sparsest ${at(by[by.length - 1])}`;
|
|
1473
|
+
notes.push(`density: the figure is ${f.triangles} triangles over ${Number(f.area.toFixed(2))} world units² across ${f.slots} slots${ends} — every row is in the legend`);
|
|
1474
|
+
const unrated = density.slots.filter((r) => r.ratio === null);
|
|
1475
|
+
if (unrated.length > 0) notes.push(`density: no ratio for ${unrated.map((r) => `"${r.slot}"`).join(', ')} — ${unrated[0].why ?? ''}`);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1163
1479
|
if (build.rest !== null) {
|
|
1164
1480
|
legendTile.meshes = [];
|
|
1165
1481
|
const [mx, my] = plant.meshShift ?? [0, 0];
|
|
1166
1482
|
for (const shown of frame.attachments ?? []) {
|
|
1483
|
+
if (slotsOut.has(shown.slot)) continue;
|
|
1167
1484
|
const rest = build.rest.get(restKey(shown.slot, shown.attachment));
|
|
1168
1485
|
const vertexCount = shown.vertices.length / 2;
|
|
1169
1486
|
if (rest === undefined || rest.vertices.length !== shown.vertices.length) {
|
|
@@ -1314,6 +1631,8 @@ function drawTile(
|
|
|
1314
1631
|
legendTile.slots = [];
|
|
1315
1632
|
const shown = frame.attachments ?? [];
|
|
1316
1633
|
for (const [i, att] of shown.entries()) {
|
|
1634
|
+
// A left-out slot keeps its place: a number is the slot's place in the whole draw order, filtered or not.
|
|
1635
|
+
if (slotsOut.has(att.slot)) continue;
|
|
1317
1636
|
const order = plant.slotsReversed ? shown.length - 1 - i : i;
|
|
1318
1637
|
let minX = Infinity;
|
|
1319
1638
|
let minY = Infinity;
|