@rig-c/pose 0.16.0 → 0.18.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
@@ -9,16 +9,16 @@ import { tmpdir } from 'node:os';
9
9
  import { dirname, join, resolve } from 'node:path';
10
10
  import { readMap, resolveMap, type JointMap, type Rest } from './map.ts';
11
11
  import { cleanLine, inferredSource } from './clean.ts';
12
- import { convertCoco17, personTrack, readPoseSequence, type CameraFrame } from './pose.ts';
12
+ import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
13
13
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
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
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
- import { legsOf } from './legs.ts';
20
- import { reduceKeys, type ReduceBone, type Reduced } from './reduce.ts';
21
- import type { LegScale, Stance } from './legs.ts';
19
+ import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
20
+ import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
21
+ import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance } from './legs.ts';
22
22
 
23
23
  export interface RetargetArgs extends RetargetOptions {
24
24
  rig: string;
@@ -107,7 +107,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
107
107
  const slots = slotsOf(doc0);
108
108
  resolveMap(map, rest, a.map, slots);
109
109
 
110
- const r = retarget(rest, map, frames, times, a, slots);
110
+ const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}) }, slots);
111
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!)}`);
@@ -133,6 +133,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
133
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
134
  for (const e of r.raised) say(` RAISED ${raisedLine(e, r.times)}`);
135
135
  for (const e of r.raised) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
136
+ if (map.alternates.some((x) => x.place !== undefined && x.place !== null) && r.byHip.length === 0) say(` BY-HIP none — no set with "place": "by-hip" showed; no key moved`);
137
+ for (const e of r.byHip) say(` BY-HIP ${raisedLine(e, r.times)}`);
138
+ for (const e of r.byHip) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
136
139
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
137
140
  for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
138
141
  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`);
@@ -142,16 +145,27 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
142
145
  say(` FEET ${r.planted ? 'planted' : 'read, not planted (no --plant-feet)'}: leg length (median hip → ankle) r ${f.legLength.r.toFixed(1)}px, l ${f.legLength.l.toFixed(1)}px; ${f.supports.length} support span(s), ${f.changes.filter((c) => c.from !== null).length} change(s) of support, ${f.jumps.length} jump(s), ${f.steps.length} step(s)`);
143
146
  say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
144
147
  say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
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'}`);
148
+ if (f.lowered !== undefined) say(` FEET ${loweredLine(f.lowered)}`);
149
+ if (r.closure !== null) say(` FEET ${travelLine(r.closure)}`);
150
+ else 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'}`);
151
+ if (r.floor !== null) {
152
+ const legBones = legBonesOf(map);
153
+ if (legBones !== null) say(` FEET ${floorLine(r.floor, setupLegs(rest, legBones))}`);
154
+ }
155
+ if (r.blendHold !== null) for (const l of blendLines(r.blendHold, r.times)) say(` FEET ${l}`);
146
156
  }
147
157
  if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
148
158
 
149
159
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
150
160
  const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
