@rig-c/pose 0.3.0 → 0.5.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
@@ -39,20 +39,39 @@
39
39
  * target is aimed later, from posed positions, and is neither held short nor
40
40
  * guarded.
41
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.
58
+ *
42
59
  * What this cannot know is held to account afterwards: `verify.ts` poses the
43
60
  * gated build with rig-c's poser and measures where each bone landed.
44
61
  */
45
62
  import { screenToSpineDegrees } from 'rig-c/transform';
46
63
  import type { Body18 } from './joints.ts';
47
64
  import { isBody18, VIRTUAL } from './joints.ts';
48
- import type { Chain, JointMap, Rest } from './map.ts';
65
+ import type { Chain, JointMap, Rest, RigSlots } from './map.ts';
49
66
  import { aimAngle, rigPoint } from './map.ts';
50
67
  import type { Joint } from './pose.ts';
51
68
  import { Problems } from './problems.ts';
69
+ import type { ArtRange, SlotTrack, Switch, SwitchOptions } from './switch.ts';
70
+ import { clampArt, coveredChains, measureArt, overflow, RANGE_SLACK, runsOf, selectSwitches } from './switch.ts';
52
71
 
53
72
  const DEG = 180 / Math.PI;
54
73
 
55
- export interface RetargetOptions {
74
+ export interface RetargetOptions extends SwitchOptions {
56
75
  /** A joint with a score below this is not good on that frame (a joint with no score is). */
57
76
  minScore: number;
58
77
  /** Savitzky–Golay window over joint positions, in frames: 1 = none, else odd and ≥ 5. */
@@ -145,6 +164,8 @@ export const END_ON_GAP = 1;
145
164
  export interface MotionKey {
146
165
  t: number;
147
166
  v: number[];
167
+ /** `stepped`: the value holds until the next key (the offsets' translate keys). */
168
+ ease?: 'stepped';
148
169
  }
149
170
  export interface MotionTrack {
150
171
  bone: string;
@@ -153,9 +174,51 @@ export interface MotionTrack {
153
174
  }
154
175
  export interface Animation {
155
176
  duration: number;
156
- tracks: MotionTrack[];
177
+ /** The bone tracks, then any slot `attachment` tracks the switches add (`switch.ts`). */
178
+ tracks: Array<MotionTrack | SlotTrack>;
157
179
  }
158
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;
194
+ }
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
+
159
222
  export interface Retarget {
160
223
  animation: Animation;
161
224
  /** Key times, seconds from the first frame, as written. */
@@ -173,6 +236,20 @@ export interface Retarget {
173
236
  flips: Flip[];
174
237
  /** The figure scale, rig units per picture pixel, and the lengths it came from. */
175
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[];
176
253
  }
177
254
 
178
255
  type Pt = [number, number];
@@ -286,7 +363,7 @@ const r3 = (v: number) => {
286
363
  * good, when a hinge's side cannot be read off a straight rest, and — with
287
364
  * `bends: 'refuse'` — when a hinge bends the wrong way.
288
365
  */
289
- 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 {
290
367
  const p = new Problems();
291
368
  const n = frames.length;
292
369
  const t0 = times[0]!;
@@ -294,6 +371,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
294
371
  for (let i = 1; i < n; i++) {
295
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`);
296
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;
297
377
 
298
378
  // Every point the map reads, held where it is not good.
299
379
  const needed = new Set<string>();
@@ -305,15 +385,13 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
305
385
  const series = new Map<string, Pt[]>();
306
386
  const good = new Map<string, boolean[]>();
307
387
  const holds: Hold[] = [];
308
- 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 } => {
309
390
  const raw = frames.map((f) => pointOn(f, name, opts.minScore));
310
391
  const firstGood = raw.findIndex((v) => typeof v !== 'string');
311
- if (firstGood < 0) {
312
- const why = [...new Set(raw as string[])].slice(0, 3).join('; ');
313
- 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`);
314
- continue;
315
- }
392
+ if (firstGood < 0) return { never: [...new Set(raw as string[])].slice(0, 3).join('; ') };
316
393
  const out: Pt[] = [];
394
+ const spans: Hold[] = [];
317
395
  let last = firstGood;
318
396
  let span: Hold | null = null;
319
397
  for (let i = 0; i < n; i++) {
@@ -328,14 +406,66 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
328
406
  if (span !== null && span.source === last && span.why === v) span.to = i;
329
407
  else {
330
408
  span = { point: name, from: i, to: i, source: last, why: v };
331
- holds.push(span);
409
+ spans.push(span);
332
410
  }
333
411
  }
334
- series.set(name, savitzkyGolay(out, opts.smooth));
335
- 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);
336
423
  }
337
424
  p.throwIfAny();
338
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
+
339
469
  // The hip: the travel of one point, scaled, in its parent's axes. It comes
340
470
  // first, because a reach is aimed from where the hip has put its bone.
341
471
  let origin: Retarget['origin'] = null;
@@ -416,8 +546,6 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
416
546
  const endOn: EndOn[] = [];
417
547
  if (opts.short !== 'off') {
418
548
  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
549
  for (const c of map.chains) {
422
550
  if (c.reach) continue;
423
551
  c.bones.forEach((b, k) => {
@@ -474,6 +602,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
474
602
  return d;
475
603
  };
476
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;
477
606
  if (reaches.length > 0 && t !== null && scale !== null) {
478
607
  const s = scale.factor;
479
608
  const tb = rest.get(t.bone)!;
@@ -495,7 +624,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
495
624
  const oy = rest.get(b)!.worldY - rest.get(parent)!.worldY;
496
625
  return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy, po[1] + Math.sin(r) * ox + Math.cos(r) * oy];
497
626
  };
498
- for (const c of reaches) {
627
+ aimReach = (c: Chain): void => {
499
628
  const b = c.bones[0]!;
500
629
  const anchor = series.get(c.joints[0]!)!;
501
630
  const end = series.get(c.joints[1]!)!;
@@ -509,20 +638,57 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
509
638
  }
510
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`));
511
640
  targets.set(b, angles);
512
- }
641
+ };
642
+ for (const c of reaches) aimReach(c);
513
643
  }
514
644
  p.throwIfAny();
515
645
 
516
- // 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.
517
652
  const mappedAbove = (b: string): string | null => {
518
653
  for (let a = rest.get(b)!.parent; a !== null; a = rest.get(a)!.parent) if (targets.has(a)) return a;
519
654
  return null;
520
655
  };
521
- 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
+ clampArt(sw.switches, map, targets);
686
+
687
+ // Keys: the bone's turn less the turn of the nearest mapped bone above it.
688
+ const tracks: Array<MotionTrack | SlotTrack> = [];
522
689
  for (const c of map.chains) {
523
690
  for (const b of c.bones) {
524
691
  const above = mappedAbove(b);
525
- const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
526
692
  let prev = 0;
527
693
  const keys: MotionKey[] = [];
528
694
  for (let i = 0; i < n; i++) {
@@ -535,8 +701,13 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
535
701
  }
536
702
  }
537
703
  if (translateTrack !== null) tracks.push(translateTrack);
704
+ tracks.push(...sw.offsetTracks, ...sw.slotTracks);
538
705
 
539
- return { animation: { duration: keyTimes[n - 1]!, tracks }, times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale };
706
+ return {
707
+ animation: { duration: keyTimes[n - 1]!, tracks },
708
+ times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
709
+ switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
710
+ };
540
711
  }
541
712
 
542
713
  type ShortSpan = Omit<Short, 'chain' | 'bone' | 'joints'>;
@@ -606,6 +777,16 @@ export function shortLine(s: Short, times: readonly number[], cutoff: number): s
606
777
  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
778
  }
608
779
 
780
+ export function turnLine(e: Turn, times: readonly number[]): string {
781
+ 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}`;
782
+ 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`;
783
+ }
784
+
785
+ export function rangeLine(e: RangeDemand, times: readonly number[]): string {
786
+ const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
787
+ 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`;
788
+ }
789
+
609
790
  export function endOnLine(e: EndOn, times: readonly number[], cutoff: number): string {
610
791
  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`;
611
792
  }
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, endOnLine, flipLine, holdLine, retarget, shortLine, 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,10 +76,12 @@ 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);
84
+ const r = retarget(rest, map, frames, times, a, slots);
81
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)}`);
@@ -87,6 +91,14 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
87
91
  }
88
92
  if (a.flip !== 'off') for (const fl of r.flips) say(` FLIP ${flipLine(fl, r.times, a.flip)}`);
89
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)}`);
90
102
 
91
103
  const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
92
104
  const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile);
@@ -119,6 +131,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
119
131
  else if (f.meshes === null) say(` FOLD rigc render --folds did not read (exit ${f.run.status}); folds not measured`);
120
132
  else if (f.meshes.length === 0) say(` FOLD rigc render --folds at ${fps} fps: no mesh folds`);
121
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
+ }
122
138
 
123
139
  if (a.build !== undefined) {
124
140
  // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
@@ -138,7 +154,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
138
154
  animation: a.name,
139
155
  frames: seq.frames.length,
140
156
  duration: r.animation.duration,
141
- options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, 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 },
142
158
  scale: r.scale,
143
159
  holds: r.holds,
144
160
  short: r.short,
@@ -149,6 +165,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
149
165
  landings,
150
166
  posture,
151
167
  folds: f.meshes,
168
+ switches: r.switches,
169
+ uncovered,
170
+ turns: r.turns,
171
+ range: r.range,
172
+ artRange: r.artRange,
152
173
  }, null, 2)}\n`);
153
174
  }
154
175
  say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);