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