@rig-c/parts 0.0.0-stage → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +431 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +137 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- package/workflows/seethrough.json +68 -0
package/src/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
|
+
}
|