@rig-c/parts 1.34.0 → 1.36.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
  /**
@@ -1139,13 +1210,14 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1139
1210
  if ('scene' in t) checkScene(c, t.scene);
1140
1211
  // Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
1141
1212
  let limbs: LimbNames | null = null;
1213
+ let names: { bones: Set<string>; chains: Map<string, number>; parents: Map<string, string> } | null = null;
1142
1214
  if ('limbs' in t) {
1143
1215
  if (!('bones' in t)) c.fail('CONFIG_FIELD_PRESENT', 'config.bones', 'is absent; config.limbs names bones, and each joint is its bone\'s origin, so assemble reads the bones with the limbs');
1144
- const names = 'bones' in t ? checkBones(c, t.bones) : null;
1216
+ names = 'bones' in t ? checkBones(c, t.bones) : null;
1145
1217
  limbs = checkLimbs(c, t.limbs, made, names);
1146
1218
  }
1147
1219
  if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
1148
- checkAlternates(c, t.assemble.alternates, made, limbs, null);
1220
+ checkAlternates(c, t.assemble.alternates, made, limbs, names?.bones ?? null, names?.chains ?? null, t.regions, t.meshes);
1149
1221
  }
1150
1222
  }
1151
1223
  if ('cloth' in t) checkCloth(c, t.cloth);
@@ -1297,7 +1369,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1297
1369
  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>() };
1298
1370
  const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
1299
1371
  if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
1300
- checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs, names.bones);
1372
+ checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs, names.bones, names.chains, t.regions, t.meshes);
1301
1373
  }
1302
1374
  if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
1303
1375
  if ('regions' in t) checkRegions(c, t.regions, names.bones);
@@ -1312,7 +1384,10 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1312
1384
  if ('cloth' in t) checkCloth(c, t.cloth);
1313
1385
  const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
1314
1386
  const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
1315
- if (animations !== null) checkFaceAnimationBand(c, animations, hasAltFace, t.turn, attached);
1387
+ if (animations !== null) {
1388
+ checkFaceAnimationBand(c, animations, hasAltFace, t.turn, attached);
1389
+ checkAnimationSetKeys(c, animations, t.assemble, new Set<string>([...parts, ...patches, ...cuts, ...(limbs?.parts.keys() ?? [])]));
1390
+ }
1316
1391
  if (base !== null) poseBeside(c, t.generation, base);
1317
1392
  refuseIfAny(c.problems);
1318
1393
  return { config: raw as CharacterConfig, animations };
@@ -1828,10 +1903,18 @@ function checkAlternates(
1828
1903
  made: { parts: string[]; patches: string[]; cuts: string[] },
1829
1904
  limbs: LimbNames | null,
1830
1905
  bones: Set<string> | null = null,
1906
+ chains: Map<string, number> | null = null,
1907
+ regions: Json = undefined,
1908
+ meshes: Json = undefined,
1831
1909
  ): void {
1832
1910
  const p = 'config.assemble.alternates';
1833
1911
  if (!c.array(p, v, true)) return;
1834
- const allSlots = new Set<string>([...made.parts, ...made.patches, ...made.cuts, ...(limbs?.parts.keys() ?? [])]);
1912
+ const allSlots = new Set<string>([
1913
+ ...made.parts.filter((n) => !(limbs?.consumed.has(n) ?? false)),
1914
+ ...made.patches,
1915
+ ...made.cuts,
1916
+ ...(limbs?.parts.keys() ?? []),
1917
+ ]);
1835
1918
  const seenNames = new Map<string, number>();
1836
1919
  (v as Json[]).forEach((entry, i) => {
1837
1920
  const at = `${p}[${i}]`;
@@ -1860,29 +1943,135 @@ function checkAlternates(
1860
1943
  }
1861
1944
  if ('head_box' in e) checkHeadBox(c, `${at}.head_box`, e.head_box);
1862
1945
  const altCuts = 'cuts' in e ? checkCuts(c, e.cuts, made.parts, [], `${at}.cuts`) : [];
1946
+ let cutsConsumedLimb = false;
1947
+ if (limbs !== null && 'cuts' in e && Array.isArray(e.cuts)) {
1948
+ e.cuts.forEach((ce, ci) => {
1949
+ if (!isPlainObject(ce) || typeof ce.from !== 'string') return;
1950
+ const byLimb = limbs.consumed.get(ce.from);
1951
+ if (byLimb !== undefined) {
1952
+ cutsConsumedLimb = true;
1953
+ const segList = (limbs.chains.get(byLimb)?.parts ?? []).map((s) => `"${s}"`).join(', ');
1954
+ c.fail(
1955
+ 'CONFIG_LIMB_SET_JOINTS',
1956
+ `${at}.cuts[${ci}].from`,
1957
+ `cuts "${ce.from}", which config.limbs.${byLimb} cuts at the base's joints into ${segList}; a gesture set over a limb chain is cut at the base's joints and may not declare its own polygon cuts for that layer`,
1958
+ );
1959
+ }
1960
+ });
1961
+ }
1863
1962
  const validParts = new Set<string>([...allSlots, ...altCuts]);
1963
+ const seenParts = new Set<string>();
1864
1964
  if ('parts' in e && c.array(`${at}.parts`, e.parts, true)) {
1865
- const seen = new Set<string>();
1866
1965
  (e.parts as Json[]).forEach((pn, k) => {
1867
1966
  if (!c.string(`${at}.parts[${k}]`, pn)) return;
1868
- if (!validParts.has(pn)) {
1967
+ const byLimb = limbs?.consumed.get(pn);
1968
+ if (byLimb !== undefined) {
1969
+ const segList = (limbs?.chains.get(byLimb)?.parts ?? []).map((s) => `"${s}"`).join(', ');
1970
+ c.fail(
1971
+ 'CONFIG_LIMB_SET_JOINTS',
1972
+ `${at}.parts[${k}]`,
1973
+ `names "${pn}", which config.limbs.${byLimb} cuts at the base's joints into ${segList}; a gesture set covers the limb chain's segment parts at the base's joints, not the unsplit layer`,
1974
+ );
1975
+ } else if (!validParts.has(pn)) {
1869
1976
  c.fail('CONFIG_NAME_RESOLVES', `${at}.parts[${k}]`, `is ${show(pn)}; a slot of the base rig (or a part made by this alternate's cuts) is required`);
1870
- } else if (seen.has(pn)) {
1977
+ } else if (seenParts.has(pn)) {
1871
1978
  c.fail('CONFIG_PART_UNIQUE', `${at}.parts[${k}]`, `repeats "${pn}"; each covered part is named once`);
1872
- } else seen.add(pn);
1979
+ } else seenParts.add(pn);
1873
1980
  });
1874
1981
  }
1982
+ const seenHides = new Set<string>();
1875
1983
  if ('hides' in e && c.array(`${at}.hides`, e.hides, false)) {
1876
- const seen = new Set<string>();
1877
1984
  (e.hides as Json[]).forEach((hn, k) => {
1878
1985
  if (!c.string(`${at}.hides[${k}]`, hn)) return;
1879
- if (!allSlots.has(hn)) {
1986
+ const byLimb = limbs?.consumed.get(hn);
1987
+ if (byLimb !== undefined) {
1988
+ const segList = (limbs?.chains.get(byLimb)?.parts ?? []).map((s) => `"${s}"`).join(', ');
1989
+ c.fail(
1990
+ 'CONFIG_LIMB_SET_JOINTS',
1991
+ `${at}.hides[${k}]`,
1992
+ `names "${hn}", which config.limbs.${byLimb} cuts at the base's joints into ${segList}; a gesture set covers the limb chain's segment parts at the base's joints, not the unsplit layer`,
1993
+ );
1994
+ } else if (!allSlots.has(hn)) {
1880
1995
  c.fail('CONFIG_NAME_RESOLVES', `${at}.hides[${k}]`, `is ${show(hn)}; a slot of the base rig is required`);
1881
- } else if (seen.has(hn)) {
1996
+ } else if (seenHides.has(hn)) {
1882
1997
  c.fail('CONFIG_PART_UNIQUE', `${at}.hides[${k}]`, `repeats "${hn}"; each hidden slot is named once`);
1883
- } else seen.add(hn);
1998
+ } else seenHides.add(hn);
1884
1999
  });
1885
2000
  }
2001
+ if ('parts' in e && limbs !== null) {
2002
+ for (const [limbName, chain] of limbs.chains) {
2003
+ const coveredSegs = chain.parts.filter((sp) => seenParts.has(sp) && !seenHides.has(sp));
2004
+ const hiddenSegs = chain.parts.filter((sp) => seenHides.has(sp));
2005
+ if ((coveredSegs.length > 0 || hiddenSegs.length > 0) && coveredSegs.length !== chain.parts.length) {
2006
+ c.fail(
2007
+ 'CONFIG_LIMB_SET_JOINTS',
2008
+ `${at}.parts`,
2009
+ `covers [${coveredSegs.map((s) => `"${s}"`).join(', ')}]${hiddenSegs.length > 0 ? ` (and hides [${hiddenSegs.map((s) => `"${s}"`).join(', ')}])` : ''} of config.limbs.${limbName}, which cuts at the base's joints into [${chain.parts.map((s) => `"${s}"`).join(', ')}]; a gesture set over a limb chain must cover all ${chain.parts.length} of its segment parts so it is cut at the base's joints`,
2010
+ );
2011
+ } else if (coveredSegs.length === chain.parts.length && !cutsConsumedLimb && 'cuts' in e && Array.isArray(e.cuts) && e.cuts.length > 0) {
2012
+ c.fail(
2013
+ 'CONFIG_LIMB_SET_JOINTS',
2014
+ `${at}.cuts`,
2015
+ `declares custom polygon cuts on a gesture set covering config.limbs.${limbName}; a gesture set is cut at the base's joints and declares no cuts of its own`,
2016
+ );
2017
+ }
2018
+ }
2019
+ }
2020
+ if ('parts' in e && 'drawn' in e && isPlainObject(e.drawn)) {
2021
+ const activeParts = [...seenParts].filter((pn) => !seenHides.has(pn));
2022
+ const coveredBones = new Set<string>();
2023
+ let allPartsResolved = activeParts.length > 0;
2024
+ for (const pn of activeParts) {
2025
+ const limbSeg = limbs?.parts.get(pn);
2026
+ if (limbSeg !== undefined) {
2027
+ coveredBones.add(limbSeg.bone);
2028
+ } else if (isPlainObject(regions) && typeof regions[pn] === 'string') {
2029
+ coveredBones.add(regions[pn]);
2030
+ } else if (isPlainObject(meshes) && isPlainObject(meshes[pn])) {
2031
+ const m = meshes[pn] as Record<string, Json>;
2032
+ if (Array.isArray(m.segments)) {
2033
+ for (const s of m.segments) {
2034
+ if (typeof s === 'string') {
2035
+ const links = chains?.get(s);
2036
+ if (links !== undefined) {
2037
+ for (let lk = 0; lk < links; lk++) coveredBones.add(`${s}${lk}`);
2038
+ } else {
2039
+ coveredBones.add(s);
2040
+ }
2041
+ } else if (Array.isArray(s) && typeof s[0] === 'string') {
2042
+ coveredBones.add(s[0]);
2043
+ }
2044
+ }
2045
+ }
2046
+ if (isPlainObject(m.contour)) {
2047
+ if (Array.isArray(m.contour.regions)) {
2048
+ for (const rg of m.contour.regions) if (isPlainObject(rg) && typeof rg.bone === 'string') coveredBones.add(rg.bone);
2049
+ }
2050
+ if (isPlainObject(m.contour.bust)) {
2051
+ coveredBones.add(typeof m.contour.bust.bone_r === 'string' ? m.contour.bust.bone_r : DEFAULT_BUST_BONE_R);
2052
+ coveredBones.add(typeof m.contour.bust.bone_l === 'string' ? m.contour.bust.bone_l : DEFAULT_BUST_BONE_L);
2053
+ }
2054
+ }
2055
+ if (isPlainObject(m.auto) && Array.isArray(m.auto.regions)) {
2056
+ for (const rg of m.auto.regions) if (isPlainObject(rg) && typeof rg.bone === 'string') coveredBones.add(rg.bone);
2057
+ }
2058
+ } else {
2059
+ allPartsResolved = false;
2060
+ }
2061
+ }
2062
+ if (allPartsResolved) {
2063
+ const coveredList = [...coveredBones].map((b) => `"${b}"`).join(', ') || 'none';
2064
+ for (const bName of Object.keys(e.drawn)) {
2065
+ if ((bones === null || bones.has(bName)) && !coveredBones.has(bName)) {
2066
+ c.fail(
2067
+ 'CONFIG_SET_COVERS_BONE',
2068
+ `${at}.drawn.${bName}`,
2069
+ `names "${bName}", which alternate set "${String(e.name ?? '')}" does not cover (covered bones: ${coveredList}); a subset set may state drawn angles only for the bones its covered parts ride`,
2070
+ );
2071
+ }
2072
+ }
2073
+ }
2074
+ }
1886
2075
  });
1887
2076
  }
1888
2077
 
@@ -2199,7 +2388,7 @@ function checkRibs(c: Check, at: string, m: Record<string, Json>, chains: Map<st
2199
2388
  * the grid every region number must sit on.
2200
2389
  */
