@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/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(P: PartSet, joints?: RigJoints, wristSnap: number | null = null, gridRule: GridRule = DEFAULT_GRID_RULE, cloth: ClothRecipes | null = null): Proposal {
1368
- return proposeWithBasis(P, joints, wristSnap, gridRule, cloth).proposal;
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: two links,
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
- C(`sleeve_${a.side}`, 'chest', [a.shoulder, a.elbow], a.wrist, [used(`${a.side}_shoulder`), used(`${a.side}_elbow`)], used(`${a.side}_wrist`));
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. Otherwise the why names the nearest part and its distance, the
3511
- * tolerance that would have bound it.
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(P: PartSet, hw: readonly PartRecord[], joints: RigJoints, wristSnap: number | null): { arms: JointArm[] | null; why: string | null } {
3514
- const complete: Array<Omit<JointArm, 'part' | 'snap'>> = [];
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 near = new Map(hw.map((p) => [p, dilate(P.alpha(p), 2 * WRIST_ON_PART_PX + 1)]));
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(P.alpha(p), a.wrist[0], a.wrist[1]))
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(P.alpha(p), a.wrist) }));
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
- const on = arm === null ? '' : arm.snap === null ? `the wrist lies on ${arm.part.name}` : `the wrist is bound to ${arm.part.name} by --wrist-snap`;
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 derived = alt.skeleton === undefined ? undefined : deriveAlternateDrawn(alt.skeleton, B, turn, CX, H);
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
- export type BaseTag = (typeof BODY_TAGS)[number] | (typeof HEAD_TAGS)[number];
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, and `<tag>-r` / `<tag>-l` for the six
101
- * split tags. A suffix on a tag upstream never splits (`topwear-l`) is not a
102
- * tag — upstream cannot have produced it, so accepting it would be accepting a
103
- * layer somebody renamed.
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
  }