use brepkit_math::tolerance::Tolerance;
use brepkit_topology::Topology;
use brepkit_topology::TopologyError;
use brepkit_topology::explorer;
use brepkit_topology::solid::SolidId;
#[derive(Debug, Clone)]
pub struct ValidationIssue {
pub severity: Severity,
pub description: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub struct ValidationReport {
pub issues: Vec<ValidationIssue>,
}
impl ValidationReport {
#[must_use]
pub fn is_valid(&self) -> bool {
!self.issues.iter().any(|i| i.severity == Severity::Error)
}
#[must_use]
pub fn error_count(&self) -> usize {
self.issues
.iter()
.filter(|i| i.severity == Severity::Error)
.count()
}
#[must_use]
pub fn warning_count(&self) -> usize {
self.issues
.iter()
.filter(|i| i.severity == Severity::Warning)
.count()
}
}
#[derive(Debug, Clone)]
pub struct ValidationOptions {
pub tolerance_scale: f64,
pub check_orientation: bool,
}
impl Default for ValidationOptions {
fn default() -> Self {
Self {
tolerance_scale: 1.0,
check_orientation: true,
}
}
}
pub fn euler_characteristic(
topo: &Topology,
solid: SolidId,
) -> Result<i64, crate::OperationsError> {
let (f, e, v) = explorer::solid_entity_counts(topo, solid)?;
#[allow(clippy::cast_possible_wrap)]
let euler = (v as i64) - (e as i64) + (f as i64);
Ok(euler)
}
fn face_all_edges_straight(
topo: &Topology,
face: &brepkit_topology::face::Face,
) -> Result<bool, TopologyError> {
for wire_id in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
let wire = topo.wire(wire_id)?;
for oe in wire.edges() {
let edge = topo.edge(oe.edge())?;
if !matches!(edge.curve(), brepkit_topology::edge::EdgeCurve::Line) {
return Ok(false);
}
}
}
Ok(true)
}
pub fn validate_solid(
topo: &Topology,
solid: SolidId,
) -> Result<ValidationReport, crate::OperationsError> {
validate_solid_with_options(topo, solid, &ValidationOptions::default())
}
#[allow(clippy::too_many_lines)]
pub fn validate_solid_with_options(
topo: &Topology,
solid: SolidId,
options: &ValidationOptions,
) -> Result<ValidationReport, crate::OperationsError> {
let mut issues = Vec::new();
let tol = Tolerance::new();
let scale = options.tolerance_scale.clamp(0.1, 1000.0);
let (f, e, v) = explorer::solid_entity_counts(topo, solid)?;
let mut total_inner_loops: i64 = 0;
let faces = explorer::solid_faces(topo, solid)?;
for fid in &faces {
let face = topo.face(*fid)?;
#[allow(clippy::cast_possible_wrap)]
{
total_inner_loops += face.inner_wires().len() as i64;
}
}
#[allow(clippy::cast_possible_wrap)]
let euler = (v as i64) - (e as i64) + (f as i64);
let adjusted_euler = euler - total_inner_loops;
let genus_times_2 = 2 - adjusted_euler;
if genus_times_2 < 0 || genus_times_2 % 2 != 0 {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"Euler characteristic V-E+F = {euler} is invalid \
(expected V-E+F = 2+L with L={total_inner_loops} inner loops, \
got V={v}, E={e}, F={f})"
),
});
}
let edge_map = explorer::edge_to_face_map(topo, solid)?;
let mut boundary_edges = 0;
let mut non_manifold_edges = 0;
for (&edge_idx, faces) in &edge_map {
match faces.len() {
0 => {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!("edge {edge_idx} is not referenced by any face"),
});
}
1 => {
boundary_edges += 1;
}
2 => {} n => {
non_manifold_edges += 1;
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"edge {edge_idx} is shared by {n} faces (non-manifold, expected 2)"
),
});
}
}
}
if boundary_edges > 0 {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!("{boundary_edges} boundary edge(s) found (shell is not closed)"),
});
}
if non_manifold_edges > 0 {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!("{non_manifold_edges} non-manifold edge(s) found"),
});
}
let faces = explorer::solid_faces(topo, solid)?;
for fid in &faces {
let face_data = topo.face(*fid)?;
let is_planar = matches!(
face_data.surface(),
brepkit_topology::face::FaceSurface::Plane { .. }
);
if is_planar && face_all_edges_straight(topo, face_data)? {
let face_verts = explorer::face_vertices(topo, *fid)?;
if face_verts.len() < 3 {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"face {} has only {} vertices (need at least 3)",
fid.index(),
face_verts.len()
),
});
}
}
}
let scaled_tol = Tolerance {
linear: tol.linear * scale,
angular: tol.angular * scale,
relative: tol.relative * scale,
};
for fid in &faces {
let face = topo.face(*fid)?;
if let brepkit_topology::face::FaceSurface::Plane { normal, .. } = face.surface() {
let len = normal.length();
if !scaled_tol.approx_eq(len, 1.0) {
issues.push(ValidationIssue {
severity: Severity::Warning,
description: format!(
"face {} has non-unit normal (length = {len})",
fid.index()
),
});
}
}
}
for fid in &faces {
let face = topo.face(*fid)?;
let wire_ids: Vec<_> = std::iter::once(face.outer_wire())
.chain(face.inner_wires().iter().copied())
.collect();
for wire_id in wire_ids {
let wire = topo.wire(wire_id)?;
if let Err(_e) = brepkit_topology::validation::validate_wire_closed(wire, topo) {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"wire {} on face {} is not closed",
wire_id.index(),
fid.index()
),
});
}
}
}
let area_tol_sq = scaled_tol.linear * scaled_tol.linear;
for fid in &faces {
let face = topo.face(*fid)?;
if !matches!(
face.surface(),
brepkit_topology::face::FaceSurface::Plane { .. }
) {
continue;
}
if !face_all_edges_straight(topo, face)? {
continue;
}
let wire = topo.wire(face.outer_wire())?;
let mut positions = Vec::new();
for oe in wire.edges() {
let edge = topo.edge(oe.edge())?;
let vid = oe.oriented_start(edge);
positions.push(topo.vertex(vid)?.point());
}
if positions.len() >= 3 {
let area = polygon_area_3d(&positions);
if area < area_tol_sq {
issues.push(ValidationIssue {
severity: Severity::Warning,
description: format!(
"face {} has near-zero area ({area:.2e} < {area_tol_sq:.2e})",
fid.index()
),
});
}
}
}
let all_edges = explorer::solid_edges(topo, solid)?;
for eid in &all_edges {
let edge = topo.edge(*eid)?;
if !edge.is_closed() {
let p_start = topo.vertex(edge.start())?.point();
let p_end = topo.vertex(edge.end())?.point();
let dx = p_start.x() - p_end.x();
let dy = p_start.y() - p_end.y();
let dz = p_start.z() - p_end.z();
let dist = (dx * dx + dy * dy + dz * dz).sqrt();
if dist < tol.linear {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"edge {} has near-zero length ({dist:.2e} < {:.2e})",
eid.index(),
tol.linear
),
});
}
}
}
for fid in &faces {
let face = topo.face(*fid)?;
let wire_ids: Vec<_> = std::iter::once(face.outer_wire())
.chain(face.inner_wires().iter().copied())
.collect();
for wire_id in wire_ids {
let wire = topo.wire(wire_id)?;
if wire.edges().is_empty() {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"wire {} on face {} has no edges",
wire_id.index(),
fid.index()
),
});
}
}
}
if !faces.is_empty() && genus_times_2 == 0 {
let face_set: std::collections::HashSet<usize> = faces.iter().map(|f| f.index()).collect();
let mut visited = std::collections::HashSet::new();
let mut queue = std::collections::VecDeque::new();
visited.insert(faces[0].index());
queue.push_back(faces[0]);
while let Some(current) = queue.pop_front() {
for adj_faces in edge_map.values() {
if adj_faces.iter().any(|f| f.index() == current.index()) {
for neighbor in adj_faces {
if face_set.contains(&neighbor.index()) && visited.insert(neighbor.index())
{
queue.push_back(*neighbor);
}
}
}
}
}
let unreachable = face_set.len() - visited.len();
if unreachable > 0 {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"shell is disconnected: {unreachable} face(s) not reachable from first face"
),
});
}
}
{
let mut face_counts = std::collections::HashMap::new();
for fid in &faces {
*face_counts.entry(fid.index()).or_insert(0usize) += 1;
}
for (&idx, &count) in &face_counts {
if count > 1 {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!("face {idx} appears {count} times in shell (redundant)"),
});
}
}
}
let vertex_set: std::collections::HashSet<usize> = {
let verts = explorer::solid_vertices(topo, solid)?;
verts.iter().map(|v| v.index()).collect()
};
for eid in &all_edges {
let edge = topo.edge(*eid)?;
if !vertex_set.contains(&edge.start().index()) {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"edge {} start vertex {} not found in solid",
eid.index(),
edge.start().index()
),
});
}
if !vertex_set.contains(&edge.end().index()) {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"edge {} end vertex {} not found in solid",
eid.index(),
edge.end().index()
),
});
}
}
if options.check_orientation {
let solid_data = topo.solid(solid)?;
let shells = std::iter::once(solid_data.outer_shell())
.chain(solid_data.inner_shells().iter().copied())
.collect::<Vec<_>>();
for shell_id in shells {
for issue in brepkit_check::validate::shell::check_shell_orientation(topo, shell_id)
.map_err(|e| crate::OperationsError::InvalidInput {
reason: e.to_string(),
})?
{
issues.push(ValidationIssue {
severity: Severity::Error,
description: issue.description,
});
}
}
}
Ok(ValidationReport { issues })
}
pub fn validate_solid_relaxed(
topo: &Topology,
solid: SolidId,
) -> Result<ValidationReport, crate::OperationsError> {
validate_solid_relaxed_with_options(topo, solid, &ValidationOptions::default())
}
#[allow(clippy::too_many_lines)]
pub fn validate_solid_relaxed_with_options(
topo: &Topology,
solid: SolidId,
options: &ValidationOptions,
) -> Result<ValidationReport, crate::OperationsError> {
let mut issues = Vec::new();
let tol = Tolerance::new();
let scale = options.tolerance_scale.clamp(0.1, 1000.0);
let faces = explorer::solid_faces(topo, solid)?;
for fid in &faces {
let face_data = topo.face(*fid)?;
let is_planar = matches!(
face_data.surface(),
brepkit_topology::face::FaceSurface::Plane { .. }
);
if is_planar && face_all_edges_straight(topo, face_data)? {
let face_verts = explorer::face_vertices(topo, *fid)?;
if face_verts.len() < 3 {
issues.push(ValidationIssue {
severity: Severity::Warning,
description: format!(
"face {} has only {} vertices (need at least 3)",
fid.index(),
face_verts.len()
),
});
}
}
}
let scaled_tol = Tolerance {
linear: tol.linear * scale,
angular: tol.angular * scale,
relative: tol.relative * scale,
};
for fid in &faces {
let face = topo.face(*fid)?;
if let brepkit_topology::face::FaceSurface::Plane { normal, .. } = face.surface() {
let len = normal.length();
if !scaled_tol.approx_eq(len, 1.0) {
issues.push(ValidationIssue {
severity: Severity::Warning,
description: format!(
"face {} has non-unit normal (length = {len})",
fid.index()
),
});
}
}
}
for fid in &faces {
let face = topo.face(*fid)?;
let wire_ids: Vec<_> = std::iter::once(face.outer_wire())
.chain(face.inner_wires().iter().copied())
.collect();
for wire_id in wire_ids {
let wire = topo.wire(wire_id)?;
if let Err(_e) = brepkit_topology::validation::validate_wire_closed(wire, topo) {
issues.push(ValidationIssue {
severity: Severity::Warning,
description: format!(
"wire {} on face {} is not closed",
wire_id.index(),
fid.index()
),
});
}
}
}
let area_tol_sq = scaled_tol.linear * scaled_tol.linear;
for fid in &faces {
let face = topo.face(*fid)?;
if !matches!(
face.surface(),
brepkit_topology::face::FaceSurface::Plane { .. }
) {
continue;
}
if !face_all_edges_straight(topo, face)? {
continue;
}
let wire = topo.wire(face.outer_wire())?;
let mut positions = Vec::new();
for oe in wire.edges() {
let edge = topo.edge(oe.edge())?;
let vid = oe.oriented_start(edge);
positions.push(topo.vertex(vid)?.point());
}
if positions.len() >= 3 {
let area = polygon_area_3d(&positions);
if area < area_tol_sq {
issues.push(ValidationIssue {
severity: Severity::Warning,
description: format!(
"face {} has near-zero area ({area:.2e} < {area_tol_sq:.2e})",
fid.index()
),
});
}
}
}
let all_edges = explorer::solid_edges(topo, solid)?;
for eid in &all_edges {
let edge = topo.edge(*eid)?;
if !edge.is_closed() {
let p_start = topo.vertex(edge.start())?.point();
let p_end = topo.vertex(edge.end())?.point();
let dx = p_start.x() - p_end.x();
let dy = p_start.y() - p_end.y();
let dz = p_start.z() - p_end.z();
let dist = (dx * dx + dy * dy + dz * dz).sqrt();
if dist < tol.linear {
issues.push(ValidationIssue {
severity: Severity::Warning,
description: format!(
"edge {} has near-zero length ({dist:.2e} < {:.2e})",
eid.index(),
tol.linear
),
});
}
}
}
for fid in &faces {
let face = topo.face(*fid)?;
let wire_ids: Vec<_> = std::iter::once(face.outer_wire())
.chain(face.inner_wires().iter().copied())
.collect();
for wire_id in wire_ids {
let wire = topo.wire(wire_id)?;
if wire.edges().is_empty() {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"wire {} on face {} has no edges",
wire_id.index(),
fid.index()
),
});
}
}
}
{
let mut face_counts = std::collections::HashMap::new();
for fid in &faces {
*face_counts.entry(fid.index()).or_insert(0usize) += 1;
}
for (&idx, &count) in &face_counts {
if count > 1 {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!("face {idx} appears {count} times in shell (redundant)"),
});
}
}
}
let vertex_set: std::collections::HashSet<usize> = {
let verts = explorer::solid_vertices(topo, solid)?;
verts.iter().map(|v| v.index()).collect()
};
for eid in &all_edges {
let edge = topo.edge(*eid)?;
if !vertex_set.contains(&edge.start().index()) {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"edge {} start vertex {} not found in solid",
eid.index(),
edge.start().index()
),
});
}
if !vertex_set.contains(&edge.end().index()) {
issues.push(ValidationIssue {
severity: Severity::Error,
description: format!(
"edge {} end vertex {} not found in solid",
eid.index(),
edge.end().index()
),
});
}
}
Ok(ValidationReport { issues })
}
fn polygon_area_3d(positions: &[brepkit_math::vec::Point3]) -> f64 {
use brepkit_math::vec::Vec3;
if positions.len() < 3 {
return 0.0;
}
let p0 = positions[0];
let mut sum = Vec3::new(0.0, 0.0, 0.0);
for i in 1..positions.len() - 1 {
let a = positions[i] - p0;
let b = positions[i + 1] - p0;
sum += a.cross(b);
}
sum.length() * 0.5
}
#[cfg(test)]
mod tests;