@rig-c/pose 0.37.0 → 0.38.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/pose.ts CHANGED
@@ -17,11 +17,30 @@
17
17
  * is: the unit is `picture-px` (a video frame is not a painting), and `at` may
18
18
  * lie outside the picture (a dancer's hand leaves the frame; a pose model
19
19
  * places it anyway, and its score says how much to believe it).
20
+ *
21
+ * The minor (rig-c/pose#288): `rig-pose-sequence/1.1` adds the pelvis track —
22
+ * a per-frame `pelvisTilt` and the sequence's `pelvisSource` (`rig-pose
23
+ * pelvis`). The writer writes the minor the content needs: a sequence with the
24
+ * track is "rig-pose-sequence/1.1", every other one "rig-pose-sequence/1", so
25
+ * a sequence without it stays readable by a reader of /1. The spec string is
26
+ * the version the file records of what wrote it: a file of a minor newer than
27
+ * this reader's is read, and each key it does not know is refused naming the
28
+ * minor the file says it was written as.
20
29
  */
21
30
  import { BODY18, COCO17, COCO17_TO_BODY18, isBody18, type Body18 } from './joints.ts';
22
- import { isObject, parseJson, Problems, refuseUnknownKeys, typeName } from './problems.ts';
31
+ import { isObject, parseJson, Problems, refuseUnknownKeys, refuseUnknownKeys as refuseKeys, typeName } from './problems.ts';
23
32
 
24
33
  export const POSE_SPEC = 'rig-pose-sequence/1';
34
+ /** The minor with the pelvis track (rig-c/pose#288), and the newest minor this reader reads. */
35
+ export const POSE_SPEC_PELVIS = 'rig-pose-sequence/1.1';
36
+ const POSE_MINOR = 1;
37
+ /**
38
+ * Where a sequence's `pelvisTilt` came from (rig-c/pose#288): `silhouette` — the
39
+ * straight edge of the drawn figure across the hips; `waistband` — the top edge
40
+ * of a garment covering them; `keys` — a hand file (`--pelvis-keys`).
41
+ */
42
+ export const PELVIS_SOURCES = ['silhouette', 'waistband', 'keys'] as const;
43
+ export type PelvisSource = (typeof PELVIS_SOURCES)[number];
25
44
  export const POSE_SPACE = { units: 'picture-px', origin: 'top-left', y: 'down' } as const;
26
45
  /**
27
46
  * `inferred` (rig-c/pose#63): a position the detector supplied but did not see
@@ -44,6 +63,15 @@ export interface Person {
44
63
  export interface PoseFrame {
45
64
  t: number;
46
65
  people: Person[];
66
+ /**
67
+ * rig-c/pose#288, minor 1.1: the pelvis line on this frame, read off the picture —
68
+ * Spine degrees (counter-clockwise, y up) of the direction from the figure's
69
+ * right hip to its left hip, the angle `pictureAngle(r_hip, l_hip)` gives the
70
+ * hip keypoints (about 0 for a figure facing the camera, about ±180 facing
71
+ * away); null where the reading declined the frame. Present on every frame of
72
+ * a sequence with a `pelvisSource`, on none of one without.
73
+ */
74
+ pelvisTilt?: number | null;
47
75
  }
48
76
  /**
49
77
  * The picture's camera on one frame against frame 0's (rig-c/pose#63), as
@@ -175,6 +203,10 @@ export interface CleanUnheld {
175
203
  test: 'speed' | 'reach' | 'short' | 'span';
176
204
  value: number | null;
177
205
  }
206
+ /** rig-c/pose#288: clean smoothed the sequence's `pelvisTilt` as its own track, over the legs' window (`loop` as the record's). */
207
+ export interface PelvisParams {
208
+ window: number;
209
+ }
178
210
  export interface CleanRecord {
179
211
  spec: typeof CLEAN_SPEC;
180
212
  /**
@@ -182,7 +214,7 @@ export interface CleanRecord {
182
214
  * `hold` (#91): the stillness hold's figures, or "off"; absent in a record
183
215
  * written before #91.
184
216
  */
185
- params: { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off' };
217
+ params: { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off'; pelvis?: PelvisParams };
186
218
  /**
187
219
  * `found`: every joint the spike step replaced, by frame (index into `frames`)
188
220
  * and joint. `untested`: the frames the spike step did not test, because a
@@ -215,6 +247,8 @@ export interface PoseSequence {
215
247
  * by hand. Absent: the figure's travel in the picture is its travel.
216
248
  */
217
249
  travel?: Travel;
250
+ /** rig-c/pose#288, minor 1.1: where every frame's `pelvisTilt` came from; present exactly when the frames carry it. */
251
+ pelvisSource?: PelvisSource;
218
252
  frames: PoseFrame[];
219
253
  }
220
254
 
@@ -246,9 +280,17 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
246
280
  throw new Error('unreachable');
247
281
  }
248
282
  const frames0 = doc.spec === POSE_SPEC_FRAMES0;
249
- refuseUnknownKeys(p, 'POSE_KEYS', where, doc, frames0 ? [...TOP_KEYS, ...OPTIONAL_KEYS, 'fps'] : [...TOP_KEYS, ...OPTIONAL_KEYS]);
283
+ // rig-c/pose#288: the minor the file says it was written as; a newer one is read, and what it adds is refused naming it.
284
+ const minor = frames0 ? 0 : minorOf(doc.spec);
285
+ const note = minor !== null && minor > POSE_MINOR ? `; the file says it was written as ${JSON.stringify(doc.spec)}, a newer minor than this reader's "${POSE_SPEC_PELVIS}" — read it with a rig-pose that reads that minor` : '';
286
+ const pelvisKnown = minor !== null && minor >= 1;
287
+ /** A 1.1 key in a file that says /1 (or the older name): one line naming the minor, not an unknown key. */
288
+ const minorKey = (key: string, on: string) => p.add('POSE_SPEC', `${on} carries ${JSON.stringify(key)}, a key of "${POSE_SPEC_PELVIS}", and the file says ${JSON.stringify(doc.spec)}; a sequence carrying the pelvis track is written as "${POSE_SPEC_PELVIS}" (rig-pose pelvis)`);
289
+ const without = (o: Record<string, unknown>, key: string) => (pelvisKnown || !(key in o) ? o : Object.fromEntries(Object.entries(o).filter(([k]) => k !== key)));
290
+ if (!pelvisKnown && 'pelvisSource' in doc) minorKey('pelvisSource', where);
291
+ refuseUnknownKeys(p, 'POSE_KEYS', where, without(doc, 'pelvisSource'), frames0 ? [...TOP_KEYS, ...OPTIONAL_KEYS, 'fps'] : [...TOP_KEYS, ...OPTIONAL_KEYS, ...(pelvisKnown ? ['pelvisSource'] : [])], note);
250
292
  for (const k of TOP_KEYS) if (!(k in doc)) p.add('POSE_KEYS', `${where} has no "${k}"; a ${POSE_SPEC} document carries ${TOP_KEYS.join(', ')}`);
251
- if ('spec' in doc && doc.spec !== POSE_SPEC && !frames0) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}" (and the older name "${POSE_SPEC_FRAMES0}")`);
293
+ if ('spec' in doc && minor === null) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}" and its minors ("${POSE_SPEC_PELVIS}"; a newer minor is read, and a key it adds is refused naming it), and the older name "${POSE_SPEC_FRAMES0}"`);
252
294
  const space = frames0 ? FRAMES0_SPACE : POSE_SPACE;
