Add brush validation 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
d847706b8f
commit
4dc22cd3b6
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user