@rig-c/parts 1.26.0 → 1.28.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
@@ -148,7 +148,7 @@ import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER
148
148
  import { edgeLength } from './length.ts';
149
149
  import { limbWidth } from './limbs.ts';
150
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';
151
+ import { RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, spanCrosses, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
152
152
  import { TEXTURE_STRETCH_CEILING } from './check.ts';
153
153
 
154
154
  // ---------------------------------------------------------------------------
@@ -1377,10 +1377,31 @@ export function proposeWithBasis(
1377
1377
  // Issue #364: the trees a cloth recipe drafts, their constraints, and the tags it was used on.
1378
1378
  const clothConstraints: ConfigConstraint[] = [];
1379
1379
  const clothUsed = new Set<ClothTarget>();
1380
+ const multiPartClaimed = new Set<string>();
1380
1381
  const recipeOf = (tag: ClothTarget): ClothRecipe | null => cloth?.[tag] ?? null;
1382
+ const declaredPartsOf = (tag: ClothTarget): { field: 'parts' | 'part'; list: PartRecord[] } | null => {
1383
+ const rcp = recipeOf(tag);
1384
+ if (rcp === null) return null;
1385
+ const raw = rcp.parts !== undefined ? { field: 'parts' as const, names: rcp.parts } : Array.isArray(rcp.part) ? { field: 'part' as const, names: rcp.part } : null;
1386
+ if (raw === null) return null;
1387
+ const object = `config.cloth.${JSON.stringify(tag)}.${raw.field}`;
1388
+ const list: PartRecord[] = [];
1389
+ for (const name of raw.names) {
1390
+ const q = P.recs.find((x) => x.name === name);
1391
+ if (q === undefined) {
1392
+ throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object, detail: `names "${name}", which is no part in parts.json; every part in the list must exist in parts.json` }]);
1393
+ }
1394
+ if (splitFrom(q.from)[0] === PAINTING_RUN) {
1395
+ throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object, detail: `names "${name}", from ${q.from}; a painting patch has no layer tag and cannot be part of a cloth tree` }]);
1396
+ }
1397
+ list.push(q);
1398
+ }
1399
+ return { field: raw.field, list };
1400
+ };
1381
1401
  // Issue #391: the tails the back hair recipe drafts, each with its mesh's spans, which an accessory wrapped round one rides.
1382
1402
  const tails: Tail[] = [];
1383
- const T = (p: PartRecord, tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
1403
+ const T = (p: PartRecord | readonly PartRecord[], tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
1404
+ const partNames = Array.isArray(p) ? p.map((x) => x.name) : [(p as PartRecord).name];
1384
1405
  const trees = draftTrees(input, r);
1385
1406
  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))];
1386
1407
  for (const t of trees) {
@@ -1389,8 +1410,8 @@ export function proposeWithBasis(
1389
1410
  t.parent,
1390
1411
  t.points,
1391
1412
  t.tip,
1392
- t.why.map((w) => placement('mask', w, { parts: [p.name], bones: [input.parent], constants })),
1393
- placement('mask', t.tipWhy, { parts: [p.name], bones: [input.parent], constants }),
1413
+ t.why.map((w) => placement('mask', w, { parts: partNames, bones: [input.parent], constants })),
1414
+ placement('mask', t.tipWhy, { parts: partNames, bones: [input.parent], constants }),
1394
1415
  );
1395
1416
  }
1396
1417
  clothUsed.add(tag);
@@ -1521,7 +1542,225 @@ export function proposeWithBasis(
1521
1542
  };
1522
1543
 
1523
1544
  // ---- back hair
