import fs from 'node:fs' import path from 'node:path' import zlib from 'node:zlib' const OUT = path.resolve('public/icons') fs.mkdirSync(OUT, { recursive: true }) const CRC_TABLE = (() => { const table = new Uint32Array(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 table[n] = c >>> 0 } return table })() function crc32(buf) { let c = 0xffffffff for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8) return (c ^ 0xffffffff) >>> 0 } function chunk(type, data) { const len = Buffer.alloc(4) len.writeUInt32BE(data.length, 0) const t = Buffer.from(type, 'ascii') const crc = Buffer.alloc(4) crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0) return Buffer.concat([len, t, data, crc]) } function encodePng(width, height, rgba) { const ihdr = Buffer.alloc(13) ihdr.writeUInt32BE(width, 0) ihdr.writeUInt32BE(height, 4) ihdr[8] = 8 ihdr[9] = 6 ihdr[10] = 0 ihdr[11] = 0 ihdr[12] = 0 const raw = Buffer.alloc((width * 4 + 1) * height) for (let y = 0; y < height; y++) { raw[y * (width * 4 + 1)] = 0 rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4) } const idat = zlib.deflateSync(raw, { level: 9 }) return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0)) ]) } function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v } function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t] } function roundedRectInside(x, y, cx, cy, hw, hh, r) { const dx = Math.abs(x - cx) - (hw - r) const dy = Math.abs(y - cy) - (hh - r) const ox = Math.max(dx, 0) const oy = Math.max(dy, 0) return Math.hypot(ox, oy) + Math.min(Math.max(dx, dy), 0) - r <= 0 } function segmentDistance(px, py, ax, ay, bx, by) { const dx = bx - ax const dy = by - ay const len2 = dx * dx + dy * dy || 1 let t = ((px - ax) * dx + (py - ay) * dy) / len2 t = t < 0 ? 0 : t > 1 ? 1 : t return Math.hypot(px - (ax + dx * t), py - (ay + dy * t)) } function ringInside(px, py, cx, cy, r, width) { return Math.abs(Math.hypot(px - cx, py - cy) - r) <= width / 2 } function arcInside(px, py, cx, cy, r, width, a0, a1) { if (!ringInside(px, py, cx, cy, r, width)) return false let a = Math.atan2(py - cy, px - cx) while (a < a0) a += Math.PI * 2 return a <= a1 } function samplePlan(x, y, pad) { const sx = (x - pad) / (1 - pad * 2) const sy = (y - pad) / (1 - pad * 2) const line = 0.048 const l = 0.022 // outer wall if (segmentDistance(sx, sy, 0.2, 0.3, 0.8, 0.3) < line / 2) return true if (segmentDistance(sx, sy, 0.8, 0.3, 0.8, 0.7) < line / 2) return true if (segmentDistance(sx, sy, 0.8, 0.7, 0.2, 0.7) < line / 2) return true if (segmentDistance(sx, sy, 0.2, 0.7, 0.2, 0.3) < line / 2) return true // inner walls if (segmentDistance(sx, sy, 0.52, 0.3, 0.52, 0.7) < l) return true if (segmentDistance(sx, sy, 0.52, 0.5, 0.8, 0.5) < l) return true // door swing if (arcInside(sx, sy, 0.52, 0.5, 0.15, l * 1.2, -Math.PI / 2, 0)) return true if (segmentDistance(sx, sy, 0.52, 0.5, 0.52, 0.65) < l * 0.9) return true // round table if (ringInside(sx, sy, 0.34, 0.42, 0.075, l * 1.3)) return true if (ringInside(sx, sy, 0.34, 0.42, 0.028, l)) return true // bed if (segmentDistance(sx, sy, 0.58, 0.57, 0.74, 0.57) < l * 0.9) return true if (segmentDistance(sx, sy, 0.74, 0.57, 0.74, 0.66) < l * 0.9) return true if (segmentDistance(sx, sy, 0.74, 0.66, 0.58, 0.66) < l * 0.9) return true if (segmentDistance(sx, sy, 0.58, 0.66, 0.58, 0.57) < l * 0.9) return true return false } function render(size, { maskable = false, fullSquare = false } = {}) { const aa = 4 const w = size * aa const rgba = Buffer.alloc(size * size * 4) const pad = maskable ? 0.2 : 0.14 const bgTop = [96, 156, 255] const bgBottom = [29, 78, 216] const bgDeep = [12, 22, 44] for (let py = 0; py < size; py++) { for (let px = 0; px < size; px++) { let sr = 0 let sg = 0 let sb = 0 let sa = 0 for (let oy = 0; oy < aa; oy++) { for (let ox = 0; ox < aa; ox++) { const x = (px + (ox + 0.5) / aa) / size const y = (py + (oy + 0.5) / aa) / size const insideBg = fullSquare || roundedRectInside(x, y, 0.5, 0.5, 0.5, 0.5, 0.235) if (!insideBg) continue const t = clamp01((x + y) / 2.2) let color = mix(bgTop, bgBottom, t) const vignette = clamp01(Math.hypot(x - 0.3, y - 0.26) * 1.35) color = mix(color, bgDeep, vignette * 0.24) if (samplePlan(x, y, pad)) { color = mix([255, 255, 255], [192, 219, 255], clamp01((x + y) / 2.4) * 0.45) } sr += color[0] sg += color[1] sb += color[2] sa += 1 } } const total = aa * aa const i = (py * size + px) * 4 if (sa === 0) { rgba[i] = 0 rgba[i + 1] = 0 rgba[i + 2] = 0 rgba[i + 3] = 0 } else { rgba[i] = Math.round(sr / sa) rgba[i + 1] = Math.round(sg / sa) rgba[i + 2] = Math.round(sb / sa) rgba[i + 3] = Math.round((sa / total) * 255) } } } return encodePng(size, size, rgba) } fs.writeFileSync(path.join(OUT, 'icon-192.png'), render(192)) fs.writeFileSync(path.join(OUT, 'icon-512.png'), render(512)) fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), render(180, { fullSquare: true })) fs.writeFileSync(path.join(OUT, 'icon-maskable-512.png'), render(512, { maskable: true, fullSquare: true })) console.log('icons written to', OUT)