facett-map3d/src/korp.rs
//! **korp connector seam** — the wiring point where a *korp* connector would supply
//! street + feature layers to the 3D map.
//!
//! `korp/.nornir` is **empty today**, so this is a clearly-marked SEAM + `TODO`, not
//! a live connector. The 3D map already loads streets + layered features from OSM
//! (`facett_osm` reads `ways.parquet` → [`facett_map::Way`]s → [`crate::extrude`]),
//! so this module does two things:
//!
//! 1. defines the connector-agnostic boundary type [`FeatureLayers`] — OSM-style
//! `Way`s grouped by cartographic layer (buildings / streets / landuse / water);
//! 2. provides [`load_layers`], the single function a korp connector plugs into: it
//! classifies incoming OSM ways into layers today, and is the ONE body that swaps
//! to a real `korp::connect(region)` call later. **Nothing downstream changes** —
//! the extruder, the per-layer z-ordering ([`crate::extrude::GroundLayer`]), and
//! the render backend all consume the same flat `&[Way]`.
use facett_map::Way;
use crate::extrude::{Surface, surface_of};
use crate::Map3D;
/// A **layered feature bundle** the OSM loader (or, later, a korp connector) hands to
/// the 3D map: OSM-style [`Way`]s grouped by cartographic layer. Every layer is a
/// `Way` list — the extruder classifies each way by its OSM tag — so this is a thin,
/// connector-agnostic envelope over the existing pipeline, not a new geometry format.
#[derive(Clone, Debug, Default)]
pub struct FeatureLayers {
/// Building footprints (closed rings → extruded prisms).
pub buildings: Vec<Way>,
/// Street / path / rail centrelines (line ways → ground ribbons, the Road layer).
pub streets: Vec<Way>,
/// Landuse / park / natural area fills (closed rings → flat ground Cover).
pub landuse: Vec<Way>,
/// Water bodies (`natural=water` / coastline → the reflective water grid).
pub water: Vec<Way>,
}
impl FeatureLayers {
/// Classify a flat OSM `Way` list into cartographic layers — the SAME
/// classification the extruder uses (`surface_of` for water, closed-vs-line for
/// streets, `building` tag for buildings), so grouping loses nothing and a korp
/// connector can later supply each layer independently.
#[must_use]
pub fn classify(ways: &[Way]) -> Self {
let mut fl = FeatureLayers::default();
for w in ways {
if surface_of(w.kind) == Surface::Water {
fl.water.push(w.clone());
} else if w.kind == "building" {
fl.buildings.push(w.clone());
} else if is_street(w) {
fl.streets.push(w.clone());
} else {
fl.landuse.push(w.clone());
}
}
fl
}
/// Total way count across all layers.
#[must_use]
pub fn len(&self) -> usize {
self.buildings.len() + self.streets.len() + self.landuse.len() + self.water.len()
}
/// Whether every layer is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Flatten all layers into ONE `Way` list in **cartographic draw order** —
/// landuse cover → water → streets → buildings. The extruder's per-layer z-lift
/// ([`crate::extrude::GroundLayer`]) then makes that order depth-correct on the
/// GPU depth buffer, so streets sit above the landuse they cross and buildings
/// rise over both. The layer identity survives the flatten (it lives in each
/// way's OSM tag), so the 3D map consumes a plain `&[Way]` with no loss.
#[must_use]
pub fn flatten(&self) -> Vec<Way> {
let mut out = Vec::with_capacity(self.len());
out.extend(self.landuse.iter().cloned());
out.extend(self.water.iter().cloned());
out.extend(self.streets.iter().cloned());
out.extend(self.buildings.iter().cloned());
out
}
/// Build a navigable [`Map3D`] from these layers — the real-data entry a host /
/// the demo's OSM-3D tab calls. Extrudes buildings to prisms, drapes streets +
/// landuse as depth-ordered ground layers, and routes water to the reflective
/// grid, then frames the densest town.
#[must_use]
pub fn into_map3d(&self, title: impl Into<String>) -> Map3D {
Map3D::from_geometry(title, &self.flatten())
}
}
/// Is this way a **street** (a line centreline → a ground ribbon)? Roads + paths that
/// are NOT closed area rings. Water/landuse areas and building footprints are handled
/// by their own branches; everything else that is a line falls through to landuse.
fn is_street(w: &Way) -> bool {
// `GROUND_ROAD_KIND` is a road the viewport falloff demoted to a single-pass ribbon —
// still a street, still a ground line, and it must not fall through to `landuse` and
// be drawn as an area.
(matches!(w.kind, "road" | "path") || w.kind == crate::extrude::GROUND_ROAD_KIND) && !is_closed_ring(w)
}
/// A closed ring repeats its first point as its last (an OSM area), vs an open line.
fn is_closed_ring(w: &Way) -> bool {
if w.fill.is_some() {
return true;
}
if w.points.len() < 4 {
return false;
}
let (a, b) = (w.points[0], w.points[w.points.len() - 1]);
(a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6
}
/// **THE korp CONNECTOR SEAM.** The single function a korp connector plugs into.
///
/// Today it classifies the caller-supplied OSM `ways` (already loaded by
/// `facett_osm`) into a [`FeatureLayers`] bundle — the OSM pipeline IS the fallback
/// layer source. This is deliberately the ONE place that changes when korp lands.
///
/// **TODO(korp):** once `korp/.nornir` defines the connector contract, replace the
/// body with a real connector call, e.g.
/// ```ignore
/// let layers = korp::connect(region)?; // streets + features from korp
/// FeatureLayers { streets: layers.streets, .. } // same envelope, same downstream
/// ```
/// The [`Map3D`], the extruder, the [`crate::extrude::GroundLayer`] z-ordering, and
/// the render backend do NOT change — only this function's data source does.
#[must_use]
pub fn load_layers(ways: &[Way]) -> FeatureLayers {
// SEAM: swap this line for `korp::connect(region)` when korp/.nornir exists.
FeatureLayers::classify(ways)
}
#[cfg(test)]
mod tests {
use super::*;
use facett_map::Way;
use facett_core::{Facet, Theme};
fn ring(id: i64, pts: &[(f64, f64)]) -> Way {
let tags = vec![("building".to_string(), "yes".to_string())];
Way::from_lonlat(id, pts, &tags, &Theme::default()).unwrap()
}
fn tagged(id: i64, k: &str, v: &str, pts: &[(f64, f64)]) -> Way {
let tags = vec![(k.to_string(), v.to_string())];
Way::from_lonlat(id, pts, &tags, &Theme::default()).unwrap()
}
/// The seam classifies a mixed OSM way set into the four cartographic layers by
/// tag — the connector-agnostic envelope a korp connector will later fill.
#[test]
fn load_layers_classifies_osm_ways_into_cartographic_layers() {
let building = ring(1, &[(9.50, 47.10), (9.501, 47.10), (9.501, 47.101), (9.50, 47.101), (9.50, 47.10)]);
let park = tagged(2, "leisure", "park", &[(9.50, 47.10), (9.52, 47.10), (9.52, 47.12), (9.50, 47.12), (9.50, 47.10)]);
let lake = tagged(3, "natural", "water", &[(9.51, 47.11), (9.515, 47.11), (9.515, 47.115), (9.51, 47.115), (9.51, 47.11)]);
let road = Way::line(4, "road", vec![(9.50, 47.10), (9.51, 47.105), (9.52, 47.11)], egui::Color32::GRAY, 2.0);
let fl = load_layers(&[building.clone(), park, lake, road]);
assert_eq!(fl.buildings.len(), 1, "the building footprint");
assert_eq!(fl.streets.len(), 1, "the road centreline is a street layer");
assert_eq!(fl.water.len(), 1, "the lake routes to the water layer");
assert_eq!(fl.landuse.len(), 1, "the park is landuse cover");
assert_eq!(fl.len(), 4);
assert!(!fl.is_empty());
}
/// `flatten` emits cover → water → streets → buildings, and `into_map3d`
/// extrudes a real, navigable 3D city from the layers (the real-data entry).
#[test]
fn layers_flatten_in_draw_order_and_build_a_map3d() {
let building = ring(1, &[(9.50, 47.10), (9.501, 47.10), (9.501, 47.101), (9.50, 47.101), (9.50, 47.10)]);
let road = Way::line(2, "road", vec![(9.50, 47.10), (9.52, 47.11)], egui::Color32::GRAY, 2.0);
let fl = load_layers(&[building, road]);
let flat = fl.flatten();
assert_eq!(flat.len(), 2);
// Buildings come LAST (drawn over the ground layers).
assert_eq!(flat.last().unwrap().kind, "building");
let m = fl.into_map3d("korp city");
assert!(m.triangle_count() > 0, "the layers extrude into a real 3D mesh");
assert!(m.caps().navigable, "the result is a navigable 3D map");
}
}