@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/check.ts CHANGED
@@ -125,7 +125,8 @@ import {
125
125
  type Toward,
126
126
  type WorldBox,
127
127
  } from './instruments.ts';
128
- import { type PartRecord, type PartsFile, readParts } from './parts.ts';
128
+ import { type AlternatePartsRecord, type PartRecord, type PartsFile, readParts } from './parts.ts';
129
+ import { poseRigBonesForDrawn, type RigBone, SETS_FILE } from './rig.ts';
129
130
  import { turnTwin } from './turn.ts';
130
131
  import {
131
132
  aimLine,
@@ -622,7 +623,7 @@ function readJson(path: string, problems: Problem[]): Record<string, unknown> |
622
623
  * duration. `sourcePath` is `--source`, read and held to the stage here, so a
623
624
  * painting that is not this rig's is refused before anything is built.
624
625
  */
625
- export function readCheckInputs(rigDir: string, partsHome?: string, sourcePath?: string): CheckInputs {
626
+ export function readCheckInputs(rigDir: string, partsHome?: string, sourcePath?: string, set?: string, allSets = false): CheckInputs {
626
627
  const dir = resolve(rigDir);
627
628
  const named = partsHome !== undefined;
628
629
  const home = resolve(partsHome ?? rigDir);
@@ -674,6 +675,43 @@ export function readCheckInputs(rigDir: string, partsHome?: string, sourcePath?:
674
675
  problems.push({ code: 'CHECK_PART_PNG_MATCHES_BOX', object: `part "${p.name}"`, detail: `${png} is ${im.width}x${im.height}; parts.json's box is ${p.w}x${p.h}` });
675
676
  }
676
677
  }
678
+ for (const alt of parts.alternates ?? []) {
679
+ for (const r of alt.records) {
680
+ const png = join(partsDir, `${alt.name}_${r.name}.png`);
681
+ if (!existsSync(png)) {
682
+ problems.push({ code: 'CHECK_PART_PNG_PRESENT', object: `set "${alt.name}" part "${r.name}"`, detail: `${png} does not exist; parts.json lists it` });
683
+ continue;
684
+ }
685
+ const im = readPng(png);
686
+ if (im.width !== r.w || im.height !== r.h) {
687
+ problems.push({ code: 'CHECK_PART_PNG_MATCHES_BOX', object: `set "${alt.name}" part "${r.name}"`, detail: `${png} is ${im.width}x${im.height}; parts.json's box is ${r.w}x${r.h}` });
688
+ }
689
+ }
690
+ }
691
+ }
692
+ if (set !== undefined || allSets) {
693
+ if (parts === null) {
694
+ problems.push({
695
+ code: 'CHECK_INPUT_PRESENT',
696
+ object: set !== undefined ? `--set ${set}` : '--all-sets',
697
+ detail: `${partsPath} was not read; alternate sets are read from parts.json's "alternates"`,
698
+ });
699
+ } else {
700
+ const alts = parts.alternates ?? [];
701
+ if (set !== undefined && !alts.some((a) => a.name === set)) {
702
+ problems.push({
703
+ code: 'CHECK_INPUT_PRESENT',
704
+ object: `--set ${set}`,
705
+ detail: `${partsPath} has no alternate set "${set}" in "alternates" (${alts.length === 0 ? 'none declared' : alts.map((a) => `"${a.name}"`).join(', ')})`,
706
+ });
707
+ } else if (allSets && alts.length === 0) {
708
+ problems.push({
709
+ code: 'CHECK_INPUT_PRESENT',
710
+ object: '--all-sets',
711
+ detail: `${partsPath} declares no alternate sets in "alternates"`,
712
+ });
713
+ }
714
+ }
677
715
  }
678
716
  let stage: StageBox | null = null;
679
717
  let rootBone: string | null = null;
@@ -1195,6 +1233,72 @@ export function flatComposite(parts: PartsFile, partsDir: string, background: re
1195
1233
  return canvas;
1196
1234
  }
1197
1235
 
1236
+ /**
1237
+ * The flat stack of parts for an alternate set (issue #454), in `parts.parts`
1238
+ * slot order: hidden slots skipped, covered slots drawn from `alt.records` at
1239
+ * their box, uncovered base slots drawn at their base box.
1240
+ */
1241
+ export function flatCompositeForSet(parts: PartsFile, alt: AlternatePartsRecord, partsDir: string, background: readonly [number, number, number, number]): Raster {
1242
+ const [W, H] = parts.rig_size;
1243
+ let canvas = newRaster(W, H);
1244
+ for (let i = 0; i < W * H; i++) canvas.data.set(background, i * 4);
1245
+ const hides = new Set(alt.hides ?? []);
1246
+ const byName = new Map(alt.records.map((r) => [r.name, r]));
1247
+ for (const p of parts.parts) {
1248
+ if (hides.has(p.name)) continue;
1249
+ const rec = byName.get(p.name);
1250
+ const pngName = rec === undefined ? p.name : `${alt.name}_${rec.name}`;
1251
+ const box = rec ?? p;
1252
+ canvas = alphaComposite(canvas, readPng(join(partsDir, `${pngName}.png`)), box.x, box.y);
1253
+ }
1254
+ return canvas;
1255
+ }
1256
+
1257
+ /**
1258
+ * The setup-pose rig spec with an alternate set applied (issues #454, #465):
1259
+ * hidden slots (and `<slot>_still` for any hidden or covered slot) have no
1260
+ * setup attachment, covered slots have `alt.slots[slot]`, and when the set
1261
+ * carries `drawn` bone rotations its bones are posed at those world rotations
1262
+ * so the throwaway still renders the alternate in the pose it was painted in.
1263
+ */
1264
+ export function stillRigForSet(
1265
+ rig: Record<string, unknown>,
1266
+ alt: AlternatePartsRecord,
1267
+ drawn: Readonly<Record<string, number>> | undefined = alt.drawn,
1268
+ ): Record<string, unknown> {
1269
+ const hides = new Set(alt.hides ?? []);
1270
+ const slots = (Array.isArray(rig.slots) ? rig.slots : []).map((s) => {
1271
+ if (!isRecord(s) || typeof s.name !== 'string') return s;
1272
+ const stillBase = s.name.endsWith('_still') ? s.name.slice(0, -'_still'.length) : null;
1273
+ if (hides.has(s.name) || (stillBase !== null && (hides.has(stillBase) || Object.hasOwn(alt.slots, stillBase)))) {
1274
+ return { ...s, attachment: null };
1275
+ }
1276
+ if (Object.hasOwn(alt.slots, s.name)) return { ...s, attachment: alt.slots[s.name] };
1277
+ return s;
1278
+ });
1279
+ if (drawn === undefined || Object.keys(drawn).length === 0) return { ...rig, slots };
1280
+ const rawBones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).map((b) => ({
1281
+ name: String(b.name),
1282
+ ...(typeof b.parent === 'string' && b.parent !== '' ? { parent: b.parent } : {}),
1283
+ ...(typeof b.length === 'number' ? { length: b.length } : {}),
1284
+ ...(typeof b.rotation === 'number' ? { rotation: b.rotation } : {}),
1285
+ x: typeof b.x === 'number' ? b.x : 0,
1286
+ y: typeof b.y === 'number' ? b.y : 0,
1287
+ ...(typeof b.inherit === 'string' ? { inherit: b.inherit as RigBone['inherit'] } : {}),
1288
+ }));
1289
+ const stops = (Array.isArray(rig.stops) ? rig.stops : []).filter(isRecord).map((s) => ({
1290
+ links: Array.isArray(s.links) ? s.links.filter((l): l is string => typeof l === 'string') : [],
1291
+ }));
1292
+ const bones = poseRigBonesForDrawn(rawBones, drawn, stops);
1293
+ const out: Record<string, unknown> = { ...rig, bones, slots };
1294
+ if (isRecord(out.invariants)) {
1295
+ const { axes: _axes, ...rest } = out.invariants;
1296
+ if (Object.keys(rest).length > 0) out.invariants = rest;
1297
+ else delete out.invariants;
1298
+ }
1299
+ return out;
1300
+ }
1301
+
1198
1302
  /**
1199
1303
  * The map from rig (crop) pixel indices to frame pixel indices that the seam
1200
1304
  * and the source line warp by: frame = (sx u + tx, sy v + ty).
@@ -1770,11 +1874,26 @@ export interface CheckFigures {
1770
1874
  * the keys it always wrote.
1771
1875
  */
