@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/rig.ts
CHANGED
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
* touches the disk, and the same inputs give the same bytes (key order is the
|
|
133
133
|
* order the objects are built in, and every number is rounded by `pyRound`).
|
|
134
134
|
*/
|
|
135
|
-
import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, type ConfigStop, CONSTRAINT_FOLLOWS, constraintForRig, type ContourSpec, type Point, type RibsSpec, ROOT_BONE, stopForRig, weightsABone } from './config.ts';
|
|
135
|
+
import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, type ConfigStop, CONSTRAINT_FOLLOWS, constraintForRig, contourRegionsOf, type ContourSpec, DEFAULT_BUST_BONE_L, DEFAULT_BUST_BONE_R, type Point, type RibsSpec, ROOT_BONE, stopForRig, weightsABone } from './config.ts';
|
|
136
136
|
import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
|
|
137
137
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
138
138
|
import { eyeLineDegrees } from './headframe.ts';
|
|
@@ -164,6 +164,7 @@ import {
|
|
|
164
164
|
} from './automesh.ts';
|
|
165
165
|
import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
|
|
166
166
|
import { type LocalInfluence, localInfluences } from './localweights.ts';
|
|
167
|
+
import { edgeLength } from './length.ts';
|
|
167
168
|
import { artCoverage, ART_ALPHA, latticeCells, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
|
|
168
169
|
import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
|
|
169
170
|
import { type AlternateSetMap, PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
|
|
@@ -1161,6 +1162,14 @@ export function buildRig(
|
|
|
1161
1162
|
(m.contour.regions ?? []).forEach((rg, i) => {
|
|
1162
1163
|
if (!B.has(rg.bone)) fail('RIG_NAME_RESOLVES', `config.meshes.${part}.contour.regions[${i}].bone`, `names the bone "${rg.bone}", which config.bones does not declare`);
|
|
1163
1164
|
});
|
|
1165
|
+
if (m.contour.bust !== undefined) {
|
|
1166
|
+
for (const [k, bone] of [
|
|
1167
|
+
['bone_r', m.contour.bust.bone_r ?? DEFAULT_BUST_BONE_R],
|
|
1168
|
+
['bone_l', m.contour.bust.bone_l ?? DEFAULT_BUST_BONE_L],
|
|
1169
|
+
] as const) {
|
|
1170
|
+
if (!B.has(bone)) fail('RIG_NAME_RESOLVES', `config.meshes.${part}.contour.bust.${k}`, `names the bone "${bone}", which config.bones does not declare`);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1164
1173
|
}
|
|
1165
1174
|
if ('auto' in m) {
|
|
1166
1175
|
(m.auto.regions ?? []).forEach((rg, i) => {
|
|
@@ -1274,7 +1283,7 @@ export function buildRig(
|
|
|
1274
1283
|
for (const segs of meshSegments.values()) for (const s of segs) meshBones.add(s.bone);
|
|
1275
1284
|
// A contour region's control bone is weighted to like a segment's bone (issue #84), so the idle's keys on it move
|
|
1276
1285
|
// to its control under `ctl` as theirs do. A config with no contour region adds nothing here.
|
|
1277
|
-
for (const m of Object.values(cfg.meshes)) if ('contour' in m) for (const rg of m.contour
|
|
1286
|
+
for (const m of Object.values(cfg.meshes)) if ('contour' in m) for (const rg of contourRegionsOf(m.contour)) meshBones.add(rg.bone);
|
|
1278
1287
|
for (const m of Object.values(cfg.meshes)) if ('auto' in m) for (const rg of m.auto.regions ?? []) if (weightsABone(rg)) meshBones.add(rg.bone);
|
|
1279
1288
|
const meshKeyed = controlledBones(motion, meshBones);
|
|
1280
1289
|
const controls = idleKeys === 'ctl' ? meshKeyed : [];
|
|
@@ -1600,7 +1609,7 @@ export function buildRig(
|
|
|
1600
1609
|
const h = img.height;
|
|
1601
1610
|
const alpha = new Uint8Array(w * h);
|
|
1602
1611
|
for (let i = 0; i < alpha.length; i++) alpha[i] = img.data[i * 4 + 3];
|
|
1603
|
-
const regions = spec
|
|
1612
|
+
const regions = contourRegionsOf(spec);
|
|
1604
1613
|
const inPart: ContourRegion[] = regions.map((rg) =>
|
|
1605
1614
|
rg.shape === 'circle'
|
|
1606
1615
|
? { name: rg.name, shape: 'circle', cx: rg.cx - ox, cy: rg.cy - oy, r: rg.r, spacing: rg.spacing, band: rg.band }
|
|
@@ -1635,10 +1644,25 @@ export function buildRig(
|
|
|
1635
1644
|
const li = localInfluences([wx, wy], vSegs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
|
|
1636
1645
|
if ('first' in li) {
|
|
1637
1646
|
if (!overlapped) {
|
|
1647
|
+
let bustHint = '';
|
|
1648
|
+
if (spec.bust !== undefined) {
|
|
1649
|
+
const need: number[] = [];
|
|
1650
|
+
for (const rg of [regions[li.first], regions[li.second]]) {
|
|
1651
|
+
if (rg.name === 'bust_r' || rg.name === 'bust_l') {
|
|
1652
|
+
const apex = rg.name === 'bust_r' ? spec.bust.apex_r : spec.bust.apex_l;
|
|
1653
|
+
const d = edgeLength(spec.bust.cleavage[0] - apex[0], spec.bust.cleavage[1] - apex[1]);
|
|
1654
|
+
const dist = edgeLength(wx - apex[0], wy - apex[1]);
|
|
1655
|
+
need.push(pyRound(d - dist, 2));
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (need.length > 0) {
|
|
1659
|
+
bustHint = ` (contour.bust.clearance >= ${Math.min(...need)} clears vertex ${vi})`;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1638
1662
|
out.push({
|
|
1639
1663
|
code: 'RIG_CONTOUR_REGIONS_OVERLAP',
|
|
1640
1664
|
object: `config.meshes.${p.name}.contour.regions`,
|
|
1641
|
-
detail: `vertex ${vi} at rig (${wx}, ${wy}) takes weight ${pyRound(li.g1, 5)} from region "${regions[li.first].name}" and ${pyRound(li.g2, 5)} from region "${regions[li.second].name}"; each vertex may be reached by one region's falloff (its region and band) — move the regions apart or narrow a band`,
|
|
1665
|
+
detail: `vertex ${vi} at rig (${wx}, ${wy}) takes weight ${pyRound(li.g1, 5)} from region "${regions[li.first].name}" and ${pyRound(li.g2, 5)} from region "${regions[li.second].name}"; each vertex may be reached by one region's falloff (its region and band) — move the regions apart or narrow a band${bustHint}`,
|
|
1642
1666
|
});
|
|
1643
1667
|
}
|
|
1644
1668
|
overlapped = true;
|
|
@@ -2296,7 +2320,10 @@ export function buildRig(
|
|
|
2296
2320
|
if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
|
|
2297
2321
|
const faceImg = outImages.find(([k]) => k === 'face.png')?.[1];
|
|
2298
2322
|
const facePart = parts.parts.find((p) => p.name === 'face');
|
|
2323
|
+
const partAreas: Record<string, number> = {};
|
|
2324
|
+
for (const p of parts.parts) partAreas[p.name] = p.opaque_px;
|
|
2299
2325
|
bindFaceSurfaceDeform(motion, (merged) => {
|
|
2326
|
+
expandSetAttachmentKeys(rig, sets, merged);
|
|
2300
2327
|
applyFaceSurfaceDeform(rig, merged, {
|
|
2301
2328
|
imageWidth: faceImg?.width,
|
|
2302
2329
|
imageHeight: faceImg?.height,
|
|
@@ -2304,6 +2331,9 @@ export function buildRig(
|
|
|
2304
2331
|
opaqueHeight: facePart?.h,
|
|
2305
2332
|
overrides: cfg.turn?.deform,
|
|
2306
2333
|
hasAlternateFaceSet,
|
|
2334
|
+
turn: cfg.turn,
|
|
2335
|
+
partAreas,
|
|
2336
|
+
requireTurn: true,
|
|
2307
2337
|
});
|
|
2308
2338
|
if (sets !== undefined && (merged.animations.turn !== undefined || merged.animations.tilt !== undefined)) {
|
|
2309
2339
|
for (const entry of Object.values(sets.sets)) {
|
|
@@ -2329,6 +2359,132 @@ export function buildRig(
|
|
|
2329
2359
|
};
|
|
2330
2360
|
}
|
|
2331
2361
|
|
|
2362
|
+
/**
|
|
2363
|
+
* Issue #473: expand `{"t": …, "set": "<name>" | null}` keys on slot attachment
|
|
2364
|
+
* tracks in `merged.animations` into `{"t": …, "v": "<attachment>" | null}`
|
|
2365
|
+
* keys against `rig.slots` and `sets.json`. When no key carries `"set"`, nothing
|
|
2366
|
+
* is touched and no byte moves.
|
|
2367
|
+
*/
|
|
2368
|
+
function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefined, merged: MotionSpec): void {
|
|
2369
|
+
let hasAnySetKey = false;
|
|
2370
|
+
for (const [animName, anim] of Object.entries(merged.animations)) {
|
|
2371
|
+
if (animName === 'idle' || typeof anim !== 'object' || anim === null || Array.isArray(anim)) continue;
|
|
2372
|
+
const tracks = (anim as Record<string, unknown>).tracks;
|
|
2373
|
+
if (!Array.isArray(tracks)) continue;
|
|
2374
|
+
for (const tr of tracks) {
|
|
2375
|
+
if (typeof tr !== 'object' || tr === null || Array.isArray(tr)) continue;
|
|
2376
|
+
const keys = (tr as Record<string, unknown>).keys;
|
|
2377
|
+
if (!Array.isArray(keys)) continue;
|
|
2378
|
+
for (const k of keys) {
|
|
2379
|
+
if (typeof k === 'object' && k !== null && !Array.isArray(k) && 'set' in k) {
|
|
2380
|
+
hasAnySetKey = true;
|
|
2381
|
+
break;
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
if (hasAnySetKey) break;
|
|
2385
|
+
}
|
|
2386
|
+
if (hasAnySetKey) break;
|
|
2387
|
+
}
|
|
2388
|
+
if (!hasAnySetKey) return;
|
|
2389
|
+
|
|
2390
|
+
const baseSlots = new Map<string, string | null>();
|
|
2391
|
+
for (const sl of rig.slots) {
|
|
2392
|
+
baseSlots.set(sl.name, sl.attachment ?? null);
|
|
2393
|
+
}
|
|
2394
|
+
const declaredSets =
|
|
2395
|
+
sets === undefined || Object.keys(sets.sets).length === 0
|
|
2396
|
+
? 'none'
|
|
2397
|
+
: Object.keys(sets.sets)
|
|
2398
|
+
.map((n) => `"${n}"`)
|
|
2399
|
+
.join(', ');
|
|
2400
|
+
const problems: Problem[] = [];
|
|
2401
|
+
|
|
2402
|
+
for (const [animName, anim] of Object.entries(merged.animations)) {
|
|
2403
|
+
if (animName === 'idle' || typeof anim !== 'object' || anim === null || Array.isArray(anim)) continue;
|
|
2404
|
+
const tracks = (anim as Record<string, unknown>).tracks;
|
|
2405
|
+
if (!Array.isArray(tracks)) continue;
|
|
2406
|
+
tracks.forEach((tr, ti) => {
|
|
2407
|
+
if (typeof tr !== 'object' || tr === null || Array.isArray(tr)) return;
|
|
2408
|
+
const trRec = tr as Record<string, unknown>;
|
|
2409
|
+
const keys = trRec.keys;
|
|
2410
|
+
if (!Array.isArray(keys)) return;
|
|
2411
|
+
const slot = typeof trRec.slot === 'string' && trRec.slot !== '' ? trRec.slot : null;
|
|
2412
|
+
const isAttachmentTrack = trRec.property === 'attachment' && slot !== null;
|
|
2413
|
+
keys.forEach((k, ki) => {
|
|
2414
|
+
if (typeof k !== 'object' || k === null || Array.isArray(k) || !('set' in k)) return;
|
|
2415
|
+
const kRec = k as Record<string, unknown>;
|
|
2416
|
+
const keyAt = `animations.${animName}.tracks[${ti}].keys[${ki}].set`;
|
|
2417
|
+
if (!isAttachmentTrack || slot === null) {
|
|
2418
|
+
problems.push({
|
|
2419
|
+
code: 'CONFIG_SET_COVERS_SLOT',
|
|
2420
|
+
object: keyAt,
|
|
2421
|
+
detail: `is ${JSON.stringify(kRec.set)} on a ${JSON.stringify(trRec.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}]})`,
|
|
2422
|
+
});
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
if ('v' in kRec) {
|
|
2426
|
+
problems.push({
|
|
2427
|
+
code: 'CONFIG_FIELD_TYPE',
|
|
2428
|
+
object: keyAt,
|
|
2429
|
+
detail: `is declared beside v (${JSON.stringify(kRec.v)}); state either "set": "<name>" | null or "v": "<attachment>" | null on an attachment key, not both`,
|
|
2430
|
+
});
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
const setVal = kRec.set;
|
|
2434
|
+
let resolvedV: string | null = null;
|
|
2435
|
+
if (setVal === null) {
|
|
2436
|
+
if (!baseSlots.has(slot)) {
|
|
2437
|
+
problems.push({
|
|
2438
|
+
code: 'CONFIG_SET_COVERS_SLOT',
|
|
2439
|
+
object: keyAt,
|
|
2440
|
+
detail: `restores the base attachment on slot "${slot}", which is not a declared slot of the rig (declared slots: ${[...baseSlots.keys()].map((s) => `"${s}"`).join(', ')})`,
|
|
2441
|
+
});
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
resolvedV = baseSlots.get(slot) ?? null;
|
|
2445
|
+
} else if (typeof setVal !== 'string' || setVal === '') {
|
|
2446
|
+
problems.push({
|
|
2447
|
+
code: 'CONFIG_FIELD_TYPE',
|
|
2448
|
+
object: keyAt,
|
|
2449
|
+
detail: `is ${JSON.stringify(setVal)}; a non-empty alternate set name or null (the base pose) is required`,
|
|
2450
|
+
});
|
|
2451
|
+
return;
|
|
2452
|
+
} else {
|
|
2453
|
+
const entry = sets?.sets[setVal];
|
|
2454
|
+
if (entry === undefined) {
|
|
2455
|
+
problems.push({
|
|
2456
|
+
code: 'CONFIG_SET_COVERS_SLOT',
|
|
2457
|
+
object: keyAt,
|
|
2458
|
+
detail: `names alternate set "${setVal}" for slot "${slot}", which config.assemble.alternates does not declare (declared sets: ${declaredSets})`,
|
|
2459
|
+
});
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
if (Object.hasOwn(entry.slots, slot)) {
|
|
2463
|
+
resolvedV = entry.slots[slot];
|
|
2464
|
+
} else if (entry.hides?.includes(slot) === true) {
|
|
2465
|
+
resolvedV = null;
|
|
2466
|
+
} else {
|
|
2467
|
+
const covered = [...Object.keys(entry.slots), ...(entry.hides ?? [])].map((s) => `"${s}"`).join(', ') || 'none';
|
|
2468
|
+
problems.push({
|
|
2469
|
+
code: 'CONFIG_SET_COVERS_SLOT',
|
|
2470
|
+
object: keyAt,
|
|
2471
|
+
detail: `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`,
|
|
2472
|
+
});
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
const expanded: Record<string, unknown> = {};
|
|
2477
|
+
for (const [prop, val] of Object.entries(kRec)) {
|
|
2478
|
+
if (prop === 'set') expanded.v = resolvedV;
|
|
2479
|
+
else expanded[prop] = val;
|
|
2480
|
+
}
|
|
2481
|
+
keys[ki] = expanded;
|
|
2482
|
+
});
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
refuseIfAny(problems);
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2332
2488
|
/**
|
|
2333
2489
|
* Re-parent every bone that hangs from a stopped link onto that link's drawn
|
|
2334
2490
|
* link `<link>_d`, move slots and mesh weights from a stopped link onto
|
package/src/structure.ts
CHANGED
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
import { existsSync, readFileSync } from 'node:fs';
|
|
73
73
|
import { CompileError } from 'rig-c/src/errors.ts';
|
|
74
74
|
import { parseRigSpec, RIG_SPEC_VERSION, type RigBone as RigcBone, type RigSpec as RigcSpec, splitRigSkin } from 'rig-c/src/rig.ts';
|
|
75
|
-
import { type ConfigConstraint, CONSTRAINT_FOLLOWS, parseConfig, parseProposalSections, type Point, ROOT_BONE, type SkeletonSections, weightsABone } from './config.ts';
|
|
75
|
+
import { type ConfigConstraint, CONSTRAINT_FOLLOWS, contourRegionsOf, parseConfig, parseProposalSections, type Point, ROOT_BONE, type SkeletonSections, weightsABone } from './config.ts';
|
|
76
76
|
import { computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toWorld } from './coords.ts';
|
|
77
77
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
78
78
|
import { edgeLength } from './length.ts';
|
|
@@ -183,8 +183,11 @@ export function configRoles(s: SkeletonSections & { constraints?: readonly Confi
|
|
|
183
183
|
if (typeof sp === 'string') for (const b of chains.get(sp) ?? [sp]) push(bound, b, at);
|
|
184
184
|
else push(bound, sp[0], at);
|
|
185
185
|
});
|
|
186
|
-
// A contour mesh's region bone is weighted to, like a segment's (issue #84).
|
|
187
|
-
if ('contour' in m)
|
|
186
|
+
// A contour mesh's region bone is weighted to, like a segment's (issue #84; including contour.bust, issue #475).
|
|
187
|
+
if ('contour' in m) {
|
|
188
|
+
const explicitCount = (m.contour.regions ?? []).length;
|
|
189
|
+
contourRegionsOf(m.contour).forEach((rg, i) => push(bound, rg.bone, i < explicitCount ? `meshes.${part}.contour.regions[${i}]` : `meshes.${part}.contour.bust`));
|
|
190
|
+
}
|
|
188
191
|
if ('auto' in m) (m.auto.regions ?? []).forEach((rg, i) => {
|
|
189
192
|
// A density-only region binds no bone (issue #155).
|
|
190
193
|
if (weightsABone(rg)) push(bound, rg.bone, `meshes.${part}.auto.regions[${i}]`);
|