@rig-c/parts 1.9.1 → 1.10.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.
@@ -0,0 +1,887 @@
1
+ /**
2
+ * `rig-parts mesh-draft` (issue #265, the gated half of the ruling of 2026-10-12): for every lattice mesh a config
3
+ * states, a traced outline around a coarse grid — drafted only where it holds against that part's lattice, read
4
+ * through rig-c. It reads a config and writes one; the config it reads is never touched.
5
+ *
6
+ * ## The candidate ({@link candidateMesh})
7
+ *
8
+ * A `contour` mesh with every number derived from the figure — H, the height of the union of every part's box in
9
+ * rig px ({@link figureRules}) — and nothing read off the part:
10
+ *
11
+ * - the traced outline at tolerance and margin T = max(1, round(H / {@link TOLERANCE_FIGURE_DIVISOR}));
12
+ * - no outline edge longer than the grid cell: `maxEdge` S (`src/contour.ts`, {@link capOutlineEdges});
13
+ * - the interior grid at spacing S = round(H / {@link FIGURE_GRID_DIVISOR});
14
+ * - dust left out: an extra 4-connected island of the art (alpha above `ART_ALPHA`) of at most {@link DUST_MAX_PX} px
15
+ * and at most {@link DUST_MAX_SHARE} of the part's art is dust, and a `stray` equal to the largest such island leaves
16
+ * it out. A part with any other extra island holds a real piece, and keeps its lattice ({@link dustOf});
17
+ * - `r`, `segments`, `exponent` and `rule` as the lattice entry states them.
18
+ *
19
+ * The constants are the ones issue #265's stages measured (Stage A passed every bar at c = 37 on three examples; T 2
20
+ * at the trailer's 1108 px figure is Stage A2's point; the dust rule is Stage B's). They are calibrated on three
21
+ * figures of similar size (1106, 1108 and 1185 px), not laws of figure size, and every drafted mesh's note says so.
22
+ *
23
+ * ## The gate ({@link gateVerdict}), per part, against the lattice the part would otherwise keep
24
+ *
25
+ * 1. **bars**: every `check` bar that reads the part passes on the drafted rig (the rig stage's own gate included);
26
+ * a failing problem is laid on the parts it names ({@link partsNamed}).
27
+ * 2. **idle**: within {@link IDLE_BOUND_PX} rig px of the lattice over the idle — rig-c's `MQ_LOCAL_DEFORMATION`,
28
+ * the lattice the reference, the idle as the motion gate walks it (`idleSchedule`, `src/automotion.ts`).
29
+ * 3. **bend inverted**: at the bend set ({@link bendAnimations}: every chain link the part's segments name turned by
30
+ * +{@link BEND_DEGREES}° about its joint, then by −{@link BEND_DEGREES}°, Stage C's definition) no more inverted
31
+ * triangles than the lattice — rig-c's `MQ_INVERSION` (A39's band), summed over the two poses.
32
+ * 4. **bend worst**: at the bend set, the worst sample within {@link BEND_WORST_SHARE} of the lattice's, both read as
33
+ * `MQ_LOCAL_DEFORMATION` against a reference denser than either: the same part as a lattice at the spacing Stage
34
+ * C's point reference sampled at ({@link denseGrid}), weighted by the same rule.
35
+ * 5. **triangles**: no more triangles than the lattice (HQ's ruling on #295): a candidate that holds every motion
36
+ * line but costs more than the mesh it would replace is not drafted — unless the author passes
37
+ * {@link MORE_TRIANGLES_FLAG}, under which the count is reported (with the overdraw it buys) and is no line.
38
+ *
39
+ * The gate is a judgment relative to that lattice, not an absolute quality ({@link RELATIVE_SENTENCE}).
40
+ *
41
+ * The lattice is the config's own `grid` entry, which is what a part keeps when it fails. For a config whose meshes
42
+ * came from `propose`, that is the lattice propose drafts — since #294 the figure's spacing, `round(H / 37)` with a
43
+ * part already coarser keeping its own (`figureGrid`, `src/propose.ts`, whose divisor and rounding S also is).
44
+ *
45
+ * A part whose segments name no chain link has an empty bend set, and readings 3 and 4 have nothing to read; its
46
+ * note says it was measured on the idle only. A part that passes all five is drafted; one that fails any keeps its
47
+ * lattice entry byte for byte, and the report names each failed reading and by how much. Every reading with a number
48
+ * carries its margin to the line ({@link GateLine.margin}), so a pass at the line reads as one.
49
+ *
50
+ * ## The one poser
51
+ *
52
+ * Every reading is rig-c's: the bars are `check`'s, through the injected {@link RigcRunner}; readings 2–4 are
53
+ * `compareMeshesInMotion` (`rig-c/meshcompare`) over the model documents of three unpacked gate builds — the drafted
54
+ * rig with every candidate part's attachment set to its lattice, to its candidate and to its dense reference, and
55
+ * nothing else different — whose motion carries the bend animations. This module poses nothing.
56
+ *
57
+ * Pure in the sense `src/` is held to: no clock, no randomness, no network, no child process. rigc runs as a process,
58
+ * but the process is injected, exactly as `src/check.ts` and `src/build.ts` take it.
59
+ */
60
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
61
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
62
+ import type { AlphaMask, MeasureRow, MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
63
+ import { compareMeshesInMotion, type MotionComparisonInput } from 'rig-c/meshcompare';
64
+ import { MeshReductionError } from 'rig-c/mesh';
65
+ import { explicitFrameId, idleSchedule } from './automotion.ts';
66
+ import { checkStage, gateThroughRigc, type Log, RIGC_MODEL_DOCUMENT, rigStage } from './build.ts';
67
+ import { BARS, type CheckReport, type PackShape, type PageEdges, type RigcRunner } from './check.ts';
68
+ import { type CharacterConfig, type ContourMeshSpec, type LatticeMeshSpec, loadConfigAndAnimations, type Segment } from './config.ts';
69
+ import { PartsError, type Problem, problemLine } from './errors.ts';
70
+ import { IDLE_FPS } from './motion.ts';
71
+ import { ART_ALPHA } from './mesh.ts';
72
+ import { type PartsFile, readParts } from './parts.ts';
73
+ import { alphaAbove, connectedComponents } from './raster/index.ts';
74
+ import { readPng } from './raster/png.ts';
75
+ import type { Raster } from './raster/types.ts';
76
+ import { buildRig, type IdleKeys, type MeshAttachment, rigJsonText, type RigSpec } from './rig.ts';
77
+ import { pyRound } from './round.ts';
78
+ import { FIGURE_GRID_DIVISOR, figureGrid } from './propose.ts';
79
+
80
+ /** The report's spec. */
81
+ export const MESH_DRAFT_SPEC = 'rig-parts-mesh-draft/1';
82
+
83
+ /** The report's file name, beside the drafted `config.json`. */
84
+ export const MESH_DRAFT_REPORT = 'mesh_draft.json';
85
+
86
+ /** T = max(1, round(H / 554)): T 2 at the trailer's 1108 px figure, Stage A2's measured point (issue #265). */
87
+ export const TOLERANCE_FIGURE_DIVISOR = 554;
88
+
89
+
90
+ /** The heights of the three figures the two divisors were measured on, rig px (demo, the trailer, scarf). */
91
+ export const CALIBRATION_HEIGHTS: readonly number[] = [1106, 1108, 1185];
92
+
93
+ /** Dust: an extra island of at most this many art px (Stage B: 1–2 px dots were the measured case) … */
94
+ export const DUST_MAX_PX = 4;
95
+
96
+ /** … and at most this share of the part's art px. */
97
+ export const DUST_MAX_SHARE = 0.001;
98
+
99
+ /** The bend set's turn per link, degrees: the fallback Stage C stated, a physics constraint's reach not being derivable from it. */
100
+ export const BEND_DEGREES = 15;
101
+
102
+ /** Reading 2's line: the candidate within this many rig px of the lattice over the idle. */
103
+ export const IDLE_BOUND_PX = 1;
104
+
105
+ /** Reading 4's line: the candidate's worst bend sample at most this share above the lattice's. */
106
+ export const BEND_WORST_SHARE = 0.1;
107
+
108
+ /** The dense reference's sample budget: Stage C's point reference took at most this many samples per part. */
109
+ export const DENSE_MAX_SAMPLES = 20000;
110
+
111
+ /** The author's flag that takes reading 5 off the gate: a candidate holding the four motion readings is drafted whatever its triangles. */
112
+ export const MORE_TRIANGLES_FLAG = '--more-triangles';
113
+
114
+ /**
115
+ * What every note and the docs say of the gate's reference: the judgment is relative to the lattice the config states
116
+ * — not an absolute quality — so when that lattice coarsens, the reference moves with it (on #295, moving demo,
117
+ * scarf and the trailer from the part rule's lattice to the figure's flipped nine readings, with no candidate changed).
118
+ */
119
+ export const RELATIVE_SENTENCE = 'the gate is a judgment relative to this config\'s lattice, not an absolute quality: when the lattice coarsens, the reference moves with it';
120
+
121
+ /** The five readings, in the order the gate reads them. */
122
+ export const READINGS = ['bars', 'idle', 'bend inverted', 'bend worst', 'triangles'] as const;
123
+ export type Reading = (typeof READINGS)[number];
124
+
125
+ /** What every drafted mesh's note says it was and was not measured against. */
126
+ export const MEASURED_SENTENCE = `measured against the idle and the ${BEND_DEGREES}°-per-link bend set only — nothing larger was measured, so a chain an animation or a physics constraint bends further than that has not been read at that bend`;
127
+
128
+ /** What every drafted mesh's note says of the two rules. */
129
+ export const CALIBRATED_SENTENCE = `the spacing rule round(H / ${FIGURE_GRID_DIVISOR}) and the tolerance rule max(1, round(H / ${TOLERANCE_FIGURE_DIVISOR})) are calibrated on three figures of similar size (${CALIBRATION_HEIGHTS.join(', ')} px), not laws of figure size`;
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // the figure's rules
133
+ // ---------------------------------------------------------------------------
134
+
135
+ export interface FigureRules {
136
+ /** The union of every part's box, rig px. */
137
+ top: number;
138
+ bottom: number;
139
+ height: number;
140
+ /** T, the outline's tolerance and margin. */
141
+ tolerance: number;
142
+ /** S, the interior spacing and the outline's longest edge. */
143
+ spacing: number;
144
+ }
145
+
146
+ /** H, T and S off parts.json's boxes, or the problem that refuses a figure the spacing rule gives no spacing for. */
147
+ export function figureRules(parts: Pick<PartsFile, 'parts'>): FigureRules | Problem {
148
+ if (parts.parts.length === 0) return { code: 'MESH_DRAFT_FIGURE', object: 'parts.json', detail: 'holds no part, so the figure has no height; the rules are read off the union of the parts\' boxes' };
149
+ const top = Math.min(...parts.parts.map((p) => p.y));
150
+ const bottom = Math.max(...parts.parts.map((p) => p.y + p.h));
151
+ const height = bottom - top;
152
+ // S is propose's figure spacing (issue #265 half 1), the one definition of the rule: its divisor and its rounding.
153
+ const spacing = figureGrid(parts.parts).grid;
154
+ const tolerance = Math.max(1, Math.round(height / TOLERANCE_FIGURE_DIVISOR));
155
+ if (spacing < 1) {
156
+ return {
157
+ code: 'MESH_DRAFT_FIGURE',
158
+ object: `the figure, rows ${top} to ${bottom} rig px`,
159
+ detail: `is ${height} px tall, so the spacing rule round(H / ${FIGURE_GRID_DIVISOR}) gives ${spacing}; a spacing of at least 1 px is required, and nothing is raised to reach it — the rule is calibrated on figures of ${CALIBRATION_HEIGHTS.join(', ')} px`,
160
+ };
161
+ }
162
+ return { top, bottom, height, tolerance, spacing };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // the candidate
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /** A part's art islands, largest first, and what the dust rule makes of them. */
170
+ export type DustReading = { stray: number | undefined; islands: number[]; dust: number[] } | { keep: string; islands: number[] };
171
+
172
+ /**
173
+ * The dust rule on a part image (its alpha, unpadded or padded alike: the pad adds no art): the 4-connected islands
174
+ * of alpha above `ART_ALPHA` — the contour mode's own island rule — and whether every island past the largest is
175
+ * dust ({@link DUST_MAX_PX}, {@link DUST_MAX_SHARE}). A tie for the largest is no single largest island, which the
176
+ * contour mode refuses, so it keeps the lattice too.
177
+ */
178
+ export function dustOf(image: Raster): DustReading {
179
+ const comp = connectedComponents(alphaAbove(image, ART_ALPHA), 4);
180
+ const islands = comp.stats.slice(1).map((s) => s.area).sort((a, b) => b - a);
181
+ const art = islands.reduce((a, b) => a + b, 0);
182
+ if (islands.length === 0) return { keep: `its image has no pixel with alpha above ${ART_ALPHA}`, islands };
183
+ if (islands.length > 1 && islands[0] === islands[1]) return { keep: `its art is ${islands.length} islands (${islands.join(', ')} px) of which two share the largest count, so no one island is the largest`, islands };
184
+ const extra = islands.slice(1);
185
+ const pieces = extra.filter((n) => !(n <= DUST_MAX_PX && n <= DUST_MAX_SHARE * art));
186
+ if (pieces.length > 0) {
187
+ return {
188
+ keep: `its art is ${islands.length} islands (${islands.join(', ')} px); ${pieces.length} beside the largest ${pieces.length === 1 ? 'is a real piece' : 'are real pieces'}, not dust (at most ${DUST_MAX_PX} px and at most ${pyRound(DUST_MAX_SHARE * 100, 6)} % of the part's ${art} art px), and the contour mode meshes one island`,
189
+ islands,
190
+ };
191
+ }
192
+ return { stray: extra.length === 0 ? undefined : extra[0], islands, dust: extra };
193
+ }
194
+
195
+ /** The candidate for one lattice entry: the outline mesh at the figure's numbers, its weights' fields as the lattice states them. */
196
+ export function candidateMesh(lattice: LatticeMeshSpec, rules: Pick<FigureRules, 'tolerance' | 'spacing'>, stray: number | undefined): ContourMeshSpec {
197
+ return {
198
+ contour: { tolerance: rules.tolerance, margin: rules.tolerance, spacing: rules.spacing, maxEdge: rules.spacing, ...(stray === undefined ? {} : { stray }) },
199
+ r: lattice.r,
200
+ segments: lattice.segments,
201
+ ...(lattice.exponent === undefined ? {} : { exponent: lattice.exponent }),
202
+ ...(lattice.rule === undefined ? {} : { rule: lattice.rule }),
203
+ };
204
+ }
205
+
206
+ /** The dense reference's lattice cell, px: Stage C's sample stride, the least whole number keeping a part's art at or under {@link DENSE_MAX_SAMPLES} samples. */
207
+ export function denseGrid(artPx: number): number {
208
+ return Math.max(1, Math.ceil(Math.sqrt(artPx / DENSE_MAX_SAMPLES)));
209
+ }
210
+
211
+ /** The pixels of an image with alpha above `ART_ALPHA`. */
212
+ export function artPixels(image: Raster): number {
213
+ let n = 0;
214
+ for (let i = 0; i < image.width * image.height; i++) if (image.data[i * 4 + 3] > ART_ALPHA) n++;
215
+ return n;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // the bend set
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /** The chain links a mesh's segments name — every link of a named chain, a named link, the bone of an explicit segment that is a link — in config order. */
223
+ export function bendLinks(cfg: Pick<CharacterConfig, 'bones'>, segments: readonly Segment[]): string[] {
224
+ const chains = new Map<string, string[]>();
225
+ for (const b of cfg.bones) if ('chain' in b) chains.set(b.chain, b.points.map((_p, k) => `${b.chain}${k}`));
226
+ const links = new Set([...chains.values()].flat());
227
+ const out: string[] = [];
228
+ const add = (n: string): void => {
229
+ if (!out.includes(n)) out.push(n);
230
+ };
231
+ for (const s of segments) {
232
+ const name = typeof s === 'string' ? s : s[0];
233
+ if (typeof s === 'string' && chains.has(s)) for (const l of chains.get(s) as string[]) add(l);
234
+ else if (links.has(name)) add(name);
235
+ }
236
+ return out;
237
+ }
238
+
239
+ /** The two animations one part's bend set is walked as. */
240
+ export function bendAnimationNames(part: string): [string, string] {
241
+ return [`mesh-draft ${part} +${BEND_DEGREES}`, `mesh-draft ${part} -${BEND_DEGREES}`];
242
+ }
243
+
244
+ /** What every bend animation says of itself in the comparison builds' `motion.json`. */
245
+ export const BEND_NOTE = `rig-parts mesh-draft: the bend set — every chain link the part's segments name turned by the stated degrees about its joint, walked at time 0 by the gate's comparison only; never written`;
246
+
247
+ /** Each part's two bend animations: one key at time 0 on every link, `rotate` by +θ or −θ, duration 0. */
248
+ export function bendAnimations(sets: ReadonlyArray<{ part: string; links: readonly string[] }>): Record<string, { duration: number; note: string; tracks: Array<{ bone: string; property: string; keys: Array<{ t: number; v: number[] }> }> }> {
249
+ const out: ReturnType<typeof bendAnimations> = {};
250
+ for (const s of sets) {
251
+ if (s.links.length === 0) continue;
252
+ const [plus, minus] = bendAnimationNames(s.part);
253
+ for (const [name, v] of [[plus, BEND_DEGREES], [minus, -BEND_DEGREES]] as const) {
254
+ out[name] = { duration: 0, note: BEND_NOTE, tracks: s.links.map((bone) => ({ bone, property: 'rotate', keys: [{ t: 0, v: [v] }] })) };
255
+ }
256
+ }
257
+ return out;
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // the readings and the gate
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /** A bend reading: the worst sample against the dense reference, rig px, and the inverted triangles summed over both poses. */
265
+ export interface BendFigure {
266
+ worst: number;
267
+ inverted: number;
268
+ }
269
+
270
+ export interface PartReadings {
271
+ /** Every `check` problem laid on the part by {@link partsNamed}: empty when every bar reading it passed. Null when not read. */
272
+ bars: string[] | null;
273
+ /** The candidate's worst idle sample against the lattice, rig px; a string says why it was not measured. */
274
+ idle: number | string;
275
+ /** Null when the part's segments name no chain link (an empty bend set); a string says why it was not measured. */
276
+ bend: { links: string[]; lattice: BendFigure; candidate: BendFigure } | string | null;
277
+ /** Reading 5 (HQ's ruling on #295): the two meshes' triangle counts; a string says why they were not read. */
278
+ triangles: { lattice: number; candidate: number } | string;
279
+ }
280
+
281
+ export interface GateLine {
282
+ reading: Reading;
283
+ pass: boolean;
284
+ text: string;
285
+ /**
286
+ * How far the reading sits from its line: the value read, the line, and `percent` = 100 · (line − value) / line —
287
+ * positive inside the line, negative past it; null when the line is 0. Null for a reading with no number (bars, an
288
+ * empty bend set, a reading not measured).
289
+ */
290
+ margin: { value: number; line: number; percent: number | null } | null;
291
+ }
292
+
293
+ const fx = (v: number): string => String(pyRound(v, 3));
294
+
295
+ /** A reading's margin to its line ({@link GateLine.margin}), 6 decimals. */
296
+ export function marginTo(value: number, line: number): { value: number; line: number; percent: number | null } {
297
+ return { value: pyRound(value, 6), line: pyRound(line, 6), percent: line === 0 ? null : pyRound((100 * (line - value)) / line, 6) };
298
+ }
299
+
300
+ /** The five readings against their lines; `pass` only when every line passes. A reading that was not measured fails: missing is not a pass. */
301
+ export function gateVerdict(r: PartReadings, moreTriangles = false): { pass: boolean; lines: GateLine[] } {
302
+ const lines: GateLine[] = [];
303
+ if (r.bars === null) lines.push({ reading: 'bars', pass: false, text: 'not read: the drafted rig was not checked', margin: null });
304
+ else lines.push({ reading: 'bars', pass: r.bars.length === 0, text: r.bars.length === 0 ? 'every check bar reading the part passed' : `${r.bars.length} check problem(s) name the part: ${r.bars.join('; ')}`, margin: null });
305
+ if (typeof r.idle === 'string') lines.push({ reading: 'idle', pass: false, text: `not measured: ${r.idle}`, margin: null });
306
+ else lines.push({ reading: 'idle', pass: r.idle <= IDLE_BOUND_PX, text: `${fx(r.idle)} rig px from the lattice over the idle; at most ${IDLE_BOUND_PX} is required`, margin: marginTo(r.idle, IDLE_BOUND_PX) });
307
+ if (r.bend === null) {
308
+ const none = 'the part\'s segments name no chain link, so its bend set is empty and there is nothing to read';
309
+ lines.push({ reading: 'bend inverted', pass: true, text: none, margin: null });
310
+ lines.push({ reading: 'bend worst', pass: true, text: none, margin: null });
311
+ } else if (typeof r.bend === 'string') {
312
+ lines.push({ reading: 'bend inverted', pass: false, text: `not measured: ${r.bend}`, margin: null });
313
+ lines.push({ reading: 'bend worst', pass: false, text: `not measured: ${r.bend}`, margin: null });
314
+ } else {
315
+ const { lattice: l, candidate: c } = r.bend;
316
+ lines.push({ reading: 'bend inverted', pass: c.inverted <= l.inverted, text: `${c.inverted} inverted triangle(s) at the bend set against the lattice's ${l.inverted}; no more than the lattice's is required`, margin: marginTo(c.inverted, l.inverted) });
317
+ const line = l.worst * (1 + BEND_WORST_SHARE);
318
+ const share = l.worst > 0 ? ` (${c.worst >= l.worst ? '+' : ''}${fx((100 * (c.worst - l.worst)) / l.worst)} %)` : '';
319
+ lines.push({ reading: 'bend worst', pass: c.worst <= line, text: `worst sample ${fx(c.worst)} rig px from the dense reference at the bend set against the lattice's ${fx(l.worst)}${share}; at most ${fx(line)} (the lattice's + ${BEND_WORST_SHARE * 100} %) is required`, margin: marginTo(c.worst, line) });
320
+ }
321
+ if (typeof r.triangles === 'string') lines.push({ reading: 'triangles', pass: false, text: `not read: ${r.triangles}`, margin: null });
322
+ else if (moreTriangles) {
323
+ const d = r.triangles.candidate - r.triangles.lattice;
324
+ lines.push({ reading: 'triangles', pass: true, text: `${r.triangles.candidate} triangle(s) against the lattice's ${r.triangles.lattice} (${d >= 0 ? '+' : ''}${d}); not a line under ${MORE_TRIANGLES_FLAG}, which accepts a candidate holding the four motion readings whatever its triangles`, margin: marginTo(r.triangles.candidate, r.triangles.lattice) });
325
+ } else lines.push({ reading: 'triangles', pass: r.triangles.candidate <= r.triangles.lattice, text: `${r.triangles.candidate} triangle(s) against the lattice's ${r.triangles.lattice}; no more than the lattice's is required (${MORE_TRIANGLES_FLAG} lifts this line)`, margin: marginTo(r.triangles.candidate, r.triangles.lattice) });
326
+ return { pass: lines.every((x) => x.pass), lines };
327
+ }
328
+
329
+ /** The attachment-level row of `code` for one attachment in a compare report's candidate (by index), or null. */
330
+ function rowOf(report: MeshQualityReport, candidate: number, part: string, code: string): MeasureRow | null {
331
+ return report.candidates[candidate]?.motion?.rows.find((r) => r.code === code && r.object.region === null && r.object.attachment.slot === part) ?? null;
332
+ }
333
+
334
+ /** Reading 2 off the idle comparison (the lattice the reference, the candidate its one candidate): the value, or why there is none. */
335
+ export function idleReading(report: MeshQualityReport, part: string): number | string {
336
+ const row = rowOf(report, 0, part, 'MQ_LOCAL_DEFORMATION');
337
+ if (row === null) return `rig-c's comparison holds no MQ_LOCAL_DEFORMATION row for "${part}"`;
338
+ if (row.value === null) return `rig-c's MQ_LOCAL_DEFORMATION for "${part}" is ${row.state}: ${row.reason ?? 'no reason given'}`;
339
+ return row.value;
340
+ }
341
+
342
+ /** The per-frame label rig-c gives an attachment-level row when a call compares several attachments (`perFrame`). */
343
+ export function perFrameLabel(code: string, part: string, attachments: number): string {
344
+ return attachments > 1 ? `${code}[(no skin)/${part}/${part}]` : code;
345
+ }
346
+
347
+ /**
348
+ * Readings 3 and 4 off the bend comparison (the dense reference, candidates [lattice, candidate], `perFrame`): over
349
+ * the part's own two frames, the largest `MQ_LOCAL_DEFORMATION` and the sum of `MQ_INVERSION`, per build.
350
+ */
351
+ export function bendReading(report: MeshQualityReport, part: string, attachments: number): { lattice: BendFigure; candidate: BendFigure } | string {
352
+ const frames = bendAnimationNames(part).map(explicitFrameId);
353
+ const figure = (k: number): BendFigure | string => {
354
+ const pf = report.candidates[k]?.perFrame;
355
+ if (pf === undefined) return `rig-c's comparison returned no per-frame table for candidate ${k}`;
356
+ const at = (code: string): Array<number | null> => frames.map((f) => pf.find((e) => e.code === perFrameLabel(code, part, attachments) && e.frame === f)?.value ?? null);
357
+ const local = at('MQ_LOCAL_DEFORMATION');
358
+ const inv = at('MQ_INVERSION');
359
+ if (local.some((v) => v === null) || inv.some((v) => v === null)) return `rig-c's per-frame table has no reading of "${part}" at ${frames.join(' or ')}`;
360
+ return { worst: Math.max(...(local as number[])), inverted: (inv as number[]).reduce((a, b) => a + b, 0) };
361
+ };
362
+ const l = figure(0);
363
+ const c = figure(1);
364
+ if (typeof l === 'string') return l;
365
+ if (typeof c === 'string') return c;
366
+ return { lattice: l, candidate: c };
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // laying a check problem on the parts it names
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /** One seam pair's count, as check.json's seam_pairs holds it. */
374
+ export interface SeamCount {
375
+ parts: [string, string];
376
+ px_over_40: number;
377
+ }
378
+
379
+ /**
380
+ * The parts among `among` a failing problem names, by the bar's own words: a `STILL_REGIONS_DARK` problem the slot it
381
+ * opens with; a seam problem every part of a pair whose count on the drafted rig is above the lattice's (`seams`);
382
+ * any other the parts quoted in its object (`"name"`, or `config.meshes.<name>`), else in its detail. Empty when it
383
+ * names none of them.
384
+ */
385
+ export function partsNamed(p: Problem, among: readonly string[], seams: { draft: readonly SeamCount[]; lattice: readonly SeamCount[] } | null): string[] {
386
+ if (p.code === 'CHECK_STILL_REGIONS_DARK') {
387
+ const slot = /^slot "([^"]+)"/.exec(p.object)?.[1];
388
+ return slot !== undefined && among.includes(slot) ? [slot] : [];
389
+ }
390
+ if (p.code === 'CHECK_SEAM_WITHIN_BAR') {
391
+ if (seams === null) return [];
392
+ const key = (s: SeamCount): string => [...s.parts].sort().join('\u0000');
393
+ const before = new Map(seams.lattice.map((s) => [key(s), s.px_over_40]));
394
+ const grew = seams.draft.filter((s) => s.px_over_40 > (before.get(key(s)) ?? 0));
395
+ return among.filter((n) => grew.some((s) => s.parts.includes(n)));
396
+ }
397
+ const quoted = (text: string): string[] => among.filter((n) => text.includes(`"${n}"`) || text.includes(`config.meshes.${n}.`) || text.endsWith(`config.meshes.${n}`) || text.includes(`config.meshes.${n} `));
398
+ const inObject = quoted(p.object);
399
+ return inObject.length > 0 ? inObject : quoted(p.detail);
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // the config written: the input's own text, with the drafted entries spliced in
404
+ // ---------------------------------------------------------------------------
405
+
406
+ /** Where a JSON value starts and ends in a text, by a path of object keys. Throws when the path does not resolve. */
407
+ export function jsonValueSpan(text: string, path: readonly string[]): { start: number; end: number } {
408
+ let i = 0;
409
+ const ws = (): void => {
410
+ while (i < text.length && /\s/.test(text[i])) i++;
411
+ };
412
+ const str = (): string => {
413
+ const start = i;
414
+ i++;
415
+ while (text[i] !== '"') i += text[i] === '\\' ? 2 : 1;
416
+ i++;
417
+ return JSON.parse(text.slice(start, i)) as string;
418
+ };
419
+ const skip = (): void => {
420
+ ws();
421
+ const ch = text[i];
422
+ if (ch === '"') {
423
+ str();
424
+ return;
425
+ }
426
+ if (ch === '{' || ch === '[') {
427
+ const close = ch === '{' ? '}' : ']';
428
+ i++;
429
+ ws();
430
+ if (text[i] === close) {
431
+ i++;
432
+ return;
433
+ }
434
+ for (;;) {
435
+ if (ch === '{') {
436
+ ws();
437
+ str();
438
+ ws();
439
+ i++; // ':'
440
+ }
441
+ skip();
442
+ ws();
443
+ if (text[i] === ',') {
444
+ i++;
445
+ continue;
446
+ }
447
+ i++; // the close
448
+ return;
449
+ }
450
+ }
451
+ while (i < text.length && !/[\s,}\]]/.test(text[i])) i++;
452
+ };
453
+ const find = (depth: number): { start: number; end: number } => {
454
+ ws();
455
+ if (depth === path.length) {
456
+ const start = i;
457
+ skip();
458
+ return { start, end: i };
459
+ }
460
+ if (text[i] !== '{') throw new Error(`jsonValueSpan: ${path.slice(0, depth).join('.') || 'the document'} is not an object`);
461
+ i++;
462
+ for (;;) {
463
+ ws();
464
+ if (text[i] === '}') throw new Error(`jsonValueSpan: no key "${path[depth]}" at ${path.slice(0, depth).join('.') || 'the top'}`);
465
+ const key = str();
466
+ ws();
467
+ i++; // ':'
468
+ if (key === path[depth]) return find(depth + 1);
469
+ skip();
470
+ ws();
471
+ if (text[i] === ',') i++;
472
+ }
473
+ };
474
+ return find(0);
475
+ }
476
+
477
+ /**
478
+ * The config written: `text` with `meshes.<part>` replaced for every part in `drafted`, each entry as
479
+ * `JSON.stringify(entry, null, 2)` indented to the line its key starts on. Every other byte is the input's, so a
480
+ * config with nothing drafted is written byte for byte as read, and a part that keeps its lattice keeps its text.
481
+ * The result is parsed and held to the intended document before it is returned.
482
+ */
483
+ export function splicedConfig(text: string, drafted: ReadonlyMap<string, unknown>): string {
484
+ const spans = [...drafted.keys()].map((part) => ({ part, ...jsonValueSpan(text, ['meshes', part]) })).sort((a, b) => b.start - a.start);
485
+ let out = text;
486
+ for (const s of spans) {
487
+ const lineStart = out.lastIndexOf('\n', s.start) + 1;
488
+ const indent = /^\s*/.exec(out.slice(lineStart, s.start))?.[0] ?? '';
489
+ const body = JSON.stringify(drafted.get(s.part), null, 2).split('\n').join(`\n${indent}`);
490
+ out = `${out.slice(0, s.start)}${body}${out.slice(s.end)}`;
491
+ }
492
+ const want = JSON.parse(text) as { meshes: Record<string, unknown> };
493
+ for (const [part, entry] of drafted) want.meshes[part] = entry;
494
+ if (JSON.stringify(JSON.parse(out)) !== JSON.stringify(want)) throw new Error('splicedConfig: the spliced text does not parse to the intended config');
495
+ return out;
496
+ }
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // the stage
500
+ // ---------------------------------------------------------------------------
501
+
502
+ export interface MeshDraftInput {
503
+ config: string;
504
+ /** The directory holding parts.json and parts/<name>.png. */
505
+ parts: string;
506
+ out: string;
507
+ requirements?: string;
508
+ idleKeys?: IdleKeys;
509
+ pageEdges?: PageEdges;
510
+ packShape?: PackShape;
511
+ /** `--more-triangles` ({@link MORE_TRIANGLES_FLAG}): reading 5 is reported, not a line. Absent or false: it is a line. */
512
+ moreTriangles?: boolean;
513
+ }
514
+
515
+ /** A part's figures: triangles, and the mesh's area over the art's (overdraw). */
516
+ export interface MeshFigure {
517
+ triangles: number;
518
+ overdraw: number;
519
+ }
520
+
521
+ export interface PartRow {
522
+ part: string;
523
+ /** What the written config holds for the part. */
524
+ mode: 'contour' | 'grid';
525
+ lattice: MeshFigure & { grid: number; art_px: number };
526
+ /** Null when no candidate was drafted for it (a real piece, or the stage refused it before any reading). */
527
+ candidate: (MeshFigure & { mesh: ContourMeshSpec }) | null;
528
+ readings: PartReadings | null;
529
+ gate: GateLine[];
530
+ note: string;
531
+ }
532
+
533
+ export interface MeshDraftResult {
534
+ rules: FigureRules;
535
+ rows: PartRow[];
536
+ written: string[];
537
+ }
538
+
539
+ /** The mesh's area over the art's: the triangles' area in image px over the image's art px. */
540
+ export function overdrawOf(att: MeshAttachment, art: number): number {
541
+ let twice = 0;
542
+ for (let t = 0; t < att.triangles.length; t += 3) {
543
+ const [a, b, c] = [att.triangles[t], att.triangles[t + 1], att.triangles[t + 2]];
544
+ const [ax, ay, bx, by, cx, cy] = [att.uvs[a * 2] * att.width, att.uvs[a * 2 + 1] * att.height, att.uvs[b * 2] * att.width, att.uvs[b * 2 + 1] * att.height, att.uvs[c * 2] * att.width, att.uvs[c * 2 + 1] * att.height];
545
+ twice += Math.abs((bx - ax) * (cy - ay) - (by - ay) * (cx - ax));
546
+ }
547
+ return art === 0 ? 0 : pyRound(twice / 2 / art, 4);
548
+ }
549
+
550
+ type Json = Record<string, unknown>;
551
+
552
+ /** A part's mesh attachment in a rig spec. */
553
+ function attachmentOf(rig: RigSpec, part: string): MeshAttachment {
554
+ return (rig.skins.default[part] as Record<string, MeshAttachment>)[part];
555
+ }
556
+
557
+ /**
558
+ * The stage: lattice baseline, candidates, readings, gate, written config (module header). Throws a PartsError for
559
+ * an input it refuses — a config or parts it cannot read, a figure the spacing rule gives no spacing for, a lattice
560
+ * baseline whose `check` is not PASS, a comparison build rigc refuses or a comparison rig-c refuses — and writes
561
+ * nothing then. Otherwise writes `<out>/config.json` and `<out>/mesh_draft.json` once the drafted config's check is
562
+ * PASS, and returns the rows.
563
+ */
564
+ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin: string, scratch: string, log: Log): MeshDraftResult {
565
+ const text = readFileSync(input.config, 'utf8');
566
+ const { config: cfg, animations } = loadConfigAndAnimations(input.config);
567
+ const raw = JSON.parse(text) as Json;
568
+ const parts = readParts(join(input.parts, 'parts.json'));
569
+ const rules = figureRules(parts);
570
+ if ('code' in rules) throw new PartsError([rules]);
571
+ const images = new Map<string, Raster>();
572
+ for (const p of parts.parts) {
573
+ const png = join(input.parts, 'parts', `${p.name}.png`);
574
+ if (existsSync(png)) images.set(p.name, readPng(png));
575
+ }
576
+ log(`rig-parts mesh-draft: ${cfg.key}, figure ${rules.height} px (rows ${rules.top}..${rules.bottom}): tolerance and margin T = max(1, round(${rules.height} / ${TOLERANCE_FIGURE_DIVISOR})) = ${rules.tolerance}, spacing and longest outline edge S = round(${rules.height} / ${FIGURE_GRID_DIVISOR}) = ${rules.spacing}`);
577
+
578
+ // The configs the stage builds are written in the scratch directory: each path the loader resolves against the
579
+ // config's directory (motion.animations_from, generation.control.skeleton) is made absolute where it names a file
580
+ // there, so it resolves to the file the author's config names.
581
+ const configDir = dirname(resolve(input.config));
582
+ const relativePaths = (o: Json): Array<{ at: string; value: string; set: (v: string) => void }> => {
583
+ const out: Array<{ at: string; value: string; set: (v: string) => void }> = [];
584
+ const motion = o.motion as Json | undefined;
585
+ if (motion !== undefined && typeof motion.animations_from === 'string' && !isAbsolute(motion.animations_from) && existsSync(resolve(configDir, motion.animations_from))) {
586
+ out.push({ at: 'motion.animations_from', value: motion.animations_from, set: (v) => (motion.animations_from = v) });
587
+ }
588
+ const control = (o.generation as Json | undefined)?.control as Json | undefined;
589
+ if (control !== undefined && typeof control.skeleton === 'string' && !isAbsolute(control.skeleton) && existsSync(resolve(configDir, control.skeleton))) {
590
+ out.push({ at: 'generation.control.skeleton', value: control.skeleton, set: (v) => (control.skeleton = v) });
591
+ }
592
+ return out;
593
+ };
594
+ const absolute = (o: Json): Json => {
595
+ for (const r of relativePaths(o)) r.set(resolve(configDir, r.value));
596
+ return o;
597
+ };
598
+ const configWith = (name: string, meshes: ReadonlyMap<string, unknown>): string => {
599
+ const o = absolute(JSON.parse(text) as Json);
600
+ o.meshes = { ...(o.meshes as Json), ...Object.fromEntries(meshes) };
601
+ const dir = join(scratch, name);
602
+ mkdirSync(dir, { recursive: true });
603
+ writeFileSync(join(dir, 'config.json'), `${JSON.stringify(o, null, 2)}\n`);
604
+ return join(dir, 'config.json');
605
+ };
606
+ function stage(name: string, meshes: ReadonlyMap<string, unknown>, check: false): { rigDir: string } | { refused: Problem[] };
607
+ function stage(name: string, meshes: ReadonlyMap<string, unknown>, check: true): { rigDir: string; report: CheckReport } | { refused: Problem[] };
608
+ function stage(name: string, meshes: ReadonlyMap<string, unknown>, check: boolean): { rigDir: string; report?: CheckReport } | { refused: Problem[] } {
609
+ const rigDir = join(scratch, name, 'rig');
610
+ const at = configWith(name, meshes);
611
+ try {
612
+ rigStage({ config: at, parts: input.parts, out: rigDir, ...(input.idleKeys === undefined ? {} : { idleKeys: input.idleKeys }), ...(input.pageEdges === undefined ? {} : { pageEdges: input.pageEdges }), ...(input.packShape === undefined ? {} : { packShape: input.packShape }) }, rigc, join(scratch, name, 'gate'), (l) => log(`[${name}:rig] ${l}`));
613
+ if (!check) return { rigDir };
614
+ const report = checkStage({ rig: rigDir, parts: input.parts, out: join(scratch, name, 'check'), seamPairs: true, ...(input.requirements === undefined ? {} : { requirements: input.requirements }), ...(input.pageEdges === undefined ? {} : { pageEdges: input.pageEdges }), ...(input.packShape === undefined ? {} : { packShape: input.packShape }) }, rigc, checkBin, (l) => log(`[${name}:check] ${l}`));
615
+ return { rigDir, report };
616
+ } catch (err) {
617
+ if (!(err instanceof PartsError)) throw err;
618
+ return { refused: [...err.problems] };
619
+ }
620
+ }
621
+
622
+ // 1. The lattice baseline: the config as given. A gate held against a red baseline is no gate, so it must be PASS.
623
+ const base = stage('lattice', new Map(), true);
624
+ if ('refused' in base || !base.report.figures.PASS) {
625
+ const why = 'refused' in base ? base.refused : base.report.problems;
626
+ throw new PartsError(why.map((p) => ({ code: 'MESH_DRAFT_LATTICE_GREEN', object: `${input.config} as written (every lattice part on its lattice)`, detail: `${problemLine(p)}; the gate holds each candidate to the lattice it would replace, so the config as written must build and check PASS first` })));
627
+ }
628
+ const latticeRig = JSON.parse(readFileSync(join(base.rigDir, 'rig.json'), 'utf8')) as RigSpec;
629
+
630
+ // 2. Candidates for every lattice entry; a real piece keeps the lattice.
631
+ const rows = new Map<string, PartRow>();
632
+ const candidates = new Map<string, ContourMeshSpec>();
633
+ for (const [part, spec] of Object.entries(cfg.meshes)) {
634
+ if (!('grid' in spec)) continue;
635
+ const img = images.get(part);
636
+ const att = attachmentOf(latticeRig, part);
637
+ const art = img === undefined ? 0 : artPixels(img);
638
+ const row: PartRow = { part, mode: 'grid', lattice: { grid: spec.grid, art_px: art, triangles: att.triangles.length / 3, overdraw: overdrawOf(att, art) }, candidate: null, readings: null, gate: [], note: '' };
639
+ rows.set(part, row);
640
+ if (img === undefined) {
641
+ row.note = 'keeps its lattice: parts/ holds no image for it';
642
+ continue;
643
+ }
644
+ const dust = dustOf(img);
645
+ if ('keep' in dust) {
646
+ row.note = `keeps its lattice: ${dust.keep}`;
647
+ continue;
648
+ }
649
+ candidates.set(part, candidateMesh(spec, rules, dust.stray));
650
+ }
651
+ const dustText = (part: string): string => {
652
+ const img = images.get(part);
653
+ const d = img === undefined ? null : dustOf(img);
654
+ return d === null || 'keep' in d || d.dust.length === 0 ? 'no dust' : `stray ${d.stray as number} leaves out ${d.dust.length} dust island(s) of ${d.dust.join(', ')} px`;
655
+ };
656
+
657
+ // 3. The drafted rig must build: a refusal laid on a part keeps that part's lattice.
658
+ const keep = (part: string, reading: Reading, text: string): void => {
659
+ const row = rows.get(part) as PartRow;
660
+ row.gate.push({ reading, pass: false, text, margin: null });
661
+ candidates.delete(part);
662
+ };
663
+ let draftedRig: { rigDir: string } | null = null;
664
+ for (let round = 0; candidates.size > 0; round++) {
665
+ const r = stage(`draft-${round}`, candidates, false);
666
+ if (!('refused' in r)) {
667
+ draftedRig = r;
668
+ break;
669
+ }
670
+ const among = [...candidates.keys()];
671
+ const laid = new Set<string>();
672
+ for (const p of r.refused) for (const n of partsNamed(p, among, null)) {
673
+ laid.add(n);
674
+ keep(n, 'bars', `the drafted rig is refused at the rig stage: ${problemLine(p)}`);
675
+ }
676
+ if (laid.size === 0) for (const n of among) keep(n, 'bars', `the drafted rig is refused at the rig stage, naming no part: ${r.refused.map(problemLine).join('; ')}`);
677
+ }
678
+
679
+ // 4. Readings 2-4, through rig-c's comparison of three unpacked gate builds that differ in the candidates' attachments only.
680
+ if (draftedRig !== null && candidates.size > 0) {
681
+ const draftRig = JSON.parse(readFileSync(join(draftedRig.rigDir, 'rig.json'), 'utf8')) as RigSpec;
682
+ const motion = JSON.parse(readFileSync(join(draftedRig.rigDir, 'motion.json'), 'utf8')) as Json;
683
+ const names = [...candidates.keys()];
684
+ const sets = names.map((part) => ({ part, links: bendLinks(cfg, cfg.meshes[part].segments) }));
685
+ const bends = bendAnimations(sets);
686
+ const taken = Object.keys(bends).filter((n) => n in ((motion.animations ?? {}) as Json));
687
+ if (taken.length > 0) {
688
+ throw new PartsError([{ code: 'MESH_DRAFT_BEND_NAMES', object: `motion.json${animations === null ? '' : ` (with ${animations.file})`}`, detail: `already holds the animation(s) ${taken.map((n) => `"${n}"`).join(', ')}, which the gate's bend set is walked as; rename them — a bend is added beside the motion's animations and never replaces one` }]);
689
+ }
690
+ const bentMotion = rigJsonText({ ...motion, animations: { ...(motion.animations as Json), ...bends } });
691
+ // The dense reference: the same parts as lattices at the reference's spacing, built in-process; only their attachments are read.
692
+ const denseMeshes = new Map<string, LatticeMeshSpec>();
693
+ for (const part of names) {
694
+ const l = cfg.meshes[part] as LatticeMeshSpec;
695
+ denseMeshes.set(part, { grid: denseGrid(artPixels(images.get(part) as Raster)), r: l.r, segments: l.segments, ...(l.exponent === undefined ? {} : { exponent: l.exponent }), ...(l.rule === undefined ? {} : { rule: l.rule }) });
696
+ }
697
+ const denseRig = buildRig({ ...cfg, meshes: { ...cfg.meshes, ...Object.fromEntries(denseMeshes) } }, parts, images, undefined, input.idleKeys).rig;
698
+ const pngs: Array<[string, Uint8Array]> = readdirSync(join(draftedRig.rigDir, 'images')).sort().map((f) => [f, new Uint8Array(readFileSync(join(draftedRig.rigDir, 'images', f)))]);
699
+ const model = (label: string, from: RigSpec | null): string => {
700
+ const rig = JSON.parse(rigJsonText(draftRig)) as RigSpec;
701
+ if (from !== null) for (const part of names) rig.skins.default[part] = { [part]: attachmentOf(from, part) };
702
+ let doc: string | null = null;
703
+ const [g] = gateThroughRigc(pngs, [['rig.json', rigJsonText(rig)], ['motion.json', bentMotion]], rigc, join(scratch, `compare-${label}`), null, (dir) => {
704
+ const p = join(dir, RIGC_MODEL_DOCUMENT);
705
+ doc = existsSync(p) ? readFileSync(p, 'utf8') : null;
706
+ });
707
+ log(`[compare] rigc ${g.label}, the drafted rig with every candidate part as its ${label}: exit ${g.status}`);
708
+ for (const l of g.lines) log(`[compare] ${l.trim()}`);
709
+ if (g.status !== 0 || doc === null) {
710
+ throw new PartsError([{ code: 'MESH_DRAFT_COMPARE_BUILD', object: `the comparison build with every candidate part as its ${label}`, detail: `rigc's gate ${g.status !== 0 ? `exited ${g.status}: ${g.lines.map((l) => l.trim()).join(' | ')}` : `wrote no ${RIGC_MODEL_DOCUMENT}`}; a build that is not gated is not evidence, and nothing is written` }]);
711
+ }
712
+ return doc;
713
+ };
714
+ const latticeDoc = model('lattice', latticeRig);
715
+ const candidateDoc = model('candidate', null);
716
+ const denseDoc = model('dense reference', denseRig);
717
+ const masks = new Map<string, AlphaMask>();
718
+ for (const part of names) {
719
+ const att = attachmentOf(draftRig, part);
720
+ const img = readPng(join(draftedRig.rigDir, 'images', att.image));
721
+ const alpha = new Uint8Array(img.width * img.height);
722
+ for (let i = 0; i < alpha.length; i++) alpha[i] = img.data[i * 4 + 3];
723
+ masks.set(part, { width: img.width, height: img.height, alpha });
724
+ }
725
+ const compare = (what: string, reference: string, cands: string[], schedule: MotionSchedule, perFrame: boolean): MeshQualityReport => {
726
+ const inputC: MotionComparisonInput = {
727
+ reference: { id: 'reference', model: reference },
728
+ candidates: cands.map((m, i) => ({ id: `candidate ${i}`, model: m })),
729
+ attachments: names.map((part) => {
730
+ const mask = masks.get(part) as AlphaMask;
731
+ return { attachment: { skin: null, slot: part, attachment: part }, art: { mask, threshold: 1, frame: { space: 'part-local-drawing-px-y-down', width: mask.width, height: mask.height, pageScale: 1, conversion: 'texels = px * pageScale' } }, finalThreshold: 1, minArtSamples: 1, regions: [] };
732
+ }),
733
+ // The gate reads motion only: the art fit is the contour mode's and check's, so no fit bound is declared here.
734
+ referenceArtFit: NO_ART_FIT,
735
+ candidateArtFit: NO_ART_FIT,
736
+ schedule,
737
+ bounds: { maxLocalDeformation: IDLE_BOUND_PX },
738
+ motionRequired: false,
739
+ perFrame,
740
+ };
741
+ try {
742
+ return compareMeshesInMotion(inputC);
743
+ } catch (err) {
744
+ if (!(err instanceof MeshReductionError)) throw err;
745
+ throw new PartsError([{ code: 'MESH_DRAFT_COMPARE', object: `the ${what} comparison`, detail: `rig-c's compareMeshesInMotion refused it (${err.code}): ${err.message}; nothing is written` }]);
746
+ }
747
+ };
748
+ const idle = compare('idle', latticeDoc, [candidateDoc], idleSchedule(), false);
749
+ const bendFrames = Object.keys(bends).map((animation) => ({ animation, times: [0] }));
750
+ const bend = bendFrames.length === 0 ? null : compare('bend set', denseDoc, [latticeDoc, candidateDoc], { frames: bendFrames, phases: ['grid'], physics: { mode: 'step', dt: 1 / IDLE_FPS, warmupSteps: 0 }, selection: [] }, true);
751
+ for (const { part, links } of sets) {
752
+ const row = rows.get(part) as PartRow;
753
+ const read = links.length === 0 ? null : bend === null ? 'no bend comparison ran' : bendReading(bend, part, names.length);
754
+ const att = attachmentOf(draftRig, part);
755
+ const img = images.get(part) as Raster;
756
+ row.candidate = { triangles: att.triangles.length / 3, overdraw: overdrawOf(att, artPixels(img)), mesh: candidates.get(part) as ContourMeshSpec };
757
+ const readings: PartReadings = { bars: null, idle: idleReading(idle, part), bend: read === null || typeof read === 'string' ? read : { links, ...read }, triangles: { lattice: row.lattice.triangles, candidate: row.candidate.triangles } };
758
+ row.readings = readings;
759
+ const v = gateVerdict({ ...readings, bars: [] }, input.moreTriangles === true);
760
+ const failed = v.lines.filter((x) => !x.pass && x.reading !== 'bars');
761
+ if (failed.length > 0) {
762
+ row.gate = v.lines.filter((x) => x.reading !== 'bars');
763
+ candidates.delete(part);
764
+ }
765
+ }
766
+ }
767
+
768
+ // 5. Reading 1 on the config as it will be written: check it, lay each failing bar on the parts it names, repeat.
769
+ let final: { rigDir: string; report: CheckReport } = base;
770
+ const latticeSeams = (base.report.figures.seam_pairs ?? []).map((s) => ({ parts: s.parts, px_over_40: s.px_over_40 }));
771
+ for (let round = 0; candidates.size > 0; round++) {
772
+ const r = stage(`final-${round}`, candidates, true);
773
+ const among = [...candidates.keys()];
774
+ const problems = 'refused' in r ? r.refused : r.report.problems;
775
+ if (!('refused' in r) && r.report.figures.PASS) {
776
+ final = r;
777
+ break;
778
+ }
779
+ const seams = 'refused' in r ? null : { draft: (r.report.figures.seam_pairs ?? []).map((s) => ({ parts: s.parts, px_over_40: s.px_over_40 })), lattice: latticeSeams };
780
+ const laid = new Map<string, string[]>();
781
+ for (const p of problems) for (const n of partsNamed(p, among, seams)) laid.set(n, [...(laid.get(n) ?? []), problemLine(p)]);
782
+ if (laid.size === 0) for (const n of among) laid.set(n, [`the drafted rig fails a bar that names no part: ${problems.map(problemLine).join('; ')}`]);
783
+ for (const [n, why] of laid) {
784
+ const row = rows.get(n) as PartRow;
785
+ if (row.readings !== null) row.readings.bars = why;
786
+ row.gate = gateVerdict(row.readings ?? { bars: why, idle: 'not measured', bend: 'not measured', triangles: 'not measured' }, input.moreTriangles === true).lines;
787
+ candidates.delete(n);
788
+ }
789
+ }
790
+
791
+ // 6. The rows, the notes, the written config.
792
+ const drafted = new Map<string, unknown>();
793
+ for (const row of rows.values()) {
794
+ const cand = candidates.get(row.part);
795
+ if (cand !== undefined && row.readings !== null) {
796
+ row.readings.bars = [];
797
+ const v = gateVerdict(row.readings, input.moreTriangles === true);
798
+ row.gate = v.lines;
799
+ row.mode = 'contour';
800
+ const latticeGrid = row.lattice.grid;
801
+ row.note =
802
+ `drafted by rig-parts mesh-draft in place of the lattice at grid ${latticeGrid}: the traced outline at tolerance and margin ${rules.tolerance}, no outline edge longer than ${rules.spacing} px, the interior grid at spacing ${rules.spacing} (figure ${rules.height} px), ${dustText(row.part)}; ` +
803
+ `held to the lattice: ${v.lines.map((x) => `${x.reading} — ${x.text}`).join('; ')}; ${row.readings.bend === null ? 'measured against the idle only (no chain link, so no bend set)' : MEASURED_SENTENCE}; ${RELATIVE_SENTENCE}; ${CALIBRATED_SENTENCE}`;
804
+ drafted.set(row.part, { ...cand, note: row.note });
805
+ } else if (row.note === '') {
806
+ const failed = row.gate.filter((x) => !x.pass);
807
+ row.note = `keeps its lattice at grid ${row.lattice.grid}: ${failed.length === 0 ? 'no candidate was read' : failed.map((x) => `${x.reading} — ${x.text}`).join('; ')}`;
808
+ }
809
+ }
810
+ const configText = splicedConfig(text, drafted);
811
+ const rowsOut = [...rows.values()];
812
+ const report = {
813
+ spec: MESH_DRAFT_SPEC,
814
+ rules: {
815
+ figure: { top: rules.top, bottom: rules.bottom, height: rules.height },
816
+ tolerance: { value: rules.tolerance, rule: `max(1, round(H / ${TOLERANCE_FIGURE_DIVISOR}))` },
817
+ spacing: { value: rules.spacing, rule: `round(H / ${FIGURE_GRID_DIVISOR})` },
818
+ calibrated_on: CALIBRATION_HEIGHTS,
819
+ dust: { max_px: DUST_MAX_PX, max_share: DUST_MAX_SHARE },
820
+ gate: { more_triangles: input.moreTriangles === true, idle_bound_px: IDLE_BOUND_PX, bend_degrees: BEND_DEGREES, bend_worst_share: BEND_WORST_SHARE, dense_reference: `each candidate part as a lattice at grid max(1, ceil(sqrt(art px / ${DENSE_MAX_SAMPLES})))` },
821
+ },
822
+ lattice_check: barsOf(base.report),
823
+ draft_check: barsOf(final.report),
824
+ figure_overdraw: figureOverdraw(rowsOut),
825
+ parts: rowsOut.map((r) => ({
826
+ part: r.part,
827
+ mode: r.mode,
828
+ lattice: r.lattice,
829
+ candidate: r.candidate === null ? null : { triangles: r.candidate.triangles, overdraw: r.candidate.overdraw },
830
+ // Under --more-triangles only: what accepting a costlier candidate trades, triangles against overdraw.
831
+ ...(input.moreTriangles === true && r.candidate !== null ? { delta: { triangles: r.candidate.triangles - r.lattice.triangles, overdraw: pyRound(r.candidate.overdraw - r.lattice.overdraw, 4) } } : {}),
832
+ readings: r.readings,
833
+ gate: r.gate,
834
+ note: r.note,
835
+ })),
836
+ };
837
+ mkdirSync(input.out, { recursive: true });
838
+ const written = [join(input.out, 'config.json'), join(input.out, MESH_DRAFT_REPORT)];
839
+ writeFileSync(written[0], configText);
840
+ writeFileSync(written[1], `${JSON.stringify(report, null, 2)}\n`);
841
+ for (const r of rowsOut) {
842
+ const cand = r.candidate === null ? '' : `; candidate ${r.candidate.triangles} triangle(s), overdraw ${r.candidate.overdraw}`;
843
+ log(` ${r.mode === 'contour' ? 'DRAFTED' : 'LATTICE'} ${r.part}: lattice grid ${r.lattice.grid} ${r.lattice.triangles} triangle(s), overdraw ${r.lattice.overdraw}${cand}`);
844
+ for (const g of r.gate) log(` ${g.pass ? 'pass' : 'FAIL'} ${g.reading}: ${g.text}`);
845
+ if (r.mode === 'grid' && r.gate.length === 0) log(` ${r.note}`);
846
+ }
847
+ const nDrafted = rowsOut.filter((r) => r.mode === 'contour').length;
848
+ const tri = (pick: (r: PartRow) => number): number => rowsOut.reduce((a, r) => a + pick(r), 0);
849
+ log(`rig-parts mesh-draft: ${nDrafted} of ${rowsOut.length} lattice part(s) drafted; their triangles ${tri((r) => r.lattice.triangles)} -> ${tri((r) => (r.mode === 'contour' && r.candidate !== null ? r.candidate.triangles : r.lattice.triangles))}, the figure's overdraw over them ${report.figure_overdraw.lattice} -> ${report.figure_overdraw.written}${input.moreTriangles === true ? ` (${MORE_TRIANGLES_FLAG}: triangles reported, not a line)` : ''}; the drafted config's check: ${final.report.figures.PASS ? 'PASS' : 'FAIL'}`);
850
+ if (resolve(configDir) !== resolve(input.out)) {
851
+ for (const r of relativePaths(raw)) log(` note: ${r.at} is "${r.value}", relative to the config's directory; the written config keeps the text, so it resolves against ${input.out} — copy the file beside the written config, or the written config beside it`);
852
+ }
853
+ log(`rig-parts mesh-draft: wrote ${written.join(' and ')}`);
854
+ return { rules, rows: rowsOut, written };
855
+ }
856
+
857
+ /**
858
+ * The figure's overdraw over its lattice parts: Σ mesh area over Σ art px, for the lattices as the config states them
859
+ * and for the meshes the written config holds (a part's overdraw times its art px is its mesh area, to the overdraw's
860
+ * 4 decimals).
861
+ */
862
+ export function figureOverdraw(rows: readonly PartRow[]): { lattice: number; written: number; art_px: number } {
863
+ const art = rows.reduce((a, r) => a + r.lattice.art_px, 0);
864
+ const area = (pick: (r: PartRow) => number): number => rows.reduce((a, r) => a + pick(r) * r.lattice.art_px, 0);
865
+ const lattice = area((r) => r.lattice.overdraw);
866
+ const written = area((r) => (r.mode === 'contour' && r.candidate !== null ? r.candidate.overdraw : r.lattice.overdraw));
867
+ return { lattice: art === 0 ? 0 : pyRound(lattice / art, 4), written: art === 0 ? 0 : pyRound(written / art, 4), art_px: art };
868
+ }
869
+
870
+ /** No art-fit bound: the comparison's setup measurement is reported, never gated (`ArtFitBounds`, rig-c). */
871
+ const NO_ART_FIT = { minCoverage: 0, maxOvershoot: null, maxUndercut: null };
872
+
873
+ /** A check report's bars as the mesh-draft report holds them: PASS, and each bar's status. */
874
+ function barsOf(r: CheckReport): { PASS: boolean; bars: Record<string, string>; problems: string[] } {
875
+ const bars: Record<string, string> = {};
876
+ for (const b of BARS) bars[b] = r.bars.skipped.some((s) => s.bar === b) ? 'SKIP' : r.problems.some((p) => problemBar(p) === b) ? 'FAIL' : 'PASS';
877
+ return { PASS: r.figures.PASS, bars, problems: r.problems.map(problemLine) };
878
+ }
879
+
880
+ /** The bar a check problem belongs to, by its code. */
881
+ function problemBar(p: Problem): string {
882
+ if (p.code === 'CHECK_RIGC_GREEN') return 'gate';
883
+ if (p.code === 'CHECK_LOOP_CLOSES') return 'loop';
884
+ if (p.code === 'CHECK_SEAM_WITHIN_BAR') return 'seam';
885
+ return p.code.replace(/^CHECK_/, '');
886
+ }
887
+