@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.
@@ -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
@@ -42,7 +42,10 @@
42
42
  * cut from, which shares its `from`. The record is in draw order, and a piece
43
43
  * drawn `"back"` comes before its base, so order cannot say which of two parts
44
44
  * sharing a layer is the piece; this field does. A `parts.json` written before
45
- * 1.6.0 has none, and still reads.
45
+ * 1.6.0 has none, and still reads. `limb`, written only on a part a
46
+ * `config.limbs` segment made (issue #349), names the limb, the segment's
47
+ * index (0 the upper), the part it was cut from (`of`), the one bone it rides
48
+ * and, below a joint, the joint and its cap ({@link LimbRecord}).
46
49
  *
47
50
  * ⭐ **A patch is 100 % source.** Every pixel of a `painting:` part is the
48
51
  * painting's own, so it is all visible (the painting shows it; nothing of it
@@ -59,6 +62,10 @@
59
62
  * per source-painting pixel, and `ghost_px` the sub-threshold specks removed
60
63
  * from each `<run>:<tag>` layer before anything else.
61
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
+ *
62
69
  * `recomposite` is the flat stack of every part against the painting (this
63
70
  * port's, issue #25; a `parts.json` the reference wrote has none and reads as
64
71
  * it did): the four figures the assemble summary prints, the limits they were
@@ -104,6 +111,51 @@ export interface PartRecord {
104
111
  seam_override_px: number;
105
112
  /** Absent when the part had no fringe pushed back (and in a `parts.json` the reference wrote). */
106
113
  fringe_pushed_back?: Array<{ part: string; px: number }>;
114
+ /** Written only on a part a `config.limbs` segment made (issue #349). */
115
+ limb?: LimbRecord;
116
+ }
117
+
118
+ /**
119
+ * A limb segment's provenance (issue #349): the limb, the segment's index
120
+ * (0 the upper), the part its pixels were cut from (`of`), the bone it rides
121
+ * with weight 1, and, on a segment below a joint, the joint's name and its cap.
122
+ */
123
+ export interface LimbRecord {
124
+ name: string;
125
+ segment: number;
126
+ of: string;
127
+ bone: string;
128
+ joint?: string;
129
+ cap?: CapRecord;
130
+ }
131
+
132
+ /**
133
+ * The cap a lower segment carries past its joint (issue #349): the disc's
134
+ * centre (the joint, rig px), its radius (half the limb's width across the
135
+ * joint line, `width`), the box its image covers `[x, y, w, h]`, and its
136
+ * pixels on the upper side of the joint line counted by where each came from
137
+ * — the layer (`layer_px`), the config's cap image (`image_px`, only with one)
138
+ * — or none (`missing_px`: no pixel in the layer and none in an image).
139
+ */
140
+ export interface CapRecord {
141
+ at: [number, number];
142
+ width: number;
143
+ radius: number;
144
+ box: [number, number, number, number];
145
+ layer_px: number;
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;
149
+ missing_px: number;
150
+ }
151
+
152
+ const LIMB_KEYS = ['name', 'segment', 'of', 'bone', 'joint', 'cap'] as const;
153
+ const CAP_KEYS = ['at', 'width', 'radius', 'box', 'layer_px', 'image_px', 'image_layer', 'missing_px'] as const;
154
+
155
+ /** The limb record with its keys in the fixed order, so two runs write the same bytes. */
156
+ function orderedLimb(l: LimbRecord): Record<string, unknown> {
157
+ const cap = l.cap === undefined ? undefined : Object.fromEntries(CAP_KEYS.filter((k) => (l.cap as CapRecord)[k] !== undefined).map((k) => [k, (l.cap as CapRecord)[k]]));
158
+ return Object.fromEntries(LIMB_KEYS.filter((k) => (k === 'cap' ? cap : l[k]) !== undefined).map((k) => [k, k === 'cap' ? cap : l[k]]));
107
159
  }
108
160
 
109
161
  /** One listed hole: its area, its box in rig pixels, and the parts beside it (`src/assemble.ts` `uncoveredHoles`). */
@@ -138,6 +190,27 @@ export interface PartsFile {
138
190
  recomposite?: RecompositeRecord;
139
191
  /** Written only under `config.scene` (issue #242): the recomposite was read inside this figure; absent otherwise. */
140
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;
141
214
  }
142
215
 
143
216
  /**
@@ -153,8 +226,9 @@ export interface SceneRecord {
153
226
  figure_mask: string;
154
227
  }
155
228
 
156
- const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene'] as const;
157
- 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;
158
232
  export const SCENE_KEYS = ['figure_alpha_above', 'figure_px', 'figure_mask'] as const;
159
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;
160
234
  const RECOMPOSITE_COUNTS = ['within_limit', 'error_limit', 'error_px', 'covered_alpha', 'uncovered_error_px', 'hole_count', 'holes_listed'] as const;
@@ -178,15 +252,18 @@ export const PART_KEYS = [
178
252
  'merged_px',
179
253
  'seam_override_px',
180
254
  'fringe_pushed_back',
255
+ 'limb',
181
256
  ] as const;
182
257
  /** The one part field that is not a count: a list of `{part, px}`, written only when it is not empty. */
183
258
  const FRINGE_KEY = 'fringe_pushed_back';
184
259
  /** The one part field that names another part: a cut's piece names its base, written only on a piece. */
185
260
  const CUT_FROM_KEY = 'cut_from';
186
- const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY) as ReadonlyArray<(typeof PART_KEYS)[number]>;
261
+ /** The one part field that is a record of its own: a limb segment's provenance (issue #349), written only on one. */
262
+ const LIMB_KEY = 'limb';
263
+ const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY) as ReadonlyArray<(typeof PART_KEYS)[number]>;
187
264
  /** The port's own counts: all three or none (none = a record the reference wrote). */
