use std::fs; use std::path::{Path, PathBuf}; use nav_glam::{Mat4, 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, PrefabInstance, PrefabOverrides, PrefabRef, Primitive, PrimitiveShape, COMPONENT_BRUSH_DESC, COMPONENT_NAVIGATION_AREA, COMPONENT_NAVIGATION_BOUNDS, COMPONENT_NAVIGATION_LINK, COMPONENT_NAVIGATION_OBSTACLE, COMPONENT_PRIMITIVE, }; use crate::document::{SceneComponentBlob, SceneDocument, SceneEntity}; pub const NAVIGATION_ARTIFACT_SCHEMA_VERSION: u32 = 2; pub const NAVIGATION_GENERATOR_ID: &str = "blacksite-nav-2/rerecast-0.3.2/polyanya-0.16.1/glam-0.30.10"; const MAX_NAVIGATION_GRID_AXIS: u64 = 8_192; const MAX_NAVIGATION_GRID_CELLS: u64 = 4_194_304; #[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 NavigationPathSampleInput { pub id: String, pub start: [f32; 3], pub end: [f32; 3], pub enabled: bool, } #[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, #[serde(default)] pub samples: 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, #[serde(default)] pub payload_hash: 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, #[serde(default)] pub samples: Vec, #[serde(default)] pub diagnostics: NavigationBakeDiagnostics, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct NavigationBakeDiagnostics { pub polygon_count: usize, pub island_count: usize, #[serde(default)] pub effective_component_count: usize, } 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 navigation_artifact_content_hash( artifact: &NavigationBakeArtifact, ) -> Result { let bytes = ron::ser::to_string(artifact) .map_err(|error| format!("failed to serialize navigation artifact for hashing: {error}"))?; Ok(blake3::hash(bytes.as_bytes()).to_hex().to_string()) } pub fn navigation_artifact_payload_hash( artifact: &NavigationBakeArtifact, ) -> Result { let mut payload = artifact.clone(); payload.payload_hash.clear(); navigation_artifact_content_hash(&payload) } 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 = TriMesh { vertices: Vec::new(), indices: Vec::new(), area_types: Vec::new(), }; 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(); let diagnostics = navigation_diagnostics(&mesh, &input.links, input.agent.radius); mesh.unbake(); let mut artifact = NavigationBakeArtifact { schema_version: NAVIGATION_ARTIFACT_SCHEMA_VERSION, generator: NAVIGATION_GENERATOR_ID.into(), source_scene: input.source_scene.clone(), source_fingerprint: navigation_source_fingerprint(input), payload_hash: String::new(), 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(), samples: input.samples.clone(), diagnostics, }; artifact.payload_hash = navigation_artifact_payload_hash(&artifact)?; Ok(artifact) } 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 )); } if artifact.generator != NAVIGATION_GENERATOR_ID { return Err(format!( "navigation artifact generator `{}` is unsupported; rebake with `{NAVIGATION_GENERATOR_ID}`", artifact.generator )); } let actual_hash = navigation_artifact_payload_hash(&artifact)?; if artifact.payload_hash != actual_hash { return Err(format!( "navigation artifact payload hash mismatch (stored `{}`, actual `{actual_hash}`); delete it and rebake", artifact.payload_hash )); } Ok(artifact) } /// Engine-agnostic runtime query result shared by the editor, validation, and game crates. #[derive(Debug, Clone, PartialEq)] pub struct NavigationQueryPath { pub points: Vec<[f32; 3]>, pub length: f32, pub used_link_actor_ids: Vec, } impl NavigationQueryPath { pub fn used_link_actor_id(&self) -> Option<&str> { self.used_link_actor_ids.first().map(String::as_str) } } /// A baked navigation artifact with Polyanya acceleration data ready for repeated queries. #[derive(Debug, Clone)] pub struct NavigationQueryRuntime { artifact: NavigationBakeArtifact, mesh: Mesh, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct NavigationLinkValidationFinding { pub actor_id: String, pub endpoint: &'static str, pub message: String, } impl NavigationQueryRuntime { pub fn from_artifact(artifact: NavigationBakeArtifact) -> Result { if artifact.mesh.layers.is_empty() || artifact .mesh .layers .iter() .all(|layer| layer.polygons.is_empty()) { return Err("navigation artifact contains no walkable polygons; rebake it".into()); } let mesh = artifact.runtime_mesh(); Ok(Self { artifact, mesh }) } pub fn load(path: &Path) -> Result { Self::from_artifact(read_navigation_artifact(path)?) } pub fn artifact(&self) -> &NavigationBakeArtifact { &self.artifact } pub fn query(&self, start: [f32; 3], end: [f32; 3]) -> Result { if !finite3(start) || !finite3(end) { return Err("navigation query endpoints must be finite".into()); } let mut nodes = vec![vec3(start), vec3(end)]; let mut link_edges = Vec::new(); for link in self.artifact.links.iter().filter(|link| { link.enabled && self.endpoint_is_near_mesh(link.start) && self.endpoint_is_near_mesh(link.end) }) { let start_index = nodes.len(); nodes.push(vec3(link.start)); let end_index = nodes.len(); nodes.push(vec3(link.end)); link_edges.push(QueryLinkEdge::new(start_index, end_index, link, false)); if link.bidirectional { link_edges.push(QueryLinkEdge::new(end_index, start_index, link, true)); } } let mut distance = vec![f32::INFINITY; nodes.len()]; let mut visited = vec![false; nodes.len()]; let mut previous: Vec> = vec![None; nodes.len()]; let mut mesh_edges: Vec>> = vec![vec![None; nodes.len()]; nodes.len()]; distance[0] = 0.0; for _ in 0..nodes.len() { let Some(current) = (0..nodes.len()) .filter(|index| !visited[*index]) .min_by(|a, b| distance[*a].total_cmp(&distance[*b])) else { break; }; if !distance[current].is_finite() { break; } if current == 1 { break; } visited[current] = true; for next in 0..nodes.len() { if next == current || visited[next] { continue; } if mesh_edges[current][next].is_none() { if let Ok(path) = self.direct_path(nodes[current], nodes[next]) { let forward = QueryRouteSegment::from_path(path); let mut reverse = forward.clone(); reverse.points.reverse(); mesh_edges[current][next] = Some(forward); mesh_edges[next][current] = Some(reverse); } } if let Some(segment) = mesh_edges[current][next].clone() { relax_query_route(current, next, segment, &mut distance, &mut previous); } } for edge in link_edges.iter().filter(|edge| edge.from == current) { relax_query_route( current, edge.to, edge.segment.clone(), &mut distance, &mut previous, ); } } if !distance[1].is_finite() { return Err( "no navigation path connects the requested endpoints; inspect islands or add a link" .into(), ); } assemble_query_route(&previous, 1) } pub fn validate_links(&self) -> Vec { self.validate_link_findings() .into_iter() .map(|finding| finding.message) .collect() } pub fn validate_link_findings(&self) -> Vec { let mut findings = Vec::new(); for link in self.artifact.links.iter().filter(|link| link.enabled) { for (label, endpoint) in [("start", link.start), ("end", link.end)] { let endpoint = vec3(endpoint); let point = Vec2::new(endpoint.x, endpoint.z); let Some(closest) = self.mesh.get_closest_point_at_height(point, endpoint.y) else { findings.push(NavigationLinkValidationFinding { actor_id: link.actor_id.clone(), endpoint: label, message: format!( "navigation link {} {label} endpoint is not near a walkable polygon", link.actor_id ), }); continue; }; let closest = closest.position_with_height(&self.mesh); let closest = Vec3::new(closest.x, closest.y, closest.z); if closest.distance(endpoint) > self.artifact.agent.radius * 2.0 { findings.push(NavigationLinkValidationFinding { actor_id: link.actor_id.clone(), endpoint: label, message: format!( "navigation link {} {label} endpoint is farther than two agent radii from the mesh", link.actor_id ), }); } } } findings } pub fn validate_samples(&self) -> Vec { self.artifact .samples .iter() .filter(|sample| sample.enabled) .filter_map(|sample| { self.query(sample.start, sample.end).err().map(|error| { format!( "navigation validation sample `{}` failed: {error}", sample.id ) }) }) .collect() } fn direct_path(&self, start: Vec3, end: Vec3) -> Result { let start2 = Vec2::new(start.x, start.z); let end2 = Vec2::new(end.x, end.z); let start_coord = self .mesh .get_closest_point_at_height(start2, start.y) .ok_or_else(|| "navigation start is outside the mesh".to_string())?; let end_coord = self .mesh .get_closest_point_at_height(end2, end.y) .ok_or_else(|| "navigation end is outside the mesh".to_string())?; let nav_start = start_coord.position_with_height(&self.mesh); let nav_end = end_coord.position_with_height(&self.mesh); let nav_start = Vec3::new(nav_start.x, nav_start.y, nav_start.z); let nav_end = Vec3::new(nav_end.x, nav_end.y, nav_end.z); let maximum_snap = self.artifact.agent.radius * 2.0; if nav_start.distance(start) > maximum_snap { return Err("navigation start is farther than two agent radii from the mesh".into()); } if nav_end.distance(end) > maximum_snap { return Err("navigation end is farther than two agent radii from the mesh".into()); } let path = self.mesh.path(start_coord, end_coord).ok_or_else(|| { "navigation endpoints are outside the same reachable mesh island".to_string() })?; let mut points = vec![nav_start]; for point in path.path_with_height(nav_start, nav_end, &self.mesh) { append_distinct_query_point(&mut points, Vec3::new(point.x, point.y, point.z)); } append_distinct_query_point(&mut points, nav_end); let length = query_path_length(&points); Ok(QueryDirectPath { points, length }) } fn endpoint_is_near_mesh(&self, endpoint: [f32; 3]) -> bool { let endpoint = vec3(endpoint); self.mesh .get_closest_point_at_height(Vec2::new(endpoint.x, endpoint.z), endpoint.y) .is_some_and(|closest| { let closest = closest.position_with_height(&self.mesh); Vec3::new(closest.x, closest.y, closest.z).distance(endpoint) <= self.artifact.agent.radius * 2.0 }) } } #[derive(Debug, Clone)] struct QueryDirectPath { points: Vec, length: f32, } #[derive(Debug, Clone)] struct QueryRouteSegment { points: Vec, weighted_cost: f32, link_actor_id: Option, } impl QueryRouteSegment { fn from_path(path: QueryDirectPath) -> Self { Self { points: path.points, weighted_cost: path.length, link_actor_id: None, } } } struct QueryLinkEdge { from: usize, to: usize, segment: QueryRouteSegment, } impl QueryLinkEdge { fn new(from: usize, to: usize, link: &NavigationLinkInput, reverse: bool) -> Self { let points = if reverse { vec![vec3(link.end), vec3(link.start)] } else { vec![vec3(link.start), vec3(link.end)] }; let length = query_path_length(&points); Self { from, to, segment: QueryRouteSegment { points, weighted_cost: length * link.cost, link_actor_id: Some(link.actor_id.clone()), }, } } } fn relax_query_route( from: usize, to: usize, segment: QueryRouteSegment, distance: &mut [f32], previous: &mut [Option<(usize, QueryRouteSegment)>], ) { let candidate = distance[from] + segment.weighted_cost; if candidate < distance[to] { distance[to] = candidate; previous[to] = Some((from, segment)); } } fn assemble_query_route( previous: &[Option<(usize, QueryRouteSegment)>], destination: usize, ) -> Result { let mut segments = Vec::new(); let mut current = destination; while current != 0 { let Some((from, segment)) = previous[current].clone() else { return Err("navigation route reconstruction failed".into()); }; segments.push(segment); current = from; } segments.reverse(); let mut points = Vec::new(); let mut used_link_actor_ids = Vec::new(); for segment in segments { for point in segment.points { append_distinct_query_point(&mut points, point); } if let Some(actor_id) = segment.link_actor_id { used_link_actor_ids.push(actor_id); } } Ok(NavigationQueryPath { length: query_path_length(&points), points: points.into_iter().map(|point| point.to_array()).collect(), used_link_actor_ids, }) } fn append_distinct_query_point(points: &mut Vec, point: Vec3) { if points .last() .is_none_or(|last| last.distance_squared(point) > 1.0e-8) { points.push(point); } } fn query_path_length(points: &[Vec3]) -> f32 { points .windows(2) .map(|edge| edge[0].distance(edge[1])) .sum() } pub fn query_navigation_artifact( artifact: NavigationBakeArtifact, start: [f32; 3], end: [f32; 3], ) -> Result { NavigationQueryRuntime::from_artifact(artifact)?.query(start, end) } 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.transform_point([0.0; 3]).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.transform_point([0.0; 3]).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_world_matrix( actor_id, pose.matrix.to_cols_array_2d(), 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(navigation_jobs_from_sources( source_scene, bounds, &geometry, &obstacles, &areas, &links, )) } /// Resolves the exact navigation contributors visible from one saved scene. /// /// `entry_text_override` lets the editor provide its current unsaved authored snapshot while /// subscenes and prefab sources continue to resolve from project files. Only bounds authored in /// the entry document produce jobs; visible composed scenes and linked prefabs contribute source /// geometry, volumes, areas, and links to those jobs. pub fn resolve_navigation_bake_jobs( project_root: &Path, entry_path: &Path, entry_text_override: Option<&str>, ) -> Result, String> { let project_root = project_root.canonicalize().map_err(|error| { format!( "could not resolve project root {}: {error}", project_root.display() ) })?; let entry_path = if entry_path.is_absolute() { entry_path.to_path_buf() } else { project_root.join(entry_path) }; let entry_path = entry_path.canonicalize().map_err(|error| { format!( "could not resolve navigation scene {}: {error}", entry_path.display() ) })?; if !entry_path.starts_with(&project_root) { return Err(format!( "navigation scene {} is outside project root {}", entry_path.display(), project_root.display() )); } let source_scene = entry_path .strip_prefix(&project_root) .map_err(|_| "navigation scene is outside the project root".to_string())? .to_string_lossy() .replace('\\', "/"); let entry_text = match entry_text_override { Some(text) => text.to_string(), None => fs::read_to_string(&entry_path) .map_err(|error| format!("could not read {}: {error}", entry_path.display()))?, }; let document = SceneDocument::from_ron_text(&entry_text) .map_err(|error| format!("{}: {error}", entry_path.display()))?; if !document.entities.iter().any(|entity| { entity .components .iter() .any(|component| component.type_name == COMPONENT_NAVIGATION_BOUNDS) }) { return Ok(Vec::new()); } let mut sources = ResolvedNavigationSources::default(); let mut composition_stack = vec![entry_path.clone()]; let mut prefab_stack = Vec::new(); let entry_is_prefab = source_scene.starts_with("assets/prefabs/"); collect_resolved_navigation_document( document, &entry_path, &project_root, WorldPose::identity(), "", !entry_is_prefab, entry_is_prefab, &[], &mut composition_stack, &mut prefab_stack, &mut sources, )?; sources.sort(); Ok(navigation_jobs_from_sources( &source_scene, sources.bounds, &sources.geometry, &sources.obstacles, &sources.areas, &sources.links, )) } #[derive(Default)] struct ResolvedNavigationSources { bounds: Vec<(String, WorldPose, NavigationBounds)>, geometry: Vec, obstacles: Vec, areas: Vec, links: Vec, } impl ResolvedNavigationSources { fn sort(&mut self) { self.bounds.sort_by(|a, b| a.0.cmp(&b.0)); self.geometry.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); self.obstacles.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); self.areas.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); self.links.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); } } #[allow(clippy::too_many_arguments)] fn collect_resolved_navigation_document( mut document: SceneDocument, document_path: &Path, project_root: &Path, base_pose: WorldPose, namespace: &str, collect_bounds: bool, inside_prefab: bool, override_layers: &[PrefabOverrides], composition_stack: &mut Vec, prefab_stack: &mut Vec, sources: &mut ResolvedNavigationSources, ) -> Result<(), String> { apply_navigation_override_layers(&mut document, override_layers, namespace)?; let poses = document_world_poses(&document)?; 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)?; let prefab = component::(entity, PREFAB_INSTANCE_COMPONENT)?; if bounds_value.is_none() && obstacle_value.is_none() && area_value.is_none() && link_value.is_none() && primitive.is_none() && brush.is_none() && prefab.is_none() { continue; } let actor_id = entity.actor_id.clone().ok_or_else(|| { format!( "navigation contributor {} in {} requires ActorId", entity.document_id, document_path.display() ) })?; let qualified_id = qualify_navigation_actor_id(namespace, &actor_id); let local_pose = poses .get(&actor_id) .copied() .unwrap_or_else(WorldPose::identity); let pose = base_pose.compose(local_pose); if let Some(value) = bounds_value { if inside_prefab { return Err(format!( "prefab navigation contributor `{qualified_id}` contains Navigation Bounds; bounds must be authored in a level scene and the prefab should contain only geometry, obstacles, areas, or links" )); } if collect_bounds { sources.bounds.push((qualified_id.clone(), pose, value)); } } if let Some(value) = obstacle_value { sources.obstacles.push(NavigationVolumeInput { actor_id: qualified_id.clone(), center: pose.transform_point([0.0; 3]).to_array(), half_extents: rotated_aabb_half_extents(pose, value.half_extents.to_array()), }); } if let Some(value) = area_value { sources.areas.push(NavigationAreaInput { actor_id: qualified_id.clone(), id: value.id, center: pose.transform_point([0.0; 3]).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 { sources.links.push(NavigationLinkInput { actor_id: qualified_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_world_matrix( qualified_id.clone(), pose.matrix.to_cols_array_2d(), primitive.as_ref(), brush.as_ref(), ) { sources.geometry.push(value); } if let Some(instance) = prefab { let target = resolve_project_scene_path(project_root, &instance.source_path)?; if let Some(index) = prefab_stack.iter().position(|path| path == &target) { let mut cycle = prefab_stack[index..] .iter() .map(|path| project_relative_label(project_root, path)) .collect::>(); cycle.push(project_relative_label(project_root, &target)); return Err(format!("cyclic prefab reference: {}", cycle.join(" -> "))); } let own_overrides = shared::decode_prefab_overrides(&instance).map_err(|error| { format!("prefab instance `{qualified_id}` has invalid overrides: {error}") })?; let mut child_layers = vec![own_overrides]; child_layers.extend( override_layers .iter() .map(|layer| descend_navigation_overrides(layer, &actor_id)), ); let text = fs::read_to_string(&target) .map_err(|error| format!("could not read prefab {}: {error}", target.display()))?; let child_document = SceneDocument::from_ron_text(&text) .map_err(|error| format!("{}: {error}", target.display()))?; let child_namespace = if namespace.is_empty() { format!("prefab:{actor_id}") } else { format!("{namespace}/prefab:{actor_id}") }; prefab_stack.push(target.clone()); collect_resolved_navigation_document( child_document, &target, project_root, pose, &child_namespace, false, true, &child_layers, composition_stack, prefab_stack, sources, )?; prefab_stack.pop(); } } if !inside_prefab { if let Some(composition) = document.composition { for reference in composition .subscenes .into_iter() .filter(|reference| reference.visible) { let target = resolve_project_scene_path(project_root, &reference.path)?; if let Some(index) = composition_stack.iter().position(|path| path == &target) { let mut cycle = composition_stack[index..] .iter() .map(|path| project_relative_label(project_root, path)) .collect::>(); cycle.push(project_relative_label(project_root, &target)); return Err(format!("cyclic subscene reference: {}", cycle.join(" -> "))); } let text = fs::read_to_string(&target).map_err(|error| { format!("could not read subscene {}: {error}", target.display()) })?; let child_document = SceneDocument::from_ron_text(&text) .map_err(|error| format!("{}: {error}", target.display()))?; let child_namespace = if namespace.is_empty() { format!("subscene:{}", reference.id) } else { format!("{namespace}/subscene:{}", reference.id) }; composition_stack.push(target.clone()); collect_resolved_navigation_document( child_document, &target, project_root, base_pose, &child_namespace, false, false, &[], composition_stack, prefab_stack, sources, )?; composition_stack.pop(); } } } Ok(()) } fn qualify_navigation_actor_id(namespace: &str, actor_id: &str) -> String { if namespace.is_empty() { actor_id.to_string() } else { format!("{namespace}::{actor_id}") } } fn resolve_project_scene_path(project_root: &Path, relative: &str) -> Result { let relative_path = Path::new(relative); if relative.trim().is_empty() || relative_path.is_absolute() || !relative.replace('\\', "/").ends_with(".scn.ron") || relative_path.components().any(|component| { matches!( component, std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) ) }) { return Err(format!( "scene dependency path `{relative}` must be a project-relative .scn.ron file" )); } let target = project_root .join(relative_path) .canonicalize() .map_err(|error| { format!( "missing scene dependency `{relative}` under {}: {error}", project_root.display() ) })?; if !target.starts_with(project_root) { return Err(format!( "scene dependency `{relative}` escapes the project root" )); } Ok(target) } fn project_relative_label(project_root: &Path, path: &Path) -> String { path.strip_prefix(project_root) .unwrap_or(path) .to_string_lossy() .replace('\\', "/") } fn apply_navigation_override_layers( document: &mut SceneDocument, layers: &[PrefabOverrides], namespace: &str, ) -> Result<(), String> { for layer in layers { if !layer.structural.is_empty() { return Err(format!( "prefab `{namespace}` has structural overrides that can change navigation hierarchy; apply or unpack the prefab before baking" )); } for component in &layer.components { if !component.target.instance_chain.is_empty() || !navigation_relevant_override_component(&component.component_type) { continue; } apply_navigation_component_override( document, &component.target.actor_id, &component.component_type, component.value_component_ron.as_deref(), )?; } let properties = layer .properties .iter() .filter(|property| { property.target.instance_chain.is_empty() && navigation_relevant_override_component(&property.component_type) }) .collect::>(); for property in &properties { validate_navigation_property_override_base(document, property)?; } for property in properties { apply_navigation_property_override(document, property)?; } } Ok(()) } fn navigation_relevant_override_component(component_type: &str) -> bool { matches!( component_type, TRANSFORM_COMPONENT | COMPONENT_PRIMITIVE | COMPONENT_BRUSH_DESC | COMPONENT_NAVIGATION_BOUNDS | COMPONENT_NAVIGATION_OBSTACLE | COMPONENT_NAVIGATION_AREA | COMPONENT_NAVIGATION_LINK | PREFAB_INSTANCE_COMPONENT | PREFAB_REF_COMPONENT ) } fn apply_navigation_component_override( document: &mut SceneDocument, actor_id: &str, component_type: &str, value: Option<&str>, ) -> Result<(), String> { let entity = document .entities .iter_mut() .find(|entity| entity.actor_id.as_deref() == Some(actor_id)) .ok_or_else(|| { format!("navigation-relevant prefab override targets missing actor `{actor_id}`") })?; match value { Some(value) => { let replacement = SceneComponentBlob::from_ron(component_type, value)?; if let Some(component) = entity .components .iter_mut() .find(|component| component.type_name == component_type) { *component = replacement; } else { entity.components.push(replacement); } } None => entity .components .retain(|component| component.type_name != component_type), } Ok(()) } fn validate_navigation_property_override_base( document: &SceneDocument, property: &shared::PrefabPropertyOverride, ) -> Result<(), String> { let entity = document .entities .iter() .find(|entity| entity.actor_id.as_deref() == Some(&property.target.actor_id)) .ok_or_else(|| { format!( "navigation-relevant prefab override targets missing actor `{}`", property.target.actor_id ) })?; let component = entity .components .iter() .find(|component| component.type_name == property.component_type) .ok_or_else(|| { format!( "navigation-relevant prefab property override targets missing component `{}` on actor `{}`", property.component_type, property.target.actor_id ) })?; if !navigation_component_values_equal( &property.component_type, &component.ron, &property.base_component_ron, )? { return Err(format!( "prefab property `{}` on actor `{}` was authored against a different `{}` base; rebase or apply the override before baking navigation", property.property_path, property.target.actor_id, property.component_type )); } Ok(()) } fn apply_navigation_property_override( document: &mut SceneDocument, property: &shared::PrefabPropertyOverride, ) -> Result<(), String> { let entity = document .entities .iter_mut() .find(|entity| entity.actor_id.as_deref() == Some(&property.target.actor_id)) .ok_or_else(|| { format!( "navigation-relevant prefab override targets missing actor `{}`", property.target.actor_id ) })?; let component = entity .components .iter_mut() .find(|component| component.type_name == property.component_type) .ok_or_else(|| { format!( "navigation-relevant prefab property override targets missing component `{}` on actor `{}`", property.component_type, property.target.actor_id ) })?; let merged = shared::apply_serialized_navigation_property_override( &property.component_type, &component.ron, &property.value_component_ron, &property.property_path, )?; *component = SceneComponentBlob::from_ron(&property.component_type, &merged)?; Ok(()) } fn navigation_component_values_equal( component_type: &str, current: &str, base: &str, ) -> Result { fn compare(component_type: &str, current: &str, base: &str) -> Result where T: for<'de> Deserialize<'de> + PartialEq, { let current = ron::from_str::(current).map_err(|error| { format!("invalid current value for prefab component `{component_type}`: {error}") })?; let base = ron::from_str::(base).map_err(|error| { format!("invalid base value for prefab component `{component_type}`: {error}") })?; Ok(current == base) } match component_type { TRANSFORM_COMPONENT => compare::(component_type, current, base), COMPONENT_PRIMITIVE => compare::(component_type, current, base), COMPONENT_BRUSH_DESC => compare::(component_type, current, base), COMPONENT_NAVIGATION_BOUNDS => compare::(component_type, current, base), COMPONENT_NAVIGATION_OBSTACLE => { compare::(component_type, current, base) } COMPONENT_NAVIGATION_AREA => compare::(component_type, current, base), COMPONENT_NAVIGATION_LINK => compare::(component_type, current, base), PREFAB_INSTANCE_COMPONENT => compare::(component_type, current, base), PREFAB_REF_COMPONENT => compare::(component_type, current, base), _ => Err(format!( "component `{component_type}` is not part of the navigation override contract" )), } } fn descend_navigation_overrides( overrides: &PrefabOverrides, nested_actor_id: &str, ) -> PrefabOverrides { let components = overrides .components .iter() .filter_map(|component| { let mut component = component.clone(); if component.target.instance_chain.first().map(String::as_str) == Some(nested_actor_id) { component.target.instance_chain.remove(0); Some(component) } else { None } }) .collect(); let properties = overrides .properties .iter() .filter_map(|property| { let mut property = property.clone(); if property.target.instance_chain.first().map(String::as_str) == Some(nested_actor_id) { property.target.instance_chain.remove(0); Some(property) } else { None } }) .collect(); PrefabOverrides { format_version: overrides.format_version, source_revision: overrides.source_revision.clone(), components, properties, ..Default::default() } } const PREFAB_INSTANCE_COMPONENT: &str = "shared::components::PrefabInstance"; const PREFAB_REF_COMPONENT: &str = "shared::components::PrefabRef"; fn navigation_jobs_from_sources( source_scene: &str, bounds: Vec<(String, WorldPose, NavigationBounds)>, geometry: &[NavigationGeometryInput], obstacles: &[NavigationVolumeInput], areas: &[NavigationAreaInput], links: &[NavigationLinkInput], ) -> Vec { bounds .into_iter() .map(|(actor_id, pose, bounds)| { let center = pose.transform_point([0.0; 3]).to_array(); let half_extents = rotated_aabb_half_extents(pose, bounds.half_extents.to_array()); let samples = bounds .validation_samples .iter() .map(|sample| NavigationPathSampleInput { id: sample.id.clone(), start: pose.transform_point(sample.start.to_array()).to_array(), end: pose.transform_point(sample.end.to_array()).to_array(), enabled: sample.enabled, }) .collect(); 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(), samples, }, } }) .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")?; validate_grid_budget(input)?; if input .geometry .iter() .all(|source| source.triangles.is_empty()) { return Err(format!( "navigation bounds {} contains no primitive or additive-brush source geometry; add walkable geometry inside the bounds", input.bounds_actor_id )); } 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 )); } } let mut sample_ids = std::collections::HashSet::new(); for sample in &input.samples { let sample_id = sample.id.trim(); if sample_id.is_empty() { return Err(format!( "navigation bounds {} has a validation sample with an empty ID", input.bounds_actor_id )); } if !sample_ids.insert(sample_id) { return Err(format!( "navigation bounds {} repeats validation sample ID `{sample_id}`", input.bounds_actor_id )); } if !finite3(sample.start) || !finite3(sample.end) { return Err(format!( "navigation validation sample `{sample_id}` endpoints must be finite" )); } if squared_distance(sample.start, sample.end) <= f32::EPSILON { return Err(format!( "navigation validation sample `{sample_id}` endpoints must be distinct" )); } } Ok(()) } fn validate_grid_budget(input: &NavigationBakeInput) -> Result<(), String> { let cell_size = f64::from(input.agent.radius / input.agent.cell_size_fraction); if !cell_size.is_finite() || cell_size <= 0.0 { return Err("navigation voxel detail produces an invalid cell size".into()); } let width = (f64::from(input.half_extents[0]) * 2.0 / cell_size).ceil(); let height = (f64::from(input.half_extents[2]) * 2.0 / cell_size).ceil(); if width < 1.0 || height < 1.0 { return Err( "navigation bounds are smaller than one voxel; reduce voxel detail or enlarge bounds" .into(), ); } if width > MAX_NAVIGATION_GRID_AXIS as f64 || height > MAX_NAVIGATION_GRID_AXIS as f64 { return Err(format!( "navigation bake requests a {width:.0} x {height:.0} voxel grid; each axis is limited to {MAX_NAVIGATION_GRID_AXIS}. Split the bounds or reduce voxel detail" )); } let cells = width * height; if cells > MAX_NAVIGATION_GRID_CELLS as f64 { return Err(format!( "navigation bake requests {cells:.0} XZ cells ({width:.0} x {height:.0}); the limit is {MAX_NAVIGATION_GRID_CELLS}. Split the bounds or reduce voxel detail" )); } Ok(()) } 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); } } } fn navigation_diagnostics( mesh: &Mesh, links: &[NavigationLinkInput], agent_radius: f32, ) -> NavigationBakeDiagnostics { let polygon_count = mesh.layers.iter().map(|layer| layer.polygons.len()).sum(); let mut visited: Vec> = mesh .layers .iter() .map(|layer| vec![false; layer.polygons.len()]) .collect(); let mut island_by_polygon: Vec> = mesh .layers .iter() .map(|layer| vec![usize::MAX; layer.polygons.len()]) .collect(); let mut island_count = 0; for layer_index in 0..mesh.layers.len() { for polygon_index in 0..mesh.layers[layer_index].polygons.len() { if visited[layer_index][polygon_index] { continue; } let island = island_count; island_count += 1; let mut pending = vec![(layer_index, polygon_index)]; while let Some((layer_index, polygon_index)) = pending.pop() { if visited[layer_index][polygon_index] { continue; } visited[layer_index][polygon_index] = true; island_by_polygon[layer_index][polygon_index] = island; let layer = &mesh.layers[layer_index]; for vertex_index in &layer.polygons[polygon_index].vertices { for neighbour in &layer.vertices[*vertex_index as usize].polygons { if *neighbour == u32::MAX { continue; } let neighbour_layer = (*neighbour >> 24) as usize; let neighbour_polygon = (*neighbour & 0x00ff_ffff) as usize; if neighbour_layer < visited.len() && neighbour_polygon < visited[neighbour_layer].len() && !visited[neighbour_layer][neighbour_polygon] { pending.push((neighbour_layer, neighbour_polygon)); } } } } } } let mut parents: Vec = (0..island_count).collect(); let mut runtime_mesh = mesh.clone(); runtime_mesh.bake(); for link in links.iter().filter(|link| link.enabled) { let Some(start) = endpoint_island(&runtime_mesh, link.start, agent_radius, &island_by_polygon) else { continue; }; let Some(end) = endpoint_island(&runtime_mesh, link.end, agent_radius, &island_by_polygon) else { continue; }; union_components(&mut parents, start, end); } let mut effective_roots = std::collections::BTreeSet::new(); for island in 0..island_count { effective_roots.insert(component_root(&mut parents, island)); } NavigationBakeDiagnostics { polygon_count, island_count, effective_component_count: effective_roots.len(), } } fn endpoint_island( mesh: &Mesh, endpoint: [f32; 3], agent_radius: f32, island_by_polygon: &[Vec], ) -> Option { let point = Vec2::new(endpoint[0], endpoint[2]); let coord = mesh.get_closest_point_at_height(point, endpoint[1])?; let closest = coord.position_with_height(mesh); if closest.distance(Vec3::from_array(endpoint)) > agent_radius * 2.0 { return None; } let packed_polygon = coord.polygon(); let layer = coord .layer() .map(usize::from) .unwrap_or((packed_polygon >> 24) as usize); let polygon = (packed_polygon & 0x00ff_ffff) as usize; island_by_polygon.get(layer)?.get(polygon).copied() } fn component_root(parents: &mut [usize], component: usize) -> usize { if parents[component] != component { parents[component] = component_root(parents, parents[component]); } parents[component] } fn union_components(parents: &mut [usize], a: usize, b: usize) { let a = component_root(parents, a); let b = component_root(parents, b); if a != b { parents[b] = a; } } 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 matrix = Mat4::from_scale_rotation_translation( Vec3::from_array(scale), nav_glam::Quat::from_array(rotation).normalize(), Vec3::from_array(translation), ); navigation_geometry_from_world_matrix(actor_id, matrix.to_cols_array_2d(), primitive, brush) } pub fn navigation_geometry_from_world_matrix( actor_id: String, world_matrix: [[f32; 4]; 4], primitive: Option<&Primitive>, brush: Option<&BrushDesc>, ) -> Option { let matrix = Mat4::from_cols_array_2d(&world_matrix); 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| matrix.transform_point3(Vec3::from_array(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, PartialEq, 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 { matrix: Mat4, } impl WorldPose { fn identity() -> Self { Self { matrix: Mat4::IDENTITY, } } fn from_serialized(value: SerializedTransform) -> Self { Self { matrix: Mat4::from_scale_rotation_translation( Vec3::from_array(value.scale), nav_glam::Quat::from_array(value.rotation).normalize(), Vec3::from_array(value.translation), ), } } fn compose(self, local: Self) -> Self { Self { matrix: self.matrix * local.matrix, } } fn transform_point(self, point: [f32; 3]) -> Vec3 { self.matrix.transform_point3(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 = Vec3::from_array(local_half); let x = pose.matrix.x_axis.truncate().abs() * half.x; let y = pose.matrix.y_axis.truncate().abs() * half.y; let z = pose.matrix.z_axis.truncate().abs() * half.z; (x + y + z).to_array() } #[cfg(test)] mod tests { use nav_glam::Vec2; use std::sync::atomic::{AtomicU64, Ordering}; use super::*; static NEXT_PROJECT: AtomicU64 = AtomicU64::new(1); fn resolver_project() -> PathBuf { let root = std::env::temp_dir().join(format!( "blacksite-navigation-resolver-{}-{}", std::process::id(), NEXT_PROJECT.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir_all(root.join("assets/levels")).unwrap(); std::fs::create_dir_all(root.join("assets/prefabs")).unwrap(); root } fn bounds_ron(actor_id: &str) -> String { let mut bounds = NavigationBounds::for_actor(actor_id); bounds.half_extents = [10.0, 3.0, 10.0].into(); bounds.agent.min_region_size = 1; bounds.agent.merge_region_size = 2; ron::to_string(&bounds).unwrap() } 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 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![floor_geometry("walkable-floor", -5.0, 5.0, -5.0, 5.0, 0.0)], 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(), samples: 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 empty_bounds_do_not_synthesize_walkable_floor() { let mut input = fixture(); input.geometry.clear(); let error = bake_navigation(&input).unwrap_err(); assert!(error.contains("contains no primitive or additive-brush source geometry")); assert!(error.contains("bounds")); } #[test] fn excessive_voxel_grid_is_rejected_before_allocation() { let mut input = fixture(); input.half_extents = [10_000.0, 2.0, 10_000.0]; let error = bake_navigation(&input).unwrap_err(); assert!(error.contains("voxel grid")); assert!(error.contains("Split the bounds or reduce voxel detail")); } #[test] fn separated_authored_surfaces_remain_disconnected() { let mut input = fixture(); input.geometry = vec![ floor_geometry("left", -5.0, -1.0, -4.0, 4.0, 0.0), floor_geometry("right", 1.0, 5.0, -4.0, 4.0, 0.0), ]; input.obstacles.clear(); let artifact = bake_navigation(&input).unwrap(); assert!(artifact.diagnostics.island_count >= 2); assert!(artifact.diagnostics.effective_component_count >= 2); let mesh = artifact.runtime_mesh(); assert!(mesh .path(Vec2::new(-3.0, 0.0), Vec2::new(3.0, 0.0)) .is_none()); } #[test] fn valid_link_unions_disconnected_islands_in_diagnostics() { let mut input = fixture(); input.geometry = vec![ floor_geometry("left", -5.0, -1.0, -4.0, 4.0, 0.0), floor_geometry("right", 1.0, 5.0, -4.0, 4.0, 0.0), ]; input.obstacles.clear(); input.links.push(NavigationLinkInput { actor_id: "bridge".into(), start: [-1.5, 0.0, 0.0], end: [1.5, 0.0, 0.0], bidirectional: true, cost: 1.0, enabled: true, }); let diagnostics = bake_navigation(&input).unwrap().diagnostics; assert!(diagnostics.island_count >= 2); assert_eq!(diagnostics.effective_component_count, 1); } #[test] fn invalid_link_is_reported_and_excluded_from_runtime_routes() { let mut input = fixture(); input.geometry = vec![ floor_geometry("left", -5.0, -1.0, -4.0, 4.0, 0.0), floor_geometry("right", 1.0, 5.0, -4.0, 4.0, 0.0), ]; input.obstacles.clear(); input.links.push(NavigationLinkInput { actor_id: "invalid-bridge".into(), start: [-100.0, 0.0, 0.0], end: [1.5, 0.0, 0.0], bidirectional: true, cost: 1.0, enabled: true, }); let runtime = NavigationQueryRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap(); assert!(runtime .validate_links() .iter() .any(|finding| finding.contains("invalid-bridge start"))); assert!(runtime.query([-3.0, 0.0, 0.0], [3.0, 0.0, 0.0]).is_err()); } #[test] fn artifact_round_trip_preserves_query_mesh() { let mut input = fixture(); input.samples.push(NavigationPathSampleInput { id: "fixture-route".into(), start: [-3.0, 0.0, -3.0], end: [3.0, 0.0, 3.0], enabled: true, }); let artifact = bake_navigation(&input).unwrap(); let text = ron::ser::to_string(&artifact).unwrap(); let restored: NavigationBakeArtifact = ron::from_str(&text).unwrap(); assert_eq!(restored.samples, input.samples); assert!(restored .runtime_mesh() .path(Vec2::new(-3.0, -3.0), Vec2::new(3.0, 3.0)) .is_some()); assert!(NavigationQueryRuntime::from_artifact(restored) .unwrap() .validate_samples() .is_empty()); } #[test] fn shared_query_runtime_reports_unreachable_authored_sample() { let mut input = fixture(); input.samples.push(NavigationPathSampleInput { id: "outside-bounds".into(), start: [100.0, 0.0, 100.0], end: [101.0, 0.0, 100.0], enabled: true, }); let runtime = NavigationQueryRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap(); let findings = runtime.validate_samples(); assert_eq!(findings.len(), 1); assert!(findings[0].contains("outside-bounds")); assert!(findings[0].contains("no navigation path")); } #[test] fn artifact_payload_hash_detects_parseable_tampering() { let mut artifact = bake_navigation(&fixture()).unwrap(); artifact.links.push(NavigationLinkInput { actor_id: "tampered-link".into(), start: [-1.0, 0.0, 0.0], end: [1.0, 0.0, 0.0], bidirectional: true, cost: 1.0, enabled: true, }); let path = std::env::temp_dir().join(format!( "blacksite-navigation-tamper-{}.nav.ron", std::process::id() )); write_navigation_artifact(&path, &artifact).unwrap(); let error = read_navigation_artifact(&path).unwrap_err(); std::fs::remove_file(path).unwrap(); assert!(error.contains("payload hash mismatch")); } #[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(); 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 resolver_includes_visible_subscene_geometry_with_qualified_identity() { let root = resolver_project(); let child = r#"(schema_version:4,resources:{},entities:{ 1:(components:{ "shared::components::ActorId":("child-floor"), "bevy_transform::components::transform::Transform":(translation:(0.0,-0.1,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0)), "shared::components::Primitive":(shape:Box,size:(12.0,0.2,12.0)), }), })"#; std::fs::write(root.join("assets/levels/child.scn.ron"), child).unwrap(); let bounds = bounds_ron("main-bounds"); let main = format!( r#"(schema_version:4,resources:{{ "shared::components::SceneComposition":(scene_id:"main",subscenes:[(id:"geometry",path:"assets/levels/child.scn.ron",visible:true,locked:true)]), }},entities:{{ 1:(components:{{"shared::components::ActorId":("main-bounds"),"shared::navigation::NavigationBounds":{bounds},}}), }})"# ); let main_path = root.join("assets/levels/main.scn.ron"); std::fs::write(&main_path, main).unwrap(); let job = resolve_navigation_bake_jobs(&root, &main_path, None) .unwrap() .pop() .unwrap(); assert_eq!(job.input.geometry.len(), 1); assert_eq!( job.input.geometry[0].actor_id, "subscene:geometry::child-floor" ); assert!(bake_navigation(&job.input).is_ok()); std::fs::remove_dir_all(root).unwrap(); } #[test] fn resolver_applies_navigation_relevant_prefab_transform_override() { let root = resolver_project(); let prefab = r#"(schema_version:4,resources:{},entities:{ 1:(components:{ "shared::components::ActorId":("prefab-floor"), "bevy_transform::components::transform::Transform":(translation:(1.0,-0.1,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0)), "shared::components::Primitive":(shape:Box,size:(4.0,0.2,4.0)), }), })"#; std::fs::write(root.join("assets/prefabs/floor.scn.ron"), prefab).unwrap(); let mut overrides = PrefabOverrides::default(); overrides.components.push(shared::PrefabComponentOverride { target: shared::PrefabActorPath::direct("prefab-floor"), component_type: TRANSFORM_COMPONENT.into(), base_component_ron: None, value_component_ron: Some( "(translation:(4.0,-0.1,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))" .into(), ), }); let mut instance = PrefabInstance::new("floor-prefab", "assets/prefabs/floor.scn.ron"); instance.overrides_ron = shared::encode_prefab_overrides(&overrides).unwrap(); let instance = ron::to_string(&instance).unwrap(); let bounds = bounds_ron("main-bounds"); let main = format!( r#"(schema_version:4,resources:{{}},entities:{{ 1:(components:{{"shared::components::ActorId":("main-bounds"),"shared::navigation::NavigationBounds":{bounds},}}), 2:(components:{{ "shared::components::ActorId":("floor-instance"), "bevy_transform::components::transform::Transform":(translation:(2.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0)), "shared::components::PrefabInstance":{instance}, }}), }})"# ); let main_path = root.join("assets/levels/main.scn.ron"); std::fs::write(&main_path, main).unwrap(); let job = resolve_navigation_bake_jobs(&root, &main_path, None) .unwrap() .pop() .unwrap(); assert_eq!(job.input.geometry.len(), 1); assert_eq!( job.input.geometry[0].actor_id, "prefab:floor-instance::prefab-floor" ); let x_min = job.input.geometry[0] .triangles .iter() .flatten() .map(|vertex| vertex[0]) .fold(f32::INFINITY, f32::min); assert!((x_min - 4.0).abs() < 0.001, "x_min={x_min}"); std::fs::remove_dir_all(root).unwrap(); } #[test] fn resolver_applies_multiple_properties_and_blocks_a_drifted_prefab_base() { let root = resolver_project(); let prefab = r#"(schema_version:4,resources:{},entities:{ 1:(components:{ "shared::components::ActorId":("prefab-floor"), "bevy_transform::components::transform::Transform":(translation:(1.0,-0.1,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0)), "shared::components::Primitive":(shape:Box,size:(4.0,0.2,4.0)), }), })"#; std::fs::write(root.join("assets/prefabs/floor.scn.ron"), prefab).unwrap(); let mut overrides = PrefabOverrides::default(); overrides.properties.push(shared::PrefabPropertyOverride { target: shared::PrefabActorPath::direct("prefab-floor"), component_type: TRANSFORM_COMPONENT.into(), property_path: "translation.x".into(), base_component_ron: "(translation:(1.0,-0.1,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))".into(), value_component_ron: "(translation:(4.0,-0.1,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))".into(), }); overrides.properties.push(shared::PrefabPropertyOverride { target: shared::PrefabActorPath::direct("prefab-floor"), component_type: TRANSFORM_COMPONENT.into(), property_path: "scale.z".into(), base_component_ron: "(translation:(1.0,-0.1,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))".into(), value_component_ron: "(translation:(1.0,-0.1,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,2.0))".into(), }); let mut instance = PrefabInstance::new("floor-prefab", "assets/prefabs/floor.scn.ron"); instance.overrides_ron = shared::encode_prefab_overrides(&overrides).unwrap(); let instance = ron::to_string(&instance).unwrap(); let bounds = bounds_ron("main-bounds"); let main = format!( r#"(schema_version:4,resources:{{}},entities:{{ 1:(components:{{"shared::components::ActorId":("main-bounds"),"shared::navigation::NavigationBounds":{bounds},}}), 2:(components:{{ "shared::components::ActorId":("floor-instance"), "bevy_transform::components::transform::Transform":(translation:(2.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0)), "shared::components::PrefabInstance":{instance}, }}), }})"# ); let main_path = root.join("assets/levels/main.scn.ron"); std::fs::write(&main_path, main).unwrap(); let job = resolve_navigation_bake_jobs(&root, &main_path, None) .unwrap() .pop() .unwrap(); let x_min = job.input.geometry[0] .triangles .iter() .flatten() .map(|vertex| vertex[0]) .fold(f32::INFINITY, f32::min); assert!((x_min - 4.0).abs() < 0.001, "x_min={x_min}"); let z_extent = job.input.geometry[0] .triangles .iter() .flatten() .map(|vertex| vertex[2]) .fold((f32::INFINITY, f32::NEG_INFINITY), |(min, max), value| { (min.min(value), max.max(value)) }); assert!((z_extent.0 + 4.0).abs() < 0.001, "z_min={}", z_extent.0); assert!((z_extent.1 - 4.0).abs() < 0.001, "z_max={}", z_extent.1); let drifted_prefab = r#"(schema_version:4,resources:{},entities:{ 1:(components:{ "shared::components::ActorId":("prefab-floor"), "bevy_transform::components::transform::Transform":(translation:(1.0,-0.1,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(2.0,1.0,1.0)), "shared::components::Primitive":(shape:Box,size:(4.0,0.2,4.0)), }), })"#; std::fs::write(root.join("assets/prefabs/floor.scn.ron"), drifted_prefab).unwrap(); let error = resolve_navigation_bake_jobs(&root, &main_path, None).unwrap_err(); assert!(error.contains("translation.x"), "{error}"); assert!(error.contains("different"), "{error}"); assert!(error.contains("rebase or apply"), "{error}"); std::fs::remove_dir_all(root).unwrap(); } #[test] fn navigation_override_comparison_detects_primitive_shape_drift() { assert!(!navigation_component_values_equal( COMPONENT_PRIMITIVE, "(shape:Sphere,size:(4.0,0.2,4.0))", "(shape:Box,size:(4.0,0.2,4.0))", ) .unwrap()); } #[test] fn resolver_rejects_structural_prefab_overrides_instead_of_baking_mismatch() { let root = resolver_project(); let prefab = r#"(schema_version:4,resources:{},entities:{ 1:(components:{"shared::components::ActorId":("prefab-floor"),"shared::components::Primitive":(shape:Box,size:(4.0,0.2,4.0)),}), })"#; std::fs::write(root.join("assets/prefabs/floor.scn.ron"), prefab).unwrap(); let mut overrides = PrefabOverrides::default(); overrides .structural .push(shared::PrefabStructuralOverride::RemoveChild { target: shared::PrefabActorPath::direct("prefab-floor"), base_subtree_revision: "fixture".into(), }); let mut instance = PrefabInstance::new("floor-prefab", "assets/prefabs/floor.scn.ron"); instance.overrides_ron = shared::encode_prefab_overrides(&overrides).unwrap(); let instance = ron::to_string(&instance).unwrap(); let bounds = bounds_ron("main-bounds"); let main = format!( r#"(schema_version:4,resources:{{}},entities:{{ 1:(components:{{"shared::components::ActorId":("main-bounds"),"shared::navigation::NavigationBounds":{bounds},}}), 2:(components:{{"shared::components::ActorId":("floor-instance"),"shared::components::PrefabInstance":{instance},}}), }})"# ); let main_path = root.join("assets/levels/main.scn.ron"); std::fs::write(&main_path, main).unwrap(); let error = resolve_navigation_bake_jobs(&root, &main_path, None).unwrap_err(); assert!(error.contains("structural overrides"), "{error}"); assert!(error.contains("prefab:floor-instance"), "{error}"); std::fs::remove_dir_all(root).unwrap(); } #[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_eq!(job.input.samples.len(), 1); assert!(NavigationQueryRuntime::from_artifact(committed.clone()) .unwrap() .validate_samples() .is_empty()); assert!(committed .runtime_mesh() .path(Vec2::new(-6.0, 0.0), Vec2::new(6.0, 0.0)) .is_some()); } }