/* ============================================================ * tools/make_icons.mjs — generate PNG icons with zero deps. * Pixel-math art: hellfire sigil on obsidian. * node tools/make_icons.mjs * ============================================================ */ import zlib from 'node:zlib'; import fs from 'node:fs'; import path from 'node:path'; /* ---------- minimal PNG encoder (RGBA8, filter 0) ---------- */ const CRC_TABLE = (() => { const t = new Int32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c; } return t; })(); function crc32(buf) { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type, 'ascii'), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td)); return Buffer.concat([len, td, crc]); } function encodePNG(w, h, rgba) { const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; const stride = w * 4; const raw = Buffer.alloc(h * (stride + 1)); for (let y = 0; y < h; y++) { raw[y * (stride + 1)] = 0; rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); } return Buffer.concat([ sig, chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0)), ]); } /* ---------- sigil renderer (SDF-ish per pixel) ---------- */ function drawIcon(S) { const px = Buffer.alloc(S * S * 4); const cx = S / 2, cy = S / 2; const put = (x, y, r, g, b, a = 255) => { const i = (y * S + x) * 4; px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a; }; const mix = (x, y, r, g, b, alpha) => { const i = (y * S + x) * 4; px[i] = px[i] * (1 - alpha) + r * alpha; px[i + 1] = px[i + 1] * (1 - alpha) + g * alpha; px[i + 2] = px[i + 2] * (1 - alpha) + b * alpha; px[i + 3] = 255; }; for (let y = 0; y < S; y++) { for (let x = 0; x < S; x++) { const nx = (x - cx) / (S / 2), ny = (y - cy) / (S / 2); const rad = Math.hypot(nx, ny); /* obsidian background with vignette */ let r = 13, g = 10, b = 9; const vg = Math.max(0, 1 - rad * 0.85); r += 26 * vg; g += 18 * vg; b += 14 * vg; /* outer gold ring */ const ring = Math.abs(rad - 0.86); if (ring < 0.045) { const edge = 1 - ring / 0.045; mix(x, y, 200, 163, 90, 0.55 + 0.45 * edge); } /* hellfire pentagram star (5-point, drawn via angular falloff) */ const ang = Math.atan2(ny, nx) - Math.PI / 2; const spikes = 5; const starR = 0.34 + 0.30 * Math.pow(Math.abs(Math.cos(spikes * ang / 2)), 3); const d = rad - starR; if (d < 0) { const glow = Math.min(1, -d * 5); const fire = Math.pow(glow, 0.6); mix(x, y, 120 + 120 * fire, 24 + 60 * fire * (0.6 + 0.4 * Math.sin(ang * spikes)), 18, Math.min(1, 0.35 + glow)); /* molten core line */ if (Math.abs(d) < 0.02 && rad > 0.12) mix(x, y, 255, 214, 130, 0.75); } /* center ember */ const ember = Math.hypot(nx * 2.4, ny * 2.4); if (ember < 1) { const e = 1 - ember; mix(x, y, 255, 190 + 40 * e, 110, e * e); } put(x, y, px[(y * S + x) * 4], px[(y * S + x) * 4 + 1], px[(y * S + x) * 4 + 2], 255); } } return encodePNG(S, S, px); } const outDir = path.join(process.cwd(), 'icons'); fs.mkdirSync(outDir, { recursive: true }); for (const size of [180, 192, 512]) { fs.writeFileSync(path.join(outDir, `icon-${size}.png`), drawIcon(size)); console.log(`wrote icons/icon-${size}.png`); }