@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
|
@@ -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
|
-
|
|
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
|