spine-rigc 2.43.0 → 2.45.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 +2 -1
- package/docs/AUTHORING.md +16 -3
- package/package.json +1 -1
- package/src/cli/shared.ts +7 -5
- package/src/inspect.ts +274 -7
- package/src/meshquality.ts +63 -1
- package/src/meshreduce.ts +96 -1
package/README.md
CHANGED
|
@@ -412,12 +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
420
|
bones by glob, `--overlay-slot`/`--hide-overlay-slot` the slots whose mesh, hull,
|
|
420
|
-
weights and number are drawn over the whole painting, and `--compare <dir>` draws a second build beside the first on one
|
|
421
|
+
weights, density and number are drawn over the whole painting, and `--compare <dir>` draws a second build beside the first on one
|
|
421
422
|
transform, to see what a change moved. Beside the PNG it writes a legend:
|
|
422
423
|
the world-to-pixel transform both ways, what each colour means, and every bone's
|
|
423
424
|
origin and tip in world units and in pixels, so a position read off the picture goes
|
package/docs/AUTHORING.md
CHANGED
|
@@ -10095,6 +10095,7 @@ pixel (`IN09`).
|
|
|
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
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 |
|
|
10098
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 |
|
|
10099
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 |
|
|
10100
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 |
|
|
@@ -10151,11 +10152,12 @@ pixel (`IN09`).
|
|
|
10151
10152
|
legend, and a left-out one says which flag left it out in `undrawn`. A pattern
|
|
10152
10153
|
that matches no bone is refused naming the bones there are.
|
|
10153
10154
|
- **`--overlay-slot <glob[,glob…]>`** draws the per-slot views — `mesh`, `hull`,
|
|
10154
|
-
`weights:<bone
|
|
10155
|
+
`weights:<bone>`, `density` and `slots` — only for the slots a pattern matches, and
|
|
10155
10156
|
**`--hide-overlay-slot <glob[,glob…]>`** for none of the ones it matches, applied
|
|
10156
10157
|
after `--overlay-slot`; the patterns are globs, as `--bone`'s are. The **art is not
|
|
10157
10158
|
filtered**: the painting is what the edge is read against, and `--dim` already
|
|
10158
|
-
quiets it
|
|
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
|
|
10159
10161
|
keeps its number — its place in the whole draw order, not among the slots drawn. A
|
|
10160
10162
|
pattern that matches no slot is refused naming the slots there are. It is not
|
|
10161
10163
|
`--slot`: on `render` that flag draws a subset of the *art*, by exact name, which is
|
|
@@ -10182,7 +10184,15 @@ pixel (`IN09`).
|
|
|
10182
10184
|
value; with `declared`, each entry's line in degrees, its two points, and each held
|
|
10183
10185
|
bone's angle and how far off the line it is. With `names`, `labels[]`: each label's
|
|
10184
10186
|
`text`, the `bones` it stands for, its `anchor` pixel, the `box` it is drawn in,
|
|
10185
|
-
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`,
|
|
10186
10196
|
`meshes[]`: every region and mesh the pose shows, in draw order, with its `kind`,
|
|
10187
10197
|
`vertexCount`, `triangleCount`, `hull` (a mesh's hull vertex count), its unique
|
|
10188
10198
|
triangle `edges` as vertex pairs, every posed vertex in `world` and `pixel`, and
|
|
@@ -10196,6 +10206,9 @@ pixel (`IN09`).
|
|
|
10196
10206
|
was stated, or `viewport`).
|
|
10197
10207
|
- `weights` — with `weights:<bone>`: the bone, the ramp's stops as RGBA, the floor
|
|
10198
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.
|
|
10199
10212
|
- `boneFilter` — `--bone` and `--hide-bone` as given.
|
|
10200
10213
|
- `slotFilter` — `--overlay-slot` and `--hide-overlay-slot` as given; with it,
|
|
10201
10214
|
`meshes[]` and `slots[]` list only the slots it keeps.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.45.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/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, " +
|
|
@@ -1089,11 +1091,11 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
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",
|
|
1091
1093
|
'overlay-slot':
|
|
1092
|
-
"draw the per-slot views — mesh, hull, weights:<bone
|
|
1094
|
+
"draw the per-slot views — mesh, hull, weights:<bone>, density and slots — only for the slots these globs match (`*` any run of " +
|
|
1093
1095
|
"characters, `?` one, the whole name; comma-separated); the art is every slot's still, since it is what the geometry is read " +
|
|
1094
|
-
"against. Not `--slot`: that flag draws a subset of the art on `render`, by exact name. The legend's meshes[]
|
|
1095
|
-
|
|
1096
|
-
'naming the slots there are',
|
|
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',
|
|
1097
1099
|
'hide-overlay-slot':
|
|
1098
1100
|
"draw none of the per-slot views for the slots these globs match — `--overlay-slot` the other way round, applied after it, " +
|
|
1099
1101
|
"so `--overlay-slot 'hair_*' --hide-overlay-slot hair_back` is the hair but its back layer",
|
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
|
|
|
@@ -172,8 +204,9 @@ export interface BoneFilter {
|
|
|
172
204
|
|
|
173
205
|
/**
|
|
174
206
|
* `--overlay-slot` / `--hide-overlay-slot`: which slots the per-slot views
|
|
175
|
-
* (`mesh`, `hull`, `weights:<bone>`, `slots`) draw, by glob. The art
|
|
176
|
-
* filtered — it is what the geometry is read against
|
|
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.
|
|
177
210
|
*/
|
|
178
211
|
export interface SlotFilter {
|
|
179
212
|
/** Draw only the slots some pattern matches; absent draws every slot. */
|
|
@@ -189,7 +222,7 @@ export interface InspectRequest {
|
|
|
189
222
|
weightsBone?: string;
|
|
190
223
|
/** Which bones `bones`, `axes` and `names` draw; absent is every one. */
|
|
191
224
|
boneFilter?: BoneFilter;
|
|
192
|
-
/** Which slots `mesh`, `hull`, `weights` and `slots` draw; absent is every one. */
|
|
225
|
+
/** Which slots `mesh`, `hull`, `weights`, `density` and `slots` draw; absent is every one. */
|
|
193
226
|
slotFilter?: SlotFilter;
|
|
194
227
|
dim: boolean;
|
|
195
228
|
/** `null` is the setup pose. */
|
|
@@ -250,6 +283,14 @@ export interface InspectPlant {
|
|
|
250
283
|
ignoreSlotFilter?: boolean;
|
|
251
284
|
/** Draw the first build in both halves of a `--compare`. */
|
|
252
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;
|
|
253
294
|
}
|
|
254
295
|
|
|
255
296
|
/** A point in both spaces. */
|
|
@@ -299,7 +340,7 @@ export interface LegendLabel {
|
|
|
299
340
|
overlaps: boolean;
|
|
300
341
|
}
|
|
301
342
|
|
|
302
|
-
/** 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. */
|
|
303
344
|
export interface LegendMesh {
|
|
304
345
|
slot: string;
|
|
305
346
|
attachment: string;
|
|
@@ -346,6 +387,43 @@ export interface LegendDeclared {
|
|
|
346
387
|
bones: Array<{ name: string; degrees: number | null; off: number | null }>;
|
|
347
388
|
}
|
|
348
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
|
+
|
|
349
427
|
export interface LegendTile {
|
|
350
428
|
/** With `--compare`: which build this tile is — `A` is `--candidate`, `B` is `--compare`. */
|
|
351
429
|
build?: 'A' | 'B';
|
|
@@ -364,6 +442,8 @@ export interface LegendTile {
|
|
|
364
442
|
labels?: LegendLabel[];
|
|
365
443
|
meshes?: LegendMesh[];
|
|
366
444
|
slots?: LegendSlot[];
|
|
445
|
+
/** With `density`: the figure's totals and every kept slot's row. */
|
|
446
|
+
density?: LegendDensity;
|
|
367
447
|
declared?: LegendDeclared[];
|
|
368
448
|
grid?: { step: number; x: number[]; y: number[] };
|
|
369
449
|
}
|
|
@@ -387,6 +467,8 @@ export interface InspectLegend {
|
|
|
387
467
|
slotFilter?: { include: string[] | null; exclude: string[] | null };
|
|
388
468
|
/** With `weights:<bone>`: the bone, the ramp a weight is painted with, and how a pixel's weight is read. */
|
|
389
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 };
|
|
390
472
|
/** With `--compare`: each build, the poser that posed it and its `skeleton.json`'s sha256; a tile names its own in `build`. */
|
|
391
473
|
builds?: Array<{ build: 'A' | 'B'; poser: 'core' | 'spine'; skeletonSha256: string }>;
|
|
392
474
|
coordinates: { world: string; image: string; toPixel: string; toWorld: string };
|
|
@@ -748,6 +830,136 @@ function heatTriangle(plate: Plate, p: ReadonlyArray<[number, number]>, w: reado
|
|
|
748
830
|
}
|
|
749
831
|
}
|
|
750
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
|
+
|
|
751
963
|
/** Two pixel boxes overlap, or sit with no clear pixel between them. */
|
|
752
964
|
function crowds(a: PixelBox, b: PixelBox): boolean {
|
|
753
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;
|
|
@@ -925,7 +1137,7 @@ interface Gathered {
|
|
|
925
1137
|
sha: string;
|
|
926
1138
|
}
|
|
927
1139
|
|
|
928
|
-
const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights'];
|
|
1140
|
+
const MESH_VIEWS: readonly ShowWord[] = ['mesh', 'hull', 'weights', 'density'];
|
|
929
1141
|
const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
|
|
930
1142
|
|
|
931
1143
|
/** A refusal about the second build says so: the same sentence, under the name of the build it is about. */
|
|
@@ -971,7 +1183,7 @@ function gather(tag: 'A' | 'B', candidate: Candidate, texts: InspectTexts, reque
|
|
|
971
1183
|
pages,
|
|
972
1184
|
lengths,
|
|
973
1185
|
axes,
|
|
974
|
-
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,
|
|
975
1187
|
sha: createHash('sha256').update(texts.skeletonText).digest('hex'),
|
|
976
1188
|
};
|
|
977
1189
|
});
|
|
@@ -1120,6 +1332,30 @@ function view(builds: readonly Gathered[], request: InspectRequest, plant: Inspe
|
|
|
1120
1332
|
"Each vertex's weight is in its tile's meshes[].weights, read off skeleton.json",
|
|
1121
1333
|
},
|
|
1122
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
|
+
: {}),
|
|
1123
1359
|
...(compare ? { builds: builds.map((b) => ({ build: b.tag, poser: b.poser, skeletonSha256: b.sha })) } : {}),
|
|
1124
1360
|
coordinates: {
|
|
1125
1361
|
world: 'spine world: y up, world units',
|
|
@@ -1209,6 +1445,37 @@ function drawTile(
|
|
|
1209
1445
|
// ── what a bone moves: weights under the edges, the edges under the hull ──
|
|
1210
1446
|
let weighted = false;
|
|
1211
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
|
+
}
|
|
1212
1479
|
if (build.rest !== null) {
|
|
1213
1480
|
legendTile.meshes = [];
|
|
1214
1481
|
const [mx, my] = plant.meshShift ?? [0, 0];
|
package/src/meshquality.ts
CHANGED
|
@@ -306,6 +306,54 @@ export interface MeshReductionInput {
|
|
|
306
306
|
* a list. `protect.edges` keeps its meaning: a line held whole is listed there.
|
|
307
307
|
*/
|
|
308
308
|
lines?: NamedLine[];
|
|
309
|
+
/**
|
|
310
|
+
* What refused each kept source vertex (issue #1374) — opt-in. Left out = the report is the one it was before the
|
|
311
|
+
* field existed, byte for byte, and no refusal is measured that the run would not measure. Set, `changes` ends in
|
|
312
|
+
* `kept` (`KeptRefusals`): for the last removal pass, the one that took nothing, each kept source vertex's reason
|
|
313
|
+
* counted per reason and split boundary / interior — and with `'by-vertex'` also listed vertex by vertex with the
|
|
314
|
+
* refusal's text. A refusal the deviation floor decided is measured in full to be named, in that pass only, as
|
|
315
|
+
* `blockingConstraint`'s is. No step, order or mesh reads it. Any other value, `null` included, is refused.
|
|
316
|
+
*/
|
|
317
|
+
explainKept?: 'by-row' | 'by-vertex';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Issue #1374: why the kept source vertices are kept — `ReductionChanges.kept`, written last in `changes` and only when
|
|
322
|
+
* the input set `explainKept`. The reasons are read from the last removal pass, the one in which no step was taken, so
|
|
323
|
+
* every kept vertex not protected was attempted there once as a single removal, on the mesh the result carries (before
|
|
324
|
+
* any `retriangulate` post-pass). A vertex is boundary when it is a source-hull vertex — a kept source vertex is on the
|
|
325
|
+
* outline exactly then — and interior otherwise. Vertices the refinement inserted are never candidates and are not
|
|
326
|
+
* listed.
|
|
327
|
+
*
|
|
328
|
+
* A reason is the refusal's constraint without the object an author named: a row's code (`MQ_BOUNDARY_DEVIATION`,
|
|
329
|
+
* `MQ_SKINNING_RESIDUAL`, …, the region or line it names left in `refusal`), or a structural condition as the refusal
|
|
330
|
+
* opens — `retriangulation`, `outline`, `protect (a)`, `weightJump (b)`, `line`. A vertex no attempt in such a pass
|
|
331
|
+
* reached reads why, never an absence: `protected` (`protect`, or a line's end or crossing), and — when the run has no
|
|
332
|
+
* pass that took nothing — `budget-exhausted`, `replayed-to-accepted-step` or `no-removal-pass` (the refined source
|
|
333
|
+
* did not meet its targets, so no removal was tried), the termination it ended in.
|
|
334
|
+
*/
|
|
335
|
+
export interface KeptRefusals {
|
|
336
|
+
/** The 1-based removal pass the reasons were read from; null when the run had no pass that took nothing. */
|
|
337
|
+
pass: number | null;
|
|
338
|
+
/** One entry per reason that holds a kept source vertex, ascending by reason. `boundary + interior` over the entries is the kept source vertices. */
|
|
339
|
+
rows: KeptRow[];
|
|
340
|
+
/** `explainKept: 'by-vertex'` only: every kept source vertex, ascending by source index. */
|
|
341
|
+
vertices?: KeptVertex[];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Issue #1374: one reason, and the kept source vertices it holds on the outline and inside it. */
|
|
345
|
+
export interface KeptRow {
|
|
346
|
+
reason: string;
|
|
347
|
+
boundary: number;
|
|
348
|
+
interior: number;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Issue #1374: one kept source vertex, its reason, and the refusal's text as `blockingConstraint` writes it — null for a reason no attempt gave. */
|
|
352
|
+
export interface KeptVertex {
|
|
353
|
+
vertex: number;
|
|
354
|
+
boundary: boolean;
|
|
355
|
+
reason: string;
|
|
356
|
+
refusal: string | null;
|
|
309
357
|
}
|
|
310
358
|
|
|
311
359
|
/**
|
|
@@ -687,8 +735,10 @@ export interface ReductionChanges {
|
|
|
687
735
|
* `stopAfterAccepted: k` replays to the operation at `acceptedAt[k - 1]`.
|
|
688
736
|
*/
|
|
689
737
|
acceptedAt: AcceptedOperation[];
|
|
690
|
-
/** Issue #1283: which triangulation the mesh carries — present only when the input set `retriangulate
|
|
738
|
+
/** Issue #1283: which triangulation the mesh carries — present only when the input set `retriangulate`. */
|
|
691
739
|
retriangulation?: Retriangulation;
|
|
740
|
+
/** Issue #1374: what refused each kept source vertex in the last removal pass — present only when the input set `explainKept`, written last. */
|
|
741
|
+
kept?: KeptRefusals;
|
|
692
742
|
}
|
|
693
743
|
|
|
694
744
|
export interface MeshQualityReport {
|
|
@@ -750,6 +800,8 @@ export interface EffectiveSettings {
|
|
|
750
800
|
lines?: NamedLine[];
|
|
751
801
|
/** A measurement's `lineSource`, by id and `sourceMeshDigest`, echoed when the input set it (issue #1326); absent otherwise. */
|
|
752
802
|
lineSource?: { id: string; digest: string };
|
|
803
|
+
/** A reduction's `explainKept`, echoed when the input set it (issue #1374); absent otherwise. */
|
|
804
|
+
explainKept?: 'by-row' | 'by-vertex';
|
|
753
805
|
}
|
|
754
806
|
|
|
755
807
|
export interface MeshCounts {
|
|
@@ -2805,6 +2857,7 @@ function effectiveJson(e: EffectiveSettings): Json {
|
|
|
2805
2857
|
...(e.overBound === undefined ? {} : { overBound: { maxSamples: e.overBound.maxSamples, triangles: e.overBound.triangles } }),
|
|
2806
2858
|
...(e.lines === undefined ? {} : { lines: e.lines.map((l): Json => ({ name: l.name, vertices: [...l.vertices], closed: l.closed, maxDeviation: l.maxDeviation })) }),
|
|
2807
2859
|
...(e.lineSource === undefined ? {} : { lineSource: { id: e.lineSource.id, digest: e.lineSource.digest } }),
|
|
2860
|
+
...(e.explainKept === undefined ? {} : { explainKept: e.explainKept }),
|
|
2808
2861
|
};
|
|
2809
2862
|
}
|
|
2810
2863
|
|
|
@@ -2950,6 +3003,15 @@ function candidateJson(c: CandidateReport): Json {
|
|
|
2950
3003
|
monotonicity: k.retriangulation.monotonicity,
|
|
2951
3004
|
},
|
|
2952
3005
|
}),
|
|
3006
|
+
...(k.kept === undefined
|
|
3007
|
+
? {}
|
|
3008
|
+
: {
|
|
3009
|
+
kept: {
|
|
3010
|
+
pass: k.kept.pass,
|
|
3011
|
+
rows: k.kept.rows.map((r) => ({ reason: r.reason, boundary: r.boundary, interior: r.interior })),
|
|
3012
|
+
...(k.kept.vertices === undefined ? {} : { vertices: k.kept.vertices.map((v) => ({ vertex: v.vertex, boundary: v.boundary, reason: v.reason, refusal: v.refusal })) }),
|
|
3013
|
+
},
|
|
3014
|
+
}),
|
|
2953
3015
|
};
|
|
2954
3016
|
}
|
|
2955
3017
|
return out;
|
package/src/meshreduce.ts
CHANGED
|
@@ -90,6 +90,15 @@
|
|
|
90
90
|
* of the triangles it changed; the result's own measurement reads the row in
|
|
91
91
|
* full. Left out, nothing here reads it and the call is the one it was.
|
|
92
92
|
*
|
|
93
|
+
* ## What refused each kept vertex (issue #1374, opt-in)
|
|
94
|
+
*
|
|
95
|
+
* With `explainKept`, the pass that ends the reduction having taken nothing
|
|
96
|
+
* keeps each single removal's refusal and names it once the pass is over — the
|
|
97
|
+
* working mesh is then the one every attempt in it was tried on, so a refusal
|
|
98
|
+
* the deviation floor decided is measured as `blockingConstraint`'s is — and the
|
|
99
|
+
* result's `changes.kept` counts the kept source vertices per reason, boundary
|
|
100
|
+
* and interior (`keptOf`). No step, order or mesh reads it.
|
|
101
|
+
*
|
|
93
102
|
* A removal whose deviation floor — the furthest removed source-hull vertex
|
|
94
103
|
* from the candidate's outline, which `MQ_BOUNDARY_DEVIATION` can only exceed —
|
|
95
104
|
* is over the bound is refused without the measurement; its name, when read,
|
|
@@ -148,6 +157,8 @@ import {
|
|
|
148
157
|
type CandidateReport,
|
|
149
158
|
type DeformKeyInput,
|
|
150
159
|
type EffectiveSettings,
|
|
160
|
+
type KeptRefusals,
|
|
161
|
+
type KeptVertex,
|
|
151
162
|
type MeasureRow,
|
|
152
163
|
type MeshCounts,
|
|
153
164
|
type MeshMeasureInput,
|
|
@@ -344,6 +355,9 @@ function validateReduction(input: MeshReductionInput, plant: ReductionPlant | nu
|
|
|
344
355
|
if ('removalOrder' in input && input.removalOrder !== undefined && input.removalOrder !== 'deformation-load') {
|
|
345
356
|
refuse('REDUCE_INPUT_MISSING', `${who}: removalOrder is ${JSON.stringify(input.removalOrder)}; required "deformation-load" — single removals in ascending predicted deformation load, ties by source index — or the field left out (issue #1283: ascending source index)`);
|
|
346
357
|
}
|
|
358
|
+
if ('explainKept' in input && input.explainKept !== undefined && input.explainKept !== 'by-row' && input.explainKept !== 'by-vertex') {
|
|
359
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: explainKept is ${JSON.stringify(input.explainKept)}; required "by-row" — what refused the kept source vertices in the last removal pass, counted per reason — or "by-vertex" — those counts and every kept source vertex listed — or the field left out (issue #1374: no such block)`);
|
|
360
|
+
}
|
|
347
361
|
// Issue #1287: refused here, before the admission measurement and any step, in the measurement's own words — the
|
|
348
362
|
// admission does not carry the field, and a call whose source is refused never reaches the one measurement that does.
|
|
349
363
|
if (plant !== 'amplitude-unvalidated') validateMotionAmplitude(who, input.motionAmplitude);
|
|
@@ -961,6 +975,18 @@ interface Run {
|
|
|
961
975
|
hooks: SkinningHooks | null;
|
|
962
976
|
/** Issue #1326: the declared lines, or null when the input left `lines` out. */
|
|
963
977
|
lines: LineRun | null;
|
|
978
|
+
/** Issue #1374: the refusals of the current pass's single removals, and the last pass's once it took nothing — null when the input left `explainKept` out. */
|
|
979
|
+
kept: KeptLog | null;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Issue #1374: what `explainKept` reads. `attempts` is the current pass's single removals refused, by source vertex,
|
|
984
|
+
* as tried — a floor's refusal still a thunk, read only if the pass takes nothing; `final` is that pass's, read in
|
|
985
|
+
* the state it was tried in, once a pass ends having taken nothing.
|
|
986
|
+
*/
|
|
987
|
+
interface KeptLog {
|
|
988
|
+
attempts: Map<number, Refusal>;
|
|
989
|
+
final: { pass: number; refusals: Map<number, string> } | null;
|
|
964
990
|
}
|
|
965
991
|
|
|
966
992
|
/** Issue #1326: the input's lines as the steps read them. */
|
|
@@ -1486,7 +1512,14 @@ export type ReductionPlant =
|
|
|
1486
1512
|
| OrderPlant
|
|
1487
1513
|
| AmplitudePlant
|
|
1488
1514
|
| RefinementPlant
|
|
1489
|
-
| LinePlant
|
|
1515
|
+
| LinePlant
|
|
1516
|
+
| KeptPlant;
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Issue #1374's fault, for the `mesh-quality` suite's control: each kept vertex named by the refusal of the next
|
|
1520
|
+
* vertex refused in the last pass (the first's for the last) — a reason that did not refuse it.
|
|
1521
|
+
*/
|
|
1522
|
+
export type KeptPlant = 'kept-reason-shifted';
|
|
1490
1523
|
|
|
1491
1524
|
/**
|
|
1492
1525
|
* Issue #1326's faults in the line mechanism, for the `mesh-quality` suite's controls: the line rows read by no step
|
|
@@ -1909,6 +1942,7 @@ function removeVertices(run: Run): PhaseEnd {
|
|
|
1909
1942
|
let taken = 0;
|
|
1910
1943
|
let tried = 0;
|
|
1911
1944
|
pass++;
|
|
1945
|
+
if (run.kept !== null) run.kept.attempts = new Map();
|
|
1912
1946
|
if (runs !== undefined) {
|
|
1913
1947
|
let walk: number[] | null = null;
|
|
1914
1948
|
for (let s = 0; s < hull; s++) {
|
|
@@ -1990,12 +2024,14 @@ function removeVertices(run: Run): PhaseEnd {
|
|
|
1990
2024
|
if (accept(run, 'removal', [v])) return { kind: 'replayed' };
|
|
1991
2025
|
} else {
|
|
1992
2026
|
lastBlock = { refusal: block, what: `removing source vertex ${v}` };
|
|
2027
|
+
if (run.kept !== null) run.kept.attempts.set(v, block);
|
|
1993
2028
|
if (run.hooks?.plant === 'veto-ends-search' && typeof block === 'string' && block.startsWith('MQ_SKINNING_RESIDUAL')) return { kind: 'stuck', constraint: lastName() };
|
|
1994
2029
|
}
|
|
1995
2030
|
}
|
|
1996
2031
|
if (taken === 0) {
|
|
1997
2032
|
// A pass that took nothing left the working mesh as it found it, so a refusal the floor decided is measured
|
|
1998
2033
|
// now in the state it was decided in.
|
|
2034
|
+
if (run.kept !== null) run.kept.final = { pass, refusals: new Map([...run.kept.attempts].map(([v, r]) => [v, refusalText(r)])) };
|
|
1999
2035
|
const kept = run.lines === null ? '' : ", or an open line's end or a vertex two lines list";
|
|
2000
2036
|
const constraint = tried === 0 ? `protect: every surviving source vertex is protected (protect.hull, protect.vertices, protect.edges, protect.regionBoundaries or a weightJump edge${kept})` : lastName();
|
|
2001
2037
|
return { kind: 'stuck', constraint };
|
|
@@ -2688,6 +2724,7 @@ function reduceValidated(
|
|
|
2688
2724
|
skinning: null,
|
|
2689
2725
|
hooks,
|
|
2690
2726
|
lines: null,
|
|
2727
|
+
kept: input.explainKept === undefined ? null : { attempts: new Map(), final: null },
|
|
2691
2728
|
};
|
|
2692
2729
|
run.skinning = skinningRunOf(run, skinningAdmitted);
|
|
2693
2730
|
run.lines = lineRunOf(run);
|
|
@@ -2880,6 +2917,7 @@ function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts
|
|
|
2880
2917
|
linkedMeshes: input.linkedMeshes,
|
|
2881
2918
|
acceptedAt: run.acceptedAt.map((a) => ({ step: a.step, kind: a.kind, count: a.count, sourceVertices: [...a.sourceVertices] })),
|
|
2882
2919
|
...(retriangulation === undefined ? {} : { retriangulation }),
|
|
2920
|
+
...(run.kept === null ? {} : { kept: keptOf(run, termination) }),
|
|
2883
2921
|
};
|
|
2884
2922
|
const counts = candidate.counts!;
|
|
2885
2923
|
const mesh: ReducedMesh = {
|
|
@@ -2894,6 +2932,62 @@ function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts
|
|
|
2894
2932
|
return { mesh, report: reportOf(effective, sourceCounts, { ...candidate, changes }, termination) };
|
|
2895
2933
|
}
|
|
2896
2934
|
|
|
2935
|
+
/**
|
|
2936
|
+
* A refusal's reason (issue #1374, `KeptRefusals`): a row's code, or the structural condition the refusal opens with.
|
|
2937
|
+
* Every refusal a step writes opens with one of them; one that does not is a defect here, thrown rather than named.
|
|
2938
|
+
*/
|
|
2939
|
+
function refusalReason(text: string): string {
|
|
2940
|
+
const code = /^MQ_[A-Z0-9_]+/.exec(text);
|
|
2941
|
+
if (code !== null) return code[0];
|
|
2942
|
+
if (text.startsWith('line "')) return 'line';
|
|
2943
|
+
for (const condition of ['retriangulation', 'outline', 'protect (a)', 'weightJump (b)']) if (text.startsWith(`${condition}:`)) return condition;
|
|
2944
|
+
throw new Error(`issue #1374: a refusal opens with no reason KeptRefusals names: "${text.slice(0, 120)}"`);
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
/**
|
|
2948
|
+
* Issue #1374: `ReductionChanges.kept` — every kept source vertex with its reason: `protected`, else its refusal in the
|
|
2949
|
+
* last pass when one took nothing, else the termination that ended the run without one.
|
|
2950
|
+
*/
|
|
2951
|
+
function keptOf(run: Run, termination: Termination): KeptRefusals {
|
|
2952
|
+
const { work } = run;
|
|
2953
|
+
const hull = run.input.source.hull;
|
|
2954
|
+
const final = run.kept!.final;
|
|
2955
|
+
const unreached = final !== null ? null : termination.reason === 'budget-exhausted' || termination.reason === 'replayed-to-accepted-step' ? termination.reason : 'no-removal-pass';
|
|
2956
|
+
let refusals = final?.refusals ?? null;
|
|
2957
|
+
if (refusals !== null && run.plant === 'kept-reason-shifted') {
|
|
2958
|
+
const order = [...refusals.keys()].sort((a, b) => a - b);
|
|
2959
|
+
refusals = new Map(order.map((v, i) => [v, refusals!.get(order[(i + 1) % order.length])!]));
|
|
2960
|
+
}
|
|
2961
|
+
const vertices: KeptVertex[] = [];
|
|
2962
|
+
for (let v = 0; v < work.nSource; v++) {
|
|
2963
|
+
if (!work.alive[v]) continue;
|
|
2964
|
+
const boundary = v < hull;
|
|
2965
|
+
if (run.protectedVertices.has(v)) {
|
|
2966
|
+
vertices.push({ vertex: v, boundary, reason: 'protected', refusal: null });
|
|
2967
|
+
continue;
|
|
2968
|
+
}
|
|
2969
|
+
if (refusals === null) {
|
|
2970
|
+
vertices.push({ vertex: v, boundary, reason: unreached!, refusal: null });
|
|
2971
|
+
continue;
|
|
2972
|
+
}
|
|
2973
|
+
const refusal = refusals.get(v);
|
|
2974
|
+
if (refusal === undefined) throw new Error(`issue #1374: source vertex ${v} is kept and not protected, yet removal pass ${final!.pass}, which took nothing, did not refuse it`);
|
|
2975
|
+
vertices.push({ vertex: v, boundary, reason: refusalReason(refusal), refusal });
|
|
2976
|
+
}
|
|
2977
|
+
const rows = new Map<string, { boundary: number; interior: number }>();
|
|
2978
|
+
for (const k of vertices) {
|
|
2979
|
+
const row = rows.get(k.reason) ?? { boundary: 0, interior: 0 };
|
|
2980
|
+
if (k.boundary) row.boundary++;
|
|
2981
|
+
else row.interior++;
|
|
2982
|
+
rows.set(k.reason, row);
|
|
2983
|
+
}
|
|
2984
|
+
return {
|
|
2985
|
+
pass: final === null ? null : final.pass,
|
|
2986
|
+
rows: [...rows.keys()].sort().map((reason) => ({ reason, ...rows.get(reason)! })),
|
|
2987
|
+
...(run.input.explainKept === 'by-vertex' ? { vertices } : {}),
|
|
2988
|
+
};
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2897
2991
|
function reportOf(effective: EffectiveSettings, sourceCounts: MeshCounts | null, candidate: CandidateReport, termination: Termination): MeshQualityReport {
|
|
2898
2992
|
return {
|
|
2899
2993
|
spec: 'mesh-quality-report/1',
|
|
@@ -2924,6 +3018,7 @@ function effectiveOf(input: MeshReductionInput, measured: EffectiveSettings, sou
|
|
|
2924
3018
|
...(input.retriangulate === undefined ? {} : { retriangulate: input.retriangulate }),
|
|
2925
3019
|
...(input.removalOrder === undefined ? {} : { removalOrder: input.removalOrder }),
|
|
2926
3020
|
...(input.lines === undefined ? {} : { lines: input.lines }),
|
|
3021
|
+
...(input.explainKept === undefined ? {} : { explainKept: input.explainKept }),
|
|
2927
3022
|
};
|
|
2928
3023
|
}
|
|
2929
3024
|
|