@rig-c/pose 0.8.1 → 0.10.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
@@ -31,7 +31,7 @@ Nothing is written until every check passes:
31
31
  | Check | Fails with |
32
32
  | --- | --- |
33
33
  | rig-c's gate is green with the rig as it is, and again with the animation in (`rigc build`) | `RIG_GATE_BASE`, `RIG_GATE`, then rigc's own `FAIL` lines |
34
- | every mapped bone **lands** on the angle the pose asks for, on every frame — the gated build is posed by rig-c's poser and measured (0.01° / 0.01 units) | `POSE_LANDS`: the bone, the worst error, the frame |
34
+ | every mapped bone **lands** on the angle the pose asks for, on every frame — the gated build is posed by rig-c's poser and measured (0.01° / 0.01 units); with `--leg-scale`, every scaled bone's x axis on its asked scale (0.0001 of its setup length), and with either leg flag each ankle where the keys put it (0.01 units + 0.0001 × its setup distance from the hip) | `POSE_LANDS`: the bone, the worst error, the frame |
35
35
  | elbows and knees bend only the way they can: at every joint inside a chain, the inner angle stays on the side of the rest pose's | `POSE_BEND_ONE_WAY`: the chain, the joint, the frame, the angle |
36
36
  | every point the map needs is good on at least one frame | `POSE_POINT_NEVER_GOOD` |
37
37
  | the map resolves against the rig: bones exist, chains descend, aims descend | `MAP_*`, naming both sides |
@@ -50,9 +50,14 @@ and what the run cannot decide for you it lists:
50
50
  - `UNCOVERED` — with `alternates` in the map: every fold on a frame where no switch took the folding mesh off screen. This is the list of poses still to paint.
51
51
  - `RANGE` — a chain's `range` ([below](#a-joints-range-range)): a bone whose rotate key the pose asks past its range, outside every span of a set depicting its chain, is clamped at the limit; one line per run, with the frames, the seconds and the largest overflow. The demand for an alternate.
52
52
  - `ART-RANGE` — inside a set's span: a bone with a `drawn` angle in the set, turned from it past its limits — the set's `turn` for that bone (held at the limit) or, without one, its chain's `range` (the bone follows the pose). Listed as the pose asked it either way: the demand for another drawing of that set.
53
+ - `LEG-SCALE` — with `--leg-scale` ([below](#the-legs-foreshortening-and-planted-feet)): per leg segment, its straight length, the spread of its picture length over it, and the frames clamped at each limit.
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
+ - `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
+ - `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)).
53
58
  - `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.
54
59
 
55
- `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance`, `switchMin` and `switchAt` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, and the demand lists:
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:
56
61
 
57
62
  - `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.
58
63
  - `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.
@@ -65,7 +70,9 @@ Runs in `turns`, `range` and `artRange` merge across a gap of one frame, as `end
65
70
 
66
71
  `--smooth <n>` smooths each joint's positions, after its holds, with a Savitzky–Golay filter: at every frame, the least-squares cubic over the *n* frames around it (clipped at the ends), read at that frame. It is centred, so it moves no motion in time, and a cubic keeps the peaks a moving average flattens. `1` (the default) is none; otherwise *n* is odd and at least 5, since a cubic through 3 points passes through all three.
67
72
 
