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/osm_fly.rs

//! **OSM-Fly flow** — the ONE connected story: pick a region (Sweden) → **clip** the
//! cached planet OSM to the region polygon (driving an animated progress bar the
//! WHOLE time) → **fly** the clipped result in 3D with the flight-sim camera, with
//! the planet source + the clip **cached on a large disk** (never T9).
//!
//! This module is the wiring seam between three pieces that already existed
//! *separately* in facett-demo:
//! - [`crate::region_picker`] — the real [`SWEDEN_POLY`](crate::region_picker::SWEDEN_POLY)
//!   polygon + the in-process osm-katana clip.
//! - [`crate::osm_tabs`] — the GeoParquet reader ([`OsmGeometry`], [`load_from`]).
//! - [`facett_map3d_flight`] — the 6DOF [`FlightCamera`] / [`FlightController`] /
//!   real-DEM [`TerrainField`] engine (the SAME engine the standalone `flight_demo`
//!   bin drives; we reuse it, we don't fork it).
//!
//! ## The flow (one continuous progress story)
//! 1. **Select** the region — Sweden by default, from the real
//!    [`SWEDEN_POLY`](crate::region_picker::SWEDEN_POLY). Kept generic ([`FlyConfig`])
//!    so another country works the moment its polygon + data exist.
//! 2. **Clip** the planet to the polygon (osm-katana, in-process) — unless the clip is
//!    already cached. The progress bar sweeps the CLIP phase.
//! 3. **Load + build** the clipped GeoParquet into the flight scene (terrain + OSM
//!    buildings/roads). The SAME progress bar continues through the load + scene-build
//!    phases — one bar, one story, not two disjoint bars.
//! 4. **Fly** it with the existing [`FlightController`].
//!
//! ## Cache on a large disk (never T9)
//! Both the planet source AND the Sweden clip live under a **large-disk** root —
//! [`cache_root`] = `$FACETT_OSM_CACHE` or `/mnt/work4t/facett-osm-cache`. The clip is
//! keyed by **region + source-hash** ([`region_clip_dir`]) so a second run finds the
//! GeoParquet already there, **skips the clip**, and loads straight to flight. Every
//! run logs a cache **hit/miss**. If `$FACETT_OSM_CACHE` ever points at T9 it is
//! refused (we NEVER write cache to T9) and the default large disk is used.
//!
//! ## Honesty (no faking "planet loaded")
//! A real planet `.pbf` is huge and usually absent on a dev box. When no planet source
//! is found the flow does NOT pretend — it logs
//! `no planet .pbf at <path> — using <fallback>; drop a planet extract there for the
//! full run.` and flies the **embedded Sigtuna** real-OSM clip (a genuine slice of
//! Sweden) over the procedural DEM. Drop a `planet.osm.pbf` under [`cache_root`] (or
//! point `$FACETT_PLANET` at one) and the same flow does the real clip.

#![cfg(not(target_arch = "wasm32"))]

use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, Sender};

use eframe::egui;

use facett::map::Way;
use facett_map3d::Map3D;
use facett_map3d_flight::{
    build_terrain_mesh, ControlFrame, FloatingOrigin, FlightCamera, FlightController,
    GeoRegion, TerrainField, TerrainMesh, WorldPos,
};

use crate::osm_tabs::{self, OsmGeometry, OsmSource};
use crate::region_picker::SWEDEN_POLY;

// ── large-disk cache (NEVER T9) ──────────────────────────────────────────────

/// The default large-disk cache root (see [MEMORY: disk layout] — `/mnt/work4t` is
/// the 3.4T PCI4 build/scratch disk; T9 is the flaky source disk we never write to).
pub const DEFAULT_CACHE_ROOT: &str = "/mnt/work4t/facett-osm-cache";

/// True if `p` resolves onto the T9 source disk (which we must NEVER write to). We
/// match the mount stem `/T9` in any of its usual spellings (`/run/media/.../T9`,
/// `/var/run/media/.../T9`, a bare `/T9`, `media/<user>/T9…`).
pub fn is_on_t9(p: &Path) -> bool {
    let s = p.to_string_lossy();
    s.contains("/T9/")
        || s.ends_with("/T9")
        || s.split('/').collect::<Vec<_>>().windows(3).any(|w| w[0] == "media" && w[2].starts_with("T9"))
}

/// The large-disk cache root: `$FACETT_OSM_CACHE` (unless it points at T9, which is
/// refused) else [`DEFAULT_CACHE_ROOT`]. Both the planet source and every region clip
/// live under here.
pub fn cache_root() -> PathBuf {
    if let Ok(v) = std::env::var("FACETT_OSM_CACHE") && !v.is_empty() {
        let p = PathBuf::from(&v);
        if is_on_t9(&p) {
            eprintln!(
                "[osm-fly] FACETT_OSM_CACHE={v} is on T9 — refusing (cache is NEVER written to \
                 T9); falling back to {DEFAULT_CACHE_ROOT}"
            );
        } else {
            return p;
        }
    }
    PathBuf::from(DEFAULT_CACHE_ROOT)
}

