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

nordisk/facett

facett-map/src/index.rs

//! Spatial / LOD index over [`Way`]s, ported from katana-osm's
//! `osm-viewer/src/render/index.rs`. The source uses `static_aabb2d_index` for
//! a packed Hilbert R-tree; facett keeps the **LOD-tier split** (the part the
//! GPU path actually needs — `gpu_ways()` walks all tiers to build the upload-
//! once vertex buffer) and adds a dependency-free **uniform grid** over each
//! tier's way bboxes for the per-frame CPU query.
//!
//! ## The per-frame cost has to track the SCREEN, not the dataset
//!
//! `query_visible` used to be an honest brute-force scan: every frame it walked
//! every way in every enabled tier and bbox-tested it. Its comment said "the GPU
//! path culls on-device" — but the 2D viewer calls `query_visible` on **every**
//! frame regardless of the GPU lane, because the visible set also feeds the
//! filled silhouettes, the road-name labels and the `state_json` counts. All of
//! that is CPU work sitting *next to* the GPU line layer, not replaced by it. So
//! a street-level frame over a country-sized way set paid a full-dataset scan 60
//! times a second, and the demo's 2D tab admits up to `MAX_WAYS_2D = 400000`
//! ways.
//!
//! This is the same bug class the point layer had (`PointGrid`: a world-spanning
//! lattice made a city-block cull scan whole 11000-point cells) and that
//! graphnav's hover had (a whole-dataset scan per mouse-move). The cure is the
//! same shape: a uniform lattice **spanning the data's own bbox**, plus a
//! whole-data fast path for the zoomed-all-the-way-out frame where the lattice
//! buys nothing.
//!
//! ## Why a grid and not an R-tree
//!
//! The index is never rebuilt (ways are immutable after `build`; `recolor` only
//! touches colours), the query is always a rectangle, and the data is a road
//! network — near-uniform in density over its own bbox. A uniform grid is then
//! within noise of an R-tree for query time, needs no dependency, and costs one
//! `Vec<u32>` per cell to build. The one case a grid handles badly is a **huge**
//! bbox (a country-spanning coastline way): those go into an always-scanned
//! `oversized` overflow list, so they cost O(few) instead of being smeared across
//! thousands of cells.

use crate::{
    Way,
    // `Lod` itself is not named by any code here — only by the doc link on
    // `per_lod` below, which now spells the full path. Importing a type purely so a
    // doc link resolves makes `unused_imports` fire on a live file; a path in the
    // link keeps rustdoc resolving it with nothing imported.
    style::LOD_COUNT,
};

/// Ways whose bbox spans more than this many cells in either axis are "oversized"
/// and go to the always-scanned overflow list instead of the lattice. Two cells of
/// slack is enough that a normal road / building footprint never lands in the
/// overflow, while a coastline always does.
const MAX_CELL_SPAN: usize = 2;

/// A dependency-free **uniform grid** over one LOD tier's way bboxes, laid over
/// the TIER's own bounding box — never the whole world, which is exactly the
/// mistake that made the point grid's city-block cull scan the whole dataset.
///
/// Each way is registered in the single cell of its bbox **min corner**; a query
/// therefore widens its cell range by [`MAX_CELL_SPAN`] on the min side to catch
/// ways that start just outside and reach in. That keeps every way in exactly one
/// cell, so a query needs no dedup pass and no visited-stamp array.
/// The lattice is stored **CSR-style** (`starts` + `items`), not as
/// `Vec<Vec<u32>>`: a 256×256 lattice is 65536 cells, and one `Vec` per cell means
/// 65536 heap allocations to build and a pointer chase per cell to query. Two flat
/// arrays cost one allocation each, keep the whole cell block contiguous, and make
/// the build a counting sort.
#[derive(Default)]
struct WayGrid {
    /// `starts[c] .. starts[c + 1]` is cell `c`'s slice of [`Self::items`], with
    /// `c = gy * cols + gx`. Length `cols * rows + 1`.
    starts: Vec<u32>,
    /// Way indices, grouped by cell — the CSR payload.
    items: Vec<u32>,
    /// Ways too large for the lattice (see [`MAX_CELL_SPAN`]) — always scanned.
    oversized: Vec<u32>,
    cols: usize,
    rows: usize,
    /// Lattice origin + inverse span in Mercator units (the tier's data bbox).
    ox: f32,
    oy: f32,
    inv_w: f32,
    inv_h: f32,
    /// The data bbox max corner — drives the whole-data fast path.
    max_x: f32,
    max_y: f32,
}

