From 041ec3d710fa75c75903edf5ceb141f21b137524 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Mon, 13 Apr 2026 20:34:54 -0500 Subject: [PATCH] Add texture padder tool --- tools/editor/index.html | 4 + tools/editor/styles/components/hero.css | 2 +- tools/editor/styles/objects/section.css | 1 - tools/editor/styles/settings.css | 23 +- tools/editor/texture-padder/index.html | 130 +++++++++ tools/editor/texture-padder/texture-padder.js | 275 ++++++++++++++++++ 6 files changed, 422 insertions(+), 13 deletions(-) create mode 100644 tools/editor/texture-padder/index.html create mode 100644 tools/editor/texture-padder/texture-padder.js diff --git a/tools/editor/index.html b/tools/editor/index.html index babf92c3..91a27fec 100644 --- a/tools/editor/index.html +++ b/tools/editor/index.html @@ -29,6 +29,10 @@
Texture Creator
Load PNG, JPG, or DTF files and export to the Dusk Texture Format (.dtf) or PNG.
+ +
Texture Padder
+
Pad texture dimensions to power-of-two or enforce minimum size requirements.
+
diff --git a/tools/editor/styles/components/hero.css b/tools/editor/styles/components/hero.css index b5fb9645..ea7fc20f 100644 --- a/tools/editor/styles/components/hero.css +++ b/tools/editor/styles/components/hero.css @@ -1,7 +1,7 @@ /* Component – Hero section */ .hero { - padding: 2.5rem 0 2rem; + padding: var(--gap) 0; } .hero h1 { diff --git a/tools/editor/styles/objects/section.css b/tools/editor/styles/objects/section.css index 0c559b33..b15d11b7 100644 --- a/tools/editor/styles/objects/section.css +++ b/tools/editor/styles/objects/section.css @@ -1,5 +1,4 @@ /* Object – Page section */ - .section { margin-top: var(--gap); } diff --git a/tools/editor/styles/settings.css b/tools/editor/styles/settings.css index 7d9c31ff..a770dd7e 100644 --- a/tools/editor/styles/settings.css +++ b/tools/editor/styles/settings.css @@ -1,16 +1,17 @@ -/* Settings – Design tokens */ - :root { - --bg: #1a1a1e; - --bg-surface: #25252b; - --bg-raised: #2e2e36; - --border: #3a3a44; - --text: #e4e4ed; - --text-muted: #7a7a90; - --accent: #6e8efb; - --accent-dim: #3a4a8a; + --bg: #121217; /* deep dark base */ + --bg-surface: #1a1a22; /* subtle elevation */ + --bg-raised: #23232d; /* layered surfaces */ + --border: #2e2e3a; /* soft border contrast */ + + --text: #e6e6f0; /* high readability */ + --text-muted: #9a9ab0; /* muted secondary text */ + + --accent: #8b5cf6; /* vibrant purple */ + --accent-dim: #6d46c7; /* deeper purple for hover/active */ + --radius: 6px; --radius-sm: 4px; --gap: 1.5rem; --speed: 0.15s; -} +} \ No newline at end of file diff --git a/tools/editor/texture-padder/index.html b/tools/editor/texture-padder/index.html new file mode 100644 index 00000000..aa9e7f33 --- /dev/null +++ b/tools/editor/texture-padder/index.html @@ -0,0 +1,130 @@ + + + + + + Texture Padder – Dusk Editor + + + + + + +
+ +
+

Texture Padder

+

Load an image or .dtf file and pad its dimensions to meet power-of-two or minimum size requirements.

+
+ +
+ + + + + +
+
Load an image or .dtf file to get started
+ +
+ +
+
+ + + + + + + diff --git a/tools/editor/texture-padder/texture-padder.js b/tools/editor/texture-padder/texture-padder.js new file mode 100644 index 00000000..193da80d --- /dev/null +++ b/tools/editor/texture-padder/texture-padder.js @@ -0,0 +1,275 @@ +"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);