fuzzyworld

tower / interior-flight / files / tools / png.mjs

Branch interior-flight · default branch · all branches

png.mjs

2.8 KB · history · blame

1/**
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 */
8import { deflateSync } from 'node:zlib'
9
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}