253
295
  if ('space' in doc && JSON.stringify(doc.space) !== JSON.stringify(space)) {
254
296
  p.add('POSE_SPACE', `${where} "space" is ${JSON.stringify(doc.space)}; for "${frames0 ? POSE_SPEC_FRAMES0 : POSE_SPEC}" it must be exactly ${JSON.stringify(space)}`);
@@ -264,13 +306,21 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
264
306
  p.add('POSE_FRAMES', `${where} "frames" is ${typeName(doc.frames)}; it must be a non-empty array`);
265
307
  } else {
266
308
  let lastT = -Infinity;
309
+ const minorFrames: number[] = [];
267
310
  doc.frames.forEach((f: unknown, i: number) => {
268
311
  const fw = `${where} frames[${i}]`;
269
312
  if (!isObject(f)) {
270
313
  p.add('POSE_FRAME_SHAPE', `${fw} is ${typeName(f)}; a frame is { t, people }`);
271
314
  return;
272
315
  }
273
- refuseUnknownKeys(p, 'POSE_KEYS', fw, f, ['t', 'people']);
316
+ if (!pelvisKnown && 'pelvisTilt' in f) minorFrames.push(i);
317
+ refuseUnknownKeys(p, 'POSE_KEYS', fw, without(f, 'pelvisTilt'), pelvisKnown ? ['t', 'people', 'pelvisTilt'] : ['t', 'people'], note);
318
+ let pelvisTilt: number | null | undefined;
319
+ if (pelvisKnown && 'pelvisTilt' in f) {
320
+ const v = f.pelvisTilt;
321
+ if (v === null || (typeof v === 'number' && Number.isFinite(v) && v > -180 && v <= 180)) pelvisTilt = v;
322
+ else p.add('POSE_PELVIS', `${fw} "pelvisTilt" is ${typeName(v)}; it must be Spine degrees in (-180, 180] (the direction r_hip → l_hip, counter-clockwise, y up), or null where the reading declined the frame`);
323
+ }
274
324
  const t = f.t;
275
325
  if (typeof t !== 'number' || !Number.isFinite(t) || t < 0) {
276
326
  p.add('POSE_TIME', `${fw} "t" is ${typeName(t)}; it must be a finite number of seconds ≥ 0`);
@@ -289,34 +339,73 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
289
339
  p.add('POSE_PERSON_SHAPE', `${pw} is ${typeName(person)}; a person is { id, joints }`);
290
340
  return;
291
341
  }
292
- refuseUnknownKeys(p, 'POSE_KEYS', pw, person, ['id', 'joints']);
342
+ refuseUnknownKeys(p, 'POSE_KEYS', pw, person, ['id', 'joints'], note);
293
343
  if (typeof person.id !== 'string' || person.id === '') {
294
344
  p.add('POSE_PERSON_ID', `${pw} "id" is ${typeName(person.id)}; it must be a non-empty string`);
295
345
  return;
296
346
  }
297
347
  if (ids.has(person.id)) p.add('POSE_PERSON_ID', `${pw} "id" "${person.id}" appears twice in frame ${i}`);
298
348
  ids.add(person.id);
299
- people.push({ id: person.id, joints: readJoints(p, pw, person.joints) });
349
+ people.push({ id: person.id, joints: readJoints(p, pw, person.joints, note) });
300
350
  });
301
351
  }
302
- frames.push({ t: typeof t === 'number' ? t : NaN, people });
352
+ frames.push({ t: typeof t === 'number' ? t : NaN, people, ...(pelvisTilt !== undefined ? { pelvisTilt } : {}) });
303
353
  });
354
+ if (minorFrames.length > 0) minorKey('pelvisTilt', `${where} ${minorFrames.length === 1 ? `frames[${minorFrames[0]}]` : `${minorFrames.length} frames (${minorFrames[0]}–${minorFrames[minorFrames.length - 1]})`}`);
355
+ }
356
+ }
357
+ let pelvisSource: PelvisSource | undefined;
358
+ if (pelvisKnown && 'pelvisSource' in doc) {
359
+ if (typeof doc.pelvisSource === 'string' && (PELVIS_SOURCES as readonly string[]).includes(doc.pelvisSource)) pelvisSource = doc.pelvisSource as PelvisSource;
360
+ else p.add('POSE_PELVIS', `${where} "pelvisSource" is ${typeName(doc.pelvisSource)}; it must be one of ${PELVIS_SOURCES.map((x) => `"${x}"`).join(', ')} (where every frame's "pelvisTilt" came from)`);
361
+ }
362
+ if (pelvisKnown && Array.isArray(doc.frames)) {
363
+ const carry = doc.frames.map((f: unknown) => isObject(f) && 'pelvisTilt' in f);
364
+ const lacking = carry.flatMap((c: boolean, i: number) => (c ? [] : [i]));
365
+ if ('pelvisSource' in doc && lacking.length > 0) p.add('POSE_PELVIS', `${where} has "pelvisSource" and frame(s) ${spansText(lacking)} carry no "pelvisTilt"; with a pelvis track every frame carries one (null where the reading declined it)`);
366
+ if (!('pelvisSource' in doc) && lacking.length < carry.length) p.add('POSE_PELVIS', `${where} has "pelvisTilt" on ${carry.length - lacking.length} frame(s) and no "pelvisSource"; a pelvis track says where it came from (${PELVIS_SOURCES.join(', ')})`);
367
+ if ('pelvisSource' in doc) {
368
+ const ids = [...new Set(frames.flatMap((f) => f.people.map((q) => q.id)))].sort();
369
+ if (ids.length > 1) p.add('POSE_PELVIS', `${where} carries a pelvis track and ${ids.length} people (${ids.join(', ')}); the track is one figure's, so the sequence holds one person (rig-pose pelvis --person writes only that one)`);
304
370
  }
305
371
  }
