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

nordisk/facett

facett-map3d-flight/src/tiles.rs

//! **Out-of-core tile streaming (§5)** — a background worker + an `mpsc`
//! [`Msg::TileLoaded`] stream that pages Sweden's [`crate::sweden`] tile grid in and
//! out of memory as the camera flies, without ever blocking the render loop (FC-7).
//!
//! ## The pipeline
//! 1. Each frame the render loop calls [`TileLoader::frame`] with the camera-snapped
//!    [`FloatingOrigin`], a zoom, a view `radius_m`, and a **resident-tile budget**.
//! 2. [`plan_tiles`] (pure) diffs the in-view set ([`crate::sweden::tiles_in_view`],
//!    already priority-ordered nearest-first) against what is resident/in-flight and
//!    yields `(to_load, to_evict)`: load the nearest missing tiles first, evict tiles
//!    that fell out of view or spilled past the budget.
//! 3. The loader dispatches load requests to a **background worker** over a channel;
//!    the worker reads each tile from a [`TileSource`] (a synthetic in-memory set OR
//!    a directory of per-tile GeoParquet) and sends [`Msg::TileLoaded`] back.
//! 4. The render loop drains results with [`TileLoader::poll`] (non-blocking).
//!
//! ## Determinism (FC-7)
//! The tile *selection* (what to load, in what order, what to evict) is a **pure**
//! function of the injected camera + budget — [`TileLoader::frame`] returns the
//! request order verbatim, so a scripted camera path yields a bit-reproducible
//! stream. The worker thread only performs the (already-decided, order-independent)
//! IO; [`TileLoader::wait_idle`] blocks on exactly the outstanding results (no
//! wall-clock) so a test can assert the resident set + eviction after a frame.
//!
//! ## §4 LOD budget wiring
//! Each tile is loaded with a **per-tile feature budget** derived from its LOD tier
//! ([`facett_map3d::LodTier::feature_budget`]) by its ground distance: near tiles get
//! the full budget, far tiles get few/merged ways.

use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread::JoinHandle;

use facett_core::Theme;
use facett_map::Way;

use crate::origin::FloatingOrigin;
use crate::sweden::{SpatialKey, tiles_in_view};

/// A result message from the streaming worker back to the render loop.
#[derive(Clone, Debug)]
pub enum Msg {
    /// A tile finished loading: its key + the (LOD-budgeted) ways it holds.
    TileLoaded(SpatialKey, Vec<Way>),
    /// A requested tile has no data in the source (a sea tile / gap) — so the loader
    /// can drop it from the in-flight set instead of retrying forever.
    TileMissing(SpatialKey),
}

/// A pluggable source of tile geometry. Kept behind a trait so the loader is
/// deterministically testable with an in-memory fixture ([`InMemoryTileSource`]) and
/// production-usable with on-disk GeoParquet ([`DirTileSource`]). `Send + Sync` so
/// the worker thread can hold it.
pub trait TileSource: Send + Sync {
    /// Load a tile's ways, keeping at most `max_ways` (the §4 LOD budget). `None`
    /// means the tile is absent in the source (not an error — a gap/sea tile).
    fn load(&self, key: SpatialKey, max_ways: usize) -> Option<Vec<Way>>;
}

/// An **in-memory tile set** — the deterministic test fixture (and a fine cache for a
/// small preloaded region). `load` clones the tile's ways truncated to the budget.
#[derive(Clone, Default)]
pub struct InMemoryTileSource {
    tiles: HashMap<SpatialKey, Vec<Way>>,
}

impl InMemoryTileSource {
    #[must_use]
    pub fn new() -> Self {
        Self { tiles: HashMap::new() }
    }

    /// Insert (or replace) a tile's ways.
    pub fn insert(&mut self, key: SpatialKey, ways: Vec<Way>) {
        self.tiles.insert(key, ways);
    }

    /// Number of tiles held.
    #[must_use]
    pub fn len(&self) -> usize {
        self.tiles.len()
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.tiles.is_empty()
    }
}

