Ship production navigation authoring workflow
This commit is contained in:
parent
0798aa5d57
commit
b4aa61e394
@ -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
|
||||
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
|
||||
@ -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" }
|
||||
|
||||
11
README.md
11
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))
|
||||
|
||||
@ -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)),
|
||||
}),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
@ -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<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 {
|
||||
pub fn set_texture(&mut self, index: usize, handle: Option<Handle<Image>>) {
|
||||
match index {
|
||||
@ -170,8 +153,8 @@ pub struct SurfaceMaterialCache {
|
||||
}
|
||||
|
||||
enum BuiltRendererMaterial {
|
||||
Standard(StandardMaterial),
|
||||
Surface(SurfaceMaterial),
|
||||
Standard(Box<StandardMaterial>),
|
||||
Surface(Box<SurfaceMaterial>),
|
||||
}
|
||||
|
||||
#[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<AssetServer>,
|
||||
@ -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<Vec<String>, String> {
|
||||
|
||||
@ -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::<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]
|
||||
fn linked_members_reject_direct_history_mutations() {
|
||||
let mut world = World::new();
|
||||
|
||||
@ -88,6 +88,7 @@ pub struct SceneIo {
|
||||
pub events: VecDeque<SceneIoEvent>,
|
||||
pub tabs: Vec<SceneTab>,
|
||||
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<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);
|
||||
if world
|
||||
.resource::<SceneIo>()
|
||||
@ -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(),
|
||||
|
||||
@ -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::<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(
|
||||
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::<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(¤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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -154,33 +154,45 @@ fn draw_navigation_visualizers(
|
||||
links: Query<(&NavigationLink, &GlobalTransform), With<LevelObject>>,
|
||||
) {
|
||||
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(
|
||||
&mut gizmos,
|
||||
center,
|
||||
rotation,
|
||||
value.half_extents,
|
||||
Quat::IDENTITY,
|
||||
half_extents,
|
||||
Color::srgba(0.12, 0.82, 0.78, 0.72),
|
||||
);
|
||||
}
|
||||
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(
|
||||
&mut gizmos,
|
||||
center,
|
||||
rotation,
|
||||
value.half_extents,
|
||||
Quat::IDENTITY,
|
||||
half_extents,
|
||||
Color::srgba(1.0, 0.28, 0.22, 0.78),
|
||||
);
|
||||
}
|
||||
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 {
|
||||
Color::srgba(0.32, 0.72, 1.0, 0.62)
|
||||
} else {
|
||||
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 {
|
||||
let start = global.transform_point(value.start);
|
||||
|
||||
@ -2,6 +2,33 @@ use bevy::prelude::*;
|
||||
use game::{launch, GamePlugin};
|
||||
|
||||
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()
|
||||
.add_plugins(launch::default_plugins("Bevy FPS Foundation"))
|
||||
.add_plugins(GamePlugin)
|
||||
|
||||
@ -1,172 +1,63 @@
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bevy::prelude::Vec3;
|
||||
use nav_glam::{Vec2 as NavVec2, Vec3 as NavVec3};
|
||||
use polyanya::Mesh;
|
||||
use scene::navigation::{read_navigation_artifact, NavigationBakeArtifact, NavigationLinkInput};
|
||||
use scene::navigation::{NavigationBakeArtifact, NavigationQueryPath, NavigationQueryRuntime};
|
||||
|
||||
/// Loaded navigation artifact and its baked Polyanya acceleration data.
|
||||
/// Loaded navigation artifact and its shared Polyanya query runtime.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NavigationRuntime {
|
||||
artifact: NavigationBakeArtifact,
|
||||
mesh: Mesh,
|
||||
query: NavigationQueryRuntime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NavigationPath {
|
||||
pub points: Vec<Vec3>,
|
||||
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 {
|
||||
pub fn from_artifact(artifact: NavigationBakeArtifact) -> Result<Self, String> {
|
||||
if artifact.mesh.layers.is_empty()
|
||||
|| artifact
|
||||
.mesh
|
||||
.layers
|
||||
.iter()
|
||||
.all(|layer| layer.polygons.is_empty())
|
||||
{
|
||||
return Err("navigation artifact contains no walkable polygons; rebake it".into());
|
||||
}
|
||||
let mesh = artifact.runtime_mesh();
|
||||
Ok(Self { artifact, mesh })
|
||||
Ok(Self {
|
||||
query: NavigationQueryRuntime::from_artifact(artifact)?,
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
&self.artifact
|
||||
self.query.artifact()
|
||||
}
|
||||
|
||||
pub fn query(&self, start: Vec3, end: Vec3) -> Result<NavigationPath, String> {
|
||||
if !start.is_finite() || !end.is_finite() {
|
||||
return Err("navigation query endpoints must be finite".into());
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
self.query
|
||||
.query(start.to_array(), end.to_array())
|
||||
.map(NavigationPath::from)
|
||||
}
|
||||
|
||||
pub fn validate_links(&self) -> Vec<String> {
|
||||
let mut findings = Vec::new();
|
||||
for link in self.artifact.links.iter().filter(|link| link.enabled) {
|
||||
for (label, endpoint) in [("start", link.start), ("end", link.end)] {
|
||||
let 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
|
||||
self.query.validate_links()
|
||||
}
|
||||
|
||||
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(
|
||||
@ -177,15 +68,85 @@ pub fn query_navigation_path(
|
||||
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)]
|
||||
mod tests {
|
||||
use scene::navigation::{
|
||||
bake_navigation, NavigationBakeInput, NavigationLinkInput, NavigationVolumeInput,
|
||||
bake_navigation, NavigationBakeInput, NavigationGeometryInput, NavigationLinkInput,
|
||||
NavigationVolumeInput,
|
||||
};
|
||||
use shared::NavigationAgentProfile;
|
||||
|
||||
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 {
|
||||
NavigationBakeInput {
|
||||
source_scene: "assets/levels/navigation_showcase.scn.ron".into(),
|
||||
@ -197,7 +158,7 @@ mod tests {
|
||||
merge_region_size: 2,
|
||||
..Default::default()
|
||||
},
|
||||
geometry: Vec::new(),
|
||||
geometry: vec![floor_geometry("floor", -6.0, 6.0, -6.0, 6.0, 0.0)],
|
||||
obstacles: vec![NavigationVolumeInput {
|
||||
actor_id: "wall".into(),
|
||||
center: [0.0, 0.5, 0.0],
|
||||
@ -205,6 +166,7 @@ mod tests {
|
||||
}],
|
||||
areas: Vec::new(),
|
||||
links: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -234,7 +196,65 @@ mod tests {
|
||||
let path = runtime
|
||||
.query(Vec3::new(-4.0, 0.0, 0.0), Vec3::new(4.0, 0.0, 0.0))
|
||||
.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]
|
||||
@ -254,4 +274,21 @@ mod tests {
|
||||
.iter()
|
||||
.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
@ -15,6 +15,7 @@ use shared::{
|
||||
PostProcessVolumeDesc, PrefabInstance, PrefabRef, RendererMaterialSet, ShaderSchemaAsset,
|
||||
SkinnedMeshRenderer, StaticMeshRenderer, ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
AUDIO_CLIP_SUB_ASSET_ID, COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_SKINNED_MESH_RENDERER,
|
||||
NAVIGATION_GENERATED_ARTIFACT_DIRECTORY,
|
||||
};
|
||||
|
||||
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| {
|
||||
(
|
||||
&left.owner_path,
|
||||
@ -1456,7 +1459,11 @@ fn validate_navigation_document(
|
||||
document: &SceneDocument,
|
||||
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,
|
||||
Err(error) => {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
@ -1473,19 +1480,66 @@ fn validate_navigation_document(
|
||||
};
|
||||
|
||||
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 {
|
||||
owner_path: source_path.to_string(),
|
||||
owner_actor_id: Some(job.input.bounds_actor_id.clone()),
|
||||
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(
|
||||
report,
|
||||
source_path,
|
||||
&job.input.bounds_actor_id,
|
||||
"navigation.artifact_unsafe_path",
|
||||
&job.artifact_path,
|
||||
&normalized_artifact_path,
|
||||
"navigation artifact path escapes the project",
|
||||
"Choose an artifact path under assets/navigation/generated/.",
|
||||
);
|
||||
@ -1497,7 +1551,7 @@ fn validate_navigation_document(
|
||||
source_path,
|
||||
&job.input.bounds_actor_id,
|
||||
"navigation.artifact_missing",
|
||||
&job.artifact_path,
|
||||
&normalized_artifact_path,
|
||||
"navigation bounds has no baked artifact",
|
||||
"Select the bounds actor and run Scene > Navigation > Bake Selected Bounds.",
|
||||
);
|
||||
@ -1511,47 +1565,141 @@ fn validate_navigation_document(
|
||||
source_path,
|
||||
&job.input.bounds_actor_id,
|
||||
"navigation.artifact_invalid",
|
||||
&job.artifact_path,
|
||||
&normalized_artifact_path,
|
||||
&error,
|
||||
"Delete the invalid generated artifact and rebake the bounds actor.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if artifact.is_stale_for(&job.input) {
|
||||
let stale = artifact.is_stale_for(&job.input);
|
||||
if stale {
|
||||
push_navigation_finding(
|
||||
report,
|
||||
source_path,
|
||||
&job.input.bounds_actor_id,
|
||||
"navigation.artifact_stale",
|
||||
&job.artifact_path,
|
||||
&normalized_artifact_path,
|
||||
"navigation artifact fingerprint does not match authored navigation data",
|
||||
"Rebake navigation after the authored geometry or agent-profile change.",
|
||||
);
|
||||
}
|
||||
|
||||
let mesh = artifact.runtime_mesh();
|
||||
for link in artifact.links.iter().filter(|link| link.enabled) {
|
||||
for (label, endpoint) in [("start", link.start), ("end", link.end)] {
|
||||
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 {
|
||||
} else {
|
||||
let stored_hash = crate::navigation::navigation_artifact_content_hash(&artifact);
|
||||
let fresh_hash = crate::navigation::navigation_artifact_content_hash(&fresh_artifact);
|
||||
match (stored_hash, fresh_hash) {
|
||||
(Ok(stored_hash), Ok(fresh_hash)) if stored_hash != fresh_hash => {
|
||||
push_navigation_finding(
|
||||
report,
|
||||
source_path,
|
||||
&link.actor_id,
|
||||
"navigation.link_unreachable",
|
||||
&job.artifact_path,
|
||||
&format!(
|
||||
"navigation link {label} endpoint is farther than two agent radii from the baked mesh"
|
||||
),
|
||||
"Move the endpoint onto a visible navigation polygon and rebake.",
|
||||
&job.input.bounds_actor_id,
|
||||
"navigation.artifact_content_mismatch",
|
||||
&normalized_artifact_path,
|
||||
"stored navigation artifact differs from a deterministic fresh bake despite a current source fingerprint",
|
||||
"Rebake this bounds actor and commit the regenerated artifact.",
|
||||
);
|
||||
}
|
||||
(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();
|
||||
let bounds_ron = ron::to_string(&bounds).unwrap();
|
||||
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();
|
||||
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]
|
||||
fn missing_navigation_artifact_blocks_release_with_bounds_owner() {
|
||||
let root = fixture_root();
|
||||
@ -3057,6 +3241,208 @@ mod tests {
|
||||
.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]
|
||||
fn valid_animation_controller_collects_stable_artifact_dependencies() {
|
||||
let root = fixture_root();
|
||||
|
||||
@ -8,6 +8,7 @@ description = "Shared reflectable authoring types and scene hydration for the FP
|
||||
[dependencies]
|
||||
avian3d.workspace = true
|
||||
bevy.workspace = true
|
||||
blake3 = "1"
|
||||
ron = "0.8"
|
||||
serde.workspace = true
|
||||
settings.workspace = true
|
||||
|
||||
@ -321,10 +321,26 @@ fn validate_navigation_actor(entity: EntityRef<'_>) -> Result<(), ActorValidatio
|
||||
.agent
|
||||
.validate()
|
||||
.map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?;
|
||||
if bounds.artifact_path.trim().is_empty() {
|
||||
return Err(ActorValidationError::InvalidNavigation(
|
||||
"navigation artifact path must not be empty".into(),
|
||||
));
|
||||
crate::navigation_generated_artifact_path(&bounds.artifact_path)
|
||||
.map_err(|error| ActorValidationError::InvalidNavigation(error.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>() {
|
||||
@ -832,4 +848,37 @@ mod tests {
|
||||
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/")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -615,8 +615,8 @@ pub enum PrimitiveShape {
|
||||
}
|
||||
|
||||
/// Reflectable authoring primitive. Hydration turns this into `Mesh3d`.
|
||||
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
|
||||
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
|
||||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Primitive {
|
||||
pub shape: PrimitiveShape,
|
||||
pub size: Vec3,
|
||||
|
||||
@ -147,23 +147,20 @@ pub fn flush_level_object_hydration(world: &mut World) {
|
||||
Option<&InspectorOrder>,
|
||||
), With<LevelObject>>()
|
||||
.iter(world)
|
||||
.filter_map(|(entity, brush, material, collider, states, order)| {
|
||||
authoring_component_active(states, order, COMPONENT_BRUSH_DESC).then(|| {
|
||||
(
|
||||
entity,
|
||||
brush.clone(),
|
||||
material
|
||||
.filter(|_| {
|
||||
authoring_component_active(states, order, COMPONENT_MATERIAL_DESC)
|
||||
})
|
||||
.cloned(),
|
||||
collider
|
||||
.filter(|_| {
|
||||
authoring_component_active(states, order, COMPONENT_COLLIDER_DESC)
|
||||
})
|
||||
.cloned(),
|
||||
)
|
||||
})
|
||||
.filter(|(_, _, _, _, states, order)| {
|
||||
authoring_component_active(*states, *order, COMPONENT_BRUSH_DESC)
|
||||
})
|
||||
.map(|(entity, brush, material, collider, states, order)| {
|
||||
(
|
||||
entity,
|
||||
brush.clone(),
|
||||
material
|
||||
.filter(|_| authoring_component_active(states, order, COMPONENT_MATERIAL_DESC))
|
||||
.cloned(),
|
||||
collider
|
||||
.filter(|_| authoring_component_active(states, order, COMPONENT_COLLIDER_DESC))
|
||||
.cloned(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -190,34 +187,25 @@ pub fn flush_level_object_hydration(world: &mut World) {
|
||||
Option<&InspectorOrder>,
|
||||
), With<LevelObject>>()
|
||||
.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)| {
|
||||
authoring_component_active(states, order, COMPONENT_STATIC_MESH_RENDERER).then(
|
||||
|| {
|
||||
(
|
||||
entity,
|
||||
renderer.clone(),
|
||||
material
|
||||
.filter(|_| {
|
||||
authoring_component_active(
|
||||
states,
|
||||
order,
|
||||
COMPONENT_MATERIAL_DESC,
|
||||
)
|
||||
})
|
||||
.cloned(),
|
||||
material_override.cloned(),
|
||||
collider
|
||||
.filter(|_| {
|
||||
authoring_component_active(
|
||||
states,
|
||||
order,
|
||||
COMPONENT_COLLIDER_DESC,
|
||||
)
|
||||
})
|
||||
.cloned(),
|
||||
)
|
||||
},
|
||||
(
|
||||
entity,
|
||||
renderer.clone(),
|
||||
material
|
||||
.filter(|_| {
|
||||
authoring_component_active(states, order, COMPONENT_MATERIAL_DESC)
|
||||
})
|
||||
.cloned(),
|
||||
material_override.cloned(),
|
||||
collider
|
||||
.filter(|_| {
|
||||
authoring_component_active(states, order, COMPONENT_COLLIDER_DESC)
|
||||
})
|
||||
.cloned(),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@ -361,6 +361,7 @@ pub fn despawn_static_mesh_parts(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn material_for_entry(
|
||||
asset_server: &AssetServer,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
|
||||
@ -32,9 +32,10 @@ pub use material_asset::{
|
||||
pub use navigation::*;
|
||||
pub use post_process_effect_asset::{PostProcessEffectAsset, PostProcessEffectKind};
|
||||
pub use prefab_overrides::{
|
||||
apply_prefab_overrides_on_ready, decode_prefab_overrides, encode_prefab_overrides,
|
||||
PrefabActorPath, PrefabComponentOverride, PrefabOverrides, PrefabPropertyOverride,
|
||||
PrefabStructuralOverride, PREFAB_OVERRIDE_FORMAT_VERSION,
|
||||
apply_prefab_overrides_on_ready, apply_serialized_navigation_property_override,
|
||||
decode_prefab_overrides, encode_prefab_overrides, PrefabActorPath, PrefabComponentOverride,
|
||||
PrefabOverrides, PrefabPropertyOverride, PrefabStructuralOverride,
|
||||
PREFAB_OVERRIDE_FORMAT_VERSION,
|
||||
};
|
||||
pub use renderer_material::*;
|
||||
pub use rendering_profile_asset::RenderingProfileAsset;
|
||||
@ -94,6 +95,7 @@ impl Plugin for SharedTypesPlugin {
|
||||
.register_type::<AnimationStateDesc>()
|
||||
.register_type::<AnimationControllerDesc>()
|
||||
.register_type::<NavigationAgentProfile>()
|
||||
.register_type::<NavigationPathSample>()
|
||||
.register_type::<NavigationBounds>()
|
||||
.register_type::<NavigationObstacle>()
|
||||
.register_type::<NavigationArea>()
|
||||
@ -149,6 +151,7 @@ impl Plugin for SharedTypesPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn migrate_legacy_authoring_component_states(
|
||||
mut commands: Commands,
|
||||
mut legacy: Query<
|
||||
|
||||
@ -191,6 +191,15 @@ pub struct ShaderSchemaAsset {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -262,12 +271,3 @@ mod tests {
|
||||
.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}"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use bevy::prelude::*;
|
||||
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_OBSTACLE: &str = "shared::navigation::NavigationObstacle";
|
||||
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) {
|
||||
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 {
|
||||
return Err("cell size fraction must be finite and greater than zero");
|
||||
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 in [1, 32]");
|
||||
}
|
||||
if !self.cell_height_fraction.is_finite() || self.cell_height_fraction <= 0.0 {
|
||||
return Err("cell height fraction must be finite and greater than zero");
|
||||
if !self.cell_height_fraction.is_finite()
|
||||
|| !(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(())
|
||||
}
|
||||
@ -73,6 +87,8 @@ pub struct NavigationBounds {
|
||||
pub artifact_path: String,
|
||||
#[serde(default)]
|
||||
pub auto_bake: bool,
|
||||
#[serde(default)]
|
||||
pub validation_samples: Vec<NavigationPathSample>,
|
||||
}
|
||||
|
||||
impl Default for NavigationBounds {
|
||||
@ -82,10 +98,111 @@ impl Default for NavigationBounds {
|
||||
agent: NavigationAgentProfile::default(),
|
||||
artifact_path: "assets/navigation/generated/main_humanoid.nav.ron".into(),
|
||||
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.
|
||||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@ -162,7 +279,10 @@ fn default_one() -> f32 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::NavigationAgentProfile;
|
||||
use super::{
|
||||
navigation_artifact_path_for_actor, navigation_generated_artifact_path,
|
||||
NavigationAgentProfile, NAVIGATION_GENERATED_ARTIFACT_DIRECTORY,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn default_agent_profile_is_valid() {
|
||||
@ -178,4 +298,59 @@ mod tests {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ use bevy::prelude::*;
|
||||
use bevy::reflect::serde::TypedReflectDeserializer;
|
||||
use bevy::reflect::{GetPath, ParsedPath, ReflectPath};
|
||||
use bevy::world_serialization::{WorldInstanceReady, WorldInstanceSpawner};
|
||||
use serde::de::DeserializeSeed;
|
||||
use serde::de::{DeserializeOwned, DeserializeSeed};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{ActorId, EditorVisibility, HydratedPrefabMember, MaterialDesc, PrefabInstance};
|
||||
@ -198,6 +198,61 @@ pub fn validate_prefab_overrides_payload(overrides: &PrefabOverrides) -> Result<
|
||||
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(¤t)
|
||||
.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> {
|
||||
if overrides == &PrefabOverrides::default() {
|
||||
Ok(None)
|
||||
@ -572,6 +627,26 @@ mod tests {
|
||||
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(¤t).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]
|
||||
fn invalid_inner_override_payload_is_rejected_during_decode() {
|
||||
let mut instance = PrefabInstance::new("prefab", "assets/prefabs/test.scn.ron");
|
||||
|
||||
@ -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/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/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
|
||||
|
||||
## Working plans (not canonical long-term)
|
||||
|
||||
|
||||
@ -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
|
||||
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,
|
||||
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
|
||||
loading and path queries. Editor bake controls, overlays, and path preview call those shared/game
|
||||
contracts and keep all visual helpers transient. Relevant authored geometry changes mark the
|
||||
artifact stale; V1 debounces a full deterministic rebuild and reports the affected region until
|
||||
Rerecast provides production-ready tiled regeneration.
|
||||
The scene crate owns fingerprinting, bake artifact IO, the engine-independent query runtime, and
|
||||
validation. The game crate provides the Bevy `Vec3` adapter and renderer-free validation CLI.
|
||||
Editor bake controls, overlays, path preview, headless baking, and project validation call the same
|
||||
resolver/query contracts and keep all visual helpers transient. Relevant authored geometry changes
|
||||
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
|
||||
|
||||
- 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
|
||||
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
|
||||
partial tile rebuilding remain future work.
|
||||
- Primitive and additive-brush triangles are deterministic source geometry. Imported static meshes
|
||||
|
||||
@ -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 |
|
||||
| [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/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
|
||||
|
||||
## Subsystems (code → doc)
|
||||
|
||||
|
||||
64
docs/editor/evaluations/navigation-authoring/README.md
Normal file
64
docs/editor/evaluations/navigation-authoring/README.md
Normal 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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
BIN
docs/editor/evaluations/navigation-authoring/navigation-authoring-overview.png
(Stored with Git LFS)
Normal file
BIN
docs/editor/evaluations/navigation-authoring/navigation-authoring-overview.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
docs/editor/evaluations/navigation-authoring/navigation-path-preview.png
(Stored with Git LFS)
Normal file
BIN
docs/editor/evaluations/navigation-authoring/navigation-path-preview.png
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -1,15 +1,16 @@
|
||||
# Navigation Authoring
|
||||
|
||||
Blacksite navigation v1 uses authored bounds, obstacles, areas, and links to produce a deterministic
|
||||
Rerecast bake artifact. Editor preview and packaged gameplay query that artifact through the same
|
||||
`game::navigation::NavigationRuntime` Polyanya path API.
|
||||
Blacksite navigation v1 uses authored bounds, obstacles, areas, links, and validation samples to
|
||||
produce a deterministic Rerecast bake artifact. The scene crate owns one height-aware Polyanya
|
||||
query core; editor preview, project validation, and `game::navigation::NavigationRuntime` use that
|
||||
same implementation.
|
||||
|
||||
## 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,
|
||||
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 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.
|
||||
@ -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
|
||||
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
|
||||
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
|
||||
|
||||
@ -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.
|
||||
|
||||
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
|
||||
endpoint markers and an amber route show the exact result returned by the game runtime API.
|
||||
path test. Bounds, obstacles, and areas are shown as the same scaled, rotated world-axis-aligned
|
||||
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
|
||||
|
||||
@ -47,9 +73,14 @@ cargo bake-navigation --project .
|
||||
cargo bake-navigation --project . --check
|
||||
```
|
||||
|
||||
`--check` never writes. It fails when an artifact is missing, malformed, or stale, making it suitable
|
||||
for CI. `cargo validate-levels` applies the same fingerprint policy, validates link proximity, and
|
||||
adds current navigation artifacts to package dependencies.
|
||||
`--check` never writes. It fails when an artifact is missing, malformed, stale, generated by a
|
||||
different exact navigation toolchain, or differs from a fresh deterministic bake, making it
|
||||
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:
|
||||
|
||||
@ -58,8 +89,18 @@ let navigation = game::navigation::NavigationRuntime::load(path)?;
|
||||
let path = navigation.query(start, destination)?;
|
||||
```
|
||||
|
||||
The returned path contains world-space points, total length, and the optional stable actor ID of an
|
||||
off-mesh link used by the route.
|
||||
The returned path contains height-resolved world-space points, 3D total length, and the ordered
|
||||
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
|
||||
ownership decisions and [the implementation plan](../../.cursor/plans/navigation_authoring_2026-07-11.plan.md)
|
||||
|
||||
@ -147,7 +147,7 @@ with implementation sequencing in
|
||||
| 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 |
|
||||
| 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
|
||||
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)
|
||||
|
||||
@ -31,6 +31,7 @@ required-features = ["validate-levels"]
|
||||
[dependencies]
|
||||
walkdir = "2.5"
|
||||
scene = { path = "../crates/scene", optional = true }
|
||||
shared = { path = "../crates/shared", optional = true }
|
||||
settings = { workspace = true, optional = true }
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@ -42,4 +43,4 @@ libc = "0.2"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
validate-levels = ["dep:scene", "dep:settings"]
|
||||
validate-levels = ["dep:scene", "dep:settings", "dep:shared"]
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
//! Deterministically bake navigation artifacts from authored scene components.
|
||||
|
||||
use std::fs;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use scene::document::SceneDocument;
|
||||
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;
|
||||
|
||||
@ -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
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("could not resolve project root: {error}"))?;
|
||||
@ -48,41 +63,105 @@ fn run() -> Result<(), String> {
|
||||
None => discover_scenes(&project_root)?,
|
||||
};
|
||||
|
||||
let mut artifact_count = 0usize;
|
||||
let mut requests = Vec::new();
|
||||
for scene_path in scenes {
|
||||
let relative_scene = scene_path
|
||||
.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)
|
||||
let jobs = resolve_navigation_bake_jobs(&project_root, &scene_path, None)
|
||||
.map_err(|error| format!("{}: {error}", scene_path.display()))?;
|
||||
for job in navigation_bake_jobs_from_document(&document, &relative_scene)? {
|
||||
let artifact = bake_navigation(&job.input)?;
|
||||
for job in jobs {
|
||||
let output = safe_artifact_path(&project_root, &job.artifact_path)?;
|
||||
if check {
|
||||
let existing = scene::navigation::read_navigation_artifact(&output)?;
|
||||
if existing.is_stale_for(&job.input) {
|
||||
return Err(format!(
|
||||
"{} is stale for {}",
|
||||
output.display(),
|
||||
scene_path.display()
|
||||
));
|
||||
}
|
||||
println!("current {}", output.display());
|
||||
} else {
|
||||
write_navigation_artifact(&output, &artifact)?;
|
||||
println!("baked {}", output.display());
|
||||
}
|
||||
artifact_count += 1;
|
||||
requests.push(BakeRequest {
|
||||
source_path: scene_path.clone(),
|
||||
output,
|
||||
job,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reject_duplicate_artifact_ownership(&requests)?;
|
||||
let artifact_count = requests.len();
|
||||
for request in requests {
|
||||
if check {
|
||||
let fresh = bake_navigation(&request.job.input)
|
||||
.map_err(|error| owner_error(&request, &format!("rebake failed: {error}")))?;
|
||||
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");
|
||||
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> {
|
||||
let levels = project_root.join("assets/levels");
|
||||
if !levels.is_dir() {
|
||||
@ -94,19 +173,20 @@ fn discover_scenes(project_root: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| entry.file_type().is_file())
|
||||
.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();
|
||||
scenes.sort();
|
||||
Ok(scenes)
|
||||
}
|
||||
|
||||
fn safe_artifact_path(project_root: &Path, authored: &str) -> Result<PathBuf, String> {
|
||||
let normalized = authored.replace('\\', "/");
|
||||
if !normalized.starts_with("assets/navigation/generated/") {
|
||||
return Err(format!(
|
||||
"navigation artifact `{authored}` must be under assets/navigation/generated/"
|
||||
));
|
||||
}
|
||||
let normalized = shared::navigation_generated_artifact_path(authored)
|
||||
.map_err(|error| format!("navigation artifact `{authored}` is invalid: {error}"))?;
|
||||
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))
|
||||
}
|
||||
|
||||
#[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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user