@rig-c/pose 0.17.0 → 0.19.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 { BlendHold, Floor, 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,7 +145,9 @@ 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'}`);
146
151
  if (r.floor !== null) {
147
152
  const legBones = legBonesOf(map);
148
153
  if (legBones !== null) say(` FEET ${floorLine(r.floor, setupLegs(rest, legBones))}`);
@@ -154,9 +159,13 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
154
159
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
155
160
  const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
156
161
  if (reduced !== null) {
157
- 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)`);
158
- const sum = (k: 'before' | 'after') => reduced.rows.filter((x) => x.kind === 'angle').reduce((t, x) => t + x[k], 0);
159
- 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)' : ''}`);
160
169
  }
161
170
 
162
171
  // The idle against the new animation (#43 item 2): what it drops, and with --carry-idle, looped back in.
@@ -209,8 +218,13 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
209
218
 
210
219
  const doc = readBuild(g1.build);
211
220
  const posed = poseFrames(doc, a.name, r.times);
212
- // A reduced track is held to the dense target plus its tolerance (#32).
213
- 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));
214
228
  const p = new Problems();
215
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).
216
230
  const carriedAbove = (bone: string): string => {
@@ -299,10 +313,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
299
313
  range: r.range,
300
314
  artRange: r.artRange,
301
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 } : {}),
302
318
  legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
303
319
  pivot: r.pivot,
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 },
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 },
306
322
  shear,
307
323
  idleDropped: idle === null ? null : idle.dropped,
308
324
  idleKeptOut: idle === null ? null : idle.keptOut,
@@ -319,9 +335,18 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
319
335
 
320
336
  /**
321
337
  * The retarget's rotate tracks (and with a hip tolerance, its translate) thinned
322
- * in place (#32). Kept regardless, per bone: the frames either side of each
323
- * switch boundary on the chains it changes, and the edges of every FLIP spread,
324
- * 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.
325
350
  */
326
351
  function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest, tolerance: number, hipTolerance: number | undefined): Reduced {
327
352
  const n = r.times.length;
@@ -341,8 +366,19 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
341
366
  return null;
342
367
  };
343
368
  const rotate = new Map<string, MotionTrack>();
344
- for (const tr of r.animation.tracks) if ('bone' in tr && tr.property === 'rotate') rotate.set(tr.bone, tr);
345
- 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>() }));
346
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));
347
383
  const parent = hipTrack === undefined ? null : rest.get(hipTrack.bone)!.parent;
348
384
  const pm = parent === null ? null : rest.get(parent)!;
@@ -355,7 +391,101 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
355
391
  });
356
392
  for (const [bone, keys] of out.rotate) rotate.get(bone)!.keys = keys;
357
393
  if (hipTrack !== undefined && out.hip !== null) hipTrack.keys = out.hip;
358
- 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);
359
489
  }
360
490
 
361
491
  function spans(ts: number[], step: number): string {
@@ -462,33 +592,78 @@ export function floorLine(floor: Floor, leg: { r: number; l: number }): string {
462
592
  * was stretched to keep its ankle on the floor, with the stretch keyed; one per
463
593
  * run whose stretch fell past its bounds (`past the bounds`, as STANCE says it).
464
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
+
465
620
  export function blendLines(h: BlendHold, times: readonly number[]): string[] {
466
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)`);
467
622
  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`);
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`);
469
624
  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`);
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`);
471
627
  return out;
472
628
  }
473
629
 
474
630
  /**
475
- * The STANCE lines (#60): one per run of planted-both or lifted frames, with
476
- * the tolerance and the stretch keyed; one per run a stretch was capped.
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.
477
634
  */
478
635
  export function stanceLines(st: Stance, times: readonly number[]): string[] {
479
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)`);
480
637
  const tol = `the tolerance ${st.tolerance} leg lengths`;
