@rig-c/parts 1.23.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/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
@@ -43,7 +46,12 @@
43
46
  * layer with hanging strands ({@link Strand}) gets one pendulum chain per
44
47
  * strand instead of the single pendant chain, and one note naming every
45
48
  * strand and what became of it — a strand left without a chain is never
46
- * silent (issue #24).
49
+ * silent (issue #24). Under the `back hair` cloth recipe (issue #391) a
50
+ * layer of either tag lying on a drafted tail rides the links it overlaps
51
+ * instead: its pixels over the tail's art are each given to the nearest span
52
+ * of the back hair's mesh (`tailOverlap`, `src/cloth.ts`), and when one is a
53
+ * link the part's mesh takes those spans as its segments and no rule above
54
+ * runs for it; a note names each span with its pixel count.
47
55
  * - anything no rule claims rides its nearest trunk bone as a region —
48
56
  * including every part from an author-made layer (`<run>:author:<label>`,
49
57
  * issue #256), which is no See-through tag and so has no role: no rule
@@ -138,8 +146,9 @@ import { configRoles, type RoleOf } from './structure.ts';
138
146
  import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
139
147
  import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
140
148
  import { edgeLength } from './length.ts';
141
- import { chainSqueezes, type SqueezeFinding } from './squeeze.ts';
142
- import { RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, swingName, 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';
143
152
  import { TEXTURE_STRETCH_CEILING } from './check.ts';
144
153
 
145
154
  // ---------------------------------------------------------------------------
@@ -1364,6 +1373,8 @@ export function proposeWithBasis(
1364
1373
  const clothConstraints: ConfigConstraint[] = [];
1365
1374
  const clothUsed = new Set<ClothTarget>();
1366
1375
  const recipeOf = (tag: ClothTarget): ClothRecipe | null => cloth?.[tag] ?? null;
1376
+ // Issue #391: the tails the back hair recipe drafts, each with its mesh's spans, which an accessory wrapped round one rides.
1377
+ const tails: Tail[] = [];
1367
1378
  const T = (p: PartRecord, tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
1368
1379
  const trees = draftTrees(input, r);
1369
1380
  const constants = [konst('cloth_chains', r.chains), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
@@ -1547,7 +1558,14 @@ export function proposeWithBasis(
1547
1558
  all.push(...trees);
1548
1559
  }
1549
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'));
1550
- 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 };
1562
+ // Issue #391: the spans are this mesh's segments, the head's fixed one and every tree link, in segment order.
1563
+ const spans: TailSpan[] = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false }];
1564
+ for (const t of all) {
1565
+ const line = [...t.points, t.tip];
1566
+ t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true }));
1567
+ }
1568
+ tails.push({ part: p.name, mask, spans });
1551
1569
  } else {
1552
1570
  const mask = P.alpha(p);
1553
1571
  const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
@@ -1714,7 +1732,8 @@ export function proposeWithBasis(
1714
1732
  tracks.push({ chain: name, amps: [0.8, 1.6, 2.6, 3.6], period: 4.0, phase: 0.16, lag: 0.07 });
1715
1733
  k++;
1716
1734
  }
1717
- 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 };
1718
1737
  }
1719
1738
 
1720
1739
  // ---- accessories: rigid body + pendant chain where the part narrows, or one pendulum chain per hanging strand