306
- const camera = 'camera' in doc ? readCamera(p, where, doc.camera, Array.isArray(doc.frames) ? doc.frames.length : null) : undefined;
372
+ const camera = 'camera' in doc ? readCamera(p, where, doc.camera, Array.isArray(doc.frames) ? doc.frames.length : null, note) : undefined;
307
373
  if ('cameraSource' in doc) {
308
374
  if (typeof doc.cameraSource !== 'string' || doc.cameraSource.trim() === '') p.add('POSE_CAMERA_SOURCE', `${where} "cameraSource" is ${typeName(doc.cameraSource)}; it must be a non-empty string saying where the camera came from ("tracked", "hand-labelled, N keys")`);
309
375
  else if (!('camera' in doc)) p.add('POSE_CAMERA_SOURCE', `${where} has "cameraSource" ${JSON.stringify(doc.cameraSource)} and no "camera"; it states where a camera came from, so it needs one`);
310
376
  }
311
377
  if ('travel' in doc && doc.travel !== TRAVEL_IN_PLACE) p.add('POSE_TRAVEL', `${where} "travel" is ${typeName(doc.travel)}; the one value is "${TRAVEL_IN_PLACE}" (a gait filmed without the figure travelling), and without the key the picture's travel is the figure's`);
312
- const cleaned = 'cleaned' in doc ? readCleaned(p, where, doc.cleaned, Array.isArray(doc.frames) ? doc.frames.length : null) : undefined;
378
+ const cleaned = 'cleaned' in doc ? readCleaned(p, where, doc.cleaned, Array.isArray(doc.frames) ? doc.frames.length : null, pelvisKnown, note) : undefined;
379
+ if (cleaned !== undefined && pelvisKnown) {
380
+ const hasPelvis = isObject(doc.cleaned) && isObject(doc.cleaned.params) && 'pelvis' in doc.cleaned.params;
381
+ if ('pelvisSource' in doc && !hasPelvis) p.add('POSE_PELVIS', `${where} carries a pelvis track and its "cleaned" record has no "params" "pelvis"; clean smooths the track and records it, so the track was added after clean — run rig-pose pelvis before rig-pose clean`);
382
+ if (!('pelvisSource' in doc) && hasPelvis) p.add('POSE_PELVIS', `${where} "cleaned" "params" has "pelvis" and the sequence no "pelvisSource"; the record says clean smoothed a pelvis track the frames do not carry`);
383
+ }
313
384
  p.throwIfAny();
314
385
  const cameraSource = typeof doc.cameraSource === 'string' ? doc.cameraSource : undefined;
315
- return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, ...(cameraSource !== undefined ? { cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), ...(doc.travel === TRAVEL_IN_PLACE ? { travel: TRAVEL_IN_PLACE } : {}), frames };
386
+ return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, ...(cameraSource !== undefined ? { cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), ...(doc.travel === TRAVEL_IN_PLACE ? { travel: TRAVEL_IN_PLACE } : {}), ...(pelvisSource !== undefined ? { pelvisSource } : {}), frames };
387
+ }
388
+
389
+ /** The minor a sequence's spec names: 0 for "rig-pose-sequence/1", N for "rig-pose-sequence/1.N"; null for anything else. */
390
+ function minorOf(spec: unknown): number | null {
391
+ if (spec === POSE_SPEC) return 0;
392
+ const m = typeof spec === 'string' ? /^rig-pose-sequence\/1\.([1-9]\d{0,5})$/.exec(spec) : null;
393
+ return m === null ? null : Number(m[1]);
394
+ }
395
+
396
+ /** Frame indices as runs: "3, 5–9". */
397
+ function spansText(fs: readonly number[]): string {
398
+ const out: Array<[number, number]> = [];
399
+ for (const f of fs) {
400
+ const last = out[out.length - 1];
401
+ if (last !== undefined && f === last[1] + 1) last[1] = f;
402
+ else out.push([f, f]);
403
+ }
404
+ return out.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
316
405
  }
317
406
 
318
407
  /** `camera`: one `{ s > 0, tx, ty, inliers ≥ 0 }` per frame, as many as `frames`. */
