Files

755 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// Anim Studio maths, no UI (also runs under node for tests):
// Q quaternions the io3DEngine way (x, y, z, w; world = qmul(parentWorld, local), qrot rotates)
// clips an editable .ani: tracks of keys { t (ms), p, q, raw, rv } — raw / rv are the stored rotation
// words and the .ani version they belong to, written back untouched while the key isn't edited
// retarget any rig → a Lost Saga skeleton: world rotations moved over with the rest poses aligned,
// the root scaled by leg length, the feet put where the source's feet are (two-bone IK) so
// they stay on the floor, then baked to keys and thinned
// ops trim / cut / speed / reverse / insert / loop / mirror on clips
const AnimKit = (() => {
// =====================================================================
// quaternions + vectors
// =====================================================================
const Q = {
id: () => [0, 0, 0, 1],
mul: (a, b) => [
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
],
conj: (q) => [-q[0], -q[1], -q[2], q[3]],
norm(q) {
const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
return [q[0] / l, q[1] / l, q[2] / l, q[3] / l];
},
rot(q, v) {
const [x, y, z, w] = q;
const ux = y * v[2] - z * v[1], uy = z * v[0] - x * v[2], uz = x * v[1] - y * v[0];
const uux = y * uz - z * uy, uuy = z * ux - x * uz, uuz = x * uy - y * ux;
return [v[0] + 2 * (w * ux + uux), v[1] + 2 * (w * uy + uuy), v[2] + 2 * (w * uz + uuz)];
},
axis(a, ang) {
const l = Math.hypot(a[0], a[1], a[2]) || 1;
const s = Math.sin(ang / 2) / l;
return [a[0] * s, a[1] * s, a[2] * s, Math.cos(ang / 2)];
},
/** Shortest rotation taking direction u to direction v. */
fromTo(u, v) {
const a = V.norm(u), b = V.norm(v);
const d = V.dot(a, b);
if (d > 1 - 1e-9) return [0, 0, 0, 1];
if (d < -1 + 1e-9) {
const ax = Math.abs(a[0]) < 0.9 ? V.cross(a, [1, 0, 0]) : V.cross(a, [0, 1, 0]);
return Q.axis(ax, Math.PI);
}
const c = V.cross(a, b);
return Q.norm([c[0], c[1], c[2], 1 + d]);
},
slerp(a, b, f) {
let [bx, by, bz, bw] = b;
let cos = a[0] * bx + a[1] * by + a[2] * bz + a[3] * bw;
if (cos < 0) {
cos = -cos;
bx = -bx; by = -by; bz = -bz; bw = -bw;
}
let s0, s1;
if (cos > 0.9995) {
s0 = 1 - f;
s1 = f;
} else {
const th = Math.acos(cos);
const sin = Math.sin(th);
s0 = Math.sin((1 - f) * th) / sin;
s1 = Math.sin(f * th) / sin;
}
return Q.norm([s0 * a[0] + s1 * bx, s0 * a[1] + s1 * by, s0 * a[2] + s1 * bz, s0 * a[3] + s1 * bw]);
},
/** Angle between two rotations (radians). */
angle(a, b) {
const d = Math.abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]);
return 2 * Math.acos(Math.min(1, d));
},
/** Rotation whose basis maps the rest frame (x, y, z) onto the current one (orthonormal). */
fromBases(rest, cur) {
const m = [0, 0, 0, 0, 0, 0, 0, 0, 0]; // row-major R, R·r_i = c_i
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) m[j * 3 + k] += cur[i][j] * rest[i][k];
return Q.fromMat(m);
},
fromMat(m) {
const [m00, m01, m02, m10, m11, m12, m20, m21, m22] = m;
const tr = m00 + m11 + m22;
let q;
if (tr > 0) {
const s = Math.sqrt(tr + 1) * 2;
q = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4];
} else if (m00 > m11 && m00 > m22) {
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
q = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s];
} else if (m11 > m22) {
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
q = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s];
} else {
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
q = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s];
}
return Q.norm(q);
},
/** XYZ Euler angles in degrees (q = Rz·Ry·Rx applied to column vectors) and back. */
toEuler(q) {
const [x, y, z, w] = q;
const sx = 2 * (w * x + y * z), cx = 1 - 2 * (x * x + y * y);
const sy = Math.max(-1, Math.min(1, 2 * (w * y - z * x)));
const sz = 2 * (w * z + x * y), cz = 1 - 2 * (y * y + z * z);
const d = 180 / Math.PI;
return [Math.atan2(sx, cx) * d, Math.asin(sy) * d, Math.atan2(sz, cz) * d];
},
fromEuler(e) {
const r = Math.PI / 180;
return Q.mul(Q.axis([0, 0, 1], e[2] * r), Q.mul(Q.axis([0, 1, 0], e[1] * r), Q.axis([1, 0, 0], e[0] * r)));
},
};
const V = {
add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]],
sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]],
scale: (a, s) => [a[0] * s, a[1] * s, a[2] * s],
dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]],
len: (a) => Math.hypot(a[0], a[1], a[2]),
norm(a) {
const l = Math.hypot(a[0], a[1], a[2]) || 1;
return [a[0] / l, a[1] / l, a[2] / l];
},
lerp: (a, b, f) => [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f],
};
/** Orthonormal frame from a direction and a reference vector (the bend plane). */
function frame(dir, ref) {
const x = V.norm(dir);
let z = V.cross(x, ref);
if (V.len(z) < 1e-6) z = V.cross(x, Math.abs(x[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]);
z = V.norm(z);
return [x, V.cross(z, x), z];
}
// =====================================================================
// clips
// =====================================================================
const S = (text) => ({ raw: null, text });
const nameOf = (s) => (typeof s === "string" ? s : s.text);
/** LSF.parseAni result → editable clip. */
function fromLsf(a, name = "") {
const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4;
return {
name,
version: a.version,
total: a.total,
events: a.events.map((e) => ({ type: e.type, sub: e.sub, time: e.time })),
tracks: a.tracks.map((t) => {
const keys = [];
for (let k = 0; k < t.times.length; k++)
keys.push({
t: t.times[k],
p: [t.trans[k * 3], t.trans[k * 3 + 1], t.trans[k * 3 + 2]],
q: [t.rot[k * 4], t.rot[k * 4 + 1], t.rot[k * 4 + 2], t.rot[k * 4 + 3]],
raw: Array.from(t.rawRot.subarray(k * words, k * words + words)),
rv: a.version,
});
return { name: t.name, weight: t.weight, keys };
}),
trailing: a.trailing,
};
}
/** Clip → the object LSF.writeAni writes (untouched keys keep their stored words). */
function toLsf(c) {
const words = c.version === 4001 ? 1 : c.version === 4002 ? 2 : 4;
return {
version: c.version,
events: c.events.map((e) => ({ type: typeof e.type === "string" ? S(e.type) : e.type, sub: typeof e.sub === "string" ? S(e.sub) : e.sub, time: e.time })),
total: Math.max(0, Math.round(c.total)),
tracks: c.tracks.map((t) => {
const keys = t.keys.slice().sort((a, b) => a.t - b.t);
const n = keys.length;
const out = { name: typeof t.name === "string" ? S(t.name) : t.name, weight: t.weight, times: new Int32Array(n), trans: new Float32Array(n * 3), rot: new Float32Array(n * 4), rawRot: new Uint32Array(n * words) };
keys.forEach((k, i) => {
out.times[i] = Math.round(k.t);
out.trans.set(k.p, i * 3);
out.rot.set(k.q, i * 4);
const raw = k.raw && k.rv === c.version && k.raw.length === words ? k.raw : LSF.encodeRot(c.version, k.q);
out.rawRot.set(raw, i * words);
});
return out;
}),
trailing: c.trailing,
};
}
function emptyClip(name, version = 4002, total = 1000) {
return { name, version, total, events: [], tracks: [], trailing: undefined };
}
const key = (t, p, q) => ({ t, p: p.slice(), q: Q.norm(q), raw: null, rv: 0 });
const copyKey = (k, t = k.t) => ({ t, p: k.p.slice(), q: k.q.slice(), raw: k.raw ? k.raw.slice() : null, rv: k.rv });
/** Interpolated key value at t (ms): lerp position, slerp rotation, held at the ends. */
function sample(keys, t) {
const n = keys.length;
if (!n) return null;
if (t <= keys[0].t) return { p: keys[0].p.slice(), q: keys[0].q.slice() };
if (t >= keys[n - 1].t) return { p: keys[n - 1].p.slice(), q: keys[n - 1].q.slice() };
let lo = 0, hi = n - 1;
while (hi - lo > 1) {
const m = (lo + hi) >> 1;
if (keys[m].t <= t) lo = m;
else hi = m;
}
const a = keys[lo], b = keys[hi];
const f = b.t > a.t ? (t - a.t) / (b.t - a.t) : 1;
return { p: V.lerp(a.p, b.p, f), q: Q.slerp(a.q, b.q, f) };
}
/** Puts a key at t (replacing one within 0.5 ms). → the key */
function setKey(track, t, p, q) {
t = Math.round(t);
let i = track.keys.findIndex((k) => Math.abs(k.t - t) < 0.5);
const k = key(t, p, q);
if (i >= 0) track.keys[i] = k;
else {
i = track.keys.findIndex((x) => x.t > t);
track.keys.splice(i < 0 ? track.keys.length : i, 0, k);
}
return k;
}
const sortKeys = (tr) => tr.keys.sort((a, b) => a.t - b.t);
const allTimes = (c) => [...new Set([0, Math.round(c.total), ...c.tracks.flatMap((t) => t.keys.map((k) => Math.round(k.t)))])].sort((a, b) => a - b);
/** Drops keys that the neighbours already give (within the tolerances). Keeps first / last. */
function thin(keys, rotTol = 0.15 * (Math.PI / 180), posTol = 0.02) {
if (keys.length <= 2) {
if (keys.length === 2 && Q.angle(keys[0].q, keys[1].q) < rotTol && V.len(V.sub(keys[0].p, keys[1].p)) < posTol) return [keys[0]];
return keys;
}
const out = [keys[0]];
let a = 0;
for (let i = 1; i < keys.length - 1; i++) {
// can a → i+1 stand in for every key between them?
const A = keys[a], B = keys[i + 1];
let ok = true;
for (let j = a + 1; j <= i && ok; j++) {
const f = (keys[j].t - A.t) / Math.max(1e-6, B.t - A.t);
ok = Q.angle(Q.slerp(A.q, B.q, f), keys[j].q) < rotTol && V.len(V.sub(V.lerp(A.p, B.p, f), keys[j].p)) < posTol;
}
if (!ok) {
out.push(keys[i]);
a = i;
}
}
const last = keys[keys.length - 1];
if (out.length === 1 && Q.angle(out[0].q, last.q) < rotTol && V.len(V.sub(out[0].p, last.p)) < posTol) return out;
out.push(last);
return out;
}
// =====================================================================
// Lost Saga skeleton posing (SkinKit.prepare / link'ed skeleton + a clip)
// =====================================================================
function trackMap(sk, clip) {
return clip ? clip.tracks.map((t) => sk.byName.get(nameOf(t.name).toLowerCase()) ?? -1) : [];
}
/** Local position / rotation of every bone at t (track positions × the link rate, like the client). */
function locals(sk, clip, map, t, override) {
const bones = sk.data.bones;
const lp = bones.map((b) => b.localPos.slice());
const lr = bones.map((b) => b.localRot.slice());
if (clip)
clip.tracks.forEach((tr, ti) => {
const bi = map[ti];
if (bi < 0 || tr.weight <= 0) return;
const s = sample(tr.keys, t);
if (!s) return;
const r = sk.rate[bi];
lp[bi] = [s.p[0] * r, s.p[1] * r, s.p[2] * r];
lr[bi] = s.q;
});
if (override) for (const [bi, o] of override) {
if (o.p) lp[bi] = V.scale(o.p, sk.rate[bi]);
if (o.q) lr[bi] = o.q;
}
return { lp, lr };
}
function world(sk, lp, lr) {
const n = lp.length;
const wp = new Array(n), wr = new Array(n);
for (const i of sk.order) {
const pi = sk.parent[i];
if (pi < 0) {
wr[i] = lr[i];
wp[i] = lp[i];
} else {
wr[i] = Q.mul(wr[pi], lr[i]);
wp[i] = V.add(wp[pi], Q.rot(wr[pi], lp[i]));
}
}
return { wp, wr };
}
function lsPose(sk, clip, map, t, override) {
const l = locals(sk, clip, map, t, override);
return Object.assign(world(sk, l.lp, l.lr), l);
}
/** A Lost Saga skeleton + clip as a retarget source. */
function lsRig(sk, clip) {
const map = trackMap(sk, clip);
const rest = lsPose(sk, null, null, 0);
return {
kind: "ls",
names: sk.data.bones.map((b) => b.name.text),
parent: sk.parent,
restWp: rest.wp,
restWr: rest.wr,
restLp: sk.data.bones.map((b) => b.localPos.slice()),
total: clip.total,
times: allTimes(clip),
events: clip.events,
pose: (t) => lsPose(sk, clip, map, t),
};
}
/** Knee position of a two-bone chain from H reaching T (lengths a, b), bending towards pole. */
function twoBone(H, T, a, b, pole) {
const d0 = V.sub(T, H);
let d = V.len(d0);
const u = d > 1e-6 ? V.scale(d0, 1 / d) : [0, -1, 0];
d = Math.max(Math.abs(a - b) + 1e-4, Math.min(a + b - 1e-4, d));
const cosA = (a * a + d * d - b * b) / (2 * a * d);
const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
let nrm = V.sub(pole, V.scale(u, V.dot(pole, u)));
if (V.len(nrm) < 1e-6) nrm = Math.abs(u[1]) < 0.9 ? V.cross(u, [0, 1, 0]) : [0, 0, -1];
nrm = V.norm(nrm);
return V.add(H, V.add(V.scale(u, a * cosA), V.scale(nrm, a * sinA)));
}
// =====================================================================
// retarget: rig → Lost Saga skeleton
// =====================================================================
/** Which child a bone points at, for aligning rest poses (first match wins). */
const AIM = [/ head$/, / neck$/, / spine\d?$/, / calf$/, / foot$/, / toe0$/, / forearm$/, / hand$/, / upperarm$/, / finger2$/, / finger\d1$/, / finger\d2$/];
/**
* Default bone map target bone index → source bone index (-1 = keeps its rest local rotation).
* Names are matched case-insensitively.
*/
function defaultMap(src, sk) {
const names = sk.data.bones.map((b) => b.name.text);
const by = new Map(src.names.map((n, i) => [n.toLowerCase(), i]));
return names.map((n) => by.get(n.toLowerCase()) ?? -1);
}
function legChain(sk, side) {
const b = (n) => sk.byName.get(`bip01 ${side} ${n}`) ?? -1;
const c = { hip: b("thigh"), knee: b("calf"), ankle: b("foot"), toe: b("toe0") };
return c.hip >= 0 && c.knee >= 0 && c.ankle >= 0 ? c : null;
}
/**
* Bakes src onto the Lost Saga skeleton sk. opts:
* map target bone → source bone (defaultMap)
* root target root bone (Bip01) / its source bone (the map's)
* scale source → target units (default: leg length ratio)
* footIK put the ankles where the source's ankles are (no sliding, feet on the floor)
* ground (no foot IK) move the root each frame so the lowest foot is as high as the source's
* inPlace drop the root's horizontal travel
* start/end source range (ms), fps of the baked keys, version, tolerance (degrees) for thinning
* → { clip, info }
*/
function retarget(src, sk, opts = {}) {
const map = opts.map || defaultMap(src, sk);
const bones = sk.data.bones;
const n = bones.length;
const rest = lsPose(sk, null, null, 0);
const root = sk.byName.get("bip01") ?? sk.order[0];
const rootSrc = map[root];
if (rootSrc < 0) throw new Error("The root bone (Bip01) has no source bone.");
const legs = ["l", "r"].map((s) => legChain(sk, s)).filter(Boolean);
// scale: leg length (hip → ankle) of the two rest poses, else heights
let scale = opts.scale;
if (!scale) {
const lens = legs
.map((l) => [l, map[l.hip], map[l.knee], map[l.ankle]])
.filter(([, a, b, c]) => a >= 0 && b >= 0 && c >= 0)
.map(([l, a, b, c]) => {
const tl = V.len(V.sub(rest.wp[l.knee], rest.wp[l.hip])) + V.len(V.sub(rest.wp[l.ankle], rest.wp[l.knee]));
const sl = V.len(V.sub(src.restWp[b], src.restWp[a])) + V.len(V.sub(src.restWp[c], src.restWp[b]));
return tl / sl;
});
scale = lens.length ? lens.reduce((a, b) => a + b) / lens.length : rest.wp[root][1] / Math.max(1e-6, src.restWp[rootSrc][1]) || 1;
}
// rest alignment: A[b] turns the target's rest direction onto the source's rest direction
const A = new Array(n).fill(null);
const childs = bones.map(() => []);
sk.parent.forEach((p, i) => p >= 0 && childs[p].push(i));
for (const b of sk.order) {
if (map[b] < 0) continue;
// feet stand flat in both rest poses: the same posture, nothing to align
if (/ (foot|toe\d*)$/.test(bones[b].name.text.toLowerCase())) {
A[b] = Q.id();
continue;
}
let aim = -1;
for (const re of AIM) {
aim = childs[b].find((c) => map[c] >= 0 && re.test(bones[c].name.text.toLowerCase())) ?? -1;
if (aim >= 0) break;
}
if (aim >= 0) {
const dt = V.sub(rest.wp[aim], rest.wp[b]);
const ds = V.sub(src.restWp[map[aim]], src.restWp[map[b]]);
// the source's rest direction is in its own rest frame; compare them in world
if (V.len(dt) > 1e-4 && V.len(ds) > 1e-4) A[b] = Q.fromTo(dt, ds);
}
if (!A[b]) {
// finger tips, the toe, the head: the alignment of the nearest aligned parent
let p = sk.parent[b];
while (p >= 0 && !A[p]) p = sk.parent[p];
A[b] = p >= 0 && /finger|toe/.test(bones[b].name.text.toLowerCase()) ? A[p] : Q.id();
}
}
if (opts.alignRoot === false) A[root] = Q.id();
const restLR = bones.map((b) => b.localRot.slice());
const restLP = bones.map((b) => b.localPos.slice());
// local positions: the skeleton's own, or (Lost Saga sources) the source's, scaled to the bone
const lpOf = (b, S) => {
const m = map[b];
if (src.kind !== "ls" || m < 0 || !S.lp) return restLP[b];
const l = V.len(src.restLp[m]);
return V.scale(S.lp[m], l > 1e-6 ? V.len(restLP[b]) / l : 1);
};
/** Target world rotations + positions for a source pose; rootPos placed by the caller. */
function solve(S, rootPos) {
const wr = new Array(n), wp = new Array(n);
for (const b of sk.order) {
const pi = sk.parent[b];
const m = map[b];
if (m >= 0) {
const delta = Q.mul(S.wr[m], Q.conj(src.restWr[m]));
wr[b] = Q.norm(Q.mul(delta, Q.mul(A[b], rest.wr[b])));
} else wr[b] = pi < 0 ? restLR[b] : Q.mul(wr[pi], restLR[b]);
const lp = b === root ? rootPos : lpOf(b, S);
wp[b] = pi < 0 ? lp : V.add(wp[pi], Q.rot(wr[pi], lp));
}
return { wr, wp, S };
}
// where the target root sits for a source pose: its rest place + the scaled source travel, lifted
// so the aligned rest pose's feet stand where the target's rest feet do
const srcRest = { wr: src.restWr, wp: src.restWp };
const aligned = solve(srcRest, rest.wp[root]);
const footBones = legs.map((l) => l.ankle);
const lift = footBones.length ? footBones.reduce((s, b) => s + rest.wp[b][1] - aligned.wp[b][1], 0) / footBones.length : 0;
const restRoot = V.add(rest.wp[root], [0, lift, 0]);
const restAligned = solve(srcRest, restRoot);
// the body moves with the source's body joint (rootPos)
const posSrc = src.rootPos >= 0 ? src.rootPos : rootSrc;
const place = (p) => V.add(restRoot, V.scale(V.sub(p, src.restWp[posSrc]), scale));
// where each target ankle goes: the source ankle's travel from rest, scaled, from the aligned rest
const ikLegs = legs
.filter((l) => map[l.ankle] >= 0)
.map((l) => ({ ...l, a: V.len(V.sub(rest.wp[l.knee], rest.wp[l.hip])), b: V.len(V.sub(rest.wp[l.ankle], rest.wp[l.knee])), src: map[l.ankle], srcToe: l.toe >= 0 ? map[l.toe] : -1 }));
const footLift = (P, l, ref) => Math.min(P.wp[l.ankle][1] - ref.wp[l.ankle][1], l.toe >= 0 ? P.wp[l.toe][1] - ref.wp[l.toe][1] : Infinity);
const start = Math.max(0, opts.start ?? 0);
const end = Math.min(src.total, opts.end ?? src.total);
const fps = opts.fps || 30;
const times = [];
for (let t = start; t < end - 1e-6; t += 1000 / fps) times.push(t);
times.push(end);
const keys = bones.map(() => []);
let dropped = 0, maxDrop = 0, jumped = 0;
const jump = opts.jump === "on" || opts.jump === true;
for (const ts of times) {
const S = src.pose(ts);
let rp = place(S.wp[posSrc]);
// jump: both source feet off the floor while the source body stays put → the body goes up
// by the lower ankle's height (the feet stay where they are, the legs unfold to standing)
// jumpScale > 1 also lifts the feet: the whole jump gets higher
let extraY = 0;
if (jump && ikLegs.length) {
const up = Math.min(...ikLegs.map((l) => S.wp[l.src][1] - src.restWp[l.src][1]));
const dz = 0.15; // source units: heels / tiptoes aren't a jump
if (up > dz) {
extraY = (up - dz) * scale * Math.max(0, (opts.jumpScale ?? 1) - 1);
rp = V.add(rp, [0, (up - dz) * scale + extraY, 0]);
jumped++;
}
}
// in place: the root's travel over the floor comes off the root and the feet alike
const travel = opts.inPlace ? [rp[0] - restRoot[0], 0, rp[2] - restRoot[2]] : [0, 0, 0];
rp = V.sub(rp, travel);
let P = solve(S, rp);
if (opts.footIK !== false && ikLegs.length) {
const targets = ikLegs.map((l) => V.add(V.sub(V.add(restAligned.wp[l.ankle], V.scale(V.sub(S.wp[l.src], src.restWp[l.src]), scale)), travel), [0, extraY, 0]));
// a planted foot the leg can't reach: lower the body just enough (else the foot would hang);
// feet in the air (kicks) just get a straight leg
let drop = 0;
ikLegs.forEach((l, i) => {
if (targets[i][1] - restAligned.wp[l.ankle][1] > 0.6 * scale) return;
const d = V.sub(targets[i], P.wp[l.hip]);
const L = (l.a + l.b) * 0.998;
const h2 = d[0] * d[0] + d[2] * d[2];
if (V.len(d) > L && h2 < L * L) drop = Math.max(drop, Math.min(0.25 * L, -Math.sqrt(L * L - h2) - d[1]));
});
if (drop > 1e-4) {
rp = V.sub(rp, [0, drop, 0]);
P = solve(S, rp);
dropped++;
maxDrop = Math.max(maxDrop, drop);
}
ikLegs.forEach((l, i) => legIK(P, l, targets[i]));
P = refk(P);
// toes as high above the floor as the source's (a Lost Saga foot is about twice as long as
// the source's, scaled: the same tilt would push its toes through the floor)
if (opts.toeFit !== false) {
let moved = false;
for (const l of ikLegs) {
if (l.toe < 0 || l.srcToe < 0) continue;
const want = restAligned.wp[l.toe][1] + (S.wp[l.srcToe][1] - src.restWp[l.srcToe][1]) * scale;
const A0 = P.wp[l.ankle];
const v = V.sub(P.wp[l.toe], A0);
const len = V.len(v);
const flat = Math.hypot(v[0], v[2]);
if (len < 1e-4 || flat < 1e-4) continue;
const pitch = Math.asin(Math.max(-1, Math.min(1, v[1] / len)));
const goal = Math.asin(Math.max(-1, Math.min(1, (want - A0[1]) / len)));
if (Math.abs(goal - pitch) < 1e-4) continue;
// turn about the horizontal axis across the foot, whichever sign raises / lowers the toe
const axis = V.norm(V.cross([v[0], 0, v[2]], [0, 1, 0]));
let D = Q.axis(axis, goal - pitch);
if (Math.sign(Q.rot(D, v)[1] - v[1]) !== Math.sign(goal - pitch)) D = Q.axis(axis, pitch - goal);
P.wr[l.ankle] = Q.norm(Q.mul(D, P.wr[l.ankle]));
moved = true;
}
if (moved) P = refk(P);
}
} else if (opts.ground !== false && ikLegs.length) {
// the lowest foot as high above its rest as the source's lowest foot (scaled)
const want = Math.min(...ikLegs.map((l) => (S.wp[l.src][1] - src.restWp[l.src][1]) * scale));
const have = Math.min(...ikLegs.map((l) => footLift(P, l, restAligned)));
if (isFinite(want) && isFinite(have)) P = solve(S, V.add(rp, [0, want - have, 0]));
}
const t = Math.round(ts - start);
for (let b = 0; b < n; b++) {
const pi = sk.parent[b];
const q = pi < 0 ? P.wr[b] : Q.norm(Q.mul(Q.conj(P.wr[pi]), P.wr[b]));
const lp = b === root ? (pi < 0 ? P.wp[b] : Q.rot(Q.conj(P.wr[pi]), V.sub(P.wp[b], P.wp[pi]))) : lpOf(b, S);
keys[b].push(key(t, V.scale(lp, 1 / sk.rate[b]), q));
}
}
// two-bone IK on the target: swing thigh and calf (twist kept), foot keeps its world rotation
function legIK(P, l, T) {
const H = P.wp[l.hip];
let pole = V.sub(P.wp[l.knee], V.lerp(H, P.wp[l.ankle], 0.5));
const K = twoBone(H, T, l.a, l.b, V.len(pole) > 1e-4 ? pole : Q.rot(P.wr[root], [0, 0, -1]));
const footW = P.wr[l.ankle];
const sw1 = Q.fromTo(V.sub(P.wp[l.knee], H), V.sub(K, H));
P.wr[l.hip] = Q.norm(Q.mul(sw1, P.wr[l.hip]));
const kneeDir = Q.rot(sw1, V.sub(P.wp[l.ankle], P.wp[l.knee]));
const A2 = V.add(K, V.scale(V.norm(V.sub(T, K)), l.b));
const sw2 = Q.fromTo(kneeDir, V.sub(A2, K));
P.wr[l.knee] = Q.norm(Q.mul(sw2, Q.mul(sw1, P.wr[l.knee])));
P.wr[l.ankle] = footW;
// the foot's children (toe) keep their world rotation relative to the foot: nothing to do,
// their local rotations are taken from the world ones below
}
// world rotations after IK → positions again
function refk(P) {
const wp = new Array(n);
for (const b of sk.order) {
const pi = sk.parent[b];
wp[b] = pi < 0 ? P.wp[b] : V.add(wp[pi], Q.rot(P.wr[pi], lpOf(b, P.S)));
}
return { wr: P.wr, wp, S: P.S };
}
const tol = (opts.tolerance ?? 0.15) * (Math.PI / 180);
const clip = emptyClip(opts.name || "retarget", opts.version || 4002, Math.round(end - start));
for (let b = 0; b < n; b++) {
const k = thin(keys[b], tol, 0.02);
clip.tracks.push({ name: S(bones[b].name.text), weight: 1, keys: k });
}
clip.events = (src.events || []).filter((e) => e.time >= start && e.time <= end).map((e) => ({ ...e, time: e.time - start }));
const mapped = map.filter((m) => m >= 0).length;
return { clip, info: { scale, mapped, bones: n, frames: times.length, keys: clip.tracks.reduce((s, t) => s + t.keys.length, 0), lift, restRoot, srcRoot: src.restWp[posSrc], dropped, maxDrop, jump, jumped } };
}
// =====================================================================
// clip operations (range in ms; keys outside keep their stored bytes)
// =====================================================================
/** Makes sure every track has a key exactly at t (sampled), so a cut there keeps the shape. */
function splitAt(c, t) {
t = Math.round(t);
for (const tr of c.tracks) {
if (!tr.keys.length || tr.keys.some((k) => Math.abs(k.t - t) < 0.5)) continue;
if (t <= tr.keys[0].t || t >= tr.keys[tr.keys.length - 1].t) continue;
const s = sample(tr.keys, t);
tr.keys.push(key(t, s.p, s.q));
sortKeys(tr);
}
}
const evShift = (c, fn) => (c.events = c.events.map((e) => ({ ...e, time: fn(e.time) })).filter((e) => e.time !== null));
/** Keeps [a, b] only, starting at 0. */
function trim(c, a, b) {
for (const tr of c.tracks) {
if (!tr.keys.length) continue;
const sa = sample(tr.keys, a), sb = sample(tr.keys, b);
const mid = tr.keys.filter((k) => k.t > a + 0.5 && k.t < b - 0.5).map((k) => copyKey(k, k.t - a));
const ka = tr.keys.find((k) => Math.abs(k.t - a) < 0.5);
const kb = tr.keys.find((k) => Math.abs(k.t - b) < 0.5);
tr.keys = [ka ? copyKey(ka, 0) : key(0, sa.p, sa.q), ...mid];
if (b - a > 0.5) tr.keys.push(kb ? copyKey(kb, b - a) : key(Math.round(b - a), sb.p, sb.q));
if (tr.keys.length === 2 && tr.keys[0].raw && tr.keys[1].raw && tr.keys[0].raw.join() === tr.keys[1].raw.join() && V.len(V.sub(tr.keys[0].p, tr.keys[1].p)) < 1e-6) tr.keys.pop();
}
evShift(c, (t) => (t >= a && t <= b ? t - a : null));
c.total = Math.round(b - a);
}
/** Removes [a, b]; what was after b follows a straight away. */
function cut(c, a, b) {
splitAt(c, a);
splitAt(c, b);
const d = b - a;
for (const tr of c.tracks) {
const keep = tr.keys.filter((k) => k.t <= a + 0.5 || k.t >= b - 0.5);
// two keys at a (the one at a and the one from b): keep the later pose
tr.keys = keep.map((k) => (k.t >= b - 0.5 ? copyKey(k, Math.round(k.t - d)) : k)).filter((k, i, arr) => !(Math.abs(k.t - a) < 0.5 && arr.some((o, j) => j > i && Math.abs(o.t - a) < 0.5)));
sortKeys(tr);
}
evShift(c, (t) => (t < a ? t : t > b ? t - d : null));
c.total = Math.round(c.total - d);
}
/** Plays [a, b] at `speed` × (2 = twice as fast); later keys move with it. */
function speed(c, a, b, s) {
splitAt(c, a);
splitAt(c, b);
const d = (b - a) / s - (b - a);
const map = (t) => (t <= a ? t : t <= b ? a + (t - a) / s : t + d);
for (const tr of c.tracks) {
tr.keys = tr.keys.map((k) => (k.t > a ? copyKey(k, Math.round(map(k.t))) : k));
sortKeys(tr);
}
evShift(c, map);
c.total = Math.round(c.total + d);
}
/** Plays [a, b] backwards. */
function reverse(c, a, b) {
splitAt(c, a);
splitAt(c, b);
for (const tr of c.tracks) {
tr.keys = tr.keys.map((k) => (k.t >= a - 0.5 && k.t <= b + 0.5 ? copyKey(k, Math.round(a + b - k.t)) : k));
sortKeys(tr);
}
evShift(c, (t) => (t >= a && t <= b ? a + b - t : t));
}
/**
* Puts clip `o` in at time `at` (append: at = c.total), `gap` ms to blend into it and out of it.
* Bones only one of them moves hold their pose (or the skeleton's rest, `bind(name)` → {p, q}).
*/
function insert(c, o, at, gap = 0, bind = () => null) {
at = Math.round(Math.max(0, Math.min(c.total, at)));
splitAt(c, at);
const len = Math.round(o.total + 2 * gap);
const names = new Map(c.tracks.map((t) => [nameOf(t.name).toLowerCase(), t]));
for (const tr of c.tracks) {
const ot = o.tracks.find((x) => nameOf(x.name).toLowerCase() === nameOf(tr.name).toLowerCase());
const before = tr.keys.filter((k) => k.t <= at + 0.5);
const after = tr.keys.filter((k) => k.t > at + 0.5).map((k) => copyKey(k, k.t + len));
let mid;
if (ot?.keys.length) mid = ot.keys.map((k) => copyKey(k, k.t + at + gap));
else {
const s = sample(tr.keys, at);
mid = s ? [key(at + gap, s.p, s.q), key(at + gap + Math.round(o.total), s.p, s.q)] : [];
}
if (gap === 0 && before.length && mid.length && Math.abs(before[before.length - 1].t - mid[0].t) < 0.5) before.pop();
tr.keys = [...before, ...mid, ...after];
if (!after.length && at < c.total) {
const s = sample(before, at);
if (s) tr.keys.push(key(at + len, s.p, s.q));
}
sortKeys(tr);
}
for (const ot of o.tracks) {
if (names.has(nameOf(ot.name).toLowerCase()) || !ot.keys.length) continue;
const b = bind(nameOf(ot.name)) || { p: ot.keys[0].p, q: ot.keys[0].q };
const keys = [];
if (at + gap > 0) keys.push(key(0, b.p, b.q), key(at, b.p, b.q));
keys.push(...ot.keys.map((k) => copyKey(k, k.t + at + gap)));
if (at < c.total) keys.push(key(at + len, b.p, b.q), key(c.total + len, b.p, b.q));
c.tracks.push({ name: ot.name, weight: ot.weight, keys: keys.filter((k, i, a) => !a.slice(i + 1).some((x) => Math.abs(x.t - k.t) < 0.5)) });
}
evShift(c, (t) => (t <= at ? t : t + len));
c.events.push(...o.events.map((e) => ({ ...e, time: e.time + at + gap })));
c.total = Math.round(c.total + len);
}
/** Last pose = first pose on every track (a key at the end, the first key's stored bytes). */
function makeLoop(c) {
const end = Math.round(c.total);
for (const tr of c.tracks) {
if (tr.keys.length < 2) continue;
tr.keys = tr.keys.filter((k) => k.t < end - 0.5);
tr.keys.push(copyKey(tr.keys[0], end));
}
}
/** "Bip01 L Thigh" ↔ "Bip01 R Thigh" (also left/right, _l/_r). */
function mirrorName(name) {
const swaps = [[/(^|[ _.])L([ _.]|$)/, "$1R$2"], [/(^|[ _.])R([ _.]|$)/, "$1L$2"], [/left/i, "right"], [/right/i, "left"]];
for (const [re, to] of swaps) if (re.test(name)) return name.replace(re, to);
return name;
}
/** Mirrors the whole clip left ↔ right across the plane x = 0 (axis "x") or z = 0 ("z"). */
function mirror(c, sk, axis = "x") {
const ax = axis === "z" ? 2 : 0;
const flip = (v) => v.map((x, i) => (i === ax ? -x : x));
const flipQ = (q) => (ax === 0 ? [q[0], -q[1], -q[2], q[3]] : [-q[0], -q[1], q[2], q[3]]);
const bones = sk.data.bones;
const n = bones.length;
const partner = bones.map((b) => sk.byName.get(mirrorName(b.name.text).toLowerCase()) ?? -1).map((p, i) => (p >= 0 ? p : i));
const map = trackMap(sk, c);
const rest = lsPose(sk, null, null, 0);
// W'(b) = M·W(b')·M · (M·Wrest(b')·M)⁻¹ · Wrest(b); positions mirrored
const corr = bones.map((_, b) => Q.mul(Q.conj(flipQ(rest.wr[partner[b]])), rest.wr[b]));
const times = allTimes(c);
const keyed = new Set(map.filter((x) => x >= 0));
const want = [...new Set([...keyed, ...[...keyed].map((b) => partner[b])])];
const out = new Map(want.map((b) => [b, []]));
for (const t of times) {
const P = lsPose(sk, c, map, t);
const wr = new Array(n), wp = new Array(n);
for (let b = 0; b < n; b++) {
wr[b] = Q.norm(Q.mul(flipQ(P.wr[partner[b]]), corr[b]));
wp[b] = flip(P.wp[partner[b]]);
}
for (const b of want) {
const pi = sk.parent[b];
const q = pi < 0 ? wr[b] : Q.norm(Q.mul(Q.conj(wr[pi]), wr[b]));
const lp = pi < 0 ? wp[b] : Q.rot(Q.conj(wr[pi]), V.sub(wp[b], wp[pi]));
out.get(b).push(key(t, V.scale(lp, 1 / sk.rate[b]), q));
}
}
const weight = new Map(c.tracks.map((t, i) => [map[i], t.weight]));
const others = c.tracks.filter((_, i) => map[i] < 0);
c.tracks = [...want.map((b) => ({ name: S(bones[b].name.text), weight: weight.get(b) ?? weight.get(partner[b]) ?? 1, keys: thin(out.get(b), 0.05 * (Math.PI / 180), 0.005) })), ...others];
c.events = c.events.map((e) => ({ ...e, sub: e.sub }));
}
return { Q, V, S, nameOf, fromLsf, toLsf, emptyClip, key, copyKey, sample, setKey, sortKeys, allTimes, thin, trackMap, locals, world, lsPose, lsRig, retarget, defaultMap, trim, cut, speed, reverse, insert, makeLoop, mirror, mirrorName, splitAt, twoBone };
})();
if (typeof window !== "undefined") window.AnimKit = AnimKit;