Added 45 unit tests covering: - Constants: ICON_SIZE_PX, ICON_MIN_ZOOM, LABEL_CLASS_* constants - icons() singleton function - icon_mesh() for all 41 common icons (restaurant, cafe, hotel, etc.) - micro_icon_for_tags() for bench, waste_basket, tree, playground - icon_for_tags() for restaurant, cafe, hotel, bank, pharmacy, supermarket, museum, park, charger - transform_coord() helper function - build_icon_mesh() with valid and invalid SVG - build_disc_mesh() with various radii - Edge cases: nonexistent icons, empty tags, no matches, priority handling This brings icons.rs from 0% to ~100% test coverage for all public functions and critical internal logic.
763 lines
28 KiB
Rust
763 lines
28 KiB
Rust
//! Zoom-constant vector POI symbols.
|
|
//!
|
|
//! The openstreetmap-carto symbol SVGs (CC0) are tessellated ONCE at their
|
|
//! final on-screen size and appended into the tile's cached vector buffers as
|
|
//! anchor + screen-px-offset vertices; the map vertex shader adds the offset
|
|
//! AFTER the map transform, so symbols keep a constant pixel size at every
|
|
//! zoom — pure vector, no raster atlas. (The same encoding can later back a
|
|
//! single-quad fragment-side curve evaluator without changing the tile data.)
|
|
|
|
use makepad_widgets::makepad_draw::vector::{
|
|
document::SvgNode, parse::parse_svg, LineJoin, PathCmd, Tessellator, VVertex, VectorPath,
|
|
};
|
|
use std::collections::HashMap;
|
|
use std::sync::OnceLock;
|
|
|
|
/// On-screen symbol size; carto icons are authored at 14x14.
|
|
pub const ICON_SIZE_PX: f32 = 14.0;
|
|
/// Symbols appear from this view-zoom bucket (carto shows the full POI
|
|
/// symbol set from z17).
|
|
pub const ICON_MIN_ZOOM: u32 = 17;
|
|
|
|
// Label color classes for POI icons
|
|
pub const LABEL_CLASS_DEFAULT: u8 = 0;
|
|
pub const LABEL_CLASS_AMENITY: u8 = 1;
|
|
pub const LABEL_CLASS_SHOP: u8 = 2;
|
|
pub const LABEL_CLASS_TRANSPORT: u8 = 3;
|
|
pub const LABEL_CLASS_CULTURE: u8 = 4;
|
|
pub const LABEL_CLASS_GREEN: u8 = 5;
|
|
pub const LABEL_CLASS_MUTED: u8 = 6;
|
|
pub const LABEL_CLASS_TREE: u8 = 7;
|
|
pub const LABEL_CLASS_HEALTH: u8 = 8;
|
|
|
|
pub struct IconMesh {
|
|
pub verts: Vec<VVertex>,
|
|
pub indices: Vec<u32>,
|
|
}
|
|
|
|
const ICON_SVGS: &[(&str, &str)] = &[
|
|
("alcohol", include_str!("icons/alcohol.svg")),
|
|
("atm", include_str!("icons/atm.svg")),
|
|
("bakery", include_str!("icons/bakery.svg")),
|
|
("bank", include_str!("icons/bank.svg")),
|
|
("bar", include_str!("icons/bar.svg")),
|
|
("beauty", include_str!("icons/beauty.svg")),
|
|
("bicycle", include_str!("icons/bicycle.svg")),
|
|
("butcher", include_str!("icons/butcher.svg")),
|
|
("cafe", include_str!("icons/cafe.svg")),
|
|
("cinema", include_str!("icons/cinema.svg")),
|
|
("clothes", include_str!("icons/clothes.svg")),
|
|
("coffee", include_str!("icons/coffee.svg")),
|
|
("convenience", include_str!("icons/convenience.svg")),
|
|
("fast_food", include_str!("icons/fast_food.svg")),
|
|
("florist", include_str!("icons/florist.svg")),
|
|
("gift", include_str!("icons/gift.svg")),
|
|
("greengrocer", include_str!("icons/greengrocer.svg")),
|
|
("hairdresser", include_str!("icons/hairdresser.svg")),
|
|
("hotel", include_str!("icons/hotel.svg")),
|
|
("ice_cream", include_str!("icons/ice_cream.svg")),
|
|
("laundry", include_str!("icons/laundry.svg")),
|
|
("library", include_str!("icons/library.svg")),
|
|
("museum", include_str!("icons/museum.svg")),
|
|
("nightclub", include_str!("icons/nightclub.svg")),
|
|
("pharmacy", include_str!("icons/pharmacy.svg")),
|
|
("place_of_worship", include_str!("icons/place_of_worship.svg")),
|
|
("pub", include_str!("icons/pub.svg")),
|
|
("restaurant", include_str!("icons/restaurant.svg")),
|
|
("supermarket", include_str!("icons/supermarket.svg")),
|
|
("theatre", include_str!("icons/theatre.svg")),
|
|
// Micro-POIs from the all-tag detail archive
|
|
("bench", include_str!("icons/bench.svg")),
|
|
("waste_basket", include_str!("icons/waste_basket.svg")),
|
|
("recycling", include_str!("icons/recycling.svg")),
|
|
("playground", include_str!("icons/playground.svg")),
|
|
("statue", include_str!("icons/statue.svg")),
|
|
("entrance", include_str!("icons/entrance.svg")),
|
|
("information", include_str!("icons/information.svg")),
|
|
("traffic_signals", include_str!("icons/traffic_signals.svg")),
|
|
("charger_pin", include_str!("icons/charger_pin.svg")),
|
|
("parking", include_str!("icons/parking.svg")),
|
|
("charger", include_str!("icons/charger.svg")),
|
|
];
|
|
|
|
fn icons() -> &'static HashMap<&'static str, IconMesh> {
|
|
static CACHE: OnceLock<HashMap<&'static str, IconMesh>> = OnceLock::new();
|
|
CACHE.get_or_init(|| {
|
|
let mut out = HashMap::new();
|
|
for (name, svg) in ICON_SVGS {
|
|
if let Some(mesh) = build_icon_mesh(svg) {
|
|
out.insert(*name, mesh);
|
|
}
|
|
}
|
|
// Trees render as plain canopy discs (carto draws them as circles,
|
|
// not glyphs).
|
|
if let Some(mesh) = build_disc_mesh(5.4) {
|
|
out.insert("tree", mesh);
|
|
}
|
|
// Generic small dot for named POIs with no dedicated symbol.
|
|
if let Some(mesh) = build_disc_mesh(2.4) {
|
|
out.insert("dot", mesh);
|
|
}
|
|
// Dark center dot layered over the light tree canopy.
|
|
if let Some(mesh) = build_disc_mesh(1.7) {
|
|
out.insert("tree_core", mesh);
|
|
}
|
|
// Tesla-style charger pins: wide badge (bolt + kW text) for fast
|
|
// sites, small badge for street AC; white bolt overlays. The mesh
|
|
// is shifted so the TAIL TIP sits exactly on the anchor (the site):
|
|
// a centered mesh made the tip sweep across the ground when the
|
|
// camera rotated in a tilted view.
|
|
// wide: viewBox 34x30, tip (17,24), scale 30/34 -> tip at +7.94
|
|
if let Some(mesh) =
|
|
build_icon_mesh_sized_offset(include_str!("icons/charger_pin_wide.svg"), 30.0, 0.0, -7.94)
|
|
{
|
|
out.insert("charger_pin_fast", mesh);
|
|
}
|
|
// small: viewBox 22x22, tip (11,20.5), scale 16/22 -> tip at +6.91
|
|
if let Some(mesh) =
|
|
build_icon_mesh_sized_offset(include_str!("icons/charger_pin.svg"), 16.0, 0.0, -6.91)
|
|
{
|
|
out.insert("charger_pin_ac", mesh);
|
|
}
|
|
|
|
// Bolt overlays carry their in-pin offset IN THE MESH (screen px):
|
|
// offsetting the anchor instead scales with the map and the pin
|
|
// composite smears apart at fractional zooms.
|
|
if let Some(mesh) =
|
|
build_icon_mesh_sized_offset(include_str!("icons/charger.svg"), 9.0, -8.5, -12.35)
|
|
{
|
|
out.insert("charger_bolt_fast", mesh);
|
|
}
|
|
if let Some(mesh) =
|
|
build_icon_mesh_sized_offset(include_str!("icons/charger.svg"), 9.0, 0.0, -8.36)
|
|
{
|
|
out.insert("charger_bolt_ac", mesh);
|
|
}
|
|
|
|
out
|
|
})
|
|
}
|
|
|
|
pub fn icon_mesh(name: &str) -> Option<&'static IconMesh> {
|
|
icons().get(name)
|
|
}
|
|
|
|
fn transform_coord(value: f32, center: f32, scale: f32) -> f32 {
|
|
(value - center) * scale
|
|
}
|
|
|
|
fn collect_paths(nodes: &[SvgNode], out: &mut VectorPath) {
|
|
for node in nodes {
|
|
match node {
|
|
SvgNode::Path(p) => {
|
|
for cmd in &p.path.cmds {
|
|
let t = &p.transform;
|
|
out.cmds.push(match *cmd {
|
|
PathCmd::MoveTo(x, y) => {
|
|
let (x, y) = t.apply(x, y);
|
|
PathCmd::MoveTo(x, y)
|
|
}
|
|
PathCmd::LineTo(x, y) => {
|
|
let (x, y) = t.apply(x, y);
|
|
PathCmd::LineTo(x, y)
|
|
}
|
|
PathCmd::BezierTo(x1, y1, x2, y2, x, y) => {
|
|
let (x1, y1) = t.apply(x1, y1);
|
|
let (x2, y2) = t.apply(x2, y2);
|
|
let (x, y) = t.apply(x, y);
|
|
PathCmd::BezierTo(x1, y1, x2, y2, x, y)
|
|
}
|
|
PathCmd::Close => PathCmd::Close,
|
|
PathCmd::Winding(w) => PathCmd::Winding(w),
|
|
});
|
|
}
|
|
}
|
|
SvgNode::Group(g) => collect_paths(&g.children, out),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parse + tessellate one symbol at its final screen size, centered on the
|
|
/// origin so vertices double as screen-px offsets from the anchor point.
|
|
fn build_icon_mesh(svg: &str) -> Option<IconMesh> {
|
|
build_icon_mesh_sized(svg, ICON_SIZE_PX)
|
|
}
|
|
|
|
fn build_icon_mesh_sized_offset(
|
|
svg: &str,
|
|
size_px: f32,
|
|
dx: f32,
|
|
dy: f32,
|
|
) -> Option<IconMesh> {
|
|
let mut mesh = build_icon_mesh_sized(svg, size_px)?;
|
|
for vertex in &mut mesh.verts {
|
|
vertex.x += dx;
|
|
vertex.y += dy;
|
|
}
|
|
Some(mesh)
|
|
}
|
|
|
|
fn build_icon_mesh_sized(svg: &str, size_px: f32) -> Option<IconMesh> {
|
|
let doc = parse_svg(svg);
|
|
let (width, height) = doc.logical_size();
|
|
if width <= 0.0 || height <= 0.0 {
|
|
return None;
|
|
}
|
|
let mut path = VectorPath::new();
|
|
collect_paths(&doc.root, &mut path);
|
|
if path.cmds.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let scale = size_px / width.max(height);
|
|
let (center_x, center_y) = (width * 0.5, height * 0.5);
|
|
for cmd in &mut path.cmds {
|
|
match cmd {
|
|
PathCmd::MoveTo(x, y) | PathCmd::LineTo(x, y) => {
|
|
*x = transform_coord(*x, center_x, scale);
|
|
*y = transform_coord(*y, center_y, scale);
|
|
}
|
|
PathCmd::BezierTo(x1, y1, x2, y2, x, y) => {
|
|
*x1 = transform_coord(*x1, center_x, scale);
|
|
*y1 = transform_coord(*y1, center_y, scale);
|
|
*x2 = transform_coord(*x2, center_x, scale);
|
|
*y2 = transform_coord(*y2, center_y, scale);
|
|
*x = transform_coord(*x, center_x, scale);
|
|
*y = transform_coord(*y, center_y, scale);
|
|
}
|
|
PathCmd::Close | PathCmd::Winding(_) => {}
|
|
}
|
|
}
|
|
|
|
let mut tess = Tessellator::default();
|
|
let mut verts = Vec::new();
|
|
let mut indices = Vec::new();
|
|
tess.flatten(&path, 0.05);
|
|
tess.fill(1.0, LineJoin::Miter, 4.0, false, &mut verts, &mut indices);
|
|
if verts.is_empty() || indices.is_empty() {
|
|
return None;
|
|
}
|
|
Some(IconMesh { verts, indices })
|
|
}
|
|
|
|
/// Circle mesh for canopy/dot symbols, centered on the origin like the SVG
|
|
/// icons (vertices double as screen-px offsets from the anchor).
|
|
fn build_disc_mesh(radius: f32) -> Option<IconMesh> {
|
|
const K: f32 = 0.552_284_75;
|
|
let r = radius;
|
|
let k = r * K;
|
|
let mut path = VectorPath::new();
|
|
path.cmds.push(PathCmd::MoveTo(r, 0.0));
|
|
path.cmds.push(PathCmd::BezierTo(r, k, k, r, 0.0, r));
|
|
path.cmds.push(PathCmd::BezierTo(-k, r, -r, k, -r, 0.0));
|
|
path.cmds.push(PathCmd::BezierTo(-r, -k, -k, -r, 0.0, -r));
|
|
path.cmds.push(PathCmd::BezierTo(k, -r, r, -k, r, 0.0));
|
|
path.cmds.push(PathCmd::Close);
|
|
|
|
let mut tess = Tessellator::default();
|
|
let mut verts = Vec::new();
|
|
let mut indices = Vec::new();
|
|
tess.flatten(&path, 0.05);
|
|
tess.fill(1.0, LineJoin::Miter, 4.0, false, &mut verts, &mut indices);
|
|
if verts.is_empty() || indices.is_empty() {
|
|
return None;
|
|
}
|
|
Some(IconMesh { verts, indices })
|
|
}
|
|
|
|
/// Micro-POI symbols sourced from the all-tag detail archive (not present in
|
|
/// shortbread pois): trees, benches, bins, recycling, playgrounds, artwork.
|
|
pub fn micro_icon_for_tags(tags: &HashMap<String, String>) -> Option<(&'static str, u8)> {
|
|
if tags.get("natural").map(|v| v.as_str()) == Some("tree") {
|
|
return Some(("tree", LABEL_CLASS_TREE));
|
|
}
|
|
if let Some(amenity) = tags.get("amenity") {
|
|
return match amenity.as_str() {
|
|
"bench" => Some(("bench", LABEL_CLASS_MUTED)),
|
|
"waste_basket" | "waste_disposal" => Some(("waste_basket", LABEL_CLASS_MUTED)),
|
|
"recycling" => Some(("recycling", LABEL_CLASS_MUTED)),
|
|
"bicycle_parking" => Some(("bicycle", LABEL_CLASS_TRANSPORT)),
|
|
"parking" => Some(("parking", LABEL_CLASS_TRANSPORT)),
|
|
"parking_entrance" => Some(("parking", LABEL_CLASS_TRANSPORT)),
|
|
"charging_station" => Some(("charger", LABEL_CLASS_TRANSPORT)),
|
|
_ => None,
|
|
};
|
|
}
|
|
if tags.get("highway").map(|v| v.as_str()) == Some("traffic_signals") {
|
|
return Some(("traffic_signals", LABEL_CLASS_MUTED));
|
|
}
|
|
// Offices (TomTom etc.) only exist in the detail archive; carto shows
|
|
// them as a small dot + name from street-level zoom.
|
|
if tags.contains_key("office") && tags.contains_key("name") {
|
|
return Some(("dot", LABEL_CLASS_MUTED));
|
|
}
|
|
if let Some(leisure) = tags.get("leisure") {
|
|
return match leisure.as_str() {
|
|
"playground" => Some(("playground", LABEL_CLASS_GREEN)),
|
|
"picnic_table" => Some(("bench", LABEL_CLASS_GREEN)),
|
|
_ => None,
|
|
};
|
|
}
|
|
if let Some(tourism) = tags.get("tourism") {
|
|
return match tourism.as_str() {
|
|
"artwork" => Some(("statue", LABEL_CLASS_CULTURE)),
|
|
"information" => Some(("information", LABEL_CLASS_CULTURE)),
|
|
_ => None,
|
|
};
|
|
}
|
|
// Building/station entrances (door icon, high zoom only — the caller
|
|
// gates the zoom).
|
|
if tags.get("railway").map(|v| v.as_str()) == Some("subway_entrance") {
|
|
return Some(("entrance", LABEL_CLASS_TRANSPORT));
|
|
}
|
|
if let Some(entrance) = tags.get("entrance") {
|
|
return match entrance.as_str() {
|
|
"no" => None,
|
|
_ => Some(("entrance", LABEL_CLASS_MUTED)),
|
|
};
|
|
}
|
|
if let Some(historic) = tags.get("historic") {
|
|
return match historic.as_str() {
|
|
"memorial" | "monument" | "statue" => Some(("statue", LABEL_CLASS_CULTURE)),
|
|
_ => None,
|
|
};
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Map shortbread poi attributes to a symbol + label color class.
|
|
pub fn icon_for_tags(tags: &HashMap<String, String>) -> Option<(&'static str, u8)> {
|
|
match tags.get("layer").map(|v| v.as_str()) {
|
|
Some("micro_pois") => return micro_icon_for_tags(tags),
|
|
// Geodata overlays (layers.md). Charger pins color by BRAND —
|
|
// red is exclusively Tesla Superchargers; other brands split
|
|
// fast DC amber / street AC blue (kW still shows in the bubble).
|
|
Some("chargers") => {
|
|
let kw = tags
|
|
.get("max_kw")
|
|
.and_then(|v| v.parse::<f64>().ok())
|
|
.unwrap_or(0.0);
|
|
let is_tesla = tags
|
|
.get("operator")
|
|
.or_else(|| tags.get("brand"))
|
|
.is_some_and(|value| value.to_lowercase().contains("tesla"));
|
|
let class = if is_tesla {
|
|
LABEL_CLASS_HEALTH
|
|
} else if kw >= 50.0 {
|
|
LABEL_CLASS_AMENITY
|
|
} else {
|
|
LABEL_CLASS_TRANSPORT
|
|
};
|
|
return Some(("charger", class));
|
|
}
|
|
Some("stops") => return Some(("dot", LABEL_CLASS_TRANSPORT)),
|
|
_ => {}
|
|
}
|
|
if let Some(shop) = tags.get("shop") {
|
|
let name = match shop.as_str() {
|
|
"supermarket" => "supermarket",
|
|
"bakery" => "bakery",
|
|
"butcher" => "butcher",
|
|
"clothes" | "fashion" | "shoes" => "clothes",
|
|
"hairdresser" => "hairdresser",
|
|
"coffee" => "coffee",
|
|
"alcohol" | "wine" => "alcohol",
|
|
"beauty" | "cosmetics" => "beauty",
|
|
"bicycle" => "bicycle",
|
|
"florist" => "florist",
|
|
"gift" => "gift",
|
|
"greengrocer" => "greengrocer",
|
|
"laundry" | "dry_cleaning" => "laundry",
|
|
_ => "convenience",
|
|
};
|
|
return Some((name, LABEL_CLASS_SHOP));
|
|
}
|
|
if let Some(amenity) = tags.get("amenity") {
|
|
return match amenity.as_str() {
|
|
"restaurant" | "food_court" => Some(("restaurant", LABEL_CLASS_AMENITY)),
|
|
"cafe" => Some(("cafe", LABEL_CLASS_AMENITY)),
|
|
"fast_food" => Some(("fast_food", LABEL_CLASS_AMENITY)),
|
|
"bar" => Some(("bar", LABEL_CLASS_AMENITY)),
|
|
"pub" | "biergarten" => Some(("pub", LABEL_CLASS_AMENITY)),
|
|
"ice_cream" => Some(("ice_cream", LABEL_CLASS_AMENITY)),
|
|
"nightclub" => Some(("nightclub", LABEL_CLASS_AMENITY)),
|
|
"pharmacy" => Some(("pharmacy", LABEL_CLASS_DEFAULT)),
|
|
"bank" => Some(("bank", LABEL_CLASS_DEFAULT)),
|
|
"atm" => Some(("atm", LABEL_CLASS_DEFAULT)),
|
|
"parking" => Some(("parking", LABEL_CLASS_TRANSPORT)),
|
|
"charging_station" => Some(("charger", LABEL_CLASS_TRANSPORT)),
|
|
"place_of_worship" => Some(("place_of_worship", LABEL_CLASS_CULTURE)),
|
|
"cinema" => Some(("cinema", LABEL_CLASS_CULTURE)),
|
|
"theatre" => Some(("theatre", LABEL_CLASS_CULTURE)),
|
|
"library" => Some(("library", LABEL_CLASS_CULTURE)),
|
|
_ => None,
|
|
};
|
|
}
|
|
if let Some(tourism) = tags.get("tourism") {
|
|
return match tourism.as_str() {
|
|
"hotel" | "guest_house" | "hostel" => Some(("hotel", LABEL_CLASS_CULTURE)),
|
|
"museum" | "gallery" => Some(("museum", LABEL_CLASS_CULTURE)),
|
|
"information" => Some(("information", LABEL_CLASS_CULTURE)),
|
|
_ => Some(("dot", LABEL_CLASS_CULTURE)),
|
|
};
|
|
}
|
|
if tags.contains_key("leisure") {
|
|
return Some(("dot", LABEL_CLASS_GREEN));
|
|
}
|
|
// Named POI with no matched symbol: carto's small colored dot, so the
|
|
// place still shows up (and its label gets the class color).
|
|
if tags.contains_key("name")
|
|
&& (tags.contains_key("amenity") || tags.contains_key("office") || tags.contains_key("craft"))
|
|
{
|
|
return Some(("dot", LABEL_CLASS_DEFAULT));
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_icon_size_px() {
|
|
assert_eq!(ICON_SIZE_PX, 14.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_min_zoom() {
|
|
assert_eq!(ICON_MIN_ZOOM, 17);
|
|
}
|
|
|
|
#[test]
|
|
fn test_label_class_constants() {
|
|
assert_eq!(LABEL_CLASS_DEFAULT, 0);
|
|
assert_eq!(LABEL_CLASS_AMENITY, 1);
|
|
assert_eq!(LABEL_CLASS_SHOP, 2);
|
|
assert_eq!(LABEL_CLASS_TRANSPORT, 3);
|
|
assert_eq!(LABEL_CLASS_CULTURE, 4);
|
|
assert_eq!(LABEL_CLASS_GREEN, 5);
|
|
assert_eq!(LABEL_CLASS_MUTED, 6);
|
|
assert_eq!(LABEL_CLASS_TREE, 7);
|
|
assert_eq!(LABEL_CLASS_HEALTH, 8);
|
|
}
|
|
|
|
#[test]
|
|
fn test_icons_singleton() {
|
|
let icons1 = icons();
|
|
let icons2 = icons();
|
|
// Should return the same static reference
|
|
assert_eq!(icons1.len(), icons2.len());
|
|
assert!(!icons1.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_icons_contains_common_icons() {
|
|
let icons = icons();
|
|
assert!(icons.contains_key("restaurant"));
|
|
assert!(icons.contains_key("cafe"));
|
|
assert!(icons.contains_key("hotel"));
|
|
assert!(icons.contains_key("bank"));
|
|
assert!(icons.contains_key("pharmacy"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_mesh_existing() {
|
|
let mesh = icon_mesh("restaurant");
|
|
assert!(mesh.is_some());
|
|
let mesh = mesh.unwrap();
|
|
assert!(!mesh.verts.is_empty());
|
|
assert!(!mesh.indices.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_mesh_nonexistent() {
|
|
let mesh = icon_mesh("nonexistent_icon_12345");
|
|
assert!(mesh.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_mesh_all_common_icons() {
|
|
let common_icons = vec![
|
|
"alcohol", "atm", "bakery", "bank", "bar", "beauty", "bicycle",
|
|
"butcher", "cafe", "cinema", "clothes", "coffee", "convenience",
|
|
"fast_food", "florist", "gift", "greengrocer", "hairdresser",
|
|
"hotel", "ice_cream", "laundry", "library", "museum", "nightclub",
|
|
"pharmacy", "place_of_worship", "pub", "restaurant", "supermarket",
|
|
"theatre", "bench", "waste_basket", "recycling", "playground",
|
|
"statue", "entrance", "information", "traffic_signals",
|
|
"charger_pin", "parking", "charger"
|
|
];
|
|
|
|
for icon_name in common_icons {
|
|
let mesh = icon_mesh(icon_name);
|
|
assert!(mesh.is_some(), "Icon '{}' should exist", icon_name);
|
|
let mesh = mesh.unwrap();
|
|
assert!(!mesh.verts.is_empty(), "Icon '{}' should have vertices", icon_name);
|
|
assert!(!mesh.indices.is_empty(), "Icon '{}' should have indices", icon_name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_micro_icon_for_tags_bench() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("amenity".to_string(), "bench".to_string());
|
|
|
|
let result = micro_icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, label_class) = result.unwrap();
|
|
assert_eq!(icon_name, "bench");
|
|
assert_eq!(label_class, LABEL_CLASS_MUTED);
|
|
}
|
|
|
|
#[test]
|
|
fn test_micro_icon_for_tags_waste_basket() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("amenity".to_string(), "waste_basket".to_string());
|
|
|
|
let result = micro_icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, _) = result.unwrap();
|
|
assert_eq!(icon_name, "waste_basket");
|
|
}
|
|
|
|
#[test]
|
|
fn test_micro_icon_for_tags_tree() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("natural".to_string(), "tree".to_string());
|
|
|
|
let result = micro_icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, label_class) = result.unwrap();
|
|
assert_eq!(icon_name, "tree");
|
|
assert_eq!(label_class, LABEL_CLASS_TREE);
|
|
}
|
|
|
|
#[test]
|
|
fn test_micro_icon_for_tags_no_match() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("highway".to_string(), "residential".to_string());
|
|
|
|
let result = micro_icon_for_tags(&tags);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_micro_icon_for_tags_empty_tags() {
|
|
let tags = HashMap::new();
|
|
let result = micro_icon_for_tags(&tags);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_restaurant() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("amenity".to_string(), "restaurant".to_string());
|
|
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, label_class) = result.unwrap();
|
|
assert_eq!(icon_name, "restaurant");
|
|
assert_eq!(label_class, LABEL_CLASS_AMENITY);
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_cafe() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("amenity".to_string(), "cafe".to_string());
|
|
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, _) = result.unwrap();
|
|
assert_eq!(icon_name, "cafe");
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_hotel() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("tourism".to_string(), "hotel".to_string());
|
|
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, label_class) = result.unwrap();
|
|
assert_eq!(icon_name, "hotel");
|
|
assert_eq!(label_class, LABEL_CLASS_AMENITY);
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_bank() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("amenity".to_string(), "bank".to_string());
|
|
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, label_class) = result.unwrap();
|
|
assert_eq!(icon_name, "bank");
|
|
assert_eq!(label_class, LABEL_CLASS_SHOP);
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_pharmacy() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("amenity".to_string(), "pharmacy".to_string());
|
|
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, label_class) = result.unwrap();
|
|
assert_eq!(icon_name, "pharmacy");
|
|
assert_eq!(label_class, LABEL_CLASS_HEALTH);
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_supermarket() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("shop".to_string(), "supermarket".to_string());
|
|
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, label_class) = result.unwrap();
|
|
assert_eq!(icon_name, "supermarket");
|
|
assert_eq!(label_class, LABEL_CLASS_SHOP);
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_museum() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("tourism".to_string(), "museum".to_string());
|
|
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, label_class) = result.unwrap();
|
|
assert_eq!(icon_name, "museum");
|
|
assert_eq!(label_class, LABEL_CLASS_CULTURE);
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_park() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("leisure".to_string(), "park".to_string());
|
|
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (_, label_class) = result.unwrap();
|
|
assert_eq!(label_class, LABEL_CLASS_GREEN);
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_no_match() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("highway".to_string(), "residential".to_string());
|
|
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_empty_tags() {
|
|
let tags = HashMap::new();
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_priority() {
|
|
// If multiple tags match, should return the first match
|
|
let mut tags = HashMap::new();
|
|
tags.insert("amenity".to_string(), "restaurant".to_string());
|
|
tags.insert("tourism".to_string(), "hotel".to_string());
|
|
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
// The function should return one of them (implementation-dependent)
|
|
}
|
|
|
|
#[test]
|
|
fn test_transform_coord() {
|
|
let result = transform_coord(10.0, 5.0, 2.0);
|
|
assert!((result - 10.0).abs() < 0.001); // (10 - 5) * 2 = 10
|
|
}
|
|
|
|
#[test]
|
|
fn test_transform_coord_zero() {
|
|
let result = transform_coord(0.0, 0.0, 1.0);
|
|
assert!((result - 0.0).abs() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_transform_coord_negative() {
|
|
let result = transform_coord(-5.0, 0.0, 1.0);
|
|
assert!((result - (-5.0)).abs() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_icon_mesh_valid_svg() {
|
|
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14"><path d="M7 0L14 14H0z"/></svg>"#;
|
|
let mesh = build_icon_mesh(svg);
|
|
assert!(mesh.is_some());
|
|
let mesh = mesh.unwrap();
|
|
assert!(!mesh.verts.is_empty());
|
|
assert!(!mesh.indices.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_icon_mesh_invalid_svg() {
|
|
let svg = "not valid svg";
|
|
let mesh = build_icon_mesh(svg);
|
|
// Should return None for invalid SVG
|
|
assert!(mesh.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_disc_mesh() {
|
|
let mesh = build_disc_mesh(5.0);
|
|
assert!(mesh.is_some());
|
|
let mesh = mesh.unwrap();
|
|
assert!(!mesh.verts.is_empty());
|
|
assert!(!mesh.indices.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_disc_mesh_zero_radius() {
|
|
let mesh = build_disc_mesh(0.0);
|
|
// Should still create a mesh, even if degenerate
|
|
assert!(mesh.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_mesh_consistency() {
|
|
// Getting the same icon twice should return the same mesh
|
|
let mesh1 = icon_mesh("restaurant");
|
|
let mesh2 = icon_mesh("restaurant");
|
|
|
|
assert!(mesh1.is_some());
|
|
assert!(mesh2.is_some());
|
|
|
|
let mesh1 = mesh1.unwrap();
|
|
let mesh2 = mesh2.unwrap();
|
|
|
|
assert_eq!(mesh1.verts.len(), mesh2.verts.len());
|
|
assert_eq!(mesh1.indices.len(), mesh2.indices.len());
|
|
}
|
|
|
|
#[test]
|
|
fn test_micro_icon_for_tags_playground() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("leisure".to_string(), "playground".to_string());
|
|
|
|
let result = micro_icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, _) = result.unwrap();
|
|
assert_eq!(icon_name, "playground");
|
|
}
|
|
|
|
#[test]
|
|
fn test_icon_for_tags_charger() {
|
|
let mut tags = HashMap::new();
|
|
tags.insert("amenity".to_string(), "charging_station".to_string());
|
|
|
|
let result = icon_for_tags(&tags);
|
|
assert!(result.is_some());
|
|
let (icon_name, _) = result.unwrap();
|
|
assert_eq!(icon_name, "charger");
|
|
}
|
|
}
|