@rig-c/pose 0.37.1 → 0.39.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
@@ -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,6 +1252,16 @@ 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);
@@ -1485,7 +1536,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1485
1536
  }
1486
1537
  }
1487
1538
  const rawTargets = new Map<string, number[]>([...targets].map(([b, arr]) => [b, [...arr]]));
1488
- 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 } : {}) });
1489
1540
 
1490
1541
  // The range clamp, outside the spans of a set depicting the bone's chain. Parents
1491
1542
  // first, so a bone below takes its clamped parent into its key; a reach is aimed
@@ -3038,6 +3089,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3038
3089
  const reach = reachReadings(rest, map, tracks, reachAims, t?.bone ?? null);
3039
3090
 
3040
3091
  const pivot = pivotOf(rest, t?.bone ?? null, legs);
3092
+ const pelvis = pelvisReadingOf(pelvisChain, opts.pelvis, track, fromTrack, tracks);
3041
3093
  const scaleFit = t === null || scale === null || origin === null ? null : scaleFitOf({
3042
3094
  joints: t.scale.pose, joint: t.joint, bone: t.bone, lengths: scaleLengths, factor: scale.factor,
3043
3095
  series, good, legLength: feet?.legLength ?? null, legStraight, rigLeg,
@@ -3048,10 +3100,53 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3048
3100
  animation: { duration: keyTimes[n - 1]!, tracks },
3049
3101
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale, scaleFit,
3050
3102
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
3051
- 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,
3052
3104
  };
3053
3105
  }
3054
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
+
3055
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. */
3056
3151
  export const REACH_OFFSET = 1e-6;
3057
3152
 
package/src/run.ts CHANGED
@@ -10,19 +10,21 @@ import { tmpdir } from 'node:os';
10
10
  import { dirname, join, relative, resolve } from 'node:path';
11
11
  import { knownSetsOf, readMap, resolveMap, type JointMap, type Rest } from './map.ts';
12
12
  import { cleanLine, cleanSummaryLine, inferredSource } from './clean.ts';
13
+ import { depthLine, layersOf, type FrameLayers } from './depth.ts';
13
14
  import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
14
15
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
15
16
  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';
17
+ 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
18
  import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, seamRender, slotsOf } from './rigc.ts';
18
19
  import { partsPath, projectSeams, readSeamDoc, readSeamPairs, seamLines, type SeamPair } from './seams.ts';
