@rig-c/parts 1.30.0 → 1.32.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/config.ts CHANGED
@@ -245,6 +245,19 @@ export interface Cut {
245
245
  under_fill?: 'colour';
246
246
  }
247
247
 
248
+ export interface AlternateSetConfig {
249
+ name: string;
250
+ source?: string;
251
+ full?: string;
252
+ head?: string;
253
+ parts?: string[];
254
+ hides?: string[];
255
+ head_box?: [number, number, number, number];
256
+ cuts?: Cut[];
257
+ skeleton?: string;
258
+ drawn?: Record<string, number>;
259
+ }
260
+
248
261
  export interface Assemble {
249
262
  /** Rig pixels per source pixel. */
250
263
  rig_scale: number;
@@ -252,6 +265,7 @@ export interface Assemble {
252
265
  extend_below_crop?: Extend[];
253
266
  patches?: Patch[];
254
267
  cuts?: Cut[];
268
+ alternates?: AlternateSetConfig[];
255
269
  }
256
270
 
257
271
  export interface SingleBone {
@@ -761,6 +775,8 @@ export interface CharacterConfig {
761
775
  cloth?: ClothRecipes;
762
776
  /** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) `propose` drafted the rig sections at (issue #354): absent on a hand-authored config, held equal to `assemble.rig_scale` when present. */
763
777
  proposed_rig_scale?: number;
778
+ /** Alternate painting sets (issue #454): absent, no alternate sets are assembled or emitted. */
779
+ alternates?: AlternateSetConfig[];
764
780
  }
765
781
 
766
782
  /** `config.scene` (issue #242): the declaration that the painting is a scene. It holds no field today. */
@@ -1112,7 +1128,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1112
1128
  if (door === 'layers') {
1113
1129
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1114
1130
  if ('assemble' in t) {
1115
- const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts']);
1131
+ const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
1116
1132
  if (a !== null && 'rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
1117
1133
  }
1118
1134
  }
@@ -1122,10 +1138,14 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1122
1138
  checkProposedRigScale(c, t);
1123
1139
  if ('scene' in t) checkScene(c, t.scene);
1124
1140
  // Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
1141
+ let limbs: LimbNames | null = null;
1125
1142
  if ('limbs' in t) {
1126
1143
  if (!('bones' in t)) c.fail('CONFIG_FIELD_PRESENT', 'config.bones', 'is absent; config.limbs names bones, and each joint is its bone\'s origin, so assemble reads the bones with the limbs');
1127
1144
  const names = 'bones' in t ? checkBones(c, t.bones) : null;
1128
- checkLimbs(c, t.limbs, made, names);
1145
+ limbs = checkLimbs(c, t.limbs, made, names);
1146
+ }
1147
+ if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
1148
+ checkAlternates(c, t.assemble.alternates, made, limbs, null);
1129
1149
  }
1130
1150
  }
1131
1151
  if ('cloth' in t) checkCloth(c, t.cloth);
@@ -1276,6 +1296,9 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1276
1296
  checkProposedRigScale(c, t);
1277
1297
  const names = 'bones' in t ? checkBones(c, t.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
1278
1298
  const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
1299
+ if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
1300
+ checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs, names.bones);
1301
+ }
1279
1302
  if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
1280
1303
  if ('regions' in t) checkRegions(c, t.regions, names.bones);
1281
1304
  if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions, limbs);
@@ -1602,6 +1625,17 @@ function checkGeneration(c: Check, v: Json): void {
1602
1625
  }
1603
1626
  }
1604
1627
 
