@rig-c/parts 1.36.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/build.ts CHANGED
@@ -492,6 +492,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
492
492
  ...(Object.keys(altLimbs).length > 0 ? { limbs: altLimbs, bones: altBones } : {}),
493
493
  ...(fields.scene === undefined ? {} : { scene: fields.scene }),
494
494
  ...(fields.armSplit !== undefined && altPlan.some((e) => e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l')) ? { armSplit: fields.armSplit } : {}),
495
+ ...(fields.headFaceUnderfill === undefined ? {} : { headFaceUnderfill: fields.headFaceUnderfill }),
495
496
  seamRule: input.seam,
496
497
  projectRule: input.project,
497
498
  });
@@ -569,10 +570,11 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
569
570
  const pad = result.parts.single_run_pad;
570
571
  if (pad !== undefined) log(singleRunPadLine(input.full, pad));
571
572
  for (const p of result.parts.parts) {
573
+ const underfillSuffix = p.from === 'head:face' && (result.headFaceUnderfillPx ?? 0) > 0 ? ` underfill=${result.headFaceUnderfillPx}px(full:face)` : '';
572
574
  log(
573
575
  ` ${p.name.padEnd(11)} ${p.from.padEnd(16)} ${`${p.w}x${p.h}`.padEnd(9)} @${String(p.x).padStart(4)},${String(p.y).padStart(4)} ` +
574
576
  `op=${String(p.opaque_px).padStart(6)} vis=${String(p.visible_px).padStart(6)} src=${String(p.source_px_taken).padStart(6)}/${String(p.projected_core_px).padStart(6)} ` +
575
- `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}`,
577
+ `unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}${underfillSuffix}`,
576
578
  );
577
579
  }
578
580
  // Issue #256: one line per part from an author-made layer, so it never reads as See-through's. None without one.