151
161
  if (reduced !== null) {
152
- for (const row of reduced.rows) say(` REDUCE ${row.kind === 'angle' ? 'bone' : 'hip'} "${row.bone}" ${row.kind === 'angle' ? 'rotate' : 'translate'}: ${row.before} → ${row.after} key(s); worst ${row.kind === 'angle' ? 'aim' : 'origin'} error ${row.worst.toPrecision(3)}${row.kind === 'angle' ? '°' : ' units'} at frame ${row.frame} (t=${r.times[row.frame]!.toFixed(3)}s)`);
153
- const sum = (k: 'before' | 'after') => reduced.rows.filter((x) => x.kind === 'angle').reduce((t, x) => t + x[k], 0);
154
- say(` REDUCE rotate keys ${sum('before')} → ${sum('after')} (${(sum('before') / sum('after')).toFixed(2)}×) within ${a.reduce}°${a.reduceHip === undefined ? '; the hip stays dense (no --reduce-hip)' : ''}`);
162
+ for (const row of reduced.rows) {
163
+ const [who, track, what, unit] = row.kind === 'angle' ? ['bone', 'rotate', 'aim', '°'] : row.kind === 'origin' ? ['hip', 'translate', 'origin', ' units'] : ['bone', 'scalex', 'scale', ' of its setup length'];
164
+ say(` REDUCE ${who} "${row.bone}" ${track}: ${row.before} → ${row.after} key(s); worst ${what} error ${row.worst.toPrecision(3)}${unit} at frame ${row.frame} (t=${r.times[row.frame]!.toFixed(3)}s)`);
165
+ }
166
+ const sum = (kind: ReduceRow['kind'], k: 'before' | 'after') => reduced.rows.filter((x) => x.kind === kind).reduce((t, x) => t + x[k], 0);
167
+ const scales = reduced.rows.some((x) => x.kind === 'scale') ? `; scalex keys ${sum('scale', 'before')} → ${sum('scale', 'after')} within ${SCALE_REDUCE} of the setup length` : '';
168
+ say(` REDUCE rotate keys ${sum('angle', 'before')} → ${sum('angle', 'after')} (${(sum('angle', 'before') / sum('angle', 'after')).toFixed(2)}×) within ${a.reduce}°${scales}${a.reduceHip === undefined ? '; the hip stays dense (no --reduce-hip)' : ''}`);
155
169
  }
156
170
 
157
171
  // The idle against the new animation (#43 item 2): what it drops, and with --carry-idle, looped back in.
@@ -204,8 +218,13 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
204
218
 
205
219
  const doc = readBuild(g1.build);
206
220
  const posed = poseFrames(doc, a.name, r.times);
207
- // A reduced track is held to the dense target plus its tolerance (#32).
208
- const landings = measureLanding(doc, a.name, map, r, posed).map((l) => (l.kind === 'angle' && a.reduce !== undefined ? { ...l, tolerance: l.tolerance + a.reduce } : l.kind === 'origin' && a.reduceHip !== undefined ? { ...l, tolerance: l.tolerance + a.reduceHip } : l));
221
+ // A reduced track is held to the dense target plus its tolerance (#32); an ankle, to what the
222
+ // reduced bones above it may move it by (#94, `footReduce`). A scale keeps its own: it is reduced within half of it.
223
+ const landings = measureLanding(doc, a.name, map, r, posed).map((l) => (a.reduce === undefined ? l
224
+ : l.kind === 'angle' ? { ...l, tolerance: l.tolerance + a.reduce }
225
+ : l.kind === 'origin' && a.reduceHip !== undefined ? { ...l, tolerance: l.tolerance + a.reduceHip }
226
+ : l.kind === 'foot' ? { ...l, tolerance: l.tolerance + footReduce(rest, r, l.bone.replace(/:tip$/, ''), a.reduce, a.reduceHip) }
227
+ : l));
209
228
  const p = new Problems();
210
229
  // A carried idle track on a bone above a mapped one moves it where the keys did not ask (the key formula holds unmapped bones at setup).
211
230
  const carriedAbove = (bone: string): string => {
@@ -228,7 +247,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
228
247
  p.throwIfAny();
229
248
 
230
249
  const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
231
- const posture = measurePosture(posed, lengths, map, r, frames, a.minScore, inferredSource(seq.cleaned));
250
+ const hips = seq.cleaned?.params.hipsInferred;
251
+ const posture = measurePosture(posed, lengths, map, r, frames, a.minScore, inferredSource(seq.cleaned), hips?.by === 'measured' ? hips : null);
232
252
  if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
233
253
  const legBones = legBonesOf(map);
234
254
  const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
@@ -276,7 +296,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
276
296
  frames: seq.frames.length,
277
297
  duration: r.animation.duration,
278
298
  cleaned: seq.cleaned,
279
- 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', raisedByHead: a.raisedByHead === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
299
+ 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' },
280
300
  scale: r.scale,
281
301
  holds: r.holds,
282
302
  short: r.short,
@@ -293,10 +313,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
293
313
  range: r.range,
294
314
  artRange: r.artRange,
295
315
  raised: r.raised,
316
+ // Only when a set declares `place` (#93): a map without one writes the report it always has.
317
+ ...(map.alternates.some((x) => x.place !== undefined && x.place !== null) ? { byHip: r.byHip } : {}),
296
318
  legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
297
319
  pivot: r.pivot,
298
- stance: r.stance === null ? null : { tolerance: r.stance.tolerance, bounds: r.stance.bounds, runs: r.stance.runs, over: r.stance.over },
299
- 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 },
320
+ stance: r.stance === null ? null : { tolerance: r.stance.tolerance, release: r.stance.release, minSeconds: r.stance.minSeconds, bounds: r.stance.bounds, ...(r.stance.range === undefined ? {} : { range: r.stance.range }), runs: r.stance.runs, over: r.stance.over },
321
+ 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, ...(r.feet.lowered === undefined ? {} : { lowered: r.feet.lowered }), floor: r.floor?.y ?? null, shifts: r.floor?.shifts ?? [], blend: r.blendHold === null ? null : { bounds: r.blendHold.bounds, ...(r.blendHold.range === undefined ? {} : { range: r.blendHold.range }), runs: r.blendHold.runs, over: r.blendHold.over }, reading: feetReading, closure: r.closure },
300
322
  shear,
