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

nordisk/facett

facett-map/src/layerset.rs

//! **`LayerSet` — the Google-Maps-style feature-layer toggles** (the common
//! data-feeder target). One shared model of **named, toggleable feature layers**
//! — *roads, water, buildings, landuse, coastline* plus the POI categories
//! *bars, kiosks, shops, restaurants, amenities* — each with a **visible on/off**
//! flag and a **`Symbol`** (its style, derived from a [`Theme`](facett_core::Theme),
//! the galileo `Symbol`-trait pattern). "Turn on roads, bars, kiosks…" — exactly
//! like flipping layers on Google Maps.
//!
//! ## Engine-agnostic by design (L5 — one layer model, never forked per engine)
//! The `LayerSet` is a pure model: it answers *"is this category visible?"* and
//! *"how is it styled?"*, and it filters a `&[`[`Way`]`]` feed
//! ([`LayerSet::filter_ways`]) into the visible subset. **Both** map engines
//! consume the *same* set through that one seam — the 2D [`crate::MapView`] /
//! [`crate::layer::MapFacet`] and `facett-map3d`'s `Map3D` (and, tomorrow,
//! `facett-map3d-flight`) all filter their `Way` feed through `filter_ways` and
//! style through [`LayerCategory::symbol`]. No engine re-implements the toggle
//! logic; it lives here once.
//!
//! ## Fed by any source (not just local OSM)
//! `facett-osm` classifies an OSM extract into these categories (ways via the
//! existing [`crate::classify`], POIs via [`LayerCategory::from_osm_tags`]). But
//! the classification is a pure tag → category map, so an **external** collector
//! (korp's Trafikverket roads, a bars/kiosks feed) feeds the *same* `LayerSet`
//! by tagging its features — the feeder API is a clean data boundary, not
//! OSM-only.
//!
//! ## Reuse, not twinning
//! The per-category [`Symbol`] colours come from [`crate::MapPalette`] +
//! [`Theme`](facett_core::Theme) (the same palette the basemap paints with), and
//! the way → category mapping runs through the existing [`crate::classify`]
//! (WayKind), so nothing about road/water/building classification is duplicated —
//! only the *new* POI (amenity/shop) buckets are added here.

use egui::Color32;
use serde::{Deserialize, Serialize};

use crate::style::MapPalette;
use crate::{Way, WayKind, classify};
use facett_core::Theme;

/// A toggleable **feature layer** — one Google-Maps-style checkbox. Base map
/// layers (roads … coastline) plus POI categories (bars … amenities).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)]
pub enum LayerCategory {
    // ── base map layers ──
    Roads,
    Water,
    Buildings,
    Landuse,
    Coastline,
    // ── points of interest (OSM amenity / shop) ──
    Bars,
    Kiosks,
    Shops,
    Restaurants,
    Amenities,
}

