"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 }); })();