19
- import { artRangeLine, chainsMoving, raisedLine, RAISED_WITHIN, reachLine, switchLine, uncoveredFolds } from './switch.ts';
20
+ import { artRangeLine, chainsMoving, phaseLines, raisedLine, RAISED_WITHIN, reachLine, switchLine, uncoveredFolds } from './switch.ts';
20
21
  import { jitterLine, landed, type FeetReading, type JitterRow, type Landing, measureFeet, measureJitter, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
21
- import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
22
+ import { FRONTAL_DISAGREE, legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
22
23
  import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, smoothTrunk, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
23
- import type { BlendHold, Closure, Floor, FreeAnkleShift, LegScale, LegSegmentRatio, Lowered, Side, Stance, SupportScaleRun } from './legs.ts';
24
+ import type { BlendHold, Closure, Feet, Floor, FreeAnkleShift, LegScale, LegSegmentRatio, Lowered, Side, Stance, SupportScaleRun, SupportSignal } from './legs.ts';
24
25
  import { reportRenderDomain, writeReport, type ReportInput } from './report.ts';
25
26
  import { PROVENANCE_FILE, provenanceText } from './provenance.ts';
27
+ import { madeLine, planOutDirs } from './outdir.ts';
26
28
  import { bodyLine, faceLine } from './face.ts';
27
29
 
28
30
  export interface RetargetArgs extends RetargetOptions {
@@ -104,15 +106,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
104
106
  const report = a.report ?? defaultReportPath(a.out);
105
107
  // A file this run writes goes into a directory the user named: one that is not there yet is made
106
108
  // after the run is green (one line says so), as --build and --render make theirs; a file in the way is refused.
107
- const makeDirs = new Map<string, string[]>();
108
- for (const [flag, path] of [['--out', a.out], ['--report', report], ['--camera', a.camera]] as const) {
109
- if (path === undefined) continue;
110
- const dir = dirname(path);
111
- let up = dir;
112
- while (!existsSync(up) && dirname(up) !== up) up = dirname(up);
113
- if (!statSync(up).isDirectory()) args.add('ARG_PATH', up === dir ? `${flag} ${path} — ${dir} is not a directory` : `${flag} ${path} — ${up} is a file, so directory ${dir} cannot be made under it`);
114
- else if (up !== dir) makeDirs.set(dir, [...(makeDirs.get(dir) ?? []), `${flag} ${path}`]);
115
- }
109
+ const makeDirs = planOutDirs([['--out', a.out], ['--report', report], ['--camera', a.camera]], args);
110
+ // #284: --build and --render make their own directories (rigc, the copy); a file in the way is refused here, up front.
111
+ planOutDirs([['--build', a.build], ['--render', a.render]], args);
116
112
  if (a.reference !== undefined && a.build === undefined) args.add('ARG_REFERENCE', `--reference ${a.reference} needs --build: the record it goes in is written beside the build (${PROVENANCE_FILE})`);
117
113
  args.throwIfAny();
118
114
  const input = resolveRig(a.rig, a.baseMotion);
@@ -151,7 +147,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
151
147
  const slots = slotsOf(doc0);
152
148
  resolveMap(map, rest, a.map, slots, input.sets);
153
149
 
154
- const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.cleaned!.params.loop ? { loop: true } : {}) }, slots, input.sets);
150
+ 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
151
  if (fragment.length > 0 && ((r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
156
152
  const known = knownSetsOf(map, input.sets);
157
153
  for (const s of [...(r.face?.sets ?? []), ...(r.body?.sets ?? [])]) {
@@ -196,6 +192,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
196
192
  if (r.switches.length === 0) say(` SWITCH none — no set within ${a.switchTolerance}° of the pose for ${a.switchMin}s`);
197
193
  for (const s of r.switches) say(` SWITCH ${switchLine(s, r.times)}`);
198
194
  for (const l of windowLines(map, r.switches, r.distances, r.times, a.switchTolerance)) say(` FRAMES ${l}`);
195
+ for (const l of phaseLines(map, r.switches)) say(` PHASE ${l}`);
199
196
  }
200
197
  for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
201
198
  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`);
@@ -206,8 +203,14 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
206
203
  for (const e of r.byHip) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
207
204
  for (const e of r.reach) say(` REACH ${reachCostLine(e, r.times)}`);
208
205
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
209
- for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times, r.feet?.legLength ?? null, r.rigLeg)}`);
206
+ // #287: a range that clamps no frame says so once, on the last segment's line (the line count stays one per segment).
207
+ const noClamp = legScaleNoClamp(r.legScale);
208
+ r.legScale.forEach((l, k) => say(` LEG-SCALE ${legScaleLine(l, r.times, r.feet?.legLength ?? null, r.rigLeg)}${k === r.legScale.length - 1 && noClamp !== null ? `; ${noClamp}` : ''}`));
210
209
  for (const l of r.legScale) for (const s of l.support ?? []) say(` LEG-SCALE ${supportScaleLine(l, s, r.times)}`);
210
+ if (r.pelvis !== null) say(` ${pelvisLine(r.pelvis, seq.frames.length)}`);
211
+ // rig-c/pose#281: the limbs' front-to-back order from the sequence's depth, only when it carries depth.
212
+ const layers: FrameLayers[] | null = seq.frames.some((x) => x.depth !== undefined) ? frames.map((j, i) => layersOf(j, seq.frames[i]!.depth ?? {})) : null;
213
+ if (layers !== null) say(` ${depthLine(layers, seq.frames, seq.cleaned!.params.depth?.gait ?? null)}`);
211
214
  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
215
  if (r.feet !== null) {
213
216
  const f = r.feet;
@@ -215,6 +218,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
215
218
  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)`);
216
219
  for (const sg of r.segments) say(` FEET ${segmentLine(sg)}`);
217
220
  say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
221
+ if (f.signal !== undefined) for (const l of signalLines(f.signal, f.supports)) say(` FEET ${l}`);
218
222
  say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
219
223
  if (f.lowered !== undefined) say(` FEET ${loweredLine(f.lowered)}`);
220
224
  if (f.tracked !== undefined) say(` FEET tracked (the lower foot supports on the floor, following its scaled ankle track until still, rig-c/pose#119): ${f.tracked.map((x) => `${x.side} ${span(x.frames)}`).join(', ')}`);
@@ -353,7 +357,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
353
357
 
354
358
  const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
355
359
  const hips = seq.cleaned?.params.hipsInferred;
356
- const posture = measurePosture(posed, lengths, map, r, frames, a.minScore, inferredSource(seq.cleaned), hips?.by === 'measured' ? hips : null);
360
+ const posture = measurePosture(posed, lengths, map, r, frames, a.minScore, inferredSource(seq.cleaned), hips?.by === 'measured' ? hips : null, seq.pelvisSource !== undefined ? { source: seq.pelvisSource, tilt: seq.frames.map((f) => f.pelvisTilt ?? null) } : null);
357
361
  if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
358
362
  const legBones = legBonesOf(map);
359
363
  const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
@@ -441,6 +445,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
441
445
  },
442
446
  legScale: r.legScale.map(({ scale: _scale, asked: _asked, picture: _picture, ...rest }) => rest),
443
447
  pivot: r.pivot,
448
+ pelvis: r.pelvis,
449
+ // Only when the sequence carries depth (#281): a sequence without it writes the report it always has.
450
+ ...(layers !== null ? { layers: layers.map((l, frame) => ({ frame, order: l.order, depth: l.depth })) } : {}),
444
451
  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
452
  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
453
  shear,
@@ -913,6 +920,48 @@ function loweredLine(lowered: readonly Lowered[]): string {
913
920
  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(', ')}`;
914
921
  }
915
922
 
923
+ /**
924
+ * The support-signal lines (rig-c/pose#280). On a clip read as frontal: two
925
+ * lines — how often the ankle's vertical speed contradicts ankle height against
926
+ * `FRONTAL_DISAGREE`, and where the stride shows (in depth) — then one line per
927
+ * support span (a stride) naming the signal that decided its frames and what
928
+ * ankle height alone would have read. On any other clip ankle height decided as
929
+ * before and nothing is printed: the output of a clip the second signal does not
930
+ * decide stays as it was, in every mode (`Feet.signal` still carries the figures).
931
+ */
932
+ export function signalLines(sig: SupportSignal, supports: Feet['supports']): string[] {
933
+ const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
934
+ const n = Math.round(FRONTAL_DISAGREE * 100);
935
+ const pct = sig.read === 0 ? 0 : (100 * sig.contradict.length) / sig.read;
936
+ if (!sig.frontal) return [];
937
+ const ranges = `x · y against the hips: r ${sig.range.r.x.toFixed(1)} · ${sig.range.r.y.toFixed(1)} px, l ${sig.range.l.x.toFixed(1)} · ${sig.range.l.y.toFixed(1)} px`;
938
+ const contra = `${sig.contradict.length} of ${sig.read} frame(s) (${pct.toFixed(1)} %`;
939
+ const out = [
940
+ `support: ankle vertical speed (rig-c/pose#280) — it contradicts ankle height on ${contra}, past ${n} %): the lower ankle moving while the higher moves with the ground`,
941
+ `support: read as a frontal view — ankles move more in y than x (${ranges}); ankle height only where neither foot moves with the ground`,
942
+ ];
943
+ for (const sp of supports) {
944
+ let speed = 0;
945
+ let height = 0;
946
+ let both = 0;
947
+ let differ = 0;
948
+ const was = new Set<string>();
949
+ for (let i = sp.frames[0]; i <= sp.frames[1]; i++) {
950
+ if (sig.by[i] === 'speed') speed++;
951
+ else if (sig.by[i] === 'height') height++;
952
+ if (sig.contact[i] === 'both') both++;
953
+ if (sig.height[i] !== sp.side) {
954
+ differ++;
955
+ was.add(sig.height[i] ?? 'none');
956
+ }
957
+ }
958
+ const by = speed > 0 ? `ankle vertical speed on ${speed} frame(s)${height > 0 ? `, ankle height on ${height} (neither foot moved with the ground)` : ''}` : `ankle height on ${height} frame(s) (neither foot moved with the ground)`;
959
+ const alone = differ === 0 ? 'ankle height alone reads the same' : `ankle height alone would read ${[...was].join(' or ')} on ${differ} of them`;
960
+ out.push(`stride ${sp.side} ${span(sp.frames)}: ${by} (both feet down on ${both}); ${alone}`);
961
+ }
962
+ return out;
963
+ }
964
+
916
965
  /**
917
966
  * The in-place line without --plant-feet (rig-c/pose#242): what the declaration still changes, and the one thing it
918
967
  * cannot without planted feet — the loop closure, which spreads back the planted hip's drift. `drift` is the
@@ -1049,6 +1098,21 @@ export function legScaleLine(l: LegScale, times: readonly number[], legLength: R
1049
1098
  return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (larger 95th percentile of ${times.length} frames, both legs)${share}; 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]}] of the straight length (${limitsPx}): ${clamp(l.low, l.limits[0], 'under', 'low')}; ${clamp(l.high, l.limits[1], 'over', 'high')}${free}`;
1050
1099
  }
1051
1100
 
1101
+ /**
1102
+ * rig-c/pose#287: when `--leg-scale`'s range clamps no frame on any leg segment
1103
+ * (no run under the floor, none over the ceiling, no supporting frame scaled past
1104
+ * the floor), the clause naming the range and the picture's own spread across
1105
+ * every segment, so a reader learns the range never bound and what floor would.
1106
+ * Null when any frame was clamped, or with no segment scaled.
1107
+ */
1108
+ export function legScaleNoClamp(ls: readonly LegScale[]): string | null {
1109
+ if (ls.length === 0 || ls.some((l) => l.low.length > 0 || l.high.length > 0 || (l.support ?? []).length > 0)) return null;
1110
+ const [floor, ceiling] = ls[0]!.limits;
1111
+ const min = Math.min(...ls.map((l) => l.ratio.min));
1112
+ const max = Math.max(...ls.map((l) => l.ratio.max));
1113
+ return `no frame clamped on any leg segment: the range [${floor}, ${ceiling}] holds the picture's whole spread, picture length ÷ straight ${min.toFixed(3)} … ${max.toFixed(3)} over the ${ls.length} segment(s), so each segment is scaled to the picture on every frame; a floor above ${min.toFixed(3)} would clamp`;
1114
+ }
1115
+
1052
1116
  /** A LEG-SCALE past the floor (support) line (rig-c/pose#133): the supporting leg segment scaled below `--leg-scale`'s floor down to #95's bound. */
1053
1117
  export function supportScaleLine(l: LegScale, s: SupportScaleRun, times: readonly number[]): string {
1054
1118
  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)`);
@@ -1133,11 +1197,6 @@ export function defaultReportPath(out: string): string {
1133
1197
  return `${out.endsWith('.json') ? out.slice(0, -'.json'.length) : out}.report.json`;
1134
1198
  }
1135
1199
 
1136
- /** rig-c/pose#239: the line a directory this run made prints — the directory, and the paths that named it. */
1137
- export function madeLine(dir: string, flags: readonly string[]): string {
1138
- return ` made directory ${dir} — it did not exist; ${flags.join(' and ')} ${flags.length === 1 ? 'is' : 'are'} written into it`;
1139
- }
1140
-
1141
1200
  /** rig-c/pose#239: the report's `wrote` line — whether it was the default, and the mtime of the report it replaced. */
1142
1201
  export function reportLine(report: string, byDefault: boolean, previous: Date | null): string {
1143
1202
  return ` wrote ${report} — the run's report (rig-pose-report/1)${byDefault ? ', beside --out by default; name another path with --report' : ''}${previous === null ? '' : `; it replaced the report there last written ${previous.toISOString()}`}`;
package/src/runcompare.ts CHANGED
@@ -5,12 +5,12 @@
5
5
  * not a failure: the run exits 0 whenever it compared.
6
6
  */
7
7
  import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
8
- import { dirname } from 'node:path';
9
8
  import { compare, findingLine, readCloth, readHands, readSpans, summaryLine, writeComparison, type ClothWindow, type CompareInput, type Comparison } from './compare.ts';
10
9
  import { readMap } from './map.ts';
11
10
  import { personTrack, readPoseSequence, type PoseSequence } from './pose.ts';
12
11
  import { Problems, Refusal } from './problems.ts';
13
12
  import { drawable, drawSheet } from './sheet.ts';
13
+ import { makeOutDirs, planOutDirs } from './outdir.ts';
14
14
 
15
15
  export interface CompareArgs {
16
16
  reference: string;
@@ -37,12 +37,9 @@ export function runCompare(a: CompareArgs, log: (line: string) => void): Compari
37
37
  if (!existsSync(path)) p.add('ARG_PATH', `${flag} ${path} does not exist`);
38
38
  else if (!statSync(path).isFile()) p.add('ARG_PATH', `${flag} ${path} is not a file`);
39
39
  }
40
- for (const [flag, path] of [['--report', a.report], ['--sheet', a.sheet]] as const) {
41
- if (path === undefined) continue;
42
- const dir = dirname(path);
43
- if (!existsSync(dir)) p.add('ARG_PATH', `${flag} ${path} — directory ${dir} does not exist`);
44
- else if (!statSync(dir).isDirectory()) p.add('ARG_PATH', `${flag} ${path} — ${dir} is not a directory`);
45
- }
40
+ // #284: a directory --report or --sheet names that is not there yet is made just before that file is written.
41
+ const reportDirs = planOutDirs([['--report', a.report]], p);
42
+ const sheetDirs = planOutDirs([['--sheet', a.sheet]], p);
46
43
  p.throwIfAny();
47
44
 
48
45
  // Every reader runs, and every refusal is reported together.
@@ -98,6 +95,7 @@ export function runCompare(a: CompareArgs, log: (line: string) => void): Compari
98
95
  if (c.cloth.windows.length > 0) log(` CLOTH the render's stroke threshold ${c.cloth.stroke.render.toFixed(4)} of its hip widths = ${c.cloth.stroke.carried}`);
99
96
  }
100
97
  if (a.report !== undefined) {
98
+ makeOutDirs(reportDirs, log);
101
99
  writeFileSync(a.report, writeComparison(c, {
102
100
  reference: a.reference, render: a.render, map: a.map, retargetReport: a.retargetReport ?? null,
103
101
  referenceHands: a.referenceHands ?? null, renderHands: a.renderHands ?? null, clothReference: a.clothReference ?? null, clothRender: a.clothRender ?? null,
@@ -108,6 +106,7 @@ export function runCompare(a: CompareArgs, log: (line: string) => void): Compari
108
106
  const plate = drawSheet({ frames: ref!.frames, width: ref!.seq.width, height: ref!.seq.height }, { frames: ren!.frames, width: ren!.seq.width, height: ren!.seq.height }, c.findings);
109
107
  if (plate === null) log(` SHEET no finding has a frame to draw (${c.findings.length} finding(s), none outside cloth); ${a.sheet} not written`);
110
108
  else {
109
+ makeOutDirs(sheetDirs, log);
111
110
  plate.writePng(a.sheet);
112
111
  log(` SHEET ${drawable(c.findings).length} finding(s), reference | render at each worst frame: wrote ${a.sheet}`);
113
112
  }
package/src/switch.ts CHANGED
@@ -12,6 +12,11 @@
12
12
  * span is dropped, and the base art shows there; a gap shorter than the minimum
13
13
  * span between two runs of one set is bridged by that set. No other hysteresis.
14
14
  *
15
+ * A set with a `phase` (rig-c/pose#286) is not selected by distance: it shows
16
+ * exactly on its supporting foot's spans (`phaseSupports`, the
17
+ * report's `feet.supports`) and switches on the hand-over, with none of the
18
+ * rules here or below applied to it.
19
+ *
15
20
  * Sets compete only with the sets they share a slot with (shown or hidden,
16
21
  * transitively), or a bone they offset: two that touch nothing in common — a
17
22
  * drawing of each arm — can show at once, and two that do never overlap, so
@@ -52,8 +57,9 @@
52
57
  * selection put it; it is the CLI's default, because on HQ's dancer the bounded
53
58
  * peak still raised the sets' ART-RANGE runs from 24 to 31 (#31, round 2).
54
59
  */
55
- import type { AlternateSet, JointMap, Rest, RigSlots } from './map.ts';
56
- import { descends } from './map.ts';
60
+ import type { Side } from './legs.ts';
61
+ import type { AlternateSet, JointMap, Phase, Rest, RigSlots } from './map.ts';
62
+ import { descends, PHASE_SIDE } from './map.ts';
57
63
  import type { MotionKey, MotionTrack } from './retarget.ts';
58
64
  import { pictureAngle, wrap } from './retarget.ts';
59
65
  import type { MeshFolds } from './rigc.ts';
@@ -80,6 +86,19 @@ export interface SwitchOptions {
80
86
  shortInfo?: ShortSwitchInfo;
81
87
  /** Per chain, the reference pose's normalised wrist-from-shoulder offset on each frame, `(wrist - shoulder) / scale`, y down (#180). */
82
88
  handOffsets?: ReadonlyMap<string, ReadonlyArray<readonly [number, number]>>;
89
+ /** The report's `feet.supports` (rig-c/pose#286): the spans a set with a `phase` shows on. Given only when a set declares one. */
90
+ phaseSupports?: ReadonlyArray<{ side: Side; frames: [number, number] }>;
91
+ }
92
+
93
+ /** A phase set's span (rig-c/pose#286): its step side, and the support hand-overs it was switched on and off at. */
94
+ export interface PhaseSpan {
95
+ step: Phase;
96
+ /** The supporting foot it shows on (left-step: the left foot is stepping, so the right foot supports). */
97
+ side: Side;
98
+ /** The hand-over it starts on: the frame, and the side that supported on the frame before (null: no foot). Null from the first frame. */
99
+ on: { frame: number; from: Side | null } | null;
100
+ /** The hand-over it ends on: the first frame after it, and the side that supports there (null: no foot). Null to the end. */
101
+ off: { frame: number; to: Side | null } | null;
83
102
  }
84
103
 
85
104
  /** One boundary of a span: where the selection put it, where it is keyed, and how fast the chains it changes move on each. */
@@ -115,6 +134,8 @@ export interface Switch {
115
134
  artTurn: Record<string, { max: number; median: number }>;
116
135
  /** The boundary it starts on (null from the first frame) and the one it ends on (null to the end); a boundary between two sets is in both. */
117
136
  moved: { on: Move | null; off: Move | null };
137
+ /** Only on a set with a `phase` (rig-c/pose#286): the step side and the hand-overs the span was switched on. */
138
+ phase?: PhaseSpan;
118
139
  }
119
140
 
120
141
  export interface AttachmentKey {
@@ -245,6 +266,28 @@ function touches(a: AlternateSet): string[] {
245
266
  return [...Object.keys(a.slots), ...a.hides].map((s) => `slot:${s}`).concat(Object.keys(a.offsets).map((b) => `bone:${b}`));
246
267
  }
247
268
 
269
+ /**
270
+ * rig-c/pose#286: what the retarget refuses about the map's phase sets, one text each. A group of competing sets (a
271
+ * common slot or offset bone, transitively) that holds a phase set must hold only phase sets, and at most one of each
272
+ * side: the hand-over decides which of them shows, and nothing would decide between two of one side, or between a phase
273
+ * set and an angle set over the frames they share.
274
+ */
275
+ export function phaseGroupProblems(sets: readonly AlternateSet[]): string[] {
276
+ const out: string[] = [];
277
+ for (const group of competing(sets)) {
278
+ const phased = group.filter((a) => a.phase !== undefined);
279
+ if (phased.length === 0) continue;
280
+ const angle = group.filter((a) => a.phase === undefined);
281
+ const named = (xs: readonly AlternateSet[]) => xs.map((a) => `"${a.name}"`).join(', ');
282
+ if (angle.length > 0) out.push(`set(s) ${named(phased)} declare "phase" and compete with set(s) ${named(angle)}, which declare none (they switch a common slot or offset bone); a phase set shows on its supporting foot's spans and an angle set on its distance, and nothing decides between them — give every set of the group a "phase", or none`);
283
+ for (const step of Object.keys(PHASE_SIDE) as Phase[]) {
284
+ const same = phased.filter((a) => a.phase === step);
285
+ if (same.length > 1) out.push(`set(s) ${named(same)} all declare "phase": "${step}" and compete (they switch a common slot or offset bone); one drawing shows per support span — keep one "${step}" set per group`);
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+
248
291
  /** Groups of sets that switch a common slot or offset a common bone, transitively; each in map order. */
249
292
  function competing(sets: readonly AlternateSet[]): AlternateSet[][] {
250
293
  const parent = sets.map((_, i) => i);
@@ -447,8 +490,41 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
447
490
  const distances = setDistances(map, targets, n, opts.shortInfo);
448
491
  const hyst = opts.switchHysteresis ?? SWITCH_LEAVE_HYSTERESIS;
449
492
  const switches: Switch[] = [];
493
+ // rig-c/pose#286: the supporting side on each frame, from the report's support spans (null: no foot, or no set declares a phase).
494
+ const supportAt: Array<Side | null> = Array.from({ length: n }, () => null);
495
+ for (const sp of opts.phaseSupports ?? []) for (let i = sp.frames[0]; i <= Math.min(sp.frames[1], n - 1); i++) supportAt[i] = sp.side;
496
+ /** One group's pick, as segments between boundaries, then each boundary moved (#31), pushed as switches. */
497
+ const spans = (pick: ReadonlyArray<AlternateSet | null>, moveOpts: SwitchOptions): void => {
498
+ const vals: Array<AlternateSet | null> = [pick[0] ?? null];
499
+ const pos: number[] = [];
500
+ for (let i = 1; i < n; i++) {
501
+ if (pick[i] === pick[i - 1]) continue;
502
+ pos.push(i);
503
+ vals.push(pick[i] ?? null);
504
+ }
505
+ const moves = moveBoundaries(vals, pos, map, targets, distances, times, duration, moveOpts);
506
+ for (let s = 0; s < vals.length; s++) {
507
+ const a = vals[s];
508
+ if (a === null || a === undefined) continue;
509
+ const i = s === 0 ? 0 : moves[s - 1]!.frame[1];
510
+ const j = s < pos.length ? moves[s]!.frame[1] - 1 : n - 1;
511
+ const from = times[i]!;
512
+ const to = j + 1 < n ? times[j + 1]! : duration;
513
+ const ds = distances.get(a.name)!.slice(i, j + 1);
514
+ const phase: PhaseSpan | undefined = a.phase === undefined ? undefined : { step: a.phase, side: PHASE_SIDE[a.phase], on: i === 0 ? null : { frame: i, from: supportAt[i - 1] ?? null }, off: j + 1 < n ? { frame: j + 1, to: supportAt[j + 1] ?? null } : null };
515
+ switches.push({ set: a.name, chains: a.chains, slots: a.slots, hides: a.hides, offsets: a.offsets, from, to, frames: [i, j], distance: { max: r3(Math.max(...ds)), median: r3(median(ds)) }, artTurn: {}, moved: { on: s === 0 ? null : moves[s - 1]!, off: s < pos.length ? moves[s]! : null }, ...(phase === undefined ? {} : { phase }) });
516
+ }
517
+ };
450
518
  const groups = competing(map.alternates);
451
519
  for (const group of groups) {
520
+ // rig-c/pose#286: a group of phase sets shows each exactly on its supporting foot's spans — no tolerance, no minimum run,
521
+ // no hysteresis, no bridging, and its boundaries stay on the hand-overs (no --switch-at peak move). The retarget refuses
522
+ // a group mixing phase and angle sets, or two sets of one side (`phaseGroupProblems`), before it gets here.
523
+ if (group.some((a) => a.phase !== undefined)) {
524
+ const pick = supportAt.map((side) => (side === null ? null : group.find((a) => a.phase !== undefined && PHASE_SIDE[a.phase] === side) ?? null));
525
+ spans(pick, { ...opts, switchAt: 'first' });
526
+ continue;
527
+ }
452
528
  const layer = groupLayering(group, map, slots);
453
529
  const pick: Array<AlternateSet | null> = [];
454
530
  for (let i = 0; i < n; i++) {
@@ -582,25 +658,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
582
658
  }
583
659
  if (dropped) bridgeSame(pick);
584
660
  }
585
- // The segments between boundaries, then each boundary moved (#31).
586
- const vals: Array<AlternateSet | null> = [pick[0] ?? null];
587
- const pos: number[] = [];
588
- for (let i = 1; i < n; i++) {
589
- if (pick[i] === pick[i - 1]) continue;
590
- pos.push(i);
591
- vals.push(pick[i] ?? null);
592
- }
593
- const moves = moveBoundaries(vals, pos, map, targets, distances, times, duration, opts);
594
- for (let s = 0; s < vals.length; s++) {
595
- const a = vals[s];
596
- if (a === null || a === undefined) continue;
597
- const i = s === 0 ? 0 : moves[s - 1]!.frame[1];
598
- const j = s < pos.length ? moves[s]!.frame[1] - 1 : n - 1;
599
- const from = times[i]!;
600
- const to = j + 1 < n ? times[j + 1]! : duration;
601
- const ds = distances.get(a.name)!.slice(i, j + 1);
602
- switches.push({ set: a.name, chains: a.chains, slots: a.slots, hides: a.hides, offsets: a.offsets, from, to, frames: [i, j], distance: { max: r3(Math.max(...ds)), median: r3(median(ds)) }, artTurn: {}, moved: { on: s === 0 ? null : moves[s - 1]!, off: s < pos.length ? moves[s]! : null } });
603
- }
661
+ spans(pick, opts);
604
662
  }
605
663
  const order = (name: string) => map.alternates.findIndex((a) => a.name === name);
606
664
  switches.sort((x, y) => x.frames[0] - y.frames[0] || order(x.set) - order(y.set));
@@ -632,6 +690,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
632
690
  // and in a one-sided gesture set keep an arm whose wrist stays behind the torso on its previous behind set.
633
691
  const chainHolds: Array<{ set: string; chain: string; frames: [number, number] }> = [];
634
692
  for (const group of groups) {
693
+ if (group.some((a) => a.phase !== undefined)) continue; // #286: a phase group shows exactly its support spans
635
694
  const layer = groupLayering(group, map, slots);
636
695
  if (!layer.baseInFront) continue;
637
696
  const names = new Set(group.map((a) => a.name));
@@ -1193,6 +1252,35 @@ export function switchLine(s: Switch, times: readonly number[]): string {
1193
1252
  return parts.join('; ');
1194
1253
  }
1195
1254
 
1255
+ /**
1256
+ * rig-c/pose#286: one `PHASE` line per set with a `phase` — the support side it shows on, every span it showed, and the
1257
+ * hand-over each span was switched on and off at (the side before → the side after; "none" where no foot supports).
1258
+ */
1259
+ export function phaseLines(map: JointMap, switches: readonly Switch[]): string[] {
1260
+ const out: string[] = [];
1261
+ const sideText = (x: Side | null) => x ?? 'none';
1262
+ for (const set of map.alternates) {
1263
+ if (set.phase === undefined) continue;
1264
+ const side = PHASE_SIDE[set.phase];
1265
+ const foot = (x: Side) => (x === 'l' ? 'left' : 'right');
1266
+ const head = `set "${set.name}" "${set.phase}" — the ${foot(side === 'l' ? 'r' : 'l')} foot is stepping; shown while the ${foot(side)} foot supports (feet.supports)`;
1267
+ const own = switches.filter((s) => s.set === set.name && s.phase !== undefined);
1268
+ if (own.length === 0) {
1269
+ out.push(`${head}: on no frame — no ${side} support span`);
1270
+ continue;
1271
+ }
1272
+ const count = own.reduce((t, s) => t + s.frames[1] - s.frames[0] + 1, 0);
1273
+ const spansText = own.map((s) => {
1274
+ const ph = s.phase!;
1275
+ const on = ph.on === null ? 'on the first frame' : `on frame ${ph.on.frame} ${sideText(ph.on.from)} → ${side}`;
1276
+ const off = ph.off === null ? 'off at the end' : `off frame ${ph.off.frame} ${side} → ${sideText(ph.off.to)}`;
1277
+ return `${s.frames[0]}–${s.frames[1]} (${on}, ${off})`;
1278
+ });
1279
+ out.push(`${head}: ${own.length} span(s), ${count} frame(s): ${spansText.join(', ')}`);
1280
+ }
1281
+ return out;
1282
+ }
1283
+
1196
1284
  const spd = (v: number) => `${v.toFixed(1)}°/frame`;
1197
1285
  const win = (w: [number, number]) => (w[0] === w[1] ? `window frame ${w[0]}` : `window frames ${w[0]}–${w[1]}`);
1198
1286
  function moveText(m: Move): string {