Add texture padder tool
This commit is contained in:
@@ -29,6 +29,10 @@
|
|||||||
<div class="tool-name">Texture Creator</div>
|
<div class="tool-name">Texture Creator</div>
|
||||||
<div class="tool-desc">Load PNG, JPG, or DTF files and export to the Dusk Texture Format (.dtf) or PNG.</div>
|
<div class="tool-desc">Load PNG, JPG, or DTF files and export to the Dusk Texture Format (.dtf) or PNG.</div>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="tool-card" href="/texture-padder/">
|
||||||
|
<div class="tool-name">Texture Padder</div>
|
||||||
|
<div class="tool-desc">Pad texture dimensions to power-of-two or enforce minimum size requirements.</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* Component – Hero section */
|
/* Component – Hero section */
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
padding: 2.5rem 0 2rem;
|
padding: var(--gap) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/* Object – Page section */
|
/* Object – Page section */
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
margin-top: var(--gap);
|
margin-top: var(--gap);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
/* Settings – Design tokens */
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #1a1a1e;
|
--bg: #121217; /* deep dark base */
|
||||||
--bg-surface: #25252b;
|
--bg-surface: #1a1a22; /* subtle elevation */
|
||||||
--bg-raised: #2e2e36;
|
--bg-raised: #23232d; /* layered surfaces */
|
||||||
--border: #3a3a44;
|
--border: #2e2e3a; /* soft border contrast */
|
||||||
--text: #e4e4ed;
|
|
||||||
--text-muted: #7a7a90;
|
--text: #e6e6f0; /* high readability */
|
||||||
--accent: #6e8efb;
|
--text-muted: #9a9ab0; /* muted secondary text */
|
||||||
--accent-dim: #3a4a8a;
|
|
||||||
|
--accent: #8b5cf6; /* vibrant purple */
|
||||||
|
--accent-dim: #6d46c7; /* deeper purple for hover/active */
|
||||||
|
|
||||||
--radius: 6px;
|
--radius: 6px;
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--gap: 1.5rem;
|
--gap: 1.5rem;
|
||||||
--speed: 0.15s;
|
--speed: 0.15s;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Texture Padder – Dusk Editor</title>
|
||||||
|
<link rel="stylesheet" href="/styles/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<a href="/" class="logo" style="text-decoration:none;color:inherit">Dusk <span>Editor</span></a>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/texture-padder/" class="active">Texture Padder</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="page">
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Texture Padder</h1>
|
||||||
|
<p>Load an image or .dtf file and pad its dimensions to meet power-of-two or minimum size requirements.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-workspace">
|
||||||
|
|
||||||
|
<!-- ─── Sidebar ────────────────────────────────────────────────────── -->
|
||||||
|
<aside class="tool-panel">
|
||||||
|
|
||||||
|
<section class="panel-section">
|
||||||
|
<div class="section-label">Load</div>
|
||||||
|
<label class="load-area" id="load-label">
|
||||||
|
<input type="file" id="file-input" accept=".png,.jpg,.jpeg,.gif,.bmp,.webp,.dtf" style="display:none">
|
||||||
|
Click or drop a file<br>
|
||||||
|
<small>PNG · JPG · GIF · BMP · WebP · DTF</small>
|
||||||
|
</label>
|
||||||
|
<div class="file-name" id="file-name">No file loaded</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel-section">
|
||||||
|
<div class="section-label">Padding</div>
|
||||||
|
<div class="control-row">
|
||||||
|
<input type="checkbox" id="chk-pad-width-pow2" checked>
|
||||||
|
<label for="chk-pad-width-pow2">Pad width to power of two</label>
|
||||||
|
</div>
|
||||||
|
<div class="control-row">
|
||||||
|
<input type="checkbox" id="chk-pad-height-pow2" checked>
|
||||||
|
<label for="chk-pad-height-pow2">Pad height to power of two</label>
|
||||||
|
</div>
|
||||||
|
<div class="control-row">
|
||||||
|
<input type="checkbox" id="chk-min-width-4" checked>
|
||||||
|
<label for="chk-min-width-4">Ensure minimum width (4 px)</label>
|
||||||
|
</div>
|
||||||
|
<div class="control-row">
|
||||||
|
<input type="checkbox" id="chk-min-height-4" checked>
|
||||||
|
<label for="chk-min-height-4">Ensure minimum height (4 px)</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel-section">
|
||||||
|
<div class="section-label">Preview</div>
|
||||||
|
|
||||||
|
<div class="control-row">
|
||||||
|
<label for="scale-input">Scale</label>
|
||||||
|
<input type="number" id="scale-input" min="1" max="10" value="1">
|
||||||
|
<span class="unit">×</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-label">Background</div>
|
||||||
|
|
||||||
|
<div class="bg-swatches" id="bg-swatches">
|
||||||
|
<button class="bg-swatch active" data-bg="grid" title="Transparent (grid)">
|
||||||
|
<svg width="26" height="26" viewBox="0 0 26 26" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="13" height="13" fill="#c8c8c8"/>
|
||||||
|
<rect x="13" width="13" height="13" fill="#fff"/>
|
||||||
|
<rect y="13" width="13" height="13" fill="#fff"/>
|
||||||
|
<rect x="13" y="13" width="13" height="13" fill="#c8c8c8"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="bg-swatch" data-bg="#ffffff" title="White" style="background:#ffffff"></button>
|
||||||
|
<button class="bg-swatch" data-bg="#000000" title="Black" style="background:#000000"></button>
|
||||||
|
<button class="bg-swatch" data-bg="#ff00ff" title="Magenta" style="background:#ff00ff"></button>
|
||||||
|
<button class="bg-swatch" data-bg="#00ff00" title="Green" style="background:#00ff00"></button>
|
||||||
|
<button class="bg-swatch" data-bg="#ff0000" title="Red" style="background:#ff0000"></button>
|
||||||
|
<button class="bg-swatch" data-bg="#0000ff" title="Blue" style="background:#0000ff"></button>
|
||||||
|
<button class="bg-swatch" data-bg="#ffff00" title="Yellow" style="background:#ffff00"></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel-section" id="info-section" hidden>
|
||||||
|
<div class="section-label">Info</div>
|
||||||
|
<table class="info-table">
|
||||||
|
<tr><td>File</td><td id="info-filename">—</td></tr>
|
||||||
|
<tr><td>Input</td><td id="info-input-size">—</td></tr>
|
||||||
|
<tr><td>Output</td><td id="info-output-size">—</td></tr>
|
||||||
|
<tr><td>DTF</td><td id="info-dtf-size">—</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel-section">
|
||||||
|
<div class="section-label">Export</div>
|
||||||
|
<select id="format-select">
|
||||||
|
<option value="1">Alpha (0x01)</option>
|
||||||
|
<option value="3">RGB (0x03)</option>
|
||||||
|
<option value="4" selected>RGBA (0x04)</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" id="btn-dtf" disabled>Download .dtf</button>
|
||||||
|
<button class="btn" id="btn-png" disabled>Download .png</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ─── Preview ───────────────────────────────────────────────────── -->
|
||||||
|
<div class="tool-preview" id="preview-area">
|
||||||
|
<div class="preview-empty" id="preview-empty">Load an image or .dtf file to get started</div>
|
||||||
|
<div class="preview-scroll" id="preview-scroll" hidden>
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/pngjs@6.0.0/browser.js"></script>
|
||||||
|
<script src="/common/dtf.js"></script>
|
||||||
|
<script src="/common/png.js"></script>
|
||||||
|
<script src="/texture-padder/texture-padder.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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