airliner mesh is generated in-repo: tools/plane_gen.py writes vendor/plane3d.js (480 tris, CC0, same unit extent as the old Grab3D derivative); attribution updated

This commit is contained in:
Richard Zink committed 2026-10-11 18:42:48 +02:00
1 parent 499c00d8b1
commit 113e4c522d
5 files changed
+211 -12

No files matched your search

+6 -2
View File
@@ -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 - **Realistic night sky**: ~8700 stars from the Yale Bright Star Catalogue
(points sized by magnitude, coloured by temperature), rotated at the sidereal (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. 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 airfields, with a destination rolled at every take-off, sun-lit silhouettes
and contrails by day, strobing position lights by night. and contrails by day, strobing position lights by night.
- **Night lights**: vehicle lights moving along the real road network (Overpass) - **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 ├── ash.css default colour theme
├── gruvbox.css alternative colour theme ├── gruvbox.css alternative colour theme
├── nord.css alternative colour theme ├── nord.css alternative colour theme
├── tools/
│ └── plane_gen.py generates vendor/plane3d.js (the airliner mesh)
├── vendor/ ├── vendor/
│ ├── clouds3d.js WebGL scene renderer (sky, clouds, terrain, lights) │ ├── clouds3d.js WebGL scene renderer (sky, clouds, terrain, lights)
│ ├── suncalc.js sun/moon positions - ISC (SunCalc by mourner) │ ├── suncalc.js sun/moon positions - ISC (SunCalc by mourner)
@@ -255,7 +258,8 @@ window.TRANSLATIONS['Sunrise']['xx'] = 'MyTranslation';
- **Background application code**: MIT (see `LICENSE`) - **Background application code**: MIT (see `LICENSE`)
- **SunCalc** (vendored): ISC - <https://github.com/mourner/suncalc> - **SunCalc** (vendored): ISC - <https://github.com/mourner/suncalc>
- **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 - <https://open-meteo.com/> - **Open-Meteo** (weather API): CC BY 4.0 - <https://open-meteo.com/>
- **ESRI** basemaps (`World_Imagery`, `World_Street_Map`) served through the - **ESRI** basemaps (`World_Imagery`, `World_Street_Map`) served through the
bundled proxy: Esri, its licensors and contributors - bundled proxy: Esri, its licensors and contributors -
-2
View File
@@ -1623,8 +1623,6 @@ window.weatherThemes = {
lines.push('Terrain: Copernicus DEM via Mapzen Terrarium'); lines.push('Terrain: Copernicus DEM via Mapzen Terrarium');
if (t.carLights !== false || t.cityLights !== false) lines.push('Roads: \u00a9 OpenStreetMap contributors (ODbL)'); 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('Stars: Yale Bright Star Catalogue');
lines.push('Weather: Open-Meteo'); lines.push('Weather: Open-Meteo');
el.innerHTML = lines.join('<br>'); el.innerHTML = lines.join('<br>');
+195
View File
@@ -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 = '<base64>' 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('<fffffff',
p[0], p[1], p[2], n[0], n[1], n[2], col)
b64 = base64.b64encode(bytes(out)).decode('ascii')
head = ("""/* Airliner low-poly mesh (%d triangles, flat shaded, no textures).
* Generated procedurally by tools/plane_gen.py - original geometry, CC0 1.0.
* Non-indexed interleaved: pos(3) nrm(3) colIdx(1); unit extent 1.0,
* forward = +Z, up = +Y, nose-to-tail ~2.0 (see PLANE_VERT_SRC in clouds3d.js).
* colIdx: 0 = body, 1 = glazing.
*/
window.__planeVerts = '""" % len(TRIS))
path = os.path.join(ROOT, 'vendor', 'plane3d.js')
with open(path, 'w', encoding='utf-8') as f:
f.write(head + b64 + "';\n")
pts = [p for t in TRIS for p in t[:3]]
xs = [p[0] for p in pts]; ys = [p[1] for p in pts]; zs = [p[2] for p in pts]
glass = sum(1 for t in TRIS if t[3] == GLASS)
print('wrote %s' % path)
print('triangles %d (glazing %d), bytes %d' % (len(TRIS), glass, len(out)))
print('x %.3f..%.3f y %.3f..%.3f z %.3f..%.3f'
% (min(xs), max(xs), min(ys), max(ys), min(zs), max(zs)))
if __name__ == '__main__':
build()
emit()
+3 -4
View File
@@ -8,10 +8,9 @@ stars.json - star catalogue derived from the Yale Bright Star Catalogue
(5th ed., public catalogue data). Generated for this project; see the (5th ed., public catalogue data). Generated for this project; see the
real-sky notes in clouds3d.js. real-sky notes in clouds3d.js.
plane3d.js - low-poly airliner mesh (599 triangles, flattened, no textures) plane3d.js - low-poly airliner mesh (480 triangles, flat shaded, no textures)
Derived from 'Passenger Airplane' by Grab3D (https://grab3d.com), CC0 1.0 Original geometry generated by tools/plane_gen.py in this repository; offered
(public domain dedication). Decimated and repacked for this project; the under CC0 1.0 like the rest of the project's own assets.
derived mesh is offered under CC0 as well.
clouds3d.js - original code for this project (same license as the clouds3d.js - original code for this project (same license as the
repository root LICENSE). repository root LICENSE).
+7 -4
View File
@@ -1,4 +1,7 @@
/* Airliner low-poly mesh (599 tris). Derived from 'Passenger Airplane' by Grab3D, /* Airliner low-poly mesh (480 triangles, flat shaded, no textures).
* CC0 (https://grab3d.com) - decimated and flattened for distant viewing. * Generated procedurally by tools/plane_gen.py - original geometry, CC0 1.0.
* Non-indexed interleaved: pos(3) nrm(3) colIdx(1); unit extent 1.0. */ * Non-indexed interleaved: pos(3) nrm(3) colIdx(1); unit extent 1.0,
window.__planeVerts = '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 Line truncated * forward = +Z, up = +Y, nose-to-tail ~2.0 (see PLANE_VERT_SRC in clouds3d.js).
* colIdx: 0 = body, 1 = glazing.
*/
window.__planeVerts = 'AAAAAHE9Cj7sUXg/gMqIPu5/Uj+DowA/AAAAAACbQDwkVAY+7FF4P4DKiD7uf1I/g6MAPwAAAABQkTQ9e+QzPq5HYT+Ayog+7n9SP4OjAD8AAAAAAAAAAHE9Cj7sUXg/gMqIPu5/Uj+DowA/AAAAAFCRND175DM+rkdhP4DKiD7uf1I/g6MAPwAAAAAAAAAAXI9CPq5HYT+Ayog+7n9SP4OjAD8AAAAAAJtAPCRUBj7sUXg/0A8zP5IYAj+DowA/AAAAADTSmzxmLfg97FF4P9APMz+SGAI/g6MAPwAAAAAQFZI9FX4NPq5HYT/QDzM/khgCP4OjAD8AAAAAAJtAPCRUBj7sUXg/0A8zP5IYAj+DowA/AAAAABAVkj0Vfg0+rkdhP9APMz+SGAI/g6MAPwAAAABQkTQ9e+QzPq5HYT/QDzM/khgCP4OjAD8AAAAANNKbPGYt+D3sUXg/H1VdPwAAAACDowA/AAAAADTSmzzY3N497FF4Px9VXT8AAAAAg6MAPwAAAAAQFZI9FA68Pa5HYT8fVV0/AAAAAIOjAD8AAAAANNKbPGYt+D3sUXg/H1VdP1Y/TyWDowA/AAAAABAVkj0UDrw9rkdhPx9VXT9WP08lg6MAPwAAAAAQFZI9FX4NPq5HYT8fVV0/Vj9PJYOjAD8AAAAANNKbPNjc3j3sUXg/0A8zP5IYAr+DowA/AAAAAACbQDz2Yco97FF4P9APMz+SGAK/g6MAPwAAAABQkTQ9joJePa5HYT/QDzM/khgCv4OjAD8AAAAANNKbPNjc3j3sUXg/0A8zP5IYAr+DowA/AAAAAFCRND2Ogl49rkdhP9APMz+SGAK/g6MAPwAAAAAQFZI9FA68Pa5HYT/QDzM/khgCv4OjAD8AAAAAAJtAPPZhyj3sUXg/gMqIPu5/Ur+DowA/AAAAANm5NCJcj8I97FF4P4DKiD7uf1K/g6MAPwAAAAA8bikjCtcjPa5HYT+Ayog+7n9Sv4OjAD8AAAAAAJtAPPZhyj3sUXg/gMqIPu5/Ur+DowA/AAAAADxuKSMK1yM9rkdhP4DKiD7uf1K/g6MAPwAAAABQkTQ9joJePa5HYT+Ayog+7n9Sv4OjAD8AAAAA2bk0IlyPwj3sUXg/gMqIvu5/Ur+DowA/AAAAAACbQLz2Yco97FF4P4DKiL7uf1K/g6MAPwAAAABQkTS9joJePa5HYT+Ayoi+7n9Sv4OjAD8AAAAA2bk0IlyPwj3sUXg/gMqIvu5/Ur+DowA/AAAAAFCRNL2Ogl49rkdhP4DKiL7uf1K/g6MAPwAAAAA8bikjCtcjPa5HYT+Ayoi+7n9Sv4OjAD8AAAAAAJtAvPZhyj3sUXg/0A8zv5IYAr+DowA/AAAAADTSm7zY3N497FF4P9APM7+SGAK/g6MAPwAAAAAQFZK9FA68Pa5HYT/QDzO/khgCv4OjAD8AAAAAAJtAvPZhyj3sUXg/0A8zv5IYAr+DowA/AAAAABAVkr0UDrw9rkdhP9APM7+SGAK/g6MAPwAAAABQkTS9joJePa5HYT/QDzO/khgCv4OjAD8AAAAANNKbvNjc3j3sUXg/H1VdvwAAAACDowA/AAAAADTSm7xmLfg97FF4Px9VXb8AAAAAg6MAPwAAAAAQFZK9FX4NPq5HYT8fVV2/AAAAAIOjAD8AAAAANNKbvNjc3j3sUXg/H1Vdv1Y/T6WDowA/AAAAABAVkr0Vfg0+rkdhPx9VXb9WP0+lg6MAPwAAAAAQFZK9FA68Pa5HYT8fVV2/Vj9PpYOjAD8AAAAANNKbvGYt+D3sUXg/0A8zv5IYAj+DowA/AAAAAACbQLwkVAY+7FF4P9APM7+SGAI/g6MAPwAAAABQkTS9e+QzPq5HYT/QDzO/khgCP4OjAD8AAAAANNKbvGYt+D3sUXg/0A8zv5IYAj+DowA/AAAAAFCRNL175DM+rkdhP9APM7+SGAI/g6MAPwAAAAAQFZK9FX4NPq5HYT/QDzO/khgCP4OjAD8AAAAAAJtAvCRUBj7sUXg/gMqIvu5/Uj+DowA/AAAAAAAAAABxPQo+7FF4P4DKiL7uf1I/g6MAPwAAAAAAAAAAXI9CPq5HYT+Ayoi+7n9SP4OjAD8AAAAAAJtAvCRUBj7sUXg/gMqIvu5/Uj+DowA/AAAAAAAAAABcj0I+rkdhP4DKiL7uf1I/g6MAPwAAAABQkTS9e+QzPq5HYT+Ayoi+7n9SP4OjAD8AAAAAAAAAAFyPQj6uR2E/JiWYPpMgaj/yeYw+AAAAAFCRND175DM+rkdhPyYlmD6TIGo/8nmMPgAAAABwy3w92r5MPhSuRz8mJZg+kyBqP/J5jD4AAAAAAAAAAFyPQj6uR2E/JiWYPpMgaj/yeYw+AAAAAHDLfD3avkw+FK5HPyYlmD6TIGo/8nmMPgAAAAAAAAAArkdhPhSuRz8mJZg+kyBqP/J5jD4AAAAAUJE0PXvkMz6uR2E/GClHP9eyED/yeYw+AAAAABAVkj0Vfg0+rkdhPxgpRz/XshA/8nmMPgAAAADkg8w9SvwWPhSuRz8YKUc/17IQP/J5jD4AAAAAUJE0PXvkMz6uR2E/GClHP9eyED/yeYw+AAAAAOSDzD1K/BY+FK5HPxgpRz/XshA/8nmMPgAAAABwy3w92r5MPhSuRz8YKUc/17IQP/J5jD4AAAAAEBWSPRV+DT6uR2E/Cy12P5T3pSXyeYw+AAAAABAVkj0UDrw9rkdhPwstdj+U96Ul8nmMPgAAAADkg8w9qRGpPRSuRz8LLXY/lPelJfJ5jD4AAAAAEBWSPRV+DT6uR2E/Cy12PwJHXiXyeYw+AAAAAOSDzD2pEak9FK5HPwstdj8CR14l8nmMPgAAAADkg8w9SvwWPhSuRz8LLXY/AkdeJfJ5jD4AAAAAEBWSPRQOvD2uR2E/GClHP9eyEL/yeYw+AAAAAFCRND2Ogl49rkdhPxgpRz/XshC/8nmMPgAAAABwy3w9KTL2PBSuRz8YKUc/17IQv/J5jD4AAAAAEBWSPRQOvD2uR2E/GClHP9eyEL/yeYw+AAAAAHDLfD0pMvY8FK5HPxgpRz/XshC/8nmMPgAAAADkg8w9qRGpPRSuRz8YKUc/17IQv/J5jD4AAAAAUJE0PY6CXj2uR2E/JiWYPpMgar/yeYw+AAAAADxuKSMK1yM9rkdhPyYlmD6TIGq/8nmMPgAAAADtM20jCtcjPBSuRz8mJZg+kyBqv/J5jD4AAAAAUJE0PY6CXj2uR2E/JiWYPpMgar/yeYw+AAAAAO0zbSMK1yM8FK5HPyYlmD6TIGq/8nmMPgAAAABwy3w9KTL2PBSuRz8mJZg+kyBqv/J5jD4AAAAAPG4pIwrXIz2uR2E/JiWYvpMgar/yeYw+AAAAAFCRNL2Ogl49rkdhPyYlmL6TIGq/8nmMPgAAAABwy3y9KTL2PBSuRz8mJZi+kyBqv/J5jD4AAAAAPG4pIwrXIz2uR2E/JiWYvpMgar/yeYw+AAAAAHDLfL0pMvY8FK5HPyYlmL6TIGq/8nmMPgAAAADtM20jCtcjPBSuRz8mJZi+kyBqv/J5jD4AAAAAUJE0vY6CXj2uR2E/GClHv9eyEL/yeYw+AAAAABAVkr0UDrw9rkdhPxgpR7/XshC/8nmMPgAAAADkg8y9qRGpPRSuRz8YKUe/17IQv/J5jD4AAAAAUJE0vY6CXj2uR2E/GClHv9eyEL/yeYw+AAAAAOSDzL2pEak9FK5HPxgpR7/XshC/8nmMPgAAAABwy3y9KTL2PBSuRz8YKUe/17IQv/J5jD4AAAAAEBWSvRQOvD2uR2E/Cy12v5T3paXyeYw+AAAAABAVkr0Vfg0+rkdhPwstdr+U96Wl8nmMPgAAAADkg8y9SvwWPhSuRz8LLXa/lPelpfJ5jD4AAAAAEBWSvRQOvD2uR2E/Cy12vwJHXqXyeYw+AAAAAOSDzL1K/BY+FK5HPwstdr8CR16l8nmMPgAAAADkg8y9qRGpPRSuRz8LLXa/AkdepfJ5jD4AAAAAEBWSvRV+DT6uR2E/GClHv9eyED/yeYw+AAAAAFCRNL175DM+rkdhPxgpR7/XshA/8nmMPgAAAABwy3y92r5MPhSuRz8YKUe/17IQP/J5jD4AAAAAEBWSvRV+DT6uR2E/GClHv9eyED/yeYw+AAAAAHDLfL3avkw+FK5HPxgpR7/XshA/8nmMPgAAAADkg8y9SvwWPhSuRz8YKUe/17IQP/J5jD4AAAAAUJE0vXvkMz6uR2E/JiWYvpMgaj/yeYw+AAAAAAAAAABcj0I+rkdhPyYlmL6TIGo/8nmMPgAAAAAAAAAArkdhPhSuRz8mJZi+kyBqP/J5jD4AAAAAUJE0vXvkMz6uR2E/JiWYvpMgaj/yeYw+AAAAAAAAAACuR2E+FK5HPyYlmL6TIGo/8nmMPgAAAABwy3y92r5MPhSuRz8mJZi+kyBqP/J5jD4AAAAAAAAAAK5HYT4Urkc/QhydPp3EcT+dxPE9AAAAAHDLfD3avkw+FK5HP0IcnT6dxHE/ncTxPQAAAABob4o9pAdVPjMzMz9CHJ0+ncRxP53E8T0AAAAAAAAAAK5HYT4Urkc/QhydPp3EcT+dxPE9AAAAAGhvij2kB1U+MzMzP0IcnT6dxHE/ncTxPQAAAAAAAAAAH4VrPjMzMz9CHJ0+ncRxP53E8T0AAAAAcMt8Pdq+TD4Urkc/AKlNP71rFT+dxPE9AAAAAOSDzD1K/BY+FK5HPwCpTT+9axU/ncTxPQAAAAAq/t89XCYaPjMzMz8AqU0/vWsVP53E8T0AAAAAcMt8Pdq+TD4Urkc/AKlNP71rFT+dxPE9AAAAACr+3z1cJho+MzMzPwCpTT+9axU/ncTxPQAAAABob4o9pAdVPjMzMz8AqU0/vWsVP53E8T0AAAAA5IPMPUr8Fj4Urkc/vjV+P2LVdCWdxPE9AAAAAOSDzD2pEak9Line truncated