@rig-c/pose 0.15.0 → 0.17.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/run.ts CHANGED
@@ -14,11 +14,11 @@ import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './
14
14
  import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
15
15
  import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type MotionTrack, type RetargetOptions } from './retarget.ts';
16
16
  import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
17
- import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
17
+ import { artRangeLine, chainsMoving, raisedLine, RAISED_WITHIN, reachLine, switchLine, uncoveredFolds } from './switch.ts';
18
18
  import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
19
19
  import { legsOf } from './legs.ts';
20
20
  import { reduceKeys, type ReduceBone, type Reduced } from './reduce.ts';
21
- import type { LegScale, Stance } from './legs.ts';
21
+ import type { BlendHold, Floor, LegScale, Stance } from './legs.ts';
22
22
 
23
23
  export interface RetargetArgs extends RetargetOptions {
24
24
  rig: string;
@@ -108,7 +108,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
108
108
  resolveMap(map, rest, a.map, slots);
109
109
 
110
110
  const r = retarget(rest, map, frames, times, a, slots);
111
- 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}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
111
+ 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}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.raisedByHead === true ? ', raised-by-head' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
112
112
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
113
113
  say(` CLEAN ${cleanLine(seq.cleaned!)}`);
114
114
  if (a.smoothAlias !== undefined) say(` SMOOTH --smooth is carried by clean; ignored (given ${a.smoothAlias}; this sequence was cleaned with legs ${seq.cleaned!.params.legs}, rest ${seq.cleaned!.params.rest})`);
@@ -130,6 +130,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
130
130
  for (const l of windowLines(map, r.switches, r.distances, r.times, a.switchTolerance)) say(` FRAMES ${l}`);
131
131
  }
132
132
  for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
133
+ if (a.raisedByHead === true && r.raised.length === 0) say(` RAISED none — no set drawing an arm within ${RAISED_WITHIN}° of straight up showed; no key moved`);
134
+ for (const e of r.raised) say(` RAISED ${raisedLine(e, r.times)}`);
135
+ for (const e of r.raised) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
133
136
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
134
137
  for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
135
138
  if (r.pivot !== null) say(` PIVOT bone "${r.pivot.bone}"'s origin is ${r.pivot.distance.toFixed(2)} units (${r.pivot.hipWidths.toFixed(3)} hip widths) from its thigh roots' midpoint (${r.pivot.roots.join(' + ')}): dx ${r.pivot.offset[0].toFixed(2)}, dy ${r.pivot.offset[1].toFixed(2)}, at setup — the pivot every hip turn swings the legs about`);
@@ -140,6 +143,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
140
143
  say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
141
144
  say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
142
145
  say(` FEET steps (the supporting ankle > 0.03 leg lengths a frame; re-planted after 2 frames < 0.01): ${f.steps.map((x) => `${x.side} ${span(x.frames)} → ${x.to === null ? 'the pose\'s travel' : `${x.to} supports`}, ${x.replant === null ? 'never re-planted' : `re-planted at ${x.replant}`}`).join('; ') || 'none'}`);
146
+ if (r.floor !== null) {
147
+ const legBones = legBonesOf(map);
148
+ if (legBones !== null) say(` FEET ${floorLine(r.floor, setupLegs(rest, legBones))}`);
149
+ }
150
+ if (r.blendHold !== null) for (const l of blendLines(r.blendHold, r.times)) say(` FEET ${l}`);
143
151
  }
144
152
  if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
145
153
 
@@ -225,7 +233,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
225
233
  p.throwIfAny();
226
234
 
227
235
  const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
228
- const posture = measurePosture(posed, lengths, map, r, frames, a.minScore, inferredSource(seq.cleaned));
236
+ const hips = seq.cleaned?.params.hipsInferred;
237
+ const posture = measurePosture(posed, lengths, map, r, frames, a.minScore, inferredSource(seq.cleaned), hips?.by === 'measured' ? hips : null);
229
238
  if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
230
239
  const legBones = legBonesOf(map);
231
240
  const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
@@ -273,7 +282,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
273
282
  frames: seq.frames.length,
274
283
  duration: r.animation.duration,
275
284
  cleaned: seq.cleaned,
