@rig-c/parts 1.33.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -91,6 +91,7 @@ import { pyRound } from './round.ts';
91
91
  import { CLOTH_TARGETS, type ClothRecipes, RECIPE_STRENGTH, RIGC_DEFAULT_DAMPING, springStable } from './cloth.ts';
92
92
  import { GRID, MAX_SIDE } from './contour.ts';
93
93
  import { type Problem, refuseIfAny } from './errors.ts';
94
+ import { edgeLength } from './length.ts';
94
95
  import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
95
96
  import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
96
97
  import { EAR_DEPTH_RANGE, FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, type FaceDeformKey, FACE_RATED_PITCH_BAND, FACE_RATED_YAW_BAND, TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
@@ -392,6 +393,76 @@ export interface ContourSpec {
392
393
  bridges?: true;
393
394
  /** Local deformation regions; absent is none. */
394
395
  regions?: ContourRegionSpec[];
396
+ /**
397
+ * Issue #475: derives the `bust_r` and `bust_l` circle regions from three required landmarks (`apex_r`, `apex_l`,
398
+ * `cleavage`) and two constants (`alpha`, `clearance`). Mutually exclusive with hand-written `bust_r` or `bust_l`
399
+ * in `regions`.
400
+ */
401
+ bust?: ContourBustSpec;
402
+ }
403
+
404
+ /** Default solid-core ratio `α = r / (r + band)` fitted across the five calibrated example characters (issue #475). */
405
+ export const DEFAULT_BUST_ALPHA = 0.22;
406
+ /** Default cleavage clearance `ε` (rig px) fitted across the five calibrated example characters (issue #475). */
407
+ export const DEFAULT_BUST_CLEARANCE = 3.8;
408
+ /** Default interior refinement spacing (rig px) for derived `bust_r` and `bust_l` regions (issue #475). */
409
+ export const DEFAULT_BUST_SPACING = 14;
410
+ /** Default control bone names for derived `bust_r` and `bust_l` regions (issue #475). */
411
+ export const DEFAULT_BUST_BONE_R = 'boob_r';
412
+ export const DEFAULT_BUST_BONE_L = 'boob_l';
413
+
414
+ /**
415
+ * Landmark shorthand for the `bust_r` and `bust_l` contour regions (issue #475):
416
+ * `R_s = round(|apex_s - cleavage| - clearance)`, `r_s = round(alpha * R_s)`, `band_s = R_s - r_s`.
417
+ */
418
+ export interface ContourBustSpec {
419
+ apex_r: Point;
420
+ apex_l: Point;
421
+ cleavage: Point;
422
+ /** Core-to-outer radius ratio `r / (r + band)` in `(0, 1)`; absent is {@link DEFAULT_BUST_ALPHA} (`0.22`). */
423
+ alpha?: number;
424
+ /** Cleavage clearance `ε` in rig px (`> 0`, with `R_r + R_l < D`); absent is {@link DEFAULT_BUST_CLEARANCE} (`3.8`). */
425
+ clearance?: number;
426
+ /** Control bone for `bust_r`; absent is {@link DEFAULT_BUST_BONE_R} (`"boob_r"`). */
427
+ bone_r?: string;
428
+ /** Control bone for `bust_l`; absent is {@link DEFAULT_BUST_BONE_L} (`"boob_l"`). */
429
+ bone_l?: string;
430
+ /** Region refinement spacing in rig px (`> 0`); absent is {@link DEFAULT_BUST_SPACING} (`14`). */
431
+ spacing?: number;
432
+ }
433
+
434
+ /**
435
+ * Derive the `[bust_r, bust_l]` circle contour regions from {@link ContourBustSpec} (issue #475).
436
+ * Read by both `propose` and `rig` through {@link contourRegionsOf}.
437
+ */
438
+ export function deriveBustRegions(bust: ContourBustSpec): [ContourRegionSpec, ContourRegionSpec] {
439
+ const alpha = bust.alpha ?? DEFAULT_BUST_ALPHA;
440
+ const clearance = bust.clearance ?? DEFAULT_BUST_CLEARANCE;
441
+ const spacing = bust.spacing ?? DEFAULT_BUST_SPACING;
442
+ const boneR = bust.bone_r ?? DEFAULT_BUST_BONE_R;
443
+ const boneL = bust.bone_l ?? DEFAULT_BUST_BONE_L;
444
+ const [rx, ry] = bust.apex_r;
445
+ const [lx, ly] = bust.apex_l;
446
+ const [kx, ky] = bust.cleavage;
447
+ const dR = edgeLength(kx - rx, ky - ry);
448
+ const dL = edgeLength(kx - lx, ky - ly);
449
+ const RR = Math.round(dR - clearance);
450
+ const RL = Math.round(dL - clearance);
451
+ const rR = Math.round(alpha * RR);
452
+ const rL = Math.round(alpha * RL);
453
+ return [
454
+ { name: 'bust_r', shape: 'circle', cx: rx, cy: ry, r: rR, spacing, band: RR - rR, bone: boneR },
455
+ { name: 'bust_l', shape: 'circle', cx: lx, cy: ly, r: rL, spacing, band: RL - rL, bone: boneL },
456
+ ];
457
+ }
458
+
459
+ /**
460
+ * Every contour region on `spec`: its hand-given `regions` (in declared order) followed by `deriveBustRegions(spec.bust)`
461
+ * when `spec.bust` is declared (issue #475). When `spec.bust` is absent, returns `spec.regions ?? []`.
462
+ */
463
+ export function contourRegionsOf(spec: ContourSpec): ContourRegionSpec[] {
464
+ if (spec.bust === undefined) return spec.regions ?? [];
465
+ return [...(spec.regions ?? []), ...deriveBustRegions(spec.bust)];
395
466
  }
396
467
 
397
468
  /**
@@ -1305,13 +1376,17 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1305
1376
  if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
1306
1377
  if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
1307
1378
  if ('stops' in t) checkStops(c, t.stops, names.bones, names.chains);
1379
+ const attached = attachedParts(t.meshes, t.regions);
1308
1380
  const hasAltFace = hasAlternateFaceSet(t.assemble, [...parts, ...cuts]);
1309
- if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones, hasAltFace);
1381
+ if ('turn' in t) checkTurn(c, t.turn, attached, names.bones, hasAltFace);
1310
1382
  if ('scene' in t) checkScene(c, t.scene);
1311
1383
  if ('cloth' in t) checkCloth(c, t.cloth);
1312
1384
  const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
1313
1385
  const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
1314
- if (animations !== null) checkFaceAnimationBand(c, animations, hasAltFace);
1386
+ if (animations !== null) {
1387
+ checkFaceAnimationBand(c, animations, hasAltFace, t.turn, attached);
1388
+ checkAnimationSetKeys(c, animations, t.assemble, new Set<string>([...parts, ...patches, ...cuts, ...(limbs?.parts.keys() ?? [])]));
1389
+ }
1315
1390
  if (base !== null) poseBeside(c, t.generation, base);
1316
1391
  refuseIfAny(c.problems);
1317
1392
  return { config: raw as CharacterConfig, animations };
@@ -2198,7 +2273,7 @@ function checkRibs(c: Check, at: string, m: Record<string, Json>, chains: Map<st
2198
2273
  * the grid every region number must sit on.
2199
2274
  */
2200
2275
  function checkContour(c: Check, at: string, v: Json, bones: Set<string>): void {
2201
- const o = c.object(at, v, ['tolerance', 'margin', 'spacing'], ['maxEdge', 'budget', 'stray', 'bridges', 'regions']);
2276
+ const o = c.object(at, v, ['tolerance', 'margin', 'spacing'], ['maxEdge', 'budget', 'stray', 'bridges', 'regions', 'bust']);
2202
2277
  if (o === null) return;
2203
2278
  if ('tolerance' in o) c.number(`${at}.tolerance`, o.tolerance, 'non-negative');
2204
2279
  if ('margin' in o && c.number(`${at}.margin`, o.margin, 'non-negative') && (o.margin as number) > 0 && (o.margin as number) < 1) {
@@ -2209,46 +2284,117 @@ function checkContour(c: Check, at: string, v: Json, bones: Set<string>): void {
2209
2284
  if ('budget' in o) c.int(`${at}.budget`, o.budget, 3);
2210
2285
  if ('stray' in o) c.int(`${at}.stray`, o.stray, 0);
2211
2286
  if ('bridges' in o && o.bridges !== true) c.fail('CONFIG_FIELD_TYPE', `${at}.bridges`, `is ${show(o.bridges)}; true is required (every island joined into one outline by bridges, issue #326) — remove the field for the one-island rule`);
2212
- if (!('regions' in o) || !c.array(`${at}.regions`, o.regions, false)) return;
2287
+ const onGrid = (path: string, n: Json, rule: 'any' | 'positive' | 'non-negative'): void => {
2288
+ if (!c.number(path, n, rule)) return;
2289
+ const v2 = n as number;
2290
+ if (!Number.isInteger(v2 * GRID) || Math.abs(v2) > MAX_SIDE) {
2291
+ 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`);
2292
+ }
2293
+ };
2213
2294
  const names = new Set<string>();
2214
- (o.regions as Json[]).forEach((rv, i) => {
2215
- const rat = `${at}.regions[${i}]`;
2216
- const shape = typeof rv === 'object' && rv !== null && !Array.isArray(rv) ? (rv as Record<string, Json>).shape : undefined;
2217
- const own = shape === 'circle' ? ['cx', 'cy', 'r'] : shape === 'polygon' ? ['points'] : [];
2218
- const r = c.object(rat, rv, ['name', 'shape', 'bone', 'spacing', 'band', ...own], []);
2219
- if (r === null) return;
2220
- 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`);
2221
- if ('name' in r && c.string(`${rat}.name`, r.name)) {
2222
- 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`);
2223
- names.add(r.name);
2224
- }
2225
- if ('bone' in r && (typeof r.bone !== 'string' || !bones.has(r.bone))) {
2226
- c.fail('CONFIG_NAME_RESOLVES', `${rat}.bone`, `is ${show(r.bone)}; a bone config.bones declares is required — the region's control bone`);
2227
- }
2228
- const onGrid = (path: string, n: Json, rule: 'any' | 'positive' | 'non-negative'): void => {
2229
- if (!c.number(path, n, rule)) return;
2230
- const v2 = n as number;
2231
- if (!Number.isInteger(v2 * GRID) || Math.abs(v2) > MAX_SIDE) {
2232
- 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`);
2295
+ if ('regions' in o && c.array(`${at}.regions`, o.regions, false)) {
2296
+ (o.regions as Json[]).forEach((rv, i) => {
2297
+ const rat = `${at}.regions[${i}]`;
2298
+ const shape = typeof rv === 'object' && rv !== null && !Array.isArray(rv) ? (rv as Record<string, Json>).shape : undefined;
2299
+ const own = shape === 'circle' ? ['cx', 'cy', 'r'] : shape === 'polygon' ? ['points'] : [];
2300
+ const r = c.object(rat, rv, ['name', 'shape', 'bone', 'spacing', 'band', ...own], []);
2301
+ if (r === null) return;
2302
+ 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`);
2303
+ if ('name' in r && c.string(`${rat}.name`, r.name)) {
2304
+ 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`);
2305
+ names.add(r.name);
2306
+ }
2307
+ if ('bone' in r && (typeof r.bone !== 'string' || !bones.has(r.bone))) {
2308
+ c.fail('CONFIG_NAME_RESOLVES', `${rat}.bone`, `is ${show(r.bone)}; a bone config.bones declares is required — the region's control bone`);
2309
+ }
2310
+ if ('spacing' in r) c.number(`${rat}.spacing`, r.spacing, 'positive');
2311
+ if ('band' in r) onGrid(`${rat}.band`, r.band, 'non-negative');
2312
+ if (shape === 'circle') {
2313
+ if ('cx' in r) onGrid(`${rat}.cx`, r.cx, 'any');
2314
+ if ('cy' in r) onGrid(`${rat}.cy`, r.cy, 'any');
2315
+ if ('r' in r) onGrid(`${rat}.r`, r.r, 'positive');
2316
+ }
2317
+ if (shape === 'polygon' && 'points' in r && c.array(`${rat}.points`, r.points, true)) {
2318
+ const pts = r.points as Json[];
2319
+ 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`);
2320
+ pts.forEach((p, k) => {
2321
+ if (!c.point(`${rat}.points[${k}]`, p)) return;
2322
+ onGrid(`${rat}.points[${k}][0]`, (p as Point)[0], 'any');
2323
+ onGrid(`${rat}.points[${k}][1]`, (p as Point)[1], 'any');
2324
+ });
2325
+ }
2326
+ });
2327
+ }
2328
+ if ('bust' in o) {
2329
+ const bat = `${at}.bust`;
2330
+ const b = c.object(bat, o.bust, ['apex_r', 'apex_l', 'cleavage'], ['alpha', 'clearance', 'bone_r', 'bone_l', 'spacing']);
2331
+ if (b === null) return;
2332
+ for (const dup of ['bust_r', 'bust_l'] as const) {
2333
+ if (names.has(dup)) {
2334
+ c.fail('CONFIG_REGION_NAME_UNIQUE', bat, `"${dup}" is declared in both ${at}.regions and ${bat}; give either ${bat} or hand-written bust_r/bust_l in ${at}.regions, not both`);
2233
2335
  }
2234
- };
2235
- if ('spacing' in r) c.number(`${rat}.spacing`, r.spacing, 'positive');
2236
- if ('band' in r) onGrid(`${rat}.band`, r.band, 'non-negative');
2237
- if (shape === 'circle') {
2238
- if ('cx' in r) onGrid(`${rat}.cx`, r.cx, 'any');
2239
- if ('cy' in r) onGrid(`${rat}.cy`, r.cy, 'any');
2240
- if ('r' in r) onGrid(`${rat}.r`, r.r, 'positive');
2241
- }
2242
- if (shape === 'polygon' && 'points' in r && c.array(`${rat}.points`, r.points, true)) {
2243
- const pts = r.points as Json[];
2244
- 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`);
2245
- pts.forEach((p, k) => {
2246
- if (!c.point(`${rat}.points[${k}]`, p)) return;
2247
- onGrid(`${rat}.points[${k}][0]`, (p as Point)[0], 'any');
2248
- onGrid(`${rat}.points[${k}][1]`, (p as Point)[1], 'any');
2249
- });
2250
2336
  }
2251
- });
2337
+ let ptsOk = true;
2338
+ for (const k of ['apex_r', 'apex_l', 'cleavage'] as const) {
2339
+ if (!(k in b)) {
2340
+ ptsOk = false;
2341
+ continue;
2342
+ }
2343
+ if (!c.point(`${bat}.${k}`, b[k])) {
2344
+ ptsOk = false;
2345
+ continue;
2346
+ }
2347
+ onGrid(`${bat}.${k}[0]`, (b[k] as Point)[0], 'any');
2348
+ onGrid(`${bat}.${k}[1]`, (b[k] as Point)[1], 'any');
2349
+ }
2350
+ let alpha = DEFAULT_BUST_ALPHA;
2351
+ let alphaOk = true;
2352
+ if ('alpha' in b) {
2353
+ if (!c.number(`${bat}.alpha`, b.alpha, 'positive')) alphaOk = false;
2354
+ else if ((b.alpha as number) >= 1) {
2355
+ c.fail('CONFIG_FIELD_TYPE', `${bat}.alpha`, `is ${b.alpha as number}; a core ratio in (0, 1) is required (default ${DEFAULT_BUST_ALPHA})`);
2356
+ alphaOk = false;
2357
+ } else alpha = b.alpha as number;
2358
+ }
2359
+ let clearance = DEFAULT_BUST_CLEARANCE;
2360
+ let clearanceOk = true;
2361
+ if ('clearance' in b) {
2362
+ if (!c.number(`${bat}.clearance`, b.clearance, 'any')) clearanceOk = false;
2363
+ else clearance = b.clearance as number;
2364
+ }
2365
+ if ('spacing' in b) c.number(`${bat}.spacing`, b.spacing, 'positive');
2366
+ for (const [k, def, regName] of [
2367
+ ['bone_r', DEFAULT_BUST_BONE_R, 'bust_r'],
2368
+ ['bone_l', DEFAULT_BUST_BONE_L, 'bust_l'],
2369
+ ] as const) {
2370
+ const bone = k in b ? b[k] : def;
2371
+ if (typeof bone !== 'string' || !bones.has(bone)) {
2372
+ c.fail('CONFIG_NAME_RESOLVES', `${bat}.${k}`, `is ${show(bone)}; a bone config.bones declares is required — ${regName}'s control bone`);
2373
+ }
2374
+ }
2375
+ if (ptsOk && alphaOk && clearanceOk) {
2376
+ const [rx, ry] = b.apex_r as Point;
2377
+ const [lx, ly] = b.apex_l as Point;
2378
+ const [kx, ky] = b.cleavage as Point;
2379
+ const D = edgeLength(lx - rx, ly - ry);
2380
+ const dR = edgeLength(kx - rx, ky - ry);
2381
+ const dL = edgeLength(kx - lx, ky - ly);
2382
+ const RR = Math.round(dR - clearance);
2383
+ const RL = Math.round(dL - clearance);
2384
+ const rR = Math.round(alpha * RR);
2385
+ const rL = Math.round(alpha * RL);
2386
+ const bandR = RR - rR;
2387
+ const bandL = RL - rL;
2388
+ if (clearance <= 0 || RR + RL >= D || rR <= 0 || rL <= 0 || bandR < 0 || bandL < 0) {
2389
+ const minClearance = Math.max(0, (dR + dL - D) / 2);
2390
+ c.fail(
2391
+ 'CONFIG_BUST_CLEARANCE',
2392
+ `${bat}.clearance`,
2393
+ `is ${clearance} (giving R_r = ${RR}, R_l = ${RL}, R_r + R_l = ${RR + RL} px against apex distance D = ${pyRound(D, 2)} px, r_r = ${rR}, band_r = ${bandR}, r_l = ${rL}, band_l = ${bandL}); a clearance > 0 with R_r + R_l < D (clearance > ${pyRound(minClearance, 2)} px) and positive radii is required`,
2394
+ );
2395
+ }
2396
+ }
2397
+ }
2252
2398
  }
2253
2399
 
2254
2400
  /**
@@ -3219,10 +3365,29 @@ function maxDeriveDegrees(anim: unknown): number {
3219
3365
  return maxDeg;
3220
3366
  }
3221
3367
 
3222
- function checkFaceAnimationBand(c: Check, animations: AnimationsFrom, hasAltFace: boolean): void {
3368
+ function checkFaceAnimationBand(c: Check, animations: AnimationsFrom, hasAltFace: boolean, turn?: Json, attached?: Set<string>): void {
3223
3369
  const at = `config.motion.animations_from (${animations.file}).animations`;
3224
3370
  const maxYaw = maxDeriveDegrees(animations.table.turn);
3225
3371
  const maxPitch = maxDeriveDegrees(animations.table.tilt);
3372
+ if (maxYaw > 0 || maxPitch > 0) {
3373
+ if (turn === undefined) {
3374
+ c.fail(
3375
+ 'CONFIG_TURN_REQUIRED',
3376
+ 'config.turn',
3377
+ "is absent and required when motion.animations_from carries a turn or tilt slider animation with derive keys; copy the turn block propose drafted into config.turn so the head pivot and each head part's depth are declared",
3378
+ );
3379
+ } else if (isPlainObject(turn) && isPlainObject(turn.depths) && attached !== undefined) {
3380
+ for (const reqPart of ['face', 'hair_front', 'hair_back'] as const) {
3381
+ if (attached.has(reqPart) && !(reqPart in turn.depths)) {
3382
+ c.fail(
3383
+ 'CONFIG_TURN_REQUIRED',
3384
+ `config.turn.depths.${reqPart}`,
3385
+ `is absent and required when motion.animations_from carries a turn or tilt slider animation with derive keys and "${reqPart}" is attached; include "${reqPart}"'s See-through depth_median in config.turn.depths so the head pivot and fraction are defined`,
3386
+ );
3387
+ }
3388
+ }
3389
+ }
3390
+ }
3226
3391
  if (maxYaw > FACE_RATED_YAW_BAND && !hasAltFace) {
3227
3392
  c.fail(
3228
3393
  'CONFIG_FACE_BAND_EXCEEDED',
@@ -3274,11 +3439,123 @@ function checkFaceAnimationBand(c: Check, animations: AnimationsFrom, hasAltFace
3274
3439
  }
3275
3440
  }
3276
3441
 
3442
+ /**
3443
+ * Issue #473: an attachment track key in `motion.animations_from` may name an
3444
+ * alternate set (`{"t": …, "set": "<name>" | null}`) instead of spelling out the
3445
+ * `<set>_<slot>` attachment name. Refuses (`CONFIG_SET_COVERS_SLOT`) when `"set"`
3446
+ * appears on a non-attachment track, names an undeclared alternate set, or keys a
3447
+ * slot the set does not cover (`slots` or `hides`).
3448
+ */
3449
+ function checkAnimationSetKeys(
3450
+ c: Check,
3451
+ animations: AnimationsFrom,
3452
+ assemble: Json,
3453
+ allSlots: Set<string>,
3454
+ ): void {
3455
+ const at = `config.motion.animations_from (${animations.file}).animations`;
3456
+ const sets = new Map<string, { slots: Set<string>; hides: Set<string> }>();
3457
+ if (isPlainObject(assemble) && Array.isArray(assemble.alternates)) {
3458
+ for (const alt of assemble.alternates) {
3459
+ if (!isPlainObject(alt) || typeof alt.name !== 'string' || alt.name === '') continue;
3460
+ const hides = new Set<string>(
3461
+ Array.isArray(alt.hides) ? alt.hides.filter((h): h is string => typeof h === 'string') : [],
3462
+ );
3463
+ const altCuts = Array.isArray(alt.cuts)
3464
+ ? alt.cuts.filter(isPlainObject).map((cu) => cu.into).filter((s): s is string => typeof s === 'string')
3465
+ : [];
3466
+ const slots = Array.isArray(alt.parts)
3467
+ ? new Set<string>(alt.parts.filter((p): p is string => typeof p === 'string' && !hides.has(p)))
3468
+ : new Set<string>([...allSlots, ...altCuts].filter((p) => !hides.has(p)));
3469
+ sets.set(alt.name, { slots, hides });
3470
+ }
3471
+ }
3472
+ const declaredSets = sets.size === 0 ? 'none' : [...sets.keys()].map((n) => `"${n}"`).join(', ');
3473
+ for (const [animName, anim] of Object.entries(animations.table)) {
3474
+ if (!isPlainObject(anim) || !Array.isArray(anim.tracks)) continue;
3475
+ anim.tracks.forEach((tr, ti) => {
3476
+ if (!isPlainObject(tr) || !Array.isArray(tr.keys)) return;
3477
+ const slot = typeof tr.slot === 'string' && tr.slot !== '' ? tr.slot : null;
3478
+ const isAttachmentTrack = tr.property === 'attachment' && slot !== null;
3479
+ tr.keys.forEach((k, ki) => {
3480
+ if (!isPlainObject(k) || !('set' in k)) return;
3481
+ const keyAt = `${at}.${animName}.tracks[${ti}].keys[${ki}].set`;
3482
+ if (!isAttachmentTrack || slot === null) {
3483
+ c.fail(
3484
+ 'CONFIG_SET_COVERS_SLOT',
3485
+ keyAt,
3486
+ `is ${show(k.set)} on a ${show(tr.property)} track${slot === null ? '' : ` for slot "${slot}"`}; a "set" key belongs on a slot attachment track ({"slot": "<part>", "property": "attachment", "keys": [{"t": …, "set": "<name>" | null}]})`,
3487
+ );
3488
+ return;
3489
+ }
3490
+ if ('v' in k) {
3491
+ c.fail(
3492
+ 'CONFIG_FIELD_TYPE',
3493
+ keyAt,
3494
+ `is declared beside v (${show(k.v)}); state either "set": "<name>" | null or "v": "<attachment>" | null on an attachment key, not both`,
3495
+ );
3496
+ return;
3497
+ }
3498
+ const setVal = k.set;
3499
+ if (setVal === null) {
3500
+ if (!allSlots.has(slot)) {
3501
+ c.fail(
3502
+ 'CONFIG_SET_COVERS_SLOT',
3503
+ keyAt,
3504
+ `restores the base attachment on slot "${slot}", which is not a declared slot of the rig (declared slots: ${[...allSlots].map((s) => `"${s}"`).join(', ')})`,
3505
+ );
3506
+ }
3507
+ return;
3508
+ }
3509
+ if (typeof setVal !== 'string' || setVal === '') {
3510
+ c.fail(
3511
+ 'CONFIG_FIELD_TYPE',
3512
+ keyAt,
3513
+ `is ${show(setVal)}; a non-empty alternate set name or null (the base pose) is required`,
3514
+ );
3515
+ return;
3516
+ }
3517
+ const entry = sets.get(setVal);
3518
+ if (entry === undefined) {
3519
+ c.fail(
3520
+ 'CONFIG_SET_COVERS_SLOT',
3521
+ keyAt,
3522
+ `names alternate set "${setVal}" for slot "${slot}", which config.assemble.alternates does not declare (declared sets: ${declaredSets})`,
3523
+ );
3524
+ return;
3525
+ }
3526
+ if (!entry.slots.has(slot) && !entry.hides.has(slot)) {
3527
+ const covered = [...entry.slots, ...entry.hides].map((s) => `"${s}"`).join(', ') || 'none';
3528
+ c.fail(
3529
+ 'CONFIG_SET_COVERS_SLOT',
3530
+ keyAt,
3531
+ `names alternate set "${setVal}" on slot "${slot}", which set "${setVal}" does not cover (set "${setVal}" covers: ${covered}); add "${slot}" to that set's parts (or hides) in config.assemble.alternates, or key a slot the set covers`,
3532
+ );
3533
+ }
3534
+ });
3535
+ });
3536
+ }
3537
+ }
3538
+
3277
3539
  function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>, hasAltFace: boolean): void {
3278
3540
  const at = 'config.turn';
3279
- const o = c.object(at, v, ['pivot', 'depths'], ['deform', 'band']);
3541
+ const o = c.object(at, v, ['pivot', 'depths'], ['head', 'deform', 'band', 'radius', 'surface']);
3280
3542
  if (o === null) return;
3281
3543
  const pivot = 'pivot' in o && c.number(`${at}.pivot`, o.pivot) ? (o.pivot as number) : null;
3544
+ if ('radius' in o) c.number(`${at}.radius`, o.radius, 'positive');
3545
+ if ('surface' in o) {
3546
+ const so = c.object(`${at}.surface`, o.surface, [], ['radius']);
3547
+ if (so !== null && 'radius' in so) c.number(`${at}.surface.radius`, so.radius, 'positive');
3548
+ }
3549
+ let headPivot: number | null = null;
3550
+ if ('head' in o) {
3551
+ const ho = c.object(`${at}.head`, o.head, [], ['pivot', 'radius']);
3552
+ if (ho !== null) {
3553
+ if ('pivot' in ho && c.number(`${at}.head.pivot`, ho.pivot)) {
3554
+ headPivot = ho.pivot as number;
3555
+ }
3556
+ if ('radius' in ho) c.number(`${at}.head.radius`, ho.radius, 'positive');
3557
+ }
3558
+ }
3282
3559
  const depths: Record<string, number> = {};
3283
3560
  let depthsOk = false;
3284
3561
  if ('depths' in o) {
@@ -3369,6 +3646,16 @@ function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>,
3369
3646
  );
3370
3647
  }
