@rig-c/parts 1.17.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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: rig-parts
3
- description: Turn one anime character painting and its See-through layer decomposition into a verified 2D character rig (Spine 4.3 skeleton data) — measured parts, weighted meshes, bone chains and a looping idle — with rig-parts, which gates everything through rig-c before it is written. Use for a request to rig or animate a single character painting, to run or read rig-parts layers, sheet, assemble, propose, rig, check, loop or build, or to write or fix a rig-parts config.json. Not for cutting art into parts by hand, for authoring a rig from loose PNGs (that is the rigc skill), for lip-sync or swapping a part's image, or for running See-through itself.
3
+ description: Turn one anime character painting and its See-through layer decomposition into a verified 2D character rig (Spine 4.3 skeleton data) — measured parts, weighted meshes, bone chains and a looping idle — with rig-parts, which gates everything through rig-c before it is written. Use for a request to rig or animate a single character painting, to check a painting before See-through (rig-parts painting), to run or read rig-parts layers, sheet, assemble, propose, rig, check, loop or build, or to write or fix a rig-parts config.json. Not for cutting art into parts by hand, for authoring a rig from loose PNGs (that is the rigc skill), for lip-sync or swapping a part's image, or for running See-through itself.
4
4
  license: MIT
5
5
  compatibility: Requires Bun 1.2 or later. The tool is the npm package @rig-c/parts (npm i -D @rig-c/parts, or bun add -d @rig-c/parts), whose command is rig-parts; rig-c is installed with it. See-through is run separately.
6
6
  ---
@@ -58,6 +58,15 @@ under that.
58
58
 
59
59
  ## The loop
60
60
 
61
+ 0. Before See-through, check the painting: `rig-parts painting --keypoints
62
+ <k.json> <painting.png>` with the figure's body-18 joints (the file
63
+ `propose --keypoints` reads, from an estimator or the painting — never a joint
64
+ you guessed). A `FAIL PAINTING_…` line names what a rig cannot be cut from —
65
+ an arm against the body, hands together, legs with no gap, a joint off the
66
+ figure, a background that is not plain — and the fix is a new painting, not a
67
+ config field. `docs/PAINTING.md` lists every requirement, the ones only an eye
68
+ reads (front view, feet, a cape over an elbow), and one generation recipe;
69
+ report those as not checked.
61
70
  1. `rig-parts inputs --source <painting> --config config.json --out <dir>` writes
62
71
  `st_input_full.png`, the painting on a white square. The config needs only `key`,
63
72
  `seethrough` and `assemble.rig_scale` at this point. Run See-through on it — any
@@ -114,6 +123,7 @@ under that.
114
123
  (rigc refuses the pair the link's control splits, and the line names the flag
115
124
  on the command that ran; AUTHORING §3).
116
125
  7. `rig-parts build --config … --source … --full … --head … --out out --loop`
