@rig-c/pose 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,16 @@ Part of the rig-c toolchain: [core](https://github.com/rig-c/core) compiles and
8
8
 
9
9
  <!-- demo -->
10
10
 
11
+ ## Try it
12
+
13
+ [`examples/wave`](examples/wave) runs with nothing else: a bones-only stick rig (no art), a 3-second wave made from joint angles (`make-pose.ts` writes it), and the map that joins them.
14
+
15
+ ```sh
16
+ rig-pose retarget --rig examples/wave/rig --map examples/wave/map.json --pose examples/wave/pose.json --out wave.motion.json --build wave-build
17
+ ```
18
+
19
+ It prints the gate's verdict and where every mapped bone landed, then writes `wave.motion.json` and the gated build. `rigc preview --candidate wave-build` plays it in the Spine Web Player. A rig with no art shows only its bones.
20
+
11
21
  ## What it does
12
22
 
13
23
  ```sh
@@ -29,6 +39,7 @@ Nothing is written until every check passes:
29
39
  and what the run cannot decide for you it lists:
30
40
 
31
41
  - `HOLD` — a joint missing, or scored under `--min-score`, holds its last good value; one line per span, with the frames and the reason.
42
+ - `FLIP` — a segment that turns more than `--flip` degrees in one frame (with `--flip <deg>`; off by default) is spread over the frames around the jump; one line per spread, with the chain, the segment, the jump, the frames and h. Below.
32
43
  - `BEND` — with `--bends clamp`, a hinge bent the wrong way is straightened on that frame instead of refusing.
33
44
  - `FOLD` — `rigc render --folds` over the new animation: every mesh that folds, and when. A reading, not a gate.
34
45
  - `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.
@@ -37,6 +48,12 @@ and what the run cannot decide for you it lists:
37
48
 
38
49
  **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.
39
50
 
51
+ **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`.
52
+
53
+ The threshold was fixed by measuring first ([#15](https://github.com/rig-c/pose/issues/15)). On the 13 s dance (313 frames, RTMPose), the one-frame turns of the picture angle of 10 segments — both upper arms, forearms, thighs and shins, the eye line and the hip line, 3,120 turns — run p50 1.0°, p90 5.0°, p99 23.1°, p99.5 33.4°, p99.9 78.2°, max 148.6°. 90° is the p99.9 rounded up past a margin, and marks the 3 turns above it (0.1%): both forearms between frames 181 and 182 (148.6° and 114.4°, a motion-blurred stretch) and the left forearm between 53 and 54 (99.2°, the forearm pointing at the camera). None of them is a spike that comes back: each keeps the direction the limb was already turning, and the new angle holds — a real sweep put into one frame. Holding the last angle would be wrong a frame later, so the guard spreads the step instead. With it, on the @rig-c/parts rig #11 measured, the largest one-frame turn of any guarded bone in the gated build, posed by rig-c, is 78.2° (148.6° without it), and every bone still lands.
54
+
55
+ Re-measured with the guard on, against the guarded unsmoothed run, on #11's criteria (power above 6 Hz down ≥ 2× on every point, timing lag 0, p95 speed ≥ 0.85×): no window passes. `--smooth 7` comes closest — the left wrist's power drops 1.99× and the hip's p95 speed is 0.847× — so `--smooth` stays off by default, and so does the guard: the rule posted before the run made a default for either one depend on a window passing. **Use `--flip 90`**: with it, no bone of the gated build turns more than 90° in one frame (without it, 148.6°), and every bone still lands.
56
+
40
57
  Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
41
58
 
42
59
  ## The pose: `rig-pose-sequence/1`
package/cli.ts CHANGED
@@ -35,13 +35,16 @@ retarget
35
35
  --smooth <n> Savitzky–Golay over n frames of joint positions: a centred least-squares cubic,
36
36
  read at each frame (default: 1, none); n is odd and ≥ 5 — a cubic through 3
37
37
  points passes through all three, so 3 would change nothing
38
+ --flip <deg>|off a segment that turns more than <deg> degrees in one frame of the pose is
39
+ spread over the frames around the jump, centred on it, and listed as FLIP
40
+ (default: off, every turn kept as the pose gives it; 90 is the measured setting)
38
41
  --bends refuse|clamp a hinge (elbow, knee: any joint inside a chain) bent the wrong way refuses
39
42
  the run (default), or is straightened on that frame and listed
40
43
  --bend-tolerance <d> degrees past straight a hinge may bend the wrong way before it counts (default: 0)
41
44
  --profile spine|spine-html rigc's gate profile (default: spine, rigc's own default)
42
45
  --build <dir> keep the gated build (skeleton.json, .atlas, .model.json) here
43
46
  --render <dir> keep rigc's render of the animation (frames, contact.png, folds.json) here
44
- --report <file> write the run's measurements (holds, bends, landings, folds) as JSON
47
+ --report <file> write the run's measurements (holds, flips, bends, landings, folds) as JSON
45
48
 
46
49
  It writes nothing until rigc's gate is green with the animation in, every mapped bone lands on
47
50
  the angle the pose asks for (posed by rig-c's own poser), and no hinge bends the wrong way.
@@ -96,8 +99,13 @@ function smoothOf(f: Map<string, string>): number {
96
99
  return n;
97
100
  }
98
101
 
102
+ function flipOf(f: Map<string, string>): number | 'off' {
103
+ if (!f.has('flip') || f.get('flip') === 'off') return 'off';
104
+ return num(f, 'flip', 90, (v) => v > 0 && v < 180, 'off, or degrees in (0, 180) — a one-frame turn is never more than 180°');
105
+ }
106
+
99
107
  function retargetArgs(argv: string[]): RetargetArgs {
100
- const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'min-score', 'smooth', 'bends', 'bend-tolerance', 'profile', 'build', 'render', 'report'], ['coco']);
108
+ const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'min-score', 'smooth', 'flip', 'bends', 'bend-tolerance', 'profile', 'build', 'render', 'report'], ['coco']);
101
109
  const bends = f.get('bends') ?? 'refuse';
102
110
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
103
111
  const profile = f.get('profile') ?? 'spine';
@@ -115,6 +123,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
115
123
  baseMotion: f.get('base-motion'),
116
124
  minScore: num(f, 'min-score', 0.3, (n) => n >= 0, 'a number ≥ 0'),
117
125
  smooth: smoothOf(f),
126
+ flip: flipOf(f),
118
127
  bends,
119
128
  bendTolerance: num(f, 'bend-tolerance', 0, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
120
129
  profile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Puts motion from a body-18 pose sequence onto a rig built with rig-c: bone keys in rig-c's motion format, checked through rig-c's gate before anything is written. A CLI for agents that cannot see the rig.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/retarget.ts CHANGED
@@ -23,6 +23,12 @@
23
23
  * evaluated at the frame, on each joint's positions after its holds. Centred,
24
24
  * it moves no motion in time; a cubic keeps a peak a moving average flattens.
25
25
  *
26
+ * The flip guard (rig-c/pose#15) then spreads a segment's turn greater than
27
+ * `flip` degrees in one frame over the frames around it, linearly and centred on
28
+ * the jump (`spreadFlips`). It reads the mapped segments' target angles after
29
+ * holds and smoothing and before the hinge check. A reach's target is aimed
30
+ * later, from posed positions, and is not guarded.
31
+ *
26
32
  * What this cannot know is held to account afterwards: `verify.ts` poses the
27
33
  * gated build with rig-c's poser and measures where each bone landed.
28
34
  */
@@ -45,6 +51,8 @@ export interface RetargetOptions {
45
51
  bends: 'refuse' | 'clamp';
46
52
  /** Degrees past straight a hinge may bend the wrong way before it counts. */
47
53
  bendTolerance: number;
54
+ /** A segment turning more than this many degrees in one frame is spread (`spreadFlips`); 'off' keeps every turn. */
55
+ flip: number | 'off';
48
56
  }
49
57
 
50
58
  export interface Hold {
@@ -69,6 +77,26 @@ export interface BendEvent {
69
77
  rest: number;
70
78
  }
71
79
 
80
+ /** One spread: a segment's turn over the threshold, and the frames whose angle was replaced. */
81
+ export interface Flip {
82
+ chain: string;
83
+ bone: string;
84
+ /** The segment the bone carries: from joint, to joint. */
85
+ joints: [string, string];
86
+ /** The largest input turn in the spread, degrees, signed (Spine's sense, counter-clockwise +). */
87
+ jump: number;
88
+ /** Every input turn over the threshold the spread covers: `frame` is k, the turn is from frame k−1 to k. */
89
+ turns: Array<{ frame: number; jump: number }>;
90
+ /** First and last frame whose angle was replaced; the frames either side keep the input's. */
91
+ frames: [number, number];
92
+ /** The half-width the spread needed (for spreads merged from neighbouring turns, the widest). */
93
+ h: number;
94
+ /** The one-frame turn across the spread, degrees, signed. */
95
+ step: number;
96
+ /** Which end of the series cut the window short, if one did. */
97
+ clipped: 'start' | 'end' | 'both' | null;
98
+ }
99
+
72
100
  export interface MotionKey {
73
101
  t: number;
74
102
  v: number[];
@@ -93,6 +121,7 @@ export interface Retarget {
93
121
  origin: { bone: string; at: Array<[number, number]> } | null;
94
122
  holds: Hold[];
95
123
  bends: BendEvent[];
124
+ flips: Flip[];
96
125
  /** The figure scale, rig units per picture pixel, and the lengths it came from. */
97
126
  scale: { factor: number; pose: number; rig: number } | null;
98
127
  }
@@ -329,6 +358,20 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
329
358
  }
330
359
  p.throwIfAny();
331
360
 
361
+ // The flip guard, on every segment the pose aims directly (not a reach).
362
+ const flips: Flip[] = [];
363
+ if (opts.flip !== 'off') {
364
+ const threshold = opts.flip;
365
+ for (const c of map.chains) {
366
+ if (c.reach) continue;
367
+ c.bones.forEach((b, k) => {
368
+ for (const s of spreadFlips(targets.get(b)!, threshold)) {
369
+ flips.push({ chain: c.name, bone: b, joints: [c.joints[k]!, c.joints[k + 1]!], ...s });
370
+ }
371
+ });
372
+ }
373
+ }
374
+
332
375
  // Hinges: each joint strictly inside a chain (a reach has one bone, so none).
333
376
  const bends: BendEvent[] = [];
334
377
  for (const c of map.chains) {
@@ -409,7 +452,90 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
409
452
  }
410
453
  if (translateTrack !== null) tracks.push(translateTrack);
411
454
 
412
- return { animation: { duration: keyTimes[n - 1]!, tracks }, times: keyTimes, targets, origin, holds, bends, scale };
455
+ return { animation: { duration: keyTimes[n - 1]!, tracks }, times: keyTimes, targets, origin, holds, bends, flips, scale };
456
+ }
457
+
458
+ type Spread = Omit<Flip, 'chain' | 'bone' | 'joints'>;
459
+
460
+ /**
461
+ * The flip guard on one angle series (degrees), in place. A turn greater than
462
+ * `threshold` from frame k−1 to frame k is spread: for the smallest h ≥ 1, the
463
+ * angles of frames k−h…k+h−1 are replaced by the straight line from frame
464
+ * k−h−1 to frame k+h, taken through the unwrapped series (each one-frame turn
465
+ * the short way round, so the jump keeps its own direction), such that every
466
+ * one-frame turn in frames k−h…k+h is at or under the threshold. It is centred
467
+ * on the jump, so the step happens when the input put it, spread over 2h frames,
468
+ * and both ends are the input's own angles. A window that would cross the first
469
+ * or the last frame stops there (`clipped`). Windows that overlap — one replaces
470
+ * a frame the other ends on — merge into one line over both, widened again
471
+ * while that line still turns faster than the threshold. Returns the spreads.
472
+ */
473
+ export function spreadFlips(angles: number[], threshold: number): Spread[] {
474
+ const n = angles.length;
475
+ if (n < 2) return [];
476
+ const u: number[] = [angles[0]!];
477
+ for (let i = 1; i < n; i++) u.push(u[i - 1]! + wrap(angles[i]! - angles[i - 1]!));
478
+ const fits = (lo: number, hi: number) => Math.abs(u[hi]! - u[lo]!) <= threshold * (hi - lo);
479
+ interface Win { lo: number; hi: number; h: number; start: boolean; end: boolean; turns: Array<{ frame: number; jump: number }> }
480
+ const wins: Win[] = [];
481
+ for (let k = 1; k < n; k++) {
482
+ const jump = u[k]! - u[k - 1]!;
483
+ if (Math.abs(jump) <= threshold) continue;
484
+ let h = 1;
485
+ let lo = Math.max(0, k - h - 1);
486
+ let hi = Math.min(n - 1, k + h);
487
+ while (!fits(lo, hi) && (lo > 0 || hi < n - 1)) {
488
+ h++;
489
+ lo = Math.max(0, k - h - 1);
490
+ hi = Math.min(n - 1, k + h);
491
+ }
492
+ wins.push({ lo, hi, h, start: k - h - 1 < 0, end: k + h > n - 1, turns: [{ frame: k, jump }] });
493
+ }
494
+ // Merge overlapping windows; a merged line that turns too fast widens by one on each side, then merges again.
495
+ for (let changed = true; changed;) {
496
+ changed = false;
497
+ wins.sort((a, b) => a.lo - b.lo);
498
+ for (let i = 0; i + 1 < wins.length; i++) {
499
+ const a = wins[i]!;
500
+ const b = wins[i + 1]!;
501
+ if (b.lo >= a.hi) continue;
502
+ wins.splice(i, 2, { lo: a.lo, hi: Math.max(a.hi, b.hi), h: Math.max(a.h, b.h), start: a.start || b.start, end: a.end || b.end, turns: [...a.turns, ...b.turns].sort((x, y) => x.frame - y.frame) });
503
+ changed = true;
504
+ break;
505
+ }
506
+ if (changed) continue;
507
+ for (const w of wins) {
508
+ if (w.turns.length < 2 || fits(w.lo, w.hi) || (w.lo === 0 && w.hi === n - 1)) continue;
509
+ if (w.lo === 0) w.start = true;
510
+ if (w.hi === n - 1) w.end = true;
511
+ w.lo = Math.max(0, w.lo - 1);
512
+ w.hi = Math.min(n - 1, w.hi + 1);
513
+ w.h++;
514
+ changed = true;
515
+ }
516
+ }
517
+ return wins.map((w) => {
518
+ const step = (u[w.hi]! - u[w.lo]!) / (w.hi - w.lo);
519
+ for (let i = w.lo + 1; i < w.hi; i++) angles[i] = wrap(u[w.lo]! + step * (i - w.lo));
520
+ const top = w.turns.reduce((m, t) => (Math.abs(t.jump) > Math.abs(m.jump) ? t : m));
521
+ return {
522
+ jump: top.jump,
523
+ turns: w.turns,
524
+ frames: [w.lo + 1, w.hi - 1] as [number, number],
525
+ h: w.h,
526
+ step,
527
+ clipped: w.start && w.end ? 'both' : w.start ? 'start' : w.end ? 'end' : null,
528
+ };
529
+ });
530
+ }
531
+
532
+ export function flipLine(f: Flip, times: readonly number[], threshold: number): string {
533
+ const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
534
+ const turns = f.turns.map((t) => `${sg(t.jump)} from frame ${t.frame - 1} to ${t.frame} (t=${times[t.frame - 1]!.toFixed(3)}–${times[t.frame]!.toFixed(3)}s)`).join(', ');
535
+ const span = f.frames[0] > f.frames[1] ? 'no frame (the pose has none between the two)' : f.frames[0] === f.frames[1] ? `frame ${f.frames[0]}` : `frames ${f.frames[0]}–${f.frames[1]}`;
536
+ const clip = f.clipped === null ? '' : f.clipped === 'both' ? '; the window was cut short by both ends of the pose' : `; the window was cut short at the ${f.clipped === 'start' ? 'first' : 'last'} frame of the pose`;
537
+ const still = Math.abs(f.step) > threshold ? ` — still over ${threshold}°: the pose is too short to spread it` : '';
538
+ return `chain "${f.chain}" bone "${f.bone}" (${f.joints[0]} → ${f.joints[1]}) turned ${turns}, over ${threshold}° in one frame; spread over ${span} (h=${f.h}), ${sg(f.step)} per frame${clip}${still}`;
413
539
  }
414
540
 
415
541
  /** Frames before the first with a direction take its value; none at all is a problem. */
package/src/rigc.ts CHANGED
@@ -100,18 +100,27 @@ export function gate(rig: string, motion: Record<string, unknown>, dir: string,
100
100
  mkdirSync(dir, { recursive: true });
101
101
  const motionPath = join(dir, 'motion.json');
102
102
  writeFileSync(motionPath, `${JSON.stringify(motion, null, 2)}\n`);
103
- const build = join(dir, 'build');
104
- // --copy-images: a build's page paths are relative to its directory, and the
105
- // gated build is copied out of the scratch directory for `--build`, so its
106
- // pages travel with it (rigc refuses to draw a moved build otherwise).
107
- const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', build, '--profile', profile, '--copy-images', '--report', join(dir, 'build-report.json')]);
103
+ return buildInto(rig, motionPath, join(dir, 'build'), profile, join(dir, 'build-report.json'));
104
+ }
105
+
106
+ /**
107
+ * `rigc build` of `rig` with the motion spec at `motionPath` into `out`, under
108
+ * rigc's gate (it writes nothing when red). rigc records each source image's
109
+ * path relative to `out` (`images[].page` in the model document), so a build is
110
+ * only right where it was built: the run gates in a scratch directory, and the
111
+ * build `--build` keeps is built again, by rigc, where it will live (#18) —
112
+ * never copied out, which would carry a path climbing from the scratch
113
+ * directory to the filesystem root. --copy-images puts the pages beside it.
114
+ */
115
+ export function buildInto(rig: string, motionPath: string, out: string, profile: string, report?: string): Gate {
116
+ const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', out, '--profile', profile, '--copy-images', ...(report === undefined ? [] : ['--report', report])]);
108
117
  const lines = run.out.split('\n');
109
118
  return {
110
119
  run,
111
120
  green: run.status === 0,
112
121
  summary: lines.find((l) => /assertions: \d+ measured/.test(l))?.replace(/^[\s.]+/, '') ?? null,
113
122
  fails: lines.filter((l) => /^\s*FAIL\s/.test(l) || l.startsWith('rigc compile error')).map((l) => l.trim()),
114
- build,
123
+ build: out,
115
124
  };
116
125
  }
117
126
 
package/src/run.ts CHANGED
@@ -5,12 +5,12 @@
5
5
  */
6
6
  import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
- import { dirname, join } from 'node:path';
8
+ import { dirname, join, resolve } from 'node:path';
9
9
  import { readMap, resolveMap } from './map.ts';
10
10
  import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
11
11
  import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
12
- import { bendLine, holdLine, retarget, type RetargetOptions } from './retarget.ts';
13
- import { baseMotionOf, folds, gate, readBuild, resolveRig, restOf } from './rigc.ts';
12
+ import { bendLine, flipLine, holdLine, retarget, type RetargetOptions } from './retarget.ts';
13
+ import { baseMotionOf, buildInto, folds, gate, readBuild, resolveRig, restOf } from './rigc.ts';
14
14
  import { landed, measureLanding, measurePosture, poseFrames, postureLines, ANGLE_TOLERANCE, ORIGIN_TOLERANCE } from './verify.ts';
15
15
 
16
16
  export interface RetargetArgs extends RetargetOptions {
@@ -78,9 +78,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
78
78
  resolveMap(map, rest, a.map);
79
79
 
80
80
  const r = retarget(rest, map, frames, times, a);
81
- 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}, bends ${a.bends} (tolerance ${a.bendTolerance}°)`);
81
+ 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}°`}, bends ${a.bends} (tolerance ${a.bendTolerance}°)`);
82
82
  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)})`);
83
83
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
84
+ if (a.flip !== 'off') for (const fl of r.flips) say(` FLIP ${flipLine(fl, r.times, a.flip)}`);
84
85
  for (const e of r.bends) say(` BEND ${bendLine(e)} — clamped straight`);
85
86
 
86
87
  const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
@@ -108,17 +109,21 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
108
109
 
109
110
  const dts = r.times.slice(1).map((t, i) => t - r.times[i]!).sort((x, y) => x - y);
110
111
  const fps = dts.length === 0 ? 12 : Math.max(1, Math.min(60, Math.round(1 / dts[dts.length >> 1]!)));
111
- const f = folds(g1.build, a.name, join(scratch, 'folds'), fps);
112
- if (f.meshes === null) say(` FOLD rigc render --folds did not read (exit ${f.run.status}); folds not measured`);
112
+ // A rig that draws nothing (bones only) has nothing to fold, and rigc's render refuses it.
113
+ const f = doc.slots.length === 0 ? { run: { args: [], status: 0, out: '' }, meshes: [], frames: '' } : folds(g1.build, a.name, join(scratch, 'folds'), fps);
114
+ if (doc.slots.length === 0) say(' FOLD the rig has no slots, so it draws nothing to fold; not measured');
115
+ else if (f.meshes === null) say(` FOLD rigc render --folds did not read (exit ${f.run.status}); folds not measured`);
113
116
  else if (f.meshes.length === 0) say(` FOLD rigc render --folds at ${fps} fps: no mesh folds`);
114
117
  else for (const m of f.meshes) say(` FOLD mesh "${m.attachment}" (slot "${m.slot}") folds on ${m.times.length} frame(s) at ${fps} fps: t=${spans(m.times, 1 / fps)}`);
115
118
 
116
- writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
117
119
  if (a.build !== undefined) {
120
+ // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
118
121
  rmSync(a.build, { recursive: true, force: true });
119
- cpSync(g1.build, a.build, { recursive: true });
122
+ const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile);
123
+ if (!kept.green) refuse([{ code: 'RIG_GATE', text: `rigc build into --build ${a.build} is red where the same build was green in scratch (exit ${kept.run.status}); nothing written` }, ...kept.fails.map((x) => ({ code: 'RIGC', text: x }))]);
120
124
  }
121
- if (a.render !== undefined && f.meshes !== null) {
125
+ writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
126
+ if (a.render !== undefined && f.meshes !== null && f.frames !== '') {
122
127
  rmSync(a.render, { recursive: true, force: true });
123
128
  cpSync(f.frames, a.render, { recursive: true });
124
129
  }
@@ -129,10 +134,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
129
134
  animation: a.name,
130
135
  frames: seq.frames.length,
131
136
  duration: r.animation.duration,
132
- options: { minScore: a.minScore, smooth: a.smooth, bends: a.bends, bendTolerance: a.bendTolerance, profile: a.profile },
137
+ options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, bends: a.bends, bendTolerance: a.bendTolerance, profile: a.profile },
133
138
  scale: r.scale,
134
139
  holds: r.holds,
135
140
  bends: r.bends,
141
+ flips: r.flips,
136
142
  gate: g1.summary,
137
143
  landings,
138
144
  posture,