/// A small, stable FNV-1a-64 hex digest — used to key a clip by its source so a
/// different planet (or a re-downloaded one of a different size) gets a fresh cache
/// slot rather than a stale hit.
fn fnv1a_hex(bytes: &[u8]) -> String {
    let mut h: u64 = 0xcbf29ce484222325;
    for &b in bytes {
        h ^= b as u64;
        h = h.wrapping_mul(0x100000001b3);
    }
    format!("{h:016x}")
}

/// The **source-hash** keying the cache: derived from the planet source's path + byte
/// size (cheap, no full read), or the stable tag `"no-planet"` when we fly the
/// fallback. A second run with the same planet → same hash → cache hit → skip clip.
pub fn source_hash(planet: Option<&Path>) -> String {
    match planet {
        Some(p) => {
            let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
            let key = format!("{}|{}", p.display(), size);
            fnv1a_hex(key.as_bytes())
        }
        None => "no-planet".to_string(),
    }
}

/// The GeoParquet cache dir for `region` clipped from a source with digest
/// `src_hash`: `<cache_root>/<region>-<src_hash>-geo`. osm-katana writes
/// `nodes.parquet` / `ways.parquet` here; a hit is "both already exist".
pub fn region_clip_dir(region: &str, src_hash: &str) -> PathBuf {
    cache_root().join(format!("{region}-{src_hash}-geo"))
}

/// True iff the region clip is already on the large disk (both parquets present).
pub fn clip_cached(dir: &Path) -> bool {
    dir.join("nodes.parquet").exists() && dir.join("ways.parquet").exists()
}

/// Locate the **planet source** to clip, in priority order (reading FROM T9 is fine —
/// only *writing cache* to T9 is forbidden):
/// 1. `$FACETT_PLANET` (an explicit extract).
/// 2. `planet.osm.pbf` / `planet.osm.bz2` / `planet-latest.osm.pbf` under [`cache_root`].
/// 3. The legacy locations the region picker knows (`/home/.../planet-*.osm.bz2`, T9).
///
/// `None` → no planet anywhere → the honest fallback flight.
pub fn planet_source() -> Option<PathBuf> {
    if let Ok(p) = std::env::var("FACETT_PLANET") {
        let pb = PathBuf::from(p);
        if pb.exists() {
            return Some(pb);
        }
    }
    let root = cache_root();
    for name in ["planet.osm.pbf", "planet.osm.bz2", "planet-latest.osm.pbf"] {
        let c = root.join(name);
        if c.exists() {
            return Some(c);
        }
    }
    crate::region_picker::planet_source()
}

// ── the flow config + report ─────────────────────────────────────────────────

/// A region to fly. Sweden is the built-in preset ([`FlyConfig::sweden`]); the shape
/// is kept generic (region name + Osmosis `.poly` text) so any country flies the
/// moment its polygon + data exist.
#[derive(Clone)]
pub struct FlyConfig {
    /// Region name — the cache-dir stem + the flight-scene label (e.g. `"sweden"`).
    pub region: String,
    /// The clip polygon in Osmosis `.poly` format (e.g. [`SWEDEN_POLY`]).
    pub poly: String,
}

impl FlyConfig {
    /// The Sweden preset: the real bundled [`SWEDEN_POLY`] outline.
    #[must_use]
    pub fn sweden() -> Self {
        Self { region: "sweden".to_string(), poly: SWEDEN_POLY.to_string() }
    }
}

/// What actually happened, for the log line + the smoke test's assertions.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FlyReport {
    /// Region name flown.
    pub region: String,
    /// Was the clip already cached on the large disk (→ clip skipped)?
    pub cache_hit: bool,
    /// Did we run a real planet clip (`true`) or fly the honest fallback (`false`)?
    pub real_clip: bool,
    /// The clip cache dir (large disk).
    pub clip_dir: PathBuf,
    /// The planet source used, if any.
    pub planet: Option<PathBuf>,
    /// A one-line human summary (also logged to stderr).
    pub summary: String,
}

// ── the continuous progress story ────────────────────────────────────────────

/// The phases of the ONE progress story. Fractions climb monotonically across the
/// whole flow so a single bar tells the clip → load → build → fly tale end-to-end.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FlyPhase {
    /// Not started.
    Idle,
    /// Clipping the planet to the polygon (osm-katana, in-process).
    Clipping,
    /// Reading the clipped GeoParquet.
    Loading,
    /// Building the terrain + OSM meshes for the flight scene.
    Building,
    /// Airborne — the scene is flyable.
    Flying,
    /// Something failed (string carried out-of-band via the log).
    Error,
}