impl WayGrid {
    /// Build a lattice sized for ~8 ways per cell (clamped to `1..=256` a side),
    /// spanning the tier's own bbox. An empty tier yields an empty 1×1 grid whose
    /// queries return nothing.
    fn build(ways: &[Way]) -> Self {
        let side = ((ways.len() as f64 / 8.0).sqrt().ceil() as usize).clamp(1, 256);
        let (cols, rows) = (side, side);
        // ── THE LATTICE STAYS f32, deliberately. `Way::bbox` is f64 now
        //    (stinky `seam-15`), but this grid is a BUCKETING — ~8 ways a cell, a
        //    hash of position — and a cell boundary landing an f32 ULP off puts a way
        //    in a neighbouring bucket that is still scanned. What must not be f32 is
        //    the CULL COMPARISON, and that is `WaysLayer::draws` and the GPU's
        //    per-way meta, both of which now work in f64.
        //
        //    RESIDUE, stated rather than hidden: `query_into`/`query_visible` take an
        //    `[f32; 4]` viewport (facett-geomap's legacy `osm2d` path passes one), so
        //    THAT cull keeps the old precision. Widening it is a facett-geomap change
        //    and belongs to whoever owns that lane.
        let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
        for w in ways {
            x0 = x0.min(w.bbox[0] as f32);
            y0 = y0.min(w.bbox[1] as f32);
            x1 = x1.max(w.bbox[2] as f32);
            y1 = y1.max(w.bbox[3] as f32);
        }
        if !x0.is_finite() || !x1.is_finite() || x1 <= x0 {
            (x0, x1) = (0.0, 1.0);
        }
        if !y0.is_finite() || !y1.is_finite() || y1 <= y0 {
            (y0, y1) = (0.0, 1.0);
        }
        // A hair of padding so a way sitting exactly on the max corner still lands
        // inside the last cell rather than one past it.
        let (w_span, h_span) = ((x1 - x0) * 1.000_001, (y1 - y0) * 1.000_001);
        let (inv_w, inv_h) =
            (1.0 / w_span.max(f32::MIN_POSITIVE), 1.0 / h_span.max(f32::MIN_POSITIVE));

        let mut grid = Self {
            starts: Vec::new(),
            items: Vec::new(),
            oversized: Vec::new(),
            cols,
            rows,
            ox: x0,
            oy: y0,
            inv_w,
            inv_h,
            max_x: x0 + w_span,
            max_y: y0 + h_span,
        };
        // Counting sort into the CSR arrays: pass 1 assigns each way its cell (or
        // the oversized overflow) and counts, pass 2 prefix-sums, pass 3 scatters.
        let ncells = cols * rows;
        let mut cell_of_way: Vec<u32> = Vec::with_capacity(ways.len());
        let mut counts: Vec<u32> = vec![0; ncells + 1];
        const OVERSIZED: u32 = u32::MAX;
        for w in ways {
            let (gx0, gy0) = grid.cell_of(w.bbox[0] as f32, w.bbox[1] as f32);
            let (gx1, gy1) = grid.cell_of(w.bbox[2] as f32, w.bbox[3] as f32);
            if gx1.saturating_sub(gx0) > MAX_CELL_SPAN || gy1.saturating_sub(gy0) > MAX_CELL_SPAN {
                cell_of_way.push(OVERSIZED);
            } else {
                let c = gy0 * cols + gx0;
                cell_of_way.push(c as u32);
                counts[c] += 1;
            }
        }
        let mut acc = 0u32;
        for c in counts.iter_mut() {
            let n = *c;
            *c = acc;
            acc += n;
        }
        grid.items = vec![0u32; acc as usize];
        let mut cursor = counts.clone();
        for (i, &c) in cell_of_way.iter().enumerate() {
            if c == OVERSIZED {
                grid.oversized.push(i as u32);
            } else {
                let slot = &mut cursor[c as usize];
                grid.items[*slot as usize] = i as u32;
                *slot += 1;
            }
        }
        grid.starts = counts;
        grid
    }

