diff --git a/Cargo.lock b/Cargo.lock index e28764a..a12b4c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3624,6 +3624,22 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "viewer_core" +version = "0.1.0" +dependencies = [ + "bytemuck", + "dem_io", + "glyphon", + "half", + "rayon", + "render_gpu", + "terrain", + "web-time", + "wgpu", + "winit", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index f520c61..77038d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/terrain", "crates/render_gpu", "crates/profiling", + "crates/viewer_core", ] resolver = "2" diff --git a/crates/viewer_core/Cargo.toml b/crates/viewer_core/Cargo.toml new file mode 100644 index 0000000..0bd3f41 --- /dev/null +++ b/crates/viewer_core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "viewer_core" +version = "0.1.0" +edition = "2024" + +[lib] +name = "viewer_core" +path = "src/lib.rs" + +[dependencies] +dem_io = { path = "../dem_io" } +terrain = { path = "../terrain" } +render_gpu = { path = "../render_gpu" } +wgpu = "29.0.3" +winit = "0.30" +glyphon = { git = "https://github.com/grovesNL/glyphon", branch = "main" } +bytemuck = { version = "1", features = ["derive"] } +half = { version = "2", features = ["bytemuck"] } +rayon = "1.12.0" +# Drop-in `Instant` that uses std natively and `performance.now()` on wasm. +# Keeps the viewer logic free of the std::time::Instant wasm panic. +web-time = "1" diff --git a/crates/viewer_core/src/app.rs b/crates/viewer_core/src/app.rs new file mode 100644 index 0000000..1845f9c --- /dev/null +++ b/crates/viewer_core/src/app.rs @@ -0,0 +1,1100 @@ +//! `ViewerCore` — the platform-agnostic `winit::ApplicationHandler` that drives +//! the terrain viewer. Mirrors the binary's `viewer::Viewer` but with the +//! camera factored into [`FlyCamera`], the clock swapped to `web_time::Instant`, +//! and all off-thread work / tile I/O routed through the injected +//! [`Spawner`] / [`TileSource`] adapters. The shell builds the EventLoop, +//! window, surface and `GpuContext`, constructs a `ViewerCore`, and delegates +//! winit events to it. + +use std::sync::{Arc, mpsc}; + +use dem_io::Heightmap; +use render_gpu::{GpuContext, GpuScene, VramClass}; +use terrain::ShadowMask; +use web_time::Instant; + +use winit::{ + application::ApplicationHandler, + dpi::LogicalSize, + event::WindowEvent, + keyboard::KeyCode, + window::{Window, WindowAttributes}, +}; + +use crate::camera::FlyCamera; +use crate::consts::M_PER_DEG; +use crate::geo::{latlon_to_tile_metres, sun_position}; +use crate::hud::HudRenderer; +use crate::platform::{Spawner, TileSource}; +use crate::scene_build::{INIT_SIM_DAY, INIT_SIM_HOUR, compute_ao_cropped}; +use crate::tiers::{ + AO_DRIFT_THRESHOLD_M, BevBaseState, TierRadii, cross_crs_world_origin_and_extent, tier_radii, +}; +use crate::tile_index::TileIndex; + +/// Pre-computed terrain data produced by the loading step (in the shell), handed +/// to [`ViewerCore::new`] so no loading work happens after construction. +pub struct PreparedScene { + pub scene: GpuScene, + pub hm: Arc, + pub lat_rad: f32, + pub width: u32, + pub height: u32, + /// Overview cache built during loading (for projected high-res tiles). + pub cache_path: Option, +} + +/// Initial camera placement (WGS84) and orientation. `Default` reproduces the +/// binary's hard-coded Hintertux-glacier start view. +#[derive(Clone, Copy, Debug)] +pub struct InitialView { + pub cam_lat: f64, + pub cam_lon: f64, + pub cam_elev: f32, + pub yaw: f32, + pub pitch: f32, +} + +impl Default for InitialView { + fn default() -> Self { + InitialView { + cam_lat: 47.076211, // 47°04'34.36"N + cam_lon: 11.687592, // 11°41'15.33"E + cam_elev: 3258.0, + yaw: (19627.0_f32).atan2(1718.0_f32), + pitch: (-3472.0_f32).atan2(19702.0_f32), + } + } +} + +/// Quality + behaviour settings, decoupled from the binary's launcher config. +pub struct ViewerSettings { + pub vsync: bool, + pub ao_mode: u32, + pub shadows_enabled: bool, + pub fog_enabled: bool, + pub vat_mode: u32, + pub lod_mode: u32, + pub vram_class: VramClass, + pub initial_view: InitialView, +} + +/// How the viewer streams (or doesn't) tiles. Tile indices are built by the +/// shell (discovery is I/O) and passed in ready. +pub enum TierSetup { + /// Static single tile — no sliding (geographic single-file mode). + Static, + /// Three-tier streaming over pre-built indices. + Streaming { + fine_index: Arc, + close_index: Arc, + base_index: Arc, + cam_lat: f64, + cam_lon: f64, + }, +} + +pub struct ViewerCore { + scene: Option, + window: Option>, + /// Surface handed over from the shell — reconfigured in resumed() instead of + /// being recreated, which eliminates the visible flash during a transition. + pre_surface: Option>, + surface: Option>, + surface_config: Option, + width: u32, + height: u32, + render_width: u32, + vsync: bool, + ao_mode: u32, + shadows_enabled: bool, + fog_enabled: bool, + vat_mode: u32, // 0=Ultra, 1=High, 2=Mid, 3=Low + lod_mode: u32, // 0=Ultra, 1=High, 2=Mid, 3=Low + smooth_radius_m: f32, // close-range bicubic smoothing radius (f32::MAX = off) + // fps counter + fps_timer: Instant, + frame_count: u32, + fps: f64, + // camera controls + last_frame: Instant, + camera: FlyCamera, + // hud + hud_renderer: Option, + hud_visible: bool, + // sun animation — date/time driven + sim_day: i32, // 1–365 + sim_hour: f32, // 0.0–24.0 solar time + lat_rad: f32, // tile centre latitude (radians) + day_accum: f32, // fractional day accumulator for [ / ] keys + shadow_tx: mpsc::SyncSender<(f32, f32)>, + shadow_rx: mpsc::Receiver, + shadow_computing: bool, + last_shadow_az: f32, + last_shadow_el: f32, + // drift-based AO recompute + ao_tx: mpsc::SyncSender<(f64, f64)>, + ao_rx: mpsc::Receiver>, + ao_computing: bool, + ao_last_x: f64, // tile-local metres of last AO centre + ao_last_y: f64, + // detail-tier speed gate: close/fine tiers are suppressed while flying fast + detail_allowed_since: Option, + // base heightmap (shared with shadow worker; replaced on tile slide) + hm: Arc, + bev_base: Option, + tier_radii: TierRadii, + close_tier_disabled: bool, + fine_disabled_by_oom: bool, + align_mode_viz: bool, // V key: show all 3 tiers as separate colored surfaces + // Injected spawner — retained so a base-tier reload can respawn the shadow + // and AO workers. The tile_source is consumed at construction (each tier + // worker captures its own clone), so it is not stored. + spawner: Arc, +} + +impl ViewerCore { + /// Build a fully wired `ViewerCore` from a pre-loaded scene. Surface + /// configuration and HUD setup happen later inside `resumed()`, which the + /// shell calls immediately after this. + #[allow(clippy::too_many_arguments)] + pub fn new( + window: Arc, + surface: wgpu::Surface<'static>, + prepared: PreparedScene, + settings: ViewerSettings, + setup: TierSetup, + tile_source: Arc, + spawner: Arc, + ) -> Self { + let PreparedScene { + mut scene, + hm, + lat_rad, + width, + height, + cache_path: _, + } = prepared; + let dx: f32 = scene.get_dx_meters(); + let dy: f32 = scene.get_dy_meters(); + + let chosen_class = settings.vram_class; + let radii = tier_radii(chosen_class); + eprintln!( + "[tier] vram_class={chosen_class:?} (adapter detected={:?}); \ + radii: base {:.0} km / drift {:.0} km, close {:.0} km / drift {:.0} km, fine {:.1} km / drift {:.1} km", + scene.get_gpu_ctx().vram_class, + radii.base_radius_m / 1000.0, + radii.base_drift_m / 1000.0, + radii.close_radius_m / 1000.0, + radii.close_drift_m / 1000.0, + radii.fine_radius_m / 1000.0, + radii.fine_drift_m / 1000.0, + ); + + let iv = settings.initial_view; + let init_cam_pos = latlon_to_tile_metres(iv.cam_lat, iv.cam_lon, &hm) + .map(|(x, y)| [x, y, iv.cam_elev]) + .unwrap_or_else(|| { + let cx = hm.cols as f32 * dx * 0.5; + let cy = hm.rows as f32 * dy * 0.5; + let center_i = (hm.rows / 2) * hm.cols + hm.cols / 2; + let elev = hm + .data + .get(center_i) + .copied() + .filter(|&v| v > -1000.0) + .unwrap_or(1000.0); + [cx, cy, elev + 2000.0] + }); + let camera = FlyCamera::new(init_cam_pos, iv.yaw, iv.pitch); + + // shadow worker + let (shadow_tx, worker_rx) = mpsc::sync_channel::<(f32, f32)>(1); + let (worker_tx, shadow_rx) = mpsc::channel::(); + { + let hm_w = Arc::clone(&hm); + spawner.spawn(Box::new(move || { + while let Ok((az, el)) = worker_rx.recv() { + let mask = + terrain::compute_shadow_vector_par_with_azimuth(&hm_w, az, el, 200.0); + if worker_tx.send(mask).is_err() { + break; + } + } + })); + } + + // AO worker + let (ao_tx, ao_worker_rx) = mpsc::sync_channel::<(f64, f64)>(1); + let (ao_worker_tx, ao_rx) = mpsc::channel::>(); + { + let hm_ao = Arc::clone(&hm); + spawner.spawn(Box::new(move || { + while let Ok((cam_x, cam_y)) = ao_worker_rx.recv() { + let ao = compute_ao_cropped(&hm_ao, cam_x, cam_y); + if ao_worker_tx.send(render_gpu::pack_ao_u8(&ao)).is_err() { + break; + } + } + })); + } + + let bev_base: Option = match setup { + TierSetup::Static => None, + TierSetup::Streaming { + fine_index, + close_index, + base_index, + cam_lat, + cam_lon, + } => Some(BevBaseState::new( + fine_index, + close_index, + base_index, + cam_lat, + cam_lon, + lat_rad, + radii, + &hm, + &mut scene, + &tile_source, + spawner.as_ref(), + )), + }; + + ViewerCore { + scene: Some(scene), + window: Some(window), + pre_surface: Some(surface), + surface: None, + surface_config: None, + width, + height, + render_width: width, + vsync: settings.vsync, + ao_mode: settings.ao_mode, + shadows_enabled: settings.shadows_enabled, + fog_enabled: settings.fog_enabled, + vat_mode: settings.vat_mode, + lod_mode: settings.lod_mode, + smooth_radius_m: 2000.0, + fps_timer: Instant::now(), + frame_count: 0, + fps: 0.0, + last_frame: Instant::now(), + camera, + hud_renderer: None, + hud_visible: true, + sim_day: INIT_SIM_DAY, + sim_hour: INIT_SIM_HOUR, + lat_rad, + day_accum: 0.0, + shadow_tx, + shadow_rx, + shadow_computing: false, + last_shadow_az: 0.0, + last_shadow_el: -1.0, + ao_tx, + ao_rx, + ao_computing: false, + ao_last_x: init_cam_pos[0] as f64, + ao_last_y: init_cam_pos[1] as f64, + detail_allowed_since: Some(Instant::now()), + hm, + bev_base, + tier_radii: radii, + close_tier_disabled: false, + fine_disabled_by_oom: false, + align_mode_viz: false, + spawner, + } + } + + /// Poll the global OOM flag set by `device.on_uncaptured_error` and degrade + /// the active tier set instead of letting the next allocation panic. + /// + /// Step-down order: + /// 1. Fine tier — disabled, GPU memory reclaimed via `set_hm1m_inactive`. + /// 2. Close tier — disabled, GPU memory reclaimed via `set_hm5m_inactive`. + /// 3. If both already disabled and we still OOM, give up — the base tier + /// itself isn't safe to drop, so we let wgpu's default behaviour panic. + fn poll_and_handle_oom(&mut self) { + if !render_gpu::OOM_OBSERVED.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + render_gpu::clear_oom_flag(); + let count = render_gpu::OOM_COUNT.load(std::sync::atomic::Ordering::Relaxed); + + let Some(scene) = self.scene.as_mut() else { + return; + }; + + // Step 1: kill fine tier if it's still alive. + if let Some(ref mut bev_base) = self.bev_base + && bev_base.fine.is_some() + { + eprintln!( + "[OOM #{count}] disabling fine tier — freeing ~hm1m_tex + normal + shadow memory" + ); + scene.set_hm1m_inactive(); + bev_base.fine = None; + self.tier_radii.fine_radius_m = 0.0; + self.tier_radii.fine_drift_m = 0.0; + self.fine_disabled_by_oom = true; + return; + } + + // Step 2: kill close tier. + if !self.close_tier_disabled { + eprintln!( + "[OOM #{count}] disabling close tier — freeing ~hm5m_tex + normal + shadow memory" + ); + scene.set_hm5m_inactive(); + self.close_tier_disabled = true; + self.tier_radii.close_radius_m = 0.0; + self.tier_radii.close_drift_m = 0.0; + return; + } + + // Step 3: both detail tiers gone and the base tier won't fit. There's + // no graceful path from here — keep running on whatever's currently + // bound and let the user see the warning. + eprintln!("[OOM #{count}] all detail tiers already disabled — base tier is the floor"); + } +} + +/// Convert tile-local camera position (metres from top-left) to WGS84 (lat, lon). +fn cam_wgs84(cam_pos: [f32; 3], hm: &Heightmap) -> (f64, f64) { + if dem_io::crs::is_geographic(&hm.crs_proj4) { + // dx_meters is unreliable for geographic tiles: extract_window stores deg/px there, + // while parse_geotiff and stitch_windows_geographic store actual m/px. + // Always derive m/px from dx_deg (reliably deg/px in all code paths). + let dx_m = hm.dx_deg * M_PER_DEG * hm.crs_origin_y.to_radians().cos(); + let dy_m = hm.dy_deg.abs() * M_PER_DEG; + let px = cam_pos[0] as f64 / dx_m; + let py = cam_pos[1] as f64 / dy_m; + let lon = hm.crs_origin_x + px * hm.dx_deg; + let lat = hm.crs_origin_y - py * hm.dy_deg.abs(); + (lat, lon) + } else { + let e = hm.crs_origin_x + cam_pos[0] as f64; + let n = hm.crs_origin_y - cam_pos[1] as f64; + dem_io::crs::to_wgs84(e, n, &hm.crs_proj4).unwrap_or((0.0, 0.0)) + } +} + +impl ApplicationHandler for ViewerCore { + fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) { + // Reuse a pre-built window (from the shell) if one was provided. + let window: Arc = if let Some(w) = &self.window { + // The shell's event loop exit may have hidden the window on macOS. + w.set_visible(true); + w.focus_window(); + w.clone() + } else { + let w = Arc::new( + event_loop + .create_window( + WindowAttributes::default() + .with_title("dem_renderer") + .with_inner_size(LogicalSize::new(self.width, self.height)), + ) + .expect("error creating a window from event loop in resumed method call"), + ); + self.window = Some(w.clone()); + w + }; + + // Sync dimensions with the actual window — the user may have resized the window + // between pressing Start and the viewer initialising. Do this before any GPU + // allocations so the surface config, HUD, and scene buffer all use the right size. + { + let sz = window.inner_size(); + let actual_w = sz.width.max(1); + let actual_h = sz.height.max(1); + if actual_w != self.width || actual_h != self.height { + self.width = actual_w; + self.render_width = (actual_w + 63) & !63; + self.height = actual_h; + self.scene + .as_mut() + .unwrap() + .resize(self.render_width, actual_h); + } + } + + let scene: &GpuScene = self + .scene + .as_ref() + .expect("no scene to get ctx for resumed method run"); + + // Reuse the shell's surface if one was handed over — reconfiguring in-place + // avoids the drop+recreate that would cause a visible flash during the transition. + let surface = if let Some(s) = self.pre_surface.take() { + s + } else { + scene + .get_gpu_ctx() + .instance + .create_surface(window.clone()) + .expect("error creating a surface from default Instance in resumed method") + }; + self.surface = Some(surface); + + // surface configuration + let ctx: &GpuContext = scene.get_gpu_ctx(); + let adapter: &wgpu::Adapter = &ctx.adapter; + let caps = self + .surface + .as_ref() + .expect("no surface to get capabilities") + .get_capabilities(adapter); + let format = caps + .formats + .iter() + .find(|&&f| f == wgpu::TextureFormat::Bgra8Unorm) + .copied() + .unwrap_or(caps.formats[0]); + + // HUD — created with the correct (possibly resized) dimensions. + let hud_renderer: HudRenderer = HudRenderer::new( + &scene.get_gpu_ctx().device, + &scene.get_gpu_ctx().queue, + self.width, + self.height, + format, + ); + self.hud_renderer = Some(hud_renderer); + + let mut present_mode: wgpu::PresentMode = wgpu::PresentMode::Immediate; + if self.vsync { + present_mode = wgpu::PresentMode::Fifo; + } else if !caps.present_modes.contains(&wgpu::PresentMode::Immediate) { + present_mode = wgpu::PresentMode::Fifo; + println!( + "present mode in capabilities not fount: wgpu::PresentMode::Immediate; FALLBACK to wgpu::PresentMode::Fifo" + ) + } + + let config: wgpu::SurfaceConfiguration = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: self.width, + height: self.height, + present_mode, + alpha_mode: caps.alpha_modes[0], + view_formats: vec![], + desired_maximum_frame_latency: 2, + }; + let device: &wgpu::Device = &ctx.device; + self.surface + .as_ref() + .expect("no surface to configure") + .configure(device, &config); + self.surface_config = Some(config); + + self.window + .as_ref() + .expect("no window for resumed method call") + .request_redraw(); + } + + fn window_event( + &mut self, + event_loop: &winit::event_loop::ActiveEventLoop, + _window_id: winit::window::WindowId, + event: winit::event::WindowEvent, + ) { + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::RedrawRequested => { + // OOM safety net: react before any new allocation can be requested + // this frame, so the degradation lands before the next reload tries. + self.poll_and_handle_oom(); + + // delta time for frame-rate-independent camera movement + let dt = self.last_frame.elapsed().as_secs_f32(); + self.last_frame = Instant::now(); + + // cam movements + let cam_pos_before = self.camera.pos; + self.camera.update(dt); + + // Keep the camera inside the loaded heightmap so the shader's bounds-check + // never fires on the first ray step (which would render a solid blue frame). + let hm_max_x = self.hm.cols as f32 * self.hm.dx_meters as f32 - 1.0; + let hm_max_y = self.hm.rows as f32 * self.hm.dy_meters as f32 - 1.0; + self.camera.pos[0] = self.camera.pos[0].clamp(1.0, hm_max_x); + self.camera.pos[1] = self.camera.pos[1].clamp(1.0, hm_max_y); + + // Speed gate for close/fine tier triggers. + // At boost speed (5000 m/s) loading a 20 km close-tier window is pointless — + // the camera leaves before compute finishes. Gate on speed so triggers only + // fire when the camera has been slow (< 2500 m/s) for 400 ms continuously. + // Normal speed is 500 m/s, so the threshold sits cleanly between the two. + const DETAIL_SPEED_GATE: f32 = 2500.0; // m/s + const DETAIL_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(400); + let cam_moved = { + let dx = self.camera.pos[0] - cam_pos_before[0]; + let dy = self.camera.pos[1] - cam_pos_before[1]; + (dx * dx + dy * dy).sqrt() + }; + let cam_speed_est = cam_moved / dt.max(0.001); + let was_fast = self.detail_allowed_since.is_none(); + if cam_speed_est > DETAIL_SPEED_GATE { + self.detail_allowed_since = None; + } else if self.detail_allowed_since.is_none() { + self.detail_allowed_since = Some(Instant::now()); + } + let is_fast = self.detail_allowed_since.is_none(); + if is_fast && !was_fast { + println!("camera moving fast ({cam_speed_est:.0} m/s) — detail suppressed"); + } else if !is_fast && was_fast { + println!("camera slowed ({cam_speed_est:.0} m/s) — debouncing"); + } + let detail_allowed = self + .detail_allowed_since + .map(|t| t.elapsed() >= DETAIL_DEBOUNCE) + .unwrap_or(false); + + let look_at = self.camera.look_at(); + + // advance time (+/-) and day ([ / ]) + let time_speed = if self.camera.speed_boost { 4.0_f32 } else { 0.4_f32 }; // hours/s + let day_speed = if self.camera.speed_boost { 60.0_f32 } else { 10.0_f32 }; // days/s + if self.camera.keys_held.contains(&KeyCode::Equal) { + self.sim_hour = (self.sim_hour + time_speed * dt).rem_euclid(24.0); + } + if self.camera.keys_held.contains(&KeyCode::Minus) { + self.sim_hour = (self.sim_hour - time_speed * dt).rem_euclid(24.0); + } + if self.camera.keys_held.contains(&KeyCode::BracketRight) { + self.day_accum += day_speed * dt; + } + if self.camera.keys_held.contains(&KeyCode::BracketLeft) { + self.day_accum -= day_speed * dt; + } + if self.day_accum.abs() >= 1.0 { + let steps = self.day_accum.trunc() as i32; + self.sim_day = (self.sim_day + steps - 1).rem_euclid(365) + 1; + self.day_accum = self.day_accum.fract(); + } + + // derive sun direction before acquiring scene borrow + let (azimuth, elevation) = sun_position(self.lat_rad, self.sim_day, self.sim_hour); + let r = elevation.cos(); + let sun_dir = [r * azimuth.sin(), -r * azimuth.cos(), elevation.sin()]; + + // pick up finished shadow mask if ready + if let Ok(new_mask) = self.shadow_rx.try_recv() { + self.scene + .as_ref() + .expect("no scene for shadow update") + .update_shadow(&new_mask); + self.shadow_computing = false; + } + + // recompute shadow only when sun moves more than 0.1° (≈ 2 min real time at 0.4h/s) + let sun_moved = (azimuth - self.last_shadow_az).abs() > 0.00175 + || (elevation - self.last_shadow_el).abs() > 0.00175; + if !self.shadow_computing + && elevation > 0.0 + && sun_moved + && self.shadow_tx.try_send((azimuth, elevation)).is_ok() + { + self.shadow_computing = true; + self.last_shadow_az = azimuth; + self.last_shadow_el = elevation; + } + + // drift-based AO recompute (5 km threshold in tile-local metres) + if let Ok(new_ao) = self.ao_rx.try_recv() { + self.scene.as_ref().unwrap().update_ao(&new_ao); + self.ao_computing = false; + } + if !self.ao_computing { + let cam_x = self.camera.pos[0] as f64; + let cam_y = self.camera.pos[1] as f64; + // recompute AO when camera drifts far enough that the 20km radius + // no longer fully covers the new position with good data + if ((cam_x - self.ao_last_x).abs() > AO_DRIFT_THRESHOLD_M + || (cam_y - self.ao_last_y).abs() > AO_DRIFT_THRESHOLD_M) + && self.ao_tx.try_send((cam_x, cam_y)).is_ok() + { + self.ao_computing = true; + self.ao_last_x = cam_x; + self.ao_last_y = cam_y; + println!("AO recompute triggered at ({cam_x:.0}, {cam_y:.0})"); + } + } + + // BEV two-tier drift reload + if let Some(ref mut bev_base) = self.bev_base { + // ── base tier ── + if let Some(data) = bev_base.base.try_recv() { + // re-project camera to new heightmap tile-local metres + let (lat, lon) = cam_wgs84(self.camera.pos, &self.hm); + if let Some((nx, ny)) = latlon_to_tile_metres(lat, lon, &data.hm) { + self.camera.pos[0] = nx; + self.camera.pos[1] = ny; + } else { + // Camera drifted past the new tile's extent while loading. + // Place at tile centre so the shader never fires an all-blue frame. + let (tile_w, tile_h) = if dem_io::crs::is_geographic(&data.hm.crs_proj4) + { + let dx_m = (data.hm.dx_deg + * M_PER_DEG + * data.hm.crs_origin_y.to_radians().cos()) + as f32; + let dy_m = (data.hm.dy_deg.abs() * M_PER_DEG) as f32; + (data.hm.cols as f32 * dx_m, data.hm.rows as f32 * dy_m) + } else { + ( + data.hm.cols as f32 * data.hm.dx_meters as f32, + data.hm.rows as f32 * data.hm.dy_meters as f32, + ) + }; + println!( + "WARN: camera ({lat:.4}°, {lon:.4}°) outside new base tile — snapping to centre" + ); + self.camera.pos[0] = (tile_w * 0.5).clamp(1.0, tile_w - 1.0); + self.camera.pos[1] = (tile_h * 0.5).clamp(1.0, tile_h - 1.0); + } + { + let scene = self.scene.as_mut().unwrap(); + scene.update_heightmap( + &data.hm, + &data.gpu_hm_f16, + &data.gpu_hm_mips, + &data.gpu_normals_u32, + &data.gpu_ao_u8, + ); + scene.update_shadow(&data.shadow); + // The fine-tier origins are offsets relative to the base heightmap origin. + // After a base reload the origin shifts, so the old offsets are wrong — + // hide both fine tiers until their workers deliver fresh windows. + scene.set_hm5m_inactive(); + scene.set_hm1m_inactive(); + } + self.hm = data.hm; + // Recalibrate drift threshold to match the actual loaded window. + let new_half_m = (self.hm.cols as f64 * self.hm.dx_meters) + .min(self.hm.rows as f64 * self.hm.dy_meters) + * 0.5; + let new_thresh = self.tier_radii.base_drift_m.min(new_half_m * 0.5); + // Geographic base tracks position in degrees; convert the metre threshold. + let new_thresh_unit = if dem_io::crs::is_geographic(&self.hm.crs_proj4) { + new_thresh / M_PER_DEG + } else { + new_thresh + }; + bev_base.base.update_threshold(new_thresh_unit); + // close and fine tier offsets were relative to the old base origin — must reload + bev_base.close.invalidate(); + if let Some(ref mut fine) = bev_base.fine { + fine.invalidate(); + } + // Respawn shadow worker with updated heightmap. + let (new_tx, new_worker_rx) = mpsc::sync_channel::<(f32, f32)>(1); + let (new_worker_tx, new_rx) = mpsc::channel::(); + let old_tx = std::mem::replace(&mut self.shadow_tx, new_tx); + let _ = std::mem::replace(&mut self.shadow_rx, new_rx); + drop(old_tx); + self.shadow_computing = false; + let hm_w = Arc::clone(&self.hm); + self.spawner.spawn(Box::new(move || { + while let Ok((az, el)) = new_worker_rx.recv() { + let mask = terrain::compute_shadow_vector_par_with_azimuth( + &hm_w, az, el, 200.0, + ); + if new_worker_tx.send(mask).is_err() { + break; + } + } + })); + // Respawn AO worker with updated heightmap so AO data matches the new + // tile's dimensions and terrain layout. + let (new_ao_tx, new_ao_worker_rx) = mpsc::sync_channel::<(f64, f64)>(1); + let (new_ao_worker_tx, new_ao_rx) = mpsc::channel::>(); + let old_ao_tx = std::mem::replace(&mut self.ao_tx, new_ao_tx); + let _ = std::mem::replace(&mut self.ao_rx, new_ao_rx); + drop(old_ao_tx); + self.ao_computing = false; + // Force an immediate AO recompute for the new tile centre. + self.ao_last_x = f64::MAX; + self.ao_last_y = f64::MAX; + let hm_ao = Arc::clone(&self.hm); + self.spawner.spawn(Box::new(move || { + while let Ok((cam_x, cam_y)) = new_ao_worker_rx.recv() { + let ao = compute_ao_cropped(&hm_ao, cam_x, cam_y); + if new_ao_worker_tx.send(render_gpu::pack_ao_u8(&ao)).is_err() { + break; + } + } + })); + println!( + "BEV base reloaded: {}×{} at {:.1}m/px", + self.hm.cols, self.hm.rows, self.hm.dx_meters + ); + } + // Always check base drift even while a reload is in-flight. + { + let (lat, lon) = cam_wgs84(self.camera.pos, &self.hm); + if !bev_base.base.computing + && bev_base.base.needs_reload(lat, lon) + && bev_base.base.try_trigger(lat, lon) + { + println!("BEV base reload triggered at lat={lat:.4} lon={lon:.4}"); + } + } + + // ── 5 m close tier ── + if let Some(data) = bev_base.close.try_recv() { + // After an OOM-driven shutdown the worker may still + // deliver one in-flight reload — drop it on the floor + // instead of re-allocating the texture we just freed. + if self.close_tier_disabled { + eprintln!("[OOM] discarding in-flight close reload (tier disabled)"); + } else { + let (origin_x, origin_y, extent_x, extent_y, rot_rad) = + cross_crs_world_origin_and_extent(&data.hm, &self.hm); + self.scene.as_mut().unwrap().upload_hm5m( + origin_x, + origin_y, + rot_rad, + extent_x, + extent_y, + &data.hm, + &data.gpu_normals_rg16, + &data.shadow, + ); + println!( + "5m tier updated: {}×{} at {:.1}m/px", + data.hm.cols, data.hm.rows, data.hm.dx_meters + ); + } + } + if detail_allowed && !bev_base.close.computing && !self.close_tier_disabled { + let (lat, lon) = cam_wgs84(self.camera.pos, &self.hm); + if bev_base.close.needs_reload(lat, lon) + && bev_base.close.try_trigger(lat, lon) + { + println!("5m reload triggered at lat={lat:.4} lon={lon:.4}"); + } + } + + // ── 1 m fine tier ── + if let Some(ref mut fine) = bev_base.fine { + if let Some(data) = fine.try_recv() { + let (origin_x, origin_y, extent_x, extent_y, rot_rad) = + cross_crs_world_origin_and_extent(&data.hm, &self.hm); + self.scene.as_mut().unwrap().upload_hm1m( + origin_x, + origin_y, + rot_rad, + extent_x, + extent_y, + &data.hm, + &data.gpu_normals_rg16, + &data.shadow, + ); + println!( + "1m tier updated: {}×{} at {:.1}m/px", + data.hm.cols, data.hm.rows, data.hm.dx_meters + ); + } + if detail_allowed && !fine.computing { + let (lat, lon) = cam_wgs84(self.camera.pos, &self.hm); + if fine.needs_reload(lat, lon) && fine.try_trigger(lat, lon) { + println!("1m reload triggered at lat={lat:.4} lon={lon:.4}"); + } + } + } + } + + let surface: &wgpu::Surface = + self.surface.as_ref().expect("no surface for window event"); + let scene: &GpuScene = self.scene.as_ref().expect("no scene for window event"); + let ctx: &GpuContext = scene.get_gpu_ctx(); + let surface_texture = match surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(t) => t, + wgpu::CurrentSurfaceTexture::Suboptimal(t) => t, + _ => return, // Timeout or occluded — skip this frame + }; + + let mut encoder = + ctx.device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("blit_enc"), + }); + + let vat_step_divisors = [20.0_f32, 10.0, 5.0, 3.0]; + let step_m = scene.get_dx_meters() / vat_step_divisors[self.vat_mode as usize]; + scene.dispatch_frame( + &mut encoder, + self.camera.pos, + look_at, + 70.0, + self.width as f32 / self.height as f32, + sun_dir, + step_m, + 200_000.0, + self.ao_mode, + self.shadows_enabled as u32, + self.fog_enabled as u32, + self.vat_mode, + self.lod_mode, + self.smooth_radius_m, + self.align_mode_viz as u32, + ); + let output_buf: &wgpu::Buffer = scene.get_output_buffer(); + + encoder.copy_buffer_to_texture( + wgpu::TexelCopyBufferInfo { + buffer: output_buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(self.render_width * 4), // 4 bytes per RGBA pixel + rows_per_image: None, + }, + }, + surface_texture.texture.as_image_copy(), + wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + ); + + // HUD + if self.hud_visible { + let surface_view = surface_texture + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let hud = self.hud_renderer.as_mut().expect("no hud renderer"); + hud.set_oom_state(self.fine_disabled_by_oom, self.close_tier_disabled); + hud.draw( + &scene.get_gpu_ctx().queue, + &scene.get_gpu_ctx().device, + &mut encoder, + &surface_view, + self.fps as f32, + 1000.0, + self.sim_day, + self.sim_hour, + self.ao_mode, + self.shadows_enabled, + self.fog_enabled, + self.vat_mode, + self.lod_mode, + self.smooth_radius_m, + ); + } + + ctx.queue.submit([encoder.finish()]); + surface_texture.present(); + + // fps counter + self.frame_count += 1; + let elapsed = self.fps_timer.elapsed().as_secs_f64(); + if elapsed >= 1.0 { + self.fps = self.frame_count as f64 / elapsed; + self.fps_timer = Instant::now(); + self.frame_count = 0; + self.window.as_ref().unwrap().set_title(&format!( + "dem_renderer {:.0} fps {:.1} ms", + self.fps, + 1000.0 / self.fps + )); + } + + self.window + .as_ref() + .expect("no window for window event") + .request_redraw(); + } + WindowEvent::KeyboardInput { + device_id: _, + event, + is_synthetic: _, + } => { + if let winit::keyboard::PhysicalKey::Code(kc) = event.physical_key { + if kc == KeyCode::KeyQ && event.state == winit::event::ElementState::Pressed { + if !self.camera.immersive_mode { + self.camera.immersive_mode = true; + let _ = self + .window + .as_ref() + .unwrap() + .set_cursor_grab(winit::window::CursorGrabMode::Locked); + self.window.as_ref().unwrap().set_cursor_visible(false); + } else { + self.camera.immersive_mode = false; + let _ = self + .window + .as_ref() + .unwrap() + .set_cursor_grab(winit::window::CursorGrabMode::None); + self.window.as_ref().unwrap().set_cursor_visible(true); + } + + return; + } + if kc == KeyCode::KeyE && event.state == winit::event::ElementState::Pressed { + self.hud_visible = !self.hud_visible; + return; + } + if kc == KeyCode::Slash && event.state == winit::event::ElementState::Pressed { + self.ao_mode = (self.ao_mode + 1).rem_euclid(6); + return; + } + if kc == KeyCode::Period && event.state == winit::event::ElementState::Pressed { + self.shadows_enabled = !self.shadows_enabled; + return; + } + if kc == KeyCode::Comma && event.state == winit::event::ElementState::Pressed { + self.fog_enabled = !self.fog_enabled; + return; + } + if kc == KeyCode::Semicolon + && event.state == winit::event::ElementState::Pressed + { + self.vat_mode = (self.vat_mode + 1).rem_euclid(4); + return; + } + if kc == KeyCode::Quote && event.state == winit::event::ElementState::Pressed { + self.lod_mode = (self.lod_mode + 1).rem_euclid(4); + return; + } + if kc == KeyCode::KeyV && event.state == winit::event::ElementState::Pressed { + self.align_mode_viz = !self.align_mode_viz; + return; + } + if kc == KeyCode::KeyB && event.state == winit::event::ElementState::Pressed { + // 0.0 = off (dist < 0 never true), other values = active radius + let presets = [0.0_f32, 500.0, 1000.0, 2000.0, 5000.0]; + let cur = presets + .iter() + .position(|&r| r >= self.smooth_radius_m) + .unwrap_or(0); + self.smooth_radius_m = presets[(cur + 1) % presets.len()]; + return; + } + // Debug: force close + fine tier reloads on the next frame. + // Used to repro tier-swap memory peaks without flying. + if kc == KeyCode::KeyR && event.state == winit::event::ElementState::Pressed { + if let Some(ref mut bev_base) = self.bev_base { + bev_base.close.invalidate(); + if let Some(ref mut fine) = bev_base.fine { + fine.invalidate(); + } + eprintln!("[vram] debug: close + fine tiers invalidated (R)"); + } + return; + } + // Debug: simulate a wgpu OOM event. Used to test the + // degradation path on machines that don't actually OOM + // (M4 Max, high-VRAM cards). Each press steps down one + // tier (fine → close → no-op). + if kc == KeyCode::KeyO && event.state == winit::event::ElementState::Pressed { + render_gpu::signal_oom_for_testing(); + eprintln!("[vram] debug: simulated OOM (O)"); + return; + } + if kc == KeyCode::ShiftLeft { + match event.state { + winit::event::ElementState::Pressed => { + self.camera.speed_boost = true; + } + winit::event::ElementState::Released => { + self.camera.speed_boost = false; + } + } + return; + } + + match event.state { + winit::event::ElementState::Pressed => self.camera.keys_held.insert(kc), + winit::event::ElementState::Released => self.camera.keys_held.remove(&kc), + }; + } + } + WindowEvent::MouseInput { state, button, .. } + if button == winit::event::MouseButton::Left && !self.camera.immersive_mode => + { + match state { + winit::event::ElementState::Pressed => { + self.camera.mouse_look = true; + let _ = self + .window + .as_ref() + .unwrap() + .set_cursor_grab(winit::window::CursorGrabMode::Locked); + self.window.as_ref().unwrap().set_cursor_visible(false); + } + winit::event::ElementState::Released => { + self.camera.mouse_look = false; + let _ = self + .window + .as_ref() + .unwrap() + .set_cursor_grab(winit::window::CursorGrabMode::None); + self.window.as_ref().unwrap().set_cursor_visible(true); + } + } + } + WindowEvent::Resized(new_size) => { + // 1. guard against zero-size (happens on minimize on some platforms) + if new_size.width == 0 || new_size.height == 0 { + return; + } + + // 2. update stored dimensions + self.width = new_size.width; + self.render_width = (new_size.width + 63) & !63; + self.height = new_size.height; + + // 3. reconfigure the surface + if let (Some(surface), Some(cfg), Some(scene)) = + (&self.surface, &mut self.surface_config, &mut self.scene) + { + cfg.width = new_size.width; + cfg.height = new_size.height; + surface.configure(&scene.get_gpu_ctx().device, cfg); + + // 4. reallocate output buffer in GpuScene + // surface.configure keeps using self.width (actual) + scene.resize(self.render_width, self.height); + } + + // update hint hud + self.hud_renderer + .as_mut() + .expect("no hud renderer") + .update_size( + &self + .scene + .as_ref() + .expect("no scene for hud resize") + .get_gpu_ctx() + .queue, + new_size.width, + new_size.height, + ); + } + _ => {} + } + } + + fn device_event( + &mut self, + _event_loop: &winit::event_loop::ActiveEventLoop, + _device_id: winit::event::DeviceId, + event: winit::event::DeviceEvent, + ) { + if let winit::event::DeviceEvent::MouseMotion { delta: (dx, dy) } = event { + self.camera.apply_mouse_delta(dx, dy); + } + } +} diff --git a/crates/viewer_core/src/camera.rs b/crates/viewer_core/src/camera.rs new file mode 100644 index 0000000..98d4cc2 --- /dev/null +++ b/crates/viewer_core/src/camera.rs @@ -0,0 +1,160 @@ +//! Fly-through camera, extracted from the inline camera state + control logic in +//! the binary's `viewer/mod.rs`. Pure input → state; bounds-clamping against the +//! loaded heightmap stays in `ViewerCore` (it needs the scene extent). + +use std::collections::HashSet; + +use winit::keyboard::KeyCode; + +/// Horizontal/vertical fly speed at normal and boosted (Shift) rates. +const BASE_SPEED_M_PER_S: f32 = 500.0; +const BOOST_MULTIPLIER: f32 = 10.0; +const MOUSE_SENSITIVITY: f32 = 0.001; +const PITCH_LIMIT: f32 = 1.57; + +pub struct FlyCamera { + /// Tile-local metres from the heightmap top-left: [x (east), y (south), z (up)]. + pub pos: [f32; 3], + pub yaw: f32, + pub pitch: f32, + pub keys_held: HashSet, + /// Left-button drag look (non-immersive). Inverts the mouse delta. + pub mouse_look: bool, + /// Q-toggled cursor-locked free-look. + pub immersive_mode: bool, + /// Shift-held movement/time boost. + pub speed_boost: bool, +} + +impl FlyCamera { + pub fn new(pos: [f32; 3], yaw: f32, pitch: f32) -> Self { + FlyCamera { + pos, + yaw, + pitch, + keys_held: HashSet::new(), + mouse_look: false, + immersive_mode: false, + speed_boost: false, + } + } + + /// Movement speed in metres/second for the current boost state. + pub fn speed(&self) -> f32 { + BASE_SPEED_M_PER_S * if self.speed_boost { BOOST_MULTIPLIER } else { 1.0 } + } + + /// Apply WASD horizontal movement (from yaw only) and Space/Alt vertical + /// movement for `dt` seconds. Does **not** clamp to the heightmap — the + /// caller does that since it owns the scene extent. + pub fn update(&mut self, dt: f32) { + let speed = self.speed(); + + // horizontal movement vectors from yaw only + let forward_h = [self.yaw.sin(), -self.yaw.cos(), 0.0_f32]; + let right_h = [self.yaw.cos(), self.yaw.sin(), 0.0_f32]; + + if self.keys_held.contains(&KeyCode::KeyW) { + self.pos[0] += forward_h[0] * speed * dt; + self.pos[1] += forward_h[1] * speed * dt; + } + if self.keys_held.contains(&KeyCode::KeyS) { + self.pos[0] -= forward_h[0] * speed * dt; + self.pos[1] -= forward_h[1] * speed * dt; + } + if self.keys_held.contains(&KeyCode::KeyA) { + self.pos[0] -= right_h[0] * speed * dt; + self.pos[1] -= right_h[1] * speed * dt; + } + if self.keys_held.contains(&KeyCode::KeyD) { + self.pos[0] += right_h[0] * speed * dt; + self.pos[1] += right_h[1] * speed * dt; + } + if self.keys_held.contains(&KeyCode::Space) { + self.pos[2] += speed * dt; + } + if self.keys_held.contains(&KeyCode::AltLeft) || self.keys_held.contains(&KeyCode::SuperLeft) + { + self.pos[2] -= speed * dt; + } + } + + /// Full forward vector including pitch (for `look_at`). + pub fn forward(&self) -> [f32; 3] { + [ + self.pitch.cos() * self.yaw.sin(), + -self.pitch.cos() * self.yaw.cos(), + self.pitch.sin(), + ] + } + + /// Look-at target one unit ahead of the camera. + pub fn look_at(&self) -> [f32; 3] { + let fwd = self.forward(); + [ + self.pos[0] + fwd[0], + self.pos[1] + fwd[1], + self.pos[2] + fwd[2], + ] + } + + /// Apply a raw mouse-motion delta to yaw/pitch. No-op unless a look mode is + /// active. Non-immersive (left-drag) look inverts the delta, matching the + /// original behaviour. + pub fn apply_mouse_delta(&mut self, dx: f64, dy: f64) { + if !self.mouse_look && !self.immersive_mode { + return; + } + let inversion: f32 = if self.immersive_mode { 1.0 } else { -1.0 }; + self.yaw += dx as f32 * MOUSE_SENSITIVITY * inversion; + self.pitch -= dy as f32 * MOUSE_SENSITIVITY * inversion; + self.pitch = self.pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forward_north_at_zero_yaw_pitch() { + // yaw 0, pitch 0: forward = [sin0, -cos0, sin0] = [0, -1, 0] (due "north"/-y). + let cam = FlyCamera::new([0.0, 0.0, 0.0], 0.0, 0.0); + let f = cam.forward(); + assert!(f[0].abs() < 1e-6 && (f[1] + 1.0).abs() < 1e-6 && f[2].abs() < 1e-6); + } + + #[test] + fn w_moves_along_forward_horizontal() { + let mut cam = FlyCamera::new([100.0, 100.0, 10.0], 0.0, 0.5); + cam.keys_held.insert(KeyCode::KeyW); + cam.update(1.0); + // At yaw 0, forward_h = [0, -1, 0]; one second at 500 m/s moves -500 on y. + assert!((cam.pos[1] - (100.0 - 500.0)).abs() < 1e-3); + assert!((cam.pos[0] - 100.0).abs() < 1e-3, "no x drift at yaw 0"); + assert!((cam.pos[2] - 10.0).abs() < 1e-3, "pitch must not affect WASD"); + } + + #[test] + fn boost_multiplies_speed() { + let mut cam = FlyCamera::new([0.0, 0.0, 0.0], 0.0, 0.0); + cam.speed_boost = true; + assert!((cam.speed() - 5000.0).abs() < 1e-3); + } + + #[test] + fn pitch_is_clamped() { + let mut cam = FlyCamera::new([0.0, 0.0, 0.0], 0.0, 0.0); + cam.immersive_mode = true; + // Large downward delta would drive pitch past the limit. + cam.apply_mouse_delta(0.0, 1_000_000.0); + assert!(cam.pitch >= -PITCH_LIMIT - 1e-6 && cam.pitch <= PITCH_LIMIT + 1e-6); + } + + #[test] + fn mouse_delta_ignored_without_look_mode() { + let mut cam = FlyCamera::new([0.0, 0.0, 0.0], 0.3, 0.2); + cam.apply_mouse_delta(500.0, 500.0); + assert_eq!((cam.yaw, cam.pitch), (0.3, 0.2)); + } +} diff --git a/crates/viewer_core/src/consts.rs b/crates/viewer_core/src/consts.rs new file mode 100644 index 0000000..b40ffca --- /dev/null +++ b/crates/viewer_core/src/consts.rs @@ -0,0 +1,11 @@ +//! Constants shared across the viewer core. Mirrors the binary's `src/consts.rs` +//! for the subset the portable viewer logic depends on. + +/// Metres per degree of latitude (mean meridional arc length). +/// Longitude degrees scale by an additional cos(lat) factor. +pub(crate) const M_PER_DEG: f64 = 111_320.0; + +/// Maximum texture dimension accepted by wgpu without error. +/// Applied before every GPU upload so tiles with no overviews (e.g. 1m NZ LiDAR, 24000px wide) +/// never exceed the hardware texture dimension limit. +pub(crate) const GPU_SAFE_PX: usize = 8192; diff --git a/crates/viewer_core/src/geo.rs b/crates/viewer_core/src/geo.rs new file mode 100644 index 0000000..771baa2 --- /dev/null +++ b/crates/viewer_core/src/geo.rs @@ -0,0 +1,167 @@ +use dem_io::Heightmap; +use dem_io::crs; + +use crate::consts::M_PER_DEG; + +/// Convert WGS84 lat/lon to tile-local metres (cam_pos.x, cam_pos.y). +/// Returns None if the position falls outside the tile bounds. +pub fn latlon_to_tile_metres(lat: f64, lon: f64, hm: &Heightmap) -> Option<(f32, f32)> { + let (x, y, max_x, max_y) = if crs::is_geographic(&hm.crs_proj4) { + // dx_meters is unreliable for geographic tiles (may be deg/px or m/px depending + // on which loader was used). Derive m/px consistently from dx_deg. + let dx_m = hm.dx_deg * M_PER_DEG * hm.crs_origin_y.to_radians().cos(); + let dy_m = hm.dy_deg.abs() * M_PER_DEG; + let x = (lon - hm.crs_origin_x) / hm.dx_deg * dx_m; + let y = (hm.crs_origin_y - lat) / hm.dy_deg.abs() * dy_m; + (x, y, hm.cols as f64 * dx_m, hm.rows as f64 * dy_m) + } else { + let (e, n) = crs::from_wgs84(lat, lon, &hm.crs_proj4).ok()?; + let x = e - hm.crs_origin_x; + let y = hm.crs_origin_y - n; + ( + x, + y, + hm.cols as f64 * hm.dx_meters, + hm.rows as f64 * hm.dy_meters, + ) + }; + + if x >= 0.0 && x <= max_x && y >= 0.0 && y <= max_y { + Some((x as f32, y as f32)) + } else { + None + } +} + +/// Geographic solar position (Spencer 1971 declination approximation). +/// Returns (azimuth_rad, elevation_rad) where azimuth is measured clockwise from North. +pub fn sun_position(lat_rad: f32, day: i32, hour: f32) -> (f32, f32) { + use std::f32::consts::TAU; + // Solar declination + let decl = + 23.45_f32.to_radians() * ((360.0_f32 / 365.0 * (day as f32 + 284.0)).to_radians()).sin(); + // Hour angle: 0 at solar noon, negative = morning + let h = (15.0_f32 * (hour - 12.0)).to_radians(); + // Elevation + let sin_el = lat_rad.sin() * decl.sin() + lat_rad.cos() * decl.cos() * h.cos(); + let elevation = sin_el.clamp(-1.0, 1.0).asin(); + // Azimuth from North, clockwise + let cos_el = elevation.cos(); + let azimuth = if cos_el < 1e-6 { + 0.0 + } else { + let cos_az = (decl.sin() - sin_el * lat_rad.sin()) / (cos_el * lat_rad.cos()); + let az = cos_az.clamp(-1.0, 1.0).acos(); + if h > 0.0 { TAU - az } else { az } + }; + (azimuth, elevation) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consts::M_PER_DEG; + use std::f32::consts::PI; + + const LAT_47: f32 = 0.821_405; // 47.076211° in radians (DEFAULT_CAM_LAT) + const SUMMER_SOLSTICE: i32 = 172; // ~21 June + const WINTER_SOLSTICE: i32 = 355; // ~21 December + + // sun_position + + #[test] + fn elevation_peaks_at_solar_noon() { + let (_, el_noon) = sun_position(LAT_47, SUMMER_SOLSTICE, 12.0); + let (_, el_morning) = sun_position(LAT_47, SUMMER_SOLSTICE, 9.0); + let (_, el_afternoon) = sun_position(LAT_47, SUMMER_SOLSTICE, 15.0); + assert!( + el_noon > el_morning, + "noon {el_noon} should top morning {el_morning}" + ); + assert!( + el_noon > el_afternoon, + "noon {el_noon} should top afternoon {el_afternoon}" + ); + } + + #[test] + fn azimuth_straddles_south_across_noon() { + // Northern mid-latitude: sun is due south (az = π) at solar noon, in the + // eastern half (az < π) in the morning, western half (az > π) afternoon. + // The afternoon branch is the `TAU - az` path. + let (az_morning, _) = sun_position(LAT_47, SUMMER_SOLSTICE, 9.0); + let (az_afternoon, _) = sun_position(LAT_47, SUMMER_SOLSTICE, 15.0); + assert!( + az_morning < PI, + "morning azimuth {az_morning} should be east of south" + ); + assert!( + az_afternoon > PI, + "afternoon azimuth {az_afternoon} should be west of south" + ); + assert!(az_morning < az_afternoon); + } + + #[test] + fn summer_sun_higher_than_winter() { + let (_, el_summer) = sun_position(LAT_47, SUMMER_SOLSTICE, 12.0); + let (_, el_winter) = sun_position(LAT_47, WINTER_SOLSTICE, 12.0); + assert!( + el_summer > el_winter, + "summer noon {el_summer} should exceed winter noon {el_winter}" + ); + } + + // latlon_to_tile_metres + + /// Geographic heightmap fixture. `dx_meters`/`dy_meters` are set to bogus + /// values on purpose — the geographic branch must derive m/px from `dx_deg`. + fn geo_hm() -> Heightmap { + Heightmap { + data: vec![0.0; 4], + rows: 1000, + cols: 1000, + nodata: -9999.0, + origin_lat: 47.0, + origin_lon: 11.0, + dx_deg: 0.001, + dy_deg: -0.001, + dx_meters: 999_999.0, // deliberately wrong; must be ignored + dy_meters: 999_999.0, + crs_origin_x: 11.0, + crs_origin_y: 47.0, + crs_epsg: 4326, + crs_proj4: "+proj=longlat +datum=WGS84 +no_defs".to_string(), + } + } + + #[test] + fn geographic_point_maps_using_dx_deg() { + let hm = geo_hm(); + // 0.001° east, 0.001° south of the NW origin. + let (x, y) = latlon_to_tile_metres(46.999, 11.001, &hm).expect("in bounds"); + // x = Δlon° · M_PER_DEG · cos(lat); y = Δlat° · M_PER_DEG. + let expected_x = 0.001 * M_PER_DEG as f32 * 47.0_f32.to_radians().cos(); + let expected_y = 0.001 * M_PER_DEG as f32; + assert!( + (x - expected_x).abs() < 0.5, + "x = {x}, expected ≈ {expected_x}" + ); + assert!( + (y - expected_y).abs() < 0.5, + "y = {y}, expected ≈ {expected_y}" + ); + } + + #[test] + fn out_of_bounds_returns_none() { + let hm = geo_hm(); + // West of the origin → negative x. + assert!(latlon_to_tile_metres(47.0, 10.9, &hm).is_none()); + // North of the origin → negative y. + assert!(latlon_to_tile_metres(47.5, 11.0, &hm).is_none()); + // Past the east edge: the tile spans exactly 1.0° (1000 × 0.001°), so + // lon 12.0 is the edge — 12.1 is beyond it. + assert!(latlon_to_tile_metres(46.5, 12.1, &hm).is_none()); + } +} diff --git a/crates/viewer_core/src/hud/mod.rs b/crates/viewer_core/src/hud/mod.rs new file mode 100644 index 0000000..22f2063 --- /dev/null +++ b/crates/viewer_core/src/hud/mod.rs @@ -0,0 +1,956 @@ +struct HudBackground { + pipeline: wgpu::RenderPipeline, + vertex_buf: wgpu::Buffer, + uniform_buf: wgpu::Buffer, + bind_group: wgpu::BindGroup, +} + +pub struct HudRenderer { + font_system: glyphon::FontSystem, + swash_cache: glyphon::SwashCache, + text_atlas: glyphon::TextAtlas, + text_renderer: glyphon::TextRenderer, + fps_buffer: glyphon::Buffer, + hint_buffer: glyphon::Buffer, + settings_buffer: glyphon::Buffer, + /// Top-of-screen warning banner. Empty text until the OOM handler kicks + /// in; `set_oom_state` rewrites the buffer when a tier gets disabled. + /// Drawn in red so the user can't miss it. + oom_buffer: glyphon::Buffer, + // Cardinal labels around season circle (static) + lbl_summer: glyphon::Buffer, + lbl_fall: glyphon::Buffer, + lbl_winter: glyphon::Buffer, + lbl_spring: glyphon::Buffer, + // Cardinal labels around time circle (static) + lbl_12: glyphon::Buffer, + lbl_15: glyphon::Buffer, + lbl_18: glyphon::Buffer, + lbl_21: glyphon::Buffer, + // Current-value labels (updated every frame) + season_current_buf: glyphon::Buffer, + time_current_buf: glyphon::Buffer, + viewport: glyphon::Viewport, + hud_bg: HudBackground, + sun_indicator: SunIndicator, + width: u32, + height: u32, + sim_day: i32, + sim_hour: f32, +} + +impl HudBackground { + pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("shader_hud_bg.wgsl")); + + let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("uniform_buf"), + size: 8, + mapped_at_creation: false, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("hud_bg_init_bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let vertex_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("vertex_buf"), + size: 144, + mapped_at_creation: false, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("hud_bg_bg"), + layout: &bgl, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buf.as_entire_binding(), + }], + }); + + let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("hud_bg_init_pl"), + bind_group_layouts: &[Some(&bgl)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("hud_bg"), + layout: Some(&pl), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[wgpu::VertexBufferLayout { + array_stride: 8, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 0, + shader_location: 0, + }], + }], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent::OVER, + }), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + HudBackground { + pipeline, + vertex_buf, + uniform_buf, + bind_group, + } + } + + pub fn update_size(&self, queue: &wgpu::Queue, width: u32, height: u32) { + let vetices = build_vertices(width, height); + queue.write_buffer(&self.vertex_buf, 0, bytemuck::cast_slice(&vetices)); + queue.write_buffer( + &self.uniform_buf, + 0, + bytemuck::cast_slice(&[width as f32, height as f32]), + ); + } + + pub fn draw<'a>(&'a self, rpass: &mut wgpu::RenderPass<'a>) { + rpass.set_pipeline(&self.pipeline); + rpass.set_bind_group(0, &self.bind_group, &[]); + rpass.set_vertex_buffer(0, self.vertex_buf.slice(..)); + rpass.draw(0..18, 0..1); + } +} + +fn build_vertices(width: u32, height: u32) -> [f32; 36] { + let h = height as f32; + let w = width as f32; + [ + //For the fps box (x0=4, y0=4, x1=180, y1=36): + // triangle 1 + 4.0, + 4.0, // (x0, y0) + 180.0, + 4.0, // (x1, y0) + 180.0, + 36.0, // (x1, y1) + // triangle 2 + 4.0, + 4.0, // (x0, y0) + 180.0, + 36.0, // (x1, y1) + 4.0, + 36.0, // (x0, y1) + // For the hint box (x0=0, y0=height-36, x1=width, y1=height-4): + // triangle 1 + 0.0, + h - 36.0, // (x0, y0) + w, + h - 36.0, // (x1, y0) + w, + h - 4.0, // (x1, y1) + // triangle 2 + 0.0, + h - 36.0, // (x0, y0) + w, + h - 4.0, // (x1, y1) + 0.0, + h - 4.0, // (x0, y1) + //For the settings box (x0=width-296, y0=4, x1=width-4, y1=136): + // triangle 1 + w - 296.0, + 4.0, // (x0, y0) + w - 4.0, + 4.0, // (x1, y0) + w - 4.0, + 136.0, // (x1, y1) + // triangle 2 + w - 296.0, + 4.0, + w - 4.0, + 136.0, + w - 296.0, + 136.0, + ] +} + +// Sun / season indicator + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct SunHudUniform { + screen_w: f32, + screen_h: f32, + cx1: f32, + cy1: f32, + cx2: f32, + cy2: f32, + radius: f32, + day_angle: f32, + hour_angle: f32, + _pad: [f32; 3], +} + +struct SunIndicator { + pipeline: wgpu::RenderPipeline, + vertex_buf: wgpu::Buffer, + uniform_buf: wgpu::Buffer, + bind_group: wgpu::BindGroup, + // cached pixel positions so HudRenderer can place text labels beside the circles + cx: f32, + cy1: f32, // season circle centre Y + cy2: f32, // time circle centre Y +} + +impl SunIndicator { + const RADIUS: f32 = 50.0; + const RIGHT_MARGIN: f32 = 80.0; // from right edge (room for "Fall"/"15:00" labels) + const BOTTOM_OFFSET: f32 = 118.0; // hint_bar(36)+label_h(18)+padding(14)+radius(50) + const GAP: f32 = 60.0; // between circles + + fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("shader_sun_hud.wgsl")); + let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("sun_hud_uniform"), + size: std::mem::size_of::() as u64, + mapped_at_creation: false, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + // full-screen NDC quad (6 vertices, static — never updated) + let ndc_quad: [f32; 12] = [ + -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, + ]; + let vertex_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("sun_hud_vb"), + size: (ndc_quad.len() * 4) as u64, + mapped_at_creation: false, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + }); + + let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sun_hud_bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sun_hud_bg"), + layout: &bgl, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buf.as_entire_binding(), + }], + }); + + let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sun_hud_pl"), + bind_group_layouts: &[Some(&bgl)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sun_hud_pipeline"), + layout: Some(&pl), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[wgpu::VertexBufferLayout { + array_stride: 8, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 0, + shader_location: 0, + }], + }], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent::OVER, + }), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + let indicator = SunIndicator { + pipeline, + vertex_buf, + uniform_buf, + bind_group, + cx: 0.0, + cy1: 0.0, + cy2: 0.0, + }; + // cx/cy1/cy2 are set correctly on the first update() call (requires queue). + indicator + } + + fn update( + &mut self, + queue: &wgpu::Queue, + width: u32, + height: u32, + sim_day: i32, + sim_hour: f32, + ) { + use std::f32::consts::TAU; + // Summer (day 172) = top of season circle; 12:00 = top of time circle. + let day_angle = (sim_day as f32 - 172.0).rem_euclid(365.0) / 365.0 * TAU; + let hour_angle = (sim_hour % 12.0) / 12.0 * TAU; + + // Right side, stacked vertically: season (top), time (bottom). + let cx = width as f32 - Self::RIGHT_MARGIN - Self::RADIUS; + let cy2 = height as f32 - Self::BOTTOM_OFFSET; + let cy1 = cy2 - 2.0 * Self::RADIUS - Self::GAP; + self.cx = cx; + self.cy1 = cy1; + self.cy2 = cy2; + + let u = SunHudUniform { + screen_w: width as f32, + screen_h: height as f32, + cx1: cx, + cy1, + cx2: cx, + cy2, + radius: Self::RADIUS, + day_angle, + hour_angle, + _pad: [0.0; 3], + }; + queue.write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&u)); + + // write static NDC quad vertices (idempotent — same data every call) + let ndc_quad: [f32; 12] = [ + -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, + ]; + queue.write_buffer(&self.vertex_buf, 0, bytemuck::cast_slice(&ndc_quad)); + } + + fn draw<'a>(&'a self, rpass: &mut wgpu::RenderPass<'a>) { + rpass.set_pipeline(&self.pipeline); + rpass.set_bind_group(0, &self.bind_group, &[]); + rpass.set_vertex_buffer(0, self.vertex_buf.slice(..)); + rpass.draw(0..6, 0..1); + } +} + +fn day_to_date(day: i32) -> String { + const MONTHS: [(&str, i32); 12] = [ + ("Jan", 31), + ("Feb", 28), + ("Mar", 31), + ("Apr", 30), + ("May", 31), + ("Jun", 30), + ("Jul", 31), + ("Aug", 31), + ("Sep", 30), + ("Oct", 31), + ("Nov", 30), + ("Dec", 31), + ]; + let mut rem = day.clamp(1, 365); + for (name, days) in &MONTHS { + if rem <= *days { + return format!("{} {}", name, rem); + } + rem -= days; + } + // It's a fallback for the case when the loop exhausts all 12 months without returning. + // In theory it should never be reached because day.clamp(1, 365) guarantees + // the input is at most 365. + // It exists purely to satisfy the Rust compiler. + "Dec 31".to_string() +} + +fn make_small_label(font_system: &mut glyphon::FontSystem, text: &str) -> glyphon::Buffer { + let mut buf = glyphon::Buffer::new(font_system, glyphon::Metrics::new(13.0, 16.0)); + buf.set_size(font_system, Some(60.0), Some(20.0)); + buf.set_text( + font_system, + text, + &glyphon::Attrs::new(), + glyphon::Shaping::Basic, + Some(glyphon::cosmic_text::Align::Center), + ); + buf +} + +fn make_current_label(font_system: &mut glyphon::FontSystem, text: &str) -> glyphon::Buffer { + let mut buf = glyphon::Buffer::new(font_system, glyphon::Metrics::new(13.0, 16.0)); + buf.set_size(font_system, Some(90.0), Some(20.0)); + buf.set_text( + font_system, + text, + &glyphon::Attrs::new(), + glyphon::Shaping::Basic, + Some(glyphon::cosmic_text::Align::Right), + ); + buf +} + +// Build a TextArea with default clipping bounds (full screen). +fn build_label_text_area( + buffer: &glyphon::Buffer, + left: f32, + top: f32, + width: u32, + height: u32, + color: glyphon::Color, +) -> glyphon::TextArea<'_> { + glyphon::TextArea { + buffer, + left, + top, + scale: 1.0, + bounds: glyphon::TextBounds { + left: 0, + top: 0, + right: width as i32, + bottom: height as i32, + }, + default_color: color, + custom_glyphs: &[], + } +} + +impl HudRenderer { + pub fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + width: u32, + height: u32, + format: wgpu::TextureFormat, + ) -> Self { + let mut font_system: glyphon::FontSystem = glyphon::FontSystem::new(); + let swash_cache: glyphon::SwashCache = glyphon::SwashCache::new(); + let cache: glyphon::Cache = glyphon::Cache::new(device); + let mut text_atlas: glyphon::TextAtlas = + glyphon::TextAtlas::new(device, queue, &cache, format); + let text_renderer: glyphon::TextRenderer = glyphon::TextRenderer::new( + &mut text_atlas, + device, + wgpu::MultisampleState::default(), + None, + ); + let mut fps_buffer: glyphon::Buffer = + glyphon::Buffer::new(&mut font_system, glyphon::Metrics::new(18.0, 20.0)); + fps_buffer.set_size(&mut font_system, Some(400.0), Some(40.0)); + fps_buffer.set_text( + &mut font_system, + "0 fps", + &glyphon::Attrs::new(), + glyphon::Shaping::Basic, + None, + ); + let mut hint_buffer: glyphon::Buffer = + glyphon::Buffer::new(&mut font_system, glyphon::Metrics::new(18.0, 20.0)); + hint_buffer.set_size(&mut font_system, Some(width as f32), Some(40.0)); + hint_buffer.set_text( + &mut font_system, + "Q - immersive mode; Shift - speed boost; Space/Alt - up/down; E - hide/show HUD; +/- - time; [/] - days; V - tier viz (green=30m blue=5m red=1m)", + &glyphon::Attrs::new(), + glyphon::Shaping::Basic, + Some(glyphon::cosmic_text::Align::Center), + ); + // OOM warning banner — empty until set_oom_state writes a message. + // Centred at the top of the screen; rendered in red via the + // default_color on the TextArea, so the buffer itself stores no colour. + let mut oom_buffer: glyphon::Buffer = + glyphon::Buffer::new(&mut font_system, glyphon::Metrics::new(18.0, 20.0)); + oom_buffer.set_size(&mut font_system, Some(width as f32), Some(40.0)); + oom_buffer.set_text( + &mut font_system, + "", + &glyphon::Attrs::new(), + glyphon::Shaping::Basic, + Some(glyphon::cosmic_text::Align::Center), + ); + let mut settings_buffer: glyphon::Buffer = + glyphon::Buffer::new(&mut font_system, glyphon::Metrics::new(18.0, 20.0)); + settings_buffer.set_size(&mut font_system, Some(292.0), Some(100.0)); + settings_buffer.set_text( + &mut font_system, + "AO: Off (Press / to change)\nShadows: On (Press . to toggle)\nFog: On (Press , to toggle)\nQuality: High (Press ; to change)\nLOD: Mid (Press ' to change)", + &glyphon::Attrs::new(), + glyphon::Shaping::Basic, + Some(glyphon::cosmic_text::Align::Right), + ); + // Static cardinal labels + let lbl_summer = make_small_label(&mut font_system, "Summer"); + let lbl_fall = make_small_label(&mut font_system, "Fall"); + let lbl_winter = make_small_label(&mut font_system, "Winter"); + let lbl_spring = make_small_label(&mut font_system, "Spring"); + let lbl_12 = make_small_label(&mut font_system, "12:00"); + let lbl_15 = make_small_label(&mut font_system, "15:00"); + let lbl_18 = make_small_label(&mut font_system, "18:00"); + let lbl_21 = make_small_label(&mut font_system, "21:00"); + // Dynamic current-value labels + let season_current_buf = make_current_label(&mut font_system, "Day 172"); + let time_current_buf = make_current_label(&mut font_system, "10:00"); + let hud_bg: HudBackground = HudBackground::new(device, format); + // Vertex buffer is created without data; write it now before first draw. + hud_bg.update_size(queue, width, height); + let mut sun_indicator = SunIndicator::new(device, format); + // Write initial vertex + uniform data (requires queue, done here after construction) + sun_indicator.update(queue, width, height, 172, 10.0); + let viewport: glyphon::Viewport = glyphon::Viewport::new(device, &cache); + + HudRenderer { + font_system, + swash_cache, + text_atlas, + text_renderer, + fps_buffer, + hint_buffer, + settings_buffer, + oom_buffer, + lbl_summer, + lbl_fall, + lbl_winter, + lbl_spring, + lbl_12, + lbl_15, + lbl_18, + lbl_21, + season_current_buf, + time_current_buf, + viewport, + hud_bg, + sun_indicator, + width, + height, + sim_day: 172, + sim_hour: 10.0, + } + } + + pub fn update_size(&mut self, queue: &wgpu::Queue, width: u32, height: u32) { + self.width = width; + self.height = height; + self.hint_buffer + .set_size(&mut self.font_system, Some(width as f32), Some(40.0)); + self.oom_buffer + .set_size(&mut self.font_system, Some(width as f32), Some(40.0)); + self.hud_bg.update_size(queue, width, height); + self.sun_indicator + .update(queue, width, height, self.sim_day, self.sim_hour); + } + + /// Update the top-of-screen OOM banner. Pass the current tier-disabled + /// flags; the method rewrites the buffer text only when the state actually + /// changes (avoiding glyphon shaping cost on every frame). An empty text + /// renders nothing, so the banner disappears once both flags are false. + pub fn set_oom_state(&mut self, fine_disabled_by_oom: bool, close_disabled_by_oom: bool) { + let new_text = match (fine_disabled_by_oom, close_disabled_by_oom) { + (false, false) => "", + (true, false) => { + "OOM prevented — not enough VRAM. Fine detail tier disabled. Lower the VRAM Budget preset for next launch." + } + (_, true) => { + "OOM prevented — not enough VRAM. Fine and close detail tiers disabled. Lower the VRAM Budget preset for next launch." + } + }; + // Cheap content-comparison: glyphon's Buffer doesn't expose its text + // directly, so we track via the buffer line metrics; in practice the + // unconditional set_text is fine here (called at most a handful of + // times per session — once per OOM step). + self.oom_buffer.set_text( + &mut self.font_system, + new_text, + &glyphon::Attrs::new(), + glyphon::Shaping::Basic, + Some(glyphon::cosmic_text::Align::Center), + ); + } + + // Per-frame HUD state (camera/sun/settings/fps) forwarded to the overlay; kept flat. + #[allow(clippy::too_many_arguments)] + pub fn draw( + &mut self, + queue: &wgpu::Queue, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + surface_view: &wgpu::TextureView, + fps: f32, + ms: f32, + sim_day: i32, + sim_hour: f32, + ao_mode: u32, + shadows_enabled: bool, + fog_enabled: bool, + vat_mode: u32, + lod_mode: u32, + smooth_radius_m: f32, + ) { + self.sim_day = sim_day; + self.sim_hour = sim_hour; + self.sun_indicator + .update(queue, self.width, self.height, sim_day, sim_hour); + + // Update current-value labels + let day_text = day_to_date(sim_day); + self.season_current_buf.set_text( + &mut self.font_system, + &day_text, + &glyphon::Attrs::new(), + glyphon::Shaping::Basic, + Some(glyphon::cosmic_text::Align::Right), + ); + let h = sim_hour as u32 % 24; + let m = ((sim_hour.fract()) * 60.0) as u32; + let time_text = format!("Time: {:02}:{:02}", h, m); + self.time_current_buf.set_text( + &mut self.font_system, + &time_text, + &glyphon::Attrs::new(), + glyphon::Shaping::Basic, + Some(glyphon::cosmic_text::Align::Right), + ); + + self.fps_buffer.set_text( + &mut self.font_system, + &format!("{:.0} fps {:.1} ms", fps, ms / fps.max(0.001)), + &glyphon::Attrs::new(), + glyphon::Shaping::Basic, + None, + ); + let ao_label = match ao_mode { + 0 => "AO: Off (Press / to change)", + 1 => "AO: SSAO x8 (Press / to change)", + 2 => "AO: SSAO x16 (Press / to change)", + 3 => "AO: HBAO x4 (Press / to change)", + 4 => "AO: HBAO x8 (Press / to change)", + _ => "AO: True Hemi (Press / to change)", + }; + let shadows_label = if shadows_enabled { + "Shadows: On (Press . to toggle)" + } else { + "Shadows: Off (Press . to toggle)" + }; + let fog_label = if fog_enabled { + "Fog: On (Press , to toggle)" + } else { + "Fog: Off (Press , to toggle)" + }; + let vat_label = match vat_mode { + 0 => "Quality: Ultra (Press ; to change)", + 1 => "Quality: High (Press ; to change)", + 2 => "Quality: Mid (Press ; to change)", + _ => "Quality: Low (Press ; to change)", + }; + let lod_label = match lod_mode { + 0 => "LOD: Ultra (Press ' to change)", + 1 => "LOD: High (Press ' to change)", + 2 => "LOD: Mid (Press ' to change)", + _ => "LOD: Low (Press ' to change)", + }; + let smooth_label = if smooth_radius_m <= 0.0 { + "Smooth: Off (Press B to change)".to_string() + } else { + format!("Smooth: {:.0}m (Press B to change)", smooth_radius_m) + }; + let settings_text = format!( + "{}\n{}\n{}\n{}\n{}\n{}", + ao_label, shadows_label, fog_label, vat_label, lod_label, smooth_label + ); + self.settings_buffer.set_text( + &mut self.font_system, + &settings_text, + &glyphon::Attrs::new(), + glyphon::Shaping::Basic, + Some(glyphon::cosmic_text::Align::Right), + ); + self.viewport.update( + queue, + glyphon::Resolution { + width: self.width, + height: self.height, + }, + ); + // Compute label positions from stored circle centres. + let cx = self.sun_indicator.cx; + let cy1 = self.sun_indicator.cy1; + let cy2 = self.sun_indicator.cy2; + let r = SunIndicator::RADIUS; + let lw = 60.0_f32; // label box width for cardinal labels + let dim = glyphon::Color::rgb(210, 210, 210); + let shd = glyphon::Color::rgba(0, 0, 0, 160); // drop-shadow colour (semi-transparent black) + let w = self.width; + let h = self.height; + + // Current-value labels: right-aligned 90px box at 10–11 o'clock, outside ring. + let cur_w = 90.0_f32; + let cur_l = cx - r - cur_w - 4.0; + let sc_t = cy1 - r * 0.5 - 8.0; // season current top + let tc_t = cy2 - r * 0.5 - 8.0; // time current top + + // Each cardinal label and current-value label is rendered twice: + // 1. Shadow pass — same buffer, offset (+1, +1), dark semi-transparent colour. + // 2. Real pass — normal position, light colour. + // glyphon composites TextAreas in order, so shadows land under the real glyphs. + self.text_renderer + .prepare( + device, + queue, + &mut self.font_system, + &mut self.text_atlas, + &self.viewport, + [ + // FPS counter (top-left) + glyphon::TextArea { + buffer: &self.fps_buffer, + left: 10.0, + top: 10.0, + scale: 1.0, + bounds: glyphon::TextBounds { + left: 0, + top: 0, + right: w as i32, + bottom: h as i32, + }, + default_color: glyphon::Color::rgb(255, 255, 255), + custom_glyphs: &[], + }, + //Settings + glyphon::TextArea { + buffer: &self.settings_buffer, + left: w as f32 - 298.0, + top: 10.0, + scale: 1.0, + bounds: glyphon::TextBounds { + left: 0, + top: 0, + right: w as i32, + bottom: h as i32, + }, + default_color: glyphon::Color::rgb(255, 255, 255), + custom_glyphs: &[], + }, + // Hint bar (bottom) + glyphon::TextArea { + buffer: &self.hint_buffer, + left: 0.0, + top: h as f32 - 30.0, + scale: 1.0, + bounds: glyphon::TextBounds { + left: 0, + top: 0, + right: w as i32, + bottom: h as i32, + }, + default_color: glyphon::Color::rgb(255, 255, 255), + custom_glyphs: &[], + }, + // OOM warning banner (top, centred, red). Buffer is empty + // unless the viewer's OOM handler set it via + // `set_oom_state`, so this TextArea costs nothing to + // prepare when there's no message. + glyphon::TextArea { + buffer: &self.oom_buffer, + left: 0.0, + top: 10.0, + scale: 1.0, + bounds: glyphon::TextBounds { + left: 0, + top: 0, + right: w as i32, + bottom: h as i32, + }, + default_color: glyphon::Color::rgb(255, 90, 90), + custom_glyphs: &[], + }, + // Season circle labels — shadows + build_label_text_area( + &self.lbl_summer, + cx - lw / 2.0 + 1.0, + cy1 - r - 19.0, + w, + h, + shd, + ), + build_label_text_area( + &self.lbl_winter, + cx - lw / 2.0 + 1.0, + cy1 + r + 5.0, + w, + h, + shd, + ), + build_label_text_area(&self.lbl_fall, cx + r + 5.0, cy1 - 7.0, w, h, shd), + build_label_text_area( + &self.lbl_spring, + cx - r - lw - 3.0, + cy1 - 7.0, + w, + h, + shd, + ), + build_label_text_area( + &self.season_current_buf, + cur_l - 9.0, + sc_t - 14.0, + w, + h, + shd, + ), + // Season circle labels — real + build_label_text_area( + &self.lbl_summer, + cx - lw / 2.0, + cy1 - r - 20.0, + w, + h, + dim, + ), + build_label_text_area( + &self.lbl_winter, + cx - lw / 2.0, + cy1 + r + 4.0, + w, + h, + dim, + ), + build_label_text_area(&self.lbl_fall, cx + r + 4.0, cy1 - 8.0, w, h, dim), + build_label_text_area( + &self.lbl_spring, + cx - r - lw - 4.0, + cy1 - 8.0, + w, + h, + dim, + ), + build_label_text_area( + &self.season_current_buf, + cur_l - 10.0, + sc_t - 15.0, + w, + h, + dim, + ), + // Time circle labels — shadows + build_label_text_area( + &self.lbl_12, + cx - lw / 2.0 + 1.0, + cy2 - r - 19.0, + w, + h, + shd, + ), + build_label_text_area( + &self.lbl_18, + cx - lw / 2.0 + 1.0, + cy2 + r + 5.0, + w, + h, + shd, + ), + build_label_text_area(&self.lbl_15, cx + r + 5.0, cy2 - 7.0, w, h, shd), + build_label_text_area(&self.lbl_21, cx - r - lw - 3.0, cy2 - 7.0, w, h, shd), + build_label_text_area( + &self.time_current_buf, + cur_l - 9.0, + tc_t - 14.0, + w, + h, + shd, + ), + // Time circle labels — real + build_label_text_area(&self.lbl_12, cx - lw / 2.0, cy2 - r - 20.0, w, h, dim), + build_label_text_area(&self.lbl_18, cx - lw / 2.0, cy2 + r + 4.0, w, h, dim), + build_label_text_area(&self.lbl_15, cx + r + 4.0, cy2 - 8.0, w, h, dim), + build_label_text_area(&self.lbl_21, cx - r - lw - 4.0, cy2 - 8.0, w, h, dim), + build_label_text_area( + &self.time_current_buf, + cur_l - 10.0, + tc_t - 15.0, + w, + h, + dim, + ), + ], + &mut self.swash_cache, + ) + .unwrap(); + + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("hud"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: surface_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + self.hud_bg.draw(&mut rpass); + self.sun_indicator.draw(&mut rpass); + self.text_renderer + .render(&self.text_atlas, &self.viewport, &mut rpass) + .unwrap(); + // begin_render_pass borrows encoder mutably, which means you can't use encoder again until the pass is dropped. + // The explicit drop(rpass) releases that borrow before encoder.finish(). + drop(rpass); + } +} diff --git a/crates/viewer_core/src/hud/shader_hud_bg.wgsl b/crates/viewer_core/src/hud/shader_hud_bg.wgsl new file mode 100644 index 0000000..fe2a03f --- /dev/null +++ b/crates/viewer_core/src/hud/shader_hud_bg.wgsl @@ -0,0 +1,19 @@ +struct ScreenSize { + width: f32, + height: f32, +} + +@group(0) @binding(0) var screen: ScreenSize; + +@vertex +fn vs_main(@location(0) pos: vec2) -> @builtin(position) vec4 { + let ndc_x = (pos.x / screen.width) * 2.0 - 1.0; + let ndc_y = 1.0 - (pos.y / screen.height) * 2.0; // Y is flipped: pixel 0 is top, NDC +1 is top + + return vec4(ndc_x, ndc_y, 0.0, 1.0); +} + +@fragment +fn fs_main() -> @location(0) vec4 { + return vec4(0.0, 0.0, 0.0, 0.6); +} diff --git a/crates/viewer_core/src/hud/shader_sun_hud.wgsl b/crates/viewer_core/src/hud/shader_sun_hud.wgsl new file mode 100644 index 0000000..86b9780 --- /dev/null +++ b/crates/viewer_core/src/hud/shader_sun_hud.wgsl @@ -0,0 +1,250 @@ +// TAU = 2π = one full turn in radians. Used everywhere instead of "2.0 * PI" +// so formulas read as "fraction of a full circle * TAU" rather than "* 2 * PI". +const TAU: f32 = 6.28318530718; + +// Uniform block uploaded from Rust every frame (48 bytes). +// cx1/cy1 = centre of the season circle (top one). +// cx2/cy2 = centre of the time circle (bottom one). +// day_angle = needle angle for season circle: 0 = top = Jun 21, increases clockwise. +// hour_angle = needle angle for time circle: 0 = top = 12:00, increases clockwise. +struct SunHud { + screen_w: f32, + screen_h: f32, + cx1: f32, + cy1: f32, // season circle centre (pixel coords, Y-down) + cx2: f32, + cy2: f32, // time circle centre + radius: f32, + day_angle: f32, + hour_angle: f32, + _pad1: f32, // padding to reach 48-byte std140 alignment + _pad2: f32, + _pad3: f32, +} + +@group(0) @binding(0) var u: SunHud; + +// Vertex shader +// Passes NDC positions straight through. The CPU sends a full-screen quad +// (two triangles covering −1..+1 in both axes) so every screen pixel gets a +// fragment invocation and we can do all the circle math per-pixel. +@vertex +fn vs_main(@location(0) pos: vec2) -> @builtin(position) vec4 { + return vec4(pos.x, pos.y, 0.0, 1.0); +} + +// Helper: SDF of line segment a→b +// Returns the shortest distance from pixel p to the segment [a, b]. +// Used to draw the needle and tick marks as anti-aliased lines. +// +// How it works: +// 1. Project p onto the infinite line through a and b. +// t = dot(p-a, b-a) / |b-a|² gives a 0..1 parameter along the segment. +// 2. Clamp t to [0,1] so the closest point stays on the segment (not beyond). +// 3. Distance = length from p to that closest point. +fn seg_sdf(p: vec2, a: vec2, b: vec2) -> f32 { + let ba = b - a; // direction vector of the segment + let pa = p - a; // vector from segment start to pixel + let t = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); // clamped projection + return length(pa - ba * t); // distance to nearest point on segment +} + +// Helper: clockwise angle from top +// Returns the angle of pixel p around `center`, measured clockwise from 12 o'clock. +// 0 = straight up (12 o'clock) +// TAU / 4 = right ( 3 o'clock) +// TAU / 2 = straight down ( 6 o'clock) +// TAU*3/4 = left ( 9 o'clock) +// +// Standard atan2(y, x) gives counter-clockwise angle from the positive X axis. +// We want clockwise from the positive Y axis (= up on screen). +// Two adjustments: +// • Swap x and y arguments → rotates reference to the vertical axis. +// • Negate dp.y → flips Y because screen Y grows downward. +// The result can be negative for the left half; adding TAU wraps it to [0, TAU). +fn frag_angle(p: vec2, center: vec2) -> f32 { + let dp = p - center; + var a = atan2(dp.x, -dp.y); // clockwise from top; may be in (−π, 0) for left half + if a < 0.0 { a += TAU; } // remap to [0, TAU) + return a; +} + +// Helper: season colour from angular position +// The season circle's ring is coloured by which season each arc position falls in. +// angle=0 is the summer solstice (top); the year runs clockwise. +// Season boundaries are the number of days after Jun 21, converted to radians. +fn season_col(angle: f32) -> vec3 { + let fa = (93.0 / 365.0) * TAU; // fall start (~Sep 22, 93 days after Jun 21) + let wi = (183.0 / 365.0) * TAU; // winter start (~Dec 21, 183 days after Jun 21) + let sp = (273.0 / 365.0) * TAU; // spring start (~Mar 20, 273 days after Jun 21) + if angle < fa { return vec3(0.95, 0.82, 0.08); } // summer → yellow + if angle < wi { return vec3(0.90, 0.45, 0.10); } // fall → orange + if angle < sp { return vec3(0.45, 0.65, 0.95); } // winter → blue + return vec3(0.25, 0.80, 0.30); // spring → green +} + +// Helper: draw one tick mark +// A tick is a short radial line segment at a given angle on the ring. +// `inner` is the fraction of the radius where the tick starts (0.72 = 72% out). +// Returns the SDF distance so the caller can blend it into the colour. +fn tick(p: vec2, center: vec2, r: f32, angle: f32, inner: f32) -> f32 { + // Direction vector pointing outward at `angle` (clockwise-from-top convention). + // sin/−cos converts the clock angle to a screen-space 2D unit vector. + let dir = vec2(sin(angle), -cos(angle)); + let a = center + dir * (r * inner); // inner end of tick (72% of radius) + let b = center + dir * r; // outer end of tick (at the ring) + return seg_sdf(p, a, b); +} + +// Main circle drawing function +// Draws one complete circle (background disc + coloured ring + tick marks + +// needle + centre dot) and returns the RGBA colour for the current pixel. +// kind=0 → season circle (coloured ring, season tints) +// kind=1 → time circle (white ring, dark background) +// needle → angle of the yellow needle (day_angle or hour_angle from the uniform) +fn draw_circle(p: vec2, center: vec2, r: f32, needle: f32, kind: i32) -> vec4 { + let d = length(p - center); // pixel's distance from circle centre + let fa = frag_angle(p, center); // pixel's clockwise angle from top + + // Early exit: pixel is outside the circle (with 1.5px anti-alias margin). + // This is a secondary guard; fs_main already discards most far pixels. + if d > r + 1.5 { return vec4(0.0); } + + var col = vec4(0.0); // start fully transparent + + // Background disc + // Fill the interior with a semi-transparent tint so the terrain shows through. + // Season circle: dim version of the season colour at that angle (22% brightness). + // Time circle: uniform dark grey. + if d < r { + if kind == 0 { + let sc = season_col(fa); + col = vec4(sc * 0.22, 0.60); // dim season tint, 60% opaque + } else { + col = vec4(0.0, 0.0, 0.0, 0.55); // dark, 55% opaque + } + } + + // Outer ring + // A thin band around d ≈ r, anti-aliased by blending over 1.8 pixels. + // ring_d = distance from the ring edge (0 = exactly on the ring). + // Colour: season ring uses the season colour; time ring is white. + // mix(existing, new, weight) blends smoothly rather than hard-replacing. + let ring_d = abs(d - r); + if ring_d < 1.8 { + var rc = vec3(1.0); // default white for time circle + if kind == 0 { rc = season_col(fa); } // season colour for season circle + col = mix(col, vec4(rc, 1.0), 1.0 - ring_d / 1.8); + // weight = 1.0 at ring_d=0 (dead centre of ring), 0.0 at ring_d=1.8 (edge) + } + + // Tick marks + // Four white radial lines at the cardinal positions of each circle. + // The tick SDF returns a distance; if < 1.5px we paint it white with + // the same linear anti-alias blend as the ring. + if kind == 0 { + // Season circle: solstices and equinoxes at 90° intervals. + // Angles match the season_col boundaries (same convention: 0 = top = summer). + let t0 = 0.0; // Jun 21 summer solstice → top + let t1 = (93.0 / 365.0) * TAU; // Sep 22 fall equinox → right + let t2 = (183.0 / 365.0) * TAU; // Dec 21 winter solstice → bottom + let t3 = (273.0 / 365.0) * TAU; // Mar 20 spring equinox → left + if tick(p, center, r, t0, 0.72) < 1.5 { col = mix(col, vec4(1.0, 1.0, 1.0, 1.0), 1.0 - tick(p, center, r, t0, 0.72) / 1.5); } + if tick(p, center, r, t1, 0.72) < 1.5 { col = mix(col, vec4(1.0, 1.0, 1.0, 1.0), 1.0 - tick(p, center, r, t1, 0.72) / 1.5); } + if tick(p, center, r, t2, 0.72) < 1.5 { col = mix(col, vec4(1.0, 1.0, 1.0, 1.0), 1.0 - tick(p, center, r, t2, 0.72) / 1.5); } + if tick(p, center, r, t3, 0.72) < 1.5 { col = mix(col, vec4(1.0, 1.0, 1.0, 1.0), 1.0 - tick(p, center, r, t3, 0.72) / 1.5); } + } else { + // Time circle: 12-hour clock face, evenly spaced at TAU/4 intervals. + let h12 = 0.0; // 12:00 → top (0 radians) + let h15 = TAU / 4.0; // 15:00 → right (π/2 radians) + let h18 = TAU / 2.0; // 18:00 → bottom (π radians) + let h21 = TAU * 3.0 / 4.0; // 21:00 → left (3π/2 radians) + if tick(p, center, r, h12, 0.72) < 1.5 { col = mix(col, vec4(1.0, 1.0, 1.0, 0.9), 1.0 - tick(p, center, r, h12, 0.72) / 1.5); } + if tick(p, center, r, h15, 0.72) < 1.5 { col = mix(col, vec4(1.0, 1.0, 1.0, 0.9), 1.0 - tick(p, center, r, h15, 0.72) / 1.5); } + if tick(p, center, r, h18, 0.72) < 1.5 { col = mix(col, vec4(1.0, 1.0, 1.0, 0.9), 1.0 - tick(p, center, r, h18, 0.72) / 1.5); } + if tick(p, center, r, h21, 0.72) < 1.5 { col = mix(col, vec4(1.0, 1.0, 1.0, 0.9), 1.0 - tick(p, center, r, h21, 0.72) / 1.5); } + } + + // Yellow needle + // A line from the centre to (r − 9) pixels out in the needle direction. + // Only drawn inside the disc (d < r − 3) so it doesn't bleed onto the ring. + // The needle direction uses the same sin/−cos clock-angle conversion as tick(). + if d < r - 3.0 { + let tip = center + vec2(sin(needle), -cos(needle)) * (r - 9.0); + let nd = seg_sdf(p, center, tip); + if nd < 2.0 { + // Blend yellow over whatever colour is already there. + // Weight = 1 at the centreline, 0 at 2px away → smooth anti-aliased line. + col = mix(col, vec4(1.0, 0.85, 0.15, 1.0), 1.0 - nd / 2.0); + } + } + + // White centre dot + // A solid white disc of radius 3.5px hides the messy needle base and + // gives the clock a clean pivot point. + if d < 3.5 { + col = vec4(1.0, 1.0, 1.0, 1.0); + } + + return col; +} + +// Helper: HUD panel background SDF +// Returns the signed distance for a single rounded rectangle that covers the +// entire HUD widget: both circles + all surrounding cardinal labels + the +// current-value labels to the left. +// Convention: negative = inside, 0 = boundary, positive = outside. +// +// Layout (all relative to the shared circle centre cx = cx1 = cx2): +// left : cx − radius − 112 (past the current-value label, ~10 px padding) +// right : cx + radius + 72 (past the Fall/15:00 label, ~8 px padding) +// top : cy1 − radius − 28 (above the Summer/12:00 label) +// bottom: cy2 + radius + 28 (below the Winter/18:00 label) +fn panel_rect_sdf(p: vec2) -> f32 { + let cx = u.cx1; // cx1 == cx2: both circles share the same X + let r = u.radius; + let x0 = cx - r - 112.0; + let x1 = cx + r + 72.0; + let y0 = u.cy1 - r - 28.0; + let y1 = u.cy2 + r + 28.0; + // Rounded-rect SDF (corner radius 8 px for a smooth panel feel). + let center = vec2((x0 + x1) * 0.5, (y0 + y1) * 0.5); + let half_ext = vec2((x1 - x0) * 0.5, (y1 - y0) * 0.5); + let cr = 8.0; + let q = abs(p - center) - half_ext + vec2(cr); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - cr; +} + +// Fragment shader (entry point) +// Runs once per pixel of the full-screen quad. +// Rendering order (back to front): +// 1. Panel background — dark semi-transparent rounded rect behind everything. +// 2. Circles — drawn on top of the panel wherever they overlap. +@fragment +fn fs_main(@builtin(position) frag_pos: vec4) -> @location(0) vec4 { + let p = frag_pos.xy; + let c1 = vec2(u.cx1, u.cy1); + let c2 = vec2(u.cx2, u.cy2); + let r = u.radius; + + let d1 = length(p - c1); // distance to season circle centre + let d2 = length(p - c2); // distance to time circle centre + let pd = panel_rect_sdf(p); // signed distance to the panel background rect + + // Discard pixels that are outside both circles and outside the panel. + if d1 > r + 1.5 && d2 > r + 1.5 && pd > 1.0 { discard; } + + // Circles (drawn first so they appear above the panel background) + // Each pixel belongs to at most one circle; check season circle first. + if d1 <= r + 1.5 { + return draw_circle(p, c1, r, u.day_angle, 0); // season circle + } + if d2 <= r + 1.5 { + return draw_circle(p, c2, r, u.hour_angle, 1); // time circle + } + + // Panel background (pixels inside the panel but outside both circles) + // Alpha fades from 0.60 deep inside to 0 at 1 px outside the rounded edge. + let alpha = 0.60 * clamp(1.0 - pd, 0.0, 1.0); + return vec4(0.05, 0.05, 0.05, alpha); +} diff --git a/crates/viewer_core/src/lib.rs b/crates/viewer_core/src/lib.rs new file mode 100644 index 0000000..f016a89 --- /dev/null +++ b/crates/viewer_core/src/lib.rs @@ -0,0 +1,39 @@ +//! Platform-agnostic terrain viewer core. +//! +//! Extracted from the `dem_renderer` binary's `src/viewer/` so the camera, +//! tier-streaming, render-orchestration and HUD logic can be reused by a wasm +//! shell (and any future non-winit front-end). Platform-touching seams live +//! behind the [`platform::TileSource`] / [`platform::Spawner`] traits; native +//! implementations are provided under `#[cfg(not(target_arch = "wasm32"))]`. + +mod consts; + +pub mod app; +pub mod camera; +pub mod geo; +pub mod hud; +pub mod platform; +pub mod scene_build; +pub mod tiers; +pub mod tile_index; + +#[cfg(not(target_arch = "wasm32"))] +pub mod platform_native; + +pub use app::{InitialView, PreparedScene, TierSetup, ViewerCore, ViewerSettings}; +pub use hud::HudRenderer; +pub use platform::{Spawner, TileSource}; +pub use tiers::BevBaseState; + +pub use camera::FlyCamera; +pub use geo::{latlon_to_tile_metres, sun_position}; +pub use scene_build::{INIT_SIM_DAY, INIT_SIM_HOUR, compute_ao_cropped}; +pub use tiers::{ + AO_DRIFT_THRESHOLD_M, AO_RADIUS_M, StreamingTier, TierData, TierRadii, cap_to_gpu_limit, + cross_crs_world_origin, cross_crs_world_origin_and_extent, select_ifd, tier_radii, +}; +pub use tile_index::{TileEntry, TileIndex, tiles_overlapping_wgs84}; + +// Re-export the VRAM class so shells can resolve tier radii without depending on +// render_gpu directly. +pub use render_gpu::VramClass; diff --git a/crates/viewer_core/src/platform.rs b/crates/viewer_core/src/platform.rs new file mode 100644 index 0000000..bd54ff2 --- /dev/null +++ b/crates/viewer_core/src/platform.rs @@ -0,0 +1,53 @@ +//! Platform-bound seams behind small traits. `viewer_core` is otherwise +//! platform-clean; these two traits are the only places the engine touches the +//! outside world. Native implementations live in [`crate::platform_native`] +//! (filesystem + `std::thread`); a wasm shell supplies its own (in-memory bytes +//! + a Web Worker pool). + +use std::path::Path; + +use dem_io::Heightmap; + +/// Reads DEM windows for the streaming tier workers. +/// +/// The `key: &Path` is an **opaque tile identifier**, not necessarily a real +/// filesystem path: the native impl treats it as one, a wasm impl maps it to +/// in-memory bytes (and, later, HTTP Range requests). Errors are stringified so +/// the trait stays free of `dem_io`'s native error type. +/// +/// Implementations must be `Send + Sync` because the tier workers run on spawned +/// jobs and hold an `Arc`. +pub trait TileSource: Send + Sync { + /// Extract a window of `radius_m` metres centred on `centre_crs` (the tile's + /// native CRS coordinates) at overview level `ifd`. + fn read_window( + &self, + key: &Path, + centre_crs: (f64, f64), + radius_m: f64, + ifd: usize, + ) -> Result; + + /// Read the entire tile (auto-detecting the CRS), used by the geographic + /// single-tile path and as a last-resort fallback. + fn read_full(&self, key: &Path) -> Result; + + /// Centre point of the tile in its native CRS, without loading pixel data. + fn tile_centre_crs(&self, key: &Path) -> Result<(f64, f64), String>; + + /// Pixel scale of each overview (IFD) level, finest first. + fn ifd_scales(&self, key: &Path) -> Result, String>; +} + +/// Runs a job off the main thread. Replaces the direct `std::thread::spawn` +/// sites in the original viewer so the shell decides the threading model: +/// `std::thread` natively, a Web Worker pool (`wasm_thread` / +/// `wasm-bindgen-rayon`) on wasm. +/// +/// Job closures must carry **only plain data** (`Heightmap`, `NormalMap`, +/// `ShadowMask`, `Vec`) plus an `Arc` — never a wgpu handle, +/// which is `!Send` on wasm. Results flow back over `std::sync::mpsc`, which is +/// portable. +pub trait Spawner: Send + Sync { + fn spawn(&self, job: Box); +} diff --git a/crates/viewer_core/src/platform_native.rs b/crates/viewer_core/src/platform_native.rs new file mode 100644 index 0000000..baa663a --- /dev/null +++ b/crates/viewer_core/src/platform_native.rs @@ -0,0 +1,76 @@ +//! Native (filesystem + `std::thread`) implementations of the platform traits. +//! Compiled only off-wasm; a wasm shell supplies its own adapters. + +#![cfg(not(target_arch = "wasm32"))] + +use std::path::{Path, PathBuf}; + +use dem_io::{Heightmap, extract_window}; + +use crate::platform::{Spawner, TileSource}; +use crate::tile_index::{TileEntry, TileIndex}; + +/// Filesystem-backed [`TileSource`] wrapping the `dem_io` `&Path` readers. +pub struct NativeTileSource; + +impl TileSource for NativeTileSource { + fn read_window( + &self, + key: &Path, + centre_crs: (f64, f64), + radius_m: f64, + ifd: usize, + ) -> Result { + extract_window(key, centre_crs, radius_m, ifd).map_err(|e| e.to_string()) + } + + fn read_full(&self, key: &Path) -> Result { + dem_io::parse_geotiff_auto(key).map_err(|e| e.to_string()) + } + + fn tile_centre_crs(&self, key: &Path) -> Result<(f64, f64), String> { + dem_io::tile_centre_crs(key).map_err(|e| e.to_string()) + } + + fn ifd_scales(&self, key: &Path) -> Result, String> { + dem_io::ifd_scales(key).map_err(|e| e.to_string()) + } +} + +/// `std::thread::spawn`-backed [`Spawner`]. +pub struct ThreadSpawner; + +impl Spawner for ThreadSpawner { + fn spawn(&self, job: Box) { + std::thread::spawn(job); + } +} + +/// Build a `TileIndex` from an explicit list of paths. Missing files or files +/// that fail CRS / bounds extraction are silently skipped (graceful +/// degradation). This is I/O (it opens each tile), so it lives in the native +/// adapter rather than the portable `tile_index` module. +pub fn build_tile_index(paths: &[PathBuf]) -> TileIndex { + let mut index = Vec::new(); + for path in paths { + if !path.exists() { + continue; + } + let Ok(proj4) = dem_io::crs::tile_proj4(path) else { + continue; + }; + let Ok((lat_min, lat_max, lon_min, lon_max)) = dem_io::tile_bounds_wgs84(path) else { + continue; + }; + index.push(TileEntry { + path: path.clone(), + ifd: 0, + crs_proj4: proj4, + lat_min, + lat_max, + lon_min, + lon_max, + }); + } + index +} diff --git a/crates/viewer_core/src/scene_build.rs b/crates/viewer_core/src/scene_build.rs new file mode 100644 index 0000000..2aa7a7a --- /dev/null +++ b/crates/viewer_core/src/scene_build.rs @@ -0,0 +1,39 @@ +//! CPU pipeline over in-memory `Heightmap`s, extracted from the binary's +//! `viewer/scene_init.rs`. The `&Path`/`File` reads that fed the original +//! `prepare_*` functions are platform-bound and live behind +//! [`crate::platform::TileSource`]; what remains here operates purely on +//! already-loaded heightmaps and is shared by the streaming tier workers. + +use dem_io::{Heightmap, crop}; + +use crate::tiers::AO_RADIUS_M; + +// Day 172 = June 21 (summer solstice). Must match sim_day / sim_hour in the +// ViewerCore init and the initial shadow computed at scene build — changing one +// without the others produces a mismatch between the displayed sun and the +// shadow map at startup. +pub const INIT_SIM_DAY: i32 = 172; +pub const INIT_SIM_HOUR: f32 = 10.0; // 10:00 AM solar time + +/// Compute ambient occlusion for a 2×AO_RADIUS_M window centred on the camera, +/// then splat the result back into a full-heightmap-sized buffer (1.0 fill outside +/// the crop). This is ~27× faster than running AO over the entire heightmap. +pub fn compute_ao_cropped(hm: &Heightmap, cam_x: f64, cam_y: f64) -> Vec { + let cam_col = (cam_x / hm.dx_meters) as isize; + let cam_row = (cam_y / hm.dy_meters) as isize; + let radius_px = (AO_RADIUS_M / hm.dx_meters) as isize; + let row_start = (cam_row - radius_px).max(0) as usize; + let col_start = (cam_col - radius_px).max(0) as usize; + let crop_rows = + ((cam_row + radius_px).min(hm.rows as isize) - row_start as isize).max(0) as usize; + let crop_cols = + ((cam_col + radius_px).min(hm.cols as isize) - col_start as isize).max(0) as usize; + let cropped_hm = crop(hm, row_start, col_start, crop_rows, crop_cols); + let crop_ao = terrain::compute_ao_true_hemi(&cropped_hm, 16, 10.0f32.to_radians(), 200.0); + let mut ao = vec![1.0f32; hm.rows * hm.cols]; + for r in 0..crop_rows { + let dst = (row_start + r) * hm.cols + col_start; + ao[dst..dst + crop_cols].copy_from_slice(&crop_ao[r * crop_cols..(r + 1) * crop_cols]); + } + ao +} diff --git a/crates/viewer_core/src/tiers.rs b/crates/viewer_core/src/tiers.rs new file mode 100644 index 0000000..9e42666 --- /dev/null +++ b/crates/viewer_core/src/tiers.rs @@ -0,0 +1,866 @@ +use std::sync::{Arc, mpsc}; + +use dem_io::{Heightmap, crop, stitch_windows, stitch_windows_geographic}; +use render_gpu::GpuScene; +use terrain::ShadowMask; + +use render_gpu::VramClass; + +use crate::consts::{GPU_SAFE_PX, M_PER_DEG}; +use crate::geo::sun_position; +use crate::platform::{Spawner, TileSource}; +use crate::scene_build::{INIT_SIM_DAY, INIT_SIM_HOUR, compute_ao_cropped}; +use crate::tile_index::{TileIndex, tiles_overlapping_wgs84}; + +/// Resolved tier geometry for the active VRAM class. +/// +/// `fine.radius_m == 0.0` is the sentinel for "don't spawn the fine tier +/// worker"; the BevBaseState stores `fine: None` in that case and the viewer's +/// reload loop short-circuits on it. None of the shipped presets set it to +/// zero — the Low preset keeps a tiny fine window (1 km radius / 300 m drift) +/// so the user still sees 1 m detail right around the camera. The runtime OOM +/// handler mutates it to 0.0 if the actual GPU pressure turns out to be +/// tighter than the preset assumed. +#[derive(Clone, Copy, Debug)] +pub struct TierRadii { + pub base_radius_m: f64, + pub base_drift_m: f64, + pub close_radius_m: f64, + pub close_drift_m: f64, + pub fine_radius_m: f64, + pub fine_drift_m: f64, +} + +/// Map a VRAM class to tier radii / drift thresholds. +/// +/// Memory math (Tirol demo, with the drop-first eager-dealloc reload cycle): +/// +/// | preset | base | close | fine | steady mem | reload peak | +/// |---|---|---|---|---|---| +/// | High | 90 km / 30 km drift | 20 km / 3 km drift | 3.5 km / 1 km drift | ~2.6 GB | ~2.6 GB | +/// | Mid | 70 km / 23 km drift | 14 km / 2 km drift | 2.5 km / 800 m drift | ~1.7 GB | ~1.7 GB | +/// | Low | 50 km / 17 km drift | 8 km / 1.5 km drift | 1 km / 300 m drift | ~0.7 GB | ~0.7 GB | +/// +/// The "High" row is the project's original hardcoded geometry; it stays the +/// reference for systems with no memory pressure (Apple Silicon, 8 GB+ discrete). +/// +/// Low's fine tier loads ~2000×2000 R32Float (≈ 48 MB across hm + normal + +/// shadow) which is cheap enough to fit on a 4 GB card and gives a small island +/// of 1 m detail right around the camera. The drift threshold is tighter (300 m +/// vs the High preset's 1 km) because the smaller window means the camera +/// leaves it sooner — but the source is local IO + CPU work, not GPU memory, +/// so frequent reloads are fine. +pub fn tier_radii(class: VramClass) -> TierRadii { + match class { + VramClass::High => TierRadii { + base_radius_m: 90_000.0, + base_drift_m: 30_000.0, + close_radius_m: 20_000.0, + close_drift_m: 3_000.0, + fine_radius_m: 3_500.0, + fine_drift_m: 1_000.0, + }, + VramClass::Mid => TierRadii { + base_radius_m: 70_000.0, + base_drift_m: 23_000.0, + close_radius_m: 14_000.0, + close_drift_m: 2_000.0, + fine_radius_m: 2_500.0, + fine_drift_m: 800.0, + }, + VramClass::Low => TierRadii { + base_radius_m: 50_000.0, + base_drift_m: 17_000.0, + close_radius_m: 8_000.0, + close_drift_m: 1_500.0, + // Tiny fine window — still useful at low altitudes, ~48 MB of GPU + // memory total. The runtime OOM handler may zero this on pressure. + fine_radius_m: 1_000.0, + fine_drift_m: 300.0, + }, + } +} + +/// Crop a heightmap to at most `GPU_SAFE_PX × GPU_SAFE_PX` pixels centered on +/// `(centre_e, centre_n)` (CRS-native: easting/northing for projected, lon/lat for geographic). +/// No-op when the heightmap already fits. +pub fn cap_to_gpu_limit(hm: Heightmap, centre_e: f64, centre_n: f64) -> Heightmap { + if hm.cols <= GPU_SAFE_PX && hm.rows <= GPU_SAFE_PX { + return hm; + } + // For geographic tiles dx_meters stores deg/px, not m/px — use dx_deg / dy_deg for + // pixel position. For projected tiles dx_deg == 0.0. + let (px_per_unit_x, px_per_unit_y) = if hm.dx_deg != 0.0 { + (1.0 / hm.dx_deg, 1.0 / hm.dy_deg) + } else { + (1.0 / hm.dx_meters, 1.0 / hm.dy_meters) + }; + let cam_col = + ((centre_e - hm.crs_origin_x) * px_per_unit_x).clamp(0.0, (hm.cols - 1) as f64) as usize; + let cam_row = + ((hm.crs_origin_y - centre_n) * px_per_unit_y).clamp(0.0, (hm.rows - 1) as f64) as usize; + let out_cols = GPU_SAFE_PX.min(hm.cols); + let out_rows = GPU_SAFE_PX.min(hm.rows); + let col_start = cam_col.saturating_sub(out_cols / 2).min(hm.cols - out_cols); + let row_start = cam_row.saturating_sub(out_rows / 2).min(hm.rows - out_rows); + crop(&hm, row_start, col_start, out_rows, out_cols) +} + +pub const AO_RADIUS_M: f64 = 20_000.0; +// AO_RADIUS_M − AO_DRIFT_THRESHOLD_M = minimum margin of valid AO data behind the camera +pub const AO_DRIFT_THRESHOLD_M: f64 = 5_000.0; + +/// Common result sent by any BEV background streaming worker. +/// `centre_lat`/`centre_lon` are WGS84 degrees of the loaded window centre. +/// All `gpu_*` fields are pre-converted to GPU-ready byte layouts on the worker thread so +/// the main thread only needs to call `write_texture`/`write_buffer` — no blocking CPU work. +pub struct TierData { + pub hm: Arc, + pub shadow: ShadowMask, + pub centre_lat: f64, + pub centre_lon: f64, + /// Rg16Snorm bytes (4 bytes/pixel): normal texture for 5m/1m tiers. + pub gpu_normals_rg16: Vec, + /// u32-packed normal bytes (4 bytes/pixel): storage buffer for base tier. + pub gpu_normals_u32: Vec, + /// R16Float bytes (2 bytes/pixel): heightmap texture for base tier. + pub gpu_hm_f16: Vec, + /// Pre-generated mip levels (width, height, bytes) for base tier heightmap. + pub gpu_hm_mips: Vec<(u32, u32, Vec)>, + /// R8Unorm bytes (1 byte/pixel): AO texture for base tier. + pub gpu_ao_u8: Vec, +} + +/// Per-tier channel state and drift-detection bookkeeping. +/// +/// `last_cx`/`last_cy` store WGS84 (lat, lon) in degrees. +/// `drift_threshold_m` is stored in degrees (metres / M_PER_DEG). +pub struct StreamingTier { + pub tx: mpsc::SyncSender<(f64, f64)>, + rx: mpsc::Receiver, + pub computing: bool, + last_cx: f64, + last_cy: f64, + drift_threshold_m: f64, +} + +impl StreamingTier { + pub fn new( + tx: mpsc::SyncSender<(f64, f64)>, + rx: mpsc::Receiver, + init_cx: f64, + init_cy: f64, + drift_threshold_m: f64, + ) -> Self { + StreamingTier { + tx, + rx, + computing: false, + last_cx: init_cx, + last_cy: init_cy, + drift_threshold_m, + } + } + + /// True when the camera has drifted far enough from the last window centre + /// that a reload is warranted. + pub fn needs_reload(&self, e: f64, n: f64) -> bool { + (e - self.last_cx).abs() > self.drift_threshold_m + || (n - self.last_cy).abs() > self.drift_threshold_m + } + + /// Send a reload request to the background worker. + /// Sets `computing = true` on success and returns true. + pub fn try_trigger(&mut self, e: f64, n: f64) -> bool { + if self.tx.try_send((e, n)).is_ok() { + self.computing = true; + true + } else { + false + } + } + + /// Poll for a finished bundle. On success, clears `computing` and + /// updates `last_cx`/`last_cy` from the bundle's centre coordinates. + pub fn try_recv(&mut self) -> Option { + match self.rx.try_recv() { + Ok(data) => { + self.computing = false; + self.last_cx = data.centre_lat; + self.last_cy = data.centre_lon; + Some(data) + } + Err(_) => None, + } + } + + /// Force-reset drift tracking so `needs_reload` returns true on the next check. + /// Call this when the base heightmap swaps: the close tier's tile-local offsets + /// become stale and it must reload immediately regardless of camera position. + /// Setting last_cx/cy to 0.0 guarantees the check fires (Austrian CRS values + /// are at ~4.4 M easting, far from zero). + pub fn invalidate(&mut self) { + self.computing = false; + self.last_cx = 0.0; + self.last_cy = 0.0; + } + + /// Update the drift threshold to match the actual loaded window half-extent. + /// Called after a base tier reload so the threshold reflects the real window size + /// rather than the initial (potentially much smaller) estimate. + pub fn update_threshold(&mut self, new_threshold_m: f64) { + self.drift_threshold_m = new_threshold_m; + } +} + +/// Find the finest IFD level where scale ≥ `min_scale_m` and window fits in `max_px`. +pub fn select_ifd(scales: &[f64], min_scale_m: f64, radius_m: f64, max_px: u32) -> usize { + for (i, &scale) in scales.iter().enumerate() { + let window_px = (radius_m * 2.0 / scale) as u32; + if scale >= min_scale_m && window_px <= max_px { + return i; + } + } + scales.len().saturating_sub(1) +} + +/// Persistent state for BEV multi-tier streaming mode. +pub struct BevBaseState { + pub base: StreamingTier, // wide window, low resolution (IFD-2/1) + pub close: StreamingTier, // close window, 5 m/px (IFD-0) + pub fine: Option, // fine window, 1 m/px (1m tile IFD-0); None if no 1m tiles available +} + +impl BevBaseState { + /// Spawn all three tier workers and return the populated state. + /// + /// Works for both demo view (3 TileIndex from config, `TileEntry.ifd = 0`) and single-file + /// mode (1-entry TileIndex per tier with pre-selected IFD). All workers communicate in + /// WGS84 `(lat, lon)` and convert to each tile's native CRS independently. + /// + /// `hm` is the already-loaded base heightmap; `scene` receives a synchronous initial + /// close-tier upload so the viewer starts with close-range detail visible immediately. + /// `tile_source` reads each window (filesystem natively, bytes on wasm) and `spawner` + /// runs the three worker loops off the main thread. + #[allow(clippy::too_many_arguments)] + pub fn new( + fine_index: Arc, + close_index: Arc, + base_index: Arc, + cam_lat: f64, + cam_lon: f64, + lat_rad: f32, + radii: TierRadii, + hm: &Arc, + scene: &mut GpuScene, + tile_source: &Arc, + spawner: &dyn Spawner, + ) -> Self { + let base_radius_m = radii.base_radius_m; + let close_radius_m = radii.close_radius_m; + let fine_radius_m = radii.fine_radius_m; + + // base worker + let (base_tx, base_worker_rx) = mpsc::sync_channel::<(f64, f64)>(1); + let (base_worker_tx, base_rx) = mpsc::channel::(); + let base_idx = Arc::clone(&base_index); + let lat_rad_b = lat_rad; + let ts_base = Arc::clone(tile_source); + spawner.spawn(Box::new(move || { + while let Ok((lat, lon)) = base_worker_rx.recv() { + let radius_deg_lat = base_radius_m / M_PER_DEG; + let radius_deg_lon = base_radius_m / (M_PER_DEG * lat.to_radians().cos()); + let overlapping = tiles_overlapping_wgs84(&base_idx, lat, lon, base_radius_m); + if overlapping.is_empty() { + continue; + } + // Convert camera to the first entry's CRS for AO and cap_to_gpu_limit. + let first = &base_idx[overlapping[0]]; + let Ok((cam_cx, cam_cy)) = dem_io::crs::from_wgs84(lat, lon, &first.crs_proj4) + else { + continue; + }; + let is_geo = dem_io::crs::is_geographic(&first.crs_proj4); + let windows: Vec<_> = overlapping + .iter() + .filter_map(|&i| { + let e = &base_idx[i]; + let Ok((cx, cy)) = dem_io::crs::from_wgs84(lat, lon, &e.crs_proj4) else { + return None; + }; + let radius = if is_geo { + radius_deg_lon.max(radius_deg_lat) + } else { + base_radius_m + }; + ts_base.read_window(&e.path, (cx, cy), radius, e.ifd).ok() + }) + .collect(); + if windows.is_empty() { + continue; + } + let raw = if windows.len() == 1 { + windows.into_iter().next().unwrap() + } else { + stitch_windows_geographic(windows, lon, lat, radius_deg_lon, radius_deg_lat) + }; + let mut hm = cap_to_gpu_limit(raw, cam_cx, cam_cy); + dem_io::clamp_nodata_to_sea(&mut hm); + let hm = Arc::new(hm); + let normals = terrain::compute_normals_vector_par(&hm); + let (az, el) = sun_position(lat_rad_b, INIT_SIM_DAY, INIT_SIM_HOUR); + let shadow = terrain::compute_shadow_vector_par_with_azimuth(&hm, az, el, 200.0); + let (cam_x, cam_y) = if dem_io::crs::is_geographic(&hm.crs_proj4) { + let dx_m = hm.dx_deg * M_PER_DEG * lat.to_radians().cos(); + let dy_m = hm.dy_deg.abs() * M_PER_DEG; + let px = (lon - hm.crs_origin_x) / hm.dx_deg; + let py = (hm.crs_origin_y - lat) / hm.dy_deg.abs(); + (px * dx_m, py * dy_m) + } else { + (cam_cx - hm.crs_origin_x, hm.crs_origin_y - cam_cy) + }; + let ao = compute_ao_cropped(&hm, cam_x, cam_y); + let gpu_hm_f16 = render_gpu::hm_to_f16_bytes(&hm.data); + let gpu_hm_mips = render_gpu::gen_hm_mip_bytes(&gpu_hm_f16, hm.cols, hm.rows); + let gpu_normals_u32 = render_gpu::pack_normals_u32_bytes(&normals.nx, &normals.ny); + let gpu_ao_u8 = render_gpu::pack_ao_u8(&ao); + if base_worker_tx + .send(TierData { + hm, + shadow, + centre_lat: lat, + centre_lon: lon, + gpu_normals_rg16: vec![], + gpu_normals_u32, + gpu_hm_f16, + gpu_hm_mips, + gpu_ao_u8, + }) + .is_err() + { + break; + } + } + })); + + // close worker + // Shared slot: close worker writes its latest filled hm; fine worker reads it to + // fill 1m NODATA from the 5m source. + let recent_5m: Arc>>> = + Arc::new(std::sync::Mutex::new(None)); + let recent_5m_close = Arc::clone(&recent_5m); + let recent_5m_fine = Arc::clone(&recent_5m); + + let (hm5m_tx, hm5m_worker_rx) = mpsc::sync_channel::<(f64, f64)>(1); + let (hm5m_worker_tx, hm5m_rx) = mpsc::channel::(); + let close_idx = Arc::clone(&close_index); + let lat_rad_5m = lat_rad; + let base_hm_close = Arc::clone(hm); + let ts_close = Arc::clone(tile_source); + spawner.spawn(Box::new(move || { + while let Ok((lat, lon)) = hm5m_worker_rx.recv() { + let overlapping = tiles_overlapping_wgs84(&close_idx, lat, lon, close_radius_m); + if overlapping.is_empty() { + continue; + } + let entry = &close_idx[overlapping[0]]; + let Ok((cx, cy)) = dem_io::crs::from_wgs84(lat, lon, &entry.crs_proj4) else { + continue; + }; + let Ok(hm5m_raw) = + ts_close.read_window(&entry.path, (cx, cy), close_radius_m, entry.ifd) + else { + continue; + }; + let mut hm5m_raw = cap_to_gpu_limit(hm5m_raw, cx, cy); + dem_io::fill_nodata_from_base(&mut hm5m_raw, &base_hm_close); + dem_io::clamp_nodata_to_sea(&mut hm5m_raw); + let hm5m = Arc::new(hm5m_raw); + if let Ok(mut g) = recent_5m_close.lock() { + *g = Some(Arc::clone(&hm5m)); + } + let normals = terrain::compute_normals_vector_par(&hm5m); + let (az, el) = sun_position(lat_rad_5m, INIT_SIM_DAY, INIT_SIM_HOUR); + let shadow = terrain::compute_shadow_vector_par_with_azimuth(&hm5m, az, el, 200.0); + let gpu_normals_rg16 = render_gpu::pack_normals_rg16_bytes(&normals.nx, &normals.ny); + // Use the *requested* centre, not the geometric window centre. + // Near a tile edge the window is clipped, so its geometric centre drifts + // away from the camera — triggering an infinite reload loop. + if hm5m_worker_tx + .send(TierData { + hm: hm5m, + shadow, + centre_lat: lat, + centre_lon: lon, + gpu_normals_rg16, + gpu_normals_u32: vec![], + gpu_hm_f16: vec![], + gpu_hm_mips: vec![], + gpu_ao_u8: vec![], + }) + .is_err() + { + break; + } + } + })); + + // blocking initial close-tier load + // Loads synchronously so the viewer starts with close-range detail immediately + // rather than waiting for the first drift threshold to fire. + let mut last_5m_lat = 0.0_f64; + let mut last_5m_lon = 0.0_f64; + let mut effective_close_threshold = radii.close_drift_m; + let overlapping_close = + tiles_overlapping_wgs84(&close_index, cam_lat, cam_lon, close_radius_m); + if let Some(&ci) = overlapping_close.first() { + let entry = &close_index[ci]; + if let Ok((cx, cy)) = dem_io::crs::from_wgs84(cam_lat, cam_lon, &entry.crs_proj4) + && let Ok(hm5m_init) = + tile_source.read_window(&entry.path, (cx, cy), close_radius_m, entry.ifd) + { + let mut hm5m_init = cap_to_gpu_limit(hm5m_init, cx, cy); + dem_io::fill_nodata_from_base(&mut hm5m_init, hm); + dem_io::clamp_nodata_to_sea(&mut hm5m_init); + // When the GPU cap shrinks the window below close_radius_m (e.g. 1m tiles with no + // overviews), keep the threshold at ≤ half the actual window half-extent so the + // camera never exits the loaded window before a reload fires. + let close_half_m = (hm5m_init.cols as f64 * hm5m_init.dx_meters) + .min(hm5m_init.rows as f64 * hm5m_init.dy_meters) + * 0.5; + effective_close_threshold = radii.close_drift_m.min(close_half_m * 0.5); + let (origin_x, origin_y, extent_x, extent_y, rot_rad) = + cross_crs_world_origin_and_extent(&hm5m_init, hm); + let hm5m_init = Arc::new(hm5m_init); + if let Ok(mut g) = recent_5m.lock() { + *g = Some(Arc::clone(&hm5m_init)); + } + let normals5 = terrain::compute_normals_vector_par(&hm5m_init); + let (az, el) = sun_position(lat_rad, INIT_SIM_DAY, INIT_SIM_HOUR); + let shadow5 = + terrain::compute_shadow_vector_par_with_azimuth(&hm5m_init, az, el, 200.0); + let normals5_rg16 = render_gpu::pack_normals_rg16_bytes(&normals5.nx, &normals5.ny); + last_5m_lat = cam_lat; + last_5m_lon = cam_lon; + scene.upload_hm5m( + origin_x, + origin_y, + rot_rad, + extent_x, + extent_y, + &hm5m_init, + &normals5_rg16, + &shadow5, + ); + } + } + + // fine worker + // fine_radius_m == 0.0 is the runtime kill sentinel (set by the OOM + // degradation path). None of the launcher presets currently set it to + // zero — even Low loads a tiny 1 km fine window — but we keep the gate + // so a future preset (or a hand-edited config) can opt out cleanly. + let fine = if fine_index.is_empty() || fine_radius_m <= 0.0 { + if fine_radius_m <= 0.0 { + eprintln!("[tier] fine tier disabled (radius = 0)"); + } + None + } else { + let (hm1m_tx, hm1m_worker_rx) = mpsc::sync_channel::<(f64, f64)>(1); + let (hm1m_worker_tx, hm1m_rx) = mpsc::channel::(); + let fine_idx = Arc::clone(&fine_index); + let lat_rad_1m = lat_rad; + let recent_5m_w = recent_5m_fine; + let ts_fine = Arc::clone(tile_source); + spawner.spawn(Box::new(move || { + while let Ok((lat, lon)) = hm1m_worker_rx.recv() { + let overlapping = tiles_overlapping_wgs84(&fine_idx, lat, lon, fine_radius_m); + if overlapping.is_empty() { + continue; + } + let entry = &fine_idx[overlapping[0]]; + let Ok((e_tile, n_tile)) = dem_io::crs::from_wgs84(lat, lon, &entry.crs_proj4) + else { + continue; + }; + let windows: Vec<_> = overlapping + .iter() + .filter_map(|&i| { + let e = &fine_idx[i]; + let Ok((et, nt)) = dem_io::crs::from_wgs84(lat, lon, &e.crs_proj4) + else { + return None; + }; + ts_fine.read_window(&e.path, (et, nt), fine_radius_m, e.ifd).ok() + }) + .collect(); + if windows.is_empty() { + continue; + } + let raw1m = if windows.len() == 1 { + windows.into_iter().next().unwrap() + } else { + stitch_windows(windows, e_tile, n_tile, fine_radius_m) + }; + let mut raw1m = cap_to_gpu_limit(raw1m, e_tile, n_tile); + if let Ok(g) = recent_5m_w.lock() + && let Some(ref close_hm) = *g + { + dem_io::fill_nodata_from_base(&mut raw1m, close_hm); + } + dem_io::clamp_nodata_to_sea(&mut raw1m); + let hm1m = Arc::new(raw1m); + let normals = terrain::compute_normals_vector_par(&hm1m); + let (az, el) = sun_position(lat_rad_1m, INIT_SIM_DAY, INIT_SIM_HOUR); + let shadow = + terrain::compute_shadow_vector_par_with_azimuth(&hm1m, az, el, 200.0); + let gpu_normals_rg16 = + render_gpu::pack_normals_rg16_bytes(&normals.nx, &normals.ny); + if hm1m_worker_tx + .send(TierData { + hm: hm1m, + shadow, + centre_lat: lat, + centre_lon: lon, + gpu_normals_rg16, + gpu_normals_u32: vec![], + gpu_hm_f16: vec![], + gpu_hm_mips: vec![], + gpu_ao_u8: vec![], + }) + .is_err() + { + break; + } + } + })); + Some(StreamingTier::new( + hm1m_tx, + hm1m_rx, + 0.0, + 0.0, + radii.fine_drift_m / M_PER_DEG, + )) + }; + + // Base drift threshold: cap to half the actual window half-extent so that the camera + // always stays inside the loaded window between reloads. For large-overview tiles + // the window >> base_radius_m and the constant wins; for GPU-capped tiles + // (e.g. 1m NZ LiDAR, 8192 px = 8 km) the derived value is much smaller. + let base_half_m = (hm.cols as f64 * hm.dx_meters).min(hm.rows as f64 * hm.dy_meters) * 0.5; + let effective_base_threshold = radii.base_drift_m.min(base_half_m * 0.5); + let base_drift_deg = effective_base_threshold / M_PER_DEG; + + BevBaseState { + base: StreamingTier::new(base_tx, base_rx, cam_lat, cam_lon, base_drift_deg), + close: StreamingTier::new( + hm5m_tx, + hm5m_rx, + last_5m_lat, + last_5m_lon, + effective_close_threshold / M_PER_DEG, + ), + fine, + } + } +} + +/// Like `cross_crs_world_origin` but also returns `(extent_x, extent_y)` of `hm` in the base +/// world frame. When the two tiers share the same CRS this falls back to `cols*dx / rows*dy`. +/// When they differ, both the TR corner (for extent_x) and BL corner (for extent_y) of `hm` +/// are projected through WGS84 so the extents are consistent with the cos-scaled world metres +/// used for origin_x/y. +pub fn cross_crs_world_origin_and_extent( + hm: &Heightmap, + base_hm: &Heightmap, +) -> (f32, f32, f32, f32, f32) { + let (ox, oy) = cross_crs_world_origin(hm, base_hm); + + if hm.crs_proj4 == base_hm.crs_proj4 { + return ( + ox, + oy, + (hm.cols as f64 * hm.dx_meters) as f32, + (hm.rows as f64 * hm.dy_meters) as f32, + 0.0, + ); + } + + // TR and BL corners of hm in its native CRS. + let (tr_crs_x, bl_crs_y) = if dem_io::crs::is_geographic(&hm.crs_proj4) { + ( + hm.crs_origin_x + hm.cols as f64 * hm.dx_deg, + hm.crs_origin_y - hm.rows as f64 * hm.dy_deg.abs(), + ) + } else { + ( + hm.crs_origin_x + hm.cols as f64 * hm.dx_meters, + hm.crs_origin_y - hm.rows as f64 * hm.dy_meters, + ) + }; + + let fallback = ( + ox, + oy, + hm.cols as f32 * hm.dx_meters as f32, + hm.rows as f32 * hm.dy_meters as f32, + 0.0, + ); + + if dem_io::crs::is_geographic(&base_hm.crs_proj4) { + let dx_m = base_hm.dx_deg * M_PER_DEG * base_hm.crs_origin_y.to_radians().cos(); + let dy_m = base_hm.dy_deg.abs() * M_PER_DEG; + let Ok((tr_lat, tr_lon)) = dem_io::crs::to_wgs84(tr_crs_x, hm.crs_origin_y, &hm.crs_proj4) + else { + return fallback; + }; + let Ok((bl_lat, bl_lon)) = dem_io::crs::to_wgs84(hm.crs_origin_x, bl_crs_y, &hm.crs_proj4) + else { + return fallback; + }; + let tr_wx = ((tr_lon - base_hm.crs_origin_x) / base_hm.dx_deg * dx_m) as f32; + let tr_wy = ((base_hm.crs_origin_y - tr_lat) / base_hm.dy_deg.abs() * dy_m) as f32; + let bl_wx = ((bl_lon - base_hm.crs_origin_x) / base_hm.dx_deg * dx_m) as f32; + let bl_wy = ((base_hm.crs_origin_y - bl_lat) / base_hm.dy_deg.abs() * dy_m) as f32; + let ex = tr_wx - ox; + let rot_rad = (tr_wy - oy).atan2(ex); + + let true_extent_x = ex.hypot(tr_wy - oy); + let true_extent_y = (bl_wy - oy).hypot(bl_wx - ox); + + (ox, oy, true_extent_x, true_extent_y, rot_rad) + } else { + let Ok((tr_lat, tr_lon)) = dem_io::crs::to_wgs84(tr_crs_x, hm.crs_origin_y, &hm.crs_proj4) + else { + return fallback; + }; + let Ok((tr_e, tr_n)) = dem_io::crs::from_wgs84(tr_lat, tr_lon, &base_hm.crs_proj4) else { + return fallback; + }; + let Ok((bl_lat, bl_lon)) = dem_io::crs::to_wgs84(hm.crs_origin_x, bl_crs_y, &hm.crs_proj4) + else { + return fallback; + }; + let Ok((bl_e, bl_n)) = dem_io::crs::from_wgs84(bl_lat, bl_lon, &base_hm.crs_proj4) else { + return fallback; + }; + + let tr_wx = (tr_e - base_hm.crs_origin_x) as f32; + let tr_wy = (base_hm.crs_origin_y - tr_n) as f32; + let bl_wx = (bl_e - base_hm.crs_origin_x) as f32; + let bl_wy = (base_hm.crs_origin_y - bl_n) as f32; + + let ex = tr_wx - ox; + let rot_rad = (tr_wy - oy).atan2(ex); + + let true_extent_x = ex.hypot(tr_wy - oy); + let true_extent_y = (bl_wy - oy).hypot(bl_wx - ox); + + (ox, oy, true_extent_x, true_extent_y, rot_rad) + } +} + +/// Compute the tile-local position of `hm`'s top-left corner in `base_hm`'s world frame +/// (metres from base_hm's top-left, X right, Y down). Routes through WGS84 for any CRS pair. +pub fn cross_crs_world_origin(hm: &Heightmap, base_hm: &Heightmap) -> (f32, f32) { + if hm.crs_proj4 == base_hm.crs_proj4 { + return ( + (hm.crs_origin_x - base_hm.crs_origin_x) as f32, + (base_hm.crs_origin_y - hm.crs_origin_y) as f32, + ); + } + let Ok((lat, lon)) = dem_io::crs::to_wgs84(hm.crs_origin_x, hm.crs_origin_y, &hm.crs_proj4) + else { + return (0.0, 0.0); + }; + if dem_io::crs::is_geographic(&base_hm.crs_proj4) { + // dx_meters is unreliable for geographic tiles; derive m/px from dx_deg. + let dx_m = base_hm.dx_deg * M_PER_DEG * base_hm.crs_origin_y.to_radians().cos(); + let dy_m = base_hm.dy_deg.abs() * M_PER_DEG; + let px = (lon - base_hm.crs_origin_x) / base_hm.dx_deg; + let py = (base_hm.crs_origin_y - lat) / base_hm.dy_deg.abs(); + ((px * dx_m) as f32, (py * dy_m) as f32) + } else { + let Ok((e, n)) = dem_io::crs::from_wgs84(lat, lon, &base_hm.crs_proj4) else { + return (0.0, 0.0); + }; + ( + (e - base_hm.crs_origin_x) as f32, + (base_hm.crs_origin_y - n) as f32, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // tier_radii + + #[test] + fn tier_radii_are_internally_ordered() { + for class in [VramClass::Low, VramClass::Mid, VramClass::High] { + let r = tier_radii(class); + assert!( + r.base_radius_m > r.close_radius_m && r.close_radius_m > r.fine_radius_m, + "{class:?}: radii must shrink base→close→fine" + ); + // A drift threshold ≥ its radius would let the camera leave the + // window before a reload ever fires. + assert!(r.base_drift_m < r.base_radius_m, "{class:?} base drift"); + assert!(r.close_drift_m < r.close_radius_m, "{class:?} close drift"); + assert!(r.fine_drift_m < r.fine_radius_m, "{class:?} fine drift"); + } + } + + #[test] + fn higher_budget_never_shrinks_a_radius() { + let lo = tier_radii(VramClass::Low); + let mid = tier_radii(VramClass::Mid); + let hi = tier_radii(VramClass::High); + assert!(hi.base_radius_m >= mid.base_radius_m && mid.base_radius_m >= lo.base_radius_m); + assert!(hi.close_radius_m >= mid.close_radius_m && mid.close_radius_m >= lo.close_radius_m); + assert!(hi.fine_radius_m >= mid.fine_radius_m && mid.fine_radius_m >= lo.fine_radius_m); + } + + // select_ifd + + #[test] + fn select_ifd_picks_finest_level_meeting_scale_and_size() { + let scales = [5.0, 25.0, 125.0]; + // base tier: want ≥ 30 m/px, 70 km radius, 8192 px cap. + // 5 m and 25 m fail the scale floor; 125 m passes and its window + // (70000·2/125 = 1120 px) fits the cap → level 2. + assert_eq!(select_ifd(&scales, 30.0, 70_000.0, 8192), 2); + // close tier: ≥ 4 m/px, 5 km radius → finest level 0 (5 m) qualifies. + assert_eq!(select_ifd(&scales, 4.0, 5_000.0, 8192), 0); + } + + #[test] + fn select_ifd_falls_through_to_coarsest_when_nothing_fits() { + // Single 5 m level, huge radius → 100000·2/5 = 40000 px blows the cap; + // no level satisfies it, so the function returns the coarsest (len-1 = 0). + assert_eq!(select_ifd(&[5.0], 4.0, 100_000.0, 8192), 0); + let scales = [5.0, 25.0]; + assert_eq!(select_ifd(&scales, 4.0, 1_000_000.0, 8192), 1); + } + + // cap_to_gpu_limit + + fn proj_hm(rows: usize, cols: usize) -> Heightmap { + Heightmap { + data: vec![0.0; rows * cols], + rows, + cols, + nodata: -9999.0, + origin_lat: 0.0, + origin_lon: 0.0, + dx_deg: 0.0, // projected → cap uses dx_meters + dy_deg: 0.0, + dx_meters: 1.0, + dy_meters: 1.0, + crs_origin_x: 0.0, + crs_origin_y: 0.0, + crs_epsg: 32633, + crs_proj4: String::new(), + } + } + + #[test] + fn cap_is_noop_when_within_limit() { + let hm = proj_hm(100, 100); + let out = cap_to_gpu_limit(hm, 50.0, -50.0); + assert_eq!((out.rows, out.cols), (100, 100)); + } + + #[test] + fn cap_crops_oversized_axis_around_camera() { + // 8200 px wide (over the 8192 limit), 4 px tall (under it). + let hm = proj_hm(4, 8200); + // Camera at easting 4100 → centred crop. col_start clamps to + // min(4100-4096, 8200-8192) = min(4, 8) = 4 → origin shifts east by 4 m. + let out = cap_to_gpu_limit(hm, 4100.0, 0.0); + assert_eq!(out.cols, GPU_SAFE_PX); + assert_eq!(out.rows, 4); // short axis untouched + assert_eq!(out.crs_origin_x, 4.0); + } + + // StreamingTier drift bookkeeping + + fn streaming_tier(init_cx: f64, init_cy: f64, drift_m: f64) -> StreamingTier { + // Worker-side endpoints are dropped immediately; these tests never send. + let (tx, _worker_rx) = mpsc::sync_channel::<(f64, f64)>(1); + let (_worker_tx, rx) = mpsc::channel::(); + StreamingTier::new(tx, rx, init_cx, init_cy, drift_m) + } + + #[test] + fn needs_reload_fires_past_threshold_on_either_axis() { + let t = streaming_tier(1000.0, 1000.0, 100.0); + assert!( + !t.needs_reload(1050.0, 1050.0), + "within threshold on both axes" + ); + assert!(t.needs_reload(1150.0, 1000.0), "x drift exceeds threshold"); + assert!(t.needs_reload(1000.0, 1150.0), "y drift exceeds threshold"); + } + + #[test] + fn invalidate_forces_next_reload() { + let mut t = streaming_tier(1000.0, 1000.0, 100.0); + assert!(!t.needs_reload(1000.0, 1000.0)); + t.invalidate(); + // last_cx/cy are now 0, so any realistic CRS coordinate trips the check. + assert!(t.needs_reload(1000.0, 1000.0)); + } + + #[test] + fn update_threshold_changes_drift_sensitivity() { + let mut t = streaming_tier(1000.0, 1000.0, 100.0); + assert!(t.needs_reload(1150.0, 1000.0)); + t.update_threshold(2_000.0); + assert!( + !t.needs_reload(1150.0, 1000.0), + "150 m drift is now within 2 km" + ); + } + + #[test] + fn try_recv_updates_centre_and_clears_computing() { + let (tx, _worker_rx) = mpsc::sync_channel::<(f64, f64)>(1); + let (worker_tx, rx) = mpsc::channel::(); + let mut t = StreamingTier::new(tx, rx, 0.0, 0.0, 100.0); + t.computing = true; + + let hm = Arc::new(proj_hm(2, 2)); + worker_tx + .send(TierData { + hm, + shadow: ShadowMask { + data: vec![1.0; 4], + rows: 2, + cols: 2, + }, + centre_lat: 47.5, + centre_lon: 11.5, + gpu_normals_rg16: Vec::new(), + gpu_normals_u32: Vec::new(), + gpu_hm_f16: Vec::new(), + gpu_hm_mips: Vec::new(), + gpu_ao_u8: Vec::new(), + }) + .expect("send"); + + let got = t.try_recv().expect("a bundle is queued"); + assert_eq!((got.centre_lat, got.centre_lon), (47.5, 11.5)); + assert!(!t.computing, "computing cleared once a bundle arrives"); + // Centre is now (47.5, 11.5); a nearby point no longer needs a reload. + assert!(!t.needs_reload(47.51, 11.51)); + } + + #[test] + fn try_recv_returns_none_when_idle() { + let mut t = streaming_tier(0.0, 0.0, 100.0); + assert!(t.try_recv().is_none()); + } +} diff --git a/crates/viewer_core/src/tile_index.rs b/crates/viewer_core/src/tile_index.rs new file mode 100644 index 0000000..0d96bda --- /dev/null +++ b/crates/viewer_core/src/tile_index.rs @@ -0,0 +1,98 @@ +use std::path::PathBuf; + +pub struct TileEntry { + /// Opaque key identifying the tile to the [`crate::platform::TileSource`]. + /// Native impl treats it as a filesystem path; a wasm impl may treat it as + /// a lookup key into in-memory bytes. + pub path: PathBuf, + /// IFD level to use when reading a window (0 = finest). + /// Set to 0 by `build_tile_index`; callers may override for coarser tiers. + pub ifd: usize, + pub crs_proj4: String, + pub lat_min: f64, + pub lat_max: f64, + pub lon_min: f64, + pub lon_max: f64, +} + +pub type TileIndex = Vec; + +/// Return indices of TileIndex entries whose WGS84 bounds overlap a box of +/// `radius_m` metres around `(lat, lon)`. +pub fn tiles_overlapping_wgs84(index: &TileIndex, lat: f64, lon: f64, radius_m: f64) -> Vec { + let dlat = radius_m / crate::consts::M_PER_DEG; + let dlon = radius_m / (crate::consts::M_PER_DEG * lat.to_radians().cos()); + index + .iter() + .enumerate() + .filter(|(_, e)| { + e.lat_max > lat - dlat + && e.lat_min < lat + dlat + && e.lon_max > lon - dlon + && e.lon_min < lon + dlon + }) + .map(|(i, _)| i) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // `build_tile_index` reads real GeoTIFFs (CRS + bounds extraction) and lives + // in `platform_native` (it is I/O) — not unit-tested here. + // These tests cover the pure geometry of `tiles_overlapping_wgs84`. + + fn entry(lat_min: f64, lat_max: f64, lon_min: f64, lon_max: f64) -> TileEntry { + TileEntry { + path: PathBuf::from("dummy.tif"), + ifd: 0, + crs_proj4: String::new(), + lat_min, + lat_max, + lon_min, + lon_max, + } + } + + #[test] + fn returns_overlapping_excludes_distant() { + let index = vec![ + entry(46.9, 47.1, 10.9, 11.1), // straddles the query point + entry(40.0, 41.0, 5.0, 6.0), // far to the south-west + ]; + let hits = tiles_overlapping_wgs84(&index, 47.0, 11.0, 5_000.0); + assert_eq!(hits, vec![0]); + } + + #[test] + fn longitude_margin_widens_with_latitude() { + // Same metre radius and the same 0.13° longitude gap from the query: + // at 60°N the 1/cos(lat) widening makes the box overlap, but at the + // equator the narrower longitude band does not reach it. + let radius = 10_000.0; + let high_lat = vec![entry(59.0, 61.0, 10.13, 10.14)]; + let equator = vec![entry(-1.0, 1.0, 10.13, 10.14)]; + + assert_eq!( + tiles_overlapping_wgs84(&high_lat, 60.0, 10.0, radius), + vec![0], + "0.13° gap is within the widened band at 60°N" + ); + assert!( + tiles_overlapping_wgs84(&equator, 0.0, 10.0, radius).is_empty(), + "0.13° gap is outside the narrow band at the equator" + ); + } + + #[test] + fn just_inside_vs_just_outside_margin() { + // dlat = radius / M_PER_DEG. At radius 5 km that's ≈ 0.0449°. + let radius = 5_000.0; + let dlat = radius / crate::consts::M_PER_DEG; + let inside = vec![entry(47.0 + dlat * 0.5, 48.0, 10.99, 11.01)]; + let outside = vec![entry(47.0 + dlat * 2.0, 48.0, 10.99, 11.01)]; + assert_eq!(tiles_overlapping_wgs84(&inside, 47.0, 11.0, radius), vec![0]); + assert!(tiles_overlapping_wgs84(&outside, 47.0, 11.0, radius).is_empty()); + } +}