@rig-c/pose 0.40.0 → 0.42.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 CHANGED
@@ -13,10 +13,17 @@
13
13
  * | segment | sense when foreshortened | why |
14
14
  * | --------- | ------------------------ | --- |
15
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 |
16
+ * | shin | back | the owner's walk statement (rig-c/pose#336): the lifted foot is behind the supporting leg — a knee folds the shin back |
17
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
18
  * | forearm | forward | an elbow bends toward the back of the body, so the forearm folds forward |
19
19
  *
20
+ * The thigh's row is the owner's run statement (rig-c/pose#336): the lifted leg
21
+ * is in front, its knee protruding — a raised thigh brings the knee forward.
22
+ * The table is the same in a walk and a run, so `clean --depth walk|run` writes
23
+ * the same signs for both; the gait it records decides which joint
24
+ * `--draw-order depth` orders the legs by (`src/draworder.ts`): a walk by the
25
+ * ankle (the lifted foot behind), a run by the knee (the lifted knee in front).
26
+ *
20
27
  * "Forward" is the trunk's facing: toward the camera (+1) when the figure faces
21
28
  * it (its left shoulder and hip drawn right of its right ones), away (−1) when
22
29
  * it faces away, and undecided (sign 0) when the trunk is side-on — shoulders
@@ -86,8 +93,8 @@ export interface DerivedDepth {
86
93
  signs: { toward: number; away: number; flat: number };
87
94
  }
88
95
 
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 {
96
+ /** Each frame's depth, derived from the picture's lengths (the table above; the same for every gait). */
97
+ export function deriveDepth(track: readonly Joints[]): DerivedDepth {
91
98
  const lengths = new Map(DEPTH_SEGMENT_NAMES.map((s) => [s, track.map((j) => drawn(j, s))]));
92
99
  const rest = {} as DerivedDepth['rest'];
93
100
  for (const k of ['upper_arm', 'forearm', 'thigh', 'shin'] as const) {
@@ -112,7 +119,7 @@ export function deriveDepth(track: readonly Joints[], gait: DepthGait): DerivedD
112
119
  if (r === null) continue;
113
120
  let sense: -1 | 1;
114
121
  const kind = kindOf(s);
115
- if (kind === 'shin') sense = gait === 'walk' ? 1 : -1;
122
+ if (kind === 'shin') sense = -1;
116
123
  else if (kind === 'upper_arm') {
117
124
  // The arm swings with the opposite leg: its own thigh and the other side's, by how foreshortened each is.
118
125
  const own = ratio(`${s.slice(0, 2)}thigh` as DepthSegment);
@@ -143,19 +150,52 @@ export function segmentDepth(d: SegmentDepth, length: number): number {
143
150
  return d.sign * (length / d.ratio) * Math.sqrt(1 - r * r);
144
151
  }
145
152
 
153
+ /**
154
+ * A limb's two joint depths on one frame, px (+ toward the camera): its middle joint (the upper segment's) and its end
155
+ * joint (the middle's plus the lower segment's), from the shoulder or hip at 0, unrounded. Null where the upper segment
156
+ * is not named and placed; `end` null where the lower one is not (the middle joint does not need it: rig-c/pose#336).
157
+ */
158
+ export function limbJointDepths(joints: Joints, depth: FrameDepth, limb: Limb): { mid: number; end: number | null } | null {
159
+ const [upper, lower] = LIMBS[limb];
160
+ const du = depth[upper];
161
+ const dl = depth[lower];
162
+ const lu = drawn(joints, upper);
163
+ const ll = drawn(joints, lower);
164
+ if (du === undefined || lu === null) return null;
165
+ const mid = segmentDepth(du, lu);
166
+ return { mid, end: dl === undefined || ll === null ? null : mid + segmentDepth(dl, ll) };
167
+ }
168
+
169
+ /**
170
+ * rig-c/pose#336: the frames of a derived depth on which a shin leans the way the trunk faces — the sign table before
171
+ * #336, where a walk's foreshortened shin read forward. The table since leans it back (−facing) in both gaits, and a run's
172
+ * never changed, so only a record a release before #336 cleaned with `--depth walk` carries one. A record with no
173
+ * foreshortened shin reads the same under both tables and is not listed.
174
+ */
175
+ export function forwardShinFrames(track: readonly Joints[], frames: ReadonlyArray<{ depth?: FrameDepth }>): number[] {
176
+ const out: number[] = [];
177
+ frames.forEach((f, i) => {
178
+ const face = facingOf(track[i] ?? {});
179
+ if (face === 0) return;
180
+ if ((['r_shin', 'l_shin'] as const).some((s) => f.depth?.[s]?.source === 'derived' && f.depth[s]!.sign === face)) out.push(i);
181
+ });
182
+ return out;
183
+ }
184
+
185
+ /** The DEPTH_RECLEAN refusal (#336): the file, the gait it records, the frames (the first five), and what to run. */
186
+ export function depthRecleanText(file: string, gait: DepthGait | null, frames: readonly number[]): string {
187
+ const shown = frames.slice(0, 5).join(', ') + (frames.length > 5 ? `, … +${frames.length - 5} more` : '');
188
+ const g = gait ?? 'walk';
189
+ return `${file} carries derived depth (clean --depth ${gait ?? '?'}) whose foreshortened shin leans forward, the way the trunk faces, on ${frames.length} frame(s) (${shown}): the sign table before rig-c/pose#336, where a walk's lifted foot read forward. --draw-order depth reads a walk at the ankle and would draw that foot in front of the supporting leg. Clean it again with this version (rig-pose clean --depth ${g}), which leans a foreshortened shin back in both gaits; a sequence cleaned with --depth run by an earlier release is read as it is (its signs did not change), and measured depth is not checked`;
190
+ }
191
+
146
192
  /** The limbs' front-to-back order on one frame, from its depth and its joints' drawn lengths. */
147
193
  export function layersOf(joints: Joints, depth: FrameDepth): FrameLayers {
148
194
  const z: Partial<Record<Limb, number>> = {};
149
195
  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;
196
+ const j = limbJointDepths(joints, depth, limb);
197
+ if (j === null || j.end === null) continue;
198
+ z[limb] = Math.round(((j.mid + j.end) / 2) * 100) / 100;
159
199
  }
160
200
  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
201
  const order: Limb[][] = [];
package/src/draworder.ts CHANGED
@@ -19,7 +19,13 @@
19
19
  * together they fill one unbroken run of the setup order. A slot of anything
20
20
  * else drawn between them would be re-layered by the swap — that is changing
21
21
  * the setup order, not keying the pair — so the pair is not keyed and the line
22
- * names the slot.
22
+ * counts the slots between them first, then names them as far as the line's
23
+ * cap allows (#324; the report's `why` and `between` carry every one).
24
+ * - **The gap (#324).** Per pair, keyed or not, the leg depth gap |depth a −
25
+ * depth b| over the frames both limbs read — its median and max, in px and as
26
+ * a fraction of the leg (upper + lower rest length of the limb the tie is read
27
+ * from) — against the tie, and the frames that passed it: the `DRAWGAP` line
28
+ * and the report's `drawOrder.gaps`.
23
29
  * - **The rule.** On each frame the limb whose depth is the lower (farther from
24
30
  * the camera) is drawn behind: its slots first in the pair's run, in their
25
31
  * setup order, then the other's — when the two depths differ by at least
@@ -30,19 +36,38 @@
30
36
  * ordered, holds the order before; before the first decided frame, the setup
31
37
  * order — or, on a looped sequence, the order the clip ends in, held across
32
38
  * the seam. A key is written where the order changes, and none while it is the
33
- * setup order from the start.
39
+ * setup order from the start. Its time is half a frame before the frame it
40
+ * shows on (0 on frame 0), the rule the switches' attachment keys follow
41
+ * (`keyAtFrame` in `switch.ts`): a key exactly on a frame time is a tie the
42
+ * render breaks a frame late about half the time (rig-c/pose#325).
43
+ * - **By the joint the gait names** (rig-c/pose#336, the owner's two
44
+ * statements). On a sequence cleaned with `--depth walk` the pair is ordered
45
+ * by each leg's ankle depth (the hip at 0, plus the thigh's, plus the shin's:
46
+ * `limbJointDepths`' end) — in a walk the lifted foot is behind the supporting
47
+ * leg; with `--depth run` by the knee depth (the thigh's: its middle) — in a
48
+ * run the lifted leg is in front, its knee protruding. The limb mean cannot
49
+ * say either: a lifted leg's thigh forward and shin back cancel in it, and a
50
+ * walk's flat thigh leaves it at most half a shin (35 px on the fixtures),
51
+ * under the limb tie. The tie is scaled to the joint, what a flat chain can
52
+ * hold there: √(1 − 0.95²) × thigh at the knee, × (thigh + shin) at the
53
+ * ankle, the smaller leg's. Measured depth names no gait and keeps the limb
54
+ * mean and its tie (#304).
34
55
  *
35
56
  * Pure: no clock, no randomness, no disk.
36
57
  */
37
58
  import type { Body18 } from './joints.ts';
38
- import { FLAT_BAND, LIMBS, type FrameLayers, type Limb } from './depth.ts';
59
+ import { FLAT_BAND, LIMBS, limbJointDepths, type FrameLayers, type Limb } from './depth.ts';
39
60
  import { knownSetsOf, type JointMap, type Rest, type RigSets } from './map.ts';
40
- import { DEPTH_SEGMENTS, type DepthSegment, type FrameDepth, type Joint } from './pose.ts';
61
+ import { DEPTH_SEGMENTS, type DepthGait, type DepthSegment, type FrameDepth, type Joint } from './pose.ts';
62
+ import { keyAtFrame } from './switch.ts';
41
63
 
42
64
  /** The limb pairs `--draw-order depth` keys. */
43
65
  export const DRAW_ORDER_PAIRS: ReadonlyArray<readonly [Limb, Limb]> = [['r_leg', 'l_leg']];
44
66
  /** What one segment inside the flat band can hide out of the picture, per unit of its rest length: √(1 − 0.95²). */
45
67
  export const TIE_PER_LENGTH = Math.sqrt(1 - FLAT_BAND[0] * FLAT_BAND[0]);
68
+ /** The joint a gait's legs are ordered by (rig-c/pose#336): a walk's lifted foot is behind, a run's lifted knee in front. */
69
+ export const ORDER_JOINT: Record<DepthGait, LegJoint> = { walk: 'ankle', run: 'knee' };
70
+ export type LegJoint = 'knee' | 'ankle';
46
71
  /** The DRAWORDER line's cap, characters (the span list is cut to it; the report carries every span). */
47
72
  export const DRAW_ORDER_LINE_MAX = 260;
48
73
 
@@ -50,7 +75,7 @@ export interface DrawOrderOffset {
50
75
  slot: string;
51
76
  offset: number;
52
77
  }
53
- /** One key as written into the animation (`t` the frame's time). */
78
+ /** One key as written into the animation (`t` half a frame before the frame it shows on, 0 on frame 0: rig-c/pose#325). */
54
79
  export interface DrawOrderKey {
55
80
  t: number;
56
81
  offsets?: DrawOrderOffset[];
@@ -65,8 +90,29 @@ export interface DrawOrderPair {
65
90
  keyed: boolean;
66
91
  /** #281's tie for the pair, px: depths closer than this hold the order before (null where a rest length is unread: no tie, equal depths only). */
67
92
  tie: number | null;
93
+ /** #336: the joint the pair is ordered by, from the gait clean recorded (the tie then the joint's); absent, the limb mean (#304). */
94
+ by?: LegJoint;
68
95
  /** Why the pair is not keyed. */
69
96
  why?: string;
97
+ /** The slots of anything else drawn between the pair's slots, in setup order (#324: the line counts them first and cuts this list). */
98
+ between?: string[];
99
+ }
100
+ /** One pair's leg depth gap against its tie (#324): the figures that say why a pair keyed little or nothing. */
101
+ export interface DrawOrderGap {
102
+ a: string;
103
+ b: string;
104
+ /** |depth a − depth b| over the frames both limbs read, px: the median and the largest (at the pair's joint, when it has one). */
105
+ median: number | null;
106
+ max: number | null;
107
+ /** The same as a fraction of the leg (upper + lower rest length of the limb the tie is read from); null where a rest length is unread. */
108
+ medianLegs: number | null;
109
+ maxLegs: number | null;
110
+ /** The pair's tie, px and as a fraction of the leg. */
111
+ tie: number | null;
112
+ tieLegs: number | null;
113
+ /** Frames whose gap passed the tie (decided an order), of the frames both limbs read. */
114
+ passed: number;
115
+ frames: number;
70
116
  }
71
117
  export interface DrawOrderSpan {
72
118
  /** The chain drawn behind, and the one in front. */
@@ -78,10 +124,12 @@ export interface DrawOrderSpan {
78
124
  }
79
125
  export interface DrawOrderReading {
80
126
  pairs: DrawOrderPair[];
81
- /** The `drawOrder` timeline, with the frame each key sits on. */
127
+ /** The `drawOrder` timeline as written, with the frame each key first shows on. */
82
128
  keys: Array<DrawOrderKey & { frame: number }>;
83
129
  /** Every span of one decided order, setup or keyed, in frame order per pair. */
84
130
  spans: DrawOrderSpan[];
131
+ /** Per pair, the leg depth gap against the tie (#324); keyed or not. */
132
+ gaps?: DrawOrderGap[];
85
133
  }
86
134
 
87
135
  interface Resolved {
@@ -164,10 +212,19 @@ function resolvePair(pairLimbs: readonly [Limb, Limb], map: JointMap, rest: Rest
164
212
  const lo = idx[0]!;
165
213
  const hi = idx[idx.length - 1]!;
166
214
  const between = names.slice(lo, hi + 1).filter((n) => !slotsA.has(n) && !slotsB.has(n));
167
- if (between.length > 0) return none(`their slots do not overlap in the draw order: ${between.map((n) => `"${n}"`).join(', ')} ${between.length === 1 ? 'is' : 'are'} drawn between them, and a swap would re-layer ${between.length === 1 ? 'it' : 'them'} (the setup order is not changed here)`);
215
+ if (between.length > 0) return { ...none(betweenWhy(between, between.length)), pair: { ...pair, why: betweenWhy(between, between.length), between } };
168
216
  return { pair: { ...pair, keyed: true }, lo, hi, aBehind: arrangement(names, lo, a, b), bBehind: arrangement(names, lo, b, a) };
169
217
  }
170
218
 
219
+ /** The not-keyed reason for slots drawn between the pair: the count first, then the first `shown` of the list (#324). */
220
+ function betweenWhy(between: readonly string[], shown: number): string {
221
+ const tail = 'the setup order is not changed here';
222
+ if (between.length === 1) return shown > 0 ? `1 slot: "${between[0]}" is drawn between them, and a swap would re-layer it (${tail})` : `1 slot is drawn between them (… +1 more), and a swap would re-layer it (${tail})`;
223
+ const more = between.length - shown;
224
+ const list = `${between.slice(0, shown).map((n) => `"${n}"`).join(', ')}${more > 0 ? `${shown > 0 ? ', ' : ''}… +${more} more` : ''}`;
225
+ return `${between.length} slots are drawn between them (${list}), and a swap would re-layer them (${tail})`;
226
+ }
227
+
171
228
  type Joints = Partial<Record<Body18, Joint>>;
172
229
 
173
230
  /** A segment's rest length as the depth states it (drawn ÷ ratio), the median over the frames that name it; null where none does. */
@@ -187,33 +244,84 @@ function restOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | unde
187
244
  return vals.length % 2 === 1 ? vals[m]! : (vals[m - 1]! + vals[m]!) / 2;
188
245
  }
189
246
 
190
- /** #281's tie for two limbs, px: √(1 − 0.95²) × (upper + lower ÷ 2), the smaller limb's; null where a rest length is unread. */
191
- function tieOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, limbs: readonly [Limb, Limb]): number | null {
192
- const hidden = limbs.map((l) => {
247
+ /**
248
+ * #281's tie for two limbs, px: √(1 − 0.95²) × (upper + lower ÷ 2), the smaller limb's; null where a rest length is unread.
249
+ * By a joint (#336): √(1 − 0.95²) × upper at the knee, × (upper + lower) at the ankle — what a flat chain can hold there.
250
+ * `leg` is that limb's upper + lower rest length (#324's unit for the gap).
251
+ */
252
+ function tieOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, limbs: readonly [Limb, Limb], by: LegJoint | undefined): { tie: number | null; leg: number | null } {
253
+ const rests = limbs.map((l) => {
193
254
  const [u, w] = LIMBS[l].map((s) => restOf(track, depth, s));
194
- return u === null || u === undefined || w === null || w === undefined ? null : u + w / 2;
255
+ // The knee's tie needs the upper segment alone (#336: a leg whose ankle is never placed still orders at the knee).
256
+ if (by === 'knee') return u === null || u === undefined ? null : { hidden: u, leg: w === null || w === undefined ? null : u + w };
257
+ return u === null || u === undefined || w === null || w === undefined ? null : { hidden: by === 'ankle' ? u + w : u + w / 2, leg: u + w };
195
258
  });
196
- return hidden.some((h) => h === null) ? null : Math.round(TIE_PER_LENGTH * Math.min(hidden[0]!, hidden[1]!) * 100) / 100;
259
+ if (rests[0] == null || rests[1] == null) return { tie: null, leg: null };
260
+ const r = rests[1].hidden < rests[0].hidden ? rests[1] : rests[0];
261
+ return { tie: Math.round(TIE_PER_LENGTH * r.hidden * 100) / 100, leg: r.leg };
262
+ }
263
+
264
+ const round = (x: number, k: number): number => Math.round(x * 10 ** k) / 10 ** k;
265
+
266
+ /** One pair's leg depth gap against its tie over the frames (the rule's own test: unequal and at least the tie). */
267
+ function gapOf(pair: DrawOrderPair, zs: ReadonlyArray<Partial<Record<Limb, number>>>, leg: number | null): DrawOrderGap {
268
+ const tie = pair.tie ?? 0;
269
+ const gaps: number[] = [];
270
+ let passed = 0;
271
+ for (const z of zs) {
272
+ const za = z[pair.limbs[0]];
273
+ const zb = z[pair.limbs[1]];
274
+ if (za === undefined || zb === undefined) continue;
275
+ gaps.push(Math.abs(za - zb));
276
+ if (za !== zb && Math.abs(za - zb) >= tie) passed++;
277
+ }
278
+ gaps.sort((x, y) => x - y);
279
+ const m = gaps.length >> 1;
280
+ const median = gaps.length === 0 ? null : gaps.length % 2 === 1 ? gaps[m]! : (gaps[m - 1]! + gaps[m]!) / 2;
281
+ const max = gaps.length === 0 ? null : gaps[gaps.length - 1]!;
282
+ const legs = (x: number | null): number | null => (x === null || leg === null || !(leg > 0) ? null : round(x / leg, 3));
283
+ return { a: pair.a, b: pair.b, median: median === null ? null : round(median, 2), max: max === null ? null : round(max, 2), medianLegs: legs(median), maxLegs: legs(max), tie: pair.tie, tieLegs: legs(pair.tie), passed, frames: gaps.length };
197
284
  }
198
285
 
199
286
  /**
200
287
  * The draw-order keys from the frames' layers. `track` and `depth` are the sequence's
201
288
  * joints and depth per frame (the rest lengths the tie reads); `order` is the rig's
202
- * setup draw order (the compiled model's slots); `times` the frames' times.
289
+ * setup draw order (the compiled model's slots); `times` the frames' times. `gait` is
290
+ * the gait clean recorded for its derived depth: a walk orders by the ankle, a run by
291
+ * the knee (#336); none (measured depth) by the limb mean (#304).
203
292
  */
204
- export function drawOrderFromDepth(layers: readonly FrameLayers[], track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, times: readonly number[], map: JointMap, rest: Rest, order: ReadonlyArray<{ name: string; bone: string }>, sets?: RigSets, loop = false): DrawOrderReading {
293
+ export function drawOrderFromDepth(layers: readonly FrameLayers[], track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, times: readonly number[], map: JointMap, rest: Rest, order: ReadonlyArray<{ name: string; bone: string }>, sets?: RigSets, loop = false, gait: DepthGait | null = null): DrawOrderReading {
294
+ const by = gait === null ? undefined : ORDER_JOINT[gait];
205
295
  const resolved = DRAW_ORDER_PAIRS.map((p) => resolvePair(p, map, rest, order, sets));
206
- for (const r of resolved) r.pair.tie = tieOf(track, depth, r.pair.limbs);
296
+ const legs = resolved.map((r) => {
297
+ const t = tieOf(track, depth, r.pair.limbs, by);
298
+ r.pair.tie = t.tie;
299
+ if (by !== undefined) r.pair.by = by;
300
+ return t.leg;
301
+ });
302
+ // #336: per frame, each limb's depth at the gait's joint (rounded as the layers are); without a gait, the layers' limb means.
303
+ const zs: ReadonlyArray<Partial<Record<Limb, number>>> =
304
+ by === undefined
305
+ ? layers.map((l) => l.depth)
306
+ : track.map((j, i) => {
307
+ const z: Partial<Record<Limb, number>> = {};
308
+ for (const limb of new Set(DRAW_ORDER_PAIRS.flat())) {
309
+ const d = depth[i] === undefined ? null : limbJointDepths(j, depth[i]!, limb);
310
+ const v = d === null ? null : by === 'knee' ? d.mid : d.end;
311
+ if (v !== null) z[limb] = Math.round(v * 100) / 100;
312
+ }
313
+ return z;
314
+ });
207
315
  const names = order.map((s) => s.name);
208
316
  // Per pair, per frame: who is behind ('a' | 'b'), or null for the setup order before any decision.
209
317
  const states = resolved.map((r) => {
210
318
  const tie = r.pair.tie ?? 0;
211
319
  const pass = (start: 'a' | 'b' | null): Array<'a' | 'b' | null> => {
212
320
  let state = start;
213
- return layers.map((l) => {
321
+ return zs.map((z) => {
214
322
  if (!r.pair.keyed) return null;
215
- const za = l.depth[r.pair.limbs[0]];
216
- const zb = l.depth[r.pair.limbs[1]];
323
+ const za = z[r.pair.limbs[0]];
324
+ const zb = z[r.pair.limbs[1]];
217
325
  if (za !== undefined && zb !== undefined && za !== zb && Math.abs(za - zb) >= tie) state = za < zb ? 'a' : 'b';
218
326
  return state;
219
327
  });
@@ -231,12 +339,14 @@ export function drawOrderFromDepth(layers: readonly FrameLayers[], track: readon
231
339
  .sort((x, y) => names.indexOf(x.slot) - names.indexOf(y.slot));
232
340
  const keys: DrawOrderReading['keys'] = [];
233
341
  let last = '[]';
234
- for (let f = 0; f < layers.length; f++) {
342
+ for (let f = 0; f < zs.length; f++) {
235
343
  const offs = offsetsAt(f);
236
344
  const text = JSON.stringify(offs);
237
345
  if (text === last) continue;
238
346
  last = text;
239
- keys.push(offs.length === 0 ? { frame: f, t: times[f]! } : { frame: f, t: times[f]!, offsets: offs });
347
+ // Half a frame before the frame it shows on, as the switches' keys (`keyAtFrame`, rig-c/pose#325).
348
+ const t = keyAtFrame(times, f);
349
+ keys.push(offs.length === 0 ? { frame: f, t } : { frame: f, t, offsets: offs });
240
350
  }
241
351
  const spans: DrawOrderSpan[] = [];
242
352
  resolved.forEach((r, k) => {
@@ -247,7 +357,7 @@ export function drawOrderFromDepth(layers: readonly FrameLayers[], track: readon
247
357
  else spans.push({ behind: s === 'a' ? r.pair.a : r.pair.b, front: s === 'a' ? r.pair.b : r.pair.a, frames: [f, f], keyed: (s === 'a' ? r.aBehind : r.bBehind).length > 0 });
248
358
  });
249
359
  });
250
- return { pairs: resolved.map((r) => r.pair), keys, spans };
360
+ return { pairs: resolved.map((r) => r.pair), keys, spans, gaps: resolved.map((r, k) => gapOf(r.pair, zs, legs[k]!)) };
251
361
  }
252
362
 
253
363
  /** The animation's `drawOrder` value: the keys without their frames. */
@@ -263,7 +373,9 @@ const span = (f: [number, number]): string => (f[0] === f[1] ? `${f[0]}` : `${f[
263
373
  */
264
374
  export function drawOrderLines(d: DrawOrderReading, prefix = 'DRAWORDER '): string[] {
265
375
  const keyed = d.spans.filter((s) => s.keyed);
266
- const head = `${prefix}from depth, ${d.keys.length} key(s) over ${keyed.length} span(s)`;
376
+ // #336: a keyed pair ordered by a joint names it and its joint-scaled tie.
377
+ const joint = d.pairs.find((p) => p.by !== undefined && p.keyed);
378
+ const head = `${prefix}from depth${joint === undefined ? '' : ` by the ${joint.by} (${joint.by === 'ankle' ? 'walk' : 'run'}; tie ${joint.tie === null ? 'none' : `${joint.tie} px`})`}, ${d.keys.length} key(s) over ${keyed.length} span(s)`;
267
379
  const groups = new Map<string, string[]>();
268
380
  for (const s of keyed) {
269
381
  const g = `${s.behind} behind ${s.front}`;
@@ -285,6 +397,32 @@ export function drawOrderLines(d: DrawOrderReading, prefix = 'DRAWORDER '): stri
285
397
  while (n > 1 && text(n).length > DRAW_ORDER_LINE_MAX) n--;
286
398
  const why = d.pairs.every((p) => !p.keyed) ? 'no pair keyed' : 'the setup order holds on every frame';
287
399
  const lines = [keyed.length === 0 ? `${head} (${why})` : text(n)];
288
- for (const p of d.pairs) if (!p.keyed) lines.push(`${prefix}${p.a} / ${p.b} not keyed: ${p.why}`.slice(0, DRAW_ORDER_LINE_MAX));
400
+ for (const p of d.pairs) {
401
+ if (p.keyed) continue;
402
+ const line = (why: string | undefined): string => `${prefix}${p.a} / ${p.b} not keyed: ${why}`;
403
+ // Slots between the pair: the count first, the list cut until the line fits (#324).
404
+ let shown = p.between?.length ?? 0;
405
+ while (p.between !== undefined && shown > 0 && line(betweenWhy(p.between, shown)).length > DRAW_ORDER_LINE_MAX) shown--;
406
+ lines.push(line(p.between === undefined ? p.why : betweenWhy(p.between, shown)).slice(0, DRAW_ORDER_LINE_MAX));
407
+ }
289
408
  return lines;
290
409
  }
410
+
411
+ /** px with its fraction of the leg: "12.3 px (0.084 leg)". */
412
+ const pxLeg = (px: number | null, legs: number | null): string => (px === null ? 'unread' : `${px} px${legs === null ? '' : ` (${legs} leg)`}`);
413
+
414
+ /** " at the knee" / " at the ankle" for a pair ordered by a joint (#336), else nothing. */
415
+ const gapAt = (d: DrawOrderReading, g: DrawOrderGap): string => {
416
+ const j = d.pairs.find((p) => p.a === g.a && p.b === g.b)?.by;
417
+ return j === undefined ? '' : ` at the ${j}`;
418
+ };
419
+
420
+ /**
421
+ * The DRAWGAP lines (rig-c/pose#324): one per pair, keyed or not — the leg depth gap's median and
422
+ * max against the tie, and how many frames passed it. Cut to `DRAW_ORDER_LINE_MAX` characters.
423
+ */
424
+ export function drawGapLines(d: DrawOrderReading, prefix = 'DRAWGAP '): string[] {
425
+ return (d.gaps ?? []).map((g) =>
426
+ `${prefix}${g.a} / ${g.b}: leg depth gap${gapAt(d, g)} median ${pxLeg(g.median, g.medianLegs)}, max ${pxLeg(g.max, g.maxLegs)}, against the tie of ${g.tie === null ? 'none (a rest length is unread; equal depths only)' : pxLeg(g.tie, g.tieLegs)}; ${g.passed} of ${g.frames} frame(s) passed it`.slice(0, DRAW_ORDER_LINE_MAX),
427
+ );
428
+ }
package/src/legs.ts CHANGED
@@ -58,6 +58,7 @@
58
58
  * frames (`closeLoop`), so the loop carries no net travel.
59
59
  */
60
60
  import type { Chain, JointMap, Rest } from './map.ts';
61
+ import type { ContactKeysReading } from './contacts.ts';
61
62
 
62
63
  type Pt = [number, number];
63
64
  /** A world 2×2: columns (a, c) — the x axis — and (b, d) — the y axis. */
@@ -522,6 +523,12 @@ export interface Feet {
522
523
  * `supportsOf` on every reading.
523
524
  */
524
525
  signal?: SupportSignal;
526
+ /**
527
+ * rig-c/pose#303: what `--contact-keys` did — the keys, the frames they cover,
528
+ * the support without them and the runs they changed. Set by `contactKeysOf`,
529
+ * applied last; absent without the flag.
530
+ */
531
+ contactKeys?: ContactKeysReading;
525
532
  }
526
533
 
527
534
  /** A run of frames whose support the lower-ankle rule (rig-c/pose#113) reads differently from the reading before it. */
package/src/outdir.ts CHANGED
@@ -14,7 +14,7 @@ export type OutDirs = Map<string, string[]>;
14
14
 
15
15
  const writable = (dir: string): boolean => {
16
16
  try {
17
- accessSync(dir, constants.W_OK);
17
+ accessSync(dir, constants.W_OK | constants.X_OK);
18
18
  return true;
19
19
  } catch {
20
20
  return false;
@@ -22,9 +22,9 @@ const writable = (dir: string): boolean => {
22
22
  };
23
23
 
24
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.
25
+ * Check where each named output lands: its directory is there and this process can write into it
26
+ * (rig-c/pose#323), or the nearest existing ancestor is a directory this process can write into.
27
+ * A miss is added to `p`; a directory still to make is added to `dirs`. A directory a flag names itself (--build, --render) is checked by its parent.
28
28
  */
29
29
  export function planOutDirs(outputs: ReadonlyArray<readonly [string, string | undefined]>, p: Problems, dirs: OutDirs = new Map()): OutDirs {
30
30
  for (const [flag, path] of outputs) {
@@ -33,7 +33,7 @@ export function planOutDirs(outputs: ReadonlyArray<readonly [string, string | un
33
33
  let up = dir;
34
34
  while (!existsSync(up) && dirname(up) !== up) up = dirname(up);
35
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`);
36
+ else if (!writable(up)) p.add('ARG_PATH', up === dir ? `${flag} ${path} — ${dir} is not writable` : `${flag} ${path} — ${up} is not writable, so directory ${dir} cannot be made under it`);
37
37
  else if (up !== dir) dirs.set(dir, [...(dirs.get(dir) ?? []), `${flag} ${path}`]);
38
38
  }
39
39
  return dirs;