impl LayerCategory {
    /// Every category, in a sensible picker order (base layers first, then POIs).
    /// This is the toggle roster a UI checkbox list / a headless driver walks.
    pub const ALL: &'static [LayerCategory] = &[
        LayerCategory::Roads,
        LayerCategory::Water,
        LayerCategory::Buildings,
        LayerCategory::Landuse,
        LayerCategory::Coastline,
        LayerCategory::Bars,
        LayerCategory::Kiosks,
        LayerCategory::Shops,
        LayerCategory::Restaurants,
        LayerCategory::Amenities,
    ];

    /// Stable machine id (the toggle key + the `state_json` key).
    pub fn as_str(self) -> &'static str {
        match self {
            LayerCategory::Roads => "roads",
            LayerCategory::Water => "water",
            LayerCategory::Buildings => "buildings",
            LayerCategory::Landuse => "landuse",
            LayerCategory::Coastline => "coastline",
            LayerCategory::Bars => "bars",
            LayerCategory::Kiosks => "kiosks",
            LayerCategory::Shops => "shops",
            LayerCategory::Restaurants => "restaurants",
            LayerCategory::Amenities => "amenities",
        }
    }

    /// Human label for a checkbox (Title Case).
    pub fn label(self) -> &'static str {
        match self {
            LayerCategory::Roads => "Roads",
            LayerCategory::Water => "Water",
            LayerCategory::Buildings => "Buildings",
            LayerCategory::Landuse => "Landuse",
            LayerCategory::Coastline => "Coastline",
            LayerCategory::Bars => "Bars",
            LayerCategory::Kiosks => "Kiosks",
            LayerCategory::Shops => "Shops",
            LayerCategory::Restaurants => "Restaurants",
            LayerCategory::Amenities => "Amenities",
        }
    }

    /// Look a category up by its [`as_str`](Self::as_str) id (`None` if unknown).
    #[allow(clippy::should_implement_trait)] // public lookup returning `Option`, not the `FromStr` contract (no error type)
    pub fn from_str(s: &str) -> Option<Self> {
        Self::ALL.iter().copied().find(|c| c.as_str() == s)
    }

    /// A point-of-interest category (a node feed), as opposed to a base map layer
    /// (ways / areas). Drives whether a source should feed it points or ways.
    pub fn is_poi(self) -> bool {
        matches!(
            self,
            LayerCategory::Bars
                | LayerCategory::Kiosks
                | LayerCategory::Shops
                | LayerCategory::Restaurants
                | LayerCategory::Amenities
        )
    }

    /// Map a classified [`WayKind`] to its layer category (`None` for the generic
    /// `Other` bucket — uncategorised geometry, never hidden by a toggle). Reuses
    /// the existing classifier's output — no road/water rules are duplicated.
    pub fn from_way_kind(kind: WayKind) -> Option<Self> {
        match kind {
            // Roads + paths are the drivable/walkable network — one toggle.
            WayKind::Road | WayKind::Path => Some(LayerCategory::Roads),
            WayKind::Water => Some(LayerCategory::Water),
            WayKind::Building => Some(LayerCategory::Buildings),
            WayKind::Park => Some(LayerCategory::Landuse),
            WayKind::Coastline => Some(LayerCategory::Coastline),
            // An operator-loaded overlay is never hidden by a basemap toggle — it is
            // not part of the basemap.
            // A case trail is korp's own overlay and has its own layer row; a basemap
            // toggle must never hide it.
            // A railway is neither drivable nor walkable, so the Roads toggle is not its
            // switch, and korp feeds it as its own `rails` network layer with its own row.
            // No category = always shown, which is exactly what it was as `Other` (GG #28).
            WayKind::Other | WayKind::Overprint | WayKind::Glow | WayKind::Rail => None,
        }
    }

    /// **Classify any OSM feature's tags into a category** — the one tag → layer
    /// map every feeder (local OSM *and* an external collector) shares. POIs
    /// (`amenity=` / `shop=` on a node) resolve to the POI buckets; everything
    /// else falls back to the way classifier ([`crate::classify`]) → [`WayKind`]
    /// → [`from_way_kind`](Self::from_way_kind). `None` = uncategorised.
    ///
    /// The POI rules (the *only* new classification here) are checked first so a
    /// tagged bar/kiosk/restaurant isn't swallowed by a generic `amenity` area
    /// fallback:
    /// * `amenity=bar|pub|biergarten|nightclub` → **Bars**
    /// * `shop=kiosk`, `amenity=kiosk`, `shop=convenience`, `shop=newsagent` → **Kiosks**
    /// * `amenity=restaurant|cafe|fast_food|food_court|ice_cream` → **Restaurants**
    /// * any other `shop=*` → **Shops**
    /// * any other `amenity=*` (bare POI, no `building`) → **Amenities**
    pub fn from_osm_tags<K: AsRef<str>, V: AsRef<str>>(tags: &[(K, V)]) -> Option<Self> {
        let get = |k: &str| crate::tag(tags, k);
        let amenity = get("amenity");
        let shop = get("shop");

        // POI buckets first (most specific).
        if matches!(amenity, Some("bar") | Some("pub") | Some("biergarten") | Some("nightclub")) {
            return Some(LayerCategory::Bars);
        }
        if matches!(shop, Some("kiosk") | Some("convenience") | Some("newsagent")) || amenity == Some("kiosk") {
            return Some(LayerCategory::Kiosks);
        }
        if matches!(
            amenity,
            Some("restaurant") | Some("cafe") | Some("fast_food") | Some("food_court") | Some("ice_cream")
        ) {
            return Some(LayerCategory::Restaurants);
        }
        if shop.is_some() {
            return Some(LayerCategory::Shops);
        }
        // A lone `amenity=*` (no `building` geometry) is a generic POI. An
        // `amenity` ON a closed building ring is left to the way classifier.
        if amenity.is_some() && get("building").is_none() {
            return Some(LayerCategory::Amenities);
        }

        // Fall back to the existing way classifier (roads/water/landuse/…).
        Self::from_way_kind(classify(tags, &Theme::default()).kind)
    }

    /// The category's **`Symbol`** — its resolved style, derived from a
    /// [`Theme`](facett_core::Theme) (the galileo `Symbol`-trait pattern:
    /// per-feature-class styling off the palette). Areas (water/landuse/buildings)
    /// come back `filled`; lines (roads/coastline) and POI markers do not.
    pub fn symbol(self, theme: &Theme) -> Symbol {
        let pal = MapPalette::from_theme(theme);
        match self {
            LayerCategory::Roads => Symbol::line(theme.accent, 2.0),
            LayerCategory::Coastline => Symbol::line(pal.coast, 1.6),
            LayerCategory::Water => Symbol::area(pal.coast, pal.water),
            LayerCategory::Landuse => Symbol::area(mix(pal.green, Color32::BLACK, 0.2), pal.green),
            LayerCategory::Buildings => Symbol::area(mix(pal.building, Color32::BLACK, 0.3), pal.building),
            // POI markers — a filled dot in a distinct hue off the theme.
            LayerCategory::Bars => Symbol::marker(theme.accent),
            LayerCategory::Kiosks => Symbol::marker(theme.point),
            LayerCategory::Shops => Symbol::marker(mix(theme.accent, theme.point, 0.5)),
            LayerCategory::Restaurants => Symbol::marker(mix(theme.accent, Color32::from_rgb(240, 140, 60), 0.5)),
            LayerCategory::Amenities => Symbol::marker(theme.text_dim),
        }
    }
}

