Dusk texture creator
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Texture Creator – 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/" class="active">Texture Creator</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
|
||||
<div class="hero">
|
||||
<h1>Texture Creator</h1>
|
||||
<p>Load an image or existing .dtf file, then export as Dusk Texture Format (.dtf) or PNG.</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">Format</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>
|
||||
<div class="control-row" id="red-as-alpha-row" hidden>
|
||||
<input type="checkbox" id="red-as-alpha">
|
||||
<label for="red-as-alpha">Red channel as alpha</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">
|
||||
<!-- Transparent grid -->
|
||||
<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>Size</td><td id="info-size">—</td></tr>
|
||||
<tr><td>Format</td><td id="info-format">—</td></tr>
|
||||
<tr><td>DTF</td><td id="info-dtf-size">—</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel-section warnings-section" id="warnings-section" hidden>
|
||||
<div class="section-label">Warnings</div>
|
||||
<ul class="warning-list" id="warning-list"></ul>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="section-label">Export</div>
|
||||
<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/texture.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user