Files
losadev-tools/app/public/legacy/js/model.js
T

1736 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// Model viewer (3D tab): browse the model configs, meshes, animations and skeletons of a set of
// packs, see them with their textures, play animations on the skeleton (CPU skinning, the same
// maths as io3DEngine), and make the safe edits: animation events / track weights / duration and
// mesh control points, plus the material scripts. Saves go back into the pack or to a file.
(() => {
const MDL = {
src: null, // asset source { kind: "session"|"packs"|"dir", sessionId|folder, name, label }
rows: [], // [key, display, pack, kind, path]
kind: "model",
scene: null,
playing: false,
time: 0,
speed: 1,
loop: true,
undo: [],
redo: [],
seq: 0,
};
window.MDL = MDL;
let view = null;
let kit = null; // meshkit.js loader for this view
let heroSequence = null, heroSequenceToken = 0, heroMotionToken = 0, heroFx = null, heroFxUntil = 0, heroFxLast = 0;
let heroFxModelSeq = 0, heroFxRequest = 0, heroFxModels = [];
let heroRoot = { pos: [0, 0, 0], yaw: 0 }, heroTarget = null;
const heroDummyIds = ["hero-practice-dummy-body", "hero-practice-dummy-head"];
const heroTextures = new Map();
const stem = (p) => p.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
const kindOf = (p) => {
const l = p.toLowerCase();
if (l.endsWith(".msh")) return "msh";
if (l.endsWith(".ani")) return "ani";
if (l.endsWith(".skl")) return "skl";
if (l.endsWith(".txt") && /(^|\/)model\//.test(l)) return "model";
return null;
};
const fmtMs = (ms) => `${(ms / 1000).toFixed(2)} s`;
const plural = (n, w) => `${n.toLocaleString()} ${w}${n === 1 ? "" : "s"}`;
const isAscii = (s) => /^[\x20-\x7e]*$/.test(s);
function ensureView() {
if (view) return view;
try {
view = new GL3D.View($("mdl-canvas"));
} catch (err) {
toast(`3D view unavailable: ${err.message}`, true);
return null;
}
MDL.view = view;
kit = MeshKit.create(view, () => MDL.src);
return view;
}
// =====================================================================
// source + browser
// =====================================================================
async function setSource(src, { quiet = false } = {}) {
try {
const d = await call("mdl:describe", src);
MDL.src = { ...src, label: d.label };
} catch (err) {
return toast(err.message, true);
}
$("mdl-src-label").textContent = MDL.src.kind === "dir" ? "Extracted folder" : MDL.src.kind === "packs" ? "Pack folder" : `Packs next to ${MDL.src.name}`;
$("mdl-src").title = `Reading from ${MDL.src.label}`;
kit?.clearTextures();
await loadList();
if (!quiet) toast(`Reading models from ${MDL.src.label}.`);
}
async function chooseSource() {
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
let choice = null;
const options = [
...profileSourceOption(),
...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })),
{ value: "packs", title: "A folder with .iop packs…", sub: "the client folder: map.iop, map2.iop, rs.iop… — passwords found from the profiles" },
{ value: "dir", title: "A folder of extracted files…", sub: "resource/mesh, resource/animation, resource/texture… extracted from the packs" },
];
const list = el(
"div",
{ class: "choice-list" },
options.map((o, i) =>
el("label", { class: "choice" }, [
el("input", { type: "radio", name: "mdlsrc", checked: i === 0, onchange: () => (choice = o.value) }),
el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]),
]),
),
);
choice = options[0].value;
const ok = await modal("Where are the models?", ["Meshes, textures, skeletons and animations are spread over several packs (map.iop, map2.iop, rs.iop, …). Pick the folder they are in.", list], [
{ label: "Cancel", value: false },
{ label: "Use this", kind: "primary", value: true },
]);
if (!ok) return false;
let src = choice;
if (choice === "packs" || choice === "dir") {
const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files");
if (!folder) return false;
src = { kind: choice, folder };
}
await setSource(src);
return true;
}
$("mdl-src").onclick = () => chooseSource();
const list = new VTable(
$("mdl-list"),
[
{ key: 1, label: "Name", width: 200, grow: true, title: true },
{ key: 2, label: "Pack", width: 70, cls: "mono dim" },
],
{ multi: false, id: "mdl" },
);
list.keyOf = (r) => r[0];
list.sortBy(1);
async function loadList() {
if (!MDL.src) return;
loading(true, "Reading the pack index…");
try {
const rows = await call("mdl:list", MDL.src);
MDL.rows = rows.map(([key, path, pack]) => {
const kind = kindOf(path);
const display = kind === "model" ? path.replace(/^.*?model\//i, "").replace(/\.txt$/i, "") : stem(path);
return [key, display, pack || path.split("/").slice(0, -1).join("/"), kind, path];
});
} catch (err) {
toast(err.message, true);
MDL.rows = [];
} finally {
loading(false);
}
filterList();
}
function filterList() {
const q = $("mdl-search").value.trim().toLowerCase();
const rows = MDL.rows.filter((r) => r[3] === MDL.kind && (!q || r[1].toLowerCase().includes(q)));
list.setRows(rows);
$("mdl-count").textContent = rows.length.toLocaleString();
}
let searchTimer = 0;
$("mdl-search").addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(filterList, 120);
});
for (const b of $("mdl-kinds").children) {
b.onclick = () => {
MDL.kind = b.dataset.kind;
for (const x of $("mdl-kinds").children) x.classList.toggle("on", x === b);
filterList();
};
}
list.onOpen = (r) => openRow(r);
$("mdl-list").addEventListener("contextmenu", (e) => {
const rowEl = e.target.closest(".vt-row");
if (!rowEl) return;
e.preventDefault();
const i = Number(rowEl.dataset.i);
const r = list.view[i];
list.click(i, {});
const s = MDL.scene;
const items = [{ label: "Open", icon: "external", hint: "Double-click", run: () => openRow(r) }];
if (r[3] === "msh") items.push({ label: "Add to the scene", icon: "plus", disabled: !s, run: () => addMeshRow(r) });
if (r[3] === "ani") items.push({ label: "Play on this skeleton", icon: "play", disabled: !s?.skel, run: () => playRow(r, { force: true }) });
if (r[3] === "skl") items.push({ label: "Use as the scene's skeleton", icon: "bone", disabled: !s?.meshes.length, run: () => useSkeletonRow(r) });
items.push("-", { label: "Copy name", icon: "copy", run: () => navigator.clipboard.writeText(r[4].split("/").pop()) });
showMenu(items, { x: e.clientX, y: e.clientY });
});
function loading(on, text = "Loading…") {
$("mdl-loading").hidden = !on;
$("mdl-loading").querySelector("span").textContent = text;
}
// =====================================================================
// reading
// =====================================================================
async function readKey(key) {
const r = await call("mdl:readKey", MDL.src, key);
if (!r) throw new Error("Not found in the packs any more.");
return r;
}
/** A resource by bare name in its usual folder (skeleton / mesh / animation / material). */
async function readNamed(name, folder, strict = false) {
if (!MDL.src) return null;
return call("mdl:read", MDL.src, name, folder, strict);
}
// =====================================================================
// scene
// =====================================================================
function anyDirty() {
const s = MDL.scene;
return !!s && (s.anim?.dirty || s.meshes.some((m) => m.dirty || m.mat?.dirty));
}
async function confirmDiscard() {
if (!anyDirty()) return true;
return confirmBox("Unsaved changes", "The scene has changes that aren't saved. Discard them?", "Discard", "danger");
}
function newScene(title) {
if (!ensureView()) return null;
stop();
view.clear();
MDL.scene = { title, model: null, skel: null, meshes: [], anim: null, animList: [], missing: [], sel: -1 };
MDL.time = 0;
MDL.undo = [];
MDL.redo = [];
$("mdl-title").textContent = title;
$("mdl-empty").hidden = true;
$("mdl-side").hidden = false;
return MDL.scene;
}
async function openRow(r) {
if (!r) return;
try {
if (r[3] === "model") return await openModel(await readKey(r[0]), r[1]);
if (r[3] === "msh") return await openMesh(await readKey(r[0]));
if (r[3] === "ani") return await playRow(r);
if (r[3] === "skl") return await openSkeleton(await readKey(r[0]));
} catch (err) {
toast(err.message, true);
} finally {
loading(false);
}
}
async function openModel(hit, name) {
if (!(await confirmDiscard())) return;
loading(true, "Loading the model…");
const cfg = LSF.parseModelCfg(LSF.decode(hit.bytes));
const s = newScene(name || stem(hit.source));
if (!s) return;
s.model = { name: name || stem(hit.source), cfg, where: hit.where, source: hit.source };
if (cfg.skeleton) {
const sk = await readNamed(cfg.skeleton, "skeleton");
if (sk) setSkeleton(LSF.parseSkl(sk.bytes), cfg.skeleton, sk);
else s.missing.push(cfg.skeleton);
}
for (const n of cfg.meshes) {
const h = await readNamed(`${n}.msh`, "mesh");
if (!h) {
s.missing.push(`${n}.msh`);
continue;
}
await addMesh(h, n);
}
s.animList = cfg.animations;
frameScene();
renderSide();
if (s.animList.length) {
const first = s.animList.find((a) => /delay/i.test(a)) || s.animList[0];
await playNamed(first);
}
applyPose();
if (s.missing.length) toast(`Not in these packs: ${s.missing.join(", ")}`, true);
}
async function openMesh(hit) {
if (!(await confirmDiscard())) return;
loading(true, "Loading the mesh…");
const s = newScene(stem(hit.source));
if (!s) return;
const m = await addMesh(hit, stem(hit.source));
if (m?.data.boneNames?.length) await autoSkeleton(m);
frameScene();
renderSide();
applyPose();
}
async function openSkeleton(hit) {
if (!(await confirmDiscard())) return;
const s = newScene(stem(hit.source));
if (!s) return;
setSkeleton(LSF.parseSkl(hit.bytes), hit.source.split(/[/\\: ]/).pop(), hit);
setToggle("mdl-t-bones", true);
frameScene();
renderSide();
applyPose();
}
async function addMeshRow(r) {
try {
const m = await addMesh(await readKey(r[0]), r[1]);
if (m && MDL.scene.skel) mapPalette(m);
else if (m?.data.boneNames?.length && !MDL.scene.skel) await autoSkeleton(m);
frameScene();
renderSide();
applyPose();
} catch (err) {
toast(err.message, true);
}
}
async function useSkeletonRow(r) {
try {
const hit = await readKey(r[0]);
setSkeleton(LSF.parseSkl(hit.bytes), `${r[1]}.skl`, hit);
if (MDL.scene.anim) mapTracks(MDL.scene.anim);
renderSide();
applyPose();
} catch (err) {
toast(err.message, true);
}
}
/** Picks a skeleton for a skinned mesh: from the model configs, else by bone names. */
async function autoSkeleton(m) {
if (!MDL.src) return;
loading(true, "Finding the skeleton…");
try {
const c = await call("mdl:findSkeleton", MDL.src, { mesh: m.name, bones: m.data.boneNames.map((b) => b.text) });
const best = c.find((x) => x.fromModel) || c.find((x) => x.matched === x.total) || c[0];
if (!best) return toast("No skeleton in these packs has this mesh's bones; it shows in its bind pose.", true);
const sk = await readNamed(best.name, "skeleton");
if (sk) setSkeleton(LSF.parseSkl(sk.bytes), best.name, sk);
} finally {
loading(false);
}
}
// ---------- skeleton ----------
function setSkeleton(data, name, hit) {
const s = MDL.scene;
const n = data.bones.length;
const byName = new Map();
data.bones.forEach((b, i) => byName.has(b.name.text.toLowerCase()) || byName.set(b.name.text.toLowerCase(), i));
const parent = data.bones.map((b) => {
const p = b.parent.text.toLowerCase();
return p === "noparent" || !byName.has(p) ? -1 : byName.get(p);
});
// parents before children
const order = [];
const done = new Uint8Array(n);
const visit = (i, depth) => {
if (done[i] || depth > n) return;
if (parent[i] >= 0) visit(parent[i], depth + 1);
if (!done[i]) {
done[i] = 1;
order.push(i);
}
};
for (let i = 0; i < n; i++) visit(i, 0);
const bst = data.bones.map((b) => M4.fromQT(b.invRot, b.invPos));
const sk = { name, data, where: hit?.where, source: hit?.source, byName, parent, order, bst, rate: new Float64Array(n).fill(1), linked: data.linked || "" };
s.skel = sk;
for (const m of s.meshes) mapPalette(m);
if (data.linked) linkSkeleton(sk);
return sk;
}
/** Linked skeleton: the bind offset and bone-length ratio come from the skeleton it links to. */
async function linkSkeleton(sk) {
const h = await readNamed(sk.linked, "skeleton");
if (!h) return toast(`${sk.name} links to ${sk.linked}, which isn't in these packs; poses may look off.`, true);
const other = LSF.parseSkl(h.bytes);
if (other.bones.length !== sk.data.bones.length) return toast(`${sk.linked} has a different bone count; not linked (the client does the same).`, true);
sk.data.bones.forEach((b, i) => {
const o = other.bones[i];
const offset = M4.mul(M4.invert(M4.rowMajor(o.objectTM)), M4.rowMajor(b.objectTM));
sk.bst[i] = M4.mul(offset, M4.fromQT(b.invRot, b.invPos));
const linkLen = Math.hypot(...o.localPos);
sk.rate[i] = linkLen > 1e-6 ? Math.hypot(...b.localPos) / linkLen : 1;
});
applyPose();
}
function mapPalette(m) {
const sk = MDL.scene.skel;
if (!m.data.boneNames || !sk) {
m.palette = null;
m.missingBones = [];
return;
}
m.palette = Int32Array.from(m.data.boneNames, (b) => sk.byName.get(b.text.toLowerCase()) ?? -1);
m.missingBones = m.data.boneNames.filter((_, i) => m.palette[i] < 0).map((b) => b.text);
}
// ---------- meshes ----------
/** Mesh + material + textures (meshkit.js), added to the scene. */
async function addMesh(hit, name) {
const s = MDL.scene;
const m = await kit.loadMesh(hit, name);
s.meshes.push(m);
mapPalette(m);
loadTextures(m);
return m;
}
async function loadTextures(m) {
await kit.loadTextures(m);
if (MDL.scene?.meshes[MDL.scene.sel] === m) renderMeshCard();
}
function removeMesh(m) {
const s = MDL.scene;
view.removeMesh(m.id);
s.meshes.splice(s.meshes.indexOf(m), 1);
if (s.sel >= s.meshes.length) s.sel = s.meshes.length - 1;
renderSide();
applyPose();
}
// ---------- animation ----------
async function playRow(r, { force = false } = {}) {
const hit = await readKey(r[0]);
const data = LSF.parseAni(hit.bytes);
const s = MDL.scene;
const names = data.tracks.map((t) => t.name.text.toLowerCase());
const fits = s?.skel && names.filter((n) => s.skel.byName.has(n)).length >= Math.max(1, names.length * 0.5);
if (!fits && !force) {
// show it on a model that uses it, else on the best skeleton
if (!(await confirmDiscard())) return;
loading(true, "Finding a model for this animation…");
const used = MDL.src ? await call("mdl:usedBy", MDL.src, { ani: r[4].split("/").pop() }) : [];
const modelRow = used.length && MDL.rows.find((x) => x[3] === "model" && x[4].toLowerCase().endsWith("/" + used[0]));
if (modelRow) {
await openModel(await readKey(modelRow[0]), modelRow[1]);
} else {
const c = MDL.src ? await call("mdl:findSkeleton", MDL.src, { ani: r[4].split("/").pop(), bones: names }) : [];
const sk = c[0] && (await readNamed(c[0].name, "skeleton"));
const sc = newScene(r[1]);
if (!sc) return;
if (sk) setSkeleton(LSF.parseSkl(sk.bytes), c[0].name, sk);
setToggle("mdl-t-bones", true);
frameScene();
}
}
setAnim(data, `${r[1]}.ani`, hit);
}
async function playNamed(name) {
const h = await readNamed(name, "animation");
if (!h) return toast(`${name} isn't in these packs.`, true);
setAnim(LSF.parseAni(h.bytes), name, h);
}
function mapTracks(a) {
const sk = MDL.scene.skel;
a.map = Int32Array.from(a.data.tracks, (t) => (sk ? sk.byName.get(t.name.text.toLowerCase()) ?? -1 : -1));
}
function setAnim(data, name, hit) {
const s = MDL.scene;
if (!s) return;
s.anim = { name, data, where: hit?.where, source: hit?.source, dirty: false };
mapTracks(s.anim);
MDL.time = 0;
MDL.undo = [];
MDL.redo = [];
$("mdl-timeline").hidden = false;
renderTimeline();
renderSide();
play();
}
// =====================================================================
// pose + skinning (io3DEngine maths: D3D row vectors, v' = v · BipedSpaceTM · BoneWorld)
// =====================================================================
const M4 = {
/** Rotation (quaternion x,y,z,w) + translation as a D3D row-major matrix (D3DXMatrixRotationQuaternion + _41.._43). */
fromQT(q, p) {
const [x, y, z, w] = q;
return new Float64Array([
1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0,
2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0,
2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0,
p[0], p[1], p[2], 1,
]);
},
rowMajor: (a) => Float64Array.from(a),
mul(a, b) {
const o = new Float64Array(16);
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) o[r * 4 + c] = a[r * 4] * b[c] + a[r * 4 + 1] * b[4 + c] + a[r * 4 + 2] * b[8 + c] + a[r * 4 + 3] * b[12 + c];
return o;
},
invert(m) {
const inv = new Float64Array(16);
const a = m;
inv[0] = a[5] * a[10] * a[15] - a[5] * a[11] * a[14] - a[9] * a[6] * a[15] + a[9] * a[7] * a[14] + a[13] * a[6] * a[11] - a[13] * a[7] * a[10];
inv[4] = -a[4] * a[10] * a[15] + a[4] * a[11] * a[14] + a[8] * a[6] * a[15] - a[8] * a[7] * a[14] - a[12] * a[6] * a[11] + a[12] * a[7] * a[10];
inv[8] = a[4] * a[9] * a[15] - a[4] * a[11] * a[13] - a[8] * a[5] * a[15] + a[8] * a[7] * a[13] + a[12] * a[5] * a[11] - a[12] * a[7] * a[9];
inv[12] = -a[4] * a[9] * a[14] + a[4] * a[10] * a[13] + a[8] * a[5] * a[14] - a[8] * a[6] * a[13] - a[12] * a[5] * a[10] + a[12] * a[6] * a[9];
inv[1] = -a[1] * a[10] * a[15] + a[1] * a[11] * a[14] + a[9] * a[2] * a[15] - a[9] * a[3] * a[14] - a[13] * a[2] * a[11] + a[13] * a[3] * a[10];
inv[5] = a[0] * a[10] * a[15] - a[0] * a[11] * a[14] - a[8] * a[2] * a[15] + a[8] * a[3] * a[14] + a[12] * a[2] * a[11] - a[12] * a[3] * a[10];
inv[9] = -a[0] * a[9] * a[15] + a[0] * a[11] * a[13] + a[8] * a[1] * a[15] - a[8] * a[3] * a[13] - a[12] * a[1] * a[11] + a[12] * a[3] * a[9];
inv[13] = a[0] * a[9] * a[14] - a[0] * a[10] * a[13] - a[8] * a[1] * a[14] + a[8] * a[2] * a[13] + a[12] * a[1] * a[10] - a[12] * a[2] * a[9];
inv[2] = a[1] * a[6] * a[15] - a[1] * a[7] * a[14] - a[5] * a[2] * a[15] + a[5] * a[3] * a[14] + a[13] * a[2] * a[7] - a[13] * a[3] * a[6];
inv[6] = -a[0] * a[6] * a[15] + a[0] * a[7] * a[14] + a[4] * a[2] * a[15] - a[4] * a[3] * a[14] - a[12] * a[2] * a[7] + a[12] * a[3] * a[6];
inv[10] = a[0] * a[5] * a[15] - a[0] * a[7] * a[13] - a[4] * a[1] * a[15] + a[4] * a[3] * a[13] + a[12] * a[1] * a[7] - a[12] * a[3] * a[5];
inv[14] = -a[0] * a[5] * a[14] + a[0] * a[6] * a[13] + a[4] * a[1] * a[14] - a[4] * a[2] * a[13] - a[12] * a[1] * a[6] + a[12] * a[2] * a[5];
inv[3] = -a[1] * a[6] * a[11] + a[1] * a[7] * a[10] + a[5] * a[2] * a[11] - a[5] * a[3] * a[10] - a[9] * a[2] * a[7] + a[9] * a[3] * a[6];
inv[7] = a[0] * a[6] * a[11] - a[0] * a[7] * a[10] - a[4] * a[2] * a[11] + a[4] * a[3] * a[10] + a[8] * a[2] * a[7] - a[8] * a[3] * a[6];
inv[11] = -a[0] * a[5] * a[11] + a[0] * a[7] * a[9] + a[4] * a[1] * a[11] - a[4] * a[3] * a[9] - a[8] * a[1] * a[7] + a[8] * a[3] * a[5];
inv[15] = a[0] * a[5] * a[10] - a[0] * a[6] * a[9] - a[4] * a[1] * a[10] + a[4] * a[2] * a[9] + a[8] * a[1] * a[6] - a[8] * a[2] * a[5];
let det = a[0] * inv[0] + a[1] * inv[4] + a[2] * inv[8] + a[3] * inv[12];
if (Math.abs(det) < 1e-12) return M4.fromQT([0, 0, 0, 1], [0, 0, 0]);
det = 1 / det;
for (let i = 0; i < 16; i++) inv[i] *= det;
return inv;
},
};
const qmul = (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],
];
/** Rotates v by q (HelpFunc.cpp operator*: v + 2w(q×v) + 2q×(q×v)). */
function qrot(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)];
}
/** Bone world transforms + skin matrices at time t. */
function computePose(t) {
const sk = MDL.scene.skel;
const bones = sk.data.bones;
const n = bones.length;
const lp = bones.map((b) => b.localPos.slice());
const lr = bones.map((b) => b.localRot.slice());
const a = MDL.scene.anim;
if (a) {
const p = [0, 0, 0];
const q = [0, 0, 0, 1];
a.data.tracks.forEach((tr, ti) => {
const bi = a.map[ti];
if (bi < 0 || tr.weight <= 0) return; // unknown bone: skipped (the client would move the root)
if (!LSF.sampleTrack(tr, t, p, q)) return;
const r = sk.rate[bi];
lp[bi] = [p[0] * r, p[1] * r, p[2] * r];
lr[bi] = q.slice();
});
}
const wp = new Array(n);
const 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] = qmul(wr[pi], lr[i]);
const v = qrot(wr[pi], lp[i]);
wp[i] = [v[0] + wp[pi][0], v[1] + wp[pi][1], v[2] + wp[pi][2]];
}
}
const skin = new Array(n);
for (let i = 0; i < n; i++) skin[i] = M4.mul(sk.bst[i], M4.fromQT(wr[i] || [0, 0, 0, 1], wp[i] || [0, 0, 0]));
return { wp, wr, skin };
}
function skinMesh(m, pose) {
const d = m.data;
const V = d.vertexCount;
const P = d.pos;
const N = d.normal;
const out = m.skinPos;
const on = m.skinNrm;
const pal = m.palette;
for (let v = 0; v < V; v++) {
let x = 0, y = 0, z = 0, nx = 0, ny = 0, nz = 0, tw = 0;
const px = P[v * 3], py = P[v * 3 + 1], pz = P[v * 3 + 2];
const qx = N ? N[v * 3] : 0, qy = N ? N[v * 3 + 1] : 0, qz = N ? N[v * 3 + 2] : 0;
for (let j = 0; j < 4; j++) {
const w = d.weights[v * 4 + j];
if (w <= 0) continue;
const bi = pal[d.boneIdx[v * 4 + j]];
if (bi === undefined || bi < 0) continue;
const M = pose.skin[bi];
x += w * (px * M[0] + py * M[4] + pz * M[8] + M[12]);
y += w * (px * M[1] + py * M[5] + pz * M[9] + M[13]);
z += w * (px * M[2] + py * M[6] + pz * M[10] + M[14]);
if (N) {
nx += w * (qx * M[0] + qy * M[4] + qz * M[8]);
ny += w * (qx * M[1] + qy * M[5] + qz * M[9]);
nz += w * (qx * M[2] + qy * M[6] + qz * M[10]);
}
tw += w;
}
if (tw <= 0) {
x = px;
y = py;
z = pz;
nx = qx;
ny = qy;
nz = qz;
} else if (Math.abs(tw - 1) > 1e-3) {
x /= tw;
y /= tw;
z /= tw;
}
out[v * 3] = x;
out[v * 3 + 1] = y;
out[v * 3 + 2] = z;
if (on) {
on[v * 3] = nx;
on[v * 3 + 1] = ny;
on[v * 3 + 2] = nz;
}
}
view.updateMesh(m.id, out, on);
}
let lastPose = null;
function applyPose() {
const s = MDL.scene;
if (!s || !view) return;
lastPose = s.skel ? computePose(MDL.time) : null;
for (const m of s.meshes) if (m.palette && lastPose) skinMesh(m, lastPose);
if (MDL.heroPreview) for (const m of s.meshes) view.setTransform(m.id, heroRoot.pos, [1, 1, 1], [0, heroRoot.yaw, 0]);
drawOverlays();
renderClock();
}
// ---------- overlays ----------
const C = { bone: [0.96, 0.79, 0.35, 1], joint: [1, 0.9, 0.55, 1], root: [1, 0.42, 0.37, 1], grid: [1, 1, 1, 0.1], axis: [1, 1, 1, 0.26], box: [0.35, 0.8, 1, 0.55], point: [1, 0.42, 0.37, 1] };
function push(arr, p, c) {
arr.push(p[0], p[1], p[2], c[0], c[1], c[2], c[3]);
}
function sceneBounds() {
const s = MDL.scene;
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
const add = (p) => {
for (let i = 0; i < 3; i++) {
min[i] = Math.min(min[i], p[i]);
max[i] = Math.max(max[i], p[i]);
}
};
for (const m of s.meshes) {
if (!m.visible) continue;
add(m.data.aabbMin);
add(m.data.aabbMax);
}
if (s.skel) {
const pose = computePose(0);
pose.wp.forEach((p) => p && add(p));
}
if (!isFinite(min[0])) return [[-50, 0, -50], [50, 100, 50]];
return [min, max];
}
// Floor grid: sized to the model normally, to the training arena while hero training runs.
let sceneGrid = null, practiceGrid = null;
function floorGrid(half, step) {
const g = [];
for (let v = -half; v <= half + 1e-6; v += step) {
const c = Math.abs(v) < step / 2 ? C.axis : C.grid;
push(g, [v, 0, -half], c);
push(g, [v, 0, half], c);
push(g, [-half, 0, v], c);
push(g, [half, 0, v], c);
}
view.setOverlay("grid", new Float32Array(g));
view.showOverlay("grid", toggled("mdl-t-grid"));
}
/** Training arena floor (half size in game units), or null to go back to the model-sized grid. */
function setPracticeFloor(half) {
practiceGrid = half ? [half, 50] : null;
if (!view) return;
const grid = practiceGrid || sceneGrid;
if (grid) floorGrid(...grid);
view.dirty = true;
}
function frameScene() {
const [min, max] = sceneBounds();
view.frame(min, max);
// floor grid under the model
const r = Math.max(max[0] - min[0], max[2] - min[2], max[1] - min[1], 20);
const step = 10 ** Math.floor(Math.log10(r / 4));
sceneGrid = [Math.ceil(r / step) * step, step];
floorGrid(...(practiceGrid || sceneGrid));
const b = [];
const corners = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => [i & 1 ? max[0] : min[0], i & 2 ? max[1] : min[1], i & 4 ? max[2] : min[2]]);
for (const [a, c] of [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]) {
push(b, corners[a], C.box);
push(b, corners[c], C.box);
}
view.setOverlay("box", new Float32Array(b));
view.showOverlay("box", toggled("mdl-t-box"));
const s = MDL.scene;
$("mdl-info").textContent = [
s.meshes.length ? plural(s.meshes.reduce((a, m) => a + m.data.faceCount, 0), "triangle") : "",
s.skel ? plural(s.skel.data.bones.length, "bone") : "",
`${Math.round(max[1] - min[1])} tall`,
]
.filter(Boolean)
.join(" · ");
}
function drawOverlays() {
const s = MDL.scene;
const labels = $("mdl-labels");
labels.replaceChildren();
// bones
if (s.skel && lastPose) {
const l = [];
const pts = [];
s.skel.parent.forEach((pi, i) => {
const p = lastPose.wp[i];
if (!p) return;
push(pts, p, pi < 0 ? C.root : C.joint);
if (pi >= 0 && lastPose.wp[pi]) {
push(l, lastPose.wp[pi], C.bone);
push(l, p, C.bone);
}
});
view.setOverlay("bones", new Float32Array(l), { depth: false });
view.setOverlay("joints", new Float32Array(pts), { mode: "points", depth: false, point: 5 });
} else {
view.setOverlay("bones", null);
view.setOverlay("joints", null);
}
const showBones = toggled("mdl-t-bones");
view.showOverlay("bones", showBones);
view.showOverlay("joints", showBones);
// control points (trail anchors), moved with their bone
const cp = [];
const tags = [];
for (const m of s.meshes) {
if (!m.visible) continue;
for (const c of m.data.controlPoints || []) {
let p = c.point;
const bi = s.skel && lastPose ? s.skel.byName.get(c.bone.text.toLowerCase()) : undefined;
if (bi !== undefined && bi >= 0) {
const M = lastPose.skin[bi];
p = [p[0] * M[0] + p[1] * M[4] + p[2] * M[8] + M[12], p[0] * M[1] + p[1] * M[5] + p[2] * M[9] + M[13], p[0] * M[2] + p[1] * M[6] + p[2] * M[10] + M[14]];
}
push(cp, p, C.point);
tags.push([p, c.name.text]);
}
}
const showPts = toggled("mdl-t-points");
view.setOverlay("points", cp.length ? new Float32Array(cp) : null, { mode: "points", depth: false, point: 8 });
view.showOverlay("points", showPts);
if (showPts && tags.length <= 40) {
for (const [p, text] of tags) {
const sp = view.project(p);
if (sp) labels.append(el("span", { class: "mdl-tag", text, style: `left:${sp[0]}px;top:${sp[1]}px` }));
}
}
}
// labels follow the camera
$("mdl-canvas").addEventListener("mousemove", (e) => e.buttons && MDL.scene && requestAnimationFrame(drawOverlays));
$("mdl-canvas").addEventListener("wheel", () => MDL.scene && requestAnimationFrame(drawOverlays));
// ---------- toggles ----------
const toggled = (id) => $(id).classList.contains("on");
function setToggle(id, on) {
$(id).classList.toggle("on", on);
applyToggles();
}
function applyToggles() {
if (!view) return;
view.opts.textures = toggled("mdl-t-tex");
view.opts.wire = toggled("mdl-t-wire");
view.showOverlay("grid", toggled("mdl-t-grid"));
view.showOverlay("box", toggled("mdl-t-box"));
if (MDL.scene) drawOverlays();
view.dirty = true;
}
for (const id of ["mdl-t-tex", "mdl-t-wire", "mdl-t-bones", "mdl-t-points", "mdl-t-grid", "mdl-t-box"]) $(id).onclick = () => setToggle(id, !toggled(id));
$("mdl-reset").onclick = () => view?.reset();
// =====================================================================
// timeline
// =====================================================================
let rafId = 0;
let lastT = 0;
function play() {
const a = MDL.scene?.anim;
if (!a) return;
if (MDL.time >= a.data.total && !MDL.loop) MDL.time = 0;
MDL.playing = true;
$("mdl-play").replaceChildren(icon("pause"));
lastT = performance.now();
cancelAnimationFrame(rafId);
const tick = (now) => {
if (!MDL.playing) return;
const s = MDL.scene;
const total = Math.max(1, s?.anim?.data.total || 1);
const previousTime = MDL.time;
MDL.time += (now - lastT) * MDL.speed;
lastT = now;
if (heroSequence?.onEvent && s?.anim?.data.events) {
for (const event of s.anim.data.events) {
if (event.time >= previousTime && event.time < Math.min(MDL.time, total)) heroSequence.onEvent(event);
}
}
if (MDL.time > total) {
if (heroSequence) {
if (heroSequence.steps.length) {
const next = heroSequence.steps.shift();
setAnim(next.data, next.name, next.hit);
heroSequence.onStep?.(next.name);
return;
}
const done = heroSequence.onDone;
heroSequence = null;
MDL.time = total;
stop();
applyPose();
done?.();
return;
}
if (MDL.loop) MDL.time %= total;
else {
MDL.time = total;
stop();
}
}
if ($("view-mdl").classList.contains("on") || heroShown()) applyPose();
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
}
function stop() {
MDL.playing = false;
cancelAnimationFrame(rafId);
$("mdl-play").replaceChildren(icon("play"));
}
function stopHeroSequence() {
heroSequenceToken++;
heroMotionToken++;
heroSequence = null;
stop();
}
async function playHeroMotion(name) {
if (!name) return;
const token = ++heroMotionToken;
const hit = await readNamed(name, "animation", true);
if (token !== heroMotionToken || !hit || !MDL.heroPreview) return;
stopHeroSequence();
setAnim(LSF.parseAni(hit.bytes), name, hit);
}
async function playSequence(names, { onStep, onEvent, onDone } = {}) {
stopHeroSequence();
const token = heroSequenceToken;
const steps = [];
for (const name of names.filter(Boolean)) {
const hit = await readNamed(name, "animation", true);
if (!hit) throw new Error(`Animation ${name} was not found`);
steps.push({ name, hit, data: LSF.parseAni(hit.bytes) });
}
if (token !== heroSequenceToken || !steps.length) return;
heroSequence = { steps: steps.slice(1), onStep, onEvent, onDone };
setAnim(steps[0].data, steps[0].name, steps[0].hit);
onStep?.(steps[0].name);
}
$("mdl-play").onclick = () => (MDL.playing ? stop() : play());
$("mdl-loop").onclick = () => {
MDL.loop = !MDL.loop;
$("mdl-loop").classList.toggle("on", MDL.loop);
};
$("mdl-speed").onchange = () => (MDL.speed = Number($("mdl-speed").value) || 1);
$("mdl-time").addEventListener("input", () => {
stop();
MDL.time = Number($("mdl-time").value);
applyPose();
});
function renderTimeline() {
const a = MDL.scene?.anim;
$("mdl-timeline").hidden = !a;
if (!a) return;
const total = Math.max(1, a.data.total);
$("mdl-time").max = String(total);
$("mdl-events").replaceChildren(
...a.data.events.map((e) =>
el("i", {
class: "mdl-ev",
title: `${fmtMs(e.time)} · ${e.type.text}${e.sub.text ? ` ${e.sub.text}` : ""}`,
style: `left:${Math.min(100, (100 * e.time) / total)}%`,
onclick: () => {
stop();
MDL.time = e.time;
applyPose();
},
}),
),
);
renderClock();
}
function renderClock() {
const a = MDL.scene?.anim;
if (!a) return;
$("mdl-time").value = String(Math.round(MDL.time));
$("mdl-clock").textContent = `${(MDL.time / 1000).toFixed(2)} / ${(a.data.total / 1000).toFixed(2)} s`;
}
// =====================================================================
// side panel
// =====================================================================
function renderSide() {
const s = MDL.scene;
if (!s) return;
const rows = [];
if (s.model) rows.push(infoRow("cube", "Model", s.model.name, s.model.source));
if (s.skel) {
const linked = s.skel.linked ? ` · linked to ${s.skel.linked}` : "";
rows.push(
infoRow("bone", "Skeleton", `${stem(s.skel.name)} (${s.skel.data.bones.length} bones${linked})`, s.skel.source, [
el("button", { class: "iconbtn only", title: "Change the skeleton", onclick: pickSkeleton }, [icon("swap")]),
]),
);
} else if (s.meshes.some((m) => m.data.boneNames?.length)) {
rows.push(infoRow("bone", "Skeleton", "none — bind pose", "", [el("button", { class: "iconbtn only", title: "Choose a skeleton", onclick: pickSkeleton }, [icon("plus")])]));
}
s.meshes.forEach((m, i) => {
const vis = el("input", { type: "checkbox", checked: m.visible, title: "Show", onchange: () => ((m.visible = vis.checked), view.setVisible(m.id, m.visible), drawOverlays()) });
rows.push(
el("div", { class: `mdl-row mesh${i === s.sel ? " on" : ""}`, onclick: (e) => e.target !== vis && !e.target.closest("button") && selectMesh(i) }, [
vis,
el("span", { class: "grow" }, [el("b", { text: m.name + (m.dirty || m.mat?.dirty ? " •" : "") }), el("em", { text: `${plural(m.data.faceCount, "tri")}${m.missingBones?.length ? ` · ${m.missingBones.length} bones missing` : ""}` })]),
el("button", { class: "iconbtn only", title: "Remove from the scene", onclick: () => removeMesh(m) }, [icon("trash")]),
]),
);
});
if (s.animList.length) {
const sel = el("select", { class: "grow", title: "Animations of this model", onchange: () => sel.value && playNamed(sel.value).then(renderSide) }, [
el("option", { value: "", text: `${s.animList.length} animations…` }),
...s.animList.map((a) => el("option", { value: a, text: a, selected: s.anim?.name === a })),
]);
rows.push(el("div", { class: "mdl-row" }, [icon("play"), sel]));
}
if (s.missing.length) rows.push(el("div", { class: "mdl-note bad", text: `Missing: ${s.missing.join(", ")}` }));
$("mdl-scene").replaceChildren(...rows);
$("mdl-scene-count").textContent = s.meshes.length ? plural(s.meshes.length, "mesh").replace("meshs", "meshes") : "";
if (s.sel < 0 && s.meshes.length) s.sel = s.meshes.reduce((best, m, i) => (m.data.vertexCount > s.meshes[best].data.vertexCount ? i : best), 0);
renderMeshCard();
renderAniCard();
updateSave();
}
function infoRow(ic, label, text, title, extra = []) {
return el("div", { class: "mdl-row", title: title || "" }, [icon(ic), el("span", { class: "grow" }, [el("em", { text: label }), el("b", { text })]), ...extra]);
}
function selectMesh(i) {
MDL.scene.sel = i;
renderSide();
}
async function pickSkeleton() {
const s = MDL.scene;
const m = s.meshes[Math.max(0, s.sel)];
const bones = m?.data.boneNames?.map((b) => b.text) || s.anim?.data.tracks.map((t) => t.name.text) || [];
loading(true, "Finding skeletons…");
let c = [];
try {
c = MDL.src ? await call("mdl:findSkeleton", MDL.src, { mesh: m?.name, ani: s.anim?.name, bones }) : [];
} finally {
loading(false);
}
if (!c.length) return toast("No skeleton in these packs matches.", true);
let choice = c[0].name;
const listEl = el(
"div",
{ class: "choice-list scroll" },
c.map((x, i) =>
el("label", { class: "choice" }, [
el("input", { type: "radio", name: "mdlskl", checked: i === 0, onchange: () => (choice = x.name) }),
el("span", {}, [el("b", { text: x.name }), el("em", { text: `${x.fromModel ? "used by a model config · " : ""}${x.total ? `${x.matched}/${x.total} bones match` : ""} · ${x.bones} bones` })]),
]),
),
);
if (!(await modal("Skeleton", [listEl], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }]))) return;
const h = await readNamed(choice, "skeleton");
if (!h) return;
setSkeleton(LSF.parseSkl(h.bytes), choice, h);
if (s.anim) mapTracks(s.anim);
renderSide();
applyPose();
}
// ---------- mesh card ----------
function renderMeshCard() {
const s = MDL.scene;
const m = s?.meshes[s.sel];
$("mdl-mesh-card").hidden = !m;
if (!m) return;
const d = m.data;
$("mdl-mesh-name").textContent = m.name;
const dl = el("dl", { class: "mdl-dl" });
const add = (k, v, cls) => dl.append(el("dt", { text: k }), el("dd", { text: v, class: cls || "" }));
add("Type", `${LSF.MESH_TYPES[d.type] || d.type} · v${d.version}`);
add("Geometry", `${d.vertexCount.toLocaleString()} vertices · ${d.faceCount.toLocaleString()} triangles · ${d.index32 ? "32" : "16"}-bit indices`);
if (d.boneNames?.length) add("Bones", `${d.boneNames.length} used${m.missingBones?.length ? ` · not in skeleton: ${m.missingBones.join(", ")}` : ""}`, m.missingBones?.length ? "bad" : "");
add("Source", m.source || "", "mono small");
const subs = el(
"div",
{ class: "mdl-subs" },
m.groups.map((g, i) =>
el("div", { class: "mdl-sub", onmouseenter: () => highlight(m, i, true), onmouseleave: () => highlight(m, i, false) }, [
el("b", { text: `${i + 1}` }),
el("span", { class: "grow mono", text: g.texName || "(no texture)", title: g.texInfo?.source || g.texInfo?.error || "" }),
el("em", { class: g.texInfo?.error ? "bad" : "", text: g.texInfo ? (g.texInfo.error ? "missing" : `${g.texInfo.w}×${g.texInfo.h}`) : g.texName ? "…" : "" }),
]),
),
);
const matBtn = el("button", { class: "iconbtn", title: m.mat?.source || "", disabled: !m.mat, onclick: () => editMaterial(m) }, [icon("note"), el("span", { text: m.mat ? `Material ${m.name}.txt${m.mat.dirty ? " •" : ""}` : "No material script" })]);
$("mdl-mesh").replaceChildren(dl, el("h5", { text: `Sub-meshes (${m.groups.length})` }), subs, matBtn, controlPointsEditor(m));
}
function highlight(m, gi, on) {
const g = m.groups[gi];
if (!g) return;
g.highlight = on;
view.dirty = true;
}
function controlPointsEditor(m) {
const cps = m.data.controlPoints || (m.data.controlPoints = []);
const bones = MDL.scene.skel?.data.bones.map((b) => b.name.text) || [];
const wrap = el("div", { class: "mdl-cps" });
wrap.append(el("h5", { text: `Control points (${cps.length})`, title: "Weapon trail anchors: trailstart_N / trailend_N, linked to a bone" }));
cps.forEach((c, i) => {
const txt = (s, ph, onSet) => {
const inp = el("input", { type: "text", value: s.text, placeholder: ph, spellcheck: "false" });
inp.addEventListener("change", () => {
if (!isAscii(inp.value)) return ((inp.value = s.text), toast("Plain ASCII only.", true));
pushUndo();
onSet(inp.value);
meshChanged(m);
});
return inp;
};
const num = (k) => {
const inp = el("input", { type: "number", value: String(+c.point[k].toFixed(3)), step: "0.5" });
inp.addEventListener("change", () => {
const v = Number(inp.value);
if (!isFinite(v)) return;
pushUndo();
c.point[k] = v;
meshChanged(m, { keepCard: true });
});
return inp;
};
const boneSel = bones.length
? el("select", { title: "Linked bone", onchange: (e) => (pushUndo(), (c.bone = LSF.S(e.target.value)), meshChanged(m)) }, [
...(bones.includes(c.bone.text) ? [] : [el("option", { value: c.bone.text, text: c.bone.text || "(none)" })]),
...bones.map((b) => el("option", { value: b, text: b, selected: b === c.bone.text })),
])
: txt(c.bone, "bone", (v) => (c.bone = LSF.S(v)));
wrap.append(
el("div", { class: "mdl-cp" }, [
el("div", { class: "row" }, [txt(c.name, "trailstart_0", (v) => (c.name = LSF.S(v))), boneSel, el("button", { class: "iconbtn only danger", title: "Remove", onclick: () => (pushUndo(), cps.splice(i, 1), meshChanged(m)) }, [icon("trash")])]),
el("div", { class: "row xyz" }, [num(0), num(1), num(2), txt(c.extra, "extra", (v) => (c.extra = LSF.S(v)))]),
]),
);
});
wrap.append(
el("button", {
class: "iconbtn",
onclick: () => {
pushUndo();
const n = cps.filter((c) => /^trailstart_/i.test(c.name.text)).length;
cps.push({ name: LSF.S(`trailstart_${n}`), bone: LSF.S(bones[0] || ""), extra: LSF.S(""), point: [0, 0, 0] });
meshChanged(m);
},
}, [icon("plus"), el("span", { text: "Point" })]),
);
return wrap;
}
function meshChanged(m, { keepCard = false } = {}) {
m.dirty = true;
drawOverlays();
if (!keepCard) renderSide();
else updateSave();
}
async function editMaterial(m) {
const ta = el("textarea", { class: "mdl-mat", spellcheck: "false" });
ta.value = m.mat.text;
const ok = await modal(`Material — ${m.name}.txt`, [el("p", { class: "muted", text: "One material { } block per sub-mesh. Texture names are looked up in resource/texture." }), ta], [
{ label: "Cancel", value: false },
{ label: "Apply", kind: "primary", value: true },
]);
if (!ok || ta.value === m.mat.text) return;
if (!isAscii(ta.value.replace(/[\r\n\t]/g, " ")) && LSF.decode(m.mat.raw) !== ta.value) {
if (!(await confirmBox("Non-ASCII text", "The script has characters outside plain ASCII; they will be written as UTF-8 and may not read back the same in the client. Apply anyway?", "Apply"))) return;
}
m.mat.text = ta.value;
m.mat.mats = LSF.parseMaterial(ta.value);
m.mat.dirty = true;
kit.applyMaterial(m).then(() => renderMeshCard());
renderSide();
}
// ---------- animation card ----------
function renderAniCard() {
const s = MDL.scene;
const a = s?.anim;
$("mdl-ani-card").hidden = !a;
if (!a) return;
const d = a.data;
$("mdl-ani-name").textContent = a.name + (a.dirty ? " •" : "");
const missing = d.tracks.filter((_, i) => a.map[i] < 0).length;
const dl = el("dl", { class: "mdl-dl" });
const add = (k, v, cls) => dl.append(el("dt", { text: k }), el("dd", { text: v, class: cls || "" }));
add("Format", `v${d.version} · ${LSF.ANI_VERSIONS[d.version]}`);
add("Tracks", `${d.tracks.length} · ${d.tracks.reduce((x, t) => x + t.times.length, 0).toLocaleString()} keys${missing ? ` · ${missing} not in the skeleton (skipped)` : ""}`, missing ? "bad" : "");
if (a.source) add("Source", a.source, "mono small");
const usedBy = el("dd", { class: "small", text: "…" });
dl.append(el("dt", { text: "Used by" }), usedBy);
if (MDL.src)
call("mdl:usedBy", MDL.src, { ani: a.name })
.then((u) => {
const names = u.map((x) => x.replace(/\.txt$/, ""));
usedBy.textContent = names.length ? names.slice(0, 6).join(", ") + (names.length > 6 ? ` +${names.length - 6} more` : "") : "no model config (hero / item animations are set in the .ini files)";
usedBy.title = names.join("\n");
})
.catch(() => (usedBy.textContent = ""));
// duration (stretches the keys and events with it)
const dur = el("input", { type: "number", min: "1", step: "1", value: String(d.total) });
const stretch = el("input", { type: "checkbox", checked: true });
dur.addEventListener("change", () => {
const v = Math.round(Number(dur.value));
if (!(v > 0) || v === d.total) return (dur.value = String(d.total));
pushUndo();
if (stretch.checked) {
const f = v / d.total;
for (const t of d.tracks) for (let k = 0; k < t.times.length; k++) t.times[k] = Math.round(t.times[k] * f);
for (const e of d.events) e.time = Math.round(e.time * f * 1000) / 1000;
MDL.time *= f;
}
d.total = v;
aniChanged();
});
const durRow = el("div", { class: "mdl-dur" }, [el("label", { class: "fld" }, [el("span", { text: "Duration (ms)" }), dur]), el("label", { class: "chip" }, [stretch, el("span", { text: "Stretch keys & events" })])]);
// events
const evWrap = el("div", { class: "mdl-events-list" });
d.events.forEach((e, i) => {
const t = el("input", { type: "number", step: "1", value: String(+e.time.toFixed(3)), title: "Time (ms)" });
t.addEventListener("change", () => {
const v = Number(t.value);
if (!isFinite(v) || v < 0) return (t.value = String(e.time));
pushUndo();
e.time = v;
aniChanged({ keepCard: true });
});
const txt = (s, ph, set) => {
const inp = el("input", { type: "text", value: s.text, placeholder: ph, spellcheck: "false" });
inp.addEventListener("change", () => {
if (!isAscii(inp.value)) return ((inp.value = s.text), toast("Plain ASCII only (the file keeps CP949 names you don't change).", true));
pushUndo();
set(LSF.S(inp.value));
aniChanged({ keepCard: true });
});
return inp;
};
evWrap.append(
el("div", { class: "mdl-cp" }, [
el("div", { class: "mdl-ev-row" }, [
el("button", { class: "iconbtn only", title: "Go to this time", onclick: () => (stop(), (MDL.time = e.time), applyPose()) }, [icon("target")]),
t,
txt(e.type, "type", (v) => (e.type = v)),
el("button", { class: "iconbtn only danger", title: "Remove", onclick: () => (pushUndo(), d.events.splice(i, 1), aniChanged()) }, [icon("trash")]),
]),
el("div", { class: "mdl-ev-row" }, [txt(e.sub, "info (sound file, effect, …)", (v) => (e.sub = v))]),
]),
);
});
const addEv = el("button", {
class: "iconbtn",
title: "New event at the playhead",
onclick: () => {
pushUndo();
d.events.push({ type: LSF.S("sound"), sub: LSF.S(""), time: Math.round(MDL.time) });
d.events.sort((x, y) => x.time - y.time);
aniChanged();
},
}, [icon("plus"), el("span", { text: "Event at playhead" })]);
// tracks
const q = el("input", { type: "search", placeholder: "Filter bones", spellcheck: "false" });
const trWrap = el("div", { class: "mdl-tracks" });
const drawTracks = () => {
const f = q.value.trim().toLowerCase();
trWrap.replaceChildren(
...d.tracks.flatMap((t, i) => {
if (f && !t.name.text.toLowerCase().includes(f)) return [];
const w = el("input", { type: "number", step: "0.1", min: "0", value: String(+t.weight.toFixed(3)), title: "Track weight (blending)" });
w.addEventListener("change", () => {
const v = Number(w.value);
if (!isFinite(v) || v < 0) return (w.value = String(t.weight));
pushUndo();
t.weight = v;
aniChanged({ keepCard: true });
});
return [
el("div", { class: `mdl-tr${a.map[i] < 0 ? " bad" : ""}`, title: a.map[i] < 0 ? "This bone isn't in the skeleton" : "" }, [
el("span", { class: "grow", text: t.name.text }),
el("em", { text: `${t.times.length}` }),
w,
el("button", { class: "iconbtn only danger", title: "Remove this track", onclick: () => (pushUndo(), d.tracks.splice(i, 1), mapTracks(a), aniChanged()) }, [icon("trash")]),
]),
];
}),
);
};
q.addEventListener("input", drawTracks);
drawTracks();
$("mdl-ani").replaceChildren(
dl,
durRow,
el("h5", { text: `Events (${d.events.length})`, title: "Sound / effect / trail triggers the client fires during the animation" }),
evWrap,
addEv,
el("h5", { text: `Tracks (${d.tracks.length})` }),
el("div", { class: "search" }, [icon("search"), q]),
trWrap,
);
}
function aniChanged({ keepCard = false } = {}) {
const a = MDL.scene.anim;
a.dirty = true;
renderTimeline();
applyPose();
if (!keepCard) renderAniCard();
else $("mdl-ani-name").textContent = a.name + " •";
updateSave();
}
// ---------- undo (whole-file snapshots of the edited animation / meshes) ----------
function snapshot() {
const s = MDL.scene;
return {
anim: s.anim ? { bytes: LSF.writeAni(s.anim.data), dirty: s.anim.dirty } : null,
cps: s.meshes.map((m) => ({ m, cps: JSON.stringify((m.data.controlPoints || []).map((c) => [c.name.text, c.bone.text, c.extra.text, c.point])), raw: m.data.controlPoints, dirty: m.dirty })),
};
}
function pushUndo() {
MDL.undo.push(snapshot());
if (MDL.undo.length > 100) MDL.undo.shift();
MDL.redo = [];
}
function restore(from, to) {
const snap = from.pop();
if (!snap) return;
to.push(snapshot());
const s = MDL.scene;
if (snap.anim && s.anim) {
s.anim.data = LSF.parseAni(snap.anim.bytes);
s.anim.dirty = snap.anim.dirty;
mapTracks(s.anim);
}
for (const c of snap.cps) {
if (!s.meshes.includes(c.m)) continue;
c.m.data.controlPoints = JSON.parse(c.cps).map(([name, bone, extra, point], i) => {
const old = c.raw?.[i];
return { name: old && old.name.text === name ? old.name : LSF.S(name), bone: old && old.bone.text === bone ? old.bone : LSF.S(bone), extra: old && old.extra.text === extra ? old.extra : LSF.S(extra), point };
});
c.m.dirty = c.dirty;
}
renderTimeline();
renderSide();
applyPose();
}
// =====================================================================
// save / export
// =====================================================================
function dirtyItems() {
const s = MDL.scene;
if (!s) return [];
const out = [];
if (s.anim?.dirty) out.push({ what: "animation", name: s.anim.name, ext: "ani", obj: s.anim, bytes: () => LSF.writeAni(s.anim.data) });
for (const m of s.meshes) {
if (m.dirty) out.push({ what: "mesh", name: `${m.name}.msh`, ext: "msh", obj: m, bytes: () => LSF.writeMsh(m.data) });
if (m.mat?.dirty) out.push({ what: "material", name: `${m.name}.txt`, ext: "txt", obj: m.mat, bytes: () => encodeText(m.mat) });
}
return out;
}
function encodeText(mat) {
if (LSF.decode(mat.raw) === mat.text) return mat.raw;
const t = mat.text;
if (/^[\x00-\x7e]*$/.test(t)) return Uint8Array.from(t, (c) => c.charCodeAt(0));
return new TextEncoder().encode(t);
}
function updateSave() {
const n = dirtyItems().length;
$("mdl-save").disabled = !n;
$("mdl-dirty").hidden = !n;
}
async function saveItem(it, as = false) {
let where = it.obj.where;
if (as || !where) {
const file = await call("dialog:saveAs", it.name, it.ext);
if (!file) return false;
where = { kind: "file", file };
} else if (where.kind === "pack") {
const pack = where.file.split(/[\\/]/).pop();
if (!(await confirmBox("Save into the pack", `Write ${where.entry} into ${pack}? The new copy replaces the old one (IOP Manager's Optimize frees the old space).`, "Save"))) return false;
}
try {
await call("mdl:writeBack", where, it.bytes());
it.obj.where = where;
it.obj.dirty = false;
if (it.obj.raw !== undefined && it.what === "material") it.obj.raw = encodeText(it.obj);
if (where.kind === "pack") kit.clearTextures(); // the source's packs were reopened
toast(`Saved ${it.name} ${where.kind === "pack" ? `into ${where.file.split(/[\\/]/).pop()}` : `to ${where.file}`}.`);
renderSide();
return true;
} catch (err) {
toast(err.message, true);
return false;
}
}
$("mdl-save").onclick = (e) => {
const items = dirtyItems();
if (!items.length) return;
const menu = [];
for (const it of items) {
const w = it.obj.where;
menu.push({ label: w?.kind === "pack" ? `Save ${it.name} into ${w.file.split(/[\\/]/).pop()}` : w?.kind === "file" ? `Save ${it.name}` : `Save ${it.name}…`, icon: w?.kind === "pack" ? "box" : "save", run: () => saveItem(it) });
menu.push({ label: `Save ${it.name} as file…`, icon: "download", run: () => saveItem(it, true) });
}
showMenu(menu, { anchor: e.currentTarget });
};
$("mdl-export").onclick = (e) =>
showMenu(
[
{ label: "Screenshot (PNG)…", icon: "camera", disabled: !MDL.scene, run: screenshot },
{ label: "Export OBJ + textures (current pose)…", icon: "cube", disabled: !MDL.scene?.meshes.length, run: exportObj },
"-",
{ label: "Save the original .msh / .ani / .skl as file…", icon: "download", disabled: !MDL.scene, run: exportOriginal },
],
{ anchor: e.currentTarget },
);
async function screenshot() {
const file = await call("dialog:savePng", `${MDL.scene.title}.png`);
if (!file) return;
view.draw();
const src = $("mdl-canvas");
const c = document.createElement("canvas");
c.width = src.width;
c.height = src.height;
const g = c.getContext("2d");
const bg = g.createLinearGradient(0, 0, 0, c.height);
bg.addColorStop(0, "#1a191f");
bg.addColorStop(1, "#0b0a0d");
g.fillStyle = bg;
g.fillRect(0, 0, c.width, c.height);
g.drawImage(src, 0, 0);
await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]);
toast(`Saved ${file}.`);
}
async function exportOriginal() {
const s = MDL.scene;
const items = [
...s.meshes.map((m) => ({ label: `${m.name}.msh`, run: () => saveBytes(`${m.name}.msh`, "msh", m.data.bytes) })),
s.skel && { label: stem(s.skel.name) + ".skl", run: () => readNamed(s.skel.name, "skeleton").then((h) => h && saveBytes(stem(s.skel.name) + ".skl", "skl", h.bytes)) },
s.anim && { label: s.anim.name, run: () => saveBytes(s.anim.name, "ani", LSF.writeAni(s.anim.data)) },
].filter(Boolean);
showMenu(items.map((x) => ({ ...x, icon: "download" })), { anchor: $("mdl-export") });
}
async function saveBytes(name, ext, bytes) {
const file = await call("dialog:saveAs", name, ext);
if (!file) return;
await call("mdl:writeBack", { kind: "file", file }, bytes);
toast(`Saved ${file}.`);
}
/** OBJ + MTL + PNG textures of the visible meshes in the current pose (right-handed, for Blender etc.). */
async function exportObj(target) {
const s = MDL.scene;
const folder = typeof target === "string" ? target : await call("dialog:folder", "Folder for the OBJ export");
if (!folder) return;
const base = s.title.replace(/[^\w.-]+/g, "_");
const obj = [`# ${s.title} — exported by LS Tools`, `mtllib ${base}.mtl`];
const mtl = [];
const files = [];
const texDone = new Set();
let vOff = 1;
const used = new Map();
for (const m of s.meshes) {
if (!m.visible) continue;
const n = (used.get(m.name) || 0) + 1;
used.set(m.name, n);
const oname = n > 1 ? `${m.name}_${n}` : m.name;
const d = m.data;
const P = m.palette && lastPose ? m.skinPos : d.pos;
const N = m.palette && lastPose && m.skinNrm ? m.skinNrm : d.normal;
const uvSet = m.groups[0]?.uvSet === 1 && d.uv1 ? d.uv1 : d.uv0;
obj.push(`o ${oname}`);
for (let v = 0; v < d.vertexCount; v++) obj.push(`v ${P[v * 3].toFixed(4)} ${P[v * 3 + 1].toFixed(4)} ${(-P[v * 3 + 2]).toFixed(4)}`);
if (uvSet) for (let v = 0; v < d.vertexCount; v++) obj.push(`vt ${uvSet[v * 2].toFixed(5)} ${(1 - uvSet[v * 2 + 1]).toFixed(5)}`);
if (N) for (let v = 0; v < d.vertexCount; v++) obj.push(`vn ${N[v * 3].toFixed(4)} ${N[v * 3 + 1].toFixed(4)} ${(-N[v * 3 + 2]).toFixed(4)}`);
m.groups.forEach((g, gi) => {
const mname = `${oname}_${gi + 1}`;
obj.push(`g ${mname}`, `usemtl ${mname}`);
mtl.push(`newmtl ${mname}`, "Kd 1 1 1");
if (g.texName && g.texInfo?.img) {
const png = g.texName.replace(/\.[^.]+$/, "") + ".png";
mtl.push(`map_Kd ${png}`);
if (!texDone.has(png)) {
texDone.add(png);
files.push({ name: png, img: g.texInfo.img });
}
}
mtl.push("");
const f = (i) => {
const k = d.indices[i] + vOff;
return uvSet && N ? `${k}/${k}/${k}` : uvSet ? `${k}/${k}` : N ? `${k}//${k}` : `${k}`;
};
// mirrored Z: reverse the winding
for (let i = g.start; i + 2 < g.start + g.count; i += 3) obj.push(`f ${f(i)} ${f(i + 2)} ${f(i + 1)}`);
});
vOff += d.vertexCount;
}
const out = [
{ name: `${base}.obj`, data: obj.join("\n") + "\n" },
{ name: `${base}.mtl`, data: mtl.join("\n") + "\n" },
];
for (const t of files) {
const c = document.createElement("canvas");
c.width = t.img.width;
c.height = t.img.height;
const g = c.getContext("2d");
if (t.img instanceof ImageData) g.putImageData(t.img, 0, 0);
else g.drawImage(t.img, 0, 0);
const b64 = c.toDataURL("image/png").split(",")[1];
out.push({ name: t.name, data: Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)) });
}
try {
await call("mdl:writeFiles", folder, out);
toast(`Exported ${base}.obj with ${files.length} texture(s) to ${folder}.`);
} catch (err) {
toast(err.message, true);
}
}
// ---------- loose files ----------
$("mdl-open-file").onclick = async () => {
const file = await call("dialog:openModel");
if (!file) return;
try {
const hit = await call("mdl:readFile", file);
const ext = file.split(".").pop().toLowerCase();
if (ext === "msh") await openMesh(hit);
else if (ext === "skl") await openSkeleton(hit);
else if (ext === "ani") {
const data = LSF.parseAni(hit.bytes);
if (!MDL.scene?.skel) {
const c = MDL.src ? await call("mdl:findSkeleton", MDL.src, { ani: file.split(/[\\/]/).pop(), bones: data.tracks.map((t) => t.name.text) }) : [];
if (!newScene(stem(file))) return;
const sk = c[0] && (await readNamed(c[0].name, "skeleton"));
if (sk) setSkeleton(LSF.parseSkl(sk.bytes), c[0].name, sk);
else toast("Choose packs first so a skeleton can be found for this animation.", true);
setToggle("mdl-t-bones", true);
frameScene();
}
setAnim(data, file.split(/[\\/]/).pop(), hit);
} else {
const text = LSF.decode(hit.bytes);
if (!/#MODEL_CFG/.test(text)) throw new Error("This .txt isn't a model config (#MODEL_CFG).");
await openModel(hit, stem(file));
}
} catch (err) {
toast(err.message, true);
} finally {
loading(false);
}
};
// ---------- keys ----------
document.addEventListener("keydown", (e) => {
if (!$("view-mdl").classList.contains("on") || !MDL.scene) return;
if (e.target.matches?.("input, textarea, select")) return;
if (e.key === " ") return e.preventDefault(), MDL.playing ? stop() : play();
if (e.ctrlKey && e.key.toLowerCase() === "z") return e.preventDefault(), restore(MDL.undo, MDL.redo);
if (e.ctrlKey && e.key.toLowerCase() === "y") return e.preventDefault(), restore(MDL.redo, MDL.undo);
if (e.ctrlKey && e.key.toLowerCase() === "s") {
e.preventDefault();
const it = dirtyItems()[0];
if (it) saveItem(it);
}
if ((e.key === "ArrowLeft" || e.key === "ArrowRight") && MDL.scene.anim) {
e.preventDefault();
stop();
MDL.time = Math.max(0, Math.min(MDL.scene.anim.data.total, MDL.time + (e.key === "ArrowLeft" ? -1 : 1) * (e.shiftKey ? 100 : 33)));
applyPose();
}
});
// ---------- texture animations (material scroll / rotate / wave / frames), always running ----------
const TEXANIM = { t0: performance.now() };
function texAnimTick(now) {
const s = MDL.scene;
if (view && kit && s && ($("view-mdl").classList.contains("on") || heroShown())) {
const on = toggled("mdl-t-texanim");
let any = false;
for (const m of s.meshes) any = kit.animate(m, on ? (now - TEXANIM.t0) / 1000 : null) || any;
if (any && (on || !TEXANIM.rested)) view.dirty = true;
TEXANIM.rested = !on;
}
requestAnimationFrame(texAnimTick);
}
requestAnimationFrame(texAnimTick);
$("mdl-t-texanim").onclick = () => $("mdl-t-texanim").classList.toggle("on");
// First visit: default to the packs next to a file open in IOP Manager
const modelStage = $("mdl-canvas").parentElement;
let modelEnvBeforeHero = null;
// The hero viewport is the Hero tab stage by default; FX Lab borrows it with its own host.
let heroHost = null;
const observedStages = new WeakSet();
function embedHero(on, host = null) {
if (on) heroHost = host || { stage: $("hero-stage"), viewId: "view-hero" };
ensureView();
if (on && !MDL.heroPreview) {
modelEnvBeforeHero = view.env;
// The normal model viewer clears transparent. Additive particles need an
// opaque target here or canvas alpha compositing exposes black DDS quads.
view.env = { ...(view.env || {}), clear: [0.09, 0.085, 0.10] };
} else if (!on && MDL.heroPreview) view.env = modelEnvBeforeHero;
MDL.heroPreview = !!on;
if (!on) setHeroPracticeTarget(null);
const target = on ? heroHost.stage : modelStage;
if (on && !observedStages.has(target)) { observedStages.add(target); new ResizeObserver(() => { if (MDL.heroPreview && $("mdl-canvas").parentElement === target) view?.resize(); }).observe(target); }
if ($("mdl-canvas").parentElement !== target) target.prepend($("mdl-canvas"));
view?.resize();
}
document.querySelector('[data-view="mdl"]').addEventListener("click", async () => {
if (MDL.heroPreview) embedHero(false);
ensureView();
view?.resize();
if (MDL.src) return;
if (profileSource()) return setSource(profileSource(), { quiet: true });
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
const s = sessions.find((x) => /^(rs|map)\.iop$/i.test(x.name)) || sessions[0];
if (s) await setSource({ kind: "session", sessionId: s.id, name: s.name }, { quiet: true });
});
const sameSource = (src) => MDL.src && MDL.src.kind === src.kind && MDL.src.folder === src.folder && MDL.src.sessionId === src.sessionId;
async function previewAsset(src, name, kind) {
if (!sameSource(src)) await setSource(src, { quiet: true });
document.querySelector('[data-view="mdl"]').click();
const file = kind === "mesh" && !/\.msh$/i.test(name) ? `${name}.msh` : name;
const row = MDL.rows.find((r) => r[3] === (kind === "mesh" ? "msh" : "ani") && r[4].toLowerCase().endsWith(`/${file.toLowerCase()}`));
if (!row) throw new Error(`${file} was not found in the model source`);
await openRow(row);
}
async function previewHero(src, meshes, animation, { embedded = false, host = null, hair = null } = {}) {
if (!sameSource(src)) await setSource(src, { quiet: true });
if (embedded) embedHero(true, host);
else document.querySelector('[data-view="mdl"]').click();
stopHeroSequence(); heroFx = null; clearHeroFxModels(); view?.setParticles([]);
const hit = await readNamed("sp2_human_man.txt", "model");
if (!hit) throw new Error("sp2_human_man.txt was not found in the model source");
await openModel(hit, "Hero preview");
if (MDL.scene?.model?.name !== "Hero preview") return;
if (meshes.some((name) => /_body(?:\d+)?$/i.test(name))) {
const baseBody = MDL.scene.meshes.find((m) => m.name === "human_body");
if (baseBody) removeMesh(baseBody);
}
// web fix: the helmet's hair rule (0 half, 1 none, 2 bind, 3 full)
if (hair !== null && hair !== undefined && Number(hair) !== 3) {
const baseHair = MDL.scene.meshes.find((m) => /^hair/i.test(m.name));
if (baseHair) {
removeMesh(baseHair);
const alt = Number(hair) === 0 ? `half${baseHair.name}` : Number(hair) === 2 ? `bind${baseHair.name}` : "";
const h = alt ? await readNamed(`${alt}.msh`, "mesh") : null;
if (h) await addMesh(h, alt);
}
}
const missing = [];
for (const name of [...new Set(meshes)].filter(Boolean)) {
const h = await readNamed(`${name.replace(/\.msh$/i, "")}.msh`, "mesh");
if (!h) { missing.push(name); continue; }
await addMesh(h, name);
}
frameScene(); renderSide();
setHeroTransform(heroRoot.pos, heroRoot.yaw);
if (animation) await playNamed(animation);
applyPose();
if (missing.length) toast(`Missing gear meshes: ${missing.join(", ")}`, true);
}
function clearHeroFxModels() {
heroFxModelSeq++;
for (const entry of heroFxModels) for (const id of entry?.slots || []) view?.removeMesh(id);
heroFxModels = [];
}
function setHeroTransform(pos, yaw) {
heroRoot = { pos: pos.slice(), yaw };
if (!MDL.heroPreview || !MDL.scene || !view) return;
for (const m of MDL.scene.meshes) view.setTransform(m.id, heroRoot.pos, [1, 1, 1], [0, yaw, 0]);
}
function setHeroPracticeTarget(pos) {
heroTarget = pos?.slice() || null;
if (!view) return;
for (const id of heroDummyIds) view.removeMesh(id);
if (!heroTarget || !MDL.heroPreview) return;
const cube = { pos: new Float32Array([-1,-1,-1, 1,-1,-1, 1,1,-1, -1,1,-1, -1,-1,1, 1,-1,1, 1,1,1, -1,1,1]),
indices: new Uint32Array([0,2,1,0,3,2, 4,5,6,4,6,7, 0,1,5,0,5,4, 3,7,6,3,6,2, 0,4,7,0,7,3, 1,2,6,1,6,5]),
groups: [{ start: 0, count: 36, color: [0.62, 0.43, 0.28, 1] }] };
view.addMesh(heroDummyIds[0], cube);
view.setTransform(heroDummyIds[0], [heroTarget[0], 45, heroTarget[2]], [16, 45, 14]);
view.addMesh(heroDummyIds[1], { ...cube, groups: [{ start: 0, count: 36, color: [0.85, 0.69, 0.42, 1] }] });
view.setTransform(heroDummyIds[1], [heroTarget[0], 105, heroTarget[2]], [19, 17, 17]);
}
function clearHeroEffect() {
heroFxRequest++;
heroFx = null;
clearHeroFxModels();
view?.setParticles([]);
}
async function loadHeroFxModel(index, spec, seq) {
const name = (spec.attrs.mesh_file || "").trim().replace(/\.msh$/i, "");
if (!name || name.toLowerCase() === "none") return;
try {
const hit = await kit.readNamed(`${name}.msh`, "mesh", true);
if (!hit || seq !== heroFxModelSeq) return;
const mesh = await kit.loadMesh(hit, name);
if (seq !== heroFxModelSeq) return view.removeMesh(mesh.id);
view.setVisible(mesh.id, false);
heroFxModels[index] = { mesh, slots: [mesh.id] };
await kit.loadTextures(mesh);
if (seq !== heroFxModelSeq) return view.removeMesh(mesh.id);
} catch (err) { if (seq === heroFxModelSeq) console.warn(`Hero effect model ${name}: ${err.message}`); }
}
function syncHeroFxModels() {
heroFx.modelSystems.forEach((system, index) => {
const entry = heroFxModels[index];
if (!entry) return;
kit.animate(entry.mesh, system.time / 1000);
system.parts.forEach((part, i) => {
let id = entry.slots[i];
if (!id) {
id = `hero-fx-model-${heroFxModelSeq}-${index}-${i}`;
const m = entry.mesh;
view.addMesh(id, { pos: m.data.pos, normal: m.data.normal, uv0: m.data.uv0, uv1: m.data.uv1, indices: m.data.indices, groups: m.groups });
entry.slots[i] = id;
}
view.setTransformQuaternion(id, part.pos, part.modelScale, part.modelRotation);
const t = Math.min(1, part.age / part.life), color = system.color(t);
view.setTint(id, [color[0] / 255, color[1] / 255, color[2] / 255, Math.max(0, Math.min(1, system.alpha(t)[0]))]);
view.setVisible(id, true);
});
entry.slots.forEach((id, i) => { if (i >= system.parts.length) view.setVisible(id, false); });
});
}
async function playHeroEffect(src, name, { target = false } = {}) {
if (!name || !view || !kit) return;
const request = ++heroFxRequest;
clearHeroFxModels();
const hit = await call("mdl:read", src, name, "effect", true);
if (request !== heroFxRequest) return;
if (!hit) throw new Error(`Effect ${name} was not found`);
// AttackAttribute explosion_effect is a hit effect in the client. With no
// opponent in this preview, use a point in front of the hero as the hit.
const origin = target ? (heroTarget ? [heroTarget[0], 75, heroTarget[2]] : [0, 75, 130]) : heroRoot.pos;
startHeroFx(Effects.parse(LSF.decode(hit.bytes)), { target });
}
/** Play a parsed effect on the hero (FX Lab): customTexture(name, view) → WebGL texture for textures not in the packs. */
function startHeroFx(def, { target = false, duration = 3500, customTexture = null } = {}) {
heroFxRequest++;
clearHeroFxModels();
const origin = target ? (heroTarget ? [heroTarget[0], 75, heroTarget[2]] : [0, 75, 130]) : heroRoot.pos;
heroFx = Effects.instance(def, origin);
heroFx.target = target;
heroFx.heroPreview = true;
heroFx.customTexture = customTexture;
for (const system of [...heroFx.systems, ...heroFx.modelSystems]) system.heroAttached = true;
const seq = heroFxModelSeq;
heroFx.def.models.forEach((spec, index) => loadHeroFxModel(index, spec, seq));
heroFxUntil = performance.now() + duration;
heroFxLast = 0;
}
const heroShown = () => MDL.heroPreview && !!heroHost && $(heroHost.viewId).classList.contains("on");
const heroBones = () => MDL.scene?.skel ? MDL.scene.skel.data.bones.map((b) => b.name.text) : [];
const heroFxCount = () => heroFx ? Effects.count(heroFx) : 0;
function heroFxTick(now) {
if (view && heroFx && now < heroFxUntil && heroShown()) {
const dt = Math.min(100, Math.max(0, now - (heroFxLast || now)));
heroFxLast = now;
if (!heroFx.target && lastPose && MDL.scene?.skel) for (const system of [...heroFx.systems, ...heroFx.modelSystems]) {
const index = MDL.scene.skel.byName.get(system.linkBone.toLowerCase());
const rootRotation = [0, Math.sin(heroRoot.yaw * Math.PI / 360), 0, Math.cos(heroRoot.yaw * Math.PI / 360)];
const localPos = index === undefined ? null : lastPose.wp[index];
const bonePos = localPos ? qrot(rootRotation, localPos).map((v, i) => v + heroRoot.pos[i]) : heroRoot.pos;
const boneRot = index === undefined ? null : lastPose.wr[index];
system.ownerOrigin = bonePos;
const worldRot = boneRot ? qmul(rootRotation, boneRot) : rootRotation;
system.ownerOffset = boneRot ? qrot(worldRot, system.linkOffset) : [0, 0, 0];
system.ownerRotation = qmul(worldRot, system.linkRotation);
}
Effects.update(heroFx, dt);
syncHeroFxModels();
const parts = Effects.build(heroFx, view.cameraAxes());
for (const p of parts) {
const key = String(p.texture || "").toLowerCase();
if (!key) continue;
const custom = heroFx.customTexture?.(p.texture, view);
if (custom) { p.tex = custom; continue; }
if (!heroTextures.has(key)) {
heroTextures.set(key, null);
kit.texture(p.texture).then((t) => { if (t.tex) heroTextures.set(key, t.tex); });
}
p.tex = heroTextures.get(key);
}
view.setParticles(parts);
} else if (heroFx && now >= heroFxUntil) {
heroFx = null;
clearHeroFxModels();
view?.setParticles([]);
}
requestAnimationFrame(heroFxTick);
}
requestAnimationFrame(heroFxTick);
Object.assign(MDL, { setSource, openRow, openModel, openMesh, playNamed, playHeroMotion, previewAsset, previewHero, setHeroTransform, setHeroPracticeTarget, setPracticeFloor, clearHeroEffect, playSequence, stopHeroSequence, playHeroEffect, startHeroFx, heroBones, heroFxCount, embedHero, applyPose, dirtyItems, saveItem, restore, pushUndo, exportObj, rowsOf: () => list.view });
})();