Terrain Height Service
Issue: #744 Parent: #429 (Planetary terrain system) Depends on: #743 (Terrain data pipeline) Blocks: #745 (Landing mechanics)
Summary
Add server-side terrain height computation to the physics service so collision detection uses actual terrain elevation instead of mean body radius. Expose terrain height via gRPC for client AGL (above ground level) altitude display.
Architecture
Terrain Height Module
New module services/physics/src/terrain.py containing:
- SimplexNoise — Python port of the JavaScript implementation (same algorithm, same seed → same output)
- fbm / craterNoise — Fractal Brownian motion and Voronoi crater functions
- TerrainHeightmap — Loads and bicubic-samples LOLA heightmap (same Catmull-Rom interpolation as client)
- TerrainProfile — Per-body terrain parameters (Luna profile matches client)
- terrain_height() — Main entry point:
(body_name, nx, ny, nz) → elevation_meters
The module uses the same algorithms as the web client’s TerrainNoise.js and TerrainHeightmap.js to ensure server/client consistency. Minor floating-point differences between JavaScript and Python are acceptable (< 1m).
Heightmap Data
The LOLA heightmap binary (luna_heightmap.bin, 33.2 MB) is built at Docker image time using the same scripts/terrain-data/build_luna_heightmap.py script. It is copied into the physics service image at /app/terrain/luna_heightmap.bin.
Coordinate System
Same as the client terrain pipeline (see terrain-data-pipeline.md):
- Input: unit sphere normal
(nx, ny, nz)in body-local ICRF coordinates - Y = polar axis (north pole), Z = prime meridian (0° lon), X = 90° E
- The physics service converts ship position relative to body center into a unit normal, then queries terrain height
Body Rotation
To get the correct body-local normal from ICRF positions, the physics service must account for body rotation at the current simulation time.
The rotation uses quaternion math that mirrors the web client’s Three.js body quaternion construction exactly:
- Convert ICRF direction to Three.js coordinates:
(x, y, z) → (x, z, -y) - Convert spin axis to Three.js:
(sx, sy, sz) → (sx, sz, -sy)and normalize - Build
tiltQuat = setFromUnitVectors((0,1,0), spinAxis_tj)— shortest rotation from ecliptic up to spin axis - Build
spinQuat = setFromAxisAngle(spinAxis_tj, W₀ + ωt)— body rotation bodyQuat = spinQuat × tiltQuat(forward: body-local → world)- Apply
inverse(bodyQuat)to Three.js direction → body-local normal
This ensures exact numerical parity with the client. The previous Rodrigues implementation was mathematically equivalent but introduced ~90° intermediate rotations for Luna (whose spin axis is nearly ecliptic north). The FBM terrain noise at 256× frequency amplified tiny numerical differences into ~88m elevation mismatches.
Parameters:
spin_axis— unit vector in ICRF (ecliptic coords) fromBODY_SPIN_AXESrotation_period(sidereal, in seconds)prime_meridian_at_epoch(IAU W₀ in degrees)- Current simulation time (elapsed since J2000 epoch)
Changes
1. New file: services/physics/src/terrain.py
Python implementations of:
SimplexNoiseclass (deterministic 3D simplex noise, seed-based)fbm()— multi-octave fractal noisecrater_noise()— Voronoi-based crater displacementTerrainHeightmapclass — loads binary Int16 heightmap, bicubic samplingTerrainProfiledataclass — per-body parametersLUNA_PROFILE— matches client’sTerrainProfiles.jsLUNA objectterrain_height(body_name, nx, ny, nz)— returns elevation in metersget_terrain_height_at_position(body_name, body, ship_position, sim_time)— converts ICRF position to body-local normal, accounts for body rotation, returns elevation
2. Modified: services/physics/src/simulation.py
Collision detection (lines 402–412): Replace mean-radius check with terrain-aware check:
# Before (mean radius):
if dist_sq < body.radius * body.radius:
# After (terrain-aware):
surface_radius = body.radius + get_terrain_elevation(body.name, body, ship.position, sim_time)
if dist_sq < surface_radius * surface_radius:
Only bodies with terrain profiles get terrain-aware collision. Others keep the mean-radius check.
Landed ships: For ships resting on a body surface, terrain height is computed from the stored body-local surface normal (not from the ship’s ICRF position). This avoids a one-tick lag where the body’s orbital movement shifts the ICRF→body-local direction slightly, causing terrain height lookup at the wrong surface point.
# Landed ship terrain (use stored body-local normal directly):
elev = terrain_svc.terrain_height(body.name, ship.landed_surface_normal_x, ...)
# Flying ship collision (use ICRF position — both positions are same-tick):
elev = terrain_svc.terrain_height_at_position(body.name, body, ship.position, sim_time)
Initialization: Load heightmap data once at service startup (in Simulation.__init__ or on first use). The heightmap stays in memory for the lifetime of the process (~33 MB for Luna).
3. Modified: services/physics/proto/physics.proto
Add terrain height query RPC:
// Query terrain height at a position relative to a body
rpc GetTerrainHeight(TerrainHeightRequest) returns (TerrainHeightResponse);
message TerrainHeightRequest {
string body_name = 1; // e.g., "Luna"
double nx = 2; // Unit sphere normal X (body-local)
double ny = 3; // Unit sphere normal Y (body-local)
double nz = 4; // Unit sphere normal Z (body-local)
}
message TerrainHeightResponse {
bool success = 1;
double elevation = 2; // Meters above mean radius
string error = 3;
}
4. Modified: services/physics/src/grpc_server.py
Implement GetTerrainHeight RPC handler.
5. Modified: services/physics/Dockerfile
Add terrain data build stage (same as web-client) and copy heightmap into image:
# Terrain data stage
FROM python:3.11-slim AS terrain-data
RUN pip install --no-cache-dir numpy
COPY scripts/terrain-data/ /scripts/
RUN python /scripts/build_luna_heightmap.py --output /terrain/luna_heightmap.bin
# In main stage:
COPY --from=terrain-data /terrain/ /app/terrain/
6. Modified: client altitude display
The API gateway already streams ship positions to clients. For AGL display, two options:
Option A (chosen — client-side computation): The client already has the heightmap loaded for terrain rendering. Compute AGL locally:
AGL = distance_to_body_center - body_radius - terrain_height(ship_normal)
This avoids a round-trip to the server and is consistent with what the player sees rendered.
Option B (deferred): Server streams AGL alongside ship state. Not needed until landing (#745) requires server-authoritative ground distance.
Client changes:
cockpitShipSystems.js: Show AGL when terrain is active for the reference bodyorbital.js: AddformatAGL()or reuseformatAltitude()with “(AGL)” label
7. Modified: scripts/build-images.sh
Copy terrain data scripts into physics service build context (same pattern as web-client).
Terrain Profile Matching
The server and client must produce consistent terrain heights. Key parameters that must match exactly:
| Parameter | Client (JS) | Server (Python) |
|---|---|---|
| Noise seed | 42 | 42 |
| Noise algorithm | 3D Simplex (Gustavson) | Same algorithm, ported |
| Heightmap | LDEM_16, Int16, 0.5 scale | Same binary file |
| Interpolation | Bicubic Catmull-Rom | Same algorithm |
| Crater scale | 200 × density, 400m depth | Same |
| FBM | 256× freq, 3 octaves, 200m | Same |
| Body radius | 1,737,400 m | Same |
Floating-point differences (JS Float64 vs Python float64) will be negligible (< 0.01m).
Performance
Terrain height queries during collision detection are per-ship, per-tick. With ~10 ships near Luna:
- 10 heightmap samples × bicubic (16 pixel reads + interpolation) ≈ negligible
- 10 crater noise evaluations (27-cell Voronoi search) ≈ ~0.1ms total
- 10 fbm evaluations (3 octaves × simplex3D) ≈ ~0.05ms total
Well within the tick budget. The heightmap stays memory-mapped after initial load.
Testing
Unit tests (services/physics/tests/test_terrain.py):
- SimplexNoise determinism: Same seed produces same values
- SimplexNoise range: Output in [-1, 1]
- Heightmap sampling: Known coordinates produce expected elevations (SPA basin, Mons Huygens, Mare Imbrium)
- terrain_height consistency: Verify Luna profile produces reasonable elevations (-9100m to +10786m range)
- Body rotation: Verify ICRF-to-body-local transform produces correct lat/lon
- Collision detection: Ship above terrain survives; ship below terrain crashes
Integration test:
- Spawn ship in low Luna orbit, verify no false collision with terrain
- Place ship below terrain surface, verify collision detected
Acceptance Criteria
- Physics collision uses terrain height for Luna (ships crash into mountains, not through valleys)
GetTerrainHeightgRPC endpoint returns correct elevation- Client displays AGL altitude when near terrain-enabled bodies
- Heightmap loaded at physics service startup (~33 MB memory)
- No measurable tick processing slowdown (< 1ms added per tick)
- Server terrain heights match client within < 1m tolerance