impl TileSource for InMemoryTileSource {
    fn load(&self, key: SpatialKey, max_ways: usize) -> Option<Vec<Way>> {
        self.tiles.get(&key).map(|ways| {
            let n = max_ways.min(ways.len());
            ways[..n].to_vec()
        })
    }
}

/// A **directory of per-tile GeoParquet** — the on-disk production source. Tiles live
/// at `{root}/{z}_{x}_{y}.parquet` (an `osm2geoparquet`/katana-osm `ways.parquet`
/// per tile); `load` reads them with [`facett_osm::read_ways`], honouring `max_ways`
/// as the per-tile feature cap. A missing file is a `None` (gap tile), not an error.
pub struct DirTileSource {
    root: std::path::PathBuf,
    theme: Theme,
}

impl DirTileSource {
    /// A directory source rooted at `root`, classifying ways through `theme`.
    #[must_use]
    pub fn new(root: impl Into<std::path::PathBuf>, theme: Theme) -> Self {
        Self { root: root.into(), theme }
    }

    /// The on-disk path for a tile: `{root}/{z}_{x}_{y}.parquet`.
    #[must_use]
    pub fn tile_path(&self, key: SpatialKey) -> std::path::PathBuf {
        self.root.join(format!("{}_{}_{}.parquet", key.z, key.x, key.y))
    }
}

impl TileSource for DirTileSource {
    fn load(&self, key: SpatialKey, max_ways: usize) -> Option<Vec<Way>> {
        let path = self.tile_path(key);
        if !path.exists() {
            return None; // gap / sea tile
        }
        match facett_osm::read_ways(path.to_str()?, max_ways, &self.theme) {
            Ok(ways) => Some(ways),
            Err(e) => {
                eprintln!("[tiles] {}: {e}", path.display());
                None
            }
        }
    }
}

/// The pure output of [`plan_tiles`]: which tiles to load (nearest-first) and which
/// to evict, for one frame.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TilePlan {
    /// Missing/not-in-flight in-view tiles to request, **priority-ordered
    /// nearest-first**, already capped so the resident + in-flight set stays inside
    /// the budget.
    pub to_load: Vec<SpatialKey>,
    /// Resident tiles to drop this frame: those that fell out of view, plus (if the
    /// resident set still overflows) the farthest-from-camera tiles down to budget.
    pub to_evict: Vec<SpatialKey>,
}

