@rig-c/pose 0.2.0 → 0.3.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
@@ -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,8 @@ 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
+ - `SHORT` — a segment shorter in the picture than `--short` of its median length (with `--short <fraction>`; off by default) has its angle there interpolated, not read; one line per span, with the chain, the segment, the frames and the lowest fraction. Below.
43
+ - `END-ON` — a `SHORT` run lasting at least 0.15 s: the limb points at the camera, a pose the rig cannot draw.
32
44
  - `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.
33
45
  - `BEND` — with `--bends clamp`, a hinge bent the wrong way is straightened on that frame instead of refusing.
34
46
  - `FOLD` — `rigc render --folds` over the new animation: every mesh that folds, and when. A reading, not a gate.
@@ -44,6 +56,17 @@ The threshold was fixed by measuring first ([#15](https://github.com/rig-c/pose/
44
56
 
45
57
  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.
46
58
 
59
+ **The short hold.** A segment pointing at the camera is short in the picture, and its picture direction is then detector noise: the rig turns the bone to follow it. So after holds and smoothing, and before the flip guard, every limb segment the map aims is measured on every frame (not a `reach`, and not a line across the body from a left joint to its right twin, such as the hip line or the eye line: those shorten when the body or head turns, which is a different thing, and the cutoff below was measured on limbs only) — its picture length, between the same two points its angle is read from, over its own median length across the pose. On a frame under `--short <fraction>` its angle is not read from the pose: it is interpolated, the short way round, from the last frame at or above the cutoff to the next one, and held flat at the first or last frame of the pose. The flip guard, the hinge check and the keys all see the held angle. `--short off` (the default) reads every frame. The report lists each span under `short` (chain, bone, segment, first and last frame, the lowest fraction).
60
+
61
+ The cutoff was fixed by measuring first ([#22](https://github.com/rig-c/pose/issues/22)), over both upper arms, forearms, thighs and shins. On the 13 s dance (24 fps, 2,504 segment-frames) the fraction's 0.5th percentile is 0.51, its 5th 0.82, and 7 segment-frames (0.28%) are under 0.35; on a 16 s second sequence (30 fps, 3,936) they are 0.27 and 0.75, and 34 (0.86%). Every frame under 0.35 was looked at, and was one of two kinds:
62
+
63
+ - **end-on, lasting several frames** — the limb really points at the camera: on the second sequence the right upper arm folded across the chest (frames 395–401, 0.09–0.25), and the right thigh and shin of a knee raised at the camera (365–370, 0.16–0.34); on the dance the left upper arm with the elbow at the camera (frames 30–37, 0.27–0.35, S2 below);
64
+ - **blur collapse, lasting 1–3 frames** — a fast forearm whose elbow and wrist the detector both puts on the blurred hand (eleven spans on the second sequence).
65
+
66
+ Both have a meaningless direction; only the first is a pose the rig cannot draw. **0.35** is the smallest round value that takes in every end-on frame (the highest is 0.35, on the dance) without reaching normal motion (the 5th percentiles are 0.75 and 0.82). A run under the cutoff — spans merged across a gap of one frame — lasting **≥ 0.15 s** (3.6 frames at 24 fps, 4.5 at 30) is the end-on kind: it excludes every blur collapse above (at most 3 frames, 0.10 s at 30 fps) and keeps every end-on span (6–8 frames). The run prints it as `END-ON` and the report lists it under `endOn` (chain, bone, segment, frames, seconds, the lowest fraction): it is the list of moments that need an alternate drawing ([#6](https://github.com/rig-c/pose/issues/6)).
67
+
68
+ 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.
69
+
47
70
  Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
48
71
 
49
72
  ## The pose: `rig-pose-sequence/1`
package/cli.ts CHANGED
@@ -38,13 +38,18 @@ retarget
38
38
  --flip <deg>|off a segment that turns more than <deg> degrees in one frame of the pose is
39
39
  spread over the frames around the jump, centred on it, and listed as FLIP
40
40
  (default: off, every turn kept as the pose gives it; 90 is the measured setting)
41
+ --short <f>|off a segment whose picture length on a frame is under <f> of its median length
42
+ (seen end-on, or collapsed by blur) has its angle there interpolated from the
43
+ frames either side, before the flip guard, and listed as SHORT; a run under <f>
44
+ lasting ≥ 0.15 s is also listed as END-ON (default: off; 0.35 is the measured
45
+ setting, f in (0, 1))
41
46
  --bends refuse|clamp a hinge (elbow, knee: any joint inside a chain) bent the wrong way refuses
42
47
  the run (default), or is straightened on that frame and listed
43
48
  --bend-tolerance <d> degrees past straight a hinge may bend the wrong way before it counts (default: 0)
44
49
  --profile spine|spine-html rigc's gate profile (default: spine, rigc's own default)
45
50
  --build <dir> keep the gated build (skeleton.json, .atlas, .model.json) here
46
51
  --render <dir> keep rigc's render of the animation (frames, contact.png, folds.json) here
47
- --report <file> write the run's measurements (holds, flips, bends, landings, folds) as JSON
52
+ --report <file> write the run's measurements (holds, short, endOn, flips, bends, landings, folds) as JSON
48
53
 
49
54
  It writes nothing until rigc's gate is green with the animation in, every mapped bone lands on
50
55
  the angle the pose asks for (posed by rig-c's own poser), and no hinge bends the wrong way.
@@ -104,8 +109,13 @@ function flipOf(f: Map<string, string>): number | 'off' {
104
109
  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
110
  }
106
111
 
112
+ function shortOf(f: Map<string, string>): number | 'off' {
113
+ if (!f.has('short') || f.get('short') === 'off') return 'off';
114
+ 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');
115
+ }
116
+
107
117
  function retargetArgs(argv: string[]): RetargetArgs {
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']);
118
+ const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'profile', 'build', 'render', 'report'], ['coco']);
109
119
  const bends = f.get('bends') ?? 'refuse';
110
120
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
111
121
  const profile = f.get('profile') ?? 'spine';
@@ -124,6 +134,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
124
134
  minScore: num(f, 'min-score', 0.3, (n) => n >= 0, 'a number ≥ 0'),
125
135
  smooth: smoothOf(f),
126
136
  flip: flipOf(f),
137
+ short: shortOf(f),
127
138
  bends,
128
139
  bendTolerance: num(f, 'bend-tolerance', 0, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
129
140
  profile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.2.0",
3
+ "version": "0.3.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/retarget.ts CHANGED
@@ -23,11 +23,21 @@
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 short hold (rig-c/pose#22) comes next: a segment whose picture length on
27
+ * a frame is under `short` of its own median length over the sequence is seen
28
+ * end-on, or collapsed by blur, and its picture direction there is noise. Its
29
+ * angle on those frames is not read from the pose but interpolated, the short
30
+ * way round, between the frames either side at or above the cutoff (flat at the
31
+ * ends: `holdShort`). A run under the cutoff lasting ≥ `END_ON_SECONDS` (spans
32
+ * merged across a gap of `END_ON_GAP` frame) is listed as `endOn`: a pose the
33
+ * rig cannot draw, the demand for an alternate drawing (#6).
34
+ *
26
35
  * The flip guard (rig-c/pose#15) then spreads a segment's turn greater than
27
36
  * `flip` degrees in one frame over the frames around it, linearly and centred on
28
37
  * 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.
38
+ * holds, smoothing and the short hold, and before the hinge check. A reach's
39
+ * target is aimed later, from posed positions, and is neither held short nor
40
+ * guarded.
31
41
  *
32
42
  * What this cannot know is held to account afterwards: `verify.ts` poses the
33
43
  * gated build with rig-c's poser and measures where each bone landed.
@@ -53,6 +63,8 @@ export interface RetargetOptions {
53
63
  bendTolerance: number;
54
64
  /** A segment turning more than this many degrees in one frame is spread (`spreadFlips`); 'off' keeps every turn. */
55
65
  flip: number | 'off';
66
+ /** A segment shorter in the picture than this fraction of its median length has its angle held there (`holdShort`); 'off' reads every frame. */
67
+ short: number | 'off';
56
68
  }
57
69
 
58
70
  export interface Hold {
@@ -97,6 +109,39 @@ export interface Flip {
97
109
  clipped: 'start' | 'end' | 'both' | null;
98
110
  }
99
111
 
112
+ /** One span of frames under the short cutoff: the segment's angle there was interpolated, not read (`holdShort`). */
113
+ export interface Short {
114
+ chain: string;
115
+ bone: string;
116
+ /** The segment the bone carries: from joint, to joint. */
117
+ joints: [string, string];
118
+ /** First and last frame under the cutoff; every one of them had its angle replaced, and the frames either side keep the input's. */
119
+ frames: [number, number];
120
+ /** The lowest picture length on those frames, as a fraction of the segment's median picture length. */
121
+ minFraction: number;
122
+ }
123
+
124
+ /**
125
+ * A segment under the short cutoff long enough to be a pose (the limb pointing
126
+ * at the camera), not a blur: short spans merged across a gap of `END_ON_GAP`
127
+ * frame, lasting ≥ `END_ON_SECONDS`. The demand for an alternate drawing (#6).
128
+ */
129
+ export interface EndOn {
130
+ chain: string;
131
+ bone: string;
132
+ joints: [string, string];
133
+ /** First and last frame of the merged run (a gap frame inside it is at or above the cutoff). */
134
+ frames: [number, number];
135
+ /** How long it lasts: from the first frame's key time to the end of the last frame (the next frame's key time; for the pose's last frame, one frame on). */
136
+ seconds: number;
137
+ minFraction: number;
138
+ }
139
+
140
+ /** A run under the short cutoff lasting at least this long, in seconds, is listed as `endOn` (rig-c/pose#22). */
141
+ export const END_ON_SECONDS = 0.15;
142
+ /** Short spans this many frames apart or fewer merge into one `endOn` run. */
143
+ export const END_ON_GAP = 1;
144
+
100
145
  export interface MotionKey {
101
146
  t: number;
102
147
  v: number[];
@@ -121,6 +166,10 @@ export interface Retarget {
121
166
  origin: { bone: string; at: Array<[number, number]> } | null;
122
167
  holds: Hold[];
123
168
  bends: BendEvent[];
169
+ /** The short hold's spans, per segment in map order. */
170
+ short: Short[];
171
+ /** The short runs long enough to be a pose seen end-on. */
172
+ endOn: EndOn[];
124
173
  flips: Flip[];
125
174
  /** The figure scale, rig units per picture pixel, and the lengths it came from. */
126
175
  scale: { factor: number; pose: number; rig: number } | null;
@@ -358,6 +407,41 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
358
407
  }
359
408
  p.throwIfAny();
360
409
 
410
+ // The short hold, on every limb segment the pose aims directly (not a reach, and
411
+ // not a line across the body from a left joint to its right twin — the hip line,
412
+ // the eye line — whose shortening is the body or head turning, not a limb seen
413
+ // end-on; #22's cutoff was measured on limb segments only): its picture length
414
+ // against its own median, on the points the angle was read from.
415
+ const short: Short[] = [];
416
+ const endOn: EndOn[] = [];
417
+ if (opts.short !== 'off') {
418
+ const cutoff = opts.short;
419
+ /** The end of frame i: the next frame's key time; the last frame lasts as long as the one before it. */
420
+ const endOf = (i: number): number => (i + 1 < n ? keyTimes[i + 1]! : n > 1 ? 2 * keyTimes[i]! - keyTimes[i - 1]! : keyTimes[i]!);
421
+ for (const c of map.chains) {
422
+ if (c.reach) continue;
423
+ c.bones.forEach((b, k) => {
424
+ const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
425
+ if (isAcross(joints[0], joints[1])) return;
426
+ const from = series.get(joints[0])!;
427
+ const to = series.get(joints[1])!;
428
+ const lengths = from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1]));
429
+ const med = median(lengths);
430
+ if (med < 1e-6) {
431
+ p.add('POSE_SHORT_MEDIAN', `chain "${c.name}" — bone "${b}"'s segment ${joints[0]} → ${joints[1]} has median picture length ${med}px over the ${n} frames; --short measures a frame's length against that median, and there is none to measure against`);
432
+ return;
433
+ }
434
+ const spans = holdShort(targets.get(b)!, lengths.map((l) => l / med), cutoff);
435
+ for (const s of spans) short.push({ chain: c.name, bone: b, joints, ...s });
436
+ for (const run of mergeRuns(spans, END_ON_GAP)) {
437
+ const seconds = Math.round((endOf(run.frames[1]) - keyTimes[run.frames[0]]!) * 1e6) / 1e6;
438
+ if (seconds >= END_ON_SECONDS - 1e-9) endOn.push({ chain: c.name, bone: b, joints, frames: run.frames, seconds, minFraction: run.minFraction });
439
+ }
440
+ });
441
+ }
442
+ }
443
+ p.throwIfAny();
444
+
361
445
  // The flip guard, on every segment the pose aims directly (not a reach).
362
446
  const flips: Flip[] = [];
363
447
  if (opts.flip !== 'off') {
@@ -452,7 +536,78 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
452
536
  }
453
537
  if (translateTrack !== null) tracks.push(translateTrack);
454
538
 
455
- return { animation: { duration: keyTimes[n - 1]!, tracks }, times: keyTimes, targets, origin, holds, bends, flips, scale };
539
+ return { animation: { duration: keyTimes[n - 1]!, tracks }, times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale };
540
+ }
541
+
542
+ type ShortSpan = Omit<Short, 'chain' | 'bone' | 'joints'>;
543
+
544
+ /**
545
+ * The short hold on one angle series (degrees), in place. `fraction` is the
546
+ * segment's picture length on each frame over its median. Each run of frames
547
+ * under `cutoff` has its angles replaced by the line from the last frame before
548
+ * it at or above the cutoff to the first one after it, the short way round; a
549
+ * run at the start or the end of the series holds the one reliable end flat.
550
+ * With the fraction against the median and a cutoff under 1, half the frames
551
+ * at least are at or above it, so every run has a reliable end. Returns the runs.
552
+ */
553
+ /** A line across the body: a left joint and its right twin (r_hip → l_hip, r_eye → l_eye). */
554
+ export function isAcross(a: string, b: string): boolean {
555
+ const twin = (x: string): string | null => (x.startsWith('r_') ? `l_${x.slice(2)}` : x.startsWith('l_') ? `r_${x.slice(2)}` : null);
556
+ return twin(a) === b;
557
+ }
558
+
559
+ export function holdShort(angles: number[], fraction: readonly number[], cutoff: number): ShortSpan[] {
560
+ const n = angles.length;
561
+ const out: ShortSpan[] = [];
562
+ for (let i = 0; i < n; i++) {
563
+ if (!(fraction[i]! < cutoff)) continue;
564
+ let j = i;
565
+ while (j + 1 < n && fraction[j + 1]! < cutoff) j++;
566
+ const lo = i - 1;
567
+ const hi = j + 1;
568
+ let minFraction = fraction[i]!;
569
+ for (let k = i + 1; k <= j; k++) minFraction = Math.min(minFraction, fraction[k]!);
570
+ if (lo >= 0 || hi < n) {
571
+ const a = lo >= 0 ? angles[lo]! : angles[hi]!;
572
+ const b = hi < n ? angles[hi]! : a;
573
+ const turn = wrap(b - a);
574
+ for (let k = i; k <= j; k++) angles[k] = lo < 0 ? b : wrap(a + (turn * (k - lo)) / (hi - lo));
575
+ }
576
+ out.push({ frames: [i, j], minFraction });
577
+ i = j;
578
+ }
579
+ return out;
580
+ }
581
+
582
+ /** Short spans `gap` frames apart or fewer, as one run each: its first and last frame, and the lowest fraction. */
583
+ export function mergeRuns(spans: readonly ShortSpan[], gap: number): ShortSpan[] {
584
+ const out: ShortSpan[] = [];
585
+ for (const s of spans) {
586
+ const last = out[out.length - 1];
587
+ if (last !== undefined && s.frames[0] - last.frames[1] - 1 <= gap) {
588
+ last.frames = [last.frames[0], s.frames[1]];
589
+ last.minFraction = Math.min(last.minFraction, s.minFraction);
590
+ } else out.push({ frames: [s.frames[0], s.frames[1]], minFraction: s.minFraction });
591
+ }
592
+ return out;
593
+ }
594
+
595
+ const spanOf = (frames: [number, number], times: readonly number[]): string =>
596
+ frames[0] === frames[1] ? `frame ${frames[0]} (t=${times[frames[0]]!.toFixed(3)}s)` : `frames ${frames[0]}–${frames[1]} (t=${times[frames[0]]!.toFixed(3)}–${times[frames[1]]!.toFixed(3)}s)`;
597
+
598
+ export function shortLine(s: Short, times: readonly number[], cutoff: number): string {
599
+ const lo = s.frames[0] - 1;
600
+ const hi = s.frames[1] + 1;
601
+ const held = lo < 0 && hi >= times.length
602
+ ? 'no frame of the pose is at or above it, so nothing was held'
603
+ : lo < 0 ? `held flat at frame ${hi}'s angle (the pose starts short)`
604
+ : hi >= times.length ? `held flat at frame ${lo}'s angle (the pose ends short)`
605
+ : `interpolated from frame ${lo}'s angle to frame ${hi}'s, the short way round`;
606
+ return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: picture length down to ${s.minFraction.toFixed(2)} of its median, under ${cutoff}; its direction is not read — ${held}`;
607
+ }
608
+
609
+ export function endOnLine(e: EndOn, times: readonly number[], cutoff: number): string {
610
+ return `chain "${e.chain}" bone "${e.bone}" (${e.joints[0]} → ${e.joints[1]}) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s under ${cutoff} of its median picture length (down to ${e.minFraction.toFixed(2)}), ≥ ${END_ON_SECONDS}s — seen end-on, a pose the rig cannot draw`;
456
611
  }
457
612
 
458
613
  type Spread = Omit<Flip, 'chain' | 'bone' | 'joints'>;
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, flipLine, holdLine, retarget, type RetargetOptions } from './retarget.ts';
13
- import { baseMotionOf, folds, gate, readBuild, resolveRig, restOf } from './rigc.ts';
12
+ import { bendLine, endOnLine, flipLine, holdLine, retarget, shortLine, 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,13 @@ 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}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, 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}°`}, short ${a.short}, 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.short !== 'off') {
85
+ for (const s of r.short) say(` SHORT ${shortLine(s, r.times, a.short)}`);
86
+ for (const e of r.endOn) say(` END-ON ${endOnLine(e, r.times, a.short)}`);
87
+ }
84
88
  if (a.flip !== 'off') for (const fl of r.flips) say(` FLIP ${flipLine(fl, r.times, a.flip)}`);