2201
2390
  function checkContour(c: Check, at: string, v: Json, bones: Set<string>): void {
2202
- const o = c.object(at, v, ['tolerance', 'margin', 'spacing'], ['maxEdge', 'budget', 'stray', 'bridges', 'regions']);
2391
+ const o = c.object(at, v, ['tolerance', 'margin', 'spacing'], ['maxEdge', 'budget', 'stray', 'bridges', 'regions', 'bust']);
2203
2392
  if (o === null) return;
2204
2393
  if ('tolerance' in o) c.number(`${at}.tolerance`, o.tolerance, 'non-negative');
2205
2394
  if ('margin' in o && c.number(`${at}.margin`, o.margin, 'non-negative') && (o.margin as number) > 0 && (o.margin as number) < 1) {
@@ -2210,46 +2399,117 @@ function checkContour(c: Check, at: string, v: Json, bones: Set<string>): void {
2210
2399
  if ('budget' in o) c.int(`${at}.budget`, o.budget, 3);
2211
2400
  if ('stray' in o) c.int(`${at}.stray`, o.stray, 0);
2212
2401
  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`);
2213
- if (!('regions' in o) || !c.array(`${at}.regions`, o.regions, false)) return;
2402
+ const onGrid = (path: string, n: Json, rule: 'any' | 'positive' | 'non-negative'): void => {
2403
+ if (!c.number(path, n, rule)) return;
2404
+ const v2 = n as number;
2405
+ if (!Number.isInteger(v2 * GRID) || Math.abs(v2) > MAX_SIDE) {
2406
+ 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`);
2407
+ }
2408
+ };
2214
2409
  const names = new Set<string>();
