diff --git a/.cursor/plans/collider_diagnostics_2026-07-12.plan.md b/.cursor/plans/collider_diagnostics_2026-07-12.plan.md new file mode 100644 index 0000000..a1a5a25 --- /dev/null +++ b/.cursor/plans/collider_diagnostics_2026-07-12.plan.md @@ -0,0 +1,31 @@ +# Collider Authoring Diagnostics - 2026-07-12 + +Issue: Gitea #26 (`BS-JD-305`) + +## Outcome + +Collider authoring exposes one consistent health result to the inspector, Collider viewport, +Diagnostics panel, and physics placement preflight. Shape changes preserve useful dimensions and +remain one typed undo operation. + +## Contract + +- Authored box, sphere, and capsule overlays include actor world scale. +- Static mesh colliders show cooked runtime bounds when available. +- Green is ready, cyan is a trigger, gray is disabled, amber is warning/stale, and red is invalid + or missing. +- Invalid dimensions and mesh references block dependent workflows. Missing hydration and + colliders over 1,000 meters in world extent are warnings. +- Physics placement consumes the same collider-health checks instead of maintaining a separate + prerequisite definition. + +## Work + +- [x] Add shared authored/runtime collider diagnostics and focused fixtures. +- [x] Add shape/status-aware viewport overlays and selected-collider HUD state. +- [x] Add inspector health/repair feedback and dimension-preserving undoable shape switching. +- [x] Add scene-wide selectable collider findings to Diagnostics. +- [x] Reuse diagnostics in physics placement prerequisites. +- [x] Add a committed non-blocking showcase scene and update documentation. +- [x] Run source gates and live Wayland acceptance; packaged tests remain skipped by request. +- [x] Commit, push, attach native Gitea evidence, close #26, and close M3 when empty. diff --git a/README.md b/README.md index cae46a0..e864106 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ deep-stale variants. | Brush selected: `1` / `2` / `3` / `4` | Vertex / edge / face / clip edit modes | | Brush edit mode: LMB / `Shift+LMB` / `W` / `E` / `R` / `Esc` | Select element / toggle element selection / move / rotate / scale selected brush elements / return to object mode | | Viewport toolbar (sun / brush / box icons) | Shading: Lit, Unlit (albedo), Colliders (mesh off) | +| Collider viewport mode | Inspect world-scale box/sphere/capsule overlays and cooked mesh bounds; green/cyan/gray/amber/red identify ready/trigger/disabled/warning/invalid state, with selected health in the lower-left HUD | | Viewport eye/options | Toggle actor root icon categories, adjust icon/gizmo size, and control colliders, lights, spawns, gameplay markers, volumes, prefab/model anchors, and runtime player/camera visualizers | | `Tab` in viewport | Cycle selection through overlapping objects at last click | | Viewport selection/orientation HUD | Identify the primary selection, multi-selection count, overlapping-pick position, camera axes, shading mode, and active render path | @@ -406,6 +407,7 @@ crates/ - [x] Modal terrain Raise/Lower/Flatten/Smooth/Noise sculpting with a terrain-following footprint, deterministic noise, safe cancel restore, and one undo transaction per stroke ([terrain guide](docs/editor/terrain.md), [Gitea #23](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/23)) - [x] Four shared terrain Material/Material Instance layers with compact normalized sample weights, blended raster hydration, drag/browse assignment, Paint/Erase preview, exact cancel, and one undo transaction per stroke ([ADR 0040](docs/adr/0040-terrain-material-layer-weights.md), [terrain guide](docs/editor/terrain.md), [Gitea #24](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/24)) - [x] Transactional physics placement with real Avian gravity/colliders, paused Edit-mode physics, prerequisite diagnostics, isolated non-selected bodies, exact cancel, and grouped transform undo ([ADR 0041](docs/adr/0041-transactional-editor-physics-placement.md), [workflow guide](docs/editor/physics-placement.md), [Gitea #25](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/25)) +- [x] Collider authoring health shared by the inspector, Collider viewport, Diagnostics panel, and physics placement, with scaled shape overlays, cooked mesh bounds, missing/stale/invalid/oversized findings, and undoable dimension-preserving shape switching ([collider guide](docs/editor/collider-authoring.md), [Gitea #26](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/26)) - [x] Non-blocking native file/folder/confirmation broker across scene, asset, prefab, composition, collaboration, and Project Browser workflows ([ADR 0038](docs/adr/0038-non-blocking-native-dialog-broker.md), [workflow guide](docs/editor/native-dialogs.md), [Gitea #52](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/52)) - [x] Asset import, static mesh/prefab placement, texture assignment, and selection export - [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop) diff --git a/assets/.index/registry.ron b/assets/.index/registry.ron index 9789b5c..e36143f 100644 --- a/assets/.index/registry.ron +++ b/assets/.index/registry.ron @@ -251,6 +251,24 @@ ), dependencies: [], ), + ( + id: ("a7ef65b4-24d6-4c3a-b637-5bb52d8e9a26"), + path: "assets/levels/collider_diagnostics_showcase.scn.ron", + label: "collider_diagnostics_showcase.scn", + kind_tag: "Level", + import_settings: ( + scale: 1.0, + generate_collider: true, + lod0_only: true, + placement_mode: StaticAsset, + hierarchy_mode: SingleActor, + material_policy: SourceMaterials, + static_mesh_manifest_path: None, + animation_manifest_path: None, + default_animation_clip_id: None, + ), + dependencies: [], + ), ( id: ("f3dfcd11-aa5b-4fd4-872b-06832b865c99"), path: "assets/levels/audio_authoring_showcase.scn.ron", diff --git a/assets/levels/collider_diagnostics_showcase.scn.ron b/assets/levels/collider_diagnostics_showcase.scn.ron new file mode 100644 index 0000000..6949d43 --- /dev/null +++ b/assets/levels/collider_diagnostics_showcase.scn.ron @@ -0,0 +1,175 @@ +(schema_version: 4, resources: {}, entities: { + 1: (components: { + "bevy_ecs::name::Name": "Valid Box Collider", + "bevy_transform::components::transform::Transform": ( + translation: (-4.5, 1.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("collider-showcase-box"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (0), + "shared::components::LevelObject": (), + "shared::components::MaterialDesc": ( + shader: (kind: StandardLit, schema_path: None, shader_path: None), + base_color: (r: 0.18, g: 0.62, b: 0.36, a: 1.0), + metallic: 0.05, roughness: 0.55, + emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, + base_color_texture: None, emissive_texture: None, normal_map_texture: None, + metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], + ), + "shared::components::Primitive": (shape: Box, size: (1.8, 2.0, 1.8)), + "shared::components::RigidBodyDesc": (body: Static), + "shared::components::ColliderDesc": ( + enabled: true, is_trigger: false, + shape: Cuboid(x_length: 1.8, y_length: 2.0, z_length: 1.8), + ), + }), + 2: (components: { + "bevy_ecs::name::Name": "Valid Sphere Collider", + "bevy_transform::components::transform::Transform": ( + translation: (-2.25, 1.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.35, 0.8), + ), + "shared::components::ActorId": ("collider-showcase-sphere"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (1), + "shared::components::LevelObject": (), + "shared::components::MaterialDesc": ( + shader: (kind: StandardLit, schema_path: None, shader_path: None), + base_color: (r: 0.16, g: 0.52, b: 0.78, a: 1.0), + metallic: 0.08, roughness: 0.42, + emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, + base_color_texture: None, emissive_texture: None, normal_map_texture: None, + metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], + ), + "shared::components::Primitive": (shape: Sphere, size: (2.0, 2.0, 2.0)), + "shared::components::RigidBodyDesc": (body: Static), + "shared::components::ColliderDesc": ( + enabled: true, is_trigger: false, shape: Sphere(radius: 1.0), + ), + }), + 3: (components: { + "bevy_ecs::name::Name": "Valid Capsule Collider", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 1.4, 0.0), + rotation: (0.0, 0.1950903, 0.0, 0.9807853), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("collider-showcase-capsule"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (2), + "shared::components::LevelObject": (), + "shared::components::MaterialDesc": ( + shader: (kind: StandardLit, schema_path: None, shader_path: None), + base_color: (r: 0.58, g: 0.34, b: 0.78, a: 1.0), + metallic: 0.06, roughness: 0.48, + emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, + base_color_texture: None, emissive_texture: None, normal_map_texture: None, + metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], + ), + "shared::components::Primitive": (shape: Box, size: (1.4, 2.8, 1.4)), + "shared::components::RigidBodyDesc": (body: Static), + "shared::components::ColliderDesc": ( + enabled: true, is_trigger: false, shape: Capsule(radius: 0.7, height: 2.8), + ), + }), + 4: (components: { + "bevy_ecs::name::Name": "Trigger Collider", + "bevy_transform::components::transform::Transform": ( + translation: (2.25, 1.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("collider-showcase-trigger"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (3), + "shared::components::LevelObject": (), + "shared::components::MaterialDesc": ( + shader: (kind: StandardLit, schema_path: None, shader_path: None), + base_color: (r: 0.12, g: 0.68, b: 0.82, a: 1.0), + metallic: 0.03, roughness: 0.4, + emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, + base_color_texture: None, emissive_texture: None, normal_map_texture: None, + metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], + ), + "shared::components::Primitive": (shape: Sphere, size: (2.0, 2.0, 2.0)), + "shared::components::RigidBodyDesc": (body: Static), + "shared::components::ColliderDesc": ( + enabled: true, is_trigger: true, shape: Sphere(radius: 1.0), + ), + }), + 5: (components: { + "bevy_ecs::name::Name": "Disabled Collider", + "bevy_transform::components::transform::Transform": ( + translation: (4.5, 1.0, 0.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("collider-showcase-disabled"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (4), + "shared::components::LevelObject": (), + "shared::components::MaterialDesc": ( + shader: (kind: StandardLit, schema_path: None, shader_path: None), + base_color: (r: 0.38, g: 0.40, b: 0.46, a: 1.0), + metallic: 0.02, roughness: 0.68, + emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, + base_color_texture: None, emissive_texture: None, normal_map_texture: None, + metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], + ), + "shared::components::Primitive": (shape: Box, size: (1.8, 2.0, 1.8)), + "shared::components::RigidBodyDesc": (body: Static), + "shared::components::ColliderDesc": ( + enabled: false, is_trigger: false, + shape: Cuboid(x_length: 1.8, y_length: 2.0, z_length: 1.8), + ), + }), + 6: (components: { + "bevy_ecs::name::Name": "Missing Collider", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 1.0, -3.0), + rotation: (0.0, 0.0, 0.0, 1.0), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("collider-showcase-missing"), + "shared::components::ActorKind": StaticMesh, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (5), + "shared::components::LevelObject": (), + "shared::components::MaterialDesc": ( + shader: (kind: StandardLit, schema_path: None, shader_path: None), + base_color: (r: 0.78, g: 0.16, b: 0.20, a: 1.0), + metallic: 0.02, roughness: 0.58, + emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), emissive_intensity: 0.0, + base_color_texture: None, emissive_texture: None, normal_map_texture: None, + metallic_roughness_texture: None, material_asset_path: None, parameters: [], textures: [], + ), + "shared::components::Primitive": (shape: Box, size: (1.8, 2.0, 1.8)), + "shared::components::RigidBodyDesc": (body: Static), + }), + 7: (components: { + "bevy_ecs::name::Name": "Collider Showcase Sun", + "bevy_transform::components::transform::Transform": ( + translation: (0.0, 8.0, 4.0), + rotation: (-0.3826834, 0.0, 0.0, 0.9238795), + scale: (1.0, 1.0, 1.0), + ), + "shared::components::ActorId": ("collider-showcase-sun"), + "shared::components::ActorKind": Light, + "shared::components::EditorVisibility": (visible: true), + "shared::components::HierarchySiblingIndex": (6), + "shared::components::LevelObject": (), + "shared::components::LightDesc": ( + kind: Directional, color: (r: 1.0, g: 0.95, b: 0.88, a: 1.0), + intensity: 100000.0, range: 0.0, shadows: true, + inner_angle_deg: 25.0, outer_angle_deg: 35.0, + ), + }), +}) diff --git a/crates/editor/src/ui/diagnostics.rs b/crates/editor/src/ui/diagnostics.rs index 7236ae7..f96f4ef 100644 --- a/crates/editor/src/ui/diagnostics.rs +++ b/crates/editor/src/ui/diagnostics.rs @@ -15,6 +15,9 @@ use crate::project_io::ProjectWorkspace; use crate::scene_io::{SceneIo, SceneIoEventSeverity}; use crate::selection::SelectedEntity; use crate::ui::UiState; +use crate::viewport::collider_diagnostics::{ + ColliderDiagnosticSeverity, ColliderDiagnosticsState, ColliderOverlayStatus, +}; #[derive(Resource, Default)] pub struct DiagnosticsPanel { @@ -138,6 +141,9 @@ pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { } } + ui.separator(); + collider_diagnostics_ui(world, ui); + ui.separator(); ui.horizontal(|ui| { ui.strong("Support bundle"); @@ -193,6 +199,73 @@ pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { ui.small("BRP is enabled on the editor for external tooling."); } +fn collider_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { + let Some(state) = world.get_resource::() else { + return; + }; + let mut rows = state.entries.values().cloned().collect::>(); + rows.sort_by(|left, right| left.actor_name.cmp(&right.actor_name)); + let valid = state.valid_actors; + let disabled = state.disabled_actors; + let warnings = state.warning_actors; + let errors = state.error_actors; + + ui.horizontal_wrapped(|ui| { + ui.strong("Collider health"); + ui.colored_label( + egui::Color32::from_rgb(125, 210, 145), + format!("{valid} ready"), + ); + if disabled > 0 { + ui.colored_label( + egui::Color32::from_rgb(143, 151, 163), + format!("{disabled} disabled"), + ); + } + if warnings > 0 { + ui.colored_label( + egui::Color32::from_rgb(242, 173, 72), + format!("{warnings} warning"), + ); + } + if errors > 0 { + ui.colored_label( + egui::Color32::from_rgb(244, 91, 99), + format!("{errors} invalid"), + ); + } + }); + + for row in rows.into_iter().filter(|row| { + matches!( + row.overlay_status, + ColliderOverlayStatus::Warning | ColliderOverlayStatus::Error + ) + }) { + ui.horizontal_wrapped(|ui| { + let color = match row.overlay_status { + ColliderOverlayStatus::Error => egui::Color32::from_rgb(244, 91, 99), + _ => egui::Color32::from_rgb(242, 173, 72), + }; + ui.colored_label(color, row.shape_label); + ui.label(&row.actor_name); + if ui.small_button("Select").clicked() { + world.resource_mut::().0 = Some(row.entity); + world + .resource_mut::() + .selected_entities + .select_replace(row.entity); + } + }); + for diagnostic in &row.diagnostics { + if diagnostic.severity == ColliderDiagnosticSeverity::Info { + continue; + } + ui.small(format!("{}: {}", diagnostic.code, diagnostic.message)); + } + } +} + pub fn brush_diagnostics_window(world: &mut World, ctx: &egui::Context, open: &mut bool) { if !*open { return; diff --git a/crates/editor/src/ui/inspector.rs b/crates/editor/src/ui/inspector.rs index 61e9e33..7c08356 100644 --- a/crates/editor/src/ui/inspector.rs +++ b/crates/editor/src/ui/inspector.rs @@ -44,6 +44,9 @@ use crate::ui::theme::{ }; use crate::ui::widgets::{icon_button_small, phosphor_icon, phosphor_icon_text}; use crate::viewport::brush_edit::{BrushElementKey, BrushElementSelection}; +use crate::viewport::collider_diagnostics::{ + diagnose_collider, ColliderDiagnosticSeverity, ColliderOverlayStatus, +}; use super::component_registry::{ EditorComponentCategory, EditorComponentDescriptor, EditorComponentRegistry, @@ -4213,6 +4216,7 @@ fn rigid_body_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { fn collider_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { let mesh_candidates = static_mesh_asset_ref_candidates(world, AssetRefCandidateKind::Mesh); + let diagnostic_entry = diagnose_collider(world, entity); let Some(mut collider) = world.get::(entity).cloned() else { return; }; @@ -4235,13 +4239,60 @@ fn collider_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { ComponentCardOptions::removable(COMPONENT_COLLIDER_DESC, "Collider", icons::SELECTION), ); let card_response = component_card(ui, &card, |ui| { + if let Some(entry) = diagnostic_entry.as_ref() { + let (label, color) = match entry.overlay_status { + ColliderOverlayStatus::Valid => ("Ready", egui::Color32::from_rgb(112, 210, 144)), + ColliderOverlayStatus::Trigger => { + ("Trigger", egui::Color32::from_rgb(86, 195, 235)) + } + ColliderOverlayStatus::Disabled => { + ("Disabled", egui::Color32::from_rgb(143, 151, 163)) + } + ColliderOverlayStatus::Warning => { + ("Warning", egui::Color32::from_rgb(242, 173, 72)) + } + ColliderOverlayStatus::Error => ("Invalid", egui::Color32::from_rgb(244, 91, 99)), + }; + ui.horizontal_wrapped(|ui| { + ui.colored_label(color, egui::RichText::new(label).strong()); + ui.label(egui::RichText::new(entry.shape_label).color(TEXT_DIM)); + if entry.runtime_ready { + ui.label(egui::RichText::new("Hydrated").color(TEXT_MUTED).small()); + } + }); + for diagnostic in &entry.diagnostics { + let color = match diagnostic.severity { + ColliderDiagnosticSeverity::Info => TEXT_MUTED, + ColliderDiagnosticSeverity::Warning => egui::Color32::from_rgb(242, 173, 72), + ColliderDiagnosticSeverity::Error => egui::Color32::from_rgb(244, 91, 99), + }; + ui.label( + egui::RichText::new(&diagnostic.message) + .color(color) + .small(), + ); + ui.label( + egui::RichText::new(&diagnostic.repair) + .color(TEXT_MUTED) + .small(), + ); + } + if entry.highest_severity() == Some(ColliderDiagnosticSeverity::Error) + && ui.small_button("Reset shape").clicked() + { + collider.shape = ColliderShapeDesc::default(); + collider.enabled = true; + changed = true; + } + ui.add_space(2.0); + } property_row(ui, "Mode", |ui| { changed |= ui.checkbox(&mut collider.enabled, "Enabled").changed(); changed |= ui.checkbox(&mut collider.is_trigger, "Trigger").changed(); }); property_row(ui, "Shape", |ui| { let mut shape_kind = collider_shape_kind(&collider.shape); - egui::ComboBox::from_id_salt("collider_shape_kind") + egui::ComboBox::from_id_salt(("collider_shape_kind", entity)) .selected_text(shape_kind) .show_ui(ui, |ui| { for label in ["Box", "Sphere", "Capsule", "Static Mesh"] { @@ -4251,15 +4302,8 @@ fn collider_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { } }); if shape_kind != collider_shape_kind(&collider.shape) { - collider.shape = match shape_kind { - "Sphere" => ColliderShapeDesc::Sphere { radius: 0.5 }, - "Capsule" => ColliderShapeDesc::Capsule { - radius: 0.5, - height: 1.0, - }, - "Static Mesh" => ColliderShapeDesc::static_mesh(renderer_meshes.clone()), - _ => ColliderShapeDesc::default(), - }; + collider.shape = + convert_collider_shape(&collider.shape, shape_kind, renderer_meshes.clone()); changed = true; } }); @@ -4327,12 +4371,48 @@ fn collider_shape_kind(shape: &ColliderShapeDesc) -> &'static str { } } +fn convert_collider_shape( + previous: &ColliderShapeDesc, + shape_kind: &str, + renderer_meshes: Vec, +) -> ColliderShapeDesc { + let dimensions = match previous { + ColliderShapeDesc::Cuboid { + x_length, + y_length, + z_length, + } => Vec3::new(*x_length, *y_length, *z_length), + ColliderShapeDesc::Sphere { radius } => Vec3::splat(*radius * 2.0), + ColliderShapeDesc::Capsule { radius, height } => { + Vec3::new(*radius * 2.0, *height, *radius * 2.0) + } + ColliderShapeDesc::StaticMesh { .. } => Vec3::ONE, + } + .max(Vec3::splat(0.001)); + + match shape_kind { + "Sphere" => ColliderShapeDesc::Sphere { + radius: dimensions.max_element() * 0.5, + }, + "Capsule" => ColliderShapeDesc::Capsule { + radius: dimensions.x.max(dimensions.z) * 0.5, + height: dimensions.y, + }, + "Static Mesh" => ColliderShapeDesc::static_mesh(renderer_meshes), + _ => ColliderShapeDesc::Cuboid { + x_length: dimensions.x, + y_length: dimensions.y, + z_length: dimensions.z, + }, + } +} + fn dimension_drag(ui: &mut egui::Ui, label: &str, value: &mut f32) -> bool { property_row(ui, label, |ui| { ui.add_sized( [fit_width(ui, 72.0, 120.0), 20.0], egui::DragValue::new(value) - .range(0.0..=10_000.0) + .range(0.001..=10_000.0) .speed(0.05) .min_decimals(2) .max_decimals(3), @@ -4839,3 +4919,30 @@ fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option) }; changed } + +#[cfg(test)] +mod collider_inspector_tests { + use super::*; + use crate::history::{apply_command_undo, EditorHistory}; + + #[test] + fn shape_switch_preserves_dimensions_and_is_one_undoable_edit() { + let mut world = World::new(); + world.init_resource::(); + let original = ColliderDesc::static_cuboid(Vec3::new(2.0, 4.0, 6.0)); + let entity = world.spawn((LevelObject, original.clone())).id(); + + let sphere = ColliderDesc { + shape: convert_collider_shape(&original.shape, "Sphere", Vec::new()), + ..original.clone() + }; + assert_eq!(sphere.shape, ColliderShapeDesc::Sphere { radius: 3.0 }); + + set_collider_with_history(&mut world, entity, sphere.clone()); + assert_eq!(world.resource::().undo_depth(), 1); + assert_eq!(world.get::(entity), Some(&sphere)); + + apply_command_undo(&mut world); + assert_eq!(world.get::(entity), Some(&original)); + } +} diff --git a/crates/editor/src/ui/viewport_chrome.rs b/crates/editor/src/ui/viewport_chrome.rs index 3606fe9..8ac3db3 100644 --- a/crates/editor/src/ui/viewport_chrome.rs +++ b/crates/editor/src/ui/viewport_chrome.rs @@ -15,7 +15,7 @@ use crate::assets::{AssetSelection, AssetSubAssetKind, EditorAssets}; use crate::camera::EditorCamera; use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace}; use crate::render_target::ViewportRenderTarget; -use crate::selection::{ViewportClick, ViewportPickStack}; +use crate::selection::{SelectedEntity, ViewportClick, ViewportPickStack}; use crate::state::PlayPossession; use crate::viewport::actor_icons::ActorIconSettings; use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection}; @@ -200,16 +200,54 @@ fn scene_view_render_badge(world: &World, ctx: &egui::Context, scene_rect: egui: }); if mode == EditorViewportMode::Collider { + let health = + world.get_resource::(); + let selected_entry = world + .get_resource::() + .and_then(|selected| selected.0) + .and_then(|entity| health.and_then(|health| health.entry(entity))); egui::Area::new(egui::Id::new("scene_view_collider_warning")) .fixed_pos(scene_rect.left_bottom() + egui::vec2(8.0, -28.0)) .interactable(false) .show(ctx, |ui| { overlay_chip_frame().show(ui, |ui| { - ui.label( - egui::RichText::new("Collider view / meshes hidden") - .color(WARNING) + ui.horizontal_wrapped(|ui| { + ui.label( + egui::RichText::new("Collider View") + .color(WARNING) + .strong() + .small(), + ); + if let Some(health) = health { + ui.label( + egui::RichText::new(format!( + "{} ready {} disabled {} warning {} invalid", + health.valid_actors, + health.disabled_actors, + health.warning_actors, + health.error_actors + )) + .color(TEXT_MUTED) + .small(), + ); + } + }); + if let Some(entry) = selected_entry { + ui.label( + egui::RichText::new(format!( + "{} / {} / {}", + entry.actor_name, + entry.shape_label, + if entry.runtime_ready { + "hydrated" + } else { + "not hydrated" + } + )) + .color(SELECTION) .small(), - ); + ); + } }); }); } diff --git a/crates/editor/src/viewport/collider_diagnostics.rs b/crates/editor/src/viewport/collider_diagnostics.rs new file mode 100644 index 0000000..7739d6f --- /dev/null +++ b/crates/editor/src/viewport/collider_diagnostics.rs @@ -0,0 +1,428 @@ +//! Shared authored/runtime collider diagnostics for editor workflows. + +use std::collections::HashMap; + +use avian3d::prelude::{Collider, RigidBody}; +use bevy::prelude::*; +use shared::{ + authoring_component_active, AuthoringComponentStates, ColliderDesc, ColliderShapeDesc, + InspectorOrder, LevelObject, PhysicsBody, RigidBodyDesc, COMPONENT_COLLIDER_DESC, + COMPONENT_PHYSICS_BODY, COMPONENT_RIGID_BODY_DESC, +}; + +use crate::assets::asset_db::{find_asset_by_id, AssetRegistry}; +use crate::ui::selection_ops::entity_name; + +/// Collider dimensions beyond this world-space extent are usually accidental in a meter-scale +/// project and can severely degrade broad-phase performance. +pub const OVERSIZED_COLLIDER_EXTENT: f32 = 1_000.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ColliderDiagnosticSeverity { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ColliderDiagnostic { + pub code: &'static str, + pub severity: ColliderDiagnosticSeverity, + pub message: String, + pub repair: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColliderOverlayStatus { + Valid, + Trigger, + Disabled, + Warning, + Error, +} + +#[derive(Debug, Clone)] +pub struct ColliderDiagnosticEntry { + pub entity: Entity, + pub actor_name: String, + pub shape_label: &'static str, + pub runtime_ready: bool, + pub diagnostics: Vec, + pub overlay_status: ColliderOverlayStatus, +} + +impl ColliderDiagnosticEntry { + pub fn highest_severity(&self) -> Option { + self.diagnostics + .iter() + .map(|diagnostic| diagnostic.severity) + .max() + } + + pub fn blocking_messages(&self) -> impl Iterator { + self.diagnostics + .iter() + .filter(|diagnostic| diagnostic.severity == ColliderDiagnosticSeverity::Error) + .map(|diagnostic| diagnostic.message.as_str()) + } +} + +#[derive(Resource, Debug, Default)] +pub struct ColliderDiagnosticsState { + pub entries: HashMap, + pub valid_actors: usize, + pub disabled_actors: usize, + pub warning_actors: usize, + pub error_actors: usize, +} + +impl ColliderDiagnosticsState { + pub fn entry(&self, entity: Entity) -> Option<&ColliderDiagnosticEntry> { + self.entries.get(&entity) + } +} + +pub fn refresh_collider_diagnostics(world: &mut World) { + let entities = world + .iter_entities() + .filter(|entity| entity.contains::()) + .map(|entity| entity.id()) + .collect::>(); + let mut state = ColliderDiagnosticsState::default(); + for entity in entities { + let Some(entry) = diagnose_collider(world, entity) else { + continue; + }; + match entry.overlay_status { + ColliderOverlayStatus::Error => state.error_actors += 1, + ColliderOverlayStatus::Warning => state.warning_actors += 1, + ColliderOverlayStatus::Disabled => state.disabled_actors += 1, + _ => state.valid_actors += 1, + } + state.entries.insert(entity, entry); + } + world.insert_resource(state); +} + +pub fn diagnose_collider(world: &World, entity: Entity) -> Option { + let states = world.get::(entity); + let order = world.get::(entity); + let rigid_body_active = world.get::(entity).is_some() + && authoring_component_active(states, order, COMPONENT_RIGID_BODY_DESC); + let legacy_active = world.get::(entity).is_some() + && authoring_component_active(states, order, COMPONENT_PHYSICS_BODY); + let collider = world.get::(entity); + let collider_active = + collider.is_some() && authoring_component_active(states, order, COMPONENT_COLLIDER_DESC); + + if !rigid_body_active && !legacy_active && collider.is_none() { + return None; + } + + let mut diagnostics = Vec::new(); + if rigid_body_active && !legacy_active && !collider_active { + diagnostics.push(ColliderDiagnostic { + code: "collider.missing", + severity: ColliderDiagnosticSeverity::Error, + message: "Rigid body has no active Collider component.".into(), + repair: "Add or enable a Collider component on this actor.".into(), + }); + } + + let mut shape_label = if legacy_active { "Legacy" } else { "Missing" }; + let mut enabled = legacy_active; + let mut trigger = false; + let mut authoring_valid = true; + if let Some(collider) = collider { + shape_label = collider_shape_label(&collider.shape); + enabled = collider_active && collider.enabled; + trigger = collider.is_trigger; + if !collider_active { + diagnostics.push(ColliderDiagnostic { + code: "collider.inactive", + severity: ColliderDiagnosticSeverity::Info, + message: "Collider component is inactive.".into(), + repair: "Enable the component when this actor should participate in collision." + .into(), + }); + } else if !collider.enabled { + diagnostics.push(ColliderDiagnostic { + code: "collider.disabled", + severity: ColliderDiagnosticSeverity::Info, + message: "Collider is disabled.".into(), + repair: "Enable the collider when collision is required.".into(), + }); + } else { + let shape_diagnostics = validate_shape(world, entity, &collider.shape); + authoring_valid = shape_diagnostics + .iter() + .all(|diagnostic| diagnostic.severity != ColliderDiagnosticSeverity::Error); + diagnostics.extend(shape_diagnostics); + } + } + + let runtime_ready = has_runtime_collider(world, entity); + if enabled && authoring_valid && !runtime_ready { + diagnostics.push(ColliderDiagnostic { + code: "collider.runtime_stale", + severity: ColliderDiagnosticSeverity::Warning, + message: "Authored collider has not hydrated into a runtime shape.".into(), + repair: "Check mesh cooking and asset references, then refresh the scene.".into(), + }); + } + + let highest = diagnostics + .iter() + .map(|diagnostic| diagnostic.severity) + .max(); + let overlay_status = match highest { + Some(ColliderDiagnosticSeverity::Error) => ColliderOverlayStatus::Error, + Some(ColliderDiagnosticSeverity::Warning) => ColliderOverlayStatus::Warning, + _ if !enabled => ColliderOverlayStatus::Disabled, + _ if trigger => ColliderOverlayStatus::Trigger, + _ => ColliderOverlayStatus::Valid, + }; + + Some(ColliderDiagnosticEntry { + entity, + actor_name: entity_name(world, entity), + shape_label, + runtime_ready, + diagnostics, + overlay_status, + }) +} + +pub fn physics_placement_prerequisite_problems(world: &World, entity: Entity) -> Vec { + let name = entity_name(world, entity); + let states = world.get::(entity); + let order = world.get::(entity); + let split_body = world.get::(entity).is_some() + && authoring_component_active(states, order, COMPONENT_RIGID_BODY_DESC); + let legacy = world.get::(entity).is_some() + && authoring_component_active(states, order, COMPONENT_PHYSICS_BODY); + let collider = world.get::(entity).filter(|collider| { + collider.enabled + && !collider.is_trigger + && authoring_component_active(states, order, COMPONENT_COLLIDER_DESC) + }); + + let mut problems = Vec::new(); + if !split_body && !legacy { + problems.push(format!("{name}: add and enable a Rigid Body component")); + } + if collider.is_none() && !legacy { + problems.push(format!( + "{name}: add and enable a non-trigger Collider component" + )); + } + if let Some(entry) = diagnose_collider(world, entity) { + problems.extend( + entry + .diagnostics + .iter() + .filter(|diagnostic| { + diagnostic.severity == ColliderDiagnosticSeverity::Error + && diagnostic.code != "collider.missing" + }) + .map(|diagnostic| format!("{name}: {}", diagnostic.message)), + ); + } + if world.get::(entity).is_none() { + problems.push(format!("{name}: rigid body has not hydrated yet")); + } + if !has_runtime_collider(world, entity) { + problems.push(format!( + "{name}: collider has not hydrated or finished cooking" + )); + } + problems.sort(); + problems.dedup(); + problems +} + +pub fn has_runtime_collider(world: &World, root: Entity) -> bool { + let mut pending = vec![root]; + while let Some(entity) = pending.pop() { + if world.get::(entity).is_some() { + return true; + } + if let Some(children) = world.get::(entity) { + pending.extend(children.iter()); + } + } + false +} + +pub fn collider_shape_label(shape: &ColliderShapeDesc) -> &'static str { + match shape { + ColliderShapeDesc::Cuboid { .. } => "Box", + ColliderShapeDesc::Sphere { .. } => "Sphere", + ColliderShapeDesc::Capsule { .. } => "Capsule", + ColliderShapeDesc::StaticMesh { .. } => "Static Mesh", + } +} + +fn validate_shape( + world: &World, + entity: Entity, + shape: &ColliderShapeDesc, +) -> Vec { + let mut diagnostics = Vec::new(); + let dimensions = match shape { + ColliderShapeDesc::Cuboid { + x_length, + y_length, + z_length, + } => Some(Vec3::new(*x_length, *y_length, *z_length)), + ColliderShapeDesc::Sphere { radius } => Some(Vec3::splat(*radius * 2.0)), + ColliderShapeDesc::Capsule { radius, height } => { + Some(Vec3::new(*radius * 2.0, *height, *radius * 2.0)) + } + ColliderShapeDesc::StaticMesh { meshes, .. } => { + if meshes.is_empty() { + diagnostics.push(ColliderDiagnostic { + code: "collider.mesh_missing", + severity: ColliderDiagnosticSeverity::Error, + message: "Static mesh collider has no mesh sources.".into(), + repair: "Choose a mesh source or use the actor renderer meshes.".into(), + }); + } + for mesh in meshes { + if !mesh.is_resolved() { + diagnostics.push(ColliderDiagnostic { + code: "collider.mesh_invalid_ref", + severity: ColliderDiagnosticSeverity::Error, + message: format!( + "Static mesh source `{}` has no stable asset or sub-asset ID.", + mesh.label + ), + repair: "Choose a valid mesh from the asset browser.".into(), + }); + } else if world + .get_resource::() + .is_some_and(|registry| find_asset_by_id(registry, &mesh.asset_id).is_none()) + { + diagnostics.push(ColliderDiagnostic { + code: "collider.mesh_stale_ref", + severity: ColliderDiagnosticSeverity::Error, + message: format!( + "Static mesh source `{}` no longer exists in the asset registry.", + mesh.label + ), + repair: "Relink the collider to an existing mesh asset.".into(), + }); + } + } + None + } + }; + + if let Some(dimensions) = dimensions { + if !dimensions.is_finite() || dimensions.min_element() <= 0.0 { + diagnostics.push(ColliderDiagnostic { + code: "collider.invalid_dimensions", + severity: ColliderDiagnosticSeverity::Error, + message: "Collider dimensions must be positive finite values.".into(), + repair: "Reset the shape or enter valid dimensions.".into(), + }); + } else { + let scale = world + .get::(entity) + .map(|global| global.to_scale_rotation_translation().0.abs()) + .unwrap_or(Vec3::ONE); + let world_extent = (dimensions * scale).max_element(); + if world_extent > OVERSIZED_COLLIDER_EXTENT { + diagnostics.push(ColliderDiagnostic { + code: "collider.oversized", + severity: ColliderDiagnosticSeverity::Warning, + message: format!( + "Collider spans {world_extent:.1} m in world space (recommended maximum: {OVERSIZED_COLLIDER_EXTENT:.0} m)." + ), + repair: "Reduce collider dimensions or actor scale; split large collision into smaller actors." + .into(), + }); + } + } + } + diagnostics +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::AuthoringRigidBody; + + #[test] + fn reports_missing_invalid_oversized_and_stale_runtime_colliders() { + let mut world = World::new(); + let missing = world + .spawn((LevelObject, RigidBodyDesc::default(), Transform::default())) + .id(); + let invalid = world + .spawn(( + LevelObject, + ColliderDesc { + enabled: true, + is_trigger: false, + shape: ColliderShapeDesc::Sphere { radius: 0.0 }, + }, + Transform::default(), + )) + .id(); + let oversized = world + .spawn(( + LevelObject, + ColliderDesc::static_cuboid(Vec3::splat(2.0)), + Transform::from_scale(Vec3::splat(600.0)), + GlobalTransform::from(Transform::from_scale(Vec3::splat(600.0))), + )) + .id(); + + let missing = diagnose_collider(&world, missing).unwrap(); + assert!(missing + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "collider.missing")); + + let invalid = diagnose_collider(&world, invalid).unwrap(); + assert!(invalid + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "collider.invalid_dimensions")); + + let oversized = diagnose_collider(&world, oversized).unwrap(); + assert!(oversized + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "collider.oversized")); + assert!(oversized + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "collider.runtime_stale")); + } + + #[test] + fn hydrated_trigger_has_trigger_overlay_status() { + let mut world = World::new(); + let entity = world + .spawn(( + LevelObject, + RigidBodyDesc { + body: AuthoringRigidBody::Static, + }, + ColliderDesc { + enabled: true, + is_trigger: true, + shape: ColliderShapeDesc::Sphere { radius: 1.0 }, + }, + Collider::sphere(1.0), + Transform::default(), + )) + .id(); + + let entry = diagnose_collider(&world, entity).unwrap(); + assert_eq!(entry.overlay_status, ColliderOverlayStatus::Trigger); + assert!(entry.runtime_ready); + } +} diff --git a/crates/editor/src/viewport/mod.rs b/crates/editor/src/viewport/mod.rs index 467f3c6..9842ae1 100644 --- a/crates/editor/src/viewport/mod.rs +++ b/crates/editor/src/viewport/mod.rs @@ -5,6 +5,7 @@ pub mod brush_csg; pub mod brush_edit; pub mod brush_tool; pub mod camera; +pub mod collider_diagnostics; pub mod gizmos; pub mod material_drop; mod panel; diff --git a/crates/editor/src/viewport/physics_placement.rs b/crates/editor/src/viewport/physics_placement.rs index e878deb..59059ce 100644 --- a/crates/editor/src/viewport/physics_placement.rs +++ b/crates/editor/src/viewport/physics_placement.rs @@ -5,16 +5,12 @@ use std::time::Duration; use avian3d::prelude::*; use bevy::prelude::*; -use shared::{ - authoring_component_active, AuthoringComponentStates, ColliderDesc, InspectorOrder, - PhysicsBody, RigidBodyDesc, COMPONENT_COLLIDER_DESC, COMPONENT_PHYSICS_BODY, - COMPONENT_RIGID_BODY_DESC, -}; use crate::history::set_transform_group_with_history; use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus}; use crate::state::EditorMode; use crate::ui::selection_ops::{entity_name, is_mutable_level_object}; +use crate::viewport::collider_diagnostics::physics_placement_prerequisite_problems; const STEP_HZ: f64 = 120.0; const STEPS_PER_FRAME: usize = 4; @@ -369,33 +365,7 @@ fn validate_selection(world: &mut World, selected: &[Entity]) -> Result<(), Stri )); continue; } - let states = world.get::(*entity); - let order = world.get::(*entity); - let split_body = world.get::(*entity).is_some() - && authoring_component_active(states, order, COMPONENT_RIGID_BODY_DESC); - let split_collider = world.get::(*entity).filter(|collider| { - collider.enabled - && !collider.is_trigger - && authoring_component_active(states, order, COMPONENT_COLLIDER_DESC) - }); - let legacy = world.get::(*entity).is_some() - && authoring_component_active(states, order, COMPONENT_PHYSICS_BODY); - if !split_body && !legacy { - problems.push(format!("{name}: add and enable a Rigid Body component")); - } - if split_collider.is_none() && !legacy { - problems.push(format!( - "{name}: add and enable a non-trigger Collider component" - )); - } - if world.get::(*entity).is_none() { - problems.push(format!("{name}: rigid body has not hydrated yet")); - } - if !has_runtime_collider(world, *entity) { - problems.push(format!( - "{name}: collider has not hydrated or finished cooking" - )); - } + problems.extend(physics_placement_prerequisite_problems(world, *entity)); } if problems.is_empty() { Ok(()) @@ -404,19 +374,6 @@ fn validate_selection(world: &mut World, selected: &[Entity]) -> Result<(), Stri } } -fn has_runtime_collider(world: &World, root: Entity) -> bool { - let mut pending = vec![root]; - while let Some(entity) = pending.pop() { - if world.get::(entity).is_some() { - return true; - } - if let Some(children) = world.get::(entity) { - pending.extend(children.iter()); - } - } - false -} - fn restore_snapshot(world: &mut World, snapshot: &BodySnapshot, restore_pose: bool) { let Ok(mut entity) = world.get_entity_mut(snapshot.entity) else { return; @@ -498,7 +455,7 @@ mod tests { use crate::history::{apply_command_undo, EditorHistory}; use bevy::asset::{AssetApp, AssetPlugin}; use bevy::state::app::StatesPlugin; - use shared::LevelObject; + use shared::{ColliderDesc, LevelObject, RigidBodyDesc}; fn app_with_physics() -> App { let mut app = App::new(); diff --git a/crates/editor/src/viewport/visualizers.rs b/crates/editor/src/viewport/visualizers.rs index 3105692..70d438b 100644 --- a/crates/editor/src/viewport/visualizers.rs +++ b/crates/editor/src/viewport/visualizers.rs @@ -1,14 +1,16 @@ //! Editor-side scene visualizers for authoring/runtime entities that have no mesh. -use avian3d::prelude::ColliderConstructor; +use std::f32::consts::FRAC_PI_2; + +use avian3d::prelude::{ColliderAabb, ColliderConstructor}; use bevy::ecs::system::SystemParam; use bevy::prelude::*; use shared::{ AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, ColliderDesc, ColliderShapeDesc, LevelObject, LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, - PrefabRef, ProjectSun, RaytracingExcluded, SkinnedMeshRenderer, TeamSpawn, TriggerVolume, - WeaponSpawn, + PrefabRef, ProjectSun, RaytracingExcluded, RigidBodyDesc, SkinnedMeshRenderer, TeamSpawn, + TriggerVolume, WeaponSpawn, }; use sim::tuning::{EYE_OFFSET_STAND, PLAYER_LENGTH, PLAYER_RADIUS}; use sim::{Player, PlayerCamera}; @@ -16,6 +18,10 @@ use sim::{Player, PlayerCamera}; use crate::infra::EditorOnly; use crate::selection::SelectedEntity; use crate::state::{scene_tools_active, EditorMode, PlayPossession}; +use crate::viewport::collider_diagnostics::{ + refresh_collider_diagnostics, ColliderDiagnosticEntry, ColliderDiagnosticsState, + ColliderOverlayStatus, +}; use crate::viewport::{EditorViewportMode, ViewportDisplayMode}; #[derive(Resource, Debug, Clone)] @@ -102,7 +108,7 @@ struct VisualizerTargetQueries<'w, 's> { Entity, ( With, - Or<(With, With)>, + Or<(With, With, With)>, ), >, lights: Query<'w, 's, Entity, (With, With)>, @@ -127,7 +133,15 @@ pub struct EditorVisualizersPlugin; impl Plugin for EditorVisualizersPlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .add_systems(Startup, init_visualizer_proxy_assets) + .add_systems( + Update, + refresh_collider_diagnostics + .before(sync_visualizer_proxies) + .before(draw_visualizers) + .before(draw_split_collider_visualizers), + ) .add_systems(Update, sync_visualizer_proxies) .add_systems( Update, @@ -135,6 +149,7 @@ impl Plugin for EditorVisualizersPlugin { draw_visualizers, draw_audio_visualizers, draw_split_collider_visualizers, + draw_missing_collider_visualizers, draw_post_process_volume_gizmos, draw_navigation_visualizers, ) @@ -145,6 +160,52 @@ impl Plugin for EditorVisualizersPlugin { } } +fn draw_missing_collider_visualizers( + viewport_mode: Res, + settings: Res, + diagnostics: Res, + selected: Res, + mut gizmos: Gizmos, + missing: Query< + (Entity, &GlobalTransform), + ( + With, + With, + Without, + Without, + ), + >, +) { + let collider_mode = *viewport_mode == EditorViewportMode::Collider; + if !(collider_mode || settings.enabled && settings.show_colliders) { + return; + } + for (entity, global) in &missing { + let color = collider_overlay_color( + diagnostics.entry(entity), + selected.0 == Some(entity), + collider_mode, + ); + let center = global.translation(); + let radius = if selected.0 == Some(entity) { + 0.48 + } else { + 0.34 + }; + draw_box( + &mut gizmos, + center, + global.rotation(), + Vec3::splat(radius), + color, + ); + for axis in [Vec3::X, Vec3::Y, Vec3::Z] { + let direction = global.rotation() * axis * radius * 1.35; + gizmos.line(center - direction, center + direction, color); + } + } +} + fn draw_navigation_visualizers( state: Res, mut gizmos: Gizmos, @@ -556,17 +617,7 @@ fn draw_visualizers( if settings.show_colliders || collider_mode { for (entity, body, global) in &colliders { - let color = if selected.0 == Some(entity) { - if collider_mode { - Color::srgba(0.65, 1.0, 0.55, 1.0) - } else { - Color::srgba(0.55, 1.0, 0.45, 0.95) - } - } else if collider_mode { - Color::srgba(0.35, 1.0, 0.45, 0.9) - } else { - Color::srgba(0.25, 0.9, 0.35, 0.55) - }; + let color = collider_overlay_color(None, selected.0 == Some(entity), collider_mode); draw_collider(&mut gizmos, global, &body.collider, color); } } @@ -679,11 +730,18 @@ fn draw_visualizers( } } +#[expect( + clippy::too_many_arguments, + reason = "collider overlays combine authoring, runtime bounds, selection, and viewport state" +)] fn draw_split_collider_visualizers( viewport_mode: Res, settings: Res, + diagnostics: Res, selected: Res, mut gizmos: Gizmos, + children: Query<&Children>, + runtime_aabbs: Query<&ColliderAabb>, colliders: Query< (Entity, &ColliderDesc, &GlobalTransform), (With, Without), @@ -697,17 +755,80 @@ fn draw_split_collider_visualizers( return; } for (entity, collider, global) in &colliders { - let color = if selected.0 == Some(entity) { - Color::srgba(0.65, 1.0, 0.55, 1.0) - } else if collider_mode { - Color::srgba(0.35, 1.0, 0.45, 0.9) - } else { - Color::srgba(0.25, 0.9, 0.35, 0.55) - }; - draw_collider_desc(&mut gizmos, global, collider, color); + let color = collider_overlay_color( + diagnostics.entry(entity), + selected.0 == Some(entity), + collider_mode, + ); + let drew_runtime_mesh = matches!(collider.shape, ColliderShapeDesc::StaticMesh { .. }) + && draw_runtime_collider_bounds(&mut gizmos, entity, &children, &runtime_aabbs, color); + if !drew_runtime_mesh { + draw_collider_desc(&mut gizmos, global, collider, color); + } } } +fn collider_overlay_color( + entry: Option<&ColliderDiagnosticEntry>, + selected: bool, + collider_mode: bool, +) -> Color { + let status = entry + .map(|entry| entry.overlay_status) + .unwrap_or(ColliderOverlayStatus::Valid); + let (rgb, base_alpha): (Vec3, f32) = match status { + ColliderOverlayStatus::Valid => (Vec3::new(0.25, 0.92, 0.43), 0.72), + ColliderOverlayStatus::Trigger => (Vec3::new(0.18, 0.82, 1.0), 0.78), + ColliderOverlayStatus::Disabled => (Vec3::new(0.52, 0.56, 0.62), 0.42), + ColliderOverlayStatus::Warning => (Vec3::new(1.0, 0.68, 0.18), 0.9), + ColliderOverlayStatus::Error => (Vec3::new(1.0, 0.24, 0.3), 0.96), + }; + let alpha = if selected { + 1.0 + } else if collider_mode { + base_alpha.max(0.82) + } else { + base_alpha * 0.72 + }; + let rgb = if selected { + rgb.lerp(Vec3::ONE, 0.18) + } else { + rgb + }; + Color::srgba(rgb.x, rgb.y, rgb.z, alpha) +} + +fn draw_runtime_collider_bounds( + gizmos: &mut Gizmos, + root: Entity, + children: &Query<&Children>, + aabbs: &Query<&ColliderAabb>, + color: Color, +) -> bool { + let mut pending = vec![root]; + let mut drew = false; + while let Some(entity) = pending.pop() { + if let Ok(aabb) = aabbs.get(entity) { + let min = aabb.min; + let max = aabb.max; + if min.is_finite() && max.is_finite() && min.cmplt(max).all() { + draw_box( + gizmos, + (min + max) * 0.5, + Quat::IDENTITY, + (max - min) * 0.5, + color, + ); + drew = true; + } + } + if let Ok(entity_children) = children.get(entity) { + pending.extend(entity_children.iter()); + } + } + drew +} + fn draw_post_process_volume_gizmos( viewport_mode: Res, settings: Res, @@ -741,6 +862,8 @@ fn draw_collider( collider: &ColliderConstructor, color: Color, ) { + let (scale, rotation, translation) = global.to_scale_rotation_translation(); + let scale = scale.abs(); match collider { ColliderConstructor::Cuboid { x_length, @@ -748,27 +871,21 @@ fn draw_collider( z_length, } => draw_box( gizmos, - global.translation(), - global.rotation(), - Vec3::new(*x_length, *y_length, *z_length) * 0.5, + translation, + rotation, + Vec3::new(*x_length, *y_length, *z_length) * scale * 0.5, color, ), ColliderConstructor::Sphere { radius } => { - gizmos - .sphere( - Isometry3d::new(global.translation(), global.rotation()), - *radius, - color, - ) - .resolution(24); + draw_ellipsoid(gizmos, translation, rotation, scale * *radius, color); } ColliderConstructor::Capsule { radius, height } => { draw_capsule( gizmos, - global.translation(), - global.rotation(), - *radius, - *height, + translation, + rotation, + *radius * scale.x.max(scale.z), + *height * scale.y, color, ); } @@ -790,9 +907,8 @@ fn draw_collider_desc( collider: &ColliderDesc, color: Color, ) { - if !collider.enabled { - return; - } + let (scale, rotation, translation) = global.to_scale_rotation_translation(); + let scale = scale.abs(); match &collider.shape { ColliderShapeDesc::Cuboid { x_length, @@ -800,42 +916,59 @@ fn draw_collider_desc( z_length, } => draw_box( gizmos, - global.translation(), - global.rotation(), - Vec3::new(*x_length, *y_length, *z_length) * 0.5, + translation, + rotation, + Vec3::new(*x_length, *y_length, *z_length) * scale * 0.5, color, ), ColliderShapeDesc::Sphere { radius } => { - gizmos - .sphere( - Isometry3d::new(global.translation(), global.rotation()), - *radius, - color, - ) - .resolution(24); + draw_ellipsoid(gizmos, translation, rotation, scale * *radius, color); } ColliderShapeDesc::Capsule { radius, height } => { draw_capsule( gizmos, - global.translation(), - global.rotation(), - *radius, - *height, + translation, + rotation, + *radius * scale.x.max(scale.z), + *height * scale.y, color, ); } ColliderShapeDesc::StaticMesh { .. } => { - gizmos - .sphere( - Isometry3d::new(global.translation(), global.rotation()), - 0.35, - color, - ) - .resolution(16); + draw_box(gizmos, translation, rotation, Vec3::splat(0.24), color); + gizmos.line( + translation - rotation * Vec3::splat(0.34), + translation + rotation * Vec3::splat(0.34), + color, + ); } } } +fn draw_ellipsoid(gizmos: &mut Gizmos, center: Vec3, rotation: Quat, radii: Vec3, color: Color) { + gizmos + .ellipse( + Isometry3d::new(center, rotation), + Vec2::new(radii.x, radii.z), + color, + ) + .resolution(32); + gizmos + .ellipse( + Isometry3d::new(center, rotation * Quat::from_rotation_x(FRAC_PI_2)), + Vec2::new(radii.x, radii.y), + color, + ) + .resolution(32); + gizmos + .ellipse( + Isometry3d::new(center, rotation * Quat::from_rotation_z(FRAC_PI_2)), + Vec2::new(radii.y, radii.z), + color, + ) + .resolution(32); +} + fn draw_light( gizmos: &mut Gizmos, global: &GlobalTransform, diff --git a/docs/README.md b/docs/README.md index daf8aba..dc34c21 100644 --- a/docs/README.md +++ b/docs/README.md @@ -84,10 +84,12 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi | [editor/native-dialogs.md](editor/native-dialogs.md) | Non-blocking native dialog acquisition and main-thread result application | | [editor/terrain.md](editor/terrain.md) | Terrain schema, inspector workflow, chunk hydration, collision, and follow-on boundaries | | [editor/physics-placement.md](editor/physics-placement.md) | Transactional gravity placement, diagnostics, isolation, and undo workflow | +| [editor/collider-authoring.md](editor/collider-authoring.md) | Collider shape authoring, hydration diagnostics, semantic overlays, and placement prerequisites | | [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation | | [editor/evaluations/terrain-sculpt-tools/](editor/evaluations/terrain-sculpt-tools/) | Live screenshot and acceptance results for modal terrain sculpt tools | | [editor/evaluations/terrain-material-layers/](editor/evaluations/terrain-material-layers/) | Live screenshot and acceptance results for terrain material assignment, blending, painting, and history | | [editor/evaluations/physics-placement/](editor/evaluations/physics-placement/) | Live screenshot and acceptance results for physics settling, cancel, commit, undo, and redo | +| [editor/evaluations/collider-diagnostics/](editor/evaluations/collider-diagnostics/) | Live screenshot and acceptance results for collider overlays, diagnostics, shape history, and placement preflight | | [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity | | [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements | diff --git a/docs/editor/README.md b/docs/editor/README.md index d9b7500..8628b2e 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -27,6 +27,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | [native-dialogs.md](native-dialogs.md) | Non-blocking file/folder/confirmation acquisition and main-thread result application | | [terrain.md](terrain.md) | Inline height-grid terrain, chunk hydration, collision, inspector workflow, and fixtures | | [physics-placement.md](physics-placement.md) | Transactional gravity placement, prerequisites, isolation, commit/cancel, and undo | +| [collider-authoring.md](collider-authoring.md) | Collider shape editing, shared health diagnostics, overlays, hydration status, and placement reuse | | [evaluations/](evaluations/) | Acceptance evidence records and native Gitea attachment publishing policy | | [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation | | [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops | @@ -36,6 +37,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | [evaluations/terrain-sculpt-tools/](evaluations/terrain-sculpt-tools/) | Live screenshot and verification record for modal sculpt controls, footprint, and stroke history | | [evaluations/terrain-material-layers/](evaluations/terrain-material-layers/) | Live screenshot and verification record for blended layers, assignment, painting, and history | | [evaluations/physics-placement/](evaluations/physics-placement/) | Live screenshot and verification record for multi-prop settling, cancel, commit, undo, and redo | +| [evaluations/collider-diagnostics/](evaluations/collider-diagnostics/) | Live screenshot and verification record for semantic collider overlays, health linking, shape switching, and placement reuse | | [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity | | [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence | @@ -47,6 +49,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | `shared::components::BrushDesc` / `shared::hydration::brushes` | Persisted brush authoring data and generated runtime mesh hydration | brushes.md, ADR 0021 | | `shared::components::TerrainDesc` / `shared::hydration::terrain` / `blacksite_surface` | Persisted height/layer grids, generated chunks, and raster blending | terrain.md, ADR 0039, ADR 0040 | | `viewport/physics_placement.rs` / `play/state.rs` | Paused Edit physics and transactional gravity placement | physics-placement.md, ADR 0041 | +| `viewport/collider_diagnostics.rs` / `viewport/visualizers.rs` / `ui/inspector.rs` | Shared collider health, semantic overlays, and undoable shape authoring | collider-authoring.md | | `scene/` | Level I/O, tabs, composition materialization, schema, viewport render-target setup | architecture.md, multi-scene-composition.md, ADR 0026 | | `viewport/` | Camera, selection, gizmos, render views | architecture.md | | `play/` | PIE session, editor mode state | architecture.md | @@ -89,7 +92,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a - **Brush authoring** — `ActorKind::Brush + BrushDesc` stores persisted convex blockout faces, validates authored geometry in the inspector and Window → Brush Diagnostics, and hydrates active valid brushes into generated mesh children. See [brushes.md](brushes.md). - **Draw Brush** — `B`, toolbar pencil, or command `brush.draw` enters a floor-polygon draw mode. LMB places snapped points, Backspace removes the last point, Enter locks the outline for height editing, mouse up/down adjusts height, and Enter/LMB creates additive prism brushes through history. Esc/right-click cancels. Simple concave outlines decompose into convex brush parts; self-intersections remain blocked. - **Brush edit modes** — with a brush selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip element modes. Element modes show brush handles in the viewport, own LMB picking, support Shift multi-select, show a mode badge, and Esc returns to object mode. Vertex/edge/face selections use the standard `W`/`E`/`R` gizmo at the element pivot and commit undoable `SetBrush` edits. Clip previews a bounds-based half-brush and commits with Enter; command-palette intersect, convex merge, and subtract operations use the same preview/commit lifecycle for conservative cuboid/prism blockout. -- **Collider split** — imported mesh collision lives in a separate `ColliderDesc::StaticMesh` plus optional `RigidBodyDesc`; renderer slots own only render visibility, shadows, mesh, and material references. +- **Collider split and health** — imported mesh collision lives in a separate `ColliderDesc::StaticMesh` plus optional `RigidBodyDesc`; renderer slots own only render visibility, shadows, mesh, and material references. Inspector, viewport, Diagnostics, and physics placement share one authored/runtime health result; see [collider-authoring.md](collider-authoring.md). - **Material assets and renderer slots** — Material and direct-base Material Instance documents are shared assets. `StaticMeshRenderer` and `SkinnedMeshRenderer` each own stable material slots with imported source defaults and explicit shared assignments; Clear returns to the source default, while reimported missing slots retain overrides as orphans instead of matching by name. Runtime-only property blocks never mutate or persist into the shared asset. Custom Surface evaluators use the same constrained ABI for raster and Solari-eligible non-deformed geometry; skinned/morph geometry is deliberately excluded from Solari until deformed BLAS updates exist. See [material-system.md](material-system.md), [ADR 0035](../adr/0035-shared-material-assets-and-renderer-slots.md), and [ADR 0036](../adr/0036-surface-abi-and-solari-parity.md). - **Prefab authoring** — shared `PrefabOverrides` targets generated actors by nested anchor chain plus stable `ActorId`, and applies reflected property/component plus same-layer structural operations in editor and game hydration. The editor supports scoped Revert and transactional **Apply to source** with three-way conflict checks, exact-file undo guards, and recoverable changed/broken/conflict states. `HydratedPrefabMember` keeps generated content out of owner serialization; linked-root saves retain local structure as variants. **Unpack Layer** preserves nested links, while **Convert to Local** recursively removes them. See [prefab-authoring.md](prefab-authoring.md) and [ADR 0027](../adr/0027-stable-prefab-ownership-and-variants.md). - **Visual language** uses a near-black industrial shell, a compact Blacksite identity mark, restrained amber actions/primary selection, cyan secondary selection, semantic status colors, stable grouped controls, and shared elevated viewport chips. Primary/secondary selection roles remain consistent across hierarchy rows, viewport x-ray shells/corner brackets, the selection HUD, and transform interactions. See [visual-language.md](visual-language.md). diff --git a/docs/editor/collider-authoring.md b/docs/editor/collider-authoring.md new file mode 100644 index 0000000..765a01e --- /dev/null +++ b/docs/editor/collider-authoring.md @@ -0,0 +1,55 @@ +# Collider Authoring And Diagnostics + +`ColliderDesc` is the saved collision shape. `RigidBodyDesc` independently controls authored body +motion, while Avian `Collider` and `RigidBody` components are hydrated runtime state. Collider +health compares both sides of that boundary so editor tools do not treat authored-but-unavailable +collision as ready. + +## Inspector Workflow + +The Collider component card reports its shape, health, and hydration state. Box, sphere, capsule, +and static mesh shapes can be switched from the Shape menu. Primitive conversions retain the +previous shape's useful extents; each completed switch uses `SetCollider` history and is one undo +operation. Invalid shapes expose a reset action. Static mesh sources can be selected or replaced +with the actor's renderer meshes. + +Dimensions are clamped to positive values in normal inspector editing. Project validation still +rejects hand-edited or migrated non-finite/zero dimensions and empty mesh source lists. + +## Viewport Contract + +Collider view hides render meshes and emphasizes authored collision using semantic status colors: + +| Color | State | +|-------|-------| +| Green | Valid hydrated solid collider | +| Cyan | Valid hydrated trigger | +| Gray | Disabled or inactive collider | +| Amber | Stale hydration or oversized collider | +| Red | Missing or invalid collider | + +Box, sphere/ellipsoid, and capsule wireframes include actor world scale. Static mesh collision uses +the cooked runtime collider bounds when available. A rigid body with no collider receives a red +spatial marker, so missing collision remains discoverable with meshes hidden. The lower-left HUD +summarizes scene health and identifies the selected collider's shape and hydration state. + +## Diagnostics + +The shared health service reports: + +- missing/inactive/disabled collider authoring; +- non-positive or non-finite primitive dimensions; +- empty, unresolved, or registry-stale static mesh references; +- valid authoring that has not produced a runtime collider; +- primitive colliders exceeding 1,000 meters on any world-space axis. + +The 1,000-meter warning is conservative rather than a save blocker. Large worlds should split +collision into spatially bounded actors to keep broad-phase behavior predictable. Diagnostics can +select the affected actor; static mesh fields retain their asset-browser locate action. + +Physics Placement consumes the same errors plus its stricter non-trigger and hydrated rigid-body +requirements. See [physics-placement.md](physics-placement.md). + +`assets/levels/collider_diagnostics_showcase.scn.ron` contains valid box, scaled sphere, capsule, +trigger, disabled, and missing-collider states for visual acceptance. Invalid, oversized, and stale +asset cases live in unit fixtures so the committed project remains release-valid. diff --git a/docs/editor/evaluations/collider-diagnostics/README.md b/docs/editor/evaluations/collider-diagnostics/README.md new file mode 100644 index 0000000..ec62bba --- /dev/null +++ b/docs/editor/evaluations/collider-diagnostics/README.md @@ -0,0 +1,24 @@ +# Collider Diagnostics Acceptance + +Issue: Gitea #26 (`BS-JD-305`) + +## Evidence + +![Collider viewport, selected trigger inspector, and scene health panel](collider-diagnostics-live.png) + +The native Wayland editor loaded `assets/levels/collider_diagnostics_showcase.scn.ron` and entered +Collider view. The captured frame shows shape overlays with meshes hidden, the scene health HUD, +the selectable Diagnostics window, and the selected trigger's hydrated status in the inspector. + +## Live Results + +- Box, scaled sphere/ellipsoid, and capsule overlays matched authored transforms and dimensions. +- Green ready, cyan trigger, gray disabled, and red missing states were visually distinct. +- The HUD reported `4 ready / 1 disabled / 0 warning / 1 invalid` and followed selection. +- The Diagnostics panel linked the missing finding back to its actor. +- Switching the valid box to sphere preserved useful extents and produced `Undo: Set Collider`; + undo restored the box. +- Physics Placement rejected the missing-collider actor with the shared collider-health messages. +- Scene switching and editor shutdown completed without a panic or runtime validation error. + +Packaged verification remains deferred by current project instruction. diff --git a/docs/editor/evaluations/collider-diagnostics/collider-diagnostics-live.png b/docs/editor/evaluations/collider-diagnostics/collider-diagnostics-live.png new file mode 100644 index 0000000..a9687b6 --- /dev/null +++ b/docs/editor/evaluations/collider-diagnostics/collider-diagnostics-live.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f5c21097193c67d7b75004ba8cbe81b1c666057fe1960a59354ad51af2118d8 +size 1382083 diff --git a/docs/editor/evaluations/production-readiness/README.md b/docs/editor/evaluations/production-readiness/README.md index 5585fcf..60403a8 100644 --- a/docs/editor/evaluations/production-readiness/README.md +++ b/docs/editor/evaluations/production-readiness/README.md @@ -59,6 +59,7 @@ another commit, a dirty worktree, or an older package do not transfer to the can | Material catalog and assignment | Material Library and exact targeted drops accepted; `#51` open only on deformed Solari geometry | Not rerun as one candidate | | Terrain authoring | `#22`-`#24` open | Blocked | | Physics placement | `#25` source/live acceptance complete | Pass; packaged-runtime acceptance owner-deferred | +| Collider authoring and diagnostics | `#26` source/live acceptance complete | Pass; packaged-runtime acceptance owner-deferred | | Animation | Implemented | Not rerun | | Audio | Implemented | Not rerun | | Navigation | Implemented | Not rerun | diff --git a/docs/editor/physics-placement.md b/docs/editor/physics-placement.md index 7851678..7129c73 100644 --- a/docs/editor/physics-placement.md +++ b/docs/editor/physics-placement.md @@ -24,9 +24,10 @@ history. During placement, every non-selected dynamic or kinematic body is tempo a static collision surface. The tool restores those bodies exactly when the preview ends. Entering Play mode cancels an active placement preview. -The start action is non-destructive when prerequisites are missing. The viewport and status strip -identify locked/linked selections, missing or disabled rigid bodies, missing/trigger colliders, and -colliders that are still cooking. A ten-second simulated-time limit stops unstable previews while +The start action is non-destructive when prerequisites are missing. It consumes the shared +[collider health service](collider-authoring.md), so the viewport, inspector, Diagnostics panel, +and placement status agree about missing, invalid, stale, disabled, or trigger collision. A +ten-second simulated-time limit stops unstable previews while leaving explicit commit and cancel available. `assets/levels/physics_placement_showcase.scn.ron` is the committed acceptance fixture. Select diff --git a/docs/editor/roadmap.md b/docs/editor/roadmap.md index 6568555..828fcb4 100644 --- a/docs/editor/roadmap.md +++ b/docs/editor/roadmap.md @@ -58,6 +58,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur | Tab cycle overlapping picks | Done | `ViewportPickStack` + Tab in viewport | | Multi-viewport | Scaffolded | Primary viewport state is separated from render ownership; secondary viewports remain future work | | Physics placement | Done | Existing horizontal toolbar action; real Avian settling, prerequisite diagnostics, exact cancel, grouped undo, and isolated non-selected bodies | +| Collider authoring diagnostics | Done | World-scale shape overlays, semantic health colors, stale/missing/invalid/oversized findings, undoable shape switching, and shared placement preflight | ## Phase 2b — Advanced rendering