Blacksite/crates/scene/src/navigation.rs
Rbanh 0798aa5d57 Build renderer and material component foundations
Add dedicated skinned rendering, pose restoration, shared Material and Material Instance slots, registry-driven components, Surface/Solari integration, transactional schema upgrades, navigation authoring, documentation, and evaluation evidence.
2026-07-12 00:24:06 -04:00

900 lines
30 KiB
Rust

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