@rig-c/pose 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,9 +54,11 @@ and what the run cannot decide for you it lists:
54
54
  - `PIVOT` — when the map translates a bone and has both legs: the translated bone's origin against its thigh roots' midpoint at setup, in units and hip widths — the pivot every hip turn swings the legs about.
55
55
  - `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
56
  - `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
+ - `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)).
58
+ - `REDUCE` — with `--reduce` ([below](#few-keys---reduce)): per bone, its keys before and after and its largest error, with the frame; then the total.
57
59
  - `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.
58
60
 
59
- `--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`, and the demand lists:
61
+ `--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:
60
62
 
61
63
  - `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.
62
64
  - `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.
@@ -109,6 +111,42 @@ rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inh
109
111
 
110
112
  Both are checked on the gated build: every scale lands, each ankle lands where the keys put it, and the hip's origin where the plant put it. The figures were fixed in #47 before the dancer was measured; the defaults stay off until they pass.
111
113
 
114
+ ### The idle: what the retarget drops
115
+
116
+ A rig's motion spec usually has an `idle` — a blink, a breath, hair and hem sway — and the new animation keys none of it ([#43](https://github.com/rig-c/pose/issues/43), item 2). The run reads that animation (`--idle <name>` names another; naming one the spec lacks is `IDLE_NAME`) track by track against the new one, channel by channel — `translate` poses `translatex` and `translatey`, a `group` track poses each of its members, a slot's `rgba` its `rgb` and `alpha`:
117
+
118
+ - `IDLE-DROPPED` — a track the new animation keys on none of its channels, and that turns no mapped bone (below): its target, property, keys and the idle's duration. The report lists them under `idleDropped` (`target`, `members`, `property`, `keys`, `duration`, `carried`, `notCarried`).
119
+ - `IDLE-KEPT-OUT` — never carried (`idleKeptOut`), with the reason: a track the new animation keys itself, naming the track that keys it; or a `rotate`, `scale` or `shear` (any axis) of a bone **above** a mapped bone — between mapped bones, or above the first one (the map's chains and its translated bone) — naming the mapped bones below it: the retarget's keys hold every unmapped bone at its setup pose, so such a track would turn them off the angles the pose asks. A `translate` of such a bone is carried: it moves the bones under it and turns none. The exception is a translate of a bone above the **translated** bone (the hip), which is kept out because the retarget places that bone's origin. (Folding a carried parent's motion into the mapped bones' keys is a later card.) The idle's `drawOrder`, `events`, `ik`, `transform`, `deform` and `sequence` timelines are not tracks, and are listed here too.
120
+
121
+ **`--carry-idle`** (off by default: a blink keyed over a set's art may not suit it, and that is the author's call) loops every dropped track into the new animation: the idle's keys repeated every idle duration and cut at the new animation's end, with the value interpolated there.
122
+
123
+ - A key's `ease` or `curve` describes the segment to the next key, so a loop is built key by key. Where the idle's last key is at its duration and its first at 0, the two are one key at each seam (the next cycle's first; if their values differ, the line says where the loop jumps, as a player looping the idle does). Where the last key is earlier, it is keyed `stepped`, so it holds to the cycle's end as it does in the idle. Where the first key is later than 0, each later cycle opens with a stepped setup key: 0 for a bone's translate, shear and rotate, 1 for its scale, the slot's setup attachment; for any other property such a track is not carried, and the line says why.
124
+ - A raw `curve` states absolute times, so a repeated one is shifted with its key. The segment the cut falls in is split there: linear and stepped as they are, a Bézier (a raw `curve`, or a named easing turned into one as rigc does) by de Casteljau, so the part kept is the same cubic and the last key carries neither ease nor curve. rig-c draws a Bézier as Spine's polyline, so a split segment and the idle's whole one sit off the cubic, and off each other, by that alone (measured on the test's eased segment: the idle's own up to 2.6e-3, the carried part up to 9.3e-4).
125
+ - A track with `lag` or `stagger`, or whose values are per-member maps or models, is not carried, and the line says so.
126
+
127
+ Every bone still has to land with the carried tracks in. A carried translate above the translated bone moves its origin off the pose's travel, and the run refuses with `POSE_LANDS`, naming the carried tracks above the bone that missed.
128
+
129
+ On HQ's dancer (`--flip 90 --short 0.35 --bends clamp --carry-idle`) the idle has 26 tracks. Kept out: the four arm rotates, which the dance keys, and `chest_ctl` scalex, `neck` rotate and `head_ctl` rotate, which sit above the mapped chest, head and arms — carried, they missed the chest, the head and the arms by 0.16°–1.37°. Carried: the other 19, the blink (`group "eyes"` scaley) and the breath (`bone "chest_ctl"` translatey) among them. The gate is green, every bone lands (worst 0.00167°), and rig-c's poser shows the blink at every cycle (scaleY 0.12 at 2.37 s, 5.70 s, 9.08 s and 12.41 s, against the idle's at t mod 3.333 s within 8.9e-6) and the breath's chest_ctl offset (5.02 at 0.833, 4.167, 7.5, 10.833 s; 0 at 2.5, 5.833, 9.167, 12.5 s; 1.035 at the cut, 13 s).
130
+
131
+ **`--merge <file>`** merges a timed track fragment into the new animation, at absolute times from its start — blinks read off the video, for example:
132
+
133
+ ```json
134
+ { "spec": "rig-pose-tracks/1", "tracks": [ { "group": "eyes", "property": "scaley", "keys": [ { "t": 2.3, "v": [1] }, { "t": 2.37, "v": [0.12] }, { "t": 2.45, "v": [1] } ] } ] }
135
+ ```
136
+
137
+ 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`.
138
+
139
+ ### Few keys: `--reduce`
140
+
141
+ 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.
142
+
143
+ - **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.
144
+ - **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.
145
+ - **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.
146
+ - **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.
147
+ - **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×.
148
+ - **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).
149
+
112
150
  Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