301
323
  idleDropped: idle === null ? null : idle.dropped,
302
324
  idleKeptOut: idle === null ? null : idle.keptOut,
@@ -313,9 +335,18 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
313
335
 
314
336
  /**
315
337
  * The retarget's rotate tracks (and with a hip tolerance, its translate) thinned
316
- * in place (#32). Kept regardless, per bone: the frames either side of each
317
- * switch boundary on the chains it changes, and the edges of every FLIP spread,
318
- * SHORT span and RANGE run on it (the first and last frame the reducer keeps).
338
+ * in place (#32), on the final keys: the plant, the one floor, the stretches and
339
+ * the in-place closure all live inside `retarget`, so they are done (#94). Kept
340
+ * regardless, per bone: the frames either side of each switch boundary on the
341
+ * chains it changes, and the edges of every FLIP spread, SHORT span and RANGE
342
+ * run on it (the first and last frame the reducer keeps).
343
+ *
344
+ * With scaled bones (`--leg-scale`, the stretches; #94): a scaled bone's
345
+ * `scalex` is thinned within `SCALE_REDUCE`, and a mapped bone under a scaled
346
+ * bone — keyed through its parent's posed matrix (legs.ts) — is re-solved
347
+ * through its parent as reduced and thinned with its error read as the world
348
+ * aim and length it lands on (`reduceUnderScale`). With none, this is #32's
349
+ * reduction, unchanged.
319
350
  */
320
351
  function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest, tolerance: number, hipTolerance: number | undefined): Reduced {
321
352
  const n = r.times.length;
@@ -335,8 +366,19 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
335
366
  return null;
336
367
  };
337
368
  const rotate = new Map<string, MotionTrack>();
338
- for (const tr of r.animation.tracks) if ('bone' in tr && tr.property === 'rotate') rotate.set(tr.bone, tr);
339
- const bones: ReduceBone[] = [...rotate.values()].map((tr) => ({ bone: tr.bone, above: above(tr.bone), dense: tr.keys.map((k) => k.v[0]!), keep: keep.get(tr.bone) ?? new Set<number>() }));
369
+ const scalex = new Map<string, MotionTrack>();
370
+ for (const tr of r.animation.tracks) {
371
+ if (!('bone' in tr)) continue;
372
+ if (tr.property === 'rotate') rotate.set(tr.bone, tr);
373
+ else if (tr.property === 'scalex') scalex.set(tr.bone, tr);
374
+ }
375
+ /** A bone with a scaled bone above it: keyed through its parent's posed matrix (legs.ts), not by the key formula. */
376
+ const under = (b: string): boolean => {
377
+ for (let p = rest.get(b)!.parent; p !== null; p = rest.get(p)!.parent) if (scalex.has(p)) return true;
378
+ return false;
379
+ };
380
+ const dense = new Map([...rotate].map(([b, tr]) => [b, tr.keys.map((k) => k.v[0]!)]));
381
+ const bones: ReduceBone[] = [...rotate.values()].filter((tr) => !under(tr.bone)).map((tr) => ({ bone: tr.bone, above: above(tr.bone), dense: dense.get(tr.bone)!, keep: keep.get(tr.bone) ?? new Set<number>() }));
340
382
  const hipTrack = r.origin === null || hipTolerance === undefined ? undefined : r.animation.tracks.find((tr): tr is MotionTrack => 'bone' in tr && tr.property === 'translate' && tr.bone === r.origin!.bone && tr.keys.every((k) => k.ease === undefined));