276
- options: { minScore: a.minScore, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
285
+ options: { minScore: a.minScore, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', floor: a.floor ?? null, raisedByHead: a.raisedByHead === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
277
286
  scale: r.scale,
278
287
  holds: r.holds,
279
288
  short: r.short,
@@ -289,10 +298,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
289
298
  turns: r.turns,
290
299
  range: r.range,
291
300
  artRange: r.artRange,
301
+ raised: r.raised,
292
302
  legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
293
303
  pivot: r.pivot,
294
- stance: r.stance === null ? null : { tolerance: r.stance.tolerance, bounds: r.stance.bounds, runs: r.stance.runs, over: r.stance.over },
295
- feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, reading: feetReading },
304
+ stance: r.stance === null ? null : { tolerance: r.stance.tolerance, release: r.stance.release, minSeconds: r.stance.minSeconds, bounds: r.stance.bounds, runs: r.stance.runs, over: r.stance.over },
305
+ feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, floor: r.floor?.y ?? null, shifts: r.floor?.shifts ?? [], blend: r.blendHold === null ? null : { bounds: r.blendHold.bounds, runs: r.blendHold.runs, over: r.blendHold.over }, reading: feetReading },
296
306
  shear,
297
307
  idleDropped: idle === null ? null : idle.dropped,
298
308
  idleKeptOut: idle === null ? null : idle.keptOut,
@@ -412,7 +422,7 @@ export function cameraLine(camera: readonly CameraFrame[], times: readonly numbe
412
422
  const fastest = [...steps].sort((x, y) => Math.abs(y.d) - Math.abs(x.d) || x.to - y.to).slice(0, 3);
413
423
  const sign = (d: number) => `${d < 0 ? '−' : '+'}${Math.abs(d).toFixed(4)}`;
414
424
  const inl = camera.reduce((m, c) => Math.min(m, c.inliers), Infinity);
415
- return `${source ?? '(source not stated)'}: the sequence carries the picture's camera against frame 0's; its joints are normalised by it (p′ = (p − t) / s): s ${ss[lo]!.toFixed(4)} (frame ${lo}) … ${ss[hi]!.toFixed(4)} (frame ${hi}) over ${camera.length} frames; s changes fastest at ${fastest.map((x) => `frame ${x.to - 1}→${x.to} (t=${times[x.to]!.toFixed(3)}s) ${sign(x.d)}`).join(', ') || 'no frame (one frame only)'}${source?.startsWith('hand-labelled') === true ? '' : `; fewest background inliers ${inl}`}`;
425
+ return `${source ?? '(source not stated)'}: the sequence carries the picture's camera against frame 0's; its joints are normalised by it (p′ = (p − t) / s): s ${ss[lo]!.toFixed(4)} (frame ${lo}) … ${ss[hi]!.toFixed(4)} (frame ${hi}) over ${camera.length} frames; s changes fastest at ${fastest.map((x) => `frame ${x.to - 1}→${x.to} (t=${times[x.to]!.toFixed(3)}s) ${sign(x.d)}`).join(', ') || 'no frame (one frame only)'}${source?.startsWith('hand') === true ? '' : `; fewest background inliers ${inl}`}`;
416
426
  }
417
427
 
418
428
  /** Each leg's first bone (its root, the thigh) and last bone (whose tip is the ankle), when the map has both. */
@@ -422,6 +432,45 @@ function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { r
422
432
  return { r: { root: l.legs.r.chain.bones[0]!, ankle: l.legs.r.ankleBone }, l: { root: l.legs.l.chain.bones[0]!, ankle: l.legs.l.ankleBone } };
423
433
  }
424
434
 
