@rig-c/parts 1.17.0 → 1.19.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 +173 -18
- package/docs/AUTHORING.md +178 -6
- package/docs/MIGRATION.md +73 -0
- package/docs/PAINTING.md +327 -0
- package/docs/STABILITY.md +7 -4
- package/package.json +2 -1
- package/skills/rig-parts/SKILL.md +29 -1
- package/src/assemble.ts +324 -25
- package/src/build.ts +32 -9
- package/src/inputs.ts +11 -1
- package/src/layers.ts +231 -18
- package/src/paintcheck.ts +277 -0
- package/src/parts.ts +81 -5
- package/src/propose.ts +11 -1
- package/src/tags.ts +47 -0
|
@@ -0,0 +1,277 @@
|
|
|
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, except that a hip may
|
|
37
|
+
* be `occluded` with a position (issue #372). An occluded wrist is a hand the
|
|
38
|
+
* painting does not show; an occluded hip is the honest state of a hip under
|
|
39
|
+
* a skirt, and the arm's gap read from it is **well defined** when the hip
|
|
40
|
+
* carries a position — which the keypoint reader holds inside the image
|
|
41
|
+
* (`KEYPOINTS_POSITION_INSIDE`), so every sample of the wrist-to-hip line is
|
|
42
|
+
* a pixel of the painting — and that position's pixel is figure (the next
|
|
43
|
+
* rule). A hip with no position is still refused: there is no line to read.
|
|
44
|
+
* - `PAINTING_JOINT_ON_FIGURE` — every observed joint's pixel, and every
|
|
45
|
+
* occluded hip's, is figure, not background. A joint off the figure is a
|
|
46
|
+
* keypoint file for another painting, or a limb the painting does not hold
|
|
47
|
+
* where the file says; an occluded hip on the background would also put a
|
|
48
|
+
* background pixel at the end of the arm's line, a gap the hip's own position
|
|
49
|
+
* makes rather than one between the arm and the body.
|
|
50
|
+
* - `PAINTING_ARM_CLEAR` — on each side, a gap between the wrist and the hip
|
|
51
|
+
* of the same side. None is an arm painted against the body.
|
|
52
|
+
* - `PAINTING_HANDS_APART` — a gap between the two wrists. None is hands
|
|
53
|
+
* together: clasped, or touching.
|
|
54
|
+
* - `PAINTING_LEGS_APART` — a gap between the two knees and a gap between the
|
|
55
|
+
* two ankles. None is legs painted as one column at that joint.
|
|
56
|
+
*
|
|
57
|
+
* Pure: the caller reads the files and hands over a raster and a person.
|
|
58
|
+
*/
|
|
59
|
+
import type { Problem } from './errors.ts';
|
|
60
|
+
import type { KeypointPerson } from './keypoints.ts';
|
|
61
|
+
import { NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
62
|
+
import type { Raster } from './raster/types.ts';
|
|
63
|
+
import type { KeypointName } from './skeleton.ts';
|
|
64
|
+
|
|
65
|
+
/** The joints the gap rules read, in the order a refusal lists them. */
|
|
66
|
+
export const PAINTING_JOINTS: readonly KeypointName[] = ['r_wrist', 'l_wrist', 'r_hip', 'l_hip', 'r_knee', 'l_knee', 'r_ankle', 'l_ankle'];
|
|
67
|
+
|
|
68
|
+
/** The joints that may be `occluded` with a position and still be read (issue #372): a skirt hides the hips on most clothed figures. */
|
|
69
|
+
export const PAINTING_OCCLUDABLE: readonly KeypointName[] = ['r_hip', 'l_hip'];
|
|
70
|
+
|
|
71
|
+
/** Whether the check reads joint `name` as given: observed with a position, or an occludable joint occluded with one. */
|
|
72
|
+
function readable(name: KeypointName, j: KeypointPerson['joints'][KeypointName]): j is KeypointPerson['joints'][KeypointName] & { at: [number, number] } {
|
|
73
|
+
if (j.at === null) return false;
|
|
74
|
+
return j.state === 'observed' || (j.state === 'occluded' && PAINTING_OCCLUDABLE.includes(name));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The four gap rules' pairs: the rule, the two joints, and what the pair is called in a line. */
|
|
78
|
+
export const PAINTING_GAPS: ReadonlyArray<readonly [string, KeypointName, KeypointName, string]> = [
|
|
79
|
+
['PAINTING_ARM_CLEAR', 'r_wrist', 'r_hip', 'right arm (r_wrist to r_hip)'],
|
|
80
|
+
['PAINTING_ARM_CLEAR', 'l_wrist', 'l_hip', 'left arm (l_wrist to l_hip)'],
|
|
81
|
+
['PAINTING_HANDS_APART', 'r_wrist', 'l_wrist', 'hands (r_wrist to l_wrist)'],
|
|
82
|
+
['PAINTING_LEGS_APART', 'r_knee', 'l_knee', 'knees (r_knee to l_knee)'],
|
|
83
|
+
['PAINTING_LEGS_APART', 'r_ankle', 'l_ankle', 'ankles (r_ankle to l_ankle)'],
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/** What each gap rule requires, in the words its refusal ends with. */
|
|
87
|
+
const GAP_REQUIRED: Readonly<Record<string, string>> = {
|
|
88
|
+
PAINTING_ARM_CLEAR: 'the arm painted clear of the body, so the line from the wrist to the hip of its side crosses background',
|
|
89
|
+
PAINTING_HANDS_APART: 'the hands painted apart, so the line between the wrists crosses background',
|
|
90
|
+
PAINTING_LEGS_APART: 'the legs painted apart, so the line between the two joints crosses background',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** One gap reading: the pair, the line's length and samples, and how many of them are background. */
|
|
94
|
+
export interface GapReading {
|
|
95
|
+
rule: string;
|
|
96
|
+
a: KeypointName;
|
|
97
|
+
b: KeypointName;
|
|
98
|
+
what: string;
|
|
99
|
+
from: [number, number];
|
|
100
|
+
to: [number, number];
|
|
101
|
+
samples: number;
|
|
102
|
+
background: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface PaintingReading {
|
|
106
|
+
width: number;
|
|
107
|
+
height: number;
|
|
108
|
+
/** Pixels on the border, counted once each. */
|
|
109
|
+
border: number;
|
|
110
|
+
/** Border pixels that are not ground, and the first one in raster order. */
|
|
111
|
+
borderNotGround: number;
|
|
112
|
+
firstBorderNotGround: [number, number] | null;
|
|
113
|
+
backgroundPx: number;
|
|
114
|
+
figurePx: number;
|
|
115
|
+
/** Every observed joint and every occluded hip with a position, its state, its pixel, and whether that pixel is figure. */
|
|
116
|
+
joints: Array<{ name: KeypointName; state: 'observed' | 'occluded'; at: [number, number]; pixel: [number, number]; onFigure: boolean }>;
|
|
117
|
+
/** The gap readings whose two joints are both read as given (observed with a position, or an occluded hip with one). */
|
|
118
|
+
gaps: GapReading[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Whether pixel `i` of `r` is ground: not painted, or near-white in every channel. */
|
|
122
|
+
function isGround(r: Raster, i: number): boolean {
|
|
123
|
+
const d = r.data;
|
|
124
|
+
const o = i * 4;
|
|
125
|
+
return d[o + 3] <= OPAQUE_ALPHA_ABOVE || (d[o] > NEAR_WHITE_MIN && d[o + 1] > NEAR_WHITE_MIN && d[o + 2] > NEAR_WHITE_MIN);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The background: every ground pixel 4-connected to a ground pixel on the border. One byte per pixel, 1 = background. */
|
|
129
|
+
export function backgroundMask(r: Raster): Uint8Array {
|
|
130
|
+
const { width: w, height: h } = r;
|
|
131
|
+
const bg = new Uint8Array(w * h);
|
|
132
|
+
const stack: number[] = [];
|
|
133
|
+
const seed = (i: number): void => {
|
|
134
|
+
if (bg[i] === 0 && isGround(r, i)) {
|
|
135
|
+
bg[i] = 1;
|
|
136
|
+
stack.push(i);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
for (let x = 0; x < w; x++) {
|
|
140
|
+
seed(x);
|
|
141
|
+
seed((h - 1) * w + x);
|
|
142
|
+
}
|
|
143
|
+
for (let y = 0; y < h; y++) {
|
|
144
|
+
seed(y * w);
|
|
145
|
+
seed(y * w + w - 1);
|
|
146
|
+
}
|
|
147
|
+
while (stack.length > 0) {
|
|
148
|
+
const i = stack.pop() as number;
|
|
149
|
+
const x = i % w;
|
|
150
|
+
const y = (i - x) / w;
|
|
151
|
+
if (x > 0) seed(i - 1);
|
|
152
|
+
if (x < w - 1) seed(i + 1);
|
|
153
|
+
if (y > 0) seed(i - w);
|
|
154
|
+
if (y < h - 1) seed(i + w);
|
|
155
|
+
}
|
|
156
|
+
return bg;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The pixel holding a continuous painting point: floor, the right and bottom edges taken as the last column and row. */
|
|
160
|
+
export function pixelOf(at: readonly [number, number], w: number, h: number): [number, number] {
|
|
161
|
+
return [Math.min(Math.floor(at[0]), w - 1), Math.min(Math.floor(at[1]), h - 1)];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The pixels the line from `a` to `b` is sampled at: `n + 1` evenly spaced points, `n = max(ceil(max(|dx|, |dy|)), 1)`. */
|
|
165
|
+
export function linePixels(a: readonly [number, number], b: readonly [number, number], w: number, h: number): Array<[number, number]> {
|
|
166
|
+
const dx = b[0] - a[0];
|
|
167
|
+
const dy = b[1] - a[1];
|
|
168
|
+
const n = Math.max(Math.ceil(Math.max(Math.abs(dx), Math.abs(dy))), 1);
|
|
169
|
+
const out: Array<[number, number]> = [];
|
|
170
|
+
for (let i = 0; i <= n; i++) out.push(pixelOf([a[0] + (dx * i) / n, a[1] + (dy * i) / n], w, h));
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read the painting against the person's joints: every figure the rules decide on, before any rule decides. */
|
|
175
|
+
export function readPainting(r: Raster, person: KeypointPerson): PaintingReading {
|
|
176
|
+
const { width: w, height: h } = r;
|
|
177
|
+
const bg = backgroundMask(r);
|
|
178
|
+
let border = 0;
|
|
179
|
+
let borderNotGround = 0;
|
|
180
|
+
let firstBorderNotGround: [number, number] | null = null;
|
|
181
|
+
for (let y = 0; y < h; y++) {
|
|
182
|
+
for (let x = 0; x < w; x++) {
|
|
183
|
+
if (y !== 0 && y !== h - 1 && x !== 0 && x !== w - 1) continue;
|
|
184
|
+
border++;
|
|
185
|
+
if (!isGround(r, y * w + x)) {
|
|
186
|
+
borderNotGround++;
|
|
187
|
+
firstBorderNotGround ??= [x, y];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
let backgroundPx = 0;
|
|
192
|
+
for (const v of bg) backgroundPx += v;
|
|
193
|
+
const joints: PaintingReading['joints'] = [];
|
|
194
|
+
for (const [name, j] of Object.entries(person.joints) as Array<[KeypointName, KeypointPerson['joints'][KeypointName]]>) {
|
|
195
|
+
if (!readable(name, j)) continue;
|
|
196
|
+
const pixel = pixelOf(j.at, w, h);
|
|
197
|
+
joints.push({ name, state: j.state === 'observed' ? 'observed' : 'occluded', at: j.at, pixel, onFigure: bg[pixel[1] * w + pixel[0]] === 0 });
|
|
198
|
+
}
|
|
199
|
+
const gaps: GapReading[] = [];
|
|
200
|
+
for (const [rule, a, b, what] of PAINTING_GAPS) {
|
|
201
|
+
const ja = person.joints[a];
|
|
202
|
+
const jb = person.joints[b];
|
|
203
|
+
if (!readable(a, ja) || !readable(b, jb)) continue;
|
|
204
|
+
const line = linePixels(ja.at, jb.at, w, h);
|
|
205
|
+
let background = 0;
|
|
206
|
+
for (const [x, y] of line) background += bg[y * w + x];
|
|
207
|
+
gaps.push({ rule, a, b, what, from: ja.at, to: jb.at, samples: line.length, background });
|
|
208
|
+
}
|
|
209
|
+
return { width: w, height: h, border, borderNotGround, firstBorderNotGround, backgroundPx, figurePx: w * h - backgroundPx, joints, gaps };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const pt = (p: readonly [number, number]): string => `[${p[0]}, ${p[1]}]`;
|
|
213
|
+
|
|
214
|
+
/** The refusals a reading earns, every one at once, in the order the module's comment lists the rules. */
|
|
215
|
+
export function paintingProblems(reading: PaintingReading, person: KeypointPerson, painting: string, keypoints: string): Problem[] {
|
|
216
|
+
const problems: Problem[] = [];
|
|
217
|
+
if (reading.borderNotGround > 0) {
|
|
218
|
+
const f = reading.firstBorderNotGround as [number, number];
|
|
219
|
+
problems.push({
|
|
220
|
+
code: 'PAINTING_BACKGROUND_PLAIN',
|
|
221
|
+
object: `painting ${painting}`,
|
|
222
|
+
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`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
for (const n of PAINTING_JOINTS) {
|
|
226
|
+
const j = person.joints[n];
|
|
227
|
+
if (readable(n, j)) continue;
|
|
228
|
+
const hip = PAINTING_OCCLUDABLE.includes(n);
|
|
229
|
+
problems.push({
|
|
230
|
+
code: 'PAINTING_JOINTS_STATED',
|
|
231
|
+
object: `${keypoints} person "${person.id}" joint ${n}`,
|
|
232
|
+
detail: hip
|
|
233
|
+
? `is ${j.listed ? j.state : 'not listed'} with no position; observed or occluded with a position is required — the arm's gap is read on the line from the wrist to this hip, and a hip with no position gives no line to read`
|
|
234
|
+
: `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 (only a hip may be occluded, with a position)`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
for (const j of reading.joints) {
|
|
238
|
+
if (j.onFigure) continue;
|
|
239
|
+
problems.push({
|
|
240
|
+
code: 'PAINTING_JOINT_ON_FIGURE',
|
|
241
|
+
object: `${keypoints} person "${person.id}" joint ${j.name}`,
|
|
242
|
+
detail: `is ${j.state === 'occluded' ? 'occluded ' : ''}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${j.state === 'occluded' ? '; an occluded hip is read only from a point on the figure, or the arm\'s gap would be the hip\'s own background' : ''}`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
for (const g of reading.gaps) {
|
|
246
|
+
if (g.background > 0) continue;
|
|
247
|
+
problems.push({
|
|
248
|
+
code: g.rule,
|
|
249
|
+
object: `painting ${painting} ${g.what}`,
|
|
250
|
+
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]}`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return problems;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The lines a reading prints, before any refusal: the figure, every joint and every gap, each with its numbers. */
|
|
257
|
+
export function paintingLines(reading: PaintingReading): string[] {
|
|
258
|
+
const out = [
|
|
259
|
+
` painting ${reading.width}x${reading.height}: background ${reading.backgroundPx} px, figure ${reading.figurePx} px; border ${reading.border} px, ${reading.borderNotGround} not background-coloured`,
|
|
260
|
+
];
|
|
261
|
+
const on = reading.joints.filter((j) => j.onFigure).length;
|
|
262
|
+
const observed = reading.joints.filter((j) => j.state === 'observed').length;
|
|
263
|
+
const occluded = reading.joints.filter((j) => j.state === 'occluded').map((j) => j.name);
|
|
264
|
+
const occ = occluded.length === 0 ? '' : `, ${occluded.length} occluded with a position (${occluded.join(', ')})`;
|
|
265
|
+
out.push(` joints: ${observed} observed${occ}, ${on} on the figure${on === reading.joints.length ? '' : ` (off: ${reading.joints.filter((j) => !j.onFigure).map((j) => j.name).join(', ')})`}`);
|
|
266
|
+
for (const g of reading.gaps) {
|
|
267
|
+
const hidden = [g.a, g.b].filter((n) => occluded.includes(n));
|
|
268
|
+
out.push(` gap ${g.what}: ${g.background} of ${g.samples} sampled pixels are background${hidden.length === 0 ? '' : ` (${hidden.join(', ')} occluded, read at its position)`}`);
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Read and judge: the lines to print and every refusal, which the caller prints before it throws them at once. */
|
|
274
|
+
export function checkPainting(r: Raster, person: KeypointPerson, painting: string, keypoints: string): { lines: string[]; problems: Problem[] } {
|
|
275
|
+
const reading = readPainting(r, person);
|
|
276
|
+
return { lines: paintingLines(reading), problems: paintingProblems(reading, person, painting, keypoints) };
|
|
277
|
+
}
|
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
|
-
|
|
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
|
-
|
|
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`;
|