@rig-c/parts 1.36.0 → 1.38.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/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
  }