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

nordisk/facett

facett-map3d/tests/garmin_nav_robot.rs

//! **ROBOT-UI — Garmin turn-by-turn nav chrome** (live egui_kittest render + data drive).
//!
//! Drives the LIVE `Map3D` Facet headless and proves the new car-navigator gaps the
//! `feat-osm-garmin` work closed, as DATA (`state_json`) AND as pixels:
//!   • MANEUVER MODEL: attaching an L-shaped route derives a discrete turn maneuver
//!     (`maneuver_count == 1`) and a next-turn banner ("… Turn … in …").
//!   • BANNER: turning the maneuver overlay on paints the top-of-screen banner (the
//!     top-centre strip differs from the overlay-off frame).
//!   • VEHICLE MARKER: the heading-oriented chevron reports on (`show_vehicle`) with a
//!     live bearing.
//!   • NIGHT MODE: toggling night flips the `night` state flag (the sun/sky preset swap).
//!
//! Run: `cargo test -p facett-map3d --features testmatrix --test garmin_nav_robot`

use egui_kittest::Harness;
use facett_core::{Facet, Theme};
use facett_map::Way;
use facett_map3d::Map3D;

fn city() -> Vec<Way> {
    let mut ways = Vec::new();
    let mut id = 1;
    for r in 0..6 {
        for c in 0..6 {
            let bx = 17.70 + c as f64 * 0.0012;
            let by = 59.61 + r as f64 * 0.0012;
            let tags = vec![("building".to_string(), "yes".to_string())];
            let ring = [(bx, by), (bx + 0.0007, by), (bx + 0.0007, by + 0.0007), (bx, by + 0.0007), (bx, by)];
            ways.push(Way::from_lonlat(id, &ring, &tags, &Theme::default()).expect("a building way"));
            id += 1;
        }
    }
    ways
}

/// An L-shaped route (east, then north) — one 90° corner ⇒ exactly one turn maneuver.
fn l_route_points() -> Vec<(f32, f32)> {
    let stops = vec![(17.7006, 59.6106), (17.7054, 59.6106), (17.7054, 59.6154)];
    Way::line(9001, "road", stops, egui::Color32::WHITE, 2.0).points
}

const SIZE: f32 = 480.0;

/// Render the map headless (maneuver overlay on/off) at a pinned clock, route + minimap
/// on, and return the frame.
fn render(maneuvers: bool) -> image::RgbaImage {
    use std::cell::RefCell;
    use std::rc::Rc;
    let ways = city();
    let pts = l_route_points();
    let view = Rc::new(RefCell::new({
        let mut m = Map3D::from_geometry("OSM 3D", &ways).with_minimap(true);
        m.set_route(&ways, &pts);
        m.set_maneuvers(maneuvers);
        m.set_vehicle(true);
        m.spin = false;
        m.set_clock(0.0);
        m
    }));
    let v2 = view.clone();
    let mut harness = Harness::builder().with_size(egui::vec2(SIZE, SIZE)).build_ui(move |ui| {
        Facet::ui(&mut *v2.borrow_mut(), ui);
    });
    harness.run_steps(3);
    harness.render().expect("headless render produced an image")
}

fn diff_in_region(a: &image::RgbaImage, b: &image::RgbaImage, x0: f32, y0: f32, x1: f32, y1: f32) -> u64 {
    let (w, h) = a.dimensions();
    let (px0, py0) = ((x0 * w as f32) as u32, (y0 * h as f32) as u32);
    let (px1, py1) = ((x1 * w as f32) as u32, (y1 * h as f32) as u32);
    let mut d = 0u64;
    for y in py0..py1.min(h) {
        for x in px0..px1.min(w) {
            if a.get_pixel(x, y) != b.get_pixel(x, y) {
                d += 1;
            }
        }
    }
    d
}