impl FlyPhase {
    /// A short, stable tag.
    pub fn tag(self) -> &'static str {
        match self {
            FlyPhase::Idle => "idle",
            FlyPhase::Clipping => "clipping",
            FlyPhase::Loading => "loading",
            FlyPhase::Building => "building",
            FlyPhase::Flying => "flying",
            FlyPhase::Error => "error",
        }
    }

    /// The `[lo, hi)` fraction band this phase occupies in the single continuous bar.
    /// The clip is the long pole, so it owns the widest band.
    pub fn band(self) -> (f32, f32) {
        match self {
            FlyPhase::Idle => (0.0, 0.02),
            FlyPhase::Clipping => (0.02, 0.60),
            FlyPhase::Loading => (0.60, 0.85),
            FlyPhase::Building => (0.85, 0.99),
            FlyPhase::Flying | FlyPhase::Error => (1.0, 1.0),
        }
    }

    /// The overall bar fraction (0..1) for this phase at intra-phase progress `sweep`
    /// (0..1). Time-driven `sweep` gives the bar visible motion even though the clip
    /// exposes no fine-grained progress — one moving bar the whole time.
    pub fn fraction(self, sweep: f32) -> f32 {
        let (lo, hi) = self.band();
        (lo + (hi - lo) * sweep.clamp(0.0, 1.0)).clamp(0.0, 1.0)
    }
}

/// A message from the background pipeline to the UI.
pub enum FlyMsg {
    /// The pipeline moved to a new phase.
    Phase(FlyPhase),
    /// A log line (cache hit/miss, fallback notice, …) to surface + echo.
    Log(String),
    /// The geometry is loaded — build the flight world from it. Carries the report.
    Ready(Box<OsmGeometry>, Box<FlyReport>),
    /// The flow failed; carries the user-facing reason.
    Failed(String),
}

// ── the pipeline ─────────────────────────────────────────────────────────────

/// Run the clip→load flow to completion **synchronously** (the headless entry the
/// smoke test drives): given the resolved `planet` source (`None` → honest fallback),
/// key the cache, clip if needed (real planet + `region-clip` feature), else fall back
/// honestly, then load the GeoParquet. Emits phase/log messages through `tx` if given.
/// Returns the loaded geometry + report. The planet is passed IN (not discovered here)
/// so callers/tests fully control whether a real clip runs — [`spawn_flow`] supplies
/// [`planet_source`]; a test passes `None` for a deterministic, fast fallback run.
pub fn run_flow_blocking(
    cfg: &FlyConfig,
    planet: Option<PathBuf>,
    tx: Option<&Sender<FlyMsg>>,
) -> (OsmGeometry, FlyReport) {
    let send = |m: FlyMsg| {
        if let Some(t) = tx {
            let _ = t.send(m);
        }
    };
    let log = |s: String| {
        eprintln!("{s}");
        send(FlyMsg::Log(s));
    };

    let src_hash = source_hash(planet.as_deref());
    let clip_dir = region_clip_dir(&cfg.region, &src_hash);
    let hit = clip_cached(&clip_dir);

    // 1. Cache hit → skip the clip entirely, load straight to flight.
    if hit {
        log(format!(
            "[osm-fly] CACHE HIT region={} dir={} — skipping clip, loading straight to flight",
            cfg.region,
            clip_dir.display()
        ));
        send(FlyMsg::Phase(FlyPhase::Loading));
        if let Some(geo) = load_clip(&clip_dir, &cfg.region) {
            let report = FlyReport {
                region: cfg.region.clone(),
                cache_hit: true,
                real_clip: true,
                clip_dir: clip_dir.clone(),
                planet: planet.clone(),
                summary: format!("cache hit: flew {} from {}", cfg.region, clip_dir.display()),
            };
            return (geo, report);
        }
        log(format!(
            "[osm-fly] cached clip at {} read empty — re-clipping",
            clip_dir.display()
        ));
    } else {
        log(format!(
            "[osm-fly] CACHE MISS region={} dir={} (source-hash {src_hash})",
            cfg.region,
            clip_dir.display()
        ));
    }

    // 2. Real clip if we have a planet AND the region-clip feature is compiled in.
    #[cfg(feature = "region-clip")]
    if let Some(planet_path) = planet.as_deref() {
        log(format!(
            "[osm-fly] clipping {} → {} (region-clip in-process)",
            planet_path.display(),
            clip_dir.display()
        ));
        send(FlyMsg::Phase(FlyPhase::Clipping));
        match clip_region(planet_path, &cfg.poly, &clip_dir) {
            Ok(()) => {
                send(FlyMsg::Phase(FlyPhase::Loading));
                if let Some(geo) = load_clip(&clip_dir, &cfg.region) {
                    let report = FlyReport {
                        region: cfg.region.clone(),
                        cache_hit: false,
                        real_clip: true,
                        clip_dir: clip_dir.clone(),
                        planet: planet.clone(),
                        summary: format!(
                            "clipped {} from {} → {}",
                            cfg.region,
                            planet_path.display(),
                            clip_dir.display()
                        ),
                    };
                    return (geo, report);
                }
                log(format!(
                    "[osm-fly] clip produced no features in {} — using fallback",
                    clip_dir.display()
                ));
            }
            Err(e) => log(format!("[osm-fly] clip failed: {e} — using fallback")),
        }
    }

    // 3. Honest fallback — NO faking "planet loaded".
    let root = cache_root();
    log(format!(
        "[osm-fly] no planet .pbf at {} — using embedded Sigtuna OSM (a real slice of Sweden) + \
         procedural DEM; drop a planet extract there (or set $FACETT_PLANET) for the full run.",
        root.join("planet.osm.pbf").display()
    ));
    send(FlyMsg::Phase(FlyPhase::Building));
    let geo = osm_tabs::load_sigtuna().unwrap_or_else(osm_tabs::load);
    let report = FlyReport {
        region: cfg.region.clone(),
        cache_hit: false,
        real_clip: false,
        clip_dir,
        planet,
        summary: format!("fallback flight: {} ({})", cfg.region, geo.source.label()),
    };
    (geo, report)
}

