Blacksite/crates/game/src/navigation.rs

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
);
}
}