@rig-c/parts 1.28.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/cloth.ts CHANGED
@@ -65,7 +65,9 @@
65
65
  * Pure: no clock, no randomness; every value is a function of its inputs.
66
66
  */
67
67
  import type { ConfigConstraint, Point } from './config.ts';
68
- import type { Mask } from './raster/types.ts';
68
+ import { connectedComponents } from './raster/components.ts';
69
+ import { dilate } from './raster/morph.ts';
70
+ import { type Mask, newMask } from './raster/types.ts';
69
71
  import { pyRound } from './round.ts';
70
72
  import { segmentDistance } from './weights.ts';
71
73
 
@@ -80,7 +82,7 @@ export interface ClothStrength {
80
82
  edge: number;
81
83
  }
82
84
 
83
- /** One target's recipe, as `config.cloth.<tag>` declares it (`chains` may be omitted on `"back hair"`, where it is derived from the tail's width at the root row). */
85
+ /** One target's recipe, as `config.cloth.<tag>` declares it (`chains` may be omitted on `"back hair"`, where it is derived from the visible hair masses below the root row, issue #445). */
84
86
  export interface ClothRecipe {
85
87
  chains?: number;
86
88
  links: number;
@@ -104,14 +106,12 @@ export interface ClothRecipe {
104
106
  export type ClothRecipes = Partial<Record<ClothTarget, ClothRecipe>>;
105
107
 
106
108
  /**
107
- * 📐 The recipe's default maximum back-hair tree count per side when `chains`
108
- * is omitted on `config.cloth."back hair"` (issue #390): 2 trees per side when
109
- * the tail's opaque width at the root row holds at least two mesh-grid cells
110
- * (`floor(width / grid) >= 2`, `fixtures/cloth.ts`: 48 px on a 14 px grid) and
111
- * every tree root lies inside the art, and fewer when the tail at the root row
112
- * is narrower or a root falls off the art (`examples/wind`: 45 px on the r side
113
- * and 61 px on the l side at y=162 on a 28 px grid, with inner roots at
114
- * [323, 162] and [496, 162] on alpha 0 -> 1 tree per side).
109
+ * 📐 The fallback tree count when `draftTrees` is called without `chains` or
110
+ * `masses` (issues #390, #445). When `chains` is omitted on
111
+ * `config.cloth."back hair"`, `propose` derives the count per side from the
112
+ * visible hair masses ({@link backHairMasses}): connected components of the
113
+ * back-hair part's alpha below the root row (merged when they touch within one
114
+ * grid cell), one tree per mass, rooted at that mass's own top row.
115
115
  */
116
116
  export const RECIPE_BACK_HAIR_CHAINS = 2;
117
117
 
@@ -185,6 +185,20 @@ export interface TreeChain {
185
185
  /** Which outer sides of the piece are edges: both (a skirt, a lock), or one side of the eye axis (back hair). */
186
186
  export type EdgeSides = 'both' | 'r' | 'l';
187
187
 
188
+ /** Issue #445: one visible hair mass's own frame for a single tree rooted at that mass's top row. */
189
+ export interface TreeMassInput {
190
+ rootY: number;
191
+ tipY: number;
192
+ xAt: (y: number, frac: number) => number | null;
193
+ fallbackX: (frac: number) => number;
194
+ frame: string;
195
+ }
196
+
197
+ export interface TreeColumnTip {
198
+ tipY: number;
199
+ xAt?: (y: number, frac: number) => number | null;
200
+ }
201
+
188
202
  export interface TreeInput {
189
203
  /** The chains' name prefix: tree k's trunk is `<prefix><k>_`, its branches `<prefix><k><a|b|…>_`. */
190
204
  prefix: string;
@@ -199,6 +213,14 @@ export interface TreeInput {
199
213
  /** What the cell's frame is, for the rules: "skirt's art", "the r side of back's art". */
200
214
  frame: string;
201
215
  edges: EdgeSides;
216
+ /** Issue #445: when present, one entry per visible hair mass; tree k sits inside `masses[k]` rather than cell [k/n, (k+1)/n] of the whole side. */
217
+ masses?: readonly TreeMassInput[];
218
+ /**
219
+ * Issue #438: when present, resolves the tip row (and an optional `xAt` bounded to that column's cloth rows) for
220
+ * column `k` (`0 <= k < n`) and share `frac` (`0 <= frac <= 1`), or returns `null` when column `k` holds no cloth
221
+ * below `rootY` (skipping tree `k`).
222
+ */
223
+ columnTip?: (k: number, frac: number) => TreeColumnTip | null;
202
224
  }
203
225
 
204
226
  /** The rows the n links of a path start at, then its tip's: lengths in the ratio q^j, from root row to tip row. */
@@ -220,40 +242,71 @@ const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
220
242
 
221
243
  /** The trees of one piece, in tree order, each trunk before its branches. */
222
244
  export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
223
- const n = r.chains ?? RECIPE_BACK_HAIR_CHAINS;
245
+ const n = input.masses !== undefined ? input.masses.length : (r.chains ?? RECIPE_BACK_HAIR_CHAINS);
224
246
  const ratio = r.length_ratio ?? RECIPE_LENGTH_RATIO;
225
- const rows = linkRows(input.rootY, input.tipY, r.links, ratio);
247
+ const baseRows = linkRows(input.rootY, input.tipY, r.links, ratio);
226
248
  const at = r.split === undefined ? r.links : r.split.at;
227
249
  const into = r.split === undefined ? 1 : r.split.into;
228
250
  const out: TreeChain[] = [];
229
- const x = (y: number, f: number): number => input.xAt(y, f) ?? input.fallbackX(f);
230
- const pt = (y: number, f: number): Point => [pyRound(x(y, f), 0), pyRound(y, 0)];
231
- const rowRule = (j: number): string =>
232
- `link ${j}'s row: ${j === 0 ? 'the root row' : `the root row + ${pyRound((rows[j] - rows[0]) / (rows[r.links] - rows[0]), 4)} of the span to the tip row`} (lengths in the ratio ${pyRound(ratio, 6)} last to first)`;
251
+ const activeTrees: number[] = [];
252
+ const trunkCols = new Map<number, TreeColumnTip | null>();
253
+ for (let k = 0; k < n; k++) {
254
+ if (input.masses === undefined && input.columnTip !== undefined) {
255
+ const col = input.columnTip(k, (k + 0.5) / n);
256
+ trunkCols.set(k, col);
257
+ if (col === null) continue;
258
+ }
259
+ activeTrees.push(k);
260
+ }
261
+ const firstTree = activeTrees.length > 0 ? activeTrees[0] : 0;
262
+ const lastTree = activeTrees.length > 0 ? activeTrees[activeTrees.length - 1] : n - 1;
233
263
  const outerSide = (k: number, b: number): boolean => {
234
- const left = k === 0 && b === 0;
235
- const right = k === n - 1 && b === into - 1;
264
+ const left = k === firstTree && b === 0;
265
+ const right = k === lastTree && b === into - 1;
236
266
  return input.edges === 'both' ? left || right : input.edges === 'r' ? left : right;
237
267
  };
238
- for (let k = 0; k < n; k++) {
239
- const fc = (k + 0.5) / n;
268
+ for (const k of activeTrees) {
269
+ const mass = input.masses?.[k];
270
+ const fc = mass === undefined ? (k + 0.5) / n : 0.5;
271
+ const trunkCol = trunkCols.get(k) ?? null;
272
+ const branchCols: Array<TreeColumnTip | null> = [];
273
+ if (into > 1 && mass === undefined && input.columnTip !== undefined) {
274
+ for (let b = 0; b < into; b++) {
275
+ branchCols.push(input.columnTip(k, (k + (b + 0.5) / into) / n));
276
+ }
277
+ }
278
+ const trunkTipY =
279
+ mass !== undefined
280
+ ? mass.tipY
281
+ : trunkCol === null
282
+ ? input.tipY
283
+ : branchCols.length > 0
284
+ ? Math.min(trunkCol.tipY, ...branchCols.map((bc) => bc?.tipY ?? trunkCol.tipY))
285
+ : trunkCol.tipY;
286
+ const rows = mass !== undefined ? linkRows(mass.rootY, mass.tipY, r.links, ratio) : trunkTipY === input.tipY ? baseRows : linkRows(input.rootY, trunkTipY, r.links, ratio);
287
+ const frame = mass === undefined ? input.frame : mass.frame;
288
+ const xWith = (col: TreeColumnTip | null, y: number, f: number): number =>
289
+ mass !== undefined ? (mass.xAt(y, f) ?? mass.fallbackX(f)) : ((col?.xAt ?? input.xAt)(y, f) ?? input.fallbackX(f));
290
+ const ptWith = (col: TreeColumnTip | null, y: number, f: number): Point => [pyRound(xWith(col, y, f), 0), pyRound(y, 0)];
291
+ const rowRuleFor = (pathRows: readonly number[], j: number): string =>
292
+ `link ${j}'s row: ${j === 0 ? 'the root row' : `the root row + ${pyRound((pathRows[j] - pathRows[0]) / (pathRows[r.links] - pathRows[0]), 4)} of the span to the tip row`} (lengths in the ratio ${pyRound(ratio, 6)} last to first)`;
240
293
  const trunkLinks = Math.min(at, r.links);
241
294
  const trunkEdge = into === 1 && outerSide(k, 0);
242
295
  const trunk: TreeChain = {
243
296
  chain: `${input.prefix}${k}_`,
244
297
  parent: input.parent,
245
298
  points: [],
246
- tip: pt(rows[trunkLinks], fc),
299
+ tip: ptWith(trunkCol, rows[trunkLinks], fc),
247
300
  why: [],
248
- tipWhy: `${trunkLinks === r.links ? 'the tip row' : `link ${trunkLinks}'s row, the fork`}, ${pyRound(fc, 6)} of the way across ${input.frame} (tree ${k} of ${n}, the centre of its cell)`,
301
+ tipWhy: `${trunkLinks === r.links ? 'the tip row' : `link ${trunkLinks}'s row, the fork`}, ${pyRound(fc, 6)} of the way across ${frame} (tree ${k} of ${n}, the centre of its cell)`,
249
302
  tree: k,
250
303
  roles: [],
251
304
  depths: [],
252
305
  last: [],
253
306
  };
254
307
  for (let j = 0; j < trunkLinks; j++) {
255
- trunk.points.push(pt(rows[j], fc));
256
- trunk.why.push(`${rowRule(j)}, ${pyRound(fc, 6)} of the way across ${input.frame} in the 24-row band there (tree ${k} of ${n}, the centre of its cell)`);
308
+ trunk.points.push(ptWith(trunkCol, rows[j], fc));
309
+ trunk.why.push(`${rowRuleFor(rows, j)}, ${pyRound(fc, 6)} of the way across ${frame} in the 24-row band there (tree ${k} of ${n}, the centre of its cell)`);
257
310
  trunk.roles.push(into === 1 ? (trunkEdge ? 'edge' : 'trunk') : 'trunk');
258
311
  trunk.depths.push(j);
259
312
  trunk.last.push(into === 1 && j === trunkLinks - 1);
@@ -261,14 +314,17 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
261
314
  out.push(trunk);
262
315
  if (into === 1) continue;
263
316
  for (let b = 0; b < into; b++) {
264
- const fb = (k + (b + 0.5) / into) / n;
317
+ const fb = mass === undefined ? (k + (b + 0.5) / into) / n : (b + 0.5) / into;
318
+ const bCol = branchCols[b] ?? trunkCol;
319
+ const bTipY = bCol?.tipY ?? trunkTipY;
320
+ const bRows = bTipY === trunkTipY ? rows : linkRows(input.rootY, bTipY, r.links, ratio);
265
321
  const ch: TreeChain = {
266
322
  chain: `${input.prefix}${k}${LETTERS[b]}_`,
267
323
  parent: `${trunk.chain}${trunkLinks - 1}`,
268
324
  points: [],
269
- tip: pt(rows[r.links], fb),
325
+ tip: ptWith(bCol, bRows[r.links], fb),
270
326
  why: [],
271
- tipWhy: `the tip row, ${pyRound(fb, 6)} of the way across ${input.frame} (branch ${b} of ${into} of tree ${k}, the centre of its share of the cell)`,
327
+ tipWhy: `the tip row, ${pyRound(fb, 6)} of the way across ${frame} (branch ${b} of ${into} of tree ${k}, the centre of its share of the cell)`,
272
328
  tree: k,
273
329
  roles: [],
274
330
  depths: [],
@@ -276,8 +332,8 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
276
332
  };
277
333
  const edge = r.split?.hold_tip === true && r.strength === undefined ? false : outerSide(k, b);
278
334
  for (let j = trunkLinks; j < r.links; j++) {
279
- ch.points.push(j === trunkLinks ? trunk.tip : pt(rows[j], fb));
280
- ch.why.push(j === trunkLinks ? `the fork: the trunk ${trunk.chain}'s tip` : `${rowRule(j)}, ${pyRound(fb, 6)} of the way across ${input.frame} in the 24-row band there (branch ${b} of ${into} of tree ${k})`);
335
+ ch.points.push(j === trunkLinks ? trunk.tip : ptWith(bCol, bRows[j], fb));
336
+ ch.why.push(j === trunkLinks ? `the fork: the trunk ${trunk.chain}'s tip` : `${rowRuleFor(bRows, j)}, ${pyRound(fb, 6)} of the way across ${frame} in the 24-row band there (branch ${b} of ${into} of tree ${k})`);
281
337
  ch.roles.push(edge ? 'edge' : 'branch');
282
338
  ch.depths.push(j);
283
339
  ch.last.push(j === r.links - 1);
@@ -496,3 +552,215 @@ export function spanCrosses(span: Pick<TailSpan, 'a' | 'b'>, mask: Mask): number
496
552
  return count;
497
553
  }
498
554
 
555
+ /**
556
+ * Issue #445: one visible hair mass on a side (`r`: `x < axis`, `l`: `x >= axis`)
557
+ * of the back-hair part's alpha below the root row.
558
+ */
559
+ export interface BackHairMass {
560
+ side: 'r' | 'l';
561
+ minX: number;
562
+ maxX: number;
563
+ minY: number;
564
+ maxY: number;
565
+ area: number;
566
+ mask: Mask;
567
+ }
568
+
569
+ /**
570
+ * Issue #445: the visible hair masses on one side of the eye axis (`r`: `x < axis`,
571
+ * `l`: `x >= axis`) — 8-connected components of the back-hair part's alpha below
572
+ * the root row (`y > y0`, after any undivided crown rows above `shoulderLine` that
573
+ * bridge continuously across `axis`, with row `y0` attached when a component touches
574
+ * `y0` at `y0 + 1`), merged when an above-shoulder crown/nape remnant sits beside a
575
+ * hanging tail on that side or when two components touch within one grid cell
576
+ * (`grid` px), sorted left-to-right by `minX`.
577
+ */
578
+ export function backHairMasses(mask: Mask, side: 'r' | 'l', axis: number, y0: number, shoulderLine: number, grid: number): BackHairMass[] {
579
+ const W = mask.width;
580
+ const H = mask.height;
581
+ const onSide = (x: number): boolean => (side === 'r' ? x < axis : x >= axis);
582
+ const axL = Math.max(0, Math.floor(axis) - 1);
583
+ const axR = Math.min(W - 1, Math.floor(axis));
584
+ let yStart = Math.max(0, y0 + 1);
585
+ const shoulderRow = Math.min(H, Math.floor(shoulderLine));
586
+ while (yStart < shoulderRow && mask.data[yStart * W + axL] !== 0 && mask.data[yStart * W + axR] !== 0) {
587
+ yStart++;
588
+ }
589
+ const hasAnyFrom = (yFrom: number): boolean => {
590
+ for (let y = yFrom; y < H; y++) {
591
+ for (let x = 0; x < W; x++) {
592
+ if (onSide(x) && mask.data[y * W + x] !== 0) return true;
593
+ }
594
+ }
595
+ return false;
596
+ };
597
+ if (!hasAnyFrom(yStart)) yStart = Math.max(0, y0 + 1);
598
+ const sub = newMask(W, H);
599
+ for (let y = yStart; y < H; y++) {
600
+ for (let x = 0; x < W; x++) {
601
+ if (onSide(x) && mask.data[y * W + x] !== 0) sub.data[y * W + x] = 1;
602
+ }
603
+ }
604
+ const cc = connectedComponents(sub, 8);
605
+ const comps: Array<{ minX: number; maxX: number; minY: number; maxY: number; area: number; mask: Mask }> = [];
606
+ for (let i = 1; i < cc.count; i++) {
607
+ const st = cc.stats[i];
608
+ const m = newMask(W, H);
609
+ let minX = st.left;
610
+ let maxX = st.left + st.width - 1;
611
+ let minY = st.top;
612
+ const maxY = st.top + st.height - 1;
613
+ let area = st.area;
614
+ for (let y = st.top; y <= maxY; y++) {
615
+ for (let x = st.left; x <= maxX; x++) {
616
+ if (cc.labels[y * W + x] === i) m.data[y * W + x] = 1;
617
+ }
618
+ }
619
+ if (yStart === y0 + 1 && minY === y0 + 1 && y0 >= 0 && y0 < H) {
620
+ let touched = false;
621
+ for (let x = 0; x < W; x++) {
622
+ if (!onSide(x) || mask.data[y0 * W + x] === 0) continue;
623
+ if (
624
+ m.data[(y0 + 1) * W + x] !== 0 ||
625
+ (x > 0 && m.data[(y0 + 1) * W + (x - 1)] !== 0) ||
626
+ (x + 1 < W && m.data[(y0 + 1) * W + (x + 1)] !== 0)
627
+ ) {
628
+ m.data[y0 * W + x] = 1;
629
+ touched = true;
630
+ }
631
+ }
632
+ if (touched) {
633
+ for (let x = 1; x < W; x++) {
634
+ if (onSide(x) && mask.data[y0 * W + x] !== 0 && m.data[y0 * W + (x - 1)] !== 0) m.data[y0 * W + x] = 1;
635
+ }
636
+ for (let x = W - 2; x >= 0; x--) {
637
+ if (onSide(x) && mask.data[y0 * W + x] !== 0 && m.data[y0 * W + (x + 1)] !== 0) m.data[y0 * W + x] = 1;
638
+ }
639
+ minY = y0;
640
+ for (let x = 0; x < W; x++) {
641
+ if (m.data[y0 * W + x] !== 0) {
642
+ minX = Math.min(minX, x);
643
+ maxX = Math.max(maxX, x);
644
+ area++;
645
+ }
646
+ }
647
+ }
648
+ }
649
+ comps.push({ minX, maxX, minY, maxY, area, mask: m });
650
+ }
651
+ if (comps.length === 0) return [];
652
+ const par = comps.map((_, i) => i);
653
+ const find = ( idx: number): number => {
654
+ let cur = idx;
655
+ while (par[cur] !== cur) {
656
+ par[cur] = par[par[cur]];
657
+ cur = par[cur];
658
+ }
659
+ return cur;
660
+ };
661
+ const unite = (a: number, b: number): void => {
662
+ const ra = find(a);
663
+ const rb = find(b);
664
+ if (ra !== rb) par[rb] = ra;
665
+ };
666
+ const dilatedCache = new Map<number, Mask>();
667
+ const dilatedOf = (idx: number): Mask => {
668
+ let d = dilatedCache.get(idx);
669
+ if (d === undefined) {
670
+ d = dilate(comps[idx].mask, Math.max(1, 2 * grid + 1));
671
+ dilatedCache.set(idx, d);
672
+ }
673
+ return d;
674
+ };
675
+ const touchesWithinGrid = (ia: number, ib: number): boolean => {
676
+ const a = comps[ia];
677
+ const b = comps[ib];
678
+ if (a.maxX + grid < b.minX || b.maxX + grid < a.minX || a.maxY + grid < b.minY || b.maxY + grid < a.minY) return false;
679
+ const da = dilatedOf(ia);
680
+ for (let y = b.minY; y <= b.maxY; y++) {
681
+ for (let x = b.minX; x <= b.maxX; x++) {
682
+ if (b.mask.data[y * W + x] !== 0 && da.data[y * W + x] !== 0) return true;
683
+ }
684
+ }
685
+ return false;
686
+ };
687
+ for (let i = 0; i < comps.length; i++) {
688
+ for (let j = i + 1; j < comps.length; j++) {
689
+ if (find(i) !== find(j) && touchesWithinGrid(i, j)) unite(i, j);
690
+ }
691
+ }
692
+ const groups = new Map<number, typeof comps>();
693
+ for (let i = 0; i < comps.length; i++) {
694
+ const r = find(i);
695
+ if (!groups.has(r)) groups.set(r, []);
696
+ (groups.get(r) as typeof comps).push(comps[i]);
697
+ }
698
+ const merged: BackHairMass[] = [];
699
+ for (const list of groups.values()) {
700
+ const m = newMask(W, H);
701
+ let minX = Infinity;
702
+ let maxX = -Infinity;
703
+ let minY = Infinity;
704
+ let maxY = -Infinity;
705
+ let area = 0;
706
+ for (const c of list) {
707
+ minX = Math.min(minX, c.minX);
708
+ maxX = Math.max(maxX, c.maxX);
709
+ minY = Math.min(minY, c.minY);
710
+ maxY = Math.max(maxY, c.maxY);
711
+ area += c.area;
712
+ for (let y = c.minY; y <= c.maxY; y++) {
713
+ for (let x = c.minX; x <= c.maxX; x++) {
714
+ if (c.mask.data[y * W + x] !== 0) m.data[y * W + x] = 1;
715
+ }
716
+ }
717
+ }
718
+ merged.push({ side, minX, maxX, minY, maxY, area, mask: m });
719
+ }
720
+ const hanging = merged.filter((c) => c.maxY + 1 >= shoulderLine);
721
+ const out = hanging.length > 0 ? hanging : merged;
722
+ for (const h of out) {
723
+ if (h.minY <= y0) {
724
+ for (let y = Math.max(0, y0 - 12); y < h.minY; y++) {
725
+ for (let x = h.minX; x <= h.maxX; x++) {
726
+ if (onSide(x) && mask.data[y * W + x] !== 0) h.mask.data[y * W + x] = 1;
727
+ }
728
+ }
729
+ }
730
+ }
731
+ if (hanging.length === 1 && hanging[0].minY <= y0) {
732
+ const h = hanging[0];
733
+ const upper = merged.filter((c) => c.maxY + 1 < shoulderLine);
734
+ if (upper.length > 0) {
735
+ const a = Math.max(0, y0 - 12);
736
+ const b = Math.min(H, Math.max(0, y0 + 12));
737
+ let uMinX = h.minX;
738
+ let uMaxX = h.maxX;
739
+ for (let yy = a; yy < b; yy++) {
740
+ for (let x = 0; x < W; x++) {
741
+ if (onSide(x) && mask.data[yy * W + x] !== 0) {
742
+ uMinX = Math.min(uMinX, x);
743
+ uMaxX = Math.max(uMaxX, x);
744
+ }
745
+ }
746
+ }
747
+ const rootMid = pyRound(uMinX + 0.5 * (uMaxX - uMinX), 0);
748
+ if (rootMid >= h.minX && rootMid <= h.maxX) {
749
+ for (const u of upper) {
750
+ for (let y = u.minY; y <= u.maxY; y++) {
751
+ for (let x = u.minX; x <= u.maxX; x++) {
752
+ if (u.mask.data[y * W + x] !== 0) h.mask.data[y * W + x] = 1;
753
+ }
754
+ }
755
+ }
756
+ for (let y = Math.max(0, y0 - 12); y < y0; y++) {
757
+ for (let x = 0; x < W; x++) {
758
+ if (onSide(x) && mask.data[y * W + x] !== 0) h.mask.data[y * W + x] = 1;
759
+ }
760
+ }
761
+ }
762
+ }
763
+ }
764
+ out.sort((a, b) => a.minX - b.minX);
765
+ return out;
766
+ }
package/src/config.ts CHANGED
@@ -139,9 +139,29 @@ export interface Generation {
139
139
  control?: Control;
140
140
  }
141
141
 
142
+ export type ArmSplitTag = 'handwear-r' | 'handwear-l';
143
+ export const ARM_SPLIT_TAGS: readonly ArmSplitTag[] = ['handwear-r', 'handwear-l'];
144
+
145
+ /**
146
+ * Issue #439: an optional declaration on `config.seethrough` that separates
147
+ * overlapping arms in the full run into `handwear-r` (smaller image x, the
148
+ * character's right as they face the viewer) and `handwear-l` (larger image x,
149
+ * the character's left) in the draw order `order` states, back to front.
150
+ * Exactly one of `x` (an integer column on the full run's canvas) or `line`
151
+ * (a polyline of at least two `[x, y]` integer points on the full run's
152
+ * canvas) is given.
153
+ */
154
+ export interface ArmSplit {
155
+ x?: number;
156
+ line?: Array<[number, number]>;
157
+ order: ['handwear-r', 'handwear-l'] | ['handwear-l', 'handwear-r'];
158
+ }
159
+
142
160
  export interface SeeThrough {
143
161
  /** Square crop `[x0, y0, x1, y1]` in source pixels for the second, head-only run. */
144
162
  head_box?: [number, number, number, number];
163
+ /** Issue #439: arm separation when both arms overlap in the full run. */
164
+ arm_split?: ArmSplit;
145
165
  resolution: number;
146
166
  steps: number;
147
167
  seed: number;
@@ -701,9 +721,21 @@ export interface LimbSegment {
701
721
  cut_band?: number;
702
722
  }
703
723
 
724
+ /**
725
+ * Issue #446: a per-limb split declared on the covering limb where two limbs
726
+ * share one part and cross: either `line` (a polyline of two or more `[x, y]`
727
+ * points in rig px along the covering limb's seam) or `edge: "painting"`
728
+ * (traces the covering limb's painted stroke or silhouette across the crossing).
729
+ */
730
+ export interface LimbSplit {
731
+ line?: Array<[number, number]>;
732
+ edge?: 'painting';
733
+ }
734
+
704
735
  /** A limb (issue #349): its segments, upper first; each segment's bone is the child of the one before. */
705
736
  export interface Limb {
706
737
  segments: LimbSegment[];
738
+ split?: LimbSplit;
707
739
  }
708
740
 
709
741
  export interface CharacterConfig {
@@ -1571,10 +1603,10 @@ function checkGeneration(c: Check, v: Json): void {
1571
1603
  }
1572
1604
 
1573
1605
  function checkSeeThrough(c: Check, v: Json): void {
1574
- const s = c.object('config.seethrough', v, ['resolution', 'steps', 'seed', 'offload'], ['head_box']);
1606
+ const s = c.object('config.seethrough', v, ['resolution', 'steps', 'seed', 'offload'], ['head_box', 'arm_split']);
1575
1607
  if (s === null) return;
1576
1608
  const p = 'config.seethrough';
1577
- if ('resolution' in s) c.int(`${p}.resolution`, s.resolution, 1);
1609
+ const res = 'resolution' in s && c.int(`${p}.resolution`, s.resolution, 1) ? (s.resolution as number) : null;
1578
1610
  if ('steps' in s) c.int(`${p}.steps`, s.steps, 1);
1579
1611
  if ('seed' in s) c.int(`${p}.seed`, s.seed, 0);
1580
1612
  if ('offload' in s && typeof s.offload !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${p}.offload`, `is ${show(s.offload)}; true or false is required`);
@@ -1589,6 +1621,55 @@ function checkSeeThrough(c: Check, v: Json): void {
1589
1621
  }
1590
1622
  }
1591
1623
  }
1624
+ if ('arm_split' in s) checkArmSplit(c, `${p}.arm_split`, s.arm_split, res);
1625
+ }
1626
+
1627
+ function checkArmSplit(c: Check, path: string, v: Json, resolution: number | null): void {
1628
+ const a = c.object(path, v, ['order'], ['x', 'line']);
1629
+ if (a === null) return;
1630
+ if (!('x' in a) && !('line' in a)) {
1631
+ c.fail('CONFIG_FIELD_PRESENT', path, 'has neither x nor line; a split column ("x") or a polyline ("line") is required to separate the arms');
1632
+ } else if ('x' in a && 'line' in a) {
1633
+ c.fail('CONFIG_FIELD_TYPE', `${path}.line`, 'is set alongside x; give either a split column ("x") or a polyline ("line"), not both');
1634
+ }
1635
+ if ('x' in a && c.number(`${path}.x`, a.x, 'non-negative')) {
1636
+ const x = a.x as number;
1637
+ if (resolution !== null && x > resolution) {
1638
+ c.fail('CONFIG_FIELD_TYPE', `${path}.x`, `is ${x}; a column inside the ${resolution}x${resolution} run canvas (0 <= x <= ${resolution}) is required`);
1639
+ }
1640
+ }
1641
+ if ('line' in a) {
1642
+ const l = a.line;
1643
+ if (!Array.isArray(l) || l.length < 2) {
1644
+ c.fail('CONFIG_FIELD_TYPE', `${path}.line`, `is ${show(l)}; an array of at least 2 [x, y] points in run pixels is required`);
1645
+ } else {
1646
+ l.forEach((pt, i) => {
1647
+ const ok = Array.isArray(pt) && pt.length === 2 && pt.every((n) => typeof n === 'number' && Number.isFinite(n));
1648
+ if (!ok) {
1649
+ c.fail('CONFIG_FIELD_TYPE', `${path}.line[${i}]`, `is ${show(pt)}; a point [x, y] in run pixels is required`);
1650
+ return;
1651
+ }
1652
+ const [px, py] = pt as [number, number];
1653
+ if (px < 0 || py < 0 || (resolution !== null && (px > resolution || py > resolution))) {
1654
+ c.fail(
1655
+ 'CONFIG_FIELD_TYPE',
1656
+ `${path}.line[${i}]`,
1657
+ `is [${px}, ${py}]; ${resolution !== null ? `a point inside the ${resolution}x${resolution} run canvas (0 <= x, y <= ${resolution})` : 'x >= 0 and y >= 0'} is required`,
1658
+ );
1659
+ }
1660
+ });
1661
+ }
1662
+ }
1663
+ if ('order' in a) {
1664
+ const o = a.order;
1665
+ const valid =
1666
+ Array.isArray(o) &&
1667
+ o.length === 2 &&
1668
+ ((o[0] === 'handwear-r' && o[1] === 'handwear-l') || (o[0] === 'handwear-l' && o[1] === 'handwear-r'));
1669
+ if (!valid) {
1670
+ c.fail('CONFIG_FIELD_TYPE', `${path}.order`, `is ${show(o)}; ["handwear-r", "handwear-l"] or ["handwear-l", "handwear-r"] (back to front) is required`);
1671
+ }
1672
+ }
1592
1673
  }
1593
1674
 
1594
1675
  function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
@@ -2309,10 +2390,39 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2309
2390
  }
2310
2391
  const sources = new Set([...made.parts, ...made.cuts]);
2311
2392
  const firstAt = new Map<string, string>();
2393
+ const limbFroms = new Map<string, Set<string>>();
2394
+ const splitLimbs: string[] = [];
2312
2395
  for (const [limb, spec] of Object.entries(v)) {
2313
2396
  const at = `config.limbs.${limb}`;
2314
- const o = c.object(at, spec, ['segments'], []);
2315
- if (o === null || !('segments' in o)) continue;
2397
+ const o = c.object(at, spec, ['segments'], ['split']);
2398
+ if (o === null) continue;
2399
+ if ('split' in o) {
2400
+ const sp = c.object(`${at}.split`, o.split, [], ['line', 'edge']);
2401
+ if (sp !== null) {
2402
+ const hasLine = 'line' in sp;
2403
+ const hasEdge = 'edge' in sp;
2404
+ if ((hasLine && hasEdge) || (!hasLine && !hasEdge)) {
2405
+ c.fail('CONFIG_FIELD_TYPE', `${at}.split`, `is ${show(o.split)}; either "line" (a polyline of two or more [x, y] points in rig px) or "edge": "painting" is required, not both and not neither`);
2406
+ } else if (hasLine) {
2407
+ const pts = sp.line;
2408
+ const ok =
2409
+ Array.isArray(pts) &&
2410
+ pts.length >= 2 &&
2411
+ pts.every((p) => Array.isArray(p) && p.length === 2 && typeof p[0] === 'number' && Number.isFinite(p[0]) && typeof p[1] === 'number' && Number.isFinite(p[1]));
2412
+ if (!ok) {
2413
+ c.fail('CONFIG_FIELD_TYPE', `${at}.split.line`, `is ${show(pts)}; an array of two or more [x, y] points in rig px is required`);
2414
+ } else {
2415
+ const typed = pts as Array<[number, number]>;
2416
+ if (typed.every((p) => p[0] === typed[0][0] && p[1] === typed[0][1])) {
2417
+ c.fail('CONFIG_FIELD_TYPE', `${at}.split.line`, `is ${show(pts)}; a polyline with non-zero length is required — all points stand on [${typed[0].join(', ')}]`);
2418
+ } else splitLimbs.push(limb);
2419
+ }
2420
+ } else if (sp.edge !== 'painting') {
2421
+ c.fail('CONFIG_FIELD_TYPE', `${at}.split.edge`, `is ${show(sp.edge)}; "painting" is required — traces the covering limb's edge across the crossing from the painting's own stroke or silhouette`);
2422
+ } else splitLimbs.push(limb);
2423
+ }
2424
+ }
2425
+ if (!('segments' in o)) continue;
2316
2426
  if (!Array.isArray(o.segments) || o.segments.length < 2) {
2317
2427
  c.fail('CONFIG_FIELD_TYPE', `${at}.segments`, `is ${show(o.segments)}; an array of two segments or more, upper first, is required — a limb is cut at a joint, and a joint has a segment on each side`);
2318
2428
  continue;
@@ -2382,6 +2492,7 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2382
2492
  }
2383
2493
  }
2384
2494
  });
2495
+ limbFroms.set(limb, new Set(froms.filter((f): f is string => f !== null)));
2385
2496
  froms.forEach((f, i) => {
2386
2497
  if (f === null || i === 0 || froms[i - 1] === f) return;
2387
2498
  const back = froms.slice(0, i - 1).lastIndexOf(f);
@@ -2399,6 +2510,20 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2399
2510
  });
2400
2511
  }
2401
2512
  }
2513
+ for (const limb of splitLimbs) {
2514
+ const myFroms = limbFroms.get(limb) ?? new Set<string>();
2515
+ if (myFroms.size === 0) continue;
2516
+ const sharers = [...limbFroms.entries()].filter(([other, fs]) => other !== limb && [...myFroms].some((f) => fs.has(f)));
2517
+ if (sharers.length === 0) {
2518
+ c.fail('CONFIG_FIELD_TYPE', `config.limbs.${limb}.split`, `is declared on "${limb}", whose from part(s) (${[...myFroms].map((f) => `"${f}"`).join(', ')}) no other limb takes; a per-limb split divides a part shared by two limbs`);
2519
+ } else {
2520
+ const otherSplit = sharers.find(([other]) => splitLimbs.includes(other));
2521
+ if (otherSplit !== undefined) {
2522
+ const sharedFrom = [...myFroms].find((f) => otherSplit[1].has(f)) as string;
2523
+ c.fail('CONFIG_FIELD_TYPE', `config.limbs.${limb}.split`, `is declared on "${limb}" and on "${otherSplit[0]}", which both take "${sharedFrom}"; declare split on the covering limb alone`);
2524
+ }
2525
+ }
2526
+ }
2402
2527
  return out;
2403
2528
  }
2404
2529
 
@@ -79,6 +79,7 @@ export interface BasisFile {
79
79
  * `derived` placement names at least one bone; a constant is a finite number.
80
80
  */
81
81
  export function checkBasis(P: PartSet, proposal: Proposal, basis: ProposalBasis): void {
82
+ P = P.beforeLimbs();
82
83
  const problems: Problem[] = [];
83
84
  const names = [...expand(proposal.bones).byName.keys()];
84
85
  const bones = new Set(names);