/// **Pure tile planner (the FC-7 core).** Diff the in-view set against what is
/// resident + in-flight and decide loads + evictions for one frame.
///
/// * `resident` — tiles currently held in memory.
/// * `in_flight` — tiles already requested but not yet arrived (don't re-request).
/// * `origin`, `z`, `radius_m` — the frustum/priority query (see [`tiles_in_view`]).
/// * `budget` — the maximum number of resident + in-flight tiles.
///
/// Loads are the nearest missing tiles, capped so `resident_after_evict + in_flight +
/// loads ≤ budget`. Evictions are every resident tile no longer in view, then (if
/// still over budget) the farthest resident tiles until it fits.
#[must_use]
pub fn plan_tiles(
    resident: &HashSet<SpatialKey>,
    in_flight: &HashSet<SpatialKey>,
    origin: FloatingOrigin,
    z: u8,
    radius_m: f64,
    budget: usize,
) -> TilePlan {
    let want = tiles_in_view(origin, z, radius_m); // nearest-first
    let want_set: HashSet<SpatialKey> = want.iter().copied().collect();
    let center = origin.anchor;

    // 1) Evict everything resident that is no longer in view.
    let mut to_evict: Vec<SpatialKey> = resident.iter().copied().filter(|k| !want_set.contains(k)).collect();
    // Deterministic order for the out-of-view evictions (farthest first).
    to_evict.sort_by(|a, b| {
        dist2(*b, center).partial_cmp(&dist2(*a, center)).unwrap_or(std::cmp::Ordering::Equal).then(a.cmp(b))
    });

    // Tiles that survive eviction so far (still in view).
    let kept: HashSet<SpatialKey> = resident.iter().copied().filter(|k| want_set.contains(k)).collect();

    // 2) Enforce the budget over kept + in-flight: if we already overflow with
    //    survivors, evict the farthest survivors down to budget (they are the
    //    lowest-priority in-view tiles).
    let mut kept_by_dist: Vec<SpatialKey> = kept.iter().copied().collect();
    kept_by_dist.sort_by(|a, b| {
        dist2(*a, center).partial_cmp(&dist2(*b, center)).unwrap_or(std::cmp::Ordering::Equal).then(a.cmp(b))
    });
    let in_flight_kept = in_flight.len();
    let mut kept_count = kept_by_dist.len();
    while kept_count + in_flight_kept > budget && kept_count > 0 {
        // Drop the farthest survivor.
        let victim = kept_by_dist[kept_count - 1];
        to_evict.push(victim);
        kept_count -= 1;
    }
    let kept_final: HashSet<SpatialKey> = kept_by_dist[..kept_count].iter().copied().collect();

    // 3) Load the nearest in-view tiles that are neither kept nor in-flight, capped
    //    so the total resident + in-flight stays within budget.
    let mut to_load = Vec::new();
    let mut total = kept_count + in_flight_kept;
    for key in want {
        if total >= budget {
            break;
        }
        if kept_final.contains(&key) || in_flight.contains(&key) {
            continue;
        }
        to_load.push(key);
        total += 1;
    }

    TilePlan { to_load, to_evict }
}

#[inline]
fn dist2(key: SpatialKey, center: crate::origin::WorldPos) -> f64 {
    let c = key.center_world();
    let dx = c.x - center.x;
    let dy = c.y - center.y;
    dx * dx + dy * dy
}

/// A worker request: load `key`, keeping at most `max_ways` (the tile's LOD budget).
struct Req {
    key: SpatialKey,
    max_ways: usize,
}

/// What [`TileLoader::frame`] did this frame — deterministic, so tests assert on it
/// directly rather than on thread timing.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FrameReport {
    /// Tiles requested this frame, **priority-ordered nearest-first**.
    pub requested: Vec<SpatialKey>,
    /// Tiles evicted from memory this frame.
    pub evicted: Vec<SpatialKey>,
}

/// The **out-of-core tile loader**: owns the background worker, the resident tile
/// map, and the in-flight set. Non-blocking: `frame` dispatches, `poll` drains.
pub struct TileLoader {
    tx: Option<Sender<Req>>,
    rx: Receiver<Msg>,
    worker: Option<JoinHandle<()>>,
    resident: HashMap<SpatialKey, Vec<Way>>,
    in_flight: HashSet<SpatialKey>,
    /// Base per-tile feature budget for the nearest (`Ground`) tier; far tiles scale
    /// down via [`facett_map3d::LodTier::feature_budget`].
    base_feature_budget: usize,
}

impl TileLoader {
    /// Spawn a loader over `source` with a base per-tile feature budget (the ground
    /// tier's cap; far tiles get fewer via the LOD ladder).
    #[must_use]
    pub fn new(source: Arc<dyn TileSource>, base_feature_budget: usize) -> Self {
        let (req_tx, req_rx) = channel::<Req>();
        let (res_tx, res_rx) = channel::<Msg>();
        let worker = std::thread::Builder::new()
            .name("facett-tile-stream".into())
            .spawn(move || worker_loop(source, req_rx, res_tx))
            .expect("spawn tile worker");
        Self {
            tx: Some(req_tx),
            rx: res_rx,
            worker: Some(worker),
            resident: HashMap::new(),
            in_flight: HashSet::new(),
            base_feature_budget: base_feature_budget.max(1),
        }
    }

