@rig-c/pose 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/retarget.ts CHANGED
@@ -19,9 +19,8 @@
19
19
  * pose puts the segment's end, relative to the translated joint and scaled. So
20
20
  * the hip and its travel are computed before any bone's target.
21
21
  *
22
- * Smoothing is Savitzky–Golay: a centred least-squares cubic over the window,
23
- * evaluated at the frame, on each joint's positions after its holds. Centred,
24
- * it moves no motion in time; a cubic keeps a peak a moving average flattens.
22
+ * Smoothing is not done here: the sequence comes cleaned (`clean.ts`, #67 —
23
+ * spikes, occlusion, a per-joint Savitzky–Golay), and its record says how.
25
24
  *
26
25
  * The short hold (rig-c/pose#22) comes next: a segment whose picture length on
27
26
  * a frame is under `short` of its own median length over the sequence is seen
@@ -35,7 +34,7 @@
35
34
  * The flip guard (rig-c/pose#15) then spreads a segment's turn greater than
36
35
  * `flip` degrees in one frame over the frames around it, linearly and centred on
37
36
  * the jump (`spreadFlips`). It reads the mapped segments' target angles after
38
- * holds, smoothing and the short hold, and before the hinge check. A reach's
37
+ * holds and the short hold, and before the hinge check. A reach's
39
38
  * target is aimed later, from posed positions, and is neither held short nor
40
39
  * guarded.
41
40
  *
@@ -52,7 +51,7 @@
52
51
  * against it (`artTurn`, and `artRange` where it leaves its range).
53
52
  *
54
53
  * The lines across the body — hips, shoulders, eyes — are read from the pose
55
- * after holds and smoothing, whether the map uses them or not: a line under
54
+ * after holds, whether the map uses them or not: a line under
56
55
  * `TURN_FRACTION` of its median picture length, or reversed (pointing more
57
56
  * than 90° from the subject's right to left), for ≥ `TURN_SECONDS`, is a `TURN`:
58
57
  * the body or head turned, a pose a flat rig cannot draw. It changes no key.
@@ -77,8 +76,6 @@ const DEG = 180 / Math.PI;
77
76
  export interface RetargetOptions extends SwitchOptions {
78
77
  /** A joint with a score below this is not good on that frame (a joint with no score is). */
79
78
  minScore: number;
80
- /** Savitzky–Golay window over joint positions, in frames: 1 = none, else odd and ≥ 5. */
81
- smooth: number;
82
79
  /** What a hinge bent the wrong way does: refuse the run, or straighten that hinge on that frame. */
83
80
  bends: 'refuse' | 'clamp';
84
81
  /** Degrees past straight a hinge may bend the wrong way before it counts. */
@@ -331,64 +328,6 @@ function median(xs: number[]): number {
331
328
  return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
332
329
  }
333
330
 
334
- /**
335
- * Savitzky–Golay, centred: at each frame, the least-squares polynomial of
336
- * `order` over the frames of the window that exist (clipped at the ends), read
337
- * at that frame. With fewer points than order + 1 the order drops to fit them
338
- * exactly, so a window clipped to 4 frames or fewer returns the input there.
339
- * A polynomial of degree ≤ `order` passes through unchanged.
340
- */
341
- export function savitzkyGolay(xs: readonly Pt[], window: number, order = 3): Pt[] {
342
- if (window <= 1) return xs.map((x) => [x[0], x[1]]);
343
- const h = window >> 1;
344
- const n = xs.length;
345
- const cache = new Map<string, number[]>();
346
- return xs.map((_, i) => {
347
- const lo = Math.max(0, i - h) - i;
348
- const hi = Math.min(n - 1, i + h) - i;
349
- const key = `${lo},${hi}`;
350
- let w = cache.get(key);
351
- if (w === undefined) {
352
- w = sgWeights(lo, hi, Math.min(order, hi - lo));
353
- cache.set(key, w);
354
- }
355
- let x = 0;
356
- let y = 0;
357
- for (let u = lo; u <= hi; u++) {
358
- const c = w[u - lo]!;
359
- x += c * xs[i + u]![0];
360
- y += c * xs[i + u]![1];
361
- }
362
- return [x, y];
363
- });
364
- }
365
-
366
- /** The weights that read a least-squares polynomial of degree q over u = lo..hi at u = 0. */
367
- function sgWeights(lo: number, hi: number, q: number): number[] {
368
- const m = q + 1;
369
- // Normal equations: A[j][l] = Σ u^(j+l); solve A z = e0, then w(u) = Σ z_j u^j.
370
- const A = Array.from({ length: m }, (_, j) => Array.from({ length: m + 1 }, (_, l) => {
371
- if (l === m) return j === 0 ? 1 : 0;
372
- let s = 0;
373
- for (let u = lo; u <= hi; u++) s += u ** (j + l);
374
- return s;
375
- }));
376
- for (let c = 0; c < m; c++) {
377
- let piv = c;
378
- for (let r = c + 1; r < m; r++) if (Math.abs(A[r]![c]!) > Math.abs(A[piv]![c]!)) piv = r;
379
- [A[c], A[piv]] = [A[piv]!, A[c]!];
380
- for (let r = 0; r < m; r++) {
381
- if (r === c) continue;
382
- const f = A[r]![c]! / A[c]![c]!;
383
- for (let l = c; l <= m; l++) A[r]![l]! -= f * A[c]![l]!;
384
- }
385
- }
386
- const z = A.map((row, j) => row[m]! / row[j]!);
387
- const w: number[] = [];
388
- for (let u = lo; u <= hi; u++) w.push(z.reduce((s, zj, j) => s + zj * u ** j, 0));
389
- return w;
390
- }
391
-
392
331
  const r3 = (v: number) => {
393
332
  const r = Math.round(v * 1000) / 1000;
394
333
  return Object.is(r, -0) ? 0 : r;
@@ -439,7 +378,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
439
378
  const series = new Map<string, Pt[]>();
440
379
  const good = new Map<string, boolean[]>();
441
380
  const holds: Hold[] = [];
442
- /** A point held where it is not good, then smoothed; or why it is good on no frame. */
381
+ /** A point held where it is not good; or why it is good on no frame. */
443
382
  const heldOf = (name: string): { at: Pt[]; good: boolean[]; holds: Hold[] } | { never: string } => {
444
383
  const raw = frames.map((f) => pointOn(f, name, opts.minScore));
445
384
  const firstGood = raw.findIndex((v) => typeof v !== 'string');
@@ -463,7 +402,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
463
402
  spans.push(span);
464
403
  }
465
404
  }
466
- return { at: savitzkyGolay(out, opts.smooth), good: raw.map((v) => typeof v !== 'string'), holds: spans };
405
+ return { at: out, good: raw.map((v) => typeof v !== 'string'), holds: spans };
467
406
  };
468
407
  for (const name of [...needed].sort()) {
469
408
  const h = heldOf(name);
@@ -477,7 +416,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
477
416
  }
478
417
  p.throwIfAny();
479
418
 
480
- // TURN: the lines across the body, read from the pose (held and smoothed as the
419
+ // TURN: the lines across the body, read from the pose (held as the
481
420
  // map's points are, whether or not the map uses them). A reading; no key moves.
482
421
  const turns: Turn[] = [];
483
422
  const turnsUnread: Retarget['turnsUnread'] = [];
package/src/run.ts CHANGED
@@ -8,6 +8,7 @@ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeF
8
8
  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
+ import { cleanLine, inferredSource } from './clean.ts';
11
12
  import { convertCoco17, personTrack, readPoseSequence, type CameraFrame } from './pose.ts';
12
13
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
13
14
  import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
@@ -26,6 +27,8 @@ export interface RetargetArgs extends RetargetOptions {
26
27
  out: string;
27
28
  name: string;
28
29
  coco: boolean;
30
+ /** `--smooth`'s value when given: an alias since #67 — the cleaning carries the smoothing; it prints so and changes nothing. */
31
+ smoothAlias?: string;
29
32
  person?: string;
30
33
  baseMotion?: string;
31
34
  /** Passed to rigc build as --images: the directory the rig spec's images resolve in, when rig.json's own `images` does not point there (a copied rig). */
@@ -87,6 +90,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
87
90
  const map = readMap(readFileSync(a.map, 'utf8'), a.map);
88
91
  const poseText = readFileSync(a.pose, 'utf8');
89
92
  const seq = a.coco ? convertCoco17(poseText, a.pose) : readPoseSequence(poseText, a.pose);
93
+ if (seq.cleaned === undefined) refuse([{ code: 'POSE_NOT_CLEANED', text: notCleaned(a.pose, a.coco) }]);
90
94
  const frames = personTrack(seq, a.person, a.pose);
91
95
  const times = seq.frames.map((f) => f.t);
92
96
 
@@ -104,8 +108,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
104
108
  resolveMap(map, rest, a.map, slots);
105
109
 
106
110
  const r = retarget(rest, map, frames, times, a, slots);
107
- say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
111
+ say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
108
112
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
113
+ say(` CLEAN ${cleanLine(seq.cleaned!)}`);
114
+ if (a.smoothAlias !== undefined) say(` SMOOTH --smooth is carried by clean; ignored (given ${a.smoothAlias}; this sequence was cleaned with legs ${seq.cleaned!.params.legs}, rest ${seq.cleaned!.params.rest})`);
109
115
  if (seq.camera !== undefined) say(` CAMERA ${cameraLine(seq.camera, times, seq.cameraSource)}`);
110
116
  else if (a.plantFeet === true) say(' CAMERA none in the sequence: --plant-feet reads the picture as a fixed camera, so a zoom or a pan in the video moves the floor with it and the floor may step with the picture (tools/scan.py reads the camera, rig-c/pose#63)');
111
117
  if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
@@ -219,7 +225,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
219
225
  p.throwIfAny();
220
226
 
221
227
  const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
222
- const posture = measurePosture(posed, lengths, map, r, frames, a.minScore);
228
+ const posture = measurePosture(posed, lengths, map, r, frames, a.minScore, inferredSource(seq.cleaned));
223
229
  if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
224
230
  const legBones = legBonesOf(map);
225
231
  const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
@@ -266,7 +272,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
266
272
  animation: a.name,
267
273
  frames: seq.frames.length,
268
274
  duration: r.animation.duration,
269
- options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
275
+ cleaned: seq.cleaned,
276
+ options: { minScore: a.minScore, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
270
277
  scale: r.scale,
271
278
  holds: r.holds,
272
279
  short: r.short,
@@ -436,3 +443,14 @@ function legScaleLine(l: LegScale, times: readonly number[]): string {
436
443
  const clamp = (rs: Array<[number, number]>, at: number, how: string) => (rs.length === 0 ? `none ${how} ${at}` : `${count(rs)} frame(s) ${how} ${at}, clamped (frames ${rs.map(span).join(', ')})`);
437
444
  return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (95th percentile of ${times.length} frames); picture length ÷ straight ${l.ratio.min.toFixed(3)} … ${l.ratio.max.toFixed(3)} (median ${l.ratio.median.toFixed(3)}); scalex keyed in [${l.limits[0]}, ${l.limits[1]}]: ${clamp(l.low, l.limits[0], 'under')}; ${clamp(l.high, l.limits[1], 'over')}`;
438
445
  }
446
+
447
+ /**
448
+ * POSE_NOT_CLEANED (#67): retarget reads only a sequence `rig-pose clean` wrote.
449
+ * The message is the command to run on this file; the output name is a
450
+ * suggestion beside it (`<name>.clean.json`).
451
+ */
452
+ export function notCleaned(pose: string, coco: boolean): string {
453
+ const out = pose.endsWith('.json') ? `${pose.slice(0, -'.json'.length)}.clean.json` : `${pose}.clean.json`;
454
+ const what = coco ? `${pose} is COCO-17 (--coco), which carries no "cleaned" record` : `${pose} has no "cleaned" record`;
455
+ return `${what}; retarget reads a sequence rig-pose clean wrote (spikes, occlusion, smoothing; there is no flag to skip it). Run \`rig-pose clean ${coco ? '--coco ' : ''}--pose ${pose} --out ${out}\` (add --loop for one cycle of a gait) and retarget --pose ${out}`;
456
+ }
package/src/switch.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * The metric, per frame and per set: the largest |wrap(pose − key)| over every
6
6
  * segment of the set's chains, where "pose" is the segment's angle exactly as
7
- * the retarget asked the bone for it (after holds, smoothing, the short hold,
7
+ * the retarget asked the bone for it (after holds, the short hold,
8
8
  * the flip guard and the hinge check; before any range clamp) and "key" is the
9
9
  * same segment's angle in the set's key skeleton, both by `pictureAngle`. Per
10
10
  * frame the candidate is the set with the smallest distance within the
package/src/verify.ts CHANGED
@@ -246,8 +246,19 @@ export interface Posture {
246
246
  rigShoulder: string | null;
247
247
  /** Reference frames on which the hips (tilt) and the hips and shoulders (sway) are all good. */
248
248
  frames: { tilt: number; sway: number; of: number };
249
- /** Hip-line tilt, Spine degrees (r_hip → l_hip; the rig: the pelvis chain's aim). */
250
- tilt: { reference: Range; rig: Range; ratio: number };
249
+ /**
250
+ * Hip-line tilt, Spine degrees (r_hip → l_hip; the rig: the pelvis chain's
251
+ * aim). Null when every frame with both hips good has an `inferred` hip
252
+ * (#63): the tilt is not read, and `tiltWhy` says why.
253
+ */
254
+ tilt: { reference: Range; rig: Range; ratio: number } | null;
255
+ /** Present only when `tilt` is null: why it was not read. */
256
+ tiltWhy?: string;
257
+ /**
258
+ * Present only when some frame with both hips good has an `inferred` hip:
259
+ * how many such frames the tilt left out, and where the state came from.
260
+ */
261
+ hipsInferred?: { frames: number; source: string };
251
262
  /**
252
263
  * Sway: the x-range of mid_hip − mid_shoulder, in rig units (the reference's
253
264
  * picture px × the figure scale) and in each body's own shoulder widths.
@@ -282,8 +293,17 @@ const medianOf = (xs: number[]): number => {
282
293
  * the joints it needs are good), the rig is the gated build posed by rig-c.
283
294
  * Null when the map has no pelvis chain (joints exactly r_hip, l_hip) or no
284
295
  * translate.
296
+ *
297
+ * Inferred hips (#63): a frame on which either hip is `inferred` is left out of
298
+ * the tilt — its hip line is a position the detector supplied but did not see,
299
+ * and a tilt read off it would be the detector's guess reported as the dancer's.
300
+ * When that is every frame (`clean --hips-inferred` marks them all), the tilt
301
+ * is null and `tiltWhy` names the reason and its source (`inferredSource`, read
302
+ * from the clean record). On some frames only, the tilt is read over the rest
303
+ * and the line counts the frames left out. Sway is read as before, inferred
304
+ * hips included: it is a different measure, and the card leaves it as it is.
285
305
  */
286
- export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: ReadonlyMap<string, number>, map: JointMap, r: Retarget, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, minScore: number): Posture | null {
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 {
287
307
  const pelvis = map.chains.find((c) => c.joints.length === 2 && c.joints[0] === 'r_hip' && c.joints[1] === 'l_hip');
288
308
  if (pelvis === undefined || map.translate === null || r.origin === null || r.scale === null) return null;
289
309
  const s = r.scale.factor;
@@ -294,11 +314,13 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
294
314
  const refTilt: number[] = [];
295
315
  const refSway: number[] = [];
296
316
  const refSw: number[] = [];
297
- frames.forEach((_, i) => {
317
+ let inferred = 0;
318
+ frames.forEach((f, i) => {
298
319
  const rh = at(i, 'r_hip');
299
320
  const lh = at(i, 'l_hip');
300
321
  if (rh === null || lh === null) return;
301
- refTilt.push(pictureAngle(rh, lh));
322
+ if (f.r_hip?.state === 'inferred' || f.l_hip?.state === 'inferred') inferred++;
323
+ else refTilt.push(pictureAngle(rh, lh));
302
324
  const rs = at(i, 'r_shoulder');
303
325
  const ls = at(i, 'l_shoulder');
304
326
  if (rs === null || ls === null) return;
@@ -307,9 +329,13 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
307
329
  });
308
330
  const bone = pelvis.bones[0]!;
309
331
  const aim = pelvis.aim[0]!;
310
- const rigTilt = posed.map((b) => aimAngle(b, lengths, bone, aim));
311
- const tilt = { reference: rangeOf(unwrapped(refTilt)), rig: rangeOf(unwrapped(rigTilt)), ratio: 0 };
312
- tilt.ratio = tilt.rig.range / tilt.reference.range;
332
+ let tilt: Posture['tilt'] = null;
333
+ const why = `hips inferred (${inferredSource})`;
334
+ if (inferred === 0 || refTilt.length > 0) {
335
+ const rigTilt = posed.map((b) => aimAngle(b, lengths, bone, aim));
336
+ tilt = { reference: rangeOf(unwrapped(refTilt)), rig: rangeOf(unwrapped(rigTilt)), ratio: 0 };
337
+ tilt.ratio = tilt.rig.range / tilt.reference.range;
338
+ }
313
339
 
314
340
  // The rig's mid-shoulder: the point a reach aims at, else the midpoint of the arms' first bones.
315
341
  const reach = map.chains.find((c) => c.reach);
@@ -340,7 +366,16 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
340
366
  ratioSw: rig === null || rig.rangeSw === null ? null : rig.rangeSw / reference.rangeSw,
341
367
  };
342
368
  }
343
- return { pelvis: pelvis.name, rigHip: r.origin.bone, rigShoulder, frames: { tilt: refTilt.length, sway: refSway.length, of: frames.length }, tilt, sway };
369
+ return {
370
+ pelvis: pelvis.name,
371
+ rigHip: r.origin.bone,
372
+ rigShoulder,
373
+ frames: { tilt: refTilt.length, sway: refSway.length, of: frames.length },
374
+ tilt,
375
+ ...(tilt === null ? { tiltWhy: why } : {}),
376
+ ...(inferred > 0 ? { hipsInferred: { frames: inferred, source: inferredSource } } : {}),
377
+ sway,
378
+ };
344
379
  }
345
380
 
346
381
  const f1 = (x: number) => x.toFixed(1);
@@ -350,8 +385,12 @@ const sg = (x: number) => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
350
385
  /** The POSTURE lines. */
351
386
  export function postureLines(q: Posture): string[] {
352
387
  const over = (k: number) => (k === q.frames.of ? '' : ` over ${k} of ${q.frames.of} frames`);
388
+ const t = q.tilt;
389
+ const left = q.hipsInferred === undefined ? '' : `; ${q.hipsInferred.frames} frame(s) with an inferred hip not read (${q.hipsInferred.source})`;
353
390
  const out = [
354
- `hip tilt range (chain "${q.pelvis}", r_hip → l_hip, Spine degrees): reference ${f1(q.tilt.reference.range)}° (${sg(q.tilt.reference.min)}° … ${sg(q.tilt.reference.max)}°${over(q.frames.tilt)}), rig ${f1(q.tilt.rig.range)}° (${sg(q.tilt.rig.min)}° … ${sg(q.tilt.rig.max)}°) — rig/reference ${f3(q.tilt.ratio)}`,
391
+ t === null
392
+ ? `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}`,
355
394
  ];
356
395
  const w = q.sway;
357
396
  if (w === null) out.push('sway: the reference has no frame with both hips and both shoulders good; not measured');