@rig-c/parts 1.28.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 +14 -5
- package/docs/STABILITY.md +2 -1
- package/package.json +1 -1
- package/src/assemble.ts +286 -13
- package/src/cloth.ts +297 -29
- package/src/config.ts +129 -4
- package/src/diagnostics.ts +1 -0
- package/src/limbs.ts +221 -1
- package/src/propose.ts +618 -37
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
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -139,9 +139,29 @@ export interface Generation {
|
|
|
139
139
|
control?: Control;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
export type ArmSplitTag = 'handwear-r' | 'handwear-l';
|
|
143
|
+
export const ARM_SPLIT_TAGS: readonly ArmSplitTag[] = ['handwear-r', 'handwear-l'];
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Issue #439: an optional declaration on `config.seethrough` that separates
|
|
147
|
+
* overlapping arms in the full run into `handwear-r` (smaller image x, the
|
|
148
|
+
* character's right as they face the viewer) and `handwear-l` (larger image x,
|
|
149
|
+
* the character's left) in the draw order `order` states, back to front.
|
|
150
|
+
* Exactly one of `x` (an integer column on the full run's canvas) or `line`
|
|
151
|
+
* (a polyline of at least two `[x, y]` integer points on the full run's
|
|
152
|
+
* canvas) is given.
|
|
153
|
+
*/
|
|
154
|
+
export interface ArmSplit {
|
|
155
|
+
x?: number;
|
|
156
|
+
line?: Array<[number, number]>;
|
|
157
|
+
order: ['handwear-r', 'handwear-l'] | ['handwear-l', 'handwear-r'];
|
|
158
|
+
}
|
|
159
|
+
|
|
142
160
|
export interface SeeThrough {
|
|
143
161
|
/** Square crop `[x0, y0, x1, y1]` in source pixels for the second, head-only run. */
|
|
144
162
|
head_box?: [number, number, number, number];
|
|
163
|
+
/** Issue #439: arm separation when both arms overlap in the full run. */
|
|
164
|
+
arm_split?: ArmSplit;
|
|
145
165
|
resolution: number;
|
|
146
166
|
steps: number;
|
|
147
167
|
seed: number;
|
|
@@ -701,9 +721,21 @@ export interface LimbSegment {
|
|
|
701
721
|
cut_band?: number;
|
|
702
722
|
}
|
|
703
723
|
|
|
724
|
+
/**
|
|
725
|
+
* Issue #446: a per-limb split declared on the covering limb where two limbs
|
|
726
|
+
* share one part and cross: either `line` (a polyline of two or more `[x, y]`
|
|
727
|
+
* points in rig px along the covering limb's seam) or `edge: "painting"`
|
|
728
|
+
* (traces the covering limb's painted stroke or silhouette across the crossing).
|
|
729
|
+
*/
|
|
730
|
+
export interface LimbSplit {
|
|
731
|
+
line?: Array<[number, number]>;
|
|
732
|
+
edge?: 'painting';
|
|
733
|
+
}
|
|
734
|
+
|
|
704
735
|
/** A limb (issue #349): its segments, upper first; each segment's bone is the child of the one before. */
|
|
705
736
|
export interface Limb {
|
|
706
737
|
segments: LimbSegment[];
|
|
738
|
+
split?: LimbSplit;
|
|
707
739
|
}
|
|
708
740
|
|
|
709
741
|
export interface CharacterConfig {
|
|
@@ -1571,10 +1603,10 @@ function checkGeneration(c: Check, v: Json): void {
|
|
|
1571
1603
|
}
|
|
1572
1604
|
|
|
1573
1605
|
function checkSeeThrough(c: Check, v: Json): void {
|
|
1574
|
-
const s = c.object('config.seethrough', v, ['resolution', 'steps', 'seed', 'offload'], ['head_box']);
|
|
1606
|
+
const s = c.object('config.seethrough', v, ['resolution', 'steps', 'seed', 'offload'], ['head_box', 'arm_split']);
|
|
1575
1607
|
if (s === null) return;
|
|
1576
1608
|
const p = 'config.seethrough';
|
|
1577
|
-
|
|
1609
|
+
const res = 'resolution' in s && c.int(`${p}.resolution`, s.resolution, 1) ? (s.resolution as number) : null;
|
|
1578
1610
|
if ('steps' in s) c.int(`${p}.steps`, s.steps, 1);
|
|
1579
1611
|
if ('seed' in s) c.int(`${p}.seed`, s.seed, 0);
|
|
1580
1612
|
if ('offload' in s && typeof s.offload !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${p}.offload`, `is ${show(s.offload)}; true or false is required`);
|
|
@@ -1589,6 +1621,55 @@ function checkSeeThrough(c: Check, v: Json): void {
|
|
|
1589
1621
|
}
|
|
1590
1622
|
}
|
|
1591
1623
|
}
|
|
1624
|
+
if ('arm_split' in s) checkArmSplit(c, `${p}.arm_split`, s.arm_split, res);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function checkArmSplit(c: Check, path: string, v: Json, resolution: number | null): void {
|
|
1628
|
+
const a = c.object(path, v, ['order'], ['x', 'line']);
|
|
1629
|
+
if (a === null) return;
|
|
1630
|
+
if (!('x' in a) && !('line' in a)) {
|
|
1631
|
+
c.fail('CONFIG_FIELD_PRESENT', path, 'has neither x nor line; a split column ("x") or a polyline ("line") is required to separate the arms');
|
|
1632
|
+
} else if ('x' in a && 'line' in a) {
|
|
1633
|
+
c.fail('CONFIG_FIELD_TYPE', `${path}.line`, 'is set alongside x; give either a split column ("x") or a polyline ("line"), not both');
|
|
1634
|
+
}
|
|
1635
|
+
if ('x' in a && c.number(`${path}.x`, a.x, 'non-negative')) {
|
|
1636
|
+
const x = a.x as number;
|
|
1637
|
+
if (resolution !== null && x > resolution) {
|
|
1638
|
+
c.fail('CONFIG_FIELD_TYPE', `${path}.x`, `is ${x}; a column inside the ${resolution}x${resolution} run canvas (0 <= x <= ${resolution}) is required`);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
if ('line' in a) {
|
|
1642
|
+
const l = a.line;
|
|
1643
|
+
if (!Array.isArray(l) || l.length < 2) {
|
|
1644
|
+
c.fail('CONFIG_FIELD_TYPE', `${path}.line`, `is ${show(l)}; an array of at least 2 [x, y] points in run pixels is required`);
|
|
1645
|
+
} else {
|
|
1646
|
+
l.forEach((pt, i) => {
|
|
1647
|
+
const ok = Array.isArray(pt) && pt.length === 2 && pt.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
1648
|
+
if (!ok) {
|
|
1649
|
+
c.fail('CONFIG_FIELD_TYPE', `${path}.line[${i}]`, `is ${show(pt)}; a point [x, y] in run pixels is required`);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
const [px, py] = pt as [number, number];
|
|
1653
|
+
if (px < 0 || py < 0 || (resolution !== null && (px > resolution || py > resolution))) {
|
|
1654
|
+
c.fail(
|
|
1655
|
+
'CONFIG_FIELD_TYPE',
|
|
1656
|
+
`${path}.line[${i}]`,
|
|
1657
|
+
`is [${px}, ${py}]; ${resolution !== null ? `a point inside the ${resolution}x${resolution} run canvas (0 <= x, y <= ${resolution})` : 'x >= 0 and y >= 0'} is required`,
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
if ('order' in a) {
|
|
1664
|
+
const o = a.order;
|
|
1665
|
+
const valid =
|
|
1666
|
+
Array.isArray(o) &&
|
|
1667
|
+
o.length === 2 &&
|
|
1668
|
+
((o[0] === 'handwear-r' && o[1] === 'handwear-l') || (o[0] === 'handwear-l' && o[1] === 'handwear-r'));
|
|
1669
|
+
if (!valid) {
|
|
1670
|
+
c.fail('CONFIG_FIELD_TYPE', `${path}.order`, `is ${show(o)}; ["handwear-r", "handwear-l"] or ["handwear-l", "handwear-r"] (back to front) is required`);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1592
1673
|
}
|
|
1593
1674
|
|
|
1594
1675
|
function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
|
|
@@ -2309,10 +2390,39 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
|
|
|
2309
2390
|
}
|
|
2310
2391
|
const sources = new Set([...made.parts, ...made.cuts]);
|
|
2311
2392
|
const firstAt = new Map<string, string>();
|
|
2393
|
+
const limbFroms = new Map<string, Set<string>>();
|
|
2394
|
+
const splitLimbs: string[] = [];
|
|
2312
2395
|
for (const [limb, spec] of Object.entries(v)) {
|
|
2313
2396
|
const at = `config.limbs.${limb}`;
|
|
2314
|
-
const o = c.object(at, spec, ['segments'], []);
|
|
2315
|
-
if (o === null
|
|
2397
|
+
const o = c.object(at, spec, ['segments'], ['split']);
|
|
2398
|
+
if (o === null) continue;
|
|
2399
|
+
if ('split' in o) {
|
|
2400
|
+
const sp = c.object(`${at}.split`, o.split, [], ['line', 'edge']);
|
|
2401
|
+
if (sp !== null) {
|
|
2402
|
+
const hasLine = 'line' in sp;
|
|
2403
|
+
const hasEdge = 'edge' in sp;
|
|
2404
|
+
if ((hasLine && hasEdge) || (!hasLine && !hasEdge)) {
|
|
2405
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.split`, `is ${show(o.split)}; either "line" (a polyline of two or more [x, y] points in rig px) or "edge": "painting" is required, not both and not neither`);
|
|
2406
|
+
} else if (hasLine) {
|
|
2407
|
+
const pts = sp.line;
|
|
2408
|
+
const ok =
|
|
2409
|
+
Array.isArray(pts) &&
|
|
2410
|
+
pts.length >= 2 &&
|
|
2411
|
+
pts.every((p) => Array.isArray(p) && p.length === 2 && typeof p[0] === 'number' && Number.isFinite(p[0]) && typeof p[1] === 'number' && Number.isFinite(p[1]));
|
|
2412
|
+
if (!ok) {
|
|
2413
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.split.line`, `is ${show(pts)}; an array of two or more [x, y] points in rig px is required`);
|
|
2414
|
+
} else {
|
|
2415
|
+
const typed = pts as Array<[number, number]>;
|
|
2416
|
+
if (typed.every((p) => p[0] === typed[0][0] && p[1] === typed[0][1])) {
|
|
2417
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.split.line`, `is ${show(pts)}; a polyline with non-zero length is required — all points stand on [${typed[0].join(', ')}]`);
|
|
2418
|
+
} else splitLimbs.push(limb);
|
|
2419
|
+
}
|
|
2420
|
+
} else if (sp.edge !== 'painting') {
|
|
2421
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.split.edge`, `is ${show(sp.edge)}; "painting" is required — traces the covering limb's edge across the crossing from the painting's own stroke or silhouette`);
|
|
2422
|
+
} else splitLimbs.push(limb);
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
if (!('segments' in o)) continue;
|
|
2316
2426
|
if (!Array.isArray(o.segments) || o.segments.length < 2) {
|
|
2317
2427
|
c.fail('CONFIG_FIELD_TYPE', `${at}.segments`, `is ${show(o.segments)}; an array of two segments or more, upper first, is required — a limb is cut at a joint, and a joint has a segment on each side`);
|
|
2318
2428
|
continue;
|
|
@@ -2382,6 +2492,7 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
|
|
|
2382
2492
|
}
|
|
2383
2493
|
}
|
|
2384
2494
|
});
|
|
2495
|
+
limbFroms.set(limb, new Set(froms.filter((f): f is string => f !== null)));
|
|
2385
2496
|
froms.forEach((f, i) => {
|
|
2386
2497
|
if (f === null || i === 0 || froms[i - 1] === f) return;
|
|
2387
2498
|
const back = froms.slice(0, i - 1).lastIndexOf(f);
|
|
@@ -2399,6 +2510,20 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
|
|
|
2399
2510
|
});
|
|
2400
2511
|
}
|
|
2401
2512
|
}
|
|
2513
|
+
for (const limb of splitLimbs) {
|
|
2514
|
+
const myFroms = limbFroms.get(limb) ?? new Set<string>();
|
|
2515
|
+
if (myFroms.size === 0) continue;
|
|
2516
|
+
const sharers = [...limbFroms.entries()].filter(([other, fs]) => other !== limb && [...myFroms].some((f) => fs.has(f)));
|
|
2517
|
+
if (sharers.length === 0) {
|
|
2518
|
+
c.fail('CONFIG_FIELD_TYPE', `config.limbs.${limb}.split`, `is declared on "${limb}", whose from part(s) (${[...myFroms].map((f) => `"${f}"`).join(', ')}) no other limb takes; a per-limb split divides a part shared by two limbs`);
|
|
2519
|
+
} else {
|
|
2520
|
+
const otherSplit = sharers.find(([other]) => splitLimbs.includes(other));
|
|
2521
|
+
if (otherSplit !== undefined) {
|
|
2522
|
+
const sharedFrom = [...myFroms].find((f) => otherSplit[1].has(f)) as string;
|
|
2523
|
+
c.fail('CONFIG_FIELD_TYPE', `config.limbs.${limb}.split`, `is declared on "${limb}" and on "${otherSplit[0]}", which both take "${sharedFrom}"; declare split on the covering limb alone`);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2402
2527
|
return out;
|
|
2403
2528
|
}
|
|
2404
2529
|
|
package/src/diagnostics.ts
CHANGED
|
@@ -79,6 +79,7 @@ export interface BasisFile {
|
|
|
79
79
|
* `derived` placement names at least one bone; a constant is a finite number.
|
|
80
80
|
*/
|
|
81
81
|
export function checkBasis(P: PartSet, proposal: Proposal, basis: ProposalBasis): void {
|
|
82
|
+
P = P.beforeLimbs();
|
|
82
83
|
const problems: Problem[] = [];
|
|
83
84
|
const names = [...expand(proposal.bones).byName.keys()];
|
|
84
85
|
const bones = new Set(names);
|