68
- **Use `--smooth 7` at 24 fps.** It was measured on a 13 s dance on a @rig-c/parts rig, read back from the gated build: the motion's timing did not move (speed cross-correlation lag 0 on the wrists, ankles and hip), the frame-to-frame second difference dropped 2.5× on the wrists, 1.7–2.1× on the ankles and 1.5× on the hip, and the hip's 95th-percentile speed fell to 0.85×. The criterion set beforehand, a 2× drop on every point, was not met on the hip. That criterion was flawed: the hip's second difference is mostly real motion, while the filter cuts white noise 4.6×. A measure that isolates noise is a later card, and the default stays off until it passes.
73
+ **`--smooth 7` flattens the pelvis's rebound.** Measured on HQ's dancer with `--leg-scale 0.8,1.05 --plant-feet` ([#47](https://github.com/rig-c/pose/issues/47)): the rebound — the hip's peak upward acceleration at the bottom of each bounce, rig ÷ reference, median over a window, bar ≥ 0.7 — reads 0.40–0.52 with `--smooth 7` in every configuration measured (0.461 and 0.501 in the two windows, where `--smooth 1` reads 0.648 and 0.942). So the pelvis is read at `--smooth 1`, and `--smooth 7` is not advised for it. What follows is the earlier advice, for the limbs.
74
+
75
+ **`--smooth 7` at 24 fps, for the limbs.** It was measured on a 13 s dance on a @rig-c/parts rig, read back from the gated build: the motion's timing did not move (speed cross-correlation lag 0 on the wrists, ankles and hip), the frame-to-frame second difference dropped 2.5× on the wrists, 1.7–2.1× on the ankles and 1.5× on the hip, and the hip's 95th-percentile speed fell to 0.85×. The criterion set beforehand, a 2× drop on every point, was not met on the hip. That criterion was flawed: the hip's second difference is mostly real motion, while the filter cuts white noise 4.6×. A measure that isolates noise is a later card, and the default stays off until it passes.
69
76
 
70
77
  **The flip guard.** A pose model can put a fast sweep of a limb into one frame — under motion blur it takes the elbow or the wrist for another point — and the rig then whips the limb round between two keys. Position smoothing cannot remove a step like that. So after holds and smoothing, and before the hinge check, every segment the map aims (not a `reach`, which is aimed later from posed positions) is read frame to frame: a turn greater than `--flip <deg>` from frame k−1 to frame k is spread. For the smallest h ≥ 1 that keeps every one-frame turn at or under the threshold, frames k−h … k+h−1 are replaced by the straight line from frame k−h−1 to frame k+h, the turn's own way round. It is centred, so the step happens when the pose put it, over 2h frames, and both ends keep the pose's angles. A window that would cross the first or last frame stops there and the line says so; two windows that overlap become one. `--flip off` keeps every turn. The report lists each spread under `flips`.
71
78
 
@@ -84,6 +91,50 @@ Both have a meaningless direction; only the first is a pose the rig cannot draw.
84
91
 
85
92
  Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. On the second sequence the three spreads the flip guard made out of end-on segments are SHORT holds now, not FLIPs: right upper arm 399–400, right thigh 368–369, right shin 368–371. Of the flip guard's other spreads only one is left, the left forearm at 322–323; every other one fell on frames under the cutoff and is a SHORT hold instead. No guarded bone of the gated build turns more than 81.4° in one frame (85.1° with `--flip 90` alone), and every bone lands. `endOn` there lists the right upper arm (395–401) and the right shin (365–370). It does not list the right thigh, which is under 0.35 on one frame only (369, 0.16). The plan before the run expected the thigh too, and that expectation was wrong. On the dance `endOn` lists exactly S2's left upper arm (30–37); the three FLIPs are #15's, the largest one-frame turn is 78.2°, and every bone lands. A first implementation also held the lines across the body. It listed the eye line (head in profile) and the hip line (body side-on) as end-on, and holding the hip line narrowed the rig's hip tilt range to 39.3° against the pose's 125.2°. The cutoff was never measured on those lines, so they are excluded; with them out the rig reads 125.2° again. The default stays off: a default needs its own criterion and a third sequence.
86
93
 
94
+ ### The legs: foreshortening and planted feet
95
+
96
+ A knee bending toward the camera is seen from the front only as a shorter leg, and a rig with rigid legs cannot lower its hip without lifting its feet ([#47](https://github.com/rig-c/pose/issues/47)). Two flags, both off by default:
97
+
98
+ **`--leg-scale <min,max>`** scales each mapped leg segment (two of one side's hip, knee and ankle) along its own x axis — a `scalex` key — by its picture length on the frame over its **straight length**, the 95th percentile of its picture length over the sequence, clamped to [min, max]. The card's range is `0.8,1.05`. A segment the map aims other than `"x"` is refused (`LEGS_MAP`). `LEG-SCALE` lists each segment's frames clamped at either limit.
99
+
100
+ rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inherit mode takes its parent's whole world matrix. So under a thigh scaled by s, a shin at angle φ in the thigh's axes points at atan2(sin φ, s·cos φ), and is itself scaled by |(s·cos φ, sin φ)|. The rotate key `turn − turn above` assumes unit scale. A mapped bone under a scaled bone is therefore keyed through its parent's posed matrix: a `rotate` key k and a `scalex` key s' make its local matrix R(k) · L · diag(s', 1), L its setup local matrix, and k and s' are solved so its world x axis lands on its target at the scale asked. On HQ's dancer the unit-scale formula missed the shins by up to 2.4°, their length by 0.16 of setup and the ankles by 38 units; solved, every bone lands. The child's art is still sheared by its parent's scale, and a bone under it (a foot) is squashed along the shin: `SHEAR` reads both.
101
+
102
+ **`--plant-feet`** places the hip so the supporting foot stays where it took support, through the posed (and scaled) legs, instead of moving it by the pose's mid_hip travel:
103
+
104
+ - The supporting foot is the one whose reference ankle is lower in the picture. It changes only when the other ankle has been at least **0.02 leg lengths** lower for at least **0.1 s** (timed as `END-ON` runs are); a leg length is each side's median picture length hip → ankle, and the switch compares against their mean.
105
+ - The new support is held where it is on the frame it takes over, and the hip blends from the old derivation to the new over **3 frames**.
106
+ - A **jump** — both reference ankles **and** mid_hip more than **0.05** leg lengths above their **rolling floor**, the 95th-percentile picture y over ±1.0 s around the frame, for at least **2 frames** — releases the plant: the hip follows the pose's travel from where it was, and re-plants on landing (blended the same way). The body has to lift: an ankle's picture y drifts with depth, and under one floor for the whole sequence HQ's dancer standing still (frames 0–29 and 277–312, mid_hip moving 10 and 14 px) read as jumping, 152–155 of 313 frames in all.
107
+ - A **step** releases the plant too: while the supporting ankle moves more than **0.03** leg lengths a frame, the other foot takes support if it is planted (not itself released or moving), and otherwise the hip follows the pose's travel. The released ankle may support again once it has moved less than **0.01** leg lengths a frame for **2 frames**. On the dancer the plant held the supporting ankle through a real step (frames 176–181, 579 → 718 px) and threw the hip sideways.
108
+ - `FEET` lists the supports, the jumps and the steps (each with the side, its frames, who took support and where it re-planted); the report carries them under `feet` (`supports`, `jumps`, `steps`, `changes`).
109
+ - The map needs one chain ending at each ankle whose last bone is aimed `"x"` (the ankle is that bone's tip), and a `translate` the legs hang from (`LEGS_MAP`). A `range`, or a set's `turn` or `offsets`, on a bone between the hip and an ankle is refused (`LEGS_RANGE`): the plant is derived before the range clamp, and a clamp after it would move the planted foot.
110
+
111
+ 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.
112
+
113
+ ### The idle: what the retarget drops
114
+
115
+ 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`:
116
+
117
+ - `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`).
118
+ - `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.
119
+
120
+ **`--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.
121
+
122
+ - 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.
123
+ - 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).
124
+ - A track with `lag` or `stagger`, or whose values are per-member maps or models, is not carried, and the line says so.
125
+
126
+ 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.
127
+
128
+ 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).
129
+
130
+ **`--merge <file>`** merges a timed track fragment into the new animation, at absolute times from its start — blinks read off the video, for example:
131
+
132
+ ```json
133
+ { "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] } ] } ] }
134
+ ```
135
+
136
+ 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
+
87
138
  Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
88
139
 
89
140
  ## The pose: `rig-pose-sequence/1`
@@ -284,7 +335,7 @@ The answer is a second drawing of the arm for each of those poses, shown in plac
284
335
 
285
336
  - **Extract poses from video.** The input is joint positions; how you get them (a pose model, a dataset, a hand) is yours.
286
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.
287
- - **Plant feet** beyond what the pose gives, **hands and fingers** (no bones for them yet, rig-c/parts#171), mobile.
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.
288
339
 
289
340
  ## Licence
290
341
 
package/cli.ts CHANGED
@@ -51,6 +51,30 @@ retarget
51
51
  --bends refuse|clamp a hinge (elbow, knee: any joint inside a chain) bent the wrong way refuses
52
52
  the run (default), or is straightened on that frame and listed
53
53
  --bend-tolerance <d> degrees past straight a hinge may bend the wrong way before it counts (default: 0)
54
+ --leg-scale <min,max>|off
55
+ each leg segment (two of one side's hip, knee, ankle) is scaled along its own x axis
56
+ (a scalex key) by its picture length over its straight length — the 95th percentile of
57
+ its picture length — clamped to [min, max], listed as LEG-SCALE with the frames clamped;
58
+ a bone under a scaled one is keyed through its parent's scale (default: off; 0.8,1.05
59
+ is the card's range, rig-c/pose#47)
60
+ --plant-feet the hip is placed so the supporting foot stays where it took support: the lower ankle
61
+ in the picture, changing after the other has been 0.02 leg lengths lower for 0.1 s,
62
+ blended over 3 frames; a jump (both ankles and mid_hip 0.05 leg lengths above their
63
+ rolling floor — the 95th-percentile y over ±1 s — for ≥ 2 frames) follows the pose's
64
+ travel and re-plants on landing; a supporting ankle moving > 0.03 leg lengths a frame
65
+ releases its plant to the other foot, or to the pose's travel, until it is still
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)
54
78
  --switch-tolerance <d> a set of alternate drawings (the map's "alternates") may show on a frame where
55
79
  every segment of its chains is within d degrees of its key skeleton (default: 25)
56
80
  --switch-min <s> a run of one set shorter than s seconds is dropped (default: 0.25)
@@ -62,7 +86,8 @@ retarget
62
86
  --build <dir> keep the gated build (skeleton.json, .atlas, .model.json) here
63
87
  --render <dir> keep rigc's render of the animation (frames, contact.png, folds.json) here
64
88
  --report <file> write the run's measurements (holds, short, endOn, flips, bends, landings, folds,
65
- switches, uncovered, turns, range, artRange) as JSON
89
+ switches, uncovered, turns, range, artRange,
90
+ idleDropped, idleKeptOut, merged) as JSON
66
91
 
67
92
  It writes nothing until rigc's gate is green with the animation in, every mapped bone lands on
68
93
  the angle the pose asks for (posed by rig-c's own poser), and no hinge bends the wrong way.
@@ -155,8 +180,18 @@ function shortOf(f: Map<string, string>): number | 'off' {
155
180
  return num(f, 'short', 0.35, (v) => v > 0 && v < 1, 'off, or a fraction in (0, 1) of the segment\'s median picture length');
156
181
  }
157
182
 
183
+ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
184
+ const v = f.get('leg-scale');
185
+ if (v === undefined || v === 'off') return 'off';
186
+ const parts = v.split(',').map((x) => (x.trim() === '' ? NaN : Number(x)));
187
+ if (parts.length !== 2 || !parts.every((x) => Number.isFinite(x)) || !(parts[0]! > 0) || parts[0]! > 1 || parts[1]! < 1) {
188
+ throw new Usage(`--leg-scale is "${v}"; it must be off, or min,max with 0 < min ≤ 1 ≤ max (the scale of a leg segment against its straight length, e.g. 0.8,1.05)`);
189
+ }
190
+ return [parts[0]!, parts[1]!];
191
+ }
192
+
158
193
  function retargetArgs(argv: string[]): RetargetArgs {
159
- 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'], ['coco']);
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']);
160
195
  const bends = f.get('bends') ?? 'refuse';
161
196
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
162
197
  const switchAt = f.get('switch-at') ?? 'first';
@@ -179,6 +214,8 @@ function retargetArgs(argv: string[]): RetargetArgs {
179
214
  smooth: smoothOf(f),
180
215
  flip: flipOf(f),
181
216
  short: shortOf(f),
217
+ legScale: legScaleOf(f),
218
+ plantFeet: f.has('plant-feet'),
182
219
  bends,
183
220
  bendTolerance: num(f, 'bend-tolerance', 0, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
184
221
  switchTolerance: num(f, 'switch-tolerance', 25, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
@@ -188,6 +225,9 @@ function retargetArgs(argv: string[]): RetargetArgs {
188
225
  build: f.get('build'),
189
226
  render: f.get('render'),
190
227
  report: f.get('report'),
228
+ idle: f.get('idle'),
229
+ carryIdle: f.has('carry-idle'),
230
+ merge: f.get('merge'),
191
231
  };
192
232
  }
193
233
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.8.1",
3
+ "version": "0.10.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
+ }