1545
+ const backHairParts = declaredPartsOf('back hair');
1546
+ const backHairSingleSame =
1547
+ backHairParts !== null &&
1548
+ backHairParts.list.length === 1 &&
1549
+ P.headFirst('back hair').length === 1 &&
1550
+ backHairParts.list[0] === P.headFirst('back hair')[0] &&
1551
+ SIDES.every(([s]) => {
1552
+ const m = P.alpha(backHairParts.list[0]);
1553
+ for (let y = 0; y < m.height; y++) {
1554
+ for (let x = 0; x < m.width; x++) {
1555
+ if ((s === 'r' ? x < axis : x >= axis) && m.data[y * m.width + x] !== 0) return true;
1556
+ }
1557
+ }
1558
+ return false;
1559
+ });
1560
+ if (backHairSingleSame) {
1561
+ const p0 = backHairParts.list[0];
1562
+ if (p0.y + p0.h < neckY + 0.5 * fh) {
1563
+ throw new PartsError([
1564
+ {
1565
+ code: 'PROPOSE_CLOTH_PART',
1566
+ object: `config.cloth."back hair".${backHairParts.field}`,
1567
+ detail: `names "${p0.name}" (${p0.from}, box y=${p0.y}..${p0.y + p0.h}), which ends above the shoulders (y=${pyFixed(neckY + 0.5 * fh, 1)}), where the proposer drafts a bun bone with no tree links — no tree link crosses "${p0.name}"`,
1568
+ },
1569
+ ]);
1570
+ }
1571
+ } else if (backHairParts !== null) {
1572
+ const { field, list } = backHairParts;
1573
+ const object = `config.cloth."back hair".${field}`;
1574
+ const rawRcp = recipeOf('back hair') as ClothRecipe;
1575
+ const p0 = list[0];
1576
+ const m0 = P.alpha(p0);
1577
+ const shoulderLine = neckY + 0.5 * fh;
1578
+ const sideHasBelow = (m: Mask, s: 'r' | 'l', yMin: number): boolean => {
1579
+ const r0 = Math.max(0, Math.ceil(yMin));
1580
+ for (let y = r0; y < m.height; y++) {
1581
+ for (let x = 0; x < m.width; x++) {
1582
+ if ((s === 'r' ? x < axis : x >= axis) && m.data[y * m.width + x] !== 0) return true;
1583
+ }
1584
+ }
1585
+ return false;
1586
+ };
1587
+ const activeSides = SIDES.filter(([s]) => list.some((q) => sideHasBelow(P.alpha(q), s, shoulderLine)));
1588
+ if (activeSides.length === 0) {
1589
+ throw new PartsError([
1590
+ {
1591
+ code: 'PROPOSE_CLOTH_PART',
1592
+ object,
1593
+ detail: `names "${p0.name}" (${p0.from}, box y=${p0.y}..${p0.y + p0.h}), which has no art below the shoulders (y=${pyFixed(shoulderLine, 1)}) — no back hair tree link crosses "${p0.name}"`,
1594
+ },
1595
+ ]);
1596
+ }
1597
+ const unionMask = newMask(P.W, P.H);
1598
+ unionMask.data.set(m0.data);
1599
+ for (let idx = 1; idx < list.length; idx++) {
1600
+ const mq = P.alpha(list[idx]);
1601
+ for (const [s] of activeSides) {
1602
+ for (let y = 0; y < P.H; y++) {
1603
+ let p0HasRow = false;
1604
+ for (let x = 0; x < P.W; x++) {
1605
+ if ((s === 'r' ? x < axis : x >= axis) && m0.data[y * P.W + x] !== 0) {
1606
+ p0HasRow = true;
1607
+ break;
1608
+ }
1609
+ }
1610
+ if (p0HasRow) continue;
1611
+ for (let x = 0; x < P.W; x++) {
1612
+ if ((s === 'r' ? x < axis : x >= axis) && mq.data[y * P.W + x] !== 0) {
1613
+ unionMask.data[y * P.W + x] = 1;
1614
+ }
1615
+ }
1616
+ }
1617
+ }
1618
+ }
1619
+ const uRows = rowsAny(unionMask);
1620
+ const top = uRows.length > 0 ? uRows[0] : p0.y;
1621
+ const bot = uRows.length > 0 ? uRows[uRows.length - 1] + 1 : p0.y + p0.h;
1622
+ const [g, r] = gridR(p0);
1623
+ const dilated = dilate(unionMask, 31);
1624
+ const y0 = pyRound(eyeY);
1625
+ const sideOpaqueWidth = (s: 'r' | 'l'): number => {
1626
+ if (y0 < 0 || y0 >= unionMask.height) return 0;
1627
+ let w = 0;
1628
+ for (let x = 0; x < unionMask.width; x++) {
1629
+ if ((s === 'r' ? x < axis : x >= axis) && unionMask.data[y0 * unionMask.width + x]) w++;
1630
+ }
1631
+ return w;
1632
+ };
1633
+ const prefixOf = (s: 'r' | 'l'): string => `hairback_${s}`;
1634
+ const offRootsAt = (cnt: number, s: 'r' | 'l'): Array<{ bone: string; at: Point; alpha: number }> => {
1635
+ const pref = prefixOf(s);
1636
+ const im = P.images.get(p0.name) as Raster;
1637
+ const off: Array<{ bone: string; at: Point; alpha: number }> = [];
1638
+ for (let k = 0; k < cnt; k++) {
1639
+ const fc = (k + 0.5) / cnt;
1640
+ const rx = pyRound(bandX(unionMask, eyeY, { frac: fc, side: s, axis }) ?? axis);
1641
+ if (!isOn(dilated, rx, y0)) {
1642
+ const lx = rx - p0.x;
1643
+ const ly = y0 - p0.y;
1644
+ const alpha = lx >= 0 && lx < p0.w && ly >= 0 && ly < p0.h ? im.data[(ly * p0.w + lx) * 4 + 3] : 0;
1645
+ off.push({ bone: `${pref}${k}_0`, at: [rx, y0], alpha });
1646
+ }
1647
+ }
1648
+ return off;
1649
+ };
1650
+ const sideWidths = activeSides.map(([s]) => sideOpaqueWidth(s));
1651
+ const widthCap = Math.max(1, Math.min(...sideWidths.map((w) => Math.floor(w / g))));
1652
+ let maxFit = 1;
1653
+ for (let cnt = widthCap; cnt >= 1; cnt--) {
1654
+ if (activeSides.every(([s]) => offRootsAt(cnt, s).length === 0)) {
1655
+ maxFit = cnt;
1656
+ break;
1657
+ }
1658
+ }
1659
+ if (rawRcp.chains !== undefined) {
1660
+ const offRoots = activeSides.flatMap(([s]) => offRootsAt(rawRcp.chains as number, s));
1661
+ if (rawRcp.chains > widthCap || offRoots.length > 0) {
1662
+ const wR = sideOpaqueWidth('r');
1663
+ const wL = sideOpaqueWidth('l');
1664
+ throw new PartsError([
1665
+ {
1666
+ code: 'PROPOSE_CLOTH_BACK_HAIR_CHAINS',
1667
+ object: 'config.cloth."back hair".chains',
1668
+ detail:
1669
+ `declares ${rawRcp.chains} tree(s) per side on "${p0.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` +
1670
+ (offRoots.length > 0 ? `; off the art: ${offRoots.map((o) => `${o.bone} at [${o.at[0]}, ${o.at[1]}] (alpha ${o.alpha})`).join(', ')}` : '') +
1671
+ `) — set chains to at most ${maxFit}, or leave chains out to derive it from the tail's width`,
1672
+ },
1673
+ ]);
1674
+ }
1675
+ }
1676
+ const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
1677
+ const all: TreeChain[] = [];
1678
+ let n = 0;
1679
+ const partNamesLabel = list.map((q) => q.name).join(' -> ');
1680
+ for (const [side] of activeSides) {
1681
+ const prefix = prefixOf(side);
1682
+ const trees = T(list, 'back hair', rcp, {
1683
+ prefix,
1684
+ parent: 'head',
1685
+ rootY: eyeY,
1686
+ tipY: bot - 4,
1687
+ xAt: (y, f) => bandX(unionMask, y, { frac: f, side, axis }),
1688
+ fallbackX: () => axis,
1689
+ frame: `${partNamesLabel}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`,
1690
+ edges: side,
1691
+ });
1692
+ const made = treeConstraints(trees, rcp, null);
1693
+ clothConstraints.push(...made);
1694
+ n += made.length;
1695
+ all.push(...trees);
1696
+ }
1697
+ const spans: Array<TailSpan & { depth: number }> = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false, depth: -1 }];
1698
+ for (const t of all) {
1699
+ const line = [...t.points, t.tip];
1700
+ t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true, depth: t.depths[i] }));
1701
+ }
1702
+ let prevMinD = -1;
1703
+ let prevMaxD = -1;
1704
+ const linkSummary: string[] = [];
1705
+ for (let idx = 0; idx < list.length; idx++) {
1706
+ const q = list[idx];
1707
+ const mq = P.alpha(q);
1708
+ const crossedLinks = spans.filter((sp) => sp.link && spanCrosses(sp, mq) > 0);
1709
+ if (crossedLinks.length === 0) {
1710
+ const linkDesc = spans
1711
+ .filter((sp) => sp.link)
1712
+ .map((sp) => `${sp.bone} [${sp.a[0]}, ${sp.a[1]}]->[${sp.b[0]}, ${sp.b[1]}]`)
1713
+ .join(', ');
1714
+ throw new PartsError([
1715
+ {
1716
+ code: 'PROPOSE_CLOTH_PART',
1717
+ object,
1718
+ detail: `names "${q.name}" (${q.from}, box x=${q.x}..${q.x + q.w}, y=${q.y}..${q.y + q.h}), which no link of the drafted tree crosses (${linkDesc}: 0 px on "${q.name}") — every part in ${field} must be crossed by at least one link of the tree`,
1719
+ },
1720
+ ]);
1721
+ }
1722
+ const minD = Math.min(...crossedLinks.map((sp) => sp.depth));
1723
+ const maxD = Math.max(...crossedLinks.map((sp) => sp.depth));
1724
+ if (idx > 0 && (minD < prevMinD || maxD < prevMaxD)) {
1725
+ throw new PartsError([
1726
+ {
1727
+ code: 'PROPOSE_CLOTH_PART',
1728
+ object,
1729
+ detail: `names "${q.name}" after "${list[idx - 1].name}", and "${q.name}" is crossed at link depth ${minD}..${maxD} while "${list[idx - 1].name}" is crossed at depth ${prevMinD}..${prevMaxD} — list parts in order from root to tip`,
1730
+ },
1731
+ ]);
1732
+ }
1733
+ prevMinD = minD;
1734
+ prevMaxD = maxD;
1735
+ const [gq, rq] = q === p0 ? [g, r] : gridR(q);
1736
+ const qSegs: Segment[] = [];
1737
+ const crossedDesc: string[] = [];
1738
+ for (const sp of spans) {
1739
+ const px = spanCrosses(sp, mq);
1740
+ if (px > 0) {
1741
+ qSegs.push(sp.link ? sp.bone : [sp.bone, sp.a, sp.b]);
1742
+ if (sp.link) crossedDesc.push(`${sp.bone} (${px} px)`);
1743
+ }
1744
+ }
1745
+ linkSummary.push(`${q.name}: ${crossedDesc.join(', ')}`);
1746
+ delete regions[q.name];
1747
+ meshes[q.name] = { grid: gq, r: rcp.split?.hold_tip === true ? Math.max(rq, gq) : rq, segments: qSegs };
1748
+ multiPartClaimed.add(q.name);
1749
+ }
1750
+ notes.push(
1751
+ clothNote(
1752
+ partNamesLabel,
1753
+ 'back hair',
1754
+ all,
1755
+ rcp,
1756
+ n,
1757
+ ` across ${list.length} parts in root-to-tip order (${linkSummary.join('; ')}), from head, rooted on the eye line and tipped 4 px above ${list[list.length - 1].name}'s bottom`,
1758
+ ),
1759
+ );
1760
+ tails.push({ part: p0.name, mask: unionMask, spans });
1761
+ }
1524
1762
  for (const p of P.headFirst('back hair')) {
1763
+ if (multiPartClaimed.has(p.name)) continue;
1525
1764
  const [g, r] = gridR(p);
1526
1765
  const top = p.y;
1527
1766
  const bot = p.y + p.h;
@@ -1536,7 +1775,7 @@ export function proposeWithBasis(
1536
1775
  );
1537
1776
  meshes[p.name] = { grid: g, r, segments: [['head', rnd(head), rnd([axis, top + 13])], 'bun'] };
1538
1777
  tracks.push({ bone: 'bun', prop: 'rotate', amp: 0.8, period: 4.0, phase: 0.2 });
1539
- } else if (recipeOf('back hair') !== null) {
1778
+ } else if (recipeOf('back hair') !== null && (backHairParts === null || backHairSingleSame)) {
1540
1779
  // Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
1541
1780
  // 4 px above the part's bottom, in place of the two keyed chains.
1542
1781
  // Issue #390: the tree count per side derives from the tail's opaque width at the root row (`floor(width / grid)`
@@ -1660,6 +1899,7 @@ export function proposeWithBasis(
1660
1899
  const turned = 'none' in roll ? 'on the page' : `along the head's down axis (the eye line's perpendicular, roll ${pyFixed(roll.degrees, 3)} degrees)`;
1661
1900
  const rollBones = 'none' in roll ? [] : ['eye_r', 'eye_l'];
1662
1901
  for (const p of P.headFirst('front hair')) {
1902
+ if (multiPartClaimed.has(p.name)) continue;
1663
1903
  const [g, r] = gridR(p);
1664
1904
  const mask = P.alpha(p);
1665
1905
  const top = p.y;
@@ -1805,7 +2045,7 @@ export function proposeWithBasis(
1805
2045
  // Every part with the tag (a plan may take both runs' layers): the biggest
1806
2046
  // gets the bone and chain, the rest ride the head rigidly. A stable sort,
1807
2047
  // as Python's `sorted` is.
1808
- const cands = [...P.byTag(tag)].sort((a, b) => b.opaque_px - a.opaque_px);
2048
+ const cands = [...P.byTag(tag)].filter((q) => !multiPartClaimed.has(q.name)).sort((a, b) => b.opaque_px - a.opaque_px);
1809
2049
  // Issue #391: a layer of the tag lying on a tail the back hair recipe drafted rides the links it overlaps — the
1810
2050
  // spans of the tail its pixels over the tail's art fall nearest — and not head: its mesh names those bones and no
1811
2051
  // others, so every vertex weights by the rule the tail's own art does. The links are read off the overlap, never
@@ -2173,31 +2413,65 @@ export function proposeWithBasis(
2173
2413
 
2174
2414
  // Issue #402: the topwear part config.cloth.bottomwear.part names takes the skirt's rule and recipe below; every
2175
2415
  // other topwear shaped like a skirt is named in a note when a recipe is in play, never drafted on by its shape.
2416
+ const bottomwearParts = declaredPartsOf('bottomwear');
2417
+ const bottomwearSingleSame =
2418
+ bottomwearParts !== null &&
2419
+ bottomwearParts.list.length === 1 &&
2420
+ bw.length === 1 &&
2421
+ bottomwearParts.list[0] === bw[0];
2176
2422
  const namedSkirt = ((): PartRecord | null => {
2177
- const name = recipeOf('bottomwear')?.part;
2178
- if (name === undefined) return null;
2179
- const refuse = (detail: string): never => {
2180
- throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object: 'config.cloth."bottomwear".part', detail }]);
2181
- };
2182
- const q = P.recs.find((x) => x.name === name);
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') {
2187
- return refuse(
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)"}`,
2189
- );
2423
+ const rawPart = recipeOf('bottomwear')?.part;
2424
+ if (typeof rawPart === 'string') {
2425
+ const name = rawPart;
2426
+ const refuse = (detail: string): never => {
2427
+ throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object: 'config.cloth."bottomwear".part', detail }]);
2428
+ };
2429
+ const q = P.recs.find((x) => x.name === name);
2430
+ 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`);
2431
+ // An explicit name may select a topwear cut piece; automatic tag rules still exclude pieces.
2432
+ const [run, tag] = splitFrom(q.from);
2433
+ if (run === PAINTING_RUN || tag !== 'topwear') {
2434
+ return refuse(
2435
+ `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)"}`,
2436
+ );
2437
+ }
2438
+ const line = hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh;
2439
+ if (q.y + q.h < line) {
2440
+ return refuse(`names "${name}", whose box ends at y=${q.y + q.h}, above hip + ${TOPWEAR_HANGS_FACE_HEIGHTS} face heights (y=${pyFixed(line, 1)}), where the topwear rule reads a bodice that stops at the waist: no skirt hangs below the hip to hang trees on`);
2441
+ }
2442
+ return q;
2190
2443
  }
