@rig-c/pose 0.2.1 → 0.4.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/src/retarget.ts CHANGED
@@ -23,11 +23,38 @@
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.
41
+ *
42
+ * Then the switches (rig-c/pose#6, `switch.ts`): with every target known —
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
45
+ * of a set depicting its chain, a bone whose rotate key would leave its
46
+ * `range` is clamped at the limit (its target becomes the clamped world angle),
47
+ * in depth order, so a bone below takes the clamped parent into its own key
48
+ * and still follows the pose; a reach below is aimed again from where the
49
+ * clamped bones put its origin. Every overflow is listed as `range` demand.
50
+ * Inside a span nothing is clamped, but a bone with a `drawn` angle is read
51
+ * against it (`artTurn`, and `artRange` where it leaves its range).
52
+ *
53
+ * The lines across the body — hips, shoulders, eyes — are read from the pose
54
+ * after holds and smoothing, whether the map uses them or not: a line under
55
+ * `TURN_FRACTION` of its median picture length, or reversed (pointing more
56
+ * than 90° from the subject's right to left), for ≥ `TURN_SECONDS`, is a `TURN`:
57
+ * the body or head turned, a pose a flat rig cannot draw. It changes no key.
31
58
  *
32
59
  * What this cannot know is held to account afterwards: `verify.ts` poses the
33
60
  * gated build with rig-c's poser and measures where each bone landed.
@@ -35,14 +62,16 @@
35
62
  import { screenToSpineDegrees } from 'rig-c/transform';
36
63
  import type { Body18 } from './joints.ts';
37
64
  import { isBody18, VIRTUAL } from './joints.ts';
38
- import type { Chain, JointMap, Rest } from './map.ts';
65
+ import type { Chain, JointMap, Rest, RigSlots } from './map.ts';
39
66
  import { aimAngle, rigPoint } from './map.ts';
40
67
  import type { Joint } from './pose.ts';
41
68
  import { Problems } from './problems.ts';
69
+ import type { ArtRange, SlotTrack, Switch, SwitchOptions } from './switch.ts';
70
+ import { coveredChains, measureArt, overflow, RANGE_SLACK, runsOf, selectSwitches } from './switch.ts';
42
71
 
43
72
  const DEG = 180 / Math.PI;
44
73
 
45
- export interface RetargetOptions {
74
+ export interface RetargetOptions extends SwitchOptions {
46
75
  /** A joint with a score below this is not good on that frame (a joint with no score is). */
47
76
  minScore: number;
48
77
  /** Savitzky–Golay window over joint positions, in frames: 1 = none, else odd and ≥ 5. */
@@ -53,6 +82,8 @@ export interface RetargetOptions {
53
82
  bendTolerance: number;
54
83
  /** A segment turning more than this many degrees in one frame is spread (`spreadFlips`); 'off' keeps every turn. */
55
84
  flip: number | 'off';
85
+ /** A segment shorter in the picture than this fraction of its median length has its angle held there (`holdShort`); 'off' reads every frame. */
86
+ short: number | 'off';
56
87
  }
57
88
 
58
89
  export interface Hold {
@@ -97,9 +128,44 @@ export interface Flip {
97
128
  clipped: 'start' | 'end' | 'both' | null;
98
129
  }
99
130
 
131
+ /** One span of frames under the short cutoff: the segment's angle there was interpolated, not read (`holdShort`). */
132
+ export interface Short {
133
+ chain: string;
134
+ bone: string;
135
+ /** The segment the bone carries: from joint, to joint. */
136
+ joints: [string, string];
137
+ /** First and last frame under the cutoff; every one of them had its angle replaced, and the frames either side keep the input's. */
138
+ frames: [number, number];
139
+ /** The lowest picture length on those frames, as a fraction of the segment's median picture length. */
140
+ minFraction: number;
141
+ }
142
+
143
+ /**
144
+ * A segment under the short cutoff long enough to be a pose (the limb pointing
145
+ * at the camera), not a blur: short spans merged across a gap of `END_ON_GAP`
146
+ * frame, lasting ≥ `END_ON_SECONDS`. The demand for an alternate drawing (#6).
147
+ */
148
+ export interface EndOn {
149
+ chain: string;
150
+ bone: string;
151
+ joints: [string, string];
152
+ /** First and last frame of the merged run (a gap frame inside it is at or above the cutoff). */
153
+ frames: [number, number];
154
+ /** 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). */
155
+ seconds: number;
156
+ minFraction: number;
157
+ }
158
+
159
+ /** A run under the short cutoff lasting at least this long, in seconds, is listed as `endOn` (rig-c/pose#22). */
160
+ export const END_ON_SECONDS = 0.15;
161
+ /** Short spans this many frames apart or fewer merge into one `endOn` run. */
162
+ export const END_ON_GAP = 1;
163
+
100
164
  export interface MotionKey {
101
165
  t: number;
102
166
  v: number[];
167
+ /** `stepped`: the value holds until the next key (the offsets' translate keys). */
168
+ ease?: 'stepped';
103
169
  }
104
170
  export interface MotionTrack {
105
171
  bone: string;
@@ -108,9 +174,51 @@ export interface MotionTrack {
108
174
  }
109
175
  export interface Animation {
110
176
  duration: number;
111
- tracks: MotionTrack[];
177
+ /** The bone tracks, then any slot `attachment` tracks the switches add (`switch.ts`). */
178
+ tracks: Array<MotionTrack | SlotTrack>;
179
+ }
180
+
181
+ /** A line across the body turned: short in the picture, or reversed, long enough to be a pose (the demand for a turned-body drawing). */
182
+ export interface Turn {
183
+ /** `hip`, `shoulder` or `eye`. */
184
+ line: string;
185
+ /** The subject's right joint, then its left twin. */
186
+ joints: [string, string];
187
+ /** First and last frame of the run (merged across a gap of `TURN_GAP` frame). */
188
+ frames: [number, number];
189
+ seconds: number;
190
+ /** The lowest picture length on the run's turned frames, as a fraction of the line's median. */
191
+ minFraction: number;
192
+ /** Whether the line points from the right joint to the left more than 90° off the subject's right-to-left on any frame of the run. */
193
+ reversed: boolean;
112
194
  }
113
195
 
196
+ /** A bone's rotate key past its range outside every span of a set depicting its chain: clamped there, and the demand for an alternate. */
197
+ export interface RangeDemand {
198
+ chain: string;
199
+ bone: string;
200
+ /** First and last frame of the run (merged across a gap of one frame; a gap frame inside is within range or inside a span). */
201
+ frames: [number, number];
202
+ seconds: number;
203
+ /** Degrees past the range, the most on any frame of the run. */
204
+ maxOverflow: number;
205
+ /** The rotate key the pose asked for on that frame, degrees relative to setup, before the clamp. */
206
+ key: number;
207
+ /** The bone's range, degrees. */
208
+ limits: [number, number];
209
+ }
210
+
211
+ /** The lines across the body a `TURN` is read on: the subject's right joint, then its left twin. */
212
+ export const TURN_LINES: ReadonlyArray<[string, string, string]> = [['hip', 'r_hip', 'l_hip'], ['shoulder', 'r_shoulder', 'l_shoulder'], ['eye', 'r_eye', 'l_eye']];
213
+ /** A line shorter in the picture than this fraction of its median is turned (rig-c/pose#6: between the front-facing dance's floor, 0.81, and a side-on body's 0.17–0.49). */
214
+ export const TURN_FRACTION = 0.5;
215
+ /** A turned run lasting at least this long, in seconds, is listed (as `endOn`). */
216
+ export const TURN_SECONDS = 0.15;
217
+ /** Turned spans this many frames apart or fewer merge into one run. */
218
+ export const TURN_GAP = 1;
219
+ /** Range overflow and art-range spans this many frames apart or fewer merge into one run. */
220
+ export const RANGE_GAP = 1;
221
+
114
222
  export interface Retarget {
115
223
  animation: Animation;
116
224
  /** Key times, seconds from the first frame, as written. */
@@ -121,9 +229,27 @@ export interface Retarget {
121
229
  origin: { bone: string; at: Array<[number, number]> } | null;
122
230
  holds: Hold[];
123
231
  bends: BendEvent[];
232
+ /** The short hold's spans, per segment in map order. */
233
+ short: Short[];
234
+ /** The short runs long enough to be a pose seen end-on. */
235
+ endOn: EndOn[];
124
236
  flips: Flip[];
125
237
  /** The figure scale, rig units per picture pixel, and the lengths it came from. */
126
238
  scale: { factor: number; pose: number; rig: number } | null;
239
+ /** The spans each alternate set shows, with their art turn. */
240
+ switches: Switch[];
241
+ /** Per switched or hidden slot, what it shows on each frame (null: nothing). */
242
+ shown: Map<string, Array<string | null>>;
243
+ /** Per set, its distance to the pose on each frame, degrees. */
244
+ distances: Map<string, number[]>;
245
+ /** The lines across the body turned long enough to be a pose. */
246
+ turns: Turn[];
247
+ /** The lines across the body that could not be read, and why. */
248
+ turnsUnread: Array<{ line: string; joints: [string, string]; why: string }>;
249
+ /** Every run a bone's key was clamped at its range. */
250
+ range: RangeDemand[];
251
+ /** Every run a bone inside a span turned from its drawn angle past its range. */
252
+ artRange: ArtRange[];
127
253
  }
128
254
 
129
255
  type Pt = [number, number];
@@ -237,7 +363,7 @@ const r3 = (v: number) => {
237
363
  * good, when a hinge's side cannot be read off a straight rest, and — with
238
364
  * `bends: 'refuse'` — when a hinge bends the wrong way.
239
365
  */
240
- export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, times: readonly number[], opts: RetargetOptions): Retarget {
366
+ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, times: readonly number[], opts: RetargetOptions, slots: RigSlots = new Map()): Retarget {
241
367
  const p = new Problems();
242
368
  const n = frames.length;
243
369
  const t0 = times[0]!;
@@ -245,6 +371,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
245
371
  for (let i = 1; i < n; i++) {
246
372
  if (keyTimes[i]! <= keyTimes[i - 1]!) p.add('POSE_TIME', `frames ${i - 1} and ${i} are ${keyTimes[i - 1]}s and ${keyTimes[i]}s after rounding to the microsecond; keys must strictly increase`);
247
373
  }
374
+ /** The end of frame i: the next frame's key time; the last frame lasts as long as the one before it. */
375
+ const endOf = (i: number): number => (i + 1 < n ? keyTimes[i + 1]! : n > 1 ? 2 * keyTimes[i]! - keyTimes[i - 1]! : keyTimes[i]!);
376
+ const secondsOf = (run: [number, number]): number => Math.round((endOf(run[1]) - keyTimes[run[0]]!) * 1e6) / 1e6;
248
377
 
249
378
  // Every point the map reads, held where it is not good.
250
379
  const needed = new Set<string>();
@@ -256,15 +385,13 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
256
385
  const series = new Map<string, Pt[]>();
257
386
  const good = new Map<string, boolean[]>();
258
387
  const holds: Hold[] = [];
259
- for (const name of [...needed].sort()) {
388
+ /** A point held where it is not good, then smoothed; or why it is good on no frame. */
389
+ const heldOf = (name: string): { at: Pt[]; good: boolean[]; holds: Hold[] } | { never: string } => {
260
390
  const raw = frames.map((f) => pointOn(f, name, opts.minScore));
261
391
  const firstGood = raw.findIndex((v) => typeof v !== 'string');
262
- if (firstGood < 0) {
263
- const why = [...new Set(raw as string[])].slice(0, 3).join('; ');
264
- p.add('POSE_POINT_NEVER_GOOD', `point "${name}" is good on none of the ${n} frames (${why}); the map needs it, and there is no value to hold`);
265
- continue;
266
- }
392
+ if (firstGood < 0) return { never: [...new Set(raw as string[])].slice(0, 3).join('; ') };
267
393
  const out: Pt[] = [];
394
+ const spans: Hold[] = [];
268
395
  let last = firstGood;
269
396
  let span: Hold | null = null;
270
397
  for (let i = 0; i < n; i++) {
@@ -279,14 +406,66 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
279
406
  if (span !== null && span.source === last && span.why === v) span.to = i;
280
407
  else {
281
408
  span = { point: name, from: i, to: i, source: last, why: v };
282
- holds.push(span);
409
+ spans.push(span);
283
410
  }
284
411
  }
285
- series.set(name, savitzkyGolay(out, opts.smooth));
286
- good.set(name, raw.map((v) => typeof v !== 'string'));
412
+ return { at: savitzkyGolay(out, opts.smooth), good: raw.map((v) => typeof v !== 'string'), holds: spans };
413
+ };
414
+ for (const name of [...needed].sort()) {
415
+ const h = heldOf(name);
416
+ if ('never' in h) {
417
+ p.add('POSE_POINT_NEVER_GOOD', `point "${name}" is good on none of the ${n} frames (${h.never}); the map needs it, and there is no value to hold`);
418
+ continue;
419
+ }
420
+ holds.push(...h.holds);
421
+ series.set(name, h.at);
422
+ good.set(name, h.good);
287
423
  }
288
424
  p.throwIfAny();
289
425
 
426
+ // TURN: the lines across the body, read from the pose (held and smoothed as the
427
+ // map's points are, whether or not the map uses them). A reading; no key moves.
428
+ const turns: Turn[] = [];
429
+ const turnsUnread: Retarget['turnsUnread'] = [];
430
+ for (const [line, a, b] of TURN_LINES) {
431
+ const ends: Pt[][] = [];
432
+ for (const j of [a, b]) {
433
+ const known = series.get(j);
434
+ if (known !== undefined) ends.push(known);
435
+ else {
436
+ const h = heldOf(j);
437
+ if ('never' in h) {
438
+ turnsUnread.push({ line, joints: [a, b], why: `${j} is good on none of the ${n} frames (${h.never})` });
439
+ break;
440
+ }
441
+ ends.push(h.at);
442
+ }
443
+ }
444
+ if (ends.length < 2) continue;
445
+ const [A, B] = ends as [Pt[], Pt[]];
446
+ const lengths = A.map((f, i) => Math.hypot(B[i]![0] - f[0], B[i]![1] - f[1]));
447
+ const med = median(lengths);
448
+ if (med < 1e-6) {
449
+ turnsUnread.push({ line, joints: [a, b], why: `its median picture length is ${med}px; a fraction of it is not a measure` });
450
+ continue;
451
+ }
452
+ const fraction = lengths.map((l) => l / med);
453
+ const reversed = A.map((f, i) => lengths[i]! > 1e-9 && Math.abs(pictureAngle(f, B[i]!)) > 90);
454
+ const turned = fraction.map((f, i) => f < TURN_FRACTION || reversed[i]!);
455
+ for (const run of runsOf(turned, TURN_GAP)) {
456
+ const seconds = secondsOf(run);
457
+ if (seconds < TURN_SECONDS - 1e-9) continue;
458
+ let minFraction = Infinity;
459
+ let rev = false;
460
+ for (let i = run[0]; i <= run[1]; i++) {
461
+ if (!turned[i]) continue;
462
+ minFraction = Math.min(minFraction, fraction[i]!);
463
+ rev ||= reversed[i]!;
464
+ }
465
+ turns.push({ line, joints: [a, b], frames: run, seconds, minFraction, reversed: rev });
466
+ }
467
+ }
468
+
290
469
  // The hip: the travel of one point, scaled, in its parent's axes. It comes
291
470
  // first, because a reach is aimed from where the hip has put its bone.
292
471
  let origin: Retarget['origin'] = null;
@@ -358,6 +537,39 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
358
537
  }
359
538
  p.throwIfAny();
360
539
 
540
+ // The short hold, on every limb segment the pose aims directly (not a reach, and
541
+ // not a line across the body from a left joint to its right twin — the hip line,
542
+ // the eye line — whose shortening is the body or head turning, not a limb seen
543
+ // end-on; #22's cutoff was measured on limb segments only): its picture length
544
+ // against its own median, on the points the angle was read from.
545
+ const short: Short[] = [];
546
+ const endOn: EndOn[] = [];
547
+ if (opts.short !== 'off') {
548
+ const cutoff = opts.short;
549
+ for (const c of map.chains) {
550
+ if (c.reach) continue;
551
+ c.bones.forEach((b, k) => {
552
+ const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
553
+ if (isAcross(joints[0], joints[1])) return;
554
+ const from = series.get(joints[0])!;
555
+ const to = series.get(joints[1])!;
556
+ const lengths = from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1]));
557
+ const med = median(lengths);
558
+ if (med < 1e-6) {
559
+ 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`);
560
+ return;
561
+ }
562
+ const spans = holdShort(targets.get(b)!, lengths.map((l) => l / med), cutoff);
563
+ for (const s of spans) short.push({ chain: c.name, bone: b, joints, ...s });
564
+ for (const run of mergeRuns(spans, END_ON_GAP)) {
565
+ const seconds = Math.round((endOf(run.frames[1]) - keyTimes[run.frames[0]]!) * 1e6) / 1e6;
566
+ if (seconds >= END_ON_SECONDS - 1e-9) endOn.push({ chain: c.name, bone: b, joints, frames: run.frames, seconds, minFraction: run.minFraction });
567
+ }
568
+ });
569
+ }
570
+ }
571
+ p.throwIfAny();
572
+
361
573
  // The flip guard, on every segment the pose aims directly (not a reach).
362
574
  const flips: Flip[] = [];
363
575
  if (opts.flip !== 'off') {
@@ -390,6 +602,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
390
602
  return d;
391
603
  };
392
604
  const reaches = map.chains.filter((c) => c.reach).sort((x, y) => depth(x.bones[0]!) - depth(y.bones[0]!));
605
+ let aimReach: ((c: Chain) => void) | null = null;
393
606
  if (reaches.length > 0 && t !== null && scale !== null) {
394
607
  const s = scale.factor;
395
608
  const tb = rest.get(t.bone)!;
@@ -411,7 +624,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
411
624
  const oy = rest.get(b)!.worldY - rest.get(parent)!.worldY;
412
625
  return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy, po[1] + Math.sin(r) * ox + Math.cos(r) * oy];
413
626
  };
414
- for (const c of reaches) {
627
+ aimReach = (c: Chain): void => {
415
628
  const b = c.bones[0]!;
416
629
  const anchor = series.get(c.joints[0]!)!;
417
630
  const end = series.get(c.joints[1]!)!;
@@ -425,20 +638,56 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
425
638
  }
426
639
  fillLeading(angles, () => p.add('POSE_SEGMENT_ZERO', `chain "${c.name}" — bone "${b}" reaches for ${c.joints[1]}, which the pose puts on the bone's posed origin on every frame; it has no direction`));
427
640
  targets.set(b, angles);
428
- }
641
+ };
642
+ for (const c of reaches) aimReach(c);
429
643
  }
430
644
  p.throwIfAny();
431
645
 
432
- // Keys: the bone's turn less the turn of the nearest mapped bone above it.
646
+ // The switches: with every target known, each set's distance and its spans.
647
+ const sw = selectSwitches(map, targets, keyTimes, keyTimes[n - 1]!, slots, opts);
648
+
649
+ // The range clamp, outside the spans of a set depicting the bone's chain. Parents
650
+ // first, so a bone below takes its clamped parent into its key; a reach is aimed
651
+ // again from where the clamped bones above put its origin.
433
652
  const mappedAbove = (b: string): string | null => {
434
653
  for (let a = rest.get(b)!.parent; a !== null; a = rest.get(a)!.parent) if (targets.has(a)) return a;
435
654
  return null;
436
655
  };
437
- const tracks: MotionTrack[] = [];
656
+ const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
657
+ const covered = coveredChains(sw.switches, n);
658
+ const range: RangeDemand[] = [];
659
+ const order = map.chains.flatMap((c) => c.bones.map((b) => ({ c, b }))).filter(({ c, b }) => c.reach || b in c.range);
660
+ order.sort((x, y) => depth(x.b) - depth(y.b));
661
+ for (const { c, b } of order) {
662
+ if (c.reach && aimReach !== null) aimReach(c);
663
+ const limits = c.range[b];
664
+ if (limits === undefined) continue;
665
+ const above = mappedAbove(b);
666
+ const tg = targets.get(b)!;
667
+ const cov = covered.get(c.name);
668
+ const over: number[] = [];
669
+ const asked: number[] = [];
670
+ for (let i = 0; i < n; i++) {
671
+ const key = wrap(turn(b, i) - (above === null ? 0 : turn(above, i)));
672
+ asked.push(key);
673
+ const o = cov?.[i] === true ? 0 : overflow(key, limits);
674
+ over.push(o);
675
+ if (o > RANGE_SLACK) tg[i] = wrap(tg[i]! + Math.min(Math.max(key, limits[0]), limits[1]) - key);
676
+ }
677
+ for (const run of runsOf(over.map((o) => o > RANGE_SLACK), RANGE_GAP)) {
678
+ let worst = run[0];
679
+ for (let i = run[0]; i <= run[1]; i++) if (over[i]! > over[worst]!) worst = i;
680
+ range.push({ chain: c.name, bone: b, frames: run, seconds: secondsOf(run), maxOverflow: r3(over[worst]!), key: r3(asked[worst]!), limits });
681
+ }
682
+ }
683
+ p.throwIfAny();
684
+ const artRange = measureArt(sw.switches, map, targets, endOf, keyTimes, RANGE_GAP);
685
+
686
+ // Keys: the bone's turn less the turn of the nearest mapped bone above it.
687
+ const tracks: Array<MotionTrack | SlotTrack> = [];
438
688
  for (const c of map.chains) {
439
689
  for (const b of c.bones) {
440
690
  const above = mappedAbove(b);
441
- const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
442
691
  let prev = 0;
443
692
  const keys: MotionKey[] = [];
444
693
  for (let i = 0; i < n; i++) {
@@ -451,8 +700,94 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
451
700
  }
452
701
  }
453
702
  if (translateTrack !== null) tracks.push(translateTrack);
703
+ tracks.push(...sw.offsetTracks, ...sw.slotTracks);
704
+
705
+ return {
706
+ animation: { duration: keyTimes[n - 1]!, tracks },
707
+ times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
708
+ switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
709
+ };
710
+ }
711
+
712
+ type ShortSpan = Omit<Short, 'chain' | 'bone' | 'joints'>;
713
+
714
+ /**
715
+ * The short hold on one angle series (degrees), in place. `fraction` is the
716
+ * segment's picture length on each frame over its median. Each run of frames
717
+ * under `cutoff` has its angles replaced by the line from the last frame before
718
+ * it at or above the cutoff to the first one after it, the short way round; a
719
+ * run at the start or the end of the series holds the one reliable end flat.
720
+ * With the fraction against the median and a cutoff under 1, half the frames
721
+ * at least are at or above it, so every run has a reliable end. Returns the runs.
722
+ */
723
+ /** A line across the body: a left joint and its right twin (r_hip → l_hip, r_eye → l_eye). */
724
+ export function isAcross(a: string, b: string): boolean {
725
+ const twin = (x: string): string | null => (x.startsWith('r_') ? `l_${x.slice(2)}` : x.startsWith('l_') ? `r_${x.slice(2)}` : null);
726
+ return twin(a) === b;
727
+ }
728
+
729
+ export function holdShort(angles: number[], fraction: readonly number[], cutoff: number): ShortSpan[] {
730
+ const n = angles.length;
731
+ const out: ShortSpan[] = [];
732
+ for (let i = 0; i < n; i++) {
733
+ if (!(fraction[i]! < cutoff)) continue;
734
+ let j = i;
735
+ while (j + 1 < n && fraction[j + 1]! < cutoff) j++;
736
+ const lo = i - 1;
737
+ const hi = j + 1;
738
+ let minFraction = fraction[i]!;
739
+ for (let k = i + 1; k <= j; k++) minFraction = Math.min(minFraction, fraction[k]!);
740
+ if (lo >= 0 || hi < n) {
741
+ const a = lo >= 0 ? angles[lo]! : angles[hi]!;
742
+ const b = hi < n ? angles[hi]! : a;
743
+ const turn = wrap(b - a);
744
+ for (let k = i; k <= j; k++) angles[k] = lo < 0 ? b : wrap(a + (turn * (k - lo)) / (hi - lo));
745
+ }
746
+ out.push({ frames: [i, j], minFraction });
747
+ i = j;
748
+ }
749
+ return out;
750
+ }
751
+
752
+ /** Short spans `gap` frames apart or fewer, as one run each: its first and last frame, and the lowest fraction. */
753
+ export function mergeRuns(spans: readonly ShortSpan[], gap: number): ShortSpan[] {
754
+ const out: ShortSpan[] = [];
755
+ for (const s of spans) {
756
+ const last = out[out.length - 1];
757
+ if (last !== undefined && s.frames[0] - last.frames[1] - 1 <= gap) {
758
+ last.frames = [last.frames[0], s.frames[1]];
759
+ last.minFraction = Math.min(last.minFraction, s.minFraction);
760
+ } else out.push({ frames: [s.frames[0], s.frames[1]], minFraction: s.minFraction });
761
+ }
762
+ return out;
763
+ }
764
+
765
+ const spanOf = (frames: [number, number], times: readonly number[]): string =>
766
+ 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)`;
767
+
768
+ export function shortLine(s: Short, times: readonly number[], cutoff: number): string {
769
+ const lo = s.frames[0] - 1;
770
+ const hi = s.frames[1] + 1;
771
+ const held = lo < 0 && hi >= times.length
772
+ ? 'no frame of the pose is at or above it, so nothing was held'
773
+ : lo < 0 ? `held flat at frame ${hi}'s angle (the pose starts short)`
774
+ : hi >= times.length ? `held flat at frame ${lo}'s angle (the pose ends short)`
775
+ : `interpolated from frame ${lo}'s angle to frame ${hi}'s, the short way round`;
776
+ 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}`;
777
+ }
778
+
779
+ export function turnLine(e: Turn, times: readonly number[]): string {
780
+ const how = e.reversed ? `reversed (pointing more than 90° from ${e.joints[0]} → ${e.joints[1]}'s own way), picture length down to ${e.minFraction.toFixed(2)} of its median` : `picture length down to ${e.minFraction.toFixed(2)} of its median, under ${TURN_FRACTION}`;
781
+ return `line "${e.line}" (${e.joints[0]} → ${e.joints[1]}) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s ${how}, ≥ ${TURN_SECONDS}s — the body or head turned, a pose the rig cannot draw`;
782
+ }
783
+
784
+ export function rangeLine(e: RangeDemand, times: readonly number[]): string {
785
+ const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
786
+ return `chain "${e.chain}" bone "${e.bone}" ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s the pose asks its rotate key past its range [${e.limits[0]}, ${e.limits[1]}] (${sg(e.key)}, up to ${e.maxOverflow.toFixed(1)}° past); clamped at the limit — demand for an alternate`;
787
+ }
454
788
 
455
- return { animation: { duration: keyTimes[n - 1]!, tracks }, times: keyTimes, targets, origin, holds, bends, flips, scale };
789
+ export function endOnLine(e: EndOn, times: readonly number[], cutoff: number): string {
790
+ 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
791
  }
457
792
 
458
793
  type Spread = Omit<Flip, 'chain' | 'bone' | 'joints'>;
package/src/rigc.ts CHANGED
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'no
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { poseRawSetup, readModel, type CompiledDocument } from 'rig-c/core';
16
- import type { Rest, RestBone } from './map.ts';
16
+ import type { Rest, RestBone, RigSlot, RigSlots } from './map.ts';
17
17
  import { isObject, parseJson, Problems, Refusal, typeName } from './problems.ts';
18
18
 
19
19
  export interface RigInput {
@@ -137,6 +137,41 @@ export function restOf(doc: CompiledDocument): Rest {
137
137
  return out;
138
138
  }
139
139
 
140
+ /** Every slot of the build: what it shows at setup, as the compiled model states it, and every attachment any skin gives it. */
141
+ export function slotsOf(doc: CompiledDocument): RigSlots {
142
+ const out = new Map<string, RigSlot>();
143
+ for (const s of doc.slots) {
144
+ const attachments = new Set<string>();
145
+ for (const k of doc.skins) for (const name of Object.keys(k.attachments[s.name] ?? {})) attachments.add(name);
146
+ out.set(s.name, { setup: s.setup, attachments });
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * The bones that move an attachment: a weighted mesh's bindings (a linked mesh
153
+ * its source's), or the slot's bone for anything else — what an uncovered fold
154
+ * names its chains by.
155
+ */
156
+ export function attachmentBones(doc: CompiledDocument, slot: string, attachment: string): string[] {
157
+ const slotBone = doc.slots.find((s) => s.name === slot)?.bone;
158
+ const find = (sl: string, name: string) => {
159
+ for (const k of doc.skins) {
160
+ const g = k.attachments[sl]?.[name]?.geometry;
161
+ if (g !== undefined) return g;
162
+ }
163
+ return undefined;
164
+ };
165
+ let g = find(slot, attachment);
166
+ if (g?.kind === 'linkedmesh') g = find(g.slot, g.source);
167
+ if (g !== undefined && 'vertices' in g && g.vertices.weighted) {
168
+ const bones = new Set<string>();
169
+ for (const v of g.vertices.bindings) for (const b of v) bones.add(b.bone);
170
+ return [...bones];
171
+ }
172
+ return slotBone === undefined ? [] : [slotBone];
173
+ }
174
+
140
175
  export interface MeshFolds {
141
176
  slot: string;
142
177
  attachment: string;
package/src/run.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * `rig-pose retarget`, end to end: read, gate the rig as it is, retarget, gate
3
- * it with the animation in, measure where the rig landed, read the folds, and
4
- * write — only when every check passed.
2
+ * `rig-pose retarget`, end to end: read, gate the rig as it is, retarget (the
3
+ * switches and the range clamp included), gate it with the animation in,
4
+ * measure where the rig landed, read the folds (and which no switch covers),
5
+ * and write — only when every check passed.
5
6
  */
6
7
  import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
8
  import { tmpdir } from 'node:os';
@@ -9,8 +10,9 @@ import { dirname, join, resolve } from 'node:path';
9
10
  import { readMap, resolveMap } from './map.ts';
10
11
  import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
11
12
  import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
12
- import { bendLine, flipLine, holdLine, retarget, type RetargetOptions } from './retarget.ts';
13
- import { baseMotionOf, buildInto, folds, gate, readBuild, resolveRig, restOf } from './rigc.ts';
13
+ import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
14
+ import { attachmentBones, baseMotionOf, buildInto, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
15
+ import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
14
16
  import { landed, measureLanding, measurePosture, poseFrames, postureLines, ANGLE_TOLERANCE, ORIGIN_TOLERANCE } from './verify.ts';
15
17
 
16
18
  export interface RetargetArgs extends RetargetOptions {
@@ -74,15 +76,29 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
74
76
  if (!g0.green) {
75
77
  refuse([{ code: 'RIG_GATE_BASE', text: `${input.rig} with ${input.motion ?? 'no motion spec'} is red under rigc build --profile ${a.profile} before any animation is added (exit ${g0.run.status})` }, ...g0.fails.map((f) => ({ code: 'RIGC', text: f }))]);
76
78
  }
77
- const rest = restOf(readBuild(g0.build));
78
- resolveMap(map, rest, a.map);
79
+ const doc0 = readBuild(g0.build);
80
+ const rest = restOf(doc0);
81
+ const slots = slotsOf(doc0);
82
+ resolveMap(map, rest, a.map, slots);
79
83
 
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}°)`);
84
+ const r = retarget(rest, map, frames, times, a, slots);
85
+ 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
86
  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
87
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
88
+ if (a.short !== 'off') {
89
+ for (const s of r.short) say(` SHORT ${shortLine(s, r.times, a.short)}`);
90
+ for (const e of r.endOn) say(` END-ON ${endOnLine(e, r.times, a.short)}`);
91
+ }
84
92
  if (a.flip !== 'off') for (const fl of r.flips) say(` FLIP ${flipLine(fl, r.times, a.flip)}`);
85
93
  for (const e of r.bends) say(` BEND ${bendLine(e)} — clamped straight`);
94
+ for (const e of r.turns) say(` TURN ${turnLine(e, r.times)}`);
95
+ for (const u of r.turnsUnread) say(` TURN line "${u.line}" (${u.joints[0]} → ${u.joints[1]}) not measured: ${u.why}`);
96
+ if (map.alternates.length > 0) {
97
+ if (r.switches.length === 0) say(` SWITCH none — no set within ${a.switchTolerance}° of the pose for ${a.switchMin}s`);
98
+ for (const s of r.switches) say(` SWITCH ${switchLine(s, r.times)}`);
99
+ }
100
+ for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
101
+ for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
86
102
 
87
103
  const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
88
104
  const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile);
@@ -115,6 +131,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
115
131
  else if (f.meshes === null) say(` FOLD rigc render --folds did not read (exit ${f.run.status}); folds not measured`);
116
132
  else if (f.meshes.length === 0) say(` FOLD rigc render --folds at ${fps} fps: no mesh folds`);
117
133
  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)}`);
134
+ const uncovered = f.meshes === null ? null : uncoveredFolds(f.meshes, r.shown, r.times, slots, (slot, att) => chainsMoving(attachmentBones(doc, slot, att), rest, map));
135
+ if (uncovered !== null && map.alternates.length > 0) {
136
+ for (const u of uncovered) say(` UNCOVERED mesh "${u.attachment}" (slot "${u.slot}"; chains ${u.chains.length === 0 ? 'none' : u.chains.join(', ')}) folds with no switch covering it on ${u.times.length} frame(s): t=${spans(u.times, 1 / fps)}`);
137
+ }
118
138
 
119
139
  if (a.build !== undefined) {
120
140
  // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
@@ -134,15 +154,22 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
134
154
  animation: a.name,
135
155
  frames: seq.frames.length,
136
156
  duration: r.animation.duration,
137
- options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, bends: a.bends, bendTolerance: a.bendTolerance, 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, profile: a.profile },
138
158
  scale: r.scale,
139
159
  holds: r.holds,
160
+ short: r.short,
161
+ endOn: r.endOn,
140
162
  bends: r.bends,
141
163
  flips: r.flips,
142
164
  gate: g1.summary,
143
165
  landings,
144
166
  posture,
145
167
  folds: f.meshes,
168
+ switches: r.switches,
169
+ uncovered,
170
+ turns: r.turns,
171
+ range: r.range,
172
+ artRange: r.artRange,
146
173
  }, null, 2)}\n`);
147
174
  }
148
175
  say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);