@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/docs/AUTHORING.md +9 -6
- package/docs/STABILITY.md +5 -2
- package/package.json +1 -1
- package/src/check.ts +24 -5
- package/src/config.ts +329 -42
- package/src/propose.ts +2 -1
- package/src/rig.ts +160 -4
- package/src/structure.ts +6 -3
- package/src/turn.ts +404 -102
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,
|
|
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)
|
|
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
|
-
|
|
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
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
if (
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
if (
|
|
2230
|
-
|
|
2231
|
-
if (
|
|
2232
|
-
|
|
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';
|