1772
1876
  skipped?: { loop?: string; seam?: string };
1877
+ /** Written only under `--set` or `--all-sets` (issue #454): each checked alternate set's setup-pose seam and recomposite figures, keyed by set name. */
1878
+ sets?: Record<string, SetCheckFigures>;
1773
1879
  /** Written only under `--requirements` (issue #93): the file's fps, the summary, and one line per declared requirement, by name. */
1774
1880
  requirements?: RequirementsBlock;
1775
1881
  PASS: boolean;
1776
1882
  }
1777
1883
 
1884
+ /** One alternate set's setup-pose check record under `--set` or `--all-sets` (issue #454). */
1885
+ export interface SetCheckFigures {
1886
+ render_px: string;
1887
+ seam_mean: number;
1888
+ seam_px_over_40: number;
1889
+ seam_px_over_80: number;
1890
+ seam_frame_scale: number;
1891
+ seam_border?: { frame_px_left_out: number; px_over_40_left_out: number };
1892
+ seam_pairs?: SeamPairRow[];
1893
+ RECOMPOSITE_HOLES: ReportedLine;
1894
+ PASS: boolean;
1895
+ }
1896
+
1778
1897
  /** `check.json`'s `requirements` block: the fps every named animation was sampled at, the summary, and each line by its name, in the file's order. */