2191
- const line = hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh;
2192
- if (q.y + q.h < line) {
2193
- return refuse(`names "${name}", whose box ends at y=${q.y + q.h}, above hip + ${TOPWEAR_HANGS_FACE_HEIGHTS} face heights (y=${pyFixed(line, 1)}), where the topwear rule reads a bodice that stops at the waist: no skirt hangs below the hip to hang trees on`);
2444
+ if (bottomwearParts !== null && bottomwearParts.list.length === 1 && !bottomwearSingleSame) {
2445
+ const q = bottomwearParts.list[0];
2446
+ const object = `config.cloth."bottomwear".${bottomwearParts.field}`;
2447
+ const [run, tag] = splitFrom(q.from);
2448
+ if (run === PAINTING_RUN || tag !== 'topwear') {
2449
+ throw new PartsError([
2450
+ {
2451
+ code: 'PROPOSE_CLOTH_PART',
2452
+ object,
2453
+ detail: `names "${q.name}" (${q.from}, box x=${q.x}..${q.x + q.w}, y=${q.y}..${q.y + q.h}), which no link of the skirt's trees crosses`,
2454
+ },
2455
+ ]);
2456
+ }
2457
+ const line = hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh;
2458
+ if (q.y + q.h < line) {
2459
+ throw new PartsError([
2460
+ {
2461
+ code: 'PROPOSE_CLOTH_PART',
2462
+ object,
2463
+ detail: `names "${q.name}", whose box ends at y=${q.y + q.h}, above hip + ${TOPWEAR_HANGS_FACE_HEIGHTS} face heights (y=${pyFixed(line, 1)}), where the topwear rule reads a bodice that stops at the waist: no skirt hangs below the hip to hang trees on`,
2464
+ },
2465
+ ]);
2466
+ }
2467
+ return q;
2194
2468
  }
2195
- return q;
2469
+ return null;
2196
2470
  })();
