Expand brush validation coverage
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
dc593978d8
commit
b2305932d9
@ -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")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user