@rig-c/pose 0.20.3 → 0.20.5

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
@@ -195,19 +195,19 @@ Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. O
195
195
 
196
196
  A knee bending toward the camera is seen from the front only as a shorter leg, and a rig with rigid legs cannot lower its hip without lifting its feet ([#47](https://github.com/rig-c/pose/issues/47)). Two flags, both off by default:
197
197
 
198
- **`--leg-scale <min,max>`** scales each mapped leg segment (two of one side's hip, knee and ankle) along its own x axis — a `scalex` key — by its picture length on the frame over its **straight length**, the 95th percentile of its picture length over the sequence, clamped to [min, max]. The card's range is `0.8,1.05`. A segment the map aims other than `"x"` is refused (`LEGS_MAP`). `LEG-SCALE` lists each segment's frames clamped at either limit.
198
+ **`--leg-scale <min,max>`** scales each mapped leg segment (two of one side's hip, knee and ankle) along its own x axis — a `scalex` key — by its picture length on the frame over its **straight length**, the larger of the two sides' 95th-percentile picture lengths for that segment over the sequence ([#148](https://github.com/rig-c/pose/issues/148)), clamped to [min, max]. The card's range is `0.8,1.05`. A segment the map aims other than `"x"` is refused (`LEGS_MAP`). `LEG-SCALE` lists each segment's frames clamped at either limit.
199
199
 
200
- rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inherit mode takes its parent's whole world matrix. So under a thigh scaled by s, a shin at angle φ in the thigh's axes points at atan2(sin φ, s·cos φ), and is itself scaled by |(s·cos φ, sin φ)|. The rotate key `turn − turn above` assumes unit scale. A mapped bone under a scaled bone is therefore keyed through its parent's posed matrix: a `rotate` key k and a `scalex` key s' make its local matrix R(k) · L · diag(s', 1), L its setup local matrix, and k and s' are solved so its world x axis lands on its target at the scale asked. On HQ's dancer the unit-scale formula missed the shins by up to 2.4°, their length by 0.16 of setup and the ankles by 38 units; solved, every bone lands. The child's art is still sheared by its parent's scale, and a bone under it (a foot) is squashed along the shin: `SHEAR` reads both.
200
+ rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inherit mode takes its parent's whole world matrix. So under a thigh scaled by s, a shin at angle φ in the thigh's axes points at atan2(sin φ, s·cos φ), and is itself scaled by |(s·cos φ, sin φ)|. A child (or its intermediate control bone) with `noScale` or `noScaleOrReflection` ([#153](https://github.com/rig-c/pose/issues/153)) still has its direction carried through the parent's scaled matrix, so its angle is skewed the same way, while rig-c normalises that direction to unit length so the parent's scale is not inherited. The rotate key `turn − turn above` assumes unit scale. A mapped bone under a scaled bone is therefore keyed through its parent's posed matrix: a `rotate` key k and a `scalex` key s' put its world x axis on its target at the scale asked (under `noScale`, s' is the bone's own asked scale s directly). On HQ's dancer the unit-scale formula missed the shins by up to 2.4°, their length by 0.16 of setup and the ankles by 38 units; solved, every bone lands. A `normal` child's art is still sheared by its parent's scale, and a bone under it (a foot) is squashed along the shin: `SHEAR` reads both.
201
201
 
202
202
  **`--plant-feet`** places the hip so the supporting foot stays where it took support, through the posed legs at the scale the pose asked for (when a segment asks for a scale below `--leg-scale`'s clamp floor, the clamp does not lift the hip by what it withheld, [#107](https://github.com/rig-c/pose/issues/107); on a supporting leg, the segment's `scalex` follows the asked scale down to the bound #95 derives from the leg's own range so the supporting ankle stays on #85's floor while the free leg keeps the configured clamp, and any frame clamped at #95's bound lifts the hip by the remainder so the ankle never sinks, listed as `LEG-SCALE past the floor (support)`, [#133](https://github.com/rig-c/pose/issues/133)), instead of moving it by the pose's mid_hip travel:
203
203
 
204
204
  - The supporting foot is the one whose reference ankle is lower in the picture. It changes only when the other ankle has been at least **0.02 leg lengths** lower for at least **0.1 s** (timed as `END-ON` runs are), and once confirmed takes support from the frame it crossed that margin ([#102](https://github.com/rig-c/pose/issues/102)); a leg length is each side's median picture length hip → ankle, and the switch compares against their mean.
205
205
  - The new support is held where it is on the frame it takes over, and the hip blends in y from the old derivation to the new over **3 frames** while taking the new derivation's x immediately ([#102](https://github.com/rig-c/pose/issues/102)), so the planted foot does not slide across the blend.
206
- - A **jump** — both reference ankles **and** mid_hip more than **0.05** leg lengths above their **rolling floor**, the 95th-percentile picture y over ±1.0 s around the frame, for at least **2 frames** — releases the plant: the hip follows the pose's travel from where it was, and re-plants on landing (blended the same way). At a walking hand-over the front foot has not landed yet while the back ankle lifts its heel and the hip rises 0.051–0.065 leg lengths; when the front foot lands while the back ankle stays above its floor, a back ankle that holds its height in y (< **0.01** leg lengths a frame for **2 frames**) is on a raised heel rather than in flight and is not read as a jump ([#101](https://github.com/rig-c/pose/issues/101)). The body has to lift: an ankle's picture y drifts with depth, and under one floor for the whole sequence HQ's dancer standing still (frames 0–29 and 277–312, mid_hip moving 10 and 14 px) read as jumping, 152–155 of 313 frames in all.
206
+ - A **jump** — both reference ankles **and** mid_hip more than **0.05** leg lengths above their **rolling floor**, the 95th-percentile picture y over ±1.0 s around the frame, for at least **2 frames**, starting from the first immediately preceding push-off frame where both ankles are already above their rolling floor while mid_hip lags ([#142](https://github.com/rig-c/pose/issues/142)) — releases the plant: the hip follows the pose's travel from where it was, and re-plants on landing (blended the same way). At a walking hand-over the front foot has not landed yet while the back ankle lifts its heel and the hip rises 0.051–0.065 leg lengths; when the front foot lands while the back ankle stays above its floor, a back ankle that holds its height in y (< **0.01** leg lengths a frame for **2 frames**) is on a raised heel rather than in flight and is not read as a jump ([#101](https://github.com/rig-c/pose/issues/101)). The body has to lift: an ankle's picture y drifts with depth, and under one floor for the whole sequence HQ's dancer standing still (frames 0–29 and 277–312, mid_hip moving 10 and 14 px) read as jumping, 152–155 of 313 frames in all.
207
207
  - **In place** ([#92](https://github.com/rig-c/pose/issues/92)). On a sequence with `"travel": "in-place"`, the supporting foot of a figure that does not travel moves back in the picture while the body stays. Holding it where it planted turns that slide into the body's travel, so the hip walks off and never comes back. Under the declaration each plant's anchor follows its own ankle's picture x, scaled into the rig, from the frame it planted, and keeps its y on the one floor. No step releases a foot, since a foot that follows its track has no plant to drag. Every foot may take support, and the lower one does, by the rules above. The loop is then closed: the hip's x on frame i moves by −drift · i / (n − 1), where drift is its x on the last frame less its x on the first. The last frame's hip x is then the first's, and the loop carries no net travel. The y is the floor's and is not touched. `FEET` prints the drift closed, in place of the steps line, and the report's `feet.closure` carries `{ drift, frames }` (`null` otherwise). On the tests' synthetic in-place loop (40 frames, each supporting ankle sliding back 2 px a frame, the hips still), the hip moves less than 10⁻⁹ px. The same joints without the declaration put it 68 px forward over the loop. With a slide of 0.04 leg lengths a frame, both feet are released as stepping and never re-plant without the declaration, and the supports alternate with it. Jumps are read as before.
208
208
  - A **step** releases the plant too: while the supporting ankle moves more than **0.03** leg lengths a frame, the other foot takes support if it is planted (not itself released or moving), and otherwise the hip follows the pose's travel. The released ankle may support again once it has moved less than **0.01** leg lengths a frame for **2 frames**. On the dancer the plant held the supporting ankle through a real step (frames 176–181, 579 → 718 px) and threw the hip sideways.
209
209
  - **Read from the lower ankle** ([#113](https://github.com/rig-c/pose/issues/113), [#119](https://github.com/rig-c/pose/issues/119)). On a sequence not declared `"in-place"`, a foot whose ankle is at least **0.02 leg lengths** higher in the picture than the other's never takes support: not when a plant starts, and not when a step releases the other foot. Before, a step handed support to the only foot free to take it, even a folded, lifted one, and the hip was then placed under a foot in the air. A supporting foot that the other ankle has been that much lower than for **0.1 s** hands support to it when it may take it, as before; when the lower foot may not (it is released and not yet still, or moving), the supporting foot is released, and the hip follows the pose's travel until a foot may take support. On a frame after a jump or a step's release where the only foot free under #113 is the higher one (`cause: "higher"`), the lower foot supports **tracked, not planted** ([#119](https://github.com/rig-c/pose/issues/119)): the hip puts it on #85's floor and follows its scaled ankle track (`FEET tracked`, `feet.tracked`) until it has been still for 2 frames (`< 0.01`), when the normal plant takes over at that position. Steps and jumps are read as before, and under `"in-place"` #92's reading stands. `FEET support read from the lower ankle` names every run of frames whose support differs from the reading before #113, the side before and after (`none`: no foot supports), and for each `none` why: `released`, the supporting foot let go because the lower ankle could not take support (released by a step, or moving), or the only foot free to take support was the higher one. The report's `feet.lowered` holds them as `{ frames, was, now, cause }` (absent in place). On the tests' synthetic step under a lifted leg (the lifted ankle 0.2 leg lengths up, the grounded one stepping 0.05 leg lengths on frame 10 and still from 11), the lifted leg took support on frames 10–11 before; now frames 10–11 track the lower foot on the floor (`feet.tracked`) and the grounded one is planted from 12, when it is still again.
210
- - **One floor** ([#85](https://github.com/rig-c/pose/issues/85)). A 2D rig has no depth, so its floor is one line, and a planted foot is on it. The picture disagrees: a step toward the camera puts the foot lower in the frame, and a camera residual puts a whole span higher, so a plant held where the picture puts each span leaves the floor stepping between spans. Every support span's supporting ankle is therefore moved in y onto one floor, the hip and everything above with it. A span's *planted height* is the world y (on the rig, posed by rig-c) its supporting ankle would be held at; the floor is the median of the spans' planted heights, or **`--floor <y>`** (world units, y up; only with `--plant-feet`, exit 2 without it). Each span's *shift* is floor − its planted height. A change of support still blends the hip over the 3 frames, now between two spans on the floor, and a planted foot does not leave the floor: on each blend frame the **supporting** leg is stretched along its chain about its root, by the factor `--stance` uses, k = (root y − floor y) / (root y − ankle y), held within **[0.92, 1.08]**, its lower bound on the frame the supporting leg's own range as `--stance`'s ([#95](https://github.com/rig-c/pose/issues/95); where that went below 0.92, a `FEET bound from the leg's own range` line, the report's `feet.blend.range`, and each blend run's `lower`). A frame that needs more is held at the bound, the rest left to the blend, and listed `FEET past the bounds` with the k needed and how far the foot was left from the floor. An ankle already within 0.01 units (the landing check's origin tolerance) is not stretched. The stretched scales are scale targets, so the landing check holds them; `--stance` then stretches the free leg, a different chain, to the supporting ankle. A jump follows the pose's travel from the hip it left, which is on the floor, carried linearly from take-off so the landing frame meets the floor and clamped so no frame inside the flight puts a foot below it ([#103](https://github.com/rig-c/pose/issues/103)), and is held the same way over its first 3 planted frames. `--stance` is unchanged: it stretches the free leg to the supporting ankle, which is on the floor. On the tests' synthetic stick, three spans 0, 6 and 1 px apart in the picture (0, 9 and 1.5 units on the rig) plant within 0.0001 units of one floor. With the shift forced to 0 they stay 9 units apart; without the stretch, the supporting ankle leaves the floor by 6.75 units on the first blend frame.
210
+ - **One floor** ([#85](https://github.com/rig-c/pose/issues/85)). A 2D rig has no depth, so its floor is one line, and a planted foot is on it. The picture disagrees: a step toward the camera puts the foot lower in the frame, and a camera residual puts a whole span higher, so a plant held where the picture puts each span leaves the floor stepping between spans. Every support span's supporting ankle is therefore moved in y onto one floor, the hip and everything above with it. A span's *planted height* is the world y (on the rig, posed by rig-c) its supporting ankle would be held at; the floor is the median of the spans' planted heights, or **`--floor <y>`** (world units, y up; only with `--plant-feet`, exit 2 without it). Each span's *shift* is floor − its planted height. A change of support still blends the hip over the 3 frames, now between two spans on the floor, and a planted foot does not leave the floor: on each blend frame the **supporting** leg is stretched along its chain about its root, by the factor `--stance` uses, k = (root y − floor y) / (root y − ankle y), held within **[0.92, 1.08]**, its lower bound on the frame the supporting leg's own range as `--stance`'s ([#95](https://github.com/rig-c/pose/issues/95); where that went below 0.92, a `FEET bound from the leg's own range` line, the report's `feet.blend.range`, and each blend run's `lower`). A frame that needs more is held at the bound, the rest left to the blend, and listed `FEET past the bounds` with the k needed and how far the foot was left from the floor. An ankle already within 0.01 units (the landing check's origin tolerance) is not stretched. The stretched scales are scale targets, so the landing check holds them; `--stance` then stretches the free leg, a different chain, to the supporting ankle. A jump follows the pose's travel from the hip it left, which is on the floor, carried linearly from take-off so the landing frame meets the floor and clamped so no frame inside the flight puts a foot below it ([#103](https://github.com/rig-c/pose/issues/103)), and is held the same way over its first 3 planted frames, with the flight's carry held across the landing blend so it fades out with the blend weights rather than dropping on the first frame after landing ([#147](https://github.com/rig-c/pose/issues/147)). `--stance` is unchanged: it stretches the free leg to the supporting ankle, which is on the floor. On the tests' synthetic stick, three spans 0, 6 and 1 px apart in the picture (0, 9 and 1.5 units on the rig) plant within 0.0001 units of one floor. With the shift forced to 0 they stay 9 units apart; without the stretch, the supporting ankle leaves the floor by 6.75 units on the first blend frame.
211
211
  - `FEET` lists the supports, the jumps and the steps (each with the side, its frames, who took support and where it re-planted; in place, the loop's closure instead), and with `--plant-feet` the floor: its world y and where it came from, each span's shift in units and in that side's rig leg at setup (thigh origin → ankle), and the shifts' range, then one line per run of blend frames whose supporting leg was stretched (`blend held on the floor`) and per run `past the bounds`. The report carries them under `feet` (`supports`, `jumps`, `steps`, `changes`, `floor`, `shifts`, `blend`, `closure`).
212
212
  - **`--stance <leg lengths>`** (only with `--plant-feet`, which it is refused without; off by default) plants **both** feet when the free one is near the floor ([#60](https://github.com/rig-c/pose/issues/60)). On a frame with a supporting side (so outside every jump), the free ankle's height above the supporting one is read in the picture, in the free side's leg length — the leg length `FEET` uses. The tolerance is a **distance** ([#114](https://github.com/rig-c/pose/issues/114)): within it, above the supporting ankle or below it, the frame is **planted both**: every bone of the free leg's chain has its `scalex` key multiplied by k = (root y − floor y) / (root y − ankle y) on the posed rig, the root being the chain's first bone's origin and the floor the supporting ankle, so the foot reaches the floor. A free ankle within the tolerance below the supporting one is planted too: its k is under 1, since a foot slightly under the floor is the same fault as one floating above it. A free ankle **past the tolerance below** the supporting one is not a stance but a support error (the foot read as supporting is the one in the air), so that frame is never planted both and keeps the pose's height; it is named on a `STANCE past the tolerance below` line with the side, the frames and the largest distance, and read `"below"` in the report's `stance.runs`. Before #114 the gap was signed, so any free ankle below the supporting one counted as within, and a lifted foot read as supporting had the grounded leg shrunk up to it. On the tests' synthetic stance, a free ankle 0.5 leg lengths below is read below and not planted; one 0.03 below is planted with k under 1. k is held within **[0.92, 1.08]**, except that the lower bound comes from the leg's own range in the sequence ([#95](https://github.com/rig-c/pose/issues/95)): per side, r = the leg's picture length hip → ankle on the frame ÷ its straight length (the 95th percentile of that length, as `--leg-scale` reads a segment's), and the frame's lower bound is min(0.92, r), so the rig leg may fold as far as the picture folds it on that frame and never less far than 0.92. The upper bound stays 1.08, since a picture leg does not read longer than straight. A sequence whose legs never fold below 0.92 keeps exactly [0.92, 1.08]. Where the range set a bound below 0.92 on a planted-both frame, one `STANCE bound from the leg's own range` line gives each side's lowest, as does the report's `stance.range`. A frame past its bound is listed `past the bounds` with the bound it had, held at the bound and not forced. Past the tolerance above, the foot is **lifted** and keeps the pose's height. The state is read with **hysteresis** and a **shortest run** ([#87](https://github.com/rig-c/pose/issues/87)), on the distance: a planted foot leaves the stance when its distance from the supporting one goes past the tolerance, and one past it is planted again only under **0.8** of it (both figures are on the `STANCE` line). A change of state takes effect only when its condition holds for **0.1 s** (by the frames' times). A shorter crossing with the other state on both sides of it keeps their state and is listed `absorbed` with its frames and the height that crossed (`absorbed: true` in the report's `stance.runs`, beside the run that took it in). One that runs to the end of a support span is taken, since nothing of the other state follows it. A threshold read frame by frame turned noise at the threshold into a pop: one frame at 0.0708 against 0.07 dropped the stretch for one frame and the foot jumped up and back. The stretched scales are the scale targets, so the landing check holds them. Measured with `--stance 0.07` and HQ's flags (`--smooth 7 --flip 90 --short 0.35 --bends clamp --leg-scale 0.8,1.05 --plant-feet --carry-idle`): on HQ's dancer, frames 94–175 outside the jumps, the free ankle's height above the supporting one on the posed build went from median 18.3 and p90 25.4 units to 0.0 and 0.0. On a second sequence (Toca, 492 frames at 30 fps) the 29 frames labelled by eye as both feet flat, outside the jumps, read 0.0 and 0.0. Of the 6 labelled one foot lifted, 5 stay above the tolerance; the sixth (frame 360) was 0.042 leg lengths up in the picture, within the tolerance, and so planted; without `--stance` it stood 6.5 units up, also under the tolerance (22.6 units there). That is 83 %, under the 90 % the card asked.
213
213
  - The map needs one chain ending at each ankle whose last bone is aimed `"x"` (the ankle is that bone's tip), and a `translate` the legs hang from (`LEGS_MAP`). A `range`, or a set's `turn` or `offsets`, on a bone between the hip and an ankle is refused (`LEGS_RANGE`): the plant is derived before the range clamp, and a clamp after it would move the planted foot.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.20.3",
3
+ "version": "0.20.5",
4
4
  "description": "Puts motion from a body-18 pose sequence onto a rig built with rig-c: bone keys in rig-c's motion format, checked through rig-c's gate before anything is written. A CLI for agents that cannot see the rig.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/legs.ts CHANGED
@@ -12,14 +12,16 @@
12
12
  * x axis, and a child with the `normal` inherit mode takes the parent's whole
13
13
  * world matrix — so under a thigh scaled by s, a shin at angle φ in the thigh's
14
14
  * axes points at atan2(sin φ, s·cos φ), not φ, and its own length is scaled by
15
- * |(s·cos φ, sin φ)|. The rotate key `turn − turn above` assumes unit scale. A
16
- * mapped bone below a scaled bone is therefore keyed through its parent's
17
- * posed matrix (`posePath`): with rig-c's semantics, a `rotate` key k and a
18
- * `scalex` key s' make the bone's local matrix R(k) · L · diag(s', 1), L its
19
- * setup local matrix (from rig-c's setup world matrices), so k and s' are the
20
- * ones that put its world x axis on the target angle at the asked length. Its
21
- * art is still sheared by the parent's scale; that is measured, not hidden
22
- * (`shear` in the report).
15
+ * |(s·cos φ, sin φ)|. A child with `noScale` or `noScaleOrReflection`
16
+ * (rig-c/pose#153) still has its direction carried through the parent's scaled
17
+ * matrix, so its angle is skewed the same way, while rig-c normalises that
18
+ * direction to unit length so the parent's scale is not inherited. The rotate
19
+ * key `turn − turn above` assumes unit scale. A mapped bone below a scaled bone
20
+ * is therefore keyed through its parent's posed matrix (`posePath`): with
21
+ * rig-c's semantics, a `rotate` key k and a `scalex` key s' put its world x
22
+ * axis on the target angle at the asked length (under `noScale`, s' is the
23
+ * bone's own asked scale s directly). Its art is still sheared by a `normal`
24
+ * parent's scale; that is measured, not hidden (`shear` in the report).
23
25
  *
24
26
  * **Planted feet.** The hip is no longer the pose's mid_hip travel scaled. On
25
27
  * every frame one foot supports: the one whose reference ankle is lower in the
@@ -35,7 +37,9 @@
35
37
  * picture y over ± `FLOOR_SECONDS`), for ≥ `JUMP_FRAMES` frames, excluding a
36
38
  * hand-over whose back ankle stays up and holds its height in y (`< STILL_SPEED`
37
39
  * for `STILL_FRAMES` frames) when the front foot lands (a raised heel,
38
- * rig-c/pose#101) — releases the plant: the hip follows the pose's travel from
40
+ * rig-c/pose#101), and starting from the first preceding push-off frame where
41
+ * both ankles are already above their rolling floor while mid_hip lags
42
+ * (rig-c/pose#142) — releases the plant: the hip follows the pose's travel from
39
43
  * where it was, and re-plants on landing. A step does too: while the supporting ankle moves more than
40
44
  * `STEP_SPEED` leg lengths a frame its plant is released, the other foot takes
41
45
  * support if it is planted (not released, not moving) and otherwise the hip
@@ -162,12 +166,37 @@ export interface PathPose {
162
166
  solved: Map<string, { rotate: number; scale: number }>;
163
167
  }
164
168
 
169
+ /**
170
+ * World 2×2 of a `noScale` or `noScaleOrReflection` bone under `Wp` at rotate
171
+ * key `k` (degrees) and scalex key `sk` (rig-c/pose#153), matching rig-c's
172
+ * `modeMatrix` (`rigc/src/core/world.ts`): the bone's local x direction is
173
+ * rotated by `k`, carried through `Wp` and normalised to unit length before its
174
+ * setup local frame (`F0`) and `sk` are applied.
175
+ */
176
+ export function poseNoScale(Wp: M2, Wp0: M2, W0: M2, inherit: 'noScale' | 'noScaleOrReflection', k: number, sk: number): M2 {
177
+ const L0 = mul(inv(Wp0), W0);
178
+ const l0: Pt = [L0[0], L0[2]];
179
+ const len0 = Math.hypot(W0[0], W0[2]);
180
+ const u0: Pt = [W0[0] / len0, W0[2] / len0];
181
+ const flip0 = inherit === 'noScale' && Wp0[0] * Wp0[3] - Wp0[1] * Wp0[2] < 0 ? -1 : 1;
182
+ const turned0: M2 = [u0[0], -u0[1] * flip0, u0[1], u0[0] * flip0];
183
+ const F0 = mul(inv(turned0), W0);
184
+ const u = apply(Wp, apply(rot(k), l0));
185
+ const invLen = 1 / Math.sqrt(u[0] * u[0] + u[1] * u[1]);
186
+ const ux = u[0] * invLen;
187
+ const uy = u[1] * invLen;
188
+ const flip = inherit === 'noScale' && Wp[0] * Wp[3] - Wp[1] * Wp[2] < 0 ? -1 : 1;
189
+ const turned: M2 = [ux, -uy * flip, uy, ux * flip];
190
+ return mul(mul(turned, F0), scaleX(sk));
191
+ }
192
+
165
193
  /**
166
194
  * Pose a path of bones, parents first, the first the path's top (its origin is
167
195
  * (0, 0)). A mapped bone with no scaled bone above it turns rigidly from setup
168
196
  * (R(turn) · setup world · diag(scale, 1)); a mapped bone under a scaled bone
169
197
  * is solved through its parent's posed matrix (the header); an unmapped bone
170
- * keeps its setup local matrix. Every setup matrix and origin is rig-c's.
198
+ * keeps its setup local matrix (or its `noScale` unit frame, #153). Every setup
199
+ * matrix and origin is rig-c's.
171
200
  */
172
201
  export function posePath(rest: Rest, path: readonly string[], posing: (b: string) => Posing | null, scaleOf: (b: string) => number | null): PathPose | string {
173
202
  const origin = new Map<string, Pt>();
@@ -197,7 +226,8 @@ export function posePath(rest: Rest, path: readonly string[], posing: (b: string
197
226
  origin.set(b, [po[0] + o[0], po[1] + o[1]]);
198
227
  const L0 = mul(inv(Wp0), W0);
199
228
  const under = scaledAbove.has(p);
200
- if (pz === null) m.set(b, mul(Wp, L0));
229
+ const noScale = rb.inherit === 'noScale' || rb.inherit === 'noScaleOrReflection' ? rb.inherit : null;
230
+ if (pz === null) m.set(b, noScale !== null ? poseNoScale(Wp, Wp0, W0, noScale, 0, 1) : mul(Wp, L0));
201
231
  else if (!under) m.set(b, mul(mul(rot(pz.turn), W0), scaleX(s)));
202
232
  else {
203
233
  if (!pz.aimX) return `bone "${b}" is mapped under a scaled bone and aimed other than along its x axis; only an "x" aim is solved through a scaled parent`;
@@ -206,8 +236,9 @@ export function posePath(rest: Rest, path: readonly string[], posing: (b: string
206
236
  const v = apply(inv(Wp), X);
207
237
  const l0: Pt = [L0[0], L0[2]];
208
238
  const k = (Math.atan2(v[1], v[0]) - Math.atan2(l0[1], l0[0])) * DEG;
209
- const sk = Math.hypot(v[0], v[1]) / Math.hypot(l0[0], l0[1]);
210
- m.set(b, mul(mul(mul(Wp, rot(k)), L0), scaleX(sk)));
239
+ // Under noScale / noScaleOrReflection (#153), rig-c normalises the direction carried through Wp to unit length, so the parent's scale is not inherited and the bone's own scalex is s directly.
240
+ const sk = noScale !== null ? s : Math.hypot(v[0], v[1]) / Math.hypot(l0[0], l0[1]);
241
+ m.set(b, noScale !== null ? poseNoScale(Wp, Wp0, W0, noScale, k, sk) : mul(mul(mul(Wp, rot(k)), L0), scaleX(sk)));
211
242
  solved.set(b, { rotate: k, scale: sk });
212
243
  }
213
244
  if (under || scaleOf(b) !== null) scaledAbove.add(b);
@@ -268,7 +299,7 @@ export interface LegScale {
268
299
  chain: string;
269
300
  bone: string;
270
301
  joints: [string, string];
271
- /** The straight length: the 95th percentile of the segment's picture length, px. */
302
+ /** The straight length: the larger of the two sides' 95th-percentile picture lengths for this segment (#148), px. */
272
303
  straight: number;
273
304
  /** The picture length over the straight length, before the clamp: lowest, median, highest. */
274
305
  ratio: { min: number; median: number; max: number };
@@ -290,9 +321,8 @@ export interface LegScale {
290
321
  support?: SupportScaleRun[];
291
322
  }
292
323
 
293
- /** One segment's scale per frame: picture length ÷ its 95th percentile, clamped; its asked scale without the clamp floor (#107); and the runs clamped. */
294
- export function legScaleOf(lengths: readonly number[], limits: [number, number]): Omit<LegScale, 'chain' | 'bone' | 'joints'> {
295
- const straight = percentile(lengths, STRAIGHT_PERCENTILE);
324
+ /** One segment's scale per frame: picture length ÷ straight length (the larger of the two sides' 95th percentiles, #148), clamped; its asked scale without the clamp floor (#107); and the runs clamped. */
325
+ export function legScaleOf(lengths: readonly number[], limits: [number, number], straight = percentile(lengths, STRAIGHT_PERCENTILE)): Omit<LegScale, 'chain' | 'bone' | 'joints'> {
296
326
  const ratio = lengths.map((l) => l / straight);
297
327
  const scale = ratio.map((r) => Math.min(Math.max(r, limits[0]), limits[1]));
298
328
  const asked = ratio.map((r) => Math.min(r, limits[1]));
@@ -444,6 +474,9 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
444
474
  // #101: at a hand-over the front foot has not landed yet while the back ankle lifts its heel; when
445
475
  // the front foot lands (the first frame after its lifted run) while the back ankle stays up, a back
446
476
  // ankle that holds its height in y (< STILL_SPEED a frame for STILL_FRAMES frames) is a raised heel.
477
+ // #142: a run's push-off lifts the ankles off the floor while mid_hip is already rising from its
478
+ // crouch bottom; a jump starts at the first immediately preceding frame where both ankles are above
479
+ // their rolling floors and mid_hip is rising, so the support span ends at its ankle's push-off.
447
480
  const midY = hip.r.map((h, i) => (h[1] + hip.l[i]![1]) / 2);
448
481
  const rises: Array<{ ys: number[]; unit: number }> = [
449
482
  { ys: ankle.r.map((a) => a[1]), unit: legLength.r },
@@ -471,6 +504,7 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
471
504
  };
472
505
  const lifted = Array.from({ length: n }, (_, i) => up.every((u) => u[i]));
473
506
  const jumps = runs(lifted).filter(([a, b]) => b - a + 1 >= JUMP_FRAMES && !raisedHeel(a, b));
507
+ for (const j of jumps) while (j[0] > 1 && up[0]![j[0] - 1] && up[1]![j[0] - 1] && midY[j[0] - 1]! < midY[j[0] - 2]!) j[0]--;
474
508
  const jump = new Array<boolean>(n).fill(false);
475
509
  for (const [a, b] of jumps) for (let i = a; i <= b; i++) jump[i] = true;
476
510
  // Ankle speeds, leg lengths a frame (0 on the first frame).
@@ -683,23 +717,6 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
683
717
  const tracked = new Array<Side | null>(n).fill(null);
684
718
  for (const t of feet.tracked ?? []) for (let i = t.frames[0]; i <= t.frames[1]; i++) tracked[i] = t.side;
685
719
  const trkX = trackFn ?? follow;
686
- // #142: on a run (two or more flights), each support span's push-off ankle lift and side-to-side
687
- // straight-length asymmetry would otherwise swing floorY - offset(i, s)[1] far past the pose's
688
- // own bounce and ratchet the un-floored picture pass per cycle. Keep the support spans' mean floor
689
- // shift across both support spans and flights, holding each grounded ankle on floorY via blends.
690
- const isRun = feet.jumps.length >= 2 && feet.supports.length > 0;
691
- let runShift = 0;
692
- if (isRun && floorY !== null) {
693
- const t0 = travel(0)[1];
694
- const shifts: number[] = [];
695
- for (let i = 0; i < n; i++) {
696
- const s = feet.support[i] ?? null;
697
- if (s === null) continue;
698
- shifts.push(floorY - offset(i, s)[1] - (start[1] + travel(i)[1] - t0));
699
- }
700
- if (shifts.length > 0) runShift = shifts.reduce((a, b) => a + b, 0) / shifts.length;
701
- }
702
- const flightShift = runShift;
703
720
  /** How the hip is derived on a frame: held over a foot's anchor, tracking the lower foot on the floor (#119), or carried by the pose's travel from a frame's hip (and for a jump against `floorY`, carried so the landing frame meets the floor and frames inside the flight never go below it, #103). */
704
721
  type Mode =
705
722
  | { kind: 'plant'; side: Side; anchor: Pt; from: number }
@@ -710,13 +727,6 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
710
727
  const o = offset(i, mode.side);
711
728
  // In place (#92) the anchor's x follows the supporting ankle's own track from the frame it planted.
712
729
  const dx = follow === undefined ? 0 : follow(i, mode.side) - follow(mode.from, mode.side);
713
- if (isRun) {
714
- const yPose = start[1] + travel(i)[1] - travel(0)[1] + (floorY === null ? 0 : runShift);
715
- if (floorY === null) return [mode.anchor[0] + dx - o[0], yPose];
716
- const d = -o[1];
717
- const y = d > 0 ? Math.min(Math.max(yPose, mode.anchor[1] + STANCE_BOUNDS[0] * d), mode.anchor[1] + STANCE_BOUNDS[1] * d) : yPose;
718
- return [mode.anchor[0] + dx - o[0], y];
719
- }
720
730
  return [mode.anchor[0] + dx - o[0], mode.anchor[1] - o[1]];
721
731
  }
722
732
  if (mode.kind === 'track') {
@@ -727,17 +737,10 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
727
737
  }
728
738
  const a = travel(i);
729
739
  const b = travel(mode.from);
730
- if (isRun) {
731
- let y = start[1] + a[1] - travel(0)[1] + (floorY === null ? 0 : flightShift);
732
- if (mode.end !== undefined && floorY !== null && i <= mode.end) {
733
- y = Math.max(y, floorY - Math.min(offset(i, 'r')[1], offset(i, 'l')[1]));
734
- }
735
- return [mode.at[0] + a[0] - b[0], y];
736
- }
737
740
  let y = mode.at[1] + a[1] - b[1];
738
741
  if (mode.carry !== undefined && mode.land !== undefined && mode.end !== undefined && floorY !== null) {
739
- if (mode.land < n && i <= mode.land && mode.land > mode.from) {
740
- y += mode.carry * ((i - mode.from) / (mode.land - mode.from));
742
+ if (mode.land < n && mode.land > mode.from) {
743
+ y += mode.carry * ((Math.min(i, mode.land) - mode.from) / (mode.land - mode.from));
741
744
  }
742
745
  if (i <= mode.end) {
743
746
  y = Math.max(y, floorY - Math.min(offset(i, 'r')[1], offset(i, 'l')[1]));
@@ -787,7 +790,7 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
787
790
  // The new support is held where it is on this frame, under the hip the old derivation gives here.
788
791
  const here = of(mode!, i);
789
792
  const o = offset(i, s);
790
- from = isRun ? null : mode;
793
+ from = mode;
791
794
  mode = plant(i, s, [here[0] + o[0], here[1] + o[1]]);
792
795
  blendAt = i;
793
796
  }
@@ -815,10 +818,6 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
815
818
  } else {
816
819
  from = null;
817
820
  hip.push(to);
818
- const m = mode!;
819
- if (isRun && floorY !== null && m.kind === 'plant' && Math.abs(to[1] - (m.anchor[1] - offset(i, m.side)[1])) > BLEND_HOLD_WITHIN) {
820
- blends.push({ frame: i, side: m.side, y: m.anchor[1] });
821
- }
822
821
  }
823
822
  }
824
823
  return { hip, anchors, blends };
package/src/map.ts CHANGED
@@ -116,10 +116,14 @@ export interface RigSlot {
116
116
  }
117
117
  export type RigSlots = ReadonlyMap<string, RigSlot>;
118
118
 
119
+ export type InheritMode = 'normal' | 'onlyTranslation' | 'noRotationOrReflection' | 'noScale' | 'noScaleOrReflection';
120
+
119
121
  /** The rig as the map is resolved against it: each bone's setup pose, by rig-c's poser. */
120
122
  export interface RestBone extends RawBone {
121
123
  /** The bone's `length` in the compiled model (0 when it has none). */
122
124
  length: number;
125
+ /** The bone's `inheritMode` in the compiled model, folded to its canonical lowercase-first name (`normal` when absent). */
126
+ inherit?: InheritMode;
123
127
  }
124
128
  export type Rest = ReadonlyMap<string, RestBone>;
125
129
 
package/src/reduce.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  * of the gated build against the dense targets with the tolerance added.
32
32
  */
33
33
  import { f32 } from 'rig-c/compile';
34
+ import { poseNoScale } from './legs.ts';
34
35
 
35
36
  /** A key as the reducer writes it: a time, its values, and a raw curve on the segment to the next key. */
36
37
  export interface ReducedKey {
@@ -444,11 +445,21 @@ const wrap180 = (d: number): number => {
444
445
  * the reduced ones), or another bone of the same call given before it — times
445
446
  * the setup locals of the unmapped bones between (`via`).
446
447
  */
448
+ export interface UnderStep {
449
+ wp0: M2;
450
+ w0: M2;
451
+ inherit?: 'noScale' | 'noScaleOrReflection';
452
+ }
453
+
447
454
  export interface UnderBone {
448
455
  bone: string;
449
- parent: { rigid: { turn: readonly number[]; world0: M2; scale: readonly number[] }; via: M2 } | { under: string; via: M2 };
456
+ parent: { rigid: { turn: readonly number[]; world0: M2; scale: readonly number[] }; via: M2; steps?: readonly UnderStep[] } | { under: string; via: M2; steps?: readonly UnderStep[] };
450
457
  /** Its setup local 2×2 in its parent's axes: inv(parent's setup world) · its setup world, by rig-c's setup. */
451
458
  local: M2;
459
+ /** Its parent's and its own setup world 2×2 when `inherit` is `noScale` or `noScaleOrReflection` (#153). */
460
+ wp0?: M2;
461
+ w0?: M2;
462
+ inherit?: 'noScale' | 'noScaleOrReflection';
452
463
  /** Its setup world x-axis length. */
453
464
  len0: number;
454
465
  /** The world angle its x axis is asked on every frame (degrees), and the scale of it asked (1: none). */
@@ -475,6 +486,12 @@ export function reduceUnderScale(times: readonly number[], bones: readonly Under
475
486
  const rotate = new Map<string, ReducedKey[]>();
476
487
  const scale = new Map<string, ReducedKey[]>();
477
488
  const rows: ReduceRow[] = [];
489
+ const stepVia = (m: M2, via: M2, steps: readonly UnderStep[] | undefined): M2 => {
490
+ if (steps === undefined || !steps.some((st) => st.inherit !== undefined)) return mul(m, via);
491
+ let cur = m;
492
+ for (const st of steps) cur = st.inherit !== undefined ? poseNoScale(cur, st.wp0, st.w0, st.inherit, 0, 1) : mul(cur, mul(inv(st.wp0), st.w0));
493
+ return cur;
494
+ };
478
495
  for (const b of bones) {
479
496
  for (const [what, xs] of [['target', b.target], ['asked', b.asked], ['dense', b.dense]] as const) {
480
497
  if (xs.length !== n) throw new Error(`reduce: bone "${b.bone}" has ${xs.length} ${what} value(s) for ${n} frame(s)`);
@@ -484,9 +501,9 @@ export function reduceUnderScale(times: readonly number[], bones: readonly Under
484
501
  if ('under' in p) {
485
502
  const above = world.get(p.under);
486
503
  if (above === undefined) throw new Error(`reduce: bone "${b.bone}" hangs from bone "${p.under}", which is not given before it`);
487
- parent = above.map((m) => mul(m, p.via));
504
+ parent = above.map((m) => stepVia(m, p.via, p.steps));
488
505
  } else {
489
- parent = Array.from({ length: n }, (_, i) => mul(mul(mul(rot(p.rigid.turn[i]!), p.rigid.world0), scaleX(p.rigid.scale[i]!)), p.via));
506
+ parent = Array.from({ length: n }, (_, i) => stepVia(mul(mul(rot(p.rigid.turn[i]!), p.rigid.world0), scaleX(p.rigid.scale[i]!)), p.via, p.steps));
490
507
  }
491
508
  const l0: [number, number] = [b.local[0], b.local[2]];
492
509
  const a0 = Math.atan2(l0[1], l0[0]) * DEG;
@@ -507,13 +524,14 @@ export function reduceUnderScale(times: readonly number[], bones: readonly Under
507
524
  const kAt = trackAt(rk, samples, 1).map((v) => v[0]!);
508
525
  const ae = worstOf(kAt.map((_, i) => i), (i) => aimError(i, kAt[i]!))!;
509
526
  rows.push({ bone: b.bone, kind: 'angle', before: n, after: rk.length, worst: ae.worst, frame: ae.frame });
510
- // The scale that gives it the length asked on the rotate as reduced.
511
- const reach = kAt.map((k, i) => Math.hypot(...axis(i, k)) / b.len0);
527
+ // The scale that gives it the length asked on the rotate as reduced (#153: under noScale, parent scale is normalised to 1).
528
+ const noScale = b.inherit;
529
+ const reach = noScale !== undefined ? kAt.map(() => 1) : kAt.map((k, i) => Math.hypot(...axis(i, k)) / b.len0);
512
530
  const sk = fitTrack(times, samples, { targets: reach.map((r, i) => [b.asked[i]! / r]), keep, tolerance: scaleTolerance, error: (i, v) => Math.abs(v[0]! * reach[i]! - b.asked[i]!), round: r6 });
513
531
  const sAt = trackAt(sk, samples, 1).map((v) => v[0]!);
514
532
  const se = worstOf(sAt.map((_, i) => i), (i) => Math.abs(sAt[i]! * reach[i]! - b.asked[i]!))!;
515
533
  rows.push({ bone: b.bone, kind: 'scale', before: n, after: sk.length, worst: se.worst, frame: se.frame });
516
- world.set(b.bone, parent.map((m, i) => mul(mul(mul(m, rot(kAt[i]!)), b.local), scaleX(sAt[i]!))));
534
+ world.set(b.bone, parent.map((m, i) => (noScale !== undefined ? poseNoScale(m, b.wp0!, b.w0!, noScale, kAt[i]!, sAt[i]!) : mul(mul(mul(m, rot(kAt[i]!)), b.local), scaleX(sAt[i]!)))));
517
535
  rotate.set(b.bone, rk);
518
536
  scale.set(b.bone, sk);
519
537
  }
package/src/retarget.ts CHANGED
@@ -82,7 +82,7 @@ import { Problems, Refusal } from './problems.ts';
82
82
  import type { ArtRange, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
83
  import { clampArt, coveredChains, isRaised, measureArt, overflow, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
84
84
  import type { BlendHold, Closure, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
85
- import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
85
+ import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
86
86
 
87
87
  const DEG = 180 / Math.PI;
88
88
 
@@ -680,6 +680,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
680
680
  }
681
681
  if (legScaleOn) {
682
682
  const limits = opts.legScale as [number, number];
683
+ const rawSegments: Array<{ chain: string; bone: string; joints: [string, string]; kind: string; lengths: number[]; ownStraight: number }> = [];
683
684
  for (const c of map.chains) {
684
685
  if (c.reach) continue;
685
686
  c.bones.forEach((b, k) => {
@@ -691,15 +692,22 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
691
692
  }
692
693
  const from = series.get(joints[0])!;
693
694
  const to = series.get(joints[1])!;
694
- const ls = legScaleOf(from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1])), limits);
695
- if (!(ls.straight > 1e-6)) {
696
- p.add('LEGS_MAP', `--leg-scale: chain "${c.name}" bone "${b}"'s segment ${joints[0]} → ${joints[1]} has a straight length (95th percentile) of ${ls.straight}px; there is nothing to scale against`);
695
+ const lengths = from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1]));
696
+ const ownStraight = percentile(lengths, STRAIGHT_PERCENTILE);
697
+ if (!(ownStraight > 1e-6)) {
698
+ p.add('LEGS_MAP', `--leg-scale: chain "${c.name}" bone "${b}"'s segment ${joints[0]} → ${joints[1]} has a straight length (95th percentile) of ${ownStraight}px; there is nothing to scale against`);
697
699
  return;
698
700
  }
699
- legScale.push({ chain: c.name, bone: b, joints, ...ls });
700
- scales.set(b, ls.scale);
701
+ rawSegments.push({ chain: c.name, bone: b, joints, kind: `${joints[0].slice(2)}:${joints[1].slice(2)}`, lengths, ownStraight });
701
702
  });
702
703
  }
704
+ const straightByKind = new Map<string, number>();
705
+ for (const seg of rawSegments) straightByKind.set(seg.kind, Math.max(straightByKind.get(seg.kind) ?? 0, seg.ownStraight));
706
+ for (const seg of rawSegments) {
707
+ const ls = legScaleOf(seg.lengths, limits, straightByKind.get(seg.kind)!);
708
+ legScale.push({ chain: seg.chain, bone: seg.bone, joints: seg.joints, ...ls });
709
+ scales.set(seg.bone, ls.scale);
710
+ }
703
711
  if (legScale.length === 0 && !p.any) p.add('LEGS_MAP', '--leg-scale scales the leg segments (two of one side\'s hip, knee and ankle), and the map aims none');
704
712
  }
705
713
  if (opts.stance !== undefined && opts.stance !== 'off' && !plantOn) p.add('STANCE', `--stance ${opts.stance} plants the free foot beside the supporting one that --plant-feet holds, and --plant-feet is not given`);
@@ -775,16 +783,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
775
783
  const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
776
784
  const lower = stanceRangeOf(ankleSeries, { r: series.get('r_hip')!, l: series.get('l_hip')! });
777
785
  blendHold = holdBlend(planted.blends, n, posedFeet, lower);
778
- const holdAnchorX = feet!.jumps.length >= 2 ? blendHold.stretch.map((b, i) => (b ? posedFeet(i).ankle[b.side][0] : 0)) : null;
779
786
  stretchChain(blendHold.stretch);
780
- if (holdAnchorX !== null) {
781
- for (let i = 0; i < n; i++) {
782
- const b = blendHold.stretch[i];
783
- if (!b) continue;
784
- hip[i]![0] += holdAnchorX[i]! - posedFeet(i).ankle[b.side][0];
785
- travel[i]![0] = hip[i]![0] - tb.worldX;
786
- }
787
- }
788
787
  // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
789
788
  // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
790
789
  if (opts.stance !== undefined && opts.stance !== 'off') {
package/src/rigc.ts CHANGED
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'no
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { poseRawSetup, readModel, type CompiledDocument } from 'rig-c/core';
16
- import type { Rest, RestBone, RigSlot, RigSlots } from './map.ts';
16
+ import type { InheritMode, Rest, RestBone, RigSlot, RigSlots } from './map.ts';
17
17
  import { isObject, parseJson, Problems, Refusal, typeName } from './problems.ts';
18
18
 
19
19
  export interface RigInput {
@@ -129,11 +129,18 @@ export function readBuild(build: string): CompiledDocument {
129
129
  return readModel(readFileSync(path, 'utf8'), path);
130
130
  }
131
131
 
132
- /** Every bone's setup pose, by rig-c's poser, with its model `length`. */
132
+ /** Every bone's setup pose, by rig-c's poser, with its model `length` and `inheritMode`. */
133
133
  export function restOf(doc: CompiledDocument): Rest {
134
134
  const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
135
+ const inherits = new Map<string, InheritMode>(
136
+ doc.bones.map((b) => {
137
+ const s = b.inheritMode;
138
+ const folded = s === undefined || s.length === 0 ? 'normal' : `${s[0]!.toLowerCase()}${s.slice(1)}`;
139
+ return [b.name, folded === 'onlyTranslation' || folded === 'noRotationOrReflection' || folded === 'noScale' || folded === 'noScaleOrReflection' ? folded : 'normal'];
140
+ }),
141
+ );
135
142
  const out = new Map<string, RestBone>();
136
- for (const b of poseRawSetup(doc).bones) out.set(b.name, { ...b, length: lengths.get(b.name) ?? 0 });
143
+ for (const b of poseRawSetup(doc).bones) out.set(b.name, { ...b, length: lengths.get(b.name) ?? 0, inherit: inherits.get(b.name) ?? 'normal' });
137
144
  return out;
138
145
  }
139
146
 
package/src/run.ts CHANGED
@@ -431,21 +431,34 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
431
431
  return d;
432
432
  };
433
433
  const underBones = [...rotate.keys()].filter(under).sort((x, y) => depth(x) - depth(y));
434
+ const noScaleOf = (b: string): 'noScale' | 'noScaleOrReflection' | undefined => {
435
+ const inh = rest.get(b)!.inherit;
436
+ return inh === 'noScale' || inh === 'noScaleOrReflection' ? inh : undefined;
437
+ };
434
438
  const given: UnderBone[] = underBones.map((b) => {
435
439
  if (!scalex.has(b)) throw new Refusal([{ code: 'POSE_REDUCE', text: `bone "${b}" hangs under a scaled bone and has no scalex track, so it was not keyed through its parent's posed matrix; --reduce re-solves only a bone the legs keyed that way` }]);
436
- // Up through the unmapped bones between (their setup locals), to a bone given before it or one the key formula holds for.
440
+ // Up through the unmapped bones between (their setup locals, or their noScale unit frames, #153), to a bone given before it or one the key formula holds for.
437
441
  let via: M2 = M2_IDENTITY;
442
+ const steps: Array<{ wp0: M2; w0: M2; inherit?: 'noScale' | 'noScaleOrReflection' }> = [];
438
443
  let p = rest.get(b)!.parent!;
439
444
  while (under(p) && !mapped.has(p)) {
440
- via = mulM2(setupLocal(world0(rest.get(p)!.parent!), world0(p)), via);
445
+ const wp0 = world0(rest.get(p)!.parent!);
446
+ const w0 = world0(p);
447
+ via = mulM2(setupLocal(wp0, w0), via);
448
+ const inherit = noScaleOf(p);
449
+ steps.unshift({ wp0, w0, ...(inherit !== undefined ? { inherit } : {}) });
441
450
  p = rest.get(p)!.parent!;
442
451
  }
443
- const parent: UnderBone['parent'] = under(p) ? { under: p, via } : { rigid: { turn: turnOf(p), world0: world0(p), scale: scaleAt.get(p) ?? new Array<number>(n).fill(1) }, via };
452
+ const viaSteps = steps.some((st) => st.inherit !== undefined) ? { steps } : {};
453
+ const parent: UnderBone['parent'] = under(p) ? { under: p, via, ...viaSteps } : { rigid: { turn: turnOf(p), world0: world0(p), scale: scaleAt.get(p) ?? new Array<number>(n).fill(1) }, via, ...viaSteps };
454
+ const wp0 = world0(rest.get(b)!.parent!);
444
455
  const w0 = world0(b);
456
+ const inherit = noScaleOf(b);
445
457
  return {
446
458
  bone: b,
447
459
  parent,
448
- local: setupLocal(world0(rest.get(b)!.parent!), w0),
460
+ local: setupLocal(wp0, w0),
461
+ ...(inherit !== undefined ? { wp0, w0, inherit } : {}),
449
462
  len0: Math.hypot(w0[0], w0[2]),
450
463
  target: r.targets.get(b)!,
451
464
  asked: r.scales.get(b) ?? new Array<number>(n).fill(1),
@@ -671,7 +684,7 @@ function legScaleLine(l: LegScale, times: readonly number[]): string {
671
684
  const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
672
685
  const count = (rs: Array<[number, number]>) => rs.reduce((k, x) => k + x[1] - x[0] + 1, 0);
673
686
  const clamp = (rs: Array<[number, number]>, at: number, how: string) => (rs.length === 0 ? `none ${how} ${at}` : `${count(rs)} frame(s) ${how} ${at}, clamped (frames ${rs.map(span).join(', ')})`);
674
- return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (95th percentile of ${times.length} frames); picture length ÷ straight ${l.ratio.min.toFixed(3)} … ${l.ratio.max.toFixed(3)} (median ${l.ratio.median.toFixed(3)}); scalex keyed in [${l.limits[0]}, ${l.limits[1]}]: ${clamp(l.low, l.limits[0], 'under')}; ${clamp(l.high, l.limits[1], 'over')}`;
687
+ return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (larger 95th percentile of ${times.length} frames, both legs); picture length ÷ straight ${l.ratio.min.toFixed(3)} … ${l.ratio.max.toFixed(3)} (median ${l.ratio.median.toFixed(3)}); scalex keyed in [${l.limits[0]}, ${l.limits[1]}]: ${clamp(l.low, l.limits[0], 'under')}; ${clamp(l.high, l.limits[1], 'over')}`;
675
688
  }
676
689
 
677
690
  /** A LEG-SCALE past the floor (support) line (rig-c/pose#133): the supporting leg segment scaled below `--leg-scale`'s floor down to #95's bound. */