85
89
  for (const e of r.bends) say(` BEND ${bendLine(e)} — clamped straight`);
86
90
 
@@ -109,17 +113,21 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
109
113
 
110
114
  const dts = r.times.slice(1).map((t, i) => t - r.times[i]!).sort((x, y) => x - y);
111
115
  const fps = dts.length === 0 ? 12 : Math.max(1, Math.min(60, Math.round(1 / dts[dts.length >> 1]!)));
112
- const f = folds(g1.build, a.name, join(scratch, 'folds'), fps);
113
- if (f.meshes === null) say(` FOLD rigc render --folds did not read (exit ${f.run.status}); folds not measured`);
116
+ // A rig that draws nothing (bones only) has nothing to fold, and rigc's render refuses it.
117
+ const f = doc.slots.length === 0 ? { run: { args: [], status: 0, out: '' }, meshes: [], frames: '' } : folds(g1.build, a.name, join(scratch, 'folds'), fps);
118
+ if (doc.slots.length === 0) say(' FOLD the rig has no slots, so it draws nothing to fold; not measured');
119
+ else if (f.meshes === null) say(` FOLD rigc render --folds did not read (exit ${f.run.status}); folds not measured`);
114
120
  else if (f.meshes.length === 0) say(` FOLD rigc render --folds at ${fps} fps: no mesh folds`);
115
121
  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)}`);
116
122
 
117
- writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
118
123
  if (a.build !== undefined) {
124
+ // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
119
125
  rmSync(a.build, { recursive: true, force: true });
120
- cpSync(g1.build, a.build, { recursive: true });
126
+ const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile);
127
+ 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 }))]);
121
128
  }
122
- if (a.render !== undefined && f.meshes !== null) {
129
+ writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
130
+ if (a.render !== undefined && f.meshes !== null && f.frames !== '') {
123
131
  rmSync(a.render, { recursive: true, force: true });
124
132
  cpSync(f.frames, a.render, { recursive: true });
125
133
  }
@@ -130,9 +138,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
130
138
  animation: a.name,
131
139
  frames: seq.frames.length,
132
140
  duration: r.animation.duration,
133
- options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, bends: a.bends, bendTolerance: a.bendTolerance, profile: a.profile },
141
+ options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, profile: a.profile },
134
142
  scale: r.scale,
135
143
  holds: r.holds,
144
+ short: r.short,
145
+ endOn: r.endOn,
136
146
  bends: r.bends,
137
147
  flips: r.flips,
138
148
  gate: g1.summary,