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