188
265
  export const VISIBILITY_KEYS = ['visible_px', 'occluded_px', 'visible_not_projected_px'] as const;
189
- const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY);
266
+ const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY);
190
267
 
191
268
  /** Where a part came from: a See-through run and a v3 tag or an author-made layer's `author:<label>` (issue #256), or the painting itself for a patch. */
192
269
  export type From = { run: 'full' | 'head'; tag: string } | { run: 'painting'; patch: string };
@@ -214,7 +291,7 @@ export function serializeParts(file: PartsFile): string {
214
291
  rig_size: file.rig_size,
215
292
  scale_rig_per_source: file.scale_rig_per_source,
216
293
  parts: file.parts.map((p) =>
217
- Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : p[k]])),
294
+ Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : p[k]])),
218
295
  ),
219
296
  ghost_px: Object.fromEntries(Object.keys(file.ghost_px).sort().map((k) => [k, file.ghost_px[k]])),
220
297
  ...(file.recomposite === undefined
@@ -226,6 +303,7 @@ export function serializeParts(file: PartsFile): string {
226
303
  },
227
304
  }),
228
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]])) }),
229
307
  };
230
308
  return `${JSON.stringify(ordered, null, 2)}\n`;
231
309
  }
@@ -374,6 +452,43 @@ function checkParts(raw: unknown, path: string): PartsFile {
374
452
  else if (b.from !== r.from) fail('PARTS_CUT_FROM_KNOWN', object, `names "${base}", whose from is ${show(b.from)}; this piece's is ${show(r.from)}, and a piece shares its base's layer`);
375
453
  }
376
454
  }
455
+ if (Array.isArray(o.parts)) {
456
+ // A limb segment (issue #349) names its limb, its index, the part it was cut from, its bone, and below a joint the joint and its cap.
457
+ for (const p of o.parts) {
458
+ if (typeof p !== 'object' || p === null || Array.isArray(p) || !(LIMB_KEY in p)) continue;
459
+ const r = p as Record<string, unknown>;
460
+ const object = `part "${String(r.name)}" field "${LIMB_KEY}"`;
461
+ const l = r[LIMB_KEY];
462
+ if (typeof l !== 'object' || l === null || Array.isArray(l)) {
463
+ fail('PARTS_FIELD_TYPE', object, `is ${show(l)}; an object {name, segment, of, bone, joint?, cap?} is required`);
464
+ continue;
465
+ }
466
+ const q = l as Record<string, unknown>;
467
+ for (const k of Object.keys(q)) if (!(LIMB_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${object} key "${k}"`, `is not a limb field; known: ${LIMB_KEYS.join(', ')}`);
468
+ for (const k of ['name', 'of', 'bone'] as const) if (!(typeof q[k] === 'string' && q[k] !== '')) fail('PARTS_FIELD_TYPE', `${object} "${k}"`, `is ${show(q[k])}; a non-empty string is required`);
469
+ if (!isCount(q.segment)) fail('PARTS_FIELD_TYPE', `${object} "segment"`, `is ${show(q.segment)}; the segment's index, a non-negative integer, is required`);
470
+ const below = isCount(q.segment) && q.segment > 0;
471
+ if (below !== ('joint' in q) || below !== ('cap' in q)) fail('PARTS_FIELD_PRESENT', object, `segment ${show(q.segment)} with${'joint' in q ? '' : 'out'} a joint and with${'cap' in q ? '' : 'out'} a cap; a segment below a joint (index above 0) has both, the upper segment neither`);
472
+ if ('joint' in q && !(typeof q.joint === 'string' && q.joint !== '')) fail('PARTS_FIELD_TYPE', `${object} "joint"`, `is ${show(q.joint)}; the joint's name is required`);
473
+ if ('cap' in q) {
474
+ const c = q.cap;
475
+ const co = typeof c === 'object' && c !== null && !Array.isArray(c) ? (c as Record<string, unknown>) : null;
476
+ const pt = (v: unknown, n: number): boolean => Array.isArray(v) && v.length === n && v.every((x) => typeof x === 'number' && Number.isFinite(x));
477
+ const ok =
478
+ co !== null &&
479
+ Object.keys(co).every((k) => (CAP_KEYS as readonly string[]).includes(k)) &&
480
+ pt(co.at, 2) &&
481
+ typeof co.width === 'number' &&
482
+ typeof co.radius === 'number' &&
483
+ pt(co.box, 4) &&
484
+ isCount(co.layer_px) &&
485
+ isCount(co.missing_px) &&
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`);
489
+ }
490
+ }
491
+ }
377
492
  if ('scene' in o) {
378
493
  const sc = o.scene;
379
494
  if (typeof sc !== 'object' || sc === null || Array.isArray(sc)) fail('PARTS_FIELD_TYPE', `${path} field "scene"`, `is ${show(sc)}; an object is required`);
@@ -386,6 +501,7 @@ function checkParts(raw: unknown, path: string): PartsFile {
386
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');
387
502
  }
388
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);
389
505
  if ('recomposite' in o && sizeOk) {
390
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>();
391
507
  checkRecomposite(o.recomposite, path, size as [number, number], names, fail);
@@ -396,6 +512,51 @@ function checkParts(raw: unknown, path: string): PartsFile {
396
512
 
397
513
  const isCount = (v: unknown): v is number => Number.isInteger(v) && (v as number) >= 0;
398
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
+
399
560
  /**
400
561
  * The `recomposite` block: every key known and present, every figure its
401
562
  * type, and the counts consistent with one another — the listed holes are the