#[test]
fn maneuver_banner_vehicle_and_night_drive_the_state_and_paint() {
    // ── MANEUVER MODEL + banner + vehicle as DATA ─────────────────────────────────
    let ways = city();
    let pts = l_route_points();
    let mut m = Map3D::from_geometry("OSM 3D", &ways)
        .with_minimap(true)
        .with_maneuvers(true)
        .with_vehicle(true);
    m.set_route(&ways, &pts);
    m.set_clock(0.0);

    let plan = m.maneuver_plan().expect("a maneuver plan for the routed L");
    let turns = plan.turn_count();
    let banner = m.maneuver_banner().expect("a next-turn banner at the start");
    let veh_bearing = m.vehicle_bearing_deg();

    let j = m.state_json();
    let count_json = j["maneuver_count"].as_u64().unwrap_or(0);
    let banner_json = j["maneuver_banner"].as_str().unwrap_or("").to_string();
    let show_man = j["show_maneuvers"].as_bool().unwrap_or(false);
    let show_veh = j["show_vehicle"].as_bool().unwrap_or(false);

    assert_eq!(turns, 1, "the L-route derives exactly one turn maneuver");
    assert_eq!(count_json, 1, "state_json surfaces the turn count");
    assert!(banner.contains(" in "), "the banner reads a distance: {banner}");
    assert!(
        banner.contains("Turn") || banner.contains("Bear") || banner.contains("Sharp") || banner.contains("U-turn"),
        "the banner names a turn instruction: {banner}"
    );
    assert_eq!(banner, banner_json, "the typed banner matches state_json");
    assert!(show_man, "state_json reports the maneuver overlay is on");
    assert!(show_veh, "state_json reports the vehicle chevron is on");
    assert!(veh_bearing.is_some(), "the vehicle has a live heading");

    // ── NIGHT MODE flips the state flag (sun/sky preset swap) ──────────────────────
    assert!(!m.night_on(), "day by default");
    m.set_night(true);
    assert!(m.night_on(), "night mode engages");
    assert!(m.state_json()["night"].as_bool().unwrap_or(false), "state_json reports night");

    // ── AUTO-ZOOM by speed is opt-in + moves the chase pose ────────────────────────
    m.set_route_follow(true);
    let dist_fixed = m.state_json()["camera"]["distance"].as_f64().unwrap();
    m.set_auto_zoom(true);
    let dist_auto = m.state_json()["camera"]["distance"].as_f64().unwrap();
    let auto_zoomed = dist_auto > dist_fixed + 1e-4;
    assert!(auto_zoomed, "auto-zoom dollies the follow camera out ({dist_fixed} → {dist_auto})");

    // ── BANNER PAINTS: the top-centre strip differs with the overlay on vs off ─────
    let plain = render(false);
    let banded = render(true);
    let banner_diff = diff_in_region(&plain, &banded, 0.2, 0.0, 0.8, 0.12);
    let painted = banner_diff > 20;
    assert!(painted, "the next-turn banner paints across the top strip (diff={banner_diff} px)");

    #[cfg(feature = "testmatrix")]
    {
        let ok = turns == 1 && count_json == 1 && show_man && show_veh && veh_bearing.is_some() && auto_zoomed && painted;
        facett_core::testmatrix::emit(
            "facett-map3d",
            "garmin_nav_robot_maneuver_banner_vehicle_night",
            ok,
            &format!(
                "turns={turns} banner={banner:?} show_man={show_man} show_veh={show_veh} \
                 auto_zoomed={auto_zoomed} banner_diff={banner_diff} painted={painted}"
            ),
        );
    }
}