package/src/config.ts CHANGED
@@ -92,7 +92,7 @@ import { CLOTH_TARGETS, type ClothRecipes, RECIPE_STRENGTH, RIGC_DEFAULT_DAMPING
92
92
  import { GRID, MAX_SIDE } from './contour.ts';
93
93
  import { type Problem, refuseIfAny } from './errors.ts';
94
94
  import { edgeLength } from './length.ts';
95
- import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
95
+ import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, scaledPoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
96
96
  import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
97
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';
98
98
  import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
@@ -267,6 +267,8 @@ export interface Assemble {
267
267
  patches?: Patch[];
268
268
  cuts?: Cut[];
269
269
  alternates?: AlternateSetConfig[];
270
+ /** Issue #492: composite full:face under head:face on the head canvas to fill transparent holes inside the face silhouette. */
271
+ head_face_underfill?: boolean;
270
272
  }
271
273
 
272
274
  export interface SingleBone {
@@ -1140,7 +1142,7 @@ export interface PaintConfig {
1140
1142
  export interface EarlyConfig {
1141
1143
  key: string;
1142
1144
  seethrough: SeeThrough;
1143
- assemble: { rig_scale: number };
1145
+ assemble: { rig_scale: number; head_face_underfill?: boolean };
1144
1146
  }
1145
1147
 
1146
1148
  /** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
@@ -1199,8 +1201,13 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1199
1201
  if (door === 'layers') {
1200
1202
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1201
1203
  if ('assemble' in t) {
1202
- const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
1203
- if (a !== null && 'rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
1204
+ const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill']);
1205
+ if (a !== null) {
1206
+ if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
1207
+ if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
1208
+ c.fail('CONFIG_FIELD_TYPE', 'config.assemble.head_face_underfill', `is ${show(a.head_face_underfill)}; true or false is required`);
1209
+ }
1210
+ }
1204
1211
  }
1205
1212
  }
1206
1213
  if (door === 'assemble') {
@@ -1223,7 +1230,10 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1223
1230
  if ('cloth' in t) checkCloth(c, t.cloth);
1224
1231
  const pose = base === null ? null : poseBeside(c, t.generation, base);
1225
1232
  refuseIfAny(c.problems);
1226
- return { config: raw as PaintConfig | EarlyConfig | AssembleConfig, pose };
1233
+ const config = raw as PaintConfig | EarlyConfig | AssembleConfig;
1234
+ const controlSkel = resolveControlSkeleton(t.generation, pose);
1235
+ if (controlSkel !== undefined) controlSkeletons.set(config, controlSkel);
1236
+ return { config, pose };
1227
1237
  }
1228
1238
 
1229
1239
  export function loadEarlyConfig(path: string, door: 'paint'): PaintConfig;
@@ -1243,6 +1253,40 @@ export function loadPaintConfig(path: string): { config: PaintConfig; pose: Cont
1243
1253
  return { config: config as PaintConfig, pose };
1244
1254
  }
1245
1255
 
1256
+ /**
1257
+ * Issue #490: the control skeleton `config.generation.control.skeleton` names,
1258
+ * with its points on the `generation.latent` canvas (`[w, h]`, x right, y down).
1259
+ */
1260
+ export interface ControlSkeleton {
1261
+ latent: readonly [number, number];
1262
+ points: PosePoints;
1263
+ }
1264
+
1265
+ const controlSkeletons = new WeakMap<object, ControlSkeleton>();
1266
+
1267
+ function resolveControlSkeleton(gen: Json, pose: ControlPose | null): ControlSkeleton | undefined {
1268
+ if (!isPlainObject(gen) || !isPlainObject(gen.control)) return undefined;
1269
+ const l = gen.latent;
1270
+ if (!(Array.isArray(l) && l.length === 2 && typeof l[0] === 'number' && typeof l[1] === 'number' && l[0] > 0 && l[1] > 0)) return undefined;
1271
+ const latent: readonly [number, number] = [l[0], l[1]];
1272
+ if (pose !== null) return { latent, points: pose.points };
1273
+ const skel = gen.control.skeleton;
1274
+ if (isSkeletonName(skel)) return { latent, points: scaledPoints(skel, latent[0], latent[1]) };
1275
+ return undefined;
1276
+ }
1277
+
1278
+ /**
1279
+ * Return the {@link ControlSkeleton} resolved for a loaded or parsed config
1280
+ * (`loadEarlyConfig`, `loadConfig`, `parseEarlyConfig`, `parseConfig`), or
1281
+ * computed from an inline `generation.control.skeleton` built-in when present.
1282
+ */
1283
+ export function controlSkeletonOf(cfg: object): ControlSkeleton | undefined {
1284
+ const cached = controlSkeletons.get(cfg);
1285
+ if (cached !== undefined) return cached;
1286
+ const gen = (cfg as { generation?: unknown }).generation;
1287
+ return resolveControlSkeleton(gen, null);
1288
+ }
1289
+
1246
1290
  /** The pose file a checked `generation` block's control names, read relative to `base`; null for a built-in, an absent control, or a block already refused. */
1247
1291
  function poseBeside(c: Check, gen: Json, base: string): ControlPose | null {
1248
1292
  if (!isPlainObject(gen) || !isPlainObject(gen.control)) return null;
@@ -1388,9 +1432,12 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1388
1432
  checkFaceAnimationBand(c, animations, hasAltFace, t.turn, attached);
1389
1433
  checkAnimationSetKeys(c, animations, t.assemble, new Set<string>([...parts, ...patches, ...cuts, ...(limbs?.parts.keys() ?? [])]));
1390
1434
  }
1391
- if (base !== null) poseBeside(c, t.generation, base);
1435
+ const pose = base === null ? null : poseBeside(c, t.generation, base);
1392
1436
  refuseIfAny(c.problems);
1393
- return { config: raw as CharacterConfig, animations };
1437
+ const config = raw as CharacterConfig;
1438
+ const controlSkel = resolveControlSkeleton(t.generation, pose);
1439
+ if (controlSkel !== undefined) controlSkeletons.set(config, controlSkel);
1440
+ return { config, animations };
1394
1441
  }
1395
1442
 
1396
1443
  /**
@@ -1791,13 +1838,16 @@ function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
1791
1838
  */
1792
1839
  function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
1793
1840
  const required = sectionRequired(door, 'assemble');
1794
- const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
1841
+ const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill']);
1795
1842
  if (a === null) return { parts: [], patches: [], cuts: [] };
1796
1843
  if (required.includes('plan') && !('plan' in a)) {
1797
1844
  c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
1798
1845
  }
1799
1846
  const p = 'config.assemble';
1800
1847
  if ('rig_scale' in a) c.number(`${p}.rig_scale`, a.rig_scale, 'positive');
1848
+ if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
1849
+ c.fail('CONFIG_FIELD_TYPE', `${p}.head_face_underfill`, `is ${show(a.head_face_underfill)}; true or false is required`);
1850
+ }
1801
1851
  const parts: string[] = [];
1802
1852
  if ('plan' in a && c.array(`${p}.plan`, a.plan, true)) {
1803
1853
  const seenPart = new Map<string, number>();
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
  }