@rig-c/parts 1.25.0 → 1.27.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
@@ -2017,12 +2017,24 @@ export const FOLD_RENDER_MAX = 16;
2017
2017
  /** The fold render, under `--out`; removed before the stage returns. */
2018
2018
  const FOLD_SCRATCH = '_folds';
2019
2019
 
2020
- /** One mesh that folds in one animation: rigc's figures, the declaration that takes it if any, and rigc's own line. */
2020
+ /** One side of a mesh's fold reading (with physics on or off): rigc's figures and its printed line. */
2021
+ export interface FoldSide {
2022
+ /** How many frames show a folded triangle. */
2023
+ frames: number;
2024
+ /** The frame with the most, the earliest on a tie (rigc's `worst`). */
2025
+ worst_frame: number;
2026
+ /** How many triangles are folded on that frame. */
2027
+ worst_triangles: number;
2028
+ /** rigc's printed line for the mesh, verbatim (`rigc render --folds`). */
2029
+ line: string;
2030
+ }
2031
+
2032
+ /** One mesh that folds in one animation: rigc's figures, with physics on and off side by side, its physics constraints when only physics folds it, the declaration that takes it if any, and rigc's own line. */
2021
2033
  export interface FoldRow {
2022
2034
  animation: string;
2023
2035
  slot: string;
2024
2036
  attachment: string;
2025
- /** How many frames show a folded triangle. */
2037
+ /** How many frames show a folded triangle (the active reading, physics on when the rig has physics). */
2026
2038
  frames: number;
2027
2039
  /** The frame with the most, the earliest on a tie (rigc's `worst`). */
2028
2040
  worst_frame: number;
@@ -2030,6 +2042,12 @@ export interface FoldRow {
2030
2042
  worst_triangles: number;
2031
2043
  /** How many triangles the mesh has. */
2032
2044
  triangles: number;
2045
+ /** The reading with physics on, or null when the mesh folds only with physics off. */
2046
+ physics_on: FoldSide | null;
2047
+ /** The reading with physics off, or null when the mesh folds only with physics on. */
2048
+ physics_off: FoldSide | null;
2049
+ /** When the mesh folds only with physics on (`physics_on !== null && physics_off === null`), the physics constraints on its bones (`<name> on <bone>`); empty otherwise. */
2050
+ physics_constraints: string[];
2033
2051
  /** The `may_fold` entry's why when the requirements file declares this slot and animation; null when nothing does. */
2034
2052
  declared: string | null;
2035
2053
  /** rigc's printed line for the mesh, verbatim (`rigc render --folds`). */
@@ -2042,7 +2060,7 @@ export interface FoldsLine {
2042
2060
  /** Every animation, the idle included, in motion.json's order: each one read. */
2043
2061
  animations: string[];
2044
2062
  fps: number;
2045
- /** How many physics constraints the read build left out ({@link withoutPhysics}); 0 when the rig has none. */
2063
+ /** How many physics constraints the rig declares ({@link withoutPhysics}) — stepped in `physics_on` and left out in `physics_off`; 0 when the rig has none. */
2046
2064
  physics_left_out: number;
2047
2065
  /** Every mesh that folds in an animation read, in that animation's order and rigc's mesh order. */
2048
2066
  folding: FoldRow[];
@@ -2130,26 +2148,118 @@ export function readFoldSets(dir: string, named: readonly string[], fps: number,
2130
2148
  }
2131
2149
 
2132
2150
  /**
2133
- * The fold line (issue #320) from the reading: every folding mesh a row, a
2134
- * row no `may_fold` entry takes a `CHECK_ANIMATIONS_DO_NOT_FOLD` problem
2135
- * naming the animation, the slot and rigc's line, and an entry no row
2136
- * matches a problem of its own — a declaration that excepts nothing.
2151
+ * The physics constraints on the bones a mesh attachment is bound to (or their
2152
+ * ancestors, or `<bone>` when a stopped link is bound as `<bone>_d`), in
2153
+ * {@link withoutPhysics} order, formatted `"<name> on <bone>"`.
2137
2154
  */
2138
- export function foldsLine(named: readonly string[], fps: number, physicsLeftOut: number, readings: ReadonlyMap<string, readonly FoldingMesh[]>, mayFold: readonly MayFold[], requirementsPath: string | null, problems: Problem[]): FoldsLine {
2155
+ export function meshPhysicsConstraints(rig: Record<string, unknown>, slot: string, attachment: string, physics: readonly LoopPhysics[]): string[] {
2156
+ if (physics.length === 0) return [];
2157
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
2158
+ const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
2159
+ const skins = isRecord(rig.skins) ? rig.skins : {};
2160
+ const skin = isRecord(skins.default) ? skins.default : {};
2161
+ const onSlot = isRecord(skin[slot]) ? skin[slot] : {};
2162
+ const att = isRecord(onSlot[attachment]) ? onSlot[attachment] : null;
2163
+ const bound = new Set<string>();
2164
+ const addWithAncestors = (start: string): void => {
2165
+ for (let b: string | null = start; b !== null && !bound.has(b); b = parent.get(b) ?? null) {
2166
+ bound.add(b);
2167
+ if (b.endsWith('_d')) bound.add(b.slice(0, -2));
2168
+ }
2169
+ };
2170
+ if (att !== null && Array.isArray(att.weights)) {
2171
+ for (const v of att.weights) {
2172
+ if (!Array.isArray(v)) continue;
2173
+ for (const w of v) if (isRecord(w) && typeof w.bone === 'string' && typeof w.weight === 'number' && w.weight > 0) addWithAncestors(w.bone);
2174
+ }
2175
+ }
2176
+ if (bound.size === 0) {
2177
+ const slotRec = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((s) => s.name === slot);
2178
+ if (slotRec !== undefined && typeof slotRec.bone === 'string') addWithAncestors(slotRec.bone);
2179
+ }
2180
+ const direct = physics.filter((p) => bound.has(p.bone));
2181
+ return (direct.length > 0 ? direct : physics).map((p) => `${p.name} on ${p.bone}`);
2182
+ }
2183
+
2184
+ /**
2185
+ * The fold line (issues #320, #333, #363) from the reading: posed with physics
2186
+ * stepping on the render clock (`readings`, and `readingsWithoutPhysics`
2187
+ * beside it when the rig declares physics constraints), every folding mesh a
2188
+ * row reporting `physics_on` and `physics_off` side by side, a row no
2189
+ * `may_fold` entry takes a `CHECK_ANIMATIONS_DO_NOT_FOLD` problem naming the
2190
+ * animation, the slot, the physics constraint(s) on its bones and the worst
2191
+ * frame when the fold appears only with physics on, and rigc's line, and an
2192
+ * entry no row matches a problem of its own — a declaration that excepts
2193
+ * nothing.
2194
+ */
2195
+ export function foldsLine(
2196
+ named: readonly string[],
2197
+ fps: number,
2198
+ physicsLeftOut: number,
2199
+ readings: ReadonlyMap<string, readonly FoldingMesh[]>,
2200
+ mayFold: readonly MayFold[],
2201
+ requirementsPath: string | null,
2202
+ problems: Problem[],
2203
+ readingsWithoutPhysics: ReadonlyMap<string, readonly FoldingMesh[]> = readings,
2204
+ rig: Record<string, unknown> = {},
2205
+ physicsList: readonly LoopPhysics[] = [],
2206
+ ): FoldsLine {
2207
+ const toSide = (m: FoldingMesh | undefined): FoldSide | null =>
2208
+ m === undefined ? null : { frames: m.frames, worst_frame: m.worstFrame, worst_triangles: m.worstTriangles, line: m.line };
2139
2209
  const folding: FoldRow[] = [];
2140
2210
  for (const name of named) {
2141
- for (const m of readings.get(name) ?? []) {
2142
- const entry = mayFold.find((e) => e.slot === m.slot && e.animation === name);
2143
- folding.push({ animation: name, slot: m.slot, attachment: m.attachment, frames: m.frames, worst_frame: m.worstFrame, worst_triangles: m.worstTriangles, triangles: m.triangleCount, declared: entry === undefined ? null : entry.why, line: m.line });
2211
+ const onList = readings.get(name) ?? [];
2212
+ const offList = readingsWithoutPhysics.get(name) ?? [];
2213
+ const seen = new Set<string>();
2214
+ const keys: Array<{ slot: string; attachment: string }> = [];
2215
+ for (const m of [...onList, ...offList]) {
2216
+ const k = `${m.slot}\0${m.attachment}`;
2217
+ if (!seen.has(k)) {
2218
+ seen.add(k);
2219
+ keys.push({ slot: m.slot, attachment: m.attachment });
2220
+ }
2221
+ }
2222
+ for (const { slot, attachment } of keys) {
2223
+ const on = onList.find((m) => m.slot === slot && m.attachment === attachment);
2224
+ const off = offList.find((m) => m.slot === slot && m.attachment === attachment);
2225
+ const primary = (on ?? off) as FoldingMesh;
2226
+ const entry = mayFold.find((e) => e.slot === slot && e.animation === name);
2227
+ const onlyPhysicsOn = on !== undefined && off === undefined;
2228
+ const constraints = onlyPhysicsOn ? meshPhysicsConstraints(rig, slot, attachment, physicsList) : [];
2229
+ folding.push({
2230
+ animation: name,
2231
+ slot,
2232
+ attachment,
2233
+ frames: primary.frames,
2234
+ worst_frame: primary.worstFrame,
2235
+ worst_triangles: primary.worstTriangles,
2236
+ triangles: primary.triangleCount,
2237
+ physics_on: toSide(on),
2238
+ physics_off: toSide(off),
2239
+ physics_constraints: constraints,
2240
+ declared: entry === undefined ? null : entry.why,
2241
+ line: primary.line,
2242
+ });
2144
2243
  }
2145
2244
  }
2146
- const physics = physicsLeftOut === 0 ? '' : `, its ${physicsLeftOut} physics constraint(s) left out`;
2245
+ const physics =
2246
+ physicsLeftOut === 0
2247
+ ? ''
2248
+ : `, with its ${physicsLeftOut} physics constraint(s) on and off`;
2147
2249
  for (const r of folding) {
2148
2250
  if (r.declared !== null) continue;
2251
+ const sideClause =
2252
+ r.physics_on !== null && r.physics_off === null && physicsLeftOut > 0
2253
+ ? ` (physics on: ${r.physics_on.frames} frame(s), worst frame ${r.physics_on.worst_frame} (${r.physics_on.worst_triangles}/${r.triangles} triangle(s)); physics off: 0 frame(s); physics constraint(s) on mesh "${r.attachment}": ${r.physics_constraints.join(', ')})`
2254
+ : r.physics_on !== null && r.physics_off !== null && physicsLeftOut > 0
2255
+ ? ` (physics on: ${r.physics_on.frames} frame(s), worst frame ${r.physics_on.worst_frame}; physics off: ${r.physics_off.frames} frame(s), worst frame ${r.physics_off.worst_frame})`
2256
+ : r.physics_on === null && r.physics_off !== null && physicsLeftOut > 0
2257
+ ? ` (physics on: 0 frame(s); physics off: ${r.physics_off.frames} frame(s), worst frame ${r.physics_off.worst_frame})`
2258
+ : '';
2149
2259
  problems.push({
2150
2260
  code: `CHECK_${FOLD_LINE}`,
2151
2261
  object: `animation "${r.animation}", mesh "${r.attachment}" (slot "${r.slot}")`,
2152
- detail: `rigc render --folds at ${fps} fps${physics}: ${r.line}; no triangle turned over against the setup pose is required — a fold the art means is declared in the --requirements file's may_fold, { "slot": "${r.slot}", "animation": "${r.animation}", "why": … }`,
2262
+ detail: `rigc render --folds at ${fps} fps${physics}${sideClause}: ${r.line}; no triangle turned over against the setup pose is required — a fold the art means is declared in the --requirements file's may_fold, { "slot": "${r.slot}", "animation": "${r.animation}", "why": … }`,
2153
2263
  });
2154
2264
  }
2155
2265
  const declaredNotFolding = mayFold.filter((e) => !folding.some((r) => r.slot === e.slot && r.animation === e.animation)).map((e) => ({ slot: e.slot, animation: e.animation }));
@@ -3101,26 +3211,46 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3101
3211
  written.push('motion_heat.png');
3102
3212
  }
3103
3213
 
3104
- // 3a. every animation, the idle included, read for folds by rigc (issues #320, #333) — only when there is one; at
3105
- // the idle's rate whatever --requirements says, so a requirements file moves only what it declares (CK78), from the keyed build
3214
+ // 3a. every animation, the idle included, read for folds by rigc (issues #320, #333, #363) — only when there is one; at
3215
+ // the idle's rate whatever --requirements says, so a requirements file moves only what it declares (CK78), with physics on and off
3106
3216
  if (animationNames.length > 0) {
3107
3217
  const fps = IDLE_FPS;
3108
3218
  try {
3109
- // Issue #348: with a foot to read, the same render writes each animation's geometry as well.
3219
+ // Issue #348: with a foot to read, the keyed render writes each animation's geometry as well.
3110
3220
  const geometry = feet.length > 0 ? ['--geometry'] : [];
3111
- const fr = rigc(['render', '--candidate', keyedBuild('the animations with their physics constraint(s) left out'), '--folds', ...geometry, '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', foldDir]);
3112
- if (fr.status !== 0) refuseIfAny(rigcFailed(`render --folds${geometry.map((g) => ` ${g}`).join('')} --fps ${fps}${stripped === null ? '' : ' (physics constraint(s) left out)'}`, fr, fr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
3113
- const readings = readFoldSets(foldDir, animationNames, fps, fr.out);
3114
- folds = foldsLine(animationNames, fps, stripped === null ? 0 : stripped.physics.length, readings, reqFile === null ? [] : reqFile.may_fold, reqFile === null ? null : reqFile.path, foldProblems);
3115
- if (feet.length > 0) {
3116
- const sets = readJson(join(foldDir, 'frames.json'), []);
3117
- const listed = sets !== null && Array.isArray(sets.sets) ? sets.sets.filter(isRecord) : [];
3118
- // readFoldSets has refused a missing set by name, so each animation has its directory here.
3119
- const levelReadings = animationNames.map((name) => {
3120
- const dir = join(foldDir, String((listed.find((x) => x.animation === name) as Record<string, unknown>).dir));
3121
- return { animation: name, set: readFrameSet(dir), geometryPath: join(dir, GEOMETRY_FILE) };
3122
- });
3123
- level = levelLine(feet, inp.rig, levelReadings, fps, stripped === null ? 0 : stripped.physics.length, levelProblems);
3221
+ if (stripped === null) {
3222
+ const fr = rigc(['render', '--candidate', buildDir, '--folds', ...geometry, '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', foldDir]);
3223
+ if (fr.status !== 0) refuseIfAny(rigcFailed(`render --folds${geometry.map((g) => ` ${g}`).join('')} --fps ${fps}`, fr, fr.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
3224
+ const readings = readFoldSets(foldDir, animationNames, fps, fr.out);
3225
+ folds = foldsLine(animationNames, fps, 0, readings, reqFile === null ? [] : reqFile.may_fold, reqFile === null ? null : reqFile.path, foldProblems, readings, inp.rig, []);
3226
+ if (feet.length > 0) {
3227
+ const sets = readJson(join(foldDir, 'frames.json'), []);
3228
+ const listed = sets !== null && Array.isArray(sets.sets) ? sets.sets.filter(isRecord) : [];
3229
+ const levelReadings = animationNames.map((name) => {
3230
+ const dir = join(foldDir, String((listed.find((x) => x.animation === name) as Record<string, unknown>).dir));
3231
+ return { animation: name, set: readFrameSet(dir), geometryPath: join(dir, GEOMETRY_FILE) };
3232
+ });
3233
+ level = levelLine(feet, inp.rig, levelReadings, fps, 0, levelProblems);
3234
+ }
3235
+ } else {
3236
+ const onDir = join(foldDir, 'on');
3237
+ const offDir = join(foldDir, 'off');
3238
+ const frOn = rigc(['render', '--candidate', buildDir, '--folds', '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', onDir]);
3239
+ if (frOn.status !== 0) refuseIfAny(rigcFailed(`render --folds --fps ${fps}`, frOn, frOn.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
3240
+ const frOff = rigc(['render', '--candidate', keyedBuild('the animations with their physics constraint(s) left out'), '--folds', ...geometry, '--fps', String(fps), '--max', String(FOLD_RENDER_MAX), '--out', offDir]);
3241
+ if (frOff.status !== 0) refuseIfAny(rigcFailed(`render --folds${geometry.map((g) => ` ${g}`).join('')} --fps ${fps} (physics constraint(s) left out)`, frOff, frOff.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
3242
+ const readingsOn = readFoldSets(onDir, animationNames, fps, frOn.out);
3243
+ const readingsOff = readFoldSets(offDir, animationNames, fps, frOff.out);
3244
+ folds = foldsLine(animationNames, fps, stripped.physics.length, readingsOn, reqFile === null ? [] : reqFile.may_fold, reqFile === null ? null : reqFile.path, foldProblems, readingsOff, inp.rig, stripped.physics);
3245
+ if (feet.length > 0) {
3246
+ const sets = readJson(join(offDir, 'frames.json'), []);
3247
+ const listed = sets !== null && Array.isArray(sets.sets) ? sets.sets.filter(isRecord) : [];
3248
+ const levelReadings = animationNames.map((name) => {
3249
+ const dir = join(offDir, String((listed.find((x) => x.animation === name) as Record<string, unknown>).dir));
3250
+ return { animation: name, set: readFrameSet(dir), geometryPath: join(dir, GEOMETRY_FILE) };
3251
+ });
3252
+ level = levelLine(feet, inp.rig, levelReadings, fps, stripped.physics.length, levelProblems);
3253
+ }
3124
3254
  }
3125
3255
  } finally {
3126
3256
  rmSync(foldDir, { recursive: true, force: true });
@@ -4066,12 +4196,17 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
4066
4196
  } else if (!ok) {
4067
4197
  failed = true;
4068
4198
  // Issue #262: a root the breath carries is named with its carrier, not blamed on the chain.
4199
+ // Issue #351: when the breath carries the root and the chain is also below the bar, or when the mesh is weighted
4200
+ // off its chain without a breath carrier, name that too instead of only blaming the chain's amplitudes.
4069
4201
  const carry = breathCarrier(inp.rig, inp.motion, p.name, geometryPath, frames, { art, box: p, stage: inp.stage, shown, toward: rises ? 'up' : 'down', root: t.root, tip: t.tip });
4070
4202
  if (carry !== null) rows[rows.length - 1] = { ...rows[rows.length - 1], carried_by: carry.bone, breath_rig_px: round3(carry.breath), root_without_breath_rig_px: round3(carry.root), tip_without_breath_rig_px: round3(carry.tip) };
4203
+ const offChain = carry === null && round3(t.tip) > 0 ? offChainClause(inp.rig, inp.motion, p.name) : null;
4071
4204
  const why =
4072
4205
  carry === null
4073
- ? "the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain"
4074
- : `the root travels with the breath (${round3(t.root)} px of ${round3(carry.breath)} px): it is carried by \`${carry.bone}\`, which moves it more than the tip; with \`${carry.keyed}\`'s translate held still the root travels ${round3(carry.root)} rig px and the tip ${round3(carry.tip)}, which the bar passes (ratio ${carry.root > 0 ? round3(carry.tip / carry.root) : 'undefined: the root moves only with it'}) — bind the root to the bone the part hangs from, not to \`${carry.bone}\``;
4206
+ ? `the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain${offChain === null ? '' : `; ${offChain}`}`
4207
+ : carry.passes
4208
+ ? `the root travels with the breath (${round3(t.root)} px of ${round3(carry.breath)} px): it is carried by \`${carry.bone}\`, which moves it more than the tip; with \`${carry.keyed}\`'s translate held still the root travels ${round3(carry.root)} rig px and the tip ${round3(carry.tip)}, which the bar passes (ratio ${carry.root > 0 ? round3(carry.tip / carry.root) : 'undefined: the root moves only with it'}) — bind the root to the bone the part hangs from, not to \`${carry.bone}\``
4209
+ : `the root travels with the breath (${round3(t.root)} px of ${round3(carry.breath)} px): it is carried by \`${carry.bone}\`, which moves it more than the tip; with \`${carry.keyed}\`'s translate held still the root travels ${round3(carry.root)} rig px and the tip ${round3(carry.tip)}, which is still below the bar (ratio ${carry.root > 0 ? round3(carry.tip / carry.root) : 'undefined: the root moves only with it'}, < ${TIP_RATIO_FLOOR}) — drop the segment on \`${carry.bone}\` from \`meshes.${p.name}.segments\` (or bind the root to the bone the part hangs from, not to \`${carry.bone}\`) and grow the chain's amplitudes toward the tip`;
4075
4210
  problems.push({
4076
4211
  code: 'CHECK_TIP_OVER_ROOT',
4077
4212
  object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry${rises ? ', declared rises' : ''}`,
@@ -4088,6 +4223,52 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
4088
4223
  return { status: failed ? 'FAIL' : 'PASS', ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR, ...(excepted.rests.length === 0 ? {} : { rest_ratio_ceiling: TIP_REST_RATIO_CEILING }) };
4089
4224
  }
4090
4225
 
4226
+ /**
4227
+ * When a failing weighted mesh rides a chain (`chainsCarrying`) and also carries positive weight on a bone off that
4228
+ * chain (neither a link nor under one, as {@link stressPlan} reads it), name the off-chain bones and their vertex
4229
+ * counts so `CHECK_TIP_OVER_ROOT` does not only blame the chain's amplitudes (issue #351).
4230
+ */
4231
+ function offChainClause(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string): string | null {
4232
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
4233
+ const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
4234
+ const skins = isRecord(rig.skins) ? rig.skins : {};
4235
+ const skin = isRecord(skins.default) ? skins.default : {};
4236
+ const onSlot = skin[part];
4237
+ const atts: unknown[] = isRecord(onSlot) ? Object.values(onSlot) : [];
4238
+ const weighted = atts.filter((a): a is Record<string, unknown> & { weights: unknown[] } => isRecord(a) && Array.isArray(a.weights));
4239
+ if (weighted.length === 0) return null;
4240
+ const { found } = chainsCarrying(rig, motion, part);
4241
+ if (found.length === 0) return null;
4242
+ const links = new Set(found.flat());
4243
+ const onChain = (b: string): boolean => {
4244
+ const seen = new Set<string>();
4245
+ for (let x: string | null = b; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
4246
+ if (links.has(x)) return true;
4247
+ seen.add(x);
4248
+ }
4249
+ return false;
4250
+ };
4251
+ const off = new Map<string, { vertices: number; largest: number }>();
4252
+ let of = 0;
4253
+ for (const a of weighted) {
4254
+ for (const v of a.weights) {
4255
+ of++;
4256
+ const ws = (Array.isArray(v) ? v : []).filter(isRecord).filter((w) => typeof w.bone === 'string' && typeof w.weight === 'number') as Array<{ bone: string; weight: number }>;
4257
+ for (const w of ws) {
4258
+ if (onChain(w.bone) || w.weight <= 0) continue;
4259
+ const e = off.get(w.bone) ?? { vertices: 0, largest: 0 };
4260
+ e.vertices++;
4261
+ e.largest = Math.max(e.largest, w.weight);
4262
+ off.set(w.bone, e);
4263
+ }
4264
+ }
4265
+ }
4266
+ if (off.size === 0) return null;
4267
+ const list = [...off].map(([bone, e]) => `"${bone}" (${e.vertices} of ${of} vertices, largest weight ${round3(e.largest)})`).join(', ');
4268
+ const drop = off.size === 1 ? `"${[...off.keys()][0]}"` : 'each of those bones';
4269
+ return `it is weighted off the chain to ${list} — drop the segment on ${drop} in \`meshes.${part}.segments\`, or grow the chain's amplitudes toward the tip`;
4270
+ }
4271
+
4091
4272
  /**
4092
4273
  * The bones a part's attachments are bound to in the default skin (issue #231): a mesh's weights, or its slot's
4093
4274
  * bone for an attachment that carries no weights.
@@ -4109,17 +4290,16 @@ function boundBones(rig: Record<string, unknown>, part: string): Set<string> {
4109
4290
  const BREATH_PROPS: readonly string[] = ['translate', 'translatex', 'translatey'];
4110
4291
 
4111
4292
  /**
4112
- * The bone a failing part's root travels with, when that is the breath's (issue #262). The breath is every idle
4293
+ * The bone a failing part's root travels with, when that is the breath's (issues #262, #351). The breath is every idle
4113
4294
  * track that translates a bone ({@link BREATH_PROPS}), each read on its own: `K` the bone it keys. Its motion is
4114
4295
  * `K`'s world origin less where `K`'s parent carries `K`'s setup position, frame by frame — what the translate keys
4115
4296
  * add — and the breath's travel is that, less its frame-0 value, at its largest. The part (one weighted mesh; a
4116
4297
  * region is carried whole and cannot stretch) is posed again with that motion taken out of every vertex in
4117
4298
  * proportion to its weight on `K` and the bones under it, and its halves measured as {@link halfTravels} measures
4118
- * them. The root travels with the breath when the breath adds more to the root's travel than to the tip's and, held
4119
- * still, the halves pass the bar: tip >= {@link TIP_RATIO_FLOOR} x root, the tip above 0 at the line's precision
4120
- * (round3). The margin is the bar's own, so the line names the carrier exactly when the breath is what fails it;
4121
- * the carrier named is the bone the part is bound to ({@link boundBones}) at or under `K` that no other such bone is
4122
- * above (the sleeve's links ride `chest`, which is the carrier). Null otherwise.
4299
+ * them. The root travels with the breath when the breath adds more to the root's travel than to the tip's and the tip
4300
+ * moves above 0 at the line's precision (round3); `passes` records whether, held still, the halves pass the bar
4301
+ * (tip >= {@link TIP_RATIO_FLOOR} x root). The carrier named is the bone the part is bound to ({@link boundBones}) at
4302
+ * or under `K` that no other such bone is above (the sleeve's links ride `chest`, which is the carrier). Null otherwise.
4123
4303
  */
4124
4304
  function breathCarrier(
4125
4305
  rig: Record<string, unknown>,
@@ -4128,7 +4308,7 @@ function breathCarrier(
4128
4308
  geometryPath: string,
4129
4309
  frames: number,
4130
4310
  m: { art: ArtMask; box: { x: number; y: number; w: number; h: number }; stage: StageBox; shown: ReadonlyArray<PosedAttachment | null>; toward: 'down' | 'up'; root: number; tip: number },
4131
- ): { bone: string; keyed: string; breath: number; root: number; tip: number } | null {
4311
+ ): { bone: string; keyed: string; breath: number; root: number; tip: number; passes: boolean } | null {
4132
4312
  const anims = isRecord(motion.animations) ? motion.animations : {};
4133
4313
  const idle = isRecord(anims.idle) ? anims.idle : {};
4134
4314
  const keyed = [...new Set((Array.isArray(idle.tracks) ? idle.tracks : []).filter(isRecord).filter((t) => typeof t.bone === 'string' && typeof t.property === 'string' && BREATH_PROPS.includes(t.property)).map((t) => String(t.bone)))];
@@ -4186,8 +4366,8 @@ function breathCarrier(
4186
4366
  const held = m.shown.map((f, i) => (f === null ? null : { rest: f.rest, triangles: f.triangles, posed: f.posed.map((v, j) => v - share[j >> 1] * delta[i][j & 1]) }));
4187
4367
  const t = halfTravels(m.art, m.box, m.stage, held, m.toward);
4188
4368
  if (t.root === null || t.tip === null) continue;
4189
- if (!(m.root - t.root > m.tip - t.tip) || round3(t.tip) === 0 || !(t.tip >= TIP_RATIO_FLOOR * t.root)) continue;
4190
- return { bone: carrier, keyed: k, breath, root: t.root, tip: t.tip };
4369
+ if (!(m.root - t.root > Math.max(0, m.tip - t.tip)) || round3(t.tip) === 0) continue;
4370
+ return { bone: carrier, keyed: k, breath, root: t.root, tip: t.tip, passes: t.tip >= TIP_RATIO_FLOOR * t.root };
4191
4371
  }
4192
4372
  return null;
4193
4373
  }
package/src/cloth.ts CHANGED
@@ -80,9 +80,9 @@ export interface ClothStrength {
80
80
  edge: number;
81
81
  }
82
82
 
83
- /** One target's recipe, as `config.cloth.<tag>` declares it. */
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). */
84
84
  export interface ClothRecipe {
85
- chains: number;
85
+ chains?: number;
86
86
  links: number;
87
87
  split?: { at: number; into: number; hold_tip?: boolean };
88
88
  length_ratio?: number;
@@ -95,12 +95,26 @@ export interface ClothRecipe {
95
95
  gravity: [number, number];
96
96
  translate?: number;
97
97
  swing?: { scales: number[] };
98
- /** `bottomwear` only (issue #402): a `topwear` part the skirt's trees are drafted on as well — a dress. */
99
- part?: string;
98
+ /** `bottomwear` only when a string (a `topwear` part or its cut skirt piece, issue #402); on any target when a list of parts the recipe's trees run through from root to tip (issue #437; the single-part form is the one-element list). */
99
+ part?: string | string[];
100
+ /** A list of parts the recipe's trees run through in order from root to tip, one chain across the union and each link weighted to the part whose pixels it crosses (issue #437; the single-part form is the one-element list). */
101
+ parts?: string[];
100
102
  }
101
103
 
102
104
  export type ClothRecipes = Partial<Record<ClothTarget, ClothRecipe>>;
103
105
 
106
+ /**
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).
115
+ */
116
+ export const RECIPE_BACK_HAIR_CHAINS = 2;
117
+
104
118
  /**
105
119
  * 📐 The default ratio of a path's last link to its first: 0.5, the recipe's
106
120
  * own. Measured on the cloth fixture (fixtures/cloth.ts, rig-c 2.47.1, the
@@ -206,7 +220,7 @@ const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
206
220
 
207
221
  /** The trees of one piece, in tree order, each trunk before its branches. */
208
222
  export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
209
- const n = r.chains;
223
+ const n = r.chains ?? RECIPE_BACK_HAIR_CHAINS;
210
224
  const ratio = r.length_ratio ?? RECIPE_LENGTH_RATIO;
211
225
  const rows = linkRows(input.rootY, input.tipY, r.links, ratio);
212
226
  const at = r.split === undefined ? r.links : r.split.at;
@@ -260,7 +274,7 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
260
274
  depths: [],
261
275
  last: [],
262
276
  };
263
- const edge = r.split?.hold_tip === true ? false : outerSide(k, b);
277
+ const edge = r.split?.hold_tip === true && r.strength === undefined ? false : outerSide(k, b);
264
278
  for (let j = trunkLinks; j < r.links; j++) {
265
279
  ch.points.push(j === trunkLinks ? trunk.tip : pt(rows[j], fb));
266
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})`);
@@ -457,3 +471,28 @@ export function tailOverlap(tail: Tail, part: Mask, box: { x: number; y: number;
457
471
  }
458
472
  return { counts, over, total };
459
473
  }
474
+
475
+ /**
476
+ * Issue #437: how many interior pixel steps along `span` (`a -> b`, sampled at
477
+ * `(s + 0.5) / ceil(|b - a|)` for `s = 0 .. ceil(|b - a|) - 1`) land on an
478
+ * opaque pixel of `mask`. Consecutive vertical links `[y0, y1]`, `[y1, y2]`
479
+ * sample half-open row intervals `[y0, y1)`, `[y1, y2)` with no shared boundary
480
+ * pixel at the joint `y1`, so a link strictly on one side of a horizontal seam
481
+ * takes 0 pixels on the other part.
482
+ */
483
+ export function spanCrosses(span: Pick<TailSpan, 'a' | 'b'>, mask: Mask): number {
484
+ const [x0, y0] = span.a;
485
+ const [x1, y1] = span.b;
486
+ const dx = x1 - x0;
487
+ const dy = y1 - y0;
488
+ const steps = Math.max(1, Math.ceil(Math.sqrt(dx * dx + dy * dy)));
489
+ let count = 0;
490
+ for (let s = 0; s < steps; s++) {
491
+ const t = (s + 0.5) / steps;
492
+ const x = Math.floor(x0 + t * dx);
493
+ const y = Math.floor(y0 + t * dy);
494
+ if (x >= 0 && x < mask.width && y >= 0 && y < mask.height && mask.data[y * mask.width + x] !== 0) count++;
495
+ }
496
+ return count;
497
+ }
498
+