@rig-c/parts 1.27.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/check.ts CHANGED
@@ -1045,6 +1045,21 @@ export interface LoopPhysics {
1045
1045
  in: 'rig.json' | 'motion.json';
1046
1046
  }
1047
1047
 
1048
+ /**
1049
+ * A throwaway copy of `rig` whose accompanying `motion` replaces `animations`
1050
+ * with a synthetic set (`still`, `blink_shut`, `stress ...`, `sweep ...`) omits
1051
+ * any `slider` constraint whose target animation is not in that set.
1052
+ */
1053
+ function withoutSliders(rig: Record<string, unknown>, animations: Record<string, unknown>): Record<string, unknown> {
1054
+ if (!Array.isArray(rig.constraints)) return rig;
1055
+ const kept = rig.constraints.filter((c) => !(isRecord(c) && c.type === 'slider' && typeof c.animation === 'string' && !(c.animation in animations)));
1056
+ if (kept.length === rig.constraints.length) return rig;
1057
+ const out: Record<string, unknown> = { ...rig };
1058
+ if (kept.length > 0) out.constraints = kept;
1059
+ else delete out.constraints;
1060
+ return out;
1061
+ }
1062
+
1048
1063
  /**
1049
1064
  * The rig spec and motion spec with every physics constraint left out, and
1050
1065
  * the constraints it left out; null when there is none, so a rig without one
@@ -3331,8 +3346,9 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3331
3346
  mkdirSync(stressDir, { recursive: true });
3332
3347
  const base = stripped === null ? inp.rig : stripped.rig;
3333
3348
  const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
3334
- writeFileSync(join(stressDir, 'rig.json'), JSON.stringify({ ...base, images }));
3335
- writeFileSync(join(stressDir, 'motion.json'), JSON.stringify({ ...(stripped === null ? inp.motion : stripped.motion), animations: stressAnimations(plan) }));
3349
+ const stressAnims = stressAnimations(plan);
3350
+ writeFileSync(join(stressDir, 'rig.json'), JSON.stringify({ ...withoutSliders(base, stressAnims), images }));
3351
+ writeFileSync(join(stressDir, 'motion.json'), JSON.stringify({ ...(stripped === null ? inp.motion : stripped.motion), animations: stressAnims }));
3336
3352
  const sb = rigc(['build', '--rig', join(stressDir, 'rig.json'), '--motion', join(stressDir, 'motion.json'), '--out', join(stressDir, 'build'), '--profile', 'spine']);
3337
3353
  if (sb.status !== 0) refuseIfAny(rigcFailed(`build (${STRESS_LINE}'s turned copy)`, sb, buildGateLines(sb.out)));
3338
3354
  const sr = rigc(['render', '--candidate', join(stressDir, 'build'), '--fps', String(IDLE_FPS), '--max', String(FOLD_RENDER_MAX), '--geometry', '--out', join(stressDir, 'render')]);
@@ -3380,7 +3396,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3380
3396
  const sets = new Map<string, { under: FrameSet; bare: FrameSet }>();
3381
3397
  if (copy.slots.length > 0) {
3382
3398
  for (const [file, image] of copy.images) writePng(join(imagesDir, file), image);
3383
- writeFileSync(join(sweepDir, 'rig.json'), JSON.stringify({ ...copy.rig, images: imagesDir }));
3399
+ const sweepAnims = isRecord(copy.motion.animations) ? copy.motion.animations : {};
3400
+ writeFileSync(join(sweepDir, 'rig.json'), JSON.stringify({ ...withoutSliders(copy.rig, sweepAnims), images: imagesDir }));
3384
3401
  writeFileSync(join(sweepDir, 'motion.json'), JSON.stringify(copy.motion));
3385
3402
  const sb = rigc(['build', '--rig', join(sweepDir, 'rig.json'), '--motion', join(sweepDir, 'motion.json'), '--out', join(sweepDir, 'build'), '--profile', 'spine']);
3386
3403
  if (sb.status !== 0) refuseIfAny(rigcFailed(`build (${SWEEP_LINE}'s bent copy)`, sb, buildGateLines(sb.out)));
@@ -3423,7 +3440,6 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3423
3440
  try {
3424
3441
  mkdirSync(stillDir, { recursive: true });
3425
3442
  const images = typeof inp.rig.images === 'string' ? resolve(inp.rigDir, inp.rig.images) : inp.rig.images;
3426
- writeFileSync(join(stillDir, 'rig.json'), JSON.stringify({ ...inp.rig, images }));
3427
3443
  const eyes = full === null ? [] : partsTagged(full.parts, EYE_TAGS);
3428
3444
  const slotBone = new Map((Array.isArray(inp.rig.slots) ? inp.rig.slots : []).filter(isRecord).map((sl) => [String(sl.name), String(sl.bone)]));
3429
3445
  const shut = full === null ? [] : blinkTracks(inp.motion, eyeBones(inp.rig, eyes.map((p) => slotBone.get(p.name) ?? '')));
@@ -3437,6 +3453,8 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3437
3453
  tracks: shut.map((b) => ({ ...b.target, property: 'scaley', keys: [{ t: 0, v: [b.closed] }, { t: STILL_DURATION, v: [b.closed] }] })),
3438
3454
  };
3439
3455
  }
3456
+ const stillRig = { ...withoutSliders(inp.rig, animations), images };
3457
+ writeFileSync(join(stillDir, 'rig.json'), JSON.stringify(stillRig));
3440
3458
  writeFileSync(join(stillDir, 'motion.json'), JSON.stringify({ ...inp.motion, animations }));
3441
3459
  const sb = rigc(['build', '--rig', join(stillDir, 'rig.json'), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'build'), '--profile', 'spine']);
3442
3460
  if (sb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still)', sb, buildGateLines(sb.out)));
@@ -3460,7 +3478,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3460
3478
  }
3461
3479
  if (inp.source !== null) {
3462
3480
  // The same pose with every slot tinted black, built with the same animations so rigc fits the same grid.
3463
- writeFileSync(join(stillDir, BLACK_RIG), JSON.stringify(blackRig({ ...inp.rig, images })));
3481
+ writeFileSync(join(stillDir, BLACK_RIG), JSON.stringify(blackRig(stillRig)));
3464
3482
  const kb = rigc(['build', '--rig', join(stillDir, BLACK_RIG), '--motion', join(stillDir, 'motion.json'), '--out', join(stillDir, 'black_build'), '--profile', 'spine']);
3465
3483
  if (kb.status !== 0) refuseIfAny(rigcFailed('build (the setup-pose still, every slot tinted black)', kb, buildGateLines(kb.out)));
3466
3484
  const kr = rigc(['render', '--candidate', join(stillDir, 'black_build'), '--animation', 'still', '--fps', String(STILL_FPS), '--max', String(maxSide), '--out', join(stillDir, 'black')]);
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
+ }