    /// The per-tile feature budget for `key` given the camera centre: its LOD tier
    /// (by ground distance) scales the base budget (§4). Near = full, far = few.
    #[must_use]
    pub fn feature_budget_for(&self, key: SpatialKey, center: crate::origin::WorldPos) -> usize {
        let dist_m = key.center_world().ground_distance(center);
        facett_map3d::lod_tier(dist_m as f32).feature_budget(self.base_feature_budget)
    }

    /// Plan + dispatch one frame: request the nearest missing in-view tiles (priority
    /// order) and evict tiles that fell out of view / past the budget. Returns what it
    /// did (deterministic). Never blocks on IO.
    pub fn frame(&mut self, origin: FloatingOrigin, z: u8, radius_m: f64, tile_budget: usize) -> FrameReport {
        let resident_keys: HashSet<SpatialKey> = self.resident.keys().copied().collect();
        let plan = plan_tiles(&resident_keys, &self.in_flight, origin, z, radius_m, tile_budget);
        // Evict.
        for key in &plan.to_evict {
            self.resident.remove(key);
        }
        // Dispatch loads in priority order with per-tile LOD budgets.
        let center = origin.anchor;
        for &key in &plan.to_load {
            let max_ways = self.feature_budget_for(key, center);
            if let Some(tx) = &self.tx
                && tx.send(Req { key, max_ways }).is_ok()
            {
                self.in_flight.insert(key);
            }
        }
        FrameReport { requested: plan.to_load, evicted: plan.to_evict }
    }

    /// Integrate one result message into the resident/in-flight state.
    fn integrate(&mut self, msg: &Msg) {
        match msg {
            Msg::TileLoaded(key, ways) => {
                self.in_flight.remove(key);
                self.resident.insert(*key, ways.clone());
            }
            Msg::TileMissing(key) => {
                self.in_flight.remove(key);
            }
        }
    }

    /// Drain all currently-available results (non-blocking) into the resident set and
    /// return them — the render-loop pump.
    pub fn poll(&mut self) -> Vec<Msg> {
        let mut out = Vec::new();
        while let Ok(msg) = self.rx.try_recv() {
            self.integrate(&msg);
            out.push(msg);
        }
        out
    }

    /// Block until every in-flight tile has resolved, integrating each result as it
    /// arrives. Deterministic completion (waits on exactly the outstanding results —
    /// no wall-clock), so a test can assert the resident set after a frame.
    pub fn wait_idle(&mut self) -> Vec<Msg> {
        let mut out = Vec::new();
        while !self.in_flight.is_empty() {
            match self.rx.recv() {
                Ok(msg) => {
                    self.integrate(&msg);
                    out.push(msg);
                }
                Err(_) => break, // worker gone
            }
        }
        out
    }

    /// Keys currently resident in memory.
    #[must_use]
    pub fn resident_keys(&self) -> HashSet<SpatialKey> {
        self.resident.keys().copied().collect()
    }

    /// Number of resident tiles.
    #[must_use]
    pub fn resident_count(&self) -> usize {
        self.resident.len()
    }

    /// Number of tiles requested but not yet arrived.
    #[must_use]
    pub fn in_flight_count(&self) -> usize {
        self.in_flight.len()
    }

    /// The ways of a resident tile, if held.
    #[must_use]
    pub fn ways(&self, key: SpatialKey) -> Option<&[Way]> {
        self.resident.get(&key).map(|v| v.as_slice())
    }

    /// Total resident way count across all tiles (the memory-pressure proxy).
    #[must_use]
    pub fn total_ways(&self) -> usize {
        self.resident.values().map(Vec::len).sum()
    }
}

impl Drop for TileLoader {
    fn drop(&mut self) {
        // Close the request channel so the worker's recv loop ends, then join.
        self.tx = None;
        if let Some(h) = self.worker.take() {
            let _ = h.join();
        }
    }
}