    /// Lattice cell of a Mercator position, clamped to the edge cells so a viewport
    /// wider than the data still resolves to a legal range.
    #[inline]
    fn cell_of(&self, x: f32, y: f32) -> (usize, usize) {
        let fx = ((x - self.ox) * self.inv_w).clamp(0.0, 1.0);
        let fy = ((y - self.oy) * self.inv_h).clamp(0.0, 1.0);
        let gx = ((fx * self.cols as f32) as usize).min(self.cols.saturating_sub(1));
        let gy = ((fy * self.rows as f32) as usize).min(self.rows.saturating_sub(1));
        (gx, gy)
    }

    /// `true` when `vp` contains the whole tier — the zoomed-all-the-way-out frame,
    /// where walking the lattice is pure overhead over one sequential sweep.
    #[inline]
    fn covers_all(&self, vp: [f32; 4]) -> bool {
        vp[0] <= self.ox && vp[1] <= self.oy && vp[2] >= self.max_x && vp[3] >= self.max_y
    }

    /// Push the indices of every way whose bbox intersects `vp` into `out`
    /// (unsorted — the caller sorts to restore insertion order).
    fn query_into(&self, ways: &[Way], vp: [f32; 4], out: &mut Vec<u32>) {
        if self.starts.is_empty() || ways.is_empty() {
            return;
        }
        let [x0, y0, x1, y1] = vp;
        let hit = |w: &Way| {
            let [a, b, c, d] = w.bbox;
            !(c < f64::from(x0) || a > f64::from(x1) || d < f64::from(y0) || b > f64::from(y1))
        };
        for &i in &self.oversized {
            if hit(&ways[i as usize]) {
                out.push(i);
            }
        }
        let (cx0, cy0) = self.cell_of(x0, y0);
        let (cx1, cy1) = self.cell_of(x1, y1);
        // Widen on the MIN side only: a non-oversized way registered at cell
        // (gx, gy) reaches at most MAX_CELL_SPAN cells right/down, so it can only
        // touch the query when its own cell lies within that many cells before it.
        let cx0 = cx0.saturating_sub(MAX_CELL_SPAN);
        let cy0 = cy0.saturating_sub(MAX_CELL_SPAN);
        for gy in cy0..=cy1 {
            let row = gy * self.cols;
            // One contiguous run per ROW of the cell block: cells in a row are
            // adjacent in the CSR layout, so `starts[row + cx0] .. starts[row + cx1
            // + 1]` is a single slice of `items` — one bounds check and one
            // sequential walk instead of a pointer chase per cell.
            let (lo, hi) = (self.starts[row + cx0] as usize, self.starts[row + cx1 + 1] as usize);
            for &i in &self.items[lo..hi] {
                if hit(&ways[i as usize]) {
                    out.push(i);
                }
            }
        }
    }
}