481
638
  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');
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`);
483
644
  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`
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`
485
646
  : r.state === 'both'
486
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)})`
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`));
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`);
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`);
489
653
  return out;
490
654
  }
491
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
+
492
667
  /** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped. */
493
668
  function legScaleLine(l: LegScale, times: readonly number[]): string {
494
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 { HipsInferred, Joint, Run } from './pose.ts';
14
+ import type { HipsInferred, Joint, PelvisFit, Run } from './pose.ts';
15
15
  import type { Retarget } from './retarget.ts';
16
16
  import { pictureAngle, pointOn, wrap } from './retarget.ts';
17
17
 
@@ -260,13 +260,25 @@ export interface Posture {
260
260
  */
261
261
  hipsInferred?: { frames: number; source: string };
262
262
  /**
263
- * Present only when the hips were measured (#86, `hipsInferred.by` is
263
+ * Present only when the hips were measured (#86, #106, `hipsInferred.by` is
264
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`.
265
+ * record's read, interpolated, scaled and held frames with both hips good,
266
+ * each class counted — and the record's spans. Any `unmeasured` frame (corr
267
+ * < 0.9) or other frame with an inferred hip is left out of `tilt`.
268
268
  */
269
- tiltMeasured?: { method: string; frames: number; read: number; interpolated: number; held: number; heldSpans: Run[] };
269
+ tiltMeasured?: {
270
+ method: string;
271
+ frames: number;
272
+ read: number;
273
+ interpolated: number;
274
+ held: number;
275
+ heldSpans: Run[];
276
+ scaled?: number;
277
+ scaledSpans?: Run[];
278
+ unmeasured?: number;
279
+ unmeasuredSpans?: Run[];
280
+ fit?: PelvisFit;
281
+ };
270
282
  /**
271
283
  * Sway: the x-range of mid_hip − mid_shoulder, in rig units (the reference's
272
284
  * picture px × the figure scale) and in each body's own shoulder widths.
@@ -311,11 +323,12 @@ const medianOf = (xs: number[]): number => {
311
323
  * and the line counts the frames left out. Sway is read as before, inferred
312
324
  * hips included: it is a different measure, and the card leaves it as it is.
313
325
  *
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.
326
+ * Measured hips (#86, #106, `measured`): the hips are inferred on every frame,
327
+ * and turned to a measured angle: read off the picture, interpolated between two
328
+ * close reads, scaled from the detector's hip line when `fit.corr >= 0.9`, or
329
+ * held from the earlier read. Those four are the measurement, so the tilt is
330
+ * read on them; any `unmeasured` frame (`fit.corr < 0.9`) or other frame with
331
+ * an inferred hip is left out and counted.
319
332
  */
320
333
  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 {
321
334
  const pelvis = map.chains.find((c) => c.joints.length === 2 && c.joints[0] === 'r_hip' && c.joints[1] === 'l_hip');
@@ -330,16 +343,23 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
330
343
  const refSw: number[] = [];
331
344
  let inferred = 0;
332
345
  const inClass = (rs: Run[], i: number) => rs.some(([a, b]) => a <= i && i <= b);
333
- const counts = { read: 0, interpolated: 0, held: 0 };
346
+ const counts = { read: 0, interpolated: 0, held: 0, scaled: 0, unmeasured: 0 };
334
347
  frames.forEach((f, i) => {
335
348
  const rh = at(i, 'r_hip');
336
349
  const lh = at(i, 'l_hip');
337
350
  if (rh === null || lh === null) return;
338
351
  const isInferred = f.r_hip?.state === 'inferred' || f.l_hip?.state === 'inferred';
339
352
  const cls = measured === null ? undefined : (['read', 'interpolated', 'held'] as const).find((c) => inClass(measured[c], i));
353
+ const isScaled = measured !== null && measured.scaled !== undefined && inClass(measured.scaled, i);
354
+ const isUnmeasured = measured !== null && measured.unmeasured !== undefined && inClass(measured.unmeasured, i);
340
355
  if (isInferred && cls !== undefined) {
341
356
  counts[cls]++;
342
357
  refTilt.push(pictureAngle(rh, lh));
358
+ } else if (isInferred && isScaled) {
359
+ counts.scaled++;
360
+ refTilt.push(pictureAngle(rh, lh));
361
+ } else if (isInferred && isUnmeasured) {
362
+ counts.unmeasured++;
343
363
  } else if (isInferred) inferred++;
344
364
  else refTilt.push(pictureAngle(rh, lh));
345
365
  const rs = at(i, 'r_shoulder');
@@ -352,7 +372,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
352
372
  const aim = pelvis.aim[0]!;
353
373
  let tilt: Posture['tilt'] = null;
354
374
  const why = `hips inferred (${inferredSource})`;
355
- if (inferred === 0 || refTilt.length > 0) {
375
+ if ((inferred === 0 && counts.unmeasured === 0) || refTilt.length > 0) {
356
376
  const rigTilt = posed.map((b) => aimAngle(b, lengths, bone, aim));
357
377
  tilt = { reference: rangeOf(unwrapped(refTilt)), rig: rangeOf(unwrapped(rigTilt)), ratio: 0 };
358
378
  tilt.ratio = tilt.rig.range / tilt.reference.range;
@@ -387,6 +407,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
387
407
  ratioSw: rig === null || rig.rangeSw === null ? null : rig.rangeSw / reference.rangeSw,
388
408
  };
389
409
  }
410
+ const has106 = measured !== null && (measured.scaled !== undefined || measured.unmeasured !== undefined || measured.fit !== undefined);
390
411
  return {
391
412
  pelvis: pelvis.name,
392
413
  rigHip: r.origin.bone,
@@ -395,7 +416,27 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
395
416
  tilt,
396
417
  ...(tilt === null ? { tiltWhy: why } : {}),
397
418
  ...(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]) } } : {}),
