@rig-c/parts 1.16.0 → 1.18.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
@@ -25,7 +25,9 @@
25
25
  * - `topwear` -> two outer-robe chains, only when it hangs below the hip line;
26
26
  * a side whose link falls off the art is dropped.
27
27
  * - `handwear-*` -> two blobs: one sleeve chain per blob, its mesh taking that
28
- * chain and a chest stub at the shoulder; one blob: both chains and the chest
28
+ * chain and nothing else (issue #347: a chest stub at the shoulder pinned the
29
+ * puff to the torso, so the part stretched when the chain turned; without it the
30
+ * part rides its chain rigidly); one blob: both chains and the chest
29
31
  * midline, split at the eye axis — unless the blob is clasped hands (at the
30
32
  * axis and at most half the shoulders' width), which ride `hip` as a region.
31
33
  * - `front hair` -> three fringe chains + one lock chain per strand that hangs
@@ -117,7 +119,7 @@
117
119
  import { existsSync } from 'node:fs';
118
120
  import { join } from 'node:path';
119
121
  import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
120
- import { type BoneEntry, type ConfigConstraint, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
122
+ import { type BoneEntry, type ConfigConstraint, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
121
123
  import { PartsError, type Problem, refuseIfAny } from './errors.ts';
122
124
  import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
123
125
  import { type LayerSet, OPAQUE_ALPHA_ABOVE } from './layers.ts';
@@ -254,6 +256,8 @@ export interface Proposal {
254
256
  bones: BoneEntry[];
255
257
  meshes: Record<string, MeshSpec>;
256
258
  regions: Record<string, string>;
259
+ /** Only under `--keypoints`, where an arm or a leg is drafted from its joints (issue #349): absent otherwise, so a proposal without one is the one written before it. */
260
+ limbs?: Record<string, Limb>;
257
261
  motion: {
258
262
  duration: number;
259
263
  tracks: Array<ProposedSingleTrack | ProposedChainTrack>;
@@ -417,6 +421,7 @@ export function checkProposal(P: PartSet, p: Proposal): void {
417
421
  bones: p.bones,
418
422
  meshes: p.meshes,
419
423
  regions: p.regions,
424
+ ...(p.limbs === undefined ? {} : { limbs: p.limbs }),
420
425
  motion: p.motion,
421
426
  ...(p.turn === undefined ? {} : { turn: p.turn }),
422
427
  });
@@ -465,6 +470,7 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
465
470
  const read: Array<{ part: string; run: 'full' | 'head'; tag: string; base: string | null; own: number; area: number; order: number; piece: boolean }> = [];
466
471
  const patches: string[] = [];
467
472
  const unread = new Map<string, string[]>();
473
+ const depthless: Array<{ part: string; from: string; set: LayerSet }> = [];
468
474
  P.recs.forEach((r, order) => {
469
475
  const [run, tag] = splitFrom(r.from);
470
476
  if (run === PAINTING_RUN) {
@@ -482,7 +488,7 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
482
488
  return;
483
489
  }
484
490
  if (layer.depth === null) {
485
- problems.push({ code: 'PROPOSE_TURN_DEPTH_PRESENT', object: `part "${r.name}" (${r.from})`, detail: `its layer in ${set.source} carries no depth_median (a ${set.form} input); the turn orders parts by See-through's per-layer depth, so a run that carries one is required` });
491
+ depthless.push({ part: r.name, from: r.from, set });
486
492
  return;
487
493
  }
488
494
  read.push({ part: r.name, run: run as 'full' | 'head', tag, base: 'base' in layer.tag ? layer.tag.base : null, own: layer.depth, area: r.opaque_px, order, piece: r.cut_from !== undefined });
@@ -490,6 +496,15 @@ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null;
490
496
  for (const [run, names] of unread) {
491
497
  problems.push({ code: 'PROPOSE_TURN_RUN_GIVEN', object: `the ${run} run`, detail: `part(s) ${names.map((n) => `"${n}"`).join(', ')} came from it and its layers were not given; --${run} <dir | layers.json> is required to read their depth` });
492
498
  }
499
+ // Issue #359: runs that carry no depth at all (a PSD, or an export of one) are skipped by name, never passed and never
500
+ // given an invented depth; a run where some parts' layers carry one and others do not is refused part by part.
501
+ if (problems.length === 0 && read.length === 0 && depthless.length > 0) {
502
+ const sources = [...new Set(depthless.map((d) => d.set.source))];
503
+ return { turn: null, notes: [`turn: SKIP — ${sources.join(', ')} carr${sources.length === 1 ? 'ies' : 'y'} no depth_median (${[...new Set(depthless.map((d) => (d.set.form === 'psd' ? 'a PSD carries none' : 'null on every layer')))].join('; ')}), so no turn control is drafted from ${depthless.length} part(s); the turn orders parts by See-through's per-layer depth, and none was measured — a config.turn written by hand sets one`] };
504
+ }
505
+ for (const d of depthless) {
506
+ problems.push({ code: 'PROPOSE_TURN_DEPTH_PRESENT', object: `part "${d.part}" (${d.from})`, detail: `its layer in ${d.set.source} carries no depth_median (a ${d.set.form} input) while other parts' layers carry one; the turn orders parts by See-through's per-layer depth, so a run that carries one for every part — or for none, which skips the turn — is required` });
507
+ }
493
508
  refuseIfAny(problems);
494
509
  const notes: string[] = [];
495
510
  if (patches.length > 0) {
@@ -560,7 +575,20 @@ export const TURN_DEPTH_CAVEAT: readonly string[] = [
560
575
 
561
576
  /** The proposal with `turn` written after `motion` and `notes` extended; the proposal itself when `turn` is null. */
562
577
  export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: string[] }): Proposal {
563
- return { bones: p.bones, meshes: p.meshes, regions: p.regions, motion: p.motion, ...(draft.turn === null ? {} : { turn: draft.turn }), notes: [...p.notes, ...draft.notes] };
578
+ // Issue #349: a part a limb takes is not made; each of its limb parts carries its depth instead.
579
+ let turn = draft.turn;
580
+ if (turn !== null && p.limbs !== undefined) {
581
+ const depths: Record<string, number> = {};
582
+ const into = new Map<string, string[]>();
583
+ for (const l of Object.values(p.limbs)) for (const sg of l.segments) into.set(sg.from, [...(into.get(sg.from) ?? []), sg.part]);
584
+ for (const [part, d] of Object.entries(turn.depths)) {
585
+ const parts = into.get(part);
586
+ if (parts === undefined) depths[part] = d;
587
+ else for (const q of parts) depths[q] = d;
588
+ }
589
+ turn = { pivot: turn.pivot, depths };
590
+ }
591
+ return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(turn === null ? {} : { turn }), notes: [...p.notes, ...draft.notes] };
564
592
  }
565
593
 
566
594
  // ---------------------------------------------------------------------------
@@ -604,7 +632,7 @@ export class PartSet {
604
632
  */
605
633
  unmarkedShares(): Array<{ from: string; parts: string[] }> {
606
634
  const by = new Map<string, string[]>();
607
- for (const p of this.layered()) if (p.cut_from === undefined) by.set(p.from, [...(by.get(p.from) ?? []), p.name]);
635
+ for (const p of this.layered()) if (p.cut_from === undefined && p.limb === undefined) by.set(p.from, [...(by.get(p.from) ?? []), p.name]);
608
636
  return [...by.entries()].filter(([, names]) => names.length > 1).map(([from, parts]) => ({ from, parts }));
609
637
  }
610
638
 
@@ -1128,8 +1156,24 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1128
1156
  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`,
1129
1157
  })),
1130
1158
  );
1159
+ // Issue #349: limbs are drafted from the parts before their cut — the layer each limb takes is what the rules read.
1160
+ const limbed = P.recs.filter((r) => r.limb !== undefined);
1161
+ refuseIfAny(
1162
+ limbed.length === 0
1163
+ ? []
1164
+ : [
1165
+ {
1166
+ code: 'PROPOSE_LIMB_PARTS',
1167
+ object: `parts ${limbed.map((r) => `"${r.name}"`).join(', ')}`,
1168
+ detail: `were cut at their joints by config.limbs (from ${[...new Set(limbed.map((r) => `"${(r.limb as NonNullable<PartRecord['limb']>).of}"`))].join(', ')}), and propose drafts the limbs from the parts before the cut; assemble with the config's limbs left out, propose from that build, and keep the limbs block (and any caps, ranges) the config already holds — or read the config's bones with propose --from-config`,
1169
+ },
1170
+ ],
1171
+ );
1131
1172
  const bones: BoneEntry[] = [];
1132
1173
  const records: BoneBasis[] = [];
1174
+ /** Issue #349: the limbs `--keypoints` drafts, and the parts they take (not made, so not given a mesh or a region). */
1175
+ const limbs: Record<string, Limb> = Object.create(null) as Record<string, Limb>;
1176
+ const consumed = new Set<string>();
1133
1177
  // No prototype: a part may be named anything file-safe, "__proto__" included.
1134
1178
  const meshes = Object.create(null) as Record<string, MeshSpec>;
1135
1179
  const regions = Object.create(null) as Record<string, string>;
@@ -1764,30 +1808,37 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1764
1808
  } else if (armPlan !== null && armPlan.arms !== null) {
1765
1809
  // Each chain runs shoulder -> elbow with its tip at the wrist: two links,
1766
1810
  // the upper arm and the forearm, each with the amplitude the two-blob rule
1767
- // gives its link of the same index. Its part's mesh takes the chain and a
1768
- // chest stub at the shoulder, the two-blob rule's 16 px stub laid along
1769
- // the upper arm instead of down the screen.
1811
+ // gives its link of the same index. Its part's mesh takes the chain alone,
1812
+ // as the two-blob rule's does since issue #347 (no chest stub: a stub on
1813
+ // chest pinned the puff, and the arm stretched it when it turned).
1770
1814
  for (const a of armPlan.arms) {
1771
1815
  const sgn = a.side === 'r' ? 1 : -1;
1772
1816
  const used = (n: KeypointName): Placement => placement('joint', `the ${n} joint, ${usedAs((joints as RigJoints).joints[n])}`, { joints: [jointRef(n)] });
1773
1817
  C(`sleeve_${a.side}`, 'chest', [a.shoulder, a.elbow], a.wrist, [used(`${a.side}_shoulder`), used(`${a.side}_elbow`)], used(`${a.side}_wrist`));
1774
1818
  tracks.push({ chain: `sleeve_${a.side}`, amps: [sgn * 0.2, sgn * 0.6], period: 4.0, phase: 0.22 + (a.side === 'l' ? 0.05 : 0), lag: 0.08 });
1775
1819
  }
1776
- for (const p of hw) {
1777
- const [g, r] = gridR(p);
1778
- const segs: Segment[] = [];
1779
- for (const a of armPlan.arms.filter((q) => q.part === p)) {
1780
- const len = edgeLength(a.elbow[0] - a.shoulder[0], a.elbow[1] - a.shoulder[1]);
1781
- const d: [number, number] = [(a.elbow[0] - a.shoulder[0]) / len, (a.elbow[1] - a.shoulder[1]) / len];
1782
- segs.push(['chest', rnd([a.shoulder[0] - 14 * d[0], a.shoulder[1] - 14 * d[1]]), rnd([a.shoulder[0] + 2 * d[0], a.shoulder[1] + 2 * d[1]])], `sleeve_${a.side}`);
1783
- }
1784
- meshes[p.name] = { grid: g, r, segments: segs };
1820
+ // Issue #349: each arm is a limb — the upper arm on link 0 and the forearm (with the hand) on link 1, each rigid on
1821
+ // its link, cut at the elbow — not a mesh blended along the chain. Its handwear part is the layer the limb takes.
1822
+ for (const a of armPlan.arms) {
1823
+ limbs[`arm_${a.side}`] = {
1824
+ segments: [
1825
+ { part: `upperarm_${a.side}`, from: a.part.name, bone: `sleeve_${a.side}0` },
1826
+ { part: `forearm_${a.side}`, from: a.part.name, bone: `sleeve_${a.side}1`, joint: 'elbow' },
1827
+ ],
1828
+ };
1829
+ regions[`upperarm_${a.side}`] = `sleeve_${a.side}0`;
1830
+ regions[`forearm_${a.side}`] = `sleeve_${a.side}1`;
1831
+ consumed.add(a.part.name);
1832
+ notes.push(
1833
+ `limb arm_${a.side}: ${a.part.name} (${a.part.from}) is cut at the ${a.side}_elbow joint into upperarm_${a.side} on sleeve_${a.side}0 and forearm_${a.side} (with the hand) on sleeve_${a.side}1, each rigid on its link with weight 1 — a bend is a rotation at the joint; assemble with this limbs block cuts the layer, and the forearm carries a cap past the elbow (its line counts the cap's pixels, and names any the layer does not hold)`,
1834
+ );
1785
1835
  }
1786
1836
  } else if (hw.length === 2 && sides.size === 2) {
1787
1837
  sleeveRule = 'the two-blob sleeve rule (one chain per handwear blob, down its rows)';
1788
- // Arms apart: two blobs. Each mesh gets ONLY its own sleeve chain and a
1789
- // short chest segment at its shoulder; the one-blob recipe made hanging
1790
- // hands breathe with the sternum.
1838
+ // Arms apart: two blobs. Each mesh gets ONLY its own sleeve chain; the
1839
+ // one-blob recipe made hanging hands breathe with the sternum, and the short
1840
+ // chest segment at the shoulder this rule drafted until issue #347 pinned the
1841
+ // puff to the torso, so the sleeve stretched when its chain turned.
1791
1842
  for (const [s, sgn] of SIDES) {
1792
1843
  const p = (sides.get(s) as PartRecord[])[0];
1793
1844
  const mask = P.alpha(p);
@@ -1809,10 +1860,9 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1809
1860
  C(`sleeve_${s}`, 'chest', pts, [orSaid(bandX(mask, bot - 6), pts[pts.length - 1][0], "no art in the band: the last link's x", tipWhy), bot - 3], why, tipWhy);
1810
1861
  tracks.push({ chain: `sleeve_${s}`, amps: [sgn * 0.2, sgn * 0.6, sgn * 1.2], period: 4.0, phase: 0.22 + (s === 'l' ? 0.05 : 0), lag: 0.08 });
1811
1862
  const [g, r] = gridR(p);
1812
- const sx = pts[0][0];
1813
- meshes[p.name] = { grid: g, r, segments: [['chest', rnd([sx, y0 - 14]), rnd([sx, y0 + 2])], `sleeve_${s}`] };
1863
+ meshes[p.name] = { grid: g, r, segments: [`sleeve_${s}`] };
1814
1864
  }
1815
- notes.push('handwear is two blobs: one sleeve chain per mesh, chest only at the shoulder');
1865
+ notes.push('handwear is two blobs: one sleeve chain per mesh, and nothing on chest');
1816
1866
  } else if (hw.length > 0) {
1817
1867
  sleeveRule = `the one-blob sleeve rule (both chains in one mesh, split at the eye axis x=${pyFixed(axis, 0)})`;
1818
1868
  const segs: Segment[] = [['chest', rnd([chest[0], chest[1] - 40]), rnd([chest[0], hip[1]])]];
@@ -2003,33 +2053,64 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
2003
2053
  for (const c of legPlan.chains) {
2004
2054
  const J = (joints as RigJoints).joints;
2005
2055
  const used = (n: KeypointName): Placement => placement('joint', `the ${n} joint, ${usedAs(J[n])}`, { joints: [jointRef(n)] });
2006
- 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)] });
2007
- const why =
2008
- c.side === null
2009
- ? [mid('the hips', 'r_hip', 'l_hip'), mid('the knees', 'r_knee', 'l_knee')]
2010
- : [used(`${c.side}_hip`), used(`${c.side}_knee`)];
2011
- const tipWhy = c.side === null ? mid('the ankles', 'r_ankle', 'l_ankle') : used(`${c.side}_ankle`);
2012
- C(c.chain, 'hip', [c.hip, c.knee], c.ankle, why, tipWhy);
2013
- }
2014
- for (const p of lw) {
2015
- const [g, r] = gridR(p);
2016
- const segs: Segment[] = [];
2017
- for (const c of legPlan.chains.filter((q) => q.part === p)) {
2018
- // The sleeve's chest stub, laid along the thigh from the hip: 14 px above the hip to 2 px below it.
2019
- const len = edgeLength(c.knee[0] - c.hip[0], c.knee[1] - c.hip[1]);
2020
- const d: [number, number] = [(c.knee[0] - c.hip[0]) / len, (c.knee[1] - c.hip[1]) / len];
2021
- 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);
2022
- }
2023
- meshes[p.name] = { grid: g, r, segments: segs };
2024
- }
2056
+ C(c.chain, 'hip', [c.hip, c.knee], c.ankle, [used(`${c.side}_hip`), used(`${c.side}_knee`)], used(`${c.side}_ankle`));
2057
+ }
2058
+ // Issue #349: each leg is a limb — the thigh on link 0 and the shin on link 1, cut at the knee, and the foot below
2059
+ // the ankle on a bone of its own that does not take the shin's rotation, so the shoe stays level (issue #348). The
2060
+ // thigh rides its link alone: no hip stub pins it.
2061
+ const fw = P.byTag('footwear', undefined, true).filter((q) => q.opaque_px > 500);
2062
+ const nearFoot = new Map(fw.map((q) => [q, dilate(P.alpha(q), 2 * WRIST_ON_PART_PX + 1)]));
2063
+ // The feet are drafted only when every leg's ankle lies on exactly one footwear part: a shoe layer holding both
2064
+ // shoes split between one leg's foot and no other would hand that leg the other shoe.
2065
+ const legChains = legPlan.chains;
2066
+ const feetOn = legChains.map((c) => fw.filter((q) => isOn(nearFoot.get(q) as Mask, c.ankle[0], c.ankle[1])));
2067
+ const feetBound = fw.length > 0 && feetOn.every((on) => on.length === 1);
2068
+ legChains.forEach((c, ci) => {
2069
+ const s = c.side as 'r' | 'l';
2070
+ const J = (joints as RigJoints).joints;
2071
+ const segs: Limb['segments'] = [
2072
+ { part: `thigh_${s}`, from: c.part.name, bone: `leg_${s}0` },
2073
+ { part: `shin_${s}`, from: c.part.name, bone: `leg_${s}1`, joint: 'knee' },
2074
+ ];
2075
+ regions[`thigh_${s}`] = `leg_${s}0`;
2076
+ regions[`shin_${s}`] = `leg_${s}1`;
2077
+ consumed.add(c.part.name);
2078
+ const on = feetOn[ci];
2079
+ let foot = '';
2080
+ if (feetBound) {
2081
+ B(`foot_${s}`, `leg_${s}1`, c.ankle, placement('joint', `the ${s}_ankle joint, ${usedAs(J[`${s}_ankle`])}`, { joints: [jointRef(`${s}_ankle`)] }));
2082
+ (bones[bones.length - 1] as { inherit?: string }).inherit = FOOT_INHERIT;
2083
+ segs.push({ part: `foot_${s}`, from: on[0].name, bone: `foot_${s}`, joint: 'ankle' });
2084
+ regions[`foot_${s}`] = `foot_${s}`;
2085
+ consumed.add(on[0].name);
2086
+ foot = `, and foot_${s}, ${on[0].name}'s share below the ankle, on bone foot_${s} at the ${s}_ankle joint under leg_${s}1 with inherit "${FOOT_INHERIT}" — the shoe keeps its setup angle while the leg turns (FEET_STAY_LEVEL)`;
2087
+ } else if (fw.length > 0) {
2088
+ const where = (k: number): string => {
2089
+ const o = feetOn[k];
2090
+ const g = legChains[k];
2091
+ if (o.length > 1) return `${g.side}_ankle lies on the art of ${o.length} footwear parts (${o.map((q) => q.name).join(', ')})`;
2092
+ if (o.length === 1) return `${g.side}_ankle lies on ${o[0].name}`;
2093
+ const d = fw.map((q) => ({ q, d: artDistance(P.alpha(q), g.ankle) })).sort((a, b) => a.d - b.d)[0];
2094
+ return `${g.side}_ankle at ${fmtAt(g.ankle)} lies on the art of no footwear part over 500 px within ${WRIST_ON_PART_PX} px — the nearest is ${d.q.name}, ${d.d} px from its art by the larger of the x and y gaps`;
2095
+ };
2096
+ foot = `; no foot segment: every leg's ankle must lie on exactly one footwear part for the feet to be drafted, and ${legChains.map((_, k) => where(k)).join('; ')} — move the ankle joint onto the shoe, or write the foot segment and its bone in config by hand`;
2097
+ } else foot = '; no foot segment: no footwear part is over 500 px';
2098
+ limbs[`leg_${s}`] = { segments: segs };
2099
+ notes.push(`limb leg_${s}: ${c.part.name} (${c.part.from}) is cut at the ${s}_knee joint into thigh_${s} on leg_${s}0 and shin_${s} on leg_${s}1, each rigid on its link with weight 1${foot}; the shin carries a cap past the knee`);
2100
+ });
2025
2101
  notes.push(...legPlan.notes);
