use std::fs; use std::path::{Path, PathBuf}; use nav_glam::{UVec3, Vec2, Vec3, Vec3A}; use polyanya::{Mesh, RecastFullMesh}; use rerecast::{ Aabb3d, AreaType, ConfigBuilder, ConvexVolume, DetailNavmesh, HeightfieldBuilder, TriMesh, }; use serde::{Deserialize, Serialize}; use shared::{ BrushDesc, BrushKind, NavigationAgentProfile, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle, Primitive, PrimitiveShape, COMPONENT_BRUSH_DESC, COMPONENT_NAVIGATION_AREA, COMPONENT_NAVIGATION_BOUNDS, COMPONENT_NAVIGATION_LINK, COMPONENT_NAVIGATION_OBSTACLE, COMPONENT_PRIMITIVE, }; use crate::document::{SceneDocument, SceneEntity}; pub const NAVIGATION_ARTIFACT_SCHEMA_VERSION: u32 = 1; pub const NAVIGATION_GENERATOR_ID: &str = "rerecast-0.3/polyanya-0.16"; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NavigationVolumeInput { pub actor_id: String, pub center: [f32; 3], pub half_extents: [f32; 3], } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NavigationAreaInput { pub actor_id: String, pub id: String, pub center: [f32; 3], pub half_extents: [f32; 3], pub cost: f32, pub walkable: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NavigationLinkInput { pub actor_id: String, pub start: [f32; 3], pub end: [f32; 3], pub bidirectional: bool, pub cost: f32, pub enabled: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NavigationGeometryInput { pub actor_id: String, pub triangles: Vec<[[f32; 3]; 3]>, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NavigationBakeInput { pub source_scene: String, pub bounds_actor_id: String, pub center: [f32; 3], pub half_extents: [f32; 3], pub agent: NavigationAgentProfile, #[serde(default)] pub geometry: Vec, #[serde(default)] pub obstacles: Vec, #[serde(default)] pub areas: Vec, #[serde(default)] pub links: Vec, } #[derive(Debug, Clone, PartialEq)] pub struct NavigationBakeJob { pub artifact_path: String, pub input: NavigationBakeInput, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NavigationBakeArtifact { pub schema_version: u32, pub generator: String, pub source_scene: String, pub source_fingerprint: String, pub bounds_actor_id: String, pub center: [f32; 3], pub half_extents: [f32; 3], pub agent: NavigationAgentProfile, pub mesh: Mesh, pub links: Vec, pub areas: Vec, } impl NavigationBakeArtifact { pub fn runtime_mesh(&self) -> Mesh { let mut mesh = self.mesh.clone(); mesh.bake(); mesh } pub fn is_stale_for(&self, input: &NavigationBakeInput) -> bool { self.schema_version != NAVIGATION_ARTIFACT_SCHEMA_VERSION || self.generator != NAVIGATION_GENERATOR_ID || self.source_fingerprint != navigation_source_fingerprint(input) } } pub fn navigation_source_fingerprint(input: &NavigationBakeInput) -> String { let bytes = ron::ser::to_string(input) .expect("navigation bake input contains only serializable authoring data"); blake3::hash(bytes.as_bytes()).to_hex().to_string() } pub fn bake_navigation(input: &NavigationBakeInput) -> Result { validate_navigation_input(input)?; let center = vec3(input.center); let half_extents = vec3(input.half_extents); let min = center - half_extents; let max = center + half_extents; let mut trimesh = floor_trimesh(min, max, center.y); append_geometry(&mut trimesh, &input.geometry); let config = ConfigBuilder { cell_size_fraction: input.agent.cell_size_fraction, cell_height_fraction: input.agent.cell_height_fraction, agent_height: input.agent.height, agent_radius: input.agent.radius, walkable_climb: input.agent.max_climb, walkable_slope_angle: input.agent.max_slope_deg.to_radians(), min_region_size: input.agent.min_region_size, merge_region_size: input.agent.merge_region_size, aabb: Aabb3d { min, max }, ..Default::default() } .build(); trimesh.mark_walkable_triangles(config.walkable_slope_angle); let mut heightfield = HeightfieldBuilder { aabb: config.aabb, cell_size: config.cell_size, cell_height: config.cell_height, } .build() .map_err(|error| format!("failed to allocate navigation heightfield: {error}"))?; heightfield .rasterize_triangles(&trimesh, config.walkable_climb) .map_err(|error| format!("failed to rasterize navigation geometry: {error}"))?; heightfield.filter_low_hanging_walkable_obstacles(config.walkable_climb); heightfield.filter_ledge_spans(config.walkable_height, config.walkable_climb); heightfield.filter_walkable_low_height_spans(config.walkable_height); let mut compact = heightfield .into_compact(config.walkable_height, config.walkable_climb) .map_err(|error| format!("failed to compact navigation heightfield: {error}"))?; compact.erode_walkable_area(config.walkable_radius); for obstacle in &input.obstacles { compact.mark_convex_poly_area(&unwalkable_volume(obstacle.center, obstacle.half_extents)); } for area in input.areas.iter().filter(|area| !area.walkable) { compact.mark_convex_poly_area(&unwalkable_volume(area.center, area.half_extents)); } compact.build_distance_field(); compact .build_regions( config.border_size, config.min_region_area, config.merge_region_area, ) .map_err(|error| format!("failed to partition navigation regions: {error}"))?; let contours = compact.build_contours( config.max_simplification_error, config.max_edge_len, config.contour_flags, ); let polygon = contours .into_polygon_mesh(config.max_vertices_per_polygon) .map_err(|error| format!("failed to build navigation polygons: {error}"))?; if polygon.polygon_count() == 0 { return Err( "navigation bake produced no walkable polygons; enlarge bounds or repair obstacles" .into(), ); } let detail = DetailNavmesh::new( &polygon, &compact, config.detail_sample_dist, config.detail_sample_max_error, ) .map_err(|error| format!("failed to build navigation detail mesh: {error}"))?; let mut mesh: Mesh = RecastFullMesh::new(polygon, detail).into(); mesh.unbake(); Ok(NavigationBakeArtifact { schema_version: NAVIGATION_ARTIFACT_SCHEMA_VERSION, generator: NAVIGATION_GENERATOR_ID.into(), source_scene: input.source_scene.clone(), source_fingerprint: navigation_source_fingerprint(input), bounds_actor_id: input.bounds_actor_id.clone(), center: input.center, half_extents: input.half_extents, agent: input.agent.clone(), mesh, links: input.links.clone(), areas: input.areas.clone(), }) } pub fn write_navigation_artifact( path: &Path, artifact: &NavigationBakeArtifact, ) -> Result<(), String> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| { format!("failed to create navigation artifact directory {parent:?}: {error}") })?; } let text = ron::ser::to_string_pretty(artifact, ron::ser::PrettyConfig::default()) .map_err(|error| format!("failed to serialize navigation artifact: {error}"))?; let temporary = temporary_path(path); fs::write(&temporary, text) .map_err(|error| format!("failed to write navigation artifact {temporary:?}: {error}"))?; fs::rename(&temporary, path) .map_err(|error| format!("failed to replace navigation artifact {path:?}: {error}")) } pub fn read_navigation_artifact(path: &Path) -> Result { let text = fs::read_to_string(path) .map_err(|error| format!("failed to read navigation artifact {path:?}: {error}"))?; let artifact: NavigationBakeArtifact = ron::from_str(&text) .map_err(|error| format!("failed to parse navigation artifact {path:?}: {error}"))?; if artifact.schema_version != NAVIGATION_ARTIFACT_SCHEMA_VERSION { return Err(format!( "navigation artifact schema {} is unsupported; rebake with schema {}", artifact.schema_version, NAVIGATION_ARTIFACT_SCHEMA_VERSION )); } Ok(artifact) } pub fn navigation_bake_jobs_from_document( document: &SceneDocument, source_scene: &str, ) -> Result, String> { if !document.entities.iter().any(|entity| { entity .components .iter() .any(|component| component.type_name == COMPONENT_NAVIGATION_BOUNDS) }) { return Ok(Vec::new()); } let poses = document_world_poses(document)?; let mut obstacles = Vec::new(); let mut areas = Vec::new(); let mut links = Vec::new(); let mut bounds = Vec::new(); let mut geometry = Vec::new(); for entity in &document.entities { let bounds_value = component::(entity, COMPONENT_NAVIGATION_BOUNDS)?; let obstacle_value = component::(entity, COMPONENT_NAVIGATION_OBSTACLE)?; let area_value = component::(entity, COMPONENT_NAVIGATION_AREA)?; let link_value = component::(entity, COMPONENT_NAVIGATION_LINK)?; let primitive = component::(entity, COMPONENT_PRIMITIVE)?; let brush = component::(entity, COMPONENT_BRUSH_DESC)?; if bounds_value.is_none() && obstacle_value.is_none() && area_value.is_none() && link_value.is_none() && primitive.is_none() && brush.is_none() { continue; } let actor_id = entity .actor_id .clone() .ok_or_else(|| format!("navigation entity {} requires ActorId", entity.document_id))?; let pose = poses .get(&actor_id) .copied() .unwrap_or_else(WorldPose::identity); if let Some(value) = bounds_value { bounds.push((actor_id.clone(), pose, value)); } if let Some(value) = obstacle_value { obstacles.push(NavigationVolumeInput { actor_id: actor_id.clone(), center: pose.translation.to_array(), half_extents: rotated_aabb_half_extents(pose, value.half_extents.to_array()), }); } if let Some(value) = area_value { areas.push(NavigationAreaInput { actor_id: actor_id.clone(), id: value.id, center: pose.translation.to_array(), half_extents: rotated_aabb_half_extents(pose, value.half_extents.to_array()), cost: value.cost, walkable: value.walkable, }); } if let Some(value) = link_value { links.push(NavigationLinkInput { actor_id: actor_id.clone(), start: pose.transform_point(value.start.to_array()).to_array(), end: pose.transform_point(value.end.to_array()).to_array(), bidirectional: value.bidirectional, cost: value.cost, enabled: value.enabled, }); } if let Some(value) = navigation_geometry_from_parts( actor_id, pose.translation.to_array(), pose.rotation.to_array(), pose.scale.to_array(), primitive.as_ref(), brush.as_ref(), ) { geometry.push(value); } } obstacles.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); areas.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); links.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); geometry.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); bounds.sort_by(|a, b| a.0.cmp(&b.0)); Ok(bounds .into_iter() .map(|(actor_id, pose, bounds)| { let center = pose.translation.to_array(); let half_extents = rotated_aabb_half_extents(pose, bounds.half_extents.to_array()); NavigationBakeJob { artifact_path: bounds.artifact_path, input: NavigationBakeInput { source_scene: source_scene.to_string(), bounds_actor_id: actor_id, center, half_extents, agent: bounds.agent, geometry: geometry .iter() .filter(|value| geometry_overlaps_bounds(value, center, half_extents)) .cloned() .collect(), obstacles: obstacles .iter() .filter(|value| { aabb_overlaps(center, half_extents, value.center, value.half_extents) }) .cloned() .collect(), areas: areas .iter() .filter(|value| { aabb_overlaps(center, half_extents, value.center, value.half_extents) }) .cloned() .collect(), links: links .iter() .filter(|value| { point_in_aabb(value.start, center, half_extents) || point_in_aabb(value.end, center, half_extents) }) .cloned() .collect(), }, } }) .collect()) } fn validate_navigation_input(input: &NavigationBakeInput) -> Result<(), String> { input .agent .validate() .map_err(|error| format!("invalid navigation agent profile: {error}"))?; if input.source_scene.trim().is_empty() { return Err("navigation bake requires a source scene path".into()); } if input.bounds_actor_id.trim().is_empty() { return Err("navigation bounds actor requires a stable ActorId".into()); } validate_half_extents(input.half_extents, "navigation bounds")?; for geometry in &input.geometry { if geometry.actor_id.trim().is_empty() { return Err("navigation geometry requires a stable actor ID".into()); } for triangle in &geometry.triangles { if !triangle.iter().copied().all(finite3) { return Err(format!( "navigation geometry {} contains a non-finite triangle", geometry.actor_id )); } } } for obstacle in &input.obstacles { validate_half_extents(obstacle.half_extents, "navigation obstacle")?; } for area in &input.areas { validate_half_extents(area.half_extents, "navigation area")?; if area.id.trim().is_empty() { return Err(format!("navigation area {} has an empty ID", area.actor_id)); } if !area.cost.is_finite() || area.cost <= 0.0 { return Err(format!( "navigation area {} cost must be finite and greater than zero", area.actor_id )); } } for link in &input.links { if link.enabled && (!finite3(link.start) || !finite3(link.end)) { return Err(format!( "navigation link {} endpoints must be finite", link.actor_id )); } if link.enabled && squared_distance(link.start, link.end) <= f32::EPSILON { return Err(format!( "navigation link {} endpoints must be distinct", link.actor_id )); } if !link.cost.is_finite() || link.cost <= 0.0 { return Err(format!( "navigation link {} cost must be finite and greater than zero", link.actor_id )); } } Ok(()) } fn floor_trimesh(min: Vec3, max: Vec3, y: f32) -> TriMesh { TriMesh { vertices: vec![ Vec3A::new(min.x, y, min.z), Vec3A::new(max.x, y, min.z), Vec3A::new(max.x, y, max.z), Vec3A::new(min.x, y, max.z), ], indices: vec![UVec3::new(0, 2, 1), UVec3::new(0, 3, 2)], area_types: vec![AreaType::DEFAULT_WALKABLE; 2], } } fn append_geometry(trimesh: &mut TriMesh, geometry: &[NavigationGeometryInput]) { for source in geometry { for triangle in &source.triangles { let base = trimesh.vertices.len() as u32; trimesh .vertices .extend(triangle.iter().map(|vertex| Vec3A::from_array(*vertex))); trimesh.indices.push(UVec3::new(base, base + 1, base + 2)); trimesh.area_types.push(AreaType::DEFAULT_WALKABLE); } } } pub fn navigation_geometry_from_parts( actor_id: String, translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3], primitive: Option<&Primitive>, brush: Option<&BrushDesc>, ) -> Option { let pose = WorldPose { translation: Vec3::from_array(translation), rotation: nav_glam::Quat::from_array(rotation).normalize(), scale: Vec3::from_array(scale), }; let local_triangles = if let Some(brush) = brush.filter(|brush| brush.kind == BrushKind::Additive) { brush_triangles(brush) } else if let Some(primitive) = primitive { primitive_triangles(primitive) } else { Vec::new() }; (!local_triangles.is_empty()).then(|| NavigationGeometryInput { actor_id, triangles: local_triangles .into_iter() .map(|triangle| triangle.map(|point| pose.transform_point(point).to_array())) .collect(), }) } fn brush_triangles(brush: &BrushDesc) -> Vec<[[f32; 3]; 3]> { let mut triangles = Vec::new(); for face in &brush.faces { let Some(first) = face.vertices.first() else { continue; }; for edge in face.vertices[1..].windows(2) { triangles.push([first.to_array(), edge[0].to_array(), edge[1].to_array()]); } } triangles } fn primitive_triangles(primitive: &Primitive) -> Vec<[[f32; 3]; 3]> { let half = primitive.size.abs() * 0.5; let vertices = match primitive.shape { PrimitiveShape::Box => vec![ [-half.x, -half.y, -half.z], [half.x, -half.y, -half.z], [half.x, half.y, -half.z], [-half.x, half.y, -half.z], [-half.x, -half.y, half.z], [half.x, -half.y, half.z], [half.x, half.y, half.z], [-half.x, half.y, half.z], ], PrimitiveShape::Ramp => vec![ [-half.x, -half.y, -half.z], [half.x, -half.y, -half.z], [half.x, half.y, -half.z], [-half.x, half.y, -half.z], [-half.x, -half.y, half.z], [half.x, -half.y, half.z], ], PrimitiveShape::Sphere => vec![ [0.0, half.y, 0.0], [half.x, 0.0, 0.0], [0.0, 0.0, half.z], [-half.x, 0.0, 0.0], [0.0, 0.0, -half.z], [0.0, -half.y, 0.0], ], }; let indices: &[[usize; 3]] = match primitive.shape { PrimitiveShape::Box => &[ [0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [3, 7, 6], [3, 6, 2], [0, 4, 7], [0, 7, 3], [1, 2, 6], [1, 6, 5], ], PrimitiveShape::Ramp => &[ [0, 2, 1], [0, 3, 2], [0, 1, 5], [0, 5, 4], [0, 4, 3], [1, 2, 5], [3, 4, 5], [3, 5, 2], ], PrimitiveShape::Sphere => &[ [0, 2, 1], [0, 3, 2], [0, 4, 3], [0, 1, 4], [5, 1, 2], [5, 2, 3], [5, 3, 4], [5, 4, 1], ], }; indices .iter() .map(|[a, b, c]| [vertices[*a], vertices[*b], vertices[*c]]) .collect() } pub fn aabb_overlaps( a_center: [f32; 3], a_half: [f32; 3], b_center: [f32; 3], b_half: [f32; 3], ) -> bool { (0..3).all(|axis| (a_center[axis] - b_center[axis]).abs() <= a_half[axis] + b_half[axis]) } pub fn point_in_aabb(point: [f32; 3], center: [f32; 3], half_extents: [f32; 3]) -> bool { (0..3).all(|axis| (point[axis] - center[axis]).abs() <= half_extents[axis]) } pub fn geometry_overlaps_bounds( geometry: &NavigationGeometryInput, center: [f32; 3], half_extents: [f32; 3], ) -> bool { geometry.triangles.iter().any(|triangle| { let mut min = triangle[0]; let mut max = triangle[0]; for vertex in &triangle[1..] { for axis in 0..3 { min[axis] = min[axis].min(vertex[axis]); max[axis] = max[axis].max(vertex[axis]); } } let geometry_center = std::array::from_fn(|axis| (min[axis] + max[axis]) * 0.5); let geometry_half = std::array::from_fn(|axis| (max[axis] - min[axis]) * 0.5); aabb_overlaps(center, half_extents, geometry_center, geometry_half) }) } fn unwalkable_volume(center: [f32; 3], half_extents: [f32; 3]) -> ConvexVolume { let center = vec3(center); let half = vec3(half_extents); ConvexVolume { vertices: vec![ Vec2::new(center.x - half.x, center.z - half.z), Vec2::new(center.x + half.x, center.z - half.z), Vec2::new(center.x + half.x, center.z + half.z), Vec2::new(center.x - half.x, center.z + half.z), ], min_y: center.y - half.y, max_y: center.y + half.y, area: AreaType::NOT_WALKABLE, } } fn vec3(value: [f32; 3]) -> Vec3 { Vec3::from_array(value) } fn finite3(value: [f32; 3]) -> bool { value.into_iter().all(f32::is_finite) } fn validate_half_extents(value: [f32; 3], label: &str) -> Result<(), String> { if !finite3(value) || value.into_iter().any(|axis| axis <= 0.0) { return Err(format!( "{label} half extents must be finite and greater than zero" )); } Ok(()) } fn squared_distance(a: [f32; 3], b: [f32; 3]) -> f32 { a.into_iter().zip(b).map(|(a, b)| (a - b) * (a - b)).sum() } fn temporary_path(path: &Path) -> PathBuf { let mut temporary = path.as_os_str().to_os_string(); temporary.push(".tmp"); PathBuf::from(temporary) } const TRANSFORM_COMPONENT: &str = "bevy_transform::components::transform::Transform"; #[derive(Debug, Clone, Copy, Deserialize)] struct SerializedTransform { translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3], } impl Default for SerializedTransform { fn default() -> Self { Self { translation: [0.0; 3], rotation: [0.0, 0.0, 0.0, 1.0], scale: [1.0; 3], } } } #[derive(Debug, Clone, Copy)] struct WorldPose { translation: Vec3, rotation: nav_glam::Quat, scale: Vec3, } impl WorldPose { fn identity() -> Self { Self { translation: Vec3::ZERO, rotation: nav_glam::Quat::IDENTITY, scale: Vec3::ONE, } } fn from_serialized(value: SerializedTransform) -> Self { Self { translation: Vec3::from_array(value.translation), rotation: nav_glam::Quat::from_array(value.rotation).normalize(), scale: Vec3::from_array(value.scale), } } fn compose(self, local: Self) -> Self { Self { translation: self.translation + self.rotation * (self.scale * local.translation), rotation: (self.rotation * local.rotation).normalize(), scale: self.scale * local.scale, } } fn transform_point(self, point: [f32; 3]) -> Vec3 { self.translation + self.rotation * (self.scale * Vec3::from_array(point)) } } fn document_world_poses( document: &SceneDocument, ) -> Result, String> { let entities: std::collections::BTreeMap<&str, &SceneEntity> = document .entities .iter() .filter_map(|entity| Some((entity.actor_id.as_deref()?, entity))) .collect(); let mut poses = std::collections::BTreeMap::new(); let mut visiting = std::collections::BTreeSet::new(); for actor_id in entities.keys().copied() { resolve_document_pose(actor_id, &entities, &mut poses, &mut visiting)?; } Ok(poses) } fn resolve_document_pose( actor_id: &str, entities: &std::collections::BTreeMap<&str, &SceneEntity>, poses: &mut std::collections::BTreeMap, visiting: &mut std::collections::BTreeSet, ) -> Result { if let Some(pose) = poses.get(actor_id).copied() { return Ok(pose); } if !visiting.insert(actor_id.to_string()) { return Err(format!( "navigation hierarchy contains a cycle at actor {actor_id}" )); } let entity = entities .get(actor_id) .ok_or_else(|| format!("navigation actor {actor_id} is missing from the scene document"))?; let local = component::(entity, TRANSFORM_COMPONENT)? .map(WorldPose::from_serialized) .unwrap_or_else(WorldPose::identity); let world = match entity.parent_actor_id.as_deref() { Some(parent) => resolve_document_pose(parent, entities, poses, visiting)?.compose(local), None => local, }; visiting.remove(actor_id); poses.insert(actor_id.to_string(), world); Ok(world) } fn component Deserialize<'de>>( entity: &SceneEntity, type_name: &str, ) -> Result, String> { entity .components .iter() .find(|component| component.type_name == type_name) .map(|component| { ron::from_str(&component.ron).map_err(|error| { format!( "actor {} has invalid {type_name}: {error}", entity.document_id ) }) }) .transpose() } fn rotated_aabb_half_extents(pose: WorldPose, local_half: [f32; 3]) -> [f32; 3] { let half = pose.scale.abs() * Vec3::from_array(local_half); let x = pose.rotation * Vec3::X; let y = pose.rotation * Vec3::Y; let z = pose.rotation * Vec3::Z; (x.abs() * half.x + y.abs() * half.y + z.abs() * half.z).to_array() } #[cfg(test)] mod tests { use nav_glam::Vec2; use super::*; fn fixture() -> 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: [5.0, 2.0, 5.0], agent: NavigationAgentProfile { min_region_size: 1, merge_region_size: 2, ..Default::default() }, geometry: Vec::new(), obstacles: vec![NavigationVolumeInput { actor_id: "center-obstacle".into(), center: [0.0, 0.5, 0.0], half_extents: [0.75, 1.0, 0.75], }], areas: Vec::new(), links: Vec::new(), } } #[test] fn deterministic_bake_routes_around_an_obstacle() { let input = fixture(); let artifact = bake_navigation(&input).unwrap(); assert_eq!( artifact.source_fingerprint, navigation_source_fingerprint(&input) ); let mesh = artifact.runtime_mesh(); let path = mesh.path(Vec2::new(-3.0, 0.0), Vec2::new(3.0, 0.0)); assert!(path.is_some()); assert!(path.unwrap().length > 6.0); } #[test] fn artifact_round_trip_preserves_query_mesh() { let artifact = bake_navigation(&fixture()).unwrap(); let text = ron::ser::to_string(&artifact).unwrap(); let restored: NavigationBakeArtifact = ron::from_str(&text).unwrap(); assert!(restored .runtime_mesh() .path(Vec2::new(-3.0, -3.0), Vec2::new(3.0, 3.0)) .is_some()); } #[test] fn agent_change_marks_artifact_stale() { let input = fixture(); let artifact = bake_navigation(&input).unwrap(); let mut changed = input; changed.agent.radius += 0.1; assert!(artifact.is_stale_for(&changed)); } #[test] fn authored_geometry_change_marks_artifact_stale() { let mut input = fixture(); input.geometry.push(NavigationGeometryInput { actor_id: "walkable-floor".into(), triangles: vec![[[-2.0, 0.0, -2.0], [2.0, 0.0, 2.0], [2.0, 0.0, -2.0]]], }); let artifact = bake_navigation(&input).unwrap(); input.geometry[0].triangles[0][0][0] -= 0.25; assert!(artifact.is_stale_for(&input)); } #[test] fn bounds_filter_excludes_distant_authoring() { assert!(aabb_overlaps( [0.0, 0.0, 0.0], [2.0, 2.0, 2.0], [3.0, 0.0, 0.0], [1.0, 1.0, 1.0] )); assert!(!aabb_overlaps( [0.0, 0.0, 0.0], [2.0, 2.0, 2.0], [3.01, 0.0, 0.0], [1.0, 1.0, 1.0] )); } #[test] fn committed_navigation_fixture_matches_its_bake_artifact() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let scene_path = root.join("assets/levels/navigation_authoring_showcase.scn.ron"); let text = std::fs::read_to_string(scene_path).unwrap(); let document = SceneDocument::from_ron_text(&text).unwrap(); let job = navigation_bake_jobs_from_document( &document, "assets/levels/navigation_authoring_showcase.scn.ron", ) .unwrap() .pop() .unwrap(); let committed = read_navigation_artifact(&root.join(&job.artifact_path)).unwrap(); assert!(!committed.is_stale_for(&job.input)); assert_eq!(committed.bounds_actor_id, "navigation-bounds-main"); assert_eq!(job.input.geometry.len(), 1); assert_eq!(job.input.geometry[0].actor_id, "navigation-floor-geometry"); assert!(committed .runtime_mesh() .path(Vec2::new(-6.0, 0.0), Vec2::new(6.0, 0.0)) .is_some()); } }