/// Spawn the flow on a background thread; poll `rx` each frame for phase/log/ready.
pub fn spawn_flow(cfg: FlyConfig) -> Receiver<FlyMsg> {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let planet = planet_source();
        let (geo, report) = run_flow_blocking(&cfg, planet, Some(&tx));
        let _ = tx.send(FlyMsg::Log(report.summary.clone()));
        let _ = tx.send(FlyMsg::Ready(Box::new(geo), Box::new(report)));
        let _ = tx.send(FlyMsg::Phase(FlyPhase::Flying));
    });
    rx
}

/// Load the region's GeoParquet clip (`nodes.parquet` + `ways.parquet`) into the
/// shared [`OsmGeometry`] the flight scene ingests. `None` if empty/unreadable.
fn load_clip(dir: &Path, region: &str) -> Option<OsmGeometry> {
    let nodes = dir.join("nodes.parquet");
    let ways = dir.join("ways.parquet");
    osm_tabs::load_from(
        &nodes.to_string_lossy(),
        &ways.to_string_lossy(),
        OsmSource::Region(region.to_string()),
    )
}

/// Clip the planet to the polygon with osm-katana **in-process**, writing the
/// GeoParquet into `out` (on the large disk). Only compiled with `region-clip`.
#[cfg(feature = "region-clip")]
fn clip_region(planet: &Path, poly: &str, out: &Path) -> Result<(), String> {
    use std::sync::Arc;
    let polygon = osm_katana::Polygon::from_poly_str(poly).map_err(|e| e.to_string())?;
    std::fs::create_dir_all(out).map_err(|e| format!("mkdir {}: {e}", out.display()))?;
    let opts = osm_katana::ConvertOptions {
        output_dir: out.to_path_buf(),
        clip: Some(Arc::new(osm_katana::Clip::Poly(polygon))),
        ..Default::default()
    };
    osm_katana::convert(planet, &opts)
        .map_err(|e| format!("osm-katana convert {} → {}: {e}", planet.display(), out.display()))
}

// ── the flyable world (terrain + OSM features + camera) ──────────────────────

// The field of view, near plane and far plane that used to live here are GONE with the
// painter that read them. `Map3D`'s own camera owns all three now, and a second set of
// frustum constants beside it is exactly the drift LAW 5 is about — they would have kept
// agreeing right up until somebody changed one.

/// Cap on extruded buildings — still read: it bounds what `from_geometry` splits out of
/// the clip, which is a fact about the DATA rather than about how fast a CPU painter is.
const MAX_BUILDINGS: usize = 3000;
/// Cap on road segments drawn.
const MAX_ROAD_SEGMENTS: usize = 8000;

/// The flyable scene built from the clipped region: the DEM terrain + the OSM
/// buildings/roads, plus the 6DOF camera rig + input controller. Reuses
/// [`facett_map3d_flight`] wholesale (the engine `flight_demo` uses); the net-new bit
/// here is draping the clipped OSM features onto that terrain so you fly the REAL
/// clipped city, not just relief.
pub struct FlyWorld {
    /// Elevation source (real SRTM `.hgt` via `$FACETT_HGT`, else procedural).
    pub field: TerrainField,
    /// Did a real DEM load (vs synthetic)?
    pub real_terrain: bool,
    /// The 6DOF flight camera. Its position is in the engine's **world** frame
    /// (`WorldPos` = `{x: easting, y: northing, z: altitude}`) — the frame
    /// `from_lonlat`/`to_lonlat` speak. The world→render (Y-up) swap happens ONCE,
    /// at the render boundary below, exactly as `build_terrain_mesh` does it.
    /// (Was kept pre-swapped in a Y-up `WorldPos`, which is what let the rig's
    /// up-axis bug hide: `to_lonlat` on that position was meaningless.)
    pub cam: FlightCamera,
    /// Key/mouse → camera-input mapper.
    pub controller: FlightController,
    /// Vertical exaggeration for elevations.
    pub vexag: f64,
    /// Half-width (degrees) of the terrain patch built around the camera.
    pub span_deg: f64,
    /// Terrain mesh resolution (vertices per axis).
    pub grid: usize,
    /// Buildings (closed footprints, extruded to `height_m`).
    pub buildings: Vec<Way>,
    /// Roads / lines (drawn draped on the terrain).
    pub roads: Vec<Way>,
    /// Water polygons (flat, at ground).
    pub water: Vec<Way>,
    /// A label for the HUD (region + terrain + feature counts).
    pub label: String,
    /// **The renderer.** The SAME `facett_map3d::Map3D` the sister tab (`osm_tabs.rs`)
    /// builds, from the same ways, through the same roofed constructor — so the flight
    /// view and the tab view are one render path and cannot drift apart (LAW 5). The
    /// flight rig above drives it: [`Self::sync_camera`] hands it the 6DOF pose each
    /// frame and `Facet::ui` draws it.
    ///
    /// `None` only when the geometry has no extrudable ways at all, which is the honest
    /// empty case rather than a reason to keep a second painter around for.
    pub map: Option<Map3D>,
}