126
+ (no `--head` for an artist's own file, below)
117
127
  (add `--idle-keys direct` when step 6 said so).
118
128
  It stops at the first stage that refuses, printing that stage's lines under
119
129
  `[assemble]`, `[rig]` or `[check]`. Fix what the FAIL line names and build again.
@@ -127,6 +137,23 @@ under that.
127
137
  last: `check/build/skeleton.json`, `skeleton.atlas` and the packed page. Report
128
138
  them with the pack line and the check figures verbatim.
129
139
 
140
+ An artist's own layered PSD (issue #359; AUTHORING §4, *For artists*): one run,
141
+ no See-through, no `--head` and no `head_box` in the config. The file must hold
142
+ `face`, `eyewhite-r` and `eyewhite-l`, painted (`ASSEMBLE_SINGLE_RUN_FACE`
143
+ otherwise); its stacking is the draw order and it carries no depth, so `layers`
144
+ prints `SKIP depth:` and the turn draft says `turn: SKIP` — report those as
145
+ skipped, never as passed. `rig-parts layers --template art.psd --canvas <W>x<H>`
146
+ writes the empty layers an artist paints into. A layer named `<layer>:hidden` is
147
+ that layer's art where something covers it (the forearm under a sleeve): read as a
148
+ cut's `under` and a limb segment's cap, never drawn; a `hidden art:` line names
149
+ what read it. To correct a parse, `rig-parts layers --export work art.psd` writes
150
+ the same layers as `layers.json` and one PNG each; move pixels between the PNGs
151
+ (grow a PNG and its box in `layers.json` together), then pass `--full work` and
152
+ read `recomposite vs source` and the error map again (AUTHORING §4, *The
153
+ correction loop*). A patch in the wrong layer shows as error only where the
154
+ painting is near-white; elsewhere it shows as `drift=`/`seam=` on the part drawn
155
+ over it.
156
+
130
157
  Several characters in one rig: build each one green on its own, then
131
158
  `rig-parts compose --scene scene.json --out scene` (spec `spine-parts-scene/1`:
132
159
  the canvas, each character's `build` and `offset`, an optional plate with its
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
@@ -94,8 +100,8 @@
94
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 { type CapRecord, type LimbRecord, 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';
99
105
  import { type BonePlace, bonePlaces, capBox, inDisc, type JointLine, jointLine, limbPolyline, limbWidth, nearestLimb, pastJoint, segmentOf } from './limbs.ts';
100
106
  import {
101
107
  alphaComposite,
@@ -214,6 +220,72 @@ export interface Geometry {
214
220
  headBox: [number, number, number, number];
215
221
  /** `assemble.rig_scale`: rig pixels per source pixel. */
216
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 };
217
289
  }
218
290
 
219
291
  export interface AssembleInput {
@@ -247,6 +319,8 @@ export interface AssembleInput {
247
319
  bones?: BoneEntry[];
248
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`). */
249
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;
250
324
  }
251
325
 
252
326
  /** The derived numbers of the two runs' geometry. */
@@ -275,23 +349,43 @@ export function checkGeometry(g: Geometry, runs?: { full: LayerSet; head: LayerS
275
349
  problems.push({ code, object, detail });
276
350
  };
277
351
  const { sourceW: w, sourceH: h, resolution, headBox, rigScale: S } = g;
278
- 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);
279
354
  const W = Math.trunc(w * S);
280
355
  const H = Math.trunc(h * S);
281
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`);
282
357
  const [x0, y0, x1, y1] = headBox;
283
- 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)) {
284
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`);
285
361
  }
286
362
  const headBottom = Math.trunc(y1 * S) - HEAD_BOTTOM_INSET;
287
- if (headBottom - CROP_PROBE_ROWS < 0 && y1 > y0) {
363
+ if (g.single !== true && headBottom - CROP_PROBE_ROWS < 0 && y1 > y0) {
288
364
  fail(
289
365
  'ASSEMBLE_HEAD_BOX_INSIDE',
290
366
  'config.seethrough.head_box',
291
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`,
292
368
  );
293
369
  }
294
- 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) {
295
389
  for (const run of ['full', 'head'] as const) {
296
390
  const c = runs[run].canvas;
297
391
  if (c.w !== resolution || c.h !== resolution) {
@@ -321,7 +415,7 @@ export function checkGeometry(g: Geometry, runs?: { full: LayerSet; head: LayerS
321
415
  * Place a layer's pixels on its run's `resolution x resolution` canvas.
322
416
  * `readWrapperLayers`/`readPsdLayers` already refused a box outside it.
323
417
  */
324
- export function placeLayer(layer: Layer, resolution: number): Raster {
418
+ export function placeLayer(layer: Pick<Layer, 'pixels' | 'left' | 'top'>, resolution: number): Raster {
325
419
  const out = newRaster(resolution, resolution);
326
420
  const p = layer.pixels;
327
421
  for (let y = 0; y < p.height; y++) {
@@ -1759,7 +1853,7 @@ export function limbLines(counts: readonly LimbCount[]): string[] {
1759
1853
  out.push(` limb cut: "${c.of}" opaque ${c.before} = ${c.shares.map((q) => `"${q.part}" ${q.px}`).join(' + ')}`);
1760
1854
  for (const j of c.joints) {
1761
1855
  const k = j.cap;
1762
- const from = k.image_px === undefined ? `${k.layer_px} px from the layer` : `${k.image_px} px from the cap image, ${k.layer_px} px from the layer`;
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`;
1763
1857
  const miss =
1764
1858
  k.missing_px === 0
1765
1859
  ? 'none missing'
@@ -1822,6 +1916,8 @@ function cutLimbs(
1822
1916
  opaqueOf: (r: Raster) => number,
1823
1917
  mergedIn: (b: Built, region: Mask) => number,
1824
1918
  count: (m: Mask | null, region: Mask) => number,
1919
+ hiddenOf: (part: string) => HiddenArt | null = () => null,
1920
+ readHidden: (layer: string, by: string) => void = () => undefined,
1825
1921
  ): { parts: LimbPart[]; pieces: Built[]; counts: LimbCount[] } {
1826
1922
  const names = Object.keys(limbs);
1827
1923
  if (names.length === 0) return { parts: [], pieces: [], counts: [] };
@@ -1946,7 +2042,13 @@ function cutLimbs(
1946
2042
  const radius = w3 / 2;
1947
2043
  const box = capBox(line.at, radius);
1948
2044
  const at = parts.findIndex((q) => q.limb === l && q.segment === k);
1949
- const img = sg.cap === undefined ? null : (caps.get(`${l}/${k}`) ?? null);
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);
1950
2052
  if (sg.cap !== undefined && img === null) {
1951
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)` });
1952
2054
  return;
@@ -1979,11 +2081,12 @@ function cutLimbs(
1979
2081
  } else missing++;
1980
2082
  }
1981
2083
  }
1982
- if (img !== null && imagePx === 0) {
2084
+ if (img !== null && imagePx === 0 && hidden === null) {
1983
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` });
1984
2086
  return;
1985
2087
  }
1986
- const cap: CapRecord = { at: [line.at[0], line.at[1]], width: w3, radius, box, layer_px: layerPx, ...(img === null ? {} : { image_px: imagePx }), missing_px: missing };
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}")`);
1987
2090
  parts[at].record.cap = cap;
1988
2091
  const c = counts.find((q) => q.of === sg.from);
1989
2092
  if (c !== undefined) c.joints.push({ limb: l, joint: String(sg.joint), part: sg.part, cap });
@@ -2015,10 +2118,83 @@ export interface AssembleResult {
2015
2118
  uncovered: UncoveredErrorLayer[];
2016
2119
  /** The parts whose `DRIFT_LIMIT` refusals hold a body another part holds in the draw order (`driftBodies`), in draw order; empty otherwise. */
2017
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[];
2018
2123
  seamRule: SeamRule;
2019
2124
  projectRule: ProjectRule;
2020
2125
  }
2021
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
+
2022
2198
  function tagsOf(set: LayerSet): Set<string> {
2023
2199
  return new Set(set.layers.map((l) => l.name));
2024
2200
  }
@@ -2052,28 +2228,90 @@ export function checkPlanAgainstRuns(plan: PlanEntry[], extend: Extend[], full:
2052
2228
  refuseIfAny(problems);
2053
2229
  }
2054
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
+
2055
2265
  /**
2056
2266
  * Run the stage in memory. Everything that can be refused is refused before
2057
2267
  * this returns; nothing here writes a file, so a caller that writes only
2058
2268
  * after this returned writes only after green.
2059
2269
  */
2060
2270
  export function assemble(input: AssembleInput): AssembleResult {
2061
- const { source, full, head, plan, extend, patches, cuts, seamRule, projectRule } = input;
2062
- 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;
2063
2273
  const frame = checkGeometry(
2064
- { sourceW: source.width, sourceH: source.height, resolution: input.resolution, headBox: input.headBox, rigScale: input.rigScale },
2065
- { 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 },
2066
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);
2067
2282
  checkPlanAgainstRuns(plan, extend, full, head);
2068
2283
  const { W, H } = frame;
2069
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);
2070
2307
 
2071
2308
  const rig = new Map<string, Raster>();
2072
2309
  const stats = new Map<string, ProjectionStats>();
2073
2310
  const ghost: Record<string, number> = {};
2074
2311
  /** The full run's layers with an opaque pixel, in draw order: the candidates `uncoveredErrorLayers` reads. */
2075
2312
  const fullTags: string[] = [];
2076
- 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)) {
2077
2315
  const layers = runLayers(set, input.resolution);
2078
2316
  if (run === 'full') fullTags.push(...layers.filter((l) => l.opaqueRunPx > 0).map((l) => l.tag));
2079
2317
  const rigLayers = layers.map((l) => layerToRig(l.canvas, frame, run));
@@ -2326,7 +2564,9 @@ export function assemble(input: AssembleInput): AssembleResult {
2326
2564
  if (u === null) return undefined;
2327
2565
  // Filled where the image is at least as opaque as the layer was; left empty elsewhere (`fillUnder`).
2328
2566
  const f = fillUnder(b.r, (pieces[i] as Built).r, cutBand(cutMasks[i], cuts[i].overlap), u, underBox(cuts[i].polygon));
2329
- 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) {
2330
2570
  empty.push({
2331
2571
  code: 'ASSEMBLE_UNDER_EMPTY',
2332
2572
  object: `config.assemble.cuts[${i}].under (part "${cuts[i].into}")`,
@@ -2355,7 +2595,7 @@ export function assemble(input: AssembleInput): AssembleResult {
2355
2595
  if (pi >= 0) return built[pi];
2356
2596
  const ci = cuts.findIndex((q) => q.into === name);
2357
2597
  return ci >= 0 ? pieces[ci] : null;
2358
- }, opaqueOf, mergedIn, count);
2598
+ }, opaqueOf, mergedIn, count, (name) => hiddenArt.get(name) ?? null, (layer, by) => hiddenUses.get(layer)?.push(by));
2359
2599
  const limbPieces = lim.pieces;
2360
2600
  const order = limbOrder(order0, plan, cuts, lim.parts);
2361
2601
  const pieceAt = (d: { plan: number } | { cut: number } | { limb: number }): { name: string; b: Built } =>
@@ -2480,7 +2720,15 @@ export function assemble(input: AssembleInput): AssembleResult {
2480
2720
  bodyMinPx(frame),
2481
2721
  );
2482
2722
  return {
2483
- 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
+ },
2484
2732
  images: placed,
2485
2733
  recomposite: can,
2486
2734
  errorMap: recompositeErrorMap(can, srcr, placed, inside),
@@ -2490,6 +2738,7 @@ export function assemble(input: AssembleInput): AssembleResult {
2490
2738
  limbs: lim.counts,
2491
2739
  uncovered,
2492
2740
  drift,
2741
+ hidden: [...hiddenUses].map(([layer, readBy]) => ({ layer, readBy })),
2493
2742
  seamRule,
2494
2743
  projectRule,
2495
2744
  };
@@ -2962,8 +3211,11 @@ function fmt0(v: number): string {
2962
3211
  * before; each gains one such note per tag left out (demo 6, scarf 9,
2963
3212
  * reference-demo 7, reference-sample 9).
2964
3213
  */
2965
- export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
2966
- 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;
2967
3219
  const runs = { full: runLayers(full, g.resolution), head: runLayers(head, g.resolution) };
2968
3220
  const find = (run: Run, tag: string): RunLayer | undefined => runs[run].find((l) => l.tag === tag);
2969
3221
  const figures: Record<Run, LayerFigures[]> = { full: layerFigures(full), head: layerFigures(head) };
@@ -3090,6 +3342,27 @@ export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx =
3090
3342
  return { plan, extend_below_crop: extend, notes: [...notes, ...bodyNotes, ...unplanned, ...dropNotes] };
3091
3343
  }
3092
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
+
3093
3366
  /** What every `--propose-plan` note on a layer in no row says its pixel figures are. */
3094
3367
  export const UNPLANNED_PX = 'px: run pixels of alpha above 8 after ghost clean-up';
3095
3368
 
@@ -3177,7 +3450,10 @@ function unplannedNotes(
3177
3450
  * the loader, because `inputs` runs before it exists; `seethrough` too, for a
3178
3451
  * full-loader config, where it is optional.
3179
3452
  */
3180
- export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble' | 'scene'> & { limbs?: Record<string, Limb>; bones?: BoneEntry[] }): {
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
+ ): {
3181
3457
  resolution: number;
3182
3458
  headBox: [number, number, number, number];
3183
3459
  rigScale: number;
@@ -3188,18 +3464,21 @@ export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble'
3188
3464
  scene?: boolean;
3189
3465
  limbs?: Record<string, Limb>;
3190
3466
  bones?: BoneEntry[];
3467
+ single?: boolean;
3191
3468
  } {
3192
3469
  const problems: Problem[] = [];
3193
3470
  if (cfg.seethrough === undefined) {
3194
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' });
3195
- } 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) {
3196
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' });
3197
3475
  }
3198
3476
  refuseIfAny(problems);
3199
3477
  const st = cfg.seethrough as SeeThrough;
3200
3478
  return {
3201
3479
  resolution: st.resolution,
3202
- 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 } : {}),
3203
3482
  rigScale: cfg.assemble.rig_scale,
3204
3483
  plan: cfg.assemble.plan,
3205
3484
  extend: cfg.assemble.extend_below_crop ?? [],
@@ -3217,9 +3496,29 @@ export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble'
3217
3496
  * loader's rules; what is left here is that this stage needs the head box,
3218
3497
  * which that loader leaves optional.
3219
3498
  */
3220
- 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
+ }
3221
3504
  if (cfg.seethrough.head_box === undefined) {
3222
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' }]);
3223
3506
  }
3224
3507
  return { resolution: cfg.seethrough.resolution, headBox: cfg.seethrough.head_box as [number, number, number, number], rigScale: cfg.assemble.rig_scale };
3225
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
+ }