LosaDev Tools: Lost Saga modding tools in the browser

This commit is contained in:
LosaDev committed 2026-11-07 23:08:43 +07:00
commit c5e04472fb
105 files changed
+38541

No files matched your search

+754
View File
@@ -0,0 +1,754 @@
"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;