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
+173
View File
@@ -0,0 +1,173 @@
"use strict";
// DTF Dusk Texture Format
//
// Header (13 bytes):
// [02] "DTF" magic
// [3] 0x01 version
// [47] uint32 width (little-endian)
// [811] uint32 height (little-endian)
// [12] uint8 format
//
// Formats:
// 0x01 Alpha 1 byte per pixel (alpha channel only)
// 0x03 RGB 3 bytes per pixel (no alpha)
// 0x04 RGBA 4 bytes per pixel
//
// Followed by width × height × bpp bytes of tightly-packed pixel data.
const DTF = (() => {
const MAGIC = [0x44, 0x54, 0x46]; // "DTF"
const VERSION = 0x01;
const FORMAT_ALPHA = 0x01;
const FORMAT_RGB = 0x03;
const FORMAT_RGBA = 0x04;
const HEADER_SIZE = 13;
// Bytes per pixel for each format.
const BPP = {
[FORMAT_ALPHA]: 1,
[FORMAT_RGB]: 3,
[FORMAT_RGBA]: 4,
};
// Encode RGBA source pixels into a DTF ArrayBuffer at the given format.
// When format is FORMAT_ALPHA and redAsAlpha is true, the red channel is
// used as the alpha value instead of the actual alpha channel.
function encode(width, height, rgbaData, format, redAsAlpha) {
if (format === undefined) format = FORMAT_RGBA;
const bpp = BPP[format];
if (bpp === undefined) throw new Error(`Unknown DTF format: 0x${format.toString(16)}`);
const src = rgbaData instanceof Uint8ClampedArray ? rgbaData : new Uint8ClampedArray(rgbaData);
const buf = new ArrayBuffer(HEADER_SIZE + width * height * bpp);
const bytes = new Uint8Array(buf);
const view = new DataView(buf);
bytes[0] = MAGIC[0];
bytes[1] = MAGIC[1];
bytes[2] = MAGIC[2];
bytes[3] = VERSION;
view.setUint32(4, width, true);
view.setUint32(8, height, true);
bytes[12] = format;
let dst = HEADER_SIZE;
for (let i = 0; i < width * height; i++) {
const o = i * 4;
switch (format) {
case FORMAT_ALPHA:
bytes[dst++] = redAsAlpha ? src[o] : src[o + 3];
break;
case FORMAT_RGB:
bytes[dst++] = src[o];
bytes[dst++] = src[o + 1];
bytes[dst++] = src[o + 2];
break;
default: // FORMAT_RGBA
bytes[dst++] = src[o];
bytes[dst++] = src[o + 1];
bytes[dst++] = src[o + 2];
bytes[dst++] = src[o + 3];
}
}
return buf;
}
// Decode a DTF ArrayBuffer. Always returns RGBA pixel data for internal use.
// Alpha-format files decode as {R=0, G=0, B=0, A=alpha} so the alpha channel
// is preserved and toImageData() can display it correctly.
function decode(buffer) {
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
if (bytes.length < HEADER_SIZE) throw new Error("File too small to be a valid DTF");
if (bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1] || bytes[2] !== MAGIC[2]) {
throw new Error("Invalid DTF magic bytes not a DTF file");
}
const version = bytes[3];
if (version !== VERSION) {
throw new Error(`Unsupported DTF version: 0x${version.toString(16).padStart(2, "0")}`);
}
const width = view.getUint32(4, true);
const height = view.getUint32(8, true);
const format = bytes[12];
const bpp = BPP[format];
if (bpp === undefined) {
throw new Error(`Unsupported DTF format: 0x${format.toString(16).padStart(2, "0")}`);
}
const expected = HEADER_SIZE + width * height * bpp;
if (bytes.length < expected) {
throw new Error(`DTF pixel data truncated (expected ${expected} bytes, got ${bytes.length})`);
}
const rgba = new Uint8ClampedArray(width * height * 4);
let src = HEADER_SIZE;
for (let i = 0; i < width * height; i++) {
const o = i * 4;
switch (format) {
case FORMAT_ALPHA:
rgba[o] = rgba[o + 1] = rgba[o + 2] = 0;
rgba[o + 3] = bytes[src++];
break;
case FORMAT_RGB:
rgba[o] = bytes[src++];
rgba[o + 1] = bytes[src++];
rgba[o + 2] = bytes[src++];
rgba[o + 3] = 255;
break;
default: // FORMAT_RGBA
rgba[o] = bytes[src++];
rgba[o + 1] = bytes[src++];
rgba[o + 2] = bytes[src++];
rgba[o + 3] = bytes[src++];
}
}
return { width, height, format, data: rgba };
}
// Convert RGBA source pixels to a display-ready ImageData for the given format.
// Shows exactly how the texture will look after a DTF encode/decode round-trip.
// Alpha → grayscale from alpha channel (or red if redAsAlpha), out-alpha=255
// RGB → discard alpha, fully opaque
// RGBA → pass-through
function toImageData(width, height, rgbaData, format, redAsAlpha) {
const src = rgbaData instanceof Uint8ClampedArray ? rgbaData : new Uint8ClampedArray(rgbaData);
const out = new Uint8ClampedArray(width * height * 4);
for (let i = 0; i < width * height; i++) {
const o = i * 4;
switch (format) {
case FORMAT_ALPHA:
out[o] = out[o + 1] = out[o + 2] = redAsAlpha ? src[o] : src[o + 3];
out[o + 3] = 255;
break;
case FORMAT_RGB:
out[o] = src[o];
out[o + 1] = src[o + 1];
out[o + 2] = src[o + 2];
out[o + 3] = 255;
break;
default: // FORMAT_RGBA
out[o] = src[o];
out[o + 1] = src[o + 1];
out[o + 2] = src[o + 2];
out[o + 3] = src[o + 3];
}
}
return new ImageData(out, width, height);
}
return Object.freeze({
encode, decode, toImageData,
FORMAT_ALPHA, FORMAT_RGB, FORMAT_RGBA,
VERSION, HEADER_SIZE, BPP,
});
})();
+55
View File
@@ -0,0 +1,55 @@
"use strict";
// DuskPNG PNG export using pngjs (window.PNG)
// Falls back to canvas.toBlob if pngjs is unavailable.
const DuskPNG = (() => {
function _pngAvailable() {
return typeof PNG !== "undefined" && PNG.sync && typeof PNG.sync.write === "function";
}
// Encode RGBA pixel data into a PNG Buffer via pngjs.
// Returns a Uint8Array (Buffer) if pngjs is available, otherwise null.
function encode(width, height, rgbaData) {
if (!_pngAvailable()) return null;
const png = new PNG({ width, height });
const src = rgbaData instanceof Uint8ClampedArray
? rgbaData
: new Uint8ClampedArray(rgbaData);
for (let i = 0; i < src.length; i++) {
png.data[i] = src[i];
}
return PNG.sync.write(png); // returns a Buffer (Uint8Array subclass)
}
// Trigger a browser download of the RGBA data as a PNG file.
function download(filename, width, height, rgbaData) {
const buf = encode(width, height, rgbaData);
if (buf) {
// pngjs path
const blob = new Blob([buf], { type: "image/png" });
_triggerDownload(URL.createObjectURL(blob), filename);
} else {
// Canvas fallback
const canvas = Object.assign(document.createElement("canvas"), { width, height });
const ctx = canvas.getContext("2d");
const src = rgbaData instanceof Uint8ClampedArray
? rgbaData
: new Uint8ClampedArray(rgbaData);
ctx.putImageData(new ImageData(src, width, height), 0, 0);
canvas.toBlob(blob => _triggerDownload(URL.createObjectURL(blob), filename), "image/png");
}
}
function _triggerDownload(url, filename) {
const a = Object.assign(document.createElement("a"), { href: url, download: filename });
a.click();
URL.revokeObjectURL(url);
}
return Object.freeze({ encode, download });
})();
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dusk Editor Tools</title>
<link rel="stylesheet" href="styles/main.css">
</head>
<body>
<header class="site-header">
<span class="logo">Dusk <span>Editor</span></span>
<nav>
<a href="/" class="active">Home</a>
</nav>
</header>
<main class="page">
<section class="hero">
<h1>Editor Tools</h1>
<p>Asset creation and data authoring tools for the Dusk game engine.</p>
</section>
<section class="section">
<div class="section-label">Tools</div>
<div class="tool-grid" id="tool-grid">
<a class="tool-card" href="/texture/">
<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>
</a>
</div>
</section>
</main>
</body>
</html>
@@ -0,0 +1,34 @@
/* Component Buttons */
.btn {
display: block;
width: 100%;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 0.4rem 0.75rem;
font-size: 0.825rem;
cursor: pointer;
text-align: center;
font-family: inherit;
transition: background var(--speed), border-color var(--speed);
}
.btn + .btn {
margin-top: 0.375rem;
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent-dim);
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent);
}
+38
View File
@@ -0,0 +1,38 @@
/* Component Tool cards and empty state */
.tool-card {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
transition: border-color var(--speed);
text-decoration: none;
color: inherit;
}
.tool-card:hover {
text-decoration: none;
}
.tool-card .tool-name {
font-weight: 600;
font-size: 0.95rem;
}
.tool-card .tool-desc {
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.5;
}
.empty-state {
border-style: dashed;
padding: 3rem;
text-align: center;
color: var(--text-muted);
}
.empty-state p {
margin-top: 0.5rem;
font-size: 0.875rem;
}
@@ -0,0 +1,43 @@
/* Component File info and load area */
.file-name {
font-size: 0.775rem;
color: var(--text-muted);
word-break: break-all;
line-height: 1.4;
}
.info-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.info-table td {
padding: 0.15rem 0;
vertical-align: top;
}
.info-table td:first-child {
color: var(--text-muted);
width: 50px;
font-size: 0.75rem;
padding-right: 0.75rem;
}
.load-area {
border: 1px dashed var(--border);
border-radius: var(--radius);
padding: 0.6rem;
text-align: center;
cursor: pointer;
font-size: 0.825rem;
color: var(--text-muted);
transition: border-color var(--speed), color var(--speed);
line-height: 1.5;
}
.load-area:hover,
.load-area.drag-over {
color: var(--text);
}
+43
View File
@@ -0,0 +1,43 @@
/* Component Site header */
.site-header {
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
padding: 0 var(--gap);
display: flex;
align-items: center;
gap: 1rem;
height: 52px;
}
.site-header .logo {
font-weight: 700;
font-size: 1rem;
color: var(--text);
letter-spacing: 0.03em;
}
.site-header .logo span {
color: var(--accent);
}
.site-header nav {
display: flex;
gap: 0.25rem;
margin-left: auto;
}
.site-header nav a {
padding: 0.3rem 0.75rem;
border-radius: var(--radius);
color: var(--text-muted);
font-size: 0.875rem;
transition: background var(--speed), color var(--speed);
}
.site-header nav a:hover,
.site-header nav a.active {
background: var(--bg-raised);
color: var(--text);
text-decoration: none;
}
+16
View File
@@ -0,0 +1,16 @@
/* Component Hero section */
.hero {
padding: 2.5rem 0 2rem;
}
.hero h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.hero p {
color: var(--text-muted);
max-width: 540px;
}
+79
View File
@@ -0,0 +1,79 @@
/* Component Tool panel (sidebar) */
.tool-panel {
width: 210px;
flex-shrink: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.panel-section {
padding: 0.875rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
border-bottom: 1px solid var(--border);
}
.panel-section:last-child {
border-bottom: none;
}
.control-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.control-row label {
color: var(--text-muted);
min-width: 44px;
}
.tool-panel select {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 0.25rem 0.4rem;
font-size: 0.875rem;
font-family: inherit;
transition: border-color var(--speed);
cursor: pointer;
}
.tool-panel select:focus {
outline: none;
border-color: var(--accent-dim);
}
.tool-panel input[type="checkbox"] {
accent-color: var(--accent);
cursor: pointer;
flex-shrink: 0;
}
.tool-panel input[type="number"] {
width: 52px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 0.25rem 0.4rem;
font-size: 0.875rem;
font-family: inherit;
transition: border-color var(--speed);
}
.tool-panel input[type="number"]:focus {
outline: none;
border-color: var(--accent-dim);
}
.unit {
color: var(--text-muted);
font-size: 0.875rem;
}
@@ -0,0 +1,36 @@
/* Component Preview area */
.tool-preview {
flex: 1;
position: relative;
min-height: 300px;
transition: border-color var(--speed);
}
.preview-scroll {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.preview-scroll canvas {
image-rendering: pixelated;
image-rendering: crisp-edges;
display: block;
max-width: none;
}
.preview-empty {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-muted);
font-size: 0.875rem;
text-align: center;
white-space: nowrap;
user-select: none;
pointer-events: none;
}
@@ -0,0 +1,28 @@
/* Component Background swatches */
.bg-swatches {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.bg-swatch {
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
border: 2px solid transparent;
cursor: pointer;
padding: 0;
flex-shrink: 0;
overflow: hidden;
transition: border-color var(--speed);
}
.bg-swatch:hover {
border-color: var(--text-muted);
}
.bg-swatch.active,
.bg-swatch.active:hover {
border-color: var(--accent);
}
@@ -0,0 +1,28 @@
/* Component Warnings panel */
.warnings-section {
border-color: #6b4a00;
}
.warning-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.warning-list li {
font-size: 0.775rem;
color: #e8a030;
line-height: 1.4;
padding-left: 1.1rem;
position: relative;
}
.warning-list li::before {
content: "!";
position: absolute;
left: 0;
font-weight: 700;
color: #e8a030;
}
+19
View File
@@ -0,0 +1,19 @@
/* Elements Bare HTML */
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
+7
View File
@@ -0,0 +1,7 @@
/* Generic Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
+15
View File
@@ -0,0 +1,15 @@
/* ITCSS entry point */
@import "settings.css";
@import "generic.css";
@import "elements.css";
@import "objects.css";
@import "components/header.css";
@import "components/hero.css";
@import "components/cards.css";
@import "components/panel.css";
@import "components/buttons.css";
@import "components/swatches.css";
@import "components/preview.css";
@import "components/file-info.css";
@import "components/warnings.css";
+59
View File
@@ -0,0 +1,59 @@
/* Objects Layout patterns and shared abstractions */
/* Surface base background, border, and radius shared across raised UI */
.tool-card,
.tool-panel,
.tool-preview,
.empty-state {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
/* Hover accent border interactive surfaces that highlight on focus/drag */
.tool-card:hover,
.btn:hover:not(:disabled),
.load-area:hover,
.load-area.drag-over,
.tool-preview.drag-over {
border-color: var(--accent-dim);
}
/* Section label small uppercase heading shared across page and panel contexts */
.section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
/* Page container */
.page {
max-width: 1400px;
margin: 0 auto;
padding: var(--gap);
}
/* Top-level sections share uniform vertical spacing */
.section,
.tool-workspace {
margin-top: var(--gap);
}
.section > .section-label {
margin-bottom: 1rem;
}
/* Tool grid */
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
/* Tool workspace */
.tool-workspace {
display: flex;
gap: 1rem;
}
+16
View File
@@ -0,0 +1,16 @@
/* Settings Design tokens */
:root {
--bg: #1a1a1e;
--bg-surface: #25252b;
--bg-raised: #2e2e36;
--border: #3a3a44;
--text: #e4e4ed;
--text-muted: #7a7a90;
--accent: #6e8efb;
--accent-dim: #3a4a8a;
--radius: 6px;
--radius-sm: 4px;
--gap: 1.5rem;
--speed: 0.15s;
}
+124
View File
@@ -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>
+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);