2026
2102
  } else if (legPlan !== null && legPlan.why !== null) {
2027
2103
  notes.push(`${lw.map((p) => p.name).join(', ')}: no leg chain — ${legPlan.why}; the trunk rule places ${lw.length === 1 ? 'it' : 'them'}`);
2028
2104
  }
2029
2105
 
2106
+ // Issue #349: a part a limb takes is not made, so the rule that read its tag first (footwear rides root) gives it nothing.
2107
+ for (const name of consumed) {
2108
+ delete meshes[name];
2109
+ delete regions[name];
2110
+ }
2030
2111
  // Anything no rule claimed rides its nearest trunk bone rigidly, so the rig stage never meets an unassigned part.
2031
2112
  for (const p of P.recs) {
2032
- if (!(p.name in meshes) && !(p.name in regions)) {
2113
+ if (!(p.name in meshes) && !(p.name in regions) && !consumed.has(p.name)) {
2033
2114
  const cy = center(p)[1];
2034
2115
  regions[p.name] = cy < neckY ? 'head' : cy < hip[1] ? 'chest' : 'hip';
2035
2116
  if (splitFrom(p.from)[0] === PAINTING_RUN) {
@@ -2078,7 +2159,8 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
2078
2159
  // cycles per idle (REFERENCE_IDLE_SECONDS).
2079
2160
  const scale = IDLE_SECONDS / REFERENCE_IDLE_SECONDS;
2080
2161
  const idleTracks = tracks.map((tr) => ({ ...tr, period: tr.period * scale }));
2081
- return { proposal: { bones, meshes, regions, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, notes }, basis: { frame, bones: records } };
2162
+ const drafted = Object.keys(limbs).length === 0 ? {} : { limbs };
2163
+ return { proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, notes }, basis: { frame, bones: records } };
2082
2164
  }
2083
2165
 
2084
2166
  /**
@@ -2191,6 +2273,13 @@ export const WRIST_ON_PART_PX = 15;
2191
2273
  * when the wrist is within {@link WRIST_ON_PART_PX} of that part's art, else
2192
2274
  * the distance `--wrist-snap` bound it across.
2193
2275
  */
2276
+ /**
2277
+ * The `inherit` a drafted foot bone takes (issue #349): the shin's position and not its rotation or reflection, so
2278
+ * the shoe keeps its setup angle while the leg is posed — the mode `FEET_STAY_LEVEL` names (issue #348). The
2279
+ * proposer drafts it; which mode a foot takes stays the author's.
2280
+ */
2281
+ export const FOOT_INHERIT = 'noRotationOrReflection';
2282
+
2194
2283
  interface JointArm {
2195
2284
  side: 'r' | 'l';
2196
2285
  shoulder: [number, number];
@@ -2291,40 +2380,18 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
2291
2380
 
2292
2381
  /**
2293
2382
  * One leg chain drafted from joints (issue #241): its name, the side whose
2294
- * joints it runs through (`null` for the one chain that carries both legs,
2295
- * through the midpoints of the two hips, the two knees and the two ankles),
2296
- * its two links and tip in rig px, and the legwear part it meshes.
2383
+ * joints it runs through, its two links and tip in rig px, and the legwear
2384
+ * part its limb takes (issue #349).
2297
2385
  */
2298
2386
  interface LegChain {
2299
2387
  chain: string;
2300
- side: 'r' | 'l' | null;
2388
+ side: 'r' | 'l';
2301
2389
  hip: [number, number];
2302
2390
  knee: [number, number];
2303
2391
  ankle: [number, number];
2304
2392
  part: PartRecord;
2305
2393
  }
2306
2394
 
2307
- /**
2308
- * Whether `m`'s art breaks along the straight line from `a` to `b`: walked
2309
- * one pixel step at a time (the larger of the x and y gaps, in steps of at
2310
- * most one pixel, each point read as {@link isOn} reads it), some point with
2311
- * no art lies between two points with art. Art that only starts or stops
2312
- * along the line — an end point off the art — is no break.
2313
- */
2314
- function artBreaks(m: Mask, a: readonly [number, number], b: readonly [number, number]): boolean {
2315
- const n = Math.ceil(Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1])));
2316
- let seenArt = false;
2317
- let gapAfterArt = false;
2318
- for (let i = 0; i <= n; i++) {
2319
- const t = n === 0 ? 0 : i / n;
2320
- const on = isOn(m, a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]));
2321
- if (on && gapAfterArt) return true;
2322
- if (on) seenArt = true;
2323
- else if (seenArt) gapAfterArt = true;
2324
- }
2325
- return false;
2326
- }
2327
-
2328
2395
  /**
2329
2396
  * The leg chains a keypoint file drafts for the legwear parts, or why none
2330
2397
  * is drafted (issue #241) — the sleeve's {@link armsFromJoints} for the legs.
@@ -2337,14 +2404,11 @@ function artBreaks(m: Mask, a: readonly [number, number], b: readonly [number, n
2337
2404
  * drafted and `why` is `null`: the proposal is the one written before the
2338
2405
  * rule existed.
2339
2406
  *
2340
- * One chain or two is read off the part and the joints. A part carrying one
2341
- * leg gets that leg's chain, `leg_<side>`. A part carrying both gets one
2342
- * chain per leg only when its art breaks ({@link artBreaks}) along the line
2343
- * from r_knee to l_knee and along the line from r_ankle to l_ankle — the
2344
- * legs painted apart at both; otherwise the legs touch, cross or overlap in
2345
- * its art, two chains would pull one stretch of it two ways, and it gets one
2346
- * chain, `leg`, through the midpoints of the hips, the knees and the ankles.
2347
- * Each part gets one note saying which and why.
2407
+ * Every bound leg gets its own chain, `leg_<side>`, and becomes a limb
2408
+ * (issue #349): a part carrying both legs is split between the two limbs by
2409
+ * the cut itself (each pixel to the leg whose bones pass nearest it), so the
2410
+ * single midpoint chain the mesh needed when the legs touched (issue #241) is
2411
+ * not drafted. Each part gets one note saying which and why.
2348
2412
  */
