Add collider authoring diagnostics
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
This commit is contained in:
parent
0e7aed40f1
commit
0ce43fc399
31
.cursor/plans/collider_diagnostics_2026-07-12.plan.md
Normal file
31
.cursor/plans/collider_diagnostics_2026-07-12.plan.md
Normal 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.
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
175
assets/levels/collider_diagnostics_showcase.scn.ron
Normal file
175
assets/levels/collider_diagnostics_showcase.scn.ron
Normal 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,
|
||||
),
|
||||
}),
|
||||
})
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
428
crates/editor/src/viewport/collider_diagnostics.rs
Normal file
428
crates/editor/src/viewport/collider_diagnostics.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 |
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
55
docs/editor/collider-authoring.md
Normal file
55
docs/editor/collider-authoring.md
Normal 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.
|
||||
24
docs/editor/evaluations/collider-diagnostics/README.md
Normal file
24
docs/editor/evaluations/collider-diagnostics/README.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Collider Diagnostics Acceptance
|
||||
|
||||
Issue: Gitea #26 (`BS-JD-305`)
|
||||
|
||||
## Evidence
|
||||
|
||||

|
||||
|
||||
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.
|
||||
BIN
docs/editor/evaluations/collider-diagnostics/collider-diagnostics-live.png
(Stored with Git LFS)
Normal file
BIN
docs/editor/evaluations/collider-diagnostics/collider-diagnostics-live.png
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user