facett-map3d/tests/manual_control.rs
//! **ROBOT-UI — seize the stick from the autopilot tour** (live egui_kittest render +
//! data drive).
//!
//! The owner reported that the cinematic tour plays but "I can't take control over the
//! flight". The fix: any manual gesture (orbit / pan / dolly / keyboard) routes its
//! `Msg` through `Map3D::update`, which now calls `take_manual_control()` first —
//! freezing the camera at the tour's current pose and dropping the tour so the user
//! flies freely — and the tour key (T → `Msg::SetTour(true)`) re-arms it.
//!
//! This drives the LIVE Facet the way the demo does (the SAME `Msg`s the drag/wheel/key
//! handlers emit, applied through the ELM `update` path, rendered on the CPU painter)
//! and proves, as DATA off `state_json` + the render surface:
//! 1. with the tour armed, `tour_active` is true and the camera flies the tour pose;
//! 2. a manual orbit gesture SEIZES control — `tour_active` flips false and the eye
//! keeps moving as more manual input arrives (the user is flying, not the tour);
//! 3. the seize is JUMP-FREE — control is taken from exactly the tour's pose;
//! 4. re-arming (`SetTour(true)`) hands the stick back — `tour_active` true again.
//!
//! RED when broken: revert the `take_manual_control()` calls and step 2 fails (the tour
//! clobbers the camera every frame, so manual input can't move the eye / disarm it).
//!
//! Run: `cargo test -p facett-map3d --features testmatrix --test manual_control`
use egui_kittest::Harness;
use facett_core::{Facet, Theme};
use facett_map::Way;
use facett_map3d::{Map3D, Msg};
/// A small extruded town + a diagonal street the drive phase follows (same shape as
/// the tour robot fixture).
fn town() -> (Vec<Way>, Vec<(f32, f32)>) {
let mut ways = Vec::new();
let mut id = 1i64;
for r in 0..6_i32 {
for c in 0..6_i32 {
if (r - c).abs() < 1 {
continue;
}
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;
}
}
let stops: Vec<(f64, f64)> = (0..6).map(|i| (17.70 + i as f64 * 0.0012, 59.61 + i as f64 * 0.0012)).collect();
let road = Way::line(9001, "road", stops, egui::Color32::WHITE, 2.0);
let waypoints = road.points.clone();
ways.push(road);
(ways, waypoints)
}
fn eye(m: &Map3D) -> [f32; 3] {
let e = m.render_camera().eye();
[e.x, e.y, e.z]
}
fn dist2(a: [f32; 3], b: [f32; 3]) -> f32 {
(a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)
}
#[test]
fn manual_input_seizes_the_stick_and_t_rearms_the_tour() {
use std::cell::RefCell;
use std::rc::Rc;
let (ways, wps) = town();
let view = Rc::new(RefCell::new({
let mut m = Map3D::from_geometry_roofed("seize", &ways).with_route(&ways, &wps).with_tour(true);
m.spin = false;
m.set_clock(40.0); // pin into the street-drive phase (drive_secs 26..52)
m
}));
let v2 = view.clone();
let mut harness = Harness::builder().with_size(egui::vec2(420.0, 420.0)).build_ui(move |ui| {
Facet::ui(&mut *v2.borrow_mut(), ui);
});
harness.run_steps(2);
// (1) The tour is armed and flying — the pose is the tour's, not manual.
let tour_eye = eye(&view.borrow());
assert!(view.borrow().tour_active(), "the tour is armed and flying the camera");
let phase = view.borrow().state_json()["tour_phase"].as_str().unwrap_or("none").to_string();
assert_eq!(phase, "drive", "pinned clock renders the street-drive phase");
// (2) A manual orbit gesture (the exact Msg a left-drag emits) SEIZES control.
view.borrow_mut().update(Msg::Orbit { dx: 60.0, dy: -25.0 });
harness.run_steps(1);
assert!(!view.borrow().tour_active(), "a manual drag disarms the autopilot (stick seized)");
let seized_eye = eye(&view.borrow());
// (3) The seize is jump-free: control was taken FROM the tour's pose (the first
// manual orbit is a small nudge off it, not a teleport to some default framing).
let jump = dist2(tour_eye, seized_eye).sqrt();
assert!(jump < 1.5, "control is seized from the tour pose, no big jump (moved {jump:.3})");
// …and MORE manual input keeps moving the eye — the user is flying now, the tour
// is not clobbering the camera back every frame.
view.borrow_mut().update(Msg::Orbit { dx: 120.0, dy: 0.0 });
view.borrow_mut().update(Msg::Settle);
harness.run_steps(1);
let flown_eye = eye(&view.borrow());
let moved = dist2(seized_eye, flown_eye).sqrt();
assert!(moved > 1e-3, "manual input keeps flying the camera ({moved:.4}) — the tour no longer overrides it");
// (4) Re-arm: T → SetTour(true) hands the stick back to the autopilot.
view.borrow_mut().update(Msg::SetTour(true));
harness.run_steps(1);
assert!(view.borrow().tour_active(), "T re-arms the cinematic tour");
#[cfg(feature = "testmatrix")]
{
let ok = phase == "drive" && jump < 1.5 && moved > 1e-3 && view.borrow().tour_active();
facett_core::testmatrix::emit(
"facett-map3d",
"manual_control_seizes_and_rearms",
ok,
&format!("seize_jump={jump:.3} manual_move={moved:.4} rearmed={}", view.borrow().tour_active()),
);
}
}