@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/depth.ts ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Derived depth (rig-c/pose#281): which way each limb segment leans out of the
3
+ * picture, read off its 2D length, and the limbs' front-to-back order from it.
4
+ *
5
+ * A sequence carries x and y only, so nothing downstream can say which limb is
6
+ * nearer the camera. A segment drawn shorter than its rest length is tilted out
7
+ * of the picture plane, and by how much its length says: `ratio` = drawn length
8
+ * ÷ rest length, the rest length being the larger of the two sides' 95th
9
+ * percentile picture lengths of that segment over the clip — the straight length
10
+ * the leg scale reads (`STRAIGHT_PERCENTILE`, #148), per segment. Which way it
11
+ * tilts the length cannot say; the sign comes from the body:
12
+ *
13
+ * | segment | sense when foreshortened | why |
14
+ * | --------- | ------------------------ | --- |
15
+ * | thigh | forward | a hip flexes forward; it extends back too little to foreshorten past the band |
16
+ * | shin | walk: forward · run: back | the gait rule (rig-c/pose#281, HQ): a walk's lifted foot comes forward, a run's is kicked back — the knee alone cannot say, since it bends forward in both |
17
+ * | upper arm | with the opposite leg | in a gait each arm swings with the opposite leg: forward when the other side's thigh is the more foreshortened of the two, back when its own is; forward (a shoulder flexes forward) when neither thigh is foreshortened |
18
+ * | forearm | forward | an elbow bends toward the back of the body, so the forearm folds forward |
19
+ *
20
+ * "Forward" is the trunk's facing: toward the camera (+1) when the figure faces
21
+ * it (its left shoulder and hip drawn right of its right ones), away (−1) when
22
+ * it faces away, and undecided (sign 0) when the trunk is side-on — shoulders
23
+ * and hips drawn less than `SIDE_ON` trunk lengths wide — since forward is then
24
+ * across the picture, not into it. A ratio within `FLAT_BAND` is flat, sign 0,
25
+ * and so is one above it (drawn longer than rest is not a tilt).
26
+ *
27
+ * The layers: each joint's depth is its parent's plus its segment's,
28
+ * sign × rest × √(1 − ratio²) px (rest = drawn ÷ ratio), from the shoulders and
29
+ * hips at 0; a limb's depth is the mean of its middle and end joints'; the limbs
30
+ * are ordered nearest first, equal depths (to 0.01 px) sharing a layer. A limb
31
+ * whose segments are not both named on a frame is not ordered on it.
32
+ *
33
+ * Pure: no clock, no randomness, no disk.
34
+ */
35
+ import type { Body18 } from './joints.ts';
36
+ import { percentile, STRAIGHT_PERCENTILE } from './legs.ts';
37
+ import { DEPTH_SEGMENT_NAMES, DEPTH_SEGMENTS, type DepthGait, type DepthSegment, type DepthSource, type FrameDepth, type Joint, type SegmentDepth } from './pose.ts';
38
+
39
+ /** A ratio within this band is flat (sign 0): cos 18.2° = 0.95, so a segment within about 18° of the picture plane reads flat. */
40
+ export const FLAT_BAND: readonly [number, number] = [0.95, 1.05];
41
+ /** The trunk is side-on when its shoulders and hips are drawn, on average, less than this many trunk lengths (neck → mid_hip) wide. */
42
+ export const SIDE_ON = 0.1;
43
+ /** The limbs `layers` orders, each by its segments from the trunk out. */
44
+ export const LIMBS = {
45
+ r_arm: ['r_upper_arm', 'r_forearm'],
46
+ l_arm: ['l_upper_arm', 'l_forearm'],
47
+ r_leg: ['r_thigh', 'r_shin'],
48
+ l_leg: ['l_thigh', 'l_shin'],
49
+ } as const satisfies Record<string, readonly [DepthSegment, DepthSegment]>;
50
+ export type Limb = keyof typeof LIMBS;
51
+ export const LIMB_NAMES = Object.keys(LIMBS) as Limb[];
52
+
53
+ type Pt = [number, number];
54
+ type Joints = Partial<Record<Body18, Joint>>;
55
+ const at = (j: Joint | undefined): Pt | null => (j === undefined || j.state === 'missing' || j.at === undefined ? null : j.at);
56
+ const kindOf = (s: DepthSegment) => s.slice(2) as 'upper_arm' | 'forearm' | 'thigh' | 'shin';
57
+ const other = (s: DepthSegment): DepthSegment => `${s.startsWith('r_') ? 'l' : 'r'}_${s.slice(2)}` as DepthSegment;
58
+
59
+ /** A segment's drawn length on a frame; null where a joint is not placed. */
60
+ function drawn(joints: Joints, s: DepthSegment): number | null {
61
+ const [a, b] = DEPTH_SEGMENTS[s];
62
+ const pa = at(joints[a]);
63
+ const pb = at(joints[b]);
64
+ return pa === null || pb === null ? null : Math.hypot(pb[0] - pa[0], pb[1] - pa[1]);
65
+ }
66
+
67
+ /** The trunk's facing on a frame: +1 toward the camera, −1 away, 0 side-on or not placed. */
68
+ export function facingOf(joints: Joints): -1 | 0 | 1 {
69
+ const p = (n: Body18) => at(joints[n]);
70
+ const [rs, ls, rh, lh, neck] = [p('r_shoulder'), p('l_shoulder'), p('r_hip'), p('l_hip'), p('neck')];
71
+ if (rs === null || ls === null || rh === null || lh === null || neck === null) return 0;
72
+ const trunk = Math.hypot(neck[0] - (rh[0] + lh[0]) / 2, neck[1] - (rh[1] + lh[1]) / 2);
73
+ if (!(trunk > 0)) return 0;
74
+ const width = (ls[0] - rs[0] + (lh[0] - rh[0])) / 2 / trunk;
75
+ return width > SIDE_ON ? 1 : width < -SIDE_ON ? -1 : 0;
76
+ }
77
+
78
+ export interface DerivedDepth {
79
+ /** Per frame, each segment whose joints are placed. */
80
+ frames: FrameDepth[];
81
+ /** Each segment kind's rest length, px (the larger side's 95th percentile); null where neither side is ever placed. */
82
+ rest: Record<'upper_arm' | 'forearm' | 'thigh' | 'shin', number | null>;
83
+ /** Frames the trunk faced the camera, faced away, and was side-on (or not placed). */
84
+ facing: { toward: number; away: number; side: number };
85
+ /** Segment-frames by sign. */
86
+ signs: { toward: number; away: number; flat: number };
87
+ }
88
+
89
+ /** Each frame's depth, derived from the picture's lengths for `gait` (the table above). */
90
+ export function deriveDepth(track: readonly Joints[], gait: DepthGait): DerivedDepth {
91
+ const lengths = new Map(DEPTH_SEGMENT_NAMES.map((s) => [s, track.map((j) => drawn(j, s))]));
92
+ const rest = {} as DerivedDepth['rest'];
93
+ for (const k of ['upper_arm', 'forearm', 'thigh', 'shin'] as const) {
94
+ const sides = (['r', 'l'] as const).map((side) => percentile(lengths.get(`${side}_${k}`)!.filter((v): v is number => v !== null), STRAIGHT_PERCENTILE)).filter((v) => Number.isFinite(v) && v > 0);
95
+ rest[k] = sides.length === 0 ? null : Math.max(...sides);
96
+ }
97
+ const facing = { toward: 0, away: 0, side: 0 };
98
+ const signs = { toward: 0, away: 0, flat: 0 };
99
+ const frames = track.map((joints, i) => {
100
+ const face = facingOf(joints);
101
+ if (face === 1) facing.toward++;
102
+ else if (face === -1) facing.away++;
103
+ else facing.side++;
104
+ const ratio = (s: DepthSegment): number | null => {
105
+ const d = lengths.get(s)![i];
106
+ const r = rest[kindOf(s)];
107
+ return d === null || d === undefined || r === null ? null : d / r;
108
+ };
109
+ const out: FrameDepth = {};
110
+ for (const s of DEPTH_SEGMENT_NAMES) {
111
+ const r = ratio(s);
112
+ if (r === null) continue;
113
+ let sense: -1 | 1;
114
+ const kind = kindOf(s);
115
+ if (kind === 'shin') sense = gait === 'walk' ? 1 : -1;
116
+ else if (kind === 'upper_arm') {
117
+ // The arm swings with the opposite leg: its own thigh and the other side's, by how foreshortened each is.
118
+ const own = ratio(`${s.slice(0, 2)}thigh` as DepthSegment);
119
+ const opp = ratio(other(`${s.slice(0, 2)}thigh` as DepthSegment));
120
+ sense = own === null || opp === null || Math.min(own, opp) >= FLAT_BAND[0] || own === opp ? 1 : opp < own ? 1 : -1;
121
+ } else sense = 1;
122
+ const sign = (r < FLAT_BAND[0] ? face * sense : 0) as -1 | 0 | 1;
123
+ if (sign === 1) signs.toward++;
124
+ else if (sign === -1) signs.away++;
125
+ else signs.flat++;
126
+ out[s] = { ratio: r, sign: sign === 0 ? 0 : sign, source: 'derived' };
127
+ }
128
+ return out;
129
+ });
130
+ return { frames, rest, facing, signs };
131
+ }
132
+
133
+ /** One frame's layers: the limbs nearest first, equal depths sharing a layer, and each limb's depth (px, + toward the camera). */
134
+ export interface FrameLayers {
135
+ order: Limb[][];
136
+ depth: Partial<Record<Limb, number>>;
137
+ }
138
+
139
+ /** The depth one segment adds to its distal joint over its proximal one, px: sign × rest × √(1 − ratio²). */
140
+ export function segmentDepth(d: SegmentDepth, length: number): number {
141
+ if (d.sign === 0 || !(d.ratio > 0)) return 0;
142
+ const r = Math.min(d.ratio, 1);
143
+ return d.sign * (length / d.ratio) * Math.sqrt(1 - r * r);
144
+ }
145
+
146
+ /** The limbs' front-to-back order on one frame, from its depth and its joints' drawn lengths. */
147
+ export function layersOf(joints: Joints, depth: FrameDepth): FrameLayers {
148
+ const z: Partial<Record<Limb, number>> = {};
149
+ for (const limb of LIMB_NAMES) {
150
+ const [upper, lower] = LIMBS[limb];
151
+ const du = depth[upper];
152
+ const dl = depth[lower];
153
+ const lu = drawn(joints, upper);
154
+ const ll = drawn(joints, lower);
155
+ if (du === undefined || dl === undefined || lu === null || ll === null) continue;
156
+ const mid = segmentDepth(du, lu);
157
+ const end = mid + segmentDepth(dl, ll);
158
+ z[limb] = Math.round(((mid + end) / 2) * 100) / 100;
159
+ }
160
+ const placed = LIMB_NAMES.filter((l) => z[l] !== undefined).sort((a, b) => z[b]! - z[a]! || LIMB_NAMES.indexOf(a) - LIMB_NAMES.indexOf(b));
161
+ const order: Limb[][] = [];
162
+ for (const l of placed) {
163
+ const last = order[order.length - 1];
164
+ if (last !== undefined && z[last[0]!] === z[l]) last.push(l);
165
+ else order.push([l]);
166
+ }
167
+ return { order, depth: z };
168
+ }
169
+
170
+ /** A frame's layers as text: "r_arm > l_arm = r_leg > l_leg". */
171
+ export const orderText = (order: Limb[][]): string => order.map((g) => g.join(' = ')).join(' > ');
172
+
173
+ /** The source a sequence's depth came from, or null when its frames carry none. */
174
+ export function depthSourceOf(frames: ReadonlyArray<{ depth?: FrameDepth }>): DepthSource | null {
175
+ for (const f of frames) for (const d of Object.values(f.depth ?? {})) return d.source;
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * retarget's DEPTH line (rig-c/pose#281): where the depth came from, on how many
181
+ * frames the limbs were ordered, and which limb was in front alone how often.
182
+ */
183
+ export function depthLine(layers: readonly FrameLayers[], frames: ReadonlyArray<{ depth?: FrameDepth }>, gait: DepthGait | null): string {
184
+ const source = depthSourceOf(frames);
185
+ const from = source === null ? 'the sequence\'s depth (no segment named)' : source === 'derived' ? `the sequence's derived depth (clean --depth ${gait ?? '?'})` : 'the sequence\'s measured depth';
186
+ const ordered = layers.filter((l) => l.order.length > 0).length;
187
+ const front = LIMB_NAMES.map((limb) => `${limb} ${layers.filter((l) => l.order[0]?.length === 1 && l.order[0][0] === limb).length}`).join(', ');
188
+ const tied = layers.filter((l) => (l.order[0]?.length ?? 0) > 1).length;
189
+ return `DEPTH layers from ${from}: the limbs ordered front to back on ${ordered} of ${layers.length} frame(s); in front alone: ${front}; tied in front on ${tied} (the report's layers)`;
190
+ }
package/src/legs.ts CHANGED
@@ -516,6 +516,12 @@ export interface Feet {
516
516
  * rule does not apply.
517
517
  */
518
518
  tracked?: Array<{ side: Side; frames: [number, number] }>;
519
+ /**
520
+ * rig-c/pose#280: the second support signal (the ankle's vertical speed), how
521
+ * often it contradicts ankle height, and which one decided each frame. Set by
522
+ * `supportsOf` on every reading.
523
+ */
524
+ signal?: SupportSignal;
519
525
  }
520
526
 
521
527
  /** A run of frames whose support the lower-ankle rule (rig-c/pose#113) reads differently from the reading before it. */
@@ -559,8 +565,17 @@ export function rollingFloor(ys: readonly number[], times: readonly number[]): n
559
565
  * The frames whose support this changes are `lowered`. On a frame where no foot
560
566
  * is planted because the only foot free is the higher one (rig-c/pose#119), the
561
567
  * lower foot is tracked (`tracked`).
568
+ *
569
+ * rig-c/pose#280: the ankle's vertical speed is read beside ankle height on
570
+ * every clip (`speedSignalOf`), and on a frontal view it decides the support
571
+ * (`frontalOf`); `signal` says which signal decided each frame.
562
572
  */
563
573
  export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, times: readonly number[], secondsOf: (run: [number, number]) => number, inPlace = false, lowerAnkle = false): Feet {
574
+ return frontalOf(ankle, hip, heightSupportsOf(ankle, hip, times, secondsOf, inPlace, lowerAnkle));
575
+ }
576
+
577
+ /** The support read from ankle height alone: the rules above, #113's included (the whole reading before rig-c/pose#280). */
578
+ function heightSupportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, times: readonly number[], secondsOf: (run: [number, number]) => number, inPlace: boolean, lowerAnkle: boolean): Feet {
564
579
  if (inPlace || !lowerAnkle) return readSupports(ankle, hip, times, secondsOf, inPlace, false).feet;
565
580
  const { feet, released } = readSupports(ankle, hip, times, secondsOf, false, true);
566
581
  const before = readSupports(ankle, hip, times, secondsOf, false, false).feet.support;
@@ -727,6 +742,179 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
727
742
  return { feet: { legLength, support, supports, jumps, steps, changes, ...(tracked.length > 0 ? { tracked } : {}) }, released };
728
743
  }
729
744
 
745
+ // --- the second signal: the ankle's vertical speed against the ground (rig-c/pose#280) ---
746
+
747
+ /** A foot's contact on one frame, read from the ankles' vertical speed: one side, both, or neither (null). */
748
+ export type Contact = Side | 'both' | null;
749
+
750
+ export interface SpeedSignal {
751
+ /** Each side's ground speed: the median of its ankle's vertical step (picture px a frame, y down) over the frames outside every jump. */
752
+ ground: Record<Side, number>;
753
+ /** Per frame: the feet that move with the ground, on the step into the frame or the step out of it (null on a jump frame or where neither does). */
754
+ contact: Contact[];
755
+ /** The frames the two signals contradict: the lower ankle (by ≥ `PLANT_SWITCH` leg lengths) leaves the ground's speed while the higher moves with it. */
756
+ contradict: number[];
757
+ /** The frames both signals read: outside every jump. */
758
+ read: number;
759
+ }
760
+
761
+ /**
762
+ * The ankle's vertical speed as a support signal (rig-c/pose#280). A planted
763
+ * foot moves with the ground: still under a fixed camera, or sliding at the
764
+ * ground's speed in a gait drawn in place. Each side's ground speed is the
765
+ * median of its ankle's vertical step over the frames outside every jump —
766
+ * a walking foot is down on more than half of them — and a foot moves with it
767
+ * on a step that departs from it by less than `STILL_SPEED` of that side's leg
768
+ * length. A frame's foot is down when either the step into the frame or the
769
+ * step out of it moves with the ground, so a drawing a foot lands or lifts on
770
+ * reads it down. `contradict` lists the frames where this and ankle height
771
+ * disagree in the way a frontal view's depth makes them: the lower ankle is
772
+ * the moving one, the higher the one moving with the ground.
773
+ */
774
+ export function speedSignalOf(ankle: Record<Side, readonly Pt[]>, feet: Pick<Feet, 'legLength' | 'jumps'>): SpeedSignal {
775
+ const n = ankle.r.length;
776
+ const jump = new Array<boolean>(n).fill(false);
777
+ for (const [a, b] of feet.jumps) for (let i = a; i <= b; i++) jump[i] = true;
778
+ const step = (s: Side, i: number): number | null => (i < 1 || i >= n || jump[i] || jump[i - 1] ? null : ankle[s][i]![1] - ankle[s][i - 1]![1]);
779
+ const ground = {} as Record<Side, number>;
780
+ for (const s of SIDES) {
781
+ const xs: number[] = [];
782
+ for (let i = 1; i < n; i++) {
783
+ const v = step(s, i);
784
+ if (v !== null) xs.push(v);
785
+ }
786
+ ground[s] = xs.length === 0 ? 0 : percentile(xs, 0.5);
787
+ }
788
+ const withGround = (s: Side, i: number): boolean => {
789
+ const v = step(s, i);
790
+ return v !== null && Math.abs(v - ground[s]) < STILL_SPEED * feet.legLength[s];
791
+ };
792
+ const down = (s: Side, i: number) => withGround(s, i) || withGround(s, i + 1);
793
+ const mean = (feet.legLength.r + feet.legLength.l) / 2;
794
+ const contact: Contact[] = [];
795
+ const contradict: number[] = [];
796
+ let read = 0;
797
+ for (let i = 0; i < n; i++) {
798
+ if (jump[i]) {
799
+ contact.push(null);
800
+ continue;
801
+ }
802
+ read++;
803
+ const r = down('r', i);
804
+ const l = down('l', i);
805
+ contact.push(r && l ? 'both' : r ? 'r' : l ? 'l' : null);
806
+ const gap = ankle.l[i]![1] - ankle.r[i]![1];
807
+ const lower: Side | null = Math.abs(gap) < PLANT_SWITCH * mean ? null : gap > 0 ? 'l' : 'r';
808
+ if (lower !== null && !down(lower, i) && down(lower === 'r' ? 'l' : 'r', i)) contradict.push(i);
809
+ }
810
+ return { ground, contact, contradict, read };
811
+ }
812
+
813
+ /**
814
+ * A clip whose two support signals contradict on more than this share of the
815
+ * frames both read is read as a frontal view (rig-c/pose#280), when its stride
816
+ * also shows in depth (`strideInDepth`). A frontal walk contradicts at every
817
+ * hand-over, for as long as the nearer foot is in the air below the planted one:
818
+ * 18 of 96 frames (18.8 %) on the tests' synthetic frontal walk, where ankle
819
+ * height alone hands support to the nearer foot 1 or 2 frames before it lands
820
+ * (16 frames in all, declared in place). Every clip the other tests read with
821
+ * its stride in depth contradicts on none; the shortest contradiction a walk
822
+ * can make, one frame a step, is 8.3 % of a 1 s cycle at 24 fps (2 of 24).
823
+ */
824
+ export const FRONTAL_DISAGREE = 0.05;
825
+
826
+ /** rig-c/pose#280: the two support signals of a clip, and which one decided each frame's support. */
827
+ export interface SupportSignal {
828
+ /** The frames the signals contradict (`speedSignalOf`), of `read` frames both read. */
829
+ contradict: number[];
830
+ read: number;
831
+ /** Each side's ankle range in the picture, relative to its hip (5th to 95th percentile), px: the stride shows in y on a frontal view and in x on a side view. */
832
+ range: Record<Side, { x: number; y: number }>;
833
+ /** The stride shows in depth (y range over x range on both sides) and the signals contradict on more than `FRONTAL_DISAGREE` of the frames: the support comes from the vertical speed. */
834
+ frontal: boolean;
835
+ /** Per frame, the vertical speed's reading: the side down, both, or null (a jump, or neither foot moving with the ground). */
836
+ contact: Contact[];
837
+ /** Per frame, ankle height's reading (the support before #280). */
838
+ height: Array<Side | null>;
839
+ /** Per frame, the signal that decided the support: `speed`, `height`, or null where no foot supports. */
840
+ by: Array<'speed' | 'height' | null>;
841
+ }
842
+
843
+ /** Each side's ankle range relative to its hip, px, x and y (5th to 95th percentile). */
844
+ function rangeOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>): Record<Side, { x: number; y: number }> {
845
+ const span = (xs: number[]) => percentile(xs, 0.95) - percentile(xs, 0.05);
846
+ const out = {} as Record<Side, { x: number; y: number }>;
847
+ for (const s of SIDES) out[s] = { x: span(ankle[s].map((a, i) => a[0] - hip[s][i]![0])), y: span(ankle[s].map((a, i) => a[1] - hip[s][i]![1])) };
848
+ return out;
849
+ }
850
+
851
+ /**
852
+ * rig-c/pose#280: the support from the ankle's vertical speed, on a frontal
853
+ * view. Seen from the front, a foot's depth is drawn as height, so the lower
854
+ * ankle is the nearer foot, which may be the one in the air. A clip is read as
855
+ * frontal when both hold: its stride shows in depth — on each side the ankle's
856
+ * range relative to its hip is larger in y than in x (a side view's stride is
857
+ * in x) — and the two signals contradict on more than `FRONTAL_DISAGREE` of the
858
+ * frames both read. Then each frame's support is the vertical speed's: the one
859
+ * foot down; on frames both are down, the foot that landed (the other side from
860
+ * the support before the run, or, at the clip's start, the side that stays down
861
+ * after it), so support passes on the frame the incoming foot lands (as #102
862
+ * takes it from the landing frame); and ankle height's reading where neither
863
+ * foot moves with the ground. A plant ends where the other foot lands, so no
864
+ * step releases one and none is tracked (#113's `lowered` and #119's `tracked`
865
+ * are not read). Otherwise the feet are ankle height's, unchanged.
866
+ */
867
+ function frontalOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, feet: Feet): Feet {
868
+ const sig = speedSignalOf(ankle, feet);
869
+ const range = rangeOf(ankle, hip);
870
+ const strideInDepth = SIDES.every((s) => range[s].y > range[s].x);
871
+ const frontal = strideInDepth && sig.read > 0 && sig.contradict.length > FRONTAL_DISAGREE * sig.read;
872
+ const height = feet.support.map((s) => s ?? null);
873
+ const signal = (by: SupportSignal['by']): SupportSignal => ({ contradict: sig.contradict, read: sig.read, range, frontal, contact: sig.contact, height, by });
874
+ if (!frontal) return { ...feet, signal: signal(height.map((s) => (s === null ? null : 'height'))) };
875
+ const n = height.length;
876
+ const other = (s: Side): Side => (s === 'r' ? 'l' : 'r');
877
+ const support: Array<Side | null> = new Array<Side | null>(n).fill(null);
878
+ const by: SupportSignal['by'] = new Array<'speed' | 'height' | null>(n).fill(null);
879
+ const fromHeight = (i: number) => {
880
+ support[i] = height[i]!;
881
+ by[i] = height[i] === null ? null : 'height';
882
+ };
883
+ for (let i = 0; i < n; i++) {
884
+ const c = sig.contact[i] ?? null;
885
+ if (c === 'r' || c === 'l') {
886
+ support[i] = c;
887
+ by[i] = 'speed';
888
+ } else if (c === 'both') {
889
+ let b = i;
890
+ while (b + 1 < n && sig.contact[b + 1] === 'both') b++;
891
+ const before = i > 0 ? (support[i - 1] ?? null) : null;
892
+ const after = b + 1 < n ? (sig.contact[b + 1] ?? null) : null;
893
+ const landed: Side | null = before !== null ? other(before) : after === 'r' || after === 'l' ? after : null;
894
+ for (let k = i; k <= b; k++) {
895
+ if (landed === null) fromHeight(k);
896
+ else {
897
+ support[k] = landed;
898
+ by[k] = 'speed';
899
+ }
900
+ }
901
+ i = b;
902
+ } else fromHeight(i);
903
+ }
904
+ const supports: Feet['supports'] = [];
905
+ const changes: Feet['changes'] = [];
906
+ for (let i = 0; i < n; i++) {
907
+ const s = support[i];
908
+ if (s === null || s === undefined) continue;
909
+ const prev = i > 0 ? (support[i - 1] ?? null) : null;
910
+ if (prev !== s) changes.push({ frame: i, from: prev, to: s });
911
+ const last = supports[supports.length - 1];
912
+ if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
913
+ else supports.push({ side: s, frames: [i, i] });
914
+ }
915
+ return { legLength: feet.legLength, support, supports, jumps: feet.jumps, steps: [], changes, signal: signal(by) };
916
+ }
917
+
730
918
  /** One support span's shift onto the floor (rig-c/pose#85): its side, its frames, and floor − its planted height, world units. */
731
919
  export interface FloorShift {
732
920
  side: Side;
@@ -1584,3 +1772,4 @@ export function holdBlend(blends: readonly BlendFrame[], n: number, posed: (i: n
1584
1772
  const range = rangeUsed(lower, held);
1585
1773
  return { bounds: STANCE_BOUNDS, ...(range === null ? {} : { range }), runs, over, stretch };
1586
1774
  }
1775
+
package/src/map.ts CHANGED
@@ -58,6 +58,11 @@
58
58
  * `place: "by-hip"` (rig-c/pose#93) places the wrist of each of its chains that
59
59
  * `pose` puts within one hip width of the mid-hip by its offset from the hip
60
60
  * (`byHipChains`; the retarget solves it).
61
+ * `phase: "left-step" | "right-step"` (rig-c/pose#286) declares a gait set drawn
62
+ * for the foot that is stepping (swinging), named by it — left-step: the left foot is stepping; shown while the right foot supports (right-step
63
+ * mirrors it). It shows exactly on the report's `feet.supports` spans of the
64
+ * supporting foot and is switched on the support hand-over, not on angle
65
+ * distance (`PHASE_SIDE`).
61
66
  * `switch.ts` decides when each one shows.
62
67
  */
63
68
  import type { RawBone } from 'rig-c/core';
@@ -86,6 +91,9 @@ export interface Translate {
86
91
  joint: string;
87
92
  scale: { pose: [string, string]; rig: [string, string] };
88
93
  }
94
+ /** The step sides a set may declare as its `phase` (rig-c/pose#286), and the supporting foot each one shows on: left-step: the left foot is stepping; shown while the right foot supports. */
95
+ export const PHASE_SIDE = { 'left-step': 'r', 'right-step': 'l' } as const;
96
+ export type Phase = keyof typeof PHASE_SIDE;
89
97
  /** An alternate drawing: a set of slots switched together, and the key skeleton it is selected by. */
90
98
  export interface AlternateSet {
91
99
  name: string;
@@ -113,6 +121,8 @@ export interface AlternateSet {
113
121
  support?: string[];
114
122
  /** `anchor` (rig-c/pose#191): the set's two-bone arm chains whose hand-tip is anchored to the set's `drawn` head-local offset while the set shows. */
115
123
  anchor?: string[];
124
+ /** `phase` (rig-c/pose#286): the stepping foot the set is drawn for (left-step: the left foot is stepping; shown while the right foot supports); it shows exactly on that supporting foot's `feet.supports` spans, switched on the hand-over. Absent: the angle rule. */
125
+ phase?: Phase;
116
126
  }
117
127
  export interface YawSet {
118
128
  set: string;
@@ -423,7 +433,7 @@ function readBodyMap(p: Problems, where: string, raw: unknown): BodyMap | undefi
423
433
  return Object.keys(out).length > 0 ? out : undefined;
424
434
  }
425
435
 
426
- const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …], place?: "by-hip" }';
436
+ const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …], place?: "by-hip", phase?: "left-step" | "right-step" }';
427
437
 
428
438
  /** The joints a wrist is, for `place: "by-hip"`: a chain whose last joint is one of them carries a wrist. */
429
439
  export const WRISTS: readonly string[] = ['r_wrist', 'l_wrist'];
@@ -472,7 +482,7 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
472
482
  p.add('MAP_ALT_SHAPE', `${aw} is ${typeName(a)}; a set is ${SET_SHAPE}`);
473
483
  return;
474
484
  }
475
- refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames', 'place', 'symmetric', 'support', 'anchor']);
485
+ refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames', 'place', 'symmetric', 'support', 'anchor', 'phase']);
476
486
  const name = typeof a.name === 'string' && a.name !== '' ? a.name : null;
477
487
  if (name === null) p.add('MAP_ALT_NAME', `${aw} "name" is ${typeName(a.name)}; it must be a non-empty string`);
478
488
  else if (names.has(name)) p.add('MAP_ALT_NAME', `${aw} "name" "${name}" names two sets`);
@@ -676,7 +686,18 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
676
686
  if (ok) anchor = anc;
677
687
  }
