@rig-c/parts 1.6.0 → 1.7.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 +13 -8
- package/cli.ts +9 -4
- package/docs/AUTHORING.md +29 -10
- package/docs/STABILITY.md +1 -1
- package/package.json +2 -2
- package/src/build.ts +161 -10
- package/src/parts.ts +2 -2
- package/src/propose.ts +281 -10
- package/src/rig.ts +214 -11
- package/src/scene.ts +14 -1
package/src/propose.ts
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
* chains a second time. The one exception is the sleeve rule, which names
|
|
50
50
|
* its chains by side and takes a cut sleeve as a blob of its own (#199). A
|
|
51
51
|
* `parts.json` sharing a layer between parts with no `cut_from` (an
|
|
52
|
-
* assemble before 1.
|
|
52
|
+
* assemble before 1.6.0) is refused, `PROPOSE_CUT_FROM_RECORDED`.
|
|
53
53
|
*
|
|
54
54
|
* What it cannot know is written into `notes` when it guessed, and otherwise
|
|
55
55
|
* left to the person correcting: things hidden inside a layer (a sash tail
|
|
@@ -79,6 +79,14 @@
|
|
|
79
79
|
* never moved by a joint. `lint` then reads the torso and the sleeves by the
|
|
80
80
|
* relations the joints declare instead of by screen y. Without joints every
|
|
81
81
|
* value, note and LINT line is the one written before the option existed.
|
|
82
|
+
* One rule runs only with joints (issue #241): a `legwear` part holding the
|
|
83
|
+
* ankle of a leg whose hip, knee and ankle are given is meshed on a leg chain
|
|
84
|
+
* (hip -> knee, tip at the ankle; one per leg, or one through the midpoints
|
|
85
|
+
* when the part's art does not separate the legs — {@link legsFromJoints}),
|
|
86
|
+
* where without them it rides `hip` as a region. And with a joint at either
|
|
87
|
+
* end of the trunk (issue #243), the trunk parts' fixed segments run along
|
|
88
|
+
* neck -> hip, the line the chest is placed on, instead of down the screen
|
|
89
|
+
* ({@link trunkLineOf}).
|
|
82
90
|
*
|
|
83
91
|
* Issue #86 adds what the proposer and lint looked at, and changes nothing
|
|
84
92
|
* they wrote: {@link proposeWithBasis} records, at each placement, what the
|
|
@@ -259,7 +267,7 @@ export function proposalAssemble(P: PartSet): Record<string, unknown> {
|
|
|
259
267
|
// loader only type-checks like the patches' fields. The piece names its base
|
|
260
268
|
// (`cut_from`, issue #230); file order cannot, as a piece drawn "back" comes
|
|
261
269
|
// before its base. A file with an unmarked shared layer (an assemble before
|
|
262
|
-
// 1.
|
|
270
|
+
// 1.6.0, which propose refuses) goes back with the first part of the layer
|
|
263
271
|
// as the plan part, as it did before the field.
|
|
264
272
|
const run = P.recs.filter((r) => splitFrom(r.from)[0] !== PAINTING_RUN);
|
|
265
273
|
const painted = P.recs.filter((r) => splitFrom(r.from)[0] === PAINTING_RUN);
|
|
@@ -349,7 +357,7 @@ export class PartSet {
|
|
|
349
357
|
|
|
350
358
|
/**
|
|
351
359
|
* The layers more than one part holds with no `cut_from` to tell the piece
|
|
352
|
-
* from its base: a `parts.json` an `assemble` before 1.
|
|
360
|
+
* from its base: a `parts.json` an `assemble` before 1.6.0 wrote with
|
|
353
361
|
* `assemble.cuts`. parts.json is in draw order and a piece drawn `"back"`
|
|
354
362
|
* comes before its base, so which part takes the layer's rule cannot be
|
|
355
363
|
* read off the file. Each entry is the layer and its parts, in file order.
|
|
@@ -877,7 +885,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
877
885
|
P.unmarkedShares().map(({ from, parts }) => ({
|
|
878
886
|
code: 'PROPOSE_CUT_FROM_RECORDED',
|
|
879
887
|
object: `parts ${parts.map((n) => `"${n}"`).join(', ')} (${from})`,
|
|
880
|
-
detail: `share one layer and none of them records cut_from, so which is the plan part and which the piece an assemble.cuts entry took cannot be read off parts.json (it is in draw order, and a piece drawn "back" comes before its base); re-run assemble (1.
|
|
888
|
+
detail: `share one layer and none of them records cut_from, so which is the plan part and which the piece an assemble.cuts entry took cannot be read off parts.json (it is in draw order, and a piece drawn "back" comes before its base); re-run assemble (1.6.0+) to record cut_from`,
|
|
881
889
|
})),
|
|
882
890
|
);
|
|
883
891
|
const bones: BoneEntry[] = [];
|
|
@@ -996,6 +1004,13 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
996
1004
|
// down the screen, because the torso need not be upright.
|
|
997
1005
|
const chestAlong = neckJ !== null || hipFromJoints;
|
|
998
1006
|
const chest: [number, number] = chestAlong ? [(neckAt[0] + hip[0]) / 2, (neckAt[1] + hip[1]) / 2] : [hip[0], neckY + 0.5 * (hip[1] - neckY)];
|
|
1007
|
+
// Issue #243: on that same line, neck -> hip, the trunk parts' fixed segments are laid too (neck, topwear,
|
|
1008
|
+
// bottomwear; {@link trunkSegments}), not down the screen through the bones' x. Without a joint at either end,
|
|
1009
|
+
// today's verticals; with the two ends on one point the line has no direction, and a note says the verticals stay.
|
|
1010
|
+
const trunk = chestAlong ? trunkLineOf(neckAt, hip) : null;
|
|
1011
|
+
if (chestAlong && trunk === null) {
|
|
1012
|
+
notes.push(`trunk segments: neck and hip are one point, ${fmtAt(hip)}, so neck -> hip declares no line; the trunk parts' segments are drafted down the screen, as without keypoints`);
|
|
1013
|
+
}
|
|
999
1014
|
// Each placement hands its basis with its value: one record per bone, in the order the bones are written.
|
|
1000
1015
|
const B = (name: string, parent: string, at: readonly [number, number], why: Placement, tip?: readonly [number, number], tipWhy?: Placement): void => {
|
|
1001
1016
|
bones.push(tip === undefined ? { name, parent, at: rnd(at) } : { name, parent, at: rnd(at), tip: rnd(tip) });
|
|
@@ -1428,7 +1443,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1428
1443
|
grid: g,
|
|
1429
1444
|
r,
|
|
1430
1445
|
segments: [
|
|
1431
|
-
['chest', rnd([axis, neckY + 18]), rnd([axis, chest[1]])],
|
|
1446
|
+
trunk === null ? ['chest', rnd([axis, neckY + 18]), rnd([axis, chest[1]])] : ['chest', rnd(along(neckAt, trunk.u, 18)), rnd(chest)],
|
|
1432
1447
|
['head', rnd(head), rnd([axis, head[1] - 50])],
|
|
1433
1448
|
],
|
|
1434
1449
|
};
|
|
@@ -1574,10 +1589,17 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1574
1589
|
const mask = P.alpha(p);
|
|
1575
1590
|
const top = chest[1] - 0.3 * fh;
|
|
1576
1591
|
const bot = p.y + p.h;
|
|
1577
|
-
const segs: Segment[] =
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1592
|
+
const segs: Segment[] =
|
|
1593
|
+
trunk === null
|
|
1594
|
+
? [
|
|
1595
|
+
['chest', rnd([chest[0], p.y]), rnd([chest[0], hip[1] - 20])],
|
|
1596
|
+
['hip', rnd([chest[0], hip[1] - 20]), rnd([chest[0], hip[1] + 100])],
|
|
1597
|
+
]
|
|
1598
|
+
: [
|
|
1599
|
+
// The part's top read along the trunk: the nearest its art comes to the neck end of the line.
|
|
1600
|
+
['chest', rnd(along(hip, trunk.u, artExtent(mask, p, hip, trunk.u)[0])), rnd(along(hip, trunk.u, -20))],
|
|
1601
|
+
['hip', rnd(along(hip, trunk.u, -20)), rnd(along(hip, trunk.u, 100))],
|
|
1602
|
+
];
|
|
1581
1603
|
if (bw.length > 0 && bot < hip[1] + 0.25 * fh) {
|
|
1582
1604
|
// A bodice that stops at the waist: robe chains down to the hem would fall off the art.
|
|
1583
1605
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
@@ -1631,10 +1653,19 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1631
1653
|
const mask = P.alpha(p);
|
|
1632
1654
|
const bot = p.y + p.h;
|
|
1633
1655
|
const y0 = hip[1] + 20;
|
|
1634
|
-
|
|
1656
|
+
let segs: Segment[] = [
|
|
1635
1657
|
['chest', rnd([chest[0], chest[1] - 30]), rnd([chest[0], hip[1] - 20])],
|
|
1636
1658
|
['hip', rnd([p.x + 0.09 * p.w, hip[1] + 5]), rnd([p.x + 0.91 * p.w, hip[1] + 5])],
|
|
1637
1659
|
];
|
|
1660
|
+
if (trunk !== null) {
|
|
1661
|
+
// The waist line runs across the trunk, 5 px down it from the hip, over 0.09 to 0.91 of the art's width across it.
|
|
1662
|
+
const [s0, s1] = artExtent(mask, p, hip, trunk.v);
|
|
1663
|
+
const waist = along(hip, trunk.u, 5);
|
|
1664
|
+
segs = [
|
|
1665
|
+
['chest', rnd(along(chest, trunk.u, -30)), rnd(along(hip, trunk.u, -20))],
|
|
1666
|
+
['hip', rnd(along(waist, trunk.v, s0 + 0.09 * (s1 - s0))), rnd(along(waist, trunk.v, s0 + 0.91 * (s1 - s0)))],
|
|
1667
|
+
];
|
|
1668
|
+
}
|
|
1638
1669
|
for (const [tag, f, ph] of [
|
|
1639
1670
|
['skirt_r', 0.3, 0.3],
|
|
1640
1671
|
['skirt_c', 0.5, 0.34],
|
|
@@ -1658,6 +1689,41 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1658
1689
|
meshes[p.name] = { grid: g, r, segments: segs };
|
|
1659
1690
|
}
|
|
1660
1691
|
|
|
1692
|
+
// ---- legs (issue #241)
|
|
1693
|
+
// Only with joints: a legwear part whose art holds the ankle of a leg whose hip, knee and ankle are all given is
|
|
1694
|
+
// meshed on a leg chain hip -> knee, tip at the ankle; without them no line below runs, and the part stays the
|
|
1695
|
+
// region the trunk rule gives it, its note unchanged. No idle track is written for a leg chain: the chain is
|
|
1696
|
+
// drafted whatever the pose, and a standing figure's legs are planted — whether they move is the scene's.
|
|
1697
|
+
const lw = P.byTag('legwear', undefined, true).filter((p) => p.opaque_px > 500);
|
|
1698
|
+
const legPlan = joints !== undefined && lw.length > 0 ? legsFromJoints(P, lw, joints) : null;
|
|
1699
|
+
if (legPlan !== null && legPlan.chains !== null) {
|
|
1700
|
+
for (const c of legPlan.chains) {
|
|
1701
|
+
const J = (joints as RigJoints).joints;
|
|
1702
|
+
const used = (n: KeypointName): Placement => placement('joint', `the ${n} joint, ${usedAs(J[n])}`, { joints: [jointRef(n)] });
|
|
1703
|
+
const mid = (what: string, a: KeypointName, b: KeypointName): Placement => placement('joint', `the midpoint of the ${a} and ${b} joints (${what}) — a point between two joints, not a joint`, { joints: [jointRef(a), jointRef(b)] });
|
|
1704
|
+
const why =
|
|
1705
|
+
c.side === null
|
|
1706
|
+
? [mid('the hips', 'r_hip', 'l_hip'), mid('the knees', 'r_knee', 'l_knee')]
|
|
1707
|
+
: [used(`${c.side}_hip`), used(`${c.side}_knee`)];
|
|
1708
|
+
const tipWhy = c.side === null ? mid('the ankles', 'r_ankle', 'l_ankle') : used(`${c.side}_ankle`);
|
|
1709
|
+
C(c.chain, 'hip', [c.hip, c.knee], c.ankle, why, tipWhy);
|
|
1710
|
+
}
|
|
1711
|
+
for (const p of lw) {
|
|
1712
|
+
const [g, r] = gridR(p);
|
|
1713
|
+
const segs: Segment[] = [];
|
|
1714
|
+
for (const c of legPlan.chains.filter((q) => q.part === p)) {
|
|
1715
|
+
// The sleeve's chest stub, laid along the thigh from the hip: 14 px above the hip to 2 px below it.
|
|
1716
|
+
const len = Math.hypot(c.knee[0] - c.hip[0], c.knee[1] - c.hip[1]);
|
|
1717
|
+
const d: [number, number] = [(c.knee[0] - c.hip[0]) / len, (c.knee[1] - c.hip[1]) / len];
|
|
1718
|
+
segs.push(['hip', rnd([c.hip[0] - 14 * d[0], c.hip[1] - 14 * d[1]]), rnd([c.hip[0] + 2 * d[0], c.hip[1] + 2 * d[1]])], c.chain);
|
|
1719
|
+
}
|
|
1720
|
+
meshes[p.name] = { grid: g, r, segments: segs };
|
|
1721
|
+
}
|
|
1722
|
+
notes.push(...legPlan.notes);
|
|
1723
|
+
} else if (legPlan !== null && legPlan.why !== null) {
|
|
1724
|
+
notes.push(`${lw.map((p) => p.name).join(', ')}: no leg chain — ${legPlan.why}; the trunk rule places ${lw.length === 1 ? 'it' : 'them'}`);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1661
1727
|
// Anything no rule claimed rides its nearest trunk bone rigidly, so the rig stage never meets an unassigned part.
|
|
1662
1728
|
for (const p of P.recs) {
|
|
1663
1729
|
if (!(p.name in meshes) && !(p.name in regions)) {
|
|
@@ -1672,6 +1738,14 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1672
1738
|
} else notes.push(`${p.name} (${p.from}): no rule -> region on ${regions[p.name]}`);
|
|
1673
1739
|
}
|
|
1674
1740
|
}
|
|
1741
|
+
if (trunk !== null) {
|
|
1742
|
+
const laid = [...P.headFirst('neck'), ...tw, ...bw].map((p) => p.name);
|
|
1743
|
+
if (laid.length > 0) {
|
|
1744
|
+
notes.push(
|
|
1745
|
+
`trunk segments (${laid.join(', ')}): laid along neck -> hip, the line the chest was placed on (${pyFixed((Math.atan2(trunk.u[0], trunk.u[1]) * 180) / Math.PI, 1)} degrees off the screen's downward vertical, positive towards +x): each [bone, from, to] segment the rule drafts down the screen runs along that line instead, and the waist segment across it`,
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1675
1749
|
if (joints !== undefined) {
|
|
1676
1750
|
notes.push(
|
|
1677
1751
|
...jointNotes(joints, {
|
|
@@ -1680,6 +1754,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1680
1754
|
chest: chestAlong ? { neck: neckAt, hip } : null,
|
|
1681
1755
|
sleeveRule,
|
|
1682
1756
|
armWhy: armPlan === null ? null : armPlan.why,
|
|
1757
|
+
legs: legPlan === null ? null : legPlan.chains,
|
|
1683
1758
|
arms: armPlan === null ? null : armPlan.arms,
|
|
1684
1759
|
wristSnap,
|
|
1685
1760
|
eyes: (['r', 'l'] as const).map((s) => {
|
|
@@ -1696,6 +1771,55 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1696
1771
|
// external keypoints (issue #75)
|
|
1697
1772
|
// ---------------------------------------------------------------------------
|
|
1698
1773
|
|
|
1774
|
+
/**
|
|
1775
|
+
* The trunk's line (issue #243), from the neck bone's origin to the hip's, when a joint placed either end: `u` is
|
|
1776
|
+
* its unit direction, down the trunk, and `v` the unit across it, `u` turned a quarter (`[u_y, -u_x]`). An upright
|
|
1777
|
+
* trunk's `u` is `[0, 1]` and its `v` `[1, 0]`, the screen's y and x, which is what the vertical drafts read: each
|
|
1778
|
+
* trunk segment is that draft with "down the screen" read as "along `u`" and "across the screen" as "along `v`".
|
|
1779
|
+
* `null` when the two ends are one point: a line through one point has no direction.
|
|
1780
|
+
*/
|
|
1781
|
+
interface TrunkLine {
|
|
1782
|
+
u: [number, number];
|
|
1783
|
+
v: [number, number];
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function trunkLineOf(neck: readonly [number, number], hip: readonly [number, number]): TrunkLine | null {
|
|
1787
|
+
const dx = hip[0] - neck[0];
|
|
1788
|
+
const dy = hip[1] - neck[1];
|
|
1789
|
+
const len = Math.hypot(dx, dy);
|
|
1790
|
+
if (len === 0) return null;
|
|
1791
|
+
const u: [number, number] = [dx / len, dy / len];
|
|
1792
|
+
return { u, v: [u[1], -u[0]] };
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/** The point `k` px from `p` along the unit direction `d`. */
|
|
1796
|
+
function along(p: readonly [number, number], d: readonly [number, number], k: number): [number, number] {
|
|
1797
|
+
return [p[0] + k * d[0], p[1] + k * d[1]];
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
/**
|
|
1801
|
+
* The extent of a part's art along the unit direction `d`, measured from `o`: the least and the greatest
|
|
1802
|
+
* projection onto `d` of its opaque pixels, each taken as the unit square it covers. An axis-aligned box tight on
|
|
1803
|
+
* its art therefore projects onto `[1, 0]` as its own left and right edges and onto `[0, 1]` as its top and bottom,
|
|
1804
|
+
* the values the vertical drafts read off the box. A part with no opaque pixel is read as its box.
|
|
1805
|
+
*/
|
|
1806
|
+
function artExtent(mask: Mask, p: PartRecord, o: readonly [number, number], d: readonly [number, number]): [number, number] {
|
|
1807
|
+
let lo = Infinity;
|
|
1808
|
+
let hi = -Infinity;
|
|
1809
|
+
for (let y = Math.max(0, p.y); y < Math.min(mask.height, p.y + p.h); y++) {
|
|
1810
|
+
for (let x = Math.max(0, p.x); x < Math.min(mask.width, p.x + p.w); x++) {
|
|
1811
|
+
if (mask.data[y * mask.width + x] !== 1) continue;
|
|
1812
|
+
const t = (x - o[0]) * d[0] + (y - o[1]) * d[1];
|
|
1813
|
+
if (t < lo) lo = t;
|
|
1814
|
+
if (t > hi) hi = t;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
const w = lo === Infinity ? p.w : 1;
|
|
1818
|
+
const h = lo === Infinity ? p.h : 1;
|
|
1819
|
+
if (lo === Infinity) lo = hi = (p.x - o[0]) * d[0] + (p.y - o[1]) * d[1];
|
|
1820
|
+
return [lo + Math.min(0, d[0]) * w + Math.min(0, d[1]) * h, hi + Math.max(0, d[0]) * w + Math.max(0, d[1]) * h];
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1699
1823
|
/**
|
|
1700
1824
|
* How far a wrist joint may lie from a handwear part's art and still be on
|
|
1701
1825
|
* it: the LINT's own tolerance, the part's mask dilated this many px each way
|
|
@@ -1808,6 +1932,128 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
|
|
|
1808
1932
|
return { arms, why: null };
|
|
1809
1933
|
}
|
|
1810
1934
|
|
|
1935
|
+
/**
|
|
1936
|
+
* One leg chain drafted from joints (issue #241): its name, the side whose
|
|
1937
|
+
* joints it runs through (`null` for the one chain that carries both legs,
|
|
1938
|
+
* through the midpoints of the two hips, the two knees and the two ankles),
|
|
1939
|
+
* its two links and tip in rig px, and the legwear part it meshes.
|
|
1940
|
+
*/
|
|
1941
|
+
interface LegChain {
|
|
1942
|
+
chain: string;
|
|
1943
|
+
side: 'r' | 'l' | null;
|
|
1944
|
+
hip: [number, number];
|
|
1945
|
+
knee: [number, number];
|
|
1946
|
+
ankle: [number, number];
|
|
1947
|
+
part: PartRecord;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Whether `m`'s art breaks along the straight line from `a` to `b`: walked
|
|
1952
|
+
* one pixel step at a time (the larger of the x and y gaps, in steps of at
|
|
1953
|
+
* most one pixel, each point read as {@link isOn} reads it), some point with
|
|
1954
|
+
* no art lies between two points with art. Art that only starts or stops
|
|
1955
|
+
* along the line — an end point off the art — is no break.
|
|
1956
|
+
*/
|
|
1957
|
+
function artBreaks(m: Mask, a: readonly [number, number], b: readonly [number, number]): boolean {
|
|
1958
|
+
const n = Math.ceil(Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1])));
|
|
1959
|
+
let seenArt = false;
|
|
1960
|
+
let gapAfterArt = false;
|
|
1961
|
+
for (let i = 0; i <= n; i++) {
|
|
1962
|
+
const t = n === 0 ? 0 : i / n;
|
|
1963
|
+
const on = isOn(m, a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]));
|
|
1964
|
+
if (on && gapAfterArt) return true;
|
|
1965
|
+
if (on) seenArt = true;
|
|
1966
|
+
else if (seenArt) gapAfterArt = true;
|
|
1967
|
+
}
|
|
1968
|
+
return false;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* The leg chains a keypoint file drafts for the legwear parts, or why none
|
|
1973
|
+
* is drafted (issue #241) — the sleeve's {@link armsFromJoints} for the legs.
|
|
1974
|
+
* A leg is complete when its hip, knee and ankle all have a position and the
|
|
1975
|
+
* hip and knee are two points; a legwear part carries a leg when the ankle
|
|
1976
|
+
* lies on its art within the LINT's own tolerance ({@link WRIST_ON_PART_PX}).
|
|
1977
|
+
* The joints are used only when every complete leg's ankle is on exactly one
|
|
1978
|
+
* part and every legwear part carries one, as for the sleeves; when a leg is
|
|
1979
|
+
* complete and they are not, `why` says so. With no complete leg nothing is
|
|
1980
|
+
* drafted and `why` is `null`: the proposal is the one written before the
|
|
1981
|
+
* rule existed.
|
|
1982
|
+
*
|
|
1983
|
+
* One chain or two is read off the part and the joints. A part carrying one
|
|
1984
|
+
* leg gets that leg's chain, `leg_<side>`. A part carrying both gets one
|
|
1985
|
+
* chain per leg only when its art breaks ({@link artBreaks}) along the line
|
|
1986
|
+
* from r_knee to l_knee and along the line from r_ankle to l_ankle — the
|
|
1987
|
+
* legs painted apart at both; otherwise the legs touch, cross or overlap in
|
|
1988
|
+
* its art, two chains would pull one stretch of it two ways, and it gets one
|
|
1989
|
+
* chain, `leg`, through the midpoints of the hips, the knees and the ankles.
|
|
1990
|
+
* Each part gets one note saying which and why.
|
|
1991
|
+
*/
|
|
1992
|
+
function legsFromJoints(P: PartSet, lw: readonly PartRecord[], joints: RigJoints): { chains: LegChain[] | null; notes: string[]; why: string | null } {
|
|
1993
|
+
const complete: Array<{ side: 'r' | 'l'; hip: [number, number]; knee: [number, number]; ankle: [number, number] }> = [];
|
|
1994
|
+
const gaps: string[] = [];
|
|
1995
|
+
for (const s of ['r', 'l'] as const) {
|
|
1996
|
+
const hp = joints.joints[`${s}_hip`].at;
|
|
1997
|
+
const kn = joints.joints[`${s}_knee`].at;
|
|
1998
|
+
const an = joints.joints[`${s}_ankle`].at;
|
|
1999
|
+
if (hp === null || kn === null || an === null) {
|
|
2000
|
+
const absent = [hp === null ? `${s}_hip` : null, kn === null ? `${s}_knee` : null, an === null ? `${s}_ankle` : null].filter((n): n is string => n !== null);
|
|
2001
|
+
gaps.push(`${absent.join(', ')} ${absent.length === 1 ? 'has' : 'have'} no position`);
|
|
2002
|
+
} else if (hp[0] === kn[0] && hp[1] === kn[1]) gaps.push(`${s}_hip and ${s}_knee are one point, so the thigh has no direction`);
|
|
2003
|
+
else complete.push({ side: s, hip: hp, knee: kn, ankle: an });
|
|
2004
|
+
}
|
|
2005
|
+
// No complete leg is the file before this rule existed: nothing is said, and the part stays the trunk rule's.
|
|
2006
|
+
if (complete.length === 0) return { chains: null, notes: [], why: null };
|
|
2007
|
+
const near = new Map(lw.map((p) => [p, dilate(P.alpha(p), 2 * WRIST_ON_PART_PX + 1)]));
|
|
2008
|
+
const bound: Array<(typeof complete)[number] & { part: PartRecord }> = [];
|
|
2009
|
+
for (const g of complete) {
|
|
2010
|
+
const on = lw.filter((p) => isOn(near.get(p) as Mask, g.ankle[0], g.ankle[1]));
|
|
2011
|
+
if (on.length !== 1) {
|
|
2012
|
+
const where = on.length === 0 ? `the art of no legwear part within ${WRIST_ON_PART_PX} px` : `the art of ${on.length} legwear parts (${on.map((p) => p.name).join(', ')})`;
|
|
2013
|
+
return { chains: null, notes: [], why: `${g.side}_ankle at ${fmtAt(g.ankle)} lies on ${where}, so it does not say which part its leg is` };
|
|
2014
|
+
}
|
|
2015
|
+
bound.push({ ...g, part: on[0] });
|
|
2016
|
+
}
|
|
2017
|
+
const idle = lw.filter((p) => !bound.some((g) => g.part === p));
|
|
2018
|
+
if (idle.length > 0) {
|
|
2019
|
+
const why = gaps.length > 0 ? ` (${gaps.join('; ')})` : '';
|
|
2020
|
+
return { chains: null, notes: [], why: `${idle.map((p) => p.name).join(', ')} ${idle.length === 1 ? 'carries' : 'carry'} the ankle of no leg whose three joints are given${why}` };
|
|
2021
|
+
}
|
|
2022
|
+
const chains: LegChain[] = [];
|
|
2023
|
+
const notes: string[] = [];
|
|
2024
|
+
const still = 'no idle track is written for it: the chain is drafted whatever the pose, and whether the legs move is the scene\'s to key or drive';
|
|
2025
|
+
for (const p of lw) {
|
|
2026
|
+
const on = bound.filter((g) => g.part === p);
|
|
2027
|
+
if (on.length === 1) {
|
|
2028
|
+
const g = on[0];
|
|
2029
|
+
chains.push({ chain: `leg_${g.side}`, side: g.side, hip: g.hip, knee: g.knee, ankle: g.ankle, part: p });
|
|
2030
|
+
const other = g.side === 'r' ? 'l' : 'r';
|
|
2031
|
+
const lack = complete.some((q) => q.side === other) ? `${other}_ankle lies on another part` : gaps.join('; ');
|
|
2032
|
+
notes.push(`${p.name} (${p.from}): one leg chain, leg_${g.side}, hip -> knee -> ankle from the ${g.side} joints — ${g.side}_ankle lies on its art and no ${other} leg is bound to it (${lack}); ${still}`);
|
|
2033
|
+
continue;
|
|
2034
|
+
}
|
|
2035
|
+
const [r, l] = on;
|
|
2036
|
+
const m = P.alpha(p);
|
|
2037
|
+
const kneesBreak = artBreaks(m, r.knee, l.knee);
|
|
2038
|
+
const anklesBreak = artBreaks(m, r.ankle, l.ankle);
|
|
2039
|
+
if (kneesBreak && anklesBreak) {
|
|
2040
|
+
for (const g of on) chains.push({ chain: `leg_${g.side}`, side: g.side, hip: g.hip, knee: g.knee, ankle: g.ankle, part: p });
|
|
2041
|
+
notes.push(
|
|
2042
|
+
`${p.name} (${p.from}): one chain per leg, leg_r and leg_l, sharing its mesh — both ankles lie on its art, and its art breaks along the line from r_knee to l_knee and along the line from r_ankle to l_ankle, so the legs are painted apart; each runs hip -> knee -> ankle from its side's joints, and ${still}`,
|
|
2043
|
+
);
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
2046
|
+
const mid = (a: [number, number], b: [number, number]): [number, number] => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
2047
|
+
chains.push({ chain: 'leg', side: null, hip: mid(r.hip, l.hip), knee: mid(r.knee, l.knee), ankle: mid(r.ankle, l.ankle), part: p });
|
|
2048
|
+
const unbroken = [kneesBreak ? null : 'from r_knee to l_knee', anklesBreak ? null : 'from r_ankle to l_ankle'].filter((s): s is string => s !== null);
|
|
2049
|
+
notes.push(
|
|
2050
|
+
`${p.name} (${p.from}): one leg chain, leg, for both legs — both ankles lie on its art, and its art runs unbroken along the line ${unbroken.join(' and along the line ')}, so the legs touch, cross or overlap in it and a chain per leg would pull one stretch of art two ways; ` +
|
|
2051
|
+
`the chain runs hip -> knee -> ankle through the midpoints of the two hips, the two knees and the two ankles, and ${still}`,
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2054
|
+
return { chains, notes, why: null };
|
|
2055
|
+
}
|
|
2056
|
+
|
|
1811
2057
|
/** A rig-px point as the notes and LINT lines print it: one decimal place. */
|
|
1812
2058
|
function fmtAt(p: readonly [number, number]): string {
|
|
1813
2059
|
return `[${pyFixed(p[0], 1)}, ${pyFixed(p[1], 1)}]`;
|
|
@@ -1833,6 +2079,8 @@ interface JointNoteFacts {
|
|
|
1833
2079
|
sleeveRule: string;
|
|
1834
2080
|
armWhy: string | null;
|
|
1835
2081
|
arms: JointArm[] | null;
|
|
2082
|
+
/** The leg chains drafted (issue #241), or `null` when none was: then no line here names a leg joint. */
|
|
2083
|
+
legs: LegChain[] | null;
|
|
1836
2084
|
/** `propose --wrist-snap`, or `null` without it. */
|
|
1837
2085
|
wristSnap: number | null;
|
|
1838
2086
|
eyes: ReadonlyArray<{ bone: string; part: string; at: [number, number] } | null>;
|
|
@@ -1902,6 +2150,29 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
|
|
|
1902
2150
|
`to separate them, cut one sleeve out of ${both} with an assemble.cuts entry (a polygon round it) and propose again`,
|
|
1903
2151
|
);
|
|
1904
2152
|
}
|
|
2153
|
+
// Issue #241: the knees and ankles a leg chain was drafted from, and the hips as its first link.
|
|
2154
|
+
for (const c of f.legs ?? []) {
|
|
2155
|
+
const on = `the ankle${c.side === null ? 's lie' : ' lies'} on ${c.part.name}`;
|
|
2156
|
+
if (c.side === null) {
|
|
2157
|
+
out.push(`chain leg: link 0 is the midpoint of r_hip and l_hip, ${fmtAt(c.hip)} — the point bone hip stands on`);
|
|
2158
|
+
for (const [joint, role, pair] of [
|
|
2159
|
+
['r_knee', 'link 1', 'r_knee and l_knee'],
|
|
2160
|
+
['l_knee', 'link 1', 'r_knee and l_knee'],
|
|
2161
|
+
['r_ankle', 'the tip', 'r_ankle and l_ankle'],
|
|
2162
|
+
['l_ankle', 'the tip', 'r_ankle and l_ankle'],
|
|
2163
|
+
] as const) {
|
|
2164
|
+
tell(joint, `${said(joint, J[joint])}: ${usedAs(J[joint])} for chain leg, ${role}, the midpoint of ${pair} — a point between two joints, not a joint (${on})`);
|
|
2165
|
+
}
|
|
2166
|
+
continue;
|
|
2167
|
+
}
|
|
2168
|
+
out.push(`chain leg_${c.side}: link 0 is the ${c.side}_hip joint, ${fmtAt(c.hip)}`);
|
|
2169
|
+
for (const [joint, role] of [
|
|
2170
|
+
[`${c.side}_knee`, 'link 1'],
|
|
2171
|
+
[`${c.side}_ankle`, 'the tip'],
|
|
2172
|
+
] as const) {
|
|
2173
|
+
tell(joint, `${said(joint, J[joint])}: ${usedAs(J[joint])} for chain leg_${c.side}, ${role} (${on})`);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
1905
2176
|
(['r', 'l'] as const).forEach((s, i) => {
|
|
1906
2177
|
const n = `${s}_eye` as const;
|
|
1907
2178
|
const j = J[n];
|