Blacksite/crates/scene/src/navigation.rs

2489 lines
90 KiB
Rust

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<NavigationGeometryInput>,
#[serde(default)]
pub obstacles: Vec<NavigationVolumeInput>,
#[serde(default)]
pub areas: Vec<NavigationAreaInput>,
#[serde(default)]
pub links: Vec<NavigationLinkInput>,
#[serde(default)]
pub samples: Vec<NavigationPathSampleInput>,
}
#[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<NavigationLinkInput>,
pub areas: Vec<NavigationAreaInput>,
#[serde(default)]
pub samples: Vec<NavigationPathSampleInput>,
#[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<String, String> {
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<String, String> {
let mut payload = artifact.clone();
payload.payload_hash.clear();
navigation_artifact_content_hash(&payload)
}
pub fn bake_navigation(input: &NavigationBakeInput) -> Result<NavigationBakeArtifact, String> {
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<NavigationBakeArtifact, String> {
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<String>,
}
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<Self, String> {
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, String> {
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<NavigationQueryPath, String> {
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<Option<(usize, QueryRouteSegment)>> = vec![None; nodes.len()];
let mut mesh_edges: Vec<Vec<Option<QueryRouteSegment>>> =
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<String> {
self.validate_link_findings()
.into_iter()
.map(|finding| finding.message)
.collect()
}
pub fn validate_link_findings(&self) -> Vec<NavigationLinkValidationFinding> {
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<String> {
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<QueryDirectPath, String> {
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<Vec3>,
length: f32,
}
#[derive(Debug, Clone)]
struct QueryRouteSegment {
points: Vec<Vec3>,
weighted_cost: f32,
link_actor_id: Option<String>,
}
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<NavigationQueryPath, String> {
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<Vec3>, 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<NavigationQueryPath, String> {
NavigationQueryRuntime::from_artifact(artifact)?.query(start, end)
}
pub fn navigation_bake_jobs_from_document(
document: &SceneDocument,
source_scene: &str,
) -> Result<Vec<NavigationBakeJob>, 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::<NavigationBounds>(entity, COMPONENT_NAVIGATION_BOUNDS)?;
let obstacle_value =
component::<NavigationObstacle>(entity, COMPONENT_NAVIGATION_OBSTACLE)?;
let area_value = component::<NavigationArea>(entity, COMPONENT_NAVIGATION_AREA)?;
let link_value = component::<NavigationLink>(entity, COMPONENT_NAVIGATION_LINK)?;
let primitive = component::<Primitive>(entity, COMPONENT_PRIMITIVE)?;
let brush = component::<BrushDesc>(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<Vec<NavigationBakeJob>, 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<NavigationGeometryInput>,
obstacles: Vec<NavigationVolumeInput>,
areas: Vec<NavigationAreaInput>,
links: Vec<NavigationLinkInput>,
}
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<PathBuf>,
prefab_stack: &mut Vec<PathBuf>,
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::<NavigationBounds>(entity, COMPONENT_NAVIGATION_BOUNDS)?;
let obstacle_value =
component::<NavigationObstacle>(entity, COMPONENT_NAVIGATION_OBSTACLE)?;
let area_value = component::<NavigationArea>(entity, COMPONENT_NAVIGATION_AREA)?;
let link_value = component::<NavigationLink>(entity, COMPONENT_NAVIGATION_LINK)?;
let primitive = component::<Primitive>(entity, COMPONENT_PRIMITIVE)?;
let brush = component::<BrushDesc>(entity, COMPONENT_BRUSH_DESC)?;
let prefab = component::<PrefabInstance>(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::<Vec<_>>();
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::<Vec<_>>();
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<PathBuf, String> {
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::<Vec<_>>();
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<bool, String> {
fn compare<T>(component_type: &str, current: &str, base: &str) -> Result<bool, String>
where
T: for<'de> Deserialize<'de> + PartialEq,
{
let current = ron::from_str::<T>(current).map_err(|error| {
format!("invalid current value for prefab component `{component_type}`: {error}")
})?;
let base = ron::from_str::<T>(base).map_err(|error| {
format!("invalid base value for prefab component `{component_type}`: {error}")
})?;
Ok(current == base)
}
match component_type {
TRANSFORM_COMPONENT => compare::<SerializedTransform>(component_type, current, base),
COMPONENT_PRIMITIVE => compare::<Primitive>(component_type, current, base),
COMPONENT_BRUSH_DESC => compare::<BrushDesc>(component_type, current, base),
COMPONENT_NAVIGATION_BOUNDS => compare::<NavigationBounds>(component_type, current, base),
COMPONENT_NAVIGATION_OBSTACLE => {
compare::<NavigationObstacle>(component_type, current, base)
}
COMPONENT_NAVIGATION_AREA => compare::<NavigationArea>(component_type, current, base),
COMPONENT_NAVIGATION_LINK => compare::<NavigationLink>(component_type, current, base),
PREFAB_INSTANCE_COMPONENT => compare::<PrefabInstance>(component_type, current, base),
PREFAB_REF_COMPONENT => compare::<PrefabRef>(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<NavigationBakeJob> {
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<Vec<bool>> = mesh
.layers
.iter()
.map(|layer| vec![false; layer.polygons.len()])
.collect();
let mut island_by_polygon: Vec<Vec<usize>> = 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<usize> = (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<usize>],
) -> Option<usize> {
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<NavigationGeometryInput> {
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<NavigationGeometryInput> {
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<std::collections::BTreeMap<String, WorldPose>, 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<String, WorldPose>,
visiting: &mut std::collections::BTreeSet<String>,
) -> Result<WorldPose, String> {
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::<SerializedTransform>(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<T: for<'de> Deserialize<'de>>(
entity: &SceneEntity,
type_name: &str,
) -> Result<Option<T>, 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());
}
}