Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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