@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.
@@ -0,0 +1,250 @@
1
+ /**
2
+ * `rig-parts painting`: what a painting must be before See-through runs on it,
3
+ * read off the painting and its body-18 joints alone (issue #350).
4
+ *
5
+ * A painting with the arms against the body, the hands together or the legs
6
+ * painted as one column is found today at the far end of the pipeline — a
7
+ * sleeve layer that holds the torso, a leg chain that drags the other leg, a
8
+ * dance clip that tears. Every one of those is decidable at minute one from two
9
+ * things: where the figure is (the painting's pixels) and where its joints are
10
+ * (the keypoint file `propose --keypoints` reads, `src/keypoints.ts`). This
11
+ * module decides them, and `docs/PAINTING.md` says why each one matters.
12
+ *
13
+ * **Definitions, each one a sentence and no tuned number:**
14
+ *
15
+ * - **Ground** is a pixel the repository already calls background: alpha at or
16
+ * below {@link OPAQUE_ALPHA_ABOVE} (not painted, `src/layers.ts`), or every
17
+ * colour channel above {@link NEAR_WHITE_MIN} (near-white, the one test the
18
+ * assemble stage and the layer plausibility figures share).
19
+ * - **The background** is the ground that reaches the image's edge: every
20
+ * ground pixel 4-connected to a ground pixel on the border. A white highlight
21
+ * inside the figure is ground and not background, because line art closes it
22
+ * off; so a blouse that is white does not read as a hole in the figure.
23
+ * - **The figure** is every pixel that is not background.
24
+ * - **A gap between two joints** is at least one background pixel on the
25
+ * straight line between them, sampled at `n + 1` evenly spaced points with
26
+ * `n = max(ceil(max(|dx|, |dy|)), 1)` — at least one point per pixel crossed
27
+ * — each read at the pixel that holds it (floor, the image's right and bottom
28
+ * edges taken as the last column and row).
29
+ *
30
+ * **The rules, each a refusal by name:**
31
+ *
32
+ * - `PAINTING_BACKGROUND_PLAIN` — every border pixel is ground. A plain light
33
+ * background is what makes the figure readable at all; a border pixel that is
34
+ * not ground is scenery, a floor, a gradient, or the figure cut by the frame.
35
+ * - `PAINTING_JOINTS_STATED` — the eight joints the gap rules read (wrists,
36
+ * hips, knees, ankles) are `observed` with a position. An occluded wrist is a
37
+ * hand the painting does not show.
38
+ * - `PAINTING_JOINT_ON_FIGURE` — every observed joint's pixel is figure, not
39
+ * background. A joint off the figure is a keypoint file for another painting,
40
+ * or a limb the painting does not hold where the file says.
41
+ * - `PAINTING_ARM_CLEAR` — on each side, a gap between the wrist and the hip
42
+ * of the same side. None is an arm painted against the body.
43
+ * - `PAINTING_HANDS_APART` — a gap between the two wrists. None is hands
44
+ * together: clasped, or touching.
45
+ * - `PAINTING_LEGS_APART` — a gap between the two knees and a gap between the
46
+ * two ankles. None is legs painted as one column at that joint.
47
+ *
48
+ * Pure: the caller reads the files and hands over a raster and a person.
49
+ */
50
+ import type { Problem } from './errors.ts';
51
+ import type { KeypointPerson } from './keypoints.ts';
52
+ import { NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE } from './layers.ts';
53
+ import type { Raster } from './raster/types.ts';
54
+ import type { KeypointName } from './skeleton.ts';
55
+
56
+ /** The joints the gap rules read, in the order a refusal lists them. */
57
+ export const PAINTING_JOINTS: readonly KeypointName[] = ['r_wrist', 'l_wrist', 'r_hip', 'l_hip', 'r_knee', 'l_knee', 'r_ankle', 'l_ankle'];
58
+
59
+ /** The four gap rules' pairs: the rule, the two joints, and what the pair is called in a line. */
60
+ export const PAINTING_GAPS: ReadonlyArray<readonly [string, KeypointName, KeypointName, string]> = [
61
+ ['PAINTING_ARM_CLEAR', 'r_wrist', 'r_hip', 'right arm (r_wrist to r_hip)'],
62
+ ['PAINTING_ARM_CLEAR', 'l_wrist', 'l_hip', 'left arm (l_wrist to l_hip)'],
63
+ ['PAINTING_HANDS_APART', 'r_wrist', 'l_wrist', 'hands (r_wrist to l_wrist)'],
64
+ ['PAINTING_LEGS_APART', 'r_knee', 'l_knee', 'knees (r_knee to l_knee)'],
65
+ ['PAINTING_LEGS_APART', 'r_ankle', 'l_ankle', 'ankles (r_ankle to l_ankle)'],
66
+ ];
67
+
68
+ /** What each gap rule requires, in the words its refusal ends with. */
69
+ const GAP_REQUIRED: Readonly<Record<string, string>> = {
70
+ PAINTING_ARM_CLEAR: 'the arm painted clear of the body, so the line from the wrist to the hip of its side crosses background',
71
+ PAINTING_HANDS_APART: 'the hands painted apart, so the line between the wrists crosses background',
72
+ PAINTING_LEGS_APART: 'the legs painted apart, so the line between the two joints crosses background',
73
+ };
74
+
75
+ /** One gap reading: the pair, the line's length and samples, and how many of them are background. */
76
+ export interface GapReading {
77
+ rule: string;
78
+ a: KeypointName;
79
+ b: KeypointName;
80
+ what: string;
81
+ from: [number, number];
82
+ to: [number, number];
83
+ samples: number;
84
+ background: number;
85
+ }
86
+
87
+ export interface PaintingReading {
88
+ width: number;
89
+ height: number;
90
+ /** Pixels on the border, counted once each. */
91
+ border: number;
92
+ /** Border pixels that are not ground, and the first one in raster order. */
93
+ borderNotGround: number;
94
+ firstBorderNotGround: [number, number] | null;
95
+ backgroundPx: number;
96
+ figurePx: number;
97
+ /** Every observed joint, its pixel, and whether that pixel is figure. */
98
+ joints: Array<{ name: KeypointName; at: [number, number]; pixel: [number, number]; onFigure: boolean }>;
99
+ /** The gap readings whose two joints are both observed with a position. */
100
+ gaps: GapReading[];
101
+ }
102
+
103
+ /** Whether pixel `i` of `r` is ground: not painted, or near-white in every channel. */
104
+ function isGround(r: Raster, i: number): boolean {
105
+ const d = r.data;
106
+ const o = i * 4;
107
+ return d[o + 3] <= OPAQUE_ALPHA_ABOVE || (d[o] > NEAR_WHITE_MIN && d[o + 1] > NEAR_WHITE_MIN && d[o + 2] > NEAR_WHITE_MIN);
108
+ }
109
+
110
+ /** The background: every ground pixel 4-connected to a ground pixel on the border. One byte per pixel, 1 = background. */
111
+ export function backgroundMask(r: Raster): Uint8Array {
112
+ const { width: w, height: h } = r;
113
+ const bg = new Uint8Array(w * h);
114
+ const stack: number[] = [];
115
+ const seed = (i: number): void => {
116
+ if (bg[i] === 0 && isGround(r, i)) {
117
+ bg[i] = 1;
118
+ stack.push(i);
119
+ }
120
+ };
121
+ for (let x = 0; x < w; x++) {
122
+ seed(x);
123
+ seed((h - 1) * w + x);
124
+ }
125
+ for (let y = 0; y < h; y++) {
126
+ seed(y * w);
127
+ seed(y * w + w - 1);
128
+ }
129
+ while (stack.length > 0) {
130
+ const i = stack.pop() as number;
131
+ const x = i % w;
132
+ const y = (i - x) / w;
133
+ if (x > 0) seed(i - 1);
134
+ if (x < w - 1) seed(i + 1);
135
+ if (y > 0) seed(i - w);
136
+ if (y < h - 1) seed(i + w);
137
+ }
138
+ return bg;
139
+ }
140
+
141
+ /** The pixel holding a continuous painting point: floor, the right and bottom edges taken as the last column and row. */
142
+ export function pixelOf(at: readonly [number, number], w: number, h: number): [number, number] {
143
+ return [Math.min(Math.floor(at[0]), w - 1), Math.min(Math.floor(at[1]), h - 1)];
144
+ }
145
+
146
+ /** The pixels the line from `a` to `b` is sampled at: `n + 1` evenly spaced points, `n = max(ceil(max(|dx|, |dy|)), 1)`. */
147
+ export function linePixels(a: readonly [number, number], b: readonly [number, number], w: number, h: number): Array<[number, number]> {
148
+ const dx = b[0] - a[0];
149
+ const dy = b[1] - a[1];
150
+ const n = Math.max(Math.ceil(Math.max(Math.abs(dx), Math.abs(dy))), 1);
151
+ const out: Array<[number, number]> = [];
152
+ for (let i = 0; i <= n; i++) out.push(pixelOf([a[0] + (dx * i) / n, a[1] + (dy * i) / n], w, h));
153
+ return out;
154
+ }
155
+
156
+ /** Read the painting against the person's joints: every figure the rules decide on, before any rule decides. */
157
+ export function readPainting(r: Raster, person: KeypointPerson): PaintingReading {
158
+ const { width: w, height: h } = r;
159
+ const bg = backgroundMask(r);
160
+ let border = 0;
161
+ let borderNotGround = 0;
162
+ let firstBorderNotGround: [number, number] | null = null;
163
+ for (let y = 0; y < h; y++) {
164
+ for (let x = 0; x < w; x++) {
165
+ if (y !== 0 && y !== h - 1 && x !== 0 && x !== w - 1) continue;
166
+ border++;
167
+ if (!isGround(r, y * w + x)) {
168
+ borderNotGround++;
169
+ firstBorderNotGround ??= [x, y];
170
+ }
171
+ }
172
+ }
173
+ let backgroundPx = 0;
174
+ for (const v of bg) backgroundPx += v;
175
+ const joints: PaintingReading['joints'] = [];
176
+ for (const [name, j] of Object.entries(person.joints) as Array<[KeypointName, KeypointPerson['joints'][KeypointName]]>) {
177
+ if (j.state !== 'observed' || j.at === null) continue;
178
+ const pixel = pixelOf(j.at, w, h);
179
+ joints.push({ name, at: j.at, pixel, onFigure: bg[pixel[1] * w + pixel[0]] === 0 });
180
+ }
181
+ const gaps: GapReading[] = [];
182
+ for (const [rule, a, b, what] of PAINTING_GAPS) {
183
+ const ja = person.joints[a];
184
+ const jb = person.joints[b];
185
+ if (ja.state !== 'observed' || jb.state !== 'observed' || ja.at === null || jb.at === null) continue;
186
+ const line = linePixels(ja.at, jb.at, w, h);
187
+ let background = 0;
188
+ for (const [x, y] of line) background += bg[y * w + x];
189
+ gaps.push({ rule, a, b, what, from: ja.at, to: jb.at, samples: line.length, background });
190
+ }
191
+ return { width: w, height: h, border, borderNotGround, firstBorderNotGround, backgroundPx, figurePx: w * h - backgroundPx, joints, gaps };
192
+ }
193
+
194
+ const pt = (p: readonly [number, number]): string => `[${p[0]}, ${p[1]}]`;
195
+
196
+ /** The refusals a reading earns, every one at once, in the order the module's comment lists the rules. */
197
+ export function paintingProblems(reading: PaintingReading, person: KeypointPerson, painting: string, keypoints: string): Problem[] {
198
+ const problems: Problem[] = [];
199
+ if (reading.borderNotGround > 0) {
200
+ const f = reading.firstBorderNotGround as [number, number];
201
+ problems.push({
202
+ code: 'PAINTING_BACKGROUND_PLAIN',
203
+ object: `painting ${painting}`,
204
+ detail: `${reading.borderNotGround} of its ${reading.border} border pixels are not background-coloured, the first at ${pt(f)}; every border pixel near-white (each channel above ${NEAR_WHITE_MIN}) or unpainted (alpha ${OPAQUE_ALPHA_ABOVE} or less) is required — a plain light background, with the whole figure inside the frame`,
205
+ });
206
+ }
207
+ for (const n of PAINTING_JOINTS) {
208
+ const j = person.joints[n];
209
+ if (j.state === 'observed' && j.at !== null) continue;
210
+ problems.push({
211
+ code: 'PAINTING_JOINTS_STATED',
212
+ object: `${keypoints} person "${person.id}" joint ${n}`,
213
+ detail: `is ${j.listed ? j.state : 'not listed'}${j.at === null ? '' : ` at ${pt(j.at)}`}; observed with a position is required — the gap rules read ${PAINTING_JOINTS.join(', ')}, and a joint the painting does not show is a limb it cannot rig`,
214
+ });
215
+ }
216
+ for (const j of reading.joints) {
217
+ if (j.onFigure) continue;
218
+ problems.push({
219
+ code: 'PAINTING_JOINT_ON_FIGURE',
220
+ object: `${keypoints} person "${person.id}" joint ${j.name}`,
221
+ detail: `is at ${pt(j.at)}, and pixel ${pt(j.pixel)} is background (ground connected to the painting's edge); a joint on the figure is required — a file measured on another painting, or a limb that is not where the file says`,
222
+ });
223
+ }
224
+ for (const g of reading.gaps) {
225
+ if (g.background > 0) continue;
226
+ problems.push({
227
+ code: g.rule,
228
+ object: `painting ${painting} ${g.what}`,
229
+ detail: `the line from ${pt(g.from)} to ${pt(g.to)} crosses no background: 0 of its ${g.samples} sampled pixels; ${GAP_REQUIRED[g.rule]}`,
230
+ });
231
+ }
232
+ return problems;
233
+ }
234
+
235
+ /** The lines a reading prints, before any refusal: the figure, every joint and every gap, each with its numbers. */
236
+ export function paintingLines(reading: PaintingReading): string[] {
237
+ const out = [
238
+ ` painting ${reading.width}x${reading.height}: background ${reading.backgroundPx} px, figure ${reading.figurePx} px; border ${reading.border} px, ${reading.borderNotGround} not background-coloured`,
239
+ ];
240
+ const on = reading.joints.filter((j) => j.onFigure).length;
241
+ out.push(` joints: ${reading.joints.length} observed, ${on} on the figure${on === reading.joints.length ? '' : ` (off: ${reading.joints.filter((j) => !j.onFigure).map((j) => j.name).join(', ')})`}`);
242
+ for (const g of reading.gaps) out.push(` gap ${g.what}: ${g.background} of ${g.samples} sampled pixels are background`);
243
+ return out;
244
+ }
245
+
246
+ /** Read and judge: the lines to print and every refusal, which the caller prints before it throws them at once. */
247
+ export function checkPainting(r: Raster, person: KeypointPerson, painting: string, keypoints: string): { lines: string[]; problems: Problem[] } {
248
+ const reading = readPainting(r, person);
249
+ return { lines: paintingLines(reading), problems: paintingProblems(reading, person, painting, keypoints) };
250
+ }
package/src/parts.ts CHANGED
@@ -62,6 +62,10 @@
62
62
  * per source-painting pixel, and `ghost_px` the sub-threshold specks removed
