@rig-c/parts 1.24.0 → 1.26.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
@@ -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_BACK_HAIR_CHAINS, 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
  // ---------------------------------------------------------------------------
@@ -274,20 +278,23 @@ export interface Proposal {
274
278
  constraints?: ConfigConstraint[];
275
279
  /** Only with `propose --full`/`--head` (issue #251, {@link draftTurn}); absent otherwise, so a proposal without them is the one written before it. */
276
280
  turn?: TurnSpec;
281
+ /** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) its coordinates were drafted at (issue #354). */
282
+ proposed_rig_scale: number;
277
283
  notes: string[];
278
284
  }
279
285
 
280
286
  /**
281
287
  * Every section of a proposal that is a config section, in the order the
282
288
  * proposal writes them: what an author copies into the config (issue #375).
283
- * `notes` is the proposal's own and is copied nowhere. Two of them are written
284
- * only on request — `limbs` under `--keypoints`, `turn` under `--full`/`--head`
285
- * — and a `limbs` block left behind is refused at the next step, by the name of
286
- * every part it cuts. {@link checkProposal} reads the proposal through the config
287
- * loader by this list, the type below refuses a {@link Proposal} key it does not
288
- * hold, and the selftest's `TY18` holds every paste sentence in the documents to it.
289
+ * `notes` is the proposal's own and is copied nowhere. Three of them are written
290
+ * only on request — `limbs` under `--keypoints`, `constraints` under `--cloth`,
291
+ * `turn` under `--full`/`--head` — and a `limbs` block left behind is refused
292
+ * at the next step, by the name of every part it cuts. {@link checkProposal} reads
293
+ * the proposal through the config loader by this list, the type below refuses a
294
+ * {@link Proposal} key it does not hold, and the selftest's `TY18` holds every
295
+ * paste sentence in the documents to it.
289
296
  */
290
- export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
297
+ export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn', 'proposed_rig_scale'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
291
298
 
292
299
  /** A {@link Proposal} key that is neither `notes` nor in {@link PROPOSAL_CONFIG_SECTIONS}; `never` while the list is whole. */
293
300
  type UnlistedProposalSection = Exclude<keyof Proposal, 'notes' | (typeof PROPOSAL_CONFIG_SECTIONS)[number]>;
@@ -410,7 +417,7 @@ export function proposalAssemble(P: PartSet): Record<string, unknown> {
410
417
  const planned = run.filter((r) => r.cut_from === undefined && firstOf.get(r.from) === r.name);
411
418
  const pieces = run.filter((r) => r.cut_from !== undefined || firstOf.get(r.from) !== r.name);
412
419
  return {
413
- rig_scale: 1,
420
+ rig_scale: P.rigScale,
414
421
  plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
415
422
  patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
416
423
  ...(pieces.length === 0
@@ -607,7 +614,7 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
607
614
  }
608
615
  turn = { pivot: turn.pivot, depths };
609
616
  }
610
- return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), notes: [...p.notes, ...draft.notes] };
617
+ return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), proposed_rig_scale: p.proposed_rig_scale, notes: [...p.notes, ...draft.notes] };
611
618
  }
612
619
 
613
620
  // ---------------------------------------------------------------------------