/// A resolved per-category style (galileo `Symbol`): a stroke/marker `color`, a
/// `width` (logical px), a `fill` for area layers, and a `filled` flag. Produced
/// from a [`Theme`](facett_core::Theme) by [`LayerCategory::symbol`], so a layer
/// re-themes with the deck.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Symbol {
    pub color: Color32,
    pub width: f32,
    pub fill: Option<Color32>,
    pub filled: bool,
}

impl Symbol {
    /// A stroked line (roads, coastline).
    pub fn line(color: Color32, width: f32) -> Self {
        Self { color, width, fill: None, filled: false }
    }
    /// A filled area (water, landuse, buildings).
    pub fn area(stroke: Color32, fill: Color32) -> Self {
        Self { color: stroke, width: 0.8, fill: Some(fill), filled: true }
    }
    /// A filled point marker (POI dot).
    pub fn marker(color: Color32) -> Self {
        Self { color, width: 3.0, fill: Some(color), filled: true }
    }
}

/// Blend two colours (`t=0 → a`, `t=1 → b`) — matches `style::mix` (kept local so
/// this module carries no cross-module private dep).
fn mix(a: Color32, b: Color32, t: f32) -> Color32 {
    let l = |x: u8, y: u8| (x as f32 * (1.0 - t) + y as f32 * t).round().clamp(0.0, 255.0) as u8;
    Color32::from_rgb(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()))
}