341
383
  const parent = hipTrack === undefined ? null : rest.get(hipTrack.bone)!.parent;
342
384
  const pm = parent === null ? null : rest.get(parent)!;
@@ -349,7 +391,101 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
349
391
  });
350
392
  for (const [bone, keys] of out.rotate) rotate.get(bone)!.keys = keys;
351
393
  if (hipTrack !== undefined && out.hip !== null) hipTrack.keys = out.hip;
352
- return out;
394
+ if (scalex.size === 0) return out;
395
+
396
+ // #94: the scales of the bones the key formula holds for, then the bones under a scaled bone, parents first.
397
+ const rows = new Map<string, ReduceRow>();
398
+ for (const row of out.rows) if (row.kind === 'angle') rows.set(`rotate ${row.bone}`, row);
399
+ const scaleAt = new Map<string, number[]>();
400
+ const keepOf = (b: string) => keep.get(b) ?? new Set<number>();
401
+ for (const [bone, tr] of scalex) {
402
+ if (under(bone)) continue;
403
+ const red = reduceScale(r.times, bone, r.scales.get(bone) ?? tr.keys.map((k) => k.v[0]!), keepOf(bone), SCALE_REDUCE);
404
+ tr.keys = red.keys;
405
+ scaleAt.set(bone, red.at);
406
+ rows.set(`scalex ${bone}`, red.row);
407
+ }
408
+ /** A bone the key formula holds for: its world turn from setup on every frame as reduced — its dense turn (the keys summed down the mapped chain) plus its world error; an unmapped bone turns with the nearest mapped bone above it. */
409
+ const turnOf = (b: string): number[] => {
410
+ const m = mapped.has(b) ? b : above(b);
411
+ if (m === null) return new Array<number>(n).fill(0);
412
+ const denseTurn = (x: string): number[] => {
413
+ const up = above(x);
414
+ const own = dense.get(x)!;
415
+ if (up === null) return [...own];
416
+ const t = denseTurn(up);
417
+ return own.map((v, i) => v + t[i]!);
418
+ };
419
+ const e = out.errors.get(m)!;
420
+ return denseTurn(m).map((v, i) => v + e[i]!);
421
+ };
422
+ const world0 = (b: string): M2 => {
423
+ const x = rest.get(b)!;
424
+ return [x.a, x.b, x.c, x.d];
425
+ };
426
+ const depth = (b: string): number => {
427
+ let d = 0;
428
+ for (let p = rest.get(b)!.parent; p !== null; p = rest.get(p)!.parent) d++;
429
+ return d;
430
+ };
431
+ const underBones = [...rotate.keys()].filter(under).sort((x, y) => depth(x) - depth(y));
432
+ const given: UnderBone[] = underBones.map((b) => {
433
+ if (!scalex.has(b)) throw new Refusal([{ code: 'POSE_REDUCE', text: `bone "${b}" hangs under a scaled bone and has no scalex track, so it was not keyed through its parent's posed matrix; --reduce re-solves only a bone the legs keyed that way` }]);
434
+ // Up through the unmapped bones between (their setup locals), to a bone given before it or one the key formula holds for.
435
+ let via: M2 = M2_IDENTITY;
436
+ let p = rest.get(b)!.parent!;
437
+ while (under(p) && !mapped.has(p)) {
438
+ via = mulM2(setupLocal(world0(rest.get(p)!.parent!), world0(p)), via);
439
+ p = rest.get(p)!.parent!;
440
+ }
441
+ const parent: UnderBone['parent'] = under(p) ? { under: p, via } : { rigid: { turn: turnOf(p), world0: world0(p), scale: scaleAt.get(p) ?? new Array<number>(n).fill(1) }, via };
442
+ const w0 = world0(b);
443
+ return {
444
+ bone: b,
445
+ parent,
446
+ local: setupLocal(world0(rest.get(b)!.parent!), w0),
447
+ len0: Math.hypot(w0[0], w0[2]),
448
+ target: r.targets.get(b)!,
449
+ asked: r.scales.get(b) ?? new Array<number>(n).fill(1),
450
+ dense: dense.get(b)!,
451
+ keep: keepOf(b),
452
+ };
453
+ });
454
+ const red = reduceUnderScale(r.times, given, tolerance, SCALE_REDUCE);
455
+ for (const [bone, keys] of red.rotate) rotate.get(bone)!.keys = keys;
456
+ for (const [bone, keys] of red.scale) scalex.get(bone)!.keys = keys;
457
+ for (const row of red.rows) rows.set(`${row.kind === 'angle' ? 'rotate' : 'scalex'} ${row.bone}`, row);
458
+ // The rows: every rotate track in the animation's order, the hip, then every scalex track.
459
+ const hipRow = out.rows.filter((x) => x.kind === 'origin');
460
+ return {
461
+ ...out,
462
+ rows: [...[...rotate.keys()].map((b) => rows.get(`rotate ${b}`)!), ...hipRow, ...[...scalex.keys()].map((b) => rows.get(`scalex ${b}`)!)],
463
+ };
464
+ }
465
+
466
+ /**
467
+ * How far `--reduce` may move an ankle (the tip of `ankle`), world units (#94):
468
+ * the hip's tolerance when its translate is reduced, plus every segment from
469
+ * the legs' top down to the ankle — each bone's setup offset from its parent,
470
+ * and the ankle bone's length — times the largest scale asked of any bone, each
471
+ * turned by at most the angle tolerance (a vector of length ℓ turned by θ moves
472
+ * its end ≤ ℓ·θ) and lengthened by at most `SCALE_REDUCE`. Every reduced bone's
473
+ * world aim is within the angle tolerance of its dense one, top-down, so the
474
+ * segments' errors are each bounded and the sum bounds the ankle's.
475
+ */
476
+ export function footReduce(rest: Rest, r: ReturnType<typeof retarget>, ankle: string, tolerance: number, hipTolerance: number | undefined): number {
477
+ const top = r.origin?.bone ?? null;
478
+ let sMax = 1;
479
+ for (const s of r.scales.values()) for (const v of s) sMax = Math.max(sMax, v);
480
+ const a = rest.get(ankle)!;
481
+ let lever = a.length * Math.hypot(a.a, a.c);
482
+ for (let b: string | null = ankle; b !== null && b !== top; b = rest.get(b)!.parent) {
483
+ const x = rest.get(b)!;
484
+ const p = x.parent === null ? null : rest.get(x.parent)!;
485
+ if (p === null) break;
486
+ lever += Math.hypot(x.worldX - p.worldX, x.worldY - p.worldY);
487
+ }
488
+ return (hipTolerance ?? 0) + lever * sMax * ((tolerance * Math.PI) / 180 + SCALE_REDUCE);
353
489
  }
