rig-parts 2.4.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,543 @@
1
+ import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
2
+ import { connectedComponents } from './raster/components.ts';
3
+ import { type Mask, newMask, type Raster, RasterError } from './raster/types.ts';
4
+
5
+ /**
6
+ * Issue #953, under `projectRule: 'visible'` only: the parts' visible edges are
7
+ * resolved from the painting, at the rig's own density, and See-through keeps
8
+ * what lies under another part (its hidden surface) and the partition into
9
+ * parts. Not in the reference.
10
+ *
11
+ * Why: a See-through run is capped at its resolution (1024 on the public
12
+ * examples, over the padded square of the painting), so every run layer is
13
+ * enlarged into the rig and its alpha edge is a soft ramp the painting does not
14
+ * have; where two parts meet along a painted line each layer repaints the line
15
+ * on its own side of its own ramp, which reads as a doubled line, a stair-step
16
+ * and, where neither layer is opaque, the background through a gap. Projection
17
+ * (`projectSource`) recolours only a layer's opaque area, so it cannot reach
18
+ * either.
19
+ *
20
+ * The step reads the final stack (every part on the full rig canvas, in draw
21
+ * order) and the painting resampled into the rig, and writes alpha and colour
22
+ * at the pixels near a visible outline. Everything else is unchanged.
23
+ *
24
+ * ⚖️ Invariant. Let OWNER(p) be the top-most part with alpha above
25
+ * {@link EDGE_OWNER_ALPHA} at p, or the background (-1): See-through's own
26
+ * half-coverage line, which says which parts meet and roughly where. A pixel
27
+ * is an EDGE pixel when another owner lies within {@link EDGE_RADIUS}
28
+ * (Chebyshev) — the nearest one, ties to the part drawn in front. Of the pair
29
+ * (p's owner, that nearest other), FRONT is the one drawn in front and BEHIND
30
+ * the other (the background is behind every part). Inside the
31
+ * `(2 * EDGE_RADIUS + 1)²` window around p:
32
+ *
33
+ * - the behind colour `Cb` is the per-channel median of the painting over the
34
+ * window's BEHIND-owned pixels farthest from FRONT (the window pixels least
35
+ * likely to be the edge), so a painted line between the two never sets it;
36
+ * - the front reference `Cf` is the painting at the window's FRONT-owned pixel
37
+ * farthest from `Cb` (max channel, the distance every other assemble limit
38
+ * is in; first in scan order on a tie) — a painted line along FRONT's
39
+ * outline counts as FRONT, which gives the line to the part drawn in front,
40
+ * the part whose outline it is in the rig;
41
+ * - FRONT's coverage at p is P's position along `Cb -> Cf` (integer dot
42
+ * products), normalised by a trimap: 0 at the highest position among the
43
+ * BEHIND-side pixels above, 1 at the lowest among FRONT's window pixels
44
+ * farthest from BEHIND. Where those two sides do not separate along the axis
45
+ * (a side is all edge, or the textures overlap) — counted as `unseparated` —
46
+ * the ends are `Cb` and `Cf` themselves. On a mix of the two colours it is
47
+ * the mixing weight, and a texture of BEHIND across that axis reads as 0
48
+ * rather than as FRONT (measured: a distance-only weight left a 10–27 % halo
49
+ * of the face over the hair at 3–4 rig px from the face's edge on
50
+ * reference-sample);
51
+ * - on FRONT's side of See-through's line, a pixel whose colour FRONT itself
52
+ * holds deeper in (within half of {@link EDGE_CONTRAST}, and nearer that
53
+ * than any BEHIND-side pixel) is FRONT's interior — a lining, a highlight —
54
+ * and reads 1, however it falls along the axis.
55
+ *
56
+ * A painted pixel no part covers above the owner floor — the gap between two
57
+ * soft rims — is owned by the nearest part (within `2 * EDGE_RADIUS + 2`), so
58
+ * the gap is never read as the background's colour.
59
+ *
60
+ * The ring rule. Rings are counted in Chebyshev distance from the other owner.
61
+ * FRONT's coverage never rises going away from FRONT and never falls going
62
+ * into it: on the inner ring (FRONT-owned, next to the line) it is at least
63
+ * that of its resolved outer-ring neighbours (a painted line just outside
64
+ * See-through's line is FRONT's outline, so what lies between is FRONT's too);
65
+ * deeper inside it is the greater of See-through's own alpha and the
66
+ * painting's (the ramp past the half line is filled; a light interior is never
67
+ * holed); farther out it is no more than its neighbour one ring nearer.
68
+ * Resolved ring by ring, outer before inner.
69
+ *
70
+ * FRONT's alpha becomes that coverage (as 0..255, rounded; within
71
+ * {@link OPAQUE_ALPHA_ABOVE} of 0 or 255 it is snapped there), and its colour
72
+ * the painting unmixed from `Cb` (`Cb + (P - Cb) / a`, clamped); at `a = 1` that
73
+ * is the painting's pixel. Where that colour clamps so the composite would
74
+ * miss the painting by more than half of {@link EDGE_CONTRAST} (a painted line
75
+ * darker or lighter than both colours), FRONT's alpha rises to the least that
76
+ * brings it within. Where FRONT does not cover p fully and BEHIND is a
77
+ * part, BEHIND is made opaque there in `Cb` (in the painting's own colour
78
+ * where FRONT's alpha is 0), so the flat composite at p is the painting, the
79
+ * rim stays FRONT's, and BEHIND's hidden surface past the window is untouched.
80
+ *
81
+ * A pixel is left as it was — and counted, by reason — when a part drawn in
82
+ * front of FRONT, or between BEHIND and FRONT, has alpha above
83
+ * {@link OPAQUE_ALPHA_ABOVE} there (its coverage is not modelled: a
84
+ * translucent part is never erased; at or below that floor a part is not
85
+ * opaque anywhere in this package, and it is left as it was); when
86
+ * `|Cf - Cb|` (max channel) is below {@link EDGE_CONTRAST} (the painting draws
87
+ * no edge between the two: a pair under the recomposite's error limit cannot
88
+ * hold an error pixel); when
89
+ * either part may not be edited (`editable`: a painting patch is the painting
90
+ * already); when either part keeps the pixel (`keep`, issue #637), feathers it
91
+ * (`feathered`, a limb's authored soft edge) or the two are a turn's moving
92
+ * pair; or when the window holds no BEHIND-owned pixel.
93
+ *
94
+ * A part's art (alpha above {@link OPAQUE_ALPHA_ABOVE}) keeps its pieces,
95
+ * read 4-connected as `CONTOUR_ONE_ISLAND` reads them: where the step would
96
+ * split a piece, each cut-off part is joined back to the piece's largest part
97
+ * along the path that restores the fewest lowered pixels, and those are not
98
+ * written; a new piece
99
+ * holding none of the part's art is not written (`pieces`, counted per part
100
+ * and pixel). Where that leaves out BEHIND's fill under FRONT, FRONT takes the
101
+ * painting's pixel at full alpha instead, under the same rule.
102
+ *
103
+ * Every decision is read off the stack as it stood before the step, then
104
+ * applied, so the result does not depend on the order pixels are visited.
105
+ * Deterministic: integer rig arithmetic, medians by sort, no float reduction
106
+ * whose order varies.
107
+ */
108
+ export const EDGE_RADIUS = 3;
109
+ /** The owner's alpha floor: above it a part covers a pixel. Equal to `COVERED_ALPHA` in `src/assemble.ts` (held by the selftest). */
110
+ export const EDGE_OWNER_ALPHA = 128;
111
+ /** The least front contrast resolved, max channel. Equal to `ERROR_LIMIT` in `src/assemble.ts` (held by the selftest). */
112
+ export const EDGE_CONTRAST = 40;
113
+
114
+ /** Why an edge pixel was left as it was; see {@link paintingEdges}. */
115
+ export interface PaintingEdgeSkips {
116
+ cover: number;
117
+ contrast: number;
118
+ locked: number;
119
+ noBehind: number;
120
+ /** Part writes left out so a part's art keeps its 4-connected pieces (counted per part and pixel, not per edge pixel). */
121
+ pieces: number;
122
+ }
123
+
124
+ export interface PaintingEdgeResult {
125
+ /** Per part (stack index), the pixels whose RGBA the step changed. */
126
+ changed: Mask[];
127
+ /** Edge pixels resolved (whether or not a value changed), and the ones left. */
128
+ resolved: number;
129
+ /** Of `resolved`, the pixels whose two far sides did not separate along the axis, read against `Cb` and `Cf` themselves. */
130
+ unseparated: number;
131
+ skipped: PaintingEdgeSkips;
132
+ }
133
+
134
+ export interface PaintingEdgeOptions {
135
+ /**
136
+ * Per rig pixel, 1 where the painting shows figure (`paintedFigure` in
137
+ * `src/assemble.ts`). A figure pixel no part covers above
138
+ * {@link EDGE_OWNER_ALPHA} — a gap between two parts' soft rims — is owned by
139
+ * the nearest part within the cap, not the background: the painting holds
140
+ * figure there, and taking the behind colour from it would take a painted line.
141
+ */
142
+ painted: Uint8Array;
143
+ /** Per part: whether the step may write it (false for a painting patch). */
144
+ editable: readonly boolean[];
145
+ keep?: ReadonlyArray<Mask | null>;
146
+ feathered?: ReadonlyArray<Mask | null>;
147
+ /** Two parts a turn moves against each other; their shared edge is left alone. */
148
+ movingPair?: ((a: number, b: number) => boolean) | null;
149
+ /** Called once per part and pixel whose RGBA changed. */
150
+ written?: (part: number, pixel: number) => void;
151
+ }
152
+
153
+ /** Chebyshev distance to the nearest set pixel, capped at `cap` (two-pass, exact for the 8-neighbour metric). */
154
+ function chebyshev(set: Uint8Array, W: number, H: number, cap: number): Uint8Array {
155
+ const d = new Uint8Array(W * H).fill(cap);
156
+ for (let p = 0; p < W * H; p++) if (set[p] === 1) d[p] = 0;
157
+ for (let y = 0; y < H; y++) {
158
+ for (let x = 0; x < W; x++) {
159
+ const p = y * W + x;
160
+ let v = d[p];
161
+ if (v === 0) continue;
162
+ if (x > 0) v = Math.min(v, d[p - 1] + 1);
163
+ if (y > 0) {
164
+ v = Math.min(v, d[p - W] + 1);
165
+ if (x > 0) v = Math.min(v, d[p - W - 1] + 1);
166
+ if (x < W - 1) v = Math.min(v, d[p - W + 1] + 1);
167
+ }
168
+ d[p] = Math.min(v, cap);
169
+ }
170
+ }
171
+ for (let y = H - 1; y >= 0; y--) {
172
+ for (let x = W - 1; x >= 0; x--) {
173
+ const p = y * W + x;
174
+ let v = d[p];
175
+ if (v === 0) continue;
176
+ if (x < W - 1) v = Math.min(v, d[p + 1] + 1);
177
+ if (y < H - 1) {
178
+ v = Math.min(v, d[p + W] + 1);
179
+ if (x < W - 1) v = Math.min(v, d[p + W + 1] + 1);
180
+ if (x > 0) v = Math.min(v, d[p + W - 1] + 1);
181
+ }
182
+ d[p] = Math.min(v, cap);
183
+ }
184
+ }
185
+ return d;
186
+ }
187
+
188
+ function maxChannel(a: Uint8ClampedArray, i: number, c: readonly number[]): number {
189
+ return Math.max(Math.abs(a[i] - c[0]), Math.abs(a[i + 1] - c[1]), Math.abs(a[i + 2] - c[2]));
190
+ }
191
+
192
+ /** Lower median of a list of integers (sorted copy; deterministic). */
193
+ function median(v: number[]): number {
194
+ const s = v.slice().sort((a, b) => a - b);
195
+ return s[(s.length - 1) >> 1];
196
+ }
197
+
198
+ interface Decision {
199
+ p: number;
200
+ front: number;
201
+ behind: number;
202
+ /** FRONT's coverage read off the painting (0..255, before the ring rule), and FRONT's own alpha there before the step. */
203
+ matte: number;
204
+ own: number;
205
+ /** Which side of See-through's half-coverage line p is on (FRONT-owned or not), and its distance to the other owner. */
206
+ inside: boolean;
207
+ ring: number;
208
+ cb: [number, number, number];
209
+ }
210
+
211
+ /** See the invariant above {@link EDGE_RADIUS}. Modifies `parts` (full rig canvases, draw order) in place. */
212
+ export function paintingEdges(parts: Raster[], srcr: Raster, options: PaintingEdgeOptions): PaintingEdgeResult {
213
+ const W = srcr.width;
214
+ const H = srcr.height;
215
+ const n = parts.length;
216
+ // Every part is read as the whole rig canvas; a cropped image would be read at the wrong place.
217
+ parts.forEach((r, i) => {
218
+ if (r.width !== W || r.height !== H) throw new RasterError(`paintingEdges: part ${i} is ${r.width}x${r.height}; the rig canvas ${W}x${H} is required (place a cropped part on it first)`);
219
+ });
220
+ const R = EDGE_RADIUS;
221
+ const cap = 2 * R + 2;
222
+ const owner = new Int16Array(W * H).fill(-1);
223
+ for (let p = 0; p < W * H; p++) {
224
+ for (let i = n - 1; i >= 0; i--) {
225
+ if (parts[i].data[p * 4 + 3] > EDGE_OWNER_ALPHA) {
226
+ owner[p] = i;
227
+ break;
228
+ }
229
+ }
230
+ }
231
+ // A painted pixel no part covers goes to the nearest part (ties to the one drawn in front); farther than the cap, it stays background.
232
+ {
233
+ const near: Uint8Array[] = [];
234
+ for (let k = 0; k < n; k++) {
235
+ const set = new Uint8Array(W * H);
236
+ for (let p = 0; p < W * H; p++) if (owner[p] === k) set[p] = 1;
237
+ near.push(chebyshev(set, W, H, cap));
238
+ }
239
+ const fill: Array<[number, number]> = [];
240
+ for (let p = 0; p < W * H; p++) {
241
+ if (owner[p] !== -1 || options.painted[p] !== 1) continue;
242
+ let best = -1;
243
+ let bd = cap;
244
+ for (let k = n - 1; k >= 0; k--) if (near[k][p] < bd) { best = k; bd = near[k][p]; }
245
+ if (best >= 0) fill.push([p, best]);
246
+ }
247
+ for (const [p, k] of fill) owner[p] = k;
248
+ }
249
+ // Distance to each owner, the background at index n.
250
+ const dist: Uint8Array[] = [];
251
+ for (let k = 0; k <= n; k++) {
252
+ const set = new Uint8Array(W * H);
253
+ const label = k === n ? -1 : k;
254
+ let any = false;
255
+ for (let p = 0; p < W * H; p++) {
256
+ if (owner[p] === label) {
257
+ set[p] = 1;
258
+ any = true;
259
+ }
260
+ }
261
+ dist.push(any ? chebyshev(set, W, H, cap) : new Uint8Array(W * H).fill(cap));
262
+ }
263
+ const distOf = (label: number): Uint8Array => dist[label < 0 ? n : label];
264
+ const skipped: PaintingEdgeSkips = { cover: 0, contrast: 0, locked: 0, noBehind: 0, pieces: 0 };
265
+ const decisions: Decision[] = [];
266
+ let resolved = 0;
267
+ let unseparated = 0;
268
+ for (let y = 0; y < H; y++) {
269
+ for (let x = 0; x < W; x++) {
270
+ const p = y * W + x;
271
+ const a = owner[p];
272
+ // The nearest other owner within R; ties to the one drawn in front (the background is behind every part).
273
+ let b = -2;
274
+ let bd = R + 1;
275
+ for (let k = n; k >= 0; k--) {
276
+ const label = k === n ? -1 : k;
277
+ if (label === a) continue;
278
+ const d = dist[k][p];
279
+ if (d < bd || (d === bd && b !== -2 && label > b)) {
280
+ if (d <= R) {
281
+ b = label;
282
+ bd = d;
283
+ }
284
+ }
285
+ }
286
+ if (b === -2) continue;
287
+ const front = Math.max(a, b);
288
+ const behind = Math.min(a, b);
289
+ // Coverage the two-layer model cannot carry: anything in front of FRONT, or between BEHIND and FRONT.
290
+ let covered = false;
291
+ for (let j = behind + 1; j < n && !covered; j++) if (j !== front && parts[j].data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) covered = true;
292
+ if (covered) {
293
+ skipped.cover++;
294
+ continue;
295
+ }
296
+ if (
297
+ !options.editable[front] ||
298
+ (behind >= 0 && !options.editable[behind]) ||
299
+ options.keep?.[front]?.data[p] === 1 ||
300
+ (behind >= 0 && options.keep?.[behind]?.data[p] === 1) ||
301
+ options.feathered?.[front]?.data[p] === 1 ||
302
+ (behind >= 0 && options.feathered?.[behind]?.data[p] === 1) ||
303
+ (behind >= 0 && options.movingPair?.(front, behind) === true)
304
+ ) {
305
+ skipped.locked++;
306
+ continue;
307
+ }
308
+ const df = distOf(front);
309
+ const db = distOf(behind);
310
+ // BEHIND's and FRONT's window pixels farthest from the other: the two sides least likely to be the edge itself.
311
+ let farB = -1;
312
+ let farF = -1;
313
+ const sideB: number[] = [];
314
+ const sideF: number[] = [];
315
+ for (let qy = Math.max(0, y - R); qy <= Math.min(H - 1, y + R); qy++) {
316
+ for (let qx = Math.max(0, x - R); qx <= Math.min(W - 1, x + R); qx++) {
317
+ const q = qy * W + qx;
318
+ if (owner[q] === behind) {
319
+ if (df[q] > farB) {
320
+ farB = df[q];
321
+ sideB.length = 0;
322
+ }
323
+ if (df[q] === farB) sideB.push(q);
324
+ } else if (owner[q] === front) {
325
+ if (db[q] > farF) {
326
+ farF = db[q];
327
+ sideF.length = 0;
328
+ }
329
+ if (db[q] === farF) sideF.push(q);
330
+ }
331
+ }
332
+ }
333
+ if (sideB.length === 0) {
334
+ skipped.noBehind++;
335
+ continue;
336
+ }
337
+ const channel = (c: number): number[] => sideB.map((q) => srcr.data[q * 4 + c]);
338
+ const cb = [median(channel(0)), median(channel(1)), median(channel(2))];
339
+ // The front reference: the window's FRONT-owned pixel farthest from Cb (max channel), first in scan order on a tie.
340
+ let contrast = 0;
341
+ let ref = -1;
342
+ for (let qy = Math.max(0, y - R); qy <= Math.min(H - 1, y + R); qy++) {
343
+ for (let qx = Math.max(0, x - R); qx <= Math.min(W - 1, x + R); qx++) {
344
+ const q = qy * W + qx;
345
+ if (owner[q] !== front) continue;
346
+ const c = maxChannel(srcr.data, q * 4, cb);
347
+ if (c > contrast) {
348
+ contrast = c;
349
+ ref = q;
350
+ }
351
+ }
352
+ }
353
+ // Every pixel's position along Cb -> Cf, as an integer dot product.
354
+ const axis = ref < 0 ? [0, 0, 0] : [0, 1, 2].map((c) => srcr.data[ref * 4 + c] - cb[c]);
355
+ const along = (q: number): number => (srcr.data[q * 4] - cb[0]) * axis[0] + (srcr.data[q * 4 + 1] - cb[1]) * axis[1] + (srcr.data[q * 4 + 2] - cb[2]) * axis[2];
356
+ if (ref < 0 || contrast < EDGE_CONTRAST) {
357
+ skipped.contrast++;
358
+ continue;
359
+ }
360
+ // The trimap: BEHIND's far side reads 0 up to its highest position, FRONT's far side 1 from its lowest.
361
+ // Where the two far sides do not separate along the axis (a side is all edge, or the texture overlaps), Cb and Cf themselves.
362
+ const sideLo = Math.max(...sideB.map(along));
363
+ const sideHi = sideF.length === 0 ? -Infinity : Math.min(...sideF.map(along));
364
+ const separate = sideHi > sideLo;
365
+ if (!separate) unseparated++;
366
+ const lo = separate ? sideLo : 0;
367
+ const hi = separate ? sideHi : along(ref);
368
+ resolved++;
369
+ let matte = Math.round((255 * Math.min(hi - lo, Math.max(0, along(p) - lo))) / (hi - lo));
370
+ // On FRONT's side of the line, a colour FRONT itself holds deeper in (within half the contrast floor, and nearer FRONT than
371
+ // BEHIND) is FRONT's interior — a light lining, a highlight — not a mix with BEHIND, however it falls along the axis.
372
+ if (a === front && matte < 255) {
373
+ let dF = Infinity;
374
+ let dFdeep = Infinity;
375
+ for (let qy = Math.max(0, y - R); qy <= Math.min(H - 1, y + R); qy++) {
376
+ for (let qx = Math.max(0, x - R); qx <= Math.min(W - 1, x + R); qx++) {
377
+ const q = qy * W + qx;
378
+ if (q === p || owner[q] !== front) continue;
379
+ const d = Math.max(Math.abs(srcr.data[q * 4] - srcr.data[p * 4]), Math.abs(srcr.data[q * 4 + 1] - srcr.data[p * 4 + 1]), Math.abs(srcr.data[q * 4 + 2] - srcr.data[p * 4 + 2]));
380
+ dF = Math.min(dF, d);
381
+ if (db[q] >= 2) dFdeep = Math.min(dFdeep, d);
382
+ }
383
+ }
384
+ const dOwn = dFdeep === Infinity ? dF : dFdeep;
385
+ let dB = Infinity;
386
+ for (const q of sideB) dB = Math.min(dB, Math.max(Math.abs(srcr.data[q * 4] - srcr.data[p * 4]), Math.abs(srcr.data[q * 4 + 1] - srcr.data[p * 4 + 1]), Math.abs(srcr.data[q * 4 + 2] - srcr.data[p * 4 + 2])));
387
+ if (2 * dOwn <= EDGE_CONTRAST && dOwn < dB) matte = 255;
388
+ }
389
+ const own = parts[front].data[p * 4 + 3];
390
+ decisions.push({ p, front, behind, matte, own, inside: a === front, ring: bd, cb: [cb[0], cb[1], cb[2]] });
391
+ }
392
+ }
393
+ // The ring rule (see the invariant above), resolved ring by ring, the outer ring before the inner one.
394
+ const at = new Int32Array(W * H).fill(-1);
395
+ decisions.forEach((q, k) => { at[q.p] = k; });
396
+ const alphaOf = new Int16Array(decisions.length);
397
+ const stage = (q: Decision): number => (q.inside ? 2 * q.ring : 2 * q.ring - 1);
398
+ const order = decisions.map((_, k) => k).sort((u, v) => stage(decisions[u]) - stage(decisions[v]) || u - v);
399
+ /** The greatest coverage already resolved for the same FRONT among p's 8 neighbours on the given side and ring. */
400
+ const neighbours = (q: Decision, inside: boolean, ring: number): number => {
401
+ let best = 0;
402
+ const x = q.p % W;
403
+ const y = (q.p - x) / W;
404
+ for (let dy = -1; dy <= 1; dy++) {
405
+ for (let dx = -1; dx <= 1; dx++) {
406
+ const nx = x + dx;
407
+ const ny = y + dy;
408
+ if ((dx === 0 && dy === 0) || nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
409
+ const j = at[ny * W + nx];
410
+ if (j >= 0 && decisions[j].front === q.front && decisions[j].inside === inside && decisions[j].ring === ring) best = Math.max(best, alphaOf[j]);
411
+ }
412
+ }
413
+ return best;
414
+ };
415
+ for (const k of order) {
416
+ const q = decisions[k];
417
+ let alpha = q.matte;
418
+ if (q.inside && q.ring > 1) alpha = Math.max(q.own, q.matte);
419
+ else if (q.inside) alpha = Math.max(alpha, neighbours(q, false, 1));
420
+ else if (q.ring > 1) alpha = Math.min(alpha, neighbours(q, false, q.ring - 1));
421
+ if (alpha <= OPAQUE_ALPHA_ABOVE) alpha = 0;
422
+ else if (alpha >= 255 - OPAQUE_ALPHA_ABOVE) alpha = 255;
423
+ alphaOf[k] = alpha;
424
+ }
425
+ // Every write is planned first, per part, so a part's pieces can be held before anything lands.
426
+ const planned: Array<Map<number, readonly [number, number, number, number]>> = parts.map(() => new Map());
427
+ const plan = (i: number, p: number, r: number, g: number, bl: number, al: number): void => {
428
+ const d = parts[i].data;
429
+ const s = p * 4;
430
+ if (d[s] === r && d[s + 1] === g && d[s + 2] === bl && d[s + 3] === al) return;
431
+ planned[i].set(p, [r, g, bl, al]);
432
+ };
433
+ /** Per part, the pixels it fills as BEHIND, with the FRONT part over them. */
434
+ const fills: Array<Map<number, number>> = parts.map(() => new Map());
435
+ decisions.forEach((q, k) => {
436
+ const s = q.p * 4;
437
+ const P = [srcr.data[s], srcr.data[s + 1], srcr.data[s + 2]];
438
+ const unmixAt = (c: number, a: number): number => Math.min(255, Math.max(0, Math.round(q.cb[c] + ((P[c] - q.cb[c]) * 255) / a)));
439
+ // FRONT's colour is clamped to 0..255, so a pixel the two colours cannot mix at that coverage (a painted line darker or
440
+ // lighter than both) would miss the painting: FRONT's coverage rises to the least that brings the composite within half
441
+ // the contrast floor of it (at full coverage it is the painting).
442
+ const miss = (a: number): number => Math.max(...[0, 1, 2].map((c) => Math.abs(Math.round((unmixAt(c, a) * a + q.cb[c] * (255 - a)) / 255) - P[c])));
443
+ let alpha = alphaOf[k];
444
+ if (alpha > 0 && alpha < 255) while (alpha < 255 && 2 * miss(alpha) > EDGE_CONTRAST) alpha++;
445
+ if (alpha >= 255 - OPAQUE_ALPHA_ABOVE) alpha = 255;
446
+ const unmix = (c: number): number => unmixAt(c, alpha);
447
+ if (alpha === 0) plan(q.front, q.p, 0, 0, 0, 0);
448
+ else if (alpha === 255) plan(q.front, q.p, P[0], P[1], P[2], 255);
449
+ else plan(q.front, q.p, unmix(0), unmix(1), unmix(2), alpha);
450
+ if (q.behind >= 0 && alpha < 255) {
451
+ if (alpha === 0) plan(q.behind, q.p, P[0], P[1], P[2], 255);
452
+ else plan(q.behind, q.p, q.cb[0], q.cb[1], q.cb[2], 255);
453
+ fills[q.behind].set(q.p, q.front);
454
+ }
455
+ });
456
+ // A part's art (alpha above the floor) keeps its pieces: rigc takes one closed outline per mesh, read 4-connected
457
+ // (`CONTOUR_ONE_ISLAND`). A new piece holding none of the part's art is not written.
458
+ planned.forEach((writes, i) => {
459
+ if (writes.size === 0) return;
460
+ const before = newMask(W, H);
461
+ for (let p = 0; p < W * H; p++) before.data[p] = parts[i].data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE ? 1 : 0;
462
+ const after = (): Mask => {
463
+ const m = newMask(W, H);
464
+ m.data.set(before.data);
465
+ for (const [p, v] of writes) m.data[p] = v[3] > OPAQUE_ALPHA_ABOVE ? 1 : 0;
466
+ return m;
467
+ };
468
+ const was = connectedComponents(before, 4);
469
+ let now = connectedComponents(after(), 4);
470
+ const pieces = new Map<number, Set<number>>();
471
+ for (let p = 0; p < W * H; p++) {
472
+ const o = was.labels[p];
473
+ const l = now.labels[p];
474
+ if (o === 0 || l === 0) continue;
475
+ const set = pieces.get(o) ?? new Set<number>();
476
+ set.add(l);
477
+ pieces.set(o, set);
478
+ }
479
+ // A piece of art the step would split: each cut-off part of it is joined back to its largest part along the path that
480
+ // restores the fewest lowered pixels (a 0-1 search inside the piece as it was), and only those are not written.
481
+ for (const [o, set] of pieces) {
482
+ if (set.size < 2) continue;
483
+ const labels = [...set].sort((u, v) => now.stats[v].area - now.stats[u].area || u - v);
484
+ const main = labels[0];
485
+ const cost = new Int32Array(W * H).fill(-1);
486
+ const from = new Int32Array(W * H).fill(-1);
487
+ const deque = new Int32Array(2 * W * H + 2);
488
+ let head = W * H + 1;
489
+ let tail = head;
490
+ const nowOver = after();
491
+ for (let p = 0; p < W * H; p++) {
492
+ if (now.labels[p] === main && was.labels[p] === o) {
493
+ cost[p] = 0;
494
+ deque[tail++] = p;
495
+ }
496
+ }
497
+ while (head < tail) {
498
+ const p = deque[head++];
499
+ const x = p % W;
500
+ for (const q of [x > 0 ? p - 1 : -1, x < W - 1 ? p + 1 : -1, p - W, p + W]) {
501
+ if (q < 0 || q >= W * H || was.labels[q] !== o) continue;
502
+ const step = nowOver.data[q] === 1 ? 0 : 1;
503
+ if (cost[q] !== -1 && cost[q] <= cost[p] + step) continue;
504
+ cost[q] = cost[p] + step;
505
+ from[q] = p;
506
+ if (step === 0) deque[--head] = q;
507
+ else deque[tail++] = q;
508
+ }
509
+ }
510
+ for (const l of labels.slice(1)) {
511
+ let best = -1;
512
+ for (let p = 0; p < W * H; p++) if (now.labels[p] === l && cost[p] >= 0 && (best < 0 || cost[p] < cost[best])) best = p;
513
+ for (let p = best; p >= 0 && cost[p] > 0; p = from[p]) {
514
+ if (nowOver.data[p] === 0 && writes.delete(p)) skipped.pieces++;
515
+ }
516
+ }
517
+ }
518
+ now = connectedComponents(after(), 4);
519
+ const holdsArt = new Uint8Array(now.count);
520
+ for (let p = 0; p < W * H; p++) if (before.data[p] === 1) holdsArt[now.labels[p]] = 1;
521
+ for (const [p] of [...writes]) {
522
+ const l = now.labels[p];
523
+ if (l !== 0 && holdsArt[l] === 0) {
524
+ writes.delete(p);
525
+ skipped.pieces++;
526
+ // A fill BEHIND cannot take: FRONT takes the painting's pixel whole, so the composite there is still the painting
527
+ // (FRONT is later in draw order, so its own pieces are held after this).
528
+ const front = fills[i].get(p);
529
+ if (front !== undefined) planned[front].set(p, [srcr.data[p * 4], srcr.data[p * 4 + 1], srcr.data[p * 4 + 2], 255]);
530
+ }
531
+ }
532
+ });
533
+ const changed = parts.map(() => newMask(W, H));
534
+ planned.forEach((writes, i) => {
535
+ const d = parts[i].data;
536
+ for (const [p, v] of writes) {
537
+ d.set(v, p * 4);
538
+ changed[i].data[p] = 1;
539
+ options.written?.(i, p);
540
+ }
541
+ });
542
+ return { changed, resolved, unseparated, skipped };
543
+ }
@@ -0,0 +1,22 @@
1
+ /** Shared projection choices for the config reader and assembly. */
2
+
3
+ /**
4
+ * Where a layer may take the painting's pixel (`projectSource` in
5
+ * `src/assemble.ts`). `core` is the reference's rule: the layer's top-most
6
+ * `alpha >= 250` pixels eroded by a 5x5 square, so a part a few pixels wide has
7
+ * no core and none of its visible pixels is projected. `visible` keeps the
8
+ * erosion only where it guards something — along a rim where a later layer of
9
+ * the run is in front — and projects every other top-most `alpha >= 250` pixel
10
+ * of the layer; the parts' visible edges are then resolved from the painting
11
+ * (`paintingEdges`).
12
+ */
13
+ export type ProjectRule = 'core' | 'visible';
14
+ export const PROJECT_RULES: readonly ProjectRule[] = ['core', 'visible'];
15
+
16
+ /**
17
+ * The projection rule a run uses when none is named: the reference's, so the
18
+ * examples stay comparable with it. `visible` was measured on the public
19
+ * examples against it (the changes that added the flag and `paintingEdges`);
20
+ * it is opt-in.
21
+ */
22
+ export const DEFAULT_PROJECT_RULE: ProjectRule = 'core';
package/src/provenance.ts CHANGED
@@ -8,7 +8,7 @@ import { crop, type Raster } from './raster/index.ts';
8
8
  export const AUTOMATIC_WRITERS = {
9
9
  sourceProjection: -1, extend: -2, growRim: -3, limbSplit: -4,
10
10
  limbCap: -5, limbHybrid: -6, limbFeather: -7, fringeRestore: -8,
11
- seamOverride: -9, armSplit: -10, headFaceUnderfill: -11, ghostCleanup: -12, unattributed: -13, paintingExtension: -14,
11
+ seamOverride: -9, armSplit: -10, headFaceUnderfill: -11, ghostCleanup: -12, unattributed: -13, paintingExtension: -14, paintingEdge: -15,
12
12
  } as const;
13
13
  export type AutomaticWriter = keyof typeof AUTOMATIC_WRITERS;
14
14
  export interface PixelProvenance {