diff --git a/crates/editor/src/ui/diagnostics.rs b/crates/editor/src/ui/diagnostics.rs index 90f7501..d0cd44a 100644 --- a/crates/editor/src/ui/diagnostics.rs +++ b/crates/editor/src/ui/diagnostics.rs @@ -2,18 +2,29 @@ use bevy::prelude::*; use bevy_egui::egui; -use shared::LevelObject; +use shared::{ + brush_math::{validate_brush, BrushDiagnosticSeverity}, + ActorName, BrushDesc, LevelObject, +}; use crate::assets::EditorAssets; +use crate::history::set_brush_with_history; use crate::history::EditorHistory; use crate::project_io::ProjectWorkspace; use crate::scene_io::SceneIo; +use crate::selection::SelectedEntity; +use crate::ui::UiState; #[derive(Resource, Default)] pub struct DiagnosticsPanel { pub open: bool, } +#[derive(Resource, Default)] +pub struct BrushDiagnosticsPanel { + pub open: bool, +} + pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { let scene_io = world.resource::(); let dirty = if scene_io.dirty { " (dirty)" } else { "" }; @@ -52,6 +63,126 @@ pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { ui.small("BRP is enabled on the editor for external tooling."); } +pub fn brush_diagnostics_window(world: &mut World, ctx: &egui::Context, open: &mut bool) { + if !*open { + return; + } + egui::Window::new("Brush Diagnostics") + .open(open) + .default_width(520.0) + .show(ctx, |ui| { + brush_diagnostics_ui(world, ui); + }); +} + +fn brush_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) { + let rows = brush_diagnostic_rows(world); + let invalid_count = rows.iter().filter(|row| row.error_count > 0).count(); + ui.label(format!( + "{} brush actor(s), {} invalid", + rows.len(), + invalid_count + )); + ui.separator(); + if rows.is_empty() { + ui.small("No brush actors in the current scene."); + return; + } + + for row in rows { + ui.horizontal_wrapped(|ui| { + let color = if row.error_count > 0 { + egui::Color32::from_rgb(255, 137, 129) + } else if row.warning_count > 0 { + egui::Color32::from_rgb(255, 190, 110) + } else { + egui::Color32::from_rgb(125, 210, 145) + }; + ui.colored_label(color, row.status_label()); + ui.label(row.name.as_str()); + if ui.button("Select").clicked() { + world.resource_mut::().0 = Some(row.entity); + world + .resource_mut::() + .selected_entities + .select_replace(row.entity); + } + if ui + .add_enabled(row.error_count > 0, egui::Button::new("Reset Cube")) + .clicked() + { + set_brush_with_history(world, row.entity, BrushDesc::default()); + } + }); + if let Some(message) = row.first_message.as_ref() { + ui.small(message); + } + } +} + +#[derive(Debug)] +struct BrushDiagnosticRow { + entity: Entity, + name: String, + error_count: usize, + warning_count: usize, + first_message: Option, +} + +impl BrushDiagnosticRow { + fn status_label(&self) -> String { + if self.error_count > 0 { + format!("{} error(s)", self.error_count) + } else if self.warning_count > 0 { + format!("{} warning(s)", self.warning_count) + } else { + "valid".into() + } + } +} + +fn brush_diagnostic_rows(world: &mut World) -> Vec { + let mut query = + world.query_filtered::<(Entity, Option<&ActorName>, Option<&Name>, &BrushDesc), With>(); + let mut rows: Vec<_> = query + .iter(world) + .map(|(entity, actor_name, name, brush)| { + let report = validate_brush(brush); + let error_count = report + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Error) + .count(); + let warning_count = report + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Warning) + .count(); + let first_message = report + .diagnostics + .first() + .map(|diagnostic| diagnostic.message.clone()); + BrushDiagnosticRow { + entity, + name: actor_name + .map(|name| name.0.clone()) + .or_else(|| name.map(|name| name.to_string())) + .unwrap_or_else(|| format!("{entity:?}")), + error_count, + warning_count, + first_message, + } + }) + .collect(); + rows.sort_by(|a, b| { + b.error_count + .cmp(&a.error_count) + .then(b.warning_count.cmp(&a.warning_count)) + .then(a.name.cmp(&b.name)) + }); + rows +} + pub fn diagnostics_window(world: &mut World, ctx: &egui::Context, open: &mut bool) { if !*open { return; diff --git a/crates/editor/src/ui/menu.rs b/crates/editor/src/ui/menu.rs index d58df3e..b4987a1 100644 --- a/crates/editor/src/ui/menu.rs +++ b/crates/editor/src/ui/menu.rs @@ -11,7 +11,7 @@ use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel}; use crate::state::{EditorMode, PlayPaused}; use crate::workspace::{request_new_project, request_open_project}; -use super::diagnostics::DiagnosticsPanel; +use super::diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel}; use super::dock_tabs::{open_and_focus_tab, tab_is_open, tab_label, PanelNodes, PANEL_TABS}; use super::helpers::{ delete_selection, duplicate_selection, toggle_play_mode, toggle_play_paused, toggle_possession, @@ -159,6 +159,13 @@ pub fn top_menu_bar( if ui.checkbox(&mut diagnostics.open, "Diagnostics").clicked() { ui.close(); } + let mut brush_diagnostics = world.resource_mut::(); + if ui + .checkbox(&mut brush_diagnostics.open, "Brush Diagnostics") + .clicked() + { + ui.close(); + } let mut rendering = world.resource_mut::(); if ui.checkbox(&mut rendering.open, "Rendering").clicked() { diff --git a/crates/editor/src/ui/mod.rs b/crates/editor/src/ui/mod.rs index b4f7bf0..9311c20 100644 --- a/crates/editor/src/ui/mod.rs +++ b/crates/editor/src/ui/mod.rs @@ -34,7 +34,7 @@ use crate::project_io::UserPreferences; use crate::selection::SelectedEntity; use crate::state::EditorMode; -pub use diagnostics::DiagnosticsPanel; +pub use diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel}; pub use layout::LayoutSaveTimer; pub use viewport_chrome::ViewportUiState; @@ -184,6 +184,10 @@ impl UiState { diagnostics_window(world, ctx, &mut diagnostics_open); world.resource_mut::().open = diagnostics_open; + let mut brush_diagnostics_open = world.resource::().open; + diagnostics::brush_diagnostics_window(world, ctx, &mut brush_diagnostics_open); + world.resource_mut::().open = brush_diagnostics_open; + let mut rendering_open = world .resource::() .open; @@ -246,6 +250,7 @@ impl Plugin for EditorUiPlugin { app.insert_resource(UiState::default_layout()) .add_plugins(fonts::EditorFontsPlugin) .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/docs/editor/README.md b/docs/editor/README.md index 9433b9f..eca312e 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -43,7 +43,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a - **PIE:** F8 possess/eject while sim runs; **F6** pauses/resumes simulation in Play; project settings drive shared rendering for the active viewport camera. - **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions; narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. **Materials** folder scans `assets/materials/*.ron`, renders material thumbnails on a sphere using `MaterialDesc`, and exposes shader-schema-driven parameters/textures in the details editor; **Shaders** holds shader schema RON files. glTF/GLB/FBX rows can expand into a shelf of normalized embedded mesh, material, and texture subassets with independent generated thumbnails. Mesh subassets can be selected, dragged into the viewport, or placed from details/context menus; material subassets render source-material spheres; texture subassets can be applied to the selected actor. Model import settings are staged with **Apply** / **Revert**, asset context menus can regenerate thumbnails, material asset details edit shared `MaterialAsset` fields, and file asset deletion moves sources/generated artifacts into `assets/.trash/`. - **Static mesh renderer** — model drag/drop defaults to `ActorKind::StaticMesh + StaticMeshRenderer` using normalized artifacts under `assets/meshes/generated/`. Renderer slots store imported `EditorAssetRef` mesh/material references, not source FBX/glTF paths. `SceneInstance` placement keeps the legacy `ImportedModel + ModelRef` path for full scene playback. -- **Brush authoring** — `ActorKind::Brush + BrushDesc` stores persisted convex blockout faces, validates authored geometry in the inspector, and hydrates active valid brushes into generated mesh children. See [brushes.md](brushes.md). +- **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 creates an additive prism brush through history, and Esc/right-click cancels. - **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 can be dragged on the viewport floor plane and commit undoable `SetBrush` edits; clip mode is a preview/selection mode until CSG split support lands. - **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. diff --git a/docs/editor/brushes.md b/docs/editor/brushes.md index 4fc94f7..60a174f 100644 --- a/docs/editor/brushes.md +++ b/docs/editor/brushes.md @@ -11,6 +11,7 @@ Brushes are persisted blockout geometry stored as `ActorKind::Brush + BrushDesc` - The Actor Inspector Add Component shelf can add a **Brush** component. The Brush card exposes kind, face count, shadow flags, and cube reset. - The Brush card runs shared validation and reports invalid faces inline. Fatal geometry errors prevent hydration; warnings call out authoring issues that can still render. **Reset Cube Brush** is the MVP repair path. - Scene save runs the same fatal brush validation and blocks writing unrecoverable invalid brush geometry. +- **Window → Brush Diagnostics** lists all brush actors, surfaces validation counts/messages, can select the affected brush, and provides an undoable **Reset Cube** repair for invalid brushes. - Draw Brush mode (`B`, toolbar pencil, or command `brush.draw`) creates additive prism brushes from snapped floor points. LMB places points, Backspace removes the last point, Enter commits through undo history, and Esc/right-click cancels without scene mutation. ## Element Modes @@ -34,7 +35,7 @@ These operations validate selected brushes before applying results and validate ## Current Limits - Only convex authored faces are supported by the mesh builder. -- Brush diagnostics currently focus on face validity, plane normals, finite vertices, duplicate vertices, degenerate area, and UV scale warnings; a dedicated multi-brush diagnostics window is future work. +- Brush diagnostics currently focus on face validity, plane normals, finite vertices, duplicate vertices, degenerate area, and UV scale warnings. - Subtractive brush markers are stored but not automatically evaluated. - Clip split application and arbitrary-face CSG remain future roadmap work. - Per-face material and texture refs persist and are editable through Asset Browser-aware controls, but generated brush meshes still hydrate through a single material batch.