1use std::collections::HashMap;
30use std::fmt;
31
32use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
33use ogeom_geom::{Curve2d, Curve3d, Surface};
34use ogeom_mesh::Deflection;
35use ogeom_topo::{EdgeRepr, Filter, Model, Shape, ShapeType, TShapeId, explore, explore_unique};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
39pub enum Severity {
40 Suspect,
42 Broken,
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub struct Problem {
49 pub severity: Severity,
51 pub at: Shape,
53 pub kind: ShapeType,
55 pub what: String,
57}
58
59impl fmt::Display for Problem {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 let mark = match self.severity {
62 Severity::Broken => "broken",
63 Severity::Suspect => "suspect",
64 };
65 write!(f, "[{mark}] {:?}: {}", self.kind, self.what)
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Default)]
71pub struct Diagnosis {
72 pub problems: Vec<Problem>,
74}
75
76impl Diagnosis {
77 #[must_use]
79 pub fn is_valid(&self) -> bool {
80 self.problems.is_empty()
81 }
82
83 #[must_use]
87 pub fn is_usable(&self) -> bool {
88 !self.problems.iter().any(|p| p.severity == Severity::Broken)
89 }
90
91 #[must_use]
93 pub fn worst(&self) -> Option<Severity> {
94 self.problems.iter().map(|p| p.severity).max()
95 }
96
97 #[must_use]
99 pub fn of(&self, severity: Severity) -> Vec<&Problem> {
100 self.problems
101 .iter()
102 .filter(|p| p.severity == severity)
103 .collect()
104 }
105
106 fn note(&mut self, severity: Severity, at: &Shape, kind: ShapeType, what: String) {
107 self.problems.push(Problem {
108 severity,
109 at: at.clone(),
110 kind,
111 what,
112 });
113 }
114}
115
116impl fmt::Display for Diagnosis {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 if self.problems.is_empty() {
119 return write!(f, "valid");
120 }
121 for (i, problem) in self.problems.iter().enumerate() {
122 if i > 0 {
123 writeln!(f)?;
124 }
125 write!(f, "{problem}")?;
126 }
127 Ok(())
128 }
129}
130
131pub fn check(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Diagnosis> {
140 if model.node(shape).is_none() {
141 ogeom_bail!(Dangling, "shape refers to a node not in this model");
142 }
143 let mut found = Diagnosis::default();
144
145 for edge in explore_unique(model, shape, ShapeType::Edge)? {
146 check_edge(model, &edge, tol, &mut found)?;
147 }
148 for wire in explore_unique(model, shape, ShapeType::Wire)? {
149 check_wire(model, &wire, tol, &mut found)?;
150 }
151 for face in explore_unique(model, shape, ShapeType::Face)? {
152 check_face(model, &face, tol, &mut found)?;
153 }
154 for shell in explore_unique(model, shape, ShapeType::Shell)? {
155 check_shell(model, &shell, &mut found)?;
156 }
157 check_containment(model, shape, &mut found)?;
158 Ok(found)
159}
160
161pub fn check_tessellation(
183 model: &Model,
184 shape: &Shape,
185 deflection: Deflection,
186 tol: Tolerances,
187) -> OgeomResult<Diagnosis> {
188 let mut found = Diagnosis::default();
189
190 for shell in explore_unique(model, shape, ShapeType::Shell)? {
191 if !crate::build::is_shell_closed(model, &shell)? {
195 continue;
196 }
197 let mesh = ogeom_mesh::triangulate(model, &shell, deflection, tol)?;
198 if mesh.is_empty() {
199 found.note(
200 Severity::Broken,
201 &shell,
202 ShapeType::Shell,
203 "the topology says this shell is closed and it tessellates to \
204 nothing at all"
205 .into(),
206 );
207 continue;
208 }
209 if let Some(report) = open_edges(&mesh) {
210 found.note(Severity::Broken, &shell, ShapeType::Shell, report);
211 }
212 }
213 Ok(found)
214}
215
216fn open_edges(mesh: &ogeom_topo::Triangulation) -> Option<String> {
218 let mut uses: HashMap<(u32, u32), usize> = HashMap::new();
219 for triangle in &mesh.triangles {
220 for i in 0..3 {
221 let (a, b) = (triangle[i], triangle[(i + 1) % 3]);
222 *uses.entry((a.min(b), a.max(b))).or_default() += 1;
223 }
224 }
225
226 let mut loose: Vec<&(u32, u32)> = uses
227 .iter()
228 .filter(|(_, n)| **n != 2)
229 .map(|(e, _)| e)
230 .collect();
231 if loose.is_empty() {
232 return None;
233 }
234 loose.sort_unstable();
237
238 let sample: Vec<String> = loose
239 .iter()
240 .take(3)
241 .map(|(a, b)| {
242 let (p, q) = (mesh.positions[*a as usize], mesh.positions[*b as usize]);
243 format!(
244 "({:.6}, {:.6}, {:.6})-({:.6}, {:.6}, {:.6})",
245 p.x, p.y, p.z, q.x, q.y, q.z
246 )
247 })
248 .collect();
249
250 Some(format!(
251 "the topology says this shell is closed, but its mesh has {} triangle \
252 edge(s) not shared by two triangles, so the tessellated solid has a \
253 slit in it. The first are at {}. This is a parameter-space defect \
254 (some face's pcurves do not cover the region its edges bound), and no \
255 topological check can see it",
256 loose.len(),
257 sample.join(", ")
258 ))
259}
260
261fn check_edge(
264 model: &Model,
265 edge: &Shape,
266 tol: Tolerances,
267 found: &mut Diagnosis,
268) -> OgeomResult<()> {
269 let Some(node) = model.node(edge) else {
270 ogeom_bail!(Dangling, "edge is not in this model");
271 };
272 let Some(data) = node.data().as_edge() else {
273 return Ok(());
274 };
275 let reach = data.tolerance.get().max(tol.confusion());
276
277 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
278 if !data.degenerate {
282 found.note(
283 Severity::Broken,
284 edge,
285 ShapeType::Edge,
286 "no curve in space, and not marked degenerate; a boundary walk \
287 will step over it without noticing"
288 .into(),
289 );
290 }
291 return Ok(());
292 };
293 if data.degenerate {
294 found.note(
295 Severity::Suspect,
296 edge,
297 ShapeType::Edge,
298 "marked degenerate but carries a curve in space".into(),
299 );
300 }
301 let Some(geometry) = model.geometry().curve(*curve) else {
302 ogeom_bail!(Dangling, "curve is not in this model");
303 };
304
305 let placement = edge.transform(model.datums())?;
306 let bounds = model.children_of(edge)?;
307 for (parameter, vertex) in [(range.0, bounds.first()), (range.1, bounds.last())] {
308 let Some(vertex) = vertex else { continue };
309 let Some((point, vertex_reach)) = model
310 .node(vertex)
311 .and_then(|n| n.data().as_vertex())
312 .map(|v| (v.point, v.tolerance.get()))
313 else {
314 continue;
315 };
316 let placed = vertex.transform(model.datums())?.apply(point);
317 let on_curve = placement.apply(geometry.point_at(parameter, tol)?);
318 let gap = on_curve.distance(placed);
319 let reach = reach.max(vertex_reach);
324 if gap > reach {
325 found.note(
326 Severity::Broken,
327 edge,
328 ShapeType::Edge,
329 format!(
330 "curve stops {gap} from the vertex it should meet, outside \
331 its tolerance of {reach}; the boundary has a gap there"
332 ),
333 );
334 }
335 }
336
337 if data.same_parameter() {
341 check_same_parameter(model, edge, data, geometry, *range, reach, tol, found)?;
342 }
343 Ok(())
344}
345
346#[allow(clippy::too_many_arguments)]
348fn check_same_parameter(
349 model: &Model,
350 edge: &Shape,
351 data: &ogeom_topo::EdgeData,
352 curve: &ogeom_geom::Curve,
353 range: (f64, f64),
354 reach: f64,
355 tol: Tolerances,
356 found: &mut Diagnosis,
357) -> OgeomResult<()> {
358 const SAMPLES: usize = 8;
359 for repr in &data.representations {
360 let (pcurve_id, pcurve_range, surface_id) = match repr {
361 EdgeRepr::PCurve {
362 curve,
363 range,
364 surface,
365 ..
366 } => (*curve, *range, *surface),
367 EdgeRepr::Seam {
368 forward,
369 range,
370 surface,
371 ..
372 } => (*forward, *range, *surface),
373 _ => continue,
374 };
375 let (Some(pcurve), Some(surface)) = (
376 model.geometry().pcurve(pcurve_id),
377 model.geometry().surface(surface_id),
378 ) else {
379 ogeom_bail!(Dangling, "an edge names geometry not in this model");
380 };
381
382 for i in 0..=SAMPLES {
383 #[allow(clippy::cast_precision_loss)]
384 let t = i as f64 / SAMPLES as f64;
385 let on_curve = curve.point_at(range.0 + (range.1 - range.0) * t, tol)?;
386 let at =
387 pcurve.point_at(pcurve_range.0 + (pcurve_range.1 - pcurve_range.0) * t, tol)?;
388 let Ok(on_surface) = surface.point_at(at.x, at.y, tol) else {
389 continue;
390 };
391 let gap = on_curve.distance(on_surface);
392 if gap > reach {
393 found.note(
394 Severity::Broken,
395 edge,
396 ShapeType::Edge,
397 format!(
398 "claims same_parameter but its pcurve is {gap} from its \
399 curve at parameter {t} of the range, outside the edge's \
400 tolerance of {reach}"
401 ),
402 );
403 break;
404 }
405 }
406 }
407 Ok(())
408}
409
410fn check_wire(
412 model: &Model,
413 wire: &Shape,
414 tol: Tolerances,
415 found: &mut Diagnosis,
416) -> OgeomResult<()> {
417 let edges = model.ordered_children_of(wire)?;
418 if edges.is_empty() {
419 found.note(
420 Severity::Broken,
421 wire,
422 ShapeType::Wire,
423 "has no edges, so it bounds nothing".into(),
424 );
425 return Ok(());
426 }
427 for i in 0..edges.len() {
428 let (Some((_, end)), Some((next, _))) = (
429 crate::build::edge_vertices(model, &edges[i])?,
430 crate::build::edge_vertices(model, &edges[(i + 1) % edges.len()])?,
431 ) else {
432 found.note(
433 Severity::Broken,
434 wire,
435 ShapeType::Wire,
436 format!("edge {i} has no bounding vertices, so it joins nothing"),
437 );
438 continue;
439 };
440 if !end.is_same(&next)
441 && !model.same_position(&end, &next, tol)?
442 && !crate::build::one_point(model, &end, &next, tol)?
443 {
444 found.note(
445 Severity::Broken,
446 wire,
447 ShapeType::Wire,
448 format!(
449 "edge {i} ends where edge {} does not begin; a face built on \
450 this has a gap in its boundary",
451 (i + 1) % edges.len()
452 ),
453 );
454 }
455 }
456 Ok(())
457}
458
459fn check_face(
461 model: &Model,
462 face: &Shape,
463 _tol: Tolerances,
464 found: &mut Diagnosis,
465) -> OgeomResult<()> {
466 let Some(node) = model.node(face) else {
467 ogeom_bail!(Dangling, "face is not in this model");
468 };
469 let Some(data) = node.data().as_face() else {
470 return Ok(());
471 };
472 if model.geometry().surface(data.surface).is_none() {
473 ogeom_bail!(Dangling, "face names a surface not in this model");
474 }
475
476 let wires = model.children_of(face)?;
477 if wires.is_empty() && !data.natural_restriction {
478 found.note(
479 Severity::Broken,
480 face,
481 ShapeType::Face,
482 "has no wires and is not marked as covering its whole surface, so \
483 what it is a face *of* is undefined"
484 .into(),
485 );
486 }
487
488 for wire in &wires {
489 for edge in model.children_of(wire)? {
490 let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
491 continue;
492 };
493 if edge_data
494 .pcurve_for(data.surface, edge.location())
495 .is_none()
496 {
497 found.note(
498 Severity::Broken,
499 &edge,
500 ShapeType::Edge,
501 "bounds a face it has no pcurve on; the face cannot be split \
502 or triangulated in its own parameter space"
503 .into(),
504 );
505 }
506 }
507 }
508 Ok(())
509}
510
511fn check_shell(model: &Model, shell: &Shape, found: &mut Diagnosis) -> OgeomResult<()> {
518 let mut uses: HashMap<TShapeId, usize> = HashMap::new();
519 for face in explore(model, shell, Filter::OfType(ShapeType::Face))? {
520 for wire in model.children_of(&face)? {
521 for edge in model.children_of(&wire)? {
522 if model
523 .node(&edge)
524 .and_then(|n| n.data().as_edge())
525 .is_some_and(|d| d.degenerate)
526 {
527 continue;
528 }
529 *uses.entry(edge.node()).or_default() += 1;
530 }
531 }
532 }
533 let odd = uses.values().filter(|n| *n % 2 == 1).count();
534 if odd > 0 {
535 found.note(
536 Severity::Suspect,
537 shell,
538 ShapeType::Shell,
539 format!(
540 "{odd} edge(s) used an odd number of times, so the shell is open \
541 along them; it encloses no volume"
542 ),
543 );
544 }
545 Ok(())
546}
547
548fn check_containment(model: &Model, shape: &Shape, found: &mut Diagnosis) -> OgeomResult<()> {
555 for face in explore_unique(model, shape, ShapeType::Face)? {
556 compare(model, &face, ShapeType::Face, found)?;
557 }
558 for edge in explore_unique(model, shape, ShapeType::Edge)? {
559 compare(model, &edge, ShapeType::Edge, found)?;
560 }
561 Ok(())
562}
563
564pub fn restore_containment(model: &mut Model, shape: &Shape) -> OgeomResult<usize> {
582 let bounded: Vec<Shape> = explore_unique(model, shape, ShapeType::Edge)?
583 .into_iter()
584 .chain(explore_unique(model, shape, ShapeType::Vertex)?)
585 .collect();
586 let before: Vec<f64> = bounded
587 .iter()
588 .map(|s| model.tolerance_of(s).map(|t| t.map_or(0.0, |t| t.get())))
589 .collect::<OgeomResult<_>>()?;
590 for kind in [ShapeType::Face, ShapeType::Edge] {
591 for bounding in explore_unique(model, shape, kind)? {
592 if let Some(own) = model.tolerance_of(&bounding)? {
593 model.widen(&bounding, own)?;
594 }
595 }
596 }
597 let mut grown = 0;
598 for (s, was) in bounded.iter().zip(before) {
599 if model.tolerance_of(s)?.is_some_and(|t| t.get() > was) {
600 grown += 1;
601 }
602 }
603 Ok(grown)
604}
605
606fn compare(
608 model: &Model,
609 shape: &Shape,
610 kind: ShapeType,
611 found: &mut Diagnosis,
612) -> OgeomResult<()> {
613 let Some(bounding) = model.tolerance_of(shape)? else {
614 return Ok(());
615 };
616 for below in explore(model, shape, Filter::All)? {
617 if below.is_same(shape) {
618 continue;
619 }
620 let Some(bounded) = model.tolerance_of(&below)? else {
621 continue;
622 };
623 if bounded.get() < bounding.get() {
624 found.note(
625 Severity::Broken,
626 &below,
627 model.kind_of(&below)?,
628 format!(
629 "tolerance {} is tighter than the {kind:?} that bounds it \
630 ({}); the bound does not reliably contain what it bounds",
631 bounded.get(),
632 bounding.get()
633 ),
634 );
635 }
636 }
637 Ok(())
638}
639
640pub fn check_self_intersection(
654 model: &Model,
655 shape: &Shape,
656 tol: Tolerances,
657) -> OgeomResult<Vec<(Shape, Shape)>> {
658 use ogeom_topo::explore_unique;
659 let faces = explore_unique(model, shape, ShapeType::Face)?;
660 let mut below: Vec<std::collections::BTreeSet<u64>> = Vec::with_capacity(faces.len());
662 for face in &faces {
663 let mut set = std::collections::BTreeSet::new();
664 for kind in [ShapeType::Edge, ShapeType::Vertex] {
665 for sub in explore_unique(model, face, kind)? {
666 let mut hasher = std::hash::DefaultHasher::new();
667 std::hash::Hash::hash(&sub.node(), &mut hasher);
668 set.insert(std::hash::Hasher::finish(&hasher));
669 }
670 }
671 below.push(set);
672 }
673
674 let mut crossings = Vec::new();
675 for i in 0..faces.len() {
676 for j in i + 1..faces.len() {
677 ogeom_core::progress::checkpoint()?;
678 if !below[i].is_disjoint(&below[j]) {
679 continue;
680 }
681 let reach = crate::distance_between_shapes(
682 model,
683 &faces[i],
684 &faces[j],
685 ogeom_intersect::ExtremaOptions::default(),
686 tol,
687 )?;
688 if reach.distance <= tol.confusion() {
689 crossings.push((faces[i].clone(), faces[j].clone()));
690 }
691 }
692 }
693 Ok(crossings)
694}
695
696#[cfg(test)]
697#[allow(clippy::unwrap_used, clippy::expect_used)]
698mod tests {
699 use super::*;
700 use crate::{make_box, make_cylinder, make_sphere, make_torus};
701 use ogeom_core::Tolerance;
702 use ogeom_math::{Frame, Point};
703 use ogeom_topo::NodeData;
704 use ogeom_topo::VertexData;
705
706 const T: Tolerances = Tolerances::millimetres();
707
708 #[test]
709 fn every_primitive_is_valid() {
710 let mut model = Model::new();
714 let shapes = [
715 make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
716 .unwrap()
717 .shape,
718 make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T)
719 .unwrap()
720 .shape,
721 make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap().shape,
722 make_torus(&mut model, Frame::WORLD, 5.0, 2.0, T)
723 .unwrap()
724 .shape,
725 ];
726 for shape in &shapes {
727 let found = check(&model, shape, T).unwrap();
728 assert!(found.is_valid(), "a primitive was flagged: {found}");
729 }
730 }
731
732 #[test]
733 fn a_prism_is_valid() {
734 use ogeom_math::Vector;
735 let mut model = Model::new();
736 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
737 .unwrap()
738 .shape;
739 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
740 let prism = crate::make_prism(&mut model, &face, Vector::new(0.0, 0.0, 2.0), T)
741 .unwrap()
742 .shape;
743
744 let found = check(&model, &prism, T).unwrap();
745 assert!(found.is_valid(), "the prism was flagged: {found}");
746 }
747
748 #[test]
749 fn a_single_face_is_reported_open_but_still_usable() {
750 let mut model = Model::new();
754 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
755 .unwrap()
756 .shape;
757 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
758 let shell = crate::build::make_shell(&mut model, std::slice::from_ref(&face))
759 .unwrap()
760 .shape;
761
762 let found = check(&model, &shell, T).unwrap();
763 assert!(!found.is_valid(), "an open shell is worth reporting");
764 assert!(found.is_usable(), "but nothing here answers wrongly");
765 assert_eq!(found.worst(), Some(Severity::Suspect));
766 assert_eq!(found.of(Severity::Suspect).len(), 1);
767 }
768
769 #[test]
770 fn a_vertex_tighter_than_its_edge_is_caught() {
771 let mut model = Model::new();
775 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
776 .unwrap()
777 .shape;
778 let edge = explore_unique(&model, &solid, ShapeType::Edge).unwrap()[0].clone();
779
780 let loose = Tolerance::new(1e-3).unwrap();
783 if let Some(NodeData::Edge(data)) = model.node_mut(&edge).map(ogeom_topo::TShape::data_mut)
784 {
785 data.tolerance = loose;
786 }
787
788 let found = check(&model, &edge, T).unwrap();
789 assert!(!found.is_usable(), "a broken containment is not usable");
790 let broken = found.of(Severity::Broken);
791 assert!(!broken.is_empty());
792 assert!(broken.iter().all(|p| p.kind == ShapeType::Vertex));
793 }
794
795 #[test]
796 fn an_edge_with_no_curve_and_no_excuse_is_caught() {
797 let mut model = Model::new();
798 let v = model.add_vertex(VertexData::new(Point::ORIGIN));
799 let edge = model
800 .add_edge(ogeom_topo::EdgeData::new(), &[v.clone(), v])
801 .unwrap();
802
803 let found = check(&model, &edge, T).unwrap();
804 assert!(!found.is_usable());
805 assert!(found.problems[0].what.contains("not marked degenerate"));
806 }
807
808 #[test]
809 fn an_edge_whose_curve_misses_its_vertex_is_caught() {
810 let mut model = Model::new();
813 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
814 .unwrap()
815 .shape;
816 let vertex = explore_unique(&model, &solid, ShapeType::Vertex).unwrap()[0].clone();
817 if let Some(NodeData::Vertex(data)) =
818 model.node_mut(&vertex).map(ogeom_topo::TShape::data_mut)
819 {
820 data.point = Point::new(50.0, 50.0, 50.0);
821 }
822
823 let found = check(&model, &solid, T).unwrap();
824 assert!(!found.is_usable());
825 assert!(
826 found
827 .of(Severity::Broken)
828 .iter()
829 .any(|p| p.what.contains("from the vertex it should meet")),
830 "got {found}"
831 );
832 }
833
834 #[test]
835 fn a_face_whose_edge_has_no_pcurve_is_caught() {
836 let mut model = Model::new();
839 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
840 .unwrap()
841 .shape;
842 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
843 let edge = model
844 .children_of(&model.children_of(&face).unwrap()[0])
845 .unwrap()[0]
846 .clone();
847 if let Some(NodeData::Edge(data)) = model.node_mut(&edge).map(ogeom_topo::TShape::data_mut)
848 {
849 data.representations.retain(|r| r.is_curve3d());
850 }
851
852 let found = check(&model, &face, T).unwrap();
853 assert!(!found.is_usable());
854 assert!(
855 found
856 .of(Severity::Broken)
857 .iter()
858 .any(|p| p.what.contains("no pcurve on")),
859 "got {found}"
860 );
861 }
862
863 #[test]
864 fn a_diagnosis_reads_as_a_report_rather_than_a_debug_dump() {
865 let mut model = Model::new();
866 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
867 .unwrap()
868 .shape;
869 assert_eq!(check(&model, &solid, T).unwrap().to_string(), "valid");
870
871 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
872 let shell = crate::build::make_shell(&mut model, std::slice::from_ref(&face))
873 .unwrap()
874 .shape;
875 let text = check(&model, &shell, T).unwrap().to_string();
876 assert!(text.starts_with("[suspect] Shell:"), "got {text}");
877 assert!(text.contains("open"), "got {text}");
878 }
879
880 #[test]
881 fn a_handle_that_does_not_resolve_is_an_error_not_a_finding() {
882 let mut other = Model::new();
891 for _ in 0..4 {
892 other.add_vertex(VertexData::new(Point::ORIGIN));
893 }
894 let beyond = other.add_vertex(VertexData::new(Point::ORIGIN));
895
896 let empty = Model::new();
897 assert!(check(&empty, &beyond, T).is_err());
898 }
899}
900
901#[cfg(test)]
902#[allow(clippy::unwrap_used, clippy::expect_used)]
903mod tessellation_tests {
904 use super::*;
905 use crate::{make_box, make_cone, make_cylinder, make_sphere, make_torus};
906 use ogeom_math::{Frame, Point};
907 use ogeom_topo::{NodeData, VertexData};
908
909 const T: Tolerances = Tolerances::millimetres();
910
911 fn fine() -> Deflection {
912 Deflection {
913 chord: 0.02,
914 ..Deflection::default()
915 }
916 }
917
918 #[test]
919 fn every_primitive_tessellates_into_a_mesh_that_agrees_with_its_topology() {
920 let mut model = Model::new();
924 let shapes = [
925 make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
926 .unwrap()
927 .shape,
928 make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T)
929 .unwrap()
930 .shape,
931 make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap().shape,
932 make_cone(&mut model, Frame::WORLD, 3.0, 1.0, 4.0, T)
933 .unwrap()
934 .shape,
935 make_cone(&mut model, Frame::WORLD, 3.0, 0.0, 4.0, T)
936 .unwrap()
937 .shape,
938 make_torus(&mut model, Frame::WORLD, 5.0, 2.0, T)
939 .unwrap()
940 .shape,
941 ];
942 for shape in &shapes {
943 let found = check_tessellation(&model, shape, fine(), T).unwrap();
944 assert!(found.is_valid(), "a primitive's mesh came apart: {found}");
945 }
946 }
947
948 #[test]
949 fn a_prism_tessellates_into_an_agreeing_mesh_whichever_face_it_swept() {
950 use ogeom_math::Vector;
956 for role in [
957 crate::primitive::roles::FACE_MAX_Z,
958 crate::primitive::roles::FACE_MIN_Z,
959 ] {
960 let mut model = Model::new();
961 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
962 .unwrap()
963 .shape;
964 let face = explore_unique(&model, &solid, ShapeType::Face)
965 .unwrap()
966 .into_iter()
967 .find(|f| {
968 model
969 .provenance_of(f)
970 .and_then(ogeom_core::Provenance::role)
971 == Some(role)
972 })
973 .expect("the box has a face with that role");
974 let prism = crate::make_prism(&mut model, &face, Vector::new(0.0, 0.0, 2.0), T)
975 .unwrap()
976 .shape;
977 assert!(
978 check_tessellation(&model, &prism, fine(), T)
979 .unwrap()
980 .is_valid(),
981 "{role:?}"
982 );
983 assert!(check(&model, &prism, T).unwrap().is_valid(), "{role:?}");
984 }
985 }
986
987 #[test]
988 fn moving_a_vertex_does_not_move_the_mesh() {
989 let mut model = Model::new();
996 let solid = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T)
997 .unwrap()
998 .shape;
999 let before = ogeom_mesh::triangulate(&model, &solid, fine(), T).unwrap();
1000
1001 let vertex = explore_unique(&model, &solid, ShapeType::Vertex).unwrap()[0].clone();
1002 if let Some(NodeData::Vertex(data)) =
1003 model.node_mut(&vertex).map(ogeom_topo::TShape::data_mut)
1004 {
1005 data.point = Point::new(0.5, 0.5, 0.5);
1006 }
1007
1008 let after = ogeom_mesh::triangulate(&model, &solid, fine(), T).unwrap();
1009 assert_eq!(before.positions, after.positions);
1010 assert!(
1011 check_tessellation(&model, &solid, fine(), T)
1012 .unwrap()
1013 .is_valid()
1014 );
1015 assert!(
1016 !check(&model, &solid, T).unwrap().is_usable(),
1017 "check sees it"
1018 );
1019 }
1020
1021 #[test]
1022 fn an_open_shell_is_not_reported_because_it_never_claimed_to_close() {
1023 let mut model = Model::new();
1026 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
1027 .unwrap()
1028 .shape;
1029 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
1030 let shell = crate::build::make_shell(&mut model, std::slice::from_ref(&face))
1031 .unwrap()
1032 .shape;
1033 assert!(
1034 check_tessellation(&model, &shell, fine(), T)
1035 .unwrap()
1036 .is_valid()
1037 );
1038 }
1039
1040 #[test]
1041 fn a_shape_with_no_shell_has_nothing_to_disagree_about() {
1042 let mut model = Model::new();
1043 let vertex = model.add_vertex(VertexData::new(Point::ORIGIN));
1044 assert!(
1045 check_tessellation(&model, &vertex, fine(), T)
1046 .unwrap()
1047 .is_valid()
1048 );
1049 }
1050}