Skip to main content

brepkit_check/validate/
shell.rs

1//! Shell validation checks.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4
5use brepkit_topology::Topology;
6use brepkit_topology::edge::EdgeId;
7use brepkit_topology::shell::ShellId;
8
9use super::checks::{CheckId, EntityRef, Severity, ValidationIssue};
10use crate::CheckError;
11
12/// Check shell is not empty.
13///
14/// # Errors
15///
16/// Returns [`CheckError`] on a topology lookup failure.
17pub fn check_shell_empty(
18    topo: &Topology,
19    shell_id: ShellId,
20) -> Result<Vec<ValidationIssue>, CheckError> {
21    let shell = topo.shell(shell_id)?;
22    if shell.faces().is_empty() {
23        return Ok(vec![ValidationIssue {
24            check: CheckId::ShellEmpty,
25            severity: Severity::Error,
26            entity: EntityRef::Shell(shell_id),
27            description: "shell contains no faces".into(),
28            deviation: None,
29        }]);
30    }
31    Ok(vec![])
32}
33
34/// Collect all edge IDs from a face (outer wire + inner wires).
35fn face_edge_ids(
36    topo: &Topology,
37    face_id: brepkit_topology::face::FaceId,
38) -> Result<Vec<EdgeId>, crate::CheckError> {
39    let face = topo.face(face_id)?;
40    let mut eids = Vec::new();
41    let wire = topo.wire(face.outer_wire())?;
42    for oe in wire.edges() {
43        eids.push(oe.edge());
44    }
45    for &iw in face.inner_wires() {
46        let inner_wire = topo.wire(iw)?;
47        for oe in inner_wire.edges() {
48            eids.push(oe.edge());
49        }
50    }
51    Ok(eids)
52}
53
54/// Check shell connectivity: all faces connected via shared edges (BFS).
55///
56/// # Errors
57///
58/// Returns [`CheckError`] on a topology lookup failure.
59#[allow(clippy::too_many_lines)]
60pub fn check_shell_connected(
61    topo: &Topology,
62    shell_id: ShellId,
63) -> Result<Vec<ValidationIssue>, CheckError> {
64    let shell = topo.shell(shell_id)?;
65    let faces = shell.faces();
66    if faces.len() <= 1 {
67        return Ok(vec![]);
68    }
69
70    let mut edge_to_faces: HashMap<EdgeId, Vec<usize>> = HashMap::new();
71    for (fi, &fid) in faces.iter().enumerate() {
72        for eid in face_edge_ids(topo, fid)? {
73            edge_to_faces.entry(eid).or_default().push(fi);
74        }
75    }
76
77    let mut visited = HashSet::new();
78    let mut queue = VecDeque::new();
79    visited.insert(0usize);
80    queue.push_back(0usize);
81
82    while let Some(fi) = queue.pop_front() {
83        let fid = faces[fi];
84        for eid in face_edge_ids(topo, fid)? {
85            if let Some(neighbors) = edge_to_faces.get(&eid) {
86                for &nfi in neighbors {
87                    if visited.insert(nfi) {
88                        queue.push_back(nfi);
89                    }
90                }
91            }
92        }
93    }
94
95    if visited.len() < faces.len() {
96        return Ok(vec![ValidationIssue {
97            check: CheckId::ShellConnected,
98            severity: Severity::Error,
99            entity: EntityRef::Shell(shell_id),
100            description: format!(
101                "shell has {} connected components ({} of {} faces reached)",
102                faces.len() - visited.len() + 1,
103                visited.len(),
104                faces.len()
105            ),
106            deviation: None,
107        }]);
108    }
109    Ok(vec![])
110}
111
112/// Check that shell face orientations are consistent: for each edge shared
113/// by two faces, it should be used once FORWARD and once REVERSED.
114///
115/// # Errors
116///
117/// Returns [`CheckError`] on a topology lookup failure.
118pub fn check_shell_orientation(
119    topo: &Topology,
120    shell_id: ShellId,
121) -> Result<Vec<ValidationIssue>, CheckError> {
122    let shell = topo.shell(shell_id)?;
123
124    let mut edge_uses: HashMap<EdgeId, Vec<(usize, bool)>> = HashMap::new();
125
126    for (fi, &fid) in shell.faces().iter().enumerate() {
127        let face = topo.face(fid)?;
128        let face_reversed = face.is_reversed();
129        let wire = topo.wire(face.outer_wire())?;
130        for oe in wire.edges() {
131            let effective_forward = oe.is_forward() != face_reversed;
132            edge_uses
133                .entry(oe.edge())
134                .or_default()
135                .push((fi, effective_forward));
136        }
137        for &iw in face.inner_wires() {
138            if let Ok(inner_wire) = topo.wire(iw) {
139                for oe in inner_wire.edges() {
140                    let effective_forward = oe.is_forward() != face_reversed;
141                    edge_uses
142                        .entry(oe.edge())
143                        .or_default()
144                        .push((fi, effective_forward));
145                }
146            }
147        }
148    }
149
150    let mut misoriented = 0usize;
151
152    for uses in edge_uses.values() {
153        if uses.len() == 2 {
154            // Shared edge: should be used once forward and once reversed
155            if uses[0].1 == uses[1].1 {
156                misoriented += 1;
157            }
158        }
159    }
160
161    if misoriented > 0 {
162        return Ok(vec![ValidationIssue {
163            check: CheckId::ShellOrientationConsistent,
164            severity: Severity::Error,
165            entity: EntityRef::Shell(shell_id),
166            description: format!("{misoriented} shared edges have inconsistent face orientations"),
167            deviation: Some(misoriented as f64),
168        }]);
169    }
170
171    Ok(vec![])
172}
173
174/// Check shell closure: every edge shared by exactly 2 faces.
175///
176/// # Errors
177///
178/// Returns [`CheckError`] on a topology lookup failure.
179pub fn check_shell_closed(
180    topo: &Topology,
181    shell_id: ShellId,
182) -> Result<Vec<ValidationIssue>, CheckError> {
183    let shell = topo.shell(shell_id)?;
184    let mut edge_count: HashMap<EdgeId, usize> = HashMap::new();
185
186    for &fid in shell.faces() {
187        for eid in face_edge_ids(topo, fid)? {
188            *edge_count.entry(eid).or_default() += 1;
189        }
190    }
191
192    let boundary = edge_count.values().filter(|&&c| c == 1).count();
193    let nonmanifold = edge_count.values().filter(|&&c| c > 2).count();
194
195    if boundary == 0 && nonmanifold == 0 {
196        return Ok(vec![]);
197    }
198
199    let mut issues = Vec::new();
200    if boundary > 0 {
201        issues.push(ValidationIssue {
202            check: CheckId::ShellClosed,
203            severity: Severity::Error,
204            entity: EntityRef::Shell(shell_id),
205            description: format!("shell has {boundary} free (boundary) edges"),
206            deviation: Some(boundary as f64),
207        });
208    }
209    if nonmanifold > 0 {
210        issues.push(ValidationIssue {
211            check: CheckId::ShellClosed,
212            severity: Severity::Error,
213            entity: EntityRef::Shell(shell_id),
214            description: format!("shell has {nonmanifold} non-manifold edges (shared by >2 faces)"),
215            deviation: Some(nonmanifold as f64),
216        });
217    }
218    Ok(issues)
219}