@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/docs/AUTHORING.md +10 -5
- package/docs/STABILITY.md +2 -1
- package/package.json +1 -1
- package/src/build.ts +63 -18
- package/src/check.ts +24 -5
- package/src/config.ts +418 -52
- package/src/limbs.ts +92 -0
- package/src/propose.ts +2 -1
- package/src/rig.ts +242 -6
- package/src/structure.ts +6 -3
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
|
-
|
|
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)
|
|
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>([
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
if (
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
if (
|
|
2231
|
-
|
|
2232
|
-
if (
|
|
2233
|
-
|
|
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']);
|