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 bevy_egui::egui;
|
||||||
use egui_phosphor_icons::{icons, Icon};
|
use egui_phosphor_icons::{icons, Icon};
|
||||||
use shared::{
|
use shared::{
|
||||||
|
brush_math::{validate_brush, BrushDiagnosticSeverity},
|
||||||
inspector_component_active, AuthoringLightKind, AuthoringRigidBody, BrushDesc, BrushKind,
|
inspector_component_active, AuthoringLightKind, AuthoringRigidBody, BrushDesc, BrushKind,
|
||||||
ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef,
|
ColliderDesc, ColliderShapeDesc, ColorDesc, ComponentInstanceId, EditorAssetRef,
|
||||||
InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialParameter,
|
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();
|
changed |= ui.checkbox(&mut brush.receive_shadows, "Receive").changed();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
brush_validation_ui(ui, &brush);
|
||||||
changed |= selected_brush_face_controls(world, ui, entity, &mut brush);
|
changed |= selected_brush_face_controls(world, ui, entity, &mut brush);
|
||||||
if ui.button("Reset Cube Brush").clicked() {
|
if ui.button("Reset Cube Brush").clicked() {
|
||||||
reset_cube = true;
|
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(
|
fn selected_brush_face_controls(
|
||||||
world: &World,
|
world: &World,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{BrushDesc, ComponentInstanceId};
|
||||||
|
|
||||||
const EPSILON: f32 = 0.0001;
|
const EPSILON: f32 = 0.0001;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@ -14,6 +16,107 @@ pub enum BrushPolygonError {
|
|||||||
NonConvex,
|
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 {
|
pub fn signed_area_xz(vertices: &[Vec3]) -> f32 {
|
||||||
if vertices.len() < 3 {
|
if vertices.len() < 3 {
|
||||||
return 0.0;
|
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
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -128,4 +246,28 @@ mod tests {
|
|||||||
Err(BrushPolygonError::NonConvex)
|
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 bevy::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
inspector_component_active, BrushDesc, BrushFaceDesc, ColliderDesc, InspectorOrder,
|
brush_math::validate_brush, inspector_component_active, BrushDesc, BrushFaceDesc, ColliderDesc,
|
||||||
LevelObject, MaterialDesc, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC,
|
InspectorOrder, LevelObject, MaterialDesc, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC,
|
||||||
COMPONENT_MATERIAL_DESC,
|
COMPONENT_MATERIAL_DESC,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -75,6 +75,16 @@ pub fn spawn_brush_mesh(
|
|||||||
material: Option<&MaterialDesc>,
|
material: Option<&MaterialDesc>,
|
||||||
collider: Option<&ColliderDesc>,
|
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 {
|
let Some(mesh) = brush_mesh(brush) else {
|
||||||
warn!("Brush hydration skipped invalid brush on {parent:?}");
|
warn!("Brush hydration skipped invalid brush on {parent:?}");
|
||||||
return;
|
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.
|
- **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/`.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- 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.
|
- 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 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.
|
- 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
|
## Element Modes
|
||||||
@ -32,6 +33,7 @@ These operations commit through history but are not full arbitrary-face CSG yet.
|
|||||||
## Current Limits
|
## Current Limits
|
||||||
|
|
||||||
- Only convex authored faces are supported by the mesh builder.
|
- 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.
|
- Subtractive brush markers are stored but not automatically evaluated.
|
||||||
- Clip split application and arbitrary-face CSG remain future roadmap work.
|
- 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.
|
- 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