@rig-c/parts 1.27.0 → 1.29.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
@@ -146,9 +146,9 @@ import { configRoles, type RoleOf } from './structure.ts';
146
146
  import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
147
147
  import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
148
148
  import { edgeLength } from './length.ts';
149
- import { limbWidth } from './limbs.ts';
149
+ import { bonePlaces, limbCrossing, limbPolyline, 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, spanCrosses, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
151
+ import { backHairMasses, type BackHairMass, 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, type TreeMassInput } from './cloth.ts';
152
152
  import { TEXTURE_STRETCH_CEILING } from './check.ts';
153
153
 
154
154
  // ---------------------------------------------------------------------------
@@ -397,6 +397,7 @@ function konst(name: string, value: number): BasisConstant {
397
397
  * plan part and which as the piece (issue #230).
398
398
  */
399
399
  export function proposalAssemble(P: PartSet): Record<string, unknown> {
400
+ P = P.beforeLimbs();
400
401
  // A patch is not a plan entry; it goes back as the patches entry it came
401
402
  // from, so the loader holds it to its own rule (a region, never a mesh).
402
403
  // Its box, alpha and draw are the record's own box and placeholders the
@@ -492,6 +493,7 @@ export type TurnRuns = Partial<Record<'full' | 'head', LayerSet>>;
492
493
  * span away.
493
494
  */
494
495
  export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null; notes: string[] } {
496
+ P = P.beforeLimbs();
495
497
  const problems: Problem[] = [];
496
498
  const read: Array<{ part: string; run: 'full' | 'head'; tag: string; base: string | null; own: number; area: number; order: number; piece: boolean }> = [];
497
499
  const patches: string[] = [];
@@ -629,6 +631,7 @@ export class PartSet {
629
631
  readonly recs: readonly PartRecord[];
630
632
  readonly images: ReadonlyMap<string, Raster>;
631
633
  private readonly masks = new Map<string, Mask>();
634
+ private preLimbs: PartSet | null = null;
632
635
 
633
636
  constructor(file: PartsFile, images: ReadonlyMap<string, Raster>) {
634
637
  [this.W, this.H] = file.rig_size;
@@ -664,6 +667,170 @@ export class PartSet {
664
667
  return [...by.entries()].filter(([, names]) => names.length > 1).map(([from, parts]) => ({ from, parts }));
665
668
  }
666
669
 
670
+ /**
671
+ * Issue #440: limb records (`r.limb !== undefined`) that cannot be reassembled
672
+ * into their pre-limb source parts (`r.limb.of`) — a limb part handed to
673
+ * `propose` on its own or with an incomplete segment chain (fewer than two
674
+ * segments, a segment index missing from `0..n-1`, a lower segment without
675
+ * its `cap` record, or the source part `of` still present beside the limb
676
+ * parts). When every limb in `parts.json` is complete, returns `[]` and
677
+ * {@link beforeLimbs} reconstructs the pre-limb part set.
678
+ */
679
+ standaloneLimbParts(): PartRecord[] {
680
+ const limbed = this.recs.filter((r) => r.limb !== undefined);
681
+ if (limbed.length === 0) return [];
682
+ const nonLimbNames = new Set(this.recs.filter((r) => r.limb === undefined).map((r) => r.name));
683
+ const byLimb = new Map<string, PartRecord[]>();
684
+ for (const r of limbed) {
685
+ const l = r.limb as NonNullable<PartRecord['limb']>;
686
+ byLimb.set(l.name, [...(byLimb.get(l.name) ?? []), r]);
687
+ }
688
+ const badLimbs = new Set<string>();
689
+ const badOf = new Set<string>();
690
+ for (const [name, segs] of byLimb) {
691
+ const sorted = [...segs].sort((a, b) => (a.limb?.segment ?? 0) - (b.limb?.segment ?? 0));
692
+ const complete =
693
+ sorted.length >= 2 &&
694
+ sorted.every((s, i) => {
695
+ const l = s.limb as NonNullable<PartRecord['limb']>;
696
+ return l.segment === i && (i === 0 ? l.cap === undefined : l.cap !== undefined) && !nonLimbNames.has(l.of);
697
+ });
698
+ if (!complete) {
699
+ badLimbs.add(name);
700
+ for (const s of segs) badOf.add((s.limb as NonNullable<PartRecord['limb']>).of);
701
+ }
702
+ }
703
+ return limbed.filter((r) => {
704
+ const l = r.limb as NonNullable<PartRecord['limb']>;
705
+ return badLimbs.has(l.name) || badOf.has(l.of);
706
+ });
707
+ }
708
+
709
+ /**
710
+ * Issue #440: when `parts.json` came from an `assemble` run with `config.limbs`
711
+ * (every limb complete, {@link standaloneLimbParts} empty), reconstruct the
712
+ * pre-limb part set so `propose`, `draftTurn`, `checkProposal` and
713
+ * `checkBasis` read the whole layer each limb took in a single run. Without
714
+ * limb records (or when a standalone limb part will be refused by
715
+ * `PROPOSE_LIMB_PARTS`), returns `this`.
716
+ */
717
+ beforeLimbs(): PartSet {
718
+ if (this.preLimbs !== null) return this.preLimbs;
719
+ if (!this.recs.some((r) => r.limb !== undefined) || this.standaloneLimbParts().length > 0) {
720
+ this.preLimbs = this;
721
+ return this;
722
+ }
723
+ const segsByOf = new Map<string, PartRecord[]>();
724
+ const segByLimb = new Map<string, PartRecord>();
725
+ for (const r of this.recs) {
726
+ if (r.limb === undefined) continue;
727
+ segsByOf.set(r.limb.of, [...(segsByOf.get(r.limb.of) ?? []), r]);
728
+ segByLimb.set(`${r.limb.name}:${r.limb.segment}`, r);
729
+ }
730
+ const buildOf = (ofName: string): { rec: PartRecord; raster: Raster } => {
731
+ const segs = segsByOf.get(ofName) as PartRecord[];
732
+ const union = new Uint8Array(this.W * this.H);
733
+ for (const s of segs) {
734
+ const m = this.alpha(s);
735
+ const l = s.limb as NonNullable<PartRecord['limb']>;
736
+ const up = l.segment > 0 ? segByLimb.get(`${l.name}:${l.segment - 1}`) : undefined;
737
+ const dropUpperCap = up !== undefined && up.limb?.of !== ofName && (l.cap?.upper_px ?? 0) > 0 && (l.cap?.layer_px ?? 0) === 0 ? this.alpha(up) : null;
738
+ const box = l.cap?.box;
739
+ for (let y = s.y; y < s.y + s.h; y++) {
740
+ for (let x = s.x; x < s.x + s.w; x++) {
741
+ const p = y * this.W + x;
742
+ if (!m.data[p]) continue;
743
+ if (dropUpperCap !== null && box !== undefined && x >= box[0] && x < box[0] + box[2] && y >= box[1] && y < box[1] + box[3] && dropUpperCap.data[p]) continue;
744
+ union[p] = 1;
745
+ }
746
+ }
747
+ }
748
+ let x0 = this.W;
749
+ let y0 = this.H;
750
+ let x1 = -1;
751
+ let y1 = -1;
752
+ let opaquePx = 0;
753
+ for (let y = 0; y < this.H; y++) {
754
+ for (let x = 0; x < this.W; x++) {
755
+ if (!union[y * this.W + x]) continue;
756
+ if (x < x0) x0 = x;
757
+ if (x > x1) x1 = x;
758
+ if (y < y0) y0 = y;
759
+ if (y > y1) y1 = y;
760
+ opaquePx++;
761
+ }
762
+ }
763
+ const first = segs[0];
764
+ if (x1 < x0) {
765
+ x0 = first.x;
766
+ y0 = first.y;
767
+ x1 = first.x + first.w - 1;
768
+ y1 = first.y + first.h - 1;
769
+ }
770
+ const w = x1 - x0 + 1;
771
+ const h = y1 - y0 + 1;
772
+ const raster = newRaster(w, h);
773
+ for (let y = 0; y < h; y++) {
774
+ for (let x = 0; x < w; x++) {
775
+ if (union[(y0 + y) * this.W + x0 + x]) raster.data[(y * w + x) * 4 + 3] = 255;
776
+ }
777
+ }
778
+ const sum = (f: (r: PartRecord) => number): number => segs.reduce((acc, r) => acc + f(r), 0);
779
+ const hasVis = segs.every((r) => r.visible_px !== undefined && r.occluded_px !== undefined);
780
+ const rec: PartRecord = {
781
+ name: ofName,
782
+ from: first.from,
783
+ ...(first.cut_from === undefined ? {} : { cut_from: first.cut_from }),
784
+ x: x0,
785
+ y: y0,
786
+ w,
787
+ h,
788
+ opaque_px: opaquePx,
789
+ ...(hasVis ? { visible_px: sum((r) => r.visible_px as number), occluded_px: sum((r) => r.occluded_px as number) } : {}),
790
+ projected_core_px: sum((r) => r.projected_core_px),
791
+ source_px_taken: sum((r) => r.source_px_taken),
792
+ ...(segs.some((r) => r.visible_not_projected_px !== undefined) ? { visible_not_projected_px: sum((r) => r.visible_not_projected_px ?? 0) } : {}),
793
+ refused_drift_px: sum((r) => r.refused_drift_px),
794
+ merged_px: sum((r) => r.merged_px),
795
+ seam_override_px: sum((r) => r.seam_override_px),
796
+ };
797
+ return { rec, raster };
798
+ };
799
+ const recs: PartRecord[] = [];
800
+ const images = new Map<string, Raster>();
801
+ const emittedOf = new Set<string>();
802
+ for (const r of this.recs) {
803
+ if (r.limb === undefined) {
804
+ recs.push(r);
805
+ const im = this.images.get(r.name);
806
+ if (im !== undefined) images.set(r.name, im);
807
+ continue;
808
+ }
809
+ const seg0 = segByLimb.get(`${r.limb.name}:0`) as PartRecord;
810
+ const rootOf = (seg0.limb as NonNullable<PartRecord['limb']>).of;
811
+ if (emittedOf.has(rootOf)) continue;
812
+ const groupLimbs = new Set(
813
+ this.recs.filter((q) => q.limb !== undefined && q.limb.segment === 0 && q.limb.of === rootOf).map((q) => (q.limb as NonNullable<PartRecord['limb']>).name),
814
+ );
815
+ const minSegByOf = new Map<string, number>();
816
+ for (const q of this.recs) {
817
+ if (q.limb === undefined || !groupLimbs.has(q.limb.name)) continue;
818
+ const prev = minSegByOf.get(q.limb.of);
819
+ if (prev === undefined || q.limb.segment < prev) minSegByOf.set(q.limb.of, q.limb.segment);
820
+ }
821
+ const orderedOfs = [...minSegByOf.entries()].sort((a, b) => a[1] - b[1]).map(([n]) => n);
822
+ for (const ofName of orderedOfs) {
823
+ if (emittedOf.has(ofName)) continue;
824
+ emittedOf.add(ofName);
825
+ const built = buildOf(ofName);
826
+ recs.push(built.rec);
827
+ images.set(ofName, built.raster);
828
+ }
829
+ }
830
+ this.preLimbs = new PartSet({ rig_size: [this.W, this.H], scale_rig_per_source: this.rigScale, ghost_px: {}, parts: recs }, images);
831
+ return this.preLimbs;
832
+ }
833
+
667
834
  /** The See-through parts: every part but the `painting:` patches. */
668
835
  layered(): PartRecord[] {
669
836
  return this.recs.filter((p) => splitFrom(p.from)[0] !== PAINTING_RUN);
@@ -1226,8 +1393,10 @@ export function proposeWithBasis(
1226
1393
  detail: `share one layer and none of them records cut_from, so which is the plan part and which the piece an assemble.cuts entry took cannot be read off parts.json (it is in draw order, and a piece drawn "back" comes before its base); re-run assemble (1.6.0+) to record cut_from`,
1227
1394
  })),
1228
1395
  );
1229
- // Issue #349: limbs are drafted from the parts before their cut — the layer each limb takes is what the rules read.
1230
- const limbed = P.recs.filter((r) => r.limb !== undefined);
1396
+ // Issue #349, #440: limbs are drafted from the parts before their cut — a complete assemble with config.limbs
1397
+ // reconstructs its pre-limb parts here in the same run; a standalone or incomplete limb part handed to propose
1398
+ // on its own is refused by name.
1399
+ const limbed = P.standaloneLimbParts();
1231
1400
  refuseIfAny(
1232
1401
  limbed.length === 0
1233
1402
  ? []
@@ -1235,10 +1404,11 @@ export function proposeWithBasis(
1235
1404
  {
1236
1405
  code: 'PROPOSE_LIMB_PARTS',
1237
1406
  object: `parts ${limbed.map((r) => `"${r.name}"`).join(', ')}`,
1238
- detail: `were cut at their joints by config.limbs (from ${[...new Set(limbed.map((r) => `"${(r.limb as NonNullable<PartRecord['limb']>).of}"`))].join(', ')}), and propose drafts the limbs from the parts before the cut; assemble with the config's limbs left out, propose from that build, and keep the limbs block (and any caps, ranges) the config already holds — or read the config's bones with propose --from-config`,
1407
+ detail: `were cut at their joints by config.limbs (from ${[...new Set(limbed.map((r) => `"${(r.limb as NonNullable<PartRecord['limb']>).of}"`))].join(', ')}), and propose drafts the limbs from the whole layer each limb takes; hand propose the complete assemble output (every segment of each limb, with its cap record) so the source part can be reconstructed in one run, or assemble with config.limbs left out — or read the config's bones with propose --from-config`,
1239
1408
  },
1240
1409
  ],
1241
1410
  );
1411
+ P = P.beforeLimbs();
1242
1412
  const bones: BoneEntry[] = [];
1243
1413
  const records: BoneBasis[] = [];
1244
1414
  /** Issue #349: the limbs `--keypoints` drafts, and the parts they take (not made, so not given a mesh or a region). */
@@ -1403,7 +1573,7 @@ export function proposeWithBasis(
1403
1573
  const T = (p: PartRecord | readonly PartRecord[], tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
1404
1574
  const partNames = Array.isArray(p) ? p.map((x) => x.name) : [(p as PartRecord).name];
1405
1575
  const trees = draftTrees(input, r);
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))];
1576
+ const constants = [konst('cloth_chains', input.masses !== undefined ? input.masses.length : (r.chains ?? RECIPE_BACK_HAIR_CHAINS)), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
1407
1577
  for (const t of trees) {
1408
1578
  C(
1409
1579
  t.chain,
@@ -1673,27 +1843,45 @@ export function proposeWithBasis(
1673
1843
  ]);
1674
1844
  }
1675
1845
  }
1676
- const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
1846
+ const massesBySide = new Map<'r' | 'l', BackHairMass[]>(activeSides.map(([s]) => [s, backHairMasses(unionMask, s, axis, y0, shoulderLine, g)]));
1847
+ const useMasses = rawRcp.chains === undefined;
1677
1848
  const all: TreeChain[] = [];
1678
1849
  let n = 0;
1679
1850
  const partNamesLabel = list.map((q) => q.name).join(' -> ');
1680
1851
  for (const [side] of activeSides) {
1681
1852
  const prefix = prefixOf(side);
1682
- const trees = T(list, 'back hair', rcp, {
1853
+ const sideMasses = massesBySide.get(side) ?? [];
1854
+ if (useMasses && sideMasses.length === 0) continue;
1855
+ const sideRcp: ClothRecipe = { ...rawRcp, chains: useMasses ? sideMasses.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)) };
1856
+ const frame = `${partNamesLabel}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
1857
+ const trees = T(list, 'back hair', sideRcp, {
1683
1858
  prefix,
1684
1859
  parent: 'head',
1685
1860
  rootY: eyeY,
1686
1861
  tipY: bot - 4,
1687
1862
  xAt: (y, f) => bandX(unionMask, y, { frac: f, side, axis }),
1688
1863
  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)`,
1864
+ frame,
1690
1865
  edges: side,
1866
+ masses: useMasses
1867
+ ? sideMasses.map((m): TreeMassInput => ({
1868
+ rootY: m.minY <= y0 ? eyeY : m.minY,
1869
+ tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
1870
+ xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
1871
+ fallbackX: m.minY <= y0 && sideMasses.length === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
1872
+ frame,
1873
+ }))
1874
+ : undefined,
1691
1875
  });
1692
- const made = treeConstraints(trees, rcp, null);
1876
+ const made = treeConstraints(trees, sideRcp, null);
1693
1877
  clothConstraints.push(...made);
1694
1878
  n += made.length;
1695
1879
  all.push(...trees);
1696
1880
  }
1881
+ const rcp: ClothRecipe = {
1882
+ ...rawRcp,
1883
+ chains: useMasses ? Math.max(1, ...(activeSides.map(([s]) => (massesBySide.get(s) ?? []).length))) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
1884
+ };
1697
1885
  const spans: Array<TailSpan & { depth: number }> = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false, depth: -1 }];