2215
- (o.regions as Json[]).forEach((rv, i) => {
2216
- const rat = `${at}.regions[${i}]`;
2217
- const shape = typeof rv === 'object' && rv !== null && !Array.isArray(rv) ? (rv as Record<string, Json>).shape : undefined;
2218
- const own = shape === 'circle' ? ['cx', 'cy', 'r'] : shape === 'polygon' ? ['points'] : [];
2219
- const r = c.object(rat, rv, ['name', 'shape', 'bone', 'spacing', 'band', ...own], []);
2220
- if (r === null) return;
2221
- 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`);
2222
- if ('name' in r && c.string(`${rat}.name`, r.name)) {
2223
- 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`);
2224
- names.add(r.name);
2225
- }
2226
- if ('bone' in r && (typeof r.bone !== 'string' || !bones.has(r.bone))) {
2227
- c.fail('CONFIG_NAME_RESOLVES', `${rat}.bone`, `is ${show(r.bone)}; a bone config.bones declares is required — the region's control bone`);
2228
- }
2229
- const onGrid = (path: string, n: Json, rule: 'any' | 'positive' | 'non-negative'): void => {
2230
- if (!c.number(path, n, rule)) return;
2231
- const v2 = n as number;
2232
- if (!Number.isInteger(v2 * GRID) || Math.abs(v2) > MAX_SIDE) {
2233
- 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`);
2410
+ if ('regions' in o && c.array(`${at}.regions`, o.regions, false)) {
2411
+ (o.regions as Json[]).forEach((rv, i) => {
2412
+ const rat = `${at}.regions[${i}]`;
2413
+ const shape = typeof rv === 'object' && rv !== null && !Array.isArray(rv) ? (rv as Record<string, Json>).shape : undefined;
2414
+ const own = shape === 'circle' ? ['cx', 'cy', 'r'] : shape === 'polygon' ? ['points'] : [];
2415
+ const r = c.object(rat, rv, ['name', 'shape', 'bone', 'spacing', 'band', ...own], []);
2416
+ if (r === null) return;
2417
+ 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`);
2418
+ if ('name' in r && c.string(`${rat}.name`, r.name)) {
2419
+ 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`);
2420
+ names.add(r.name);
2421
+ }
2422
+ if ('bone' in r && (typeof r.bone !== 'string' || !bones.has(r.bone))) {
2423
+ c.fail('CONFIG_NAME_RESOLVES', `${rat}.bone`, `is ${show(r.bone)}; a bone config.bones declares is required — the region's control bone`);
2424
+ }
2425
+ if ('spacing' in r) c.number(`${rat}.spacing`, r.spacing, 'positive');
2426
+ if ('band' in r) onGrid(`${rat}.band`, r.band, 'non-negative');
2427
+ if (shape === 'circle') {
2428
+ if ('cx' in r) onGrid(`${rat}.cx`, r.cx, 'any');
2429
+ if ('cy' in r) onGrid(`${rat}.cy`, r.cy, 'any');
2430
+ if ('r' in r) onGrid(`${rat}.r`, r.r, 'positive');
2431
+ }
2432
+ if (shape === 'polygon' && 'points' in r && c.array(`${rat}.points`, r.points, true)) {
2433
+ const pts = r.points as Json[];
2434
+ 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`);
2435
+ pts.forEach((p, k) => {
2436
+ if (!c.point(`${rat}.points[${k}]`, p)) return;
2437
+ onGrid(`${rat}.points[${k}][0]`, (p as Point)[0], 'any');
2438
+ onGrid(`${rat}.points[${k}][1]`, (p as Point)[1], 'any');
2439
+ });
2440
+ }
2441
+ });
2442
+ }
2443
+ if ('bust' in o) {
2444
+ const bat = `${at}.bust`;
2445
+ const b = c.object(bat, o.bust, ['apex_r', 'apex_l', 'cleavage'], ['alpha', 'clearance', 'bone_r', 'bone_l', 'spacing']);
2446
+ if (b === null) return;
2447
+ for (const dup of ['bust_r', 'bust_l'] as const) {
2448
+ if (names.has(dup)) {
2449
+ 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`);
2234
2450
  }
