Add collider authoring diagnostics
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-07-12 22:48:00 -04:00
parent 0e7aed40f1
commit 0ce43fc399
19 changed files with 1180 additions and 127 deletions

View File

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

View File

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

View File

@ -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",

View File

@ -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,
),
}),
})

View File

@ -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::<ColliderDiagnosticsState>() else {
return;
};
let mut rows = state.entries.values().cloned().collect::<Vec<_>>();
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::<SelectedEntity>().0 = Some(row.entity);
world
.resource_mut::<UiState>()
.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;

View File

@ -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::<ColliderDesc>(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<EditorAssetRef>,
) -> 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<String>)
};
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::<EditorHistory>();
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::<EditorHistory>().undo_depth(), 1);
assert_eq!(world.get::<ColliderDesc>(entity), Some(&sphere));
apply_command_undo(&mut world);
assert_eq!(world.get::<ColliderDesc>(entity), Some(&original));
}
}

View File

@ -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::<crate::viewport::collider_diagnostics::ColliderDiagnosticsState>();
let selected_entry = world
.get_resource::<SelectedEntity>()
.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.horizontal_wrapped(|ui| {
ui.label(
egui::RichText::new("Collider view / meshes hidden")
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(),
);
}
});
});
}

View File

@ -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<ColliderDiagnostic>,
pub overlay_status: ColliderOverlayStatus,
}
impl ColliderDiagnosticEntry {
pub fn highest_severity(&self) -> Option<ColliderDiagnosticSeverity> {
self.diagnostics
.iter()
.map(|diagnostic| diagnostic.severity)
.max()
}
pub fn blocking_messages(&self) -> impl Iterator<Item = &str> {
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<Entity, ColliderDiagnosticEntry>,
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::<LevelObject>())
.map(|entity| entity.id())
.collect::<Vec<_>>();
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<ColliderDiagnosticEntry> {
let states = world.get::<AuthoringComponentStates>(entity);
let order = world.get::<InspectorOrder>(entity);
let rigid_body_active = world.get::<RigidBodyDesc>(entity).is_some()
&& authoring_component_active(states, order, COMPONENT_RIGID_BODY_DESC);
let legacy_active = world.get::<PhysicsBody>(entity).is_some()
&& authoring_component_active(states, order, COMPONENT_PHYSICS_BODY);
let collider = world.get::<ColliderDesc>(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<String> {
let name = entity_name(world, entity);
let states = world.get::<AuthoringComponentStates>(entity);
let order = world.get::<InspectorOrder>(entity);
let split_body = world.get::<RigidBodyDesc>(entity).is_some()
&& authoring_component_active(states, order, COMPONENT_RIGID_BODY_DESC);
let legacy = world.get::<PhysicsBody>(entity).is_some()
&& authoring_component_active(states, order, COMPONENT_PHYSICS_BODY);
let collider = world.get::<ColliderDesc>(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::<RigidBody>(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::<Collider>(entity).is_some() {
return true;
}
if let Some(children) = world.get::<Children>(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<ColliderDiagnostic> {
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::<AssetRegistry>()
.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::<GlobalTransform>(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);
}
}

View File

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

View File

@ -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::<AuthoringComponentStates>(*entity);
let order = world.get::<InspectorOrder>(*entity);
let split_body = world.get::<RigidBodyDesc>(*entity).is_some()
&& authoring_component_active(states, order, COMPONENT_RIGID_BODY_DESC);
let split_collider = world.get::<ColliderDesc>(*entity).filter(|collider| {
collider.enabled
&& !collider.is_trigger
&& authoring_component_active(states, order, COMPONENT_COLLIDER_DESC)
});
let legacy = world.get::<PhysicsBody>(*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::<RigidBody>(*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::<Collider>(entity).is_some() {
return true;
}
if let Some(children) = world.get::<Children>(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();

View File

@ -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<LevelObject>,
Or<(With<PhysicsBody>, With<ColliderDesc>)>,
Or<(With<PhysicsBody>, With<ColliderDesc>, With<RigidBodyDesc>)>,
),
>,
lights: Query<'w, 's, Entity, (With<LevelObject>, With<LightDesc>)>,
@ -127,7 +133,15 @@ pub struct EditorVisualizersPlugin;
impl Plugin for EditorVisualizersPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<EditorVisualizationSettings>()
.init_resource::<ColliderDiagnosticsState>()
.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<EditorViewportMode>,
settings: Res<EditorVisualizationSettings>,
diagnostics: Res<ColliderDiagnosticsState>,
selected: Res<SelectedEntity>,
mut gizmos: Gizmos,
missing: Query<
(Entity, &GlobalTransform),
(
With<LevelObject>,
With<RigidBodyDesc>,
Without<ColliderDesc>,
Without<PhysicsBody>,
),
>,
) {
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<crate::ui::navigation_inspector::NavigationEditorState>,
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<EditorViewportMode>,
settings: Res<EditorVisualizationSettings>,
diagnostics: Res<ColliderDiagnosticsState>,
selected: Res<SelectedEntity>,
mut gizmos: Gizmos,
children: Query<&Children>,
runtime_aabbs: Query<&ColliderAabb>,
colliders: Query<
(Entity, &ColliderDesc, &GlobalTransform),
(With<LevelObject>, Without<PhysicsBody>),
@ -697,16 +755,79 @@ 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)
};
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<EditorViewportMode>,
@ -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,40 +916,57 @@ 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 { .. } => {
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
.sphere(
Isometry3d::new(global.translation(), global.rotation()),
0.35,
.ellipse(
Isometry3d::new(center, rotation),
Vec2::new(radii.x, radii.z),
color,
)
.resolution(16);
}
}
.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(

View File

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

View File

@ -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).

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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