1698
1886
  for (const t of all) {
1699
1887
  const line = [...t.points, t.tip];
@@ -1778,9 +1966,11 @@ export function proposeWithBasis(
1778
1966
  } else if (recipeOf('back hair') !== null && (backHairParts === null || backHairSingleSame)) {
1779
1967
  // Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
1780
1968
  // 4 px above the part's bottom, in place of the two keyed chains.
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`).
1969
+ // Issue #390: an explicit tree count per side that exceeds `floor(width / grid)` at the root row or roots a tree
1970
+ // outside the 15 px dilated art is refused by name (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`).
1971
+ // Issue #445: when `chains` is omitted, the tree count on each side is the number of visible hair masses on that
1972
+ // side (connected components below the root row, merged within one grid cell), one tree per mass, rooted at that
1973
+ // mass's top row and inside its columns.
1784
1974
  const rawRcp = recipeOf('back hair') as ClothRecipe;
1785
1975
  const mask = P.alpha(p);
1786
1976
  const dilated = dilate(mask, 31);
@@ -1835,30 +2025,66 @@ export function proposeWithBasis(
1835
2025
  ]);
1836
2026
  }
1837
2027
  }
1838
- const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
2028
+ const shoulderLine = neckY + 0.5 * fh;
2029
+ const rMasses = backHairMasses(mask, 'r', axis, y0, shoulderLine, g);
2030
+ const lMasses = backHairMasses(mask, 'l', axis, y0, shoulderLine, g);
2031
+ const massesBySide = new Map<'r' | 'l', BackHairMass[]>([
2032
+ ['r', rMasses],
2033
+ ['l', lMasses],
2034
+ ]);
2035
+ const useMasses = rawRcp.chains === undefined;
1839
2036
  const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
