facett-demo/src/bin/flight_demo.rs
//! **facett — standalone FLIGHT demo** (`flight_demo`).
//!
//! A self-contained eframe/wgpu app that lets a human **fly** the
//! [`facett_map3d_flight`] engine over Sweden: the crate's 6DOF
//! [`FlightCamera`](facett_map3d_flight::FlightCamera) rig +
//! [`FlightController`](facett_map3d_flight::FlightController) input mapping, over
//! **real-DEM terrain** built by
//! [`build_terrain_mesh`](facett_map3d_flight::build_terrain_mesh) on the `f64`
//! floating origin (so the terrain is jitter-free at planetary scale). This is the
//! "test application" entry point for the flight layer — it does NOT touch the
//! shared `facett-demo` binary, mirroring `osm3d_demo.rs`.
//!
//! ## Terrain source (self-contained — runs with NO external files)
//! - By default it uses the crate's **deterministic procedural terrain fallback**
//! ([`TerrainField::synthetic`](facett_map3d_flight::TerrainField::synthetic)) —
//! smooth Sweden-flavoured relief, no SRTM `.hgt` needed, no network.
//! - If **`$FACETT_HGT`** points at a real SRTM `.hgt` tile (e.g.
//! `N59E017.hgt`), it loads that instead
//! ([`load_hgt_or_synthetic`](facett_map3d_flight::TerrainField::load_hgt_or_synthetic));
//! a bad/missing path falls back to synthetic so the demo is always green.
//!
//! ## Flight controls (the crate's `ControlFrame::from_egui` mapping)
//! - **W / A / S / D** — forward / strafe-left / back / strafe-right (free-fly) or
//! throttle (flight-stick).
//! - **R / F** — climb / descend (free-fly).
//! - **Q / E** — roll left / right (flight-stick aileron).
//! - **drag the mouse** over the viewport — yaw (x) + pitch (y) look.
//! - **Shift** — speed boost.
//! - **1 / 2 / 3** — FreeFly / cinematic Orbit / FlightStick.
//!
//! Run:
//! ```bash
//! cargo run -p facett-demo --bin flight_demo # synthetic terrain
//! FACETT_HGT=/data/N59E017.hgt cargo run -p facett-demo --bin flight_demo --release # real SRTM
//! ```
// Native-only standalone flight viewer (drives `eframe::run_native`). Excluded from
// the wasm web demo; compiles to an empty `main` on wasm so the package builds for
// `wasm32-unknown-unknown` (identical policy to `osm3d_demo.rs`).
#[cfg(target_arch = "wasm32")]
fn main() {}
#[cfg(not(target_arch = "wasm32"))]
use flight::App;
#[cfg(not(target_arch = "wasm32"))]
mod flight {
use eframe::egui;
use egui::epaint::{Vertex, WHITE_UV};
use egui::{Color32, Pos2};
use facett_map3d_flight::{
build_terrain_mesh, ControlFrame, FloatingOrigin, FlightCamera, FlightController, FlyMode,
GeoRegion, TerrainField, TerrainMesh, WorldPos,
};
/// A field-of-view for the flight camera (radians).
const FOV_Y: f32 = 1.05; // ~60°
/// View-space near plane (metres) — geometry closer than this is clipped.
const NEAR_M: f32 = 1.0;
/// View-space far plane (metres) — the horizon bound / fog end.
const FAR_M: f32 = 220_000.0;
/// The **window-free heart** of the flight demo: the terrain source + the camera
/// rig + the input→pose controller. Holds everything needed to advance the flight
/// and build the terrain mesh, with NO egui window — so a headless smoke test can
/// construct it, step it, and assert the camera flies + the mesh has real relief.
///
/// ## Frame convention — ONE world frame, one swap at the render boundary
/// The [`FlightCamera`] integrates in the engine's **world** frame, which is what
/// [`WorldPos`] means: `{x: easting, y: northing, z: altitude}` (the frame
/// `from_lonlat`/`to_lonlat`/`ground_distance` all speak). [`build_terrain_mesh`]
/// emits vertices in the **Y-up render frame** `[easting, altitude, northing]`,
/// i.e. the `[v0, v2, v1]` swap — and that swap is applied ONCE, at the render
/// boundary, to the camera basis so basis and geometry agree.
///
/// This used to store the camera position pre-swapped into a Y-up `WorldPos`,
/// which made `to_lonlat()` on that position meaningless and hid an up-axis bug
/// in the rig itself (climbing moved you north). One frame, one swap.
/// Terrain is built with a [`FloatingOrigin`] anchored at the camera, so the eye
/// sits exactly at the render origin `[0, 0, 0]`.
pub struct FlightScene {
/// Elevation source: a real SRTM DEM, or the deterministic procedural fallback.
pub field: TerrainField,
/// Whether a real `.hgt` tile loaded (vs the synthetic fallback) — for the HUD.
pub real_terrain: bool,
/// The 6DOF flight camera rig (position in the WORLD frame: `{easting, northing, altitude}`).
pub cam: FlightCamera,
/// Maps a frame of key/mouse state into camera input.
pub controller: FlightController,
/// Vertical exaggeration applied to elevations when building the mesh.
pub vexag: f64,
/// Half-width (degrees) of the terrain patch built around the camera each frame.
pub span_deg: f64,
/// Terrain mesh resolution (vertices per axis).
pub grid: usize,
}
impl Default for FlightScene {
fn default() -> Self {
Self::new()
}
}
impl FlightScene {
/// Build the scene over the centre of Sweden, a few km up, pitched down so the
/// terrain is in view from the first frame. Uses `$FACETT_HGT` if it points at
/// a real SRTM tile, otherwise the deterministic procedural fallback.
#[must_use]
pub fn new() -> Self {
let (lon, lat) = GeoRegion::sweden().center();
let altitude = 4_000.0;
let mut cam = FlightCamera::free_fly(WorldPos::from_lonlat(lon, lat, altitude));
cam.pitch = -0.35; // nose down ~20° so the ground below is framed
cam.yaw = 0.0;
let (field, real_terrain) = match std::env::var("FACETT_HGT") {
Ok(path) => TerrainField::load_hgt_or_synthetic(path),
Err(_) => (TerrainField::synthetic(), false),
};
Self {
field,
real_terrain,
cam,
controller: FlightController::default(),
vexag: 1.5,
span_deg: 0.35,
grid: 72,
}
}
/// The camera's geographic `(lon, lat, altitude)` — a direct read of the
/// world frame now that the position is stored in it (no pre-swap to undo).
#[must_use]
pub fn cam_geo(&self) -> (f64, f64, f64) {
let (lon, lat) = self.cam.position.to_lonlat();
(lon, lat, self.cam.position.z)
}
/// The per-frame floating origin, anchored at the camera, so the terrain mesh
/// rebases the eye onto the render origin.
#[must_use]
fn terrain_origin(&self) -> FloatingOrigin {
FloatingOrigin::at(self.cam.position)
}
/// Advance the flight one frame: apply the control frame to the camera over `dt`.
pub fn step(&mut self, frame: &ControlFrame, dt: f64) {
self.controller.apply(&mut self.cam, frame, dt);
}
/// Build the terrain mesh for the current camera position — a patch centred on
/// the camera (so terrain follows you as you fly), rebased onto the camera's
/// floating origin (Y-up render frame, eye at `[0, 0, 0]`).
#[must_use]
pub fn terrain_mesh(&self) -> TerrainMesh {
let (lon, lat, _alt) = self.cam_geo();
// Latitude degrees are "longer" than longitude at 60°N; a narrower lat span
// keeps the patch roughly square on the ground.
let region = GeoRegion::new(
lon - self.span_deg,
lat - self.span_deg * 0.55,
lon + self.span_deg,
lat + self.span_deg * 0.55,
);
build_terrain_mesh(&self.field, region, self.grid, self.grid, self.terrain_origin(), self.vexag)
}
/// A one-line description of the elevation source, for the HUD.
#[must_use]
pub fn terrain_label(&self) -> String {
if self.real_terrain {
"SRTM .hgt (real DEM)".to_string()
} else {
"synthetic procedural terrain (no .hgt needed)".to_string()
}
}
}
/// Colour the ground by elevation — lowland green → upland brown → alpine snow.
fn terrain_color(elev_m: f32) -> Color32 {
// A few stops; linearly blended so the shading reads as a continuous surface.
const STOPS: &[(f32, [u8; 3])] = &[
(0.0, [46, 92, 58]), // lowland green
(250.0, [78, 120, 60]), // rolling green
(500.0, [120, 110, 70]), // brown foothills
(750.0, [140, 130, 120]), // grey rock
(1000.0, [235, 238, 245]), // snow
];
let e = elev_m.clamp(STOPS[0].0, STOPS[STOPS.len() - 1].0);
for w in STOPS.windows(2) {
let (a_e, a_c) = w[0];
let (b_e, b_c) = w[1];
if e <= b_e {
let t = ((e - a_e) / (b_e - a_e).max(1e-3)).clamp(0.0, 1.0);
let lerp = |i: usize| (a_c[i] as f32 + (b_c[i] as f32 - a_c[i] as f32) * t) as u8;
return Color32::from_rgb(lerp(0), lerp(1), lerp(2));
}
}
let last = STOPS[STOPS.len() - 1].1;
Color32::from_rgb(last[0], last[1], last[2])
}
/// The eframe app: wraps a [`FlightScene`] and does the egui I/O + CPU perspective
/// paint of the terrain mesh. Mirrors `osm3d_demo.rs`'s single-full-window `ui`.
pub struct App {
scene: FlightScene,
show_hud: bool,
}
impl App {
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
Self { scene: FlightScene::new(), show_hud: true }
}
/// CPU-project + shade + depth-sort the terrain mesh through the flight camera
/// and paint it (one egui mesh, painter's-algorithm order) with a sky backdrop.
fn paint(&self, painter: &egui::Painter, rect: egui::Rect) {
let center = rect.center();
let half_h = (rect.height() * 0.5).max(1.0);
let focal = half_h / (FOV_Y * 0.5).tan();
let sky_top = Color32::from_rgb(92, 141, 205);
let sky_bot = Color32::from_rgb(198, 214, 228);
// ── sky gradient backdrop (two triangles, top→bottom lerp) ─────────────
let mut sky = egui::Mesh::default();
let v = |p: Pos2, c: Color32| Vertex { pos: p, uv: WHITE_UV, color: c };
sky.vertices.push(v(rect.left_top(), sky_top));
sky.vertices.push(v(rect.right_top(), sky_top));
sky.vertices.push(v(rect.right_bottom(), sky_bot));
sky.vertices.push(v(rect.left_bottom(), sky_bot));
sky.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]);
painter.add(egui::Shape::mesh(sky));
// ── project the terrain vertices through the flight camera ─────────────
let mesh = self.scene.terrain_mesh();
let (_lon, _lat, cam_alt) = self.scene.cam_geo();
let cam_alt = cam_alt as f32;
// World frame (x=easting, y=northing, z=altitude) → render frame (Y-up,
// [easting, altitude, northing]): the SAME `[v0, v2, v1]` swap
// `build_terrain_mesh` applies to geometry. One swap, at the boundary.
let to_render = |v: [f64; 3]| [v[0] as f32, v[2] as f32, v[1] as f32];
let f = to_render(self.scene.cam.forward());
let r = to_render(self.scene.cam.right());
let u = to_render(self.scene.cam.up());
let dot = |a: &[f32; 3], b: &[f32; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
// Eye is at the render origin [0,0,0] (terrain rebased around the camera).
let projected: Vec<Option<(Pos2, f32)>> = mesh
.positions
.iter()
.map(|p| {
let vx = dot(p, &r);
let vy = dot(p, &u);
let vz = dot(p, &f); // depth in front of the eye
if vz <= NEAR_M || vz >= FAR_M {
return None;
}
let sx = center.x + (vx / vz) * focal;
let sy = center.y - (vy / vz) * focal;
Some((Pos2::new(sx, sy), vz))
})
.collect();
// Directional sun (render frame is Y-up = altitude), from the upper front.
let light = {
let l = [-0.4f32, 0.85, 0.3];
let n = (l[0] * l[0] + l[1] * l[1] + l[2] * l[2]).sqrt();
[l[0] / n, l[1] / n, l[2] / n]
};
let sky_fog = [sky_bot.r() as f32, sky_bot.g() as f32, sky_bot.b() as f32];
// ── assemble shaded, fogged, depth-sorted faces ────────────────────────
let n_tris = mesh.indices.len() / 3;
let mut faces: Vec<(f32, [Pos2; 3], Color32)> = Vec::with_capacity(n_tris);
for t in 0..n_tris {
let i0 = mesh.indices[t * 3] as usize;
let i1 = mesh.indices[t * 3 + 1] as usize;
let i2 = mesh.indices[t * 3 + 2] as usize;
let (p0, p1, p2) = match (projected[i0], projected[i1], projected[i2]) {
(Some(a), Some(b), Some(c)) => (a, b, c),
_ => continue, // any vertex behind the near plane → drop the tri
};
let depth = (p0.1 + p1.1 + p2.1) / 3.0;
// Face normal (average of the three vertex normals, re-normalised).
let nrm = {
let (a, b, c) = (mesh.normals[i0], mesh.normals[i1], mesh.normals[i2]);
let mut n = [a[0] + b[0] + c[0], a[1] + b[1] + c[1], a[2] + b[2] + c[2]];
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-6);
n[0] /= len;
n[1] /= len;
n[2] /= len;
n
};
let diff = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).max(0.0);
let shade = (0.35 + 0.65 * diff).clamp(0.0, 1.0);
// Elevation of this face = render-Y (Δaltitude) + camera altitude.
let avg_y = (mesh.positions[i0][1] + mesh.positions[i1][1] + mesh.positions[i2][1]) / 3.0;
let elev = avg_y + cam_alt;
let base = terrain_color(elev);
let (lr, lg, lb) = (base.r() as f32 * shade, base.g() as f32 * shade, base.b() as f32 * shade);
// Distance fog toward the horizon haze.
let fog_t = ((depth / FAR_M).clamp(0.0, 1.0)).powf(0.5) * 0.85;
let col = Color32::from_rgb(
(lr + (sky_fog[0] - lr) * fog_t) as u8,
(lg + (sky_fog[1] - lg) * fog_t) as u8,
(lb + (sky_fog[2] - lb) * fog_t) as u8,
);
faces.push((depth, [p0.0, p1.0, p2.0], col));
}
// Painter's algorithm: far → near, so nearer triangles overpaint farther.
faces.sort_by(|l, r| r.0.partial_cmp(&l.0).unwrap_or(std::cmp::Ordering::Equal));
let mut tmesh = egui::Mesh::default();
for (_, pts, col) in &faces {
let b = tmesh.vertices.len() as u32;
for p in pts {
tmesh.vertices.push(Vertex { pos: *p, uv: WHITE_UV, color: *col });
}
tmesh.indices.extend_from_slice(&[b, b + 1, b + 2]);
}
painter.add(egui::Shape::mesh(tmesh));
}
}
impl eframe::App for App {
// This facett-demo eframe build hands the app a single full-window `Ui` (the
// same `App::ui` signature `main.rs` / `osm3d_demo.rs` use), so panels lay out
// with the `Ui`-taking `show` (egui 0.36's name for `show_inside`).
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Keep the flight animating even without input events.
ui.ctx().request_repaint();
let dt = (ui.input(|i| i.stable_dt) as f64).clamp(1.0e-4, 0.1);
egui::Panel::top("bar").show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.heading("facett · flight");
ui.separator();
ui.label(self.scene.terrain_label());
ui.separator();
for (label, mode) in [
("1 FreeFly", FlyMode::FreeFly),
("2 Orbit", FlyMode::Orbit),
("3 Stick", FlyMode::FlightStick),
] {
if ui.selectable_label(self.scene.cam.mode == mode, label).clicked() {
self.scene.cam.set_mode(mode);
}
}
ui.separator();
ui.checkbox(&mut self.show_hud, "HUD");
});
});
egui::CentralPanel::default().show(ui, |ui| {
let size = ui.available_size();
let (resp, painter) = ui.allocate_painter(size, egui::Sense::click_and_drag());
let rect = resp.rect;
// Look while the viewport is being dragged (no pointer-lock needed).
let captured = resp.dragged();
let frame = ui.ctx().input(|i| ControlFrame::from_egui(i, captured));
self.scene.step(&frame, dt);
self.paint(&painter, rect);
if self.show_hud {
let (lon, lat, alt) = self.scene.cam_geo();
let fps = ui.ctx().input(|i| 1.0 / i.stable_dt.max(1e-4));
let hud = format!(
"mode: {}\nalt: {:.0} m lon/lat: {:.3}, {:.3}\nheading: {:.0}° pitch: {:.0}°\nFPS ≈ {:.0}\n\nWASD move · R/F climb/dive · drag mouse to look\nShift boost · Q/E roll (stick) · 1/2/3 mode",
self.scene.cam.mode.name(),
alt,
lon,
lat,
self.scene.cam.yaw.to_degrees().rem_euclid(360.0),
self.scene.cam.pitch.to_degrees(),
fps,
);
painter.text(
rect.left_top() + egui::vec2(10.0, 8.0),
egui::Align2::LEFT_TOP,
hud,
egui::FontId::monospace(13.0),
Color32::from_rgb(20, 28, 36),
);
}
});
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result<()> {
let native_options = eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_inner_size([1100.0, 760.0])
.with_title("facett — flight"),
// Match the rest of facett-demo's host (wgpu backend); the flight terrain is
// drawn with egui's painter (CPU perspective path), like osm3d_demo.
renderer: eframe::Renderer::Wgpu,
..Default::default()
};
eframe::run_native(
"facett-flight",
native_options,
Box::new(|cc| Ok(Box::new(App::new(cc)))),
)
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use crate::flight::FlightScene;
use facett_map3d_flight::ControlFrame;
/// **Headless smoke (construct + fly + build terrain without a window):** the
/// scene builds over Sweden with the synthetic fallback, the terrain mesh has real
/// relief (not a flat plane), and pressing "climb" for a second actually gains
/// altitude — the "a human can fly it" contract, exercised with no eframe window.
#[test]
fn scene_constructs_flies_and_builds_terrain() {
let mut scene = FlightScene::new();
assert!(!scene.real_terrain, "no $FACETT_HGT in CI → synthetic fallback");
// The terrain mesh is a real, non-flat surface.
let mesh = scene.terrain_mesh();
assert_eq!(mesh.vertex_count(), scene.grid * scene.grid, "full grid built");
assert!(mesh.triangle_count() > 0, "terrain has triangles");
assert!(mesh.max[1] - mesh.min[1] > 1.0, "terrain has vertical relief (Δy={})", mesh.max[1] - mesh.min[1]);
assert!(mesh.positions.iter().flatten().all(|v| v.is_finite()), "finite positions");
// Fly: hold "climb" (R) for ~1 s and confirm we gained altitude.
let (_, _, alt0) = scene.cam_geo();
let climb = ControlFrame { r: true, ..Default::default() };
for _ in 0..60 {
scene.step(&climb, 1.0 / 60.0);
}
let (_, _, alt1) = scene.cam_geo();
assert!(alt1 > alt0 + 1.0, "climbing gains altitude ({alt0} → {alt1})");
// And the terrain still builds (recentred) at the new pose.
let mesh2 = scene.terrain_mesh();
assert!(mesh2.triangle_count() > 0 && mesh2.max[1].is_finite());
}
/// **INJECT-ASSERT (the geographic round-trip the frame bridge relies on):** the
/// camera's world position decodes back to the Sweden centre it was seeded with, so
/// the terrain patch is actually built where the camera is.
#[test]
fn cam_geo_round_trips_to_sweden() {
let scene = FlightScene::new();
let (lon, lat, alt) = scene.cam_geo();
let (want_lon, want_lat) = facett_map3d_flight::GeoRegion::sweden().center();
assert!((lon - want_lon).abs() < 1e-6, "lon round-trips ({lon} vs {want_lon})");
assert!((lat - want_lat).abs() < 1e-6, "lat round-trips ({lat} vs {want_lat})");
assert!((alt - 4_000.0).abs() < 1e-6, "altitude preserved ({alt})");
}
}