/// The background worker: pull requests, load through the source, send results back.
/// Ends when the request channel closes (loader dropped).
fn worker_loop(source: Arc<dyn TileSource>, reqs: Receiver<Req>, out: Sender<Msg>) {
    while let Ok(Req { key, max_ways }) = reqs.recv() {
        let msg = match source.load(key, max_ways) {
            Some(ways) => Msg::TileLoaded(key, ways),
            None => Msg::TileMissing(key),
        };
        if out.send(msg).is_err() {
            break; // render loop gone
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::origin::WorldPos;
    use crate::sweden::SwedenExtent;

    /// Build `n` synthetic `building=yes` ways clustered near a tile centre — enough
    /// distinct geometry that a truncating budget is observable.
    fn fixture_ways(key: SpatialKey, n: usize) -> Vec<Way> {
        let (lon, lat) = key.center_lonlat();
        let mut out = Vec::new();
        for i in 0..n {
            let d = 0.0002 * (i as f64 + 1.0);
            let ring = [
                (lon, lat),
                (lon + d, lat),
                (lon + d, lat + d),
                (lon, lat + d),
                (lon, lat), // closed
            ];
            let tags = vec![("building".to_string(), "yes".to_string())];
            if let Some(w) = Way::from_lonlat(i as i64, &ring, &tags, &Theme::default()) {
                out.push(w);
            }
        }
        out
    }

    /// A synthetic Sweden tile set: a handful of tiles across the grid, each with
    /// `per_tile` ways. Returns the source + the keys it holds.
    fn synthetic_sweden(z: u8, per_tile: usize) -> (InMemoryTileSource, Vec<SpatialKey>) {
        let mut src = InMemoryTileSource::new();
        // Spread tiles across the country: south, mid, north.
        let spots = [(13.0, 55.6), (18.07, 59.33), (18.1, 59.34), (17.9, 59.5), (20.22, 67.85)];
        let mut keys = Vec::new();
        for &(lon, lat) in &spots {
            let k = SpatialKey::from_lonlat(lon, lat, z);
            src.insert(k, fixture_ways(k, per_tile));
            keys.push(k);
        }
        // Also fill the 3×3 neighbourhood around Stockholm so the near view is dense.
        let center = SpatialKey::from_lonlat(18.07, 59.33, z);
        for dy in -1i64..=1 {
            for dx in -1i64..=1 {
                let k = SpatialKey::new(z, (center.x as i64 + dx) as u32, (center.y as i64 + dy) as u32);
                if src.load(k, 1).is_none() {
                    src.insert(k, fixture_ways(k, per_tile));
                    keys.push(k);
                }
            }
        }
        (src, keys)
    }

    /// **INJECT-ASSERT (the pure planner streams the frustum nearest-first + evicts):**
    /// with nothing resident, `plan_tiles` requests exactly the in-view tiles in
    /// priority order (capped to budget); after the camera moves, tiles that left the
    /// view are evicted and the new near tiles are the loads. No thread involved — the
    /// deterministic core.
    #[test]
    fn plan_tiles_streams_frustum_priority_and_evicts() {
        let z = 12u8;
        let radius = 6_000.0;
        let budget = 8;
        let cam0 = FloatingOrigin::at(WorldPos::from_lonlat(18.07, 59.33, 3000.0));
        let want0 = tiles_in_view(cam0, z, radius);
        assert!(!want0.is_empty(), "camera sees tiles");

        // Nothing resident/in-flight → load the nearest `budget` in priority order.
        let plan = plan_tiles(&HashSet::new(), &HashSet::new(), cam0, z, radius, budget);
        assert!(plan.to_evict.is_empty(), "nothing to evict on the first frame");
        assert_eq!(plan.to_load.len(), budget.min(want0.len()), "loads up to the budget");
        assert_eq!(plan.to_load, want0.iter().take(plan.to_load.len()).copied().collect::<Vec<_>>(), "loads are the nearest-first prefix of the view");

        // Pretend those loaded, then fly ~4 km east so the view shifts.
        let resident: HashSet<SpatialKey> = plan.to_load.iter().copied().collect();
        let cam1 = FloatingOrigin::at(WorldPos::new(cam0.anchor.x + 4_000.0, cam0.anchor.y, cam0.anchor.z));
        let want1: HashSet<SpatialKey> = tiles_in_view(cam1, z, radius).into_iter().collect();
        let plan1 = plan_tiles(&resident, &HashSet::new(), cam1, z, radius, budget);
        // Everything evicted is genuinely out of the new view.
        assert!(plan1.to_evict.iter().all(|k| !want1.contains(k)), "only out-of-view tiles are evicted");
        // Something changed (the camera moved a tile or more).
        assert!(!plan1.to_evict.is_empty() || !plan1.to_load.is_empty(), "the moved camera restreams");
        // No tile is both loaded and evicted.
        let load1: HashSet<SpatialKey> = plan1.to_load.iter().copied().collect();
        assert!(plan1.to_evict.iter().all(|k| !load1.contains(k)), "no tile is loaded and evicted at once");

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-osm3d-sweden",
            "loader_streams_frustum",
            true,
            &format!("frame0_loads={} budget={budget} frame1_evicts={} frame1_loads={}", plan.to_load.len(), plan1.to_evict.len(), plan1.to_load.len()),
        );
    }

    /// **INJECT-ASSERT (the loader streams exactly the in-view tiles under budget):**
    /// drive the threaded loader with a synthetic Sweden source over Stockholm; the
    /// frame requests the nearest tiles in priority order, the worker streams them
    /// back, and the resident set is exactly those tiles — capped at the budget.
    #[test]
    fn loader_streams_inview_tiles_and_honours_budget() {
        let z = 12u8;
        let radius = 8_000.0;
        let budget = 6;
        let (src, _keys) = synthetic_sweden(z, 4);
        let mut loader = TileLoader::new(Arc::new(src), 256);
        let origin = FloatingOrigin::at(WorldPos::from_lonlat(18.07, 59.33, 3000.0));

        let report = loader.frame(origin, z, radius, budget);
        // Requested nearest-first, capped to budget.
        let want = tiles_in_view(origin, z, radius);
        let expect: Vec<SpatialKey> = want.iter().take(budget).copied().collect();
        assert_eq!(report.requested, expect, "requested the nearest `budget` tiles in priority order");
        assert!(report.evicted.is_empty(), "nothing evicted on the first frame");
        assert_eq!(loader.in_flight_count(), report.requested.len(), "all requests in flight");

        // Drain the worker deterministically.
        loader.wait_idle();
        assert_eq!(loader.in_flight_count(), 0, "all in-flight tiles resolved");
        // Resident set == requested set (those that had data), and within budget.
        let resident = loader.resident_keys();
        assert!(resident.len() <= budget, "resident tiles within budget ({} ≤ {budget})", resident.len());
        for k in &report.requested {
            // Every requested near tile is present (the fixture filled the Stockholm nbhd).
            assert!(resident.contains(k), "in-view tile {k:?} streamed in");
        }
    }

    /// **INJECT-ASSERT (§4 far tiles carry fewer features):** the per-tile budget from
    /// the LOD ladder gives a near (Ground) tile the full base budget and a far tile
    /// far fewer, and the streamed way counts reflect it.
    #[test]
    fn lod_budget_thins_far_tiles() {
        let z = 12u8;
        let base = 100usize;
        let center = WorldPos::from_lonlat(18.07, 59.33, 0.0);
        let near = SpatialKey::from_world(center, z);
        // A far tile ~40 km north (into the Mid/High band by distance-as-altitude).
        let far = SpatialKey::from_lonlat(18.07, 59.7, z);

        let mut src = InMemoryTileSource::new();
        // Both tiles hold `base` ways available; the budget is what truncates them.
        src.insert(near, fixture_ways(near, base));
        src.insert(far, fixture_ways(far, base));
        let loader = TileLoader::new(Arc::new(src.clone()), base);

        let near_budget = loader.feature_budget_for(near, center);
        let far_budget = loader.feature_budget_for(far, center);
        // The near tile sits in a coarser tier than a point-blank camera (its centre
        // is km away at z12), but it is always richer than the far tile — the LOD
        // ladder keyed on ground distance.
        assert!(near_budget > far_budget, "a far tile's budget is thinner ({far_budget} < {near_budget})");

        // The source truncates to the budget → the far tile streams fewer ways.
        let near_ways = src.load(near, near_budget).unwrap().len();
        let far_ways = src.load(far, far_budget).unwrap().len();
        assert_eq!(near_ways, near_budget.min(base), "near tile fills its budget");
        assert!(far_ways < near_ways, "far tile is thinned ({far_ways} < {near_ways})");

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-osm3d-sweden",
            "lod_budget",
            far_budget < near_budget && far_ways < near_ways,
            &format!("near_budget={near_budget} far_budget={far_budget} near_ways={near_ways} far_ways={far_ways}"),
        );
    }

    /// INJECT-ASSERT: a `DirTileSource` reads a synthetic per-tile `ways.parquet` off
    /// disk and honours the `max_ways` budget; a missing tile is a graceful `None`.
    #[test]
    fn dir_tile_source_reads_geoparquet_and_caps() {
        use std::sync::Arc as A;
        use arrow_array::{BinaryArray, RecordBatch, StringArray};
        use arrow_schema::{DataType, Field, Schema};

        let z = 12u8;
        let key = SpatialKey::from_lonlat(18.07, 59.33, z);
        let (lon, lat) = key.center_lonlat();

        // Write a 5-way ways.parquet at {root}/{z}_{x}_{y}.parquet.
        let dir = std::env::temp_dir().join(format!("facett_tiles_{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join(format!("{}_{}_{}.parquet", key.z, key.x, key.y));
        let schema = A::new(Schema::new(vec![
            Field::new("geometry", DataType::Binary, false),
            Field::new("tags", DataType::Utf8, true),
        ]));
        let mut blobs: Vec<Vec<u8>> = Vec::new();
        for i in 0..5u32 {
            let d = 0.0002 * (i as f64 + 1.0);
            let pts = [(lon, lat), (lon + d, lat + d)];
            let mut b = vec![1u8];
            b.extend_from_slice(&2u32.to_le_bytes()); // LineString
            b.extend_from_slice(&(pts.len() as u32).to_le_bytes());
            for &(x, y) in &pts {
                b.extend_from_slice(&x.to_le_bytes());
                b.extend_from_slice(&y.to_le_bytes());
            }
            blobs.push(b);
        }
        let geom = BinaryArray::from(blobs.iter().map(|b| Some(b.as_slice())).collect::<Vec<_>>());
        let tags = StringArray::from(vec![Some(r#"{"highway":"primary"}"#); 5]);
        let batch = RecordBatch::try_new(schema.clone(), vec![A::new(geom), A::new(tags)]).unwrap();
        {
            let f = std::fs::File::create(&path).unwrap();
            let mut w = parquet::arrow::ArrowWriter::try_new(f, schema, None).unwrap();
            w.write(&batch).unwrap();
            w.close().unwrap();
        }

        let source = DirTileSource::new(&dir, Theme::default());
        // Budget caps the read to 3 of the 5 ways.
        let capped = source.load(key, 3).expect("tile present");
        assert_eq!(capped.len(), 3, "DirTileSource honours the max_ways budget");
        // A tile with no file → None (a gap), no panic.
        let missing = SpatialKey::new(z, key.x + 999, key.y);
        assert!(source.load(missing, 10).is_none(), "absent tile is a graceful None");

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// Smoke: the Sweden extent yields a non-trivial working set at a mid zoom (the
    /// streamer really does page the whole country).
    #[test]
    fn sweden_working_set_is_nontrivial() {
        let tiles = SwedenExtent::tiles(8);
        assert!(tiles.len() > 100, "Sweden spans many z8 tiles, got {}", tiles.len());
    }
}