Skip to main content

brepkit_check/validate/
mod.rs

1//! Hierarchical shape validation.
2
3pub mod checks;
4pub(crate) mod edge;
5pub(crate) mod face;
6pub mod shell;
7pub(crate) mod solid;
8pub(crate) mod vertex;
9pub(crate) mod wire;
10
11pub use checks::{CheckId, EntityRef, Severity, ValidationIssue, ValidationReport};
12
13use std::collections::HashSet;
14
15use brepkit_topology::Topology;
16use brepkit_topology::shell::ShellId;
17use brepkit_topology::solid::SolidId;
18
19use crate::CheckError;
20
21/// Options controlling which checks run.
22#[derive(Debug, Clone)]
23pub struct ValidateOptions {
24    /// Geometric tolerance scale factor (default 1.0).
25    pub tolerance_scale: f64,
26    /// Checks to skip.
27    pub disabled_checks: HashSet<CheckId>,
28}
29
30impl Default for ValidateOptions {
31    fn default() -> Self {
32        Self {
33            tolerance_scale: 1.0,
34            disabled_checks: HashSet::new(),
35        }
36    }
37}
38
39/// Validate a solid (full check suite).
40///
41/// Runs solid-level checks, then shell and wire checks on each shell.
42///
43/// # Errors
44///
45/// Returns an error if topology lookups fail.
46pub fn validate_solid(
47    topo: &Topology,
48    solid_id: SolidId,
49    options: &ValidateOptions,
50) -> Result<ValidationReport, CheckError> {
51    let mut report = ValidationReport::default();
52    let solid_data = topo.solid(solid_id)?;
53
54    if !options
55        .disabled_checks
56        .contains(&CheckId::SolidEulerCharacteristic)
57    {
58        report.issues.extend(solid::check_euler(topo, solid_id)?);
59    }
60    if !options
61        .disabled_checks
62        .contains(&CheckId::SolidDuplicateFaces)
63    {
64        report
65            .issues
66            .extend(solid::check_duplicate_faces(topo, solid_id)?);
67    }
68
69    let shells: Vec<_> = std::iter::once(solid_data.outer_shell())
70        .chain(solid_data.inner_shells().iter().copied())
71        .collect();
72    for &sid in &shells {
73        report
74            .issues
75            .extend(validate_shell_checks(topo, sid, options)?);
76    }
77
78    Ok(report)
79}
80
81/// Validate a single shell.
82///
83/// Runs shell-level checks and wire checks for each face.
84///
85/// # Errors
86///
87/// Returns an error if topology lookups fail.
88pub fn validate_shell(
89    topo: &Topology,
90    shell_id: ShellId,
91    options: &ValidateOptions,
92) -> Result<ValidationReport, CheckError> {
93    let mut report = ValidationReport::default();
94    report
95        .issues
96        .extend(validate_shell_checks(topo, shell_id, options)?);
97    Ok(report)
98}
99
100/// Internal: run shell + wire checks on a shell.
101fn validate_shell_checks(
102    topo: &Topology,
103    shell_id: ShellId,
104    options: &ValidateOptions,
105) -> Result<Vec<ValidationIssue>, CheckError> {
106    let mut issues = Vec::new();
107
108    if !options.disabled_checks.contains(&CheckId::ShellEmpty) {
109        issues.extend(shell::check_shell_empty(topo, shell_id)?);
110    }
111    if !options.disabled_checks.contains(&CheckId::ShellConnected) {
112        issues.extend(shell::check_shell_connected(topo, shell_id)?);
113    }
114    if !options.disabled_checks.contains(&CheckId::ShellClosed) {
115        issues.extend(shell::check_shell_closed(topo, shell_id)?);
116    }
117    if !options
118        .disabled_checks
119        .contains(&CheckId::ShellOrientationConsistent)
120    {
121        issues.extend(shell::check_shell_orientation(topo, shell_id)?);
122    }
123
124    let shell = topo.shell(shell_id)?;
125    let mut checked_wires = HashSet::new();
126    for &fid in shell.faces() {
127        let face = topo.face(fid)?;
128        let mut wire_ids = vec![face.outer_wire()];
129        wire_ids.extend(face.inner_wires().iter().copied());
130        for wid in wire_ids {
131            if checked_wires.insert(wid) {
132                if !options.disabled_checks.contains(&CheckId::WireEmpty) {
133                    issues.extend(wire::check_wire_empty(topo, wid)?);
134                }
135                if !options.disabled_checks.contains(&CheckId::WireNotConnected) {
136                    issues.extend(wire::check_wire_connected(topo, wid)?);
137                }
138                if !options.disabled_checks.contains(&CheckId::WireClosure3D) {
139                    issues.extend(wire::check_wire_closure(topo, wid)?);
140                }
141                if !options
142                    .disabled_checks
143                    .contains(&CheckId::WireRedundantEdge)
144                {
145                    issues.extend(wire::check_wire_redundant(topo, wid)?);
146                }
147                if !options
148                    .disabled_checks
149                    .contains(&CheckId::WireSelfIntersection)
150                {
151                    issues.extend(wire::check_wire_self_intersection(
152                        topo,
153                        wid,
154                        options.tolerance_scale * 1e-6,
155                    )?);
156                }
157            }
158        }
159    }
160
161    let mut checked_faces = HashSet::new();
162    for &fid in shell.faces() {
163        if checked_faces.insert(fid) {
164            if !options.disabled_checks.contains(&CheckId::FaceNoSurface) {
165                issues.extend(face::check_face_has_surface(topo, fid)?);
166            }
167            if !options
168                .disabled_checks
169                .contains(&CheckId::FaceOrientationConsistency)
170            {
171                issues.extend(face::check_face_orientation(topo, fid)?);
172            }
173        }
174    }
175
176    let mut checked_edges = HashSet::new();
177    for &fid in shell.faces() {
178        let face = topo.face(fid)?;
179        let mut wire_ids = vec![face.outer_wire()];
180        wire_ids.extend(face.inner_wires().iter().copied());
181        for wid in wire_ids {
182            let wire_data = topo.wire(wid)?;
183            for oe in wire_data.edges() {
184                let eid = oe.edge();
185                if checked_edges.insert(eid) {
186                    if !options.disabled_checks.contains(&CheckId::EdgeRangeValid) {
187                        issues.extend(edge::check_edge_range(
188                            topo,
189                            eid,
190                            options.tolerance_scale * 1e-7,
191                        )?);
192                    }
193                    if !options.disabled_checks.contains(&CheckId::EdgeDegenerate) {
194                        issues.extend(edge::check_edge_degenerate(
195                            topo,
196                            eid,
197                            options.tolerance_scale * 1e-7,
198                        )?);
199                    }
200                    if !options.disabled_checks.contains(&CheckId::VertexOnCurve) {
201                        let edge_data = topo.edge(eid)?;
202                        issues.extend(vertex::check_vertex_on_curve(
203                            topo,
204                            edge_data.start(),
205                            eid,
206                            options.tolerance_scale * 1e-4,
207                        )?);
208                        if edge_data.start() != edge_data.end() {
209                            issues.extend(vertex::check_vertex_on_curve(
210                                topo,
211                                edge_data.end(),
212                                eid,
213                                options.tolerance_scale * 1e-4,
214                            )?);
215                        }
216                    }
217                    if !options.disabled_checks.contains(&CheckId::VertexOnSurface) {
218                        let edge_data = topo.edge(eid)?;
219                        issues.extend(vertex::check_vertex_on_surface(
220                            topo,
221                            edge_data.start(),
222                            fid,
223                            options.tolerance_scale * 1e-4,
224                        )?);
225                        if edge_data.start() != edge_data.end() {
226                            issues.extend(vertex::check_vertex_on_surface(
227                                topo,
228                                edge_data.end(),
229                                fid,
230                                options.tolerance_scale * 1e-4,
231                            )?);
232                        }
233                    }
234                }
235            }
236        }
237    }
238
239    // SameParameter: check edge's 3D curve vs PCurve on each adjacent face.
240    if !options
241        .disabled_checks
242        .contains(&CheckId::EdgeSameParameter)
243    {
244        let mut sp_checked = HashSet::new();
245        for &fid in shell.faces() {
246            let face = topo.face(fid)?;
247            let mut wire_ids = vec![face.outer_wire()];
248            wire_ids.extend(face.inner_wires().iter().copied());
249            for wid in wire_ids {
250                let wire_data = topo.wire(wid)?;
251                for oe in wire_data.edges() {
252                    let eid = oe.edge();
253                    if sp_checked.insert((eid, fid)) {
254                        issues.extend(edge::check_edge_same_parameter(
255                            topo,
256                            eid,
257                            fid,
258                            options.tolerance_scale * 1e-4,
259                        )?);
260                    }
261                }
262            }
263        }
264    }
265
266    Ok(issues)
267}
268
269#[cfg(test)]
270mod tests {
271    #![allow(clippy::unwrap_used, clippy::expect_used)]
272
273    use brepkit_topology::Topology;
274    use brepkit_topology::test_utils::make_unit_cube_manifold;
275
276    use super::*;
277
278    #[test]
279    fn valid_box_no_issues() {
280        let mut topo = Topology::new();
281        let cube = make_unit_cube_manifold(&mut topo);
282        let opts = ValidateOptions::default();
283        let report = validate_solid(&topo, cube, &opts).unwrap();
284        assert!(
285            report.is_valid(),
286            "unit cube should have no errors, got: {:?}",
287            report.issues
288        );
289        assert_eq!(report.error_count(), 0);
290    }
291
292    #[test]
293    fn edge_range_valid_for_box() {
294        let mut topo = Topology::new();
295        let cube = make_unit_cube_manifold(&mut topo);
296        let opts = ValidateOptions::default();
297        let report = validate_solid(&topo, cube, &opts).unwrap();
298        let range_issues: Vec<_> = report
299            .issues
300            .iter()
301            .filter(|i| i.check == CheckId::EdgeRangeValid)
302            .collect();
303        assert!(
304            range_issues.is_empty(),
305            "box edges should have valid ranges, got: {range_issues:?}"
306        );
307    }
308
309    #[test]
310    fn valid_box_detailed() {
311        let mut topo = Topology::new();
312        let cube = make_unit_cube_manifold(&mut topo);
313        let opts = ValidateOptions::default();
314        let report = validate_solid(&topo, cube, &opts).unwrap();
315        assert_eq!(report.error_count(), 0, "errors: {:?}", report.issues);
316        assert_eq!(report.warning_count(), 0, "warnings: {:?}", report.issues);
317    }
318
319    #[test]
320    fn euler_characteristic_correct() {
321        let mut topo = Topology::new();
322        let cube = make_unit_cube_manifold(&mut topo);
323        let opts = ValidateOptions::default();
324        let report = validate_solid(&topo, cube, &opts).unwrap();
325        // Cube: V=8, E=12, F=6 → V-E+F = 2
326        let euler_issues: Vec<_> = report
327            .issues
328            .iter()
329            .filter(|i| i.check == CheckId::SolidEulerCharacteristic)
330            .collect();
331        assert!(
332            euler_issues.is_empty(),
333            "cube Euler characteristic should be 2, got issues: {euler_issues:?}"
334        );
335    }
336}