1736 lines
73 KiB
JavaScript
1736 lines
73 KiB
JavaScript
"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 });
|
||
})();
|