From e17e35bfc5606ed2f4468cf5f13fd15cbdfb9856 Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Sun, 1 Nov 2026 10:35:05 -0500 Subject: [PATCH 1/4] Memory-map snapshots (MAPSNAP2) with a stored packed Hilbert R-tree Snapshots are now mapped from disk and used in place: arrays are 64-byte aligned and borrowed from the file, features and roof details are fixed repr(C) records whose enum bytes are read through checked accessors, and the spatial index is a packed Hilbert R-tree per zoom bucket stored in the file. Kind counts are stored too, so /v1/kinds no longer scans every feature. MAPSNAP1 is still read (onto the heap, index rebuilt), and `maps convert` turns one into the other without a rebuild. /metrics reports the geometry tile cache size. --- Cargo.lock | 27 +- Cargo.toml | 2 + compose.yaml | 3 +- examples/near.rs | 4 +- examples/towers.rs | 2 +- readme.md | 15 +- src/api.rs | 22 +- src/classify.rs | 18 ++ src/index.rs | 308 ++++++++++++++++++++ src/lib.rs | 2 + src/main.rs | 11 +- src/map.rs | 272 +++++++++++++----- src/render/areas.rs | 4 +- src/render/lines.rs | 8 +- src/render/mod.rs | 4 +- src/render/models.rs | 10 +- src/render/scene.rs | 4 +- src/render/solids.rs | 16 +- src/server.rs | 7 +- src/snapshot.rs | 656 ++++++++++++++++++++++++++---------------- src/snapshot/tests.rs | 363 +++++++++++++++++++++++ src/store.rs | 47 +++ src/vtile.rs | 48 ++-- 23 files changed, 1468 insertions(+), 385 deletions(-) create mode 100644 src/index.rs create mode 100644 src/snapshot/tests.rs create mode 100644 src/store.rs diff --git a/Cargo.lock b/Cargo.lock index 07ab4ad..1754299 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,20 @@ name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] [[package]] name = "byteorder" @@ -692,9 +706,11 @@ version = "0.2.0" dependencies = [ "anyhow", "axum", + "bytemuck", "clap", "earcutr", "flate2", + "memmap2 0.9.12", "mimalloc", "moka", "osmpbf", @@ -738,6 +754,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + [[package]] name = "mimalloc" version = "0.1.52" @@ -845,7 +870,7 @@ checksum = "5d689d6e9f254bbd63893ce00a27147e41fe94bf3abca70f85b5345afb3cb728" dependencies = [ "byteorder", "flate2", - "memmap2", + "memmap2 0.5.10", "protobuf", "protobuf-codegen", "rayon", diff --git a/Cargo.toml b/Cargo.toml index ef4c47b..ccb5235 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,11 @@ license = "MIT" [dependencies] anyhow = "1" axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "query"] } +bytemuck = { version = "1.25", features = ["derive", "extern_crate_alloc"] } clap = { version = "4.6", features = ["derive"] } earcutr = "0.5" flate2 = "1" +memmap2 = "0.9.12" mimalloc = { version = "0.1", default-features = false } moka = { version = "0.12", features = ["future"] } osmpbf = "0.3.8" diff --git a/compose.yaml b/compose.yaml index 02c5d0c..20a53f5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,5 +1,6 @@ # MAP=./nyc.osm.pbf docker compose up --build -# The map lives in memory: NYC peaks near 3 GB loading, then about 1.7 GB plus cache. +# Built from a .osm.pbf the map lives in memory (NYC peaks near 3 GB, then about +# 1.7 GB plus cache). A snapshot (`maps build`) is mapped from disk instead. services: maps: build: . diff --git a/examples/near.rs b/examples/near.rs index a6ebfa4..5156bef 100644 --- a/examples/near.rs +++ b/examples/near.rs @@ -8,7 +8,7 @@ fn main() { let p = project(lon, lat); let mpu = meters_per_unit(p[1]); for f in &map.features { - if !(f.kind.is_extrusion() || f.kind.is_point()) { + if !(f.kind().is_extrusion() || f.kind().is_point()) { continue; } let cx = (f.bbox[0] + f.bbox[2]) as f64 / 2.0 + map.origin[0]; @@ -23,6 +23,6 @@ fn main() { (f.bbox[3] - f.bbox[1]) as f64 * mpu, ); let (clon, clat) = unproject([cx, cy]); - println!("{:?} flags={} base={} top={} roof={:?} rh={} vis={} {w:.0}x{h:.0}m @{clon:.5},{clat:.5}", f.kind, f.flags, f.base, f.height, det.roof, det.roof_height, f.vis_zoom); + println!("{:?} flags={} base={} top={} roof={:?} rh={} vis={} {w:.0}x{h:.0}m @{clon:.5},{clat:.5}", f.kind(), f.flags, f.base, f.height, det.roof, det.roof_height, f.vis_zoom); } } diff --git a/examples/towers.rs b/examples/towers.rs index 1fc9635..2317c2e 100644 --- a/examples/towers.rs +++ b/examples/towers.rs @@ -4,7 +4,7 @@ fn main() { let map = maps::map::Map::build(maps::ingest::read(std::path::Path::new(&path)).unwrap()); let mut seen = std::collections::BTreeMap::new(); for f in &map.features { - if f.kind != maps::classify::Kind::Tower { + if f.kind() != maps::classify::Kind::Tower { continue; } let p = map.ring(f.ring_start)[0]; diff --git a/readme.md b/readme.md index fdeafc6..d801bad 100644 --- a/readme.md +++ b/readme.md @@ -18,8 +18,8 @@ pyramid offline. ## Features **WebGL client, like Google Maps** -- `maps serve` keeps the map in memory and builds geometry tiles on - request: styled, triangulated ground, road ribbons, buildings and model +- `maps serve` maps the map from a snapshot (or builds it from an + extract) and builds geometry tiles on request: styled, triangulated ground, road ribbons, buildings and model instances (`src/vtile.rs`). - The browser draws every frame itself with WebGL2 and a perspective camera, so pan, zoom, rotate and tilt are smooth at 60 fps. @@ -97,9 +97,12 @@ cargo build --release # Feature counts and bounds ./target/release/maps info nyc.osm.pbf -# Build once into a snapshot; every command loads it in seconds +# Build once into a snapshot; every command maps it from disk at once ./target/release/maps build us-northeast.osm.pbf -o northeast.snap ./target/release/maps serve northeast.snap + +# Convert an older MAPSNAP1 snapshot without rebuilding +./target/release/maps convert old.snap northeast.snap ``` In the viewer, drag to pan and scroll to zoom. Right-drag or Ctrl+drag @@ -158,7 +161,7 @@ Endpoints: | `/models/{version}/{theme}.bin` | Model templates for instances | | `/tiles/{version}/{style}/{bearing}/{pitch}/{z}/{x}/{y}[@2x].png` | Tiles. Immutable and cacheable forever, because `version` changes with the data. | | `/healthz` | Liveness probe (`ok`); `maps check` probes it from inside the image | -| `/metrics` | Prometheus counters: requests, cache hits, renders, render time, geometry tiles and bytes sent, cache size | +| `/metrics` | Prometheus counters: requests, cache hits, renders, render time, geometry tiles and bytes sent, and the size of the raster and geometry tile caches | The server shuts down gracefully on SIGTERM. Pushes to `main` publish `ghcr.io/kensac/maps` (`latest` and `sha-`). @@ -226,8 +229,8 @@ It classifies tags into physical kinds with real or typical dimensions - Turns coastlines into land. - Drops building outlines that are drawn through their parts, and lifts rooftop objects onto their buildings. -- Sorts features into draw order and indexes them in one R-tree per zoom - bucket. +- Sorts features into draw order and indexes them in one packed Hilbert + R-tree per zoom bucket (`src/index.rs`), stored in the snapshot. **Rendering** (`src/render/`) uses an orthographic camera with bearing and pitch. The ground is foreshortened by cos(pitch) and heights rise by diff --git a/src/api.rs b/src/api.rs index 3847fb7..7390df5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -198,10 +198,10 @@ impl Api { fn geometry(&self, f: &Feature) -> Value { let rings: Vec<&[[f32; 2]]> = self.map.rings(f).collect(); - if f.kind.is_point() { + if f.kind().is_point() { return json!({ "type": "Point", "coordinates": self.lon_lat(rings[0][0]) }); } - if !f.kind.is_area() { + if !f.kind().is_area() { let elev = self.map.elevations(f); let mut offset = 0; let lines: Vec = rings @@ -255,8 +255,8 @@ impl Api { fn properties(&self, f: &Feature) -> Value { let mut p = json!({ - "kind": kind_name(f.kind), - "geometry_type": geometry_type(f.kind), + "kind": kind_name(f.kind()), + "geometry_type": geometry_type(f.kind()), }); let o = p.as_object_mut().unwrap(); if f.layer != 0 { @@ -272,13 +272,13 @@ impl Api { o.insert(name.into(), json!(true)); } } - if f.kind.is_extrusion() || f.kind.is_point() { + if f.kind().is_extrusion() || f.kind().is_point() { o.insert("height_m".into(), json!(round(f.height as f64, 2))); if f.base > 0.0 { o.insert("min_height_m".into(), json!(round(f.base as f64, 2))); } } - if f.kind.is_extrusion() { + if f.kind().is_extrusion() { let d = self.map.detail(f); o.insert("roof_shape".into(), json!(roof_name(d.roof))); if d.levels > 0.0 { @@ -416,7 +416,7 @@ async fn features(State(api): State, Query(q): Query) -> .query(bbox_area(&api, bbox)) .into_iter() .filter(|&i| { - let k = map.features[i as usize].kind; + let k = map.features[i as usize].kind(); k != Kind::Land && kinds.as_ref().is_none_or(|ks| ks.contains(&k)) }) .collect(); @@ -441,7 +441,7 @@ async fn features(State(api): State, Query(q): Query) -> async fn feature(State(api): State, Path(id): Path) -> Response { match api.map.features.get(id as usize) { - Some(f) if f.kind != Kind::Land => ok(api.feature_json(id), 300), + Some(f) if f.kind() != Kind::Land => ok(api.feature_json(id), 300), _ => error( StatusCode::NOT_FOUND, "NOT_FOUND", @@ -471,10 +471,10 @@ async fn lookup(State(api): State, Query(q): Query) -> Resp let mut hits: Vec<(f32, u32)> = Vec::new(); for i in api.query([p[0] - r, p[1] - r, p[0] + r, p[1] + r]) { let f = &map.features[i as usize]; - if f.kind == Kind::Land { + if f.kind() == Kind::Land { continue; } - let hit = if f.kind.is_area() { + let hit = if f.kind().is_area() { let pt = [p[0] as f64, p[1] as f64]; let inside = map .rings(f) @@ -538,7 +538,7 @@ async fn tallest(State(api): State, Query(q): Query) -> Re let mut ids: Vec = api .query(bbox_area(&api, bbox)) .into_iter() - .filter(|&i| map.features[i as usize].kind.is_extrusion()) + .filter(|&i| map.features[i as usize].kind().is_extrusion()) .collect(); ids.sort_by(|&a, &b| { let (fa, fb) = (&map.features[a as usize], &map.features[b as usize]); diff --git a/src/classify.rs b/src/classify.rs index 24fdef5..f36d4ec 100644 --- a/src/classify.rs +++ b/src/classify.rs @@ -375,6 +375,15 @@ pub enum RoofShape { Dome, } +impl RoofShape { + pub fn from_u8(v: u8) -> Option { + use RoofShape::*; + [Flat, Gabled, Hipped, Pyramidal, Skillion, Dome] + .get(v as usize) + .copied() + } +} + /// Architectural detail of an extruded solid. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Detail { @@ -627,6 +636,15 @@ impl Group { } impl Kind { + /// Number of kinds; discriminants run `0..COUNT`. + pub const COUNT: usize = Kind::NavLight as usize + 1; + + /// The kind with discriminant `v`, if there is one. + pub fn from_u8(v: u8) -> Option { + // SAFETY: Kind is repr(u8) with contiguous discriminants 0..COUNT. + ((v as usize) < Kind::COUNT).then(|| unsafe { std::mem::transmute::(v) }) + } + /// Polygon geometry: ground areas and extruded solids. pub fn is_area(self) -> bool { self <= Kind::Tomb diff --git a/src/index.rs b/src/index.rs new file mode 100644 index 0000000..372f73d --- /dev/null +++ b/src/index.rs @@ -0,0 +1,308 @@ +//! A packed Hilbert R-tree (as in Flatbush): items sorted along a Hilbert +//! curve and grouped [`NODE_SIZE`] to a node, level by level up to one root. +//! The whole tree is two flat arrays, so it is stored in a snapshot and +//! queried straight from the mapped file. +//! +//! Nodes `0..len` are the items in curve order; each level follows the one +//! below it, and the root is last. `ids[i]` is an item's id at the leaves +//! and the first child of node `i` above them. + +use crate::store::Store; +use rayon::prelude::*; + +pub const NODE_SIZE: usize = 16; + +pub struct Packed { + /// Number of items. + len: usize, + /// End (exclusive) of each level's nodes, from the leaves up. + levels: Vec, + pub boxes: Store<[f32; 4]>, + pub ids: Store, +} + +/// Node counts per level for `len` items, cumulative from the leaves up. +pub fn levels(len: usize) -> Vec { + let mut levels = vec![len as u32]; + let (mut n, mut total) = (len, len); + while n > 1 { + n = n.div_ceil(NODE_SIZE); + total += n; + levels.push(total as u32); + } + levels +} + +fn intersects(b: &[f32; 4], q: &[f32; 4]) -> bool { + b[0] <= q[2] && b[2] >= q[0] && b[1] <= q[3] && b[3] >= q[1] +} + +impl Packed { + /// Builds a tree over `items`, `(box, id)` pairs. + pub fn build(items: Vec<([f32; 4], u32)>) -> Packed { + let len = items.len(); + let levels = levels(len); + if len == 0 { + return Packed { + len, + levels, + boxes: Store::Owned(Vec::new()), + ids: Store::Owned(Vec::new()), + }; + } + let mut ext = [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ]; + for (b, _) in &items { + ext = union(&ext, b); + } + let (w, h) = (ext[2] - ext[0], ext[3] - ext[1]); + let scale = |v: f32, lo: f32, span: f32| { + if span > 0.0 { + ((v - lo) / span * 65535.0) as u32 + } else { + 0 + } + }; + let mut keyed: Vec<(u32, u32, [f32; 4])> = items + .into_par_iter() + .map(|(b, id)| { + let x = scale((b[0] + b[2]) * 0.5, ext[0], w); + let y = scale((b[1] + b[3]) * 0.5, ext[1], h); + (hilbert(x, y), id, b) + }) + .collect(); + // Ids are unique, so the order is fully determined. + keyed.par_sort_unstable_by_key(|&(h, id, _)| (h, id)); + + let total = *levels.last().unwrap() as usize; + let mut boxes = Vec::with_capacity(total); + let mut ids = Vec::with_capacity(total); + for (_, id, b) in keyed { + boxes.push(b); + ids.push(id); + } + let mut start = 0; + for &end in &levels[..levels.len() - 1] { + let end = end as usize; + for first in (start..end).step_by(NODE_SIZE) { + let last = (first + NODE_SIZE).min(end); + let b = boxes[first..last].iter().fold( + [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ], + |a, b| union(&a, b), + ); + boxes.push(b); + ids.push(first as u32); + } + start = end; + } + debug_assert_eq!(boxes.len(), total); + Packed { + len, + levels, + boxes: Store::Owned(boxes), + ids: Store::Owned(ids), + } + } + + /// A tree from stored arrays, checked enough that queries cannot index + /// out of bounds. + pub fn from_parts( + len: usize, + boxes: Store<[f32; 4]>, + ids: Store, + ) -> anyhow::Result { + let levels = levels(len); + let total = *levels.last().unwrap() as usize; + anyhow::ensure!( + boxes.len() == total && ids.len() == total, + "index has {} nodes, expected {total}", + boxes.len() + ); + Ok(Packed { + len, + levels, + boxes, + ids, + }) + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Calls `found` with the id of every item whose box intersects `q` + /// (bounds inclusive), in no particular order. + pub fn search(&self, q: &[f32; 4], stack: &mut Vec, mut found: impl FnMut(u32)) { + if self.len == 0 { + return; + } + stack.clear(); + let mut node = self.boxes.len() - 1; + loop { + let level_end = self.levels[self.levels.partition_point(|&e| e as usize <= node)]; + let end = (node + NODE_SIZE).min(level_end as usize); + for pos in node..end { + if !intersects(&self.boxes[pos], q) { + continue; + } + let id = self.ids[pos]; + if pos < self.len { + found(id); + } else { + stack.push(id as usize); + } + } + match stack.pop() { + Some(n) => node = n, + None => break, + } + } + } +} + +fn union(a: &[f32; 4], b: &[f32; 4]) -> [f32; 4] { + [ + a[0].min(b[0]), + a[1].min(b[1]), + a[2].max(b[2]), + a[3].max(b[3]), + ] +} + +/// Position of `(x, y)` (16 bits each) along a Hilbert curve; from +/// , as Flatbush uses. +fn hilbert(x: u32, y: u32) -> u32 { + let mut a = x ^ y; + let mut b = 0xffff ^ a; + let mut c = 0xffff ^ (x | y); + let mut d = x & (y ^ 0xffff); + + let mut aa = a | (b >> 1); + let mut bb = (a >> 1) ^ a; + let mut cc = ((c >> 1) ^ (b & (d >> 1))) ^ c; + let mut dd = ((a & (c >> 1)) ^ (d >> 1)) ^ d; + + a = aa; + b = bb; + c = cc; + d = dd; + aa = (a & (a >> 2)) ^ (b & (b >> 2)); + bb = (a & (b >> 2)) ^ (b & ((a ^ b) >> 2)); + cc ^= (a & (c >> 2)) ^ (b & (d >> 2)); + dd ^= (b & (c >> 2)) ^ ((a ^ b) & (d >> 2)); + + a = aa; + b = bb; + c = cc; + d = dd; + aa = (a & (a >> 4)) ^ (b & (b >> 4)); + bb = (a & (b >> 4)) ^ (b & ((a ^ b) >> 4)); + cc ^= (a & (c >> 4)) ^ (b & (d >> 4)); + dd ^= (b & (c >> 4)) ^ ((a ^ b) & (d >> 4)); + + a = aa; + b = bb; + c = cc; + d = dd; + cc ^= (a & (c >> 8)) ^ (b & (d >> 8)); + dd ^= (b & (c >> 8)) ^ ((a ^ b) & (d >> 8)); + + a = cc ^ (cc >> 1); + b = dd ^ (dd >> 1); + + let mut i0 = x ^ y; + let mut i1 = b | (0xffff ^ (i0 | a)); + + i0 = (i0 | (i0 << 8)) & 0x00ff00ff; + i0 = (i0 | (i0 << 4)) & 0x0f0f0f0f; + i0 = (i0 | (i0 << 2)) & 0x33333333; + i0 = (i0 | (i0 << 1)) & 0x55555555; + + i1 = (i1 | (i1 << 8)) & 0x00ff00ff; + i1 = (i1 | (i1 << 4)) & 0x0f0f0f0f; + i1 = (i1 | (i1 << 2)) & 0x33333333; + i1 = (i1 | (i1 << 1)) & 0x55555555; + + (i1 << 1) | i0 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A small deterministic generator, so the tests need no crate. + pub(crate) struct Rng(pub u64); + impl Rng { + pub fn next(&mut self) -> f32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 40) as f32 / (1u64 << 24) as f32 + } + } + + fn random_box(r: &mut Rng, size: f32) -> [f32; 4] { + let (x, y) = (r.next(), r.next()); + let (w, h) = (r.next() * size, r.next() * size); + [x, y, x + w, y + h] + } + + #[test] + fn search_matches_brute_force() { + let mut r = Rng(0x9e3779b97f4a7c15); + for n in [0, 1, 15, 16, 17, 255, 256, 257, 5000] { + let items: Vec<_> = (0..n) + .map(|i| { + // Some points (zero-size boxes), as point features are. + let size = if i % 3 == 0 { 0.0 } else { 0.05 }; + (random_box(&mut r, size), i as u32) + }) + .collect(); + let tree = Packed::build(items.clone()); + let mut stack = Vec::new(); + for _ in 0..200 { + let q = random_box(&mut r, 0.2); + let mut got = Vec::new(); + tree.search(&q, &mut stack, |id| got.push(id)); + got.sort_unstable(); + let want: Vec = items + .iter() + .filter(|(b, _)| intersects(b, &q)) + .map(|&(_, id)| id) + .collect(); + assert_eq!(got, want, "n={n}"); + } + } + } + + #[test] + fn level_counts() { + assert_eq!(levels(0), vec![0]); + assert_eq!(levels(1), vec![1]); + assert_eq!(levels(16), vec![16, 17]); + assert_eq!(levels(17), vec![17, 19, 20]); + } + + #[test] + fn hilbert_visits_each_cell_once() { + let mut seen = std::collections::HashSet::new(); + for x in 0..64 { + for y in 0..64 { + assert!(seen.insert(hilbert(x, y))); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index c7bf357..d8a543a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod assemble; pub mod classify; pub mod elevation; pub mod geo; +pub mod index; pub mod ingest; pub mod map; pub mod output; @@ -17,5 +18,6 @@ pub mod place; pub mod render; pub mod server; pub mod snapshot; +pub mod store; pub mod style; pub mod vtile; diff --git a/src/main.rs b/src/main.rs index 48954a7..5742dd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -70,7 +70,7 @@ enum Command { cache_mb: u64, }, /// Build the map once and save it as a snapshot that every command - /// loads in seconds, with less memory than building from the extract. + /// maps from disk in seconds, instead of building from the extract. Build { /// Input .osm.pbf file. input: PathBuf, @@ -78,6 +78,14 @@ enum Command { #[arg(short, long, default_value = "map.snap")] output: PathBuf, }, + /// Convert a MAPSNAP1 snapshot to the memory-mapped MAPSNAP2 format, + /// without rebuilding from the extract. + Convert { + /// Input v1 snapshot. + input: PathBuf, + /// Output v2 snapshot (written as .partial, then renamed). + output: PathBuf, + }, /// Exit 0 if a local server answers /healthz (for container healthchecks). Check { #[arg(long, default_value = "127.0.0.1:8080")] @@ -212,6 +220,7 @@ fn main() -> Result<()> { ) } Command::Check { addr } => check(addr), + Command::Convert { input, output } => maps::snapshot::convert(&input, &output), Command::Build { input, output } => maps::snapshot::save(&load(&input)?, &output), Command::Info { input } => { let map = load(&input)?; diff --git a/src/map.rs b/src/map.rs index 243f5a9..31ced17 100644 --- a/src/map.rs +++ b/src/map.rs @@ -4,37 +4,37 @@ use crate::assemble::{ assemble_coastlines, assemble_rings, land_polygons, orient_rings, Background, }; -use crate::classify::{flags, Detail, Group, Kind}; +use crate::classify::{flags, Detail, Group, Kind, RoofShape}; use crate::geo::{signed_area2, Point, Rect, TILE_SIZE}; +use crate::index::Packed; use crate::ingest::RawData; +use crate::store::Store; +use bytemuck::{Pod, Zeroable}; use rayon::prelude::*; use rstar::primitives::{GeomWithData, Rectangle}; -use rstar::{RTree, AABB}; +use rstar::RTree; use std::time::Instant; /// Highest zoom bucket in the index; anything visible later shares it. -const MAX_BUCKET: usize = 20; - -#[derive(Clone, Copy, Debug)] +pub const MAX_BUCKET: usize = 20; + +/// A feature as stored, in memory and in snapshots alike: plain numbers in a +/// fixed layout, so a mapped snapshot is used in place. The kind is a raw +/// byte read through [`Feature::kind`], since a mapped file could hold any +/// value and an invalid enum is undefined behaviour. +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable)] pub struct Feature { - pub kind: Kind, + kind: u8, pub flags: u8, pub layer: i8, + /// Model variant of a point object (e.g. a tower's type). + pub variant: u8, /// Index of the first ring in [`Map::ring_starts`]. pub ring_start: u32, pub ring_count: u32, - /// Top above the ground in meters (admin level for boundaries). - pub height: f32, - /// Bottom above the ground in meters: `min_height`, or the roof an - /// object stands on. - pub base: f32, /// Index into [`Map::details`] for solids with roof/facade detail. pub detail: u32, - /// Model variant of a point object (e.g. a tower's type). - pub variant: u8, - /// Compass heading (radians) a point object's front faces; `NaN` when - /// it has no particular orientation. - pub heading: f32, /// For roads and rails that leave the ground: offset into /// [`Map::elevations`] of their per-vertex elevations; `u32::MAX` if they /// stay on the ground. @@ -42,6 +42,14 @@ pub struct Feature { /// For a `building:part`: index into [`Map::groups`], the footprint of /// the building it belongs to; `u32::MAX` otherwise. pub group: u32, + /// Top above the ground in meters (admin level for boundaries). + pub height: f32, + /// Bottom above the ground in meters: `min_height`, or the roof an + /// object stands on. + pub base: f32, + /// Compass heading (radians) a point object's front faces; `NaN` when + /// it has no particular orientation. + pub heading: f32, /// Zoom from which the feature is drawn: its kind's minimum zoom, or the /// zoom at which it grows past a pixel, whichever is later. pub vis_zoom: f32, @@ -49,13 +57,78 @@ pub struct Feature { pub bbox: [f32; 4], } +const _: () = assert!(std::mem::size_of::() == 56); + impl Feature { + /// What the feature is. Snapshot writers check every kind byte, so the + /// fallback only keeps a damaged file from being undefined behaviour. + pub fn kind(&self) -> Kind { + Kind::from_u8(self.kind).unwrap_or(Kind::Land) + } + + pub(crate) fn raw_kind(&self) -> u8 { + self.kind + } + + #[cfg(test)] + pub(crate) fn set_kind(&mut self, kind: Kind) { + self.kind = kind as u8; + } + pub fn group(&self) -> Group { - self.kind.group(self.layer, self.flags) + self.kind().group(self.layer, self.flags) } } -type Entry = GeomWithData, u32>; +/// [`Detail`] as stored: `roof` and `across` are raw bytes, decoded (and +/// checked) by [`DetailRec::get`]. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, Pod, Zeroable)] +pub struct DetailRec { + roof: u8, + across: u8, + /// Bit 0: `roof_colour` is set; bit 1: `facade_colour` is. + present: u8, + _pad: u8, + roof_height: f32, + levels: f32, + roof_colour: u32, + facade_colour: u32, + _pad2: u32, +} + +const _: () = assert!(std::mem::size_of::() == 24); + +impl DetailRec { + pub fn new(d: &Detail) -> Self { + DetailRec { + roof: d.roof as u8, + across: d.across as u8, + present: d.roof_colour.is_some() as u8 | (d.facade_colour.is_some() as u8) << 1, + _pad: 0, + roof_height: d.roof_height, + levels: d.levels, + roof_colour: d.roof_colour.unwrap_or(0), + facade_colour: d.facade_colour.unwrap_or(0), + _pad2: 0, + } + } + + pub(crate) fn raw_roof(&self) -> u8 { + self.roof + } + + pub fn get(&self) -> Detail { + Detail { + roof: RoofShape::from_u8(self.roof).unwrap_or_default(), + across: self.across != 0, + roof_colour: (self.present & 1 != 0).then_some(self.roof_colour), + facade_colour: (self.present & 2 != 0).then_some(self.facade_colour), + roof_height: self.roof_height, + levels: self.levels, + } + } +} pub struct Map { /// Data extent in normalized Mercator coordinates. @@ -63,22 +136,25 @@ pub struct Map { /// Local coordinates are `world - origin`, stored as `f32`. pub origin: Point, pub background: Background, - pub points: Vec<[f32; 2]>, + pub points: Store<[f32; 2]>, /// Ring `r` spans `points[ring_starts[r]..ring_starts[r + 1]]`. - pub ring_starts: Vec, + pub ring_starts: Store, /// Sorted in draw order. - pub features: Vec, + pub features: Store, /// Top of the tallest structure, in meters. pub max_height: f32, /// Roof and facade detail of solids, indexed by [`Feature::detail`]. - pub details: Vec, + pub details: Store, /// Per-vertex elevations (meters) of raised roads and rails, smoothed /// into ramps; see [`crate::elevation`]. - pub elevations: Vec, + pub elevations: Store, /// Bounding boxes of buildings drawn through their parts, indexed by /// [`Feature::group`]: parts of one building sort together. - pub groups: Vec<[f32; 4]>, - index: Vec>, + pub groups: Store<[f32; 4]>, + /// Features per kind, indexed by discriminant. + pub(crate) kind_counts: Vec, + /// One tree per zoom bucket; see [`build_index`]. + pub(crate) index: Vec, } /// Per-feature attributes passed through map building. @@ -215,7 +291,7 @@ impl Chunk { }; self.features.push(( Feature { - kind, + kind: kind as u8, flags, layer, ring_start, @@ -290,7 +366,7 @@ fn contains(rings: &[&[[f32; 2]]], p: [f32; 2]) -> bool { /// from the ground; antennas, masts, flagpoles and tanks stand on top. fn stands_on_roofs(f: &Feature) -> bool { use crate::classify::TowerType; - match f.kind { + match f.kind() { Kind::Tower => matches!( TowerType::from_u8(f.variant), TowerType::Generic | TowerType::Monopole | TowerType::Lattice | TowerType::Guyed @@ -332,7 +408,7 @@ fn place_structures( let buildings: Vec, usize>> = features .iter() .enumerate() - .filter(|(_, (f, _))| f.kind == Kind::Building) + .filter(|(_, (f, _))| f.kind() == Kind::Building) .map(|(i, (f, _))| { let r = Rectangle::from_corners([f.bbox[0], f.bbox[1]], [f.bbox[2], f.bbox[3]]); GeomWithData::new(r, i) @@ -572,7 +648,7 @@ impl Map { let groups = place_structures(&mut features, &map_points, &ring_starts, &details); let max_height = features .iter() - .filter(|(f, _)| f.kind.is_extrusion() || f.kind.is_point()) + .filter(|(f, _)| f.kind().is_extrusion() || f.kind().is_point()) .map(|(f, _)| f.height) .fold(0.0_f32, f32::max); @@ -591,35 +667,33 @@ impl Map { let rank = if g == Group::Areas { u32::MAX - area.to_bits() } else { - f.kind as u32 + f.kind() as u32 }; ( g, layer, - f.kind == Kind::Water, + f.kind() == Kind::Water, rank, - f.kind, + f.kind(), f.flags, f.ring_start, ) }); let features: Vec = features.into_iter().map(|(f, _)| f).collect(); - let index = build_index(&features); - - let map = Map { + let details: Vec = details.iter().map(DetailRec::new).collect(); + let map = Map::from_parts( bounds, origin, background, - points: map_points, + map_points, ring_starts, features, max_height, details, elevations, groups, - index, - }; + ); eprintln!( " built {} features, {} vertices ({:.2?})", map.features.len(), @@ -629,7 +703,7 @@ impl Map { map } - /// A map from its parts, as read back from a snapshot. + /// A map from owned parts, indexing it. #[allow(clippy::too_many_arguments)] pub(crate) fn from_parts( bounds: Rect, @@ -639,22 +713,24 @@ impl Map { ring_starts: Vec, features: Vec, max_height: f32, - details: Vec, + details: Vec, elevations: Vec, groups: Vec<[f32; 4]>, ) -> Map { - let index = build_index(&features); + let index = build_index(features.iter().copied()); + let kind_counts = count_kinds(features.iter().copied()); Map { bounds, origin, background, - points, - ring_starts, - features, + points: points.into(), + ring_starts: ring_starts.into(), + features: features.into(), max_height, - details, - elevations, - groups, + details: details.into(), + elevations: elevations.into(), + groups: groups.into(), + kind_counts, index, } } @@ -673,7 +749,7 @@ impl Map { pub fn detail(&self, f: &Feature) -> Detail { self.details .get(f.detail as usize) - .copied() + .map(DetailRec::get) .unwrap_or_default() } @@ -705,38 +781,96 @@ impl Map { /// (local coordinates), in draw order. pub fn query(&self, area: [f32; 4], zoom: f32, out: &mut Vec) { out.clear(); - let env = AABB::from_corners([area[0], area[1]], [area[2], area[3]]); + let q = [ + area[0].min(area[2]), + area[1].min(area[3]), + area[0].max(area[2]), + area[1].max(area[3]), + ]; let last = (zoom.max(0.0) as usize).min(MAX_BUCKET); - for tree in &self.index[..=last] { - out.extend( - tree.locate_in_envelope_intersecting(env) - .map(|e| e.data) - .filter(|&i| self.features[i as usize].vis_zoom <= zoom), - ); + let mut stack = Vec::new(); + for (b, tree) in self.index[..=last].iter().enumerate() { + // Bucket b holds vis_zoom in [b, b + 1) (and the top one all + // beyond), so only the last bucket needs each feature checked. + if b < last { + tree.search(&q, &mut stack, |i| out.push(i)); + } else { + tree.search(&q, &mut stack, |i| { + if self.features[i as usize].vis_zoom <= zoom { + out.push(i); + } + }); + } } out.sort_unstable(); } /// Per-kind feature counts, most common first. pub fn stats(&self) -> Vec<(Kind, usize)> { - let mut counts = std::collections::BTreeMap::new(); - for f in &self.features { - *counts.entry(f.kind).or_insert(0usize) += 1; - } - let mut v: Vec<_> = counts.into_iter().collect(); + let mut v: Vec<(Kind, usize)> = (0..Kind::COUNT as u8) + .filter_map(Kind::from_u8) + .map(|k| { + ( + k, + self.kind_counts.get(k as usize).copied().unwrap_or(0) as usize, + ) + }) + .filter(|&(_, n)| n > 0) + .collect(); v.sort_by_key(|a| std::cmp::Reverse(a.1)); v } + + /// Bytes of map data on the heap and in a mapped snapshot. + pub fn storage(&self) -> (usize, usize) { + fn add(s: &Store, t: &mut (usize, usize)) { + let n = std::mem::size_of_val(&s[..]); + if s.is_mapped() { + t.1 += n + } else { + t.0 += n + } + } + let mut t = (0, 0); + add(&self.points, &mut t); + add(&self.ring_starts, &mut t); + add(&self.features, &mut t); + add(&self.details, &mut t); + add(&self.elevations, &mut t); + add(&self.groups, &mut t); + for tree in &self.index { + add(&tree.boxes, &mut t); + add(&tree.ids, &mut t); + } + t + } +} + +/// Features per kind, indexed by discriminant. +pub(crate) fn count_kinds(features: impl Iterator) -> Vec { + let mut c = vec![0u64; Kind::COUNT]; + for f in features { + c[f.kind() as usize] += 1; + } + c +} + +/// The zoom bucket a feature is indexed in. +pub(crate) fn bucket(vis_zoom: f32) -> usize { + (vis_zoom.max(0.0) as usize).min(MAX_BUCKET) } -/// One R-tree per zoom bucket: a feature lives in the bucket of the zoom it -/// appears at, so a query walks only the buckets up to its zoom. -fn build_index(features: &[Feature]) -> Vec> { - let mut buckets: Vec> = (0..=MAX_BUCKET).map(|_| Vec::new()).collect(); - for (i, f) in features.iter().enumerate() { - let b = (f.vis_zoom.max(0.0) as usize).min(MAX_BUCKET); - let r = Rectangle::from_corners([f.bbox[0], f.bbox[1]], [f.bbox[2], f.bbox[3]]); - buckets[b].push(GeomWithData::new(r, i as u32)); +/// One tree per zoom bucket: a feature lives in the bucket of the zoom it +/// appears at, so a query walks only the buckets up to its zoom. Features +/// with a NaN zoom are never visible and are left out. +pub(crate) fn build_index(features: impl Iterator) -> Vec { + let mut buckets: Vec> = (0..=MAX_BUCKET).map(|_| Vec::new()).collect(); + for (i, f) in features.enumerate() { + if !f.vis_zoom.is_nan() { + let [x0, y0, x1, y1] = f.bbox; + let b = [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]; + buckets[bucket(f.vis_zoom)].push((b, i as u32)); + } } - buckets.into_par_iter().map(RTree::bulk_load).collect() + buckets.into_par_iter().map(Packed::build).collect() } diff --git a/src/render/areas.rs b/src/render/areas.rs index b9e8d21..fec4a5d 100644 --- a/src/render/areas.rs +++ b/src/render/areas.rs @@ -62,10 +62,10 @@ impl Renderer<'_> { pub(super) fn draw_areas(&self, pixmap: &mut Pixmap, fr: &Frame, run: &[u32], s: &mut Scratch) { for batch in self.batches(run) { let f0 = &self.map.features[batch[0] as usize]; - let Some(style) = self.theme.area(f0.kind, &fr.ctx) else { + let Some(style) = self.theme.area(f0.kind(), &fr.ctx) else { continue; }; - let bank = banks(f0.kind).filter(|b| b.depth * fr.lift_per_m >= 0.75); + let bank = banks(f0.kind()).filter(|b| b.depth * fr.lift_per_m >= 0.75); let depth = bank.as_ref().map_or(0.0, |b| b.depth * fr.lift_per_m); // Faces hang below edges; clip far enough out that faces from // artificial clip edges never reach into the view. diff --git a/src/render/lines.rs b/src/render/lines.rs index 98d7d83..dc65568 100644 --- a/src/render/lines.rs +++ b/src/render/lines.rs @@ -206,18 +206,18 @@ impl Renderer<'_> { let mut ground: Vec<(LineStyle, LineGeom)> = Vec::new(); for ids in self.batches(run) { let f0 = &self.map.features[ids[0] as usize]; - let style = self.theme.line(f0.kind, f0.flags, f0.height, &fr.ctx); + let style = self.theme.line(f0.kind(), f0.flags, f0.height, &fr.ctx); let widest = [&style.casing, &style.line] .iter() .filter_map(|s| s.as_ref().map(|s| s.width)) .fold(0.0_f32, f32::max); - let tree_row = scene.is_some() && f0.kind == Kind::TreeRow; + let tree_row = scene.is_some() && f0.kind() == Kind::TreeRow; if widest <= 0.0 && !tree_row { continue; } let fixed = scene .as_ref() - .and_then(|_| self.raised_form(f0.kind, f0.height)) + .and_then(|_| self.raised_form(f0.kind(), f0.height)) .map(|(form, m)| (form, m * fr.lift_per_m)) .filter(|&(_, px)| px >= 1.0); let base_px = (f0.base as f64 * fr.lift_per_m).max(0.0); @@ -355,7 +355,7 @@ impl Renderer<'_> { }) .collect(); let pieces = clip_raised(&pts, &clip); - let form = if deck_height(f.kind, f.layer, f.flags) > 0.0 { + let form = if deck_height(f.kind(), f.layer, f.flags) > 0.0 { Form::Deck { pillar_spacing: PILLAR_SPACING, } diff --git a/src/render/mod.rs b/src/render/mod.rs index bf6daba..9e258dc 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -212,9 +212,9 @@ impl<'a> Renderer<'a> { let features = &self.map.features; run.chunk_by(move |&a, &b| { let (a, b) = (&features[a as usize], &features[b as usize]); - a.kind == b.kind + a.kind() == b.kind() && a.flags == b.flags - && (a.kind != Kind::Boundary || a.height == b.height) + && (a.kind() != Kind::Boundary || a.height == b.height) }) } } diff --git a/src/render/models.rs b/src/render/models.rs index 804a9a7..9f8aa32 100644 --- a/src/render/models.rs +++ b/src/render/models.rs @@ -541,10 +541,10 @@ impl Renderer<'_> { return; } let at = [ground[0], ground[1] - f.base as f64 * fr.lift_per_m]; - let c = self.theme.object(f.kind); - let mut m = model(f.kind, f.variant, h, &c, self.theme.trunk()); + let c = self.theme.object(f.kind()); + let mut m = model(f.kind(), f.variant, h, &c, self.theme.trunk()); m.turn_to(f.heading as f64); - if f.kind == Kind::Tree && f.flags & flags::CONIFER != 0 { + if f.kind() == Kind::Tree && f.flags & flags::CONIFER != 0 { m = Mesh::new(); tree(&mut m, h, true, &c, self.theme.trunk()); } @@ -562,8 +562,8 @@ impl Renderer<'_> { if !view.contains([x, y]) { continue; } - let c = self.theme.object(f.kind); - let radius = match f.kind { + let c = self.theme.object(f.kind()); + let radius = match f.kind() { Kind::Tree => { let r = ((0.3 * f.height as f64).max(1.2) * ppm).max(1.0); oval(pixmap, x, y, r, c.body); diff --git a/src/render/scene.rs b/src/render/scene.rs index d4322c9..25a4a17 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -78,7 +78,7 @@ impl Scene { pub(super) fn add_features(&mut self, fr: &Frame, map: &Map, run: &[u32]) { for &id in run { let f = &map.features[id as usize]; - let (depth, tie) = if f.kind.is_point() { + let (depth, tie) = if f.kind().is_point() { let p = map.ring(f.ring_start)[0]; (fr.apply(p[0] as f64, p[1] as f64)[1], (id as u64) << 24) } else if let Some(&group) = map.groups.get(f.group as usize) { @@ -160,7 +160,7 @@ impl Renderer<'_> { match item { Item::Feature(id) => { let f = &self.map.features[id as usize]; - if f.kind.is_point() { + if f.kind().is_point() { self.draw_prop(pixmap, fr, f); } else { self.draw_solid(pixmap, fr, f, s); diff --git a/src/render/solids.rs b/src/render/solids.rs index 0899fdb..65a66b8 100644 --- a/src/render/solids.rs +++ b/src/render/solids.rs @@ -92,7 +92,7 @@ impl Renderer<'_> { run: &[u32], s: &mut Scratch, ) { - let split = run.partition_point(|&id| !self.map.features[id as usize].kind.is_point()); + let split = run.partition_point(|&id| !self.map.features[id as usize].kind().is_point()); let (solids, points) = run.split_at(split); if fr.ctx.zoom >= SHADOW_ZOOM { self.draw_shadows(pixmap, fr, solids, s); @@ -114,7 +114,7 @@ impl Renderer<'_> { pub(super) fn draw_solid(&self, pixmap: &mut Pixmap, fr: &Frame, f: &Feature, s: &mut Scratch) { let detail = self.map.detail(f); let (mut base, top) = (f.base, f.height); - if f.kind == Kind::Canopy { + if f.kind() == Kind::Canopy { base = base.max(top - CANOPY_SLAB); } let mpu = fr.meters_per_unit(); @@ -141,7 +141,7 @@ impl Renderer<'_> { let facade_colour = detail.facade_colour; let facade = |light: f32| match facade_colour { Some(c) => self.theme.mapped_facade(c, light), - None => self.theme.facade(f.kind, light), + None => self.theme.facade(f.kind(), light), }; let base_px = base as f64 * fr.lift_per_m; @@ -175,7 +175,7 @@ impl Renderer<'_> { let floor_px = (eave_px - base_px) / floors; // Window bands fade in as floors grow from 2 to 4 pixels tall. let window_alpha = ((floor_px - 2.0) / 2.0).clamp(0.0, 1.0) as f32; - let windows = f.kind == Kind::Building && window_alpha > 0.0 && wall_m >= 2.5; + let windows = f.kind() == Kind::Building && window_alpha > 0.0 && wall_m >= 2.5; let mut walls: [Option; SHADES] = Default::default(); let mut panes: [Option; SHADES] = Default::default(); @@ -230,7 +230,7 @@ impl Renderer<'_> { } } } - if f.kind == Kind::Canopy && r == 0 && base_px >= 2.0 { + if f.kind() == Kind::Canopy && r == 0 && base_px >= 2.0 { let step = (n / 6).max(1); posts.extend(s.pts.iter().step_by(step).copied()); } @@ -252,7 +252,7 @@ impl Renderer<'_> { line_cap: LineCap::Butt, ..Stroke::default() }; - let p = paint(self.theme.facade(f.kind, 0.0)); + let p = paint(self.theme.facade(f.kind(), 0.0)); pixmap.stroke_path(&path, &p, &st, Transform::identity(), None); } } @@ -278,7 +278,7 @@ impl Renderer<'_> { let roof_fill = roof_colour.unwrap_or_else(|| match roof { Some(_) => self.theme.pitched_roof(), - None => self.theme.roof(f.kind), + None => self.theme.roof(f.kind()), }); match roof { None => { @@ -381,7 +381,7 @@ pub(crate) fn roof_plan( ) -> Option { // Pitched roofs need a single, simple outline. if d.roof == RoofShape::Flat - || f.kind != Kind::Building + || f.kind() != Kind::Building || f.ring_count != 1 || footprint.len() < 3 { diff --git a/src/server.rs b/src/server.rs index dee3010..bfcff43 100644 --- a/src/server.rs +++ b/src/server.rs @@ -363,6 +363,7 @@ async fn meta(State(app): State) -> impl IntoResponse { async fn metrics(State(app): State) -> impl IntoResponse { app.cache.run_pending_tasks().await; + app.geo_cache.run_pending_tasks().await; let m = &app.metrics; let rendered = m.rendered.load(Ordering::Relaxed); let nanos = m.render_nanos.load(Ordering::Relaxed); @@ -376,7 +377,9 @@ async fn metrics(State(app): State) -> impl IntoResponse { # TYPE maps_geo_tiles_total counter\nmaps_geo_tiles_total {}\n\ # TYPE maps_geo_bytes_total counter\nmaps_geo_bytes_total {}\n\ # TYPE maps_cache_entries gauge\nmaps_cache_entries {}\n\ - # TYPE maps_cache_bytes gauge\nmaps_cache_bytes {}\n", + # TYPE maps_cache_bytes gauge\nmaps_cache_bytes {}\n\ + # TYPE maps_geo_cache_entries gauge\nmaps_geo_cache_entries {}\n\ + # TYPE maps_geo_cache_bytes gauge\nmaps_geo_cache_bytes {}\n", m.requests.load(Ordering::Relaxed), m.hits.load(Ordering::Relaxed), rendered, @@ -387,6 +390,8 @@ async fn metrics(State(app): State) -> impl IntoResponse { m.geo_bytes.load(Ordering::Relaxed), app.cache.entry_count(), app.cache.weighted_size(), + app.geo_cache.entry_count(), + app.geo_cache.weighted_size(), ); ([(header::CONTENT_TYPE, "text/plain; version=0.0.4")], body) } diff --git a/src/snapshot.rs b/src/snapshot.rs index 8606968..dfd2468 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -1,189 +1,442 @@ -//! Map snapshots: a built [`Map`] saved to disk, so a server loads it in -//! seconds with no more memory than serving needs (building from an -//! `.osm.pbf` peaks at over twice that). +//! Map snapshots: a built [`Map`] saved to disk, so a server starts in +//! seconds instead of rebuilding from an `.osm.pbf`. //! -//! Layout, little-endian: `"MAPSNAP1"`, then a header (bounds, origin, -//! background, max height) and length-prefixed arrays of points, ring -//! starts, features, details, elevations and groups. The spatial index is -//! rebuilt on load. +//! `MAPSNAP2`, the format written, is memory-mapped and used in place: the +//! arrays are borrowed straight from the file, and the kernel pages them in +//! as tiles touch them and drops them under memory pressure, so serving +//! needs little anonymous memory. Layout, little-endian: a fixed [`Header`] +//! (bounds, origin, background, max height, and the offset and length of +//! each section), then the sections, each 64-byte aligned: points, ring +//! starts, features, details, elevations, groups, per-kind feature counts, +//! and the spatial index (one packed Hilbert R-tree per zoom bucket; see +//! [`crate::index`]). +//! +//! `MAPSNAP1`, the earlier format, is still read: copied onto the heap with +//! the index rebuilt, as it was. [`convert`] turns one into the other. +//! +//! A mapped file must never change while a server has it open: replace a +//! snapshot by writing a new file and renaming it over the old one. use crate::assemble::Background; -use crate::classify::{Detail, Kind, RoofShape}; +use crate::classify::{Kind, RoofShape}; use crate::geo::Rect; -use crate::map::{Feature, Map}; +use crate::index::{self, Packed}; +use crate::map::{build_index, count_kinds, DetailRec, Feature, Map, MAX_BUCKET}; +use crate::store::Store; use anyhow::{bail, ensure, Context, Result}; +use bytemuck::{Pod, Zeroable}; +use memmap2::Mmap; use std::fs::File; -use std::io::{BufReader, BufWriter, Read, Write}; -use std::path::Path; +use std::io::{BufWriter, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; use std::time::Instant; #[cfg(target_endian = "big")] compile_error!("snapshots are written as little-endian memory"); -pub const MAGIC: &[u8; 8] = b"MAPSNAP1"; -const FEATURE_BYTES: usize = 56; -const DETAIL_BYTES: usize = 24; +pub const MAGIC: &[u8; 8] = b"MAPSNAP2"; +pub const MAGIC_V1: &[u8; 8] = b"MAPSNAP1"; +const ALIGN: usize = 64; +const HEADER_BYTES: usize = 256; + +/// Sections in file order. +const POINTS: usize = 0; +const RING_STARTS: usize = 1; +const FEATURES: usize = 2; +const DETAILS: usize = 3; +const ELEVATIONS: usize = 4; +const GROUPS: usize = 5; +/// `u64` per kind discriminant, so `/v1/kinds` needs no scan. +const KIND_COUNTS: usize = 6; +/// `u64` per zoom bucket: the number of features in its tree. +const INDEX_LENS: usize = 7; +/// Every bucket's tree nodes, bucket after bucket. +const INDEX_BOXES: usize = 8; +const INDEX_IDS: usize = 9; +const SECTIONS: usize = 10; +const ELEMENT_BYTES: [usize; SECTIONS] = [8, 4, 56, 24, 4, 16, 8, 8, 16, 4]; + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct Header { + magic: [u8; 8], + bounds: [f64; 4], + origin: [f64; 2], + max_height: f32, + /// 1 for water, 0 for land. + background: u32, + /// `[byte offset, element count]` of each section. + sections: [[u64; 2]; SECTIONS], +} + +const _: () = assert!(std::mem::size_of::
() <= HEADER_BYTES); -/// Whether `path` holds a snapshot rather than an `.osm.pbf`. +/// Whether `path` holds a snapshot (either format) rather than an `.osm.pbf`. pub fn is_snapshot(path: &Path) -> bool { let mut head = [0u8; 8]; File::open(path) .and_then(|mut f| f.read_exact(&mut head)) - .is_ok_and(|_| &head == MAGIC) + .is_ok_and(|_| &head == MAGIC || &head == MAGIC_V1) } -/// Plain numeric arrays as raw bytes. -trait Pod: Copy + Default {} -impl Pod for u32 {} -impl Pod for f32 {} -impl Pod for [f32; 2] {} -impl Pod for [f32; 4] {} +/// Everything a v2 file holds, as bytes. +struct Parts<'a> { + bounds: Rect, + origin: [f64; 2], + background: Background, + max_height: f32, + /// Each section's bytes, possibly in several pieces. + sections: [Vec<&'a [u8]>; SECTIONS], +} -fn write_array(w: &mut impl Write, v: &[T]) -> Result<()> { - w.write_all(&(v.len() as u64).to_le_bytes())?; - // SAFETY: T is plain numbers with no padding; the target is little-endian. - let bytes = - unsafe { std::slice::from_raw_parts(v.as_ptr().cast::(), std::mem::size_of_val(v)) }; - w.write_all(bytes)?; - Ok(()) +fn index_sections(index: &[Packed]) -> (Vec, Vec<&[u8]>, Vec<&[u8]>) { + let lens = index.iter().map(|t| t.len() as u64).collect(); + let boxes = index + .iter() + .map(|t| bytemuck::cast_slice(&t.boxes[..])) + .collect(); + let ids = index + .iter() + .map(|t| bytemuck::cast_slice(&t.ids[..])) + .collect(); + (lens, boxes, ids) } -fn read_len(r: &mut impl Read) -> Result { - let mut b = [0u8; 8]; - r.read_exact(&mut b)?; - Ok(u64::from_le_bytes(b) as usize) +/// Writes `parts` to `path` via a `.partial` file renamed into place, so a +/// server mapping the old file never sees it change. +fn write(path: &Path, parts: &Parts) -> Result<()> { + let mut partial = path.as_os_str().to_owned(); + partial.push(".partial"); + let partial = PathBuf::from(partial); + let file = File::create(&partial).with_context(|| format!("creating {}", partial.display()))?; + let mut w = BufWriter::with_capacity(1 << 22, file); + w.write_all(&[0u8; HEADER_BYTES])?; + let mut pos = HEADER_BYTES; + let mut header = Header { + magic: *MAGIC, + bounds: [ + parts.bounds.min_x, + parts.bounds.min_y, + parts.bounds.max_x, + parts.bounds.max_y, + ], + origin: parts.origin, + max_height: parts.max_height, + background: (parts.background == Background::Water) as u32, + sections: [[0; 2]; SECTIONS], + }; + for (s, pieces) in parts.sections.iter().enumerate() { + let pad = pos.next_multiple_of(ALIGN) - pos; + w.write_all(&[0u8; ALIGN][..pad])?; + pos += pad; + let bytes: usize = pieces.iter().map(|p| p.len()).sum(); + ensure!( + bytes.is_multiple_of(ELEMENT_BYTES[s]), + "section {s} is not whole elements" + ); + header.sections[s] = [pos as u64, (bytes / ELEMENT_BYTES[s]) as u64]; + for p in pieces { + w.write_all(p)?; + } + pos += bytes; + } + w.seek(SeekFrom::Start(0))?; + w.write_all(bytemuck::bytes_of(&header))?; + let file = w.into_inner().map_err(|e| e.into_error())?; + file.sync_all()?; + drop(file); + std::fs::rename(&partial, path) + .with_context(|| format!("renaming {} into place", partial.display()))?; + Ok(()) } -fn read_array(r: &mut impl Read) -> Result> { - let n = read_len(r)?; - let mut v = vec![T::default(); n]; - // SAFETY: as in `write_array`; every bit pattern is a valid T. - let bytes = unsafe { - std::slice::from_raw_parts_mut(v.as_mut_ptr().cast::(), std::mem::size_of_val(&v[..])) +/// Saves `map` as a v2 snapshot. +pub fn save(map: &Map, path: &Path) -> Result<()> { + let t = Instant::now(); + let (lens, boxes, ids) = index_sections(&map.index); + let parts = Parts { + bounds: map.bounds, + origin: map.origin, + background: map.background, + max_height: map.max_height, + sections: [ + vec![bytemuck::cast_slice(&map.points[..])], + vec![bytemuck::cast_slice(&map.ring_starts[..])], + vec![bytemuck::cast_slice(&map.features[..])], + vec![bytemuck::cast_slice(&map.details[..])], + vec![bytemuck::cast_slice(&map.elevations[..])], + vec![bytemuck::cast_slice(&map.groups[..])], + vec![bytemuck::cast_slice(&map.kind_counts)], + vec![bytemuck::cast_slice(&lens)], + boxes, + ids, + ], }; - r.read_exact(bytes)?; - Ok(v) + write(path, &parts)?; + eprintln!("saved {} in {:.2?}", path.display(), t.elapsed()); + Ok(()) } -fn kind_from_u8(v: u8) -> Result { - ensure!(v <= Kind::NavLight as u8, "unknown feature kind {v}"); - // SAFETY: Kind is repr(u8) with contiguous discriminants 0..=NavLight. - Ok(unsafe { std::mem::transmute::(v) }) +/// Maps `path` read-only for the rest of the process. +fn map_file(path: &Path) -> Result<&'static [u8]> { + let file = File::open(path).with_context(|| format!("opening {}", path.display()))?; + // SAFETY: the file is opened read-only and is never modified while + // mapped: snapshots are replaced by renaming a new file over them, which + // leaves this one intact (see the module docs). + let mmap = + unsafe { Mmap::map(&file) }.with_context(|| format!("mapping {}", path.display()))?; + Ok(&Box::leak(Box::new(mmap))[..]) } -fn roof_from_u8(v: u8) -> Result { - Ok(match v { - 0 => RoofShape::Flat, - 1 => RoofShape::Gabled, - 2 => RoofShape::Hipped, - 3 => RoofShape::Pyramidal, - 4 => RoofShape::Skillion, - 5 => RoofShape::Dome, - _ => bail!("unknown roof shape {v}"), - }) +/// Loads a snapshot of either format. +pub fn load(path: &Path) -> Result { + let t = Instant::now(); + let bytes = map_file(path)?; + let map = match bytes.get(..8) { + Some(m) if m == MAGIC => open_v2(bytes)?, + Some(m) if m == MAGIC_V1 => load_v1(bytes)?, + _ => bail!("{} is not a map snapshot", path.display()), + }; + let (heap, mapped) = map.storage(); + eprintln!( + " loaded snapshot: {} features, {} vertices, {} MiB on the heap, {} MiB mapped ({:.2?})", + map.features.len(), + map.points.len(), + heap >> 20, + mapped >> 20, + t.elapsed() + ); + Ok(map) } -fn put_feature(out: &mut Vec, f: &Feature) { - out.extend([f.kind as u8, f.flags, f.layer as u8, f.variant]); - for v in [f.ring_start, f.ring_count, f.detail, f.elev, f.group] { - out.extend(v.to_le_bytes()); - } - for v in [f.height, f.base, f.heading, f.vis_zoom] { - out.extend(v.to_le_bytes()); - } - for v in f.bbox { - out.extend(v.to_le_bytes()); - } +/// A section of a mapped v2 file, bounds- and alignment-checked. +fn section(bytes: &'static [u8], h: &Header, s: usize) -> Result<&'static [T]> { + debug_assert_eq!(std::mem::size_of::(), ELEMENT_BYTES[s]); + let [offset, count] = h.sections[s]; + let len = (count as usize) + .checked_mul(ELEMENT_BYTES[s]) + .context("section size overflows")?; + let start = offset as usize; + let end = start.checked_add(len).context("section end overflows")?; + ensure!( + start.is_multiple_of(ALIGN) && start >= HEADER_BYTES && end <= bytes.len(), + "section {s} ({start}..{end}) is outside the file or misaligned" + ); + bytemuck::try_cast_slice(&bytes[start..end]).map_err(|e| anyhow::anyhow!("section {s}: {e:?}")) } -fn get_feature(b: &[u8]) -> Result { - let u = |i: usize| u32::from_le_bytes(b[i..i + 4].try_into().unwrap()); - let fl = |i: usize| f32::from_le_bytes(b[i..i + 4].try_into().unwrap()); - Ok(Feature { - kind: kind_from_u8(b[0])?, - flags: b[1], - layer: b[2] as i8, - variant: b[3], - ring_start: u(4), - ring_count: u(8), - detail: u(12), - elev: u(16), - group: u(20), - height: fl(24), - base: fl(28), - heading: fl(32), - vis_zoom: fl(36), - bbox: [fl(40), fl(44), fl(48), fl(52)], +fn open_v2(bytes: &'static [u8]) -> Result { + ensure!(bytes.len() >= HEADER_BYTES, "snapshot header is truncated"); + let h: Header = bytemuck::pod_read_unaligned(&bytes[..std::mem::size_of::
()]); + let points: &[[f32; 2]] = section(bytes, &h, POINTS)?; + let ring_starts: &[u32] = section(bytes, &h, RING_STARTS)?; + let features: &[Feature] = section(bytes, &h, FEATURES)?; + let details: &[DetailRec] = section(bytes, &h, DETAILS)?; + let elevations: &[f32] = section(bytes, &h, ELEVATIONS)?; + let groups: &[[f32; 4]] = section(bytes, &h, GROUPS)?; + let kind_counts: &[u64] = section(bytes, &h, KIND_COUNTS)?; + let lens: &[u64] = section(bytes, &h, INDEX_LENS)?; + let boxes: &[[f32; 4]] = section(bytes, &h, INDEX_BOXES)?; + let ids: &[u32] = section(bytes, &h, INDEX_IDS)?; + + ensure!( + ring_starts + .last() + .is_some_and(|&e| e as usize == points.len()), + "ring starts do not end at the last point" + ); + ensure!( + kind_counts.len() <= Kind::COUNT, + "snapshot has {} kinds, this build knows {}: rebuild or upgrade", + kind_counts.len(), + Kind::COUNT + ); + ensure!( + kind_counts.iter().sum::() == features.len() as u64, + "kind counts do not add up to the feature count" + ); + ensure!( + lens.len() == MAX_BUCKET + 1, + "index has {} buckets", + lens.len() + ); + let mut index = Vec::with_capacity(lens.len()); + let mut node = 0usize; + for &len in lens { + ensure!(len as usize <= features.len(), "index bucket is too large"); + let nodes = *index::levels(len as usize).last().unwrap() as usize; + ensure!(node + nodes <= boxes.len(), "index is truncated"); + index.push(Packed::from_parts( + len as usize, + Store::Mapped(&boxes[node..node + nodes]), + Store::Mapped(&ids[node..node + nodes]), + )?); + node += nodes; + } + ensure!( + node == boxes.len() && node == ids.len(), + "index has extra nodes" + ); + Ok(Map { + bounds: Rect::new(h.bounds[0], h.bounds[1], h.bounds[2], h.bounds[3]), + origin: h.origin, + background: if h.background == 1 { + Background::Water + } else { + Background::Land + }, + points: Store::Mapped(points), + ring_starts: Store::Mapped(ring_starts), + features: Store::Mapped(features), + max_height: h.max_height, + details: Store::Mapped(details), + elevations: Store::Mapped(elevations), + groups: Store::Mapped(groups), + kind_counts: kind_counts.to_vec(), + index, }) } -fn put_detail(out: &mut Vec, d: &Detail) { - let present = d.roof_colour.is_some() as u8 | (d.facade_colour.is_some() as u8) << 1; - out.extend([d.roof as u8, d.across as u8, present, 0]); - out.extend(d.roof_height.to_le_bytes()); - out.extend(d.levels.to_le_bytes()); - out.extend(d.roof_colour.unwrap_or(0).to_le_bytes()); - out.extend(d.facade_colour.unwrap_or(0).to_le_bytes()); - out.extend([0u8; 4]); +/// A v1 file's fields, its arrays as (unaligned) bytes. Features and details +/// have the same 56- and 24-byte records as v2. +struct V1<'a> { + bounds: Rect, + origin: [f64; 2], + background: Background, + max_height: f32, + arrays: [&'a [u8]; 6], } -fn get_detail(b: &[u8]) -> Result { - let u = |i: usize| u32::from_le_bytes(b[i..i + 4].try_into().unwrap()); - let fl = |i: usize| f32::from_le_bytes(b[i..i + 4].try_into().unwrap()); - Ok(Detail { - roof: roof_from_u8(b[0])?, - across: b[1] != 0, - roof_colour: (b[2] & 1 != 0).then(|| u(12)), - facade_colour: (b[2] & 2 != 0).then(|| u(16)), - roof_height: fl(4), - levels: fl(8), +/// Element sizes of v1's arrays, in file order: points, ring starts, +/// features, details, elevations, groups. +const V1_ELEMENT_BYTES: [usize; 6] = [8, 4, 56, 24, 4, 16]; + +fn parse_v1(b: &[u8]) -> Result> { + ensure!(b.len() >= 61 && &b[..8] == MAGIC_V1, "not a v1 snapshot"); + let f = |i: usize| f64::from_le_bytes(b[8 + 8 * i..16 + 8 * i].try_into().unwrap()); + let mut pos = 61; + let mut arrays: [&[u8]; 6] = Default::default(); + for (a, size) in arrays.iter_mut().zip(V1_ELEMENT_BYTES) { + let n = b + .get(pos..pos + 8) + .map(|n| u64::from_le_bytes(n.try_into().unwrap()) as usize) + .context("v1 snapshot is truncated")?; + let len = n.checked_mul(size).context("v1 array size overflows")?; + *a = b + .get(pos + 8..pos + 8 + len) + .context("v1 snapshot is truncated")?; + pos += 8 + len; + } + Ok(V1 { + bounds: Rect::new(f(0), f(1), f(2), f(3)), + origin: [f(4), f(5)], + background: if b[56] == 1 { + Background::Water + } else { + Background::Land + }, + max_height: f32::from_le_bytes(b[57..61].try_into().unwrap()), + arrays, }) } -/// Writes records of `size` bytes, a chunk at a time. -fn write_records( - w: &mut impl Write, - items: &[T], - size: usize, - put: impl Fn(&mut Vec, &T), -) -> Result<()> { - w.write_all(&(items.len() as u64).to_le_bytes())?; - let mut buf = Vec::with_capacity(size * 65536); - for chunk in items.chunks(65536) { - buf.clear(); - for item in chunk { - put(&mut buf, item); - } - w.write_all(&buf)?; +impl V1<'_> { + fn features(&self) -> impl Iterator + '_ { + self.arrays[2] + .as_chunks::<56>() + .0 + .iter() + .map(|r| bytemuck::pod_read_unaligned(r)) } - Ok(()) -} -fn read_records( - r: &mut impl Read, - size: usize, - get: impl Fn(&[u8]) -> Result, -) -> Result> { - let n = read_len(r)?; - let mut out = Vec::with_capacity(n); - let mut buf = vec![0u8; size * 65536]; - let mut left = n; - while left > 0 { - let k = left.min(65536); - r.read_exact(&mut buf[..k * size])?; - for rec in buf[..k * size].chunks_exact(size) { - out.push(get(rec)?); + /// Every kind and roof byte is one this build knows. + fn validate(&self) -> Result<()> { + if let Some(f) = self + .features() + .find(|f| Kind::from_u8(f.raw_kind()).is_none()) + { + bail!("unknown feature kind {}", f.raw_kind()); + } + for rec in self.arrays[3].as_chunks::<24>().0 { + let d: DetailRec = bytemuck::pod_read_unaligned(rec); + ensure!( + RoofShape::from_u8(d.raw_roof()).is_some(), + "unknown roof shape {}", + d.raw_roof() + ); } - left -= k; + Ok(()) } - Ok(out) } -pub fn save(map: &Map, path: &Path) -> Result<()> { +/// Reads a v1 snapshot onto the heap and rebuilds its index. +fn load_v1(bytes: &[u8]) -> Result { + let v1 = parse_v1(bytes)?; + v1.validate()?; + let [points, ring_starts, features, details, elevations, groups] = v1.arrays; + Ok(Map::from_parts( + v1.bounds, + v1.origin, + v1.background, + bytemuck::pod_collect_to_vec(points), + bytemuck::pod_collect_to_vec(ring_starts), + bytemuck::pod_collect_to_vec(features), + v1.max_height, + bytemuck::pod_collect_to_vec(details), + bytemuck::pod_collect_to_vec(elevations), + bytemuck::pod_collect_to_vec(groups), + )) +} + +/// Converts a v1 snapshot to v2 without rebuilding the map: the arrays are +/// copied straight from the mapped input, and only the index and the kind +/// counts are computed (about 45 bytes per feature of memory, briefly). +pub fn convert(input: &Path, output: &Path) -> Result<()> { let t = Instant::now(); - let file = File::create(path).with_context(|| format!("creating {}", path.display()))?; - let mut w = BufWriter::with_capacity(1 << 22, file); - w.write_all(MAGIC)?; + let bytes = map_file(input)?; + if bytes.get(..8) == Some(MAGIC) { + bail!("{} is already a v2 snapshot", input.display()); + } + let v1 = parse_v1(bytes).with_context(|| format!("reading {}", input.display()))?; + v1.validate()?; + let index = build_index(v1.features()); + let kind_counts = count_kinds(v1.features()); + let (lens, boxes, ids) = index_sections(&index); + let a = v1.arrays; + let parts = Parts { + bounds: v1.bounds, + origin: v1.origin, + background: v1.background, + max_height: v1.max_height, + sections: [ + vec![a[0]], + vec![a[1]], + vec![a[2]], + vec![a[3]], + vec![a[4]], + vec![a[5]], + vec![bytemuck::cast_slice(&kind_counts)], + vec![bytemuck::cast_slice(&lens)], + boxes, + ids, + ], + }; + write(output, &parts)?; + eprintln!( + "converted {} to {} ({} features) in {:.2?}", + input.display(), + output.display(), + a[2].len() / 56, + t.elapsed() + ); + Ok(()) +} + +/// Writes `map` in the v1 format, for tests of reading and converting it. +#[cfg(test)] +pub(crate) fn save_v1(map: &Map, path: &Path) -> Result<()> { + let mut w = BufWriter::new(File::create(path)?); + w.write_all(MAGIC_V1)?; let b = &map.bounds; for v in [ b.min_x, @@ -197,120 +450,21 @@ pub fn save(map: &Map, path: &Path) -> Result<()> { } w.write_all(&[(map.background == Background::Water) as u8])?; w.write_all(&map.max_height.to_le_bytes())?; - write_array(&mut w, &map.points)?; - write_array(&mut w, &map.ring_starts)?; - write_records(&mut w, &map.features, FEATURE_BYTES, put_feature)?; - write_records(&mut w, &map.details, DETAIL_BYTES, put_detail)?; - write_array(&mut w, &map.elevations)?; - write_array(&mut w, &map.groups)?; + let arrays: [&[u8]; 6] = [ + bytemuck::cast_slice(&map.points[..]), + bytemuck::cast_slice(&map.ring_starts[..]), + bytemuck::cast_slice(&map.features[..]), + bytemuck::cast_slice(&map.details[..]), + bytemuck::cast_slice(&map.elevations[..]), + bytemuck::cast_slice(&map.groups[..]), + ]; + for (a, size) in arrays.iter().zip(V1_ELEMENT_BYTES) { + w.write_all(&((a.len() / size) as u64).to_le_bytes())?; + w.write_all(a)?; + } w.flush()?; - eprintln!("saved {} in {:.2?}", path.display(), t.elapsed()); Ok(()) } -pub fn load(path: &Path) -> Result { - let t = Instant::now(); - let file = File::open(path).with_context(|| format!("opening {}", path.display()))?; - let mut r = BufReader::with_capacity(1 << 22, file); - let mut magic = [0u8; 8]; - r.read_exact(&mut magic)?; - ensure!(&magic == MAGIC, "{} is not a map snapshot", path.display()); - let mut f64s = [0f64; 6]; - for v in &mut f64s { - let mut b = [0u8; 8]; - r.read_exact(&mut b)?; - *v = f64::from_le_bytes(b); - } - let mut one = [0u8; 1]; - r.read_exact(&mut one)?; - let mut four = [0u8; 4]; - r.read_exact(&mut four)?; - let points = read_array(&mut r)?; - let ring_starts = read_array(&mut r)?; - let features = read_records(&mut r, FEATURE_BYTES, get_feature)?; - let details = read_records(&mut r, DETAIL_BYTES, get_detail)?; - let elevations = read_array(&mut r)?; - let groups = read_array(&mut r)?; - let map = Map::from_parts( - Rect::new(f64s[0], f64s[1], f64s[2], f64s[3]), - [f64s[4], f64s[5]], - if one[0] == 1 { - Background::Water - } else { - Background::Land - }, - points, - ring_starts, - features, - f32::from_le_bytes(four), - details, - elevations, - groups, - ); - eprintln!( - " loaded snapshot: {} features, {} vertices ({:.2?})", - map.features.len(), - map.points.len(), - t.elapsed() - ); - Ok(map) -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn features_round_trip() { - let f = Feature { - kind: Kind::Building, - flags: 3, - layer: -2, - ring_start: 7, - ring_count: 2, - height: 120.5, - base: 4.0, - detail: 9, - variant: 1, - heading: f32::NAN, - elev: u32::MAX, - group: 5, - vis_zoom: 13.5, - bbox: [0.1, 0.2, 0.3, 0.4], - }; - let mut b = Vec::new(); - put_feature(&mut b, &f); - assert_eq!(b.len(), FEATURE_BYTES); - let g = get_feature(&b).unwrap(); - assert_eq!( - (g.kind, g.layer, g.ring_start, g.elev), - (f.kind, f.layer, 7, u32::MAX) - ); - assert!(g.heading.is_nan() && g.bbox == f.bbox && g.height == f.height); - } - - #[test] - fn details_round_trip() { - let d = Detail { - roof: RoofShape::Hipped, - roof_height: 3.5, - across: true, - roof_colour: Some(0x8a4b38), - facade_colour: None, - levels: 12.0, - }; - let mut b = Vec::new(); - put_detail(&mut b, &d); - assert_eq!(b.len(), DETAIL_BYTES); - let e = get_detail(&b).unwrap(); - assert_eq!(e, d); - } - - #[test] - fn every_kind_reads_back() { - for v in 0..=Kind::NavLight as u8 { - assert_eq!(kind_from_u8(v).unwrap() as u8, v); - } - assert!(kind_from_u8(Kind::NavLight as u8 + 1).is_err()); - } -} +mod tests; diff --git a/src/snapshot/tests.rs b/src/snapshot/tests.rs new file mode 100644 index 0000000..54c11e4 --- /dev/null +++ b/src/snapshot/tests.rs @@ -0,0 +1,363 @@ +use super::*; +use crate::classify::Detail; +use crate::index::NODE_SIZE; +use rstar::primitives::{GeomWithData, Rectangle}; +use rstar::{RTree, AABB}; + +/// A small deterministic generator, so the tests need no crate. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 40) as f32 / (1u64 << 24) as f32 + } + fn below(&mut self, n: usize) -> usize { + ((self.next() * n as f32) as usize).min(n - 1) + } +} + +type Entry = GeomWithData, u32>; + +/// The index as it was before v2: one rstar tree per zoom bucket, filtered +/// by `vis_zoom`. The reference the packed trees must agree with. +struct Reference(Vec>); + +impl Reference { + fn new(map: &Map) -> Self { + let mut buckets: Vec> = (0..=MAX_BUCKET).map(|_| Vec::new()).collect(); + for (i, f) in map.features.iter().enumerate() { + let b = (f.vis_zoom.max(0.0) as usize).min(MAX_BUCKET); + let r = Rectangle::from_corners([f.bbox[0], f.bbox[1]], [f.bbox[2], f.bbox[3]]); + buckets[b].push(GeomWithData::new(r, i as u32)); + } + Reference(buckets.into_iter().map(RTree::bulk_load).collect()) + } + + fn query(&self, map: &Map, area: [f32; 4], zoom: f32) -> Vec { + let env = AABB::from_corners([area[0], area[1]], [area[2], area[3]]); + let last = (zoom.max(0.0) as usize).min(MAX_BUCKET); + let mut out = Vec::new(); + for tree in &self.0[..=last] { + out.extend( + tree.locate_in_envelope_intersecting(env) + .map(|e| e.data) + .filter(|&i| map.features[i as usize].vis_zoom <= zoom), + ); + } + out.sort_unstable(); + out + } +} + +/// A map of random features over the unit square, every kind and roof +/// shape represented. +fn synthetic(n: usize, seed: u64) -> Map { + let mut r = Rng(seed); + let mut points = Vec::new(); + let mut ring_starts = Vec::new(); + let mut features = Vec::new(); + let mut details = Vec::new(); + for i in 0..n { + let (x, y) = (r.next(), r.next()); + let size = if i % 4 == 0 { 0.0 } else { r.next() * 0.02 }; + ring_starts.push(points.len() as u32); + points.extend([[x, y], [x + size, y], [x + size, y + size]]); + let mut f: Feature = Zeroable::zeroed(); + f.set_kind(Kind::from_u8((i % Kind::COUNT) as u8).unwrap()); + f.ring_start = i as u32; + f.ring_count = 1; + f.layer = (i % 5) as i8 - 2; + f.detail = u32::MAX; + f.elev = u32::MAX; + f.group = u32::MAX; + f.heading = f32::NAN; + f.vis_zoom = r.next() * 23.0 - 1.0; + f.bbox = [x, y, x + size, y + size]; + if i % 7 == 0 { + f.detail = details.len() as u32; + details.push(DetailRec::new(&Detail { + roof: RoofShape::from_u8((i % 6) as u8).unwrap(), + roof_height: 2.5, + across: i % 2 == 0, + roof_colour: (i % 3 == 0).then_some(0x8a4b38), + facade_colour: None, + levels: 4.0, + })); + } + features.push(f); + } + ring_starts.push(points.len() as u32); + Map::from_parts( + Rect::new(0.25, 0.25, 0.5, 0.5), + [0.25, 0.25], + Background::Water, + points, + ring_starts, + features, + 321.0, + details, + vec![1.0, 2.0, 3.0], + vec![[0.0, 0.0, 1.0, 1.0]], + ) +} + +fn temp(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("maps-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) +} + +fn same_arrays(a: &Map, b: &Map) { + fn bytes(s: &[T]) -> &[u8] { + bytemuck::cast_slice(s) + } + assert_eq!(bytes(&a.points), bytes(&b.points)); + assert_eq!(bytes(&a.ring_starts), bytes(&b.ring_starts)); + assert_eq!(bytes(&a.features), bytes(&b.features)); + assert_eq!(bytes(&a.details), bytes(&b.details)); + assert_eq!(bytes(&a.elevations), bytes(&b.elevations)); + assert_eq!(bytes(&a.groups), bytes(&b.groups)); + assert_eq!(a.stats(), b.stats()); + assert_eq!( + (a.bounds, a.origin, a.background, a.max_height), + (b.bounds, b.origin, b.background, b.max_height) + ); +} + +/// `query` agrees with the rstar reference over random boxes and zooms. +fn queries_match(map: &Map, reference: &Reference, mut r: Rng, area: [f32; 4], tries: usize) { + let mut out = Vec::new(); + let (w, h) = (area[2] - area[0], area[3] - area[1]); + for _ in 0..tries { + let (x, y) = (area[0] + r.next() * w, area[1] + r.next() * h); + let size = r.next().powi(3) * w.max(h) * 0.5; + let q = [x, y, x + size, y + size * (0.5 + r.next())]; + // Whole, fractional and out-of-range zooms. + let zoom = match r.below(4) { + 0 => r.below(22) as f32, + 1 => r.next() * 21.0, + 2 => -1.0 + r.next(), + _ => 20.0 + r.next() * 5.0, + }; + map.query(q, zoom, &mut out); + assert_eq!(out, reference.query(map, q, zoom), "{q:?} at z{zoom}"); + } +} + +#[test] +fn convert_matches_save_and_reads_back() { + let map = synthetic(20_000, 0x2545f4914f6cdd1d); + let (v1, saved, converted) = (temp("a.snap1"), temp("b.snap"), temp("c.snap")); + save_v1(&map, &v1).unwrap(); + save(&map, &saved).unwrap(); + convert(&v1, &converted).unwrap(); + // Building straight to v2 and converting from v1 write the same file. + assert_eq!( + std::fs::read(&saved).unwrap(), + std::fs::read(&converted).unwrap() + ); + assert!(convert(&converted, &temp("d.snap")).is_err()); + + let from_v1 = load(&v1).unwrap(); + let from_v2 = load(&converted).unwrap(); + assert!(!from_v1.features.is_mapped() && from_v2.features.is_mapped()); + same_arrays(&map, &from_v1); + same_arrays(&map, &from_v2); + for f in from_v2.features.iter().take(500) { + assert_eq!(from_v2.detail(f), map.detail(f)); + } + let reference = Reference::new(&from_v1); + for m in [&map, &from_v1, &from_v2] { + queries_match(m, &reference, Rng(7), [0.0, 0.0, 1.0, 1.0], 2000); + } +} + +#[test] +fn damaged_files_are_errors() { + let map = synthetic(3000, 99); + let path = temp("damaged.snap"); + save(&map, &path).unwrap(); + let good = std::fs::read(&path).unwrap(); + let try_load = |bytes: &[u8], name: &str| { + let p = temp(name); + std::fs::write(&p, bytes).unwrap(); + load(&p).map(|_| ()) + }; + assert!(try_load(&good, "ok.snap").is_ok()); + assert!(try_load(&good[..good.len() - 4], "short.snap").is_err()); + assert!(try_load(&good[..100], "header.snap").is_err()); + // A section moved off its alignment. + let mut bad = good.clone(); + let at = std::mem::offset_of!(Header, sections) + FEATURES * 16; + bad[at] = bad[at].wrapping_add(4); + assert!(try_load(&bad, "misaligned.snap").is_err()); + // An unknown kind in a v1 file is refused when read or converted. + let v1 = temp("badkind.snap1"); + save_v1(&map, &v1).unwrap(); + let mut b = std::fs::read(&v1).unwrap(); + let features_at = 61 + 8 + map.points.len() * 8 + 8 + map.ring_starts.len() * 4 + 8; + b[features_at] = 255; + assert!(try_load(&b, "badkind2.snap1").is_err()); +} + +/// v1 records, as v1 wrote them field by field, read as v2's `Feature` +/// and `DetailRec`: the conversion copies them byte for byte. +#[test] +fn v1_records_are_v2_records() { + let mut out = vec![Kind::Building as u8, 3, (-2i8) as u8, 1]; + for v in [7u32, 2, 9, u32::MAX, 5] { + out.extend(v.to_le_bytes()); + } + for v in [120.5f32, 4.0, f32::NAN, 13.5, 0.1, 0.2, 0.3, 0.4] { + out.extend(v.to_le_bytes()); + } + let f: Feature = bytemuck::pod_read_unaligned(&out); + assert_eq!( + (f.kind(), f.flags, f.layer, f.variant), + (Kind::Building, 3, -2, 1) + ); + assert_eq!( + (f.ring_start, f.ring_count, f.detail, f.elev, f.group), + (7, 2, 9, u32::MAX, 5) + ); + assert!(f.heading.is_nan() && f.vis_zoom == 13.5 && f.bbox == [0.1, 0.2, 0.3, 0.4]); + + let d = Detail { + roof: RoofShape::Hipped, + roof_height: 3.5, + across: true, + roof_colour: Some(0x8a4b38), + facade_colour: None, + levels: 12.0, + }; + let mut out = vec![d.roof as u8, 1, 1, 0]; + for v in [3.5f32, 12.0] { + out.extend(v.to_le_bytes()); + } + for v in [0x8a4b38u32, 0, 0] { + out.extend(v.to_le_bytes()); + } + assert_eq!(bytemuck::bytes_of(&DetailRec::new(&d)), &out[..]); + assert_eq!(bytemuck::pod_read_unaligned::(&out).get(), d); +} + +#[test] +fn invalid_bytes_read_safely() { + let mut f: Feature = Zeroable::zeroed(); + bytemuck::bytes_of_mut(&mut f)[0] = 250; + assert_eq!(f.kind(), Kind::Land); + let mut d = DetailRec::default(); + bytemuck::bytes_of_mut(&mut d)[..2].copy_from_slice(&[77, 9]); + assert_eq!((d.get().roof, d.get().across), (RoofShape::Flat, true)); + for v in 0..Kind::COUNT as u8 { + assert_eq!(Kind::from_u8(v).unwrap() as u8, v); + } + assert!(Kind::from_u8(Kind::COUNT as u8).is_none()); +} + +#[test] +fn trees_use_node_size() { + let map = synthetic(NODE_SIZE * NODE_SIZE + 3, 5); + let total: usize = map.index.iter().map(|t| t.len()).sum(); + assert_eq!(total, map.features.len()); +} + +/// On a real extract: `MAPS_TEST_PBF=jfk.osm.pbf cargo test --release real_ +/// -- --nocapture`. Queries on v2 match rstar on v1, and tiles from both are +/// byte-identical. Skipped without the variable. +#[test] +fn real_extract_v1_and_v2_agree() { + let Ok(pbf) = std::env::var("MAPS_TEST_PBF") else { + eprintln!("MAPS_TEST_PBF not set; skipped"); + return; + }; + use crate::render::{Buildings, Renderer, Viewport}; + use crate::style::ThemeName; + + let built = Map::build(crate::ingest::read(Path::new(&pbf)).unwrap()); + let (v1, v2) = (temp("real.snap1"), temp("real.snap")); + save_v1(&built, &v1).unwrap(); + drop(built); + convert(&v1, &v2).unwrap(); + let a = load(&v1).unwrap(); + let b = load(&v2).unwrap(); + same_arrays(&a, &b); + + let reference = Reference::new(&a); + let mut extent = [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ]; + for f in a.features.iter() { + extent = [ + extent[0].min(f.bbox[0]), + extent[1].min(f.bbox[1]), + extent[2].max(f.bbox[2]), + extent[3].max(f.bbox[3]), + ]; + } + queries_match(&b, &reference, Rng(0xdeadbeef), extent, 20_000); + eprintln!("20000 random queries agree"); + + // Sample tiles across the extract, zoom 10 to 18. + let mut r = Rng(42); + let (mut tiles, mut drawn) = (0, 0); + let mut ids = Vec::new(); + let mut pixmap = tiny_skia::Pixmap::new(256, 256).unwrap(); + for _ in 0..60 { + let z = 10 + r.below(9) as u8; + let n = (1u64 << z) as f64; + let bx = &a.bounds; + let x = ((bx.min_x + r.next() as f64 * (bx.max_x - bx.min_x)) * n) as u32; + let y = ((bx.min_y + r.next() as f64 * (bx.max_y - bx.min_y)) * n) as u32; + for theme in [ThemeName::Light, ThemeName::Dark] { + let t = theme.theme(); + assert_eq!( + crate::vtile::build( + &a, + t, + z.min(crate::vtile::MAX_ZOOM), + x >> z.saturating_sub(crate::vtile::MAX_ZOOM), + y >> z.saturating_sub(crate::vtile::MAX_ZOOM) + ), + crate::vtile::build( + &b, + t, + z.min(crate::vtile::MAX_ZOOM), + x >> z.saturating_sub(crate::vtile::MAX_ZOOM), + y >> z.saturating_sub(crate::vtile::MAX_ZOOM) + ), + "geo tile {z}/{x}/{y}" + ); + for buildings in [Buildings::Flat, Buildings::Extruded] { + let vp = Viewport::camera_tile(z, x as i64, y as i64, 1.0, 0.0, 0.0); + let mut png = |m: &Map| { + pixmap.fill(tiny_skia::Color::TRANSPARENT); + Renderer::new(m, t, buildings).render_into(&vp, &mut pixmap); + crate::output::encode_png(&pixmap).unwrap() + }; + assert_eq!(png(&a), png(&b), "tile {z}/{x}/{y}"); + tiles += 1; + let (x0, y0) = (x as f64 / n - a.origin[0], y as f64 / n - a.origin[1]); + let q = [ + x0 as f32, + y0 as f32, + (x0 + 1.0 / n) as f32, + (y0 + 1.0 / n) as f32, + ]; + a.query(q, z as f32, &mut ids); + drawn += (ids.len() > 1) as usize; + } + } + } + eprintln!( + "{tiles} raster tiles ({drawn} with features) and {} geo tiles identical", + tiles / 2 + ); + std::fs::remove_file(v1).unwrap(); + std::fs::remove_file(v2).unwrap(); +} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..127ed46 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,47 @@ +//! An array that is either owned (a map built from an extract, or read from +//! a v1 snapshot) or borrowed from a memory-mapped v2 snapshot. + +use std::ops::Deref; + +pub enum Store { + Owned(Vec), + /// Borrowed from a mapping that lives for the rest of the process. + Mapped(&'static [T]), +} + +impl Store { + /// Whether the data is in the mapped file rather than on the heap. + pub fn is_mapped(&self) -> bool { + matches!(self, Store::Mapped(_)) + } +} + +impl Default for Store { + fn default() -> Self { + Store::Owned(Vec::new()) + } +} + +impl Deref for Store { + type Target = [T]; + fn deref(&self) -> &[T] { + match self { + Store::Owned(v) => v, + Store::Mapped(s) => s, + } + } +} + +impl From> for Store { + fn from(v: Vec) -> Self { + Store::Owned(v) + } +} + +impl<'a, T> IntoIterator for &'a Store { + type Item = &'a T; + type IntoIter = std::slice::Iter<'a, T>; + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} diff --git a/src/vtile.rs b/src/vtile.rs index 058652d..81b4647 100644 --- a/src/vtile.rs +++ b/src/vtile.rs @@ -365,7 +365,7 @@ pub fn build(map: &Map, theme: &Theme, z: u8, x: u32, y: u32) -> Vec { let group = feat.group(); match group { Group::Land | Group::Areas | Group::AreaOverlays => { - if let Some(style) = theme.area(feat.kind, &ctx) { + if let Some(style) = theme.area(feat.kind(), &ctx) { fill( &mut b, map, @@ -379,7 +379,7 @@ pub fn build(map: &Map, theme: &Theme, z: u8, x: u32, y: u32) -> Vec { } // Level of detail: solids from z15, objects at full detail only. Group::Objects => { - if feat.kind.is_point() { + if feat.kind().is_point() { if z >= MAX_ZOOM { instance(&mut b, map, &tf, feat); } @@ -663,11 +663,11 @@ fn line_feature( rect: &Rect, tol: f64, ) { - let style = theme.line(feat.kind, feat.flags, feat.height, ctx); + let style = theme.line(feat.kind(), feat.flags, feat.height, ctx); let elev = map.elevations(feat); let group = feat.group(); // Heights: elevation profiles, else a fixed height for raised kinds. - let fixed = match feat.kind { + let fixed = match feat.kind() { Kind::Hedge | Kind::Fence | Kind::Wall | Kind::LowBarrier | Kind::Dam | Kind::Weir => { Some(feat.height as f64) } @@ -713,10 +713,10 @@ fn line_feature( continue; } let raised_kind = matches!( - feat.kind, + feat.kind(), Kind::Hedge | Kind::Fence | Kind::Wall | Kind::LowBarrier | Kind::Dam | Kind::Weir ); - if feat.kind == Kind::TreeRow { + if feat.kind() == Kind::TreeRow { tree_row(b, tf, feat, &piece, dist); continue; } @@ -726,14 +726,26 @@ fn line_feature( _ => piece.clone(), }; if raised_kind && tf.solids { - curtain(b, tf, &top, feat.base as f64, theme.barrier_face(feat.kind)); + curtain( + b, + tf, + &top, + feat.base as f64, + theme.barrier_face(feat.kind()), + ); } - if tf.solids && matches!(feat.kind, Kind::PowerLine | Kind::Aerialway | Kind::Gantry) { - poles(b, tf, &top, theme.wire(feat.kind)); + if tf.solids + && matches!( + feat.kind(), + Kind::PowerLine | Kind::Aerialway | Kind::Gantry + ) + { + poles(b, tf, &top, theme.wire(feat.kind())); } - let bridge = deck_height(feat.kind, feat.layer, feat.flags) > 0.0 - || matches!(feat.kind, Kind::JetBridge | Kind::Pipeline); - if tf.solids && (top.iter().any(|p| p[2] > 0.3) && feat.kind.is_transport() || bridge) { + let bridge = deck_height(feat.kind(), feat.layer, feat.flags) > 0.0 + || matches!(feat.kind(), Kind::JetBridge | Kind::Pipeline); + if tf.solids && (top.iter().any(|p| p[2] > 0.3) && feat.kind().is_transport() || bridge) + { support(b, theme, tf, &top, &style, ctx.ppm, bridge, dist); } // Casings are invisible at a pixel or two wide. @@ -928,9 +940,9 @@ fn instance(b: &mut Buffers, map: &Map, tf: &TileFrame, feat: &Feature) { return; } let conifer = feat.flags & flags::CONIFER != 0; - let tpl = template_id(feat.kind, feat.variant, conifer); + let tpl = template_id(feat.kind(), feat.variant, conifer); let h = (feat.height - feat.base).max(0.1) as f64; - let scale = h / template_height(feat.kind, feat.variant); + let scale = h / template_height(feat.kind(), feat.variant); let heading = if feat.heading.is_finite() { feat.heading as f64 } else { @@ -1020,7 +1032,7 @@ fn solid( } let detail = map.detail(feat); let (mut base, top) = (feat.base, feat.height); - if feat.kind == Kind::Canopy { + if feat.kind() == Kind::Canopy { base = base.max(top - 0.4); } let to_m = |p: [f32; 2]| { @@ -1038,7 +1050,7 @@ fn solid( let eave = roof.as_ref().map_or(top, |r| r.eave); let facade = match detail.facade_colour { Some(c) => theme.mapped_facade(c, 1.0), - None => theme.facade(feat.kind, 1.0), + None => theme.facade(feat.kind(), 1.0), }; let roof_color = detail .roof_colour @@ -1047,7 +1059,7 @@ fn solid( if roof.is_some() { theme.pitched_roof() } else { - theme.roof(feat.kind) + theme.roof(feat.kind()) } }); let floors = if detail.levels >= 1.0 { @@ -1055,7 +1067,7 @@ fn solid( } else { ((eave - base) as f64 / 3.2).round().max(1.0) }; - let window = if feat.kind == Kind::Building && eave - base >= 2.5 { + let window = if feat.kind() == Kind::Building && eave - base >= 2.5 { [ (eave - base) as f64 / floors, base as f64, From b7e70a5579099a57115c05c4abe5e7125b6d5062 Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Sun, 1 Nov 2026 10:42:08 -0500 Subject: [PATCH 2/4] Unmap a v1 snapshot once copied; document MAPSNAP2 and convert --- readme.md | 45 ++++++++++++++++++++++++++++++++------------- src/snapshot.rs | 21 +++++++++++---------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/readme.md b/readme.md index d801bad..3d7d428 100644 --- a/readme.md +++ b/readme.md @@ -168,21 +168,40 @@ The server shuts down gracefully on SIGTERM. Pushes to `main` publish ### Large extracts -Building the map from an extract briefly needs about twice the memory it -serves with. `maps build` does that once and writes a snapshot, which every -command accepts in place of the `.osm.pbf`: +Building the map from an extract needs about twice the memory the map +itself takes. `maps build` does that once and writes a snapshot, which every +command accepts in place of the `.osm.pbf`. A snapshot is memory-mapped and +used in place, spatial index included, so a server is up at once and holds +only the pages tiles touch, which the kernel can drop again: | US Northeast (1.8 GB PBF, 25 M features) | From PBF | From snapshot | | --- | ---: | ---: | -| Load time (M1 Pro) | 42 s | 3.5 s | -| Peak memory | 12 GB | 5.7 GB | - -Ingest keeps the build lean: node coordinates are written once into a -packed 32-bit fixed-point array (about 1 cm), node references become -32-bit indices so the IDs can be freed, raw ways are released as they are -built, and the feature sort is in place. - -The snapshot is 3.8 GB. Serving it uses about 5.7 GB plus the tile cache. +| Ready to serve | 72 s | 0.02 s | +| Peak memory | 10 GB | 13 MB resident after start | +| Anonymous memory after 180 tiles in five cities (Linux) | | 135 MB, plus 1.5 GB of file pages | + +The snapshot is 4.3 GB, of which the index is 0.5 GB (21 bytes a feature). +A tile that touches pages not yet read waits for the disk: in that test +first renders took about twice as long as repeats. + +**Format.** `MAPSNAP2` (`src/snapshot.rs`): a fixed header (bounds, origin, +background, tallest structure, and the offset and length of each section), +then 64-byte-aligned sections: points, ring starts, features (56-byte +records), roof and facade details (24-byte records), elevations, building +groups, per-kind feature counts (so `/v1/kinds` needs no scan) and one packed +Hilbert R-tree per zoom bucket (`src/index.rs`; 16 children a node, `f32` +boxes, `u32` ids). Opening checks the header and every section's bounds and +alignment; kind and roof bytes are read through checked accessors. + +**Older snapshots.** `MAPSNAP1` files still load, copied onto the heap with +the index rebuilt (4.7 GB and 11 s for US Northeast). `maps convert old.snap +new.snap` converts one in seconds without the extract, copying the arrays +and building only the index (1.3 GB peak for US Northeast). + +**Replacing a snapshot.** A server reads the file as it runs, so never +overwrite one in place: write the new file alongside and `mv` it over the +old one (same filesystem), then restart. `maps build` and `maps convert` +write `.partial` and rename it themselves. ## Performance @@ -192,7 +211,7 @@ Measured on an Apple M1 Pro (10 cores), release build. | --- | ---: | ---: | ---: | | JFK airport | 2.3 MB | 59 k | 0.12 s | | Manhattan + Brooklyn | 16 MB | 361 k | 0.34 s | -| New York metro | 184 MB | 3.9 M | 4.5 s, then 1.7 GB resident | +| New York metro | 184 MB | 3.9 M | 4.5 s, then 1.7 GB resident; from a snapshot (590 MB) at once, with a 70 MB footprint after 300 tile requests | Serving the NYC extract (Midtown, 512 px retina tiles at bearing 29° and pitch 45°, 16 concurrent clients): diff --git a/src/snapshot.rs b/src/snapshot.rs index dfd2468..e61113c 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -177,24 +177,24 @@ pub fn save(map: &Map, path: &Path) -> Result<()> { Ok(()) } -/// Maps `path` read-only for the rest of the process. -fn map_file(path: &Path) -> Result<&'static [u8]> { +/// Maps `path` read-only. +fn map_file(path: &Path) -> Result { let file = File::open(path).with_context(|| format!("opening {}", path.display()))?; // SAFETY: the file is opened read-only and is never modified while // mapped: snapshots are replaced by renaming a new file over them, which // leaves this one intact (see the module docs). - let mmap = - unsafe { Mmap::map(&file) }.with_context(|| format!("mapping {}", path.display()))?; - Ok(&Box::leak(Box::new(mmap))[..]) + unsafe { Mmap::map(&file) }.with_context(|| format!("mapping {}", path.display())) } /// Loads a snapshot of either format. pub fn load(path: &Path) -> Result { let t = Instant::now(); - let bytes = map_file(path)?; - let map = match bytes.get(..8) { - Some(m) if m == MAGIC => open_v2(bytes)?, - Some(m) if m == MAGIC_V1 => load_v1(bytes)?, + let mmap = map_file(path)?; + let map = match mmap.get(..8) { + // Borrowed for the rest of the process. + Some(m) if m == MAGIC => open_v2(&Box::leak(Box::new(mmap))[..])?, + // Copied, then unmapped. + Some(m) if m == MAGIC_V1 => load_v1(&mmap)?, _ => bail!("{} is not a map snapshot", path.display()), }; let (heap, mapped) = map.storage(); @@ -393,7 +393,8 @@ fn load_v1(bytes: &[u8]) -> Result { /// counts are computed (about 45 bytes per feature of memory, briefly). pub fn convert(input: &Path, output: &Path) -> Result<()> { let t = Instant::now(); - let bytes = map_file(input)?; + let mmap = map_file(input)?; + let bytes = &mmap[..]; if bytes.get(..8) == Some(MAGIC) { bail!("{} is already a v2 snapshot", input.display()); } From 9356648bb1ff520b6688ca73dd8e417afc715e4b Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Sun, 1 Nov 2026 11:01:33 -0500 Subject: [PATCH 3/4] Refuse damaged snapshots at open; contain render panics Opening a v2 snapshot checks that the index is exactly the shape the builder writes, with every item id in range, and that ring starts are in order and in bounds. Feature records are not scanned (1.4 GB for US Northeast); their ring, detail, elevation and group indices are checked where they are used. Render panics fail their own request, and the rayon pool has a panic handler. write() removes the partial file on failure and syncs the directory after the rename. The v1 loader unmaps its input before building the index. Tests: damaged index and ring starts are refused at open, damaged feature indices render without panicking, NaN zooms are left out of the index, temp dirs are removed, and a v1 snapshot written by bb62e44 (25 KB, with its 18 KB extract) reads, converts and is reproduced byte for byte. --- examples/towers.rs | 2 +- readme.md | 35 +++--- src/api.rs | 5 +- src/index.rs | 24 ++++- src/main.rs | 3 + src/map.rs | 27 +++-- src/render/models.rs | 4 +- src/render/scene.rs | 2 +- src/server.rs | 22 +++- src/snapshot.rs | 72 ++++++++++--- src/snapshot/tests.rs | 241 +++++++++++++++++++++++++++++++++++++----- src/vtile.rs | 2 +- 12 files changed, 368 insertions(+), 71 deletions(-) diff --git a/examples/towers.rs b/examples/towers.rs index 2317c2e..08920bc 100644 --- a/examples/towers.rs +++ b/examples/towers.rs @@ -7,7 +7,7 @@ fn main() { if f.kind() != maps::classify::Kind::Tower { continue; } - let p = map.ring(f.ring_start)[0]; + let p = map.first_point(f); let (lon, lat) = maps::geo::unproject([p[0] as f64 + map.origin[0], p[1] as f64 + map.origin[1]]); let e = seen diff --git a/readme.md b/readme.md index 3d7d428..65073ca 100644 --- a/readme.md +++ b/readme.md @@ -176,8 +176,8 @@ only the pages tiles touch, which the kernel can drop again: | US Northeast (1.8 GB PBF, 25 M features) | From PBF | From snapshot | | --- | ---: | ---: | -| Ready to serve | 72 s | 0.02 s | -| Peak memory | 10 GB | 13 MB resident after start | +| Ready to serve | 72 s | 0.04 s | +| Peak memory | 10 GB | 3 MB, plus 200 MB of file pages read by the checks | | Anonymous memory after 180 tiles in five cities (Linux) | | 135 MB, plus 1.5 GB of file pages | The snapshot is 4.3 GB, of which the index is 0.5 GB (21 bytes a feature). @@ -190,18 +190,29 @@ then 64-byte-aligned sections: points, ring starts, features (56-byte records), roof and facade details (24-byte records), elevations, building groups, per-kind feature counts (so `/v1/kinds` needs no scan) and one packed Hilbert R-tree per zoom bucket (`src/index.rs`; 16 children a node, `f32` -boxes, `u32` ids). Opening checks the header and every section's bounds and -alignment; kind and roof bytes are read through checked accessors. +boxes, `u32` ids). Opening checks the header, every section's bounds and +alignment, that ring starts are in order and end at the last point, and +that the index has exactly the shape the builder writes, with every id in +range. That reads the ring starts and index ids, about 200 MB for US +Northeast (40 ms from the page cache). The 1.4 GB of feature records are not +scanned: their kind and roof bytes go through checked accessors, and their +ring, detail, elevation and group indices are bounds-checked where they are +used, so a damaged feature draws nothing. A render that panics anyway fails +its own request, not the server. **Older snapshots.** `MAPSNAP1` files still load, copied onto the heap with -the index rebuilt (4.7 GB and 11 s for US Northeast). `maps convert old.snap -new.snap` converts one in seconds without the extract, copying the arrays -and building only the index (1.3 GB peak for US Northeast). - -**Replacing a snapshot.** A server reads the file as it runs, so never -overwrite one in place: write the new file alongside and `mv` it over the -old one (same filesystem), then restart. `maps build` and `maps convert` -write `.partial` and rename it themselves. +the index rebuilt (5.3 GB peak, 4.7 GB after loading, for US Northeast). +`maps convert old.snap new.snap` converts one in seconds without the +extract, copying the arrays and building only the index (1.3 GB peak for US +Northeast). + +**Replacing a snapshot.** A running server reads the file in place, so +changing it under the server crashes it (SIGBUS) or serves garbage. Never +`cp` over a snapshot or write into it. Write the new file next to it as +`.partial`, then `mv` it over the old one on the same filesystem (a +rename leaves the old file intact for anyone still mapping it), then +restart. `maps build` and `maps convert` already write `.partial` +and rename it, syncing both the file and its directory. ## Performance diff --git a/src/api.rs b/src/api.rs index 7390df5..bc6dd98 100644 --- a/src/api.rs +++ b/src/api.rs @@ -635,8 +635,9 @@ async fn static_map(State(api): State, Query(q): Query) -> return; } let t = Instant::now(); - let renderer = Renderer::new(map, theme.theme(), buildings); - let png = encode_png(&renderer.render(&vp)); + let png = crate::server::contained(|| { + encode_png(&Renderer::new(map, theme.theme(), buildings).render(&vp)) + }); let _ = tx.send(png.map(|b| (b, t.elapsed()))); }); match rx.await { diff --git a/src/index.rs b/src/index.rs index 372f73d..019f335 100644 --- a/src/index.rs +++ b/src/index.rs @@ -113,12 +113,16 @@ impl Packed { } } - /// A tree from stored arrays, checked enough that queries cannot index - /// out of bounds. + /// A tree from stored arrays, refused unless it is exactly the shape + /// [`Packed::build`] writes: every internal node points at the first of + /// its own children, and every item id is below `items`. A query then + /// stays in bounds and terminates. Reads the whole id array (4 bytes a + /// node), not the boxes. pub fn from_parts( len: usize, boxes: Store<[f32; 4]>, ids: Store, + items: usize, ) -> anyhow::Result { let levels = levels(len); let total = *levels.last().unwrap() as usize; @@ -127,6 +131,22 @@ impl Packed { "index has {} nodes, expected {total}", boxes.len() ); + if let Some(bad) = ids[..len].iter().find(|&&id| id as usize >= items) { + anyhow::bail!("index item {bad} is past the {items} features"); + } + let mut below = 0; + for w in levels.windows(2) { + let (start, end) = (w[0] as usize, w[1] as usize); + for (k, &id) in ids[start..end].iter().enumerate() { + let want = below + k * NODE_SIZE; + anyhow::ensure!( + id as usize == want, + "index node {} points at {id}, expected {want}", + start + k + ); + } + below = start; + } Ok(Packed { len, levels, diff --git a/src/main.rs b/src/main.rs index 5742dd2..bf53276 100644 --- a/src/main.rs +++ b/src/main.rs @@ -160,8 +160,11 @@ fn load(path: &Path) -> Result { fn main() -> Result<()> { let cli = Cli::parse(); // Headroom for deep work-stealing recursion over large tile sets. + // Renders catch their own panics; this keeps any other panic in a + // spawned job from aborting the process. rayon::ThreadPoolBuilder::new() .stack_size(8 << 20) + .panic_handler(|_| eprintln!("a pool job panicked")) .build_global()?; match cli.command { Command::Render { diff --git a/src/map.rs b/src/map.rs index 31ced17..593f107 100644 --- a/src/map.rs +++ b/src/map.rs @@ -742,7 +742,7 @@ impl Map { } let start = f.elev as usize; let n = self.rings(f).map(<[_]>::len).sum::(); - self.elevations.get(start..start + n) + self.elevations.get(start..start.checked_add(n)?) } /// Roof and facade detail of a solid, if mapped. @@ -753,12 +753,25 @@ impl Map { .unwrap_or_default() } + /// Ring `r`; empty if `r` is out of range. Snapshots check ring starts + /// when opened but not every feature's ring range (that would read all + /// of the features), so a damaged feature draws nothing. pub fn ring(&self, r: u32) -> &[[f32; 2]] { - let (a, b) = ( - self.ring_starts[r as usize], - self.ring_starts[r as usize + 1], - ); - &self.points[a as usize..b as usize] + let r = r as usize; + match (self.ring_starts.get(r), self.ring_starts.get(r + 1)) { + (Some(&a), Some(&b)) => self.points.get(a as usize..b as usize).unwrap_or(&[]), + _ => &[], + } + } + + /// A point standing for the feature: the first vertex of its first + /// ring, or its bounding box's corner (the same point, for a point + /// object) if it has none. + pub fn first_point(&self, f: &Feature) -> [f32; 2] { + self.ring(f.ring_start) + .first() + .copied() + .unwrap_or([f.bbox[0], f.bbox[1]]) } /// The ring with the largest area: the outline of a solid. Rings come @@ -774,7 +787,7 @@ impl Map { } pub fn rings(&self, f: &Feature) -> impl Iterator { - (f.ring_start..f.ring_start + f.ring_count).map(|r| self.ring(r)) + (f.ring_start..f.ring_start.saturating_add(f.ring_count)).map(|r| self.ring(r)) } /// Indices of features visible at `zoom` whose bounds intersect `area` diff --git a/src/render/models.rs b/src/render/models.rs index 9f8aa32..776f03f 100644 --- a/src/render/models.rs +++ b/src/render/models.rs @@ -531,7 +531,7 @@ impl Renderer<'_> { /// A point object, drawn from its bespoke model. pub(super) fn draw_prop(&self, pixmap: &mut Pixmap, fr: &Frame, f: &Feature) { - let p = self.map.ring(f.ring_start)[0]; + let p = self.map.first_point(f); let ground = fr.apply(p[0] as f64, p[1] as f64); let h = (f.height - f.base).max(0.1) as f64; // Skip objects that cannot reach the view. @@ -557,7 +557,7 @@ impl Renderer<'_> { let view = fr.rect(8.0); for &id in ids { let f = &self.map.features[id as usize]; - let p = self.map.ring(f.ring_start)[0]; + let p = self.map.first_point(f); let [x, y] = fr.apply(p[0] as f64, p[1] as f64); if !view.contains([x, y]) { continue; diff --git a/src/render/scene.rs b/src/render/scene.rs index 25a4a17..08aeb67 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -79,7 +79,7 @@ impl Scene { for &id in run { let f = &map.features[id as usize]; let (depth, tie) = if f.kind().is_point() { - let p = map.ring(f.ring_start)[0]; + let p = map.first_point(f); (fr.apply(p[0] as f64, p[1] as f64)[1], (id as u64) << 24) } else if let Some(&group) = map.groups.get(f.group as usize) { // Parts of one building sort as a unit, lowest top first, so diff --git a/src/server.rs b/src/server.rs index bfcff43..4dd6a5f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -133,6 +133,19 @@ fn parse_file(s: &str) -> Option<(i64, u8)> { .and(y.parse().ok().map(|y| (y, scale))) } +/// Runs `work`, turning a panic into an error, so a bad tile fails its own +/// request instead of taking the process down. +pub(crate) fn contained(work: impl FnOnce() -> Result) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).unwrap_or_else(|p| { + let msg = p + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| p.downcast_ref::().cloned()) + .unwrap_or_default(); + anyhow::bail!("render panicked: {msg}") + }) +} + thread_local! { /// One reusable render target per worker thread and tile size. static PIXMAP: RefCell> = const { RefCell::new(None) }; @@ -177,7 +190,7 @@ impl App { return; } let t = Instant::now(); - let result = work(&app).map(Bytes::from); + let result = contained(|| work(&app)).map(Bytes::from); app.metrics .render_nanos .fetch_add(t.elapsed().as_nanos() as u64, Ordering::Relaxed); @@ -502,6 +515,13 @@ mod tests { assert_eq!(parse_style("neon"), None); } + #[test] + fn panics_become_errors() { + let e = contained::<()>(|| panic!("boom")).unwrap_err(); + assert!(e.to_string().contains("boom")); + assert_eq!(contained(|| Ok(7)).unwrap(), 7); + } + #[test] fn tile_files_parse() { assert_eq!(parse_file("12.png"), Some((12, 1))); diff --git a/src/snapshot.rs b/src/snapshot.rs index e61113c..76b916b 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -103,12 +103,32 @@ fn index_sections(index: &[Packed]) -> (Vec, Vec<&[u8]>, Vec<&[u8]>) { } /// Writes `parts` to `path` via a `.partial` file renamed into place, so a -/// server mapping the old file never sees it change. +/// server mapping the old file never sees it change. The partial file is +/// removed on failure. fn write(path: &Path, parts: &Parts) -> Result<()> { let mut partial = path.as_os_str().to_owned(); partial.push(".partial"); let partial = PathBuf::from(partial); - let file = File::create(&partial).with_context(|| format!("creating {}", partial.display()))?; + let result = write_partial(&partial, parts).and_then(|()| { + std::fs::rename(&partial, path) + .with_context(|| format!("renaming {} into place", partial.display())) + }); + if result.is_err() { + let _ = std::fs::remove_file(&partial); + return result; + } + // Make the rename itself durable. + let dir = match path.parent() { + Some(d) if !d.as_os_str().is_empty() => d, + _ => Path::new("."), + }; + File::open(dir) + .and_then(|d| d.sync_all()) + .with_context(|| format!("syncing {}", dir.display())) +} + +fn write_partial(partial: &Path, parts: &Parts) -> Result<()> { + let file = File::create(partial).with_context(|| format!("creating {}", partial.display()))?; let mut w = BufWriter::with_capacity(1 << 22, file); w.write_all(&[0u8; HEADER_BYTES])?; let mut pos = HEADER_BYTES; @@ -144,9 +164,6 @@ fn write(path: &Path, parts: &Parts) -> Result<()> { w.write_all(bytemuck::bytes_of(&header))?; let file = w.into_inner().map_err(|e| e.into_error())?; file.sync_all()?; - drop(file); - std::fs::rename(&partial, path) - .with_context(|| format!("renaming {} into place", partial.display()))?; Ok(()) } @@ -194,7 +211,7 @@ pub fn load(path: &Path) -> Result { // Borrowed for the rest of the process. Some(m) if m == MAGIC => open_v2(&Box::leak(Box::new(mmap))[..])?, // Copied, then unmapped. - Some(m) if m == MAGIC_V1 => load_v1(&mmap)?, + Some(m) if m == MAGIC_V1 => load_v1(mmap)?, _ => bail!("{} is not a map snapshot", path.display()), }; let (heap, mapped) = map.storage(); @@ -239,12 +256,19 @@ fn open_v2(bytes: &'static [u8]) -> Result { let boxes: &[[f32; 4]] = section(bytes, &h, INDEX_BOXES)?; let ids: &[u32] = section(bytes, &h, INDEX_IDS)?; + // Ring starts are read whole (4 bytes a ring): in order and ending at + // the last point, every ring is in bounds. Feature records (56 bytes + // each) are not scanned; their indices are checked where they are used. ensure!( ring_starts .last() .is_some_and(|&e| e as usize == points.len()), "ring starts do not end at the last point" ); + ensure!( + ring_starts.windows(2).all(|w| w[0] <= w[1]), + "ring starts are out of order" + ); ensure!( kind_counts.len() <= Kind::COUNT, "snapshot has {} kinds, this build knows {}: rebuild or upgrade", @@ -270,6 +294,7 @@ fn open_v2(bytes: &'static [u8]) -> Result { len as usize, Store::Mapped(&boxes[node..node + nodes]), Store::Mapped(&ids[node..node + nodes]), + features.len(), )?); node += nodes; } @@ -369,22 +394,41 @@ impl V1<'_> { } } -/// Reads a v1 snapshot onto the heap and rebuilds its index. -fn load_v1(bytes: &[u8]) -> Result { - let v1 = parse_v1(bytes)?; +/// Reads a v1 snapshot onto the heap, unmaps it, then rebuilds the index. +fn load_v1(mmap: Mmap) -> Result { + let v1 = parse_v1(&mmap)?; v1.validate()?; + let (bounds, origin, background, max_height) = + (v1.bounds, v1.origin, v1.background, v1.max_height); let [points, ring_starts, features, details, elevations, groups] = v1.arrays; - Ok(Map::from_parts( - v1.bounds, - v1.origin, - v1.background, + let parts = ( bytemuck::pod_collect_to_vec(points), bytemuck::pod_collect_to_vec(ring_starts), bytemuck::pod_collect_to_vec(features), - v1.max_height, bytemuck::pod_collect_to_vec(details), bytemuck::pod_collect_to_vec(elevations), bytemuck::pod_collect_to_vec(groups), + ); + drop(mmap); + let (points, ring_starts, features, details, elevations, groups) = parts; + ensure!( + ring_starts + .last() + .is_some_and(|&e| e as usize == points.len()) + && ring_starts.windows(2).all(|w| w[0] <= w[1]), + "ring starts are out of order or out of bounds" + ); + Ok(Map::from_parts( + bounds, + origin, + background, + points, + ring_starts, + features, + max_height, + details, + elevations, + groups, )) } diff --git a/src/snapshot/tests.rs b/src/snapshot/tests.rs index 54c11e4..5206878 100644 --- a/src/snapshot/tests.rs +++ b/src/snapshot/tests.rs @@ -73,7 +73,12 @@ fn synthetic(n: usize, seed: u64) -> Map { f.elev = u32::MAX; f.group = u32::MAX; f.heading = f32::NAN; - f.vis_zoom = r.next() * 23.0 - 1.0; + // Never visible: left out of the index, filtered by the reference. + f.vis_zoom = if i % 97 == 0 { + f32::NAN + } else { + r.next() * 23.0 - 1.0 + }; f.bbox = [x, y, x + size, y + size]; if i % 7 == 0 { f.detail = details.len() as u32; @@ -103,10 +108,22 @@ fn synthetic(n: usize, seed: u64) -> Map { ) } -fn temp(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("maps-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - dir.join(name) +/// A directory for one test's files, removed when the test ends. +struct Temp(PathBuf); +impl Temp { + fn new(test: &str) -> Self { + let dir = std::env::temp_dir().join(format!("maps-test-{}-{test}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + Temp(dir) + } + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } } fn same_arrays(a: &Map, b: &Map) { @@ -126,6 +143,26 @@ fn same_arrays(a: &Map, b: &Map) { ); } +/// The bounding box of every feature, in local coordinates. +fn extent(map: &Map) -> [f32; 4] { + map.features.iter().fold( + [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ], + |e, f| { + [ + e[0].min(f.bbox[0]), + e[1].min(f.bbox[1]), + e[2].max(f.bbox[2]), + e[3].max(f.bbox[3]), + ] + }, + ) +} + /// `query` agrees with the rstar reference over random boxes and zooms. fn queries_match(map: &Map, reference: &Reference, mut r: Rng, area: [f32; 4], tries: usize) { let mut out = Vec::new(); @@ -148,8 +185,9 @@ fn queries_match(map: &Map, reference: &Reference, mut r: Rng, area: [f32; 4], t #[test] fn convert_matches_save_and_reads_back() { + let tmp = Temp::new("convert_matches_save_and_reads_back"); let map = synthetic(20_000, 0x2545f4914f6cdd1d); - let (v1, saved, converted) = (temp("a.snap1"), temp("b.snap"), temp("c.snap")); + let (v1, saved, converted) = (tmp.path("a.snap1"), tmp.path("b.snap"), tmp.path("c.snap")); save_v1(&map, &v1).unwrap(); save(&map, &saved).unwrap(); convert(&v1, &converted).unwrap(); @@ -158,7 +196,7 @@ fn convert_matches_save_and_reads_back() { std::fs::read(&saved).unwrap(), std::fs::read(&converted).unwrap() ); - assert!(convert(&converted, &temp("d.snap")).is_err()); + assert!(convert(&converted, &tmp.path("d.snap")).is_err()); let from_v1 = load(&v1).unwrap(); let from_v2 = load(&converted).unwrap(); @@ -176,12 +214,13 @@ fn convert_matches_save_and_reads_back() { #[test] fn damaged_files_are_errors() { + let tmp = Temp::new("damaged_files_are_errors"); let map = synthetic(3000, 99); - let path = temp("damaged.snap"); + let path = tmp.path("damaged.snap"); save(&map, &path).unwrap(); let good = std::fs::read(&path).unwrap(); let try_load = |bytes: &[u8], name: &str| { - let p = temp(name); + let p = tmp.path(name); std::fs::write(&p, bytes).unwrap(); load(&p).map(|_| ()) }; @@ -194,16 +233,174 @@ fn damaged_files_are_errors() { bad[at] = bad[at].wrapping_add(4); assert!(try_load(&bad, "misaligned.snap").is_err()); // An unknown kind in a v1 file is refused when read or converted. - let v1 = temp("badkind.snap1"); + let v1 = tmp.path("badkind.snap1"); save_v1(&map, &v1).unwrap(); let mut b = std::fs::read(&v1).unwrap(); let features_at = 61 + 8 + map.points.len() * 8 + 8 + map.ring_starts.len() * 4 + 8; b[features_at] = 255; assert!(try_load(&b, "badkind2.snap1").is_err()); + + // Damaged index and ring starts are refused at open, not at query time. + let h: Header = bytemuck::pod_read_unaligned(&good[..std::mem::size_of::
()]); + let u32_at = |s: usize, i: usize| h.sections[s][0] as usize + 4 * i; + let put = |bytes: &mut Vec, at: usize, v: u32| { + bytes[at..at + 4].copy_from_slice(&v.to_le_bytes()); + }; + let lens: Vec = (0..=MAX_BUCKET) + .map(|b| { + let at = h.sections[INDEX_LENS][0] as usize + 8 * b; + u64::from_le_bytes(good[at..at + 8].try_into().unwrap()) + }) + .collect(); + // The first internal node of the first bucket that has one. + let mut node = 0; + let mut internal = None; + for &len in &lens { + let nodes = *index::levels(len as usize).last().unwrap() as usize; + if len as usize > NODE_SIZE { + internal = Some((node, node + len as usize)); + break; + } + node += nodes; + } + let (leaf, internal) = internal.expect("a bucket with internal nodes"); + let n = map.features.len() as u32; + for (what, at, v, error) in [ + ( + "leaf id past the features", + u32_at(INDEX_IDS, leaf), + n, + "past the", + ), + ( + "internal id past the end", + u32_at(INDEX_IDS, internal), + u32::MAX, + "points at", + ), + ( + "internal node pointing at itself", + u32_at(INDEX_IDS, internal), + (internal - node) as u32, + "points at", + ), + ( + "ring start past the points", + u32_at(RING_STARTS, 1), + u32::MAX, + "ring starts", + ), + ] { + let mut bad = good.clone(); + put(&mut bad, at, v); + match try_load(&bad, "bad.snap") { + Ok(()) => panic!("{what} was accepted"), + Err(e) => assert!(format!("{e:#}").contains(error), "{what}: {e:#}"), + } + } } /// v1 records, as v1 wrote them field by field, read as v2's `Feature` /// and `DetailRec`: the conversion copies them byte for byte. +/// A v1 snapshot written by the last v1 build (bb62e44) from +/// `times-square.osm.pbf` (a block of Midtown; data © OpenStreetMap +/// contributors, ODbL). Reads with the counts that build's `maps info` +/// printed, converts to the same arrays, and this build's v1 writer +/// reproduces it byte for byte from the same extract. +#[test] +fn old_v1_snapshot_reads_and_converts() { + let tmp = Temp::new("old_v1_snapshot_reads_and_converts"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let v1 = fixture.join("times-square.v1.snap"); + let old = load(&v1).unwrap(); + assert_eq!((old.features.len(), old.points.len()), (195, 1454)); + let names: Vec<(String, usize)> = old + .stats() + .into_iter() + .map(|(k, n)| (format!("{k:?}"), n)) + .collect(); + let want = [ + ("Building", 89), + ("Footway", 42), + ("Kerb", 17), + ("Subway", 11), + ("TrafficSignal", 10), + ("Secondary", 9), + ("PedestrianStreet", 3), + ("Minor", 3), + ("Bench", 3), + ("WasteBasket", 3), + ("PedestrianArea", 2), + ("Service", 2), + ("BicycleParking", 1), + ]; + let mut got = names.clone(); + let mut want: Vec<(String, usize)> = want.iter().map(|&(k, n)| (k.to_string(), n)).collect(); + got.sort(); + want.sort(); + assert_eq!(got, want); + + let v2 = tmp.path("times-square.snap"); + convert(&v1, &v2).unwrap(); + let new = load(&v2).unwrap(); + same_arrays(&old, &new); + let reference = Reference::new(&old); + queries_match(&new, &reference, Rng(3), extent(&old), 2000); + + let built = Map::build(crate::ingest::read(&fixture.join("times-square.osm.pbf")).unwrap()); + let rewritten = tmp.path("rewritten.snap1"); + save_v1(&built, &rewritten).unwrap(); + assert_eq!( + std::fs::read(&rewritten).unwrap(), + std::fs::read(&v1).unwrap() + ); +} + +/// Feature records are not scanned at open, so out-of-range ring, detail, +/// elevation and group indices must draw nothing rather than panic. +#[test] +fn damaged_features_render_without_panicking() { + use crate::render::{Buildings, Renderer, Viewport}; + let tmp = Temp::new("damaged_features_render_without_panicking"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let v2 = tmp.path("ts.snap"); + convert(&fixture.join("times-square.v1.snap"), &v2).unwrap(); + let mut bytes = std::fs::read(&v2).unwrap(); + let h: Header = bytemuck::pod_read_unaligned(&bytes[..std::mem::size_of::
()]); + let [at, n] = h.sections[FEATURES]; + for i in 0..n as usize { + let f = at as usize + 56 * i; + // ring_start, ring_count, detail, elev, group + let v = [u32::MAX - 1, 5, 1 << 30, u32::MAX - 2, 1 << 30]; + for (k, v) in v.iter().enumerate() { + bytes[f + 4 + 4 * k..f + 8 + 4 * k].copy_from_slice(&v.to_le_bytes()); + } + } + std::fs::write(&v2, &bytes).unwrap(); + let map = load(&v2).unwrap(); + let b = map.bounds; + for z in [14u8, 16, 18] { + let n = (1u64 << z) as f64; + let x = (b.center()[0] * n) as u32; + let y = (b.center()[1] * n) as u32; + for theme in [ + crate::style::ThemeName::Light, + crate::style::ThemeName::Dark, + ] { + let t = theme.theme(); + for buildings in [Buildings::Flat, Buildings::Extruded] { + for pitch in [0.0, 45.0] { + let vp = Viewport::camera_tile(z, x as i64, y as i64, 1.0, 20.0, pitch); + Renderer::new(&map, t, buildings).render(&vp); + } + } + let gz = z.min(crate::vtile::MAX_ZOOM); + let shift = z - gz; + crate::vtile::build(&map, t, gz, x >> shift, y >> shift); + } + } +} + #[test] fn v1_records_are_v2_records() { let mut out = vec![Kind::Building as u8, 3, (-2i8) as u8, 1]; @@ -261,7 +458,9 @@ fn invalid_bytes_read_safely() { fn trees_use_node_size() { let map = synthetic(NODE_SIZE * NODE_SIZE + 3, 5); let total: usize = map.index.iter().map(|t| t.len()).sum(); - assert_eq!(total, map.features.len()); + let visible = map.features.iter().filter(|f| !f.vis_zoom.is_nan()).count(); + assert!(visible < map.features.len()); + assert_eq!(total, visible); } /// On a real extract: `MAPS_TEST_PBF=jfk.osm.pbf cargo test --release real_ @@ -269,6 +468,7 @@ fn trees_use_node_size() { /// byte-identical. Skipped without the variable. #[test] fn real_extract_v1_and_v2_agree() { + let tmp = Temp::new("real_extract_v1_and_v2_agree"); let Ok(pbf) = std::env::var("MAPS_TEST_PBF") else { eprintln!("MAPS_TEST_PBF not set; skipped"); return; @@ -277,7 +477,7 @@ fn real_extract_v1_and_v2_agree() { use crate::style::ThemeName; let built = Map::build(crate::ingest::read(Path::new(&pbf)).unwrap()); - let (v1, v2) = (temp("real.snap1"), temp("real.snap")); + let (v1, v2) = (tmp.path("real.snap1"), tmp.path("real.snap")); save_v1(&built, &v1).unwrap(); drop(built); convert(&v1, &v2).unwrap(); @@ -286,20 +486,7 @@ fn real_extract_v1_and_v2_agree() { same_arrays(&a, &b); let reference = Reference::new(&a); - let mut extent = [ - f32::INFINITY, - f32::INFINITY, - f32::NEG_INFINITY, - f32::NEG_INFINITY, - ]; - for f in a.features.iter() { - extent = [ - extent[0].min(f.bbox[0]), - extent[1].min(f.bbox[1]), - extent[2].max(f.bbox[2]), - extent[3].max(f.bbox[3]), - ]; - } + let extent = extent(&a); queries_match(&b, &reference, Rng(0xdeadbeef), extent, 20_000); eprintln!("20000 random queries agree"); @@ -358,6 +545,4 @@ fn real_extract_v1_and_v2_agree() { "{tiles} raster tiles ({drawn} with features) and {} geo tiles identical", tiles / 2 ); - std::fs::remove_file(v1).unwrap(); - std::fs::remove_file(v2).unwrap(); } diff --git a/src/vtile.rs b/src/vtile.rs index 81b4647..83d55ba 100644 --- a/src/vtile.rs +++ b/src/vtile.rs @@ -935,7 +935,7 @@ fn tree_row(b: &mut Buffers, tf: &TileFrame, feat: &Feature, pts: &[[f64; 3]], d /// A point object as an instance, if its anchor lies in this tile. fn instance(b: &mut Buffers, map: &Map, tf: &TileFrame, feat: &Feature) { - let p = tf.uv(map.ring(feat.ring_start)[0]); + let p = tf.uv(map.first_point(feat)); if !(0.0..1.0).contains(&p[0]) || !(0.0..1.0).contains(&p[1]) { return; } From 46d1638d4e9ac534217932f79b326b46dd8b0bf4 Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Sun, 1 Nov 2026 11:01:47 -0500 Subject: [PATCH 4/4] Track the test fixtures --- .gitignore | 1 + tests/fixtures/times-square.osm.pbf | Bin 0 -> 18010 bytes tests/fixtures/times-square.v1.snap | Bin 0 -> 25845 bytes 3 files changed, 1 insertion(+) create mode 100644 tests/fixtures/times-square.osm.pbf create mode 100644 tests/fixtures/times-square.v1.snap diff --git a/.gitignore b/.gitignore index 8ec9098..4c7c547 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .DS_Store .vscode *.snap +!/tests/fixtures/* diff --git a/tests/fixtures/times-square.osm.pbf b/tests/fixtures/times-square.osm.pbf new file mode 100644 index 0000000000000000000000000000000000000000..034e138288a50a4c018a963c5f54176ab45248c6 GIT binary patch literal 18010 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 literal 0 HcmV?d00001