@@ -618,12 +625,14 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
618
625
  export class PartSet {
619
626
  readonly W: number;
620
627
  readonly H: number;
628
+ readonly rigScale: number;
621
629
  readonly recs: readonly PartRecord[];
622
630
  readonly images: ReadonlyMap<string, Raster>;
623
631
  private readonly masks = new Map<string, Mask>();
624
632
 
625
633
  constructor(file: PartsFile, images: ReadonlyMap<string, Raster>) {
626
634
  [this.W, this.H] = file.rig_size;
635
+ this.rigScale = file.scale_rig_per_source;
627
636
  this.recs = file.parts;
628
637
  this.images = images;
629
638
  }
@@ -1373,7 +1382,7 @@ export function proposeWithBasis(
1373
1382
  const tails: Tail[] = [];
1374
1383
  const T = (p: PartRecord, tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
1375
1384
  const trees = draftTrees(input, r);
1376
- const constants = [konst('cloth_chains', r.chains), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
1385
+ const constants = [konst('cloth_chains', r.chains ?? RECIPE_BACK_HAIR_CHAINS), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
1377
1386
  for (const t of trees) {
1378
1387
  C(
1379
1388
  t.chain,
@@ -1530,13 +1539,69 @@ export function proposeWithBasis(
1530
1539
  } else if (recipeOf('back hair') !== null) {
1531
1540
  // Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
1532
1541
  // 4 px above the part's bottom, in place of the two keyed chains.
1533
- const rcp = recipeOf('back hair') as ClothRecipe;
1542
+ // Issue #390: the tree count per side derives from the tail's opaque width at the root row (`floor(width / grid)`
1543
+ // on each side, and every tree root inside the 15 px dilated art); a declared count that does not fit is refused
1544
+ // by name (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`).
1545
+ const rawRcp = recipeOf('back hair') as ClothRecipe;
1534
1546
  const mask = P.alpha(p);
1547
+ const dilated = dilate(mask, 31);
1548
+ const y0 = pyRound(eyeY);
1549
+ const sideOpaqueWidth = (s: 'r' | 'l'): number => {
1550
+ if (y0 < 0 || y0 >= mask.height) return 0;
1551
+ let w = 0;
1552
+ for (let x = 0; x < mask.width; x++) {
1553
+ if ((s === 'r' ? x < axis : x >= axis) && mask.data[y0 * mask.width + x]) w++;
1554
+ }
1555
+ return w;
1556
+ };
1557
+ const prefixOf = (s: 'r' | 'l'): string => (P.headFirst('back hair').length === 1 ? `hairback_${s}` : `hairback_${p.name}_${s}`);
1558
+ const offRootsAt = (cnt: number, s: 'r' | 'l'): Array<{ bone: string; at: Point; alpha: number }> => {
1559
+ const pref = prefixOf(s);
1560
+ const im = P.images.get(p.name) as Raster;
1561
+ const off: Array<{ bone: string; at: Point; alpha: number }> = [];
1562
+ for (let k = 0; k < cnt; k++) {
1563
+ const fc = (k + 0.5) / cnt;
1564
+ const rx = pyRound(bandX(mask, eyeY, { frac: fc, side: s, axis }) ?? axis);
1565
+ if (!isOn(dilated, rx, y0)) {
1566
+ const lx = rx - p.x;
1567
+ const ly = y0 - p.y;
1568
+ const alpha = lx >= 0 && lx < p.w && ly >= 0 && ly < p.h ? im.data[(ly * p.w + lx) * 4 + 3] : 0;
1569
+ off.push({ bone: `${pref}${k}_0`, at: [rx, y0], alpha });
1570
+ }
1571
+ }
1572
+ return off;
1573
+ };
1574
+ const wR = sideOpaqueWidth('r');
1575
+ const wL = sideOpaqueWidth('l');
1576
+ const widthCap = Math.max(1, Math.min(Math.floor(wR / g), Math.floor(wL / g)));
1577
+ let maxFit = 1;
1578
+ for (let cnt = widthCap; cnt >= 1; cnt--) {
1579
+ if (offRootsAt(cnt, 'r').length === 0 && offRootsAt(cnt, 'l').length === 0) {
1580
+ maxFit = cnt;
1581
+ break;
1582
+ }
1583
+ }
1584
+ if (rawRcp.chains !== undefined) {
1585
+ const offRoots = [...offRootsAt(rawRcp.chains, 'r'), ...offRootsAt(rawRcp.chains, 'l')];
1586
+ if (rawRcp.chains > widthCap || offRoots.length > 0) {
1587
+ throw new PartsError([
1588
+ {
1589
+ code: 'PROPOSE_CLOTH_BACK_HAIR_CHAINS',
1590
+ object: 'config.cloth."back hair".chains',
1591
+ detail:
1592
+ `declares ${rawRcp.chains} tree(s) per side on "${p.name}", and at the root row y=${y0} the tail is ${wR} px wide on the r side and ${wL} px on the l side on a ${g} px mesh grid (at most ${maxFit} tree(s) per side fit inside the art` +
1593
+ (offRoots.length > 0 ? `; off the art: ${offRoots.map((o) => `${o.bone} at [${o.at[0]}, ${o.at[1]}] (alpha ${o.alpha})`).join(', ')}` : '') +
1594
+ `) — set chains to at most ${maxFit}, or leave chains out to derive it from the tail's width`,
1595
+ },
1596
+ ]);
1597
+ }
1598
+ }
1599
+ const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
1535
1600
  const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
1536
1601
  const all: TreeChain[] = [];
1537
1602
  let n = 0;
1538
1603
  for (const [side] of SIDES) {
1539
- const prefix = P.headFirst('back hair').length === 1 ? `hairback_${side}` : `hairback_${p.name}_${side}`;
1604
+ const prefix = prefixOf(side);
1540
1605
  const trees = T(p, 'back hair', rcp, {
1541
1606
  prefix,
1542
1607
  parent: 'head',
@@ -1554,7 +1619,7 @@ export function proposeWithBasis(
1554
1619
  all.push(...trees);
1555
1620
  }
1556
1621
  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 };
1622
+ meshes[p.name] = { grid: g, r: rcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
1558
1623
  // Issue #391: the spans are this mesh's segments, the head's fixed one and every tree link, in segment order.
1559
1624
  const spans: TailSpan[] = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false }];
1560
1625
  for (const t of all) {
@@ -1728,7 +1793,8 @@ export function proposeWithBasis(
1728
1793
  tracks.push({ chain: name, amps: [0.8, 1.6, 2.6, 3.6], period: 4.0, phase: 0.16, lag: 0.07 });
1729
1794
  k++;
1730
1795
  }
1731
- meshes[p.name] = { grid: g, r, segments: segs };
1796
+ const frontRcp = recipeOf('front hair');
1797
+ meshes[p.name] = { grid: g, r: frontRcp?.split?.hold_tip === true && k > 0 ? Math.max(r, g) : r, segments: segs };
1732
1798
  }
1733
1799
 
1734
1800
  // ---- accessories: rigid body + pendant chain where the part narrows, or one pendulum chain per hanging strand
@@ -2114,9 +2180,10 @@ export function proposeWithBasis(
2114
2180
  throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object: 'config.cloth."bottomwear".part', detail }]);
2115
2181
  };
2116
2182
  const q = P.recs.find((x) => x.name === name);
2117
- if (q === undefined) return refuse(`names "${name}", which is no part in parts.json; a topwear part whose art hangs below the hip (a dress) is required`);
2118
- if (q.cut_from !== undefined) return refuse(`names "${name}", a piece an assemble.cuts entry took from ${q.cut_from}; name the part the plan made, ${q.cut_from}, or leave part out`);
2119
- if (!tw.includes(q)) {
2183
+ if (q === undefined) return refuse(`names "${name}", which is no part in parts.json; a topwear part or its cut skirt piece whose art hangs below the hip (a dress) is required`);
2184
+ // An explicit name may select a topwear cut piece; automatic tag rules still exclude pieces.
2185
+ const [run, tag] = splitFrom(q.from);
2186
+ if (run === PAINTING_RUN || tag !== 'topwear') {
2120
2187
  return refuse(
2121
2188
  `names "${name}", from ${q.from}; ${splitFrom(q.from)[1] === 'bottomwear' ? 'a bottomwear part takes the recipe by its tag already — leave part out' : "only a topwear part takes the skirt's recipe by name (a skirt See-through returned inside topwear, a dress)"}`,
2122
2189
  );
@@ -2156,8 +2223,8 @@ export function proposeWithBasis(
2156
2223
  ['chest', rnd(along(hip, trunk.u, artExtent(mask, p, hip, trunk.u)[0])), rnd(along(hip, trunk.u, -20))],
2157
2224
  ['hip', rnd(along(hip, trunk.u, -20)), rnd(along(hip, trunk.u, 100))],
2158
2225
  ];
2159
- if (bw.length > 0 && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
2160
- // A bodice that stops at the waist: robe chains down to the hem would fall off the art.
2226
+ if ((bw.length > 0 || namedSkirt?.cut_from === p.name) && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
2227
+ // A bodice above bottomwear, including its explicitly named cut skirt: robe chains would fall off the art.
2161
2228
  meshes[p.name] = { grid: g, r, segments: segs };
2162
2229
  notes.push(`${p.name} ends at y=${bot} (hip ${pyFixed(hip[1], 0)}): no robe chains -- add a sash chain by hand if one hangs`);
2163
2230
  continue;
@@ -2204,12 +2271,8 @@ export function proposeWithBasis(
2204
2271
  }
2205
2272
 
2206
2273
  // ---- 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;
2274
+ // The fixed segments of a skirt's mesh, and the note when it hangs from the waist (pushed by the drafting loop only).
2275
+ const skirtFrame = (p: PartRecord, mask: Mask): { segs: Segment[]; hangs: string | null } => {
2213
2276
  let segs: Segment[] = [
2214
2277
  ['chest', rnd([chest[0], chest[1] - 30]), rnd([chest[0], hip[1] - 20])],
2215
2278
  ['hip', rnd([p.x + 0.09 * p.w, hip[1] + 5]), rnd([p.x + 0.91 * p.w, hip[1] + 5])],
@@ -2231,26 +2294,67 @@ export function proposeWithBasis(
2231
2294
  const down: readonly [number, number] = trunk === null ? [0, 1] : trunk.u;
2232
2295
  const artTop = artExtent(mask, p, hip, down)[0];
2233
2296
  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
- );
2297
+ if (artTop < chestAt) return { segs, hangs: null };
2298
+ return {
2299
+ segs: segs.filter((s) => typeof s === 'string' || s[0] !== 'chest'),
2300
+ 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`,
2301
+ };
2302
+ };
2303
+ // 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.
2304
+ const skirtInput = (p: PartRecord, mask: Mask, prefix: string): TreeInput => ({
2305
+ prefix,
2306
+ parent: 'hip',
2307
+ rootY: hip[1] + 20,
2308
+ tipY: p.y + p.h - 4,
2309
+ xAt: (y, f) => bandX(mask, y, { frac: f }),
2310
+ fallbackX: (f) => p.x + f * p.w,
2311
+ frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
2312
+ edges: 'both',
2313
+ });
2314
+ // Issue #416: a bottomwear part lying on the trees of the dress config.cloth.bottomwear.part names (a ruffle hem
2315
+ // See-through returned on its own) rides the links it overlaps, as a layer wrapped round a tail does (issue #391),
2316
+ // and gets no trees of its own: one skirt carries one set of trees. The dress's mesh is the tail — its fixed
2317
+ // segments and every tree link, in segment order — and the links are read off the overlap, never off the part's
2318
+ // name. Which parts ride is read here, before any bone is named, off the trees as the recipe places them (a tree's
2319
+ // points do not depend on its prefix); a part over no link keeps its own trees below. Only with the field.
2320
+ const dressTail = (p: PartRecord, segs: readonly Segment[], trees: readonly TreeChain[]): Tail => {
2321
+ const spans: TailSpan[] = [];
2322
+ for (const s of segs) if (typeof s !== 'string') spans.push({ bone: s[0], a: s[1], b: s[2], link: false });
2323
+ for (const t of trees) {
2324
+ const line = [...t.points, t.tip];
2325
+ t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true }));
2239
2326
  }
2327
+ return { part: p.name, mask: P.alpha(p), spans };
2328
+ };
2329
+ const overLinks = (tail: Tail, q: PartRecord): { o: TailOverlap; rides: boolean } => {
2330
+ const o = tailOverlap(tail, P.alpha(q), q);
2331
+ return { o, rides: tail.spans.some((sp, k) => sp.link && o.counts[k] > 0) };
2332
+ };
2333
+ const dressRecipe = recipeOf('bottomwear');
2334
+ const riders: PartRecord[] =
2335
+ namedSkirt === null || dressRecipe === null
2336
+ ? []
2337
+ : ((): PartRecord[] => {
2338
+ const mask = P.alpha(namedSkirt);
2339
+ const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
2340
+ return bw.filter((q) => overLinks(tail, q).rides);
2341
+ })();
2342
+ let dressDrafted: Tail | null = null;
2343
+ const skirts = namedSkirt === null ? bw : [...bw.filter((q) => !riders.includes(q)), namedSkirt];
2344
+ for (const p of skirts) {
2345
+ const [g, r] = gridR(p);
2346
+ const mask = P.alpha(p);
2347
+ const bot = p.y + p.h;
2348
+ const y0 = hip[1] + 20;
2349
+ const { segs, hangs } = skirtFrame(p, mask);
2350
+ if (hangs !== null) notes.push(hangs);
2240
2351
  // Issue #364: with config.cloth.bottomwear, the recipe's trees hang from the hip in place of the three keyed chains.
2241
2352
  const skirtRecipe = recipeOf('bottomwear');
2242
2353
  if (skirtRecipe !== null) {
2243
2354
  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
- });
2355
+ const fixed = [...segs];
2356
+ const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix));
2357
+ if (p === namedSkirt) dressDrafted = dressTail(p, fixed, trees);
2254
2358
  for (const t of trees) segs.push(t.chain);
2255
2359
  let swing: { bone: string; rest: number } | null = null;
2256
2360
  if (skirtRecipe.swing !== undefined) {
@@ -2283,7 +2387,7 @@ export function proposeWithBasis(
2283
2387
  p === namedSkirt ? p.from : undefined,
2284
2388
  ),
2285
2389
  );
2286
- meshes[p.name] = { grid: g, r, segments: segs };
2390
+ meshes[p.name] = { grid: g, r: skirtRecipe.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
2287
2391
  continue;
2288
2392
  }
2289
2393
  for (const [tag, f, ph] of [
@@ -2308,6 +2412,28 @@ export function proposeWithBasis(
2308
2412
  }
2309
2413
  meshes[p.name] = { grid: g, r, segments: segs };
2310
2414
  }
2415
+ // Issue #416: each rider's mesh takes the dress's spans its pixels over the dress's art fall nearest — a link by its
2416
+ // bone, a fixed segment written out as the dress's mesh writes it — so every vertex weights by the rule the dress's
2417
+ // own art does, and the note names each span with its pixel count.
2418
+ for (const q of riders) {
2419
+ const tail = dressDrafted as Tail | null;
2420
+ if (tail === null) break;
2421
+ const { o } = overLinks(tail, q);
2422
+ const segments: Segment[] = [];
2423
+ const links: string[] = [];
2424
+ const on: string[] = [];
2425
+ tail.spans.forEach((sp, k) => {
2426
+ if (o.counts[k] === 0) return;
2427
+ on.push(`${sp.link ? sp.bone : `${sp.bone}'s fixed segment`} ${o.counts[k]}`);
2428
+ segments.push(sp.link ? sp.bone : [sp.bone, sp.a, sp.b]);
2429
+ if (sp.link) links.push(sp.bone);
2430
+ });
2431
+ const [g, r] = gridR(q);
2432
+ meshes[q.name] = { grid: g, r, segments };
2433
+ notes.push(
2434
+ `${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)`,
2435
+ );
2436
+ }
2311
2437
 
2312
2438
  // ---- legs (issue #241)
2313
2439
  // Only with joints: a legwear part whose art holds the ankle of a leg whose hip, knee and ankle are all given is
@@ -2451,7 +2577,7 @@ export function proposeWithBasis(
2451
2577
  }
2452
2578
  const constrained = clothConstraints.length === 0 ? {} : { constraints: clothConstraints };
2453
2579
  return {
2454
- proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, notes },
2580
+ proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, proposed_rig_scale: P.rigScale, notes },
2455
2581
  basis: { frame, bones: records },
2456
2582
  };
2457
2583
  }
@@ -3243,7 +3369,45 @@ export interface ChainNotAdvancingFinding {
3243
3369
  /** 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
3370
  export type SqueezeLintFinding = { kind: 'squeeze' } & SqueezeFinding;
3245
3371
 
3246
- export type LintFinding = OffArtFinding | OffLockFinding | HipAboveChestFinding | HipTooHighFinding | ChestNotBetweenFinding | HipNotPastChestFinding | HipNearerNeckFinding | ChainNotAdvancingFinding | SqueezeLintFinding;
3372
+ /**
3373
+ * Issue #400: a hand layer (`handwear-r` or `handwear-l`, un-cut) whose side's wrist keypoint is placed is held to at
3374
+ * least this share of the other hand layer's opaque pixel count (`ceil(HAND_MIN_OF_OTHER * other.opaque_px)`), or, when
3375
+ * the other side has no hand layer and the side's elbow and wrist keypoints are placed, to a square of the forearm's
3376
+ * width (`ceil(width²)`). On issue #400's overlapping-hands decomposition, See-through put 1,923 px into `handwear_l`
3377
+ * and 123 px of finger slivers into `handwear_r` (6.4 % of the other hand, below `ceil(0.25 * 1923) = 481` px); on two
3378
+ * separate hands (`standing` 1,200 / 1,200, `seated` 816 / 816, `lying` 1,120 / 1,120) the two layers are equal.
3379
+ */
3380
+ export const HAND_MIN_OF_OTHER = 0.25;
3381
+
3382
+ /**
3383
+ * Issue #400: a hand layer holding fewer opaque pixels than the bar derived from the other hand layer or from the
3384
+ * forearm's width, while its side's wrist keypoint says a hand is there.
3385
+ */
3386
+ export interface NearEmptyHandFinding {
3387
+ kind: 'near-empty-hand';
3388
+ part: string;
3389
+ from: string;
3390
+ side: 'r' | 'l';
3391
+ wrist: KeypointName;
3392
+ wristAt: [number, number];
3393
+ opaquePx: number;
3394
+ bar: number;
3395
+ basis:
3396
+ | { kind: 'other-hand'; otherPart: string; otherFrom: string; otherPx: number; share: number }
3397
+ | { kind: 'forearm-width'; width: number };
3398
+ }
3399
+
3400
+ export type LintFinding =
3401
+ | OffArtFinding
3402
+ | OffLockFinding
3403
+ | HipAboveChestFinding
3404
+ | HipTooHighFinding
3405
+ | ChestNotBetweenFinding
3406
+ | HipNotPastChestFinding
3407
+ | HipNearerNeckFinding
3408
+ | ChainNotAdvancingFinding
3409
+ | SqueezeLintFinding
3410
+ | NearEmptyHandFinding;
3247
3411
 
3248
3412
  /** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
3249
3413
  export interface LintBasis {
@@ -3263,6 +3427,8 @@ export interface LintResult {
3263
3427
  missingTorsoBones: string[];
3264
3428
  /** 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
3429
  squeezeUnread: string[];
3430
+ /** Issue #403: sibling branch pairs on a fork whose cell-boundary weights and spring strengths are unheld. */
3431
+ branchSqueezes: BranchSqueezeFinding[];
3266
3432
  /** Present only under `--keypoints`: the rule set that read the torso and the chains. */
3267
3433
  basis?: LintBasis;
3268
3434
  /** 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 +3636,68 @@ export function lint(
3470
3636
  }
3471
3637
  }
3472
3638
  }
3639
+ // Issue #400: when a hand layer holds fewer opaque pixels than the bar derived from the other hand layer, or from the
3640
+ // forearm's width when the other side has no hand layer, while the wrist keypoint says a hand is there.
3641
+ if (joints !== undefined) {
3642
+ const handOf = (s: 'r' | 'l'): PartRecord | null => {
3643
+ const ps = P.byTag(`handwear-${s}`).filter((p) => p.limb === undefined);
3644
+ return ps.length === 1 ? ps[0] : null;
3645
+ };
3646
+ for (const s of ['r', 'l'] as const) {
3647
+ const wrist: KeypointName = `${s}_wrist`;
3648
+ const wristAt = joints.joints[wrist].at;
3649
+ const part = handOf(s);
3650
+ if (wristAt === null || part === null) continue;
3651
+ const other = handOf(s === 'r' ? 'l' : 'r');
3652
+ if (other !== null) {
3653
+ const bar = Math.ceil(HAND_MIN_OF_OTHER * other.opaque_px);
3654
+ if (part.opaque_px < bar) {
3655
+ findings.push({
3656
+ kind: 'near-empty-hand',
3657
+ part: part.name,
3658
+ from: part.from,
3659
+ side: s,
3660
+ wrist,
3661
+ wristAt: [wristAt[0], wristAt[1]],
3662
+ opaquePx: part.opaque_px,
3663
+ bar,
3664
+ basis: { kind: 'other-hand', otherPart: other.name, otherFrom: other.from, otherPx: other.opaque_px, share: HAND_MIN_OF_OTHER },
3665
+ });
3666
+ }
3667
+ continue;
3668
+ }
3669
+ const elAt = joints.joints[`${s}_elbow`].at;
3670
+ if (elAt === null) continue;
3671
+ const dx = wristAt[0] - elAt[0];
3672
+ const dy = wristAt[1] - elAt[1];
3673
+ const len = edgeLength(dx, dy);
3674
+ if (len === 0) continue;
3675
+ const armMasks = [...P.byTag('topwear'), part].map((p) => P.alpha(p));
3676
+ 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);
3677
+ const mid: [number, number] = [(elAt[0] + wristAt[0]) / 2, (elAt[1] + wristAt[1]) / 2];
3678
+ const axis: [number, number] = [dx / len, dy / len];
3679
+ 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]);
3680
+ if (w === null) continue;
3681
+ const bar = Math.ceil(w * w);
3682
+ if (part.opaque_px < bar) {
3683
+ findings.push({
3684
+ kind: 'near-empty-hand',
3685
+ part: part.name,
3686
+ from: part.from,
3687
+ side: s,
3688
+ wrist,
3689
+ wristAt: [wristAt[0], wristAt[1]],
3690
+ opaquePx: part.opaque_px,
3691
+ bar,
3692
+ basis: { kind: 'forearm-width', width: w },
3693
+ });
3694
+ }
3695
+ }
3696
+ }
3473
3697
  // Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
3474
3698
  // 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 }])));
3699
+ 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 }]));
3700
+ const sq = chainSqueezes({ bones: spec.bones, meshes: spec.meshes, motion: spec.motion }, partImages);
3476
3701
  for (const { mesh, a, b } of sq.read) {
3477
3702
  for (const [c, other] of [[a, b], [b, a]]) {
3478
3703
  const e = spec.bones.find((x) => 'chain' in x && x.chain === c);
@@ -3481,10 +3706,11 @@ export function lint(
3481
3706
  }
3482
3707
  for (const f of sq.findings) findings.push({ kind: 'squeeze', ...f });
3483
3708
  const squeezeUnread = sq.unread;
3709
+ const branchSq = branchSqueezes({ bones: spec.bones, meshes: spec.meshes, constraints: spec.constraints }, partImages);
3484
3710
  const coverage = lintCoverage(spec, byName, read, findings, unknownMeshes, basis);
3485
3711
  return basis === undefined
3486
- ? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, coverage }
3487
- : { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, basis, coverage };
3712
+ ? { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, branchSqueezes: branchSq, coverage }
3713
+ : { findings, unknownMeshes, lockUnread, missingTorsoBones, squeezeUnread, branchSqueezes: branchSq, basis, coverage };
3488
3714
  }
3489
3715
 
3490
3716
  /** 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 +3729,8 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
3503
3729
  return ['chest'];
3504
3730
  case 'squeeze':
3505
3731
  return [...new Set(f.vertices.map((v) => v.weights[0]?.bone).filter((n): n is string => n !== undefined))];
3732
+ case 'near-empty-hand':
3733
+ return [];
3506
3734
  case 'chain-not-advancing': {
3507
3735
  if (!f.point.endsWith(' tip')) return [f.point];
3508
3736
  const chain = f.point.slice(0, -' tip'.length);
@@ -3670,6 +3898,13 @@ export function lintLine(f: LintFinding): string {
3670
3898
  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
3899
  case 'squeeze':
3672
3900
  return squeezeLine(f);
3901
+ case 'near-empty-hand': {
3902
+ const why =
3903
+ f.basis.kind === 'other-hand'
3904
+ ? `${f.basis.share} of ${f.basis.otherPart} (${f.basis.otherFrom}), ${f.basis.otherPx} px`
3905
+ : `forearm width ${pyFixed(f.basis.width, 1)} px squared`;
3906
+ 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`;
3907
+ }
3673
3908
  case 'chain-not-advancing':
3674
3909
  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
3910
  }
@@ -3704,6 +3939,23 @@ function squeezeLine(f: SqueezeLintFinding): string {
3704
3939
  );
3705
3940
  }
3706
3941
 
3942
+ /**
3943
+ * Issue #403: the note line naming a branch tip's squeezed triangle, the bones (`a` and `b` forked from `parent`),
3944
+ * the mechanism (`the weights where two branches' cells meet` and, when present, unequal spring strength on the fork),
3945
+ * and the setting (`split.hold_tip: true`) that holds the tip.
3946
+ */
3947
+ export function branchSqueezeLine(f: BranchSqueezeFinding): string {
3948
+ const stiff =
3949
+ f.strengthA !== null && f.strengthB !== null && f.strengthA !== f.strengthB
3950
+ ? ` and unequal spring strength on the fork (${f.a} ${f.strengthA} vs ${f.b} ${f.strengthB})`
3951
+ : '';
3952
+ return (
3953
+ `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) ` +
3954
+ `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} — ` +
3955
+ `set split.hold_tip: true to hold the branch tips (keeps branch strength across the fork and raises r to grid)`
3956
+ );
3957
+ }
3958
+
3707
3959
  export interface Comparison {
3708
3960
  rows: Array<[string, number]>;
3709
3961
  onlyConfig: string[];