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/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..08920bc 100644 --- a/examples/towers.rs +++ b/examples/towers.rs @@ -4,10 +4,10 @@ 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]; + 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 fdeafc6..65073ca 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,28 +161,58 @@ 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-`). ### 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.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). +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, 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 (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 @@ -189,7 +222,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): @@ -226,8 +259,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..bc6dd98 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]); @@ -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/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..019f335 --- /dev/null +++ b/src/index.rs @@ -0,0 +1,328 @@ +//! 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, 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; + anyhow::ensure!( + boxes.len() == total && ids.len() == total, + "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, + 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..bf53276 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")] @@ -152,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 { @@ -212,6 +223,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..593f107 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, } } @@ -666,23 +742,36 @@ 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. pub fn detail(&self, f: &Feature) -> Detail { self.details .get(f.detail as usize) - .copied() + .map(DetailRec::get) .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 @@ -698,45 +787,103 @@ 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` /// (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..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. @@ -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()); } @@ -557,13 +557,13 @@ 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; } - 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..08aeb67 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -78,8 +78,8 @@ 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 p = map.ring(f.ring_start)[0]; + let (depth, tie) = if f.kind().is_point() { + 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 @@ -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..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); @@ -363,6 +376,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 +390,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 +403,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) } @@ -497,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 8606968..76b916b 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -1,189 +1,487 @@ -//! 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. 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 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 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[..])) +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; + 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], }; - r.read_exact(bytes)?; - Ok(v) + 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()?; + 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) }) +/// 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, + ], + }; + write(path, &parts)?; + eprintln!("saved {} in {:.2?}", path.display(), t.elapsed()); + Ok(()) } -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}"), - }) +/// 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). + unsafe { Mmap::map(&file) }.with_context(|| format!("mapping {}", path.display())) } -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()); - } +/// Loads a snapshot of either format. +pub fn load(path: &Path) -> Result { + let t = Instant::now(); + 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(); + 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) +} + +/// 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)?; + + // 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", + 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]), + features.len(), + )?); + 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, 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; + let parts = ( + bytemuck::pod_collect_to_vec(points), + bytemuck::pod_collect_to_vec(ring_starts), + bytemuck::pod_collect_to_vec(features), + 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, + )) +} + +/// 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 mmap = map_file(input)?; + let bytes = &mmap[..]; + 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 +495,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..5206878 --- /dev/null +++ b/src/snapshot/tests.rs @@ -0,0 +1,548 @@ +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; + // 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; + 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]], + ) +} + +/// 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) { + 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) + ); +} + +/// 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(); + 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 tmp = Temp::new("convert_matches_save_and_reads_back"); + let map = synthetic(20_000, 0x2545f4914f6cdd1d); + 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(); + // 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, &tmp.path("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 tmp = Temp::new("damaged_files_are_errors"); + let map = synthetic(3000, 99); + 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 = tmp.path(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 = 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]; + 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(); + 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_ +/// -- --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 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; + }; + use crate::render::{Buildings, Renderer, Viewport}; + use crate::style::ThemeName; + + let built = Map::build(crate::ingest::read(Path::new(&pbf)).unwrap()); + let (v1, v2) = (tmp.path("real.snap1"), tmp.path("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 extent = extent(&a); + 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 + ); +} 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..83d55ba 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. @@ -923,14 +935,14 @@ 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; } 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, diff --git a/tests/fixtures/times-square.osm.pbf b/tests/fixtures/times-square.osm.pbf new file mode 100644 index 0000000..034e138 Binary files /dev/null and b/tests/fixtures/times-square.osm.pbf differ diff --git a/tests/fixtures/times-square.v1.snap b/tests/fixtures/times-square.v1.snap new file mode 100644 index 0000000..faae4f6 Binary files /dev/null and b/tests/fixtures/times-square.v1.snap differ