1779
1898
  export interface RequirementsBlock {
1780
1899
  fps: number;
@@ -1959,7 +2078,10 @@ export function barsNotMet(fig: Readonly<Record<string, unknown>>): string[] {
1959
2078
  if (typeof fig.loop_max_diff === 'number' && fig.loop_max_diff !== LOOP_MAX_BAR) out.push('loop');
1960
2079
  // A check.json written before issue #216 has no seam_frame_scale; it is read by the frame count, the bar it was written under.
1961
2080
  const scale = typeof fig.seam_frame_scale === 'number' ? fig.seam_frame_scale : 1;
1962
- if (typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) out.push('seam');
2081
+ const setFailed =
2082
+ isRecord(fig.sets) &&
2083
+ Object.values(fig.sets).some((s) => isRecord(s) && typeof s.seam_mean === 'number' && typeof s.seam_px_over_40 === 'number' && seamFails(s.seam_mean, s.seam_px_over_40, typeof s.seam_frame_scale === 'number' ? s.seam_frame_scale : 1));
2084
+ if ((typeof fig.seam_mean === 'number' && typeof fig.seam_px_over_40 === 'number' && seamFails(fig.seam_mean, fig.seam_px_over_40, scale)) || setFailed) out.push('seam');
1963
2085
  for (const n of [...JUDGEMENT_LINES, FOLD_LINE, LEVEL_LINE, STRESS_LINE, LIMB_LINE, SWEEP_LINE]) {
1964
2086
  const l = fig[n];
1965
2087
  if (typeof l === 'object' && l !== null && (l as { status?: unknown }).status === 'FAIL') out.push(n);
@@ -2569,7 +2691,7 @@ export function stressPlan(rig: Record<string, unknown>, motion: Record<string,
2569
2691
  continue;
2570
2692
  }
2571
2693
  const onSlot = skin[p.name];
2572
- const atts: unknown[] = isRecord(onSlot) ? Object.values(onSlot) : [];
2694
+ const atts: unknown[] = isRecord(onSlot) ? (isRecord(onSlot[p.name]) ? [onSlot[p.name]] : Object.values(onSlot)) : [];
2573
2695
  const weighted = atts.filter((a): a is Record<string, unknown> & { weights: unknown[] } => isRecord(a) && Array.isArray(a.weights));
2574
2696
  if (weighted.length === 0) {
2575
2697
  notRead.push({ part: p.name, reason: 'it is not a weighted mesh, so it rides one bone whole and cannot stretch' });
@@ -3072,11 +3194,11 @@ export function rigcFailed(what: string, call: RigcCall, lines: readonly string[
3072
3194
  * MEASURABLE is a problem. Without it nothing here reads, writes or prints
3073
3195
  * anything it did not before.
3074
3196
  */
3075
- export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome?: string, mode: PackMode = DEFAULT_PACK_MODE, source?: string, requirements?: string, seamPairsAsked = false, keepStills = false): CheckReport {
3197
+ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, partsHome?: string, mode: PackMode = DEFAULT_PACK_MODE, source?: string, requirements?: string, seamPairsAsked = false, keepStills = false, set?: string, allSets = false): CheckReport {
3076
3198
  let inp: CheckInputs;
3077
3199
  let reqFile: RequirementsFile | null = null;
3078
3200
  let seams: Map<string, SeamPair[]> | null = null;
3079
- if (requirements === undefined) inp = readCheckInputs(rigDir, partsHome, source);
3201
+ if (requirements === undefined) inp = readCheckInputs(rigDir, partsHome, source, set, allSets);
3080
3202
  else {
3081
3203
  // Both readers collect every problem; one refusal names both sets.
3082
3204
  const refused: Problem[] = [];
@@ -3089,7 +3211,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3089
3211
  return null;
3090
3212
  }
3091
3213
  };
3092
- const got = take(() => readCheckInputs(rigDir, partsHome, source));
3214
+ const got = take(() => readCheckInputs(rigDir, partsHome, source, set, allSets));
3093
3215
  reqFile = take(() => readRequirements(requirements));
3094
3216
  refuseIfAny(refused);
3095
3217
  inp = got as CheckInputs;
@@ -3436,6 +3558,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3436
3558
  let seamViewport: Viewport | null = null;
3437
3559
  let blink: JudgementLine = skip(lacking);
3438
3560
  let sourceRead: ReportedLine | null = null;
3561
+ const setsFigures: Record<string, SetCheckFigures> = {};
3439
3562
  if (inp.parts !== null || inp.source !== null) {
3440
3563
  try {
3441
3564
  mkdirSync(stillDir, { recursive: true });
@@ -3475,6 +3598,71 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3475
3598
  for (const { row, pixels } of split) writePng(join(out, row.picture), seamPicture(stillSet.frames[0].image, row, pixels));
3476
3599
  seamPairs = split.map((x) => x.row);
3477
3600
  }
3601
+ if (set !== undefined || allSets) {
3602
+ const alts = allSets ? (inp.parts.alternates ?? []) : (inp.parts.alternates ?? []).filter((a) => a.name === set);
3603
+ const setStillAnimations: Record<string, unknown> = { still: animations.still };
3604
+ const setStillBase = { ...withoutSliders(inp.rig, setStillAnimations), images };
3605
+ const setsJsonPath = join(inp.rigDir, SETS_FILE);
3606
+ const setsJsonRaw = existsSync(setsJsonPath) ? readJson(setsJsonPath, []) : null;
3607
+ const setsMap = setsJsonRaw !== null && isRecord(setsJsonRaw.sets) ? setsJsonRaw.sets : null;
3608
+ for (const alt of alts) {
3609
+ const setEntry: Record<string, unknown> | null =
3610
+ setsMap !== null && isRecord(setsMap[alt.name]) ? (setsMap[alt.name] as Record<string, unknown>) : null;
3611
+ const setDrawn: Record<string, number> | undefined =
3612
+ setEntry !== null && isRecord(setEntry.drawn)
3613
+ ? Object.fromEntries(
3614
+ Object.entries(setEntry.drawn).filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1])),
3615
+ )
3616
+ : alt.drawn;
3617
+ const setDir = join(stillDir, `set_${alt.name}`);
3618
+ mkdirSync(setDir, { recursive: true });
3619
+ writeFileSync(join(setDir, 'rig.json'), JSON.stringify(stillRigForSet(setStillBase, alt, setDrawn)));
3620
+ writeFileSync(join(setDir, 'motion.json'), JSON.stringify({ ...inp.motion, animations: setStillAnimations }));
3621
+ const asb = rigc(['build', '--rig', join(setDir, 'rig.json'), '--motion', join(setDir, 'motion.json'), '--out', join(setDir, 'build'), '--profile', 'spine']);
3622
+ if (asb.status !== 0) refuseIfAny(rigcFailed(`build (the setup-pose still of set "${alt.name}")`, asb, buildGateLines(asb.out)));
3623
+ const asr = rigc(['render', '--candidate', join(setDir, 'build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(setDir, 'render')]);
3624
+ if (asr.status !== 0) refuseIfAny(rigcFailed(`render (the setup-pose still of set "${alt.name}")`, asr, asr.out.split('\n').filter((l) => l.includes('FAIL'))));
3625
+ const altStillSet = readFrameSet(join(setDir, 'render'));
3626
+ if (keepStills) cpSync(join(setDir, 'render'), join(keptDir, alt.name), { recursive: true });
3627
+ const altComposite = flatCompositeForSet(inp.parts, alt, inp.partsDir, altStillSet.background);
3628
+ const altSeam = seamFigures(altComposite, altStillSet.frames[0].image, inp.stage, altStillSet.viewport, altStillSet.background, undefined, alt.scene !== undefined);
3629
+ const altMean = round3(altSeam.mean);
3630
+ const altScale = round6(altStillSet.viewport.scale);
3631
+ const altFailed = seamFails(altMean, altSeam.over40, altScale);
3632
+ let altPairs: SeamPairRow[] | null = null;
3633
+ if (seamPairsAsked || altFailed) {
3634
+ const hides = new Set(alt.hides ?? []);
3635
+ const byName = new Map(alt.records.map((r) => [r.name, r]));
3636
+ const placed = inp.parts.parts
3637
+ .filter((p) => !hides.has(p.name))
3638
+ .map((p) => {
3639
+ const rec = byName.get(p.name);
3640
+ const pngName = rec === undefined ? p.name : `${alt.name}_${rec.name}`;
3641
+ const box = rec ?? p;
3642
+ return { name: p.name, from: box.from, x: box.x, y: box.y, image: readPng(join(inp.partsDir, `${pngName}.png`)) };
3643
+ });
3644
+ const split = seamPairRows(altSeam, placed, inp.parts.rig_size, altStillSet.viewport);
3645
+ const altSeamDir = join(seamDir, alt.name);
3646
+ mkdirSync(altSeamDir, { recursive: true });
3647
+ for (const { row, pixels } of split) {
3648
+ row.picture = `${SEAM_DIR}/${alt.name}/${basename(row.picture)}`;
3649
+ writePng(join(out, row.picture), seamPicture(altStillSet.frames[0].image, row, pixels));
3650
+ }
3651
+ altPairs = split.map((x) => x.row);
3652
+ }
3653
+ setsFigures[alt.name] = {
3654
+ render_px: `${altStillSet.viewport.pixelWidth}x${altStillSet.viewport.pixelHeight}`,
3655
+ seam_mean: altMean,
3656
+ seam_px_over_40: altSeam.over40,
3657
+ seam_px_over_80: altSeam.over80,
3658
+ seam_frame_scale: altScale,
3659
+ ...(altSeam.border === null ? {} : { seam_border: { frame_px_left_out: altSeam.border.framePx, px_over_40_left_out: altSeam.border.over40 } }),
3660
+ ...(altPairs === null ? {} : { seam_pairs: altPairs }),
3661
+ RECOMPOSITE_HOLES: altHolesLine(inp.parts, alt),
3662
+ PASS: !altFailed,
3663
+ };
3664
+ }
3665
+ }
3478
3666
  }
3479
3667
  if (inp.source !== null) {
3480
3668
  // The same pose with every slot tinted black, built with the same animations so rigc fits the same grid.
@@ -3545,6 +3733,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3545
3733
  RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
3546
3734
  ...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
3547
3735
  ...(Object.keys(skipped).length === 0 ? {} : { skipped }),
3736
+ ...(Object.keys(setsFigures).length === 0 ? {} : { sets: setsFigures }),
3548
3737
  ...(reqBlock === null ? {} : { requirements: reqBlock }),
3549
3738
  PASS: false,
3550
3739
  };
@@ -3570,6 +3759,20 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3570
3759
  : `; worst of ${(seamPairs as SeamPairRow[]).length} pair(s): ${seamPairText(worst)} — every pair is a \`seam pair\` line above and a row of check.json's seam_pairs`),
3571
3760
  });
3572
3761
  }
3762
+ for (const [setName, setFig] of Object.entries(setsFigures)) {
3763
+ if (!setFig.PASS) {
3764
+ const worst = setFig.seam_pairs === undefined || setFig.seam_pairs.length === 0 ? null : setFig.seam_pairs[0];
3765
+ barProblems.push({
3766
+ code: 'CHECK_SEAM_WITHIN_BAR',
3767
+ object: `the setup-pose render of set "${setName}" vs the flat composite of parts/`,
3768
+ detail:
3769
+ `mean |d| ${setFig.seam_mean}/255 and ${setFig.seam_px_over_40} frame px over ${SEAM_PX_LEVEL} (${setFig.seam_px_over_80} over ${SEAM_PX_LEVEL_HIGH}) at frame scale ${setFig.seam_frame_scale}, ${seamRigPxText(setFig.seam_px_over_40, setFig.seam_frame_scale)} rig px (the count / s²); mean <= ${SEAM_MEAN_BAR.toFixed(1)} and <= ${SEAM_PX_BAR} rig px over ${SEAM_PX_LEVEL} are required` +
3770
+ (worst === null
3771
+ ? `; no pixel is over ${SEAM_PX_LEVEL}, so the mean alone fails and there is no pair to name`
3772
+ : `; worst of ${(setFig.seam_pairs as SeamPairRow[]).length} pair(s): ${seamPairText(worst)}`),
3773
+ });
3774
+ }
3775
+ }
3573
3776
  const rank = (q: Problem): number => JUDGEMENT_LINES.findIndex((n) => q.code === `CHECK_${n}`);
3574
3777
  problems.sort((a, b) => rank(a) - rank(b));
3575
3778
  problems.unshift(...barProblems);
@@ -3775,6 +3978,20 @@ export function holesLine(parts: PartsFile): ReportedLine {
3775
3978
  };
3776
3979
  }
