1use brepkit_math::tolerance::Tolerance;
6use brepkit_topology::Topology;
7use brepkit_topology::TopologyError;
8use brepkit_topology::explorer;
9use brepkit_topology::solid::SolidId;
10
11#[derive(Debug, Clone)]
13pub struct ValidationIssue {
14 pub severity: Severity,
16 pub description: String,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Severity {
23 Error,
25 Warning,
27}
28
29#[derive(Debug, Clone)]
31pub struct ValidationReport {
32 pub issues: Vec<ValidationIssue>,
34}
35
36impl ValidationReport {
37 #[must_use]
39 pub fn is_valid(&self) -> bool {
40 !self.issues.iter().any(|i| i.severity == Severity::Error)
41 }
42
43 #[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 #[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#[derive(Debug, Clone)]
69pub struct ValidationOptions {
70 pub tolerance_scale: f64,
74 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
91pub 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
111fn 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
131pub 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#[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 let scale = options.tolerance_scale.clamp(0.1, 1000.0);
174
175 let (f, e, v) = explorer::solid_entity_counts(topo, solid)?;
176
177 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 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 => {} 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 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 let area_tol_sq = scaled_tol.linear * scaled_tol.linear;
325 for fid in &faces {
326 let face = topo.face(*fid)?;
327
328 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 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 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 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
517pub 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#[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 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 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 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 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
770fn 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;