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

725 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// Texture Studio (Texture tab): open the pictures of a folder (.png / .jpg / .tga / .bmp / .dds),
// select parts of one (magic wand, colour range, lasso, rectangle), recolour the selection (colourise
// to a colour keeping the shading, or hue / saturation / lightness), and save it as a .dds the way the
// game stores textures (DXT1 / DXT3 / DXT5 / A8R8G8B8, with mip maps) — one file or a whole folder.
// The maths is in texkit.js.
(() => {
const TK = TexKit;
const TEX = {
dir: null,
files: [], // { name, size, mtime }
file: null, // { path, name, stem, ext, info (a .dds: its format) }
w: 0,
h: 0,
base: null, // the pixels as they are now (Uint8ClampedArray RGBA)
shown: null, // what the view shows: base, or base with the recolour previewed
raw: null, // the selection as picked (Uint8Array, 0 / 255)
mask: null, // raw, gaps closed and feathered (what the recolour uses)
undo: [],
redo: [],
tool: "wand",
tol: 24,
contiguous: true,
close: 0,
feather: 1,
rc: { mode: "colorize", color: [230, 190, 100], strength: 1, bright: 0, hue: 0, sat: 0, light: 0, contrast: 0 },
rcOn: false, // the recolour is previewed (not applied yet)
showSel: true,
picking: false, // the next click takes the colourise colour
exp: { format: "auto", mips: true, max: 0 },
dirty: false,
zoom: 1,
pan: [0, 0],
fitted: true, // the view follows the window size until zoomed / moved
};
window.TEX = TEX;
const KEY = "tex:dir";
const stem = (n) => n.replace(/\.[^.]+$/, "");
const extOf = (n) => (n.match(/\.([^.]+)$/)?.[1] || "").toLowerCase();
const join = (d, n) => `${d.replace(/[\\/]+$/, "")}\\${n}`;
const hex = (c) => "#" + c.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
const unhex = (s) => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16));
const field = (label, input) => [el("label", { text: label }), input];
const fill = (box, ...kids) => box.replaceChildren(...kids.filter(Boolean));
let cv, g, imgCanvas, selCanvas;
let started = false;
// =====================================================================
// folder + files
// =====================================================================
async function openFolder(dir) {
dir = dir || (await call("dialog:folder", "Folder with the textures"));
if (!dir) return;
TEX.dir = dir;
try {
localStorage.setItem(KEY, dir);
} catch {}
await listFiles();
}
async function listFiles() {
if (!TEX.dir) return;
try {
TEX.files = (await call("tex:list", TEX.dir)).sort((a, b) => a.name.localeCompare(b.name));
} catch (err) {
TEX.files = [];
toast(`${TEX.dir}: ${err.message}`, true);
}
$("tex-dir").textContent = TEX.dir;
$("tex-dir").title = TEX.dir;
renderList();
}
function renderList() {
const q = $("tex-search").value.trim().toLowerCase();
const rows = TEX.files.filter((f) => !q || f.name.toLowerCase().includes(q));
$("tex-count").textContent = rows.length ? String(rows.length) : "";
// web fix: draw 300 rows at a time, more while scrolling (10 000+ textures froze the page)
const box = $("tex-list");
const CHUNK = 300;
let shown = Math.min(rows.length, Math.max(CHUNK, TEX._shown || 0));
const rowEl = (f) =>
el("div", { class: `tex-row${TEX.file?.name === f.name && TEX.file?.dir === TEX.dir ? " on" : ""}`, title: `${f.name} — ${size(f.size)}`, onclick: () => openFile(join(TEX.dir, f.name)) }, [
el("span", { class: `tex-ext ${extOf(f.name)}`, text: extOf(f.name) }),
el("span", { class: "grow", text: f.name }),
el("span", { class: "dim", text: size(f.size) }),
]);
box.onscroll = () => {
if (shown >= rows.length || box.scrollTop + box.clientHeight < box.scrollHeight - 400) return;
box.append(...rows.slice(shown, shown + CHUNK).map(rowEl));
shown = Math.min(rows.length, shown + CHUNK);
TEX._shown = shown;
};
if (TEX._q !== q) (TEX._q = q), (shown = Math.min(rows.length, CHUNK)), (box.scrollTop = 0);
TEX._shown = shown;
$("tex-list").replaceChildren(
...rows.slice(0, shown).map((f) =>
el("div", { class: `tex-row${TEX.file?.name === f.name && TEX.file?.dir === TEX.dir ? " on" : ""}`, title: `${f.name} — ${size(f.size)}`, onclick: () => openFile(join(TEX.dir, f.name)) }, [
el("span", { class: `tex-ext ${extOf(f.name)}`, text: extOf(f.name) }),
el("span", { class: "grow", text: f.name }),
el("span", { class: "dim", text: size(f.size) }),
]),
),
);
if (!rows.length) $("tex-list").append(el("div", { class: "ans-note", text: TEX.dir ? "No pictures here (.png .jpg .tga .bmp .dds)." : "Open a folder with textures." }));
}
async function keepChanges() {
if (!TEX.dirty) return true;
const r = await modal("Unsaved changes", `${TEX.file.name} has changes that aren't saved.`, [
{ label: "Cancel", value: false },
{ label: "Discard", kind: "danger", value: true },
]);
return r;
}
async function openFile(path) {
if (!(await keepChanges())) return;
const name = path.replace(/\\/g, "/").split("/").pop();
const ext = extOf(name);
loading(true, `Opening ${name}…`);
try {
const { bytes } = await call("mdl:readFile", path);
const bmp = await TexDecode.decode(bytes, ext);
const c = new OffscreenCanvas(bmp.width, bmp.height);
const cx = c.getContext("2d", { willReadFrequently: true });
cx.drawImage(bmp, 0, 0);
const px = cx.getImageData(0, 0, bmp.width, bmp.height).data;
TEX.file = { path, name, stem: stem(name), ext, dir: path.replace(/[\\/][^\\/]*$/, ""), info: ext === "dds" ? TK.ddsInfo(bytes) : null };
TEX.w = bmp.width;
TEX.h = bmp.height;
TEX.base = new Uint8ClampedArray(px);
TEX.raw = TEX.mask = null;
TEX.undo = [];
TEX.redo = [];
TEX.rcOn = false;
TEX.dirty = false;
// a .dds keeps its own format when saved again
if (TEX.file.info && /^DXT[135]$/.test(TEX.file.info.format)) TEX.exp.format = TEX.file.info.format;
else if (TEX.file.info?.format === "RGB32") TEX.exp.format = "ARGB";
else TEX.exp.format = "auto";
TEX.exp.mips = TEX.file.info ? TEX.file.info.mips > 1 : true;
$("tex-empty").hidden = true;
refresh();
fitView();
renderList();
renderSide();
} catch (err) {
toast(`${name}: ${err.message}`, true);
} finally {
loading(false);
}
}
function loading(on, text) {
$("tex-loading").hidden = !on;
if (text) $("tex-loading").querySelector("span").textContent = text;
}
const frame = () => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
// =====================================================================
// pixels → view
// =====================================================================
/** Recomputes the effective mask, the preview and the view images. */
function refresh() {
if (!TEX.base) return;
const { w, h } = TEX;
if (TEX.raw) {
let m = TEX.raw;
if (TEX.close) m = TK.grow(TK.grow(m, w, h, TEX.close), w, h, -TEX.close);
TEX.mask = TK.feather(m, w, h, TEX.feather);
} else TEX.mask = null;
TEX.shown = TEX.rcOn ? TK.recolor(TEX.base, new Uint8ClampedArray(TEX.base.length), TEX.mask, TEX.rc) : TEX.base;
imgCanvas = imgCanvas && imgCanvas.width === w && imgCanvas.height === h ? imgCanvas : new OffscreenCanvas(w, h);
imgCanvas.getContext("2d").putImageData(new ImageData(TEX.shown, w, h), 0, 0);
drawSelection();
$("tex-dirty").hidden = !TEX.dirty;
$("tex-title").textContent = TEX.file ? `${TEX.file.name} — ${w}×${h}${TEX.file.info ? ` ${TEX.file.info.format}${TEX.file.info.mips > 1 ? " +mips" : ""}` : ""}` : "";
$("tex-save").disabled = !TEX.base;
$("tex-saveas").disabled = !TEX.base;
draw();
}
/** The selection's outline (and a light tint inside) as an image the size of the texture. */
function drawSelection() {
const { w, h, raw } = TEX;
if (!raw) return (selCanvas = null);
const id = new ImageData(w, h);
const d = id.data;
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++) {
const i = y * w + x;
if (raw[i] < 128) continue;
const edge = x === 0 || y === 0 || x === w - 1 || y === h - 1 || raw[i - 1] < 128 || raw[i + 1] < 128 || raw[i - w] < 128 || raw[i + w] < 128;
if (edge) d.set([60, 220, 255, 255], i * 4);
else d.set([60, 180, 255, 38], i * 4);
}
selCanvas = new OffscreenCanvas(w, h);
selCanvas.getContext("2d").putImageData(id, 0, 0);
}
function draw() {
if (!cv) return;
const r = cv.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
if (cv.width !== Math.round(r.width * dpr) || cv.height !== Math.round(r.height * dpr)) (cv.width = Math.round(r.width * dpr)), (cv.height = Math.round(r.height * dpr));
g.setTransform(1, 0, 0, 1, 0, 0);
g.clearRect(0, 0, cv.width, cv.height);
if (!TEX.base) return;
g.setTransform(dpr, 0, 0, dpr, 0, 0);
const z = TEX.zoom, [px, py] = TEX.pan;
// checkerboard under the picture (transparent parts)
g.save();
g.beginPath();
g.rect(px, py, TEX.w * z, TEX.h * z);
g.clip();
const s = 10;
for (let y = Math.floor(py / s) * s; y < py + TEX.h * z; y += s)
for (let x = Math.floor(px / s) * s; x < px + TEX.w * z; x += s) {
g.fillStyle = ((x / s + y / s) & 1) === 0 ? "#2a2a30" : "#1e1e22";
g.fillRect(x, y, s, s);
}
g.restore();
g.imageSmoothingEnabled = z < 1;
g.drawImage(imgCanvas, px, py, TEX.w * z, TEX.h * z);
if (selCanvas && TEX.showSel) {
g.imageSmoothingEnabled = false;
g.drawImage(selCanvas, px, py, TEX.w * z, TEX.h * z);
}
// the lasso / rectangle being drawn
if (drag?.pts?.length > 1) {
g.strokeStyle = "#3cdcff";
g.lineWidth = 1;
g.setLineDash([4, 3]);
g.beginPath();
drag.pts.forEach(([x, y], i) => (i ? g.lineTo : g.moveTo).call(g, px + x * z, py + y * z));
if (drag.tool === "rect") g.closePath();
g.stroke();
g.setLineDash([]);
}
$("tex-zoom").textContent = `${Math.round(z * 100)}%`;
}
function fitView() {
if (!cv || !TEX.base) return;
const r = cv.getBoundingClientRect();
TEX.zoom = Math.min((r.width - 40) / TEX.w, (r.height - 40) / TEX.h);
TEX.zoom = TEX.zoom > 1 ? Math.floor(TEX.zoom) : TEX.zoom;
TEX.pan = [(r.width - TEX.w * TEX.zoom) / 2, (r.height - TEX.h * TEX.zoom) / 2];
TEX.fitted = true;
draw();
}
// =====================================================================
// editing: undo, selection, recolour
// =====================================================================
function pushUndo() {
TEX.undo.push(TEX.base);
if (TEX.undo.length > 20) TEX.undo.shift();
TEX.redo = [];
}
function undo(back = true) {
const from = back ? TEX.undo : TEX.redo, to = back ? TEX.redo : TEX.undo;
if (!from.length) return;
to.push(TEX.base);
TEX.base = from.pop();
TEX.dirty = true;
refresh();
}
/** A new selection piece: shift adds, alt takes away, else it replaces. */
function select(piece, e) {
const mode = e?.shiftKey ? "add" : e?.altKey ? "sub" : "new";
TEX.raw = TK.combine(TEX.raw, piece, mode);
if (!TK.count(TEX.raw)) TEX.raw = null;
refresh();
renderSel();
}
function selectAll(how) {
if (!TEX.base) return;
const n = TEX.w * TEX.h;
if (how === "all") TEX.raw = new Uint8Array(n).fill(255);
else if (how === "none") TEX.raw = null;
else if (how === "invert") TEX.raw = TEX.raw ? TK.invert(TEX.raw) : new Uint8Array(n).fill(255);
else if (TEX.raw) TEX.raw = TK.grow(TEX.raw, TEX.w, TEX.h, how);
refresh();
renderSel();
}
function applyRecolor() {
if (!TEX.rcOn) return;
pushUndo();
TEX.base = TEX.shown;
TEX.rcOn = false;
TEX.dirty = true;
refresh();
renderRecolor();
}
function cancelRecolor() {
TEX.rcOn = false;
refresh();
renderRecolor();
}
let rcTimer = 0;
function setRc(k, v) {
TEX.rc[k] = v;
TEX.rcOn = true;
clearTimeout(rcTimer);
rcTimer = setTimeout(() => {
refresh();
renderRecolorButtons();
}, 30);
}
// =====================================================================
// mouse
// =====================================================================
let drag = null;
const toImage = (e) => {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left - TEX.pan[0]) / TEX.zoom, (e.clientY - r.top - TEX.pan[1]) / TEX.zoom];
};
function bindCanvas() {
cv.addEventListener("contextmenu", (e) => e.preventDefault());
cv.addEventListener("mousedown", (e) => {
if (!TEX.base) return;
cv.focus();
const [x, y] = toImage(e);
const pan = e.button === 1 || e.button === 2 || TEX.tool === "pan" || spaceDown;
if (pan) {
drag = { pan: true, sx: e.clientX, sy: e.clientY, p0: TEX.pan.slice() };
return;
}
if (e.button !== 0) return;
const ix = Math.floor(x), iy = Math.floor(y);
if (TEX.picking) {
if (ix >= 0 && iy >= 0 && ix < TEX.w && iy < TEX.h) {
const p = (iy * TEX.w + ix) * 4;
TEX.picking = false;
cv.classList.remove("pick");
setRc("color", [TEX.base[p], TEX.base[p + 1], TEX.base[p + 2]]);
renderRecolor();
}
return;
}
if (TEX.tool === "wand" || TEX.tool === "color") {
if (ix < 0 || iy < 0 || ix >= TEX.w || iy >= TEX.h) return;
select(TK.wand(TEX.base, TEX.w, TEX.h, ix, iy, TEX.tol, TEX.tool === "wand" && TEX.contiguous), e);
return;
}
drag = { tool: TEX.tool, pts: [[x, y]], shift: e.shiftKey, alt: e.altKey };
if (TEX.tool === "rect") drag.pts = [[x, y], [x, y], [x, y], [x, y]];
});
window.addEventListener("mousemove", (e) => {
if (!drag || !visible()) return;
if (drag.pan) {
TEX.pan = [drag.p0[0] + e.clientX - drag.sx, drag.p0[1] + e.clientY - drag.sy];
TEX.fitted = false;
return draw();
}
const [x, y] = toImage(e);
if (drag.tool === "rect") {
const [x0, y0] = drag.pts[0];
drag.pts = [[x0, y0], [x, y0], [x, y], [x0, y]];
} else {
const last = drag.pts[drag.pts.length - 1];
if (Math.hypot(x - last[0], y - last[1]) * TEX.zoom > 2) drag.pts.push([x, y]);
}
draw();
});
window.addEventListener("mouseup", () => {
if (!drag) return;
const d = drag;
drag = null;
if (d.pan) return;
const pts = d.pts.map(([x, y]) => [Math.max(0, Math.min(TEX.w, x)), Math.max(0, Math.min(TEX.h, y))]);
const piece = d.tool === "rect" ? TK.rect(TEX.w, TEX.h, pts[0][0], pts[0][1], pts[2][0], pts[2][1]) : TK.polygon(TEX.w, TEX.h, pts);
if (TK.count(piece)) select(piece, { shiftKey: d.shift, altKey: d.alt });
else draw();
});
cv.addEventListener(
"wheel",
(e) => {
if (!TEX.base) return;
e.preventDefault();
const r = cv.getBoundingClientRect();
const mx = e.clientX - r.left, my = e.clientY - r.top;
const z0 = TEX.zoom, z1 = Math.max(0.05, Math.min(32, z0 * (e.deltaY < 0 ? 1.2 : 1 / 1.2)));
TEX.pan = [mx - ((mx - TEX.pan[0]) * z1) / z0, my - ((my - TEX.pan[1]) * z1) / z0];
TEX.zoom = z1;
TEX.fitted = false;
draw();
},
{ passive: false },
);
cv.addEventListener("dragover", (e) => e.preventDefault());
cv.addEventListener("drop", (e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) openFile(bridge.pathOf(f));
});
}
let spaceDown = false;
const visible = () => $("view-tex").classList.contains("on");
window.addEventListener("keydown", (e) => {
if (!visible() || /input|select|textarea/i.test(document.activeElement?.tagName || "")) return;
if (e.code === "Space") (spaceDown = true), e.preventDefault();
const k = e.key.toLowerCase();
if (e.ctrlKey && k === "z") (undo(!e.shiftKey), e.preventDefault());
else if (e.ctrlKey && k === "y") (undo(false), e.preventDefault());
else if (e.ctrlKey && k === "a") (selectAll("all"), e.preventDefault());
else if (e.ctrlKey && k === "d") (selectAll("none"), e.preventDefault());
else if (e.ctrlKey && e.shiftKey && k === "i") (selectAll("invert"), e.preventDefault());
else if (e.ctrlKey && k === "s") (save(), e.preventDefault());
else if (!e.ctrlKey && TOOLS.some((t) => t.key === k)) setTool(TOOLS.find((t) => t.key === k).id);
else if (k === "enter" && TEX.rcOn) applyRecolor();
else if (k === "escape") TEX.rcOn ? cancelRecolor() : TEX.picking ? ((TEX.picking = false), cv.classList.remove("pick")) : selectAll("none");
else if (k === "0" && !e.ctrlKey) fitView();
});
window.addEventListener("keyup", (e) => e.code === "Space" && (spaceDown = false));
// =====================================================================
// side cards
// =====================================================================
const TOOLS = [
{ id: "wand", key: "w", label: "Magic wand", tip: "Click: the area of similar colour around that point (W)" },
{ id: "color", key: "c", label: "Colour range", tip: "Click: every pixel of similar colour in the picture (C)" },
{ id: "lasso", key: "l", label: "Lasso", tip: "Drag around what to select (L)" },
{ id: "rect", key: "m", label: "Rectangle", tip: "Drag a box (M)" },
{ id: "pan", key: "h", label: "Hand", tip: "Drag to move the view (H) — or the middle / right button, or hold Space" },
];
function setTool(id) {
TEX.tool = id;
for (const b of $("tex-tools").children) b.classList.toggle("on", b.dataset.tool === id);
cv?.setAttribute("data-tool", id);
renderSel();
}
function renderSide() {
renderSel();
renderRecolor();
renderExport();
}
function renderSel() {
const box = $("tex-sel");
const n = TEX.raw ? TK.count(TEX.raw) : 0;
const num = (value, min, max, onchange) => el("input", { type: "number", min, max, step: "1", value: String(value), onchange: (e) => onchange(Math.max(min, Math.min(max, Math.round(Number(e.target.value) || 0)))) });
fill(box,
el("div", { class: "ans-grid" }, [
...(TEX.tool === "wand" || TEX.tool === "color"
? field("Tolerance", el("div", { class: "ans-vec" }, [
el("input", { type: "range", min: "0", max: "128", value: String(TEX.tol), class: "grow", oninput: (e) => (($("tex-tol-v").textContent = e.target.value), (TEX.tol = Number(e.target.value))) }),
el("span", { id: "tex-tol-v", class: "mono", text: String(TEX.tol) }),
]))
: []),
...field("Close gaps", num(TEX.close, 0, 16, (v) => ((TEX.close = v), refresh()))),
...field("Feather", num(TEX.feather, 0, 16, (v) => ((TEX.feather = v), refresh()))),
]),
TEX.tool === "wand" ? el("label", { class: "ans-check", title: "Off: like Colour range (the colour anywhere in the picture)" }, [el("input", { type: "checkbox", checked: TEX.contiguous, onchange: (e) => (TEX.contiguous = e.target.checked) }), "Only touching pixels"]) : null,
el("div", { class: "ans-row" }, [
el("button", { class: "btn", text: "All", title: "Ctrl+A", onclick: () => selectAll("all") }),
el("button", { class: "btn", text: "None", title: "Ctrl+D / Esc", onclick: () => selectAll("none") }),
el("button", { class: "btn", text: "Invert", title: "Ctrl+Shift+I", onclick: () => selectAll("invert") }),
el("button", { class: "btn", text: "Grow", title: "+2 px", onclick: () => selectAll(2) }),
el("button", { class: "btn", text: "Shrink", title: "−2 px", onclick: () => selectAll(-2) }),
]),
el("label", { class: "ans-check" }, [el("input", { type: "checkbox", checked: TEX.showSel, onchange: (e) => ((TEX.showSel = e.target.checked), draw()) }), "Show the selection"]),
el("div", { class: "ans-note", text: n ? `${n.toLocaleString()} pixels selected (${((100 * n) / (TEX.w * TEX.h)).toFixed(1)}%). Shift + click / drag adds, Alt takes away.` : "Nothing selected: a recolour changes the whole picture. Shift + click / drag adds to a selection, Alt takes away. Close gaps fills the dark strands a colour pick misses." }),
);
}
function slider(label, k, min, max, scale = 1, unit = "") {
const out = el("span", { class: "mono tex-val", text: `${Math.round(TEX.rc[k] * scale)}${unit}` });
return field(label, el("div", { class: "ans-vec" }, [
el("input", { type: "range", min: String(min), max: String(max), value: String(Math.round(TEX.rc[k] * scale)), class: "grow", oninput: (e) => {
out.textContent = `${e.target.value}${unit}`;
setRc(k, Number(e.target.value) / scale);
}, ondblclick: (e) => {
// back to the start value
const v = k === "strength" ? 1 : 0;
e.target.value = String(Math.round(v * scale));
out.textContent = `${Math.round(v * scale)}${unit}`;
setRc(k, v);
} }),
out,
]));
}
const SWATCHES = [[34, 32, 32], [150, 145, 128], [235, 190, 90], [130, 95, 35], [105, 38, 14], [190, 20, 20], [72, 10, 28], [20, 40, 120], [90, 30, 120], [20, 110, 60], [140, 145, 155], [240, 150, 190]];
function renderRecolor() {
const r = TEX.rc;
const box = $("tex-rc");
const mode = el("div", { class: "seg" }, [["colorize", "Colourise"], ["adjust", "Hue / Sat / Light"]].map(([v, t]) =>
el("button", { class: r.mode === v ? "on" : "", text: t, onclick: () => (setRc("mode", v), renderRecolor()) }),
));
const color = el("input", { type: "color", value: hex(r.color), oninput: (e) => setRc("color", unhex(e.target.value)) });
fill(box,
mode,
r.mode === "colorize"
? el("div", {}, [
el("div", { class: "ans-grid" }, [
...field("Colour", el("div", { class: "ans-vec" }, [
color,
el("button", { class: `iconbtn only${TEX.picking ? " on" : ""}`, title: "Take the colour from the picture (click it)", onclick: () => ((TEX.picking = !TEX.picking), cv.classList.toggle("pick", TEX.picking), renderRecolor()) }, [icon("target")]),
])),
...slider("Strength", "strength", 0, 100, 100, "%"),
...slider("Brightness", "bright", -100, 100, 100),
...slider("Contrast", "contrast", -100, 100, 100),
]),
el("div", { class: "tex-swatches" }, SWATCHES.map((c) => el("button", { class: "tex-sw", title: hex(c), style: `background:${hex(c)}`, onclick: () => (setRc("color", c), renderRecolor()) }))),
el("div", { class: "ans-note", text: "Each pixel keeps its light and dark (strands, shading) in the new colour: made for dark or brown hair." }),
])
: el("div", { class: "ans-grid" }, [...slider("Hue", "hue", -180, 180, 1, "°"), ...slider("Saturation", "sat", -100, 100, 100), ...slider("Lightness", "light", -100, 100, 100), ...slider("Contrast", "contrast", -100, 100, 100)]),
el("div", { id: "tex-rc-btns", class: "ans-row" }),
);
renderRecolorButtons();
}
function renderRecolorButtons() {
const b = $("tex-rc-btns");
if (!b) return;
b.replaceChildren(
el("button", { class: "btn primary", text: "Apply", title: "Enter", disabled: !TEX.rcOn || !TEX.base, onclick: applyRecolor }),
el("button", { class: "btn", text: "Cancel", title: "Esc", disabled: !TEX.rcOn, onclick: cancelRecolor }),
el("span", { class: "grow" }),
el("button", { class: "iconbtn only", title: "Undo (Ctrl+Z)", disabled: !TEX.undo.length, onclick: () => undo(true) }, [icon("undo")]),
el("button", { class: "iconbtn only", title: "Redo (Ctrl+Y)", disabled: !TEX.redo.length, onclick: () => undo(false) }, [icon("redo")]),
);
}
const FORMATS = [
["auto", "Auto (DXT1, DXT5 with alpha)"],
["DXT1", "DXT1 — no / cut-out alpha (smallest)"],
["DXT3", "DXT3 — sharp alpha"],
["DXT5", "DXT5 — smooth alpha"],
["ARGB", "A8R8G8B8 — uncompressed (4× bigger)"],
];
function outSize() {
let w = TEX.w, h = TEX.h;
if (TEX.exp.max && Math.max(w, h) > TEX.exp.max) {
const f = TEX.exp.max / Math.max(w, h);
(w = Math.max(1, Math.round(w * f))), (h = Math.max(1, Math.round(h * f)));
}
// the game's cards want powers of two
return [TK.pow2(w), TK.pow2(h)];
}
function renderExport() {
const box = $("tex-exp");
if (!TEX.base) return box.replaceChildren(el("div", { class: "ans-note", text: "Open a picture." }));
const e = TEX.exp;
const [ow, oh] = outSize();
const fmt = TK.pickFormat(TEX.base, e.format);
fill(box,
el("div", { class: "ans-grid" }, [
...field("Format", el("select", { onchange: (ev) => ((e.format = ev.target.value), renderExport()) }, FORMATS.map(([v, t]) => el("option", { value: v, text: t, selected: v === e.format })))),
...field("Size", el("select", { onchange: (ev) => ((e.max = Number(ev.target.value)), renderExport()) }, [[0, "Keep"], [2048, "≤ 2048"], [1024, "≤ 1024"], [512, "≤ 512"], [256, "≤ 256"], [128, "≤ 128"]].map(([v, t]) => el("option", { value: v, text: t, selected: v === e.max })))),
]),
el("label", { class: "ans-check", title: "Smaller copies for far away (what most of the game's textures have)" }, [el("input", { type: "checkbox", checked: e.mips, onchange: (ev) => ((e.mips = ev.target.checked), renderExport()) }), "Mip maps"]),
el("div", { class: "ans-note", text: `Saves ${ow}×${oh} ${fmt === "ARGB" ? "A8R8G8B8" : fmt}${e.mips ? " + mip maps" : ""}${ow !== TEX.w || oh !== TEX.h ? ` (resized from ${TEX.w}×${TEX.h}${TK.pow2(TEX.w) !== TEX.w || TK.pow2(TEX.h) !== TEX.h ? ": the game wants powers of two" : ""})` : ""}. Transparent black in DXT1 / alpha 0 is where the game shows the skin colour (#MODEL_COLOR).` }),
);
}
// =====================================================================
// saving
// =====================================================================
/** The current pixels (recolour applied if previewed) as .dds bytes with the export settings. */
function ddsBytes(px = TEX.rcOn ? TEX.shown : TEX.base, w = TEX.w, h = TEX.h) {
const [ow, oh] = TEX.base && px === (TEX.rcOn ? TEX.shown : TEX.base) ? outSize() : [TK.pow2(w), TK.pow2(h)];
const p = ow !== w || oh !== h ? TK.resize(px, w, h, ow, oh) : px;
return TK.encodeDds(p, ow, oh, { format: TEX.exp.format, mips: TEX.exp.mips });
}
async function confirmOverwrite(names) {
if (!names.length) return true;
return modal("Replace files?", [el("p", { text: `${names.length} file(s) already exist and will be replaced:` }), el("div", { class: "mono ans-note" }, names.map((n) => el("div", { text: n })))], [
{ label: "Cancel", value: false },
{ label: "Replace", kind: "danger", value: true },
]);
}
async function writeDds(folder, name) {
if (TEX.rcOn) applyRecolor();
loading(true, `Encoding ${name}…`);
await frame();
try {
const r = ddsBytes();
await call("mdl:writeFiles", folder, [{ name, data: r.bytes }]);
TEX.dirty = false;
refresh();
toast(`Saved ${name} (${r.format === "ARGB" ? "A8R8G8B8" : r.format}, ${r.levels} level${r.levels > 1 ? "s" : ""}, ${size(r.bytes.length)}).`);
if (folder === TEX.dir) await listFiles();
} catch (err) {
toast(`Save: ${err.message}`, true);
} finally {
loading(false);
}
}
/** Save: <name>.dds next to the picture (a .dds is replaced). */
async function save() {
if (!TEX.base) return;
const name = `${TEX.file.stem}.dds`;
const exists = TEX.file.ext === "dds" || (TEX.file.dir === TEX.dir && TEX.files.some((f) => f.name.toLowerCase() === name.toLowerCase()));
if (exists && !(await confirmOverwrite([name]))) return;
await writeDds(TEX.file.dir, name);
}
async function saveAs() {
if (!TEX.base) return;
const kind = await modal("Save as", [el("p", { text: "Save the picture as:" })], [
{ label: "Cancel", value: null },
{ label: "PNG", value: "png" },
{ label: "DDS", kind: "primary", value: "dds" },
]);
if (!kind) return;
const file = await call("dialog:saveAs", `${TEX.file.stem}.${kind}`, kind);
if (!file) return;
const folder = file.replace(/[\\/][^\\/]*$/, ""), name = file.replace(/\\/g, "/").split("/").pop();
if (kind === "dds") return writeDds(folder, /\.dds$/i.test(name) ? name : `${name}.dds`);
if (TEX.rcOn) applyRecolor();
const c = new OffscreenCanvas(TEX.w, TEX.h);
c.getContext("2d").putImageData(new ImageData(TEX.base, TEX.w, TEX.h), 0, 0);
const blob = await c.convertToBlob({ type: "image/png" });
const b64 = await new Promise((res) => {
const fr = new FileReader();
fr.onload = () => res(String(fr.result).split(",")[1]);
fr.readAsDataURL(blob);
});
await call("file:writeBase64", file, b64);
toast(`Saved ${name}.`);
if (folder === TEX.dir) listFiles();
}
/** Every picture of the folder that isn't a .dds → <name>.dds with the export settings. */
async function convertFolder() {
if (!TEX.dir) return openFolder();
const src = TEX.files.filter((f) => extOf(f.name) !== "dds");
if (!src.length) return toast("No .png / .jpg / .tga / .bmp in this folder.", true);
const have = new Set(TEX.files.map((f) => f.name.toLowerCase()));
const list = el("div", { class: "tex-conv" }, src.map((f) => el("label", { class: "ans-check" }, [el("input", { type: "checkbox", checked: true, "data-name": f.name }), `${f.name} → ${stem(f.name)}.dds${have.has(`${stem(f.name)}.dds`.toLowerCase()) ? " (replaces)" : ""}`])));
const fmt = FORMATS.find(([v]) => v === TEX.exp.format)[1];
const ok = await modal("Convert to .dds", [
el("p", { text: `Into ${TEX.dir}, as ${fmt}${TEX.exp.mips ? " with mip maps" : ""}${TEX.exp.max ? `, at most ${TEX.exp.max}` : ""} (sizes to powers of two). Change it in Save as .dds.` }),
list,
], [{ label: "Cancel", value: false }, { label: "Convert", kind: "primary", value: true }]);
if (!ok) return;
const names = [...list.querySelectorAll("input:checked")].map((i) => i.dataset.name);
let done = 0;
loading(true, "Converting…");
try {
for (const n of names) {
$("tex-loading").querySelector("span").textContent = `Converting ${n} (${done + 1} / ${names.length})…`;
await frame();
const { bytes } = await call("mdl:readFile", join(TEX.dir, n));
const bmp = await TexDecode.decode(bytes, extOf(n));
const c = new OffscreenCanvas(bmp.width, bmp.height);
const cx = c.getContext("2d", { willReadFrequently: true });
cx.drawImage(bmp, 0, 0);
let px = cx.getImageData(0, 0, bmp.width, bmp.height).data, w = bmp.width, h = bmp.height;
if (TEX.exp.max && Math.max(w, h) > TEX.exp.max) {
const f = TEX.exp.max / Math.max(w, h);
const nw = Math.max(1, Math.round(w * f)), nh = Math.max(1, Math.round(h * f));
(px = TK.resize(px, w, h, nw, nh)), (w = nw), (h = nh);
}
const r = ddsBytes(px, w, h);
await call("mdl:writeFiles", TEX.dir, [{ name: `${stem(n)}.dds`, data: r.bytes }]);
done++;
}
toast(`Converted ${done} file(s) to .dds.`);
} catch (err) {
toast(`Convert: ${err.message} (${done} done)`, true);
} finally {
loading(false);
listFiles();
}
}
// =====================================================================
// start
// =====================================================================
function start() {
if (started) return;
started = true;
cv = $("tex-canvas");
g = cv.getContext("2d");
bindCanvas();
new ResizeObserver(() => (TEX.fitted ? fitView() : draw())).observe(cv);
$("tex-tools").replaceChildren(...TOOLS.map((t) => el("button", { "data-tool": t.id, title: `${t.label}: ${t.tip}`, text: t.label, onclick: () => setTool(t.id) })));
setTool("wand");
$("tex-open").onclick = () => openFolder();
$("tex-openfile").onclick = async () => {
const f = await call("dialog:openExt", "Open a picture", "Pictures", ["png", "jpg", "jpeg", "tga", "bmp", "dds"]);
if (!f) return;
const dir = f.replace(/[\\/][^\\/]*$/, "");
if (dir !== TEX.dir) await openFolder(dir);
openFile(f);
};
$("tex-refresh").onclick = () => listFiles();
$("tex-convert").onclick = () => convertFolder();
$("tex-save").onclick = () => save();
$("tex-saveas").onclick = () => saveAs();
$("tex-fit").onclick = () => fitView();
$("tex-100").onclick = () => {
const r = cv.getBoundingClientRect();
TEX.zoom = 1;
TEX.fitted = false;
TEX.pan = [(r.width - TEX.w) / 2, (r.height - TEX.h) / 2];
draw();
};
$("tex-search").addEventListener("input", renderList);
renderSide();
let dir = null;
try {
dir = localStorage.getItem(KEY);
} catch {}
if (dir) openFolder(dir);
else renderList();
}
// the tab opens: set up once
document.querySelector('#tabs button[data-view="tex"]')?.addEventListener("click", () => setTimeout(start, 0));
Object.assign(TEX, { openFolder, openFile, save, refresh, selectAll, applyRecolor, setRc, convertFolder, start });
})();