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

This commit is contained in:
Rbanh 2026-06-06 06:38:37 -04:00
parent dc593978d8
commit b2305932d9
2 changed files with 95 additions and 1 deletions

View File

@ -3,6 +3,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::{BrushDesc, ComponentInstanceId}; use crate::{BrushDesc, ComponentInstanceId};
use std::collections::HashMap;
const EPSILON: f32 = 0.0001; const EPSILON: f32 = 0.0001;
@ -105,6 +106,18 @@ pub fn validate_brush(brush: &BrushDesc) -> BrushValidationReport {
"Face area is zero or degenerate.", "Face area is zero or degenerate.",
); );
} }
if let (Some(plane_normal), Some(geometry_normal)) = (
face.plane.normal.try_normalize(),
polygon_normal_3d(&face.vertices),
) {
if plane_normal.dot(geometry_normal) < -0.5 {
report.push(
BrushDiagnosticSeverity::Error,
Some(face.id.clone()),
"Face plane normal is inverted relative to its vertices.",
);
}
}
if !face.uv_scale.is_finite() || face.uv_scale.cmpeq(Vec2::ZERO).any() { if !face.uv_scale.is_finite() || face.uv_scale.cmpeq(Vec2::ZERO).any() {
report.push( report.push(
BrushDiagnosticSeverity::Warning, BrushDiagnosticSeverity::Warning,
@ -114,6 +127,15 @@ pub fn validate_brush(brush: &BrushDesc) -> BrushValidationReport {
} }
} }
let non_manifold_edges = count_non_manifold_edges(brush);
if non_manifold_edges > 0 {
report.push(
BrushDiagnosticSeverity::Error,
None,
format!("Brush has {non_manifold_edges} open or non-manifold edge(s)."),
);
}
report report
} }
@ -212,6 +234,54 @@ fn polygon_area_3d(vertices: &[Vec3]) -> f32 {
area area
} }
fn polygon_normal_3d(vertices: &[Vec3]) -> Option<Vec3> {
if vertices.len() < 3 || !vertices.iter().all(|vertex| vertex.is_finite()) {
return None;
}
let origin = vertices[0];
let mut normal = Vec3::ZERO;
for index in 1..(vertices.len() - 1) {
normal += (vertices[index] - origin).cross(vertices[index + 1] - origin);
}
normal.try_normalize()
}
fn count_non_manifold_edges(brush: &BrushDesc) -> usize {
let mut edge_counts = HashMap::<(QuantizedVertex, QuantizedVertex), usize>::new();
for face in &brush.faces {
if face.vertices.len() < 3 || !face.vertices.iter().all(|vertex| vertex.is_finite()) {
continue;
}
for index in 0..face.vertices.len() {
let a = QuantizedVertex::from(face.vertices[index]);
let b = QuantizedVertex::from(face.vertices[(index + 1) % face.vertices.len()]);
if a == b {
continue;
}
let edge = if a <= b { (a, b) } else { (b, a) };
*edge_counts.entry(edge).or_default() += 1;
}
}
edge_counts.values().filter(|count| **count != 2).count()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
struct QuantizedVertex {
x: i64,
y: i64,
z: i64,
}
impl From<Vec3> for QuantizedVertex {
fn from(value: Vec3) -> Self {
Self {
x: (value.x / EPSILON).round() as i64,
y: (value.y / EPSILON).round() as i64,
z: (value.z / EPSILON).round() as i64,
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -270,4 +340,28 @@ mod tests {
.iter() .iter()
.any(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Warning)); .any(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Warning));
} }
#[test]
fn reports_inverted_face_normals() {
let mut brush = BrushDesc::default();
brush.faces[0].plane.normal = -brush.faces[0].plane.normal;
let report = validate_brush(&brush);
assert!(!report.is_valid());
assert!(report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("inverted")));
}
#[test]
fn reports_open_edges() {
let mut brush = BrushDesc::default();
brush.faces.pop();
let report = validate_brush(&brush);
assert!(!report.is_valid());
assert!(report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("non-manifold")));
}
} }

View File

@ -35,7 +35,7 @@ These operations validate selected brushes before applying results and validate
## 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. - Brush diagnostics currently cover face validity, plane normals, inverted face normals, finite vertices, duplicate vertices, degenerate area, open/non-manifold edges, and UV scale warnings.
- 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.
- Imported source-material subasset refs persist for future material-preserving CSG, but only face refs with loadable source paths can drive brush material hydration today. - Imported source-material subasset refs persist for future material-preserving CSG, but only face refs with loadable source paths can drive brush material hydration today.