@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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
package/src/config.ts ADDED
@@ -0,0 +1,2283 @@
1
+ /**
2
+ * The character config: everything character-specific the CPU stages read,
3
+ * plus the optional blocks for the two GPU stages.
4
+ *
5
+ * One file per character, filled in as the loop runs. What a step requires is
6
+ * what that step reads, and {@link CONFIG_REQUIRES} states it once per loader:
7
+ * the full loader, in front of `rig` and `build`, requires `key`, `assemble`,
8
+ * `bones`, `meshes`, `regions` and `motion`; the early doors, in front of the
9
+ * steps that run before `propose` has drafted the rig, require less.
10
+ * `generation` is read only by the optional image-generation adapter; a
11
+ * character whose painting came from anywhere else leaves it out.
12
+ *
13
+ * 🔒 **Every key is known or refused.** An unknown key is refused by name and
14
+ * so is a missing one — a config that half-loads is a rig nobody can reason
15
+ * about. There are two open doors, and both lead nowhere — nothing reads what
16
+ * stands behind them. Annotation: `note`, and any key ending in `_note`, may
17
+ * hold a string anywhere an object is expected. Annotations are how a hand
18
+ * correction records its reason beside the value it corrected, and refusing
19
+ * them would push that reason out of the file. Record: any key beginning with
20
+ * `x-` and at least one character after it may hold any JSON value in the same
21
+ * places (issue #70) — a project's own provenance, such as the gate results of
22
+ * a past build or the seeds a search rejected, kept beside the conditions that
23
+ * made the rig. The record test comes first, so `x-seed_note` is a record. The
24
+ * part-name maps (`meshes`, `regions`, `motion.blink.still`) are not objects
25
+ * in this sense: their keys are part names, and an `x-` key there is a part
26
+ * name like any other. No stage writes a vouched object whole into an output;
27
+ * each writer names its fields (`resolvedSampler` and its kin in
28
+ * `src/graphs.ts`), which is what keeps both doors read by nothing.
29
+ *
30
+ * ⛔ **The generation block is inline.** Configs written for the private
31
+ * reference pointed at a character file and an art-job id elsewhere
32
+ * (`generation.character_file`, `generation.art_job`) and overrode parts of it
33
+ * (`identity_override`, `lora_strength`). None of that resolves outside the
34
+ * tree it was written in, so each of those keys is refused with a message that
35
+ * names what replaces it, rather than as a bare unknown.
36
+ *
37
+ * Coordinates in `bones`, `meshes` and `motion` are RIG pixels, y down, origin
38
+ * top-left — the parts' own space. The y flip to Spine happens once, in
39
+ * `rig-c/src/transform.ts` (see `src/coords.ts`), when the rig is built.
40
+ *
41
+ * 🔗 **`constraints` is rig-c's, verbatim (issue #92).** An optional list
42
+ * of constraint objects in rig-c's own rig-spec shape (`RigConstraint`:
43
+ * `ik`, `transform`, `path`, `physics`, `slider`), handed to rigc as
44
+ * `rig.json`'s `constraints` in the order written. The loader owns four things
45
+ * and nothing else: the list is a list of objects; each has a `type` rigc's
46
+ * union names and a non-empty `name`, unique within its kind (rigc's identity
47
+ * of a constraint is its kind and its name); every bone a constraint names
48
+ * — the fields {@link CONSTRAINT_BONE_FIELDS} lists, read off rigc's
49
+ * `buildRigConstraint` — is a bone `bones` declares; and a bone a constraint
50
+ * follows does not move with the bones it drives ({@link checkConstraints}).
51
+ * Every other field, its type and its range — an ik's `bones` as a shape
52
+ * included (rig-c 2.15.0, issue #103) — are rigc's to refuse, at the rig
53
+ * stage's gate, in rigc's words. A record
54
+ * (`x-…`) and an annotation (`note`, `…_note`) ride on a constraint as on any
55
+ * object the loader vouches for, and the rig stage leaves them out of what it
56
+ * hands rigc ({@link constraintForRig}).
57
+ *
58
+ * 🔗 **`motion.animations_from` is rig-c's too, verbatim (issue #183).** An
59
+ * optional path, relative to the config file's directory, to a file in
60
+ * rig-c's motion-spec shape whose `animations` are written into `motion.json`
61
+ * beside the idle on every `rig` and `build`, untouched. The loader owns what
62
+ * the merge needs and nothing else ({@link readAnimationsFrom}): the file
63
+ * exists and parses; it is an object holding `spec` ("rigc-motion/1") and an
64
+ * `animations` table of objects, nothing else that would be dropped; no
65
+ * animation is named `idle`; and no key in it is written twice. What an
66
+ * animation holds is rigc's to accept or refuse, at the rig stage's gate. The
67
+ * file is read where the config is read from a path ({@link loadConfig});
68
+ * {@link parseConfig}, which has no path to resolve against, checks only that
69
+ * the field is a non-empty string.
70
+ */
71
+ import { existsSync, readFileSync, statSync } from 'node:fs';
72
+ import { dirname, resolve } from 'node:path';
73
+ import { RIG_SKIN_CONSTRAINT_KEYS, type RigConstraint, type RigSkinConstraintKey } from 'rig-c/src/rig.ts';
74
+ import { GRID, MAX_SIDE } from './contour.ts';
75
+ import { type Problem, refuseIfAny } from './errors.ts';
76
+ import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
77
+ import { acceptedTagNames, readTag } from './tags.ts';
78
+
79
+ export type Point = [number, number];
80
+
81
+ export interface Lora {
82
+ name: string;
83
+ strength: number;
84
+ strength_clip?: number;
85
+ }
86
+
87
+ export interface Sampler {
88
+ steps: number;
89
+ cfg: number;
90
+ sampler: string;
91
+ scheduler: string;
92
+ }
93
+
94
+ export interface Control {
95
+ /**
96
+ * A built-in skeleton's name, or the path of a pose file relative to the
97
+ * config's directory (issue #198; {@link readControlPose} reads it). A
98
+ * string that is not a built-in name is a path.
99
+ */
100
+ skeleton: SkeletonName | string;
101
+ strength: number;
102
+ end_percent: number;
103
+ model?: string;
104
+ }
105
+
106
+ export interface Generation {
107
+ checkpoint: string;
108
+ loras: Lora[];
109
+ trigger: string;
110
+ identity: string;
111
+ sampler: Sampler;
112
+ costume: string;
113
+ negative_extra: string;
114
+ pose?: string;
115
+ style: string;
116
+ negative_pose: string;
117
+ latent: [number, number];
118
+ seed: number;
119
+ control?: Control;
120
+ }
121
+
122
+ export interface SeeThrough {
123
+ /** Square crop `[x0, y0, x1, y1]` in source pixels for the second, head-only run. */
124
+ head_box?: [number, number, number, number];
125
+ resolution: number;
126
+ steps: number;
127
+ seed: number;
128
+ offload: boolean;
129
+ }
130
+
131
+ export type Run = 'full' | 'head';
132
+
133
+ /** One part: its name, which run it comes from, and the See-through tag it is. Order = draw order, back to front. */
134
+ export type PlanEntry = [string, Run, string];
135
+
136
+ export interface Extend {
137
+ part: string;
138
+ run: Run;
139
+ tag: string;
140
+ }
141
+
142
+ /** Which pixels of a patch's box it takes: the painting's figure silhouette inside the box, or the whole box. */
143
+ export type PatchAlpha = 'silhouette' | 'box';
144
+ export const PATCH_ALPHAS: readonly PatchAlpha[] = ['silhouette', 'box'];
145
+
146
+ /** Where a patch is drawn: behind every part, in front of every part, or immediately behind a named plan part. */
147
+ export type PatchDraw = 'back' | 'front' | { before: string };
148
+
149
+ /**
150
+ * An extra part cut from the PAINTING rather than from a See-through layer —
151
+ * for a piece of the figure no layer holds (a hem the runs dropped). Its
152
+ * `box` is `[x0, y0, x1, y1]` in RIG pixels, the space `parts.json` and the
153
+ * recomposite are in, `x1`/`y1` exclusive. Its bone is `regions.<name>`, as
154
+ * for every region part: a patch is always a region, never a mesh.
155
+ */
156
+ export interface Patch {
157
+ name: string;
158
+ box: [number, number, number, number];
159
+ alpha: PatchAlpha;
160
+ draw: PatchDraw;
161
+ }
162
+
163
+ /**
164
+ * A declared cut of a plan part (issue #170): the pixels of `from`'s layer
165
+ * whose centre lies inside `polygon` leave `from` and make the part `into`,
166
+ * a part like any other — its own `parts.json` record and PNG, a `meshes` or
167
+ * `regions` entry, its own place in the draw order. `polygon` is `[[x, y],
168
+ * …]`, at least three points, in non-negative integer RIG pixels (the space
169
+ * of `parts.json` and the patches' boxes), even-odd, a pixel inside when its
170
+ * centre `(x + 0.5, y + 0.5)` is. `draw` places `into` exactly as a patch's
171
+ * `draw` places a patch. What the config alone cannot know — whether the
172
+ * polygon lies inside the rig, takes any of the layer's art, or overlaps
173
+ * another cut of the same part — the assemble stage refuses
174
+ * (`ASSEMBLE_CUT_INSIDE`, `ASSEMBLE_CUT_PIXELS`, `ASSEMBLE_CUT_OVERLAP`).
175
+ */
176
+ export interface Cut {
177
+ from: string;
178
+ into: string;
179
+ polygon: Array<[number, number]>;
180
+ draw: PatchDraw;
181
+ /**
182
+ * The band, in rig px, inside the cut's edge that the from part keeps as
183
+ * well as the piece (`cutBand` in `src/assemble.ts`), so the piece's
184
+ * resampled edge has art under it. Required, 0 allowed: 0 makes the two
185
+ * abut, which `check`'s seam bar reads.
186
+ */
187
+ overlap: number;
188
+ /**
189
+ * Issue #193, optional: a PNG, its path relative to the config's directory,
190
+ * of what lies under the piece — in rig pixels over the polygon's bounding
191
+ * box (`underBox` in `src/assemble.ts`). Where the cut removed pixels from
192
+ * the base (the piece's pixels less the band), the base takes this image's
193
+ * pixels; it is read nowhere else. Absent: the base keeps only its band.
194
+ */
195
+ under?: string;
196
+ }
197
+
198
+ export interface Assemble {
199
+ /** Rig pixels per source pixel. */
200
+ rig_scale: number;
201
+ plan: PlanEntry[];
202
+ extend_below_crop?: Extend[];
203
+ patches?: Patch[];
204
+ cuts?: Cut[];
205
+ }
206
+
207
+ export interface SingleBone {
208
+ name: string;
209
+ parent: string;
210
+ at: Point;
211
+ tip?: Point;
212
+ }
213
+
214
+ export interface ChainBones {
215
+ /** The links are named `<chain>0`, `<chain>1`, … in `points` order. */
216
+ chain: string;
217
+ parent: string;
218
+ points: Point[];
219
+ tip: Point;
220
+ }
221
+
222
+ export type BoneEntry = SingleBone | ChainBones;
223
+
224
+ /** A chain name, a bone name, or an explicit segment `[bone, from, to]`. */
225
+ export type Segment = string | [string, Point, Point];
226
+
227
+ /**
228
+ * One mesh: exactly one of three modes (issue #84, issue #126). `r` and
229
+ * `segments` mean the same in all three. Which mode is the author's choice on
230
+ * every entry; nothing picks one, and `propose` still writes `grid`.
231
+ *
232
+ * - `grid`: the lattice (`src/mesh.ts`), as it has always been — every byte it
233
+ * writes is unchanged by the second mode existing.
234
+ * - `contour`: the part's alpha outline through rig-c's tracer, with
235
+ * interior vertices placed where they are declared (`src/contour.ts`), and
236
+ * local deformation regions weighted to their own bones
237
+ * (`src/localweights.ts`).
238
+ * - `auto`: the contour mesh at alpha 1 and above as the source, reduced and
239
+ * locally refined by rig-c's `reduceMesh` under the author's declared
240
+ * bounds (`src/automesh.ts`).
241
+ */
242
+ export type MeshSpec = LatticeMeshSpec | ContourMeshSpec | AutoMeshSpec;
243
+
244
+ /**
245
+ * `meshes.<part>.rule` (issue #161): how a mesh's vertices are weighted. `"distance"` is `influences()`
246
+ * (`src/weights.ts`), and what an absent field means; `"heat"` is bone heat over the part's silhouette
247
+ * (`src/heat.ts`), in every mode. Nothing else is a rule.
248
+ */
249
+ export type WeightRule = 'distance' | 'heat';
250
+
251
+ export const WEIGHT_RULES: readonly WeightRule[] = ['distance', 'heat'];
252
+
253
+ export interface LatticeMeshSpec {
254
+ grid: number;
255
+ r: number;
256
+ segments: Segment[];
257
+ /** The distance rule's exponent, `w = 1/(d + r)^exponent`, above 0 (issue #161); absent is 2, the rule as it always was. */
258
+ exponent?: number;
259
+ /** Absent: `"distance"`. */
260
+ rule?: WeightRule;
261
+ }
262
+
263
+ export interface ContourMeshSpec {
264
+ contour: ContourSpec;
265
+ r: number;
266
+ segments: Segment[];
267
+ /** The distance rule's exponent, `w = 1/(d + r)^exponent`, above 0 (issue #161); absent is 2, the rule as it always was. */
268
+ exponent?: number;
269
+ /** Absent: `"distance"`. */
270
+ rule?: WeightRule;
271
+ /** Rows of vertices across the part along the chains named here (issue #188); absent places none. */
272
+ ribs?: RibsSpec;
273
+ }
274
+
275
+ /**
276
+ * `meshes.<part>.ribs` (issue #188): per chain the mesh's `segments` name, how
277
+ * many ribs each link carries between its origin and its end (`stations`, a
278
+ * whole number, 0 or more; required — a rib at each joint is always placed).
279
+ * The contour and automatic modes only; a lattice's vertices are its grid's
280
+ * corners, and a lattice mesh that declares ribs is refused.
281
+ */
282
+ export type RibsSpec = Record<string, { stations: number }>;
283
+
284
+ /**
285
+ * `meshes.<part>.contour`. Lengths and positions are RIG pixels, like every
286
+ * other length in the config; the rig stage carries a position into the part
287
+ * image by a translation only (the part's box less the pad), so a length means
288
+ * the same number of pixels in both. Art is `ART_ALPHA`, the lattice's and
289
+ * `check`'s; it is not a field.
290
+ */
291
+ export interface ContourSpec {
292
+ /** rig-c's Douglas–Peucker tolerance on the traced outline, px, 0 or more. */
293
+ tolerance: number;
294
+ /** rig-c's outward offset of the simplified outline, px, 0 or more. */
295
+ margin: number;
296
+ /** The background interior spacing, px, above 0. */
297
+ spacing: number;
298
+ /** The most vertices the mesh may have; absent is no budget. */
299
+ budget?: number;
300
+ /** The largest island, in art pixels, that may be left out of the mesh; absent leaves none out. */
301
+ stray?: number;
302
+ /** Local deformation regions; absent is none. */
303
+ regions?: ContourRegionSpec[];
304
+ }
305
+
306
+ /**
307
+ * A local deformation region: a shape in rig px, a control bone, the spacing
308
+ * the mesh is refined at inside it and its band, and the band the bone's
309
+ * weight falls across ({@link import('./localweights.ts').regionWeight}).
310
+ * Every coordinate and length is a multiple of 1/256 px: the weight's 0 and 1
311
+ * are decided exactly on that grid.
312
+ */
313
+ export type ContourRegionSpec =
314
+ | { name: string; shape: 'circle'; cx: number; cy: number; r: number; spacing: number; band: number; bone: string }
315
+ | { name: string; shape: 'polygon'; points: Point[]; spacing: number; band: number; bone: string };
316
+
317
+ /**
318
+ * One mesh in the automatic mode (issue #126, item 2): the contour mesh over
319
+ * the part at alpha 1 and above is the source, and rig-c's `reduceMesh`
320
+ * (`rig-c/mesh`, 2.28.x) refines it inside the declared regions and
321
+ * removes what the declared bounds allow (`src/automesh.ts`); the result is
322
+ * kept only when its motion passes `motion` (`src/automotion.ts`). Every quality
323
+ * input is a number the author wrote, named as rig-c's contract
324
+ * (docs/MESH_REDUCTION.md) names it; none has a default inside this package
325
+ * but the two the agreement fixed for parts's policy — `protect.hull` is false
326
+ * when absent (P20) — and the lists of `protect`, which are empty when absent,
327
+ * as an absent `regions` is "none" everywhere in this config.
328
+ */
329
+ export interface AutoMeshSpec {
330
+ auto: AutoSpec;
331
+ r: number;
332
+ segments: Segment[];
333
+ /** The distance rule's exponent, `w = 1/(d + r)^exponent`, above 0 (issue #161); absent is 2, the rule as it always was. */
334
+ exponent?: number;
335
+ /** Absent: `"distance"`. */
336
+ rule?: WeightRule;
337
+ /** As the contour mode's (issue #188): rows across the source along the chains named, kept by the reduction; absent places none. */
338
+ ribs?: RibsSpec;
339
+ }
340
+
341
+ export interface AutoSpec {
342
+ /** The source mesh: the contour mode's own parameters, px. */
343
+ source: { tolerance: number; margin: number; spacing: number; stray?: number };
344
+ /** What the source must already satisfy (correction 3). */
345
+ sourceBounds: ArtFitBoundsSpec;
346
+ /** What the result must satisfy. */
347
+ targets: { artFit: ArtFitBoundsSpec; maxBoundaryDeviation: number; minAngle?: number };
348
+ protect?: {
349
+ hull?: boolean;
350
+ vertices?: number[];
351
+ edges?: Array<[number, number]>;
352
+ regionBoundaries?: string[];
353
+ weightJump?: number | null;
354
+ influences?: string[];
355
+ };
356
+ influences: { maxInfluences: number; minWeight: number };
357
+ budget: { maxCandidates: number };
358
+ minArtSamples: number;
359
+ regions?: AutoRegionSpec[];
360
+ /**
361
+ * The motion gate's bounds (issue #126, item 3; `src/automotion.ts`). The
362
+ * loader requires it on every auto mesh (`CONFIG_FIELD_PRESENT`): the mode is
363
+ * accepted into a build only when its motion is measured and passes. It is
364
+ * optional in the type only because `buildRig`, which measures geometry
365
+ * alone, does not read it; the rig stage refuses a spec without it by the
366
+ * same code.
367
+ */
368
+ motion?: AutoMotionSpec;
369
+ /**
370
+ * Stage B of rigc#1271 (rig-c 2.25.0–2.27.0), each opt-in and each the
371
+ * author's: absent, the field is not sent to `reduceMesh` and the call is the
372
+ * one it was before the field existed. `boundaryRuns.maxVertices` (a whole
373
+ * number, 2 or more; no default) lets each removal pass replace a run of 2 to
374
+ * that many consecutive source-hull vertices with one chord, as one step held
375
+ * to every declared row; `retriangulate: 'delaunay'` re-triangulates the kept
376
+ * vertices by Delaunay flips once the reduction ends, taken whole only when
377
+ * every declared row still passes; `removalOrder: 'deformation-load'` tries the
378
+ * single removals in ascending predicted load instead of ascending source
379
+ * index. What each promises and does not is rig-c's (docs/MESH_REDUCTION.md
380
+ * §8, the three *Stage B* subsections).
381
+ */
382
+ boundaryRuns?: { maxVertices: number };
383
+ retriangulate?: 'delaunay';
384
+ removalOrder?: 'deformation-load';
385
+ /**
386
+ * The declared stimulus of a part no idle key deforms (issue #196): each
387
+ * link of the named chain rotated by +`degrees` and by −`degrees` about its
388
+ * joint, poses the motion gate measures against beside the idle's
389
+ * (`src/automotion.ts`). A list for a part bound to several chains. Absent:
390
+ * none, and the stage is byte for byte the one it was before the field
391
+ * existed — nothing here derives a motion.
392
+ */
393
+ stimulus?: StimulusSpec | StimulusSpec[];
394
+ }
395
+
396
+ /** `meshes.<part>.auto.stimulus` (issue #196): a chain this mesh's segments name, and the bend, degrees above 0. */
397
+ export interface StimulusSpec {
398
+ chain: string;
399
+ degrees: number;
400
+ }
401
+
402
+ /**
403
+ * `meshes.<part>.auto.motion` — what rig-c's `compareMeshesInMotion`
404
+ * holds the reduced mesh to against its unreduced source on the idle:
405
+ * `maxLocalDeformation` in rig px (the rig's world units: its bones carry no
406
+ * scale), required; `maxStretch` / `minStretch` (ratios) gate only when
407
+ * written — absent, the rows are reported and not gated, the contract's own
408
+ * rule; `deformMayFold` lets the part's folds be listed instead of refused,
409
+ * false when absent — the one non-numeric default, echoed in the row.
410
+ * `gradation` (px per px, 0 or more, or null) is the author's G for rig-c's
411
+ * `MQ_ALLOCATION_CONTRAST` (docs/MESH_REDUCTION.md §8, *G — what it derives
412
+ * from*): how fast the mesh may coarsen away from something that needs
413
+ * density — at d px from a need of size h, edges up to h + G · d, the form of
414
+ * a region's `grade`. Nothing a rig declares fixes it (rigc#1291), so it is
415
+ * never derived: left out (or null) it is sent as null, and the row reads
416
+ * `not-measurable` naming it while `MQ_DEFORM_LOAD` is measured.
417
+ */
418
+ export interface AutoMotionSpec {
419
+ maxLocalDeformation: number;
420
+ maxStretch?: number;
421
+ minStretch?: number;
422
+ deformMayFold?: boolean;
423
+ gradation?: number | null;
424
+ /**
425
+ * How the acceptance loop (`src/autoreplay.ts`) chooses a replayed step when
426
+ * the full result fails the gate (issue #148). Absent: the bisection, and the
427
+ * stage is byte for byte the one it was before the field existed.
428
+ * `{ policy: 'multi-interval', maxProbes }` probes up to `maxProbes` distinct
429
+ * removal steps (a whole number, 1 or more; the author's, never derived) in
430
+ * a fixed order and keeps the fewest-vertex candidate among those that
431
+ * passed — the best among tested candidates, never the last, the minimal
432
+ * or a complete walk.
433
+ */
434
+ selection?: AutoSelectionSpec;
435
+ /**
436
+ * The skinning residual as a per-step veto (issue #126, rig-c 2.31.0's
437
+ * `targets.skinning`, rigc#1295; `src/autoenvelope.ts`). Absent: nothing is
438
+ * sent and the stage is byte for byte the one it was before the field
439
+ * existed. `{ maxResidual }` (drawing px, 0 or more; the author's, never
440
+ * derived) asks rig-c to refuse every removal, boundary run and post-pass
441
+ * whose `MQ_SKINNING_RESIDUAL` against the original source exceeds it, under
442
+ * the envelope derived from the declared idle. The residual is a pose-free
443
+ * bound, not the motion verdict: the motion comparison still runs and still
444
+ * decides.
445
+ */
446
+ residual?: AutoResidualSpec;
447
+ }
448
+
449
+ /** `meshes.<part>.auto.motion.residual` (issue #126, rigc#1295): the author's bound on the skinning residual, drawing px. */
450
+ export interface AutoResidualSpec {
451
+ maxResidual: number;
452
+ }
453
+
454
+ /** `meshes.<part>.auto.motion.selection` (issue #148): the one policy besides the bisection, and its probe budget. */
455
+ export interface AutoSelectionSpec {
456
+ policy: 'multi-interval';
457
+ maxProbes: number;
458
+ }
459
+
460
+ /**
461
+ * An art-fit bound set. `maxOvershoot` and `maxUndercut` are a number (px) or
462
+ * `null`, "declared absent": rig-c measures the residual, reports its row
463
+ * `undeclared` and gates nothing on it. Left out, either is refused by name —
464
+ * absence is never read as "not bounded". `minCoverage` is always a number.
465
+ */
466
+ export interface ArtFitBoundsSpec {
467
+ minCoverage: number;
468
+ maxOvershoot: number | null;
469
+ maxUndercut: number | null;
470
+ }
471
+
472
+ /**
473
+ * A region of the automatic mode, in one of two forms (issue #155).
474
+ *
475
+ * - **Weight and density** ({@link AutoWeightRegionSpec}): a shape and a
476
+ * control bone as a contour region has them, the bone's weight `band` (the
477
+ * same falloff, `src/localweights.ts`), and the density rig-c holds the
478
+ * mesh to (§5 of its contract): every edge meeting the region at most
479
+ * `maxEdgeLength` px (L0), relaxing as `L0 + grade·d` across `transition` px
480
+ * outside it, and the region's art sample floor (P9).
481
+ * - **Density only** ({@link AutoDensityRegionSpec}): the same shape and
482
+ * density with no `bone` and no `band`. It is handed to rig-c exactly as the
483
+ * first form's density is, and it takes no part in the weights: no vertex's
484
+ * `g` comes from it, it adds no bone to `protect.influences`, and a source
485
+ * vertex inside it is weighted as one no region reaches. A vertex rig-c
486
+ * inserts there carries rig-c's interpolation of its source triangle (§6),
487
+ * as every inserted vertex does in either form.
488
+ *
489
+ * The two are told apart by `bone`: the loader takes `bone` and `band`
490
+ * together or neither, and refuses either alone by name. Coordinates and
491
+ * `band` are multiples of 1/256 px, as a contour region's.
492
+ */
493
+ export type AutoRegionSpec = AutoWeightRegionSpec | AutoDensityRegionSpec;
494
+
495
+ /** An automatic-mode region that weights its bone and asks for density (the one form before issue #155). */
496
+ export type AutoWeightRegionSpec =
497
+ | { name: string; shape: 'circle'; cx: number; cy: number; r: number; band: number; bone: string; maxEdgeLength: number; transition: number; grade: number; minArtSamples: number }
498
+ | { name: string; shape: 'polygon'; points: Point[]; band: number; bone: string; maxEdgeLength: number; transition: number; grade: number; minArtSamples: number };
499
+
500
+ /** An automatic-mode region that asks for density and weights nothing (issue #155): no `bone`, no `band`. */
501
+ export type AutoDensityRegionSpec =
502
+ | { name: string; shape: 'circle'; cx: number; cy: number; r: number; maxEdgeLength: number; transition: number; grade: number; minArtSamples: number }
503
+ | { name: string; shape: 'polygon'; points: Point[]; maxEdgeLength: number; transition: number; grade: number; minArtSamples: number };
504
+
505
+ /** Whether an automatic-mode region weights a bone: the loader admits `bone` and `band` together or neither (issue #155). */
506
+ export function weightsABone(rg: AutoRegionSpec): rg is AutoWeightRegionSpec {
507
+ return 'bone' in rg;
508
+ }
509
+
510
+ export interface SingleTrack {
511
+ bone: string;
512
+ prop: SingleProp;
513
+ amp: number;
514
+ period: number;
515
+ phase: number;
516
+ base?: number;
517
+ }
518
+
519
+ export interface ChainTrack {
520
+ chain: string;
521
+ amps: number[];
522
+ period: number;
523
+ phase: number;
524
+ lag: number;
525
+ }
526
+
527
+ export type Track = SingleTrack | ChainTrack;
528
+
529
+ /**
530
+ * A region part the blink moves, cut at a rig row: the rows above `row` are
531
+ * drawn by a second slot `<part>_still` on `bone`, which the blink does not
532
+ * key, and the rows from `row` down stay on the part's own slot and blink.
533
+ * For an eyelash layer that also carries the eyelid crease (issue #26).
534
+ */
535
+ export interface BlinkStill {
536
+ row: number;
537
+ bone: string;
538
+ }
539
+
540
+ /**
541
+ * The idle's one blink. `eyes` names at least one bone; `brows` and
542
+ * `brow_drop` are stated together or not at all, and a stated `brows` names
543
+ * at least one bone — rigc refuses a group with no members, and a
544
+ * `brow_drop` with no brows is a value nothing reads. A figure with no eye
545
+ * bone has no blink: leave `blink` out (`CONFIG_BLINK_GROUP_MEMBERS`).
546
+ */
547
+ export interface Blink {
548
+ t: number;
549
+ eyes: string[];
550
+ brows?: string[];
551
+ squash: number;
552
+ brow_drop?: number;
553
+ still?: Record<string, BlinkStill>;
554
+ }
555
+
556
+ export interface Motion {
557
+ duration: number;
558
+ tracks: Track[];
559
+ blink?: Blink;
560
+ /**
561
+ * A file in rig-c's motion-spec shape whose `animations` are written beside
562
+ * the idle (issue #183), its path relative to the config file's directory.
563
+ * Absent, `motion.json` holds the idle alone.
564
+ */
565
+ animations_from?: string;
566
+ }
567
+
568
+ /** The motion-spec version a file `motion.animations_from` names must state: rig-c's, the one `motion.json` states. */
569
+ export const ANIMATIONS_FROM_SPEC = 'rigc-motion/1';
570
+
571
+ /**
572
+ * The animations `motion.animations_from` names, as read: `table` is the file's
573
+ * `animations` object itself, each animation untouched, in the order
574
+ * `JSON.parse` gives its keys (the file's order, except that a name that is an
575
+ * array index, `"0"` to `"4294967294"`, comes first, in numeric order — the
576
+ * JavaScript object's own key order, which no writer here can change).
577
+ */
578
+ export interface AnimationsFrom {
579
+ /** The file, resolved against the config's directory. */
580
+ file: string;
581
+ table: Record<string, unknown>;
582
+ }
583
+
584
+ /**
585
+ * One entry of `config.constraints`: a constraint in rig-c's rig-spec
586
+ * shape. The loader vouches for `type`, `name` and the bone names in the
587
+ * fields {@link CONSTRAINT_BONE_FIELDS} lists; every other field is carried
588
+ * unread and is rigc's to accept or refuse.
589
+ */
590
+ export interface ConfigConstraint {
591
+ type: RigSkinConstraintKey;
592
+ name: string;
593
+ [field: string]: unknown;
594
+ }
595
+
596
+ export interface CharacterConfig {
597
+ key: string;
598
+ note?: string;
599
+ generation?: Generation;
600
+ seethrough?: SeeThrough;
601
+ assemble: Assemble;
602
+ bones: BoneEntry[];
603
+ meshes: Record<string, MeshSpec>;
604
+ regions: Record<string, string>;
605
+ motion: Motion;
606
+ constraints?: ConfigConstraint[];
607
+ }
608
+
609
+ /** The fields of one constraint kind, as rig-c's interface for that kind declares them. */
610
+ type ConstraintField<K extends RigSkinConstraintKey> = Exclude<keyof Extract<RigConstraint, { type: K }>, number | symbol>;
611
+
612
+ /**
613
+ * 🔒 The bone-bearing fields of each of rig-c's five constraint kinds,
614
+ * and whether each holds one bone name or a list of them — the one table this
615
+ * package keeps of rigc's constraint shapes, and only of the fields that name
616
+ * a bone. Read off `buildRigConstraint` in rig-c 2.10.1
617
+ * (`src/compile.ts`), where each is resolved with its `needBone` (the ik's
618
+ * `bones` and `target`, the transform's `bones` and `source`, the path's
619
+ * `bones`, the physics constraint's `bone`, the slider's optional `bone`); a
620
+ * path's `slot` names a slot and a slider's `animation` an animation, which
621
+ * rigc resolves. rigc exports no such table. Each field name is typed
622
+ * against rigc's own interface for its kind, so a rigc release that renames
623
+ * one breaks the typecheck by name, and the selftest holds every name to
624
+ * rigc's `RIG_KEYS` at run time (`CF66`).
625
+ */
626
+ export const CONSTRAINT_BONE_FIELDS: { readonly [K in RigSkinConstraintKey]: ReadonlyArray<readonly [ConstraintField<K>, 'one' | 'list']> } = {
627
+ ik: [['bones', 'list'], ['target', 'one']],
628
+ transform: [['bones', 'list'], ['source', 'one']],
629
+ path: [['bones', 'list']],
630
+ physics: [['bone', 'one']],
631
+ slider: [['bone', 'one']],
632
+ };
633
+
634
+ /**
635
+ * The bone a constraint follows, by kind — an ik's `target`, a transform's
636
+ * `source` (rigc: "4.2 called this target") — or none. It is what makes a
637
+ * bone a `target` in `src/structure.ts`'s roles, and the bone the rig stage
638
+ * declares `invariants.detached` for.
639
+ */
640
+ export const CONSTRAINT_FOLLOWS: Readonly<Partial<Record<RigSkinConstraintKey, 'target' | 'source'>>> = { ik: 'target', transform: 'source' };
641
+
642
+ /** Whether a key on a vouched object is the loader's own door — a record or an annotation — that nothing reads. */
643
+ export function isDoorKey(key: string): boolean {
644
+ return isRecordKey(key) || key === 'note' || key.endsWith('_note');
645
+ }
646
+
647
+ /**
648
+ * A constraint as the rig stage hands it to rigc: every field the config
649
+ * wrote, in the order written, but the records (`x-…`) and annotations
650
+ * (`note`, `…_note`) the loader let ride on it — they are read by nothing,
651
+ * and rigc refuses a key it does not read. No rigc constraint field is a
652
+ * record or an annotation name (`CF66` holds that to rigc's `RIG_KEYS`), so
653
+ * nothing rigc would read is left out.
654
+ */
655
+ export function constraintForRig(c: ConfigConstraint): Record<string, unknown> {
656
+ const out: Record<string, unknown> = {};
657
+ for (const [key, value] of Object.entries(c)) if (!isDoorKey(key)) out[key] = value;
658
+ return out;
659
+ }
660
+
661
+ /** The single-value bone properties a sine track may drive — rigc's one-channel bone timelines. */
662
+ export const SINGLE_PROPS = ['rotate', 'translatex', 'translatey', 'scalex', 'scaley', 'shearx', 'sheary'] as const;
663
+ export type SingleProp = (typeof SINGLE_PROPS)[number];
664
+
665
+ /** The bone every rig has and no config declares. */
666
+ export const ROOT_BONE = 'root';
667
+
668
+ /** Keys from the private reference era, each with what replaces it. */
669
+ const RETIRED: Record<string, Record<string, string>> = {
670
+ generation: {
671
+ character_file:
672
+ 'pointed at a character file outside this tree; write its values inline instead — generation.checkpoint, generation.loras, generation.trigger, generation.identity and generation.sampler',
673
+ art_job: 'named a job record outside this tree; put the costume words themselves in generation.costume and where they came from in generation.costume_note',
674
+ identity_override: 'overrode the character file\'s identity; with the values inline, write the identity you want in generation.identity',
675
+ lora_strength: 'overrode every LoRA\'s strength; with the values inline, set generation.loras[i].strength on each',
676
+ seed0: 'recorded where a seed search started; the chosen seed is generation.seed, and the search belongs in generation.seed_note, or as a structured record in generation.x-seeds_tried',
677
+ seeds_tried: 'recorded the seeds a search tried; the chosen seed is generation.seed, and the search belongs in generation.seed_note, or as a structured record in generation.x-seeds_tried',
678
+ },
679
+ };
680
+
681
+ /**
682
+ * A project's own record: a key beginning `x-` with at least one character
683
+ * after it, holding any JSON value, read by nothing. `X-`, `x_` and a bare
684
+ * `x-` are not record names, so a typo of one is still refused by name.
685
+ */
686
+ function isRecordKey(key: string): boolean {
687
+ return key.length > 2 && key.startsWith('x-');
688
+ }
689
+
690
+ /** What every refusal of a key the schema does not know adds after its own sentence: the two doors. */
691
+ const DOORS = 'a project\'s own record goes under a key beginning "x-" (any JSON, read by nothing), a remark under note or <name>_note (a string)';
692
+
693
+ // ---------------------------------------------------------------------------
694
+ // the checker
695
+ // ---------------------------------------------------------------------------
696
+
697
+ type Json = unknown;
698
+
699
+ class Check {
700
+ readonly problems: Problem[] = [];
701
+
702
+ fail(code: string, object: string, detail: string): void {
703
+ this.problems.push({ code, object, detail });
704
+ }
705
+
706
+ /** An object with exactly these keys (plus records and annotations); returns it, or null after refusing. */
707
+ object(path: string, v: Json, required: readonly string[], optional: readonly string[]): Record<string, Json> | null {
708
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) {
709
+ this.fail('CONFIG_FIELD_TYPE', path, `is ${show(v)}; an object is required`);
710
+ return null;
711
+ }
712
+ const o = v as Record<string, Json>;
713
+ const section = path.replace(/^config\.?/, '');
714
+ for (const key of Object.keys(o)) {
715
+ if (required.includes(key) || optional.includes(key)) continue;
716
+ if (isRecordKey(key)) continue;
717
+ if (key === 'note' || key.endsWith('_note')) {
718
+ if (typeof o[key] !== 'string') {
719
+ this.fail('CONFIG_FIELD_TYPE', `${path}.${key}`, `is ${show(o[key])}; an annotation is a string — a structured record goes under a key beginning "x-" (any JSON, read by nothing)`);
720
+ }
721
+ continue;
722
+ }
723
+ const retired = RETIRED[section]?.[key];
724
+ if (retired !== undefined) this.fail('CONFIG_KEY_RETIRED', `${path}.${key}`, retired);
725
+ else this.fail('CONFIG_KEY_KNOWN', `${path}.${key}`, `is not a field here; known: ${[...required, ...optional].join(', ')} — ${DOORS}`);
726
+ }
727
+ for (const key of required) if (!(key in o)) this.fail('CONFIG_FIELD_PRESENT', `${path}.${key}`, 'is absent and required');
728
+ return o;
729
+ }
730
+
731
+ string(path: string, v: Json, nonEmpty = true): v is string {
732
+ if (typeof v === 'string' && (!nonEmpty || v !== '')) return true;
733
+ this.fail('CONFIG_FIELD_TYPE', path, `is ${show(v)}; ${nonEmpty ? 'a non-empty string' : 'a string'} is required`);
734
+ return false;
735
+ }
736
+
737
+ number(path: string, v: Json, rule: 'any' | 'positive' | 'non-negative' | 'unit' = 'any'): v is number {
738
+ const ok =
739
+ typeof v === 'number' &&
740
+ Number.isFinite(v) &&
741
+ (rule === 'any' || (rule === 'positive' ? v > 0 : rule === 'non-negative' ? v >= 0 : v >= 0 && v <= 1));
742
+ if (!ok) {
743
+ const need = { any: 'a finite number', positive: 'a number above 0', 'non-negative': 'a number at or above 0', unit: 'a number in [0, 1]' }[rule];
744
+ this.fail('CONFIG_FIELD_TYPE', path, `is ${show(v)}; ${need} is required`);
745
+ }
746
+ return ok;
747
+ }
748
+
749
+ int(path: string, v: Json, min: number): v is number {
750
+ if (typeof v === 'number' && Number.isInteger(v) && v >= min) return true;
751
+ this.fail('CONFIG_FIELD_TYPE', path, `is ${show(v)}; an integer at or above ${min} is required`);
752
+ return false;
753
+ }
754
+
755
+ point(path: string, v: Json): v is Point {
756
+ if (Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number' && Number.isFinite(n))) return true;
757
+ this.fail('CONFIG_FIELD_TYPE', path, `is ${show(v)}; a point [x, y] in rig pixels is required`);
758
+ return false;
759
+ }
760
+
761
+ array(path: string, v: Json, nonEmpty: boolean): v is Json[] {
762
+ if (Array.isArray(v) && (!nonEmpty || v.length > 0)) return true;
763
+ this.fail('CONFIG_FIELD_TYPE', path, `is ${show(v)}; ${nonEmpty ? 'a non-empty array' : 'an array'} is required`);
764
+ return false;
765
+ }
766
+ }
767
+
768
+ function show(v: Json): string {
769
+ if (v === undefined) return 'absent';
770
+ const s = JSON.stringify(v);
771
+ return s.length > 80 ? `${s.slice(0, 77)}...` : s;
772
+ }
773
+
774
+ /** A part name becomes a file name (`parts/<name>.png`) and a Spine slot name, so it may not be a path. */
775
+ function partNameOk(name: string): boolean {
776
+ return name !== '' && !name.startsWith('.') && !/[\\/]/.test(name);
777
+ }
778
+
779
+ // ---------------------------------------------------------------------------
780
+ // the loader
781
+ // ---------------------------------------------------------------------------
782
+
783
+ /** The file as parsed JSON, or a refusal naming why not. Both loaders read through it. */
784
+ function readConfigFile(path: string): Json {
785
+ if (!existsSync(path)) refuseIfAny([{ code: 'CONFIG_FILE_PRESENT', object: path, detail: 'no such file' }]);
786
+ let raw: Json;
787
+ try {
788
+ raw = JSON.parse(readFileSync(path, 'utf8'));
789
+ } catch (err) {
790
+ refuseIfAny([{ code: 'CONFIG_IS_JSON', object: path, detail: `does not parse as JSON: ${(err as Error).message}` }]);
791
+ }
792
+ return raw;
793
+ }
794
+
795
+ export function loadConfig(path: string): CharacterConfig {
796
+ return loadConfigAndAnimations(path).config;
797
+ }
798
+
799
+ /**
800
+ * The full loader with the file `motion.animations_from` names read beside it,
801
+ * relative to the config's directory: every problem in either is one refusal.
802
+ * `animations` is null when the config names no file.
803
+ */
804
+ export function loadConfigAndAnimations(path: string): { config: CharacterConfig; animations: AnimationsFrom | null } {
805
+ return parseConfigFrom(readConfigFile(path), dirname(resolve(path)));
806
+ }
807
+
808
+ // ---------------------------------------------------------------------------
809
+ // the early loader — before the rig exists
810
+ // ---------------------------------------------------------------------------
811
+
812
+ /**
813
+ * Which step is reading the config before the rig exists, and so which fields
814
+ * that step requires. The requirement is the caller's to name because it is a
815
+ * fact about the step, not about the file: the same config legitimately lacks
816
+ * `seethrough` when the painting is generated and must carry it when the
817
+ * See-through images are cut.
818
+ *
819
+ * - `paint` — `comfy paint`, the first step of all: `key` and `generation`.
820
+ * Nothing else exists yet, and nothing else is read.
821
+ * - `layers` — `inputs` and `assemble --propose-plan`: `key`, `seethrough`
822
+ * (`head_box` absent until `propose --head-box` has run) and
823
+ * `assemble.rig_scale`.
824
+ * - `assemble` — plain `assemble`: the `layers` door plus `assemble.plan`,
825
+ * which `--propose-plan` printed, and `extend_below_crop` when present, both
826
+ * under the full loader's own rules. `bones`, `meshes`, `regions` and
827
+ * `motion` are drafted by `propose` from the parts this step writes, so
828
+ * requiring them here is what made a new character need placeholder rig
829
+ * fields (issue #20).
830
+ *
831
+ * `generation` is checked on every door when present, because that block is
832
+ * complete before any image exists.
833
+ */
834
+ export type EarlyDoor = 'paint' | 'layers' | 'assemble';
835
+
836
+ /** Every loader: the three early doors, and `full` — `parseConfig`, in front of `rig`, `build` and `propose --from-config`/`--compare`. */
837
+ export type ConfigDoor = EarlyDoor | 'full';
838
+
839
+ /**
840
+ * 🔒 The one statement of what each loader requires, as dotted field paths.
841
+ * The loaders read their required keys from here — the top-level keys are the
842
+ * first segments, `config.assemble`'s the second — and docs/AUTHORING.md §4's
843
+ * table is compared against it by the selftest, so neither the doc nor a
844
+ * loader can state a requirement the other does not.
845
+ */
846
+ export const CONFIG_REQUIRES: Readonly<Record<ConfigDoor, readonly string[]>> = {
847
+ paint: ['key', 'generation'],
848
+ layers: ['key', 'seethrough', 'assemble.rig_scale'],
849
+ assemble: ['key', 'seethrough', 'assemble.rig_scale', 'assemble.plan'],
850
+ full: ['key', 'assemble.rig_scale', 'assemble.plan', 'bones', 'meshes', 'regions', 'motion'],
851
+ };
852
+
853
+ /** The top-level keys a loader requires, in `CONFIG_REQUIRES` order. */
854
+ function topRequired(door: ConfigDoor): string[] {
855
+ return [...new Set(CONFIG_REQUIRES[door].map((f) => f.split('.')[0]))];
856
+ }
857
+
858
+ /** The keys a loader requires inside one top-level section. */
859
+ function sectionRequired(door: ConfigDoor, section: string): string[] {
860
+ return CONFIG_REQUIRES[door].filter((f) => f.startsWith(`${section}.`)).map((f) => f.slice(section.length + 1));
861
+ }
862
+
863
+ /** What `comfy paint` reads: the key its files are named for and the generation block it paints from. */
864
+ export interface PaintConfig {
865
+ key: string;
866
+ generation: Generation;
867
+ }
868
+
869
+ /**
870
+ * What a character's config holds before its layers exist: a key, the
871
+ * See-through block and the rig scale. `inputs` and `assemble --propose-plan`
872
+ * run at that point, and the full loader would refuse the config for the plan,
873
+ * bones, meshes, regions and motion that are written only after them.
874
+ */
875
+ export interface EarlyConfig {
876
+ key: string;
877
+ seethrough: SeeThrough;
878
+ assemble: { rig_scale: number };
879
+ }
880
+
881
+ /** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
882
+ export interface AssembleConfig {
883
+ key: string;
884
+ seethrough: SeeThrough;
885
+ assemble: Assemble;
886
+ }
887
+
888
+ const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints'] as const;
889
+
890
+ /**
891
+ * The one partial entry point. It validates exactly what its door requires —
892
+ * with the full loader's own rules for each — plus `generation` whenever it is
893
+ * present. The sections a later step writes may be present and are NOT read or
894
+ * vouched for (for `paint` that is everything but `key` and `generation`; for
895
+ * `layers`, `assemble.plan`, `extend_below_crop`, `patches`, `cuts`, `bones`, `meshes`,
896
+ * `regions` and `motion`; for `assemble`, the last four); a caller that needs
897
+ * them uses {@link parseConfig}. Every other key that is not a record or an
898
+ * annotation is still refused by name, and a retired one
899
+ * (`generation.character_file` and its kin) still with what replaces it: a
900
+ * config is not allowed to be half-known at any stage.
901
+ */
902
+ export function parseEarlyConfig(raw: Json, door: 'paint'): PaintConfig;
903
+ export function parseEarlyConfig(raw: Json, door: 'layers'): EarlyConfig;
904
+ export function parseEarlyConfig(raw: Json, door: 'assemble'): AssembleConfig;
905
+ export function parseEarlyConfig(raw: Json, door: EarlyDoor): PaintConfig | EarlyConfig | AssembleConfig {
906
+ return parseEarlyConfigFrom(raw, door, null).config;
907
+ }
908
+
909
+ /** The early loader with, when `base` is the config's directory, the pose file `generation.control.skeleton` names read beside it. */
910
+ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null): { config: PaintConfig | EarlyConfig | AssembleConfig; pose: ControlPose | null } {
911
+ const c = new Check();
912
+ // `generation` is taken out of the generic presence check so its absence can
913
+ // say what the block has to hold, rather than only that it is missing.
914
+ const needed = topRequired(door);
915
+ const required = needed.filter((k) => k !== 'generation');
916
+ const top = c.object('config', raw, required, TOP_KEYS.filter((k) => !required.includes(k)));
917
+ if (top === null) refuseIfAny(c.problems);
918
+ const t = top as Record<string, Json>;
919
+ if ('key' in t) c.string('config.key', t.key);
920
+ if ('generation' in t) checkGeneration(c, t.generation);
921
+ else if (needed.includes('generation')) {
922
+ c.fail(
923
+ 'CONFIG_FIELD_PRESENT',
924
+ 'config.generation',
925
+ `is absent and required; comfy paint generates from it and nothing else — ${GENERATION_REQUIRED.join(', ')}, and pose or control`,
926
+ );
927
+ }
928
+ if (door === 'layers') {
929
+ if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
930
+ if ('assemble' in t) {
931
+ const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts']);
932
+ if (a !== null && 'rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
933
+ }
934
+ }
935
+ if (door === 'assemble') {
936
+ if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
937
+ if ('assemble' in t) checkAssemble(c, t.assemble, door);
938
+ }
939
+ const pose = base === null ? null : poseBeside(c, t.generation, base);
940
+ refuseIfAny(c.problems);
941
+ return { config: raw as PaintConfig | EarlyConfig | AssembleConfig, pose };
942
+ }
943
+
944
+ export function loadEarlyConfig(path: string, door: 'paint'): PaintConfig;
945
+ export function loadEarlyConfig(path: string, door: 'layers'): EarlyConfig;
946
+ export function loadEarlyConfig(path: string, door: 'assemble'): AssembleConfig;
947
+ export function loadEarlyConfig(path: string, door: EarlyDoor): PaintConfig | EarlyConfig | AssembleConfig {
948
+ return parseEarlyConfigFrom(readConfigFile(path), door, dirname(resolve(path))).config;
949
+ }
950
+
951
+ /**
952
+ * `comfy paint`'s loader: the `paint` door, and the pose file
953
+ * `generation.control.skeleton` names read beside the config (issue #198).
954
+ * `pose` is null when the control is a built-in skeleton or absent.
955
+ */
956
+ export function loadPaintConfig(path: string): { config: PaintConfig; pose: ControlPose | null } {
957
+ const { config, pose } = parseEarlyConfigFrom(readConfigFile(path), 'paint', dirname(resolve(path)));
958
+ return { config: config as PaintConfig, pose };
959
+ }
960
+
961
+ /** The pose file a checked `generation` block's control names, read relative to `base`; null for a built-in, an absent control, or a block already refused. */
962
+ function poseBeside(c: Check, gen: Json, base: string): ControlPose | null {
963
+ if (!isPlainObject(gen) || !isPlainObject(gen.control)) return null;
964
+ const skel = gen.control.skeleton;
965
+ if (typeof skel !== 'string' || skel === '' || isSkeletonName(skel)) return null;
966
+ const l = gen.latent;
967
+ const latent = Array.isArray(l) && l.length === 2 && l.every((n) => typeof n === 'number' && Number.isInteger(n) && n > 0) ? ([l[0], l[1]] as [number, number]) : null;
968
+ return readControlPose(c, resolve(base, skel), latent);
969
+ }
970
+
971
+ /**
972
+ * Read the pose file `generation.control.skeleton` names (issue #198), every
973
+ * problem collected in `c`. The file is the built-ins' shape, stated by its
974
+ * fields: `{"width": W, "height": H, "points": {"nose": [x, y], …}}` — the
975
+ * canvas the points are in, which is `generation.latent` (the control image is
976
+ * drawn at the latent size and the points are not scaled), and the 18 body-18
977
+ * keypoints by name, each `[x, y]` in that canvas's pixels, x right, y down.
978
+ *
979
+ * - `CONFIG_FILE_PRESENT`: no such file, or one that cannot be read — the
980
+ * detail names the built-ins too, since a misspelt built-in lands here.
981
+ * - `CONFIG_IS_JSON`: the text does not parse.
982
+ * - `CONFIG_FIELD_TYPE` / `CONFIG_KEY_KNOWN` / `CONFIG_FIELD_PRESENT`: not an
983
+ * object; a key besides the three (and the annotation and record doors);
984
+ * one of the three absent; a size that is not a whole positive pixel count;
985
+ * a point that is not two finite numbers.
986
+ * - `CONFIG_KEY_UNIQUE`: a key written twice (`JSON.parse` keeps the last).
987
+ * - `CONFIG_POSE_CANVAS`: `width` x `height` is not `generation.latent`.
988
+ * - `CONFIG_POSE_KEYPOINTS`: `points` holds another count than the 18, or a
989
+ * name that is not body-18; the found and required counts and the names.
990
+ * - `CONFIG_POSE_IN_CANVAS`: a point outside `0 <= x <= width`, `0 <= y <=
991
+ * height` (continuous canvas coordinates, the edges inside, as
992
+ * `src/keypoints.ts` reads a joint), named with the canvas size.
993
+ *
994
+ * Returns the pose when this file added no problem, else null.
995
+ */
996
+ function readControlPose(c: Check, file: string, latent: [number, number] | null): ControlPose | null {
997
+ const field = 'config.generation.control.skeleton';
998
+ const shape = `${SKELETON_NAMES.map((n) => `"${n}"`).join(' or ')}, or a pose file {"width": W, "height": H, "points": {"nose": [x, y], …the 18 body-18 keypoints}} at a path relative to the config's directory, is required`;
999
+ if (!existsSync(file) || !statSync(file).isFile()) {
1000
+ c.fail('CONFIG_FILE_PRESENT', field, `names ${file}, which is neither a built-in skeleton nor a file; ${shape}`);
1001
+ return null;
1002
+ }
1003
+ let text: string;
1004
+ try {
1005
+ text = readFileSync(file, 'utf8');
1006
+ } catch (err) {
1007
+ c.fail('CONFIG_FILE_PRESENT', field, `names ${file}, which cannot be read: ${(err as Error).message}; ${shape}`);
1008
+ return null;
1009
+ }
1010
+ let raw: Json;
1011
+ try {
1012
+ raw = JSON.parse(text);
1013
+ } catch (err) {
1014
+ c.fail('CONFIG_IS_JSON', `${field} (${file})`, `does not parse as JSON: ${(err as Error).message}`);
1015
+ return null;
1016
+ }
1017
+ const before = c.problems.length;
1018
+ const at = `${field} (${file})`;
1019
+ for (const r of repeatedKeys(text)) {
1020
+ c.fail('CONFIG_KEY_UNIQUE', `${at}${r.at === '' ? '' : `.${r.at}`}`, `names "${r.key}" ${r.count === 2 ? 'twice' : `${r.count} times`}; each key once is required — JSON keeps only the last, so every value but the last would be dropped without a word`);
1021
+ }
1022
+ const o = c.object(at, raw, ['width', 'height', 'points'], []);
1023
+ if (o === null) return null;
1024
+ const w = 'width' in o && c.int(`${at}.width`, o.width, 1) ? o.width : null;
1025
+ const h = 'height' in o && c.int(`${at}.height`, o.height, 1) ? o.height : null;
1026
+ if (w !== null && h !== null && latent !== null && (w !== latent[0] || h !== latent[1])) {
1027
+ c.fail('CONFIG_POSE_CANVAS', at, `states a ${w}x${h} canvas; generation.latent's ${latent[0]}x${latent[1]} is required — the control image is drawn at the latent size and the points are read in its pixels, not scaled`);
1028
+ }
1029
+ const points = {} as Record<KeypointName, readonly [number, number]>;
1030
+ if ('points' in o) {
1031
+ const pts = o.points;
1032
+ if (!isPlainObject(pts)) {
1033
+ c.fail('CONFIG_FIELD_TYPE', `${at}.points`, `is ${show(pts)}; an object of the 18 body-18 keypoints by name, each [x, y], is required`);
1034
+ } else {
1035
+ const names = Object.keys(pts);
1036
+ const missing = KEYPOINT_NAMES.filter((k) => !(k in pts));
1037
+ const unknown = names.filter((k) => !(KEYPOINT_NAMES as readonly string[]).includes(k));
1038
+ if (missing.length > 0 || unknown.length > 0) {
1039
+ c.fail(
1040
+ 'CONFIG_POSE_KEYPOINTS',
1041
+ `${at}.points`,
1042
+ `holds ${names.length} keypoint(s); the ${KEYPOINT_NAMES.length} body-18 keypoints are required, each once (${KEYPOINT_NAMES.join(', ')})${missing.length > 0 ? ` — missing: ${missing.join(', ')}` : ''}${unknown.length > 0 ? ` — not body-18: ${unknown.join(', ')}` : ''}`,
1043
+ );
1044
+ }
1045
+ for (const k of KEYPOINT_NAMES) {
1046
+ if (!(k in pts)) continue;
1047
+ const v = pts[k];
1048
+ if (!(Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number' && Number.isFinite(n)))) {
1049
+ c.fail('CONFIG_FIELD_TYPE', `${at}.points.${k}`, `is ${show(v)}; a point [x, y] in canvas pixels is required`);
1050
+ continue;
1051
+ }
1052
+ const [x, y] = v as [number, number];
1053
+ if (w !== null && h !== null && !(x >= 0 && x <= w && y >= 0 && y <= h)) {
1054
+ c.fail('CONFIG_POSE_IN_CANVAS', `${at}.points.${k}`, `is [${x}, ${y}]; a point inside the ${w}x${h} canvas is required, 0 <= x <= ${w} and 0 <= y <= ${h}`);
1055
+ }
1056
+ points[k] = [x, y];
1057
+ }
1058
+ }
1059
+ }
1060
+ if (c.problems.length > before) return null;
1061
+ return { file, points };
1062
+ }
1063
+
1064
+ /**
1065
+ * Validate a parsed config. Every problem found is thrown at once, as one
1066
+ * `PartsError`. With no path to resolve against, `motion.animations_from` is
1067
+ * checked as a non-empty string and its file is not read ({@link loadConfig} reads it).
1068
+ */
1069
+ export function parseConfig(raw: Json): CharacterConfig {
1070
+ return parseConfigFrom(raw, null).config;
1071
+ }
1072
+
1073
+ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterConfig; animations: AnimationsFrom | null } {
1074
+ const c = new Check();
1075
+ const required = topRequired('full');
1076
+ const top = c.object('config', raw, required, TOP_KEYS.filter((k) => !required.includes(k)));
1077
+ if (top === null) refuseIfAny(c.problems);
1078
+ const t = top as Record<string, Json>;
1079
+ if ('key' in t) c.string('config.key', t.key);
1080
+ if ('generation' in t) checkGeneration(c, t.generation);
1081
+ if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1082
+ const { parts, patches, cuts } = 'assemble' in t ? checkAssemble(c, t.assemble, 'full') : { parts: [], patches: [], cuts: [] };
1083
+ const names = 'bones' in t ? checkBones(c, t.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
1084
+ if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
1085
+ if ('regions' in t) checkRegions(c, t.regions, names.bones);
1086
+ if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions);
1087
+ if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
1088
+ if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
1089
+ const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
1090
+ const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
1091
+ if (base !== null) poseBeside(c, t.generation, base);
1092
+ refuseIfAny(c.problems);
1093
+ return { config: raw as CharacterConfig, animations };
1094
+ }
1095
+
1096
+ function isPlainObject(v: Json): v is Record<string, Json> {
1097
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
1098
+ }
1099
+
1100
+ /**
1101
+ * Read the file `motion.animations_from` names (issue #183) and vouch for what
1102
+ * merging its animations beside the idle needs, every problem collected in `c`:
1103
+ *
1104
+ * - `CONFIG_FILE_PRESENT`: no such file, or a file that cannot be read.
1105
+ * - `CONFIG_IS_JSON`: the text does not parse.
1106
+ * - `CONFIG_FIELD_TYPE`: the file is not an object, its `spec` is not
1107
+ * "rigc-motion/1", its `animations` is not an object, or an animation in it
1108
+ * is not one. `CONFIG_KEY_KNOWN` / `CONFIG_FIELD_PRESENT`: a key besides
1109
+ * `spec` and `animations` (and the annotation and record doors), or one of
1110
+ * the two absent. rig-c's `easings`, `groups`, `setup`, `physics` and `mix`
1111
+ * are refused rather than dropped: `motion.json`'s own are this package's,
1112
+ * and a table read and not written would be a value lost without a word.
1113
+ * - `CONFIG_ANIMATION_NAME_FREE`: an animation named `idle`, the one the
1114
+ * config's own `motion` writes.
1115
+ * - `CONFIG_KEY_UNIQUE`: a key written twice in one object of the file.
1116
+ * `JSON.parse` keeps the last and drops the others silently, so the file's
1117
+ * text is read for it ({@link repeatedKeys}).
1118
+ *
1119
+ * Returns the animations when this file added no problem, else null.
1120
+ */
1121
+ function readAnimationsFrom(c: Check, file: string): AnimationsFrom | null {
1122
+ const field = 'config.motion.animations_from';
1123
+ const shape = `a file in rig-c's motion-spec shape, {"spec": "${ANIMATIONS_FROM_SPEC}", "animations": {"<name>": {"duration": …, "tracks": [...]}}}, its path relative to the config's directory`;
1124
+ if (!existsSync(file) || !statSync(file).isFile()) {
1125
+ c.fail('CONFIG_FILE_PRESENT', field, `names ${file}, which is no such file; ${shape} is required`);
1126
+ return null;
1127
+ }
1128
+ let text: string;
1129
+ try {
1130
+ text = readFileSync(file, 'utf8');
1131
+ } catch (err) {
1132
+ c.fail('CONFIG_FILE_PRESENT', field, `names ${file}, which cannot be read: ${(err as Error).message}; ${shape} is required`);
1133
+ return null;
1134
+ }
1135
+ let raw: Json;
1136
+ try {
1137
+ raw = JSON.parse(text);
1138
+ } catch (err) {
1139
+ c.fail('CONFIG_IS_JSON', `${field} (${file})`, `does not parse as JSON: ${(err as Error).message}`);
1140
+ return null;
1141
+ }
1142
+ const before = c.problems.length;
1143
+ const at = `${field} (${file})`;
1144
+ if (!isPlainObject(raw)) {
1145
+ c.fail('CONFIG_FIELD_TYPE', at, `is ${show(raw)}; ${shape} is required`);
1146
+ return null;
1147
+ }
1148
+ for (const r of repeatedKeys(text)) {
1149
+ c.fail(
1150
+ 'CONFIG_KEY_UNIQUE',
1151
+ `${at}${r.at === '' ? '' : `.${r.at}`}`,
1152
+ `names "${r.key}" ${r.count === 2 ? 'twice' : `${r.count} times`}; each key once is required — JSON keeps only the last, so ${r.at === 'animations' ? 'every animation of that name but the last' : 'every value but the last'} would be dropped without a word`,
1153
+ );
1154
+ }
1155
+ const o = c.object(at, raw, ['spec', 'animations'], []);
1156
+ if (o !== null && 'spec' in o && o.spec !== ANIMATIONS_FROM_SPEC) {
1157
+ c.fail('CONFIG_FIELD_TYPE', `${at}.spec`, `is ${show(o.spec)}; "${ANIMATIONS_FROM_SPEC}" is required, the motion-spec version motion.json states`);
1158
+ }
1159
+ if (o !== null && 'animations' in o) {
1160
+ if (!isPlainObject(o.animations)) {
1161
+ c.fail('CONFIG_FIELD_TYPE', `${at}.animations`, `is ${show(o.animations)}; a table keyed by animation name is required, {"<name>": {"duration": …, "tracks": [...]}}`);
1162
+ } else {
1163
+ for (const [name, anim] of Object.entries(o.animations)) {
1164
+ if (name === 'idle') {
1165
+ c.fail('CONFIG_ANIMATION_NAME_FREE', `${at}.animations.idle`, 'is the idle, which config.motion writes; an animation beside it needs another name');
1166
+ } else if (!isPlainObject(anim)) {
1167
+ c.fail('CONFIG_FIELD_TYPE', `${at}.animations.${name}`, `is ${show(anim)}; an animation object in rig-c's motion-spec shape is required ({"duration": …, "tracks": [...]}, and what else rigc reads)`);
1168
+ }
1169
+ }
1170
+ }
1171
+ }
1172
+ if (c.problems.length > before || o === null) return null;
1173
+ return { file, table: o.animations as Record<string, unknown> };
1174
+ }
1175
+
1176
+ /**
1177
+ * Every key an object in the JSON `text` names more than once, with the
1178
+ * object's dotted path (`""` for the top level, `animations.smile.tracks[0]`
1179
+ * below it) and how many times — what `JSON.parse` folds into its last value
1180
+ * without a word. `text` must already parse; the walk is a reader of valid
1181
+ * JSON only, and keys are compared after their escapes are decoded.
1182
+ */
1183
+ export function repeatedKeys(text: string): Array<{ at: string; key: string; count: number }> {
1184
+ const out: Array<{ at: string; key: string; count: number }> = [];
1185
+ let i = 0;
1186
+ const ws = (): void => {
1187
+ while (i < text.length && ' \t\n\r'.includes(text[i])) i++;
1188
+ };
1189
+ const str = (): string => {
1190
+ const start = i;
1191
+ i++;
1192
+ while (text[i] !== '"') i += text[i] === '\\' ? 2 : 1;
1193
+ i++;
1194
+ return JSON.parse(text.slice(start, i)) as string;
1195
+ };
1196
+ const value = (at: string): void => {
1197
+ ws();
1198
+ const ch = text[i];
1199
+ if (ch === '{') {
1200
+ i++;
1201
+ const seen = new Map<string, number>();
1202
+ ws();
1203
+ if (text[i] === '}') {
1204
+ i++;
1205
+ return;
1206
+ }
1207
+ for (;;) {
1208
+ ws();
1209
+ const key = str();
1210
+ seen.set(key, (seen.get(key) ?? 0) + 1);
1211
+ ws();
1212
+ i++; // the colon
1213
+ value(at === '' ? key : `${at}.${key}`);
1214
+ ws();
1215
+ if (text[i++] === '}') break;
1216
+ }
1217
+ for (const [key, count] of seen) if (count > 1) out.push({ at, key, count });
1218
+ } else if (ch === '[') {
1219
+ i++;
1220
+ ws();
1221
+ if (text[i] === ']') {
1222
+ i++;
1223
+ return;
1224
+ }
1225
+ for (let k = 0; ; k++) {
1226
+ value(`${at}[${k}]`);
1227
+ ws();
1228
+ if (text[i++] === ']') break;
1229
+ }
1230
+ } else if (ch === '"') str();
1231
+ else while (i < text.length && !',]} \t\n\r'.includes(text[i])) i++;
1232
+ };
1233
+ value('');
1234
+ return out;
1235
+ }
1236
+
1237
+ /** The four sections `propose` drafts and a config carries: what a skeleton comparison reads of either. */
1238
+ export interface SkeletonSections {
1239
+ bones: BoneEntry[];
1240
+ meshes: Record<string, MeshSpec>;
1241
+ regions: Record<string, string>;
1242
+ motion: Motion;
1243
+ }
1244
+
1245
+ /**
1246
+ * A `proposal.json` as `propose` writes it — `bones`, `meshes`, `regions`,
1247
+ * `motion` and `notes` (strings) — under the full loader's own rules for the
1248
+ * four rig sections (issue #85). There is no plan to hold the meshes and
1249
+ * regions against, so the coverage rule (every plan part exactly one of a
1250
+ * mesh or a region) is the one rule not run; `propose` held its proposal to
1251
+ * it before writing (`checkProposal`). A key the loader would not take is
1252
+ * refused by name, as in a config. Refusals name the fields `proposal.…`.
1253
+ */
1254
+ export function parseProposalSections(raw: Json): SkeletonSections {
1255
+ const c = new Check();
1256
+ const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes']);
1257
+ if (top !== null) {
1258
+ const names = 'bones' in top ? checkBones(c, top.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
1259
+ if ('meshes' in top) checkMeshes(c, top.meshes, names.bones, names.chains);
1260
+ if ('regions' in top) checkRegions(c, top.regions, names.bones);
1261
+ if ('motion' in top) checkMotion(c, top.motion, names.bones, names.chains, 'regions' in top ? top.regions : undefined);
1262
+ if ('notes' in top && c.array('config.notes', top.notes, false)) (top.notes as Json[]).forEach((n, i) => c.string(`config.notes[${i}]`, n, false));
1263
+ }
1264
+ refuseIfAny(c.problems.map((p) => ({ ...p, object: p.object.replace(/^config\b/, 'proposal') })));
1265
+ return raw as SkeletonSections;
1266
+ }
1267
+
1268
+ /**
1269
+ * `motion.blink.still`: part name -> `{row, bone}`. The part must be a region
1270
+ * whose bone the blink's `eyes` names — a cut on anything else holds nothing
1271
+ * still that was moving — and `bone` must be declared and must not be one of
1272
+ * the blink's eyes, or the still piece would blink with the rest.
1273
+ */
1274
+ function checkBlinkStill(c: Check, v: Json, eyes: Json, bones: Set<string>, regions: Json): void {
1275
+ const at = 'config.motion.blink.still';
1276
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) {
1277
+ c.fail('CONFIG_FIELD_TYPE', at, `is ${show(v)}; an object of part name -> {row, bone} is required`);
1278
+ return;
1279
+ }
1280
+ const eyeList = Array.isArray(eyes) ? eyes.filter((e): e is string => typeof e === 'string') : [];
1281
+ const regionOf = typeof regions === 'object' && regions !== null && !Array.isArray(regions) ? (regions as Record<string, Json>) : {};
1282
+ for (const [part, entry] of Object.entries(v as Record<string, Json>)) {
1283
+ const e = c.object(`${at}.${part}`, entry, ['row', 'bone'], []);
1284
+ if (e === null) continue;
1285
+ if ('row' in e) c.int(`${at}.${part}.row`, e.row, 0);
1286
+ if ('bone' in e) {
1287
+ if (typeof e.bone !== 'string' || !bones.has(e.bone)) c.fail('CONFIG_NAME_RESOLVES', `${at}.${part}.bone`, `is ${show(e.bone)}; a declared bone is required`);
1288
+ else if (eyeList.includes(e.bone)) {
1289
+ c.fail('CONFIG_STILL_OFF_THE_BLINK', `${at}.${part}.bone`, `is "${e.bone}", which config.motion.blink.eyes names, so the still piece would blink too; a bone the blink does not key is required (the eye bone's parent, as propose writes it)`);
1290
+ }
1291
+ }
1292
+ const rb = Object.prototype.hasOwnProperty.call(regionOf, part) ? regionOf[part] : undefined;
1293
+ if (typeof rb !== 'string') {
1294
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.${part}`, `names "${part}", which config.regions does not attach; a region part is required — a mesh is not cut`);
1295
+ } else if (!eyeList.includes(rb)) {
1296
+ c.fail(
1297
+ 'CONFIG_STILL_OFF_THE_BLINK',
1298
+ `${at}.${part}`,
1299
+ `names a region on "${rb}", which config.motion.blink.eyes (${eyeList.join(', ') || 'none'}) does not name, so the blink never moves it and there is nothing to hold still; a region on a blinking eye bone is required`,
1300
+ );
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ /**
1306
+ * A pose file `generation.control.skeleton` names, as read (issue #198): its
1307
+ * 18 body-18 keypoints, in the pixels of the `generation.latent` canvas the
1308
+ * file states, drawn as given.
1309
+ */
1310
+ export interface ControlPose {
1311
+ /** The file, resolved against the config's directory. */
1312
+ file: string;
1313
+ points: PosePoints;
1314
+ }
1315
+
1316
+ /** The fields every generation block carries; `pose` or `control` is required besides, checked below. */
1317
+ const GENERATION_REQUIRED = ['checkpoint', 'loras', 'trigger', 'identity', 'sampler', 'costume', 'negative_extra', 'style', 'negative_pose', 'latent', 'seed'] as const;
1318
+
1319
+ function checkGeneration(c: Check, v: Json): void {
1320
+ const g = c.object('config.generation', v, GENERATION_REQUIRED, ['pose', 'control']);
1321
+ if (g === null) return;
1322
+ const p = 'config.generation';
1323
+ for (const key of ['checkpoint', 'identity', 'costume', 'style', 'negative_pose'] as const) if (key in g) c.string(`${p}.${key}`, g[key]);
1324
+ for (const key of ['trigger', 'negative_extra'] as const) if (key in g) c.string(`${p}.${key}`, g[key], false);
1325
+ if ('pose' in g) c.string(`${p}.pose`, g.pose);
1326
+ if ('seed' in g) c.int(`${p}.seed`, g.seed, 0);
1327
+ if ('latent' in g) {
1328
+ const l = g.latent;
1329
+ if (!(Array.isArray(l) && l.length === 2 && l.every((n) => typeof n === 'number' && Number.isInteger(n) && n > 0))) {
1330
+ c.fail('CONFIG_FIELD_TYPE', `${p}.latent`, `is ${show(l)}; [width, height] in positive integers is required`);
1331
+ }
1332
+ }
1333
+ if ('loras' in g && c.array(`${p}.loras`, g.loras, false)) {
1334
+ (g.loras as Json[]).forEach((lo, i) => {
1335
+ const o = c.object(`${p}.loras[${i}]`, lo, ['name', 'strength'], ['strength_clip']);
1336
+ if (o === null) return;
1337
+ if ('name' in o) c.string(`${p}.loras[${i}].name`, o.name);
1338
+ if ('strength' in o) c.number(`${p}.loras[${i}].strength`, o.strength);
1339
+ if ('strength_clip' in o) c.number(`${p}.loras[${i}].strength_clip`, o.strength_clip);
1340
+ });
1341
+ }
1342
+ if ('sampler' in g) {
1343
+ const s = c.object(`${p}.sampler`, g.sampler, ['steps', 'cfg', 'sampler', 'scheduler'], []);
1344
+ if (s !== null) {
1345
+ if ('steps' in s) c.int(`${p}.sampler.steps`, s.steps, 1);
1346
+ if ('cfg' in s) c.number(`${p}.sampler.cfg`, s.cfg, 'positive');
1347
+ if ('sampler' in s) c.string(`${p}.sampler.sampler`, s.sampler);
1348
+ if ('scheduler' in s) c.string(`${p}.sampler.scheduler`, s.scheduler);
1349
+ }
1350
+ }
1351
+ if ('control' in g) {
1352
+ const k = c.object(`${p}.control`, g.control, ['skeleton', 'strength', 'end_percent'], ['model']);
1353
+ if (k !== null) {
1354
+ if ('skeleton' in k && (typeof k.skeleton !== 'string' || k.skeleton === '')) {
1355
+ c.fail('CONFIG_FIELD_TYPE', `${p}.control.skeleton`, `is ${show(k.skeleton)}; ${SKELETON_NAMES.map((n) => `"${n}"`).join(' or ')}, or the path of a pose file relative to the config's directory, is required`);
1356
+ } else if ('skeleton' in k && !isSkeletonName(k.skeleton) && !('pose' in g)) {
1357
+ // A pose file is points only; the built-ins' words are theirs, and none is guessed for a file.
1358
+ c.fail('CONFIG_FIELD_PRESENT', `${p}.pose`, `is absent, and generation.control.skeleton names a pose file (${show(k.skeleton)}), which carries no words; the pose words are required from generation.pose`);
1359
+ }
1360
+ if ('strength' in k) c.number(`${p}.control.strength`, k.strength, 'non-negative');
1361
+ if ('end_percent' in k) c.number(`${p}.control.end_percent`, k.end_percent, 'unit');
1362
+ if ('model' in k) c.string(`${p}.control.model`, k.model);
1363
+ }
1364
+ }
1365
+ if (!('pose' in g) && !('control' in g)) {
1366
+ c.fail('CONFIG_FIELD_PRESENT', `${p}.pose`, 'is absent and so is generation.control; the pose words come from one of them, and neither is guessed');
1367
+ }
1368
+ }
1369
+
1370
+ function checkSeeThrough(c: Check, v: Json): void {
1371
+ const s = c.object('config.seethrough', v, ['resolution', 'steps', 'seed', 'offload'], ['head_box']);
1372
+ if (s === null) return;
1373
+ const p = 'config.seethrough';
1374
+ if ('resolution' in s) c.int(`${p}.resolution`, s.resolution, 1);
1375
+ if ('steps' in s) c.int(`${p}.steps`, s.steps, 1);
1376
+ if ('seed' in s) c.int(`${p}.seed`, s.seed, 0);
1377
+ if ('offload' in s && typeof s.offload !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${p}.offload`, `is ${show(s.offload)}; true or false is required`);
1378
+ if ('head_box' in s) {
1379
+ const b = s.head_box;
1380
+ const ints = Array.isArray(b) && b.length === 4 && b.every((n) => typeof n === 'number' && Number.isInteger(n));
1381
+ if (!ints) c.fail('CONFIG_FIELD_TYPE', `${p}.head_box`, `is ${show(b)}; [x0, y0, x1, y1] in integer source pixels is required`);
1382
+ else {
1383
+ const [x0, y0, x1, y1] = b as number[];
1384
+ if (!(x1 > x0 && y1 > y0 && x1 - x0 === y1 - y0)) {
1385
+ c.fail('CONFIG_HEAD_BOX_SQUARE', `${p}.head_box`, `is ${x1 - x0}x${y1 - y0}; a non-empty square is required (the head run is fed a square crop)`);
1386
+ }
1387
+ }
1388
+ }
1389
+ }
1390
+
1391
+ function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
1392
+ if (run !== 'full' && run !== 'head') c.fail('CONFIG_FIELD_TYPE', `${path} run`, `is ${show(run)}; "full" or "head" is required`);
1393
+ if (typeof tag !== 'string' || readTag(tag) === null) {
1394
+ c.fail('CONFIG_TAG_KNOWN', `${path} tag`, `is ${show(tag)}; a See-through v3 tag is required — one of ${acceptedTagNames().join(', ')}`);
1395
+ }
1396
+ }
1397
+
1398
+ /**
1399
+ * Returns the plan's part names, in plan order, the patches' names, in patch
1400
+ * order, and the names the cuts make (`into`), in cut order. `plan` is left
1401
+ * out of the generic presence check so that its absence says which command
1402
+ * writes one — the step a config without a plan has most likely skipped.
1403
+ */
1404
+ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
1405
+ const required = sectionRequired(door, 'assemble');
1406
+ const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts']);
1407
+ if (a === null) return { parts: [], patches: [], cuts: [] };
1408
+ if (required.includes('plan') && !('plan' in a)) {
1409
+ c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
1410
+ }
1411
+ const p = 'config.assemble';
1412
+ if ('rig_scale' in a) c.number(`${p}.rig_scale`, a.rig_scale, 'positive');
1413
+ const parts: string[] = [];
1414
+ if ('plan' in a && c.array(`${p}.plan`, a.plan, true)) {
1415
+ const seenPart = new Map<string, number>();
1416
+ const seenSource = new Map<string, number>();
1417
+ (a.plan as Json[]).forEach((entry, i) => {
1418
+ const at = `${p}.plan[${i}]`;
1419
+ if (!(Array.isArray(entry) && entry.length === 3 && typeof entry[0] === 'string')) {
1420
+ c.fail('CONFIG_FIELD_TYPE', at, `is ${show(entry)}; [part name, "full" | "head", tag] is required`);
1421
+ return;
1422
+ }
1423
+ const [name, run, tag] = entry as [string, Json, Json];
1424
+ if (!partNameOk(name)) c.fail('CONFIG_PART_NAME', at, `names the part ${show(name)}; a part name is a file name, so it may not be empty, start with "." or hold a slash`);
1425
+ checkRunTag(c, at, run, tag);
1426
+ if (seenPart.has(name)) c.fail('CONFIG_PART_UNIQUE', at, `names the part "${name}" again (first at plan[${seenPart.get(name)}]); each part is one layer`);
1427
+ else seenPart.set(name, i);
1428
+ const source = `${String(run)}:${String(tag)}`;
1429
+ if (seenSource.has(source)) c.fail('CONFIG_PART_UNIQUE', at, `takes ${source} again (first at plan[${seenSource.get(source)}]); one layer makes one part`);
1430
+ else seenSource.set(source, i);
1431
+ parts.push(name);
1432
+ });
1433
+ }
1434
+ if ('extend_below_crop' in a && c.array(`${p}.extend_below_crop`, a.extend_below_crop, false)) {
1435
+ (a.extend_below_crop as Json[]).forEach((entry, i) => {
1436
+ const at = `${p}.extend_below_crop[${i}]`;
1437
+ const e = c.object(at, entry, ['part', 'run', 'tag'], []);
1438
+ if (e === null) return;
1439
+ checkRunTag(c, at, e.run, e.tag);
1440
+ if (typeof e.part !== 'string' || !parts.includes(e.part)) {
1441
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.part`, `is ${show(e.part)}; a part named in assemble.plan is required`);
1442
+ }
1443
+ });
1444
+ }
1445
+ const patches = 'patches' in a ? checkPatches(c, a.patches, parts) : [];
1446
+ const cuts = 'cuts' in a ? checkCuts(c, a.cuts, parts, patches) : [];
1447
+ return { parts, patches, cuts };
1448
+ }
1449
+
1450
+ /**
1451
+ * `assemble.cuts` (issue #170): each entry `{from, into, polygon, draw, overlap}`
1452
+ * and optionally `under` (issue #193, a non-empty path string; the assemble
1453
+ * stage reads the file), refused field by field. `from` must be a plan part — a cut of a patch or of
1454
+ * another cut's piece is refused, so every piece's provenance is one layer and
1455
+ * one polygon. `into` is a new part name: not a plan part, not a patch, not
1456
+ * another cut's. Returns the names the cuts make, in order.
1457
+ */
1458
+ function checkCuts(c: Check, v: Json, plan: string[], patches: string[]): string[] {
1459
+ const p = 'config.assemble.cuts';
1460
+ const names: string[] = [];
1461
+ if (!c.array(p, v, false)) return names;
1462
+ const seen = new Map<string, number>();
1463
+ (v as Json[]).forEach((entry, i) => {
1464
+ const at = `${p}[${i}]`;
1465
+ const e = c.object(at, entry, ['from', 'into', 'polygon', 'draw', 'overlap'], ['under']);
1466
+ if (e === null) return;
1467
+ if ('under' in e && !(typeof e.under === 'string' && e.under !== '')) {
1468
+ c.fail('CONFIG_FIELD_TYPE', `${at}.under`, `is ${show(e.under)}; a non-empty path to a PNG is required, relative to the config's directory — the image of what lies under the piece, in rig pixels over the polygon's bounding box`);
1469
+ }
1470
+ if ('overlap' in e && !(typeof e.overlap === 'number' && Number.isInteger(e.overlap) && e.overlap >= 0)) {
1471
+ c.fail('CONFIG_FIELD_TYPE', `${at}.overlap`, `is ${show(e.overlap)}; a non-negative integer of rig pixels is required — the band inside the cut's edge the part keeps under the piece, 0 for none`);
1472
+ }
1473
+ if ('from' in e && (typeof e.from !== 'string' || !plan.includes(e.from))) {
1474
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.from`, `is ${show(e.from)}; a part named in assemble.plan is required — a cut takes pixels out of one plan part's layer`);
1475
+ }
1476
+ if ('into' in e && c.string(`${at}.into`, e.into)) {
1477
+ const name = e.into;
1478
+ if (!partNameOk(name)) c.fail('CONFIG_PART_NAME', `${at}.into`, `names the part ${show(name)}; a part name is a file name, so it may not be empty, start with "." or hold a slash`);
1479
+ if (plan.includes(name)) c.fail('CONFIG_PART_UNIQUE', `${at}.into`, `names the part "${name}", which assemble.plan[${plan.indexOf(name)}] already makes; a cut makes a part of its own`);
1480
+ else if (patches.includes(name)) c.fail('CONFIG_PART_UNIQUE', `${at}.into`, `names the part "${name}", which assemble.patches[${patches.indexOf(name)}] already makes; a cut makes a part of its own`);
1481
+ else if (seen.has(name)) c.fail('CONFIG_PART_UNIQUE', `${at}.into`, `names the part "${name}" again (first at cuts[${seen.get(name)}])`);
1482
+ else seen.set(name, i);
1483
+ names.push(name);
1484
+ }
1485
+ if ('polygon' in e) {
1486
+ const g = e.polygon;
1487
+ const point = (q: Json): boolean => Array.isArray(q) && q.length === 2 && q.every((n) => typeof n === 'number' && Number.isInteger(n) && n >= 0);
1488
+ if (!(Array.isArray(g) && g.length >= 3 && g.every(point))) {
1489
+ c.fail('CONFIG_FIELD_TYPE', `${at}.polygon`, `is ${show(g)}; at least three [x, y] points in non-negative integer rig pixels are required`);
1490
+ }
1491
+ }
1492
+ if ('draw' in e) {
1493
+ const d = e.draw;
1494
+ if (d === 'back' || d === 'front') return;
1495
+ if (typeof d === 'object' && d !== null && !Array.isArray(d)) {
1496
+ const o = c.object(`${at}.draw`, d, ['before'], []);
1497
+ if (o !== null && 'before' in o && (typeof o.before !== 'string' || !plan.includes(o.before))) {
1498
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.draw.before`, `is ${show(o.before)}; a part named in assemble.plan is required`);
1499
+ }
1500
+ return;
1501
+ }
1502
+ c.fail('CONFIG_FIELD_TYPE', `${at}.draw`, `is ${show(d)}; "back", "front" or {"before": "<plan part>"} is required`);
1503
+ }
1504
+ });
1505
+ return names;
1506
+ }
1507
+
1508
+ /**
1509
+ * `assemble.patches`: each entry `{name, box, alpha, draw}`, refused field by
1510
+ * field. What the config alone cannot know — whether the box lies inside the
1511
+ * rig, whether the rule leaves any pixel — the assemble stage refuses
1512
+ * (`ASSEMBLE_PATCH_BOX_INSIDE`, `ASSEMBLE_PATCH_OPAQUE`). Returns the patch
1513
+ * names, in order.
1514
+ */
1515
+ function checkPatches(c: Check, v: Json, plan: string[]): string[] {
1516
+ const p = 'config.assemble.patches';
1517
+ const names: string[] = [];
1518
+ if (!c.array(p, v, false)) return names;
1519
+ const seen = new Map<string, number>();
1520
+ (v as Json[]).forEach((entry, i) => {
1521
+ const at = `${p}[${i}]`;
1522
+ // `bone` is the field a reader expects here, and the answer is a place
1523
+ // that already exists: one place for a region's bone, not two.
1524
+ let fields = entry;
1525
+ if (typeof entry === 'object' && entry !== null && !Array.isArray(entry) && 'bone' in entry) {
1526
+ c.fail('CONFIG_KEY_KNOWN', `${at}.bone`, 'is not a field here; a patch is a region, and the bone a region rides is config.regions.<name> — write it there');
1527
+ const { bone: _bone, ...rest } = entry as Record<string, Json>;
1528
+ fields = rest;
1529
+ }
1530
+ const e = c.object(at, fields, ['name', 'box', 'alpha', 'draw'], []);
1531
+ if (e === null) return;
1532
+ if ('name' in e && c.string(`${at}.name`, e.name)) {
1533
+ const name = e.name;
1534
+ if (!partNameOk(name)) c.fail('CONFIG_PART_NAME', `${at}.name`, `names the part ${show(name)}; a part name is a file name, so it may not be empty, start with "." or hold a slash`);
1535
+ if (plan.includes(name)) c.fail('CONFIG_PART_UNIQUE', `${at}.name`, `names the part "${name}", which assemble.plan[${plan.indexOf(name)}] already makes; a patch is a part of its own`);
1536
+ else if (seen.has(name)) c.fail('CONFIG_PART_UNIQUE', `${at}.name`, `names the part "${name}" again (first at patches[${seen.get(name)}])`);
1537
+ else seen.set(name, i);
1538
+ names.push(name);
1539
+ }
1540
+ if ('box' in e) {
1541
+ const b = e.box;
1542
+ const ints = Array.isArray(b) && b.length === 4 && b.every((n) => typeof n === 'number' && Number.isInteger(n) && n >= 0);
1543
+ if (!ints) c.fail('CONFIG_FIELD_TYPE', `${at}.box`, `is ${show(b)}; [x0, y0, x1, y1] in non-negative integer rig pixels is required`);
1544
+ else {
1545
+ const [x0, y0, x1, y1] = b as number[];
1546
+ if (!(x1 > x0 && y1 > y0)) c.fail('CONFIG_PATCH_BOX', `${at}.box`, `is [${x0}, ${y0}, ${x1}, ${y1}], ${x1 - x0}x${y1 - y0}; a non-empty box is required (x1 > x0 and y1 > y0; x1 and y1 are exclusive)`);
1547
+ }
1548
+ }
1549
+ if ('alpha' in e && !(PATCH_ALPHAS as readonly Json[]).includes(e.alpha)) {
1550
+ c.fail('CONFIG_FIELD_TYPE', `${at}.alpha`, `is ${show(e.alpha)}; one of ${PATCH_ALPHAS.map((r) => `"${r}"`).join(', ')} is required — "silhouette" takes the painting's figure inside the box, "box" the whole box`);
1551
+ }
1552
+ if ('draw' in e) {
1553
+ const d = e.draw;
1554
+ if (d === 'back' || d === 'front') return;
1555
+ if (typeof d === 'object' && d !== null && !Array.isArray(d)) {
1556
+ const o = c.object(`${at}.draw`, d, ['before'], []);
1557
+ if (o !== null && 'before' in o && (typeof o.before !== 'string' || !plan.includes(o.before))) {
1558
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.draw.before`, `is ${show(o.before)}; a part named in assemble.plan is required`);
1559
+ }
1560
+ return;
1561
+ }
1562
+ c.fail('CONFIG_FIELD_TYPE', `${at}.draw`, `is ${show(d)}; "back", "front" or {"before": "<plan part>"} is required`);
1563
+ }
1564
+ });
1565
+ return names;
1566
+ }
1567
+
1568
+ /** Returns every bone name (root and chain links included), each chain's link count, and each declared bone's parent as written. */
1569
+ function checkBones(c: Check, v: Json): { bones: Set<string>; chains: Map<string, number>; parents: Map<string, string> } {
1570
+ const bones = new Set<string>([ROOT_BONE]);
1571
+ const chains = new Map<string, number>();
1572
+ const parents = new Map<string, string>();
1573
+ if (!c.array('config.bones', v, true)) return { bones, chains, parents };
1574
+ // Not `declare`: Bun strips `declare(...)` as a TypeScript ambient declaration
1575
+ // while tsc accepts it as a call, so the call vanished at run time with both
1576
+ // gates green. Measured, not assumed — the selftest has a control for it.
1577
+ const addBone = (name: string, at: string): void => {
1578
+ if (name === ROOT_BONE) c.fail('CONFIG_BONE_UNIQUE', at, `declares "${ROOT_BONE}", which every rig already has`);
1579
+ else if (bones.has(name)) c.fail('CONFIG_BONE_UNIQUE', at, `declares the bone "${name}" a second time`);
1580
+ bones.add(name);
1581
+ };
1582
+ (v as Json[]).forEach((entry, i) => {
1583
+ const at = `config.bones[${i}]`;
1584
+ const isChain = typeof entry === 'object' && entry !== null && 'chain' in entry;
1585
+ if (isChain) {
1586
+ const b = c.object(at, entry, ['chain', 'parent', 'points', 'tip'], []);
1587
+ if (b === null) return;
1588
+ if ('parent' in b && c.string(`${at}.parent`, b.parent) && !bones.has(b.parent)) {
1589
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.parent`, `names "${b.parent}", which is not "${ROOT_BONE}" or a bone declared above it; parents come first`);
1590
+ }
1591
+ if ('tip' in b) c.point(`${at}.tip`, b.tip);
1592
+ if (!c.string(`${at}.chain`, b.chain)) return;
1593
+ const name = b.chain;
1594
+ if (chains.has(name)) c.fail('CONFIG_BONE_UNIQUE', `${at}.chain`, `declares the chain "${name}" a second time`);
1595
+ if ('points' in b && c.array(`${at}.points`, b.points, true)) {
1596
+ const points = b.points as Json[];
1597
+ points.forEach((pt, k) => c.point(`${at}.points[${k}]`, pt));
1598
+ points.forEach((_, k) => addBone(`${name}${k}`, `${at} link ${k}`));
1599
+ points.forEach((_, k) => {
1600
+ if (k > 0) parents.set(`${name}${k}`, `${name}${k - 1}`);
1601
+ else if (typeof b.parent === 'string' && !parents.has(`${name}0`)) parents.set(`${name}0`, b.parent);
1602
+ });
1603
+ chains.set(name, points.length);
1604
+ }
1605
+ return;
1606
+ }
1607
+ const b = c.object(at, entry, ['name', 'parent', 'at'], ['tip']);
1608
+ if (b === null) return;
1609
+ if ('parent' in b && c.string(`${at}.parent`, b.parent) && !bones.has(b.parent)) {
1610
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.parent`, `names "${b.parent}", which is not "${ROOT_BONE}" or a bone declared above it; parents come first`);
1611
+ }
1612
+ if ('at' in b) c.point(`${at}.at`, b.at);
1613
+ if ('tip' in b) c.point(`${at}.tip`, b.tip);
1614
+ if ('name' in b && c.string(`${at}.name`, b.name)) {
1615
+ addBone(b.name, `${at}.name`);
1616
+ if (typeof b.parent === 'string' && !parents.has(b.name)) parents.set(b.name, b.parent);
1617
+ }
1618
+ });
1619
+ return { bones, chains, parents };
1620
+ }
1621
+
1622
+ function checkMeshes(c: Check, v: Json, bones: Set<string>, chains: Map<string, number>): void {
1623
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) {
1624
+ c.fail('CONFIG_FIELD_TYPE', 'config.meshes', `is ${show(v)}; an object of part name -> mesh is required`);
1625
+ return;
1626
+ }
1627
+ for (const [part, spec] of Object.entries(v as Record<string, Json>)) {
1628
+ const at = `config.meshes.${part}`;
1629
+ // The known keys keep their old order (grid, r, segments) with contour after them, and `r` and `segments` are
1630
+ // required in the place and words the object check uses, so a lattice mesh's refusals read as they did.
1631
+ const m = c.object(at, spec, [], ['grid', 'r', 'segments', 'contour', 'auto', 'exponent', 'rule', 'ribs']);
1632
+ if (m === null) continue;
1633
+ for (const key of ['r', 'segments']) if (!(key in m)) c.fail('CONFIG_FIELD_PRESENT', `${at}.${key}`, 'is absent and required');
1634
+ const modes = ['grid', 'contour', 'auto'].filter((k) => k in m);
1635
+ if (modes.length === 2 && !modes.includes('auto')) {
1636
+ c.fail('CONFIG_MESH_MODE', at, 'has both grid (the lattice) and contour; exactly one is required — a mesh is one or the other');
1637
+ } else if (modes.length > 1) {
1638
+ c.fail('CONFIG_MESH_MODE', at, `has ${modes.join(' and ')}; exactly one of grid (the lattice), contour (the outline mode) and auto (the automatic mode) is required — a mesh is one of them`);
1639
+ } else if (modes.length === 0) {
1640
+ c.fail('CONFIG_MESH_MODE', at, 'has neither grid (the lattice cell size, px) nor contour (the outline mode) nor auto (the automatic mode); exactly one is required');
1641
+ }
1642
+ if ('grid' in m) c.int(`${at}.grid`, m.grid, 1);
1643
+ if ('contour' in m) checkContour(c, `${at}.contour`, m.contour, bones);
1644
+ if ('auto' in m) checkAuto(c, `${at}.auto`, m.auto, bones);
1645
+ if ('r' in m) c.number(`${at}.r`, m.r, 'non-negative');
1646
+ // issue #161: the distance rule's exponent, in every mode; absent is 2 (src/weights.ts), nothing is filled in here.
1647
+ if ('exponent' in m) c.number(`${at}.exponent`, m.exponent, 'positive');
1648
+ if ('rule' in m && !WEIGHT_RULES.includes(m.rule as WeightRule)) {
1649
+ c.fail('CONFIG_FIELD_TYPE', `${at}.rule`, `is ${show(m.rule)}; "distance" (the distance rule, what an absent rule means) or "heat" (bone heat over the part's silhouette) is required`);
1650
+ }
1651
+ // issue #161: the exponent is the distance rule's; bone heat has none, so declaring both is refused rather than ignored.
1652
+ if ('exponent' in m && m.rule === 'heat') {
1653
+ c.fail('CONFIG_FIELD_TYPE', `${at}.exponent`, `is ${show(m.exponent)} on a mesh that declares rule "heat"; the exponent belongs to the distance rule (w = 1/(d + r)^exponent) and bone heat reads none — remove exponent, or declare rule "distance"`);
1654
+ }
1655
+ if ('segments' in m && c.array(`${at}.segments`, m.segments, true)) {
1656
+ (m.segments as Json[]).forEach((s, i) => {
1657
+ const sp = `${at}.segments[${i}]`;
1658
+ if (typeof s === 'string') {
1659
+ if (!chains.has(s) && !bones.has(s)) c.fail('CONFIG_NAME_RESOLVES', sp, `names "${s}", which is neither a chain nor a bone`);
1660
+ return;
1661
+ }
1662
+ if (!(Array.isArray(s) && s.length === 3 && typeof s[0] === 'string')) {
1663
+ c.fail('CONFIG_FIELD_TYPE', sp, `is ${show(s)}; a chain name, a bone name, or [bone, [x, y], [x, y]] is required`);
1664
+ return;
1665
+ }
1666
+ if (!bones.has(s[0] as string)) c.fail('CONFIG_NAME_RESOLVES', `${sp}[0]`, `names "${String(s[0])}", which is not a bone`);
1667
+ c.point(`${sp}[1]`, s[1]);
1668
+ c.point(`${sp}[2]`, s[2]);
1669
+ });
1670
+ }
1671
+ if ('ribs' in m) checkRibs(c, `${at}.ribs`, m, chains);
1672
+ if ('auto' in m && typeof m.auto === 'object' && m.auto !== null && !Array.isArray(m.auto) && 'stimulus' in m.auto) {
1673
+ checkStimulus(c, `${at}.auto.stimulus`, (m.auto as Record<string, Json>).stimulus, m, chains);
1674
+ }
1675
+ }
1676
+ }
1677
+
1678
+ /**
1679
+ * `meshes.<part>.auto.stimulus` (issue #196): a declared bend, `{ chain,
1680
+ * degrees }` or a non-empty list of them, for a part no idle key deforms (a
1681
+ * part only a physics constraint moves). Each `chain` must be one this mesh's
1682
+ * `segments` name — the bend is measured on the bones the part is weighted to
1683
+ * — and named once; `degrees` a number above 0, the rotation each link of the
1684
+ * chain takes about its joint, both signs measured (`src/automotion.ts`).
1685
+ * Nothing is defaulted: absent, no stimulus is declared and the stage is the
1686
+ * one it was before the field existed.
1687
+ */
1688
+ function checkStimulus(c: Check, at: string, v: Json, m: Record<string, Json>, chains: Map<string, number>): void {
1689
+ const list = Array.isArray(v) ? v : [v];
1690
+ if (Array.isArray(v) && v.length === 0) {
1691
+ c.fail('CONFIG_FIELD_TYPE', at, 'is []; { chain, degrees } or a non-empty list of them is required — remove stimulus to declare none');
1692
+ return;
1693
+ }
1694
+ const named = new Set((Array.isArray(m.segments) ? m.segments : []).filter((s): s is string => typeof s === 'string' && chains.has(s)));
1695
+ const seen = new Set<string>();
1696
+ list.forEach((e, i) => {
1697
+ const eat = Array.isArray(v) ? `${at}[${i}]` : at;
1698
+ const o = c.object(eat, e, ['chain', 'degrees'], []);
1699
+ if (o === null) return;
1700
+ if ('chain' in o) {
1701
+ if (typeof o.chain !== 'string') {
1702
+ c.fail('CONFIG_FIELD_TYPE', `${eat}.chain`, `is ${show(o.chain)}; the name of a chain this mesh's segments name is required`);
1703
+ } else if (!named.has(o.chain)) {
1704
+ c.fail(
1705
+ 'CONFIG_NAME_RESOLVES',
1706
+ `${eat}.chain`,
1707
+ `names "${o.chain}", which ${chains.has(o.chain) ? "is a chain this mesh's segments do not name" : 'is not a chain config.bones declares'}; a chain among this mesh's segments is required${named.size > 0 ? ` (${[...named].join(', ')})` : ' (it names none)'} — a bend of bones the part is not weighted to moves none of it`,
1708
+ );
1709
+ } else if (seen.has(o.chain)) {
1710
+ c.fail('CONFIG_FIELD_TYPE', `${eat}.chain`, `names "${o.chain}" a second time; each chain is bent once, by its one declared degrees`);
1711
+ } else seen.add(o.chain);
1712
+ }
1713
+ if ('degrees' in o) c.number(`${eat}.degrees`, o.degrees, 'positive');
1714
+ });
1715
+ }
1716
+
1717
+ /**
1718
+ * `meshes.<part>.ribs` (issue #188): an object of chain name → `{ stations }`.
1719
+ * Each key must be a chain this mesh's `segments` name (a rib is placed along
1720
+ * a chain the mesh is weighted to), `stations` a whole number, 0 or more, and
1721
+ * the mesh one of the two modes that place vertices where declared; a lattice
1722
+ * that declares ribs is refused (`CONFIG_RIBS_MODE`), and so is an object that
1723
+ * names no chain, which would place nothing. Where a rib falls is decided at
1724
+ * the rig stage, against the part's art (`src/contour.ts`, `placeRibs`).
1725
+ */
1726
+ function checkRibs(c: Check, at: string, m: Record<string, Json>, chains: Map<string, number>): void {
1727
+ if ('grid' in m) {
1728
+ c.fail('CONFIG_RIBS_MODE', at, "is declared on a lattice mesh (grid); ribs are placed by the contour and auto modes, whose vertices go where they are declared — a lattice's vertices are its grid's corners, which no row can move; declare contour or auto, or remove ribs");
1729
+ }
1730
+ const v = m.ribs;
1731
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) {
1732
+ c.fail('CONFIG_FIELD_TYPE', at, `is ${show(v)}; an object of chain name -> { stations } is required`);
1733
+ return;
1734
+ }
1735
+ const named = new Set((Array.isArray(m.segments) ? m.segments : []).filter((s): s is string => typeof s === 'string' && chains.has(s)));
1736
+ const keys = Object.keys(v).filter((k) => !isDoorKey(k));
1737
+ if (keys.length === 0) c.fail('CONFIG_FIELD_TYPE', at, 'names no chain; one or more chains this mesh\'s segments name are required — remove ribs to place none');
1738
+ for (const k of keys) {
1739
+ const kat = `${at}.${k}`;
1740
+ if (!named.has(k)) {
1741
+ c.fail('CONFIG_NAME_RESOLVES', kat, `names "${k}", which ${chains.has(k) ? "is a chain this mesh's segments do not name" : 'is not a chain config.bones declares'}; a chain among this mesh's segments is required${named.size > 0 ? ` (${[...named].join(', ')})` : ' (it names none)'}`);
1742
+ }
1743
+ const r = c.object(kat, (v as Record<string, Json>)[k], ['stations'], []);
1744
+ if (r !== null && 'stations' in r) c.int(`${kat}.stations`, r.stations, 0);
1745
+ }
1746
+ }
1747
+
1748
+ /**
1749
+ * `meshes.<part>.contour` (issue #84). `tolerance`, `margin` and `spacing` are
1750
+ * required — nothing is defaulted; `budget`, `stray` and `regions` are
1751
+ * optional, and an absent one is "none", not a guessed value. What the outline
1752
+ * itself refuses (a band that folds a polygon, a spacing whose keep radius
1753
+ * snaps to nothing) is `src/contour.ts`'s, at the rig stage, in its words;
1754
+ * what is read here is the shape of the block, the bone each region names, and
1755
+ * the grid every region number must sit on.
1756
+ */
1757
+ function checkContour(c: Check, at: string, v: Json, bones: Set<string>): void {
1758
+ const o = c.object(at, v, ['tolerance', 'margin', 'spacing'], ['budget', 'stray', 'regions']);
1759
+ if (o === null) return;
1760
+ if ('tolerance' in o) c.number(`${at}.tolerance`, o.tolerance, 'non-negative');
1761
+ if ('margin' in o && c.number(`${at}.margin`, o.margin, 'non-negative') && (o.margin as number) > 0 && (o.margin as number) < 1) {
1762
+ c.fail('CONFIG_FIELD_TYPE', `${at}.margin`, `is ${o.margin as number}; 0, or 1 px or more, is required — the outline's silhouette grows by the pixels whose centre lies within the margin of an art pixel's centre, and none lies closer than 1 px, so a margin under 1 px would add nothing`);
1763
+ }
1764
+ if ('spacing' in o) c.number(`${at}.spacing`, o.spacing, 'positive');
1765
+ if ('budget' in o) c.int(`${at}.budget`, o.budget, 3);
1766
+ if ('stray' in o) c.int(`${at}.stray`, o.stray, 0);
1767
+ if (!('regions' in o) || !c.array(`${at}.regions`, o.regions, false)) return;
1768
+ const names = new Set<string>();
1769
+ (o.regions as Json[]).forEach((rv, i) => {
1770
+ const rat = `${at}.regions[${i}]`;
1771
+ const shape = typeof rv === 'object' && rv !== null && !Array.isArray(rv) ? (rv as Record<string, Json>).shape : undefined;
1772
+ const own = shape === 'circle' ? ['cx', 'cy', 'r'] : shape === 'polygon' ? ['points'] : [];
1773
+ const r = c.object(rat, rv, ['name', 'shape', 'bone', 'spacing', 'band', ...own], []);
1774
+ if (r === null) return;
1775
+ if ('shape' in r && own.length === 0) c.fail('CONFIG_FIELD_TYPE', `${rat}.shape`, `is ${show(r.shape)}; "circle" (with cx, cy, r) or "polygon" (with points) is required`);
1776
+ if ('name' in r && c.string(`${rat}.name`, r.name)) {
1777
+ if (names.has(r.name)) c.fail('CONFIG_REGION_NAME_UNIQUE', `${rat}.name`, `"${r.name}" is declared twice in this mesh; each region needs its own name`);
1778
+ names.add(r.name);
1779
+ }
1780
+ if ('bone' in r && (typeof r.bone !== 'string' || !bones.has(r.bone))) {
1781
+ c.fail('CONFIG_NAME_RESOLVES', `${rat}.bone`, `is ${show(r.bone)}; a bone config.bones declares is required — the region's control bone`);
1782
+ }
1783
+ const onGrid = (path: string, n: Json, rule: 'any' | 'positive' | 'non-negative'): void => {
1784
+ if (!c.number(path, n, rule)) return;
1785
+ const v2 = n as number;
1786
+ if (!Number.isInteger(v2 * GRID) || Math.abs(v2) > MAX_SIDE) {
1787
+ c.fail('CONFIG_FIELD_TYPE', path, `is ${v2}; a multiple of 1/${GRID} px within ±${MAX_SIDE} px is required — a region's weight is 1 inside it and 0 past its band, and those two are decided exactly on that grid`);
1788
+ }
1789
+ };
1790
+ if ('spacing' in r) c.number(`${rat}.spacing`, r.spacing, 'positive');
1791
+ if ('band' in r) onGrid(`${rat}.band`, r.band, 'non-negative');
1792
+ if (shape === 'circle') {
1793
+ if ('cx' in r) onGrid(`${rat}.cx`, r.cx, 'any');
1794
+ if ('cy' in r) onGrid(`${rat}.cy`, r.cy, 'any');
1795
+ if ('r' in r) onGrid(`${rat}.r`, r.r, 'positive');
1796
+ }
1797
+ if (shape === 'polygon' && 'points' in r && c.array(`${rat}.points`, r.points, true)) {
1798
+ const pts = r.points as Json[];
1799
+ if (pts.length < 3) c.fail('CONFIG_FIELD_TYPE', `${rat}.points`, `has ${pts.length} point(s); 3 or more [x, y] in rig pixels are required`);
1800
+ pts.forEach((p, k) => {
1801
+ if (!c.point(`${rat}.points[${k}]`, p)) return;
1802
+ onGrid(`${rat}.points[${k}][0]`, (p as Point)[0], 'any');
1803
+ onGrid(`${rat}.points[${k}][1]`, (p as Point)[1], 'any');
1804
+ });
1805
+ }
1806
+ });
1807
+ }
1808
+
1809
+ /**
1810
+ * `meshes.<part>.auto` (issue #126, item 2). Every number is required and
1811
+ * read as rig-c's contract types it (docs/MESH_REDUCTION.md §1, §5, §6):
1812
+ * nothing is defaulted, nothing is read off the image. The two absences that
1813
+ * mean something are stated: `protect` and each of its fields may be left out
1814
+ * — `hull` is then false (P20, the default the agreement fixed for parts's
1815
+ * policy), each list empty and `weightJump` null (no such protection, which
1816
+ * the report echoes) — and `regions` and `source.stray` may be left out, as in
1817
+ * the contour mode. `motion` is required (issue #126 item 3), and inside it
1818
+ * `maxLocalDeformation`; `maxStretch`, `minStretch`, `deformMayFold` and
1819
+ * `gradation` may be left out (`AutoMotionSpec`). What only rig-c can judge (a region bound under one
1820
+ * texel, a region outside the art, a protected vertex index the source does
1821
+ * not have) is its refusal at the rig stage, in its words.
1822
+ */
1823
+ function checkAuto(c: Check, at: string, v: Json, bones: Set<string>): void {
1824
+ const o = c.object(at, v, ['source', 'sourceBounds', 'targets', 'influences', 'budget', 'minArtSamples', 'motion'], ['protect', 'regions', 'boundaryRuns', 'retriangulate', 'removalOrder', 'stimulus']);
1825
+ if (o === null) return;
1826
+ // Stage B (rigc#1271): three opt-ins, each absent = not sent; a value is exactly one rig-c accepts, or it is refused by name.
1827
+ if ('boundaryRuns' in o) {
1828
+ const b = c.object(`${at}.boundaryRuns`, o.boundaryRuns, ['maxVertices'], []);
1829
+ if (b !== null && 'maxVertices' in b) c.int(`${at}.boundaryRuns.maxVertices`, b.maxVertices, 2);
1830
+ }
1831
+ if ('retriangulate' in o && o.retriangulate !== 'delaunay') {
1832
+ c.fail('CONFIG_FIELD_TYPE', `${at}.retriangulate`, `is ${show(o.retriangulate)}; "delaunay" is required (the one post-pass rig-c offers), or leave the field out for the triangles the removals leave`);
1833
+ }
1834
+ if ('removalOrder' in o && o.removalOrder !== 'deformation-load') {
1835
+ c.fail('CONFIG_FIELD_TYPE', `${at}.removalOrder`, `is ${show(o.removalOrder)}; "deformation-load" is required (the one order rig-c offers besides its default), or leave the field out for ascending source index`);
1836
+ }
1837
+ if ('motion' in o) {
1838
+ const m = c.object(`${at}.motion`, o.motion, ['maxLocalDeformation'], ['maxStretch', 'minStretch', 'deformMayFold', 'gradation', 'selection', 'residual']);
1839
+ if (m !== null) {
1840
+ // issue #126 (rigc#1295): the skinning residual veto, opt-in; its bound is the author's number, never defaulted.
1841
+ if ('residual' in m) {
1842
+ const res = c.object(`${at}.motion.residual`, m.residual, ['maxResidual'], []);
1843
+ if (res !== null && 'maxResidual' in res) c.number(`${at}.motion.residual.maxResidual`, res.maxResidual, 'non-negative');
1844
+ }
1845
+ // issue #148: the multi-interval selection, opt-in; its budget is the author's whole number, never defaulted.
1846
+ if ('selection' in m) {
1847
+ const sel = c.object(`${at}.motion.selection`, m.selection, ['policy', 'maxProbes'], []);
1848
+ if (sel !== null) {
1849
+ if ('policy' in sel && sel.policy !== 'multi-interval') {
1850
+ c.fail('CONFIG_FIELD_TYPE', `${at}.motion.selection.policy`, `is ${show(sel.policy)}; "multi-interval" is required (the one policy besides the bisection), or leave selection out for the bisection`);
1851
+ }
1852
+ if ('maxProbes' in sel) c.int(`${at}.motion.selection.maxProbes`, sel.maxProbes, 1);
1853
+ }
1854
+ }
1855
+ // rigc#1291: the author's G, a number at or above 0, or null (sent as null: Δ not measured); never derived.
1856
+ if ('gradation' in m && m.gradation !== null) c.number(`${at}.motion.gradation`, m.gradation, 'non-negative');
1857
+ if ('maxLocalDeformation' in m) c.number(`${at}.motion.maxLocalDeformation`, m.maxLocalDeformation, 'non-negative');
1858
+ if ('maxStretch' in m) c.number(`${at}.motion.maxStretch`, m.maxStretch, 'non-negative');
1859
+ if ('minStretch' in m) c.number(`${at}.motion.minStretch`, m.minStretch, 'non-negative');
1860
+ if ('deformMayFold' in m && typeof m.deformMayFold !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${at}.motion.deformMayFold`, `is ${show(m.deformMayFold)}; true or false is required`);
1861
+ }
1862
+ }
1863
+ if ('source' in o) {
1864
+ const s = c.object(`${at}.source`, o.source, ['tolerance', 'margin', 'spacing'], ['stray']);
1865
+ if (s !== null) {
1866
+ if ('tolerance' in s) c.number(`${at}.source.tolerance`, s.tolerance, 'non-negative');
1867
+ if ('margin' in s && c.number(`${at}.source.margin`, s.margin, 'non-negative') && (s.margin as number) > 0 && (s.margin as number) < 1) {
1868
+ c.fail('CONFIG_FIELD_TYPE', `${at}.source.margin`, `is ${s.margin as number}; 0, or 1 px or more, is required — the source's silhouette grows by the pixels whose centre lies within the margin of an art pixel's centre, and none lies closer than 1 px`);
1869
+ }
1870
+ if ('spacing' in s) c.number(`${at}.source.spacing`, s.spacing, 'positive');
1871
+ if ('stray' in s) c.int(`${at}.source.stray`, s.stray, 0);
1872
+ }
1873
+ }
1874
+ const fit = (path: string, f: Json): void => {
1875
+ const b = c.object(path, f, ['minCoverage', 'maxOvershoot', 'maxUndercut'], []);
1876
+ if (b === null) return;
1877
+ if ('minCoverage' in b) c.number(`${path}.minCoverage`, b.minCoverage, 'unit');
1878
+ // A bound may be declared absent with null: rig-c (from 2.21.0) measures
1879
+ // and reports it as `undeclared` and gates nothing on it. Left out, it is
1880
+ // refused above (CONFIG_FIELD_PRESENT), as rig-c refuses it too.
1881
+ for (const k of ['maxOvershoot', 'maxUndercut'] as const) {
1882
+ if (!(k in b) || b[k] === null) continue;
1883
+ const v = b[k];
1884
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
1885
+ c.fail('CONFIG_FIELD_TYPE', `${path}.${k}`, `is ${show(v)}; a number at or above 0, or null (declared absent: measured and reported, not bounded), is required`);
1886
+ }
1887
+ }
1888
+ };
1889
+ if ('sourceBounds' in o) fit(`${at}.sourceBounds`, o.sourceBounds);
1890
+ if ('targets' in o) {
1891
+ const t = c.object(`${at}.targets`, o.targets, ['artFit', 'maxBoundaryDeviation'], ['minAngle']);
1892
+ if (t !== null) {
1893
+ if ('artFit' in t) fit(`${at}.targets.artFit`, t.artFit);
1894
+ if ('maxBoundaryDeviation' in t) c.number(`${at}.targets.maxBoundaryDeviation`, t.maxBoundaryDeviation, 'non-negative');
1895
+ if ('minAngle' in t) c.number(`${at}.targets.minAngle`, t.minAngle, 'non-negative');
1896
+ }
1897
+ }
1898
+ if ('influences' in o) {
1899
+ const l = c.object(`${at}.influences`, o.influences, ['maxInfluences', 'minWeight'], []);
1900
+ if (l !== null) {
1901
+ if ('maxInfluences' in l) c.int(`${at}.influences.maxInfluences`, l.maxInfluences, 1);
1902
+ if ('minWeight' in l && c.number(`${at}.influences.minWeight`, l.minWeight, 'non-negative') && (l.minWeight as number) >= 1) {
1903
+ c.fail('CONFIG_FIELD_TYPE', `${at}.influences.minWeight`, `is ${l.minWeight as number}; a share at or above 0 and below 1 is required (0 drops only shares that are 0 on the weight grid)`);
1904
+ }
1905
+ }
1906
+ }
1907
+ if ('budget' in o) {
1908
+ const b = c.object(`${at}.budget`, o.budget, ['maxCandidates'], []);
1909
+ if (b !== null && 'maxCandidates' in b) c.int(`${at}.budget.maxCandidates`, b.maxCandidates, 0);
1910
+ }
1911
+ if ('minArtSamples' in o) c.int(`${at}.minArtSamples`, o.minArtSamples, 1);
1912
+ const names = new Set<string>();
1913
+ if ('regions' in o && c.array(`${at}.regions`, o.regions, false)) {
1914
+ (o.regions as Json[]).forEach((rv, i) => {
1915
+ const rat = `${at}.regions[${i}]`;
1916
+ const shape = typeof rv === 'object' && rv !== null && !Array.isArray(rv) ? (rv as Record<string, Json>).shape : undefined;
1917
+ const own = shape === 'circle' ? ['cx', 'cy', 'r'] : shape === 'polygon' ? ['points'] : [];
1918
+ const r = c.object(rat, rv, ['name', 'shape', 'maxEdgeLength', 'transition', 'grade', 'minArtSamples', ...own], ['bone', 'band']);
1919
+ if (r === null) return;
1920
+ // issue #155: `bone` and `band` together are the weight-and-density form, neither is the density-only form; one alone is refused.
1921
+ if ('band' in r && !('bone' in r)) {
1922
+ c.fail('CONFIG_REGION_BAND_NEEDS_BONE', `${rat}.band`, `is ${show(r.band)} and the region names no bone; a band is the falloff of a bone's weight — add "bone", or leave "band" out for a density-only region`);
1923
+ }
1924
+ if ('bone' in r && !('band' in r)) {
1925
+ c.fail('CONFIG_REGION_BONE_NEEDS_BAND', `${rat}.bone`, `is ${show(r.bone)} and the region declares no band; a region that weights a bone needs the band its weight falls off across (0 for a hard edge) — add "band", or leave "bone" out for a density-only region`);
1926
+ }
1927
+ if ('shape' in r && own.length === 0) c.fail('CONFIG_FIELD_TYPE', `${rat}.shape`, `is ${show(r.shape)}; "circle" (with cx, cy, r) or "polygon" (with points) is required`);
1928
+ if ('name' in r && c.string(`${rat}.name`, r.name)) {
1929
+ if (names.has(r.name)) c.fail('CONFIG_REGION_NAME_UNIQUE', `${rat}.name`, `"${r.name}" is declared twice in this mesh; each region needs its own name`);
1930
+ names.add(r.name);
1931
+ }
1932
+ if ('bone' in r && (typeof r.bone !== 'string' || !bones.has(r.bone))) {
1933
+ c.fail('CONFIG_NAME_RESOLVES', `${rat}.bone`, `is ${show(r.bone)}; a bone config.bones declares is required — the region's control bone`);
1934
+ }
1935
+ const onGrid = (path: string, n: Json, rule: 'any' | 'positive' | 'non-negative'): void => {
1936
+ if (!c.number(path, n, rule)) return;
1937
+ const v2 = n as number;
1938
+ if (!Number.isInteger(v2 * GRID) || Math.abs(v2) > MAX_SIDE) {
1939
+ c.fail('CONFIG_FIELD_TYPE', path, `is ${v2}; a multiple of 1/${GRID} px within ±${MAX_SIDE} px is required — a region's weight is 1 inside it and 0 past its band, and those two are decided exactly on that grid`);
1940
+ }
1941
+ };
1942
+ if ('band' in r) onGrid(`${rat}.band`, r.band, 'non-negative');
1943
+ if ('maxEdgeLength' in r) c.number(`${rat}.maxEdgeLength`, r.maxEdgeLength, 'positive');
1944
+ if ('transition' in r) c.number(`${rat}.transition`, r.transition, 'non-negative');
1945
+ if ('grade' in r) c.number(`${rat}.grade`, r.grade, 'non-negative');
1946
+ if ('minArtSamples' in r) c.int(`${rat}.minArtSamples`, r.minArtSamples, 1);
1947
+ if (shape === 'circle') {
1948
+ if ('cx' in r) onGrid(`${rat}.cx`, r.cx, 'any');
1949
+ if ('cy' in r) onGrid(`${rat}.cy`, r.cy, 'any');
1950
+ if ('r' in r) onGrid(`${rat}.r`, r.r, 'positive');
1951
+ }
1952
+ if (shape === 'polygon' && 'points' in r && c.array(`${rat}.points`, r.points, true)) {
1953
+ const pts = r.points as Json[];
1954
+ if (pts.length < 3) c.fail('CONFIG_FIELD_TYPE', `${rat}.points`, `has ${pts.length} point(s); 3 or more [x, y] in rig pixels are required`);
1955
+ pts.forEach((p, k) => {
1956
+ if (!c.point(`${rat}.points[${k}]`, p)) return;
1957
+ onGrid(`${rat}.points[${k}][0]`, (p as Point)[0], 'any');
1958
+ onGrid(`${rat}.points[${k}][1]`, (p as Point)[1], 'any');
1959
+ });
1960
+ }
1961
+ });
1962
+ }
1963
+ if ('protect' in o) {
1964
+ const p = c.object(`${at}.protect`, o.protect, [], ['hull', 'vertices', 'edges', 'regionBoundaries', 'weightJump', 'influences']);
1965
+ if (p === null) return;
1966
+ if ('hull' in p && typeof p.hull !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${at}.protect.hull`, `is ${show(p.hull)}; true or false is required`);
1967
+ if ('vertices' in p && c.array(`${at}.protect.vertices`, p.vertices, false)) (p.vertices as Json[]).forEach((n, k) => c.int(`${at}.protect.vertices[${k}]`, n, 0));
1968
+ if ('edges' in p && c.array(`${at}.protect.edges`, p.edges, false)) {
1969
+ (p.edges as Json[]).forEach((e, k) => {
1970
+ if (!(Array.isArray(e) && e.length === 2 && e.every((n) => typeof n === 'number' && Number.isInteger(n) && n >= 0))) {
1971
+ c.fail('CONFIG_FIELD_TYPE', `${at}.protect.edges[${k}]`, `is ${show(e)}; a pair of source vertex indices [a, b] is required`);
1972
+ }
1973
+ });
1974
+ }
1975
+ if ('regionBoundaries' in p && c.array(`${at}.protect.regionBoundaries`, p.regionBoundaries, false)) {
1976
+ (p.regionBoundaries as Json[]).forEach((n, k) => {
1977
+ if (typeof n !== 'string' || !names.has(n)) c.fail('CONFIG_NAME_RESOLVES', `${at}.protect.regionBoundaries[${k}]`, `is ${show(n)}; the name of a region this mesh declares is required`);
1978
+ });
1979
+ }
1980
+ if ('weightJump' in p && p.weightJump !== null) c.number(`${at}.protect.weightJump`, p.weightJump, 'non-negative');
1981
+ if ('influences' in p && c.array(`${at}.protect.influences`, p.influences, false)) {
1982
+ (p.influences as Json[]).forEach((n, k) => {
1983
+ if (typeof n !== 'string' || !bones.has(n)) c.fail('CONFIG_NAME_RESOLVES', `${at}.protect.influences[${k}]`, `is ${show(n)}; a bone config.bones declares is required`);
1984
+ });
1985
+ }
1986
+ }
1987
+ }
1988
+
1989
+ function checkRegions(c: Check, v: Json, bones: Set<string>): void {
1990
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) {
1991
+ c.fail('CONFIG_FIELD_TYPE', 'config.regions', `is ${show(v)}; an object of part name -> bone name is required`);
1992
+ return;
1993
+ }
1994
+ for (const [part, bone] of Object.entries(v as Record<string, Json>)) {
1995
+ if (typeof bone !== 'string' || !bones.has(bone)) {
1996
+ c.fail('CONFIG_NAME_RESOLVES', `config.regions.${part}`, `is ${show(bone)}; a bone name is required`);
1997
+ }
1998
+ }
1999
+ }
2000
+
2001
+ /**
2002
+ * Every plan part — and every cut's piece, which the caller lists with them —
2003
+ * is exactly one of a mesh or a region, every patch is a region, and every
2004
+ * mesh or region is one of those parts or a patch.
2005
+ */
2006
+ function checkCoverage(c: Check, parts: string[], patches: string[], meshes: Json, regions: Json): void {
2007
+ const m = typeof meshes === 'object' && meshes !== null ? Object.keys(meshes) : [];
2008
+ const r = typeof regions === 'object' && regions !== null ? Object.keys(regions) : [];
2009
+ for (const patch of patches) {
2010
+ if (m.includes(patch)) c.fail('CONFIG_PART_ATTACHED', `part "${patch}"`, `is an assemble.patches entry and has a meshes entry; a patch is a region — drop config.meshes.${patch} and name its bone in config.regions.${patch}`);
2011
+ else if (!r.includes(patch)) c.fail('CONFIG_PART_ATTACHED', `part "${patch}"`, `is an assemble.patches entry with no regions entry; a patch is a region, so config.regions.${patch} naming its bone is required`);
2012
+ }
2013
+ for (const part of parts) {
2014
+ const inM = m.includes(part);
2015
+ const inR = r.includes(part);
2016
+ if (!inM && !inR) c.fail('CONFIG_PART_ATTACHED', `part "${part}"`, 'has neither a meshes entry nor a regions entry; exactly one is required');
2017
+ if (inM && inR) c.fail('CONFIG_PART_ATTACHED', `part "${part}"`, 'has both a meshes entry and a regions entry; exactly one is required');
2018
+ }
2019
+ for (const name of [...m, ...r]) {
2020
+ if (!parts.includes(name) && !patches.includes(name)) {
2021
+ c.fail('CONFIG_NAME_RESOLVES', `config.${m.includes(name) ? 'meshes' : 'regions'}.${name}`, 'names a part that neither assemble.plan nor assemble.patches makes (nor an assemble.cuts entry)');
2022
+ }
2023
+ }
2024
+ }
2025
+
2026
+ /** What an empty blink group is told: the tags whose parts make its bones, and what to write instead. */
2027
+ const BLINK_GROUP_EMPTY: Readonly<Record<'eyes' | 'brows', string>> = {
2028
+ eyes: 'is []; the eyes group must name at least one bone (rigc refuses a group with no members). The eye bones come from eyewhite-r / eyewhite-l parts; a figure with none has nothing to blink, so leave config.motion.blink out',
2029
+ brows: 'is []; the brows group must name at least one bone (rigc refuses a group with no members). The brow bones come from eyebrow-r / eyebrow-l parts; a figure with none has nothing to drop, so leave brows and brow_drop out',
2030
+ };
2031
+
2032
+ function checkMotion(c: Check, v: Json, bones: Set<string>, chains: Map<string, number>, regions: Json): void {
2033
+ const m = c.object('config.motion', v, ['duration', 'tracks'], ['blink', 'animations_from']);
2034
+ if (m === null) return;
2035
+ const p = 'config.motion';
2036
+ if ('animations_from' in m) c.string(`${p}.animations_from`, m.animations_from);
2037
+ // Every bone property the idle will key, and the tracks that key it, in the
2038
+ // order idleMotion writes them: single and chain tracks, then the eyes
2039
+ // group, then the brows group.
2040
+ const claims = new Map<string, string[]>();
2041
+ const claim = (target: string, by: string): void => {
2042
+ claims.set(target, [...(claims.get(target) ?? []), by]);
2043
+ };
2044
+ const duration = 'duration' in m && c.number(`${p}.duration`, m.duration, 'positive') ? (m.duration as number) : null;
2045
+ const periodFits = (at: string, period: Json): void => {
2046
+ if (!c.number(at, period, 'positive') || duration === null) return;
2047
+ const cycles = duration / period;
2048
+ if (Math.abs(cycles - Math.round(cycles)) > 1e-9) {
2049
+ c.fail(
2050
+ 'CONFIG_PERIOD_DIVIDES_DURATION',
2051
+ at,
2052
+ `is ${period} s against a ${duration} s idle, ${cycles.toFixed(4)} cycles; a whole number of cycles is required or the loop's last frame is not its first`,
2053
+ );
2054
+ }
2055
+ };
2056
+ if ('tracks' in m && c.array(`${p}.tracks`, m.tracks, false)) {
2057
+ (m.tracks as Json[]).forEach((tr, i) => {
2058
+ const at = `${p}.tracks[${i}]`;
2059
+ if (typeof tr === 'object' && tr !== null && 'chain' in tr) {
2060
+ const t = c.object(at, tr, ['chain', 'amps', 'period', 'phase', 'lag'], []);
2061
+ if (t === null) return;
2062
+ if ('period' in t) periodFits(`${at}.period`, t.period);
2063
+ if ('phase' in t) c.number(`${at}.phase`, t.phase);
2064
+ if ('lag' in t) c.number(`${at}.lag`, t.lag);
2065
+ const links = typeof t.chain === 'string' ? chains.get(t.chain) : undefined;
2066
+ if (links === undefined) c.fail('CONFIG_NAME_RESOLVES', `${at}.chain`, `is ${show(t.chain)}; a chain declared in config.bones is required`);
2067
+ // A chain track keys rotate on every link (idleMotion), one link per point.
2068
+ else for (let k = 0; k < links; k++) claim(`${String(t.chain)}${k}.rotate`, `${at} (chain "${String(t.chain)}", link ${k})`);
2069
+ if ('amps' in t && c.array(`${at}.amps`, t.amps, true)) {
2070
+ const amps = t.amps as Json[];
2071
+ amps.forEach((a, k) => c.number(`${at}.amps[${k}]`, a));
2072
+ if (links !== undefined && amps.length !== links) {
2073
+ c.fail('CONFIG_AMPS_MATCH_CHAIN', `${at}.amps`, `holds ${amps.length} amplitude(s) for the ${links}-link chain "${String(t.chain)}"; one per link is required`);
2074
+ }
2075
+ }
2076
+ return;
2077
+ }
2078
+ const t = c.object(at, tr, ['bone', 'prop', 'amp', 'period', 'phase'], ['base']);
2079
+ if (t === null) return;
2080
+ if (typeof t.bone !== 'string' || !bones.has(t.bone)) c.fail('CONFIG_NAME_RESOLVES', `${at}.bone`, `is ${show(t.bone)}; a declared bone is required`);
2081
+ if ('prop' in t && !(SINGLE_PROPS as readonly string[]).includes(t.prop as string)) {
2082
+ c.fail('CONFIG_FIELD_TYPE', `${at}.prop`, `is ${show(t.prop)}; one of ${SINGLE_PROPS.join(', ')} is required`);
2083
+ } else if ('prop' in t && typeof t.bone === 'string' && bones.has(t.bone)) claim(`${t.bone}.${String(t.prop)}`, at);
2084
+ if ('amp' in t) c.number(`${at}.amp`, t.amp);
2085
+ if ('period' in t) periodFits(`${at}.period`, t.period);
2086
+ if ('phase' in t) c.number(`${at}.phase`, t.phase);
2087
+ if ('base' in t) c.number(`${at}.base`, t.base);
2088
+ });
2089
+ }
2090
+ if ('blink' in m) {
2091
+ const b = c.object(`${p}.blink`, m.blink, ['t', 'eyes', 'squash'], ['brows', 'brow_drop', 'still']);
2092
+ if (b === null) return refuseSharedTargets(c, claims);
2093
+ if ('t' in b) c.number(`${p}.blink.t`, b.t, 'non-negative');
2094
+ if ('squash' in b) c.number(`${p}.blink.squash`, b.squash, 'positive');
2095
+ if ('brow_drop' in b) c.number(`${p}.blink.brow_drop`, b.brow_drop);
2096
+ if ('brows' in b && !('brow_drop' in b)) {
2097
+ c.fail('CONFIG_BLINK_BROWS_PAIRED', `${p}.blink.brow_drop`, 'is absent while config.motion.blink.brows is stated; the brows group drops by brow_drop, so state both or neither');
2098
+ }
2099
+ if ('brow_drop' in b && !('brows' in b)) {
2100
+ c.fail('CONFIG_BLINK_BROWS_PAIRED', `${p}.blink.brows`, `is absent while config.motion.blink.brow_drop is ${show(b.brow_drop)}; nothing would drop, so state both or neither`);
2101
+ }
2102
+ for (const key of ['eyes', 'brows'] as const) {
2103
+ if (key in b && c.array(`${p}.blink.${key}`, b[key], false)) {
2104
+ const list = b[key] as Json[];
2105
+ // rigc refuses a group with no members (`group "eyes" declares no
2106
+ // members`) one stage later, at the gate; the stage whose input is
2107
+ // wrong is this one.
2108
+ if (list.length === 0) c.fail('CONFIG_BLINK_GROUP_MEMBERS', `${p}.blink.${key}`, BLINK_GROUP_EMPTY[key]);
2109
+ const at = new Map<string, number[]>();
2110
+ list.forEach((name, k) => {
2111
+ if (typeof name !== 'string' || !bones.has(name)) c.fail('CONFIG_NAME_RESOLVES', `${p}.blink.${key}[${k}]`, `is ${show(name)}; a declared bone is required`);
2112
+ if (typeof name === 'string') at.set(name, [...(at.get(name) ?? []), k]);
2113
+ });
2114
+ // The group track keys one property on every member: scaley for the
2115
+ // eyes, translatey for the brows — and the brows track is written only
2116
+ // when brow_drop is stated beside them (idleMotion). A member named
2117
+ // twice is CONFIG_BLINK_GROUP_UNIQUE's, so each bone claims once, at
2118
+ // its first index.
2119
+ const prop = key === 'eyes' ? 'scaley' : 'translatey';
2120
+ if (key === 'eyes' || 'brow_drop' in b) {
2121
+ for (const [name, ks] of at) if (bones.has(name)) claim(`${name}.${prop}`, `${p}.blink.${key}[${ks[0]}] (the blink's ${key} group, which keys ${prop} on every member)`);
2122
+ }
2123
+ // rigc refuses a member named twice (`group "eyes" names member "eye"
2124
+ // twice`) at the gate, one stage late — issue #45, the sibling of the
2125
+ // empty group above. One refusal per repeated name, in first-seen order.
2126
+ for (const [name, ks] of at) {
2127
+ if (ks.length > 1) {
2128
+ c.fail(
2129
+ 'CONFIG_BLINK_GROUP_UNIQUE',
2130
+ `${p}.blink.${key}`,
2131
+ `names ${show(name)} ${ks.length === 2 ? 'twice' : `${ks.length} times`} (at ${ks.map((k) => `[${k}]`).join(', ')}); each bone is named once — the group keys every member it names, so a repeat keys one bone twice, and rigc refuses a group naming a member twice`,
2132
+ );
2133
+ }
2134
+ }
2135
+ }
2136
+ }
2137
+ if ('still' in b) checkBlinkStill(c, b.still, b.eyes, bones, regions);
2138
+ }
2139
+ refuseSharedTargets(c, claims);
2140
+ }
2141
+
2142
+ /**
2143
+ * Two tracks keying one bone property — two single tracks, a single track
2144
+ * beside a chain link's rotate or a blink group's member — are one refusal
2145
+ * per property, naming every track that keys it (issue #49). rigc refuses the
2146
+ * pair at the rig gate (`animation "idle" has two tracks on eye.scaley`), one
2147
+ * stage after the input that was wrong; the loader knows every track it
2148
+ * writes. The control bones the rig stage adds (`<bone>_ctl`) cannot make two
2149
+ * distinct targets one: the rename is the same suffix on every key of a bone,
2150
+ * and a declared bone already holding the control's name is the rig stage's
2151
+ * refusal, `RIG_CONTROL_NAME_FREE`.
2152
+ */
2153
+ function refuseSharedTargets(c: Check, claims: ReadonlyMap<string, string[]>): void {
2154
+ for (const [target, by] of claims) {
2155
+ if (by.length < 2) continue;
2156
+ c.fail(
2157
+ 'CONFIG_BONE_PROPERTY_KEYED_ONCE',
2158
+ `bone property "${target}"`,
2159
+ `is keyed by ${by.length} tracks: ${by.join(' and ')}; one track per bone property is required — the idle would hold ${by.length} timelines on ${target}, and rigc refuses two tracks on one bone property. Merge them into one track, or key another property or bone`,
2160
+ );
2161
+ }
2162
+ }
2163
+
2164
+ /**
2165
+ * `config.constraints` (issue #92): rig-c's own constraint shapes, read
2166
+ * for the four things this package owns and nothing else (see the module
2167
+ * comment). What each refusal is for:
2168
+ *
2169
+ * - `CONFIG_FIELD_TYPE` / `CONFIG_FIELD_PRESENT`: not a list, an entry not an
2170
+ * object, a `type` or `name` absent, a `name` not a non-empty string, an
2171
+ * annotation not a string, a bone field holding something that is not a
2172
+ * name (or a list of names).
2173
+ * - `CONFIG_CONSTRAINT_TYPE_KNOWN`: a `type` rigc's union does not name.
2174
+ * rigc refuses it too; the stage whose input is wrong is this one.
2175
+ * - `CONFIG_CONSTRAINT_NAME_UNIQUE`: two constraints of one kind under one
2176
+ * name. rigc resolves a constraint by its kind and its name
2177
+ * (`SkeletonData.findConstraint(name, type)`), so an ik and a transform may
2178
+ * share a name and two iks may not — the identity rigc keeps.
2179
+ * - `CONFIG_NAME_RESOLVES`: a bone a constraint names that `bones` does not
2180
+ * declare, naming the constraint, the field and the name, with the bones
2181
+ * that exist.
2182
+ * - `CONFIG_CONSTRAINT_TARGET_DETACHED`: the bone an ik follows (`target`)
2183
+ * or a transform reads (`source`) is one of the bones it drives or sits
2184
+ * under one, so driving them moves the bone they follow. A scene target is
2185
+ * a bone parented to `root`. The rig stage declares the same fact for rigc
2186
+ * (`invariants.detached`, rigc's `A25`), so the gate holds the built rig to
2187
+ * it too. rigc does not refuse this parentage undeclared: measured through
2188
+ * rig-c 2.15.0 on the rig fixture, an ik over `hem0` following `hem1`
2189
+ * and a transform over `hem0` reading `hem1` each gate green with nothing
2190
+ * declared.
2191
+ *
2192
+ * Every other field — `mix`, `properties`, a path's `slot`, a slider's
2193
+ * `animation` — and every value of it is rigc's, refused at the rig stage's
2194
+ * gate in rigc's words. So is an ik's `bones` as a shape (issue #103): from
2195
+ * rig-c 2.15.0 its rig-spec parser refuses an ik over more than two
2196
+ * bones, or over a pair whose second bone is not the first's child, by name
2197
+ * (firejune/rigc#1205); this loader refused both itself while rigc's gate
2198
+ * passed them (issue #92), and no longer does.
2199
+ */
2200
+ function checkConstraints(c: Check, v: Json, bones: Set<string>, parents: Map<string, string>): void {
2201
+ const p = 'config.constraints';
2202
+ if (!c.array(p, v, false)) return;
2203
+ const kinds = RIG_SKIN_CONSTRAINT_KEYS as readonly string[];
2204
+ const declared = [...bones].join(', ');
2205
+ const seen = new Map<string, number>();
2206
+ // A bone's parents as the config writes them, nearest first.
2207
+ const above = (bone: string): string[] => {
2208
+ const out: string[] = [];
2209
+ for (let up = parents.get(bone); up !== undefined && !out.includes(up); up = parents.get(up)) out.push(up);
2210
+ return out;
2211
+ };
2212
+ (v as Json[]).forEach((entry, i) => {
2213
+ const at = `${p}[${i}]`;
2214
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
2215
+ c.fail('CONFIG_FIELD_TYPE', at, `is ${show(entry)}; a constraint object in rig-c's rig-spec shape is required ({"type": …, "name": …, and that kind's fields})`);
2216
+ return;
2217
+ }
2218
+ const o = entry as Record<string, Json>;
2219
+ for (const key of Object.keys(o)) {
2220
+ if (!isRecordKey(key) && (key === 'note' || key.endsWith('_note')) && typeof o[key] !== 'string') {
2221
+ c.fail('CONFIG_FIELD_TYPE', `${at}.${key}`, `is ${show(o[key])}; an annotation is a string — a structured record goes under a key beginning "x-" (any JSON, read by nothing)`);
2222
+ }
2223
+ }
2224
+ let kind: RigSkinConstraintKey | null = null;
2225
+ if (!('type' in o)) c.fail('CONFIG_FIELD_PRESENT', `${at}.type`, `is absent and required; one of ${kinds.join(', ')}`);
2226
+ else if (typeof o.type !== 'string' || !kinds.includes(o.type)) {
2227
+ c.fail('CONFIG_CONSTRAINT_TYPE_KNOWN', `${at}.type`, `is ${show(o.type)}; one of ${kinds.join(', ')} is required — rig-c's rig-spec constraint kinds, spelled as rigc spells them`);
2228
+ } else kind = o.type as RigSkinConstraintKey;
2229
+ if (!('name' in o)) c.fail('CONFIG_FIELD_PRESENT', `${at}.name`, 'is absent and required; a constraint is found by its kind and its name');
2230
+ else if (c.string(`${at}.name`, o.name) && kind !== null) {
2231
+ const id = `${kind} ${o.name}`;
2232
+ const first = seen.get(id);
2233
+ if (first !== undefined) {
2234
+ c.fail(
2235
+ 'CONFIG_CONSTRAINT_NAME_UNIQUE',
2236
+ `${at}.name`,
2237
+ `names the ${kind} constraint "${o.name}" again (first at constraints[${first}]); a constraint is found by its kind and its name, so two of one kind may not share one (an ik and a transform may: rig-c's identity of a constraint)`,
2238
+ );
2239
+ } else seen.set(id, i);
2240
+ }
2241
+ if (kind === null) return;
2242
+ const label = typeof o.name === 'string' && o.name !== '' ? `${kind} constraint "${o.name}"` : `the ${kind} constraint`;
2243
+ // The bone names, field by field, resolved against the declared bones.
2244
+ const named = new Map<string, string[]>();
2245
+ for (const [field, arity] of CONSTRAINT_BONE_FIELDS[kind]) {
2246
+ if (!(field in o)) continue;
2247
+ const value = o[field];
2248
+ const fat = `${at}.${field}`;
2249
+ if (arity === 'one') {
2250
+ if (typeof value !== 'string') {
2251
+ c.fail('CONFIG_FIELD_TYPE', fat, `is ${show(value)}; ${label} names a bone here, and a bone name is required`);
2252
+ } else if (!bones.has(value)) {
2253
+ c.fail('CONFIG_NAME_RESOLVES', fat, `names "${value}", which config.bones does not declare (${label}); the bones that exist: ${declared}`);
2254
+ } else named.set(field, [value]);
2255
+ continue;
2256
+ }
2257
+ if (!Array.isArray(value) || !value.every((n) => typeof n === 'string')) {
2258
+ c.fail('CONFIG_FIELD_TYPE', fat, `is ${show(value)}; ${label} names bones here, and a list of bone names is required`);
2259
+ continue;
2260
+ }
2261
+ const list = value as string[];
2262
+ list.forEach((n, k) => {
2263
+ if (!bones.has(n)) c.fail('CONFIG_NAME_RESOLVES', `${fat}[${k}]`, `names "${n}", which config.bones does not declare (${label}); the bones that exist: ${declared}`);
2264
+ });
2265
+ if (list.every((n) => bones.has(n))) named.set(field, list);
2266
+ }
2267
+ const driven = named.get('bones');
2268
+ const follows = CONSTRAINT_FOLLOWS[kind];
2269
+ const target = follows === undefined ? undefined : named.get(follows)?.[0];
2270
+ if (follows !== undefined && target !== undefined && driven !== undefined) {
2271
+ const line = [target, ...above(target)];
2272
+ const hit = driven.find((b) => line.includes(b));
2273
+ if (hit !== undefined) {
2274
+ const where = hit === target ? `is "${target}", which ${label} also drives` : `is "${target}", which sits under "${hit}" (${line.slice(0, line.indexOf(hit) + 1).join(' < ')}), a bone ${label} drives`;
2275
+ c.fail(
2276
+ 'CONFIG_CONSTRAINT_TARGET_DETACHED',
2277
+ `${at}.${follows}`,
2278
+ `${where}; the bone a constraint follows must not move with the bones it drives, or driving them moves what they follow — a scene target is a bone parented to "${ROOT_BONE}"`,
2279
+ );
2280
+ }
2281
+ }
2282
+ });
2283
+ }