@rig-c/parts 0.0.0-stage → 1.5.4
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/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +433 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +150 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- package/workflows/seethrough.json +68 -0
package/src/parts.ts
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The output contract of the assemble stage: `parts.json`, one record per
|
|
3
|
+
* part, beside `parts/<name>.png`.
|
|
4
|
+
*
|
|
5
|
+
* It is a MEASUREMENT record as much as a manifest. Each part says where it
|
|
6
|
+
* came from (`from` = `<run>:<tag>`, the See-through run and tag — the only
|
|
7
|
+
* thing downstream stages classify a part by — or `painting:<name>` for an
|
|
8
|
+
* `assemble.patches` entry, cut from the painting itself and named for the
|
|
9
|
+
* part it made, which no tag classifies), where it sits in rig pixels
|
|
10
|
+
* (`x`/`y`/`w`/`h`, y down, origin top-left: the PNG's own box), and what the
|
|
11
|
+
* assembler did to its pixels, counted:
|
|
12
|
+
*
|
|
13
|
+
* - `opaque_px` — pixels with alpha above 8 in the written PNG;
|
|
14
|
+
* - `visible_px` — of those, the ones no later layer of their See-through run
|
|
15
|
+
* is opaque (alpha >= 250) in front of: what the painting shows of this
|
|
16
|
+
* layer, and so what projection can reach;
|
|
17
|
+
* - `occluded_px` — the rest of `opaque_px`: art the painting does not show,
|
|
18
|
+
* so See-through's synthesis by necessity. `visible_px + occluded_px =
|
|
19
|
+
* opaque_px`;
|
|
20
|
+
* - `projected_core_px` — pixels where this layer was the top-most opaque layer
|
|
21
|
+
* of its own run, eroded: the candidates for taking the source painting's
|
|
22
|
+
* pixel instead of See-through's repaint;
|
|
23
|
+
* - `source_px_taken` — of those, the ones that did take it;
|
|
24
|
+
* - `visible_not_projected_px` — visible pixels that did not take it (too thin
|
|
25
|
+
* for the core, a fringe below alpha 250, refused for drift): synthesis where
|
|
26
|
+
* the painting was there to be taken. At most `visible_px`;
|
|
27
|
+
* - `refused_drift_px` — of those, the ones refused because See-through's
|
|
28
|
+
* pixel and the painting's disagreed by more than the drift limit;
|
|
29
|
+
* - `merged_px` — pixels brought in from the other run below the head crop;
|
|
30
|
+
* - `seam_override_px` — pixels whose colour the seam pass replaced;
|
|
31
|
+
* - `fringe_pushed_back` — written only when not empty: the pixels of this
|
|
32
|
+
* part's fringe (alpha below 250) made transparent because the painting
|
|
33
|
+
* shows another part there, as `{part, px}` — the part beneath that holds
|
|
34
|
+
* them at alpha >= 250 and how many (`src/assemble.ts` `pushBackFringe`,
|
|
35
|
+
* issue #119). They are not in `opaque_px`: the counts above are taken
|
|
36
|
+
* after it.
|
|
37
|
+
*
|
|
38
|
+
* ⭐ **A patch is 100 % source.** Every pixel of a `painting:` part is the
|
|
39
|
+
* painting's own, so it is all visible (the painting shows it; nothing of it
|
|
40
|
+
* is synthesis), all candidates and all taken: `visible_px =
|
|
41
|
+
* projected_core_px = source_px_taken = opaque_px`, and `occluded_px`,
|
|
42
|
+
* `visible_not_projected_px`, `refused_drift_px` and `merged_px` are 0. The
|
|
43
|
+
* reader holds a `painting:` record to exactly that.
|
|
44
|
+
*
|
|
45
|
+
* The three visibility counts are this port's, not the reference's: a
|
|
46
|
+
* `parts.json` the reference wrote has none of them and reads as it always
|
|
47
|
+
* did, and a record holds all three or none.
|
|
48
|
+
*
|
|
49
|
+
* `rig_size` is the rig canvas in pixels, `scale_rig_per_source` the rig pixels
|
|
50
|
+
* per source-painting pixel, and `ghost_px` the sub-threshold specks removed
|
|
51
|
+
* from each `<run>:<tag>` layer before anything else.
|
|
52
|
+
*
|
|
53
|
+
* `recomposite` is the flat stack of every part against the painting (this
|
|
54
|
+
* port's, issue #25; a `parts.json` the reference wrote has none and reads as
|
|
55
|
+
* it did): the four figures the assemble summary prints, the limits they were
|
|
56
|
+
* measured at, and the largest uncovered holes — 8-connected components of the
|
|
57
|
+
* error pixels no part covers, each with its box and the parts bordering it.
|
|
58
|
+
* A hole is a class of defect no gate downstream can see: `check`'s seam
|
|
59
|
+
* compares the setup pose with the flat stack of parts, so a pixel missing
|
|
60
|
+
* from every part is missing from both sides. `check` reports it
|
|
61
|
+
* (`RECOMPOSITE_HOLES`) from this block.
|
|
62
|
+
*
|
|
63
|
+
* This file is types, a writer and a reader; the assembly that fills it in is
|
|
64
|
+
* a later stage. The writer's key order is fixed, so two runs over the same
|
|
65
|
+
* inputs write the same bytes.
|
|
66
|
+
*/
|
|
67
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
68
|
+
import { type Problem, refuseIfAny } from './errors.ts';
|
|
69
|
+
import { readTag } from './tags.ts';
|
|
70
|
+
|
|
71
|
+
export interface PartRecord {
|
|
72
|
+
name: string;
|
|
73
|
+
from: string;
|
|
74
|
+
x: number;
|
|
75
|
+
y: number;
|
|
76
|
+
w: number;
|
|
77
|
+
h: number;
|
|
78
|
+
opaque_px: number;
|
|
79
|
+
/** Absent in a `parts.json` the reference wrote; `assemble` always writes it. */
|
|
80
|
+
visible_px?: number;
|
|
81
|
+
/** Absent in a `parts.json` the reference wrote; `assemble` always writes it. */
|
|
82
|
+
occluded_px?: number;
|
|
83
|
+
projected_core_px: number;
|
|
84
|
+
source_px_taken: number;
|
|
85
|
+
/** Absent in a `parts.json` the reference wrote; `assemble` always writes it. */
|
|
86
|
+
visible_not_projected_px?: number;
|
|
87
|
+
refused_drift_px: number;
|
|
88
|
+
merged_px: number;
|
|
89
|
+
seam_override_px: number;
|
|
90
|
+
/** Absent when the part had no fringe pushed back (and in a `parts.json` the reference wrote). */
|
|
91
|
+
fringe_pushed_back?: Array<{ part: string; px: number }>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One listed hole: its area, its box in rig pixels, and the parts beside it (`src/assemble.ts` `uncoveredHoles`). */
|
|
95
|
+
export interface HoleRecord {
|
|
96
|
+
px: number;
|
|
97
|
+
x: number;
|
|
98
|
+
y: number;
|
|
99
|
+
w: number;
|
|
100
|
+
h: number;
|
|
101
|
+
borders: Array<{ part: string; px: number }>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface RecompositeRecord {
|
|
105
|
+
mean_abs: number;
|
|
106
|
+
within_limit: number;
|
|
107
|
+
within_share: number;
|
|
108
|
+
error_limit: number;
|
|
109
|
+
error_px: number;
|
|
110
|
+
covered_alpha: number;
|
|
111
|
+
uncovered_error_px: number;
|
|
112
|
+
hole_count: number;
|
|
113
|
+
holes_listed: number;
|
|
114
|
+
holes: HoleRecord[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface PartsFile {
|
|
118
|
+
rig_size: [number, number];
|
|
119
|
+
scale_rig_per_source: number;
|
|
120
|
+
parts: PartRecord[];
|
|
121
|
+
ghost_px: Record<string, number>;
|
|
122
|
+
/** Absent in a `parts.json` the reference wrote; `assemble` always writes it. */
|
|
123
|
+
recomposite?: RecompositeRecord;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite'] as const;
|
|
127
|
+
const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite');
|
|
128
|
+
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;
|
|
129
|
+
const RECOMPOSITE_COUNTS = ['within_limit', 'error_limit', 'error_px', 'covered_alpha', 'uncovered_error_px', 'hole_count', 'holes_listed'] as const;
|
|
130
|
+
export const HOLE_KEYS = ['px', 'x', 'y', 'w', 'h', 'borders'] as const;
|
|
131
|
+
const BORDER_KEYS = ['part', 'px'] as const;
|
|
132
|
+
export const PART_KEYS = [
|
|
133
|
+
'name',
|
|
134
|
+
'from',
|
|
135
|
+
'x',
|
|
136
|
+
'y',
|
|
137
|
+
'w',
|
|
138
|
+
'h',
|
|
139
|
+
'opaque_px',
|
|
140
|
+
'visible_px',
|
|
141
|
+
'occluded_px',
|
|
142
|
+
'projected_core_px',
|
|
143
|
+
'source_px_taken',
|
|
144
|
+
'visible_not_projected_px',
|
|
145
|
+
'refused_drift_px',
|
|
146
|
+
'merged_px',
|
|
147
|
+
'seam_override_px',
|
|
148
|
+
'fringe_pushed_back',
|
|
149
|
+
] as const;
|
|
150
|
+
/** The one part field that is not a count: a list of `{part, px}`, written only when it is not empty. */
|
|
151
|
+
const FRINGE_KEY = 'fringe_pushed_back';
|
|
152
|
+
const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY) as ReadonlyArray<(typeof PART_KEYS)[number]>;
|
|
153
|
+
/** The port's own counts: all three or none (none = a record the reference wrote). */
|
|
154
|
+
export const VISIBILITY_KEYS = ['visible_px', 'occluded_px', 'visible_not_projected_px'] as const;
|
|
155
|
+
const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY);
|
|
156
|
+
|
|
157
|
+
/** Where a part came from: a See-through run and a v3 tag, or the painting itself for a patch. */
|
|
158
|
+
export type From = { run: 'full' | 'head'; tag: string } | { run: 'painting'; patch: string };
|
|
159
|
+
|
|
160
|
+
/** The `from` a patch's record carries. */
|
|
161
|
+
export const PAINTING_RUN = 'painting';
|
|
162
|
+
|
|
163
|
+
/** `<run>:<tag>` with run `full` or `head` and a v3 tag, `painting:<patch name>`, or null. */
|
|
164
|
+
export function readFrom(from: string): From | null {
|
|
165
|
+
const p = /^painting:(.+)$/.exec(from);
|
|
166
|
+
if (p !== null) return { run: 'painting', patch: p[1] };
|
|
167
|
+
const m = /^(full|head):(.+)$/.exec(from);
|
|
168
|
+
if (m === null || readTag(m[2]) === null) return null;
|
|
169
|
+
return { run: m[1] as 'full' | 'head', tag: m[2] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The See-through tag a part came from, or null for a `painting:` patch (or a `from` that is neither). */
|
|
173
|
+
export function tagOf(from: string): string | null {
|
|
174
|
+
const f = readFrom(from);
|
|
175
|
+
return f === null || f.run === 'painting' ? null : f.tag;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function serializeParts(file: PartsFile): string {
|
|
179
|
+
const ordered = {
|
|
180
|
+
rig_size: file.rig_size,
|
|
181
|
+
scale_rig_per_source: file.scale_rig_per_source,
|
|
182
|
+
parts: file.parts.map((p) =>
|
|
183
|
+
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]])),
|
|
184
|
+
),
|
|
185
|
+
ghost_px: Object.fromEntries(Object.keys(file.ghost_px).sort().map((k) => [k, file.ghost_px[k]])),
|
|
186
|
+
...(file.recomposite === undefined
|
|
187
|
+
? {}
|
|
188
|
+
: {
|
|
189
|
+
recomposite: {
|
|
190
|
+
...Object.fromEntries(RECOMPOSITE_KEYS.filter((k) => k !== 'holes').map((k) => [k, (file.recomposite as RecompositeRecord)[k]])),
|
|
191
|
+
holes: file.recomposite.holes.map((h) => ({ ...Object.fromEntries(HOLE_KEYS.filter((k) => k !== 'borders').map((k) => [k, h[k]])), borders: h.borders.map((b) => ({ part: b.part, px: b.px })) })),
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
};
|
|
195
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Write `parts.json`. The record is checked first, so nothing unreadable is ever written. */
|
|
199
|
+
export function writeParts(path: string, file: PartsFile): void {
|
|
200
|
+
const text = serializeParts(file);
|
|
201
|
+
checkParts(JSON.parse(text), path);
|
|
202
|
+
writeFileSync(path, text);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function readParts(path: string): PartsFile {
|
|
206
|
+
if (!existsSync(path)) refuseIfAny([{ code: 'PARTS_FILE_PRESENT', object: path, detail: 'no such file' }]);
|
|
207
|
+
let raw: unknown;
|
|
208
|
+
try {
|
|
209
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
210
|
+
} catch (err) {
|
|
211
|
+
refuseIfAny([{ code: 'PARTS_IS_JSON', object: path, detail: `does not parse as JSON: ${(err as Error).message}` }]);
|
|
212
|
+
}
|
|
213
|
+
return checkParts(raw, path);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function show(v: unknown): string {
|
|
217
|
+
return v === undefined ? 'absent' : JSON.stringify(v);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function checkParts(raw: unknown, path: string): PartsFile {
|
|
221
|
+
const problems: Problem[] = [];
|
|
222
|
+
const fail = (code: string, object: string, detail: string): void => {
|
|
223
|
+
problems.push({ code, object, detail });
|
|
224
|
+
};
|
|
225
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
226
|
+
fail('PARTS_FIELD_TYPE', path, `holds ${show(raw)}; an object is required`);
|
|
227
|
+
refuseIfAny(problems);
|
|
228
|
+
}
|
|
229
|
+
const o = raw as Record<string, unknown>;
|
|
230
|
+
for (const k of Object.keys(o)) if (!(TOP_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${path} key "${k}"`, `is not a parts.json field; known: ${TOP_KEYS.join(', ')}`);
|
|
231
|
+
for (const k of REQUIRED_TOP_KEYS) if (!(k in o)) fail('PARTS_FIELD_PRESENT', `${path} field "${k}"`, 'is absent and required');
|
|
232
|
+
const size = o.rig_size;
|
|
233
|
+
const sizeOk = Array.isArray(size) && size.length === 2 && size.every((n) => Number.isInteger(n) && (n as number) > 0);
|
|
234
|
+
if ('rig_size' in o && !sizeOk) fail('PARTS_FIELD_TYPE', `${path} field "rig_size"`, `is ${show(size)}; [width, height] in positive integers is required`);
|
|
235
|
+
const scale = o.scale_rig_per_source;
|
|
236
|
+
if ('scale_rig_per_source' in o && !(typeof scale === 'number' && Number.isFinite(scale) && scale > 0)) {
|
|
237
|
+
fail('PARTS_FIELD_TYPE', `${path} field "scale_rig_per_source"`, `is ${show(scale)}; a number above 0 is required`);
|
|
238
|
+
}
|
|
239
|
+
if ('ghost_px' in o) {
|
|
240
|
+
const g = o.ghost_px;
|
|
241
|
+
if (typeof g !== 'object' || g === null || Array.isArray(g)) fail('PARTS_FIELD_TYPE', `${path} field "ghost_px"`, `is ${show(g)}; an object of "<run>:<tag>" -> pixels is required`);
|
|
242
|
+
else {
|
|
243
|
+
for (const [k, v] of Object.entries(g as Record<string, unknown>)) {
|
|
244
|
+
if (tagOf(k) === null) fail('PARTS_FROM_KNOWN', `${path} ghost_px key "${k}"`, 'is not "<full|head>:<v3 tag>" (a patch has no See-through layer, so no ghost count)');
|
|
245
|
+
if (!(Number.isInteger(v) && (v as number) >= 0)) fail('PARTS_FIELD_TYPE', `${path} ghost_px "${k}"`, `is ${show(v)}; a pixel count is required`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if ('parts' in o && !(Array.isArray(o.parts) && o.parts.length > 0)) fail('PARTS_FIELD_TYPE', `${path} field "parts"`, `is ${show(o.parts)}; a non-empty array is required`);
|
|
250
|
+
if (Array.isArray(o.parts)) {
|
|
251
|
+
const seen = new Map<string, number>();
|
|
252
|
+
o.parts.forEach((p, i) => {
|
|
253
|
+
const at = `${path} parts[${i}]`;
|
|
254
|
+
if (typeof p !== 'object' || p === null || Array.isArray(p)) {
|
|
255
|
+
fail('PARTS_FIELD_TYPE', at, `is ${show(p)}; an object is required`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const r = p as Record<string, unknown>;
|
|
259
|
+
const label = typeof r.name === 'string' ? `part "${r.name}"` : at;
|
|
260
|
+
for (const k of Object.keys(r)) if (!(PART_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${label} key "${k}"`, `is not a part field; known: ${PART_KEYS.join(', ')}`);
|
|
261
|
+
for (const k of REQUIRED_KEYS) if (!(k in r)) fail('PARTS_FIELD_PRESENT', `${label} field "${k}"`, 'is absent and required');
|
|
262
|
+
const held = VISIBILITY_KEYS.filter((k) => k in r);
|
|
263
|
+
if (held.length > 0 && held.length < VISIBILITY_KEYS.length) {
|
|
264
|
+
fail('PARTS_FIELD_PRESENT', `${label} fields ${VISIBILITY_KEYS.map((k) => `"${k}"`).join(', ')}`, `only ${held.join(', ')} present; all three (assemble writes them) or none (the reference's record) is required`);
|
|
265
|
+
}
|
|
266
|
+
if (typeof r.name !== 'string' || r.name === '' || /[\\/]/.test(r.name) || r.name.startsWith('.')) {
|
|
267
|
+
fail('PARTS_FIELD_TYPE', `${label} field "name"`, `is ${show(r.name)}; a file-name-safe part name is required`);
|
|
268
|
+
} else if (seen.has(r.name)) fail('PARTS_NAME_UNIQUE', label, `appears at parts[${seen.get(r.name)}] and parts[${i}]`);
|
|
269
|
+
else seen.set(r.name, i);
|
|
270
|
+
const from = typeof r.from === 'string' ? readFrom(r.from) : null;
|
|
271
|
+
if (from === null) fail('PARTS_FROM_KNOWN', `${label} field "from"`, `is ${show(r.from)}; "<full|head>:<v3 tag>", or "painting:<the part's own name>" for a patch, is required`);
|
|
272
|
+
else if (from.run === 'painting' && from.patch !== r.name) {
|
|
273
|
+
fail('PARTS_FROM_KNOWN', `${label} field "from"`, `is ${show(r.from)}; a patch's provenance names the patch itself, so "painting:${String(r.name)}" is required`);
|
|
274
|
+
}
|
|
275
|
+
for (const k of COUNT_KEYS) {
|
|
276
|
+
if (k in r && !(Number.isInteger(r[k]) && (r[k] as number) >= 0)) fail('PARTS_FIELD_TYPE', `${label} field "${k}"`, `is ${show(r[k])}; a non-negative integer is required`);
|
|
277
|
+
}
|
|
278
|
+
const count = (k: string): number | null => (Number.isInteger(r[k]) && (r[k] as number) >= 0 ? (r[k] as number) : null);
|
|
279
|
+
const [op, vis, occ, vnp] = ['opaque_px', 'visible_px', 'occluded_px', 'visible_not_projected_px'].map(count);
|
|
280
|
+
if (held.length === VISIBILITY_KEYS.length && op !== null && vis !== null && occ !== null && vnp !== null) {
|
|
281
|
+
if (vis + occ !== op) fail('PARTS_COUNTS_ADD_UP', `${label}`, `visible_px ${vis} + occluded_px ${occ} = ${vis + occ}; opaque_px ${op} is required`);
|
|
282
|
+
if (vnp > vis) fail('PARTS_COUNTS_ADD_UP', `${label}`, `visible_not_projected_px ${vnp} is above visible_px ${vis}; at most visible_px is required`);
|
|
283
|
+
}
|
|
284
|
+
if (from !== null && from.run === 'painting' && op !== null) {
|
|
285
|
+
// A patch is 100 % source: every count is opaque_px or 0 (module doc).
|
|
286
|
+
const whole = ['visible_px', 'projected_core_px', 'source_px_taken'].filter((k) => k in r && count(k) !== op);
|
|
287
|
+
const none = ['occluded_px', 'visible_not_projected_px', 'refused_drift_px', 'merged_px'].filter((k) => k in r && count(k) !== 0);
|
|
288
|
+
if (whole.length > 0 || none.length > 0) {
|
|
289
|
+
const said = [...whole.map((k) => `${k} ${show(r[k])} (opaque_px ${op} required)`), ...none.map((k) => `${k} ${show(r[k])} (0 required)`)].join(', ');
|
|
290
|
+
fail('PARTS_COUNTS_ADD_UP', `${label}`, `is a painting patch (${String(r.from)}), which is 100 % source; ${said}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (FRINGE_KEY in r) {
|
|
294
|
+
const list = r[FRINGE_KEY];
|
|
295
|
+
const ok =
|
|
296
|
+
Array.isArray(list) &&
|
|
297
|
+
list.length > 0 &&
|
|
298
|
+
list.every((q) => typeof q === 'object' && q !== null && !Array.isArray(q) && Object.keys(q).join(',') === 'part,px' && typeof q.part === 'string' && q.part !== r.name && Number.isInteger(q.px) && q.px > 0);
|
|
299
|
+
if (!ok) fail('PARTS_FIELD_TYPE', `${label} field "${FRINGE_KEY}"`, `is ${show(list)}; a non-empty list of {"part": <another part's name>, "px": <a count above 0>} is required (the field is absent when no fringe was pushed back)`);
|
|
300
|
+
}
|
|
301
|
+
if (sizeOk && [r.x, r.y, r.w, r.h].every((n) => Number.isInteger(n))) {
|
|
302
|
+
const [W, H] = size as [number, number];
|
|
303
|
+
const [x, y, w, h] = [r.x, r.y, r.w, r.h] as number[];
|
|
304
|
+
if (w < 1 || h < 1 || x + w > W || y + h > H) {
|
|
305
|
+
fail('PARTS_BOX_INSIDE_RIG', label, `box ${x},${y} ${w}x${h}; a non-empty box inside the ${W}x${H} rig is required`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (Array.isArray(o.parts)) {
|
|
311
|
+
const named = new Set(o.parts.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : undefined)));
|
|
312
|
+
o.parts.forEach((p) => {
|
|
313
|
+
if (typeof p !== 'object' || p === null || !Array.isArray((p as Record<string, unknown>)[FRINGE_KEY])) return;
|
|
314
|
+
const r = p as Record<string, unknown>;
|
|
315
|
+
for (const q of r[FRINGE_KEY] as unknown[]) {
|
|
316
|
+
const held = typeof q === 'object' && q !== null ? (q as Record<string, unknown>).part : undefined;
|
|
317
|
+
if (typeof held === 'string' && !named.has(held)) fail('PARTS_FROM_KNOWN', `part "${String(r.name)}" field "${FRINGE_KEY}"`, `names "${held}", which is no part of this file; the part that holds a pushed-back pixel is one of its parts`);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if ('recomposite' in o && sizeOk) {
|
|
322
|
+
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>();
|
|
323
|
+
checkRecomposite(o.recomposite, path, size as [number, number], names, fail);
|
|
324
|
+
}
|
|
325
|
+
refuseIfAny(problems);
|
|
326
|
+
return raw as PartsFile;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const isCount = (v: unknown): v is number => Number.isInteger(v) && (v as number) >= 0;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The `recomposite` block: every key known and present, every figure its
|
|
333
|
+
* type, and the counts consistent with one another — the listed holes are the
|
|
334
|
+
* largest `holes_listed` of `hole_count` (so there are `min` of the two),
|
|
335
|
+
* largest first, their areas sum to at most `uncovered_error_px` (to exactly
|
|
336
|
+
* it when every hole is listed), each area fits its box, each box is inside
|
|
337
|
+
* the rig, and each bordering part is a part of this file.
|
|
338
|
+
*/
|
|
339
|
+
function checkRecomposite(r: unknown, path: string, [W, H]: [number, number], names: ReadonlySet<string>, fail: (code: string, object: string, detail: string) => void): void {
|
|
340
|
+
const at = `${path} field "recomposite"`;
|
|
341
|
+
if (typeof r !== 'object' || r === null || Array.isArray(r)) {
|
|
342
|
+
fail('PARTS_FIELD_TYPE', at, `is ${show(r)}; an object is required`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const b = r as Record<string, unknown>;
|
|
346
|
+
for (const k of Object.keys(b)) if (!(RECOMPOSITE_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${at} key "${k}"`, `is not a recomposite field; known: ${RECOMPOSITE_KEYS.join(', ')}`);
|
|
347
|
+
for (const k of RECOMPOSITE_KEYS) if (!(k in b)) fail('PARTS_FIELD_PRESENT', `${at} field "${k}"`, 'is absent and required');
|
|
348
|
+
for (const k of RECOMPOSITE_COUNTS) if (k in b && !isCount(b[k])) fail('PARTS_FIELD_TYPE', `${at} field "${k}"`, `is ${show(b[k])}; a non-negative integer is required`);
|
|
349
|
+
if ('mean_abs' in b && !(typeof b.mean_abs === 'number' && b.mean_abs >= 0 && b.mean_abs <= 255)) fail('PARTS_FIELD_TYPE', `${at} field "mean_abs"`, `is ${show(b.mean_abs)}; a number from 0 to 255 is required`);
|
|
350
|
+
if ('within_share' in b && !(typeof b.within_share === 'number' && b.within_share >= 0 && b.within_share <= 1)) fail('PARTS_FIELD_TYPE', `${at} field "within_share"`, `is ${show(b.within_share)}; a number from 0 to 1 is required`);
|
|
351
|
+
const [err, unc, count, listed] = [b.error_px, b.uncovered_error_px, b.hole_count, b.holes_listed].map((v) => (isCount(v) ? v : null));
|
|
352
|
+
if (err !== null && err > W * H) fail('PARTS_COUNTS_ADD_UP', at, `error_px ${err} is above the ${W}x${H} rig's ${W * H} pixels`);
|
|
353
|
+
if (err !== null && unc !== null && unc > err) fail('PARTS_COUNTS_ADD_UP', at, `uncovered_error_px ${unc} is above error_px ${err}; at most error_px is required`);
|
|
354
|
+
if (unc !== null && count !== null && (count > unc || (count === 0) !== (unc === 0))) {
|
|
355
|
+
fail('PARTS_COUNTS_ADD_UP', at, `hole_count ${count} with uncovered_error_px ${unc}; at least one pixel per hole, and no hole exactly when no uncovered pixel, is required`);
|
|
356
|
+
}
|
|
357
|
+
if (!('holes' in b)) return;
|
|
358
|
+
if (!Array.isArray(b.holes)) {
|
|
359
|
+
fail('PARTS_FIELD_TYPE', `${at} field "holes"`, `is ${show(b.holes)}; an array is required`);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (count !== null && listed !== null && b.holes.length !== Math.min(count, listed)) {
|
|
363
|
+
fail('PARTS_COUNTS_ADD_UP', `${at} field "holes"`, `lists ${b.holes.length} hole(s); the largest min(hole_count ${count}, holes_listed ${listed}) = ${Math.min(count, listed)} is required`);
|
|
364
|
+
}
|
|
365
|
+
let sum = 0;
|
|
366
|
+
let prev: number | null = null;
|
|
367
|
+
b.holes.forEach((h, i) => {
|
|
368
|
+
const hat = `${at} holes[${i}]`;
|
|
369
|
+
if (typeof h !== 'object' || h === null || Array.isArray(h)) {
|
|
370
|
+
fail('PARTS_FIELD_TYPE', hat, `is ${show(h)}; an object is required`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const o = h as Record<string, unknown>;
|
|
374
|
+
for (const k of Object.keys(o)) if (!(HOLE_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${hat} key "${k}"`, `is not a hole field; known: ${HOLE_KEYS.join(', ')}`);
|
|
375
|
+
for (const k of HOLE_KEYS) if (!(k in o)) fail('PARTS_FIELD_PRESENT', `${hat} field "${k}"`, 'is absent and required');
|
|
376
|
+
for (const k of ['px', 'x', 'y', 'w', 'h'] as const) if (k in o && !isCount(o[k])) fail('PARTS_FIELD_TYPE', `${hat} field "${k}"`, `is ${show(o[k])}; a non-negative integer is required`);
|
|
377
|
+
const [px, x, y, w, hh] = [o.px, o.x, o.y, o.w, o.h].map((v) => (isCount(v) ? v : null));
|
|
378
|
+
if (px !== null) {
|
|
379
|
+
sum += px;
|
|
380
|
+
if (px < 1) fail('PARTS_COUNTS_ADD_UP', hat, 'px 0; a hole has at least one pixel');
|
|
381
|
+
if (prev !== null && px > prev) fail('PARTS_HOLES_LARGEST_FIRST', hat, `px ${px} is above holes[${i - 1}]'s ${prev}; the holes are listed largest first`);
|
|
382
|
+
prev = px;
|
|
383
|
+
}
|
|
384
|
+
if (x !== null && y !== null && w !== null && hh !== null) {
|
|
385
|
+
if (w < 1 || hh < 1 || x + w > W || y + hh > H) fail('PARTS_BOX_INSIDE_RIG', hat, `box ${x},${y} ${w}x${hh}; a non-empty box inside the ${W}x${H} rig is required`);
|
|
386
|
+
else if (px !== null && px > w * hh) fail('PARTS_COUNTS_ADD_UP', hat, `px ${px} is above its ${w}x${hh} box's ${w * hh} pixels`);
|
|
387
|
+
}
|
|
388
|
+
if (!('borders' in o)) return;
|
|
389
|
+
if (!Array.isArray(o.borders)) {
|
|
390
|
+
fail('PARTS_FIELD_TYPE', `${hat} field "borders"`, `is ${show(o.borders)}; an array of {part, px} is required`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
o.borders.forEach((bd, j) => {
|
|
394
|
+
const bat = `${hat} borders[${j}]`;
|
|
395
|
+
if (typeof bd !== 'object' || bd === null || Array.isArray(bd)) {
|
|
396
|
+
fail('PARTS_FIELD_TYPE', bat, `is ${show(bd)}; an object {part, px} is required`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const q = bd as Record<string, unknown>;
|
|
400
|
+
for (const k of Object.keys(q)) if (!(BORDER_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${bat} key "${k}"`, `is not a border field; known: ${BORDER_KEYS.join(', ')}`);
|
|
401
|
+
for (const k of BORDER_KEYS) if (!(k in q)) fail('PARTS_FIELD_PRESENT', `${bat} field "${k}"`, 'is absent and required');
|
|
402
|
+
if ('px' in q && !(isCount(q.px) && q.px >= 1)) fail('PARTS_FIELD_TYPE', `${bat} field "px"`, `is ${show(q.px)}; a pixel count of at least 1 is required`);
|
|
403
|
+
if ('part' in q && !(typeof q.part === 'string' && names.has(q.part))) fail('PARTS_HOLE_PART_KNOWN', `${bat} field "part"`, `is ${show(q.part)}; a part this file lists is required`);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
if (unc !== null && sum > unc) fail('PARTS_COUNTS_ADD_UP', `${at} field "holes"`, `the listed holes hold ${sum} px, above uncovered_error_px ${unc}`);
|
|
407
|
+
if (unc !== null && count !== null && b.holes.length === count && sum !== unc && sum <= unc) {
|
|
408
|
+
fail('PARTS_COUNTS_ADD_UP', `${at} field "holes"`, `every one of the ${count} hole(s) is listed and they hold ${sum} px; uncovered_error_px ${unc} is required`);
|
|
409
|
+
}
|
|
410
|
+
}
|