@rig-c/parts 1.28.0 → 1.30.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
@@ -127,7 +127,8 @@
127
127
  import { existsSync } from 'node:fs';
128
128
  import { join } from 'node:path';
129
129
  import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
130
- import { type BoneEntry, type ConfigConstraint, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
130
+ import { type BoneEntry, type ConfigConstraint, type ConfigStop, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
131
+ import { computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toWorld } from './coords.ts';
131
132
  import { PartsError, type Problem, refuseIfAny } from './errors.ts';
132
133
  import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
133
134
  import { type LayerSet, OPAQUE_ALPHA_ABOVE } from './layers.ts';
@@ -146,9 +147,9 @@ import { configRoles, type RoleOf } from './structure.ts';
146
147
  import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
147
148
  import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
148
149
  import { edgeLength } from './length.ts';
149
- import { limbWidth } from './limbs.ts';
150
+ import { bonePlaces, limbCrossing, limbPolyline, limbWidth } from './limbs.ts';
150
151
  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';
152
+ 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
153
  import { TEXTURE_STRETCH_CEILING } from './check.ts';
153
154
 
154
155
  // ---------------------------------------------------------------------------
@@ -397,6 +398,7 @@ function konst(name: string, value: number): BasisConstant {
397
398
  * plan part and which as the piece (issue #230).
398
399
  */
399
400
  export function proposalAssemble(P: PartSet): Record<string, unknown> {
401
+ P = P.beforeLimbs();
400
402
  // A patch is not a plan entry; it goes back as the patches entry it came
401
403
  // from, so the loader holds it to its own rule (a region, never a mesh).
402
404
  // Its box, alpha and draw are the record's own box and placeholders the
@@ -492,6 +494,7 @@ export type TurnRuns = Partial<Record<'full' | 'head', LayerSet>>;
492
494
  * span away.
493
495
  */
494
496
  export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null; notes: string[] } {
497
+ P = P.beforeLimbs();
495
498
  const problems: Problem[] = [];
496
499
  const read: Array<{ part: string; run: 'full' | 'head'; tag: string; base: string | null; own: number; area: number; order: number; piece: boolean }> = [];
497
500
  const patches: string[] = [];
@@ -629,6 +632,7 @@ export class PartSet {
629
632
  readonly recs: readonly PartRecord[];
630
633
  readonly images: ReadonlyMap<string, Raster>;
631
634
  private readonly masks = new Map<string, Mask>();
635
+ private preLimbs: PartSet | null = null;
632
636
 
633
637
  constructor(file: PartsFile, images: ReadonlyMap<string, Raster>) {
634
638
  [this.W, this.H] = file.rig_size;
@@ -664,6 +668,170 @@ export class PartSet {
664
668
  return [...by.entries()].filter(([, names]) => names.length > 1).map(([from, parts]) => ({ from, parts }));
665
669
  }
666
670
 
671
+ /**
672
+ * Issue #440: limb records (`r.limb !== undefined`) that cannot be reassembled
673
+ * into their pre-limb source parts (`r.limb.of`) — a limb part handed to
674
+ * `propose` on its own or with an incomplete segment chain (fewer than two
675
+ * segments, a segment index missing from `0..n-1`, a lower segment without
676
+ * its `cap` record, or the source part `of` still present beside the limb
677
+ * parts). When every limb in `parts.json` is complete, returns `[]` and
678
+ * {@link beforeLimbs} reconstructs the pre-limb part set.
679
+ */
680
+ standaloneLimbParts(): PartRecord[] {
681
+ const limbed = this.recs.filter((r) => r.limb !== undefined);
682
+ if (limbed.length === 0) return [];
683
+ const nonLimbNames = new Set(this.recs.filter((r) => r.limb === undefined).map((r) => r.name));
684
+ const byLimb = new Map<string, PartRecord[]>();
685
+ for (const r of limbed) {
686
+ const l = r.limb as NonNullable<PartRecord['limb']>;
687
+ byLimb.set(l.name, [...(byLimb.get(l.name) ?? []), r]);
688
+ }
689
+ const badLimbs = new Set<string>();
690
+ const badOf = new Set<string>();
691
+ for (const [name, segs] of byLimb) {
692
+ const sorted = [...segs].sort((a, b) => (a.limb?.segment ?? 0) - (b.limb?.segment ?? 0));
693
+ const complete =
694
+ sorted.length >= 2 &&
695
+ sorted.every((s, i) => {
696
+ const l = s.limb as NonNullable<PartRecord['limb']>;
697
+ return l.segment === i && (i === 0 ? l.cap === undefined : l.cap !== undefined) && !nonLimbNames.has(l.of);
698
+ });
699
+ if (!complete) {
700
+ badLimbs.add(name);
701
+ for (const s of segs) badOf.add((s.limb as NonNullable<PartRecord['limb']>).of);
702
+ }
703
+ }
704
+ return limbed.filter((r) => {
705
+ const l = r.limb as NonNullable<PartRecord['limb']>;
706
+ return badLimbs.has(l.name) || badOf.has(l.of);
707
+ });
708
+ }
709
+
710
+ /**
711
+ * Issue #440: when `parts.json` came from an `assemble` run with `config.limbs`
712
+ * (every limb complete, {@link standaloneLimbParts} empty), reconstruct the
713
+ * pre-limb part set so `propose`, `draftTurn`, `checkProposal` and
714
+ * `checkBasis` read the whole layer each limb took in a single run. Without
715
+ * limb records (or when a standalone limb part will be refused by
716
+ * `PROPOSE_LIMB_PARTS`), returns `this`.
717
+ */
718
+ beforeLimbs(): PartSet {
719
+ if (this.preLimbs !== null) return this.preLimbs;
720
+ if (!this.recs.some((r) => r.limb !== undefined) || this.standaloneLimbParts().length > 0) {
721
+ this.preLimbs = this;
722
+ return this;
723
+ }
724
+ const segsByOf = new Map<string, PartRecord[]>();
725
+ const segByLimb = new Map<string, PartRecord>();
726
+ for (const r of this.recs) {
727
+ if (r.limb === undefined) continue;
728
+ segsByOf.set(r.limb.of, [...(segsByOf.get(r.limb.of) ?? []), r]);
729
+ segByLimb.set(`${r.limb.name}:${r.limb.segment}`, r);
730
+ }
731
+ const buildOf = (ofName: string): { rec: PartRecord; raster: Raster } => {
732
+ const segs = segsByOf.get(ofName) as PartRecord[];
733
+ const union = new Uint8Array(this.W * this.H);
734
+ for (const s of segs) {
735
+ const m = this.alpha(s);
736
+ const l = s.limb as NonNullable<PartRecord['limb']>;
737
+ const up = l.segment > 0 ? segByLimb.get(`${l.name}:${l.segment - 1}`) : undefined;
738
+ const dropUpperCap = up !== undefined && up.limb?.of !== ofName && (l.cap?.upper_px ?? 0) > 0 && (l.cap?.layer_px ?? 0) === 0 ? this.alpha(up) : null;
739
+ const box = l.cap?.box;
740
+ for (let y = s.y; y < s.y + s.h; y++) {
741
+ for (let x = s.x; x < s.x + s.w; x++) {
742
+ const p = y * this.W + x;
743
+ if (!m.data[p]) continue;
744
+ 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;
745
+ union[p] = 1;
746
+ }
747
+ }
748
+ }
749
+ let x0 = this.W;
750
+ let y0 = this.H;
751
+ let x1 = -1;
752
+ let y1 = -1;
753
+ let opaquePx = 0;
754
+ for (let y = 0; y < this.H; y++) {
755
+ for (let x = 0; x < this.W; x++) {
756
+ if (!union[y * this.W + x]) continue;
757
+ if (x < x0) x0 = x;
758
+ if (x > x1) x1 = x;
759
+ if (y < y0) y0 = y;
760
+ if (y > y1) y1 = y;
761
+ opaquePx++;
762
+ }
763
+ }
764
+ const first = segs[0];
765
+ if (x1 < x0) {
766
+ x0 = first.x;
767
+ y0 = first.y;
768
+ x1 = first.x + first.w - 1;
769
+ y1 = first.y + first.h - 1;
770
+ }
771
+ const w = x1 - x0 + 1;
772
+ const h = y1 - y0 + 1;
773
+ const raster = newRaster(w, h);
774
+ for (let y = 0; y < h; y++) {
775
+ for (let x = 0; x < w; x++) {
776
+ if (union[(y0 + y) * this.W + x0 + x]) raster.data[(y * w + x) * 4 + 3] = 255;
777
+ }
778
+ }
779
+ const sum = (f: (r: PartRecord) => number): number => segs.reduce((acc, r) => acc + f(r), 0);
780
+ const hasVis = segs.every((r) => r.visible_px !== undefined && r.occluded_px !== undefined);
781
+ const rec: PartRecord = {
782
+ name: ofName,
783
+ from: first.from,
784
+ ...(first.cut_from === undefined ? {} : { cut_from: first.cut_from }),
785
+ x: x0,
786
+ y: y0,
787
+ w,
788
+ h,
789
+ opaque_px: opaquePx,
790
+ ...(hasVis ? { visible_px: sum((r) => r.visible_px as number), occluded_px: sum((r) => r.occluded_px as number) } : {}),
791
+ projected_core_px: sum((r) => r.projected_core_px),
792
+ source_px_taken: sum((r) => r.source_px_taken),
793
+ ...(segs.some((r) => r.visible_not_projected_px !== undefined) ? { visible_not_projected_px: sum((r) => r.visible_not_projected_px ?? 0) } : {}),
794
+ refused_drift_px: sum((r) => r.refused_drift_px),
795
+ merged_px: sum((r) => r.merged_px),
796
+ seam_override_px: sum((r) => r.seam_override_px),
797
+ };
798
+ return { rec, raster };
799
+ };
800
+ const recs: PartRecord[] = [];
801
+ const images = new Map<string, Raster>();
802
+ const emittedOf = new Set<string>();
803
+ for (const r of this.recs) {
804
+ if (r.limb === undefined) {
805
+ recs.push(r);
806
+ const im = this.images.get(r.name);
807
+ if (im !== undefined) images.set(r.name, im);
808
+ continue;
809
+ }
810
+ const seg0 = segByLimb.get(`${r.limb.name}:0`) as PartRecord;
811
+ const rootOf = (seg0.limb as NonNullable<PartRecord['limb']>).of;
812
+ if (emittedOf.has(rootOf)) continue;
813
+ const groupLimbs = new Set(
814
+ this.recs.filter((q) => q.limb !== undefined && q.limb.segment === 0 && q.limb.of === rootOf).map((q) => (q.limb as NonNullable<PartRecord['limb']>).name),
815
+ );
816
+ const minSegByOf = new Map<string, number>();
817
+ for (const q of this.recs) {
818
+ if (q.limb === undefined || !groupLimbs.has(q.limb.name)) continue;
819
+ const prev = minSegByOf.get(q.limb.of);
820
+ if (prev === undefined || q.limb.segment < prev) minSegByOf.set(q.limb.of, q.limb.segment);
821
+ }
822
+ const orderedOfs = [...minSegByOf.entries()].sort((a, b) => a[1] - b[1]).map(([n]) => n);
823
+ for (const ofName of orderedOfs) {
824
+ if (emittedOf.has(ofName)) continue;
825
+ emittedOf.add(ofName);
826
+ const built = buildOf(ofName);
827
+ recs.push(built.rec);
828
+ images.set(ofName, built.raster);
829
+ }
830
+ }
831
+ this.preLimbs = new PartSet({ rig_size: [this.W, this.H], scale_rig_per_source: this.rigScale, ghost_px: {}, parts: recs }, images);
832
+ return this.preLimbs;
833
+ }
834
+
667
835
  /** The See-through parts: every part but the `painting:` patches. */
668
836
  layered(): PartRecord[] {
669
837
  return this.recs.filter((p) => splitFrom(p.from)[0] !== PAINTING_RUN);
@@ -1226,8 +1394,10 @@ export function proposeWithBasis(
1226
1394
  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
1395
  })),
1228
1396
  );
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);
1397
+ // Issue #349, #440: limbs are drafted from the parts before their cut — a complete assemble with config.limbs
1398
+ // reconstructs its pre-limb parts here in the same run; a standalone or incomplete limb part handed to propose
1399
+ // on its own is refused by name.
1400
+ const limbed = P.standaloneLimbParts();
1231
1401
  refuseIfAny(
1232
1402
  limbed.length === 0
1233
1403
  ? []
@@ -1235,10 +1405,11 @@ export function proposeWithBasis(
1235
1405
  {
1236
1406
  code: 'PROPOSE_LIMB_PARTS',
1237
1407
  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`,
1408
+ 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
1409
  },
1240
1410
  ],
1241
1411
  );
1412
+ P = P.beforeLimbs();
1242
1413
  const bones: BoneEntry[] = [];
1243
1414
  const records: BoneBasis[] = [];
1244
1415
  /** Issue #349: the limbs `--keypoints` drafts, and the parts they take (not made, so not given a mesh or a region). */
@@ -1403,7 +1574,7 @@ export function proposeWithBasis(
1403
1574
  const T = (p: PartRecord | readonly PartRecord[], tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
1404
1575
  const partNames = Array.isArray(p) ? p.map((x) => x.name) : [(p as PartRecord).name];
1405
1576
  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))];
1577
+ 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
1578
  for (const t of trees) {
1408
1579
  C(
1409
1580
  t.chain,
@@ -1673,27 +1844,45 @@ export function proposeWithBasis(
1673
1844
  ]);
1674
1845
  }
1675
1846
  }
1676
- const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
1847
+ const massesBySide = new Map<'r' | 'l', BackHairMass[]>(activeSides.map(([s]) => [s, backHairMasses(unionMask, s, axis, y0, shoulderLine, g)]));
1848
+ const useMasses = rawRcp.chains === undefined;
1677
1849
  const all: TreeChain[] = [];
1678
1850
  let n = 0;
1679
1851
  const partNamesLabel = list.map((q) => q.name).join(' -> ');
1680
1852
  for (const [side] of activeSides) {
1681
1853
  const prefix = prefixOf(side);
1682
- const trees = T(list, 'back hair', rcp, {
1854
+ const sideMasses = massesBySide.get(side) ?? [];
1855
+ if (useMasses && sideMasses.length === 0) continue;
1856
+ const sideRcp: ClothRecipe = { ...rawRcp, chains: useMasses ? sideMasses.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)) };
1857
+ const frame = `${partNamesLabel}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
1858
+ const trees = T(list, 'back hair', sideRcp, {
1683
1859
  prefix,
1684
1860
  parent: 'head',
1685
1861
  rootY: eyeY,
1686
1862
  tipY: bot - 4,
1687
1863
  xAt: (y, f) => bandX(unionMask, y, { frac: f, side, axis }),
1688
1864
  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)`,
1865
+ frame,
1690
1866
  edges: side,
1867
+ masses: useMasses
1868
+ ? sideMasses.map((m): TreeMassInput => ({
1869
+ rootY: m.minY <= y0 ? eyeY : m.minY,
1870
+ tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
1871
+ xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
1872
+ fallbackX: m.minY <= y0 && sideMasses.length === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
1873
+ frame,
1874
+ }))
1875
+ : undefined,
1691
1876
  });
1692
- const made = treeConstraints(trees, rcp, null);
1877
+ const made = treeConstraints(trees, sideRcp, null);
1693
1878
  clothConstraints.push(...made);
1694
1879
  n += made.length;
1695
1880
  all.push(...trees);
1696
1881
  }
1882
+ const rcp: ClothRecipe = {
1883
+ ...rawRcp,
1884
+ chains: useMasses ? Math.max(1, ...(activeSides.map(([s]) => (massesBySide.get(s) ?? []).length))) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
1885
+ };
1697
1886
  const spans: Array<TailSpan & { depth: number }> = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false, depth: -1 }];
1698
1887
  for (const t of all) {
1699
1888
  const line = [...t.points, t.tip];
@@ -1778,9 +1967,11 @@ export function proposeWithBasis(
1778
1967
  } else if (recipeOf('back hair') !== null && (backHairParts === null || backHairSingleSame)) {
1779
1968
  // Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
1780
1969
  // 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`).
1970
+ // Issue #390: an explicit tree count per side that exceeds `floor(width / grid)` at the root row or roots a tree
1971
+ // outside the 15 px dilated art is refused by name (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`).
1972
+ // Issue #445: when `chains` is omitted, the tree count on each side is the number of visible hair masses on that
1973
+ // side (connected components below the root row, merged within one grid cell), one tree per mass, rooted at that
1974
+ // mass's top row and inside its columns.
1784
1975
  const rawRcp = recipeOf('back hair') as ClothRecipe;
1785
1976
  const mask = P.alpha(p);
1786
1977
  const dilated = dilate(mask, 31);
@@ -1835,30 +2026,66 @@ export function proposeWithBasis(
1835
2026
  ]);
1836
2027
  }
1837
2028
  }
1838
- const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
2029
+ const shoulderLine = neckY + 0.5 * fh;
2030
+ const rMasses = backHairMasses(mask, 'r', axis, y0, shoulderLine, g);
2031
+ const lMasses = backHairMasses(mask, 'l', axis, y0, shoulderLine, g);
2032
+ const massesBySide = new Map<'r' | 'l', BackHairMass[]>([
2033
+ ['r', rMasses],
2034
+ ['l', lMasses],
2035
+ ]);
2036
+ const useMasses = rawRcp.chains === undefined;
1839
2037
  const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
1840
2038
  const all: TreeChain[] = [];
1841
2039
  let n = 0;
1842
2040
  for (const [side] of SIDES) {
1843
2041
  const prefix = prefixOf(side);
1844
- const trees = T(p, 'back hair', rcp, {
2042
+ const sideMasses = massesBySide.get(side) ?? [];
2043
+ if (useMasses && sideMasses.length === 0) continue;
2044
+ const sideRcp: ClothRecipe = { ...rawRcp, chains: useMasses ? sideMasses.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)) };
2045
+ const frame = `${p.name}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
2046
+ const trees = T(p, 'back hair', sideRcp, {
1845
2047
  prefix,
1846
2048
  parent: 'head',
1847
2049
  rootY: eyeY,
1848
2050
  tipY: bot - 4,
1849
2051
  xAt: (y, f) => bandX(mask, y, { frac: f, side, axis }),
1850
2052
  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)`,
2053
+ frame,
1852
2054
  edges: side,
2055
+ masses: useMasses
2056
+ ? sideMasses.map((m): TreeMassInput => ({
2057
+ rootY: m.minY <= y0 ? eyeY : m.minY,
2058
+ tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
2059
+ xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
2060
+ fallbackX: m.minY <= y0 && sideMasses.length === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
2061
+ frame,
2062
+ }))
2063
+ : undefined,
1853
2064
  });
1854
2065
  for (const t of trees) segs.push(t.chain);
1855
- const made = treeConstraints(trees, rcp, null);
2066
+ const made = treeConstraints(trees, sideRcp, null);
1856
2067
  clothConstraints.push(...made);
1857
2068
  n += made.length;
1858
2069
  all.push(...trees);
1859
2070
  }
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 };
2071
+ const sameSideCounts = !useMasses || rMasses.length === lMasses.length;
2072
+ const noteRcp: ClothRecipe = {
2073
+ ...rawRcp,
2074
+ chains: useMasses ? (sameSideCounts ? rMasses.length : rMasses.length + lMasses.length) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
2075
+ };
2076
+ notes.push(
2077
+ clothNote(
2078
+ p.name,
2079
+ 'back hair',
2080
+ all,
2081
+ noteRcp,
2082
+ n,
2083
+ sameSideCounts
2084
+ ? ' per side of the eye axis, from head, rooted on the eye line and tipped 4 px above its bottom'
2085
+ : ` (${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`,
2086
+ ),
2087
+ );
2088
+ meshes[p.name] = { grid: g, r: rawRcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
1862
2089
  // Issue #391: the spans are this mesh's segments, the head's fixed one and every tree link, in segment order.
1863
2090
  const spans: TailSpan[] = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false }];