435
+ /** Each side's rig leg at setup, world units: its thigh's origin to its ankle (the leg's last bone's tip), by rig-c's setup pose. */
436
+ export function setupLegs(rest: Rest, legs: { r: { root: string; ankle: string }; l: { root: string; ankle: string } }): { r: number; l: number } {
437
+ const of = (s: 'r' | 'l') => {
438
+ const o = rest.get(legs[s].root)!;
439
+ const b = rest.get(legs[s].ankle)!;
440
+ return Math.hypot(b.worldX + b.a * b.length - o.worldX, b.worldY + b.c * b.length - o.worldY);
441
+ };
442
+ return { r: of('r'), l: of('l') };
443
+ }
444
+
445
+ /**
446
+ * The FEET floor line (#85): the floor's world y and where it came from, each
447
+ * support span's shift onto it in world units and in that side's rig leg at
448
+ * setup, and the shifts' range in the two legs' mean.
449
+ */
450
+ export function floorLine(floor: Floor, leg: { r: number; l: number }): string {
451
+ const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
452
+ const sign = (v: number, d: number) => `${v < 0 ? '−' : '+'}${Math.abs(v).toFixed(d)}`;
453
+ const ss = floor.shifts.map((x) => x.shift);
454
+ const lo = Math.min(...ss);
455
+ const hi = Math.max(...ss);
456
+ const mean = (leg.r + leg.l) / 2;
457
+ return `floor: world y ${floor.y.toFixed(2)} (${floor.given ? 'given by --floor' : `the median of the ${floor.shifts.length} support span(s)' planted heights`}); each span's supporting ankle moved onto it, the hip and everything above with it — shift per span in units (leg lengths: the rig's at setup, thigh origin → ankle, r ${leg.r.toFixed(1)}, l ${leg.l.toFixed(1)} units): ${floor.shifts.map((x) => `${x.side} ${span(x.frames)} ${sign(x.shift, 2)} (${sign(x.shift / leg[x.side], 4)})`).join(', ')}; range ${sign(lo, 2)} … ${sign(hi, 2)} units = ${((hi - lo) / mean).toFixed(4)} leg lengths`;
458
+ }
459
+
460
+ /**
461
+ * The FEET blend lines (#85): one per run of blend frames whose supporting leg
462
+ * was stretched to keep its ankle on the floor, with the stretch keyed; one per
463
+ * run whose stretch fell past its bounds (`past the bounds`, as STANCE says it).
464
+ */
465
+ export function blendLines(h: BlendHold, times: readonly number[]): string[] {
466
+ const span = (x: [number, number]) => (x[0] === x[1] ? `frame ${x[0]} (t=${times[x[0]]!.toFixed(3)}s)` : `frames ${x[0]}–${x[1]} (t=${times[x[0]]!.toFixed(3)}–${times[x[1]]!.toFixed(3)}s)`);
467
+ const out: string[] = [];
468
+ for (const r of h.runs) out.push(`blend held on the floor: supporting foot ${r.side} ${span(r.frames)} — the hip blends between two derivations there, so its leg is stretched by k ${r.k.min.toFixed(4)}–${r.k.max.toFixed(4)} within [${h.bounds[0]}, ${h.bounds[1]}] to keep the ankle on the floor`);
469
+ if (h.runs.length === 0) out.push('blend held on the floor: no blend frame left a supporting ankle off the floor');
470
+ for (const o of h.over) out.push(`past the bounds: supporting foot ${o.side} ${span(o.frames)} — ${o.needed === null ? 'its ankle is not below its leg\'s root, so no stretch reaches the floor' : `the floor needed k ${o.needed.toFixed(4)}, past [${h.bounds[0]}, ${h.bounds[1]}]; held at the bound`}, the foot left up to ${o.left.toFixed(2)} units from the floor`);
471
+ return out;
472
+ }
473
+
425
474
  /**
426
475
  * The STANCE lines (#60): one per run of planted-both or lifted frames, with
427
476
  * the tolerance and the stretch keyed; one per run a stretch was capped.
@@ -429,9 +478,13 @@ function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { r
429
478
  export function stanceLines(st: Stance, times: readonly number[]): string[] {
430
479
  const span = (x: [number, number]) => (x[0] === x[1] ? `frame ${x[0]} (t=${times[x[0]]!.toFixed(3)}s)` : `frames ${x[0]}–${x[1]} (t=${times[x[0]]!.toFixed(3)}–${times[x[1]]!.toFixed(3)}s)`);
431
480
  const tol = `the tolerance ${st.tolerance} leg lengths`;
432
- const out = st.runs.map((r) => (r.state === 'both'
481
+ const release = `planted again under ${st.release} of it, ${(st.release * st.tolerance).toFixed(4)}`;
482
+ const other = (r: Stance['runs'][number]) => (r.state === 'both' ? 'lifted' : 'planted-both');
483
+ const out = st.runs.map((r) => (r.absorbed === true
484
+ ? `absorbed: free foot ${r.side} ${span(r.frames)} — read ${r.state === 'both' ? 'planted both' : 'lifted'} (its ankle up to ${r.maxHeight.toFixed(4)} leg lengths above the supporting one in the picture; ${tol}, ${release}) for ${r.frames[1] - r.frames[0] + 1} frame(s), under ${st.minSeconds} s between two ${other(r)} runs; it takes their state`
485
+ : r.state === 'both'
433
486
  ? `planted both: free foot ${r.side} ${span(r.frames)} — its ankle up to ${r.maxHeight.toFixed(4)} leg lengths above the supporting one in the picture, within ${tol}; its leg stretched by k ${r.k!.min === r.k!.max ? r.k!.max.toFixed(4) : `${r.k!.min.toFixed(4)} … ${r.k!.max.toFixed(4)}`} (largest ${r.k!.max.toFixed(4)})`
434
- : `lifted: free foot ${r.side} ${span(r.frames)} — its ankle up to ${r.maxHeight.toFixed(4)} leg lengths above the supporting one in the picture, over ${tol}; the pose's height kept`));
487
+ : `lifted: free foot ${r.side} ${span(r.frames)} — its ankle up to ${r.maxHeight.toFixed(4)} leg lengths above the supporting one in the picture, over ${tol} (${release}); the pose's height kept`));
435
488
  for (const o of st.over) out.push(`past the bounds: free foot ${o.side} ${span(o.frames)} — ${o.needed === null ? 'its ankle is not below its leg\'s root, so no stretch reaches the floor' : `the floor needed k ${o.needed.toFixed(4)}, past [${st.bounds[0]}, ${st.bounds[1]}]; held at the bound`}, the foot left up to ${o.left.toFixed(2)} units from the floor`);
436
489
  return out;
437
490
  }
package/src/switch.ts CHANGED
@@ -129,6 +129,8 @@ export interface ArtRange {
129
129
  limits: [number, number];
130
130
  /** Whether the bone was held at the limit (the set's `turn`) or followed the pose (its chain's `range` only). */
131
131
  clamped: boolean;
132
+ /** --raised-by-head (#62): on a held upper arm, the most the re-aimed forearm left the wrist short of its placed point, eye distances; `turn` and `maxOverflow` are then the upper arm's on that frame. */
133
+ shortfall?: number;
132
134
  }
