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