@rig-c/pose 0.22.1 → 0.23.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/README.md +7 -5
- package/package.json +1 -1
- package/src/face.ts +295 -2
- package/src/map.ts +165 -14
- package/src/retarget.ts +223 -15
- package/src/rigc.ts +29 -2
- package/src/run.ts +34 -5
- package/src/verify.ts +23 -2
package/README.md
CHANGED
|
@@ -114,7 +114,7 @@ Nothing is written until every check passes:
|
|
|
114
114
|
| Check | Fails with |
|
|
115
115
|
| --- | --- |
|
|
116
116
|
| rig-c's gate is green with the rig as it is, and again with the animation in (`rigc build`) | `RIG_GATE_BASE`, `RIG_GATE`, then rigc's own `FAIL` lines |
|
|
117
|
-
| every mapped bone **lands** on the angle the pose asks for, on every frame — the gated build is posed by rig-c's poser and measured (0.01° / 0.01 units); with `--leg-scale`, every scaled bone's x axis on its asked scale (0.0001 of its setup length), and with either leg flag each ankle where the keys put it (0.01 units + 0.0001 × its setup distance from the hip) | `POSE_LANDS`: the bone, the worst error, the frame |
|
|
117
|
+
| every mapped bone **lands** on the angle the pose asks for, on every frame — the gated build is posed by rig-c's poser and measured (0.01° / 0.01 units); with `--leg-scale`, every scaled bone's x axis (and the trunk's y axis when trunk foreshortening is keyed, [#172](https://github.com/rig-c/pose/issues/172)) on its asked scale (0.0001 of its setup length), and with either leg flag each ankle where the keys put it (0.01 units + 0.0001 × its setup distance from the hip) | `POSE_LANDS`: the bone, the worst error, the frame |
|
|
118
118
|
| elbows and knees bend only the way they can: at every joint inside a chain, the inner angle stays on the side of the rest pose's | `POSE_BEND_ONE_WAY`: the chain, the joint, the frame, the angle |
|
|
119
119
|
| every point the map needs is good on at least one frame | `POSE_POINT_NEVER_GOOD` |
|
|
120
120
|
| the map resolves against the rig: bones exist, chains descend, aims descend | `MAP_*`, naming both sides |
|
|
@@ -143,10 +143,11 @@ and what the run cannot decide for you it lists:
|
|
|
143
143
|
- `SHEAR` — with `--leg-scale`: every scaled bone and every bone under one, how far its art is sheared and how long its world axes are against setup. A reading.
|
|
144
144
|
- `IDLE`, `IDLE-DROPPED`, `IDLE-KEPT-OUT`, `MERGE` — the rig's idle against the new animation, and what `--carry-idle` and `--merge` put back ([below](#the-idle-what-the-retarget-drops)).
|
|
145
145
|
- `REDUCE` — with `--reduce` ([below](#few-keys---reduce)): per bone, its keys before and after and its largest error, with the frame; then the total.
|
|
146
|
-
- `FACE` — with `face` in the map ([#138](https://github.com/rig-c/pose/issues/138)): per-frame yaw and pitch read from `nose` against the eye midpoint (`r_eye + l_eye`) and, when present, the ear midpoint (`r_ear + l_ear`), de-rolled by the eye line, then compressed into each configured axis's `[min, max]` range (`scale` and correlation `r`), and written as `face` in `--report`.
|
|
146
|
+
- `FACE` — with `face` in the map ([#138](https://github.com/rig-c/pose/issues/138), [#173](https://github.com/rig-c/pose/issues/173)): per-frame yaw and pitch read from `nose` against the eye midpoint (`r_eye + l_eye`) and, when present, the ear midpoint (`r_ear + l_ear`), de-rolled by the eye line, then compressed into each configured axis's `[min, max]` range (`scale` and correlation `r`), and, when `face.yaw.sets` (or `face.sets`) is declared, every yaw-switched set's entered spans, swap count, and any `hides` slot keyed by another track, written as `face` in `--report`.
|
|
147
|
+
- `BODY` — per-frame body yaw read from the camera-normalised shoulder width (`r_shoulder + l_shoulder`) and hip width (`r_hip + l_hip`) projected perpendicular to the trunk against the sequence's standing width (`yaw ≈ acos(w / w_standing)`, [#173](https://github.com/rig-c/pose/issues/173)): its overall and opening (`frames 0–47`) range, every span with `|yaw| ≥ 45°`, and, when `body.yaw` or `body.sets` is declared in the map, its knee-mapped dial keys and every yaw-switched set's entered spans, swap count, and any `hides` slot keyed by another track, written as `body` in `--report`.
|
|
147
148
|
- `TURN` — a line across the body (hips, shoulders, eyes) shorter in the picture than 0.5 of its median length, or reversed, for at least 0.15 s: the body or the head turned. A reading of the pose, made whether or not the map uses the line; it changes no key. The demand for a turned-body drawing, as `END-ON` is for a foreshortened limb.
|
|
148
149
|
|
|
149
|
-
`--report <file>` writes all of it as JSON (`rig-pose-report/1`): `spec`, `rigc` (`{ version, entry }`: the rig-c that gated it), `rig`, `animation`, `frames`, `duration`, `cleaned` (the sequence's `cleaned` record, [above](#clean-first-rig-pose-clean)), `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale`, `plantFeet`, `stance`, `floor` and `raisedByHead` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate` (rig-c's gate summary, a string), `landings`, `posture`, `folds`, `legScale`, `pivot`, `feet` (`planted`, `legLength`, `supports`, `jumps`, `steps`, `changes`, `lowered`, `floor`, `shifts`, `blend`, `closure`, and the posed `reading`; a jump is an inclusive `[first, last]` pair of frames; `lowered` holds `{ frames, was, now, cause? }` runs (`cause`, where `now` is `null`: `released` or `higher`), present only with `--plant-feet` on a sequence not declared in place ([#113](https://github.com/rig-c/pose/issues/113)); `floor` is the world y every support span was planted on and `shifts` holds one `{ side, frames, shift }` per support span in world units, `null` and empty without `--plant-feet`; `blend` is `{ bounds, runs: [{ side, frames, k: { min, max } }], over: [{ side, frames, needed, left }] }`, the supporting leg stretched on the blend frames, `null` without `--plant-feet`; `closure` is `{ drift, frames }`, the hip's x drift closed over an in-place loop, `null` unless the sequence is `"in-place"` and `--plant-feet` is given), `stance` (`tolerance`, `release`, `minSeconds`, `bounds`, `runs`: `{ state, side, frames, maxHeight, maxDepth?, k, absorbed? }` (`state` is `both`, `lifted` or `below`; `maxDepth`, on a `below` run, is the free ankle's largest distance below the supporting one in leg lengths), `over`: `{ side, frames, needed, left }`; `null` without `--stance`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), `reduce` (the tolerances and each row of `REDUCE`, [below](#few-keys---reduce)), and the demand lists. The fields a run is graded by:
|
|
150
|
+
`--report <file>` writes all of it as JSON (`rig-pose-report/1`): `spec`, `rigc` (`{ version, entry }`: the rig-c that gated it), `rig`, `animation`, `frames`, `duration`, `cleaned` (the sequence's `cleaned` record, [above](#clean-first-rig-pose-clean)), `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale`, `plantFeet`, `stance`, `floor` and `raisedByHead` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate` (rig-c's gate summary, a string), `landings`, `posture`, `folds`, `body` (`standing`, `read`, `over45`, `framesOver45`, and with `body.yaw` or `body.sets` in the map, `yaw` and `sets`), `legScale`, `pivot`, `feet` (`planted`, `legLength`, `supports`, `jumps`, `steps`, `changes`, `lowered`, `floor`, `shifts`, `blend`, `closure`, and the posed `reading`; a jump is an inclusive `[first, last]` pair of frames; `lowered` holds `{ frames, was, now, cause? }` runs (`cause`, where `now` is `null`: `released` or `higher`), present only with `--plant-feet` on a sequence not declared in place ([#113](https://github.com/rig-c/pose/issues/113)); `floor` is the world y every support span was planted on and `shifts` holds one `{ side, frames, shift }` per support span in world units, `null` and empty without `--plant-feet`; `blend` is `{ bounds, runs: [{ side, frames, k: { min, max } }], over: [{ side, frames, needed, left }] }`, the supporting leg stretched on the blend frames, `null` without `--plant-feet`; `closure` is `{ drift, frames }`, the hip's x drift closed over an in-place loop, `null` unless the sequence is `"in-place"` and `--plant-feet` is given), `stance` (`tolerance`, `release`, `minSeconds`, `bounds`, `runs`: `{ state, side, frames, maxHeight, maxDepth?, k, absorbed? }` (`state` is `both`, `lifted` or `below`; `maxDepth`, on a `below` run, is the free ankle's largest distance below the supporting one in leg lengths), `over`: `{ side, frames, needed, left }`; `null` without `--stance`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), `reduce` (the tolerances and each row of `REDUCE`, [below](#few-keys---reduce)), and the demand lists. The fields a run is graded by:
|
|
150
151
|
|
|
151
152
|
| field | each entry |
|
|
152
153
|
| --- | --- |
|
|
@@ -196,7 +197,7 @@ Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. O
|
|
|
196
197
|
|
|
197
198
|
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
199
|
|
|
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`
|
|
200
|
+
**`--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` derived from posture-invariant measures — `max(r_thigh + r_shin, l_thigh + l_shin)`, shoulder width and eye width, never the trunk — with frame-to-frame step ≤ 1 % and frozen spans ≥ 5 frames held at their median, so neither camera zoom nor a forward trunk bend reads as the camera moving, 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), [#172](https://github.com/rig-c/pose/issues/172)), clamped to [min, max]. When the camera-normalised trunk (`neck → mid_hip` or `mid_shoulder → mid_hip`) foreshortens or extends by more than 4 % from its standing length, the trunk bone is keyed in `scaley` (for a `+y`-aimed trunk, `scalex` for `+x`) clamped to the same `[min, max]` bounds, carrying the trunk and head's depth bend while keeping the aimed rotation ([#172](https://github.com/rig-c/pose/issues/172)). 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
201
|
|
|
201
202
|
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
203
|
|
|
@@ -314,7 +315,8 @@ Which bones carry which joints. You write it for your rig:
|
|
|
314
315
|
⚠️ **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.
|
|
315
316
|
- `mid_hip` and `mid_shoulder` are the midpoints of the two hips and the two shoulders.
|
|
316
317
|
- `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.
|
|
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`).
|
|
318
|
+
- `face` (optional, [#138](https://github.com/rig-c/pose/issues/138), [#164](https://github.com/rig-c/pose/issues/164), [#173](https://github.com/rig-c/pose/issues/173)): `{ "yaw"?: { "bone": "yaw", "range": [-15, 15], "sets"?: [{ "set": "T1", "above": 45 }] }, "pitch"?: { "bone": "pitch", "range": [-10, 15] }, "sets"?: [{ "set": "T1", "above": 45 }] }` (`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`). When `sets` (`[{ "set": "<name>", "above": <deg> }]`, `0 < above ≤ 90`) is declared on `face.yaw.sets` or `face.sets`, each set enters on frames where `|yaw| ≥ above` (or the highest `above` satisfied when multiple sets are declared) and leaves only when `|yaw| < above − 10°` (`10°` hysteresis), emitting stepped slot `attachment` keys for the set's `slots` and `hides` (resolved from `alternates` in the map or `sets.json` (`rig-parts-sets/1`) beside `rig.json`; an unknown set is refused by name as `MAP_SET_EXISTS`).
|
|
319
|
+
- `body` (optional, [#173](https://github.com/rig-c/pose/issues/173)): `{ "yaw"?: { "bone": "body_yaw", "range": [-30, 30], "sets"?: [{ "set": "T8", "above": 45 }] }, "sets"?: [{ "set": "T8", "above": 45 }] }` (`min ≤ 0 ≤ max`, `min < max`). Maps the per-frame body yaw read from the camera-normalised shoulder and hip width (`BODY`) into `range` with the same `0.8 × range` knee mapping as `face`, keying the named dial bone's `rotate` track, and switches any declared `sets` (`body.sets` or `body.yaw.sets`) with the same `above` / `above − 10°` hysteresis (overriding `face` sets and arm `alternates` on slots they share while active). Without `body` in the map, `BODY` and `--report`'s `body` still report the reading while leaving every track in `motion.json` unchanged.
|
|
318
320
|
|
|
319
321
|
### A joint's range: `range`
|
|
320
322
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/pose",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and map each configured axis into its declared range by compression (never
|
|
6
6
|
* clipping, so motion changes at the extremes survive).
|
|
7
7
|
*/
|
|
8
|
-
import type { FaceAxis, FaceMap } from './map.ts';
|
|
8
|
+
import type { BodyMap, FaceAxis, FaceMap, YawSet } from './map.ts';
|
|
9
9
|
|
|
10
10
|
type Pt = [number, number];
|
|
11
11
|
|
|
@@ -18,6 +18,21 @@ const DEG = 180 / Math.PI;
|
|
|
18
18
|
*/
|
|
19
19
|
export const FACE_KNEE = 0.8;
|
|
20
20
|
|
|
21
|
+
/** Hysteresis in degrees for leaving a yaw-selected set (`leave = above - 10°`, `rig-c/pose#173`). */
|
|
22
|
+
export const SET_HYSTERESIS_DEG = 10;
|
|
23
|
+
|
|
24
|
+
export interface YawSetSwitch {
|
|
25
|
+
set: string;
|
|
26
|
+
above: number;
|
|
27
|
+
leave: number;
|
|
28
|
+
/** Inclusive `[first, last]` frame spans where the set is shown. */
|
|
29
|
+
spans: Array<[number, number]>;
|
|
30
|
+
/** Total number of attachment transitions (enter + leave boundaries inside the clip). */
|
|
31
|
+
swaps: number;
|
|
32
|
+
/** Any `hides` slot of this set that is also keyed by another track, with the track that keys it. */
|
|
33
|
+
hidesKeyed: Array<{ slot: string; by: string }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
export interface FaceSpan {
|
|
22
37
|
min: number;
|
|
23
38
|
max: number;
|
|
@@ -51,6 +66,7 @@ export interface FaceReading {
|
|
|
51
66
|
};
|
|
52
67
|
yaw?: FaceAxisReading;
|
|
53
68
|
pitch?: FaceAxisReading;
|
|
69
|
+
sets?: YawSetSwitch[];
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
const r3 = (v: number): number => {
|
|
@@ -246,6 +262,26 @@ export function readFace(
|
|
|
246
262
|
const f1 = (x: number): string => x.toFixed(1);
|
|
247
263
|
const sg = (x: number): string => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
|
|
248
264
|
|
|
265
|
+
function formatYawSets(sets: readonly YawSetSwitch[] | undefined, raw: readonly number[]): string {
|
|
266
|
+
if (sets === undefined || sets.length === 0) return '';
|
|
267
|
+
const parts = sets.map((s) => {
|
|
268
|
+
const spanStr = s.spans.length === 0
|
|
269
|
+
? 'none (0 swaps)'
|
|
270
|
+
: `${s.spans.map(([lo, hi]) => {
|
|
271
|
+
const slice = raw.slice(lo, hi + 1);
|
|
272
|
+
const sMin = Math.min(...slice);
|
|
273
|
+
const sMax = Math.max(...slice);
|
|
274
|
+
const where = lo === hi ? `frame ${lo}` : `frames ${lo}–${hi}`;
|
|
275
|
+
return `${where} (${lo === hi ? `${sg(sMax)}°` : `${sg(sMin)}° … ${sg(sMax)}°`})`;
|
|
276
|
+
}).join(', ')} (${s.swaps} swap${s.swaps === 1 ? '' : 's'})`;
|
|
277
|
+
const hidesStr = s.hidesKeyed.length === 0
|
|
278
|
+
? ''
|
|
279
|
+
: `; hides keyed by another track: ${s.hidesKeyed.map((h) => `slot "${h.slot}" (${h.by})`).join(', ')}`;
|
|
280
|
+
return `set "${s.set}" (≥ ${s.above}°, leave < ${s.leave}°): ${spanStr}${hidesStr}`;
|
|
281
|
+
});
|
|
282
|
+
return `; sets ${parts.join('; ')}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
249
285
|
export function faceLine(f: FaceReading): string {
|
|
250
286
|
const joints = f.ears ? 'nose against r_eye + l_eye, r_ear + l_ear' : 'nose against r_eye + l_eye (ears absent)';
|
|
251
287
|
const readPart = `read yaw ${sg(f.read.yaw.min)}° … ${sg(f.read.yaw.max)}° (${f1(f.read.yaw.range)}°), pitch ${sg(f.read.pitch.min)}° … ${sg(f.read.pitch.max)}° (${f1(f.read.pitch.range)}°)`;
|
|
@@ -255,5 +291,262 @@ export function faceLine(f: FaceReading): string {
|
|
|
255
291
|
if (ax === undefined) continue;
|
|
256
292
|
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)})`);
|
|
257
293
|
}
|
|
258
|
-
|
|
294
|
+
const mappedPart = axes.length === 0 ? '' : `; mapped ${axes.join(', ')}`;
|
|
295
|
+
return `${joints}: ${readPart}${mappedPart}${formatYawSets(f.sets, f.read.rawYaw)}`;
|
|
259
296
|
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Select yaw-switched sets (`rig-c/pose#173` Part 2):
|
|
300
|
+
* - A set is entered when `raw[i] >= above` for at least `switchMin` seconds,
|
|
301
|
+
* and left when `raw[i] < above - SET_HYSTERESIS_DEG` (`above - 10°`) for at
|
|
302
|
+
* least `switchMin` seconds.
|
|
303
|
+
* - Between two frames of the same set separated by `< switchMin`, nothing flips (`#162`).
|
|
304
|
+
*/
|
|
305
|
+
export function selectYawSets(
|
|
306
|
+
raw: readonly number[],
|
|
307
|
+
declared: readonly YawSet[] | undefined,
|
|
308
|
+
times: readonly number[],
|
|
309
|
+
switchMin: number,
|
|
310
|
+
): YawSetSwitch[] {
|
|
311
|
+
if (declared === undefined || declared.length === 0 || raw.length === 0) return [];
|
|
312
|
+
const n = raw.length;
|
|
313
|
+
const endOf = (i: number): number => (i + 1 < n ? times[i + 1]! : n > 1 ? 2 * times[i]! - times[i - 1]! : times[i]!);
|
|
314
|
+
const spanSec = (lo: number, hi: number): number => endOf(hi) - times[lo]!;
|
|
315
|
+
|
|
316
|
+
return declared.map((decl) => {
|
|
317
|
+
const above = decl.above;
|
|
318
|
+
const leave = r3(above - SET_HYSTERESIS_DEG);
|
|
319
|
+
const active = new Array<boolean>(n).fill(false);
|
|
320
|
+
let inSet = false;
|
|
321
|
+
let i = 0;
|
|
322
|
+
while (i < n) {
|
|
323
|
+
if (!inSet) {
|
|
324
|
+
if (raw[i]! >= above) {
|
|
325
|
+
let j = i;
|
|
326
|
+
while (j + 1 < n && raw[j + 1]! >= above) j++;
|
|
327
|
+
if (spanSec(i, j) >= switchMin - 1e-9) {
|
|
328
|
+
inSet = true;
|
|
329
|
+
for (let k = i; k <= j; k++) active[k] = true;
|
|
330
|
+
}
|
|
331
|
+
i = j + 1;
|
|
332
|
+
} else {
|
|
333
|
+
i++;
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
if (raw[i]! < leave) {
|
|
337
|
+
let j = i;
|
|
338
|
+
while (j + 1 < n && raw[j + 1]! < leave) j++;
|
|
339
|
+
if (spanSec(i, j) >= switchMin - 1e-9) {
|
|
340
|
+
inSet = false;
|
|
341
|
+
} else {
|
|
342
|
+
for (let k = i; k <= j; k++) active[k] = true;
|
|
343
|
+
}
|
|
344
|
+
i = j + 1;
|
|
345
|
+
} else {
|
|
346
|
+
active[i] = true;
|
|
347
|
+
i++;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// #162: bridge any remaining gap between two spans of the same set shorter than switchMin.
|
|
352
|
+
for (let k = 0; k < n;) {
|
|
353
|
+
if (active[k]) {
|
|
354
|
+
k++;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
let j = k;
|
|
358
|
+
while (j + 1 < n && !active[j + 1]) j++;
|
|
359
|
+
if (k > 0 && j + 1 < n && active[k - 1] && active[j + 1] && spanSec(k, j) < switchMin - 1e-9) {
|
|
360
|
+
for (let m = k; m <= j; m++) active[m] = true;
|
|
361
|
+
}
|
|
362
|
+
k = j + 1;
|
|
363
|
+
}
|
|
364
|
+
const spans: Array<[number, number]> = [];
|
|
365
|
+
for (let k = 0; k < n; k++) {
|
|
366
|
+
if (!active[k]) continue;
|
|
367
|
+
const last = spans[spans.length - 1];
|
|
368
|
+
if (last !== undefined && last[1] === k - 1) last[1] = k;
|
|
369
|
+
else spans.push([k, k]);
|
|
370
|
+
}
|
|
371
|
+
let swaps = 0;
|
|
372
|
+
for (const [lo, hi] of spans) {
|
|
373
|
+
swaps++;
|
|
374
|
+
if (hi < n - 1) swaps++;
|
|
375
|
+
}
|
|
376
|
+
return { set: decl.set, above, leave, spans, swaps, hidesKeyed: [] };
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Threshold in degrees at or above which a frame is listed on `BODY` as needing a turned-body drawing (`rig-c/pose#173`). */
|
|
381
|
+
export const BODY_TURN_DEG = 45;
|
|
382
|
+
|
|
383
|
+
export interface BodyReading {
|
|
384
|
+
/** Camera-normalised standing widths in picture pixels (at frame 0's scale). */
|
|
385
|
+
standing: {
|
|
386
|
+
shoulder: number;
|
|
387
|
+
hip: number;
|
|
388
|
+
};
|
|
389
|
+
read: {
|
|
390
|
+
yaw: FaceSpan;
|
|
391
|
+
opening: FaceSpan;
|
|
392
|
+
rawYaw: number[];
|
|
393
|
+
};
|
|
394
|
+
/** Inclusive `[first, last]` frame spans where `|yaw| >= BODY_TURN_DEG` (`45°`). */
|
|
395
|
+
over45: Array<[number, number]>;
|
|
396
|
+
/** Frame indices where `|yaw| >= BODY_TURN_DEG` (`45°`). */
|
|
397
|
+
framesOver45: number[];
|
|
398
|
+
/** Present when the map declares `body.yaw`. */
|
|
399
|
+
yaw?: FaceAxisReading;
|
|
400
|
+
/** Present when the map declares `body.sets` or `body.yaw.sets`. */
|
|
401
|
+
sets?: YawSetSwitch[];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Read per-frame body yaw (degrees) from camera-normalised shoulder width
|
|
406
|
+
* (`r_shoulder`, `l_shoulder`) and hip width (`r_hip`, `l_hip`), projected
|
|
407
|
+
* perpendicular to the trunk (`mid_hip → neck`) so raising one shoulder or
|
|
408
|
+
* tilting the torso in 2D roll does not distort the horizontal torso width,
|
|
409
|
+
* against the sequence's standing width (`yaw ≈ acos(w / w_standing)`,
|
|
410
|
+
* `rig-c/pose#173`).
|
|
411
|
+
*
|
|
412
|
+
* Sign convention (matching `readFaceAngles`):
|
|
413
|
+
* - `yaw > 0`: torso turned toward the subject's right (viewer-left, left shoulder nearer).
|
|
414
|
+
* - `yaw < 0`: torso turned toward the subject's left (viewer-right, right shoulder nearer).
|
|
415
|
+
*/
|
|
416
|
+
export function readBodyYaw(
|
|
417
|
+
rShoulder: readonly Pt[],
|
|
418
|
+
lShoulder: readonly Pt[],
|
|
419
|
+
rHip: readonly Pt[] | null,
|
|
420
|
+
lHip: readonly Pt[] | null,
|
|
421
|
+
neck: readonly Pt[] | null,
|
|
422
|
+
scale: readonly number[] | null,
|
|
423
|
+
faceYaw: readonly number[] | null,
|
|
424
|
+
): { standing: { shoulder: number; hip: number }; yaw: number[] } {
|
|
425
|
+
const n = rShoulder.length;
|
|
426
|
+
const hasHips = rHip !== null && lHip !== null && rHip.length === n && lHip.length === n;
|
|
427
|
+
const wSh: number[] = [];
|
|
428
|
+
const wHip: number[] = [];
|
|
429
|
+
const signs: number[] = [];
|
|
430
|
+
const revSh: boolean[] = [];
|
|
431
|
+
|
|
432
|
+
for (let i = 0; i < n; i++) {
|
|
433
|
+
const rS = rShoulder[i]!;
|
|
434
|
+
const lS = lShoulder[i]!;
|
|
435
|
+
const s = scale !== null && scale[i] !== undefined && scale[i]! > 1e-6 ? scale[i]! : 1;
|
|
436
|
+
const mS: Pt = [0.5 * (rS[0] + lS[0]), 0.5 * (rS[1] + lS[1])];
|
|
437
|
+
const mH: Pt = hasHips ? [0.5 * (rHip[i]![0] + lHip[i]![0]), 0.5 * (rHip[i]![1] + lHip[i]![1])] : [mS[0], mS[1] + 100];
|
|
438
|
+
const top: Pt = neck !== null && neck[i] !== undefined ? neck[i]! : mS;
|
|
439
|
+
const tx = top[0] - mH[0];
|
|
440
|
+
const ty = top[1] - mH[1];
|
|
441
|
+
const tl = Math.hypot(tx, ty);
|
|
442
|
+
const nx = tl > 1e-6 ? -ty / tl : 1;
|
|
443
|
+
const ny = tl > 1e-6 ? tx / tl : 0;
|
|
444
|
+
|
|
445
|
+
const projSh = ((lS[0] - rS[0]) * nx + (lS[1] - rS[1]) * ny) / s;
|
|
446
|
+
const absSh = Math.abs(projSh);
|
|
447
|
+
wSh.push(absSh);
|
|
448
|
+
revSh.push(projSh < -1e-6);
|
|
449
|
+
|
|
450
|
+
if (hasHips) {
|
|
451
|
+
const rH = rHip[i]!;
|
|
452
|
+
const lH = lHip[i]!;
|
|
453
|
+
const projHip = ((lH[0] - rH[0]) * nx + (lH[1] - rH[1]) * ny) / s;
|
|
454
|
+
wHip.push(Math.abs(projHip));
|
|
455
|
+
} else {
|
|
456
|
+
wHip.push(absSh);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const neckOff = neck !== null && neck[i] !== undefined ? ((mS[0] - neck[i]![0]) * nx + (mS[1] - neck[i]![1]) * ny) / s : 0;
|
|
460
|
+
const fy = faceYaw !== null && faceYaw[i] !== undefined ? faceYaw[i]! : 0;
|
|
461
|
+
let sign = 1;
|
|
462
|
+
if (Math.abs(neckOff) > 0.05 * Math.max(1, absSh)) sign = neckOff >= 0 ? 1 : -1;
|
|
463
|
+
else if (Math.abs(fy) > 0.5) sign = fy >= 0 ? 1 : -1;
|
|
464
|
+
else {
|
|
465
|
+
const shear = (mS[0] - mH[0]) * nx + (mS[1] - mH[1]) * ny;
|
|
466
|
+
if (Math.abs(shear) > 1e-6) sign = shear >= 0 ? 1 : -1;
|
|
467
|
+
else if (i > 0) sign = signs[i - 1]!;
|
|
468
|
+
}
|
|
469
|
+
signs.push(sign);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const openN = Math.min(n, 48);
|
|
473
|
+
const sh0 = Math.max(1e-6, Math.min(median(wSh.slice(0, openN)), median(wSh)));
|
|
474
|
+
const hip0 = Math.max(1e-6, Math.min(median(wHip.slice(0, openN)), median(wHip)));
|
|
475
|
+
|
|
476
|
+
const yaw: number[] = [];
|
|
477
|
+
for (let i = 0; i < n; i++) {
|
|
478
|
+
if (revSh[i]) {
|
|
479
|
+
yaw.push(signs[i]! * 90);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const rS = Math.min(1, wSh[i]! / sh0);
|
|
483
|
+
const rH = hasHips ? Math.min(1, wHip[i]! / hip0) : rS;
|
|
484
|
+
const mx = Math.min(Math.max(rS, rH), rS / 0.65);
|
|
485
|
+
const mn = Math.min(rS, rH);
|
|
486
|
+
const alpha = Math.max(0, Math.min(1, (mx - 0.85) / 0.1));
|
|
487
|
+
const w = Math.max(0, Math.min(1, alpha * Math.max(rS, rH) + (1 - alpha) * mn));
|
|
488
|
+
const mag = Math.acos(w) * DEG;
|
|
489
|
+
yaw.push(mag < 1e-6 ? 0 : signs[i]! * mag);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
standing: { shoulder: r3(sh0), hip: r3(hasHips ? hip0 : 0) },
|
|
494
|
+
yaw,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function readBody(
|
|
499
|
+
body: BodyMap | undefined,
|
|
500
|
+
rShoulder: readonly Pt[],
|
|
501
|
+
lShoulder: readonly Pt[],
|
|
502
|
+
rHip: readonly Pt[] | null,
|
|
503
|
+
lHip: readonly Pt[] | null,
|
|
504
|
+
neck: readonly Pt[] | null,
|
|
505
|
+
scale: readonly number[] | null,
|
|
506
|
+
faceYaw: readonly number[] | null,
|
|
507
|
+
): BodyReading {
|
|
508
|
+
const { standing, yaw } = readBodyYaw(rShoulder, lShoulder, rHip, lHip, neck, scale, faceYaw);
|
|
509
|
+
const openN = Math.min(yaw.length, 48);
|
|
510
|
+
const framesOver45: number[] = [];
|
|
511
|
+
const over45: Array<[number, number]> = [];
|
|
512
|
+
for (let i = 0; i < yaw.length; i++) {
|
|
513
|
+
if (Math.abs(yaw[i]!) >= BODY_TURN_DEG) {
|
|
514
|
+
framesOver45.push(i);
|
|
515
|
+
const last = over45[over45.length - 1];
|
|
516
|
+
if (last !== undefined && last[1] === i - 1) last[1] = i;
|
|
517
|
+
else over45.push([i, i]);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
standing,
|
|
522
|
+
read: {
|
|
523
|
+
yaw: spanOf(yaw),
|
|
524
|
+
opening: spanOf(yaw.slice(0, openN)),
|
|
525
|
+
rawYaw: yaw.map(r3),
|
|
526
|
+
},
|
|
527
|
+
over45,
|
|
528
|
+
framesOver45,
|
|
529
|
+
...(body?.yaw === undefined ? {} : { yaw: mapFaceAxis(yaw, body.yaw) }),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function bodyLine(b: BodyReading): string {
|
|
534
|
+
const openEnd = Math.min(b.read.rawYaw.length, 48) - 1;
|
|
535
|
+
const openAbs = Math.max(Math.abs(b.read.opening.min), Math.abs(b.read.opening.max));
|
|
536
|
+
const spans = b.over45.map(([lo, hi]) => {
|
|
537
|
+
const slice = b.read.rawYaw.slice(lo, hi + 1);
|
|
538
|
+
const sMin = Math.min(...slice);
|
|
539
|
+
const sMax = Math.max(...slice);
|
|
540
|
+
const where = lo === hi ? `frame ${lo}` : `frames ${lo}–${hi}`;
|
|
541
|
+
return `${where} (${lo === hi ? `${sg(sMax)}°` : `${sg(sMin)}° … ${sg(sMax)}°`})`;
|
|
542
|
+
});
|
|
543
|
+
const overPart = b.framesOver45.length === 0
|
|
544
|
+
? `≥ ${BODY_TURN_DEG}°: none`
|
|
545
|
+
: `≥ ${BODY_TURN_DEG}° on ${b.framesOver45.length} frame(s): ${spans.join(', ')}`;
|
|
546
|
+
const head = `r_shoulder + l_shoulder, r_hip + l_hip (standing shoulder ${b.standing.shoulder.toFixed(1)}px, hip ${b.standing.hip.toFixed(1)}px): read yaw ${sg(b.read.yaw.min)}° … ${sg(b.read.yaw.max)}° (${f1(b.read.yaw.range)}°), opening (frames 0–${openEnd}) ${sg(b.read.opening.min)}° … ${sg(b.read.opening.max)}° (max |yaw| ${f1(openAbs)}°); ${overPart}`;
|
|
547
|
+
const mappedPart = b.yaw === undefined
|
|
548
|
+
? ''
|
|
549
|
+
: `; mapped yaw → bone "${b.yaw.bone}" [${b.yaw.limits[0]}, ${b.yaw.limits[1]}]° (knee [${b.yaw.knee[0]}, ${b.yaw.knee[1]}]°): ${sg(b.yaw.mapped.min)}° … ${sg(b.yaw.mapped.max)}° (scale ${b.yaw.scale[0].toFixed(3)}/${b.yaw.scale[1].toFixed(3)}, r=${b.yaw.corr.toFixed(3)})`;
|
|
550
|
+
return `${head}${mappedPart}${formatYawSets(b.sets, b.read.rawYaw)}`;
|
|
551
|
+
}
|
|
552
|
+
|
package/src/map.ts
CHANGED
|
@@ -101,20 +101,38 @@ export interface AlternateSet {
|
|
|
101
101
|
/** `place: "by-hip"` (rig-c/pose#93): the chains whose wrist `pose` puts within one hip width of the mid-hip, their wrists placed by the offset from the hip while the set shows. Absent or null: the pose's angles. */
|
|
102
102
|
place?: { by: 'hip'; chains: string[] } | null;
|
|
103
103
|
}
|
|
104
|
+
export interface YawSet {
|
|
105
|
+
set: string;
|
|
106
|
+
above: number;
|
|
107
|
+
}
|
|
104
108
|
export interface FaceAxis {
|
|
105
109
|
bone: string;
|
|
106
110
|
range: [number, number];
|
|
111
|
+
sets?: YawSet[];
|
|
107
112
|
}
|
|
108
113
|
export interface FaceMap {
|
|
109
114
|
yaw?: FaceAxis;
|
|
110
115
|
pitch?: FaceAxis;
|
|
116
|
+
sets?: YawSet[];
|
|
117
|
+
}
|
|
118
|
+
export interface BodyMap {
|
|
119
|
+
yaw?: FaceAxis;
|
|
120
|
+
sets?: YawSet[];
|
|
111
121
|
}
|
|
112
122
|
export interface JointMap {
|
|
113
123
|
chains: Chain[];
|
|
114
124
|
translate: Translate | null;
|
|
115
125
|
alternates: AlternateSet[];
|
|
116
126
|
face?: FaceMap;
|
|
127
|
+
body?: BodyMap;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** An alternate attachment set from `sets.json` (`rig-parts-sets/1`, `rig-c/pose#173`). */
|
|
131
|
+
export interface RigSet {
|
|
132
|
+
slots: Record<string, string>;
|
|
133
|
+
hides: string[];
|
|
117
134
|
}
|
|
135
|
+
export type RigSets = ReadonlyMap<string, RigSet>;
|
|
118
136
|
|
|
119
137
|
/** What a slot of the built rig shows at setup, and every attachment any skin gives it. */
|
|
120
138
|
export interface RigSlot {
|
|
@@ -144,7 +162,7 @@ export function readMap(text: string, where: string): JointMap {
|
|
|
144
162
|
p.throwIfAny();
|
|
145
163
|
throw new Error('unreachable');
|
|
146
164
|
}
|
|
147
|
-
refuseUnknownKeys(p, 'MAP_KEYS', where, doc, ['spec', 'note', 'chains', 'translate', 'alternates', 'face']);
|
|
165
|
+
refuseUnknownKeys(p, 'MAP_KEYS', where, doc, ['spec', 'note', 'chains', 'translate', 'alternates', 'face', 'body']);
|
|
148
166
|
if (doc.spec !== MAP_SPEC) p.add('MAP_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${MAP_SPEC}"`);
|
|
149
167
|
const chains: Chain[] = [];
|
|
150
168
|
if (!Array.isArray(doc.chains) || doc.chains.length === 0) {
|
|
@@ -245,22 +263,60 @@ export function readMap(text: string, where: string): JointMap {
|
|
|
245
263
|
}
|
|
246
264
|
const alternates = 'alternates' in doc ? readAlternates(p, where, doc.alternates, chains, translate) : [];
|
|
247
265
|
const face = 'face' in doc ? readFaceMap(p, where, doc.face) : undefined;
|
|
266
|
+
const body = 'body' in doc ? readBodyMap(p, where, doc.body) : undefined;
|
|
248
267
|
p.throwIfAny();
|
|
249
|
-
return { chains, translate, alternates, ...(face === undefined ? {} : { face }) };
|
|
268
|
+
return { chains, translate, alternates, ...(face === undefined ? {} : { face }), ...(body === undefined ? {} : { body }) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function readYawSets(p: Problems, where: string, raw: unknown, shapeCode: string): YawSet[] | undefined {
|
|
272
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
273
|
+
p.add(shapeCode, `${where} is ${typeName(raw)}; it must be a non-empty array of { set, above }`);
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
const out: YawSet[] = [];
|
|
277
|
+
const seen = new Set<string>();
|
|
278
|
+
raw.forEach((entry: unknown, i: number) => {
|
|
279
|
+
const ew = `${where}[${i}]`;
|
|
280
|
+
if (!isObject(entry)) {
|
|
281
|
+
p.add(shapeCode, `${ew} is ${typeName(entry)}; it is { set, above }`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
refuseUnknownKeys(p, 'MAP_KEYS', ew, entry, ['set', 'above']);
|
|
285
|
+
const set = typeof entry.set === 'string' && entry.set !== '' ? entry.set : null;
|
|
286
|
+
if (set === null) p.add('MAP_SET_NAME', `${ew} "set" is ${typeName(entry.set)}; it must be a non-empty string`);
|
|
287
|
+
else if (seen.has(set)) p.add('MAP_SET_NAME', `${ew} "set" "${set}" names two entries`);
|
|
288
|
+
if (set !== null) seen.add(set);
|
|
289
|
+
const above = typeof entry.above === 'number' && Number.isFinite(entry.above) && entry.above > 0 ? entry.above : null;
|
|
290
|
+
if (above === null) p.add('MAP_SET_ABOVE', `${ew} "above" is ${JSON.stringify(entry.above)}; it must be a positive finite number of degrees`);
|
|
291
|
+
if (set !== null && above !== null) out.push({ set, above });
|
|
292
|
+
});
|
|
293
|
+
return out.length > 0 ? out : undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function faceYawSets(face: FaceMap | undefined): YawSet[] | undefined {
|
|
297
|
+
return face?.yaw?.sets ?? face?.sets;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function bodyYawSets(body: BodyMap | undefined): YawSet[] | undefined {
|
|
301
|
+
return body?.sets ?? body?.yaw?.sets;
|
|
250
302
|
}
|
|
251
303
|
|
|
252
304
|
function readFaceMap(p: Problems, where: string, raw: unknown): FaceMap | undefined {
|
|
253
305
|
const fw = `${where} "face"`;
|
|
254
306
|
if (!isObject(raw)) {
|
|
255
|
-
p.add('MAP_FACE_SHAPE', `${fw} is ${typeName(raw)}; it is { yaw?: { bone, range: [min, max] }, pitch?: { bone, range: [min, max] } }`);
|
|
307
|
+
p.add('MAP_FACE_SHAPE', `${fw} is ${typeName(raw)}; it is { yaw?: { bone, range: [min, max], sets? }, pitch?: { bone, range: [min, max] }, sets? }`);
|
|
256
308
|
return undefined;
|
|
257
309
|
}
|
|
258
|
-
refuseUnknownKeys(p, 'MAP_KEYS', fw, raw, ['yaw', 'pitch']);
|
|
259
|
-
if (!('yaw' in raw) && !('pitch' in raw)) {
|
|
310
|
+
refuseUnknownKeys(p, 'MAP_KEYS', fw, raw, ['yaw', 'pitch', 'sets']);
|
|
311
|
+
if (!('yaw' in raw) && !('pitch' in raw) && !('sets' in raw)) {
|
|
260
312
|
p.add('MAP_FACE_SHAPE', `${fw} names neither "yaw" nor "pitch"; it must name at least one axis`);
|
|
261
313
|
return undefined;
|
|
262
314
|
}
|
|
263
315
|
const out: FaceMap = {};
|
|
316
|
+
if ('sets' in raw) {
|
|
317
|
+
const sets = readYawSets(p, `${fw} "sets"`, raw.sets, 'MAP_FACE_SHAPE');
|
|
318
|
+
if (sets !== undefined) out.sets = sets;
|
|
319
|
+
}
|
|
264
320
|
for (const axis of ['yaw', 'pitch'] as const) {
|
|
265
321
|
if (!(axis in raw)) continue;
|
|
266
322
|
const aw = `${fw} "${axis}"`;
|
|
@@ -269,19 +325,67 @@ function readFaceMap(p: Problems, where: string, raw: unknown): FaceMap | undefi
|
|
|
269
325
|
p.add('MAP_FACE_SHAPE', `${aw} is ${typeName(ax)}; it is { bone, range: [min, max] }`);
|
|
270
326
|
continue;
|
|
271
327
|
}
|
|
272
|
-
refuseUnknownKeys(p, 'MAP_KEYS', aw, ax, ['bone', 'range']);
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
p.add('
|
|
278
|
-
|
|
279
|
-
|
|
328
|
+
refuseUnknownKeys(p, 'MAP_KEYS', aw, ax, axis === 'yaw' ? ['bone', 'range', 'sets'] : ['bone', 'range']);
|
|
329
|
+
const sets = axis === 'yaw' && 'sets' in ax ? readYawSets(p, `${aw} "sets"`, ax.sets, 'MAP_FACE_SHAPE') : undefined;
|
|
330
|
+
const hasBoneOrRange = 'bone' in ax || 'range' in ax || !('sets' in ax);
|
|
331
|
+
if (hasBoneOrRange) {
|
|
332
|
+
const bone = typeof ax.bone === 'string' && ax.bone !== '' ? ax.bone : null;
|
|
333
|
+
if (bone === null) p.add('MAP_FACE_BONE', `${aw} "bone" is ${typeName(ax.bone)}; it must name a bone`);
|
|
334
|
+
const v = ax.range;
|
|
335
|
+
if (!Array.isArray(v) || v.length !== 2 || !v.every((x) => typeof x === 'number' && Number.isFinite(x)) || (v[0] as number) > 0 || (v[1] as number) < 0 || (v[0] as number) >= (v[1] as number)) {
|
|
336
|
+
p.add('MAP_FACE_RANGE', `${aw} "range" is ${JSON.stringify(v)}; it must be [min, max], two finite numbers of degrees with min ≤ 0 ≤ max and min < max`);
|
|
337
|
+
} else if (bone !== null) {
|
|
338
|
+
out[axis] = { bone, range: [v[0] as number, v[1] as number], ...(sets === undefined ? {} : { sets }) };
|
|
339
|
+
}
|
|
340
|
+
} else if (sets !== undefined) {
|
|
341
|
+
out.sets = sets;
|
|
280
342
|
}
|
|
281
343
|
}
|
|
282
344
|
return out;
|
|
283
345
|
}
|
|
284
346
|
|
|
347
|
+
function readBodyMap(p: Problems, where: string, raw: unknown): BodyMap | undefined {
|
|
348
|
+
const bw = `${where} "body"`;
|
|
349
|
+
if (!isObject(raw)) {
|
|
350
|
+
p.add('MAP_BODY_SHAPE', `${bw} is ${typeName(raw)}; it is { yaw?: { bone, range: [min, max] }, sets?: [{ set, above }] }`);
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
refuseUnknownKeys(p, 'MAP_KEYS', bw, raw, ['yaw', 'sets']);
|
|
354
|
+
if (!('yaw' in raw) && !('sets' in raw)) {
|
|
355
|
+
p.add('MAP_BODY_SHAPE', `${bw} does not name "yaw" or "sets"; it must name at least one`);
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
const out: BodyMap = {};
|
|
359
|
+
if ('sets' in raw) {
|
|
360
|
+
const sets = readYawSets(p, `${bw} "sets"`, raw.sets, 'MAP_BODY_SHAPE');
|
|
361
|
+
if (sets !== undefined) out.sets = sets;
|
|
362
|
+
}
|
|
363
|
+
if ('yaw' in raw) {
|
|
364
|
+
const aw = `${bw} "yaw"`;
|
|
365
|
+
const ax = raw.yaw;
|
|
366
|
+
if (!isObject(ax)) {
|
|
367
|
+
p.add('MAP_BODY_SHAPE', `${aw} is ${typeName(ax)}; it is { bone, range: [min, max] }`);
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
refuseUnknownKeys(p, 'MAP_KEYS', aw, ax, ['bone', 'range', 'sets']);
|
|
371
|
+
const sets = 'sets' in ax ? readYawSets(p, `${aw} "sets"`, ax.sets, 'MAP_BODY_SHAPE') : undefined;
|
|
372
|
+
const hasBoneOrRange = 'bone' in ax || 'range' in ax || !('sets' in ax);
|
|
373
|
+
if (hasBoneOrRange) {
|
|
374
|
+
const bone = typeof ax.bone === 'string' && ax.bone !== '' ? ax.bone : null;
|
|
375
|
+
if (bone === null) p.add('MAP_BODY_BONE', `${aw} "bone" is ${typeName(ax.bone)}; it must name a bone`);
|
|
376
|
+
const v = ax.range;
|
|
377
|
+
if (!Array.isArray(v) || v.length !== 2 || !v.every((x) => typeof x === 'number' && Number.isFinite(x)) || (v[0] as number) > 0 || (v[1] as number) < 0 || (v[0] as number) >= (v[1] as number)) {
|
|
378
|
+
p.add('MAP_BODY_RANGE', `${aw} "range" is ${JSON.stringify(v)}; it must be [min, max], two finite numbers of degrees with min ≤ 0 ≤ max and min < max`);
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
if (bone !== null) out.yaw = { bone, range: [v[0] as number, v[1] as number], ...(sets === undefined ? {} : { sets }) };
|
|
382
|
+
} else if (sets !== undefined) {
|
|
383
|
+
out.sets = sets;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
285
389
|
const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …], place?: "by-hip" }';
|
|
286
390
|
|
|
287
391
|
/** The joints a wrist is, for `place: "by-hip"`: a chain whose last joint is one of them carries a wrist. */
|
|
@@ -535,8 +639,15 @@ export function rigPoint(rest: Rest, spec: string): [number, number] | null {
|
|
|
535
639
|
return pointAt(rest, new Map([...rest].map(([n, b]) => [n, b.length])), spec);
|
|
536
640
|
}
|
|
537
641
|
|
|
642
|
+
export function knownSetsOf(map: JointMap, rigSets: RigSets = new Map()): Map<string, RigSet> {
|
|
643
|
+
const out = new Map<string, RigSet>();
|
|
644
|
+
for (const [k, v] of rigSets) out.set(k, v);
|
|
645
|
+
for (const a of map.alternates) if (!out.has(a.name)) out.set(a.name, { slots: a.slots, hides: a.hides });
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
|
|
538
649
|
/** Check every name the map gives against the rig; every miss is named. */
|
|
539
|
-
export function resolveMap(map: JointMap, rest: Rest, where: string, slots: RigSlots = new Map()): void {
|
|
650
|
+
export function resolveMap(map: JointMap, rest: Rest, where: string, slots: RigSlots = new Map(), rigSets: RigSets = new Map()): void {
|
|
540
651
|
const p = new Problems();
|
|
541
652
|
const all = [...rest.keys()];
|
|
542
653
|
const listed = all.length <= 40 ? all.join(', ') : `${all.slice(0, 40).join(', ')} … (${all.length} in all)`;
|
|
@@ -619,6 +730,46 @@ export function resolveMap(map: JointMap, rest: Rest, where: string, slots: RigS
|
|
|
619
730
|
if (Math.hypot(o.a, o.c) < 1e-9) p.add('MAP_AIM_LENGTH', `${label} — bone "${ax.bone}" has a collapsed x axis at setup (scale 0)`);
|
|
620
731
|
}
|
|
621
732
|
}
|
|
733
|
+
if (map.body?.yaw !== undefined) {
|
|
734
|
+
const ax = map.body.yaw;
|
|
735
|
+
const label = '"body.yaw"';
|
|
736
|
+
if (!rest.has(ax.bone)) {
|
|
737
|
+
p.add('MAP_BONE_EXISTS', `${label} — bone "${ax.bone}" is not in the rig; its bones are ${listed}`);
|
|
738
|
+
} else {
|
|
739
|
+
const prev = owner.get(ax.bone);
|
|
740
|
+
if (prev !== undefined) p.add('MAP_BONE_ONCE', `bone "${ax.bone}" is in ${prev.startsWith('"') ? prev : `chain "${prev}"`} and in ${label}; a bone carries one rotate track`);
|
|
741
|
+
owner.set(ax.bone, label);
|
|
742
|
+
const o = rest.get(ax.bone)!;
|
|
743
|
+
if (Math.hypot(o.a, o.c) < 1e-9) p.add('MAP_AIM_LENGTH', `${label} — bone "${ax.bone}" has a collapsed x axis at setup (scale 0)`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const knownSets = knownSetsOf(map, rigSets);
|
|
747
|
+
const allSets = [...knownSets.keys()];
|
|
748
|
+
const setsListed = allSets.length === 0 ? 'none' : allSets.join(', ');
|
|
749
|
+
const allSlots = [...slots.keys()];
|
|
750
|
+
const slotsListed = allSlots.length === 0 ? 'none' : allSlots.length <= 40 ? allSlots.join(', ') : `${allSlots.slice(0, 40).join(', ')} … (${allSlots.length} in all)`;
|
|
751
|
+
for (const [label, declared] of [['"face.yaw.sets"', faceYawSets(map.face)], ['"body.sets"', bodyYawSets(map.body)]] as const) {
|
|
752
|
+
if (declared === undefined) continue;
|
|
753
|
+
for (const s of declared) {
|
|
754
|
+
const def = knownSets.get(s.set);
|
|
755
|
+
if (def === undefined) {
|
|
756
|
+
p.add('MAP_SET_EXISTS', `${label} — set "${s.set}" is not in the rig; its sets are ${setsListed}`);
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (slots.size === 0) continue;
|
|
760
|
+
for (const [slot, att] of Object.entries(def.slots)) {
|
|
761
|
+
const rs = slots.get(slot);
|
|
762
|
+
if (rs === undefined) p.add('MAP_ALT_SLOT', `set "${s.set}" — slot "${slot}" is not in the rig; its slots are ${slotsListed}`);
|
|
763
|
+
else if (!rs.attachments.has(att)) {
|
|
764
|
+
const has = [...rs.attachments];
|
|
765
|
+
p.add('MAP_ALT_ATTACHMENT', `set "${s.set}" — slot "${slot}" has no attachment "${att}" under any skin; it has ${has.length === 0 ? 'none' : has.join(', ')}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
for (const h of def.hides) {
|
|
769
|
+
if (!slots.has(h)) p.add('MAP_ALT_HIDES', `set "${s.set}" hides slot "${h}", which is not in the rig; its slots are ${slotsListed}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
622
773
|
resolveAlternates(p, map.alternates, slots);
|
|
623
774
|
if (p.any) p.list.unshift({ code: 'MAP_RESOLVES', text: `${where} does not resolve against the rig` });
|
|
624
775
|
p.throwIfAny();
|
package/src/retarget.ts
CHANGED
|
@@ -74,17 +74,17 @@
|
|
|
74
74
|
import { screenToSpineDegrees } from 'rig-c/transform';
|
|
75
75
|
import type { Body18 } from './joints.ts';
|
|
76
76
|
import { isBody18, VIRTUAL } from './joints.ts';
|
|
77
|
-
import type { Chain, JointMap, Rest, RigSlots } from './map.ts';
|
|
78
|
-
import { aimAngle, aimEnds, descends, partBone, pointParts, rigPoint } from './map.ts';
|
|
77
|
+
import type { Chain, JointMap, Rest, RigSets, RigSlots } from './map.ts';
|
|
78
|
+
import { aimAngle, aimEnds, bodyYawSets, descends, faceYawSets, knownSetsOf, partBone, pointParts, rigPoint } from './map.ts';
|
|
79
79
|
import type { Joint, Travel } from './pose.ts';
|
|
80
80
|
import { TRAVEL_IN_PLACE } from './pose.ts';
|
|
81
81
|
import { Problems, Refusal } from './problems.ts';
|
|
82
|
-
import type { ArtRange, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
|
|
82
|
+
import type { ArtRange, AttachmentKey, 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
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
|
-
import type { FaceReading } from './face.ts';
|
|
87
|
-
import { readFace } from './face.ts';
|
|
86
|
+
import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
|
|
87
|
+
import { readBody, readFace, readFaceAngles, selectYawSets } from './face.ts';
|
|
88
88
|
|
|
89
89
|
const DEG = 180 / Math.PI;
|
|
90
90
|
|
|
@@ -219,7 +219,7 @@ export interface MotionKey {
|
|
|
219
219
|
}
|
|
220
220
|
export interface MotionTrack {
|
|
221
221
|
bone: string;
|
|
222
|
-
property: 'rotate' | 'translate' | 'scalex';
|
|
222
|
+
property: 'rotate' | 'translate' | 'scalex' | 'scaley';
|
|
223
223
|
keys: MotionKey[];
|
|
224
224
|
}
|
|
225
225
|
export interface Animation {
|
|
@@ -326,6 +326,8 @@ 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
|
+
/** Per-frame body yaw read from camera-normalised shoulder and hip width (#173), and optional "body.yaw" dial mapping. */
|
|
330
|
+
body: BodyReading | null;
|
|
329
331
|
/** Continuous per-frame figure scale relative to frame 0 (`#163`, `#161`). */
|
|
330
332
|
figureScale: FigureScaleReading;
|
|
331
333
|
}
|
|
@@ -430,7 +432,7 @@ function pivotOf(rest: Rest, bone: string | null, legs: ReturnType<typeof legsOf
|
|
|
430
432
|
* good, when a hinge's side cannot be read off a straight rest, and — with
|
|
431
433
|
* `bends: 'refuse'` — when a hinge bends the wrong way.
|
|
432
434
|
*/
|
|
433
|
-
export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, times: readonly number[], opts: RetargetOptions, slots: RigSlots = new Map()): Retarget {
|
|
435
|
+
export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, times: readonly number[], opts: RetargetOptions, slots: RigSlots = new Map(), rigSets: RigSets = new Map()): Retarget {
|
|
434
436
|
const p = new Problems();
|
|
435
437
|
const n = frames.length;
|
|
436
438
|
const t0 = times[0]!;
|
|
@@ -462,6 +464,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
462
464
|
needed.add('r_eye');
|
|
463
465
|
needed.add('l_eye');
|
|
464
466
|
}
|
|
467
|
+
if (map.body !== undefined) {
|
|
468
|
+
needed.add('r_shoulder');
|
|
469
|
+
needed.add('l_shoulder');
|
|
470
|
+
}
|
|
465
471
|
const series = new Map<string, Pt[]>();
|
|
466
472
|
const good = new Map<string, boolean[]>();
|
|
467
473
|
const holds: Hold[] = [];
|
|
@@ -713,19 +719,76 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
713
719
|
const h = heldOf(name);
|
|
714
720
|
return 'never' in h ? null : h.at;
|
|
715
721
|
};
|
|
722
|
+
const winPercentile = (xs: readonly number[], w: number, q: number): number[] => {
|
|
723
|
+
const effW = Math.min(w, Math.floor((n - 1) / 2));
|
|
724
|
+
if (effW <= 0) return [...xs];
|
|
725
|
+
return xs.map((_, i) => {
|
|
726
|
+
const c = Math.max(effW, Math.min(n - 1 - effW, i));
|
|
727
|
+
return percentile(xs.slice(c - effW, c + effW + 1), q);
|
|
728
|
+
});
|
|
729
|
+
};
|
|
730
|
+
const dist2 = (a: Pt, b: Pt): number => Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
716
731
|
const rawFigureHeight: number[] = (() => {
|
|
717
732
|
const ar = heldPt('r_ankle');
|
|
718
733
|
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
734
|
const hr = heldPt('r_hip');
|
|
725
735
|
const hl = heldPt('l_hip');
|
|
726
736
|
if (ar !== null && al !== null && hr !== null && hl !== null) {
|
|
727
|
-
const
|
|
728
|
-
|
|
737
|
+
const kr = heldPt('r_knee');
|
|
738
|
+
const kl = heldPt('l_knee');
|
|
739
|
+
const legSum = ar.map((r, i) =>
|
|
740
|
+
kr !== null && kl !== null
|
|
741
|
+
? Math.max(dist2(hr[i]!, kr[i]!) + dist2(kr[i]!, r), dist2(hl[i]!, kl[i]!) + dist2(kl[i]!, al[i]!))
|
|
742
|
+
: Math.max(dist2(r, hr[i]!), dist2(al[i]!, hl[i]!)),
|
|
743
|
+
);
|
|
744
|
+
if (legSum.every((v) => v > 1e-6)) {
|
|
745
|
+
if (n > 160) {
|
|
746
|
+
const seriesList: number[][] = [winPercentile(legSum, 75, STRAIGHT_PERCENTILE)];
|
|
747
|
+
const rs = heldPt('r_shoulder');
|
|
748
|
+
const ls = heldPt('l_shoulder');
|
|
749
|
+
if (rs !== null && ls !== null) {
|
|
750
|
+
const shW = rs.map((r, i) => dist2(r, ls[i]!));
|
|
751
|
+
if (shW.every((v) => v > 1e-6)) seriesList.push(winPercentile(shW, 75, STRAIGHT_PERCENTILE));
|
|
752
|
+
}
|
|
753
|
+
const re = heldPt('r_eye');
|
|
754
|
+
const le = heldPt('l_eye');
|
|
755
|
+
if (re !== null && le !== null) {
|
|
756
|
+
const eyeW = re.map((r, i) => dist2(r, le[i]!));
|
|
757
|
+
if (eyeW.every((v) => v > 1e-6)) seriesList.push(winPercentile(eyeW, 75, STRAIGHT_PERCENTILE));
|
|
758
|
+
}
|
|
759
|
+
if (kr !== null && kl !== null) {
|
|
760
|
+
const thighMin = hr.map((r, i) => Math.min(dist2(r, kr[i]!), dist2(hl[i]!, kl[i]!)));
|
|
761
|
+
if (thighMin.every((v) => v > 1e-6)) seriesList.push(winPercentile(thighMin, 75, 0.5));
|
|
762
|
+
}
|
|
763
|
+
const rel = heldPt('r_elbow');
|
|
764
|
+
const lel = heldPt('l_elbow');
|
|
765
|
+
const rw = heldPt('r_wrist');
|
|
766
|
+
const lw = heldPt('l_wrist');
|
|
767
|
+
if (rs !== null && ls !== null && rel !== null && lel !== null && rw !== null && lw !== null) {
|
|
768
|
+
const armSum = rs.map((r, i) => Math.max(dist2(r, rel[i]!) + dist2(rel[i]!, rw[i]!), dist2(ls[i]!, lel[i]!) + dist2(lel[i]!, lw[i]!)));
|
|
769
|
+
if (armSum.every((v) => v > 1e-6)) seriesList.push(winPercentile(armSum, 75, 0.5));
|
|
770
|
+
}
|
|
771
|
+
if (seriesList.length >= 3) {
|
|
772
|
+
const norms = seriesList.map((s) => s.map((v) => v / s[0]!));
|
|
773
|
+
const cons = legSum.map((_, i) => {
|
|
774
|
+
let minR = Infinity;
|
|
775
|
+
let maxR = -Infinity;
|
|
776
|
+
for (const nr of norms) {
|
|
777
|
+
if (nr[i]! < minR) minR = nr[i]!;
|
|
778
|
+
if (nr[i]! > maxR) maxR = nr[i]!;
|
|
779
|
+
}
|
|
780
|
+
return minR > 1 ? minR : maxR < 1 ? maxR : 1;
|
|
781
|
+
});
|
|
782
|
+
if (cons.every((c) => Math.abs(c - 1) <= 0.02)) {
|
|
783
|
+
return cons.map((c) => c * legSum[0]!);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const out = winPercentile(legSum, 1, STRAIGHT_PERCENTILE);
|
|
787
|
+
out[0] = legSum[0]!;
|
|
788
|
+
return out;
|
|
789
|
+
}
|
|
790
|
+
return legSum;
|
|
791
|
+
}
|
|
729
792
|
}
|
|
730
793
|
if (t !== null) {
|
|
731
794
|
const [pa, pb] = t.scale.pose;
|
|
@@ -761,6 +824,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
761
824
|
};
|
|
762
825
|
})();
|
|
763
826
|
const poseScale: number[] | null = t !== null && scale !== null && n > 25 && !inPlace ? continuousScale : null;
|
|
827
|
+
let trunkScaleBone: string | null = null;
|
|
828
|
+
let trunkScale: number[] | null = null;
|
|
764
829
|
if (legScaleOn) {
|
|
765
830
|
const limits = opts.legScale as [number, number];
|
|
766
831
|
const rawSegments: Array<{ chain: string; bone: string; joints: [string, string]; kind: string; lengths: number[]; ownStraight: number }> = [];
|
|
@@ -817,6 +882,43 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
817
882
|
scales.set(seg.bone, ls.scale);
|
|
818
883
|
}
|
|
819
884
|
if (legScale.length === 0 && !p.any) p.add('LEGS_MAP', '--leg-scale scales the leg segments (two of one side\'s hip, knee and ankle), and the map aims none');
|
|
885
|
+
const isTrunkPair = (a: string, b: string): boolean => (a === 'mid_hip' && (b === 'neck' || b === 'mid_shoulder')) || (b === 'mid_hip' && (a === 'neck' || a === 'mid_shoulder'));
|
|
886
|
+
const trunkChain = map.chains.find((c) => c.bones.length === 1 && isTrunkPair(c.joints[0]!, c.joints[1]!));
|
|
887
|
+
if (!inPlace && trunkChain !== undefined && !p.any) {
|
|
888
|
+
const tb = trunkChain.bones[0]!;
|
|
889
|
+
const from = series.get(trunkChain.joints[0]!)!;
|
|
890
|
+
const to = series.get(trunkChain.joints[1]!)!;
|
|
891
|
+
const rawT = from.map((f, i) => dist2(f, to[i]!));
|
|
892
|
+
const frameScale = (i: number): number => (!inPlace && n > 25 && rawFigureHeight[0]! > 1e-6 && rawFigureHeight[i]! > 1e-6 ? rawFigureHeight[i]! / rawFigureHeight[0]! : 1);
|
|
893
|
+
const normT = rawT.map((l, i) => l / frameScale(i));
|
|
894
|
+
const standingT = median(normT);
|
|
895
|
+
if (standingT > 1e-6) {
|
|
896
|
+
const rawRatio = normT.map((l) => l / standingT);
|
|
897
|
+
const re = heldPt('r_eye');
|
|
898
|
+
const le = heldPt('l_eye');
|
|
899
|
+
let effRatio = rawRatio;
|
|
900
|
+
if (re !== null && le !== null) {
|
|
901
|
+
const mh = series.get('mid_hip') ?? from;
|
|
902
|
+
const rawEH = mh.map((h, i) => dist2(h, [(re[i]![0] + le[i]![0]) / 2, (re[i]![1] + le[i]![1]) / 2]));
|
|
903
|
+
const normEH = rawEH.map((l, i) => l / frameScale(i));
|
|
904
|
+
const standingEH = median(normEH);
|
|
905
|
+
if (standingEH > 1e-6) {
|
|
906
|
+
effRatio = rawRatio.map((r, i) => (r > 1 ? Math.min(r, Math.max(1, normEH[i]! / standingEH)) : r));
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
const minR = Math.max(0.5, Math.min(...effRatio));
|
|
910
|
+
const supFloor = Math.min(limits[0], minR);
|
|
911
|
+
const sy = effRatio.map((r, i) => {
|
|
912
|
+
const supported = plantOn && feet !== null && (feet.support[i] !== null || feet.tracked?.some((tr) => i >= tr.frames[0] && i <= tr.frames[1]) === true);
|
|
913
|
+
const lo = supported ? supFloor : limits[0];
|
|
914
|
+
return Math.min(Math.max(r, lo), limits[1]);
|
|
915
|
+
});
|
|
916
|
+
if (effRatio.some((v) => Math.abs(v - 1) > 0.04) && sy.some((v) => Math.abs(r6(v) - 1) > 0)) {
|
|
917
|
+
trunkScaleBone = tb;
|
|
918
|
+
trunkScale = sy;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
820
922
|
}
|
|
821
923
|
if (opts.stance !== undefined && opts.stance !== 'off' && !plantOn) p.add('STANCE', `--stance ${opts.stance} plants the free foot beside the supporting one that --plant-feet holds, and --plant-feet is not given`);
|
|
822
924
|
if (opts.floor !== undefined && !plantOn) p.add('FLOOR', `--floor ${opts.floor} is the floor --plant-feet plants every support span on, and --plant-feet is not given`);
|
|
@@ -1424,6 +1526,109 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1424
1526
|
targets.set(ax.bone, ax.keys.map((v, i) => wrap(rest0 + (above === null ? 0 : turn(above, i)) + v)));
|
|
1425
1527
|
tracks.push({ bone: ax.bone, property: 'rotate', keys: ax.keys.map((v, i) => ({ t: keyTimes[i]!, v: [v] })) });
|
|
1426
1528
|
}
|
|
1529
|
+
const fSets = faceYawSets(map.face);
|
|
1530
|
+
if (fSets !== undefined) face.sets = selectYawSets(face.read.rawYaw, fSets, keyTimes, opts.switchMin);
|
|
1531
|
+
}
|
|
1532
|
+
let body: BodyReading | null = null;
|
|
1533
|
+
{
|
|
1534
|
+
const rSh = heldPt('r_shoulder');
|
|
1535
|
+
const lSh = heldPt('l_shoulder');
|
|
1536
|
+
if (rSh !== null && lSh !== null) {
|
|
1537
|
+
const rHip = heldPt('r_hip');
|
|
1538
|
+
const lHip = heldPt('l_hip');
|
|
1539
|
+
const neck = heldPt('neck');
|
|
1540
|
+
const faceYaw = face !== null ? face.read.rawYaw : (() => {
|
|
1541
|
+
const nose = heldPt('nose');
|
|
1542
|
+
const rEye = heldPt('r_eye');
|
|
1543
|
+
const lEye = heldPt('l_eye');
|
|
1544
|
+
if (nose === null || rEye === null || lEye === null) return null;
|
|
1545
|
+
return readFaceAngles(nose, rEye, lEye, heldPt('r_ear'), heldPt('l_ear')).yaw;
|
|
1546
|
+
})();
|
|
1547
|
+
body = readBody(map.body, rSh, lSh, rHip, lHip, neck, !inPlace && n > 25 ? continuousScale : null, faceYaw);
|
|
1548
|
+
const bodyAx = body.yaw;
|
|
1549
|
+
if (bodyAx !== undefined) {
|
|
1550
|
+
const rest0 = restAngle(rest, bodyAx.bone, 'x');
|
|
1551
|
+
const above = mappedAbove(bodyAx.bone);
|
|
1552
|
+
targets.set(bodyAx.bone, bodyAx.keys.map((v, i) => wrap(rest0 + (above === null ? 0 : turn(above, i)) + v)));
|
|
1553
|
+
tracks.push({ bone: bodyAx.bone, property: 'rotate', keys: bodyAx.keys.map((v, i) => ({ t: keyTimes[i]!, v: [v] })) });
|
|
1554
|
+
}
|
|
1555
|
+
const bSets = bodyYawSets(map.body);
|
|
1556
|
+
if (bSets !== undefined) body.sets = selectYawSets(body.read.rawYaw, bSets, keyTimes, opts.switchMin);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
const activeYawSets: YawSetSwitch[] = [...(face?.sets ?? []), ...(body?.sets ?? [])];
|
|
1560
|
+
if (activeYawSets.some((s) => s.spans.length > 0)) {
|
|
1561
|
+
const known = knownSetsOf(map, rigSets);
|
|
1562
|
+
const slotOrder: string[] = [];
|
|
1563
|
+
for (const tr of sw.slotTracks) if (!slotOrder.includes(tr.slot)) slotOrder.push(tr.slot);
|
|
1564
|
+
for (const s of activeYawSets) {
|
|
1565
|
+
const def = known.get(s.set);
|
|
1566
|
+
if (def === undefined) continue;
|
|
1567
|
+
for (const sl of [...Object.keys(def.slots), ...def.hides]) {
|
|
1568
|
+
if (!slotOrder.includes(sl)) slotOrder.push(sl);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
const yawExactAt = new Map<string, Set<number>>();
|
|
1572
|
+
for (const s of activeYawSets) {
|
|
1573
|
+
const def = known.get(s.set);
|
|
1574
|
+
if (def === undefined) continue;
|
|
1575
|
+
const values: Array<[string, string | null]> = [...Object.entries(def.slots), ...def.hides.map((h): [string, null] => [h, null])];
|
|
1576
|
+
for (const [sl, v] of values) {
|
|
1577
|
+
if (!sw.shown.has(sl)) sw.shown.set(sl, Array.from({ length: n }, () => slots.get(sl)?.setup ?? null));
|
|
1578
|
+
const row = sw.shown.get(sl)!;
|
|
1579
|
+
for (const [lo, hi] of s.spans) {
|
|
1580
|
+
for (let i = lo; i <= hi; i++) row[i] = v;
|
|
1581
|
+
const exact = yawExactAt.get(sl) ?? new Set<number>();
|
|
1582
|
+
exact.add(lo);
|
|
1583
|
+
if (hi + 1 < n) exact.add(hi + 1);
|
|
1584
|
+
yawExactAt.set(sl, exact);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const rebuilt: SlotTrack[] = [];
|
|
1589
|
+
for (const sl of slotOrder) {
|
|
1590
|
+
const row = sw.shown.get(sl);
|
|
1591
|
+
if (row === undefined) continue;
|
|
1592
|
+
let prev = slots.get(sl)?.setup ?? null;
|
|
1593
|
+
const keys: AttachmentKey[] = [];
|
|
1594
|
+
const exact = yawExactAt.get(sl);
|
|
1595
|
+
row.forEach((v, i) => {
|
|
1596
|
+
if (v === prev) return;
|
|
1597
|
+
const t = i === 0 ? 0 : exact?.has(i) === true ? keyTimes[i]! : r6((keyTimes[i - 1]! + keyTimes[i]!) / 2);
|
|
1598
|
+
keys.push({ t, v });
|
|
1599
|
+
prev = v;
|
|
1600
|
+
});
|
|
1601
|
+
if (keys.length > 0) rebuilt.push({ slot: sl, property: 'attachment', keys });
|
|
1602
|
+
}
|
|
1603
|
+
sw.slotTracks.splice(0, sw.slotTracks.length, ...rebuilt);
|
|
1604
|
+
}
|
|
1605
|
+
if (activeYawSets.length > 0) {
|
|
1606
|
+
const known = knownSetsOf(map, rigSets);
|
|
1607
|
+
const otherSlotKeyers = new Map<string, string[]>();
|
|
1608
|
+
const addKeyer = (sl: string, who: string) => {
|
|
1609
|
+
const list = otherSlotKeyers.get(sl) ?? [];
|
|
1610
|
+
if (!list.includes(who)) list.push(who);
|
|
1611
|
+
otherSlotKeyers.set(sl, list);
|
|
1612
|
+
};
|
|
1613
|
+
for (const aSw of sw.switches) {
|
|
1614
|
+
for (const sl of [...Object.keys(aSw.slots), ...aSw.hides]) addKeyer(sl, `set "${aSw.set}"`);
|
|
1615
|
+
}
|
|
1616
|
+
for (const s of activeYawSets) {
|
|
1617
|
+
if (s.spans.length === 0) continue;
|
|
1618
|
+
const def = known.get(s.set);
|
|
1619
|
+
if (def === undefined) continue;
|
|
1620
|
+
for (const sl of [...Object.keys(def.slots), ...def.hides]) addKeyer(sl, `set "${s.set}"`);
|
|
1621
|
+
}
|
|
1622
|
+
for (const s of activeYawSets) {
|
|
1623
|
+
const def = known.get(s.set);
|
|
1624
|
+
if (def === undefined) continue;
|
|
1625
|
+
for (const h of def.hides) {
|
|
1626
|
+
const keyers = (otherSlotKeyers.get(h) ?? []).filter((who) => who !== `set "${s.set}"`);
|
|
1627
|
+
if (keyers.length > 0) {
|
|
1628
|
+
s.hidesKeyed.push({ slot: h, by: `slot "${h}" attachment (${keyers.join(', ')})` });
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1427
1632
|
}
|
|
1428
1633
|
// Scale keys: a scaled leg segment's own scale, or the scale that lands a bone under one at its asked length.
|
|
1429
1634
|
for (const c of map.chains) {
|
|
@@ -1432,6 +1637,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1432
1637
|
tracks.push({ bone: b, property: 'scalex', keys: Array.from({ length: n }, (_, i) => ({ t: keyTimes[i]!, v: [r6(solvedKey(b, i)?.scale ?? scales.get(b)?.[i] ?? 1)] })) });
|
|
1433
1638
|
}
|
|
1434
1639
|
}
|
|
1640
|
+
if (trunkScaleBone !== null && trunkScale !== null) {
|
|
1641
|
+
tracks.push({ bone: trunkScaleBone, property: 'scaley', keys: trunkScale.map((v, i) => ({ t: keyTimes[i]!, v: [r6(v)] })) });
|
|
1642
|
+
}
|
|
1435
1643
|
if (translateTrack !== null) tracks.push(translateTrack);
|
|
1436
1644
|
if (headNodTrack !== null) tracks.push(headNodTrack);
|
|
1437
1645
|
tracks.push(...sw.offsetTracks, ...sw.slotTracks);
|
|
@@ -1440,7 +1648,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1440
1648
|
animation: { duration: keyTimes[n - 1]!, tracks },
|
|
1441
1649
|
times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
|
|
1442
1650
|
switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
|
|
1443
|
-
legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face, figureScale,
|
|
1651
|
+
legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face, body, figureScale,
|
|
1444
1652
|
};
|
|
1445
1653
|
}
|
|
1446
1654
|
|
package/src/rigc.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'no
|
|
|
13
13
|
import { dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { poseRawSetup, readModel, type CompiledDocument } from 'rig-c/core';
|
|
16
|
-
import type { InheritMode, Rest, RestBone, RigSlot, RigSlots } from './map.ts';
|
|
16
|
+
import type { InheritMode, Rest, RestBone, RigSet, RigSets, RigSlot, RigSlots } from './map.ts';
|
|
17
17
|
import { isObject, parseJson, Problems, Refusal, typeName } from './problems.ts';
|
|
18
18
|
|
|
19
19
|
export interface RigInput {
|
|
@@ -23,6 +23,32 @@ export interface RigInput {
|
|
|
23
23
|
name: string;
|
|
24
24
|
/** The motion spec the rig is built with, or null when it has none. */
|
|
25
25
|
motion: string | null;
|
|
26
|
+
/** Any alternate sets declared in `sets.json` (`rig-parts-sets/1`) beside `rig.json`. */
|
|
27
|
+
sets: RigSets;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readRigSets(path: string): RigSets {
|
|
31
|
+
const out = new Map<string, RigSet>();
|
|
32
|
+
if (!existsSync(path)) return out;
|
|
33
|
+
const doc = parseJson(readFileSync(path, 'utf8'), path, 'SETS_JSON');
|
|
34
|
+
if (!isObject(doc) || (doc.spec !== 'rig-parts-sets/1' && doc.spec !== 'spine-parts-sets/1') || (!isObject(doc.sets) && !Array.isArray(doc.sets))) {
|
|
35
|
+
throw new Refusal([{ code: 'SETS_SPEC', text: `${path} is not a rig-parts-sets/1 spec with a "sets" object` }]);
|
|
36
|
+
}
|
|
37
|
+
const addSet = (name: string, s: unknown) => {
|
|
38
|
+
if (!isObject(s) || !isObject(s.slots)) return;
|
|
39
|
+
const slots: Record<string, string> = {};
|
|
40
|
+
for (const [k, v] of Object.entries(s.slots)) if (typeof v === 'string') slots[k] = v;
|
|
41
|
+
const hides = Array.isArray(s.hides) ? s.hides.filter((h): h is string => typeof h === 'string') : [];
|
|
42
|
+
out.set(name, { slots, hides });
|
|
43
|
+
};
|
|
44
|
+
if (Array.isArray(doc.sets)) {
|
|
45
|
+
for (const s of doc.sets) {
|
|
46
|
+
if (isObject(s) && typeof s.name === 'string') addSet(s.name, s);
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
for (const [name, s] of Object.entries(doc.sets)) addSet(name, s);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
26
52
|
}
|
|
27
53
|
|
|
28
54
|
/**
|
|
@@ -49,7 +75,8 @@ export function resolveRig(path: string, baseMotion: string | undefined): RigInp
|
|
|
49
75
|
if (!isObject(doc) || doc.spec !== 'rigc-rig/1' || typeof doc.name !== 'string') {
|
|
50
76
|
throw new Refusal([{ code: 'RIG_SPEC', text: `${rig} is not a rig spec: "spec" is ${isObject(doc) ? typeName(doc.spec) : typeName(doc)} and "name" ${isObject(doc) ? typeName(doc.name) : '-'}; it must be "rigc-rig/1" with a name` }]);
|
|
51
77
|
}
|
|
52
|
-
|
|
78
|
+
const sets = readRigSets(join(dirname(rig), 'sets.json'));
|
|
79
|
+
return { rig, name: doc.name, motion, sets };
|
|
53
80
|
}
|
|
54
81
|
|
|
55
82
|
/** The motion spec the rig is built with, or an empty one under its name. */
|
package/src/run.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { dirname, join, resolve } from 'node:path';
|
|
10
|
-
import { readMap, resolveMap, type JointMap, type Rest } from './map.ts';
|
|
10
|
+
import { knownSetsOf, readMap, resolveMap, type JointMap, type Rest } from './map.ts';
|
|
11
11
|
import { cleanLine, inferredSource } from './clean.ts';
|
|
12
12
|
import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
|
|
13
13
|
import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
|
|
@@ -19,7 +19,7 @@ import { landed, measureFeet, measureLanding, measurePosture, measureShear, pose
|
|
|
19
19
|
import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
|
|
20
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
|
-
import { faceLine } from './face.ts';
|
|
22
|
+
import { bodyLine, faceLine } from './face.ts';
|
|
23
23
|
|
|
24
24
|
export interface RetargetArgs extends RetargetOptions {
|
|
25
25
|
rig: string;
|
|
@@ -108,9 +108,25 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
108
108
|
const rigc = rigcVersion();
|
|
109
109
|
const rest = restOf(doc0);
|
|
110
110
|
const slots = slotsOf(doc0);
|
|
111
|
-
resolveMap(map, rest, a.map, slots);
|
|
111
|
+
resolveMap(map, rest, a.map, slots, input.sets);
|
|
112
112
|
|
|
113
|
-
const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}) }, slots);
|
|
113
|
+
const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}) }, slots, input.sets);
|
|
114
|
+
if (fragment.length > 0 && ((r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
|
|
115
|
+
const known = knownSetsOf(map, input.sets);
|
|
116
|
+
for (const s of [...(r.face?.sets ?? []), ...(r.body?.sets ?? [])]) {
|
|
117
|
+
const def = known.get(s.set);
|
|
118
|
+
if (def === undefined) continue;
|
|
119
|
+
for (const h of def.hides) {
|
|
120
|
+
for (const tr of fragment) {
|
|
121
|
+
const tgt = targetOf(tr);
|
|
122
|
+
if (tgt?.kind === 'slot' && tgt.name === h) {
|
|
123
|
+
const by = `slot "${h}" ${tr.property} (--merge ${a.merge})`;
|
|
124
|
+
if (!s.hidesKeyed.some((x) => x.slot === h && x.by === by)) s.hidesKeyed.push({ slot: h, by });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
114
130
|
say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.raisedByHead === true ? ', raised-by-head' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
|
|
115
131
|
say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
|
|
116
132
|
say(` CLEAN ${cleanLine(seq.cleaned!)}`);
|
|
@@ -161,6 +177,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
161
177
|
}
|
|
162
178
|
if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
|
|
163
179
|
if (r.face !== null) say(` FACE ${faceLine(r.face)}`);
|
|
180
|
+
if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
|
|
164
181
|
|
|
165
182
|
// --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
|
|
166
183
|
const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
|
|
@@ -192,6 +209,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
192
209
|
...(map.translate === null ? [] : [map.translate.bone]),
|
|
193
210
|
...(map.face?.yaw === undefined ? [] : [map.face.yaw.bone]),
|
|
194
211
|
...(map.face?.pitch === undefined ? [] : [map.face.pitch.bone]),
|
|
212
|
+
...(map.body?.yaw === undefined ? [] : [map.body.yaw.bone]),
|
|
195
213
|
];
|
|
196
214
|
const below = new Map<string, string[]>();
|
|
197
215
|
for (const m of new Set(mapped)) {
|
|
@@ -283,7 +301,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
283
301
|
else if (f.meshes.length === 0) say(` FOLD rigc render --folds at ${fps} fps: no mesh folds`);
|
|
284
302
|
else for (const m of f.meshes) say(` FOLD mesh "${m.attachment}" (slot "${m.slot}") folds on ${m.times.length} frame(s) at ${fps} fps: t=${spans(m.times, 1 / fps)}`);
|
|
285
303
|
const uncovered = f.meshes === null ? null : uncoveredFolds(f.meshes, r.shown, r.times, slots, (slot, att) => chainsMoving(attachmentBones(doc, slot, att), rest, map));
|
|
286
|
-
if (uncovered !== null && map.alternates.length > 0) {
|
|
304
|
+
if (uncovered !== null && (map.alternates.length > 0 || (r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
|
|
287
305
|
for (const u of uncovered) say(` UNCOVERED mesh "${u.attachment}" (slot "${u.slot}"; chains ${u.chains.length === 0 ? 'none' : u.chains.join(', ')}) folds with no switch covering it on ${u.times.length} frame(s): t=${spans(u.times, 1 / fps)}`);
|
|
288
306
|
}
|
|
289
307
|
|
|
@@ -363,8 +381,19 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
363
381
|
read: { yaw: r.face.read.yaw, pitch: r.face.read.pitch },
|
|
364
382
|
...(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
383
|
...(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 } }),
|
|
384
|
+
...(r.face.sets === undefined ? {} : { sets: r.face.sets }),
|
|
366
385
|
},
|
|
367
386
|
}),
|
|
387
|
+
body: r.body === null
|
|
388
|
+
? null
|
|
389
|
+
: {
|
|
390
|
+
standing: r.body.standing,
|
|
391
|
+
read: { yaw: r.body.read.yaw, opening: r.body.read.opening },
|
|
392
|
+
over45: r.body.over45,
|
|
393
|
+
framesOver45: r.body.framesOver45,
|
|
394
|
+
...(r.body.yaw === undefined ? {} : { yaw: { bone: r.body.yaw.bone, limits: r.body.yaw.limits, knee: r.body.yaw.knee, read: r.body.yaw.read, mapped: r.body.yaw.mapped, scale: r.body.yaw.scale, corr: r.body.yaw.corr } }),
|
|
395
|
+
...(r.body.sets === undefined ? {} : { sets: r.body.sets }),
|
|
396
|
+
},
|
|
368
397
|
legScale: r.legScale.map(({ scale: _scale, asked: _asked, ...rest }) => rest),
|
|
369
398
|
pivot: r.pivot,
|
|
370
399
|
stance: r.stance === null ? null : { tolerance: r.stance.tolerance, release: r.stance.release, minSeconds: r.stance.minSeconds, bounds: r.stance.bounds, ...(r.stance.range === undefined ? {} : { range: r.stance.range }), runs: r.stance.runs, over: r.stance.over },
|
package/src/verify.ts
CHANGED
|
@@ -64,8 +64,29 @@ export function measureLanding(doc: CompiledDocument, animation: string, map: Jo
|
|
|
64
64
|
const faceBones = new Set([r.face?.yaw?.bone, r.face?.pitch?.bone].filter((b): b is string => b !== undefined));
|
|
65
65
|
const isFaceSlider = (c: CompiledDocument['constraints'][number]): boolean => c.record?.kind === 'slider' && c.record.bone !== null && faceBones.has(c.record.bone);
|
|
66
66
|
const hasFaceSliders = faceBones.size > 0 && doc.constraints.some(isFaceSlider);
|
|
67
|
-
const
|
|
68
|
-
|
|
67
|
+
const hasScaleY = doc.animations.some((a) => a.name === animation && a.timelines.bones.some((b) => b.timelines.some((t) => t.kind === 'scaley')));
|
|
68
|
+
const chainPosed = hasFaceSliders || hasScaleY
|
|
69
|
+
? poseFrames(
|
|
70
|
+
{
|
|
71
|
+
...doc,
|
|
72
|
+
constraints: doc.constraints.filter((c) => !isFaceSlider(c)),
|
|
73
|
+
animations: hasScaleY
|
|
74
|
+
? doc.animations.map((a) =>
|
|
75
|
+
a.name === animation
|
|
76
|
+
? {
|
|
77
|
+
...a,
|
|
78
|
+
timelines: {
|
|
79
|
+
...a.timelines,
|
|
80
|
+
bones: a.timelines.bones.map((b) => ({ ...b, timelines: b.timelines.filter((t) => t.kind !== 'scaley') })),
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
: a,
|
|
84
|
+
)
|
|
85
|
+
: doc.animations,
|
|
86
|
+
},
|
|
87
|
+
animation,
|
|
88
|
+
r.times,
|
|
89
|
+
)
|
|
69
90
|
: posed;
|
|
70
91
|
for (const c of map.chains) {
|
|
71
92
|
c.bones.forEach((b, k) => {
|