@rig-c/parts 1.9.0 → 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.
package/src/assemble.ts CHANGED
@@ -95,7 +95,7 @@ import type { CharacterConfig, Cut, EarlyConfig, Extend, Patch, PlanEntry, Run,
95
95
  import { type Problem, refuseIfAny } from './errors.ts';
96
96
  import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
97
97
  import { squarePad } from './inputs.ts';
98
- import { PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord } from './parts.ts';
98
+ import { PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord } from './parts.ts';
99
99
  import {
100
100
  alphaComposite,
101
101
  connectedComponents,
@@ -114,6 +114,7 @@ import {
114
114
  warpAffine,
115
115
  } from './raster/index.ts';
116
116
  import { pyRound } from './round.ts';
117
+ import { AUTHOR_NO_ROLE, readAuthorTag } from './tags.ts';
117
118
 
118
119
  const f32 = Math.fround;
119
120
 
@@ -237,6 +238,8 @@ export interface AssembleInput {
237
238
  under?: ReadonlyArray<Raster | null>;
238
239
  seamRule: SeamRule;
239
240
  projectRule: ProjectRule;
241
+ /** `config.scene` (issue #242): the painting is a scene, so the recomposite is read inside the figure ({@link sceneFigure}). Absent or false: nothing changes. */
242
+ scene?: boolean;
240
243
  }
241
244
 
242
245
  /** The derived numbers of the two runs' geometry. */
@@ -1239,11 +1242,12 @@ function coveredMask(parts: PlacedPart[], W: number, H: number): Uint8Array {
1239
1242
  return covered;
1240
1243
  }
1241
1244
 
1242
- /** Per rig pixel: 1 where the recomposite's max-channel difference from the painting is above `ERROR_LIMIT`. */
1243
- function errorMask(can: Raster, srcr: Raster): Uint8Array {
1245
+ /** Per rig pixel: 1 where the recomposite's max-channel difference from the painting is above `ERROR_LIMIT` — and, with `inside` (a scene, issue #242), the pixel is the figure's. */
1246
+ function errorMask(can: Raster, srcr: Raster, inside: Uint8Array | null = null): Uint8Array {
1244
1247
  const n = srcr.width * srcr.height;
1245
1248
  const err = new Uint8Array(n);
1246
1249
  for (let p = 0; p < n; p++) {
1250
+ if (inside !== null && inside[p] === 0) continue;
1247
1251
  let m = 0;
1248
1252
  for (let c = 0; c < 3; c++) m = Math.max(m, Math.abs(can.data[p * 4 + c] - srcr.data[p * 4 + c]));
1249
1253
  if (m > ERROR_LIMIT) err[p] = 1;
@@ -1270,10 +1274,10 @@ function errorMask(can: Raster, srcr: Raster): Uint8Array {
1270
1274
  * This is not in the reference, whose report stopped at the count. The count
1271
1275
  * cannot say where to look; a box and the parts round it can.
1272
1276
  */
1273
- export function uncoveredHoles(can: Raster, srcr: Raster, parts: PlacedPart[]): { count: number; holes: RecompositeHole[] } {
1277
+ export function uncoveredHoles(can: Raster, srcr: Raster, parts: PlacedPart[], inside: Uint8Array | null = null): { count: number; holes: RecompositeHole[] } {
1274
1278
  const { width: W, height: H } = srcr;
1275
1279
  const covered = coveredMask(parts, W, H);
1276
- const err = errorMask(can, srcr);
1280
+ const err = errorMask(can, srcr, inside);
1277
1281
  const mask = newMask(W, H);
1278
1282
  for (let p = 0; p < W * H; p++) if (err[p] === 1 && covered[p] === 0) mask.data[p] = 1;
1279
1283
  const cc = connectedComponents(mask, 8);
@@ -1312,14 +1316,23 @@ export function uncoveredHoles(can: Raster, srcr: Raster, parts: PlacedPart[]):
1312
1316
  return { count: all.length, holes };
1313
1317
  }
1314
1318
 
1315
- export function measureRecomposite(can: Raster, srcr: Raster, parts: PlacedPart[]): RecompositeFigures {
1319
+ /**
1320
+ * The recomposite's four figures and its holes. With `inside` (a scene, issue
1321
+ * #242; {@link sceneFigure}) every one is read over the figure's pixels alone:
1322
+ * the mean and the within share are over them, and a pixel outside is neither an
1323
+ * error pixel nor a hole's. Without it, over the whole rig, as always.
1324
+ */
1325
+ export function measureRecomposite(can: Raster, srcr: Raster, parts: PlacedPart[], inside: Uint8Array | null = null): RecompositeFigures {
1316
1326
  const { width: W, height: H } = srcr;
1317
1327
  const covered = coveredMask(parts, W, H);
1318
1328
  let sum = 0;
1319
1329
  let within = 0;
1320
1330
  let errorPx = 0;
1321
1331
  let uncoveredErrorPx = 0;
1332
+ let read = 0;
1322
1333
  for (let p = 0; p < W * H; p++) {
1334
+ if (inside !== null && inside[p] === 0) continue;
1335
+ read++;
1323
1336
  let s = 0;
1324
1337
  let m = 0;
1325
1338
  for (let c = 0; c < 3; c++) {
@@ -1335,8 +1348,44 @@ export function measureRecomposite(can: Raster, srcr: Raster, parts: PlacedPart[
1335
1348
  if (covered[p] === 0) uncoveredErrorPx++;
1336
1349
  }
1337
1350
  }
1338
- const { count, holes } = uncoveredHoles(can, srcr, parts);
1339
- return { meanAbs: sum / (W * H), within: within / (W * H), errorPx, uncoveredErrorPx, holeCount: count, holes };
1351
+ const { count, holes } = uncoveredHoles(can, srcr, parts, inside);
1352
+ // Over the whole rig `read` is W * H, and the two quotients are the ones they always were.
1353
+ return { meanAbs: inside === null ? sum / (W * H) : read === 0 ? 0 : sum / read, within: inside === null ? within / (W * H) : read === 0 ? 0 : within / read, errorPx, uncoveredErrorPx, holeCount: count, holes };
1354
+ }
1355
+
1356
+ /** The figure mask's file name, beside the recomposite (issue #242): `recomposite_rig.png` -> `figure_rig.png`. */
1357
+ export const FIGURE_MASK_FILE = 'figure_rig.png';
1358
+
1359
+ /**
1360
+ * The figure of a scene painting (issue #242): per rig pixel, 1 where any
1361
+ * full-run layer — See-through's decomposition of the whole figure, each layer
1362
+ * placed on the rig as the stage places it — has alpha above
1363
+ * `OPAQUE_ALPHA_ABOVE` (8), the cut this package already reads a layer's
1364
+ * painted pixels by (ghost clean-up, the sheets, every part's opaque count).
1365
+ * The threshold is that one, not a new one: a pixel no layer paints is the
1366
+ * scene's, and a pixel any layer paints is the figure's, by the same rule that
1367
+ * says what a layer holds. The head run is not read: it is a crop of the same
1368
+ * figure, inside the full run's.
1369
+ */
1370
+ export function sceneFigure(fullRig: readonly Raster[], W: number, H: number): Uint8Array {
1371
+ const inside = new Uint8Array(W * H);
1372
+ for (const r of fullRig) for (let p = 0; p < W * H; p++) if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) inside[p] = 1;
1373
+ return inside;
1374
+ }
1375
+
1376
+ /** The figure as an image, the mask `assemble` writes beside the recomposite: opaque white where the figure is, opaque black elsewhere. */
1377
+ export function figureImage(inside: Uint8Array, W: number, H: number): Raster {
1378
+ const r = newRaster(W, H);
1379
+ for (let p = 0; p < W * H; p++) {
1380
+ const v = inside[p] === 1 ? 255 : 0;
1381
+ r.data.set([v, v, v, 255], p * 4);
1382
+ }
1383
+ return r;
1384
+ }
1385
+
1386
+ /** What a scene declaration says first on each recomposite line (issue #242): where it was read, and the figure's size against the rig's. */
1387
+ export function scenePhrase(s: SceneRecord, rigPx: number): string {
1388
+ return `inside the figure (config.scene: the union of the full run's layers at alpha above ${s.figure_alpha_above}, ${s.figure_px} of ${rigPx} rig px)`;
1340
1389
  }
1341
1390
 
1342
1391
  /** The error map's colour for an uncovered error pixel: no part is there, so the page shows through. */
@@ -1359,10 +1408,10 @@ export const MAP_MISMATCHED: readonly [number, number, number] = [0, 0, 255];
1359
1408
  * both), so the two colours mean exactly the two masks. Integer arithmetic
1360
1409
  * only, so the bytes are deterministic.
1361
1410
  */
1362
- export function recompositeErrorMap(can: Raster, srcr: Raster, parts: PlacedPart[]): Raster {
1411
+ export function recompositeErrorMap(can: Raster, srcr: Raster, parts: PlacedPart[], inside: Uint8Array | null = null): Raster {
1363
1412
  const { width: W, height: H } = srcr;
1364
1413
  const covered = coveredMask(parts, W, H);
1365
- const err = errorMask(can, srcr);
1414
+ const err = errorMask(can, srcr, inside);
1366
1415
  const map = newRaster(W, H);
1367
1416
  for (let p = 0; p < W * H; p++) {
1368
1417
  const s = p * 4;
@@ -1381,9 +1430,9 @@ export function recompositeErrorMap(can: Raster, srcr: Raster, parts: PlacedPart
1381
1430
  return map;
1382
1431
  }
1383
1432
 
1384
- export function figuresLine(f: RecompositeFigures): string {
1433
+ export function figuresLine(f: RecompositeFigures, scene: string | null = null): string {
1385
1434
  return (
1386
- `recomposite vs source: mean |d|=${f.meanAbs.toFixed(2)}, within ${WITHIN_LIMIT}: ${(100 * f.within).toFixed(1)}%, ` +
1435
+ `${scene === null ? '' : `${scene}: `}recomposite vs source: mean |d|=${f.meanAbs.toFixed(2)}, within ${WITHIN_LIMIT}: ${(100 * f.within).toFixed(1)}%, ` +
1387
1436
  `error px > ${ERROR_LIMIT}: ${f.errorPx}, uncovered error px: ${f.uncoveredErrorPx}`
1388
1437
  );
1389
1438
  }
@@ -1398,8 +1447,8 @@ export function holeBox(h: RecompositeHole): string {
1398
1447
  * hole — `uncovered hole 1: 2340 px at 412,1088 30x78 (between "legwear_r"
1399
1448
  * 120 px, "legwear_l" 96 px)`, or `(borders no part)`.
1400
1449
  */
1401
- export function holeLines(f: RecompositeFigures): string[] {
1402
- const head = `uncovered holes (8-connected): ${f.holeCount}${f.holeCount > f.holes.length ? `, the largest ${f.holes.length} listed` : ''}`;
1450
+ export function holeLines(f: RecompositeFigures, scene: string | null = null): string[] {
1451
+ const head = `${scene === null ? '' : `${scene}: `}uncovered holes (8-connected): ${f.holeCount}${f.holeCount > f.holes.length ? `, the largest ${f.holes.length} listed` : ''}`;
1403
1452
  return [
1404
1453
  head,
1405
1454
  ...f.holes.map(
@@ -1665,6 +1714,8 @@ export interface AssembleResult {
1665
1714
  recomposite: Raster;
1666
1715
  /** `recompositeErrorMap`: uncovered error pixels red, covered ones blue, the painting dimmed grey. */
1667
1716
  errorMap: Raster;
1717
+ /** Under `config.scene` (issue #242), the figure the recomposite was read inside ({@link figureImage}); null otherwise. */
1718
+ figure: Raster | null;
1668
1719
  figures: RecompositeFigures;
1669
1720
  /** What each `assemble.cuts` entry took, counted when it was made (`cutLines`); empty without cuts. */
1670
1721
  cuts: CutCount[];
@@ -2113,12 +2164,15 @@ export function assemble(input: AssembleInput): AssembleResult {
2113
2164
 
2114
2165
  seamOverride(placed, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
2115
2166
  const can = recomposite(placed, W, H);
2116
- const figures = measureRecomposite(can, srcr, placed);
2167
+ // Issue #242: on a scene painting the figure is read off the full run, and every recomposite figure inside it.
2168
+ const inside = input.scene === true ? sceneFigure([...rig.entries()].filter(([k]) => k.startsWith('full:')).map(([, r]) => r), W, H) : null;
2169
+ const sceneRecord: SceneRecord | null = inside === null ? null : { figure_alpha_above: OPAQUE_ALPHA_ABOVE, figure_px: inside.reduce((a, b) => a + b, 0), figure_mask: FIGURE_MASK_FILE };
2170
+ const figures = measureRecomposite(can, srcr, placed, inside);
2117
2171
  const fullRig = fullTags.map((tag) => ({ tag, rig: rig.get(`full:${tag}`) as Raster }));
2118
2172
  const uncovered = uncoveredErrorLayers(
2119
2173
  fullRig,
2120
2174
  coveredMask(placed, W, H),
2121
- errorMask(can, srcr),
2175
+ errorMask(can, srcr, inside),
2122
2176
  figures.uncoveredErrorPx,
2123
2177
  plan,
2124
2178
  extend,
@@ -2128,10 +2182,11 @@ export function assemble(input: AssembleInput): AssembleResult {
2128
2182
  bodyMinPx(frame),
2129
2183
  );
2130
2184
  return {
2131
- parts: { rig_size: [W, H], scale_rig_per_source: frame.S, parts: placed.map((p) => p.record), ghost_px: ghost, recomposite: recompositeRecord(figures) },
2185
+ parts: { rig_size: [W, H], scale_rig_per_source: frame.S, parts: placed.map((p) => p.record), ghost_px: ghost, recomposite: recompositeRecord(figures), ...(sceneRecord === null ? {} : { scene: sceneRecord }) },
2132
2186
  images: placed,
2133
2187
  recomposite: can,
2134
- errorMap: recompositeErrorMap(can, srcr, placed),
2188
+ errorMap: recompositeErrorMap(can, srcr, placed, inside),
2189
+ figure: inside === null ? null : figureImage(inside, W, H),
2135
2190
  figures,
2136
2191
  cuts: cutCounts,
2137
2192
  uncovered,
@@ -2459,12 +2514,12 @@ export function uncoveredErrorLayers(
2459
2514
  * of the 240 uncovered error px lie on the full run's "back hair", in a body
2460
2515
  * of 240 px at 36,22 8x30; hair_back takes the head run's "back hair"; …`.
2461
2516
  */
2462
- export function uncoveredLine(d: UncoveredErrorLayer): string {
2517
+ export function uncoveredLine(d: UncoveredErrorLayer, scene: string | null = null): string {
2463
2518
  const u = d.layer;
2464
2519
  const total = u.bodies.reduce((a, b) => a + b.px, 0);
2465
2520
  const big = u.bodies[0];
2466
2521
  const held = u.bodies.length > 1 ? `${u.bodies.length} bodies of ${total} px, the largest ${big.px} px at ${bodyBox(big)}` : `a body of ${big.px} px at ${bodyBox(big)}`;
2467
- return `uncovered: ${d.errorPx} of the ${d.of} uncovered error px lie on the full run's "${u.tag}", in ${held}; ${u.rule}`;
2522
+ return `${scene === null ? '' : `${scene}: `}uncovered: ${d.errorPx} of the ${d.of} uncovered error px lie on the full run's "${u.tag}", in ${held}; ${u.rule}`;
2468
2523
  }
2469
2524
 
2470
2525
  /**
@@ -2534,7 +2589,10 @@ export const PROPOSE_ALIAS: Readonly<Record<string, string>> = {
2534
2589
  earwear: 'earring',
2535
2590
  };
2536
2591
 
2592
+ /** An author-made layer's part is named by its label as the author wrote it (issue #256): the label is already a part name (`authorLabelOk`). */
2537
2593
  function aliasOf(tag: string): string {
2594
+ const author = readAuthorTag(tag);
2595
+ if (author !== null) return author.label;
2538
2596
  return PROPOSE_ALIAS[tag] ?? tag.replaceAll(' ', '_').replaceAll('-', '_');
2539
2597
  }
2540
2598
 
@@ -2587,6 +2645,15 @@ function fmt0(v: number): string {
2587
2645
  * changed. [observed] reference-sample's proposal is the reference's byte for
2588
2646
  * byte; demo's, reference-demo's and scarf's each gain one note, on the hair
2589
2647
  * their configs had already corrected by hand.
2648
+ *
2649
+ * ⚖️ This port's too (issue #256): an author-made layer (`author:<label>`,
2650
+ * `src/tags.ts`) is proposed from the run that holds it, at its place in that
2651
+ * run's draw order, as the part named by its label, under the same floor and
2652
+ * plausibility rules as any layer — its draw position is then the plan's,
2653
+ * where the author moves it. A note per such part says it has no tag and so
2654
+ * no role. One both runs hold is proposed once, from the full run, and a note
2655
+ * says how to take the head run's too. No public example holds one, so no
2656
+ * proposal of theirs moves.
2590
2657
  */
2591
2658
  export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
2592
2659
  const frame = checkGeometry(g, { full, head });
@@ -2611,7 +2678,10 @@ export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx =
2611
2678
  return tag !== 'nose' && l !== undefined && l.opaqueRunPx >= minPx && !implausible(run, tag, 'not proposed');
2612
2679
  };
2613
2680
  const body = runs.full.filter((l) => !PROPOSE_HEAD_TAGS.includes(l.tag) && ok('full', l.tag)).map((l) => l.tag);
2614
- const heads = runs.head.filter((l) => PROPOSE_HEAD_TAGS.includes(l.tag) && ok('head', l.tag)).map((l) => l.tag);
2681
+ // Issue #256: an author-made layer is proposed from the run that holds it, at its place in that run's draw order,
2682
+ // like any layer; one both runs hold is proposed once, from the full run when the full run's is proposed, and the note says so.
2683
+ const authorBoth = runs.head.filter((l) => readAuthorTag(l.tag) !== null && body.includes(l.tag)).map((l) => l.tag);
2684
+ const heads = runs.head.filter((l) => (PROPOSE_HEAD_TAGS.includes(l.tag) || (readAuthorTag(l.tag) !== null && !authorBoth.includes(l.tag))) && ok('head', l.tag)).map((l) => l.tag);
2615
2685
  const hw = body.filter((t) => t.startsWith('handwear'));
2616
2686
  const plan: PlanEntry[] = [];
2617
2687
  const drop = (t: string): void => {
@@ -2647,6 +2717,12 @@ export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx =
2647
2717
  }
2648
2718
  }
2649
2719
  for (const t of heads) plan.push([aliasOf(t), 'head', t]);
2720
+ for (const [part, run, tag] of plan) {
2721
+ if (readAuthorTag(tag) !== null) notes.push(`${tag}: ${AUTHOR_NO_ROLE}; proposed as "${part}" from the ${run} run, at its place in that run's draw order`);
2722
+ }
2723
+ for (const tag of authorBoth) {
2724
+ notes.push(`${tag}: the head run holds a layer of this name too, and the proposer drafts one part per author-made layer, from the full run; to take the head run's as well, add ["<a part name>", "head", "${tag}"] to the plan`);
2725
+ }
2650
2726
  const extend: Extend[] = [];
2651
2727
  /** A hair tag the proposer did not continue below the crop: how far its full-run layer reaches, rig px (null: no opaque row). */
2652
2728
  const reaches = new Map<string, number | null>();
@@ -2719,7 +2795,7 @@ export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx =
2719
2795
  * the loader, because `inputs` runs before it exists; `seethrough` too, for a
2720
2796
  * full-loader config, where it is optional.
2721
2797
  */
2722
- export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble'>): {
2798
+ export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble' | 'scene'>): {
2723
2799
  resolution: number;
2724
2800
  headBox: [number, number, number, number];
2725
2801
  rigScale: number;
@@ -2727,6 +2803,7 @@ export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble'
2727
2803
  extend: Extend[];
2728
2804
  patches: Patch[];
2729
2805
  cuts: Cut[];
2806
+ scene?: boolean;
2730
2807
  } {
2731
2808
  const problems: Problem[] = [];
2732
2809
  if (cfg.seethrough === undefined) {
@@ -2744,6 +2821,7 @@ export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble'
2744
2821
  extend: cfg.assemble.extend_below_crop ?? [],
2745
2822
  patches: cfg.assemble.patches ?? [],
2746
2823
  cuts: cfg.assemble.cuts ?? [],
2824
+ ...(cfg.scene === undefined ? {} : { scene: true }),
2747
2825
  };
2748
2826
  }
2749
2827
 
@@ -82,6 +82,7 @@
82
82
  import { type BoneMotionRange, type EnvelopeBoneRanges, MeshReductionError, type MeshQualityReport, type MeshReductionInput, type SkinningEnvelope, type SkinningEnvelopeBone, skinningEnvelopeBone } from 'rig-c/mesh';
83
83
  import type { ConfigConstraint } from './config.ts';
84
84
  import type { MotionKey, MotionSpec } from './motion.ts';
85
+ import { edgeLength } from './length.ts';
85
86
 
86
87
  /** What the derivation reads off the rig besides the part: the bones (name, parent), each bone's setup joint in crop px, the idle as written, the constraints. */
87
88
  export interface EnvelopeBasis {
@@ -168,7 +169,7 @@ export function boneRange(basis: EnvelopeBasis, bone: string, origin: readonly [
168
169
  rotate,
169
170
  scaleX,
170
171
  scaleY,
171
- translate: Math.hypot(tx, ty),
172
+ translate: edgeLength(tx, ty),
172
173
  setup: { ...SETUP },
173
174
  };
174
175
  }
package/src/automotion.ts CHANGED
@@ -344,6 +344,47 @@ export function motionRowText(r: MeasureRow): string {
344
344
  return `${r.code}${region} ${r.state}: ${r.reason ?? ''}`;
345
345
  }
346
346
 
347
+ /**
348
+ * Where a row of a comparison was read at its worst, in the words a reader can act on (issue #280), with its
349
+ * preposition: a declared stimulus by its animation's name (`at stimulus tail_l -10`, the frame rig-c ids
350
+ * `stimulus tail_l -10@explicit@0`), an idle frame as `at idle frame <id>`, a geometry row — read on the mesh, at
351
+ * no frame — as `in its geometry`, and a motion row that names no frame as `on the idle`.
352
+ */
353
+ export function readingPlace(r: MeasureRow, stimulus: readonly StimulusPose[] | null, geometry: boolean): { text: string; stimulus: boolean } {
354
+ const id = r.worst?.frame?.id;
355
+ if (id === undefined) return { text: geometry ? 'in its geometry' : 'on the idle', stimulus: false };
356
+ const name = (stimulus ?? []).flatMap((st) => stimulusAnimationNames(st)).find((n) => explicitFrameId(n) === id);
357
+ return name === undefined ? { text: `at idle frame ${id}`, stimulus: false } : { text: `at ${name}`, stimulus: true };
358
+ }
359
+
360
+ /**
361
+ * Issue #280: the source compared with itself failed, so no replayed step can pass — the one sentence that says
362
+ * so (`the unreduced source itself fails <where> (<row> <value> against <bound>), so …`): where (a declared stimulus by name, or the idle frame), each failing row and its value against its bound,
363
+ * and the remedy, which is the source's (its spacing or outline), or the stimulus's when the failure is read at
364
+ * one. A row the source fails against itself is an absolute reading (`MQ_INVERSION` counts the source's own
365
+ * flipped triangles), which is why no reduction of it can pass. Null when the comparison has no failing row.
366
+ */
367
+ export function sourceSelfFailure(self: MeshQualityReport, stimulus: readonly StimulusPose[] | null): { where: string; sentence: string } | null {
368
+ const rows = failingRows(self);
369
+ if (rows.length === 0) return null;
370
+ const geometryRows = new Set(self.candidates[0]?.geometry?.rows ?? []);
371
+ const places = rows.map((r) => readingPlace(r, stimulus, geometryRows.has(r)));
372
+ const distinct = [...new Set(places.map((p) => p.text))];
373
+ const where = distinct.join(', ');
374
+ // One place: the rows say only their values; several: each row says its own.
375
+ const read = rows
376
+ .map((r, i) => {
377
+ const at = distinct.length > 1 ? ` ${places[i].text}` : '';
378
+ return r.state === 'fail' || r.state === 'pass' || r.state === 'undeclared' ? `${r.code} ${r.value}${r.bound === null ? '' : ` against ${r.bound.op} ${r.bound.value}`}${at}` : `${r.code} ${r.state}${at}: ${r.reason ?? ''}`;
379
+ })
380
+ .join('; ');
381
+ const atStimulus = places.some((p) => p.stimulus);
382
+ return {
383
+ where,
384
+ sentence: `the unreduced source itself fails ${where} (${read}), so no replayed step can pass — change the source (its spacing or its outline)${atStimulus ? ' or lower the stimulus' : ''}`,
385
+ };
386
+ }
387
+
347
388
  /** The candidate's rows with a declared bound that did not pass, geometry then motion. */
348
389
  export function failingRows(report: MeshQualityReport): MeasureRow[] {
349
390
  const c = report.candidates[0];
package/src/build.ts CHANGED
@@ -43,10 +43,10 @@
43
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, join, resolve } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
- import { assemble, type AssembleResult, cutLines, driftLine, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, driftLine, FIGURE_MASK_FILE, figuresLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
- import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, stimulusMotion, withStimulus } from './automotion.ts';
49
+ import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
50
50
  import {
51
51
  bisectAccepted,
52
52
  budgetProblem,
@@ -78,11 +78,12 @@ import { encodeGif } from './gif.ts';
78
78
  import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
79
79
  import type { PaletteError } from './palette.ts';
80
80
  import { type LayerSet, readLayers } from './layers.ts';
81
- import { type PartRecord, readParts, writeParts } from './parts.ts';
81
+ import { type PartRecord, readParts, tagOf, writeParts } from './parts.ts';
82
82
  import { encodePngBytes, readPng, writePng } from './raster/png.ts';
83
83
  import type { Raster } from './raster/types.ts';
84
84
  import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
85
85
  import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared, type TurnRow } from './rig.ts';
86
+ import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
86
87
  import { TURN_BONE } from './turn.ts';
87
88
  import { pyRound } from './round.ts';
88
89
 
@@ -161,6 +162,16 @@ export function fringeLines(parts: readonly PartRecord[]): string[] {
161
162
  });
162
163
  }
163
164
 
165
+ /**
166
+ * One line per part whose layer the author made (issue #256), in draw order:
167
+ * ` author-made: part "hand" (full:author:hand) — an author-made layer, …`.
168
+ * No line when no part came from one, so a plan of See-through layers prints
169
+ * what it printed before.
170
+ */
171
+ export function authorPartLines(parts: readonly PartRecord[]): string[] {
172
+ return parts.filter((p) => isAuthorTag(tagOf(p.from) ?? '')).map((p) => ` author-made: part "${p.name}" (${p.from}) — ${AUTHOR_NO_ROLE}`);
173
+ }
174
+
164
175
  /**
165
176
  * Each cut's `under` image (issue #193), read from its path relative to the
166
177
  * config's directory, null for a cut without one. Every cut is read before
@@ -207,6 +218,9 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
207
218
  for (const { record, image } of result.images) writePng(join(outs.partsDir, `${record.name}.png`), image);
208
219
  writePng(outs.recomposite, result.recomposite);
209
220
  writePng(outs.errorMap, result.errorMap);
221
+ // Issue #242: under config.scene, the figure the recomposite was read inside, beside the recomposite.
222
+ const figurePath = result.figure === null ? null : join(dirname(outs.recomposite), FIGURE_MASK_FILE);
223
+ if (figurePath !== null) writePng(figurePath, result.figure as Raster);
210
224
  const [W, H] = result.parts.rig_size;
211
225
  log(`rig-parts assemble: ${result.images.length} part(s) on a ${W}x${H} rig (${result.parts.scale_rig_per_source} rig px per source px), seam rule ${result.seamRule}, projection rule ${result.projectRule}`);
212
226
  for (const p of result.parts.parts) {
@@ -216,6 +230,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
216
230
  `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} merged=${p.merged_px} seam=${p.seam_override_px}`,
217
231
  );
218
232
  }
233
+ // Issue #256: one line per part from an author-made layer, so it never reads as See-through's. None without one.
234
+ for (const l of authorPartLines(result.parts.parts)) log(l);
219
235
  // One line per plan part an assemble.cuts entry cut, before the fringe lines: the cut is made first. None without cuts.
220
236
  for (const l of cutLines(result.cuts)) log(l);
221
237
  for (const l of fringeLines(result.parts.parts)) log(l);
@@ -228,11 +244,12 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
228
244
  );
229
245
  const ghosts = Object.values(result.parts.ghost_px).reduce((a, b) => a + b, 0);
230
246
  log(` ghost px removed: ${ghosts} over ${Object.keys(result.parts.ghost_px).length} layer(s)`);
231
- log(figuresLine(result.figures));
232
- for (const l of holeLines(result.figures)) log(l);
233
- for (const u of result.uncovered) log(uncoveredLine(u));
247
+ const scene = result.parts.scene === undefined ? null : scenePhrase(result.parts.scene, W * H);
248
+ log(figuresLine(result.figures, scene));
249
+ for (const l of holeLines(result.figures, scene)) log(l);
250
+ for (const u of result.uncovered) log(uncoveredLine(u, scene));
234
251
  for (const d of result.drift) log(driftLine(d));
235
- log(`wrote ${outs.partsJson}, ${result.images.length} PNG(s) in ${outs.partsDir}, ${outs.recomposite}, ${outs.errorMap} (uncovered error px red, covered error px blue, the painting grey)`);
252
+ log(`wrote ${outs.partsJson}, ${result.images.length} PNG(s) in ${outs.partsDir}, ${outs.recomposite}, ${outs.errorMap} (uncovered error px red, covered error px blue, the painting grey)${figurePath === null ? '' : `, ${figurePath} (the figure, white, the recomposite was read inside)`}`);
236
253
  return result;
237
254
  }
238
255
 
@@ -582,8 +599,12 @@ export function replaySearch(
582
599
  const self = runComparison(c.object, motionInput(c, motion, refModel, refModel, probeSchedule));
583
600
  if ('code' in self) return refuse(self);
584
601
  if (motionVerdict(c.object, self) !== null) {
585
- lines.push(`replay "${c.part}": the unreduced source is not accepted against itself on the grid frames; no search runs`);
586
- return { part: c.part, chosen: null, problems: run.problems, lines };
602
+ // Issue #280: the refusal says, in its own sentence, that the source fails itself — where, the row, the remedy;
603
+ // the reduction's reading follows it for the record. The line names the stimulus where it is read at one.
604
+ const why = sourceSelfFailure(self, c.stimulus);
605
+ lines.push(`replay "${c.part}": the unreduced source is not accepted against itself ${why === null ? 'on its selection frames' : why.where}; no search runs`);
606
+ const problems = why === null ? run.problems : run.problems.map((p) => (p.code === 'AUTO_MESH_MOTION' ? { ...p, detail: `${why.sentence}. The reduction's own reading, for the record: ${p.detail}` } : p));
607
+ return { part: c.part, chosen: null, problems, lines };
587
608
  }
588
609
  const selection = motion.selection;
589
610
  if (selection !== undefined && selection.maxProbes < maxReplays(N, I)) return refuse(budgetProblem(c.object, selection.maxProbes, N, I));
@@ -812,7 +833,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
812
833
  const c = m.contour;
813
834
  const stray = c.strayIslands === 0 ? '' : ` left out ${c.strayIslands} island(s), ${c.strayPixels} px`;
814
835
  const regions = m.regions.map((rg) => ` region ${rg.name}->${rg.bone} reaches ${rg.reached} (${rg.whole} whole)`).join('');
815
- log(`${head}contour tol=${m.params.tolerance} margin=${m.params.margin} spacing=${m.params.spacing} ${infl} overshoot ${c.overshoot} min angle ${c.smallestAngle.value}${stray}${regions}`);
836
+ log(`${head}contour tol=${m.params.tolerance} margin=${m.params.margin} spacing=${m.params.spacing}${m.params.maxEdge === undefined ? '' : ` maxEdge=${m.params.maxEdge} (outline +${c.outlineCut ?? 0})`} ${infl} overshoot ${c.overshoot} min angle ${c.smallestAngle.value}${stray}${regions}`);
816
837
  } else log(`${head}grid=${m.grid} ${infl} loop passes ${rig.loopPasses[m.part]}`);
817
838
  }
818
839
  const regions = rig.rig.slots.length - rig.meshReport.length;
@@ -1024,7 +1045,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1024
1045
  if (r.seamViewport === null || fig.seam_mean === null) log(` seam: SKIP — ${fig.skipped?.seam ?? ''}`);
1025
1046
  else {
1026
1047
  log(
1027
- ` seam: setup pose at ${r.seamViewport.pixelWidth}x${r.seamViewport.pixelHeight}, scale ${r.seamViewport.scale.toFixed(4)}: mean |d| ${fig.seam_mean} (<= ${SEAM_MEAN_BAR.toFixed(1)}), ` +
1048
+ ` seam${fig.seam_border === undefined ? '' : ` (config.scene: ${fig.seam_border.frame_px_left_out} frame px on the painting's border rows and columns left out, ${fig.seam_border.px_over_40_left_out} of them over ${SEAM_PX_LEVEL})`}: setup pose at ${r.seamViewport.pixelWidth}x${r.seamViewport.pixelHeight}, scale ${r.seamViewport.scale.toFixed(4)}: mean |d| ${fig.seam_mean} (<= ${SEAM_MEAN_BAR.toFixed(1)}), ` +
1028
1049
  `${fig.seam_px_over_40} px over ${SEAM_PX_LEVEL}, ${fig.seam_frame_scale === null || fig.seam_px_over_40 === null ? '' : seamRigPxText(fig.seam_px_over_40, fig.seam_frame_scale)} rig px (the count / s²; <= ${SEAM_PX_BAR}), ${fig.seam_px_over_80} px over ${SEAM_PX_LEVEL_HIGH} (reported)`,
1029
1050
  );
1030
1051
  const pairs = fig.seam_pairs ?? [];
@@ -1274,6 +1295,40 @@ export interface BuildInput {
1274
1295
  * `check/idle_frames/`; absent, the loop is as before.
1275
1296
  */
1276
1297
  leadIn?: number;
1298
+ /** `--seam-pairs`, forwarded to the check as `check` takes it (issue #257): the seam split by pair on a pass too; absent, nothing moves. */
1299
+ seamPairs?: boolean;
1300
+ /** `--keep-stills`, forwarded to the check as `check` takes it (issue #257): the setup-pose still under `check/setup_still/`; absent, nothing moves. */
1301
+ keepStills?: boolean;
1302
+ }
1303
+
1304
+ /** What `build` does with one flag `check` takes: hands it to its check stage, takes it as its own, or refuses it because build supplies the value itself. */
1305
+ export type CheckFlagInBuild =
1306
+ | { as: 'forwarded' }
1307
+ | { as: 'own'; why: string }
1308
+ | { as: 'supplied'; why: string };
1309
+
1310
+ /**
1311
+ * Every flag `check` takes and what `build` does with it (issue #257). `cli.ts`
1312
+ * refuses a `supplied` flag on `build` with its `why`; the selftest holds the
1313
+ * table's keys to the flags `check`'s own refusal lists, so a flag added to
1314
+ * `check` without a line here is a named failure, not a flag `build` drops.
1315
+ */
1316
+ export const CHECK_FLAGS_IN_BUILD: Readonly<Record<string, CheckFlagInBuild>> = {
1317
+ '--rig': { as: 'supplied', why: 'its check reads the rig its rig stage writes, <out>/rig; run check --rig to measure another rig' },
1318
+ '--out': { as: 'own', why: 'build\'s own --out; its check writes under <out>/check' },
1319
+ '--parts': { as: 'supplied', why: 'its check reads the parts its assemble stage writes, <out>; run check --parts to measure against others' },
1320
+ '--source': { as: 'own', why: 'the painting assemble reads; it is not handed to the check, so check.json carries no SETUP_POSE_VS_SOURCE — run check --rig <out>/rig --parts <out> --source <painting> for that line' },
1321
+ '--page-edges': { as: 'forwarded' },
1322
+ '--pack-shape': { as: 'forwarded' },
1323
+ '--requirements': { as: 'forwarded' },
1324
+ '--seam-pairs': { as: 'forwarded' },
1325
+ '--keep-stills': { as: 'forwarded' },
1326
+ };
1327
+
1328
+ /** The `build` refusal of a `check` flag build supplies itself ({@link CHECK_FLAGS_IN_BUILD}), or null for any other flag. */
1329
+ export function suppliedCheckFlag(flag: string): string | null {
1330
+ const row = Object.hasOwn(CHECK_FLAGS_IN_BUILD, flag) ? CHECK_FLAGS_IN_BUILD[flag] : undefined;
1331
+ return row?.as === 'supplied' ? `build does not take "${flag}"; ${row.why}` : null;
1277
1332
  }
1278
1333
 
1279
1334
  /**
@@ -1347,6 +1402,8 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1347
1402
  const out = input.out;
1348
1403
  mkdirSync(out, { recursive: true });
1349
1404
  for (const p of BUILD_OWNS) rmSync(join(out, p), { recursive: true, force: true });
1405
+ // Issue #242: the figure mask is written only under config.scene; one left by an earlier scene build is not this build's.
1406
+ rmSync(join(out, FIGURE_MASK_FILE), { force: true });
1350
1407
  // The idle-keys value is named only when it is not the default, so a build without the flag, or with the default, prints the line it printed before issue #95.
1351
1408
  const idleKeys = input.idleKeys ?? DEFAULT_IDLE_KEYS;
1352
1409
  log(`rig-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${idleKeys === DEFAULT_IDLE_KEYS ? '' : `, idle keys ${idleKeys}`}${input.loop ? `, with the idle loop${input.leadIn === undefined ? '' : ` after a lead-in of ${input.leadIn} period(s)`}` : ''}`);
@@ -1376,7 +1433,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1376
1433
 
1377
1434
  let report: CheckReport;
1378
1435
  try {
1379
- report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges, packShape: input.packShape, ...(input.requirements === undefined ? {} : { requirements: input.requirements }) }, run.check, run.checkBin, prefixed('check'));
1436
+ report = checkStage({ rig: join(out, 'rig'), parts: out, out: join(out, 'check'), pageEdges: input.pageEdges, packShape: input.packShape, ...(input.requirements === undefined ? {} : { requirements: input.requirements }), seamPairs: input.seamPairs === true, keepStills: input.keepStills === true }, run.check, run.checkBin, prefixed('check'));
1380
1437
  } catch (err) {
1381
1438
  return refused('check', err);
1382
1439
  }