@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.
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
 
package/src/build.ts CHANGED
@@ -43,7 +43,7 @@
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
49
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
@@ -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
 
@@ -816,7 +833,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
816
833
  const c = m.contour;
817
834
  const stray = c.strayIslands === 0 ? '' : ` left out ${c.strayIslands} island(s), ${c.strayPixels} px`;
818
835
  const regions = m.regions.map((rg) => ` region ${rg.name}->${rg.bone} reaches ${rg.reached} (${rg.whole} whole)`).join('');
819
- 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}`);
820
837
  } else log(`${head}grid=${m.grid} ${infl} loop passes ${rig.loopPasses[m.part]}`);
821
838
  }
822
839
  const regions = rig.rig.slots.length - rig.meshReport.length;
@@ -1028,7 +1045,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1028
1045
  if (r.seamViewport === null || fig.seam_mean === null) log(` seam: SKIP — ${fig.skipped?.seam ?? ''}`);
1029
1046
  else {
1030
1047
  log(
1031
- ` 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)}), ` +
1032
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)`,
1033
1050
  );
1034
1051
  const pairs = fig.seam_pairs ?? [];
@@ -1278,6 +1295,40 @@ export interface BuildInput {
1278
1295
  * `check/idle_frames/`; absent, the loop is as before.
1279
1296
  */
1280
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;
1281
1332
  }
1282
1333
 
1283
1334
  /**
@@ -1351,6 +1402,8 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1351
1402
  const out = input.out;
1352
1403
  mkdirSync(out, { recursive: true });
1353
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 });
1354
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.
1355
1408
  const idleKeys = input.idleKeys ?? DEFAULT_IDLE_KEYS;
1356
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)`}` : ''}`);
@@ -1380,7 +1433,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1380
1433
 
1381
1434
  let report: CheckReport;
1382
1435
  try {
1383
- 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'));
1384
1437
  } catch (err) {
1385
1438
  return refused('check', err);
1386
1439
  }