impl FlyWorld {
    /// Build the world from a loaded region [`OsmGeometry`]. The camera is seeded over
    /// the geometry's centroid (so the clipped city is in view from frame one), a
    /// couple of km up, nosed down.
    #[must_use]
    pub fn from_geometry(geo: &OsmGeometry) -> Self {
        let (clon, clat) = centroid(geo).unwrap_or_else(|| GeoRegion::sweden().center());

        let (field, real_terrain) = match std::env::var("FACETT_HGT") {
            Ok(path) => TerrainField::load_hgt_or_synthetic(path),
            Err(_) => (TerrainField::synthetic(), false),
        };

        let altitude = 1_800.0;
        let mut cam = FlightCamera::free_fly(WorldPos::from_lonlat(clon, clat, altitude));
        cam.pitch = -0.45;
        cam.yaw = 0.0;

        // Split the clipped ways into buildings (extrude), water (flat fill), roads.
        let mut buildings = Vec::new();
        let mut water = Vec::new();
        let mut roads = Vec::new();
        for w in &geo.ways_3d {
            let closed = w.lonlat.len() >= 4
                && (w.lonlat[0].0 - w.lonlat[w.lonlat.len() - 1].0).abs() < 1e-7
                && (w.lonlat[0].1 - w.lonlat[w.lonlat.len() - 1].1).abs() < 1e-7;
            if (w.height_m.is_some() || w.kind.contains("building")) && closed {
                if buildings.len() < MAX_BUILDINGS {
                    buildings.push(w.clone());
                }
            } else if w.fill.is_some() && closed {
                water.push(w.clone());
            } else if roads.len() < MAX_ROAD_SEGMENTS {
                roads.push(w.clone());
            }
        }

        let label = format!(
            "{} · {} buildings · {} roads · {}",
            geo.source.label(),
            buildings.len(),
            roads.len(),
            if real_terrain { "SRTM DEM" } else { "procedural DEM" }
        );

        // **The renderer, built exactly as `osm_tabs.rs` builds it** — `..._roofed` grows
        // gabled/hipped roofs from OSM `roof:shape` plus OSM colours, and the roof accent
        // lifts the caps to a warm highlight. Not a lighter variant "because this is the
        // flight view": a second construction is a second look, and the flat grey houses
        // this file used to draw are what that costs.
        let elev = facett_map3d::TerrainField::from_env().0;
        let map = (!geo.ways_3d.is_empty()).then(|| {
            facett_map3d::Map3D::from_geometry_relief_roofed(
                "3D flight",
                &geo.ways_3d,
                &elev,
                RELIEF_SUBDIV,
                RELIEF_EXAGGERATION,
            )
            .with_roof_accent(0.55)
        });

        Self {
            field,
            real_terrain,
            cam,
            controller: FlightController::default(),
            vexag: 1.5,
            span_deg: 0.25,
            grid: 72,
            buildings,
            roads,
            water,
            label,
            map,
        }
    }

    /// The camera's `(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.
    fn origin(&self) -> FloatingOrigin {
        FloatingOrigin::at(self.cam.position)
    }

    /// Advance the flight one frame, and hand the new pose to the renderer.
    ///
    /// The sync is HERE and not in the painter because the pose is a fact about the
    /// simulation step, not about drawing: a headless drive that steps the flight without
    /// ever painting must still be able to ask the `Map3D` where its camera ended up, or
    /// every camera assertion would be a fact about a painter nobody ran.
    pub fn step(&mut self, frame: &ControlFrame, dt: f64) {
        self.controller.apply(&mut self.cam, frame, dt);
        self.sync_camera();
    }

    /// **Hand the flight rig's pose to the renderer.** The whole of what used to be a
    /// three-hundred-line painter.
    ///
    /// Returns `false` when there is no scene, or when the scene has no geographic span to
    /// place the camera in — see [`facett_map3d::Map3D::look_from_lonlat`], which refuses
    /// rather than parking the camera at the design origin.
    pub fn sync_camera(&mut self) -> bool {
        let (lon, lat, alt) = self.cam_geo();
        // All THREE axes the rig has. Roll used to stop here — the orbit camera had no bank
        // axis and the stick moved nothing on screen — which is why `View` grew one.
        let (yaw, pitch, roll) = (self.cam.yaw, self.cam.pitch, self.cam.roll);
        self.map
            .as_mut()
            .is_some_and(|m| m.look_from_lonlat(lon, lat, alt, yaw, pitch, roll))
    }