/// All ways, partitioned into the three LOD tiers used by the cull shader, each
/// tier carrying its own uniform-grid spatial index.
pub struct MapIndex {
    /// One way vector per [`crate::style::Lod`] tier, ascending — index IS the tier number, which
    /// is what `query_visible`'s `tier_lod as u32 > lod` test relies on.
    ///
    /// These were three named fields `lod0`/`lod1`/`lod2` and a `[WayGrid; 3]` beside
    /// them, and the pairing was held by a comment. An array over
    /// [`LOD_COUNT`](crate::style::LOD_COUNT) makes a new tier arrive here by
    /// construction instead of by remembering — `kalm-1` added a fourth and the enum
    /// arm was the only place that had to be typed.
    tiers: [Vec<Way>; LOD_COUNT],
    /// One grid per tier, parallel to `tiers` by construction (both are built from
    /// the same iteration).
    grids: [WayGrid; LOD_COUNT],
    /// The dataset's Mercator bbox-min, computed ONCE at build (see [`Self::origin`]).
    origin: Option<[f64; 2]>,
}

impl MapIndex {
    /// Partition ways into LOD tiers (country / region / city) by their style, then
    /// build a per-tier spatial grid and cache the data-local GPU origin.
    pub fn build(ways: Vec<Way>) -> Self {
        let mut tiers: [Vec<Way>; LOD_COUNT] = Default::default();
        // The origin is the Mercator bbox-min over ALL ways — a constant of the
        // dataset, so it is computed here (once) instead of being re-derived on
        // every frame by the GPU line path.
        let mut origin: Option<[f64; 2]> = None;
        for w in ways {
            let (x, y) = (w.bbox[0], w.bbox[1]);
            origin = Some(match origin {
                None => [x, y],
                Some([a, b]) => [a.min(x), b.min(y)],
            });
            tiers[w.style.lod as usize].push(w);
        }
        let grids = std::array::from_fn(|i| WayGrid::build(&tiers[i]));
        Self { tiers, grids, origin }
    }

    /// Reskin every way to a [`MapPalette`](crate::MapPalette) — a runtime swap
    /// from the theme-derived look to a cartographic preset (or back), keyed on
    /// each way's kind + width (no source re-read). LOD tiers are untouched.
    ///
    /// Colours only: geometry — and therefore the spatial index — is unchanged.
    pub fn recolor(&mut self, pal: &crate::MapPalette) {
        for tier in self.tiers.iter_mut() {
            for w in tier.iter_mut() {
                pal.recolor_way(w);
            }
        }
    }

    /// Way counts per tier, ascending (`country, region, city, detail`) — drives the
    /// HUD + tests. An array and not a tuple: a tuple is a tier count written into the
    /// TYPE, and it is the shape that has to be edited at every call site when a tier
    /// is added, which is exactly the edit `kalm-1` wanted to be impossible to forget.
    pub fn lod_sizes(&self) -> [usize; LOD_COUNT] {
        std::array::from_fn(|i| self.tiers[i].len())
    }

