@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/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.5.6) is refused, `PROPOSE_CUT_FROM_RECORDED`.
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.5.6, which propose refuses) goes back with the first part of the layer
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.5.6 wrote with
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.5.6+) to record cut_from`,
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
- ['chest', rnd([chest[0], p.y]), rnd([chest[0], hip[1] - 20])],
1579
- ['hip', rnd([chest[0], hip[1] - 20]), rnd([chest[0], hip[1] + 100])],
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
- const segs: Segment[] = [
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];