Dusk texture creator

This commit is contained in:
2026-04-13 19:51:11 -05:00
parent 4b3826edd9
commit 5a651d2d1f
39 changed files with 1402 additions and 2659 deletions
+278
View File
@@ -0,0 +1,278 @@
"use strict";
// ─── State ───────────────────────────────────────────────────────────────────
const state = {
pixels: null, // Uint8ClampedArray RGBA at original resolution
width: 0,
height: 0,
scale: 1,
bg: "grid",
format: DTF.FORMAT_RGBA, // output DTF format
redAsAlpha: false, // Alpha format: use red channel instead of alpha
filename: "texture", // basename without extension
};
// ─── 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 infoSize = document.getElementById("info-size");
const infoFormat = document.getElementById("info-format");
const btnDtf = document.getElementById("btn-dtf");
const btnPng = document.getElementById("btn-png");
const formatSelect = document.getElementById("format-select");
const infoDtfSize = document.getElementById("info-dtf-size");
const warningsSection = document.getElementById("warnings-section");
const warningList = document.getElementById("warning-list");
const redAsAlphaRow = document.getElementById("red-as-alpha-row");
const redAsAlphaCheck = document.getElementById("red-as-alpha");
// ─── 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, scale, bg, format } = state;
const cw = width * scale;
const ch = height * scale;
canvas.width = cw;
canvas.height = ch;
// 1. Background
if (bg === "grid") {
drawCheckerboard(cw, ch);
} else {
ctx.fillStyle = bg;
ctx.fillRect(0, 0, cw, ch);
}
// 2. Composite image on top at scaled size (pixelated)
const src = Object.assign(document.createElement("canvas"), { width, height });
src.getContext("2d").putImageData(DTF.toImageData(width, height, pixels, format, state.redAsAlpha), 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(src, 0, 0, cw, ch);
}
// ─── 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,
file.type || "image",
);
URL.revokeObjectURL(url);
};
img.onerror = () => {
showError("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, format, data } = DTF.decode(e.target.result);
const label = format === DTF.FORMAT_ALPHA ? "DTF (Alpha)"
: format === DTF.FORMAT_RGB ? "DTF (RGB)"
: "DTF (RGBA)";
applyImageData(width, height, data, file.name, label, format);
} catch (err) {
showError(`Failed to load DTF: ${err.message}`);
}
};
reader.readAsArrayBuffer(file);
}
function updateWarnings() {
const warnings = [];
if (state.pixels) {
const { width, height } = state;
const isPow2 = n => n > 0 && (n & (n - 1)) === 0;
if (width < 4) warnings.push(`Width is below 4 px (${width})`);
if (height < 4) warnings.push(`Height is below 4 px (${height})`);
if (!isPow2(width)) warnings.push(`Width is not a power of two (${width})`);
if (!isPow2(height)) warnings.push(`Height is not a power of two (${height})`);
const bytes = DTF.HEADER_SIZE + width * height * DTF.BPP[state.format];
if (bytes > 256 * 1024) {
warnings.push(`Output exceeds 256 KB (${(bytes / 1024).toFixed(1)} KB)`);
}
}
warningList.replaceChildren(
...warnings.map(msg => Object.assign(document.createElement("li"), { textContent: msg })),
);
warningsSection.hidden = warnings.length === 0;
}
function updateDtfSize() {
if (!state.pixels) return;
const bytes = DTF.HEADER_SIZE + state.width * state.height * DTF.BPP[state.format];
infoDtfSize.textContent = `${(bytes / 1024).toFixed(1)} KB`;
updateWarnings();
}
function applyImageData(width, height, data, filename, formatLabel, format) {
state.pixels = new Uint8ClampedArray(data); // defensive copy
state.width = width;
state.height = height;
state.filename = filename.replace(/\.[^/.]+$/, "");
// Sync format selector when loading an existing DTF
if (format !== undefined) {
state.format = format;
formatSelect.value = format;
}
// Sidebar info
fileNameEl.textContent = filename;
infoFilename.textContent = filename;
infoSize.textContent = `${width} × ${height}`;
infoFormat.textContent = formatLabel;
infoSection.hidden = false;
updateDtfSize();
// Show canvas
previewEmpty.hidden = true;
previewScroll.hidden = false;
// Enable export
btnDtf.disabled = false;
btnPng.disabled = false;
render();
}
function handleFile(file) {
if (!file) return;
if (file.name.toLowerCase().endsWith(".dtf")) {
loadDTF(file);
} else {
loadStandardImage(file);
}
}
function showError(msg) {
alert(msg);
}
// ─── Export ───────────────────────────────────────────────────────────────────
function exportDTF() {
if (!state.pixels) return;
const buf = DTF.encode(state.width, state.height, state.pixels, state.format, state.redAsAlpha);
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;
DuskPNG.download(`${state.filename}.png`, state.width, state.height, state.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();
});
btnDtf.addEventListener("click", exportDTF);
btnPng.addEventListener("click", exportPNG);
formatSelect.addEventListener("change", () => {
state.format = parseInt(formatSelect.value, 10);
redAsAlphaRow.hidden = state.format !== DTF.FORMAT_ALPHA;
updateDtfSize();
render();
});
redAsAlphaCheck.addEventListener("change", () => {
state.redAsAlpha = redAsAlphaCheck.checked;
render();
});
// Drag-and-drop on the load label
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);