1840
2037
  const all: TreeChain[] = [];
1841
2038
  let n = 0;
1842
2039
  for (const [side] of SIDES) {
1843
2040
  const prefix = prefixOf(side);
1844
- const trees = T(p, 'back hair', rcp, {
2041
+ const sideMasses = massesBySide.get(side) ?? [];
2042
+ if (useMasses && sideMasses.length === 0) continue;
2043
+ const sideRcp: ClothRecipe = { ...rawRcp, chains: useMasses ? sideMasses.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)) };
2044
+ const frame = `${p.name}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
2045
+ const trees = T(p, 'back hair', sideRcp, {
1845
2046
  prefix,
1846
2047
  parent: 'head',
1847
2048
  rootY: eyeY,
1848
2049
  tipY: bot - 4,
1849
2050
  xAt: (y, f) => bandX(mask, y, { frac: f, side, axis }),
1850
2051
  fallbackX: () => axis,
1851
- frame: `${p.name}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`,
2052
+ frame,
1852
2053
  edges: side,
2054
+ masses: useMasses
2055
+ ? sideMasses.map((m): TreeMassInput => ({
2056
+ rootY: m.minY <= y0 ? eyeY : m.minY,
2057
+ tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
2058
+ xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
2059
+ fallbackX: m.minY <= y0 && sideMasses.length === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
2060
+ frame,
2061
+ }))
2062
+ : undefined,
1853
2063
  });
1854
2064
  for (const t of trees) segs.push(t.chain);
1855
- const made = treeConstraints(trees, rcp, null);
2065
+ const made = treeConstraints(trees, sideRcp, null);
1856
2066
  clothConstraints.push(...made);
1857
2067
  n += made.length;
1858
2068
  all.push(...trees);
1859
2069
  }
1860
- 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'));
1861
- meshes[p.name] = { grid: g, r: rcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
2070
+ const sameSideCounts = !useMasses || rMasses.length === lMasses.length;
2071
+ const noteRcp: ClothRecipe = {
2072
+ ...rawRcp,
2073
+ chains: useMasses ? (sameSideCounts ? rMasses.length : rMasses.length + lMasses.length) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
2074
+ };
2075
+ notes.push(
2076
+ clothNote(
2077
+ p.name,
2078
+ 'back hair',
2079
+ all,
2080
+ noteRcp,
2081
+ n,
2082
+ sameSideCounts
2083
+ ? ' per side of the eye axis, from head, rooted on the eye line and tipped 4 px above its bottom'
2084
+ : ` (${rMasses.length} on the r side and ${lMasses.length} on the l side of the eye axis, one per visible hair mass), from head, rooted on each mass's top row and tipped 4 px above its bottom`,
2085
+ ),
2086
+ );
2087
+ meshes[p.name] = { grid: g, r: rawRcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
1862
2088
  // Issue #391: the spans are this mesh's segments, the head's fixed one and every tree link, in segment order.
1863
2089
  const spans: TailSpan[] = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false }];