3777
3980
 
3981
+ /** `RECOMPOSITE_HOLES` for an alternate set (issue #454), carrying its assemble `mean_abs` and hole figures. */
3982
+ export function altHolesLine(parts: PartsFile, alt: AlternatePartsRecord): ReportedLine {
3983
+ const r = alt.recomposite;
3984
+ return {
3985
+ status: 'REPORTED',
3986
+ ...(alt.scene === undefined ? {} : { declared_scene: scenePhrase(alt.scene, parts.rig_size[0] * parts.rig_size[1]) }),
3987
+ mean_abs: r.mean_abs,
3988
+ error_px: r.error_px,
3989
+ uncovered_error_px: r.uncovered_error_px,
3990
+ hole_count: r.hole_count,
3991
+ largest: r.holes.length === 0 ? null : { px: r.holes[0].px, box: boxText(r.holes[0]), borders: r.holes[0].borders.map((b) => `${b.part} ${b.px} px`) },
3992
+ };
3993
+ }
3994
+
3778
3995
  function boxText(h: { x: number; y: number; w: number; h: number }): string {
3779
3996
  return `${h.x},${h.y} ${h.w}x${h.h}`;
3780
3997
  }
@@ -4252,7 +4469,7 @@ function offChainClause(rig: Record<string, unknown>, motion: Record<string, unk
4252
4469
  const skins = isRecord(rig.skins) ? rig.skins : {};
4253
4470
  const skin = isRecord(skins.default) ? skins.default : {};
4254
4471
  const onSlot = skin[part];
4255
- const atts: unknown[] = isRecord(onSlot) ? Object.values(onSlot) : [];
4472
+ const atts: unknown[] = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
4256
4473
  const weighted = atts.filter((a): a is Record<string, unknown> & { weights: unknown[] } => isRecord(a) && Array.isArray(a.weights));
4257
4474
  if (weighted.length === 0) return null;
4258
4475
  const { found } = chainsCarrying(rig, motion, part);
@@ -4295,7 +4512,8 @@ function boundBones(rig: Record<string, unknown>, part: string): Set<string> {
4295
4512
  const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
4296
4513
  const skins = isRecord(rig.skins) ? rig.skins : {};
4297
4514
  const skin = isRecord(skins.default) ? skins.default : {};
4298
- const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
4515
+ const onSlot = skin[part];
4516
+ const atts = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
4299
4517
  const bound = new Set<string>();
4300
4518
  for (const a of atts) {
4301
4519
  if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
@@ -4345,7 +4563,8 @@ function breathCarrier(
4345
4563
  // The one weighted mesh the part draws, its weights in the order of the geometry's vertices.
4346
4564
  const skins = isRecord(rig.skins) ? rig.skins : {};
4347
4565
  const skin = isRecord(skins.default) ? skins.default : {};
4348
- const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
4566
+ const onSlot = skin[part];
4567
+ const atts = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
4349
4568
  const first = m.shown[0];
4350
4569
  if (atts.length !== 1 || first === null || first === undefined) return null;
4351
4570
  const att = atts[0];
package/src/cloth.ts CHANGED
@@ -107,11 +107,13 @@ export type ClothRecipes = Partial<Record<ClothTarget, ClothRecipe>>;
107
107
 
108
108
  /**
109
109
  * 📐 The fallback tree count when `draftTrees` is called without `chains` or
110
- * `masses` (issues #390, #445). When `chains` is omitted on
110
+ * `masses` (issues #390, #445, #461). When `chains` is omitted on
111
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.
112
+ * visible hair masses ({@link backHairMasses}, {@link backHairMassTrees}):
113
+ * connected components of the back-hair part's alpha below the root row (merged
114
+ * when they touch within one grid cell), with `max(1, floor(w / (4 * grid)) + 1)`
115
+ * trees per mass across its root-row width `w`, rooted at that mass's own top
116
+ * row and inside its columns.
115
117
  */
116
118
  export const RECIPE_BACK_HAIR_CHAINS = 2;
117
119
 
@@ -185,13 +187,16 @@ export interface TreeChain {
185
187
  /** Which outer sides of the piece are edges: both (a skirt, a lock), or one side of the eye axis (back hair). */
186
188
  export type EdgeSides = 'both' | 'r' | 'l';
187
189
 
188
- /** Issue #445: one visible hair mass's own frame for a single tree rooted at that mass's top row. */
190
+ /** Issue #445, #461: one visible hair mass's own frame for a tree rooted at that mass's top row. */
189
191
  export interface TreeMassInput {
190
192
  rootY: number;
191
193
  tipY: number;
192
194
  xAt: (y: number, frac: number) => number | null;
193
195
  fallbackX: (frac: number) => number;
194
196
  frame: string;
197
+ /** Issue #461: tree index within this mass (0-based, default 0) and total trees in this mass (default 1). */
198
+ treeInMass?: number;
199
+ treesInMass?: number;
195
200
  }
196
201
 
197
202
  export interface TreeColumnTip {
@@ -213,7 +218,7 @@ export interface TreeInput {
213
218
  /** What the cell's frame is, for the rules: "skirt's art", "the r side of back's art". */
214
219
  frame: string;
215
220
  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. */
221
+ /** Issue #445: when present, one entry per drafted back-hair tree; tree k sits inside `masses[k]` rather than cell [k/n, (k+1)/n] of the whole side. */
217
222
  masses?: readonly TreeMassInput[];
218
223
  /**
219
224
  * Issue #438: when present, resolves the tip row (and an optional `xAt` bounded to that column's cloth rows) for
@@ -267,7 +272,9 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
267
272
  };
268
273
  for (const k of activeTrees) {
269
274
  const mass = input.masses?.[k];
270
- const fc = mass === undefined ? (k + 0.5) / n : 0.5;
275
+ const tInMass = mass?.treeInMass ?? 0;
276
+ const nInMass = mass?.treesInMass ?? 1;
277
+ const fc = mass === undefined ? (k + 0.5) / n : (tInMass + 0.5) / nInMass;
271
278
  const trunkCol = trunkCols.get(k) ?? null;
272
279
  const branchCols: Array<TreeColumnTip | null> = [];
273
280
  if (into > 1 && mass === undefined && input.columnTip !== undefined) {
@@ -314,7 +321,7 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
314
321
  out.push(trunk);
315
322
  if (into === 1) continue;
316
323
  for (let b = 0; b < into; b++) {
317
- const fb = mass === undefined ? (k + (b + 0.5) / into) / n : (b + 0.5) / into;
324
+ const fb = mass === undefined ? (k + (b + 0.5) / into) / n : (tInMass + (b + 0.5) / into) / nInMass;
318
325
  const bCol = branchCols[b] ?? trunkCol;
319
326
  const bTipY = bCol?.tipY ?? trunkTipY;
320
327
  const bRows = bTipY === trunkTipY ? rows : linkRows(input.rootY, bTipY, r.links, ratio);
@@ -764,3 +771,48 @@ export function backHairMasses(mask: Mask, side: 'r' | 'l', axis: number, y0: nu
764
771
  out.sort((a, b) => a.minX - b.minX);
765
772
  return out;
766
773
  }
774
+
775
+ /**
776
+ * 📐 The horizontal influence reach of one back-hair tree in mesh grid cells: 4
777
+ * (2 grid cells on each side of the trunk, issue #461).
778
+ *
779
+ * Under the segment weight rule `w = 1 / (d + r)^2` (`src/weights.ts`) with
780
+ * `partGrid`'s falloff `r ≈ 0.42 * grid` and cutoff `MIN_WEIGHT = 0.03`, a
781
+ * tree's weight relative to `d = 0` (`(r / (d + r))^2`) drops to `MIN_WEIGHT`
782
+ * at `d = (1 / sqrt(0.03) - 1) * r = 4.7735 * 0.42 * grid = 2.0049 * grid ≈ 2 * grid`
783
+ * on each side of the trunk — a horizontal span of `4 * grid` per tree. Beyond
784
+ * that span a single tree leaves outer columns weighted to the static `head`
785
+ * segment while adjacent columns move with the tree, stretching the triangles
786
+ * across the boundary.
787
+ */
788
+ export const BACK_HAIR_TREE_REACH_CELLS = 4;
789
+
790
+ /**
791
+ * Opaque pixel count of a visible hair mass `m` along its own root row `m.minY`
792
+ * (issue #461).
793
+ */
794
+ export function backHairMassRootWidth(m: BackHairMass): number {
795
+ const W = m.mask.width;
796
+ const H = m.mask.height;
797
+ if (m.minY < 0 || m.minY >= H) return 0;
798
+ let w = 0;
799
+ for (let x = m.minX; x <= m.maxX; x++) {
800
+ if (m.mask.data[m.minY * W + x] !== 0) w++;
801
+ }
802
+ return w > 0 ? w : m.maxX - m.minX + 1;
803
+ }
804
+
805
+ /**
806
+ * Number of trees placed inside one visible hair mass `m` on a `grid` px mesh
807
+ * (issue #461): `max(1, floor(w / (4 * grid)) + 1)` where `w` is the mass's
808
+ * root-row opaque width ({@link backHairMassRootWidth}).
809
+ *
810
+ * A narrow mass (`w < 4 * grid`) gets 1 tree; a wide undivided tail spanning
811
+ * `4 * grid <= w < 8 * grid` gets 2 trees; `8 * grid <= w < 12 * grid` gets 3.
812
+ */
813
+ export function backHairMassTrees(m: BackHairMass, grid: number): number {
814
+ const step = Math.max(1, BACK_HAIR_TREE_REACH_CELLS * grid);
815
+ const w = backHairMassRootWidth(m);
816
+ return Math.max(1, Math.floor(w / step) + 1);
817
+ }
818
+