@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/README.md +5 -3
- package/cli.ts +10 -2
- package/docs/AUTHORING.md +83 -10
- package/docs/STABILITY.md +1 -1
- package/package.json +1 -1
- package/src/assemble.ts +329 -9
- package/src/build.ts +2 -1
- package/src/check.ts +224 -28
- package/src/instruments.ts +34 -10
- package/src/parts.ts +39 -2
- package/src/propose.ts +97 -39
- package/src/requirements.ts +58 -3
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
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
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
|
|
2123
|
-
*
|
|
2124
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|