diff --git a/README.md b/README.md index 11ad260..4b2efbf 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ dropped into an iframe or kiosk browser and left running. - **Realistic night sky**: ~8700 stars from the Yale Bright Star Catalogue (points sized by magnitude, coloured by temperature), rotated at the sidereal rate for the site's latitude, a Milky Way band and a phase-correct moon. -- **Aircraft**: low-poly jets (CC0 model) on curved routes between real +- **Aircraft**: low-poly jets (own CC0 mesh, generated by `tools/plane_gen.py`) + on curved routes between real airfields, with a destination rolled at every take-off, sun-lit silhouettes and contrails by day, strobing position lights by night. - **Night lights**: vehicle lights moving along the real road network (Overpass) @@ -237,6 +238,8 @@ window.TRANSLATIONS['Sunrise']['xx'] = 'MyTranslation'; ├── ash.css default colour theme ├── gruvbox.css alternative colour theme ├── nord.css alternative colour theme +├── tools/ +│ └── plane_gen.py generates vendor/plane3d.js (the airliner mesh) ├── vendor/ │ ├── clouds3d.js WebGL scene renderer (sky, clouds, terrain, lights) │ ├── suncalc.js sun/moon positions - ISC (SunCalc by mourner) @@ -255,7 +258,8 @@ window.TRANSLATIONS['Sunrise']['xx'] = 'MyTranslation'; - **Background application code**: MIT (see `LICENSE`) - **SunCalc** (vendored): ISC - -- **Aircraft model** (`vendor/plane3d.js`): CC0 +- **Aircraft model** (`vendor/plane3d.js`): own geometry, generated by + `tools/plane_gen.py`, CC0 - **Open-Meteo** (weather API): CC BY 4.0 - - **ESRI** basemaps (`World_Imagery`, `World_Street_Map`) served through the bundled proxy: Esri, its licensors and contributors - diff --git a/background.html b/background.html index 5ac9a1e..43ed059 100644 --- a/background.html +++ b/background.html @@ -1623,8 +1623,6 @@ window.weatherThemes = { lines.push('Terrain: Copernicus DEM via Mapzen Terrarium'); if (t.carLights !== false || t.cityLights !== false) lines.push('Roads: \u00a9 OpenStreetMap contributors (ODbL)'); } - var fc = cfg.flights; - if (!fc || fc.enabled !== false) lines.push('Airliner model: Grab3D (CC0)'); lines.push('Stars: Yale Bright Star Catalogue'); lines.push('Weather: Open-Meteo'); el.innerHTML = lines.join('
'); diff --git a/tools/plane_gen.py b/tools/plane_gen.py new file mode 100644 index 0000000..c508914 --- /dev/null +++ b/tools/plane_gen.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Generate vendor/plane3d.js - the low-poly airliner used by the flight layer. + +The mesh is built here from primitives (no source model, no textures), so the +asset is CC0 by construction and reproducible: + + python3 tools/plane_gen.py # writes vendor/plane3d.js + +Output format (exactly what clouds3d.js expects): + window.__planeVerts = '' of non-indexed triangles, interleaved per + vertex: pos(3), normal(3), colIdx(1), float32 little endian. + colIdx: 0 = body (light albedo), 1 = glazing (dark albedo). + Convention: forward = +Z, up = +Y, nose-to-tail ~2.0 (the renderer scales + the model with uS and assumes that ~2.0 span, see PLANE_VERT_SRC). +""" + +import base64 +import math +import os +import struct + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +BODY, GLASS = 0.0, 1.0 +SIDES = 10 +TRIS = [] + + +def add_tri(a, b, c, col, ref): + """Store one triangle; its flat normal is oriented away from ref.""" + ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2] + vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2] + nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx + ln = math.sqrt(nx * nx + ny * ny + nz * nz) + if ln < 1e-9: + return + nx, ny, nz = nx / ln, ny / ln, nz / ln + mx = (a[0] + b[0] + c[0]) / 3.0 - ref[0] + my = (a[1] + b[1] + c[1]) / 3.0 - ref[1] + mz = (a[2] + b[2] + c[2]) / 3.0 - ref[2] + if nx * mx + ny * my + nz * mz < 0: + nx, ny, nz = -nx, -ny, -nz + TRIS.append((a, b, c, col, (nx, ny, nz))) + + +def add_quad(a, b, c, d, col, ref): + add_tri(a, b, c, col, ref) + add_tri(a, c, d, col, ref) + + +def ring(z, r, cy, cx=0.0, phase=0.0): + pts = [] + for i in range(SIDES): + ang = phase + 2.0 * math.pi * i / SIDES + pts.append((cx + r * math.sin(ang), cy + r * math.cos(ang), z)) + return pts + + +def add_cap(r, ref, col=BODY): + cx = sum(p[0] for p in r) / len(r) + cy = sum(p[1] for p in r) / len(r) + cz = sum(p[2] for p in r) / len(r) + for i in range(len(r)): + add_tri((cx, cy, cz), r[i], r[(i + 1) % len(r)], col, ref) + + +def tube(stations, cy, ref, cx=0.0, col=BODY): + """stations: [(z, radius), ...] from one end to the other, caps closed.""" + rings = [ring(z, r, cy, cx) for (z, r) in stations] + for k in range(len(rings) - 1): + a, b = rings[k], rings[k + 1] + for i in range(SIDES): + j = (i + 1) % SIDES + add_quad(a[i], a[j], b[j], b[i], col, ref) + add_cap(rings[0], ref, col) + add_cap(rings[-1], ref, col) + + +def slab(root_le, root_te, tip_le, tip_te, half_t): + """Tapered wing/stabilizer: planform corners extruded by +-half_t in y.""" + def off(p, dy): + return (p[0], p[1] + dy, p[2]) + tlr, ttr = off(root_le, half_t), off(root_te, half_t) + tlt, ttt = off(tip_le, half_t), off(tip_te, half_t) + blr, btr = off(root_le, -half_t), off(root_te, -half_t) + blt, btt = off(tip_le, -half_t), off(tip_te, -half_t) + ref = ((root_le[0] + tip_le[0]) / 2.0, root_le[1] - 0.25, + (root_le[2] + tip_le[2]) / 2.0) + add_quad(tlr, tlt, ttt, ttr, BODY, ref) # top skin + add_quad(blr, btr, btt, blt, BODY, ref) # bottom skin + add_quad(tlr, blr, blt, tlt, BODY, ref) # leading edge + add_quad(ttr, ttt, btt, btr, BODY, ref) # trailing edge + add_quad(tlt, ttt, btt, blt, BODY, ref) # tip + + +def plate(outline, x, thickness, col=BODY): + """Thin plate: outline is a list of (z, y), extruded around x.""" + a = [(x - thickness / 2.0, p[1], p[0]) for p in outline] + b = [(x + thickness / 2.0, p[1], p[0]) for p in outline] + ym = sum(p[1] for p in outline) / len(outline) + zm = sum(p[0] for p in outline) / len(outline) + for i in range(1, len(a) - 1): + add_tri(a[0], a[i], a[i + 1], col, (x - 1.0, ym, zm)) + add_tri(b[0], b[i + 1], b[i], col, (x + 1.0, ym, zm)) + for i in range(len(a)): + j = (i + 1) % len(a) + add_quad(a[i], a[j], b[j], b[i], col, (x, ym, zm)) + + +def window_band(ang_mid, cy, r, z0, z1, step, h, col=GLASS): + """Glazing quads on the fuselage skin around the section angle ang_mid.""" + x = r * 1.02 * math.sin(ang_mid) + ym = cy + r * 1.02 * math.cos(ang_mid) + y_lo, y_hi = ym - h / 2.0, ym + h / 2.0 + ref = (0.0, cy, 0.5 * (z0 + z1)) + z = z0 + while z + step <= z1 + 1e-9: + za, zb = z + step * 0.18, z + step * 0.82 + add_quad((x, y_hi, za), (x, y_hi, zb), (x, y_lo, zb), (x, y_lo, za), + col, ref) + z += step + + +def build(): + cy = 0.115 + + # fuselage: nose cone, parallel section, tail cone + tube([(0.97, 0.020), (0.88, 0.075), (0.78, 0.105), (0.70, 0.115), + (-0.55, 0.115), (-0.75, 0.095), (-0.90, 0.055), (-1.00, 0.015)], + cy, (0.0, cy, 0.0)) + + # wings: swept and tapered, slight dihedral (root chord 0.56, tip 0.18) + for s in (1.0, -1.0): + slab((s * 0.09, 0.055, 0.30), (s * 0.09, 0.055, -0.26), + (s * 0.91, 0.125, -0.28), (s * 0.91, 0.125, -0.46), 0.016) + # winglet: small plate standing up at the tip + plate([(-0.28, 0.125), (-0.46, 0.125), (-0.46, 0.235), (-0.28, 0.215)], + s * 0.912, 0.014) + + # horizontal stabilizers + for s in (1.0, -1.0): + slab((s * 0.07, 0.135, -0.66), (s * 0.07, 0.135, -0.94), + (s * 0.42, 0.165, -0.86), (s * 0.42, 0.165, -1.00), 0.011) + + # vertical fin (outline in the (z, y) plane) + plate([(-0.48, 0.20), (-0.60, 0.60), (-0.86, 0.60), (-1.00, 0.26)], + 0.0, 0.026) + + # engine nacelles under the wings, with a thin pylon each + for s in (1.0, -1.0): + ex, ey = s * 0.36, -0.055 + tube([(0.24, 0.030), (0.18, 0.072), (-0.06, 0.075), (-0.14, 0.045)], + ey, (ex, ey, 0.05), cx=ex) + plate([(0.16, 0.015), (-0.02, 0.015), (-0.02, 0.075), (0.16, 0.075)], + ex, 0.030) + + # window bands on both flanks, cockpit glazing on the upper flanks + d = math.radians + window_band(d(90), cy, 0.115, -0.42, 0.58, 0.075, 0.048) + window_band(d(270), cy, 0.115, -0.42, 0.58, 0.075, 0.048) + window_band(d(54), cy, 0.115, 0.64, 0.82, 0.09, 0.055) + window_band(d(306), cy, 0.115, 0.64, 0.82, 0.09, 0.055) + + +def emit(): + out = bytearray() + for (a, b, c, col, n) in TRIS: + for p in (a, b, c): + out += struct.pack('