/// **The shared toggle model.** A visibility flag per [`LayerCategory`]. Both map
/// engines filter their feature feed through the *same* `LayerSet`, so flipping a
/// category here changes what every engine draws. Default: **all layers on**.
#[derive(Clone, Debug, PartialEq)]
pub struct LayerSet {
    /// Indexed by `LayerCategory::ALL` position — one bool per category.
    visible: [bool; LayerCategory::ALL.len()],
}

impl Default for LayerSet {
    fn default() -> Self {
        Self { visible: [true; LayerCategory::ALL.len()] }
    }
}

impl LayerSet {
    /// All layers visible (the Google-Maps default: everything on).
    pub fn new() -> Self {
        Self::default()
    }

    /// A set with **only** `cats` visible (everything else off) — handy for a
    /// "roads + water only" preset or a test.
    pub fn only(cats: &[LayerCategory]) -> Self {
        let mut s = Self { visible: [false; LayerCategory::ALL.len()] };
        for &c in cats {
            s.set_visible(c, true);
        }
        s
    }

    #[inline]
    fn idx(cat: LayerCategory) -> usize {
        LayerCategory::ALL.iter().position(|&c| c == cat).expect("every category is in ALL")
    }

    /// Is `cat` currently drawn?
    pub fn is_visible(&self, cat: LayerCategory) -> bool {
        self.visible[Self::idx(cat)]
    }

    /// Set `cat`'s visibility explicitly.
    pub fn set_visible(&mut self, cat: LayerCategory, on: bool) {
        self.visible[Self::idx(cat)] = on;
    }

    /// Flip `cat`'s visibility; returns the new state (the checkbox click).
    pub fn toggle(&mut self, cat: LayerCategory) -> bool {
        let i = Self::idx(cat);
        self.visible[i] = !self.visible[i];
        self.visible[i]
    }

    /// The categories that are currently visible, in [`LayerCategory::ALL`] order.
    pub fn visible_categories(&self) -> Vec<LayerCategory> {
        LayerCategory::ALL.iter().copied().filter(|&c| self.is_visible(c)).collect()
    }

    /// How many layers are on.
    pub fn visible_count(&self) -> usize {
        self.visible.iter().filter(|&&v| v).count()
    }

    /// **Does this way draw?** — its [`WayKind`] → category → visibility.
    /// Uncategorised geometry (`WayKind::Other` → no category) is always shown, so
    /// a toggle never silently drops unknown data.
    pub fn shows_way(&self, w: &Way) -> bool {
        match LayerCategory::from_way_kind(w.style.kind) {
            Some(cat) => self.is_visible(cat),
            None => true,
        }
    }

    /// **The one shared consumption seam.** Filter a `Way` feed to the visible
    /// subset — the exact `&[Way]` both engines accept (`MapView::with_ways`,
    /// `Map3D::from_ways`). Hiding *Buildings* here drops the building prisms from
    /// the 3D engine and the building fills from the 2D engine, identically.
    pub fn filter_ways(&self, ways: &[Way]) -> Vec<Way> {
        ways.iter().filter(|w| self.shows_way(w)).cloned().collect()
    }

    /// The observable state (FC-3): each category's visibility + the visible count.
    /// A headless / robot driver reads this to PROVE a toggle flipped a layer.
    pub fn state_json(&self) -> serde_json::Value {
        let mut layers = serde_json::Map::new();
        for &c in LayerCategory::ALL {
            layers.insert(c.as_str().to_string(), serde_json::Value::Bool(self.is_visible(c)));
        }
        serde_json::json!({
            "layers": layers,
            "visible_count": self.visible_count(),
        })
    }
}

// ── The layer-toggle UI Facet — the Google-Maps "layers" checkbox list ───────

