@rig-c/pose 0.21.0 → 0.22.1
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/README.md +4 -3
- package/cli.ts +5 -1
- package/package.json +1 -1
- package/src/face.ts +51 -13
- package/src/legs.ts +88 -8
- package/src/reduce.ts +359 -0
- package/src/retarget.ts +92 -41
- package/src/run.ts +45 -4
- package/src/switch.ts +53 -4
package/README.md
CHANGED
|
@@ -196,7 +196,7 @@ Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. O
|
|
|
196
196
|
|
|
197
197
|
A knee bending toward the camera is seen from the front only as a shorter leg, and a rig with rigid legs cannot lower its hip without lifting its feet ([#47](https://github.com/rig-c/pose/issues/47)). Two flags, both off by default:
|
|
198
198
|
|
|
199
|
-
**`--leg-scale <min,max>`** scales each mapped leg segment (two of one side's hip, knee and ankle) along its own x axis — a `scalex` key — by its picture length on the frame over its **straight length**, the larger of the two sides' 95th-percentile picture lengths for that segment over the sequence ([#148](https://github.com/rig-c/pose/issues/148); on a non-`in-place` sequence longer than 25 frames, each frame's segment length is normalised by
|
|
199
|
+
**`--leg-scale <min,max>`** scales each mapped leg segment (two of one side's hip, knee and ankle) along its own x axis — a `scalex` key — by its picture length on the frame over its **straight length**, the larger of the two sides' 95th-percentile picture lengths for that segment over the sequence ([#148](https://github.com/rig-c/pose/issues/148); on a non-`in-place` sequence longer than 25 frames, each frame's segment length is normalised by a continuous rate-limited figure-scale signal `figureScaleOf` over `nose → lower ankle` standing height — frame-to-frame step ≤ 1 %, frozen spans ≥ 5 frames held at their median — so camera zoom neither shrinks earlier phrases nor lags behind frozen endings, and `--camera <file>` writes that track as `rig-pose-camera/1` without changing `motion.json`, [#159](https://github.com/rig-c/pose/issues/159), [#161](https://github.com/rig-c/pose/issues/161), [#163](https://github.com/rig-c/pose/issues/163)), clamped to [min, max]. The card's range is `0.8,1.05`. A segment the map aims other than `"x"` is refused (`LEGS_MAP`). `LEG-SCALE` lists each segment's frames clamped at either limit.
|
|
200
200
|
|
|
201
201
|
rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inherit mode takes its parent's whole world matrix. So under a thigh scaled by s, a shin at angle φ in the thigh's axes points at atan2(sin φ, s·cos φ), and is itself scaled by |(s·cos φ, sin φ)|. A child (or its intermediate control bone) with `noScale` or `noScaleOrReflection` ([#153](https://github.com/rig-c/pose/issues/153)) still has its direction carried through the parent's scaled matrix, so its angle is skewed the same way, while rig-c normalises that direction to unit length so the parent's scale is not inherited. The rotate key `turn − turn above` assumes unit scale. A mapped bone under a scaled bone is therefore keyed through its parent's posed matrix: a `rotate` key k and a `scalex` key s' put its world x axis on its target at the scale asked (under `noScale`, s' is the bone's own asked scale s directly). On HQ's dancer the unit-scale formula missed the shins by up to 2.4°, their length by 0.16 of setup and the ankles by 38 units; solved, every bone lands. A `normal` child's art is still sheared by its parent's scale, and a bone under it (a foot) is squashed along the shin: `SHEAR` reads both.
|
|
202
202
|
|
|
@@ -253,6 +253,7 @@ The retarget writes one key per pose frame on every mapped bone. That is faithfu
|
|
|
253
253
|
- A scaled bone's `scalex` track is thinned within 5e-5 of its setup length: half of the scale landing check's 1e-4, so a reduced curve cannot fail that check.
|
|
254
254
|
- A mapped bone under a scaled bone (keyed through its parent's posed matrix by `--leg-scale`) is solved again through its parent as reduced. Its rotate is then thinned with the error read as the world aim it lands on, and its `scalex` with the error read as the world length it lands on. Its world error is its own fit error, as down any other chain.
|
|
255
255
|
- Each ankle is held to the plant's tolerance plus the most the reduction may move it: the hip's tolerance (with `--reduce-hip`), plus the leg from the hip to the ankle (each bone's setup offset from its parent and the last bone's length) times the largest scale asked, times the angle tolerance in radians plus 5e-5. The ankle's `LAND` line prints that tolerance.
|
|
256
|
+
- When `--leg-scale` is active, each trunk bone (`chest` and `head`, not an ancestor of any scaled bone) has its kept knots and Bézier handles smoothed within `deg` of its dense world aim to minimise the mean absolute second difference of its evaluated local angle and its child's compensated local angle, holding any limb children within 0.03° of their fitted world aim (rig-c/pose#116).
|
|
256
257
|
|
|
257
258
|
Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
|
|
258
259
|
|
|
@@ -313,7 +314,7 @@ Which bones carry which joints. You write it for your rig:
|
|
|
313
314
|
⚠️ **Seen from the front, an elbow's side is not a joint limit.** An elbow flexes toward the camera, and its picture falls on either side of the upper arm depending on how the arm is turned. On a real 13 s front-facing dance (313 frames, RTMPose), the right elbow sat on the "wrong" side of the rig's rest on 165 frames (median 23°), and the left on 200 (median 29°, up to 179° with the hands behind the head). Every one was a real elbow bend. The knees strayed at most 8.8° and 14.7°, which is pose noise around a straight leg. So for front-facing material, arms go `"free"`. Legs on `"rest"` with `--bends clamp` hold only while the knees stay near straight. On a second dance with knee bends (Toca Toca, rig-c/pose#27), that setting straightened the left knee for most of the dance: `leg_l1` was 48.3° off at p90, against 0.0–0.3° with free legs. Leave legs `"free"` unless the dance keeps its knees straight.
|
|
314
315
|
- `mid_hip` and `mid_shoulder` are the midpoints of the two hips and the two shoulders.
|
|
315
316
|
- `translate.scale` is one length measured on both: two points on the rig, two points in the pose (its median over the frames where both are good). Pick a long segment that does not foreshorten much.
|
|
316
|
-
- `face` (optional, [#138](https://github.com/rig-c/pose/issues/138)): `{ "yaw"?: { "bone": "yaw", "range": [-15, 15] }, "pitch"?: { "bone": "pitch", "range": [-10, 15] } }` (`min ≤ 0 ≤ max`, `min < max`). Reads per-frame yaw and pitch from `nose` against `r_eye + l_eye` (and `r_ear + l_ear` when present), compresses
|
|
317
|
+
- `face` (optional, [#138](https://github.com/rig-c/pose/issues/138), [#164](https://github.com/rig-c/pose/issues/164)): `{ "yaw"?: { "bone": "yaw", "range": [-15, 15] }, "pitch"?: { "bone": "pitch", "range": [-10, 15] } }` (`min ≤ 0 ≤ max`, `min < max`). Reads per-frame yaw and pitch from `nose` against `r_eye + l_eye` (and `r_ear + l_ear` when present), maps 1:1 inside `0.8 × range` (`[-12, 12]°` on `[-15, 15]°`) and compresses only the overshoot above the knee into the remaining 20% of the range so a single outlier frame does not crush the rest of the clip, and keys the named dial bone's `rotate` track (`FACE`).
|
|
317
318
|
|
|
318
319
|
### A joint's range: `range`
|
|
319
320
|
|
|
@@ -359,7 +360,7 @@ The bound exists because speed alone rewarded showing art far from its pose. Unb
|
|
|
359
360
|
|
|
360
361
|
Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's old and new frame (the same under `first`), the frames and seconds it moved, the speed on both and its window, and the report carries the same under `moved`.
|
|
361
362
|
|
|
362
|
-
**What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. A switch onto frame *i* is keyed halfway between frames *i*−1 and *i*: a stepped key on the frame's own time fires a frame late wherever a player's accumulated time lands just below it (rig-c's docs/AUTHORING.md §4.5), and halfway both the pose's times and a renderer's `i / fps` see it on frame *i*. Before the first key a slot shows its setup attachment, so no key is written at 0 unless a span starts there. Offsets are `translate` keys at the same times, `stepped`. rig-c's gate checks these keys like any other.
|
|
363
|
+
**What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. Between two sets that both draw an arm behind the torso, a frame that belongs to neither set stays behind (the nearer set's layer); the default front slot is for a frame no set can claim **and** whose arm is read in front — not a fallback ([#136](https://github.com/rig-c/pose/issues/136), [#139](https://github.com/rig-c/pose/issues/139), [#162](https://github.com/rig-c/pose/issues/162)). A switch onto frame *i* is keyed halfway between frames *i*−1 and *i*: a stepped key on the frame's own time fires a frame late wherever a player's accumulated time lands just below it (rig-c's docs/AUTHORING.md §4.5), and halfway both the pose's times and a renderer's `i / fps` see it on frame *i*. Before the first key a slot shows its setup attachment, so no key is written at 0 unless a span starts there. Offsets are `translate` keys at the same times, `stepped`. rig-c's gate checks these keys like any other.
|
|
363
364
|
|
|
364
365
|
**TURN.** The cutoff was measured on the three lines across the body before it was fixed ([#6](https://github.com/rig-c/pose/issues/6)): on the 13 s front-facing dance their picture length never drops under 0.81 of its median and none reverses; on a second sequence the hip line falls to 0.17 at frames 465–491 (side-on) and the eye line to 0.22 at 26–58 (head in profile), and 3 frames reverse. 0.5 lies between. A line is reversed when it points more than 90° from the subject's right joint to the left (front-facing, it points at 0°). The lines are read after holds, held as the map's points are even where the map does not use them; a line whose points are good on no frame is named as not measured.
|
|
365
366
|
|
package/cli.ts
CHANGED
|
@@ -165,6 +165,9 @@ retarget
|
|
|
165
165
|
--report <file> write the run's measurements (holds, short, endOn, flips, bends, landings, folds,
|
|
166
166
|
switches, uncovered, turns, range, artRange, raised, byHip,
|
|
167
167
|
idleDropped, idleKeptOut, merged, reduce) as JSON
|
|
168
|
+
--camera <file> write the sequence's camera track (rig-pose-camera/1: continuous figure scale s,
|
|
169
|
+
raw standing height ratio, pan tx/ty, and composite videoS) as a sidecar JSON file
|
|
170
|
+
without changing motion.json (rig-c/pose#161)
|
|
168
171
|
|
|
169
172
|
It writes nothing until rigc's gate is green with the animation in, every mapped bone lands on
|
|
170
173
|
the angle the pose asks for (posed by rig-c's own poser), and no hinge bends the wrong way.
|
|
@@ -262,7 +265,7 @@ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
|
|
|
262
265
|
}
|
|
263
266
|
|
|
264
267
|
function retargetArgs(argv: string[]): RetargetArgs {
|
|
265
|
-
const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip', 'stance', 'floor'], ['coco', 'plant-feet', 'carry-idle', 'raised-by-head']);
|
|
268
|
+
const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report', 'camera', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip', 'stance', 'floor'], ['coco', 'plant-feet', 'carry-idle', 'raised-by-head']);
|
|
266
269
|
const bends = f.get('bends') ?? 'refuse';
|
|
267
270
|
if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
|
|
268
271
|
const switchAt = f.get('switch-at') ?? 'first';
|
|
@@ -299,6 +302,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
|
|
|
299
302
|
build: f.get('build'),
|
|
300
303
|
render: f.get('render'),
|
|
301
304
|
report: f.get('report'),
|
|
305
|
+
camera: f.get('camera'),
|
|
302
306
|
idle: f.get('idle'),
|
|
303
307
|
carryIdle: f.has('carry-idle'),
|
|
304
308
|
merge: f.get('merge'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/pose",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Puts motion from a body-18 pose sequence onto a rig built with rig-c: bone keys in rig-c's motion format, checked through rig-c's gate before anything is written. A CLI for agents that cannot see the rig.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/face.ts
CHANGED
|
@@ -11,6 +11,13 @@ type Pt = [number, number];
|
|
|
11
11
|
|
|
12
12
|
const DEG = 180 / Math.PI;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Fraction of each side's limit below which `mapFaceAxis` is 1:1 identity
|
|
16
|
+
* (`rig-c/pose#164`); above `FACE_KNEE * limit` (`12°` for `15°`, `8°` for `10°`),
|
|
17
|
+
* a piecewise-linear knee compresses only the overshoot `[k, peak]` into `[k, limit]`.
|
|
18
|
+
*/
|
|
19
|
+
export const FACE_KNEE = 0.8;
|
|
20
|
+
|
|
14
21
|
export interface FaceSpan {
|
|
15
22
|
min: number;
|
|
16
23
|
max: number;
|
|
@@ -20,11 +27,13 @@ export interface FaceSpan {
|
|
|
20
27
|
export interface FaceAxisReading {
|
|
21
28
|
bone: string;
|
|
22
29
|
limits: [number, number];
|
|
30
|
+
/** Knee thresholds `[kneeNeg, kneePos]` in degrees (`FACE_KNEE * limits`), inside which mapping is 1:1. */
|
|
31
|
+
knee: [number, number];
|
|
23
32
|
read: FaceSpan;
|
|
24
33
|
mapped: FaceSpan;
|
|
25
|
-
/**
|
|
34
|
+
/** Peak-to-limit ratios `[kNeg, kPos]` in (0, 1] for negative and positive sides. */
|
|
26
35
|
scale: [number, number];
|
|
27
|
-
/**
|
|
36
|
+
/** Spearman rank (monotone) correlation between the read angle and the mapped keys over the clip. */
|
|
28
37
|
corr: number;
|
|
29
38
|
/** Per-frame read angle in degrees, before range compression. */
|
|
30
39
|
raw: number[];
|
|
@@ -65,16 +74,31 @@ function spanOf(xs: readonly number[]): FaceSpan {
|
|
|
65
74
|
return { min: r3(min), max: r3(max), range: r3(max - min) };
|
|
66
75
|
}
|
|
67
76
|
|
|
77
|
+
function ranksOf(xs: readonly number[]): number[] {
|
|
78
|
+
const idx = xs.map((_, i) => i).sort((a, b) => xs[a]! - xs[b]!);
|
|
79
|
+
const out = new Array<number>(xs.length).fill(0);
|
|
80
|
+
for (let i = 0; i < idx.length;) {
|
|
81
|
+
let j = i;
|
|
82
|
+
while (j + 1 < idx.length && xs[idx[j + 1]!]! === xs[idx[i]!]!) j++;
|
|
83
|
+
const rank = 0.5 * (i + j);
|
|
84
|
+
for (let k = i; k <= j; k++) out[idx[k]!] = rank;
|
|
85
|
+
i = j + 1;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
function corrOf(a: readonly number[], b: readonly number[]): number {
|
|
69
91
|
if (a.length < 2 || a.length !== b.length) return 1;
|
|
70
|
-
const
|
|
71
|
-
const
|
|
92
|
+
const ra = ranksOf(a);
|
|
93
|
+
const rb = ranksOf(b);
|
|
94
|
+
const ma = ra.reduce((x, y) => x + y, 0) / ra.length;
|
|
95
|
+
const mb = rb.reduce((x, y) => x + y, 0) / rb.length;
|
|
72
96
|
let num = 0;
|
|
73
97
|
let da = 0;
|
|
74
98
|
let db = 0;
|
|
75
|
-
for (let i = 0; i <
|
|
76
|
-
const xa =
|
|
77
|
-
const xb =
|
|
99
|
+
for (let i = 0; i < ra.length; i++) {
|
|
100
|
+
const xa = ra[i]! - ma;
|
|
101
|
+
const xb = rb[i]! - mb;
|
|
78
102
|
num += xa * xb;
|
|
79
103
|
da += xa * xa;
|
|
80
104
|
db += xb * xb;
|
|
@@ -153,12 +177,21 @@ export function readFaceAngles(
|
|
|
153
177
|
return { ears, yaw, pitch };
|
|
154
178
|
}
|
|
155
179
|
|
|
180
|
+
function mapKneeSide(u: number, limit: number, peak: number): number {
|
|
181
|
+
if (!(limit > 1e-9)) return 0;
|
|
182
|
+
if (!(peak > limit)) return u;
|
|
183
|
+
const k = FACE_KNEE * limit;
|
|
184
|
+
if (u <= k) return u;
|
|
185
|
+
if (u >= peak) return limit;
|
|
186
|
+
return k + ((limit - k) * (u - k)) / (peak - k);
|
|
187
|
+
}
|
|
188
|
+
|
|
156
189
|
/**
|
|
157
190
|
* Compress a read angle series into `axis.range` (`[min, max]` with `min <= 0 <= max`):
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
191
|
+
* readings on a side whose peak stays inside the limit map 1:1 (`f(x) = x`); when a
|
|
192
|
+
* side's peak exceeds its limit `L`, readings up to the knee `k = FACE_KNEE * L`
|
|
193
|
+
* (`0.8 * L`, `rig-c/pose#164`) still map 1:1 and only the overshoot `[k, peak]` is
|
|
194
|
+
* compressed linearly into `[k, L]`.
|
|
162
195
|
*/
|
|
163
196
|
export function mapFaceAxis(raw: readonly number[], axis: FaceAxis): FaceAxisReading {
|
|
164
197
|
const limits = axis.range;
|
|
@@ -168,12 +201,17 @@ export function mapFaceAxis(raw: readonly number[], axis: FaceAxis): FaceAxisRea
|
|
|
168
201
|
if (x < rawMin) rawMin = x;
|
|
169
202
|
if (x > rawMax) rawMax = x;
|
|
170
203
|
}
|
|
204
|
+
const limNeg = -limits[0];
|
|
205
|
+
const limPos = limits[1];
|
|
206
|
+
const peakNeg = -rawMin;
|
|
207
|
+
const peakPos = rawMax;
|
|
171
208
|
const kNeg = rawMin < limits[0] && rawMin < -1e-9 ? limits[0] / rawMin : 1;
|
|
172
209
|
const kPos = rawMax > limits[1] && rawMax > 1e-9 ? limits[1] / rawMax : 1;
|
|
173
|
-
const keys = raw.map((x) => r3(x < 0 ? x
|
|
210
|
+
const keys = raw.map((x) => r3(x < 0 ? -mapKneeSide(-x, limNeg, peakNeg) : mapKneeSide(x, limPos, peakPos)));
|
|
174
211
|
return {
|
|
175
212
|
bone: axis.bone,
|
|
176
213
|
limits: [limits[0], limits[1]],
|
|
214
|
+
knee: [r3(FACE_KNEE * limits[0]), r3(FACE_KNEE * limits[1])],
|
|
177
215
|
read: spanOf(raw),
|
|
178
216
|
mapped: spanOf(keys),
|
|
179
217
|
scale: [r3(kNeg), r3(kPos)],
|
|
@@ -215,7 +253,7 @@ export function faceLine(f: FaceReading): string {
|
|
|
215
253
|
for (const name of ['yaw', 'pitch'] as const) {
|
|
216
254
|
const ax = f[name];
|
|
217
255
|
if (ax === undefined) continue;
|
|
218
|
-
axes.push(`${name} → bone "${ax.bone}" [${ax.limits[0]}, ${ax.limits[1]}]
|
|
256
|
+
axes.push(`${name} → bone "${ax.bone}" [${ax.limits[0]}, ${ax.limits[1]}]° (knee [${ax.knee[0]}, ${ax.knee[1]}]°): ${sg(ax.mapped.min)}° … ${sg(ax.mapped.max)}° (scale ${ax.scale[0].toFixed(3)}/${ax.scale[1].toFixed(3)}, r=${ax.corr.toFixed(3)})`);
|
|
219
257
|
}
|
|
220
258
|
return `${joints}: ${readPart}; mapped ${axes.join(', ')}`;
|
|
221
259
|
}
|
package/src/legs.ts
CHANGED
|
@@ -834,6 +834,82 @@ export const STANCE_BOUNDS: readonly [number, number] = [0.92, 1.08];
|
|
|
834
834
|
/** Per side, per frame: the lower bound of the leg's stretch (#95). */
|
|
835
835
|
export type StanceLower = Record<Side, readonly number[]>;
|
|
836
836
|
|
|
837
|
+
/** Maximum per-frame relative step of the continuous figure-scale curve (`rig-c/pose#163`). */
|
|
838
|
+
export const FIGURE_SCALE_STEP = 0.01;
|
|
839
|
+
/** Consecutive frames whose raw figure height stays within this many pixels over ≥ `FROZEN_MIN_FRAMES` frames hold their span median (`rig-c/pose#163`). */
|
|
840
|
+
export const FROZEN_STEP_PX = 0.5;
|
|
841
|
+
export const FROZEN_MIN_FRAMES = 5;
|
|
842
|
+
|
|
843
|
+
function medianOf(xs: readonly number[]): number {
|
|
844
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
845
|
+
const m = s.length >> 1;
|
|
846
|
+
return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Continuous per-frame figure scale `s[i]` (`s[0] = 1`, `rig-c/pose#163`,
|
|
851
|
+
* `rig-c/pose#161`): L2 projection of `log(raw)` onto the rate-limited corridor
|
|
852
|
+
* `|log(s[i+1] / s[i])| <= log(1 + FIGURE_SCALE_STEP)` (`≤ 1%` per frame),
|
|
853
|
+
* holding any frozen span (`≥ FROZEN_MIN_FRAMES` frames where consecutive raw
|
|
854
|
+
* heights move by `≤ FROZEN_STEP_PX`) constant at its span median.
|
|
855
|
+
*/
|
|
856
|
+
export function figureScaleOf(raw: readonly number[], maxStep = FIGURE_SCALE_STEP): number[] {
|
|
857
|
+
const m = raw.length;
|
|
858
|
+
if (m === 0) return [];
|
|
859
|
+
const u = raw.map((v) => Math.log(Math.max(1e-6, v)));
|
|
860
|
+
const spans: Array<{ lo: number; hi: number; val: number }> = [];
|
|
861
|
+
for (let i = 0; i < m - 1;) {
|
|
862
|
+
if (Math.abs(raw[i + 1]! - raw[i]!) > FROZEN_STEP_PX) {
|
|
863
|
+
i++;
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
let j = i + 1;
|
|
867
|
+
while (j + 1 < m && Math.abs(raw[j + 1]! - raw[j]!) <= FROZEN_STEP_PX) j++;
|
|
868
|
+
if (j - i + 1 >= FROZEN_MIN_FRAMES) {
|
|
869
|
+
const med = Math.log(Math.max(1e-6, medianOf(raw.slice(i, j + 1))));
|
|
870
|
+
for (let k = i; k <= j; k++) u[k] = med;
|
|
871
|
+
spans.push({ lo: i, hi: j, val: med });
|
|
872
|
+
}
|
|
873
|
+
i = j + 1;
|
|
874
|
+
}
|
|
875
|
+
const r0 = Math.log(1 + maxStep);
|
|
876
|
+
const r = new Array<number>(Math.max(0, m - 1)).fill(r0);
|
|
877
|
+
for (const sp of spans) {
|
|
878
|
+
for (let i = sp.lo; i < sp.hi; i++) r[i] = 0;
|
|
879
|
+
}
|
|
880
|
+
for (let k = 0; k + 1 < spans.length; k++) {
|
|
881
|
+
const a = spans[k]!;
|
|
882
|
+
const b = spans[k + 1]!;
|
|
883
|
+
const gap = b.lo - a.hi;
|
|
884
|
+
if (gap > 0) {
|
|
885
|
+
const needed = Math.abs(b.val - a.val) / gap;
|
|
886
|
+
if (needed > r0) {
|
|
887
|
+
for (let i = a.hi; i < b.lo; i++) r[i] = needed * 1.0001;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const lam = new Array<number>(Math.max(0, m - 1)).fill(0);
|
|
892
|
+
const y = [...u];
|
|
893
|
+
for (let iter = 0; iter < 5000; iter++) {
|
|
894
|
+
let maxChange = 0;
|
|
895
|
+
for (let i = 0; i < m - 1; i++) {
|
|
896
|
+
const z = lam[i]! + 0.5 * (y[i + 1]! - y[i]!);
|
|
897
|
+
const thresh = 0.5 * r[i]!;
|
|
898
|
+
const next = z > thresh ? z - thresh : z < -thresh ? z + thresh : 0;
|
|
899
|
+
const d = next - lam[i]!;
|
|
900
|
+
if (Math.abs(d) > 1e-12) {
|
|
901
|
+
lam[i] = next;
|
|
902
|
+
y[i] = y[i]! + d;
|
|
903
|
+
y[i + 1] = y[i + 1]! - d;
|
|
904
|
+
if (Math.abs(d) > maxChange) maxChange = Math.abs(d);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (maxChange < 1e-11) break;
|
|
908
|
+
}
|
|
909
|
+
const y0 = y[0]!;
|
|
910
|
+
return y.map((v) => Math.exp(v - y0));
|
|
911
|
+
}
|
|
912
|
+
|
|
837
913
|
/**
|
|
838
914
|
* The leg's own range (rig-c/pose#95). Per side, r(i) = the leg's picture
|
|
839
915
|
* length hip → ankle on frame i ÷ its straight length (`STRAIGHT_PERCENTILE`
|
|
@@ -842,16 +918,20 @@ export type StanceLower = Record<Side, readonly number[]>;
|
|
|
842
918
|
* fold as far as the picture's leg is folded on that frame, never less far
|
|
843
919
|
* than the default. A leg with no straight length keeps the default.
|
|
844
920
|
*/
|
|
845
|
-
export function stanceRangeOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, inPlace = false): StanceLower {
|
|
921
|
+
export function stanceRangeOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, inPlace = false, figureScale?: readonly number[]): StanceLower {
|
|
846
922
|
const out = {} as Record<Side, number[]>;
|
|
923
|
+
const n = ankle.r.length;
|
|
924
|
+
const scale = !inPlace && n > 25
|
|
925
|
+
? (figureScale ?? figureScaleOf(ankle.r.map((ar, i) => Math.max(
|
|
926
|
+
Math.hypot(ar[0] - hip.r[i]![0], ar[1] - hip.r[i]![1]),
|
|
927
|
+
Math.hypot(ankle.l[i]![0] - hip.l[i]![0], ankle.l[i]![1] - hip.l[i]![1]),
|
|
928
|
+
))))
|
|
929
|
+
: null;
|
|
847
930
|
for (const s of SIDES) {
|
|
848
|
-
const
|
|
849
|
-
const
|
|
850
|
-
const
|
|
851
|
-
out[s] = len.map((l,
|
|
852
|
-
const straight = inPlace || n <= 25 ? globalStraight : percentile(len.slice(Math.max(0, i - 24), Math.min(n, i + 25)), STRAIGHT_PERCENTILE);
|
|
853
|
-
return straight > 1e-9 && Number.isFinite(l) ? Math.min(STANCE_BOUNDS[0], l / straight) : STANCE_BOUNDS[0];
|
|
854
|
-
});
|
|
931
|
+
const raw = ankle[s].map((a, i) => Math.hypot(a[0] - hip[s][i]![0], a[1] - hip[s][i]![1]));
|
|
932
|
+
const len = scale !== null ? raw.map((l, i) => (scale[i]! > 1e-6 ? l / scale[i]! : l)) : raw;
|
|
933
|
+
const straight = percentile(len, STRAIGHT_PERCENTILE);
|
|
934
|
+
out[s] = len.map((l) => (straight > 1e-9 && Number.isFinite(l) ? Math.min(STANCE_BOUNDS[0], l / straight) : STANCE_BOUNDS[0]));
|
|
855
935
|
}
|
|
856
936
|
return out;
|
|
857
937
|
}
|
package/src/reduce.ts
CHANGED
|
@@ -379,6 +379,365 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
|
|
|
379
379
|
return { rotate: ordered, hip: hipKeys, rows, errors: errorsOrdered };
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// trunk-chain smoothing within the stated tolerance (rig-c/pose#116)
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
interface TrackBasis {
|
|
387
|
+
keys: readonly ReducedKey[];
|
|
388
|
+
samples: readonly number[];
|
|
389
|
+
K: number;
|
|
390
|
+
spans: number;
|
|
391
|
+
P: number;
|
|
392
|
+
hx1s: number[];
|
|
393
|
+
hx2s: number[];
|
|
394
|
+
f0: number[];
|
|
395
|
+
cols: Float64Array[];
|
|
396
|
+
d2Cols: Float64Array[];
|
|
397
|
+
activeFrames: number[][];
|
|
398
|
+
activeD2: number[][];
|
|
399
|
+
x0: Float64Array;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function evalTrack1D(keys: readonly ReducedKey[], samples: readonly number[]): number[] {
|
|
403
|
+
return trackAt(keys, samples, 1).map((v) => v[0]!);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function buildBasis(keys: readonly ReducedKey[], samples: readonly number[]): TrackBasis {
|
|
407
|
+
const K = keys.length;
|
|
408
|
+
const n = samples.length;
|
|
409
|
+
const spans = K - 1;
|
|
410
|
+
const hx1s: number[] = [];
|
|
411
|
+
const hx2s: number[] = [];
|
|
412
|
+
for (let j = 0; j < spans; j++) {
|
|
413
|
+
const t0 = keys[j]!.t;
|
|
414
|
+
const t1 = keys[j + 1]!.t;
|
|
415
|
+
hx1s.push(t0 + (t1 - t0) / 3);
|
|
416
|
+
hx2s.push(t0 + (2 * (t1 - t0)) / 3);
|
|
417
|
+
}
|
|
418
|
+
const zeroKeys: ReducedKey[] = keys.map((k, j) => (j + 1 < K ? { t: k.t, v: [0], curve: [hx1s[j]!, 0, hx2s[j]!, 0] } : { t: k.t, v: [0] }));
|
|
419
|
+
const f0 = evalTrack1D(zeroKeys, samples);
|
|
420
|
+
const P = K + 2 * spans;
|
|
421
|
+
const cols: Float64Array[] = [];
|
|
422
|
+
for (let p = 0; p < P; p++) {
|
|
423
|
+
const testKeys = zeroKeys.map((k) => ({ t: k.t, v: [...k.v], ...(k.curve !== undefined ? { curve: [...k.curve] } : {}) }));
|
|
424
|
+
if (p < K) testKeys[p]!.v[0] = 1;
|
|
425
|
+
else if (p < K + spans) testKeys[p - K]!.curve![1] = 1;
|
|
426
|
+
else testKeys[p - K - spans]!.curve![3] = 1;
|
|
427
|
+
const fp = evalTrack1D(testKeys, samples);
|
|
428
|
+
const col = new Float64Array(n);
|
|
429
|
+
for (let i = 0; i < n; i++) col[i] = fp[i]! - f0[i]!;
|
|
430
|
+
cols.push(col);
|
|
431
|
+
}
|
|
432
|
+
const d2Cols: Float64Array[] = cols.map((col) => {
|
|
433
|
+
const d = new Float64Array(Math.max(0, n - 2));
|
|
434
|
+
for (let i = 0; i < n - 2; i++) d[i] = col[i + 2]! - 2 * col[i + 1]! + col[i]!;
|
|
435
|
+
return d;
|
|
436
|
+
});
|
|
437
|
+
const activeFrames: number[][] = cols.map((col) => {
|
|
438
|
+
const idx: number[] = [];
|
|
439
|
+
for (let i = 0; i < n; i++) if (Math.abs(col[i]!) > 1e-9) idx.push(i);
|
|
440
|
+
return idx;
|
|
441
|
+
});
|
|
442
|
+
const activeD2: number[][] = d2Cols.map((dcol) => {
|
|
443
|
+
const idx: number[] = [];
|
|
444
|
+
for (let i = 0; i < dcol.length; i++) if (Math.abs(dcol[i]!) > 1e-9) idx.push(i);
|
|
445
|
+
return idx;
|
|
446
|
+
});
|
|
447
|
+
const x0 = new Float64Array(P);
|
|
448
|
+
for (let j = 0; j < K; j++) x0[j] = keys[j]!.v[0]!;
|
|
449
|
+
for (let j = 0; j < spans; j++) {
|
|
450
|
+
const c = keys[j]!.curve;
|
|
451
|
+
if (c !== undefined) {
|
|
452
|
+
x0[K + j] = c[1]!;
|
|
453
|
+
x0[K + spans + j] = c[3]!;
|
|
454
|
+
} else {
|
|
455
|
+
x0[K + j] = (2 * keys[j]!.v[0]! + keys[j + 1]!.v[0]!) / 3;
|
|
456
|
+
x0[K + spans + j] = (keys[j]!.v[0]! + 2 * keys[j + 1]!.v[0]!) / 3;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return { keys, samples, K, spans, P, hx1s, hx2s, f0, cols, d2Cols, activeFrames, activeD2, x0 };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function solveMinPWL(terms: ReadonlyArray<{ a: number; b: number; w: number }>, lo: number, hi: number): number {
|
|
463
|
+
const cands = [lo, hi];
|
|
464
|
+
for (let i = 0; i < terms.length; i++) {
|
|
465
|
+
const t = terms[i]!;
|
|
466
|
+
if (Math.abs(t.a) > 1e-12) {
|
|
467
|
+
const r = -t.b / t.a;
|
|
468
|
+
if (r > lo && r < hi) cands.push(r);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
let bestX = lo;
|
|
472
|
+
let bestVal = Infinity;
|
|
473
|
+
for (let c = 0; c < cands.length; c++) {
|
|
474
|
+
const x = cands[c]!;
|
|
475
|
+
let val = 0;
|
|
476
|
+
for (let i = 0; i < terms.length; i++) {
|
|
477
|
+
const t = terms[i]!;
|
|
478
|
+
val += t.w * Math.abs(t.a * x + t.b);
|
|
479
|
+
}
|
|
480
|
+
if (val < bestVal) {
|
|
481
|
+
bestVal = val;
|
|
482
|
+
bestX = x;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return bestX;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function optimizeBasis(
|
|
489
|
+
basis: TrackBasis,
|
|
490
|
+
lb: readonly number[],
|
|
491
|
+
ub: readonly number[],
|
|
492
|
+
wSmooth = 1.0,
|
|
493
|
+
refY?: readonly number[],
|
|
494
|
+
refD2?: readonly number[],
|
|
495
|
+
): { keys: ReducedKey[]; y: number[] } {
|
|
496
|
+
const { keys, samples, K, spans, P, hx1s, hx2s, f0, cols, d2Cols, activeFrames, activeD2, x0 } = basis;
|
|
497
|
+
const n = samples.length;
|
|
498
|
+
const x = new Float64Array(x0);
|
|
499
|
+
const y = new Float64Array(n);
|
|
500
|
+
for (let i = 0; i < n; i++) {
|
|
501
|
+
let s = f0[i]!;
|
|
502
|
+
for (let p = 0; p < P; p++) s += cols[p]![i]! * x[p]!;
|
|
503
|
+
y[i] = s;
|
|
504
|
+
}
|
|
505
|
+
const seamWeight = Math.abs(y[n - 1]! - y[0]!) < 2.0 ? 20.0 : 0.0;
|
|
506
|
+
const seamDiff0 = y[n - 1]! - y[0]!;
|
|
507
|
+
const stepLimit = Math.max(5.0, Math.max(...ub.map((u, i) => Math.abs(u - lb[i]!))) * 5);
|
|
508
|
+
for (const pen of [2, 10, 50, 500, 10000, 100000]) {
|
|
509
|
+
for (let sweep = 0; sweep < 12; sweep++) {
|
|
510
|
+
for (let p = 0; p < P; p++) {
|
|
511
|
+
const col = cols[p]!;
|
|
512
|
+
const dcol = d2Cols[p]!;
|
|
513
|
+
const af = activeFrames[p]!;
|
|
514
|
+
const ad = activeD2[p]!;
|
|
515
|
+
const terms: Array<{ a: number; b: number; w: number }> = [];
|
|
516
|
+
for (let k = 0; k < ad.length; k++) {
|
|
517
|
+
const i = ad[k]!;
|
|
518
|
+
const targetD2 = refD2 !== undefined ? refD2[i]! : 0;
|
|
519
|
+
const curD2 = y[i + 2]! - 2 * y[i + 1]! + y[i]! - targetD2;
|
|
520
|
+
terms.push({ a: dcol[i]!, b: curD2, w: wSmooth / Math.max(1, n - 2) });
|
|
521
|
+
}
|
|
522
|
+
if (seamWeight > 0 && (Math.abs(col[0]!) > 1e-9 || Math.abs(col[n - 1]!) > 1e-9)) {
|
|
523
|
+
const aSeam = col[n - 1]! - col[0]!;
|
|
524
|
+
const bSeam = y[n - 1]! - y[0]! - seamDiff0;
|
|
525
|
+
terms.push({ a: aSeam, b: bSeam, w: seamWeight });
|
|
526
|
+
}
|
|
527
|
+
for (let k = 0; k < af.length; k++) {
|
|
528
|
+
const i = af[k]!;
|
|
529
|
+
const ci = col[i]!;
|
|
530
|
+
const mid = 0.5 * (lb[i]! + ub[i]!);
|
|
531
|
+
const half = Math.max(1e-6, 0.5 * (ub[i]! - lb[i]!));
|
|
532
|
+
const bMid = y[i]! - mid;
|
|
533
|
+
terms.push({ a: ci, b: bMid - half, w: pen * 0.5 });
|
|
534
|
+
terms.push({ a: ci, b: bMid + half, w: pen * 0.5 });
|
|
535
|
+
if (refY !== undefined) {
|
|
536
|
+
terms.push({ a: ci, b: y[i]! - refY[i]!, w: 1.0 / n });
|
|
537
|
+
} else {
|
|
538
|
+
terms.push({ a: ci, b: bMid, w: 1e-5 / n });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const dx = solveMinPWL(terms, -stepLimit, stepLimit);
|
|
542
|
+
if (Math.abs(dx) > 1e-9) {
|
|
543
|
+
x[p]! += dx;
|
|
544
|
+
for (let k = 0; k < af.length; k++) y[af[k]!]! += col[af[k]!]! * dx;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const outKeys: ReducedKey[] = keys.map((k, j) => {
|
|
550
|
+
const v0 = r3(x[j]!);
|
|
551
|
+
if (j + 1 < K) {
|
|
552
|
+
const cy1 = r3(x[K + j]!);
|
|
553
|
+
const cy2 = r3(x[K + spans + j]!);
|
|
554
|
+
if (k.curve === undefined) {
|
|
555
|
+
const v1 = r3(x[j + 1]!);
|
|
556
|
+
const lin1 = r3((2 * v0 + v1) / 3);
|
|
557
|
+
const lin2 = r3((v0 + 2 * v1) / 3);
|
|
558
|
+
if (Math.abs(cy1 - lin1) <= 0.001 && Math.abs(cy2 - lin2) <= 0.001) return { t: k.t, v: [v0] };
|
|
559
|
+
}
|
|
560
|
+
return { t: k.t, v: [v0], curve: [hx1s[j]!, cy1, hx2s[j]!, cy2] };
|
|
561
|
+
}
|
|
562
|
+
return { t: k.t, v: [v0] };
|
|
563
|
+
});
|
|
564
|
+
return { keys: outKeys, y: evalTrack1D(outKeys, samples) };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function msdDeg(arr: readonly number[]): number {
|
|
568
|
+
if (arr.length < 3) return 0;
|
|
569
|
+
let s = 0;
|
|
570
|
+
for (let i = 1; i < arr.length - 1; i++) s += Math.abs(wrap180(arr[i + 1]! - arr[i]!) - wrap180(arr[i]! - arr[i - 1]!));
|
|
571
|
+
return s / (arr.length - 2);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function d2Of(arr: readonly number[]): number[] {
|
|
575
|
+
const res: number[] = [];
|
|
576
|
+
for (let i = 0; i < arr.length - 2; i++) res.push(arr[i + 2]! - 2 * arr[i + 1]! + arr[i]!);
|
|
577
|
+
return res;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function solveLimbPreservingJitter(
|
|
581
|
+
basis: TrackBasis,
|
|
582
|
+
yOld: readonly number[],
|
|
583
|
+
lb: readonly number[],
|
|
584
|
+
ub: readonly number[],
|
|
585
|
+
): { keys: ReducedKey[]; y: number[] } {
|
|
586
|
+
const jOld = msdDeg(yOld);
|
|
587
|
+
const baseD2 = d2Of(yOld);
|
|
588
|
+
let scale = 1.0;
|
|
589
|
+
let sm = optimizeBasis(basis, lb, ub, 4.0, yOld, baseD2);
|
|
590
|
+
for (let sIter = 0; sIter < 2; sIter++) {
|
|
591
|
+
const jNew = msdDeg(sm.y);
|
|
592
|
+
if (Math.abs(jNew - jOld) <= 0.02 || jNew < 1e-6) break;
|
|
593
|
+
scale *= jOld / jNew;
|
|
594
|
+
sm = optimizeBasis(basis, lb, ub, 4.0, yOld, baseD2.map((d) => d * scale));
|
|
595
|
+
}
|
|
596
|
+
return sm;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Smooths the reduced trunk and head rotate tracks within the stated world-aim
|
|
601
|
+
* tolerance (#116): fitTrack's per-span least-squares handles leave C0 slope
|
|
602
|
+
* breaks at knots pinned to the raw frame values, which across a parent→child
|
|
603
|
+
* trunk chain (`chest` → `head`) can amplify local second-difference jitter
|
|
604
|
+
* above the sequence's own. Optimizing knot values and handle y-coordinates
|
|
605
|
+
* jointly within the ±tolerance world-aim tube removes those C0 breaks while
|
|
606
|
+
* keeping any limb chains hanging from the trunk within 0.04 of their landing
|
|
607
|
+
* and jitter.
|
|
608
|
+
*/
|
|
609
|
+
export function smoothTrunk(
|
|
610
|
+
times: readonly number[],
|
|
611
|
+
bones: readonly ReduceBone[],
|
|
612
|
+
tolerance: number,
|
|
613
|
+
out: Reduced,
|
|
614
|
+
trunkBones: ReadonlySet<string>,
|
|
615
|
+
): void {
|
|
616
|
+
const n = times.length;
|
|
617
|
+
if (n < 4 || trunkBones.size === 0) return;
|
|
618
|
+
const samples = samplesOf(times);
|
|
619
|
+
const byName = new Map(bones.map((b) => [b.bone, b]));
|
|
620
|
+
const roots = [...trunkBones].filter((b) => {
|
|
621
|
+
const info = byName.get(b);
|
|
622
|
+
return info !== undefined && (info.above === null || !trunkBones.has(info.above));
|
|
623
|
+
});
|
|
624
|
+
const tolInner = 0.985 * tolerance;
|
|
625
|
+
|
|
626
|
+
for (const root of roots) {
|
|
627
|
+
const rootKeys = out.rotate.get(root);
|
|
628
|
+
if (rootKeys === undefined || rootKeys.length <= 2) continue;
|
|
629
|
+
const headBones = [...trunkBones].filter((b) => byName.get(b)?.above === root && (out.rotate.get(b)?.length ?? 0) > 2);
|
|
630
|
+
if (headBones.length === 0) continue;
|
|
631
|
+
const arm0Bones = bones.filter((b) => !trunkBones.has(b.bone) && b.above === root && (out.rotate.get(b.bone)?.length ?? 0) > 2).map((b) => b.bone);
|
|
632
|
+
const arm0Set = new Set(arm0Bones);
|
|
633
|
+
const arm1Bones = bones.filter((b) => !trunkBones.has(b.bone) && b.above !== null && arm0Set.has(b.above) && (out.rotate.get(b.bone)?.length ?? 0) > 2).map((b) => b.bone);
|
|
634
|
+
const allManaged = [root, ...headBones, ...arm0Bones, ...arm1Bones];
|
|
635
|
+
|
|
636
|
+
const basisOf = new Map<string, TrackBasis>();
|
|
637
|
+
const yOldOf = new Map<string, number[]>();
|
|
638
|
+
for (const b of allManaged) {
|
|
639
|
+
const k = out.rotate.get(b)!;
|
|
640
|
+
basisOf.set(b, buildBasis(k, samples));
|
|
641
|
+
yOldOf.set(b, evalTrack1D(k, samples));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const rootInfo = byName.get(root)!;
|
|
645
|
+
const eAboveRoot = rootInfo.above === null ? new Array<number>(n).fill(0) : out.errors.get(rootInfo.above)!;
|
|
646
|
+
const targetRoot = rootInfo.dense.map((d, i) => d - eAboveRoot[i]!);
|
|
647
|
+
const yRootOld = yOldOf.get(root)!;
|
|
648
|
+
|
|
649
|
+
const rootLb = targetRoot.map((t) => t - tolInner);
|
|
650
|
+
const rootUb = targetRoot.map((t) => t + tolInner);
|
|
651
|
+
let smRoot = optimizeBasis(basisOf.get(root)!, rootLb, rootUb);
|
|
652
|
+
let eRoot = smRoot.y.map((v, i) => v - targetRoot[i]!);
|
|
653
|
+
|
|
654
|
+
const smSub = new Map<string, { keys: ReducedKey[]; y: number[] }>();
|
|
655
|
+
const eSub = new Map<string, number[]>();
|
|
656
|
+
|
|
657
|
+
const tightenAround = (i: number, excess: number) => {
|
|
658
|
+
for (let k = Math.max(0, i - 2); k <= Math.min(n - 1, i + 2); k++) {
|
|
659
|
+
const w = k === i ? 1.0 : 0.7;
|
|
660
|
+
if (smRoot.y[k]! > yRootOld[k]!) rootUb[k] = Math.min(rootUb[k]!, Math.max(yRootOld[k]!, smRoot.y[k]! - excess * w));
|
|
661
|
+
else rootLb[k] = Math.max(rootLb[k]!, Math.min(yRootOld[k]!, smRoot.y[k]! + excess * w));
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const solveChildren = () => {
|
|
666
|
+
eSub.set(root, eRoot);
|
|
667
|
+
for (const h of headBones) {
|
|
668
|
+
const dH = byName.get(h)!.dense;
|
|
669
|
+
const targetH = dH.map((d, i) => d - eRoot[i]!);
|
|
670
|
+
const smH = optimizeBasis(basisOf.get(h)!, targetH.map((t) => t - tolInner), targetH.map((t) => t + tolInner));
|
|
671
|
+
smSub.set(h, smH);
|
|
672
|
+
eSub.set(h, smH.y.map((v, i) => v - targetH[i]!));
|
|
673
|
+
}
|
|
674
|
+
for (const b0 of arm0Bones) {
|
|
675
|
+
const yOld = yOldOf.get(b0)!;
|
|
676
|
+
const wOld = Math.max(...out.errors.get(b0)!.map(Math.abs));
|
|
677
|
+
const cap = Math.min(0.98 * tolerance, wOld + 0.035 * tolerance);
|
|
678
|
+
const targetB = byName.get(b0)!.dense.map((d, i) => d - eRoot[i]!);
|
|
679
|
+
const smB = solveLimbPreservingJitter(basisOf.get(b0)!, yOld, targetB.map((t) => t - cap), targetB.map((t) => t + cap));
|
|
680
|
+
smSub.set(b0, smB);
|
|
681
|
+
eSub.set(b0, smB.y.map((v, i) => v - targetB[i]!));
|
|
682
|
+
}
|
|
683
|
+
for (const b1 of arm1Bones) {
|
|
684
|
+
const up = byName.get(b1)!.above!;
|
|
685
|
+
const eUp = eSub.get(up)!;
|
|
686
|
+
const yOld = yOldOf.get(b1)!;
|
|
687
|
+
const wOld = Math.max(...out.errors.get(b1)!.map(Math.abs));
|
|
688
|
+
const cap = Math.min(0.98 * tolerance, wOld + 0.035 * tolerance);
|
|
689
|
+
const targetB = byName.get(b1)!.dense.map((d, i) => d - eUp[i]!);
|
|
690
|
+
const smB = solveLimbPreservingJitter(basisOf.get(b1)!, yOld, targetB.map((t) => t - cap), targetB.map((t) => t + cap));
|
|
691
|
+
smSub.set(b1, smB);
|
|
692
|
+
eSub.set(b1, smB.y.map((v, i) => v - targetB[i]!));
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
for (let round = 0; round < 5; round++) {
|
|
697
|
+
solveChildren();
|
|
698
|
+
let anyTightened = false;
|
|
699
|
+
for (let i = 0; i < n; i++) {
|
|
700
|
+
for (const h of headBones) {
|
|
701
|
+
const eh = eSub.get(h)![i]!;
|
|
702
|
+
if (Math.abs(eh) > 0.99 * tolerance) {
|
|
703
|
+
tightenAround(i, Math.abs(eh) - 0.975 * tolerance);
|
|
704
|
+
anyTightened = true;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
for (const b of [...arm0Bones, ...arm1Bones]) {
|
|
708
|
+
const wOld = Math.max(...out.errors.get(b)!.map(Math.abs));
|
|
709
|
+
const cap = Math.min(0.985 * tolerance, wOld + 0.04 * tolerance);
|
|
710
|
+
const eb = eSub.get(b)![i]!;
|
|
711
|
+
if (Math.abs(eb) > cap) {
|
|
712
|
+
tightenAround(i, Math.abs(eb) - (cap - 0.01 * tolerance));
|
|
713
|
+
anyTightened = true;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (!anyTightened) break;
|
|
718
|
+
smRoot = optimizeBasis(basisOf.get(root)!, rootLb, rootUb);
|
|
719
|
+
eRoot = smRoot.y.map((v, i) => v - targetRoot[i]!);
|
|
720
|
+
if (round === 4) solveChildren();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const updateBone = (b: string, keys: ReducedKey[], e: number[]) => {
|
|
724
|
+
out.rotate.set(b, keys);
|
|
725
|
+
out.errors.set(b, e);
|
|
726
|
+
const worst = worstOf(e.map((_, i) => i), (i) => Math.abs(e[i]!))!;
|
|
727
|
+
const rowIdx = out.rows.findIndex((r) => r.kind === 'angle' && r.bone === b);
|
|
728
|
+
if (rowIdx >= 0) {
|
|
729
|
+
out.rows[rowIdx] = { bone: b, kind: 'angle', before: n, after: keys.length, worst: worst.worst, frame: worst.frame };
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
updateBone(root, smRoot.keys, eRoot);
|
|
734
|
+
for (const b of [...headBones, ...arm0Bones, ...arm1Bones]) {
|
|
735
|
+
updateBone(b, smSub.get(b)!.keys, eSub.get(b)!);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
|
|
382
741
|
/** The times the poser samples a track at: the running sum of the frame steps (verify.ts `poseFrames`). */
|
|
383
742
|
export function samplesOf(times: readonly number[]): number[] {
|
|
384
743
|
const samples: number[] = [times[0]!];
|
package/src/retarget.ts
CHANGED
|
@@ -82,7 +82,7 @@ import { Problems, Refusal } from './problems.ts';
|
|
|
82
82
|
import type { ArtRange, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
|
|
83
83
|
import { clampArt, coveredChains, isRaised, measureArt, overflow, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
|
|
84
84
|
import type { BlendHold, Closure, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
|
|
85
|
-
import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
|
|
85
|
+
import { figureScaleOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
|
|
86
86
|
import type { FaceReading } from './face.ts';
|
|
87
87
|
import { readFace } from './face.ts';
|
|
88
88
|
|
|
@@ -326,6 +326,26 @@ export interface Retarget {
|
|
|
326
326
|
byHip: ByHip[];
|
|
327
327
|
/** With "face" in the map (#138): per-frame yaw/pitch reading and range-compressed mapping. */
|
|
328
328
|
face: FaceReading | null;
|
|
329
|
+
/** Continuous per-frame figure scale relative to frame 0 (`#163`, `#161`). */
|
|
330
|
+
figureScale: FigureScaleReading;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface FigureScaleFrame {
|
|
334
|
+
frame: number;
|
|
335
|
+
t: number;
|
|
336
|
+
/** Continuous rate-limited figure scale relative to frame 0 (`s[0] = 1`, step ≤ 1%/frame, held on frozen spans). */
|
|
337
|
+
s: number;
|
|
338
|
+
/** Raw standing-height ratio relative to frame 0 (`raw[i] / raw[0]`). */
|
|
339
|
+
rawS: number;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface FigureScaleReading {
|
|
343
|
+
/** Largest frame-to-frame relative change `|s[i] - s[i-1]| / s[i-1]` and the frame `i` where it occurs. */
|
|
344
|
+
maxStep: { step: number; frame: number; t: number };
|
|
345
|
+
/** Ending scale over frame 0 (`s[n-1] / s[0]`). */
|
|
346
|
+
endOverStart: number;
|
|
347
|
+
/** Per-frame continuous scale `s` and raw scale `rawS`. */
|
|
348
|
+
frames: FigureScaleFrame[];
|
|
329
349
|
}
|
|
330
350
|
|
|
331
351
|
/** The hip's pivot against the thigh roots, at setup (rig-c/pose#47). */
|
|
@@ -687,20 +707,60 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
687
707
|
else if (plantOn) p.add('LEGS_MAP', `--plant-feet reads both hips and both ankles from the pose; the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`);
|
|
688
708
|
}
|
|
689
709
|
}
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
710
|
+
const heldPt = (name: string): Pt[] | null => {
|
|
711
|
+
const known = series.get(name);
|
|
712
|
+
if (known !== undefined) return known;
|
|
713
|
+
const h = heldOf(name);
|
|
714
|
+
return 'never' in h ? null : h.at;
|
|
715
|
+
};
|
|
716
|
+
const rawFigureHeight: number[] = (() => {
|
|
717
|
+
const ar = heldPt('r_ankle');
|
|
718
|
+
const al = heldPt('l_ankle');
|
|
719
|
+
const top = heldPt('nose') ?? heldPt('neck');
|
|
720
|
+
if (ar !== null && al !== null && top !== null) {
|
|
721
|
+
const h = ar.map((r, i) => Math.max(r[1], al[i]![1]) - top[i]![1]);
|
|
722
|
+
if (h.every((v) => v > 1e-6)) return h;
|
|
723
|
+
}
|
|
724
|
+
const hr = heldPt('r_hip');
|
|
725
|
+
const hl = heldPt('l_hip');
|
|
726
|
+
if (ar !== null && al !== null && hr !== null && hl !== null) {
|
|
727
|
+
const h = ar.map((r, i) => Math.max(Math.hypot(r[0] - hr[i]![0], r[1] - hr[i]![1]), Math.hypot(al[i]![0] - hl[i]![0], al[i]![1] - hl[i]![1])));
|
|
728
|
+
if (h.every((v) => v > 1e-6)) return h;
|
|
729
|
+
}
|
|
730
|
+
if (t !== null) {
|
|
731
|
+
const [pa, pb] = t.scale.pose;
|
|
732
|
+
const A = series.get(pa)!;
|
|
733
|
+
const B = series.get(pb)!;
|
|
734
|
+
const h = A.map((a, i) => Math.hypot(B[i]![0] - a[0], B[i]![1] - a[1]));
|
|
735
|
+
if (h.every((v) => v > 1e-6)) return h;
|
|
736
|
+
}
|
|
737
|
+
return new Array<number>(n).fill(1);
|
|
738
|
+
})();
|
|
739
|
+
const continuousScale = figureScaleOf(rawFigureHeight);
|
|
740
|
+
const figureScale: FigureScaleReading = (() => {
|
|
741
|
+
const raw0 = rawFigureHeight[0] ?? 1;
|
|
742
|
+
let maxStep = 0;
|
|
743
|
+
let stepFrame = 0;
|
|
744
|
+
for (let i = 1; i < n; i++) {
|
|
745
|
+
const prev = continuousScale[i - 1]!;
|
|
746
|
+
const st = prev > 1e-9 ? Math.abs(continuousScale[i]! - prev) / prev : 0;
|
|
747
|
+
if (st > maxStep) {
|
|
748
|
+
maxStep = st;
|
|
749
|
+
stepFrame = i;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return {
|
|
753
|
+
maxStep: { step: r6(maxStep), frame: stepFrame, t: keyTimes[stepFrame] ?? 0 },
|
|
754
|
+
endOverStart: r6(continuousScale[n - 1] ?? 1),
|
|
755
|
+
frames: continuousScale.map((s, i) => ({
|
|
756
|
+
frame: i,
|
|
757
|
+
t: keyTimes[i]!,
|
|
758
|
+
s: r6(s),
|
|
759
|
+
rawS: r6(raw0 > 1e-9 ? rawFigureHeight[i]! / raw0 : 1),
|
|
760
|
+
})),
|
|
761
|
+
};
|
|
762
|
+
})();
|
|
763
|
+
const poseScale: number[] | null = t !== null && scale !== null && n > 25 && !inPlace ? continuousScale : null;
|
|
704
764
|
if (legScaleOn) {
|
|
705
765
|
const limits = opts.legScale as [number, number];
|
|
706
766
|
const rawSegments: Array<{ chain: string; bone: string; joints: [string, string]; kind: string; lengths: number[]; ownStraight: number }> = [];
|
|
@@ -727,6 +787,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
727
787
|
const straightByKind = new Map<string, number>();
|
|
728
788
|
for (const seg of rawSegments) straightByKind.set(seg.kind, Math.max(straightByKind.get(seg.kind) ?? 0, seg.ownStraight));
|
|
729
789
|
if (!inPlace && n > 25) {
|
|
790
|
+
for (const seg of rawSegments) {
|
|
791
|
+
seg.lengths = seg.lengths.map((l, i) => (continuousScale[i]! > 1e-6 ? l / continuousScale[i]! : l));
|
|
792
|
+
seg.ownStraight = percentile(seg.lengths, STRAIGHT_PERCENTILE);
|
|
793
|
+
}
|
|
794
|
+
straightByKind.clear();
|
|
795
|
+
for (const seg of rawSegments) straightByKind.set(seg.kind, Math.max(straightByKind.get(seg.kind) ?? 0, seg.ownStraight));
|
|
730
796
|
const byChain = new Map<string, typeof rawSegments>();
|
|
731
797
|
for (const seg of rawSegments) {
|
|
732
798
|
const list = byChain.get(seg.chain) ?? [];
|
|
@@ -734,30 +800,15 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
734
800
|
byChain.set(seg.chain, list);
|
|
735
801
|
}
|
|
736
802
|
const chainTotals = [...byChain.values()].filter((list) => list.length >= 2).map((list) => list[0]!.lengths.map((_, i) => list.reduce((s, seg) => s + seg.lengths[i]!, 0)));
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
if (chainTotals.length > 0 && straightByKind.size >= 2) {
|
|
747
|
-
let sumKindStraight = 0;
|
|
748
|
-
for (const kind of straightByKind.keys()) {
|
|
749
|
-
let ks = 0;
|
|
750
|
-
for (const s of rawSegments) if (s.kind === kind) ks = Math.max(ks, percentile(rawBySeg.get(s)!.slice(lo, hi), STRAIGHT_PERCENTILE));
|
|
751
|
-
sumKindStraight += ks;
|
|
752
|
-
}
|
|
753
|
-
let maxChainTotal = 0;
|
|
754
|
-
for (const tot of chainTotals) maxChainTotal = Math.max(maxChainTotal, percentile(tot.slice(lo, hi), STRAIGHT_PERCENTILE));
|
|
755
|
-
if (sumKindStraight > maxChainTotal && maxChainTotal > 1e-6) {
|
|
756
|
-
localStraight *= maxChainTotal / sumKindStraight;
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
return localStraight > 1e-6 ? l * (globalStraight / localStraight) : l;
|
|
760
|
-
});
|
|
803
|
+
if (chainTotals.length > 0 && straightByKind.size >= 2) {
|
|
804
|
+
let sumKindStraight = 0;
|
|
805
|
+
for (const ks of straightByKind.values()) sumKindStraight += ks;
|
|
806
|
+
let maxChainTotal = 0;
|
|
807
|
+
for (const tot of chainTotals) maxChainTotal = Math.max(maxChainTotal, percentile(tot, STRAIGHT_PERCENTILE));
|
|
808
|
+
if (sumKindStraight > maxChainTotal && maxChainTotal > 1e-6) {
|
|
809
|
+
const k = maxChainTotal / sumKindStraight;
|
|
810
|
+
for (const [kind, ks] of straightByKind) straightByKind.set(kind, ks * k);
|
|
811
|
+
}
|
|
761
812
|
}
|
|
762
813
|
}
|
|
763
814
|
for (const seg of rawSegments) {
|
|
@@ -838,7 +889,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
838
889
|
// (stanceOf's mechanism, its bounds) so its ankle stays on the floor. Before --stance, whose
|
|
839
890
|
// floor is that supporting ankle. #95: the lower bound on a frame is the leg's own range there.
|
|
840
891
|
const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
|
|
841
|
-
const lower = stanceRangeOf(ankleSeries, { r: series.get('r_hip')!, l: series.get('l_hip')! }, inPlace);
|
|
892
|
+
const lower = stanceRangeOf(ankleSeries, { r: series.get('r_hip')!, l: series.get('l_hip')! }, inPlace, continuousScale);
|
|
842
893
|
blendHold = holdBlend(planted.blends, n, posedFeet, lower);
|
|
843
894
|
const beforeBlendX = blendHold.stretch.map((b, i) => (b === null ? 0 : posedFeet(i).ankle[b.side][0]));
|
|
844
895
|
stretchChain(blendHold.stretch);
|
|
@@ -1389,7 +1440,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1389
1440
|
animation: { duration: keyTimes[n - 1]!, tracks },
|
|
1390
1441
|
times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
|
|
1391
1442
|
switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
|
|
1392
|
-
legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face,
|
|
1443
|
+
legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face, figureScale,
|
|
1393
1444
|
};
|
|
1394
1445
|
}
|
|
1395
1446
|
|
package/src/run.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, rea
|
|
|
17
17
|
import { artRangeLine, chainsMoving, raisedLine, RAISED_WITHIN, reachLine, switchLine, uncoveredFolds } from './switch.ts';
|
|
18
18
|
import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
|
|
19
19
|
import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
|
|
20
|
-
import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
|
|
20
|
+
import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, smoothTrunk, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
|
|
21
21
|
import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance, SupportScaleRun } from './legs.ts';
|
|
22
22
|
import { faceLine } from './face.ts';
|
|
23
23
|
|
|
@@ -41,6 +41,8 @@ export interface RetargetArgs extends RetargetOptions {
|
|
|
41
41
|
render?: string;
|
|
42
42
|
/** Write the run's measurements as JSON here. */
|
|
43
43
|
report?: string;
|
|
44
|
+
/** Write the sequence's per-frame relative figure scale and pan as a sidecar JSON here (#161). */
|
|
45
|
+
camera?: string;
|
|
44
46
|
/** The rig's animation read as its idle (default: idle); naming one the motion spec lacks is refused. */
|
|
45
47
|
idle?: string;
|
|
46
48
|
/** Loop the idle's dropped tracks into the new animation (#43 item 2). */
|
|
@@ -72,7 +74,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
72
74
|
if (path !== undefined && !existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
|
|
73
75
|
}
|
|
74
76
|
// A file this run writes goes into a directory that is there already; --build and --render make theirs.
|
|
75
|
-
for (const [flag, path] of [['--out', a.out], ['--report', a.report]] as const) {
|
|
77
|
+
for (const [flag, path] of [['--out', a.out], ['--report', a.report], ['--camera', a.camera]] as const) {
|
|
76
78
|
if (path === undefined) continue;
|
|
77
79
|
const dir = dirname(path);
|
|
78
80
|
if (!existsSync(dir)) args.add('ARG_PATH', `${flag} ${path} — directory ${dir} does not exist`);
|
|
@@ -292,6 +294,35 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
292
294
|
if (!kept.green) refuse([{ code: 'RIG_GATE', text: `rigc build into --build ${a.build} is red where the same build was green in scratch (exit ${kept.run.status}); nothing written` }, ...kept.fails.map((x) => ({ code: 'RIGC', text: x }))]);
|
|
293
295
|
}
|
|
294
296
|
writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
|
|
297
|
+
if (a.camera !== undefined) {
|
|
298
|
+
const ss = r.figureScale.frames.map((x) => x.s);
|
|
299
|
+
const minS = ss.length === 0 ? 1 : Math.min(...ss);
|
|
300
|
+
const maxS = ss.length === 0 ? 1 : Math.max(...ss);
|
|
301
|
+
writeFileSync(a.camera, `${JSON.stringify({
|
|
302
|
+
spec: 'rig-pose-camera/1',
|
|
303
|
+
rig: input.name,
|
|
304
|
+
animation: a.name,
|
|
305
|
+
frames: r.figureScale.frames.map((f, i) => {
|
|
306
|
+
const cam = seq.camera?.[i];
|
|
307
|
+
const camS = cam?.s ?? 1;
|
|
308
|
+
return {
|
|
309
|
+
frame: f.frame,
|
|
310
|
+
t: f.t,
|
|
311
|
+
s: f.s,
|
|
312
|
+
rawS: f.rawS,
|
|
313
|
+
tx: cam?.tx ?? 0,
|
|
314
|
+
ty: cam?.ty ?? 0,
|
|
315
|
+
videoS: Number((f.s * camS).toFixed(6)),
|
|
316
|
+
};
|
|
317
|
+
}),
|
|
318
|
+
summary: {
|
|
319
|
+
minS,
|
|
320
|
+
maxS,
|
|
321
|
+
maxStep: r.figureScale.maxStep,
|
|
322
|
+
endOverStart: r.figureScale.endOverStart,
|
|
323
|
+
},
|
|
324
|
+
}, null, 2)}\n`);
|
|
325
|
+
}
|
|
295
326
|
if (a.render !== undefined && f.meshes !== null && f.frames !== '') {
|
|
296
327
|
rmSync(a.render, { recursive: true, force: true });
|
|
297
328
|
cpSync(f.frames, a.render, { recursive: true });
|
|
@@ -330,8 +361,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
330
361
|
face: {
|
|
331
362
|
ears: r.face.ears,
|
|
332
363
|
read: { yaw: r.face.read.yaw, pitch: r.face.read.pitch },
|
|
333
|
-
...(r.face.yaw === undefined ? {} : { yaw: { bone: r.face.yaw.bone, limits: r.face.yaw.limits, read: r.face.yaw.read, mapped: r.face.yaw.mapped, scale: r.face.yaw.scale, corr: r.face.yaw.corr } }),
|
|
334
|
-
...(r.face.pitch === undefined ? {} : { pitch: { bone: r.face.pitch.bone, limits: r.face.pitch.limits, read: r.face.pitch.read, mapped: r.face.pitch.mapped, scale: r.face.pitch.scale, corr: r.face.pitch.corr } }),
|
|
364
|
+
...(r.face.yaw === undefined ? {} : { yaw: { bone: r.face.yaw.bone, limits: r.face.yaw.limits, knee: r.face.yaw.knee, read: r.face.yaw.read, mapped: r.face.yaw.mapped, scale: r.face.yaw.scale, corr: r.face.yaw.corr } }),
|
|
365
|
+
...(r.face.pitch === undefined ? {} : { pitch: { bone: r.face.pitch.bone, limits: r.face.pitch.limits, knee: r.face.pitch.knee, read: r.face.pitch.read, mapped: r.face.pitch.mapped, scale: r.face.pitch.scale, corr: r.face.pitch.corr } }),
|
|
335
366
|
},
|
|
336
367
|
}),
|
|
337
368
|
legScale: r.legScale.map(({ scale: _scale, asked: _asked, ...rest }) => rest),
|
|
@@ -345,6 +376,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
345
376
|
reduce: reduced === null ? null : { tolerance: a.reduce, hipTolerance: a.reduceHip ?? null, rows: reduced.rows },
|
|
346
377
|
}, null, 2)}\n`);
|
|
347
378
|
}
|
|
379
|
+
if (a.camera !== undefined) say(` wrote ${a.camera} — camera track (${r.figureScale.frames.length} frames)`);
|
|
348
380
|
say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
|
|
349
381
|
return { lines, written: true };
|
|
350
382
|
} finally {
|
|
@@ -412,6 +444,15 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
|
|
|
412
444
|
if (hipTrack !== undefined && out.hip !== null) hipTrack.keys = out.hip;
|
|
413
445
|
if (scalex.size === 0) return out;
|
|
414
446
|
|
|
447
|
+
// #116: smooth the trunk-and-head chain within the stated tolerance while keeping limb landings and jitter within 0.04.
|
|
448
|
+
const trunkBones = new Set(
|
|
449
|
+
map.chains
|
|
450
|
+
.filter((c) => c.bones.length === 1 && !(c.joints.includes('r_hip') && c.joints.includes('l_hip')))
|
|
451
|
+
.map((c) => c.bones[0]!),
|
|
452
|
+
);
|
|
453
|
+
smoothTrunk(r.times, bones, tolerance, out, trunkBones);
|
|
454
|
+
for (const [bone, keys] of out.rotate) rotate.get(bone)!.keys = keys;
|
|
455
|
+
|
|
415
456
|
// #94: the scales of the bones the key formula holds for, then the bones under a scaled bone, parents first.
|
|
416
457
|
const rows = new Map<string, ReduceRow>();
|
|
417
458
|
for (const row of out.rows) if (row.kind === 'angle') rows.set(`rotate ${row.bone}`, row);
|
package/src/switch.ts
CHANGED
|
@@ -498,10 +498,9 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
498
498
|
for (let i = sw.frames[0]; i <= sw.frames[1]; i++) row[i] = o;
|
|
499
499
|
}
|
|
500
500
|
}
|
|
501
|
-
// #136:
|
|
502
|
-
// behind
|
|
503
|
-
//
|
|
504
|
-
// and across a joint behind-to-behind transition where all chains move from one behind set to another.
|
|
501
|
+
// #136/#139/#162: between two sets that both draw an arm behind the torso, a frame that belongs to
|
|
502
|
+
// neither set stays behind (the nearer set's layer). The default front slot is for a frame no set can
|
|
503
|
+
// claim and whose arm is read in front — not a fallback.
|
|
505
504
|
const chainHolds: Array<{ set: string; chain: string; frames: [number, number] }> = [];
|
|
506
505
|
for (const group of groups) {
|
|
507
506
|
const layer = groupLayering(group, map, slots);
|
|
@@ -578,6 +577,56 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
578
577
|
return false;
|
|
579
578
|
});
|
|
580
579
|
if (!frontNear) for (const cn of chains) for (let i = g0; i <= g1; i++) cPick.get(cn)![i] = set0;
|
|
580
|
+
} else {
|
|
581
|
+
for (const cn of chains) {
|
|
582
|
+
const arr = cPick.get(cn)!;
|
|
583
|
+
const frontSets = group.filter((a) => layer.isFront(a) && a.chains.includes(cn));
|
|
584
|
+
const d0s = byChain.get(set0.name)?.get(cn);
|
|
585
|
+
const d1s = byChain.get(set1.name)?.get(cn);
|
|
586
|
+
for (let i = g0; i <= g1; i++) {
|
|
587
|
+
if (arr[i] !== null) continue;
|
|
588
|
+
const claimedFront = frontSets.some((f) => (byChain.get(f.name)?.get(cn)?.[i] ?? Infinity) <= opts.switchTolerance);
|
|
589
|
+
if (!claimedFront) {
|
|
590
|
+
const d0 = d0s?.[i] ?? Infinity;
|
|
591
|
+
const d1 = d1s?.[i] ?? Infinity;
|
|
592
|
+
arr[i] = d0 <= d1 ? set0 : set1;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
for (let i = g0; i <= g1;) {
|
|
596
|
+
const a = arr[i];
|
|
597
|
+
let j = i;
|
|
598
|
+
while (j + 1 <= g1 && arr[j + 1] === a) j++;
|
|
599
|
+
const before = arr[i - 1] ?? null;
|
|
600
|
+
const after = arr[j + 1] ?? null;
|
|
601
|
+
const span = times[j + 1]! - times[i]!;
|
|
602
|
+
if (span < opts.switchMin - 1e-9) {
|
|
603
|
+
if (a === null && before !== null && after !== null) {
|
|
604
|
+
for (let k = i; k <= j; k++) {
|
|
605
|
+
const d0 = d0s?.[k] ?? Infinity;
|
|
606
|
+
const d1 = d1s?.[k] ?? Infinity;
|
|
607
|
+
arr[k] = d0 <= d1 ? set0 : set1;
|
|
608
|
+
}
|
|
609
|
+
} else if (before !== null && before === after && a !== before) {
|
|
610
|
+
for (let k = i; k <= j; k++) arr[k] = before;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
i = j + 1;
|
|
614
|
+
}
|
|
615
|
+
for (let i = g0; i <= g1;) {
|
|
616
|
+
const a = arr[i];
|
|
617
|
+
let j = i;
|
|
618
|
+
while (j + 1 <= g1 && arr[j + 1] === a) j++;
|
|
619
|
+
const before = arr[i - 1] ?? null;
|
|
620
|
+
const after = arr[j + 1] ?? null;
|
|
621
|
+
if (before !== null && before === after && a !== before && times[j + 1]! - times[i]! < opts.switchMin - 1e-9) {
|
|
622
|
+
for (let k = i; k <= j; k++) arr[k] = before;
|
|
623
|
+
} else if (a !== null && i > g0 && j < g1 && times[j + 1]! - times[i]! < opts.switchMin - 1e-9) {
|
|
624
|
+
const fill = before ?? after ?? null;
|
|
625
|
+
for (let k = i; k <= j; k++) arr[k] = fill;
|
|
626
|
+
}
|
|
627
|
+
i = j + 1;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
581
630
|
}
|
|
582
631
|
for (const s of layer.touched) {
|
|
583
632
|
if (!shown.has(s)) shown.set(s, Array.from({ length: n }, () => slots.get(s)!.setup));
|