rig-parts 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,10 +41,10 @@ from the full run, because the twin tails leave the head crop sideways (a plan e
41
41
  <code>loop: idle 41 frame(s) at 12 fps, f0000 vs f0040 (t = 3.333333333333333s): max |d| 0 (0 required)</code><br/>
42
42
  <code>seam: setup pose at 654x1216, scale 0.9438: mean |d| 0.147 (&lt;= 1.0), 0 px over 40, 0.00 rig px (the count / s²; &lt;= 50), 0 px over 80 (reported)</code><br/>
43
43
  <code>pack: skeleton.png 1051x972, 23 region(s), 96.9% covered, padding 2, page edges free, shape polygon</code><br/>
44
- The loop is that build's indexed APNG, 40 frames at 12 fps, 725,845 bytes, one
44
+ The loop is that build's indexed APNG, 40 frames at 12 fps, 704,009 bytes, one
45
45
  256-entry palette at a measured error of max 70, mean 0.631 per channel over every
46
- frame (the lossless APNG beside it, the exactness record, is 4,514,756 bytes; the
47
- GIF, at the same error, 784,074). The rig's hair, skirt, sleeves, bows and earrings
46
+ frame (the lossless APNG beside it, the exactness record, is 4,446,263 bytes; the
47
+ GIF, at the same error, 760,294). The rig's hair, skirt, sleeves, bows and earrings
48
48
  carry physics, which rigc's render starts from rest, so the idle is played three
49
49
  periods ahead and the loop is the fourth: the frame one period after its first differs
50
50
  from it by max 7 levels, 0 px over 40 (<code>--lead-in</code>). Frame 29 (2.417 s) is the
@@ -255,10 +255,11 @@ rig-parts build --config config.json --source painting.png --full layers/full --
255
255
  At each step the config holds only what that step reads; the one table of what
256
256
  that is, step by step, is [docs/AUTHORING.md §4](docs/AUTHORING.md#4-the-command-order).
257
257
  The selftest runs this block in order, command by command, on each fetched example,
258
- from a config holding only what the first step reads (`RL01`). On `scarf` the
258
+ from a config holding only what the first step reads (`RL01`). On `demo` the
259
259
  proposal pasted uncorrected stops at the last step, naming its `bottomwear` on
260
- `CHECK_TIP_OVER_ROOT` — the one correction its tracked config records, a mesh mode
261
- (issue #118, [its README](examples/scarf/README.md)).
260
+ `CHECK_TIP_OVER_ROOT` — the correction its tracked config records there, the two outer
261
+ skirt chains moved (issue #227, [its README](examples/demo/README.md)); on `scarf`,
262
+ since issue #262, it builds green.
262
263
 
263
264
  `propose` is deliberately not a step of `build`: the proposal is a draft to correct
264
265
  against its overlay, and a config with bones is `build`'s input. `rig`, `check` and
package/cli.ts CHANGED
@@ -26,7 +26,7 @@ import { makeInputs } from './src/inputs.ts';
26
26
  import { idleKeysOf, isIdleKeys } from './src/rig.ts';
27
27
  import { figuresPhrase, implausibleRules, layerFigures, type LayerSet, pct, readLayers, ruleSummary, times } from './src/layers.ts';
28
28
  import { checkImageSize, choosePerson, KEYPOINTS_SPEC, loadKeypoints, type RigJoints, toRigJoints } from './src/keypoints.ts';
29
- import { basisLines, checkProposal, compare, compareLines, drawLandmarks, FRINGE_STEP_PX, HIP_MIN_FRACTION, lint, lintLine, type PartSet, proposeWithBasis, readPartSet, serializeProposal, WRIST_ON_PART_PX } from './src/propose.ts';
29
+ import { basisLines, checkProposal, compare, compareLines, draftTurn, drawLandmarks, FRINGE_STEP_PX, HIP_MIN_FRACTION, lint, lintLine, type PartSet, proposeWithBasis, readPartSet, serializeProposal, withTurn, WRIST_ON_PART_PX } from './src/propose.ts';
30
30
  import { BASIS_FILE, BASIS_SPEC, basisFile, basisLine, coverageLines, serializeBasis } from './src/diagnostics.ts';
31
31
  import { readPng, writePng } from './src/raster/png.ts';
32
32
  import { REQUIREMENTS_SENTENCE } from './src/requirements.ts';
@@ -135,6 +135,20 @@ usage:
135
135
  part to the one nearest part within <px>, said in a "wrist snap:" note
136
136
  (with --keypoints, not with --from-config). Without it nothing changes.
137
137
 
138
+ rig-parts propose … [--full <dir | layers.json>] [--head <dir | layers.json>]
139
+ Draft the turn control (issue #251) into proposal.json's "turn": the
140
+ See-through runs the parts came from, read for each part's layer
141
+ depth_median (larger = further back). turn.depths holds every part's
142
+ depth, turn.pivot their mean weighted by opaque_px — the depth at which
143
+ the painted pixels' centroid stays put. The rig stage adds a bone "turn"
144
+ under root that nothing keys; each part travels (pivot - depth) /
145
+ (pivot - nearest depth) of its x, the nearest part all of it, a part
146
+ behind the pivot the other way. A note states the pivot and every
147
+ fraction with its depth; a painting patch has no depth, is not carried,
148
+ and is named in a note. A part from a run not given, a tag the run lacks
149
+ or a layer with no depth is refused by name. Without either flag nothing
150
+ changes.
151
+
138
152
  rig-parts propose --parts <dir> --source <painting.png> --out <dir> --from-config <config.json>
139
153
  Draw the config's CURRENT bones instead (<out>/render/landmarks_config.png
140
154
  and _head) and LINT them (by the joints too, given --keypoints), with the
@@ -573,7 +587,7 @@ function printLayerTable(set: LayerSet): void {
573
587
  const TAKES = {
574
588
  sheet: { valued: ['--source', '--layers', '--out', '--cell', '--cols'], switches: [] },
575
589
  rig: { valued: ['--config', '--parts', '--out', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
576
- propose: { valued: ['--parts', '--source', '--out', '--from-config', '--compare', '--full', '--canvas', '--keypoints', '--person', '--wrist-snap'], switches: ['--head-box'] },
590
+ propose: { valued: ['--parts', '--source', '--out', '--from-config', '--compare', '--full', '--head', '--canvas', '--keypoints', '--person', '--wrist-snap'], switches: ['--head-box'] },
577
591
  compare: { valued: ['--left', '--right', '--map'], switches: [] },
578
592
  check: { valued: ['--rig', '--out', '--parts', '--source', '--page-edges', '--pack-shape', '--requirements'], switches: ['--seam-pairs', '--keep-stills'] },
579
593
  loop: { valued: ['--frames', '--out'], switches: ['--palette'] },
@@ -854,7 +868,7 @@ function cmdPropose(args: string[]): number {
854
868
  console.log(JSON.stringify({ head_box: r.head_box }));
855
869
  return EXIT_OK;
856
870
  }
857
- for (const f of ['--full', '--canvas']) if (flags.has(f)) return usage(`${f} belongs to --head-box`);
871
+ if (flags.has('--canvas')) return usage('--canvas belongs to --head-box');
858
872
  const partsArg = flags.get('--parts');
859
873
  const source = flags.get('--source');
860
874
  const out = flags.get('--out');
@@ -863,6 +877,8 @@ function cmdPropose(args: string[]): number {
863
877
  if (out === undefined) return usage('propose needs --out <dir>');
864
878
  if (flags.has('--from-config') && flags.has('--compare')) return usage('--from-config and --compare are two modes; give one');
865
879
  if (flags.has('--person') && !flags.has('--keypoints')) return usage('--person names a person in the --keypoints file; give --keypoints <file>');
880
+ // Issue #251: the See-through runs the turn control's depths are read from; without them nothing is drafted.
881
+ if (flags.has('--from-config') && (flags.has('--full') || flags.has('--head'))) return usage('--full and --head draft the turn control while proposing; --from-config proposes nothing');
866
882
  // Issue #199: a declared tolerance for a wrist on no handwear part. Whole px above the built-in one: the distance is
867
883
  // counted in whole px, so a fraction would bind what its floor binds, and a value at or under the built-in binds nothing.
868
884
  const snapArg = flags.get('--wrist-snap');
@@ -902,7 +918,13 @@ function cmdPropose(args: string[]): number {
902
918
  }
903
919
  const cmpPath = flags.get('--compare');
904
920
  const cfg = cmpPath === undefined ? null : loadConfig(cmpPath);
905
- const { proposal: prop, basis } = proposeWithBasis(P, joints, wristSnap);
921
+ const { proposal: drafted, basis } = proposeWithBasis(P, joints, wristSnap);
922
+ const fullRun = flags.get('--full');
923
+ const headRun = flags.get('--head');
924
+ const prop =
925
+ fullRun === undefined && headRun === undefined
926
+ ? drafted
927
+ : withTurn(drafted, draftTurn(P, { ...(fullRun === undefined ? {} : { full: readLayers(fullRun) }), ...(headRun === undefined ? {} : { head: readLayers(headRun) }) }));
906
928
  // Emit only after green: a proposal the config loader would refuse is not written, nor a basis record that does not hold to it.
907
929
  checkProposal(P, prop);
908
930
  const bfile = basisFile(P, prop, basis, cfg === null || cmpPath === undefined ? null : { where: cmpPath, config: cfg });
package/docs/AUTHORING.md CHANGED
@@ -97,11 +97,12 @@ read by no CPU stage).
97
97
  | `meshes.<part>.ribs` | rig | **authored**, optional (issue #188), in the `contour` and `auto` modes: `{<chain>: {stations}, …}`, each key a chain this mesh's `segments` name. Each link of the chain gets a row of vertices across the part at its origin and at `stations` points evenly between its origin and its end (`stations`, a whole number, 0 or more, required; a chain of L links carries L × (stations + 1) ribs), each rib a row of vertices across the part, kept as a row of locked edges; the rule is "Ribs along a chain" under "A contour mesh". Absent places none and changes no byte. Refused: a lattice mesh with ribs (`CONFIG_RIBS_MODE` — its vertices are its grid's corners), a key that is not one of the mesh's chains (`CONFIG_NAME_RESOLVES`), an object naming no chain or a `stations` that is not a whole number from 0 (`CONFIG_FIELD_TYPE`); at the rig stage `CONTOUR_RIB_ART`, `CONTOUR_RIB_LINK`, `CONTOUR_RIB_CROSSING`, `CONTOUR_RIB_EDGE` (the contour refusals). The mesh's `mesh_report.json` row gains `ribs` — the declaration, the ribs placed and the vertices they hold — and its contour report (the source's, in `auto`) lists each rib's vertices |
98
98
  | `regions.<part>` | rig | **proposed**: the bone a rigid part rides. Every plan part is exactly one of a mesh or a region (`CONFIG_PART_ATTACHED`) |
99
99
  | `motion.duration` | rig; check (the loop is measured at this time) | proposed as 40 frames at 12 fps, `40/12` s, written `3.3333333333333335` (issue #252; the reference proposed 4 s, and every period it drafted is scaled with it so each track keeps its cycles per idle); a whole number of 1/12 s ticks, because `check` renders at 12 fps and compares frame 0 with the frame at this time — read within 10^-9 s (`TIME_SLACK`, issue #244), since a whole number of frames is often not exact in binary: rigc's 40th frame sits at `40 × (1/12)` = `3.333333333333333`. Half a frame off is `CHECK_LOOP_LAST_FRAME_AT_DURATION` |
100
- | `motion.tracks` | rig | **proposed**, then tuned. Single `{bone, prop, amp, period, phase, base?}` or chain `{chain, amps, period, phase, lag}` — one amplitude per link, link `i` at phase `phase + lag·i`. Every `period` must divide `duration` (`CONFIG_PERIOD_DIVIDES_DURATION`). A bone property is keyed by one track (`CONFIG_BONE_PROPERTY_KEYED_ONCE`): a chain keys `rotate` on every link, the blink's `eyes` group `scaley` and its `brows` group `translatey` on every member, so a single track on any of those is a second track on it — rigc would refuse it one stage later, `animation "idle" has two tracks on eye.scaley`. The proposed breath (issue #252) is `chest` `translatey` travelling 2.5 % of the `topwear` part's height peak to peak (amplitude half of it to two decimals, `base` the amplitude, so the chest rises from the setup pose and returns) — height, because the travel is along y; the `breath:` note prints the fraction, the part, its height and the pixels. No `topwear` part: no height to scale by, so the breath keeps the reference's amplitude 1.3 (2.6 px peak to peak) and the note says so — scale it to the figure. `chest` `scalex` and the `head` and `neck` rolls are a ratio and angles and are proposed the same on every figure. A chain link is turned along its chain (the `bones` row), so a `translatex`/`translatey` key on a bone whose parent is a turned link, or a `scale`/`shear` key on a turned link, would move or stretch along the link and not along the picture's axis: the rig stage refuses it (`RIG_KEY_FRAME_UNTURNED`); `rotate` is keyed on any bone |
100
+ | `motion.tracks` | rig | **proposed**, then tuned. Single `{bone, prop, amp, period, phase, base?}` or chain `{chain, amps, period, phase, lag}` — one amplitude per link, link `i` at phase `phase + lag·i`. Every `period` must divide `duration` (`CONFIG_PERIOD_DIVIDES_DURATION`). A bone property is keyed by one track (`CONFIG_BONE_PROPERTY_KEYED_ONCE`): a chain keys `rotate` on every link, the blink's `eyes` group `scaley` and its `brows` group `translatey` on every member, so a single track on any of those is a second track on it — rigc would refuse it one stage later, `animation "idle" has two tracks on eye.scaley`. The proposed breath (issues #252, #270) is `chest` `translatey` travelling 2.5 % of the torso's length peak to peak — the distance from the `neck` bone to the `hip` bone as the proposal writes them, the run the chest sits half way along, whichever rule placed them — (amplitude half of it to two decimals, `base` the amplitude, so the chest rises from the setup pose and returns); the `breath:` note prints the fraction, the length, the two bones and the pixels. Until #270 it was 2.5 % of the `topwear` part's height, and a sweater to the thighs (`scarf`, 690 px) breathed 2.9 times as deep as `demo`; on neck → hip `scarf` reads 431 px (10.78 px) and `demo` 192 px (4.80 px). Neck and hip on one point: no length to scale by, so the breath keeps the reference's amplitude 1.3 (2.6 px peak to peak) and the note says so — scale it to the figure. `chest` `scalex` and the `head` and `neck` rolls are a ratio and angles and are proposed the same on every figure. A chain link is turned along its chain (the `bones` row), so a `translatex`/`translatey` key on a bone whose parent is a turned link, or a `scale`/`shear` key on a turned link, would move or stretch along the link and not along the picture's axis: the rig stage refuses it (`RIG_KEY_FRAME_UNTURNED`); `rotate` is keyed on any bone |
101
101
  | `motion.blink` | rig | **proposed**: `{t, eyes, brows, squash, brow_drop}`; the whole blink, `t` to `t + 0.364` s (`t + 0.314` s without brows: the brows' keys are the ones that end last), must fit inside the idle (`RIG_BLINK_INSIDE_IDLE`). The `eyes` group's `scaley` squashes every part on the eye bones about the bone's origin, the eyewhite's centre. Optional: a figure with no `eyewhite-r`/`-l` part has no eye bone, so `propose` writes no `blink` and notes `no blink: no eyewhite part (looked for: eyewhite-r, eyewhite-l), …`; with eyes and no `eyebrow-r`/`-l` part it writes the blink without `brows` and `brow_drop` and notes `blink without brows: …`. `brows` and `brow_drop` are stated together or not at all (`CONFIG_BLINK_BROWS_PAIRED`), and a group that names no bone is refused (`CONFIG_BLINK_GROUP_MEMBERS`) — rigc would refuse it one stage later, `group "eyes" declares no members` — as is a group that names one bone twice (`CONFIG_BLINK_GROUP_UNIQUE`; rigc: `group "eyes" names member "eye" twice`). An `irides-*` or `eyelash-*` part on a side with no eyewhite has no eye bone to ride: `propose` places it on `head` as a region, which the blink does not move, and notes `no eyewhite part for eye_r (looked for: eyewhite-r): iris (head:irides-r) is placed on head as a region, …`. The timing is fixed, not a field (table below) |
102
102
  | `motion.blink.still` | rig | **proposed** only for a lash `propose` notes (below), then checked: `{<part>: {row, bone}}` — the rows of that region part above `row` (rig px, y down) are drawn by a second slot `<part>_still` on `bone` and do not blink; rows from `row` down keep the part's slot and blink. The part must be a region on a bone `eyes` names, `bone` one it does not (`CONFIG_STILL_OFF_THE_BLINK`), and `row` a row of the part with no art across its whole width (`RIG_STILL_ROW_CLEAR`) |
103
103
  | `motion.animations_from` | rig; check (named, not measured) | optional, never proposed (issue #183): a path, relative to the config file's directory, to a file in rig-c's motion-spec shape, `{"spec": "rigc-motion/1", "animations": {"<name>": {"duration": …, "tracks": [...]}, …}}` — an expression, a gesture, anything beside the idle. On every `rig` and `build` each of its animations is written into `rig/motion.json` after `idle`, as read: nothing added, re-keyed or judged here, and each is rigc's to accept or refuse at the rig stage's gate, in rigc's words. It may name what `motion.json` already holds — the bones `bones` declares, the easings `shut` and `open`, the groups `eyes` and `brows` when the blink writes them — and nothing else from the file reaches it. The loader refuses, every problem at once: no such file or one that cannot be read (`CONFIG_FILE_PRESENT`), text that is not JSON (`CONFIG_IS_JSON`), a file that is not an object, a `spec` other than `"rigc-motion/1"`, an `animations` that is not a table of objects (`CONFIG_FIELD_TYPE`), any other key — rig-c's `easings`, `groups`, `setup`, `physics`, `mix`, `archetype` and `cut` included, which would be read and not written (`CONFIG_KEY_KNOWN`), an animation named `idle` (`CONFIG_ANIMATION_NAME_FREE`), and a key written twice in one object of the file, which JSON would fold into its last value without a word (`CONFIG_KEY_UNIQUE`). `check` measures the idle alone and prints one line naming the others (`beside the idle: …`). Absent, `motion.json` holds the idle alone, byte for byte as before |
104
104
  | `constraints` | rig (handed to rig-c as `rig.json`'s `constraints`); `compare` and the coverage lines (roles) | **authored**, optional; `propose` writes none. A list of constraints in **rig-c's own rig-spec shape** (`RigConstraint`: `ik`, `transform`, `path`, `physics`, `slider` — rig-c's `src/rig.ts` documents every field), in the order rigc applies them. The loader checks only what this package owns: each entry is an object with a `type` rigc names (`CONFIG_CONSTRAINT_TYPE_KNOWN`) and a non-empty `name`, unique within its kind as rigc finds constraints (`CONFIG_CONSTRAINT_NAME_UNIQUE`; an ik and a transform may share one); every bone it names — `bones`, an ik's `target`, a transform's `source`, a physics or slider constraint's `bone` — is a bone `bones` declares, a chain link as `<chain><i>` (`CONFIG_NAME_RESOLVES`); the bone a constraint follows does not sit under a bone it drives (`CONFIG_CONSTRAINT_TARGET_DETACHED`). Every other field (`mix`, `softness`, `properties`, a path's `slot`, a slider's `animation`) and every value is rigc's to accept or refuse at the rig stage's gate, in rigc's words (`RIG_RIGC_GREEN`) — an ik's `bones` as a shape too: from rig-c 2.15.0 rigc refuses an ik over more than two bones, or over a pair whose second bone is not the first's child (issue #103; this loader refused both itself until then). A note and an `x-` record may ride on an entry; the rig stage leaves them out of what it hands rigc. Under the default `--idle-keys ctl` a two-bone ik whose child link the idle keys is split by that link's control, and rigc refuses the pair: run `rig --idle-keys direct` or `build --idle-keys direct` (the rig stage adds that sentence, naming the flag on the command that ran, to rigc's line; `build` forwards it to its rig stage, issue #95). Each bone a constraint follows is declared to rigc as `invariants.detached` from every bone it drives, which rigc's gate rule `A25` checks |
105
+ | `turn` | rig (adds the turn control) | **proposed** only with `propose --full`/`--head` (issue #251), then corrected: `{pivot, depths}`. `depths` holds, per part, the See-through layer's `depth_median` the part came from (larger = further back); a `painting:` patch has none and is not carried. `pivot` is the depth that stays put; `propose` drafts the mean of the parts' depths weighted by `opaque_px`, the one depth at which the painted pixels' centroid does not slide under the dial. The rig stage adds a bone `turn` under `root` at its origin that nothing keys — its local x is the dial, in rig px — and carries each part by `(pivot − depth) / (pivot − nearest depth)` of it: the nearest part all of it, a part behind the pivot the other way, a part at the pivot not at all. Each part rides leaf twins (`turn_<part>_<bone>`, an identity child of every bone its slot and weights bind) moved by one `transform` constraint (`turn_<part>`, after the config's own); the idle's keys, the `<bone>_ctl` controls and a physics constraint stay on the bones, and the dial moves none of them. At rest: with `turn` declared, no pixel differs by more than 1 level at dial 0 (not byte-identical; the cause is float noise in the identity child bones, and the constraints add nothing); without `turn`, byte-identical. Neither field has a default (`CONFIG_FIELD_PRESENT`); the pivot must lie behind the nearest part (`CONFIG_TURN_PIVOT_BEHIND_NEAREST`). Absent, no control is added and no byte of any output moves |
105
106
 
106
107
  **A scene target is an ordinary bone.** A target the scene places — a point a hand
107
108
  reaches for, a face something turns toward — is a single bone parented to `root`,
@@ -221,7 +222,7 @@ What a joint with a position places, as given — no ratio is applied over it:
221
222
  | `sleeve_<s>` | `<s>_shoulder` → `<s>_elbow` (two links), tip at `<s>_wrist`, with the two-blob rule's first two amplitudes and its chest stub laid along the upper arm — when every handwear part over 500 px holds one such wrist within 15 px of its art (the larger of the x and y gaps, the LINT's own tolerance), or is bound one by `--wrist-snap` (below) | the sleeve rule places every sleeve chain, and each arm joint's note says why: for a wrist on no part, `… lies on the art of no handwear part within 15 px, so it does not say which sleeve it ends; the nearest is <part>, <d> px from its art by the larger of the x and y gaps (--wrist-snap <d> would bind it)` — `(beyond --wrist-snap <px>)` when the flag was given and fell short, and `the nearest are <a> and <b>, each <d> px …, so no tolerance says which` when two parts tie |
222
223
  | `eye_<s>`, `head`, the face box | not moved: they are measured off the parts | the note prints the eye joint's distance from the eye bone, with no bar |
223
224
  | `leg_<s>` or `leg` (issue #241) | on a `legwear` part over 500 px: `<s>_hip` → `<s>_knee` (two links), tip at `<s>_ankle`, parent `hip`, the part meshed on the chain and a `hip` stub laid along the thigh — when every such part holds the ankle of a leg whose three joints are given, within 15 px of its art. A part holding both ankles gets a chain per leg only when its art breaks along the line from `r_knee` to `l_knee` and along the line from `r_ankle` to `l_ankle` (the legs painted apart); otherwise one chain, `leg`, through the midpoints of the two hips, the two knees and the two ankles. No idle track: the chain is drafted whatever the pose, and whether the legs move is the scene's. A note on the part says which and why | no leg with all three joints: no chain, and the part is the trunk rule's region with the note it always had, byte for byte. A complete leg whose ankle lies on no legwear part, or on two: no chain, and a note `<parts>: no leg chain — <why>; the trunk rule places it` |
224
- | the trunk parts' segments (issue #243) | on the `neck` (head run), `topwear` and `bottomwear` meshes, each `[bone, from, to]` segment the rule drafts down the screen is laid along neck → hip, the line the chest is placed on — `neck` + 18 px along it to `chest`; a topwear part's `chest` segment from its art's nearest reach toward the neck end to 20 px short of `hip`, and its `hip` segment from there to 100 px past `hip`; a bottomwear part's `chest` segment from 30 px short of `chest` to 20 px short of `hip` — and the bottomwear waist segment runs across that line, 5 px past `hip`, over 0.09 to 0.91 of the art's width across it. The `head` segment on the neck mesh and the one-blob sleeve rule's chest segment are not moved. A note, `trunk segments (<parts>): laid along neck -> hip …`, gives the line's angle | neither end a joint: the vertical draft, byte for byte. Neck and hip on one point: no line, the vertical draft, and a note says so. An upright line over boxes tight on their art writes the vertical draft's bytes |
225
+ | the trunk parts' segments (issue #243) | on the `neck` (head run), `topwear` and `bottomwear` meshes, each `[bone, from, to]` segment the rule drafts down the screen is laid along neck → hip, the line the chest is placed on — `neck` + 18 px along it to `chest`; a topwear part's `chest` segment from its art's nearest reach toward the neck end to 20 px short of `hip`, and its `hip` segment from there to 100 px past `hip`; a bottomwear part's `chest` segment, when it has one (issue #262: only when its art reaches above `chest`), from 30 px short of `chest` to 20 px short of `hip` — and the bottomwear waist segment runs across that line, 5 px past `hip`, over 0.09 to 0.91 of the art's width across it. The `head` segment on the neck mesh and the one-blob sleeve rule's chest segment are not moved. A note, `trunk segments (<parts>): laid along neck -> hip …`, gives the line's angle | neither end a joint: the vertical draft, byte for byte. Neck and hip on one point: no line, the vertical draft, and a note says so. An upright line over boxes tight on their art writes the vertical draft's bytes |
225
226
 
226
227
  **`--wrist-snap <px>`** (issue #199), with `--keypoints` only, declares a tolerance
227
228
  for a wrist that lies on no handwear part within the built-in 15 px — hands folded
@@ -301,6 +302,16 @@ Two rules read the figure rather than one tag, and each says so in a note:
301
302
  (`no bottomwear: hip from 0.32 of figure height`). Neither published example
302
303
  meets either branch: their hips sit at 0.436 (`reference-demo`) and 0.342 (`reference-sample`) of
303
304
  their figures.
305
+ - **What a skirt hangs from** (issue #262). A `bottomwear` part whose art starts
306
+ below the `chest` bone's origin (along neck → hip where the joints give that line,
307
+ down the screen otherwise) hangs from the waist: its mesh takes the hip line and
308
+ its three skirt chains, and no `chest` segment, with the note `<part> (<from>):
309
+ its art starts … px below chest's origin, so it hangs from the waist — …`. With a
310
+ `chest` segment ending at the waist the waistband takes some `chest` weight and the
311
+ breath lifts the skirt's root while its hem, on the chains, stays: on `scarf` that
312
+ was `TIP_OVER_ROOT` 0.300 at the proposal's breath (upper half 0.492 rig px, hem
313
+ 0.148), and 2.614 without the segment. A `bottomwear` that reaches above the chest —
314
+ the long robe of the hip rule above — has art on the chest and keeps the segment.
304
315
  - **Clasped hands.** When every `handwear` part together is one blob (over
305
316
  500 px) at most half the shoulders' width and centred within a quarter of it
306
317
  of the eye axis, it is the hands clasped in front, not two sleeves: it rides
@@ -935,7 +946,7 @@ safe is the author's to choose here.
935
946
  | `AUTO_MESH_TERMINATION` | rig-c returned no mesh: `invalid-input` (the source fails its own `sourceBounds`, a region it refuses, protected influences over the cap), `unsupported-topology`, or `budget-exhausted` with `none-met-the-targets`; or, in the acceptance loop, a replay that does not end as rig-c's contract promises (`replayed-to-accepted-step` at the step asked, `candidatesTried` the full run's `acceptedAt[n − 1].step`, its `acceptedAt` the full run's first n operations, each the same `step`, `kind`, `count` and `sourceVertices`) |
936
947
  | `AUTO_MESH_ACCEPTED` | rig-c returned a mesh that is not accepted; the detail names every declared row not passing and the constraint that stopped it |
937
948
  | `AUTO_MESH_NO_STIMULUS` | neither the idle nor a declared `stimulus` moves some of the part's bound bones against the others, so no frame deforms it — the detail names the bones with no motion against each other and the field to declare; missing stimulus is not a pass; declare `meshes.<part>.auto.stimulus`, put the part in `contour` or `grid` mode, or key a bone it binds |
938
- | `AUTO_MESH_MOTION` | the reduced mesh against its source on the idle is not accepted and the acceptance loop wrote no step in its place: "no reduction passes the motion bound" (the full result's failing rows and every probe named; under `motion.selection`, "… among the tested candidates", with `maxProbes` and the termination), or the chosen step passes on the grid frames and not on the whole idle (its selection and held-out values and worst frames named); when the source itself is not accepted against itself, the full result's rows as before |
949
+ | `AUTO_MESH_MOTION` | the reduced mesh against its source on the idle is not accepted and the acceptance loop wrote no step in its place: "no reduction passes the motion bound" (the full result's failing rows and every probe named; under `motion.selection`, "… among the tested candidates", with `maxProbes` and the termination), or the chosen step passes on the grid frames and not on the whole idle (its selection and held-out values and worst frames named); when the source itself is not accepted against itself, one sentence first (issue #280): "the unreduced source itself fails <where> (<row> <value> against <bound>), so no replayed step can pass — change the source (its spacing or its outline) or lower the stimulus", `<where>` a declared stimulus by its animation's name (`at stimulus hem +150`), `at idle frame <id>`, or `in its geometry` for a geometry row, the stimulus remedy only when it was read at one; then the full result's rows, for the record. No search runs, and the stage's `replay "<part>"` line names the same place |
939
950
  | `AUTO_MESH_SELECTION_BUDGET` | `motion.selection.maxProbes` is below ⌈log₂(N − I)⌉, the bisection the multi-interval search runs whole first; N, I and the number required named; nothing replayed |
940
951
  | `AUTO_MESH_SELECTION_ROLES` | a multi-interval choice whose probes read a frame the final comparison holds out, or one it does not declare `selection`; the frames named, the held-out claim withdrawn |
941
952
  | `AUTO_MESH_MOTION_INPUT` | the comparison could not be made: rig-c refused its input (its code carried — `COMPARE_REFERENCE_FAILS` when the source fails its own `sourceBounds`, `COMPARE_INPUTS_DIFFER`, …), the reference build is red at rigc's gate, or a gate build wrote no `skeleton.model.json`; in the acceptance loop, a replayed step's build that rigc's gate refuses |
@@ -1275,9 +1286,11 @@ entry, no dithering, filter None) — and is the small file to show. `idle.gif`
1275
1286
  (`loop --out x.gif`) is the same median cut in a GIF. The indexed APNG and the GIF
1276
1287
  print their palette error, per channel over R, G and B of every frame (and alpha's
1277
1288
  max for the APNG); a figure is a measurement of the file, not a bar. On the demo
1278
- (the README's animation since issue #224's follow-up: `build --loop --lead-in 3`), on
1279
- the 40-frame idle of issue #252 (rig-c 2.40.0): 4,514,756 B lossless, 725,845 B indexed
1280
- and 784,074 B GIF, both palette files at max 70, mean 0.631; on the 4 s idle before it
1289
+ (the README's animation since issue #224's follow-up: `build --loop --lead-in 3`), with
1290
+ the breath of issue #270 (2.5 % of neck -> hip, 4.80 px peak to peak): 4,446,263 B lossless,
1291
+ 704,009 B indexed and 760,294 B GIF, both palette files at max 70, mean 0.631; on the
1292
+ 40-frame idle of issue #252 (rig-c 2.40.0, the breath 2.5 % of the topwear's height, 5.92 px):
1293
+ 4,514,756 B lossless, 725,845 B indexed and 784,074 B GIF, at the same error; on the 4 s idle before it
1281
1294
  (rig-c 2.33.0): 5,148,992, 727,486 and 781,526 B at max 69, mean 0.630 — the two share one quantiser, so their error is the same by
1282
1295
  construction and the size is the difference. From issue #227 until then, `build --loop`
1283
1296
  with default flags, before the demo carried physics: 5,832,634, 827,857 and 889,063 B
@@ -1699,6 +1712,8 @@ See-through with another seed.
1699
1712
  | `CONFIG_CONSTRAINT_TYPE_KNOWN` | a `constraints` entry's `type` is not one of rig-c's five: `ik`, `transform`, `path`, `physics`, `slider` (spelled as rigc spells them; `IK` is not `ik`) | that entry's `type` |
1700
1713
  | `CONFIG_CONSTRAINT_NAME_UNIQUE` | two `constraints` entries of one kind share a `name`; rigc finds a constraint by its kind and its name, so an ik and a transform may share one | rename one |
1701
1714
  | `CONFIG_CONSTRAINT_TARGET_DETACHED` | the bone an ik follows (`target`) or a transform reads (`source`) is one of the bones that constraint drives, or sits under one (the detail gives the line of parents) — driving them would move what they follow. This package's refusal: rig-c 2.15.0 gates such a rig green when nothing declares the parentage (measured on the selftest's rig fixture, issue #103) | a bone beside the chain; a scene target is a single bone parented to `root` (§3) |
1715
+ | `CONFIG_TURN_PIVOT_BEHIND_NEAREST` | `turn.pivot` is at or in front of the nearest part's depth: the law `(pivot − depth) / (pivot − nearest)` has no value there, and in front of it every part travels against the dial | a pivot above the smallest depth in `turn.depths` |
1716
+ | `CONFIG_TURN_BONE_FREE` | the config has `turn` and `bones` declares a bone named `turn`, the control the rig stage adds | rename that bone |
1702
1717
 
1703
1718
  ### inputs
1704
1719
 
@@ -1750,6 +1765,7 @@ See-through with another seed.
1750
1765
  | `PROPOSE_FACE_PRESENT` | no part comes from a `face` layer, and the face-less fallback cannot derive a face box either: it needs the head run's `neck` and at least one head-run `front hair`/`back hair` part whose top is above the neck's, and the message names the half that is missing. With both, `propose` does not refuse — it derives the box and says so in its first note (§5) | `assemble.plan`: take the face, or the head run's hair and neck |
1751
1766
  | `PROPOSE_CUT_FROM_RECORDED` | two parts share one layer and none of them records `cut_from`: a `parts.json` an `assemble` before 1.6.0 wrote with `assemble.cuts`. The records are in draw order, so which is the plan part and which the piece cannot be read off the file; the message names the parts and the layer. Every other reader still takes the file | `--parts`: re-run assemble (1.6.0+) to record `cut_from` |
1752
1767
  | `PROPOSE_ACCESSORY_BODY` | an accessory has nothing above its pendant rows to hang its bone on | that part's plan entry, or author its bones by hand |
1768
+ | `PROPOSE_TURN_RUN_GIVEN`, `PROPOSE_TURN_LAYER_PRESENT`, `PROPOSE_TURN_DEPTH_PRESENT` | with `--full`/`--head` (issue #251): a part came from a run whose layers were not given, the run given holds no layer of the part's tag, or its layer carries no `depth_median` (a PSD carries none) | give the run the parts were assembled from, as the wrapper's `layers.json` |
1753
1769
  | `KEYPOINTS_FILE_PRESENT`, `KEYPOINTS_IS_JSON`, `KEYPOINTS_KEY_KNOWN`, `KEYPOINTS_FIELD_PRESENT`, `KEYPOINTS_FIELD_TYPE` | the `--keypoints` file is absent, not JSON, has a key this reader does not know, lacks one of `spec`, `space`, `width`, `height`, `source`, `people`, or holds a value of the wrong type (a size that is not a whole positive px count, an empty `source`, no people) | the keypoint file (§3, *A posed figure*) |
1754
1770
  | `KEYPOINTS_SPEC_KNOWN`, `KEYPOINTS_SPACE_STATED` | the file is not `spine-parts-keypoints/1`, or states a space other than painting-px, origin top-left, y down | convert the file to that space; it is not read as if it were in it |
1755
1771
  | `KEYPOINTS_JOINT_KNOWN`, `KEYPOINTS_JOINT_STATE`, `KEYPOINTS_POSITION_STATED`, `KEYPOINTS_POSITION_INSIDE` | a joint name outside body-18, a state outside observed / occluded / missing, an observed joint with no `at` or a missing one with one, or a position outside the `width`x`height` image | that joint |
@@ -1766,6 +1782,7 @@ See-through with another seed.
1766
1782
  | `RIG_NAME_RESOLVES`, `RIG_SEGMENT_DEFINED`, `RIG_CHAIN_POINTS` | a segment names an undeclared bone, a bone with no `tip` and no next link, or a chain too short to make a link | that bone's `tip`, or write the segment out as `[bone, [x0,y0], [x1,y1]]` |
1767
1783
  | `RIG_LATTICE_ONE_LOOP` | the lattice over a part does not close into one outline even after the repair passes | that mesh's `grid` |
1768
1784
  | `RIG_CONTROL_NAME_FREE` | a keyed, mesh-weighted bone needs `<bone>_ctl` and that name is taken | rename the declared bone |
1785
+ | `RIG_TURN_NAME_FREE` | with `turn`: a twin (`turn_<part>_<bone>`), the control `turn` or a transform constraint `turn_<part>` the turn writes is a name the rig already holds | rename the declared bone or constraint |
1769
1786
  | `RIG_KEY_FRAME_UNTURNED` | a `translate` key on a bone whose parent is a chain link turned along its chain, or a `scale`/`shear` key (a track, or the blink's `eyes`/`brows` group) on a turned link: the key would act along the link's axis, not the picture's; the detail names the track, the bone and the turn | key the chain's own parent, or key `rotate`; a link whose turn is 0 (a level chain) keeps every key |
1770
1787
  | `RIG_BLINK_INSIDE_IDLE` | the blink runs outside the idle | `motion.blink.t` |
1771
1788
  | `RIG_BLINK_HOLD_SPANS_A_FRAME` | the tree's `BLINK.hold` is shorter than one frame at `IDLE_FPS`, so for some `motion.blink.t` no idle frame — and no frame of the loop — shows the closed eye; the detail counts the phases that miss and names the first | nothing in the config: `BLINK.hold` in `src/motion.ts`, at least `1/IDLE_FPS` s (§3) |
@@ -1791,7 +1808,7 @@ See-through with another seed.
1791
1808
  | `CHECK_BREATH_VISIBLE` | the torso (`topwear`), rendered alone, barely moves over the idle — or the feet (`footwear`), rendered alone, move at all | the chest's breath tracks (`motion.tracks` on `chest`), or the torso mesh's `segments`; for the feet, the bone their region rides (`regions.<part>`, `root` in both examples) |
1792
1809
  | `CHECK_BLINK_NO_HOLE` | with the blink held shut, the eyewhite box shows the page where the open eye had art | the layer under the eye: the `face` part has no art there. Take the face from the other run, or add a part under the eye; `motion.blink.squash` only hides the hole less |
1793
1810
  | `CHECK_CHAIN_LAG` | a rotate track leads (or does not lag) the keyed bone above it, or a chain link swings less than the link above | that chain track's `phase`/`lag` (a positive `lag`, a child `phase` above its parent's) or its `amps` (non-decreasing toward the tip) |
1794
- | `CHECK_TIP_OVER_ROOT` | a `handwear`/`bottomwear` part's art in the lower half of its box travels less than 1.4725 times as far as in the upper half, measured from the idle's posed geometry (rig px quoted) | the chain track's `amps` (grow toward the tip), or the mesh's `segments` (the chain must be among them), or its mode: on `scarf`'s short skirt the proposal's lattice reads 1.207 and the same part in `contour` mode 1.738 with the same segments and idle (issue #118) |
1811
+ | `CHECK_TIP_OVER_ROOT` | a `handwear`/`bottomwear` part's art in the lower half of its box travels less than 1.4725 times as far as in the upper half, measured from the idle's posed geometry (rig px quoted) | the chain track's `amps` (grow toward the tip), or the mesh's `segments` (the chain must be among them), or its mode: on `scarf`'s short skirt the proposal's lattice read 1.207 and the same part in `contour` mode 1.738 with the same segments and idle (issue #118) — both carried the `chest` segment #262 removed, after which the lattice reads 3.487. When the detail says `the root travels with the breath (x px of y px): it is carried by \`<bone>\`` (issue #262) the chain is not the cause: a bone an idle `translate` track moves (the breath) moves the root more than the tip, and with that translate held still (the part posed again without it, in proportion to each vertex's weight on the bone and those under it) the ratio passes — the row adds `carried_by`, `breath_rig_px`, `root_without_breath_rig_px` and `tip_without_breath_rig_px`; take that bone's segment off the mesh, or bind the root to the bone the part hangs from |
1795
1812
  | `CHECK_STILL_REGIONS_DARK` | a slot of the face's still set (the `face` slots and every slot drawn over the face that the head bone alone carries) moves relative to the bone the face's slot rides, or a `footwear` slot moves on the screen, by more than the arithmetic's own rounding — measured from the idle's posed geometry (the slot, its largest displacement in rig px, the idle frame and the tolerance quoted) | on the face: a face mesh with a weight on a bone other than the head (`segments`), or one deformed by another bone; on the feet: a mesh weighted to a swinging bone, or a region on the wrong bone. A bang swinging over the face is not this line's failure: its depth is reported (`crossing`), with no bar |
1796
1813
  | `CHECK_TEXTURE_STRETCH` | a mesh triangle's edge, in some idle frame, is more than 1.926544 times its rest length or less than 1/1.926544 of it (the mesh, triangle, its three vertices, the edge and the frame are named); or a rest edge has length 0 | the chain tracks' `amps` on the bones that mesh is weighted to (the stretch grows with them), or the mesh's `segments` — a vertex blending two bones that swing against each other |
1797
1814
  | `CHECK_SEAM_FRAME_SIZE`, `FRAMES_SIDECAR`, `CHECK_GEOMETRY_FILE` | rigc's render is not what its `frames.json` says, or its `geometry.json` is not a whole `rigc-geometry/1` export of the frames beside it (a frame count, a mesh whose vertex count differs from its rest entry; for the face half, another viewport than `frames.json`'s, other frame indices, the head bone or a feature's bone missing from a frame, a non-finite transform or time) | a rigc problem; report it |
@@ -1967,8 +1984,9 @@ The fields are now `root_rig_px` and `tip_rig_px`, rig px, beside `instrument:
1967
1984
  "geometry"`, so an old and a new `check.json` are not read as one instrument. The bar
1968
1985
  is the one set from the pixel instrument (half of reference-demo's `bottomwear` 2.945, by the
1969
1986
  rule above, not chased); reference-demo's exact 3.222 clears it 2.19 times. The figures the two
1970
- instruments give the examples, and `scarf` (whose `bottomwear` is tracked in contour
1971
- mode because its lattice fails the line — its README):
1987
+ instruments give the examples, and `scarf` (whose `bottomwear` was tracked in contour
1988
+ mode because its lattice failed the line, until issue #262 found the cause in a `chest`
1989
+ segment and the lattice read 3.487 — its README):
1972
1990
 
1973
1991
  | part | pixel, `--max 640` (until #118) | geometry |
1974
1992
  | --- | --- | --- |
package/docs/STABILITY.md CHANGED
@@ -52,7 +52,7 @@ defaults are the help's too, and none is guessed from the art.
52
52
  | `comfy` | `seethrough`: `--image`, `--out`; optional `--resolution`, `--steps`, `--seed`, `--prefix`, `--offload`, `--lama`, `--nf4`, `--host`, `--wait`, `--timeout`, `--poll` |
53
53
  | `assemble` | `--propose-plan` with `--source`, `--full`, `--head`, `--config`; or `--source`, `--full`, `--head`, `--config`, `--out`, optional `--seam` (near-white, silhouette), `--project` (core, visible) |
54
54
  | `propose` | `--head-box` with `--full`, `--canvas` |
55
- | `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` and `--wrist-snap` (each with `--keypoints`) |
55
+ | `propose` | `--parts`, `--source`, `--out`; optional `--compare` or `--from-config` (one of them), `--keypoints`, `--person` and `--wrist-snap` (each with `--keypoints`), `--full`, `--head` (the turn control's depths; not with `--from-config`) |
56
56
  | `compare` | `--left`, `--right`; optional `--map` |
57
57
  | `rig` | `--config`, `--parts`, `--out`; optional `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
58
58
  | `check` | `--rig`, `--out`; optional `--parts`, `--source`, `--page-edges`, `--pack-shape`, `--requirements`, `--seam-pairs`, `--keep-stills` |
@@ -94,12 +94,15 @@ rules that make the contract:
94
94
  - **`constraints`** are rig-c's own rig-spec constraints (`RigConstraint` in
95
95
  rig-c's `src/rig.ts`), handed to rigc as written; this loader checks their
96
96
  `type`, their `name` and that every bone they name resolves.
97
+ - **`turn`** (issue #251) adds a control bone `turn` under `root` that nothing
98
+ keys, and carries each part in `depths` by `(pivot − depth) / (pivot − nearest
99
+ depth)` of its x. Without the field no byte of any output moves.
97
100
 
98
101
  The fields, object by object (a bold path is where the object sits; `<part>` is a
99
102
  part name, `[i]` an entry of a list):
100
103
 
101
104
  <!-- stable:config -->
102
- - **config** — required by the full loader, in front of rig and build: `key`, `assemble`, `bones`, `meshes`, `regions`, `motion`; optional: `generation`, `seethrough`, `constraints`
105
+ - **config** — required by the full loader, in front of rig and build: `key`, `assemble`, `bones`, `meshes`, `regions`, `motion`; optional: `generation`, `seethrough`, `constraints`, `turn`
103
106
  - **generation** — required: `checkpoint`, `loras`, `trigger`, `identity`, `sampler`, `costume`, `negative_extra`, `style`, `negative_pose`, `latent`, `seed`; optional: `pose`, `control`
104
107
  - **generation.loras[i]** — required: `name`, `strength`; optional: `strength_clip`
105
108
  - **generation.sampler** — required: `steps`, `cfg`, `sampler`, `scheduler`
@@ -138,7 +141,9 @@ part name, `[i]` an entry of a list):
138
141
  - **motion.blink** — required: `t`, `eyes`, `squash`; optional: `brows`, `brow_drop`, `still`
139
142
  - **motion.blink.still.&lt;part&gt;** — required: `row`, `bone`
140
143
  - **motion.animations_from**, the file it names — required: `spec`, `animations`
141
- - **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `motion` under the rules above, and `notes`
144
+ - **turn** — required: `pivot`, `depths`
145
+ - **turn.depths.&lt;part&gt;** — a See-through depth
146
+ - **proposal.json**, the file propose writes — `bones`, `meshes`, `regions`, `motion` under the rules above, `turn` with `--full`/`--head`, and `notes`
142
147
  <!-- /stable:config -->
143
148
 
144
149
  `TY14` holds the set of field names in backticks above to the set of names the
@@ -159,7 +164,7 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
159
164
  | `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 |
160
165
  | `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`; 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`); `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) |
161
166
  | `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_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`), `RECOMPOSITE_HOLES`, `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 |
162
- | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections and `notes` |
167
+ | `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `turn` with `--full`/`--head`, and `notes` |
163
168
  | `basis.json` | `propose` | spec `spine-parts-basis/1` |
164
169
  | the scene report | `compose` | spec `spine-parts-scene-report/1` |
165
170
  | `idle.png`, `idle-indexed.png`, `idle.gif` | `loop`, `build --loop` | APNG and GIF encodings of the rendered idle |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
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": {
@@ -82,6 +82,7 @@
82
82
  import { type BoneMotionRange, type EnvelopeBoneRanges, MeshReductionError, type MeshQualityReport, type MeshReductionInput, type SkinningEnvelope, type SkinningEnvelopeBone, skinningEnvelopeBone } from 'rig-c/mesh';
83
83
  import type { ConfigConstraint } from './config.ts';
84
84
  import type { MotionKey, MotionSpec } from './motion.ts';
85
+ import { edgeLength } from './length.ts';
85
86
 
86
87
  /** What the derivation reads off the rig besides the part: the bones (name, parent), each bone's setup joint in crop px, the idle as written, the constraints. */
87
88
  export interface EnvelopeBasis {
@@ -168,7 +169,7 @@ export function boneRange(basis: EnvelopeBasis, bone: string, origin: readonly [
168
169
  rotate,
169
170
  scaleX,
170
171
  scaleY,
171
- translate: Math.hypot(tx, ty),
172
+ translate: edgeLength(tx, ty),
172
173
  setup: { ...SETUP },
173
174
  };
174
175
  }
package/src/automotion.ts CHANGED
@@ -344,6 +344,47 @@ export function motionRowText(r: MeasureRow): string {
344
344
  return `${r.code}${region} ${r.state}: ${r.reason ?? ''}`;
345
345
  }
346
346
 
347
+ /**
348
+ * Where a row of a comparison was read at its worst, in the words a reader can act on (issue #280), with its
349
+ * preposition: a declared stimulus by its animation's name (`at stimulus tail_l -10`, the frame rig-c ids
350
+ * `stimulus tail_l -10@explicit@0`), an idle frame as `at idle frame <id>`, a geometry row — read on the mesh, at
351
+ * no frame — as `in its geometry`, and a motion row that names no frame as `on the idle`.
352
+ */
353
+ export function readingPlace(r: MeasureRow, stimulus: readonly StimulusPose[] | null, geometry: boolean): { text: string; stimulus: boolean } {
354
+ const id = r.worst?.frame?.id;
355
+ if (id === undefined) return { text: geometry ? 'in its geometry' : 'on the idle', stimulus: false };
356
+ const name = (stimulus ?? []).flatMap((st) => stimulusAnimationNames(st)).find((n) => explicitFrameId(n) === id);
357
+ return name === undefined ? { text: `at idle frame ${id}`, stimulus: false } : { text: `at ${name}`, stimulus: true };
358
+ }
359
+
360
+ /**
361
+ * Issue #280: the source compared with itself failed, so no replayed step can pass — the one sentence that says
362
+ * so (`the unreduced source itself fails <where> (<row> <value> against <bound>), so …`): where (a declared stimulus by name, or the idle frame), each failing row and its value against its bound,
363
+ * and the remedy, which is the source's (its spacing or outline), or the stimulus's when the failure is read at
364
+ * one. A row the source fails against itself is an absolute reading (`MQ_INVERSION` counts the source's own
365
+ * flipped triangles), which is why no reduction of it can pass. Null when the comparison has no failing row.
366
+ */
367
+ export function sourceSelfFailure(self: MeshQualityReport, stimulus: readonly StimulusPose[] | null): { where: string; sentence: string } | null {
368
+ const rows = failingRows(self);
369
+ if (rows.length === 0) return null;
370
+ const geometryRows = new Set(self.candidates[0]?.geometry?.rows ?? []);
371
+ const places = rows.map((r) => readingPlace(r, stimulus, geometryRows.has(r)));
372
+ const distinct = [...new Set(places.map((p) => p.text))];
373
+ const where = distinct.join(', ');
374
+ // One place: the rows say only their values; several: each row says its own.
375
+ const read = rows
376
+ .map((r, i) => {
377
+ const at = distinct.length > 1 ? ` ${places[i].text}` : '';
378
+ return r.state === 'fail' || r.state === 'pass' || r.state === 'undeclared' ? `${r.code} ${r.value}${r.bound === null ? '' : ` against ${r.bound.op} ${r.bound.value}`}${at}` : `${r.code} ${r.state}${at}: ${r.reason ?? ''}`;
379
+ })
380
+ .join('; ');
381
+ const atStimulus = places.some((p) => p.stimulus);
382
+ return {
383
+ where,
384
+ sentence: `the unreduced source itself fails ${where} (${read}), so no replayed step can pass — change the source (its spacing or its outline)${atStimulus ? ' or lower the stimulus' : ''}`,
385
+ };
386
+ }
387
+
347
388
  /** The candidate's rows with a declared bound that did not pass, geometry then motion. */
348
389
  export function failingRows(report: MeshQualityReport): MeasureRow[] {
349
390
  const c = report.candidates[0];
package/src/build.ts CHANGED
@@ -46,7 +46,7 @@ import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEF
46
46
  import { assemble, type AssembleResult, cutLines, driftLine, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
- import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, stimulusMotion, withStimulus } from './automotion.ts';
49
+ import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
50
50
  import {
51
51
  bisectAccepted,
52
52
  budgetProblem,
@@ -82,7 +82,8 @@ import { type PartRecord, readParts, writeParts } from './parts.ts';
82
82
  import { encodePngBytes, readPng, writePng } from './raster/png.ts';
83
83
  import type { Raster } from './raster/types.ts';
84
84
  import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
85
- import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared } from './rig.ts';
85
+ import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared, type TurnRow } from './rig.ts';
86
+ import { TURN_BONE } from './turn.ts';
86
87
  import { pyRound } from './round.ts';
87
88
 
88
89
  /** Where a stage's lines go. The commands hand it `console.log`; `build` hands it a prefixing wrapper. */
@@ -581,8 +582,12 @@ export function replaySearch(
581
582
  const self = runComparison(c.object, motionInput(c, motion, refModel, refModel, probeSchedule));
582
583
  if ('code' in self) return refuse(self);
583
584
  if (motionVerdict(c.object, self) !== null) {
584
- lines.push(`replay "${c.part}": the unreduced source is not accepted against itself on the grid frames; no search runs`);
585
- return { part: c.part, chosen: null, problems: run.problems, lines };
585
+ // Issue #280: the refusal says, in its own sentence, that the source fails itself — where, the row, the remedy;
586
+ // the reduction's reading follows it for the record. The line names the stimulus where it is read at one.
587
+ const why = sourceSelfFailure(self, c.stimulus);
588
+ lines.push(`replay "${c.part}": the unreduced source is not accepted against itself ${why === null ? 'on its selection frames' : why.where}; no search runs`);
589
+ const problems = why === null ? run.problems : run.problems.map((p) => (p.code === 'AUTO_MESH_MOTION' ? { ...p, detail: `${why.sentence}. The reduction's own reading, for the record: ${p.detail}` } : p));
590
+ return { part: c.part, chosen: null, problems, lines };
586
591
  }
587
592
  const selection = motion.selection;
588
593
  if (selection !== undefined && selection.maxProbes < maxReplays(N, I)) return refuse(budgetProblem(c.object, selection.maxProbes, N, I));
@@ -822,6 +827,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
822
827
  );
823
828
  if (from !== null) log(` animations beside the idle, from ${from.file} as written: ${Object.keys(from.table).join(', ') || 'none'}`);
824
829
  log(faceAxisLine(rig.faceAxis));
830
+ if (rig.turn !== null) log(turnLine(rig.turn));
825
831
  log(
826
832
  rig.idleKeys === 'ctl'
827
833
  ? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
@@ -971,6 +977,17 @@ export function faceAxisLine(f: FaceAxis): string {
971
977
  return ` face axis: ${pyRound(f.degrees, 6)} degrees, the line through the eye bones eye_r and eye_l (on ${f.parts[0]} and ${f.parts[1]}); ${turned}${own}${held}${tips}${up}`;
972
978
  }
973
979
 
980
+ /**
981
+ * The rig stage's turn line (issue #251): the control, and per carried part its fraction, the depth it came from and
982
+ * how many twins carry it — a part at the pivot depth said to stay. Printed only when the config has `turn`.
983
+ */
984
+ export function turnLine(rows: readonly TurnRow[]): string {
985
+ const carried = rows.filter((r) => r.twins.length > 0);
986
+ const twins = carried.reduce((n, r) => n + r.twins.length, 0);
987
+ const parts = rows.map((r) => (r.twins.length === 0 ? `${r.part} stays (depth ${r.depth}, the pivot's)` : `${r.part} ${r.fraction} (depth ${r.depth}, ${r.twins.length} twin(s))`));
988
+ return ` turn: control "${TURN_BONE}" under root, keyed by nothing; ${carried.length} part(s) carried by ${carried.length} transform constraint(s) over ${twins} twin bone(s), each part's x a fraction of the control's: ${parts.join(', ')}`;
989
+ }
990
+
974
991
  /** A judgement line as the console prints it: its name, its status, then its figures and bars as check.json holds them (a SKIP prints its reason). */
975
992
  export function judgementLine(name: string, line: JudgementLine | ReportedLine): string {
976
993
  if (line.status === 'SKIP') return `${name}: SKIP — ${String(line.reason)}`;
package/src/check.ts CHANGED
@@ -149,6 +149,7 @@ import { readPng, writePng } from './raster/png.ts';
149
149
  import { newFloatImage, newRaster, type Raster } from './raster/types.ts';
150
150
  import type { BaseTag } from './tags.ts';
151
151
  import { type ScaleTranslate, warpAffine } from './raster/warp.ts';
152
+ import { edgeLength } from './length.ts';
152
153
 
153
154
  // ---------------------------------------------------------------------------
154
155
  // the bars
@@ -2450,10 +2451,17 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2450
2451
  rows.push({ part: p.name, ...said, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
2451
2452
  if (!ok) {
2452
2453
  failed = true;
2454
+ // Issue #262: a root the breath carries is named with its carrier, not blamed on the chain.
2455
+ const carry = breathCarrier(inp.rig, inp.motion, p.name, geometryPath, frames, { art, box: p, stage: inp.stage, shown, toward: rises ? 'up' : 'down', root: t.root, tip: t.tip });
2456
+ if (carry !== null) rows[rows.length - 1] = { ...rows[rows.length - 1], carried_by: carry.bone, breath_rig_px: round3(carry.breath), root_without_breath_rig_px: round3(carry.root), tip_without_breath_rig_px: round3(carry.tip) };
2457
+ const why =
2458
+ carry === null
2459
+ ? "the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain"
2460
+ : `the root travels with the breath (${round3(t.root)} px of ${round3(carry.breath)} px): it is carried by \`${carry.bone}\`, which moves it more than the tip; with \`${carry.keyed}\`'s translate held still the root travels ${round3(carry.root)} rig px and the tip ${round3(carry.tip)}, which the bar passes (ratio ${carry.root > 0 ? round3(carry.tip / carry.root) : 'undefined: the root moves only with it'}) — bind the root to the bone the part hangs from, not to \`${carry.bone}\``;
2453
2461
  problems.push({
2454
2462
  code: 'CHECK_TIP_OVER_ROOT',
2455
2463
  object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry${rises ? ', declared rises' : ''}`,
2456
- detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain`,
2464
+ detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — ${why}`,
2457
2465
  });
2458
2466
  }
2459
2467
  }
@@ -2465,6 +2473,110 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2465
2473
  return { status: failed ? 'FAIL' : 'PASS', ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
2466
2474
  }
2467
2475
 
2476
+ /**
2477
+ * The bones a part's attachments are bound to in the default skin (issue #231): a mesh's weights, or its slot's
2478
+ * bone for an attachment that carries no weights.
2479
+ */
2480
+ function boundBones(rig: Record<string, unknown>, part: string): Set<string> {
2481
+ const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
2482
+ const skins = isRecord(rig.skins) ? rig.skins : {};
2483
+ const skin = isRecord(skins.default) ? skins.default : {};
2484
+ const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
2485
+ const bound = new Set<string>();
2486
+ for (const a of atts) {
2487
+ if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
2488
+ if (!(isRecord(a) && Array.isArray(a.weights)) && slot !== undefined && typeof slot.bone === 'string') bound.add(slot.bone);
2489
+ }
2490
+ return bound;
2491
+ }
2492
+
2493
+ /** The idle track properties that move a bone without turning it: the breath is one of these (issue #262). */
2494
+ const BREATH_PROPS: readonly string[] = ['translate', 'translatex', 'translatey'];
2495
+
2496
+ /**
2497
+ * The bone a failing part's root travels with, when that is the breath's (issue #262). The breath is every idle
2498
+ * track that translates a bone ({@link BREATH_PROPS}), each read on its own: `K` the bone it keys. Its motion is
2499
+ * `K`'s world origin less where `K`'s parent carries `K`'s setup position, frame by frame — what the translate keys
2500
+ * add — and the breath's travel is that, less its frame-0 value, at its largest. The part (one weighted mesh; a
2501
+ * region is carried whole and cannot stretch) is posed again with that motion taken out of every vertex in
2502
+ * proportion to its weight on `K` and the bones under it, and its halves measured as {@link halfTravels} measures
2503
+ * them. The root travels with the breath when the breath adds more to the root's travel than to the tip's and, held
2504
+ * still, the halves pass the bar: tip >= {@link TIP_RATIO_FLOOR} x root, the tip above 0 at the line's precision
2505
+ * (round3). The margin is the bar's own, so the line names the carrier exactly when the breath is what fails it;
2506
+ * the carrier named is the bone the part is bound to ({@link boundBones}) at or under `K` that no other such bone is
2507
+ * above (the sleeve's links ride `chest`, which is the carrier). Null otherwise.
2508
+ */
2509
+ function breathCarrier(
2510
+ rig: Record<string, unknown>,
2511
+ motion: Record<string, unknown>,
2512
+ part: string,
2513
+ geometryPath: string,
2514
+ frames: number,
2515
+ m: { art: ArtMask; box: { x: number; y: number; w: number; h: number }; stage: StageBox; shown: ReadonlyArray<PosedAttachment | null>; toward: 'down' | 'up'; root: number; tip: number },
2516
+ ): { bone: string; keyed: string; breath: number; root: number; tip: number } | null {
2517
+ const anims = isRecord(motion.animations) ? motion.animations : {};
2518
+ const idle = isRecord(anims.idle) ? anims.idle : {};
2519
+ const keyed = [...new Set((Array.isArray(idle.tracks) ? idle.tracks : []).filter(isRecord).filter((t) => typeof t.bone === 'string' && typeof t.property === 'string' && BREATH_PROPS.includes(t.property)).map((t) => String(t.bone)))];
2520
+ if (keyed.length === 0) return null;
2521
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
2522
+ const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
2523
+ const setupAt = new Map(bones.map((b) => [String(b.name), [typeof b.x === 'number' ? b.x : 0, typeof b.y === 'number' ? b.y : 0] as const]));
2524
+ const atOrUnder = (b: string, anc: string): boolean => {
2525
+ const seen = new Set<string>();
2526
+ for (let x: string | null = b; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
2527
+ if (x === anc) return true;
2528
+ seen.add(x);
2529
+ }
2530
+ return false;
2531
+ };
2532
+ // The one weighted mesh the part draws, its weights in the order of the geometry's vertices.
2533
+ const skins = isRecord(rig.skins) ? rig.skins : {};
2534
+ const skin = isRecord(skins.default) ? skins.default : {};
2535
+ const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
2536
+ const first = m.shown[0];
2537
+ if (atts.length !== 1 || first === null || first === undefined) return null;
2538
+ const att = atts[0];
2539
+ if (!isRecord(att) || !Array.isArray(att.weights) || att.weights.length * 2 !== first.rest.length) return null;
2540
+ const weights = att.weights.map((v) => (Array.isArray(v) ? v.filter(isRecord).filter((w) => typeof w.bone === 'string' && typeof w.weight === 'number') : []));
2541
+ const geo = readGeometry(geometryPath, frames);
2542
+ if (geo === null) return null;
2543
+ const world = (bs: unknown, name: string): BoneWorld | null => {
2544
+ const b = Array.isArray(bs) ? bs.filter(isRecord).find((x) => x.name === name) : undefined;
2545
+ if (b === undefined) return null;
2546
+ const f = [b.a, b.b, b.c, b.d, b.worldX, b.worldY];
2547
+ if (!f.every((v) => typeof v === 'number' && Number.isFinite(v))) return null;
2548
+ return { a: b.a as number, b: b.b as number, c: b.c as number, d: b.d as number, worldX: b.worldX as number, worldY: b.worldY as number };
2549
+ };
2550
+ const bound = [...boundBones(rig, part)];
2551
+ for (const k of keyed) {
2552
+ const under = bound.filter((b) => atOrUnder(b, k));
2553
+ const carrier = under.find((b) => !under.some((o) => o !== b && atOrUnder(b, o)));
2554
+ if (carrier === undefined) continue;
2555
+ // What the translate keys add to K's world origin, frame by frame, less frame 0's.
2556
+ const [lx, ly] = setupAt.get(k) ?? [0, 0];
2557
+ const pk = parent.get(k) ?? null;
2558
+ const added: Array<[number, number] | null> = geo.frameBones.map((bs) => {
2559
+ const w = world(bs, k);
2560
+ if (w === null) return null;
2561
+ const pw = pk === null ? { a: 1, b: 0, c: 0, d: 1, worldX: 0, worldY: 0 } : world(bs, pk);
2562
+ if (pw === null) return null;
2563
+ return [w.worldX - (pw.a * lx + pw.b * ly + pw.worldX), w.worldY - (pw.c * lx + pw.d * ly + pw.worldY)];
2564
+ });
2565
+ const a0 = added[0];
2566
+ if (a0 === null || added.some((d) => d === null)) continue;
2567
+ const delta = (added as Array<[number, number]>).map(([x, y]) => [x - a0[0], y - a0[1]] as const);
2568
+ const breath = Math.max(...delta.map(([x, y]) => edgeLength(x, y)));
2569
+ if (!(breath > 0)) continue;
2570
+ const share = weights.map((v) => v.filter((w) => atOrUnder(String(w.bone), k)).reduce((s, w) => s + (w.weight as number), 0));
2571
+ const held = m.shown.map((f, i) => (f === null ? null : { rest: f.rest, triangles: f.triangles, posed: f.posed.map((v, j) => v - share[j >> 1] * delta[i][j & 1]) }));
2572
+ const t = halfTravels(m.art, m.box, m.stage, held, m.toward);
2573
+ if (t.root === null || t.tip === null) continue;
2574
+ if (!(m.root - t.root > m.tip - t.tip) || round3(t.tip) === 0 || !(t.tip >= TIP_RATIO_FLOOR * t.root)) continue;
2575
+ return { bone: carrier, keyed: k, breath, root: t.root, tip: t.tip };
2576
+ }
2577
+ return null;
2578
+ }
2579
+
2468
2580
  /**
2469
2581
  * Which way the chain carrying a part runs (issue #231), for a part the scene
2470
2582
  * declares `rises`: the bones its attachments are bound to in the default skin
@@ -2480,15 +2592,7 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
2480
2592
  export function chainDirection(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string, origins: ReadonlyMap<string, { x: number; y: number }>): { toward: Toward; chains: string[] } | { none: string } {
2481
2593
  const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
2482
2594
  const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
2483
- const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
2484
- const skins = isRecord(rig.skins) ? rig.skins : {};
2485
- const skin = isRecord(skins.default) ? skins.default : {};
2486
- const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
2487
- const bound = new Set<string>();
2488
- for (const a of atts) {
2489
- if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
2490
- if (!(isRecord(a) && Array.isArray(a.weights)) && slot !== undefined && typeof slot.bone === 'string') bound.add(slot.bone);
2491
- }
2595
+ const bound = boundBones(rig, part);
2492
2596
  const under = (b: string, anc: string): boolean => {
2493
2597
  const seen = new Set<string>();
2494
2598
  for (let x = parent.get(b) ?? null; x !== null && !seen.has(x); x = parent.get(x) ?? null) {