Add texture padder tool
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user