63
63
  * from each `<run>:<tag>` layer before anything else.
64
64
  *
65
+ * `single_run_pad` (issue #366), written only for one run whose file is not
66
+ * the `seethrough.resolution` square, says where the file sat on the run's
67
+ * square ({@link SingleRunPad}); the rig itself holds no pad.
68
+ *
65
69
  * `recomposite` is the flat stack of every part against the painting (this
66
70
  * port's, issue #25; a `parts.json` the reference wrote has none and reads as
67
71
  * it did): the four figures the assemble summary prints, the limits they were
@@ -140,11 +144,13 @@ export interface CapRecord {
140
144
  box: [number, number, number, number];
141
145
  layer_px: number;
142
146
  image_px?: number;
147
+ /** Issue #359: with `image_px`, the hidden-art layer the cap's image came from (`<run>:<layer>:hidden`), when it came from one rather than the config's cap file. */
148
+ image_layer?: string;
143
149
  missing_px: number;
144
150
  }
145
151
 
146
152
  const LIMB_KEYS = ['name', 'segment', 'of', 'bone', 'joint', 'cap'] as const;
147
- const CAP_KEYS = ['at', 'width', 'radius', 'box', 'layer_px', 'image_px', 'missing_px'] as const;
153
+ const CAP_KEYS = ['at', 'width', 'radius', 'box', 'layer_px', 'image_px', 'image_layer', 'missing_px'] as const;
148
154
 
149
155
  /** The limb record with its keys in the fixed order, so two runs write the same bytes. */
150
156
  function orderedLimb(l: LimbRecord): Record<string, unknown> {
@@ -184,6 +190,27 @@ export interface PartsFile {
184
190
  recomposite?: RecompositeRecord;
185
191
  /** Written only under `config.scene` (issue #242): the recomposite was read inside this figure; absent otherwise. */
186
192
  scene?: SceneRecord;
193
+ /** Written only for one run whose file is not the resolution square (issue #366): where the file sat on the run's square; absent otherwise. */
194
+ single_run_pad?: SingleRunPad;
195
+ }
196
+
197
+ /**
198
+ * `parts.json`'s `single_run_pad` block (issue #366): one run (no `--head`)
199
+ * whose file — the artist's PSD or its wrapper export — is not the
200
+ * `seethrough.resolution` square was padded to it, centred, before it was
201
+ * read: `file_size` is the file's `[w, h]` (and the painting's), `side` the
202
+ * square's, `left` and `top` the margins (`floor((side - w) / 2)` and
203
+ * `floor((side - h) / 2)`, `squarePad`'s centring; the right and the bottom
204
+ * take the remainder). Run pixel `(x, y)` is file pixel `(x - left, y - top)`,
205
+ * and the rig is the file times `scale_rig_per_source` with no pad in it, so a
206
+ * part's box over the scale is its box in the file. Absent for two runs and
207
+ * for a file that is the square, whose run pixel is its file pixel.
208
+ */
209
+ export interface SingleRunPad {
210
+ file_size: [number, number];
211
+ side: number;
212
+ left: number;
213
+ top: number;
187
214
  }
188
215
 
189
216
  /**
@@ -199,8 +226,9 @@ export interface SceneRecord {
199
226
  figure_mask: string;
200
227
  }
201
228
 
202
- const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene'] as const;
203
- const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene');
229
+ const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene', 'single_run_pad'] as const;
230
+ const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene' && k !== 'single_run_pad');
231
+ export const SINGLE_RUN_PAD_KEYS = ['file_size', 'side', 'left', 'top'] as const;
204
232
  export const SCENE_KEYS = ['figure_alpha_above', 'figure_px', 'figure_mask'] as const;
205
233
  export const RECOMPOSITE_KEYS = ['mean_abs', 'within_limit', 'within_share', 'error_limit', 'error_px', 'covered_alpha', 'uncovered_error_px', 'hole_count', 'holes_listed', 'holes'] as const;
206
234
  const RECOMPOSITE_COUNTS = ['within_limit', 'error_limit', 'error_px', 'covered_alpha', 'uncovered_error_px', 'hole_count', 'holes_listed'] as const;
@@ -275,6 +303,7 @@ export function serializeParts(file: PartsFile): string {
275
303
  },
276
304
  }),
277
305
  ...(file.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (file.scene as SceneRecord)[k]])) }),
306
+ ...(file.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (file.single_run_pad as SingleRunPad)[k]])) }),
278
307
  };
279
308
  return `${JSON.stringify(ordered, null, 2)}\n`;
280
309
  }
@@ -454,8 +483,9 @@ function checkParts(raw: unknown, path: string): PartsFile {
454
483
  pt(co.box, 4) &&
455
484
  isCount(co.layer_px) &&
456
485
  isCount(co.missing_px) &&
457
- (!('image_px' in co) || isCount(co.image_px));
458
- if (!ok) fail('PARTS_FIELD_TYPE', `${object} "cap"`, `is ${show(c)}; {at: [x, y], width, radius, box: [x, y, w, h], layer_px, image_px?, missing_px} is required`);
486
+ (!('image_px' in co) || isCount(co.image_px)) &&
487
+ (!('image_layer' in co) || ('image_px' in co && typeof co.image_layer === 'string' && co.image_layer !== ''));
488
+ if (!ok) fail('PARTS_FIELD_TYPE', `${object} "cap"`, `is ${show(c)}; {at: [x, y], width, radius, box: [x, y, w, h], layer_px, image_px?, image_layer? (with image_px), missing_px} is required`);
459
489
  }
460
490
  }
461
491
  }
@@ -471,6 +501,7 @@ function checkParts(raw: unknown, path: string): PartsFile {
471
501
  if (!('recomposite' in o)) fail('PARTS_FIELD_PRESENT', `${path} field "recomposite"`, 'is absent; a scene block says how the recomposite block beside it was measured, so it is required with one');
472
502
  }
473
503
  }
504
+ if ('single_run_pad' in o) checkSingleRunPad(o.single_run_pad, path, sizeOk ? (size as [number, number]) : null, typeof scale === 'number' ? scale : null, fail);
474
505
  if ('recomposite' in o && sizeOk) {
475
506
  const names = Array.isArray(o.parts) ? new Set(o.parts.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : undefined)).filter((n): n is string => typeof n === 'string')) : new Set<string>();
476
507
  checkRecomposite(o.recomposite, path, size as [number, number], names, fail);
@@ -481,6 +512,51 @@ function checkParts(raw: unknown, path: string): PartsFile {
481
512
 
482
513
  const isCount = (v: unknown): v is number => Number.isInteger(v) && (v as number) >= 0;
483
514
 
515
+ /**
516
+ * The `single_run_pad` block (issue #366): every key known and present, each
517
+ * an integer of its kind, and the block consistent with its definition and
518
+ * with the rig — the margins are the centring of `file_size` on `side`
519
+ * (`PARTS_SINGLE_RUN_PAD`, with the margins that centring gives), no side of
520
+ * the file above `side`, and `rig_size` the file times `scale_rig_per_source`,
521
+ * truncated, as `assemble` makes it. A record that disagrees would map a part
522
+ * back to somewhere else in the file.
523
+ */
524
+ function checkSingleRunPad(r: unknown, path: string, size: [number, number] | null, scale: number | null, fail: (code: string, object: string, detail: string) => void): void {
525
+ const at = `${path} field "single_run_pad"`;
526
+ if (typeof r !== 'object' || r === null || Array.isArray(r)) {
527
+ fail('PARTS_FIELD_TYPE', at, `is ${show(r)}; an object is required`);
528
+ return;
529
+ }
530
+ const o = r as Record<string, unknown>;
531
+ for (const k of Object.keys(o)) if (!(SINGLE_RUN_PAD_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${path} single_run_pad key "${k}"`, `is not a single_run_pad field; known: ${SINGLE_RUN_PAD_KEYS.join(', ')}`);
532
+ for (const k of SINGLE_RUN_PAD_KEYS) if (!(k in o)) fail('PARTS_FIELD_PRESENT', `${path} single_run_pad field "${k}"`, 'is absent and required');
533
+ const fs = o.file_size;
534
+ const fsOk = Array.isArray(fs) && fs.length === 2 && fs.every((n) => Number.isInteger(n) && (n as number) > 0);
535
+ if ('file_size' in o && !fsOk) fail('PARTS_FIELD_TYPE', `${path} single_run_pad field "file_size"`, `is ${show(fs)}; [width, height] in positive integers is required`);
536
+ const sideOk = Number.isInteger(o.side) && (o.side as number) > 0;
537
+ if ('side' in o && !sideOk) fail('PARTS_FIELD_TYPE', `${path} single_run_pad field "side"`, `is ${show(o.side)}; a positive integer is required`);
538
+ for (const k of ['left', 'top'] as const) if (k in o && !isCount(o[k])) fail('PARTS_FIELD_TYPE', `${path} single_run_pad field "${k}"`, `is ${show(o[k])}; a non-negative integer is required`);
539
+ if (!fsOk || !sideOk || !isCount(o.left) || !isCount(o.top)) return;
540
+ const [w, h] = fs as [number, number];
541
+ const side = o.side as number;
542
+ if (w > side || h > side) {
543
+ fail('PARTS_SINGLE_RUN_PAD', at, `file_size is ${w}x${h} and side ${side}; a file no side of which is above the square it was padded to is required`);
544
+ return;
545
+ }
546
+ if (w === side && h === side) {
547
+ fail('PARTS_SINGLE_RUN_PAD', at, `file_size is ${w}x${h}, the ${side} square itself; a file that is the square has no pad, and no single_run_pad is written for it`);
548
+ return;
549
+ }
550
+ const left = Math.floor((side - w) / 2);
551
+ const top = Math.floor((side - h) / 2);
552
+ if (o.left !== left || o.top !== top) {
553
+ fail('PARTS_SINGLE_RUN_PAD', at, `has left ${String(o.left)}, top ${String(o.top)}; a ${w}x${h} file centred on a ${side} square is padded left ${left}, top ${top} (each margin floors, the right and the bottom take the remainder), and those are required`);
554
+ }
555
+ if (size !== null && scale !== null && (Math.trunc(w * scale) !== size[0] || Math.trunc(h * scale) !== size[1])) {
556
+ fail('PARTS_SINGLE_RUN_PAD', at, `file_size ${w}x${h} times scale_rig_per_source ${scale} makes a ${Math.trunc(w * scale)}x${Math.trunc(h * scale)} rig, and rig_size is ${size[0]}x${size[1]}; the rig is the file at that scale, so they must agree`);
557
+ }
558
+ }
559
+
484
560
  /**
485
561
  * The `recomposite` block: every key known and present, every figure its
486
562
  * type, and the counts consistent with one another — the listed holes are the
package/src/propose.ts CHANGED
@@ -470,6 +470,7 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
470
470
  const read: Array<{ part: string; run: 'full' | 'head'; tag: string; base: string | null; own: number; area: number; order: number; piece: boolean }> = [];
471
471
  const patches: string[] = [];
472
472
  const unread = new Map<string, string[]>();
473
+ const depthless: Array<{ part: string; from: string; set: LayerSet }> = [];
473
474
  P.recs.forEach((r, order) => {
474
475
  const [run, tag] = splitFrom(r.from);
475
476
  if (run === PAINTING_RUN) {
@@ -487,7 +488,7 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
487
488
  return;
488
489
  }
489
490
  if (layer.depth === null) {
490
- problems.push({ code: 'PROPOSE_TURN_DEPTH_PRESENT', object: `part "${r.name}" (${r.from})`, detail: `its layer in ${set.source} carries no depth_median (a ${set.form} input); the turn orders parts by See-through's per-layer depth, so a run that carries one is required` });
491
+ depthless.push({ part: r.name, from: r.from, set });
491
492
  return;
492
493
  }
493
494
  read.push({ part: r.name, run: run as 'full' | 'head', tag, base: 'base' in layer.tag ? layer.tag.base : null, own: layer.depth, area: r.opaque_px, order, piece: r.cut_from !== undefined });
@@ -495,6 +496,15 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
495
496
  for (const [run, names] of unread) {
496
497
  problems.push({ code: 'PROPOSE_TURN_RUN_GIVEN', object: `the ${run} run`, detail: `part(s) ${names.map((n) => `"${n}"`).join(', ')} came from it and its layers were not given; --${run} <dir | layers.json> is required to read their depth` });
497
498
  }
499
+ // Issue #359: runs that carry no depth at all (a PSD, or an export of one) are skipped by name, never passed and never
500
+ // given an invented depth; a run where some parts' layers carry one and others do not is refused part by part.
501
+ if (problems.length === 0 && read.length === 0 && depthless.length > 0) {
502
+ const sources = [...new Set(depthless.map((d) => d.set.source))];
503
+ return { turn: null, notes: [`turn: SKIP — ${sources.join(', ')} carr${sources.length === 1 ? 'ies' : 'y'} no depth_median (${[...new Set(depthless.map((d) => (d.set.form === 'psd' ? 'a PSD carries none' : 'null on every layer')))].join('; ')}), so no turn control is drafted from ${depthless.length} part(s); the turn orders parts by See-through's per-layer depth, and none was measured — a config.turn written by hand sets one`] };
504
+ }
505
+ for (const d of depthless) {
506
+ problems.push({ code: 'PROPOSE_TURN_DEPTH_PRESENT', object: `part "${d.part}" (${d.from})`, detail: `its layer in ${d.set.source} carries no depth_median (a ${d.set.form} input) while other parts' layers carry one; the turn orders parts by See-through's per-layer depth, so a run that carries one for every part — or for none, which skips the turn — is required` });
507
+ }
498
508
  refuseIfAny(problems);
499
509
  const notes: string[] = [];
500
510
  if (patches.length > 0) {
package/src/tags.ts CHANGED
@@ -187,6 +187,9 @@ export function isAuthorTag(name: string): boolean {
187
187
  * other names the accepted v3 tags and the author form.
188
188
  */
189
189
  export function layerTagRequirement(name: string): string {
190
+ if (name.endsWith(HIDDEN_SUFFIX)) {
191
+ return `is a hidden-art layer's name (issue #359) for ${JSON.stringify(name.slice(0, -HIDDEN_SUFFIX.length))}, which is no layer name; ${HIDDEN_NAME_RULE} is required`;
192
+ }
190
193
  if (name.startsWith(AUTHOR_PREFIX)) {
191
194
  return `is an author-made layer's name with the label ${JSON.stringify(name.slice(AUTHOR_PREFIX.length))}; ${AUTHOR_LABEL_RULE} is required after "${AUTHOR_PREFIX}"`;
192
195
  }
@@ -205,3 +208,47 @@ export const AUTHOR_NO_ROLE =
205
208
  export function authorLayerLine(name: string): string {
206
209
  return `author-made: layer "${name}" — ${AUTHOR_NO_ROLE}`;
207
210
  }
211
+
212
+ /**
213
+ * ⭐ **A hidden-art layer** (issue #359) holds the art of a layer where
214
+ * something else covers it — the forearm under the sleeve, the inside of the
215
+ * knee, the shirt under a scarf's tail — painted by an artist who has it,
216
+ * because See-through cannot see it. It is named by the layer it belongs to
217
+ * and {@link HIDDEN_SUFFIX}: `handwear-r:hidden` is `handwear-r`'s art where
218
+ * it is covered, `author:hand:hidden` is `author:hand`'s.
219
+ *
220
+ * Why this form and not `author:` with a declared target: the name alone says
221
+ * what the layer is and whose it is, so every reader knows at read time that
222
+ * it is not a part (the proposer never drafts one from it) and which layer it
223
+ * belongs to, with no config field to agree with it. The suffix holds a colon,
224
+ * which no v3 tag holds and an author-made label may not hold
225
+ * ({@link AUTHOR_LABEL_RULE}), so `<name>:hidden` can never be read as a tag,
226
+ * a side, or a label.
227
+ *
228
+ * It is never drawn and never a part. `assemble` reads it as the art the
229
+ * layer's part takes where a stage takes art from under something: a cut's
230
+ * `under` (issue #193) and a limb segment's cap (issue #349) — see
231
+ * `hiddenArt` in `src/assemble.ts`.
232
+ */
233
+ export const HIDDEN_SUFFIX = ':hidden';
234
+
235
+ /** A hidden-art layer's name read: the layer it belongs to. */
236
+ export interface HiddenTagReading {
237
+ /** The name as the layer carries it: `handwear-r:hidden`. */
238
+ name: string;
239
+ /** The layer it belongs to: `handwear-r`, a v3 tag or an author-made layer's name. */
240
+ of: string;
241
+ }
242
+
243
+ /**
244
+ * Read a layer name as a hidden-art layer's, or `null` when it does not end in
245
+ * {@link HIDDEN_SUFFIX} or what precedes it is no layer name ({@link readLayerTag}).
246
+ */
247
+ export function readHiddenTag(name: string): HiddenTagReading | null {
248
+ if (!name.endsWith(HIDDEN_SUFFIX)) return null;
249
+ const of = name.slice(0, -HIDDEN_SUFFIX.length);
250
+ return readLayerTag(of) === null ? null : { name, of };
251
+ }
252
+
253
+ /** What a hidden-art layer's name must be, as a refusal quotes it. */
254
+ export const HIDDEN_NAME_RULE = `"<layer>${HIDDEN_SUFFIX}", where <layer> is a See-through v3 tag or "${AUTHOR_PREFIX}<label>" that the same input also holds`;