"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);