@rig-c/parts 1.24.0 → 1.25.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/limbs.ts CHANGED
@@ -81,6 +81,17 @@
81
81
  * cap's disc and the limb's width stay where the bone's origin puts them;
82
82
  * only the line that splits the pixels between the two pieces moves, and
83
83
  * the cap takes the disc's pixels on the upper side of that line.
84
+ * - **Overlapping hands** (issue #400, {@link assignOverlappingHands}). When
85
+ * two forearm segments (`joint: "elbow"`, with a wrist end) share a part,
86
+ * each hand's keypoint region is the disc of radius half its forearm's
87
+ * length (`L / 2`) about its wrist, and its cuff is the line across the
88
+ * forearm at `L - L / 2 = L / 2` from the elbow, where that disc meets the
89
+ * bone's axis. When the two hand discs overlap, the overlapping hand region
90
+ * goes to the front piece (the limb `config.limbs` declares later, which
91
+ * `limbOrder` draws in front) in one piece, and the back piece ends at its
92
+ * cuff; any 4-connected island on a forearm piece that leaves its own hand's
93
+ * keypoint region is transferred to the other forearm piece, so no slivers
94
+ * are cut out of the front hand for the back arm.
84
95
  */
85
96
  import type { BoneEntry, Limb } from './config.ts';
86
97
 
@@ -429,3 +440,163 @@ export function capBox(at: Pt, radius: number): [number, number, number, number]
429
440
  export function inDisc(at: Pt, radius: number, px: number, py: number): boolean {
430
441
  return (px + 0.5 - at[0]) ** 2 + (py + 0.5 - at[1]) ** 2 <= radius * radius;
431
442
  }
443
+
444
+ /** A forearm segment on a shared part, for {@link assignOverlappingHands} (issue #400). */
445
+ export interface ForearmShare {
446
+ /** The limb's index in `Object.keys(limbs)`. */
447
+ limb: number;
448
+ /** The forearm segment's index in `limbs[limb].segments`. */
449
+ seg: number;
450
+ /** The elbow bone origin, in rig px. */
451
+ elbow: Pt;
452
+ /** The wrist bone end (`tip`), in rig px. */
453
+ wrist: Pt;
454
+ /** Whether pixel `(x, y)` on `limb` falls on this forearm segment (`segmentOf === seg`). */
455
+ isSeg: (x: number, y: number) => boolean;
456
+ }
457
+
458
+ /**
459
+ * Issue #400: when two forearm segments (`back` and `front`, in `config.limbs`
460
+ * order so `front` is drawn over `back`) share a part, each hand's keypoint
461
+ * region is the disc of radius `r = L / 2` about its wrist (`L` the forearm
462
+ * bone's length), and `back`'s cuff is the line across the forearm at
463
+ * `L - r = L / 2` from `back.elbow`. When the two wrist discs overlap, `back`'s
464
+ * pixels past its cuff (or inside `front`'s hand disc) go to `front`'s forearm
465
+ * in one piece so `back` ends at its cuff; and any 4-connected island on a
466
+ * forearm piece that leaves its own hand's keypoint region goes to the other
467
+ * forearm piece. Mutates `own` and `segOverride` in place.
468
+ */
469
+ export function assignOverlappingHands(back: ForearmShare, front: ForearmShare, W: number, H: number, own: Int32Array, segOverride: Int32Array): void {
470
+ const dx0 = back.wrist[0] - back.elbow[0];
471
+ const dy0 = back.wrist[1] - back.elbow[1];
472
+ const L0 = Math.sqrt(dx0 * dx0 + dy0 * dy0);
473
+ const dx1 = front.wrist[0] - front.elbow[0];
474
+ const dy1 = front.wrist[1] - front.elbow[1];
475
+ const L1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
476
+ if (L0 === 0 || L1 === 0) return;
477
+ const r0 = L0 / 2;
478
+ const r1 = L1 / 2;
479
+ const u0x = dx0 / L0;
480
+ const u0y = dy0 / L0;
481
+ const cuff0 = L0 - r0;
482
+ const proj0 = (x: number, y: number): number => (x + 0.5 - back.elbow[0]) * u0x + (y + 0.5 - back.elbow[1]) * u0y;
483
+ const onSeg = (f: ForearmShare, p: number): boolean => own[p] === f.limb && (segOverride[p] === f.seg || (segOverride[p] === -1 && f.isSeg(p % W, Math.floor(p / W))));
484
+ const wristsOverlap = (back.wrist[0] - front.wrist[0]) ** 2 + (back.wrist[1] - front.wrist[1]) ** 2 <= (r0 + r1) ** 2;
485
+ if (wristsOverlap) {
486
+ let cuffHasArt = false;
487
+ for (let y = 0; y < H && !cuffHasArt; y++) {
488
+ for (let x = 0; x < W && !cuffHasArt; x++) {
489
+ if (onSeg(back, y * W + x) && proj0(x, y) <= cuff0) cuffHasArt = true;
490
+ }
491
+ }
492
+ if (cuffHasArt) {
493
+ for (let y = 0; y < H; y++) {
494
+ for (let x = 0; x < W; x++) {
495
+ const p = y * W + x;
496
+ if (onSeg(back, p) && (proj0(x, y) > cuff0 || inDisc(front.wrist, r1, x, y)) && !inDisc(back.elbow, r0 / 2, x, y)) {
497
+ own[p] = front.limb;
498
+ segOverride[p] = front.seg;
499
+ } else if (own[p] === back.limb && inDisc(front.wrist, r1, x, y) && !inDisc(back.elbow, r0, x, y)) {
500
+ own[p] = front.limb;
501
+ segOverride[p] = front.seg;
502
+ }
503
+ }
504
+ }
505
+ }
506
+ }
507
+ const componentsOf = (f: ForearmShare): number[][] => {
508
+ const seen = new Uint8Array(W * H);
509
+ const comps: number[][] = [];
510
+ for (let p = 0; p < W * H; p++) {
511
+ if (seen[p] === 1 || !onSeg(f, p)) continue;
512
+ seen[p] = 1;
513
+ const comp: number[] = [p];
514
+ for (let h = 0; h < comp.length; h++) {
515
+ const q = comp[h];
516
+ const x = q % W;
517
+ const y = (q - x) / W;
518
+ for (const [nx, ny] of [
519
+ [x - 1, y],
520
+ [x + 1, y],
521
+ [x, y - 1],
522
+ [x, y + 1],
523
+ ]) {
524
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
525
+ const np = ny * W + nx;
526
+ if (seen[np] === 0 && onSeg(f, np)) {
527
+ seen[np] = 1;
528
+ comp.push(np);
529
+ }
530
+ }
531
+ }
532
+ comps.push(comp);
533
+ }
534
+ return comps;
535
+ };
536
+ const elbowDist2 = (comp: readonly number[], elbow: Pt): number => {
537
+ let best = Infinity;
538
+ for (const p of comp) {
539
+ const x = p % W;
540
+ const y = (p - x) / W;
541
+ const d = (x + 0.5 - elbow[0]) ** 2 + (y + 0.5 - elbow[1]) ** 2;
542
+ if (d < best) best = d;
543
+ }
544
+ return best;
545
+ };
546
+ const backComps = componentsOf(back);
547
+ if (backComps.length > 1) {
548
+ let main = 0;
549
+ let mainD = elbowDist2(backComps[0], back.elbow);
550
+ for (let i = 1; i < backComps.length; i++) {
551
+ const d = elbowDist2(backComps[i], back.elbow);
552
+ if (d < mainD) {
553
+ mainD = d;
554
+ main = i;
555
+ }
556
+ }
557
+ backComps.forEach((comp, i) => {
558
+ if (i === main) return;
559
+ const leavesBack = comp.some((p) => !inDisc(back.wrist, r0, p % W, Math.floor(p / W)));
560
+ const inFront = comp.some((p) => inDisc(front.wrist, r1, p % W, Math.floor(p / W)));
561
+ if (wristsOverlap || leavesBack || inFront) {
562
+ for (const p of comp) {
563
+ own[p] = front.limb;
564
+ segOverride[p] = front.seg;
565
+ }
566
+ }
567
+ });
568
+ }
569
+ const frontComps = componentsOf(front);
570
+ if (frontComps.length > 1) {
571
+ let main = 0;
572
+ let mainD = elbowDist2(frontComps[0], front.elbow);
573
+ for (let i = 1; i < frontComps.length; i++) {
574
+ const d = elbowDist2(frontComps[i], front.elbow);
575
+ if (d < mainD) {
576
+ mainD = d;
577
+ main = i;
578
+ }
579
+ }
580
+ frontComps.forEach((comp, i) => {
581
+ if (i === main) return;
582
+ const leavesFront = comp.some((p) => !inDisc(front.wrist, r1, p % W, Math.floor(p / W)));
583
+ const touchesBack = comp.some((p) => {
584
+ const x = p % W;
585
+ const y = (p - x) / W;
586
+ return (
587
+ (x > 0 && onSeg(back, p - 1)) ||
588
+ (x + 1 < W && onSeg(back, p + 1)) ||
589
+ (y > 0 && onSeg(back, p - W)) ||
590
+ (y + 1 < H && onSeg(back, p + W))
591
+ );
592
+ });
593
+ if (leavesFront && touchesBack) {
594
+ for (const p of comp) {
595
+ own[p] = back.limb;
596
+ segOverride[p] = back.seg;
597
+ }
598
+ }
599
+ });
600
+ }
601
+ }
602
+
package/src/propose.ts CHANGED
@@ -21,7 +21,10 @@
21
21
  * hip would sit above `HIP_MIN_FRACTION` of the figure's height (a long
22
22
  * robe tagged `bottomwear` starts at the collar): then `hip` is the waist,
