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