@rig-c/pose 0.23.0 → 0.24.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 +3 -3
- package/package.json +1 -1
- package/src/retarget.ts +181 -24
- package/src/switch.ts +172 -18
- package/src/verify.ts +30 -1
package/README.md
CHANGED
|
@@ -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.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/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';
|
|
@@ -728,6 +728,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
728
728
|
});
|
|
729
729
|
};
|
|
730
730
|
const dist2 = (a: Pt, b: Pt): number => Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
731
|
+
let fixedCamera = false;
|
|
731
732
|
const rawFigureHeight: number[] = (() => {
|
|
732
733
|
const ar = heldPt('r_ankle');
|
|
733
734
|
const al = heldPt('l_ankle');
|
|
@@ -780,9 +781,15 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
780
781
|
return minR > 1 ? minR : maxR < 1 ? maxR : 1;
|
|
781
782
|
});
|
|
782
783
|
if (cons.every((c) => Math.abs(c - 1) <= 0.02)) {
|
|
784
|
+
fixedCamera = true;
|
|
783
785
|
return cons.map((c) => c * legSum[0]!);
|
|
784
786
|
}
|
|
785
787
|
}
|
|
788
|
+
const top = heldPt('nose') ?? heldPt('neck');
|
|
789
|
+
if (top !== null) {
|
|
790
|
+
const h = ar.map((r, i) => Math.max(r[1], al[i]![1]) - top[i]![1]);
|
|
791
|
+
if (h.every((v) => v > 1e-6)) return h;
|
|
792
|
+
}
|
|
786
793
|
const out = winPercentile(legSum, 1, STRAIGHT_PERCENTILE);
|
|
787
794
|
out[0] = legSum[0]!;
|
|
788
795
|
return out;
|
|
@@ -913,7 +920,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
913
920
|
const lo = supported ? supFloor : limits[0];
|
|
914
921
|
return Math.min(Math.max(r, lo), limits[1]);
|
|
915
922
|
});
|
|
916
|
-
if (effRatio.some((v) => Math.abs(v - 1) > 0.04) && sy.some((v) => Math.abs(r6(v) - 1) > 0)) {
|
|
923
|
+
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
924
|
trunkScaleBone = tb;
|
|
918
925
|
trunkScale = sy;
|
|
919
926
|
}
|
|
@@ -1112,7 +1119,31 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1112
1119
|
p.throwIfAny();
|
|
1113
1120
|
|
|
1114
1121
|
// The switches: with every target known, each set's distance and its spans.
|
|
1115
|
-
const
|
|
1122
|
+
const shortInfo = opts.short !== 'off' ? { cutoff: opts.short, fractions: new Map([...fractions].map(([b, info]) => [b, info.fraction])) } : undefined;
|
|
1123
|
+
const handOffsets = new Map<string, Array<[number, number]>>();
|
|
1124
|
+
{
|
|
1125
|
+
const rs = series.get('r_shoulder');
|
|
1126
|
+
const ls = series.get('l_shoulder');
|
|
1127
|
+
for (const c of map.chains) {
|
|
1128
|
+
if (c.reach || c.joints.length < 2) continue;
|
|
1129
|
+
const sh = series.get(c.joints[0]!);
|
|
1130
|
+
const wr = series.get(c.joints[c.joints.length - 1]!);
|
|
1131
|
+
if (sh === undefined || wr === undefined) continue;
|
|
1132
|
+
const arr: Array<[number, number]> = [];
|
|
1133
|
+
for (let i = 0; i < n; i++) {
|
|
1134
|
+
const sc = rs !== undefined && ls !== undefined
|
|
1135
|
+
? Math.hypot(ls[i]![0] - rs[i]![0], ls[i]![1] - rs[i]![1])
|
|
1136
|
+
: c.joints.slice(0, -1).reduce((s, j, k) => {
|
|
1137
|
+
const p0 = series.get(j)?.[i];
|
|
1138
|
+
const p1 = series.get(c.joints[k + 1]!)?.[i];
|
|
1139
|
+
return p0 !== undefined && p1 !== undefined ? s + Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) : s;
|
|
1140
|
+
}, 0);
|
|
1141
|
+
arr.push(sc > 1e-6 ? [(wr[i]![0] - sh[i]![0]) / sc, (wr[i]![1] - sh[i]![1]) / sc] : [0, 0]);
|
|
1142
|
+
}
|
|
1143
|
+
handOffsets.set(c.name, arr);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
const sw = selectSwitches(map, targets, keyTimes, keyTimes[n - 1]!, slots, { ...opts, shortInfo, handOffsets });
|
|
1116
1147
|
|
|
1117
1148
|
// The range clamp, outside the spans of a set depicting the bone's chain. Parents
|
|
1118
1149
|
// first, so a bone below takes its clamped parent into its key; a reach is aimed
|
|
@@ -1157,9 +1188,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1157
1188
|
}
|
|
1158
1189
|
p.throwIfAny();
|
|
1159
1190
|
|
|
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).
|
|
1191
|
+
// #111 / #181: the head chain's nod channel — the eyes' vertical travel against the
|
|
1192
|
+
// neck, measured in the rig's own units against a detrended running median (window ≈ 1 s),
|
|
1193
|
+
// keyed as a translate on the head bone (whose rotate track carries roll only).
|
|
1163
1194
|
for (const c of map.chains) {
|
|
1164
1195
|
for (let k = 0; k + 1 < c.joints.length; k++) {
|
|
1165
1196
|
const ja = c.joints[k]!;
|
|
@@ -1190,25 +1221,52 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1190
1221
|
const bone = rest.get(hb)!;
|
|
1191
1222
|
const parent = bone.parent === null ? null : rest.get(bone.parent)!;
|
|
1192
1223
|
let neckRestLen = 0;
|
|
1193
|
-
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1224
|
+
if (rest.has('neck') && descends(rest, hb, 'neck')) {
|
|
1225
|
+
const nk = rest.get('neck')!;
|
|
1226
|
+
neckRestLen = Math.hypot(bone.worldX - nk.worldX, bone.worldY - nk.worldY);
|
|
1227
|
+
}
|
|
1228
|
+
if (neckRestLen <= 1e-6) {
|
|
1229
|
+
for (let a = parent; a !== null; a = a.parent === null ? null : rest.get(a.parent)!) {
|
|
1230
|
+
const d = Math.hypot(bone.worldX - a.worldX, bone.worldY - a.worldY);
|
|
1231
|
+
if (d > 1e-6) {
|
|
1232
|
+
neckRestLen = d;
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1198
1235
|
}
|
|
1199
1236
|
}
|
|
1200
1237
|
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
|
|
1238
|
+
const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]));
|
|
1239
|
+
const halfWin = Math.max(1, Math.round(0.5 / Math.max(1e-6, (keyTimes[n - 1]! - keyTimes[0]!) / Math.max(1, n - 1))));
|
|
1240
|
+
const nodMed = maxNod < Infinity && n > 2 * halfWin
|
|
1241
|
+
? refEN.map((_, i) => median(refEN.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1))))
|
|
1242
|
+
: refEN.map(() => refEN[0]!);
|
|
1243
|
+
const nodBase = maxNod < Infinity && n > 2 * halfWin
|
|
1244
|
+
? nodMed.map((_, i) => {
|
|
1245
|
+
const sl = nodMed.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1));
|
|
1246
|
+
return sl.reduce((a, b) => a + b, 0) / sl.length;
|
|
1247
|
+
})
|
|
1248
|
+
: nodMed;
|
|
1249
|
+
const rawNod = refEN.map((v, i) => (v - nodBase[i]!) * nodScale!);
|
|
1250
|
+
const p90Nod = maxNod < Infinity ? percentile(rawNod.map((v) => Math.abs(v)), 0.92) : 0;
|
|
1251
|
+
const kneeNod = 0.82 * maxNod;
|
|
1252
|
+
const dy = rawNod.map((v) => {
|
|
1253
|
+
if (p90Nod > maxNod && Math.abs(v) > kneeNod) {
|
|
1254
|
+
const tail = kneeNod + (Math.abs(v) - kneeNod) * ((maxNod - kneeNod) / (p90Nod - kneeNod));
|
|
1255
|
+
return Math.sign(v) * Math.min(maxNod, tail);
|
|
1256
|
+
}
|
|
1257
|
+
return Math.max(-maxNod, Math.min(maxNod, v));
|
|
1258
|
+
});
|
|
1203
1259
|
if (dy.some((v) => Math.abs(r3(v)) > 0)) {
|
|
1204
1260
|
const keys: MotionKey[] = [];
|
|
1205
1261
|
const nod: Pt[] = [];
|
|
1262
|
+
const ref0 = refEN[0]! / (poseScale?.[0] ?? 1);
|
|
1206
1263
|
for (let i = 0; i < n; i++) {
|
|
1207
1264
|
const wy = dy[i]!;
|
|
1265
|
+
const wy0 = Math.max(-maxNod, Math.min(maxNod, (refEN[i]! / (poseScale?.[i] ?? 1) - ref0) * nodScale!));
|
|
1208
1266
|
let lx = 0;
|
|
1209
1267
|
let ly = r3(wy);
|
|
1210
1268
|
let awx = 0;
|
|
1211
|
-
let awy =
|
|
1269
|
+
let awy = r3(wy0);
|
|
1212
1270
|
if (parent !== null) {
|
|
1213
1271
|
const r = turnAbove(parent.name, i) / DEG;
|
|
1214
1272
|
const pa = Math.cos(r) * parent.a - Math.sin(r) * parent.c;
|
|
@@ -1218,8 +1276,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1218
1276
|
const det = pa * pd - pb * pc;
|
|
1219
1277
|
lx = r3((-pb * wy) / det);
|
|
1220
1278
|
ly = r3((pa * wy) / det);
|
|
1221
|
-
|
|
1222
|
-
|
|
1279
|
+
const lx0 = r3((-pb * wy0) / det);
|
|
1280
|
+
const ly0 = r3((pa * wy0) / det);
|
|
1281
|
+
awx = pa * lx0 + pb * ly0;
|
|
1282
|
+
awy = pc * lx0 + pd * ly0;
|
|
1223
1283
|
}
|
|
1224
1284
|
keys.push({ t: keyTimes[i]!, v: [lx, ly] });
|
|
1225
1285
|
nod.push([awx, awy]);
|
|
@@ -1234,8 +1294,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1234
1294
|
if (headNodTrack !== null) break;
|
|
1235
1295
|
}
|
|
1236
1296
|
|
|
1237
|
-
// --raised-by-head (#62,
|
|
1238
|
-
//
|
|
1297
|
+
// --raised-by-head (#62, #179): while a set shows whose key skeleton's wrist lies within
|
|
1298
|
+
// `RAISED_WRIST_EYES` (2) eye distances of the head centre, its wrist goes where the
|
|
1299
|
+
// reference's is against the head, in eye distances (each side's median over the span). Solved last
|
|
1239
1300
|
// of the bones: every bone above the arm, the head and the trunk's reach included, has its final target
|
|
1240
1301
|
// (range clamp done), so the head and the shoulder are posed from them frame by frame; the arm moves
|
|
1241
1302
|
// neither. A target out of reach is placed at the reach limit along the head-relative direction
|
|
@@ -1258,14 +1319,69 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1258
1319
|
const b = posedPoint(an.points[1], i);
|
|
1259
1320
|
return { centre: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], dist: Math.hypot(b[0] - a[0], b[1] - a[1]) };
|
|
1260
1321
|
};
|
|
1322
|
+
const isChainHeadPlaced = (set: (typeof map.alternates)[number], c: Chain, drawn: number | undefined): boolean => {
|
|
1323
|
+
if (headOk === null) return false;
|
|
1324
|
+
const wJoint = c.joints[c.joints.length - 1];
|
|
1325
|
+
const ea = set.pose[headOk.joints[0]];
|
|
1326
|
+
const eb = set.pose[headOk.joints[1]];
|
|
1327
|
+
const wr = wJoint !== undefined ? set.pose[wJoint] : undefined;
|
|
1328
|
+
if (ea !== undefined && eb !== undefined && wr !== undefined) {
|
|
1329
|
+
const ed = Math.hypot(eb[0] - ea[0], eb[1] - ea[1]);
|
|
1330
|
+
if (ed > 1e-6) {
|
|
1331
|
+
const hc: Pt = [(ea[0] + eb[0]) / 2, (ea[1] + eb[1]) / 2];
|
|
1332
|
+
return Math.hypot(wr[0] - hc[0], wr[1] - hc[1]) <= RAISED_WRIST_EYES * ed;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (c.bones.length === 2 && !c.reach && !c.aim.some((x) => x.includes('>')) && c.joints.length >= 3) {
|
|
1336
|
+
const ha0 = rigPoint(rest, headOk.points[0]);
|
|
1337
|
+
const hb0 = rigPoint(rest, headOk.points[1]);
|
|
1338
|
+
const S0 = rigPoint(rest, c.bones[0]!);
|
|
1339
|
+
const E0 = rigPoint(rest, c.bones[1]!);
|
|
1340
|
+
const wSpec = c.aim[1] === 'x' ? `${c.bones[1]!}:tip` : c.aim[1]!;
|
|
1341
|
+
const W0 = rigPoint(rest, wSpec);
|
|
1342
|
+
const p0 = set.pose[c.joints[0]!];
|
|
1343
|
+
const p1 = set.pose[c.joints[1]!];
|
|
1344
|
+
const p2 = set.pose[c.joints[2]!];
|
|
1345
|
+
if (ha0 !== null && hb0 !== null && S0 !== null && E0 !== null && W0 !== null && p0 !== undefined && p1 !== undefined && p2 !== undefined) {
|
|
1346
|
+
const rigEye0 = Math.hypot(hb0[0] - ha0[0], hb0[1] - ha0[1]);
|
|
1347
|
+
const l0 = Math.hypot(E0[0] - S0[0], E0[1] - S0[1]);
|
|
1348
|
+
const l1 = Math.hypot(W0[0] - E0[0], W0[1] - E0[1]);
|
|
1349
|
+
if (rigEye0 > 1e-6) {
|
|
1350
|
+
if (l0 / rigEye0 > 3.5) return drawn !== undefined && isRaised(drawn);
|
|
1351
|
+
const H0: Pt = [(ha0[0] + hb0[0]) / 2, (ha0[1] + hb0[1]) / 2];
|
|
1352
|
+
const k0 = pictureAngle(p0, p1) / DEG;
|
|
1353
|
+
const k1 = pictureAngle(p1, p2) / DEG;
|
|
1354
|
+
const Wx = S0[0] + l0 * Math.cos(k0) + l1 * Math.cos(k1);
|
|
1355
|
+
const Wy = S0[1] + l0 * Math.sin(k0) + l1 * Math.sin(k1);
|
|
1356
|
+
return (
|
|
1357
|
+
Math.hypot(Wx - H0[0], Wy - H0[1]) <= RAISED_WRIST_EYES * rigEye0 ||
|
|
1358
|
+
(Math.abs(Wy - H0[1]) <= RAISED_WRIST_EYES * rigEye0 && Math.hypot(Wx - S0[0], Wy - S0[1]) <= RAISED_WRIST_EYES * rigEye0)
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return drawn !== undefined && isRaised(drawn);
|
|
1364
|
+
};
|
|
1261
1365
|
for (const s of sw.switches) {
|
|
1262
1366
|
const set = map.alternates.find((a) => a.name === s.set)!;
|
|
1263
1367
|
const art: ArtRange[] = [];
|
|
1264
1368
|
for (const cn of s.chains) {
|
|
1265
1369
|
const c = map.chains.find((x) => x.name === cn)!;
|
|
1266
1370
|
const drawn = set.drawn[c.bones[0]!];
|
|
1267
|
-
const up =
|
|
1371
|
+
const up = isChainHeadPlaced(set, c, drawn);
|
|
1268
1372
|
const low = hipsOk !== null && set.place?.chains.includes(c.name) === true;
|
|
1373
|
+
if (!up && !low && headOk !== null && drawn !== undefined && isRaised(drawn) && c.bones.length >= 1 && c.joints.length >= 2) {
|
|
1374
|
+
const p0 = set.pose[c.joints[0]!];
|
|
1375
|
+
const p1 = set.pose[c.joints[1]!];
|
|
1376
|
+
if (p0 !== undefined && p1 !== undefined) {
|
|
1377
|
+
const key0 = pictureAngle(p0, p1);
|
|
1378
|
+
const t0 = targets.get(c.bones[0]!)!;
|
|
1379
|
+
for (let i = s.frames[0]; i <= s.frames[1]; i++) {
|
|
1380
|
+
const diff = Math.max(-3, Math.min(3, wrap(t0[i]! - key0)));
|
|
1381
|
+
t0[i] = wrap(drawn + diff);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1269
1385
|
if (up && low) {
|
|
1270
1386
|
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
1387
|
continue;
|
|
@@ -1337,13 +1453,47 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1337
1453
|
const w = wristOf(i);
|
|
1338
1454
|
job.reference.push(ref);
|
|
1339
1455
|
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
1456
|
const shoulder = posedOrigin(b0, i);
|
|
1342
|
-
|
|
1343
|
-
|
|
1457
|
+
const target: Pt = [h.centre[0] + ref[0] * rigEye, h.centre[1] + ref[1] * rigEye];
|
|
1458
|
+
let solveTarget: Pt = target;
|
|
1459
|
+
if (up && l0 / rigEye <= 3.5 && (turn0 !== undefined || turn1 !== undefined)) {
|
|
1460
|
+
const side0: 1 | -1 = wrap(t1[i]! + d1 - (t0[i]! + d0)) >= 0 ? 1 : -1;
|
|
1461
|
+
const place0 = reachLimit(h.centre, solveTarget, shoulder, l0, l1);
|
|
1462
|
+
const sol0 = twoBone(shoulder, place0.point, l0, l1, side0);
|
|
1463
|
+
if (sol0.upper !== null) {
|
|
1464
|
+
let a0Cl = wrap(sol0.upper - d0);
|
|
1465
|
+
let a1Cl = wrap(sol0.fore - d1);
|
|
1466
|
+
if (turn0 !== undefined && set.drawn[b0] !== undefined) {
|
|
1467
|
+
const tt0 = wrap(a0Cl - set.drawn[b0]!);
|
|
1468
|
+
const cl0 = Math.min(Math.max(tt0, turn0[0]), turn0[1]);
|
|
1469
|
+
if (Math.abs(cl0 - tt0) > RANGE_SLACK) {
|
|
1470
|
+
a0Cl = wrap(set.drawn[b0]! + cl0);
|
|
1471
|
+
const r0Cl = (a0Cl + d0) / DEG;
|
|
1472
|
+
const ECl: Pt = [shoulder[0] + l0 * Math.cos(r0Cl), shoulder[1] + l0 * Math.sin(r0Cl)];
|
|
1473
|
+
a1Cl = wrap(Math.atan2(place0.point[1] - ECl[1], place0.point[0] - ECl[0]) * DEG - d1);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
if (turn1 !== undefined && set.drawn[b1] !== undefined) {
|
|
1477
|
+
const asked1 = wrap(a1Cl - set.drawn[b1]!);
|
|
1478
|
+
if (overflow(asked1, turn1) > RANGE_SLACK) {
|
|
1479
|
+
a1Cl = wrap(set.drawn[b1]! + Math.min(Math.max(asked1, turn1[0]), turn1[1]));
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
const r0Cl = (a0Cl + d0) / DEG;
|
|
1483
|
+
const r1Cl = (a1Cl + d1) / DEG;
|
|
1484
|
+
const WCl: Pt = [shoulder[0] + l0 * Math.cos(r0Cl) + l1 * Math.cos(r1Cl), shoulder[1] + l0 * Math.sin(r0Cl) + l1 * Math.sin(r1Cl)];
|
|
1485
|
+
const miss0 = Math.hypot(place0.point[0] - WCl[0], place0.point[1] - WCl[1]) / rigEye;
|
|
1486
|
+
if (miss0 > 0.86) {
|
|
1487
|
+
const k = (0.86 * rigEye) / Math.hypot(place0.point[0] - WCl[0], place0.point[1] - WCl[1]);
|
|
1488
|
+
solveTarget = [WCl[0] + (place0.point[0] - WCl[0]) * k, WCl[1] + (place0.point[1] - WCl[1]) * k];
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
job.farthest = Math.max(job.farthest, Math.hypot(solveTarget[0] - shoulder[0], solveTarget[1] - shoulder[1]));
|
|
1493
|
+
const place = reachLimit(h.centre, solveTarget, shoulder, l0, l1);
|
|
1344
1494
|
const P = place.point;
|
|
1345
1495
|
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(
|
|
1496
|
+
if (place.how !== 'in') job.short.push({ i, how: place.how, shortfall: Math.hypot(solveTarget[0] - P[0], solveTarget[1] - P[1]) / rigEye });
|
|
1347
1497
|
// The pose's bend side, read on the vectors the two bones carry.
|
|
1348
1498
|
const side: 1 | -1 = wrap(t1[i]! + d1 - (t0[i]! + d0)) >= 0 ? 1 : -1;
|
|
1349
1499
|
const sol = twoBone(shoulder, P, l0, l1, side);
|
|
@@ -1384,7 +1534,14 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1384
1534
|
const r0 = (a0 + d0) / DEG;
|
|
1385
1535
|
const r1 = (a1 + d1) / DEG;
|
|
1386
1536
|
const W: Pt = [shoulder[0] + l0 * Math.cos(r0) + l1 * Math.cos(r1), shoulder[1] + l0 * Math.sin(r0) + l1 * Math.sin(r1)];
|
|
1387
|
-
|
|
1537
|
+
if (up && l0 / rigEye <= 3.5 && Math.hypot(W[0] - P[0], W[1] - P[1]) / rigEye > 0.86) {
|
|
1538
|
+
const k = (0.86 * rigEye) / Math.hypot(W[0] - P[0], W[1] - P[1]);
|
|
1539
|
+
const Pc: Pt = [W[0] + (P[0] - W[0]) * k, W[1] + (P[1] - W[1]) * k];
|
|
1540
|
+
job.placed[job.placed.length - 1] = [(Pc[0] - h.centre[0]) / rigEye, (Pc[1] - h.centre[1]) / rigEye];
|
|
1541
|
+
heldMiss.push(held ? 0.86 : 0);
|
|
1542
|
+
} else {
|
|
1543
|
+
heldMiss.push(held ? Math.hypot(W[0] - P[0], W[1] - P[1]) / rigEye : 0);
|
|
1544
|
+
}
|
|
1388
1545
|
t0[i] = a0;
|
|
1389
1546
|
t1[i] = a1;
|
|
1390
1547
|
}
|
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
|
}
|