1864
2091
  for (const t of all) {
@@ -2574,17 +2801,118 @@ export function proposeWithBasis(
2574
2801
  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
2802
  };
2576
2803
  };
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
- });
2804
+ // 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.
2805
+ const skirtInput = (p: PartRecord, mask: Mask, prefix: string, r: ClothRecipe, bot = p.y + p.h, emitNotes = true): TreeInput => {
2806
+ const y0 = hip[1] + 20;
2807
+ const y0Int = Math.max(0, pyInt(y0));
2808
+ const botInt = Math.min(mask.height, pyInt(bot));
2809
+ const n = r.chains ?? RECIPE_BACK_HAIR_CHAINS;
2810
+ const into = r.split === undefined ? 1 : r.split.into;
2811
+ const traceLastOpaqueY = (f: number): number => {
2812
+ let lastY = -1;
2813
+ for (let y = y0Int; y < botInt; y++) {
2814
+ const bx = bandX(mask, y, { frac: f }) ?? p.x + f * p.w;
2815
+ const rx = Math.max(0, Math.min(mask.width - 1, pyRound(bx)));
2816
+ if (mask.data[y * mask.width + rx] !== 0) {
2817
+ lastY = y;
2818
+ } else if (lastY >= y0Int) {
2819
+ let gapContinues = false;
2820
+ for (let yy = y + 1; yy <= Math.min(botInt - 1, y + 12); yy++) {
2821
+ if (mask.data[yy * mask.width + rx] !== 0) {
2822
+ gapContinues = true;
2823
+ break;
2824
+ }
2825
+ }
2826
+ if (!gapContinues) break;
2827
+ }
2828
+ }
2829
+ return lastY;
2830
+ };
2831
+ const shares: number[] = [];
2832
+ for (let k = 0; k < n; k++) {
2833
+ shares.push((k + 0.5) / n);
2834
+ if (into > 1) {
2835
+ for (let b = 0; b < into; b++) shares.push((k + (b + 0.5) / into) / n);
2836
+ }
2837
+ }
2838
+ const uniformBottom = shares.every((f) => {
2839
+ const lastY = traceLastOpaqueY(f);
2840
+ if (lastY >= botInt - 4) return true;
2841
+ if (lastY < botInt - 36 || lastY < y0Int + 4) return false;
2842
+ const txBot = Math.max(0, Math.min(mask.width - 1, pyRound(bandX(mask, bot - 4, { frac: f }) ?? p.x + f * p.w)));
2843
+ for (let yy = Math.max(y0Int, botInt - 16); yy < botInt; yy++) {
2844
+ if (mask.data[yy * mask.width + txBot] !== 0) return true;
2845
+ }
2846
+ return false;
2847
+ });
2848
+ const notedEmpty = new Set<number>();
2849
+ const cappedXAt = (maxY: number) => (y: number, f: number): number | null => {
2850
+ const yi = Math.min(maxY, Math.max(0, pyRound(y)));
2851
+ const a = Math.max(0, pyInt(y - 12));
2852
+ const b = Math.min(maxY + 1, pyInt(y + 12));
2853
+ let x0 = mask.width;
2854
+ let x1 = -1;
2855
+ for (let yy = a; yy < b; yy++) {
2856
+ for (let x = 0; x < mask.width; x++) {
2857
+ if (mask.data[yy * mask.width + x] !== 0) {
2858
+ if (x < x0) x0 = x;
2859
+ if (x > x1) x1 = x;
2860
+ }
2861
+ }
2862
+ }
2863
+ const raw = x1 < x0 ? (bandX(mask, yi, { frac: f }) ?? p.x + f * p.w) : x0 + f * (x1 - x0);
2864
+ const rx = Math.max(0, Math.min(mask.width - 1, pyRound(raw)));
2865
+ if (mask.data[yi * mask.width + rx] !== 0) return raw;
2866
+ let bestX: number | null = null;
2867
+ let bestDist = mask.width + 1;
2868
+ for (let x = 0; x < mask.width; x++) {
2869
+ if (mask.data[yi * mask.width + x] !== 0) {
2870
+ const d = Math.abs(x - rx);
2871
+ if (d < bestDist) {
2872
+ bestDist = d;
2873
+ bestX = x;
2874
+ }
2875
+ }
2876
+ }
2877
+ return bestX ?? raw;
2878
+ };
2879
+ return {
2880
+ prefix,
2881
+ parent: 'hip',
2882
+ rootY: y0,
2883
+ tipY: bot - 4,
2884
+ xAt: (y, f) => bandX(mask, y, { frac: f }),
2885
+ fallbackX: (f) => p.x + f * p.w,
2886
+ frame: `${p.name}'s art (no art in the band: that share of its box's width)`,
2887
+ edges: 'both',
2888
+ columnTip: (k, f) => {
2889
+ const col0 = Math.floor(p.x + (k / n) * p.w);
2890
+ const col1 = Math.max(col0, Math.ceil(p.x + ((k + 1) / n) * p.w) - 1);
2891
+ let colMaxY = -1;
2892
+ for (let y = y0Int; y < botInt; y++) {
2893
+ for (let x = col0; x <= col1; x++) {
2894
+ if (mask.data[y * mask.width + x] !== 0) colMaxY = y;
2895
+ }
2896
+ }
2897
+ const fc = (k + 0.5) / n;
2898
+ const trunkLastY = traceLastOpaqueY(fc);
2899
+ if (colMaxY < y0Int + 4 || trunkLastY < y0Int + 4) {
2900
+ if (emitNotes && !notedEmpty.has(k)) {
2901
+ notedEmpty.add(k);
2902
+ notes.push(
2903
+ `${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`,
2904
+ );
2905
+ }
2906
+ return null;
2907
+ }
2908
+ if (uniformBottom) return { tipY: bot - 4 };
2909
+ const rawLastY = traceLastOpaqueY(f);
2910
+ const lastY = rawLastY >= y0Int + 4 ? rawLastY : trunkLastY;
2911
+ const tipY = Math.min(bot - 4, Math.max(y0Int + 4, lastY - 3));
2912
+ return { tipY, xAt: cappedXAt(lastY) };
2913
+ },
2914
+ };
2915
+ };
2588
2916
  // Issue #416: a bottomwear part lying on the trees of the dress config.cloth.bottomwear.part names (a ruffle hem