2235
- };
2236
- if ('spacing' in r) c.number(`${rat}.spacing`, r.spacing, 'positive');
2237
- if ('band' in r) onGrid(`${rat}.band`, r.band, 'non-negative');
2238
- if (shape === 'circle') {
2239
- if ('cx' in r) onGrid(`${rat}.cx`, r.cx, 'any');
2240
- if ('cy' in r) onGrid(`${rat}.cy`, r.cy, 'any');
2241
- if ('r' in r) onGrid(`${rat}.r`, r.r, 'positive');
2242
- }
2243
- if (shape === 'polygon' && 'points' in r && c.array(`${rat}.points`, r.points, true)) {
2244
- const pts = r.points as Json[];
2245
- 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`);
2246
- pts.forEach((p, k) => {
2247
- if (!c.point(`${rat}.points[${k}]`, p)) return;
2248
- onGrid(`${rat}.points[${k}][0]`, (p as Point)[0], 'any');
2249
- onGrid(`${rat}.points[${k}][1]`, (p as Point)[1], 'any');
2250
- });
2251
2451
  }
2252
- });
2452
+ let ptsOk = true;
2453
+ for (const k of ['apex_r', 'apex_l', 'cleavage'] as const) {
2454
+ if (!(k in b)) {
2455
+ ptsOk = false;
2456
+ continue;
2457
+ }
2458
+ if (!c.point(`${bat}.${k}`, b[k])) {
2459
+ ptsOk = false;
2460
+ continue;
2461
+ }
2462
+ onGrid(`${bat}.${k}[0]`, (b[k] as Point)[0], 'any');
2463
+ onGrid(`${bat}.${k}[1]`, (b[k] as Point)[1], 'any');
2464
+ }
2465
+ let alpha = DEFAULT_BUST_ALPHA;
2466
+ let alphaOk = true;
2467
+ if ('alpha' in b) {
2468
+ if (!c.number(`${bat}.alpha`, b.alpha, 'positive')) alphaOk = false;
2469
+ else if ((b.alpha as number) >= 1) {
2470
+ c.fail('CONFIG_FIELD_TYPE', `${bat}.alpha`, `is ${b.alpha as number}; a core ratio in (0, 1) is required (default ${DEFAULT_BUST_ALPHA})`);
2471
+ alphaOk = false;
2472
+ } else alpha = b.alpha as number;
2473
+ }
2474
+ let clearance = DEFAULT_BUST_CLEARANCE;
2475
+ let clearanceOk = true;
2476
+ if ('clearance' in b) {
2477
+ if (!c.number(`${bat}.clearance`, b.clearance, 'any')) clearanceOk = false;
2478
+ else clearance = b.clearance as number;
2479
+ }
2480
+ if ('spacing' in b) c.number(`${bat}.spacing`, b.spacing, 'positive');
2481
+ for (const [k, def, regName] of [
2482
+ ['bone_r', DEFAULT_BUST_BONE_R, 'bust_r'],
2483
+ ['bone_l', DEFAULT_BUST_BONE_L, 'bust_l'],
2484
+ ] as const) {
2485
+ const bone = k in b ? b[k] : def;
2486
+ if (typeof bone !== 'string' || !bones.has(bone)) {
2487
+ c.fail('CONFIG_NAME_RESOLVES', `${bat}.${k}`, `is ${show(bone)}; a bone config.bones declares is required — ${regName}'s control bone`);
2488
+ }
2489
+ }
2490
+ if (ptsOk && alphaOk && clearanceOk) {
2491
+ const [rx, ry] = b.apex_r as Point;
2492
+ const [lx, ly] = b.apex_l as Point;
2493
+ const [kx, ky] = b.cleavage as Point;
2494
+ const D = edgeLength(lx - rx, ly - ry);
2495
+ const dR = edgeLength(kx - rx, ky - ry);
2496
+ const dL = edgeLength(kx - lx, ky - ly);
2497
+ const RR = Math.round(dR - clearance);
2498
+ const RL = Math.round(dL - clearance);
2499
+ const rR = Math.round(alpha * RR);
2500
+ const rL = Math.round(alpha * RL);
2501
+ const bandR = RR - rR;
2502
+ const bandL = RL - rL;
2503
+ if (clearance <= 0 || RR + RL >= D || rR <= 0 || rL <= 0 || bandR < 0 || bandL < 0) {
2504
+ const minClearance = Math.max(0, (dR + dL - D) / 2);
2505
+ c.fail(
2506
+ 'CONFIG_BUST_CLEARANCE',
2507
+ `${bat}.clearance`,
2508
+ `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`,
2509
+ );
2510
+ }
2511
+ }
2512
+ }
2253
2513
  }
