Files
resolved/scripts/make-example-scenes.py
T
2026-10-14 20:12:49 -04:00

182 lines
6.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Writes examples/scenes/<model>.toml for the NASA models fetch-models.sh downloads.
Every scene has the same composition: the spacecraft in an isometric view (the
camera looks along its body (+1, +1, +1) diagonal, body +Z toward image up) with
Earth's limb crossing the image 2 degrees above center, Earth below and space
above. The camera distance makes the model's bounding sphere span about 5.6
degrees, half the example camera's width.
Earth is turned so the ground behind the spacecraft, at the image center, is
25 N 33 E (Egypt, the Nile and the Red Sea), with the camera looking north
toward the Mediterranean on the limb. The Sun is the direction, on a 2-degree
grid, that puts it at least 58 degrees above that ground (cos i >= 0.85) and
best lights the spacecraft's three visible faces, without putting the
spacecraft in Earth's shadow. With the Sun that high over the ground, one face
is lit at a slant.
Model centers and radii were measured from the decoded files at the listed
scales (the bounding box of every vertex); remeasure if a model changes.
"""
import math
import pathlib
import textwrap
RE = 6378137.0
# The ground at the image center, geocentric latitude and longitude in degrees.
GROUND = (25.0, 33.0)
# The least cos(incidence) the Sun may have on that ground.
GROUND_SUN = 0.85
AU = 1.495978707e11
LEO = 7.0e6
GEO = 42_164_000.0
# name, file, scale, bounding-box center (model frame, scaled, m), radius (m),
# orbit radius (m), description
MODELS = [
("landsat8", "landsat8.glb", 1.0, (0.0001, -3.6797, 3.6108), 8.3027, RE + 705e3,
"Landsat 8 (85k triangles) in its 705 km orbit"),
("icesat2", "icesat2.glb", 1.0, (1.8979, -0.0013, 4.1380), 5.5159, RE + 496e3,
"ICESat-2 (752k triangles) in its 496 km orbit; the heavy model for benchmarks"),
("ostm", "ostm.glb", 3.5, (0.0, -0.1750, 0.0), 4.3988, RE + 1336e3,
"OSTM / Jason-2 (65k triangles) in its 1336 km orbit"),
("ssl1300", "ssl1300.glb", 0.1, (0.0, 0.5135, 0.0), 14.3366, GEO,
"an SSL-1300 communications satellite (182k triangles) in geostationary orbit"),
("ace", "ace.glb", 0.00485, (0.0, 0.0, 0.0), 4.0114, LEO,
"the Advanced Composition Explorer (26k triangles). The real ACE sits at the "
"Sun-Earth L1 point; here it is put in low orbit"),
]
def norm(v):
n = math.sqrt(sum(x * x for x in v))
return [x / n for x in v]
def cross(a, b):
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
def dot(a, b):
return sum(x * y for x, y in zip(a, b))
def mul(m, v):
return [dot(row, v) for row in m]
def transpose(m):
return [[m[j][i] for j in range(3)] for i in range(3)]
def matmul(a, b):
return [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
def triad(b1, b2, i1, i2):
"""The rotation taking body b1 onto inertial i1, with b2 as close to i2 as it can."""
def frame(v1, v2):
t1 = norm(v1)
t2 = norm(cross(v1, v2))
t3 = cross(t1, t2)
return [[t1[i], t2[i], t3[i]] for i in range(3)]
return matmul(frame(i1, i2), transpose(frame(b1, b2)))
def quaternion(r):
"""[w, x, y, z] of rotation matrix r, with w >= 0."""
tr = r[0][0] + r[1][1] + r[2][2]
if tr > 0:
s = math.sqrt(tr + 1) * 2
q = [0.25 * s, (r[2][1] - r[1][2]) / s, (r[0][2] - r[2][0]) / s, (r[1][0] - r[0][1]) / s]
else:
i = max(range(3), key=lambda k: r[k][k])
j, k = (i + 1) % 3, (i + 2) % 3
s = math.sqrt(1 + r[i][i] - r[j][j] - r[k][k]) * 2
v = [0.0, 0.0, 0.0]
v[i] = 0.25 * s
v[j] = (r[j][i] + r[i][j]) / s
v[k] = (r[k][i] + r[i][k]) / s
q = [(r[k][j] - r[j][k]) / s] + v
return [-x for x in q] if q[0] < 0 else q
def scene(name, file, scale, center, radius, orbit, description):
limb = math.asin(RE / orbit)
theta = limb - math.radians(2.0)
zenith = [1.0, 0.0, 0.0]
look = [orbit, 0.0, 0.0]
boresight = [-math.cos(theta), math.sin(theta), 0.0]
attitude = triad([1, 1, 1], [0, 0, 1], [-x for x in boresight], zenith)
distance = radius / math.tan(math.radians(2.8))
camera = [look[i] - distance * boresight[i] for i in range(3)]
# Place the model so its bounding-box center is at the look point.
offset = mul(attitude, center)
position = [look[i] - offset[i] for i in range(3)]
# The ground behind the spacecraft at the image center: where the boresight,
# continued past the spacecraft, meets Earth.
b = dot(look, boresight)
reach = -b - math.sqrt(b * b - (dot(look, look) - RE * RE))
ground = norm([look[i] + reach * boresight[i] for i in range(3)])
# Turn Earth so GROUND is there, with north along the camera's view across it.
lat, lon = (math.radians(x) for x in GROUND)
fixed = [math.cos(lat) * math.cos(lon), math.cos(lat) * math.sin(lon), math.sin(lat)]
north = norm([boresight[i] - dot(boresight, ground) * ground[i] for i in range(3)])
earth = triad(fixed, [0, 0, 1], ground, north)
best = None
for az in range(0, 360, 2):
for el in range(-88, 90, 2):
a, e = math.radians(az), math.radians(el)
sun = [math.cos(e) * math.cos(a), math.cos(e) * math.sin(a), math.sin(e)]
if dot(sun, [-1.0, 0.0, 0.0]) > math.cos(limb):
continue # behind Earth: the spacecraft would be in shadow
if dot(ground, sun) < GROUND_SUN:
continue
faces = [dot(mul(attitude, n), sun) for n in ([1, 0, 0], [0, 1, 0], [0, 0, 1])]
if best is None or min(faces) > best[0]:
best = (min(faces), sun)
sun = best[1]
fmt = lambda v, d=2: "[" + ", ".join(f"{x:.{d}f}" for x in v) + "]"
q = quaternion(attitude)
header = textwrap.fill(
f"{description[0].upper() + description[1:]}, in an isometric view with Earth's limb "
"behind it. Generated by scripts/make-example-scenes.py; the model comes from "
"scripts/fetch-models.sh.",
width=80, initial_indent="# ", subsequent_indent="# ", break_on_hyphens=False)
return f"""{header}
#
# resolved render --scene examples/scenes/{name}.toml --preview {name}.png
# resolved render --scene examples/scenes/{name}.toml --lighting L1 --preview {name}.png
sun = {fmt([AU * x for x in sun], 0)}
moon = [0, 0, -3.844e8]
# Earth-fixed to inertial: 25 N 33 E behind the spacecraft, looking north.
earth_attitude = {fmt(quaternion(earth), 9)}
[observer]
position = {fmt(camera)}
look_at = {fmt(look)}
up = [1, 0, 0]
# At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh).
[appearance]
earth_month = 7
[[target]]
model = "../models/nasa/{file}"
scale = {scale}
position = {fmt(position, 4)}
attitude = {fmt(q, 9)}
"""
out = pathlib.Path(__file__).resolve().parent.parent / "examples" / "scenes"
for model in MODELS:
path = out / f"{model[0]}.toml"
path.write_text(scene(*model))
print(f"wrote {path}")