2349
2413
  function legsFromJoints(P: PartSet, lw: readonly PartRecord[], joints: RigJoints): { chains: LegChain[] | null; notes: string[]; why: string | null } {
2350
2414
  const complete: Array<{ side: 'r' | 'l'; hip: [number, number]; knee: [number, number]; ankle: [number, number] }> = [];
@@ -2389,23 +2453,11 @@ function legsFromJoints(P: PartSet, lw: readonly PartRecord[], joints: RigJoints
2389
2453
  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}`);
2390
2454
  continue;
2391
2455
  }
2392
- const [r, l] = on;
2393
- const m = P.alpha(p);
2394
- const kneesBreak = artBreaks(m, r.knee, l.knee);
2395
- const anklesBreak = artBreaks(m, r.ankle, l.ankle);
2396
- if (kneesBreak && anklesBreak) {
2397
- 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 });
2398
- notes.push(
2399
- `${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}`,
2400
- );
2401
- continue;
2402
- }
2403
- const mid = (a: [number, number], b: [number, number]): [number, number] => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
2404
- 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 });
2405
- 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);
2456
+ // Issue #349: two legs on one part are two limbs; the cut gives each pixel to the leg whose bones pass nearest it,
2457
+ // so the legs need not be painted apart, and no midpoint chain stands for both.
2458
+ 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 });
2406
2459
  notes.push(
2407
- `${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; ` +
2408
- `the chain runs hip -> knee -> ankle through the midpoints of the two hips, the two knees and the two ankles, and ${still}`,
2460
+ `${p.name} (${p.from}): one chain per leg, leg_r and leg_l — both ankles lie on its art; each runs hip -> knee -> ankle from its side's joints, the two limbs split the part between them (each pixel to the leg whose bones pass nearest it), and ${still}`,
2409
2461
  );
2410
2462
  }