/// **The layer-toggle Facet** — a checkbox list, one row per [`LayerCategory`],
/// exactly like Google Maps' *Layers* panel ("turn on roads, bars, kiosks…").
/// Owns a [`LayerSet`]; a host reads [`layer_set`](Self::layer_set) each frame to
/// filter its map engine's feed, so ticking a box changes what the map draws.
///
/// Implements [`Facet`](facett_core::Facet): `state_json` exposes each layer's
/// on/off (so a robot test asserts a click flipped it — **red when broken**), and
/// `update_json` is the headless toggle path (`{"toggle":"bars"}` /
/// `{"category":"bars","visible":false}`).
pub struct LayerToggles {
    title: String,
    set: LayerSet,
    /// The last category the user clicked (surfaced in `state_json` for a robot
    /// read-back — "which checkbox did the click land on?"). `None` until a click.
    last_toggled: Option<LayerCategory>,
}

impl LayerToggles {
    pub fn new(title: impl Into<String>) -> Self {
        Self { title: title.into(), set: LayerSet::new(), last_toggled: None }
    }

    /// Start from a specific [`LayerSet`] (e.g. a saved preset).
    pub fn with_set(title: impl Into<String>, set: LayerSet) -> Self {
        Self { title: title.into(), set, last_toggled: None }
    }

    /// The shared toggle model (a host filters its engine feed through this).
    pub fn layer_set(&self) -> &LayerSet {
        &self.set
    }
    /// Mutable access for a host that drives toggles programmatically.
    pub fn layer_set_mut(&mut self) -> &mut LayerSet {
        &mut self.set
    }

    /// Is `cat` on? (convenience over `layer_set().is_visible`).
    pub fn is_visible(&self, cat: LayerCategory) -> bool {
        self.set.is_visible(cat)
    }

    /// Programmatically toggle `cat` (the headless equivalent of clicking its
    /// checkbox) — returns the new state. Records it as the last-toggled category.
    pub fn toggle(&mut self, cat: LayerCategory) -> bool {
        self.last_toggled = Some(cat);
        self.set.toggle(cat)
    }
}

impl facett_core::Facet for LayerToggles {
    fn title(&self) -> &str {
        &self.title
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        ui.label(egui::RichText::new("Layers").strong());
        ui.separator();
        // Base map layers, then a divider, then the POI categories — the two
        // groups a Google-Maps layers panel shows.
        let mut drew_poi_header = false;
        for &cat in LayerCategory::ALL {
            if cat.is_poi() && !drew_poi_header {
                ui.add_space(4.0);
                ui.label(egui::RichText::new("Points of interest").weak());
                drew_poi_header = true;
            }
            let mut on = self.set.is_visible(cat);
            if ui.checkbox(&mut on, cat.label()).changed() {
                self.set.set_visible(cat, on);
                self.last_toggled = Some(cat);
            }
        }
    }

    fn state_json(&self) -> serde_json::Value {
        let mut v = self.set.state_json();
        if let serde_json::Value::Object(ref mut m) = v {
            m.insert("title".to_string(), serde_json::Value::String(self.title.clone()));
            m.insert(
                "last_toggled".to_string(),
                self.last_toggled
                    .map(|c| serde_json::Value::String(c.as_str().to_string()))
                    .unwrap_or(serde_json::Value::Null),
            );
        }
        v
    }