678
688
  }
679
- if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames, ...(place === null ? {} : { place }), ...(symmetric === undefined ? {} : { symmetric }), ...(support === undefined ? {} : { support }), ...(anchor === undefined ? {} : { anchor }) });
689
+ let phase: Phase | undefined;
690
+ if ('phase' in a) {
691
+ const phases = Object.keys(PHASE_SIDE);
692
+ if (typeof a.phase !== 'string' || !phases.includes(a.phase)) {
693
+ p.add('MAP_ALT_PHASE', `${label} "phase" is ${JSON.stringify(a.phase)}; it is the stepping foot the set is drawn for, one of ${phases.map((x) => `"${x}"`).join(', ')} (left-step: the left foot is stepping; shown while the right foot supports)`);
694
+ ok = false;
695
+ } else if ('frames' in a) {
696
+ p.add('MAP_ALT_PHASE', `${label} names both "phase" and "frames"; a phase set shows on its supporting foot's spans, and a window would cut them — drop one`);
697
+ ok = false;
698
+ } else phase = a.phase as Phase;
699
+ }
700
+ if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames, ...(place === null ? {} : { place }), ...(symmetric === undefined ? {} : { symmetric }), ...(support === undefined ? {} : { support }), ...(anchor === undefined ? {} : { anchor }), ...(phase === undefined ? {} : { phase }) });
680
701
  });