    /// **The sun the scene is lit by** — now that there IS one shared sun, the day cycle
    /// reaches the flight view too. The old painter had a hard-coded light vector, so no
    /// clock could ever move it.
    pub fn set_sun(&mut self, sun: facett_map3d::SunSky) {
        if let Some(m) = self.map.as_mut() {
            m.set_sun(sun);
        }
    }

    /// Build the terrain mesh for the current camera position.
    #[must_use]
    pub fn terrain_mesh(&self) -> TerrainMesh {
        let (lon, lat, _alt) = self.cam_geo();
        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.origin(), self.vexag)
    }

}

/// The `(lon, lat)` centroid of a loaded geometry — mean of node points, else mean of
/// way vertices. `None` if the geometry has neither.
fn centroid(geo: &OsmGeometry) -> Option<(f64, f64)> {
    if !geo.points.is_empty() {
        let (mut sl, mut sa) = (0.0, 0.0);
        for &(lon, lat) in &geo.points {
            sl += lon;
            sa += lat;
        }
        let n = geo.points.len() as f64;
        return Some((sl / n, sa / n));
    }
    let mut sl = 0.0;
    let mut sa = 0.0;
    let mut n = 0.0;
    for w in &geo.ways_2d {
        for &(lon, lat) in &w.lonlat {
            sl += lon;
            sa += lat;
            n += 1.0;
        }
    }
    (n > 0.0).then(|| (sl / n, sa / n))
}

// ── the renderer ────────────────────────────────────────────────────────────
//
// There is no painter here any more, and the deletion is the point.
//
// This file used to carry `paint_world`: ~300 lines of its own perspective projection,
// its own flat shading, its own depth fog, its own sky gradient, its own
// painter's-algorithm depth sort and its own terrain / wall / roof / road / water
// colours. It referenced `facett_map3d_flight` (the ENGINE) three times and
// `facett_map3d` (the RENDERER) zero times, while this module's own doc claimed it
// "reuses `facett_map3d_flight` wholesale" — true of the engine, and the engine was
// never the twin. The renderer was.
//
// What it cost, against the sister tab `osm_tabs.rs` which builds a real `Map3D`:
// flat-capped monochrome houses instead of gabled, hipped and OSM-coloured ones; a
// painter's-algorithm sort instead of a z-buffer, so a wall in front of a roof was a
// matter of triangle centroids; a hard-coded light vector instead of the scene's `SunSky`,
// so no day cycle could ever reach it; and a fog that lerped toward one constant sky
// colour instead of the atmospheric haze the shared core computes from that same sun.
//
// LAW 5 says fix it by CONSTRUCTION, not by adding a guard that watches two painters agree.
// So [`FlyWorld`] now owns a `Map3D` built from the same ways, and every frame it hands
// that `Map3D` the flight camera's pose through [`facett_map3d::Map3D::look_from_lonlat`]
// — the seam proven in `facett-map3d/tests/flight_pose_reaches_the_renderer.rs`, where a
// 6DOF pose is shown to be an orbit pose exactly. Z-buffer, tonemap, roofs, colours, haze
// and sun are then inherited rather than reimplemented, and `Map3D::set_sun` makes the day
// cycle available here for the first time.

/// Terrain subdivision for the flight scene's relief mesh — the same rung `osm_tabs.rs`
/// passes, so the two views stand on the same ground.
const RELIEF_SUBDIV: usize = 96;
/// Vertical exaggeration for that relief, likewise shared with the sister tab.
const RELIEF_EXAGGERATION: f32 = 3.0;

/// **Draw the flight world.** One call into the shared renderer, and nothing else.
///
/// `world` must have been [`stepped`](FlyWorld::step) (or
/// [`synced`](FlyWorld::sync_camera)) this frame — the pose belongs to the simulation, not
/// to the paint. A world with no scene draws the honest empty note rather than a blank
/// pane, because a silently blank 3D view is indistinguishable from a broken one.
pub fn paint_world(world: &mut FlyWorld, ui: &mut egui::Ui) {
    match world.map.as_mut() {
        Some(m) => facett::Facet::ui(m, ui),
        None => {
            ui.centered_and_justified(|ui| {
                ui.weak("no extrudable ways in this clip — nothing to fly over");
            });
        }
    }
}
#[cfg(test)]
mod tests {
    use super::*;

