@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/docs/AUTHORING.md +15 -5
- package/docs/STABILITY.md +4 -2
- package/package.json +1 -1
- package/src/assemble.ts +286 -13
- package/src/check.ts +23 -5
- package/src/cloth.ts +297 -29
- package/src/config.ts +160 -6
- package/src/coords.ts +1 -1
- package/src/diagnostics.ts +1 -0
- package/src/limbs.ts +221 -1
- package/src/motion.ts +15 -5
- package/src/propose.ts +618 -37
- package/src/rig.ts +13 -2
- package/src/turn.ts +665 -1
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
|
-
|
|
3335
|
-
writeFileSync(join(stressDir, '
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
|
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
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
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 ===
|
|
235
|
-
const right = k ===
|
|
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 (
|
|
239
|
-
const
|
|
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:
|
|
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 ${
|
|
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(
|
|
256
|
-
trunk.why.push(`${
|
|
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:
|
|
325
|
+
tip: ptWith(bCol, bRows[r.links], fb),
|
|
270
326
|
why: [],
|
|
271
|
-
tipWhy: `the tip row, ${pyRound(fb, 6)} of the way across ${
|
|
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 :
|
|
280
|
-
ch.why.push(j === trunkLinks ? `the fork: the trunk ${trunk.chain}'s tip` : `${
|
|
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
|
+
}
|