@rig-c/parts 0.0.0-stage → 1.5.4

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
@@ -0,0 +1,2259 @@
1
+ /**
2
+ * The assemble stage: two See-through runs over one painting — a FULL-body run
3
+ * and a HEAD-crop run — merged into one set of rig-space parts, each part one
4
+ * RGBA image cropped to its own alpha box, plus the `parts.json` measurement
5
+ * record (`src/parts.ts`) and a flat recomposite to compare with the painting.
6
+ *
7
+ * Rig space is source pixels times `assemble.rig_scale`, y down, origin
8
+ * top-left; the rig canvas is `trunc(w * s) x trunc(h * s)`. The painting is
9
+ * resampled into it once (`sourceInRig`) and every "source" pixel below is a
10
+ * pixel of that resample.
11
+ *
12
+ * Each See-through layer goes through five steps, each its own function with
13
+ * its invariant stated where it is written:
14
+ *
15
+ * 1. `cleanGhosts` — drop sub-threshold specks from a run-space layer;
16
+ * 2. `layerToRig` — resample the layer into rig space through its run's geometry;
17
+ * 3. `projectSource` — where the layer is the top-most opaque layer of its run,
18
+ * take the painting's pixel instead of See-through's repaint;
19
+ * 4. `mergeBelowCrop` — a head-run part below the head crop is continued from a
20
+ * full-run layer (whole components, `belowCrop`), and its
21
+ * seam closed by `growRim`;
22
+ * 4b. `pushBackFringe` — a fringe below alpha 250 that keeps the stack off the
23
+ * painting where a part beneath holds the pixel in the
24
+ * painting's colour is cleared (not in the reference);
25
+ * 5. `seamOverride` — where the flat composite of the parts still differs from
26
+ * the painting, the top-most part takes the painting's colour.
27
+ *
28
+ * This is a port of a reference implementation, and it is faithful to it by
29
+ * default — measured against its outputs (ORACLE_assemble.md at the root of
30
+ * the change that added this file). The places it deliberately is NOT are
31
+ * named here, so none is found by accident:
32
+ *
33
+ * - **Refusals where the reference guessed or skipped.** A plan or extend
34
+ * entry naming a tag its run does not hold (the reference raised a KeyError),
35
+ * a part left with no opaque pixel (the reference printed `EMPTY` and wrote a
36
+ * `parts.json` without it), a head box outside the painting (the reference
37
+ * read row `-1` as the last row), a run whose canvas is not the configured
38
+ * resolution (the reference assumed 1024).
39
+ * - **A landscape painting is mapped back, not refused.** The reference
40
+ * refused one as a bare exit; here the full run's input is the painting on a
41
+ * square of its longer side (`squarePad`, `src/inputs.ts`), and the full
42
+ * run's map carries the vertical pad exactly as it carries the horizontal
43
+ * one. A portrait painting's vertical pad is 0, so its map is the
44
+ * reference's.
45
+ * - **`seamRule: 'silhouette'`** is available beside the faithful
46
+ * `'near-white'` rule. The reference's seam override skips every pixel whose
47
+ * painting colour is near-white (min channel above 235), which protects the
48
+ * background and also every white garment. The silhouette rule skips a
49
+ * near-white pixel only OUTSIDE the figure (`figureSilhouette`). Which one
50
+ * is the default is decided by measurement, recorded beside
51
+ * `DEFAULT_SEAM_RULE`.
52
+ * - **`projectRule: 'visible'`** is available beside the faithful `'core'`
53
+ * rule (`ProjectRule`): the reference erodes every layer's top-most opaque
54
+ * area before projecting, so a part a few pixels wide takes nothing from the
55
+ * painting; `visible` erodes only along a rim with a later layer in front.
56
+ * - **The visibility counts** (`visible_px`, `occluded_px`,
57
+ * `visible_not_projected_px`) are this port's, derived from the masks
58
+ * `projectSource` and `mergeBelowCrop` already build (`visibilityCounts`).
59
+ * - **The recomposite's holes and error map** (`uncoveredHoles`,
60
+ * `recompositeErrorMap`) are this port's: the reference printed the
61
+ * uncovered count alone, which says a hole exists but not where.
62
+ * - **The fringe push-back** (`pushBackFringe`, issue #119) is this port's,
63
+ * and it is on by default: the reference left a fringe below alpha 250 in
64
+ * another part's colour on the rig, where neither projection nor the seam
65
+ * override (both at alpha >= 250) could reach it. Its effect on the public
66
+ * examples is counted in the pull request that added it; a stack it does
67
+ * not touch assembles to the reference's bytes.
68
+ * - **Patches** (`assemble.patches`, `cutPatch`) are this port's: an extra
69
+ * part cut from the painting itself, for a piece of the figure no layer
70
+ * holds, recorded as `painting:<name>` and 100 % source.
71
+ * - **Cuts** (`assemble.cuts`, `cutPart`, issue #170) are this port's: a
72
+ * declared polygon takes a plan part's pixels out of it into a new part,
73
+ * recorded with the same `<run>:<tag>` (its pixels came from that layer).
74
+ * The cut is made after step 4, so the new part takes the merged pixels and
75
+ * every later step treats it as a part of its own.
76
+ * - **What lies under a piece** (`cuts[].under`, `fillUnder`, issue #193) is
77
+ * this port's: an authored image the base takes where the cut removed its
78
+ * pixels, so a piece that swings away leaves no hole. The stage reads that
79
+ * image only there and never invents a pixel.
80
+ *
81
+ * Pure: no clock, no randomness, no file access. The CLI reads and writes.
82
+ */
83
+ import type { CharacterConfig, Cut, EarlyConfig, Extend, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
84
+ import { type Problem, refuseIfAny } from './errors.ts';
85
+ import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
86
+ import { squarePad } from './inputs.ts';
87
+ import { PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord } from './parts.ts';
88
+ import {
89
+ alphaComposite,
90
+ connectedComponents,
91
+ crop,
92
+ dilate,
93
+ erode,
94
+ fillHoles,
95
+ gaussianBlur,
96
+ type Mask,
97
+ morphClose,
98
+ newFloatImage,
99
+ newMask,
100
+ newRaster,
101
+ type Raster,
102
+ resize,
103
+ warpAffine,
104
+ } from './raster/index.ts';
105
+ import { pyRound } from './round.ts';
106
+
107
+ const f32 = Math.fround;
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // the reference's constants, each named once
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /** Alpha at or above this is "opaque" for ownership, projection cores and the seam override. `>= 250`, not `== 255`: See-through leaves alpha-254 speckle. */
114
+ export const CORE_ALPHA = 250;
115
+ /** A ghost component is kept when its area is at least this many pixels … */
116
+ export const GHOST_MIN_AREA = 40;
117
+ /** … and at least this fraction of the layer's largest component. */
118
+ export const GHOST_MIN_FRACTION = 0.01;
119
+ /** The side of the square erosion that makes a projection core, and of the closing that fills speckle refusals. */
120
+ export const CORE_KERNEL = 5;
121
+ /** Projection is refused where See-through's pixel and the painting's differ by more than this, max channel. */
122
+ export const DRIFT_LIMIT = 90;
123
+ /** The Gaussian sigma that feathers the projection weight. */
124
+ export const FEATHER_SIGMA = 1.0;
125
+ /** Head-run projection stays this many rig pixels inside the head crop. */
126
+ export const HEAD_WINDOW_INSET = 3;
127
+ /** The head crop's bottom line in rig pixels is `trunc(y1 * s) - HEAD_BOTTOM_INSET`. */
128
+ export const HEAD_BOTTOM_INSET = 4;
129
+ /** `belowCrop` probes the head part this many rows above the crop line … */
130
+ export const CROP_PROBE_ROWS = 2;
131
+ /** … seeds components in this many rows from the crop line down … */
132
+ export const CROP_SEED_ROWS = 6;
133
+ /** … within this many columns of a column where the head part reaches the line. */
134
+ export const CROP_SEED_COLUMNS = 12;
135
+ /** Alpha above this counts as "the head part reaches the crop line". */
136
+ export const CROP_PROBE_ALPHA = 128;
137
+ /** Alpha at or above this in a later plan part keeps `growRim`'s ring off the pixel. */
138
+ export const FRONT_ALPHA = 128;
139
+ /** A painting pixel whose min channel is above this is "near-white" (the faithful rim and seam guard). Defined beside the layer reader, which applies the same test to a layer's colour (`layerFigures`). */
140
+ export { NEAR_WHITE_MIN };
141
+ /** The seam override acts where the flat composite differs from the painting by more than this, max channel. */
142
+ export const SEAM_LIMIT = 60;
143
+ /** `--propose-plan` drops a tag with fewer run-space opaque pixels than this after ghost clean-up. */
144
+ export const PROPOSE_MIN_PX = 150;
145
+ /** The recomposite's "error pixel": max-channel difference from the painting above this. */
146
+ export const ERROR_LIMIT = 40;
147
+ /** The recomposite's "within" figure: mean-channel difference at or below this. */
148
+ export const WITHIN_LIMIT = 8;
149
+ /**
150
+ * An error pixel is "uncovered" when no part has alpha above this there. The
151
+ * definition is the one the reference's own demo report measured with, and it
152
+ * reproduces that report's figure exactly (1,564 on the reference's demo
153
+ * output; with "no part has alpha above 0" the same output reads 105).
154
+ */
155
+ export const COVERED_ALPHA = 128;
156
+ /** `figureSilhouette`: the border ring whose median colour is the background, in rig pixels. */
157
+ export const SILHOUETTE_BORDER = 8;
158
+ /** `figureSilhouette`: a pixel is figure where it differs from that median by more than this, max channel. */
159
+ export const SILHOUETTE_LIMIT = 40;
160
+
161
+ export type SeamRule = 'near-white' | 'silhouette';
162
+ export const SEAM_RULES: readonly SeamRule[] = ['near-white', 'silhouette'];
163
+
164
+ /**
165
+ * The seam rule a run uses when none is named. Set by measurement, not taste:
166
+ * the silhouette rule ships as the default only if it lowers the error pixel
167
+ * count on every oracle character without raising mean |d| on any
168
+ * (ORACLE_assemble.md, *faithful vs silhouette*).
169
+ */
170
+ export const DEFAULT_SEAM_RULE: SeamRule = 'near-white';
171
+
172
+ /**
173
+ * Where a layer may take the painting's pixel (`projectSource`). `core` is the
174
+ * reference's rule: the layer's top-most `alpha >= 250` pixels eroded by a 5x5
175
+ * square, so a part a few pixels wide has no core and none of its visible
176
+ * pixels is projected. `visible` keeps the erosion only where it guards
177
+ * something — along a rim where a later layer of the run is in front — and
178
+ * projects every other top-most `alpha >= 250` pixel of the layer.
179
+ */
180
+ export type ProjectRule = 'core' | 'visible';
181
+ export const PROJECT_RULES: readonly ProjectRule[] = ['core', 'visible'];
182
+
183
+ /**
184
+ * The projection rule a run uses when none is named: the reference's, so the
185
+ * examples stay comparable with it. `visible` was measured on both public
186
+ * examples against it (the change that added the flag); it is opt-in.
187
+ */
188
+ export const DEFAULT_PROJECT_RULE: ProjectRule = 'core';
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // inputs
192
+ // ---------------------------------------------------------------------------
193
+
194
+ export interface Geometry {
195
+ /** The painting's size in source pixels. */
196
+ sourceW: number;
197
+ sourceH: number;
198
+ /** `seethrough.resolution`: both runs' canvas side. */
199
+ resolution: number;
200
+ /** `seethrough.head_box`, source pixels. */
201
+ headBox: [number, number, number, number];
202
+ /** `assemble.rig_scale`: rig pixels per source pixel. */
203
+ rigScale: number;
204
+ }
205
+
206
+ export interface AssembleInput {
207
+ /** The painting. Its alpha is ignored, as the reference's `convert("RGB")` drops it. */
208
+ source: Raster;
209
+ full: LayerSet;
210
+ head: LayerSet;
211
+ resolution: number;
212
+ headBox: [number, number, number, number];
213
+ rigScale: number;
214
+ plan: PlanEntry[];
215
+ extend: Extend[];
216
+ /** `assemble.patches`: extra parts cut from the painting, placed per their `draw` (`placePatches`). */
217
+ patches: Patch[];
218
+ /** `assemble.cuts`: plan parts' pixels taken into new parts by polygon (`cutPart`), placed per their `draw`. */
219
+ cuts: Cut[];
220
+ /**
221
+ * `cuts[i].under`'s image, decoded by the caller (the stage reads no file),
222
+ * at the cut's index: null for a cut without `under`. Absent = every cut
223
+ * without one. A cut that names `under` with no image here is refused
224
+ * (`ASSEMBLE_UNDER_PRESENT`), never assembled as if it named none.
225
+ */
226
+ under?: ReadonlyArray<Raster | null>;
227
+ seamRule: SeamRule;
228
+ projectRule: ProjectRule;
229
+ }
230
+
231
+ /** The derived numbers of the two runs' geometry. */
232
+ interface Frame {
233
+ W: number;
234
+ H: number;
235
+ S: number;
236
+ /** Full run: the painting was centred on a white `side x side` square (`squarePad`), padded this much on the left… */
237
+ fullPad: number;
238
+ /** …and this much on top, in source px. At most one of the two is non-zero. */
239
+ fullPadTop: number;
240
+ fullK: number;
241
+ headBox: [number, number, number, number];
242
+ headK: number;
243
+ /** The crop line in rig pixels. */
244
+ headBottom: number;
245
+ }
246
+
247
+ /**
248
+ * Check the geometry and derive the frame, refusing by name what the
249
+ * reference would have mis-read. Every problem is collected.
250
+ */
251
+ export function checkGeometry(g: Geometry, runs?: { full: LayerSet; head: LayerSet }): Frame {
252
+ const problems: Problem[] = [];
253
+ const fail = (code: string, object: string, detail: string): void => {
254
+ problems.push({ code, object, detail });
255
+ };
256
+ const { sourceW: w, sourceH: h, resolution, headBox, rigScale: S } = g;
257
+ const pad = squarePad(w, h);
258
+ const W = Math.trunc(w * S);
259
+ const H = Math.trunc(h * S);
260
+ if (W < 1 || H < 1) fail('ASSEMBLE_RIG_SIZE', 'config.assemble.rig_scale', `${S} makes a ${W}x${H} rig from a ${w}x${h} painting; at least 1x1 is required`);
261
+ const [x0, y0, x1, y1] = headBox;
262
+ if (!(x0 >= 0 && y0 >= 0 && x1 <= w && y1 <= h && x1 > x0 && y1 > y0)) {
263
+ fail('ASSEMBLE_HEAD_BOX_INSIDE', 'config.seethrough.head_box', `is [${headBox.join(', ')}]; a non-empty box inside the ${w}x${h} painting is required`);
264
+ }
265
+ const headBottom = Math.trunc(y1 * S) - HEAD_BOTTOM_INSET;
266
+ if (headBottom - CROP_PROBE_ROWS < 0 && y1 > y0) {
267
+ fail(
268
+ 'ASSEMBLE_HEAD_BOX_INSIDE',
269
+ 'config.seethrough.head_box',
270
+ `bottom ${y1} is rig row ${Math.trunc(y1 * S)}; the crop line (that row - ${HEAD_BOTTOM_INSET}) probes ${CROP_PROBE_ROWS} rows above itself, so a bottom at rig row ${HEAD_BOTTOM_INSET + CROP_PROBE_ROWS} or further down is required`,
271
+ );
272
+ }
273
+ if (runs !== undefined) {
274
+ for (const run of ['full', 'head'] as const) {
275
+ const c = runs[run].canvas;
276
+ if (c.w !== resolution || c.h !== resolution) {
277
+ fail('ASSEMBLE_RUN_CANVAS', `the ${run} run (${runs[run].source})`, `has a ${c.w}x${c.h} canvas; config.seethrough.resolution ${resolution} makes ${resolution}x${resolution} required`);
278
+ }
279
+ }
280
+ }
281
+ refuseIfAny(problems);
282
+ return {
283
+ W,
284
+ H,
285
+ S,
286
+ fullPad: pad.left,
287
+ fullPadTop: pad.top,
288
+ fullK: pad.side / resolution,
289
+ headBox,
290
+ headK: (x1 - x0) / resolution,
291
+ headBottom,
292
+ };
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // step 1 — ghost clean-up
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /**
300
+ * Place a layer's pixels on its run's `resolution x resolution` canvas.
301
+ * `readWrapperLayers`/`readPsdLayers` already refused a box outside it.
302
+ */
303
+ export function placeLayer(layer: Layer, resolution: number): Raster {
304
+ const out = newRaster(resolution, resolution);
305
+ const p = layer.pixels;
306
+ for (let y = 0; y < p.height; y++) {
307
+ out.data.set(p.data.subarray(y * p.width * 4, (y + 1) * p.width * 4), ((layer.top + y) * resolution + layer.left) * 4);
308
+ }
309
+ return out;
310
+ }
311
+
312
+ /**
313
+ * Step 1. See-through writes every tag, and an empty tag still carries a few
314
+ * dozen sub-threshold pixels scattered at the canvas edge.
315
+ *
316
+ * ⚖️ Invariant: afterwards the layer's alpha is non-zero only on the 8-connected
317
+ * components of `alpha > 8` whose area is at least `GHOST_MIN_AREA` and at
318
+ * least `GHOST_MIN_FRACTION` of the largest component's; every other pixel —
319
+ * including every pixel of alpha 8 or less — has alpha 0. Colour channels are
320
+ * untouched. `ghostPx` counts the `alpha > 8` pixels removed. A layer with no
321
+ * `alpha > 8` pixel comes back fully transparent with `ghostPx` 0.
322
+ * (`assemble_parts.py` `clean`.)
323
+ */
324
+ export function cleanGhosts(canvas: Raster): { canvas: Raster; ghostPx: number } {
325
+ const { width: w, height: h } = canvas;
326
+ const out: Raster = { width: w, height: h, data: new Uint8ClampedArray(canvas.data) };
327
+ const m = newMask(w, h);
328
+ let any = false;
329
+ for (let i = 0; i < w * h; i++) {
330
+ if (canvas.data[i * 4 + 3] > OPAQUE_ALPHA_ABOVE) {
331
+ m.data[i] = 1;
332
+ any = true;
333
+ }
334
+ }
335
+ if (!any) {
336
+ for (let i = 0; i < w * h; i++) out.data[i * 4 + 3] = 0;
337
+ return { canvas: out, ghostPx: 0 };
338
+ }
339
+ const cc = connectedComponents(m, 8);
340
+ let big = 0;
341
+ for (let l = 1; l < cc.count; l++) big = Math.max(big, cc.stats[l].area);
342
+ const keep = new Uint8Array(cc.count);
343
+ for (let l = 1; l < cc.count; l++) keep[l] = cc.stats[l].area >= Math.max(GHOST_MIN_AREA, GHOST_MIN_FRACTION * big) ? 1 : 0;
344
+ let ghostPx = 0;
345
+ for (let i = 0; i < w * h; i++) {
346
+ const l = cc.labels[i];
347
+ if (keep[l] === 0) {
348
+ if (m.data[i] === 1) ghostPx++;
349
+ out.data[i * 4 + 3] = 0;
350
+ }
351
+ }
352
+ return { canvas: out, ghostPx };
353
+ }
354
+
355
+ export interface RunLayer {
356
+ tag: string;
357
+ /** The cleaned run-space canvas. */
358
+ canvas: Raster;
359
+ drawOrder: number;
360
+ ghostPx: number;
361
+ /** `alpha > 8` pixels after clean-up, in run pixels. */
362
+ opaqueRunPx: number;
363
+ }
364
+
365
+ /** Every layer of a run, placed and cleaned, in draw order (back to front). */
366
+ export function runLayers(set: LayerSet, resolution: number): RunLayer[] {
367
+ return set.layers.map((layer) => {
368
+ const { canvas, ghostPx } = cleanGhosts(placeLayer(layer, resolution));
369
+ let opaqueRunPx = 0;
370
+ for (let i = 3; i < canvas.data.length; i += 4) if (canvas.data[i] > OPAQUE_ALPHA_ABOVE) opaqueRunPx++;
371
+ return { tag: layer.name, canvas, drawOrder: layer.drawOrder, ghostPx, opaqueRunPx };
372
+ });
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // step 2 — resample into rig space
377
+ // ---------------------------------------------------------------------------
378
+
379
+ /** The run-to-rig map: `rig = k * run + (tx, ty)`, in float32 as the reference's `np.float32` matrix holds it. */
380
+ export function runMap(frame: Frame, run: Run): { k: number; sx: number; tx: number; ty: number } {
381
+ if (run === 'full') {
382
+ const k = frame.fullK * frame.S;
383
+ // A portrait painting's top pad is 0, and its ty is the reference's literal 0 rather than f32(-0).
384
+ return { k, sx: f32(k), tx: f32(-frame.fullPad * frame.S), ty: frame.fullPadTop === 0 ? 0 : f32(-frame.fullPadTop * frame.S) };
385
+ }
386
+ const k = frame.headK * frame.S;
387
+ return { k, sx: f32(k), tx: f32(frame.headBox[0] * frame.S), ty: f32(frame.headBox[1] * frame.S) };
388
+ }
389
+
390
+ /**
391
+ * Step 2. Warp a run-space layer into the `W x H` rig, premultiplied.
392
+ *
393
+ * ⚖️ Invariant: the result is `cv2.warpAffine` of the premultiplied layer
394
+ * through `runMap` — bicubic when the map enlarges (`k >= 1`), and the
395
+ * reference's `INTER_AREA` otherwise, which `warpAffine` answers with its
396
+ * bilinear filter (`src/raster/warp.ts` measured that) — clipped to 0..255,
397
+ * un-premultiplied, and truncated to 8 bits. Premultiplying and
398
+ * un-premultiplying are done in float32, as numpy does them on a float32
399
+ * array. A layer with no non-zero alpha maps to a fully transparent rig layer
400
+ * without being warped: every tap would read 0.
401
+ * (`assemble_parts.py` `to_rig`.)
402
+ */
403
+ export function layerToRig(canvas: Raster, frame: Frame, run: Run): Raster {
404
+ const { W, H } = frame;
405
+ const n = canvas.width * canvas.height;
406
+ let any = false;
407
+ for (let i = 0; i < n && !any; i++) if (canvas.data[i * 4 + 3] !== 0) any = true;
408
+ if (!any) return newRaster(W, H);
409
+ const pre = newFloatImage(canvas.width, canvas.height, 4);
410
+ for (let i = 0; i < n; i++) {
411
+ const a = canvas.data[i * 4 + 3];
412
+ const af = f32(a / 255);
413
+ for (let c = 0; c < 3; c++) pre.data[i * 4 + c] = f32(canvas.data[i * 4 + c] * af);
414
+ pre.data[i * 4 + 3] = a;
415
+ }
416
+ const m = runMap(frame, run);
417
+ const wr = warpAffine(pre, { sx: m.sx, sy: m.sx, tx: m.tx, ty: m.ty }, W, H, m.k < 1 ? 'bilinear' : 'bicubic');
418
+ const out = newRaster(W, H);
419
+ const eps = f32(1e-3);
420
+ const clip = (v: number): number => (v < 0 ? 0 : v > 255 ? 255 : v);
421
+ for (let i = 0; i < W * H; i++) {
422
+ const a = f32(clip(wr.data[i * 4 + 3]));
423
+ for (let c = 0; c < 3; c++) {
424
+ const v = f32(clip(wr.data[i * 4 + c]));
425
+ out.data[i * 4 + c] = a > 0 ? Math.trunc(clip(f32(f32(v * 255) / Math.max(a, eps)))) : 0;
426
+ }
427
+ out.data[i * 4 + 3] = Math.trunc(a);
428
+ }
429
+ return out;
430
+ }
431
+
432
+ /**
433
+ * The painting in rig space: `PIL.Image.resize(..., LANCZOS)` of its RGB, as
434
+ * an opaque raster. (`src/raster/resize.ts` `lanczos3` is PIL's, bit-exact;
435
+ * with alpha 255 its premultiply round trip is the identity.)
436
+ */
437
+ export function sourceInRig(source: Raster, W: number, H: number): Raster {
438
+ const rgb: Raster = { width: source.width, height: source.height, data: new Uint8ClampedArray(source.data) };
439
+ for (let i = 3; i < rgb.data.length; i += 4) rgb.data[i] = 255;
440
+ return resize(rgb, W, H, 'lanczos3');
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // step 3 — source projection
445
+ // ---------------------------------------------------------------------------
446
+
447
+ export interface ProjectionStats {
448
+ core: number;
449
+ taken: number;
450
+ refused: number;
451
+ /**
452
+ * Where no later layer of the run is opaque (`alpha >= 250`) in front of
453
+ * this one: the run's owner of the pixel is this layer or one behind it. A
454
+ * pixel of this layer with alpha above 8 is VISIBLE exactly where this is
455
+ * set, and OCCLUDED everywhere else.
456
+ */
457
+ clear: Mask;
458
+ /** The pixels counted in `taken`: the accepted set, cut to the core. */
459
+ takenMask: Mask;
460
+ /** The pixels counted in `core`, and in `refused`; null when the layer has no top-most pixel (both counts 0). A cut splits the counts by these. */
461
+ coreMask: Mask | null;
462
+ refusedMask: Mask | null;
463
+ }
464
+
465
+ function maxDiff(a: Uint8ClampedArray, i: number, b: Uint8ClampedArray, j: number): number {
466
+ return Math.max(Math.abs(a[i] - b[j]), Math.abs(a[i + 1] - b[j + 1]), Math.abs(a[i + 2] - b[j + 2]));
467
+ }
468
+
469
+ /**
470
+ * Step 3, over one run's layers in draw order. Modifies each rig layer's
471
+ * colour in place and returns its counts.
472
+ *
473
+ * ⚖️ Invariant: a layer's CORE is where it is the top-most `alpha >= 250`
474
+ * layer of its own run, eroded by a 5x5 square. Inside the core, the pixels
475
+ * that agree with the painting (max channel difference <= `DRIFT_LIMIT`),
476
+ * closed by 5x5 and cut back to the core — and, for the head run, kept
477
+ * `HEAD_WINDOW_INSET` pixels inside the head crop — are the ACCEPTED set. The
478
+ * colour becomes `w * painting + (1 - w) * layer`, truncated, with `w` the
479
+ * accepted set blurred by a Gaussian of sigma 1 and zeroed outside the core;
480
+ * outside the core nothing changes, alpha never changes, and occluded pixels
481
+ * keep See-through's synthesis. `core`, `taken` (accepted) and `refused` (core
482
+ * pixels over the drift limit, before closing) are counted.
483
+ * (`assemble_parts.py` `main`, the per-run loop.)
484
+ *
485
+ * With `rule: 'visible'` (NOT in the reference) the core is the top-most
486
+ * `alpha >= 250` set less every pixel within the 5x5 square of a pixel where a
487
+ * LATER layer of the run is the owner — the erosion kept only along a rim with
488
+ * a neighbour in front, where the painting's pixel may be that neighbour's
489
+ * edge. Every other step is the same. Fringe pixels (alpha below 250) are not
490
+ * projected under either rule: the painting there is this layer blended with
491
+ * what is behind it.
492
+ *
493
+ * Both rules return `clear` (no later layer is the owner) and `takenMask`, the
494
+ * masks the part counts in `parts.json` are derived from; `takenMask` is a
495
+ * subset of the top-most set, and so of `clear`.
496
+ */
497
+ export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, run: Run, rule: ProjectRule = DEFAULT_PROJECT_RULE): ProjectionStats[] {
498
+ const { W, H } = frame;
499
+ const owner = new Int32Array(W * H).fill(-1);
500
+ rigLayers.forEach((r, i) => {
501
+ for (let p = 0; p < W * H; p++) if (r.data[p * 4 + 3] >= CORE_ALPHA) owner[p] = i;
502
+ });
503
+ let win: Mask | null = null;
504
+ if (run === 'head') {
505
+ win = newMask(W, H);
506
+ const [bx0, by0, bx1, by1] = frame.headBox;
507
+ const x0 = Math.max(0, Math.trunc(bx0 * frame.S) + HEAD_WINDOW_INSET);
508
+ const y0 = Math.max(0, Math.trunc(by0 * frame.S) + HEAD_WINDOW_INSET);
509
+ const x1 = Math.min(W, Math.trunc(bx1 * frame.S) - HEAD_WINDOW_INSET);
510
+ const y1 = Math.min(H, Math.trunc(by1 * frame.S) - HEAD_WINDOW_INSET);
511
+ for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) win.data[y * W + x] = 1;
512
+ }
513
+ return rigLayers.map((r, i) => {
514
+ const top = newMask(W, H);
515
+ const clear = newMask(W, H);
516
+ const takenMask = newMask(W, H);
517
+ let anyTop = false;
518
+ for (let p = 0; p < W * H; p++) {
519
+ if (owner[p] <= i) clear.data[p] = 1;
520
+ if (owner[p] === i && r.data[p * 4 + 3] >= CORE_ALPHA) {
521
+ top.data[p] = 1;
522
+ anyTop = true;
523
+ }
524
+ }
525
+ if (!anyTop) return { core: 0, taken: 0, refused: 0, clear, takenMask, coreMask: null, refusedMask: null };
526
+ let core: Mask;
527
+ if (rule === 'core') core = erode(top, CORE_KERNEL);
528
+ else {
529
+ const front = newMask(W, H);
530
+ for (let p = 0; p < W * H; p++) if (owner[p] > i) front.data[p] = 1;
531
+ const rim = dilate(front, CORE_KERNEL);
532
+ core = newMask(W, H);
533
+ for (let p = 0; p < W * H; p++) if (top.data[p] === 1 && rim.data[p] === 0) core.data[p] = 1;
534
+ }
535
+ const ok = newMask(W, H);
536
+ const refusedMask = newMask(W, H);
537
+ let coreN = 0;
538
+ let refused = 0;
539
+ for (let p = 0; p < W * H; p++) {
540
+ if (core.data[p] === 0) continue;
541
+ coreN++;
542
+ if (maxDiff(r.data, p * 4, srcr.data, p * 4) <= DRIFT_LIMIT) ok.data[p] = 1;
543
+ else {
544
+ refused++;
545
+ refusedMask.data[p] = 1;
546
+ }
547
+ }
548
+ const closed = morphClose(ok, CORE_KERNEL);
549
+ const okf = newFloatImage(W, H, 1);
550
+ let taken = 0;
551
+ for (let p = 0; p < W * H; p++) {
552
+ const v = closed.data[p] === 1 && core.data[p] === 1 && (win === null || win.data[p] === 1) ? 1 : 0;
553
+ okf.data[p] = v;
554
+ takenMask.data[p] = v;
555
+ taken += v;
556
+ }
557
+ const blurred = gaussianBlur(okf, FEATHER_SIGMA);
558
+ for (let p = 0; p < W * H; p++) {
559
+ if (core.data[p] === 0) continue;
560
+ const w = blurred.data[p];
561
+ const rest = f32(1 - w);
562
+ for (let c = 0; c < 3; c++) {
563
+ const v = f32(f32(w * srcr.data[p * 4 + c]) + f32(rest * r.data[p * 4 + c]));
564
+ r.data[p * 4 + c] = Math.trunc(v < 0 ? 0 : v > 255 ? 255 : v);
565
+ }
566
+ }
567
+ return { core: coreN, taken, refused, clear, takenMask, coreMask: core, refusedMask };
568
+ });
569
+ }
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // step 4 — merge below the head crop
573
+ // ---------------------------------------------------------------------------
574
+
575
+ /**
576
+ * The pixels of `extra` (a full-run layer in rig space) that continue `part`
577
+ * below the crop line.
578
+ *
579
+ * ⚖️ Invariant: the result is every 8-connected component of `extra`'s
580
+ * `alpha > 8` pixels at or below row `headBottom` that has a pixel in rows
581
+ * `headBottom .. headBottom + 5` within `CROP_SEED_COLUMNS` columns of a column
582
+ * where `part` has alpha above 128 on row `headBottom - 2`. Whole components,
583
+ * so a long lock keeps its own outline to the tip. Empty when `part` does not
584
+ * reach the line or `extra` has nothing below it.
585
+ * (`assemble_parts.py` `below_crop`.)
586
+ */
587
+ export function belowCrop(part: Raster, extra: Raster, headBottom: number): Mask {
588
+ const { width: W, height: H } = part;
589
+ const sel = newMask(W, H);
590
+ const below = newMask(W, H);
591
+ let anyBelow = false;
592
+ for (let y = Math.max(0, headBottom); y < H; y++) {
593
+ for (let x = 0; x < W; x++) {
594
+ if (extra.data[(y * W + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE) {
595
+ below.data[y * W + x] = 1;
596
+ anyBelow = true;
597
+ }
598
+ }
599
+ }
600
+ const probe = headBottom - CROP_PROBE_ROWS;
601
+ const near = new Uint8Array(W);
602
+ let anyCol = false;
603
+ for (let x = 0; x < W; x++) {
604
+ if (part.data[(probe * W + x) * 4 + 3] > CROP_PROBE_ALPHA) {
605
+ anyCol = true;
606
+ for (let c = Math.max(0, x - CROP_SEED_COLUMNS); c <= Math.min(W - 1, x + CROP_SEED_COLUMNS); c++) near[c] = 1;
607
+ }
608
+ }
609
+ if (!anyCol || !anyBelow) return sel;
610
+ const cc = connectedComponents(below, 8);
611
+ const chosen = new Uint8Array(cc.count);
612
+ for (let y = Math.max(0, headBottom); y < Math.min(H, headBottom + CROP_SEED_ROWS); y++) {
613
+ for (let x = 0; x < W; x++) {
614
+ const l = cc.labels[y * W + x];
615
+ if (l > 0 && near[x] === 1) chosen[l] = 1;
616
+ }
617
+ }
618
+ for (let p = 0; p < W * H; p++) if (chosen[cc.labels[p]] === 1 && extra.data[p * 4 + 3] > 0) sel.data[p] = 1;
619
+ return sel;
620
+ }
621
+
622
+ /**
623
+ * Close the half-pixel seam round a merged-in piece: the full run's silhouette
624
+ * is a few pixels narrower than the painting, and a renderer's sub-pixel
625
+ * resample shows the layer beneath along that edge.
626
+ *
627
+ * ⚖️ Invariant: the RING is the 3x3 dilation of `sel`'s `alpha >= 250` pixels,
628
+ * restricted to pixels where `part` has alpha below 250, the painting is not
629
+ * near-white (min channel <= 235) and no later plan part covers
630
+ * (`front`, alpha >= 128). Every ring pixel takes the painting's colour at
631
+ * alpha 255, so the flat composite there equals the painting; the count is
632
+ * returned. Nothing changes when `sel` is empty. When `touched` is given,
633
+ * every ring pixel written is set in it.
634
+ * (`assemble_parts.py` `grow_rim`.)
635
+ */
636
+ export function growRim(part: Raster, sel: Mask, srcr: Raster, front: Mask | null, touched?: Mask): number {
637
+ const { width: W, height: H } = part;
638
+ const core = newMask(W, H);
639
+ let anySel = false;
640
+ for (let p = 0; p < W * H; p++) {
641
+ if (sel.data[p] === 1) {
642
+ anySel = true;
643
+ if (part.data[p * 4 + 3] >= CORE_ALPHA) core.data[p] = 1;
644
+ }
645
+ }
646
+ if (!anySel) return 0;
647
+ const ring = dilate(core, 3);
648
+ let grown = 0;
649
+ for (let p = 0; p < W * H; p++) {
650
+ if (ring.data[p] === 0 || part.data[p * 4 + 3] >= CORE_ALPHA) continue;
651
+ const s = p * 4;
652
+ if (Math.min(srcr.data[s], srcr.data[s + 1], srcr.data[s + 2]) > NEAR_WHITE_MIN) continue;
653
+ if (front !== null && front.data[p] === 1) continue;
654
+ part.data[s] = srcr.data[s];
655
+ part.data[s + 1] = srcr.data[s + 1];
656
+ part.data[s + 2] = srcr.data[s + 2];
657
+ part.data[s + 3] = 255;
658
+ if (touched !== undefined) touched.data[p] = 1;
659
+ grown++;
660
+ }
661
+ return grown;
662
+ }
663
+
664
+ /**
665
+ * Step 4 for one part. Modifies `part` in place and returns `merged_px`.
666
+ *
667
+ * ⚖️ Invariant: for each `extend_below_crop` entry naming this part, the
668
+ * `belowCrop` pixels are copied from the extend layer (colour and alpha), then
669
+ * `growRim` closes their seam against the later plan parts. `merged_px` is the
670
+ * copied pixels plus the ring of the LAST such entry — the reference assigns
671
+ * rather than adds, and that is kept (a part with one entry, the only case the
672
+ * reference's corpus has, is unaffected).
673
+ *
674
+ * When `trace` is given, every pixel an entry wrote (copied or ring) has
675
+ * `trace.from` set to that entry's index in `extras`, a later entry
676
+ * overwriting an earlier one — the layer whose run says whether the pixel is
677
+ * visible — and `trace.ring` set when the last write was the ring's.
678
+ */
679
+ export function mergeBelowCrop(part: Raster, extras: Raster[], front: Mask, srcr: Raster, headBottom: number, trace?: MergeTrace): number {
680
+ let merged = 0;
681
+ extras.forEach((extra, e) => {
682
+ const sel = belowCrop(part, extra, headBottom);
683
+ let copied = 0;
684
+ for (let p = 0; p < sel.data.length; p++) {
685
+ if (sel.data[p] === 0) continue;
686
+ copied++;
687
+ part.data.set(extra.data.subarray(p * 4, p * 4 + 4), p * 4);
688
+ if (trace !== undefined) {
689
+ trace.from[p] = e;
690
+ trace.ring.data[p] = 0;
691
+ }
692
+ }
693
+ const ring = newMask(part.width, part.height);
694
+ merged = copied + growRim(part, sel, srcr, front, ring);
695
+ if (trace !== undefined) {
696
+ trace.lastSel = sel;
697
+ trace.lastRing = ring;
698
+ for (let p = 0; p < ring.data.length; p++) {
699
+ if (ring.data[p] === 1) {
700
+ trace.from[p] = e;
701
+ trace.ring.data[p] = 1;
702
+ }
703
+ }
704
+ }
705
+ });
706
+ return merged;
707
+ }
708
+
709
+ /** Which extend entry last wrote each pixel (-1: none), and whether that write was the ring's. */
710
+ export interface MergeTrace {
711
+ from: Int32Array;
712
+ ring: Mask;
713
+ /**
714
+ * The last entry's copied pixels and its ring: `merged_px` is their two
715
+ * counts added (the reference assigns per entry), so a cut splits it by
716
+ * where these lie. Absent when no entry ran.
717
+ */
718
+ lastSel?: Mask;
719
+ lastRing?: Mask;
720
+ }
721
+
722
+ export interface VisibilityCounts {
723
+ visible_px: number;
724
+ occluded_px: number;
725
+ visible_not_projected_px: number;
726
+ /** The part's projected pixels, the part layer's and any a merge copied in. */
727
+ projected: number;
728
+ }
729
+
730
+ /**
731
+ * The visibility counts of one part, from the masks its pixels were made
732
+ * with, refusing — as a bug, not a report — when they do not add up.
733
+ *
734
+ * ⚖️ Invariant: `opaque` is the part's `alpha > 8` pixels; `visible` the pixels
735
+ * set in `visible`; `occluded` the opaque pixels NOT set in it; `projected` the
736
+ * pixels set in `projected`. Required: `visible + occluded = opaque` (so every
737
+ * visible pixel is opaque) and every projected pixel is visible;
738
+ * `visible_not_projected = visible - projected`. The assembler builds
739
+ * `visible` as the opaque pixels no later layer of their run is in front of,
740
+ * and `projected` as the accepted set of `projectSource`, so both hold by
741
+ * construction and a refusal here names a mask that escaped its definition.
742
+ */
743
+ export function visibilityCounts(object: string, part: Raster, visible: Mask, projected: Mask): VisibilityCounts {
744
+ const n = part.width * part.height;
745
+ let opaque = 0;
746
+ let vis = 0;
747
+ let occ = 0;
748
+ let proj = 0;
749
+ let projHidden = 0;
750
+ for (let p = 0; p < n; p++) {
751
+ const o = part.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE;
752
+ if (o) opaque++;
753
+ if (visible.data[p] === 1) vis++;
754
+ else if (o) occ++;
755
+ if (projected.data[p] === 1) {
756
+ proj++;
757
+ if (visible.data[p] !== 1) projHidden++;
758
+ }
759
+ }
760
+ const problems: Problem[] = [];
761
+ if (vis + occ !== opaque) {
762
+ problems.push({
763
+ code: 'ASSEMBLE_COUNTS_ADD_UP',
764
+ object,
765
+ detail: `visible ${vis} + occluded ${occ} = ${vis + occ}, opaque (alpha above ${OPAQUE_ALPHA_ABOVE}) ${opaque}; equal is required — the visible mask holds ${vis + occ - opaque} pixel(s) that are not opaque (a bug in the assembler, not in the inputs)`,
766
+ });
767
+ }
768
+ if (projHidden > 0) {
769
+ problems.push({
770
+ code: 'ASSEMBLE_COUNTS_ADD_UP',
771
+ object,
772
+ detail: `${projHidden} of ${proj} projected pixel(s) are not visible; projected <= visible, pixel by pixel, is required — a pixel a later layer covers was taken from the painting (a bug in the assembler, not in the inputs)`,
773
+ });
774
+ }
775
+ refuseIfAny(problems);
776
+ return { visible_px: vis, occluded_px: occ, visible_not_projected_px: vis - proj, projected: proj };
777
+ }
778
+
779
+ // ---------------------------------------------------------------------------
780
+ // step 5 — seam override
781
+ // ---------------------------------------------------------------------------
782
+
783
+ /**
784
+ * The figure, as a mask over the rig: the pixels whose colour differs from
785
+ * the median colour of the painting's outer `SILHOUETTE_BORDER`-pixel ring by
786
+ * more than `SILHOUETTE_LIMIT` (max channel), opened twice by a 3x3 square
787
+ * (erode twice, dilate twice) to drop edge noise, with every enclosed hole
788
+ * filled — so a white blouse inside the figure's outline is figure, and the
789
+ * white page round it is not. Computed once, from the painting alone.
790
+ *
791
+ * This is NOT in the reference. It is the input of `seamRule: 'silhouette'`.
792
+ */
793
+ export function figureSilhouette(srcr: Raster): Mask {
794
+ const { width: W, height: H } = srcr;
795
+ const ring: number[][] = [[], [], []];
796
+ for (let y = 0; y < H; y++) {
797
+ for (let x = 0; x < W; x++) {
798
+ if (y >= SILHOUETTE_BORDER && y < H - SILHOUETTE_BORDER && x >= SILHOUETTE_BORDER && x < W - SILHOUETTE_BORDER) continue;
799
+ for (let c = 0; c < 3; c++) ring[c].push(srcr.data[(y * W + x) * 4 + c]);
800
+ }
801
+ }
802
+ const median = ring.map((v) => {
803
+ const s = [...v].sort((a, b) => a - b);
804
+ const n = s.length;
805
+ return n % 2 === 1 ? s[(n - 1) / 2] : (s[n / 2 - 1] + s[n / 2]) / 2;
806
+ });
807
+ const fig = newMask(W, H);
808
+ for (let p = 0; p < W * H; p++) {
809
+ const d = Math.max(...[0, 1, 2].map((c) => Math.abs(srcr.data[p * 4 + c] - median[c])));
810
+ if (d > SILHOUETTE_LIMIT) fig.data[p] = 1;
811
+ }
812
+ const opened = dilate(dilate(erode(erode(fig, 3), 3), 3), 3);
813
+ return fillHoles(opened);
814
+ }
815
+
816
+ export interface PlacedPart {
817
+ record: PartRecord;
818
+ image: Raster;
819
+ }
820
+
821
+ /** The seam pass's view of a stack of parts, before anything is recoloured (`seamCandidates`). */
822
+ export interface SeamCandidates {
823
+ /** Per rig pixel: the index of the top-most part with alpha above 0 there, or -1. */
824
+ top: Int32Array;
825
+ /** Per rig pixel: the index of the top-most part with alpha >= `CORE_ALPHA` there, or -1. */
826
+ holder: Int32Array;
827
+ /** Per rig pixel: 1 where the seam rule admits the pixel as a candidate (`seamOverride`'s invariant). */
828
+ cand: Uint8Array;
829
+ }
830
+
831
+ /**
832
+ * The candidates of the seam pass over `parts` in draw order, exactly as
833
+ * `seamOverride` defines them (its invariant): the flat composite on white,
834
+ * in float as the reference composites; a candidate differs from the
835
+ * painting by more than `SEAM_LIMIT` (max channel), is covered by some part
836
+ * (alpha > 0) and is admitted by the rule. Also returned: which part is the
837
+ * top-most cover of each pixel, and which is the top-most at alpha >=
838
+ * `CORE_ALPHA` — the part that holds the pixel.
839
+ */
840
+ export function seamCandidates(parts: PlacedPart[], srcr: Raster, rule: SeamRule, silhouette: Mask | null): SeamCandidates {
841
+ const { width: W, height: H } = srcr;
842
+ const top = new Int32Array(W * H).fill(-1);
843
+ const holder = new Int32Array(W * H).fill(-1);
844
+ const can = new Float64Array(W * H * 3).fill(255);
845
+ parts.forEach(({ record: p, image }, i) => {
846
+ for (let y = 0; y < p.h; y++) {
847
+ for (let x = 0; x < p.w; x++) {
848
+ const s = (y * p.w + x) * 4;
849
+ const d = (p.y + y) * W + p.x + x;
850
+ const alpha = image.data[s + 3];
851
+ if (alpha > 0) top[d] = i;
852
+ if (alpha >= CORE_ALPHA) holder[d] = i;
853
+ const a = f32(alpha / 255);
854
+ const one = f32(1 - a);
855
+ for (let c = 0; c < 3; c++) can[d * 3 + c] = can[d * 3 + c] * one + f32(image.data[s + c] * a);
856
+ }
857
+ }
858
+ });
859
+ const cand = new Uint8Array(W * H);
860
+ for (let p = 0; p < W * H; p++) {
861
+ if (top[p] < 0) continue;
862
+ const s = p * 4;
863
+ let d = 0;
864
+ for (let c = 0; c < 3; c++) d = Math.max(d, Math.abs(can[p * 3 + c] - srcr.data[s + c]));
865
+ if (!(d > SEAM_LIMIT)) continue;
866
+ const painted = Math.min(srcr.data[s], srcr.data[s + 1], srcr.data[s + 2]) <= NEAR_WHITE_MIN;
867
+ const admitted = rule === 'near-white' ? painted : painted || (silhouette !== null && silhouette.data[p] === 1);
868
+ if (admitted) cand[p] = 1;
869
+ }
870
+ return { top, holder, cand };
871
+ }
872
+
873
+ /** Pixels of one part's fringe that `pushBackFringe` cleared, counted by the part that holds them. */
874
+ export interface FringePushed {
875
+ /** The part drawn beneath, at alpha >= `CORE_ALPHA`, that the painting shows there. */
876
+ part: string;
877
+ px: number;
878
+ }
879
+
880
+ /** The art mask the contour mesher reads (alpha above 8), and its 4-connected islands. */
881
+ function artIslands(image: Raster): { labels: Int32Array; count: number; core: Uint8Array; area: Int32Array } {
882
+ const n = image.width * image.height;
883
+ const art = newMask(image.width, image.height);
884
+ for (let q = 0; q < n; q++) if (image.data[q * 4 + 3] > OPAQUE_ALPHA_ABOVE) art.data[q] = 1;
885
+ const cc = connectedComponents(art, 4);
886
+ const core = new Uint8Array(cc.count);
887
+ const area = new Int32Array(cc.count);
888
+ for (let q = 0; q < n; q++) {
889
+ const l = cc.labels[q];
890
+ if (l === 0) continue;
891
+ area[l]++;
892
+ if (image.data[q * 4 + 3] >= CORE_ALPHA) core[l] = 1;
893
+ }
894
+ return { labels: cc.labels, count: cc.count, core, area };
895
+ }
896
+
897
+ /** One pixel `pushBackFringe` cleared: its index in the part's box, its RGBA before, its holder's index in the stack, and whether it is still clear. */
898
+ interface Cleared {
899
+ q: number;
900
+ rgba: number[];
901
+ h: number;
902
+ on: boolean;
903
+ }
904
+
905
+ /**
906
+ * `pushBackFringe`'s after-step on one part: clearing a fringe never splits
907
+ * an island of the part's art. The art is the contour mesher's reading —
908
+ * alpha above 8, 4-connected (`src/contour.ts`, `CONTOUR_ONE_ISLAND`) — and
909
+ * the islands before the clearing are the part's islands with every cleared
910
+ * pixel put back.
911
+ *
912
+ * ⚖️ Invariant: afterwards every island the part had before is at most one
913
+ * island. Where the clearing left one in several pieces, the largest piece
914
+ * (by area; the first in scan order on a tie) stays, and each other piece is
915
+ * either
916
+ * - a CRUMB — every pixel below `CORE_ALPHA`, and every pixel either held
917
+ * by some part at alpha >= `CORE_ALPHA` (`holderAt` >= 0) or not covering
918
+ * (alpha <= `COVERED_ALPHA`), so clearing it uncovers nothing — which is
919
+ * cleared too: a piece of the same fringe, cut off from its part. Its
920
+ * pixels are counted against the holder of the first cleared pixel beside
921
+ * it, the clearing that cut it off; or
922
+ * - anything else (it holds a pixel at alpha >= `CORE_ALPHA`, or a covering
923
+ * pixel nothing holds), in which case every cleared pixel 4-adjacent to it
924
+ * is put back as it was.
925
+ * Repeated until no island is split: the pieces were joined only through
926
+ * cleared pixels, and each round clears a crumb or puts a pixel back, so the
927
+ * loop ends.
928
+ * So the part's island count never rises, and `CONTOUR_ONE_ISLAND` reads
929
+ * the islands it read before. Without the bridge put back, the clearing cut a
930
+ * 7 px piece of opaque art off demo's `sleeves` (the pull request that added
931
+ * this step); a fringe cleared along a long edge leaves a crumb wherever the
932
+ * edge was two pixels wide, and selftest `AS47` plants both.
933
+ */
934
+ function keepArtWhole(part: PlacedPart, cleared: Cleared[], holderAt: (q: number) => number): void {
935
+ if (cleared.length === 0) return;
936
+ const { image } = part;
937
+ const { width: w, height: h } = image;
938
+ const n = w * h;
939
+ const whole: Raster = { width: w, height: h, data: new Uint8ClampedArray(image.data) };
940
+ for (const c of cleared) whole.data.set(c.rgba, c.q * 4);
941
+ const before = artIslands(whole);
942
+ for (;;) {
943
+ const after = artIslands(image);
944
+ // Per piece: its island before, and whether it is a crumb; per island before: its pieces and the largest.
945
+ const islandOf = new Int32Array(after.count).fill(-1);
946
+ const crumb = new Uint8Array(after.count).fill(1);
947
+ const pieces = new Int32Array(before.count);
948
+ const largest = new Int32Array(before.count).fill(-1);
949
+ for (let q = 0; q < n; q++) {
950
+ const l = after.labels[q];
951
+ if (l === 0) continue;
952
+ if (islandOf[l] < 0) {
953
+ const b = before.labels[q];
954
+ islandOf[l] = b;
955
+ pieces[b]++;
956
+ if (largest[b] < 0 || after.area[l] > after.area[largest[b]]) largest[b] = l;
957
+ }
958
+ const a = image.data[q * 4 + 3];
959
+ if (a >= CORE_ALPHA || (a > COVERED_ALPHA && holderAt(q) < 0)) crumb[l] = 0;
960
+ }
961
+ const split = (l: number): boolean => l > 0 && pieces[islandOf[l]] > 1 && largest[islandOf[l]] !== l;
962
+ const beside = (c: Cleared): number[] => {
963
+ const x = c.q % w;
964
+ const y = (c.q - x) / w;
965
+ return [x > 0 ? c.q - 1 : -1, x < w - 1 ? c.q + 1 : -1, y > 0 ? c.q - w : -1, y < h - 1 ? c.q + w : -1].filter((m) => m >= 0 && split(after.labels[m])).map((m) => after.labels[m]);
966
+ };
967
+ // A crumb is counted against the holder of the first cleared pixel beside it: the clearing that cut it off.
968
+ const cutBy = new Int32Array(after.count).fill(-1);
969
+ for (const c of cleared) if (c.on) for (const l of beside(c)) if (crumb[l] === 1 && cutBy[l] < 0) cutBy[l] = c.h;
970
+ for (let l = 1; l < after.count; l++) if (cutBy[l] < 0) crumb[l] = 0;
971
+ const restore = cleared.filter((c) => c.on && beside(c).some((l) => crumb[l] === 0));
972
+ let changed = 0;
973
+ for (let q = 0; q < n; q++) {
974
+ const l = after.labels[q];
975
+ if (!split(l) || crumb[l] === 0) continue;
976
+ cleared.push({ q, rgba: Array.from(image.data.subarray(q * 4, q * 4 + 4)), h: cutBy[l], on: true });
977
+ image.data.fill(0, q * 4, q * 4 + 4);
978
+ changed++;
979
+ }
980
+ for (const c of restore) {
981
+ image.data.set(c.rgba, c.q * 4);
982
+ c.on = false;
983
+ changed++;
984
+ }
985
+ if (changed === 0) break;
986
+ }
987
+ }
988
+
989
+ /**
990
+ * Step 4b (issue #119), over every part in draw order, before the seam
991
+ * override. Modifies the part images in place and returns, per part, the
992
+ * pixels it cleared, by holder.
993
+ *
994
+ * ⚖️ Invariant: a pixel is PUSHED BACK in a part when, over the stack as it
995
+ * stands (`seamCandidates`): (1) the pixel is a seam candidate — the flat
996
+ * composite differs from the painting by more than `SEAM_LIMIT` (max
997
+ * channel) and the seam rule admits it; (2) this part is drawn after the
998
+ * pixel's HOLDER — the top-most part at alpha >= `CORE_ALPHA` there — with
999
+ * alpha above 0, which makes it a fringe (below `CORE_ALPHA`, or it would be
1000
+ * the holder) that the seam override, recolouring only at alpha >=
1001
+ * `CORE_ALPHA`, cannot reach; and (3) the holder is in the painting's colour
1002
+ * — its own RGB within `SEAM_LIMIT` of the painting's (max channel): the
1003
+ * painting shows the holder there. Every such pixel becomes transparent (all
1004
+ * four channels 0). Then `keepArtWhole` holds each part's art to the islands
1005
+ * it had: a crumb the clearing cut off is cleared too, and a cleared bridge
1006
+ * to opaque art is put back. No constant is new: (1) and (2) are the seam
1007
+ * override's own candidate set and alpha, (3) is the seam override's own
1008
+ * limit, applied to the part the pixel falls back to, and the islands are the
1009
+ * contour mesher's reading of the art. Where the holder is itself the top
1010
+ * cover, nothing is drawn after it and nothing is cleared: a pixel at alpha
1011
+ * >= `CORE_ALPHA` is the seam override's, as before.
1012
+ *
1013
+ * Why (3) asks for the holder's colour: without it a fringe that is too
1014
+ * faint rather than the wrong colour — an earring's edge over hair, where the
1015
+ * painting shows the earring — is cleared too, and the seam override then
1016
+ * paints the earring's colour onto the hair beneath, which moves with the
1017
+ * hair: the sliver this rule exists to remove, moved to the other part.
1018
+ * Measured on the public examples (the pull request that closed issue #119):
1019
+ * without (3) the holders' `seam_override_px` rose from 47 to 617 on demo's
1020
+ * `hair_back` and from 20 to 222 on sample's `bottomwear`; with it, and with
1021
+ * the crumbs `keepArtWhole` clears, no part's `seam_override_px` moved by
1022
+ * more than 1 on either example under either seam rule.
1023
+ *
1024
+ * Why it is right to clear rather than to recolour: the holder is opaque
1025
+ * there, so no pixel loses cover (`COVERED_ALPHA` is below `CORE_ALPHA`) and
1026
+ * no uncovered error pixel appears; and the colour the painting shows there
1027
+ * belongs to the holder, which moves with the holder's bones. A fringe
1028
+ * recoloured to the painting would carry the holder's colour on the part in
1029
+ * front, as a translucent rim that leaves the holder when the two move apart.
1030
+ *
1031
+ * Left alone: a pixel whose stack composites to within `SEAM_LIMIT` of the
1032
+ * painting (an anti-aliased edge that is the painting's edge); a fringe no
1033
+ * part holds beneath (the figure's outline over the page — nothing would
1034
+ * hold the pixel after it); and every pixel at alpha >= `CORE_ALPHA`, which
1035
+ * is the seam override's.
1036
+ */
1037
+ export function pushBackFringe(parts: PlacedPart[], srcr: Raster, rule: SeamRule, silhouette: Mask | null): FringePushed[][] {
1038
+ const { width: W } = srcr;
1039
+ const { holder, cand } = seamCandidates(parts, srcr, rule, silhouette);
1040
+ const at = (i: number, d: number): number => {
1041
+ const p = parts[i].record;
1042
+ const x = (d % W) - p.x;
1043
+ const y = Math.floor(d / W) - p.y;
1044
+ return x >= 0 && x < p.w && y >= 0 && y < p.h ? (y * p.w + x) * 4 : -1;
1045
+ };
1046
+ /** The holder shows the painting at rig pixel `d`: it exists and its own colour is within `SEAM_LIMIT` of the painting's. */
1047
+ const shows = (d: number): boolean => holder[d] >= 0 && maxDiff(parts[holder[d]].image.data, at(holder[d], d), srcr.data, d * 4) <= SEAM_LIMIT;
1048
+ /** Per part, every pixel cleared: its index in the part's box, its colour before, its holder, and whether it is still clear. */
1049
+ const cleared: Cleared[][] = parts.map(() => []);
1050
+ for (let d = 0; d < cand.length; d++) {
1051
+ // Every part drawn after the holder is below CORE_ALPHA there, by the holder's definition; when the holder is the top
1052
+ // cover itself the loop below has nothing to clear, and the seam override acts on the pixel as before.
1053
+ if (cand[d] === 0 || !shows(d)) continue;
1054
+ for (let i = holder[d] + 1; i < parts.length; i++) {
1055
+ const s = at(i, d);
1056
+ if (s < 0) continue;
1057
+ const img = parts[i].image.data;
1058
+ if (img[s + 3] === 0) continue;
1059
+ cleared[i].push({ q: s / 4, rgba: Array.from(img.subarray(s, s + 4)), h: holder[d], on: true });
1060
+ img.fill(0, s, s + 4);
1061
+ }
1062
+ }
1063
+ parts.forEach((part, i) => keepArtWhole(part, cleared[i], (q) => {
1064
+ const d = (part.record.y + Math.floor(q / part.record.w)) * W + part.record.x + (q % part.record.w);
1065
+ return holder[d];
1066
+ }));
1067
+ const counts: Array<Map<number, number>> = parts.map(() => new Map());
1068
+ cleared.forEach((list, i) => {
1069
+ for (const c of list) if (c.on) counts[i].set(c.h, (counts[i].get(c.h) ?? 0) + 1);
1070
+ });
1071
+ return counts.map((m) =>
1072
+ [...m.entries()]
1073
+ .sort((a, b) => b[1] - a[1] || a[0] - b[0])
1074
+ .map(([h, px]) => ({ part: parts[h].record.name, px })),
1075
+ );
1076
+ }
1077
+
1078
+ /**
1079
+ * Step 5, over every part in plan order. Modifies the part images in place and
1080
+ * sets each record's `seam_override_px`.
1081
+ *
1082
+ * ⚖️ Invariant: over the flat composite of the parts on white (float, as the
1083
+ * reference composites), a CANDIDATE is a pixel differing from the painting by
1084
+ * more than `SEAM_LIMIT` (max channel) that some part covers (alpha > 0) and
1085
+ * that the rule admits — `near-white` (the reference's): the painting's min
1086
+ * channel is <= 235; `silhouette`: that, OR the pixel is inside
1087
+ * `figureSilhouette` — so a near-white painting pixel is protected only
1088
+ * outside the figure, which is the background the reference's guard exists
1089
+ * to protect. (Replacing the whiteness test by the silhouette outright was
1090
+ * measured and rejected: the opened mask loses figure-edge pixels the
1091
+ * whiteness test admits, and error px rose on eight of ten oracle
1092
+ * characters — ORACLE_assemble.md.)
1093
+ * Each candidate is recoloured to the painting's colour in the TOP-MOST part
1094
+ * covering it, and only where that part's alpha is >= 250; alpha never
1095
+ * changes. Candidates are chosen once, before any recolouring.
1096
+ * (`assemble_parts.py` `main`, "5. seam override".)
1097
+ */
1098
+ export function seamOverride(parts: PlacedPart[], srcr: Raster, rule: SeamRule, silhouette: Mask | null): void {
1099
+ const { width: W } = srcr;
1100
+ const { top, cand } = seamCandidates(parts, srcr, rule, silhouette);
1101
+ parts.forEach(({ record: p, image }, i) => {
1102
+ let n = 0;
1103
+ for (let y = 0; y < p.h; y++) {
1104
+ for (let x = 0; x < p.w; x++) {
1105
+ const d = (p.y + y) * W + p.x + x;
1106
+ const s = (y * p.w + x) * 4;
1107
+ if (cand[d] === 0 || top[d] !== i || image.data[s + 3] < CORE_ALPHA) continue;
1108
+ image.data[s] = srcr.data[d * 4];
1109
+ image.data[s + 1] = srcr.data[d * 4 + 1];
1110
+ image.data[s + 2] = srcr.data[d * 4 + 2];
1111
+ n++;
1112
+ }
1113
+ }
1114
+ p.seam_override_px = n;
1115
+ });
1116
+ }
1117
+
1118
+ // ---------------------------------------------------------------------------
1119
+ // the recomposite
1120
+ // ---------------------------------------------------------------------------
1121
+
1122
+ export interface RecompositeFigures {
1123
+ /** Mean over pixels of the mean-channel |recomposite - painting|. */
1124
+ meanAbs: number;
1125
+ /** Fraction of pixels whose mean-channel difference is <= `WITHIN_LIMIT`. */
1126
+ within: number;
1127
+ /** Pixels whose max-channel difference is > `ERROR_LIMIT`. */
1128
+ errorPx: number;
1129
+ /** Of those, the ones no part covers (no part has alpha above `COVERED_ALPHA`). */
1130
+ uncoveredErrorPx: number;
1131
+ /** The 8-connected components of the uncovered error pixels: how many there are … */
1132
+ holeCount: number;
1133
+ /** … and the largest `HOLES_LISTED` of them, largest first (`uncoveredHoles`). */
1134
+ holes: RecompositeHole[];
1135
+ }
1136
+
1137
+ /** How many uncovered holes the assemble summary and `parts.json` list, largest first. */
1138
+ export const HOLES_LISTED = 5;
1139
+
1140
+ /** A part beside an uncovered hole: how many of its covered pixels touch the hole. */
1141
+ export interface HoleBorder {
1142
+ part: string;
1143
+ px: number;
1144
+ }
1145
+
1146
+ /** One 8-connected component of the uncovered error pixels, in rig pixels (y down, origin top-left). */
1147
+ export interface RecompositeHole {
1148
+ px: number;
1149
+ x: number;
1150
+ y: number;
1151
+ w: number;
1152
+ h: number;
1153
+ /** Every part bordering the hole, most border pixels first, then plan order; empty when it borders none. */
1154
+ borders: HoleBorder[];
1155
+ }
1156
+
1157
+ /** The parts composited in plan order onto opaque white, as `PIL.Image.alpha_composite` does it. */
1158
+ export function recomposite(parts: PlacedPart[], W: number, H: number): Raster {
1159
+ let can = newRaster(W, H);
1160
+ can.data.fill(255);
1161
+ for (const { record: p, image } of parts) can = alphaComposite(can, image, p.x, p.y);
1162
+ return can;
1163
+ }
1164
+
1165
+ /** Per rig pixel: 1 where some part has alpha above `COVERED_ALPHA`. */
1166
+ function coveredMask(parts: PlacedPart[], W: number, H: number): Uint8Array {
1167
+ const covered = new Uint8Array(W * H);
1168
+ for (const { record: p, image } of parts) {
1169
+ for (let y = 0; y < p.h; y++) for (let x = 0; x < p.w; x++) if (image.data[(y * p.w + x) * 4 + 3] > COVERED_ALPHA) covered[(p.y + y) * W + p.x + x] = 1;
1170
+ }
1171
+ return covered;
1172
+ }
1173
+
1174
+ /** Per rig pixel: 1 where the recomposite's max-channel difference from the painting is above `ERROR_LIMIT`. */
1175
+ function errorMask(can: Raster, srcr: Raster): Uint8Array {
1176
+ const n = srcr.width * srcr.height;
1177
+ const err = new Uint8Array(n);
1178
+ for (let p = 0; p < n; p++) {
1179
+ let m = 0;
1180
+ for (let c = 0; c < 3; c++) m = Math.max(m, Math.abs(can.data[p * 4 + c] - srcr.data[p * 4 + c]));
1181
+ if (m > ERROR_LIMIT) err[p] = 1;
1182
+ }
1183
+ return err;
1184
+ }
1185
+
1186
+ /**
1187
+ * The uncovered error pixels as holes: every 8-connected component of the
1188
+ * pixels whose recomposite differs from the painting by more than
1189
+ * `ERROR_LIMIT` (max channel) and that no part covers (alpha above
1190
+ * `COVERED_ALPHA`) — the pixels `uncoveredErrorPx` counts, so the components'
1191
+ * areas sum to it.
1192
+ *
1193
+ * ⚖️ Invariant: a hole's box is its pixels' bounding box; its `borders` are
1194
+ * the parts with a covered pixel 8-adjacent to one of its pixels, each with
1195
+ * the number of such pixels it covers (a pixel two parts cover counts for
1196
+ * both), most first and then in plan order. Holes are sorted by area, largest
1197
+ * first, then by the box's top row and left column, so the order is a
1198
+ * function of the pixels and not of a labelling scan. 8-connectivity is the
1199
+ * reference's own for blobs (`cv2.connectedComponentsWithStats(…, 8)` in
1200
+ * ghost clean-up): a hole joined only at a corner is one hole.
1201
+ *
1202
+ * This is not in the reference, whose report stopped at the count. The count
1203
+ * cannot say where to look; a box and the parts round it can.
1204
+ */
1205
+ export function uncoveredHoles(can: Raster, srcr: Raster, parts: PlacedPart[]): { count: number; holes: RecompositeHole[] } {
1206
+ const { width: W, height: H } = srcr;
1207
+ const covered = coveredMask(parts, W, H);
1208
+ const err = errorMask(can, srcr);
1209
+ const mask = newMask(W, H);
1210
+ for (let p = 0; p < W * H; p++) if (err[p] === 1 && covered[p] === 0) mask.data[p] = 1;
1211
+ const cc = connectedComponents(mask, 8);
1212
+ const all = cc.stats.slice(1).filter((s) => s.area > 0);
1213
+ all.sort((a, b) => b.area - a.area || a.top - b.top || a.left - b.left);
1214
+ const listed = all.slice(0, HOLES_LISTED);
1215
+ const holes = listed.map((s): RecompositeHole => {
1216
+ const borders: HoleBorder[] = [];
1217
+ parts.forEach(({ record: p, image }) => {
1218
+ let n = 0;
1219
+ // A part pixel can touch the hole only inside the hole's box grown by one.
1220
+ const x0 = Math.max(p.x, s.left - 1);
1221
+ const x1 = Math.min(p.x + p.w - 1, s.left + s.width);
1222
+ const y0 = Math.max(p.y, s.top - 1);
1223
+ const y1 = Math.min(p.y + p.h - 1, s.top + s.height);
1224
+ for (let y = y0; y <= y1; y++) {
1225
+ for (let x = x0; x <= x1; x++) {
1226
+ if (image.data[((y - p.y) * p.w + (x - p.x)) * 4 + 3] <= COVERED_ALPHA) continue;
1227
+ let touches = false;
1228
+ for (let dy = -1; dy <= 1 && !touches; dy++) {
1229
+ for (let dx = -1; dx <= 1 && !touches; dx++) {
1230
+ const u = x + dx;
1231
+ const v = y + dy;
1232
+ if (u >= 0 && u < W && v >= 0 && v < H && cc.labels[v * W + u] === s.label) touches = true;
1233
+ }
1234
+ }
1235
+ if (touches) n++;
1236
+ }
1237
+ }
1238
+ if (n > 0) borders.push({ part: p.name, px: n });
1239
+ });
1240
+ // Array.prototype.sort is stable, so equal counts keep plan order.
1241
+ borders.sort((a, b) => b.px - a.px);
1242
+ return { px: s.area, x: s.left, y: s.top, w: s.width, h: s.height, borders };
1243
+ });
1244
+ return { count: all.length, holes };
1245
+ }
1246
+
1247
+ export function measureRecomposite(can: Raster, srcr: Raster, parts: PlacedPart[]): RecompositeFigures {
1248
+ const { width: W, height: H } = srcr;
1249
+ const covered = coveredMask(parts, W, H);
1250
+ let sum = 0;
1251
+ let within = 0;
1252
+ let errorPx = 0;
1253
+ let uncoveredErrorPx = 0;
1254
+ for (let p = 0; p < W * H; p++) {
1255
+ let s = 0;
1256
+ let m = 0;
1257
+ for (let c = 0; c < 3; c++) {
1258
+ const d = Math.abs(can.data[p * 4 + c] - srcr.data[p * 4 + c]);
1259
+ s += d;
1260
+ m = Math.max(m, d);
1261
+ }
1262
+ const mean = s / 3;
1263
+ sum += mean;
1264
+ if (mean <= WITHIN_LIMIT) within++;
1265
+ if (m > ERROR_LIMIT) {
1266
+ errorPx++;
1267
+ if (covered[p] === 0) uncoveredErrorPx++;
1268
+ }
1269
+ }
1270
+ const { count, holes } = uncoveredHoles(can, srcr, parts);
1271
+ return { meanAbs: sum / (W * H), within: within / (W * H), errorPx, uncoveredErrorPx, holeCount: count, holes };
1272
+ }
1273
+
1274
+ /** The error map's colour for an uncovered error pixel: no part is there, so the page shows through. */
1275
+ export const MAP_UNCOVERED: readonly [number, number, number] = [255, 0, 0];
1276
+ /** … for a covered error pixel: a part is there, in a colour more than `ERROR_LIMIT` off the painting's. */
1277
+ export const MAP_MISMATCHED: readonly [number, number, number] = [0, 0, 255];
1278
+
1279
+ /**
1280
+ * The error map, `recomposite_error_rig.png`: the rig canvas, opaque, with
1281
+ * every uncovered error pixel `MAP_UNCOVERED` (red), every covered error pixel
1282
+ * `MAP_MISMATCHED` (blue), and every other pixel the painting in grey, dimmed
1283
+ * to the top quarter of the range — `192 + floor(luma / 4)`, luma the integer
1284
+ * BT.601 `(299 R + 587 G + 114 B + 500) / 1000` truncated.
1285
+ *
1286
+ * Why the painting dimmed rather than transparent: the map is read at contact-
1287
+ * sheet size, and a red blob on nothing says a hole exists but not where on
1288
+ * the figure it is; on a faint grey figure it sits between the legs it falls
1289
+ * between. Why grey and light: no pixel of the dimmed painting can be either
1290
+ * flag colour (a grey has equal channels, and at 192..255 it is lighter than
1291
+ * both), so the two colours mean exactly the two masks. Integer arithmetic
1292
+ * only, so the bytes are deterministic.
1293
+ */
1294
+ export function recompositeErrorMap(can: Raster, srcr: Raster, parts: PlacedPart[]): Raster {
1295
+ const { width: W, height: H } = srcr;
1296
+ const covered = coveredMask(parts, W, H);
1297
+ const err = errorMask(can, srcr);
1298
+ const map = newRaster(W, H);
1299
+ for (let p = 0; p < W * H; p++) {
1300
+ const s = p * 4;
1301
+ let rgb: readonly [number, number, number];
1302
+ if (err[p] === 1) rgb = covered[p] === 0 ? MAP_UNCOVERED : MAP_MISMATCHED;
1303
+ else {
1304
+ const luma = Math.floor((299 * srcr.data[s] + 587 * srcr.data[s + 1] + 114 * srcr.data[s + 2] + 500) / 1000);
1305
+ const g = 192 + Math.floor(luma / 4);
1306
+ rgb = [g, g, g];
1307
+ }
1308
+ map.data[s] = rgb[0];
1309
+ map.data[s + 1] = rgb[1];
1310
+ map.data[s + 2] = rgb[2];
1311
+ map.data[s + 3] = 255;
1312
+ }
1313
+ return map;
1314
+ }
1315
+
1316
+ export function figuresLine(f: RecompositeFigures): string {
1317
+ return (
1318
+ `recomposite vs source: mean |d|=${f.meanAbs.toFixed(2)}, within ${WITHIN_LIMIT}: ${(100 * f.within).toFixed(1)}%, ` +
1319
+ `error px > ${ERROR_LIMIT}: ${f.errorPx}, uncovered error px: ${f.uncoveredErrorPx}`
1320
+ );
1321
+ }
1322
+
1323
+ /** A hole's box as the part lines and refusals print a box: `x,y wxh`. */
1324
+ export function holeBox(h: RecompositeHole): string {
1325
+ return `${h.x},${h.y} ${h.w}x${h.h}`;
1326
+ }
1327
+
1328
+ /**
1329
+ * The hole lines under `figuresLine`: the count, then one line per listed
1330
+ * hole — `uncovered hole 1: 2340 px at 412,1088 30x78 (between "legwear_r"
1331
+ * 120 px, "legwear_l" 96 px)`, or `(borders no part)`.
1332
+ */
1333
+ export function holeLines(f: RecompositeFigures): string[] {
1334
+ const head = `uncovered holes (8-connected): ${f.holeCount}${f.holeCount > f.holes.length ? `, the largest ${f.holes.length} listed` : ''}`;
1335
+ return [
1336
+ head,
1337
+ ...f.holes.map(
1338
+ (h, i) =>
1339
+ ` uncovered hole ${i + 1}: ${h.px} px at ${holeBox(h)} (${h.borders.length === 0 ? 'borders no part' : `between ${h.borders.map((b) => `"${b.part}" ${b.px} px`).join(', ')}`})`,
1340
+ ),
1341
+ ];
1342
+ }
1343
+
1344
+ /** The `parts.json` `recomposite` block: the four figures, the limits they were measured at, and the listed holes. */
1345
+ export function recompositeRecord(f: RecompositeFigures): RecompositeRecord {
1346
+ return {
1347
+ mean_abs: pyRound(f.meanAbs, 3),
1348
+ within_limit: WITHIN_LIMIT,
1349
+ within_share: pyRound(f.within, 4),
1350
+ error_limit: ERROR_LIMIT,
1351
+ error_px: f.errorPx,
1352
+ covered_alpha: COVERED_ALPHA,
1353
+ uncovered_error_px: f.uncoveredErrorPx,
1354
+ hole_count: f.holeCount,
1355
+ holes_listed: HOLES_LISTED,
1356
+ holes: f.holes.map((h) => ({ px: h.px, x: h.x, y: h.y, w: h.w, h: h.h, borders: h.borders.map((b) => ({ part: b.part, px: b.px })) })),
1357
+ };
1358
+ }
1359
+
1360
+ // ---------------------------------------------------------------------------
1361
+ // patches — extra parts cut from the painting
1362
+ // ---------------------------------------------------------------------------
1363
+
1364
+ /**
1365
+ * One `assemble.patches` entry as a part: the painting's own pixels over its
1366
+ * box, taken where its alpha rule says.
1367
+ *
1368
+ * ⚖️ Invariant: a pixel of the rig is in the patch exactly when it lies in
1369
+ * `box` (`[x0, y0, x1, y1]`, rig pixels, `x1`/`y1` exclusive) and, for
1370
+ * `alpha: "silhouette"`, `figureSilhouette(srcr)` holds it; there its colour
1371
+ * is the painting's (`srcr`) and its alpha 255, everywhere else the pixel is
1372
+ * transparent. The silhouette is the painting's, not the union of the
1373
+ * layers' alpha: the pixels a patch exists for are exactly the ones no layer
1374
+ * holds, so the layers' union is empty over them by definition. The record is
1375
+ * the patch's alpha box and its counts, all of them `opaque_px` or 0 — a
1376
+ * patch is 100 % source (`src/parts.ts`). `seam_override_px` is set later by
1377
+ * `seamOverride`, as for every part.
1378
+ */
1379
+ export function cutPatch(patch: Patch, srcr: Raster, silhouette: Mask | null): { record: PartRecord; image: Raster } | null {
1380
+ const { width: W, height: H } = srcr;
1381
+ const [bx0, by0, bx1, by1] = patch.box;
1382
+ const r = newRaster(W, H);
1383
+ let x0 = W;
1384
+ let y0 = H;
1385
+ let x1 = -1;
1386
+ let y1 = -1;
1387
+ let n = 0;
1388
+ for (let y = by0; y < by1; y++) {
1389
+ for (let x = bx0; x < bx1; x++) {
1390
+ const p = y * W + x;
1391
+ if (patch.alpha === 'silhouette' && (silhouette === null || silhouette.data[p] === 0)) continue;
1392
+ r.data.set(srcr.data.subarray(p * 4, p * 4 + 3), p * 4);
1393
+ r.data[p * 4 + 3] = 255;
1394
+ n++;
1395
+ if (x < x0) x0 = x;
1396
+ if (x > x1) x1 = x;
1397
+ if (y < y0) y0 = y;
1398
+ if (y > y1) y1 = y;
1399
+ }
1400
+ }
1401
+ if (n === 0) return null;
1402
+ const record: PartRecord = {
1403
+ name: patch.name,
1404
+ from: `${PAINTING_RUN}:${patch.name}`,
1405
+ x: x0,
1406
+ y: y0,
1407
+ w: x1 - x0 + 1,
1408
+ h: y1 - y0 + 1,
1409
+ opaque_px: n,
1410
+ visible_px: n,
1411
+ occluded_px: 0,
1412
+ projected_core_px: n,
1413
+ source_px_taken: n,
1414
+ visible_not_projected_px: 0,
1415
+ refused_drift_px: 0,
1416
+ merged_px: 0,
1417
+ seam_override_px: 0,
1418
+ };
1419
+ return { record, image: crop(r, x0, y0, record.w, record.h) };
1420
+ }
1421
+
1422
+ /** One place in the draw order: a plan part, a patch or a cut's new part, by its index in its list. */
1423
+ export type DrawSlot = { plan: number } | { patch: number } | { cut: number };
1424
+
1425
+ /**
1426
+ * The draw order of plan parts, patches and cuts together, back to front:
1427
+ * every `"back"` patch in `patches` order, then every `"back"` cut in `cuts`
1428
+ * order; then each plan part preceded by the patches drawn `{before: <that
1429
+ * part>}` in `patches` order and then the cuts drawn so in `cuts` order; then
1430
+ * every `"front"` patch, then every `"front"` cut. With no cuts it is the
1431
+ * order of plan parts and patches it always was. The loader has already
1432
+ * refused a `before` that names no plan part.
1433
+ */
1434
+ export function drawOrder(plan: readonly string[], patches: readonly Patch[], cuts: readonly Cut[] = []): DrawSlot[] {
1435
+ const out: DrawSlot[] = [];
1436
+ const place = (at: (draw: Patch['draw']) => boolean): void => {
1437
+ patches.forEach((q, i) => {
1438
+ if (at(q.draw)) out.push({ patch: i });
1439
+ });
1440
+ cuts.forEach((q, i) => {
1441
+ if (at(q.draw)) out.push({ cut: i });
1442
+ });
1443
+ };
1444
+ place((d) => d === 'back');
1445
+ plan.forEach((name, pi) => {
1446
+ place((d) => typeof d === 'object' && d.before === name);
1447
+ out.push({ plan: pi });
1448
+ });
1449
+ place((d) => d === 'front');
1450
+ return out;
1451
+ }
1452
+
1453
+ // ---------------------------------------------------------------------------
1454
+ // cuts — a plan part's pixels taken into a new part by a declared polygon
1455
+ // ---------------------------------------------------------------------------
1456
+
1457
+ /**
1458
+ * The rig pixels a polygon holds: pixel `(x, y)` is inside when its centre
1459
+ * `(x + 0.5, y + 0.5)` is inside the polygon by the even-odd rule: on each
1460
+ * row, the polygon's edges cross the centre line at sorted x, and the centres
1461
+ * strictly between the first and second crossing, the third and fourth, …
1462
+ * are inside. An edge crosses when exactly one of its ends lies below the
1463
+ * line; the vertices are integers and the line is at a half, so no vertex
1464
+ * lies on it and a horizontal edge never crosses. A centre exactly on a
1465
+ * crossing is outside. Pure arithmetic on the declared integers: the same
1466
+ * polygon holds the same pixels on every machine.
1467
+ */
1468
+ export function pixelsInPolygon(polygon: ReadonlyArray<readonly [number, number]>, W: number, H: number): Mask {
1469
+ const m = newMask(W, H);
1470
+ const n = polygon.length;
1471
+ for (let y = 0; y < H; y++) {
1472
+ const cy = y + 0.5;
1473
+ // The crossings of this row's centre line, sorted; pixels between pairs are inside.
1474
+ const xs: number[] = [];
1475
+ for (let i = 0, j = n - 1; i < n; j = i++) {
1476
+ const [xi, yi] = polygon[i];
1477
+ const [xj, yj] = polygon[j];
1478
+ if (yi > cy !== yj > cy) xs.push(xi + ((cy - yi) * (xj - xi)) / (yj - yi));
1479
+ }
1480
+ if (xs.length === 0) continue;
1481
+ xs.sort((a, b) => a - b);
1482
+ for (let k = 0; k + 1 < xs.length; k += 2) {
1483
+ // Centres x + 0.5 with xs[k] < x + 0.5 < xs[k + 1]; a centre exactly on a crossing is outside.
1484
+ const from = Math.max(0, Math.floor(xs[k] - 0.5) + 1);
1485
+ const to = Math.min(W - 1, Math.ceil(xs[k + 1] - 0.5) - 1);
1486
+ for (let x = from; x <= to; x++) m.data[y * W + x] = 1;
1487
+ }
1488
+ }
1489
+ return m;
1490
+ }
1491
+
1492
+ /** What one cut did, counted when it was made: the from part's opaque pixels before, what it kept, what the cut took. */
1493
+ export interface CutCount {
1494
+ from: string;
1495
+ into: string;
1496
+ /** The from part's `alpha > 8` pixels before any of its cuts. */
1497
+ before: number;
1498
+ /** What the from part kept after all its cuts; the same on every cut of one part. */
1499
+ kept: number;
1500
+ /** What this cut took. */
1501
+ taken: number;
1502
+ /** Of `taken`, the band the from part keeps as well (`cutBand`); counted in neither `kept` nor `before` twice. */
1503
+ band: number;
1504
+ /** With `under` only (issue #193): the base pixels filled from the under image (`fillUnder`); absent without it. */
1505
+ under?: number;
1506
+ /** With `under` only: the removed pixels the image left empty (its alpha below the alpha removed); `under + underEmpty` is the removed count. */
1507
+ underEmpty?: number;
1508
+ }
1509
+
1510
+ /**
1511
+ * The cut lines the assemble stage prints, one per plan part with cuts, in
1512
+ * the `pixels:` line's shape: ` cut: "<from>" opaque <before> = "<from>"
1513
+ * <kept> + "<into>" <taken> …`. Empty without cuts, so a config with none
1514
+ * prints what it printed before.
1515
+ */
1516
+ export function cutLines(cuts: readonly CutCount[]): string[] {
1517
+ const byFrom = new Map<string, CutCount[]>();
1518
+ for (const c of cuts) byFrom.set(c.from, [...(byFrom.get(c.from) ?? []), c]);
1519
+ return [...byFrom.entries()].map(
1520
+ ([from, cs]) =>
1521
+ ` cut: "${from}" opaque ${cs[0].before} = "${from}" ${cs[0].kept} + ${cs.map((c) => `"${c.into}" ${c.taken}`).join(' + ')}; band ${cs.reduce((a, c) => a + c.band, 0)} px held by both` +
1522
+ (cs.some((c) => c.under !== undefined) ? `; under ${cs.reduce((a, c) => a + (c.under ?? 0), 0)} px filled, ${cs.reduce((a, c) => a + (c.underEmpty ?? 0), 0)} px left empty` : ''),
1523
+ );
1524
+ }
1525
+
1526
+ /**
1527
+ * The box a cut's `under` image covers (issue #193): the polygon's bounding
1528
+ * box in rig pixels, its corners the polygon's least and greatest x and y —
1529
+ * the image is `x1 - x0` by `y1 - y0` and its pixel `(i, j)` is rig pixel
1530
+ * `(x0 + i, y0 + j)`. Every pixel the polygon holds lies in it: a pixel is
1531
+ * held by its centre, and the centres inside lie strictly between the
1532
+ * extreme vertices.
1533
+ */
1534
+ export function underBox(polygon: ReadonlyArray<readonly [number, number]>): { x0: number; y0: number; w: number; h: number } {
1535
+ const xs = polygon.map((q) => q[0]);
1536
+ const ys = polygon.map((q) => q[1]);
1537
+ const x0 = Math.min(...xs);
1538
+ const y0 = Math.min(...ys);
1539
+ return { x0, y0, w: Math.max(...xs) - x0, h: Math.max(...ys) - y0 };
1540
+ }
1541
+
1542
+ /**
1543
+ * Fill the base where a cut removed its pixels (issue #193). The removed
1544
+ * pixels are those the piece holds (alpha above 0) and the band does not —
1545
+ * the pixels the cut cleared in the base. At each, when the under image's
1546
+ * alpha is at least the alpha the cut removed there, the base takes the
1547
+ * image's pixel, colour and alpha (filled); otherwise the base stays cleared
1548
+ * there (left empty): a transparent pixel is the author's "nothing lies under
1549
+ * here", and a pixel less opaque than the layer was is not half-filled. That
1550
+ * is the only place the image is read: its pixels over the band, over pixels
1551
+ * the layer never had, and over the box outside the polygon change nothing.
1552
+ * `filled + empty` is the removed count.
1553
+ */
1554
+ export function fillUnder(base: Raster, piece: Raster, band: Mask, under: Raster, box: { x0: number; y0: number }): { filled: number; empty: number } {
1555
+ const W = base.width;
1556
+ let filled = 0;
1557
+ let empty = 0;
1558
+ for (let p = 0; p < W * base.height; p++) {
1559
+ const a = piece.data[p * 4 + 3];
1560
+ if (a === 0 || band.data[p] === 1) continue;
1561
+ const u = ((Math.floor(p / W) - box.y0) * under.width + (p % W) - box.x0) * 4;
1562
+ if (under.data[u + 3] < a) {
1563
+ empty++;
1564
+ continue;
1565
+ }
1566
+ base.data.set(under.data.subarray(u, u + 4), p * 4);
1567
+ filled++;
1568
+ }
1569
+ return { filled, empty };
1570
+ }
1571
+
1572
+ /**
1573
+ * A cut's band (issue #170, `overlap`): the polygon's pixels within `overlap`
1574
+ * rig px of its edge, distance read as Chebyshev distance — a pixel of the
1575
+ * polygon is in the band when the `(2 * overlap + 1)`-square centred on it
1576
+ * holds a pixel the polygon does not, i.e. the polygon's mask less its
1577
+ * `erode` by that square (`cv2.erode`'s border: the rig's edge is not the
1578
+ * polygon's edge). Empty at `overlap` 0. The base part keeps its pixels there
1579
+ * as well as the piece, so the piece's resampled edge has art under it.
1580
+ */
1581
+ export function cutBand(polygonMask: Mask, overlap: number): Mask {
1582
+ const band = newMask(polygonMask.width, polygonMask.height);
1583
+ if (overlap === 0) return band;
1584
+ const inner = erode(polygonMask, 2 * overlap + 1);
1585
+ for (let p = 0; p < band.data.length; p++) band.data[p] = polygonMask.data[p] === 1 && inner.data[p] === 0 ? 1 : 0;
1586
+ return band;
1587
+ }
1588
+
1589
+ // ---------------------------------------------------------------------------
1590
+ // the stage
1591
+ // ---------------------------------------------------------------------------
1592
+
1593
+ export interface AssembleResult {
1594
+ parts: PartsFile;
1595
+ /** In plan order, each cropped to its record's box. */
1596
+ images: PlacedPart[];
1597
+ recomposite: Raster;
1598
+ /** `recompositeErrorMap`: uncovered error pixels red, covered ones blue, the painting dimmed grey. */
1599
+ errorMap: Raster;
1600
+ figures: RecompositeFigures;
1601
+ /** What each `assemble.cuts` entry took, counted when it was made (`cutLines`); empty without cuts. */
1602
+ cuts: CutCount[];
1603
+ seamRule: SeamRule;
1604
+ projectRule: ProjectRule;
1605
+ }
1606
+
1607
+ function tagsOf(set: LayerSet): Set<string> {
1608
+ return new Set(set.layers.map((l) => l.name));
1609
+ }
1610
+
1611
+ /**
1612
+ * Refuse, by name, every plan and extend entry whose tag its run does not
1613
+ * hold. Collected before any pixel work.
1614
+ */
1615
+ export function checkPlanAgainstRuns(plan: PlanEntry[], extend: Extend[], full: LayerSet, head: LayerSet): void {
1616
+ const tags = { full: tagsOf(full), head: tagsOf(head) };
1617
+ const problems: Problem[] = [];
1618
+ const known = (run: Run): string => [...tags[run]].sort().join(', ');
1619
+ plan.forEach(([name, run, tag], i) => {
1620
+ if (!tags[run].has(tag)) {
1621
+ problems.push({
1622
+ code: 'ASSEMBLE_PLAN_TAG_IN_RUN',
1623
+ object: `config.assemble.plan[${i}] (part "${name}")`,
1624
+ detail: `takes ${run}:${tag}; the ${run} run has no layer "${tag}" — it holds ${known(run)}`,
1625
+ });
1626
+ }
1627
+ });
1628
+ extend.forEach((e, i) => {
1629
+ if (!tags[e.run].has(e.tag)) {
1630
+ problems.push({
1631
+ code: 'ASSEMBLE_EXTEND_TAG_IN_RUN',
1632
+ object: `config.assemble.extend_below_crop[${i}] (part "${e.part}")`,
1633
+ detail: `takes ${e.run}:${e.tag}; the ${e.run} run has no layer "${e.tag}" — it holds ${known(e.run)}`,
1634
+ });
1635
+ }
1636
+ });
1637
+ refuseIfAny(problems);
1638
+ }
1639
+
1640
+ /**
1641
+ * Run the stage in memory. Everything that can be refused is refused before
1642
+ * this returns; nothing here writes a file, so a caller that writes only
1643
+ * after this returned writes only after green.
1644
+ */
1645
+ export function assemble(input: AssembleInput): AssembleResult {
1646
+ const { source, full, head, plan, extend, patches, cuts, seamRule, projectRule } = input;
1647
+ const unders = cuts.map((q, i) => input.under?.[i] ?? null);
1648
+ const frame = checkGeometry(
1649
+ { sourceW: source.width, sourceH: source.height, resolution: input.resolution, headBox: input.headBox, rigScale: input.rigScale },
1650
+ { full, head },
1651
+ );
1652
+ checkPlanAgainstRuns(plan, extend, full, head);
1653
+ const { W, H } = frame;
1654
+ const srcr = sourceInRig(source, W, H);
1655
+
1656
+ const rig = new Map<string, Raster>();
1657
+ const stats = new Map<string, ProjectionStats>();
1658
+ const ghost: Record<string, number> = {};
1659
+ for (const [run, set] of [['full', full], ['head', head]] as const) {
1660
+ const layers = runLayers(set, input.resolution);
1661
+ const rigLayers = layers.map((l) => layerToRig(l.canvas, frame, run));
1662
+ const st = projectSource(rigLayers, srcr, frame, run, projectRule);
1663
+ layers.forEach((l, i) => {
1664
+ rig.set(`${run}:${l.tag}`, rigLayers[i]);
1665
+ stats.set(`${run}:${l.tag}`, st[i]);
1666
+ ghost[`${run}:${l.tag}`] = l.ghostPx;
1667
+ });
1668
+ }
1669
+
1670
+ // Patches are checked against the rig before any pixel work, like the plan.
1671
+ const patchProblems: Problem[] = [];
1672
+ patches.forEach((q, i) => {
1673
+ const [, , bx1, by1] = q.box;
1674
+ if (bx1 > W || by1 > H) {
1675
+ patchProblems.push({
1676
+ code: 'ASSEMBLE_PATCH_BOX_INSIDE',
1677
+ object: `config.assemble.patches[${i}] (patch "${q.name}")`,
1678
+ detail: `box is [${q.box.join(', ')}]; the rig is ${W}x${H} (the painting times rig_scale ${frame.S}), so x1 <= ${W} and y1 <= ${H} are required — the box is in rig pixels, x1 and y1 exclusive`,
1679
+ });
1680
+ }
1681
+ });
1682
+ // Cuts too: a polygon past the rig is in some other space (source pixels,
1683
+ // most likely), and two cuts of one part that share a pixel would give it
1684
+ // to both. Every pair is named.
1685
+ const cutMasks = cuts.map((q) => pixelsInPolygon(q.polygon, W, H));
1686
+ cuts.forEach((q, i) => {
1687
+ const out = q.polygon.filter(([x, y]) => x > W || y > H);
1688
+ if (out.length > 0) {
1689
+ patchProblems.push({
1690
+ code: 'ASSEMBLE_CUT_INSIDE',
1691
+ object: `config.assemble.cuts[${i}] (part "${q.into}")`,
1692
+ detail: `polygon point(s) ${out.map(([x, y]) => `[${x}, ${y}]`).join(', ')} lie past the ${W}x${H} rig (the painting times rig_scale ${frame.S}); x <= ${W} and y <= ${H} are required — the polygon is in rig pixels, the space of parts.json`,
1693
+ });
1694
+ }
1695
+ for (let j = 0; j < i; j++) {
1696
+ if (cuts[j].from !== q.from) continue;
1697
+ let shared = 0;
1698
+ let first = -1;
1699
+ for (let p = 0; p < W * H; p++) {
1700
+ if (cutMasks[i].data[p] === 1 && cutMasks[j].data[p] === 1) {
1701
+ shared++;
1702
+ if (first < 0) first = p;
1703
+ }
1704
+ }
1705
+ if (shared > 0) {
1706
+ patchProblems.push({
1707
+ code: 'ASSEMBLE_CUT_OVERLAP',
1708
+ object: `config.assemble.cuts[${i}] (part "${q.into}")`,
1709
+ detail: `shares ${shared} rig pixel(s) with cuts[${j}] (part "${cuts[j].into}"), the first at ${first % W},${Math.floor(first / W)}; both cut "${q.from}", and a pixel goes to one part — polygons of one part that do not overlap are required`,
1710
+ });
1711
+ }
1712
+ }
1713
+ });
1714
+ // A cut's under image (issue #193), before any pixel work too: it must have
1715
+ // been read, be exactly the polygon's bounding box, and the piece must be
1716
+ // drawn in front of the part it fills — drawn behind, the fill would hide
1717
+ // the piece where it was painted.
1718
+ const order0 = drawOrder(
1719
+ plan.map((e) => e[0]),
1720
+ patches,
1721
+ cuts,
1722
+ );
1723
+ cuts.forEach((q, i) => {
1724
+ if (q.under === undefined) return;
1725
+ const object = `config.assemble.cuts[${i}].under (part "${q.into}")`;
1726
+ const u = unders[i];
1727
+ if (u === null) {
1728
+ patchProblems.push({ code: 'ASSEMBLE_UNDER_PRESENT', object, detail: `names ${q.under}, and no image was handed to the stage for it; the decoded PNG is required (a caller of assemble() reads the file)` });
1729
+ return;
1730
+ }
1731
+ const box = underBox(q.polygon);
1732
+ if (u.width !== box.w || u.height !== box.h) {
1733
+ patchProblems.push({
1734
+ code: 'ASSEMBLE_UNDER_SIZE',
1735
+ object,
1736
+ detail: `${q.under} is ${u.width}x${u.height}; the polygon's bounding box is ${box.w}x${box.h} at ${box.x0},${box.y0} (least and greatest polygon x and y, in rig pixels), and an image of exactly that size is required — its pixel (i, j) is rig pixel (${box.x0} + i, ${box.y0} + j)`,
1737
+ });
1738
+ }
1739
+ const at = order0.findIndex((d) => 'cut' in d && d.cut === i);
1740
+ const base = order0.findIndex((d) => 'plan' in d && plan[d.plan][0] === q.from);
1741
+ if (at < base) {
1742
+ patchProblems.push({
1743
+ code: 'ASSEMBLE_UNDER_DRAW',
1744
+ object: `config.assemble.cuts[${i}].draw (part "${q.into}")`,
1745
+ detail: `is ${JSON.stringify(q.draw)}, which draws "${q.into}" behind "${q.from}"; with under, "${q.from}" is filled where the piece was painted and would hide it, so a draw in front of "${q.from}" is required — "front", or {"before": "<the plan part after ${q.from}>"} to keep the setup pose`,
1746
+ });
1747
+ }
1748
+ });
1749
+ refuseIfAny(patchProblems);
1750
+
1751
+ interface Built {
1752
+ key: string;
1753
+ r: Raster;
1754
+ st: ProjectionStats;
1755
+ extendKeys: string[];
1756
+ trace: MergeTrace;
1757
+ merged: number;
1758
+ /** The rig pixels this piece of the layer owns when the layer is cut, null when it is not: the counts are split by it. */
1759
+ region: Mask | null;
1760
+ }
1761
+ const built: Array<Built | null> = plan.map(() => null);
1762
+ const empty: Problem[] = [];
1763
+ /** The part's alpha box and its `alpha > 8` count, over the whole rig. */
1764
+ const measureBox = (r: Raster): { x0: number; y0: number; x1: number; y1: number; opaque: number } => {
1765
+ let x0 = W;
1766
+ let y0 = H;
1767
+ let x1 = -1;
1768
+ let y1 = -1;
1769
+ let opaque = 0;
1770
+ for (let y = 0; y < H; y++) {
1771
+ for (let x = 0; x < W; x++) {
1772
+ const a = r.data[(y * W + x) * 4 + 3];
1773
+ if (a > OPAQUE_ALPHA_ABOVE) opaque++;
1774
+ if (a > 0) {
1775
+ if (x < x0) x0 = x;
1776
+ if (x > x1) x1 = x;
1777
+ if (y < y0) y0 = y;
1778
+ if (y > y1) y1 = y;
1779
+ }
1780
+ }
1781
+ }
1782
+ return { x0, y0, x1, y1, opaque };
1783
+ };
1784
+ plan.forEach(([name, run, tag], pi) => {
1785
+ const key = `${run}:${tag}`;
1786
+ const src = rig.get(key) as Raster;
1787
+ const r: Raster = { width: W, height: H, data: new Uint8ClampedArray(src.data) };
1788
+ const st = stats.get(key) as ProjectionStats;
1789
+ const extendKeys = extend.filter((e) => e.part === name).map((e) => `${e.run}:${e.tag}`);
1790
+ const extras = extendKeys.map((k) => rig.get(k) as Raster);
1791
+ let merged = 0;
1792
+ const trace: MergeTrace = { from: new Int32Array(W * H).fill(-1), ring: newMask(W, H) };
1793
+ if (extras.length > 0) {
1794
+ const front = newMask(W, H);
1795
+ for (const [, run2, tag2] of plan.slice(pi + 1)) {
1796
+ const f = rig.get(`${run2}:${tag2}`) as Raster;
1797
+ for (let p = 0; p < W * H; p++) if (f.data[p * 4 + 3] >= FRONT_ALPHA) front.data[p] = 1;
1798
+ }
1799
+ merged = mergeBelowCrop(r, extras, front, srcr, frame.headBottom, trace);
1800
+ }
1801
+ const { x1, opaque } = measureBox(r);
1802
+ if (opaque === 0) {
1803
+ empty.push({
1804
+ code: 'ASSEMBLE_PART_OPAQUE',
1805
+ object: `part "${name}" (${key}, config.assemble.plan[${pi}])`,
1806
+ detail: `has 0 pixels with alpha above ${OPAQUE_ALPHA_ABOVE} in the rig (${x1 < 0 ? 'no pixel at all' : 'only fringe below it'}; ${ghost[key]} ghost px removed in the run); a part with at least one opaque pixel is required — drop it from the plan or take the tag from the other run`,
1807
+ });
1808
+ return;
1809
+ }
1810
+ built[pi] = { key, r, st, extendKeys, trace, merged, region: null };
1811
+ });
1812
+ const silhouette = seamRule === 'silhouette' || patches.some((q) => q.alpha === 'silhouette') ? figureSilhouette(srcr) : null;
1813
+ const cut = patches.map((q, i) => {
1814
+ const c = cutPatch(q, srcr, silhouette);
1815
+ if (c === null) {
1816
+ empty.push({
1817
+ code: 'ASSEMBLE_PATCH_OPAQUE',
1818
+ object: `patch "${q.name}" (config.assemble.patches[${i}])`,
1819
+ detail: `takes 0 pixels: ${q.alpha === 'silhouette' ? `the painting's figure silhouette does not reach its box [${q.box.join(', ')}]` : 'its box is empty'}; a patch with at least one pixel is required — move the box onto the figure, or take "alpha": "box"`,
1820
+ });
1821
+ }
1822
+ return c;
1823
+ });
1824
+
1825
+ // The cuts (issue #170), after step 4 so a piece takes the merged pixels
1826
+ // too: every pixel of the part with alpha above 0 whose centre a cut's
1827
+ // polygon holds moves to that cut's piece, colour and alpha, and is cleared
1828
+ // in the part. The polygons of one part are disjoint (refused above), so
1829
+ // each pixel goes to exactly one piece, and the counts printed are taken
1830
+ // here, before steps 4b and 5.
1831
+ const count = (m: Mask | null, region: Mask): number => {
1832
+ if (m === null) return 0;
1833
+ let n = 0;
1834
+ for (let p = 0; p < W * H; p++) if (m.data[p] === 1 && region.data[p] === 1) n++;
1835
+ return n;
1836
+ };
1837
+ const opaqueOf = (r: Raster): number => {
1838
+ let n = 0;
1839
+ for (let p = 0; p < W * H; p++) if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) n++;
1840
+ return n;
1841
+ };
1842
+ const mergedIn = (b: Built, region: Mask): number => count(b.trace.lastSel ?? null, region) + count(b.trace.lastRing ?? null, region);
1843
+ const pieces: Array<Built | null> = cuts.map(() => null);
1844
+ const cutCounts: CutCount[] = [];
1845
+ plan.forEach(([name], pi) => {
1846
+ const mine = cuts.map((q, i) => i).filter((i) => cuts[i].from === name);
1847
+ const b = built[pi];
1848
+ if (mine.length === 0 || b === null) return;
1849
+ const before = opaqueOf(b.r);
1850
+ const rest = newMask(W, H);
1851
+ rest.data.fill(1);
1852
+ // The base's own pixels: `rest`, and every cut's band, which it keeps as well as the piece.
1853
+ const own = newMask(W, H);
1854
+ const bands: number[] = [];
1855
+ const taken = mine.map((i) => {
1856
+ const m = cutMasks[i];
1857
+ const band = cutBand(m, cuts[i].overlap);
1858
+ const r = newRaster(W, H);
1859
+ let n = 0;
1860
+ let nb = 0;
1861
+ for (let p = 0; p < W * H; p++) {
1862
+ rest.data[p] &= 1 - m.data[p];
1863
+ if (band.data[p] === 1) own.data[p] = 1;
1864
+ const a = b.r.data[p * 4 + 3];
1865
+ if (m.data[p] === 0 || a === 0) continue;
1866
+ r.data.set(b.r.data.subarray(p * 4, p * 4 + 4), p * 4);
1867
+ if (band.data[p] === 0) b.r.data.fill(0, p * 4, p * 4 + 4);
1868
+ if (a > OPAQUE_ALPHA_ABOVE) {
1869
+ n++;
1870
+ if (band.data[p] === 1) nb++;
1871
+ }
1872
+ }
1873
+ pieces[i] = { ...b, r, region: m, merged: mergedIn(b, m) };
1874
+ bands.push(nb);
1875
+ return n;
1876
+ });
1877
+ for (let p = 0; p < W * H; p++) if (rest.data[p] === 1) own.data[p] = 1;
1878
+ const bandSum = bands.reduce((a, n) => a + n, 0);
1879
+ // What the base kept of the strict partition; its image also holds the bands.
1880
+ const kept = opaqueOf(b.r) - bandSum;
1881
+ const sum = taken.reduce((a, n) => a + n, 0);
1882
+ const object = `part "${name}" (${b.key}, config.assemble.plan[${pi}])`;
1883
+ // By construction: the pieces partition the layer, so their counts add up to the layer's. A refusal here is a bug.
1884
+ const split = [
1885
+ ['opaque px', before, kept + sum],
1886
+ ['merged_px', b.merged, mergedIn(b, rest) + mine.reduce((a, i) => a + (pieces[i] as Built).merged, 0)],
1887
+ ['projected_core_px', b.st.core, count(b.st.coreMask, rest) + mine.reduce((a, i) => a + count(b.st.coreMask, cutMasks[i]), 0)],
1888
+ ['source_px_taken', b.st.taken, count(b.st.takenMask, rest) + mine.reduce((a, i) => a + count(b.st.takenMask, cutMasks[i]), 0)],
1889
+ ['refused_drift_px', b.st.refused, count(b.st.refusedMask, rest) + mine.reduce((a, i) => a + count(b.st.refusedMask, cutMasks[i]), 0)],
1890
+ ] as const;
1891
+ for (const [what, whole, parts] of split) {
1892
+ if (whole !== parts) empty.push({ code: 'ASSEMBLE_COUNTS_ADD_UP', object, detail: `${what}: ${whole} before its cuts, ${parts} over the pieces; equal is required (a bug in the assembler, not in the inputs)` });
1893
+ }
1894
+ mine.forEach((i, k) => {
1895
+ if (taken[k] === 0) {
1896
+ empty.push({
1897
+ code: 'ASSEMBLE_CUT_PIXELS',
1898
+ object: `config.assemble.cuts[${i}] (part "${cuts[i].into}")`,
1899
+ detail: `takes 0 of "${name}"'s ${before} opaque pixel(s) (alpha above ${OPAQUE_ALPHA_ABOVE}); its polygon holds ${count(cutMasks[i], cutMasks[i])} rig pixel(s) and none is that layer's art — a cut that takes at least one is required: draw the polygon over the art (parts/${name}.png, placed at its parts.json x, y, is the layer in rig pixels)`,
1900
+ });
1901
+ }
1902
+ });
1903
+ if (kept === 0) {
1904
+ empty.push({
1905
+ code: 'ASSEMBLE_CUT_PIXELS',
1906
+ object,
1907
+ detail: `keeps 0 of its ${before} opaque pixel(s): ${mine.map((i, k) => `cuts[${i}] ("${cuts[i].into}") takes ${taken[k]}`).join(', ')}; a part that keeps at least one is required — to move the whole layer, rename the plan part instead`,
1908
+ });
1909
+ }
1910
+ b.region = own;
1911
+ b.merged = mergedIn(b, own);
1912
+ // Issue #193: the base takes the under image where the cut removed its
1913
+ // pixels, after the counts above were taken from the cut alone. A filled
1914
+ // pixel lies outside the base's region, so it is counted occluded (the
1915
+ // piece is drawn over it) and never projected.
1916
+ const filled = mine.map((i) => {
1917
+ const u = unders[i];
1918
+ if (u === null) return undefined;
1919
+ // Filled where the image is at least as opaque as the layer was; left empty elsewhere (`fillUnder`).
1920
+ const f = fillUnder(b.r, (pieces[i] as Built).r, cutBand(cutMasks[i], cuts[i].overlap), u, underBox(cuts[i].polygon));
1921
+ if (f.filled === 0) {
1922
+ empty.push({
1923
+ code: 'ASSEMBLE_UNDER_EMPTY',
1924
+ object: `config.assemble.cuts[${i}].under (part "${cuts[i].into}")`,
1925
+ detail: `${cuts[i].under} fills 0 of the ${f.empty} pixel(s) the cut removed from "${name}": at every one its alpha is below the alpha removed (an all-transparent image, or another file); an image that fills at least one is required — to leave the whole hole, drop under`,
1926
+ });
1927
+ }
1928
+ return f;
1929
+ });
1930
+ mine.forEach((i, k) => {
1931
+ const c: CutCount = { from: name, into: cuts[i].into, before, kept, taken: taken[k], band: bands[k] };
1932
+ const f = filled[k];
1933
+ if (f !== undefined) {
1934
+ c.under = f.filled;
1935
+ c.underEmpty = f.empty;
1936
+ }
1937
+ cutCounts.push(c);
1938
+ });
1939
+ });
1940
+ refuseIfAny(empty);
1941
+ // In cut order, as the config lists them.
1942
+ cutCounts.sort((a, b) => cuts.findIndex((q) => q.into === a.into) - cuts.findIndex((q) => q.into === b.into));
1943
+ const order = order0;
1944
+ const pieceAt = (d: { plan: number } | { cut: number }): { name: string; b: Built } =>
1945
+ 'plan' in d ? { name: plan[d.plan][0], b: built[d.plan] as Built } : { name: cuts[d.cut].into, b: pieces[d.cut] as Built };
1946
+
1947
+ // Step 4b: push back a fringe the painting shows another part through, over
1948
+ // the plan parts still uncropped and the patches, in draw order.
1949
+ const stack: PlacedPart[] = order.map((d) => {
1950
+ if ('patch' in d) return cut[d.patch] as PlacedPart;
1951
+ const { name, b } = pieceAt(d);
1952
+ const whole: PartRecord = { name, from: b.key, x: 0, y: 0, w: W, h: H, opaque_px: 0, projected_core_px: 0, source_px_taken: 0, refused_drift_px: 0, merged_px: 0, seam_override_px: 0 };
1953
+ return { record: whole, image: b.r };
1954
+ });
1955
+ const pushedBy = pushBackFringe(stack, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
1956
+ const pushedOf: FringePushed[][] = plan.map(() => []);
1957
+ const pushedOfCut: FringePushed[][] = cuts.map(() => []);
1958
+ order.forEach((d, i) => {
1959
+ if ('plan' in d) pushedOf[d.plan] = pushedBy[i];
1960
+ if ('cut' in d) pushedOfCut[d.cut] = pushedBy[i];
1961
+ });
1962
+
1963
+ const planned: Array<PlacedPart | null> = plan.map(() => null);
1964
+ const cutPlaced: Array<PlacedPart | null> = cuts.map(() => null);
1965
+ const pieceList: Array<{ name: string; b: Built; pushed: FringePushed[]; at: string; set: (p: PlacedPart) => void }> = [
1966
+ ...plan.map(([name], pi) => ({ name, b: built[pi] as Built, pushed: pushedOf[pi], at: `config.assemble.plan[${pi}]`, set: (q: PlacedPart) => (planned[pi] = q) })),
1967
+ ...cuts.map((q, i) => ({ name: q.into, b: pieces[i] as Built, pushed: pushedOfCut[i], at: `config.assemble.cuts[${i}]`, set: (pp: PlacedPart) => (cutPlaced[i] = pp) })),
1968
+ ];
1969
+ pieceList.forEach(({ name, b, pushed: pushedHere, at, set }) => {
1970
+ const { key, r, st, extendKeys, trace, merged, region } = b;
1971
+ // A pixel the merge wrote is judged in the extend layer's run, every other
1972
+ // in the part's own. Projected = the colour came from projectSource's
1973
+ // accepted set: the part layer's, or for a copied pixel the extend
1974
+ // layer's (its rig layer was projected before it was copied). A ring pixel
1975
+ // is growRim's, counted in merged_px, and is not projection.
1976
+ const visible = newMask(W, H);
1977
+ const projected = newMask(W, H);
1978
+ for (let p = 0; p < W * H; p++) {
1979
+ const e = trace.from[p];
1980
+ const own = e < 0 ? st : (stats.get(extendKeys[e]) as ProjectionStats);
1981
+ // A pixel outside the piece's region is a base pixel filled from a cut's under image (issue #193): occluded, never visible.
1982
+ if (r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE && own.clear.data[p] === 1 && (region === null || region.data[p] === 1)) visible.data[p] = 1;
1983
+ if (trace.ring.data[p] === 0 && own.takenMask.data[p] === 1 && (region === null || region.data[p] === 1)) projected.data[p] = 1;
1984
+ }
1985
+ const { x0, y0, x1, y1, opaque } = measureBox(r);
1986
+ if (opaque === 0) {
1987
+ const pushed = pushedHere.reduce((a, q) => a + q.px, 0);
1988
+ empty.push({
1989
+ code: 'ASSEMBLE_PART_OPAQUE',
1990
+ object: `part "${name}" (${key}, ${at})`,
1991
+ detail: `has 0 pixels with alpha above ${OPAQUE_ALPHA_ABOVE} in the rig once its fringe is pushed back: every one of its pixels was below alpha ${CORE_ALPHA} where the painting shows another part (${pushed} px pushed back, held by ${pushedHere.map((q) => `"${q.part}" ${q.px} px`).join(', ')}); a part with at least one opaque pixel is required — drop it from the plan or take the tag from the other run`,
1992
+ });
1993
+ return;
1994
+ }
1995
+ const vc = visibilityCounts(`part "${name}" (${key})`, r, visible, projected);
1996
+ const record: PartRecord = {
1997
+ name,
1998
+ from: key,
1999
+ x: x0,
2000
+ y: y0,
2001
+ w: x1 - x0 + 1,
2002
+ h: y1 - y0 + 1,
2003
+ opaque_px: opaque,
2004
+ visible_px: vc.visible_px,
2005
+ occluded_px: vc.occluded_px,
2006
+ projected_core_px: region === null ? st.core : count(st.coreMask, region),
2007
+ source_px_taken: region === null ? st.taken : count(st.takenMask, region),
2008
+ visible_not_projected_px: vc.visible_not_projected_px,
2009
+ refused_drift_px: region === null ? st.refused : count(st.refusedMask, region),
2010
+ merged_px: merged,
2011
+ seam_override_px: 0,
2012
+ };
2013
+ if (pushedHere.length > 0) record.fringe_pushed_back = pushedHere;
2014
+ set({ record, image: crop(r, x0, y0, record.w, record.h) });
2015
+ });
2016
+ refuseIfAny(empty);
2017
+ const placed: PlacedPart[] = order.map((d) => ('plan' in d ? planned[d.plan] : 'cut' in d ? cutPlaced[d.cut] : cut[d.patch]) as PlacedPart);
2018
+
2019
+ seamOverride(placed, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
2020
+ const can = recomposite(placed, W, H);
2021
+ const figures = measureRecomposite(can, srcr, placed);
2022
+ return {
2023
+ parts: { rig_size: [W, H], scale_rig_per_source: frame.S, parts: placed.map((p) => p.record), ghost_px: ghost, recomposite: recompositeRecord(figures) },
2024
+ images: placed,
2025
+ recomposite: can,
2026
+ errorMap: recompositeErrorMap(can, srcr, placed),
2027
+ figures,
2028
+ cuts: cutCounts,
2029
+ seamRule,
2030
+ projectRule,
2031
+ };
2032
+ }
2033
+
2034
+ // ---------------------------------------------------------------------------
2035
+ // --propose-plan
2036
+ // ---------------------------------------------------------------------------
2037
+
2038
+ /**
2039
+ * The tags the reference proposes from the HEAD run. Its own set, kept
2040
+ * verbatim — it is not `src/tags.ts`'s head group: it adds the hair and the
2041
+ * neck, and it holds only the split forms of the eye, brow and ear tags, and
2042
+ * neither `eyewear` nor `nose`. So an unsplit `eyebrow`, or `eyewear`, is
2043
+ * proposed from the full run.
2044
+ */
2045
+ export const PROPOSE_HEAD_TAGS: readonly string[] = [
2046
+ 'back hair',
2047
+ 'front hair',
2048
+ 'face',
2049
+ 'ears-r',
2050
+ 'ears-l',
2051
+ 'headwear',
2052
+ 'earwear',
2053
+ 'mouth',
2054
+ 'neck',
2055
+ 'eyewhite-r',
2056
+ 'eyewhite-l',
2057
+ 'irides-r',
2058
+ 'irides-l',
2059
+ 'eyelash-r',
2060
+ 'eyelash-l',
2061
+ 'eyebrow-r',
2062
+ 'eyebrow-l',
2063
+ ];
2064
+
2065
+ /** The part name the reference proposes for a tag; any other tag becomes its own name with spaces and hyphens as underscores. */
2066
+ export const PROPOSE_ALIAS: Readonly<Record<string, string>> = {
2067
+ 'back hair': 'hair_back',
2068
+ 'front hair': 'hair_front',
2069
+ footwear: 'shoes',
2070
+ 'irides-r': 'iris_r',
2071
+ 'irides-l': 'iris_l',
2072
+ 'eyelash-r': 'lash_r',
2073
+ 'eyelash-l': 'lash_l',
2074
+ 'eyebrow-r': 'brow_r',
2075
+ 'eyebrow-l': 'brow_l',
2076
+ 'ears-r': 'ear_r',
2077
+ 'ears-l': 'ear_l',
2078
+ headwear: 'hairpin',
2079
+ earwear: 'earring',
2080
+ };
2081
+
2082
+ function aliasOf(tag: string): string {
2083
+ return PROPOSE_ALIAS[tag] ?? tag.replaceAll(' ', '_').replaceAll('-', '_');
2084
+ }
2085
+
2086
+ export interface PlanProposal {
2087
+ plan: PlanEntry[];
2088
+ extend_below_crop: Extend[];
2089
+ notes: string[];
2090
+ }
2091
+
2092
+ /** Python's `%.0f`: round half to even on the binary value. */
2093
+ function fmt0(v: number): string {
2094
+ const f = Math.floor(v);
2095
+ const d = v - f;
2096
+ const r = d > 0.5 ? f + 1 : d < 0.5 ? f : f % 2 === 0 ? f : f + 1;
2097
+ return String(r);
2098
+ }
2099
+
2100
+ /**
2101
+ * A default `assemble.plan` and `extend_below_crop` from the two runs, with
2102
+ * notes on every choice that was not the default.
2103
+ *
2104
+ * ⚖️ Invariant (`assemble_parts.py` `propose_plan`): a tag is proposable when
2105
+ * it is not `nose` and holds at least `PROPOSE_MIN_PX` run pixels of alpha
2106
+ * above 8 after ghost clean-up. Head tags (`PROPOSE_HEAD_TAGS`) come from the
2107
+ * head run, the rest from the full run. Order, back to front: `back hair`;
2108
+ * the body layers in the full run's draw order, with `neck` placed just before
2109
+ * the first of topwear, bottomwear or neckwear; then the remaining head layers
2110
+ * in the head run's draw order. A `headwear`/`earwear` the head run drops or
2111
+ * shrinks (its rig-pixel area below 0.8 of the full run's) is taken from the
2112
+ * full run instead, drawn right after `back hair` — noted. `front hair` and
2113
+ * `back hair` taken from the head run are extended below the crop from the
2114
+ * full run when the full run's layer reaches more than 8 rig pixels below the
2115
+ * crop line. A lone `handwear` layer is named `sleeves`.
2116
+ *
2117
+ * ⚖️ This port's, not the reference's: a layer that crosses a plausibility
2118
+ * rule (`implausibleRules` in `src/layers.ts` — mostly translucent, a pale
2119
+ * background-coloured haze, or out of proportion with the rest of its run's
2120
+ * figure) is neither proposed, nor taken as the head-run fallback, nor extended
2121
+ * below the crop, and a note names the layer, its three figures and every rule
2122
+ * it crossed with the bar. Those notes follow the reference's. [observed] No
2123
+ * layer of either public example crosses a rule, so their proposals are the
2124
+ * reference's byte for byte.
2125
+ */
2126
+ export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
2127
+ const frame = checkGeometry(g, { full, head });
2128
+ const runs = { full: runLayers(full, g.resolution), head: runLayers(head, g.resolution) };
2129
+ const find = (run: Run, tag: string): RunLayer | undefined => runs[run].find((l) => l.tag === tag);
2130
+ const figures: Record<Run, LayerFigures[]> = { full: layerFigures(full), head: layerFigures(head) };
2131
+ const dropNotes: string[] = [];
2132
+ const noted = new Set<string>();
2133
+ /** The plausibility rules `run:tag` crosses; the first time a consulted layer crosses any, a note says so. */
2134
+ const implausible = (run: Run, tag: string, what: string): boolean => {
2135
+ const f = figures[run].find((x) => x.name === tag);
2136
+ const rules = f === undefined ? [] : implausibleRules(f);
2137
+ if (f === undefined || rules.length === 0) return false;
2138
+ if (!noted.has(`${run}:${tag}`)) {
2139
+ noted.add(`${run}:${tag}`);
2140
+ dropNotes.push(`${tag}: ${run} run layer ${figuresPhrase(f)} -> ${what} by ${rules.map((r) => `${r} (${ruleSummary(r)})`).join(', ')}`);
2141
+ }
2142
+ return true;
2143
+ };
2144
+ const ok = (run: Run, tag: string): boolean => {
2145
+ const l = find(run, tag);
2146
+ return tag !== 'nose' && l !== undefined && l.opaqueRunPx >= minPx && !implausible(run, tag, 'not proposed');
2147
+ };
2148
+ const body = runs.full.filter((l) => !PROPOSE_HEAD_TAGS.includes(l.tag) && ok('full', l.tag)).map((l) => l.tag);
2149
+ const heads = runs.head.filter((l) => PROPOSE_HEAD_TAGS.includes(l.tag) && ok('head', l.tag)).map((l) => l.tag);
2150
+ const hw = body.filter((t) => t.startsWith('handwear'));
2151
+ const plan: PlanEntry[] = [];
2152
+ const drop = (t: string): void => {
2153
+ const i = heads.indexOf(t);
2154
+ if (i >= 0) heads.splice(i, 1);
2155
+ };
2156
+ if (heads.includes('back hair')) {
2157
+ plan.push([PROPOSE_ALIAS['back hair'], 'head', 'back hair']);
2158
+ drop('back hair');
2159
+ }
2160
+ let neckDone = !heads.includes('neck');
2161
+ for (const t of body) {
2162
+ if (!neckDone && (t === 'topwear' || t === 'bottomwear' || t === 'neckwear')) {
2163
+ plan.push(['neck', 'head', 'neck']);
2164
+ drop('neck');
2165
+ neckDone = true;
2166
+ }
2167
+ plan.push([t.startsWith('handwear') && hw.length === 1 ? 'sleeves' : aliasOf(t), 'full', t]);
2168
+ }
2169
+ const notes: string[] = [];
2170
+ const headArea = (frame.headK * frame.S) ** 2;
2171
+ const fullArea = (frame.fullK * frame.S) ** 2;
2172
+ for (const t of ['headwear', 'earwear']) {
2173
+ const hl = find('head', t);
2174
+ const fl = find('full', t);
2175
+ const hp = hl !== undefined ? hl.opaqueRunPx * headArea : 0;
2176
+ const fp = fl !== undefined ? fl.opaqueRunPx * fullArea : 0;
2177
+ if (ok('full', t) && hp < 0.8 * fp) {
2178
+ drop(t);
2179
+ const at = plan.length > 0 && plan[0][2] === 'back hair' ? 1 : 0;
2180
+ plan.splice(at, 0, [PROPOSE_ALIAS[t], 'full', t]);
2181
+ notes.push(`${t}: head run ${fmt0(hp)} rig px < 0.8 x full run ${fmt0(fp)} -> full-run layer, drawn behind the face`);
2182
+ }
2183
+ }
2184
+ for (const t of heads) plan.push([aliasOf(t), 'head', t]);
2185
+ const extend: Extend[] = [];
2186
+ for (const t of ['front hair', 'back hair']) {
2187
+ const fl = find('full', t);
2188
+ if (fl === undefined || !plan.some((p) => p[1] === 'head' && p[2] === t)) continue;
2189
+ if (implausible('full', t, 'not extended below the crop')) continue;
2190
+ let last = -1;
2191
+ const n = g.resolution;
2192
+ for (let y = 0; y < n; y++) {
2193
+ for (let x = 0; x < n; x++) {
2194
+ if (fl.canvas.data[(y * n + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE) {
2195
+ last = y;
2196
+ break;
2197
+ }
2198
+ }
2199
+ }
2200
+ // The run row maps to the rig as the warp maps it: k * row, less the top pad in rig px.
2201
+ if (last >= 0 && (last + 1) * frame.fullK * frame.S - frame.fullPadTop * frame.S > frame.headBottom + 8) extend.push({ part: PROPOSE_ALIAS[t], run: 'full', tag: t });
2202
+ }
2203
+ return { plan, extend_below_crop: extend, notes: [...notes, ...dropNotes] };
2204
+ }
2205
+
2206
+ // ---------------------------------------------------------------------------
2207
+ // what the stage reads from a config
2208
+ // ---------------------------------------------------------------------------
2209
+
2210
+ /**
2211
+ * The config fields the stage reads: `seethrough.resolution` and `.head_box`,
2212
+ * `assemble.rig_scale`, `.plan`, `.extend_below_crop`, `.patches` and `.cuts` — and
2213
+ * nothing of the rig. The CLI hands it a config from
2214
+ * `parseEarlyConfig(raw, 'assemble')`; a config the full loader accepted is
2215
+ * the same shape with more in it. The head box is refused here rather than by
2216
+ * the loader, because `inputs` runs before it exists; `seethrough` too, for a
2217
+ * full-loader config, where it is optional.
2218
+ */
2219
+ export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble'>): {
2220
+ resolution: number;
2221
+ headBox: [number, number, number, number];
2222
+ rigScale: number;
2223
+ plan: PlanEntry[];
2224
+ extend: Extend[];
2225
+ patches: Patch[];
2226
+ cuts: Cut[];
2227
+ } {
2228
+ const problems: Problem[] = [];
2229
+ if (cfg.seethrough === undefined) {
2230
+ problems.push({ code: 'ASSEMBLE_FIELD_PRESENT', object: 'config.seethrough', detail: 'is absent; assemble reads seethrough.resolution and seethrough.head_box, which place the two runs on the painting' });
2231
+ } else if (cfg.seethrough.head_box === undefined) {
2232
+ problems.push({ code: 'ASSEMBLE_FIELD_PRESENT', object: 'config.seethrough.head_box', detail: 'is absent; the head run cannot be placed on the painting without the box it was cropped from' });
2233
+ }
2234
+ refuseIfAny(problems);
2235
+ const st = cfg.seethrough as SeeThrough;
2236
+ return {
2237
+ resolution: st.resolution,
2238
+ headBox: st.head_box as [number, number, number, number],
2239
+ rigScale: cfg.assemble.rig_scale,
2240
+ plan: cfg.assemble.plan,
2241
+ extend: cfg.assemble.extend_below_crop ?? [],
2242
+ patches: cfg.assemble.patches ?? [],
2243
+ cuts: cfg.assemble.cuts ?? [],
2244
+ };
2245
+ }
2246
+
2247
+ /**
2248
+ * The three numbers `--propose-plan` reads, from a config that does not have a
2249
+ * plan yet. The config comes through `parseEarlyConfig` (`src/config.ts`), the
2250
+ * one partial entry point, which has already checked the fields with the full
2251
+ * loader's rules; what is left here is that this stage needs the head box,
2252
+ * which that loader leaves optional.
2253
+ */
2254
+ export function proposeFields(cfg: EarlyConfig): { resolution: number; headBox: [number, number, number, number]; rigScale: number } {
2255
+ if (cfg.seethrough.head_box === undefined) {
2256
+ refuseIfAny([{ code: 'ASSEMBLE_FIELD_PRESENT', object: 'config.seethrough.head_box', detail: 'is absent; the plan is proposed from both runs, and the head run cannot be placed on the painting without the box it was cropped from — run `propose --head-box` first' }]);
2257
+ }
2258
+ return { resolution: cfg.seethrough.resolution, headBox: cfg.seethrough.head_box as [number, number, number, number], rigScale: cfg.assemble.rig_scale };
2259
+ }