use std::path::{Path, PathBuf}; use bevy::prelude::Vec3; use scene::navigation::{NavigationBakeArtifact, NavigationQueryPath, NavigationQueryRuntime}; /// Loaded navigation artifact and its shared Polyanya query runtime. #[derive(Debug, Clone)] pub struct NavigationRuntime { query: NavigationQueryRuntime, } #[derive(Debug, Clone, PartialEq)] pub struct NavigationPath { pub points: Vec, pub length: f32, pub used_link_actor_ids: Vec, } impl NavigationPath { pub fn used_link_actor_id(&self) -> Option<&str> { self.used_link_actor_ids.first().map(String::as_str) } } impl From for NavigationPath { fn from(path: NavigationQueryPath) -> Self { Self { points: path.points.into_iter().map(Vec3::from_array).collect(), length: path.length, used_link_actor_ids: path.used_link_actor_ids, } } } impl NavigationRuntime { pub fn from_artifact(artifact: NavigationBakeArtifact) -> Result { Ok(Self { query: NavigationQueryRuntime::from_artifact(artifact)?, }) } pub fn load(path: &Path) -> Result { Ok(Self { query: NavigationQueryRuntime::load(path)?, }) } pub fn artifact(&self) -> &NavigationBakeArtifact { self.query.artifact() } pub fn query(&self, start: Vec3, end: Vec3) -> Result { self.query .query(start.to_array(), end.to_array()) .map(NavigationPath::from) } pub fn validate_links(&self) -> Vec { self.query.validate_links() } } pub fn query_navigation_path( artifact_path: &Path, start: Vec3, end: Vec3, ) -> Result { NavigationRuntime::load(artifact_path)?.query(start, end) } #[derive(Debug, Clone, PartialEq, Eq)] pub struct NavigationValidationSummary { pub artifact_path: PathBuf, pub enabled_link_count: usize, pub enabled_sample_count: usize, } pub fn validate_navigation_artifact( artifact_path: &Path, ) -> Result { let runtime = NavigationQueryRuntime::load(artifact_path)?; let mut findings = runtime.validate_links(); findings.extend(runtime.validate_samples()); if !findings.is_empty() { return Err(findings.join("\n")); } Ok(NavigationValidationSummary { artifact_path: artifact_path.to_path_buf(), enabled_link_count: runtime .artifact() .links .iter() .filter(|link| link.enabled) .count(), enabled_sample_count: runtime .artifact() .samples .iter() .filter(|sample| sample.enabled) .count(), }) } pub fn navigation_validation_argument(args: &[String]) -> Result, String> { let Some(index) = args .iter() .position(|argument| argument == "--validate-navigation") else { return Ok(None); }; if args.len() != 2 || index != 0 { return Err( "usage: game --validate-navigation ".into(), ); } let path = args[1].trim(); if path.is_empty() { return Err("navigation validation artifact path must not be empty".into()); } Ok(Some(PathBuf::from(path))) } #[cfg(test)] mod tests { use scene::navigation::{ bake_navigation, NavigationBakeInput, NavigationGeometryInput, NavigationLinkInput, NavigationVolumeInput, }; use shared::NavigationAgentProfile; use super::*; fn floor_geometry( actor_id: &str, min_x: f32, max_x: f32, min_z: f32, max_z: f32, y: f32, ) -> NavigationGeometryInput { NavigationGeometryInput { actor_id: actor_id.into(), triangles: vec![ [[min_x, y, min_z], [max_x, y, max_z], [max_x, y, min_z]], [[min_x, y, min_z], [min_x, y, max_z], [max_x, y, max_z]], ], } } fn input() -> NavigationBakeInput { NavigationBakeInput { source_scene: "assets/levels/navigation_showcase.scn.ron".into(), bounds_actor_id: "bounds".into(), center: [0.0, 0.0, 0.0], half_extents: [6.0, 2.0, 6.0], agent: NavigationAgentProfile { min_region_size: 1, merge_region_size: 2, ..Default::default() }, geometry: vec![floor_geometry("floor", -6.0, 6.0, -6.0, 6.0, 0.0)], obstacles: vec![NavigationVolumeInput { actor_id: "wall".into(), center: [0.0, 0.5, 0.0], half_extents: [0.5, 1.0, 4.5], }], areas: Vec::new(), links: Vec::new(), samples: Vec::new(), } } #[test] fn runtime_query_routes_on_the_baked_mesh() { let runtime = NavigationRuntime::from_artifact(bake_navigation(&input()).unwrap()).unwrap(); let path = runtime .query(Vec3::new(-4.0, 0.0, -5.0), Vec3::new(4.0, 0.0, -5.0)) .unwrap(); assert!(path.length >= 8.0); assert!(path.points.len() >= 2); } #[test] fn explicit_link_can_connect_isolated_regions() { let mut input = input(); input.obstacles[0].half_extents[2] = 6.0; input.links.push(NavigationLinkInput { actor_id: "door-link".into(), start: [-1.0, 0.0, 0.0], end: [1.0, 0.0, 0.0], bidirectional: true, cost: 1.0, enabled: true, }); let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap(); let path = runtime .query(Vec3::new(-4.0, 0.0, 0.0), Vec3::new(4.0, 0.0, 0.0)) .unwrap(); assert_eq!(path.used_link_actor_id(), Some("door-link")); assert_eq!(path.used_link_actor_ids, ["door-link"]); } #[test] fn route_can_traverse_multiple_explicit_links() { let mut input = input(); input.geometry = vec![ floor_geometry("island-a", -6.0, -3.0, -2.0, 2.0, 0.0), floor_geometry("island-b", -1.5, 1.5, -2.0, 2.0, 0.0), floor_geometry("island-c", 3.0, 6.0, -2.0, 2.0, 0.0), ]; input.obstacles.clear(); input.links = vec![ NavigationLinkInput { actor_id: "link-a-b".into(), start: [-3.6, 0.0, 0.0], end: [-1.0, 0.0, 0.0], bidirectional: true, cost: 1.0, enabled: true, }, NavigationLinkInput { actor_id: "link-b-c".into(), start: [1.0, 0.0, 0.0], end: [3.6, 0.0, 0.0], bidirectional: true, cost: 1.0, enabled: true, }, ]; let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap(); let path = runtime .query(Vec3::new(-5.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0)) .unwrap(); assert_eq!(path.used_link_actor_ids, ["link-a-b", "link-b-c"]); assert!(path.length >= 10.0); } #[test] fn query_selects_vertically_nearest_walkable_surface() { let mut input = input(); input.center = [0.0, 2.0, 0.0]; input.half_extents = [6.0, 3.0, 6.0]; input.geometry = vec![ floor_geometry("lower-floor", -5.0, 5.0, -5.0, 5.0, 0.0), floor_geometry("upper-floor", -5.0, 5.0, -5.0, 5.0, 4.0), ]; input.obstacles.clear(); let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap(); let lower = runtime .query(Vec3::new(-3.0, 0.1, 0.0), Vec3::new(3.0, 0.1, 0.0)) .unwrap(); let upper = runtime .query(Vec3::new(-3.0, 3.9, 0.0), Vec3::new(3.0, 3.9, 0.0)) .unwrap(); assert!(lower.points.iter().all(|point| point.y < 1.0)); assert!(upper.points.iter().all(|point| point.y > 3.0)); } #[test] fn invalid_link_endpoint_is_reported_with_actor_identity() { let mut input = input(); input.links.push(NavigationLinkInput { actor_id: "bad-link".into(), start: [100.0, 0.0, 100.0], end: [0.0, 0.0, 0.0], bidirectional: false, cost: 1.0, enabled: true, }); let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap(); assert!(runtime .validate_links() .iter() .any(|finding| finding.contains("bad-link start"))); } #[test] fn validation_cli_argument_is_strict_and_window_free() { assert_eq!( navigation_validation_argument(&[ "--validate-navigation".into(), "assets/navigation/generated/main.nav.ron".into(), ]) .unwrap(), Some(PathBuf::from("assets/navigation/generated/main.nav.ron")) ); assert!(navigation_validation_argument(&["--validate-navigation".into()]).is_err()); assert_eq!( navigation_validation_argument(&["--project".into(), ".".into()]).unwrap(), None ); } }