@rig-c/pose 0.10.0 → 0.11.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
@@ -10,7 +10,7 @@ Part of the rig-c toolchain: [core](https://github.com/rig-c/core) compiles and
10
10
 
11
11
  ## Try it
12
12
 
13
- [`examples/wave`](examples/wave) runs with nothing else: a bones-only stick rig (no art), a 3-second wave made from joint angles (`make-pose.ts` writes it), and the map that joins them.
13
+ [`examples/wave`](https://github.com/rig-c/pose/tree/main/examples/wave) is in the repository, not the npm package: clone the repository, or copy its four files. It runs with nothing else: a bones-only stick rig (no art), a 3-second wave made from joint angles (`make-pose.ts` writes it), and the map that joins them.
14
14
 
15
15
  ```sh
16
16
  rig-pose retarget --rig examples/wave/rig --map examples/wave/map.json --pose examples/wave/pose.json --out wave.motion.json --build wave-build
@@ -18,6 +18,10 @@ rig-pose retarget --rig examples/wave/rig --map examples/wave/map.json --pose ex
18
18
 
19
19
  It prints the gate's verdict and where every mapped bone landed, then writes `wave.motion.json` and the gated build. `rigc preview --candidate wave-build` plays it in the Spine Web Player. A rig with no art shows only its bones.
20
20
 
21
+ ## What the rig needs
22
+
23
+ The map names **chains**: each limb as bones that descend from one another, parent to child. That means a thigh with its shin below it, and an upper arm with its forearm below it, and likewise for the trunk and the head. A rig whose arm is one bone, or that has no leg bones, cannot take a pose: the map is refused by name (`MAP_*`). [@rig-c/parts](https://github.com/rig-c/parts) builds such a rig from a painting's keypoints with `propose --keypoints`. A rig drawn by hand needs the same chains.
24
+
21
25
  ## What it does
22
26
 
23
27
  ```sh
@@ -55,9 +59,20 @@ and what the run cannot decide for you it lists:
55
59
  - `FEET` — when the map has both legs: the supports and jumps read off the reference ankles (planted or not), and on the posed build, outside the jumps, the lowest ankle's vertical range and each support span's worst vertical range and horizontal drift of the supporting ankle, in leg lengths.
56
60
  - `SHEAR` — with `--leg-scale`: every scaled bone and every bone under one, how far its art is sheared and how long its world axes are against setup. A reading.
57
61
  - `IDLE`, `IDLE-DROPPED`, `IDLE-KEPT-OUT`, `MERGE` — the rig's idle against the new animation, and what `--carry-idle` and `--merge` put back ([below](#the-idle-what-the-retarget-drops)).
62
+ - `REDUCE` — with `--reduce` ([below](#few-keys---reduce)): per bone, its keys before and after and its largest error, with the frame; then the total.
58
63
  - `TURN` — a line across the body (hips, shoulders, eyes) shorter in the picture than 0.5 of its median length, or reversed, for at least 0.15 s: the body or the head turned. A reading of the pose, made whether or not the map uses the line; it changes no key. The demand for a turned-body drawing, as `END-ON` is for a foreshortened limb.
59
64
 
60
- `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale` and `plantFeet` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, `legScale`, `pivot`, `feet` (`planted`, `legLength`, `floor`, `supports`, `jumps`, `changes`, and the posed `reading`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), and the demand lists:
65
+ `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale` and `plantFeet` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, `legScale`, `pivot`, `feet` (`planted`, `legLength`, `floor`, `supports`, `jumps`, `changes`, and the posed `reading`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), `reduce` (the tolerances and each row of `REDUCE`, [below](#few-keys---reduce)), and the demand lists. The fields a run is graded by:
66
+
67
+ | field | each entry |
68
+ | --- | --- |
69
+ | `holds` | `{ point, from, to, source, why }`: a joint held over frames `from`–`to` at frame `source`'s value |
70
+ | `bends` | `{ chain, joint, frame, t, angle, side, rest }`: a hinge found bent the wrong way (`angle` in degrees, Spine's sense; `side` ±1, the side it may bend) |
71
+ | `endOn` | `{ chain, bone, joints, frames, seconds, minFraction }`: a limb pointing at the camera for ≥ 0.15 s |
72
+ | `landings` | `{ bone, worst, frame, kind, tolerance }`: where rig-c's poser put each bone, against the target. `kind` is `angle` (degrees), `origin` or `foot` (world units), or `scale` (a fraction of the setup length). A run writes only when every `worst` ≤ `tolerance` |
73
+ | `folds` | `{ slot, attachment, times }`: every mesh `rigc render --folds` saw fold, at those seconds; `null` when it did not read |
74
+
75
+ The demand lists:
61
76
 
62
77
  - `switches: [{ set, chains, slots, hides, offsets, from, to, frames: [first, last], distance: { max, median }, artTurn: { <bone>: { max, median } }, moved: { on, off } }]` — one per span shown; `from` is the first frame's time, `to` the time the base art comes back (the animation's end when the span runs to it), `distance` in degrees over the frames shown, `artTurn` |wrap(target − drawn)| in degrees per bone the set has a `drawn` angle for. `moved.on` is the boundary the span starts on (null from the first frame), `moved.off` the one the base art or the next set comes back on (null to the end), each `{ frame: [selected, keyed], frames, seconds, window: [first, last], speed: { old, new } }`: signed frames and seconds, the frames it could move to, and the speed in degrees per frame on the old and the new frame. A boundary between two sets is the same entry in both.
63
78
  - `uncovered: [{ slot, attachment, chains, times }]` — every entry of `folds` on the times its slot still shows that mesh, with the map's chains that move it (the deepest: the arm, not the trunk it hangs from). `rigc render --folds` reads only what the pose shows, so a mesh swapped out or hidden by a switch is not read on those frames at all; `uncovered` is the fold times less the switched ones.
@@ -135,6 +150,17 @@ On HQ's dancer (`--flip 90 --short 0.35 --bends clamp --carry-idle`) the idle ha
135
150
 
136
151
  Each track is a rigc-motion track. One posing a channel the new animation (or a carried idle track, or an earlier fragment track) already keys is refused by name (`MERGE_CONFLICT`): a fragment adds a track, it never replaces one. So is a key before 0 or past the animation's duration (`MERGE_TIME`), and a file of any other shape (`MERGE_SPEC`). What the track means — its target, property and values — is rigc's to judge: its gate checks every merged track like any other (`RIG_GATE`). `MERGE` lists each track merged; the report lists them under `merged`.
137
152
 
153
+ ### Few keys: `--reduce`
154
+
155
+ The retarget writes one key per pose frame on every mapped bone. That is faithful, but nobody can edit it by hand. **`--reduce <deg>`** (off by default) thins each mapped bone's `rotate` track to a few keys with curves, so that every bone's **world** aim stays within `deg` of the dense keys on every pose frame. **`--reduce-hip <units>`** does the same for the hip's `translate`, holding its world origin within `units`; without it, the hip stays dense.
156
+
157
+ - **Keys stay on the pose's own frame times.** Between two kept keys, the span is linear if a straight line is within the tolerance. Otherwise it gets one raw Bézier `curve`, with its handles at the span's thirds and its values fitted by least squares. The error is measured the way rig-c's poser evaluates a curve (its 10-piece polyline). A span still over the tolerance gets a key at its worst frame, and both halves are fitted again.
158
+ - **Errors do not pile up down a chain.** Bones are fitted top-down, and each bone is fitted against its dense key less the error its nearest mapped parent already carries. So a bone's world error is its own fit error.
159
+ - **Kept regardless:** the first and last frame; the frames either side of a switch boundary, on the chains the switch changes; and the first and last frame of every `FLIP` spread, `SHORT` span and `RANGE` run, on that bone.
160
+ - **The landing check holds the dense targets** with the tolerance added (0.01° + `deg`; 0.01 + `units`), and it is posed by rig-c as always.
161
+ - **On HQ's dancer** (`--smooth 7 --flip 90 --short 0.35 --bends clamp`; 313 frames, 11 mapped bones): `--reduce 1 --reduce-hip 1` takes the rotate keys from 3443 to 588 (5.86×), 296 of the spans curved, and the hip's translate keys from 313 to 96. The gate is green. rig-c's poser lands every bone within 0.9998° of the dense target (head) and the hip within 0.901 units. At 0.5° the keys drop 3.92×; at 2°, 8.38×.
162
+ - **Not combined with `--leg-scale` or `--plant-feet`.** Their keys land a bone through a scaled parent or an ankle's lever, which this tolerance does not bound (rig-c/pose#32).
163
+
138
164
  Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
139
165
 
140
166
  ## The pose: `rig-pose-sequence/1`
@@ -182,7 +208,7 @@ Which bones carry which joints. You write it for your rig:
182
208
  - A **point** of the rig is a bone's origin (`name`), the end of its x axis (`name:tip`), or the midpoint of two of those (`"a+b"`: `"sleeve_r0+sleeve_l0"` is the shoulders' midpoint). An aim's points must be the bone itself or descend from it.
183
209
  - `reach: true`, on a one-bone chain from the translated joint (a trunk from `mid_hip`): the bone is aimed from where its origin is posed — the hip moved and turned — at where the pose puts the segment's end relative to mid_hip, scaled. Without it, a tilted pelvis carries the chest's origin sideways and the trunk, parallel to the pose's, misses the shoulders. Its aim is one point, from the bone's own origin: `{ "name": "trunk", "bones": ["chest"], "joints": ["mid_hip", "mid_shoulder"], "aim": ["sleeve_r0+sleeve_l0"], "reach": true }`. The chain must descend from the translated bone.
184
210
  - `bend`: every joint inside a chain is a hinge. `"rest"` (default) takes the side it may bend from the rig's rest pose and refuses a rest that is straight (under 0.5°); `"ccw"`/`"cw"` state it; `"free"` skips the check.
185
- ⚠️ **Seen from the front, an elbow's side is not a joint limit.** An elbow flexes toward the camera, and its picture falls on either side of the upper arm depending on how the arm is turned. On a real 13 s front-facing dance (313 frames, RTMPose), the right elbow sat on the "wrong" side of the rig's rest on 165 frames (median 23°), and the left on 200 (median 29°, up to 179° with the hands behind the head). Every one was a real elbow bend. The knees strayed at most 8.8° and 14.7°, which is pose noise around a straight leg. So for front-facing material: arms `"free"`; legs on `"rest"`, with `--bends clamp` straightening the noise.
211
+ ⚠️ **Seen from the front, an elbow's side is not a joint limit.** An elbow flexes toward the camera, and its picture falls on either side of the upper arm depending on how the arm is turned. On a real 13 s front-facing dance (313 frames, RTMPose), the right elbow sat on the "wrong" side of the rig's rest on 165 frames (median 23°), and the left on 200 (median 29°, up to 179° with the hands behind the head). Every one was a real elbow bend. The knees strayed at most 8.8° and 14.7°, which is pose noise around a straight leg. So for front-facing material, arms go `"free"`. Legs on `"rest"` with `--bends clamp` hold only while the knees stay near straight. On a second dance with knee bends (Toca Toca, rig-c/pose#27), that setting straightened the left knee for most of the dance: `leg_l1` was 48.3° off at p90, against 0.0–0.3° with free legs. Leave legs `"free"` unless the dance keeps its knees straight.
186
212
  - `mid_hip` and `mid_shoulder` are the midpoints of the two hips and the two shoulders.
187
213
  - `translate.scale` is one length measured on both: two points on the rig, two points in the pose (its median over the frames where both are good). Pick a long segment that does not foreshorten much.
188
214
 
@@ -317,7 +343,7 @@ Exit codes: `0` compared, whether it found anything or not, since a finding is n
317
343
 
318
344
  [Bun](https://bun.sh) ≥ 1.2 on `PATH` — rig-pose, like rig-c, runs its TypeScript sources directly. `rig-c` comes as a dependency. With `@esotericsoftware/spine-core` installed beside it, rig-c's gate runs the spine-core round trip; without it, rig-c's own gate, and the gate's report says which.
319
345
 
320
- ## What 0.2 fixes
346
+ ## Why alternate drawings (0.2)
321
347
 
322
348
  A flat painting turned by its bones matches each limb's direction, not its depth. On a real 13 s front-facing dance, retargeted onto a @rig-c/parts rig, five moments were found where it cannot follow. They were found by measuring the pose against the rig and checked against the video:
323
349
 
@@ -329,13 +355,15 @@ A flat painting turned by its bones matches each limb's direction, not its depth
329
355
  | S4 | 7.7–10.9 | hands crossed in front | which forearm is in front |
330
356
  | S5 | 11.1–13.0 | an arm thrust at the camera, then a V sign | foreshortening (0.63) and the shape of the hand; the arm mesh folds |
331
357
 
332
- The answer is a second drawing of the arm for each of those poses, shown in place of the bent mesh while the pose lasts. rig-pose chooses when to show it, keys the switch in the motion, and lists every fold no drawing covers ([#6](https://github.com/rig-c/pose/issues/6), [above](#alternate-drawings-alternates)).
358
+ The answer, since 0.2, is a second drawing of the arm for each of those poses, shown in place of the bent mesh while the pose lasts. rig-pose chooses when to show it, keys the switch in the motion, and lists every fold no drawing covers ([#6](https://github.com/rig-c/pose/issues/6), [above](#alternate-drawings-alternates)).
333
359
 
334
- ## What 0.1.0 does not do
360
+ ## What it does not do
335
361
 
336
- - **Extract poses from video.** The input is joint positions; how you get them (a pose model, a dataset, a hand) is yours.
337
- - **Turn the body.** 2D only: a segment pointing at the camera foreshortens, and the rig cannot. The direction is matched; the length is not.
338
- - **Plant feet** beyond what the pose gives (until `--plant-feet`, [#47](https://github.com/rig-c/pose/issues/47)), **hands and fingers** (no bones for them yet, rig-c/parts#171), mobile.
362
+ - **Extract poses from video.** The input is joint positions. How you get them is up to you: a pose model, a dataset, or by hand.
363
+ - **Turn the body.** It works in 2D only. A segment pointing at the camera foreshortens, and the rig cannot: the direction is matched, the length is not. With `--leg-scale`, leg segments are the exception.
364
+ - **Read a moving camera.** A reference whose camera zooms or pans moves the planted floor with it under `--plant-feet` ([#52](https://github.com/rig-c/pose/issues/52)).
365
+ - **Front and behind, blinks, mouth shapes.** The detector's scores and face points do not carry them ([#33](https://github.com/rig-c/pose/issues/33), [#46](https://github.com/rig-c/pose/issues/46)).
366
+ - **Hands and fingers.** There are no bones for them yet (rig-c/parts#171).
339
367
 
340
368
  ## Licence
341
369
 
package/cli.ts CHANGED
@@ -75,6 +75,15 @@ retarget
75
75
  "tracks": [ <rigc-motion track> … ] } at absolute times from its start (blinks read
76
76
  off the video); a track on a target and property it already keys is refused
77
77
  (MERGE_CONFLICT), and so is a key past its duration (MERGE_TIME)
78
+ --reduce <deg> thin each mapped bone's rotate keys to few keys with curves (linear, or one raw
79
+ Bezier per span with handles at its thirds), on the pose's own frame times, so every
80
+ bone's world aim stays within deg of the dense keys on every frame; fitted top-down,
81
+ each bone against its parent's reduced error, so errors do not pile up down a chain.
82
+ Kept regardless: the first and last frame, the frames either side of a switch boundary
83
+ on the chains it changes, and the edges of every FLIP spread, SHORT span and RANGE run
84
+ (REDUCE lines; default: off). Not with --leg-scale or --plant-feet (rig-c/pose#32)
85
+ --reduce-hip <units> with --reduce, thin the hip's translate keys too, its world origin within units
86
+ of the dense keys (default: the hip stays dense)
78
87
  --switch-tolerance <d> a set of alternate drawings (the map's "alternates") may show on a frame where
79
88
  every segment of its chains is within d degrees of its key skeleton (default: 25)
80
89
  --switch-min <s> a run of one set shorter than s seconds is dropped (default: 0.25)
@@ -87,7 +96,7 @@ retarget
87
96
  --render <dir> keep rigc's render of the animation (frames, contact.png, folds.json) here
88
97
  --report <file> write the run's measurements (holds, short, endOn, flips, bends, landings, folds,
89
98
  switches, uncovered, turns, range, artRange,
90
- idleDropped, idleKeptOut, merged) as JSON
99
+ idleDropped, idleKeptOut, merged, reduce) as JSON
91
100
 
92
101
  It writes nothing until rigc's gate is green with the animation in, every mapped bone lands on
93
102
  the angle the pose asks for (posed by rig-c's own poser), and no hinge bends the wrong way.
@@ -191,7 +200,7 @@ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
191
200
  }
192
201
 
193
202
  function retargetArgs(argv: string[]): RetargetArgs {
194
- const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report', 'leg-scale', 'idle', 'merge'], ['coco', 'plant-feet', 'carry-idle']);
203
+ const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip'], ['coco', 'plant-feet', 'carry-idle']);
195
204
  const bends = f.get('bends') ?? 'refuse';
196
205
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
197
206
  const switchAt = f.get('switch-at') ?? 'first';
@@ -228,9 +237,23 @@ function retargetArgs(argv: string[]): RetargetArgs {
228
237
  idle: f.get('idle'),
229
238
  carryIdle: f.has('carry-idle'),
230
239
  merge: f.get('merge'),
240
+ ...reduceOf(f),
231
241
  };
232
242
  }
233
243
 
244
+ /** --reduce and --reduce-hip (rig-c/pose#32): off unless given; the hip only with the bones. */
245
+ function reduceOf(f: Map<string, string>): { reduce?: number; reduceHip?: number } {
246
+ if (!f.has('reduce')) {
247
+ if (f.has('reduce-hip')) throw new Usage('--reduce-hip is given without --reduce; the hip is thinned with the bones, not alone');
248
+ return {};
249
+ }
250
+ const reduce = num(f, 'reduce', 0, (n) => n > 0 && n < 180, 'degrees in (0, 180)');
251
+ for (const other of ['leg-scale', 'plant-feet'] as const) {
252
+ if (f.has(other) && f.get(other) !== 'off') throw new Usage(`--reduce is given with --${other}; their keys land a bone through a scaled parent or an ankle's lever, which the reduction's tolerance does not bound (rig-c/pose#32 round 1)`);
253
+ }
254
+ return f.has('reduce-hip') ? { reduce, reduceHip: num(f, 'reduce-hip', 0, (n) => n > 0, 'rig units > 0') } : { reduce };
255
+ }
256
+
234
257
  function compareArgs(argv: string[]): CompareArgs {
235
258
  const f = parseFlags(argv, ['reference', 'render', 'map', 'retarget-report', 'reference-hands', 'render-hands', 'cloth-reference', 'cloth-render', 'cloth-windows', 'report', 'sheet'], []);
236
259
  const pair = (x: string, y: string) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
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/reduce.ts ADDED
@@ -0,0 +1,361 @@
1
+ /**
2
+ * The dense keys thinned to few keys with curves, within a stated tolerance
3
+ * (rig-c/pose#32, `--reduce`): one key per pose frame on every mapped bone's
4
+ * `rotate` track and on the hip's `translate`, in; the keys a curve needs to
5
+ * stay within the tolerance on every frame, out.
6
+ *
7
+ * Pure: numbers in, keys out. The method was fixed on the issue before any
8
+ * measurement. Keys sit on the pose's own frame times; between two kept keys a
9
+ * span is linear when every frame inside is within the tolerance, else it
10
+ * carries one raw Bézier `curve` whose handle times sit at the span's thirds and
11
+ * whose handle values are a least-squares fit; a span still over the tolerance
12
+ * takes a key at its worst frame and both halves are fitted again.
13
+ *
14
+ * Top-down along the tree. A child's `rotate` key adds to the turn of the
15
+ * mapped bone above it, so the reduced above's error turns the child's world
16
+ * aim too. A bone is fitted against its dense key minus the error its above
17
+ * carries (`target = dense − e_above`), and its own residual against that
18
+ * target is its world error: errors do not pile up down a chain. A knot's value
19
+ * is the target's, not the dense key's.
20
+ *
21
+ * Every error is read after the numbers are rounded as written and evaluated as
22
+ * rig-c's poser evaluates them (`curveAt`): the curve is the poser's ten-piece
23
+ * polyline, not the cubic. The poser is the judge — run.ts measures the landing
24
+ * of the gated build against the dense targets with the tolerance added.
25
+ */
26
+ import { f32 } from 'rig-c/compile';
27
+
28
+ /** A key as the reducer writes it: a time, its values, and a raw curve on the segment to the next key. */
29
+ export interface ReducedKey {
30
+ t: number;
31
+ v: number[];
32
+ curve?: number[];
33
+ }
34
+
35
+ export interface ReduceBone {
36
+ bone: string;
37
+ /** The nearest mapped bone above it (whose rotate track it adds to), or null. Always one of the other bones given, or null. */
38
+ above: string | null;
39
+ /** The dense key value on every frame (degrees, unwrapped, already rounded to 0.001). */
40
+ dense: number[];
41
+ /** Frame indices that must keep a key regardless (the first and last always do). */
42
+ keep: ReadonlySet<number>;
43
+ }
44
+ export interface ReduceHip {
45
+ bone: string;
46
+ /** Dense translate key on every frame, parent-local units, rounded to 0.001. */
47
+ dense: Array<[number, number]>;
48
+ keep: ReadonlySet<number>;
49
+ /** The hip's parent world matrix [a, b, c, d] at setup (wx = a·lx + b·ly, wy = c·lx + d·ly); null when it has none (identity). The tolerance is a world distance. */
50
+ parent: [number, number, number, number] | null;
51
+ tolerance: number;
52
+ }
53
+ export interface ReduceRow {
54
+ bone: string;
55
+ kind: 'angle' | 'origin';
56
+ /** Dense key count. */
57
+ before: number;
58
+ /** Reduced key count. */
59
+ after: number;
60
+ /** The largest error over all frames: degrees of world aim for a bone, world units for the hip. */
61
+ worst: number;
62
+ /** The first frame where it is. */
63
+ frame: number;
64
+ }
65
+ export interface Reduced {
66
+ rotate: Map<string, ReducedKey[]>;
67
+ hip: ReducedKey[] | null;
68
+ /** The bones in the order given, then the hip. */
69
+ rows: ReduceRow[];
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // rig-c's evaluation
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** rig-c's `BEZIER_SIXTH` (node_modules/rig-c/src/core/animation.ts). */
77
+ const BEZIER_SIXTH = 0.16666667;
78
+ /** Pieces of the poser's polyline per Bézier segment — rig-c's `BEZIER_PIECES`. */
79
+ export const REDUCE_PIECES = 10;
80
+
81
+ /**
82
+ * Copied verbatim from rig-c `src/core/animation.ts` (`bezierPolyline`, not
83
+ * exported by `rig-c/core`): the nine interior points of one channel's Bézier
84
+ * segment, `[t1, v1, …, t9, v9]`, each float32, by forward differences. The
85
+ * poser is the judge; this copy only lets the fit see what it will see.
86
+ */
87
+ export function bezierPolyline(t0: number, v0: number, cx1: number, cy1: number, cx2: number, cy2: number, t3: number, v3: number): number[] {
88
+ const walk = (p0: number, p1: number, p2: number, p3: number): number[] => {
89
+ const second = (p0 - 2 * p1 + p2) * 0.03;
90
+ const third = ((p1 - p2) * 3 - p0 + p3) * 0.006;
91
+ let first = (p1 - p0) * 0.3 + second + third * BEZIER_SIXTH;
92
+ let d2 = second * 2 + third;
93
+ let at = p0;
94
+ const out: number[] = [];
95
+ for (let i = 1; i < REDUCE_PIECES; i++) {
96
+ at += first;
97
+ first += d2;
98
+ d2 += third;
99
+ out.push(Math.fround(at));
100
+ }
101
+ return out;
102
+ };
103
+ const ts = walk(t0, cx1, cx2, t3);
104
+ const vs = walk(v0, cy1, cy2, v3);
105
+ const out: number[] = [];
106
+ for (let i = 0; i < ts.length; i++) out.push(ts[i]!, vs[i]!);
107
+ return out;
108
+ }
109
+
110
+ const F32 = new Float32Array(1);
111
+ const F32_BITS = new Uint32Array(F32.buffer);
112
+
113
+ /** A key time as the built model states it — rig-c's `keyTime`: its own float32 name, else the float32 at or below it, named. */
114
+ function statedTime(t: number): number {
115
+ const named = f32(t);
116
+ if (named === t) return named;
117
+ let f = Math.fround(t);
118
+ if (f > t) {
119
+ F32[0] = f;
120
+ if (f > 0) F32_BITS[0]! -= 1;
121
+ else if (f < 0) F32_BITS[0]! += 1;
122
+ else F32_BITS[0] = 0x80000001;
123
+ f = F32[0]!;
124
+ }
125
+ return f32(f);
126
+ }
127
+ /** A key time as the poser compares it: the float32 of the stated time. */
128
+ const storedTime = (t: number): number => Math.fround(statedTime(t));
129
+
130
+ /**
131
+ * One channel of one segment, as the poser holds it: null for a linear
132
+ * segment, else the polyline `[t0, v0, …nine points…, t3, v3]`. The recurrence
133
+ * runs on the numbers the built model states (rig-c's `f32` of each, a key
134
+ * time by `keyTime`); the two ends are the keys as stored, float32.
135
+ */
136
+ function segmentPoints(a: ReducedKey, b: ReducedKey, channel: number): number[] | null {
137
+ if (a.curve === undefined) return null;
138
+ const c = a.curve.slice(channel * 4, channel * 4 + 4).map(f32);
139
+ const ta = statedTime(a.t);
140
+ const tb = statedTime(b.t);
141
+ const va = f32(a.v[channel]!);
142
+ const vb = f32(b.v[channel]!);
143
+ const inner = bezierPolyline(ta, va, c[0]!, c[1]!, c[2]!, c[3]!, tb, vb);
144
+ return [Math.fround(ta), Math.fround(va), ...inner, Math.fround(tb), Math.fround(vb)];
145
+ }
146
+
147
+ /** `segmentPoints`' segment at `t` — rig-c's `channelAt` past the key search. */
148
+ function pointsAt(a: ReducedKey, b: ReducedKey, channel: number, points: readonly number[] | null, t: number): number {
149
+ if (points === null) {
150
+ const ta = storedTime(a.t);
151
+ const va = Math.fround(a.v[channel]!);
152
+ return va + ((t - ta) / (storedTime(b.t) - ta)) * (Math.fround(b.v[channel]!) - va);
153
+ }
154
+ let i = 2;
155
+ while (i < points.length - 2 && points[i]! < t) i += 2;
156
+ const x0 = points[i - 2]!;
157
+ const y0 = points[i - 1]!;
158
+ return y0 + ((t - x0) / (points[i]! - x0)) * (points[i + 1]! - y0);
159
+ }
160
+
161
+ /** Channel `channel` of the segment from key `a` to key `b` (`a.curve` shapes it) at `t`, as rig-c's poser evaluates it. */
162
+ export function curveAt(a: ReducedKey, b: ReducedKey, channel: number, t: number): number {
163
+ return pointsAt(a, b, channel, segmentPoints(a, b, channel), t);
164
+ }
165
+
166
+ /** Every channel of a whole track at each sample time: the key search (last key stored at or before `t`), then the segment. */
167
+ function trackAt(keys: readonly ReducedKey[], samples: readonly number[], channels: number): number[][] {
168
+ const stored = keys.map((k) => storedTime(k.t));
169
+ const points = keys.map((k, j) => (j + 1 < keys.length ? Array.from({ length: channels }, (_, c) => segmentPoints(k, keys[j + 1]!, c)) : []));
170
+ let j = 0;
171
+ return samples.map((t) => {
172
+ while (j + 1 < keys.length && stored[j + 1]! <= t) j++;
173
+ const a = keys[j]!;
174
+ const b = keys[j + 1];
175
+ // Before the first key the track does not pose; the first key is on frame 0, stored at or below it.
176
+ if (b === undefined || t < stored[j]!) return a.v.map((v) => Math.fround(v));
177
+ return Array.from({ length: channels }, (_, c) => pointsAt(a, b, c, points[j]![c]!, t));
178
+ });
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // the fit
183
+ // ---------------------------------------------------------------------------
184
+
185
+ const r3 = (v: number): number => {
186
+ const r = Math.round(v * 1000) / 1000;
187
+ return Object.is(r, -0) ? 0 : r;
188
+ };
189
+
190
+ /** One track to reduce: its targets per frame (one array per frame, a value per channel) and how a frame's error is read. */
191
+ interface Fit {
192
+ targets: readonly (readonly number[])[];
193
+ keep: ReadonlySet<number>;
194
+ tolerance: number;
195
+ /** The error at frame `i` of `values` against the target. */
196
+ error: (i: number, values: readonly number[]) => number;
197
+ }
198
+
199
+ /** The frame with the largest error over `frames` (the first, on a tie), or null when there is none. */
200
+ function worstOf(frames: readonly number[], errorAt: (i: number) => number): { worst: number; frame: number } | null {
201
+ let best: { worst: number; frame: number } | null = null;
202
+ for (const i of frames) {
203
+ const e = errorAt(i);
204
+ if (best === null || e > best.worst) best = { worst: e, frame: i };
205
+ }
206
+ return best;
207
+ }
208
+
209
+ /** The least-squares handle values of one channel, or null when the normal equations are singular. */
210
+ function solveHandles(a: ReducedKey, b: ReducedKey, channel: number, hx1: number, hx2: number, interior: readonly number[], samples: readonly number[], target: (i: number) => number): [number, number] | null {
211
+ const model = (cy1: number, cy2: number): number[] => {
212
+ const c = new Array<number>(a.v.length * 4).fill(0);
213
+ c.splice(channel * 4, 4, hx1, cy1, hx2, cy2);
214
+ const k: ReducedKey = { t: a.t, v: a.v, curve: c };
215
+ const points = segmentPoints(k, b, channel);
216
+ return interior.map((i) => pointsAt(k, b, channel, points, samples[i]!));
217
+ };
218
+ const f00 = model(0, 0);
219
+ const f10 = model(1, 0);
220
+ const f01 = model(0, 1);
221
+ let s11 = 0;
222
+ let s12 = 0;
223
+ let s22 = 0;
224
+ let r1 = 0;
225
+ let r2 = 0;
226
+ interior.forEach((i, j) => {
227
+ const g1 = f10[j]! - f00[j]!;
228
+ const g2 = f01[j]! - f00[j]!;
229
+ const r = target(i) - f00[j]!;
230
+ s11 += g1 * g1;
231
+ s12 += g1 * g2;
232
+ s22 += g2 * g2;
233
+ r1 += g1 * r;
234
+ r2 += g2 * r;
235
+ });
236
+ const det = s11 * s22 - s12 * s12;
237
+ if (!(s11 > 0 && s22 > 0) || !(det > 1e-12 * s11 * s22)) return null;
238
+ return [(r1 * s22 - r2 * s12) / det, (r2 * s11 - r1 * s12) / det];
239
+ }
240
+
241
+ /** A track's keys: the kept frames, then a knot at the worst frame of every span over the tolerance, until none is. */
242
+ function fitTrack(times: readonly number[], samples: readonly number[], fit: Fit): ReducedKey[] {
243
+ const n = times.length;
244
+ const channels = fit.targets[0]!.length;
245
+ const keyOn = (i: number): ReducedKey => ({ t: times[i]!, v: fit.targets[i]!.map(r3) });
246
+ const knots = [...new Set([0, n - 1, ...fit.keep])].sort((x, y) => x - y);
247
+ const curves = new Map<number, number[]>();
248
+ const done: number[] = [];
249
+ const spans: Array<[number, number]> = [];
250
+ for (let j = knots.length - 1; j > 0; j--) spans.push([knots[j - 1]!, knots[j]!]);
251
+ done.push(...knots);
252
+ while (spans.length > 0) {
253
+ const [k0, k1] = spans.pop()!;
254
+ if (k1 - k0 <= 1) continue;
255
+ const a = keyOn(k0);
256
+ const b = keyOn(k1);
257
+ const interior = Array.from({ length: k1 - k0 - 1 }, (_, j) => k0 + 1 + j);
258
+ const errorWith = (k: ReducedKey) => {
259
+ const points = Array.from({ length: channels }, (_, c) => segmentPoints(k, b, c));
260
+ return worstOf(interior, (i) => fit.error(i, points.map((p, c) => pointsAt(k, b, c, p, samples[i]!))))!;
261
+ };
262
+ const linear = errorWith(a);
263
+ if (linear.worst <= fit.tolerance) continue;
264
+ let split = linear.frame;
265
+ const hx1 = times[k0]! + (times[k1]! - times[k0]!) / 3;
266
+ const hx2 = times[k0]! + (2 * (times[k1]! - times[k0]!)) / 3;
267
+ const handles = Array.from({ length: channels }, (_, c) => solveHandles(a, b, c, hx1, hx2, interior, samples, (i) => fit.targets[i]![c]!));
268
+ if (handles.every((h) => h !== null)) {
269
+ const curve = handles.flatMap(([cy1, cy2]) => [hx1, r3(cy1), hx2, r3(cy2)]);
270
+ const bent = errorWith({ ...a, curve });
271
+ if (bent.worst <= fit.tolerance) {
272
+ curves.set(k0, curve);
273
+ continue;
274
+ }
275
+ split = bent.frame;
276
+ }
277
+ done.push(split);
278
+ spans.push([split, k1], [k0, split]);
279
+ }
280
+ return [...new Set(done)].sort((x, y) => x - y).map((i) => {
281
+ const k = keyOn(i);
282
+ const curve = curves.get(i);
283
+ return curve === undefined ? k : { ...k, curve };
284
+ });
285
+ }
286
+
287
+ /** The frame indices `keep` names, each checked against the frame count. */
288
+ function keptFrames(keep: ReadonlySet<number>, n: number, label: string): Set<number> {
289
+ for (const i of keep) {
290
+ if (!Number.isInteger(i) || i < 0 || i >= n) throw new Error(`reduce: ${label} keeps frame ${i}, outside the ${n} frame(s) 0–${n - 1}`);
291
+ }
292
+ return new Set(keep);
293
+ }
294
+
295
+ /**
296
+ * Every bone's rotate track (and the hip's translate) reduced within the
297
+ * tolerance, top-down along `above`; the rows say each track's key counts and
298
+ * its worst world error, measured on the reduced keys as the poser evaluates
299
+ * them.
300
+ */
301
+ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[], tolerance: number, hip: ReduceHip | null): Reduced {
302
+ const n = times.length;
303
+ if (n === 0) throw new Error('reduce: no frames to reduce');
304
+ if (!(Number.isFinite(tolerance) && tolerance > 0)) throw new Error(`reduce: the tolerance is ${tolerance}°, not a positive number`);
305
+ if (hip !== null && !(Number.isFinite(hip.tolerance) && hip.tolerance > 0)) throw new Error(`reduce: the hip's tolerance is ${hip.tolerance} units, not a positive number`);
306
+ // The poser samples at the running sum of the frame steps (verify.ts `poseFrames`).
307
+ const samples: number[] = [times[0]!];
308
+ for (let i = 1; i < n; i++) samples.push(samples[i - 1]! + (times[i]! - times[i - 1]!));
309
+
310
+ const byName = new Map<string, ReduceBone>();
311
+ for (const b of bones) {
312
+ if (byName.has(b.bone)) throw new Error(`reduce: bone "${b.bone}" is given twice`);
313
+ if (b.dense.length !== n) throw new Error(`reduce: bone "${b.bone}" has ${b.dense.length} dense key(s) for ${n} frame(s)`);
314
+ byName.set(b.bone, b);
315
+ }
316
+ for (const b of bones) {
317
+ if (b.above !== null && !byName.has(b.above)) throw new Error(`reduce: bone "${b.bone}" adds to bone "${b.above}", which is not among the bones reduced`);
318
+ }
319
+
320
+ // Top-down: a bone once its above is done, in the order given otherwise.
321
+ const errors = new Map<string, number[]>();
322
+ const rotate = new Map<string, ReducedKey[]>();
323
+ const rowOf = new Map<string, ReduceRow>();
324
+ const pending = [...bones];
325
+ while (pending.length > 0) {
326
+ const at = pending.findIndex((b) => b.above === null || errors.has(b.above));
327
+ if (at < 0) throw new Error(`reduce: bones ${pending.map((b) => `"${b.bone}"`).join(', ')} each add to another of them — a loop, not a tree`);
328
+ const b = pending.splice(at, 1)[0]!;
329
+ const eAbove = b.above === null ? null : errors.get(b.above)!;
330
+ const targets = b.dense.map((d, i) => [eAbove === null ? d : d - eAbove[i]!]);
331
+ const keys = fitTrack(times, samples, {
332
+ targets,
333
+ keep: keptFrames(b.keep, n, `bone "${b.bone}"`),
334
+ tolerance,
335
+ error: (i, v) => Math.abs(v[0]! - targets[i]![0]!),
336
+ });
337
+ const e = trackAt(keys, samples, 1).map((v, i) => v[0]! - targets[i]![0]!);
338
+ errors.set(b.bone, e);
339
+ rotate.set(b.bone, keys);
340
+ const worst = worstOf(e.map((_, i) => i), (i) => Math.abs(e[i]!))!;
341
+ rowOf.set(b.bone, { bone: b.bone, kind: 'angle', before: n, after: keys.length, worst: worst.worst, frame: worst.frame });
342
+ }
343
+ const rows = bones.map((b) => rowOf.get(b.bone)!);
344
+ const ordered = new Map(bones.map((b) => [b.bone, rotate.get(b.bone)!]));
345
+
346
+ let hipKeys: ReducedKey[] | null = null;
347
+ if (hip !== null) {
348
+ if (hip.dense.length !== n) throw new Error(`reduce: hip "${hip.bone}" has ${hip.dense.length} dense key(s) for ${n} frame(s)`);
349
+ const [ma, mb, mc, md] = hip.parent ?? [1, 0, 0, 1];
350
+ const world = (i: number, v: readonly number[]): number => {
351
+ const dx = v[0]! - hip.dense[i]![0];
352
+ const dy = v[1]! - hip.dense[i]![1];
353
+ return Math.hypot(ma * dx + mb * dy, mc * dx + md * dy);
354
+ };
355
+ hipKeys = fitTrack(times, samples, { targets: hip.dense, keep: keptFrames(hip.keep, n, `hip "${hip.bone}"`), tolerance: hip.tolerance, error: world });
356
+ const at = trackAt(hipKeys, samples, 2);
357
+ const worst = worstOf(at.map((_, i) => i), (i) => world(i, at[i]!))!;
358
+ rows.push({ bone: hip.bone, kind: 'origin', before: n, after: hipKeys.length, worst: worst.worst, frame: worst.frame });
359
+ }
360
+ return { rotate: ordered, hip: hipKeys, rows };
361
+ }
package/src/retarget.ts CHANGED
@@ -173,6 +173,8 @@ export interface MotionKey {
173
173
  v: number[];
174
174
  /** `stepped`: the value holds until the next key (the offsets' translate keys). */
175
175
  ease?: 'stepped';
176
+ /** A raw Bézier to the next key: four absolute (time, value) numbers per channel (`--reduce`, #32). */
177
+ curve?: number[];
176
178
  }
177
179
  export interface MotionTrack {
178
180
  bone: string;
package/src/run.ts CHANGED
@@ -7,15 +7,16 @@
7
7
  import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { dirname, join, resolve } from 'node:path';
10
- import { readMap, resolveMap, type JointMap } from './map.ts';
10
+ import { readMap, resolveMap, type JointMap, type Rest } from './map.ts';
11
11
  import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
12
12
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
13
13
  import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
14
- import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
14
+ import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type MotionTrack, type RetargetOptions } from './retarget.ts';
15
15
  import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
16
16
  import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
17
17
  import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
18
18
  import { legsOf } from './legs.ts';
19
+ import { reduceKeys, type ReduceBone, type Reduced } from './reduce.ts';
19
20
  import type { LegScale } from './legs.ts';
20
21
 
21
22
  export interface RetargetArgs extends RetargetOptions {
@@ -42,6 +43,10 @@ export interface RetargetArgs extends RetargetOptions {
42
43
  carryIdle?: boolean;
43
44
  /** A rig-pose-tracks/1 fragment merged into the new animation. */
44
45
  merge?: string;
46
+ /** Thin the mapped bones' rotate keys within this many degrees (#32). */
47
+ reduce?: number;
48
+ /** With reduce: thin the hip's translate keys within this many rig units. */
49
+ reduceHip?: number;
45
50
  }
46
51
 
47
52
  export interface RunResult {
@@ -99,7 +104,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
99
104
  resolveMap(map, rest, a.map, slots);
100
105
 
101
106
  const r = retarget(rest, map, frames, times, a, slots);
102
- say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}`);
107
+ say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
103
108
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
104
109
  if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
105
110
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
@@ -129,6 +134,14 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
129
134
  say(` FEET steps (the supporting ankle > 0.03 leg lengths a frame; re-planted after 2 frames < 0.01): ${f.steps.map((x) => `${x.side} ${span(x.frames)} → ${x.to === null ? 'the pose\'s travel' : `${x.to} supports`}, ${x.replant === null ? 'never re-planted' : `re-planted at ${x.replant}`}`).join('; ') || 'none'}`);
130
135
  }
131
136
 
137
+ // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
138
+ const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
139
+ if (reduced !== null) {
140
+ for (const row of reduced.rows) say(` REDUCE ${row.kind === 'angle' ? 'bone' : 'hip'} "${row.bone}" ${row.kind === 'angle' ? 'rotate' : 'translate'}: ${row.before} → ${row.after} key(s); worst ${row.kind === 'angle' ? 'aim' : 'origin'} error ${row.worst.toPrecision(3)}${row.kind === 'angle' ? '°' : ' units'} at frame ${row.frame} (t=${r.times[row.frame]!.toFixed(3)}s)`);
141
+ const sum = (k: 'before' | 'after') => reduced.rows.filter((x) => x.kind === 'angle').reduce((t, x) => t + x[k], 0);
142
+ say(` REDUCE rotate keys ${sum('before')} → ${sum('after')} (${(sum('before') / sum('after')).toFixed(2)}×) within ${a.reduce}°${a.reduceHip === undefined ? '; the hip stays dense (no --reduce-hip)' : ''}`);
143
+ }
144
+
132
145
  // The idle against the new animation (#43 item 2): what it drops, and with --carry-idle, looped back in.
133
146
  const groups = (isObject(base.groups) ? base.groups : {}) as Record<string, string[]>;
134
147
  const easings = (isObject(base.easings) ? base.easings : {}) as Record<string, number[]>;
@@ -179,7 +192,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
179
192
 
180
193
  const doc = readBuild(g1.build);
181
194
  const posed = poseFrames(doc, a.name, r.times);
182
- const landings = measureLanding(doc, a.name, map, r, posed);
195
+ // A reduced track is held to the dense target plus its tolerance (#32).
196
+ const landings = measureLanding(doc, a.name, map, r, posed).map((l) => (l.kind === 'angle' && a.reduce !== undefined ? { ...l, tolerance: l.tolerance + a.reduce } : l.kind === 'origin' && a.reduceHip !== undefined ? { ...l, tolerance: l.tolerance + a.reduceHip } : l));
183
197
  const p = new Problems();
184
198
  // A carried idle track on a bone above a mapped one moves it where the keys did not ask (the key formula holds unmapped bones at setup).
185
199
  const carriedAbove = (bone: string): string => {
@@ -249,7 +263,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
249
263
  animation: a.name,
250
264
  frames: seq.frames.length,
251
265
  duration: r.animation.duration,
252
- options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null },
266
+ options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
253
267
  scale: r.scale,
254
268
  holds: r.holds,
255
269
  short: r.short,
@@ -272,6 +286,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
272
286
  idleDropped: idle === null ? null : idle.dropped,
273
287
  idleKeptOut: idle === null ? null : idle.keptOut,
274
288
  merged: fragment.map((tr) => ({ track: trackLabel(tr, groups), keys: tr.keys.length })),
289
+ reduce: reduced === null ? null : { tolerance: a.reduce, hipTolerance: a.reduceHip ?? null, rows: reduced.rows },
275
290
  }, null, 2)}\n`);
276
291
  }
277
292
  say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
@@ -281,6 +296,47 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
281
296
  }
282
297
  }
283
298
 
299
+ /**
300
+ * The retarget's rotate tracks (and with a hip tolerance, its translate) thinned
301
+ * in place (#32). Kept regardless, per bone: the frames either side of each
302
+ * switch boundary on the chains it changes, and the edges of every FLIP spread,
303
+ * SHORT span and RANGE run on it (the first and last frame the reducer keeps).
304
+ */
305
+ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest, tolerance: number, hipTolerance: number | undefined): Reduced {
306
+ const n = r.times.length;
307
+ const keep = new Map<string, Set<number>>();
308
+ const add = (bone: string, ...frames: number[]) => {
309
+ const s = keep.get(bone) ?? new Set<number>();
310
+ for (const f of frames) if (f >= 0 && f < n) s.add(f);
311
+ keep.set(bone, s);
312
+ };
313
+ for (const sw of r.switches) {
314
+ for (const c of map.chains.filter((x) => sw.chains.includes(x.name))) for (const b of c.bones) add(b, sw.frames[0] - 1, sw.frames[0], sw.frames[1], sw.frames[1] + 1);
315
+ }
316
+ for (const x of [...r.flips, ...r.short, ...r.range]) add(x.bone, x.frames[0], x.frames[1]);
317
+ const mapped = new Set(r.targets.keys());
318
+ const above = (b: string): string | null => {
319
+ for (let p = rest.get(b)!.parent; p !== null; p = rest.get(p)!.parent) if (mapped.has(p)) return p;
320
+ return null;
321
+ };
322
+ const rotate = new Map<string, MotionTrack>();
323
+ for (const tr of r.animation.tracks) if ('bone' in tr && tr.property === 'rotate') rotate.set(tr.bone, tr);
324
+ const bones: ReduceBone[] = [...rotate.values()].map((tr) => ({ bone: tr.bone, above: above(tr.bone), dense: tr.keys.map((k) => k.v[0]!), keep: keep.get(tr.bone) ?? new Set<number>() }));
325
+ const hipTrack = r.origin === null || hipTolerance === undefined ? undefined : r.animation.tracks.find((tr): tr is MotionTrack => 'bone' in tr && tr.property === 'translate' && tr.bone === r.origin!.bone && tr.keys.every((k) => k.ease === undefined));
326
+ const parent = hipTrack === undefined ? null : rest.get(hipTrack.bone)!.parent;
327
+ const pm = parent === null ? null : rest.get(parent)!;
328
+ const out = reduceKeys(r.times, bones, tolerance, hipTrack === undefined ? null : {
329
+ bone: hipTrack.bone,
330
+ dense: hipTrack.keys.map((k) => [k.v[0]!, k.v[1]!]),
331
+ keep: new Set<number>(),
332
+ parent: pm === null ? null : [pm.a, pm.b, pm.c, pm.d],
333
+ tolerance: hipTolerance!,
334
+ });
335
+ for (const [bone, keys] of out.rotate) rotate.get(bone)!.keys = keys;
336
+ if (hipTrack !== undefined && out.hip !== null) hipTrack.keys = out.hip;
337
+ return out;
338
+ }
339
+
284
340
  function spans(ts: number[], step: number): string {
285
341
  const out: string[] = [];
286
342
  let s = ts[0]!;