419
+ ...(measured !== null
420
+ ? {
421
+ tiltMeasured: {
422
+ method: measured.method,
423
+ frames: counts.read + counts.interpolated + counts.scaled + counts.held,
424
+ read: counts.read,
425
+ interpolated: counts.interpolated,
426
+ held: counts.held,
427
+ heldSpans: measured.held.map(([a, b]): Run => [a, b]),
428
+ ...(has106
429
+ ? {
430
+ scaled: counts.scaled,
431
+ scaledSpans: (measured.scaled ?? []).map(([a, b]): Run => [a, b]),
432
+ unmeasured: counts.unmeasured,
433
+ unmeasuredSpans: (measured.unmeasured ?? []).map(([a, b]): Run => [a, b]),
434
+ ...(measured.fit !== undefined ? { fit: { frames: measured.fit.frames, gain: measured.fit.gain, corr: measured.fit.corr } } : {}),
435
+ }
436
+ : {}),
437
+ },
438
+ }
439
+ : {}),
399
440
  sway,
400
441
  };
401
442
  }
@@ -403,14 +444,23 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
403
444
  const f1 = (x: number) => x.toFixed(1);
404
445
  const f3 = (x: number) => x.toFixed(3);
405
446
  const sg = (x: number) => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
447
+ const spansText = (rs: Run[] | undefined) => (rs === undefined || rs.length === 0 ? 'none' : rs.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', '));
406
448
 
407
449
  /** The POSTURE lines. */
408
450
  export function postureLines(q: Posture): string[] {
409
451
  const over = (k: number) => (k === q.frames.of ? '' : ` over ${k} of ${q.frames.of} frames`);
410
452
  const t = q.tilt;
411
453
  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})`;
454
+ const heldSpans = spansText(m?.heldSpans);
455
+ const scaledPart =
456
+ m?.scaled !== undefined && m.scaled > 0
457
+ ? `, ${m.scaled} scaled (scaled: ${spansText(m.scaledSpans)}${m.fit?.gain !== undefined && m.fit.gain !== null && m.fit.corr !== null ? `, gain ${m.fit.gain.toFixed(2)}×, r=${m.fit.corr.toFixed(3)}` : ''})`
458
+ : '';
459
+ const unmeasuredPart =
460
+ m?.unmeasured !== undefined && (m.unmeasured > 0 || (m.fit !== undefined && (m.fit.corr === null || m.fit.corr < 0.9)))
461
+ ? `; ${m.unmeasured} unmeasured (${spansText(m.unmeasuredSpans)}${m.fit !== undefined ? `, r=${m.fit.corr === null ? 'none' : m.fit.corr.toFixed(3)} < 0.9` : ''})`
462
+ : '';
463
+ const measured = m === undefined ? '' : `; tilt measured (${m.method}) on ${m.frames} frame(s): ${m.read} read, ${m.interpolated} interpolated${scaledPart}, ${m.held} held (held: ${heldSpans})${unmeasuredPart}`;
414
464
  const left = q.hipsInferred === undefined ? '' : `; ${q.hipsInferred.frames} frame(s) with an inferred hip not read (${q.hipsInferred.source})`;
415
465
  const out = [
416
466
  t === null