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.
@@ -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
+ }
@@ -1,8 +1,10 @@
1
1
  /** Posed-axis evidence from one stock seam render, never sampled source keys. */
2
- import type { GeometryFile, GeometryFrame } from '@rig-c/core/render';
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
+ }
@@ -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 ?? cfg.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 ?? cfg.alternates ?? []) {
1357
+ for (const a of cfg.assemble.alternates ?? []) {
1358
1358
  const record = parts.alternates?.find((p) => p.name === a.name);
1359
- if (a.base_anchor !== undefined && record === undefined) fail('RIG_ALT_BASE_ANCHOR_MATCH_CONFIG', `alternate set "${a.name}" base_anchor`, 'has no assembled set; re-run assemble before rig');
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 ?? cfg.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 ?? cfgAlt?.drawn;
2757
- const isSubsetSet = alt.parts !== undefined || cfgAlt?.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', `config.alternates[${altIdx}].drawn.${boneName}`, `names the bone "${boneName}", which config.bones does not declare`);
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(
@@ -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 (!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');
32
- if (!Array.isArray(value.regions) || value.regions.length === 0) fail(`${at}.regions`, 'requires a nonempty list of named source-grid regions');
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;