2589
2917
  // See-through returned on its own) rides the links it overlaps, as a layer wrapped round a tail does (issue #391),
2590
2918
  // 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 +2974,12 @@ export function proposeWithBasis(
2646
2974
  const prefix = 'skirt';
2647
2975
  const partNamesLabel = list.map((q) => q.name).join(' -> ');
2648
2976
  const trees = T(list, 'bottomwear', dressRecipe, {
2649
- ...skirtInput(p0, unionMask, prefix),
2977
+ ...skirtInput(p0, unionMask, prefix, dressRecipe, bot),
2650
2978
  tipY: bot - 4,
2651
2979
  frame: `${partNamesLabel}'s art (no art in the band: that share of ${p0.name}'s box width)`,
2652
2980
  });
2653
2981
  let swing: { bone: string; rest: number } | null = null;
2654
- if (dressRecipe.swing !== undefined) {
2982
+ if (dressRecipe.swing !== undefined && trees.length > 0) {
2655
2983
  const cx = or(bandX(unionMask, y0, { frac: 0.5 }), p0.x + 0.5 * p0.w);
2656
2984
  const first = trees[0].points.length > 1 ? trees[0].points[1][1] : trees[0].tip[1];
2657
2985
  const at: [number, number] = [cx, y0];
@@ -2739,7 +3067,7 @@ export function proposeWithBasis(
2739
3067
  ? []
2740
3068
  : ((): PartRecord[] => {
2741
3069
  const mask = P.alpha(namedSkirt);
2742
- const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
3070
+ const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt', dressRecipe, namedSkirt.y + namedSkirt.h, false), dressRecipe));
2743
3071
  return bw.filter((q) => !multiPartClaimed.has(q.name) && overLinks(tail, q).rides);
2744
3072
  })();
2745
3073
  let dressDrafted: Tail | null = null;
@@ -2756,11 +3084,11 @@ export function proposeWithBasis(
2756
3084
  if (skirtRecipe !== null) {
2757
3085
  const prefix = skirts.length === 1 ? 'skirt' : `skirt_${p.name}`;
2758
3086
  const fixed = [...segs];
2759
- const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix));
3087
+ const trees = T(p, 'bottomwear', skirtRecipe, skirtInput(p, mask, prefix, skirtRecipe));
2760
3088
  if (p === namedSkirt) dressDrafted = dressTail(p, fixed, trees);
2761
3089
  for (const t of trees) segs.push(t.chain);
2762
3090
  let swing: { bone: string; rest: number } | null = null;
2763
- if (skirtRecipe.swing !== undefined) {
3091
+ if (skirtRecipe.swing !== undefined && trees.length > 0) {
2764
3092
  // One link long, straight down from the centre of the root row: the hip is drafted with no tip, so it is
2765
3093
  // unturned and the swing's setup rotation in its frame is its own direction, -90 degrees (Spine, y up).
2766
3094
  const cx = or(bandX(mask, y0, { frac: 0.5 }), p.x + 0.5 * p.w);
@@ -3144,7 +3472,10 @@ function artDistance(m: Mask, at: readonly [number, number]): number {
3144
3472
  * carries one: a part left to the rule beside parts placed from joints would
3145
3473
  * be read by two rules at once. Sides come from the joints, not from the eye
3146
3474
  * axis, so a figure seen from behind or lying across the axis is paired by
3147
- * where its wrists are.
3475
+ * where its wrists are. Issue #458: when pre-separated `handwear-r` and
3476
+ * `handwear-l` parts cross so that `<s>_wrist` lies on the opaque art of its
3477
+ * own side's `handwear-<s>` part and also within 15 px of the opposite side's
3478
+ * `handwear-<other>` part, it resolves to its own side's part.
3148
3479
  *
3149
3480
  * Issue #199: a wrist on no part within that tolerance is measured to every
3150
3481
  * part by the same Chebyshev distance ({@link artDistance}). With
@@ -3171,7 +3502,18 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
3171
3502
  const near = new Map(hw.map((p) => [p, dilate(P.alpha(p), 2 * WRIST_ON_PART_PX + 1)]));
3172
3503
  const arms: JointArm[] = [];
3173
3504
  for (const a of complete) {
3174
- const on = hw.filter((p) => isOn(near.get(p) as Mask, a.wrist[0], a.wrist[1]));
3505
+ const raw = hw.filter((p) => isOn(near.get(p) as Mask, a.wrist[0], a.wrist[1]));
3506
+ const ownTag = `handwear-${a.side}`;
3507
+ const oppTag = `handwear-${a.side === 'r' ? 'l' : 'r'}`;
3508
+ const own =
3509
+ raw.length > 1 &&
3510
+ raw.every((p) => {
3511
+ const t = splitFrom(p.from)[1];
3512
+ return t === ownTag || t === oppTag;
3513
+ })
3514
+ ? raw.filter((p) => splitFrom(p.from)[1] === ownTag && isOn(P.alpha(p), a.wrist[0], a.wrist[1]))
3515
+ : [];
3516
+ const on = own.length === 1 ? own : raw;
3175
3517
  if (on.length > 1) return { arms: null, why: `${a.side}_wrist at ${fmtAt(a.wrist)} lies on the art of ${on.length} handwear parts (${on.map((p) => p.name).join(', ')}), so it does not say which sleeve it ends` };
3176
3518
  if (on.length === 1) {
3177
3519
  arms.push({ ...a, part: on[0], snap: null });
@@ -3800,6 +4142,62 @@ export interface NearEmptyHandFinding {
3800
4142
  | { kind: 'forearm-width'; width: number };
3801
4143
  }
3802
4144
 
4145
+ /**
4146
+ * Issue #445: back-hair cloth trees on one side of the eye axis whose count does not match the side's visible hair
4147
+ * masses or whose link or tip leaves its mass's column range.
4148
+ */
4149
+ export interface BackHairMassesFinding {
4150
+ kind: 'back-hair-masses';
4151
+ mesh: string;
4152
+ side: 'r' | 'l';
4153
+ trunks: string[];
4154
+ masses: Array<{ minX: number; maxX: number; minY: number; maxY: number }>;
4155
+ outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }>;
4156
+ }
4157
+
4158
+ /**
4159
+ * Issue #439: both arms overlap in one 8-connected component of a single handwear layer (`handwear-r` or `handwear-l`),
4160
+ * resolved by declaring `config.seethrough.arm_split` (`x` or `line`, and `order`).
4161
+ */
4162
+ export interface OverlappingArmsFinding {
4163
+ kind: 'overlapping-arms';
4164
+ part: string;
4165
+ from: string;
4166
+ rAt: [number, number];
4167
+ lAt: [number, number];
4168
+ opaquePx: number;
4169
+ }
4170
+
4171
+ /**
4172
+ * Issue #446: two limbs sharing one part (`from`) cross in an X at `at` with no `config.limbs.<limb>.split`
4173
+ * declared on the covering limb, so `nearestLimb` cuts a straight line through the crossing.
4174
+ */
4175
+ export interface CrossedLimbsFinding {
4176
+ kind: 'crossed-limbs';
4177
+ part: string;
4178
+ from: string;
4179
+ a: string;
4180
+ b: string;
4181
+ at: [number, number];
4182
+ }
4183
+
4184
+ /**
4185
+ * Issue #447: a stop's contact point `<chain>_touch` (`at` in `against`'s local frame) lies above the first stopped
4186
+ * link's root (`links[0]` at `[x0, y0]`) in rig pixels (`ty < y0`, i.e. higher Spine y), so `<chain>_rest` aims
4187
+ * upward into the upper half-plane (`|rest| > 90°`) and `|rest| + outward > 180°` wraps past ±180° (`RIG_STOP_BOUNDS`).
4188
+ */
4189
+ export interface StopTouchAboveRootFinding {
4190
+ kind: 'stop-touch-above-root';
4191
+ chain: string;
4192
+ firstLink: string;
4193
+ rootAt: [number, number];
4194
+ against: string;
4195
+ at: [number, number];
4196
+ touchAt: [number, number];
4197
+ rest: number;
4198
+ outward: number;
4199
+ }
4200
+
3803
4201
  export type LintFinding =
3804
4202
  | OffArtFinding
3805
4203
  | OffLockFinding
@@ -3810,7 +4208,11 @@ export type LintFinding =
3810
4208
  | HipNearerNeckFinding
3811
4209
  | ChainNotAdvancingFinding
3812
4210
  | SqueezeLintFinding
3813
- | NearEmptyHandFinding;
4211
+ | NearEmptyHandFinding
4212
+ | BackHairMassesFinding
4213
+ | OverlappingArmsFinding
4214
+ | CrossedLimbsFinding
4215
+ | StopTouchAboveRootFinding;
3814
4216
 
3815
4217
  /** Which rule set read the torso and the sleeve chains, under `--keypoints`. */
3816
4218
  export interface LintBasis {
@@ -3910,7 +4312,7 @@ export function coverageOutcome(c: BoneCoverage): CoverageOutcome {
3910
4312
  */
3911
4313
  export function lint(
3912
4314
  P: PartSet,
3913
- spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; regions?: Readonly<Record<string, string>>; motion?: SkeletonSections['motion']; constraints?: readonly ConfigConstraint[] },
4315
+ 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>>; stops?: readonly ConfigStop[] },
3914
4316
  joints?: RigJoints,
3915
4317
  ): LintResult {
3916
4318
  const { byName } = expand(spec.bones);
@@ -4097,6 +4499,243 @@ export function lint(
4097
4499
  }
4098
4500
  }
4099
4501
  }
4502
+ // Issue #439: when both arms overlap in one 8-connected component of a single handwear layer, name the overlap and
4503
+ // the config.seethrough.arm_split declaration that separates them into two layers.
4504
+ if (joints !== undefined && joints.joints.r_wrist.at !== null && joints.joints.l_wrist.at !== null) {
4505
+ const hwParts = [...P.byTag('handwear-r', undefined, true), ...P.byTag('handwear-l', undefined, true)].filter((p) => p.limb === undefined);
4506
+ if (hwParts.length === 1) {
4507
+ const part = hwParts[0];
4508
+ const rAt: [number, number] = [joints.joints.r_wrist.at[0], joints.joints.r_wrist.at[1]];
4509
+ const lAt: [number, number] = [joints.joints.l_wrist.at[0], joints.joints.l_wrist.at[1]];
4510
+ {
4511
+ const cc = connectedComponents(P.alpha(part), 8);
4512
+ const compsNear = (pt: readonly [number, number]): Set<number> => {
4513
+ const hit = new Set<number>();
4514
+ const px = pyInt(pt[0]);
4515
+ const py = pyInt(pt[1]);
4516
+ const x0 = Math.max(0, px - WRIST_ON_PART_PX);
4517
+ const x1 = Math.min(P.W - 1, px + WRIST_ON_PART_PX);
4518
+ const y0 = Math.max(0, py - WRIST_ON_PART_PX);
4519
+ const y1 = Math.min(P.H - 1, py + WRIST_ON_PART_PX);
4520
+ for (let y = y0; y <= y1; y++) {
4521
+ for (let x = x0; x <= x1; x++) {
4522
+ const l = cc.labels[y * P.W + x];
4523
+ if (l > 0) hit.add(l);
4524
+ }
4525
+ }
4526
+ return hit;
4527
+ };
4528
+ const rComps = compsNear(rAt);
4529
+ const lComps = compsNear(lAt);
4530
+ const shared = [...rComps].find((l) => lComps.has(l));
4531
+ if (shared !== undefined) {
4532
+ findings.push({
4533
+ kind: 'overlapping-arms',
4534
+ part: part.name,
4535
+ from: part.from,
4536
+ rAt,
4537
+ lAt,
4538
+ opaquePx: cc.stats[shared].area,
4539
+ });
4540
+ }
4541
+ }
4542
+ }
4543
+ }
4544
+ // Issue #445: back-hair cloth trees (trunks `hairback_..._<r|l><k>_` hung from head) are held to the visible hair
4545
+ // masses on each side of the eye axis — one tree per mass, every link and tip inside that mass's column range.
4546
+ const bhParts = P.headFirst('back hair').filter((p) => spec.meshes[p.name] !== undefined);
4547
+ if (bhParts.length > 0) {
4548
+ try {
4549
+ const [fx0, fy0, fw, fh] = faceBoxOf(P).box;
4550
+ const ewR = P.byTag('eyewhite-r')[0] ?? null;
4551
+ const ewL = P.byTag('eyewhite-l')[0] ?? null;
4552
+ const axis = ewR !== null && ewL !== null ? (center(ewR)[0] + center(ewL)[0]) / 2 : fx0 + fw / 2;
4553
+ const eyeYs = [ewR, ewL].filter((x): x is PartRecord => x !== null).map((x) => center(x)[1]);
4554
+ const eyeY = eyeYs.length > 0 ? npMean(eyeYs) : fy0 + 0.5 * fh;
4555
+ const chin = fy0 + fh;
4556
+ const neckp = P.headFirst('neck');
4557
+ const neckY = joints?.joints.neck.at?.[1] ?? (neckp.length > 0 ? (chin + neckp[0].y + neckp[0].h) / 2 : chin + 0.12 * fh);
4558
+ const shoulderLine = neckY + 0.5 * fh;
4559
+ const y0 = pyRound(eyeY);
4560
+ const seenGroups = new Set<string>();
4561
+ for (const part of bhParts) {
4562
+ const m = spec.meshes[part.name];
4563
+ const segStrings = m.segments.filter((sp): sp is string => typeof sp === 'string');
4564
+ const trunkMatch = segStrings.map((sp) => /^(hairback_(?:.+_)?)(r|l)(\d+)_$/.exec(sp)).find((x) => x !== null) ?? null;
4565
+ if (trunkMatch === null) continue;
4566
+ const basePrefix = trunkMatch[1];
4567
+ if (seenGroups.has(basePrefix)) continue;
4568
+ seenGroups.add(basePrefix);
4569
+ const groupParts = bhParts.filter((q) =>
4570
+ spec.meshes[q.name].segments.some((sp) => typeof sp === 'string' && sp.startsWith(basePrefix) && /^(r|l)\d+_$/.test(sp.slice(basePrefix.length))),
4571
+ );
4572
+ const mask = groupParts.length === 1 ? P.alpha(part) : newMask(P.W, P.H);
4573
+ if (groupParts.length > 1) {
4574
+ for (const q of groupParts) {
4575
+ const mq = P.alpha(q);
4576
+ for (let i = 0; i < mask.data.length; i++) if (mq.data[i]) mask.data[i] = 1;
4577
+ }
4578
+ }
4579
+ const m0 = spec.meshes[groupParts[0].name];
4580
+ const g = 'grid' in m0 ? m0.grid : partGrid(groupParts[0])[0];
4581
+ for (const side of ['r', 'l'] as const) {
4582
+ const trunkRe = new RegExp(`^${basePrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${side}(\\d+)_$`);
4583
+ const sideTrunks = spec.bones
4584
+ .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)))
4585
+ .sort((a, b) => Number(trunkRe.exec(a.chain)![1]) - Number(trunkRe.exec(b.chain)![1]));
4586
+ if (sideTrunks.length === 0) continue;
4587
+ const masses = backHairMasses(mask, side, axis, y0, shoulderLine, g);
4588
+ const outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }> = [];
4589
+ const sideMinX = masses.length > 0 ? Math.min(...masses.map((x) => x.minX)) : 0;
4590
+ const sideMaxX = masses.length > 0 ? Math.max(...masses.map((x) => x.maxX)) : P.W - 1;
4591
+ sideTrunks.forEach((tr, k) => {
4592
+ const target = k < masses.length ? masses[k] : null;
4593
+ const minX = target !== null ? target.minX : sideMinX;
4594
+ const maxX = target !== null ? target.maxX : sideMaxX;
4595
+ const treeBase = tr.chain.slice(0, -1);
4596
+ const treeRe = new RegExp(`^${treeBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[a-z]*_$`);
4597
+ const treeChains = spec.bones.filter((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && treeRe.test(b.chain));
4598
+ for (const c of treeChains) {
4599
+ c.points.forEach((pt, idx) => {
4600
+ const px = pyInt(pt[0]);
4601
+ const py = pyInt(pt[1]);
4602
+ if (px < minX || px > maxX) outside.push({ point: `${c.chain}${idx}`, at: [px, py], minX, maxX });
4603
+ });
4604
+ const tx = pyInt(c.tip[0]);
4605
+ const ty = pyInt(c.tip[1]);
4606
+ if (tx < minX || tx > maxX) outside.push({ point: `${c.chain} tip`, at: [tx, ty], minX, maxX });
4607
+ }
4608
+ });
4609
+ if (sideTrunks.length !== masses.length || outside.length > 0) {
4610
+ findings.push({
4611
+ kind: 'back-hair-masses',
4612
+ mesh: groupParts.map((q) => q.name).join(' -> '),
4613
+ side,
4614
+ trunks: sideTrunks.map((t) => t.chain),
4615
+ masses: masses.map((x) => ({ minX: x.minX, maxX: x.maxX, minY: x.minY, maxY: x.maxY })),
4616
+ outside,
4617
+ });
4618
+ }
4619
+ }
4620
+ }
4621
+ } catch (err) {
4622
+ if (!(err instanceof PartsError)) throw err;
4623
+ }
4624
+ }
4625
+ // Issue #446: when two limbs sharing one part cross in an X without a per-limb split declared on the covering limb,
4626
+ // nearest-bone splitting cuts a straight line through the crossing.
4627
+ {
4628
+ const baseP = P.beforeLimbs();
4629
+ const places = bonePlaces(spec.bones);
4630
+ if (spec.limbs !== undefined) {
4631
+ const limbNames = Object.keys(spec.limbs);
4632
+ const fromParts = [...new Set(limbNames.flatMap((l) => spec.limbs![l].segments.map((sg) => sg.from)))];
4633
+ for (const src of fromParts) {
4634
+ const on = limbNames.filter((l) => spec.limbs![l].segments.some((sg) => sg.from === src));
4635
+ if (on.length < 2 || on.some((l) => spec.limbs![l].split !== undefined)) continue;
4636
+ const valid = on.filter((l) => spec.limbs![l].segments.every((sg) => places.has(sg.bone)));
4637
+ if (valid.length < 2) continue;
4638
+ const rec = baseP.recs.find((r) => r.name === src);
4639
+ for (let i = 0; i < valid.length; i++) {
4640
+ const polyA = limbPolyline(spec.limbs[valid[i]], places);
4641
+ for (let j = i + 1; j < valid.length; j++) {
4642
+ const polyB = limbPolyline(spec.limbs[valid[j]], places);
4643
+ const cross = limbCrossing(polyA, polyB);
4644
+ if (cross !== null) {
4645
+ findings.push({
4646
+ kind: 'crossed-limbs',
4647
+ part: src,
4648
+ from: rec?.from ?? '',
4649
+ a: valid[i],
4650
+ b: valid[j],
4651
+ at: cross,
4652
+ });
4653
+ }
4654
+ }
4655
+ }
4656
+ }
4657
+ } else {
4658
+ const legR = spec.bones.find((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && b.chain === 'leg_r' && b.points.length >= 2);
4659
+ const legL = spec.bones.find((b): b is Extract<BoneEntry, { chain: string }> => 'chain' in b && b.chain === 'leg_l' && b.points.length >= 2);
4660
+ if (legR !== undefined && legL !== undefined) {
4661
+ const polyR: Array<[number, number]> = [...legR.points.map((p): [number, number] => [p[0], p[1]]), [legR.tip[0], legR.tip[1]]];
4662
+ const polyL: Array<[number, number]> = [...legL.points.map((p): [number, number] => [p[0], p[1]]), [legL.tip[0], legL.tip[1]]];
4663
+ const cross = limbCrossing(polyR, polyL);
4664
+ if (cross !== null) {
4665
+ const lwParts = baseP.byTag('legwear');
4666
+ const shared = lwParts.find((p: PartRecord) => {
4667
+ const near = dilate(baseP.alpha(p), 2 * WRIST_ON_PART_PX + 1);
4668
+ return isOn(near, legR.tip[0], legR.tip[1]) && isOn(near, legL.tip[0], legL.tip[1]);
4669
+ });
4670
+ if (shared !== undefined) {
4671
+ findings.push({
4672
+ kind: 'crossed-limbs',
4673
+ part: shared.name,
4674
+ from: shared.from,
4675
+ a: 'leg_r',
4676
+ b: 'leg_l',
4677
+ at: cross,
4678
+ });
4679
+ }
4680
+ }
4681
+ }
4682
+ }
4683
+ }
4684
+ // Issue #447: when a stop's contact point `<chain>_touch` (`at` in `against`'s local frame) lies above the first
4685
+ // stopped link's root (`links[0]`) in rig pixels (`ty < y0`, i.e. higher Spine y), `<chain>_rest` aims into the
4686
+ // upper half-plane (`|rest| > 90°`) and `|rest| + outward > 180°` wraps past ±180° (`RIG_STOP_BOUNDS`).
4687
+ if (spec.stops !== undefined && spec.stops.length > 0) {
4688
+ const boneEnds = new Map<string, Point>();
4689
+ const chainLinks = new Map<string, string[]>();
4690
+ for (const e of spec.bones) {
4691
+ if ('chain' in e) {
4692
+ const names = e.points.map((_, k) => `${e.chain}${k}`);
4693
+ chainLinks.set(e.chain, names);
4694
+ e.points.forEach((_, k) => {
4695
+ boneEnds.set(names[k], k + 1 < e.points.length ? e.points[k + 1] : e.tip);
4696
+ });
4697
+ } else if (e.tip !== undefined) {
4698
+ boneEnds.set(e.name, e.tip);
4699
+ }
4700
+ }
4701
+ for (const s of spec.stops) {
4702
+ const links = s.links ?? chainLinks.get(s.chain) ?? [];
4703
+ if (links.length === 0) continue;
4704
+ const firstLink = links[0];
4705
+ const rootBone = byName.get(firstLink);
4706
+ const againstBone = byName.get(s.against);
4707
+ if (rootBone === undefined || againstBone === undefined) continue;
4708
+ const againstEnd = boneEnds.get(s.against);
4709
+ let againstRot = 0;
4710
+ if (againstEnd !== undefined) {
4711
+ const dx = againstEnd[0] - againstBone.x;
4712
+ const dy = cropToSpineY(againstEnd[1], P.H) - cropToSpineY(againstBone.y, P.H);
4713
+ if (dx !== 0 || dy !== 0) againstRot = (Math.atan2(dy, dx) * 180) / Math.PI;
4714
+ }
4715
+ const frame = computeExactFrameTransforms([
4716
+ { name: s.against, x: againstBone.x, y: cropToSpineY(againstBone.y, P.H), rotation: againstRot },
4717
+ ]).get(s.against)!;
4718
+ const [wx, wy] = toWorld(frame, s.at[0], s.at[1]);
4719
+ const touchAt: [number, number] = [pyRound(wx * 10) / 10, pyRound(cropToSpineY(wy, P.H) * 10) / 10];
4720
+ const aimDx = wx - rootBone.x;
4721
+ const aimDy = wy - cropToSpineY(rootBone.y, P.H);
4722
+ if (aimDx === 0 && aimDy === 0) continue;
4723
+ const rest = pyRound(normaliseDegrees((Math.atan2(aimDy, aimDx) * 180) / Math.PI) * 100) / 100;
4724
+ if (touchAt[1] < rootBone.y || Math.abs(rest) + s.outward > 180) {
4725
+ findings.push({
4726
+ kind: 'stop-touch-above-root',
4727
+ chain: s.chain,
4728
+ firstLink,
4729
+ rootAt: [rootBone.x, rootBone.y],
4730
+ against: s.against,
4731
+ at: [s.at[0], s.at[1]],
4732
+ touchAt,
4733
+ rest,
4734
+ outward: s.outward,
4735
+ });
4736
+ }
4737
+ }
4738
+ }
4100
4739
  // Issue #314: every pair of keyed chains a lattice mesh names, swung on the rig stage's own lattice and weights, held
4101
4740
  // to check's TEXTURE_STRETCH bar (src/squeeze.ts). Only with a motion: without one nothing swings.
4102
4741
  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 +4772,23 @@ function findingBones(f: LintFinding, bones: readonly BoneEntry[]): string[] {
4133
4772
  case 'squeeze':
4134
4773
  return [...new Set(f.vertices.map((v) => v.weights[0]?.bone).filter((n): n is string => n !== undefined))];
4135
4774
  case 'near-empty-hand':
4775
+ case 'overlapping-arms':
4776
+ case 'crossed-limbs':
4777
+ case 'stop-touch-above-root':
4136
4778
  return [];
4779
+ case 'back-hair-masses': {
4780
+ const out = new Set<string>();
4781
+ for (const o of f.outside) {
4782
+ if (!o.point.endsWith(' tip')) out.add(o.point);
4783
+ else {
4784
+ const chain = o.point.slice(0, -' tip'.length);
4785
+ const e = bones.find((b) => 'chain' in b && b.chain === chain);
4786
+ if (e !== undefined && 'chain' in e) out.add(`${chain}${e.points.length - 1}`);
4787
+ }
4788
+ }
4789
+ if (out.size === 0) for (const t of f.trunks) out.add(`${t}0`);
4790
+ return [...out];
4791
+ }
4137
4792
  case 'chain-not-advancing': {
4138
4793
  if (!f.point.endsWith(' tip')) return [f.point];
4139
4794
  const chain = f.point.slice(0, -' tip'.length);
@@ -4308,6 +4963,23 @@ export function lintLine(f: LintFinding): string {
4308
4963
  : `forearm width ${pyFixed(f.basis.width, 1)} px squared`;
4309
4964
  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
4965
  }
4966
+ case 'overlapping-arms':
4967
+ 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`;
4968
+ case 'crossed-limbs':
4969
+ 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`;
4970
+ case 'stop-touch-above-root':
4971
+ return `LINT stop on ${f.chain} (${f.firstLink} at ${fmtAt(f.rootAt)}) against ${f.against} (at ${fmtAt(f.at)}) places ${f.chain}_touch at ${fmtAt(f.touchAt)}, above ${f.firstLink}'s root (rest ${pyFixed(f.rest, 2)} deg, |rest| + outward ${pyFixed(Math.abs(f.rest) + f.outward, 2)} deg): stop against the descending limb bone below ${f.firstLink}, set links to start at a link below ${f.chain}_touch, or omit the stop on a rising limb`;
4972
+ case 'back-hair-masses': {
4973
+ const massDesc =
4974
+ f.masses.length === 0
4975
+ ? 'none'
4976
+ : f.masses.map((m, i) => `mass ${i} columns x=${m.minX}..${m.maxX}, y=${m.minY}..${m.maxY}`).join('; ');
4977
+ const outDesc =
4978
+ f.outside.length === 0
4979
+ ? ''
4980
+ : `; outside mass columns: ${f.outside.map((o) => `${o.point} at ${pyIntList(o.at)} (outside x=${o.minX}..${o.maxX})`).join(', ')}`;
4981
+ 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`;
4982
+ }
4311
4983
  case 'chain-not-advancing':
4312
4984
  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
4985
  }