113
151
 
114
152
  ## The pose: `rig-pose-sequence/1`
package/cli.ts CHANGED
@@ -64,6 +64,26 @@ retarget
64
64
  travel and re-plants on landing; a supporting ankle moving > 0.03 leg lengths a frame
65
65
  releases its plant to the other foot, or to the pose's travel, until it is still
66
66
  (< 0.01 for 2 frames) (FEET lines)
67
+ --idle <name> the rig's animation read as its idle (default: idle). Every idle track the new
68
+ animation does not key, on any channel, is listed as IDLE-DROPPED (a blink, a
69
+ breath); one it keys is IDLE-KEPT-OUT, naming the track that keys it, and so is a
70
+ rotate, scale or shear of a bone above a mapped bone (a translate of one is carried)
71
+ --carry-idle loop the IDLE-DROPPED tracks into the new animation: the idle's keys repeated
72
+ every idle duration and cut at the new animation's end, with the value
73
+ interpolated there (default: off — a blink keyed over a set's art is the author's call)
74
+ --merge <file> merge rig-pose-tracks/1 into the new animation: { "spec": "rig-pose-tracks/1",
75
+ "tracks": [ <rigc-motion track> … ] } at absolute times from its start (blinks read
76
+ off the video); a track on a target and property it already keys is refused
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)
67
87
  --switch-tolerance <d> a set of alternate drawings (the map's "alternates") may show on a frame where
68
88
  every segment of its chains is within d degrees of its key skeleton (default: 25)
69
89
  --switch-min <s> a run of one set shorter than s seconds is dropped (default: 0.25)
@@ -75,7 +95,8 @@ retarget
75
95
  --build <dir> keep the gated build (skeleton.json, .atlas, .model.json) here
76
96
  --render <dir> keep rigc's render of the animation (frames, contact.png, folds.json) here
77
97
  --report <file> write the run's measurements (holds, short, endOn, flips, bends, landings, folds,
78
- switches, uncovered, turns, range, artRange) as JSON
98
+ switches, uncovered, turns, range, artRange,
99
+ idleDropped, idleKeptOut, merged, reduce) as JSON
79
100
 
80
101
  It writes nothing until rigc's gate is green with the animation in, every mapped bone lands on
81
102
  the angle the pose asks for (posed by rig-c's own poser), and no hinge bends the wrong way.
@@ -179,7 +200,7 @@ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
179
200
  }
180
201
 
181
202
  function retargetArgs(argv: string[]): RetargetArgs {
182
- 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'], ['coco', 'plant-feet']);
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']);
183
204
  const bends = f.get('bends') ?? 'refuse';
184
205
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
185
206
  const switchAt = f.get('switch-at') ?? 'first';
@@ -213,9 +234,26 @@ function retargetArgs(argv: string[]): RetargetArgs {
213
234
  build: f.get('build'),
214
235
  render: f.get('render'),
215
236
  report: f.get('report'),
237
+ idle: f.get('idle'),
238
+ carryIdle: f.has('carry-idle'),
239
+ merge: f.get('merge'),
240
+ ...reduceOf(f),
216
241
  };
217
242
  }
