@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/README.md +1 -1
- package/cli.ts +20 -5
- package/docs/AUTHORING.md +61 -38
- package/docs/MIGRATION.md +1 -1
- package/docs/STABILITY.md +7 -7
- package/package.json +1 -1
- package/skills/rig-parts/SKILL.md +1 -1
- package/src/assemble.ts +131 -25
- package/src/build.ts +23 -9
- package/src/check.ts +220 -40
- package/src/cloth.ts +45 -6
- package/src/config.ts +80 -11
- package/src/headbox.ts +68 -34
- package/src/propose.ts +506 -41
- package/src/rig.ts +74 -6
- package/src/scene.ts +87 -7
- package/src/squeeze.ts +1 -1
package/src/propose.ts
CHANGED
|
@@ -148,7 +148,7 @@ import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER
|
|
|
148
148
|
import { edgeLength } from './length.ts';
|
|
149
149
|
import { limbWidth } from './limbs.ts';
|
|
150
150
|
import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.ts';
|
|
151
|
-
import { RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
|
|
151
|
+
import { RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, spanCrosses, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
|
|
152
152
|
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
153
153
|
|
|
154
154
|
// ---------------------------------------------------------------------------
|
|
@@ -278,20 +278,23 @@ export interface Proposal {
|
|
|
278
278
|
constraints?: ConfigConstraint[];
|
|
279
279
|
/** Only with `propose --full`/`--head` (issue #251, {@link draftTurn}); absent otherwise, so a proposal without them is the one written before it. */
|
|
280
280
|
turn?: TurnSpec;
|
|
281
|
+
/** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) its coordinates were drafted at (issue #354). */
|
|
282
|
+
proposed_rig_scale: number;
|
|
281
283
|
notes: string[];
|
|
282
284
|
}
|
|
283
285
|
|
|
284
286
|
/**
|
|
285
287
|
* Every section of a proposal that is a config section, in the order the
|
|
286
288
|
* proposal writes them: what an author copies into the config (issue #375).
|
|
287
|
-
* `notes` is the proposal's own and is copied nowhere.
|
|
288
|
-
* only on request — `limbs` under `--keypoints`, `
|
|
289
|
-
* — and a `limbs` block left behind is refused
|
|
290
|
-
* every part it cuts. {@link checkProposal} reads
|
|
291
|
-
* loader by this list, the type below refuses a
|
|
292
|
-
* hold, and the selftest's `TY18` holds every
|
|
289
|
+
* `notes` is the proposal's own and is copied nowhere. Three of them are written
|
|
290
|
+
* only on request — `limbs` under `--keypoints`, `constraints` under `--cloth`,
|
|
291
|
+
* `turn` under `--full`/`--head` — and a `limbs` block left behind is refused
|
|
292
|
+
* at the next step, by the name of every part it cuts. {@link checkProposal} reads
|
|
293
|
+
* the proposal through the config loader by this list, the type below refuses a
|
|
294
|
+
* {@link Proposal} key it does not hold, and the selftest's `TY18` holds every
|
|
295
|
+
* paste sentence in the documents to it.
|
|
293
296
|
*/
|
|
294
|
-
export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
|
|
297
|
+
export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn', 'proposed_rig_scale'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
|
|
295
298
|
|
|
296
299
|
/** A {@link Proposal} key that is neither `notes` nor in {@link PROPOSAL_CONFIG_SECTIONS}; `never` while the list is whole. */
|
|
297
300
|
type UnlistedProposalSection = Exclude<keyof Proposal, 'notes' | (typeof PROPOSAL_CONFIG_SECTIONS)[number]>;
|
|
@@ -414,7 +417,7 @@ export function proposalAssemble(P: PartSet): Record<string, unknown> {
|
|
|
414
417
|
const planned = run.filter((r) => r.cut_from === undefined && firstOf.get(r.from) === r.name);
|
|
415
418
|
const pieces = run.filter((r) => r.cut_from !== undefined || firstOf.get(r.from) !== r.name);
|
|
416
419
|
return {
|
|
417
|
-
rig_scale:
|
|
420
|
+
rig_scale: P.rigScale,
|
|
418
421
|
plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
|
|
419
422
|
patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
|
|
420
423
|
...(pieces.length === 0
|
|
@@ -611,7 +614,7 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
|
|
|
611
614
|
}
|
|
612
615
|
turn = { pivot: turn.pivot, depths };
|
|
613
616
|
}
|
|
614
|
-
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), notes: [...p.notes, ...draft.notes] };
|
|
617
|
+
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), proposed_rig_scale: p.proposed_rig_scale, notes: [...p.notes, ...draft.notes] };
|
|
615
618
|
}
|
|
616
619
|
|
|
617
620
|
// ---------------------------------------------------------------------------
|
|
@@ -622,12 +625,14 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
|
|
|
622
625
|
export class PartSet {
|
|
623
626
|
readonly W: number;
|
|
624
627
|
readonly H: number;
|
|
628
|
+
readonly rigScale: number;
|
|
625
629
|
readonly recs: readonly PartRecord[];
|
|
626
630
|
readonly images: ReadonlyMap<string, Raster>;
|
|
627
631
|
private readonly masks = new Map<string, Mask>();
|
|
628
632
|
|
|
629
633
|
constructor(file: PartsFile, images: ReadonlyMap<string, Raster>) {
|
|
630
634
|
[this.W, this.H] = file.rig_size;
|
|
635
|
+
this.rigScale = file.scale_rig_per_source;
|
|
631
636
|
this.recs = file.parts;
|
|
632
637
|
this.images = images;
|
|
633
638
|
}
|
|
@@ -1372,20 +1377,41 @@ export function proposeWithBasis(
|
|
|
1372
1377
|
// Issue #364: the trees a cloth recipe drafts, their constraints, and the tags it was used on.
|
|
1373
1378
|
const clothConstraints: ConfigConstraint[] = [];
|
|
1374
1379
|
const clothUsed = new Set<ClothTarget>();
|
|
1380
|
+
const multiPartClaimed = new Set<string>();
|
|
1375
1381
|
const recipeOf = (tag: ClothTarget): ClothRecipe | null => cloth?.[tag] ?? null;
|
|
1382
|
+
const declaredPartsOf = (tag: ClothTarget): { field: 'parts' | 'part'; list: PartRecord[] } | null => {
|
|
1383
|
+
const rcp = recipeOf(tag);
|
|
1384
|
+
if (rcp === null) return null;
|
|
1385
|
+
const raw = rcp.parts !== undefined ? { field: 'parts' as const, names: rcp.parts } : Array.isArray(rcp.part) ? { field: 'part' as const, names: rcp.part } : null;
|
|
1386
|
+
if (raw === null) return null;
|
|
1387
|
+
const object = `config.cloth.${JSON.stringify(tag)}.${raw.field}`;
|
|
1388
|
+
const list: PartRecord[] = [];
|
|
1389
|
+
for (const name of raw.names) {
|
|
1390
|
+
const q = P.recs.find((x) => x.name === name);
|
|
1391
|
+
if (q === undefined) {
|
|
1392
|
+
throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object, detail: `names "${name}", which is no part in parts.json; every part in the list must exist in parts.json` }]);
|
|
1393
|
+
}
|
|
1394
|
+
if (splitFrom(q.from)[0] === PAINTING_RUN) {
|
|
1395
|
+
throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object, detail: `names "${name}", from ${q.from}; a painting patch has no layer tag and cannot be part of a cloth tree` }]);
|
|
1396
|
+
}
|
|
1397
|
+
list.push(q);
|
|
1398
|
+
}
|
|
1399
|
+
return { field: raw.field, list };
|
|
1400
|
+
};
|
|
1376
1401
|
// Issue #391: the tails the back hair recipe drafts, each with its mesh's spans, which an accessory wrapped round one rides.
|
|
1377
1402
|
const tails: Tail[] = [];
|
|
1378
|
-
const T = (p: PartRecord, tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
|
|
1403
|
+
const T = (p: PartRecord | readonly PartRecord[], tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
|
|
1404
|
+
const partNames = Array.isArray(p) ? p.map((x) => x.name) : [(p as PartRecord).name];
|
|
1379
1405
|
const trees = draftTrees(input, r);
|
|
1380
|
-
const constants = [konst('cloth_chains', r.chains), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
|
|
1406
|
+
const constants = [konst('cloth_chains', r.chains ?? RECIPE_BACK_HAIR_CHAINS), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
|
|
1381
1407
|
for (const t of trees) {
|
|
1382
1408
|
C(
|
|
1383
1409
|
t.chain,
|
|
1384
1410
|
t.parent,
|
|
1385
1411
|
t.points,
|
|
1386
1412
|
t.tip,
|
|
1387
|
-
t.why.map((w) => placement('mask', w, { parts:
|
|
1388
|
-
placement('mask', t.tipWhy, { parts:
|
|
1413
|
+
t.why.map((w) => placement('mask', w, { parts: partNames, bones: [input.parent], constants })),
|
|
1414
|
+
placement('mask', t.tipWhy, { parts: partNames, bones: [input.parent], constants }),
|
|
1389
1415
|
);
|
|
1390
1416
|
}
|
|
1391
1417
|
clothUsed.add(tag);
|
|
@@ -1516,7 +1542,225 @@ export function proposeWithBasis(
|
|
|
1516
1542
|
};
|
|
1517
1543
|
|
|
1518
1544
|
// ---- back hair
|
|
1545
|
+
const backHairParts = declaredPartsOf('back hair');
|
|
1546
|
+
const backHairSingleSame =
|
|
1547
|
+
backHairParts !== null &&
|
|
1548
|
+
backHairParts.list.length === 1 &&
|
|
1549
|
+
P.headFirst('back hair').length === 1 &&
|
|
1550
|
+
backHairParts.list[0] === P.headFirst('back hair')[0] &&
|
|
1551
|
+
SIDES.every(([s]) => {
|
|
1552
|
+
const m = P.alpha(backHairParts.list[0]);
|
|
1553
|
+
for (let y = 0; y < m.height; y++) {
|
|
1554
|
+
for (let x = 0; x < m.width; x++) {
|
|
1555
|
+
if ((s === 'r' ? x < axis : x >= axis) && m.data[y * m.width + x] !== 0) return true;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
return false;
|
|
1559
|
+
});
|
|
1560
|
+
if (backHairSingleSame) {
|
|
1561
|
+
const p0 = backHairParts.list[0];
|
|
1562
|
+
if (p0.y + p0.h < neckY + 0.5 * fh) {
|
|
1563
|
+
throw new PartsError([
|
|
1564
|
+
{
|
|
1565
|
+
code: 'PROPOSE_CLOTH_PART',
|
|
1566
|
+
object: `config.cloth."back hair".${backHairParts.field}`,
|
|
1567
|
+
detail: `names "${p0.name}" (${p0.from}, box y=${p0.y}..${p0.y + p0.h}), which ends above the shoulders (y=${pyFixed(neckY + 0.5 * fh, 1)}), where the proposer drafts a bun bone with no tree links — no tree link crosses "${p0.name}"`,
|
|
1568
|
+
},
|
|
1569
|
+
]);
|
|
1570
|
+
}
|
|
1571
|
+
} else if (backHairParts !== null) {
|
|
1572
|
+
const { field, list } = backHairParts;
|
|
1573
|
+
const object = `config.cloth."back hair".${field}`;
|
|
1574
|
+
const rawRcp = recipeOf('back hair') as ClothRecipe;
|
|
1575
|
+
const p0 = list[0];
|
|
1576
|
+
const m0 = P.alpha(p0);
|
|
1577
|
+
const shoulderLine = neckY + 0.5 * fh;
|
|
1578
|
+
const sideHasBelow = (m: Mask, s: 'r' | 'l', yMin: number): boolean => {
|
|
1579
|
+
const r0 = Math.max(0, Math.ceil(yMin));
|
|
1580
|
+
for (let y = r0; y < m.height; y++) {
|
|
1581
|
+
for (let x = 0; x < m.width; x++) {
|
|
1582
|
+
if ((s === 'r' ? x < axis : x >= axis) && m.data[y * m.width + x] !== 0) return true;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
return false;
|
|
1586
|
+
};
|
|
1587
|
+
const activeSides = SIDES.filter(([s]) => list.some((q) => sideHasBelow(P.alpha(q), s, shoulderLine)));
|
|
1588
|
+
if (activeSides.length === 0) {
|
|
1589
|
+
throw new PartsError([
|
|
1590
|
+
{
|
|
1591
|
+
code: 'PROPOSE_CLOTH_PART',
|
|
1592
|
+
object,
|
|
1593
|
+
detail: `names "${p0.name}" (${p0.from}, box y=${p0.y}..${p0.y + p0.h}), which has no art below the shoulders (y=${pyFixed(shoulderLine, 1)}) — no back hair tree link crosses "${p0.name}"`,
|
|
1594
|
+
},
|
|
1595
|
+
]);
|
|
1596
|
+
}
|
|
1597
|
+
const unionMask = newMask(P.W, P.H);
|
|
1598
|
+
unionMask.data.set(m0.data);
|
|
1599
|
+
for (let idx = 1; idx < list.length; idx++) {
|
|
1600
|
+
const mq = P.alpha(list[idx]);
|
|
1601
|
+
for (const [s] of activeSides) {
|
|
1602
|
+
for (let y = 0; y < P.H; y++) {
|
|
1603
|
+
let p0HasRow = false;
|
|
1604
|
+
for (let x = 0; x < P.W; x++) {
|
|
1605
|
+
if ((s === 'r' ? x < axis : x >= axis) && m0.data[y * P.W + x] !== 0) {
|
|
1606
|
+
p0HasRow = true;
|
|
1607
|
+
break;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
if (p0HasRow) continue;
|
|
1611
|
+
for (let x = 0; x < P.W; x++) {
|
|
1612
|
+
if ((s === 'r' ? x < axis : x >= axis) && mq.data[y * P.W + x] !== 0) {
|
|
1613
|
+
unionMask.data[y * P.W + x] = 1;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
const uRows = rowsAny(unionMask);
|
|
1620
|
+
const top = uRows.length > 0 ? uRows[0] : p0.y;
|
|
1621
|
+
const bot = uRows.length > 0 ? uRows[uRows.length - 1] + 1 : p0.y + p0.h;
|
|
1622
|
+
const [g, r] = gridR(p0);
|
|
1623
|
+
const dilated = dilate(unionMask, 31);
|
|
1624
|
+
const y0 = pyRound(eyeY);
|
|
1625
|
+
const sideOpaqueWidth = (s: 'r' | 'l'): number => {
|
|
1626
|
+
if (y0 < 0 || y0 >= unionMask.height) return 0;
|
|
1627
|
+
let w = 0;
|
|
1628
|
+
for (let x = 0; x < unionMask.width; x++) {
|
|
1629
|
+
if ((s === 'r' ? x < axis : x >= axis) && unionMask.data[y0 * unionMask.width + x]) w++;
|
|
1630
|
+
}
|
|
1631
|
+
return w;
|
|
1632
|
+
};
|
|
1633
|
+
const prefixOf = (s: 'r' | 'l'): string => `hairback_${s}`;
|
|
1634
|
+
const offRootsAt = (cnt: number, s: 'r' | 'l'): Array<{ bone: string; at: Point; alpha: number }> => {
|
|
1635
|
+
const pref = prefixOf(s);
|
|
1636
|
+
const im = P.images.get(p0.name) as Raster;
|
|
1637
|
+
const off: Array<{ bone: string; at: Point; alpha: number }> = [];
|
|
1638
|
+
for (let k = 0; k < cnt; k++) {
|
|
1639
|
+
const fc = (k + 0.5) / cnt;
|
|
1640
|
+
const rx = pyRound(bandX(unionMask, eyeY, { frac: fc, side: s, axis }) ?? axis);
|
|
1641
|
+
if (!isOn(dilated, rx, y0)) {
|
|
1642
|
+
const lx = rx - p0.x;
|
|
1643
|
+
const ly = y0 - p0.y;
|
|
1644
|
+
const alpha = lx >= 0 && lx < p0.w && ly >= 0 && ly < p0.h ? im.data[(ly * p0.w + lx) * 4 + 3] : 0;
|
|
1645
|
+
off.push({ bone: `${pref}${k}_0`, at: [rx, y0], alpha });
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
return off;
|
|
1649
|
+
};
|
|
1650
|
+
const sideWidths = activeSides.map(([s]) => sideOpaqueWidth(s));
|
|
1651
|
+
const widthCap = Math.max(1, Math.min(...sideWidths.map((w) => Math.floor(w / g))));
|
|
1652
|
+
let maxFit = 1;
|
|
1653
|
+
for (let cnt = widthCap; cnt >= 1; cnt--) {
|
|
1654
|
+
if (activeSides.every(([s]) => offRootsAt(cnt, s).length === 0)) {
|
|
1655
|
+
maxFit = cnt;
|
|
1656
|
+
break;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
if (rawRcp.chains !== undefined) {
|
|
1660
|
+
const offRoots = activeSides.flatMap(([s]) => offRootsAt(rawRcp.chains as number, s));
|
|
1661
|
+
if (rawRcp.chains > widthCap || offRoots.length > 0) {
|
|
1662
|
+
const wR = sideOpaqueWidth('r');
|
|
1663
|
+
const wL = sideOpaqueWidth('l');
|
|
1664
|
+
throw new PartsError([
|
|
1665
|
+
{
|
|
1666
|
+
code: 'PROPOSE_CLOTH_BACK_HAIR_CHAINS',
|
|
1667
|
+
object: 'config.cloth."back hair".chains',
|
|
1668
|
+
detail:
|
|
1669
|
+
`declares ${rawRcp.chains} tree(s) per side on "${p0.name}", and at the root row y=${y0} the tail is ${wR} px wide on the r side and ${wL} px on the l side on a ${g} px mesh grid (at most ${maxFit} tree(s) per side fit inside the art` +
|
|
1670
|
+
(offRoots.length > 0 ? `; off the art: ${offRoots.map((o) => `${o.bone} at [${o.at[0]}, ${o.at[1]}] (alpha ${o.alpha})`).join(', ')}` : '') +
|
|
1671
|
+
`) — set chains to at most ${maxFit}, or leave chains out to derive it from the tail's width`,
|
|
1672
|
+
},
|
|
1673
|
+
]);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
|
|
1677
|
+
const all: TreeChain[] = [];
|
|
1678
|
+
let n = 0;
|
|
1679
|
+
const partNamesLabel = list.map((q) => q.name).join(' -> ');
|
|
1680
|
+
for (const [side] of activeSides) {
|
|
1681
|
+
const prefix = prefixOf(side);
|
|
1682
|
+
const trees = T(list, 'back hair', rcp, {
|
|
1683
|
+
prefix,
|
|
1684
|
+
parent: 'head',
|
|
1685
|
+
rootY: eyeY,
|
|
1686
|
+
tipY: bot - 4,
|
|
1687
|
+
xAt: (y, f) => bandX(unionMask, y, { frac: f, side, axis }),
|
|
1688
|
+
fallbackX: () => axis,
|
|
1689
|
+
frame: `${partNamesLabel}'s art on the ${side} side of the eye axis (no art there in the band: the eye axis x)`,
|
|
1690
|
+
edges: side,
|
|
1691
|
+
});
|
|
1692
|
+
const made = treeConstraints(trees, rcp, null);
|
|
1693
|
+
clothConstraints.push(...made);
|
|
1694
|
+
n += made.length;
|
|
1695
|
+
all.push(...trees);
|
|
1696
|
+
}
|
|
1697
|
+
const spans: Array<TailSpan & { depth: number }> = [{ bone: 'head', a: rnd(head), b: rnd([axis, top + 13]), link: false, depth: -1 }];
|
|
1698
|
+
for (const t of all) {
|
|
1699
|
+
const line = [...t.points, t.tip];
|
|
1700
|
+
t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true, depth: t.depths[i] }));
|
|
1701
|
+
}
|
|
1702
|
+
let prevMinD = -1;
|
|
1703
|
+
let prevMaxD = -1;
|
|
1704
|
+
const linkSummary: string[] = [];
|
|
1705
|
+
for (let idx = 0; idx < list.length; idx++) {
|
|
1706
|
+
const q = list[idx];
|
|
1707
|
+
const mq = P.alpha(q);
|
|
1708
|
+
const crossedLinks = spans.filter((sp) => sp.link && spanCrosses(sp, mq) > 0);
|
|
1709
|
+
if (crossedLinks.length === 0) {
|
|
1710
|
+
const linkDesc = spans
|
|
1711
|
+
.filter((sp) => sp.link)
|
|
1712
|
+
.map((sp) => `${sp.bone} [${sp.a[0]}, ${sp.a[1]}]->[${sp.b[0]}, ${sp.b[1]}]`)
|
|
1713
|
+
.join(', ');
|
|
1714
|
+
throw new PartsError([
|
|
1715
|
+
{
|
|
1716
|
+
code: 'PROPOSE_CLOTH_PART',
|
|
1717
|
+
object,
|
|
1718
|
+
detail: `names "${q.name}" (${q.from}, box x=${q.x}..${q.x + q.w}, y=${q.y}..${q.y + q.h}), which no link of the drafted tree crosses (${linkDesc}: 0 px on "${q.name}") — every part in ${field} must be crossed by at least one link of the tree`,
|
|
1719
|
+
},
|
|
1720
|
+
]);
|
|
1721
|
+
}
|
|
1722
|
+
const minD = Math.min(...crossedLinks.map((sp) => sp.depth));
|
|
1723
|
+
const maxD = Math.max(...crossedLinks.map((sp) => sp.depth));
|
|
1724
|
+
if (idx > 0 && (minD < prevMinD || maxD < prevMaxD)) {
|
|
1725
|
+
throw new PartsError([
|
|
1726
|
+
{
|
|
1727
|
+
code: 'PROPOSE_CLOTH_PART',
|
|
1728
|
+
object,
|
|
1729
|
+
detail: `names "${q.name}" after "${list[idx - 1].name}", and "${q.name}" is crossed at link depth ${minD}..${maxD} while "${list[idx - 1].name}" is crossed at depth ${prevMinD}..${prevMaxD} — list parts in order from root to tip`,
|
|
1730
|
+
},
|
|
1731
|
+
]);
|
|
1732
|
+
}
|
|
1733
|
+
prevMinD = minD;
|
|
1734
|
+
prevMaxD = maxD;
|
|
1735
|
+
const [gq, rq] = q === p0 ? [g, r] : gridR(q);
|
|
1736
|
+
const qSegs: Segment[] = [];
|
|
1737
|
+
const crossedDesc: string[] = [];
|
|
1738
|
+
for (const sp of spans) {
|
|
1739
|
+
const px = spanCrosses(sp, mq);
|
|
1740
|
+
if (px > 0) {
|
|
1741
|
+
qSegs.push(sp.link ? sp.bone : [sp.bone, sp.a, sp.b]);
|
|
1742
|
+
if (sp.link) crossedDesc.push(`${sp.bone} (${px} px)`);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
linkSummary.push(`${q.name}: ${crossedDesc.join(', ')}`);
|
|
1746
|
+
delete regions[q.name];
|
|
1747
|
+
meshes[q.name] = { grid: gq, r: rcp.split?.hold_tip === true ? Math.max(rq, gq) : rq, segments: qSegs };
|
|
1748
|
+
multiPartClaimed.add(q.name);
|
|
1749
|
+
}
|
|
1750
|
+
notes.push(
|
|
1751
|
+
clothNote(
|
|
1752
|
+
partNamesLabel,
|
|
1753
|
+
'back hair',
|
|
1754
|
+
all,
|
|
1755
|
+
rcp,
|
|
1756
|
+
n,
|
|
1757
|
+
` across ${list.length} parts in root-to-tip order (${linkSummary.join('; ')}), from head, rooted on the eye line and tipped 4 px above ${list[list.length - 1].name}'s bottom`,
|
|
1758
|
+
),
|
|
1759
|
+
);
|
|
1760
|
+
tails.push({ part: p0.name, mask: unionMask, spans });
|
|
1761
|
+
}
|
|
1519
1762
|
for (const p of P.headFirst('back hair')) {
|
|
1763
|
+
if (multiPartClaimed.has(p.name)) continue;
|
|
1520
1764
|
const [g, r] = gridR(p);
|
|
1521
1765
|
const top = p.y;
|
|
1522
1766
|
const bot = p.y + p.h;
|
|
@@ -1531,16 +1775,72 @@ export function proposeWithBasis(
|
|
|
1531
1775
|
);
|
|
1532
1776
|
meshes[p.name] = { grid: g, r, segments: [['head', rnd(head), rnd([axis, top + 13])], 'bun'] };
|
|
1533
1777
|
tracks.push({ bone: 'bun', prop: 'rotate', amp: 0.8, period: 4.0, phase: 0.2 });
|
|
1534
|
-
} else if (recipeOf('back hair') !== null) {
|
|
1778
|
+
} else if (recipeOf('back hair') !== null && (backHairParts === null || backHairSingleSame)) {
|
|
1535
1779
|
// Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
|
|
1536
1780
|
// 4 px above the part's bottom, in place of the two keyed chains.
|
|
1537
|
-
|
|
1781
|
+
// Issue #390: the tree count per side derives from the tail's opaque width at the root row (`floor(width / grid)`
|
|
1782
|
+
// on each side, and every tree root inside the 15 px dilated art); a declared count that does not fit is refused
|
|
1783
|
+
// by name (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`).
|
|
1784
|
+
const rawRcp = recipeOf('back hair') as ClothRecipe;
|
|
1538
1785
|
const mask = P.alpha(p);
|
|
1786
|
+
const dilated = dilate(mask, 31);
|
|
1787
|
+
const y0 = pyRound(eyeY);
|
|
1788
|
+
const sideOpaqueWidth = (s: 'r' | 'l'): number => {
|
|
1789
|
+
if (y0 < 0 || y0 >= mask.height) return 0;
|
|
1790
|
+
let w = 0;
|
|
1791
|
+
for (let x = 0; x < mask.width; x++) {
|
|
1792
|
+
if ((s === 'r' ? x < axis : x >= axis) && mask.data[y0 * mask.width + x]) w++;
|
|
1793
|
+
}
|
|
1794
|
+
return w;
|
|
1795
|
+
};
|
|
1796
|
+
const prefixOf = (s: 'r' | 'l'): string => (P.headFirst('back hair').length === 1 ? `hairback_${s}` : `hairback_${p.name}_${s}`);
|
|
1797
|
+
const offRootsAt = (cnt: number, s: 'r' | 'l'): Array<{ bone: string; at: Point; alpha: number }> => {
|
|
1798
|
+
const pref = prefixOf(s);
|
|
1799
|
+
const im = P.images.get(p.name) as Raster;
|
|
1800
|
+
const off: Array<{ bone: string; at: Point; alpha: number }> = [];
|
|
1801
|
+
for (let k = 0; k < cnt; k++) {
|
|
1802
|
+
const fc = (k + 0.5) / cnt;
|
|
1803
|
+
const rx = pyRound(bandX(mask, eyeY, { frac: fc, side: s, axis }) ?? axis);
|
|
1804
|
+
if (!isOn(dilated, rx, y0)) {
|
|
1805
|
+
const lx = rx - p.x;
|
|
1806
|
+
const ly = y0 - p.y;
|
|
1807
|
+
const alpha = lx >= 0 && lx < p.w && ly >= 0 && ly < p.h ? im.data[(ly * p.w + lx) * 4 + 3] : 0;
|
|
1808
|
+
off.push({ bone: `${pref}${k}_0`, at: [rx, y0], alpha });
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
return off;
|
|
1812
|
+
};
|
|
1813
|
+
const wR = sideOpaqueWidth('r');
|
|
1814
|
+
const wL = sideOpaqueWidth('l');
|
|
1815
|
+
const widthCap = Math.max(1, Math.min(Math.floor(wR / g), Math.floor(wL / g)));
|
|
1816
|
+
let maxFit = 1;
|
|
1817
|
+
for (let cnt = widthCap; cnt >= 1; cnt--) {
|
|
1818
|
+
if (offRootsAt(cnt, 'r').length === 0 && offRootsAt(cnt, 'l').length === 0) {
|
|
1819
|
+
maxFit = cnt;
|
|
1820
|
+
break;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
if (rawRcp.chains !== undefined) {
|
|
1824
|
+
const offRoots = [...offRootsAt(rawRcp.chains, 'r'), ...offRootsAt(rawRcp.chains, 'l')];
|
|
1825
|
+
if (rawRcp.chains > widthCap || offRoots.length > 0) {
|
|
1826
|
+
throw new PartsError([
|
|
1827
|
+
{
|
|
1828
|
+
code: 'PROPOSE_CLOTH_BACK_HAIR_CHAINS',
|
|
1829
|
+
object: 'config.cloth."back hair".chains',
|
|
1830
|
+
detail:
|
|
1831
|
+
`declares ${rawRcp.chains} tree(s) per side on "${p.name}", and at the root row y=${y0} the tail is ${wR} px wide on the r side and ${wL} px on the l side on a ${g} px mesh grid (at most ${maxFit} tree(s) per side fit inside the art` +
|
|
1832
|
+
(offRoots.length > 0 ? `; off the art: ${offRoots.map((o) => `${o.bone} at [${o.at[0]}, ${o.at[1]}] (alpha ${o.alpha})`).join(', ')}` : '') +
|
|
1833
|
+
`) — set chains to at most ${maxFit}, or leave chains out to derive it from the tail's width`,
|
|
1834
|
+
},
|
|
1835
|
+
]);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
|
|
1539
1839
|
const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
|
|
1540
1840
|
const all: TreeChain[] = [];
|
|
1541
1841
|
let n = 0;
|
|
1542
1842
|
for (const [side] of SIDES) {
|
|
1543
|
-
const prefix =
|
|
1843
|
+
const prefix = prefixOf(side);
|
|
1544
1844
|
const trees = T(p, 'back hair', rcp, {
|
|
1545
1845
|
prefix,
|
|
1546
1846
|
parent: 'head',
|
|
@@ -1599,6 +1899,7 @@ export function proposeWithBasis(
|
|
|
1599
1899
|
const turned = 'none' in roll ? 'on the page' : `along the head's down axis (the eye line's perpendicular, roll ${pyFixed(roll.degrees, 3)} degrees)`;
|
|
1600
1900
|
const rollBones = 'none' in roll ? [] : ['eye_r', 'eye_l'];
|
|
1601
1901
|
for (const p of P.headFirst('front hair')) {
|
|
1902
|
+
if (multiPartClaimed.has(p.name)) continue;
|
|
1602
1903
|
const [g, r] = gridR(p);
|
|
1603
1904
|
const mask = P.alpha(p);
|
|
1604
1905
|
const top = p.y;
|
|
@@ -1744,7 +2045,7 @@ export function proposeWithBasis(
|
|
|
1744
2045
|
// Every part with the tag (a plan may take both runs' layers): the biggest
|
|
1745
2046
|
// gets the bone and chain, the rest ride the head rigidly. A stable sort,
|
|
1746
2047
|
// as Python's `sorted` is.
|
|
1747
|
-
const cands = [...P.byTag(tag)].sort((a, b) => b.opaque_px - a.opaque_px);
|
|
2048
|
+
const cands = [...P.byTag(tag)].filter((q) => !multiPartClaimed.has(q.name)).sort((a, b) => b.opaque_px - a.opaque_px);
|
|
1748
2049
|
// Issue #391: a layer of the tag lying on a tail the back hair recipe drafted rides the links it overlaps — the
|
|
1749
2050
|
// spans of the tail its pixels over the tail's art fall nearest — and not head: its mesh names those bones and no
|
|
1750
2051
|
// others, so every vertex weights by the rule the tail's own art does. The links are read off the overlap, never
|
|
@@ -2112,30 +2413,65 @@ export function proposeWithBasis(
|
|
|
2112
2413
|
|
|
2113
2414
|
// Issue #402: the topwear part config.cloth.bottomwear.part names takes the skirt's rule and recipe below; every
|
|
2114
2415
|
// other topwear shaped like a skirt is named in a note when a recipe is in play, never drafted on by its shape.
|
|
2416
|
+
const bottomwearParts = declaredPartsOf('bottomwear');
|
|
2417
|
+
const bottomwearSingleSame =
|
|
2418
|
+
bottomwearParts !== null &&
|
|
2419
|
+
bottomwearParts.list.length === 1 &&
|
|
2420
|
+
bw.length === 1 &&
|
|
2421
|
+
bottomwearParts.list[0] === bw[0];
|
|
2115
2422
|
const namedSkirt = ((): PartRecord | null => {
|
|
2116
|
-
const
|
|
2117
|
-
if (
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2423
|
+
const rawPart = recipeOf('bottomwear')?.part;
|
|
2424
|
+
if (typeof rawPart === 'string') {
|
|
2425
|
+
const name = rawPart;
|
|
2426
|
+
const refuse = (detail: string): never => {
|
|
2427
|
+
throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object: 'config.cloth."bottomwear".part', detail }]);
|
|
2428
|
+
};
|
|
2429
|
+
const q = P.recs.find((x) => x.name === name);
|
|
2430
|
+
if (q === undefined) return refuse(`names "${name}", which is no part in parts.json; a topwear part or its cut skirt piece whose art hangs below the hip (a dress) is required`);
|
|
2431
|
+
// An explicit name may select a topwear cut piece; automatic tag rules still exclude pieces.
|
|
2432
|
+
const [run, tag] = splitFrom(q.from);
|
|
2433
|
+
if (run === PAINTING_RUN || tag !== 'topwear') {
|
|
2434
|
+
return refuse(
|
|
2435
|
+
`names "${name}", from ${q.from}; ${splitFrom(q.from)[1] === 'bottomwear' ? 'a bottomwear part takes the recipe by its tag already — leave part out' : "only a topwear part takes the skirt's recipe by name (a skirt See-through returned inside topwear, a dress)"}`,
|
|
2436
|
+
);
|
|
2437
|
+
}
|
|
2438
|
+
const line = hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh;
|
|
2439
|
+
if (q.y + q.h < line) {
|
|
2440
|
+
return refuse(`names "${name}", whose box ends at y=${q.y + q.h}, above hip + ${TOPWEAR_HANGS_FACE_HEIGHTS} face heights (y=${pyFixed(line, 1)}), where the topwear rule reads a bodice that stops at the waist: no skirt hangs below the hip to hang trees on`);
|
|
2441
|
+
}
|
|
2442
|
+
return q;
|
|
2128
2443
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2444
|
+
if (bottomwearParts !== null && bottomwearParts.list.length === 1 && !bottomwearSingleSame) {
|
|
2445
|
+
const q = bottomwearParts.list[0];
|
|
2446
|
+
const object = `config.cloth."bottomwear".${bottomwearParts.field}`;
|
|
2447
|
+
const [run, tag] = splitFrom(q.from);
|
|
2448
|
+
if (run === PAINTING_RUN || tag !== 'topwear') {
|
|
2449
|
+
throw new PartsError([
|
|
2450
|
+
{
|
|
2451
|
+
code: 'PROPOSE_CLOTH_PART',
|
|
2452
|
+
object,
|
|
2453
|
+
detail: `names "${q.name}" (${q.from}, box x=${q.x}..${q.x + q.w}, y=${q.y}..${q.y + q.h}), which no link of the skirt's trees crosses`,
|
|
2454
|
+
},
|
|
2455
|
+
]);
|
|
2456
|
+
}
|
|
2457
|
+
const line = hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh;
|
|
2458
|
+
if (q.y + q.h < line) {
|
|
2459
|
+
throw new PartsError([
|
|
2460
|
+
{
|
|
2461
|
+
code: 'PROPOSE_CLOTH_PART',
|
|
2462
|
+
object,
|
|
2463
|
+
detail: `names "${q.name}", whose box ends at y=${q.y + q.h}, above hip + ${TOPWEAR_HANGS_FACE_HEIGHTS} face heights (y=${pyFixed(line, 1)}), where the topwear rule reads a bodice that stops at the waist: no skirt hangs below the hip to hang trees on`,
|
|
2464
|
+
},
|
|
2465
|
+
]);
|
|
2466
|
+
}
|
|
2467
|
+
return q;
|
|
2132
2468
|
}
|
|
2133
|
-
return
|
|
2469
|
+
return null;
|
|
2134
2470
|
})();
|
|
2135
2471
|
const skirtHints = new Map<string, string>();
|
|
2136
2472
|
if (cloth !== null) {
|
|
2137
2473
|
for (const p of tw) {
|
|
2138
|
-
if (p === namedSkirt) continue;
|
|
2474
|
+
if (p === namedSkirt || multiPartClaimed.has(p.name)) continue;
|
|
2139
2475
|
const hint = skirtInsideNote(p, P.alpha(p), hip[1], fh);
|
|
2140
2476
|
if (hint !== null) skirtHints.set(p.name, hint);
|
|
2141
2477
|
}
|
|
@@ -2143,7 +2479,7 @@ export function proposeWithBasis(
|
|
|
2143
2479
|
|
|
2144
2480
|
// ---- outer robe (topwear)
|
|
2145
2481
|
for (const p of tw) {
|
|
2146
|
-
if (p === namedSkirt) continue;
|
|
2482
|
+
if (p === namedSkirt || multiPartClaimed.has(p.name)) continue;
|
|
2147
2483
|
const hint = skirtHints.get(p.name);
|
|
2148
2484
|
if (hint !== undefined) notes.push(hint);
|
|
2149
2485
|
const [g, r] = gridR(p);
|
|
@@ -2161,8 +2497,8 @@ export function proposeWithBasis(
|
|
|
2161
2497
|
['chest', rnd(along(hip, trunk.u, artExtent(mask, p, hip, trunk.u)[0])), rnd(along(hip, trunk.u, -20))],
|
|
2162
2498
|
['hip', rnd(along(hip, trunk.u, -20)), rnd(along(hip, trunk.u, 100))],
|
|
2163
2499
|
];
|
|
2164
|
-
if (bw.length > 0 && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
|
|
2165
|
-
// A bodice
|
|
2500
|
+
if ((bw.length > 0 || namedSkirt?.cut_from === p.name) && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
|
|
2501
|
+
// A bodice above bottomwear, including its explicitly named cut skirt: robe chains would fall off the art.
|
|
2166
2502
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
2167
2503
|
notes.push(`${p.name} ends at y=${bot} (hip ${pyFixed(hip[1], 0)}): no robe chains -- add a sash chain by hand if one hangs`);
|
|
2168
2504
|
continue;
|
|
@@ -2269,16 +2605,145 @@ export function proposeWithBasis(
|
|
|
2269
2605
|
return { o, rides: tail.spans.some((sp, k) => sp.link && o.counts[k] > 0) };
|
|
2270
2606
|
};
|
|
2271
2607
|
const dressRecipe = recipeOf('bottomwear');
|
|
2608
|
+
if (bottomwearParts !== null && bottomwearParts.list.length >= 2 && dressRecipe !== null) {
|
|
2609
|
+
const { field, list } = bottomwearParts;
|
|
2610
|
+
const object = `config.cloth."bottomwear".${field}`;
|
|
2611
|
+
const p0 = list[0];
|
|
2612
|
+
const m0 = P.alpha(p0);
|
|
2613
|
+
const y0 = hip[1] + 20;
|
|
2614
|
+
const unionMask = newMask(P.W, P.H);
|
|
2615
|
+
unionMask.data.set(m0.data);
|
|
2616
|
+
for (let idx = 1; idx < list.length; idx++) {
|
|
2617
|
+
const mq = P.alpha(list[idx]);
|
|
2618
|
+
for (let y = p0.y; y < P.H; y++) {
|
|
2619
|
+
let p0HasRow = false;
|
|
2620
|
+
for (let x = 0; x < P.W; x++) {
|
|
2621
|
+
if (m0.data[y * P.W + x] !== 0) {
|
|
2622
|
+
p0HasRow = true;
|
|
2623
|
+
break;
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
if (p0HasRow) continue;
|
|
2627
|
+
for (let x = 0; x < P.W; x++) {
|
|
2628
|
+
if (mq.data[y * P.W + x] !== 0) unionMask.data[y * P.W + x] = 1;
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
const uRows = rowsAny(unionMask);
|
|
2633
|
+
const bot = uRows.length > 0 ? uRows[uRows.length - 1] + 1 : p0.y + p0.h;
|
|
2634
|
+
if (bot - 4 <= y0) {
|
|
2635
|
+
throw new PartsError([
|
|
2636
|
+
{
|
|
2637
|
+
code: 'PROPOSE_CLOTH_PART',
|
|
2638
|
+
object,
|
|
2639
|
+
detail: `names "${p0.name}" (${p0.from}, box y=${p0.y}..${p0.y + p0.h}), which ends above the skirt root row (y=${pyFixed(y0, 1)}) — no skirt tree link crosses "${p0.name}"`,
|
|
2640
|
+
},
|
|
2641
|
+
]);
|
|
2642
|
+
}
|
|
2643
|
+
const [g, r] = gridR(p0);
|
|
2644
|
+
const { segs: fixedSegs, hangs } = skirtFrame(p0, m0);
|
|
2645
|
+
if (hangs !== null) notes.push(hangs);
|
|
2646
|
+
const prefix = 'skirt';
|
|
2647
|
+
const partNamesLabel = list.map((q) => q.name).join(' -> ');
|
|
2648
|
+
const trees = T(list, 'bottomwear', dressRecipe, {
|
|
2649
|
+
...skirtInput(p0, unionMask, prefix),
|
|
2650
|
+
tipY: bot - 4,
|
|
2651
|
+
frame: `${partNamesLabel}'s art (no art in the band: that share of ${p0.name}'s box width)`,
|
|
2652
|
+
});
|
|
2653
|
+
let swing: { bone: string; rest: number } | null = null;
|
|
2654
|
+
if (dressRecipe.swing !== undefined) {
|
|
2655
|
+
const cx = or(bandX(unionMask, y0, { frac: 0.5 }), p0.x + 0.5 * p0.w);
|
|
2656
|
+
const first = trees[0].points.length > 1 ? trees[0].points[1][1] : trees[0].tip[1];
|
|
2657
|
+
const at: [number, number] = [cx, y0];
|
|
2658
|
+
const tipAt: [number, number] = [cx, first];
|
|
2659
|
+
B(
|
|
2660
|
+
swingName(prefix),
|
|
2661
|
+
'hip',
|
|
2662
|
+
at,
|
|
2663
|
+
placement('mask', `the centre of ${partNamesLabel}'s art in the 24-row band at the root row (20 px below hip's y)`, { parts: list.map((q) => q.name), bones: ['hip'], constants: [konst('share_across', 0.5)] }),
|
|
2664
|
+
tipAt,
|
|
2665
|
+
placement('derived', `straight down from the swing's origin to the row of the first tree's second link (one link long)`, { bones: [`${trees[0].chain}0`] }),
|
|
2666
|
+
);
|
|
2667
|
+
swing = { bone: swingName(prefix), rest: roundTo((Math.atan2(-(rnd(tipAt)[1] - rnd(at)[1]), rnd(tipAt)[0] - rnd(at)[0]) * 180) / Math.PI, 6) };
|
|
2668
|
+
}
|
|
2669
|
+
const made = treeConstraints(trees, dressRecipe, swing);
|
|
2670
|
+
clothConstraints.push(...made);
|
|
2671
|
+
const spans: Array<TailSpan & { depth: number }> = [];
|
|
2672
|
+
for (const s of fixedSegs) if (typeof s !== 'string') spans.push({ bone: s[0], a: s[1], b: s[2], link: false, depth: -1 });
|
|
2673
|
+
for (const t of trees) {
|
|
2674
|
+
const line = [...t.points, t.tip];
|
|
2675
|
+
t.points.forEach((q, i) => spans.push({ bone: `${t.chain}${i}`, a: q, b: line[i + 1], link: true, depth: t.depths[i] }));
|
|
2676
|
+
}
|
|
2677
|
+
let prevMinD = -1;
|
|
2678
|
+
let prevMaxD = -1;
|
|
2679
|
+
const linkSummary: string[] = [];
|
|
2680
|
+
for (let idx = 0; idx < list.length; idx++) {
|
|
2681
|
+
const q = list[idx];
|
|
2682
|
+
const mq = P.alpha(q);
|
|
2683
|
+
const crossedLinks = spans.filter((sp) => sp.link && spanCrosses(sp, mq) > 0);
|
|
2684
|
+
if (crossedLinks.length === 0) {
|
|
2685
|
+
const linkDesc = spans
|
|
2686
|
+
.filter((sp) => sp.link)
|
|
2687
|
+
.map((sp) => `${sp.bone} [${sp.a[0]}, ${sp.a[1]}]->[${sp.b[0]}, ${sp.b[1]}]`)
|
|
2688
|
+
.join(', ');
|
|
2689
|
+
throw new PartsError([
|
|
2690
|
+
{
|
|
2691
|
+
code: 'PROPOSE_CLOTH_PART',
|
|
2692
|
+
object,
|
|
2693
|
+
detail: `names "${q.name}" (${q.from}, box x=${q.x}..${q.x + q.w}, y=${q.y}..${q.y + q.h}), which no link of the drafted tree crosses (${linkDesc}: 0 px on "${q.name}") — every part in ${field} must be crossed by at least one link of the tree`,
|
|
2694
|
+
},
|
|
2695
|
+
]);
|
|
2696
|
+
}
|
|
2697
|
+
const minD = Math.min(...crossedLinks.map((sp) => sp.depth));
|
|
2698
|
+
const maxD = Math.max(...crossedLinks.map((sp) => sp.depth));
|
|
2699
|
+
if (idx > 0 && (minD < prevMinD || maxD < prevMaxD)) {
|
|
2700
|
+
throw new PartsError([
|
|
2701
|
+
{
|
|
2702
|
+
code: 'PROPOSE_CLOTH_PART',
|
|
2703
|
+
object,
|
|
2704
|
+
detail: `names "${q.name}" after "${list[idx - 1].name}", and "${q.name}" is crossed at link depth ${minD}..${maxD} while "${list[idx - 1].name}" is crossed at depth ${prevMinD}..${prevMaxD} — list parts in order from root to tip`,
|
|
2705
|
+
},
|
|
2706
|
+
]);
|
|
2707
|
+
}
|
|
2708
|
+
prevMinD = minD;
|
|
2709
|
+
prevMaxD = maxD;
|
|
2710
|
+
const [gq, rq] = q === p0 ? [g, r] : gridR(q);
|
|
2711
|
+
const qSegs: Segment[] = idx === 0 ? [...fixedSegs] : [];
|
|
2712
|
+
const crossedDesc: string[] = [];
|
|
2713
|
+
for (const sp of spans) {
|
|
2714
|
+
if (!sp.link) continue;
|
|
2715
|
+
const px = spanCrosses(sp, mq);
|
|
2716
|
+
if (px > 0) {
|
|
2717
|
+
qSegs.push(sp.bone);
|
|
2718
|
+
crossedDesc.push(`${sp.bone} (${px} px)`);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
linkSummary.push(`${q.name}: ${crossedDesc.join(', ')}`);
|
|
2722
|
+
delete regions[q.name];
|
|
2723
|
+
meshes[q.name] = { grid: gq, r: dressRecipe.split?.hold_tip === true ? Math.max(rq, gq) : rq, segments: qSegs };
|
|
2724
|
+
multiPartClaimed.add(q.name);
|
|
2725
|
+
}
|
|
2726
|
+
notes.push(
|
|
2727
|
+
clothNote(
|
|
2728
|
+
partNamesLabel,
|
|
2729
|
+
'bottomwear',
|
|
2730
|
+
trees,
|
|
2731
|
+
dressRecipe,
|
|
2732
|
+
made.length,
|
|
2733
|
+
`${swing === null ? '' : ` on one shared swing, ${swing.bone} (each tree's first link takes its rotation times ${dressRecipe.swing?.scales.join('/')}, its setup rotation ${swing.rest} degrees subtracted)`} across ${list.length} parts in root-to-tip order (${linkSummary.join('; ')}) from hip, rooted 20 px below its y and tipped 4 px above ${list[list.length - 1].name}'s bottom, laid down the screen`,
|
|
2734
|
+
),
|
|
2735
|
+
);
|
|
2736
|
+
}
|
|
2272
2737
|
const riders: PartRecord[] =
|
|
2273
2738
|
namedSkirt === null || dressRecipe === null
|
|
2274
2739
|
? []
|
|
2275
2740
|
: ((): PartRecord[] => {
|
|
2276
2741
|
const mask = P.alpha(namedSkirt);
|
|
2277
2742
|
const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
|
|
2278
|
-
return bw.filter((q) => overLinks(tail, q).rides);
|
|
2743
|
+
return bw.filter((q) => !multiPartClaimed.has(q.name) && overLinks(tail, q).rides);
|
|
2279
2744
|
})();
|
|
2280
2745
|
let dressDrafted: Tail | null = null;
|
|
2281
|
-
const skirts = namedSkirt === null ? bw : [...bw.filter((q) => !riders.includes(q)), namedSkirt];
|
|
2746
|
+
const skirts = (namedSkirt === null ? bw : [...bw.filter((q) => !riders.includes(q)), namedSkirt]).filter((q) => !multiPartClaimed.has(q.name));
|
|
2282
2747
|
for (const p of skirts) {
|
|
2283
2748
|
const [g, r] = gridR(p);
|
|
2284
2749
|
const mask = P.alpha(p);
|
|
@@ -2515,7 +2980,7 @@ export function proposeWithBasis(
|
|
|
2515
2980
|
}
|
|
2516
2981
|
const constrained = clothConstraints.length === 0 ? {} : { constraints: clothConstraints };
|
|
2517
2982
|
return {
|
|
2518
|
-
proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, notes },
|
|
2983
|
+
proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, proposed_rig_scale: P.rigScale, notes },
|
|
2519
2984
|
basis: { frame, bones: records },
|
|
2520
2985
|
};
|
|
2521
2986
|
}
|