    /// **The flight tab draws through the SHARED renderer, and the camera it drives is the
    /// renderer's own.**
    ///
    /// # What this replaces
    ///
    /// `PLAN.md:2106`, verified again 2026-08-07: this file was 929 lines with
    /// `fn paint_world` at :619 — its own projection, shading, fog, sky, depth sort and
    /// colours — and referenced `facett_map3d` (the RENDERER) **zero** times while its own
    /// doc at :430 claimed reuse of `facett_map3d_flight` (the ENGINE). The engine was never
    /// the twin. Per LAW 5 the fix is by construction, not a guard that watches two painters
    /// agree — so there is only one painter left and this asserts that the flight rig
    /// reaches it.
    ///
    /// The assertion is on APPLIED OUTPUT: `Map3D::render_camera()` is the camera the frame
    /// is actually projected with, and `design_from_lonlat` is where the renderer says the
    /// rig's lon/lat lives. A `sync_camera` that returned `true` and wrote nothing fails
    /// here.
    ///
    /// RED directions: make `sync_camera` a no-op returning `true` and the eye never moves;
    /// drop the `self.sync_camera()` from `step` and a flown camera leaves the renderer
    /// behind; build the `Map3D` with `from_geometry` instead of `..._roofed` and
    /// `pitched_roofs()` falls to zero, which is the "houses render FLAT" complaint itself.
    #[test]
    fn the_flight_world_draws_through_map3d_and_the_camera_reaches_it() {
        let Some(geo) = osm_tabs::load_sigtuna() else {
            eprintln!("embedded Sigtuna clip unavailable — skipping");
            return;
        };
        let mut world = FlyWorld::from_geometry(&geo);

        // (1) THERE IS A SHARED RENDERER. Not "the file mentions it" — an actual scene.
        let map = world.map.as_ref().expect("the flight world builds a Map3D");
        assert!(
            map.triangle_count() > 0,
            "the shared renderer must hold a real mesh, got {} triangles",
            map.triangle_count(),
        );
        // (2) AND IT IS THE ROOFED, COLOURED CONSTRUCTION — the one `osm_tabs.rs` uses. The
        //     old painter drew flat monochrome prisms; a plain `from_geometry` here would
        //     reintroduce exactly that while still passing (1).
        assert!(
            map.pitched_roofs() > 0,
            "the flight scene must have PITCHED roofs like the sister tab, got {}",
            map.pitched_roofs(),
        );
        assert!(
            map.coloured_buildings() > 0,
            "…and OSM building colours, got {}",
            map.coloured_buildings(),
        );

        // (3) THE CAMERA REACHES IT. Where the rig says it is, in the renderer's own frame.
        assert!(world.sync_camera(), "a geographic scene accepts the rig's pose");
        let (lon, lat, _alt) = world.cam_geo();
        let map = world.map.as_ref().expect("still there");
        let want = map.design_from_lonlat(lon, lat).expect("the rig is inside the scene");
        let eye = map.render_camera().eye();
        assert!(
            (eye.x - want[0]).abs() < 1e-3 && (eye.z - want[1]).abs() < 1e-3,
            "the renderer's eye must be where the flight rig is: got ({}, {}), want {want:?}",
            eye.x,
            eye.z,
        );

        // (4) AND IT KEEPS REACHING IT. Fly east, and the rendered eye must follow — a sync
        //     that ran once at construction would pass (3) and fail this.
        let before = world.map.as_ref().unwrap().render_camera().eye();
        world.cam.position = WorldPos::from_lonlat(lon + 0.004, lat, 900.0);
        world.sync_camera();
        let after = world.map.as_ref().unwrap().render_camera().eye();
        assert!(
            after.x - before.x > 1e-3,
            "flying EAST must move the rendered eye east: {} → {}",
            before.x,
            after.x,
        );
        assert!(
            (after.y - before.y).abs() > 1e-4,
            "…and a changed altitude must move it vertically: {} → {}",
            before.y,
            after.y,
        );
    }

    /// **The sun reaches the flight view** — which it could not before, because the old
    /// painter's light was a hard-coded vector in `push_tri`.
    ///
    /// RED direction: make `FlyWorld::set_sun` a no-op and the renderer's own `sun()` getter
    /// reports the value it started with.
    #[test]
    fn the_day_cycle_can_now_light_the_flight_view() {
        let Some(geo) = osm_tabs::load_sigtuna() else {
            eprintln!("embedded Sigtuna clip unavailable — skipping");
            return;
        };
        let mut world = FlyWorld::from_geometry(&geo);
        let before = world.map.as_ref().expect("a scene").sun().sun_uniform();
        world.set_sun(facett_map3d::SunSky::golden_hour());
        let after = world.map.as_ref().expect("a scene").sun().sun_uniform();
        assert_ne!(
            before, after,
            "a new sun must reach the renderer's OWN sun uniform, not just a field beside it",
        );
    }

    #[test]
    fn cache_root_default_is_large_disk_never_t9() {
        // With no env override, the default is the large disk and NOT T9.
        let prev = std::env::var("FACETT_OSM_CACHE").ok();
        unsafe {
            std::env::remove_var("FACETT_OSM_CACHE");
        }
        let root = cache_root();
        assert_eq!(root, PathBuf::from(DEFAULT_CACHE_ROOT));
        assert!(!is_on_t9(&root), "default cache root must not be on T9");
        unsafe {
            if let Some(v) = prev {
                std::env::set_var("FACETT_OSM_CACHE", v);
            }
        }
    }