@@ -1726,7 +1745,39 @@ export function proposeWithBasis(
1726
1745
  // gets the bone and chain, the rest ride the head rigidly. A stable sort,
1727
1746
  // as Python's `sorted` is.
1728
1747
  const cands = [...P.byTag(tag)].sort((a, b) => b.opaque_px - a.opaque_px);
1748
+ // Issue #391: a layer of the tag lying on a tail the back hair recipe drafted rides the links it overlaps — the
1749
+ // spans of the tail its pixels over the tail's art fall nearest — and not head: its mesh names those bones and no
1750
+ // others, so every vertex weights by the rule the tail's own art does. The links are read off the overlap, never
1751
+ // off the part's name. A layer over no link keeps its rule below. Only with the recipe: without it no tail moves
1752
+ // by the world, and nothing here runs.
1753
+ const rides = (q: PartRecord): boolean => {
1754
+ const segments: Segment[] = [];
1755
+ const links: string[] = [];
1756
+ const said: string[] = [];
1757
+ for (const tail of tails) {
1758
+ const o = tailOverlap(tail, P.alpha(q), q);
1759
+ if (!tail.spans.some((sp, k) => sp.link && o.counts[k] > 0)) continue;
1760
+ const on: string[] = [];
1761
+ tail.spans.forEach((sp, k) => {
1762
+ if (o.counts[k] === 0) return;
1763
+ on.push(`${sp.link ? sp.bone : `${sp.bone}'s fixed segment`} ${o.counts[k]}`);
1764
+ // A link is named, so its segment is the link as the bones declare it; the head's span is written out, as the tail's mesh writes it.
1765
+ segments.push(sp.link ? sp.bone : [sp.bone, sp.a, sp.b]);
1766
+ if (sp.link) links.push(sp.bone);
1767
+ });
1768
+ said.push(`${o.over} of its ${o.total} px lie over ${tail.part}'s art, nearest ${tail.part}'s spans ${on.join(', ')} px`);
1769
+ }
1770
+ if (links.length === 0) return false;
1771
+ const [g, r] = gridR(q);
1772
+ delete regions[q.name];
1773
+ meshes[q.name] = { grid: g, r, segments };
1774
+ notes.push(
1775
+ `${q.name} (${q.from}): wraps a tail config.cloth."back hair" drafts — ${said.join('; ')} — so its mesh's segments are those spans and it rides the links ${links.join(', ')} with the tail, not head; no ${tag} bone, pendant or strand chain is drafted for it (issue #391)`,
1776
+ );
1777
+ return true;
1778
+ };
1729
1779
  for (const q of cands.slice(1)) {
1780
+ if (rides(q)) continue;
1730
1781
  regions[q.name] = 'head';
1731
1782
  notes.push(`${q.name} (${q.from}): second ${tag} layer, rigid on head`);
1732
1783
  // A region cannot swing, so strands found on it are only said.
@@ -1735,6 +1786,7 @@ export function proposeWithBasis(
1735
1786
  if (strands.length > 0) notes.push(strandNote(q, strands, [], strands, 'it rides the head as a region'));
1736
1787
  }
1737
1788
  for (const p of cands.slice(0, 1)) {
1789
+ if (rides(p)) continue;
1738
1790
  const [g, r] = gridR(p);
1739
1791
  const fullMask = dilate(P.alpha(p), 7);
1740
1792
  const onArt = (pts: ReadonlyArray<readonly [number, number]>): boolean => pts.every((q) => isOn(fullMask, q[0], q[1]));
@@ -2157,12 +2209,8 @@ export function proposeWithBasis(
2157
2209
  }
2158
2210
 
2159
2211
  // ---- skirt (bottomwear, and the topwear part config.cloth.bottomwear.part names: issue #402)
2160
- const skirts = namedSkirt === null ? bw : [...bw, namedSkirt];
2161
- for (const p of skirts) {
2162
- const [g, r] = gridR(p);
2163
- const mask = P.alpha(p);
2164
- const bot = p.y + p.h;
2165
- 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 } => {
2166
2214
  let segs: Segment[] = [
2167
2215
  ['chest', rnd([chest[0], chest[1] - 30]), rnd([chest[0], hip[1] - 20])],
2168
2216
  ['hip', rnd([p.x + 0.09 * p.w, hip[1] + 5]), rnd([p.x + 0.91 * p.w, hip[1] + 5])],
@@ -2184,26 +2232,67 @@ export function proposeWithBasis(
2184
2232
  const down: readonly [number, number] = trunk === null ? [0, 1] : trunk.u;
2185
2233
  const artTop = artExtent(mask, p, hip, down)[0];
2186
2234
  const chestAt = (chest[0] - hip[0]) * down[0] + (chest[1] - hip[1]) * down[1];
2187
- if (artTop >= chestAt) {
2188
- segs = segs.filter((s) => typeof s === 'string' || s[0] !== 'chest');
2189
- notes.push(
2190
- `${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`,
2191
- );
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 }));
2192
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);
2193
2289
  // Issue #364: with config.cloth.bottomwear, the recipe's trees hang from the hip in place of the three keyed chains.
2194
2290
  const skirtRecipe = recipeOf('bottomwear');
2195
2291
  if (skirtRecipe !== null) {
2196
2292
  const prefix = skirts.length === 1 ? 'skirt' : `skirt_${p.name}`;
2197
- const trees = T(p, 'bottomwear', skirtRecipe, {
2198
- prefix,
2199
- parent: 'hip',
2200
- rootY: y0,
2201
- tipY: bot - 4,
2202
- xAt: (y, f) => bandX(mask, y, { frac: f }),
2203
- fallbackX: (f) => p.x + f * p.w,
2204
- frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
2205
- edges: 'both',
2206
- });
2293
+ const fixed = [...segs];
2294
+ const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix));
2295
+ if (p === namedSkirt) dressDrafted = dressTail(p, fixed, trees);
2207
2296
  for (const t of trees) segs.push(t.chain);
2208
2297
  let swing: { bone: string; rest: number } | null = null;
2209
2298
  if (skirtRecipe.swing !== undefined) {
@@ -2236,7 +2325,7 @@ export function proposeWithBasis(
2236
2325
  p === namedSkirt ? p.from : undefined,
2237
2326
  ),
2238
2327
  );
2239
- 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 };
2240
2329
  continue;
2241
2330
  }
2242
2331
  for (const [tag, f, ph] of [
@@ -2261,6 +2350,28 @@ export function proposeWithBasis(
2261
2350
  }
2262
2351
  meshes[p.name] = { grid: g, r, segments: segs };
2263
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
+ }
2264
2375
 
2265
2376
  // ---- legs (issue #241)
2266
2377
  // Only with joints: a legwear part whose art holds the ankle of a leg whose hip, knee and ankle are all given is
@@ -3196,7 +3307,45 @@ export interface ChainNotAdvancingFinding {
3196
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`). */
3197
3308
  export type SqueezeLintFinding = { kind: 'squeeze' } & SqueezeFinding;
3198
3309
 
3199
- 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;
3200
3349
 
3201
3350
  /** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
3202
3351
  export interface LintBasis {
@@ -3216,6 +3365,8 @@ export interface LintResult {
3216
3365
  missingTorsoBones: string[];
3217
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. */
3218
3367
  squeezeUnread: string[];
3368
+ /** Issue #403: sibling branch pairs on a fork whose cell-boundary weights and spring strengths are unheld. */
3369
+ branchSqueezes: BranchSqueezeFinding[];
3219
3370
  /** Present only under `--keypoints`: the rule set that read the torso and the chains. */
3220
3371
  basis?: LintBasis;
3221
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). */
@@ -3423,9 +3574,68 @@ export function lint(
3423
3574
  }
3424
3575
  }
3425
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
+ }
3426
3635
  // Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
3427
3636
  // to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
3428
- 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);
3429
3639
  for (const { mesh, a, b } of sq.read) {
3430
3640
  for (const [c, other] of [[a, b], [b, a]]) {
3431
3641
  const e = spec.bones.find((x) => 'chain' in x && x.chain === c);
@@ -3434,10 +3644,11 @@ export function lint(
3434
3644
  }
3435
3645
  for (const f of sq.findings) findings.push({ kind: 'squeeze', ...f });
3436
3646
  const squeezeUnread = sq.unread;
3647
+ const branchSq = branchSqueezes({ bones: spec.bones, meshes: spec.meshes, constraints: spec.constraints }, partImages);
3437
3648
  const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
3438
3649
  return basis === undefined
3439
- ? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, coverage }
3440
- : { 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 };
3441
3652
  }
3442
3653
 
3443
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. */
@@ -3456,6 +3667,8 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
3456
3667
  return ['chest'];
3457
3668
  case 'squeeze':
3458
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 [];
3459
3672
  case 'chain-not-advancing': {
3460
3673
  if (!f.point.endsWith(' tip')) return [f.point];
3461
3674
  const chain = f.point.slice(0, -' tip'.length);
@@ -3623,6 +3836,13 @@ export function lintLine(f: LintFinding): string {
3623
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`;
3624
3837
  case 'squeeze':
3625
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
+ }
3626
3846
  case 'chain-not-advancing':
3627
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`;
3628
3848
  }
@@ -3657,6 +3877,23 @@ function squeezeLine(f: SqueezeLintFinding): string {
3657
3877
  );
3658
3878
  }
3659
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
+
3660
3897
  export interface Comparison {
3661
3898
  rows: Array<[string, number]>;
3662
3899
  onlyConfig: string[];