354
490
 
355
491
  function spans(ts: number[], step: number): string {
@@ -416,7 +552,7 @@ export function cameraLine(camera: readonly CameraFrame[], times: readonly numbe
416
552
  const fastest = [...steps].sort((x, y) => Math.abs(y.d) - Math.abs(x.d) || x.to - y.to).slice(0, 3);
417
553
  const sign = (d: number) => `${d < 0 ? '−' : '+'}${Math.abs(d).toFixed(4)}`;
418
554
  const inl = camera.reduce((m, c) => Math.min(m, c.inliers), Infinity);
419
- 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}`}`;
555
+ 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}`}`;
420
556
  }
421
557
 
422
558
  /** Each leg's first bone (its root, the thigh) and last bone (whose tip is the ankle), when the map has both. */
@@ -426,20 +562,108 @@ function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { r
426
562
  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 } };
427
563
  }
428
564
 
565
+ /** 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. */
566
+ export function setupLegs(rest: Rest, legs: { r: { root: string; ankle: string }; l: { root: string; ankle: string } }): { r: number; l: number } {
567
+ const of = (s: 'r' | 'l') => {
568
+ const o = rest.get(legs[s].root)!;
569
+ const b = rest.get(legs[s].ankle)!;
570
+ return Math.hypot(b.worldX + b.a * b.length - o.worldX, b.worldY + b.c * b.length - o.worldY);
571
+ };
572
+ return { r: of('r'), l: of('l') };
573
+ }
574
+
429
575
  /**
430
- * The STANCE lines (#60): one per run of planted-both or lifted frames, with
431
- * the tolerance and the stretch keyed; one per run a stretch was capped.
576
+ * The FEET floor line (#85): the floor's world y and where it came from, each
577
+ * support span's shift onto it in world units and in that side's rig leg at
578
+ * setup, and the shifts' range in the two legs' mean.
579
+ */
580
+ export function floorLine(floor: Floor, leg: { r: number; l: number }): string {
581
+ const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
582
+ const sign = (v: number, d: number) => `${v < 0 ? '−' : '+'}${Math.abs(v).toFixed(d)}`;
583
+ const ss = floor.shifts.map((x) => x.shift);
584
+ const lo = Math.min(...ss);
585
+ const hi = Math.max(...ss);
586
+ const mean = (leg.r + leg.l) / 2;
587
+ 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`;
588
+ }
589
+
590
+ /**
591
+ * The FEET blend lines (#85): one per run of blend frames whose supporting leg
592
+ * was stretched to keep its ankle on the floor, with the stretch keyed; one per
593
+ * run whose stretch fell past its bounds (`past the bounds`, as STANCE says it).
594
+ */
595
+ /**
596
+ * The lower-ankle line (rig-c/pose#113): under --plant-feet on a sequence not
597
+ * declared in place, the frames whose supporting foot differs from the reading
598
+ * before #113 — today's side, then the side read from the lower ankle ("none":
599
+ * no foot supports, the hip follows the pose).
600
+ */
601
+ const CAUSE: Record<NonNullable<Lowered['cause']>, string> = {
602
+ released: 'released: the lower ankle could not take support, released by a step or moving',
603
+ higher: 'the only foot free to take support was the higher one',
604
+ };
605
+ function loweredLine(lowered: readonly Lowered[]): string {
606
+ const side = (s: Side | null) => s ?? 'none';
607
+ const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
608
+ const head = `support read from the lower ankle (a foot ≥ ${PLANT_SWITCH} leg lengths higher than the other does not take support, rig-c/pose#113)`;
609
+ if (lowered.length === 0) return `${head}: no frame reads differently from before`;
610
+ const frames = lowered.reduce((k, x) => k + x.frames[1] - x.frames[0] + 1, 0);
611
+ return `${head}: ${frames} frame(s) read differently from before — ${lowered.map((x) => `frames ${span(x.frames)} ${side(x.was)} → ${side(x.now)}${x.cause === undefined ? '' : ` (${CAUSE[x.cause]})`}`).join(', ')}`;
612
+ }
613
+
614
+ /** The in-place line (rig-c/pose#92): what --plant-feet did instead of holding the foot, and the loop's closure. */
615
+ export function travelLine(c: Closure): string {
616
+ const per = c.frames > 1 ? Math.abs(c.drift) / (c.frames - 1) : 0;
617
+ return `in place (the sequence's travel "${TRAVEL_IN_PLACE}"): each supporting foot follows its own ankle's picture x, scaled, instead of being held where it planted, and no step releases it; the hip's x travelled ${c.drift >= 0 ? '+' : ''}${c.drift.toFixed(2)} units from the first frame to the last, closed — spread back over the ${c.frames} frames (${per.toFixed(3)} units a frame), so the last frame's hip x is the first's`;
618
+ }
619
+
620
+ export function blendLines(h: BlendHold, times: readonly number[]): string[] {
621
+ 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)`);
622
+ const out: string[] = [];
623
+ 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 ${boundText([r.lower ?? h.bounds[0], h.bounds[1]])} to keep the ankle on the floor`);
624
+ if (h.runs.length === 0) out.push('blend held on the floor: no blend frame left a supporting ankle off the floor');
625
+ if (h.range !== undefined) out.push(legRangeLine('supporting', h.range, h.bounds));
626
+ 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 ${boundText(o.bound ?? h.bounds)}; held at the bound`}, the foot left up to ${o.left.toFixed(2)} units from the floor`);
627
+ return out;
628
+ }
629
+
630
+ /**
631
+ * The STANCE lines (#60): one per run of planted-both, lifted or (#114) below
632
+ * frames, with the tolerance and the stretch keyed; one per run a stretch was
633
+ * capped.
432
634
  */
