Skip to main content

brepkit_operations/
validate.rs

1//! Comprehensive solid validation.
2//!
3//! Performs structural and geometric validation on solids.
4
5use brepkit_math::tolerance::Tolerance;
6use brepkit_topology::Topology;
7use brepkit_topology::TopologyError;
8use brepkit_topology::explorer;
9use brepkit_topology::solid::SolidId;
10
11/// A validation issue found in a solid.
12#[derive(Debug, Clone)]
13pub struct ValidationIssue {
14    /// Severity of the issue.
15    pub severity: Severity,
16    /// Human-readable description.
17    pub description: String,
18}
19
20/// Severity of a validation issue.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Severity {
23    /// The solid is invalid and may cause downstream failures.
24    Error,
25    /// The solid has a potential problem but may still be usable.
26    Warning,
27}
28
29/// Result of validating a solid.
30#[derive(Debug, Clone)]
31pub struct ValidationReport {
32    /// All issues found.
33    pub issues: Vec<ValidationIssue>,
34}
35
36impl ValidationReport {
37    /// Whether the solid passed all validation checks (no errors).
38    #[must_use]
39    pub fn is_valid(&self) -> bool {
40        !self.issues.iter().any(|i| i.severity == Severity::Error)
41    }
42
43    /// Count of error-severity issues.
44    #[must_use]
45    pub fn error_count(&self) -> usize {
46        self.issues
47            .iter()
48            .filter(|i| i.severity == Severity::Error)
49            .count()
50    }
51
52    /// Count of warning-severity issues.
53    #[must_use]
54    pub fn warning_count(&self) -> usize {
55        self.issues
56            .iter()
57            .filter(|i| i.severity == Severity::Warning)
58            .count()
59    }
60}
61
62/// Options for controlling validation tolerance.
63///
64/// Operations like fillet and shell produce NURBS faces where geometric
65/// checks (normal length, face area) may trigger false positives at
66/// default tolerance. Increasing `tolerance_scale` relaxes these
67/// thresholds.
68#[derive(Debug, Clone)]
69pub struct ValidationOptions {
70    /// Multiplier applied to geometric tolerances for the face normal
71    /// length check and the degenerate face area check. Default is `1.0`.
72    /// A value of `10.0` means tolerances are 10x more permissive.
73    pub tolerance_scale: f64,
74    /// Check shell orientation consistency: adjacent faces must traverse
75    /// each shared edge in opposite effective senses (is_forward XOR
76    /// is_reversed). Defaults to `true`: construction ops (revolve,
77    /// extrude, sweep, loft, pipe), GFA boolean outputs, and blend bands
78    /// all emit consistent shells (the orientation-emission campaign).
79    pub check_orientation: bool,
80}
81
82impl Default for ValidationOptions {
83    fn default() -> Self {
84        Self {
85            tolerance_scale: 1.0,
86            check_orientation: true,
87        }
88    }
89}
90
91/// Compute the raw Euler characteristic (V - E + F) for a solid.
92///
93/// Returns the unmodified V - E + F value. For a genus-0 closed manifold
94/// solid without inner wire loops this equals 2. Solids with through-holes
95/// (genus > 0) or inner loops will have different values — use
96/// [`validate_solid`] for a full topological check that accounts for these.
97///
98/// # Errors
99///
100/// Returns an error if topology lookups fail.
101pub fn euler_characteristic(
102    topo: &Topology,
103    solid: SolidId,
104) -> Result<i64, crate::OperationsError> {
105    let (f, e, v) = explorer::solid_entity_counts(topo, solid)?;
106    #[allow(clippy::cast_possible_wrap)]
107    let euler = (v as i64) - (e as i64) + (f as i64);
108    Ok(euler)
109}
110
111/// Validate a solid, returning a report of all issues found.
112///
113/// Checks performed:
114/// Returns `true` if every edge in the face is a straight line.
115fn face_all_edges_straight(
116    topo: &Topology,
117    face: &brepkit_topology::face::Face,
118) -> Result<bool, TopologyError> {
119    for wire_id in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
120        let wire = topo.wire(wire_id)?;
121        for oe in wire.edges() {
122            let edge = topo.edge(oe.edge())?;
123            if !matches!(edge.curve(), brepkit_topology::edge::EdgeCurve::Line) {
124                return Ok(false);
125            }
126        }
127    }
128    Ok(true)
129}
130
131/// 1. **Euler-Poincaré**: V - E + F = 2(1 - g) for genus-g closed solid
132/// 2. **Manifold edges**: each edge shared by exactly 2 faces
133/// 3. **Boundary edges**: no edge shared by only 1 face (open shell)
134/// 4. **Degenerate faces**: each face has at least 3 vertices
135/// 5. **Face normal consistency**: normals should be non-zero
136/// 6. **Wire closure**: every wire forms a closed loop
137/// 7. **Degenerate face area**: near-zero polygon area warning for planar faces
138/// 8. **Zero-length edges**: edges with coincident start/end vertices
139/// 9. **Empty wires**: wires with no edges
140/// 10. **Shell connectivity**: all faces reachable from any face
141/// 11. **Redundant faces**: same face ID appearing twice in shell
142/// 12. **Edge vertex consistency**: edge vertices belong to the solid
143///
144/// # Errors
145///
146/// Returns an error if topology lookups fail.
147pub fn validate_solid(
148    topo: &Topology,
149    solid: SolidId,
150) -> Result<ValidationReport, crate::OperationsError> {
151    validate_solid_with_options(topo, solid, &ValidationOptions::default())
152}
153
154/// Validate a solid with configurable tolerance options.
155///
156/// Same checks as [`validate_solid`] but with tolerance scaling.
157/// Use `ValidationOptions { tolerance_scale: 10.0, .. }` to relax
158/// geometric checks for NURBS faces produced by fillet/shell operations.
159///
160/// # Errors
161///
162/// Returns an error if topology lookups fail.
163#[allow(clippy::too_many_lines)]
164pub fn validate_solid_with_options(
165    topo: &Topology,
166    solid: SolidId,
167    options: &ValidationOptions,
168) -> Result<ValidationReport, crate::OperationsError> {
169    let mut issues = Vec::new();
170    let tol = Tolerance::new();
171    // Clamp to [0.1, 1000]: below 0.1 risks false positives on exact
172    // geometry, above 1000 makes the check meaningless.
173    let scale = options.tolerance_scale.clamp(0.1, 1000.0);
174
175    let (f, e, v) = explorer::solid_entity_counts(topo, solid)?;
176
177    // Euler-Poincaré formula for a cell complex with inner loops:
178    //   V - E + F = 2(1 - g) + L
179    // where g is the genus and L is the total number of inner wire loops
180    // across all faces. For a genus-0 solid with no holes: V-E+F = 2.
181    // With L inner wires: V-E+F = 2 + L.
182    let mut total_inner_loops: i64 = 0;
183    let faces = explorer::solid_faces(topo, solid)?;
184    for fid in &faces {
185        let face = topo.face(*fid)?;
186        #[allow(clippy::cast_possible_wrap)]
187        {
188            total_inner_loops += face.inner_wires().len() as i64;
189        }
190    }
191
192    #[allow(clippy::cast_possible_wrap)]
193    let euler = (v as i64) - (e as i64) + (f as i64);
194    // Adjusted Euler: subtract inner loops to get the standard characteristic.
195    let adjusted_euler = euler - total_inner_loops;
196    let genus_times_2 = 2 - adjusted_euler;
197    if genus_times_2 < 0 || genus_times_2 % 2 != 0 {
198        issues.push(ValidationIssue {
199            severity: Severity::Error,
200            description: format!(
201                "Euler characteristic V-E+F = {euler} is invalid \
202                 (expected V-E+F = 2+L with L={total_inner_loops} inner loops, \
203                 got V={v}, E={e}, F={f})"
204            ),
205        });
206    }
207
208    let edge_map = explorer::edge_to_face_map(topo, solid)?;
209    let mut boundary_edges = 0;
210    let mut non_manifold_edges = 0;
211
212    for (&edge_idx, faces) in &edge_map {
213        match faces.len() {
214            0 => {
215                issues.push(ValidationIssue {
216                    severity: Severity::Error,
217                    description: format!("edge {edge_idx} is not referenced by any face"),
218                });
219            }
220            1 => {
221                boundary_edges += 1;
222            }
223            2 => {} // correct
224            n => {
225                non_manifold_edges += 1;
226                issues.push(ValidationIssue {
227                    severity: Severity::Error,
228                    description: format!(
229                        "edge {edge_idx} is shared by {n} faces (non-manifold, expected 2)"
230                    ),
231                });
232            }
233        }
234    }
235
236    if boundary_edges > 0 {
237        issues.push(ValidationIssue {
238            severity: Severity::Error,
239            description: format!("{boundary_edges} boundary edge(s) found (shell is not closed)"),
240        });
241    }
242
243    if non_manifold_edges > 0 {
244        issues.push(ValidationIssue {
245            severity: Severity::Error,
246            description: format!("{non_manifold_edges} non-manifold edge(s) found"),
247        });
248    }
249
250    // Only faces on a planar surface bounded entirely by straight edges
251    // require ≥3 unique vertices. Faces with curved edges (Circle,
252    // Ellipse, NURBS) or non-planar surfaces (Cylinder, Sphere, Torus,
253    // etc.) can validly have fewer vertices because the surface/edge
254    // geometry defines the boundary shape.
255    let faces = explorer::solid_faces(topo, solid)?;
256    for fid in &faces {
257        let face_data = topo.face(*fid)?;
258        let is_planar = matches!(
259            face_data.surface(),
260            brepkit_topology::face::FaceSurface::Plane { .. }
261        );
262
263        if is_planar && face_all_edges_straight(topo, face_data)? {
264            let face_verts = explorer::face_vertices(topo, *fid)?;
265            if face_verts.len() < 3 {
266                issues.push(ValidationIssue {
267                    severity: Severity::Error,
268                    description: format!(
269                        "face {} has only {} vertices (need at least 3)",
270                        fid.index(),
271                        face_verts.len()
272                    ),
273                });
274            }
275        }
276    }
277
278    let scaled_tol = Tolerance {
279        linear: tol.linear * scale,
280        angular: tol.angular * scale,
281        relative: tol.relative * scale,
282    };
283    for fid in &faces {
284        let face = topo.face(*fid)?;
285        if let brepkit_topology::face::FaceSurface::Plane { normal, .. } = face.surface() {
286            let len = normal.length();
287            if !scaled_tol.approx_eq(len, 1.0) {
288                issues.push(ValidationIssue {
289                    severity: Severity::Warning,
290                    description: format!(
291                        "face {} has non-unit normal (length = {len})",
292                        fid.index()
293                    ),
294                });
295            }
296        }
297    }
298
299    for fid in &faces {
300        let face = topo.face(*fid)?;
301        let wire_ids: Vec<_> = std::iter::once(face.outer_wire())
302            .chain(face.inner_wires().iter().copied())
303            .collect();
304
305        for wire_id in wire_ids {
306            let wire = topo.wire(wire_id)?;
307            if let Err(_e) = brepkit_topology::validation::validate_wire_closed(wire, topo) {
308                issues.push(ValidationIssue {
309                    severity: Severity::Error,
310                    description: format!(
311                        "wire {} on face {} is not closed",
312                        wire_id.index(),
313                        fid.index()
314                    ),
315                });
316            }
317        }
318    }
319
320    // Only meaningful for faces bounded entirely by straight edges.
321    // The polygon area formula uses vertex positions, which is
322    // meaningless when edges are curved (e.g. a cylinder cap has
323    // 1 vertex → zero polygon area despite being a valid disc).
324    let area_tol_sq = scaled_tol.linear * scaled_tol.linear;
325    for fid in &faces {
326        let face = topo.face(*fid)?;
327
328        // Skip non-planar faces and faces with curved edges — the polygon
329        // area formula is only meaningful for planar faces with straight edges.
330        if !matches!(
331            face.surface(),
332            brepkit_topology::face::FaceSurface::Plane { .. }
333        ) {
334            continue;
335        }
336        if !face_all_edges_straight(topo, face)? {
337            continue;
338        }
339
340        let wire = topo.wire(face.outer_wire())?;
341
342        let mut positions = Vec::new();
343        for oe in wire.edges() {
344            let edge = topo.edge(oe.edge())?;
345            let vid = oe.oriented_start(edge);
346            positions.push(topo.vertex(vid)?.point());
347        }
348
349        if positions.len() >= 3 {
350            let area = polygon_area_3d(&positions);
351            if area < area_tol_sq {
352                issues.push(ValidationIssue {
353                    severity: Severity::Warning,
354                    description: format!(
355                        "face {} has near-zero area ({area:.2e} < {area_tol_sq:.2e})",
356                        fid.index()
357                    ),
358                });
359            }
360        }
361    }
362
363    // Skip intentionally closed edges (like circles) when checking for
364    // coincident start/end vertices.
365    let all_edges = explorer::solid_edges(topo, solid)?;
366    for eid in &all_edges {
367        let edge = topo.edge(*eid)?;
368        if !edge.is_closed() {
369            let p_start = topo.vertex(edge.start())?.point();
370            let p_end = topo.vertex(edge.end())?.point();
371            let dx = p_start.x() - p_end.x();
372            let dy = p_start.y() - p_end.y();
373            let dz = p_start.z() - p_end.z();
374            let dist = (dx * dx + dy * dy + dz * dz).sqrt();
375            if dist < tol.linear {
376                issues.push(ValidationIssue {
377                    severity: Severity::Error,
378                    description: format!(
379                        "edge {} has near-zero length ({dist:.2e} < {:.2e})",
380                        eid.index(),
381                        tol.linear
382                    ),
383                });
384            }
385        }
386    }
387
388    for fid in &faces {
389        let face = topo.face(*fid)?;
390        let wire_ids: Vec<_> = std::iter::once(face.outer_wire())
391            .chain(face.inner_wires().iter().copied())
392            .collect();
393
394        for wire_id in wire_ids {
395            let wire = topo.wire(wire_id)?;
396            if wire.edges().is_empty() {
397                issues.push(ValidationIssue {
398                    severity: Severity::Error,
399                    description: format!(
400                        "wire {} on face {} has no edges",
401                        wire_id.index(),
402                        fid.index()
403                    ),
404                });
405            }
406        }
407    }
408
409    // Shell connectivity: all faces should be reachable from any face.
410    // For genus-0 solids (sphere-like), all faces must be in one connected
411    // component. Higher-genus solids (e.g. hollow revolves creating a torus)
412    // can legitimately have multiple face-connected components (inner/outer
413    // shells sharing no edges), so we skip this check for genus > 0.
414    if !faces.is_empty() && genus_times_2 == 0 {
415        let face_set: std::collections::HashSet<usize> = faces.iter().map(|f| f.index()).collect();
416        let mut visited = std::collections::HashSet::new();
417        let mut queue = std::collections::VecDeque::new();
418
419        visited.insert(faces[0].index());
420        queue.push_back(faces[0]);
421
422        while let Some(current) = queue.pop_front() {
423            for adj_faces in edge_map.values() {
424                if adj_faces.iter().any(|f| f.index() == current.index()) {
425                    for neighbor in adj_faces {
426                        if face_set.contains(&neighbor.index()) && visited.insert(neighbor.index())
427                        {
428                            queue.push_back(*neighbor);
429                        }
430                    }
431                }
432            }
433        }
434
435        let unreachable = face_set.len() - visited.len();
436        if unreachable > 0 {
437            issues.push(ValidationIssue {
438                severity: Severity::Error,
439                description: format!(
440                    "shell is disconnected: {unreachable} face(s) not reachable from first face"
441                ),
442            });
443        }
444    }
445
446    {
447        let mut face_counts = std::collections::HashMap::new();
448        for fid in &faces {
449            *face_counts.entry(fid.index()).or_insert(0usize) += 1;
450        }
451        for (&idx, &count) in &face_counts {
452            if count > 1 {
453                issues.push(ValidationIssue {
454                    severity: Severity::Error,
455                    description: format!("face {idx} appears {count} times in shell (redundant)"),
456                });
457            }
458        }
459    }
460
461    let vertex_set: std::collections::HashSet<usize> = {
462        let verts = explorer::solid_vertices(topo, solid)?;
463        verts.iter().map(|v| v.index()).collect()
464    };
465    for eid in &all_edges {
466        let edge = topo.edge(*eid)?;
467        if !vertex_set.contains(&edge.start().index()) {
468            issues.push(ValidationIssue {
469                severity: Severity::Error,
470                description: format!(
471                    "edge {} start vertex {} not found in solid",
472                    eid.index(),
473                    edge.start().index()
474                ),
475            });
476        }
477        if !vertex_set.contains(&edge.end().index()) {
478            issues.push(ValidationIssue {
479                severity: Severity::Error,
480                description: format!(
481                    "edge {} end vertex {} not found in solid",
482                    eid.index(),
483                    edge.end().index()
484                ),
485            });
486        }
487    }
488
489    // Orientation consistency: adjacent faces must traverse a shared edge in
490    // opposite effective senses (is_forward XOR is_reversed). Edge-use
491    // counting alone cannot see this — the mixed-socket bin's body operand
492    // passed every count while 20 shared edges carried same-sense uses,
493    // which surfaced two subsystems later as winding-inverted mesh triangles.
494    // Delegates to the check-crate shell validator per shell.
495    if options.check_orientation {
496        let solid_data = topo.solid(solid)?;
497        let shells = std::iter::once(solid_data.outer_shell())
498            .chain(solid_data.inner_shells().iter().copied())
499            .collect::<Vec<_>>();
500        for shell_id in shells {
501            for issue in brepkit_check::validate::shell::check_shell_orientation(topo, shell_id)
502                .map_err(|e| crate::OperationsError::InvalidInput {
503                    reason: e.to_string(),
504                })?
505            {
506                issues.push(ValidationIssue {
507                    severity: Severity::Error,
508                    description: issue.description,
509                });
510            }
511        }
512    }
513
514    Ok(ValidationReport { issues })
515}
516
517/// Validate a solid with relaxed checks suitable for assembled geometry.
518///
519/// Operations like boolean, fillet, and shell produce solids where faces
520/// may not share edges (each face has its own wire/edge topology). These
521/// shapes are geometrically correct (volumes, tessellation, I/O all work)
522/// but fail strict manifold checks.
523///
524/// Relaxed mode checks:
525/// - Wire closure (every wire forms a closed loop)
526/// - Degenerate faces (planar faces with < 3 vertices)
527/// - Empty wires
528/// - Zero-length edges
529/// - Redundant faces
530/// - Edge vertex consistency
531///
532/// Skipped in relaxed mode:
533/// - Euler-Poincaré characteristic (assembled shells may have multiple components)
534/// - Boundary edges (faces from different operations may not share edges)
535/// - Non-manifold edges (edge duplication is expected in assembled geometry)
536/// - Shell connectivity (multiple disconnected face groups are valid)
537///
538/// # Errors
539///
540/// Returns an error if topology lookups fail.
541pub fn validate_solid_relaxed(
542    topo: &Topology,
543    solid: SolidId,
544) -> Result<ValidationReport, crate::OperationsError> {
545    validate_solid_relaxed_with_options(topo, solid, &ValidationOptions::default())
546}
547
548/// Validate a solid with relaxed checks and configurable tolerance options.
549///
550/// Combines the relaxed check set of [`validate_solid_relaxed`] with the
551/// tolerance scaling of [`validate_solid_with_options`].
552///
553/// # Errors
554///
555/// Returns an error if topology lookups fail.
556#[allow(clippy::too_many_lines)]
557pub fn validate_solid_relaxed_with_options(
558    topo: &Topology,
559    solid: SolidId,
560    options: &ValidationOptions,
561) -> Result<ValidationReport, crate::OperationsError> {
562    let mut issues = Vec::new();
563    let tol = Tolerance::new();
564    // Clamp to [0.1, 1000]: below 0.1 risks false positives on exact
565    // geometry, above 1000 makes the check meaningless.
566    let scale = options.tolerance_scale.clamp(0.1, 1000.0);
567
568    let faces = explorer::solid_faces(topo, solid)?;
569
570    for fid in &faces {
571        let face_data = topo.face(*fid)?;
572        let is_planar = matches!(
573            face_data.surface(),
574            brepkit_topology::face::FaceSurface::Plane { .. }
575        );
576
577        if is_planar && face_all_edges_straight(topo, face_data)? {
578            let face_verts = explorer::face_vertices(topo, *fid)?;
579            if face_verts.len() < 3 {
580                issues.push(ValidationIssue {
581                    severity: Severity::Warning,
582                    description: format!(
583                        "face {} has only {} vertices (need at least 3)",
584                        fid.index(),
585                        face_verts.len()
586                    ),
587                });
588            }
589        }
590    }
591
592    let scaled_tol = Tolerance {
593        linear: tol.linear * scale,
594        angular: tol.angular * scale,
595        relative: tol.relative * scale,
596    };
597    for fid in &faces {
598        let face = topo.face(*fid)?;
599        if let brepkit_topology::face::FaceSurface::Plane { normal, .. } = face.surface() {
600            let len = normal.length();
601            if !scaled_tol.approx_eq(len, 1.0) {
602                issues.push(ValidationIssue {
603                    severity: Severity::Warning,
604                    description: format!(
605                        "face {} has non-unit normal (length = {len})",
606                        fid.index()
607                    ),
608                });
609            }
610        }
611    }
612
613    // Wire closure — demoted to Warning for relaxed validation.
614    // Boolean assembly can produce faces with technically-open wires
615    // when edge dedup or vertex merging creates tiny gaps. These are
616    // usually below the linear tolerance and don't affect downstream use.
617    for fid in &faces {
618        let face = topo.face(*fid)?;
619        let wire_ids: Vec<_> = std::iter::once(face.outer_wire())
620            .chain(face.inner_wires().iter().copied())
621            .collect();
622
623        for wire_id in wire_ids {
624            let wire = topo.wire(wire_id)?;
625            if let Err(_e) = brepkit_topology::validation::validate_wire_closed(wire, topo) {
626                // Demoted to Warning: boolean operations can produce
627                // micro-gaps in wires from edge splitting that don't affect
628                // downstream tessellation or volume. Strict checking would
629                // reject ~25% of currently valid boolean results.
630                issues.push(ValidationIssue {
631                    severity: Severity::Warning,
632                    description: format!(
633                        "wire {} on face {} is not closed",
634                        wire_id.index(),
635                        fid.index()
636                    ),
637                });
638            }
639        }
640    }
641
642    let area_tol_sq = scaled_tol.linear * scaled_tol.linear;
643    for fid in &faces {
644        let face = topo.face(*fid)?;
645
646        if !matches!(
647            face.surface(),
648            brepkit_topology::face::FaceSurface::Plane { .. }
649        ) {
650            continue;
651        }
652        if !face_all_edges_straight(topo, face)? {
653            continue;
654        }
655
656        let wire = topo.wire(face.outer_wire())?;
657        let mut positions = Vec::new();
658        for oe in wire.edges() {
659            let edge = topo.edge(oe.edge())?;
660            let vid = oe.oriented_start(edge);
661            positions.push(topo.vertex(vid)?.point());
662        }
663
664        if positions.len() >= 3 {
665            let area = polygon_area_3d(&positions);
666            if area < area_tol_sq {
667                issues.push(ValidationIssue {
668                    severity: Severity::Warning,
669                    description: format!(
670                        "face {} has near-zero area ({area:.2e} < {area_tol_sq:.2e})",
671                        fid.index()
672                    ),
673                });
674            }
675        }
676    }
677
678    // Zero-length edges — demoted to Warning in relaxed validation.
679    // Boolean edge splitting can create tiny edges below tolerance.
680    let all_edges = explorer::solid_edges(topo, solid)?;
681    for eid in &all_edges {
682        let edge = topo.edge(*eid)?;
683        if !edge.is_closed() {
684            let p_start = topo.vertex(edge.start())?.point();
685            let p_end = topo.vertex(edge.end())?.point();
686            let dx = p_start.x() - p_end.x();
687            let dy = p_start.y() - p_end.y();
688            let dz = p_start.z() - p_end.z();
689            let dist = (dx * dx + dy * dy + dz * dz).sqrt();
690            if dist < tol.linear {
691                issues.push(ValidationIssue {
692                    severity: Severity::Warning,
693                    description: format!(
694                        "edge {} has near-zero length ({dist:.2e} < {:.2e})",
695                        eid.index(),
696                        tol.linear
697                    ),
698                });
699            }
700        }
701    }
702
703    for fid in &faces {
704        let face = topo.face(*fid)?;
705        let wire_ids: Vec<_> = std::iter::once(face.outer_wire())
706            .chain(face.inner_wires().iter().copied())
707            .collect();
708
709        for wire_id in wire_ids {
710            let wire = topo.wire(wire_id)?;
711            if wire.edges().is_empty() {
712                issues.push(ValidationIssue {
713                    severity: Severity::Error,
714                    description: format!(
715                        "wire {} on face {} has no edges",
716                        wire_id.index(),
717                        fid.index()
718                    ),
719                });
720            }
721        }
722    }
723
724    {
725        let mut face_counts = std::collections::HashMap::new();
726        for fid in &faces {
727            *face_counts.entry(fid.index()).or_insert(0usize) += 1;
728        }
729        for (&idx, &count) in &face_counts {
730            if count > 1 {
731                issues.push(ValidationIssue {
732                    severity: Severity::Error,
733                    description: format!("face {idx} appears {count} times in shell (redundant)"),
734                });
735            }
736        }
737    }
738
739    let vertex_set: std::collections::HashSet<usize> = {
740        let verts = explorer::solid_vertices(topo, solid)?;
741        verts.iter().map(|v| v.index()).collect()
742    };
743    for eid in &all_edges {
744        let edge = topo.edge(*eid)?;
745        if !vertex_set.contains(&edge.start().index()) {
746            issues.push(ValidationIssue {
747                severity: Severity::Error,
748                description: format!(
749                    "edge {} start vertex {} not found in solid",
750                    eid.index(),
751                    edge.start().index()
752                ),
753            });
754        }
755        if !vertex_set.contains(&edge.end().index()) {
756            issues.push(ValidationIssue {
757                severity: Severity::Error,
758                description: format!(
759                    "edge {} end vertex {} not found in solid",
760                    eid.index(),
761                    edge.end().index()
762                ),
763            });
764        }
765    }
766
767    Ok(ValidationReport { issues })
768}
769
770/// Compute the area of a 3D polygon using the cross-product method.
771///
772/// For a planar polygon with vertices `p0, p1, ..., pN`, the area is
773/// half the magnitude of the sum of cross products `(p[i] - p[0]) × (p[i+1] - p[0])`.
774fn polygon_area_3d(positions: &[brepkit_math::vec::Point3]) -> f64 {
775    use brepkit_math::vec::Vec3;
776
777    if positions.len() < 3 {
778        return 0.0;
779    }
780
781    let p0 = positions[0];
782    let mut sum = Vec3::new(0.0, 0.0, 0.0);
783
784    for i in 1..positions.len() - 1 {
785        let a = positions[i] - p0;
786        let b = positions[i + 1] - p0;
787        sum += a.cross(b);
788    }
789
790    sum.length() * 0.5
791}
792
793#[cfg(test)]
794mod tests;