@rig-c/parts 1.25.0 → 1.27.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_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
  // ---------------------------------------------------------------------------
@@ -278,20 +278,23 @@ export interface Proposal {
278
278
  constraints?: ConfigConstraint[];
279
279
  /** Only with `propose --full`/`--head` (issue #251, {@link draftTurn}); absent otherwise, so a proposal without them is the one written before it. */
280
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;
281
283
  notes: string[];
282
284
  }
283
285
 
284
286
  /**
285
287
  * Every section of a proposal that is a config section, in the order the
286
288
  * proposal writes them: what an author copies into the config (issue #375).
287
- * `notes` is the proposal's own and is copied nowhere. Two of them are written
288
- * only on request — `limbs` under `--keypoints`, `turn` under `--full`/`--head`
289
- * — and a `limbs` block left behind is refused at the next step, by the name of
290
- * every part it cuts. {@link checkProposal} reads the proposal through the config
291
- * loader by this list, the type below refuses a {@link Proposal} key it does not
292
- * 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.
293
296
  */
294
- 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'>>;
295
298
 
296
299
  /** A {@link Proposal} key that is neither `notes` nor in {@link PROPOSAL_CONFIG_SECTIONS}; `never` while the list is whole. */
297
300
  type UnlistedProposalSection = Exclude<keyof Proposal, 'notes' | (typeof PROPOSAL_CONFIG_SECTIONS)[number]>;
@@ -414,7 +417,7 @@ export function proposalAssemble(P: PartSet): Record<string, unknown> {
414
417
  const planned = run.filter((r) => r.cut_from === undefined && firstOf.get(r.from) === r.name);
415
418
  const pieces = run.filter((r) => r.cut_from !== undefined || firstOf.get(r.from) !== r.name);
416
419
  return {
417
- rig_scale: 1,
420
+ rig_scale: P.rigScale,
418
421
  plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
419
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' })),
420
423
  ...(pieces.length === 0
@@ -611,7 +614,7 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
611
614
  }
612
615
  turn = { pivot: turn.pivot, depths };
613
616
  }
614
- 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] };
615
618
  }
616
619
 
617
620
  // ---------------------------------------------------------------------------
@@ -622,12 +625,14 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
622
625
  export class PartSet {
623
626
  readonly W: number;
624
627
  readonly H: number;
628
+ readonly rigScale: number;
625
629
  readonly recs: readonly PartRecord[];
626
630
  readonly images: ReadonlyMap<string, Raster>;
627
631
  private readonly masks = new Map<string, Mask>();
628
632
 
629
633
  constructor(file: PartsFile, images: ReadonlyMap<string, Raster>) {
630
634
  [this.W, this.H] = file.rig_size;
635
+ this.rigScale = file.scale_rig_per_source;
631
636
  this.recs = file.parts;
632
637
  this.images = images;
633
638
  }
@@ -1372,20 +1377,41 @@ export function proposeWithBasis(
1372
1377
  // Issue #364: the trees a cloth recipe drafts, their constraints, and the tags it was used on.
1373
1378
  const clothConstraints: ConfigConstraint[] = [];
1374
1379
  const clothUsed = new Set<ClothTarget>();
1380
+ const multiPartClaimed = new Set<string>();
1375
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
+ };
1376
1401
  // Issue #391: the tails the back hair recipe drafts, each with its mesh's spans, which an accessory wrapped round one rides.
1377
1402
  const tails: Tail[] = [];
1378
- 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];
1379
1405
  const trees = draftTrees(input, r);
1380
- const constants = [konst('cloth_chains', r.chains), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
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))];
1381
1407
  for (const t of trees) {
1382
1408
  C(
1383
1409
  t.chain,
1384
1410
  t.parent,
1385
1411
  t.points,
1386
1412
  t.tip,
1387
- t.why.map((w) => placement('mask', w, { parts: [p.name], bones: [input.parent], constants })),
1388
- 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 }),
1389
1415
  );
1390
1416
  }
1391
1417
  clothUsed.add(tag);
@@ -1516,7 +1542,225 @@ export function proposeWithBasis(
1516
1542
  };
1517
1543
 
