From b4aa61e39459c12b81d86575eac62c50b4efbedd Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sun, 12 Jul 2026 02:53:43 -0400 Subject: [PATCH] Ship production navigation authoring workflow --- .../navigation_authoring_2026-07-11.plan.md | 21 +- Cargo.lock | 2 + Cargo.toml | 6 +- README.md | 11 +- .../navigation_authoring_showcase.scn.ron | 5 +- .../navigation_showcase_humanoid.nav.ron | 20 +- crates/blacksite_surface/src/lib.rs | 33 +- crates/editor/src/history/mod.rs | 104 +- crates/editor/src/scene/scene_io.rs | 12 +- crates/editor/src/ui/inspector.rs | 60 +- crates/editor/src/ui/navigation_inspector.rs | 821 ++++++-- crates/editor/src/viewport/visualizers.rs | 28 +- crates/game/src/main.rs | 27 + crates/game/src/navigation.rs | 325 ++-- crates/scene/src/navigation.rs | 1709 ++++++++++++++++- crates/scene/src/project_validation.rs | 438 ++++- crates/shared/Cargo.toml | 1 + crates/shared/src/actor.rs | 57 +- crates/shared/src/components.rs | 4 +- crates/shared/src/hydration/mod.rs | 76 +- crates/shared/src/hydration/static_meshes.rs | 1 + crates/shared/src/lib.rs | 9 +- crates/shared/src/material_asset.rs | 18 +- crates/shared/src/navigation.rs | 185 +- crates/shared/src/prefab_overrides.rs | 77 +- docs/README.md | 1 + ...ioned-navigation-bake-and-runtime-query.md | 29 +- docs/editor/README.md | 1 + .../navigation-authoring/README.md | 64 + .../navigation-authoring-overview.png | 3 + .../navigation-path-preview.png | 3 + docs/editor/navigation-authoring.md | 69 +- docs/editor/roadmap.md | 2 +- xtask/Cargo.toml | 3 +- xtask/src/bake_navigation.rs | 275 ++- 35 files changed, 3898 insertions(+), 602 deletions(-) create mode 100644 docs/editor/evaluations/navigation-authoring/README.md create mode 100644 docs/editor/evaluations/navigation-authoring/navigation-authoring-overview.png create mode 100644 docs/editor/evaluations/navigation-authoring/navigation-path-preview.png diff --git a/.cursor/plans/navigation_authoring_2026-07-11.plan.md b/.cursor/plans/navigation_authoring_2026-07-11.plan.md index 83807ab..539dc48 100644 --- a/.cursor/plans/navigation_authoring_2026-07-11.plan.md +++ b/.cursor/plans/navigation_authoring_2026-07-11.plan.md @@ -6,8 +6,9 @@ This is the navigation production loop required by the M7 content-production mil ## Status -Architecture and dependency compatibility are established. Implementation and production -acceptance are in progress. +Architecture, dependency compatibility, composed-source resolution, persisted path samples, +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 @@ -18,13 +19,15 @@ use the same artifact through a game-owned runtime query API and headless releas ## Architecture - 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 release. Polyanya performs proven any-angle runtime and editor-preview path queries. - Overlapping primitive and additive-brush triangles enter the deterministic bake source; distant - geometry and navigation authoring are excluded from each bounds fingerprint. -- The game crate owns loading and querying baked artifacts. Editor UI calls that public API for - preview rather than implementing a second pathfinder. + geometry and navigation authoring are excluded from each bounds fingerprint. No synthetic bounds + floor is added. +- 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 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. 5. Add owner-attributed project validation for invalid links, missing/stale artifacts, unreachable preview samples, and bake failures. -6. Commit a small sample scene/artifact and run automated, headless, live editor, PIE, and packaged - runtime acceptance. +6. Commit a small sample scene/artifact and run automated, headless, and live debug-editor + acceptance. PIE and packaged runtime acceptance remain deferred by project-owner direction. ## 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. - Headless bake and project validation pass for the committed sample fixture, and package dependency 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 diff --git a/Cargo.lock b/Cargo.lock index a9a9dd8..fa1d369 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6502,6 +6502,7 @@ version = "0.1.0" dependencies = [ "avian3d", "bevy", + "blake3", "ron 0.8.1", "serde", "settings", @@ -8437,6 +8438,7 @@ dependencies = [ "serde", "serde_json", "settings", + "shared", "walkdir", ] diff --git a/Cargo.toml b/Cargo.toml index 3012d30..d6f5f20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,9 +45,9 @@ egui_dock = { version = "0.19.1", features = ["serde"] } egui_phosphor_icons = { version = "0.3.1", default-features = false } transform-gizmo-bevy = "0.9" serde = { version = "1", features = ["derive"] } -nav_glam = { package = "glam", version = "0.30.8", features = ["serde"] } -polyanya = { version = "0.16.1", default-features = false, features = ["recast", "serde"] } -rerecast = { version = "0.3.2", default-features = false, features = ["std", "serialize"] } +nav_glam = { package = "glam", version = "=0.30.10", features = ["serde"] } +polyanya = { version = "=0.16.1", default-features = false, features = ["recast", "serde"] } +rerecast = { version = "=0.3.2", default-features = false, features = ["std", "serialize"] } shared = { path = "crates/shared" } game = { path = "crates/game" } game_hot = { path = "crates/game_hot" } diff --git a/README.md b/README.md index ecafa83..7de8995 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings cargo test --workspace cargo validate-levels 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 cargo validate-levels --json 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 icon in the existing horizontal toolbar. Bounds bake versioned Rerecast artifacts under `assets/navigation/generated/`; overlapping primitive and additive-brush triangles participate - in the bake fingerprint while distant authoring is excluded. The Inspector reports - stale/current state and provides a Polyanya-backed path test whose mesh, links, and route render - in the viewport. Use + in the bake fingerprint while distant authoring is excluded. Visible composed subscenes and linked + prefab sources resolve through the same deterministic bake path used by CI. The Inspector reports + 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 [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 @@ -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 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)) -- [ ] 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] 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)) diff --git a/assets/levels/navigation_authoring_showcase.scn.ron b/assets/levels/navigation_authoring_showcase.scn.ron index 4aa012f..6f95235 100644 --- a/assets/levels/navigation_authoring_showcase.scn.ron +++ b/assets/levels/navigation_authoring_showcase.scn.ron @@ -24,6 +24,9 @@ ), artifact_path: "assets/navigation/generated/navigation_showcase_humanoid.nav.ron", 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: { @@ -67,4 +70,4 @@ "shared::components::Primitive": (shape: Box, size: (16.0, 0.5, 16.0)), }), }, -) \ No newline at end of file +) diff --git a/assets/navigation/generated/navigation_showcase_humanoid.nav.ron b/assets/navigation/generated/navigation_showcase_humanoid.nav.ron index 316112f..c543aa8 100644 --- a/assets/navigation/generated/navigation_showcase_humanoid.nav.ron +++ b/assets/navigation/generated/navigation_showcase_humanoid.nav.ron @@ -1,8 +1,9 @@ ( - schema_version: 1, - generator: "rerecast-0.3/polyanya-0.16", + schema_version: 2, + 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_fingerprint: "c3f7b1f6eb8cb9ab5d4bb3a9b0d09ce47641c563f47fa75df4e7eb30862d039c", + source_fingerprint: "15649e3f7c8f22d642ea318ba1dafb5a3ada82e2713219cb60bbb518c5e45195", + payload_hash: "180b5a4698102e8185ae182bc30cf847466c4e9d65fe7834c315f6d1a03504c2", bounds_actor_id: "navigation-bounds-main", center: (0.0, 0.0, 0.0), half_extents: (8.0, 2.0, 8.0), @@ -960,4 +961,17 @@ 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, + ), ) \ No newline at end of file diff --git a/crates/blacksite_surface/src/lib.rs b/crates/blacksite_surface/src/lib.rs index f029f75..5d2922b 100644 --- a/crates/blacksite_surface/src/lib.rs +++ b/crates/blacksite_surface/src/lib.rs @@ -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)] pub struct SurfaceExtension { #[uniform(100)] @@ -96,23 +96,6 @@ pub struct SurfaceExtension { pub shader: Handle, } -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 { pub fn set_texture(&mut self, index: usize, handle: Option>) { match index { @@ -170,8 +153,8 @@ pub struct SurfaceMaterialCache { } enum BuiltRendererMaterial { - Standard(StandardMaterial), - Surface(SurfaceMaterial), + Standard(Box), + Surface(Box), } #[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( mut commands: Commands, asset_server: Res, @@ -267,6 +250,7 @@ fn sync_surface_material_bindings( &mut evaluator_registry, ) { Ok(BuiltRendererMaterial::Surface(material)) => { + let material = *material; cache.standard_only.remove(reference); cache.standard_handles.remove(reference); cache.failed_revisions.remove(reference); @@ -282,6 +266,7 @@ fn sync_surface_material_bindings( } } Ok(BuiltRendererMaterial::Standard(material)) => { + let material = *material; // Plain Material assets stay on Bevy's StandardMaterial path, while still // sharing one live-updated handle across every renderer slot. 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); let Some(evaluator) = evaluator_source.as_deref() else { - return Ok(BuiltRendererMaterial::Standard(base)); + return Ok(BuiltRendererMaterial::Standard(Box::new(base))); }; validate_surface_evaluator(evaluator)?; let runtime_shader_id = stable_shader_id(reference); @@ -443,10 +428,10 @@ fn build_surface_material( asset_server, )?; } - Ok(BuiltRendererMaterial::Surface(ExtendedMaterial { + Ok(BuiltRendererMaterial::Surface(Box::new(ExtendedMaterial { base, extension, - })) + }))) } fn material_dependency_paths(path: &str) -> Result, String> { diff --git a/crates/editor/src/history/mod.rs b/crates/editor/src/history/mod.rs index cc3d391..28c9836 100644 --- a/crates/editor/src/history/mod.rs +++ b/crates/editor/src/history/mod.rs @@ -319,6 +319,7 @@ pub fn clear_level_objects(world: &mut World) { } 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); let entity = spawn_snapshot(world, &snapshot); push_history( @@ -340,6 +341,9 @@ pub fn spawn_many_with_history( if snapshots.is_empty() { 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()) { 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) { - 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 { 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) { if !is_mutable_level_object(world, entity) { return; @@ -2825,8 +2854,10 @@ mod tests { #[test] fn snapshots_preserve_component_order_and_independent_active_state() { let mut world = World::new(); - let mut order = InspectorOrder::default(); - order.component_ids = vec![shared::AUTHORING_COMPONENT_LIGHT.to_string()]; + let order = InspectorOrder { + component_ids: vec![shared::AUTHORING_COMPONENT_LIGHT.to_string()], + ..Default::default() + }; let mut states = AuthoringComponentStates::default(); states.set_component_active(shared::COMPONENT_LIGHT_DESC, false); let entity = world @@ -3516,6 +3547,73 @@ mod tests { 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::(); + world.init_resource::(); + world.init_resource::(); + 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] fn linked_members_reject_direct_history_mutations() { let mut world = World::new(); diff --git a/crates/editor/src/scene/scene_io.rs b/crates/editor/src/scene/scene_io.rs index 4714f98..0d8c454 100644 --- a/crates/editor/src/scene/scene_io.rs +++ b/crates/editor/src/scene/scene_io.rs @@ -88,6 +88,7 @@ pub struct SceneIo { pub events: VecDeque, pub tabs: Vec, pub active_tab: usize, + change_revision: u64, next_event_id: u64, next_tab_id: u64, } @@ -127,6 +128,7 @@ impl Default for SceneIo { recovery_snapshot: None, }], active_tab: 0, + change_revision: 0, next_event_id: 1, next_tab_id: 2, } @@ -136,14 +138,20 @@ impl Default for SceneIo { impl SceneIo { pub fn mark_dirty(&mut self) { self.dirty = true; + self.change_revision = self.change_revision.wrapping_add(1); self.sync_active_tab_metadata(); } pub fn mark_clean(&mut self) { self.dirty = false; + self.change_revision = self.change_revision.wrapping_add(1); self.sync_active_tab_metadata(); } + pub fn change_revision(&self) -> u64 { + self.change_revision + } + pub fn active_path_label(&self) -> String { self.active_path .as_ref() @@ -935,7 +943,7 @@ fn save_level(world: &mut World, path: &Path) -> Result { } } -fn serialize_active_scene(world: &mut World) -> Result { +pub(crate) fn serialize_active_scene(world: &mut World) -> Result { let entities = authored_scene_entities(world); if world .resource::() @@ -1950,11 +1958,13 @@ mod tests { let mut io = SceneIo::default(); assert_eq!(io.tabs.len(), 1); assert!(!io.tabs[0].dirty); + let initial_revision = io.change_revision(); io.active_path = Some(PathBuf::from("assets/levels/arena.scn.ron")); io.mark_dirty(); assert!(io.has_unsaved_tabs()); + assert!(io.change_revision() > initial_revision); assert!(io.tabs[0].dirty); assert_eq!( io.tabs[0].path.as_deref(), diff --git a/crates/editor/src/ui/inspector.rs b/crates/editor/src/ui/inspector.rs index cee4923..9ac0f26 100644 --- a/crates/editor/src/ui/inspector.rs +++ b/crates/editor/src/ui/inspector.rs @@ -9,9 +9,9 @@ use egui_phosphor_icons::{icons, Icon}; use shared::{ authoring_component_active, brush_math::{validate_brush, BrushDiagnosticSeverity}, - infer_actor_kind, ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, - AuthoringComponentStates, AuthoringLightKind, AuthoringRigidBody, BrushDesc, BrushKind, - ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef, + infer_actor_kind, ActorId, ActorKind, AnimationControllerDesc, AudioListenerDesc, + AudioSourceDesc, AuthoringComponentStates, AuthoringLightKind, AuthoringRigidBody, BrushDesc, + BrushKind, ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef, InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter, MaterialParameterValue, MaterialRef, MaterialShaderKind, NavigationArea, NavigationBounds, 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 => { insert_direct_component(world, entity, value); } - Some(CopiedComponent::NavigationBounds(value)) + Some(CopiedComponent::NavigationBounds(mut value)) if type_name == COMPONENT_NAVIGATION_BOUNDS => { + value.artifact_path = world + .get::(entity) + .map(|bounds| bounds.artifact_path.clone()) + .unwrap_or_else(|| navigation_bounds_for_entity(world, entity).artifact_path); crate::history::set_navigation_with_history( world, entity, @@ -760,6 +764,19 @@ fn reset_component(world: &mut World, entity: Entity, type_name: &str) { 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 { let _ = crate::history::reflected_component_transaction( world, @@ -809,7 +826,7 @@ fn reset_component(world: &mut World, entity: Entity, type_name: &str) { world, entity, crate::history::NavigationComponentState { - bounds: Some(NavigationBounds::default()), + bounds: Some(reset_navigation_bounds_for_entity(world, entity)), ..Default::default() }, ), @@ -1609,6 +1626,19 @@ fn insert_registered_component(world: &mut World, entity: Entity, type_name: &st 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 { let _ = crate::history::reflected_component_transaction( 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| { - world - .entity_mut(e) - .insert((ActorKind::Navigation, NavigationBounds::default())); + let bounds = navigation_bounds_for_entity(world, e); + world.entity_mut(e).insert((ActorKind::Navigation, bounds)); }), COMPONENT_NAVIGATION_OBSTACLE => insert_component(world, entity, |world, e| { 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::(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::(entity) { + bounds.artifact_path.clone_from(¤t.artifact_path); + } + bounds +} + fn insert_component(world: &mut World, entity: Entity, insert: impl FnOnce(&mut World, Entity)) { let before = crate::history::snapshot_entity(world, entity); let old_kind = before.as_ref().map(|s| s.actor_kind); diff --git a/crates/editor/src/ui/navigation_inspector.rs b/crates/editor/src/ui/navigation_inspector.rs index c675def..5478657 100644 --- a/crates/editor/src/ui/navigation_inspector.rs +++ b/crates/editor/src/ui/navigation_inspector.rs @@ -1,19 +1,19 @@ use std::collections::HashSet; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; +use bevy::math::Affine3A; use bevy::prelude::*; use bevy_egui::egui; use egui_phosphor_icons::icons; use scene::navigation::{ - aabb_overlaps, bake_navigation, geometry_overlaps_bounds, navigation_geometry_from_parts, - navigation_source_fingerprint, point_in_aabb, read_navigation_artifact, - write_navigation_artifact, NavigationAreaInput, NavigationBakeArtifact, NavigationBakeInput, - NavigationLinkInput, NavigationVolumeInput, + bake_navigation, navigation_source_fingerprint, read_navigation_artifact, + write_navigation_artifact, NavigationBakeArtifact, NavigationBakeInput, }; use shared::{ - ActorId, BrushDesc, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle, - Primitive, COMPONENT_NAVIGATION_AREA, COMPONENT_NAVIGATION_BOUNDS, COMPONENT_NAVIGATION_LINK, - COMPONENT_NAVIGATION_OBSTACLE, + ActorId, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle, + NavigationPathSample, COMPONENT_NAVIGATION_AREA, COMPONENT_NAVIGATION_BOUNDS, + COMPONENT_NAVIGATION_LINK, COMPONENT_NAVIGATION_OBSTACLE, }; use crate::history::{set_navigation_with_history, NavigationComponentState}; @@ -36,6 +36,15 @@ pub struct NavigationEditorState { pub status: String, pub error: bool, pub show_mesh: bool, + artifact_authored_path: Option, + artifact_resolved_path: Option, + artifact_disk_revision: Option, + artifact_poll_elapsed: f32, + input_bounds_entity: Option, + input_scene_revision: u64, + input_refresh_elapsed: f32, + current_input: Option, + current_input_error: Option, observed_fingerprint: Option, auto_bake_attempt: Option, debounce_elapsed: f32, @@ -52,6 +61,15 @@ impl Default for NavigationEditorState { status: "Not baked".into(), error: false, show_mesh: true, + artifact_authored_path: None, + artifact_resolved_path: None, + artifact_disk_revision: None, + artifact_poll_elapsed: 0.0, + input_bounds_entity: None, + input_scene_revision: 0, + input_refresh_elapsed: 0.0, + current_input: None, + current_input_error: None, observed_fingerprint: None, auto_bake_attempt: None, debounce_elapsed: 0.0, @@ -69,16 +87,30 @@ pub fn navigation_bounds_inspector_ui(world: &mut World, ui: &mut egui::Ui, enti let mut show_mesh = world.resource::().show_mesh; let status = world.resource::().status.clone(); let status_error = world.resource::().error; - let current = build_input(world, entity, &bounds).ok(); - let stale = current.as_ref().is_some_and(|input| { - world - .resource::() - .artifact - .as_ref() - .is_none_or(|artifact| artifact.is_stale_for(input)) + let (current, current_error) = { + let state = world.resource::(); + if state.input_bounds_entity == Some(entity) { + ( + state.current_input.clone(), + state.current_input_error.clone(), + ) + } else { + (None, None) + } + }; + let stale = current.as_ref().is_none_or(|input| { + let state = world.resource::(); + !artifact_source_matches(world, state, entity, &bounds) + || state + .artifact + .as_ref() + .is_none_or(|artifact| artifact.is_stale_for(input)) }); let mut bake = false; let mut preview = false; + let mut pin_sample = false; + let mut load_sample = None; + let mut remove_sample = None; let card = component_card_context( world, @@ -98,17 +130,31 @@ pub fn navigation_bounds_inspector_ui(world: &mut World, ui: &mut egui::Ui, enti }); property_row(ui, "Radius / height", |ui| { ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut bounds.agent.radius).speed(0.01)); - ui.add(egui::DragValue::new(&mut bounds.agent.height).speed(0.01)) + ui.add( + egui::DragValue::new(&mut bounds.agent.radius) + .speed(0.01) + .range(0.01..=100.0), + ); + ui.add( + egui::DragValue::new(&mut bounds.agent.height) + .speed(0.01) + .range(0.01..=100.0), + ) }) .response }); + let maximum_climb = (bounds.agent.height - f32::EPSILON).max(0.0); property_row(ui, "Climb / slope", |ui| { ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut bounds.agent.max_climb).speed(0.01)); + ui.add( + egui::DragValue::new(&mut bounds.agent.max_climb) + .speed(0.01) + .range(0.0..=maximum_climb), + ); ui.add( egui::DragValue::new(&mut bounds.agent.max_slope_deg) .speed(0.25) + .range(0.0..=89.9) .suffix(" deg"), ) }) @@ -119,12 +165,27 @@ pub fn navigation_bounds_inspector_ui(world: &mut World, ui: &mut egui::Ui, enti ui.add( egui::DragValue::new(&mut bounds.agent.cell_size_fraction) .speed(0.1) - .range(1.0..=12.0), + .range(1.0..=32.0), ); ui.add( egui::DragValue::new(&mut bounds.agent.cell_height_fraction) .speed(0.1) - .range(1.0..=24.0), + .range(1.0..=64.0), + ) + }) + .response + }); + property_row(ui, "Regions min / merge", |ui| { + ui.horizontal(|ui| { + ui.add( + egui::DragValue::new(&mut bounds.agent.min_region_size) + .speed(1.0) + .range(0..=255), + ); + ui.add( + egui::DragValue::new(&mut bounds.agent.merge_region_size) + .speed(1.0) + .range(0..=255), ) }) .response @@ -151,7 +212,7 @@ pub fn navigation_bounds_inspector_ui(world: &mut World, ui: &mut egui::Ui, enti .clicked(); ui.checkbox(&mut show_mesh, "Overlay"); }); - let status_color = if status_error { + let status_color = if status_error || current_error.is_some() { ERROR } else if stale { WARNING @@ -160,10 +221,10 @@ pub fn navigation_bounds_inspector_ui(world: &mut World, ui: &mut egui::Ui, enti }; ui.colored_label( status_color, - if stale { - format!("Stale: {status}") - } else { - status.clone() + match current_error.as_deref() { + Some(error) => format!("Bake blocked: {error}"), + None if stale => format!("Stale: {status}"), + None => status.clone(), }, ); @@ -171,13 +232,59 @@ pub fn navigation_bounds_inspector_ui(world: &mut World, ui: &mut egui::Ui, enti vec3_editor(ui, &mut preview_start, 0.1) }); property_row(ui, "Path end", |ui| vec3_editor(ui, &mut preview_end, 0.1)); + ui.separator(); + ui.strong(format!( + "Validation samples ({})", + bounds.validation_samples.len() + )); + if ui + .add_enabled( + preview_start.is_finite() + && preview_end.is_finite() + && preview_start != preview_end, + egui::Button::new(format!("{} Pin Current Path", icons::PLUS.as_str())), + ) + .on_hover_text("Persist the current path endpoints as a build validation sample") + .clicked() + { + pin_sample = true; + } + for (index, sample) in bounds.validation_samples.iter_mut().enumerate() { + ui.push_id(("navigation-sample", index), |ui| { + ui.horizontal(|ui| { + ui.checkbox(&mut sample.enabled, "") + .on_hover_text("Include this path in build validation"); + ui.add( + egui::TextEdit::singleline(&mut sample.id) + .desired_width((ui.available_width() - 62.0).max(72.0)), + ); + if ui + .button(icons::PLAY.as_str()) + .on_hover_text("Load this sample into the path preview") + .clicked() + { + load_sample = Some(index); + } + if ui + .button(icons::TRASH.as_str()) + .on_hover_text("Remove validation sample") + .clicked() + { + remove_sample = Some(index); + } + }); + property_row(ui, "Start", |ui| vec3_editor(ui, &mut sample.start, 0.1)); + property_row(ui, "End", |ui| vec3_editor(ui, &mut sample.end, 0.1)); + }); + } if let Some(input) = current.as_ref() { ui.label( egui::RichText::new(format!( - "{} obstacles {} areas {} links", + "{} obstacles {} areas {} links {} samples", input.obstacles.len(), input.areas.len(), - input.links.len() + input.links.len(), + input.samples.len() )) .small() .color(TEXT_DIM), @@ -186,6 +293,25 @@ pub fn navigation_bounds_inspector_ui(world: &mut World, ui: &mut egui::Ui, enti }); apply_component_card_response(world, entity, response); + if let Some(index) = remove_sample { + bounds.validation_samples.remove(index); + } + let bounds_transform = world_affine(world, entity); + if pin_sample { + let id = next_sample_id(&bounds.validation_samples); + let inverse = bounds_transform.inverse(); + bounds.validation_samples.push(NavigationPathSample { + id, + start: inverse.transform_point3(preview_start), + end: inverse.transform_point3(preview_end), + enabled: true, + }); + } + if let Some(sample) = load_sample.and_then(|index| bounds.validation_samples.get(index)) { + preview_start = bounds_transform.transform_point3(sample.start); + preview_end = bounds_transform.transform_point3(sample.end); + } + if bounds != original { set_navigation_with_history( world, @@ -210,6 +336,13 @@ pub fn navigation_bounds_inspector_ui(world: &mut World, ui: &mut egui::Ui, enti } } +fn next_sample_id(samples: &[NavigationPathSample]) -> String { + (1..) + .map(|index| format!("sample-{index}")) + .find(|candidate| samples.iter().all(|sample| sample.id != *candidate)) + .expect("navigation sample IDs have an unbounded numeric suffix") +} + pub fn navigation_obstacle_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { let Some(mut value) = world.get::(entity).cloned() else { return; @@ -374,15 +507,46 @@ pub fn bake_bounds(world: &mut World, entity: Entity) { .iter() .map(|layer| layer.polygons.len()) .sum(); + refresh_artifact_source(world, entity, &bounds, true); + let loaded_current = world + .resource::() + .artifact + .as_ref() + .is_some_and(|loaded| !loaded.is_stale_for(&input)); + if !loaded_current { + set_status( + world, + format!( + "Bake failed: could not reload the generated artifact {}", + bounds.artifact_path + ), + true, + ); + return; + } let mut state = world.resource_mut::(); - state.bounds_entity = Some(entity); - state.artifact = Some(artifact); state.preview_path.clear(); state.error = false; state.status = format!("Baked {polygon_count} polygons to {}", bounds.artifact_path); } pub fn query_preview(world: &mut World, entity: Entity) { + let Some(bounds) = world.get::(entity).cloned() else { + set_status( + world, + "Path test failed: selected actor has no Navigation Bounds", + true, + ); + return; + }; + refresh_artifact_source(world, entity, &bounds, true); + let input = match build_input(world, entity, &bounds) { + Ok(input) => input, + Err(error) => { + set_status(world, format!("Path test failed: {error}"), true); + return; + } + }; let (artifact, start, end) = { let state = world.resource::(); ( @@ -395,6 +559,14 @@ pub fn query_preview(world: &mut World, entity: Entity) { set_status(world, "Path test failed: bake navigation first", true); return; }; + if artifact.is_stale_for(&input) { + set_status( + world, + "Path test failed: navigation artifact is stale; rebake first", + true, + ); + return; + } let runtime = match game::navigation::NavigationRuntime::from_artifact(artifact) { Ok(runtime) => runtime, Err(error) => { @@ -412,10 +584,11 @@ pub fn query_preview(world: &mut World, entity: Entity) { "Path {:.2} m, {} waypoints{}", path.length, state.preview_path.len(), - path.used_link_actor_id - .as_deref() - .map(|id| format!(", link {id}")) - .unwrap_or_default() + if path.used_link_actor_ids.is_empty() { + String::new() + } else { + format!(", links {}", path.used_link_actor_ids.join(", ")) + } ); } Err(error) => set_status(world, format!("Path test failed: {error}"), true), @@ -433,109 +606,38 @@ fn build_input( .as_ref() .cloned() .ok_or_else(|| "save the scene before baking navigation".to_string())?; - let project_root = Path::new(&world.resource::().root); - let source_scene = active_path - .strip_prefix(project_root) - .unwrap_or(&active_path) - .to_string_lossy() - .replace('\\', "/"); + let project_root = PathBuf::from(&world.resource::().root); let bounds_actor_id = actor_id(world, bounds_entity)?; - let bounds_transform = world_transform(world, bounds_entity); - let center = bounds_transform.translation.to_array(); - let half_extents = world_aabb_half_extents(bounds_transform, bounds.half_extents).to_array(); - - let mut obstacles = Vec::new(); - let mut obstacle_query = world.query::<(Entity, &NavigationObstacle)>(); - for (entity, obstacle) in obstacle_query.iter(world) { - let value = NavigationVolumeInput { - actor_id: actor_id(world, entity)?, - center: world_position(world, entity).to_array(), - half_extents: world_aabb_half_extents( - world_transform(world, entity), - obstacle.half_extents, - ) - .to_array(), - }; - if aabb_overlaps(center, half_extents, value.center, value.half_extents) { - obstacles.push(value); - } + let snapshot = crate::scene_io::serialize_active_scene(world) + .map_err(|error| format!("could not snapshot authored scene for navigation: {error}"))?; + let jobs = scene::navigation::resolve_navigation_bake_jobs( + &project_root, + &active_path, + Some(&snapshot), + )?; + let selected_artifact = shared::navigation_generated_artifact_path(&bounds.artifact_path) + .map_err(str::to_string)?; + let duplicate_owners = jobs + .iter() + .filter(|job| { + shared::navigation_generated_artifact_path(&job.artifact_path) + .is_ok_and(|path| path == selected_artifact) + }) + .map(|job| job.input.bounds_actor_id.as_str()) + .collect::>(); + if duplicate_owners.len() > 1 { + return Err(format!( + "navigation artifact `{selected_artifact}` has duplicate bounds owners: {}. Assign a unique generated path to each bounds actor before baking", + duplicate_owners.join(", ") + )); } - obstacles.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); - - let mut areas = Vec::new(); - let mut area_query = world.query::<(Entity, &NavigationArea)>(); - for (entity, area) in area_query.iter(world) { - let value = NavigationAreaInput { - actor_id: actor_id(world, entity)?, - id: area.id.clone(), - center: world_position(world, entity).to_array(), - half_extents: world_aabb_half_extents( - world_transform(world, entity), - area.half_extents, - ) - .to_array(), - cost: area.cost, - walkable: area.walkable, - }; - if aabb_overlaps(center, half_extents, value.center, value.half_extents) { - areas.push(value); - } - } - areas.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); - - let mut links = Vec::new(); - let mut link_query = world.query::<(Entity, &NavigationLink)>(); - for (entity, link) in link_query.iter(world) { - let transform = world_transform(world, entity); - let value = NavigationLinkInput { - actor_id: actor_id(world, entity)?, - start: transform.transform_point(link.start).to_array(), - end: transform.transform_point(link.end).to_array(), - bidirectional: link.bidirectional, - cost: link.cost, - enabled: link.enabled, - }; - if point_in_aabb(value.start, center, half_extents) - || point_in_aabb(value.end, center, half_extents) - { - links.push(value); - } - } - links.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); - - let mut geometry = Vec::new(); - let mut geometry_query = world.query::<(Entity, Option<&Primitive>, Option<&BrushDesc>)>(); - for (entity, primitive, brush) in geometry_query.iter(world) { - if primitive.is_none() && brush.is_none() { - continue; - } - let transform = world_transform(world, entity); - let Some(value) = navigation_geometry_from_parts( - actor_id(world, entity)?, - transform.translation.to_array(), - transform.rotation.to_array(), - transform.scale.to_array(), - primitive, - brush, - ) else { - continue; - }; - if geometry_overlaps_bounds(&value, center, half_extents) { - geometry.push(value); - } - } - geometry.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); - - Ok(NavigationBakeInput { - source_scene, - bounds_actor_id, - center, - half_extents, - agent: bounds.agent.clone(), - geometry, - obstacles, - areas, - links, + jobs.into_iter() + .find(|job| job.input.bounds_actor_id == bounds_actor_id) + .map(|job| job.input) + .ok_or_else(|| { + format!( + "navigation bounds `{bounds_actor_id}` is not owned by the active scene; open its source scene to bake it" + ) }) } @@ -547,7 +649,7 @@ fn actor_id(world: &World, entity: Entity) -> Result { .ok_or_else(|| format!("navigation actor {entity:?} requires a stable ActorId")) } -fn world_transform(world: &World, entity: Entity) -> Transform { +fn world_affine(world: &World, entity: Entity) -> Affine3A { let mut chain = Vec::new(); let mut current = entity; let mut visited = HashSet::new(); @@ -559,7 +661,7 @@ fn world_transform(world: &World, entity: Entity) -> Transform { current = parent; } if world.get::(current).is_some() { - return Transform::default(); + return Affine3A::IDENTITY; } let mut chain = chain.into_iter().rev(); let root = chain.next().unwrap_or_default(); @@ -567,39 +669,167 @@ fn world_transform(world: &World, entity: Entity) -> Transform { .fold(GlobalTransform::from(root), |global, local| { global.mul_transform(local) }) - .compute_transform() + .affine() } -fn world_position(world: &World, entity: Entity) -> Vec3 { - world_transform(world, entity).translation -} - -fn world_aabb_half_extents(transform: Transform, local_half_extents: Vec3) -> Vec3 { - let half = transform.scale.abs() * local_half_extents; - let x = transform.rotation * Vec3::X; - let y = transform.rotation * Vec3::Y; - let z = transform.rotation * Vec3::Z; - x.abs() * half.x + y.abs() * half.y + z.abs() * half.z +pub(crate) fn world_aabb_half_extents(transform: Affine3A, local_half_extents: Vec3) -> Vec3 { + let x = Vec3::from(transform.matrix3.x_axis).abs() * local_half_extents.x; + let y = Vec3::from(transform.matrix3.y_axis).abs() * local_half_extents.y; + let z = Vec3::from(transform.matrix3.z_axis).abs() * local_half_extents.z; + x + y + z } fn artifact_path(world: &World, authored: &str) -> Result { - let relative = Path::new(authored); - if relative.is_absolute() - || relative - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { - return Err("navigation artifact path must stay inside the project".into()); - } - if !authored - .replace('\\', "/") - .starts_with("assets/navigation/generated/") - { - return Err("navigation artifacts must be under assets/navigation/generated/".into()); - } + let relative = shared::navigation_generated_artifact_path(authored).map_err(str::to_string)?; Ok(Path::new(&world.resource::().root).join(relative)) } +const ARTIFACT_POLL_INTERVAL_SECS: f32 = 0.5; +const INPUT_REFRESH_INTERVAL_SECS: f32 = 1.0; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ArtifactDiskRevision { + Missing, + Present(blake3::Hash), + Unreadable(ErrorKind), +} + +fn artifact_disk_revision(path: &Path) -> ArtifactDiskRevision { + match std::fs::read(path) { + Ok(bytes) => ArtifactDiskRevision::Present(blake3::hash(&bytes)), + Err(error) if error.kind() == ErrorKind::NotFound => ArtifactDiskRevision::Missing, + Err(error) => ArtifactDiskRevision::Unreadable(error.kind()), + } +} + +fn artifact_source_matches( + world: &World, + state: &NavigationEditorState, + entity: Entity, + bounds: &NavigationBounds, +) -> bool { + let resolved = artifact_path(world, &bounds.artifact_path).ok(); + state.bounds_entity == Some(entity) + && state.artifact_authored_path.as_deref() == Some(bounds.artifact_path.as_str()) + && state.artifact_resolved_path == resolved +} + +/// Refreshes the selected artifact from its authoritative disk path. +/// +/// Identity changes are checked immediately. `poll_disk` enables the periodic content-hash check +/// used to notice external deletes and rebakes without hashing a potentially large artifact every +/// render frame. +fn refresh_artifact_source( + world: &mut World, + entity: Entity, + bounds: &NavigationBounds, + poll_disk: bool, +) -> bool { + let resolved_result = artifact_path(world, &bounds.artifact_path); + let resolved_path = resolved_result.as_ref().ok().cloned(); + let identity_changed = { + let state = world.resource::(); + state.bounds_entity != Some(entity) + || state.artifact_authored_path.as_deref() != Some(bounds.artifact_path.as_str()) + || state.artifact_resolved_path != resolved_path + }; + if !identity_changed && !poll_disk { + return false; + } + + let disk_revision = resolved_path.as_deref().map(artifact_disk_revision); + let source_changed = identity_changed + || world + .resource::() + .artifact_disk_revision + != disk_revision; + { + let mut state = world.resource_mut::(); + state.artifact_poll_elapsed = 0.0; + } + if !source_changed { + return false; + } + + let (artifact, status, error) = match (&resolved_result, &disk_revision) { + (Err(error), _) => ( + None, + format!("Navigation artifact unavailable: {error}"), + true, + ), + (Ok(_), Some(ArtifactDiskRevision::Missing)) => ( + None, + format!("Artifact missing: {}; bake required", bounds.artifact_path), + false, + ), + (Ok(path), Some(ArtifactDiskRevision::Unreadable(kind))) => ( + None, + format!("Navigation artifact unavailable: failed to read {path:?}: {kind:?}"), + true, + ), + (Ok(path), Some(ArtifactDiskRevision::Present(_))) => { + match read_navigation_artifact(path) { + Ok(artifact) => ( + Some(artifact), + format!("Loaded: {}", bounds.artifact_path), + false, + ), + Err(error) => ( + None, + format!("Navigation artifact unavailable: {error}"), + true, + ), + } + } + (Ok(_), None) => unreachable!("resolved artifacts always have a disk revision"), + }; + + let mut state = world.resource_mut::(); + state.bounds_entity = Some(entity); + state.artifact_authored_path = Some(bounds.artifact_path.clone()); + state.artifact_resolved_path = resolved_path; + state.artifact_disk_revision = disk_revision; + state.artifact = artifact; + state.preview_path.clear(); + state.status = status; + state.error = error; + state.observed_fingerprint = None; + state.auto_bake_attempt = None; + state.debounce_elapsed = 0.0; + true +} + +fn clear_artifact_selection(world: &mut World) { + let should_clear = { + let state = world.resource::(); + state.bounds_entity.is_some() + || state.artifact.is_some() + || state.artifact_authored_path.is_some() + || state.input_bounds_entity.is_some() + }; + if !should_clear { + return; + } + let mut state = world.resource_mut::(); + state.bounds_entity = None; + state.artifact = None; + state.artifact_authored_path = None; + state.artifact_resolved_path = None; + state.artifact_disk_revision = None; + state.artifact_poll_elapsed = 0.0; + state.input_bounds_entity = None; + state.input_scene_revision = 0; + state.input_refresh_elapsed = 0.0; + state.current_input = None; + state.current_input_error = None; + state.preview_path.clear(); + state.status = "Select Navigation Bounds to inspect a bake".into(); + state.error = false; + state.observed_fingerprint = None; + state.auto_bake_attempt = None; + state.debounce_elapsed = 0.0; +} + fn set_status(world: &mut World, message: impl Into, error: bool) { let message = message.into(); let mut state = world.resource_mut::(); @@ -681,31 +911,73 @@ pub fn sync_navigation_state(world: &mut World) { let selected = world.resource::().0; let Some(entity) = selected.filter(|entity| world.get::(*entity).is_some()) else { + clear_artifact_selection(world); return; }; let Some(bounds) = world.get::(entity).cloned() else { return; }; - let input = match build_input(world, entity, &bounds) { - Ok(input) => input, - Err(_) => return, - }; - let fingerprint = navigation_source_fingerprint(&input); - let changed_bounds = world.resource::().bounds_entity != Some(entity); - if changed_bounds { - let loaded = artifact_path(world, &bounds.artifact_path) - .ok() - .and_then(|path| read_navigation_artifact(&path).ok()); - let mut state = world.resource_mut::(); - state.bounds_entity = Some(entity); - state.artifact = loaded; - state.preview_path.clear(); - state.observed_fingerprint = None; - state.auto_bake_attempt = None; - state.debounce_elapsed = 0.0; - } - let delta = world.resource::