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.
900 lines
30 KiB
Rust
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());
|
|
}
|
|
}
|