/// The two remaining Garmin gaps as DATA (`state_json`):
///   • CHEVRON-FLOW (F2): opt-in direction arrows march along the ribbon — the glyphs
///     bake extra triangles into the SAME overlay mesh and count in `state_json`
///     (off ⇒ 0, the red-when-broken oracle).
///   • PREDICTIVE LOOK-AHEAD (F1): the follow camera aims a look-ahead point AHEAD of the
///     vehicle — with it on the camera target LEADS the vehicle (anticipating the road);
///     off ⇒ the target sits on the vehicle.
#[test]
fn chevron_flow_bakes_into_the_mesh_and_lookahead_leads_the_vehicle() {
    let ways = city();
    let pts = l_route_points();

    // ── CHEVRON-FLOW (F2) ─────────────────────────────────────────────────────────
    let mut plain = Map3D::from_geometry("OSM 3D", &ways);
    plain.set_route(&ways, &pts);
    let mut chevroned = Map3D::from_geometry("OSM 3D", &ways).with_chevrons(true);
    chevroned.set_route(&ways, &pts);

    let base_tris = plain.state_json()["triangles"].as_u64().unwrap();
    let cj = chevroned.state_json();
    let chev_tris = cj["triangles"].as_u64().unwrap();
    let chev_count = cj["chevron_count"].as_u64().unwrap_or(0);
    let show_chev = cj["show_chevrons"].as_bool().unwrap_or(false);
    assert!(show_chev, "state_json reports the chevron flow is on");
    assert!(chev_count > 0, "the routed L carries a chevron train (got {chev_count})");
    assert!(chev_tris > base_tris, "chevrons bake extra triangles into the overlay mesh ({base_tris} → {chev_tris})");
    assert_eq!(plain.state_json()["chevron_count"].as_u64().unwrap_or(99), 0, "no chevrons when off");

    // ── PREDICTIVE LOOK-AHEAD (F1) ────────────────────────────────────────────────
    let mut m = Map3D::from_geometry("OSM 3D", &ways);
    m.set_route(&ways, &pts);
    m.set_clock(0.0);
    m.set_route_follow(true);
    m.update(facett_map3d::Msg::RouteProgress(0.5)); // mid-route, moving forward

    let veh = m.route().unwrap().point_at(0.5);
    let xz = |t: &serde_json::Value| {
        let a = t.as_array().unwrap();
        (a[0].as_f64().unwrap(), a[2].as_f64().unwrap())
    };
    let lead = |tx: f64, tz: f64| ((tx - veh[0] as f64).powi(2) + (tz - veh[2] as f64).powi(2)).sqrt();

    // Look-ahead OFF: the follow target sits ON the vehicle.
    let (ox, oz) = xz(&m.state_json()["camera"]["target"]);
    let lead_off = lead(ox, oz);

    m.set_lookahead(true);
    let lj = m.state_json();
    let (lx, lz) = xz(&lj["camera"]["target"]);
    let lead_on = lead(lx, lz);
    let look_on = lj["lookahead"].as_bool().unwrap_or(false);
    let look_brg = lj["lookahead_bearing_deg"].as_f64();

    assert!(look_on, "state_json reports look-ahead is on");
    assert!(look_brg.is_some(), "state_json surfaces the anticipatory bearing");
    assert!(lead_off < 1e-4, "plain follow locks the target on the vehicle (lead {lead_off})");
    assert!(lead_on > 1e-3, "look-ahead makes the target LEAD the vehicle (lead {lead_on})");
    // Toggling look-ahead back off returns the target to the vehicle.
    m.set_lookahead(false);
    let (rx, rz) = xz(&m.state_json()["camera"]["target"]);
    assert!(lead(rx, rz) < 1e-4, "look-ahead off re-locks the target on the vehicle");

    #[cfg(feature = "testmatrix")]
    {
        let ok = show_chev && chev_count > 0 && chev_tris > base_tris && look_on && lead_off < 1e-4 && lead_on > 1e-3;
        facett_core::testmatrix::emit(
            "facett-map3d",
            "garmin_nav_robot_chevron_flow_and_lookahead",
            ok,
            &format!("chev_count={chev_count} base_tris={base_tris} chev_tris={chev_tris} lead_off={lead_off:.5} lead_on={lead_on:.5}"),
        );
    }
}