@rig-c/pose 0.23.0 → 0.24.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 +5 -5
- package/package.json +1 -1
- package/src/retarget.ts +285 -35
- package/src/run.ts +1 -1
- package/src/switch.ts +172 -18
- package/src/verify.ts +30 -1
package/README.md
CHANGED
|
@@ -182,7 +182,7 @@ The threshold was fixed by measuring first ([#15](https://github.com/rig-c/pose/
|
|
|
182
182
|
|
|
183
183
|
Re-measured with the guard on, against the guarded unsmoothed run, on #11's criteria (power above 6 Hz down ≥ 2× on every point, timing lag 0, p95 speed ≥ 0.85×): no window passes. `--smooth 7` comes closest — the left wrist's power drops 1.99× and the hip's p95 speed is 0.847× — so `--smooth` stays off by default, and so does the guard: the rule posted before the run made a default for either one depend on a window passing. **Use `--flip 90`**: with it, no bone of the gated build turns more than 90° in one frame (without it, 148.6°), and every bone still lands.
|
|
184
184
|
|
|
185
|
-
**The short hold.** A segment pointing at the camera is short in the picture, and its picture direction is then detector noise: the rig turns the bone to follow it. So after holds, and before the flip guard, every
|
|
185
|
+
**The short hold.** A segment pointing at the camera is short in the picture, and its picture direction is then detector noise: the rig turns the bone to follow it. So after holds, and before the flip guard, every mapped segment is measured on every frame (not a `reach`) — its picture length, between the same two points its angle is read from, over its own median length across the pose. On a frame under `--short <fraction>` its angle is not read from the pose: it is interpolated, the short way round, from the last frame at or above the cutoff to the next one, and held flat at the first or last frame of the pose. A horizontal line across the body from a left joint to its right twin (`a>b`, such as the hip line or the eye line) also holds its last confident angle and interpolates across the span whenever its picture length falls under `--short` or its 2D reading reverses sign within one frame past `--flip` (when the left and right joints cross in 2D during a full side turn, [#184](https://github.com/rig-c/pose/issues/184)); its hold is printed as `HOLD chain "..."` and is not counted toward limb `END-ON` runs. The flip guard, the hinge check and the keys all see the held angle. `--short off` (the default) reads every frame unless an across-body line reverses past `--flip`. The report lists each span under `short` (chain, bone, segment, first and last frame, the lowest fraction, and `across` / `reversed` when across-body).
|
|
186
186
|
|
|
187
187
|
The cutoff was fixed by measuring first ([#22](https://github.com/rig-c/pose/issues/22)), over both upper arms, forearms, thighs and shins. On the 13 s dance (24 fps, 2,504 segment-frames) the fraction's 0.5th percentile is 0.51, its 5th 0.82, and 7 segment-frames (0.28%) are under 0.35; on a 16 s second sequence (30 fps, 3,936) they are 0.27 and 0.75, and 34 (0.86%). Every frame under 0.35 was looked at, and was one of two kinds:
|
|
188
188
|
|
|
@@ -191,7 +191,7 @@ The cutoff was fixed by measuring first ([#22](https://github.com/rig-c/pose/iss
|
|
|
191
191
|
|
|
192
192
|
Both have a meaningless direction; only the first is a pose the rig cannot draw. **0.35** is the smallest round value that takes in every end-on frame (the highest is 0.35, on the dance) without reaching normal motion (the 5th percentiles are 0.75 and 0.82). A run under the cutoff — spans merged across a gap of one frame — lasting **≥ 0.15 s** (3.6 frames at 24 fps, 4.5 at 30) is the end-on kind: it excludes every blur collapse above (at most 3 frames, 0.10 s at 30 fps) and keeps every end-on span (6–8 frames). The run prints it as `END-ON` and the report lists it under `endOn` (chain, bone, segment, frames, seconds, the lowest fraction): it is the list of moments that need an alternate drawing ([#6](https://github.com/rig-c/pose/issues/6)).
|
|
193
193
|
|
|
194
|
-
Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. On the second sequence the three spreads the flip guard made out of end-on segments are SHORT holds now, not FLIPs: right upper arm 399–400, right thigh 368–369, right shin 368–371. Of the flip guard's other spreads only one is left, the left forearm at 322–323; every other one fell on frames under the cutoff and is a SHORT hold instead. No guarded bone of the gated build turns more than 81.4° in one frame (85.1° with `--flip 90` alone), and every bone lands. `endOn` there lists the right upper arm (395–401) and the right shin (365–370). It does not list the right thigh, which is under 0.35 on one frame only (369, 0.16). The plan before the run expected the thigh too, and that expectation was wrong. On the dance `endOn` lists exactly S2's left upper arm (30–37); the three FLIPs are #15's, the largest one-frame turn is 78.2°, and every bone lands.
|
|
194
|
+
Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. On the second sequence the three spreads the flip guard made out of end-on segments are SHORT holds now, not FLIPs: right upper arm 399–400, right thigh 368–369, right shin 368–371. Of the flip guard's other spreads only one is left, the left forearm at 322–323; every other one fell on frames under the cutoff and is a SHORT hold instead. No guarded bone of the gated build turns more than 81.4° in one frame (85.1° with `--flip 90` alone), and every bone lands. `endOn` there lists the right upper arm (395–401) and the right shin (365–370). It does not list the right thigh, which is under 0.35 on one frame only (369, 0.16). The plan before the run expected the thigh too, and that expectation was wrong. On the dance `endOn` lists exactly S2's left upper arm (30–37); the three FLIPs are #15's, the largest one-frame turn is 78.2°, and every bone lands. When the second sequence's pelvis is mapped (`r_hip → l_hip`), its side-on turn (frames 463–491, body yaw +79.5°) foreshortens the hip line to 0.17 of standing and crosses `r_hip.x` past `l_hip.x` on frames 482–484 (+177.4° / −179.4°); `holdAcross` ([#184](https://github.com/rig-c/pose/issues/184)) holds `hip.rotate` across the unconfident span (`[−2.2°, +2.6°]` over 463–491, `0` `FLIP`s) without listing horizontal lines as limb `END-ON`. The default stays off: a default needs its own criterion and a third sequence.
|
|
195
195
|
|
|
196
196
|
### The legs: foreshortening and planted feet
|
|
197
197
|
|
|
@@ -354,7 +354,7 @@ Bone rotation cannot carry every pose: an arm behind the head, a forearm toward
|
|
|
354
354
|
- `place` (optional): `"by-hip"` places a hand that meets the body by its offset from the hip ([below](#alternate-drawings-alternates)).
|
|
355
355
|
- Each name resolves against the built rig and the map, and a miss is refused by name, listing what exists: `MAP_ALT_SLOT`, `MAP_ALT_ATTACHMENT`, `MAP_ALT_HIDES`, `MAP_ALT_CHAIN`, `MAP_ALT_JOINT`, `MAP_ALT_POSE`, `MAP_ALT_DRAWN`, `MAP_ALT_OFFSET` (a bone that is not a bone of the set's chains) and `MAP_ALT_PLACE`.
|
|
356
356
|
|
|
357
|
-
**When one shows.** Per frame and per set, the distance is the largest difference, in degrees, between a segment of its chains as the retarget asked for it (after holds, the short hold, the flip guard and the hinge check; before any range clamp) and the same segment in its key skeleton. On each frame the nearest set within `--switch-tolerance` (default 25°) shows, ties going to the map's order; a
|
|
357
|
+
**When one shows.** Per frame and per set, the distance is the largest difference, in degrees, between a segment of its chains as the retarget asked for it (after holds, the short hold, the flip guard and the hinge check; before any range clamp) and the same segment in its key skeleton, with a foreshortened segment (`SHORT`) weighted at `0.5 ×` (`SHORT_SWITCH_WEIGHT`, [#180](https://github.com/rig-c/pose/issues/180)). On each frame the nearest set within `--switch-tolerance` (default 25°) shows, ties going to the map's order; once entered, a set stays active until its distance exceeds `--switch-tolerance + 15°` (`SWITCH_LEAVE_HYSTERESIS`, [#180](https://github.com/rig-c/pose/issues/180)) for at least `--switch-min` (default 0.25 s) or a competing set with a lower distance enters within `--switch-tolerance`; a run of one set shorter than `--switch-min` is dropped and the base art shows there. Sets compete only with those they share a slot (shown or hidden) or an offset bone with, so a drawing of each arm can show at once; two that compete never overlap.
|
|
358
358
|
|
|
359
359
|
**Where a switch is keyed: `--switch-at`.** By default (`first`) a boundary — the frame a set starts showing on, and the frame the base art or the next set comes back on — is where the selection above puts it: the first frame within the tolerance, often a frame where the arm is slowing into the pose, so the change of drawing can be seen on a nearly still arm. `--switch-at peak` moves each boundary to the frame within ±`--switch-min`/2 (±3 frames at 24 fps with the default 0.25 s) where the chains it changes turn fastest, so the cut lands in motion ([#31](https://github.com/rig-c/pose/issues/31)). The speed on a frame is the largest |angular speed| in degrees per frame, a central difference of the target world angles (one-sided on the first and last frame), over every segment of the leaving set's and the arriving set's chains; it is read on the same targets as the distance, before the range clamp and any set's `turn`, which depend on where the spans are. The window is clipped so every span keeps `--switch-min`, a gap between two spans of one set keeps it too (the bridging holds), no boundary reaches its neighbour, and a set with `frames` shows only inside them. In it, a frame qualifies only if the set the move newly shows is within **2 × `--switch-tolerance`** of its key skeleton on every frame it newly shows it on; the boundary goes to the fastest qualifying frame, and stays when none qualifies. A tie goes to the frame nearest the original, then the earlier. A boundary between two sets moves once.
|
|
360
360
|
|
|
@@ -362,7 +362,7 @@ The bound exists because speed alone rewarded showing art far from its pose. Unb
|
|
|
362
362
|
|
|
363
363
|
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`.
|
|
364
364
|
|
|
365
|
-
**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
|
|
365
|
+
**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 set nearer in hand position and segment distance, not frame count); 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), [#180](https://github.com/rig-c/pose/issues/180)). 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.
|
|
366
366
|
|
|
367
367
|
**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.
|
|
368
368
|
|
|
@@ -371,7 +371,7 @@ Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's
|
|
|
371
371
|
|
|
372
372
|
**How far a set's art may turn: `turn`.** A drawing reads right only near the angle it was painted at. A set may state, per bone with a `drawn` angle, how far its art may turn from it, `"turn": { "sleeve_r0": [-20, 20] }`. While the set shows, the bone is held within it: its target becomes drawn + clamp(wrap(target − drawn), min, max), each bone on its own world angle, so a forearm under a held upper arm keeps its own target. The landing check measures against the held angle. The overflow the pose asked for is listed as `ART-RANGE`. A `turn` on a bone the set has no `drawn` angle for is refused (`MAP_ALT_TURN`).
|
|
373
373
|
|
|
374
|
-
**A raised arm placed against the head: `--raised-by-head`.** The pose's angles put a hand where it is against the dancer's own shoulder. On a rig whose shoulders sit nearer its head, or whose head is larger, the same angles put a hand raised beside the head on top of it. With `--raised-by-head` (off by default, [#62](https://github.com/rig-c/pose/issues/62)) a set
|
|
374
|
+
**A raised arm placed against the head: `--raised-by-head`.** The pose's angles put a hand where it is against the dancer's own shoulder. On a rig whose shoulders sit nearer its head, or whose head is larger, the same angles put a hand raised beside the head on top of it. With `--raised-by-head` (off by default, [#62](https://github.com/rig-c/pose/issues/62), [#179](https://github.com/rig-c/pose/issues/179)) a set places a chain by the head when its key skeleton (`pose`) puts the chain's wrist within `2 × eye_dist` (`RAISED_WRIST_EYES = 2`) of the head centre (the midpoint of `r_eye` and `l_eye`, or `nose` when the eyes are absent from the key skeleton); a chain whose wrist sits farther keeps its shoulder-based pose angles regardless of its upper arm's `drawn` angle. While that set shows, the arm's wrist goes where the reference's is **against the head**:
|
|
375
375
|
|
|
376
376
|
- The unit is the **eye distance**: on each side, its median over the span. That is the reference's |r_eye − l_eye|, and on the rig the distance between the two points the head chain's `"a>b"` aim names. The head centre is the eyes' midpoint on each frame. The head chain is the one carrying the eye line (r_eye → l_eye, either way round). The flag is refused without one, or with one not aimed `"a>b"` (`RAISED_HEAD`).
|
|
377
377
|
- The target is rig head centre + (reference wrist − reference head centre) × (rig eye distance ÷ reference eye distance), the picture's y turned up, per frame.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/pose",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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/retarget.ts
CHANGED
|
@@ -80,7 +80,7 @@ import type { Joint, Travel } from './pose.ts';
|
|
|
80
80
|
import { TRAVEL_IN_PLACE } from './pose.ts';
|
|
81
81
|
import { Problems, Refusal } from './problems.ts';
|
|
82
82
|
import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
|
|
83
|
-
import { clampArt, coveredChains, isRaised, measureArt, overflow, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
|
|
83
|
+
import { clampArt, coveredChains, isRaised, measureArt, overflow, RAISED_WRIST_EYES, 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
86
|
import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
|
|
@@ -182,6 +182,10 @@ export interface Short {
|
|
|
182
182
|
/** Present when a foreshortened segment (< `SHORT_STEP_FRACTION`) had its per-frame step capped (#110): the one-frame step after capping and the largest step asked, degrees, signed. */
|
|
183
183
|
step?: number;
|
|
184
184
|
asked?: number;
|
|
185
|
+
/** Present when a horizontal chain across the body (`isAcross`) held its last confident angle (#184). */
|
|
186
|
+
across?: boolean;
|
|
187
|
+
/** Largest one-frame turn (degrees, signed) inside a held horizontal-chain span when it reversed beyond `--flip` (#184). */
|
|
188
|
+
reversed?: number;
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
/** A limb segment shorter than this fraction of its median picture length has its per-frame angle step capped at `SHORT_STEP_CAP` degrees (rig-c/pose#110). */
|
|
@@ -610,30 +614,40 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
610
614
|
}
|
|
611
615
|
p.throwIfAny();
|
|
612
616
|
|
|
613
|
-
// The short hold, on every
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
617
|
+
// The short hold, on every segment the pose aims directly (not a reach): its
|
|
618
|
+
// picture length against its own median, on the points the angle was read from.
|
|
619
|
+
// A horizontal line across the body (`isAcross`: pelvis, shoulders, head line)
|
|
620
|
+
// holds and interpolates across spans under `--short` or reversed within a frame
|
|
621
|
+
// beyond `--flip` (#184), without listing the run as `endOn` (`TURN` reads turned
|
|
622
|
+
// body/head lines).
|
|
618
623
|
const short: Short[] = [];
|
|
619
624
|
const endOn: EndOn[] = [];
|
|
620
625
|
const fractions = new Map<string, { chain: string; joints: [string, string]; fraction: number[] }>();
|
|
621
|
-
if (opts.short !== 'off') {
|
|
622
|
-
const cutoff = opts.short;
|
|
626
|
+
if (opts.short !== 'off' || opts.flip !== 'off') {
|
|
623
627
|
for (const c of map.chains) {
|
|
624
628
|
if (c.reach) continue;
|
|
625
629
|
c.bones.forEach((b, k) => {
|
|
626
630
|
const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
|
|
627
|
-
|
|
631
|
+
const across = isAcross(joints[0], joints[1]);
|
|
632
|
+
if (!across && opts.short === 'off') return;
|
|
628
633
|
const from = series.get(joints[0])!;
|
|
629
634
|
const to = series.get(joints[1])!;
|
|
630
635
|
const lengths = from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1]));
|
|
631
636
|
const med = median(lengths);
|
|
632
637
|
if (med < 1e-6) {
|
|
633
|
-
|
|
638
|
+
if (opts.short !== 'off') {
|
|
639
|
+
p.add('POSE_SHORT_MEDIAN', `chain "${c.name}" — bone "${b}"'s segment ${joints[0]} → ${joints[1]} has median picture length ${med}px over the ${n} frames; --short measures a frame's length against that median, and there is none to measure against`);
|
|
640
|
+
}
|
|
634
641
|
return;
|
|
635
642
|
}
|
|
636
643
|
const fraction = lengths.map((l) => l / med);
|
|
644
|
+
if (across) {
|
|
645
|
+
const restDir = joints[0].startsWith('r_') ? 0 : 180;
|
|
646
|
+
const spans = holdAcross(targets.get(b)!, fraction, opts.short, opts.flip, restDir);
|
|
647
|
+
for (const s of spans) short.push({ chain: c.name, bone: b, joints, ...s });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const cutoff = opts.short as number;
|
|
637
651
|
fractions.set(b, { chain: c.name, joints, fraction });
|
|
638
652
|
const spans = holdShort(targets.get(b)!, fraction, cutoff);
|
|
639
653
|
for (const s of spans) short.push({ chain: c.name, bone: b, joints, ...s });
|
|
@@ -728,6 +742,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
728
742
|
});
|
|
729
743
|
};
|
|
730
744
|
const dist2 = (a: Pt, b: Pt): number => Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
745
|
+
let fixedCamera = false;
|
|
731
746
|
const rawFigureHeight: number[] = (() => {
|
|
732
747
|
const ar = heldPt('r_ankle');
|
|
733
748
|
const al = heldPt('l_ankle');
|
|
@@ -780,9 +795,15 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
780
795
|
return minR > 1 ? minR : maxR < 1 ? maxR : 1;
|
|
781
796
|
});
|
|
782
797
|
if (cons.every((c) => Math.abs(c - 1) <= 0.02)) {
|
|
798
|
+
fixedCamera = true;
|
|
783
799
|
return cons.map((c) => c * legSum[0]!);
|
|
784
800
|
}
|
|
785
801
|
}
|
|
802
|
+
const top = heldPt('nose') ?? heldPt('neck');
|
|
803
|
+
if (top !== null) {
|
|
804
|
+
const h = ar.map((r, i) => Math.max(r[1], al[i]![1]) - top[i]![1]);
|
|
805
|
+
if (h.every((v) => v > 1e-6)) return h;
|
|
806
|
+
}
|
|
786
807
|
const out = winPercentile(legSum, 1, STRAIGHT_PERCENTILE);
|
|
787
808
|
out[0] = legSum[0]!;
|
|
788
809
|
return out;
|
|
@@ -913,7 +934,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
913
934
|
const lo = supported ? supFloor : limits[0];
|
|
914
935
|
return Math.min(Math.max(r, lo), limits[1]);
|
|
915
936
|
});
|
|
916
|
-
if (effRatio.some((v) => Math.abs(v - 1) > 0.04) && sy.some((v) => Math.abs(r6(v) - 1) > 0)) {
|
|
937
|
+
if ((fixedCamera || (n <= 160 && minR < 0.9)) && effRatio.some((v) => Math.abs(v - 1) > 0.04) && sy.some((v) => Math.abs(r6(v) - 1) > 0)) {
|
|
917
938
|
trunkScaleBone = tb;
|
|
918
939
|
trunkScale = sy;
|
|
919
940
|
}
|
|
@@ -1112,7 +1133,31 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1112
1133
|
p.throwIfAny();
|
|
1113
1134
|
|
|
1114
1135
|
// The switches: with every target known, each set's distance and its spans.
|
|
1115
|
-
const
|
|
1136
|
+
const shortInfo = opts.short !== 'off' ? { cutoff: opts.short, fractions: new Map([...fractions].map(([b, info]) => [b, info.fraction])) } : undefined;
|
|
1137
|
+
const handOffsets = new Map<string, Array<[number, number]>>();
|
|
1138
|
+
{
|
|
1139
|
+
const rs = series.get('r_shoulder');
|
|
1140
|
+
const ls = series.get('l_shoulder');
|
|
1141
|
+
for (const c of map.chains) {
|
|
1142
|
+
if (c.reach || c.joints.length < 2) continue;
|
|
1143
|
+
const sh = series.get(c.joints[0]!);
|
|
1144
|
+
const wr = series.get(c.joints[c.joints.length - 1]!);
|
|
1145
|
+
if (sh === undefined || wr === undefined) continue;
|
|
1146
|
+
const arr: Array<[number, number]> = [];
|
|
1147
|
+
for (let i = 0; i < n; i++) {
|
|
1148
|
+
const sc = rs !== undefined && ls !== undefined
|
|
1149
|
+
? Math.hypot(ls[i]![0] - rs[i]![0], ls[i]![1] - rs[i]![1])
|
|
1150
|
+
: c.joints.slice(0, -1).reduce((s, j, k) => {
|
|
1151
|
+
const p0 = series.get(j)?.[i];
|
|
1152
|
+
const p1 = series.get(c.joints[k + 1]!)?.[i];
|
|
1153
|
+
return p0 !== undefined && p1 !== undefined ? s + Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) : s;
|
|
1154
|
+
}, 0);
|
|
1155
|
+
arr.push(sc > 1e-6 ? [(wr[i]![0] - sh[i]![0]) / sc, (wr[i]![1] - sh[i]![1]) / sc] : [0, 0]);
|
|
1156
|
+
}
|
|
1157
|
+
handOffsets.set(c.name, arr);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
const sw = selectSwitches(map, targets, keyTimes, keyTimes[n - 1]!, slots, { ...opts, shortInfo, handOffsets });
|
|
1116
1161
|
|
|
1117
1162
|
// The range clamp, outside the spans of a set depicting the bone's chain. Parents
|
|
1118
1163
|
// first, so a bone below takes its clamped parent into its key; a reach is aimed
|
|
@@ -1157,9 +1202,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1157
1202
|
}
|
|
1158
1203
|
p.throwIfAny();
|
|
1159
1204
|
|
|
1160
|
-
// #111: the head chain's nod channel — the eyes' vertical travel against the
|
|
1161
|
-
// neck, measured in the rig's own units
|
|
1162
|
-
// (whose rotate track carries roll only).
|
|
1205
|
+
// #111 / #181: the head chain's nod channel — the eyes' vertical travel against the
|
|
1206
|
+
// neck, measured in the rig's own units against a detrended running median (window ≈ 1 s),
|
|
1207
|
+
// keyed as a translate on the head bone (whose rotate track carries roll only).
|
|
1163
1208
|
for (const c of map.chains) {
|
|
1164
1209
|
for (let k = 0; k + 1 < c.joints.length; k++) {
|
|
1165
1210
|
const ja = c.joints[k]!;
|
|
@@ -1190,25 +1235,52 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1190
1235
|
const bone = rest.get(hb)!;
|
|
1191
1236
|
const parent = bone.parent === null ? null : rest.get(bone.parent)!;
|
|
1192
1237
|
let neckRestLen = 0;
|
|
1193
|
-
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1238
|
+
if (rest.has('neck') && descends(rest, hb, 'neck')) {
|
|
1239
|
+
const nk = rest.get('neck')!;
|
|
1240
|
+
neckRestLen = Math.hypot(bone.worldX - nk.worldX, bone.worldY - nk.worldY);
|
|
1241
|
+
}
|
|
1242
|
+
if (neckRestLen <= 1e-6) {
|
|
1243
|
+
for (let a = parent; a !== null; a = a.parent === null ? null : rest.get(a.parent)!) {
|
|
1244
|
+
const d = Math.hypot(bone.worldX - a.worldX, bone.worldY - a.worldY);
|
|
1245
|
+
if (d > 1e-6) {
|
|
1246
|
+
neckRestLen = d;
|
|
1247
|
+
break;
|
|
1248
|
+
}
|
|
1198
1249
|
}
|
|
1199
1250
|
}
|
|
1200
1251
|
const maxNod = !inPlace && neckRestLen > 1e-6 ? 0.02 * neckRestLen : Infinity;
|
|
1201
|
-
const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1])
|
|
1202
|
-
const
|
|
1252
|
+
const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]));
|
|
1253
|
+
const halfWin = Math.max(1, Math.round(0.5 / Math.max(1e-6, (keyTimes[n - 1]! - keyTimes[0]!) / Math.max(1, n - 1))));
|
|
1254
|
+
const nodMed = maxNod < Infinity && n > 2 * halfWin
|
|
1255
|
+
? refEN.map((_, i) => median(refEN.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1))))
|
|
1256
|
+
: refEN.map(() => refEN[0]!);
|
|
1257
|
+
const nodBase = maxNod < Infinity && n > 2 * halfWin
|
|
1258
|
+
? nodMed.map((_, i) => {
|
|
1259
|
+
const sl = nodMed.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1));
|
|
1260
|
+
return sl.reduce((a, b) => a + b, 0) / sl.length;
|
|
1261
|
+
})
|
|
1262
|
+
: nodMed;
|
|
1263
|
+
const rawNod = refEN.map((v, i) => (v - nodBase[i]!) * nodScale!);
|
|
1264
|
+
const p90Nod = maxNod < Infinity ? percentile(rawNod.map((v) => Math.abs(v)), 0.92) : 0;
|
|
1265
|
+
const kneeNod = 0.82 * maxNod;
|
|
1266
|
+
const dy = rawNod.map((v) => {
|
|
1267
|
+
if (p90Nod > maxNod && Math.abs(v) > kneeNod) {
|
|
1268
|
+
const tail = kneeNod + (Math.abs(v) - kneeNod) * ((maxNod - kneeNod) / (p90Nod - kneeNod));
|
|
1269
|
+
return Math.sign(v) * Math.min(maxNod, tail);
|
|
1270
|
+
}
|
|
1271
|
+
return Math.max(-maxNod, Math.min(maxNod, v));
|
|
1272
|
+
});
|
|
1203
1273
|
if (dy.some((v) => Math.abs(r3(v)) > 0)) {
|
|
1204
1274
|
const keys: MotionKey[] = [];
|
|
1205
1275
|
const nod: Pt[] = [];
|
|
1276
|
+
const ref0 = refEN[0]! / (poseScale?.[0] ?? 1);
|
|
1206
1277
|
for (let i = 0; i < n; i++) {
|
|
1207
1278
|
const wy = dy[i]!;
|
|
1279
|
+
const wy0 = Math.max(-maxNod, Math.min(maxNod, (refEN[i]! / (poseScale?.[i] ?? 1) - ref0) * nodScale!));
|
|
1208
1280
|
let lx = 0;
|
|
1209
1281
|
let ly = r3(wy);
|
|
1210
1282
|
let awx = 0;
|
|
1211
|
-
let awy =
|
|
1283
|
+
let awy = r3(wy0);
|
|
1212
1284
|
if (parent !== null) {
|
|
1213
1285
|
const r = turnAbove(parent.name, i) / DEG;
|
|
1214
1286
|
const pa = Math.cos(r) * parent.a - Math.sin(r) * parent.c;
|
|
@@ -1218,8 +1290,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1218
1290
|
const det = pa * pd - pb * pc;
|
|
1219
1291
|
lx = r3((-pb * wy) / det);
|
|
1220
1292
|
ly = r3((pa * wy) / det);
|
|
1221
|
-
|
|
1222
|
-
|
|
1293
|
+
const lx0 = r3((-pb * wy0) / det);
|
|
1294
|
+
const ly0 = r3((pa * wy0) / det);
|
|
1295
|
+
awx = pa * lx0 + pb * ly0;
|
|
1296
|
+
awy = pc * lx0 + pd * ly0;
|
|
1223
1297
|
}
|
|
1224
1298
|
keys.push({ t: keyTimes[i]!, v: [lx, ly] });
|
|
1225
1299
|
nod.push([awx, awy]);
|
|
@@ -1234,8 +1308,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1234
1308
|
if (headNodTrack !== null) break;
|
|
1235
1309
|
}
|
|
1236
1310
|
|
|
1237
|
-
// --raised-by-head (#62,
|
|
1238
|
-
//
|
|
1311
|
+
// --raised-by-head (#62, #179): while a set shows whose key skeleton's wrist lies within
|
|
1312
|
+
// `RAISED_WRIST_EYES` (2) eye distances of the head centre, its wrist goes where the
|
|
1313
|
+
// reference's is against the head, in eye distances (each side's median over the span). Solved last
|
|
1239
1314
|
// of the bones: every bone above the arm, the head and the trunk's reach included, has its final target
|
|
1240
1315
|
// (range clamp done), so the head and the shoulder are posed from them frame by frame; the arm moves
|
|
1241
1316
|
// neither. A target out of reach is placed at the reach limit along the head-relative direction
|
|
@@ -1258,14 +1333,69 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1258
1333
|
const b = posedPoint(an.points[1], i);
|
|
1259
1334
|
return { centre: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], dist: Math.hypot(b[0] - a[0], b[1] - a[1]) };
|
|
1260
1335
|
};
|
|
1336
|
+
const isChainHeadPlaced = (set: (typeof map.alternates)[number], c: Chain, drawn: number | undefined): boolean => {
|
|
1337
|
+
if (headOk === null) return false;
|
|
1338
|
+
const wJoint = c.joints[c.joints.length - 1];
|
|
1339
|
+
const ea = set.pose[headOk.joints[0]];
|
|
1340
|
+
const eb = set.pose[headOk.joints[1]];
|
|
1341
|
+
const wr = wJoint !== undefined ? set.pose[wJoint] : undefined;
|
|
1342
|
+
if (ea !== undefined && eb !== undefined && wr !== undefined) {
|
|
1343
|
+
const ed = Math.hypot(eb[0] - ea[0], eb[1] - ea[1]);
|
|
1344
|
+
if (ed > 1e-6) {
|
|
1345
|
+
const hc: Pt = [(ea[0] + eb[0]) / 2, (ea[1] + eb[1]) / 2];
|
|
1346
|
+
return Math.hypot(wr[0] - hc[0], wr[1] - hc[1]) <= RAISED_WRIST_EYES * ed;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (c.bones.length === 2 && !c.reach && !c.aim.some((x) => x.includes('>')) && c.joints.length >= 3) {
|
|
1350
|
+
const ha0 = rigPoint(rest, headOk.points[0]);
|
|
1351
|
+
const hb0 = rigPoint(rest, headOk.points[1]);
|
|
1352
|
+
const S0 = rigPoint(rest, c.bones[0]!);
|
|
1353
|
+
const E0 = rigPoint(rest, c.bones[1]!);
|
|
1354
|
+
const wSpec = c.aim[1] === 'x' ? `${c.bones[1]!}:tip` : c.aim[1]!;
|
|
1355
|
+
const W0 = rigPoint(rest, wSpec);
|
|
1356
|
+
const p0 = set.pose[c.joints[0]!];
|
|
1357
|
+
const p1 = set.pose[c.joints[1]!];
|
|
1358
|
+
const p2 = set.pose[c.joints[2]!];
|
|
1359
|
+
if (ha0 !== null && hb0 !== null && S0 !== null && E0 !== null && W0 !== null && p0 !== undefined && p1 !== undefined && p2 !== undefined) {
|
|
1360
|
+
const rigEye0 = Math.hypot(hb0[0] - ha0[0], hb0[1] - ha0[1]);
|
|
1361
|
+
const l0 = Math.hypot(E0[0] - S0[0], E0[1] - S0[1]);
|
|
1362
|
+
const l1 = Math.hypot(W0[0] - E0[0], W0[1] - E0[1]);
|
|
1363
|
+
if (rigEye0 > 1e-6) {
|
|
1364
|
+
if (l0 / rigEye0 > 3.5) return drawn !== undefined && isRaised(drawn);
|
|
1365
|
+
const H0: Pt = [(ha0[0] + hb0[0]) / 2, (ha0[1] + hb0[1]) / 2];
|
|
1366
|
+
const k0 = pictureAngle(p0, p1) / DEG;
|
|
1367
|
+
const k1 = pictureAngle(p1, p2) / DEG;
|
|
1368
|
+
const Wx = S0[0] + l0 * Math.cos(k0) + l1 * Math.cos(k1);
|
|
1369
|
+
const Wy = S0[1] + l0 * Math.sin(k0) + l1 * Math.sin(k1);
|
|
1370
|
+
return (
|
|
1371
|
+
Math.hypot(Wx - H0[0], Wy - H0[1]) <= RAISED_WRIST_EYES * rigEye0 ||
|
|
1372
|
+
(Math.abs(Wy - H0[1]) <= RAISED_WRIST_EYES * rigEye0 && Math.hypot(Wx - S0[0], Wy - S0[1]) <= RAISED_WRIST_EYES * rigEye0)
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
return drawn !== undefined && isRaised(drawn);
|
|
1378
|
+
};
|
|
1261
1379
|
for (const s of sw.switches) {
|
|
1262
1380
|
const set = map.alternates.find((a) => a.name === s.set)!;
|
|
1263
1381
|
const art: ArtRange[] = [];
|
|
1264
1382
|
for (const cn of s.chains) {
|
|
1265
1383
|
const c = map.chains.find((x) => x.name === cn)!;
|
|
1266
1384
|
const drawn = set.drawn[c.bones[0]!];
|
|
1267
|
-
const up =
|
|
1385
|
+
const up = isChainHeadPlaced(set, c, drawn);
|
|
1268
1386
|
const low = hipsOk !== null && set.place?.chains.includes(c.name) === true;
|
|
1387
|
+
if (!up && !low && headOk !== null && drawn !== undefined && isRaised(drawn) && c.bones.length >= 1 && c.joints.length >= 2) {
|
|
1388
|
+
const p0 = set.pose[c.joints[0]!];
|
|
1389
|
+
const p1 = set.pose[c.joints[1]!];
|
|
1390
|
+
if (p0 !== undefined && p1 !== undefined) {
|
|
1391
|
+
const key0 = pictureAngle(p0, p1);
|
|
1392
|
+
const t0 = targets.get(c.bones[0]!)!;
|
|
1393
|
+
for (let i = s.frames[0]; i <= s.frames[1]; i++) {
|
|
1394
|
+
const diff = Math.max(-3, Math.min(3, wrap(t0[i]! - key0)));
|
|
1395
|
+
t0[i] = wrap(drawn + diff);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1269
1399
|
if (up && low) {
|
|
1270
1400
|
p.add('PLACE_BOTH', `set "${set.name}" chain "${c.name}": --raised-by-head places its wrist against the head (bone "${c.bones[0]}" drawn at ${drawn}°, raised), and "place": "by-hip" against the hip (its key skeleton's wrist within one hip width of the mid-hip); a wrist is placed against one — drop "place" from the set or the flag`);
|
|
1271
1401
|
continue;
|
|
@@ -1337,13 +1467,47 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1337
1467
|
const w = wristOf(i);
|
|
1338
1468
|
job.reference.push(ref);
|
|
1339
1469
|
job.before.push([(w[0] - h.centre[0]) / rigEye, (w[1] - h.centre[1]) / rigEye]);
|
|
1340
|
-
const target: Pt = [h.centre[0] + ref[0] * rigEye, h.centre[1] + ref[1] * rigEye];
|
|
1341
1470
|
const shoulder = posedOrigin(b0, i);
|
|
1342
|
-
|
|
1343
|
-
|
|
1471
|
+
const target: Pt = [h.centre[0] + ref[0] * rigEye, h.centre[1] + ref[1] * rigEye];
|
|
1472
|
+
let solveTarget: Pt = target;
|
|
1473
|
+
if (up && l0 / rigEye <= 3.5 && (turn0 !== undefined || turn1 !== undefined)) {
|
|
1474
|
+
const side0: 1 | -1 = wrap(t1[i]! + d1 - (t0[i]! + d0)) >= 0 ? 1 : -1;
|
|
1475
|
+
const place0 = reachLimit(h.centre, solveTarget, shoulder, l0, l1);
|
|
1476
|
+
const sol0 = twoBone(shoulder, place0.point, l0, l1, side0);
|
|
1477
|
+
if (sol0.upper !== null) {
|
|
1478
|
+
let a0Cl = wrap(sol0.upper - d0);
|
|
1479
|
+
let a1Cl = wrap(sol0.fore - d1);
|
|
1480
|
+
if (turn0 !== undefined && set.drawn[b0] !== undefined) {
|
|
1481
|
+
const tt0 = wrap(a0Cl - set.drawn[b0]!);
|
|
1482
|
+
const cl0 = Math.min(Math.max(tt0, turn0[0]), turn0[1]);
|
|
1483
|
+
if (Math.abs(cl0 - tt0) > RANGE_SLACK) {
|
|
1484
|
+
a0Cl = wrap(set.drawn[b0]! + cl0);
|
|
1485
|
+
const r0Cl = (a0Cl + d0) / DEG;
|
|
1486
|
+
const ECl: Pt = [shoulder[0] + l0 * Math.cos(r0Cl), shoulder[1] + l0 * Math.sin(r0Cl)];
|
|
1487
|
+
a1Cl = wrap(Math.atan2(place0.point[1] - ECl[1], place0.point[0] - ECl[0]) * DEG - d1);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if (turn1 !== undefined && set.drawn[b1] !== undefined) {
|
|
1491
|
+
const asked1 = wrap(a1Cl - set.drawn[b1]!);
|
|
1492
|
+
if (overflow(asked1, turn1) > RANGE_SLACK) {
|
|
1493
|
+
a1Cl = wrap(set.drawn[b1]! + Math.min(Math.max(asked1, turn1[0]), turn1[1]));
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
const r0Cl = (a0Cl + d0) / DEG;
|
|
1497
|
+
const r1Cl = (a1Cl + d1) / DEG;
|
|
1498
|
+
const WCl: Pt = [shoulder[0] + l0 * Math.cos(r0Cl) + l1 * Math.cos(r1Cl), shoulder[1] + l0 * Math.sin(r0Cl) + l1 * Math.sin(r1Cl)];
|
|
1499
|
+
const miss0 = Math.hypot(place0.point[0] - WCl[0], place0.point[1] - WCl[1]) / rigEye;
|
|
1500
|
+
if (miss0 > 0.86) {
|
|
1501
|
+
const k = (0.86 * rigEye) / Math.hypot(place0.point[0] - WCl[0], place0.point[1] - WCl[1]);
|
|
1502
|
+
solveTarget = [WCl[0] + (place0.point[0] - WCl[0]) * k, WCl[1] + (place0.point[1] - WCl[1]) * k];
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
job.farthest = Math.max(job.farthest, Math.hypot(solveTarget[0] - shoulder[0], solveTarget[1] - shoulder[1]));
|
|
1507
|
+
const place = reachLimit(h.centre, solveTarget, shoulder, l0, l1);
|
|
1344
1508
|
const P = place.point;
|
|
1345
1509
|
job.placed.push([(P[0] - h.centre[0]) / rigEye, (P[1] - h.centre[1]) / rigEye]);
|
|
1346
|
-
if (place.how !== 'in') job.short.push({ i, how: place.how, shortfall: Math.hypot(
|
|
1510
|
+
if (place.how !== 'in') job.short.push({ i, how: place.how, shortfall: Math.hypot(solveTarget[0] - P[0], solveTarget[1] - P[1]) / rigEye });
|
|
1347
1511
|
// The pose's bend side, read on the vectors the two bones carry.
|
|
1348
1512
|
const side: 1 | -1 = wrap(t1[i]! + d1 - (t0[i]! + d0)) >= 0 ? 1 : -1;
|
|
1349
1513
|
const sol = twoBone(shoulder, P, l0, l1, side);
|
|
@@ -1384,7 +1548,14 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1384
1548
|
const r0 = (a0 + d0) / DEG;
|
|
1385
1549
|
const r1 = (a1 + d1) / DEG;
|
|
1386
1550
|
const W: Pt = [shoulder[0] + l0 * Math.cos(r0) + l1 * Math.cos(r1), shoulder[1] + l0 * Math.sin(r0) + l1 * Math.sin(r1)];
|
|
1387
|
-
|
|
1551
|
+
if (up && l0 / rigEye <= 3.5 && Math.hypot(W[0] - P[0], W[1] - P[1]) / rigEye > 0.86) {
|
|
1552
|
+
const k = (0.86 * rigEye) / Math.hypot(W[0] - P[0], W[1] - P[1]);
|
|
1553
|
+
const Pc: Pt = [W[0] + (P[0] - W[0]) * k, W[1] + (P[1] - W[1]) * k];
|
|
1554
|
+
job.placed[job.placed.length - 1] = [(Pc[0] - h.centre[0]) / rigEye, (Pc[1] - h.centre[1]) / rigEye];
|
|
1555
|
+
heldMiss.push(held ? 0.86 : 0);
|
|
1556
|
+
} else {
|
|
1557
|
+
heldMiss.push(held ? Math.hypot(W[0] - P[0], W[1] - P[1]) / rigEye : 0);
|
|
1558
|
+
}
|
|
1388
1559
|
t0[i] = a0;
|
|
1389
1560
|
t1[i] = a1;
|
|
1390
1561
|
}
|
|
@@ -1755,6 +1926,79 @@ export function holdShort(angles: number[], fraction: readonly number[], cutoff:
|
|
|
1755
1926
|
return out;
|
|
1756
1927
|
}
|
|
1757
1928
|
|
|
1929
|
+
/**
|
|
1930
|
+
* Hold and interpolate across unconfident spans on a horizontal chain (`isAcross`,
|
|
1931
|
+
* e.g. `r_hip → l_hip`, `r_eye → l_eye`, `r_shoulder → l_shoulder`, rig-c/pose#184),
|
|
1932
|
+
* in place. A frame is unconfident when its picture length over its median is under
|
|
1933
|
+
* `shortCutoff` (with `--short`) or when its angle turns more than `flipThreshold`
|
|
1934
|
+
* from the last confident angle or reverses more than 90° from the line's rest
|
|
1935
|
+
* direction `restDir` (with `--flip`). Each contiguous unconfident run has its
|
|
1936
|
+
* angles interpolated between the confident frames either side (flat at the ends),
|
|
1937
|
+
* the short way round, exactly as `holdShort` does on limb chains.
|
|
1938
|
+
*/
|
|
1939
|
+
export function holdAcross(
|
|
1940
|
+
angles: number[],
|
|
1941
|
+
fraction: readonly number[],
|
|
1942
|
+
shortCutoff: number | 'off',
|
|
1943
|
+
flipThreshold: number | 'off',
|
|
1944
|
+
restDir = 0,
|
|
1945
|
+
): ShortSpan[] {
|
|
1946
|
+
const n = angles.length;
|
|
1947
|
+
if (n === 0 || (shortCutoff === 'off' && flipThreshold === 'off')) return [];
|
|
1948
|
+
const raw = [...angles];
|
|
1949
|
+
const unconf = new Array<boolean>(n).fill(false);
|
|
1950
|
+
let lastConfident = raw[0]!;
|
|
1951
|
+
for (let i = 0; i < n; i++) {
|
|
1952
|
+
const s = shortCutoff !== 'off' && fraction[i]! < shortCutoff;
|
|
1953
|
+
const rev = Math.abs(wrap(raw[i]! - restDir)) > 90;
|
|
1954
|
+
if (!s && !rev) {
|
|
1955
|
+
lastConfident = raw[i]!;
|
|
1956
|
+
break;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
for (let i = 0; i < n; i++) {
|
|
1960
|
+
const isShort = shortCutoff !== 'off' && fraction[i]! < shortCutoff;
|
|
1961
|
+
const isFlipped =
|
|
1962
|
+
flipThreshold !== 'off' &&
|
|
1963
|
+
(Math.abs(wrap(raw[i]! - lastConfident)) > flipThreshold || Math.abs(wrap(raw[i]! - restDir)) > 90);
|
|
1964
|
+
if (isShort || isFlipped) {
|
|
1965
|
+
unconf[i] = true;
|
|
1966
|
+
} else {
|
|
1967
|
+
lastConfident = raw[i]!;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
const out: ShortSpan[] = [];
|
|
1971
|
+
for (let i = 0; i < n; i++) {
|
|
1972
|
+
if (!unconf[i]) continue;
|
|
1973
|
+
let j = i;
|
|
1974
|
+
while (j + 1 < n && unconf[j + 1]) j++;
|
|
1975
|
+
const lo = i - 1;
|
|
1976
|
+
const hi = j + 1;
|
|
1977
|
+
let minFraction = fraction[i]!;
|
|
1978
|
+
for (let k = i + 1; k <= j; k++) minFraction = Math.min(minFraction, fraction[k]!);
|
|
1979
|
+
let maxTurn = 0;
|
|
1980
|
+
for (let k = Math.max(1, i); k <= Math.min(n - 1, j + 1); k++) {
|
|
1981
|
+
const jmp = wrap(raw[k]! - raw[k - 1]!);
|
|
1982
|
+
if (Math.abs(jmp) > Math.abs(maxTurn)) maxTurn = jmp;
|
|
1983
|
+
}
|
|
1984
|
+
if (lo >= 0 || hi < n) {
|
|
1985
|
+
const a = lo >= 0 ? angles[lo]! : angles[hi]!;
|
|
1986
|
+
const b = hi < n ? angles[hi]! : a;
|
|
1987
|
+
const turn = wrap(b - a);
|
|
1988
|
+
for (let k = i; k <= j; k++) angles[k] = lo < 0 ? b : wrap(a + (turn * (k - lo)) / (hi - lo));
|
|
1989
|
+
}
|
|
1990
|
+
const revThreshold = flipThreshold !== 'off' ? flipThreshold : 90;
|
|
1991
|
+
out.push({
|
|
1992
|
+
frames: [i, j],
|
|
1993
|
+
minFraction,
|
|
1994
|
+
across: true,
|
|
1995
|
+
...(Math.abs(maxTurn) > revThreshold ? { reversed: r3(maxTurn) } : {}),
|
|
1996
|
+
});
|
|
1997
|
+
i = j;
|
|
1998
|
+
}
|
|
1999
|
+
return out;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
1758
2002
|
/** Short spans `gap` frames apart or fewer, as one run each: its first and last frame, and the lowest fraction. */
|
|
1759
2003
|
export function mergeRuns(spans: readonly ShortSpan[], gap: number): ShortSpan[] {
|
|
1760
2004
|
const out: ShortSpan[] = [];
|
|
@@ -1854,9 +2098,9 @@ export function capShortSteps(
|
|
|
1854
2098
|
const spanOf = (frames: [number, number], times: readonly number[]): string =>
|
|
1855
2099
|
frames[0] === frames[1] ? `frame ${frames[0]} (t=${times[frames[0]]!.toFixed(3)}s)` : `frames ${frames[0]}–${frames[1]} (t=${times[frames[0]]!.toFixed(3)}–${times[frames[1]]!.toFixed(3)}s)`;
|
|
1856
2100
|
|
|
1857
|
-
export function shortLine(s: Short, times: readonly number[], cutoff: number): string {
|
|
2101
|
+
export function shortLine(s: Short, times: readonly number[], cutoff: number | 'off'): string {
|
|
2102
|
+
const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
|
|
1858
2103
|
if (s.step !== undefined && s.asked !== undefined) {
|
|
1859
|
-
const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
|
|
1860
2104
|
return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: picture length down to ${s.minFraction.toFixed(2)} of its median, under ${SHORT_STEP_FRACTION.toFixed(2)}, turned ${sg(s.asked)} in one frame (over ${SHORT_STEP_CAP}°); spread to ${sg(s.step)} per frame`;
|
|
1861
2105
|
}
|
|
1862
2106
|
const lo = s.frames[0] - 1;
|
|
@@ -1866,6 +2110,12 @@ export function shortLine(s: Short, times: readonly number[], cutoff: number): s
|
|
|
1866
2110
|
: lo < 0 ? `held flat at frame ${hi}'s angle (the pose starts short)`
|
|
1867
2111
|
: hi >= times.length ? `held flat at frame ${lo}'s angle (the pose ends short)`
|
|
1868
2112
|
: `interpolated from frame ${lo}'s angle to frame ${hi}'s, the short way round`;
|
|
2113
|
+
if (s.across === true) {
|
|
2114
|
+
const shortWhy = cutoff !== 'off' && s.minFraction < cutoff ? `picture length down to ${s.minFraction.toFixed(2)} of its median, under ${cutoff}` : null;
|
|
2115
|
+
const revWhy = s.reversed !== undefined ? `reversed ${sg(s.reversed)} in one frame` : null;
|
|
2116
|
+
const why = shortWhy !== null && revWhy !== null ? `${shortWhy}, ${revWhy}` : shortWhy ?? `${revWhy ?? 'reversed'} (picture length down to ${s.minFraction.toFixed(2)} of its median)`;
|
|
2117
|
+
return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: ${why}; its direction is not read — ${held}`;
|
|
2118
|
+
}
|
|
1869
2119
|
return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: picture length down to ${s.minFraction.toFixed(2)} of its median, under ${cutoff}; its direction is not read — ${held}`;
|
|
1870
2120
|
}
|
|
1871
2121
|
|
package/src/run.ts
CHANGED
|
@@ -135,8 +135,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
135
135
|
else if (a.plantFeet === true) say(' CAMERA none in the sequence: --plant-feet reads the picture as a fixed camera, so a zoom or a pan in the video moves the floor with it and the floor may step with the picture (tools/scan.py reads the camera, rig-c/pose#63)');
|
|
136
136
|
if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
|
|
137
137
|
for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
|
|
138
|
+
for (const s of r.short) say(` ${s.across === true ? 'HOLD ' : 'SHORT '} ${shortLine(s, r.times, a.short)}`);
|
|
138
139
|
if (a.short !== 'off') {
|
|
139
|
-
for (const s of r.short) say(` SHORT ${shortLine(s, r.times, a.short)}`);
|
|
140
140
|
for (const e of r.endOn) say(` END-ON ${endOnLine(e, r.times, a.short)}`);
|
|
141
141
|
}
|
|
142
142
|
if (a.flip !== 'off') for (const fl of r.flips) say(` FLIP ${flipLine(fl, r.times, a.flip)}`);
|
package/src/switch.ts
CHANGED
|
@@ -58,6 +58,15 @@ import type { MotionKey, MotionTrack } from './retarget.ts';
|
|
|
58
58
|
import { pictureAngle, wrap } from './retarget.ts';
|
|
59
59
|
import type { MeshFolds } from './rigc.ts';
|
|
60
60
|
|
|
61
|
+
export const SWITCH_LEAVE_HYSTERESIS = 15;
|
|
62
|
+
export const SHORT_SWITCH_WEIGHT = 0.5;
|
|
63
|
+
|
|
64
|
+
export interface ShortSwitchInfo {
|
|
65
|
+
cutoff: number;
|
|
66
|
+
fractions: ReadonlyMap<string, readonly number[]>;
|
|
67
|
+
weight?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
61
70
|
export interface SwitchOptions {
|
|
62
71
|
/** Degrees: the largest segment difference at which a set may show. */
|
|
63
72
|
switchTolerance: number;
|
|
@@ -65,6 +74,12 @@ export interface SwitchOptions {
|
|
|
65
74
|
switchMin: number;
|
|
66
75
|
/** Where a boundary is keyed: 'first' (the CLI's default) keeps the frame the selection gave it; 'peak' moves it to the fastest qualifying frame of its window (#31). */
|
|
67
76
|
switchAt: 'peak' | 'first';
|
|
77
|
+
/** Degrees above `switchTolerance` at which an entered set is left (#180; default: `SWITCH_LEAVE_HYSTERESIS` = 15). */
|
|
78
|
+
switchHysteresis?: number;
|
|
79
|
+
/** Foreshortened limb fractions for reduced weight in switch distance (#180). */
|
|
80
|
+
shortInfo?: ShortSwitchInfo;
|
|
81
|
+
/** Per chain, the reference pose's normalised wrist-from-shoulder offset on each frame, `(wrist - shoulder) / scale`, y down (#180). */
|
|
82
|
+
handOffsets?: ReadonlyMap<string, ReadonlyArray<readonly [number, number]>>;
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
/** One boundary of a span: where the selection put it, where it is keyed, and how fast the chains it changes move on each. */
|
|
@@ -158,16 +173,41 @@ export interface Switches {
|
|
|
158
173
|
chainHolds?: Array<{ set: string; chain: string; frames: [number, number] }>;
|
|
159
174
|
}
|
|
160
175
|
|
|
161
|
-
/** Per set and chain, its distance to the pose on every frame: the largest segment difference over that chain. */
|
|
162
|
-
export function chainDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number): Map<string, Map<string, number[]>> {
|
|
176
|
+
/** Per set and chain, its distance to the pose on every frame: the largest segment difference over that chain (with foreshortened `SHORT` segments weighted by `SHORT_SWITCH_WEIGHT`, #180). */
|
|
177
|
+
export function chainDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number, shortInfo?: ShortSwitchInfo): Map<string, Map<string, number[]>> {
|
|
163
178
|
const out = new Map<string, Map<string, number[]>>();
|
|
179
|
+
const wShort = shortInfo?.weight ?? SHORT_SWITCH_WEIGHT;
|
|
164
180
|
for (const a of map.alternates) {
|
|
165
181
|
const byChain = new Map<string, number[]>();
|
|
166
182
|
for (const cn of a.chains) {
|
|
167
183
|
const c = map.chains.find((x) => x.name === cn)!;
|
|
168
|
-
const
|
|
184
|
+
const foldedKey = c.joints.length >= 3 && (() => {
|
|
185
|
+
const j0 = a.pose[c.joints[0]!];
|
|
186
|
+
const j1 = a.pose[c.joints[1]!];
|
|
187
|
+
const j2 = a.pose[c.joints[c.joints.length - 1]!];
|
|
188
|
+
return j0 !== undefined && j1 !== undefined && j2 !== undefined && Math.hypot(j2[0] - j0[0], j2[1] - j0[1]) <= Math.hypot(j1[0] - j0[0], j1[1] - j0[1]);
|
|
189
|
+
})();
|
|
190
|
+
const segs = c.bones.map((b, k) => {
|
|
191
|
+
const frac = shortInfo?.fractions.get(b);
|
|
192
|
+
const hasShort = frac !== undefined && frac.some((f) => f < shortInfo!.cutoff);
|
|
193
|
+
return {
|
|
194
|
+
asked: targets.get(b)!,
|
|
195
|
+
key: pictureAngle(a.pose[c.joints[k]!]!, a.pose[c.joints[k + 1]!]!),
|
|
196
|
+
frac,
|
|
197
|
+
alwaysShort: foldedKey && hasShort,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
169
200
|
const d: number[] = [];
|
|
170
|
-
for (let i = 0; i < n; i++)
|
|
201
|
+
for (let i = 0; i < n; i++) {
|
|
202
|
+
d.push(
|
|
203
|
+
Math.max(
|
|
204
|
+
...segs.map((s) => {
|
|
205
|
+
const isShort = s.alwaysShort || (s.frac !== undefined && s.frac[i]! < shortInfo!.cutoff);
|
|
206
|
+
return (isShort ? wShort : 1) * Math.abs(wrap(s.asked[i]! - s.key));
|
|
207
|
+
}),
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
171
211
|
byChain.set(cn, d);
|
|
172
212
|
}
|
|
173
213
|
out.set(a.name, byChain);
|
|
@@ -176,8 +216,8 @@ export function chainDistances(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
176
216
|
}
|
|
177
217
|
|
|
178
218
|
/** Per set, its distance to the pose on every frame: the largest segment difference over its chains. */
|
|
179
|
-
export function setDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number): Map<string, number[]> {
|
|
180
|
-
const byChain = chainDistances(map, targets, n);
|
|
219
|
+
export function setDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number, shortInfo?: ShortSwitchInfo): Map<string, number[]> {
|
|
220
|
+
const byChain = chainDistances(map, targets, n, shortInfo);
|
|
181
221
|
const out = new Map<string, number[]>();
|
|
182
222
|
for (const a of map.alternates) {
|
|
183
223
|
const cs = a.chains.map((cn) => byChain.get(a.name)!.get(cn)!);
|
|
@@ -276,7 +316,17 @@ function groupLayering(group: readonly AlternateSet[], map: JointMap, slots: Rig
|
|
|
276
316
|
if (bySide !== undefined) slotChain.set(s, bySide);
|
|
277
317
|
else if (groupChains.length === 1) slotChain.set(s, groupChains[0]!);
|
|
278
318
|
}
|
|
279
|
-
|
|
319
|
+
const isChainBehindBase = (a: AlternateSet, cn: string): boolean => {
|
|
320
|
+
if (!a.chains.includes(cn)) return false;
|
|
321
|
+
if (isBehind(a)) return true;
|
|
322
|
+
const setupSlots = hiddenSetup.filter((s) => slotChain.get(s) === cn && slots.get(s)?.bone !== torsoSlot);
|
|
323
|
+
const setSlots = Object.keys(a.slots).filter((s) => slotChain.get(s) === cn && rank.has(s));
|
|
324
|
+
if (setupSlots.length === 0 || setSlots.length === 0) return false;
|
|
325
|
+
const boneSetup = setupSlots.filter((s) => boneOwner.has(slots.get(s)?.bone ?? ''));
|
|
326
|
+
const refSetup = boneSetup.length > 0 ? boneSetup : setupSlots;
|
|
327
|
+
return setupSlots.every((s) => a.hides.includes(s)) && Math.max(...setSlots.map((s) => rank.get(s)!)) < Math.min(...refSetup.map((s) => rank.get(s)!));
|
|
328
|
+
};
|
|
329
|
+
return { torsoSlot, torsoRank, touched, isBehind, isFront, isChainBehindBase, baseInFront, slotChain };
|
|
280
330
|
}
|
|
281
331
|
|
|
282
332
|
/**
|
|
@@ -365,6 +415,24 @@ function moveBoundaries(vals: ReadonlyArray<AlternateSet | null>, pos: readonly
|
|
|
365
415
|
/** The key time for a change that shows on frame i: half a frame before it, 0 on the first frame. */
|
|
366
416
|
const changeAt = (times: readonly number[], i: number): number => (i === 0 ? 0 : r6((times[i - 1]! + times[i]!) / 2));
|
|
367
417
|
|
|
418
|
+
function keyHandOffset(a: AlternateSet, map: JointMap, cn: string): [number, number] | null {
|
|
419
|
+
const c = map.chains.find((x) => x.name === cn);
|
|
420
|
+
if (c === undefined || c.joints.length < 2) return null;
|
|
421
|
+
const sh = a.pose[c.joints[0]!];
|
|
422
|
+
const wr = a.pose[c.joints[c.joints.length - 1]!];
|
|
423
|
+
if (sh === undefined || wr === undefined) return null;
|
|
424
|
+
const rs = a.pose.r_shoulder;
|
|
425
|
+
const ls = a.pose.l_shoulder;
|
|
426
|
+
const scale = rs !== undefined && ls !== undefined
|
|
427
|
+
? Math.hypot(ls[0] - rs[0], ls[1] - rs[1])
|
|
428
|
+
: c.joints.slice(0, -1).reduce((s, j, k) => {
|
|
429
|
+
const p0 = a.pose[j];
|
|
430
|
+
const p1 = a.pose[c.joints[k + 1]!];
|
|
431
|
+
return p0 !== undefined && p1 !== undefined ? s + Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) : s;
|
|
432
|
+
}, 0);
|
|
433
|
+
return scale > 1e-6 ? [(wr[0] - sh[0]) / scale, (wr[1] - sh[1]) / scale] : null;
|
|
434
|
+
}
|
|
435
|
+
|
|
368
436
|
/**
|
|
369
437
|
* Select the switches, and key them: per frame and per group of competing
|
|
370
438
|
* sets, the nearest within the tolerance; runs shorter than the minimum span
|
|
@@ -374,8 +442,9 @@ const changeAt = (times: readonly number[], i: number): number => (i === 0 ? 0 :
|
|
|
374
442
|
*/
|
|
375
443
|
export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, times: readonly number[], duration: number, slots: RigSlots, opts: SwitchOptions): Switches {
|
|
376
444
|
const n = times.length;
|
|
377
|
-
const byChain = chainDistances(map, targets, n);
|
|
378
|
-
const distances = setDistances(map, targets, n);
|
|
445
|
+
const byChain = chainDistances(map, targets, n, opts.shortInfo);
|
|
446
|
+
const distances = setDistances(map, targets, n, opts.shortInfo);
|
|
447
|
+
const hyst = opts.switchHysteresis ?? SWITCH_LEAVE_HYSTERESIS;
|
|
379
448
|
const switches: Switch[] = [];
|
|
380
449
|
const groups = competing(map.alternates);
|
|
381
450
|
for (const group of groups) {
|
|
@@ -419,6 +488,39 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
419
488
|
// minimum span exists to prevent (measured on the dancer: one-frame breaks at
|
|
420
489
|
// a blurred frame inside a span of one set).
|
|
421
490
|
bridgeSame(pick);
|
|
491
|
+
// #180: hysteresis on leaving a set — bridge internal excursions that stay within
|
|
492
|
+
// tolerance + hyst (never exceeding tolerance + hyst for >= switchMin), then drop
|
|
493
|
+
// runs shorter than switchMin, then hold trailing shelves within tolerance + hyst.
|
|
494
|
+
const useHyst = (a: AlternateSet) =>
|
|
495
|
+
hyst > 0 && (opts.switchHysteresis !== undefined || (a.chains.length >= 2 && opts.shortInfo !== undefined));
|
|
496
|
+
if (hyst > 0) {
|
|
497
|
+
const allowedOn = (a: AlternateSet, k: number) => a.frames === null || a.frames.some(([f, l]) => k >= f && k <= l);
|
|
498
|
+
for (let i = 0; i < n;) {
|
|
499
|
+
const a = pick[i];
|
|
500
|
+
let j = i;
|
|
501
|
+
while (j + 1 < n && pick[j + 1] === a) j++;
|
|
502
|
+
const before = i > 0 ? pick[i - 1] : null;
|
|
503
|
+
const after = j + 1 < n ? pick[j + 1] : null;
|
|
504
|
+
if (a === null && before !== null && before !== undefined && before === after && useHyst(before)) {
|
|
505
|
+
const ds = distances.get(before.name)!;
|
|
506
|
+
let leftLong = false;
|
|
507
|
+
for (let k = i; k <= j;) {
|
|
508
|
+
if (!allowedOn(before, k) || ds[k]! > opts.switchTolerance + hyst) {
|
|
509
|
+
let m = k;
|
|
510
|
+
while (m + 1 <= j && (!allowedOn(before, m + 1) || ds[m + 1]! > opts.switchTolerance + hyst)) m++;
|
|
511
|
+
const to = m + 1 < n ? times[m + 1]! : duration;
|
|
512
|
+
if (!allowedOn(before, k) || to - times[k]! >= opts.switchMin - 1e-9) {
|
|
513
|
+
leftLong = true;
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
k = m + 1;
|
|
517
|
+
} else k++;
|
|
518
|
+
}
|
|
519
|
+
if (!leftLong) for (let k = i; k <= j; k++) pick[k] = before;
|
|
520
|
+
}
|
|
521
|
+
i = j + 1;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
422
524
|
// A run shorter than the minimum span shows the base art.
|
|
423
525
|
for (let i = 0; i < n;) {
|
|
424
526
|
const a = pick[i];
|
|
@@ -428,6 +530,32 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
428
530
|
if (a !== null && a !== undefined && to - times[i]! < opts.switchMin - 1e-9) for (let k = i; k <= j; k++) pick[k] = null;
|
|
429
531
|
i = j + 1;
|
|
430
532
|
}
|
|
533
|
+
if (hyst > 0) {
|
|
534
|
+
const allowedOn = (a: AlternateSet, k: number) => a.frames === null || a.frames.some(([f, l]) => k >= f && k <= l);
|
|
535
|
+
const anyWithinTol = (a: AlternateSet, k: number) => a.chains.some((cn) => (byChain.get(a.name)?.get(cn)?.[k] ?? Infinity) <= opts.switchTolerance);
|
|
536
|
+
const hasShortAt = (a: AlternateSet, k: number) =>
|
|
537
|
+
opts.shortInfo !== undefined &&
|
|
538
|
+
Object.keys(a.drawn).some((b) => (opts.shortInfo!.fractions.get(b)?.[k] ?? 1) < 0.60);
|
|
539
|
+
for (let i = 0; i < n;) {
|
|
540
|
+
const a = pick[i];
|
|
541
|
+
let j = i;
|
|
542
|
+
while (j + 1 < n && pick[j + 1] === a) j++;
|
|
543
|
+
if (a !== null && a !== undefined && useHyst(a)) {
|
|
544
|
+
const ds = distances.get(a.name)!;
|
|
545
|
+
let l = i;
|
|
546
|
+
while (l - 1 >= 0 && pick[l - 1] === null && allowedOn(a, l - 1) && ds[l - 1]! <= opts.switchTolerance + hyst && anyWithinTol(a, l - 1) && hasShortAt(a, l - 1)) l--;
|
|
547
|
+
let m = j;
|
|
548
|
+
while (m + 1 < n && pick[m + 1] === null && allowedOn(a, m + 1) && ds[m + 1]! <= opts.switchTolerance + hyst && anyWithinTol(a, m + 1)) m++;
|
|
549
|
+
if (i - l >= 1 || m - j >= 2 || (m - j === 1 && hasShortAt(a, j + 1))) {
|
|
550
|
+
for (let k = l; k < i; k++) pick[k] = a;
|
|
551
|
+
for (let k = j + 1; k <= m; k++) pick[k] = a;
|
|
552
|
+
j = m;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
i = j + 1;
|
|
556
|
+
}
|
|
557
|
+
bridgeSame(pick);
|
|
558
|
+
}
|
|
431
559
|
// #136: a short front-set window (< 2 × switchMin) between two behind-set windows is dropped.
|
|
432
560
|
if (layer.torsoRank !== null) {
|
|
433
561
|
let dropped = false;
|
|
@@ -498,9 +626,8 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
498
626
|
for (let i = sw.frames[0]; i <= sw.frames[1]; i++) row[i] = o;
|
|
499
627
|
}
|
|
500
628
|
}
|
|
501
|
-
// #136/#139/#162: between two sets that
|
|
502
|
-
// neither set stays
|
|
503
|
-
// claim and whose arm is read in front — not a fallback.
|
|
629
|
+
// #136/#139/#162/#180: between two sets that draw an arm behind the base front setup (at least one behind the torso),
|
|
630
|
+
// a frame that belongs to neither set stays on the nearer set's layer (chosen by hand position when available, #180).
|
|
504
631
|
const chainHolds: Array<{ set: string; chain: string; frames: [number, number] }> = [];
|
|
505
632
|
for (const group of groups) {
|
|
506
633
|
const layer = groupLayering(group, map, slots);
|
|
@@ -523,11 +650,29 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
523
650
|
const sw1 = gSwitches[m + 1]!;
|
|
524
651
|
const set0 = group.find((a) => a.name === sw0.set)!;
|
|
525
652
|
const set1 = group.find((a) => a.name === sw1.set)!;
|
|
526
|
-
if (!layer.isBehind(set0)
|
|
653
|
+
if (!layer.isBehind(set0) && !layer.isBehind(set1)) continue;
|
|
527
654
|
const g0 = sw0.frames[1] + 1;
|
|
528
655
|
const g1 = sw1.frames[0] - 1;
|
|
529
656
|
if (g0 > g1) continue;
|
|
530
|
-
const chains = [...new Set([...set0.chains, ...set1.chains])];
|
|
657
|
+
const chains = [...new Set([...set0.chains, ...set1.chains])].filter((cn) => layer.isChainBehindBase(set0, cn) && layer.isChainBehindBase(set1, cn));
|
|
658
|
+
if (chains.length === 0) continue;
|
|
659
|
+
for (const cn of chains) {
|
|
660
|
+
if (!layer.isBehind(set0)) for (let i = sw0.frames[0]; i <= sw0.frames[1]; i++) cClamp.get(cn)![i] = set0;
|
|
661
|
+
if (!layer.isBehind(set1)) for (let i = sw1.frames[0]; i <= sw1.frames[1]; i++) cClamp.get(cn)![i] = set1;
|
|
662
|
+
}
|
|
663
|
+
const nearerOf = (cn: string, i: number, d0: number, d1: number): AlternateSet => {
|
|
664
|
+
if (set0 !== set1) {
|
|
665
|
+
const ref = opts.handOffsets?.get(cn)?.[i];
|
|
666
|
+
const k0 = keyHandOffset(set0, map, cn);
|
|
667
|
+
const k1 = keyHandOffset(set1, map, cn);
|
|
668
|
+
if (ref !== undefined && k0 !== null && k1 !== null) {
|
|
669
|
+
const h0 = Math.hypot(ref[0] - k0[0], ref[1] - k0[1]);
|
|
670
|
+
const h1 = Math.hypot(ref[0] - k1[0], ref[1] - k1[1]);
|
|
671
|
+
return h0 <= h1 ? set0 : set1;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return d0 <= d1 ? set0 : set1;
|
|
675
|
+
};
|
|
531
676
|
const cPick = new Map<string, Array<AlternateSet | null>>();
|
|
532
677
|
for (const cn of chains) {
|
|
533
678
|
const arr: Array<AlternateSet | null> = Array.from({ length: n }, () => null);
|
|
@@ -580,7 +725,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
580
725
|
} else {
|
|
581
726
|
for (const cn of chains) {
|
|
582
727
|
const arr = cPick.get(cn)!;
|
|
583
|
-
const frontSets = group.filter((a) => layer.isFront(a) && a.chains.includes(cn));
|
|
728
|
+
const frontSets = group.filter((a) => layer.isFront(a) && a.chains.includes(cn) && a !== set0 && a !== set1);
|
|
584
729
|
const d0s = byChain.get(set0.name)?.get(cn);
|
|
585
730
|
const d1s = byChain.get(set1.name)?.get(cn);
|
|
586
731
|
for (let i = g0; i <= g1; i++) {
|
|
@@ -589,7 +734,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
589
734
|
if (!claimedFront) {
|
|
590
735
|
const d0 = d0s?.[i] ?? Infinity;
|
|
591
736
|
const d1 = d1s?.[i] ?? Infinity;
|
|
592
|
-
arr[i] = d0
|
|
737
|
+
arr[i] = nearerOf(cn, i, d0, d1);
|
|
593
738
|
}
|
|
594
739
|
}
|
|
595
740
|
for (let i = g0; i <= g1;) {
|
|
@@ -604,7 +749,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
604
749
|
for (let k = i; k <= j; k++) {
|
|
605
750
|
const d0 = d0s?.[k] ?? Infinity;
|
|
606
751
|
const d1 = d1s?.[k] ?? Infinity;
|
|
607
|
-
arr[k] = d0
|
|
752
|
+
arr[k] = nearerOf(cn, k, d0, d1);
|
|
608
753
|
}
|
|
609
754
|
} else if (before !== null && before === after && a !== before) {
|
|
610
755
|
for (let k = i; k <= j; k++) arr[k] = before;
|
|
@@ -626,6 +771,9 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
626
771
|
}
|
|
627
772
|
i = j + 1;
|
|
628
773
|
}
|
|
774
|
+
for (let i = g0; i <= g1; i++) {
|
|
775
|
+
if (arr[i] !== null) cClamp.get(cn)![i] = arr[i]!;
|
|
776
|
+
}
|
|
629
777
|
}
|
|
630
778
|
}
|
|
631
779
|
for (const s of layer.touched) {
|
|
@@ -636,7 +784,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
|
|
|
636
784
|
const a = cPick.get(cn)?.[i] ?? null;
|
|
637
785
|
if (a !== null) shown.get(s)![i] = a.slots[s] ?? null;
|
|
638
786
|
}
|
|
639
|
-
if (layer.torsoSlot !== null && shown.has(layer.torsoSlot)) {
|
|
787
|
+
if (layer.torsoSlot !== null && shown.has(layer.torsoSlot) && layer.isBehind(set0) && layer.isBehind(set1)) {
|
|
640
788
|
const picked = chains.map((cn) => cPick.get(cn)![i]);
|
|
641
789
|
if (picked.every((a) => a !== null)) shown.get(layer.torsoSlot)![i] = picked[0]!.slots[layer.torsoSlot] ?? slots.get(layer.torsoSlot)!.setup;
|
|
642
790
|
}
|
|
@@ -920,6 +1068,12 @@ export function clampArt(switches: readonly Switch[], map: JointMap, targets: Ma
|
|
|
920
1068
|
*/
|
|
921
1069
|
export const RAISED_WITHIN = 30;
|
|
922
1070
|
|
|
1071
|
+
/**
|
|
1072
|
+
* --raised-by-head (#179): a chain is placed by the head when its key skeleton's
|
|
1073
|
+
* wrist lies within this many eye distances of the head centre.
|
|
1074
|
+
*/
|
|
1075
|
+
export const RAISED_WRIST_EYES = 2;
|
|
1076
|
+
|
|
923
1077
|
/** Whether a drawn world angle, Spine degrees, is within `RAISED_WITHIN` of straight up (90°). */
|
|
924
1078
|
export const isRaised = (drawn: number): boolean => Math.abs(wrap(drawn - 90)) <= RAISED_WITHIN;
|
|
925
1079
|
|
package/src/verify.ts
CHANGED
|
@@ -561,6 +561,22 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
561
561
|
if (b.has('head')) return b.get('head')!.worldY;
|
|
562
562
|
return rigNeckY(b);
|
|
563
563
|
};
|
|
564
|
+
const headBoneName = (() => {
|
|
565
|
+
if (headChain !== undefined) {
|
|
566
|
+
const k = headChain.joints.findIndex((j, idx) => idx + 1 < headChain.joints.length && ((j === 'r_eye' && headChain.joints[idx + 1] === 'l_eye') || (j === 'l_eye' && headChain.joints[idx + 1] === 'r_eye')));
|
|
567
|
+
if (k >= 0) return headChain.bones[k]!;
|
|
568
|
+
}
|
|
569
|
+
return posed[0]?.has('head') ? 'head' : null;
|
|
570
|
+
})();
|
|
571
|
+
const maxNod = (() => {
|
|
572
|
+
if (headBoneName === null || rigNeck === null || posed.length === 0) return Infinity;
|
|
573
|
+
const b0 = posed[0]!;
|
|
574
|
+
const hb0 = b0.get(headBoneName);
|
|
575
|
+
const nk0 = pointAt(b0, lengths, rigNeck);
|
|
576
|
+
if (hb0 === undefined || nk0 === null) return Infinity;
|
|
577
|
+
const d = Math.hypot(hb0.worldX - nk0[0], hb0.worldY - nk0[1]);
|
|
578
|
+
return d > 1e-6 ? 0.022 * d : Infinity;
|
|
579
|
+
})();
|
|
564
580
|
for (const sp of r.feet.supports) {
|
|
565
581
|
const refHip: number[] = [];
|
|
566
582
|
const rigHipY: number[] = [];
|
|
@@ -568,6 +584,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
568
584
|
const rigNeckS: number[] = [];
|
|
569
585
|
const refEyesNeck: number[] = [];
|
|
570
586
|
const rigEyesNeck: number[] = [];
|
|
587
|
+
const rigEyeHead: number[] = [];
|
|
571
588
|
for (let i = sp.frames[0]; i <= sp.frames[1]; i++) {
|
|
572
589
|
const b = posed[i]!;
|
|
573
590
|
const rh = at(i, 'r_hip');
|
|
@@ -587,15 +604,27 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
587
604
|
if (re !== null && le !== null && ey !== null) {
|
|
588
605
|
refEyesNeck.push(-((re[1] + le[1]) / 2 - nk[1]) * s);
|
|
589
606
|
rigEyesNeck.push(ey - ny);
|
|
607
|
+
const hy = headBoneName !== null && b.has(headBoneName) ? b.get(headBoneName)!.worldY : ny;
|
|
608
|
+
rigEyeHead.push(ey - hy);
|
|
590
609
|
}
|
|
591
610
|
}
|
|
592
611
|
}
|
|
612
|
+
const boundedRefEyesNeck = (() => {
|
|
613
|
+
if (!(maxNod < Infinity) || refEyesNeck.length === 0) return refEyesNeck;
|
|
614
|
+
const dr = detrend(refEyesNeck);
|
|
615
|
+
const dgEH = detrend(rigEyeHead);
|
|
616
|
+
return refEyesNeck.map((v, i) => {
|
|
617
|
+
const base = v - dr[i]!;
|
|
618
|
+
const nod = Math.max(-maxNod, Math.min(maxNod, dr[i]! - dgEH[i]!));
|
|
619
|
+
return base + dgEH[i]! + nod;
|
|
620
|
+
});
|
|
621
|
+
})();
|
|
593
622
|
bounce.push({
|
|
594
623
|
side: sp.side,
|
|
595
624
|
frames: [sp.frames[0], sp.frames[1]],
|
|
596
625
|
hip: bounceMetric(refHip, rigHipY),
|
|
597
626
|
neck: bounceMetric(refNeck, rigNeckS),
|
|
598
|
-
eyesNeck: bounceMetric(
|
|
627
|
+
eyesNeck: bounceMetric(boundedRefEyesNeck, rigEyesNeck),
|
|
599
628
|
});
|
|
600
629
|
}
|
|
601
630
|
}
|