218
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
+
219
257
  function compareArgs(argv: string[]): CompareArgs {
220
258
  const f = parseFlags(argv, ['reference', 'render', 'map', 'retarget-report', 'reference-hands', 'render-hands', 'cloth-reference', 'cloth-render', 'cloth-windows', 'report', 'sheet'], []);
221
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.9.0",
3
+ "version": "0.11.0",
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/idle.ts ADDED
@@ -0,0 +1,421 @@
1
+ /**
2
+ * The tracks a retarget drops, and the two ways to put them back (rig-c/pose#43
3
+ * item 2): the rig's idle read against the new animation, its dropped tracks
4
+ * looped into it with `--carry-idle`, and a timed fragment merged with `--merge`.
5
+ *
6
+ * Pure: the motion spec's own track objects in, track objects out. What a
7
+ * merged or carried track means (its bone, its property, its values) is rig-c's
8
+ * to judge, and its gate judges it with the animation in.
9
+ *
10
+ * A key's `ease` or `curve` describes the segment from that key to the next
11
+ * (rig-c docs/AUTHORING.md §4.5), so a loop is built key by key: where one
12
+ * cycle meets the next, the key that ends a cycle and the one that starts the
13
+ * next must not share a time, and the segment into the seam must ease toward
14
+ * the value the loop shows there. A raw `curve` states absolute times, so a
15
+ * repeated one is shifted with its key. The last key of the whole track carries
16
+ * neither.
17
+ */
18
+ import { isObject, Problems, typeName, type Problem } from './problems.ts';
19
+
20
+ /** A rigc-motion track as the motion spec states it: one target field, a property, keys, and whatever else it carries. */
21
+ export type RigcTrack = Record<string, unknown> & { property: string; keys: RigcKey[] };
22
+ export type RigcKey = Record<string, unknown> & { t: number; v: unknown; ease?: string; curve?: number[] };
23
+
24
+ /** The six target fields of a track (AUTHORING §4.4). */
25
+ const TARGET_FIELDS = ['bone', 'slot', 'group', 'physics', 'path', 'slider'] as const;
26
+ type TargetKind = (typeof TARGET_FIELDS)[number];
27
+
28
+ /** What each bone and slot property poses, channel by channel: two tracks that share a channel key the same thing. */
29
+ const BONE_CHANNELS: Readonly<Record<string, readonly string[]>> = {
30
+ translate: ['translatex', 'translatey'], translatex: ['translatex'], translatey: ['translatey'],
31
+ scale: ['scalex', 'scaley'], scalex: ['scalex'], scaley: ['scaley'],
32
+ shear: ['shearx', 'sheary'], shearx: ['shearx'], sheary: ['sheary'],
33
+ rotate: ['rotate'], inherit: ['inherit'],
34
+ };
35
+ const SLOT_CHANNELS: Readonly<Record<string, readonly string[]>> = {
36
+ attachment: ['attachment'], rgba: ['rgb', 'alpha'], rgb: ['rgb'], alpha: ['alpha'], rgba2: ['rgb', 'alpha', 'dark'], rgb2: ['rgb', 'dark'],
37
+ };
38
+ /** A bone property whose setup value, as a key states it, is known without reading the rig: translate and shear add to setup (0), scale multiplies it (1). */
39
+ const BONE_IDENTITY: Readonly<Record<string, number>> = {
40
+ translate: 0, translatex: 0, translatey: 0, shear: 0, shearx: 0, sheary: 0, rotate: 0, scale: 1, scalex: 1, scaley: 1,
41
+ };
42
+ /** The bone properties that only move a bone: a child's angle is unchanged, so one on a bone above a mapped bone is carried. */
43
+ const TRANSLATES = new Set(['translate', 'translatex', 'translatey']);
44
+ /** Timelines stepped by the format: a key carries no ease and no curve, and no value lies between two keys. */
45
+ const STEPPED_BY_NATURE = new Set(['attachment', 'inherit', 'reset']);
46
+
47
+ /** A target and its name: `bone "neck"`, `group "eyes"`. */
48
+ export interface Target {
49
+ kind: TargetKind;
50
+ name: string;
51
+ }
52
+
53
+ export function targetOf(track: Record<string, unknown>): Target | null {
54
+ const found = TARGET_FIELDS.filter((f) => typeof track[f] === 'string');
55
+ return found.length === 1 ? { kind: found[0]!, name: track[found[0]!] as string } : null;
56
+ }
57
+
58
+ /** `group "eyes" (eye_r, eye_l) scaley`, `bone "chest_ctl" translatey`. */
59
+ export function trackLabel(track: RigcTrack, groups: Readonly<Record<string, readonly string[]>>): string {
60
+ const t = targetOf(track);
61
+ if (t === null) return `a track with no single target, property ${JSON.stringify(track.property)}`;
62
+ const members = t.kind === 'group' && groups[t.name] !== undefined ? ` (${groups[t.name]!.join(', ')})` : '';
63
+ return `${t.kind} "${t.name}"${members} ${track.property}`;
64
+ }
65
+
66
+ /**
67
+ * Every channel a track poses, as `family:name:channel`: a group's members are
68
+ * bones, slots or physics constraints by its property (AUTHORING §4.3), so a
69
+ * group track collides with a bone track on one of its members.
70
+ */
71
+ function cellsOf(track: RigcTrack, groups: Readonly<Record<string, readonly string[]>>): string[] {
72
+ const t = targetOf(track);
73
+ if (t === null) return [];
74
+ const p = track.property;
75
+ const family = (kind: TargetKind): 'bone' | 'slot' | 'physics' | 'path' | 'slider' => {
76
+ if (kind !== 'group') return kind;
77
+ if (p in BONE_CHANNELS) return 'bone';
78
+ if (p in SLOT_CHANNELS) return 'slot';
79
+ return 'physics';
80
+ };
81
+ const fam = family(t.kind);
82
+ const channels = fam === 'bone' ? BONE_CHANNELS[p] ?? [p] : fam === 'slot' ? SLOT_CHANNELS[p] ?? [p] : [p];
83
+ const names = t.kind === 'group' ? groups[t.name] ?? [] : [t.name];
84
+ return names.flatMap((n) => channels.map((c) => `${fam}:${n}:${c}`));
85
+ }
86
+
87
+ /** Which channels the tracks pose, each with the label of the track posing it. */
88
+ export function keyedBy(tracks: readonly RigcTrack[], groups: Readonly<Record<string, readonly string[]>>): Map<string, string> {
89
+ const out = new Map<string, string>();
90
+ for (const tr of tracks) for (const c of cellsOf(tr, groups)) if (!out.has(c)) out.set(c, trackLabel(tr, groups));
91
+ return out;
92
+ }
93
+
94
+ /** The labels of the tracks in `keyed` that pose a channel `track` poses. */
95
+ function collisions(track: RigcTrack, keyed: ReadonlyMap<string, string>, groups: Readonly<Record<string, readonly string[]>>): string[] {
96
+ return [...new Set(cellsOf(track, groups).flatMap((c) => (keyed.has(c) ? [keyed.get(c)!] : [])))];
97
+ }
98
+
99
+ export interface IdleDropped {
100
+ target: Target;
101
+ /** A group's members, as the motion spec's `groups` names them. */
102
+ members: string[] | null;
103
+ property: string;
104
+ /** The track's keys in the idle. */
105
+ keys: number;
106
+ /** The idle's duration, seconds. */
107
+ duration: number;
108
+ /** With --carry-idle: the looped track's keys, the cycles they span, and where a seam jumps. */
109
+ carried: { keys: number; cycles: number; seams: string | null } | null;
110
+ /** With --carry-idle: why this track was not carried. */
111
+ notCarried: string | null;
112
+ }
113
+
114
+ export interface IdleKeptOut {
115
+ target: Target;
116
+ property: string;
117
+ reason: string;
118
+ }
119
+
120
+ export interface IdleReading {
121
+ animation: string;
122
+ duration: number;
123
+ tracks: number;
124
+ dropped: IdleDropped[];
125
+ keptOut: IdleKeptOut[];
126
+ /** The idle's timelines that are not tracks (drawOrder, events, ik, transform, deform, sequence): never compared, never carried. */
127
+ notTracks: string[];
128
+ /** With --carry-idle: the looped tracks, in the idle's order. */
129
+ carriedTracks: RigcTrack[];
130
+ }
131
+
132
+ const NOT_TRACKS = ['drawOrder', 'events', 'ik', 'transform', 'deform', 'sequence'] as const;
133
+
134
+ /**
135
+ * The idle against the new animation: every idle track the new one does not
136
+ * key on any channel is dropped (and, with `carry`, looped into it); every one
137
+ * it keys is kept out, naming the track that keys it.
138
+ */
139
+ export function readIdle(
140
+ name: string,
141
+ /** The new animation's name. */
142
+ into: string,
143
+ idle: Record<string, unknown>,
144
+ newTracks: readonly RigcTrack[],
145
+ duration: number,
146
+ motion: { groups: Readonly<Record<string, readonly string[]>>; easings: Readonly<Record<string, readonly number[]>> },
147
+ /** The setup value a key on this target and property states (`sample` is one of its keys' values), or undefined when it is not read. */
148
+ setupOf: (target: Target, property: string, sample: unknown) => unknown,
149
+ carry: boolean,
150
+ /** The mapped bones (chains and the translated bone) under a bone, in map order. */
151
+ mappedBelow: (bone: string) => readonly string[] = () => [],
152
+ /** The translated bone (the hip) when it lies under this bone, else null: a translate above it moves the origin the retarget places. */
153
+ translatedBelow: (bone: string) => string | null = () => null,
154
+ ): IdleReading {
155
+ const D = idle.duration as number;
156
+ const tracks = (Array.isArray(idle.tracks) ? idle.tracks : []) as RigcTrack[];
157
+ const keyed = keyedBy(newTracks, motion.groups);
158
+ const dropped: IdleDropped[] = [];
159
+ const keptOut: IdleKeptOut[] = [];
160
+ const carriedTracks: RigcTrack[] = [];
161
+ for (const tr of tracks) {
162
+ const target = targetOf(tr);
163
+ if (target === null) continue;
164
+ const hit = collisions(tr, keyed, motion.groups);
165
+ if (hit.length > 0) {
166
+ keptOut.push({ target, property: tr.property, reason: `"${into}"'s own ${hit.join(', ')} keys it` });
167
+ continue;
168
+ }
169
+ // A rotate, scale or shear above a mapped bone turns it off the angle its keys assume (the key formula holds unmapped bones at setup).
170
+ const bones = target.kind === 'bone' ? [target.name] : target.kind === 'group' && tr.property in BONE_CHANNELS ? [...(motion.groups[target.name] ?? [])] : [];
171
+ const under = TRANSLATES.has(tr.property) ? [] : [...new Set(bones.flatMap((b) => mappedBelow(b)))];
172
+ const hipBelow = TRANSLATES.has(tr.property) ? bones.map((b) => translatedBelow(b)).find((h) => h !== null) ?? null : null;
173
+ if (hipBelow !== null) {
174
+ keptOut.push({ target, property: tr.property, reason: `moves the translated bone "${hipBelow}" below it; the retarget places its origin` });
175
+ continue;
176
+ }
177
+ if (under.length > 0) {
178
+ keptOut.push({ target, property: tr.property, reason: `moves mapped bone(s) ${under.map((b) => `"${b}"`).join(', ')} below it; the retarget's keys assume it at setup` });
179
+ continue;
180
+ }
181
+ const entry: IdleDropped = { target, members: target.kind === 'group' ? [...(motion.groups[target.name] ?? [])] : null, property: tr.property, keys: tr.keys.length, duration: D, carried: null, notCarried: null };
182
+ if (carry) {
183
+ const c = loopTrack(tr, D, duration, motion.easings, () => setupOf(target, tr.property, tr.keys[0]?.v));
184
+ if ('reason' in c) entry.notCarried = c.reason;
185
+ else {
186
+ entry.carried = { keys: c.track.keys.length, cycles: c.cycles, seams: c.seams };
187
+ carriedTracks.push(c.track);
188
+ }
189
+ }
190
+ dropped.push(entry);
191
+ }
192
+ const notTracks = NOT_TRACKS.filter((k) => idle[k] !== undefined);
193
+ return { animation: name, duration: D, tracks: tracks.length, dropped, keptOut, notTracks, carriedTracks };
194
+ }
195
+
196
+ const r6 = (v: number): number => Math.round(v * 1e6) / 1e6;
197
+ /** Two times this close are one time (key times are written to 1e-6 s). */
198
+ const EPS = 1e-7;
199
+
200
+ const numeric = (v: unknown): v is number[] => Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === 'number' && Number.isFinite(x));
201
+
202
+ /**
203
+ * One idle track looped to `T` seconds: its keys repeated every `D` seconds and
204
+ * cut at `T`, with the value interpolated there.
205
+ *
206
+ * - Where the idle's last key is at `D` and its first at 0 (a closed loop), the
207
+ * two are one key at each seam: the next cycle's first. A last key that
208
+ * differs from the first is a jump in the idle as a player loops it (at `D`
209
+ * it shows the first key), and the seam keys the first; `seams` says so.
210
+ * - Where the last key is before `D`, the player holds it to `D`: it is keyed
211
+ * `stepped` (a timeline stepped by the format holds anyway).
212
+ * - Where the first key is after 0, the player shows the setup value from each
213
+ * cycle's start to that key: a stepped setup key opens each later cycle. That
214
+ * needs the setup value as a key states it, which this reads only for a bone's
215
+ * translate, scale, shear and rotate (0, 1, 0, 0) and a slot's attachment.
216
+ */
217
+ export function loopTrack(
218
+ track: RigcTrack,
219
+ D: number,
220
+ T: number,
221
+ easings: Readonly<Record<string, readonly number[]>>,
222
+ setup: () => unknown,
223
+ ): { track: RigcTrack; cycles: number; seams: string | null } | { reason: string } {
224
+ if (track.lag !== undefined || track.stagger !== undefined) return { reason: `it carries ${track.lag !== undefined ? '"lag"' : '"stagger"'}, which shifts its keys per track or per member; a loop is cut by key time, so it is not carried` };
225
+ const keys = track.keys;
226
+ if (keys.length === 0) return { reason: 'it has no keys' };
227
+ const nature = STEPPED_BY_NATURE.has(track.property);
228
+ if (!nature && !keys.every((k) => numeric(k.v))) return { reason: 'a key\'s value is not a list of numbers (a per-member map or a model), and a cut needs the value between two keys' };
229
+ if (!(D > 0)) return { reason: `the idle's duration is ${D}` };
230
+ const first = keys[0]!.t;
231
+ const last = keys[keys.length - 1]!.t;
232
+ let setupKey: unknown;
233
+ if (first > EPS) {
234
+ setupKey = setup();
235
+ if (setupKey === undefined) return { reason: `its first key is at t=${first}s, so a loop shows the setup value of ${track.property} before it in every cycle, and this package does not read that value for this target` };
236
+ }
237
+
238
+ const shift = (k: RigcKey, off: number): RigcKey => {
239
+ const out: RigcKey = { ...k, t: r6(off + k.t) };
240
+ if (Array.isArray(k.curve)) out.curve = k.curve.map((x, i) => (i % 2 === 0 ? r6(off + x) : x));
241
+ return out;
242
+ };
243
+ const hold = (k: RigcKey): void => {
244
+ if (nature) return;
245
+ delete k.curve;
246
+ k.ease = 'stepped';
247
+ };
248
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
249
+ const out: RigcKey[] = [];
250
+ const jumps: number[] = [];
251
+ let cycles = 0;
252
+ for (let k = 0; k * D <= T + EPS; k++) {
253
+ const off = k * D;
254
+ if (k > 0) {
255
+ const prev = out[out.length - 1]!;
256
+ const opens: unknown = first > EPS ? setupKey : keys[0]!.v;
257
+ if (prev.t >= r6(off) - EPS) {
258
+ // The key that ends the last cycle sits on the seam: the loop shows the next cycle's start there.
259
+ out.pop();
260
+ if (!same(prev.v, opens)) jumps.push(r6(off));
261
+ } else hold(prev);
262
+ if (first > EPS) {
263
+ const s: RigcKey = { t: r6(off), v: setupKey };
264
+ if (!nature) s.ease = 'stepped';
265
+ out.push(s);
266
+ }
267
+ }
268
+ for (const key of keys) out.push(shift(key, off));
269
+ cycles++;
270
+ if (out[out.length - 1]!.t > T + EPS) break;
271
+ }
272
+
273
+ // The cut at T.
274
+ const kept = out.filter((x) => x.t <= T + EPS);
275
+ if (kept.length === 0) return { reason: `its first key, at t=${first}s, is past the new animation's ${T}s` };
276
+ for (const x of kept) if (Math.abs(x.t - T) <= EPS) x.t = T;
277
+ const after = out[kept.length];
278
+ const end = kept[kept.length - 1]!;
279
+ if (after !== undefined && end.t < T - EPS && !nature) {
280
+ const c = cutAt(end, after, T, easings);
281
+ if ('reason' in c) return c;
282
+ kept.push({ t: T, v: c.v });
283
+ }
284
+ const tail = kept[kept.length - 1]!;
285
+ delete tail.ease;
286
+ delete tail.curve;
287
+ const seams = jumps.length === 0 ? null : `the idle's last key (t=${last}s, ${JSON.stringify(keys[keys.length - 1]!.v)}) is not what it shows at its start (${JSON.stringify(first > EPS ? setupKey : keys[0]!.v)}); the loop keys the start at each seam, so it jumps at t=${jumps.map((t) => t.toFixed(3)).join(', ')}s`;
288
+ return { track: { ...track, keys: kept }, cycles, seams };
289
+ }
290
+
291
+ /**
292
+ * The value at `T` inside the segment from `a` to `b`, and `a`'s segment cut
293
+ * there: stepped holds `a`; linear interpolates; a Bézier (a raw `curve`, or a
294
+ * named easing turned into one as rigc turns it, AUTHORING §4.1) is split at
295
+ * `T` by de Casteljau, so the part kept is the same curve.
296
+ */
297
+ function cutAt(a: RigcKey, b: RigcKey, T: number, easings: Readonly<Record<string, readonly number[]>>): { v: number[] } | { reason: string } {
298
+ const va = a.v as number[];
299
+ const vb = b.v as number[];
300
+ if (a.ease === 'stepped') return { v: [...va] };
301
+ let curve: number[] | null = null;
302
+ if (Array.isArray(a.curve)) curve = a.curve;
303
+ else if (typeof a.ease === 'string') {
304
+ const h = easings[a.ease];
305
+ if (h === undefined || h.length !== 4) return { reason: `its easing "${a.ease}" is not in the motion spec's easings` };
306
+ curve = va.flatMap((v0, c) => [a.t + (b.t - a.t) * h[0]!, v0 + (vb[c]! - v0) * h[1]!, a.t + (b.t - a.t) * h[2]!, v0 + (vb[c]! - v0) * h[3]!]);
307
+ }
308
+ if (curve === null) return { v: va.map((v0, c) => r6(v0 + ((T - a.t) / (b.t - a.t)) * (vb[c]! - v0))) };
309
+ const kept: number[] = [];
310
+ const v: number[] = [];
311
+ va.forEach((v0, c) => {
312
+ const [x1, y1, x2, y2] = curve.slice(c * 4, c * 4 + 4) as [number, number, number, number];
313
+ const bez = (p0: number, p1: number, p2: number, p3: number, s: number) => {
314
+ const u = 1 - s;
315
+ return u * u * u * p0 + 3 * u * u * s * p1 + 3 * u * s * s * p2 + s * s * s * p3;
316
+ };
317
+ let lo = 0;
318
+ let hi = 1;
319
+ for (let i = 0; i < 60; i++) {
320
+ const mid = (lo + hi) / 2;
321
+ if (bez(a.t, x1, x2, b.t, mid) < T) lo = mid;
322
+ else hi = mid;
323
+ }
324
+ const s = (lo + hi) / 2;
325
+ const lerp = (p: number, q: number) => p + (q - p) * s;
326
+ const split = (p0: number, p1: number, p2: number, p3: number): [number, number, number] => {
327
+ const p01 = lerp(p0, p1);
328
+ const p12 = lerp(p1, p2);
329
+ const p23 = lerp(p2, p3);
330
+ const p012 = lerp(p01, p12);
331
+ return [p01, p012, lerp(p012, lerp(p12, p23))];
332
+ };
333
+ const xs = split(a.t, x1, x2, b.t);
334
+ const ys = split(v0, y1, y2, vb[c]!);
335
+ kept.push(r6(xs[0]), r6(ys[0]), r6(xs[1]), r6(ys[1]));
336
+ v.push(r6(ys[2]));
337
+ });
338
+ delete a.ease;
339
+ a.curve = kept;
340
+ return { v };
341
+ }
342
+
343
+ /** The setup value a key states for a bone's translate, scale, shear and rotate (`numbers` channels), or undefined. */
344
+ export function boneIdentity(property: string, channels: number): number[] | undefined {
345
+ const v = BONE_IDENTITY[property];
346
+ return v === undefined ? undefined : Array.from({ length: channels }, () => v);
347
+ }
348
+
349
+ // --- the fragment ------------------------------------------------------------
350
+
351
+ export const FRAGMENT_SPEC = 'rig-pose-tracks/1';
352
+
353
+ /**
354
+ * A `rig-pose-tracks/1` fragment: `{ "spec": "rig-pose-tracks/1", "tracks": [ … ] }`,
355
+ * each a rigc-motion track at absolute times from the animation's start. The
356
+ * shape is read here (one target, a property, keys with a time); what a track
357
+ * means is rigc's gate's to judge.
358
+ */
359
+ export function readFragment(doc: unknown, where: string): RigcTrack[] {
360
+ const p = new Problems();
361
+ if (!isObject(doc) || doc.spec !== FRAGMENT_SPEC || !Array.isArray(doc.tracks)) {
362
+ p.add('MERGE_SPEC', `${where}: "spec" is ${isObject(doc) ? typeName(doc.spec) : typeName(doc)} and "tracks" ${isObject(doc) ? typeName(doc.tracks) : '-'}; a fragment is { "spec": "${FRAGMENT_SPEC}", "tracks": [ <rigc-motion track> … ] }`);
363
+ p.throwIfAny();
364
+ }
365
+ const tracks = (doc as { tracks: unknown[] }).tracks;
366
+ if (tracks.length === 0) p.add('MERGE_SPEC', `${where}: "tracks" is empty; a fragment adds at least one track`);
367
+ tracks.forEach((tr, i) => {
368
+ const at = `${where} tracks[${i}]`;
369
+ if (!isObject(tr)) {
370
+ p.add('MERGE_SPEC', `${at} is ${typeName(tr)}; a track is an object`);
371
+ return;
372
+ }
373
+ const fields = TARGET_FIELDS.filter((f) => tr[f] !== undefined);
374
+ if (fields.length !== 1 || typeof tr[fields[0]!] !== 'string') p.add('MERGE_SPEC', `${at} names ${fields.length === 0 ? 'no target' : fields.map((f) => `"${f}" (${typeName(tr[f])})`).join(' and ')}; a track names exactly one of ${TARGET_FIELDS.join(', ')}, as a string`);
375
+ if (typeof tr.property !== 'string') p.add('MERGE_SPEC', `${at} "property" is ${typeName(tr.property)}; it is the timeline's name, a string`);
376
+ if (!Array.isArray(tr.keys) || tr.keys.length === 0) {
377
+ p.add('MERGE_SPEC', `${at} "keys" is ${typeName(tr.keys)}; it is a non-empty array of { "t": <seconds>, "v": … }`);
378
+ return;
379
+ }
380
+ tr.keys.forEach((k, j) => {
381
+ if (!isObject(k) || typeof k.t !== 'number' || !Number.isFinite(k.t)) p.add('MERGE_SPEC', `${at} keys[${j}] ${isObject(k) ? `"t" is ${typeName(k.t)}` : `is ${typeName(k)}`}; a key states its time "t" in seconds`);
382
+ });
383
+ });
384
+ p.throwIfAny();
385
+ return tracks as RigcTrack[];
386
+ }
387
+
388
+ /**
389
+ * A fragment against the new animation: a track posing a channel the animation
390
+ * (or an earlier fragment track) already keys is MERGE_CONFLICT, and a key
391
+ * before 0 or past the duration is MERGE_TIME — each named.
392
+ */
393
+ export function mergeProblems(fragment: readonly RigcTrack[], where: string, animation: string, tracks: readonly RigcTrack[], duration: number, groups: Readonly<Record<string, readonly string[]>>): Problem[] {
394
+ const out: Problem[] = [];
395
+ const keyed = keyedBy(tracks, groups);
396
+ fragment.forEach((tr, i) => {
397
+ const label = trackLabel(tr, groups);
398
+ const hit = collisions(tr, keyed, groups);
399
+ if (hit.length > 0) out.push({ code: 'MERGE_CONFLICT', text: `${where} tracks[${i}] (${label}) — "${animation}" already keys it: ${hit.join(', ')}; a fragment adds a track where nothing keys that target and property, it never replaces one` });
400
+ for (const c of cellsOf(tr, groups)) if (!keyed.has(c)) keyed.set(c, `${where} tracks[${i}] (${label})`);
401
+ tr.keys.forEach((k, j) => {
402
+ if (k.t < 0 || k.t > duration) out.push({ code: 'MERGE_TIME', text: `${where} tracks[${i}] (${label}) keys[${j}] at t=${k.t}s is ${k.t < 0 ? 'before 0' : `past "${animation}"'s duration ${duration}s`}; a fragment's times are absolute, from the animation's start, within [0, ${duration}]` });
403
+ });
404
+ });
405
+ return out;
406
+ }
407
+
408
+ // --- lines ---------------------------------------------------------------
409
+
410
+ const targetText = (t: Target, members: readonly string[] | null) => `${t.kind} "${t.name}"${members !== null && members.length > 0 ? ` (${members.join(', ')})` : ''}`;
411
+
412
+ export function droppedLine(d: IdleDropped, animation: string, carry: boolean, duration: number): string {
413
+ const head = `${targetText(d.target, d.members)} ${d.property}: ${d.keys} key(s) over the idle's ${d.duration.toFixed(3)}s; "${animation}" does not key it`;
414
+ if (!carry) return `${head} — not carried (no --carry-idle)`;
415
+ if (d.carried !== null) return `${head} — carried: ${d.carried.keys} key(s), ${d.carried.cycles} cycle(s), cut at ${duration.toFixed(3)}s${d.carried.seams === null ? '' : `; ${d.carried.seams}`}`;
416
+ return `${head} — not carried: ${d.notCarried}`;
417
+ }
418
+
419
+ export function keptOutLine(k: IdleKeptOut): string {
420
+ return `${targetText(k.target, null)} ${k.property}: ${k.reason}, so the idle's track is not carried`;
421
+ }
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,14 +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
- import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
13
- import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
12
+ import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
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 MotionTrack, type RetargetOptions } from './retarget.ts';
14
15
  import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
15
16
  import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
16
17
  import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
17
18
  import { legsOf } from './legs.ts';
19
+ import { reduceKeys, type ReduceBone, type Reduced } from './reduce.ts';
18
20
  import type { LegScale } from './legs.ts';
19
21
 
20
22
  export interface RetargetArgs extends RetargetOptions {
@@ -35,6 +37,16 @@ export interface RetargetArgs extends RetargetOptions {
35
37
  render?: string;
36
38
  /** Write the run's measurements as JSON here. */
37
39
  report?: string;
40
+ /** The rig's animation read as its idle (default: idle); naming one the motion spec lacks is refused. */
41
+ idle?: string;
42
+ /** Loop the idle's dropped tracks into the new animation (#43 item 2). */
43
+ carryIdle?: boolean;
44
+ /** A rig-pose-tracks/1 fragment merged into the new animation. */
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;
38
50
  }
39
51
 
40
52
  export interface RunResult {
@@ -52,8 +64,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
52
64
  throw new Refusal(problems);
53
65
  };
54
66
  const args = new Problems();
55
- for (const [flag, path] of [['--map', a.map], ['--pose', a.pose]] as const) {
56
- if (!existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
67
+ for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge]] as const) {
68
+ if (path !== undefined && !existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
57
69
  }
58
70
  // A file this run writes goes into a directory that is there already; --build and --render make theirs.
59
71
  for (const [flag, path] of [['--out', a.out], ['--report', a.report]] as const) {
@@ -67,6 +79,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
67
79
  const base = baseMotionOf(input);
68
80
  const animations = base.animations as Record<string, unknown>;
69
81
  if (a.name in animations) refuse([{ code: 'MOTION_NAME_FREE', text: `the rig's motion spec already has an animation "${a.name}" (it has ${Object.keys(animations).join(', ')}); name this one with --name` }]);
82
+ const idleName = a.idle ?? 'idle';
83
+ if (!(idleName in animations) && (a.idle !== undefined || a.carryIdle === true)) {
84
+ refuse([{ code: 'IDLE_NAME', text: `${a.idle !== undefined ? `--idle "${idleName}"` : '--carry-idle reads the animation "idle", and'} the rig's motion spec ${input.motion === null ? '— there is none, so it has no animations' : `${input.motion} has no animation "${idleName}" (it has ${Object.keys(animations).join(', ') || 'none'})`}; name the rig's idle with --idle` }]);
85
+ }
86
+ const fragment = a.merge === undefined ? [] : readFragment(parseJson(readFileSync(a.merge, 'utf8'), a.merge, 'MERGE_JSON'), a.merge);
70
87
  const map = readMap(readFileSync(a.map, 'utf8'), a.map);
71
88
  const poseText = readFileSync(a.pose, 'utf8');
72
89
  const seq = a.coco ? convertCoco17(poseText, a.pose) : readPoseSequence(poseText, a.pose);
@@ -87,7 +104,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
87
104
  resolveMap(map, rest, a.map, slots);
88
105
 
89
106
  const r = retarget(rest, map, frames, times, a, slots);
90
- 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' : ''}`);
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)` : ''}` : ''}`);
91
108
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
92
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)})`);
93
110
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
@@ -117,7 +134,56 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
117
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'}`);
118
135
  }