319
- function readCamera(p: Problems, where: string, camera: unknown, frames: number | null): CameraFrame[] {
408
+ function readCamera(p: Problems, where: string, camera: unknown, frames: number | null, note: string): CameraFrame[] {
320
409
  const out: CameraFrame[] = [];
321
410
  if (!Array.isArray(camera)) {
322
411
  p.add('POSE_CAMERA', `${where} "camera" is ${typeName(camera)}; it must be an array with one { s, tx, ty, inliers } per frame`);
@@ -329,7 +418,7 @@ function readCamera(p: Problems, where: string, camera: unknown, frames: number
329
418
  p.add('POSE_CAMERA', `${cw} is ${typeName(c)}; a camera entry is { s, tx, ty, inliers }`);
330
419
  return;
331
420
  }
332
- refuseUnknownKeys(p, 'POSE_CAMERA', cw, c, CAMERA_KEYS);
421
+ refuseUnknownKeys(p, 'POSE_CAMERA', cw, c, CAMERA_KEYS, note);
333
422
  let ok = true;
334
423
  for (const k of CAMERA_KEYS) if (!(k in c)) {
335
424
  p.add('POSE_CAMERA', `${cw} has no "${k}"; a camera entry carries ${CAMERA_KEYS.join(', ')}`);
@@ -354,7 +443,8 @@ function readCamera(p: Problems, where: string, camera: unknown, frames: number
354
443
  }
355
444
 
356
445
  /** `cleaned`: { spec, params: { loop, legs, rest, hipsInferred?, hold? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after }, holds?, unheld?, seams? }. */
357
- function readCleaned(p: Problems, where: string, c: unknown, frames: number | null): CleanRecord | undefined {
446
+ function readCleaned(p: Problems, where: string, c: unknown, frames: number | null, pelvisKnown: boolean, note: string): CleanRecord | undefined {
447
+ const refuseUnknownKeys = (q: Problems, code: string, w: string, o: Record<string, unknown>, known: readonly string[]) => refuseKeys(q, code, w, o, known, note);
358
448
  const cw = `${where} "cleaned"`;
359
449
  const shape = '{ spec, params: { loop, legs, rest, hipsInferred?, hold? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after }, holds?, unheld?, seams? }';
360
450
  if (!isObject(c)) {
@@ -370,7 +460,16 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
370
460
  const q = c.params;
371
461
  if (!isObject(q)) p.add('POSE_CLEANED', `${cw} "params" is ${typeName(q)}; it must be { loop, legs, rest, hipsInferred?, hold? }`);
372
462
  else {
373
- refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest', 'hipsInferred', 'hold']);
463
+ refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest', 'hipsInferred', 'hold', ...(pelvisKnown ? ['pelvis'] : [])]);
464
+ if (pelvisKnown && 'pelvis' in q) {
465
+ const v = q.pelvis;
466
+ const vw = `${cw} "params" "pelvis"`;
467
+ if (!isObject(v)) p.add('POSE_CLEANED', `${vw} is ${typeName(v)}; it must be { window } (rig-c/pose#288: the window clean smoothed the pelvis track over)`);
468
+ else {
469
+ refuseUnknownKeys(p, 'POSE_CLEANED', vw, v, ['window']);
470
+ if (!window(v.window)) p.add('POSE_CLEANED', `${vw} "window" is ${typeName(v.window)}; it must be 1 (none) or an odd whole number ≥ 5`);
471
+ }
472
+ }
374
473
  if ('hold' in q) {
375
474
  const h = q.hold;
376
475
  const hw = `${cw} "params" "hold"`;
@@ -384,7 +483,7 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
384
483
  for (const k of ['holds', 'unheld', 'seams']) if (!(k in c)) p.add('POSE_CLEANED', `${cw} has "params" "hold" and no "${k}"; a record whose hold ran lists its holds, the joints it did not hold and its seams (an empty array when none)`);
385
484
  }
386
485
  } else for (const k of ['holds', 'unheld', 'seams']) if (k in c) p.add('POSE_CLEANED', `${cw} has "${k}" and no "params" "hold"; the holds' figures are recorded in "params" "hold"`);
387
- if ('hipsInferred' in q) readHipsInferred(p, `${cw} "params" "hipsInferred"`, q.hipsInferred, frames);
486
+ if ('hipsInferred' in q) readHipsInferred(p, `${cw} "params" "hipsInferred"`, q.hipsInferred, frames, note);
388
487
  if (typeof q.loop !== 'boolean') p.add('POSE_CLEANED', `${cw} "params" "loop" is ${typeName(q.loop)}; it must be true or false`);
389
488
  for (const k of ['legs', 'rest'] as const) if (!window(q[k])) p.add('POSE_CLEANED', `${cw} "params" "${k}" is ${typeName(q[k])}; it must be 1 (none) or an odd whole number ≥ 5`);
390
489
  }
@@ -490,11 +589,11 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
490
589
  });
491
590
  }
492
591
  if (p.list.length > before) return undefined;
493
- const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off' };
592
+ const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off'; pelvis?: PelvisParams };
494
593
  const j = c.jitter as { before: number | null; after: number | null };
495
594
  return {
496
595
  spec: CLEAN_SPEC,
497
- params: { loop: q.loop, legs: q.legs, rest: q.rest, ...(q.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(q.hipsInferred) } : {}), ...(q.hold !== undefined ? { hold: holdParamsOf(q.hold) } : {}) },
596
+ params: { loop: q.loop, legs: q.legs, rest: q.rest, ...(q.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(q.hipsInferred) } : {}), ...(q.hold !== undefined ? { hold: holdParamsOf(q.hold) } : {}), ...(pelvisKnown && q.pelvis !== undefined ? { pelvis: { window: q.pelvis.window } } : {}) },
498
597
  spikes: {
499
598
  found: ((c.spikes as { found: Array<{ frame: number; joint: Body18 }> }).found).map((s) => ({ frame: s.frame, joint: s.joint })),
500
599
  untested: [...(c.spikes as { untested: number[] }).untested],
@@ -507,7 +606,8 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
507
606
  }
508
607
 
509
608
  /** `hipsInferred`: { by: "declared" } or the measured record (the shape at `HipsInferred`), refused by name otherwise. */
510
- function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number | null): void {
609
+ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number | null, note: string): void {
610
+ const refuseUnknownKeys = (q: Problems, code: string, w: string, o: Record<string, unknown>, known: readonly string[]) => refuseKeys(q, code, w, o, known, note);
511
611
  const measuredShape = '{ by: "measured", method, read, interpolated, held: [[first, last]], declined: [{ frames: [first, last], why }] }';
512
612
  if (!isObject(h)) {
513
613
  p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; it must be { by: "declared" } or ${measuredShape}`);
@@ -652,7 +752,7 @@ const holdParamsOf = (h: HoldParams | 'off'): HoldParams | 'off' => (h === 'off'
652
752
  const copyHold = (h: CleanHold): CleanHold => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], ...(h.excursion !== undefined ? { excursion: [h.excursion[0], h.excursion[1]] as [number, number] } : {}), jitter: { before: h.jitter.before, after: h.jitter.after } });
653
753
  const copyUnheld = (u: CleanUnheld): CleanUnheld => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value });
654
754
 
655
- function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Body18, Joint>> {
755
+ function readJoints(p: Problems, pw: string, joints: unknown, note: string): Partial<Record<Body18, Joint>> {
656
756
  const out: Partial<Record<Body18, Joint>> = {};
657
757
  if (!isObject(joints)) {
658
758
  p.add('POSE_JOINTS', `${pw} "joints" is ${typeName(joints)}; it must be an object keyed by body-18 joint names`);
@@ -668,7 +768,7 @@ function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Bo
668
768
  p.add('POSE_JOINT_SHAPE', `${jw} is ${typeName(j)}; a joint is { state, at?, score? }`);
669
769
  continue;
670
770
  }
671
- refuseUnknownKeys(p, 'POSE_KEYS', jw, j, ['state', 'at', 'score']);
771
+ refuseUnknownKeys(p, 'POSE_KEYS', jw, j, ['state', 'at', 'score'], note);
672
772
  const state = j.state;
673
773
  if (typeof state !== 'string' || !(STATES as readonly string[]).includes(state)) {
674
774
  p.add('POSE_JOINT_STATE', `${jw} "state" is ${typeName(state)}; it must be one of ${STATES.join(', ')}`);
@@ -821,6 +921,7 @@ export function writePoseSequence(seq: PoseSequence): string {
821
921
  const round = (v: number, d: number) => Number(v.toFixed(d));
822
922
  const frames = seq.frames.map((f) => ({
823
923
  t: f.t,
924
+ ...(f.pelvisTilt !== undefined ? { pelvisTilt: f.pelvisTilt === null ? null : round(f.pelvisTilt, 3) } : {}),
824
925
  people: f.people.map((q) => ({
825
926
  id: q.id,
826
927
  joints: Object.fromEntries(
@@ -840,12 +941,14 @@ export function writePoseSequence(seq: PoseSequence): string {
840
941
  const jit = (v: number | null) => (v === null ? null : round(v, 4));
841
942
  const cleaned = c === undefined ? undefined : {
842
943
  spec: c.spec,
843
- params: { loop: c.params.loop, legs: c.params.legs, rest: c.params.rest, ...(c.params.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(c.params.hipsInferred) } : {}), ...(c.params.hold !== undefined ? { hold: holdParamsOf(c.params.hold) } : {}) },
944
+ params: { loop: c.params.loop, legs: c.params.legs, rest: c.params.rest, ...(c.params.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(c.params.hipsInferred) } : {}), ...(c.params.hold !== undefined ? { hold: holdParamsOf(c.params.hold) } : {}), ...(c.params.pelvis !== undefined ? { pelvis: { window: c.params.pelvis.window } } : {}) },
844
945
  spikes: { found: c.spikes.found.map((s) => ({ frame: s.frame, joint: s.joint })), untested: [...c.spikes.untested] },
845
946
  jitter: { before: jit(c.jitter.before), after: jit(c.jitter.after) },
846
947
  ...(c.holds !== undefined ? { holds: c.holds.map((h) => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], ...(h.excursion !== undefined ? { excursion: [h.excursion[0], h.excursion[1]] } : {}), jitter: { before: jit(h.jitter.before), after: jit(h.jitter.after) } })) } : {}),
847
948
  ...(c.seams !== undefined ? { seams: c.seams.map((x) => ({ joint: x.joint, frame: x.frame, step: round(x.step, 4), gap: x.gap })) } : {}),
848
949
  ...(c.unheld !== undefined ? { unheld: c.unheld.map((u) => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value === null ? null : round(u.value, 4) })) } : {}),
849
950
  };
850
- return `${JSON.stringify({ spec: seq.spec, space: seq.space, width: seq.width, height: seq.height, source: seq.source, ...(seq.cameraSource !== undefined ? { cameraSource: seq.cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), ...(seq.travel !== undefined ? { travel: seq.travel } : {}), frames }, null, 2)}\n`;
951
+ // rig-c/pose#288: the minor the content needs — /1.1 with a pelvis track, /1 otherwise.
952
+ const spec = seq.pelvisSource !== undefined ? POSE_SPEC_PELVIS : seq.spec;
953
+ return `${JSON.stringify({ spec, space: seq.space, width: seq.width, height: seq.height, source: seq.source, ...(seq.cameraSource !== undefined ? { cameraSource: seq.cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.pelvisSource !== undefined ? { pelvisSource: seq.pelvisSource } : {}), frames }, null, 2)}\n`;
851
954
  }
package/src/problems.ts CHANGED
@@ -59,10 +59,13 @@ export function isObject(v: unknown): v is Record<string, unknown> {
59
59
  return typeof v === 'object' && v !== null && !Array.isArray(v);
60
60
  }
61
61
 
62
- /** Refuse the keys of `obj` a reader does not know, by name, listing the ones it does. */
63
- export function refuseUnknownKeys(problems: Problems, code: string, where: string, obj: Record<string, unknown>, known: readonly string[]): void {
62
+ /**
63
+ * Refuse the keys of `obj` a reader does not know, by name, listing the ones it does. `note`, when
64
+ * given, ends each line: a sequence written by a newer minor of its format says which (rig-c/pose#288).
65
+ */
66
+ export function refuseUnknownKeys(problems: Problems, code: string, where: string, obj: Record<string, unknown>, known: readonly string[], note = ''): void {
64
67
  for (const k of Object.keys(obj)) {
65
- if (!known.includes(k)) problems.add(code, `${where} has a key this reader does not know: ${JSON.stringify(k)}; the keys it reads are ${known.join(', ')}`);
68
+ if (!known.includes(k)) problems.add(code, `${where} has a key this reader does not know: ${JSON.stringify(k)}; the keys it reads are ${known.join(', ')}${note}`);
66
69
  }
67
70
  }
68
71
 
package/src/retarget.ts CHANGED
@@ -80,7 +80,7 @@ import type { Joint, Travel } from './pose.ts';
80
80
  import { TRAVEL_IN_PLACE } from './pose.ts';
81
81
  import { Problems, Refusal } from './problems.ts';
82
82
  import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
- import { clampArt, coveredChains, isRaised, measureArt, overflow, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
83
+ import { clampArt, coveredChains, isRaised, measureArt, overflow, phaseGroupProblems, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
84
84
  import type { BlendHold, Closure, Feet, Floor, LegScale, LegSegmentRatio, PathPose, Posing, Side, Stance } from './legs.ts';
85
85
  import { figureScaleOf, freeAnkleShiftOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, shiftOver, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
86
86
  import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
@@ -174,6 +174,33 @@ export interface RetargetOptions extends SwitchOptions {
174
174
  travel?: Travel;
175
175
  /** The sequence was cleaned with `--loop` (its `cleaned.params.loop`, rig-c/pose#241): the head nod's detrend window wraps, the last frame running into the first. */
176
176
  loop?: boolean;
177
+ /**
178
+ * rig-c/pose#288: the sequence's pelvis track (`pelvisTilt` per frame, null where
179
+ * declined, and its `pelvisSource`). The pelvis chain's bone (a chain whose
180
+ * joints are r_hip → l_hip) is keyed from it on every frame it reads, and from
181
+ * the hip line on the frames it does not; absent, from the hip line.
182
+ */
183
+ pelvis?: { source: string; tilt: ReadonlyArray<number | null> };
184
+ }
185
+
186
+ /**
187
+ * rig-c/pose#288: where the pelvis bone's rotation came from. `source` is the
188
+ * track's (`silhouette`, `waistband`, `keys`) or "hip line"; `bone`/`chain` the
189
+ * map's pelvis chain (null without one, when nothing is keyed); `swing` the
190
+ * peak-to-peak of its rotate keys, degrees (null when not keyed); `track` and
191
+ * `hipLine` how many frames each keyed; `trackSource` the track's source when the
192
+ * sequence carries one (also when it read no frame and the hip line was keyed).
193
+ */
194
+ export interface PelvisReading {
195
+ source: string;
196
+ trackSource: string | null;
197
+ bone: string | null;
198
+ chain: string | null;
199
+ swing: number | null;
200
+ track: number;
201
+ hipLine: number;
202
+ /** The frames keyed from the hip line on a sequence that carries a track: the frames its reading declined. */
203
+ declined: Array<[number, number]>;
177
204
  }
178
205
 
179
206
  export interface Hold {
@@ -448,6 +475,8 @@ export interface Retarget {
448
475
  nodSkipped: NodSkipped | null;
449
476
  /** Each `reach` chain hanging below a bone the animation turns or translates (#236): its aim's departure from the pose pair's angle. */
450
477
  reach: ReachReading[];
478
+ /** rig-c/pose#288: the pelvis bone's source; null when the map has no pelvis chain and the sequence no track. */
479
+ pelvis: PelvisReading | null;
451
480
  }
452
481
 
453
482
  export interface FigureScaleFrame {
@@ -809,6 +838,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
809
838
  // Each mapped bone's target world angle per frame, reaches last.
810
839
  const targets = new Map<string, number[]>();
811
840
  const restOf = new Map<string, number>();
841
+ // rig-c/pose#288: the pelvis chain (one bone, joints r_hip → l_hip either way) keyed from the track where it reads.
842
+ const pelvisChain = map.chains.find((c) => !c.reach && c.bones.length === 1 && ((c.joints[0] === 'r_hip' && c.joints[1] === 'l_hip') || (c.joints[0] === 'l_hip' && c.joints[1] === 'r_hip'))) ?? null;
843
+ const track = opts.pelvis !== undefined && opts.pelvis.tilt.length === n ? opts.pelvis.tilt : null;
844
+ const fromTrack = pelvisChain !== null && track !== null && track.some((v) => v !== null) ? pelvisChain.bones[0]! : null;
812
845
  for (const c of map.chains) {
813
846
  c.bones.forEach((b, k) => {
814
847
  restOf.set(b, restAngle(rest, b, c.aim[k]!));
@@ -817,6 +850,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
817
850
  const to = series.get(c.joints[k + 1]!)!;
818
851
  const angles: number[] = [];
819
852
  for (let i = 0; i < n; i++) {
853
+ const v = b === fromTrack ? track![i] : null;
854
+ if (v !== null && v !== undefined) {
855
+ // The track is the r_hip → l_hip direction; a chain written l_hip → r_hip aims the other way.
856
+ angles.push(c.joints[0] === 'r_hip' ? v : wrap(v + 180));
857
+ continue;
858
+ }
820
859
  const d = Math.hypot(to[i]![0] - from[i]![0], to[i]![1] - from[i]![1]);
821
860
  angles.push(d > 1e-9 ? pictureAngle(from[i]!, to[i]!) : (angles[i - 1] ?? NaN));
822
861
  }
@@ -842,6 +881,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
842
881
  const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
843
882
  const across = isAcross(joints[0], joints[1]);
844
883
  if (!across && opts.short === 'off') return;
884
+ // rig-c/pose#288: a pelvis keyed from its track is not read off the hip pair's length, so it is not held across it.
885
+ if (b === fromTrack) return;
845
886
  const from = series.get(joints[0])!;
846
887
  const to = series.get(joints[1])!;
847
888
  const lengths = from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1]));
@@ -1211,12 +1252,22 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1211
1252
  if (opts.stance !== undefined && opts.stance !== 'off' && !plantOn) p.add('STANCE', `--stance ${opts.stance} plants the free foot beside the supporting one that --plant-feet holds, and --plant-feet is not given`);
1212
1253
  if (opts.floor !== undefined && !plantOn) p.add('FLOOR', `--floor ${opts.floor} is the floor --plant-feet plants every support span on, and --plant-feet is not given`);
1213
1254
  if (opts.floor !== undefined && !Number.isFinite(opts.floor)) p.add('FLOOR', `--floor is ${opts.floor}; it must be a finite world y`);
1255
+ // rig-c/pose#286: a set with a `phase` shows on the report's support spans, read with or without --plant-feet; a run that
1256
+ // reads no feet has none to show it on, and a group of competing sets mixing phase and angle sets has no rule.
1257
+ const phased = map.alternates.filter((a) => a.phase !== undefined);
1258
+ if (phased.length > 0) {
1259
+ if (feet === null) {
1260
+ const why = legs !== null && 'problems' in legs ? legs.problems.join('; ') : `the feet are read from both hips and both ankles, and the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`;
1261
+ p.add('PHASE_FEET', `set(s) ${phased.map((a) => `"${a.name}" ("phase": "${a.phase}")`).join(', ')} show by the supporting foot (left-step while the right foot supports), and this run reads no supports: ${why}`);
1262
+ }
1263
+ for (const x of phaseGroupProblems(map.alternates)) p.add('PHASE_GROUP', x);
1264
+ }
1214
1265
  p.throwIfAny();
1215
1266
  const askedScales = new Map<string, number[]>();
1216
1267
  for (const ls of legScale) askedScales.set(ls.bone, ls.asked);
1217
- /** Every bone on the legs' path posed on frame i, by the targets and the given scale series. */
1218
- const poseLegsWith = (i: number, sc: ReadonlyMap<string, number[]>): PathPose => {
1219
- const r = posePath(rest, legPath!, (b): Posing | null => (targets.has(b) ? { turn: restTurn(b, i), target: targets.get(b)![i]!, aimX: aimX.get(b)! } : null), (b) => sc.get(b)?.[i] ?? null);
1268
+ /** Every bone on the legs' path posed on frame i by the targets, with the given scale series read on frame `at` (#285; i by default). */
1269
+ const poseLegsWith = (i: number, sc: ReadonlyMap<string, number[]>, at = i): PathPose => {
1270
+ const r = posePath(rest, legPath!, (b): Posing | null => (targets.has(b) ? { turn: restTurn(b, i), target: targets.get(b)![i]!, aimX: aimX.get(b)! } : null), (b) => sc.get(b)?.[at] ?? null);
1220
1271
  if (typeof r === 'string') throw new Refusal([{ code: 'LEGS_MAP', text: r }]);
1221
1272
  return r;
1222
1273
  };
@@ -1241,7 +1292,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1241
1292
  // #119: a tracked support follows the lower ankle's own picture x, scaled, on the floor until it is still.
1242
1293
  const track = (i: number, s: Side) => series.get(`${s}_ankle`)![i]![0] * scale!.factor;
1243
1294
  const follow = inPlace ? track : undefined;
1244
- const planted = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY], opts.floor, follow, track);
1295
+ // #285: past its span, an outgoing plant holds its ankle with the leg as the pose asked it on its last supporting frame.
1296
+ const ankleBone = (s: Side) => (legs !== null && 'legs' in legs ? legs.legs[s].ankleBone : '');
1297
+ const held = legScaleOn ? (i: number, s: Side, at: number) => tipOf(rest, poseLegsWith(i, askedScales, at), ankleBone(s)) : undefined;
1298
+ const planted = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY], opts.floor, follow, track, held);
1245
1299
  const hip = planted.hip;
1246
1300
  floor = planted.floor;
1247
1301
  closure = planted.closure;
@@ -1482,7 +1536,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1482
1536
  }
1483
1537
  }
1484
1538
  const rawTargets = new Map<string, number[]>([...targets].map(([b, arr]) => [b, [...arr]]));
1485
- const sw = selectSwitches(map, targets, keyTimes, keyTimes[n - 1]!, slots, { ...opts, shortInfo, handOffsets });
1539
+ const sw = selectSwitches(map, targets, keyTimes, keyTimes[n - 1]!, slots, { ...opts, shortInfo, handOffsets, ...(phased.length > 0 && feet !== null ? { phaseSupports: feet.supports } : {}) });
1486
1540
 
1487
1541
  // The range clamp, outside the spans of a set depicting the bone's chain. Parents
1488
1542
  // first, so a bone below takes its clamped parent into its key; a reach is aimed
@@ -3035,6 +3089,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3035
3089
  const reach = reachReadings(rest, map, tracks, reachAims, t?.bone ?? null);
3036
3090
 
3037
3091
  const pivot = pivotOf(rest, t?.bone ?? null, legs);
3092
+ const pelvis = pelvisReadingOf(pelvisChain, opts.pelvis, track, fromTrack, tracks);
3038
3093
  const scaleFit = t === null || scale === null || origin === null ? null : scaleFitOf({
3039
3094
  joints: t.scale.pose, joint: t.joint, bone: t.bone, lengths: scaleLengths, factor: scale.factor,
3040
3095
  series, good, legLength: feet?.legLength ?? null, legStraight, rigLeg,
@@ -3045,10 +3100,53 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3045
3100
  animation: { duration: keyTimes[n - 1]!, tracks },
3046
3101
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale, scaleFit,
3047
3102
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
3048
- legScale, segments, legStraight, rigLeg, scales, feet, planted: plantOn, stance, floor, blendHold, closure, openDrift, ankles, pivot, raised, byHip, face, body, figureScale, headNod: headNodReading, nodSkipped, reach,
3103
+ legScale, segments, legStraight, rigLeg, scales, feet, planted: plantOn, stance, floor, blendHold, closure, openDrift, ankles, pivot, raised, byHip, face, body, figureScale, headNod: headNodReading, nodSkipped, reach, pelvis,
3049
3104
  };
3050
3105
  }
3051
3106
 
3107
+ /** rig-c/pose#288: the pelvis bone's source, from the chain, the track and the keys written. */
3108
+ function pelvisReadingOf(chain: Chain | null, given: RetargetOptions['pelvis'], track: ReadonlyArray<number | null> | null, fromTrack: string | null, tracks: ReadonlyArray<MotionTrack | SlotTrack>): PelvisReading | null {
3109
+ if (chain === null && given === undefined) return null;
3110
+ const bone = chain?.bones[0] ?? null;
3111
+ const keys = bone === null ? null : tracks.find((tr): tr is MotionTrack => 'bone' in tr && tr.bone === bone && tr.property === 'rotate');
3112
+ const vs = keys === null || keys === undefined ? [] : keys.keys.map((k) => k.v[0]!);
3113
+ const swing = vs.length === 0 ? null : r3(Math.max(...vs) - Math.min(...vs));
3114
+ const n = vs.length;
3115
+ const declined: Array<[number, number]> = [];
3116
+ (track ?? []).forEach((v, i) => {
3117
+ if (v !== null) return;
3118
+ const last = declined[declined.length - 1];
3119
+ if (last !== undefined && last[1] === i - 1) last[1] = i;
3120
+ else declined.push([i, i]);
3121
+ });
3122
+ const onTrack = fromTrack === null ? 0 : (track ?? []).filter((v) => v !== null).length;
3123
+ return {
3124
+ source: fromTrack !== null ? given!.source : 'hip line',
3125
+ trackSource: given?.source ?? null,
3126
+ bone,
3127
+ chain: chain?.name ?? null,
3128
+ swing,
3129
+ track: onTrack,
3130
+ hipLine: bone === null ? 0 : n - onTrack,
3131
+ declined: given === undefined ? [] : declined,
3132
+ };
3133
+ }
3134
+
3135
+ /**
3136
+ * The PELVIS line (rig-c/pose#288): where the pelvis bone's rotation came from and
3137
+ * its swing — "from <source>, swing N°", or "from hip line (no track)".
3138
+ */
3139
+ export function pelvisLine(r: PelvisReading, frames: number): string {
3140
+ const spans = (rs: Array<[number, number]>) => rs.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
3141
+ const where = r.bone === null ? '' : `bone "${r.bone}", chain "${r.chain}"`;
3142
+ if (r.bone === null) return `PELVIS: not keyed — the sequence carries a ${r.trackSource} track (${frames - r.declined.reduce((s, [a, b]) => s + b - a + 1, 0)} of ${frames} frames read) and the map has no pelvis chain (one bone, joints r_hip → l_hip); no bone turns with the pelvis`;
3143
+ const swing = r.swing === null ? '' : `, swing ${r.swing.toFixed(2)}°`;
3144
+ if (r.trackSource === null) return `PELVIS: from hip line (no track)${swing} (${where}; every frame from r_hip → l_hip)`;
3145
+ if (r.track === 0) return `PELVIS: from hip line (the ${r.trackSource} track read none of the ${frames} frames)${swing} (${where}; every frame from r_hip → l_hip)`;
3146
+ const rest = r.hipLine === 0 ? `all ${frames} frames from the track` : `${r.track} of ${frames} frames from the track, the hip line on the ${r.hipLine} it declined (frames ${spans(r.declined)})`;
3147
+ return `PELVIS: from ${r.source}${swing} (${where}; ${rest})`;
3148
+ }
3149
+
3052
3150
  /** A reach bone's origin within this many world units of the anchor's, with nothing above it moved, is aimed along the pair: no REACH line. */
3053
3151
  export const REACH_OFFSET = 1e-6;
3054
3152
 
package/src/run.ts CHANGED
@@ -13,10 +13,10 @@ import { cleanLine, cleanSummaryLine, inferredSource } from './clean.ts';
13
13
  import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
14
14
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
15
15
  import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
16
- import { bendGroupLine, bendGroups, bendLine, endOnLine, flipLine, holdLine, nodLine, rangeLine, reachCostLine, retarget, shortLine, turnLine, type MotionTrack, type RetargetOptions, type ScaleBand, type ScaleFit } from './retarget.ts';
16
+ import { bendGroupLine, bendGroups, bendLine, endOnLine, flipLine, holdLine, nodLine, pelvisLine, rangeLine, reachCostLine, retarget, shortLine, turnLine, type MotionTrack, type RetargetOptions, type ScaleBand, type ScaleFit } from './retarget.ts';
17
17
  import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, seamRender, slotsOf } from './rigc.ts';
18
18
  import { partsPath, projectSeams, readSeamDoc, readSeamPairs, seamLines, type SeamPair } from './seams.ts';
19
- import { artRangeLine, chainsMoving, raisedLine, RAISED_WITHIN, reachLine, switchLine, uncoveredFolds } from './switch.ts';
19
+ import { artRangeLine, chainsMoving, phaseLines, raisedLine, RAISED_WITHIN, reachLine, switchLine, uncoveredFolds } from './switch.ts';
20
20
  import { jitterLine, landed, type FeetReading, type JitterRow, type Landing, measureFeet, measureJitter, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
21
21
  import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
22
22
  import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, smoothTrunk, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
@@ -151,7 +151,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
151
151
  const slots = slotsOf(doc0);
152
152
  resolveMap(map, rest, a.map, slots, input.sets);
153
153
 
154
- const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.cleaned!.params.loop ? { loop: true } : {}) }, slots, input.sets);
154
+ const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.cleaned!.params.loop ? { loop: true } : {}), ...(seq.pelvisSource !== undefined ? { pelvis: { source: seq.pelvisSource, tilt: seq.frames.map((f) => f.pelvisTilt ?? null) } } : {}) }, slots, input.sets);
155
155
  if (fragment.length > 0 && ((r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
156
156
  const known = knownSetsOf(map, input.sets);
157
157
  for (const s of [...(r.face?.sets ?? []), ...(r.body?.sets ?? [])]) {
@@ -196,6 +196,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
196
196
  if (r.switches.length === 0) say(` SWITCH none — no set within ${a.switchTolerance}° of the pose for ${a.switchMin}s`);
197
197
  for (const s of r.switches) say(` SWITCH ${switchLine(s, r.times)}`);
198
198
  for (const l of windowLines(map, r.switches, r.distances, r.times, a.switchTolerance)) say(` FRAMES ${l}`);
199
+ for (const l of phaseLines(map, r.switches)) say(` PHASE ${l}`);
199
200
  }
200
201
  for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
201
202
  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`);
@@ -208,6 +209,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
208
209
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
209
210
  for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times, r.feet?.legLength ?? null, r.rigLeg)}`);
210
211
  for (const l of r.legScale) for (const s of l.support ?? []) say(` LEG-SCALE ${supportScaleLine(l, s, r.times)}`);
212
+ if (r.pelvis !== null) say(` ${pelvisLine(r.pelvis, seq.frames.length)}`);
211
213
  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`);
212
214
  if (r.feet !== null) {
213
215
  const f = r.feet;
@@ -441,6 +443,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
441
443
  },
442
444
  legScale: r.legScale.map(({ scale: _scale, asked: _asked, picture: _picture, ...rest }) => rest),
443
445
  pivot: r.pivot,
446
+ pelvis: r.pelvis,
444
447
  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 },
445
448
  feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, straight: r.legStraight, rigLeg: r.rigLeg, segments: r.segments, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, ...(r.feet.lowered === undefined ? {} : { lowered: r.feet.lowered }), ...(r.feet.tracked === undefined ? {} : { tracked: r.feet.tracked }), 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 },
446
449
  shear,