@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/README.md +8 -3
- package/cli.ts +191 -24
- package/docs/AUTHORING.md +381 -21
- package/docs/MIGRATION.md +52 -0
- package/docs/PAINTING.md +172 -0
- package/docs/STABILITY.md +14 -9
- package/package.json +2 -1
- package/skills/rig-parts/SKILL.md +31 -2
- package/src/assemble.ts +642 -41
- package/src/autoamplitude.ts +23 -3
- package/src/autoenvelope.ts +17 -5
- package/src/build.ts +109 -13
- package/src/check.ts +632 -11
- package/src/config.ts +185 -8
- package/src/inputs.ts +11 -1
- package/src/layers.ts +231 -18
- package/src/limbs.ts +188 -0
- package/src/paintcheck.ts +250 -0
- package/src/parts.ts +167 -6
- package/src/propose.ts +148 -108
- package/src/rig.ts +43 -8
- package/src/tags.ts +47 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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[] = []):
|
|
1552
|
-
const out:
|
|
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,
|
|
1770
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
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(
|
|
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
|
|
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
|
+
}
|