tools: add Cosmos 2612 / ICEYE-X36 RPO replay renderer (v1)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0187rSCa4AKJPGw6uiuPSGyX
This commit is contained in:
Claude committed 2026-10-25 05:17:43 +00:00
1 parent 2dca762e4a
commit 36f99cabac
11 files changed
+2157

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
# RPO replay: Cosmos 2612 → ICEYE-X36 (Sep 30 – Oct 21, 2026)
A 45-second, physics-grounded reconstruction of the Russian military satellite
Cosmos 2612 (NORAD 68762) maneuvering to within a few hundred meters of the
U.S.-registered ICEYE-X36 SAR satellite (NORAD 59103), rendered in CesiumJS
over Esri World Imagery and exported as 16:9 and 9:16 video.
```sh
python3 tools/rpo-replay/model.py # orbit reconstruction -> data.json
python3 tools/rpo-replay/build_models.py # procedural GLBs
node tools/rpo-replay/render.mjs --format 16x9 --stills 2,24,32 # spot checks
node tools/rpo-replay/render.mjs --format 16x9 # all frames (resumable)
node tools/rpo-replay/render.mjs --format 16x9 --encode # -> MP4
```
Formats: `16x9` (1920×1080), `9x16` (1080×1920), and `preview` / `preview-v`
at half size. Output goes to `output/rpo-replay/<format>/`. Requires
`pip install sgp4 numpy scipy pillow`, Playwright's Chromium (SwiftShader, no
GPU), and ffmpeg.
## What is measured and what is modeled
**ICEYE-X36** is propagated with SGP4 from its public CelesTrak element set
(`tles.txt`). X36 did not maneuver in this window.
**Cosmos 2612** has no public element-set history without an account, so its
path is a relative-orbit reconstruction about X36:
- Quasi-nonsingular relative orbital elements (δa, δλ, δe, δi), advanced
with Keplerian along-track drift and the differential J2 node drift. The
47 km "high park" is what swings Cosmos 2612's orbit plane into X36's.
The model needs a 0.27° initial plane offset; published analysis gives
~0.22°.
- Burns are impulses through Gauss's variational equations. SMA changes are
two half-orbit-apart tangential impulses. Each burn is timed inside the
element-set window that KeepTrack, LeoLabs, and Marco Langbroek published.
- Solved checkpoints:
- Oct 12 overhead pass, then 480 km behind.
- ~400 km on Oct 15.
- 3 km behind after the Oct 16 braking burn.
- 2.0 / 0.99 / 0.72 / 0.60 km on Oct 17.
- 5.1 km behind and 140 m higher at 07:38 Oct 18.
- 293 km on Oct 19.
- Matched SMA, 477–479 km behind, from Oct 21. This matches today's Cosmos
2612 element set.
- The model gives ~60 m/s total Δv (KeepTrack estimates ~67 m/s). The
closest approach comes out at **~325 m, 01:28 UTC Oct 18**, in Earth's
shadow. Published estimates range 150–650 m. Langbroek gives ~01:35 UTC.
The 26 m and 25 m figures some outlets quoted come from element sets taken
after the departure burn, and KeepTrack rejects them. This replay does not
use them.
## Rendering conventions
- Camera and Earth ride with X36 on a slower "backdrop" clock during
time-lapse to avoid strobing. Relative motion runs at the on-screen rate.
Around closest approach the two clocks are identical, so lighting, shadow,
and the following orbital sunrise are true to time.
- Up close, the spacecraft are drawn enlarged (the factor is on screen, capped
at 150×). At closest approach they are at true scale.
- In Earth's shadow the models get a dim blue fill so their shapes stay
readable.
- ICEYE-X36 is modeled to ICEYE's published Gen-3 dimensions: 3.2 × 0.4 m
antenna, ~3.25 m span. No public imagery of Cosmos 2612's design exists, so
its model is illustrative.
## Sources
- CelesTrak GP element sets for 59103 and 68762.
- KeepTrack, "Cosmos 2612 / ICEYE-X36 rendezvous" deep dive: burn windows,
separations, SMA offsets.
- LeoLabs event report via Via Satellite, Oct 19, 2026.
- Marco Langbroek (SatTrackCam Leiden), quoted by Space.com, Aerospace Global
News, and TWZ.
- Vantor image of the pair, Oct 17 (~1.02 km), published by Axios Oct 22.
- Imagery: Esri World Imagery; NASA GIBS VIIRS Black Marble (night side).
- Sky: Yale Bright Star Catalogue and ESO/S. Brunier Milky Way (see
`src/data/local_data/sky/README.md`).
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""Procedural GLB models for the RPO replay.
ICEYE-X36: ICEYE Gen-3 class SAR microsatellite. Dimensions follow ICEYE's
fleet documentation (3.2 m x 0.4 m phased-array antenna, ~3.25 m span,
two deployable solar arrays, ~0.7 x 0.5 m bus).
Cosmos 2612: no public imagery of the design exists. The model is an
illustrative small military bus (box body, two three-panel wings, a forward
optical sensor and a rear propulsion module) sized like its launch cohort.
Axes (glTF, Y-up): +X along-track (velocity), +Y zenith, +Z = -orbit normal.
"""
import io
import json
import math
import struct
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
HERE = __file__.rsplit('/', 1)[0]
RNG = np.random.default_rng(7)
# ---------------------------------------------------------------------------
# Textures
# ---------------------------------------------------------------------------
def png_bytes(img):
b = io.BytesIO()
img.save(b, 'PNG', optimize=True)
return b.getvalue()
def solar_texture(cols=8, rows=6, size=(512, 384)):
w, h = size
img = Image.new('RGB', size, (18, 22, 40))
d = ImageDraw.Draw(img)
cw, ch = w / cols, h / rows
for i in range(cols):
for j in range(rows):
x0, y0 = i * cw + 3, j * ch + 3
x1, y1 = (i + 1) * cw - 3, (j + 1) * ch - 3
shade = int(RNG.integers(-6, 6))
d.rectangle([x0, y0, x1, y1], fill=(28 + shade, 40 + shade, 92 + shade))
# clipped corners like triple-junction cells
c = 6
for cx, cy in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)]:
d.polygon([(cx, cy), (cx + (c if cx == x0 else -c), cy), (cx, cy + (c if cy == y0 else -c))],
fill=(18, 22, 40))
for k in range(1, 6):
yy = y0 + (y1 - y0) * k / 6
d.line([x0 + 2, yy, x1 - 2, yy], fill=(70, 82, 130), width=1)
d.line([x0, (y0 + y1) / 2, x1, (y0 + y1) / 2], fill=(150, 155, 170), width=2)
return img
def mli_texture(base, size=256):
n = RNG.normal(0, 1, (size // 8, size // 8))
n = np.kron(n, np.ones((8, 8)))
img = Image.fromarray(np.uint8(np.clip(128 + n * 30, 0, 255))).filter(ImageFilter.GaussianBlur(3))
a = np.asarray(img, dtype=float) / 255
rgb = np.stack([np.clip(base[c] * (0.78 + 0.45 * a), 0, 255) for c in range(3)], -1)
# seams / tape
out = Image.fromarray(np.uint8(rgb))
d = ImageDraw.Draw(out)
for x in (0, size // 2):
d.line([x, 0, x, size], fill=tuple(int(c * 0.65) for c in base), width=2)
return out
def mli_normal(size=256):
n = RNG.normal(0, 1, (size // 6 + 1, size // 6 + 1))
n = np.kron(n, np.ones((6, 6)))[:size, :size]
h = np.asarray(Image.fromarray(np.uint8(np.clip(128 + n * 40, 0, 255))).filter(ImageFilter.GaussianBlur(2)),
dtype=float) / 255
gx = np.roll(h, -1, 1) - np.roll(h, 1, 1)
gy = np.roll(h, -1, 0) - np.roll(h, 1, 0)
s = 3.0
nx, ny, nz = -gx * s, -gy * s, np.ones_like(h)
l = np.sqrt(nx ** 2 + ny ** 2 + nz ** 2)
rgb = np.stack([(nx / l + 1) / 2, (ny / l + 1) / 2, (nz / l + 1) / 2], -1)
return Image.fromarray(np.uint8(rgb * 255))
def sar_texture(size=(1024, 128)):
w, h = size
img = Image.new('RGB', size, (34, 36, 40))
d = ImageDraw.Draw(img)
nx, ny = 64, 8
for i in range(nx):
for j in range(ny):
cx, cy = (i + 0.5) * w / nx, (j + 0.5) * h / ny
r = 4.5
d.rectangle([cx - r, cy - r, cx + r, cy + r], fill=(150, 112, 70))
d.rectangle([cx - r + 2, cy - r + 2, cx + r - 2, cy + r - 2], fill=(118, 88, 56))
for k in range(1, 5):
x = w * k / 5
d.line([x, 0, x, h], fill=(10, 10, 12), width=3)
return img
def panel_texture(color, size=128, lines=4):
img = Image.new('RGB', (size, size), color)
d = ImageDraw.Draw(img)
for k in range(1, lines):
p = size * k / lines
d.line([p, 0, p, size], fill=tuple(int(c * 0.8) for c in color), width=1)
return img
# ---------------------------------------------------------------------------
# Minimal glTF builder
# ---------------------------------------------------------------------------
class Gltf:
def __init__(self):
self.bin = bytearray()
self.j = dict(asset=dict(version='2.0', generator='gods-eye-view rpo-replay'),
scenes=[dict(nodes=[0])], scene=0, nodes=[dict(name='root', children=[])],
meshes=[], materials=[], accessors=[], bufferViews=[], buffers=[],
images=[], textures=[], samplers=[dict(magFilter=9729, minFilter=9987, wrapS=10497, wrapT=10497)])
self.prims = []
def _view(self, data, target=None):
while len(self.bin) % 4:
self.bin.append(0)
off = len(self.bin)
self.bin += data
v = dict(buffer=0, byteOffset=off, byteLength=len(data))
if target:
v['target'] = target
self.j['bufferViews'].append(v)
return len(self.j['bufferViews']) - 1
def _acc(self, arr, typ, target):
arr = np.ascontiguousarray(arr)
comp = 5126 if arr.dtype == np.float32 else 5125
view = self._view(arr.tobytes(), target)
a = dict(bufferView=view, componentType=comp, count=int(arr.shape[0]), type=typ)
if typ == 'VEC3' and comp == 5126:
a['min'] = arr.min(0).tolist()
a['max'] = arr.max(0).tolist()
self.j['accessors'].append(a)
return len(self.j['accessors']) - 1
def texture(self, img):
view = self._view(png_bytes(img))
self.j['images'].append(dict(bufferView=view, mimeType='image/png'))
self.j['textures'].append(dict(sampler=0, source=len(self.j['images']) - 1))
return len(self.j['textures']) - 1
def material(self, name, color=(1, 1, 1), metal=0.0, rough=0.6, tex=None, normal=None, emissive=None,
double=False):
m = dict(name=name, pbrMetallicRoughness=dict(baseColorFactor=[*color, 1.0], metallicFactor=metal,
roughnessFactor=rough))
if tex is not None:
m['pbrMetallicRoughness']['baseColorTexture'] = dict(index=tex)
if normal is not None:
m['normalTexture'] = dict(index=normal, scale=0.6)
if emissive:
m['emissiveFactor'] = list(emissive)
if double:
m['doubleSided'] = True
self.j['materials'].append(m)
return len(self.j['materials']) - 1
def add(self, name, pos, nrm, uv, idx, mat, translation=(0, 0, 0), rotation=None):
p = dict(attributes=dict(POSITION=self._acc(np.float32(pos), 'VEC3', 34962),
NORMAL=self._acc(np.float32(nrm), 'VEC3', 34962),
TEXCOORD_0=self._acc(np.float32(uv), 'VEC2', 34962)),
indices=self._acc(np.uint32(idx).ravel(), 'SCALAR', 34963), material=mat)
self.j['meshes'].append(dict(name=name, primitives=[p]))
node = dict(name=name, mesh=len(self.j['meshes']) - 1, translation=list(map(float, translation)))
if rotation is not None:
node['rotation'] = list(map(float, rotation))
self.j['nodes'].append(node)
self.j['nodes'][0]['children'].append(len(self.j['nodes']) - 1)
def save(self, path):
while len(self.bin) % 4:
self.bin.append(0)
self.j['buffers'] = [dict(byteLength=len(self.bin))]
js = json.dumps(self.j, separators=(',', ':')).encode()
while len(js) % 4:
js += b' '
out = struct.pack('<III', 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(self.bin))
out += struct.pack('<II', len(js), 0x4E4F534A) + js
out += struct.pack('<II', len(self.bin), 0x004E4942) + bytes(self.bin)
open(path, 'wb').write(out)
print(path, f'{len(out)/1024:.0f} KB')
def box(sx, sy, sz, uv_scale=(1, 1)):
"""Axis-aligned box centred at origin with per-face UVs."""
hx, hy, hz = sx / 2, sy / 2, sz / 2
faces = [
((1, 0, 0), [(hx, -hy, hz), (hx, -hy, -hz), (hx, hy, -hz), (hx, hy, hz)]),
((-1, 0, 0), [(-hx, -hy, -hz), (-hx, -hy, hz), (-hx, hy, hz), (-hx, hy, -hz)]),
((0, 1, 0), [(-hx, hy, hz), (hx, hy, hz), (hx, hy, -hz), (-hx, hy, -hz)]),
((0, -1, 0), [(-hx, -hy, -hz), (hx, -hy, -hz), (hx, -hy, hz), (-hx, -hy, hz)]),
((0, 0, 1), [(-hx, -hy, hz), (hx, -hy, hz), (hx, hy, hz), (-hx, hy, hz)]),
((0, 0, -1), [(hx, -hy, -hz), (-hx, -hy, -hz), (-hx, hy, -hz), (hx, hy, -hz)]),
]
P, N, U, I = [], [], [], []
for n, quad in faces:
b = len(P)
P += quad
N += [n] * 4
U += [(0, uv_scale[1]), (uv_scale[0], uv_scale[1]), (uv_scale[0], 0), (0, 0)]
I += [(b, b + 1, b + 2), (b, b + 2, b + 3)]
return P, N, U, I
def cylinder(r0, r1, length, seg=32, axis='x', caps=True):
P, N, U, I = [], [], [], []
for k in range(seg + 1):
a = 2 * math.pi * k / seg
c, s = math.cos(a), math.sin(a)
slope = (r0 - r1) / length
nl = math.sqrt(1 + slope ** 2)
P += [(0, r0 * c, r0 * s), (length, r1 * c, r1 * s)]
N += [(slope / nl, c / nl, s / nl)] * 2
U += [(k / seg, 0), (k / seg, 1)]
for k in range(seg):
a, b = 2 * k, 2 * k + 2
I += [(a, b, a + 1), (a + 1, b, b + 1)]
if caps:
for x, r, nx in ((0, r0, -1), (length, r1, 1)):
if r <= 0:
continue
c0 = len(P)
P.append((x, 0, 0)); N.append((nx, 0, 0)); U.append((0.5, 0.5))
for k in range(seg + 1):
a = 2 * math.pi * k / seg
P.append((x, r * math.cos(a), r * math.sin(a))); N.append((nx, 0, 0))
U.append((0.5 + 0.5 * math.cos(a), 0.5 + 0.5 * math.sin(a)))
for k in range(seg):
tri = (c0, c0 + 1 + k, c0 + 2 + k)
I.append(tri if nx > 0 else tri[::-1])
P = np.array(P, float)
N = np.array(N, float)
if axis == 'y':
P, N = P[:, [1, 0, 2]] * [1, 1, 1], N[:, [1, 0, 2]]
I = [t[::-1] for t in I]
elif axis == 'z':
P, N = P[:, [1, 2, 0]], N[:, [1, 2, 0]]
return P.tolist(), N.tolist(), U, I
def dish(r, depth, seg=32, rings=6):
"""Paraboloid dish opening toward +X."""
P, N, U, I = [], [], [], []
for i in range(rings + 1):
rr = r * i / rings
x = depth * (rr / r) ** 2
for k in range(seg + 1):
a = 2 * math.pi * k / seg
P.append((x, rr * math.cos(a), rr * math.sin(a)))
n = np.array([-1.0, 2 * depth * rr / r ** 2 * math.cos(a), 2 * depth * rr / r ** 2 * math.sin(a)])
n = -n / np.linalg.norm(n)
N.append(tuple(n))
U.append((k / seg, i / rings))
for i in range(rings):
for k in range(seg):
a = i * (seg + 1) + k
b = a + seg + 1
I += [(a, a + 1, b), (a + 1, b + 1, b)]
return P, N, U, I
def quat(axis, deg):
axis = np.array(axis, float) / np.linalg.norm(axis)
h = math.radians(deg) / 2
return [*(axis * math.sin(h)), math.cos(h)]
# ---------------------------------------------------------------------------
# ICEYE-X36
# ---------------------------------------------------------------------------
def build_iceye():
g = Gltf()
t_sar = g.texture(sar_texture())
t_sol = g.texture(solar_texture(cols=6, rows=4, size=(384, 256)))
t_gold = g.texture(mli_texture((214, 168, 72)))
t_n = g.texture(mli_normal())
t_rad = g.texture(panel_texture((226, 228, 232)))
m_sar = g.material('sar-face', tex=t_sar, metal=0.55, rough=0.42)
m_back = g.material('antenna-back', color=(0.82, 0.83, 0.86), metal=0.2, rough=0.55, tex=t_rad)
m_gold = g.material('mli-gold', tex=t_gold, normal=t_n, metal=0.85, rough=0.32)
m_sol = g.material('solar', tex=t_sol, metal=0.4, rough=0.25)
m_frame = g.material('frame', color=(0.55, 0.56, 0.6), metal=0.9, rough=0.35)
m_black = g.material('black', color=(0.05, 0.05, 0.06), metal=0.1, rough=0.6)
m_white = g.material('white', color=(0.9, 0.9, 0.92), metal=0.0, rough=0.5)
# SAR antenna: five hinged segments, 3.25 m along-track x 0.40 m, face tilted
# 25 deg off nadir toward -Z (right-looking).
seg_len, gap = 0.638, 0.015
tilt = quat((1, 0, 0), -25)
for k in range(5):
x = (k - 2) * (seg_len + gap)
P, N, U, I = box(seg_len, 0.035, 0.40)
# face (-Y) UVs map into 1/5 of the antenna texture
U = [(u * 0.2 + 0.2 * k, v) for (u, v) in U]
P = np.array(P) + [x, 0, 0]
g.add(f'sar-seg-{k}', P, N, U, I, m_sar if True else m_back, rotation=tilt)
Pb, Nb, Ub, Ib = box(seg_len - 0.04, 0.012, 0.36)
g.add(f'sar-back-{k}', np.array(Pb) + [x, 0.022, 0], Nb, Ub, Ib, m_back, rotation=tilt)
# Bus behind the antenna
P, N, U, I = box(0.62, 0.42, 0.46)
g.add('bus', P, N, U, I, m_gold, translation=(0, 0.25, 0.06))
P, N, U, I = box(0.6, 0.02, 0.44)
g.add('bus-top-radiator', P, N, U, I, m_white, translation=(0, 0.47, 0.06))
# Solar wings off the bus, canted toward the sun-side
for side in (1, -1):
P, N, U, I = box(0.58, 0.02, 0.78)
g.add(f'solar-{side}', P, N, U, I, m_sol, translation=(0, 0.42, 0.06 + side * 0.66),
rotation=quat((1, 0, 0), side * 14))
P, N, U, I = cylinder(0.012, 0.012, 0.16, seg=8, axis='z')
g.add(f'hinge-{side}', P, N, U, I, m_frame, translation=(0, 0.42, 0.06 + side * 0.20 - (0.16 if side < 0 else 0)))
# Star trackers, X-band downlink horn, GNSS patch
for k, z in enumerate((-0.12, 0.12)):
P, N, U, I = cylinder(0.035, 0.05, 0.08, seg=16, axis='y')
g.add(f'star-tracker-{k}', P, N, U, I, m_black, translation=(-0.2, 0.48, 0.06 + z))
P, N, U, I = cylinder(0.05, 0.02, 0.09, seg=16, axis='y')
g.add('x-band', P, N, U, I, m_frame, translation=(0.2, 0.04, 0.2), rotation=quat((1, 0, 0), 180))
P, N, U, I = box(0.08, 0.012, 0.08)
g.add('gnss', P, N, U, I, m_white, translation=(0.18, 0.485, 0.06))
g.save(f'{HERE}/iceye-x36.glb')
# ---------------------------------------------------------------------------
# Cosmos 2612 (illustrative)
# ---------------------------------------------------------------------------
def build_cosmos():
g = Gltf()
t_sol = g.texture(solar_texture(cols=8, rows=6))
t_mli = g.texture(mli_texture((178, 182, 186)))
t_dark = g.texture(mli_texture((64, 62, 58)))
t_n = g.texture(mli_normal())
t_rad = g.texture(panel_texture((210, 212, 205)))
m_mli = g.material('mli-silver', tex=t_mli, normal=t_n, metal=0.9, rough=0.28)
m_dark = g.material('mli-dark', tex=t_dark, normal=t_n, metal=0.6, rough=0.4)
m_sol = g.material('solar', tex=t_sol, metal=0.4, rough=0.25)
m_rad = g.material('radiator', tex=t_rad, metal=0.0, rough=0.55)
m_frame = g.material('frame', color=(0.6, 0.6, 0.62), metal=0.9, rough=0.3)
m_black = g.material('black', color=(0.03, 0.03, 0.035), metal=0.2, rough=0.4)
m_lens = g.material('lens', color=(0.05, 0.08, 0.12), metal=1.0, rough=0.05, emissive=(0.02, 0.05, 0.08))
m_nozzle = g.material('nozzle', color=(0.35, 0.3, 0.28), metal=0.95, rough=0.4)
P, N, U, I = box(0.95, 0.72, 0.72)
g.add('bus', P, N, U, I, m_mli)
P, N, U, I = box(0.5, 0.74, 0.74)
g.add('bus-band', P, N, U, I, m_dark, translation=(-0.12, 0, 0))
P, N, U, I = box(0.8, 0.02, 0.6)
g.add('radiator-top', P, N, U, I, m_rad, translation=(0.02, 0.375, 0))
# Three-panel wings along +/-Z
for side in (1, -1):
P, N, U, I = cylinder(0.018, 0.018, 0.32, seg=10, axis='z')
g.add(f'yoke-{side}', P, N, U, I, m_frame, translation=(0, 0, side * 0.36 - (0.32 if side < 0 else 0)))
for k in range(3):
P, N, U, I = box(0.62, 0.02, 0.5)
z = side * (0.36 + 0.32 + 0.26 + k * 0.52)
g.add(f'wing-{side}-{k}', P, N, U, I, m_sol, translation=(0, 0, z), rotation=quat((0, 0, 1), 10))
# Forward optical sensor (faces +X: toward a target ahead along-track)
P, N, U, I = cylinder(0.13, 0.13, 0.24, seg=32, axis='x')
g.add('sensor-barrel', P, N, U, I, m_black, translation=(0.475, 0.12, 0.0))
P, N, U, I = cylinder(0.145, 0.16, 0.06, seg=32, axis='x', caps=False)
g.add('sensor-hood', P, N, U, I, m_frame, translation=(0.70, 0.12, 0.0))
P, N, U, I = cylinder(0.1, 0.1, 0.01, seg=32, axis='x')
g.add('sensor-lens', P, N, U, I, m_lens, translation=(0.715, 0.12, 0.0))
# Proximity radar / laser rangefinder block
P, N, U, I = box(0.12, 0.12, 0.16)
g.add('ranging', P, N, U, I, m_dark, translation=(0.53, -0.18, 0.18))
# Rear propulsion
P, N, U, I = cylinder(0.16, 0.16, 0.12, seg=24, axis='x')
g.add('prop-module', P, N, U, I, m_dark, translation=(-0.595, 0, 0))
P, N, U, I = cylinder(0.035, 0.09, 0.16, seg=24, axis='x', caps=False)
g.add('nozzle', P, N, U, I, m_nozzle, translation=(-0.76, 0, 0), rotation=quat((0, 0, 1), 180))
for k, (y, z) in enumerate(((0.3, 0.3), (0.3, -0.3), (-0.3, 0.3), (-0.3, -0.3))):
P, N, U, I = cylinder(0.012, 0.025, 0.05, seg=10, axis='x', caps=False)
g.add(f'rcs-{k}', P, N, U, I, m_nozzle, translation=(-0.48, y, z), rotation=quat((0, 0, 1), 180))
# S-band dish on a short mast (zenith)
P, N, U, I = cylinder(0.012, 0.012, 0.22, seg=8, axis='y')
g.add('mast', P, N, U, I, m_frame, translation=(-0.25, 0.37, 0.18))
P, N, U, I = dish(0.14, 0.05)
g.add('dish', P, N, U, I, m_rad, translation=(-0.25, 0.6, 0.18), rotation=quat((0, 0, 1), 90))
g.save(f'{HERE}/cosmos-2612.glb')
if __name__ == '__main__':
build_iceye()
build_cosmos()
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"t0":"2026-09-29T12:00:00Z","dt":60,"a_km":6920.86656753336,"x36":[-3625.5189,-5318.3928,2553.1804,-3766.6429,-5406.7449,2129.5637,-3891.4855,-5471.7261,1696.7165,-3999.5064,-5513.0548,1256.5148,-4090.2389,-5530.5527,810.8666,-4163.2919,-5524.1455,361.704,-4218.3513,-5493.863,-89.0263,-4255.1815,-5439.8394,-539.3709,-4273.6267,-5362.3124,-987.3787,-4273.6112,-5261.6219,-1431.1097,-4255.1399,-5138.2088,-1868.6435,-4218.2979,-4992.6125,-2298.0876,-4163.2501,-4825.4685,-2717.5863,-4090.2405,-4637.5056,-3125.3281,-3999.5906,-4429.5422,-3519.5541,-3891.6983,-4202.4827,-3898.5655,-3767.0355,-3957.3135,-4260.7306,-3626.1461,-3695.098,-4604.4922,-3469.6435,-3416.9724,-4928.3736,-3298.2077,-3124.1403,-5230.9851,-3112.5823,-2817.8673,-5511.0297,-2913.5708,-2499.4756,-5767.3082,-2702.0336,-2170.3385,-5998.7243,-2478.884,-1831.874,-6204.2888,-2245.084,-1485.5388,-6383.1236,-2001.6406,-1132.8228,-6534.465,-1749.6012,-775.2418,-6657.667,-1490.0494,-414.3321,-6752.2034,-1224.1004,-51.6435,-6817.6704,-952.8961,311.2668,-6853.7876,-677.601,672.8408,-6860.3993,-399.3967,1031.526,-6837.4753,-119.4774,1385.782,-6785.1109,160.9549,1734.0865,-6703.5265,440.6958,2074.9421,-6593.067,718.5431,2406.882,-6454.2004,993.3019,2728.4768,-6287.5159,1263.7898,3038.3397,-6093.7221,1528.8414,3335.1331,-5873.6438,1787.3137,3617.5742,-5628.2193,2038.0909,3884.4401,-5358.4963,2280.089,4134.5737,-5065.628,2512.2606,4366.8886,-4750.8684,2733.5996,4580.374,-4415.5673,2943.1456,4774.0993,-4061.1646,3139.9881,4947.2182,-3689.1846,3323.2708,5098.9729,-3301.2294,3492.1953,5228.6976,-2898.9722,3646.0251,5335.8216,-2484.1499,3784.0885,5419.8722,-2058.5561,3905.7822,5480.4768,-1624.0326,4010.5739,5517.3649,-1182.4621,4098.0049,5530.3699,-735.7589,4167.6921,5519.4291,-285.8613,4219.3301,5484.5851,165.2776,4252.6923,5425.985,615.6986,4267.6325,5343.8803,1063.4451,4264.0848,5238.6252,1506.572,4242.0646,5110.6759,1943.1542,4201.6682,4960.5876,2371.2956,4143.0722,4789.0125,2789.1375,4066.533,4596.6966,3194.8666,3972.3851,4384.4763,3586.7233,3861.0399,4153.2748,3963.0095,3732.9835,3904.0971,4322.0959,3588.7745,3638.0265,4662.4289,3429.0413,3356.2186,4982.5378,3254.4793,3059.8969,5281.0405,3065.8479,2750.347,5556.6496,2863.9667,2428.9108,5808.1779,2649.7119,2096.9807,6034.543,2424.0126,1755.9934,6234.7722,2187.8466,1407.424,6408.0057,1942.2361,1052.779,6553.5008,1688.2435,693.5905,6670.6345,1426.9663,331.4093,6758.906,1159.5332,-32.2016,6817.9385,887.0984,-395.6736,6847.4811,610.8377,-757.4391,6847.4092,331.9425,-1115.938,6817.7252,51.6159,-1469.6244,6758.5586,-228.9334,-1816.9732,6670.1653,-508.4954,-2156.4859,6552.9263,-785.8643,-2486.6979,6407.3469,-1059.8435,-2806.1839,6234.0537,-1329.2506,-3113.5641,6033.7929,-1592.9226,-3407.5106,5807.4269,-1849.7206,-3686.7526,5555.9306,-2098.535,-3950.0821,5280.3881,-2338.2901,-4196.3592,4981.9873,-2567.9488,-4424.5171,4662.0157,-2786.5173,-4633.5668,4321.8548,-2993.0494,-4822.6018,3962.9741,-3186.6507,-4990.8017,3586.925,-3366.4824,-5137.4364,3195.3341,-3531.7656,-5261.8692,2789.8963,-3681.7841,-5363.5598,2372.3671,-3815.8884,-5442.0668,1944.5552,-3933.498,-5497.0496,1508.3147,-4034.1043,-5528.2705,1065.5362,-4117.273,-5535.5952,618.1391,-4182.6458,-5518.9936,168.063,-4229.9422,-5478.5403,-282.7416,-4258.9606,-5414.4137,-732.321,-4269.5793,-5326.8954,-1178.7281,-4261.7566,-5216.369,-1620.03,-4235.5314,-5083.318,-2054.3173,-4191.0225,-4928.3236,-2479.7125,-4128.4281,-4752.0621,-2894.3776,-4048.0249,-4555.3015,-3296.5229,-3950.1663,-4338.8981,-3684.4144,-3835.2812,-4103.7925,-4056.3812,-3703.8716,-3851.0055,-4410.8229,-3556.5102,-3581.633,-4746.216,-3393.8381,-3296.8416,-5061.1204,-3216.5615,-2997.8631,-5354.1857,-3025.4486,-2685.989,-5624.1563,-2821.3264,-2362.5652,-5869.8766,-2605.0768,-2028.9858,-6090.2958,-2377.6327,-1686.6876,-6284.472,-2139.9745,-1337.1435,-6451.5758,-1893.1251,-981.8563,-6590.8939,-1638.1463,-622.3528,-6701.8315,-1376.134,-260.1771,-6783.9148,-1108.2137,103.1156,-6836.7927,-835.5356,465.9655,-6860.2385,-559.2705,826.815,-6854.1503,-280.6041,1184.1145,-6818.552,-0.7327,1536.3291,-6753.5926,279.1418,1881.9451,-6659.5462,557.817,2219.4761,-6536.8107,834.095,2547.4691,-6385.9067,1106.7874,2864.5109,-6207.4749,1374.7199,3169.2339,-6002.2742,1636.738,3460.3218,-5771.1787,1891.7111,3736.5156,-5515.1741,2138.5378,3996.6187,-5235.3541,2376.1508,4239.5027,-4932.9158,2603.521,4464.1117,-4609.155,2819.6626,4669.4677,-4265.461,3023.6373,4854.6748,-3903.3108,3214.5582,5018.9232,-3524.2629,3391.5945,5161.4933,-3129.9508,3553.9745,5281.7589,-2722.076,3700.9899,5379.1903,-2302.4007,3831.9985,5453.3567,-1872.7403,3946.4277,5503.9286,-1434.9551,4043.7766,5530.6795,-990.9423,4123.6191,5533.4868,-542.6278,4185.6053,5512.3327,-91.9572,4229.4634,5467.3044,359.1127,4255.0011,5398.5938,808.6228,4262.1061,5306.4965,1254.6202,4250.7472,5191.4108,1695.1675,4220.9739,5053.8358,2128.3511,4172.9164,4894.3688,2552.2897,4106.785,4713.7032,2965.1433,4022.8689,4512.6245,3365.1207,3921.5348,4292.0072,3750.4879,3803.2253,4052.811,4Line truncated
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="180 120 775 520" role="img" aria-labelledby="logo-title">
<title id="logo-title">God's Eye View</title>
<defs>
<style>
.cls-4 {
fill: #fff;
}
.cls-2, .cls-4 {
stroke: #000;
stroke-width: 1px;
}
.cls-1 {
fill: #4454727a;
stroke: #48b;
stroke-width: 10px;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
fill-rule: evenodd;
}
.cls-2 {
fill: #00f6ff;
}
.cls-3 {
stroke: #00f6ff;
stroke-linecap: round;
stroke-width: 6.99px;
}
.cls-4 {
opacity: 0.63;
}
</style>
</defs>
<path id="outer_eye" class="cls-1" d="M567.5,623c-163.183,0-303.026-99.084-361.823-239.922C257.508,238.76,401.037,135,570,135c166.451,0,308.222,100.7,361.954,241.667C874.771,520.905,733.152,623,567.5,623ZM567,156c124.816,0,226,101.184,226,226S691.816,608,567,608,341,506.816,341,382,442.184,156,567,156ZM816,256.213A272.836,272.836,0,0,1,846,381M793.574,488.677a249.545,249.545,0,0,0,3.239-208.3A303.384,303.384,0,0,0,816,256.213a274.468,274.468,0,0,1-1.516,252.475q-0.927,1.758-1.88,3.5-1.632-2.238-3.306-4.446Q801.826,497.884,793.574,488.677ZM345.5,490.85q-8.055,9.164-15.37,18.966a274.353,274.353,0,0,1-3.748-250.343q7.347,10.012,15.469,19.393A249.512,249.512,0,0,0,345.5,490.85Z"/>
<path id="inner_eye" class="cls-2" d="M568,179.985c107.678,0,194.968,88.655,194.968,198.015S675.678,576.015,568,576.015,373.032,487.36,373.032,378,460.322,179.985,568,179.985Z"/>
<path id="globe" class="cls-3" d="M569.5,499.831A120.331,120.331,0,1,1,689.831,379.5,120.33,120.33,0,0,1,569.5,499.831Zm65.1-50.585c1.225-.4,3.268-2.858,4.492-3.677s3.268-4.083,3.675-5.717,1.227-4.495,1.634-6.126a36.015,36.015,0,0,0,.818-6.135,14.639,14.639,0,0,0-.407-2.855l-3.266,6.127s-4.9,2.04-5.315,3.674-0.817,4.088-.817,4.088a61.248,61.248,0,0,0-2.45,6.133C632.554,446.388,633.372,449.66,634.6,449.246ZM461.011,377.072c0.72-1.105,1.928-6.276,1.6-7.894-0.411-2.037,1.225-6.94,1.225-9.392s4.083-7.358,4.9-9.4,4.491-7.349,6.535-10.623-0.4-2.038-2.449-.809,1.225-3.274,2.449-4.086,8.172-5.72,8.172-5.72,2.449-2.454-.409-7.762c-2.211-4.1,0-4.5,0-6.128a2.43,2.43,0,0,0-1.28-.223c2.129-2.952,4.408-5.788,6.811-8.512A108.871,108.871,0,0,0,494.612,458.6a0.454,0.454,0,0,1,.683-0.363c2.043,0.819,2.449-.4,4.085-0.4s4.086-2.043,4.086-2.043l-1.641-4.9,1.641-6.126,2.041-5.717s0-4.095-2.041-4.5-5.722-4.081-6.946-4.081a10.018,10.018,0,0,1-4.491-1.636c-1.227-.813-3.677-1.634-4.5-2.857a31.144,31.144,0,0,1-2.858-5.314c-0.816-2.039-.409-2.039-3.681-5.715s-2.452-2.863-4.9-4.093-3.265-2.443-5.308-3.263a13.378,13.378,0,0,1-4.5-2.857,4.884,4.884,0,0,0-2.711-1.519,110.041,110.041,0,0,1-2.595-23.808C460.98,378.618,461,377.84,461.011,377.072Zm35.743-78.7a13.8,13.8,0,0,0-1.051,2.589c-0.408,1.638,2.864.416,2.864,0.416L502.239,303l4.5-1.631a13.741,13.741,0,0,1,4.083-4.5c2.857-2.043,2.454-2.452,3.677-4.084s0-.006,5.314-5.732,8.167-5.718,8.167-5.718,1.915-2.1,3.244-3.895A108.986,108.986,0,0,0,496.754,298.368Zm41.024,56.1c-1.224.82-5.715,3.272-6.944,5.315a22.681,22.681,0,0,0-2.859,6.535,31.042,31.042,0,0,0-.408,6.538c0,2.854-.41,4.5,0,6.949s4.5,8.568,6.128,10.623c1.63,2.038,3.674,4.483,5.719,5.713a47.235,47.235,0,0,1,4.9,3.674,4.03,4.03,0,0,0,4.9,1.223c3.272-1.223,6.538-1.223,9.4-2.853s7.353-3.685,7.353-1.231,1.632,4.084,4.082,3.272,6.539-2.858,6.13,0S577,406.764,577,408.4s-0.4,3.265.818,4.495,4.5,3.265,4.5,4.49,0.409,5.311,1.229,6.946,1.639,4.08.823,5.724-2.434,6.123-2.434,6.123-0.779,3.676-.37,4.9a12.683,12.683,0,0,0,3.332,4.906c2.043,2.042,1.771,4.492,2.994,6.124s2.993,6.539,2.993,6.539l1.236,4.088v3.677s7.966-1.231,9.591-1.64a30.673,30.673,0,0,0,8.618-4.9c1.636-1.632,1.311-2.856,3.349-4.488s5.762-4.5,5.762-4.5l-0.387-6.126,4.914-6.544,3.684-3.677s3.267-4.484,2.865-6.128c-0.413-1.628-1.231-9.8-1.231-9.8a37.972,37.972,0,0,1,2.863-5.313c1.224-1.634,1.227-3.678,3.679-5.719a33.109,33.109,0,0,0,5.306-6.126c1.229-1.634,3.681-4.9,4.5-6.127a21.871,21.871,0,0,0,1.635-4.894l-7.355,1.216c-2.45-4.082-9.392-13.073-10.621-15.112a37.04,37.04,0,0,1-2.861-7.76c-0.4-1.634-2.445-6.131-2.856-7.356s-3.27-4.908-4.088-6.128-3.675-2.043-5.309-3.268-11.843-3.27-11.843-3.27-3.129-1.991-4.088-1.226c-4.083,3.269-13.48,1.226-15.523.415s-3.269-2.041-3.269-3.272-1.835-7.143-1.835-7.143-3.985.705-5.213,0.705a20.333,20.333,0,0,1-4.442-1.276s-4.055.379-5.693,1.2a61.205,61.205,0,0,1-6.115,2.438,36.488,36.488,0,0,1-4.488.814c-3.671-1.636-6.529,2.45-7.352,4.079s-2.445,2.454-2.445,4.084S539,353.659,537.778,354.471Zm18.191-54.739-3.128-4.5-3.727,2.045V304.5l-3.839-1.5-1.715,3.674,1.685,2.452L549.114,305v4.129l8.478-1.636,3.164-2.449Zm71.15-12.676c-1.669.869-4.808,3.094-6,1.894-1.227-1.218-4.086-3.423-4.086-3.423,2.448-1.636-4.9-5.212-4.9-5.212l-8.576-1.022h-6.125c-1.227,0-3.681.825-5.723,0.825s-6.943,3.583-8.577,3.994-5.314,2.5-6.944,2.5-4.088,3.086-4.088,3.086-2.856,5.218-.4,4.4,5.717-.858,6.122.367,1.227,3.242,1.636,4.471,6.131,1.618,6.131,1.618a18.551,18.551,0,0,1,2.038-3.683c1.227-1.634,2.452-3.675,2.452-3.675s2.044,4.082,2.452,5.3-0.818,4.908-.818,4.908-4.491,2.852-5.716,2.852-8.175-6.126-8.175-6.126a25.4,25.4,0,0,0-4.489,2.043c-2.856,2.861-5.717,5.31-5.717,5.31a17.057,17.057,0,0,0-4.081,2.046c-1.629,1.222-5.138,1.487-6.117,2.854-2.039,2.863.84,4.9,0.84,4.9l-2.408,3.271-1.548,1.225-5.553,1.221-2.751.616c0.4,1.227,1.258,5.105,1.258,5.105s-1.87,6.943,0,6.943c0,0,4.276,1.64,5.5,1.64s2.625,0.4,4.265-1.227a59.861,59.861,0,0,0,5.156-6.538,8.047,8.047,0,0,1,4.49-3.267c2.454-.82,3-2.047,4.231-2.452s6.47-1.229,6.47-1.229,3.709,2.043,4.932,2.861c0.573,0.386-1.393,1.658,6.086,2.907V331.6l3.7-.411-4.207-3.264-0.44-.1-4.778-4.8-0.419-3.672s4.861,2.862,6.493,3.672a43.671,43.671,0,0,1,4.81,3.272l1.783,5.719,2.4,4.5s-1.87-5.722,0-5.722c0,0,5.158.405,6.381,0a36.253,36.253,0,0,0,5.237-1.632c1.636-.815-0.648-3.68.986-5.721a10.385,10.385,0,0,1,4.981-3.27c1.23-.405,5.35-2.447,5.35-2.447l4.522,0.82c-2.041,1.627-4.486,4.077-2.85,3.676s5.313,1.63,6.949,2.855,0.413,4.087.413,4.087-4.5-.411-7.355-0.815a29.263,29.263,0,0,0-7.351.4l-3.677,2.043s-2.454.816-2.045,2.861,2.045,4.5,3.679,6.535,2.858,4.5,4.085,3.675,5.309-3.265,5.309-3.265a12.784,12.784,0,0,0-.409,4.9,30.6,30.6,0,0,1,.409,4.9s-4.082,0-2.861.822,2.967,3.905,3.681,4.9c4.083,5.717,12.664,18.383,13.482,20.015s0.44,4.126,1.631,5.31c2.454,2.45,7.352,0,8.578-.817s5.311-2.863,6.536-4.082,0.818-.822,3.272-2.454,4.079-2.856,2.859-4.085-3.68-5.725-3.68-5.725c-3.676.825-7.351-1.222-7.351-2.445s-3.269-6.124-3.269-6.124,0.407-.82,3.269.809,5.306,4.908Line truncated
<path id="globe_cage" class="cls-4" d="M569.5,576.491c-106.968,0-193.988-89.044-193.988-198.491S462.532,179.509,569.5,179.509,763.488,268.553,763.488,378,676.468,576.491,569.5,576.491ZM578.4,368.9h76.237a314.2,314.2,0,0,0-11.945-78.026H578.4V368.9ZM654.634,387.1H578.4V464.94h64.345A314.251,314.251,0,0,0,654.634,387.1Zm90.813,0H671.861a333.4,333.4,0,0,1-11.206,77.836h63.112A182.117,182.117,0,0,0,745.447,387.1Zm-32.528,95.444H655.363a288.421,288.421,0,0,1-32.3,67.191A177.214,177.214,0,0,0,712.919,482.548Zm-115.67,73.48a263.416,263.416,0,0,0,39.828-73.48H578.4v75.476A173.846,173.846,0,0,0,597.249,556.028ZM560.6,558.036V482.548H497.674a263.837,263.837,0,0,0,39.135,72.575A174.279,174.279,0,0,0,560.6,558.036ZM510.434,547.83a289.038,289.038,0,0,1-31.078-65.282H426.1A177.5,177.5,0,0,0,510.434,547.83Zm-95.208-82.89H474.1A333.353,333.353,0,0,1,462.878,387.1H393.555A182.127,182.127,0,0,0,415.226,464.94ZM393.555,368.9h69.323a333.4,333.4,0,0,1,11.268-78.026H415.332A182.13,182.13,0,0,0,393.555,368.9Zm32.676-95.633h53.185a289.064,289.064,0,0,1,31-65.087A177.487,177.487,0,0,0,426.231,273.263Zm134.372-75.3a174.068,174.068,0,0,0-23.8,2.914,263.8,263.8,0,0,0-39.06,72.384H560.6v-75.3Zm0,92.905H492.051A314.088,314.088,0,0,0,480.106,368.9h80.5V290.87Zm-80.5,96.234A314.1,314.1,0,0,0,492,464.94h68.6V387.1h-80.5ZM578.4,197.977v75.286h58.612a263.412,263.412,0,0,0-39.751-73.289A174.011,174.011,0,0,0,578.4,197.977Zm44.672,8.286a288.6,288.6,0,0,1,32.231,67h57.491A177.213,177.213,0,0,0,623.069,206.263Zm100.6,84.607H660.6A333.3,333.3,0,0,1,671.861,368.9h73.586A182.129,182.129,0,0,0,723.667,290.87Z"/>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

+445
View File
@@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""Reconstruct the Cosmos 2612 / ICEYE-X36 rendezvous (Sep 30 - Oct 22, 2026).
ICEYE-X36 is propagated with SGP4 from its public CelesTrak element set.
Cosmos 2612 is carried as quasi-nonsingular relative orbital elements (ROE)
about X36 and advanced with linearised Keplerian drift plus the differential
J2 node drift. Manoeuvres are tangential / normal impulses applied through
Gauss's variational equations, timed inside the element-set windows that
KeepTrack, LeoLabs and Marco Langbroek published. The final state is pinned
to Cosmos 2612's own current element set (479 km behind, matched SMA).
Output: data.json (60 s samples) consumed by scene.html.
"""
import json
import math
from datetime import datetime, timedelta, timezone
import numpy as np
from scipy.optimize import least_squares
from sgp4.api import Satrec, jday
HERE = __file__.rsplit('/', 1)[0]
MU = 398600.4418
RE = 6378.137
J2 = 1.08262668e-3
lines = open(f'{HERE}/tles.txt').read().split('\n')
cosmos_tle = Satrec.twoline2rv(lines[1], lines[2])
x36 = Satrec.twoline2rv(lines[4], lines[5])
A = (MU / (x36.no_kozai / 60) ** 2) ** (1 / 3) # km
N_RAD_S = math.sqrt(MU / A ** 3)
N_DAY = N_RAD_S * 86400
INC = x36.inclo
V = N_RAD_S * A * 1000 # m/s orbital speed
# Differential node rate per unit da/a: dOmegadot = -3.5 * Omegadot * da/a
OMEGA_DOT = -1.5 * N_RAD_S * J2 * (RE / A) ** 2 * math.cos(INC) # rad/s
T0 = datetime(2026, 9, 29, 12, 0, tzinfo=timezone.utc)
T1 = datetime(2026, 10, 22, 12, 0, tzinfo=timezone.utc)
DT = 60
def utc(s):
return datetime.fromisoformat(s.replace('Z', '+00:00'))
def secs(d):
return (d - T0).total_seconds()
def x36_state(t):
d = T0 + timedelta(seconds=t)
jd, fr = jday(d.year, d.month, d.day, d.hour, d.minute, d.second + d.microsecond / 1e6)
e, r, v = x36.sgp4(jd, fr)
assert e == 0
return np.array(r), np.array(v)
def frame(r, v):
R = r / np.linalg.norm(r)
Nn = np.cross(r, v)
Nn /= np.linalg.norm(Nn)
return R, np.cross(Nn, R), Nn
def arg_lat(r, v):
R, T, Nn = frame(r, v)
node = np.cross([0, 0, 1], Nn)
node /= np.linalg.norm(node)
return math.atan2(np.dot(np.cross(node, R), Nn), np.dot(node, R))
# ---------------------------------------------------------------------------
# ROE dynamics. roe = [da, dl, dex, dey, dix, diy] (dimensionless, / a)
# ---------------------------------------------------------------------------
def drift(roe, dt):
out = roe.copy()
out[1] += -1.5 * N_RAD_S * roe[0] * dt
out[5] += -3.5 * OMEGA_DOT * roe[0] * math.sin(INC) * dt
return out
def impulse(roe, u, dvr=0.0, dvt=0.0, dvn=0.0):
k = 1 / (N_RAD_S * A * 1000)
out = roe.copy()
out[0] += 2 * k * dvt
out[1] += -2 * k * dvr
out[2] += k * (math.sin(u) * dvr + 2 * math.cos(u) * dvt)
out[3] += k * (-math.cos(u) * dvr + 2 * math.sin(u) * dvt)
out[4] += k * math.cos(u) * dvn
out[5] += k * math.sin(u) * dvn
return out
def rtn(roe, u):
da, dl, dex, dey, dix, diy = roe
return A * 1000 * np.array([
da - dex * math.cos(u) - dey * math.sin(u),
dl + 2 * dex * math.sin(u) - 2 * dey * math.cos(u),
dix * math.sin(u) - diy * math.cos(u),
])
def dv_for_da(da_km):
return V * da_km / A / 2 # m/s total, split into two half-orbit impulses
PERIOD = 2 * math.pi / N_RAD_S
U_CACHE = {}
def u_at(t):
key = round(t)
if key not in U_CACHE:
U_CACHE[key] = arg_lat(*x36_state(t))
return U_CACHE[key]
def build(params):
"""Return (burns, simulate) from a parameter dict."""
p = params
burns = []
def hohmann(t, da_km, label, de_vec=None):
"""Two tangential impulses half an orbit apart -> pure SMA change.
de_vec optionally re-targets the relative eccentricity vector by
splitting the impulses unevenly (a small radial-tangential trim)."""
dv = dv_for_da(da_km)
burns.append(dict(t=t, dvt=dv / 2, label=label))
burns.append(dict(t=t + PERIOD / 2, dvt=dv / 2, label=label, second=True, de=de_vec))
hohmann(p['t_sep30'], p['da_sep30'], 'Phasing raise')
burns.append(dict(t=p['t_oct7'], dvn=p['dvn_oct7'], label='Plane trim'))
hohmann(p['t_oct12'], p['da_oct12'], 'Descent to X36 orbit')
hohmann(p['t_oct15'], p['da_oct15'], 'Closing burn')
hohmann(p['t_oct16'], p['da_oct16'], 'Braking burn', de_vec=p['de_prox'])
burns.append(dict(t=p['t_oct18a'], dvt=dv_for_da(p['da_oct18a']), label='Back-off'))
hohmann(p['t_oct18'], p['da_oct18'], 'Departure burn')
hohmann(p['t_oct19'], p['da_oct19'], 'Re-match orbit')
burns.sort(key=lambda b: b['t'])
return burns
def simulate(params, times):
burns = build(params)
roe = np.array(params['roe0'], dtype=float)
t = 0.0
bi = 0
out = []
roe_out = []
for tt in times:
while bi < len(burns) and burns[bi]['t'] <= tt:
b = burns[bi]
roe = drift(roe, b['t'] - t)
t = b['t']
u = u_at(t)
roe = impulse(roe, u, dvt=b.get('dvt', 0), dvn=b.get('dvn', 0))
if b.get('de') is not None:
roe[2], roe[3] = b['de'][0] / (A * 1000), b['de'][1] / (A * 1000)
bi += 1
roe = drift(roe, tt - t)
t = tt
out.append(rtn(roe, u_at(tt)))
roe_out.append(roe.copy())
return np.array(out), np.array(roe_out), burns
# ---------------------------------------------------------------------------
# Timeline (UTC). Windows are the published element-set gaps.
# ---------------------------------------------------------------------------
OCT12_OVERHEAD = secs(utc('2026-10-12T00:10:00Z'))
LAP_DA = 47.0 # km, KeepTrack: "raised orbit by 47 km in total"
lap_rate = 1.5 * N_RAD_S * LAP_DA / A # rad/s
t_sep30 = secs(utc('2026-09-30T11:00:00Z'))
params = dict(
t_sep30=t_sep30, da_sep30=LAP_DA - 1.0,
t_oct7=secs(utc('2026-10-07T10:30:00Z')), dvn_oct7=4.0,
t_oct12=secs(utc('2026-10-12T01:30:00Z')), da_oct12=-(LAP_DA + 0.2),
t_oct15=secs(utc('2026-10-15T03:00:00Z')), da_oct15=-2.3,
t_oct16=secs(utc('2026-10-16T05:30:00Z')), da_oct16=2.48,
de_prox=(0.0, 800.0),
t_oct18a=secs(utc('2026-10-18T02:00:00Z')), da_oct18a=0.13,
t_oct18=secs(utc('2026-10-18T12:26:00Z')), da_oct18=2.40,
t_oct19=secs(utc('2026-10-19T20:00:00Z')), da_oct19=-2.45,
roe0=[1.0 / A, 0.0, 0.0, 0.0, -0.032 * math.pi / 180, 0.0],
)
def sep_at(p, iso_list):
ts = [secs(utc(s)) for s in iso_list]
r, roe, _ = simulate(p, ts)
return np.linalg.norm(r, axis=1) / 1000, r, roe
def dl_at(p, iso):
_, _, roe = sep_at(p, [iso])
return roe[0][1]
# 1) Initial along-track phase so the Oct 12 overhead pass (dl = -2 pi) is hit.
def f_phase(x):
p = dict(params, roe0=[params['roe0'][0], x[0]] + params['roe0'][2:])
return [dl_at(p, '2026-10-12T00:10:00Z') + 2 * math.pi]
params['roe0'][1] = least_squares(f_phase, [-0.5]).x[0]
# Undo the lap so dl is near zero afterwards (wrap): handled by subtracting 2 pi.
params['roe0'][1] += 2 * math.pi
# Wrap: overhead at Oct 12 is now dl=0.
# 1b) Initial relative inclination vector: the 47 km "high park" lets
# differential J2 node drift close the plane; the Oct 7 normal burn
# trims the rest. Fit so the plane angle is ~0.002 deg by Oct 16.
def f_plane(x):
p = dict(params, roe0=params['roe0'][:4] + [x[0], x[1]])
_, _, roe = sep_at(p, ['2026-10-16T12:00:00Z'])
target = 0.002 * math.pi / 180 / math.sqrt(2)
return [(roe[0][4] - target) * 1e5, (roe[0][5] - target) * 1e5]
params['roe0'][4:6] = list(least_squares(f_plane, [0.0, -0.002]).x)
plane0 = math.degrees(math.hypot(*params['roe0'][4:6]))
# 2) Oct 12 descent timing -> ~480 km behind once the drift stops.
def f_desc(x):
p = dict(params, t_oct12=x[0])
return [dl_at(p, '2026-10-12T06:00:00Z') * A + 480.0]
params['t_oct12'] = least_squares(f_desc, [params['t_oct12']]).x[0]
# 3) Oct 15 closing burn timing so drift is arrested ~3 km behind by the Oct 16 braking burn.
def f_close(x):
p = dict(params, t_oct15=x[0])
t16 = params['t_oct16'] + PERIOD
d = (T0 + timedelta(seconds=t16)).isoformat()
return [dl_at(p, d) * A + 3.0]
params['t_oct15'] = least_squares(f_close, [params['t_oct15']]).x[0]
# 4) Proximity phase: fit the residual SMA offset, relative e / i vectors to
# published separations (Oct 17) and a sub-km closest approach early Oct 18.
CHECK = [
('2026-10-17T07:45:00Z', 2.1),
('2026-10-17T17:18:00Z', 0.85),
('2026-10-17T20:29:00Z', 0.64),
('2026-10-17T23:40:00Z', 0.64),
]
DE_PROX_M = 800.0 # KeepTrack: relative loops ~1.6 km tall after the braking burn
def prox_params(x):
t16, da16, phi = x
return dict(params, t_oct16=t16, da_oct16=da16,
de_prox=(DE_PROX_M * math.cos(phi), DE_PROX_M * math.sin(phi)))
def window(start, end, step_min):
t, out = utc(start), []
while t < utc(end):
out.append(t.isoformat())
t += timedelta(minutes=step_min)
return out
ca_window = window('2026-10-17T23:30:00Z', '2026-10-18T02:00:00Z', 1)
pre_window = window('2026-10-16T08:00:00Z', '2026-10-17T23:30:00Z', 4)
T_CA_TARGET = 75 # minutes after 00:00 -> 01:15 UTC (Langbroek ~01:35, KeepTrack ~01:10)
def f_prox(x):
p = prox_params(x)
d, _, roe = sep_at(p, [c[0] for c in CHECK] + ['2026-10-16T12:00:00Z'])
res = [(d[i] - c[1]) / 0.5 for i, c in enumerate(CHECK)]
res.append((roe[-1][1] * A + 3.0) / 0.3)
dw, _, _ = sep_at(p, ca_window)
k = int(np.argmin(dw))
res.append((dw[k] - 0.20) / 0.02)
res.append((k - 30 - T_CA_TARGET) / 15)
dp, _, _ = sep_at(p, pre_window)
res.append(max(0.0, 0.55 - dp.min()) / 0.02)
return res
best = None
for phi0 in np.linspace(0, 2 * math.pi, 8, endpoint=False):
f = least_squares(f_prox, [params['t_oct16'] - 1800, 2.49, phi0], x_scale=[600, 0.01, 0.3],
diff_step=[1e-4, 1e-4, 1e-3])
if best is None or f.cost < best.cost:
best = f
fit = best
params.update(prox_params(fit.x))
print('prox fit residuals', np.round(fit.fun, 2), 'x', fit.x)
# 5) Post-CA back-off so Oct 18 07:38 is ~5.1 km behind.
def f_back(x):
p = dict(params, da_oct18a=x[0])
d, r, _ = sep_at(p, ['2026-10-18T07:38:00Z'])
return [d[0] - 5.1]
params['da_oct18a'] = least_squares(f_back, [0.13]).x[0]
# 6) Re-match burn so the state lands on the current element sets
# (Oct 24: 479 km behind, SMA offset +0.017 km).
def pair_phase(iso):
d = utc(iso)
jd, fr = jday(d.year, d.month, d.day, d.hour, d.minute, d.second)
_, rc, _ = cosmos_tle.sgp4(jd, fr)
r, v = x36_state(secs(d))
R, T, Nn = frame(r, v)
return math.atan2(np.dot(np.cross(r, rc), Nn), np.dot(r, rc))
A_COSMOS = (MU / (cosmos_tle.no_kozai / 60) ** 2) ** (1 / 3)
END_DA = A_COSMOS - A
target_dl = pair_phase('2026-10-21T12:00:00Z')
def f_rematch(x):
p = dict(params, t_oct19=x[0], da_oct19=END_DA - (params['da_oct18a'] + params['da_oct18'] + params['roe0'][0] * 0 + 0))
return [(dl_at(p, '2026-10-21T12:00:00Z') - target_dl) * A]
# SMA bookkeeping: total offset after Oct 18 burns.
def sma_after(p, iso):
_, _, roe = sep_at(p, [iso])
return roe[0][0] * A
da_before_rematch = sma_after(params, '2026-10-19T12:00:00Z')
params['da_oct19'] = END_DA - da_before_rematch
params['t_oct19'] = least_squares(
lambda x: [(dl_at(dict(params, t_oct19=x[0]), '2026-10-21T12:00:00Z') - target_dl) * A],
[params['t_oct19']],
).x[0]
# ---------------------------------------------------------------------------
# Sample and export
# ---------------------------------------------------------------------------
times = np.arange(0, secs(T1) + 1, DT)
rel, roe, burns = simulate(params, list(times))
x36_pos = np.array([x36_state(t)[0] for t in times])
dist_km = np.linalg.norm(rel, axis=1) / 1000
# Chord distance for large phases: the ROE map is linear, so use the
# curvilinear placement for the true separation.
def true_sep(i):
r, v = x36_state(times[i])
R, T, Nn = frame(r, v)
th = rel[i][1] / 1000 / np.linalg.norm(r)
Rp = R * math.cos(th) + T * math.sin(th)
pc = (np.linalg.norm(r) + rel[i][0] / 1000) * Rp + rel[i][2] / 1000 * Nn
return np.linalg.norm(pc - r)
sep = np.array([true_sep(i) for i in range(len(times))])
ica = int(np.argmin(np.where(times > secs(utc('2026-10-17T12:00:00Z')), sep, 1e9)))
events = [
dict(t='2026-09-30T11:00:00Z', key='raise'),
dict(t='2026-10-12T00:10:00Z', key='overhead'),
]
burn_list = []
for b in burns:
if b.get('second'):
continue
dv = abs(b.get('dvt', 0)) * (1 if b['label'] == 'Back-off' else 2) + abs(b.get('dvn', 0))
burn_list.append(dict(t=(T0 + timedelta(seconds=b['t'])).isoformat().replace('+00:00', 'Z'),
label=b['label'], dv=round(dv, 2)))
total_dv = sum(b['dv'] for b in burn_list)
ca_t = (T0 + timedelta(seconds=float(times[ica]))).isoformat().replace('+00:00', 'Z')
# Refine CA with 2 s sampling
fine = list(np.arange(times[ica] - 120, times[ica] + 120, 2.0))
relf, _, _ = simulate(params, fine)
dfine = np.linalg.norm(relf, axis=1)
j = int(np.argmin(dfine))
ca_t = (T0 + timedelta(seconds=float(fine[j]))).isoformat().replace('+00:00', 'Z')
ca_m = float(dfine[j])
ca_rtn = relf[j].tolist()
# Vantor image: Oct 17, ~1.02 km. Pick the Oct 17 sample nearest 1.02 km in the afternoon.
# Vantor imaged optically, so the pair must be sunlit at capture.
def sun_dir(t):
n = (T0 + timedelta(seconds=float(t)) - datetime(2000, 1, 1, 12, tzinfo=timezone.utc)).total_seconds() / 86400
L = math.radians((280.46 + 0.9856474 * n) % 360)
g = math.radians((357.528 + 0.9856003 * n) % 360)
lam = L + math.radians(1.915) * math.sin(g) + math.radians(0.02) * math.sin(2 * g)
eps = math.radians(23.439)
return np.array([math.cos(lam), math.cos(eps) * math.sin(lam), math.sin(eps) * math.sin(lam)])
def sunlit(t):
r = x36_state(t)[0]
s = sun_dir(t)
p = np.dot(r, s)
return p > 0 or np.linalg.norm(r - p * s) > RE
lit = np.array([sunlit(t) for t in times])
mask = (times > secs(utc('2026-10-17T06:00:00Z'))) & (times < secs(utc('2026-10-17T23:59:00Z'))) & lit
iv = int(np.argmin(np.where(mask, np.abs(sep - 1.02), 1e9)))
out = dict(
t0=T0.isoformat().replace('+00:00', 'Z'),
dt=DT,
a_km=A,
x36=np.round(x36_pos, 4).ravel().tolist(),
rel=np.round(rel, 2).ravel().tolist(),
sep_km=np.round(sep, 4).tolist(),
burns=burn_list,
total_dv=round(total_dv, 1),
ca=dict(t=ca_t, m=round(ca_m, 1), rtn=[round(v, 1) for v in ca_rtn]),
vantor=dict(t=(T0 + timedelta(seconds=float(times[iv]))).isoformat().replace('+00:00', 'Z'),
km=round(float(sep[iv]), 2)),
lit=''.join('1' if v else '0' for v in lit),
sma_offset_km=np.round(roe[:, 0] * A, 3).tolist()[::10],
)
json.dump(out, open(f'{HERE}/data.json', 'w'), separators=(',', ':'))
print(f'a = {A:.3f} km, period = {PERIOD/60:.2f} min')
print('burns:')
for b in burn_list:
print(' ', b)
print('total dv', total_dv)
print('CA', ca_t, f'{ca_m:.0f} m', 'RTN', np.round(ca_rtn, 0))
print('Vantor', out['vantor'])
for iso in ['2026-10-05T12:00:00Z', '2026-10-12T00:10:00Z', '2026-10-12T06:00:00Z', '2026-10-15T00:00:00Z',
'2026-10-15T12:00:00Z', '2026-10-16T06:00:00Z', '2026-10-16T12:00:00Z', '2026-10-17T07:45:00Z',
'2026-10-17T17:18:00Z', '2026-10-17T20:29:00Z', '2026-10-17T23:40:00Z', '2026-10-18T07:38:00Z',
'2026-10-19T07:32:00Z', '2026-10-21T12:00:00Z', '2026-10-22T12:00:00Z']:
i = int(secs(utc(iso)) // DT)
print(f' {iso} sep {sep[i]:9.2f} km rtn(km) {np.round(rel[i]/1000, 2)} dSMA {roe[i][0]*A:7.3f}')
print('plane angle at T0 deg', plane0)
print('target dl (Oct 21 from TLEs) km', target_dl * A, 'END_DA', END_DA)
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env node
/**
* render.mjs — deterministic frame capture for the Cosmos 2612 / ICEYE-X36 replay.
*
* node tools/rpo-replay/render.mjs --format 16x9 --stills 2,9,24,32
* node tools/rpo-replay/render.mjs --format 9x16 --from 0 --to 675
* node tools/rpo-replay/render.mjs --format 16x9 --encode
*
* Frames are written to output/rpo-replay/<format>/frames/NNNNN.jpg; existing
* frames are skipped, so interrupted renders resume. --encode muxes them to MP4.
* Uses Playwright's Chromium with SwiftShader (no GPU needed).
*/
import { createServer } from 'node:http';
import { readFile, mkdir, access } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { chromium } = require('playwright');
const HERE = fileURLToPath(new URL('.', import.meta.url));
const ROOT = process.env.GEV_PROJECT_ROOT || join(HERE, '../..');
const CESIUM = join(ROOT, 'node_modules/cesium/Build/Cesium');
const SKY = join(ROOT, 'src/data/local_data/sky');
const args = Object.fromEntries(
process.argv.slice(2).reduce((acc, a, i, all) => {
if (a.startsWith('--')) acc.push([a.slice(2), all[i + 1] && !all[i + 1].startsWith('--') ? all[i + 1] : true]);
return acc;
}, []),
);
const FORMATS = { '16x9': [1920, 1080], '9x16': [1080, 1920], preview: [960, 540], 'preview-v': [540, 960] };
const format = args.format || '16x9';
const [W, H] = FORMATS[format];
const FPS = Number(args.fps || 30);
const DUR = 45;
const total = Math.round(DUR * FPS);
const outDir = join(ROOT, 'output/rpo-replay', format);
const frameDir = join(outDir, 'frames');
const MIME = {
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json',
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.wasm': 'application/wasm',
'.glb': 'model/gltf-binary', '.xml': 'application/xml', '.ktx2': 'image/ktx2', '.webp': 'image/webp',
};
function serve() {
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const p = decodeURIComponent(url.pathname);
const file = p.startsWith('/cesium/')
? join(CESIUM, normalize(p.slice('/cesium/'.length)))
: p.startsWith('/sky/')
? join(SKY, normalize(p.slice('/sky/'.length)))
: join(HERE, normalize(p === '/' ? 'scene.html' : p.slice(1)));
if (!file.startsWith(CESIUM) && !file.startsWith(SKY) && !file.startsWith(HERE)) {
res.writeHead(403).end();
return;
}
try {
const body = await readFile(file);
res.writeHead(200, { 'Content-Type': MIME[extname(file)] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404).end();
}
});
return new Promise((r) => server.listen(0, '127.0.0.1', () => r(server)));
}
async function exists(p) {
try {
await access(p);
return true;
} catch {
return false;
}
}
function encode() {
const out = join(outDir, `cosmos2612-iceye-x36-${format}.mp4`);
const r = spawnSync('ffmpeg', [
'-y', '-framerate', String(FPS), '-i', join(frameDir, '%05d.jpg'),
'-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
'-vf', 'format=yuv420p', out,
], { stdio: 'inherit' });
if (r.status !== 0) process.exit(r.status || 1);
console.log(out);
}
if (args.encode) {
encode();
process.exit(0);
}
const server = await serve();
const port = server.address().port;
const browser = await chromium.launch({
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist',
'--disable-gpu-sandbox', '--enable-webgl'],
});
const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
page.on('console', (m) => {
if (m.type() === 'error' || m.type() === 'warning') console.log(`[page ${m.type()}]`, m.text());
});
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
await page.goto(`http://127.0.0.1:${port}/scene.html?w=${W}&h=${H}`);
await page.waitForFunction(() => window.rpo?.ready, null, { timeout: 180000 });
if (args.stills) {
const dir = join(outDir, 'stills');
await mkdir(dir, { recursive: true });
for (const s of String(args.stills).split(',').map(Number)) {
const t0 = Date.now();
const info = await page.evaluate((x) => window.rpo.frame(x), s);
await page.screenshot({ path: join(dir, `s${s.toFixed(2).padStart(5, '0')}.jpg`), type: 'jpeg', quality: 92 });
console.log(`still ${s}s sep=${(info.sep / 1000).toFixed(3)} km mag=${info.mag.toFixed(1)} ${Date.now() - t0} ms`);
}
} else {
await mkdir(frameDir, { recursive: true });
const from = Number(args.from || 0);
const to = Math.min(Number(args.to || total), total);
const t0 = Date.now();
let done = 0;
for (let f = from; f < to; f++) {
const path = join(frameDir, `${String(f).padStart(5, '0')}.jpg`);
if (await exists(path)) continue;
await page.evaluate((x) => window.rpo.frame(x), f / FPS);
await page.screenshot({ path, type: 'jpeg', quality: 94 });
done++;
if (done % 25 === 0) {
const per = (Date.now() - t0) / done;
console.log(`${format} frame ${f}/${to} ${(per / 1000).toFixed(2)} s/frame eta ${(((to - f) * per) / 60000).toFixed(1)} min`);
}
}
}
await browser.close();
server.close();
+130
View File
@@ -0,0 +1,130 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cosmos 2612 / ICEYE-X36 replay</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=block" rel="stylesheet">
<link href="/cesium/Widgets/widgets.css" rel="stylesheet">
<style>
:root {
--x36: #5ce1e6;
--cosmos: #ff5a3c;
--burn: #ffb547;
--ink: #eef2f6;
--ink-2: rgba(238, 242, 246, 0.66);
--ink-3: rgba(238, 242, 246, 0.38);
--rule: rgba(238, 242, 246, 0.16);
--u: 1px; /* layout unit, scaled per format */
}
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; }
body { font-family: Inter, system-ui, sans-serif; color: var(--ink); -webkit-font-smoothing: antialiased; }
#globe { position: absolute; inset: 0; }
.cesium-viewer-bottom, .cesium-widget-credits { display: none !important; }
#hud { position: absolute; inset: 0; pointer-events: none; }
#vignette { position: absolute; inset: 0;
background: radial-gradient(ellipse at 50% 45%, transparent 55%, rgba(0,0,0,0.55) 100%); }
#topshade { position: absolute; left: 0; right: 0; top: 0; height: calc(380 * var(--u));
background: linear-gradient(rgba(0,0,0,0.55), transparent); }
#botshade { position: absolute; left: 0; right: 0; bottom: 0; height: calc(420 * var(--u));
background: linear-gradient(transparent, rgba(0,0,0,0.72)); }
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-feature-settings: 'tnum' 1, 'zero' 0; }
#caption { position: absolute; left: calc(64 * var(--u)); top: calc(56 * var(--u)); width: calc(760 * var(--u)); }
#caption .kicker { font: 600 calc(15 * var(--u))/1.2 'JetBrains Mono', monospace; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--ink-2); margin-bottom: calc(14 * var(--u)); }
#caption .kicker b { color: var(--ink); font-weight: 600; }
#caption h1 { margin: 0 0 calc(14 * var(--u)); font: 700 calc(46 * var(--u))/1.04 Inter, sans-serif; letter-spacing: -0.02em; }
#caption p { margin: 0; font: 400 calc(21 * var(--u))/1.42 Inter, sans-serif; color: var(--ink-2); }
#caption p em { font-style: normal; color: var(--ink); font-weight: 500; }
.c-x36 { color: var(--x36) !important; }
.c-cos { color: var(--cosmos) !important; }
.c-burn { color: var(--burn) !important; }
#clock { position: absolute; right: calc(64 * var(--u)); top: calc(56 * var(--u)); text-align: right; }
#clock .date { font: 600 calc(30 * var(--u))/1 'JetBrains Mono', monospace; letter-spacing: -0.01em; }
#clock .time { font: 400 calc(22 * var(--u))/1 'JetBrains Mono', monospace; color: var(--ink-2); margin-top: calc(10 * var(--u)); }
#clock .rate { margin-top: calc(18 * var(--u)); display: inline-flex; gap: calc(10 * var(--u)); align-items: baseline;
font: 500 calc(17 * var(--u))/1 'JetBrains Mono', monospace; color: var(--ink); padding: calc(8 * var(--u)) calc(12 * var(--u));
border: 1px solid var(--rule); border-radius: calc(4 * var(--u)); background: rgba(0,0,0,0.28); }
#clock .rate span { color: var(--ink-2); }
#clock .shadow { margin-top: calc(12 * var(--u)); font: 600 calc(14 * var(--u))/1 'JetBrains Mono', monospace;
letter-spacing: 0.12em; color: #9fb4ff; }
#readout { position: absolute; right: calc(64 * var(--u)); bottom: calc(262 * var(--u)); text-align: right; }
#readout .label { font: 600 calc(13 * var(--u))/1 'JetBrains Mono', monospace; letter-spacing: 0.16em; color: var(--ink-2); }
#readout .val { font: 600 calc(64 * var(--u))/1 'JetBrains Mono', monospace; letter-spacing: -0.03em; margin-top: calc(8 * var(--u)); }
#readout .val small { font-size: 0.45em; color: var(--ink-2); margin-left: 0.15em; letter-spacing: 0; }
#readout .ric { margin-top: calc(10 * var(--u)); font: 400 calc(15 * var(--u))/1.5 'JetBrains Mono', monospace; color: var(--ink-2); }
#readout .ric b { color: var(--ink); font-weight: 500; }
#chart { position: absolute; left: calc(64 * var(--u)); right: calc(64 * var(--u)); bottom: calc(44 * var(--u)); height: calc(190 * var(--u)); }
#chart svg { width: 100%; height: 100%; overflow: visible; }
#chart text { font-family: 'JetBrains Mono', monospace; }
.tag { position: absolute; transform: translate(0, -50%); white-space: nowrap; }
.tag .name { font: 600 calc(17 * var(--u))/1.1 Inter, sans-serif; letter-spacing: 0.01em; }
.tag .meta { font: 400 calc(13 * var(--u))/1.3 'JetBrains Mono', monospace; color: var(--ink-2); margin-top: calc(3 * var(--u)); }
#leaders { position: absolute; inset: 0; width: 100%; height: 100%; }
#callouts { position: absolute; left: calc(64 * var(--u)); top: calc(330 * var(--u)); width: calc(700 * var(--u)); }
.event { position: relative; padding-left: calc(22 * var(--u)); margin-bottom: calc(14 * var(--u));
font: 600 calc(16 * var(--u))/1.2 'JetBrains Mono', monospace; letter-spacing: 0.08em; }
.event i { position: absolute; left: 0; top: calc(4 * var(--u)); width: calc(9 * var(--u)); height: calc(9 * var(--u)); border-radius: 50%; }
.event .sub { font-weight: 400; letter-spacing: 0.02em; color: var(--ink-2); margin-top: calc(5 * var(--u)); font-size: calc(14 * var(--u)); }
#mag { position: absolute; left: calc(64 * var(--u)); bottom: calc(262 * var(--u));
font: 400 calc(14 * var(--u))/1.4 'JetBrains Mono', monospace; color: var(--ink-3); }
#endcard { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
#endcard .panel { width: calc(1180 * var(--u)); }
#endcard h2 { margin: 0 0 calc(28 * var(--u)); font: 700 calc(44 * var(--u))/1.1 Inter, sans-serif; letter-spacing: -0.02em; }
#endcard .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: calc(24 * var(--u)); margin-bottom: calc(36 * var(--u)); }
#endcard .stat .n { font: 600 calc(48 * var(--u))/1 'JetBrains Mono', monospace; letter-spacing: -0.03em; }
#endcard .stat .l { margin-top: calc(10 * var(--u)); font: 400 calc(16 * var(--u))/1.35 Inter, sans-serif; color: var(--ink-2); }
#endcard .src { font: 400 calc(14 * var(--u))/1.6 'JetBrains Mono', monospace; color: var(--ink-3); border-top: 1px solid var(--rule); padding-top: calc(18 * var(--u)); }
#endcard .src b { color: var(--ink-2); font-weight: 500; }
/* Portrait */
body.portrait #caption { left: calc(56 * var(--u)); right: calc(56 * var(--u)); top: calc(170 * var(--u)); width: auto; }
body.portrait #caption h1 { font-size: calc(54 * var(--u)); }
body.portrait #caption p { font-size: calc(25 * var(--u)); }
body.portrait #clock { left: calc(56 * var(--u)); right: auto; top: calc(64 * var(--u)); text-align: left; display: flex; gap: calc(22 * var(--u)); align-items: baseline; flex-wrap: wrap; width: calc(968 * var(--u)); }
body.portrait #clock .time { margin-top: 0; }
body.portrait #clock .rate { margin-top: 0; margin-left: auto; }
body.portrait #clock .shadow { margin-top: 0; width: 100%; text-align: right; }
body.portrait #callouts { left: calc(56 * var(--u)); top: calc(560 * var(--u)); }
body.portrait #readout { right: auto; left: calc(56 * var(--u)); bottom: calc(400 * var(--u)); text-align: left; }
body.portrait #readout .val { font-size: calc(76 * var(--u)); }
body.portrait #mag { left: auto; right: calc(56 * var(--u)); bottom: calc(400 * var(--u)); text-align: right; }
body.portrait #chart { left: calc(56 * var(--u)); right: calc(56 * var(--u)); bottom: calc(110 * var(--u)); height: calc(250 * var(--u)); }
body.portrait #topshade { height: calc(820 * var(--u)); }
body.portrait #botshade { height: calc(880 * var(--u)); }
body.portrait #endcard .panel { width: calc(940 * var(--u)); }
body.portrait #endcard .stats { grid-template-columns: repeat(2, 1fr); gap: calc(34 * var(--u)); }
body.portrait #endcard h2 { font-size: calc(52 * var(--u)); }
body.portrait #endcard .stat .n { font-size: calc(60 * var(--u)); }
body.portrait #endcard .stat .l { font-size: calc(20 * var(--u)); }
body.portrait #endcard .src { font-size: calc(17 * var(--u)); }
</style>
</head>
<body>
<div id="globe"></div>
<div id="hud">
<div id="vignette"></div>
<div id="topshade"></div>
<div id="botshade"></div>
<svg id="leaders"></svg>
<div id="tags"></div>
<div id="callouts"></div>
<div id="caption"><div class="kicker"></div><h1></h1><p></p></div>
<div id="clock"><div class="date mono"></div><div class="time mono"></div><div class="rate"></div><div class="shadow"></div></div>
<div id="readout"><div class="label">SEPARATION</div><div class="val"></div><div class="ric"></div></div>
<div id="mag"></div>
<div id="chart"></div>
<div id="endcard"></div>
<div id="dip" style="position:absolute;inset:0;background:#000;opacity:0"></div>
</div>
<script src="/cesium/Cesium.js"></script>
<script type="module" src="scene.js"></script>
</body>
</html>
+914
View File
@@ -0,0 +1,914 @@
/* global Cesium */
// Deterministic frame renderer for the Cosmos 2612 / ICEYE-X36 replay.
// window.rpo.frame(seconds) sets every piece of state for one video instant,
// renders, and resolves once imagery and models have settled.
const params = new URLSearchParams(location.search);
const W = Number(params.get('w') || 1920);
const H = Number(params.get('h') || 1080);
const PORTRAIT = H > W;
const U = PORTRAIT ? W / 1080 : W / 1920;
document.documentElement.style.setProperty('--u', `${U}px`);
if (PORTRAIT) document.body.classList.add('portrait');
const D = await (await fetch('data.json')).json();
const T0 = Date.parse(D.t0);
const DT = D.dt;
const NS = D.sep_km.length;
const sec = (iso) => (Date.parse(iso) - T0) / 1000;
const DUR = 45;
// ---------------------------------------------------------------------------
// Interpolation
// ---------------------------------------------------------------------------
function cr(arr, t, out, scale = 1) {
const x = Math.min(Math.max(t / DT, 1), NS - 3);
const i = Math.floor(x);
const u = x - i;
const u2 = u * u;
const u3 = u2 * u;
for (let k = 0; k < 3; k++) {
const p0 = arr[(i - 1) * 3 + k];
const p1 = arr[i * 3 + k];
const p2 = arr[(i + 1) * 3 + k];
const p3 = arr[(i + 2) * 3 + k];
out[k] =
0.5 * scale *
(2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
if (out.length > 3) {
out[3 + k] = (0.5 * scale * ((-p0 + p2) + 2 * (2 * p0 - 5 * p1 + 4 * p2 - p3) * u + 3 * (-p0 + 3 * p1 - 3 * p2 + p3) * u2)) / DT;
}
}
return out;
}
const v3 = Cesium.Cartesian3;
const tmp6 = new Float64Array(6);
function x36Teme(t) {
cr(D.x36, t, tmp6, 1000);
return { r: new v3(tmp6[0], tmp6[1], tmp6[2]), v: new v3(tmp6[3], tmp6[4], tmp6[5]) };
}
const tmp3 = new Float64Array(3);
function relAt(t) {
cr(D.rel, t, tmp3, 1);
return [tmp3[0], tmp3[1], tmp3[2]];
}
function sepAt(t) {
const x = Math.min(Math.max(t / DT, 0), NS - 2);
const i = Math.floor(x);
return D.sep_km[i] + (D.sep_km[i + 1] - D.sep_km[i]) * (x - i);
}
function litAt(t) {
return D.lit[Math.round(Math.min(Math.max(t / DT, 0), NS - 1))] === '1';
}
function basis(r, v) {
const R = v3.normalize(r, new v3());
const N = v3.normalize(v3.cross(r, v, new v3()), new v3());
const T = v3.cross(N, R, new v3());
return { R, T, N };
}
// Curvilinear placement: along-track offset is an arc on the anchor orbit.
function place(anchor, B, rel) {
const rm = v3.magnitude(anchor);
const th = rel[1] / rm;
const c = Math.cos(th);
const s = Math.sin(th);
const Rp = new v3(B.R.x * c + B.T.x * s, B.R.y * c + B.T.y * s, B.R.z * c + B.T.z * s);
const out = v3.multiplyByScalar(Rp, rm + rel[0], new v3());
return v3.add(out, v3.multiplyByScalar(B.N, rel[2], new v3()), out);
}
// ---------------------------------------------------------------------------
// Time maps. tau(s): simulation clock. b(s): backdrop/anchor clock (Earth and
// lighting), equal to tau around closest approach.
// ---------------------------------------------------------------------------
const CA_T = sec(D.ca.t);
const VANTOR_T = sec(D.vantor.t);
const TAU_KEYS = [
[0.0, '2026-09-30T07:30:00Z'],
[4.0, '2026-09-30T10:20:00Z'],
[7.0, '2026-09-30T13:00:00Z'],
[12.5, '2026-10-11T23:20:00Z'],
[15.0, '2026-10-12T03:20:00Z'],
[17.0, '2026-10-15T01:30:00Z'],
[18.6, '2026-10-15T04:40:00Z'],
[20.6, '2026-10-16T04:40:00Z'],
[22.2, '2026-10-16T07:40:00Z'],
[25.8, '2026-10-17T15:40:00Z'],
[27.6, '2026-10-17T18:10:00Z'],
[29.8, D.ca.t, -14 * 60],
[35.0, D.ca.t, 11 * 60],
[36.4, '2026-10-18T07:00:00Z'],
[40.6, '2026-10-21T10:00:00Z'],
[45.0, '2026-10-21T13:00:00Z'],
].map(([s, iso, off = 0]) => [s, sec(iso) + off]);
function monotone(keys) {
const n = keys.length;
const xs = keys.map((k) => k[0]);
const ys = keys.map((k) => k[1]);
const d = [];
for (let i = 0; i < n - 1; i++) d.push((ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]));
// Geometric-mean slopes keep huge rate swings smooth in log-space.
const m = [d[0]];
for (let i = 1; i < n - 1; i++) m.push(Math.sqrt(d[i - 1] * d[i]));
m.push(d[n - 2]);
for (let i = 0; i < n - 1; i++) {
const a = m[i] / d[i];
const b = m[i + 1] / d[i];
const h = a * a + b * b;
if (h > 9) {
const t = 3 / Math.sqrt(h);
m[i] = t * a * d[i];
m[i + 1] = t * b * d[i];
}
}
const f = (s) => {
let i = 0;
while (i < n - 2 && s > xs[i + 1]) i++;
const hh = xs[i + 1] - xs[i];
const t = Math.min(Math.max((s - xs[i]) / hh, 0), 1);
const t2 = t * t;
const t3 = t2 * t;
return (
(2 * t3 - 3 * t2 + 1) * ys[i] + (t3 - 2 * t2 + t) * hh * m[i] + (-2 * t3 + 3 * t2) * ys[i + 1] + (t3 - t2) * hh * m[i + 1]
);
};
return f;
}
const tau = monotone(TAU_KEYS);
const rate = (s) => (tau(s + 0.01) - tau(s - 0.01)) / 0.02;
const S_SYNC0 = 29.8;
const S_SYNC1 = 35.0;
function hermite(s, s0, y0, m0, s1, y1, m1) {
const h = s1 - s0;
const t = (s - s0) / h;
const t2 = t * t;
const t3 = t2 * t;
return (2 * t3 - 3 * t2 + 1) * y0 + (t3 - 2 * t2 + t) * h * m0 + (-2 * t3 + 3 * t2) * y1 + (t3 - t2) * h * m1;
}
const BG_RATE = 110;
function bclock(s) {
if (s >= S_SYNC0 && s <= S_SYNC1) return tau(s);
if (s < S_SYNC0) {
const y1 = tau(S_SYNC0);
return hermite(s, 0, y1 - S_SYNC0 * BG_RATE * 1.15, BG_RATE, S_SYNC0, y1, rate(S_SYNC0));
}
const y0 = tau(S_SYNC1);
return hermite(s, S_SYNC1, y0, rate(S_SYNC1), DUR, y0 + (DUR - S_SYNC1) * BG_RATE * 1.2, BG_RATE);
}
function sOfTau(t) {
let lo = 0;
let hi = DUR;
for (let k = 0; k < 50; k++) {
const mid = (lo + hi) / 2;
if (tau(mid) < t) lo = mid;
else hi = mid;
}
return lo;
}
// ---------------------------------------------------------------------------
// Story: captions, events, camera
// ---------------------------------------------------------------------------
const fmtKm = (km) => (km >= 100 ? `${Math.round(km).toLocaleString('en-US')}` : km >= 10 ? km.toFixed(0) : km.toFixed(1));
const CAPTIONS = [
{
s0: 0.2, s1: 4.3,
kicker: 'Low Earth orbit · 540 km · <b>Sep 30 – Oct 21, 2026</b>',
title: 'Hunting a radar satellite',
body: 'Over three weeks, Russia\'s military satellite <em class="c-cos">Cosmos 2612</em> stalked <em class="c-x36">ICEYE-X36</em>, a U.S.-registered radar imager whose pictures help Ukraine — and closed to a few hundred meters.',
},
{
s0: 4.5, s1: 12.3,
kicker: '<b>1</b> · Phasing',
title: 'Climb high, fall behind',
body: 'Two burns lift Cosmos 2612 <em>47 km</em> above its target. A higher orbit is slower, so it drifts back <em>~6,500 km a day</em> and laps X36 — while Earth\'s equatorial bulge slowly swings its orbit plane into line.',
},
{
s0: 12.6, s1: 21.9,
kicker: '<b>2</b> · Descent',
title: 'Drop into the same orbit',
body: 'Directly overhead on Oct 12, it drops 47 km into X36\'s orbit, <em>480 km behind</em>. On Oct 15 a 2 km dip makes it slightly faster: it closes <em>~350 km a day</em> — then brakes 3 km behind.',
},
{
s0: 22.1, s1: 29.6,
kicker: '<b>3</b> · Proximity',
title: 'Close enough to inspect',
body: 'For two days it holds station behind X36, its path looping <em>1.6 km</em> each orbit as it creeps forward. These are the maneuvers of an inspector: they need fuel, planning, and sensors that can find the target.',
},
{
s0: 29.8, s1: 35.6,
kicker: '<b>3</b> · Closest approach',
title: 'In Earth\'s shadow',
body: 'Early on Oct 18, during orbital night, it passes about <em>%CA% m</em> behind X36 at under 1 m/s. Published estimates range from <em>150 to 650 m</em>. Nine minutes later, the sun comes up.',
},
{
s0: 35.8, s1: 41.0,
kicker: '<b>4</b> · Departure',
title: 'Back off, and wait',
body: 'A 1.3 m/s burn lifts it 2.4 km and it falls away at <em>~350 km a day</em>. On Oct 19 it matches orbits again — and today it is still parked <em>480 km behind</em>.',
},
];
const BURNS = D.burns.map((b) => ({ ...b, t: sec(b.t) }));
const BURN_NOTES = {
'Phasing raise': '+46 km',
'Plane trim': 'out-of-plane',
'Descent to X36 orbit': '−47 km',
'Closing burn': '−2.3 km',
'Braking burn': '+2.5 km',
'Back-off': '+0.13 km',
'Departure burn': '+2.4 km',
'Re-match orbit': '−2.6 km',
};
const EVENTS = [
...BURNS.map((b) => ({ t: b.t, kind: 'burn', title: `BURN · Δv ${b.dv < 1 ? b.dv.toFixed(2) : b.dv.toFixed(1)} m/s`, sub: `${b.label} · ${BURN_NOTES[b.label] || ''}`, at: 'cosmos' })),
{ t: sec('2026-10-05T11:55:00Z'), kind: 'note', title: 'OVERHEAD PASS', sub: '~50 km above X36', at: 'cosmos' },
{ t: sec('2026-10-12T00:10:00Z'), kind: 'note', title: 'OVERHEAD PASS', sub: '47 km directly above', at: 'cosmos' },
{ t: VANTOR_T, kind: 'vantor', title: 'VANTOR IMAGES THE PAIR', sub: `from orbit · ~${D.vantor.km.toFixed(1)} km apart · Oct 17`, at: 'mid' },
{ t: CA_T, kind: 'ca', title: `CLOSEST APPROACH · ~${Math.round(D.ca.m / 5) * 5} m`, sub: `${D.ca.t.slice(11, 16)} UTC Oct 18 · est. range 150–650 m`, at: 'mid' },
].map((e) => ({ ...e, s: sOfTau(e.t) }));
// Two burns that land in the same instant only show once.
EVENTS.sort((a, b) => a.s - b.s);
// Camera keys, in X36's local frame (R up, T velocity, N orbit normal).
// dist: metres (or auto = f * separation, floored at min). az: 0 = behind X36
// looking forward. look: 0 = X36, 1 = Cosmos. earth: pull aim to Earth centre.
const WIDE = { f: 0, look: 0, earth: 0.86, fov: 40, mag: 0, sun: 0.55 };
const CAM = [
{ s: 0.0, ...WIDE, dist: 5.4e7, az: 82, el: 36, trail: 2 },
{ s: 6.0, ...WIDE, dist: 4.6e7, az: 96, el: 34, trail: 20 },
{ s: 12.5, ...WIDE, dist: 4.4e7, az: 118, el: 30, trail: 30 },
{ s: 12.5, cut: true, dist: 6.0e5, f: 2.8, az: 50, el: 38, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 3 },
{ s: 15.4, dist: 1.6e6, f: 2.8, az: 32, el: 36, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 8 },
{ s: 18.0, dist: 1.4e6, f: 2.8, az: 26, el: 34, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 12 },
{ s: 20.4, dist: 4.0e4, f: 2.8, az: 40, el: 32, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 10 },
{ s: 22.4, dist: 6.5e3, f: 0, az: 58, el: 34, look: 0.45, earth: 0, fov: 36, mag: 1, trail: 9 },
{ s: 26.0, dist: 4.6e3, f: 0, az: 76, el: 30, look: 0.5, earth: 0, fov: 36, mag: 1, trail: 9 },
{ s: 28.8, dist: 2.2e3, f: 0, az: 100, el: 22, look: 0.4, earth: 0, fov: 36, mag: 1, trail: 4 },
{ s: 29.9, dist: 12, f: 0, az: 96, el: 16, look: 0.0, earth: 0, fov: 40, mag: 0, trail: 0.3 },
{ s: 30.8, dist: 13, f: 0, az: 122, el: 14, look: 0.0, earth: 0, fov: 40, mag: 0, trail: 0.3 },
{ s: 31.6, dist: 18, f: 0, az: 168, el: 9, look: 0.0, earth: 0, fov: 40, mag: 0, trail: 0.3, aimRel: [0, -60, 0] },
{ s: 32.6, dist: 1.0e3, f: 0, az: 112, el: 26, look: 0.5, earth: 0, fov: 36, mag: 1, trail: 1.2 },
{ s: 35.0, dist: 1.3e3, f: 0, az: 96, el: 18, look: 0.5, earth: 0, fov: 36, mag: 1, trail: 1.6 },
{ s: 36.6, dist: 1.2e4, f: 2.8, az: 110, el: 30, look: 0.4, earth: 0, fov: 36, mag: 1, trail: 4 },
{ s: 39.0, dist: 9.0e5, f: 2.8, az: 60, el: 30, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 30 },
{ s: 41.2, dist: 2.0e6, f: 2.8, az: 44, el: 30, look: 0.5, earth: 0, fov: 34, mag: 0, trail: 60 },
{ s: 41.2, cut: true, ...WIDE, dist: 4.8e7, az: 80, el: 34, trail: 60 },
{ s: 45.0, ...WIDE, dist: 4.2e7, az: 96, el: 32, trail: 60 },
];
const CUTS = CAM.filter((k) => k.cut).map((k) => k.s);
const smooth = (x) => x * x * x * (x * (x * 6 - 15) + 10);
function camAt(s) {
// Keys are grouped into shots at cut keys; interpolation never crosses a cut.
let start = 0;
for (let k = 1; k < CAM.length; k++) if (CAM[k].cut && s >= CAM[k].s) start = k;
let end = CAM.length - 1;
for (let k = start + 1; k < CAM.length; k++) if (CAM[k].cut) { end = k - 1; break; }
let i = start;
while (i < end - 1 && s > CAM[i + 1].s) i++;
const a = CAM[i];
const b = CAM[Math.min(i + 1, end)];
const u = b === a ? 0 : smooth(Math.min(Math.max((s - a.s) / (b.s - a.s), 0), 1));
const lerp = (x, y) => x + (y - x) * u;
const ar = a.aimRel || [0, 0, 0];
const br = b.aimRel || [0, 0, 0];
return {
dist: Math.exp(lerp(Math.log(a.dist), Math.log(b.dist))),
f: lerp(a.f, b.f),
az: lerp(a.az, b.az),
el: lerp(a.el, b.el),
look: lerp(a.look, b.look),
earth: lerp(a.earth, b.earth),
fov: lerp(a.fov, b.fov),
mag: lerp(a.mag, b.mag),
trail: Math.exp(lerp(Math.log(a.trail), Math.log(b.trail))),
aimRel: [0, 1, 2].map((k) => lerp(ar[k], br[k])),
sun: lerp(a.sun || 0, b.sun || 0),
};
}
// ---------------------------------------------------------------------------
// Cesium scene
// ---------------------------------------------------------------------------
const esri = await Cesium.ArcGisMapServerImageryProvider.fromUrl(
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer',
{ enablePickFeatures: false, maximumLevel: 11 },
);
const viewer = new Cesium.Viewer('globe', {
baseLayer: new Cesium.ImageryLayer(esri, { brightness: 0.92, contrast: 1.08, gamma: 1.05 }),
animation: false, timeline: false, baseLayerPicker: false, geocoder: false, homeButton: false,
sceneModePicker: false, navigationHelpButton: false, fullscreenButton: false, infoBox: false,
selectionIndicator: false, useDefaultRenderLoop: false, shouldAnimate: false,
contextOptions: { webgl: { preserveDrawingBuffer: true, alpha: false } },
msaaSamples: 1,
});
const scene = viewer.scene;
// Night side: NASA Black Marble city lights (keyless GIBS tiles).
const nightLayer = viewer.imageryLayers.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
url: 'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_Black_Marble/default/2016-01-01/GoogleMapsCompatible_Level8/{z}/{y}/{x}.png',
maximumLevel: 8, credit: 'NASA Black Marble',
}));
nightLayer.dayAlpha = 0.0;
nightLayer.nightAlpha = 1.0;
nightLayer.brightness = 1.6;
viewer.imageryLayers.get(0).nightAlpha = 0.0;
// Sky: ESO Milky Way cube (stars removed) + Yale Bright Star Catalogue points.
scene.skyBox = new Cesium.SkyBox({ sources: Object.fromEntries(
[['positiveX', 'px'], ['negativeX', 'nx'], ['positiveY', 'py'], ['negativeY', 'ny'], ['positiveZ', 'pz'], ['negativeZ', 'nz']]
.map(([k, f]) => [k, `/sky/milkyway_${f}.webp`])) });
const starData = await (await fetch('/sky/bright_stars.json')).json();
const starArr = Array.isArray(starData) ? starData : starData.stars || starData.data;
const stars = scene.primitives.add(new Cesium.PointPrimitiveCollection({ blendOption: Cesium.BlendOption.TRANSLUCENT }));
const starDirs = [];
for (let i = 0; i + 3 < starArr.length; i += 4) {
const [ra, dec, vm, bv] = [starArr[i], starArr[i + 1], starArr[i + 2], starArr[i + 3]];
const r = Cesium.Math.toRadians(ra);
const d = Cesium.Math.toRadians(dec);
const dir = new Cesium.Cartesian3(Math.cos(d) * Math.cos(r), Math.cos(d) * Math.sin(r), Math.sin(d));
const b = Math.min(1, Math.sqrt(10 ** (-0.4 * ((Number.isFinite(vm) ? vm : 6.5) - 6.5))) / 9);
const t = Math.min(Math.max(((Number.isFinite(bv) ? bv : 0.6) + 0.3) / 2.0, 0), 1);
const col = new Cesium.Color(0.78 + 0.22 * t, 0.84 + 0.06 * (1 - Math.abs(t - 0.4)), 1.0 - 0.3 * t, 0.25 + 0.75 * b);
starDirs.push(dir);
stars.add({ position: dir, color: col, pixelSize: 0.9 + 2.6 * b });
}
const globe = scene.globe;
globe.enableLighting = true;
globe.dynamicAtmosphereLighting = true;
globe.dynamicAtmosphereLightingFromSun = true;
globe.showGroundAtmosphere = true;
globe.atmosphereLightIntensity = 9;
globe.lightingFadeOutDistance = 1;
globe.lightingFadeInDistance = 2;
globe.nightFadeOutDistance = 1e10;
globe.nightFadeInDistance = 1e10;
globe.maximumScreenSpaceError = 1.6;
globe.tileCacheSize = 1500;
globe.preloadSiblings = false;
globe.baseColor = Cesium.Color.fromCssColorString('#04070d');
scene.fog.enabled = false;
scene.skyAtmosphere.show = true;
scene.skyAtmosphere.atmosphereLightIntensity = 10;
scene.highDynamicRange = false;
scene.sun.glowFactor = 1.6;
scene.moon.show = true;
scene.postProcessStages.fxaa.enabled = true;
scene.backgroundColor = Cesium.Color.BLACK;
scene.screenSpaceCameraController.enableInputs = false;
scene.camera.frustum.near = 0.5;
scene.camera.frustum.far = 1e9;
viewer.resolutionScale = 1;
const bloom = scene.postProcessStages.bloom;
bloom.enabled = false;
bloom.uniforms.contrast = 119;
bloom.uniforms.brightness = -0.42;
bloom.uniforms.glowOnly = false;
bloom.uniforms.delta = 1.0;
bloom.uniforms.sigma = 3.2;
bloom.uniforms.stepSize = 1.0;
const COL_X36 = Cesium.Color.fromCssColorString('#5ce1e6');
const COL_COS = Cesium.Color.fromCssColorString('#ff5a3c');
const COL_BURN = Cesium.Color.fromCssColorString('#ffb547');
const modelOpts = { upAxis: Cesium.Axis.Z, forwardAxis: Cesium.Axis.X, shadows: Cesium.ShadowMode.DISABLED };
const mX36 = await Cesium.Model.fromGltfAsync({ url: 'iceye-x36.glb', ...modelOpts });
const mCos = await Cesium.Model.fromGltfAsync({ url: 'cosmos-2612.glb', ...modelOpts });
scene.primitives.add(mX36);
scene.primitives.add(mCos);
for (const m of [mX36, mCos]) {
m.environmentMapOptions = { enabled: false };
m.imageBasedLighting.sphericalHarmonicCoefficients = undefined;
}
function glowCanvas(color, core = 0.18) {
const c = document.createElement('canvas');
c.width = c.height = 128;
const g = c.getContext('2d');
const grd = g.createRadialGradient(64, 64, 0, 64, 64, 64);
grd.addColorStop(0, 'rgba(255,255,255,1)');
grd.addColorStop(core, color.withAlpha(1).toCssColorString());
grd.addColorStop(0.45, color.withAlpha(0.28).toCssColorString());
grd.addColorStop(1, color.withAlpha(0).toCssColorString());
g.fillStyle = grd;
g.fillRect(0, 0, 128, 128);
return c;
}
const billboards = scene.primitives.add(new Cesium.BillboardCollection({ scene }));
const bX36 = billboards.add({ image: glowCanvas(COL_X36), scale: 0.3 * U, disableDepthTestDistance: 0 });
const bCos = billboards.add({ image: glowCanvas(COL_COS), scale: 0.3 * U, disableDepthTestDistance: 0 });
const bBurn = billboards.add({ image: glowCanvas(COL_BURN, 0.08), scale: 0, disableDepthTestDistance: 0 });
let dynamic = [];
function clearDynamic() {
for (const p of dynamic) scene.primitives.remove(p);
dynamic = [];
}
function polyline(positions, colors, width) {
if (positions.length < 2) return;
const p = new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.PolylineGeometry({
positions, colors, colorsPerVertex: true, width: width * U, arcType: Cesium.ArcType.NONE,
}),
}),
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
asynchronous: false,
});
scene.primitives.add(p);
dynamic.push(p);
}
// ---------------------------------------------------------------------------
// HUD
// ---------------------------------------------------------------------------
const $ = (q) => document.querySelector(q);
const capEl = $('#caption');
const tagsEl = $('#tags');
const leaders = $('#leaders');
const callEl = $('#callouts');
leaders.setAttribute('viewBox', `0 0 ${W} ${H}`);
function fade(s, s0, s1, ramp = 0.45) {
return Math.min(1, Math.max(0, (s - s0) / ramp), Math.max(0, (s1 - s) / ramp));
}
function fmtRate(r) {
const p = 10 ** Math.floor(Math.log10(r) - 1);
const rr = Math.round(r / p) * p;
const real = rr;
let per;
if (real < 60) per = `${real.toFixed(0)} s`;
else if (real < 3600) per = `${(real / 60).toFixed(real < 600 ? 1 : 0)} min`;
else if (real < 86400) per = `${(real / 3600).toFixed(real < 36000 ? 1 : 0)} h`;
else per = `${(real / 86400).toFixed(1)} days`;
return `<span>×</span>${rr.toLocaleString('en-US')} <span>· 1 s = ${per}</span>`;
}
// Separation chart (log scale), drawn once and revealed by a clip.
const CH = { t0: sec('2026-09-30T00:00:00Z'), t1: sec('2026-10-21T18:00:00Z'), lo: 0.1, hi: 20000 };
const chartBox = $('#chart');
function buildChart() {
const cw = chartBox.clientWidth;
const ch = chartBox.clientHeight;
const padL = 70 * U;
const padT = 34 * U;
const padB = 26 * U;
const x = (t) => padL + ((t - CH.t0) / (CH.t1 - CH.t0)) * (cw - padL);
const y = (km) => padT + (1 - (Math.log10(km) - Math.log10(CH.lo)) / (Math.log10(CH.hi) - Math.log10(CH.lo))) * (ch - padT - padB);
const cols = Math.round(cw - padL);
let lo = '';
let hi = '';
const ptsHi = [];
for (let c = 0; c <= cols; c++) {
const ta = CH.t0 + (c / cols) * (CH.t1 - CH.t0);
const tb = CH.t0 + ((c + 1) / cols) * (CH.t1 - CH.t0);
let mn = Infinity;
let mx = 0;
for (let i = Math.floor(ta / DT); i <= Math.ceil(tb / DT); i++) {
const v = D.sep_km[Math.min(Math.max(i, 0), NS - 1)];
mn = Math.min(mn, v);
mx = Math.max(mx, v);
}
mn = Math.max(mn, CH.lo);
lo += `${c ? 'L' : 'M'}${(padL + c).toFixed(1)},${y(mn).toFixed(1)}`;
ptsHi.push(`${(padL + c).toFixed(1)},${y(mx).toFixed(1)}`);
}
hi = 'L' + ptsHi.reverse().join('L');
const grid = [0.1, 1, 10, 100, 1000, 10000]
.map((v) => {
const lab = v < 1 ? '100 m' : v < 1000 ? `${v} km` : `${(v).toLocaleString('en-US')} km`;
return `<line x1="${padL}" x2="${cw}" y1="${y(v)}" y2="${y(v)}" stroke="rgba(238,242,246,0.10)" stroke-width="1"/>
<text x="${padL - 10 * U}" y="${y(v) + 4 * U}" text-anchor="end" font-size="${12 * U}" fill="rgba(238,242,246,0.45)">${lab}</text>`;
})
.join('');
const days = [];
for (let d = 0; d <= 21; d++) {
const t = sec(`2026-${d < 1 ? '09-30' : `10-${String(d).padStart(2, '0')}`}T00:00:00Z`);
const major = d === 0 || d % (PORTRAIT ? 7 : 3) === 0;
days.push(`<line x1="${x(t)}" x2="${x(t)}" y1="${ch - padB}" y2="${ch - padB + (major ? 6 : 3) * U}" stroke="rgba(238,242,246,0.35)"/>`);
if (major) {
days.push(`<text x="${x(t)}" y="${ch - padB + 20 * U}" text-anchor="middle" font-size="${12 * U}" fill="rgba(238,242,246,0.5)">${d === 0 ? 'SEP 30' : `OCT ${d}`}</text>`);
}
}
const burns = BURNS.map(
(b) => `<g class="burn" data-t="${b.t}"><path d="M${x(b.t)},${padT - 12 * U} l${-5 * U},${-9 * U} h${10 * U} z" fill="#ffb547"/>
<line x1="${x(b.t)}" x2="${x(b.t)}" y1="${padT - 10 * U}" y2="${ch - padB}" stroke="rgba(255,181,71,0.22)" stroke-dasharray="${2 * U} ${3 * U}"/></g>`,
).join('');
const caX = x(CA_T);
const caY = y(D.ca.m / 1000);
chartBox.innerHTML = `<svg viewBox="0 0 ${cw} ${ch}">
<defs><clipPath id="past"><rect id="pastRect" x="0" y="-40" width="0" height="${ch + 80}"/></clipPath></defs>
<text x="${padL}" y="${padT - 26 * U}" font-size="${12 * U}" letter-spacing="0.14em" fill="rgba(238,242,246,0.62)">DISTANCE BETWEEN THE SATELLITES · LOG SCALE</text>
<text x="${cw}" y="${padT - 26 * U}" text-anchor="end" font-size="${12 * U}" letter-spacing="0.1em" fill="rgba(255,181,71,0.85)">▼ BURN</text>
${grid}${days.join('')}${burns}
<path d="${lo}${hi}Z" fill="rgba(255,90,60,0.07)"/>
<path d="${lo}" fill="none" stroke="rgba(255,90,60,0.22)" stroke-width="${1.4 * U}"/>
<g clip-path="url(#past)">
<path d="${lo}${hi}Z" fill="rgba(255,90,60,0.28)"/>
<path d="${lo}" fill="none" stroke="#ff5a3c" stroke-width="${2 * U}"/>
</g>
<g id="caMark" opacity="0"><circle cx="${caX}" cy="${caY}" r="${5 * U}" fill="none" stroke="#fff" stroke-width="${1.5 * U}"/>
<text x="${caX - 10 * U}" y="${caY + 4 * U}" text-anchor="end" font-size="${12 * U}" fill="#fff">~${Math.round(D.ca.m / 5) * 5} m</text></g>
<line id="playhead" x1="0" x2="0" y1="${padT - 4 * U}" y2="${ch - padB}" stroke="#fff" stroke-width="${1.2 * U}"/>
<circle id="playdot" r="${4.5 * U}" fill="#fff"/>
</svg>`;
return { x, y };
}
const chart = buildChart();
function tag(id, cls) {
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
el.className = `tag ${cls}`;
el.innerHTML = '<div class="name"></div><div class="meta"></div>';
tagsEl.appendChild(el);
}
return el;
}
function project(world) {
const p = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world);
if (!p) return null;
// Behind-camera / occluded test
const cam = scene.camera;
const d = v3.subtract(world, cam.positionWC, new v3());
if (v3.dot(d, cam.directionWC) <= 0) return null;
const ray = v3.normalize(d, new v3());
const hit = Cesium.IntersectionTests.rayEllipsoid(new Cesium.Ray(cam.positionWC, ray), Cesium.Ellipsoid.WGS84);
if (hit && hit.start * 1.0005 < v3.magnitude(d)) return { ...p, hidden: true };
return p;
}
// ---------------------------------------------------------------------------
// Frame
// ---------------------------------------------------------------------------
const M = new Cesium.Matrix3();
function toWorld(teme, jd) {
return Cesium.Matrix3.multiplyByVector(M, teme, new v3());
}
function modelMatrix(pos, X, Y, scale) {
const Z = v3.cross(X, Y, new v3());
const m = new Cesium.Matrix4(
X.x * scale, Y.x * scale, Z.x * scale, pos.x,
X.y * scale, Y.y * scale, Z.y * scale, pos.y,
X.z * scale, Y.z * scale, Z.z * scale, pos.z,
0, 0, 0, 1,
);
return m;
}
function eclipseLight(worldPos, sunW) {
// Cylindrical shadow with a soft edge.
const p = v3.dot(worldPos, sunW);
if (p > 0) return 1;
const perp = v3.magnitude(v3.subtract(worldPos, v3.multiplyByScalar(sunW, p, new v3()), new v3()));
const e = (perp - 6.371e6) / 6e4;
return Math.min(1, Math.max(0, 0.5 + e));
}
async function frame(s) {
const t = tau(s);
const b = bclock(s);
const jd = Cesium.JulianDate.addSeconds(Cesium.JulianDate.fromIso8601(D.t0), b, new Cesium.JulianDate());
Cesium.Transforms.computeTemeToPseudoFixedMatrix(jd, M);
const cam = camAt(s);
// Anchor (X36 at backdrop time) and relative state at simulation time.
const A = x36Teme(b);
const B = basis(A.r, A.v);
const rel = relAt(t);
const x36W = toWorld(A.r);
const cosW = toWorld(place(A.r, B, rel));
const RW = toWorld(B.R);
const TW = toWorld(B.T);
const NW = toWorld(B.N);
const sep = v3.distance(x36W, cosW);
// Camera
const sunW = v3.normalize(Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(jd, new v3()), new v3());
Cesium.Matrix3.multiplyByVector(Cesium.Transforms.computeIcrfToFixedMatrix(jd) || M, sunW, sunW);
v3.normalize(sunW, sunW);
// Camera orbits a pivot on the X36 -> Cosmos line; aimRel only tilts the gaze.
const pivot = v3.lerp(x36W, cosW, cam.look, new v3());
v3.lerp(pivot, v3.ZERO, cam.earth, pivot);
const aim = v3.clone(pivot);
v3.add(aim, v3.multiplyByScalar(RW, cam.aimRel[0], new v3()), aim);
v3.add(aim, v3.multiplyByScalar(TW, cam.aimRel[1], new v3()), aim);
v3.add(aim, v3.multiplyByScalar(NW, cam.aimRel[2], new v3()), aim);
const dist = Math.max(cam.dist, cam.f * sep);
const az = Cesium.Math.toRadians(cam.az);
const el = Cesium.Math.toRadians(cam.el);
const off = new v3();
v3.add(off, v3.multiplyByScalar(TW, -Math.cos(el) * Math.cos(az), new v3()), off);
v3.add(off, v3.multiplyByScalar(NW, Math.cos(el) * Math.sin(az), new v3()), off);
v3.add(off, v3.multiplyByScalar(RW, Math.sin(el), new v3()), off);
if (cam.sun > 0) {
v3.normalize(v3.add(v3.multiplyByScalar(off, 1 - cam.sun, off), v3.multiplyByScalar(sunW, cam.sun, new v3()), off), off);
}
const camPos = v3.add(pivot, v3.multiplyByScalar(off, dist, new v3()), new v3());
const minR = 6.371e6 + 1.2e5;
if (v3.magnitude(camPos) < minR) v3.multiplyByScalar(v3.normalize(camPos, camPos), minR, camPos);
const dir = v3.normalize(v3.subtract(aim, camPos, new v3()), new v3());
const right = v3.normalize(v3.cross(dir, RW, new v3()), new v3());
const up = v3.cross(right, dir, new v3());
scene.camera.setView({ destination: camPos, orientation: { direction: dir, up } });
scene.camera.frustum.fov = Cesium.Math.toRadians(PORTRAIT ? cam.fov * 1.55 : cam.fov);
{
const icrf = Cesium.Transforms.computeIcrfToFixedMatrix(jd) || M;
const tmp = new v3();
for (let i = 0; i < starDirs.length; i++) {
Cesium.Matrix3.multiplyByVector(icrf, starDirs[i], tmp);
stars.get(i).position = v3.add(camPos, v3.multiplyByScalar(tmp, 3e8, tmp), new v3());
}
}
scene.camera.frustum.near = Math.max(0.2, Math.min(dist * 0.02, 1000));
// Models: attitude, magnification, lighting
const camDistX = v3.distance(camPos, x36W);
const camDistC = v3.distance(camPos, cosW);
const pxPerRad = (PORTRAIT ? W : W) / (2 * Math.tan(scene.camera.frustum.fov / 2));
const targetPx = 110 * U; // magnified apparent size
const magFor = (d, size) => Math.max(1, (targetPx * d) / (pxPerRad * size));
const magX = 1 + (magFor(camDistX, 3.25) - 1) * cam.mag;
const magC = 1 + (magFor(camDistC, 3.6) - 1) * cam.mag;
const MAG_MAX = 150; // beyond this, a glyph is more honest than a giant model
const rawMag = Math.max(1, Math.min(magX, magC));
const mag = rawMag > MAG_MAX ? 1 : rawMag;
const nearScale = Math.min(1, Math.max(0, cam.mag * 2));
mX36.show = (cam.mag > 0.01 && rawMag <= MAG_MAX) || camDistX < 3000;
mCos.show = (cam.mag > 0.01 && rawMag <= MAG_MAX) || camDistC < 6000;
mX36.modelMatrix = modelMatrix(x36W, TW, RW, mag);
// Cosmos points its sensor at X36 when close, otherwise flies prograde.
const toX = v3.normalize(v3.subtract(x36W, cosW, new v3()), new v3());
const w = Math.min(1, Math.max(0, (60e3 - sep) / 40e3));
const cx = v3.normalize(v3.lerp(TW, toX, w, new v3()), new v3());
const cy = v3.normalize(v3.subtract(RW, v3.multiplyByScalar(cx, v3.dot(RW, cx), new v3()), new v3()), new v3());
mCos.modelMatrix = modelMatrix(cosW, cx, cy, mag);
const lx = eclipseLight(x36W, sunW);
const lc = eclipseLight(cosW, sunW);
// In Earth's shadow a dim blue fill keeps the shapes legible.
const lightFor = (l) => new v3(0.6 + (2.3 - 0.6) * l, 0.66 + (2.3 - 0.66) * l, 0.92 + (2.3 - 0.92) * l);
mX36.lightColor = lightFor(lx);
mCos.lightColor = lightFor(lc);
// Glows: shown when the model is too small to read.
const pxX = (3.25 * mag * pxPerRad) / camDistX;
const pxC = (3.6 * mag * pxPerRad) / camDistC;
bX36.position = x36W;
bCos.position = cosW;
bX36.scale = (pxX > 24 ? 0.12 : 0.28) * U;
bCos.scale = (pxC > 24 ? 0.12 : 0.28) * U;
bX36.color = Cesium.Color.WHITE.withAlpha(pxX > 40 ? 0 : 1);
bCos.color = Cesium.Color.WHITE.withAlpha(pxC > 40 ? 0 : 1);
// Burn flare
let flare = 0;
for (const bn of BURNS) {
const sb = sOfTauCached(bn.t);
const k = (s - sb) / 0.9;
if (k > -0.08 && k < 1) flare = Math.max(flare, k < 0 ? 1 + k / 0.08 : (1 - k) ** 1.6);
}
bBurn.position = cosW;
bBurn.scale = flare * 0.9 * U;
bBurn.color = Cesium.Color.WHITE.withAlpha(flare);
// Trails and reference geometry
clearDynamic();
// X36 orbit ring (inertial, at the anchor epoch); fades out up close.
const ringA = Math.min(1, Math.max(0, (camDistX - 1.5e4) / 6e4));
if (ringA > 0.01) {
const P = 5730;
const pts = [];
const cols = [];
const n = 240;
for (let k = 0; k <= n; k++) {
const tt = b - P + (k / n) * P;
pts.push(toWorld(x36Teme(tt).r));
const a = 0.06 + 0.5 * (k / n) ** 3;
cols.push(COL_X36.withAlpha(a * ringA));
}
polyline(pts, cols, 1.6);
}
// Cosmos relative trail, expressed in the current anchor frame
{
const span = cam.trail * 3600;
const n = Math.min(900, Math.max(60, Math.round(span / 45)));
const pts = [];
const cols = [];
for (let k = 0; k <= n; k++) {
const tt = t - span + (k / n) * span;
if (tt < 0) continue;
pts.push(toWorld(place(A.r, B, relAt(tt))));
cols.push(COL_COS.withAlpha(0.04 + 0.92 * (k / n) ** 1.8));
}
pts.push(cosW);
cols.push(COL_COS.withAlpha(1));
polyline(pts, cols, 2.4);
}
// Separation line when the pair is within view range
if (sep < 900e3) {
const a = Math.min(1, (900e3 - sep) / 200e3) * 0.55;
polyline([x36W, cosW], [Cesium.Color.WHITE.withAlpha(a * 0.25), Cesium.Color.WHITE.withAlpha(a * 0.25)], 1);
}
// RIC reference rings (orbital plane) during proximity
const gridA = Math.min(1, Math.max(0, (8e3 - Math.max(sep, camDistX)) / 3e3)) * (cam.mag > 0.4 ? 1 : 0.35);
if (gridA > 0.01) {
for (const r of [500, 1000, 2000]) {
const pts = [];
const cols = [];
for (let k = 0; k <= 120; k++) {
const a = (k / 120) * Math.PI * 2;
const p = v3.add(x36W, v3.multiplyByScalar(RW, r * Math.sin(a), new v3()), new v3());
v3.add(p, v3.multiplyByScalar(TW, r * Math.cos(a), new v3()), p);
pts.push(p);
cols.push(Cesium.Color.WHITE.withAlpha(0.16 * gridA));
}
polyline(pts, cols, 1);
}
const ax = (vec, len, col) => polyline(
[v3.add(x36W, v3.multiplyByScalar(vec, -len, new v3()), new v3()), v3.add(x36W, v3.multiplyByScalar(vec, len, new v3()), new v3())],
[col.withAlpha(0), col.withAlpha(0)], 1,
);
polyline([x36W, v3.add(x36W, v3.multiplyByScalar(TW, 2600, new v3()), new v3())], [COL_X36.withAlpha(0.5 * gridA), COL_X36.withAlpha(0)], 1.5);
polyline([x36W, v3.add(x36W, v3.multiplyByScalar(RW, 1400, new v3()), new v3())], [Cesium.Color.WHITE.withAlpha(0.4 * gridA), Cesium.Color.WHITE.withAlpha(0)], 1.5);
void ax;
}
updateHud(s, t, b, { x36W, cosW, RW, TW, NW, rel, sep, mag, gridA, lx, camDistX, jd });
// Render; re-render only while imagery is still streaming in.
scene.render(jd);
for (let k = 0; k < 60 && !(globe.tilesLoaded && mX36.ready && mCos.ready); k++) {
await new Promise((r) => setTimeout(r, 30));
scene.render(jd);
}
const gl = scene.context._gl;
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4));
const dbg = { pX: Cesium.SceneTransforms.worldToWindowCoordinates(scene, x36W), dX: v3.distance(scene.camera.positionWC, x36W), ang: Cesium.Math.toDegrees(v3.angleBetween(scene.camera.directionWC, v3.subtract(x36W, scene.camera.positionWC, new v3()))), mshow: mX36.show, mm: Cesium.Matrix4.getTranslation(mX36.modelMatrix, new v3()), x36W };
return { dbg, s, t, b, sep, mag, camR: v3.magnitude(camPos), dist, fov: scene.camera.frustum.fov, fovy: scene.camera.frustum.fovy, aspect: scene.camera.frustum.aspectRatio };
}
const sOfTauMemo = new Map();
function sOfTauCached(t) {
if (!sOfTauMemo.has(t)) sOfTauMemo.set(t, sOfTau(t));
return sOfTauMemo.get(t);
}
function fmtSep(m) {
if (m >= 10e3) return `${fmtKm(m / 1000)}<small>km</small>`;
if (m >= 1000) return `${(m / 1000).toFixed(2)}<small>km</small>`;
return `${Math.round(m)}<small>m</small>`;
}
function fmtComp(m) {
const a = Math.abs(m);
return a >= 10e3 ? `${fmtKm(a / 1000)} km` : a >= 1000 ? `${(a / 1000).toFixed(2)} km` : `${Math.round(a)} m`;
}
function updateHud(s, t, b, st) {
// Caption
let cap = null;
let capA = 0;
for (const c of CAPTIONS) {
const a = fade(s, c.s0, c.s1);
if (a > capA) {
cap = c;
capA = a;
}
}
if (cap) {
capEl.querySelector('.kicker').innerHTML = cap.kicker;
capEl.querySelector('h1').innerHTML = cap.title;
capEl.querySelector('p').innerHTML = cap.body.replace('%CA%', String(Math.round(D.ca.m / 5) * 5));
}
capEl.style.opacity = capA;
capEl.style.transform = `translateY(${(1 - capA) * 10 * U}px)`;
// Clock
const d = new Date(T0 + t * 1000);
const mon = d.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }).toUpperCase();
$('#clock .date').textContent = `${mon} ${d.getUTCDate()}, 2026`;
$('#clock .time').textContent = `${d.toISOString().slice(11, 16)} UTC`;
$('#clock .rate').innerHTML = fmtRate(rate(s));
const inShadow = st.lx < 0.5 && st.sep < 5e3;
$('#clock .shadow').textContent = inShadow ? '● IN EARTH\'S SHADOW' : '';
const endA = fade(s, 41.2, 60, 0.6);
$('#clock').style.opacity = 1 - endA;
// Readout
const ro = $('#readout');
ro.style.opacity = fade(s, 0.6, 41.2, 0.5);
ro.querySelector('.val').innerHTML = fmtSep(st.sep);
const [r, tt, n] = st.rel;
if (st.sep < 2e6) {
ro.querySelector('.ric').innerHTML =
`<b>${fmtComp(tt)}</b> ${tt < 0 ? 'behind' : 'ahead'} · <b>${fmtComp(r)}</b> ${r >= 0 ? 'above' : 'below'} · <b>${fmtComp(n)}</b> aside`;
} else {
const deg = (Math.abs(((tt / Cesium.Cartesian3.magnitude(x36Teme(b).r)) * 180) / Math.PI) % 360);
ro.querySelector('.ric').innerHTML = `<b>${(deg > 180 ? 360 - deg : deg).toFixed(0)}°</b> apart along the orbit · <b>${fmtComp(r)}</b> ${r >= 0 ? 'higher' : 'lower'}`;
}
// Magnification note
const magEl = $('#mag');
magEl.style.opacity = st.mag > 1.5 ? fade(s, 0, 41, 0.4) : st.mag <= 1.05 && st.camDistX < 200 ? fade(s, 0, 41, 0.4) : 0;
magEl.innerHTML = st.mag > 1.5
? `Spacecraft drawn ${Math.round(st.mag / 5) * 5 || 1}× actual size<br>Rings: 500 m · 1 km · 2 km in X36's orbit plane`
: 'Spacecraft at true scale<br>Cosmos 2612 model is illustrative — no public design imagery';
// Chart
const px = chart.x(Math.min(Math.max(t, CH.t0), CH.t1));
const sepKm = Math.max(sepAt(t), CH.lo);
document.getElementById('pastRect').setAttribute('width', px);
const ph = document.getElementById('playhead');
ph.setAttribute('x1', px);
ph.setAttribute('x2', px);
const pd = document.getElementById('playdot');
pd.setAttribute('cx', px);
pd.setAttribute('cy', chart.y(sepKm));
document.getElementById('caMark').setAttribute('opacity', t > CA_T ? 1 : 0);
for (const g of chartBox.querySelectorAll('.burn')) g.style.opacity = Number(g.dataset.t) <= t ? 1 : 0.28;
chartBox.style.opacity = fade(s, 0.6, 41.2, 0.5);
// Satellite tags
const pX = project(st.x36W);
const pC = project(st.cosW);
const tX = tag('tagX', 'x36');
const tC = tag('tagC', 'cos');
tX.querySelector('.name').innerHTML = '<span class="c-x36">ICEYE-X36</span>';
tX.querySelector('.meta').textContent = 'SAR imager · U.S.-registered';
tC.querySelector('.name').innerHTML = '<span class="c-cos">COSMOS 2612</span>';
tC.querySelector('.meta').textContent = 'Russian military · launched Apr 2026';
const tagA = fade(s, 0.8, 41.2, 0.5);
let lines = '';
const place2 = (el, p, dx, dy, color) => {
if (!p || p.x < -50 || p.y < -50 || p.x > W + 50 || p.y > H + 50) {
el.style.opacity = 0;
return;
}
const a = tagA * (p.hidden ? 0.35 : 1);
el.style.opacity = a;
const lx = p.x + dx;
const ly = p.y + dy;
el.style.left = `${dx >= 0 ? lx + 8 * U : lx - 8 * U}px`;
el.style.top = `${ly}px`;
el.style.transform = dx >= 0 ? 'translate(0,-50%)' : 'translate(-100%,-50%)';
el.style.textAlign = dx >= 0 ? 'left' : 'right';
lines += `<polyline points="${p.x + Math.sign(dx) * 9 * U},${p.y + Math.sign(dy) * 9 * U} ${lx},${ly}" fill="none" stroke="${color}" stroke-opacity="${a * 0.7}" stroke-width="${1.2 * U}"/>`;
};
let dX = [46 * U, -40 * U];
let dC = [-46 * U, 40 * U];
if (pX && pC) {
// Put each label on the side away from the other satellite.
const vx = pC.x - pX.x;
const vy = pC.y - pX.y;
const sx = vx >= 0 ? -1 : 1;
dX = [sx * 46 * U, (vy >= 0 ? -1 : 1) * 40 * U];
dC = [-sx * 46 * U, (vy >= 0 ? 1 : -1) * 40 * U];
}
place2(tX, pX, dX[0], dX[1], '#5ce1e6');
place2(tC, pC, dC[0], dC[1], '#ff5a3c');
leaders.innerHTML = lines;
// Event log under the caption: never collides with the scene labels.
let html = '';
for (const e of EVENTS) {
const hold = e.kind === 'ca' ? 4.4 : e.kind === 'vantor' ? 2.8 : 1.9;
const a = fade(s, e.s, e.s + hold, 0.25);
if (a <= 0) continue;
const color = e.kind === 'burn' ? '#ffb547' : e.kind === 'ca' ? '#ffffff' : e.kind === 'vantor' ? '#c9b8ff' : '#eef2f6';
html += `<div class="event" style="opacity:${a};color:${color}"><i style="background:${color}"></i>${e.title}<div class="sub">${e.sub}</div></div>`;
}
callEl.innerHTML = html;
// Short dip through black at hard cuts.
let dip = 0;
for (const c of CUTS) dip = Math.max(dip, 1 - Math.abs(s - c) / 0.14);
$('#dip').style.opacity = Math.max(0, dip);
// End card
const ec = $('#endcard');
ec.style.opacity = endA;
ec.style.background = `rgba(2,4,8,${0.78 * endA})`;
if (!ec.dataset.built) {
ec.dataset.built = '1';
ec.innerHTML = `<div class="panel">
<h2>22 days, 8 burns, and a pass of about <span class="c-cos">${Math.round(D.ca.m / 5) * 5} m</span></h2>
<div class="stats">
<div class="stat"><div class="n mono">~${Math.round(D.total_dv)} m/s</div><div class="l">total Δv spent by Cosmos 2612 (est.)</div></div>
<div class="stat"><div class="n mono">35+ h</div><div class="l">within ~3 km of ICEYE-X36</div></div>
<div class="stat"><div class="n mono">0.002°</div><div class="l">final difference in orbit planes</div></div>
<div class="stat"><div class="n mono">480 km</div><div class="l">behind X36 today, matched orbit</div></div>
</div>
<div class="src"><b>Reconstruction, not tracking data.</b> ICEYE-X36: SGP4 on public CelesTrak element sets (NORAD 59103). Cosmos 2612 (68762): relative-orbit model with burns timed to published analyses by KeepTrack, LeoLabs and Marco Langbroek, pinned to today's elements. Closest-approach estimates range 150–650 m.
Earth: Esri World Imagery; the Earth below is slowed during time-lapse. Vantor image: Axios, Oct 22. Spacecraft models: ICEYE to published dimensions; Cosmos 2612 illustrative.</div>
</div>`;
}
}
await document.fonts.ready;
window.rpo = { frame, tau, bclock, duration: DUR, ready: true, CAM };
+6
View File
@@ -0,0 +1,6 @@
COSMOS 2612
1 68762U 26083H 26297.29186266 .00002093 00000+0 14092-3 0 9996
2 68762 97.8545 77.8974 0006461 247.4186 112.6358 15.07858138 28631
ICEYE-X36
1 59103U 24043F 26297.22477300 .00003484 00000+0 23160-3 0 9995
2 59103 97.8537 77.8281 0004894 236.2883 123.7878 15.07863566144584