Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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})");
    }
}