    /// Total ways across all tiers.
    pub fn len(&self) -> usize {
        self.tiers.iter().map(Vec::len).sum()
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Iterate all ways across all LOD tiers, yielding `(way, lod_u32)`. Used to
    /// build the persistent GPU geometry buffer once on first frame.
    pub fn gpu_ways(&self) -> impl Iterator<Item = (&Way, u32)> {
        self.tiers
            .iter()
            .enumerate()
            .flat_map(|(t, tier)| tier.iter().map(move |w| (w, t as u32)))
    }

    /// The dataset's **Mercator bbox-min** — the data-local origin the GPU line
    /// path rebases its vertices against, and which the 2D viewer therefore needs
    /// on EVERY frame.
    ///
    /// It is a *constant of the way set*, so it is computed once in [`Self::build`]
    /// and returned here in O(1). It used to be re-derived per frame by walking
    /// every way in the dataset (`facett_map::gpu::ways_origin(index.gpu_ways())`)
    /// — a full-dataset scan 60 times a second for a value that never changes.
    /// `None` only for an empty index.
    pub fn origin(&self) -> Option<[f64; 2]> {
        self.origin
    }

    /// **Viewport cull.** Ways in tiers up to the zoom's LOD whose bbox intersects
    /// the viewport, in the same tier-then-insertion order the brute-force scan
    /// returned.
    ///
    /// Backed by the per-tier uniform grid, so the cost tracks **what is on
    /// screen**, not the dataset size: a street-level frame visits a handful of
    /// cells plus the (tiny) oversized overflow list. The zoomed-all-the-way-out
    /// frame takes a sequential whole-tier fast path instead of hopping cells.
    pub fn query_visible(&self, vp: [f32; 4], lod: u32) -> Vec<&Way> {
        let mut out: Vec<&Way> = Vec::new();
        let mut hits: Vec<u32> = Vec::new();
        for (tier_lod, tier) in self.tiers.iter().enumerate() {
            if tier_lod as u32 > lod {
                continue;
            }
            // ── WHOLE-DATA fast path ─────────────────────────────────────────
            // Zoomed all the way out, the viewport contains the entire tier: every
            // way is visible, so the lattice buys nothing and one sequential sweep
            // (already in insertion order — no sort) beats hopping cells.
            if self.grids[tier_lod].covers_all(vp) {
                out.reserve(tier.len());
                out.extend(tier.iter());
                continue;
            }
            hits.clear();
            self.grids[tier_lod].query_into(tier, vp, &mut hits);
            // Restore insertion order within the tier: the grid visits cells, not
            // ways, so without this the paint order (and every golden that depends
            // on it) would change. Sorting only the VISIBLE set is cheap.
            hits.sort_unstable();
            out.reserve(hits.len());
            out.extend(hits.iter().map(|&i| &tier[i as usize]));
        }
        out
    }

    /// Count of visible ways without materialising the `Vec<&Way>` — for
    /// `state_json` / HUD counters that only need the number.
    pub fn visible_count(&self, vp: [f32; 4], lod: u32) -> usize {
        let mut hits: Vec<u32> = Vec::new();
        let mut n = 0usize;
        for (tier_lod, tier) in self.tiers.iter().enumerate() {
            if tier_lod as u32 > lod {
                continue;
            }
            if self.grids[tier_lod].covers_all(vp) {
                n += tier.len();
                continue;
            }
            hits.clear();
            self.grids[tier_lod].query_into(tier, vp, &mut hits);
            n += hits.len();
        }
        n
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::mercator;

    /// A short road way from `(lon, lat)` spanning `d` degrees.
    /// **The tier the `road` fixture below actually classifies at**, derived rather than
    /// written as a literal. These tests all queried tier `2` because that is where
    /// `highway=residential` sat; when `Lod::District` was inserted at index 2 the
    /// residential tier became 3 and five of them went red at once — reading an empty
    /// tier and asserting things about it. A literal here is a number that means "City"
    /// until the day it does not.
    const ROAD_TIER: u32 = crate::style::Lod::City as u32;

    fn road(id: i64, lon: f64, lat: f64, d: f64) -> Way {
        Way::from_lonlat(
            id,
            &[(lon, lat), (lon + d, lat + d)],
            &[("highway".to_owned(), "residential".to_owned())],
            &facett_core::Theme::default(),
        )
        .expect("a two-vertex road is a valid way")
    }

    /// The brute-force reference the grid must reproduce EXACTLY — this is the
    /// implementation `query_visible` had before the spatial index landed.
    fn brute(idx: &MapIndex, vp: [f32; 4], lod: u32) -> Vec<i64> {
        let [x0, y0, x1, y1] = vp;
        idx.gpu_ways()
            .filter(|(w, l)| {
                let [a, b, c, d] = w.bbox;
                *l <= lod && !(c < f64::from(x0) || a > f64::from(x1) || d < f64::from(y0) || b > f64::from(y1))
            })
            .map(|(w, _)| w.osm_id)
            .collect()
    }

    fn ids(ws: &[&Way]) -> Vec<i64> {
        ws.iter().map(|w| w.osm_id).collect()
    }

    /// **RED-when-broken:** the grid must return byte-identically what the
    /// brute-force scan returned — same ways, same order — across a viewport sweep
    /// from one city block out to the whole world. A spatial index that drops a way
    /// is a hole in the map.
    #[test]
    fn grid_matches_brute_force_over_a_viewport_sweep() {
        let mut ways: Vec<Way> = Vec::new();
        for i in 0..2000i64 {
            let lon = 11.0 + (i % 50) as f64 * 0.26;
            let lat = 55.0 + (i / 50) as f64 * 0.35;
            ways.push(road(i, lon, lat, 0.004));
        }
        // A country-spanning way (the oversized-overflow case).
        ways.push(road(9999, 11.0, 55.0, 13.0));
        let idx = MapIndex::build(ways);

        let (wx0, wy0) = mercator(11.0, 69.0);
        let (wx1, wy1) = mercator(24.0, 55.0);
        for (i, scale) in [1.0f64, 0.5, 0.1, 0.02, 0.004, 0.0005].into_iter().enumerate() {
            let cx = (wx0 + wx1) / 2.0;
            let cy = (wy0 + wy1) / 2.0;
            let hw = (wx1 - wx0).abs() / 2.0 * scale;
            let hh = (wy1 - wy0).abs() / 2.0 * scale;
            // Offset the window so it is not always centred on the same features.
            let ox = (i as f64) * hw * 0.3;
            let vp =
                [(cx - hw + ox) as f32, (cy - hh) as f32, (cx + hw + ox) as f32, (cy + hh) as f32];
            // Every tier, derived — `0..=2` hard-coded left the two deepest tiers
            // unexercised, and a new tier would have joined them silently.
            for lod in 0..crate::style::LOD_COUNT as u32 {
                assert_eq!(
                    ids(&idx.query_visible(vp, lod)),
                    brute(&idx, vp, lod),
                    "grid must match brute force at scale {scale} lod {lod}"
                );
                assert_eq!(idx.visible_count(vp, lod), brute(&idx, vp, lod).len());
            }
        }
    }

    /// **RED-when-broken — THE locality claim.** A city-block viewport must touch
    /// only a small slice of the dataset. Asserted structurally (timings are the
    /// bench's job): the number of ways the grid *examines* must be a small
    /// fraction of N. Before the index it was N exactly, on every frame.
    #[test]
    fn city_block_query_is_local_not_whole_dataset() {
        let mut ways: Vec<Way> = Vec::new();
        for i in 0..10_000i64 {
            let lon = 11.0 + (i % 100) as f64 * 0.13;
            let lat = 55.0 + (i / 100) as f64 * 0.14;
            ways.push(road(i, lon, lat, 0.002));
        }
        let idx = MapIndex::build(ways);
        let (mx, my) = mercator(12.0, 56.0);
        let d = 0.00005f32; // ~a city block in Mercator units
        let vp = [mx as f32 - d, my as f32 - d, mx as f32 + d, my as f32 + d];
        let visible = idx.query_visible(vp, ROAD_TIER).len();
        assert!(visible <= 8, "a city block must see a handful of ways, saw {visible}");

        // The grid must have shrunk the CANDIDATE set too, not just the answer: the
        // tier's lattice is sized for ~8 ways/cell, so the widened cell block plus
        // the overflow list is O(100), never O(10000).
        let tier = &idx.tiers[ROAD_TIER as usize];
        let grid = &idx.grids[ROAD_TIER as usize];
        assert!(!tier.is_empty(), "the city tier must hold the residential roads");
        assert_eq!(
            grid.items.len() + grid.oversized.len(),
            tier.len(),
            "the CSR lattice must account for every way in the tier exactly once"
        );
        assert!(
            grid.oversized.len() * 20 < tier.len(),
            "short roads must not land in the oversized overflow ({} of {})",
            grid.oversized.len(),
            tier.len()
        );
        let mut hits = Vec::new();
        grid.query_into(tier, vp, &mut hits);
        assert!(
            hits.len() * 10 < tier.len(),
            "the grid must examine a small slice, examined {} of {}",
            hits.len(),
            tier.len()
        );
    }

    /// The zoomed-all-the-way-out frame returns the whole tier through the fast
    /// path, still in insertion order.
    #[test]
    fn whole_world_viewport_returns_everything_in_order() {
        let ways: Vec<Way> = (0..500i64)
            .map(|i| road(i, 11.0 + (i % 25) as f64 * 0.4, 55.0 + (i / 25) as f64 * 0.5, 0.01))
            .collect();
        let idx = MapIndex::build(ways);
        let got = idx.query_visible([0.0, 0.0, 1.0, 1.0], ROAD_TIER);
        assert_eq!(got.len(), 500);
        assert_eq!(ids(&got), (0..500i64).collect::<Vec<_>>());
    }

    /// The cached origin equals the brute-force bbox-min it replaced; an empty
    /// index has none.
    #[test]
    fn origin_is_the_cached_bbox_min() {
        let ways: Vec<Way> = (0..64i64)
            .map(|i| road(i, 11.0 + i as f64 * 0.2, 55.0 + i as f64 * 0.2, 0.01))
            .collect();
        let idx = MapIndex::build(ways);
        let brute_origin = idx.gpu_ways().fold(None::<[f64; 2]>, |acc, (w, _)| {
            let (x, y) = (w.bbox[0], w.bbox[1]);
            Some(match acc {
                None => [x, y],
                Some([a, b]) => [a.min(x), b.min(y)],
            })
        });
        assert_eq!(idx.origin(), brute_origin);
        assert!(MapIndex::build(Vec::new()).origin().is_none());
    }

    /// Degenerate inputs stay finite and lossless: an empty index, and a set where
    /// every way sits on the same point (zero span).
    #[test]
    fn degenerate_inputs_are_total() {
        let empty = MapIndex::build(Vec::new());
        assert!(empty.query_visible([0.0, 0.0, 1.0, 1.0], ROAD_TIER).is_empty());
        assert_eq!(empty.visible_count([0.0, 0.0, 1.0, 1.0], 2), 0);

        let same: Vec<Way> = (0..100i64).map(|i| road(i, 12.0, 56.0, 0.0001)).collect();
        let idx = MapIndex::build(same);
        let (mx, my) = mercator(12.0, 56.0);
        let vp = [mx as f32 - 0.001, my as f32 - 0.001, mx as f32 + 0.001, my as f32 + 0.001];
        assert_eq!(idx.query_visible(vp, ROAD_TIER).len(), 100);
        assert_eq!(ids(&idx.query_visible(vp, ROAD_TIER)), (0..100i64).collect::<Vec<_>>());
    }

    /// A way whose bbox straddles many cells (a coastline) must still be found from
    /// a viewport anywhere along it — the oversized overflow list is the safety net.
    #[test]
    fn oversized_ways_are_found_everywhere_along_them() {
        let mut ways = vec![road(1, 11.0, 55.0, 13.0)];
        for i in 0..500i64 {
            ways.push(road(
                100 + i,
                11.0 + (i % 25) as f64 * 0.5,
                55.0 + (i / 25) as f64 * 0.6,
                0.003,
            ));
        }
        let idx = MapIndex::build(ways);
        for t in [0.1f64, 0.35, 0.6, 0.9] {
            let (mx, my) = mercator(11.0 + 13.0 * t, 55.0 + 13.0 * t);
            let vp =
                [mx as f32 - 0.0002, my as f32 - 0.0002, mx as f32 + 0.0002, my as f32 + 0.0002];
            assert!(
                idx.query_visible(vp, ROAD_TIER).iter().any(|w| w.osm_id == 1),
                "the country-spanning way must be visible at t={t}"
            );
        }
    }
}