2411
2463
  return { chains, notes, why: null };
@@ -2503,25 +2555,13 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
2503
2555
  const both = f.arms !== null && f.arms.length === 2 && f.arms[0].part === f.arms[1].part ? f.arms[0].part.name : null;
2504
2556
  if (both !== null) {
2505
2557
  out.push(
2506
- `one handwear part holds both wrists: r_wrist and l_wrist both end on ${both}, so chains sleeve_r and sleeve_l share its mesh and its art bends with both arms; ` +
2507
- `to separate them, cut one sleeve out of ${both} with an assemble.cuts entry (a polygon round it) and propose again`,
2558
+ `one handwear part holds both wrists: r_wrist and l_wrist both end on ${both}, so limbs arm_r and arm_l both take it, and the cut gives each pixel to the arm whose bones pass nearest it; ` +
2559
+ `where the arms touch or cross in its art that split is a line no painting draws — to draw it yourself, cut one sleeve out of ${both} with an assemble.cuts entry (a polygon round it) and name the piece as that arm's from`,
2508
2560
  );
2509
2561
  }
2510
2562
  // Issue #241: the knees and ankles a leg chain was drafted from, and the hips as its first link.
2511
2563
  for (const c of f.legs ?? []) {
2512
- const on = `the ankle${c.side === null ? 's lie' : ' lies'} on ${c.part.name}`;
2513
- if (c.side === null) {
2514
- out.push(`chain leg: link 0 is the midpoint of r_hip and l_hip, ${fmtAt(c.hip)} — the point bone hip stands on`);
2515
- for (const [joint, role, pair] of [
2516
- ['r_knee', 'link 1', 'r_knee and l_knee'],
2517
- ['l_knee', 'link 1', 'r_knee and l_knee'],
2518
- ['r_ankle', 'the tip', 'r_ankle and l_ankle'],
2519
- ['l_ankle', 'the tip', 'r_ankle and l_ankle'],
2520
- ] as const) {
2521
- 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})`);
2522
- }
2523
- continue;
2524
- }
2564
+ const on = `the ankle lies on ${c.part.name}`;
2525
2565
  out.push(`chain leg_${c.side}: link 0 is the ${c.side}_hip joint, ${fmtAt(c.hip)}`);
2526
2566
  for (const [joint, role] of [
2527
2567
  [`${c.side}_knee`, 'link 1'],
package/src/rig.ts CHANGED
@@ -108,6 +108,16 @@
108
108
  * keys, a leaf twin of every bone a carried part binds, and one transform
109
109
  * constraint per part carrying its twins `fraction · turn.x` in world x,
110
110
  * after the config's own constraints. Without the field nothing is added.
111
+ * - **A bone's `inherit`**, only where config.bones declares one (issue
112
+ * #348): rig-c's mode name, written to that bone in rig.json as declared.
113
+ * The bone's setup rotation is then the turn from where rig-c's own poser
114
+ * puts it with a local rotation of 0 — under a mode that drops the parent's
115
+ * rotation that is not the parent's turn — so the bone still stands
116
+ * upright, or along its tip, at the setup pose. Where the idle gives the
117
+ * bone a control, the mode goes on the control, the bone that hangs from the
118
+ * declared parent, and the bone under it inherits normally: the idle's keys
119
+ * moved onto the control still turn the bone. Without the field no byte of
120
+ * any output moves.
111
121
  *
112
122
  * Every part image is padded by {@link PAD} transparent pixels on each side
113
123
  * before it is meshed or placed, and the padded image is what `images/`
@@ -126,6 +136,7 @@ import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, t
126
136
  import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
127
137
  import { type Problem, refuseIfAny } from './errors.ts';
128
138
  import { eyeLineDegrees } from './headframe.ts';
139
+ import type { RigBoneInherit } from 'rig-c/src/rig.ts';
129
140
  import { f32 } from 'rig-c/compile';
130
141
  import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
131
142
  import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
@@ -382,6 +393,8 @@ interface Bone {
382
393
  parent: string | null;
383
394
  x: number;
384
395
  y: number;
396
+ /** config.bones' `inherit`, on the bone that hangs from the declared parent (issue #348); absent when none is declared. */
397
+ inherit?: RigBoneInherit;
385
398
  }
386
399
 
387
400
  export interface RigBone {
@@ -393,6 +406,8 @@ export interface RigBone {
393
406
  rotation?: number;
394
407
  x: number;
395
408
  y: number;
409
+ /** rig-c's inherit mode, written only where config.bones declares one (issue #348). */
410
+ inherit?: RigBoneInherit;
396
411
  }
397
412
 
398
413
  export interface WeightEntry {
@@ -944,7 +959,7 @@ export function buildRig(
944
959
  chains.set(e.chain, names);
945
960
  chainLines.set(e.chain, polyline);
946
961
  } else {
947
- B.set(e.name, { name: e.name, parent: e.parent, x: e.at[0], y: e.at[1] });
962
+ B.set(e.name, { name: e.name, parent: e.parent, x: e.at[0], y: e.at[1], ...(e.inherit === undefined ? {} : { inherit: e.inherit }) });
948
963
  if (e.tip !== undefined) {
949
964
  if (same(e.at, e.tip)) {
950
965
  fail(
@@ -1103,9 +1118,10 @@ export function buildRig(
1103
1118
  // rigc's line (`ctlRemedies`, src/build.ts).
1104
1119
  refuseIfAny(problems);
1105
1120
  for (const k of controls) {
1106
- const b = B.get(k) as Bone;
1121
+ const { inherit, ...b } = B.get(k) as Bone;
1107
1122
  const ctl = `${k}${CONTROL_SUFFIX}`;
1108
- B.set(ctl, { name: ctl, parent: b.parent, x: b.x, y: b.y });
1123
+ // Issue #348: the declared mode belongs to the bone hanging from the declared parent, which is now the control.
1124
+ B.set(ctl, { name: ctl, parent: b.parent, x: b.x, y: b.y, ...(inherit === undefined ? {} : { inherit }) });
1109
1125
  B.set(k, { ...b, parent: ctl });
1110
1126
  }
1111
1127
  moveKeysToControls(motion, controls);
@@ -1197,11 +1213,22 @@ export function buildRig(
1197
1213
  const inherits = a === undefined && !faceRoots.has(b.name) && faceFramed.has(b.parent);
1198
1214
  if (faceRoots.has(b.name) || inherits) faceFramed.add(b.name);
1199
1215
  const target = a?.rotation ?? faceTurn.get(b.name) ?? (inherits ? up : 0);
1200
- const rotation = places(normaliseDegrees(target - up));
1201
- turn.set(b.name, places(up + rotation));
1202
1216
  const frame = computeExactFrameTransforms(bones).get(b.parent) as BoneTransform;
1203
1217
  const [x, y] = toBoneLocal(frame, spineX(b.x), spineY(b.y));
1204
- bones.push({ name: b.name, parent: b.parent, ...(a === undefined ? {} : { length: a.length }), ...(a !== undefined || rotation !== 0 ? { rotation } : {}), x: places(x), y: places(y) });
1218
+ if (b.inherit === undefined) {
1219
+ const rotation = places(normaliseDegrees(target - up));
1220
+ turn.set(b.name, places(up + rotation));
1221
+ bones.push({ name: b.name, parent: b.parent, ...(a === undefined ? {} : { length: a.length }), ...(a !== undefined || rotation !== 0 ? { rotation } : {}), x: places(x), y: places(y) });
1222
+ continue;
1223
+ }
1224
+ // Issue #348: where rig-c's poser puts the bone with no rotation of its own, under its declared mode — the
1225
+ // parent's turn under `normal` and the two scale modes, 0 where the mode drops the parent's rotation — and the
1226
+ // turn from there to the target; its world turn is then read back off the same poser, not summed.
1227
+ const placedAt = { name: b.name, parent: b.parent, x: places(x), y: places(y), inherit: b.inherit };
1228
+ const posed = (r: number): BoneTransform => computeExactFrameTransforms([...bones, { ...placedAt, rotation: r }]).get(b.name) as BoneTransform;
1229
+ const rotation = places(normaliseDegrees(target - normaliseDegrees(posed(0).worldRotation)));
1230
+ turn.set(b.name, places(normaliseDegrees(posed(rotation).worldRotation)));
1231
+ bones.push({ name: b.name, parent: b.parent, ...(a === undefined ? {} : { length: a.length }), ...(a !== undefined || rotation !== 0 ? { rotation } : {}), x: places(x), y: places(y), inherit: b.inherit });
1205
1232
  }
1206
1233
  // ---- the head's up axis (issue #250) ----------------------------------
1207
1234
  // `head` keeps its frame, so every key on it and under it — the idle, the blink's brow drop, an authored
@@ -1892,7 +1919,15 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1892
1919
  const read = rig.bones.map((b) => ({ ...b, rotation: (b.rotation ?? 0) * sense }));
1893
1920
  const world = computeExactFrameTransforms(read);
1894
1921
  const turn = new Map<string, number>();
1895
- for (const b of rig.bones) turn.set(b.name, placed((b.parent === undefined ? 0 : (turn.get(b.parent) as number)) + (b.rotation ?? 0), OFFSET_PLACES));
1922
+ // A bone with a declared inherit mode (issue #348) is read off rig-c's poser, which honours the mode; every other is the sum, as before.
1923
+ for (const b of rig.bones) {
1924
+ turn.set(
1925
+ b.name,
1926
+ b.inherit === undefined
1927
+ ? placed((b.parent === undefined ? 0 : (turn.get(b.parent) as number)) + (b.rotation ?? 0), OFFSET_PLACES)
1928
+ : placed(normaliseDegrees((world.get(b.name) as BoneTransform).worldRotation), OFFSET_PLACES),
1929
+ );
1930
+ }
1896
1931
  const origin = (bone: string): BoneTransform => world.get(bone) as BoneTransform;
1897
1932
  const out = (m: BoneTransform, x: number, y: number): [number, number] => {
1898
1933
  const [wx, wy] = toWorld(m, x, y);
@@ -1902,7 +1937,7 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1902
1937
  const bones: RigBone[] = rig.bones.filter((b) => b.name !== HEAD_UP_BONE).map((b) => {
1903
1938
  if (b.parent === undefined) return { name: b.name, x: b.x, y: b.y };
1904
1939
  const [x, y] = out(origin(b.parent), b.x, b.y);
1905
- return { name: b.name, parent: b.parent, x, y };
1940
+ return { name: b.name, parent: b.parent, x, y, ...(b.inherit === undefined ? {} : { inherit: b.inherit }) };
1906
1941
  });
1907
1942
  const slotBone = new Map(rig.slots.map((s) => [s.name, s.bone]));
1908
1943
  const skin: RigSpec['skins']['default'] = {};
package/src/tags.ts CHANGED
@@ -187,6 +187,9 @@ export function isAuthorTag(name: string): boolean {
187
187
  * other names the accepted v3 tags and the author form.
188
188
  */
189
189
  export function layerTagRequirement(name: string): string {
190
+ if (name.endsWith(HIDDEN_SUFFIX)) {
191
+ return `is a hidden-art layer's name (issue #359) for ${JSON.stringify(name.slice(0, -HIDDEN_SUFFIX.length))}, which is no layer name; ${HIDDEN_NAME_RULE} is required`;
192
+ }
190
193
  if (name.startsWith(AUTHOR_PREFIX)) {
191
194
  return `is an author-made layer's name with the label ${JSON.stringify(name.slice(AUTHOR_PREFIX.length))}; ${AUTHOR_LABEL_RULE} is required after "${AUTHOR_PREFIX}"`;
192
195
  }
