@rig-c/parts 1.26.0 → 1.28.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 +9 -4
- package/docs/STABILITY.md +3 -2
- package/package.json +1 -1
- package/src/assemble.ts +14 -2
- package/src/check.ts +23 -5
- package/src/cloth.ts +29 -2
- package/src/config.ts +54 -4
- package/src/coords.ts +1 -1
- package/src/motion.ts +15 -5
- package/src/propose.ts +430 -27
- package/src/rig.ts +13 -2
- package/src/turn.ts +665 -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_BACK_HAIR_CHAINS, 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
|
// ---------------------------------------------------------------------------
|
|
@@ -1377,10 +1377,31 @@ export function proposeWithBasis(
|
|
|
1377
1377
|
// Issue #364: the trees a cloth recipe drafts, their constraints, and the tags it was used on.
|
|
1378
1378
|
const clothConstraints: ConfigConstraint[] = [];
|
|
1379
1379
|
const clothUsed = new Set<ClothTarget>();
|
|
1380
|
+
const multiPartClaimed = new Set<string>();
|
|
1380
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
|
+
};
|
|
1381
1401
|
// Issue #391: the tails the back hair recipe drafts, each with its mesh's spans, which an accessory wrapped round one rides.
|
|
1382
1402
|
const tails: Tail[] = [];
|
|
1383
|
-
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];
|
|
1384
1405
|
const trees = draftTrees(input, r);
|
|
1385
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))];
|
|
1386
1407
|
for (const t of trees) {
|
|
@@ -1389,8 +1410,8 @@ export function proposeWithBasis(
|
|
|
1389
1410
|
t.parent,
|
|
1390
1411
|
t.points,
|
|
1391
1412
|
t.tip,
|
|
1392
|
-
t.why.map((w) => placement('mask', w, { parts:
|
|
1393
|
-
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 }),
|
|
1394
1415
|
);
|
|
1395
1416
|
}
|
|
1396
1417
|
clothUsed.add(tag);
|
|
@@ -1521,7 +1542,225 @@ export function proposeWithBasis(
|
|
|
1521
1542
|
};
|
|
1522
1543
|
|
|
1523
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
|
+
}
|
|
1524
1762
|
for (const p of P.headFirst('back hair')) {
|
|
1763
|
+
if (multiPartClaimed.has(p.name)) continue;
|
|
1525
1764
|
const [g, r] = gridR(p);
|
|
1526
1765
|
const top = p.y;
|
|
1527
1766
|
const bot = p.y + p.h;
|
|
@@ -1536,7 +1775,7 @@ export function proposeWithBasis(
|
|
|
1536
1775
|
);
|
|
1537
1776
|
meshes[p.name] = { grid: g, r, segments: [['head', rnd(head), rnd([axis, top + 13])], 'bun'] };
|
|
1538
1777
|
tracks.push({ bone: 'bun', prop: 'rotate', amp: 0.8, period: 4.0, phase: 0.2 });
|
|
1539
|
-
} else if (recipeOf('back hair') !== null) {
|
|
1778
|
+
} else if (recipeOf('back hair') !== null && (backHairParts === null || backHairSingleSame)) {
|
|
1540
1779
|
// Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
|
|
1541
1780
|
// 4 px above the part's bottom, in place of the two keyed chains.
|
|
1542
1781
|
// Issue #390: the tree count per side derives from the tail's opaque width at the root row (`floor(width / grid)`
|
|
@@ -1660,6 +1899,7 @@ export function proposeWithBasis(
|
|
|
1660
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)`;
|
|
1661
1900
|
const rollBones = 'none' in roll ? [] : ['eye_r', 'eye_l'];
|
|
1662
1901
|
for (const p of P.headFirst('front hair')) {
|
|
1902
|
+
if (multiPartClaimed.has(p.name)) continue;
|
|
1663
1903
|
const [g, r] = gridR(p);
|
|
1664
1904
|
const mask = P.alpha(p);
|
|
1665
1905
|
const top = p.y;
|
|
@@ -1805,7 +2045,7 @@ export function proposeWithBasis(
|
|
|
1805
2045
|
// Every part with the tag (a plan may take both runs' layers): the biggest
|
|
1806
2046
|
// gets the bone and chain, the rest ride the head rigidly. A stable sort,
|
|
1807
2047
|
// as Python's `sorted` is.
|
|
1808
|
-
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);
|
|
1809
2049
|
// Issue #391: a layer of the tag lying on a tail the back hair recipe drafted rides the links it overlaps — the
|
|
1810
2050
|
// spans of the tail its pixels over the tail's art fall nearest — and not head: its mesh names those bones and no
|
|
1811
2051
|
// others, so every vertex weights by the rule the tail's own art does. The links are read off the overlap, never
|
|
@@ -2173,31 +2413,65 @@ export function proposeWithBasis(
|
|
|
2173
2413
|
|
|
2174
2414
|
// Issue #402: the topwear part config.cloth.bottomwear.part names takes the skirt's rule and recipe below; every
|
|
2175
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];
|
|
2176
2422
|
const namedSkirt = ((): PartRecord | null => {
|
|
2177
|
-
const
|
|
2178
|
-
if (
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
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;
|
|
2190
2443
|
}
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
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;
|
|
2194
2468
|
}
|
|
2195
|
-
return
|
|
2469
|
+
return null;
|
|
2196
2470
|
})();
|
|
2197
2471
|
const skirtHints = new Map<string, string>();
|
|
2198
2472
|
if (cloth !== null) {
|
|
2199
2473
|
for (const p of tw) {
|
|
2200
|
-
if (p === namedSkirt) continue;
|
|
2474
|
+
if (p === namedSkirt || multiPartClaimed.has(p.name)) continue;
|
|
2201
2475
|
const hint = skirtInsideNote(p, P.alpha(p), hip[1], fh);
|
|
2202
2476
|
if (hint !== null) skirtHints.set(p.name, hint);
|
|
2203
2477
|
}
|
|
@@ -2205,7 +2479,7 @@ export function proposeWithBasis(
|
|
|
2205
2479
|
|
|
2206
2480
|
// ---- outer robe (topwear)
|
|
2207
2481
|
for (const p of tw) {
|
|
2208
|
-
if (p === namedSkirt) continue;
|
|
2482
|
+
if (p === namedSkirt || multiPartClaimed.has(p.name)) continue;
|
|
2209
2483
|
const hint = skirtHints.get(p.name);
|
|
2210
2484
|
if (hint !== undefined) notes.push(hint);
|
|
2211
2485
|
const [g, r] = gridR(p);
|
|
@@ -2331,16 +2605,145 @@ export function proposeWithBasis(
|
|
|
2331
2605
|
return { o, rides: tail.spans.some((sp, k) => sp.link && o.counts[k] > 0) };
|
|
2332
2606
|
};
|
|
2333
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
|
+
}
|
|
2334
2737
|
const riders: PartRecord[] =
|
|
2335
2738
|
namedSkirt === null || dressRecipe === null
|
|
2336
2739
|
? []
|
|
2337
2740
|
: ((): PartRecord[] => {
|
|
2338
2741
|
const mask = P.alpha(namedSkirt);
|
|
2339
2742
|
const tail = dressTail(namedSkirt, skirtFrame(namedSkirt, mask).segs, draftTrees(skirtInput(namedSkirt, mask, 'skirt'), dressRecipe));
|
|
2340
|
-
return bw.filter((q) => overLinks(tail, q).rides);
|
|
2743
|
+
return bw.filter((q) => !multiPartClaimed.has(q.name) && overLinks(tail, q).rides);
|
|
2341
2744
|
})();
|
|
2342
2745
|
let dressDrafted: Tail | null = null;
|
|
2343
|
-
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));
|
|
2344
2747
|
for (const p of skirts) {
|
|
2345
2748
|
const [g, r] = gridR(p);
|
|
2346
2749
|
const mask = P.alpha(p);
|
package/src/rig.ts
CHANGED
|
@@ -165,13 +165,13 @@ import {
|
|
|
165
165
|
import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
|
|
166
166
|
import { type LocalInfluence, localInfluences } from './localweights.ts';
|
|
167
167
|
import { artCoverage, ART_ALPHA, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
|
|
168
|
-
import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
|
|
168
|
+
import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
|
|
169
169
|
import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
|
|
170
170
|
import { EYE_GROUP_TAGS } from './propose.ts';
|
|
171
171
|
import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
172
172
|
import { pyRound } from './round.ts';
|
|
173
173
|
import { boneHeat, type HeatDust, heatInfluences, HEAT_LIMITS, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
174
|
-
import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
174
|
+
import { applyFaceSurfaceDeform, TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
175
175
|
import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
|
|
176
176
|
|
|
177
177
|
/** Transparent pixels added round every part image — the reference's `PAD`. */
|
|
@@ -1996,6 +1996,17 @@ export function buildRig(
|
|
|
1996
1996
|
// issue #239: the face's line, written only when the face bones were turned to one, so no other rig moves.
|
|
1997
1997
|
const axes = [faceAxis.axis, faceAxis.tipAxis].flatMap((x) => (x === null ? [] : [x]));
|
|
1998
1998
|
if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
|
|
1999
|
+
const faceImg = outImages.find(([k]) => k === 'face.png')?.[1];
|
|
2000
|
+
const facePart = parts.parts.find((p) => p.name === 'face');
|
|
2001
|
+
bindFaceSurfaceDeform(motion, (merged) => {
|
|
2002
|
+
applyFaceSurfaceDeform(rig, merged, {
|
|
2003
|
+
imageWidth: faceImg?.width,
|
|
2004
|
+
imageHeight: faceImg?.height,
|
|
2005
|
+
opaqueWidth: facePart?.w,
|
|
2006
|
+
opaqueHeight: facePart?.h,
|
|
2007
|
+
overrides: cfg.turn?.deform,
|
|
2008
|
+
});
|
|
2009
|
+
});
|
|
1999
2010
|
return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis, turn: turned?.rows ?? null };
|
|
2000
2011
|
}
|
|
2001
2012
|
|