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