@rig-c/pose 0.5.0 → 0.7.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
@@ -45,15 +45,15 @@ and what the run cannot decide for you it lists:
45
45
  - `BEND` — with `--bends clamp`, a hinge bent the wrong way is straightened on that frame instead of refusing.
46
46
  - `FOLD` — `rigc render --folds` over the new animation: every mesh that folds, and when. A reading, not a gate.
47
47
  - `POSTURE` — when the map has a pelvis chain (joints `r_hip`, `l_hip`) and a `translate`: the hip line's tilt range, and the sway — the x-range of mid_hip − mid_shoulder — in rig units and in shoulder widths, the pose's beside the posed rig's. A reading, not a gate.
48
- - `SWITCH` — with `alternates` in the map ([below](#alternate-drawings-alternates)): each span on which a set of alternate drawings shows, its slots, its frames, how far the pose was from the set's key skeleton, and how far each bone with a `drawn` angle was turned from it.
48
+ - `SWITCH` — with `alternates` in the map ([below](#alternate-drawings-alternates)): each span on which a set of alternate drawings shows, its slots, its frames, how far the pose was from the set's key skeleton, how far each bone with a `drawn` angle was turned from it, and where each of its boundaries was moved to (`moved`, [below](#alternate-drawings-alternates)).
49
49
  - `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.
50
50
  - `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.
51
51
  - `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.
52
52
  - `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.
53
53
 
54
- `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance` and `switchMin` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, and the demand lists:
54
+ `--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:
55
55
 
56
- - `switches: [{ set, chains, slots, hides, offsets, from, to, frames: [first, last], distance: { max, median }, artTurn: { <bone>: { max, median } } }]` — 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.
56
+ - `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.
57
57
  - `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.
58
58
  - `endOn` — above, under the short hold.
59
59
  - `turns: [{ line, joints, frames, seconds, minFraction, reversed }]` — `line` is `hip`, `shoulder` or `eye`.
@@ -167,11 +167,19 @@ Bone rotation cannot carry every pose: an arm behind the head, a forearm toward
167
167
 
168
168
  **When one shows.** Per frame and per set, the distance is the largest difference, in degrees, between a segment of its chains as the retarget asked for it (after holds, smoothing, the short hold, the flip guard and the hinge check; before any range clamp) and the same segment in its key skeleton. On each frame the nearest set within `--switch-tolerance` (default 25°) shows, ties going to the map's order; a run of one set shorter than `--switch-min` (default 0.25 s) is dropped and the base art shows there. Both figures were fixed before a run ([#6](https://github.com/rig-c/pose/issues/6)). Sets compete only with those they share a slot (shown or hidden) or an offset bone with, so a drawing of each arm can show at once; two that compete never overlap.
169
169
 
170
+ **Where a switch is keyed: `--switch-at`.** By default (`first`) a boundary — the frame a set starts showing on, and the frame the base art or the next set comes back on — is where the selection above puts it: the first frame within the tolerance, often a frame where the arm is slowing into the pose, so the change of drawing can be seen on a nearly still arm. `--switch-at peak` moves each boundary to the frame within ±`--switch-min`/2 (±3 frames at 24 fps with the default 0.25 s) where the chains it changes turn fastest, so the cut lands in motion ([#31](https://github.com/rig-c/pose/issues/31)). The speed on a frame is the largest |angular speed| in degrees per frame, a central difference of the target world angles (one-sided on the first and last frame), over every segment of the leaving set's and the arriving set's chains; it is read on the same targets as the distance, before the range clamp and any set's `turn`, which depend on where the spans are. The window is clipped so every span keeps `--switch-min`, a gap between two spans of one set keeps it too (the bridging holds), no boundary reaches its neighbour, and a set with `frames` shows only inside them. In it, a frame qualifies only if the set the move newly shows is within **2 × `--switch-tolerance`** of its key skeleton on every frame it newly shows it on; the boundary goes to the fastest qualifying frame, and stays when none qualifies. A tie goes to the frame nearest the original, then the earlier. A boundary between two sets moves once.
171
+
172
+ The bound exists because speed alone rewarded showing art far from its pose. Unbounded, on HQ's dancer, the peak sat mid-swing: the largest distance over the frames a set showed rose from 25° to 135° (K2), 24° to 64° (K4), 21° to 59° (K1) and 21° to 38° (K3), the sets' `turn` held the arms hard against the pose (`ART-RANGE` runs 24 → 36), and the rig snapped to a hands-low drawing two frames before the arms came down. Bounded, every set stays within 50° (the largest is 43°, K4) and the median speed at the boundaries still rises from 9.7 to 14.2°/frame, but `ART-RANGE` runs still rise, 24 → 31 (K4 1 → 3, K1 6 → 8, K2 6 → 7, K3 2 → 4). That is why `first` is the default and `peak` an option ([#31](https://github.com/rig-c/pose/issues/31), round 2).
173
+
174
+ Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's old and new frame (the same under `first`), the frames and seconds it moved, the speed on both and its window, and the report carries the same under `moved`.
175
+
170
176
  **What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. A switch onto frame *i* is keyed halfway between frames *i*−1 and *i*: a stepped key on the frame's own time fires a frame late wherever a player's accumulated time lands just below it (rig-c's docs/AUTHORING.md §4.5), and halfway both the pose's times and a renderer's `i / fps` see it on frame *i*. Before the first key a slot shows its setup attachment, so no key is written at 0 unless a span starts there. Offsets are `translate` keys at the same times, `stepped`. rig-c's gate checks these keys like any other.
171
177
 
172
178
  **TURN.** The cutoff was measured on the three lines across the body before it was fixed ([#6](https://github.com/rig-c/pose/issues/6)): on the 13 s front-facing dance their picture length never drops under 0.81 of its median and none reverses; on a second sequence the hip line falls to 0.17 at frames 465–491 (side-on) and the eye line to 0.22 at 26–58 (head in profile), and 3 frames reverse. 0.5 lies between. A line is reversed when it points more than 90° from the subject's right joint to the left (front-facing, it points at 0°). The lines are read after holds and smoothing, held and smoothed as the map's points are even where the map does not use them; a line whose points are good on no frame is named as not measured.
173
179
 
174
180
 
181
+ **A stopgap window: `frames`.** A set may state `"frames": [[first, last], …]`: it shows only inside those windows, and on them it is chosen over any set within tolerance that has no window. It is for two sets whose key skeletons the angles cannot tell apart. Measured on HQ's dancer, the hands clasped behind the back and the hands low in front differ by only 2–21°, and the wrist and hand-point scores do not separate them either; the cue that might is #33's depth state. It is recorded in the map as a stopgap, not a selection rule.
182
+
175
183
  **How far a set's art may turn: `turn`.** A drawing reads right only near the angle it was painted at. A set may state, per bone with a `drawn` angle, how far its art may turn from it, `"turn": { "sleeve_r0": [-20, 20] }`. While the set shows, the bone is held within it: its target becomes drawn + clamp(wrap(target − drawn), min, max), each bone on its own world angle, so a forearm under a held upper arm keeps its own target. The landing check measures against the held angle. The overflow the pose asked for is listed as `ART-RANGE`. A `turn` on a bone the set has no `drawn` angle for is refused (`MAP_ALT_TURN`).
176
184
  ## Requirements
177
185
 
package/cli.ts CHANGED
@@ -49,6 +49,10 @@ retarget
49
49
  --switch-tolerance <d> a set of alternate drawings (the map's "alternates") may show on a frame where
50
50
  every segment of its chains is within d degrees of its key skeleton (default: 25)
51
51
  --switch-min <s> a run of one set shorter than s seconds is dropped (default: 0.25)
52
+ --switch-at first|peak where a switch is keyed: first (default) keeps the frame the selection gave it;
53
+ peak moves each boundary to the frame within ±switch-min/2 where the chains it
54
+ changes turn fastest, among those that newly show a set only on frames within
55
+ 2 × the switch tolerance of its key skeleton (it stays when none qualifies)
52
56
  --profile spine|spine-html rigc's gate profile (default: spine, rigc's own default)
53
57
  --build <dir> keep the gated build (skeleton.json, .atlas, .model.json) here
54
58
  --render <dir> keep rigc's render of the animation (frames, contact.png, folds.json) here
@@ -124,9 +128,11 @@ function shortOf(f: Map<string, string>): number | 'off' {
124
128
  }
125
129
 
126
130
  function retargetArgs(argv: string[]): RetargetArgs {
127
- const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'profile', 'build', 'render', 'report'], ['coco']);
131
+ const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report'], ['coco']);
128
132
  const bends = f.get('bends') ?? 'refuse';
129
133
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
134
+ const switchAt = f.get('switch-at') ?? 'first';
135
+ if (switchAt !== 'peak' && switchAt !== 'first') throw new Usage(`--switch-at is "${switchAt}"; it must be peak or first`);
130
136
  const profile = f.get('profile') ?? 'spine';
131
137
  if (profile !== 'spine' && profile !== 'spine-html') throw new Usage(`--profile is "${profile}"; it must be spine or spine-html`);
132
138
  const name = f.get('name') ?? 'dance';
@@ -148,6 +154,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
148
154
  bendTolerance: num(f, 'bend-tolerance', 0, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
149
155
  switchTolerance: num(f, 'switch-tolerance', 25, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
150
156
  switchMin: num(f, 'switch-min', 0.25, (n) => n >= 0, 'seconds ≥ 0'),
157
+ switchAt,
151
158
  profile,
152
159
  build: f.get('build'),
153
160
  render: f.get('render'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.5.0",
3
+ "version": "0.7.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/map.ts CHANGED
@@ -93,6 +93,8 @@ export interface AlternateSet {
93
93
  offsets: Record<string, [number, number]>;
94
94
  /** Bone → how far its art may turn from `drawn` while the set shows, [min, max] degrees: the bone is clamped there, and the overflow is ART-RANGE demand. */
95
95
  turn: Record<string, [number, number]>;
96
+ /** A stopgap: the only frames the set may show on, a list of [first, last] windows; on them it is chosen over any set without windows. Null: any frame. */
97
+ frames: Array<[number, number]> | null;
96
98
  }
97
99
  export interface JointMap {
98
100
  chains: Chain[];
@@ -226,7 +228,7 @@ export function readMap(text: string, where: string): JointMap {
226
228
  return { chains, translate, alternates };
227
229
  }
228
230
 
229
- const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] } }';
231
+ const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …] }';
230
232
 
231
233
  /** `alternates`: each set's shape, its chains against the map's, its key skeleton against those chains' joints, and its bones against those chains' bones. */
232
234
  function readAlternates(p: Problems, where: string, raw: unknown, chains: readonly Chain[], translate: Translate | null): AlternateSet[] {
@@ -243,7 +245,7 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
243
245
  p.add('MAP_ALT_SHAPE', `${aw} is ${typeName(a)}; a set is ${SET_SHAPE}`);
244
246
  return;
245
247
  }
246
- refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn']);
248
+ refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames']);
247
249
  const name = typeof a.name === 'string' && a.name !== '' ? a.name : null;
248
250
  if (name === null) p.add('MAP_ALT_NAME', `${aw} "name" is ${typeName(a.name)}; it must be a non-empty string`);
249
251
  else if (names.has(name)) p.add('MAP_ALT_NAME', `${aw} "name" "${name}" names two sets`);
@@ -344,7 +346,15 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
344
346
  }
345
347
  }
346
348
  }
347
- if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn });
349
+ let frames: Array<[number, number]> | null = null;
350
+ if ('frames' in a) {
351
+ const f = a.frames;
352
+ const pair = (w: unknown): w is [number, number] => Array.isArray(w) && w.length === 2 && w.every((x) => Number.isInteger(x) && (x as number) >= 0) && (w[0] as number) <= (w[1] as number);
353
+ if (!Array.isArray(f) || f.length === 0 || !f.every(pair)) {
354
+ p.add('MAP_ALT_FRAMES', `${label} "frames" is ${JSON.stringify(f)}; it must be a non-empty list of windows [[first, last], …], whole frame numbers ≥ 0 with first ≤ last`);
355
+ } else frames = (f as Array<[number, number]>).map((w) => [w[0], w[1]]);
356
+ }
357
+ if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames });
348
358
  });
349
359
  return out;
350
360
  }
package/src/retarget.ts CHANGED
@@ -41,7 +41,8 @@
41
41
  *
42
42
  * Then the switches (rig-c/pose#6, `switch.ts`): with every target known —
43
43
  * reaches included — each alternate set's distance to the pose is read, and the
44
- * spans each set shows are chosen. Only then the range clamp: outside the spans
44
+ * spans each set shows are chosen, each boundary keyed at its window's speed
45
+ * peak (#31). Only then the range clamp: outside the spans
45
46
  * of a set depicting its chain, a bone whose rotate key would leave its
46
47
  * `range` is clamped at the limit (its target becomes the clamped world angle),
47
48
  * in depth order, so a bone below takes the clamped parent into its own key
package/src/run.ts CHANGED
@@ -154,7 +154,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
154
154
  animation: a.name,
155
155
  frames: seq.frames.length,
156
156
  duration: r.animation.duration,
157
- 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, profile: a.profile },
157
+ 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 },
158
158
  scale: r.scale,
159
159
  holds: r.holds,
160
160
  short: r.short,
package/src/switch.ts CHANGED
@@ -31,6 +31,26 @@
31
31
  * time lands below it (rig-c docs/AUTHORING.md §4.5), and the pose's times and
32
32
  * a renderer's `i / fps` are two grids that disagree by more than that.
33
33
  * Halfway, both see it on frame i.
34
+ *
35
+ * Where a switch is keyed (rig-c/pose#31): a boundary — a frame where a set
36
+ * starts showing, or where the base art or the next set comes back — is moved,
37
+ * after the selection above, to the fastest frame within ±`switchMin`/2 of it,
38
+ * so the change of drawing lands in motion, not on an arm slowing into the
39
+ * pose. The speed on a frame is the largest |angular speed|, degrees per frame
40
+ * (central difference of the target world angles; one-sided on the first and
41
+ * last frame), over the segments of the chains the boundary changes, the
42
+ * leaving set's and the arriving set's; read on the same targets as the
43
+ * distance, before the range clamp and any set's `turn`, which depend on where
44
+ * the spans are. The window is clipped so every span keeps `switchMin` (a gap
45
+ * between two spans of one set too, so the bridging holds), no boundary
46
+ * reaches its neighbour, and a set with `frames` shows only inside them; and
47
+ * a frame qualifies only if the set the move newly shows is within twice the
48
+ * tolerance of its key skeleton on every frame it newly shows it on (speed
49
+ * alone showed art up to 135° from its pose on the dancer). Ties
50
+ * go to the frame nearest the original, then the earlier. A boundary between
51
+ * two sets moves once. `switchAt: 'first'` keeps every boundary where the
52
+ * selection put it; it is the CLI's default, because on HQ's dancer the bounded
53
+ * peak still raised the sets' ART-RANGE runs from 24 to 31 (#31, round 2).
34
54
  */
35
55
  import type { AlternateSet, JointMap, Rest, RigSlots } from './map.ts';
36
56
  import { descends } from './map.ts';
@@ -43,6 +63,21 @@ export interface SwitchOptions {
43
63
  switchTolerance: number;
44
64
  /** Seconds: a run of one set shorter than this is dropped. */
45
65
  switchMin: number;
66
+ /** Where a boundary is keyed: 'first' (the CLI's default) keeps the frame the selection gave it; 'peak' moves it to the fastest qualifying frame of its window (#31). */
67
+ switchAt: 'peak' | 'first';
68
+ }
69
+
70
+ /** One boundary of a span: where the selection put it, where it is keyed, and how fast the chains it changes move on each. */
71
+ export interface Move {
72
+ /** The frame the boundary showed on as selected, then as keyed. */
73
+ frame: [number, number];
74
+ /** Signed: frames, and the seconds between the two frames' times. */
75
+ frames: number;
76
+ seconds: number;
77
+ /** The frames it could move to, after clipping: first and last. */
78
+ window: [number, number];
79
+ /** Degrees per frame on the old and the new frame: the largest |angular speed| over the segments of the chains it changes. */
80
+ speed: { old: number; new: number };
46
81
  }
47
82
 
48
83
  export interface Switch {
@@ -63,6 +98,8 @@ export interface Switch {
63
98
  distance: { max: number; median: number };
64
99
  /** Per bone the set has a `drawn` angle for: |wrap(target − drawn)| over the frames shown, degrees. */
65
100
  artTurn: Record<string, { max: number; median: number }>;
101
+ /** The boundary it starts on (null from the first frame) and the one it ends on (null to the end); a boundary between two sets is in both. */
102
+ moved: { on: Move | null; off: Move | null };
66
103
  }
67
104
 
68
105
  export interface AttachmentKey {
@@ -169,6 +206,89 @@ function competing(sets: readonly AlternateSet[]): AlternateSet[][] {
169
206
  return [...groups.values()];
170
207
  }
171
208
 
209
+ /**
210
+ * Per frame, the largest |angular speed| over the given bones' target world
211
+ * angles, degrees per frame: a central difference, one-sided on the first and
212
+ * the last frame.
213
+ */
214
+ export function speeds(bones: readonly string[], targets: ReadonlyMap<string, readonly number[]>, n: number): number[] {
215
+ const out: number[] = [];
216
+ for (let k = 0; k < n; k++) {
217
+ const lo = Math.max(0, k - 1);
218
+ const hi = Math.min(n - 1, k + 1);
219
+ let v = 0;
220
+ if (hi > lo) for (const b of bones) v = Math.max(v, Math.abs(wrap(targets.get(b)![hi]! - targets.get(b)![lo]!)) / (hi - lo));
221
+ out.push(v);
222
+ }
223
+ return out;
224
+ }
225
+
226
+ /** A moved boundary may newly show a set only on frames within this many times the tolerance of its key skeleton (#31). */
227
+ export const MOVE_BOUND = 2;
228
+
229
+ /**
230
+ * Move each boundary of one group's segments to the fastest frame of its
231
+ * window (`switchAt: 'peak'`), in time order, each clipped against its
232
+ * neighbours as they stand; `vals[s]` is what segment s shows (null: the base
233
+ * art) and `pos[m]` the frame boundary m shows on, between segments m and m+1.
234
+ * A candidate frame qualifies only if every frame the move newly shows a set
235
+ * on is within `MOVE_BOUND` × the tolerance of it; none qualifying, the
236
+ * boundary stays. Returns one `Move` per boundary.
237
+ */
238
+ function moveBoundaries(vals: ReadonlyArray<AlternateSet | null>, pos: readonly number[], map: JointMap, targets: ReadonlyMap<string, readonly number[]>, distances: ReadonlyMap<string, readonly number[]>, times: readonly number[], duration: number, opts: SwitchOptions): Move[] {
239
+ const n = times.length;
240
+ const at = [...pos];
241
+ const end = (b: number) => (b < n ? times[b]! : duration);
242
+ const len = (a: number, b: number) => end(b) - times[a]!;
243
+ /** What a segment must keep: the minimum span for a set's, and for a gap between two spans of one set (the bridging); never less than it had. */
244
+ const need = (s: number, had: number): number => {
245
+ const v = vals[s];
246
+ const kept = v !== null || (s > 0 && s + 1 < vals.length && vals[s - 1] !== null && vals[s - 1] === vals[s + 1]);
247
+ return kept ? Math.min(opts.switchMin, had) : 0;
248
+ };
249
+ const allowed = (a: AlternateSet | null, k: number) => a === null || a.frames === null || a.frames.some(([f, l]) => k >= f && k <= l);
250
+ const bonesOf = (a: AlternateSet | null): string[] => (a === null ? [] : a.chains.flatMap((cn) => map.chains.find((c) => c.name === cn)!.bones));
251
+ const moves: Move[] = [];
252
+ for (let m = 0; m < at.length; m++) {
253
+ const b = at[m]!;
254
+ const prev = m === 0 ? 0 : at[m - 1]!;
255
+ const next = m + 1 < at.length ? at[m + 1]! : n;
256
+ const leaving = vals[m] ?? null;
257
+ const arriving = vals[m + 1] ?? null;
258
+ const needPrev = need(m, len(prev, b));
259
+ const needNext = need(m + 1, len(b, next));
260
+ // The pose's times are read to the microsecond, so a frame exactly half a minimum span away may read a hair past it.
261
+ const half = opts.switchMin / 2 + 1e-6;
262
+ // Earlier: frames b' … b−1 show the arriving segment.
263
+ let lo = b;
264
+ while (lo - 1 > prev && times[b]! - times[lo - 1]! <= half && len(prev, lo - 1) >= needPrev - 1e-9 && allowed(arriving, lo - 1)) lo--;
265
+ // Later: frames b … b'−1 show the leaving one.
266
+ let hi = b;
267
+ while (hi + 1 < next && times[hi + 1]! - times[b]! <= half && len(hi + 1, next) >= needNext - 1e-9 && allowed(leaving, hi)) hi++;
268
+ const sp = speeds([...new Set([...bonesOf(leaving), ...bonesOf(arriving)])], targets, n);
269
+ // A frame qualifies only if the set it newly shows is within twice the tolerance of its key skeleton on every
270
+ // frame it newly shows it on (round 1 on the dancer: speed alone showed a set up to 135° from its pose).
271
+ const near = (a: AlternateSet | null, from: number, to: number): boolean => {
272
+ if (a === null) return true;
273
+ const d = distances.get(a.name)!;
274
+ for (let k = from; k < to; k++) if (d[k]! > MOVE_BOUND * opts.switchTolerance) return false;
275
+ return true;
276
+ };
277
+ const qualifies = (k: number) => (k < b ? near(arriving, k, b) : near(leaving, b, k));
278
+ let best = b;
279
+ if (opts.switchAt === 'peak') {
280
+ for (let k = lo; k <= hi; k++) {
281
+ if (!qualifies(k)) continue;
282
+ const d = sp[k]! - sp[best]!;
283
+ if (d > 1e-9 || (Math.abs(d) <= 1e-9 && (Math.abs(k - b) < Math.abs(best - b) || (Math.abs(k - b) === Math.abs(best - b) && k < best)))) best = k;
284
+ }
285
+ }
286
+ at[m] = best;
287
+ moves.push({ frame: [b, best], frames: best - b, seconds: r6(times[best]! - times[b]!), window: [lo, hi], speed: { old: r3(sp[b]!), new: r3(sp[best]!) } });
288
+ }
289
+ return moves;
290
+ }
291
+
172
292
  /** The key time for a change that shows on frame i: half a frame before it, 0 on the first frame. */
173
293
  const changeAt = (times: readonly number[], i: number): number => (i === 0 ? 0 : r6((times[i - 1]! + times[i]!) / 2));
174
294
 
@@ -186,13 +306,20 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
186
306
  for (const group of competing(map.alternates)) {
187
307
  const pick: Array<AlternateSet | null> = [];
188
308
  for (let i = 0; i < n; i++) {
309
+ // A set with a `frames` window shows only inside it, and there it is chosen
310
+ // over any set without one (a stopgap for what the angles cannot tell apart).
189
311
  let best: AlternateSet | null = null;
190
312
  let bestD = Infinity;
313
+ let bestWindowed = false;
191
314
  for (const a of group) {
315
+ if (a.frames !== null && !a.frames.some(([f, l]) => i >= f && i <= l)) continue;
192
316
  const d = distances.get(a.name)![i]!;
193
- if (d <= opts.switchTolerance && d < bestD) {
317
+ if (d > opts.switchTolerance) continue;
318
+ const windowed = a.frames !== null;
319
+ if ((windowed && !bestWindowed) || (windowed === bestWindowed && d < bestD)) {
194
320
  best = a;
195
321
  bestD = d;
322
+ bestWindowed = windowed;
196
323
  }
197
324
  }
198
325
  pick.push(best);
@@ -213,20 +340,34 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
213
340
  }
214
341
  i = j + 1;
215
342
  }
343
+ // A run shorter than the minimum span shows the base art.
216
344
  for (let i = 0; i < n;) {
217
345
  const a = pick[i];
218
346
  let j = i;
219
347
  while (j + 1 < n && pick[j + 1] === a) j++;
220
- if (a !== null && a !== undefined) {
221
- const from = times[i]!;
222
- const to = j + 1 < n ? times[j + 1]! : duration;
223
- if (to - from >= opts.switchMin - 1e-9) {
224
- const ds = distances.get(a.name)!.slice(i, j + 1);
225
- switches.push({ set: a.name, chains: a.chains, slots: a.slots, hides: a.hides, offsets: a.offsets, from, to, frames: [i, j], distance: { max: r3(Math.max(...ds)), median: r3(median(ds)) }, artTurn: {} });
226
- }
227
- }
348
+ const to = j + 1 < n ? times[j + 1]! : duration;
349
+ if (a !== null && a !== undefined && to - times[i]! < opts.switchMin - 1e-9) for (let k = i; k <= j; k++) pick[k] = null;
228
350
  i = j + 1;
229
351
  }
352
+ // The segments between boundaries, then each boundary moved (#31).
353
+ const vals: Array<AlternateSet | null> = [pick[0] ?? null];
354
+ const pos: number[] = [];
355
+ for (let i = 1; i < n; i++) {
356
+ if (pick[i] === pick[i - 1]) continue;
357
+ pos.push(i);
358
+ vals.push(pick[i] ?? null);
359
+ }
360
+ const moves = moveBoundaries(vals, pos, map, targets, distances, times, duration, opts);
361
+ for (let s = 0; s < vals.length; s++) {
362
+ const a = vals[s];
363
+ if (a === null || a === undefined) continue;
364
+ const i = s === 0 ? 0 : moves[s - 1]!.frame[1];
365
+ const j = s < pos.length ? moves[s]!.frame[1] - 1 : n - 1;
366
+ const from = times[i]!;
367
+ const to = j + 1 < n ? times[j + 1]! : duration;
368
+ const ds = distances.get(a.name)!.slice(i, j + 1);
369
+ switches.push({ set: a.name, chains: a.chains, slots: a.slots, hides: a.hides, offsets: a.offsets, from, to, frames: [i, j], distance: { max: r3(Math.max(...ds)), median: r3(median(ds)) }, artTurn: {}, moved: { on: s === 0 ? null : moves[s - 1]!, off: s < pos.length ? moves[s]! : null } });
370
+ }
230
371
  }
231
372
  const order = (name: string) => map.alternates.findIndex((a) => a.name === name);
232
373
  switches.sort((x, y) => x.frames[0] - y.frames[0] || order(x.set) - order(y.set));
@@ -426,9 +567,18 @@ export function switchLine(s: Switch, times: readonly number[]): string {
426
567
  if (offs.length > 0) parts.push(`offsets ${offs.map(([b, o]) => `"${b}" (${o[0]}, ${o[1]})`).join(', ')}`);
427
568
  const art = Object.entries(s.artTurn);
428
569
  if (art.length > 0) parts.push(`art turn ${art.map(([b, a]) => `"${b}" max ${deg(a.max)}, median ${deg(a.median)}`).join('; ')}`);
570
+ const moves = (['on', 'off'] as const).filter((k) => s.moved[k] !== null).map((k) => `${k} ${moveText(s.moved[k]!)}`);
571
+ if (moves.length > 0) parts.push(`moved ${moves.join(', ')}`);
429
572
  return parts.join('; ');
430
573
  }
431
574
 
575
+ const spd = (v: number) => `${v.toFixed(1)}°/frame`;
576
+ const win = (w: [number, number]) => (w[0] === w[1] ? `window frame ${w[0]}` : `window frames ${w[0]}–${w[1]}`);
577
+ function moveText(m: Move): string {
578
+ if (m.frames === 0) return `frame ${m.frame[0]} stays (${spd(m.speed.old)}; ${win(m.window)})`;
579
+ return `frame ${m.frame[0]} → ${m.frame[1]} (${m.frames > 0 ? '+' : '−'}${Math.abs(m.frames)} frame${Math.abs(m.frames) === 1 ? '' : 's'}, ${m.seconds > 0 ? '+' : '−'}${Math.abs(m.seconds).toFixed(3)}s; ${spd(m.speed.old)} → ${spd(m.speed.new)}; ${win(m.window)})`;
580
+ }
581
+
432
582
  export function artRangeLine(e: ArtRange, times: readonly number[]): string {
433
583
  return `set "${e.set}" chain "${e.chain}" bone "${e.bone}" turned ${sg(e.turn)} from drawn (range [${e.limits[0]}, ${e.limits[1]}]) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s, up to ${deg(e.maxOverflow)} past it; ${e.clamped ? 'held at the limit' : 'the bone follows the pose'} — demand for another drawing of the set`;
434
584
  }