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 @@
Load PNG, JPG, or DTF files and export to the Dusk Texture Format (.dtf) or PNG.
+
+
+
+
Texture Padder
+
Load an image or .dtf file and pad its dimensions to meet power-of-two or minimum size requirements.
+
+
+
+
+
+
+
+
+
+
+
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);