23
23
  * the narrowest torso row between the shoulder line and the figure's
24
- * middle, and 0.32 of the figure height when no row narrows enough.
24
+ * middle, and 0.32 of the figure height when no row narrows enough. With
25
+ * `cloth.bottomwear.part` naming a dress (issue #402), a `bottomwear` part
26
+ * lying on the dress's tree links rides them (issue #416), by the rule a
27
+ * layer on a tail does (below), and gets no trees of its own.
25
28
  * - `topwear` -> two outer-robe chains, only when it hangs below the hip line;
26
29
  * a side whose link falls off the art is dropped.
27
30
  * - `handwear-*` -> two blobs: one sleeve chain per blob, its mesh taking that
@@ -143,8 +146,9 @@ import { configRoles, type RoleOf } from './structure.ts';
143
146
  import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
144
147
  import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
145
148
  import { edgeLength } from './length.ts';
146
- import { chainSqueezes, type SqueezeFinding } from './squeeze.ts';
147
- import { RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, swingName, type Tail, tailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
149
+ import { limbWidth } from './limbs.ts';
150
+ import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.ts';
151
+ import { RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
148
152
  import { TEXTURE_STRETCH_CEILING } from './check.ts';
149
153
 
150
154
  // ---------------------------------------------------------------------------
@@ -1554,7 +1558,7 @@ export function proposeWithBasis(
1554
1558
  all.push(...trees);
1555
1559
  }
1556
1560
  notes.push(clothNote(p.name, 'back hair', all, rcp, n, ' per side of the eye axis, from head, rooted on the eye line and tipped 4 px above its bottom'));
1557
- meshes[p.name] = { grid: g, r, segments: segs };
1561
+ meshes[p.name] = { grid: g, r: rcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
1558
1562
  // Issue #391: the spans are this mesh's segments, the head's fixed one and every tree link, in segment order.
1559
1563
  const spans: TailSpan[] = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false }];
1560
1564
  for (const t of all) {
@@ -1728,7 +1732,8 @@ export function proposeWithBasis(
1728
1732
  tracks.push({ chain: name, amps: [0.8, 1.6, 2.6, 3.6], period: 4.0, phase: 0.16, lag: 0.07 });
1729
1733
  k++;
1730
1734
  }
1731
- meshes[p.name] = { grid: g, r, segments: segs };
1735
+ const frontRcp = recipeOf('front hair');
1736
+ meshes[p.name] = { grid: g, r: frontRcp?.split?.hold_tip === true && k > 0 ? Math.max(r, g) : r, segments: segs };
1732
1737
  }
1733
1738
 
1734
1739
  // ---- accessories: rigid body + pendant chain where the part narrows, or one pendulum chain per hanging strand
@@ -2204,12 +2209,8 @@ export function proposeWithBasis(
2204
2209
  }
2205
2210
 
2206
2211
  // ---- skirt (bottomwear, and the topwear part config.cloth.bottomwear.part names: issue #402)
2207
- const skirts = namedSkirt === null ? bw : [...bw, namedSkirt];
2208
- for (const p of skirts) {
2209
- const [g, r] = gridR(p);
2210
- const mask = P.alpha(p);
2211
- const bot = p.y + p.h;
2212
- const y0 = hip[1] + 20;
2212
+ // The fixed segments of a skirt's mesh, and the note when it hangs from the waist (pushed by the drafting loop only).
2213
+ const skirtFrame = (p: PartRecord, mask: Mask): { segs: Segment[]; hangs: string | null } => {
2213
2214
  let segs: Segment[] = [
2214
2215
  ['chest', rnd([chest[0], chest[1] - 30]), rnd([chest[0], hip[1] - 20])],
2215
2216
  ['hip', rnd([p.x + 0.09 * p.w, hip[1] + 5]), rnd([p.x + 0.91 * p.w, hip[1] + 5])],
@@ -2231,26 +2232,67 @@ export function proposeWithBasis(
2231
2232
  const down: readonly [number, number] = trunk === null ? [0, 1] : trunk.u;
2232
2233
  const artTop = artExtent(mask, p, hip, down)[0];
2233
2234
  const chestAt = (chest[0] - hip[0]) * down[0] + (chest[1] - hip[1]) * down[1];
2234
- if (artTop >= chestAt) {
2235
- segs = segs.filter((s) => typeof s === 'string' || s[0] !== 'chest');
2236
- notes.push(
2237
- `${p.name} (${p.from}): its art starts ${pyFixed(artTop - chestAt, 1)} px below chest's origin, so it hangs from the waist — its mesh takes the hip line and its skirt chains, no chest segment, and the breath does not carry its root`,
2238
- );
2235
+ if (artTop < chestAt) return { segs, hangs: null };
2236
+ return {
2237
+ segs: segs.filter((s) => typeof s === 'string' || s[0] !== 'chest'),
2238
+ hangs: `${p.name} (${p.from}): its art starts ${pyFixed(artTop - chestAt, 1)} px below chest's origin, so it hangs from the waist — its mesh takes the hip line and its skirt chains, no chest segment, and the breath does not carry its root`,
2239
+ };
2240
+ };
2241
+ // Where the recipe's trees hang on a skirt: from 20 px below the hip to 4 px above the part's bottom, across its art.
2242
+ const skirtInput = (p: PartRecord, mask: Mask, prefix: string): TreeInput => ({
2243
+ prefix,
2244
+ parent: 'hip',
2245
+ rootY: hip[1] + 20,
2246
+ tipY: p.y + p.h - 4,
2247
+ xAt: (y, f) => bandX(mask, y, { frac: f }),
2248
+ fallbackX: (f) => p.x + f * p.w,
2249
+ frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
2250
+ edges: 'both',
2251
+ });
2252
+ // Issue #416: a bottomwear part lying on the trees of the dress config.cloth.bottomwear.part names (a ruffle hem
2253
+ // See-through returned on its own) rides the links it overlaps, as a layer wrapped round a tail does (issue #391),
2254
+ // and gets no trees of its own: one skirt carries one set of trees. The dress's mesh is the tail — its fixed
2255
+ // segments and every tree link, in segment order — and the links are read off the overlap, never off the part's
2256
+ // name. Which parts ride is read here, before any bone is named, off the trees as the recipe places them (a tree's
2257
+ // points do not depend on its prefix); a part over no link keeps its own trees below. Only with the field.
2258
+ const dressTail = (p: PartRecord, segs: readonly Segment[], trees: readonly TreeChain[]): Tail => {
2259
+ const spans: TailSpan[] = [];
2260
+ for (const s of segs) if (typeof s !== 'string') spans.push({ bone: s[0], a: s[1], b: s[2], link: false });
2261
+ for (const t of trees) {
2262
+ const line = [...t.points, t.tip];
2263
+ t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true }));
2239
2264
  }
2265
+ return { part: p.name, mask: P.alpha(p), spans };
2266
+ };
2267
+ const overLinks = (tail: Tail, q: PartRecord): { o: TailOverlap; rides: boolean } => {
2268
+ const o = tailOverlap(tail, P.alpha(q), q);
2269
+ return { o, rides: tail.spans.some((sp, k) => sp.link && o.counts[k] > 0) };
2270
+ };
2271
+ const dressRecipe = recipeOf('bottomwear');
2272
+ const riders: PartRecord[] =
2273
+ namedSkirt === null || dressRecipe === null
2274
+ ? []
2275
+ : ((): PartRecord[] => {
2276
+ const mask = P.alpha(namedSkirt);
2277
+ const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
2278
+ return bw.filter((q) => overLinks(tail, q).rides);
2279
+ })();
2280
+ let dressDrafted: Tail | null = null;
2281
+ const skirts = namedSkirt === null ? bw : [...bw.filter((q) => !riders.includes(q)), namedSkirt];
2282
+ for (const p of skirts) {
2283
+ const [g, r] = gridR(p);
2284
+ const mask = P.alpha(p);
2285
+ const bot = p.y + p.h;
2286
+ const y0 = hip[1] + 20;
2287
+ const { segs, hangs } = skirtFrame(p, mask);
2288
+ if (hangs !== null) notes.push(hangs);
2240
2289
  // Issue #364: with config.cloth.bottomwear, the recipe's trees hang from the hip in place of the three keyed chains.
2241
2290
  const skirtRecipe = recipeOf('bottomwear');
2242
2291
  if (skirtRecipe !== null) {
2243
2292
  const prefix = skirts.length === 1 ? 'skirt' : `skirt_${p.name}`;
2244
- const trees = T(p, 'bottomwear', skirtRecipe, {
2245
- prefix,
2246
- parent: 'hip',
2247
- rootY: y0,
2248
- tipY: bot - 4,
2249
- xAt: (y, f) => bandX(mask, y, { frac: f }),
2250
- fallbackX: (f) => p.x + f * p.w,
2251
- frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
2252
- edges: 'both',
2253
- });
2293
+ const fixed = [...segs];
2294
+ const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix));
2295
+ if (p === namedSkirt) dressDrafted = dressTail(p, fixed, trees);
2254
2296
  for (const t of trees) segs.push(t.chain);
2255
2297
  let swing: { bone: string; rest: number } | null = null;
2256
2298
  if (skirtRecipe.swing !== undefined) {
@@ -2283,7 +2325,7 @@ export function proposeWithBasis(
2283
2325
  p === namedSkirt ? p.from : undefined,
2284
2326
  ),
2285
2327
  );
2286
- meshes[p.name] = { grid: g, r, segments: segs };
2328
+ meshes[p.name] = { grid: g, r: skirtRecipe.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
2287
2329
  continue;
2288
2330
  }
2289
2331
  for (const [tag, f, ph] of [
@@ -2308,6 +2350,28 @@ export function proposeWithBasis(
2308
2350
  }
2309
2351
  meshes[p.name] = { grid: g, r, segments: segs };
2310
2352
  }
2353
+ // Issue #416: each rider's mesh takes the dress's spans its pixels over the dress's art fall nearest — a link by its
2354
+ // bone, a fixed segment written out as the dress's mesh writes it — so every vertex weights by the rule the dress's
2355
+ // own art does, and the note names each span with its pixel count.
2356
+ for (const q of riders) {
2357
+ const tail = dressDrafted as Tail | null;
2358
+ if (tail === null) break;
2359
+ const { o } = overLinks(tail, q);
2360
+ const segments: Segment[] = [];
2361
+ const links: string[] = [];
2362
+ const on: string[] = [];
2363
+ tail.spans.forEach((sp, k) => {
2364
+ if (o.counts[k] === 0) return;
2365
+ on.push(`${sp.link ? sp.bone : `${sp.bone}'s fixed segment`} ${o.counts[k]}`);
2366
+ segments.push(sp.link ? sp.bone : [sp.bone, sp.a, sp.b]);
2367
+ if (sp.link) links.push(sp.bone);
2368
+ });
2369
+ const [g, r] = gridR(q);
2370
+ meshes[q.name] = { grid: g, r, segments };
2371
+ notes.push(
2372
+ `${q.name} (${q.from}): lies on the trees of ${tail.part}, which config.cloth."bottomwear".part names — ${o.over} of its ${o.total} px lie over ${tail.part}'s art, nearest its spans ${on.join(', ')} px — so its mesh's segments are those spans and it rides the links ${links.join(', ')} with the dress; no trees of its own are drafted for it, so the skirt carries one set (issue #416)`,
2373
+ );
2374
+ }
2311
2375
 
2312
2376
  // ---- legs (issue #241)
2313
2377
  // Only with joints: a legwear part whose art holds the ankle of a leg whose hip, knee and ankle are all given is
@@ -3243,7 +3307,45 @@ export interface ChainNotAdvancingFinding {
3243
3307
  /** Issue #314: two chains on one lattice whose own swing closes or opens an edge between them past check's TEXTURE_STRETCH bar (`src/squeeze.ts`). */
3244
3308
  export type SqueezeLintFinding = { kind: 'squeeze' } & SqueezeFinding;
3245
3309
 
3246
- export type LintFinding = OffArtFinding | OffLockFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding | SqueezeLintFinding;
3310
+ /**
3311
+ * Issue #400: a hand layer (`handwear-r` or `handwear-l`, un-cut) whose side's wrist keypoint is placed is held to at
3312
+ * least this share of the other hand layer's opaque pixel count (`ceil(HAND_MIN_OF_OTHER * other.opaque_px)`), or, when
3313
+ * the other side has no hand layer and the side's elbow and wrist keypoints are placed, to a square of the forearm's
3314
+ * width (`ceil(width²)`). On issue #400's overlapping-hands decomposition, See-through put 1,923 px into `handwear_l`
3315
+ * and 123 px of finger slivers into `handwear_r` (6.4 % of the other hand, below `ceil(0.25 * 1923) = 481` px); on two
3316
+ * separate hands (`standing` 1,200 / 1,200, `seated` 816 / 816, `lying` 1,120 / 1,120) the two layers are equal.
3317
+ */
3318
+ export const HAND_MIN_OF_OTHER = 0.25;
3319
+
3320
+ /**
3321
+ * Issue #400: a hand layer holding fewer opaque pixels than the bar derived from the other hand layer or from the
3322
+ * forearm's width, while its side's wrist keypoint says a hand is there.
3323
+ */
3324
+ export interface NearEmptyHandFinding {
3325
+ kind: 'near-empty-hand';
3326
+ part: string;
3327
+ from: string;
3328
+ side: 'r' | 'l';
3329
+ wrist: KeypointName;
3330
+ wristAt: [number, number];
3331
+ opaquePx: number;
3332
+ bar: number;
3333
+ basis:
3334
+ | { kind: 'other-hand'; otherPart: string; otherFrom: string; otherPx: number; share: number }
3335
+ | { kind: 'forearm-width'; width: number };
3336
+ }
3337
+
3338
+ export type LintFinding =
3339
+ | OffArtFinding
3340
+ | OffLockFinding
3341
+ | HipAboveChestFinding
3342
+ | HipTooHighFinding
3343
+ | ChestNotBetweenFinding
3344
+ | HipNotPastChestFinding
3345
+ | HipNearerNeckFinding
3346
+ | ChainNotAdvancingFinding
3347
+ | SqueezeLintFinding
3348
+ | NearEmptyHandFinding;
3247
3349
 
3248
3350
  /** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
3249
3351
  export interface LintBasis {
@@ -3263,6 +3365,8 @@ export interface LintResult {
3263
3365
  missingTorsoBones: string[];
3264
3366
  /** Issue #314: the pairs of keyed chains on one mesh the squeeze check could not build a lattice for, and why. Empty when it read every pair or had none. */
3265
3367
  squeezeUnread: string[];
3368
+ /** Issue #403: sibling branch pairs on a fork whose cell-boundary weights and spring strengths are unheld. */
3369
+ branchSqueezes: BranchSqueezeFinding[];
3266
3370
  /** Present only under `--keypoints`: the rule set that read the torso and the chains. */
3267
3371
  basis?: LintBasis;
3268
3372
  /** One record per bone of the spec, in its bone order (chain links as `<chain><i>`): which checks read it, or why none did (issue #86). */
@@ -3470,9 +3574,68 @@ export function lint(
3470
3574
  }
3471
3575
  }
3472
3576
  }
3577
+ // Issue #400: when a hand layer holds fewer opaque pixels than the bar derived from the other hand layer, or from the
3578
+ // forearm's width when the other side has no hand layer, while the wrist keypoint says a hand is there.
3579
+ if (joints !== undefined) {
3580
+ const handOf = (s: 'r' | 'l'): PartRecord | null => {
3581
+ const ps = P.byTag(`handwear-${s}`).filter((p) => p.limb === undefined);
3582
+ return ps.length === 1 ? ps[0] : null;
3583
+ };
3584
+ for (const s of ['r', 'l'] as const) {
3585
+ const wrist: KeypointName = `${s}_wrist`;
3586
+ const wristAt = joints.joints[wrist].at;
3587
+ const part = handOf(s);
3588
+ if (wristAt === null || part === null) continue;
3589
+ const other = handOf(s === 'r' ? 'l' : 'r');
3590
+ if (other !== null) {
3591
+ const bar = Math.ceil(HAND_MIN_OF_OTHER * other.opaque_px);
3592
+ if (part.opaque_px < bar) {
3593
+ findings.push({
3594
+ kind: 'near-empty-hand',
3595
+ part: part.name,
3596
+ from: part.from,
3597
+ side: s,
3598
+ wrist,
3599
+ wristAt: [wristAt[0], wristAt[1]],
3600
+ opaquePx: part.opaque_px,
3601
+ bar,
3602
+ basis: { kind: 'other-hand', otherPart: other.name, otherFrom: other.from, otherPx: other.opaque_px, share: HAND_MIN_OF_OTHER },
3603
+ });
3604
+ }
3605
+ continue;
3606
+ }
3607
+ const elAt = joints.joints[`${s}_elbow`].at;
3608
+ if (elAt === null) continue;
3609
+ const dx = wristAt[0] - elAt[0];
3610
+ const dy = wristAt[1] - elAt[1];
3611
+ const len = edgeLength(dx, dy);
3612
+ if (len === 0) continue;
3613
+ const armMasks = [...P.byTag('topwear'), part].map((p) => P.alpha(p));
3614
+ const art = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < P.W && y < P.H && armMasks.some((m) => m.data[y * P.W + x] === 1);
3615
+ const mid: [number, number] = [(elAt[0] + wristAt[0]) / 2, (elAt[1] + wristAt[1]) / 2];
3616
+ const axis: [number, number] = [dx / len, dy / len];
3617
+ const w = limbWidth({ at: mid, axis }, art, [0, 0, P.W - 1, P.H - 1]) ?? limbWidth({ at: elAt, axis }, art, [0, 0, P.W - 1, P.H - 1]);
3618
+ if (w === null) continue;
3619
+ const bar = Math.ceil(w * w);
3620
+ if (part.opaque_px < bar) {
3621
+ findings.push({
3622
+ kind: 'near-empty-hand',
3623
+ part: part.name,
3624
+ from: part.from,
3625
+ side: s,
3626
+ wrist,
3627
+ wristAt: [wristAt[0], wristAt[1]],
3628
+ opaquePx: part.opaque_px,
3629
+ bar,
3630
+ basis: { kind: 'forearm-width', width: w },
3631
+ });
3632
+ }
3633
+ }
3634
+ }
3473
3635
  // Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
3474
3636
  // to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
3475
- const sq = chainSqueezes({ bones: spec.bones, meshes: spec.meshes, motion: spec.motion }, new Map(P.recs.filter((p) => P.images.has(p.name)).map((p) => [p.name, { x: p.x, y: p.y, image: P.images.get(p.name) as Raster }])));
3637
+ const partImages = new Map(P.recs.filter((p) => P.images.has(p.name)).map((p) => [p.name, { x: p.x, y: p.y, image: P.images.get(p.name) as Raster }]));
3638
+ const sq = chainSqueezes({ bones: spec.bones, meshes: spec.meshes, motion: spec.motion }, partImages);
3476
3639
  for (const { mesh, a, b } of sq.read) {
3477
3640
  for (const [c, other] of [[a, b], [b, a]]) {
3478
3641
  const e = spec.bones.find((x) => 'chain' in x && x.chain === c);
@@ -3481,10 +3644,11 @@ export function lint(
3481
3644
  }
3482
3645
  for (const f of sq.findings) findings.push({ kind: 'squeeze', ...f });
3483
3646
  const squeezeUnread = sq.unread;
3647
+ const branchSq = branchSqueezes({ bones: spec.bones, meshes: spec.meshes, constraints: spec.constraints }, partImages);
3484
3648
  const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
3485
3649
  return basis === undefined
3486
- ? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, coverage }
3487
- : { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, basis, coverage };
3650
+ ? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, branchSqueezes: branchSq, coverage }
3651
+ : { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, branchSqueezes: branchSq, basis, coverage };
3488
3652
  }
3489
3653
 
3490
3654
  /** The bones a finding names: the off-art link; hip and chest for a relation between them; a chain's last link for its tip. */
@@ -3503,6 +3667,8 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
3503
3667
  return ['chest'];
3504
3668
  case 'squeeze':
3505
3669
  return [...new Set(f.vertices.map((v) => v.weights[0]?.bone).filter((n): n is string => n !== undefined))];
3670
+ case 'near-empty-hand':
3671
+ return [];
3506
3672
  case 'chain-not-advancing': {
3507
3673
  if (!f.point.endsWith(' tip')) return [f.point];
3508
3674
  const chain = f.point.slice(0, -' tip'.length);
@@ -3670,6 +3836,13 @@ export function lintLine(f: LintFinding): string {
3670
3836
  return `LINT hip at ${pyIntList(f.hip)} is nearer the neck joint ${fmtAt(f.neck)} (${pyFixed(f.dNeck, 1)} px) than the midpoint of the hip joints ${fmtAt(f.hips)} (${pyFixed(f.dHips, 1)} px): a hip at the shoulders`;
3671
3837
  case 'squeeze':
3672
3838
  return squeezeLine(f);
3839
+ case 'near-empty-hand': {
3840
+ const why =
3841
+ f.basis.kind === 'other-hand'
3842
+ ? `${f.basis.share} of ${f.basis.otherPart} (${f.basis.otherFrom}), ${f.basis.otherPx} px`
3843
+ : `forearm width ${pyFixed(f.basis.width, 1)} px squared`;
3844
+ return `LINT ${f.part} (${f.from}) holds ${f.opaquePx} opaque px while ${f.wrist} at ${fmtAt(f.wristAt)} says a hand is there — below the bar of ${f.bar} px (${why}): the hands may overlap in the other layer and leave finger slivers here`;
3845
+ }
3673
3846
  case 'chain-not-advancing':
3674
3847
  return `LINT ${f.point} at ${pyIntList(f.at)} does not advance from ${f.fromJoint} toward ${f.toJoint}: it falls ${pyFixed(f.t, 1)} px along that line and ${f.previous} ${pyFixed(f.tPrevious, 1)} px, and each must fall further than the one before`;
3675
3848
  }
@@ -3704,6 +3877,23 @@ function squeezeLine(f: SqueezeLintFinding): string {
3704
3877
  );
3705
3878
  }
3706
3879
 
3880
+ /**
3881
+ * Issue #403: the note line naming a branch tip's squeezed triangle, the bones (`a` and `b` forked from `parent`),
3882
+ * the mechanism (`the weights where two branches' cells meet` and, when present, unequal spring strength on the fork),
3883
+ * and the setting (`split.hold_tip: true`) that holds the tip.
3884
+ */
3885
+ export function branchSqueezeLine(f: BranchSqueezeFinding): string {
3886
+ const stiff =
3887
+ f.strengthA !== null && f.strengthB !== null && f.strengthA !== f.strengthB
3888
+ ? ` and unequal spring strength on the fork (${f.a} ${f.strengthA} vs ${f.b} ${f.strengthB})`
3889
+ : '';
3890
+ return (
3891
+ `LINT ${f.a} and ${f.b} (forked from ${f.parent}) on mesh ${pyRepr(f.mesh)} squeeze their tip: triangle ${f.triangle} across edge ${f.edge[0]}-${f.edge[1]} (tips ${pyFixed(f.tipGap, 1)} px apart) ` +
3892
+ `is squeezed by the weights where two branches' cells meet (vertex ${f.edge[0]} rides ${f.a} ${pyFixed(f.weightA, 5)}, vertex ${f.edge[1]} rides ${f.b} ${pyFixed(f.weightB, 5)} at r=${f.r} on a ${f.grid} px lattice)${stiff} — ` +
3893
+ `set split.hold_tip: true to hold the branch tips (keeps branch strength across the fork and raises r to grid)`
3894
+ );
3895
+ }
3896
+
3707
3897
  export interface Comparison {
3708
3898
  rows: Array<[string, number]>;
3709
3899
  onlyConfig: string[];