433
635
  export function stanceLines(st: Stance, times: readonly number[]): string[] {
434
636
  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)`);
435
637
  const tol = `the tolerance ${st.tolerance} leg lengths`;
436
- const out = st.runs.map((r) => (r.state === 'both'
638
+ const release = `planted again under ${st.release} of it, ${(st.release * st.tolerance).toFixed(4)}`;
639
+ const NAME: Record<Stance['runs'][number]['state'], string> = { both: 'planted-both', lifted: 'lifted', below: 'below' };
640
+ // An absorbed run lies inside a run of the state it took; the line names that state.
641
+ const around = (r: Stance['runs'][number]) => st.runs.find((x) => x.absorbed !== true && x.side === r.side && x.frames[0] <= r.frames[0] && r.frames[1] <= x.frames[1]);
642
+ const other = (r: Stance['runs'][number]) => NAME[around(r)?.state ?? (r.state === 'both' ? 'lifted' : 'both')];
643
+ const where = (r: Stance['runs'][number]) => (r.state === 'below' ? `its ankle up to ${r.maxDepth!.toFixed(4)} leg lengths below the supporting one in the picture` : `its ankle up to ${r.maxHeight.toFixed(4)} leg lengths above the supporting one in the picture`);
644
+ const out = st.runs.map((r) => (r.absorbed === true
645
+ ? `absorbed: free foot ${r.side} ${span(r.frames)} — read ${r.state === 'both' ? 'planted both' : r.state === 'below' ? 'past the tolerance below' : 'lifted'} (${where(r)}; ${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`
646
+ : r.state === 'both'
437
647
  ? `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)})`
438
- : `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`));
439
- 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`);
648
+ : r.state === 'below'
649
+ ? `past the tolerance below: free foot ${r.side} ${span(r.frames)} — ${where(r)}, past ${tol} (${release}); a free foot that far below the supporting one is a support error, not a stance: never planted both, the pose's height kept`
650
+ : `lifted: free foot ${r.side} ${span(r.frames)} — ${where(r)}, over ${tol} (${release}); the pose's height kept`));
651
+ if (st.range !== undefined) out.push(legRangeLine('free', st.range, st.bounds));
652
+ 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 ${boundText(o.bound ?? st.bounds)}; held at the bound`}, the foot left up to ${o.left.toFixed(2)} units from the floor`);
440
653
  return out;