1628
+ function checkHeadBox(c: Check, path: string, b: Json): void {
1629
+ const ints = Array.isArray(b) && b.length === 4 && b.every((n) => typeof n === 'number' && Number.isInteger(n));
1630
+ if (!ints) c.fail('CONFIG_FIELD_TYPE', path, `is ${show(b)}; [x0, y0, x1, y1] in integer source pixels is required`);
1631
+ else {
1632
+ const [x0, y0, x1, y1] = b as number[];
1633
+ if (!(x1 > x0 && y1 > y0 && x1 - x0 === y1 - y0)) {
1634
+ c.fail('CONFIG_HEAD_BOX_SQUARE', path, `is ${x1 - x0}x${y1 - y0}; a non-empty square is required (the head run is fed a square crop)`);
1635
+ }
1636
+ }
1637
+ }
1638
+
1605
1639
  function checkSeeThrough(c: Check, v: Json): void {
1606
1640
  const s = c.object('config.seethrough', v, ['resolution', 'steps', 'seed', 'offload'], ['head_box', 'arm_split']);
1607
1641
  if (s === null) return;
@@ -1610,17 +1644,7 @@ function checkSeeThrough(c: Check, v: Json): void {
1610
1644
  if ('steps' in s) c.int(`${p}.steps`, s.steps, 1);
1611
1645
  if ('seed' in s) c.int(`${p}.seed`, s.seed, 0);
1612
1646
  if ('offload' in s && typeof s.offload !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${p}.offload`, `is ${show(s.offload)}; true or false is required`);
1613
- if ('head_box' in s) {
1614
- const b = s.head_box;
1615
- const ints = Array.isArray(b) && b.length === 4 && b.every((n) => typeof n === 'number' && Number.isInteger(n));
1616
- if (!ints) c.fail('CONFIG_FIELD_TYPE', `${p}.head_box`, `is ${show(b)}; [x0, y0, x1, y1] in integer source pixels is required`);
1617
- else {
1618
- const [x0, y0, x1, y1] = b as number[];
1619
- if (!(x1 > x0 && y1 > y0 && x1 - x0 === y1 - y0)) {
1620
- c.fail('CONFIG_HEAD_BOX_SQUARE', `${p}.head_box`, `is ${x1 - x0}x${y1 - y0}; a non-empty square is required (the head run is fed a square crop)`);
1621
- }
1622
- }
1623
- }
1647
+ if ('head_box' in s) checkHeadBox(c, `${p}.head_box`, s.head_box);
1624
1648
  if ('arm_split' in s) checkArmSplit(c, `${p}.arm_split`, s.arm_split, res);
1625
1649
  }
1626
1650
 
@@ -1689,7 +1713,7 @@ function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
1689
1713
  */
1690
1714
  function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
1691
1715
  const required = sectionRequired(door, 'assemble');
1692
- const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts']);
1716
+ const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
1693
1717
  if (a === null) return { parts: [], patches: [], cuts: [] };
1694
1718
  if (required.includes('plan') && !('plan' in a)) {
1695
1719
  c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
@@ -1743,8 +1767,7 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
1743
1767
  * one polygon. `into` is a new part name: not a plan part, not a patch, not
1744
1768
  * another cut's. Returns the names the cuts make, in order.
1745
1769
  */
1746
- function checkCuts(c: Check, v: Json, plan: string[], patches: string[]): string[] {
1747
- const p = 'config.assemble.cuts';
1770
+ function checkCuts(c: Check, v: Json, plan: string[], patches: string[], p = 'config.assemble.cuts'): string[] {
1748
1771
  const names: string[] = [];
1749
1772
  if (!c.array(p, v, false)) return names;
1750
1773
  const seen = new Map<string, number>();
@@ -1796,6 +1819,70 @@ function checkCuts(c: Check, v: Json, plan: string[], patches: string[]): string
1796
1819
  return names;
1797
1820
  }
1798
1821
 
1822
+ function checkAlternates(
1823
+ c: Check,
1824
+ v: Json,
1825
+ made: { parts: string[]; patches: string[]; cuts: string[] },
1826
+ limbs: LimbNames | null,
1827
+ bones: Set<string> | null = null,
1828
+ ): void {
1829
+ const p = 'config.assemble.alternates';
1830
+ if (!c.array(p, v, true)) return;
1831
+ const allSlots = new Set<string>([...made.parts, ...made.patches, ...made.cuts, ...(limbs?.parts.keys() ?? [])]);
1832
+ const seenNames = new Map<string, number>();
1833
+ (v as Json[]).forEach((entry, i) => {
1834
+ const at = `${p}[${i}]`;
1835
+ const e = c.object(at, entry, ['name'], ['source', 'full', 'head', 'parts', 'hides', 'head_box', 'cuts', 'skeleton', 'drawn']);
1836
+ if (e === null) return;
1837
+ if ('name' in e && c.string(`${at}.name`, e.name)) {
1838
+ if (!partNameOk(e.name)) c.fail('CONFIG_PART_NAME', `${at}.name`, `names the set ${show(e.name)}; a set name is a file prefix, so it may not be empty, start with "." or hold a slash`);
1839
+ if (seenNames.has(e.name)) c.fail('CONFIG_PART_UNIQUE', `${at}.name`, `names the set "${e.name}" again (first at alternates[${seenNames.get(e.name)}])`);
1840
+ else seenNames.set(e.name, i);
1841
+ }
1842
+ for (const k of ['source', 'full', 'head', 'skeleton'] as const) {
1843
+ if (k in e) c.string(`${at}.${k}`, e[k]);
1844
+ }
1845
+ if ('drawn' in e) {
1846
+ const d = e.drawn;
1847
+ if (!isPlainObject(d) || Object.keys(d).length === 0) {
1848
+ c.fail('CONFIG_FIELD_TYPE', `${at}.drawn`, `is ${show(d)}; a non-empty object of bone name -> drawn world angle in Spine degrees is required`);
1849
+ } else {
1850
+ for (const [bName, deg] of Object.entries(d)) {
1851
+ if (bones !== null && !bones.has(bName)) {
1852
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.drawn.${bName}`, `names "${bName}", which config.bones does not declare`);
1853
+ }
1854
+ c.number(`${at}.drawn.${bName}`, deg);
1855
+ }
1856
+ }
1857
+ }
1858
+ if ('head_box' in e) checkHeadBox(c, `${at}.head_box`, e.head_box);
1859
+ const altCuts = 'cuts' in e ? checkCuts(c, e.cuts, made.parts, [], `${at}.cuts`) : [];
1860
+ const validParts = new Set<string>([...allSlots, ...altCuts]);
1861
+ if ('parts' in e && c.array(`${at}.parts`, e.parts, true)) {
1862
+ const seen = new Set<string>();
1863
+ (e.parts as Json[]).forEach((pn, k) => {
1864
+ if (!c.string(`${at}.parts[${k}]`, pn)) return;
1865
+ if (!validParts.has(pn)) {
1866
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.parts[${k}]`, `is ${show(pn)}; a slot of the base rig (or a part made by this alternate's cuts) is required`);
1867
+ } else if (seen.has(pn)) {
1868
+ c.fail('CONFIG_PART_UNIQUE', `${at}.parts[${k}]`, `repeats "${pn}"; each covered part is named once`);
1869
+ } else seen.add(pn);
1870
+ });
1871
+ }
1872
+ if ('hides' in e && c.array(`${at}.hides`, e.hides, false)) {
1873
+ const seen = new Set<string>();
1874
+ (e.hides as Json[]).forEach((hn, k) => {
1875
+ if (!c.string(`${at}.hides[${k}]`, hn)) return;
1876
+ if (!allSlots.has(hn)) {
1877
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.hides[${k}]`, `is ${show(hn)}; a slot of the base rig is required`);
1878
+ } else if (seen.has(hn)) {
1879
+ c.fail('CONFIG_PART_UNIQUE', `${at}.hides[${k}]`, `repeats "${hn}"; each hidden slot is named once`);
1880
+ } else seen.add(hn);
1881
+ });
1882
+ }
1883
+ });
1884
+ }
1885
+
1799
1886
  /**
1800
1887
  * `assemble.patches`: each entry `{name, box, alpha, draw}`, refused field by
1801
1888
  * field. What the config alone cannot know — whether the box lies inside the
package/src/parts.ts CHANGED
@@ -83,6 +83,7 @@
83
83
  */
84
84
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
85
85
  import { type Problem, refuseIfAny } from './errors.ts';
86
+ import { KEYPOINT_NAMES, type PosePoints } from './skeleton.ts';
86
87
  import { readLayerTag } from './tags.ts';
87
88
 
88
89
  export interface PartRecord {
@@ -241,6 +242,26 @@ export interface RecompositeRecord {
241
242
  holes: HoleRecord[];
242
243
  }
243
244
 
245
+ export interface AlternateSetMap {
246
+ slots: Record<string, string>;
247
+ hides?: string[];
248
+ drawn?: Record<string, number>;
249
+ }
250
+
251
+ export interface AlternatePartsRecord {
252
+ name: string;
253
+ parts?: string[];
254
+ hides?: string[];
255
+ slots: Record<string, string>;
256
+ records: PartRecord[];
257
+ ghost_px: Record<string, number>;
258
+ recomposite: RecompositeRecord;
259
+ scene?: SceneRecord;
260
+ single_run_pad?: SingleRunPad;
261
+ skeleton?: PosePoints;
262
+ drawn?: Record<string, number>;
263
+ }
264
+
244
265
  export interface PartsFile {
245
266
  rig_size: [number, number];
246
267
  scale_rig_per_source: number;
@@ -252,6 +273,8 @@ export interface PartsFile {
252
273
  scene?: SceneRecord;
253
274
  /** Written only for one run whose file is not the resolution square (issue #366): where the file sat on the run's square; absent otherwise. */
254
275
  single_run_pad?: SingleRunPad;
276
+ /** Written only when alternate painting sets were assembled (issue #454); absent otherwise. */
277
+ alternates?: AlternatePartsRecord[];
255
278
  }
256
279
 
257
280
  /**
@@ -286,8 +309,8 @@ export interface SceneRecord {
286
309
  figure_mask: string;
287
310
  }
288
311
 
289
- const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene', 'single_run_pad'] as const;
290
- const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene' && k !== 'single_run_pad');
312
+ const TOP_KEYS = ['rig_size', 'scale_rig_per_source', 'parts', 'ghost_px', 'recomposite', 'scene', 'single_run_pad', 'alternates'] as const;
313
+ const REQUIRED_TOP_KEYS = TOP_KEYS.filter((k) => k !== 'recomposite' && k !== 'scene' && k !== 'single_run_pad' && k !== 'alternates');
291
314
  export const SINGLE_RUN_PAD_KEYS = ['file_size', 'side', 'left', 'top'] as const;
292
315
  export const SCENE_KEYS = ['figure_alpha_above', 'figure_px', 'figure_mask'] as const;
293
316
  export const RECOMPOSITE_KEYS = ['mean_abs', 'within_limit', 'within_share', 'error_limit', 'error_px', 'covered_alpha', 'uncovered_error_px', 'hole_count', 'holes_listed', 'holes'] as const;
@@ -346,24 +369,45 @@ export function tagOf(from: string): string | null {
346
369
  return f === null || f.run === 'painting' ? null : f.tag;
347
370
  }
348
371
 
372
+ function serializePartList(parts: readonly PartRecord[]): Array<Record<string, unknown>> {
373
+ return parts.map((p) =>
374
+ Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : p[k]])),
375
+ );
376
+ }
377
+
378
+ function serializeRecomposite(r: RecompositeRecord): Record<string, unknown> {
379
+ return {
380
+ ...Object.fromEntries(RECOMPOSITE_KEYS.filter((k) => k !== 'holes').map((k) => [k, r[k]])),
381
+ holes: r.holes.map((h) => ({ ...Object.fromEntries(HOLE_KEYS.filter((k) => k !== 'borders').map((k) => [k, h[k]])), borders: h.borders.map((b) => ({ part: b.part, px: b.px })) })),
382
+ };
383
+ }
384
+
349
385
  export function serializeParts(file: PartsFile): string {
350
386
  const ordered = {
351
387
  rig_size: file.rig_size,
352
388
  scale_rig_per_source: file.scale_rig_per_source,
353
- parts: file.parts.map((p) =>
354
- Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : p[k]])),
355
- ),
389
+ parts: serializePartList(file.parts),
356
390
  ghost_px: Object.fromEntries(Object.keys(file.ghost_px).sort().map((k) => [k, file.ghost_px[k]])),
357
- ...(file.recomposite === undefined
391
+ ...(file.recomposite === undefined ? {} : { recomposite: serializeRecomposite(file.recomposite) }),
392
+ ...(file.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (file.scene as SceneRecord)[k]])) }),
393
+ ...(file.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (file.single_run_pad as SingleRunPad)[k]])) }),
394
+ ...(file.alternates === undefined
358
395
  ? {}
359
396
  : {
360
- recomposite: {
361
- ...Object.fromEntries(RECOMPOSITE_KEYS.filter((k) => k !== 'holes').map((k) => [k, (file.recomposite as RecompositeRecord)[k]])),
362
- holes: file.recomposite.holes.map((h) => ({ ...Object.fromEntries(HOLE_KEYS.filter((k) => k !== 'borders').map((k) => [k, h[k]])), borders: h.borders.map((b) => ({ part: b.part, px: b.px })) })),
363
- },
397
+ alternates: file.alternates.map((a) => ({
398
+ name: a.name,
399
+ ...(a.parts === undefined ? {} : { parts: a.parts }),
400
+ ...(a.hides === undefined ? {} : { hides: a.hides }),
401
+ slots: a.slots,
402
+ records: serializePartList(a.records),
403
+ ghost_px: Object.fromEntries(Object.keys(a.ghost_px).sort().map((k) => [k, a.ghost_px[k]])),
404
+ recomposite: serializeRecomposite(a.recomposite),
405
+ ...(a.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (a.scene as SceneRecord)[k]])) }),
406
+ ...(a.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (a.single_run_pad as SingleRunPad)[k]])) }),
407
+ ...(a.skeleton === undefined ? {} : { skeleton: Object.fromEntries(KEYPOINT_NAMES.map((k) => [k, (a.skeleton as PosePoints)[k]])) }),
408
+ ...(a.drawn === undefined ? {} : { drawn: a.drawn }),
409
+ })),
364
410
  }),
365
- ...(file.scene === undefined ? {} : { scene: Object.fromEntries(SCENE_KEYS.map((k) => [k, (file.scene as SceneRecord)[k]])) }),
366
- ...(file.single_run_pad === undefined ? {} : { single_run_pad: Object.fromEntries(SINGLE_RUN_PAD_KEYS.map((k) => [k, (file.single_run_pad as SingleRunPad)[k]])) }),
367
411
  };
368
412
  return `${JSON.stringify(ordered, null, 2)}\n`;
369
413
  }
@@ -596,6 +640,9 @@ function checkParts(raw: unknown, path: string): PartsFile {
596
640
  const names = Array.isArray(o.parts) ? new Set(o.parts.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : undefined)).filter((n): n is string => typeof n === 'string')) : new Set<string>();
597
641
  checkRecomposite(o.recomposite, path, size as [number, number], names, fail);
598
642
  }
643
+ if ('alternates' in o && !Array.isArray(o.alternates)) {
644
+ fail('PARTS_FIELD_TYPE', `${path} field "alternates"`, `is ${show(o.alternates)}; an array of alternate set records is required`);
645
+ }
599
646
  refuseIfAny(problems);
600
647
  return raw as PartsFile;
601
648
  }
package/src/propose.ts CHANGED
@@ -149,7 +149,7 @@ import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER
149
149
  import { edgeLength } from './length.ts';
150
150
  import { bonePlaces, limbCrossing, limbPolyline, limbWidth } from './limbs.ts';
151
151
  import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.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
+ import { backHairMasses, type BackHairMass, backHairMassTrees, 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';
153
153
  import { TEXTURE_STRETCH_CEILING } from './check.ts';
154
154
 
155
155
  // ---------------------------------------------------------------------------
@@ -1849,12 +1849,29 @@ export function proposeWithBasis(
1849
1849
  const all: TreeChain[] = [];
1850
1850
  let n = 0;
1851
1851
  const partNamesLabel = list.map((q) => q.name).join(' -> ');
1852
+ const sideTreeCounts: number[] = [];
1852
1853
  for (const [side] of activeSides) {
1853
1854
  const prefix = prefixOf(side);
1854
1855
  const sideMasses = massesBySide.get(side) ?? [];
1855
1856
  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
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 sideMassInputs: TreeMassInput[] | undefined = useMasses
1859
+ ? sideMasses.flatMap((m): TreeMassInput[] => {
1860
+ const count = backHairMassTrees(m, g);
1861
+ return Array.from({ length: count }, (_, t): TreeMassInput => ({
1862
+ rootY: m.minY <= y0 ? eyeY : m.minY,
1863
+ tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
1864
+ xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
1865
+ fallbackX: m.minY <= y0 && sideMasses.length === 1 && count === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
1866
+ frame,
1867
+ treeInMass: t,
1868
+ treesInMass: count,
1869
+ }));
1870
+ })
1871
+ : undefined;
1872
+ const sideCount = sideMassInputs !== undefined ? sideMassInputs.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit));
1873
+ sideTreeCounts.push(sideCount);
1874
+ const sideRcp: ClothRecipe = { ...rawRcp, chains: sideCount };
1858
1875
  const trees = T(list, 'back hair', sideRcp, {
1859
1876
  prefix,
1860
1877
  parent: 'head',
@@ -1864,15 +1881,7 @@ export function proposeWithBasis(
1864
1881
  fallbackX: () => axis,
1865
1882
  frame,
1866
1883
  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,
1884
+ masses: sideMassInputs,
1876
1885
  });
1877
1886
  const made = treeConstraints(trees, sideRcp, null);
1878
1887
  clothConstraints.push(...made);
@@ -1881,7 +1890,7 @@ export function proposeWithBasis(
1881
1890
  }
1882
1891
  const rcp: ClothRecipe = {
1883
1892
  ...rawRcp,
1884
- chains: useMasses ? Math.max(1, ...(activeSides.map(([s]) => (massesBySide.get(s) ?? []).length))) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
1893
+ chains: useMasses ? Math.max(1, ...sideTreeCounts) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
1885
1894
  };
1886
1895
  const spans: Array<TailSpan & { depth: number }> = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false, depth: -1 }];
1887
1896
  for (const t of all) {
@@ -2037,12 +2046,32 @@ export function proposeWithBasis(
2037
2046
  const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
2038
2047
  const all: TreeChain[] = [];
2039
2048
  let n = 0;
2049
+ const treeCountBySide = new Map<'r' | 'l', number>([
2050
+ ['r', 0],
2051
+ ['l', 0],
2052
+ ]);
2040
2053
  for (const [side] of SIDES) {
2041
2054
  const prefix = prefixOf(side);
2042
2055
  const sideMasses = massesBySide.get(side) ?? [];
2043
2056
  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
2057
  const frame = `${p.name}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`;
2058
+ const sideMassInputs: TreeMassInput[] | undefined = useMasses
2059
+ ? sideMasses.flatMap((m): TreeMassInput[] => {
2060
+ const count = backHairMassTrees(m, g);
2061
+ return Array.from({ length: count }, (_, t): TreeMassInput => ({
2062
+ rootY: m.minY <= y0 ? eyeY : m.minY,
2063
+ tipY: m.maxY >= bot - 12 ? bot - 4 : Math.max(m.minY + 4, m.maxY - 3),
2064
+ xAt: (y, f) => bandX(m.mask, y, { frac: f, side, axis }),
2065
+ fallbackX: m.minY <= y0 && sideMasses.length === 1 && count === 1 ? () => axis : (f) => pyRound(m.minX + f * (m.maxX - m.minX)),
2066
+ frame,
2067
+ treeInMass: t,
2068
+ treesInMass: count,
2069
+ }));
2070
+ })
2071
+ : undefined;
2072
+ const sideCount = sideMassInputs !== undefined ? sideMassInputs.length : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit));
2073
+ treeCountBySide.set(side, sideCount);
2074
+ const sideRcp: ClothRecipe = { ...rawRcp, chains: sideCount };
2046
2075
  const trees = T(p, 'back hair', sideRcp, {
2047
2076
  prefix,
2048
2077
  parent: 'head',
@@ -2052,15 +2081,7 @@ export function proposeWithBasis(
2052
2081
  fallbackX: () => axis,
2053
2082
  frame,
2054
2083
  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,
2084
+ masses: sideMassInputs,
2064
2085
  });
2065
2086
  for (const t of trees) segs.push(t.chain);
2066
2087
  const made = treeConstraints(trees, sideRcp, null);
@@ -2068,11 +2089,14 @@ export function proposeWithBasis(
2068
2089
  n += made.length;
2069
2090
  all.push(...trees);
2070
2091
  }
2071
- const sameSideCounts = !useMasses || rMasses.length === lMasses.length;
2092
+ const rTreeCount = treeCountBySide.get('r') ?? 0;
2093
+ const lTreeCount = treeCountBySide.get('l') ?? 0;
2094
+ const sameSideCounts = !useMasses || rTreeCount === lTreeCount;
2072
2095
  const noteRcp: ClothRecipe = {
2073
2096
  ...rawRcp,
2074
- chains: useMasses ? (sameSideCounts ? rMasses.length : rMasses.length + lMasses.length) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
2097
+ chains: useMasses ? (sameSideCounts ? rTreeCount : rTreeCount + lTreeCount) : (rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit)),
2075
2098
  };
2099
+ const massReason = rTreeCount === rMasses.length && lTreeCount === lMasses.length ? 'one per visible hair mass' : 'across the visible hair masses by root-row width';
2076
2100
  notes.push(
2077
2101
  clothNote(
2078
2102
  p.name,
@@ -2082,7 +2106,7 @@ export function proposeWithBasis(
2082
2106
  n,
2083
2107
  sameSideCounts
2084
2108
  ? ' 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`,
2109
+ : ` (${rTreeCount} on the r side and ${lTreeCount} on the l side of the eye axis, ${massReason}), from head, rooted on each mass's top row and tipped 4 px above its bottom`,
2086
2110
  ),
2087
2111
  );
2088
2112
  meshes[p.name] = { grid: g, r: rawRcp.split?.hold_tip === true ? Math.max(r, g) : r, segments: segs };
@@ -4585,11 +4609,12 @@ export function lint(
4585
4609
  .sort((a, b) => Number(trunkRe.exec(a.chain)![1]) - Number(trunkRe.exec(b.chain)![1]));
4586
4610
  if (sideTrunks.length === 0) continue;
4587
4611
  const masses = backHairMasses(mask, side, axis, y0, shoulderLine, g);
4612
+ const massSlots = masses.flatMap((x) => Array.from({ length: backHairMassTrees(x, g) }, () => x));
4588
4613
  const outside: Array<{ point: string; at: [number, number]; minX: number; maxX: number }> = [];
4589
4614
  const sideMinX = masses.length > 0 ? Math.min(...masses.map((x) => x.minX)) : 0;
4590
4615
  const sideMaxX = masses.length > 0 ? Math.max(...masses.map((x) => x.maxX)) : P.W - 1;
4591
4616
  sideTrunks.forEach((tr, k) => {
4592
- const target = k < masses.length ? masses[k] : null;
4617
+ const target = k < massSlots.length ? massSlots[k] : null;
4593
4618
  const minX = target !== null ? target.minX : sideMinX;
4594
4619
  const maxX = target !== null ? target.maxX : sideMaxX;
4595
4620
  const treeBase = tr.chain.slice(0, -1);
@@ -4606,7 +4631,7 @@ export function lint(
4606
4631
  if (tx < minX || tx > maxX) outside.push({ point: `${c.chain} tip`, at: [tx, ty], minX, maxX });
4607
4632
  }
4608
4633
  });
4609
- if (sideTrunks.length !== masses.length || outside.length > 0) {
4634
+ if (sideTrunks.length !== massSlots.length || outside.length > 0) {
4610
4635
  findings.push({
4611
4636
  kind: 'back-hair-masses',
4612
4637
  mesh: groupParts.map((q) => q.name).join(' -> '),