681
702
  return out;
682
703
  }
package/src/outdir.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Where a command's output goes (rig-c/pose#239, #284). A file a command writes goes into a
3
+ * directory the user named: one that is not there yet is made just before the write (mkdir -p),
4
+ * and a `made directory` line says so and which paths it holds. A file where a directory has to
5
+ * be made, or a directory that cannot be written into, is refused by name (`ARG_PATH`) before
6
+ * anything is read or written.
7
+ */
8
+ import { accessSync, constants, existsSync, mkdirSync, statSync } from 'node:fs';
9
+ import { dirname } from 'node:path';
10
+ import type { Problems } from './problems.ts';
11
+
12
+ /** The directories to make before writing, each with the `<flag> <path>`s written into it, in the order named. */
13
+ export type OutDirs = Map<string, string[]>;
14
+
15
+ const writable = (dir: string): boolean => {
16
+ try {
17
+ accessSync(dir, constants.W_OK);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ };
23
+
24
+ /**
25
+ * Check where each named output lands: its directory is there, or the nearest existing ancestor
26
+ * is a directory this process can write into. A miss is added to `p`; a directory still to make
27
+ * is added to `dirs`. A directory a flag names itself (--build, --render) is checked by its parent.
28
+ */
29
+ export function planOutDirs(outputs: ReadonlyArray<readonly [string, string | undefined]>, p: Problems, dirs: OutDirs = new Map()): OutDirs {
30
+ for (const [flag, path] of outputs) {
31
+ if (path === undefined) continue;
32
+ const dir = dirname(path);
33
+ let up = dir;
34
+ while (!existsSync(up) && dirname(up) !== up) up = dirname(up);
35
+ if (!statSync(up).isDirectory()) p.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`);
36
+ else if (up !== dir && !writable(up)) p.add('ARG_PATH', `${flag} ${path} — ${up} is not writable, so directory ${dir} cannot be made under it`);
37
+ else if (up !== dir) dirs.set(dir, [...(dirs.get(dir) ?? []), `${flag} ${path}`]);
38
+ }
39
+ return dirs;
40
+ }
41
+
42
+ /** Make each planned directory (mkdir -p) and print its line; run after the command is green, just before it writes. */
43
+ export function makeOutDirs(dirs: OutDirs, tell: (l: string) => void): void {
44
+ for (const [dir, flags] of dirs) {
45
+ if (existsSync(dir)) continue;
46
+ mkdirSync(dir, { recursive: true });
47
+ tell(madeLine(dir, flags));
48
+ }
49
+ }
50
+
51
+ /** rig-c/pose#239: the line a directory this run made prints — the directory, and the paths that named it. */
52
+ export function madeLine(dir: string, flags: readonly string[]): string {
53
+ return ` made directory ${dir} — it did not exist; ${flags.join(' and ')} ${flags.length === 1 ? 'is' : 'are'} written into it`;
54
+ }