295 lines
9.3 KiB
Rust
295 lines
9.3 KiB
Rust
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<Vec3>,
|
|
pub length: f32,
|
|
pub used_link_actor_ids: Vec<String>,
|
|
}
|
|
|
|
impl NavigationPath {
|
|
pub fn used_link_actor_id(&self) -> Option<&str> {
|
|
self.used_link_actor_ids.first().map(String::as_str)
|
|
}
|
|
}
|
|
|
|
impl From<NavigationQueryPath> 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<Self, String> {
|
|
Ok(Self {
|
|
query: NavigationQueryRuntime::from_artifact(artifact)?,
|
|
})
|
|
}
|
|
|
|
pub fn load(path: &Path) -> Result<Self, String> {
|
|
Ok(Self {
|
|
query: NavigationQueryRuntime::load(path)?,
|
|
})
|
|
}
|
|
|
|
pub fn artifact(&self) -> &NavigationBakeArtifact {
|
|
self.query.artifact()
|
|
}
|
|
|
|
pub fn query(&self, start: Vec3, end: Vec3) -> Result<NavigationPath, String> {
|
|
self.query
|
|
.query(start.to_array(), end.to_array())
|
|
.map(NavigationPath::from)
|
|
}
|
|
|
|
pub fn validate_links(&self) -> Vec<String> {
|
|
self.query.validate_links()
|
|
}
|
|
}
|
|
|
|
pub fn query_navigation_path(
|
|
artifact_path: &Path,
|
|
start: Vec3,
|
|
end: Vec3,
|
|
) -> Result<NavigationPath, String> {
|
|
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<NavigationValidationSummary, String> {
|
|
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<Option<PathBuf>, 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 <project-relative-or-absolute-artifact-path>".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
|
|
);
|
|
}
|
|
}
|