diff --git a/crates/editor/src/ui/inspector.rs b/crates/editor/src/ui/inspector.rs index d518626..ad6dde9 100644 --- a/crates/editor/src/ui/inspector.rs +++ b/crates/editor/src/ui/inspector.rs @@ -7,6 +7,7 @@ use bevy::prelude::*; use bevy_egui::egui; use egui_phosphor_icons::{icons, Icon}; use shared::{ + brush_math::{validate_brush, BrushDiagnosticSeverity}, inspector_component_active, AuthoringLightKind, AuthoringRigidBody, BrushDesc, BrushKind, ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef, InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter, @@ -2441,6 +2442,7 @@ fn brush_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { changed |= ui.checkbox(&mut brush.receive_shadows, "Receive").changed(); }); }); + brush_validation_ui(ui, &brush); changed |= selected_brush_face_controls(world, ui, entity, &mut brush); if ui.button("Reset Cube Brush").clicked() { reset_cube = true; @@ -2457,6 +2459,60 @@ fn brush_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { } } +fn brush_validation_ui(ui: &mut egui::Ui, brush: &BrushDesc) { + let report = validate_brush(brush); + if report.diagnostics.is_empty() { + return; + } + + ui.add_space(6.0); + egui::Frame::new() + .fill(egui::Color32::from_rgb(31, 32, 36)) + .stroke(egui::Stroke::new(1.0, BORDER)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .show(ui, |ui| { + let has_errors = !report.is_valid(); + let title_color = if has_errors { + egui::Color32::from_rgb(255, 137, 129) + } else { + egui::Color32::from_rgb(255, 190, 110) + }; + ui.horizontal(|ui| { + let icon = if has_errors { + icons::WARNING + } else { + icons::INFO + }; + ui.label(phosphor_icon(icon, 14.0).color(title_color)); + ui.label( + egui::RichText::new(if has_errors { + "Brush geometry needs repair" + } else { + "Brush diagnostics" + }) + .color(title_color), + ); + }); + for diagnostic in report.diagnostics.iter().take(4) { + let color = match diagnostic.severity { + BrushDiagnosticSeverity::Error => egui::Color32::from_rgb(255, 137, 129), + BrushDiagnosticSeverity::Warning => egui::Color32::from_rgb(255, 190, 110), + }; + let face = diagnostic + .face + .as_ref() + .filter(|face| !face.is_empty()) + .map(|face| format!("{}: ", face.0)) + .unwrap_or_default(); + ui.label(egui::RichText::new(format!("{face}{}", diagnostic.message)).color(color)); + } + let hidden_count = report.diagnostics.len().saturating_sub(4); + if hidden_count > 0 { + ui.label(egui::RichText::new(format!("+{hidden_count} more")).color(TEXT_MUTED)); + } + }); +} + fn selected_brush_face_controls( world: &World, ui: &mut egui::Ui, diff --git a/crates/shared/src/brush_math.rs b/crates/shared/src/brush_math.rs index 754e99f..7be1e2a 100644 --- a/crates/shared/src/brush_math.rs +++ b/crates/shared/src/brush_math.rs @@ -2,6 +2,8 @@ use bevy::prelude::*; +use crate::{BrushDesc, ComponentInstanceId}; + const EPSILON: f32 = 0.0001; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -14,6 +16,107 @@ pub enum BrushPolygonError { NonConvex, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrushDiagnosticSeverity { + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrushDiagnostic { + pub severity: BrushDiagnosticSeverity, + pub face: Option, + pub message: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BrushValidationReport { + pub diagnostics: Vec, +} + +impl BrushValidationReport { + pub fn is_valid(&self) -> bool { + !self + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Error) + } + + pub fn push( + &mut self, + severity: BrushDiagnosticSeverity, + face: Option, + message: impl Into, + ) { + self.diagnostics.push(BrushDiagnostic { + severity, + face, + message: message.into(), + }); + } +} + +pub fn validate_brush(brush: &BrushDesc) -> BrushValidationReport { + let mut report = BrushValidationReport::default(); + if brush.faces.is_empty() { + report.push(BrushDiagnosticSeverity::Error, None, "Brush has no faces."); + return report; + } + + for face in &brush.faces { + if face.vertices.len() < 3 { + report.push( + BrushDiagnosticSeverity::Error, + Some(face.id.clone()), + "Face has fewer than three vertices.", + ); + continue; + } + if !face.vertices.iter().all(|vertex| vertex.is_finite()) { + report.push( + BrushDiagnosticSeverity::Error, + Some(face.id.clone()), + "Face contains non-finite vertex coordinates.", + ); + } + for i in 0..face.vertices.len() { + for j in (i + 1)..face.vertices.len() { + if face.vertices[i].distance_squared(face.vertices[j]) <= EPSILON * EPSILON { + report.push( + BrushDiagnosticSeverity::Error, + Some(face.id.clone()), + "Face contains duplicate vertices.", + ); + break; + } + } + } + if face.plane.normal.try_normalize().is_none() { + report.push( + BrushDiagnosticSeverity::Error, + Some(face.id.clone()), + "Face plane normal is invalid.", + ); + } + if polygon_area_3d(&face.vertices) <= EPSILON { + report.push( + BrushDiagnosticSeverity::Error, + Some(face.id.clone()), + "Face area is zero or degenerate.", + ); + } + if !face.uv_scale.is_finite() || face.uv_scale.cmpeq(Vec2::ZERO).any() { + report.push( + BrushDiagnosticSeverity::Warning, + Some(face.id.clone()), + "Face UV scale is zero or non-finite.", + ); + } + } + + report +} + pub fn signed_area_xz(vertices: &[Vec3]) -> f32 { if vertices.len() < 3 { return 0.0; @@ -94,6 +197,21 @@ fn orient_xz(a: Vec3, b: Vec3, c: Vec3) -> f32 { ab.x * ac.y - ab.y * ac.x } +fn polygon_area_3d(vertices: &[Vec3]) -> f32 { + if vertices.len() < 3 { + return 0.0; + } + let origin = vertices[0]; + let mut area = 0.0; + for index in 1..(vertices.len() - 1) { + area += (vertices[index] - origin) + .cross(vertices[index + 1] - origin) + .length() + * 0.5; + } + area +} + #[cfg(test)] mod tests { use super::*; @@ -128,4 +246,28 @@ mod tests { Err(BrushPolygonError::NonConvex) ); } + + #[test] + fn reports_invalid_brush_faces() { + let mut brush = BrushDesc::default(); + brush.faces[0].vertices.clear(); + let report = validate_brush(&brush); + assert!(!report.is_valid()); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("fewer than three"))); + } + + #[test] + fn reports_uv_warnings_without_invalidating_brush() { + let mut brush = BrushDesc::default(); + brush.faces[0].uv_scale = Vec2::ZERO; + let report = validate_brush(&brush); + assert!(report.is_valid()); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Warning)); + } } diff --git a/crates/shared/src/hydration/brushes.rs b/crates/shared/src/hydration/brushes.rs index c03c87a..9895b71 100644 --- a/crates/shared/src/hydration/brushes.rs +++ b/crates/shared/src/hydration/brushes.rs @@ -7,8 +7,8 @@ use bevy::mesh::{Indices, PrimitiveTopology}; use bevy::prelude::*; use crate::{ - inspector_component_active, BrushDesc, BrushFaceDesc, ColliderDesc, InspectorOrder, - LevelObject, MaterialDesc, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, + brush_math::validate_brush, inspector_component_active, BrushDesc, BrushFaceDesc, ColliderDesc, + InspectorOrder, LevelObject, MaterialDesc, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_MATERIAL_DESC, }; @@ -75,6 +75,16 @@ pub fn spawn_brush_mesh( material: Option<&MaterialDesc>, collider: Option<&ColliderDesc>, ) { + let report = validate_brush(brush); + if !report.is_valid() { + for diagnostic in report.diagnostics { + warn!( + "Brush hydration skipped invalid brush on {parent:?}: {}", + diagnostic.message + ); + } + return; + } let Some(mesh) = brush_mesh(brush) else { warn!("Brush hydration skipped invalid brush on {parent:?}"); return; diff --git a/docs/editor/README.md b/docs/editor/README.md index 36adcb6..9433b9f 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 and hydrates active 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 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 26af60b..c466372 100644 --- a/docs/editor/brushes.md +++ b/docs/editor/brushes.md @@ -9,6 +9,7 @@ Brushes are persisted blockout geometry stored as `ActorKind::Brush + BrushDesc` - Hydration builds generated mesh children from active brush components and strips those children before scene save. - Brush actors validate separately from primitives and imported/static mesh actors. - 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. - 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 @@ -32,6 +33,7 @@ These operations commit through history but are not full arbitrary-face CSG yet. ## 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. - 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, but generated brush meshes still hydrate through a single material batch.