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