2254
2514
 
2255
2515
  /**
@@ -2448,6 +2708,7 @@ function checkRegions(c: Check, v: Json, bones: Set<string>): void {
2448
2708
  interface LimbNames {
2449
2709
  parts: Map<string, { bone: string; at: string }>;
2450
2710
  consumed: Map<string, string>;
2711
+ chains: Map<string, { parts: string[]; bones: string[]; froms: string[] }>;
2451
2712
  }
2452
2713
 
2453
2714
  /**
@@ -2473,7 +2734,7 @@ interface LimbNames {
2473
2734
  * resolved. Returns the limb parts and the parts the limbs take.
2474
2735
  */
2475
2736
  function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[]; cuts: string[] }, names: { bones: Set<string>; parents: Map<string, string> } | null): LimbNames {
2476
- const out: LimbNames = { parts: new Map(), consumed: new Map() };
2737
+ const out: LimbNames = { parts: new Map(), consumed: new Map(), chains: new Map() };
2477
2738
  if (!isPlainObject(v)) {
2478
2739
  c.fail('CONFIG_FIELD_TYPE', 'config.limbs', `is ${show(v)}; an object of limb name -> {"segments": [...]} is required`);
2479
2740
  return out;
@@ -2518,11 +2779,13 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2518
2779
  continue;
2519
2780
  }
2520
2781
  const segs = o.segments as Json[];
2782
+ const segParts: Array<string | null> = [];
2521
2783
  const froms: Array<string | null> = [];
2522
2784
  const bones: Array<string | null> = [];
2523
2785
  segs.forEach((entry, i) => {
2524
2786
  const sat = `${at}.segments[${i}]`;
2525
2787
  const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap', 'edge', 'cap_shape', 'cut_band']);
2788
+ segParts.push(null);
2526
2789
  froms.push(null);
2527
2790
  bones.push(null);
2528
2791
  if (e === null) return;
@@ -2538,6 +2801,7 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2538
2801
  } else if (firstAt.has(name)) c.fail('CONFIG_PART_UNIQUE', `${sat}.part`, `names the part "${name}" again (first at ${firstAt.get(name)})`);
2539
2802
  else {
2540
2803
  firstAt.set(name, sat);
2804
+ segParts[i] = name;
2541
2805
  if ('bone' in e && typeof e.bone === 'string') out.parts.set(name, { bone: e.bone, at: `config.limbs.${limb} segment ${i}` });
2542
2806
  }
2543
2807
  }
@@ -2582,6 +2846,11 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2582
2846
  }
