Ship production navigation authoring workflow

This commit is contained in:
Rbanh 2026-07-12 02:53:43 -04:00
parent 0798aa5d57
commit b4aa61e394
35 changed files with 3898 additions and 602 deletions

View File

@ -6,8 +6,9 @@ This is the navigation production loop required by the M7 content-production mil
## Status ## Status
Architecture and dependency compatibility are established. Implementation and production Architecture, dependency compatibility, composed-source resolution, persisted path samples,
acceptance are in progress. corrective source-level QA, and live debug-editor acceptance are complete. Packaged/release
acceptance remains explicitly deferred until the project owner requests another packaged pass.
## Outcome ## Outcome
@ -18,13 +19,15 @@ use the same artifact through a game-owned runtime query API and headless releas
## Architecture ## Architecture
- Shared reflected components own bounds, obstacles, area volumes, links, and scene bake settings. - Shared reflected components own bounds, obstacles, area volumes, links, and scene bake settings.
- The scene crate owns deterministic source fingerprinting, bake artifact IO, and validation. - The scene crate owns deterministic composed-source resolution, source fingerprinting, bake
artifact IO, the engine-independent query core, and validation.
- Rerecast performs the 3D walkable-surface bake without coupling navigation to a Bevy plugin - Rerecast performs the 3D walkable-surface bake without coupling navigation to a Bevy plugin
release. Polyanya performs proven any-angle runtime and editor-preview path queries. release. Polyanya performs proven any-angle runtime and editor-preview path queries.
- Overlapping primitive and additive-brush triangles enter the deterministic bake source; distant - Overlapping primitive and additive-brush triangles enter the deterministic bake source; distant
geometry and navigation authoring are excluded from each bounds fingerprint. geometry and navigation authoring are excluded from each bounds fingerprint. No synthetic bounds
- The game crate owns loading and querying baked artifacts. Editor UI calls that public API for floor is added.
preview rather than implementing a second pathfinder. - The game crate adapts the shared query core to Bevy types and exposes renderer-free artifact
validation. Editor preview and project validation do not implement separate pathfinders.
- Generated artifacts live under `assets/navigation/generated/`, are project-relative runtime - Generated artifacts live under `assets/navigation/generated/`, are project-relative runtime
dependencies, and never serialize viewport helpers into authored scenes. dependencies, and never serialize viewport helpers into authored scenes.
@ -39,8 +42,8 @@ use the same artifact through a game-owned runtime query API and headless releas
preview. preview.
5. Add owner-attributed project validation for invalid links, missing/stale artifacts, unreachable 5. Add owner-attributed project validation for invalid links, missing/stale artifacts, unreachable
preview samples, and bake failures. preview samples, and bake failures.
6. Commit a small sample scene/artifact and run automated, headless, live editor, PIE, and packaged 6. Commit a small sample scene/artifact and run automated, headless, and live debug-editor
runtime acceptance. acceptance. PIE and packaged runtime acceptance remain deferred by project-owner direction.
## Acceptance Gates ## Acceptance Gates
@ -55,6 +58,8 @@ use the same artifact through a game-owned runtime query API and headless releas
- Helper meshes, lines, endpoints, and bake state are transient and never serialize. - Helper meshes, lines, endpoints, and bake state are transient and never serialize.
- Headless bake and project validation pass for the committed sample fixture, and package dependency - Headless bake and project validation pass for the committed sample fixture, and package dependency
collection includes the current artifact. collection includes the current artifact.
- Visible subscenes and nested prefab contributors resolve identically from editor snapshots and
headless tooling; unsupported structural overrides block with an apply/unpack repair action.
## Deliberate Boundaries ## Deliberate Boundaries

2
Cargo.lock generated
View File

@ -6502,6 +6502,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"avian3d", "avian3d",
"bevy", "bevy",
"blake3",
"ron 0.8.1", "ron 0.8.1",
"serde", "serde",
"settings", "settings",
@ -8437,6 +8438,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"settings", "settings",
"shared",
"walkdir", "walkdir",
] ]

View File

@ -45,9 +45,9 @@ egui_dock = { version = "0.19.1", features = ["serde"] }
egui_phosphor_icons = { version = "0.3.1", default-features = false } egui_phosphor_icons = { version = "0.3.1", default-features = false }
transform-gizmo-bevy = "0.9" transform-gizmo-bevy = "0.9"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
nav_glam = { package = "glam", version = "0.30.8", features = ["serde"] } nav_glam = { package = "glam", version = "=0.30.10", features = ["serde"] }
polyanya = { version = "0.16.1", default-features = false, features = ["recast", "serde"] } polyanya = { version = "=0.16.1", default-features = false, features = ["recast", "serde"] }
rerecast = { version = "0.3.2", default-features = false, features = ["std", "serialize"] } rerecast = { version = "=0.3.2", default-features = false, features = ["std", "serialize"] }
shared = { path = "crates/shared" } shared = { path = "crates/shared" }
game = { path = "crates/game" } game = { path = "crates/game" }
game_hot = { path = "crates/game_hot" } game_hot = { path = "crates/game_hot" }

View File

@ -28,6 +28,8 @@ cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings
cargo test --workspace cargo test --workspace
cargo validate-levels cargo validate-levels
cargo bake-navigation --project . --check cargo bake-navigation --project . --check
# Validate one artifact without opening a game window
cargo run -p game -- --validate-navigation assets/navigation/generated/navigation_showcase_humanoid.nav.ron
# Machine-readable project dependency and finding report # Machine-readable project dependency and finding report
cargo validate-levels --json cargo validate-levels --json
cargo package-project --profile development cargo package-project --profile development
@ -242,9 +244,10 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
- Navigation bounds, obstacles, areas, and links are created from **Scene > Navigation** or the path - Navigation bounds, obstacles, areas, and links are created from **Scene > Navigation** or the path
icon in the existing horizontal toolbar. Bounds bake versioned Rerecast artifacts under icon in the existing horizontal toolbar. Bounds bake versioned Rerecast artifacts under
`assets/navigation/generated/`; overlapping primitive and additive-brush triangles participate `assets/navigation/generated/`; overlapping primitive and additive-brush triangles participate
in the bake fingerprint while distant authoring is excluded. The Inspector reports in the bake fingerprint while distant authoring is excluded. Visible composed subscenes and linked
stale/current state and provides a Polyanya-backed path test whose mesh, links, and route render prefab sources resolve through the same deterministic bake path used by CI. The Inspector reports
in the viewport. Use stale/current state, pins named validation paths, and provides a Polyanya-backed path test whose
mesh, links, and route render in the viewport. Use
`cargo bake-navigation --project . --check` in CI. See the `cargo bake-navigation --project . --check` in CI. See the
[navigation authoring guide](docs/editor/navigation-authoring.md) and [ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.md). [navigation authoring guide](docs/editor/navigation-authoring.md) and [ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.md).
- Brush actors are persisted as `ActorKind::Brush + BrushDesc`; valid convex faces hydrate into - Brush actors are persisted as `ActorKind::Brush + BrushDesc`; valid convex faces hydrate into
@ -389,7 +392,7 @@ crates/
- [x] Audio clip catalog/import foundation for Ogg, WAV, MP3, and FLAC with dedicated filtering, file details, and stable runtime-resolvable asset references - [x] Audio clip catalog/import foundation for Ogg, WAV, MP3, and FLAC with dedicated filtering, file details, and stable runtime-resolvable asset references
- [x] Audio source/listener authoring, non-dirty spatial audition, viewport icons/range gizmos, stable buses, PIE/runtime parity, device diagnostics, and shared release validation ([ADR 0030](docs/adr/0030-audio-authoring-and-bus-schema.md); production acceptance completed in [Gitea #47](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47)) - [x] Audio source/listener authoring, non-dirty spatial audition, viewport icons/range gizmos, stable buses, PIE/runtime parity, device diagnostics, and shared release validation ([ADR 0030](docs/adr/0030-audio-authoring-and-bus-schema.md); production acceptance completed in [Gitea #47](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47))
- [x] glTF/GLB skeletal animation manifests, stable controller states, non-dirty preview, PIE/runtime hydration, and exact-signature compatibility validation ([ADR 0031](docs/adr/0031-animation-authoring-runtime-contract.md); production acceptance completed in [Gitea #46](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/46)) - [x] glTF/GLB skeletal animation manifests, stable controller states, non-dirty preview, PIE/runtime hydration, and exact-signature compatibility validation ([ADR 0031](docs/adr/0031-animation-authoring-runtime-contract.md); production acceptance completed in [Gitea #46](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/46))
- [ ] Navigation bounds/obstacles/areas/links, deterministic stale-checked bake artifacts, viewport path preview, headless bake, and game runtime query API ([ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.md); tracked in [Gitea #48](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/48)) - [x] Navigation bounds/obstacles/areas/links, persisted validation samples, composed-source resolution, deterministic stale-checked bake artifacts, viewport path preview, headless bake, and shared game/runtime query API ([ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.md); [evaluation](docs/editor/evaluations/navigation-authoring/); [Gitea #48](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/48))
- [x] Prefab instances (`PrefabInstance`) + save-as-prefab + unpack - [x] Prefab instances (`PrefabInstance`) + save-as-prefab + unpack
- [x] Independent dirty-tab close confirmation and all-tab save guard when switching projects - [x] Independent dirty-tab close confirmation and all-tab save guard when switching projects
- [x] Transactional scene writes + bounded user-local recovery snapshots ([ADR 0023](docs/adr/0023-transactional-scene-persistence-and-recovery.md)) - [x] Transactional scene writes + bounded user-local recovery snapshots ([ADR 0023](docs/adr/0023-transactional-scene-persistence-and-recovery.md))

View File

@ -24,6 +24,9 @@
), ),
artifact_path: "assets/navigation/generated/navigation_showcase_humanoid.nav.ron", artifact_path: "assets/navigation/generated/navigation_showcase_humanoid.nav.ron",
auto_bake: false, auto_bake: false,
validation_samples: [
(id: "around-center-obstacle", start: (-6.0, 0.0, -5.0), end: (6.0, 0.0, -5.0), enabled: true),
],
), ),
}), }),
2: (components: { 2: (components: {
@ -67,4 +70,4 @@
"shared::components::Primitive": (shape: Box, size: (16.0, 0.5, 16.0)), "shared::components::Primitive": (shape: Box, size: (16.0, 0.5, 16.0)),
}), }),
}, },
) )

View File

@ -1,8 +1,9 @@
( (
schema_version: 1, schema_version: 2,
generator: "rerecast-0.3/polyanya-0.16", generator: "blacksite-nav-2/rerecast-0.3.2/polyanya-0.16.1/glam-0.30.10",
source_scene: "assets/levels/navigation_authoring_showcase.scn.ron", source_scene: "assets/levels/navigation_authoring_showcase.scn.ron",
source_fingerprint: "c3f7b1f6eb8cb9ab5d4bb3a9b0d09ce47641c563f47fa75df4e7eb30862d039c", source_fingerprint: "15649e3f7c8f22d642ea318ba1dafb5a3ada82e2713219cb60bbb518c5e45195",
payload_hash: "180b5a4698102e8185ae182bc30cf847466c4e9d65fe7834c315f6d1a03504c2",
bounds_actor_id: "navigation-bounds-main", bounds_actor_id: "navigation-bounds-main",
center: (0.0, 0.0, 0.0), center: (0.0, 0.0, 0.0),
half_extents: (8.0, 2.0, 8.0), half_extents: (8.0, 2.0, 8.0),
@ -960,4 +961,17 @@
walkable: true, walkable: true,
), ),
], ],
samples: [
(
id: "around-center-obstacle",
start: (-6.0, 0.0, -5.0),
end: (6.0, 0.0, -5.0),
enabled: true,
),
],
diagnostics: (
polygon_count: 26,
island_count: 1,
effective_component_count: 1,
),
) )

View File