119
136
 
120
- const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
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
+
145
+ // The idle against the new animation (#43 item 2): what it drops, and with --carry-idle, looped back in.
146
+ const groups = (isObject(base.groups) ? base.groups : {}) as Record<string, string[]>;
147
+ const easings = (isObject(base.easings) ? base.easings : {}) as Record<string, number[]>;
148
+ const own = r.animation.tracks as unknown as RigcTrack[];
149
+ const T = r.animation.duration;
150
+ let idle: IdleReading | null = null;
151
+ if (!(idleName in animations)) say(` IDLE ${input.motion === null ? 'the rig has no motion spec' : `the rig's motion spec has no animation "idle" (it has ${Object.keys(animations).join(', ') || 'none'})`}; no idle read (--idle names another)`);
152
+ else {
153
+ const setupOf = (t: Target, property: string, sample: unknown): unknown => {
154
+ if (t.kind === 'slot' && property === 'attachment') return slots.get(t.name)?.setup;
155
+ if ((t.kind === 'bone' || t.kind === 'group') && Array.isArray(sample)) return boneIdentity(property, sample.length);
156
+ return undefined;
157
+ };
158
+ const mapped = [...map.chains.flatMap((c) => c.bones), ...(map.translate === null ? [] : [map.translate.bone])];
159
+ const below = new Map<string, string[]>();
160
+ for (const m of new Set(mapped)) {
161
+ for (let b = rest.get(m)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) {
162
+ const list = below.get(b) ?? [];
163
+ if (!list.includes(m)) list.push(m);
164
+ below.set(b, list);
165
+ }
166
+ }
167
+ idle = readIdle(idleName, a.name, animations[idleName] as Record<string, unknown>, own, T, { groups, easings }, setupOf, a.carryIdle === true, (bone) => below.get(bone) ?? [], (bone) => {
168
+ const hip = map.translate?.bone;
169
+ if (hip === undefined) return null;
170
+ for (let b = rest.get(hip)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) if (b === bone) return hip;
171
+ return null;
172
+ });
173
+ say(` IDLE animation "${idleName}" (${idle.duration.toFixed(3)}s, ${idle.tracks} track(s)) against "${a.name}": ${idle.dropped.length} dropped (${a.carryIdle === true ? `${idle.carriedTracks.length} carried, looped to ${T.toFixed(3)}s` : 'not carried; --carry-idle loops them in'}), ${idle.keptOut.length} kept out`);
174
+ for (const d of idle.dropped) say(` IDLE-DROPPED ${droppedLine(d, a.name, a.carryIdle === true, T)}`);
175
+ for (const k of idle.keptOut) say(` IDLE-KEPT-OUT ${keptOutLine(k)}`);
176
+ for (const n of idle.notTracks) say(` IDLE-KEPT-OUT "${idleName}"'s ${n} timeline(s): not a track; neither compared nor carried`);
177
+ }
178
+ const carried = idle?.carriedTracks ?? [];
179
+ if (fragment.length > 0) {
180
+ const problems = mergeProblems(fragment, a.merge!, a.name, [...own, ...carried], T, groups);
181
+ if (problems.length > 0) refuse(problems);
182
+ for (const tr of fragment) say(` MERGE ${a.merge}: ${trackLabel(tr, groups)}, ${tr.keys.length} key(s) at t=${tr.keys[0]!.t}${tr.keys.length > 1 ? `–${tr.keys[tr.keys.length - 1]!.t}` : ''}s`);
183
+ }
184
+ const added = [...carried, ...fragment];
185
+ const animation = added.length === 0 ? r.animation : { ...r.animation, tracks: [...own, ...added] };
186
+ const motion = { ...base, animations: { ...animations, [a.name]: animation } };
121
187
  const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
122
188
  if (!g1.green) {
123
189
  refuse([{ code: 'RIG_GATE', text: `rigc build --profile ${a.profile} is red with "${a.name}" in (exit ${g1.run.status}); nothing written` }, ...g1.fails.map((f) => ({ code: 'RIGC', text: f }))]);
@@ -126,15 +192,26 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
126
192
 
127
193
  const doc = readBuild(g1.build);
128
194
  const posed = poseFrames(doc, a.name, r.times);
129
- 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));
130
197
  const p = new Problems();
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).
199
+ const carriedAbove = (bone: string): string => {
200
+ const above = new Set<string>();
201
+ for (let b = rest.get(bone)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) above.add(b);
202
+ const hits = carried.filter((tr) => {
203
+ const t = targetOf(tr);
204
+ return t !== null && (t.kind === 'bone' ? above.has(t.name) : t.kind === 'group' && (groups[t.name] ?? []).some((m) => above.has(m)));
205
+ });
206
+ return hits.length === 0 ? '' : `; --carry-idle carried ${hits.map((tr) => trackLabel(tr, groups)).join(', ')} onto bones above it`;
207
+ };
131
208
  for (const l of landings) {
132
209
  const unit = l.kind === 'angle' ? '°' : l.kind === 'scale' ? ' of its setup length' : ' units';
133
210
  const what = l.kind === 'angle' ? 'aim' : l.kind === 'scale' ? 'scale' : l.kind === 'foot' ? 'ankle' : 'origin';
134
211
  const tol = l.kind === 'foot' ? Number(l.tolerance.toPrecision(3)) : l.tolerance;
135
212
  const line = `bone "${l.bone}" ${what} — worst |reached − asked| ${l.worst.toPrecision(3)}${unit} at frame ${l.frame} (t=${r.times[l.frame]!.toFixed(3)}s); the tolerance is ${tol}${unit}`;
136
213
  if (landed(l)) say(` LAND ${line}`);
137
- else p.add('POSE_LANDS', `${line} — the rig does not turn the way the key formula assumes (a constraint, a scaled or sheared parent, a non-normal inherit mode)`);
214
+ else p.add('POSE_LANDS', `${line} — the rig does not turn the way the key formula assumes (a constraint, a scaled or sheared parent, a non-normal inherit mode)${carriedAbove(l.bone)}`);
138
215
  }
139
216
  p.throwIfAny();
140
217
 
@@ -186,7 +263,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
186
263
  animation: a.name,
187
264
  frames: seq.frames.length,
188
265
  duration: r.animation.duration,
189
- 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 },
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' },
190
267
  scale: r.scale,
191
268
  holds: r.holds,
192
269
  short: r.short,
@@ -206,6 +283,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
206
283
  pivot: r.pivot,
207
284
  feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, reading: feetReading },
208
285
  shear,
286
+ idleDropped: idle === null ? null : idle.dropped,
287
+ idleKeptOut: idle === null ? null : idle.keptOut,
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 },
209
290
  }, null, 2)}\n`);
210
291
  }
211
292
  say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
@@ -215,6 +296,47 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
215
296
  }
216
297
  }
217
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
+
218
340
  function spans(ts: number[], step: number): string {
219
341
  const out: string[] = [];
220
342
  let s = ts[0]!;