    /// Headless toggle: `{"toggle":"bars"}` flips a layer;
    /// `{"category":"water","visible":false}` sets it. Unknown ids are ignored.
    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
        if let Some(id) = v.get("toggle").and_then(|x| x.as_str()) {
            if let Some(cat) = LayerCategory::from_str(id) {
                self.toggle(cat);
            }
        } else if let Some(id) = v.get("category").and_then(|x| x.as_str())
            && let (Some(cat), Some(on)) = (LayerCategory::from_str(id), v.get("visible").and_then(|x| x.as_bool()))
        {
            self.set.set_visible(cat, on);
            self.last_toggled = Some(cat);
        }
    }

    fn kind(&self) -> &'static str {
        "layer-toggles"
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tags(kv: &[(&str, &str)]) -> Vec<(String, String)> {
        kv.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
    }

    fn way(id: i64, kv: &[(&str, &str)]) -> Way {
        // A small closed ring is enough to classify + carry a kind.
        let ring = [(9.50, 47.10), (9.55, 47.10), (9.55, 47.15), (9.50, 47.15), (9.50, 47.10)];
        Way::from_lonlat(id, &ring, &tags(kv), &Theme::default()).unwrap()
    }

    #[test]
    fn categories_have_unique_ids_and_round_trip() {
        let mut ids: Vec<&str> = LayerCategory::ALL.iter().map(|c| c.as_str()).collect();
        let n = ids.len();
        ids.sort_unstable();
        ids.dedup();
        assert_eq!(ids.len(), n, "category ids are unique");
        for &c in LayerCategory::ALL {
            assert_eq!(LayerCategory::from_str(c.as_str()), Some(c), "{} round-trips", c.as_str());
        }
        // The categories the prompt names all exist.
        for want in
            ["roads", "water", "buildings", "landuse", "coastline", "bars", "kiosks", "shops", "restaurants", "amenities"]
        {
            assert!(LayerCategory::from_str(want).is_some(), "category {want} exists");
        }
    }

    #[test]
    fn poi_tags_classify_bars_kiosks_restaurants_shops_amenities() {
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("amenity", "bar")])), Some(LayerCategory::Bars));
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("amenity", "pub")])), Some(LayerCategory::Bars));
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("shop", "kiosk")])), Some(LayerCategory::Kiosks));
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("amenity", "kiosk")])), Some(LayerCategory::Kiosks));
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("amenity", "restaurant")])), Some(LayerCategory::Restaurants));
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("amenity", "cafe")])), Some(LayerCategory::Restaurants));
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("shop", "bakery")])), Some(LayerCategory::Shops));
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("amenity", "townhall")])), Some(LayerCategory::Amenities));
    }

    #[test]
    fn way_tags_reuse_the_existing_classifier() {
        // roads/water/landuse/coastline flow through `classify` → WayKind → category.
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("highway", "motorway")])), Some(LayerCategory::Roads));
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("natural", "water")])), Some(LayerCategory::Water));
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("landuse", "forest")])), Some(LayerCategory::Landuse));
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("natural", "coastline")])), Some(LayerCategory::Coastline));
        assert_eq!(LayerCategory::from_osm_tags(&tags(&[("building", "yes")])), Some(LayerCategory::Buildings));
    }

    #[test]
    fn symbols_come_from_the_theme_and_areas_are_filled() {
        let th = Theme::default();
        assert!(LayerCategory::Water.symbol(&th).filled, "water is a filled area");
        assert!(LayerCategory::Buildings.symbol(&th).filled, "buildings fill");
        assert!(!LayerCategory::Roads.symbol(&th).filled, "roads are a stroked line");
        assert!(!LayerCategory::Coastline.symbol(&th).filled, "coastline is a line");
        assert_eq!(LayerCategory::Roads.symbol(&th).color, th.accent, "roads ride the theme accent");
        // Re-themes: a different palette yields a different road colour.
        assert_ne!(
            LayerCategory::Roads.symbol(&th).color,
            LayerCategory::Roads.symbol(&Theme::sci_fi()).color,
            "the Symbol follows the active theme (galileo pattern)"
        );
    }

    #[test]
    fn default_layerset_shows_everything() {
        let set = LayerSet::new();
        assert_eq!(set.visible_count(), LayerCategory::ALL.len(), "all layers on by default");
        for &c in LayerCategory::ALL {
            assert!(set.is_visible(c));
        }
    }

    /// **RED-when-broken**: toggling *Buildings* off must both flip its visibility
    /// AND drop building ways from the shared `filter_ways` feed BOTH engines use.
    /// If the toggle silently failed to affect the feed, the count would stay 3.
    #[test]
    fn toggling_a_category_off_hides_it_from_the_shared_feed() {
        let ways = vec![
            way(1, &[("highway", "primary")]), // Roads
            way(2, &[("building", "yes")]),    // Buildings
            way(3, &[("natural", "water")]),   // Water
        ];
        let mut set = LayerSet::new();
        assert_eq!(set.filter_ways(&ways).len(), 3, "all three feed through when everything is on");

        // Turn OFF buildings — the checkbox click.
        assert!(!set.toggle(LayerCategory::Buildings), "toggle returns the new (off) state");
        assert!(!set.is_visible(LayerCategory::Buildings), "buildings flipped off");
        let shown = set.filter_ways(&ways);
        assert_eq!(shown.len(), 2, "the building way is dropped from the feed");
        assert!(!shown.iter().any(|w| w.style.kind == WayKind::Building), "no building survives the filter");
        assert!(set.state_json()["layers"]["buildings"] == serde_json::json!(false), "state_json shows it off");

        // Roads + water still draw.
        assert!(shown.iter().any(|w| w.style.kind == WayKind::Road));
        assert!(shown.iter().any(|w| w.style.kind == WayKind::Water));
    }

    /// **The 2D engine consumes the same filter.** The legacy `MapView` (2D)
    /// built from `filter_ways` carries only the visible categories — the SAME
    /// shared seam `facett-map3d`'s `Map3D::from_ways_layerset` uses. Proves "both
    /// engines, one LayerSet" from the 2D side.
    #[test]
    fn two_d_engine_consumes_the_same_filter() {
        let ways = vec![
            way(1, &[("highway", "primary")]), // Roads
            way(2, &[("building", "yes")]),    // Buildings
            way(3, &[("natural", "water")]),   // Water
        ];
        let mut set = LayerSet::new();
        set.set_visible(LayerCategory::Buildings, false);
        // The 2D MapView is fed the filtered subset (what a host does each frame /
        // on a toggle) — identical `&[Way]` API to the 3D engine.
        let view = crate::MapView::new("2d", Vec::new()).with_ways(set.filter_ways(&ways));
        assert_eq!(view.ways.len(), 2, "the 2D engine drew only the two visible layers");
        assert!(!view.ways.iter().any(|w| w.style.kind == WayKind::Building), "buildings hidden in 2D");
    }

    #[test]
    fn only_preset_shows_just_the_named_layers() {
        let set = LayerSet::only(&[LayerCategory::Roads, LayerCategory::Water]);
        assert!(set.is_visible(LayerCategory::Roads) && set.is_visible(LayerCategory::Water));
        assert!(!set.is_visible(LayerCategory::Buildings) && !set.is_visible(LayerCategory::Bars));
        assert_eq!(set.visible_count(), 2);
    }

    #[test]
    fn facet_update_json_toggles_headlessly() {
        use facett_core::Facet;
        let mut t = LayerToggles::new("Layers");
        assert!(t.is_visible(LayerCategory::Bars));
        t.update_json(r#"{"toggle":"bars"}"#);
        assert!(!t.is_visible(LayerCategory::Bars), "toggle message flipped bars off");
        assert_eq!(t.state_json()["layers"]["bars"], serde_json::json!(false));
        assert_eq!(t.state_json()["last_toggled"], serde_json::json!("bars"));

        t.update_json(r#"{"category":"bars","visible":true}"#);
        assert!(t.is_visible(LayerCategory::Bars), "explicit set turned bars back on");
        // Unknown id is a graceful no-op.
        let before = t.state_json();
        t.update_json(r#"{"toggle":"nope"}"#);
        assert_eq!(t.state_json()["layers"], before["layers"], "unknown category ignored");
    }
}