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

This commit is contained in:
Rbanh 2026-06-06 06:14:00 -04:00
parent d847706b8f
commit 4dc22cd3b6
5 changed files with 213 additions and 3 deletions

View File

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

View File

@ -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<ComponentInstanceId>,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BrushValidationReport {
pub diagnostics: Vec<BrushDiagnostic>,
}
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<ComponentInstanceId>,
message: impl Into<String>,
) {
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));
}
}

View File

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

View File

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

View File

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