133
135
 
134
136
  export interface Uncovered {
@@ -580,6 +582,9 @@ function moveText(m: Move): string {
580
582
  }
581
583
 
582
584
  export function artRangeLine(e: ArtRange, times: readonly number[]): string {
585
+ if (e.shortfall !== undefined) {
586
+ return `set "${e.set}" chain "${e.chain}" bone "${e.bone}" held at its turn (range [${e.limits[0]}, ${e.limits[1]}]) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s the re-aimed forearm leaves the wrist up to ${e.shortfall.toFixed(2)} eye distances short of its placed point (the upper arm asked ${sg(e.turn)} from drawn, ${deg(e.maxOverflow)} past) — demand for another drawing of the set`;
587
+ }
583
588
  return `set "${e.set}" chain "${e.chain}" bone "${e.bone}" turned ${sg(e.turn)} from drawn (range [${e.limits[0]}, ${e.limits[1]}]) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s, up to ${deg(e.maxOverflow)} past it; ${e.clamped ? 'held at the limit' : 'the bone follows the pose'} — demand for another drawing of the set`;
584
589
  }
585
590
 
@@ -603,3 +608,162 @@ export function clampArt(switches: readonly Switch[], map: JointMap, targets: Ma
603
608
  }
604
609
  }
605
610
  }
