@rig-c/parts 1.5.5 → 1.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.
package/src/assemble.ts CHANGED
@@ -1600,6 +1600,8 @@ export interface AssembleResult {
1600
1600
  figures: RecompositeFigures;
1601
1601
  /** What each `assemble.cuts` entry took, counted when it was made (`cutLines`); empty without cuts. */
1602
1602
  cuts: CutCount[];
1603
+ /** The full-run layers the plan does not take from the full run whose uncovered error holds a body (`uncoveredErrorLayers`); empty otherwise. */
1604
+ uncovered: UncoveredErrorLayer[];
1603
1605
  seamRule: SeamRule;
1604
1606
  projectRule: ProjectRule;
1605
1607
  }
@@ -1656,8 +1658,11 @@ export function assemble(input: AssembleInput): AssembleResult {
1656
1658
  const rig = new Map<string, Raster>();
1657
1659
  const stats = new Map<string, ProjectionStats>();
1658
1660
  const ghost: Record<string, number> = {};
1661
+ /** The full run's layers with an opaque pixel, in draw order: the candidates `uncoveredErrorLayers` reads. */
1662
+ const fullTags: string[] = [];
1659
1663
  for (const [run, set] of [['full', full], ['head', head]] as const) {
1660
1664
  const layers = runLayers(set, input.resolution);
1665
+ if (run === 'full') fullTags.push(...layers.filter((l) => l.opaqueRunPx > 0).map((l) => l.tag));
1661
1666
  const rigLayers = layers.map((l) => layerToRig(l.canvas, frame, run));
1662
1667
  const st = projectSource(rigLayers, srcr, frame, run, projectRule);
1663
1668
  layers.forEach((l, i) => {
@@ -1962,11 +1967,13 @@ export function assemble(input: AssembleInput): AssembleResult {
1962
1967
 
1963
1968
  const planned: Array<PlacedPart | null> = plan.map(() => null);
1964
1969
  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) })),
1970
+ // A cut's piece records the plan part it was cut from (issue #230): parts.json is in draw order, so a piece drawn
1971
+ // "back" comes before its base, and nothing else in the file says which of the two is the piece.
1972
+ const pieceList: Array<{ name: string; b: Built; pushed: FringePushed[]; at: string; cutFrom: string | null; set: (p: PlacedPart) => void }> = [
1973
+ ...plan.map(([name], pi) => ({ name, b: built[pi] as Built, pushed: pushedOf[pi], at: `config.assemble.plan[${pi}]`, cutFrom: null, set: (q: PlacedPart) => (planned[pi] = q) })),
1974
+ ...cuts.map((q, i) => ({ name: q.into, b: pieces[i] as Built, pushed: pushedOfCut[i], at: `config.assemble.cuts[${i}]`, cutFrom: q.from, set: (pp: PlacedPart) => (cutPlaced[i] = pp) })),
1968
1975
  ];
1969
- pieceList.forEach(({ name, b, pushed: pushedHere, at, set }) => {
1976
+ pieceList.forEach(({ name, b, pushed: pushedHere, at, cutFrom, set }) => {
1970
1977
  const { key, r, st, extendKeys, trace, merged, region } = b;
1971
1978
  // A pixel the merge wrote is judged in the extend layer's run, every other
1972
1979
  // in the part's own. Projected = the colour came from projectSource's
@@ -1996,6 +2003,7 @@ export function assemble(input: AssembleInput): AssembleResult {
1996
2003
  const record: PartRecord = {
1997
2004
  name,
1998
2005
  from: key,
2006
+ ...(cutFrom === null ? {} : { cut_from: cutFrom }),
1999
2007
  x: x0,
2000
2008
  y: y0,
2001
2009
  w: x1 - x0 + 1,
@@ -2019,6 +2027,19 @@ export function assemble(input: AssembleInput): AssembleResult {
2019
2027
  seamOverride(placed, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
2020
2028
  const can = recomposite(placed, W, H);
2021
2029
  const figures = measureRecomposite(can, srcr, placed);
2030
+ const fullRig = fullTags.map((tag) => ({ tag, rig: rig.get(`full:${tag}`) as Raster }));
2031
+ const uncovered = uncoveredErrorLayers(
2032
+ fullRig,
2033
+ coveredMask(placed, W, H),
2034
+ errorMask(can, srcr),
2035
+ figures.uncoveredErrorPx,
2036
+ plan,
2037
+ extend,
2038
+ layerFigures(full),
2039
+ (tag) => rig.get(`head:${tag}`) ?? null,
2040
+ frame.headBottom,
2041
+ bodyMinPx(frame),
2042
+ );
2022
2043
  return {
2023
2044
  parts: { rig_size: [W, H], scale_rig_per_source: frame.S, parts: placed.map((p) => p.record), ghost_px: ghost, recomposite: recompositeRecord(figures) },
2024
2045
  images: placed,
@@ -2026,11 +2047,262 @@ export function assemble(input: AssembleInput): AssembleResult {
2026
2047
  errorMap: recompositeErrorMap(can, srcr, placed),
2027
2048
  figures,
2028
2049
  cuts: cutCounts,
2050
+ uncovered,
2029
2051
  seamRule,
2030
2052
  projectRule,
2031
2053
  };
2032
2054
  }
2033
2055
 
2056
+ // ---------------------------------------------------------------------------
2057
+ // uncovered bodies: a full-run layer the plan leaves out (issue #233)
2058
+ // ---------------------------------------------------------------------------
2059
+
2060
+ /**
2061
+ * The kernel that separates a body from a seam: a full-run pixel no planned
2062
+ * part covers counts toward a body only where it survives an opening (erode,
2063
+ * then dilate) by this square. It is `CORE_KERNEL`, the stage's own measure of
2064
+ * "a few pixels wide": the two runs' silhouettes of one layer differ by a band
2065
+ * that narrow all along their outline (the full run is resampled up, the head
2066
+ * run down), and that band, joined end to end, is one long component that
2067
+ * holds no hair.
2068
+ */
2069
+ export const BODY_KERNEL = CORE_KERNEL;
2070
+
2071
+ /**
2072
+ * The smallest uncovered body, in rig pixels: `PROPOSE_MIN_PX` full-run pixels
2073
+ * carried into the rig by the full run's map (each run pixel spans `k * k`
2074
+ * rig pixels, `k = fullK * rig_scale`). It is the bar below which the proposer
2075
+ * drops a whole tag as too small to be a part, so a body under it is one the
2076
+ * proposer would not have made a part of had it been a layer of its own.
2077
+ */
2078
+ export function bodyMinPx(frame: Frame, minPx = PROPOSE_MIN_PX): number {
2079
+ return minPx * (frame.fullK * frame.S) ** 2;
2080
+ }
2081
+
2082
+ /** One 8-connected body of a full-run layer that no planned part covers, rig pixels (y down, origin top-left). */
2083
+ export interface UncoveredBody {
2084
+ px: number;
2085
+ x: number;
2086
+ y: number;
2087
+ w: number;
2088
+ h: number;
2089
+ }
2090
+
2091
+ /** A full-run layer whose uncovered bodies the plan leaves out, and the rule that left them out. */
2092
+ export interface UncoveredLayer {
2093
+ /** The full-run tag. */
2094
+ tag: string;
2095
+ /** Its bodies, largest first (then top row, then left column); each at least `bodyMinPx`. */
2096
+ bodies: UncoveredBody[];
2097
+ /** The rule that skipped it (`skippedBy`). */
2098
+ rule: string;
2099
+ }
2100
+
2101
+ /**
2102
+ * The bodies of `mask`: its pixels that survive an opening by `BODY_KERNEL`,
2103
+ * as 8-connected components of at least `minPx` pixels, largest first, then by
2104
+ * the box's top row and left column.
2105
+ */
2106
+ export function bodiesOf(mask: Mask, minPx: number): UncoveredBody[] {
2107
+ const opened = dilate(erode(mask, BODY_KERNEL), BODY_KERNEL);
2108
+ for (let p = 0; p < opened.data.length; p++) if (mask.data[p] === 0) opened.data[p] = 0;
2109
+ const cc = connectedComponents(opened, 8);
2110
+ const all = cc.stats.slice(1).filter((s) => s.area > 0 && s.area >= minPx);
2111
+ all.sort((a, b) => b.area - a.area || a.top - b.top || a.left - b.left);
2112
+ return all.map((s) => ({ px: s.area, x: s.left, y: s.top, w: s.width, h: s.height }));
2113
+ }
2114
+
2115
+ /** A body's box as the hole lines print one: `x,y wxh`. */
2116
+ export function bodyBox(b: UncoveredBody): string {
2117
+ return `${b.x},${b.y} ${b.w}x${b.h}`;
2118
+ }
2119
+
2120
+ /**
2121
+ * Which rule left the full-run layer `tag` out of the plan's coverage, in
2122
+ * words that name it. `headPart` is the head-run part's rig raster when the
2123
+ * plan takes `tag` from the head run; `why` is the proposer's reason for not
2124
+ * proposing an extend entry, when the caller is the proposer.
2125
+ *
2126
+ * ⚖️ Invariant: exactly one of four cases, decided in this order — the plan
2127
+ * takes no part from `tag`; it takes one from the head run and no
2128
+ * `extend_below_crop` entry continues that part from the full run's `tag`;
2129
+ * an entry does, and the head-run part has no pixel of alpha above
2130
+ * `CROP_PROBE_ALPHA` on the probe row (`headBottom - CROP_PROBE_ROWS`), so
2131
+ * `belowCrop` seeds nothing; or it reaches the line and the body is not one
2132
+ * of the components `belowCrop` seeds.
2133
+ */
2134
+ export function skippedBy(tag: string, plan: readonly PlanEntry[], extend: readonly Extend[], headPart: Raster | null, headBottom: number, why: string | null): string {
2135
+ const entry = plan.find((e) => e[2] === tag);
2136
+ if (entry === undefined) return `the plan takes no part from "${tag}"`;
2137
+ const part = entry[0];
2138
+ const taken = `${part} takes the head run's "${tag}"`;
2139
+ if (!extend.some((e) => e.part === part && e.run === 'full' && e.tag === tag)) {
2140
+ return `${taken}, and no extend_below_crop entry continues it from the full run${why === null ? '' : ` (${why})`}`;
2141
+ }
2142
+ const probe = headBottom - CROP_PROBE_ROWS;
2143
+ let reaches = false;
2144
+ if (headPart !== null && probe >= 0 && probe < headPart.height) {
2145
+ for (let x = 0; x < headPart.width && !reaches; x++) if (headPart.data[(probe * headPart.width + x) * 4 + 3] > CROP_PROBE_ALPHA) reaches = true;
2146
+ }
2147
+ if (!reaches) {
2148
+ return `${taken}; extend_below_crop continues a head-run part only from the columns where it reaches the head crop's bottom line (alpha above ${CROP_PROBE_ALPHA} on rig row ${probe}), and ${part} reaches it nowhere`;
2149
+ }
2150
+ return `${taken}; extend_below_crop continues only the full-run components that touch rig rows ${headBottom}..${headBottom + CROP_SEED_ROWS - 1} within ${CROP_SEED_COLUMNS} columns of where ${part} reaches the head crop's bottom line, and this body is not one of them`;
2151
+ }
2152
+
2153
+ /**
2154
+ * The full-run layers holding a body no planned part covers. `covered` is 1
2155
+ * where some planned part has alpha above `COVERED_ALPHA` (the recomposite's
2156
+ * own "covered"); `fullRig` maps each full-run tag to its rig raster. A layer
2157
+ * is a candidate when no plan entry takes its tag from the full run and it
2158
+ * crosses no plausibility rule (a layer that does is noted by the proposer
2159
+ * and refused as a part by `layers`, so it is no continuation). Its body
2160
+ * pixels are its alpha-above-`COVERED_ALPHA` pixels where `covered` is 0;
2161
+ * `bodiesOf` keeps the bodies of at least `minPx`. Layers in the full run's
2162
+ * draw order.
2163
+ */
2164
+ export function uncoveredLayers(
2165
+ fullRig: ReadonlyArray<{ tag: string; rig: Raster }>,
2166
+ covered: Uint8Array,
2167
+ plan: readonly PlanEntry[],
2168
+ extend: readonly Extend[],
2169
+ figures: readonly LayerFigures[],
2170
+ headRig: (tag: string) => Raster | null,
2171
+ headBottom: number,
2172
+ minPx: number,
2173
+ why: (tag: string) => string | null,
2174
+ ): UncoveredLayer[] {
2175
+ const out: UncoveredLayer[] = [];
2176
+ for (const { tag, rig } of fullRig) {
2177
+ if (plan.some((e) => e[1] === 'full' && e[2] === tag)) continue;
2178
+ const f = figures.find((x) => x.name === tag);
2179
+ if (f !== undefined && implausibleRules(f).length > 0) continue;
2180
+ const mask = newMask(rig.width, rig.height);
2181
+ let any = false;
2182
+ for (let p = 0; p < covered.length; p++) {
2183
+ if (covered[p] === 0 && rig.data[p * 4 + 3] > COVERED_ALPHA) {
2184
+ mask.data[p] = 1;
2185
+ any = true;
2186
+ }
2187
+ }
2188
+ if (!any) continue;
2189
+ const bodies = bodiesOf(mask, minPx);
2190
+ if (bodies.length === 0) continue;
2191
+ const entry = plan.find((e) => e[2] === tag);
2192
+ out.push({ tag, bodies, rule: skippedBy(tag, plan, extend, entry !== undefined && entry[1] === 'head' ? headRig(tag) : null, headBottom, why(tag)) });
2193
+ }
2194
+ return out;
2195
+ }
2196
+
2197
+ /**
2198
+ * Where the plan's parts cover the rig before any pixel is projected: 1 where
2199
+ * some plan entry's layer, warped into the rig (`layerToRig`), has alpha above
2200
+ * `COVERED_ALPHA`, or where an `extend_below_crop` entry's `belowCrop`
2201
+ * selection copies such a pixel into its part. Projection never changes alpha,
2202
+ * so this is the stage's own coverage less what `growRim`'s ring, the fringe
2203
+ * push-back and the seam override add or clear along an outline — a pixel
2204
+ * wide, below any body (`BODY_KERNEL`).
2205
+ */
2206
+ export function planCoverage(plan: readonly PlanEntry[], extend: readonly Extend[], rigOf: (run: Run, tag: string) => Raster, headBottom: number): Uint8Array {
2207
+ let covered: Uint8Array | null = null;
2208
+ const mark = (r: Raster, sel: Mask | null): void => {
2209
+ if (covered === null) covered = new Uint8Array(r.width * r.height);
2210
+ for (let p = 0; p < covered.length; p++) if (r.data[p * 4 + 3] > COVERED_ALPHA && (sel === null || sel.data[p] === 1)) covered[p] = 1;
2211
+ };
2212
+ for (const [name, run, tag] of plan) {
2213
+ const part = rigOf(run, tag);
2214
+ mark(part, null);
2215
+ for (const e of extend) {
2216
+ if (e.part !== name) continue;
2217
+ const extra = rigOf(e.run, e.tag);
2218
+ mark(extra, belowCrop(part, extra, headBottom));
2219
+ }
2220
+ }
2221
+ return covered ?? new Uint8Array(0);
2222
+ }
2223
+
2224
+ /** A full-run layer the recomposite's uncovered error lies on in a body (`uncoveredErrorLayers`). */
2225
+ export interface UncoveredErrorLayer {
2226
+ /** The layer, its uncovered error bodies, and the rule that skipped it. */
2227
+ layer: UncoveredLayer;
2228
+ /** Every uncovered error pixel under the layer's alpha above `COVERED_ALPHA`, in a body or not … */
2229
+ errorPx: number;
2230
+ /** … of the recomposite's `uncoveredErrorPx`. */
2231
+ of: number;
2232
+ }
2233
+
2234
+ /**
2235
+ * The full-run layers the plan does not take from the full run that the
2236
+ * recomposite's uncovered error lies on in a body.
2237
+ *
2238
+ * ⚖️ Invariant: exactly `uncoveredLayers` with "covered" widened to every
2239
+ * pixel that is covered or is not an error pixel — so a layer is listed when
2240
+ * its uncovered ERROR pixels hold a body of at least `minPx` (`bodiesOf`), the
2241
+ * proposer's bar for an uncovered layer applied to what the recomposite shows.
2242
+ * `covered` and `err` are the recomposite's own masks (`coveredMask`,
2243
+ * `errorMask`). Draw order of the full run.
2244
+ *
2245
+ * Not "a layer holding most of the uncovered error": on the public examples'
2246
+ * proposed plans the seam scatter along every outline outweighs a missing
2247
+ * lock (the pull request that added this has the figures), so a majority
2248
+ * test would stay silent exactly where the proposer's note fires.
2249
+ */
2250
+ export function uncoveredErrorLayers(
2251
+ fullRig: ReadonlyArray<{ tag: string; rig: Raster }>,
2252
+ covered: Uint8Array,
2253
+ err: Uint8Array,
2254
+ of: number,
2255
+ plan: readonly PlanEntry[],
2256
+ extend: readonly Extend[],
2257
+ figures: readonly LayerFigures[],
2258
+ headRig: (tag: string) => Raster | null,
2259
+ headBottom: number,
2260
+ minPx: number,
2261
+ ): UncoveredErrorLayer[] {
2262
+ if (of === 0) return [];
2263
+ const quiet = new Uint8Array(covered.length);
2264
+ for (let p = 0; p < quiet.length; p++) quiet[p] = covered[p] === 1 || err[p] === 0 ? 1 : 0;
2265
+ return uncoveredLayers(fullRig, quiet, plan, extend, figures, headRig, headBottom, minPx, () => null).map((layer) => {
2266
+ const rig = (fullRig.find((l) => l.tag === layer.tag) as { rig: Raster }).rig;
2267
+ let n = 0;
2268
+ for (let p = 0; p < err.length; p++) if (quiet[p] === 0 && rig.data[p * 4 + 3] > COVERED_ALPHA) n++;
2269
+ return { layer, errorPx: n, of };
2270
+ });
2271
+ }
2272
+
2273
+ /**
2274
+ * One `uncovered` line under the hole lines: the layer, its share of the
2275
+ * uncovered error, its bodies, and the rule that skipped it — `uncovered: 240
2276
+ * of the 240 uncovered error px lie on the full run's "back hair", in a body
2277
+ * of 240 px at 36,22 8x30; hair_back takes the head run's "back hair"; …`.
2278
+ */
2279
+ export function uncoveredLine(d: UncoveredErrorLayer): string {
2280
+ const u = d.layer;
2281
+ const total = u.bodies.reduce((a, b) => a + b.px, 0);
2282
+ const big = u.bodies[0];
2283
+ const held = u.bodies.length > 1 ? `${u.bodies.length} bodies of ${total} px, the largest ${big.px} px at ${bodyBox(big)}` : `a body of ${big.px} px at ${bodyBox(big)}`;
2284
+ return `uncovered: ${d.errorPx} of the ${d.of} uncovered error px lie on the full run's "${u.tag}", in ${held}; ${u.rule}`;
2285
+ }
2286
+
2287
+ /**
2288
+ * The proposer's note for an uncovered layer: the layer, its pixel count and
2289
+ * largest body, the rule that skipped it, and the continuations — the plan is
2290
+ * not changed, because which one fits (the full run's resolution for the whole
2291
+ * part, or a second part behind it) is the author's to choose.
2292
+ */
2293
+ export function uncoveredNote(u: UncoveredLayer, plan: readonly PlanEntry[], minPx: number): string {
2294
+ const total = u.bodies.reduce((a, b) => a + b.px, 0);
2295
+ const big = u.bodies[0];
2296
+ const held =
2297
+ u.bodies.length > 1 ? `${u.bodies.length} bodies of ${total} rig px, the largest ${big.px} px at ${bodyBox(big)},` : `a body of ${big.px} rig px at ${bodyBox(big)}`;
2298
+ const entry = plan.find((e) => e[2] === u.tag);
2299
+ const fix =
2300
+ entry === undefined
2301
+ ? `take it as a part: ["${aliasOf(u.tag)}", "full", "${u.tag}"]`
2302
+ : `take ${entry[0]} from the full run (["${entry[0]}", "full", "${u.tag}"]), or add the full-run layer as a second part drawn just behind ${entry[0]}`;
2303
+ return `${u.tag}: the full run's layer holds ${held} that no planned part covers (a body: at least ${fmt0(minPx)} rig px past a ${BODY_KERNEL}x${BODY_KERNEL} opening); ${u.rule} -> ${fix}`;
2304
+ }
2305
+
2034
2306
  // ---------------------------------------------------------------------------
2035
2307
  // --propose-plan
2036
2308
  // ---------------------------------------------------------------------------
@@ -2119,9 +2391,19 @@ function fmt0(v: number): string {
2119
2391
  * background-coloured haze, or out of proportion with the rest of its run's
2120
2392
  * figure) is neither proposed, nor taken as the head-run fallback, nor extended
2121
2393
  * 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.
2394
+ * it crossed with the bar. Those notes come last. [observed] No layer of a
2395
+ * public example crosses a rule.
2396
+ *
2397
+ * ⚖️ This port's too (issue #233): the proposer measures what its own plan
2398
+ * leaves uncovered before it prints it. Every plan entry's layer is warped
2399
+ * into the rig and every extend entry's `belowCrop` selection added
2400
+ * (`planCoverage`); a full-run layer no entry takes from the full run whose
2401
+ * uncovered pixels hold a body (`uncoveredLayers`, `bodyMinPx`) gets a note
2402
+ * after the reference's, naming the body, the rule that skipped it
2403
+ * (`skippedBy`) and the two continuations (`uncoveredNote`). The plan is not
2404
+ * changed. [observed] reference-sample's proposal is the reference's byte for
2405
+ * byte; demo's, reference-demo's and scarf's each gain one note, on the hair
2406
+ * their configs had already corrected by hand.
2125
2407
  */
2126
2408
  export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
2127
2409
  const frame = checkGeometry(g, { full, head });
@@ -2183,6 +2465,8 @@ export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx =
2183
2465
  }
2184
2466
  for (const t of heads) plan.push([aliasOf(t), 'head', t]);
2185
2467
  const extend: Extend[] = [];
2468
+ /** A hair tag the proposer did not continue below the crop: how far its full-run layer reaches, rig px (null: no opaque row). */
2469
+ const reaches = new Map<string, number | null>();
2186
2470
  for (const t of ['front hair', 'back hair']) {
2187
2471
  const fl = find('full', t);
2188
2472
  if (fl === undefined || !plan.some((p) => p[1] === 'head' && p[2] === t)) continue;
@@ -2198,9 +2482,45 @@ export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx =
2198
2482
  }
2199
2483
  }
2200
2484
  // 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 });
2485
+ const reach = (last + 1) * frame.fullK * frame.S - frame.fullPadTop * frame.S;
2486
+ if (last >= 0 && reach > frame.headBottom + 8) extend.push({ part: PROPOSE_ALIAS[t], run: 'full', tag: t });
2487
+ else reaches.set(t, last < 0 ? null : reach);
2202
2488
  }
2203
- return { plan, extend_below_crop: extend, notes: [...notes, ...dropNotes] };
2489
+ // What the plan leaves uncovered (issue #233): a full-run layer whose body no planned part covers is named, never dropped in silence.
2490
+ const rigs = new Map<string, Raster>();
2491
+ const rigOf = (run: Run, tag: string): Raster => {
2492
+ const key = `${run}:${tag}`;
2493
+ let r = rigs.get(key);
2494
+ if (r === undefined) {
2495
+ r = layerToRig((find(run, tag) as RunLayer).canvas, frame, run);
2496
+ rigs.set(key, r);
2497
+ }
2498
+ return r;
2499
+ };
2500
+ const covered = planCoverage(plan, extend, rigOf, frame.headBottom);
2501
+ const minBody = bodyMinPx(frame, minPx);
2502
+ const why = (tag: string): string => {
2503
+ if (!reaches.has(tag)) return 'the proposer continues only front hair and back hair below the head crop';
2504
+ const r = reaches.get(tag);
2505
+ return `the proposer continues ${tag} only when its full-run layer reaches more than 8 rig px below the crop line, rig row ${frame.headBottom}; it reaches ${r === null || r === undefined ? 'no row' : `rig row ${fmt0(r)}`}`;
2506
+ };
2507
+ const left =
2508
+ covered.length === 0
2509
+ ? []
2510
+ : uncoveredLayers(
2511
+ runs.full.filter((l) => l.opaqueRunPx > 0).map((l) => ({ tag: l.tag, rig: rigOf('full', l.tag) })),
2512
+ covered,
2513
+ plan,
2514
+ extend,
2515
+ figures.full,
2516
+ (tag) => rigOf('head', tag),
2517
+ frame.headBottom,
2518
+ minBody,
2519
+ why,
2520
+ );
2521
+ const bodyNotes = left.map((u) => uncoveredNote(u, plan, minBody));
2522
+ // The reference's notes, then the uncovered bodies, then the plausibility drops — a drop note is last, as it was.
2523
+ return { plan, extend_below_crop: extend, notes: [...notes, ...bodyNotes, ...dropNotes] };
2204
2524
  }
2205
2525
 
2206
2526
  // ---------------------------------------------------------------------------
package/src/build.ts CHANGED
@@ -43,7 +43,7 @@
43
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, join, resolve } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
- import { assemble, type AssembleResult, cutLines, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
49
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, stimulusMotion, withStimulus } from './automotion.ts';
@@ -228,6 +228,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
228
228
  log(` ghost px removed: ${ghosts} over ${Object.keys(result.parts.ghost_px).length} layer(s)`);
229
229
  log(figuresLine(result.figures));
230
230
  for (const l of holeLines(result.figures)) log(l);
231
+ for (const u of result.uncovered) log(uncoveredLine(u));
231
232
  log(`wrote ${outs.partsJson}, ${result.images.length} PNG(s) in ${outs.partsDir}, ${outs.recomposite}, ${outs.errorMap} (uncovered error px red, covered error px blue, the painting grey)`);
232
233
  return result;
233
234
  }