@@ -205,3 +208,47 @@ export const AUTHOR_NO_ROLE =
205
208
  export function authorLayerLine(name: string): string {
206
209
  return `author-made: layer "${name}" — ${AUTHOR_NO_ROLE}`;
207
210
  }
211
+
212
+ /**
213
+ * ⭐ **A hidden-art layer** (issue #359) holds the art of a layer where
214
+ * something else covers it — the forearm under the sleeve, the inside of the
215
+ * knee, the shirt under a scarf's tail — painted by an artist who has it,
216
+ * because See-through cannot see it. It is named by the layer it belongs to
217
+ * and {@link HIDDEN_SUFFIX}: `handwear-r:hidden` is `handwear-r`'s art where
218
+ * it is covered, `author:hand:hidden` is `author:hand`'s.
219
+ *
220
+ * Why this form and not `author:` with a declared target: the name alone says
221
+ * what the layer is and whose it is, so every reader knows at read time that
222
+ * it is not a part (the proposer never drafts one from it) and which layer it
223
+ * belongs to, with no config field to agree with it. The suffix holds a colon,
224
+ * which no v3 tag holds and an author-made label may not hold
225
+ * ({@link AUTHOR_LABEL_RULE}), so `<name>:hidden` can never be read as a tag,
226
+ * a side, or a label.
227
+ *
228
+ * It is never drawn and never a part. `assemble` reads it as the art the
229
+ * layer's part takes where a stage takes art from under something: a cut's
230
+ * `under` (issue #193) and a limb segment's cap (issue #349) — see
231
+ * `hiddenArt` in `src/assemble.ts`.
232
+ */
233
+ export const HIDDEN_SUFFIX = ':hidden';
234
+
235
+ /** A hidden-art layer's name read: the layer it belongs to. */
236
+ export interface HiddenTagReading {
237
+ /** The name as the layer carries it: `handwear-r:hidden`. */
238
+ name: string;
239
+ /** The layer it belongs to: `handwear-r`, a v3 tag or an author-made layer's name. */
240
+ of: string;
241
+ }
242
+
243
+ /**
244
+ * Read a layer name as a hidden-art layer's, or `null` when it does not end in
245
+ * {@link HIDDEN_SUFFIX} or what precedes it is no layer name ({@link readLayerTag}).
246
+ */
247
+ export function readHiddenTag(name: string): HiddenTagReading | null {
248
+ if (!name.endsWith(HIDDEN_SUFFIX)) return null;
249
+ const of = name.slice(0, -HIDDEN_SUFFIX.length);
250
+ return readLayerTag(of) === null ? null : { name, of };
251
+ }
252
+
253
+ /** What a hidden-art layer's name must be, as a refusal quotes it. */
254
+ export const HIDDEN_NAME_RULE = `"<layer>${HIDDEN_SUFFIX}", where <layer> is a See-through v3 tag or "${AUTHOR_PREFIX}<label>" that the same input also holds`;