611
+
612
+ /**
613
+ * A set's arm is raised (rig-c/pose#62) when its `drawn` world angle on the
614
+ * chain's first bone (the upper arm) is within this many degrees of straight up.
615
+ */
616
+ export const RAISED_WITHIN = 30;
617
+
618
+ /** Whether a drawn world angle, Spine degrees, is within `RAISED_WITHIN` of straight up (90°). */
619
+ export const isRaised = (drawn: number): boolean => Math.abs(wrap(drawn - 90)) <= RAISED_WITHIN;
620
+
621
+ type P2 = [number, number];
622
+
623
+ /**
624
+ * A two-bone solve in the world (y up): the directions, degrees, of the vector
625
+ * from the shoulder to the elbow (`upper`) and from the elbow to the wrist
626
+ * (`fore`) that put the wrist on `target`, for bones of lengths `l0` and `l1`.
627
+ * `side` is the elbow's bend: +1 when the forearm turns counter-clockwise from
628
+ * the upper arm, −1 clockwise. Out of reach (farther than l0 + l1, nearer than
629
+ * |l0 − l1|, beyond a relative 1e-6) both point straight at the target and
630
+ * `reached` is false; on the shoulder, where there is no direction, `upper` is
631
+ * null. `reachLimit` puts a target inside first, so the raised solve always reaches.
632
+ */
633
+ export function twoBone(shoulder: P2, target: P2, l0: number, l1: number, side: 1 | -1): { upper: number; fore: number; reached: boolean } | { upper: null; reached: false } {
634
+ const dx = target[0] - shoulder[0];
635
+ const dy = target[1] - shoulder[1];
636
+ const d = Math.hypot(dx, dy);
637
+ if (d < 1e-9) return { upper: null, reached: false };
638
+ const theta = Math.atan2(dy, dx) * (180 / Math.PI);
639
+ const eps = 1e-6 * Math.max(1, l0 + l1);
640
+ if (d > l0 + l1 + eps || d < Math.abs(l0 - l1) - eps) return { upper: theta, fore: theta, reached: false };
641
+ const cos = Math.min(1, Math.max(-1, (l0 * l0 + d * d - l1 * l1) / (2 * l0 * d)));
642
+ const upper = theta - side * Math.acos(cos) * (180 / Math.PI);
643
+ const r = upper * (Math.PI / 180);
644
+ const ex = shoulder[0] + l0 * Math.cos(r);
645
+ const ey = shoulder[1] + l0 * Math.sin(r);
646
+ return { upper: wrap(upper), fore: Math.atan2(target[1] - ey, target[0] - ex) * (180 / Math.PI), reached: true };
647
+ }
648
+
649
+ /** Where a raised wrist is placed: the target itself, or the reach limit (rig-c/pose#62, HQ's ruling). */
650
+ export interface Placement {
651
+ point: P2;
652
+ /** `in`: the target is within reach. `far` / `near`: past l0 + l1, or nearer than |l0 − l1|, and placed on the ray from the head centre. `missed`: that ray never enters the reach; placed at the reach's point nearest the target. */
653
+ how: 'in' | 'far' | 'near' | 'missed';
654
+ }
655
+
656
+ /**
657
+ * The raised wrist's placement. The arm reaches the annulus about the shoulder
658
+ * between |l0 − l1| and l0 + l1. A target inside it is kept. Otherwise the wrist
659
+ * goes on the ray from the head centre through the target, at the point of
660
+ * that ray inside the annulus nearest the target — the reach limit along the
661
+ * head-relative direction. The ray can cross the annulus twice (out and back
662
+ * through the inner disc, or through the whole ring): of the reachable stretches
663
+ * the one nearest the target wins, a tie going to the point nearer the head. A
664
+ * ray that never enters it (`missed`) gives the annulus point nearest the
665
+ * target, on the line from the shoulder through it (toward the head centre when
666
+ * the target is on the shoulder).
667
+ */
668
+ export function reachLimit(centre: P2, target: P2, shoulder: P2, l0: number, l1: number): Placement {
669
+ const rMin = Math.abs(l0 - l1);
670
+ const rMax = l0 + l1;
671
+ const eps = 1e-9 * Math.max(1, rMax);
672
+ const dT = Math.hypot(target[0] - shoulder[0], target[1] - shoulder[1]);
673
+ if (dT >= rMin - eps && dT <= rMax + eps) return { point: target, how: 'in' };
674
+ const how = dT > rMax ? 'far' : 'near';
675
+ const vx = target[0] - centre[0];
676
+ const vy = target[1] - centre[1];
677
+ const L = Math.hypot(vx, vy);
678
+ if (L > 1e-9) {
679
+ const u: P2 = [vx / L, vy / L];
680
+ // |C + s·u − S|² = s² + 2·s·b + c
681
+ const b = u[0] * (centre[0] - shoulder[0]) + u[1] * (centre[1] - shoulder[1]);
682
+ const c = (centre[0] - shoulder[0]) ** 2 + (centre[1] - shoulder[1]) ** 2;
683
+ const roots = (r: number): [number, number] | null => {
684
+ const disc = b * b - c + r * r;
685
+ return disc < 0 ? null : [-b - Math.sqrt(disc), -b + Math.sqrt(disc)];
686
+ };
687
+ const outer = roots(rMax);
688
+ const stretches: Array<[number, number]> = [];
689
+ if (outer !== null) {
690
+ const lo = Math.max(0, outer[0]);
691
+ const hi = outer[1];
692
+ const inner = rMin > 0 ? roots(rMin) : null;
693
+ if (hi >= lo) {
694
+ if (inner === null || inner[1] <= lo || inner[0] >= hi) stretches.push([lo, hi]);
695
+ else {
696
+ if (inner[0] > lo) stretches.push([lo, inner[0]]);
697
+ if (inner[1] < hi) stretches.push([inner[1], hi]);
698
+ }
699
+ }
700
+ }
701
+ let best: number | null = null;
702
+ for (const [lo, hi] of stretches) {
703
+ const s = Math.min(Math.max(L, lo), hi);
704
+ if (best === null || Math.abs(s - L) < Math.abs(best - L) - 1e-9 || (Math.abs(Math.abs(s - L) - Math.abs(best - L)) <= 1e-9 && s < best)) best = s;
705
+ }
706
+ if (best !== null) return { point: [centre[0] + best * u[0], centre[1] + best * u[1]], how };
707
+ }
708
+ const from: P2 = dT > 1e-9 ? target : centre;
709
+ const k = Math.hypot(from[0] - shoulder[0], from[1] - shoulder[1]) || 1;
710
+ const r = Math.min(Math.max(dT, rMin), rMax);
711
+ return { point: [shoulder[0] + (r * (from[0] - shoulder[0])) / k, shoulder[1] + (r * (from[1] - shoulder[1])) / k], how: 'missed' };
712
+ }
713
+
714
+ /** A run of frames whose target was out of reach, one way. */
715
+ export interface ReachShort {
716
+ frames: [number, number];
717
+ how: 'far' | 'near' | 'missed';
718
+ /** |target − placed| in the rig's eye distances: the most, and the median, over the run. */
719
+ shortfall: { max: number; median: number };
720
+ }
721
+
722
+ /** One span of a raised set on one chain: its wrist placed against the head (rig-c/pose#62). */
723
+ export interface Raised {
724
+ set: string;
725
+ chain: string;
726
+ frames: [number, number];
727
+ seconds: number;
728
+ /** The unit: the eye distance's median over the span, the rig's in world units, the reference's in picture px. */
729
+ eyes: { rig: number; reference: number };
730
+ /**
731
+ * Per frame of the span, the wrist's offset from the head centre in eye
732
+ * distances (x right, y up): the reference's; where the wrist was placed (the
733
+ * target, or the reach limit); the rig's on the pose's angles; the rig's as
734
+ * keyed.
735
+ */
736
+ reference: P2[];
737
+ placed: P2[];
738
+ before: P2[];
739
+ after: P2[];
740
+ /** The largest |rig − reference| over the span, x and y, before and after; and the largest |after − placed|, eye distances. */
741
+ worst: { before: P2; after: P2; placed: number };
742
+ /** The runs of frames out of the arm's reach (the REACH line). */
743
+ short: ReachShort[];
744
+ /** The frames whose upper arm the set's `turn` held: the forearm re-aimed at the placed wrist. */
745
+ upperHeld: number[];
746
+ /** The arm's reach (from |l0 − l1| to l0 + l1, world units from the shoulder) and the farthest target from the shoulder over the span. */
747
+ reach: { min: number; max: number; farthest: number };
748
+ }
749
+
750
+ const xy = (v: P2) => `(${v[0].toFixed(2)}, ${v[1].toFixed(2)})`;
751
+ const framesText = (fs: readonly number[]): string => {
752
+ const flag: boolean[] = [];
753
+ for (const f of fs) flag[f] = true;
754
+ return runsOf(Array.from({ length: flag.length }, (_, i) => flag[i] === true), 0).map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
755
+ };
756
+
757
+ export function raisedLine(e: Raised, times: readonly number[]): string {
758
+ const med = (vs: readonly P2[]): P2 => [median(vs.map((v) => v[0])), median(vs.map((v) => v[1]))];
759
+ const held = e.upperHeld.length === 0 ? 'the upper arm within its turn on every frame' : `the upper arm held by the set's turn on ${e.upperHeld.length} frame(s) (${framesText(e.upperHeld)}), the forearm re-aimed`;
760
+ const out = e.short.length === 0 ? 'every target in reach' : `out of reach on ${e.short.reduce((k, s) => k + s.frames[1] - s.frames[0] + 1, 0)} frame(s) (REACH)`;
761
+ return `set "${e.set}" chain "${e.chain}" ${spanOf(e.frames, times)}: the wrist placed against the head; eye distance (median) rig ${e.eyes.rig.toFixed(2)} units, reference ${e.eyes.reference.toFixed(2)} px; wrist − head centre in eye distances (x right, y up), median reference ${xy(med(e.reference))}, placed ${xy(med(e.placed))}, before ${xy(med(e.before))}, after ${xy(med(e.after))}; worst |rig − reference| before ${xy(e.worst.before)}, after ${xy(e.worst.after)}; worst |after − placed| ${e.worst.placed.toFixed(2)}; ${held}; ${out}`;
762
+ }
763
+
764
+ /** The REACH line: a raised span whose target was out of the arm's reach on some frames — the proportions, not the art's range, were the limit. */
765
+ export function reachLine(e: Raised, times: readonly number[]): string {
766
+ const why = { far: `farther than l0 + l1 = ${e.reach.max.toFixed(1)}`, near: `nearer than |l0 − l1| = ${e.reach.min.toFixed(1)}`, missed: 'and the ray from the head centre never enters the reach; placed at the reach\'s point nearest it' } as const;
767
+ const runs = e.short.map((s) => `${spanOf(s.frames, times)} ${s.how === 'missed' ? 'out of reach' : why[s.how]}${s.how === 'missed' ? `, ${why.missed}` : ''}: up to ${s.shortfall.max.toFixed(2)} eye distances short (median ${s.shortfall.median.toFixed(2)})`);
768
+ return `set "${e.set}" chain "${e.chain}": the target out of the arm's reach from the shoulder — ${runs.join('; ')}; the wrist placed at the reach limit along the head-relative direction (the farthest target ${e.reach.farthest.toFixed(1)} units from the shoulder). Not ART-RANGE: the rig's proportions, not the art's range, were the limit`;
769
+ }
package/src/verify.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  import { poseRawAnimation, poseRawSetup, type CompiledDocument, type RawBone } from 'rig-c/core';
12
12
  import type { Body18 } from './joints.ts';
