2489 lines
90 KiB
Rust
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());
|
|
}
|
|
}
|