@rig-c/parts 1.16.0 → 1.18.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
@@ -42,6 +42,12 @@
42
42
  * run's map carries the vertical pad exactly as it carries the horizontal
43
43
  * one. A portrait painting's vertical pad is 0, so its map is the
44
44
  * reference's.
45
+ * - **One run's file need not be square** (issue #366): with no head run the
46
+ * file is padded to the `resolution` square by the same centring
47
+ * (`padToSquare`, `padSingleRun`) and mapped back one run pixel to one
48
+ * source pixel, so the rig is the file with no pad in it; `parts.json`
49
+ * records the pad (`single_run_pad`) and the painting must be the file's
50
+ * size. A file that is the square is read as it was.
45
51
  * - **`seamRule: 'silhouette'`** is available beside the faithful
46
52
  * `'near-white'` rule. The reference's seam override skips every pixel whose
47
53
  * painting colour is near-white (min channel above 235), which protects the
@@ -91,11 +97,12 @@
91
97
  *
92
98
  * Pure: no clock, no randomness, no file access. The CLI reads and writes.
93
99
  */
94
- import type { CharacterConfig, Cut, EarlyConfig, Extend, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
100
+ import type { BoneEntry, CharacterConfig, Cut, EarlyConfig, Extend, Limb, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
95
101
  import { type Problem, refuseIfAny } from './errors.ts';
96
102
  import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
97
- import { squarePad } from './inputs.ts';
98
- import { PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord } from './parts.ts';
103
+ import { padToSquare, squarePad } from './inputs.ts';
104
+ import { type CapRecord, type LimbRecord, PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord, type SingleRunPad } from './parts.ts';
105
+ import { type BonePlace, bonePlaces, capBox, inDisc, type JointLine, jointLine, limbPolyline, limbWidth, nearestLimb, pastJoint, segmentOf } from './limbs.ts';
99
106
  import {
100
107
  alphaComposite,
101
108
  connectedComponents,
@@ -213,6 +220,72 @@ export interface Geometry {
213
220
  headBox: [number, number, number, number];
214
221
  /** `assemble.rig_scale`: rig pixels per source pixel. */
215
222
  rigScale: number;
223
+ /**
224
+ * One run, no head crop (issue #359): `headBox` is {@link singleRunHeadBox},
225
+ * the full run's own frame, and is not held inside the painting (on a
226
+ * non-square painting the frame reaches past it). Absent: two runs.
227
+ *
228
+ * Issue #366: the run is the artist's file itself, so its frame is the file
229
+ * centred on the `resolution` square ({@link padToSquare}) at one run pixel
230
+ * per source pixel, and the painting must be the file's size.
231
+ */
232
+ single?: boolean;
233
+ }
234
+
235
+ /**
236
+ * The tags a single run must hold, each as a layer with an opaque pixel
237
+ * (issue #359): `face`, which places the face box, and the eye group
238
+ * (`EYE_GROUP_TAGS` in `src/propose.ts`, held equal to this list's tail by
239
+ * the selftest), from which the eye bones, the eye line and the blink come.
240
+ * The head run exists because See-through cannot separate these at
241
+ * full-figure scale; a file that already separates them needs no second run.
242
+ */
243
+ export const SINGLE_RUN_FACE_TAGS: readonly string[] = ['face', 'eyewhite-r', 'eyewhite-l'];
244
+
245
+ /**
246
+ * The head box of a single run (issue #359): the full run's own frame on the
247
+ * painting, `[-left, -top, side - left, side - top]` from {@link padToSquare},
248
+ * so the head run's map is the full run's, pixel for pixel. On a square
249
+ * painting it is `[0, 0, w, h]`. Derived, never read from the config: a
250
+ * single run has no crop.
251
+ *
252
+ * `side` is the square the file is padded to, `seethrough.resolution` (issue
253
+ * #366); left out, it is the longer side, {@link squarePad}'s square — the two
254
+ * agree whenever the longer side is the resolution.
255
+ */
256
+ export function singleRunHeadBox(w: number, h: number, side: number = Math.max(w, h)): [number, number, number, number] {
257
+ const { left, top } = padToSquare(w, h, side);
258
+ return [-left, -top, side - left, side - top];
259
+ }
260
+
261
+ /**
262
+ * A single run's file padded to the `resolution` square (issue #366): every
263
+ * drawn and hidden-art layer moved by the frame's pad, the canvas the square.
264
+ * No pixel changes; the pad holds none, so it is transparent. The same set
265
+ * comes back when there is no pad, so a square file at the resolution is read
266
+ * exactly as it was.
267
+ */
268
+ export function padSingleRun(set: LayerSet, frame: Pick<Frame, 'fullPad' | 'fullPadTop'>, resolution: number): LayerSet {
269
+ const dx = frame.fullPad;
270
+ const dy = frame.fullPadTop;
271
+ if (dx === 0 && dy === 0 && set.canvas.w === resolution && set.canvas.h === resolution) return set;
272
+ return {
273
+ ...set,
274
+ canvas: { w: resolution, h: resolution },
275
+ layers: set.layers.map((l) => ({ ...l, left: l.left + dx, top: l.top + dy, right: l.right + dx, bottom: l.bottom + dy })),
276
+ hidden: set.hidden.map((l) => ({ ...l, left: l.left + dx, top: l.top + dy, right: l.right + dx, bottom: l.bottom + dy })),
277
+ };
278
+ }
279
+
280
+ /**
281
+ * What `parts.json` records of a single run's pad (issue #366), or null when
282
+ * there is none — two runs, or a file that is the resolution square — so that
283
+ * every such `parts.json` keeps its bytes. Run pixel `(x, y)` is file pixel
284
+ * `(x - left, y - top)`; the rig is the file times `scale_rig_per_source`.
285
+ */
286
+ export function singleRunPadRecord(single: boolean, sourceW: number, sourceH: number, resolution: number, frame: Pick<Frame, 'fullPad' | 'fullPadTop'>): SingleRunPad | null {
287
+ if (!single || (sourceW === resolution && sourceH === resolution)) return null;
288
+ return { file_size: [sourceW, sourceH], side: resolution, left: frame.fullPad, top: frame.fullPadTop };
216
289
  }
217
290
 
218
291
  export interface AssembleInput {
@@ -240,6 +313,14 @@ export interface AssembleInput {
240
313
  projectRule: ProjectRule;
241
314
  /** `config.scene` (issue #242): the painting is a scene, so the recomposite is read inside the figure ({@link sceneFigure}). Absent or false: nothing changes. */
242
315
  scene?: boolean;
316
+ /** `config.limbs` (issue #349): the parts cut at their joints ({@link LimbCount}). Absent or empty: nothing changes. */
317
+ limbs?: Record<string, Limb>;
318
+ /** `config.bones`, read for the limbs' joints; required with `limbs`. */
319
+ bones?: BoneEntry[];
320
+ /** Each segment's `cap` image, decoded by the caller, keyed `<limb>/<segment index>`; a segment naming a cap with none here is refused (`ASSEMBLE_LIMB_CAP_PRESENT`). */
321
+ caps?: ReadonlyMap<string, Raster>;
322
+ /** One run (issue #359): `head` is `full`, `headBox` {@link singleRunHeadBox}, and {@link checkSingleRun} holds the plan to it. Absent: two runs. */
323
+ single?: boolean;
243
324
  }
244
325
 
245
326
  /** The derived numbers of the two runs' geometry. */
@@ -268,23 +349,43 @@ export function checkGeometry(g: Geometry, runs?: { full: LayerSet; head: LayerS
268
349
  problems.push({ code, object, detail });
269
350
  };
270
351
  const { sourceW: w, sourceH: h, resolution, headBox, rigScale: S } = g;
271
- const pad = squarePad(w, h);
352
+ // Issue #366: one run is the file itself, centred on the resolution square at one run px per source px.
353
+ const pad = g.single === true ? padToSquare(w, h, resolution) : squarePad(w, h);
272
354
  const W = Math.trunc(w * S);
273
355
  const H = Math.trunc(h * S);
274
356
  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`);
275
357
  const [x0, y0, x1, y1] = headBox;
276
- if (!(x0 >= 0 && y0 >= 0 && x1 <= w && y1 <= h && x1 > x0 && y1 > y0)) {
358
+ // A single run (issue #359) has no crop: its box is the full run's frame, derived, and may reach past the painting.
359
+ if (g.single !== true && !(x0 >= 0 && y0 >= 0 && x1 <= w && y1 <= h && x1 > x0 && y1 > y0)) {
277
360
  fail('ASSEMBLE_HEAD_BOX_INSIDE', 'config.seethrough.head_box', `is [${headBox.join(', ')}]; a non-empty box inside the ${w}x${h} painting is required`);
278
361
  }
279
362
  const headBottom = Math.trunc(y1 * S) - HEAD_BOTTOM_INSET;
280
- if (headBottom - CROP_PROBE_ROWS < 0 && y1 > y0) {
363
+ if (g.single !== true && headBottom - CROP_PROBE_ROWS < 0 && y1 > y0) {
281
364
  fail(
282
365
  'ASSEMBLE_HEAD_BOX_INSIDE',
283
366
  'config.seethrough.head_box',
284
367
  `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`,
285
368
  );
286
369
  }
287
- if (runs !== undefined) {
370
+ if (runs !== undefined && g.single === true) {
371
+ // Issue #366: a single run's file is padded to the resolution square, so a side below it is fixed; a side above it
372
+ // is not, and a painting of another size would map no part back to the file.
373
+ const c = runs.full.canvas;
374
+ if (c.w > resolution || c.h > resolution) {
375
+ fail(
376
+ 'ASSEMBLE_RUN_CANVAS',
377
+ `the full run (${runs.full.source})`,
378
+ `has a ${c.w}x${c.h} canvas; with one run the file is padded to the ${resolution}x${resolution} square config.seethrough.resolution ${resolution} makes, and padding cannot shrink a canvas, so no side above ${resolution} is required — a resolution of at least ${Math.max(c.w, c.h)}, the file's longer side`,
379
+ );
380
+ }
381
+ if (c.w !== w || c.h !== h) {
382
+ fail(
383
+ 'ASSEMBLE_SINGLE_RUN_SOURCE',
384
+ `the painting (--source, ${w}x${h})`,
385
+ `is not the size of the full run's file (${runs.full.source}), ${c.w}x${c.h}; with one run the painting is that file flattened, and every part's box maps back to the file through it, so a ${c.w}x${c.h} painting is required`,
386
+ );
387
+ }
388
+ } else if (runs !== undefined) {
288
389
  for (const run of ['full', 'head'] as const) {
289
390
  const c = runs[run].canvas;
290
391
  if (c.w !== resolution || c.h !== resolution) {
@@ -314,7 +415,7 @@ export function checkGeometry(g: Geometry, runs?: { full: LayerSet; head: LayerS
314
415
  * Place a layer's pixels on its run's `resolution x resolution` canvas.
315
416
  * `readWrapperLayers`/`readPsdLayers` already refused a box outside it.
316
417
  */
317
- export function placeLayer(layer: Layer, resolution: number): Raster {
418
+ export function placeLayer(layer: Pick<Layer, 'pixels' | 'left' | 'top'>, resolution: number): Raster {
318
419
  const out = newRaster(resolution, resolution);
319
420
  const p = layer.pixels;
320
421
  for (let y = 0; y < p.height; y++) {
@@ -1537,7 +1638,10 @@ export function cutPatch(patch: Patch, srcr: Raster, silhouette: Mask | null): {
1537
1638
  }
1538
1639
 
1539
1640
  /** One place in the draw order: a plan part, a patch or a cut's new part, by its index in its list. */
1540
- export type DrawSlot = { plan: number } | { patch: number } | { cut: number };
1641
+ export type PlanSlot = { plan: number } | { patch: number } | { cut: number };
1642
+
1643
+ /** A slot of the draw order once the limbs are in it (issue #349, {@link limbOrder}). */
1644
+ export type DrawSlot = PlanSlot | { limb: number };
1541
1645
 
1542
1646
  /**
1543
1647
  * The draw order of plan parts, patches and cuts together, back to front:
@@ -1548,8 +1652,8 @@ export type DrawSlot = { plan: number } | { patch: number } | { cut: number };
1548
1652
  * order of plan parts and patches it always was. The loader has already
1549
1653
  * refused a `before` that names no plan part.
1550
1654
  */
1551
- export function drawOrder(plan: readonly string[], patches: readonly Patch[], cuts: readonly Cut[] = []): DrawSlot[] {
1552
- const out: DrawSlot[] = [];
1655
+ export function drawOrder(plan: readonly string[], patches: readonly Patch[], cuts: readonly Cut[] = []): PlanSlot[] {
1656
+ const out: PlanSlot[] = [];
1553
1657
  const place = (at: (draw: Patch['draw']) => boolean): void => {
1554
1658
  patches.forEach((q, i) => {
1555
1659
  if (at(q.draw)) out.push({ patch: i });
@@ -1703,6 +1807,295 @@ export function cutBand(polygonMask: Mask, overlap: number): Mask {
1703
1807
  return band;
1704
1808
  }
1705
1809
 
1810
+ /** One layer's part as the stage builds it, before it is placed: its key, pixels, projection figures, merge trace and, when the layer is cut, the rig pixels it owns. */
1811
+ interface Built {
1812
+ key: string;
1813
+ r: Raster;
1814
+ st: ProjectionStats;
1815
+ extendKeys: string[];
1816
+ trace: MergeTrace;
1817
+ merged: number;
1818
+ /** 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. */
1819
+ region: Mask | null;
1820
+ }
1821
+
1822
+ // ---------------------------------------------------------------------------
1823
+ // limbs — a declared limb's parts cut at its joints (issue #349, src/limbs.ts)
1824
+ // ---------------------------------------------------------------------------
1825
+
1826
+ /** One part a limb segment makes, in `config.limbs` order (limb, then segment). */
1827
+ export interface LimbPart {
1828
+ limb: string;
1829
+ segment: number;
1830
+ part: string;
1831
+ /** The part whose pixels it takes. */
1832
+ of: string;
1833
+ record: LimbRecord;
1834
+ }
1835
+
1836
+ /** What the limbs did to one part they took: its opaque pixels and each segment's share (`limbLines`). */
1837
+ export interface LimbCount {
1838
+ of: string;
1839
+ before: number;
1840
+ shares: Array<{ part: string; px: number }>;
1841
+ /** The joints whose cap this part's segments carry, in `config.limbs` order. */
1842
+ joints: Array<{ limb: string; joint: string; part: string; cap: CapRecord }>;
1843
+ }
1844
+
1845
+ /**
1846
+ * The lines `assemble` prints for the limbs (issue #349), one per part a limb
1847
+ * took — ` limb cut: "<of>" opaque <n> = "<part>" <px> + …` — and one per
1848
+ * joint, its cap's pixels by where they came from. Empty without limbs.
1849
+ */
1850
+ export function limbLines(counts: readonly LimbCount[]): string[] {
1851
+ const out: string[] = [];
1852
+ for (const c of counts) {
1853
+ out.push(` limb cut: "${c.of}" opaque ${c.before} = ${c.shares.map((q) => `"${q.part}" ${q.px}`).join(' + ')}`);
1854
+ for (const j of c.joints) {
1855
+ const k = j.cap;
1856
+ const from = k.image_px === undefined ? `${k.layer_px} px from the layer` : `${k.image_px} px from ${k.image_layer === undefined ? 'the cap image' : `the hidden-art layer ${k.image_layer}`}, ${k.layer_px} px from the layer`;
1857
+ const miss =
1858
+ k.missing_px === 0
1859
+ ? 'none missing'
1860
+ : `${k.missing_px} px missing — the layer has no pixel there (hidden in the painting, or past the limb's outline), and the tool invents none: paint them into a ${k.box[2]}x${k.box[3]} image whose pixel (i, j) is rig pixel (${k.box[0]} + i, ${k.box[1]} + j), and name it in the segment's "cap"`;
1861
+ out.push(
1862
+ ` limb cap: "${j.limb}" joint "${j.joint}" at [${k.at[0]}, ${k.at[1]}], on "${j.part}": a disc of radius ${k.radius} px (the limb ${k.width} px across the joint line), box [${k.box.join(', ')}] — on the upper side of the line ${from}, ${miss}`,
1863
+ );
1864
+ }
1865
+ }
1866
+ return out;
1867
+ }
1868
+
1869
+ /**
1870
+ * The draw order with the limbs in it (issue #349): each part a limb takes
1871
+ * leaves the order, and each limb's parts stand where its first segment's
1872
+ * part stood, lowest segment first (drawn behind), so the upper is drawn over
1873
+ * the lower at every joint; limbs whose first segment takes one part stand
1874
+ * there in `config.limbs` order. Without limbs, `order` as it was.
1875
+ */
1876
+ export function limbOrder(order: readonly DrawSlot[], plan: readonly PlanEntry[], cuts: readonly Cut[], parts: readonly LimbPart[]): DrawSlot[] {
1877
+ if (parts.length === 0) return [...order];
1878
+ const nameOf = (d: DrawSlot): string | null => ('plan' in d ? plan[d.plan][0] : 'cut' in d ? cuts[d.cut].into : null);
1879
+ const taken = new Set(parts.map((q) => q.of));
1880
+ const out: DrawSlot[] = [];
1881
+ for (const d of order) {
1882
+ const n = nameOf(d);
1883
+ if (n === null || !taken.has(n)) {
1884
+ out.push(d);
1885
+ continue;
1886
+ }
1887
+ const limbs = [...new Set(parts.filter((q) => q.segment === 0 && q.of === n).map((q) => q.limb))];
1888
+ for (const l of limbs) {
1889
+ const mine = parts.map((q, i) => ({ q, i })).filter(({ q }) => q.limb === l);
1890
+ for (const { i } of [...mine].reverse()) out.push({ limb: i });
1891
+ }
1892
+ }
1893
+ return out;
1894
+ }
1895
+
1896
+ /**
1897
+ * Cut every declared limb (module comment of `src/limbs.ts`): each part a limb
1898
+ * takes is split among the limbs that take it (nearest polyline), then each
1899
+ * limb's share among its segments (the joint lines), and each segment below a
1900
+ * joint gets its cap. Every refusal is collected and thrown at once:
1901
+ * `ASSEMBLE_LIMB_TAKES` (a limb takes a part with no pixel), `ASSEMBLE_LIMB_JOINT`
1902
+ * (no joint line: the upper bone has no length, or the limb folds back on
1903
+ * itself), `ASSEMBLE_LIMB_JOINT_ON_ART` (the joint is not on the limb's art,
1904
+ * so it has no width), `ASSEMBLE_LIMB_PIXELS` (a segment gets no opaque
1905
+ * pixel), `ASSEMBLE_LIMB_CAP_PRESENT`, `ASSEMBLE_LIMB_CAP_SIZE` and
1906
+ * `ASSEMBLE_LIMB_CAP_EMPTY` (a cap image the caller did not hand over, not the
1907
+ * box's size, or transparent over every cap pixel).
1908
+ */
1909
+ function cutLimbs(
1910
+ limbs: Readonly<Record<string, Limb>>,
1911
+ bones: readonly BoneEntry[],
1912
+ caps: ReadonlyMap<string, Raster>,
1913
+ W: number,
1914
+ H: number,
1915
+ builtOf: (name: string) => Built | null,
1916
+ opaqueOf: (r: Raster) => number,
1917
+ mergedIn: (b: Built, region: Mask) => number,
1918
+ count: (m: Mask | null, region: Mask) => number,
1919
+ hiddenOf: (part: string) => HiddenArt | null = () => null,
1920
+ readHidden: (layer: string, by: string) => void = () => undefined,
1921
+ ): { parts: LimbPart[]; pieces: Built[]; counts: LimbCount[] } {
1922
+ const names = Object.keys(limbs);
1923
+ if (names.length === 0) return { parts: [], pieces: [], counts: [] };
1924
+ const problems: Problem[] = [];
1925
+ const places = bonePlaces(bones);
1926
+ const parts: LimbPart[] = [];
1927
+ for (const l of names) {
1928
+ limbs[l].segments.forEach((sg, k) =>
1929
+ parts.push({ limb: l, segment: k, part: sg.part, of: sg.from, record: { name: l, segment: k, of: sg.from, bone: sg.bone, ...(k === 0 ? {} : { joint: sg.joint as string }) } }),
1930
+ );
1931
+ }
1932
+ const polylines = new Map(names.map((l) => [l, limbPolyline(limbs[l], places)]));
1933
+ // The joint line above each segment (null above the first).
1934
+ const lines = new Map<string, Array<JointLine | null>>();
1935
+ for (const l of names) {
1936
+ const segs = limbs[l].segments;
1937
+ lines.set(
1938
+ l,
1939
+ segs.map((sg, k) => {
1940
+ if (k === 0) return null;
1941
+ const up = places.get(segs[k - 1].bone) as BonePlace;
1942
+ const me = places.get(sg.bone) as BonePlace;
1943
+ const end = me.end ?? (k + 1 < segs.length ? (places.get(segs[k + 1].bone) as BonePlace).at : null);
1944
+ const line = jointLine(up.at, me.at, end);
1945
+ if (line === null) {
1946
+ problems.push({
1947
+ code: 'ASSEMBLE_LIMB_JOINT',
1948
+ object: `config.limbs.${l}.segments[${k}] (joint "${String(sg.joint)}")`,
1949
+ detail: `has no joint line: "${segs[k - 1].bone}" at [${up.at.join(', ')}] and "${sg.bone}" at [${me.at.join(', ')}]${end === null ? '' : ` ending at [${end.join(', ')}]`} ${up.at[0] === me.at[0] && up.at[1] === me.at[1] ? 'stand on one point, so the upper bone has no direction' : 'point straight back at each other, so the two directions cancel'}; bones that give the limb an axis at the joint are required`,
1950
+ });
1951
+ }
1952
+ return line;
1953
+ }),
1954
+ );
1955
+ }
1956
+ refuseIfAny(problems);
1957
+ // Each part a limb takes, in the order the limbs first take them.
1958
+ const sources = [...new Set(parts.map((q) => q.of))];
1959
+ const pieces: Array<Built | null> = parts.map(() => null);
1960
+ const rasters = parts.map(() => newRaster(W, H));
1961
+ const regions = parts.map(() => newMask(W, H));
1962
+ /** For each part taken, which limb (index into names) owns each pixel, -1 where it has none. */
1963
+ const owners = new Map<string, Int32Array>();
1964
+ const counts: LimbCount[] = [];
1965
+ for (const src of sources) {
1966
+ const b = builtOf(src);
1967
+ if (b === null) continue;
1968
+ const on = names.filter((l) => limbs[l].segments.some((sg) => sg.from === src));
1969
+ const own = new Int32Array(W * H).fill(-1);
1970
+ const before = opaqueOf(b.r);
1971
+ if (before === 0) {
1972
+ problems.push({ code: 'ASSEMBLE_LIMB_TAKES', object: `part "${src}" (config.limbs.${on[0]})`, detail: 'has no opaque pixel to cut; a part with art is required — the limb takes its pixels' });
1973
+ continue;
1974
+ }
1975
+ const lns = on.map((l) => polylines.get(l) as Array<[number, number]>);
1976
+ for (let y = 0; y < H; y++) {
1977
+ for (let x = 0; x < W; x++) {
1978
+ const p = y * W + x;
1979
+ if (b.r.data[p * 4 + 3] === 0) continue;
1980
+ const li = on.length === 1 ? 0 : nearestLimb(lns, x, y);
1981
+ const l = on[li];
1982
+ own[p] = names.indexOf(l);
1983
+ const segs = limbs[l].segments;
1984
+ const first = segs.findIndex((sg) => sg.from === src);
1985
+ let last = first;
1986
+ while (last + 1 < segs.length && segs[last + 1].from === src) last++;
1987
+ const k = segmentOf(lines.get(l) as Array<JointLine | null>, first, last, x, y);
1988
+ const at = parts.findIndex((q) => q.limb === l && q.segment === k);
1989
+ rasters[at].data.set(b.r.data.subarray(p * 4, p * 4 + 4), p * 4);
1990
+ if (b.region === null || b.region.data[p] === 1) regions[at].data[p] = 1;
1991
+ }
1992
+ }
1993
+ owners.set(src, own);
1994
+ const shares = parts.map((q, i) => ({ q, i })).filter(({ q }) => q.of === src);
1995
+ const counted = shares.map(({ q, i }) => ({ part: q.part, px: opaqueOf(rasters[i]) }));
1996
+ const sum = counted.reduce((a, c) => a + c.px, 0);
1997
+ if (sum !== before) {
1998
+ problems.push({ code: 'ASSEMBLE_COUNTS_ADD_UP', object: `part "${src}" (config.limbs)`, detail: `opaque px: ${before} before the limbs, ${sum} over their parts; equal is required (a bug in the assembler, not in the inputs)` });
1999
+ }
2000
+ for (const { q, i } of shares) {
2001
+ pieces[i] = { ...b, r: rasters[i], region: regions[i], merged: mergedIn(b, regions[i]) };
2002
+ if (count(regions[i], regions[i]) === 0 && opaqueOf(rasters[i]) === 0) {
2003
+ problems.push({
2004
+ code: 'ASSEMBLE_LIMB_PIXELS',
2005
+ object: `config.limbs.${q.limb}.segments[${q.segment}] (part "${q.part}")`,
2006
+ detail: `takes 0 of "${src}"'s ${before} opaque pixel(s); a segment with art is required — the joint lines through the bones' origins put none of the limb's pixels on this segment, or another limb's polyline lies nearer every one: move the bones onto the limb's art`,
2007
+ });
2008
+ }
2009
+ }
2010
+ counts.push({ of: src, before, shares: counted, joints: [] });
2011
+ }
2012
+ refuseIfAny(problems);
2013
+ // The caps, after every part is split, so a cap reads the limb's art in the layer as the painting has it.
2014
+ for (const l of names) {
2015
+ const segs = limbs[l].segments;
2016
+ segs.forEach((sg, k) => {
2017
+ if (k === 0) return;
2018
+ const line = (lines.get(l) as Array<JointLine | null>)[k] as JointLine;
2019
+ const li = names.indexOf(l);
2020
+ const upFrom = segs[k - 1].from;
2021
+ const art = (x: number, y: number): boolean => {
2022
+ if (x < 0 || y < 0 || x >= W || y >= H) return false;
2023
+ const p = y * W + x;
2024
+ for (const src of new Set([upFrom, sg.from])) {
2025
+ const b = builtOf(src);
2026
+ if (b !== null && owners.get(src)?.[p] === li && b.r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) return true;
2027
+ }
2028
+ return false;
2029
+ };
2030
+ const object = `config.limbs.${l}.segments[${k}] (joint "${String(sg.joint)}", part "${sg.part}")`;
2031
+ const width = limbWidth(line, art, [0, 0, W - 1, H - 1]);
2032
+ if (width === null) {
2033
+ problems.push({
2034
+ code: 'ASSEMBLE_LIMB_JOINT_ON_ART',
2035
+ object,
2036
+ detail: `the joint, "${sg.bone}"'s origin at [${line.at.join(', ')}], lies on none of the limb's art within 1 px along its joint line, so the limb has no width there to size the cap by; a bone origin on the limb's art (alpha above ${OPAQUE_ALPHA_ABOVE}) is required — move the bone onto the joint`,
2037
+ });
2038
+ return;
2039
+ }
2040
+ // The width to 3 places (src/round.ts): a measured length, and a radius the line and the record print as read.
2041
+ const w3 = pyRound(width, 3);
2042
+ const radius = w3 / 2;
2043
+ const box = capBox(line.at, radius);
2044
+ const at = parts.findIndex((q) => q.limb === l && q.segment === k);
2045
+ // Issue #359: the lower segment's part's hidden-art layer is its cap when the segment names no cap image; with one named, a refusal.
2046
+ const hidden = hiddenOf(sg.from);
2047
+ if (hidden !== null && sg.cap !== undefined) {
2048
+ problems.push({ code: 'ASSEMBLE_HIDDEN_ONE_SOURCE', object: `${object}.cap`, detail: `names ${sg.cap}, and "${sg.from}" also has the hidden-art layer ${hidden.layer}; one source of the cap's art is required — remove cap, or the hidden-art layer` });
2049
+ return;
2050
+ }
2051
+ const img = sg.cap === undefined ? (hidden === null ? null : boxOf(hidden.raster, box[0], box[1], box[2], box[3])) : (caps.get(`${l}/${k}`) ?? null);
2052
+ if (sg.cap !== undefined && img === null) {
2053
+ problems.push({ code: 'ASSEMBLE_LIMB_CAP_PRESENT', object: `${object}.cap`, detail: `names ${sg.cap}, and no image was handed to the stage for it; the decoded PNG is required (a caller of assemble() reads the file)` });
2054
+ return;
2055
+ }
2056
+ if (img !== null && (img.width !== box[2] || img.height !== box[3])) {
2057
+ problems.push({
2058
+ code: 'ASSEMBLE_LIMB_CAP_SIZE',
2059
+ object: `${object}.cap`,
2060
+ detail: `${String(sg.cap)} is ${img.width}x${img.height}; the cap's box is ${box[2]}x${box[3]} at ${box[0]},${box[1]} (the pixels within ${radius} px of the joint, in rig pixels), and an image of exactly that size is required — its pixel (i, j) is rig pixel (${box[0]} + i, ${box[1]} + j)`,
2061
+ });
2062
+ return;
2063
+ }
2064
+ const lower = rasters[at];
2065
+ const lowerSrc = builtOf(sg.from) as Built;
2066
+ const ownLower = owners.get(sg.from) as Int32Array;
2067
+ let layerPx = 0;
2068
+ let imagePx = 0;
2069
+ let missing = 0;
2070
+ for (let y = Math.max(0, box[1]); y < Math.min(H, box[1] + box[3]); y++) {
2071
+ for (let x = Math.max(0, box[0]); x < Math.min(W, box[0] + box[2]); x++) {
2072
+ if (!inDisc(line.at, radius, x, y) || pastJoint(line, x, y)) continue;
2073
+ const p = y * W + x;
2074
+ const u = img === null ? -1 : ((y - box[1]) * img.width + (x - box[0])) * 4;
2075
+ if (img !== null && img.data[u + 3] > 0) {
2076
+ lower.data.set(img.data.subarray(u, u + 4), p * 4);
2077
+ imagePx++;
2078
+ } else if (ownLower[p] === li && lowerSrc.r.data[p * 4 + 3] > 0) {
2079
+ lower.data.set(lowerSrc.r.data.subarray(p * 4, p * 4 + 4), p * 4);
2080
+ layerPx++;
2081
+ } else missing++;
2082
+ }
2083
+ }
2084
+ if (img !== null && imagePx === 0 && hidden === null) {
2085
+ problems.push({ code: 'ASSEMBLE_LIMB_CAP_EMPTY', object: `${object}.cap`, detail: `${String(sg.cap)} is transparent over every one of the cap's pixels; an image that gives at least one is required — to keep the layer's cap, drop cap` });
2086
+ return;
2087
+ }
2088
+ const cap: CapRecord = { at: [line.at[0], line.at[1]], width: w3, radius, box, layer_px: layerPx, ...(img === null ? {} : { image_px: imagePx }), ...(hidden === null ? {} : { image_layer: hidden.layer }), missing_px: missing };
2089
+ if (hidden !== null) readHidden(hidden.layer, `config.limbs.${l}.segments[${k}].cap (part "${sg.part}")`);
2090
+ parts[at].record.cap = cap;
2091
+ const c = counts.find((q) => q.of === sg.from);
2092
+ if (c !== undefined) c.joints.push({ limb: l, joint: String(sg.joint), part: sg.part, cap });
2093
+ });
2094
+ }
2095
+ refuseIfAny(problems);
2096
+ return { parts, pieces: pieces as Built[], counts };
2097
+ }
2098
+
1706
2099
  // ---------------------------------------------------------------------------
1707
2100
  // the stage
1708
2101
  // ---------------------------------------------------------------------------
@@ -1719,14 +2112,89 @@ export interface AssembleResult {
1719
2112
  figures: RecompositeFigures;
1720
2113
  /** What each `assemble.cuts` entry took, counted when it was made (`cutLines`); empty without cuts. */
1721
2114
  cuts: CutCount[];
2115
+ /** What each `config.limbs` limb cut, per part it took and per joint (`limbLines`); empty without limbs (issue #349). */
2116
+ limbs: LimbCount[];
1722
2117
  /** The full-run layers the plan does not take from the full run whose uncovered error holds a body (`uncoveredErrorLayers`); empty otherwise. */
1723
2118
  uncovered: UncoveredErrorLayer[];
1724
2119
  /** The parts whose `DRIFT_LIMIT` refusals hold a body another part holds in the draw order (`driftBodies`), in draw order; empty otherwise. */
1725
2120
  drift: DriftBodies[];
2121
+ /** Every hidden-art layer of the runs read (issue #359) and what read it (`hiddenLines`); empty when the runs hold none. */
2122
+ hidden: HiddenArtUse[];
1726
2123
  seamRule: SeamRule;
1727
2124
  projectRule: ProjectRule;
1728
2125
  }
1729
2126
 
2127
+ /** A hidden-art layer of a run (issue #359) and what read it: `cuts[i].under`, or a limb segment's cap; none is said. */
2128
+ export interface HiddenArtUse {
2129
+ /** `<run>:<layer>:hidden`. */
2130
+ layer: string;
2131
+ readBy: string[];
2132
+ }
2133
+
2134
+ /** A part's hidden art in rig space (issue #359): the layer's `<run>:<name>`, and the layer warped as its run's layers are. */
2135
+ interface HiddenArt {
2136
+ layer: string;
2137
+ raster: Raster;
2138
+ }
2139
+
2140
+ /**
2141
+ * The hidden art each part may read (issue #359, `HIDDEN_SUFFIX` in
2142
+ * `src/tags.ts`), by part name: a plan part takes the hidden-art layer of
2143
+ * the layer its row takes, from the same run, and a cut's piece takes its
2144
+ * base's. The layer is placed and warped into the rig exactly as its run's
2145
+ * drawn layers are (`placeLayer`, `layerToRig`) — no ghost clean-up, because
2146
+ * it is the artist's own art, not See-through's.
2147
+ */
2148
+ export function hiddenArtByPart(plan: readonly PlanEntry[], cuts: readonly Cut[], sets: { full: LayerSet; head: LayerSet }, frame: Frame, resolution: number): Map<string, HiddenArt> {
2149
+ const out = new Map<string, HiddenArt>();
2150
+ const warped = new Map<string, HiddenArt>();
2151
+ for (const [name, run, tag] of plan) {
2152
+ const h = sets[run].hidden.find((x) => x.of === tag);
2153
+ if (h === undefined) continue;
2154
+ const key = `${run}:${h.name}`;
2155
+ let art = warped.get(key);
2156
+ if (art === undefined) {
2157
+ art = { layer: key, raster: layerToRig(placeLayer(h, resolution), frame, run) };
2158
+ warped.set(key, art);
2159
+ }
2160
+ out.set(name, art);
2161
+ }
2162
+ for (const q of cuts) {
2163
+ const art = out.get(q.from);
2164
+ if (art !== undefined) out.set(q.into, art);
2165
+ }
2166
+ return out;
2167
+ }
2168
+
2169
+ /** `r`'s pixels over the box `[x0, y0, w, h]`, transparent where the box reaches past `r`. */
2170
+ function boxOf(r: Raster, x0: number, y0: number, w: number, h: number): Raster {
2171
+ const out = newRaster(w, h);
2172
+ for (let y = 0; y < h; y++) {
2173
+ const sy = y0 + y;
2174
+ if (sy < 0 || sy >= r.height) continue;
2175
+ for (let x = 0; x < w; x++) {
2176
+ const sx = x0 + x;
2177
+ if (sx < 0 || sx >= r.width) continue;
2178
+ out.data.set(r.data.subarray((sy * r.width + sx) * 4, (sy * r.width + sx) * 4 + 4), (y * w + x) * 4);
2179
+ }
2180
+ }
2181
+ return out;
2182
+ }
2183
+
2184
+ /**
2185
+ * One line per hidden-art layer (issue #359), in the runs' order:
2186
+ * ` hidden art: full:topwear:hidden -> cuts[0].under (part "tail")`, or,
2187
+ * read by nothing, the two places that would read it. No line when the runs
2188
+ * hold none, so an input without one prints what it printed before.
2189
+ */
2190
+ export function hiddenLines(uses: readonly HiddenArtUse[]): string[] {
2191
+ return uses.map((u) =>
2192
+ u.readBy.length > 0
2193
+ ? ` hidden art: ${u.layer} -> ${u.readBy.join(', ')}`
2194
+ : ` hidden art: ${u.layer} -> read by nothing: no cut takes a piece of its layer's part and no limb segment below a joint takes that part, so it fills no under and no cap`,
2195
+ );
2196
+ }
2197
+
1730
2198
  function tagsOf(set: LayerSet): Set<string> {
1731
2199
  return new Set(set.layers.map((l) => l.name));
1732
2200
  }
@@ -1760,28 +2228,90 @@ export function checkPlanAgainstRuns(plan: PlanEntry[], extend: Extend[], full:
1760
2228
  refuseIfAny(problems);
1761
2229
  }
1762
2230
 
2231
+ /**
2232
+ * What a single run (issue #359, no `--head`) is held to, every refusal at
2233
+ * once:
2234
+ *
2235
+ * - `ASSEMBLE_SINGLE_RUN_FACE`: the run holds no layer with an opaque pixel
2236
+ * for one of {@link SINGLE_RUN_FACE_TAGS} — the parts the head run exists
2237
+ * to separate. One problem per tag missing.
2238
+ * - `ASSEMBLE_SINGLE_RUN_PLAN`: a plan row takes the head run, or an
2239
+ * `extend_below_crop` entry continues a part below a head crop. There is
2240
+ * no head run and no crop; every row takes `full`.
2241
+ *
2242
+ * `plan` and `extend` are null for `--propose-plan`, which writes them.
2243
+ */
2244
+ export function checkSingleRun(full: LayerSet, plan: readonly PlanEntry[] | null, extend: readonly Extend[] | null): void {
2245
+ const problems: Problem[] = [];
2246
+ for (const tag of SINGLE_RUN_FACE_TAGS) {
2247
+ const l = full.layers.find((x) => x.name === tag);
2248
+ if (l === undefined || l.opaquePx === 0) {
2249
+ problems.push({
2250
+ code: 'ASSEMBLE_SINGLE_RUN_FACE',
2251
+ object: `the full run (${full.source})`,
2252
+ detail: `${l === undefined ? `holds no layer "${tag}"` : `holds a layer "${tag}" with no opaque pixel (alpha above ${OPAQUE_ALPHA_ABOVE})`}; with no --head, one run must hold the face parts — ${SINGLE_RUN_FACE_TAGS.join(', ')}, each painted — or a head run (--head) is required`,
2253
+ });
2254
+ }
2255
+ }
2256
+ (plan ?? []).forEach(([name, run], i) => {
2257
+ if (run === 'head') problems.push({ code: 'ASSEMBLE_SINGLE_RUN_PLAN', object: `config.assemble.plan[${i}] (part "${name}")`, detail: 'takes the head run, and with no --head there is none; "full" is required — the one run holds every layer' });
2258
+ });
2259
+ (extend ?? []).forEach((e, i) => {
2260
+ problems.push({ code: 'ASSEMBLE_SINGLE_RUN_PLAN', object: `config.assemble.extend_below_crop[${i}] (part "${e.part}")`, detail: 'continues a part below the head crop, and with no --head there is no crop; no extend_below_crop entry is required — remove it' });
2261
+ });
2262
+ refuseIfAny(problems);
2263
+ }
2264
+
1763
2265
  /**
1764
2266
  * Run the stage in memory. Everything that can be refused is refused before
1765
2267
  * this returns; nothing here writes a file, so a caller that writes only
1766
2268
  * after this returned writes only after green.
1767
2269
  */
1768
2270
  export function assemble(input: AssembleInput): AssembleResult {
1769
- const { source, full, head, plan, extend, patches, cuts, seamRule, projectRule } = input;
1770
- const unders = cuts.map((q, i) => input.under?.[i] ?? null);
2271
+ const { source, plan, extend, patches, seamRule, projectRule } = input;
2272
+ const single = input.single === true;
1771
2273
  const frame = checkGeometry(
1772
- { sourceW: source.width, sourceH: source.height, resolution: input.resolution, headBox: input.headBox, rigScale: input.rigScale },
1773
- { full, head },
2274
+ { sourceW: source.width, sourceH: source.height, resolution: input.resolution, headBox: input.headBox, rigScale: input.rigScale, ...(single ? { single: true } : {}) },
2275
+ { full: input.full, head: input.head },
1774
2276
  );
2277
+ // Issue #366: one run's file, padded to the resolution square; read as both runs, as before.
2278
+ const full = single ? padSingleRun(input.full, frame, input.resolution) : input.full;
2279
+ const head = single ? full : input.head;
2280
+ const padRecord = singleRunPadRecord(single, source.width, source.height, input.resolution, frame);
2281
+ if (single) checkSingleRun(full, plan, extend);
1775
2282
  checkPlanAgainstRuns(plan, extend, full, head);
1776
2283
  const { W, H } = frame;
1777
2284
  const srcr = sourceInRig(source, W, H);
2285
+ // Issue #359: a part's hidden-art layer is the under of a cut that names none — and, with one named, a refusal, since
2286
+ // reading either would be a guess. Its uses are counted for `hiddenLines`.
2287
+ const hiddenArt = hiddenArtByPart(plan, input.cuts, { full, head }, frame, input.resolution);
2288
+ const hiddenUses = new Map<string, string[]>();
2289
+ for (const [run, set] of single ? ([['full', full]] as const) : ([['full', full], ['head', head]] as const)) for (const h of set.hidden) hiddenUses.set(`${run}:${h.name}`, []);
2290
+ const hiddenProblems: Problem[] = [];
2291
+ const unders = input.cuts.map((q, i) => {
2292
+ const art = hiddenArt.get(q.from);
2293
+ if (art === undefined) return input.under?.[i] ?? null;
2294
+ if (q.under !== undefined) {
2295
+ hiddenProblems.push({ code: 'ASSEMBLE_HIDDEN_ONE_SOURCE', object: `config.assemble.cuts[${i}].under (part "${q.into}")`, detail: `names ${q.under}, and "${q.from}" also has the hidden-art layer ${art.layer}; one source of what lies under the piece is required — remove under, or the hidden-art layer` });
2296
+ return null;
2297
+ }
2298
+ const box = underBox(q.polygon);
2299
+ hiddenUses.get(art.layer)?.push(`cuts[${i}].under (part "${q.into}")`);
2300
+ return boxOf(art.raster, box.x0, box.y0, box.w, box.h);
2301
+ });
2302
+ const cuts = input.cuts.map((q): Cut => {
2303
+ const art = hiddenArt.get(q.from);
2304
+ return art === undefined || q.under !== undefined ? q : { ...q, under: `the hidden-art layer ${art.layer}` };
2305
+ });
2306
+ refuseIfAny(hiddenProblems);
1778
2307
 
1779
2308
  const rig = new Map<string, Raster>();
1780
2309
  const stats = new Map<string, ProjectionStats>();
1781
2310
  const ghost: Record<string, number> = {};
1782
2311
  /** The full run's layers with an opaque pixel, in draw order: the candidates `uncoveredErrorLayers` reads. */
1783
2312
  const fullTags: string[] = [];
1784
- for (const [run, set] of [['full', full], ['head', head]] as const) {
2313
+ // A single run (issue #359) is read once: there is no head run to place, project or count ghosts for.
2314
+ for (const [run, set] of single ? ([['full', full]] as const) : ([['full', full], ['head', head]] as const)) {
1785
2315
  const layers = runLayers(set, input.resolution);
1786
2316
  if (run === 'full') fullTags.push(...layers.filter((l) => l.opaqueRunPx > 0).map((l) => l.tag));
1787
2317
  const rigLayers = layers.map((l) => layerToRig(l.canvas, frame, run));
@@ -1874,16 +2404,6 @@ export function assemble(input: AssembleInput): AssembleResult {
1874
2404
  });
1875
2405
  refuseIfAny(patchProblems);
1876
2406
 
1877
- interface Built {
1878
- key: string;
1879
- r: Raster;
1880
- st: ProjectionStats;
1881
- extendKeys: string[];
1882
- trace: MergeTrace;
1883
- merged: number;
1884
- /** 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. */
1885
- region: Mask | null;
1886
- }
1887
2407
  const built: Array<Built | null> = plan.map(() => null);
1888
2408
  const empty: Problem[] = [];
1889
2409
  /** The part's alpha box and its `alpha > 8` count, over the whole rig. */
@@ -2044,7 +2564,9 @@ export function assemble(input: AssembleInput): AssembleResult {
2044
2564
  if (u === null) return undefined;
2045
2565
  // Filled where the image is at least as opaque as the layer was; left empty elsewhere (`fillUnder`).
2046
2566
  const f = fillUnder(b.r, (pieces[i] as Built).r, cutBand(cutMasks[i], cuts[i].overlap), u, underBox(cuts[i].polygon));
2047
- if (f.filled === 0) {
2567
+ // A hidden-art layer (issue #359) is the whole layer's covered art, read by every cut of its part: a cut it holds nothing
2568
+ // under fills 0, which the cut line counts, and is not refused as a file made for this one cut would be.
2569
+ if (f.filled === 0 && input.cuts[i].under !== undefined) {
2048
2570
  empty.push({
2049
2571
  code: 'ASSEMBLE_UNDER_EMPTY',
2050
2572
  object: `config.assemble.cuts[${i}].under (part "${cuts[i].into}")`,
@@ -2066,9 +2588,18 @@ export function assemble(input: AssembleInput): AssembleResult {
2066
2588
  refuseIfAny(empty);
2067
2589
  // In cut order, as the config lists them.
2068
2590
  cutCounts.sort((a, b) => cuts.findIndex((q) => q.into === a.into) - cuts.findIndex((q) => q.into === b.into));
2069
- const order = order0;
2070
- const pieceAt = (d: { plan: number } | { cut: number }): { name: string; b: Built } =>
2071
- '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 };
2591
+
2592
+ // Issue #349: the limbs, after the cuts (a limb may take a cut's piece, and a cut's under image fills its base first).
2593
+ const lim = cutLimbs(input.limbs ?? {}, input.bones ?? [], input.caps ?? new Map(), W, H, (name) => {
2594
+ const pi = plan.findIndex((e) => e[0] === name);
2595
+ if (pi >= 0) return built[pi];
2596
+ const ci = cuts.findIndex((q) => q.into === name);
2597
+ return ci >= 0 ? pieces[ci] : null;
2598
+ }, opaqueOf, mergedIn, count, (name) => hiddenArt.get(name) ?? null, (layer, by) => hiddenUses.get(layer)?.push(by));
2599
+ const limbPieces = lim.pieces;
2600
+ const order = limbOrder(order0, plan, cuts, lim.parts);
2601
+ const pieceAt = (d: { plan: number } | { cut: number } | { limb: number }): { name: string; b: Built } =>
2602
+ 'plan' in d ? { name: plan[d.plan][0], b: built[d.plan] as Built } : 'cut' in d ? { name: cuts[d.cut].into, b: pieces[d.cut] as Built } : { name: lim.parts[d.limb].part, b: limbPieces[d.limb] as Built };
2072
2603
 
2073
2604
  // Step 4b: push back a fringe the painting shows another part through, over
2074
2605
  // the plan parts still uncropped and the patches, in draw order.
@@ -2098,20 +2629,26 @@ export function assemble(input: AssembleInput): AssembleResult {
2098
2629
  });
2099
2630
  const pushedOf: FringePushed[][] = plan.map(() => []);
2100
2631
  const pushedOfCut: FringePushed[][] = cuts.map(() => []);
2632
+ const pushedOfLimb: FringePushed[][] = lim.parts.map(() => []);
2101
2633
  order.forEach((d, i) => {
2102
2634
  if ('plan' in d) pushedOf[d.plan] = pushedBy[i];
2103
2635
  if ('cut' in d) pushedOfCut[d.cut] = pushedBy[i];
2636
+ if ('limb' in d) pushedOfLimb[d.limb] = pushedBy[i];
2104
2637
  });
2105
2638
 
2106
2639
  const planned: Array<PlacedPart | null> = plan.map(() => null);
2107
2640
  const cutPlaced: Array<PlacedPart | null> = cuts.map(() => null);
2108
2641
  // A cut's piece records the plan part it was cut from (issue #230): parts.json is in draw order, so a piece drawn
2109
2642
  // "back" comes before its base, and nothing else in the file says which of the two is the piece.
2110
- const pieceList: Array<{ name: string; b: Built; pushed: FringePushed[]; at: string; cutFrom: string | null; set: (p: PlacedPart) => void }> = [
2643
+ const limbPlaced: Array<PlacedPart | null> = lim.parts.map(() => null);
2644
+ // A part a limb takes is not made itself (issue #349): its pixels are the limb's parts'.
2645
+ const taken = new Set(lim.parts.map((q) => q.of));
2646
+ const pieceList: Array<{ name: string; b: Built; pushed: FringePushed[]; at: string; cutFrom: string | null; limb?: LimbRecord; set: (p: PlacedPart) => void }> = [
2111
2647
  ...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) })),
2112
2648
  ...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) })),
2113
- ];
2114
- pieceList.forEach(({ name, b, pushed: pushedHere, at, cutFrom, set }) => {
2649
+ ...lim.parts.map((q, i) => ({ name: q.part, b: limbPieces[i] as Built, pushed: pushedOfLimb[i], at: `config.limbs.${q.limb}.segments[${q.segment}]`, cutFrom: null, limb: q.record, set: (pp: PlacedPart) => (limbPlaced[i] = pp) })),
2650
+ ].filter((q) => !taken.has(q.name));
2651
+ pieceList.forEach(({ name, b, pushed: pushedHere, at, cutFrom, limb, set }) => {
2115
2652
  const { key, r, st, extendKeys, trace, merged, region } = b;
2116
2653
  // A pixel the merge wrote is judged in the extend layer's run, every other
2117
2654
  // in the part's own. Projected = the colour came from projectSource's
@@ -2157,10 +2694,11 @@ export function assemble(input: AssembleInput): AssembleResult {
2157
2694
  seam_override_px: 0,
2158
2695
  };
2159
2696
  if (pushedHere.length > 0) record.fringe_pushed_back = pushedHere;
2697
+ if (limb !== undefined) record.limb = limb;
2160
2698
  set({ record, image: crop(r, x0, y0, record.w, record.h) });
2161
2699
  });
2162
2700
  refuseIfAny(empty);
2163
- const placed: PlacedPart[] = order.map((d) => ('plan' in d ? planned[d.plan] : 'cut' in d ? cutPlaced[d.cut] : cut[d.patch]) as PlacedPart);
2701
+ const placed: PlacedPart[] = order.map((d) => ('plan' in d ? planned[d.plan] : 'cut' in d ? cutPlaced[d.cut] : 'limb' in d ? limbPlaced[d.limb] : cut[d.patch]) as PlacedPart);
2164
2702
 
2165
2703
  seamOverride(placed, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
2166
2704
  const can = recomposite(placed, W, H);
@@ -2182,15 +2720,25 @@ export function assemble(input: AssembleInput): AssembleResult {
2182
2720
  bodyMinPx(frame),
2183
2721
  );
2184
2722
  return {
2185
- parts: { rig_size: [W, H], scale_rig_per_source: frame.S, parts: placed.map((p) => p.record), ghost_px: ghost, recomposite: recompositeRecord(figures), ...(sceneRecord === null ? {} : { scene: sceneRecord }) },
2723
+ parts: {
2724
+ rig_size: [W, H],
2725
+ scale_rig_per_source: frame.S,
2726
+ parts: placed.map((p) => p.record),
2727
+ ghost_px: ghost,
2728
+ recomposite: recompositeRecord(figures),
2729
+ ...(sceneRecord === null ? {} : { scene: sceneRecord }),
2730
+ ...(padRecord === null ? {} : { single_run_pad: padRecord }),
2731
+ },
2186
2732
  images: placed,
2187
2733
  recomposite: can,
2188
2734
  errorMap: recompositeErrorMap(can, srcr, placed, inside),
2189
2735
  figure: inside === null ? null : figureImage(inside, W, H),
2190
2736
  figures,
2191
2737
  cuts: cutCounts,
2738
+ limbs: lim.counts,
2192
2739
  uncovered,
2193
2740
  drift,
2741
+ hidden: [...hiddenUses].map(([layer, readBy]) => ({ layer, readBy })),
2194
2742
  seamRule,
2195
2743
  projectRule,
2196
2744
  };
@@ -2663,8 +3211,11 @@ function fmt0(v: number): string {
2663
3211
  * before; each gains one such note per tag left out (demo 6, scarf 9,
2664
3212
  * reference-demo 7, reference-sample 9).
2665
3213
  */
2666
- export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
2667
- const frame = checkGeometry(g, { full, head });
3214
+ export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
3215
+ const frame = checkGeometry(g, { full: full0, head: head0 });
3216
+ // Issue #366: one run's file, padded to the resolution square as the stage pads it.
3217
+ const full = g.single === true ? padSingleRun(full0, frame, g.resolution) : full0;
3218
+ const head = g.single === true ? full : head0;
2668
3219
  const runs = { full: runLayers(full, g.resolution), head: runLayers(head, g.resolution) };
2669
3220
  const find = (run: Run, tag: string): RunLayer | undefined => runs[run].find((l) => l.tag === tag);
2670
3221
  const figures: Record<Run, LayerFigures[]> = { full: layerFigures(full), head: layerFigures(head) };
@@ -2791,6 +3342,27 @@ export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx =
2791
3342
  return { plan, extend_below_crop: extend, notes: [...notes, ...bodyNotes, ...unplanned, ...dropNotes] };
2792
3343
  }
2793
3344
 
3345
+ /**
3346
+ * `--propose-plan` over one run (issue #359): the run is held to
3347
+ * {@link checkSingleRun}, proposed as both runs (`proposePlan(full, full)`,
3348
+ * whose two maps are one when `g` is {@link singleRunHeadBox}'s), and every
3349
+ * row the proposer drafts from the head run is written from the full run —
3350
+ * there is no other. No row is dropped and none moves: the plan is the
3351
+ * proposer's, in its order. The first note says so, because the notes after
3352
+ * it still speak of a head run, meaning this same file.
3353
+ */
3354
+ export function proposeSinglePlan(full: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
3355
+ checkSingleRun(full, null, null);
3356
+ const p = proposePlan(full, full, { ...g, single: true }, minPx);
3357
+ const plan = p.plan.map(([name, , tag]): PlanEntry => [name, 'full', tag]);
3358
+ return {
3359
+ plan,
3360
+ extend_below_crop: p.extend_below_crop,
3361
+ // The proposer's note offering a head-run row for an author-made layer both runs hold is about two runs; with one it offers a row the stage refuses.
3362
+ notes: [`one run: no --head, so ${full.source} is read as both runs; every row is written from the full run, and "the head run" in a note below is this same file`, ...p.notes.filter((n) => !n.includes(': the head run holds a layer of this name too'))],
3363
+ };
3364
+ }
3365
+
2794
3366
  /** What every `--propose-plan` note on a layer in no row says its pixel figures are. */
2795
3367
  export const UNPLANNED_PX = 'px: run pixels of alpha above 8 after ghost clean-up';
2796
3368
 
@@ -2878,7 +3450,10 @@ function unplannedNotes(
2878
3450
  * the loader, because `inputs` runs before it exists; `seethrough` too, for a
2879
3451
  * full-loader config, where it is optional.
2880
3452
  */
2881
- export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble' | 'scene'>): {
3453
+ export function stageFields(
3454
+ cfg: Pick<CharacterConfig, 'seethrough' | 'assemble' | 'scene'> & { limbs?: Record<string, Limb>; bones?: BoneEntry[] },
3455
+ single: { w: number; h: number } | null = null,
3456
+ ): {
2882
3457
  resolution: number;
2883
3458
  headBox: [number, number, number, number];
2884
3459
  rigScale: number;
@@ -2887,24 +3462,30 @@ export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble'
2887
3462
  patches: Patch[];
2888
3463
  cuts: Cut[];
2889
3464
  scene?: boolean;
3465
+ limbs?: Record<string, Limb>;
3466
+ bones?: BoneEntry[];
3467
+ single?: boolean;
2890
3468
  } {
2891
3469
  const problems: Problem[] = [];
2892
3470
  if (cfg.seethrough === undefined) {
2893
3471
  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' });
2894
- } else if (cfg.seethrough.head_box === undefined) {
3472
+ } else if (single !== null) problems.push(...singleRunHeadBoxProblems(cfg.seethrough));
3473
+ else if (cfg.seethrough.head_box === undefined) {
2895
3474
  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' });
2896
3475
  }
2897
3476
  refuseIfAny(problems);
2898
3477
  const st = cfg.seethrough as SeeThrough;
2899
3478
  return {
2900
3479
  resolution: st.resolution,
2901
- headBox: st.head_box as [number, number, number, number],
3480
+ headBox: single !== null ? singleRunHeadBox(single.w, single.h, st.resolution) : (st.head_box as [number, number, number, number]),
3481
+ ...(single !== null ? { single: true } : {}),
2902
3482
  rigScale: cfg.assemble.rig_scale,
2903
3483
  plan: cfg.assemble.plan,
2904
3484
  extend: cfg.assemble.extend_below_crop ?? [],
2905
3485
  patches: cfg.assemble.patches ?? [],
2906
3486
  cuts: cfg.assemble.cuts ?? [],
2907
3487
  ...(cfg.scene === undefined ? {} : { scene: true }),
3488
+ ...(cfg.limbs === undefined ? {} : { limbs: cfg.limbs, bones: cfg.bones ?? [] }),
2908
3489
  };
2909
3490
  }
2910
3491
 
@@ -2915,9 +3496,29 @@ export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble'
2915
3496
  * loader's rules; what is left here is that this stage needs the head box,
2916
3497
  * which that loader leaves optional.
2917
3498
  */
2918
- export function proposeFields(cfg: EarlyConfig): { resolution: number; headBox: [number, number, number, number]; rigScale: number } {
3499
+ export function proposeFields(cfg: EarlyConfig, single: { w: number; h: number } | null = null): { resolution: number; headBox: [number, number, number, number]; rigScale: number; single?: boolean } {
3500
+ if (single !== null) {
3501
+ refuseIfAny(singleRunHeadBoxProblems(cfg.seethrough));
3502
+ return { resolution: cfg.seethrough.resolution, headBox: singleRunHeadBox(single.w, single.h, cfg.seethrough.resolution), rigScale: cfg.assemble.rig_scale, single: true };
3503
+ }
2919
3504
  if (cfg.seethrough.head_box === undefined) {
2920
3505
  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' }]);
2921
3506
  }
2922
3507
  return { resolution: cfg.seethrough.resolution, headBox: cfg.seethrough.head_box as [number, number, number, number], rigScale: cfg.assemble.rig_scale };
2923
3508
  }
3509
+
3510
+ /**
3511
+ * A single run's config (issue #359) sets no `seethrough.head_box`: the box
3512
+ * places a head run cut from the painting, and with no `--head` there is none,
3513
+ * so a box there would be a value nothing reads (`ASSEMBLE_SINGLE_RUN_HEAD_BOX`).
3514
+ */
3515
+ function singleRunHeadBoxProblems(st: Pick<SeeThrough, 'head_box'>): Problem[] {
3516
+ if (st.head_box === undefined) return [];
3517
+ return [
3518
+ {
3519
+ code: 'ASSEMBLE_SINGLE_RUN_HEAD_BOX',
3520
+ object: 'config.seethrough.head_box',
3521
+ detail: `is [${st.head_box.join(', ')}], and with no --head there is no head run for it to place; no head_box is required with one run — remove it, or give the head run with --head`,
3522
+ },
3523
+ ];
3524
+ }