1518
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
+ }
1519
1762
  for (const p of P.headFirst('back hair')) {
1763
+ if (multiPartClaimed.has(p.name)) continue;
1520
1764
  const [g, r] = gridR(p);
1521
1765
  const top = p.y;
1522
1766
  const bot = p.y + p.h;
@@ -1531,16 +1775,72 @@ export function proposeWithBasis(
1531
1775
  );
1532
1776
  meshes[p.name] = { grid: g, r, segments: [['head', rnd(head), rnd([axis, top + 13])], 'bun'] };
1533
1777
  tracks.push({ bone: 'bun', prop: 'rotate', amp: 0.8, period: 4.0, phase: 0.2 });
1534
- } else if (recipeOf('back hair') !== null) {
1778
+ } else if (recipeOf('back hair') !== null && (backHairParts === null || backHairSingleSame)) {
1535
1779
  // Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
1536
1780
  // 4 px above the part's bottom, in place of the two keyed chains.
1537
- const rcp = recipeOf('back hair') as ClothRecipe;
1781
+ // Issue #390: the tree count per side derives from the tail's opaque width at the root row (`floor(width / grid)`
1782
+ // on each side, and every tree root inside the 15 px dilated art); a declared count that does not fit is refused
1783
+ // by name (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`).
1784
+ const rawRcp = recipeOf('back hair') as ClothRecipe;
1538
1785
  const mask = P.alpha(p);
1786
+ const dilated = dilate(mask, 31);
1787
+ const y0 = pyRound(eyeY);
1788
+ const sideOpaqueWidth = (s: 'r' | 'l'): number => {
1789
+ if (y0 < 0 || y0 >= mask.height) return 0;
1790
+ let w = 0;
1791
+ for (let x = 0; x < mask.width; x++) {
1792
+ if ((s === 'r' ? x < axis : x >= axis) && mask.data[y0 * mask.width + x]) w++;
1793
+ }
1794
+ return w;
1795
+ };
1796
+ const prefixOf = (s: 'r' | 'l'): string => (P.headFirst('back hair').length === 1 ? `hairback_${s}` : `hairback_${p.name}_${s}`);
1797
+ const offRootsAt = (cnt: number, s: 'r' | 'l'): Array<{ bone: string; at: Point; alpha: number }> => {
1798
+ const pref = prefixOf(s);
1799
+ const im = P.images.get(p.name) as Raster;
1800
+ const off: Array<{ bone: string; at: Point; alpha: number }> = [];
1801
+ for (let k = 0; k < cnt; k++) {
1802
+ const fc = (k + 0.5) / cnt;
1803
+ const rx = pyRound(bandX(mask, eyeY, { frac: fc, side: s, axis }) ?? axis);
1804
+ if (!isOn(dilated, rx, y0)) {
1805
+ const lx = rx - p.x;
1806
+ const ly = y0 - p.y;
1807
+ const alpha = lx >= 0 && lx < p.w && ly >= 0 && ly < p.h ? im.data[(ly * p.w + lx) * 4 + 3] : 0;
1808
+ off.push({ bone: `${pref}${k}_0`, at: [rx, y0], alpha });
1809
+ }
1810
+ }
1811
+ return off;
1812
+ };
1813
+ const wR = sideOpaqueWidth('r');
1814
+ const wL = sideOpaqueWidth('l');
1815
+ const widthCap = Math.max(1, Math.min(Math.floor(wR / g), Math.floor(wL / g)));
1816
+ let maxFit = 1;
1817
+ for (let cnt = widthCap; cnt >= 1; cnt--) {
1818
+ if (offRootsAt(cnt, 'r').length === 0 && offRootsAt(cnt, 'l').length === 0) {
1819
+ maxFit = cnt;
1820
+ break;
1821
+ }
1822
+ }
1823
+ if (rawRcp.chains !== undefined) {
1824
+ const offRoots = [...offRootsAt(rawRcp.chains, 'r'), ...offRootsAt(rawRcp.chains, 'l')];
1825
+ if (rawRcp.chains > widthCap || offRoots.length > 0) {
1826
+ throw new PartsError([
1827
+ {
1828
+ code: 'PROPOSE_CLOTH_BACK_HAIR_CHAINS',
1829
+ object: 'config.cloth."back hair".chains',
1830
+ detail:
1831
+ `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` +
1832
+ (offRoots.length > 0 ? `; off the art: ${offRoots.map((o) => `${o.bone} at [${o.at[0]}, ${o.at[1]}] (alpha ${o.alpha})`).join(', ')}` : '') +
1833
+ `) — set chains to at most ${maxFit}, or leave chains out to derive it from the tail's width`,
1834
+ },
1835
+ ]);
1836
+ }
1837
+ }
1838
+ const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
1539
1839
  const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
1540
1840
  const all: TreeChain[] = [];
1541
1841
  let n = 0;
1542
1842
  for (const [side] of SIDES) {
1543
- const prefix = P.headFirst('back hair').length === 1 ? `hairback_${side}` : `hairback_${p.name}_${side}`;
1843
+ const prefix = prefixOf(side);
1544
1844
  const trees = T(p, 'back hair', rcp, {
1545
1845
  prefix,
1546
1846
  parent: 'head',
@@ -1599,6 +1899,7 @@ export function proposeWithBasis(
1599
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)`;
1600
1900
  const rollBones = 'none' in roll ? [] : ['eye_r', 'eye_l'];
1601
1901
  for (const p of P.headFirst('front hair')) {
1902
+ if (multiPartClaimed.has(p.name)) continue;
1602
1903
  const [g, r] = gridR(p);
1603
1904
  const mask = P.alpha(p);
1604
1905
  const top = p.y;
@@ -1744,7 +2045,7 @@ export function proposeWithBasis(
1744
2045
  // Every part with the tag (a plan may take both runs' layers): the biggest
1745
2046
  // gets the bone and chain, the rest ride the head rigidly. A stable sort,
1746
2047
  // as Python's `sorted` is.
1747
- 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);
1748
2049
  // Issue #391: a layer of the tag lying on a tail the back hair recipe drafted rides the links it overlaps — the
1749
2050
  // spans of the tail its pixels over the tail's art fall nearest — and not head: its mesh names those bones and no
1750
2051
  // others, so every vertex weights by the rule the tail's own art does. The links are read off the overlap, never
@@ -2112,30 +2413,65 @@ export function proposeWithBasis(
2112
2413
 
2113
2414
  // Issue #402: the topwear part config.cloth.bottomwear.part names takes the skirt's rule and recipe below; every
2114
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];
2115
2422
  const namedSkirt = ((): PartRecord | null => {
2116
- const name = recipeOf('bottomwear')?.part;
2117
- if (name === undefined) return null;
2118
- const refuse = (detail: string): never => {
2119
- throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object: 'config.cloth."bottomwear".part', detail }]);
2120
- };
2121
- const q = P.recs.find((x) => x.name === name);
2122
- 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`);
2123
- 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`);
2124
- if (!tw.includes(q)) {
2125
- return refuse(
2126
- `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)"}`,
2127
- );
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;
2128
2443
  }