441
654
  }
442
655
 
656
+ /** A bound as the lines print it: the default's figures as written, a derived lower bound to 4 places (#95). */
657
+ function boundText(b: readonly [number, number]): string {
658
+ return `[${b[0] === STANCE_BOUNDS[0] ? `${b[0]}` : b[0].toFixed(4)}, ${b[1]}]`;
659
+ }
660
+
661
+ /** The #95 line: where the picture folds a leg below the default lower bound, that fold is the frame's lower bound. */
662
+ function legRangeLine(which: 'free' | 'supporting', range: Partial<Record<'r' | 'l', number>>, bounds: readonly [number, number]): string {
663
+ const sides = (['r', 'l'] as const).filter((s) => range[s] !== undefined).map((s) => `${s} ${range[s]!.toFixed(4)}`);
664
+ return `bound from the leg's own range: on a frame where the picture folds the ${which} leg below ${bounds[0]} of its straight length (hip → ankle, its 95th percentile), the lower bound is that fold — lowest ${sides.join(', ')}; the upper stays ${bounds[1]}`;
665
+ }
666
+
443
667
  /** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped. */
444
668
  function legScaleLine(l: LegScale, times: readonly number[]): string {
445
669
  const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
package/src/switch.ts CHANGED
@@ -131,6 +131,8 @@ export interface ArtRange {
131
131
  clamped: boolean;
132
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
133
  shortfall?: number;
134
+ /** `place: "by-hip"` (#93): the shortfall is in hip widths, the wrist placed against the hip. Absent: against the head, eye distances. */
135
+ anchor?: 'hip';
134
136
  }
135
137
 
136
138
  export interface Uncovered {
@@ -583,7 +585,7 @@ function moveText(m: Move): string {
583
585
 
584
586
  export function artRangeLine(e: ArtRange, times: readonly number[]): string {
585
587
  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`;
588
+ 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)} ${e.anchor === 'hip' ? 'hip widths' : '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
589
  }
588
590
  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`;
589
591
  }
@@ -747,6 +749,17 @@ export interface Raised {
747
749
  reach: { min: number; max: number; farthest: number };
748
750
  }
749
751
 
752
+ /**
753
+ * One span of a `place: "by-hip"` set on one of its placed chains: its wrist
754
+ * placed against the hip (rig-c/pose#93), as `Raised` against the head, in hip
755
+ * widths. `reference`, `placed`, `before` and `after` are the wrist's offset
756
+ * from the mid-hip, x right and y up; `short` is in hip widths.
757
+ */
758
+ export interface ByHip extends Omit<Raised, 'eyes'> {
759
+ /** The unit: the hip width's median over the span, the rig's in world units, the reference's in picture px. */
760
+ hips: { rig: number; reference: number };
761
+ }
762
+
750
763
  const xy = (v: P2) => `(${v[0].toFixed(2)}, ${v[1].toFixed(2)})`;
751
764
  const framesText = (fs: readonly number[]): string => {
752
765
  const flag: boolean[] = [];
@@ -754,16 +767,26 @@ const framesText = (fs: readonly number[]): string => {
754
767
  return runsOf(Array.from({ length: flag.length }, (_, i) => flag[i] === true), 0).map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
755
768
  };
756
769
 
757
- export function raisedLine(e: Raised, times: readonly number[]): string {
770
+ /** The words a placement's lines use: what the wrist is placed against, its centre, and the unit. */
771
+ const ANCHOR = {
772
+ head: { against: 'the head', centre: 'head centre', unit: 'eye distances', unitOne: 'eye distance', relative: 'head-relative' },
773
+ hip: { against: 'the hip', centre: 'mid-hip', unit: 'hip widths', unitOne: 'hip width', relative: 'hip-relative' },
774
+ } as const;
775
+ const anchorOf = (e: Raised | ByHip) => ('hips' in e ? ANCHOR.hip : ANCHOR.head);
776
+
777
+ export function raisedLine(e: Raised | ByHip, times: readonly number[]): string {
778
+ const w = anchorOf(e);
779
+ const units = 'hips' in e ? e.hips : e.eyes;
758
780
  const med = (vs: readonly P2[]): P2 => [median(vs.map((v) => v[0])), median(vs.map((v) => v[1]))];
759
781
  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
782
  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}`;
783
+ return `set "${e.set}" chain "${e.chain}" ${spanOf(e.frames, times)}: the wrist placed against ${w.against}; ${w.unitOne} (median) rig ${units.rig.toFixed(2)} units, reference ${units.reference.toFixed(2)} px; wrist − ${w.centre} in ${w.unit} (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
784
  }
763
785
 
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`;
786
+ /** The REACH line: a placed span (raised, or by the hip) whose target was out of the arm's reach on some frames — the proportions, not the art's range, were the limit. */
787
+ export function reachLine(e: Raised | ByHip, times: readonly number[]): string {
788
+ const w = anchorOf(e);
789
+ 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 ${w.centre} never enters the reach; placed at the reach's point nearest it` } as const;
790
+ 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)} ${w.unit} short (median ${s.shortfall.median.toFixed(2)})`);
791
+ 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 ${w.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
792
  }
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');