@ -63,7 +63,7 @@ impl From<&SurfaceExtension> for SurfaceExtensionKey {
} }
} }
#[derive(Asset, AsBindGroup, Reflect, Debug, Clone)] #[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)]
#[bind_group_data(SurfaceExtensionKey)] #[bind_group_data(SurfaceExtensionKey)]
pub struct SurfaceExtension { pub struct SurfaceExtension {
#[uniform(100)] #[uniform(100)]
@ -96,23 +96,6 @@ pub struct SurfaceExtension {
pub shader: Handle<Shader>, pub shader: Handle<Shader>,
} }
impl Default for SurfaceExtension {
fn default() -> Self {
Self {
uniform: SurfaceUniform::default(),
texture0: None,
texture1: None,
texture2: None,
texture3: None,
texture4: None,
texture5: None,
texture6: None,
texture7: None,
shader: Handle::default(),
}
}
}
impl SurfaceExtension { impl SurfaceExtension {
pub fn set_texture(&mut self, index: usize, handle: Option<Handle<Image>>) { pub fn set_texture(&mut self, index: usize, handle: Option<Handle<Image>>) {
match index { match index {
@ -170,8 +153,8 @@ pub struct SurfaceMaterialCache {
} }
enum BuiltRendererMaterial { enum BuiltRendererMaterial {
Standard(StandardMaterial), Standard(Box<StandardMaterial>),
Surface(SurfaceMaterial), Surface(Box<SurfaceMaterial>),
} }
#[derive(Resource, Default, Debug, Clone)] #[derive(Resource, Default, Debug, Clone)]
@ -203,7 +186,7 @@ impl Plugin for SurfaceMaterialPlugin {
} }
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn sync_surface_material_bindings( fn sync_surface_material_bindings(
mut commands: Commands, mut commands: Commands,
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
@ -267,6 +250,7 @@ fn sync_surface_material_bindings(
&mut evaluator_registry, &mut evaluator_registry,
) { ) {
Ok(BuiltRendererMaterial::Surface(material)) => { Ok(BuiltRendererMaterial::Surface(material)) => {
let material = *material;
cache.standard_only.remove(reference); cache.standard_only.remove(reference);
cache.standard_handles.remove(reference); cache.standard_handles.remove(reference);
cache.failed_revisions.remove(reference); cache.failed_revisions.remove(reference);
@ -282,6 +266,7 @@ fn sync_surface_material_bindings(
} }
} }
Ok(BuiltRendererMaterial::Standard(material)) => { Ok(BuiltRendererMaterial::Standard(material)) => {
let material = *material;
// Plain Material assets stay on Bevy's StandardMaterial path, while still // Plain Material assets stay on Bevy's StandardMaterial path, while still
// sharing one live-updated handle across every renderer slot. // sharing one live-updated handle across every renderer slot.
let handle = standard_handle let handle = standard_handle
@ -404,7 +389,7 @@ fn build_surface_material(
(!asset.render_state.double_sided).then_some(bevy::render::render_resource::Face::Back); (!asset.render_state.double_sided).then_some(bevy::render::render_resource::Face::Back);
let Some(evaluator) = evaluator_source.as_deref() else { let Some(evaluator) = evaluator_source.as_deref() else {
return Ok(BuiltRendererMaterial::Standard(base)); return Ok(BuiltRendererMaterial::Standard(Box::new(base)));
}; };
validate_surface_evaluator(evaluator)?; validate_surface_evaluator(evaluator)?;
let runtime_shader_id = stable_shader_id(reference); let runtime_shader_id = stable_shader_id(reference);
@ -443,10 +428,10 @@ fn build_surface_material(
asset_server, asset_server,
)?; )?;
} }
Ok(BuiltRendererMaterial::Surface(ExtendedMaterial { Ok(BuiltRendererMaterial::Surface(Box::new(ExtendedMaterial {
base, base,
extension, extension,
})) })))
} }
fn material_dependency_paths(path: &str) -> Result<Vec<String>, String> { fn material_dependency_paths(path: &str) -> Result<Vec<String>, String> {

View File

@ -319,6 +319,7 @@ pub fn clear_level_objects(world: &mut World) {
} }
pub fn spawn_with_history(world: &mut World, mut snapshot: EditorEntitySnapshot) -> Entity { pub fn spawn_with_history(world: &mut World, mut snapshot: EditorEntitySnapshot) -> Entity {
assign_missing_actor_ids(&mut snapshot);
snapshot.hierarchy_sibling_index = next_sibling_index(world, None); snapshot.hierarchy_sibling_index = next_sibling_index(world, None);
let entity = spawn_snapshot(world, &snapshot); let entity = spawn_snapshot(world, &snapshot);
push_history( push_history(
@ -340,6 +341,9 @@ pub fn spawn_many_with_history(
if snapshots.is_empty() { if snapshots.is_empty() {
return Vec::new(); return Vec::new();
} }
for snapshot in &mut snapshots {
assign_missing_actor_ids(snapshot);
}
for (sibling_index, snapshot) in (next_sibling_index(world, None)..).zip(snapshots.iter_mut()) { for (sibling_index, snapshot) in (next_sibling_index(world, None)..).zip(snapshots.iter_mut()) {
snapshot.hierarchy_sibling_index = sibling_index; snapshot.hierarchy_sibling_index = sibling_index;
} }
@ -421,12 +425,37 @@ pub fn duplicate_entities_with_history(world: &mut World, entities: &[Entity]) {
} }
fn assign_fresh_actor_ids(snapshot: &mut EditorEntitySnapshot) { fn assign_fresh_actor_ids(snapshot: &mut EditorEntitySnapshot) {
snapshot.actor_id = Some(new_actor_id()); let actor_id = new_actor_id();
if let Some(bounds) = &mut snapshot.navigation_bounds {
bounds.artifact_path = shared::navigation_artifact_path_for_actor(&actor_id.0);
}
snapshot.actor_id = Some(actor_id);
for child in &mut snapshot.children { for child in &mut snapshot.children {
assign_fresh_actor_ids(child); assign_fresh_actor_ids(child);
} }
} }
fn assign_missing_actor_ids(snapshot: &mut EditorEntitySnapshot) {
let existing_actor_id = snapshot
.actor_id
.clone()
.filter(|id| !id.0.trim().is_empty());
let assigned_new_id = existing_actor_id.is_none();
let actor_id = existing_actor_id.unwrap_or_else(new_actor_id);
if let Some(bounds) = &mut snapshot.navigation_bounds {
if assigned_new_id
|| bounds.artifact_path == NavigationBounds::default().artifact_path
|| shared::navigation_generated_artifact_path(&bounds.artifact_path).is_err()
{
bounds.artifact_path = shared::navigation_artifact_path_for_actor(&actor_id.0);
}
}
snapshot.actor_id = Some(actor_id);
for child in &mut snapshot.children {
assign_missing_actor_ids(child);
}
}
pub fn rename_entity_with_history(world: &mut World, entity: Entity, new_name: String) { pub fn rename_entity_with_history(world: &mut World, entity: Entity, new_name: String) {
if !is_mutable_level_object(world, entity) { if !is_mutable_level_object(world, entity) {
return; return;
@ -2825,8 +2854,10 @@ mod tests {
#[test] #[test]
fn snapshots_preserve_component_order_and_independent_active_state() { fn snapshots_preserve_component_order_and_independent_active_state() {
let mut world = World::new(); let mut world = World::new();
let mut order = InspectorOrder::default(); let order = InspectorOrder {
order.component_ids = vec![shared::AUTHORING_COMPONENT_LIGHT.to_string()]; component_ids: vec![shared::AUTHORING_COMPONENT_LIGHT.to_string()],
..Default::default()
};
let mut states = AuthoringComponentStates::default(); let mut states = AuthoringComponentStates::default();
states.set_component_active(shared::COMPONENT_LIGHT_DESC, false); states.set_component_active(shared::COMPONENT_LIGHT_DESC, false);
let entity = world let entity = world
@ -3516,6 +3547,73 @@ mod tests {
assert_eq!(unique.len(), ids.len(), "duplicated actors need fresh IDs"); assert_eq!(unique.len(), ids.len(), "duplicated actors need fresh IDs");
} }
#[test]
fn duplicate_navigation_bounds_gets_actor_owned_artifact_path() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
let original_id = ActorId::new("navigation-bounds-original");
let original_bounds = NavigationBounds::for_actor(&original_id.0);
let original = world
.spawn((
LevelObject,
ActorKind::Navigation,
original_id,
Transform::IDENTITY,
original_bounds.clone(),
))
.id();
duplicate_entities_with_history(&mut world, &[original]);
let (duplicate_id, duplicate_bounds) = world
.query::<(Entity, &ActorId, &NavigationBounds)>()
.iter(&world)
.find_map(|(entity, actor_id, bounds)| {
(entity != original).then_some((actor_id.clone(), bounds.clone()))
})
.expect("duplicated navigation bounds should exist");
assert_ne!(
duplicate_bounds.artifact_path,
original_bounds.artifact_path
);
assert_eq!(
duplicate_bounds.artifact_path,
shared::navigation_artifact_path_for_actor(&duplicate_id.0)
);
apply_command_undo(&mut world);
apply_command_redo(&mut world);
let redone = world
.query::<(&ActorId, &NavigationBounds)>()
.iter(&world)
.find(|(actor_id, _)| **actor_id == duplicate_id)
.expect("redo should preserve the duplicate actor identity");
assert_eq!(redone.1, &duplicate_bounds);
}
#[test]
fn existing_navigation_snapshot_keeps_deliberate_generated_path() {
let mut world = World::new();
let mut bounds = NavigationBounds::for_actor("bounds-existing");
bounds.artifact_path = "assets/navigation/generated/custom-zone.nav.ron".into();
let entity = world
.spawn((
LevelObject,
ActorKind::Navigation,
ActorId::new("bounds-existing"),
Transform::IDENTITY,
bounds.clone(),
))
.id();
let mut snapshot = snapshot_entity(&world, entity).unwrap();
assign_missing_actor_ids(&mut snapshot);
assert_eq!(snapshot.navigation_bounds.as_ref(), Some(&bounds));
}
#[test] #[test]
fn linked_members_reject_direct_history_mutations() { fn linked_members_reject_direct_history_mutations() {
let mut world = World::new(); let mut world = World::new();

View File

@ -88,6 +88,7 @@ pub struct SceneIo {
pub events: VecDeque<SceneIoEvent>, pub events: VecDeque<SceneIoEvent>,
pub tabs: Vec<SceneTab>, pub tabs: Vec<SceneTab>,
pub active_tab: usize, pub active_tab: usize,
change_revision: u64,
next_event_id: u64, next_event_id: u64,
next_tab_id: u64, next_tab_id: u64,
} }
@ -127,6 +128,7 @@ impl Default for SceneIo {
recovery_snapshot: None, recovery_snapshot: None,
}], }],
active_tab: 0, active_tab: 0,
change_revision: 0,
next_event_id: 1, next_event_id: 1,
next_tab_id: 2, next_tab_id: 2,
} }
@ -136,14 +138,20 @@ impl Default for SceneIo {
impl SceneIo { impl SceneIo {
pub fn mark_dirty(&mut self) { pub fn mark_dirty(&mut self) {
self.dirty = true; self.dirty = true;
self.change_revision = self.change_revision.wrapping_add(1);
self.sync_active_tab_metadata(); self.sync_active_tab_metadata();
} }
pub fn mark_clean(&mut self) { pub fn mark_clean(&mut self) {
self.dirty = false; self.dirty = false;
self.change_revision = self.change_revision.wrapping_add(1);
self.sync_active_tab_metadata(); self.sync_active_tab_metadata();
} }
pub fn change_revision(&self) -> u64 {
self.change_revision
}
pub fn active_path_label(&self) -> String { pub fn active_path_label(&self) -> String {
self.active_path self.active_path
.as_ref() .as_ref()
@ -935,7 +943,7 @@ fn save_level(world: &mut World, path: &Path) -> Result<usize, String> {
} }
} }
fn serialize_active_scene(world: &mut World) -> Result<String, String> { pub(crate) fn serialize_active_scene(world: &mut World) -> Result<String, String> {
let entities = authored_scene_entities(world); let entities = authored_scene_entities(world);
if world if world
.resource::<SceneIo>() .resource::<SceneIo>()
@ -1950,11 +1958,13 @@ mod tests {
let mut io = SceneIo::default(); let mut io = SceneIo::default();
assert_eq!(io.tabs.len(), 1); assert_eq!(io.tabs.len(), 1);
assert!(!io.tabs[0].dirty); assert!(!io.tabs[0].dirty);
let initial_revision = io.change_revision();
io.active_path = Some(PathBuf::from("assets/levels/arena.scn.ron")); io.active_path = Some(PathBuf::from("assets/levels/arena.scn.ron"));
io.mark_dirty(); io.mark_dirty();
assert!(io.has_unsaved_tabs()); assert!(io.has_unsaved_tabs());
assert!(io.change_revision() > initial_revision);
assert!(io.tabs[0].dirty); assert!(io.tabs[0].dirty);
assert_eq!( assert_eq!(
io.tabs[0].path.as_deref(), io.tabs[0].path.as_deref(),

View File

@ -9,9 +9,9 @@ use egui_phosphor_icons::{icons, Icon};
use shared::{ use shared::{
authoring_component_active, authoring_component_active,
brush_math::{validate_brush, BrushDiagnosticSeverity}, brush_math::{validate_brush, BrushDiagnosticSeverity},
infer_actor_kind, ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, infer_actor_kind, ActorId, ActorKind, AnimationControllerDesc, AudioListenerDesc,
AuthoringComponentStates, AuthoringLightKind, AuthoringRigidBody, BrushDesc, BrushKind, AudioSourceDesc, AuthoringComponentStates, AuthoringLightKind, AuthoringRigidBody, BrushDesc,
ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef, BrushKind, ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef,
InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter, InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter,
MaterialParameterValue, MaterialRef, MaterialShaderKind, NavigationArea, NavigationBounds, MaterialParameterValue, MaterialRef, MaterialShaderKind, NavigationArea, NavigationBounds,
NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn, NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn,
@ -690,9 +690,13 @@ fn paste_component(world: &mut World, entity: Entity, type_name: &str) {
Some(CopiedComponent::PrefabInstance(value)) if type_name == COMPONENT_PREFAB_INSTANCE => { Some(CopiedComponent::PrefabInstance(value)) if type_name == COMPONENT_PREFAB_INSTANCE => {
insert_direct_component(world, entity, value); insert_direct_component(world, entity, value);
} }
Some(CopiedComponent::NavigationBounds(value)) Some(CopiedComponent::NavigationBounds(mut value))
if type_name == COMPONENT_NAVIGATION_BOUNDS => if type_name == COMPONENT_NAVIGATION_BOUNDS =>
{ {
value.artifact_path = world
.get::<NavigationBounds>(entity)
.map(|bounds| bounds.artifact_path.clone())
.unwrap_or_else(|| navigation_bounds_for_entity(world, entity).artifact_path);
crate::history::set_navigation_with_history( crate::history::set_navigation_with_history(
world, world,
entity, entity,
@ -760,6 +764,19 @@ fn reset_component(world: &mut World, entity: Entity, type_name: &str) {
Ok(()) Ok(())
}, },
); );
} else if type_path == COMPONENT_NAVIGATION_BOUNDS {
let bounds = reset_navigation_bounds_for_entity(world, entity);
let _ = crate::history::reflected_component_transaction(
world,
entity,
"Reset Component",
component_id,
type_path,
move |world, entity| {
world.entity_mut(entity).insert(bounds);
Ok(())
},
);
} else { } else {
let _ = crate::history::reflected_component_transaction( let _ = crate::history::reflected_component_transaction(
world, world,
@ -809,7 +826,7 @@ fn reset_component(world: &mut World, entity: Entity, type_name: &str) {
world, world,
entity, entity,
crate::history::NavigationComponentState { crate::history::NavigationComponentState {
bounds: Some(NavigationBounds::default()), bounds: Some(reset_navigation_bounds_for_entity(world, entity)),
..Default::default() ..Default::default()
}, },
), ),
@ -1609,6 +1626,19 @@ fn insert_registered_component(world: &mut World, entity: Entity, type_name: &st
Ok(()) Ok(())
}, },
); );
} else if descriptor.type_name == COMPONENT_NAVIGATION_BOUNDS {
let bounds = navigation_bounds_for_entity(world, entity);
let _ = crate::history::reflected_component_transaction(
world,
entity,
"Add Component",
descriptor.id,
descriptor.type_name,
move |world, entity| {
world.entity_mut(entity).insert(bounds);
Ok(())
},
);
} else { } else {
let _ = crate::history::reflected_component_transaction( let _ = crate::history::reflected_component_transaction(
world, world,
@ -1689,9 +1719,8 @@ fn insert_registered_component(world: &mut World, entity: Entity, type_name: &st
}); });
}), }),
COMPONENT_NAVIGATION_BOUNDS => insert_component(world, entity, |world, e| { COMPONENT_NAVIGATION_BOUNDS => insert_component(world, entity, |world, e| {
world let bounds = navigation_bounds_for_entity(world, e);
.entity_mut(e) world.entity_mut(e).insert((ActorKind::Navigation, bounds));
.insert((ActorKind::Navigation, NavigationBounds::default()));
}), }),
COMPONENT_NAVIGATION_OBSTACLE => insert_component(world, entity, |world, e| { COMPONENT_NAVIGATION_OBSTACLE => insert_component(world, entity, |world, e| {
world world
@ -1717,6 +1746,21 @@ fn insert_registered_component(world: &mut World, entity: Entity, type_name: &st
} }
} }
fn navigation_bounds_for_entity(world: &World, entity: Entity) -> NavigationBounds {
world
.get::<ActorId>(entity)
.map(|actor_id| NavigationBounds::for_actor(&actor_id.0))
.unwrap_or_else(|| NavigationBounds::for_actor(&uuid::Uuid::new_v4().to_string()))
}
fn reset_navigation_bounds_for_entity(world: &World, entity: Entity) -> NavigationBounds {
let mut bounds = navigation_bounds_for_entity(world, entity);
if let Some(current) = world.get::<NavigationBounds>(entity) {
bounds.artifact_path.clone_from(&current.artifact_path);
}
bounds
}
fn insert_component(world: &mut World, entity: Entity, insert: impl FnOnce(&mut World, Entity)) { fn insert_component(world: &mut World, entity: Entity, insert: impl FnOnce(&mut World, Entity)) {
let before = crate::history::snapshot_entity(world, entity); let before = crate::history::snapshot_entity(world, entity);
let old_kind = before.as_ref().map(|s| s.actor_kind); let old_kind = before.as_ref().map(|s| s.actor_kind);

File diff suppressed because it is too large Load Diff

View File

@ -154,33 +154,45 @@ fn draw_navigation_visualizers(
links: Query<(&NavigationLink, &GlobalTransform), With<LevelObject>>, links: Query<(&NavigationLink, &GlobalTransform), With<LevelObject>>,
) { ) {
for (value, global) in &bounds { for (value, global) in &bounds {
let (_, rotation, center) = global.to_scale_rotation_translation(); let half_extents = crate::ui::navigation_inspector::world_aabb_half_extents(
global.affine(),
value.half_extents,
);
let center = global.translation();
draw_box( draw_box(
&mut gizmos, &mut gizmos,
center, center,
rotation, Quat::IDENTITY,
value.half_extents, half_extents,
Color::srgba(0.12, 0.82, 0.78, 0.72), Color::srgba(0.12, 0.82, 0.78, 0.72),
); );
} }
for (value, global) in &obstacles { for (value, global) in &obstacles {
let (_, rotation, center) = global.to_scale_rotation_translation(); let half_extents = crate::ui::navigation_inspector::world_aabb_half_extents(
global.affine(),
value.half_extents,
);
let center = global.translation();
draw_box( draw_box(
&mut gizmos, &mut gizmos,
center, center,
rotation, Quat::IDENTITY,
value.half_extents, half_extents,
Color::srgba(1.0, 0.28, 0.22, 0.78), Color::srgba(1.0, 0.28, 0.22, 0.78),
); );
} }
for (value, global) in &areas { for (value, global) in &areas {
let (_, rotation, center) = global.to_scale_rotation_translation(); let half_extents = crate::ui::navigation_inspector::world_aabb_half_extents(
global.affine(),
value.half_extents,
);
let center = global.translation();
let color = if value.walkable { let color = if value.walkable {
Color::srgba(0.32, 0.72, 1.0, 0.62) Color::srgba(0.32, 0.72, 1.0, 0.62)
} else { } else {
Color::srgba(1.0, 0.55, 0.18, 0.72) Color::srgba(1.0, 0.55, 0.18, 0.72)
}; };
draw_box(&mut gizmos, center, rotation, value.half_extents, color); draw_box(&mut gizmos, center, Quat::IDENTITY, half_extents, color);
} }
for (value, global) in &links { for (value, global) in &links {
let start = global.transform_point(value.start); let start = global.transform_point(value.start);

View File

@ -2,6 +2,33 @@ use bevy::prelude::*;
use game::{launch, GamePlugin}; use game::{launch, GamePlugin};
fn main() { fn main() {
let arguments = std::env::args().skip(1).collect::<Vec<_>>();
match game::navigation::navigation_validation_argument(&arguments) {
Ok(Some(path)) => match game::navigation::validate_navigation_artifact(&path) {
Ok(summary) => {
println!(
"navigation validation passed: {} sample(s), {} link(s), {}",
summary.enabled_sample_count,
summary.enabled_link_count,
summary.artifact_path.display()
);
return;
}
Err(error) => {
eprintln!(
"navigation validation failed for {}:\n{error}",
path.display()
);
std::process::exit(1);
}
},
Ok(None) => {}
Err(error) => {
eprintln!("{error}");
std::process::exit(2);
}
}
App::new() App::new()
.add_plugins(launch::default_plugins("Bevy FPS Foundation")) .add_plugins(launch::default_plugins("Bevy FPS Foundation"))
.add_plugins(GamePlugin) .add_plugins(GamePlugin)

View File

@ -1,172 +1,63 @@
use std::path::Path; use std::path::{Path, PathBuf};
use bevy::prelude::Vec3; use bevy::prelude::Vec3;
use nav_glam::{Vec2 as NavVec2, Vec3 as NavVec3}; use scene::navigation::{NavigationBakeArtifact, NavigationQueryPath, NavigationQueryRuntime};
use polyanya::Mesh;
use scene::navigation::{read_navigation_artifact, NavigationBakeArtifact, NavigationLinkInput};
/// Loaded navigation artifact and its baked Polyanya acceleration data. /// Loaded navigation artifact and its shared Polyanya query runtime.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NavigationRuntime { pub struct NavigationRuntime {
artifact: NavigationBakeArtifact, query: NavigationQueryRuntime,
mesh: Mesh,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct NavigationPath { pub struct NavigationPath {
pub points: Vec<Vec3>, pub points: Vec<Vec3>,
pub length: f32, pub length: f32,
pub used_link_actor_id: Option<String>, pub used_link_actor_ids: Vec<String>,
}
impl NavigationPath {
pub fn used_link_actor_id(&self) -> Option<&str> {
self.used_link_actor_ids.first().map(String::as_str)
}
}
impl From<NavigationQueryPath> for NavigationPath {
fn from(path: NavigationQueryPath) -> Self {
Self {
points: path.points.into_iter().map(Vec3::from_array).collect(),
length: path.length,
used_link_actor_ids: path.used_link_actor_ids,
}
}
} }
impl NavigationRuntime { impl NavigationRuntime {
pub fn from_artifact(artifact: NavigationBakeArtifact) -> Result<Self, String> { pub fn from_artifact(artifact: NavigationBakeArtifact) -> Result<Self, String> {
if artifact.mesh.layers.is_empty() Ok(Self {
|| artifact query: NavigationQueryRuntime::from_artifact(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> { pub fn load(path: &Path) -> Result<Self, String> {
Self::from_artifact(read_navigation_artifact(path)?) Ok(Self {
query: NavigationQueryRuntime::load(path)?,
})
} }
pub fn artifact(&self) -> &NavigationBakeArtifact { pub fn artifact(&self) -> &NavigationBakeArtifact {
&self.artifact self.query.artifact()
} }
pub fn query(&self, start: Vec3, end: Vec3) -> Result<NavigationPath, String> { pub fn query(&self, start: Vec3, end: Vec3) -> Result<NavigationPath, String> {
if !start.is_finite() || !end.is_finite() { self.query
return Err("navigation query endpoints must be finite".into()); .query(start.to_array(), end.to_array())
} .map(NavigationPath::from)
let mut best = self.direct_path(start, end).ok().map(|path| Candidate {
path,
weighted_cost: 0.0,
});
if let Some(candidate) = best.as_mut() {
candidate.weighted_cost = candidate.path.length;
}
for link in self.artifact.links.iter().filter(|link| link.enabled) {
self.consider_link(start, end, link, false, &mut best);
if link.bidirectional {
self.consider_link(start, end, link, true, &mut best);
}
}
best.map(|candidate| candidate.path).ok_or_else(|| {
"no navigation path connects the requested endpoints; inspect islands or add a link"
.into()
})
} }
pub fn validate_links(&self) -> Vec<String> { pub fn validate_links(&self) -> Vec<String> {
let mut findings = Vec::new(); self.query.validate_links()
for link in self.artifact.links.iter().filter(|link| link.enabled) {
for (label, endpoint) in [("start", link.start), ("end", link.end)] {
let point = NavVec2::new(endpoint[0], endpoint[2]);
let Some(closest) = self.mesh.get_closest_point(point) else {
findings.push(format!(
"navigation link {} {label} endpoint is not near a walkable polygon",
link.actor_id
));
continue;
};
if closest.position().distance(point) > self.artifact.agent.radius * 2.0 {
findings.push(format!(
"navigation link {} {label} endpoint is farther than two agent radii from the mesh",
link.actor_id
));
}
}
}
findings
} }
fn consider_link(
&self,
start: Vec3,
end: Vec3,
link: &NavigationLinkInput,
reverse: bool,
best: &mut Option<Candidate>,
) {
let (entry, exit) = if reverse {
(vec3(link.end), vec3(link.start))
} else {
(vec3(link.start), vec3(link.end))
};
let (Ok(mut first), Ok(second)) =
(self.direct_path(start, entry), self.direct_path(exit, end))
else {
return;
};
append_distinct(&mut first.points, entry);
append_distinct(&mut first.points, exit);
for point in second.points.into_iter().skip(1) {
append_distinct(&mut first.points, point);
}
let link_length = entry.distance(exit);
first.length += link_length + second.length;
first.used_link_actor_id = Some(link.actor_id.clone());
let weighted_cost = first.length + link_length * (link.cost - 1.0);
if best
.as_ref()
.is_none_or(|candidate| weighted_cost < candidate.weighted_cost)
{
*best = Some(Candidate {
path: first,
weighted_cost,
});
}
}
fn direct_path(&self, start: Vec3, end: Vec3) -> Result<NavigationPath, String> {
let start2 = NavVec2::new(start.x, start.z);
let end2 = NavVec2::new(end.x, end.z);
let path = self.mesh.path(start2, end2).ok_or_else(|| {
"navigation endpoints are outside the same reachable mesh island".to_string()
})?;
let length = path.length;
let nav_start = NavVec3::new(start.x, start.y, start.z);
let nav_end = NavVec3::new(end.x, end.y, end.z);
let mut points = vec![start];
for point in path.path_with_height(nav_start, nav_end, &self.mesh) {
append_distinct(&mut points, Vec3::new(point.x, point.y, point.z));
}
append_distinct(&mut points, end);
Ok(NavigationPath {
points,
length,
used_link_actor_id: None,
})
}
}
struct Candidate {
path: NavigationPath,
weighted_cost: f32,
}
fn append_distinct(points: &mut Vec<Vec3>, point: Vec3) {
if points
.last()
.is_none_or(|last| last.distance_squared(point) > 1.0e-8)
{
points.push(point);
}
}
fn vec3(value: [f32; 3]) -> Vec3 {
Vec3::from_array(value)
} }
pub fn query_navigation_path( pub fn query_navigation_path(
@ -177,15 +68,85 @@ pub fn query_navigation_path(
NavigationRuntime::load(artifact_path)?.query(start, end) NavigationRuntime::load(artifact_path)?.query(start, end)
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavigationValidationSummary {
pub artifact_path: PathBuf,
pub enabled_link_count: usize,
pub enabled_sample_count: usize,
}
pub fn validate_navigation_artifact(
artifact_path: &Path,
) -> Result<NavigationValidationSummary, String> {
let runtime = NavigationQueryRuntime::load(artifact_path)?;
let mut findings = runtime.validate_links();
findings.extend(runtime.validate_samples());
if !findings.is_empty() {
return Err(findings.join("\n"));
}
Ok(NavigationValidationSummary {
artifact_path: artifact_path.to_path_buf(),
enabled_link_count: runtime
.artifact()
.links
.iter()
.filter(|link| link.enabled)
.count(),
enabled_sample_count: runtime
.artifact()
.samples
.iter()
.filter(|sample| sample.enabled)
.count(),
})
}
pub fn navigation_validation_argument(args: &[String]) -> Result<Option<PathBuf>, String> {
let Some(index) = args
.iter()
.position(|argument| argument == "--validate-navigation")
else {
return Ok(None);
};
if args.len() != 2 || index != 0 {
return Err(
"usage: game --validate-navigation <project-relative-or-absolute-artifact-path>".into(),
);
}
let path = args[1].trim();
if path.is_empty() {
return Err("navigation validation artifact path must not be empty".into());
}
Ok(Some(PathBuf::from(path)))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use scene::navigation::{ use scene::navigation::{
bake_navigation, NavigationBakeInput, NavigationLinkInput, NavigationVolumeInput, bake_navigation, NavigationBakeInput, NavigationGeometryInput, NavigationLinkInput,
NavigationVolumeInput,
}; };
use shared::NavigationAgentProfile; use shared::NavigationAgentProfile;
use super::*; use super::*;
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 input() -> NavigationBakeInput { fn input() -> NavigationBakeInput {
NavigationBakeInput { NavigationBakeInput {
source_scene: "assets/levels/navigation_showcase.scn.ron".into(), source_scene: "assets/levels/navigation_showcase.scn.ron".into(),
@ -197,7 +158,7 @@ mod tests {
merge_region_size: 2, merge_region_size: 2,
..Default::default() ..Default::default()
}, },
geometry: Vec::new(), geometry: vec![floor_geometry("floor", -6.0, 6.0, -6.0, 6.0, 0.0)],
obstacles: vec![NavigationVolumeInput { obstacles: vec![NavigationVolumeInput {
actor_id: "wall".into(), actor_id: "wall".into(),
center: [0.0, 0.5, 0.0], center: [0.0, 0.5, 0.0],
@ -205,6 +166,7 @@ mod tests {
}], }],
areas: Vec::new(), areas: Vec::new(),
links: Vec::new(), links: Vec::new(),
samples: Vec::new(),
} }
} }
@ -234,7 +196,65 @@ mod tests {
let path = runtime let path = runtime
.query(Vec3::new(-4.0, 0.0, 0.0), Vec3::new(4.0, 0.0, 0.0)) .query(Vec3::new(-4.0, 0.0, 0.0), Vec3::new(4.0, 0.0, 0.0))
.unwrap(); .unwrap();
assert_eq!(path.used_link_actor_id.as_deref(), Some("door-link")); assert_eq!(path.used_link_actor_id(), Some("door-link"));
assert_eq!(path.used_link_actor_ids, ["door-link"]);
}
#[test]
fn route_can_traverse_multiple_explicit_links() {
let mut input = input();
input.geometry = vec![
floor_geometry("island-a", -6.0, -3.0, -2.0, 2.0, 0.0),
floor_geometry("island-b", -1.5, 1.5, -2.0, 2.0, 0.0),
floor_geometry("island-c", 3.0, 6.0, -2.0, 2.0, 0.0),
];
input.obstacles.clear();
input.links = vec![
NavigationLinkInput {
actor_id: "link-a-b".into(),
start: [-3.6, 0.0, 0.0],
end: [-1.0, 0.0, 0.0],
bidirectional: true,
cost: 1.0,
enabled: true,
},
NavigationLinkInput {
actor_id: "link-b-c".into(),
start: [1.0, 0.0, 0.0],
end: [3.6, 0.0, 0.0],
bidirectional: true,
cost: 1.0,
enabled: true,
},
];
let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap();
let path = runtime
.query(Vec3::new(-5.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0))
.unwrap();
assert_eq!(path.used_link_actor_ids, ["link-a-b", "link-b-c"]);
assert!(path.length >= 10.0);
}
#[test]
fn query_selects_vertically_nearest_walkable_surface() {
let mut input = input();
input.center = [0.0, 2.0, 0.0];
input.half_extents = [6.0, 3.0, 6.0];
input.geometry = vec![
floor_geometry("lower-floor", -5.0, 5.0, -5.0, 5.0, 0.0),
floor_geometry("upper-floor", -5.0, 5.0, -5.0, 5.0, 4.0),
];
input.obstacles.clear();
let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap();
let lower = runtime
.query(Vec3::new(-3.0, 0.1, 0.0), Vec3::new(3.0, 0.1, 0.0))
.unwrap();
let upper = runtime
.query(Vec3::new(-3.0, 3.9, 0.0), Vec3::new(3.0, 3.9, 0.0))
.unwrap();
assert!(lower.points.iter().all(|point| point.y < 1.0));
assert!(upper.points.iter().all(|point| point.y > 3.0));
} }
#[test] #[test]
@ -254,4 +274,21 @@ mod tests {
.iter() .iter()
.any(|finding| finding.contains("bad-link start"))); .any(|finding| finding.contains("bad-link start")));
} }
#[test]
fn validation_cli_argument_is_strict_and_window_free() {
assert_eq!(
navigation_validation_argument(&[
"--validate-navigation".into(),
"assets/navigation/generated/main.nav.ron".into(),
])
.unwrap(),
Some(PathBuf::from("assets/navigation/generated/main.nav.ron"))
);
assert!(navigation_validation_argument(&["--validate-navigation".into()]).is_err());
assert_eq!(
navigation_validation_argument(&["--project".into(), ".".into()]).unwrap(),
None
);
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@ use shared::{
PostProcessVolumeDesc, PrefabInstance, PrefabRef, RendererMaterialSet, ShaderSchemaAsset, PostProcessVolumeDesc, PrefabInstance, PrefabRef, RendererMaterialSet, ShaderSchemaAsset,
SkinnedMeshRenderer, StaticMeshRenderer, ANIMATION_MANIFEST_SCHEMA_VERSION, SkinnedMeshRenderer, StaticMeshRenderer, ANIMATION_MANIFEST_SCHEMA_VERSION,
AUDIO_CLIP_SUB_ASSET_ID, COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_SKINNED_MESH_RENDERER, AUDIO_CLIP_SUB_ASSET_ID, COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_SKINNED_MESH_RENDERER,
NAVIGATION_GENERATED_ARTIFACT_DIRECTORY,
}; };
use crate::document::{SceneComponentBlob, SceneDocument}; use crate::document::{SceneComponentBlob, SceneDocument};
@ -113,6 +114,8 @@ pub fn validate_project(project_root: &Path) -> ProjectValidationReport {
); );
} }
validate_navigation_artifact_ownership(&mut report);
report.dependencies.sort_by(|left, right| { report.dependencies.sort_by(|left, right| {
( (
&left.owner_path, &left.owner_path,
@ -1456,7 +1459,11 @@ fn validate_navigation_document(
document: &SceneDocument, document: &SceneDocument,
report: &mut ProjectValidationReport, report: &mut ProjectValidationReport,
) { ) {
let jobs = match crate::navigation::navigation_bake_jobs_from_document(document, source_path) { let jobs = match crate::navigation::resolve_navigation_bake_jobs(
project_root,
&project_root.join(source_path),
Some(document.normalized_ron()),
) {
Ok(jobs) => jobs, Ok(jobs) => jobs,
Err(error) => { Err(error) => {
report.findings.push(ProjectValidationFinding { report.findings.push(ProjectValidationFinding {
@ -1473,19 +1480,66 @@ fn validate_navigation_document(
}; };
for job in jobs { for job in jobs {
let normalized_artifact_path = match shared::navigation_generated_artifact_path(
&job.artifact_path,
) {
Ok(path) => path,
Err(error) => {
push_navigation_finding(
report,
source_path,
&job.input.bounds_actor_id,
"navigation.artifact_outside_generated_directory",
&job.artifact_path,
error,
"Assign a project-relative artifact path under assets/navigation/generated/ and rebake this bounds actor.",
);
continue;
}
};
report.dependencies.push(ProjectDependency { report.dependencies.push(ProjectDependency {
owner_path: source_path.to_string(), owner_path: source_path.to_string(),
owner_actor_id: Some(job.input.bounds_actor_id.clone()), owner_actor_id: Some(job.input.bounds_actor_id.clone()),
kind: "navigation_artifact".into(), kind: "navigation_artifact".into(),
reference: job.artifact_path.clone(), reference: normalized_artifact_path.clone(),
}); });
let Some(path) = resolve_reference(project_root, &job.artifact_path) else { let fresh_artifact = match crate::navigation::bake_navigation(&job.input) {
Ok(artifact) => artifact,
Err(error) => {
push_navigation_finding(
report,
source_path,
&job.input.bounds_actor_id,
"navigation.bake_failed",
&normalized_artifact_path,
&format!("deterministic navigation bake failed: {error}"),
"Repair the bounds, agent profile, or source geometry named by the bake error, then rebake this actor.",
);
continue;
}
};
if fresh_artifact.diagnostics.effective_component_count > 1 {
report.findings.push(ProjectValidationFinding {
severity: ValidationSeverity::Warning,
code: "navigation.isolated_regions".into(),
source_path: source_path.into(),
owner_actor_id: Some(job.input.bounds_actor_id.clone()),
reference: Some(normalized_artifact_path.clone()),
message: format!(
"navigation bake has {} raw walkable islands and {} disconnected traversable components after valid links",
fresh_artifact.diagnostics.island_count,
fresh_artifact.diagnostics.effective_component_count
),
repair: "Add explicit Navigation Links between regions that agents must traverse, or confirm the isolation is intentional.".into(),
});
}
let Some(path) = resolve_reference(project_root, &normalized_artifact_path) else {
push_navigation_finding( push_navigation_finding(
report, report,
source_path, source_path,
&job.input.bounds_actor_id, &job.input.bounds_actor_id,
"navigation.artifact_unsafe_path", "navigation.artifact_unsafe_path",
&job.artifact_path, &normalized_artifact_path,
"navigation artifact path escapes the project", "navigation artifact path escapes the project",
"Choose an artifact path under assets/navigation/generated/.", "Choose an artifact path under assets/navigation/generated/.",
); );
@ -1497,7 +1551,7 @@ fn validate_navigation_document(
source_path, source_path,
&job.input.bounds_actor_id, &job.input.bounds_actor_id,
"navigation.artifact_missing", "navigation.artifact_missing",
&job.artifact_path, &normalized_artifact_path,
"navigation bounds has no baked artifact", "navigation bounds has no baked artifact",
"Select the bounds actor and run Scene > Navigation > Bake Selected Bounds.", "Select the bounds actor and run Scene > Navigation > Bake Selected Bounds.",
); );
@ -1511,47 +1565,141 @@ fn validate_navigation_document(
source_path, source_path,
&job.input.bounds_actor_id, &job.input.bounds_actor_id,
"navigation.artifact_invalid", "navigation.artifact_invalid",
&job.artifact_path, &normalized_artifact_path,
&error, &error,
"Delete the invalid generated artifact and rebake the bounds actor.", "Delete the invalid generated artifact and rebake the bounds actor.",
); );
continue; continue;
} }
}; };
if artifact.is_stale_for(&job.input) { let stale = artifact.is_stale_for(&job.input);
if stale {
push_navigation_finding( push_navigation_finding(
report, report,
source_path, source_path,
&job.input.bounds_actor_id, &job.input.bounds_actor_id,
"navigation.artifact_stale", "navigation.artifact_stale",
&job.artifact_path, &normalized_artifact_path,
"navigation artifact fingerprint does not match authored navigation data", "navigation artifact fingerprint does not match authored navigation data",
"Rebake navigation after the authored geometry or agent-profile change.", "Rebake navigation after the authored geometry or agent-profile change.",
); );
} } else {
let stored_hash = crate::navigation::navigation_artifact_content_hash(&artifact);
let mesh = artifact.runtime_mesh(); let fresh_hash = crate::navigation::navigation_artifact_content_hash(&fresh_artifact);
for link in artifact.links.iter().filter(|link| link.enabled) { match (stored_hash, fresh_hash) {
for (label, endpoint) in [("start", link.start), ("end", link.end)] { (Ok(stored_hash), Ok(fresh_hash)) if stored_hash != fresh_hash => {
let point = nav_glam::Vec2::new(endpoint[0], endpoint[2]);
let valid = mesh.get_closest_point(point).is_some_and(|closest| {
closest.position().distance(point) <= artifact.agent.radius * 2.0
});
if !valid {
push_navigation_finding( push_navigation_finding(
report, report,
source_path, source_path,
&link.actor_id, &job.input.bounds_actor_id,
"navigation.link_unreachable", "navigation.artifact_content_mismatch",
&job.artifact_path, &normalized_artifact_path,
&format!( "stored navigation artifact differs from a deterministic fresh bake despite a current source fingerprint",
"navigation link {label} endpoint is farther than two agent radii from the baked mesh" "Rebake this bounds actor and commit the regenerated artifact.",
),
"Move the endpoint onto a visible navigation polygon and rebake.",
); );
} }
(Err(error), _) | (_, Err(error)) => push_navigation_finding(
report,
source_path,
&job.input.bounds_actor_id,
"navigation.artifact_hash_failed",
&normalized_artifact_path,
&error,
"Rebake this bounds actor; if hashing still fails, repair the generated artifact serializer.",
),
_ => {}
} }
} }
let runtime = match crate::navigation::NavigationQueryRuntime::from_artifact(
fresh_artifact.clone(),
) {
Ok(runtime) => runtime,
Err(error) => {
push_navigation_finding(
report,
source_path,
&job.input.bounds_actor_id,
"navigation.runtime_invalid",
&normalized_artifact_path,
&error,
"Repair the bake inputs and rebake this bounds actor.",
);
continue;
}
};
for finding in runtime.validate_link_findings() {
push_navigation_finding(
report,
source_path,
&finding.actor_id,
"navigation.link_unreachable",
&normalized_artifact_path,
&finding.message,
"Move the endpoint onto a visible navigation polygon and rebake.",
);
}
for sample in fresh_artifact
.samples
.iter()
.filter(|sample| sample.enabled)
{
if let Err(error) = runtime.query(sample.start, sample.end) {
push_navigation_finding(
report,
source_path,
&job.input.bounds_actor_id,
"navigation.sample_unreachable",
&sample.id,
&format!("navigation validation sample `{}` failed: {error}", sample.id),
"Move the sample endpoints onto connected visible navigation polygons, add the required links, and rebake.",
);
}
}
}
}
fn validate_navigation_artifact_ownership(report: &mut ProjectValidationReport) {
let mut owners_by_artifact = BTreeMap::<String, Vec<(String, String)>>::new();
for dependency in report
.dependencies
.iter()
.filter(|dependency| dependency.kind == "navigation_artifact")
{
let Some(actor_id) = dependency.owner_actor_id.as_ref() else {
continue;
};
owners_by_artifact
.entry(dependency.reference.clone())
.or_default()
.push((dependency.owner_path.clone(), actor_id.clone()));
}
for (artifact_path, owners) in owners_by_artifact {
let owners = owners.into_iter().collect::<BTreeSet<_>>();
if owners.len() < 2 {
continue;
}
let owner_list = owners
.iter()
.map(|(source_path, actor_id)| format!("actor `{actor_id}` in {source_path}"))
.collect::<Vec<_>>()
.join(", ");
for (source_path, actor_id) in owners {
push_navigation_finding(
report,
&source_path,
&actor_id,
"navigation.artifact_duplicate_owner",
&artifact_path,
&format!(
"navigation artifact is claimed by multiple bounds actors: {owner_list}"
),
&format!(
"Keep one owner; give every other bounds actor a unique path under {NAVIGATION_GENERATED_ARTIFACT_DIRECTORY} (or remove and re-add Navigation Bounds), then rebake all affected actors."
),
);
}
} }
} }
@ -3010,7 +3158,7 @@ mod tests {
bounds.artifact_path = "assets/navigation/generated/main_humanoid.nav.ron".to_string(); bounds.artifact_path = "assets/navigation/generated/main_humanoid.nav.ron".to_string();
let bounds_ron = ron::to_string(&bounds).unwrap(); let bounds_ron = ron::to_string(&bounds).unwrap();
let scene = format!( let scene = format!(
"(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":(\"nav-bounds\"),\"shared::navigation::NavigationBounds\":{bounds_ron},}})}})" "(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":(\"nav-bounds\"),\"shared::navigation::NavigationBounds\":{bounds_ron},}}),2:(components:{{\"shared::components::ActorId\":(\"nav-floor\"),\"shared::components::Primitive\":(shape:Box,size:(10.0,0.2,10.0)),}})}})"
); );
std::fs::write(root.join("assets/levels/main.scn.ron"), &scene).unwrap(); std::fs::write(root.join("assets/levels/main.scn.ron"), &scene).unwrap();
if write_artifact { if write_artifact {
@ -3028,6 +3176,42 @@ mod tests {
} }
} }
fn navigation_scene_with_artifact_path(
bounds_actor_id: &str,
artifact_path: &str,
include_floor: bool,
link_height: Option<f32>,
) -> String {
let mut bounds = shared::NavigationBounds::for_actor(bounds_actor_id);
bounds.half_extents = [5.0, link_height.unwrap_or(0.0).abs() + 2.0, 5.0].into();
bounds.agent.min_region_size = 1;
bounds.agent.merge_region_size = 2;
bounds.artifact_path = artifact_path.to_string();
let bounds = ron::to_string(&bounds).unwrap();
let mut entities = format!(
"1:(components:{{\"shared::components::ActorId\":({bounds_actor_id:?}),\"shared::navigation::NavigationBounds\":{bounds},}})"
);
if include_floor {
entities.push_str(&format!(
",2:(components:{{\"shared::components::ActorId\":({:?}),\"shared::components::Primitive\":(shape:Box,size:(10.0,0.2,10.0)),}})",
format!("{bounds_actor_id}-floor")
));
}
if let Some(height) = link_height {
let link = shared::NavigationLink {
start: [-1.0, height, 0.0].into(),
end: [1.0, height, 0.0].into(),
..Default::default()
};
let link = ron::to_string(&link).unwrap();
entities.push_str(&format!(
",3:(components:{{\"shared::components::ActorId\":({:?}),\"shared::navigation::NavigationLink\":{link},}})",
format!("{bounds_actor_id}-link")
));
}
format!("(schema_version:2,resources:{{}},entities:{{{entities}}})")
}
#[test] #[test]
fn missing_navigation_artifact_blocks_release_with_bounds_owner() { fn missing_navigation_artifact_blocks_release_with_bounds_owner() {
let root = fixture_root(); let root = fixture_root();
@ -3057,6 +3241,208 @@ mod tests {
.any(|finding| finding.code == "navigation.artifact_stale")); .any(|finding| finding.code == "navigation.artifact_stale"));
} }
#[test]
fn navigation_artifacts_must_stay_in_generated_directory() {
let root = fixture_root();
let scene = navigation_scene_with_artifact_path(
"outside-bounds",
"assets/navigation/manual.nav.ron",
true,
None,
);
std::fs::write(root.join("assets/levels/main.scn.ron"), scene).unwrap();
let report = validate_project(&root);
let finding = report
.findings
.iter()
.find(|finding| finding.code == "navigation.artifact_outside_generated_directory")
.expect("outside artifact path should be rejected");
assert_eq!(finding.owner_actor_id.as_deref(), Some("outside-bounds"));
assert!(finding.repair.contains("assets/navigation/generated/"));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn duplicate_navigation_artifact_owners_name_every_bounds_actor() {
let root = fixture_root();
let artifact_path = "assets/navigation/generated/shared.nav.ron";
std::fs::write(
root.join("assets/levels/main.scn.ron"),
navigation_scene_with_artifact_path("first-bounds", artifact_path, true, None),
)
.unwrap();
std::fs::write(
root.join("assets/levels/secondary.scn.ron"),
navigation_scene_with_artifact_path("second-bounds", artifact_path, true, None),
)
.unwrap();
let report = validate_project(&root);
let findings = report
.findings
.iter()
.filter(|finding| finding.code == "navigation.artifact_duplicate_owner")
.collect::<Vec<_>>();
assert_eq!(findings.len(), 2, "{:?}", report.findings);
assert!(findings.iter().any(|finding| {
finding.owner_actor_id.as_deref() == Some("first-bounds")
&& finding.message.contains("second-bounds")
}));
assert!(findings.iter().all(|finding| {
finding.repair.contains("unique path") && finding.repair.contains("rebake")
}));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn deterministic_bake_failures_are_attributed_to_bounds_actor() {
let root = fixture_root();
let scene = navigation_scene_with_artifact_path(
"empty-bounds",
"assets/navigation/generated/empty.nav.ron",
false,
None,
);
std::fs::write(root.join("assets/levels/main.scn.ron"), scene).unwrap();
let report = validate_project(&root);
let finding = report
.findings
.iter()
.find(|finding| finding.code == "navigation.bake_failed")
.expect("empty bounds should fail a fresh validation bake");
assert_eq!(finding.owner_actor_id.as_deref(), Some("empty-bounds"));
assert!(finding.message.contains("no primitive or additive-brush"));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn link_validation_uses_endpoint_height_not_only_xz() {
let root = fixture_root();
let artifact_path = "assets/navigation/generated/high-link.nav.ron";
let scene = navigation_scene_with_artifact_path(
"high-link-bounds",
artifact_path,
true,
Some(10.0),
);
std::fs::write(root.join("assets/levels/main.scn.ron"), &scene).unwrap();
let document = SceneDocument::from_ron_text(&scene).unwrap();
let job = crate::navigation::navigation_bake_jobs_from_document(
&document,
"assets/levels/main.scn.ron",
)
.unwrap()
.pop()
.unwrap();
let artifact = crate::navigation::bake_navigation(&job.input).unwrap();
crate::navigation::write_navigation_artifact(&root.join(artifact_path), &artifact).unwrap();
let report = validate_project(&root);
let finding = report
.findings
.iter()
.find(|finding| finding.code == "navigation.link_unreachable")
.expect("vertically distant link should be rejected");
assert_eq!(
finding.owner_actor_id.as_deref(),
Some("high-link-bounds-link")
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn unreachable_navigation_sample_blocks_release_with_bounds_owner() {
let root = fixture_root();
let artifact_path = "assets/navigation/generated/sample.nav.ron";
let mut bounds = shared::NavigationBounds::for_actor("sample-bounds");
bounds.half_extents = [5.0, 2.0, 5.0].into();
bounds.agent.min_region_size = 1;
bounds.agent.merge_region_size = 2;
bounds.artifact_path = artifact_path.into();
bounds
.validation_samples
.push(shared::NavigationPathSample {
id: "outside-mesh".into(),
start: [100.0, 0.0, 100.0].into(),
end: [101.0, 0.0, 100.0].into(),
enabled: true,
});
let bounds = ron::to_string(&bounds).unwrap();
let scene = format!(
r#"(schema_version:2,resources:{{}},entities:{{
1:(components:{{"shared::components::ActorId":("sample-bounds"),"shared::navigation::NavigationBounds":{bounds},}}),
2:(components:{{"shared::components::ActorId":("sample-floor"),"shared::components::Primitive":(shape:Box,size:(10.0,0.2,10.0)),}}),
}})"#
);
std::fs::write(root.join("assets/levels/main.scn.ron"), &scene).unwrap();
let document = SceneDocument::from_ron_text(&scene).unwrap();
let job = crate::navigation::navigation_bake_jobs_from_document(
&document,
"assets/levels/main.scn.ron",
)
.unwrap()
.pop()
.unwrap();
let artifact = crate::navigation::bake_navigation(&job.input).unwrap();
crate::navigation::write_navigation_artifact(&root.join(artifact_path), &artifact).unwrap();
let report = validate_project(&root);
let finding = report
.findings
.iter()
.find(|finding| finding.code == "navigation.sample_unreachable")
.expect("unreachable sample should block release");
assert_eq!(finding.owner_actor_id.as_deref(), Some("sample-bounds"));
assert_eq!(finding.reference.as_deref(), Some("outside-mesh"));
assert_eq!(finding.severity, ValidationSeverity::Error);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn disconnected_navigation_islands_emit_owner_warning() {
let root = fixture_root();
let artifact_path = "assets/navigation/generated/islands.nav.ron";
let mut bounds = shared::NavigationBounds::for_actor("island-bounds");
bounds.half_extents = [6.0, 2.0, 5.0].into();
bounds.agent.min_region_size = 1;
bounds.agent.merge_region_size = 2;
bounds.artifact_path = artifact_path.into();
let bounds = ron::to_string(&bounds).unwrap();
let scene = format!(
r#"(schema_version:2,resources:{{}},entities:{{
1:(components:{{"shared::components::ActorId":("island-bounds"),"shared::navigation::NavigationBounds":{bounds},}}),
2:(components:{{"shared::components::ActorId":("left-floor"),"bevy_transform::components::transform::Transform":(translation:(-3.5,0.0,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,8.0)),}}),
3:(components:{{"shared::components::ActorId":("right-floor"),"bevy_transform::components::transform::Transform":(translation:(3.5,0.0,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,8.0)),}}),
}})"#
);
std::fs::write(root.join("assets/levels/main.scn.ron"), &scene).unwrap();
let document = SceneDocument::from_ron_text(&scene).unwrap();
let job = crate::navigation::navigation_bake_jobs_from_document(
&document,
"assets/levels/main.scn.ron",
)
.unwrap()
.pop()
.unwrap();
let artifact = crate::navigation::bake_navigation(&job.input).unwrap();
assert!(artifact.diagnostics.effective_component_count > 1);
crate::navigation::write_navigation_artifact(&root.join(artifact_path), &artifact).unwrap();
let report = validate_project(&root);
let finding = report
.findings
.iter()
.find(|finding| finding.code == "navigation.isolated_regions")
.expect("multiple islands should produce an authoring warning");
assert_eq!(finding.severity, ValidationSeverity::Warning);
assert_eq!(finding.owner_actor_id.as_deref(), Some("island-bounds"));
assert!(finding.message.contains("2 disconnected"));
assert!(finding.repair.contains("Navigation Links"));
std::fs::remove_dir_all(root).unwrap();
}
#[test] #[test]
fn valid_animation_controller_collects_stable_artifact_dependencies() { fn valid_animation_controller_collects_stable_artifact_dependencies() {
let root = fixture_root(); let root = fixture_root();

View File

@ -8,6 +8,7 @@ description = "Shared reflectable authoring types and scene hydration for the FP
[dependencies] [dependencies]
avian3d.workspace = true avian3d.workspace = true
bevy.workspace = true bevy.workspace = true
blake3 = "1"
ron = "0.8" ron = "0.8"
serde.workspace = true serde.workspace = true
settings.workspace = true settings.workspace = true

View File

@ -321,10 +321,26 @@ fn validate_navigation_actor(entity: EntityRef<'_>) -> Result<(), ActorValidatio
.agent .agent
.validate() .validate()
.map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?; .map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?;
if bounds.artifact_path.trim().is_empty() { crate::navigation_generated_artifact_path(&bounds.artifact_path)
return Err(ActorValidationError::InvalidNavigation( .map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?;
"navigation artifact path must not be empty".into(), let mut sample_ids = HashSet::new();
)); for sample in &bounds.validation_samples {
let sample_id = sample.id.trim();
if sample_id.is_empty() {
return Err(ActorValidationError::InvalidNavigation(
"navigation validation sample ID must not be empty".into(),
));
}
if !sample_ids.insert(sample_id) {
return Err(ActorValidationError::InvalidNavigation(format!(
"navigation validation sample ID `{sample_id}` must be unique within its bounds"
)));
}
if !sample.start.is_finite() || !sample.end.is_finite() || sample.start == sample.end {
return Err(ActorValidationError::InvalidNavigation(format!(
"navigation validation sample `{sample_id}` endpoints must be finite and distinct"
)));
}
} }
} }
if let Some(obstacle) = entity.get::<NavigationObstacle>() { if let Some(obstacle) = entity.get::<NavigationObstacle>() {
@ -832,4 +848,37 @@ mod tests {
Err(ActorValidationError::AnimationControllerUnknownDefaultState) Err(ActorValidationError::AnimationControllerUnknownDefaultState)
); );
} }
#[test]
fn navigation_bounds_validate_generated_path_and_unique_samples() {
let mut world = World::new();
let mut bounds = NavigationBounds::for_actor("bounds-main");
bounds.validation_samples.push(crate::NavigationPathSample {
id: "entry-to-exit".into(),
start: Vec3::new(-2.0, 0.0, 0.0),
end: Vec3::new(2.0, 0.0, 0.0),
enabled: true,
});
let entity = level_entity(&mut world, (ActorKind::Navigation, bounds.clone()));
assert!(validate_actor(world.entity(entity)).is_ok());
bounds
.validation_samples
.push(bounds.validation_samples[0].clone());
world.entity_mut(entity).insert(bounds.clone());
assert!(matches!(
validate_actor(world.entity(entity)),
Err(ActorValidationError::InvalidNavigation(message))
if message.contains("must be unique")
));
bounds.validation_samples.pop();
bounds.artifact_path = "assets/navigation/manual.nav.ron".into();
world.entity_mut(entity).insert(bounds);
assert!(matches!(
validate_actor(world.entity(entity)),
Err(ActorValidationError::InvalidNavigation(message))
if message.contains("assets/navigation/generated/")
));
}
} }

View File

@ -615,8 +615,8 @@ pub enum PrimitiveShape {
} }
/// Reflectable authoring primitive. Hydration turns this into `Mesh3d`. /// Reflectable authoring primitive. Hydration turns this into `Mesh3d`.
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)] #[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)] #[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct Primitive { pub struct Primitive {
pub shape: PrimitiveShape, pub shape: PrimitiveShape,
pub size: Vec3, pub size: Vec3,

View File

@ -147,23 +147,20 @@ pub fn flush_level_object_hydration(world: &mut World) {
Option<&InspectorOrder>, Option<&InspectorOrder>,
), With<LevelObject>>() ), With<LevelObject>>()
.iter(world) .iter(world)
.filter_map(|(entity, brush, material, collider, states, order)| { .filter(|(_, _, _, _, states, order)| {
authoring_component_active(states, order, COMPONENT_BRUSH_DESC).then(|| { authoring_component_active(*states, *order, COMPONENT_BRUSH_DESC)
( })
entity, .map(|(entity, brush, material, collider, states, order)| {
brush.clone(), (
material entity,
.filter(|_| { brush.clone(),
authoring_component_active(states, order, COMPONENT_MATERIAL_DESC) material
}) .filter(|_| authoring_component_active(states, order, COMPONENT_MATERIAL_DESC))
.cloned(), .cloned(),
collider collider
.filter(|_| { .filter(|_| authoring_component_active(states, order, COMPONENT_COLLIDER_DESC))
authoring_component_active(states, order, COMPONENT_COLLIDER_DESC) .cloned(),
}) )
.cloned(),
)
})
}) })
.collect(); .collect();
@ -190,34 +187,25 @@ pub fn flush_level_object_hydration(world: &mut World) {
Option<&InspectorOrder>, Option<&InspectorOrder>,
), With<LevelObject>>() ), With<LevelObject>>()
.iter(world) .iter(world)
.filter_map( .filter(|(_, _, _, _, _, states, order)| {
authoring_component_active(*states, *order, COMPONENT_STATIC_MESH_RENDERER)
})
.map(
|(entity, renderer, material, material_override, collider, states, order)| { |(entity, renderer, material, material_override, collider, states, order)| {
authoring_component_active(states, order, COMPONENT_STATIC_MESH_RENDERER).then( (
|| { entity,
( renderer.clone(),
entity, material
renderer.clone(), .filter(|_| {
material authoring_component_active(states, order, COMPONENT_MATERIAL_DESC)
.filter(|_| { })
authoring_component_active( .cloned(),
states, material_override.cloned(),
order, collider
COMPONENT_MATERIAL_DESC, .filter(|_| {
) authoring_component_active(states, order, COMPONENT_COLLIDER_DESC)
}) })
.cloned(), .cloned(),
material_override.cloned(),
collider
.filter(|_| {
authoring_component_active(
states,
order,
COMPONENT_COLLIDER_DESC,
)
})
.cloned(),
)
},
) )
}, },
) )

View File

@ -361,6 +361,7 @@ pub fn despawn_static_mesh_parts(
} }
} }
#[allow(clippy::too_many_arguments)]
fn material_for_entry( fn material_for_entry(
asset_server: &AssetServer, asset_server: &AssetServer,
materials: &mut Assets<StandardMaterial>, materials: &mut Assets<StandardMaterial>,

View File

@ -32,9 +32,10 @@ pub use material_asset::{
pub use navigation::*; pub use navigation::*;
pub use post_process_effect_asset::{PostProcessEffectAsset, PostProcessEffectKind}; pub use post_process_effect_asset::{PostProcessEffectAsset, PostProcessEffectKind};
pub use prefab_overrides::{ pub use prefab_overrides::{
apply_prefab_overrides_on_ready, decode_prefab_overrides, encode_prefab_overrides, apply_prefab_overrides_on_ready, apply_serialized_navigation_property_override,
PrefabActorPath, PrefabComponentOverride, PrefabOverrides, PrefabPropertyOverride, decode_prefab_overrides, encode_prefab_overrides, PrefabActorPath, PrefabComponentOverride,
PrefabStructuralOverride, PREFAB_OVERRIDE_FORMAT_VERSION, PrefabOverrides, PrefabPropertyOverride, PrefabStructuralOverride,
PREFAB_OVERRIDE_FORMAT_VERSION,
}; };
pub use renderer_material::*; pub use renderer_material::*;
pub use rendering_profile_asset::RenderingProfileAsset; pub use rendering_profile_asset::RenderingProfileAsset;
@ -94,6 +95,7 @@ impl Plugin for SharedTypesPlugin {
.register_type::<AnimationStateDesc>() .register_type::<AnimationStateDesc>()
.register_type::<AnimationControllerDesc>() .register_type::<AnimationControllerDesc>()
.register_type::<NavigationAgentProfile>() .register_type::<NavigationAgentProfile>()
.register_type::<NavigationPathSample>()
.register_type::<NavigationBounds>() .register_type::<NavigationBounds>()
.register_type::<NavigationObstacle>() .register_type::<NavigationObstacle>()
.register_type::<NavigationArea>() .register_type::<NavigationArea>()
@ -149,6 +151,7 @@ impl Plugin for SharedTypesPlugin {
} }
} }
#[allow(clippy::type_complexity)]
fn migrate_legacy_authoring_component_states( fn migrate_legacy_authoring_component_states(
mut commands: Commands, mut commands: Commands,
mut legacy: Query< mut legacy: Query<

View File

@ -191,6 +191,15 @@ pub struct ShaderSchemaAsset {
pub default_textures: Vec<MaterialTextureBinding>, pub default_textures: Vec<MaterialTextureBinding>,
} }
impl ShaderSchemaAsset {
pub fn load_from_path(catalog_path: &str) -> Result<Self, String> {
let text = std::fs::read_to_string(catalog_path)
.map_err(|err| format!("could not read {catalog_path}: {err}"))?;
ron::from_str(&text)
.map_err(|err| format!("invalid shader schema RON in {catalog_path}: {err}"))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -262,12 +271,3 @@ mod tests {
.any(|value| value.name == "edge_width")); .any(|value| value.name == "edge_width"));
} }
} }
impl ShaderSchemaAsset {
pub fn load_from_path(catalog_path: &str) -> Result<Self, String> {
let text = std::fs::read_to_string(catalog_path)
.map_err(|err| format!("could not read {catalog_path}: {err}"))?;
ron::from_str(&text)
.map_err(|err| format!("invalid shader schema RON in {catalog_path}: {err}"))
}
}

View File

@ -1,6 +1,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const NAVIGATION_GENERATED_ARTIFACT_DIRECTORY: &str = "assets/navigation/generated/";
pub const COMPONENT_NAVIGATION_BOUNDS: &str = "shared::navigation::NavigationBounds"; pub const COMPONENT_NAVIGATION_BOUNDS: &str = "shared::navigation::NavigationBounds";
pub const COMPONENT_NAVIGATION_OBSTACLE: &str = "shared::navigation::NavigationObstacle"; pub const COMPONENT_NAVIGATION_OBSTACLE: &str = "shared::navigation::NavigationObstacle";
pub const COMPONENT_NAVIGATION_AREA: &str = "shared::navigation::NavigationArea"; pub const COMPONENT_NAVIGATION_AREA: &str = "shared::navigation::NavigationArea";
@ -54,11 +55,24 @@ impl NavigationAgentProfile {
if !self.max_slope_deg.is_finite() || !(0.0..90.0).contains(&self.max_slope_deg) { if !self.max_slope_deg.is_finite() || !(0.0..90.0).contains(&self.max_slope_deg) {
return Err("maximum slope must be finite and in [0, 90) degrees"); return Err("maximum slope must be finite and in [0, 90) degrees");
} }
if !self.cell_size_fraction.is_finite() || self.cell_size_fraction <= 0.0 { if !self.cell_size_fraction.is_finite() || !(1.0..=32.0).contains(&self.cell_size_fraction)
return Err("cell size fraction must be finite and greater than zero"); {
return Err("cell size fraction must be finite and in [1, 32]");
} }
if !self.cell_height_fraction.is_finite() || self.cell_height_fraction <= 0.0 { if !self.cell_height_fraction.is_finite()
return Err("cell height fraction must be finite and greater than zero"); || !(1.0..=64.0).contains(&self.cell_height_fraction)
{
return Err("cell height fraction must be finite and in [1, 64]");
}
let cell_height = self.radius / self.cell_height_fraction;
if (self.height / cell_height).ceil() > f32::from(u16::MAX) {
return Err("agent height requires more than 65535 vertical voxels");
}
if (self.max_climb / cell_height).floor() > f32::from(u16::MAX) {
return Err("maximum climb requires more than 65535 vertical voxels");
}
if self.min_region_size > 255 || self.merge_region_size > 255 {
return Err("navigation region sizes must not exceed 255 voxels");
} }
Ok(()) Ok(())
} }
@ -73,6 +87,8 @@ pub struct NavigationBounds {
pub artifact_path: String, pub artifact_path: String,
#[serde(default)] #[serde(default)]
pub auto_bake: bool, pub auto_bake: bool,
#[serde(default)]
pub validation_samples: Vec<NavigationPathSample>,
} }
impl Default for NavigationBounds { impl Default for NavigationBounds {
@ -82,10 +98,111 @@ impl Default for NavigationBounds {
agent: NavigationAgentProfile::default(), agent: NavigationAgentProfile::default(),
artifact_path: "assets/navigation/generated/main_humanoid.nav.ron".into(), artifact_path: "assets/navigation/generated/main_humanoid.nav.ron".into(),
auto_bake: false, auto_bake: false,
validation_samples: Vec::new(),
} }
} }
} }
/// Named local-space start/end pair persisted for bake and packaged-runtime validation.
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct NavigationPathSample {
pub id: String,
pub start: Vec3,
pub end: Vec3,
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for NavigationPathSample {
fn default() -> Self {
Self {
id: "sample".into(),
start: Vec3::new(-4.0, 0.0, 0.0),
end: Vec3::new(4.0, 0.0, 0.0),
enabled: true,
}
}
}
impl NavigationBounds {
/// Creates bounds whose generated output is owned by one stable actor.
pub fn for_actor(actor_id: &str) -> Self {
Self {
artifact_path: navigation_artifact_path_for_actor(actor_id),
..Self::default()
}
}
}
/// Returns a readable, collision-resistant artifact path derived from a persisted actor ID.
pub fn navigation_artifact_path_for_actor(actor_id: &str) -> String {
let mut slug = String::with_capacity(32);
let mut last_was_separator = false;
for character in actor_id.trim().chars() {
if slug.len() >= 32 {
break;
}
let character = character.to_ascii_lowercase();
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
slug.push(character);
last_was_separator = false;
} else if !slug.is_empty() && !last_was_separator {
slug.push('-');
last_was_separator = true;
}
}
while slug.ends_with('-') {
slug.pop();
}
if slug.is_empty() {
slug.push_str("actor");
}
let digest = blake3::hash(actor_id.as_bytes()).to_hex();
format!(
"{NAVIGATION_GENERATED_ARTIFACT_DIRECTORY}{slug}-{}.nav.ron",
&digest[..24]
)
}
/// Normalizes and validates a project-relative generated navigation artifact path.
pub fn navigation_generated_artifact_path(path: &str) -> Result<String, &'static str> {
let normalized = path.replace('\\', "/");
let relative = std::path::Path::new(&normalized);
if normalized.trim().is_empty()
|| relative.is_absolute()
|| relative.components().any(|component| {
matches!(
component,
std::path::Component::CurDir
| std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
{
return Err("navigation artifacts must be project-relative files under assets/navigation/generated/");
}
let canonical = relative
.components()
.filter_map(|component| match component {
std::path::Component::Normal(component) => component.to_str(),
_ => None,
})
.collect::<Vec<_>>()
.join("/");
if !canonical.starts_with(NAVIGATION_GENERATED_ARTIFACT_DIRECTORY)
|| canonical == NAVIGATION_GENERATED_ARTIFACT_DIRECTORY.trim_end_matches('/')
|| !canonical.ends_with(".nav.ron")
{
return Err(
"navigation artifacts must be .nav.ron files under assets/navigation/generated/",
);
}
Ok(canonical)
}
/// Axis-aligned volume carved from every overlapping navigation bake. /// Axis-aligned volume carved from every overlapping navigation bake.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)] #[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
@ -162,7 +279,10 @@ fn default_one() -> f32 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::NavigationAgentProfile; use super::{
navigation_artifact_path_for_actor, navigation_generated_artifact_path,
NavigationAgentProfile, NAVIGATION_GENERATED_ARTIFACT_DIRECTORY,
};
#[test] #[test]
fn default_agent_profile_is_valid() { fn default_agent_profile_is_valid() {
@ -178,4 +298,59 @@ mod tests {
Err("agent height must be finite and greater than twice its radius") Err("agent height must be finite and greater than twice its radius")
); );
} }
#[test]
fn pathological_voxel_settings_are_rejected() {
let profile = NavigationAgentProfile {
cell_size_fraction: 1_000_000.0,
..Default::default()
};
assert_eq!(
profile.validate(),
Err("cell size fraction must be finite and in [1, 32]")
);
let profile = NavigationAgentProfile {
merge_region_size: 256,
..Default::default()
};
assert_eq!(
profile.validate(),
Err("navigation region sizes must not exceed 255 voxels")
);
}
#[test]
fn generated_artifact_paths_are_stable_and_actor_specific() {
let first = navigation_artifact_path_for_actor("Bounds / Main");
assert_eq!(first, navigation_artifact_path_for_actor("Bounds / Main"));
assert_ne!(first, navigation_artifact_path_for_actor("Bounds / Other"));
assert!(first.starts_with(NAVIGATION_GENERATED_ARTIFACT_DIRECTORY));
assert!(first.ends_with(".nav.ron"));
}
#[test]
fn generated_artifact_policy_rejects_escapes_and_other_directories() {
assert_eq!(
navigation_generated_artifact_path("assets\\navigation\\generated\\bounds.nav.ron"),
Ok("assets/navigation/generated/bounds.nav.ron".into())
);
assert_eq!(
navigation_generated_artifact_path(
"assets/navigation/generated//nested///bounds.nav.ron"
),
Ok("assets/navigation/generated/nested/bounds.nav.ron".into())
);
assert!(navigation_generated_artifact_path("assets/navigation/bounds.nav.ron").is_err());
assert!(
navigation_generated_artifact_path("assets/navigation/generated/bounds.ron").is_err()
);
assert!(navigation_generated_artifact_path(
"assets/navigation/generated/../bounds.nav.ron"
)
.is_err());
assert!(
navigation_generated_artifact_path(NAVIGATION_GENERATED_ARTIFACT_DIRECTORY).is_err()
);
}
} }

View File

@ -7,7 +7,7 @@ use bevy::prelude::*;
use bevy::reflect::serde::TypedReflectDeserializer; use bevy::reflect::serde::TypedReflectDeserializer;
use bevy::reflect::{GetPath, ParsedPath, ReflectPath}; use bevy::reflect::{GetPath, ParsedPath, ReflectPath};
use bevy::world_serialization::{WorldInstanceReady, WorldInstanceSpawner}; use bevy::world_serialization::{WorldInstanceReady, WorldInstanceSpawner};
use serde::de::DeserializeSeed; use serde::de::{DeserializeOwned, DeserializeSeed};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ActorId, EditorVisibility, HydratedPrefabMember, MaterialDesc, PrefabInstance}; use crate::{ActorId, EditorVisibility, HydratedPrefabMember, MaterialDesc, PrefabInstance};
@ -198,6 +198,61 @@ pub fn validate_prefab_overrides_payload(overrides: &PrefabOverrides) -> Result<
Ok(()) Ok(())
} }
/// Applies one reflected property path while preserving every other field in the current value.
///
/// Navigation baking consumes authored prefab overrides without instantiating a Bevy world. Keep
/// its serialized merge behavior identical to the runtime override path, including enum variants.
pub fn apply_serialized_navigation_property_override(
component_type: &str,
current_component_ron: &str,
value_component_ron: &str,
property_path: &str,
) -> Result<String, String> {
fn apply<T>(current: &str, value: &str, property_path: &str) -> Result<String, String>
where
T: Reflect + Serialize + DeserializeOwned,
{
let mut current = ron::from_str::<T>(current)
.map_err(|error| format!("invalid current component value: {error}"))?;
let value = ron::from_str::<T>(value)
.map_err(|error| format!("invalid override component value: {error}"))?;
let path = ParsedPath::parse(property_path)
.map_err(|error| format!("invalid property path `{property_path}`: {error}"))?;
let source_value = path
.reflect_element(&value)
.map_err(|error| format!("override value path `{property_path}` failed: {error}"))?;
let target_value = current
.reflect_path_mut(&path)
.map_err(|error| format!("target property path `{property_path}` failed: {error}"))?;
target_value
.try_apply(source_value)
.map_err(|error| format!("could not apply `{property_path}`: {error}"))?;
ron::to_string(&current)
.map_err(|error| format!("could not encode merged component value: {error}"))
}
macro_rules! apply_component {
($ty:ty) => {
if component_type == std::any::type_name::<$ty>() {
return apply::<$ty>(current_component_ron, value_component_ron, property_path);
}
};
}
apply_component!(Transform);
apply_component!(crate::Primitive);
apply_component!(crate::BrushDesc);
apply_component!(crate::NavigationBounds);
apply_component!(crate::NavigationObstacle);
apply_component!(crate::NavigationArea);
apply_component!(crate::NavigationLink);
apply_component!(PrefabInstance);
apply_component!(crate::PrefabRef);
Err(format!(
"component `{component_type}` is not supported by serialized authoring property overrides"
))
}
pub fn encode_prefab_overrides(overrides: &PrefabOverrides) -> Result<Option<String>, String> { pub fn encode_prefab_overrides(overrides: &PrefabOverrides) -> Result<Option<String>, String> {
if overrides == &PrefabOverrides::default() { if overrides == &PrefabOverrides::default() {
Ok(None) Ok(None)
@ -572,6 +627,26 @@ mod tests {
assert_ne!(first, second); assert_ne!(first, second);
} }
#[test]
fn serialized_property_merge_preserves_unrelated_fields_and_enum_identity() {
let current = crate::Primitive::cuboid(Vec3::new(4.0, 0.2, 6.0));
let value = crate::Primitive {
shape: crate::PrimitiveShape::Sphere,
size: Vec3::splat(99.0),
};
let merged = apply_serialized_navigation_property_override(
std::any::type_name::<crate::Primitive>(),
&ron::to_string(&current).unwrap(),
&ron::to_string(&value).unwrap(),
"shape",
)
.unwrap();
let merged: crate::Primitive = ron::from_str(&merged).unwrap();
assert_eq!(merged.shape, crate::PrimitiveShape::Sphere);
assert_eq!(merged.size, current.size);
}
#[test] #[test]
fn invalid_inner_override_payload_is_rejected_during_decode() { fn invalid_inner_override_payload_is_rejected_during_decode() {
let mut instance = PrefabInstance::new("prefab", "assets/prefabs/test.scn.ron"); let mut instance = PrefabInstance::new("prefab", "assets/prefabs/test.scn.ron");

View File

@ -76,6 +76,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [editor/extensibility.md](editor/extensibility.md) | Static authoring component registration, lifecycle, composition, and history contract | | [editor/extensibility.md](editor/extensibility.md) | Static authoring component registration, lifecycle, composition, and history contract |
| [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics | | [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics |
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation | | [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
## Working plans (not canonical long-term) ## Working plans (not canonical long-term)

View File

@ -16,19 +16,36 @@ packaged games and headless validation must consume the same data and behavior.
Use the engine-independent Rerecast crate for deterministic 3D walkable-surface generation and Use the engine-independent Rerecast crate for deterministic 3D walkable-surface generation and
Polyanya for any-angle path queries. Blacksite owns reflected authoring components and a versioned Polyanya for any-angle path queries. Blacksite owns reflected authoring components and a versioned
generated artifact under `assets/navigation/generated/`. The artifact records the source scene, generated artifact under `assets/navigation/generated/`. The artifact records the source scene,
source fingerprint, agent profile, baked mesh, links, bounds, and diagnostics. source fingerprint, exact generator/toolchain ID, agent profile, baked mesh, links, bounds, polygon
count, authored validation samples, a self-verifying payload hash, and raw/effective
disconnected-island diagnostics. Bounds do not inject fallback geometry.
Each bounds actor owns one artifact path derived from its stable `ActorId` when created or
duplicated. Artifact paths remain inside `assets/navigation/generated/`; duplicate ownership is a
blocking authoring error rather than last-writer-wins bake behavior.
The scene crate owns fingerprinting, bake artifact IO, and validation. The game crate owns artifact The scene crate owns fingerprinting, bake artifact IO, the engine-independent query runtime, and
loading and path queries. Editor bake controls, overlays, and path preview call those shared/game validation. The game crate provides the Bevy `Vec3` adapter and renderer-free validation CLI.
contracts and keep all visual helpers transient. Relevant authored geometry changes mark the Editor bake controls, overlays, path preview, headless baking, and project validation call the same
artifact stale; V1 debounces a full deterministic rebuild and reports the affected region until resolver/query contracts and keep all visual helpers transient. Relevant authored geometry changes
Rerecast provides production-ready tiled regeneration. mark the artifact stale; V1 debounces a full deterministic rebuild and reports the affected region
until Rerecast provides production-ready tiled regeneration.
The authoritative resolver expands visible scene composition and recursively linked prefab sources
from the project root. Repeated contributors receive qualified source identities. Full-value
navigation-relevant prefab component overrides are applied deterministically. Property overrides
apply only while their serialized source base remains current; drifted property layers and
structural overrides are rejected with a rebase/apply/unpack repair action. Bounds inside prefabs
are rejected so one level-scene actor remains the unambiguous artifact owner.
## Consequences ## Consequences
- Navigation remains usable on Bevy 0.19 without forking an older Bevy integration crate. - Navigation remains usable on Bevy 0.19 without forking an older Bevy integration crate.
- Rerecast and Polyanya versions become explicit serialized-artifact compatibility inputs; schema - Rerecast and Polyanya versions become explicit serialized-artifact compatibility inputs; schema
migration or rebaking is required when their representation changes. migration or rebaking is required when their representation changes.
- Runtime queries resolve vertically overlapping surfaces by endpoint height and may traverse an
ordered graph of multiple explicit links; reported distance is measured along the 3D route.
- Enabled named path samples become deterministic release assertions evaluated by the same query
core used by the editor and game.
- Static navigation and explicit links ship first. Runtime obstacle carving, crowd avoidance, and - Static navigation and explicit links ship first. Runtime obstacle carving, crowd avoidance, and
partial tile rebuilding remain future work. partial tile rebuilding remain future work.
- Primitive and additive-brush triangles are deterministic source geometry. Imported static meshes - Primitive and additive-brush triangles are deterministic source geometry. Imported static meshes

View File

@ -24,6 +24,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| [extensibility.md](extensibility.md) | Static authoring component lifecycle registration, stable IDs, composition, and generic history | | [extensibility.md](extensibility.md) | Static authoring component lifecycle registration, stable IDs, composition, and generic history |
| [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration | | [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration |
| [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation | | [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation |
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
## Subsystems (code → doc) ## Subsystems (code → doc)

View File

@ -0,0 +1,64 @@
# Navigation Authoring Evaluation
Date: 2026-07-12
Branch: `codex/renderer-material-component-foundation`
This record captures implementation evidence for deterministic navigation authoring, composed
source resolution, persisted path samples, bake diagnostics, and shared editor/game/headless path
queries. The permanent contract lives in [ADR 0032](../../../adr/0032-versioned-navigation-bake-and-runtime-query.md),
and the user workflow lives in the [navigation authoring guide](../../navigation-authoring.md).
## Live editor evidence
The images below are native Wayland captures from the debug editor running the isolated
`Blacksite Navigation QA` project. The QA project is temporary; no acceptance interaction changes
the repository scene.
### Bounds, sources, diagnostics, and validation samples
![Selected Navigation Bounds with the truthful world-space overlay and complete authoring controls](navigation-authoring-overview.png)
The selected bounds owns its generated artifact, exposes the complete agent profile, reports
current/stale state, and shows its persisted validation sample. Bounds, obstacle, area, and link
visualizers match the world-axis-aligned volumes and endpoints consumed by the bake.
### Shared runtime path preview
![Polyanya path preview crossing the authored off-mesh link](navigation-path-preview.png)
**Test Path** uses the same height-aware, multi-link query core as project validation and the game
adapter. The live query returned an 8.20 m, six-waypoint route through
`navigation-link-center`; a subsequent editor bake regenerated 26 polygons and the same query
remained valid. Pinning a second sample immediately marked the artifact stale and disabled path
testing; undo restored the current one-sample state. The transient route, endpoints, baked mesh,
and visualizers do not enter scene serialization.
## Acceptance results
| Area | Result | Evidence |
|------|--------|----------|
| Designer workflow | Pass | Typed bounds/obstacle/area/link actors, Bake, overlay, Test Path, and persisted sample controls |
| Source fidelity | Pass | Active unsaved snapshot plus visible subscene and recursive prefab resolution; exact affine transforms; no synthetic floor |
| Stale-state scope | Pass | Each bounds fingerprint includes only overlapping geometry/volumes and relevant links/samples; external artifact replacement is polled |
| Diagnostics | Pass | Invalid agents, grid budgets, links, duplicate artifact owners, prefab drift/structure, empty bakes, and unreachable named samples identify owners and repairs |
| Runtime parity | Pass | One scene-owned Polyanya query core drives editor preview, game `Vec3` adapter, link/sample validation, and the renderer-free validation CLI |
| Artifact integrity | Pass | Schema 2, exact generator identity, deterministic fresh-bake comparison, self-verifying payload hash, and atomic replacement |
| Serialization boundary | Pass | Viewport meshes, lines, markers, routes, and cache state remain transient |
| Packaged/release acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged test is claimed in this record |
## Automated verification
| Command/suite | Result |
|---------------|--------|
| Affected source test matrix (`shared`, `scene`, `game`, `editor`, `blacksite_surface`, `xtask`) | 390 passed; 1 intentional manual migration test ignored |
| `cargo clippy -p shared -p scene -p game -p editor -p xtask --all-targets -- -D warnings` | Pass |
| `cargo bake-navigation --project . --check` | 1 deterministic artifact current |
| `cargo validate-levels` | 59 dependencies, 5 non-blocking findings, 0 blocking errors |
| `cargo fmt --all` / `git diff --check` | Pass |
## Deliberate boundaries
V1 applies blocked areas and retains walkable cost metadata until Polyanya exposes a suitable
per-polygon cost callback. Imported static-mesh manifests do not yet contain normalized navigation
triangles. Runtime obstacle carving, crowd avoidance, and partial tile rebuilds remain future work;
V1 uses a debounced deterministic rebuild scoped by each bounds fingerprint.

Binary file not shown.

Binary file not shown.

View File

@ -1,15 +1,16 @@
# Navigation Authoring # Navigation Authoring
Blacksite navigation v1 uses authored bounds, obstacles, areas, and links to produce a deterministic Blacksite navigation v1 uses authored bounds, obstacles, areas, links, and validation samples to
Rerecast bake artifact. Editor preview and packaged gameplay query that artifact through the same produce a deterministic Rerecast bake artifact. The scene crate owns one height-aware Polyanya
`game::navigation::NavigationRuntime` Polyanya path API. query core; editor preview, project validation, and `game::navigation::NavigationRuntime` use that
same implementation.
## Authoring ## Authoring
Use **Scene > Navigation** or the path icon in the existing top toolbar to create navigation actors: Use **Scene > Navigation** or the path icon in the existing viewport toolbar to create navigation actors:
- **Navigation Bounds** defines an axis-aligned bake region, agent profile, generated artifact path, - **Navigation Bounds** defines an axis-aligned bake region, agent profile, generated artifact path,
and optional debounced auto-bake. optional debounced auto-bake, and named start/end validation samples.
- **Navigation Obstacle** carves a blocked volume from overlapping bounds. - **Navigation Obstacle** carves a blocked volume from overlapping bounds.
- **Navigation Area** records a named walkable/blocked volume and traversal cost. V1 applies blocked - **Navigation Area** records a named walkable/blocked volume and traversal cost. V1 applies blocked
areas during bake and retains walkable cost metadata for a future per-polygon cost callback. areas during bake and retains walkable cost metadata for a future per-polygon cost callback.
@ -18,10 +19,26 @@ Use **Scene > Navigation** or the path icon in the existing top toolbar to creat
V1 source geometry includes primitives and additive brushes. Imported static-mesh manifests do not V1 source geometry includes primitives and additive brushes. Imported static-mesh manifests do not
yet contain normalized collision triangles, so imported objects require authored navigation yet contain normalized collision triangles, so imported objects require authored navigation
obstacles until that artifact contract is extended. obstacles until that artifact contract is extended. Bounds never synthesize a walkable floor:
at least one overlapping primitive or additive brush must provide real source triangles, so gaps,
voids, and separated platforms remain non-navigable unless explicitly linked.
Each navigation actor must own exactly one navigation component. Inspector changes are undoable and Each navigation actor must own exactly one navigation component. Inspector changes are undoable and
save-time validation rejects invalid extents, profiles, area IDs/costs, and link endpoints. save-time validation rejects invalid extents, profiles, area IDs/costs, and link endpoints. New and
duplicated bounds receive an actor-ID-derived artifact path; headless bake and project validation
reject legacy or manually edited paths claimed by more than one bounds actor before any file is
written.
The authoritative bake resolver starts from the active scene's authored snapshot, expands visible
composed subscenes, and expands linked prefab sources recursively. Contributor IDs include their
subscene/prefab chain so repeated source actors remain unambiguous. Prefab transform, primitive,
brush, navigation-component, and nested-instance overrides use their serialized values. Independent
property paths on one component merge with the same reflected semantics as runtime hydration, while
every override must still match the source component base it was authored against. Drifted property
layers and structural overrides are blocked with a rebase/apply/unpack repair action because
silently guessing would produce a different mesh from the hydrated editor world. Prefabs may
contribute geometry, obstacles, areas, and links, but Navigation Bounds remain owned by level
scenes.
## Bake And Preview ## Bake And Preview
@ -34,8 +51,17 @@ stale; auto-bake waits 0.75 seconds after the latest change before rebuilding. A
reports once and waits for another authored change before retrying. reports once and waits for another authored change before retrying.
The viewport overlay draws authored volumes, baked polygon edges, link endpoints, and the current The viewport overlay draws authored volumes, baked polygon edges, link endpoints, and the current
path test. Set **Path start** and **Path end** on the bounds card and choose **Test Path**. Green/red path test. Bounds, obstacles, and areas are shown as the same scaled, rotated world-axis-aligned
endpoint markers and an amber route show the exact result returned by the game runtime API. volumes consumed by the bake, so the overlay remains truthful for transformed or parented actors.
Set **Path start** and **Path end** on the bounds card and choose **Test Path**. Green/red endpoint
markers and an amber route show the exact result returned by the game runtime API. The editor also
tracks the selected artifact path on disk: external deletion, replacement, or rebaking invalidates
the previous preview and reloads the new artifact before another path query can run.
Choose **Pin Current Path** to store the current local-space endpoints as a named validation sample.
Samples can be enabled, edited, loaded back into the preview, or removed from the same bounds card.
Enabled samples are embedded in the artifact and must resolve through the shared multi-link runtime
query during project validation.
## Headless And Runtime ## Headless And Runtime
@ -47,9 +73,14 @@ cargo bake-navigation --project .
cargo bake-navigation --project . --check cargo bake-navigation --project . --check
``` ```
`--check` never writes. It fails when an artifact is missing, malformed, or stale, making it suitable `--check` never writes. It fails when an artifact is missing, malformed, stale, generated by a
for CI. `cargo validate-levels` applies the same fingerprint policy, validates link proximity, and different exact navigation toolchain, or differs from a fresh deterministic bake, making it
adds current navigation artifacts to package dependencies. suitable for CI. Bake requests are rejected before allocation when their voxel grid exceeds the
production budget (8,192 cells per X/Z axis and 4,194,304 XZ cells total). `cargo validate-levels`
applies the same composed-scene/prefab resolver, validates link proximity and enabled samples,
reports effective disconnected components after valid links, and adds current navigation artifacts
to package dependencies. Artifacts use schema 2, an exact Rerecast/Polyanya/glam generator ID, and a
self-verifying payload hash; incompatible or parseable-tampered artifacts must be rebaked.
Gameplay loads and queries an artifact without editor dependencies: Gameplay loads and queries an artifact without editor dependencies:
@ -58,8 +89,18 @@ let navigation = game::navigation::NavigationRuntime::load(path)?;
let path = navigation.query(start, destination)?; let path = navigation.query(start, destination)?;
``` ```
The returned path contains world-space points, total length, and the optional stable actor ID of an The returned path contains height-resolved world-space points, 3D total length, and the ordered
off-mesh link used by the route. stable actor IDs of every off-mesh link used by the route. Routes may traverse multiple directed or
bidirectional links across disconnected islands.
The game binary also exposes a renderer-free artifact check for build or deployment tooling:
```bash
cargo run -p game -- --validate-navigation assets/navigation/generated/example.nav.ron
```
It validates link placement and every enabled embedded sample, then exits nonzero with the named
failure instead of opening a window.
See [ADR 0032](../adr/0032-versioned-navigation-bake-and-runtime-query.md) for dependency and See [ADR 0032](../adr/0032-versioned-navigation-bake-and-runtime-query.md) for dependency and
ownership decisions and [the implementation plan](../../.cursor/plans/navigation_authoring_2026-07-11.plan.md) ownership decisions and [the implementation plan](../../.cursor/plans/navigation_authoring_2026-07-11.plan.md)

View File

@ -147,7 +147,7 @@ with implementation sequencing in
| Milestone | Exit condition | Status | | Milestone | Exit condition | Status |
|-----------|----------------|--------| |-----------|----------------|--------|
| M6 Reliability, recovery, and project workflow | Transactional save/recovery, stable sessions, project launcher, hardened hierarchy/prefabs, multi-scene composition | Implementation complete; all six scoped issues are closed after prefab #43 passed workspace, headless, packaged-runtime, and live editor acceptance | | M6 Reliability, recovery, and project workflow | Transactional save/recovery, stable sessions, project launcher, hardened hierarchy/prefabs, multi-scene composition | Implementation complete; all six scoped issues are closed after prefab #43 passed workspace, headless, packaged-runtime, and live editor acceptance |
| M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44, #45, #46, and #47 are complete. Navigation #48 is in implementation; source-control safety #49 and final readiness gate #50 remain. | | M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#48 are complete. Source-control safety #49 and final readiness gate #50 remain; packaged acceptance is deferred until requested by the project owner. |
Production readiness is not inferred from feature count. Gitea Production readiness is not inferred from feature count. Gitea
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50) [`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)

View File

@ -31,6 +31,7 @@ required-features = ["validate-levels"]
[dependencies] [dependencies]
walkdir = "2.5" walkdir = "2.5"
scene = { path = "../crates/scene", optional = true } scene = { path = "../crates/scene", optional = true }
shared = { path = "../crates/shared", optional = true }
settings = { workspace = true, optional = true } settings = { workspace = true, optional = true }
serde_json = "1" serde_json = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@ -42,4 +43,4 @@ libc = "0.2"
[features] [features]
default = [] default = []
validate-levels = ["dep:scene", "dep:settings"] validate-levels = ["dep:scene", "dep:settings", "dep:shared"]

View File

@ -1,11 +1,11 @@
//! Deterministically bake navigation artifacts from authored scene components. //! Deterministically bake navigation artifacts from authored scene components.
use std::fs; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use scene::document::SceneDocument;
use scene::navigation::{ use scene::navigation::{
bake_navigation, navigation_bake_jobs_from_document, write_navigation_artifact, bake_navigation, navigation_artifact_content_hash, resolve_navigation_bake_jobs,
write_navigation_artifact, NavigationBakeArtifact, NavigationBakeJob,
}; };
use walkdir::WalkDir; use walkdir::WalkDir;
@ -40,6 +40,21 @@ fn run() -> Result<(), String> {
} }
} }
bake_project(project_root, requested_scene, check)
}
#[derive(Debug)]
struct BakeRequest {
source_path: PathBuf,
output: PathBuf,
job: NavigationBakeJob,
}
fn bake_project(
project_root: PathBuf,
requested_scene: Option<PathBuf>,
check: bool,
) -> Result<(), String> {
let project_root = project_root let project_root = project_root
.canonicalize() .canonicalize()
.map_err(|error| format!("could not resolve project root: {error}"))?; .map_err(|error| format!("could not resolve project root: {error}"))?;
@ -48,41 +63,105 @@ fn run() -> Result<(), String> {
None => discover_scenes(&project_root)?, None => discover_scenes(&project_root)?,
}; };
let mut artifact_count = 0usize; let mut requests = Vec::new();
for scene_path in scenes { for scene_path in scenes {
let relative_scene = scene_path let jobs = resolve_navigation_bake_jobs(&project_root, &scene_path, None)
.strip_prefix(&project_root)
.map_err(|_| format!("scene {} escaped the project", scene_path.display()))?
.to_string_lossy()
.replace('\\', "/");
let text = fs::read_to_string(&scene_path)
.map_err(|error| format!("could not read {}: {error}", scene_path.display()))?;
let document = SceneDocument::from_ron_text(&text)
.map_err(|error| format!("{}: {error}", scene_path.display()))?; .map_err(|error| format!("{}: {error}", scene_path.display()))?;
for job in navigation_bake_jobs_from_document(&document, &relative_scene)? { for job in jobs {
let artifact = bake_navigation(&job.input)?;
let output = safe_artifact_path(&project_root, &job.artifact_path)?; let output = safe_artifact_path(&project_root, &job.artifact_path)?;
if check { requests.push(BakeRequest {
let existing = scene::navigation::read_navigation_artifact(&output)?; source_path: scene_path.clone(),
if existing.is_stale_for(&job.input) { output,
return Err(format!( job,
"{} is stale for {}", });
output.display(), }
scene_path.display() }
));
} reject_duplicate_artifact_ownership(&requests)?;
println!("current {}", output.display()); let artifact_count = requests.len();
} else { for request in requests {
write_navigation_artifact(&output, &artifact)?; if check {
println!("baked {}", output.display()); let fresh = bake_navigation(&request.job.input)
} .map_err(|error| owner_error(&request, &format!("rebake failed: {error}")))?;
artifact_count += 1; let existing = scene::navigation::read_navigation_artifact(&request.output)
.map_err(|error| owner_error(&request, &error))?;
verify_checked_artifact(&request, &existing, &fresh)?;
println!("current {}", request.output.display());
} else {
let artifact = bake_navigation(&request.job.input)
.map_err(|error| owner_error(&request, &format!("bake failed: {error}")))?;
write_navigation_artifact(&request.output, &artifact)
.map_err(|error| owner_error(&request, &error))?;
println!("baked {}", request.output.display());
} }
} }
println!("bake-navigation: {artifact_count} artifacts"); println!("bake-navigation: {artifact_count} artifacts");
Ok(()) Ok(())
} }
fn verify_checked_artifact(
request: &BakeRequest,
existing: &NavigationBakeArtifact,
fresh: &NavigationBakeArtifact,
) -> Result<(), String> {
if existing.is_stale_for(&request.job.input) {
return Err(owner_error(
request,
"stored artifact fingerprint is stale; rerun `cargo bake-navigation --project .`",
));
}
let existing_hash =
navigation_artifact_content_hash(existing).map_err(|error| owner_error(request, &error))?;
let fresh_hash =
navigation_artifact_content_hash(fresh).map_err(|error| owner_error(request, &error))?;
if existing_hash != fresh_hash {
return Err(owner_error(
request,
&format!(
"stored artifact content differs from a deterministic fresh bake (stored {existing_hash}, fresh {fresh_hash}); rerun `cargo bake-navigation --project .`"
),
));
}
Ok(())
}
fn owner_error(request: &BakeRequest, message: &str) -> String {
format!(
"{}: Navigation Bounds actor `{}` in {}: {message}",
request.output.display(),
request.job.input.bounds_actor_id,
request.source_path.display()
)
}
fn reject_duplicate_artifact_ownership(requests: &[BakeRequest]) -> Result<(), String> {
let mut owners = BTreeMap::<&Path, Vec<&BakeRequest>>::new();
for request in requests {
owners.entry(&request.output).or_default().push(request);
}
let Some((output, duplicate_owners)) = owners.into_iter().find(|(_, owners)| owners.len() > 1)
else {
return Ok(());
};
let owners = duplicate_owners
.iter()
.map(|request| {
format!(
"actor `{}` in {}",
request.job.input.bounds_actor_id,
request.source_path.display()
)
})
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"navigation artifact `{}` has duplicate owners: {owners}. Give each Navigation Bounds actor a unique artifact path under {} and rerun the bake",
output.display(),
shared::NAVIGATION_GENERATED_ARTIFACT_DIRECTORY
))
}
fn discover_scenes(project_root: &Path) -> Result<Vec<PathBuf>, String> { fn discover_scenes(project_root: &Path) -> Result<Vec<PathBuf>, String> {
let levels = project_root.join("assets/levels"); let levels = project_root.join("assets/levels");
if !levels.is_dir() { if !levels.is_dir() {
@ -94,19 +173,20 @@ fn discover_scenes(project_root: &Path) -> Result<Vec<PathBuf>, String> {
.filter_map(Result::ok) .filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file()) .filter(|entry| entry.file_type().is_file())
.map(|entry| entry.into_path()) .map(|entry| entry.into_path())
.filter(|path| path.to_string_lossy().ends_with(".scn.ron")) .filter(|path| {
path.to_string_lossy().ends_with(".scn.ron")
&& path
.strip_prefix(project_root)
.is_ok_and(scene::is_runtime_package_asset)
})
.collect(); .collect();
scenes.sort(); scenes.sort();
Ok(scenes) Ok(scenes)
} }
fn safe_artifact_path(project_root: &Path, authored: &str) -> Result<PathBuf, String> { fn safe_artifact_path(project_root: &Path, authored: &str) -> Result<PathBuf, String> {
let normalized = authored.replace('\\', "/"); let normalized = shared::navigation_generated_artifact_path(authored)
if !normalized.starts_with("assets/navigation/generated/") { .map_err(|error| format!("navigation artifact `{authored}` is invalid: {error}"))?;
return Err(format!(
"navigation artifact `{authored}` must be under assets/navigation/generated/"
));
}
safe_project_path(project_root, Path::new(&normalized)) safe_project_path(project_root, Path::new(&normalized))
} }
@ -123,3 +203,126 @@ fn safe_project_path(project_root: &Path, relative: &Path) -> Result<PathBuf, St
} }
Ok(project_root.join(relative)) Ok(project_root.join(relative))
} }
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1);
fn fixture_root() -> PathBuf {
let root = std::env::temp_dir().join(format!(
"blacksite-navigation-bake-{}-{}",
std::process::id(),
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(root.join("assets/levels")).unwrap();
root
}
fn navigation_scene(actor_id: &str, artifact_path: &str) -> String {
let mut bounds = shared::NavigationBounds::for_actor(actor_id);
bounds.artifact_path = artifact_path.to_string();
let bounds = ron::to_string(&bounds).unwrap();
format!(
"(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":({actor_id:?}),\"shared::navigation::NavigationBounds\":{bounds},}})}})"
)
}
#[test]
fn duplicate_ownership_is_rejected_before_any_artifact_write() {
let root = fixture_root();
let artifact_path = "assets/navigation/generated/shared.nav.ron";
std::fs::write(
root.join("assets/levels/first.scn.ron"),
navigation_scene("first-bounds", artifact_path),
)
.unwrap();
std::fs::write(
root.join("assets/levels/second.scn.ron"),
navigation_scene("second-bounds", artifact_path),
)
.unwrap();
let error = bake_project(root.clone(), None, false).unwrap_err();
assert!(error.contains("duplicate owners"), "{error}");
assert!(error.contains("first-bounds"), "{error}");
assert!(error.contains("second-bounds"), "{error}");
assert!(!root.join(artifact_path).exists());
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn artifact_output_must_use_generated_navigation_directory() {
let root = Path::new("/project");
assert!(safe_artifact_path(root, "assets/navigation/manual.nav.ron").is_err());
assert_eq!(
safe_artifact_path(root, "assets\\navigation\\generated\\bounds.nav.ron").unwrap(),
root.join("assets/navigation/generated/bounds.nav.ron")
);
}
#[test]
fn scene_discovery_ignores_transient_hidden_snapshots() {
let root = fixture_root();
std::fs::write(
root.join("assets/levels/main.scn.ron"),
"(schema_version:4,resources:{},entities:{})",
)
.unwrap();
std::fs::write(
root.join("assets/levels/.pie_session.scn.ron"),
"(resources:{},entities:{})",
)
.unwrap();
let scenes = discover_scenes(&root).unwrap();
assert_eq!(scenes, [root.join("assets/levels/main.scn.ron")]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn check_reports_owner_when_content_differs_despite_current_fingerprint() {
let input = scene::navigation::NavigationBakeInput {
source_scene: "assets/levels/main.scn.ron".into(),
bounds_actor_id: "main-bounds".into(),
center: [0.0, 0.0, 0.0],
half_extents: [5.0, 2.0, 5.0],
agent: shared::NavigationAgentProfile {
min_region_size: 1,
merge_region_size: 2,
..Default::default()
},
geometry: vec![scene::navigation::NavigationGeometryInput {
actor_id: "floor".into(),
triangles: vec![
[[-5.0, 0.0, -5.0], [5.0, 0.0, 5.0], [5.0, 0.0, -5.0]],
[[-5.0, 0.0, -5.0], [-5.0, 0.0, 5.0], [5.0, 0.0, 5.0]],
],
}],
obstacles: Vec::new(),
areas: Vec::new(),
links: Vec::new(),
samples: Vec::new(),
};
let fresh = bake_navigation(&input).unwrap();
let mut stored = fresh.clone();
stored.bounds_actor_id = "unexpected-owner".into();
let request = BakeRequest {
source_path: PathBuf::from("/project/assets/levels/main.scn.ron"),
output: PathBuf::from("/project/assets/navigation/generated/main-bounds.nav.ron"),
job: NavigationBakeJob {
artifact_path: "assets/navigation/generated/main-bounds.nav.ron".into(),
input,
},
};
let error = verify_checked_artifact(&request, &stored, &fresh).unwrap_err();
assert!(error.contains("main-bounds"), "{error}");
assert!(error.contains("assets/levels/main.scn.ron"), "{error}");
assert!(
error.contains("differs from a deterministic fresh bake"),
"{error}"
);
}
}