fuzzyworld

tower / cascadia / files / tools / png.mjs

Branch cascadia · default branch · all branches

png.mjs

Blame: the commit that last changed each line · file · history

1de9dc7c 2026-09-07 Pavel Safronov1/**
2 * Minimal PNG encoder. Node's zlib is the only dependency.
3 *
4 * Supports what the terrain tool needs: 16-bit greyscale (height data, where 8 bits
5 * would quantise a 5,000 m range into 20 m steps) and 8-bit RGB (the colour map).
6 * Scanlines use the Up filter, which suits smooth terrain and costs three lines.
7 */
1affced2 2026-09-08 Pavel Safronov8import { deflateSync, inflateSync } from 'node:zlib'
1de9dc7c 2026-09-07 Pavel Safronov9
10const CRC_TABLE = (() => {
11 const t = new Int32Array(256)
12 for (let n = 0; n < 256; n++) {
13 let c = n
14 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
15 t[n] = c
16 }
17 return t
18})()
19
20function crc32(buf) {
21 let c = -1
22 for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
23 return (c ^ -1) >>> 0
24}
25
26function chunk(type, data) {
27 const out = Buffer.alloc(data.length + 12)
28 out.writeUInt32BE(data.length, 0)
29 out.write(type, 4, 'ascii')
30 data.copy(out, 8)
31 const body = out.subarray(4, 8 + data.length)
32 out.writeUInt32BE(crc32(body), 8 + data.length)
33 return out
34}
35
36/** Apply the Up filter in place, row by row, and prefix each row with its filter byte. */
37function filterRows(raw, width, height, bytesPerPixel) {
38 const stride = width * bytesPerPixel
39 const out = Buffer.alloc((stride + 1) * height)
40 for (let y = 0; y < height; y++) {
41 out[y * (stride + 1)] = 2 // Up
42 const row = y * stride
43 const prev = (y - 1) * stride
44 const dst = y * (stride + 1) + 1
45 for (let x = 0; x < stride; x++) {
46 const up = y === 0 ? 0 : raw[prev + x]
47 out[dst + x] = (raw[row + x] - up) & 0xff
48 }
49 }
50 return out
51}
52
53function encode(width, height, colorType, bitDepth, raw, bytesPerPixel, level) {
54 const ihdr = Buffer.alloc(13)
55 ihdr.writeUInt32BE(width, 0)
56 ihdr.writeUInt32BE(height, 4)
57 ihdr[8] = bitDepth
58 ihdr[9] = colorType
59 return Buffer.concat([
60 Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
61 chunk('IHDR', ihdr),
62 chunk('IDAT', deflateSync(filterRows(raw, width, height, bytesPerPixel), { level })),
63 chunk('IEND', Buffer.alloc(0)),
64 ])
65}
66
67/**
68 * 16-bit greyscale from normalised values in [0,1]. Level 9 is nearly free here:
69 * smooth terrain under the Up filter is almost all near-zero deltas.
70 */
71export function encodeGray16(width, height, values, level = 9) {
72 const raw = Buffer.alloc(width * height * 2)
73 for (let i = 0; i < width * height; i++) {
74 const v = Math.max(0, Math.min(1, values[i]))
75 raw.writeUInt16BE(Math.round(v * 65535), i * 2)
76 }
77 return encode(width, height, 0, 16, raw, 2, level)
78}
79
80/**
81 * 8-bit RGB from an interleaved Uint8Array of length width*height*3.
82 * Level 6 by default: hillshade noise compresses poorly, and level 9 spends
83 * several seconds per map for a couple of percent. Wrong trade for a dev tool.
84 */
85export function encodeRGB(width, height, rgb, level = 6) {
86 return encode(width, height, 2, 8, Buffer.from(rgb.buffer, rgb.byteOffset, rgb.length), 3, level)
87}
1affced2 2026-09-08 Pavel Safronov88
89/**
90 * Decode an 8-bit PNG to interleaved RGB. The inverse of the above, and only as
91 * general as tools/cascadia.mjs needs: colour types 0, 2 and 6 at bit depth 8,
92 * which is what every elevation tile service emits. Interlaced files are rejected
93 * rather than mishandled, because a silently wrong height field is D-024's failure
94 * mode — it displaces terrain instead of breaking it.
95 */
96export function decodeRGB(buf) {
97 if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG')
98 let width = 0, height = 0, colorType = 0, bitDepth = 0
99 const idat = []
100 for (let p = 8; p < buf.length;) {
101 const len = buf.readUInt32BE(p)
102 const type = buf.toString('ascii', p + 4, p + 8)
103 const data = buf.subarray(p + 8, p + 8 + len)
104 if (type === 'IHDR') {
105 width = data.readUInt32BE(0)
106 height = data.readUInt32BE(4)
107 bitDepth = data[8]
108 colorType = data[9]
109 if (data[12] !== 0) throw new Error('interlaced PNG unsupported')
110 } else if (type === 'IDAT') idat.push(data)
111 else if (type === 'IEND') break
112 p += len + 12
113 }
114 if (bitDepth !== 8) throw new Error(`bit depth ${bitDepth} unsupported`)
115 const channels = { 0: 1, 2: 3, 6: 4 }[colorType]
116 if (!channels) throw new Error(`colour type ${colorType} unsupported`)
117
118 const raw = inflateSync(Buffer.concat(idat))
119 const stride = width * channels
120 const out = Buffer.alloc(stride * height)
121 // Undo the per-scanline filter. Paeth is the only one that needs a helper.
122 for (let y = 0; y < height; y++) {
123 const filter = raw[y * (stride + 1)]
124 const src = y * (stride + 1) + 1
125 const dst = y * stride
126 const up = (y - 1) * stride
127 for (let x = 0; x < stride; x++) {
128 const a = x >= channels ? out[dst + x - channels] : 0
129 const b = y > 0 ? out[up + x] : 0
130 const c = x >= channels && y > 0 ? out[up + x - channels] : 0
131 let v = raw[src + x]
132 if (filter === 1) v += a
133 else if (filter === 2) v += b
134 else if (filter === 3) v += (a + b) >> 1
135 else if (filter === 4) {
136 const q = a + b - c
137 const pa = Math.abs(q - a), pb = Math.abs(q - b), pc = Math.abs(q - c)
138 v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
139 }
140 out[dst + x] = v & 0xff
141 }
142 }
143 return { width, height, channels, data: out }
144}