The geo/map subsystem — guide
Class:
guide· audience: a techie building or driving a facett map component. The what is inmap-design.md; the why inmap-reasoning.md. This file is the workflow.
f0 — zero to a rendered map (the whole workflow)
- Get geometry. Either build it in code (a
Vec<(f64,f64)>of points,facett_map::Way::line/Way::filledfor a synthetic basemap) or ingest real OSM GeoParquet viafacett_osm::read_points/read_ways(f3). - Pick a viewer by what you need: a static fit-to-rect scatter →
facett_map::MapView(f1); a scatter with pan/zoom + density hotspots →facett_geomap::GeoMap(f2); a full slippy map (spatial index, LOD, per-OS nav) →facett_geomap::osm2d::OsmView(f2). - Feed it geometry at construction (
MapView::new/with_ways,GeoMap::new/with_ways,OsmView::new). - Drive it. Live: it's a
Facet— drop it into aFacetDeckand gestures produceMsgs automatically. Headless:facett_core::harness::{drive, snapshot, headless_render}feed aVec<Msg>and assert on the resulting Model /state_json— no window, no GPU. - Opt into the GPU fast-path (optional) only once real geometry proves out on the CPU path — see
f4.
f1 — MapView
use facett_map::MapView;
// Point-only (today's original behaviour).
let map = MapView::new("map", vec![(11.0, 57.0), (18.0, 59.0)]);
// With a vector basemap (ways drawn under the points).
let ways = facett_osm::read_ways("ways.parquet", 0 /*=unlimited*/, &facett_core::Theme::sci_fi())?;
let map = MapView::new("map", points).with_ways(ways);
Drive it headlessly:
use facett_core::harness;
use facett_map::Msg;
let mut v = MapView::new("li", points).with_ways(ways);
let snap = harness::snapshot(&mut v, [Msg::ZoomBy(500.0), Msg::Select(22)]);
assert_eq!(snap.selected, Some(22));
let r = harness::headless_render(&mut v);
assert!(r.drew());
assert_eq!(r.state["ways"], 3);
MapView::fit() / MapView::select(Some(id)) / select(None) are thin wrappers over update for when you don't want to build a Msg by hand.
f2 — GeoMap and OsmView
GeoMap — pan/zoom scatter + hotspots:
use facett_geomap::{GeoMap, Hotspot};
let map = GeoMap::new(
"Sweden",
vec![(18.07, 59.33), (13.00, 55.60)], // points (lon, lat)
vec![Hotspot::new(13.00, 55.60, 320, "Malmö")], // hotspots
);
// map.zoom_by(2.0); map.pan_unit(0.01, 0.0); // host/headless interaction
let map = map.with_ways(some_ways); // optional basemap; re-fits
Or feed it a bundled boundary instead of a file:
use facett_geomap::load_sweden_admin;
let b = load_sweden_admin()?;
let map = GeoMap::new("Sverige", b.centers(), vec![]).with_ways(b.to_ways());
OsmView — the full slippy map, for real (large) OSM data:
use facett_geomap::OsmView;
let points = facett_osm::read_points("nodes.parquet", 0)?;
let ways = facett_osm::read_ways("ways.parquet", 0, &theme)?;
let view = OsmView::from_geometry("OSM 2D", points, ways);
Both are driven the same headless way as MapView (harness::drive/snapshot/headless_render), plus their own navigation helpers for a host without an egui input stream: OsmView::zoom_to_cursor, pan_pixels, fit_to, flick_pan + tick_pan_momentum (deterministic under an injected dt, so a headless test can assert an inertial glide step by step).
f3 — osm ingest
use facett_osm::{read_points, read_ways};
use facett_core::Theme;
let points = read_points("nodes.parquet", 1_000_000)?; // (lon, lat), capped
let ways = read_ways("ways.parquet", 0, &Theme::sci_fi())?; // 0 = unlimited
In-memory variants (no filesystem — the wasm demo include_bytes!s a clipped region into the binary):
let points = facett_osm::read_points_bytes(include_bytes!("nodes.parquet").as_slice(), 0)?;
let ways = facett_osm::read_ways_bytes(include_bytes!("ways.parquet").as_slice(), 0, &theme)?;
decode_wkb_coords(&[u8]) -> Vec<(f64,f64)> decodes one raw WKB geometry's vertices directly, if you have a blob outside a parquet reader.
f4 — enabling the wgpu fast-path
The CPU painter is always on and needs no setup. To add the GPU line-ways lane:
// eframe creation closure, once:
if let Some(rs) = cc.wgpu_render_state.as_ref() {
facett_map::gpu::install_renderer(rs);
}
# facett-map consumer:
facett-map = { version = "...", features = ["wgpu"] }
# facett-geomap (OsmView) consumer:
facett-geomap = { version = "...", features = ["map-wgpu"] }
let view = MapView::new("map", points).with_ways(ways).with_gpu(true);
// or: OsmView::from_geometry("OSM 2D", points, ways).with_gpu(true);
eframe must run its wgpu backend feature. Without install_renderer having been called, with_gpu(true) still works — the paint callback no-ops and the CPU line painter draws the basemap. Filled silhouettes always draw on the CPU regardless of this setting.
f5 — the region picker (facett-demo)
facett-demo's 🌍 Region button demonstrates the whole pipeline end to end: clip a region out of an already-cached OSM planet file and load it straight into the OSM 2D/OSM 3D tabs.
🌍 Region button ─▶ modal (Sverige / Skandinavien / Europa)
│ pick(preset)
▼
background thread
│
├─ cache_hit(out)? ──yes──▶ skip the clip ─┐
│ │
└─no─▶ planet_source() (cached, never re-downloaded)
│ build_clip(preset) → osm-katana Clip::Poly (Sverige) or Clip::Bbox (others)
│ osm_katana::convert(…) IN-PROCESS (no subprocess)
▼ │
osm_tabs::load_from(out/nodes.parquet, out/ways.parquet) ◀┘
│ Status::Loading → Msg::Loaded(geo)
▼
UI thread: RegionPicker::poll() → Status::Ready → take_loaded()
│
▼
DemoState swaps the loaded geometry into the "OSM 2D"/"OSM 3D" deck slots
Preset → clip mapping (facett-demo/src/region_picker.rs, Preset::clip_arg / Preset::clip_kind):
| modal label | Preset |
clip kind | cache dir |
| Sverige | Preset::Sverige |
Clip::Poly from the bundled assets/sweden.poly (the real country outline) |
~/.cache/facett-osm/sweden-geo |
| Skandinavien | Preset::Skandinavien |
Clip::Bbox("nordics") |
~/.cache/facett-osm/nordics-geo |
| Europa | Preset::Europa |
Clip::Bbox("europe") |
~/.cache/facett-osm/europe-geo |
The clip runs in-process (region_picker::build_clip, gated on feature region-clip) by calling osm-katana's convert() library API directly on a background thread — no subprocess, no re-download: planet_source() only ever looks at an already-cached .osm.bz2 on /home (fast disk) or /run/media/.../T9 (fallback). cache_hit(dir) (both nodes.parquet and ways.parquet present) short-circuits straight to loading. Everything lands in RegionPicker::state_json()["region_picker"] — region, clip_arg, clip_kind, source, out, cache_hit, status (idle|clipping|loading|ready|error) — so the pipeline is testable as data, not pixels.
Manual run:
cargo run -p facett-demo # click 🌍 Region → pick Sverige/Skandinavien/Europa
# needs: the cached planet on /home, built with default features (region-clip on)
f6 — sharing a way slab, and plugging in a baked-vertex store
- A host that keeps its classified ways and rebuilds the facet per apply: lay the ways out once (
Arc<[Way]>, class-sorted) and pushWaysLayer::shared(id, Arc::clone(&slab), range)per class — no copies.WaysLayer::new(id, vec)still works and owns its slab. - A host with local storage: implement
facett_map::gpu::bake::BakeStore(get(key) -> Option<Vec<u8>>,put(key, blob)) andinstall_bake_store(Some(store))once at startup; the tile lane then decodes blobs instead of baking on every VRAM miss. Invalidate the store yourself when your DATA changes (the key does not carry a data version); a format change or a corrupt blob is a miss by construction.bake_counters()gives(hits, misses, corrupt)for your About line.
f7 — the paint order, and the one rule you must not break
Everything that paints a Way sorts on facett_map::paint_order::way_paint_key. The rank is
rank = effective_layer * LAYER_STEP + band + class + tier LAYER_STEP = 100_000
effective_layer comes from the OSM layer / bridge / tunnel tags, and it dominates everything else by 100 000 per step. That is the rule people get wrong:
A band constant is not a floor. If you want something under every way, it has to sit under
paint_order::WAY_RANK_FLOOR(-LAYER_CLAMP * LAYER_STEP + AREA_BAND= -800 000), not merely underAREA_BAND.
LANDMASS_BAND is WAY_RANK_FLOOR - LAYER_STEP for exactly this reason, with a const assertion holding it there. If you add a new base band (an aerial raster, a hillshade), derive it from WAY_RANK_FLOOR the same way — never as AREA_BAND - something.
The second thing to know is that the GPU callback is one opaque slot. MapFacet::ui puts it at the lane's own rank floor and paints the CPU features below that rank first, so a CPU-only feature whose rank sits inside the lane's span lands above everything the lane drew. In practice that is only the landmass, points and labels — points and labels belong on top anyway.
Run the two guards:
cargo test -p facett-map --lib paint_order
env -u WAYLAND_DISPLAY -u DISPLAY FACETT_TEST_GPU=discrete \
cargo test -p facett-map --features wgpu --test gpu_lane_paint_order -- --nocapture
The second one needs a real device and saves its framebuffers to /home/rickard/scratch/cargo/gpu_lane_*.png — look at them, that is the point of the file.
Loading a source that is not OSM (Trafikverket, NVDB, a survey export)
Point facett_osm::read_ways_with at it. If the parquet has a geometry column and no tags column, every row draws — in the magenta overprint ink (MapPalette::overlay) at Lod::Region, WayKind::Overprint. Nothing to configure and no flag to pass: the schema is the signal.
let pal = facett_map::MapPalette::cartographic(true);
let ways = facett_osm::read_ways_with("/data/trafikverket/roads.parquet", 20_000, &pal)?;
// every way: ways[i].style.kind == WayKind::Overprint, ways[i].stroke == pal.overlay
Two things this is not:
- It is not a relaxation of the untagged-way rule. A row whose tags are
{}inside a parquet that HAS atagscolumn still draws nothing — that row is a boundary relation's member geometry and lane SI is right about it. The distinction is the SCHEMA, not the row. - It is not
road_generic. That ink is the one that was painting hairlines across open water, and it sits at1.24:1against the day paper — you would not see it if it were.
If your overlay draws nothing, check the schema first:
python3 -c "import pyarrow.parquet as pq; print(pq.read_schema('roads.parquet'))"
A tags column of a type this reader cannot read as UTF-8 counts as tagless and the whole file paints as an overlay. That is deliberate — loud beats silent — but if the whole file went magenta and you expected a classified basemap, that is the reason.
To see the count on a deck, read OsmView::state_json()["overprint_ways"]. It is 0 for every OSM cut.
Run the guards:
cargo test -p facett-map --lib the_overprint_ink_is_nobody_elses_ink
cargo test -p facett-map --lib the_three_undecided_classes_are_decided
cargo test -p facett-osm --lib a_tagless_source_draws_and_an_untagged_way_in_a_tagged_cut_does_not
cargo test -p facett-demo --lib a_tagless_ways_source_reaches_the_deck_as_overprint
…and the frames, in korp:
cargo test --test map_render_truth -- --nocapture the_three_undecided a_tagless_source
# frames land in /home/rickard/scratch/tl-verdicts/