2583
2847
  }
2584
2848
  });
2849
+ out.chains.set(limb, {
2850
+ parts: segParts.filter((q): q is string => q !== null),
2851
+ bones: bones.filter((q): q is string => q !== null),
2852
+ froms: [...new Set(froms.filter((q): q is string => q !== null))],
2853
+ });
2585
2854
  limbFroms.set(limb, new Set(froms.filter((f): f is string => f !== null)));
2586
2855
  froms.forEach((f, i) => {
2587
2856
  if (f === null || i === 0 || froms[i - 1] === f) return;
@@ -3294,6 +3563,103 @@ function checkFaceAnimationBand(c: Check, animations: AnimationsFrom, hasAltFace
3294
3563
  }
3295
3564
  }
3296
3565
 
3566
+ /**
3567
+ * Issue #473: an attachment track key in `motion.animations_from` may name an
3568
+ * alternate set (`{"t": …, "set": "<name>" | null}`) instead of spelling out the
3569
+ * `<set>_<slot>` attachment name. Refuses (`CONFIG_SET_COVERS_SLOT`) when `"set"`
3570
+ * appears on a non-attachment track, names an undeclared alternate set, or keys a
3571
+ * slot the set does not cover (`slots` or `hides`).
3572
+ */
3573
+ function checkAnimationSetKeys(
3574
+ c: Check,
3575
+ animations: AnimationsFrom,
3576
+ assemble: Json,
3577
+ allSlots: Set<string>,
3578
+ ): void {
3579
+ const at = `config.motion.animations_from (${animations.file}).animations`;
3580
+ const sets = new Map<string, { slots: Set<string>; hides: Set<string> }>();
3581
+ if (isPlainObject(assemble) && Array.isArray(assemble.alternates)) {
3582
+ for (const alt of assemble.alternates) {
3583
+ if (!isPlainObject(alt) || typeof alt.name !== 'string' || alt.name === '') continue;
3584
+ const hides = new Set<string>(
3585
+ Array.isArray(alt.hides) ? alt.hides.filter((h): h is string => typeof h === 'string') : [],
3586
+ );
3587
+ const altCuts = Array.isArray(alt.cuts)
3588
+ ? alt.cuts.filter(isPlainObject).map((cu) => cu.into).filter((s): s is string => typeof s === 'string')
3589
+ : [];
3590
+ const slots = Array.isArray(alt.parts)
3591
+ ? new Set<string>(alt.parts.filter((p): p is string => typeof p === 'string' && !hides.has(p)))
3592
+ : new Set<string>([...allSlots, ...altCuts].filter((p) => !hides.has(p)));
3593
+ sets.set(alt.name, { slots, hides });
3594
+ }
3595
+ }
3596
+ const declaredSets = sets.size === 0 ? 'none' : [...sets.keys()].map((n) => `"${n}"`).join(', ');
3597
+ for (const [animName, anim] of Object.entries(animations.table)) {
3598
+ if (!isPlainObject(anim) || !Array.isArray(anim.tracks)) continue;
3599
+ anim.tracks.forEach((tr, ti) => {
3600
+ if (!isPlainObject(tr) || !Array.isArray(tr.keys)) return;
3601
+ const slot = typeof tr.slot === 'string' && tr.slot !== '' ? tr.slot : null;
3602
+ const isAttachmentTrack = tr.property === 'attachment' && slot !== null;
3603
+ tr.keys.forEach((k, ki) => {
3604
+ if (!isPlainObject(k) || !('set' in k)) return;
3605
+ const keyAt = `${at}.${animName}.tracks[${ti}].keys[${ki}].set`;
3606
+ if (!isAttachmentTrack || slot === null) {
3607
+ c.fail(
3608
+ 'CONFIG_SET_COVERS_SLOT',
3609
+ keyAt,
3610
+ `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}]})`,
3611
+ );
3612
+ return;
3613
+ }
3614
+ if ('v' in k) {
3615
+ c.fail(
3616
+ 'CONFIG_FIELD_TYPE',
3617
+ keyAt,
3618
+ `is declared beside v (${show(k.v)}); state either "set": "<name>" | null or "v": "<attachment>" | null on an attachment key, not both`,
3619
+ );
3620
+ return;
3621
+ }
3622
+ const setVal = k.set;
3623
+ if (setVal === null) {
3624
+ if (!allSlots.has(slot)) {
3625
+ c.fail(
3626
+ 'CONFIG_SET_COVERS_SLOT',
3627
+ keyAt,
3628
+ `restores the base attachment on slot "${slot}", which is not a declared slot of the rig (declared slots: ${[...allSlots].map((s) => `"${s}"`).join(', ')})`,
3629
+ );
3630
+ }
3631
+ return;
3632
+ }
3633
+ if (typeof setVal !== 'string' || setVal === '') {
3634
+ c.fail(
3635
+ 'CONFIG_FIELD_TYPE',
3636
+ keyAt,
3637
+ `is ${show(setVal)}; a non-empty alternate set name or null (the base pose) is required`,
3638
+ );
3639
+ return;
3640
+ }
3641
+ const entry = sets.get(setVal);
3642
+ if (entry === undefined) {
3643
+ c.fail(
3644
+ 'CONFIG_SET_COVERS_SLOT',
3645
+ keyAt,
3646
+ `names alternate set "${setVal}" for slot "${slot}", which config.assemble.alternates does not declare (declared sets: ${declaredSets})`,
3647
+ );
3648
+ return;
3649
+ }
3650
+ if (!entry.slots.has(slot) && !entry.hides.has(slot)) {
3651
+ const covered = [...entry.slots, ...entry.hides].map((s) => `"${s}"`).join(', ') || 'none';
3652
+ c.fail(
3653
+ 'CONFIG_SET_COVERS_SLOT',
3654
+ keyAt,
3655
+ `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`,
3656
+ );
3657
+ }
3658
+ });
3659
+ });
3660
+ }
3661
+ }
3662
+
3297
3663
  function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>, hasAltFace: boolean): void {
3298
3664
  const at = 'config.turn';
3299
3665
  const o = c.object(at, v, ['pivot', 'depths'], ['head', 'deform', 'band', 'radius', 'surface']);