rig-parts 2.2.0 → 2.4.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/cli.ts +2 -0
- package/docs/AUTHORING.md +291 -6
- package/docs/STABILITY.md +59 -3
- package/package.json +2 -2
- package/src/assemble.ts +124 -30
- package/src/boundarycolour.ts +233 -0
- package/src/build.ts +24 -5
- package/src/check.ts +165 -6
- package/src/config.ts +49 -6
- package/src/editreceipt.ts +194 -0
- package/src/motionseampose.ts +3 -1
- package/src/partsource.ts +91 -0
- package/src/requirements.ts +64 -2
- package/src/rig.ts +31 -8
- package/src/setuppainting.ts +18 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor receipts (issue #946): the author's record that an image this build
|
|
3
|
+
* reads was edited in an image editor, held to the bytes on disk.
|
|
4
|
+
*
|
|
5
|
+
* What the package can know was edited is what the input itself says was made
|
|
6
|
+
* outside See-through, never a guess from pixels: an author-made layer
|
|
7
|
+
* (`author:<label>`, issue #256), a hidden-art layer (`<layer>:hidden`, issue
|
|
8
|
+
* #359), a cut's `under` image (issue #170) and a limb segment's `cap` image
|
|
9
|
+
* (issue #349). Each of those images IS an edited image by the input's own
|
|
10
|
+
* declaration. A See-through layer or the painting overwritten in place by a
|
|
11
|
+
* script carries no such mark, so nothing here can find it; an author may
|
|
12
|
+
* still receipt it, and the receipt is then verified like any other.
|
|
13
|
+
*
|
|
14
|
+
* A receipt is `{file, sha256, from_sha256, editor}`:
|
|
15
|
+
*
|
|
16
|
+
* - `file` and `sha256` are VERIFIED: the file, relative to the config's
|
|
17
|
+
* directory, must exist and its bytes must hash to `sha256`
|
|
18
|
+
* (`ASSEMBLE_EDITOR_RECEIPT_FILE`, `ASSEMBLE_EDITOR_RECEIPT_SHA256`).
|
|
19
|
+
* - `from_sha256` (the bytes the edit started from) and `editor` (the editor
|
|
20
|
+
* the author names) are DECLARATIONS. The loader holds them to presence and
|
|
21
|
+
* form, and `from_sha256` to differ from `sha256` (an edit that left the
|
|
22
|
+
* bytes unchanged is not an edit, `CONFIG_EDITOR_RECEIPT`). That a person
|
|
23
|
+
* used that editor is not something a file can show, and nothing here
|
|
24
|
+
* claims to check it.
|
|
25
|
+
*
|
|
26
|
+
* With `required: true` every edited image the build reads — of the base
|
|
27
|
+
* runs, of each alternate set's runs, every cut `under` and limb `cap` — must
|
|
28
|
+
* carry a receipt whose `sha256` is that image's file
|
|
29
|
+
* (`ASSEMBLE_EDITOR_RECEIPT_MISSING`). An image is matched to its receipt by
|
|
30
|
+
* content, not by path, so the two layouts a wrapper run may hold a layer in
|
|
31
|
+
* (`<filename>` and `parts/<name>.png`, byte-identical by `LAYERS_PNG_UNAMBIGUOUS`)
|
|
32
|
+
* resolve to one receipt. A PSD layer's file is the PSD. Absent `required`,
|
|
33
|
+
* receipts that are declared are verified and nothing is demanded; absent the
|
|
34
|
+
* block, no file is read and no byte of any output moves.
|
|
35
|
+
*
|
|
36
|
+
* Pure: reads files, hashes with `node:crypto`; no clock, no network.
|
|
37
|
+
*/
|
|
38
|
+
import { createHash } from 'node:crypto';
|
|
39
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
40
|
+
import { dirname, resolve } from 'node:path';
|
|
41
|
+
import type { Problem } from './errors.ts';
|
|
42
|
+
import type { LayerSet } from './layers.ts';
|
|
43
|
+
|
|
44
|
+
/** One receipt, as `config.assemble.editor_receipts.receipts[i]` holds it. */
|
|
45
|
+
export interface EditorReceipt {
|
|
46
|
+
/** The edited image, relative to the config's directory. */
|
|
47
|
+
file: string;
|
|
48
|
+
/** sha256 of the edited file's bytes, 64 lowercase hex digits; verified. */
|
|
49
|
+
sha256: string;
|
|
50
|
+
/** sha256 of the bytes the edit started from; declared, held to form and to differ from `sha256`. */
|
|
51
|
+
from_sha256: string;
|
|
52
|
+
/** The editor the author names; declared. */
|
|
53
|
+
editor: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `config.assemble.editor_receipts`. */
|
|
57
|
+
export interface EditorReceipts {
|
|
58
|
+
/** `true`: every edited image the build reads needs a receipt. Absent or `false`: receipts are verified, none is demanded. */
|
|
59
|
+
required?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Issue #947, `true`: every part's pixels trace to a layer of the split or to
|
|
62
|
+
* an image a receipt covers — every edited image needs a receipt, as under
|
|
63
|
+
* `required`, and a shape drawn in the config through a part's pixels is
|
|
64
|
+
* refused (`src/partsource.ts`). Absent or `false`: nothing more is demanded.
|
|
65
|
+
*/
|
|
66
|
+
trace_parts?: boolean;
|
|
67
|
+
receipts: EditorReceipt[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What a sha256 field must be, as a refusal quotes it. */
|
|
71
|
+
export const SHA256_RULE = '64 lowercase hex digits (a sha256 of the file\'s bytes)';
|
|
72
|
+
export const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
73
|
+
|
|
74
|
+
const AT = 'config.assemble.editor_receipts';
|
|
75
|
+
|
|
76
|
+
/** sha256 of a file's bytes, lowercase hex. */
|
|
77
|
+
export function fileSha256(path: string): string {
|
|
78
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A receipt that verified against its file, with where it was declared. */
|
|
82
|
+
export interface VerifiedReceipt extends EditorReceipt {
|
|
83
|
+
/** `config.assemble.editor_receipts.receipts[i]`. */
|
|
84
|
+
at: string;
|
|
85
|
+
/** The file, resolved against the config's directory. */
|
|
86
|
+
path: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The receipts read for one build: those that verified, keyed by sha256, and the refusals of those that did not. */
|
|
90
|
+
export interface ReceiptLedger {
|
|
91
|
+
required: boolean;
|
|
92
|
+
/** The field that made receipts required, as a refusal names it: `required`, else `trace_parts` (issue #947). */
|
|
93
|
+
by: 'required' | 'trace_parts';
|
|
94
|
+
bySha: ReadonlyMap<string, VerifiedReceipt>;
|
|
95
|
+
problems: Problem[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Verify every declared receipt against its file. `spec` has passed the
|
|
100
|
+
* config loader (shape, hex form, `from_sha256` unlike `sha256`, unique
|
|
101
|
+
* `file`). A missing file is `ASSEMBLE_EDITOR_RECEIPT_FILE`; bytes that hash
|
|
102
|
+
* to another value are `ASSEMBLE_EDITOR_RECEIPT_SHA256`, naming both values.
|
|
103
|
+
*/
|
|
104
|
+
export function readEditorReceipts(spec: EditorReceipts | undefined, configPath: string): ReceiptLedger {
|
|
105
|
+
const problems: Problem[] = [];
|
|
106
|
+
const bySha = new Map<string, VerifiedReceipt>();
|
|
107
|
+
if (spec === undefined) return { required: false, by: 'required', bySha, problems };
|
|
108
|
+
const base = dirname(resolve(configPath));
|
|
109
|
+
spec.receipts.forEach((r, i) => {
|
|
110
|
+
const at = `${AT}.receipts[${i}]`;
|
|
111
|
+
const path = resolve(base, r.file);
|
|
112
|
+
const object = `${at} (file "${r.file}")`;
|
|
113
|
+
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
114
|
+
problems.push({ code: 'ASSEMBLE_EDITOR_RECEIPT_FILE', object, detail: `names ${path}, which is no such file; the edited image, its path relative to the config's directory, is required` });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const found = fileSha256(path);
|
|
118
|
+
if (found !== r.sha256) {
|
|
119
|
+
problems.push({ code: 'ASSEMBLE_EDITOR_RECEIPT_SHA256', object, detail: `the file's bytes hash to sha256 ${found}; the receipt's sha256 ${r.sha256} is required — the file changed after its receipt was written, or the receipt names another file` });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!bySha.has(found)) bySha.set(found, { ...r, at, path });
|
|
123
|
+
});
|
|
124
|
+
const by = spec.required === true || spec.trace_parts !== true ? 'required' : 'trace_parts';
|
|
125
|
+
return { required: spec.required === true || spec.trace_parts === true, by, bySha, problems };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** An image the input itself says was made or edited outside See-through. */
|
|
129
|
+
export interface EditedImage {
|
|
130
|
+
/** How the input names it: `full:author:hand`, `config.assemble.cuts[0].under (part "x")`. */
|
|
131
|
+
object: string;
|
|
132
|
+
/** The file its pixels are read from. */
|
|
133
|
+
path: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The edited images of a pair of runs: every author-made layer and every
|
|
138
|
+
* hidden-art layer, named `<run>:<layer>` as `parts.json`'s `from` names a
|
|
139
|
+
* layer, after `label`. With one run (`single`) the head run is the full run
|
|
140
|
+
* and is listed once. A PSD layer has no file of its own; its file is the PSD.
|
|
141
|
+
*/
|
|
142
|
+
export function editedRunImages(runs: { full: LayerSet; head: LayerSet; single: boolean }, label = ''): EditedImage[] {
|
|
143
|
+
const out: EditedImage[] = [];
|
|
144
|
+
for (const run of runs.single ? (['full'] as const) : (['full', 'head'] as const)) {
|
|
145
|
+
const set = runs[run];
|
|
146
|
+
for (const l of set.layers) if (l.tag.group === 'author') out.push({ object: `${label}${run}:${l.name}`, path: resolve(l.file ?? set.source) });
|
|
147
|
+
for (const h of set.hidden) out.push({ object: `${label}${run}:${h.name}`, path: resolve(h.file ?? set.source) });
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The images a config names by path that the author painted: every cut's `under` and every limb segment's `cap`. */
|
|
153
|
+
export function editedConfigImages(configPath: string, cuts: ReadonlyArray<{ into: string; under?: string }>, limbs: Readonly<Record<string, { segments: ReadonlyArray<{ part: string; cap?: string }> }>> | undefined, cutsAt = 'config.assemble.cuts'): EditedImage[] {
|
|
154
|
+
const base = dirname(resolve(configPath));
|
|
155
|
+
const out: EditedImage[] = [];
|
|
156
|
+
cuts.forEach((q, i) => {
|
|
157
|
+
if (q.under !== undefined) out.push({ object: `${cutsAt}[${i}].under (part "${q.into}")`, path: resolve(base, q.under) });
|
|
158
|
+
});
|
|
159
|
+
for (const [l, limb] of Object.entries(limbs ?? {})) {
|
|
160
|
+
limb.segments.forEach((sg, k) => {
|
|
161
|
+
if (sg.cap !== undefined) out.push({ object: `config.limbs.${l}.segments[${k}].cap (part "${sg.part}")`, path: resolve(base, sg.cap) });
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Under `required: true` (or `trace_parts: true`, issue #947), every edited image must have a receipt whose
|
|
169
|
+
* `sha256` is its file's bytes: `ASSEMBLE_EDITOR_RECEIPT_MISSING` names the
|
|
170
|
+
* image, its file and the sha256 found. A file that is not on disk is left to
|
|
171
|
+
* the reader that names it (`ASSEMBLE_UNDER_PRESENT`, `ASSEMBLE_LIMB_CAP_PRESENT`).
|
|
172
|
+
* Absent `required`, nothing is demanded and this returns no problem.
|
|
173
|
+
*/
|
|
174
|
+
export function missingReceipts(ledger: ReceiptLedger, images: readonly EditedImage[]): Problem[] {
|
|
175
|
+
if (!ledger.required) return [];
|
|
176
|
+
const problems: Problem[] = [];
|
|
177
|
+
for (const image of images) {
|
|
178
|
+
if (!existsSync(image.path) || !statSync(image.path).isFile()) continue;
|
|
179
|
+
const found = fileSha256(image.path);
|
|
180
|
+
if (ledger.bySha.has(found)) continue;
|
|
181
|
+
problems.push({
|
|
182
|
+
code: 'ASSEMBLE_EDITOR_RECEIPT_MISSING',
|
|
183
|
+
object: image.object,
|
|
184
|
+
detail: `${image.path} (sha256 ${found}) carries no editor receipt; ${AT}.${ledger.by} is true, so a receipt {file, sha256, from_sha256, editor} whose sha256 is this file's is required — edit the image in an image editor and record it, never generate or patch it in code`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return problems;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The verified receipt for a file's current bytes, or null: the lookup a later gate (issue #947) traces a part's pixels through. */
|
|
191
|
+
export function receiptFor(ledger: ReceiptLedger, path: string): VerifiedReceipt | null {
|
|
192
|
+
if (!existsSync(path) || !statSync(path).isFile()) return null;
|
|
193
|
+
return ledger.bySha.get(fileSha256(path)) ?? null;
|
|
194
|
+
}
|
package/src/motionseampose.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/** Posed-axis evidence from one stock seam render, never sampled source keys. */
|
|
2
|
-
import type { GeometryFile
|
|
2
|
+
import type { GeometryFile } from '@rig-c/core/render-shared';
|
|
3
3
|
import type { FrameSet } from './check.ts';
|
|
4
4
|
import { PartsError } from './errors.ts';
|
|
5
5
|
|
|
6
|
+
type GeometryFrame = GeometryFile['frames'][number];
|
|
7
|
+
|
|
6
8
|
// Stock rig-c 2.69 geometry header, also exported by rig-c/render. render-shared
|
|
7
9
|
// does not expose its constants; avoid loading the optional Spine side for a header read.
|
|
8
10
|
export const SEAM_GEOMETRY_COORDINATES = 'spine world, y up, world units';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Part sources (issue #947): with `config.assemble.editor_receipts.trace_parts`
|
|
3
|
+
* true, every part's pixels trace to a layer of the See-through split or to an
|
|
4
|
+
* image an editor receipt covers (issue #946), and nothing else.
|
|
5
|
+
*
|
|
6
|
+
* Two kinds of input put pixels in a part that trace to neither, and both are
|
|
7
|
+
* read off the config, never guessed from pixels:
|
|
8
|
+
*
|
|
9
|
+
* - **An edited image with no receipt** — an author-made or hidden-art layer,
|
|
10
|
+
* a cut's `under`, a limb segment's `cap`. `trace_parts` demands a receipt
|
|
11
|
+
* for each exactly as `editor_receipts.required` does
|
|
12
|
+
* (`ASSEMBLE_EDITOR_RECEIPT_MISSING`, `src/editreceipt.ts`).
|
|
13
|
+
* - **A shape the author drew in the config** that decides which pixels a part
|
|
14
|
+
* holds: a cut's `polygon` (`assemble.cuts`, and each alternate set's own
|
|
15
|
+
* `cuts`), `seethrough.arm_split`'s column or line, a limb's `split.line`, a
|
|
16
|
+
* patch's `box` of the painting (`assemble.patches`) and a painting
|
|
17
|
+
* extension's `band` (`assemble.extend_painting`). A drawn edge through a
|
|
18
|
+
* layer's art is not an edge of the split, and it tears where the piece moves.
|
|
19
|
+
* `parts.json` cannot show all of these afterwards — an arm split's parts are
|
|
20
|
+
* recorded `full:handwear-r` and `full:handwear-l`, as See-through layers are
|
|
21
|
+
* — so the refusal is made on the config, before anything is assembled
|
|
22
|
+
* (`ASSEMBLE_PART_SOURCE`).
|
|
23
|
+
*
|
|
24
|
+
* What is NOT refused, and why: a limb's segments are cut at the joint the
|
|
25
|
+
* rig's bones place (`config.limbs`, `jointLine` in `src/limbs.ts`), not along
|
|
26
|
+
* a line the author drew, and a limb `split` with `edge: "painting"` follows the
|
|
27
|
+
* painting's stroke; a `take` entry's layer is another See-through run's,
|
|
28
|
+
* verified by its identity receipt (`taken_layers`); `extend_below_crop`
|
|
29
|
+
* continues a part from a layer of the full run; the automatic stages
|
|
30
|
+
* (projection, the seam override, the fringe push-back, the arm-run split by
|
|
31
|
+
* colour) write within a layer's own footprint or derive their line from the
|
|
32
|
+
* layers.
|
|
33
|
+
*
|
|
34
|
+
* Pure: reads the parsed config only.
|
|
35
|
+
*/
|
|
36
|
+
import type { AssembleConfig } from './config.ts';
|
|
37
|
+
import type { Problem } from './errors.ts';
|
|
38
|
+
|
|
39
|
+
const FLAG = 'config.assemble.editor_receipts.trace_parts';
|
|
40
|
+
const REQUIRED = `${FLAG} is true, so every part's pixels must trace to a layer of the See-through split or to an image with an editor receipt`;
|
|
41
|
+
const REMEDY = 'paint the piece as an author layer (author:<label>) in an image editor and record its receipt in config.assemble.editor_receipts, or take it whole from a layer of the split — never carve it by a shape drawn in the config';
|
|
42
|
+
|
|
43
|
+
const box = (b: readonly number[]): string => `[${b.join(', ')}]`;
|
|
44
|
+
|
|
45
|
+
/** Whether the config declares the gate. */
|
|
46
|
+
export function tracePartsDeclared(cfg: Pick<AssembleConfig, 'assemble'>): boolean {
|
|
47
|
+
return cfg.assemble.editor_receipts?.trace_parts === true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Under `trace_parts`, one `ASSEMBLE_PART_SOURCE` per shape the config draws
|
|
52
|
+
* through a part's pixels, naming the part (as `parts.json` names it), where
|
|
53
|
+
* the config declares the shape, and the shape found. Absent the flag, none.
|
|
54
|
+
* Order is the config's: cuts, each alternate set's cuts, patches, painting
|
|
55
|
+
* extensions, the arm split, limb splits in key order.
|
|
56
|
+
*/
|
|
57
|
+
export function untracedPartSources(cfg: Pick<AssembleConfig, 'assemble' | 'seethrough' | 'limbs'>): Problem[] {
|
|
58
|
+
if (!tracePartsDeclared(cfg)) return [];
|
|
59
|
+
const out: Problem[] = [];
|
|
60
|
+
const refuse = (object: string, found: string): void => {
|
|
61
|
+
out.push({ code: 'ASSEMBLE_PART_SOURCE', object, detail: `${found}; ${REQUIRED} — ${REMEDY}` });
|
|
62
|
+
};
|
|
63
|
+
const a = cfg.assemble;
|
|
64
|
+
(a.cuts ?? []).forEach((q, i) => {
|
|
65
|
+
refuse(`part "${q.into}" (config.assemble.cuts[${i}])`, `its pixels are carved out of part "${q.from}" by a hand-drawn ${q.polygon.length}-point polygon`);
|
|
66
|
+
});
|
|
67
|
+
(a.alternates ?? []).forEach((set, j) => {
|
|
68
|
+
(set.cuts ?? []).forEach((q, i) => {
|
|
69
|
+
refuse(`part "${q.into}" (config.assemble.alternates[${j}].cuts[${i}], alternate set "${set.name}")`, `its pixels are carved out of part "${q.from}" by a hand-drawn ${q.polygon.length}-point polygon`);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
(a.patches ?? []).forEach((p, i) => {
|
|
73
|
+
refuse(`part "${p.name}" (config.assemble.patches[${i}])`, `its pixels are the painting's inside a hand-drawn box ${box(p.box)} (alpha "${p.alpha}"), which no layer of the split holds`);
|
|
74
|
+
});
|
|
75
|
+
(a.extend_painting ?? []).forEach((e, i) => {
|
|
76
|
+
refuse(`part "${e.part}" (config.assemble.extend_painting[${i}])`, `it gains the painting's pixels inside a hand-drawn band ${box(e.band)}, which no layer of the split holds there`);
|
|
77
|
+
});
|
|
78
|
+
const arm = cfg.seethrough.arm_split;
|
|
79
|
+
if (arm !== undefined) {
|
|
80
|
+
const shape = arm.x !== undefined ? `a hand-drawn column x = ${arm.x}` : `a hand-drawn ${(arm.line ?? []).length}-point line`;
|
|
81
|
+
refuse('parts full:handwear-r and full:handwear-l (config.seethrough.arm_split)', `their pixels are split out of the full run's handwear layer along ${shape}`);
|
|
82
|
+
}
|
|
83
|
+
for (const name of Object.keys(cfg.limbs ?? {}).sort()) {
|
|
84
|
+
const limb = (cfg.limbs ?? {})[name];
|
|
85
|
+
const line = limb.split?.line;
|
|
86
|
+
if (line === undefined) continue;
|
|
87
|
+
const parts = limb.segments.map((s) => `"${s.part}"`).join(', ');
|
|
88
|
+
refuse(`limb "${name}" parts ${parts} (config.limbs.${name}.split.line)`, `their pixels are split from the crossing limb's along a hand-drawn ${line.length}-point line; split.edge "painting" follows the painting's stroke instead`);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
package/src/requirements.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { setupPaintingProblems, type SetupPainting } from './setuppainting.ts';
|
|
2
2
|
import { jointMaterialProblems, type JointMaterial } from './cutmaterial.ts';
|
|
3
3
|
import { fixedSkeletonProblems, type FixedSkeleton } from './artjoints.ts';
|
|
4
|
+
import { boundaryColourProblems, type BoundaryColour } from './boundarycolour.ts';
|
|
4
5
|
/**
|
|
5
6
|
* What a scene requires of a rig's motion, declared in a file `check` is given
|
|
6
7
|
* (`--requirements`, issue #93, increment 2 of #87) and measured from the
|
|
@@ -199,6 +200,64 @@ export interface RequirementsFile {
|
|
|
199
200
|
setup_painting?: SetupPainting;
|
|
200
201
|
/** Explicit source-support declarations (#888), absent otherwise. */
|
|
201
202
|
cut_material?: JointMaterial[];
|
|
203
|
+
/** Issue #944: `assemble`'s pinhole bar, measured only under `required: true`; absent when the file does not declare it. */
|
|
204
|
+
pinholes?: PinholesDeclaration;
|
|
205
|
+
/** Issue #941: `check`'s background bar over every declared seam pair and every frame, only under `required: true`; absent when the file does not declare it. */
|
|
206
|
+
seam_over_motion?: SeamOverMotionDeclaration;
|
|
207
|
+
/** Issue #942: the boundary colour-step bar against the rig's own pieces, measured by `check` only when declared. */
|
|
208
|
+
boundary_colour?: BoundaryColour;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Issue #944: the author's declaration that the flat parts stack may hold no
|
|
213
|
+
* pinhole at rest (`measurePinholes` in `src/assemble.ts`). `assemble` (and
|
|
214
|
+
* `build`'s assemble) measures it only when `required` is `true`, and refuses
|
|
215
|
+
* every pinhole by name (`ASSEMBLE_PINHOLES`); absent or `false`, the bar
|
|
216
|
+
* reports SKIP. The bar is 0 and is not a field: nothing is the author's to set
|
|
217
|
+
* but whether it holds.
|
|
218
|
+
*/
|
|
219
|
+
export interface PinholesDeclaration {
|
|
220
|
+
required: boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Issue #944: the shape problems of a `pinholes` block — an object holding exactly the boolean `required` (and notes). */
|
|
224
|
+
export function pinholesProblems(value: unknown, at: string): Problem[] {
|
|
225
|
+
const problems: Problem[] = [];
|
|
226
|
+
if (!isRecord(value)) return [{ code: 'REQUIREMENTS_FIELD', object: at, detail: `is ${JSON.stringify(value)?.slice(0, 40)}; requires {"required": true} (assemble refuses every pinhole of the flat parts stack) or {"required": false} (the same as omitting it: the bar reports SKIP)` }];
|
|
227
|
+
for (const k of Object.keys(value)) if (k !== 'required' && !freeKey(k, value[k])) problems.push({ code: 'REQUIREMENTS_FIELD', object: `${at}.${k}`, detail: 'is not a field this block reads (required; note or *_note as a string; x-<name> as a record) — the bar is 0 and takes no value' });
|
|
228
|
+
if (typeof value.required !== 'boolean') problems.push({ code: 'REQUIREMENTS_FIELD', object: `${at}.required`, detail: `is ${JSON.stringify(value.required)}; requires true (assemble refuses every pinhole of the flat parts stack) or false (the same as omitting the block: the bar reports SKIP)` });
|
|
229
|
+
return problems;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Issue #941: the author's declaration that every declared seam pair —
|
|
234
|
+
* body-root, internal-joint and explicit — holds 0 background cells on every
|
|
235
|
+
* frame `SEAM_OVER_MOTION` reads (`motionSeamRequired` in `src/check.ts`).
|
|
236
|
+
* `check` (and `build`'s check) applies it only when `required` is `true`;
|
|
237
|
+
* absent or `false`, `SEAM_OVER_MOTION` reads, prints and writes as before.
|
|
238
|
+
* The bar is 0 and is not a field.
|
|
239
|
+
*/
|
|
240
|
+
export interface SeamOverMotionDeclaration {
|
|
241
|
+
required: boolean;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Issue #941: the shape problems of a `seam_over_motion` block — an object holding exactly the boolean `required` (and notes). */
|
|
245
|
+
export function seamOverMotionProblems(value: unknown, at: string): Problem[] {
|
|
246
|
+
const problems: Problem[] = [];
|
|
247
|
+
if (!isRecord(value)) return [{ code: 'REQUIREMENTS_FIELD', object: at, detail: `is ${JSON.stringify(value)?.slice(0, 40)}; requires {"required": true} (check refuses background on any frame of any declared seam pair) or {"required": false} (the same as omitting it: SEAM_OVER_MOTION reads as before)` }];
|
|
248
|
+
for (const k of Object.keys(value)) if (k !== 'required' && !freeKey(k, value[k])) problems.push({ code: 'REQUIREMENTS_FIELD', object: `${at}.${k}`, detail: 'is not a field this block reads (required; note or *_note as a string; x-<name> as a record) — the bar is 0 and takes no value' });
|
|
249
|
+
if (typeof value.required !== 'boolean') problems.push({ code: 'REQUIREMENTS_FIELD', object: `${at}.required`, detail: `is ${JSON.stringify(value.required)}; requires true (check refuses background on any frame of any declared seam pair) or false (the same as omitting the block: SEAM_OVER_MOTION reads as before)` });
|
|
250
|
+
return problems;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Issue #941: whether the file declares the seam background bar required. */
|
|
254
|
+
export function seamOverMotionRequired(file: Pick<RequirementsFile, 'seam_over_motion'> | null): boolean {
|
|
255
|
+
return file?.seam_over_motion?.required === true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Issue #944: whether the file declares the pinhole bar required. */
|
|
259
|
+
export function pinholesRequired(file: Pick<RequirementsFile, 'pinholes'>): boolean {
|
|
260
|
+
return file.pinholes?.required === true;
|
|
202
261
|
}
|
|
203
262
|
|
|
204
263
|
/** One declared fold (issue #320): a slot, a named animation, and why the fold is the art's. */
|
|
@@ -289,7 +348,10 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
289
348
|
problems.push({ code: 'REQUIREMENTS_SELF_TARGET', object: `${label} field "target"`, detail });
|
|
290
349
|
};
|
|
291
350
|
if (raw.spec !== REQUIREMENTS_SPEC) field(`${at} field "spec"`, `is ${JSON.stringify(raw.spec)}; ${JSON.stringify(REQUIREMENTS_SPEC)} is required`);
|
|
292
|
-
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS, 'may_fold', 'fixed_skeleton', 'cut_material', 'setup_painting'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises, rests, may_fold, fixed_skeleton, cut_material, setup_painting; note or *_note as a string; x-<name> as a record)');
|
|
351
|
+
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS, 'may_fold', 'fixed_skeleton', 'cut_material', 'setup_painting', 'pinholes', 'seam_over_motion', 'boundary_colour'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises, rests, may_fold, fixed_skeleton, cut_material, setup_painting, pinholes, seam_over_motion, boundary_colour; note or *_note as a string; x-<name> as a record)');
|
|
352
|
+
if ('pinholes' in raw) problems.push(...pinholesProblems(raw.pinholes, `${at} pinholes`));
|
|
353
|
+
if ('seam_over_motion' in raw) problems.push(...seamOverMotionProblems(raw.seam_over_motion, `${at} seam_over_motion`));
|
|
354
|
+
if ('boundary_colour' in raw) problems.push(...boundaryColourProblems(raw.boundary_colour, `${at} boundary_colour`));
|
|
293
355
|
if ('setup_painting' in raw) problems.push(...setupPaintingProblems(raw.setup_painting, `${at} setup_painting`));
|
|
294
356
|
if ('cut_material' in raw) problems.push(...jointMaterialProblems(raw.cut_material, `${at} cut_material`));
|
|
295
357
|
if ('fixed_skeleton' in raw) problems.push(...fixedSkeletonProblems(raw.fixed_skeleton, `${at} fixed_skeleton`));
|
|
@@ -476,7 +538,7 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
476
538
|
}
|
|
477
539
|
}
|
|
478
540
|
refuseIfAny(problems);
|
|
479
|
-
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests, may_fold: mayFold, ...(raw.fixed_skeleton === undefined ? {} : { fixed_skeleton: raw.fixed_skeleton as FixedSkeleton }), ...(raw.cut_material === undefined ? {} : { cut_material: raw.cut_material as JointMaterial[] }), ...(raw.setup_painting === undefined ? {} : { setup_painting: raw.setup_painting as SetupPainting }) };
|
|
541
|
+
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests, may_fold: mayFold, ...(raw.fixed_skeleton === undefined ? {} : { fixed_skeleton: raw.fixed_skeleton as FixedSkeleton }), ...(raw.cut_material === undefined ? {} : { cut_material: raw.cut_material as JointMaterial[] }), ...(raw.setup_painting === undefined ? {} : { setup_painting: raw.setup_painting as SetupPainting }), ...(raw.pinholes === undefined ? {} : { pinholes: { required: (raw.pinholes as PinholesDeclaration).required } }), ...(raw.seam_over_motion === undefined ? {} : { seam_over_motion: { required: (raw.seam_over_motion as SeamOverMotionDeclaration).required } }), ...(raw.boundary_colour === undefined ? {} : { boundary_colour: { max_step_excess: (raw.boundary_colour as BoundaryColour).max_step_excess } }) };
|
|
480
542
|
}
|
|
481
543
|
|
|
482
544
|
// ---------------------------------------------------------------------------
|
package/src/rig.ts
CHANGED
|
@@ -1336,7 +1336,7 @@ export function buildWaistPairSource(
|
|
|
1336
1336
|
const CX = W / 2;
|
|
1337
1337
|
const byPart = new Map(parts.parts.map((p) => [p.name, p]));
|
|
1338
1338
|
|
|
1339
|
-
const configuredAlts = cfg.assemble.alternates ??
|
|
1339
|
+
const configuredAlts = cfg.assemble.alternates ?? [];
|
|
1340
1340
|
const anchorRegions = new Set(Object.keys(cfg.regions).filter(n => !(n in cfg.meshes)));
|
|
1341
1341
|
for (const a of configuredAlts) {
|
|
1342
1342
|
if (a.base_anchor === undefined) continue;
|
|
@@ -1354,14 +1354,38 @@ export function buildWaistPairSource(
|
|
|
1354
1354
|
if (JSON.stringify(configured === undefined ? null : orderedBaseAnchor(configured)) !== JSON.stringify(a.base_anchor === undefined ? null : orderedBaseAnchor(a.base_anchor))) fail('RIG_ALT_BASE_ANCHOR_MATCH_CONFIG', `alternate set "${a.name}" base_anchor`, 'config and parts.json declare different base anchors; re-run assemble before rig');
|
|
1355
1355
|
problems.push(...recordedFocusProblems(a, `parts.json alternate set "${a.name}".focus`, parts.rig_size, parts.scale_rig_per_source));
|
|
1356
1356
|
}
|
|
1357
|
-
for (const a of cfg.assemble.alternates ??
|
|
1357
|
+
for (const a of cfg.assemble.alternates ?? []) {
|
|
1358
1358
|
const record = parts.alternates?.find((p) => p.name === a.name);
|
|
1359
|
-
|
|
1359
|
+
// Issue #962: assemble writes a record for every declared set or refuses, so a declared set with no record was
|
|
1360
|
+
// added or renamed since assemble, and the alternate map below would build without it and say nothing.
|
|
1361
|
+
if (record === undefined) fail('RIG_ALT_SET_ASSEMBLED', `config.assemble alternate set "${a.name}"`, `has no record in parts.json (recorded sets: ${JSON.stringify((parts.alternates ?? []).map((p) => p.name))}); re-run assemble before rig`);
|
|
1360
1362
|
if ((a.focus !== undefined || record?.focus !== undefined)
|
|
1361
1363
|
&& JSON.stringify(a.focus) !== JSON.stringify(record?.focus)) {
|
|
1362
1364
|
fail('CONFIG_SET_FOCUS', `config.assemble alternate set "${a.name}".focus`, 'does not match the assembled painting point; re-run assemble after changing or removing focus');
|
|
1363
1365
|
}
|
|
1364
1366
|
}
|
|
1367
|
+
// Issue #950: the alternate map below reads `drawn` and `parts` from the record only, so a config edit since
|
|
1368
|
+
// assemble is refused by name rather than built from the stale record. assemble copies `drawn` verbatim and
|
|
1369
|
+
// appends the generated hybrid band names to `parts`; only those appended names may stand beyond the config's.
|
|
1370
|
+
const hybridBands = new Set(Object.values(cfg.limbs ?? {}).flatMap((limb) => limb.segments.flatMap((sg) => sg.hybrid === undefined ? [] : [sg.hybrid.part])));
|
|
1371
|
+
const showDrawn = (d: Record<string, number> | undefined): string => d === undefined ? 'absent' : JSON.stringify(Object.fromEntries(Object.keys(d).sort().map((k) => [k, d[k]])));
|
|
1372
|
+
const showParts = (p: readonly string[] | undefined): string => p === undefined ? 'absent' : JSON.stringify(p);
|
|
1373
|
+
type AltFields = { drawn?: Record<string, number>; parts?: string[] };
|
|
1374
|
+
// A set on one side only is not this rule's: a direct caller may pass records with no config set, and a config
|
|
1375
|
+
// set with no record contributes no alternate map at all.
|
|
1376
|
+
for (const record of parts.alternates ?? []) {
|
|
1377
|
+
const configured: AltFields | undefined = configuredAlts.find((c) => c.name === record.name);
|
|
1378
|
+
if (configured === undefined) continue;
|
|
1379
|
+
if (showDrawn(configured.drawn) !== showDrawn(record.drawn)) {
|
|
1380
|
+
fail('RIG_ALT_DRAWN_MATCH_CONFIG', `alternate set "${record.name}" drawn`, `config declares ${showDrawn(configured.drawn)}; parts.json records ${showDrawn(record.drawn)}; re-run assemble before rig`);
|
|
1381
|
+
}
|
|
1382
|
+
const declared = configured.parts;
|
|
1383
|
+
const stripped = record.parts?.filter((n) => !(hybridBands.has(n) && !(declared ?? []).includes(n)));
|
|
1384
|
+
if (showParts(declared) !== showParts(stripped)) {
|
|
1385
|
+
const bands = record.parts !== undefined && stripped !== undefined && stripped.length !== record.parts.length ? `, ${showParts(stripped)} without its generated hybrid bands` : '';
|
|
1386
|
+
fail('RIG_ALT_PARTS_MATCH_CONFIG', `alternate set "${record.name}" parts`, `config declares ${showParts(declared)}; parts.json records ${showParts(record.parts)}${bands}; re-run assemble before rig`);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1365
1389
|
refuseIfAny(problems);
|
|
1366
1390
|
|
|
1367
1391
|
// Standalone rig checks source-bound normalization, never reconstructing
|
|
@@ -1375,7 +1399,7 @@ export function buildWaistPairSource(
|
|
|
1375
1399
|
}
|
|
1376
1400
|
}
|
|
1377
1401
|
refuseIfAny(problems);
|
|
1378
|
-
for (const a of cfg.assemble.alternates ??
|
|
1402
|
+
for (const a of cfg.assemble.alternates ?? []) {
|
|
1379
1403
|
const record = parts.alternates?.find((p) => p.name === a.name);
|
|
1380
1404
|
if (a.tips === undefined) {
|
|
1381
1405
|
if (record?.tips !== undefined) {
|
|
@@ -2738,7 +2762,6 @@ export function buildWaistPairSource(
|
|
|
2738
2762
|
if (parts.alternates !== undefined && parts.alternates.length > 0) {
|
|
2739
2763
|
const setEntries: Record<string, AlternateSetMap> = {};
|
|
2740
2764
|
for (const [altIdx, alt] of parts.alternates.entries()) {
|
|
2741
|
-
const cfgAlt = cfg.alternates?.find((a) => a.name === alt.name);
|
|
2742
2765
|
for (const [bone, tip] of Object.entries(alt.tips ?? {})) {
|
|
2743
2766
|
if (bone === ROOT_BONE || !B.has(bone)) fail('RIG_NAME_RESOLVES', `parts.json alternates[${altIdx}].tips.${bone}`, `names "${bone}", which config.bones does not declare; re-run assemble with declared bone names`);
|
|
2744
2767
|
if (!Array.isArray(tip) || tip.length !== 2 || !tip.every((n) => typeof n === 'number' && Number.isFinite(n))) {
|
|
@@ -2753,8 +2776,8 @@ export function buildWaistPairSource(
|
|
|
2753
2776
|
}
|
|
2754
2777
|
const altFront = new Set(alt.front ?? []);
|
|
2755
2778
|
refuseIfAny(problems);
|
|
2756
|
-
const explicitDrawn = alt.drawn
|
|
2757
|
-
const isSubsetSet = alt.parts !== undefined
|
|
2779
|
+
const explicitDrawn = alt.drawn;
|
|
2780
|
+
const isSubsetSet = alt.parts !== undefined;
|
|
2758
2781
|
let coveredBones: Set<string> | null = null;
|
|
2759
2782
|
if (isSubsetSet) {
|
|
2760
2783
|
coveredBones = new Set<string>();
|
|
@@ -2776,7 +2799,7 @@ export function buildWaistPairSource(
|
|
|
2776
2799
|
if (explicitDrawn !== undefined) {
|
|
2777
2800
|
for (const boneName of Object.keys(explicitDrawn)) {
|
|
2778
2801
|
if (!B.has(boneName)) {
|
|
2779
|
-
fail('RIG_NAME_RESOLVES', `
|
|
2802
|
+
fail('RIG_NAME_RESOLVES', `parts.json alternates[${altIdx}].drawn.${boneName}`, `names the bone "${boneName}", which config.bones does not declare`);
|
|
2780
2803
|
} else if (coveredBones !== null && !coveredBones.has(boneName)) {
|
|
2781
2804
|
const coveredList = [...coveredBones].map((b) => `"${b}"`).join(', ') || 'none';
|
|
2782
2805
|
fail(
|
package/src/setuppainting.ts
CHANGED
|
@@ -11,10 +11,16 @@ export interface SetupPaintingRegion {
|
|
|
11
11
|
max_pixels?: number;
|
|
12
12
|
}
|
|
13
13
|
export interface SetupPainting {
|
|
14
|
+
/** Issue #943: the author's declaration that this check may not SKIP. When
|
|
15
|
+
* true, a missing rgb_tolerance, regions or any region's max_pixels is
|
|
16
|
+
* refused by name (SETUP_PAINTING_REQUIRED_LIMIT) instead of reported as
|
|
17
|
+
* SKIP. Absent or false: the pre-#943 reading, unchanged. */
|
|
18
|
+
required?: boolean;
|
|
14
19
|
/** A pixel counts when its maximum RGB difference is strictly greater. */
|
|
15
20
|
rgb_tolerance: number;
|
|
16
21
|
regions: SetupPaintingRegion[];
|
|
17
22
|
}
|
|
23
|
+
export const SETUP_PAINTING_REQUIRED_CODE = 'SETUP_PAINTING_REQUIRED_LIMIT';
|
|
18
24
|
const object = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
19
25
|
const integer = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v);
|
|
20
26
|
const free = (k: string, v: unknown): boolean => (k.startsWith('x-') && k.length > 2) || ((k === 'note' || k.endsWith('_note')) && typeof v === 'string');
|
|
@@ -27,9 +33,17 @@ export function setupPaintingProblems(value: unknown, at: string): Problem[] {
|
|
|
27
33
|
for (const k of Object.keys(v)) if (!allowed.includes(k) && !free(k, v[k])) fail(`${where}.${k}`, `is unknown; fields are ${allowed.join(', ')}`);
|
|
28
34
|
};
|
|
29
35
|
if (!object(value)) return [{ code: 'REQUIREMENTS_FIELD', object: at, detail: 'requires {rgb_tolerance, regions: [{name, box, max_pixels?}]}' }];
|
|
30
|
-
keys(value, ['rgb_tolerance', 'regions'], at);
|
|
31
|
-
if (
|
|
32
|
-
|
|
36
|
+
keys(value, ['required', 'rgb_tolerance', 'regions'], at);
|
|
37
|
+
if ('required' in value && typeof value.required !== 'boolean') fail(`${at}.required`, `is ${JSON.stringify(value.required)}; requires true (the check may not SKIP: every limit must be declared) or false (the same as omitting it)`);
|
|
38
|
+
// Issue #943: under required: true an absent limit is not a SKIP to report but a refusal naming the field.
|
|
39
|
+
const required = value.required === true;
|
|
40
|
+
const missing = (where: string, needed: string): void => {
|
|
41
|
+
problems.push({ code: SETUP_PAINTING_REQUIRED_CODE, object: where, detail: `is absent; setup_painting.required is true, so SETUP_MATCHES_PAINTING may not SKIP — declare ${needed}. No limit is derived from the art` });
|
|
42
|
+
};
|
|
43
|
+
if (required && !('rgb_tolerance' in value)) missing(`${at}.rgb_tolerance`, 'an authored integer from 0 through 255');
|
|
44
|
+
else if (!integer(value.rgb_tolerance) || value.rgb_tolerance < 0 || value.rgb_tolerance > 255) fail(`${at}.rgb_tolerance`, 'requires an authored integer from 0 through 255; difference strictly above it counts');
|
|
45
|
+
if (required && !('regions' in value)) missing(`${at}.regions`, 'a nonempty list of named source-grid regions, each with box and an authored max_pixels');
|
|
46
|
+
else if (!Array.isArray(value.regions) || value.regions.length === 0) fail(`${at}.regions`, 'requires a nonempty list of named source-grid regions');
|
|
33
47
|
else {
|
|
34
48
|
const names = new Set<string>();
|
|
35
49
|
value.regions.forEach((r, i) => {
|
|
@@ -40,6 +54,7 @@ export function setupPaintingProblems(value: unknown, at: string): Problem[] {
|
|
|
40
54
|
else { if (names.has(r.name)) fail(`${where}.name`, `declares region "${r.name}" twice`); names.add(r.name); }
|
|
41
55
|
if (!Array.isArray(r.box) || r.box.length !== 4 || !r.box.every(integer) || r.box[0] < 0 || r.box[1] < 0 || r.box[2] <= 0 || r.box[3] <= 0 || !Number.isSafeInteger(r.box[0] + r.box[2]) || !Number.isSafeInteger(r.box[1] + r.box[3])) fail(`${where}.box`, 'requires nonnegative integer x/y and positive integer width/height in stage pixels, with finite integer far edges');
|
|
42
56
|
if ('max_pixels' in r && (!integer(r.max_pixels) || r.max_pixels < 0)) fail(`${where}.max_pixels`, 'requires a nonnegative authored integer pixel count, or omission for SKIP');
|
|
57
|
+
else if (required && !('max_pixels' in r)) missing(`${where}.max_pixels${typeof r.name === 'string' ? ` (region "${r.name}")` : ''}`, 'an authored nonnegative integer pixel count for this region; without it the region would report SKIP');
|
|
43
58
|
});
|
|
44
59
|
}
|
|
45
60
|
return problems;
|