"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;