2197
2471
  const skirtHints = new Map<string, string>();
2198
2472
  if (cloth !== null) {
2199
2473
  for (const p of tw) {
2200
- if (p === namedSkirt) continue;
2474
+ if (p === namedSkirt || multiPartClaimed.has(p.name)) continue;
2201
2475
  const hint = skirtInsideNote(p, P.alpha(p), hip[1], fh);
2202
2476
  if (hint !== null) skirtHints.set(p.name, hint);
2203
2477
  }
@@ -2205,7 +2479,7 @@ export function proposeWithBasis(
2205
2479
 
2206
2480
  // ---- outer robe (topwear)
2207
2481
  for (const p of tw) {
2208
- if (p === namedSkirt) continue;
2482
+ if (p === namedSkirt || multiPartClaimed.has(p.name)) continue;
2209
2483
  const hint = skirtHints.get(p.name);
2210
2484
  if (hint !== undefined) notes.push(hint);
2211
2485
  const [g, r] = gridR(p);
@@ -2331,16 +2605,145 @@ export function proposeWithBasis(
2331
2605
  return { o, rides: tail.spans.some((sp, k) => sp.link && o.counts[k] > 0) };
2332
2606
  };
2333
2607
  const dressRecipe = recipeOf('bottomwear');
2608
+ if (bottomwearParts !== null && bottomwearParts.list.length >= 2 && dressRecipe !== null) {
2609
+ const { field, list } = bottomwearParts;
2610
+ const object = `config.cloth."bottomwear".${field}`;
2611
+ const p0 = list[0];
2612
+ const m0 = P.alpha(p0);
2613
+ const y0 = hip[1] + 20;
2614
+ const unionMask = newMask(P.W, P.H);
2615
+ unionMask.data.set(m0.data);
2616
+ for (let idx = 1; idx < list.length; idx++) {
2617
+ const mq = P.alpha(list[idx]);
2618
+ for (let y = p0.y; y < P.H; y++) {
2619
+ let p0HasRow = false;
2620
+ for (let x = 0; x < P.W; x++) {
2621
+ if (m0.data[y * P.W + x] !== 0) {
2622
+ p0HasRow = true;
2623
+ break;
2624
+ }
2625
+ }
2626
+ if (p0HasRow) continue;
2627
+ for (let x = 0; x < P.W; x++) {
2628
+ if (mq.data[y * P.W + x] !== 0) unionMask.data[y * P.W + x] = 1;
2629
+ }
2630
+ }
2631
+ }
2632
+ const uRows = rowsAny(unionMask);
2633
+ const bot = uRows.length > 0 ? uRows[uRows.length - 1] + 1 : p0.y + p0.h;
2634
+ if (bot - 4 <= y0) {
2635
+ throw new PartsError([
2636
+ {
2637
+ code: 'PROPOSE_CLOTH_PART',
2638
+ object,
2639
+ detail: `names "${p0.name}" (${p0.from}, box y=${p0.y}..${p0.y + p0.h}), which ends above the skirt root row (y=${pyFixed(y0, 1)}) — no skirt tree link crosses "${p0.name}"`,
2640
+ },
2641
+ ]);
2642
+ }
2643
+ const [g, r] = gridR(p0);
2644
+ const { segs: fixedSegs, hangs } = skirtFrame(p0, m0);
2645
+ if (hangs !== null) notes.push(hangs);
2646
+ const prefix = 'skirt';
2647
+ const partNamesLabel = list.map((q) => q.name).join(' -> ');
2648
+ const trees = T(list, 'bottomwear', dressRecipe, {
2649
+ ...skirtInput(p0, unionMask, prefix),
2650
+ tipY: bot - 4,
2651
+ frame: `${partNamesLabel}'s art (no art in the band: that share of ${p0.name}'s box width)`,
2652
+ });
2653
+ let swing: { bone: string; rest: number } | null = null;
2654
+ if (dressRecipe.swing !== undefined) {
2655
+ const cx = or(bandX(unionMask, y0, { frac: 0.5 }), p0.x + 0.5 * p0.w);
2656
+ const first = trees[0].points.length > 1 ? trees[0].points[1][1] : trees[0].tip[1];
2657
+ const at: [number, number] = [cx, y0];
2658
+ const tipAt: [number, number] = [cx, first];
2659
+ B(
2660
+ swingName(prefix),
2661
+ 'hip',
2662
+ at,
2663
+ placement('mask', `the centre of ${partNamesLabel}'s art in the 24-row band at the root row (20 px below hip's y)`, { parts: list.map((q) => q.name), bones: ['hip'], constants: [konst('share_across', 0.5)] }),
2664
+ tipAt,
2665
+ placement('derived', `straight down from the swing's origin to the row of the first tree's second link (one link long)`, { bones: [`${trees[0].chain}0`] }),
2666
+ );
2667
+ swing = { bone: swingName(prefix), rest: roundTo((Math.atan2(-(rnd(tipAt)[1] - rnd(at)[1]), rnd(tipAt)[0] - rnd(at)[0]) * 180) / Math.PI, 6) };
2668
+ }
2669
+ const made = treeConstraints(trees, dressRecipe, swing);
2670
+ clothConstraints.push(...made);
2671
+ const spans: Array<TailSpan & { depth: number }> = [];
2672
+ for (const s of fixedSegs) if (typeof s !== 'string') spans.push({ bone: s[0], a: s[1], b: s[2], link: false, depth: -1 });
2673
+ for (const t of trees) {
2674
+ const line = [...t.points, t.tip];
2675
+ t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true, depth: t.depths[i] }));
2676
+ }
2677
+ let prevMinD = -1;
2678
+ let prevMaxD = -1;
2679
+ const linkSummary: string[] = [];
2680
+ for (let idx = 0; idx < list.length; idx++) {
2681
+ const q = list[idx];
2682
+ const mq = P.alpha(q);
2683
+ const crossedLinks = spans.filter((sp) => sp.link && spanCrosses(sp, mq) > 0);
2684
+ if (crossedLinks.length === 0) {
2685
+ const linkDesc = spans
2686
+ .filter((sp) => sp.link)
2687
+ .map((sp) => `${sp.bone} [${sp.a[0]}, ${sp.a[1]}]->[${sp.b[0]}, ${sp.b[1]}]`)
2688
+ .join(', ');
2689
+ throw new PartsError([
2690
+ {
2691
+ code: 'PROPOSE_CLOTH_PART',
2692
+ object,
2693
+ detail: `names "${q.name}" (${q.from}, box x=${q.x}..${q.x + q.w}, y=${q.y}..${q.y + q.h}), which no link of the drafted tree crosses (${linkDesc}: 0 px on "${q.name}") — every part in ${field} must be crossed by at least one link of the tree`,
2694
+ },
2695
+ ]);
2696
+ }
2697
+ const minD = Math.min(...crossedLinks.map((sp) => sp.depth));
2698
+ const maxD = Math.max(...crossedLinks.map((sp) => sp.depth));
2699
+ if (idx > 0 && (minD < prevMinD || maxD < prevMaxD)) {
2700
+ throw new PartsError([
2701
+ {
2702
+ code: 'PROPOSE_CLOTH_PART',
2703
+ object,
2704
+ detail: `names "${q.name}" after "${list[idx - 1].name}", and "${q.name}" is crossed at link depth ${minD}..${maxD} while "${list[idx - 1].name}" is crossed at depth ${prevMinD}..${prevMaxD} — list parts in order from root to tip`,
2705
+ },
2706
+ ]);
2707
+ }
2708
+ prevMinD = minD;
2709
+ prevMaxD = maxD;
2710
+ const [gq, rq] = q === p0 ? [g, r] : gridR(q);
2711
+ const qSegs: Segment[] = idx === 0 ? [...fixedSegs] : [];
2712
+ const crossedDesc: string[] = [];
2713
+ for (const sp of spans) {
2714
+ if (!sp.link) continue;
2715
+ const px = spanCrosses(sp, mq);
2716
+ if (px > 0) {
2717
+ qSegs.push(sp.bone);
2718
+ crossedDesc.push(`${sp.bone} (${px} px)`);
2719
+ }
2720
+ }
2721
+ linkSummary.push(`${q.name}: ${crossedDesc.join(', ')}`);
2722
+ delete regions[q.name];
2723
+ meshes[q.name] = { grid: gq, r: dressRecipe.split?.hold_tip === true ? Math.max(rq, gq) : rq, segments: qSegs };
2724
+ multiPartClaimed.add(q.name);
2725
+ }
2726
+ notes.push(
2727
+ clothNote(
2728
+ partNamesLabel,
2729
+ 'bottomwear',
2730
+ trees,
2731
+ dressRecipe,
2732
+ made.length,
2733
+ `${swing === null ? '' : ` on one shared swing, ${swing.bone} (each tree's first link takes its rotation times ${dressRecipe.swing?.scales.join('/')}, its setup rotation ${swing.rest} degrees subtracted)`} across ${list.length} parts in root-to-tip order (${linkSummary.join('; ')}) from hip, rooted 20 px below its y and tipped 4 px above ${list[list.length - 1].name}'s bottom, laid down the screen`,
2734
+ ),
2735
+ );
2736
+ }
2334
2737
  const riders: PartRecord[] =
2335
2738
  namedSkirt === null || dressRecipe === null
2336
2739
  ? []
2337
2740
  : ((): PartRecord[] => {
2338
2741
  const mask = P.alpha(namedSkirt);
2339
2742
  const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
2340
- return bw.filter((q) => overLinks(tail, q).rides);
2743
+ return bw.filter((q) => !multiPartClaimed.has(q.name) && overLinks(tail, q).rides);
2341
2744
  })();
2342
2745
  let dressDrafted: Tail | null = null;
2343
- const skirts = namedSkirt === null ? bw : [...bw.filter((q) => !riders.includes(q)), namedSkirt];
2746
+ const skirts = (namedSkirt === null ? bw : [...bw.filter((q) => !riders.includes(q)), namedSkirt]).filter((q) => !multiPartClaimed.has(q.name));
2344
2747
  for (const p of skirts) {
2345
2748
  const [g, r] = gridR(p);
2346
2749
  const mask = P.alpha(p);
package/src/rig.ts CHANGED
@@ -165,13 +165,13 @@ import {
165
165
  import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
166
166
  import { type LocalInfluence, localInfluences } from './localweights.ts';
167
167
  import { artCoverage, ART_ALPHA, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
168
- import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
168
+ import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
169
169
  import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
170
170
  import { EYE_GROUP_TAGS } from './propose.ts';
171
171
  import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
172
172
  import { pyRound } from './round.ts';
173
173
  import { boneHeat, type HeatDust, heatInfluences, HEAT_LIMITS, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
174
- import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
174
+ import { applyFaceSurfaceDeform, TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
175
175
  import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
176
176
 
177
177
  /** Transparent pixels added round every part image — the reference's `PAD`. */
@@ -1996,6 +1996,17 @@ export function buildRig(
1996
1996
  // issue #239: the face's line, written only when the face bones were turned to one, so no other rig moves.
1997
1997
  const axes = [faceAxis.axis, faceAxis.tipAxis].flatMap((x) => (x === null ? [] : [x]));
1998
1998
  if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
1999
+ const faceImg = outImages.find(([k]) => k === 'face.png')?.[1];
2000
+ const facePart = parts.parts.find((p) => p.name === 'face');
2001
+ bindFaceSurfaceDeform(motion, (merged) => {
2002
+ applyFaceSurfaceDeform(rig, merged, {
2003
+ imageWidth: faceImg?.width,
2004
+ imageHeight: faceImg?.height,
2005
+ opaqueWidth: facePart?.w,
2006
+ opaqueHeight: facePart?.h,
2007
+ overrides: cfg.turn?.deform,
2008
+ });
2009
+ });
1999
2010
  return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis, turn: turned?.rows ?? null };
2000
2011
  }
2001
2012