@rig-c/parts 1.5.5 → 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
@@ -43,7 +43,13 @@
43
43
  * including every `painting:` patch (`assemble.patches`), which has no tag,
44
44
  * so no rule above ever reads it: it is never a mesh or a chain, it moves
45
45
  * no bone the rules place, and the note says the bone is the config's to
46
- * name in `regions.<name>`.
46
+ * name in `regions.<name>`. A cut's piece (`assemble.cuts`, recorded with
47
+ * `cut_from`) goes the same way (issue #230): it shares its base's tag, and
48
+ * the rules that run once per part of a tag would name the base's bones and
49
+ * chains a second time. The one exception is the sleeve rule, which names
50
+ * its chains by side and takes a cut sleeve as a blob of its own (#199). A
51
+ * `parts.json` sharing a layer between parts with no `cut_from` (an
52
+ * assemble before 1.6.0) is refused, `PROPOSE_CUT_FROM_RECORDED`.
47
53
  *
48
54
  * What it cannot know is written into `notes` when it guessed, and otherwise
49
55
  * left to the person correcting: things hidden inside a layer (a sash tail
@@ -73,6 +79,14 @@
73
79
  * never moved by a joint. `lint` then reads the torso and the sleeves by the
74
80
  * relations the joints declare instead of by screen y. Without joints every
75
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}).
76
90
  *
77
91
  * Issue #86 adds what the proposer and lint looked at, and changes nothing
78
92
  * they wrote: {@link proposeWithBasis} records, at each placement, what the
@@ -236,6 +250,54 @@ function konst(name: string, value: number): BasisConstant {
236
250
  return { name, value };
237
251
  }
238
252
 
253
+ /**
254
+ * The `assemble` section {@link checkProposal} hands the loader, rebuilt from
255
+ * parts.json: every See-through part a plan entry or a cut, every patch a
256
+ * patches entry. Exported so a control can read which part went back as the
257
+ * plan part and which as the piece (issue #230).
258
+ */
259
+ export function proposalAssemble(P: PartSet): Record<string, unknown> {
260
+ // A patch is not a plan entry; it goes back as the patches entry it came
261
+ // from, so the loader holds it to its own rule (a region, never a mesh).
262
+ // Its box, alpha and draw are the record's own box and placeholders the
263
+ // loader only type-checks: nothing here is written anywhere.
264
+ // A part that shares another's `from` is the piece of an `assemble.cuts`
265
+ // entry (issue #170) — the only way two parts share a layer — and goes back
266
+ // as a cut of its base, its polygon the record's box, a placeholder the
267
+ // loader only type-checks like the patches' fields. The piece names its base
268
+ // (`cut_from`, issue #230); file order cannot, as a piece drawn "back" comes
269
+ // before its base. A file with an unmarked shared layer (an assemble before
270
+ // 1.6.0, which propose refuses) goes back with the first part of the layer
271
+ // as the plan part, as it did before the field.
272
+ const run = P.recs.filter((r) => splitFrom(r.from)[0] !== PAINTING_RUN);
273
+ const painted = P.recs.filter((r) => splitFrom(r.from)[0] === PAINTING_RUN);
274
+ const firstOf = new Map<string, string>();
275
+ for (const r of run) if (r.cut_from === undefined && !firstOf.has(r.from)) firstOf.set(r.from, r.name);
276
+ const baseOf = (r: PartRecord): string => r.cut_from ?? (firstOf.get(r.from) as string);
277
+ const planned = run.filter((r) => r.cut_from === undefined && firstOf.get(r.from) === r.name);
278
+ const pieces = run.filter((r) => r.cut_from !== undefined || firstOf.get(r.from) !== r.name);
279
+ return {
280
+ rig_scale: 1,
281
+ plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
282
+ patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
283
+ ...(pieces.length === 0
284
+ ? {}
285
+ : {
286
+ cuts: pieces.map((r) => ({
287
+ from: baseOf(r),
288
+ into: r.name,
289
+ polygon: [
290
+ [r.x, r.y],
291
+ [r.x + r.w, r.y],
292
+ [r.x + r.w, r.y + r.h],
293
+ ],
294
+ draw: 'front',
295
+ overlap: 0,
296
+ })),
297
+ }),
298
+ };
299
+ }
300
+
239
301
  /**
240
302
  * Hold the proposal to the config contract before it is written: it is copied
241
303
  * into a config field by field, so a proposal the loader would refuse is a
@@ -246,42 +308,9 @@ function konst(name: string, value: number): BasisConstant {
246
308
  * refusals.
247
309
  */
248
310
  export function checkProposal(P: PartSet, p: Proposal): void {
249
- // A patch is not a plan entry; it goes back as the patches entry it came
250
- // from, so the loader holds it to its own rule (a region, never a mesh).
251
- // Its box, alpha and draw are the record's own box and placeholders the
252
- // loader only type-checks: nothing here is written anywhere.
253
- // A part whose `from` an earlier part already has is the piece of an
254
- // `assemble.cuts` entry (issue #170) — the only way two parts share a layer
255
- // — and goes back as a cut of that earlier part, its polygon the record's
256
- // box, a placeholder the loader only type-checks like the patches' fields.
257
- const run = P.recs.filter((r) => splitFrom(r.from)[0] !== PAINTING_RUN);
258
- const painted = P.recs.filter((r) => splitFrom(r.from)[0] === PAINTING_RUN);
259
- const firstOf = new Map<string, string>();
260
- for (const r of run) if (!firstOf.has(r.from)) firstOf.set(r.from, r.name);
261
- const planned = run.filter((r) => firstOf.get(r.from) === r.name);
262
- const pieces = run.filter((r) => firstOf.get(r.from) !== r.name);
263
311
  parseConfig({
264
312
  key: 'proposal',
265
- assemble: {
266
- rig_scale: 1,
267
- plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
268
- patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
269
- ...(pieces.length === 0
270
- ? {}
271
- : {
272
- cuts: pieces.map((r) => ({
273
- from: firstOf.get(r.from) as string,
274
- into: r.name,
275
- polygon: [
276
- [r.x, r.y],
277
- [r.x + r.w, r.y],
278
- [r.x + r.w, r.y + r.h],
279
- ],
280
- draw: 'front',
281
- overlap: 0,
282
- })),
283
- }),
284
- },
313
+ assemble: proposalAssemble(P),
285
314
  bones: p.bones,
286
315
  meshes: p.meshes,
287
316
  regions: p.regions,
@@ -312,14 +341,33 @@ export class PartSet {
312
341
  this.images = images;
313
342
  }
314
343
 
315
- /** Parts whose `from` tag is exactly `tag`, optionally from one run, in parts.json order. A `painting:` patch has no tag and is never one. */
316
- byTag(tag: string, run?: 'full' | 'head'): PartRecord[] {
344
+ /**
345
+ * Parts whose `from` tag is exactly `tag`, optionally from one run, in
346
+ * parts.json order. A `painting:` patch has no tag and is never one, and a
347
+ * cut's piece (`cut_from`, issue #230) is not one either unless `pieces` is
348
+ * set: it shares its base's layer, and a rule that runs once per part of a
349
+ * tag would run for its base a second time — the same chains named twice.
350
+ */
351
+ byTag(tag: string, run?: 'full' | 'head', pieces = false): PartRecord[] {
317
352
  return this.recs.filter((p) => {
318
353
  const [r, t] = splitFrom(p.from);
319
- return r !== PAINTING_RUN && t === tag && (run === undefined || r === run);
354
+ return r !== PAINTING_RUN && t === tag && (run === undefined || r === run) && (pieces || p.cut_from === undefined);
320
355
  });
321
356
  }
322
357
 
358
+ /**
359
+ * The layers more than one part holds with no `cut_from` to tell the piece
360
+ * from its base: a `parts.json` an `assemble` before 1.6.0 wrote with
361
+ * `assemble.cuts`. parts.json is in draw order and a piece drawn `"back"`
362
+ * comes before its base, so which part takes the layer's rule cannot be
363
+ * read off the file. Each entry is the layer and its parts, in file order.
364
+ */
365
+ unmarkedShares(): Array<{ from: string; parts: string[] }> {
366
+ const by = new Map<string, string[]>();
367
+ for (const p of this.layered()) if (p.cut_from === undefined) by.set(p.from, [...(by.get(p.from) ?? []), p.name]);
368
+ return [...by.entries()].filter(([, names]) => names.length > 1).map(([from, parts]) => ({ from, parts }));
369
+ }
370
+
323
371
  /** The See-through parts: every part but the `painting:` patches. */
324
372
  layered(): PartRecord[] {
325
373
  return this.recs.filter((p) => splitFrom(p.from)[0] !== PAINTING_RUN);
@@ -830,6 +878,16 @@ export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null
830
878
  * alone, and every value and note is the one written before it existed.
831
879
  */
832
880
  export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: number | null = null): { proposal: Proposal; basis: ProposalBasis } {
881
+ // Issue #230: a piece is not given its base's rule a second time, which
882
+ // needs to know which part is the piece. A file that cannot say is refused
883
+ // here, and only here: every other reader of parts.json still takes it.
884
+ refuseIfAny(
885
+ P.unmarkedShares().map(({ from, parts }) => ({
886
+ code: 'PROPOSE_CUT_FROM_RECORDED',
887
+ object: `parts ${parts.map((n) => `"${n}"`).join(', ')} (${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`,
889
+ })),
890
+ );
833
891
  const bones: BoneEntry[] = [];
834
892
  const records: BoneBasis[] = [];
835
893
  // No prototype: a part may be named anything file-safe, "__proto__" included.
@@ -946,6 +1004,13 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
946
1004
  // down the screen, because the torso need not be upright.
947
1005
  const chestAlong = neckJ !== null || hipFromJoints;
948
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
+ }
949
1014
  // Each placement hands its basis with its value: one record per bone, in the order the bones are written.
950
1015
  const B = (name: string, parent: string, at: readonly [number, number], why: Placement, tip?: readonly [number, number], tipWhy?: Placement): void => {
951
1016
  bones.push(tip === undefined ? { name, parent, at: rnd(at) } : { name, parent, at: rnd(at), tip: rnd(tip) });
@@ -1018,6 +1083,8 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1018
1083
  const eyeless: string[] = [];
1019
1084
  const eyelessSides = new Set<'r' | 'l'>();
1020
1085
  for (const p of P.layered()) {
1086
+ // A cut's piece takes no tag's role (issue #230): it falls to the trunk rule below, which says so.
1087
+ if (p.cut_from !== undefined) continue;
1021
1088
  const t = splitFrom(p.from)[1];
1022
1089
  let role: string | undefined = TAG_REGION[t];
1023
1090
  if (browOf.has(p.name)) role = browOf.get(p.name);
@@ -1376,14 +1443,16 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1376
1443
  grid: g,
1377
1444
  r,
1378
1445
  segments: [
1379
- ['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)],
1380
1447
  ['head', rnd(head), rnd([axis, head[1] - 50])],
1381
1448
  ],
1382
1449
  };
1383
1450
  }
1384
1451
 
1385
1452
  // ---- sleeves
1386
- const hw = [...P.byTag('handwear-r'), ...P.byTag('handwear-l')].filter((p) => p.opaque_px > 500);
1453
+ // A cut's piece counts here (issue #230): the sleeve rule names its chains by side, not once per part, so a piece
1454
+ // never repeats one, and cutting one sleeve out of a both-arms part is how #199's note says to separate them.
1455
+ const hw = [...P.byTag('handwear-r', undefined, true), ...P.byTag('handwear-l', undefined, true)].filter((p) => p.opaque_px > 500);
1387
1456
  const sides = new Map<'r' | 'l', PartRecord[]>();
1388
1457
  for (const p of hw) {
1389
1458
  const s = center(p)[0] < axis ? 'r' : 'l';
@@ -1520,10 +1589,17 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1520
1589
  const mask = P.alpha(p);
1521
1590
  const top = chest[1] - 0.3 * fh;
1522
1591
  const bot = p.y + p.h;
1523
- const segs: Segment[] = [
1524
- ['chest', rnd([chest[0], p.y]), rnd([chest[0], hip[1] - 20])],
1525
- ['hip', rnd([chest[0], hip[1] - 20]), rnd([chest[0], hip[1] + 100])],
1526
- ];
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
+ ];
1527
1603
  if (bw.length > 0 && bot < hip[1] + 0.25 * fh) {
1528
1604
  // A bodice that stops at the waist: robe chains down to the hem would fall off the art.
1529
1605
  meshes[p.name] = { grid: g, r, segments: segs };
@@ -1577,10 +1653,19 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1577
1653
  const mask = P.alpha(p);
1578
1654
  const bot = p.y + p.h;
1579
1655
  const y0 = hip[1] + 20;
1580
- const segs: Segment[] = [
1656
+ let segs: Segment[] = [
1581
1657
  ['chest', rnd([chest[0], chest[1] - 30]), rnd([chest[0], hip[1] - 20])],
1582
1658
  ['hip', rnd([p.x + 0.09 * p.w, hip[1] + 5]), rnd([p.x + 0.91 * p.w, hip[1] + 5])],
1583
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
+ }
1584
1669
  for (const [tag, f, ph] of [
1585
1670
  ['skirt_r', 0.3, 0.3],
1586
1671
  ['skirt_c', 0.5, 0.34],
@@ -1604,6 +1689,41 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1604
1689
  meshes[p.name] = { grid: g, r, segments: segs };
1605
1690
  }
1606
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
+
1607
1727
  // Anything no rule claimed rides its nearest trunk bone rigidly, so the rig stage never meets an unassigned part.
1608
1728
  for (const p of P.recs) {
1609
1729
  if (!(p.name in meshes) && !(p.name in regions)) {
@@ -1611,9 +1731,21 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1611
1731
  regions[p.name] = cy < neckY ? 'head' : cy < hip[1] ? 'chest' : 'hip';
1612
1732
  if (splitFrom(p.from)[0] === PAINTING_RUN) {
1613
1733
  notes.push(`${p.name} (${p.from}): a painting patch has no tag, so no rule -> region on ${regions[p.name]}, the nearest trunk bone; config.regions.${p.name} is the bone it rides`);
1734
+ } else if (p.cut_from !== undefined) {
1735
+ notes.push(
1736
+ `${p.name} (${p.from}): a piece an assemble.cuts entry took from ${p.cut_from}, so not given that layer's rule a second time (it would name ${p.cut_from}'s bones and chains again) -> region on ${regions[p.name]}, the nearest trunk bone; config.regions.${p.name} is the bone it rides, and a mesh in config.meshes is the author's to give it`,
1737
+ );
1614
1738
  } else notes.push(`${p.name} (${p.from}): no rule -> region on ${regions[p.name]}`);
1615
1739
  }
1616
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
+ }
1617
1749
  if (joints !== undefined) {
1618
1750
  notes.push(
1619
1751
  ...jointNotes(joints, {
@@ -1622,6 +1754,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1622
1754
  chest: chestAlong ? { neck: neckAt, hip } : null,
1623
1755
  sleeveRule,
1624
1756
  armWhy: armPlan === null ? null : armPlan.why,
1757
+ legs: legPlan === null ? null : legPlan.chains,
1625
1758
  arms: armPlan === null ? null : armPlan.arms,
1626
1759
  wristSnap,
1627
1760
  eyes: (['r', 'l'] as const).map((s) => {
@@ -1638,6 +1771,55 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1638
1771
  // external keypoints (issue #75)
1639
1772
  // ---------------------------------------------------------------------------
1640
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
+
1641
1823
  /**
1642
1824
  * How far a wrist joint may lie from a handwear part's art and still be on
1643
1825
  * it: the LINT's own tolerance, the part's mask dilated this many px each way
@@ -1750,6 +1932,128 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
1750
1932
  return { arms, why: null };
1751
1933
  }
1752
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
+
1753
2057
  /** A rig-px point as the notes and LINT lines print it: one decimal place. */
1754
2058
  function fmtAt(p: readonly [number, number]): string {
1755
2059
  return `[${pyFixed(p[0], 1)}, ${pyFixed(p[1], 1)}]`;
@@ -1775,6 +2079,8 @@ interface JointNoteFacts {
1775
2079
  sleeveRule: string;
1776
2080
  armWhy: string | null;
1777
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;
1778
2084
  /** `propose --wrist-snap`, or `null` without it. */
1779
2085
  wristSnap: number | null;
1780
2086
  eyes: ReadonlyArray<{ bone: string; part: string; at: [number, number] } | null>;
@@ -1844,6 +2150,29 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
1844
2150
  `to separate them, cut one sleeve out of ${both} with an assemble.cuts entry (a polygon round it) and propose again`,
1845
2151
  );
1846
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
+ }
1847
2176
  (['r', 'l'] as const).forEach((s, i) => {
1848
2177
  const n = `${s}_eye` as const;
1849
2178
  const j = J[n];
@@ -147,6 +147,28 @@ export interface RequirementsFile {
147
147
  fps: number;
148
148
  targets: SceneTarget[];
149
149
  requirements: Requirement[];
150
+ /**
151
+ * Issue #231: the parts the scene's figure does not stand on, by name — each
152
+ * a part `check` would otherwise read as a planted foot (a `footwear` part),
153
+ * which the feet halves of `BREATH_VISIBLE` and `STILL_REGIONS_DARK` then
154
+ * name as declared and do not hold still. Empty when the file does not
155
+ * declare it: every footwear part is a planted foot, as before.
156
+ */
157
+ not_planted: string[];
158
+ /**
159
+ * Issue #231: the swinging parts (`handwear`, `bottomwear`) the scene does
160
+ * not mean to swing — a piece cut off and left still — by name.
161
+ * `TIP_OVER_ROOT` names each in its line and does not hold it to the bar.
162
+ * Empty when not declared.
163
+ */
164
+ not_swinging: string[];
165
+ /**
166
+ * Issue #231: the swinging parts whose tip is up — a raised forearm, its
167
+ * chain rising from the elbow — by name. `TIP_OVER_ROOT` reads their upper
168
+ * half as the tip's, after checking that the chain carrying each does rise,
169
+ * and names each in its line. Empty when not declared: every part hangs.
170
+ */
171
+ rises: string[];
150
172
  }
151
173
 
152
174
  /** What `check --requirements` and `build --requirements` name, said once for the help and the refusals. */
@@ -182,10 +204,24 @@ const KIND_FIELDS: Record<RequirementKind, readonly string[]> = {
182
204
 
183
205
  const NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
184
206
 
207
+ /** The three lists of parts a scene excepts from a bar that assumes a standing figure (issue #231), in the order they are read. */
208
+ export const EXCEPTION_LISTS = ['not_planted', 'not_swinging', 'rises'] as const;
209
+ type ExceptionList = (typeof EXCEPTION_LISTS)[number];
210
+
211
+ /** What each list's absence declares. */
212
+ const EXCEPTION_ABSENT: Record<ExceptionList, string> = {
213
+ not_planted: 'every footwear part a planted foot',
214
+ not_swinging: 'every handwear and bottomwear part one that swings',
215
+ rises: 'every swinging part one that hangs',
216
+ };
217
+
185
218
  /**
186
219
  * Read and shape-check a requirements file. Every problem is collected before
187
220
  * one refusal: the file, `spec`, `fps` (required, a positive number: no
188
- * default), `targets` (optional) and `requirements` (required, possibly empty),
221
+ * default), `targets` (optional), `requirements` (required, possibly empty)
222
+ * and `not_planted`, `not_swinging` and `rises` (each optional, issue #231: a
223
+ * non-empty list of distinct part names, no part in both of the last two;
224
+ * which parts they are is read against parts.json and the rig by `check`),
189
225
  * each entry's every field and bar by name, a key the format does not read,
190
226
  * and a name used twice. Resolving the names against the rig is
191
227
  * {@link resolveRequirements}'s.
@@ -209,7 +245,7 @@ export function readRequirements(path: string): RequirementsFile {
209
245
  problems.push({ code: 'REQUIREMENTS_FIELD', object, detail });
210
246
  };
211
247
  if (raw.spec !== REQUIREMENTS_SPEC) field(`${at} field "spec"`, `is ${JSON.stringify(raw.spec)}; ${JSON.stringify(REQUIREMENTS_SPEC)} is required`);
212
- for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements; note or *_note as a string; x-<name> as a record)');
248
+ for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises; note or *_note as a string; x-<name> as a record)');
213
249
  let fps = NaN;
214
250
  if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
215
251
  else fps = raw.fps;
@@ -336,8 +372,27 @@ export function readRequirements(path: string): RequirementsFile {
336
372
  });
337
373
  }
338
374
  }
375
+ const lists = {} as Record<ExceptionList, string[]>;
376
+ for (const key of EXCEPTION_LISTS) {
377
+ const names: string[] = [];
378
+ lists[key] = names;
379
+ const v = raw[key];
380
+ if (v === undefined) continue;
381
+ if (!Array.isArray(v) || v.length === 0) {
382
+ field(`${at} field "${key}"`, `is ${JSON.stringify(v)?.slice(0, 60)}; a non-empty list of part names is required when the field is given — leave it out to declare ${EXCEPTION_ABSENT[key]}`);
383
+ continue;
384
+ }
385
+ v.forEach((n, i) => {
386
+ if (typeof n !== 'string' || n.length === 0) field(`${at} field "${key}"[${i}]`, `is ${JSON.stringify(n)?.slice(0, 40)}; a part name is required`);
387
+ else if (names.includes(n)) field(`${at} field "${key}"[${i}]`, `names "${n}" a second time; each part is declared once`);
388
+ else names.push(n);
389
+ });
390
+ }
391
+ lists.rises.forEach((n, i) => {
392
+ if (lists.not_swinging.includes(n)) field(`${at} field "rises"[${i}]`, `names "${n}", which "not_swinging" names too; a part that does not swing has no tip to read up or down`);
393
+ });
339
394
  refuseIfAny(problems);
340
- return { path: at, fps, targets, requirements };
395
+ return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises };
341
396
  }
342
397
 
343
398
  // ---------------------------------------------------------------------------