Files
dusk/tools/editor/texture-padder/texture-padder.js
T
2026-04-14 08:38:50 -05:00

276 lines
10 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";
// ─── Helpers ─────────────────────────────────────────────────────────────────
// Returns the smallest power of two >= n.
function nextPow2(n) {
if(n <= 0) return 1;
let p = 1;
while (p < n) p <<= 1;
return p;
}
// ─── State ───────────────────────────────────────────────────────────────────
const state = {
pixels: null, // Uint8ClampedArray RGBA at original resolution
width: 0,
height: 0,
scale: 1,
bg: "grid",
padWidthPow2: true,
padHeightPow2: true,
minWidth4: true,
minHeight4: true,
format: DTF.FORMAT_RGBA,
filename: "texture",
};
// ─── DOM refs ────────────────────────────────────────────────────────────────
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const fileInput = document.getElementById("file-input");
const loadLabel = document.getElementById("load-label");
const scaleInput = document.getElementById("scale-input");
const bgSwatches = document.getElementById("bg-swatches");
const previewArea = document.getElementById("preview-area");
const previewEmpty = document.getElementById("preview-empty");
const previewScroll = document.getElementById("preview-scroll");
const infoSection = document.getElementById("info-section");
const fileNameEl = document.getElementById("file-name");
const infoFilename = document.getElementById("info-filename");
const infoInputSize = document.getElementById("info-input-size");
const infoOutputSize = document.getElementById("info-output-size");
const formatSelect = document.getElementById("format-select");
const btnDtf = document.getElementById("btn-dtf");
const btnPng = document.getElementById("btn-png");
const infoDtfSize = document.getElementById("info-dtf-size");
const chkPadWidthPow2 = document.getElementById("chk-pad-width-pow2");
const chkPadHeightPow2 = document.getElementById("chk-pad-height-pow2");
const chkMinWidth4 = document.getElementById("chk-min-width-4");
const chkMinHeight4 = document.getElementById("chk-min-height-4");
// ─── Padding logic ───────────────────────────────────────────────────────────
function computeOutputSize() {
let outW = state.width;
let outH = state.height;
// Apply minimum-size constraints first so pow2 rounding accounts for them.
if(state.minWidth4 && outW < 4) outW = 4;
if(state.minHeight4 && outH < 4) outH = 4;
if(state.padWidthPow2) outW = nextPow2(outW);
if(state.padHeightPow2) outH = nextPow2(outH);
return { outW, outH };
}
function buildPaddedPixels() {
const { outW, outH } = computeOutputSize();
const src = state.pixels;
// Zero-filled Uint8ClampedArray = fully transparent black padding.
const out = new Uint8ClampedArray(outW * outH * 4);
for(let y = 0; y < state.height; y++) {
for(let x = 0; x < state.width; x++) {
const si = (y * state.width + x) * 4;
const di = (y * outW + x) * 4;
out[di] = src[si];
out[di + 1] = src[si + 1];
out[di + 2] = src[si + 2];
out[di + 3] = src[si + 3];
}
}
return { pixels: out, width: outW, height: outH };
}
// ─── Rendering ───────────────────────────────────────────────────────────────
const CHECKER_CELL = 8;
function drawCheckerboard(w, h) {
for(let y = 0; y < h; y += CHECKER_CELL) {
for(let x = 0; x < w; x += CHECKER_CELL) {
ctx.fillStyle = ((x / CHECKER_CELL + y / CHECKER_CELL) % 2 === 0) ? "#c8c8c8" : "#ffffff";
ctx.fillRect(x, y, Math.min(CHECKER_CELL, w - x), Math.min(CHECKER_CELL, h - y));
}
}
}
function render() {
if(!state.pixels) return;
const { pixels, width, height } = buildPaddedPixels();
const cw = width * state.scale;
const ch = height * state.scale;
canvas.width = cw;
canvas.height = ch;
if(state.bg === "grid") {
drawCheckerboard(cw, ch);
} else {
ctx.fillStyle = state.bg;
ctx.fillRect(0, 0, cw, ch);
}
const src = Object.assign(document.createElement("canvas"), { width, height });
src.getContext("2d").putImageData(new ImageData(pixels, width, height), 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(src, 0, 0, cw, ch);
}
// ─── Info update ─────────────────────────────────────────────────────────────
function updateInfo() {
if(!state.pixels) return;
const { outW, outH } = computeOutputSize();
const bytes = DTF.HEADER_SIZE + outW * outH * DTF.BPP[state.format];
infoInputSize.textContent = `${state.width} × ${state.height}`;
infoOutputSize.textContent = `${outW} × ${outH}`;
infoDtfSize.textContent = `${(bytes / 1024).toFixed(1)} KB`;
}
// ─── Loading ─────────────────────────────────────────────────────────────────
function loadStandardImage(file) {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const offscreen = Object.assign(document.createElement("canvas"), {
width: img.naturalWidth,
height: img.naturalHeight,
});
offscreen.getContext("2d").drawImage(img, 0, 0);
const imageData = offscreen.getContext("2d").getImageData(0, 0, img.naturalWidth, img.naturalHeight);
applyImageData(img.naturalWidth, img.naturalHeight, imageData.data, file.name);
URL.revokeObjectURL(url);
};
img.onerror = () => {
alert("Failed to load image. The file may be corrupt or unsupported.");
URL.revokeObjectURL(url);
};
img.src = url;
}
function loadDTF(file) {
const reader = new FileReader();
reader.onload = e => {
try {
const { width, height, data } = DTF.decode(e.target.result);
applyImageData(width, height, data, file.name);
} catch (err) {
alert(`Failed to load DTF: ${err.message}`);
}
};
reader.readAsArrayBuffer(file);
}
function applyImageData(width, height, data, filename) {
state.pixels = new Uint8ClampedArray(data);
state.width = width;
state.height = height;
state.filename = filename.replace(/\.[^/.]+$/, "");
fileNameEl.textContent = filename;
infoFilename.textContent = filename;
infoSection.hidden = false;
updateInfo();
previewEmpty.hidden = true;
previewScroll.hidden = false;
btnDtf.disabled = false;
btnPng.disabled = false;
render();
}
function handleFile(file) {
if(!file) return;
if(file.name.toLowerCase().endsWith(".dtf")) {
loadDTF(file);
} else {
loadStandardImage(file);
}
}
// ─── Export ───────────────────────────────────────────────────────────────────
function exportDTF() {
if(!state.pixels) return;
const { pixels, width, height } = buildPaddedPixels();
const buf = DTF.encode(width, height, pixels, state.format);
const blob = new Blob([buf], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {
href: url,
download: `${state.filename}.dtf`,
});
a.click();
URL.revokeObjectURL(url);
}
function exportPNG() {
if(!state.pixels) return;
const { pixels, width, height } = buildPaddedPixels();
DuskPNG.download(`${state.filename}.png`, width, height, pixels);
}
// ─── Event listeners ─────────────────────────────────────────────────────────
fileInput.addEventListener("change", e => handleFile(e.target.files[0]));
scaleInput.addEventListener("input", () => {
const v = Math.max(1, Math.min(10, parseInt(scaleInput.value, 10) || 1));
scaleInput.value = v;
state.scale = v;
render();
});
bgSwatches.addEventListener("click", e => {
const btn = e.target.closest(".bg-swatch");
if(!btn) return;
bgSwatches.querySelectorAll(".bg-swatch").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
state.bg = btn.dataset.bg;
render();
});
formatSelect.addEventListener("change", () => {
state.format = parseInt(formatSelect.value, 10);
updateInfo();
});
btnDtf.addEventListener("click", exportDTF);
btnPng.addEventListener("click", exportPNG);
chkPadWidthPow2.addEventListener("change", () => { state.padWidthPow2 = chkPadWidthPow2.checked; updateInfo(); render(); });
chkPadHeightPow2.addEventListener("change", () => { state.padHeightPow2 = chkPadHeightPow2.checked; updateInfo(); render(); });
chkMinWidth4.addEventListener("change", () => { state.minWidth4 = chkMinWidth4.checked; updateInfo(); render(); });
chkMinHeight4.addEventListener("change", () => { state.minHeight4 = chkMinHeight4.checked; updateInfo(); render(); });
// Drag-and-drop
function setupDrop(target) {
target.addEventListener("dragover", e => {
e.preventDefault();
loadLabel.classList.add("drag-over");
previewArea.classList.add("drag-over");
});
target.addEventListener("dragleave", () => {
loadLabel.classList.remove("drag-over");
previewArea.classList.remove("drag-over");
});
target.addEventListener("drop", e => {
e.preventDefault();
loadLabel.classList.remove("drag-over");
previewArea.classList.remove("drag-over");
handleFile(e.dataTransfer.files[0]);
});
}
setupDrop(loadLabel);
setupDrop(previewArea);