summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/portraits/render.html85
-rw-r--r--tools/portraits/server.ts27
-rw-r--r--tools/sky/astro_test.js76
-rw-r--r--tools/sky/build-milkyway.py41
-rw-r--r--tools/sky/build-stars.py38
-rw-r--r--tools/sounds/build-sounds.py107
6 files changed, 374 insertions, 0 deletions
diff --git a/tools/portraits/render.html b/tools/portraits/render.html
new file mode 100644
index 0000000..8e0a26e
--- /dev/null
+++ b/tools/portraits/render.html
@@ -0,0 +1,85 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>render</title>
+<body style="margin:0">
+<script src="/vendor/aframe-1.7.1.min.js"></script>
+<script>
+// Renders a transparent head-and-torso portrait of each avatar, arms relaxed,
+// three-quarter view, and POSTs each PNG back to the helper server.
+const SLUGS = (new URLSearchParams(location.search).get('slugs') ||
+ 'oldmoustache,bigbro,lilbro,teddy,froggy,astronaut,polybot,wizzir').split(',');
+const SIZE = 384;
+
+const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
+renderer.setPixelRatio(1);
+renderer.setSize(SIZE, SIZE);
+renderer.outputColorSpace = THREE.SRGBColorSpace;
+renderer.setClearColor(0x000000, 0);
+document.body.appendChild(renderer.domElement);
+
+const loader = new THREE.GLTFLoader();
+const load = (url) => new Promise((res, rej) => loader.load(url, res, undefined, rej));
+
+function relaxArms(bone) {
+ const shoulder = new THREE.Vector3(), wrist = new THREE.Vector3(), q = new THREE.Quaternion(), pq = new THREE.Quaternion();
+ for (const [side, sign] of [['left', -1], ['right', 1]]) {
+ const upper = bone(`${side}UpperArm`), hand = bone(`${side}Hand`);
+ if (!upper || !hand) continue;
+ upper.getWorldPosition(shoulder);
+ hand.getWorldPosition(wrist);
+ const from = wrist.sub(shoulder).normalize();
+ const to = new THREE.Vector3(sign * 0.28, -1, -0.12).normalize();
+ q.setFromUnitVectors(from, to);
+ const world = upper.getWorldQuaternion(new THREE.Quaternion()).premultiply(q);
+ upper.parent.getWorldQuaternion(pq).invert();
+ upper.quaternion.copy(pq).multiply(world);
+ upper.updateMatrixWorld(true);
+ }
+}
+
+async function renderOne(slug) {
+ const gltf = await load(`/assets/avatars/${slug}.vrm`);
+ const vrm = gltf.parser.json.extensions.VRM;
+ const nodes = new Map();
+ gltf.scene.traverse((o) => {
+ const a = gltf.parser.associations.get(o);
+ if (a && a.nodes !== undefined) nodes.set(a.nodes, o);
+ if (o.isMesh) o.frustumCulled = false;
+ });
+ const bone = (name) => {
+ const hb = vrm.humanoid.humanBones.find((b) => b.bone === name);
+ return hb ? nodes.get(hb.node) : null;
+ };
+
+ const scene = new THREE.Scene();
+ scene.add(gltf.scene);
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 2));
+ gltf.scene.updateMatrixWorld(true);
+ relaxArms(bone);
+
+ const top = new THREE.Box3().setFromObject(gltf.scene).max.y;
+ const hips = bone('hips').getWorldPosition(new THREE.Vector3()).y;
+ const bottom = hips - (top - hips) * 0.05;
+ const centerY = (top + bottom) / 2;
+ const height = (top - bottom) * 1.2;
+
+ const camera = new THREE.PerspectiveCamera(28, 1, 0.01, 100);
+ const dist = (height / 2) / Math.tan(THREE.MathUtils.degToRad(14));
+ const yaw = THREE.MathUtils.degToRad(22);
+ camera.position.set(Math.sin(yaw) * dist, centerY + height * 0.04, -Math.cos(yaw) * dist);
+ camera.lookAt(0, centerY, 0);
+
+ renderer.render(scene, camera);
+ const blob = await new Promise((res) => renderer.domElement.toBlob(res, 'image/png'));
+ await fetch(`/save/${slug}`, { method: 'POST', body: blob });
+}
+
+(async () => {
+ const results = {};
+ for (const slug of SLUGS) {
+ try { await renderOne(slug); results[slug] = 'ok'; } catch (e) { results[slug] = String(e); }
+ }
+ window.results = results;
+ document.title = 'done';
+})();
+</script>
diff --git a/tools/portraits/server.ts b/tools/portraits/server.ts
new file mode 100644
index 0000000..8af80f5
--- /dev/null
+++ b/tools/portraits/server.ts
@@ -0,0 +1,27 @@
+// Renders avatar portraits for the lobby. Serves render.html plus the project's
+// public files, and saves the PNGs the page POSTs back into ./out.
+//
+// deno run --allow-net=127.0.0.1:8097 --allow-read --allow-write=tools/portraits tools/portraits/server.ts
+// then open http://localhost:8097/ (or ?slugs=name1,name2) and wait for the title "done".
+const HERE = new URL('.', import.meta.url).pathname;
+const PUBLIC = new URL('../../public', import.meta.url).pathname;
+await Deno.mkdir(`${HERE}out`, { recursive: true });
+
+const TYPES: Record<string, string> = { html: 'text/html', js: 'text/javascript', vrm: 'application/octet-stream' };
+
+Deno.serve({ port: 8097, hostname: '127.0.0.1' }, async (req) => {
+ const url = new URL(req.url);
+ if (url.pathname.includes('..')) return new Response('no', { status: 400 });
+ if (req.method === 'POST' && url.pathname.startsWith('/save/')) {
+ const slug = url.pathname.slice(6).replace(/[^a-z0-9-]/g, '');
+ await Deno.writeFile(`${HERE}out/${slug}.png`, new Uint8Array(await req.arrayBuffer()));
+ return new Response('ok');
+ }
+ const file = url.pathname === '/' ? `${HERE}render.html` : PUBLIC + url.pathname;
+ try {
+ const ext = file.split('.').pop() || '';
+ return new Response(await Deno.readFile(file), { headers: { 'content-type': TYPES[ext] || 'application/octet-stream' } });
+ } catch {
+ return new Response('not found', { status: 404 });
+ }
+});
diff --git a/tools/sky/astro_test.js b/tools/sky/astro_test.js
new file mode 100644
index 0000000..b299d16
--- /dev/null
+++ b/tools/sky/astro_test.js
@@ -0,0 +1,76 @@
+// Checks public/js/astro.js against JPL Horizons and Meeus.
+//
+// deno test tools/sky/astro_test.js
+import '../../public/js/astro.js';
+
+const { Astro } = globalThis;
+const RAD = Math.PI / 180;
+const gap = (a, b) => Math.abs((((a - b + 180) % 360) + 360) % 360 - 180);
+const ok = (cond, msg) => { if (!cond) throw new Error(msg); };
+
+function eclipticOf(dir, ms) {
+ // Inverse of Astro.equatorial, to compare with Horizons' ecliptic longitudes.
+ const ob = (23.4393 - 3.563e-7 * (Astro.julianDay(ms) - 2451543.5)) * RAD;
+ const [x, y, z] = dir;
+ const ye = y * Math.cos(ob) + z * Math.sin(ob);
+ const ze = -y * Math.sin(ob) + z * Math.cos(ob);
+ return { lon: ((Math.atan2(ye, x) / RAD) + 360) % 360, lat: Math.asin(ze) / RAD };
+}
+
+Deno.test('planets are where Horizons puts them (geocentric, of date)', () => {
+ // From android-toolkit's AstroTest: apparent ecliptic longitude and latitude of the date.
+ const refs = {
+ '2026-10-31T20:00:00Z': { sun: [218.4102216, 0.0001252], moon: [115.1119618, 2.6704709], mercury: [226.8441830, -1.5976931], venus: [206.3301706, -4.9067015], mars: [138.5376903, 1.5969459], jupiter: [144.3028948, 0.6895096], saturn: [9.2969189, -2.6851943] },
+ '2028-08-17T06:00:00Z': { sun: [144.8636375, 0.0000135], moon: [101.0244024, 1.0776734], mercury: [165.1873864, 0.4638201], venus: [99.2952316, -2.9103916], mars: [108.2009535, 0.7458269], jupiter: [178.6149976, 1.1269943], saturn: [41.2847987, -2.5175988] },
+ };
+ for (const [iso, bodies] of Object.entries(refs)) {
+ const ms = Date.parse(iso);
+ for (const [name, [lon, lat]] of Object.entries(bodies)) {
+ const e = Astro.ecliptic(name, ms);
+ ok(gap(e.lon, lon) < 0.05, `${name} ${iso} lon ${e.lon} vs ${lon}`);
+ ok(Math.abs(e.lat - lat) < 0.05, `${name} ${iso} lat ${e.lat} vs ${lat}`);
+ const back = eclipticOf(Astro.equatorial(e.lon, e.lat, ms), ms);
+ ok(gap(back.lon, e.lon) < 1e-6 && Math.abs(back.lat - e.lat) < 1e-6, `${name} round trip`);
+ }
+ }
+});
+
+Deno.test("Seattle's sky matches Horizons (airless azimuth, elevation, magnitude)", () => {
+ // Horizons, observer at 47.6062 N, 122.3321 W, 2026-11-01 00:00 UT (JD 2461345.5).
+ const ms = Date.parse('2026-11-01T00:00:00Z');
+ const refs = {
+ sun: [239.356839801, 7.223273431],
+ moon: [338.838357584, -17.481568478, -10.609],
+ mercury: [231.106696194, 8.172959876, 3.268],
+ venus: [249.495509600, -1.327997551, -4.170],
+ mars: [316.936325646, -14.742817319, 0.789],
+ jupiter: [310.975220809, -14.507738850, -2.003],
+ saturn: [87.986528867, -0.198109819, 0.462],
+ uranus: [33.812741373, -14.521612899, 5.611],
+ };
+ const horizon = Astro.horizonMatrix(ms, 47.6062, -122.3321);
+ const sky = Astro.bodies(ms, 47.6062, -122.3321);
+ const found = { sun: { dir: sky.sun }, moon: sky.moon };
+ for (const p of sky.planets) found[p.name] = p;
+ for (const [name, [az, alt, mag]] of Object.entries(refs)) {
+ const h = Astro.altAz(found[name].dir, horizon);
+ ok(Math.abs(h.alt - alt) < 0.05, `${name} alt ${h.alt} vs ${alt}`);
+ ok(gap(h.az, az) < 0.1, `${name} az ${h.az} vs ${az}`);
+ // Schlyter's magnitudes are rough for Mercury near inferior conjunction, as it is here.
+ if (mag !== undefined) ok(Math.abs(found[name].magnitude - mag) < (name === 'mercury' ? 0.5 : 0.25), `${name} mag ${found[name].magnitude} vs ${mag}`);
+ }
+});
+
+Deno.test('precession matches Meeus example 21.b', () => {
+ // Theta Persei, J2000 to 2028 Nov 13.19 TD, with its proper motion (+0.99 s, -2.58") taken back out.
+ const ms = (2462088.69 - 2440587.5) * 86400000;
+ const ra = (2 + 44 / 60 + 11.986 / 3600) * 15 * RAD;
+ const dec = (49 + 13 / 60 + 42.48 / 3600) * RAD;
+ const v = Astro.apply(Astro.precessionMatrix(ms), [Math.cos(dec) * Math.cos(ra), Math.cos(dec) * Math.sin(ra), Math.sin(dec)]);
+ const ra2 = Math.atan2(v[1], v[0]) / RAD / 15;
+ const dec2 = Math.asin(v[2]) / RAD;
+ const wantRa = 2 + 46 / 60 + (11.331 - 0.99) / 3600;
+ const wantDec = 49 + 20 / 60 + (54.54 + 2.58) / 3600;
+ ok(Math.abs(ra2 - wantRa) * 3600 * 15 < 1, `ra ${ra2} vs ${wantRa}`);
+ ok(Math.abs(dec2 - wantDec) * 3600 < 1, `dec ${dec2} vs ${wantDec}`);
+});
diff --git a/tools/sky/build-milkyway.py b/tools/sky/build-milkyway.py
new file mode 100644
index 0000000..c9a63bd
--- /dev/null
+++ b/tools/sky/build-milkyway.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+# Turns NASA SVS's "Deep Star Maps 2020" Milky Way background (the star map
+# without its Hipparcos and Tycho stars, which the sky draws itself) into the
+# sRGB JPEG the sky component maps onto the celestial sphere.
+#
+# curl -O https://svs.gsfc.nasa.gov/vis/a000000/a004800/a004851/milkyway_2020_4k.exr
+# python3 tools/sky/build-milkyway.py milkyway_2020_4k.exr public/assets/sky/milkyway-4k.jpg
+#
+# Needs vips (to read the OpenEXR), numpy and Pillow. The map is plate carrée
+# in J2000 right ascension and declination, centred on 0h with r.a. increasing
+# to the left; this keeps that layout. Values stay linear in brightness up to
+# WHITE (only the heart of the Sagittarius star cloud goes past it), and are
+# stored with the sRGB curve so the faint parts keep their detail in 8 bits.
+import os
+import subprocess
+import sys
+import tempfile
+
+import numpy as np
+from PIL import Image
+
+WHITE = 0.6
+
+
+def main(src, dst):
+ width, height = (int(subprocess.check_output(['vipsheader', '-f', f, src], stderr=subprocess.DEVNULL)) for f in ('width', 'height'))
+ with tempfile.TemporaryDirectory() as tmp:
+ raw = os.path.join(tmp, 'map.raw')
+ subprocess.run(['vips', 'rawsave', src, raw], check=True, stderr=subprocess.DEVNULL)
+ rgb = np.fromfile(raw, dtype=np.float32).reshape(height, width, -1)[..., :3]
+ x = np.clip(rgb / WHITE, 0.0, 1.0)
+ srgb = np.where(x <= 0.0031308, 12.92 * x, 1.055 * np.power(x, 1 / 2.4) - 0.055)
+ # A little noise before rounding keeps the dark gradients from banding.
+ noise = np.random.default_rng(1).uniform(-0.5, 0.5, srgb.shape)
+ out = np.clip(np.round(srgb * 255 + noise), 0, 255).astype(np.uint8)
+ Image.fromarray(out, 'RGB').save(dst, quality=88, optimize=True, progressive=True)
+ print(f'{width}x{height} -> {dst} ({os.path.getsize(dst) // 1024} KB)')
+
+
+if __name__ == '__main__':
+ main(sys.argv[1], sys.argv[2])
diff --git a/tools/sky/build-stars.py b/tools/sky/build-stars.py
new file mode 100644
index 0000000..061cea8
--- /dev/null
+++ b/tools/sky/build-stars.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+# Packs the Yale Bright Star Catalog (5th ed.) into public/assets/sky/stars.bin
+# for the sky component: four little-endian float32s per star, right ascension
+# and declination (J2000, radians), V magnitude and B-V colour index.
+#
+# curl -O http://tdc-www.harvard.edu/catalogs/bsc5.dat.gz
+# python3 tools/sky/build-stars.py bsc5.dat.gz public/assets/sky/stars.bin
+#
+# Byte columns follow the catalog's ReadMe (CDS V/50).
+import gzip
+import math
+import struct
+import sys
+
+
+def main(src, dst):
+ opener = gzip.open if src.endswith('.gz') else open
+ stars = []
+ with opener(src, 'rt', encoding='ascii', errors='replace') as f:
+ for line in f:
+ line = line.rstrip('\n').ljust(197)
+ if not line[75:77].strip() or not line[102:107].strip():
+ continue # the 14 entries that turned out not to be stars
+ ra = (int(line[75:77]) + int(line[77:79]) / 60 + float(line[79:83]) / 3600) * 15
+ dec = int(line[84:86]) + int(line[86:88]) / 60 + int(line[88:90]) / 3600
+ if line[83] == '-':
+ dec = -dec
+ vmag = float(line[102:107])
+ bv = float(line[109:114]) if line[109:114].strip() else 0.6
+ stars.append((math.radians(ra), math.radians(dec), vmag, bv))
+ with open(dst, 'wb') as out:
+ for s in stars:
+ out.write(struct.pack('<4f', *s))
+ print(f'{len(stars)} stars, {min(s[2] for s in stars):.2f} to {max(s[2] for s in stars):.2f} mag -> {dst}')
+
+
+if __name__ == '__main__':
+ main(sys.argv[1], sys.argv[2])
diff --git a/tools/sounds/build-sounds.py b/tools/sounds/build-sounds.py
new file mode 100644
index 0000000..0b64aeb
--- /dev/null
+++ b/tools/sounds/build-sounds.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+# Cuts the night sounds in public/assets/sounds/ out of their source
+# recordings: trims them, cleans up the owls, makes the fire and wind loop
+# seamlessly, and encodes them as Ogg Opus (gapless when looped).
+#
+# python3 tools/sounds/build-sounds.py SOURCE_DIR public/assets/sounds
+#
+# SOURCE_DIR holds the downloads under these names. BigSoundBank files are
+# https://bigsoundbank.com/UPLOAD/bwf-en/<number>.wav (CC0, Joseph Sardin).
+# 0988.wav "Big branching fire #2" bigsoundbank.com/big-branching-fire-2-s0988.html
+# 0904.wav "Wind in the Trees" bigsoundbank.com/wind-in-the-trees-s0904.html
+# 1299.wav "Broken twigs #1" bigsoundbank.com/broken-twigs-1-s1299.html
+# 1301.wav "Steps on the Twigs" bigsoundbank.com/steps-on-the-twigs-s1301.html
+# 0137.wav "Feet in Leaves #1" bigsoundbank.com/feet-in-leaves-1-s0137.html
+# 1588.wav "Gavel, 1 blow" bigsoundbank.com/gavel-1-blow-s1588.html
+# 0581.wav "Two dice on wooden table" bigsoundbank.com/two-dice-on-wooden-table-s0581.html
+# gho.mp3 "Bubo virginianus - Great Horned Owl XC450919.mp3" by Michael &
+# Katie LaTour (xeno-canto), CC BY-SA 4.0, on Wikimedia Commons
+# bdow.oga "Barred Owl, Yellowstone National Park.oga", National Park
+# Service, public domain, on Wikimedia Commons
+#
+# Needs ffmpeg (with libopus) and numpy.
+import os
+import subprocess
+import sys
+
+import numpy as np
+
+RATE = 48000
+
+
+def load(path, start=0.0, length=None, filters=None, channels=1):
+ cmd = ['ffmpeg', '-v', 'error', '-ss', str(start)]
+ if length:
+ cmd += ['-t', str(length)]
+ cmd += ['-i', path, '-ac', str(channels), '-ar', str(RATE)]
+ if filters:
+ cmd += ['-af', filters]
+ raw = subprocess.run(cmd + ['-f', 'f32le', '-'], capture_output=True, check=True).stdout
+ return np.frombuffer(raw, np.float32).reshape(-1, channels).copy()
+
+
+def save(audio, path, bitrate='64k'):
+ audio = audio / np.abs(audio).max() * 0.89 # peak -1 dB; loudness is set in ambience.js
+ channels = audio.shape[1]
+ subprocess.run(
+ ['ffmpeg', '-v', 'error', '-y', '-f', 'f32le', '-ar', str(RATE), '-ac', str(channels), '-i', '-',
+ '-c:a', 'libopus', '-b:a', bitrate, path],
+ input=audio.astype(np.float32).tobytes(), check=True)
+ print(f'{os.path.basename(path)}: {len(audio) / RATE:.1f} s, {os.path.getsize(path) // 1024} KB')
+
+
+def fade(audio, fade_in=0.05, fade_out=0.3):
+ n_in, n_out = int(fade_in * RATE), int(fade_out * RATE)
+ audio[:n_in] *= np.linspace(0, 1, n_in)[:, None]
+ audio[-n_out:] *= np.linspace(1, 0, n_out)[:, None]
+ return audio
+
+
+def loop(audio, overlap):
+ """Folds the last `overlap` seconds over the start with an equal-power crossfade."""
+ n = int(overlap * RATE)
+ body = audio[:-n].copy()
+ t = np.linspace(0, 1, n)[:, None]
+ body[:n] = body[:n] * np.sqrt(t) + audio[-n:] * np.sqrt(1 - t)
+ return body
+
+
+def main(src, dst):
+ os.makedirs(dst, exist_ok=True)
+ s = lambda name: os.path.join(src, name)
+ d = lambda name: os.path.join(dst, name)
+
+ # Fire: 40 s of an outdoor branch fire, looped, without the rumble under it
+ # (around 20 Hz: the microphone or distant traffic, not the fire).
+ save(loop(load(s('0988.wav'), 4, 42, 'highpass=f=70:poles=2,highpass=f=70:poles=2'), 2.0), d('fire.ogg'))
+
+ # Wind in the trees: stereo, looped. Under the rustling there's a steady roar
+ # of distant traffic or planes (it doesn't follow the gusts), so only the
+ # leaves are kept. (From 33 s: earlier there's a bump, later a few birds.)
+ save(loop(load(s('0904.wav'), 33, 63, 'highpass=f=500:poles=2,highpass=f=500:poles=2', channels=2), 3.0),
+ d('wind.ogg'), '80k')
+
+ # Owls: two phrases of the great horned owl, with the low hum under them taken out.
+ owl = 'highpass=f=250:poles=2,highpass=f=250:poles=2,lowpass=f=1400'
+ save(fade(load(s('gho.mp3'), 2.0, 3.6, owl), 0.15, 0.4), d('owl-great-horned-1.ogg'), '48k')
+ save(fade(load(s('gho.mp3'), 10.0, 3.6, owl), 0.15, 0.4), d('owl-great-horned-2.ogg'), '48k')
+ save(fade(load(s('bdow.oga'), 0, None, 'highpass=f=200:poles=2,highpass=f=200:poles=2'), 0.05, 0.4), d('owl-barred.ogg'), '48k')
+
+ # Something out in the dark: twigs snapping, and footsteps on twigs and in
+ # leaves. The footsteps were recorded close up and thud (mostly below 40 Hz),
+ # which something small walking past 20 m away wouldn't.
+ light = 'highpass=f=150:poles=2,highpass=f=150:poles=2'
+ save(fade(load(s('1299.wav'), 3.55, 0.75, light)), d('twig-1.ogg'))
+ save(fade(load(s('1299.wav'), 0.0, 2.3, light)), d('twig-2.ogg'))
+ save(fade(load(s('1301.wav'), 0.0, 6.4, light), 0.05, 0.5), d('steps-twigs.ogg'))
+ save(fade(load(s('0137.wav'), 0.0, 7.8, light), 0.05, 0.6), d('steps-leaves.ogg'))
+
+ # A chess piece set down (chess.js): a light gavel tap, and the first knock of
+ # dice landing on a wooden table, cut before they bounce.
+ save(fade(load(s('1588.wav'), 0.02, 0.25, light), 0.003, 0.15), d('chess-1.ogg'), '48k')
+ save(fade(load(s('0581.wav'), 4.095, 0.17, light), 0.003, 0.08), d('chess-2.ogg'), '48k')
+ save(fade(load(s('0581.wav'), 12.825, 0.13, light), 0.003, 0.06), d('chess-3.ogg'), '48k')
+
+
+if __name__ == '__main__':
+ main(sys.argv[1], sys.argv[2])