@rig-c/parts 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/STABILITY.md CHANGED
@@ -111,7 +111,7 @@ The fields, object by object (a bold path is where the object sits; `<part>` is
111
111
  part name, `[i]` an entry of a list):
112
112
 
113
113
  <!-- stable:config -->
114
- - **config** — required by the full loader, in front of rig and build: `key`, `assemble`, `bones`, `meshes`, `regions`, `motion`; optional: `generation`, `seethrough`, `constraints`, `turn`, `scene`
114
+ - **config** — required by the full loader, in front of rig and build: `key`, `assemble`, `bones`, `meshes`, `regions`, `motion`; optional: `generation`, `seethrough`, `limbs`, `constraints`, `turn`, `scene`
115
115
  - **generation** — required: `checkpoint`, `loras`, `trigger`, `identity`, `sampler`, `costume`, `negative_extra`, `style`, `negative_pose`, `latent`, `seed`; optional: `pose`, `control`
116
116
  - **generation.loras[i]** — required: `name`, `strength`; optional: `strength_clip`
117
117
  - **generation.sampler** — required: `steps`, `cfg`, `sampler`, `scheduler`
@@ -124,7 +124,9 @@ part name, `[i]` an entry of a list):
124
124
  - **assemble.patches[i].draw**, when an object — required: `before`
125
125
  - **assemble.cuts[i]** — required: `from`, `into`, `polygon`, `draw`, `overlap`; optional: `under`
126
126
  - **assemble.cuts[i].draw**, when an object — required: `before`