1864
2090
  for (const t of all) {
@@ -2574,17 +2800,118 @@ export function proposeWithBasis(
2574
2800
  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`,
2575
2801
  };
2576
2802
  };
2577
- // 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.
2578
- const skirtInput = (p: PartRecord, mask: Mask, prefix: string): TreeInput => ({
2579
- prefix,
2580
- parent: 'hip',
2581
- rootY: hip[1] + 20,
2582
- tipY: p.y + p.h - 4,
2583
- xAt: (y, f) => bandX(mask, y, { frac: f }),
2584
- fallbackX: (f) => p.x + f * p.w,
2585
- frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
2586
- edges: 'both',
2587
- });
2803
+ // Where the recipe's trees hang on a skirt: from 20 px below the hip to 4 px above the cloth's bottom under each column, across its art.
2804
+ const skirtInput = (p: PartRecord, mask: Mask, prefix: string, r: ClothRecipe, bot = p.y + p.h, emitNotes = true): TreeInput => {
2805
+ const y0 = hip[1] + 20;
2806
+ const y0Int = Math.max(0, pyInt(y0));
2807
+ const botInt = Math.min(mask.height, pyInt(bot));
2808
+ const n = r.chains ?? RECIPE_BACK_HAIR_CHAINS;
2809
+ const into = r.split === undefined ? 1 : r.split.into;
2810
+ const traceLastOpaqueY = (f: number): number => {
2811
+ let lastY = -1;
2812
+ for (let y = y0Int; y < botInt; y++) {
2813
+ const bx = bandX(mask, y, { frac: f }) ?? p.x + f * p.w;
2814
+ const rx = Math.max(0, Math.min(mask.width - 1, pyRound(bx)));
2815
+ if (mask.data[y * mask.width + rx] !== 0) {
2816
+ lastY = y;
2817
+ } else if (lastY >= y0Int) {
2818
+ let gapContinues = false;
2819
+ for (let yy = y + 1; yy <= Math.min(botInt - 1, y + 12); yy++) {
2820
+ if (mask.data[yy * mask.width + rx] !== 0) {
2821
+ gapContinues = true;
2822
+ break;
2823
+ }
2824
+ }
2825
+ if (!gapContinues) break;
2826
+ }
2827
+ }
2828
+ return lastY;
2829
+ };
2830
+ const shares: number[] = [];
2831
+ for (let k = 0; k < n; k++) {
2832
+ shares.push((k + 0.5) / n);
2833
+ if (into > 1) {
2834
+ for (let b = 0; b < into; b++) shares.push((k + (b + 0.5) / into) / n);
2835
+ }
2836
+ }
2837
+ const uniformBottom = shares.every((f) => {
2838
+ const lastY = traceLastOpaqueY(f);
2839
+ if (lastY >= botInt - 4) return true;
2840
+ if (lastY < botInt - 36 || lastY < y0Int + 4) return false;
2841
+ const txBot = Math.max(0, Math.min(mask.width - 1, pyRound(bandX(mask, bot - 4, { frac: f }) ?? p.x + f * p.w)));
2842
+ for (let yy = Math.max(y0Int, botInt - 16); yy < botInt; yy++) {
2843
+ if (mask.data[yy * mask.width + txBot] !== 0) return true;
2844
+ }
2845
+ return false;
2846
+ });
2847
+ const notedEmpty = new Set<number>();
2848
+ const cappedXAt = (maxY: number) => (y: number, f: number): number | null => {
2849
+ const yi = Math.min(maxY, Math.max(0, pyRound(y)));
2850
+ const a = Math.max(0, pyInt(y - 12));
2851
+ const b = Math.min(maxY + 1, pyInt(y + 12));
2852
+ let x0 = mask.width;
2853
+ let x1 = -1;
2854
+ for (let yy = a; yy < b; yy++) {
2855
+ for (let x = 0; x < mask.width; x++) {
2856
+ if (mask.data[yy * mask.width + x] !== 0) {
2857
+ if (x < x0) x0 = x;
2858
+ if (x > x1) x1 = x;
2859
+ }
2860
+ }
2861
+ }
2862
+ const raw = x1 < x0 ? (bandX(mask, yi, { frac: f }) ?? p.x + f * p.w) : x0 + f * (x1 - x0);
2863
+ const rx = Math.max(0, Math.min(mask.width - 1, pyRound(raw)));
2864
+ if (mask.data[yi * mask.width + rx] !== 0) return raw;
2865
+ let bestX: number | null = null;
2866
+ let bestDist = mask.width + 1;
2867
+ for (let x = 0; x < mask.width; x++) {
2868
+ if (mask.data[yi * mask.width + x] !== 0) {
2869
+ const d = Math.abs(x - rx);
2870
+ if (d < bestDist) {
2871
+ bestDist = d;
2872
+ bestX = x;
2873
+ }
2874
+ }
2875
+ }
2876
+ return bestX ?? raw;
2877
+ };
2878
+ return {
2879
+ prefix,
2880
+ parent: 'hip',
2881
+ rootY: y0,
2882
+ tipY: bot - 4,
2883
+ xAt: (y, f) => bandX(mask, y, { frac: f }),
2884
+ fallbackX: (f) => p.x + f * p.w,
2885
+ frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
2886
+ edges: 'both',
2887
+ columnTip: (k, f) => {
2888
+ const col0 = Math.floor(p.x + (k / n) * p.w);
2889
+ const col1 = Math.max(col0, Math.ceil(p.x + ((k + 1) / n) * p.w) - 1);
2890
+ let colMaxY = -1;
2891
+ for (let y = y0Int; y < botInt; y++) {
2892
+ for (let x = col0; x <= col1; x++) {
2893
+ if (mask.data[y * mask.width + x] !== 0) colMaxY = y;
2894
+ }
2895
+ }
2896
+ const fc = (k + 0.5) / n;
2897
+ const trunkLastY = traceLastOpaqueY(fc);
2898
+ if (colMaxY < y0Int + 4 || trunkLastY < y0Int + 4) {
2899
+ if (emitNotes && !notedEmpty.has(k)) {
2900
+ notedEmpty.add(k);
2901
+ notes.push(
2902
+ `${p.name} (${p === namedSkirt ? p.from : 'bottomwear'}): column ${k} of ${n} (x=${col0}..${col1}, share ${roundTo(fc, 6)}) holds no cloth below the root row y=${y0Int} — no tree drafted there`,
2903
+ );
2904
+ }
2905
+ return null;
2906
+ }
2907
+ if (uniformBottom) return { tipY: bot - 4 };
2908
+ const rawLastY = traceLastOpaqueY(f);
2909
+ const lastY = rawLastY >= y0Int + 4 ? rawLastY : trunkLastY;
2910
+ const tipY = Math.min(bot - 4, Math.max(y0Int + 4, lastY - 3));
2911
+ return { tipY, xAt: cappedXAt(lastY) };
2912
+ },
2913
+ };
2914
+ };
2588
2915
  // Issue #416: a bottomwear part lying on the trees of the dress config.cloth.bottomwear.part names (a ruffle hem
2589
2916
  // See-through returned on its own) rides the links it overlaps, as a layer wrapped round a tail does (issue #391),
2590
2917
  // and gets no trees of its own: one skirt carries one set of trees. The dress's mesh is the tail — its fixed
@@ -2646,12 +2973,12 @@ export function proposeWithBasis(
2646
2973
  const prefix = 'skirt';
2647
2974
  const partNamesLabel = list.map((q) => q.name).join(' -> ');
2648
2975
  const trees = T(list, 'bottomwear', dressRecipe, {
2649
- ...skirtInput(p0, unionMask, prefix),
2976
+ ...skirtInput(p0, unionMask, prefix, dressRecipe, bot),
2650
2977
  tipY: bot - 4,
2651
2978
  frame: `${partNamesLabel}'s art (no art in the band: that share of ${p0.name}'s box width)`,
2652
2979
  });
2653
2980
  let swing: { bone: string; rest: number } | null = null;
2654
- if (dressRecipe.swing !== undefined) {
2981
+ if (dressRecipe.swing !== undefined && trees.length > 0) {
2655
2982
  const cx = or(bandX(unionMask, y0, { frac: 0.5 }), p0.x + 0.5 * p0.w);
2656
2983
  const first = trees[0].points.length > 1 ? trees[0].points[1][1] : trees[0].tip[1];
2657
2984
  const at: [number, number] = [cx, y0];
@@ -2739,7 +3066,7 @@ export function proposeWithBasis(
2739
3066
  ? []
2740
3067
  : ((): PartRecord[] => {
2741
3068
  const mask = P.alpha(namedSkirt);
2742
- const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
3069
+ const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt', dressRecipe, namedSkirt.y + namedSkirt.h, false), dressRecipe));
2743
3070
  return bw.filter((q) => !multiPartClaimed.has(q.name) && overLinks(tail, q).rides);
2744
3071
  })();
2745
3072
  let dressDrafted: Tail | null = null;
@@ -2756,11 +3083,11 @@ export function proposeWithBasis(
2756
3083
  if (skirtRecipe !== null) {
2757
3084
  const prefix = skirts.length === 1 ? 'skirt' : `skirt_${p.name}`;
2758
3085
  const fixed = [...segs];
2759
- const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix));
3086
+ const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix, skirtRecipe));
2760
3087
  if (p === namedSkirt) dressDrafted = dressTail(p, fixed, trees);
2761
3088
  for (const t of trees) segs.push(t.chain);
2762
3089
  let swing: { bone: string; rest: number } | null = null;
2763
- if (skirtRecipe.swing !== undefined) {
3090
+ if (skirtRecipe.swing !== undefined && trees.length > 0) {
2764
3091
  // One link long, straight down from the centre of the root row: the hip is drafted with no tip, so it is
2765
3092
  // unturned and the swing's setup rotation in its frame is its own direction, -90 degrees (Spine, y up).
2766
3093
  const cx = or(bandX(mask, y0, { frac: 0.5 }), p.x + 0.5 * p.w);
@@ -3800,6 +4127,45 @@ export interface NearEmptyHandFinding {
3800
4127
  | { kind: 'forearm-width'; width: number };
3801
4128
  }
3802
4129
 
4130
+ /**
4131
+ * Issue #445: back-hair cloth trees on one side of the eye axis whose count does not match the side's visible hair
4132
+ * masses or whose link or tip leaves its mass's column range.
4133
+ */
4134
+ export interface BackHairMassesFinding {
4135
+ kind: 'back-hair-masses';
4136
+ mesh: string;
4137
+ side: 'r' | 'l';
4138
+ trunks: string[];
4139
+ masses: Array<{ minX: number; maxX: number; minY: number; maxY: number }>;
4140
+ outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }>;
4141
+ }
4142
+
4143
+ /**
4144
+ * Issue #439: both arms overlap in one 8-connected component of a single handwear layer (`handwear-r` or `handwear-l`),
4145
+ * resolved by declaring `config.seethrough.arm_split` (`x` or `line`, and `order`).
4146
+ */
4147
+ export interface OverlappingArmsFinding {
4148
+ kind: 'overlapping-arms';
4149
+ part: string;
4150
+ from: string;
4151
+ rAt: [number, number];
4152
+ lAt: [number, number];
4153
+ opaquePx: number;
4154
+ }
4155
+
4156
+ /**
4157
+ * Issue #446: two limbs sharing one part (`from`) cross in an X at `at` with no `config.limbs.<limb>.split`
4158
+ * declared on the covering limb, so `nearestLimb` cuts a straight line through the crossing.
4159
+ */
4160
+ export interface CrossedLimbsFinding {
4161
+ kind: 'crossed-limbs';
4162
+ part: string;
4163
+ from: string;
4164
+ a: string;
4165
+ b: string;
4166
+ at: [number, number];
4167
+ }
4168
+
3803
4169
  export type LintFinding =
3804
4170
  | OffArtFinding
3805
4171
  | OffLockFinding
@@ -3810,7 +4176,10 @@ export type LintFinding =
3810
4176
  | HipNearerNeckFinding
3811
4177
  | ChainNotAdvancingFinding
3812
4178
  | SqueezeLintFinding
3813
- | NearEmptyHandFinding;
4179
+ | NearEmptyHandFinding
4180
+ | BackHairMassesFinding
4181
+ | OverlappingArmsFinding
4182
+ | CrossedLimbsFinding;
3814
4183
 
3815
4184
  /** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
3816
4185
  export interface LintBasis {
@@ -3910,7 +4279,7 @@ export function coverageOutcome(c: BoneCoverage): CoverageOutcome {
3910
4279
  */
3911
4280
  export function lint(
3912
4281
  P: PartSet,
3913
- spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; regions?: Readonly<Record<string, string>>; motion?: SkeletonSections['motion']; constraints?: readonly ConfigConstraint[] },
4282
+ spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; regions?: Readonly<Record<string, string>>; motion?: SkeletonSections['motion']; constraints?: readonly ConfigConstraint[]; limbs?: Readonly<Record<string, Limb>> },
3914
4283
  joints?: RigJoints,
3915
4284
  ): LintResult {
3916
4285
  const { byName } = expand(spec.bones);
@@ -4097,6 +4466,188 @@ export function lint(
4097
4466
  }
4098
4467
  }
4099
4468
  }
4469
+ // Issue #439: when both arms overlap in one 8-connected component of a single handwear layer, name the overlap and
4470
+ // the config.seethrough.arm_split declaration that separates them into two layers.
4471
+ if (joints !== undefined && joints.joints.r_wrist.at !== null && joints.joints.l_wrist.at !== null) {
4472
+ const hwParts = [...P.byTag('handwear-r', undefined, true), ...P.byTag('handwear-l', undefined, true)].filter((p) => p.limb === undefined);
4473
+ if (hwParts.length === 1) {
4474
+ const part = hwParts[0];
4475
+ const rAt: [number, number] = [joints.joints.r_wrist.at[0], joints.joints.r_wrist.at[1]];
4476
+ const lAt: [number, number] = [joints.joints.l_wrist.at[0], joints.joints.l_wrist.at[1]];
4477
+ {
4478
+ const cc = connectedComponents(P.alpha(part), 8);
4479
+ const compsNear = (pt: readonly [number, number]): Set<number> => {
4480
+ const hit = new Set<number>();
4481
+ const px = pyInt(pt[0]);
4482
+ const py = pyInt(pt[1]);
4483
+ const x0 = Math.max(0, px - WRIST_ON_PART_PX);
4484
+ const x1 = Math.min(P.W - 1, px + WRIST_ON_PART_PX);
4485
+ const y0 = Math.max(0, py - WRIST_ON_PART_PX);
4486
+ const y1 = Math.min(P.H - 1, py + WRIST_ON_PART_PX);
4487
+ for (let y = y0; y <= y1; y++) {
4488
+ for (let x = x0; x <= x1; x++) {
4489
+ const l = cc.labels[y * P.W + x];
4490
+ if (l > 0) hit.add(l);
4491
+ }
4492
+ }
4493
+ return hit;
4494
+ };
4495
+ const rComps = compsNear(rAt);
4496
+ const lComps = compsNear(lAt);
4497
+ const shared = [...rComps].find((l) => lComps.has(l));
4498
+ if (shared !== undefined) {
4499
+ findings.push({
4500
+ kind: 'overlapping-arms',
4501
+ part: part.name,
4502
+ from: part.from,
4503
+ rAt,
4504
+ lAt,
4505
+ opaquePx: cc.stats[shared].area,
4506
+ });
4507
+ }
4508
+ }
4509
+ }
4510
+ }
4511
+ // Issue #445: back-hair cloth trees (trunks `hairback_..._<r|l><k>_` hung from head) are held to the visible hair
4512
+ // masses on each side of the eye axis — one tree per mass, every link and tip inside that mass's column range.
4513
+ const bhParts = P.headFirst('back hair').filter((p) => spec.meshes[p.name] !== undefined);
4514
+ if (bhParts.length > 0) {
4515
+ try {
4516
+ const [fx0, fy0, fw, fh] = faceBoxOf(P).box;
4517
+ const ewR = P.byTag('eyewhite-r')[0] ?? null;
4518
+ const ewL = P.byTag('eyewhite-l')[0] ?? null;
4519
+ const axis = ewR !== null && ewL !== null ? (center(ewR)[0] + center(ewL)[0]) / 2 : fx0 + fw / 2;
4520
+ const eyeYs = [ewR, ewL].filter((x): x is PartRecord => x !== null).map((x) => center(x)[1]);
4521
+ const eyeY = eyeYs.length > 0 ? npMean(eyeYs) : fy0 + 0.5 * fh;
4522
+ const chin = fy0 + fh;
4523
+ const neckp = P.headFirst('neck');
4524
+ const neckY = joints?.joints.neck.at?.[1] ?? (neckp.length > 0 ? (chin + neckp[0].y + neckp[0].h) / 2 : chin + 0.12 * fh);
4525
+ const shoulderLine = neckY + 0.5 * fh;
4526
+ const y0 = pyRound(eyeY);
4527
+ const seenGroups = new Set<string>();
4528
+ for (const part of bhParts) {
4529
+ const m = spec.meshes[part.name];
4530
+ const segStrings = m.segments.filter((sp): sp is string => typeof sp === 'string');
4531
+ const trunkMatch = segStrings.map((sp) => /^(hairback_(?:.+_)?)(r|l)(\d+)_$/.exec(sp)).find((x) => x !== null) ?? null;
4532
+ if (trunkMatch === null) continue;
4533
+ const basePrefix = trunkMatch[1];
4534
+ if (seenGroups.has(basePrefix)) continue;
4535
+ seenGroups.add(basePrefix);
4536
+ const groupParts = bhParts.filter((q) =>
4537
+ spec.meshes[q.name].segments.some((sp) => typeof sp === 'string' && sp.startsWith(basePrefix) && /^(r|l)\d+_$/.test(sp.slice(basePrefix.length))),
4538
+ );
4539
+ const mask = groupParts.length === 1 ? P.alpha(part) : newMask(P.W, P.H);
4540
+ if (groupParts.length > 1) {
4541
+ for (const q of groupParts) {
4542
+ const mq = P.alpha(q);
4543
+ for (let i = 0; i < mask.data.length; i++) if (mq.data[i]) mask.data[i] = 1;
4544
+ }
4545
+ }
4546
+ const m0 = spec.meshes[groupParts[0].name];
4547
+ const g = 'grid' in m0 ? m0.grid : partGrid(groupParts[0])[0];
4548
+ for (const side of ['r', 'l'] as const) {
4549
+ const trunkRe = new RegExp(`^${basePrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${side}(\\d+)_$`);
4550
+ const sideTrunks = spec.bones
4551
+ .filter((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && b.parent === 'head' && trunkRe.test(b.chain) && groupParts.some((q) => spec.meshes[q.name].segments.includes(b.chain)))
4552
+ .sort((a, b) => Number(trunkRe.exec(a.chain)![1]) - Number(trunkRe.exec(b.chain)![1]));
4553
+ if (sideTrunks.length === 0) continue;
4554
+ const masses = backHairMasses(mask, side, axis, y0, shoulderLine, g);
4555
+ const outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }> = [];
4556
+ const sideMinX = masses.length > 0 ? Math.min(...masses.map((x) => x.minX)) : 0;
4557
+ const sideMaxX = masses.length > 0 ? Math.max(...masses.map((x) => x.maxX)) : P.W - 1;
4558
+ sideTrunks.forEach((tr, k) => {
4559
+ const target = k < masses.length ? masses[k] : null;
4560
+ const minX = target !== null ? target.minX : sideMinX;
4561
+ const maxX = target !== null ? target.maxX : sideMaxX;
4562
+ const treeBase = tr.chain.slice(0, -1);
4563
+ const treeRe = new RegExp(`^${treeBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[a-z]*_$`);
4564
+ const treeChains = spec.bones.filter((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && treeRe.test(b.chain));
4565
+ for (const c of treeChains) {
4566
+ c.points.forEach((pt, idx) => {
4567
+ const px = pyInt(pt[0]);
4568
+ const py = pyInt(pt[1]);
4569
+ if (px < minX || px > maxX) outside.push({ point: `${c.chain}${idx}`, at: [px, py], minX, maxX });
4570
+ });
4571
+ const tx = pyInt(c.tip[0]);
4572
+ const ty = pyInt(c.tip[1]);
4573
+ if (tx < minX || tx > maxX) outside.push({ point: `${c.chain} tip`, at: [tx, ty], minX, maxX });
4574
+ }
4575
+ });
4576
+ if (sideTrunks.length !== masses.length || outside.length > 0) {
4577
+ findings.push({
4578
+ kind: 'back-hair-masses',
4579
+ mesh: groupParts.map((q) => q.name).join(' -> '),
4580
+ side,
4581
+ trunks: sideTrunks.map((t) => t.chain),
4582
+ masses: masses.map((x) => ({ minX: x.minX, maxX: x.maxX, minY: x.minY, maxY: x.maxY })),
4583
+ outside,
4584
+ });
4585
+ }
4586
+ }
4587
+ }
4588
+ } catch (err) {
4589
+ if (!(err instanceof PartsError)) throw err;
4590
+ }
4591
+ }
4592
+ // Issue #446: when two limbs sharing one part cross in an X without a per-limb split declared on the covering limb,
4593
+ // nearest-bone splitting cuts a straight line through the crossing.
4594
+ {
4595
+ const baseP = P.beforeLimbs();
4596
+ const places = bonePlaces(spec.bones);
4597
+ if (spec.limbs !== undefined) {
4598
+ const limbNames = Object.keys(spec.limbs);
4599
+ const fromParts = [...new Set(limbNames.flatMap((l) => spec.limbs![l].segments.map((sg) => sg.from)))];
4600
+ for (const src of fromParts) {
4601
+ const on = limbNames.filter((l) => spec.limbs![l].segments.some((sg) => sg.from === src));
4602
+ if (on.length < 2 || on.some((l) => spec.limbs![l].split !== undefined)) continue;
4603
+ const valid = on.filter((l) => spec.limbs![l].segments.every((sg) => places.has(sg.bone)));
4604
+ if (valid.length < 2) continue;
4605
+ const rec = baseP.recs.find((r) => r.name === src);
4606
+ for (let i = 0; i < valid.length; i++) {
4607
+ const polyA = limbPolyline(spec.limbs[valid[i]], places);
4608
+ for (let j = i + 1; j < valid.length; j++) {
4609
+ const polyB = limbPolyline(spec.limbs[valid[j]], places);
4610
+ const cross = limbCrossing(polyA, polyB);
4611
+ if (cross !== null) {
4612
+ findings.push({
4613
+ kind: 'crossed-limbs',
4614
+ part: src,
4615
+ from: rec?.from ?? '',
4616
+ a: valid[i],
4617
+ b: valid[j],
4618
+ at: cross,
4619
+ });
4620
+ }
4621
+ }
4622
+ }
4623
+ }
4624
+ } else {
4625
+ const legR = spec.bones.find((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && b.chain === 'leg_r' && b.points.length >= 2);
4626
+ const legL = spec.bones.find((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && b.chain === 'leg_l' && b.points.length >= 2);
4627
+ if (legR !== undefined && legL !== undefined) {
4628
+ const polyR: Array<[number, number]> = [...legR.points.map((p): [number, number] => [p[0], p[1]]), [legR.tip[0], legR.tip[1]]];
4629
+ const polyL: Array<[number, number]> = [...legL.points.map((p): [number, number] => [p[0], p[1]]), [legL.tip[0], legL.tip[1]]];
4630
+ const cross = limbCrossing(polyR, polyL);
4631
+ if (cross !== null) {
4632
+ const lwParts = baseP.byTag('legwear');
4633
+ const shared = lwParts.find((p: PartRecord) => {
4634
+ const near = dilate(baseP.alpha(p), 2 * WRIST_ON_PART_PX + 1);
4635
+ return isOn(near, legR.tip[0], legR.tip[1]) && isOn(near, legL.tip[0], legL.tip[1]);
4636
+ });
4637
+ if (shared !== undefined) {
4638
+ findings.push({
4639
+ kind: 'crossed-limbs',
4640
+ part: shared.name,
4641
+ from: shared.from,
4642
+ a: 'leg_r',
4643
+ b: 'leg_l',
4644
+ at: cross,
4645
+ });
4646
+ }
4647
+ }
4648
+ }
4649
+ }
4650
+ }
4100
4651
  // Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
4101
4652
  // to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
4102
4653
  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 }]));
@@ -4133,7 +4684,22 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
4133
4684
  case 'squeeze':
4134
4685
  return [...new Set(f.vertices.map((v) => v.weights[0]?.bone).filter((n): n is string => n !== undefined))];
4135
4686
  case 'near-empty-hand':
4687
+ case 'overlapping-arms':
4688
+ case 'crossed-limbs':
4136
4689
  return [];
4690
+ case 'back-hair-masses': {
4691
+ const out = new Set<string>();
4692
+ for (const o of f.outside) {
4693
+ if (!o.point.endsWith(' tip')) out.add(o.point);
4694
+ else {
4695
+ const chain = o.point.slice(0, -' tip'.length);
4696
+ const e = bones.find((b) => 'chain' in b && b.chain === chain);
4697
+ if (e !== undefined && 'chain' in e) out.add(`${chain}${e.points.length - 1}`);
4698
+ }
4699
+ }
4700
+ if (out.size === 0) for (const t of f.trunks) out.add(`${t}0`);
4701
+ return [...out];
4702
+ }
4137
4703
  case 'chain-not-advancing': {
4138
4704
  if (!f.point.endsWith(' tip')) return [f.point];
4139
4705
  const chain = f.point.slice(0, -' tip'.length);
@@ -4308,6 +4874,21 @@ export function lintLine(f: LintFinding): string {
4308
4874
  : `forearm width ${pyFixed(f.basis.width, 1)} px squared`;
4309
4875
  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`;
4310
4876
  }
4877
+ case 'overlapping-arms':
4878
+ return `LINT ${f.part} (${f.from}) holds both arms in one ${f.opaquePx} px component (r_wrist at ${fmtAt(f.rAt)}, l_wrist at ${fmtAt(f.lAt)}): declare config.seethrough.arm_split (x or line, and order: ["handwear-r", "handwear-l"] or ["handwear-l", "handwear-r"]) to separate the overlapping arms into two layers`;
4879
+ case 'crossed-limbs':
4880
+ return `LINT ${f.a} and ${f.b} on ${f.part}${f.from === '' ? '' : ` (${f.from})`} cross at ${fmtAt(f.at)} with no per-limb split: nearest-bone splitting cuts a straight line through the crossing; declare config.limbs.<covering>.split (line: [[x, y], ...] or edge: "painting") on the covering limb so the seam follows the limb`;
4881
+ case 'back-hair-masses': {
4882
+ const massDesc =
4883
+ f.masses.length === 0
4884
+ ? 'none'
4885
+ : f.masses.map((m, i) => `mass ${i} columns x=${m.minX}..${m.maxX}, y=${m.minY}..${m.maxY}`).join('; ');
4886
+ const outDesc =
4887
+ f.outside.length === 0
4888
+ ? ''
4889
+ : `; outside mass columns: ${f.outside.map((o) => `${o.point} at ${pyIntList(o.at)} (outside x=${o.minX}..${o.maxX})`).join(', ')}`;
4890
+ return `LINT ${f.trunks.join(', ')} on mesh ${pyRepr(f.mesh)} (side ${f.side}): ${f.trunks.length} tree(s) for ${f.masses.length} visible hair mass(es) (${massDesc})${outDesc} — draft one tree per visible hair mass, rooted at that mass's top row and inside its columns`;
4891
+ }
4311
4892
  case 'chain-not-advancing':
4312
4893
  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`;
4313
4894
  }