13
13
  import { aimAngle, pointAt, type JointMap } from './map.ts';
14
- import type { Joint } from './pose.ts';
14
+ import type { HipsInferred, Joint, Run } from './pose.ts';
15
15
  import type { Retarget } from './retarget.ts';
16
16
  import { pictureAngle, pointOn, wrap } from './retarget.ts';
17
17
 
@@ -259,6 +259,14 @@ export interface Posture {
259
259
  * how many such frames the tilt left out, and where the state came from.
260
260
  */
261
261
  hipsInferred?: { frames: number; source: string };
262
+ /**
263
+ * Present only when the hips were measured (#86, `hipsInferred.by` is
264
+ * `measured`): the method, how many frames the tilt was read on — the
265
+ * record's read, interpolated and held frames with both hips good, each
266
+ * class counted — and the record's held spans. Any other frame with an
267
+ * inferred hip is left out and counted in `hipsInferred`.
268
+ */
269
+ tiltMeasured?: { method: string; frames: number; read: number; interpolated: number; held: number; heldSpans: Run[] };
262
270
  /**
263
271
  * Sway: the x-range of mid_hip − mid_shoulder, in rig units (the reference's
264
272
  * picture px × the figure scale) and in each body's own shoulder widths.
@@ -302,8 +310,14 @@ const medianOf = (xs: number[]): number => {
302
310
  * from the clean record). On some frames only, the tilt is read over the rest
303
311
  * and the line counts the frames left out. Sway is read as before, inferred
304
312
  * hips included: it is a different measure, and the card leaves it as it is.
313
+ *
314
+ * Measured hips (#86, `measured`): the hips are inferred on every frame, and
315
+ * turned to a measured angle: read off the picture, interpolated between two
316
+ * close reads, or held from the earlier read. Those three are the measurement,
317
+ * so the tilt is read on them; any other frame with an inferred hip is left
318
+ * out and counted, as above. The line names the held spans.
305
319
  */
306
- export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: ReadonlyMap<string, number>, map: JointMap, r: Retarget, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, minScore: number, inferredSource = 'source not stated'): Posture | null {
320
+ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: ReadonlyMap<string, number>, map: JointMap, r: Retarget, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, minScore: number, inferredSource = 'source not stated', measured: Extract<HipsInferred, { by: 'measured' }> | null = null): Posture | null {
307
321
  const pelvis = map.chains.find((c) => c.joints.length === 2 && c.joints[0] === 'r_hip' && c.joints[1] === 'l_hip');
308
322
  if (pelvis === undefined || map.translate === null || r.origin === null || r.scale === null) return null;
309
323
  const s = r.scale.factor;
@@ -315,11 +329,18 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
315
329
  const refSway: number[] = [];
316
330
  const refSw: number[] = [];
317
331
  let inferred = 0;
332
+ const inClass = (rs: Run[], i: number) => rs.some(([a, b]) => a <= i && i <= b);
333
+ const counts = { read: 0, interpolated: 0, held: 0 };
318
334
  frames.forEach((f, i) => {
319
335
  const rh = at(i, 'r_hip');
320
336
  const lh = at(i, 'l_hip');
321
337
  if (rh === null || lh === null) return;
322
- if (f.r_hip?.state === 'inferred' || f.l_hip?.state === 'inferred') inferred++;
338
+ const isInferred = f.r_hip?.state === 'inferred' || f.l_hip?.state === 'inferred';
339
+ const cls = measured === null ? undefined : (['read', 'interpolated', 'held'] as const).find((c) => inClass(measured[c], i));
340
+ if (isInferred && cls !== undefined) {
341
+ counts[cls]++;
342
+ refTilt.push(pictureAngle(rh, lh));
343
+ } else if (isInferred) inferred++;
323
344
  else refTilt.push(pictureAngle(rh, lh));
324
345
  const rs = at(i, 'r_shoulder');
325
346
  const ls = at(i, 'l_shoulder');
@@ -374,6 +395,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
374
395
  tilt,
375
396
  ...(tilt === null ? { tiltWhy: why } : {}),
376
397
  ...(inferred > 0 ? { hipsInferred: { frames: inferred, source: inferredSource } } : {}),
398
+ ...(measured !== null ? { tiltMeasured: { method: measured.method, frames: counts.read + counts.interpolated + counts.held, ...counts, heldSpans: measured.held.map(([a, b]): Run => [a, b]) } } : {}),
377
399
  sway,
378
400
  };
379
401
  }
@@ -386,11 +408,14 @@ const sg = (x: number) => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
386
408
  export function postureLines(q: Posture): string[] {
387
409
  const over = (k: number) => (k === q.frames.of ? '' : ` over ${k} of ${q.frames.of} frames`);
388
410
  const t = q.tilt;
411
+ const m = q.tiltMeasured;
412
+ const heldSpans = m === undefined || m.heldSpans.length === 0 ? 'none' : m.heldSpans.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
413
+ const measured = m === undefined ? '' : `; tilt measured (${m.method}) on ${m.frames} frame(s): ${m.read} read, ${m.interpolated} interpolated, ${m.held} held (held: ${heldSpans})`;
389
414
  const left = q.hipsInferred === undefined ? '' : `; ${q.hipsInferred.frames} frame(s) with an inferred hip not read (${q.hipsInferred.source})`;
390
415
  const out = [
391
416
  t === null
392
417
  ? `hip tilt not read: ${q.tiltWhy}`
393
- : `hip tilt range (chain "${q.pelvis}", r_hip → l_hip, Spine degrees): reference ${f1(t.reference.range)}° (${sg(t.reference.min)}° … ${sg(t.reference.max)}°${over(q.frames.tilt)}), rig ${f1(t.rig.range)}° (${sg(t.rig.min)}° … ${sg(t.rig.max)}°) — rig/reference ${f3(t.ratio)}${left}`,
418
+ : `hip tilt range (chain "${q.pelvis}", r_hip → l_hip, Spine degrees): reference ${f1(t.reference.range)}° (${sg(t.reference.min)}° … ${sg(t.reference.max)}°${over(q.frames.tilt)}), rig ${f1(t.rig.range)}° (${sg(t.rig.min)}° … ${sg(t.rig.max)}°) — rig/reference ${f3(t.ratio)}${measured}${left}`,
394
419
  ];
395
420
  const w = q.sway;
396
421
  if (w === null) out.push('sway: the reference has no frame with both hips and both shoulders good; not measured');