    #[test]
    fn cache_root_refuses_t9_override() {
        let prev = std::env::var("FACETT_OSM_CACHE").ok();
        unsafe {
            std::env::set_var("FACETT_OSM_CACHE", "/run/media/alice/T9/facett-cache");
        }
        // A T9 override is refused → falls back to the large-disk default.
        let root = cache_root();
        assert_eq!(root, PathBuf::from(DEFAULT_CACHE_ROOT));
        assert!(!is_on_t9(&root));
        unsafe {
            match prev {
                Some(v) => std::env::set_var("FACETT_OSM_CACHE", v),
                None => std::env::remove_var("FACETT_OSM_CACHE"),
            }
        }
    }

    #[test]
    fn is_on_t9_detects_the_source_disk() {
        assert!(is_on_t9(Path::new("/run/media/alice/T9/planet.pbf")));
        assert!(is_on_t9(Path::new("/var/run/media/alice/T9")));
        assert!(!is_on_t9(Path::new("/mnt/work4t/facett-osm-cache")));
        assert!(!is_on_t9(Path::new("/home/alice/.cache")));
    }

    #[test]
    fn source_hash_is_stable_and_size_sensitive() {
        // Same missing path → same "no-planet" tag.
        assert_eq!(source_hash(None), "no-planet");
        // A hash for a path is stable across calls.
        let p = Path::new("/nope/planet.osm.pbf");
        assert_eq!(source_hash(Some(p)), source_hash(Some(p)));
        // Different paths → different keys (size is 0 for both missing, path differs).
        assert_ne!(source_hash(Some(Path::new("/a/x.pbf"))), source_hash(Some(Path::new("/b/x.pbf"))));
    }

    #[test]
    fn clip_dir_keyed_by_region_and_hash_under_large_disk() {
        let prev = std::env::var("FACETT_OSM_CACHE").ok();
        unsafe {
            std::env::remove_var("FACETT_OSM_CACHE");
        }
        let dir = region_clip_dir("sweden", "deadbeef");
        assert!(dir.starts_with(DEFAULT_CACHE_ROOT), "under large disk: {}", dir.display());
        assert!(dir.ends_with("sweden-deadbeef-geo"), "keyed by region+hash: {}", dir.display());
        assert!(!is_on_t9(&dir));
        unsafe {
            if let Some(v) = prev {
                std::env::set_var("FACETT_OSM_CACHE", v);
            }
        }
    }

    #[test]
    fn phase_fractions_climb_monotonically_one_continuous_bar() {
        // The bar tells ONE story: each phase's band sits above the previous one's,
        // so the fraction never goes backwards clip → load → build → fly.
        let phases = [FlyPhase::Idle, FlyPhase::Clipping, FlyPhase::Loading, FlyPhase::Building, FlyPhase::Flying];
        let mut last = -1.0f32;
        for p in phases {
            let start = p.fraction(0.0);
            let end = p.fraction(1.0);
            assert!(start >= last - 1e-6, "{} start {start} >= prev {last}", p.tag());
            assert!(end >= start, "{} end {end} >= start {start}", p.tag());
            last = end;
        }
        assert_eq!(FlyPhase::Flying.fraction(0.0), 1.0, "flying = full bar");
    }

    /// **Headless smoke:** the whole flow runs end-to-end WITHOUT a window — resolve
    /// cache → (fallback, since CI has no planet) → load geometry → build a flyable
    /// world with terrain relief, and confirm flying gains altitude. This is the
    /// "the new flow actually runs" green-gate, exercised with no eframe window.
    #[test]
    fn flow_runs_end_to_end_and_is_flyable() {
        let cfg = FlyConfig::sweden();
        assert_eq!(cfg.region, "sweden");
        // Pass `None` for the planet → the flow takes the honest FALLBACK path
        // deterministically (no multi-GB clip, no dependence on a planet being on
        // disk). The demo passes the real `planet_source()` for the full run.
        let (geo, report) = run_flow_blocking(&cfg, None, None);
        // No planet → honest fallback, NOT a faked planet load.
        assert!(!report.real_clip, "no planet → fallback flight (never faked)");
        assert_eq!(report.region, "sweden");
        assert!(!report.cache_hit);
        assert!(!is_on_t9(&report.clip_dir), "clip dir is never on T9");
        assert!(!geo.points.is_empty() || !geo.ways_2d.is_empty(), "fallback has real geometry");

        let mut world = FlyWorld::from_geometry(&geo);
        assert!(!world.real_terrain, "no $FACETT_HGT → procedural DEM");

        // The terrain mesh has real relief.
        let mesh = world.terrain_mesh();
        assert!(mesh.triangle_count() > 0, "terrain has triangles");
        assert!(mesh.max[1] - mesh.min[1] > 1.0, "terrain has vertical relief");
        assert!(mesh.positions.iter().flatten().all(|v| v.is_finite()), "finite positions");

        // Fly: hold climb for ~1 s → gain altitude.
        let (_, _, alt0) = world.cam_geo();
        let climb = ControlFrame { r: true, ..Default::default() };
        for _ in 0..60 {
            world.step(&climb, 1.0 / 60.0);
        }
        let (_, _, alt1) = world.cam_geo();
        assert!(alt1 > alt0 + 1.0, "climbing gains altitude ({alt0} → {alt1})");
    }
}