755 lines
34 KiB
JavaScript
755 lines
34 KiB
JavaScript
"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;
|