127
- - **bones[i]**, a single bone — required: `name`, `parent`, `at`; optional: `tip`
127
+ - **limbs.&lt;limb&gt;** — required: `segments` (issue #349)
128
+ - **limbs.&lt;limb&gt;.segments[i]** — required: `part`, `from`, `bone`; required below a joint (i > 0): `joint`; optional below a joint: `range`, `cap`
129
+ - **bones[i]**, a single bone — required: `name`, `parent`, `at`; optional: `tip`, `inherit`
128
130
  - **bones[i]**, a chain — required: `chain`, `parent`, `points`, `tip`
129
131
  - **meshes.&lt;part&gt;** — exactly one of `grid`, `contour`, `auto`; required in every mode: `r`, `segments`; optional in every mode: `exponent`, `rule`; optional with `contour` or `auto`: `ribs`; optional with `grid` or `contour`: `influences` (issue #284; beside `auto` it is refused, CONFIG_INFLUENCES_MODE); optional with `grid`: `stray` (issue #303; beside `contour` or `auto` it is refused, CONFIG_STRAY_MODE)
130
132
  - **meshes.&lt;part&gt;.influences** — optional, at least one: `maxInfluences` (a whole number, 1 or more, or null for no cap), `floor` ("cut" or "continuous")
@@ -154,7 +156,7 @@ part name, `[i]` an entry of a list):
154
156
  - **turn** — required: `pivot`, `depths`
155
157
  - **turn.depths.&lt;part&gt;** — a See-through depth
156
158
  - **scene** — no field (`{}`; a record or an annotation only)
157
- - **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `motion` under the rules above, `turn` with `--full`/`--head`, and `notes`
159
+ - **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `limbs` under `--keypoints` where an arm or a leg is drafted from its joints (issue #349), `motion` under the rules above, `turn` with `--full`/`--head`, and `notes`
158
160
  <!-- /stable:config -->
159
161
 
160
162
  `TY14` holds the set of field names in backticks above to the set of names the
@@ -171,10 +173,10 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
171
173
 
172
174
  | file | written by | what is stable |
173
175
  | --- | --- | --- |
174
- | `parts.json`, `parts/<name>.png` | `assemble`, `build` | one record per part: `from` (`<run>:<tag>` with a See-through v3 tag, `<run>:author:<label>` for a layer the author made — issue #256, read by no role rule — or `painting:<name>` for a patch), `cut_from` on a cut's piece only (the plan part it was cut from, issue #230), the box `x`, `y`, `w`, `h` (rig px), the pixel counts `src/parts.ts` documents (`opaque_px`, `visible_px`, `occluded_px`, `projected_core_px`, `source_px_taken`, `visible_not_projected_px`, `refused_drift_px`, `merged_px`, `seam_override_px`, and `fringe_pushed_back` when not empty); `rig_size`, `scale_rig_per_source`, `ghost_px`, `recomposite`, and `scene` (`figure_alpha_above`, `figure_px`, `figure_mask`) under `config.scene` only, with the figure's mask `figure_rig.png` beside the recomposite (issue #242) |
175
- | `rig/rig.json`, `rig/motion.json`, `rig/images/` | `rig`, `build`, `compose` | rig-c's rig spec and motion spec, `spec` `rigc-rig/1` and `rigc-motion/1`: the format is rig-c's, and this package writes only what rig-c's parser accepts |
176
+ | `parts.json`, `parts/<name>.png` | `assemble`, `build` | one record per part: `from` (`<run>:<tag>` with a See-through v3 tag, `<run>:author:<label>` for a layer the author made — issue #256, read by no role rule — or `painting:<name>` for a patch), `cut_from` on a cut's piece only (the plan part it was cut from, issue #230), `limb` on a limb segment's part only (issue #349: `name`, `segment`, `of`, `bone`, and below a joint `joint` and `cap` — `at`, `width`, `radius`, `box`, `layer_px`, `image_px` with a cap image, `missing_px`), the box `x`, `y`, `w`, `h` (rig px), the pixel counts `src/parts.ts` documents (`opaque_px`, `visible_px`, `occluded_px`, `projected_core_px`, `source_px_taken`, `visible_not_projected_px`, `refused_drift_px`, `merged_px`, `seam_override_px`, and `fringe_pushed_back` when not empty); `rig_size`, `scale_rig_per_source`, `ghost_px`, `recomposite`, and `scene` (`figure_alpha_above`, `figure_px`, `figure_mask`) under `config.scene` only, with the figure's mask `figure_rig.png` beside the recomposite (issue #242) |
177
+ | `rig/rig.json`, `rig/motion.json`, `rig/images/` | `rig`, `build`, `compose` | rig-c's rig spec and motion spec, `spec` `rigc-rig/1` and `rigc-motion/1`: the format is rig-c's, and this package writes only what rig-c's parser accepts; a bone carries `inherit` only where config.bones declares one, rig-c's mode name as written (issue #348) |
176
178
  | `rig/mesh_report.json` | `rig`, `build` | one row per mesh: `part`, `vertices`, `triangles`, `hull`, `bones`, `max_influences`, `mean_influences`, `art_coverage`, then `grid` (a lattice); `mode` "contour" with `params`, `contour`, `regions` (`params.maxEdge` and `contour.outlineCut` only when the mesh declares `contour.maxEdge`; `params.bridges` and `contour.bridges` — `islands`, `units`, `joins`, and `faint` only when an island was left out as faint — only when it declares `contour.bridges`); or `mode` "auto" with `settings`, `source`, `result`, `residuals`, `worst_residual`, `worst_region`, `motion_amplitude`, `termination`, `weights`, `regions`, `deformation`, `quality_report` (rig-c's `mesh-quality-report/1` document whole), `motion_report`, and, only when they apply, `stray_cleared` (after `source`, when `source.stray` is declared), `skinning_residual` and `replay` (`AutoMeshReport` in `src/rig.ts`); in every mode, `exponent` when the mesh declares one (after `grid`, or after `mode`); `influences`, as declared, when a lattice or contour mesh declares it (after `exponent`, or where `exponent` would be); `stray` and `stray_cleared` when a lattice mesh declares `stray` (after `influences`, or where `influences` would be); `ribs` when a contour or auto mesh declares them (after `exponent`, or after `mode`), and then `contour.ribs` (`source.contour.ribs` in the automatic mode) |
177
- | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures — `seam_mean`, `seam_px_over_40`, `seam_px_over_80` and, from issue #216, `seam_frame_scale`, the frame scale the bar divides the count by), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_border` after the seam figures when `parts.json` declares a scene (issue #242), `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `ANIMATIONS_DO_NOT_FOLD` after them when `motion.json` holds an animation, the idle included — on every rig with an idle since issue #333 (issue #320: `status`, `animations`, `fps`, `physics_left_out`, `folding`, `folding_undeclared`, `declared_not_folding`, `bar`), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
179
+ | `check/check.json` and the rest of `check/` | `check`, `build`, `compose` | the reference's fields in its order (`gate_spine_html_green`, `rigc_entry`, `pack_mode`, `loop_max_diff`, the seam figures — `seam_mean`, `seam_px_over_40`, `seam_px_over_80` and, from issue #216, `seam_frame_scale`, the frame scale the bar divides the count by), `loop_physics` after `loop_max_diff` when the rig declares a physics constraint, `seam_border` after the seam figures when `parts.json` declares a scene (issue #242), `seam_pairs` after the seam figures when the seam bar fails or `--seam-pairs` asks (each row's picture under `check/seam/`; `--keep-stills` leaves the setup-pose still and its `frames.json` under `check/setup_still/`), then the judgement lines (`BREATH_VISIBLE`, `BLINK_NO_HOLE`, `CHAIN_LAG`, `TIP_OVER_ROOT`, `STILL_REGIONS_DARK`, `TEXTURE_STRETCH`), `ANIMATIONS_DO_NOT_FOLD` after them when `motion.json` holds an animation, the idle included — on every rig with an idle since issue #333 (issue #320: `status`, `animations`, `fps`, `physics_left_out`, `folding`, `folding_undeclared`, `declared_not_folding`, `bar`), `FEET_STAY_LEVEL` after it when a `footwear` part rides a bone that hangs from another and `motion.json` holds an animation (issue #348: `status`, `reason` on a SKIP only, `animations`, `fps`, `physics_left_out`, `bar_degrees`, `bar`, `feet` — each `part`, `bone`, `hangs_from`, `status`, `frames_posed`, `worst`), `LIMB_ONE_BONE` after it (and after `STRESS_OFF_CHAIN` where that is written) when `parts.json` holds a limb segment's part (issue #349: `status`, `parts` — each `part`, `limb`, `bone`, `status`, `others`), `RECOMPOSITE_HOLES` (its first figure `declared_scene` when `parts.json` declares a scene), `SETUP_POSE_VS_SOURCE`, `skipped`, a `requirements` block under `--requirements`, and `PASS`; `check/build/skeleton.json`, `.atlas` and `.png` are the packed artifact rig-c writes |
178
180
  | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `turn` with `--full`/`--head`, and `notes` |
179
181
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
180
182
  | `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate` (with `bridges` — `units`, `joins` of `span` and `area_px2`, `area_px2`, `overdraw`, and `faint` only when an island was left out as faint — only when the candidate is a bridged one, issue #326), `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note`; and `not_compared` (issue #307), only when the config holds an `auto` or `contour` entry: per such entry, in config order, `part`, `mode` and `note` — the part was neither drafted nor compared, and a config of lattices alone writes no such key |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/parts",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -93,7 +93,9 @@ under that.
93
93
  an arm raised, seated in a skirt that does not start at the hip: add
94
94
  `--keypoints <file>` (body-18 joints in painting px, each observed, occluded
95
95
  or missing; AUTHORING §3, *A posed figure*) so the neck, hip, chest and
96
- sleeves follow the pose and LINT reads the joints, not screen y. A clean
96
+ sleeves follow the pose and LINT reads the joints, not screen y. Each arm and
97
+ leg it places comes back as `limbs` (AUTHORING §3, *Limbs*): paste it, assemble
98
+ again, and read each `limb cap:` line for the pixels a cap is missing. A clean
97
99
  LINT does not show a seated hip is right; the overlay does. Never write a joint you did not get from an
98
100
  estimator or the painting: say `missing` and let the rule stand in.
99
101
  6. Correct against `render/landmarks.png` and `note:` lines; a
package/src/assemble.ts CHANGED
@@ -91,11 +91,12 @@
91
91
  *
92
92
  * Pure: no clock, no randomness, no file access. The CLI reads and writes.
93
93
  */
94
- import type { CharacterConfig, Cut, EarlyConfig, Extend, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
94
+ import type { BoneEntry, CharacterConfig, Cut, EarlyConfig, Extend, Limb, Patch, PlanEntry, Run, SeeThrough } from './config.ts';
95
95
  import { type Problem, refuseIfAny } from './errors.ts';
96
96
  import { figuresPhrase, implausibleRules, type Layer, type LayerFigures, layerFigures, type LayerSet, NEAR_WHITE_MIN, OPAQUE_ALPHA_ABOVE, ruleSummary } from './layers.ts';
97
97
  import { squarePad } from './inputs.ts';
98
- import { PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord } from './parts.ts';
98
+ import { type CapRecord, type LimbRecord, PAINTING_RUN, type PartRecord, type PartsFile, type RecompositeRecord, type SceneRecord } from './parts.ts';
99
+ import { type BonePlace, bonePlaces, capBox, inDisc, type JointLine, jointLine, limbPolyline, limbWidth, nearestLimb, pastJoint, segmentOf } from './limbs.ts';
99
100
  import {
100
101
  alphaComposite,
101
102
  connectedComponents,
@@ -240,6 +241,12 @@ export interface AssembleInput {
240
241
  projectRule: ProjectRule;
241
242
  /** `config.scene` (issue #242): the painting is a scene, so the recomposite is read inside the figure ({@link sceneFigure}). Absent or false: nothing changes. */
242
243
  scene?: boolean;
244
+ /** `config.limbs` (issue #349): the parts cut at their joints ({@link LimbCount}). Absent or empty: nothing changes. */
245
+ limbs?: Record<string, Limb>;
246
+ /** `config.bones`, read for the limbs' joints; required with `limbs`. */
247
+ bones?: BoneEntry[];
248
+ /** Each segment's `cap` image, decoded by the caller, keyed `<limb>/<segment index>`; a segment naming a cap with none here is refused (`ASSEMBLE_LIMB_CAP_PRESENT`). */
249
+ caps?: ReadonlyMap<string, Raster>;
243
250
  }
244
251
 
245
252
  /** The derived numbers of the two runs' geometry. */
@@ -1537,7 +1544,10 @@ export function cutPatch(patch: Patch, srcr: Raster, silhouette: Mask | null): {
1537
1544
  }
1538
1545
 
1539
1546
  /** One place in the draw order: a plan part, a patch or a cut's new part, by its index in its list. */
1540
- export type DrawSlot = { plan: number } | { patch: number } | { cut: number };
1547
+ export type PlanSlot = { plan: number } | { patch: number } | { cut: number };
1548
+
1549
+ /** A slot of the draw order once the limbs are in it (issue #349, {@link limbOrder}). */
1550
+ export type DrawSlot = PlanSlot | { limb: number };
1541
1551
 
1542
1552
  /**
1543
1553
  * The draw order of plan parts, patches and cuts together, back to front:
@@ -1548,8 +1558,8 @@ export type DrawSlot = { plan: number } | { patch: number } | { cut: number };
1548
1558
  * order of plan parts and patches it always was. The loader has already
1549
1559
  * refused a `before` that names no plan part.
1550
1560
  */
1551
- export function drawOrder(plan: readonly string[], patches: readonly Patch[], cuts: readonly Cut[] = []): DrawSlot[] {
1552
- const out: DrawSlot[] = [];
1561
+ export function drawOrder(plan: readonly string[], patches: readonly Patch[], cuts: readonly Cut[] = []): PlanSlot[] {
1562
+ const out: PlanSlot[] = [];
1553
1563
  const place = (at: (draw: Patch['draw']) => boolean): void => {
1554
1564
  patches.forEach((q, i) => {
1555
1565
  if (at(q.draw)) out.push({ patch: i });
@@ -1703,6 +1713,286 @@ export function cutBand(polygonMask: Mask, overlap: number): Mask {
1703
1713
  return band;
1704
1714
  }
1705
1715
 
1716
+ /** One layer's part as the stage builds it, before it is placed: its key, pixels, projection figures, merge trace and, when the layer is cut, the rig pixels it owns. */
1717
+ interface Built {
1718
+ key: string;
1719
+ r: Raster;
1720
+ st: ProjectionStats;
1721
+ extendKeys: string[];
1722
+ trace: MergeTrace;
1723
+ merged: number;
1724
+ /** The rig pixels this piece of the layer owns when the layer is cut, null when it is not: the counts are split by it. */
1725
+ region: Mask | null;
1726
+ }
1727
+
1728
+ // ---------------------------------------------------------------------------
1729
+ // limbs — a declared limb's parts cut at its joints (issue #349, src/limbs.ts)
1730
+ // ---------------------------------------------------------------------------
1731
+
1732
+ /** One part a limb segment makes, in `config.limbs` order (limb, then segment). */
1733
+ export interface LimbPart {
1734
+ limb: string;
1735
+ segment: number;
1736
+ part: string;
1737
+ /** The part whose pixels it takes. */
1738
+ of: string;
1739
+ record: LimbRecord;
1740
+ }
1741
+
1742
+ /** What the limbs did to one part they took: its opaque pixels and each segment's share (`limbLines`). */
1743
+ export interface LimbCount {
1744
+ of: string;
1745
+ before: number;
1746
+ shares: Array<{ part: string; px: number }>;
1747
+ /** The joints whose cap this part's segments carry, in `config.limbs` order. */
1748
+ joints: Array<{ limb: string; joint: string; part: string; cap: CapRecord }>;
1749
+ }
1750
+
1751
+ /**
1752
+ * The lines `assemble` prints for the limbs (issue #349), one per part a limb
1753
+ * took — ` limb cut: "<of>" opaque <n> = "<part>" <px> + …` — and one per
1754
+ * joint, its cap's pixels by where they came from. Empty without limbs.
1755
+ */
1756
+ export function limbLines(counts: readonly LimbCount[]): string[] {
1757
+ const out: string[] = [];
1758
+ for (const c of counts) {
1759
+ out.push(` limb cut: "${c.of}" opaque ${c.before} = ${c.shares.map((q) => `"${q.part}" ${q.px}`).join(' + ')}`);
1760
+ for (const j of c.joints) {
1761
+ const k = j.cap;
1762
+ const from = k.image_px === undefined ? `${k.layer_px} px from the layer` : `${k.image_px} px from the cap image, ${k.layer_px} px from the layer`;
1763
+ const miss =
1764
+ k.missing_px === 0
1765
+ ? 'none missing'
1766
+ : `${k.missing_px} px missing — the layer has no pixel there (hidden in the painting, or past the limb's outline), and the tool invents none: paint them into a ${k.box[2]}x${k.box[3]} image whose pixel (i, j) is rig pixel (${k.box[0]} + i, ${k.box[1]} + j), and name it in the segment's "cap"`;
1767
+ out.push(
1768
+ ` limb cap: "${j.limb}" joint "${j.joint}" at [${k.at[0]}, ${k.at[1]}], on "${j.part}": a disc of radius ${k.radius} px (the limb ${k.width} px across the joint line), box [${k.box.join(', ')}] — on the upper side of the line ${from}, ${miss}`,
1769
+ );
1770
+ }
1771
+ }
1772
+ return out;
1773
+ }
1774
+
1775
+ /**
1776
+ * The draw order with the limbs in it (issue #349): each part a limb takes
1777
+ * leaves the order, and each limb's parts stand where its first segment's
1778
+ * part stood, lowest segment first (drawn behind), so the upper is drawn over
1779
+ * the lower at every joint; limbs whose first segment takes one part stand
1780
+ * there in `config.limbs` order. Without limbs, `order` as it was.
1781
+ */
1782
+ export function limbOrder(order: readonly DrawSlot[], plan: readonly PlanEntry[], cuts: readonly Cut[], parts: readonly LimbPart[]): DrawSlot[] {
1783
+ if (parts.length === 0) return [...order];
1784
+ const nameOf = (d: DrawSlot): string | null => ('plan' in d ? plan[d.plan][0] : 'cut' in d ? cuts[d.cut].into : null);
1785
+ const taken = new Set(parts.map((q) => q.of));
1786
+ const out: DrawSlot[] = [];
1787
+ for (const d of order) {
1788
+ const n = nameOf(d);
1789
+ if (n === null || !taken.has(n)) {
1790
+ out.push(d);
1791
+ continue;
1792
+ }
1793
+ const limbs = [...new Set(parts.filter((q) => q.segment === 0 && q.of === n).map((q) => q.limb))];
1794
+ for (const l of limbs) {
1795
+ const mine = parts.map((q, i) => ({ q, i })).filter(({ q }) => q.limb === l);
1796
+ for (const { i } of [...mine].reverse()) out.push({ limb: i });
1797
+ }
1798
+ }
1799
+ return out;
1800
+ }
1801
+
1802
+ /**
1803
+ * Cut every declared limb (module comment of `src/limbs.ts`): each part a limb
1804
+ * takes is split among the limbs that take it (nearest polyline), then each
1805
+ * limb's share among its segments (the joint lines), and each segment below a
1806
+ * joint gets its cap. Every refusal is collected and thrown at once:
1807
+ * `ASSEMBLE_LIMB_TAKES` (a limb takes a part with no pixel), `ASSEMBLE_LIMB_JOINT`
1808
+ * (no joint line: the upper bone has no length, or the limb folds back on
1809
+ * itself), `ASSEMBLE_LIMB_JOINT_ON_ART` (the joint is not on the limb's art,
1810
+ * so it has no width), `ASSEMBLE_LIMB_PIXELS` (a segment gets no opaque
1811
+ * pixel), `ASSEMBLE_LIMB_CAP_PRESENT`, `ASSEMBLE_LIMB_CAP_SIZE` and
1812
+ * `ASSEMBLE_LIMB_CAP_EMPTY` (a cap image the caller did not hand over, not the
1813
+ * box's size, or transparent over every cap pixel).
1814
+ */
1815
+ function cutLimbs(
1816
+ limbs: Readonly<Record<string, Limb>>,
1817
+ bones: readonly BoneEntry[],
1818
+ caps: ReadonlyMap<string, Raster>,
1819
+ W: number,
1820
+ H: number,
1821
+ builtOf: (name: string) => Built | null,
1822
+ opaqueOf: (r: Raster) => number,
1823
+ mergedIn: (b: Built, region: Mask) => number,
1824
+ count: (m: Mask | null, region: Mask) => number,
1825
+ ): { parts: LimbPart[]; pieces: Built[]; counts: LimbCount[] } {
1826
+ const names = Object.keys(limbs);
1827
+ if (names.length === 0) return { parts: [], pieces: [], counts: [] };
1828
+ const problems: Problem[] = [];
1829
+ const places = bonePlaces(bones);
1830
+ const parts: LimbPart[] = [];
1831
+ for (const l of names) {
1832
+ limbs[l].segments.forEach((sg, k) =>
1833
+ parts.push({ limb: l, segment: k, part: sg.part, of: sg.from, record: { name: l, segment: k, of: sg.from, bone: sg.bone, ...(k === 0 ? {} : { joint: sg.joint as string }) } }),
1834
+ );
1835
+ }
1836
+ const polylines = new Map(names.map((l) => [l, limbPolyline(limbs[l], places)]));
1837
+ // The joint line above each segment (null above the first).
1838
+ const lines = new Map<string, Array<JointLine | null>>();
1839
+ for (const l of names) {
1840
+ const segs = limbs[l].segments;
1841
+ lines.set(
1842
+ l,
1843
+ segs.map((sg, k) => {
1844
+ if (k === 0) return null;
1845
+ const up = places.get(segs[k - 1].bone) as BonePlace;
1846
+ const me = places.get(sg.bone) as BonePlace;
1847
+ const end = me.end ?? (k + 1 < segs.length ? (places.get(segs[k + 1].bone) as BonePlace).at : null);
1848
+ const line = jointLine(up.at, me.at, end);
1849
+ if (line === null) {
1850
+ problems.push({
1851
+ code: 'ASSEMBLE_LIMB_JOINT',
1852
+ object: `config.limbs.${l}.segments[${k}] (joint "${String(sg.joint)}")`,
1853
+ detail: `has no joint line: "${segs[k - 1].bone}" at [${up.at.join(', ')}] and "${sg.bone}" at [${me.at.join(', ')}]${end === null ? '' : ` ending at [${end.join(', ')}]`} ${up.at[0] === me.at[0] && up.at[1] === me.at[1] ? 'stand on one point, so the upper bone has no direction' : 'point straight back at each other, so the two directions cancel'}; bones that give the limb an axis at the joint are required`,
1854
+ });
1855
+ }
1856
+ return line;
1857
+ }),
1858
+ );
1859
+ }
1860
+ refuseIfAny(problems);
1861
+ // Each part a limb takes, in the order the limbs first take them.
1862
+ const sources = [...new Set(parts.map((q) => q.of))];
1863
+ const pieces: Array<Built | null> = parts.map(() => null);
1864
+ const rasters = parts.map(() => newRaster(W, H));
1865
+ const regions = parts.map(() => newMask(W, H));
1866
+ /** For each part taken, which limb (index into names) owns each pixel, -1 where it has none. */
1867
+ const owners = new Map<string, Int32Array>();
1868
+ const counts: LimbCount[] = [];
1869
+ for (const src of sources) {
1870
+ const b = builtOf(src);
1871
+ if (b === null) continue;
1872
+ const on = names.filter((l) => limbs[l].segments.some((sg) => sg.from === src));
1873
+ const own = new Int32Array(W * H).fill(-1);
1874
+ const before = opaqueOf(b.r);
1875
+ if (before === 0) {
1876
+ problems.push({ code: 'ASSEMBLE_LIMB_TAKES', object: `part "${src}" (config.limbs.${on[0]})`, detail: 'has no opaque pixel to cut; a part with art is required — the limb takes its pixels' });
1877
+ continue;
1878
+ }
1879
+ const lns = on.map((l) => polylines.get(l) as Array<[number, number]>);
1880
+ for (let y = 0; y < H; y++) {
1881
+ for (let x = 0; x < W; x++) {
1882
+ const p = y * W + x;
1883
+ if (b.r.data[p * 4 + 3] === 0) continue;
1884
+ const li = on.length === 1 ? 0 : nearestLimb(lns, x, y);
1885
+ const l = on[li];
1886
+ own[p] = names.indexOf(l);
1887
+ const segs = limbs[l].segments;
1888
+ const first = segs.findIndex((sg) => sg.from === src);
1889
+ let last = first;
1890
+ while (last + 1 < segs.length && segs[last + 1].from === src) last++;
1891
+ const k = segmentOf(lines.get(l) as Array<JointLine | null>, first, last, x, y);
1892
+ const at = parts.findIndex((q) => q.limb === l && q.segment === k);
1893
+ rasters[at].data.set(b.r.data.subarray(p * 4, p * 4 + 4), p * 4);
1894
+ if (b.region === null || b.region.data[p] === 1) regions[at].data[p] = 1;
1895
+ }
1896
+ }
1897
+ owners.set(src, own);
1898
+ const shares = parts.map((q, i) => ({ q, i })).filter(({ q }) => q.of === src);
1899
+ const counted = shares.map(({ q, i }) => ({ part: q.part, px: opaqueOf(rasters[i]) }));
1900
+ const sum = counted.reduce((a, c) => a + c.px, 0);
1901
+ if (sum !== before) {
1902
+ problems.push({ code: 'ASSEMBLE_COUNTS_ADD_UP', object: `part "${src}" (config.limbs)`, detail: `opaque px: ${before} before the limbs, ${sum} over their parts; equal is required (a bug in the assembler, not in the inputs)` });
1903
+ }
1904
+ for (const { q, i } of shares) {
1905
+ pieces[i] = { ...b, r: rasters[i], region: regions[i], merged: mergedIn(b, regions[i]) };
1906
+ if (count(regions[i], regions[i]) === 0 && opaqueOf(rasters[i]) === 0) {
1907
+ problems.push({
1908
+ code: 'ASSEMBLE_LIMB_PIXELS',
1909
+ object: `config.limbs.${q.limb}.segments[${q.segment}] (part "${q.part}")`,
1910
+ detail: `takes 0 of "${src}"'s ${before} opaque pixel(s); a segment with art is required — the joint lines through the bones' origins put none of the limb's pixels on this segment, or another limb's polyline lies nearer every one: move the bones onto the limb's art`,
1911
+ });
1912
+ }
1913
+ }
1914
+ counts.push({ of: src, before, shares: counted, joints: [] });
1915
+ }
1916
+ refuseIfAny(problems);
1917
+ // The caps, after every part is split, so a cap reads the limb's art in the layer as the painting has it.
1918
+ for (const l of names) {
1919
+ const segs = limbs[l].segments;
1920
+ segs.forEach((sg, k) => {
1921
+ if (k === 0) return;
1922
+ const line = (lines.get(l) as Array<JointLine | null>)[k] as JointLine;
1923
+ const li = names.indexOf(l);
1924
+ const upFrom = segs[k - 1].from;
1925
+ const art = (x: number, y: number): boolean => {
1926
+ if (x < 0 || y < 0 || x >= W || y >= H) return false;
1927
+ const p = y * W + x;
1928
+ for (const src of new Set([upFrom, sg.from])) {
1929
+ const b = builtOf(src);
1930
+ if (b !== null && owners.get(src)?.[p] === li && b.r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) return true;
1931
+ }
1932
+ return false;
1933
+ };
1934
+ const object = `config.limbs.${l}.segments[${k}] (joint "${String(sg.joint)}", part "${sg.part}")`;
1935
+ const width = limbWidth(line, art, [0, 0, W - 1, H - 1]);
1936
+ if (width === null) {
1937
+ problems.push({
1938
+ code: 'ASSEMBLE_LIMB_JOINT_ON_ART',
1939
+ object,
1940
+ detail: `the joint, "${sg.bone}"'s origin at [${line.at.join(', ')}], lies on none of the limb's art within 1 px along its joint line, so the limb has no width there to size the cap by; a bone origin on the limb's art (alpha above ${OPAQUE_ALPHA_ABOVE}) is required — move the bone onto the joint`,
1941
+ });
1942
+ return;
1943
+ }
1944
+ // The width to 3 places (src/round.ts): a measured length, and a radius the line and the record print as read.
1945
+ const w3 = pyRound(width, 3);
1946
+ const radius = w3 / 2;
1947
+ const box = capBox(line.at, radius);
1948
+ const at = parts.findIndex((q) => q.limb === l && q.segment === k);
1949
+ const img = sg.cap === undefined ? null : (caps.get(`${l}/${k}`) ?? null);
1950
+ if (sg.cap !== undefined && img === null) {
1951
+ problems.push({ code: 'ASSEMBLE_LIMB_CAP_PRESENT', object: `${object}.cap`, detail: `names ${sg.cap}, and no image was handed to the stage for it; the decoded PNG is required (a caller of assemble() reads the file)` });
1952
+ return;
1953
+ }
1954
+ if (img !== null && (img.width !== box[2] || img.height !== box[3])) {
1955
+ problems.push({
1956
+ code: 'ASSEMBLE_LIMB_CAP_SIZE',
1957
+ object: `${object}.cap`,
1958
+ detail: `${String(sg.cap)} is ${img.width}x${img.height}; the cap's box is ${box[2]}x${box[3]} at ${box[0]},${box[1]} (the pixels within ${radius} px of the joint, in rig pixels), and an image of exactly that size is required — its pixel (i, j) is rig pixel (${box[0]} + i, ${box[1]} + j)`,
1959
+ });
1960
+ return;
1961
+ }
1962
+ const lower = rasters[at];
1963
+ const lowerSrc = builtOf(sg.from) as Built;
1964
+ const ownLower = owners.get(sg.from) as Int32Array;
1965
+ let layerPx = 0;
1966
+ let imagePx = 0;
1967
+ let missing = 0;
1968
+ for (let y = Math.max(0, box[1]); y < Math.min(H, box[1] + box[3]); y++) {
1969
+ for (let x = Math.max(0, box[0]); x < Math.min(W, box[0] + box[2]); x++) {
1970
+ if (!inDisc(line.at, radius, x, y) || pastJoint(line, x, y)) continue;
1971
+ const p = y * W + x;
1972
+ const u = img === null ? -1 : ((y - box[1]) * img.width + (x - box[0])) * 4;
1973
+ if (img !== null && img.data[u + 3] > 0) {
1974
+ lower.data.set(img.data.subarray(u, u + 4), p * 4);
1975
+ imagePx++;
1976
+ } else if (ownLower[p] === li && lowerSrc.r.data[p * 4 + 3] > 0) {
1977
+ lower.data.set(lowerSrc.r.data.subarray(p * 4, p * 4 + 4), p * 4);
1978
+ layerPx++;
1979
+ } else missing++;
1980
+ }
1981
+ }
1982
+ if (img !== null && imagePx === 0) {
1983
+ problems.push({ code: 'ASSEMBLE_LIMB_CAP_EMPTY', object: `${object}.cap`, detail: `${String(sg.cap)} is transparent over every one of the cap's pixels; an image that gives at least one is required — to keep the layer's cap, drop cap` });
1984
+ return;
1985
+ }
1986
+ const cap: CapRecord = { at: [line.at[0], line.at[1]], width: w3, radius, box, layer_px: layerPx, ...(img === null ? {} : { image_px: imagePx }), missing_px: missing };
1987
+ parts[at].record.cap = cap;
1988
+ const c = counts.find((q) => q.of === sg.from);
1989
+ if (c !== undefined) c.joints.push({ limb: l, joint: String(sg.joint), part: sg.part, cap });
1990
+ });
1991
+ }
1992
+ refuseIfAny(problems);
1993
+ return { parts, pieces: pieces as Built[], counts };
1994
+ }
1995
+
1706
1996
  // ---------------------------------------------------------------------------
1707
1997
  // the stage
1708
1998
  // ---------------------------------------------------------------------------
@@ -1719,6 +2009,8 @@ export interface AssembleResult {
1719
2009
  figures: RecompositeFigures;
1720
2010
  /** What each `assemble.cuts` entry took, counted when it was made (`cutLines`); empty without cuts. */
1721
2011
  cuts: CutCount[];
2012
+ /** What each `config.limbs` limb cut, per part it took and per joint (`limbLines`); empty without limbs (issue #349). */
2013
+ limbs: LimbCount[];
1722
2014
  /** The full-run layers the plan does not take from the full run whose uncovered error holds a body (`uncoveredErrorLayers`); empty otherwise. */
1723
2015
  uncovered: UncoveredErrorLayer[];
1724
2016
  /** The parts whose `DRIFT_LIMIT` refusals hold a body another part holds in the draw order (`driftBodies`), in draw order; empty otherwise. */
@@ -1874,16 +2166,6 @@ export function assemble(input: AssembleInput): AssembleResult {
1874
2166
  });
1875
2167
  refuseIfAny(patchProblems);
1876
2168
 
1877
- interface Built {
1878
- key: string;
1879
- r: Raster;
1880
- st: ProjectionStats;
1881
- extendKeys: string[];
1882
- trace: MergeTrace;
1883
- merged: number;
1884
- /** The rig pixels this piece of the layer owns when the layer is cut, null when it is not: the counts are split by it. */
1885
- region: Mask | null;
1886
- }
1887
2169
  const built: Array<Built | null> = plan.map(() => null);
1888
2170
  const empty: Problem[] = [];
1889
2171
  /** The part's alpha box and its `alpha > 8` count, over the whole rig. */
@@ -2066,9 +2348,18 @@ export function assemble(input: AssembleInput): AssembleResult {
2066
2348
  refuseIfAny(empty);
2067
2349
  // In cut order, as the config lists them.
2068
2350
  cutCounts.sort((a, b) => cuts.findIndex((q) => q.into === a.into) - cuts.findIndex((q) => q.into === b.into));
2069
- const order = order0;
2070
- const pieceAt = (d: { plan: number } | { cut: number }): { name: string; b: Built } =>
2071
- 'plan' in d ? { name: plan[d.plan][0], b: built[d.plan] as Built } : { name: cuts[d.cut].into, b: pieces[d.cut] as Built };
2351
+
2352
+ // Issue #349: the limbs, after the cuts (a limb may take a cut's piece, and a cut's under image fills its base first).
2353
+ const lim = cutLimbs(input.limbs ?? {}, input.bones ?? [], input.caps ?? new Map(), W, H, (name) => {
2354
+ const pi = plan.findIndex((e) => e[0] === name);
2355
+ if (pi >= 0) return built[pi];
2356
+ const ci = cuts.findIndex((q) => q.into === name);
2357
+ return ci >= 0 ? pieces[ci] : null;
2358
+ }, opaqueOf, mergedIn, count);
2359
+ const limbPieces = lim.pieces;
2360
+ const order = limbOrder(order0, plan, cuts, lim.parts);
2361
+ const pieceAt = (d: { plan: number } | { cut: number } | { limb: number }): { name: string; b: Built } =>
2362
+ 'plan' in d ? { name: plan[d.plan][0], b: built[d.plan] as Built } : 'cut' in d ? { name: cuts[d.cut].into, b: pieces[d.cut] as Built } : { name: lim.parts[d.limb].part, b: limbPieces[d.limb] as Built };
2072
2363
 
2073
2364
  // Step 4b: push back a fringe the painting shows another part through, over
2074
2365
  // the plan parts still uncropped and the patches, in draw order.
@@ -2098,20 +2389,26 @@ export function assemble(input: AssembleInput): AssembleResult {
2098
2389
  });
2099
2390
  const pushedOf: FringePushed[][] = plan.map(() => []);
2100
2391
  const pushedOfCut: FringePushed[][] = cuts.map(() => []);
2392
+ const pushedOfLimb: FringePushed[][] = lim.parts.map(() => []);
2101
2393
  order.forEach((d, i) => {
2102
2394
  if ('plan' in d) pushedOf[d.plan] = pushedBy[i];
2103
2395
  if ('cut' in d) pushedOfCut[d.cut] = pushedBy[i];
2396
+ if ('limb' in d) pushedOfLimb[d.limb] = pushedBy[i];
2104
2397
  });
2105
2398
 
2106
2399
  const planned: Array<PlacedPart | null> = plan.map(() => null);
2107
2400
  const cutPlaced: Array<PlacedPart | null> = cuts.map(() => null);
2108
2401
  // A cut's piece records the plan part it was cut from (issue #230): parts.json is in draw order, so a piece drawn
2109
2402
  // "back" comes before its base, and nothing else in the file says which of the two is the piece.
2110
- const pieceList: Array<{ name: string; b: Built; pushed: FringePushed[]; at: string; cutFrom: string | null; set: (p: PlacedPart) => void }> = [
2403
+ const limbPlaced: Array<PlacedPart | null> = lim.parts.map(() => null);
2404
+ // A part a limb takes is not made itself (issue #349): its pixels are the limb's parts'.
2405
+ const taken = new Set(lim.parts.map((q) => q.of));
2406
+ const pieceList: Array<{ name: string; b: Built; pushed: FringePushed[]; at: string; cutFrom: string | null; limb?: LimbRecord; set: (p: PlacedPart) => void }> = [
2111
2407
  ...plan.map(([name], pi) => ({ name, b: built[pi] as Built, pushed: pushedOf[pi], at: `config.assemble.plan[${pi}]`, cutFrom: null, set: (q: PlacedPart) => (planned[pi] = q) })),
2112
2408
  ...cuts.map((q, i) => ({ name: q.into, b: pieces[i] as Built, pushed: pushedOfCut[i], at: `config.assemble.cuts[${i}]`, cutFrom: q.from, set: (pp: PlacedPart) => (cutPlaced[i] = pp) })),
2113
- ];
2114
- pieceList.forEach(({ name, b, pushed: pushedHere, at, cutFrom, set }) => {
2409
+ ...lim.parts.map((q, i) => ({ name: q.part, b: limbPieces[i] as Built, pushed: pushedOfLimb[i], at: `config.limbs.${q.limb}.segments[${q.segment}]`, cutFrom: null, limb: q.record, set: (pp: PlacedPart) => (limbPlaced[i] = pp) })),
2410
+ ].filter((q) => !taken.has(q.name));
2411
+ pieceList.forEach(({ name, b, pushed: pushedHere, at, cutFrom, limb, set }) => {
2115
2412
  const { key, r, st, extendKeys, trace, merged, region } = b;
2116
2413
  // A pixel the merge wrote is judged in the extend layer's run, every other
2117
2414
  // in the part's own. Projected = the colour came from projectSource's
@@ -2157,10 +2454,11 @@ export function assemble(input: AssembleInput): AssembleResult {
2157
2454
  seam_override_px: 0,
2158
2455
  };
2159
2456
  if (pushedHere.length > 0) record.fringe_pushed_back = pushedHere;
2457
+ if (limb !== undefined) record.limb = limb;
2160
2458
  set({ record, image: crop(r, x0, y0, record.w, record.h) });
2161
2459
  });
2162
2460
  refuseIfAny(empty);
2163
- const placed: PlacedPart[] = order.map((d) => ('plan' in d ? planned[d.plan] : 'cut' in d ? cutPlaced[d.cut] : cut[d.patch]) as PlacedPart);
2461
+ const placed: PlacedPart[] = order.map((d) => ('plan' in d ? planned[d.plan] : 'cut' in d ? cutPlaced[d.cut] : 'limb' in d ? limbPlaced[d.limb] : cut[d.patch]) as PlacedPart);
2164
2462
 
2165
2463
  seamOverride(placed, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
2166
2464
  const can = recomposite(placed, W, H);
@@ -2189,6 +2487,7 @@ export function assemble(input: AssembleInput): AssembleResult {
2189
2487
  figure: inside === null ? null : figureImage(inside, W, H),
2190
2488
  figures,
2191
2489
  cuts: cutCounts,
2490
+ limbs: lim.counts,
2192
2491
  uncovered,
2193
2492
  drift,
2194
2493
  seamRule,
@@ -2878,7 +3177,7 @@ function unplannedNotes(
2878
3177
  * the loader, because `inputs` runs before it exists; `seethrough` too, for a
2879
3178
  * full-loader config, where it is optional.
2880
3179
  */
2881
- export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble' | 'scene'>): {
3180
+ export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble' | 'scene'> & { limbs?: Record<string, Limb>; bones?: BoneEntry[] }): {
2882
3181
  resolution: number;
2883
3182
  headBox: [number, number, number, number];
2884
3183
  rigScale: number;
@@ -2887,6 +3186,8 @@ export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble'
2887
3186
  patches: Patch[];
2888
3187
  cuts: Cut[];
2889
3188
  scene?: boolean;
3189
+ limbs?: Record<string, Limb>;
3190
+ bones?: BoneEntry[];
2890
3191
  } {
2891
3192
  const problems: Problem[] = [];
2892
3193
  if (cfg.seethrough === undefined) {
@@ -2905,6 +3206,7 @@ export function stageFields(cfg: Pick<CharacterConfig, 'seethrough' | 'assemble'
2905
3206
  patches: cfg.assemble.patches ?? [],
2906
3207
  cuts: cfg.assemble.cuts ?? [],
2907
3208
  ...(cfg.scene === undefined ? {} : { scene: true }),
3209
+ ...(cfg.limbs === undefined ? {} : { limbs: cfg.limbs, bones: cfg.bones ?? [] }),
2908
3210
  };
2909
3211
  }
2910
3212
 
@@ -22,7 +22,7 @@
22
22
  * undeclared, which would leave both rows `not-measurable` (§8: "a pair no
23
23
  * track declares makes both rows not-measurable"). A track moves bone `b`
24
24
  * when a bone it keys is `b` or an ancestor of `b`: Spine bones inherit
25
- * their parent's rotation and scale (the rig writes no inherit mode), so a
25
+ * their parent's rotation and scale (where the rig writes no inherit mode), so a
26
26
  * pair the track moves together — both under one keyed bone — or not at all
27
27
  * keeps its relative linear part, θ 0 (§8: "a bone that does not move is
28
28
  * declared with θ 0"); a pair it moves on one side only gets the track's θ.
@@ -57,6 +57,11 @@
57
57
  * the author left it out — rig-c 2.29.0 then measures `MQ_DEFORM_LOAD`, which
58
58
  * never read G, and reads `MQ_ALLOCATION_CONTRAST` `not-measurable` naming
59
59
  * the gradation. No value is derived or defaulted here.
60
+ * - **A declared inherit mode** (issue #348): a bone between a keyed bone and
61
+ * a bound one — the bound bone itself included — whose `inherit` is not
62
+ * `normal` takes less than the key's rotation or scale, by its mode, so the
63
+ * track's θ for that pair is not composed here: the term stops, naming the
64
+ * bone and its mode, and the amplitude is not sent.
60
65
  * - **Constraints** move bones by what they solve, not by a key: a rig with
61
66
  * any constraint is refused by name, because no declared amplitude bounds
62
67
  * what a constraint does.
@@ -73,7 +78,7 @@ import type { MotionKey, MotionSpec } from './motion.ts';
73
78
  * — `rig.json`'s bones, `motion.json`, the config's constraints — so both build the one input `reductionKey` matches.
74
79
  */
75
80
  export interface AmplitudeBasis {
76
- bones: ReadonlyArray<{ name: string; parent?: string }>;
81
+ bones: ReadonlyArray<{ name: string; parent?: string; inherit?: string }>;
77
82
  motion: MotionSpec;
78
83
  constraints: number;
79
84
  }
@@ -133,7 +138,7 @@ export type AmplitudeDerivation = { amplitude: MotionAmplitude } | { stops: Ampl
133
138
  * reason to stop (rig-c 2.29.0 admits null).
134
139
  */
135
140
  export function deriveMotionAmplitude(args: {
136
- bones: ReadonlyArray<{ name: string; parent?: string }>;
141
+ bones: ReadonlyArray<{ name: string; parent?: string; inherit?: string }>;
137
142
  motion: MotionSpec;
138
143
  constraints: number;
139
144
  bound: readonly string[];
@@ -142,6 +147,13 @@ export function deriveMotionAmplitude(args: {
142
147
  }): AmplitudeDerivation {
143
148
  const stops: AmplitudeStop[] = [];
144
149
  const parent = new Map(args.bones.map((b) => [b.name, b.parent ?? null]));
150
+ const mode = new Map(args.bones.filter((b) => b.inherit !== undefined && b.inherit !== 'normal').map((b) => [b.name, b.inherit as string]));
151
+ /** The bones from `bone` up to `k`, `k` left out, that declare an inherit mode other than normal (issue #348). */
152
+ const declared = (bone: string, k: string): string[] => {
153
+ const out: string[] = [];
154
+ for (let b: string | null = bone; b !== null && b !== k; b = parent.get(b) ?? null) if (mode.has(b)) out.push(b);
155
+ return out;
156
+ };
145
157
  const under = (bone: string, k: string): boolean => {
146
158
  for (let b: string | null = bone; b !== null; b = parent.get(b) ?? null) if (b === k) return true;
147
159
  return false;
@@ -159,6 +171,14 @@ export function deriveMotionAmplitude(args: {
159
171
  const name = `${t.bone ?? t.group ?? '?'}.${t.property}#${i}`;
160
172
  const moverOf = (b: string): string[] => keyed.filter((k) => under(b, k));
161
173
  if (!bound.some((b) => moverOf(b).length > 0)) return;
174
+ const through = [...new Set(bound.flatMap((b) => moverOf(b).flatMap((k) => declared(b, k))))];
175
+ if (through.length > 0) {
176
+ stops.push({
177
+ term: `tracks[${name}].pairs[].theta`,
178
+ detail: `the track keys [${keyed.join(', ')}], and ${through.map((b) => `"${b}" declares inherit "${mode.get(b)}"`).join(', ')} below it, so what the key does to the bones under it is not the key's own rotation or scale; θ is composed here only through bones that inherit normally, and none is invented`,
179
+ });
180
+ return;
181
+ }
162
182
  const th = trackTheta(t.property, t.keys);
163
183
  if ('refused' in th) {
164
184
  stops.push({ term: `tracks[${name}].pairs[].theta`, detail: th.refused });