@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.
- package/README.md +8 -3
- package/cli.ts +172 -18
- package/docs/AUTHORING.md +178 -6
- package/docs/MIGRATION.md +52 -0
- package/docs/PAINTING.md +172 -0
- package/docs/STABILITY.md +7 -4
- package/package.json +2 -1
- package/skills/rig-parts/SKILL.md +28 -1
- package/src/assemble.ts +324 -25
- package/src/build.ts +32 -9
- package/src/inputs.ts +11 -1
- package/src/layers.ts +231 -18
- package/src/paintcheck.ts +250 -0
- package/src/parts.ts +81 -5
- package/src/propose.ts +11 -1
- package/src/tags.ts +47 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
2062
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
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(
|
|
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
|
|
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
|
+
}
|