3371
3648
  }
3649
+ if (headPivot !== null && depthsOk) {
3650
+ const nearest = turnNearest(depths);
3651
+ if (nearest !== null && !(headPivot > nearest.depth)) {
3652
+ c.fail(
3653
+ 'CONFIG_TURN_PIVOT_BEHIND_NEAREST',
3654
+ `${at}.head.pivot`,
3655
+ `is ${headPivot}; the nearest part, "${nearest.part}", lies at depth ${nearest.depth}, and a head pivot above that (further back) is required`,
3656
+ );
3657
+ }
3658
+ }
3372
3659
  if (bones.has(TURN_BONE)) {
3373
3660
  c.fail('CONFIG_TURN_BONE_FREE', `bone "${TURN_BONE}"`, `is the control the rig stage adds under root for config.turn, and config.bones already declares a bone of that name; rename it`);
3374
3661
  }
package/src/propose.ts CHANGED
@@ -127,7 +127,8 @@
127
127
  import { existsSync } from 'node:fs';
128
128
  import { join } from 'node:path';
129
129
  import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
130
- import { type BoneEntry, type ConfigConstraint, type ConfigStop, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
130
+ import { type BoneEntry, type ConfigConstraint, type ConfigStop, contourRegionsOf, deriveBustRegions, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
131
+ export { contourRegionsOf, deriveBustRegions };
131
132
  import { computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toWorld } from './coords.ts';
132
133
  import { PartsError, type Problem, refuseIfAny } from './errors.ts';
133
134
  import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';