2129
- const line = hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh;
2130
- if (q.y + q.h < line) {
2131
- 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;
2132
2468
  }
2133
- return q;
2469
+ return null;
2134
2470
  })();
2135
2471
  const skirtHints = new Map<string, string>();
2136
2472
  if (cloth !== null) {
2137
2473
  for (const p of tw) {
2138
- if (p === namedSkirt) continue;
2474
+ if (p === namedSkirt || multiPartClaimed.has(p.name)) continue;
2139
2475
  const hint = skirtInsideNote(p, P.alpha(p), hip[1], fh);
2140
2476
  if (hint !== null) skirtHints.set(p.name, hint);
2141
2477
  }
@@ -2143,7 +2479,7 @@ export function proposeWithBasis(
2143
2479
 
2144
2480
  // ---- outer robe (topwear)
2145
2481
  for (const p of tw) {
2146
- if (p === namedSkirt) continue;
2482
+ if (p === namedSkirt || multiPartClaimed.has(p.name)) continue;
2147
2483
  const hint = skirtHints.get(p.name);
2148
2484
  if (hint !== undefined) notes.push(hint);
2149
2485
  const [g, r] = gridR(p);
@@ -2161,8 +2497,8 @@ export function proposeWithBasis(
2161
2497
  ['chest', rnd(along(hip, trunk.u, artExtent(mask, p, hip, trunk.u)[0])), rnd(along(hip, trunk.u, -20))],
2162
2498
  ['hip', rnd(along(hip, trunk.u, -20)), rnd(along(hip, trunk.u, 100))],
2163
2499
  ];
2164
- if (bw.length > 0 && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
2165
- // A bodice that stops at the waist: robe chains down to the hem would fall off the art.
2500
+ if ((bw.length > 0 || namedSkirt?.cut_from === p.name) && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
2501
+ // A bodice above bottomwear, including its explicitly named cut skirt: robe chains would fall off the art.
2166
2502
  meshes[p.name] = { grid: g, r, segments: segs };
2167
2503
  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`);
2168
2504
  continue;
@@ -2269,16 +2605,145 @@ export function proposeWithBasis(
2269
2605
  return { o, rides: tail.spans.some((sp, k) => sp.link && o.counts[k] > 0) };
2270
2606
  };
2271
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
+ }
2272
2737
  const riders: PartRecord[] =
2273
2738
  namedSkirt === null || dressRecipe === null
2274
2739
  ? []
2275
2740
  : ((): PartRecord[] => {
2276
2741
  const mask = P.alpha(namedSkirt);
2277
2742
  const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
2278
- return bw.filter((q) => overLinks(tail, q).rides);
2743
+ return bw.filter((q) => !multiPartClaimed.has(q.name) && overLinks(tail, q).rides);
2279
2744
  })();
2280
2745
  let dressDrafted: Tail | null = null;
2281
- 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));
2282
2747
  for (const p of skirts) {
2283
2748
  const [g, r] = gridR(p);
2284
2749
  const mask = P.alpha(p);
@@ -2515,7 +2980,7 @@ export function proposeWithBasis(
2515
2980
  }
2516
2981
  const constrained = clothConstraints.length === 0 ? {} : { constraints: clothConstraints };
2517
2982
  return {
2518
- proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, notes },
2983
+ 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 },
2519
2984
  basis: { frame, bones: records },
2520
2985
  };
2521
2986
  }