@rig-c/parts 1.35.0 → 1.37.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/cli.ts +5 -1
- package/docs/AUTHORING.md +22 -3
- package/docs/STABILITY.md +1 -1
- package/package.json +1 -1
- package/src/assemble.ts +591 -12
- package/src/build.ts +66 -19
- package/src/config.ts +195 -21
- package/src/limbs.ts +92 -0
- package/src/propose.ts +128 -15
- package/src/rig.ts +88 -3
- package/src/tags.ts +15 -6
package/src/propose.ts
CHANGED
|
@@ -1364,8 +1364,15 @@ const SIDES = [
|
|
|
1364
1364
|
* which rule stood in; without it every value and note is the one this
|
|
1365
1365
|
* function wrote before the parameter existed.
|
|
1366
1366
|
*/
|
|
1367
|
-
export function propose(
|
|
1368
|
-
|
|
1367
|
+
export function propose(
|
|
1368
|
+
P: PartSet,
|
|
1369
|
+
joints?: RigJoints,
|
|
1370
|
+
wristSnap: number | null = null,
|
|
1371
|
+
gridRule: GridRule = DEFAULT_GRID_RULE,
|
|
1372
|
+
cloth: ClothRecipes | null = null,
|
|
1373
|
+
chainRule = true,
|
|
1374
|
+
): Proposal {
|
|
1375
|
+
return proposeWithBasis(P, joints, wristSnap, gridRule, cloth, chainRule).proposal;
|
|
1369
1376
|
}
|
|
1370
1377
|
|
|
1371
1378
|
/**
|
|
@@ -1384,6 +1391,7 @@ export function proposeWithBasis(
|
|
|
1384
1391
|
wristSnap: number | null = null,
|
|
1385
1392
|
gridRule: GridRule = DEFAULT_GRID_RULE,
|
|
1386
1393
|
cloth: ClothRecipes | null = null,
|
|
1394
|
+
chainRule = true,
|
|
1387
1395
|
): { proposal: Proposal; basis: ProposalBasis } {
|
|
1388
1396
|
// Issue #230: a piece is not given its base's rule a second time, which
|
|
1389
1397
|
// needs to know which part is the piece. A file that cannot say is refused
|
|
@@ -2537,7 +2545,7 @@ export function proposeWithBasis(
|
|
|
2537
2545
|
// Issue #75: arms from joints, when every handwear part carries the wrist of
|
|
2538
2546
|
// an arm whose shoulder, elbow and wrist are all given; otherwise the rule
|
|
2539
2547
|
// below places every sleeve chain, and the notes say why.
|
|
2540
|
-
const armPlan = joints !== undefined && clasped === null && hw.length > 0 ? armsFromJoints(P, hw, joints, wristSnap) : null;
|
|
2548
|
+
const armPlan = joints !== undefined && clasped === null && hw.length > 0 ? armsFromJoints(P, hw, joints, wristSnap, chainRule) : null;
|
|
2541
2549
|
let sleeveRule = '';
|
|
2542
2550
|
if (clasped !== null) {
|
|
2543
2551
|
sleeveRule = 'the clasped-hands rule (the handwear is a region on hip, with no sleeve chain)';
|
|
@@ -2547,7 +2555,9 @@ export function proposeWithBasis(
|
|
|
2547
2555
|
for (const p of hw) regions[p.name] = 'hip';
|
|
2548
2556
|
notes.push(clasped);
|
|
2549
2557
|
} else if (armPlan !== null && armPlan.arms !== null) {
|
|
2550
|
-
// Each chain runs shoulder -> elbow with its tip at the wrist
|
|
2558
|
+
// Each chain runs shoulder -> elbow with its tip at the wrist (or, when
|
|
2559
|
+
// the wrist fell outside the arm layer onto a skirt or top layer, at the
|
|
2560
|
+
// arm layer's bottom extent, issue #491): two links,
|
|
2551
2561
|
// the upper arm and the forearm, each with the amplitude the two-blob rule
|
|
2552
2562
|
// gives its link of the same index. Its part's mesh takes the chain alone,
|
|
2553
2563
|
// as the two-blob rule's does since issue #347 (no chest stub: a stub on
|
|
@@ -2555,7 +2565,14 @@ export function proposeWithBasis(
|
|
|
2555
2565
|
for (const a of armPlan.arms) {
|
|
2556
2566
|
const sgn = a.side === 'r' ? 1 : -1;
|
|
2557
2567
|
const used = (n: KeypointName): Placement => placement('joint', `the ${n} joint, ${usedAs((joints as RigJoints).joints[n])}`, { joints: [jointRef(n)] });
|
|
2558
|
-
|
|
2568
|
+
const tipWhy =
|
|
2569
|
+
a.offLayer === null
|
|
2570
|
+
? used(`${a.side}_wrist`)
|
|
2571
|
+
: placement('mask', `3 px above ${a.part.name}'s bottom row, at its centroid x in the band 6 px above that row`, {
|
|
2572
|
+
parts: [a.part.name],
|
|
2573
|
+
fallback: a.tipFallback,
|
|
2574
|
+
});
|
|
2575
|
+
C(`sleeve_${a.side}`, 'chest', [a.shoulder, a.elbow], a.tip, [used(`${a.side}_shoulder`), used(`${a.side}_elbow`)], tipWhy);
|
|
2559
2576
|
tracks.push({ chain: `sleeve_${a.side}`, amps: [sgn * 0.2, sgn * 0.6], period: 4.0, phase: 0.22 + (a.side === 'l' ? 0.05 : 0), lag: 0.08 });
|
|
2560
2577
|
}
|
|
2561
2578
|
// Issue #349: each arm is a limb — the upper arm on link 0 and the forearm (with the hand) on link 1, each rigid on
|
|
@@ -2662,6 +2679,15 @@ export function proposeWithBasis(
|
|
|
2662
2679
|
}
|
|
2663
2680
|
if (hw.length === 1) notes.push(`handwear is one blob (${hw[0].name}): both sleeves in one mesh, split at the eye axis x=${pyFixed(axis, 0)}`);
|
|
2664
2681
|
}
|
|
2682
|
+
for (const [s] of SIDES) {
|
|
2683
|
+
const sc = bones.find((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && b.chain === `sleeve_${s}`);
|
|
2684
|
+
if (sc === undefined) continue;
|
|
2685
|
+
const bone = `sleeve_${s}${sc.points.length - 1}`;
|
|
2686
|
+
for (const hp of P.byTag(`hand-${s}`)) {
|
|
2687
|
+
regions[hp.name] = bone;
|
|
2688
|
+
notes.push(`${hp.name} (${hp.from}): split hand -> region on ${bone}, the tip link of sleeve_${s}`);
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2665
2691
|
|
|
2666
2692
|
// Issue #402: the topwear part config.cloth.bottomwear.part names takes the skirt's rule and recipe below; every
|
|
2667
2693
|
// other topwear shaped like a skirt is named in a note when a recipe is in play, never drafted on by its shape.
|
|
@@ -3460,8 +3486,14 @@ interface JointArm {
|
|
|
3460
3486
|
shoulder: [number, number];
|
|
3461
3487
|
elbow: [number, number];
|
|
3462
3488
|
wrist: [number, number];
|
|
3489
|
+
tip: [number, number];
|
|
3490
|
+
tipFallback: string | undefined;
|
|
3463
3491
|
part: PartRecord;
|
|
3464
3492
|
snap: number | null;
|
|
3493
|
+
offLayer: {
|
|
3494
|
+
distance: number;
|
|
3495
|
+
landedOn: readonly PartRecord[];
|
|
3496
|
+
} | null;
|
|
3465
3497
|
}
|
|
3466
3498
|
|
|
3467
3499
|
/**
|
|
@@ -3507,11 +3539,27 @@ function artDistance(m: Mask, at: readonly [number, number]): number {
|
|
|
3507
3539
|
* `wristSnap` (an integer above {@link WRIST_ON_PART_PX}, the CLI's to hold)
|
|
3508
3540
|
* it is bound to the one nearest part when that part is at most `wristSnap`
|
|
3509
3541
|
* px away; two parts tied nearest bind neither, as a wrist on two parts
|
|
3510
|
-
* does not.
|
|
3511
|
-
*
|
|
3542
|
+
* does not.
|
|
3543
|
+
*
|
|
3544
|
+
* Issue #491: when a hanging arm's wrist keypoint falls outside its handwear
|
|
3545
|
+
* layer's mask (`elbow[1] > shoulder[1]`, `wrist[1] > elbow[1]`, the elbow lies
|
|
3546
|
+
* within {@link WRIST_ON_PART_PX} of that side's handwear part, and the part's
|
|
3547
|
+
* bottom extent `bot - 3` reaches at least {@link WRIST_ON_PART_PX} below the
|
|
3548
|
+
* elbow — e.g. when a skin-coloured wrist read lands on a light skirt or top
|
|
3549
|
+
* layer below a hanging sleeve), the arm binds its handwear part with its
|
|
3550
|
+
* chain tip placed at the layer's bottom extent `[bandX(mask, bot - 6), bot - 3]`
|
|
3551
|
+
* so `limbs.arm_<s>` cuts at the elbow from the limb chain (`PROPOSE_WRIST_OFF_LAYER`).
|
|
3552
|
+
* Otherwise the why names the nearest part and its distance, the tolerance
|
|
3553
|
+
* that would have bound it.
|
|
3512
3554
|
*/
|
|
3513
|
-
function armsFromJoints(
|
|
3514
|
-
|
|
3555
|
+
function armsFromJoints(
|
|
3556
|
+
P: PartSet,
|
|
3557
|
+
hw: readonly PartRecord[],
|
|
3558
|
+
joints: RigJoints,
|
|
3559
|
+
wristSnap: number | null,
|
|
3560
|
+
chainRule = true,
|
|
3561
|
+
): { arms: JointArm[] | null; why: string | null } {
|
|
3562
|
+
const complete: Array<Omit<JointArm, 'tip' | 'tipFallback' | 'part' | 'snap' | 'offLayer'>> = [];
|
|
3515
3563
|
const gaps: string[] = [];
|
|
3516
3564
|
for (const s of ['r', 'l'] as const) {
|
|
3517
3565
|
const sh = joints.joints[`${s}_shoulder`].at;
|
|
@@ -3524,7 +3572,18 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
|
|
|
3524
3572
|
else complete.push({ side: s, shoulder: sh, elbow: el, wrist: wr });
|
|
3525
3573
|
}
|
|
3526
3574
|
if (complete.length === 0) return { arms: null, why: `no arm has its shoulder, elbow and wrist all given (${gaps.join('; ')})` };
|
|
3527
|
-
const
|
|
3575
|
+
const armAlpha = new Map(
|
|
3576
|
+
hw.map((p) => {
|
|
3577
|
+
const tag = splitFrom(p.from)[1];
|
|
3578
|
+
const side = tag === 'handwear-r' ? 'r' : tag === 'handwear-l' ? 'l' : null;
|
|
3579
|
+
let m = P.alpha(p);
|
|
3580
|
+
if (side !== null) {
|
|
3581
|
+
for (const h of P.byTag(`hand-${side}`)) m = union(m, P.alpha(h));
|
|
3582
|
+
}
|
|
3583
|
+
return [p, m] as const;
|
|
3584
|
+
}),
|
|
3585
|
+
);
|
|
3586
|
+
const near = new Map(hw.map((p) => [p, dilate(armAlpha.get(p) as Mask, 2 * WRIST_ON_PART_PX + 1)]));
|
|
3528
3587
|
const arms: JointArm[] = [];
|
|
3529
3588
|
for (const a of complete) {
|
|
3530
3589
|
const raw = hw.filter((p) => isOn(near.get(p) as Mask, a.wrist[0], a.wrist[1]));
|
|
@@ -3536,21 +3595,46 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
|
|
|
3536
3595
|
const t = splitFrom(p.from)[1];
|
|
3537
3596
|
return t === ownTag || t === oppTag;
|
|
3538
3597
|
})
|
|
3539
|
-
? raw.filter((p) => splitFrom(p.from)[1] === ownTag && isOn(
|
|
3598
|
+
? raw.filter((p) => splitFrom(p.from)[1] === ownTag && isOn(armAlpha.get(p) as Mask, a.wrist[0], a.wrist[1]))
|
|
3540
3599
|
: [];
|
|
3541
3600
|
const on = own.length === 1 ? own : raw;
|
|
3542
3601
|
if (on.length > 1) return { arms: null, why: `${a.side}_wrist at ${fmtAt(a.wrist)} lies on the art of ${on.length} handwear parts (${on.map((p) => p.name).join(', ')}), so it does not say which sleeve it ends` };
|
|
3543
3602
|
if (on.length === 1) {
|
|
3544
|
-
arms.push({ ...a, part: on[0], snap: null });
|
|
3603
|
+
arms.push({ ...a, tip: a.wrist, tipFallback: undefined, part: on[0], snap: null, offLayer: null });
|
|
3545
3604
|
continue;
|
|
3546
3605
|
}
|
|
3547
|
-
const far = hw.map((p) => ({ p, d: artDistance(
|
|
3606
|
+
const far = hw.map((p) => ({ p, d: artDistance(armAlpha.get(p) as Mask, a.wrist) }));
|
|
3548
3607
|
const d = Math.min(...far.map((q) => q.d));
|
|
3549
3608
|
const nearest = far.filter((q) => q.d === d).map((q) => q.p);
|
|
3550
3609
|
if (wristSnap !== null && d <= wristSnap && nearest.length === 1) {
|
|
3551
|
-
arms.push({ ...a, part: nearest[0], snap: d });
|
|
3610
|
+
arms.push({ ...a, tip: a.wrist, tipFallback: undefined, part: nearest[0], snap: d, offLayer: null });
|
|
3552
3611
|
continue;
|
|
3553
3612
|
}
|
|
3613
|
+
if (chainRule) {
|
|
3614
|
+
const byTag = hw.filter((p) => splitFrom(p.from)[1] === ownTag);
|
|
3615
|
+
const byElbow = hw.filter((p) => isOn(near.get(p) as Mask, a.elbow[0], a.elbow[1]));
|
|
3616
|
+
const sidePart =
|
|
3617
|
+
byTag.length === 1 && isOn(near.get(byTag[0]) as Mask, a.elbow[0], a.elbow[1])
|
|
3618
|
+
? byTag[0]
|
|
3619
|
+
: byElbow.length === 1
|
|
3620
|
+
? byElbow[0]
|
|
3621
|
+
: null;
|
|
3622
|
+
if (sidePart !== null) {
|
|
3623
|
+
const m = armAlpha.get(sidePart) as Mask;
|
|
3624
|
+
const ys = rowsAny(m);
|
|
3625
|
+
const bot = ys[ys.length - 1];
|
|
3626
|
+
if (bot !== undefined && a.elbow[1] > a.shoulder[1] && a.wrist[1] > a.elbow[1] && bot - 3 - a.elbow[1] >= WRIST_ON_PART_PX) {
|
|
3627
|
+
const bx = bandX(m, bot - 6);
|
|
3628
|
+
const tipX = or(bx, a.elbow[0]);
|
|
3629
|
+
const tipFallback = bx === null || bx === 0 ? `no art in the band: the ${a.side}_elbow joint's x` : undefined;
|
|
3630
|
+
const tip: [number, number] = [pyRound(tipX), bot - 3];
|
|
3631
|
+
const landedOn = P.layered().filter((p) => p !== sidePart && isOn(P.alpha(p), a.wrist[0], a.wrist[1]));
|
|
3632
|
+
const partDist = artDistance(m, a.wrist);
|
|
3633
|
+
arms.push({ ...a, tip, tipFallback, part: sidePart, snap: null, offLayer: { distance: partDist, landedOn } });
|
|
3634
|
+
continue;
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3554
3638
|
const gap = 'by the larger of the x and y gaps';
|
|
3555
3639
|
const reach = wristSnap === null ? `--wrist-snap ${Math.max(d, WRIST_ON_PART_PX + 1)} would bind it` : `beyond --wrist-snap ${wristSnap}`;
|
|
3556
3640
|
const nearestIs =
|
|
@@ -3727,7 +3811,25 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
|
|
|
3727
3811
|
[`${s}_wrist`, 'the tip'],
|
|
3728
3812
|
] as const) {
|
|
3729
3813
|
const j = J[joint];
|
|
3730
|
-
|
|
3814
|
+
if (arm !== null && arm.offLayer !== null && joint === `${s}_wrist`) {
|
|
3815
|
+
const landed =
|
|
3816
|
+
arm.offLayer.landedOn.length > 0
|
|
3817
|
+
? `, on ${arm.offLayer.landedOn.map((p) => `${p.name} (${p.from})`).join(', ')}`
|
|
3818
|
+
: '';
|
|
3819
|
+
tell(
|
|
3820
|
+
joint,
|
|
3821
|
+
`${said(joint, j)}: not used — ${s}_wrist at ${fmtAt(arm.wrist)} lies outside ${arm.part.name} (${arm.part.from})'s mask (${arm.offLayer.distance} px from its art by the larger of the x and y gaps${landed}); chain sleeve_${s}'s tip is placed at ${arm.part.name}'s bottom extent [${arm.tip[0]}, ${arm.tip[1]}] instead`,
|
|
3822
|
+
);
|
|
3823
|
+
continue;
|
|
3824
|
+
}
|
|
3825
|
+
const on =
|
|
3826
|
+
arm === null
|
|
3827
|
+
? ''
|
|
3828
|
+
: arm.offLayer !== null
|
|
3829
|
+
? `the elbow lies on ${arm.part.name}; ${s}_wrist lies outside ${arm.part.name}'s mask, so sleeve_${s}'s tip is ${arm.part.name}'s bottom extent [${arm.tip[0]}, ${arm.tip[1]}]`
|
|
3830
|
+
: arm.snap === null
|
|
3831
|
+
? `the wrist lies on ${arm.part.name}`
|
|
3832
|
+
: `the wrist is bound to ${arm.part.name} by --wrist-snap`;
|
|
3731
3833
|
if (arm !== null) tell(joint, `${said(joint, j)}: ${usedAs(j)} for chain sleeve_${s}, ${role} (${on})`);
|
|
3732
3834
|
else if (j.at !== null && f.armWhy !== null) tell(joint, `${said(joint, j)}: not used — ${f.armWhy}; ${standIn}`);
|
|
3733
3835
|
else if (j.at !== null) tell(joint, `${said(joint, j)}: not used — ${standIn}`);
|
|
@@ -3739,6 +3841,17 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
|
|
|
3739
3841
|
`wrist snap: ${s}_wrist at ${fmtAt(arm.wrist)} lies on no handwear part within ${WRIST_ON_PART_PX} px; --wrist-snap ${f.wristSnap} bound it to ${arm.part.name}, the nearest, ${arm.snap} px from its art by the larger of the x and y gaps`,
|
|
3740
3842
|
);
|
|
3741
3843
|
}
|
|
3844
|
+
// Issue #491: when the wrist keypoint lies outside the arm layer's mask, name the wrist, the arm layer, where it
|
|
3845
|
+
// landed, the chain tip at the layer's bottom extent, and the chain-derived elbow cut position.
|
|
3846
|
+
if (arm !== null && arm.offLayer !== null) {
|
|
3847
|
+
const landed =
|
|
3848
|
+
arm.offLayer.landedOn.length > 0
|
|
3849
|
+
? `, on ${arm.offLayer.landedOn.map((p) => `${p.name} (${p.from})`).join(', ')}`
|
|
3850
|
+
: '';
|
|
3851
|
+
out.push(
|
|
3852
|
+
`PROPOSE_WRIST_OFF_LAYER: ${s}_wrist at ${fmtAt(arm.wrist)} lies outside ${arm.part.name} (${arm.part.from})'s mask (${arm.offLayer.distance} px from its art by the larger of the x and y gaps${landed}); chain sleeve_${s} places its tip at ${arm.part.name}'s bottom extent [${arm.tip[0]}, ${arm.tip[1]}] instead of ${s}_wrist, and limb arm_${s} places the elbow cut at [${pyRound(arm.elbow[0])}, ${pyRound(arm.elbow[1])}] (${fmtAt(arm.elbow)}) from the limb chain (${s}_shoulder [${pyRound(arm.shoulder[0])}, ${pyRound(arm.shoulder[1])}] -> ${s}_elbow [${pyRound(arm.elbow[0])}, ${pyRound(arm.elbow[1])}] -> [${arm.tip[0]}, ${arm.tip[1]}])`,
|
|
3853
|
+
);
|
|
3854
|
+
}
|
|
3742
3855
|
}
|
|
3743
3856
|
// Issue #199: two chains on one part are said, not left to share its mesh unremarked.
|
|
3744
3857
|
const both = f.arms !== null && f.arms.length === 2 && f.arms[0].part === f.arms[1].part ? f.arms[0].part.name : null;
|
package/src/rig.ts
CHANGED
|
@@ -1007,8 +1007,12 @@ export function deriveAlternateDrawn(
|
|
|
1007
1007
|
const limbPairs: ReadonlyArray<readonly [string, readonly [number, number], readonly [number, number]]> = [
|
|
1008
1008
|
['sleeve_r0', pts.r_shoulder, pts.r_elbow],
|
|
1009
1009
|
['sleeve_r1', pts.r_elbow, pts.r_wrist],
|
|
1010
|
+
['arm_r0', pts.r_shoulder, pts.r_elbow],
|
|
1011
|
+
['arm_r1', pts.r_elbow, pts.r_wrist],
|
|
1010
1012
|
['sleeve_l0', pts.l_shoulder, pts.l_elbow],
|
|
1011
1013
|
['sleeve_l1', pts.l_elbow, pts.l_wrist],
|
|
1014
|
+
['arm_l0', pts.l_shoulder, pts.l_elbow],
|
|
1015
|
+
['arm_l1', pts.l_elbow, pts.l_wrist],
|
|
1012
1016
|
['leg_r0', pts.r_hip, pts.r_knee],
|
|
1013
1017
|
['leg_r1', pts.r_knee, pts.r_ankle],
|
|
1014
1018
|
['leg_l0', pts.l_hip, pts.l_knee],
|
|
@@ -2109,17 +2113,44 @@ export function buildRig(
|
|
|
2109
2113
|
for (const [altIdx, alt] of parts.alternates.entries()) {
|
|
2110
2114
|
const cfgAlt = cfg.alternates?.find((a) => a.name === alt.name);
|
|
2111
2115
|
const explicitDrawn = alt.drawn ?? cfgAlt?.drawn;
|
|
2116
|
+
const isSubsetSet = alt.parts !== undefined || cfgAlt?.parts !== undefined;
|
|
2117
|
+
let coveredBones: Set<string> | null = null;
|
|
2118
|
+
if (isSubsetSet) {
|
|
2119
|
+
coveredBones = new Set<string>();
|
|
2120
|
+
for (const pName of Object.keys(alt.slots)) {
|
|
2121
|
+
if (pName in cfg.regions) coveredBones.add(cfg.regions[pName]);
|
|
2122
|
+
for (const s of meshSegments.get(pName) ?? []) coveredBones.add(s.bone);
|
|
2123
|
+
const m = cfg.meshes[pName];
|
|
2124
|
+
if (m !== undefined) {
|
|
2125
|
+
if ('contour' in m) for (const rg of contourRegionsOf(m.contour)) coveredBones.add(rg.bone);
|
|
2126
|
+
if ('auto' in m) for (const rg of m.auto.regions ?? []) if (weightsABone(rg)) coveredBones.add(rg.bone);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2112
2130
|
if (explicitDrawn !== undefined) {
|
|
2113
2131
|
for (const boneName of Object.keys(explicitDrawn)) {
|
|
2114
2132
|
if (!B.has(boneName)) {
|
|
2115
2133
|
fail('RIG_NAME_RESOLVES', `config.alternates[${altIdx}].drawn.${boneName}`, `names the bone "${boneName}", which config.bones does not declare`);
|
|
2134
|
+
} else if (coveredBones !== null && !coveredBones.has(boneName)) {
|
|
2135
|
+
const coveredList = [...coveredBones].map((b) => `"${b}"`).join(', ') || 'none';
|
|
2136
|
+
fail(
|
|
2137
|
+
'RIG_SET_COVERS_BONE',
|
|
2138
|
+
`config.assemble.alternates[${altIdx}].drawn.${boneName}`,
|
|
2139
|
+
`names "${boneName}", which alternate set "${alt.name}" does not cover (covered bones: ${coveredList}); a subset set may state drawn angles only for the bones its covered parts ride`,
|
|
2140
|
+
);
|
|
2116
2141
|
}
|
|
2117
2142
|
}
|
|
2118
2143
|
}
|
|
2119
2144
|
refuseIfAny(problems);
|
|
2120
|
-
const
|
|
2145
|
+
const rawDerived = alt.skeleton === undefined ? undefined : deriveAlternateDrawn(alt.skeleton, B, turn, CX, H);
|
|
2146
|
+
const derived =
|
|
2147
|
+
rawDerived === undefined
|
|
2148
|
+
? undefined
|
|
2149
|
+
: coveredBones === null
|
|
2150
|
+
? rawDerived
|
|
2151
|
+
: Object.fromEntries(Object.entries(rawDerived).filter(([k]) => coveredBones.has(k)));
|
|
2121
2152
|
const drawn: Record<string, number> | undefined =
|
|
2122
|
-
derived === undefined && explicitDrawn === undefined
|
|
2153
|
+
(derived === undefined || Object.keys(derived).length === 0) && explicitDrawn === undefined
|
|
2123
2154
|
? undefined
|
|
2124
2155
|
: {
|
|
2125
2156
|
...(derived ?? {}),
|
|
@@ -2365,7 +2396,7 @@ export function buildRig(
|
|
|
2365
2396
|
* keys against `rig.slots` and `sets.json`. When no key carries `"set"`, nothing
|
|
2366
2397
|
* is touched and no byte moves.
|
|
2367
2398
|
*/
|
|
2368
|
-
function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefined, merged: MotionSpec): void {
|
|
2399
|
+
export function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefined, merged: MotionSpec): void {
|
|
2369
2400
|
let hasAnySetKey = false;
|
|
2370
2401
|
for (const [animName, anim] of Object.entries(merged.animations)) {
|
|
2371
2402
|
if (animName === 'idle' || typeof anim !== 'object' || anim === null || Array.isArray(anim)) continue;
|
|
@@ -2388,8 +2419,28 @@ function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefi
|
|
|
2388
2419
|
if (!hasAnySetKey) return;
|
|
2389
2420
|
|
|
2390
2421
|
const baseSlots = new Map<string, string | null>();
|
|
2422
|
+
const slotBoneMap = new Map<string, string>();
|
|
2391
2423
|
for (const sl of rig.slots) {
|
|
2392
2424
|
baseSlots.set(sl.name, sl.attachment ?? null);
|
|
2425
|
+
const rawBone = sl.bone.startsWith(`${sl.name}__`) ? sl.bone.slice(`${sl.name}__`.length) : sl.bone;
|
|
2426
|
+
slotBoneMap.set(sl.name, rawBone);
|
|
2427
|
+
}
|
|
2428
|
+
const setupRotByBone = new Map<string, number>();
|
|
2429
|
+
for (const b of rig.bones) setupRotByBone.set(b.name, b.rotation ?? 0);
|
|
2430
|
+
const setBoneDeltas = new Map<string, Map<string, number>>();
|
|
2431
|
+
if (sets !== undefined) {
|
|
2432
|
+
for (const [setName, entry] of Object.entries(sets.sets)) {
|
|
2433
|
+
if (entry.drawn === undefined || Object.keys(entry.drawn).length === 0) continue;
|
|
2434
|
+
const posed = poseRigBonesForDrawn(rig.bones, entry.drawn, rig.stops);
|
|
2435
|
+
const deltas = new Map<string, number>();
|
|
2436
|
+
for (const b of posed) {
|
|
2437
|
+
if (Object.hasOwn(entry.drawn, b.name)) {
|
|
2438
|
+
const d = places(normaliseDegrees((b.rotation ?? 0) - (setupRotByBone.get(b.name) ?? 0)));
|
|
2439
|
+
deltas.set(b.name, d);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
setBoneDeltas.set(setName, deltas);
|
|
2443
|
+
}
|
|
2393
2444
|
}
|
|
2394
2445
|
const declaredSets =
|
|
2395
2446
|
sets === undefined || Object.keys(sets.sets).length === 0
|
|
@@ -2403,6 +2454,14 @@ function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefi
|
|
|
2403
2454
|
if (animName === 'idle' || typeof anim !== 'object' || anim === null || Array.isArray(anim)) continue;
|
|
2404
2455
|
const tracks = (anim as Record<string, unknown>).tracks;
|
|
2405
2456
|
if (!Array.isArray(tracks)) continue;
|
|
2457
|
+
const existingRotateBones = new Set<string>();
|
|
2458
|
+
for (const tr of tracks) {
|
|
2459
|
+
if (typeof tr === 'object' && tr !== null && !Array.isArray(tr)) {
|
|
2460
|
+
const r = tr as Record<string, unknown>;
|
|
2461
|
+
if (r.property === 'rotate' && typeof r.bone === 'string') existingRotateBones.add(r.bone);
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
const synthesizedRotate = new Map<string, Array<Record<string, unknown>>>();
|
|
2406
2465
|
tracks.forEach((tr, ti) => {
|
|
2407
2466
|
if (typeof tr !== 'object' || tr === null || Array.isArray(tr)) return;
|
|
2408
2467
|
const trRec = tr as Record<string, unknown>;
|
|
@@ -2410,6 +2469,9 @@ function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefi
|
|
|
2410
2469
|
if (!Array.isArray(keys)) return;
|
|
2411
2470
|
const slot = typeof trRec.slot === 'string' && trRec.slot !== '' ? trRec.slot : null;
|
|
2412
2471
|
const isAttachmentTrack = trRec.property === 'attachment' && slot !== null;
|
|
2472
|
+
const slotBone = slot === null ? undefined : slotBoneMap.get(slot);
|
|
2473
|
+
let trackTouchesDrawnBone = false;
|
|
2474
|
+
const rawSetKeysForBone: Array<{ t: unknown; setVal: string | null }> = [];
|
|
2413
2475
|
keys.forEach((k, ki) => {
|
|
2414
2476
|
if (typeof k !== 'object' || k === null || Array.isArray(k) || !('set' in k)) return;
|
|
2415
2477
|
const kRec = k as Record<string, unknown>;
|
|
@@ -2442,6 +2504,7 @@ function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefi
|
|
|
2442
2504
|
return;
|
|
2443
2505
|
}
|
|
2444
2506
|
resolvedV = baseSlots.get(slot) ?? null;
|
|
2507
|
+
rawSetKeysForBone.push({ t: kRec.t, setVal: null });
|
|
2445
2508
|
} else if (typeof setVal !== 'string' || setVal === '') {
|
|
2446
2509
|
problems.push({
|
|
2447
2510
|
code: 'CONFIG_FIELD_TYPE',
|
|
@@ -2472,6 +2535,10 @@ function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefi
|
|
|
2472
2535
|
});
|
|
2473
2536
|
return;
|
|
2474
2537
|
}
|
|
2538
|
+
if (slotBone !== undefined && setBoneDeltas.get(setVal)?.has(slotBone) === true) {
|
|
2539
|
+
trackTouchesDrawnBone = true;
|
|
2540
|
+
}
|
|
2541
|
+
rawSetKeysForBone.push({ t: kRec.t, setVal });
|
|
2475
2542
|
}
|
|
2476
2543
|
const expanded: Record<string, unknown> = {};
|
|
2477
2544
|
for (const [prop, val] of Object.entries(kRec)) {
|
|
@@ -2480,7 +2547,25 @@ function expandSetAttachmentKeys(rig: RigSpec, sets: AttachmentSetsFile | undefi
|
|
|
2480
2547
|
}
|
|
2481
2548
|
keys[ki] = expanded;
|
|
2482
2549
|
});
|
|
2550
|
+
if (
|
|
2551
|
+
problems.length === 0 &&
|
|
2552
|
+
trackTouchesDrawnBone &&
|
|
2553
|
+
slotBone !== undefined &&
|
|
2554
|
+
!existingRotateBones.has(slotBone) &&
|
|
2555
|
+
!synthesizedRotate.has(slotBone)
|
|
2556
|
+
) {
|
|
2557
|
+
const rotKeys = rawSetKeysForBone.map((rk, idx) => {
|
|
2558
|
+
const d = rk.setVal === null ? 0 : (setBoneDeltas.get(rk.setVal)?.get(slotBone) ?? 0);
|
|
2559
|
+
return idx + 1 < rawSetKeysForBone.length ? { t: rk.t, v: [d], curve: 'stepped' } : { t: rk.t, v: [d] };
|
|
2560
|
+
});
|
|
2561
|
+
synthesizedRotate.set(slotBone, rotKeys);
|
|
2562
|
+
}
|
|
2483
2563
|
});
|
|
2564
|
+
if (problems.length === 0) {
|
|
2565
|
+
for (const [boneName, rotKeys] of synthesizedRotate) {
|
|
2566
|
+
tracks.push({ bone: boneName, property: 'rotate', keys: rotKeys });
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2484
2569
|
}
|
|
2485
2570
|
refuseIfAny(problems);
|
|
2486
2571
|
}
|
package/src/tags.ts
CHANGED
|
@@ -74,7 +74,11 @@ export const HEAD_TAGS = [
|
|
|
74
74
|
'mouth',
|
|
75
75
|
] as const;
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
/** The tags `splitMergedArmRuns` (issue #490) splits out of a merged `handwear-r` / `handwear-l` layer: `<tag>-r` / `<tag>-l`. */
|
|
78
|
+
export const ARM_MERGED_SPLIT_TAGS = ['hand', 'drape'] as const;
|
|
79
|
+
export type ArmMergedSplitTag = (typeof ARM_MERGED_SPLIT_TAGS)[number];
|
|
80
|
+
|
|
81
|
+
export type BaseTag = (typeof BODY_TAGS)[number] | (typeof HEAD_TAGS)[number] | ArmMergedSplitTag;
|
|
78
82
|
|
|
79
83
|
/** The tags upstream splits into `-r` / `-l` halves. */
|
|
80
84
|
export const SPLIT_TAGS = ['handwear', 'eyewhite', 'irides', 'eyelash', 'eyebrow', 'ears'] as const;
|
|
@@ -97,10 +101,11 @@ export interface TagReading {
|
|
|
97
101
|
/**
|
|
98
102
|
* Read a layer name as a v3 tag, or `null` when it is not one.
|
|
99
103
|
*
|
|
100
|
-
* Accepted: any of the 24 base tags,
|
|
101
|
-
* split tags
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
+
* Accepted: any of the 24 base tags, `<tag>-r` / `<tag>-l` for the six
|
|
105
|
+
* split tags, and `<tag>-r` / `<tag>-l` for the merged-arm split tags
|
|
106
|
+
* (`hand-r`, `hand-l`, `drape-r`, `drape-l`, issue #490). A suffix on a tag
|
|
107
|
+
* upstream never splits (`topwear-l`) is not a tag — upstream cannot have
|
|
108
|
+
* produced it, so accepting it would be accepting a layer somebody renamed.
|
|
104
109
|
*/
|
|
105
110
|
export function readTag(name: string): TagReading | null {
|
|
106
111
|
const split = /^(.+)-([rl])$/.exec(name);
|
|
@@ -108,6 +113,10 @@ export function readTag(name: string): TagReading | null {
|
|
|
108
113
|
const base = split[1] as SplitTag;
|
|
109
114
|
return { name, base, side: split[2] as Side, group: groupOf(base) };
|
|
110
115
|
}
|
|
116
|
+
if (split !== null && (ARM_MERGED_SPLIT_TAGS as readonly string[]).includes(split[1])) {
|
|
117
|
+
const base = split[1] as ArmMergedSplitTag;
|
|
118
|
+
return { name, base, side: split[2] as Side, group: 'body' };
|
|
119
|
+
}
|
|
111
120
|
if ((V3_TAGS as readonly string[]).includes(name)) {
|
|
112
121
|
const base = name as BaseTag;
|
|
113
122
|
return { name, base, side: null, group: groupOf(base) };
|
|
@@ -122,7 +131,7 @@ function groupOf(tag: BaseTag): 'body' | 'head' {
|
|
|
122
131
|
/** Every name `readTag` accepts, sorted — what a refusal quotes as "required". */
|
|
123
132
|
export function acceptedTagNames(): string[] {
|
|
124
133
|
const names = new Set<string>(V3_TAGS);
|
|
125
|
-
for (const t of SPLIT_TAGS) {
|
|
134
|
+
for (const t of [...SPLIT_TAGS, ...ARM_MERGED_SPLIT_TAGS]) {
|
|
126
135
|
names.add(`${t}-r`);
|
|
127
136
|
names.add(`${t}-l`);
|
|
128
137
|
}
|