1use ogeom_core::{FastMap, FastSet};
30use std::fmt;
31
32use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
33use ogeom_geom::{Curve2d, Curve3d, Surface};
34use ogeom_math::Point2;
35use ogeom_mesh::Deflection;
36use ogeom_topo::{EdgeRepr, Filter, Model, Shape, ShapeType, TShapeId, explore, explore_unique};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub enum Severity {
41 Suspect,
43 Broken,
45}
46
47#[derive(Debug, Clone, PartialEq)]
49pub struct Problem {
50 pub severity: Severity,
52 pub at: Shape,
54 pub kind: ShapeType,
56 pub what: String,
58}
59
60impl fmt::Display for Problem {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 let mark = match self.severity {
63 Severity::Broken => "broken",
64 Severity::Suspect => "suspect",
65 };
66 write!(f, "[{mark}] {:?}: {}", self.kind, self.what)
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Default)]
72pub struct Diagnosis {
73 pub problems: Vec<Problem>,
75}
76
77impl Diagnosis {
78 #[must_use]
80 pub fn is_valid(&self) -> bool {
81 self.problems.is_empty()
82 }
83
84 #[must_use]
88 pub fn is_usable(&self) -> bool {
89 !self.problems.iter().any(|p| p.severity == Severity::Broken)
90 }
91
92 #[must_use]
94 pub fn worst(&self) -> Option<Severity> {
95 self.problems.iter().map(|p| p.severity).max()
96 }
97
98 #[must_use]
100 pub fn of(&self, severity: Severity) -> Vec<&Problem> {
101 self.problems
102 .iter()
103 .filter(|p| p.severity == severity)
104 .collect()
105 }
106
107 fn note(&mut self, severity: Severity, at: &Shape, kind: ShapeType, what: String) {
108 self.problems.push(Problem {
109 severity,
110 at: at.clone(),
111 kind,
112 what,
113 });
114 }
115}
116
117impl fmt::Display for Diagnosis {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 if self.problems.is_empty() {
120 return write!(f, "valid");
121 }
122 for (i, problem) in self.problems.iter().enumerate() {
123 if i > 0 {
124 writeln!(f)?;
125 }
126 write!(f, "{problem}")?;
127 }
128 Ok(())
129 }
130}
131
132pub fn check(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Diagnosis> {
141 check_kept(model, shape, tol, &crate::ProbeCache::new())
142}
143
144pub fn check_kept(
152 model: &Model,
153 shape: &Shape,
154 tol: Tolerances,
155 kept: &crate::ProbeCache,
156) -> OgeomResult<Diagnosis> {
157 if model.node(shape).is_none() {
158 ogeom_bail!(Dangling, "shape refers to a node not in this model");
159 }
160 let mut found = Diagnosis::default();
161
162 let mut distinct: FastMap<ShapeType, Vec<Shape>> = FastMap::default();
166 let mut seen = FastSet::default();
167 for sub in explore(model, shape, Filter::All)? {
168 if seen.insert(ogeom_topo::SameKey(sub.clone())) {
169 distinct.entry(model.kind_of(&sub)?).or_default().push(sub);
170 }
171 }
172 let of = |kind: ShapeType| distinct.get(&kind).map_or(&[][..], Vec::as_slice);
173
174 each(of(ShapeType::Edge), &mut found, |edge, out| {
175 check_edge(model, edge, tol, out)
176 })?;
177 each(of(ShapeType::Wire), &mut found, |wire, out| {
178 check_wire(model, wire, tol, out)
179 })?;
180 each(of(ShapeType::Face), &mut found, |face, out| {
181 check_face(model, face, tol, out)?;
182 check_face_chart(model, face, &|_| false, tol, out)?;
183 check_seam_sides(model, face, tol, out)
184 })?;
185 note_out_and_back(crate::out_and_back(model, shape, tol)?, &mut found);
186 for shell in of(ShapeType::Shell) {
187 check_shell(model, shell, &mut found)?;
188 }
189 for solid in of(ShapeType::Solid) {
190 let crossings = crate::self_cross::crossings(model, solid, kept.meshes(), tol)?;
191 check_orientation(model, solid, tol, kept, !crossings.is_empty(), &mut found)?;
192 note_crossings(crossings, &mut found);
193 }
194 each(of(ShapeType::Face), &mut found, |face, out| {
197 compare(model, face, ShapeType::Face, out)
198 })?;
199 each(of(ShapeType::Edge), &mut found, |edge, out| {
200 compare(model, edge, ShapeType::Edge, out)
201 })?;
202 Ok(found)
203}
204
205fn each(
209 shapes: &[Shape],
210 found: &mut Diagnosis,
211 one: impl Fn(&Shape, &mut Diagnosis) -> OgeomResult<()> + Sync,
212) -> OgeomResult<()> {
213 let parts = ogeom_core::parallel::map_ordered(shapes, |_, shape| {
214 let mut part = Diagnosis::default();
215 one(shape, &mut part).map(|()| part)
216 });
217 for part in parts {
218 found.problems.extend(part?.problems);
219 }
220 Ok(())
221}
222
223fn check_orientation(
236 model: &Model,
237 solid: &Shape,
238 tol: Tolerances,
239 kept: &crate::ProbeCache,
240 crossed: bool,
241 found: &mut Diagnosis,
242) -> OgeomResult<()> {
243 let boundary = match probe_boundary(model, solid, tol) {
244 Ok(boundary) => boundary.keeping(kept),
245 Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
246 return Err(e);
247 }
248 Err(_) => return Ok(()),
249 };
250 let faces = explore_unique(model, solid, ShapeType::Face)?;
251 let read =
252 ogeom_core::parallel::map_ordered(&faces, |_, face| -> OgeomResult<Option<String>> {
253 let places = face_places(
254 model,
255 face,
256 &boundary,
257 PLACES_READ,
258 PLACES_READ + SPARE,
259 tol,
260 )?;
261 let read = Readings::of(&places);
262 if !read.disagree() {
263 return Ok((read.verdict() == Some(true)).then(|| {
264 "the face's outward normal points into the material: its \
265 orientation is reversed"
266 .to_string()
267 }));
268 }
269 let mut credible = crossed || places.first().is_some_and(|p| p.inward);
270 for place in places.iter().filter(|p| p.inward) {
271 if credible {
272 break;
273 }
274 credible = read_place(model, face, &boundary, place, true, tol)? == Some(true);
275 }
276 Ok(credible.then(|| {
277 format!(
278 "the face reads facing out of the material at {} of the \
279 places probed and into it at {}: the boundary passes \
280 through itself or overlaps",
281 read.out, read.inward
282 )
283 }))
284 });
285 for (face, read) in faces.iter().zip(read) {
286 if let Some(what) = read? {
287 found.note(Severity::Broken, face, ShapeType::Face, what);
288 }
289 }
290 Ok(())
291}
292
293const PLACES_READ: usize = 2;
298
299pub(crate) const AGREEING: usize = 3;
302
303#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
306pub(crate) struct Readings {
307 pub(crate) out: usize,
309 pub(crate) inward: usize,
311}
312
313impl Readings {
314 fn of(places: &[Place]) -> Self {
316 let inward = places.iter().filter(|p| p.inward).count();
317 Self {
318 out: places.len() - inward,
319 inward,
320 }
321 }
322
323 pub(crate) const fn verdict(self) -> Option<bool> {
326 match (self.out > 0, self.inward > 0) {
327 (true, false) => Some(false),
328 (false, true) => Some(true),
329 _ => None,
330 }
331 }
332
333 pub(crate) const fn disagree(self) -> bool {
335 self.out > 0 && self.inward > 0
336 }
337
338 pub(crate) const fn count(self) -> usize {
340 self.out + self.inward
341 }
342}
343
344fn note_out_and_back(pairs: Vec<crate::OutAndBack>, found: &mut Diagnosis) {
350 for pair in pairs {
351 let [out, back] = pair.edges;
352 let w = pair.wire;
353 let what = match pair.notch {
354 None => format!(
355 "edge {out} of wire {w} runs out and edge {back} straight back along \
356 it: the pair bounds nothing"
357 ),
358 Some(notch) => {
359 let (long, short) = if notch.longer == 0 {
360 (out, back)
361 } else {
362 (back, out)
363 };
364 format!(
365 "edge {short} of wire {w} runs along part of edge {long} and \
366 back: that stretch of the boundary bounds nothing"
367 )
368 }
369 };
370 found.note(Severity::Suspect, &pair.face, ShapeType::Face, what);
371 }
372}
373
374fn note_crossings(crossings: Vec<crate::self_cross::SelfCrossing>, found: &mut Diagnosis) {
378 for crossing in crossings {
379 let at = crossing.at;
380 let whom = if crossing.face.is_same(&crossing.other) {
381 "itself"
382 } else {
383 "another face of the solid"
384 };
385 found.note(
386 Severity::Broken,
387 &crossing.face,
388 ShapeType::Face,
389 format!(
390 "the face passes through {whom} at ({:.6}, {:.6}, {:.6}): the \
391 boundary crosses itself, so it bounds no single region",
392 at.x, at.y, at.z
393 ),
394 );
395 }
396}
397
398pub fn inside_out_faces(model: &Model, solid: &Shape, tol: Tolerances) -> OgeomResult<Vec<Shape>> {
418 inside_out_faces_kept(model, solid, tol, &crate::ProbeCache::new())
419}
420
421pub fn inside_out_faces_kept(
427 model: &Model,
428 solid: &Shape,
429 tol: Tolerances,
430 kept: &crate::ProbeCache,
431) -> OgeomResult<Vec<Shape>> {
432 let boundary = match probe_boundary(model, solid, tol) {
433 Ok(boundary) => boundary.keeping(kept),
434 Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
438 return Err(e);
439 }
440 Err(_) => return Ok(Vec::new()),
441 };
442 let faces = explore_unique(model, solid, ShapeType::Face)?;
443 turned_in_against(model, &boundary, &faces, tol)
444}
445
446pub(crate) fn probe_boundary(
449 model: &Model,
450 solid: &Shape,
451 tol: Tolerances,
452) -> OgeomResult<crate::SolidBoundary> {
453 crate::SolidBoundary::of(model, solid, tol.confusion() * 1e4, tol)
454}
455
456fn turned_in_against(
459 model: &Model,
460 boundary: &crate::SolidBoundary,
461 faces: &[Shape],
462 tol: Tolerances,
463) -> OgeomResult<Vec<Shape>> {
464 let inward = ogeom_core::parallel::map_ordered(faces, |_, face| {
465 faces_inward(model, face, boundary, tol)
466 });
467 let mut out = Vec::new();
468 for (face, inward) in faces.iter().zip(inward) {
469 if inward? == Some(true) {
470 out.push(face.clone());
471 }
472 }
473 Ok(out)
474}
475
476pub(crate) fn faces_inward(
481 model: &Model,
482 face: &Shape,
483 boundary: &crate::SolidBoundary,
484 tol: Tolerances,
485) -> OgeomResult<Option<bool>> {
486 const PROBES: usize = 3;
487 Ok(face_places(model, face, boundary, 1, PROBES + SPARE, tol)?
488 .first()
489 .map(|p| p.inward))
490}
491
492pub(crate) fn face_readings(
502 model: &Model,
503 face: &Shape,
504 boundary: &crate::SolidBoundary,
505 wanted: usize,
506 tol: Tolerances,
507) -> OgeomResult<Readings> {
508 Ok(Readings::of(&face_places(
509 model,
510 face,
511 boundary,
512 wanted,
513 wanted + SPARE,
514 tol,
515 )?))
516}
517
518const SPARE: usize = 2;
520
521fn face_places(
524 model: &Model,
525 face: &Shape,
526 boundary: &crate::SolidBoundary,
527 wanted: usize,
528 tried: usize,
529 tol: Tolerances,
530) -> OgeomResult<Vec<Place>> {
531 const CHOSEN_AMONG: usize = 32;
533 let own = face.located(ogeom_topo::Location::identity());
539 let mut places: Vec<Place> = Vec::new();
540 let Ok(mesh) = ogeom_mesh::triangulate_face_kept(
541 model,
542 &own,
543 Deflection::default(),
544 boundary.kept().meshes(),
545 tol,
546 ) else {
547 return Ok(places);
548 };
549 if mesh.parameters.len() != mesh.positions.len() {
550 return Ok(places);
551 }
552 let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
553 return Ok(places);
554 };
555 let Some(surface) = model.geometry().surface(data.surface) else {
556 return Ok(places);
557 };
558 let area = |t: &[u32; 3]| {
559 let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
560 (b - a).cross(c - a).magnitude()
561 };
562 let middle = |t: &[u32; 3]| {
563 let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
564 a + ((b - a) + (c - a)) / 3.0
565 };
566 let mut largest: Vec<(f64, &[u32; 3])> = mesh.triangles.iter().map(|t| (area(t), t)).collect();
567 largest.sort_by(|x, y| y.0.total_cmp(&x.0));
568 largest.truncate(CHOSEN_AMONG);
569 let mut order: Vec<(f64, &[u32; 3])> = Vec::with_capacity(largest.len());
572 let mut apart: Vec<f64> = vec![f64::INFINITY; largest.len()];
573 let mut taken = vec![false; largest.len()];
574 for _ in 0..largest.len() {
575 let mut best: Option<usize> = None;
576 for k in 0..largest.len() {
577 if !taken[k] && best.is_none_or(|b| apart[k] > apart[b]) {
578 best = Some(k);
579 }
580 }
581 let Some(k) = best else { break };
582 taken[k] = true;
583 order.push(largest[k]);
584 let at = middle(largest[k].1);
585 for (j, (_, t)) in largest.iter().enumerate() {
586 apart[j] = apart[j].min(middle(t).distance(at));
587 }
588 }
589 let placement = face.transform(model.datums())?;
590 let flip = face.orientation() == ogeom_topo::Orientation::Reversed;
591 let shortest = data.tolerance.get().max(tol.confusion() * 1e3) * 20.0;
594 let on_face = tol.confusion().max(data.tolerance.get());
596 let mut trim: Option<crate::classify::FaceTrim<'_>> = None;
597 for (size, triangle) in order.into_iter().take(tried) {
598 if places.len() >= wanted {
599 break;
600 }
601 let uv = triangle.map(|i| mesh.parameters[i as usize]);
602 let (u, v) = (
603 (uv[0].0 + uv[1].0 + uv[2].0) / 3.0,
604 (uv[0].1 + uv[1].1 + uv[2].1) / 3.0,
605 );
606 let (Ok(point), Ok(normal)) = (surface.point_at(u, v, tol), surface.normal_at(u, v, tol))
607 else {
608 continue;
609 };
610 let point = placement.apply(point);
611 if trim.is_none() {
619 let held = Deflection {
620 chord: tol.confusion() * 1e4,
621 ..Deflection::default()
622 };
623 trim = Some(crate::classify::FaceTrim::of(model, face, held, tol)?);
624 }
625 let Some(drawn) = trim.as_ref() else {
626 continue;
627 };
628 if drawn.holds(model, (u, v), on_face, tol)? == crate::Containment::Out {
629 continue;
630 }
631 let mut outward = placement.apply_vector(normal.vector());
632 if flip {
633 outward = -outward;
634 }
635 let first = (size.sqrt() * 0.05).max(tol.confusion() * 1e5);
636 let mut place = Place {
637 point,
638 outward,
639 ladder: Ladder { first, shortest },
640 inward: false,
641 };
642 if let Some(inward) = read_place(model, face, boundary, &place, false, tol)? {
643 place.inward = inward;
644 places.push(place);
645 }
646 }
647 Ok(places)
648}
649
650#[derive(Debug, Clone, Copy)]
653struct Place {
654 point: ogeom_math::Point,
655 outward: ogeom_math::Vector,
656 ladder: Ladder,
657 inward: bool,
658}
659
660#[derive(Debug, Clone, Copy)]
662struct Ladder {
663 first: f64,
664 shortest: f64,
665}
666
667fn read_place(
677 model: &Model,
678 face: &Shape,
679 boundary: &crate::SolidBoundary,
680 place: &Place,
681 by_winding: bool,
682 tol: Tolerances,
683) -> OgeomResult<Option<bool>> {
684 let side = |at| -> Option<crate::Containment> {
685 if by_winding {
686 boundary.wound_coarsely(model, at, tol)
687 } else {
688 boundary.holds(model, at, tol).ok()
689 }
690 };
691 let (point, outward, ladder) = (place.point, place.outward, place.ladder);
692 let mut step = ladder.first;
693 loop {
694 ogeom_core::progress::checkpoint()?;
695 let (out_at, in_at) = (point + outward * step, point - outward * step);
701 let clear = boundary.clear_between(model, point, out_at, face, tol)?
702 && boundary.clear_between(model, point, in_at, face, tol)?;
703 if clear && let (Some(outside), Some(inside)) = (side(out_at), side(in_at)) {
704 match (outside, inside) {
705 (crate::Containment::In, crate::Containment::Out) => return Ok(Some(true)),
706 (crate::Containment::Out, crate::Containment::In) => return Ok(Some(false)),
707 _ => {}
708 }
709 }
710 if step <= ladder.shortest {
711 return Ok(None);
712 }
713 step = (step * 0.1).max(ladder.shortest);
714 }
715}
716
717pub fn check_tessellation(
739 model: &Model,
740 shape: &Shape,
741 deflection: Deflection,
742 tol: Tolerances,
743) -> OgeomResult<Diagnosis> {
744 let mut found = Diagnosis::default();
745
746 for shell in explore_unique(model, shape, ShapeType::Shell)? {
747 if !crate::build::is_shell_closed(model, &shell)? {
751 continue;
752 }
753 let mesh = ogeom_mesh::triangulate(model, &shell, deflection, tol)?;
754 if mesh.is_empty() {
755 found.note(
756 Severity::Broken,
757 &shell,
758 ShapeType::Shell,
759 "the topology says this shell is closed and it tessellates to \
760 nothing at all"
761 .into(),
762 );
763 continue;
764 }
765 if let Some(report) = open_edges(&mesh) {
766 found.note(Severity::Broken, &shell, ShapeType::Shell, report);
767 }
768 }
769 Ok(found)
770}
771
772fn open_edges(mesh: &ogeom_topo::Triangulation) -> Option<String> {
774 let mut uses: FastMap<(u32, u32), usize> = FastMap::default();
775 for triangle in &mesh.triangles {
776 for i in 0..3 {
777 let (a, b) = (triangle[i], triangle[(i + 1) % 3]);
778 *uses.entry((a.min(b), a.max(b))).or_default() += 1;
779 }
780 }
781
782 let mut loose: Vec<&(u32, u32)> = uses
783 .iter()
784 .filter(|(_, n)| **n != 2)
785 .map(|(e, _)| e)
786 .collect();
787 if loose.is_empty() {
788 return None;
789 }
790 loose.sort_unstable();
793
794 let sample: Vec<String> = loose
795 .iter()
796 .take(3)
797 .map(|(a, b)| {
798 let (p, q) = (mesh.positions[*a as usize], mesh.positions[*b as usize]);
799 format!(
800 "({:.6}, {:.6}, {:.6})-({:.6}, {:.6}, {:.6})",
801 p.x, p.y, p.z, q.x, q.y, q.z
802 )
803 })
804 .collect();
805
806 Some(format!(
807 "the topology says this shell is closed, but its mesh has {} triangle \
808 edge(s) not shared by two triangles, so the tessellated solid has a \
809 slit in it. The first are at {}. This is a parameter-space defect \
810 (some face's pcurves do not cover the region its edges bound), and no \
811 topological check can see it",
812 loose.len(),
813 sample.join(", ")
814 ))
815}
816
817fn check_edge(
820 model: &Model,
821 edge: &Shape,
822 tol: Tolerances,
823 found: &mut Diagnosis,
824) -> OgeomResult<()> {
825 let Some(node) = model.node(edge) else {
826 ogeom_bail!(Dangling, "edge is not in this model");
827 };
828 let Some(data) = node.data().as_edge() else {
829 return Ok(());
830 };
831 let reach = data.tolerance.get().max(tol.confusion());
832
833 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
834 if !data.degenerate {
838 found.note(
839 Severity::Broken,
840 edge,
841 ShapeType::Edge,
842 "no curve in space, and not marked degenerate; a boundary walk \
843 will step over it without noticing"
844 .into(),
845 );
846 }
847 return Ok(());
848 };
849 if data.degenerate {
850 found.note(
851 Severity::Suspect,
852 edge,
853 ShapeType::Edge,
854 "marked degenerate but carries a curve in space".into(),
855 );
856 }
857 let Some(geometry) = model.geometry().curve(*curve) else {
858 ogeom_bail!(Dangling, "curve is not in this model");
859 };
860
861 let placement = edge.transform(model.datums())?;
862 let bounds = model.children_of(edge)?;
863 for (parameter, vertex) in [(range.0, bounds.first()), (range.1, bounds.last())] {
864 let Some(vertex) = vertex else { continue };
865 let Some((point, vertex_reach)) = model
866 .node(vertex)
867 .and_then(|n| n.data().as_vertex())
868 .map(|v| (v.point, v.tolerance.get()))
869 else {
870 continue;
871 };
872 let placed = vertex.transform(model.datums())?.apply(point);
873 let on_curve = placement.apply(geometry.point_at(parameter, tol)?);
874 let gap = on_curve.distance(placed);
875 let reach = reach.max(vertex_reach);
880 if gap > reach {
881 found.note(
882 Severity::Broken,
883 edge,
884 ShapeType::Edge,
885 format!(
886 "curve stops {gap} from the vertex it should meet, outside \
887 its tolerance of {reach}; the boundary has a gap there"
888 ),
889 );
890 }
891 }
892
893 check_spanned(
894 model,
895 edge,
896 (geometry, *range),
897 &placement,
898 data.tolerance.get(),
899 tol,
900 found,
901 )?;
902
903 let curve_location = match data.curve3d() {
904 Some(EdgeRepr::Curve3d { location, .. }) => location.clone(),
905 _ => ogeom_topo::Location::identity(),
906 };
907 check_pcurves(
908 model,
909 edge,
910 data,
911 (geometry, *range, &curve_location),
912 reach,
913 tol,
914 found,
915 )?;
916 Ok(())
917}
918
919fn check_spanned(
935 model: &Model,
936 edge: &Shape,
937 (curve, range): (&ogeom_geom::Curve, (f64, f64)),
938 placement: &ogeom_math::Transform,
939 tolerance: f64,
940 tol: Tolerances,
941 found: &mut Diagnosis,
942) -> OgeomResult<()> {
943 if let Some((gap, length)) =
944 spanned_past(model, edge, (curve, range), placement, tolerance, tol)?
945 {
946 found.note(
947 Severity::Suspect,
948 edge,
949 ShapeType::Edge,
950 format!(
951 "has a pcurve {gap} off its curve, farther than the edge's own \
952 length of {length}: the pcurve describes another stretch of its \
953 surface, and the tolerance of {tolerance} covering it is wider \
954 than the edge"
955 ),
956 );
957 }
958 Ok(())
959}
960
961pub(crate) fn edge_spanned_past(
970 model: &Model,
971 edge: &Shape,
972 tol: Tolerances,
973) -> OgeomResult<Option<f64>> {
974 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
975 ogeom_bail!(Dangling, "edge is not in this model");
976 };
977 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
978 return Ok(None);
979 };
980 let Some(geometry) = model.geometry().curve(*curve) else {
981 ogeom_bail!(Dangling, "curve is not in this model");
982 };
983 let placement = edge.transform(model.datums())?;
984 Ok(spanned_past(
985 model,
986 edge,
987 (geometry, *range),
988 &placement,
989 data.tolerance.get(),
990 tol,
991 )?
992 .map(|(_, length)| length))
993}
994
995fn spanned_past(
999 model: &Model,
1000 edge: &Shape,
1001 (curve, range): (&ogeom_geom::Curve, (f64, f64)),
1002 placement: &ogeom_math::Transform,
1003 tolerance: f64,
1004 tol: Tolerances,
1005) -> OgeomResult<Option<(f64, f64)>> {
1006 const PIECES: u32 = 16;
1011 let scale = placement.scale_factor().abs();
1012 let at = |f: f64| curve.point_at((range.1 - range.0).mul_add(f, range.0), tol);
1013 if at(0.0)?.distance(at(1.0)?) * scale > tolerance {
1014 return Ok(None);
1015 }
1016 let mut polyline = 0.0;
1017 let mut last = at(0.0)?;
1018 for i in 1..=PIECES {
1019 let next = at(f64::from(i) / f64::from(PIECES))?;
1020 polyline += last.distance(next);
1021 last = next;
1022 }
1023 if polyline * scale > tolerance {
1024 return Ok(None);
1025 }
1026 let Ok(length) = crate::length::curve_length(curve, range, tol) else {
1027 return Ok(None);
1028 };
1029 if length * scale > tolerance {
1030 return Ok(None);
1031 }
1032 let Some(gap) = crate::pcurve_gap::edge_pcurve_gap_sampled(model, edge, tol)? else {
1033 return Ok(None);
1034 };
1035 Ok((gap > length + tol.confusion()).then_some((gap, length)))
1038}
1039
1040fn check_pcurves(
1055 model: &Model,
1056 edge: &Shape,
1057 data: &ogeom_topo::EdgeData,
1058 (curve, range, location): (&ogeom_geom::Curve, (f64, f64), &ogeom_topo::Location),
1059 reach: f64,
1060 tol: Tolerances,
1061 found: &mut Diagnosis,
1062) -> OgeomResult<()> {
1063 check_plain_pcurves_apart(model, edge, data, tol, found)?;
1064 let claimed = data.same_parameter();
1065 for repr in &data.representations {
1066 let (sides, pcurve_range, surface_id, at) = match repr {
1067 EdgeRepr::PCurve {
1068 curve,
1069 range,
1070 surface,
1071 location,
1072 } => ([Some(*curve), None], *range, *surface, location),
1073 EdgeRepr::Seam {
1074 forward,
1075 reversed,
1076 range,
1077 surface,
1078 location,
1079 } => (
1080 [Some(*forward), Some(*reversed)],
1081 *range,
1082 *surface,
1083 location,
1084 ),
1085 _ => continue,
1086 };
1087 if at != location {
1088 continue;
1089 }
1090 let Some(surface) = model.geometry().surface(surface_id) else {
1091 ogeom_bail!(Dangling, "an edge names geometry not in this model");
1092 };
1093 if let [Some(ahead), Some(back)] = sides {
1094 check_seam_closes(
1095 model,
1096 edge,
1097 (ahead, back, pcurve_range),
1098 surface,
1099 tol,
1100 found,
1101 )?;
1102 }
1103 for pcurve_id in sides.into_iter().flatten() {
1104 let Some(pcurve) = model.geometry().pcurve(pcurve_id) else {
1105 ogeom_bail!(Dangling, "an edge names geometry not in this model");
1106 };
1107 let Some((gap, t)) = pcurve_off_curve(
1108 (curve, range),
1109 (pcurve, pcurve_range),
1110 surface,
1111 reach,
1112 claimed,
1113 tol,
1114 )?
1115 else {
1116 continue;
1117 };
1118 let what = if claimed {
1119 format!(
1120 "claims same_parameter but its pcurve is {gap} from its curve at \
1121 parameter {t} of the range, outside the edge's tolerance of {reach}"
1122 )
1123 } else {
1124 format!(
1125 "has a pcurve that leaves its curve by {gap} at parameter {t} of \
1126 the range, outside the edge's tolerance of {reach}; the face it \
1127 bounds and the curve its neighbour follows part there"
1128 )
1129 };
1130 found.note(Severity::Broken, edge, ShapeType::Edge, what);
1131 }
1132 }
1133 Ok(())
1134}
1135
1136fn check_seam_closes(
1142 model: &Model,
1143 edge: &Shape,
1144 (ahead, back, range): (ogeom_topo::PCurveId, ogeom_topo::PCurveId, (f64, f64)),
1145 surface: &ogeom_geom::SurfaceGeometry,
1146 tol: Tolerances,
1147 found: &mut Diagnosis,
1148) -> OgeomResult<()> {
1149 let (Some(ahead), Some(back)) = (
1150 model.geometry().pcurve(ahead),
1151 model.geometry().pcurve(back),
1152 ) else {
1153 ogeom_bail!(Dangling, "an edge names geometry not in this model");
1154 };
1155 let middle = 0.5 * (range.0 + range.1);
1156 let apart = ahead.point_at(middle, tol)? - back.point_at(middle, tol)?;
1157 let (across_u, closes) = if apart.x.abs() >= apart.y.abs() {
1158 (true, surface.is_periodic_u() || surface.is_closed_u(tol))
1159 } else {
1160 (false, surface.is_periodic_v() || surface.is_closed_v(tol))
1161 };
1162 if !closes && apart.x.hypot(apart.y) > tol.parametric() {
1163 found.note(
1164 Severity::Broken,
1165 edge,
1166 ShapeType::Edge,
1167 format!(
1168 "carries a seam on a surface that does not close in {}: its two sides \
1169 stand {} apart, and the side a face does not walk names another \
1170 stretch of the surface",
1171 if across_u { "u" } else { "v" },
1172 apart.x.hypot(apart.y)
1173 ),
1174 );
1175 }
1176 Ok(())
1177}
1178
1179pub(crate) fn pcurve_off_curve(
1185 (curve, range): (&ogeom_geom::Curve, (f64, f64)),
1186 (pcurve, pcurve_range): (&ogeom_geom::PlanarCurve, (f64, f64)),
1187 surface: &ogeom_geom::SurfaceGeometry,
1188 reach: f64,
1189 paced: bool,
1190 tol: Tolerances,
1191) -> OgeomResult<Option<(f64, f64)>> {
1192 const SAMPLES: u32 = 32;
1195 let reach = reach * (1.0 + 1e-9);
1199 let mut widest: Option<(f64, f64)> = None;
1200 for i in 0..=SAMPLES {
1201 let t = f64::from(i) / f64::from(SAMPLES);
1202 let on_curve = curve.point_at(range.0 + (range.1 - range.0) * t, tol)?;
1203 let uv = pcurve.point_at(pcurve_range.0 + (pcurve_range.1 - pcurve_range.0) * t, tol)?;
1204 let Ok(lifted) = surface.point_at(uv.x, uv.y, tol) else {
1205 continue;
1206 };
1207 let mut gap = on_curve.distance(lifted);
1208 if gap > reach && !paced {
1209 gap = gap.min(crate::pcurve_gap::nearest_on_stretch(
1210 curve, range, lifted, tol,
1211 )?);
1212 }
1213 if gap > reach && widest.is_none_or(|(g, _)| gap > g) {
1214 widest = Some((gap, t));
1215 }
1216 }
1217 Ok(widest)
1218}
1219
1220pub(crate) fn check_wire(
1222 model: &Model,
1223 wire: &Shape,
1224 tol: Tolerances,
1225 found: &mut Diagnosis,
1226) -> OgeomResult<()> {
1227 let edges = model.ordered_children_of(wire)?;
1228 if edges.is_empty() {
1229 found.note(
1230 Severity::Broken,
1231 wire,
1232 ShapeType::Wire,
1233 "has no edges, so it bounds nothing".into(),
1234 );
1235 return Ok(());
1236 }
1237 for i in 0..edges.len() {
1238 let (Some((_, end)), Some((next, _))) = (
1239 crate::build::edge_vertices(model, &edges[i])?,
1240 crate::build::edge_vertices(model, &edges[(i + 1) % edges.len()])?,
1241 ) else {
1242 found.note(
1243 Severity::Broken,
1244 wire,
1245 ShapeType::Wire,
1246 format!("edge {i} has no bounding vertices, so it joins nothing"),
1247 );
1248 continue;
1249 };
1250 if !end.is_same(&next)
1251 && !model.same_position(&end, &next, tol)?
1252 && !crate::build::one_point(model, &end, &next, tol)?
1253 {
1254 found.note(
1255 Severity::Broken,
1256 wire,
1257 ShapeType::Wire,
1258 format!(
1259 "edge {i} ends where edge {} does not begin; a face built on \
1260 this has a gap in its boundary",
1261 (i + 1) % edges.len()
1262 ),
1263 );
1264 }
1265 }
1266 Ok(())
1267}
1268
1269fn check_face(
1275 model: &Model,
1276 face: &Shape,
1277 tol: Tolerances,
1278 found: &mut Diagnosis,
1279) -> OgeomResult<()> {
1280 let Some(node) = model.node(face) else {
1281 ogeom_bail!(Dangling, "face is not in this model");
1282 };
1283 let Some(data) = node.data().as_face() else {
1284 return Ok(());
1285 };
1286 if model.geometry().surface(data.surface).is_none() {
1287 ogeom_bail!(Dangling, "face names a surface not in this model");
1288 }
1289
1290 let wires = model.children_of(face)?;
1291 if wires.is_empty() && !data.natural_restriction {
1292 found.note(
1293 Severity::Broken,
1294 face,
1295 ShapeType::Face,
1296 "has no wires and is not marked as covering its whole surface, so \
1297 what it is a face *of* is undefined"
1298 .into(),
1299 );
1300 }
1301
1302 for wire in &wires {
1303 for edge in model.children_of(wire)? {
1304 let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1305 continue;
1306 };
1307 if edge_data
1308 .pcurve_for(data.surface, edge.location())
1309 .is_none()
1310 {
1311 found.note(
1312 Severity::Broken,
1313 &edge,
1314 ShapeType::Edge,
1315 "bounds a face it has no pcurve on; the face cannot be split \
1316 or triangulated in its own parameter space"
1317 .into(),
1318 );
1319 }
1320 }
1321 }
1322 for crossing in crate::chart_crossings(model, face, tol)? {
1323 let at = crossing.at;
1324 let [(wa, ea), (wb, eb)] = crossing.edges;
1325 found.note(
1326 Severity::Broken,
1327 face,
1328 ShapeType::Face,
1329 format!(
1330 "its boundary crosses itself in its surface's chart at \
1331 ({:.6}, {:.6}, {:.6}), edge {ea} of wire {wa} across edge {eb} \
1332 of wire {wb}: it encloses no single region",
1333 at.x, at.y, at.z
1334 ),
1335 );
1336 }
1337 Ok(())
1338}
1339
1340fn check_plain_pcurves_apart(
1351 model: &Model,
1352 edge: &Shape,
1353 data: &ogeom_topo::EdgeData,
1354 tol: Tolerances,
1355 found: &mut Diagnosis,
1356) -> OgeomResult<()> {
1357 let reach = CHART_GAP_SLACK * data.tolerance.get().max(tol.approximation());
1358 let plain: Vec<_> = data
1359 .representations
1360 .iter()
1361 .filter_map(|r| match r {
1362 EdgeRepr::PCurve {
1363 curve,
1364 surface,
1365 location,
1366 range,
1367 } => Some((*curve, *surface, location, *range)),
1368 _ => None,
1369 })
1370 .collect();
1371 for (i, a) in plain.iter().enumerate() {
1372 for b in &plain[i + 1..] {
1373 if a.1 != b.1 || a.2 != b.2 {
1374 continue;
1375 }
1376 let (Some(surface), Some(pa), Some(pb)) = (
1377 model.geometry().surface(a.1),
1378 model.geometry().pcurve(a.0),
1379 model.geometry().pcurve(b.0),
1380 ) else {
1381 ogeom_bail!(Dangling, "an edge names geometry not in this model");
1382 };
1383 let apart = pcurves_apart(surface, (pa, a.3), (pb, b.3), tol)?;
1384 if apart > reach {
1385 found.note(
1386 Severity::Broken,
1387 edge,
1388 ShapeType::Edge,
1389 format!(
1390 "holds two plain pcurves on one surface that stand {apart} \
1391 apart along it; a lookup by surface finds the first for \
1392 every face, so a face walking the other reads its boundary \
1393 in the wrong place"
1394 ),
1395 );
1396 return Ok(());
1397 }
1398 }
1399 }
1400 Ok(())
1401}
1402
1403fn pcurves_apart(
1406 surface: &ogeom_geom::SurfaceGeometry,
1407 (a, ra): (&ogeom_geom::PlanarCurve, (f64, f64)),
1408 (b, rb): (&ogeom_geom::PlanarCurve, (f64, f64)),
1409 tol: Tolerances,
1410) -> OgeomResult<f64> {
1411 const SAMPLES: u32 = 4;
1412 let mut widest = 0.0_f64;
1413 for i in 0..=SAMPLES {
1414 let t = f64::from(i) / f64::from(SAMPLES);
1415 let pa = a.point_at(ra.0 + (ra.1 - ra.0) * t, tol)?;
1416 let pb = b.point_at(rb.0 + (rb.1 - rb.0) * t, tol)?;
1417 if let Some(apart) = along_surface(surface, pa, pb, tol) {
1418 widest = widest.max(apart);
1419 }
1420 }
1421 Ok(widest)
1422}
1423
1424pub fn chart_breaks(
1434 model: &Model,
1435 face: &Shape,
1436 kept: &dyn Fn(&Shape) -> bool,
1437 tol: Tolerances,
1438) -> OgeomResult<Vec<String>> {
1439 let mut found = Diagnosis::default();
1440 check_face_chart(model, face, kept, tol, &mut found)?;
1441 Ok(found.problems.into_iter().map(|p| p.what).collect())
1442}
1443
1444fn check_face_chart(
1468 model: &Model,
1469 face: &Shape,
1470 kept: &dyn Fn(&Shape) -> bool,
1471 tol: Tolerances,
1472 found: &mut Diagnosis,
1473) -> OgeomResult<()> {
1474 let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
1475 return Ok(());
1476 };
1477 let surface_id = data.surface;
1478 let Some(surface) = model.geometry().surface(surface_id) else {
1479 ogeom_bail!(Dangling, "face names a surface not in this model");
1480 };
1481 let ((u0, u1), (v0, v1)) = surface.domain();
1482 let periods = [
1483 (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(u1 - u0),
1484 (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(v1 - v0),
1485 ];
1486 let stored = face.oriented(ogeom_topo::Orientation::Forward);
1487 'wires: for wire in model.ordered_children_of(&stored)? {
1488 let edges = model.ordered_children_of(&wire)?;
1489 if kept(&wire) {
1490 continue;
1491 }
1492 let Some(ends) = chart_ends(model, surface_id, &edges, tol)? else {
1493 continue;
1494 };
1495 let Some(start) = ends.iter().position(|(e, _)| e.len() == 1) else {
1498 continue;
1499 };
1500 let n = edges.len();
1501 let mut walked: Vec<(Point2, Point2)> = Vec::with_capacity(n);
1502 for k in 0..n {
1503 let sides = &ends[(start + k) % n].0;
1504 let picked = match walked.last() {
1505 Some(&(_, last)) => sides
1506 .iter()
1507 .min_by(|a, b| last.distance(a.0).total_cmp(&last.distance(b.0))),
1508 None => sides.first(),
1509 };
1510 let Some(&picked) = picked else { break };
1511 walked.push(picked);
1512 }
1513 let mut turns = [0_i64; 2];
1516 let mut jumps = [0_i64; 2];
1517 let mut misread = false;
1518 for k in 0..walked.len() {
1519 let (i, j) = ((start + k) % n, (start + k + 1) % n);
1520 let (end, next) = (walked[k].1, walked[(k + 1) % walked.len()].0);
1521 let reach = joint_reach(model, &edges[i], &edges[j], tol)?;
1522 let Some(apart) = along_surface(surface, end, next, tol) else {
1523 continue;
1524 };
1525 if apart <= reach {
1526 continue;
1527 }
1528 let mut whole = [0_i64; 2];
1530 let mut moved = next;
1531 for (axis, period) in periods.iter().enumerate() {
1532 let Some(period) = period.filter(|p| *p > 0.0 && p.is_finite()) else {
1533 continue;
1534 };
1535 let (from, to) = if axis == 0 {
1536 (end.x, &mut moved.x)
1537 } else {
1538 (end.y, &mut moved.y)
1539 };
1540 #[allow(clippy::cast_possible_truncation, reason = "a few periods")]
1541 let shift = ((from - *to) / period).round() as i64;
1542 #[allow(clippy::cast_precision_loss, reason = "a few periods")]
1543 {
1544 *to += shift as f64 * period;
1545 }
1546 whole[axis] = -shift;
1547 }
1548 let closes = whole != [0, 0]
1549 && along_surface(surface, end, moved, tol).is_some_and(|gap| gap <= reach);
1550 if !closes {
1551 found.note(
1552 Severity::Broken,
1553 face,
1554 ShapeType::Face,
1555 format!(
1556 "its boundary does not close in its surface's chart: edge \
1557 {i} ends {apart} along the surface from where edge {j} \
1558 begins, outside the joint's tolerance of {reach}; a \
1559 boolean through the face finds a strand that dangles"
1560 ),
1561 );
1562 continue 'wires;
1563 }
1564 for axis in 0..2 {
1565 turns[axis] += whole[axis];
1566 jumps[axis] += whole[axis].abs();
1567 }
1568 misread |= ends[i].1 || ends[j].1;
1569 }
1570 if misread && (0..2).any(|axis| jumps[axis] > turns[axis].abs()) {
1571 found.note(
1572 Severity::Broken,
1573 face,
1574 ShapeType::Face,
1575 "its boundary does not close in its surface's chart: it jumps a \
1576 whole period at joints whose jumps cancel, so an edge between \
1577 them is read on the far column of the surface's seam; a boolean \
1578 through the face finds a strand that dangles"
1579 .into(),
1580 );
1581 }
1582 }
1583 Ok(())
1584}
1585
1586fn joint_reach(model: &Model, from: &Shape, to: &Shape, tol: Tolerances) -> OgeomResult<f64> {
1591 let joint = crate::build::edge_vertices(model, from)?
1592 .and_then(|(_, v)| model.tolerance_of(&v).ok().flatten())
1593 .map_or(0.0, |t| t.get());
1594 Ok(CHART_GAP_SLACK
1595 * [from, to]
1596 .iter()
1597 .filter_map(|e| model.tolerance_of(e).ok().flatten())
1598 .map(|t| t.get())
1599 .fold(joint.max(tol.approximation()), f64::max))
1600}
1601
1602struct SeamStep<'a> {
1607 ends: (Point2, Point2),
1608 pcurve: &'a ogeom_geom::PlanarCurve,
1609 range: (f64, f64),
1610 by: ogeom_math::Vector2,
1611 back: bool,
1612 across: Option<usize>,
1613}
1614
1615fn check_seam_sides(
1653 model: &Model,
1654 face: &Shape,
1655 tol: Tolerances,
1656 found: &mut Diagnosis,
1657) -> OgeomResult<()> {
1658 let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
1659 return Ok(());
1660 };
1661 let surface_id = data.surface;
1662 let Some(surface) = model.geometry().surface(surface_id) else {
1663 ogeom_bail!(Dangling, "face names a surface not in this model");
1664 };
1665 let ((u0, u1), (v0, v1)) = surface.domain();
1666 let periods = [
1667 (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(u1 - u0),
1668 (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(v1 - v0),
1669 ]
1670 .map(|p| p.filter(|p| *p > 0.0 && p.is_finite()));
1671 let repeats = [surface.is_periodic_u(), surface.is_periodic_v()];
1672 if !repeats.iter().any(|r| *r) {
1673 return Ok(());
1674 }
1675 let along = |p: Point2, axis: usize| if axis == 0 { p.x } else { p.y };
1676 let stored = face.oriented(ogeom_topo::Orientation::Forward);
1677 'wires: for wire in model.ordered_children_of(&stored)? {
1678 let edges = model.ordered_children_of(&wire)?;
1679 let mut steps: Vec<SeamStep<'_>> = Vec::with_capacity(edges.len());
1680 for edge in &edges {
1681 let Some(edge_data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1682 continue 'wires;
1683 };
1684 let back = edge.orientation() == ogeom_topo::Orientation::Reversed;
1685 let curve = |id| -> OgeomResult<&ogeom_geom::PlanarCurve> {
1686 let Some(pcurve) = model.geometry().pcurve(id) else {
1687 ogeom_bail!(Dangling, "an edge names geometry not in this model");
1688 };
1689 Ok(pcurve)
1690 };
1691 let walked = |pcurve: &ogeom_geom::PlanarCurve, range: (f64, f64)| {
1692 let (a, b) = (
1693 pcurve.point_at(range.0, tol)?,
1694 pcurve.point_at(range.1, tol)?,
1695 );
1696 OgeomResult::Ok(if back { (b, a) } else { (a, b) })
1697 };
1698 match edge_data.pcurve_for(surface_id, edge.location()) {
1699 Some(EdgeRepr::PCurve {
1700 curve: id, range, ..
1701 }) => {
1702 let pcurve = curve(*id)?;
1703 steps.push(SeamStep {
1704 ends: walked(pcurve, *range)?,
1705 pcurve,
1706 range: *range,
1707 by: ogeom_math::Vector2::new(0.0, 0.0),
1708 back,
1709 across: None,
1710 });
1711 }
1712 Some(EdgeRepr::Seam {
1713 forward,
1714 reversed,
1715 range,
1716 ..
1717 }) => {
1718 if edges.iter().filter(|e| e.is_same(edge)).count() != 2 {
1719 continue 'wires;
1720 }
1721 let (ahead, other) = (curve(*forward)?, curve(*reversed)?);
1722 let (a, b) = walked(ahead, *range)?;
1723 let middle = 0.5 * (range.0 + range.1);
1724 let towards = other.point_at(middle, tol)? - ahead.point_at(middle, tol)?;
1725 let run = b - a;
1726 let left = run.x * towards.y - run.y * towards.x;
1727 if left.abs() <= run.magnitude() * towards.magnitude() * tol.angular() {
1730 continue 'wires;
1731 }
1732 let across = usize::from(towards.y.abs() > towards.x.abs());
1733 let (Some(period), true) = (periods[across], repeats[across]) else {
1734 continue 'wires;
1735 };
1736 let mut by = ogeom_math::Vector2::new(0.0, 0.0);
1739 if left < 0.0 {
1740 if across == 0 {
1741 by.x = period.copysign(towards.x);
1742 } else {
1743 by.y = period.copysign(towards.y);
1744 }
1745 }
1746 steps.push(SeamStep {
1747 ends: (a + by, b + by),
1748 pcurve: ahead,
1749 range: *range,
1750 by,
1751 back,
1752 across: Some(across),
1753 });
1754 }
1755 _ => continue 'wires,
1756 }
1757 }
1758 let n = steps.len();
1759 for axis in 0..2 {
1760 let Some(period) = periods[axis] else {
1761 continue;
1762 };
1763 let pinned: Vec<usize> = (0..n).filter(|&k| steps[k].across == Some(axis)).collect();
1764 for (at, &from) in pinned.iter().enumerate() {
1765 let to = pinned[(at + 1) % pinned.len()];
1766 let between: Vec<usize> = (1..n)
1767 .map(|k| (from + k) % n)
1768 .take_while(|&k| k != to)
1769 .collect();
1770 if between.is_empty() {
1771 continue;
1772 }
1773 let column = along(steps[from].ends.1, axis);
1774 let gap_of = |p: Point2| {
1777 let turns = (along(p, axis) - column) / period;
1778 ((turns - turns.round()).abs() * period > tol.parametric())
1779 .then(|| turns.floor())
1780 };
1781 let mut last = steps[from].ends.1;
1782 let mut slack = 0.0;
1783 let mut elsewhere = false;
1784 let mut prior = from;
1785 for (k, &i) in between.iter().enumerate() {
1786 let Some((shift, gap)) = chained(
1787 model,
1788 surface,
1789 (&edges[prior], &edges[i]),
1790 last,
1791 steps[i].ends.0,
1792 &periods,
1793 tol,
1794 )?
1795 else {
1796 continue 'wires;
1797 };
1798 slack += gap;
1799 let step = &steps[i];
1800 let mut previous: Option<f64> = None;
1801 for j in 0..=SEAM_SIDE_SAMPLES {
1802 let t = f64::from(j) / f64::from(SEAM_SIDE_SAMPLES);
1803 let t = if step.back { 1.0 - t } else { t };
1804 let p = step
1805 .pcurve
1806 .point_at(step.range.0 + (step.range.1 - step.range.0) * t, tol)?
1807 + step.by
1808 + shift;
1809 let first = k == 0 && j == 0;
1810 let arrival = k + 1 == between.len() && j == SEAM_SIDE_SAMPLES;
1811 match gap_of(p) {
1812 None if !first && !arrival => elsewhere = true,
1813 None => {}
1814 Some(g) => {
1815 elsewhere |= previous.is_some_and(|q| q != g);
1816 previous = Some(g);
1817 }
1818 }
1819 }
1820 last = step.ends.1 + shift;
1821 prior = i;
1822 }
1823 if elsewhere {
1824 continue;
1825 }
1826 let next = steps[to].ends.0;
1827 let off = along(next, axis) - along(last, axis);
1828 let turns = (off / period).round();
1829 let mut home = next;
1830 if axis == 0 {
1831 home.x -= turns * period;
1832 } else {
1833 home.y -= turns * period;
1834 }
1835 let reach = joint_reach(model, &edges[prior], &edges[to], tol)?;
1836 let meets = along_surface(surface, last, home, tol).is_some_and(|d| d <= reach)
1837 || along_surface(surface, last, next, tol).is_some_and(|d| d <= reach);
1838 if !meets || off.abs() - slack <= 0.5 * period {
1839 continue;
1840 }
1841 found.note(
1842 Severity::Broken,
1843 face,
1844 ShapeType::Face,
1845 format!(
1846 "its boundary, each seam read on the side the face lies \
1847 against, does not close in its surface's chart: edges \
1848 {} to {} run from one side of a seam to the other and \
1849 arrive {turns} periods off it, leaving the seam on the \
1850 side away from the face; a boolean through the face \
1851 finds a strand that dangles",
1852 between[0],
1853 between[between.len() - 1]
1854 ),
1855 );
1856 continue 'wires;
1857 }
1858 }
1859 }
1860 Ok(())
1861}
1862
1863const SEAM_SIDE_SAMPLES: u32 = 16;
1867
1868fn chained(
1873 model: &Model,
1874 surface: &ogeom_geom::SurfaceGeometry,
1875 edges: (&Shape, &Shape),
1876 last: Point2,
1877 next: Point2,
1878 periods: &[Option<f64>; 2],
1879 tol: Tolerances,
1880) -> OgeomResult<Option<(ogeom_math::Vector2, f64)>> {
1881 let reach = joint_reach(model, edges.0, edges.1, tol)?;
1882 let mut shift = ogeom_math::Vector2::new(0.0, 0.0);
1883 if along_surface(surface, last, next, tol).is_some_and(|d| d > reach) {
1884 if let Some(period) = periods[0] {
1885 shift.x = ((last.x - next.x) / period).round() * period;
1886 }
1887 if let Some(period) = periods[1] {
1888 shift.y = ((last.y - next.y) / period).round() * period;
1889 }
1890 if !along_surface(surface, last, next + shift, tol).is_some_and(|d| d <= reach) {
1891 return Ok(None);
1892 }
1893 }
1894 Ok(Some((shift, last.distance(next + shift))))
1895}
1896
1897pub(crate) const CHART_GAP_SLACK: f64 = 10.0;
1902
1903type ChartEnds = (Vec<(Point2, Point2)>, bool);
1907
1908fn chart_ends(
1911 model: &Model,
1912 surface: ogeom_topo::SurfaceId,
1913 edges: &[Shape],
1914 tol: Tolerances,
1915) -> OgeomResult<Option<Vec<ChartEnds>>> {
1916 let mut ends = Vec::with_capacity(edges.len());
1917 for edge in edges {
1918 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1919 return Ok(None);
1920 };
1921 let (ids, range, another) = match data.pcurve_for(surface, edge.location()) {
1922 Some(EdgeRepr::PCurve {
1923 curve,
1924 range,
1925 location,
1926 ..
1927 }) => {
1928 let plain = data
1929 .representations
1930 .iter()
1931 .filter(|r| {
1932 matches!(r, EdgeRepr::PCurve { .. })
1933 && r.surface() == Some(surface)
1934 && r.location() == Some(location)
1935 })
1936 .count();
1937 (vec![*curve], *range, plain > 1)
1938 }
1939 Some(EdgeRepr::Seam {
1940 forward,
1941 reversed,
1942 range,
1943 ..
1944 }) => (vec![*forward, *reversed], *range, false),
1945 _ => return Ok(None),
1946 };
1947 let mut sides = Vec::with_capacity(ids.len());
1948 for id in ids {
1949 let Some(pcurve) = model.geometry().pcurve(id) else {
1950 ogeom_bail!(Dangling, "an edge names geometry not in this model");
1951 };
1952 let (a, b) = (
1953 pcurve.point_at(range.0, tol)?,
1954 pcurve.point_at(range.1, tol)?,
1955 );
1956 sides.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
1957 (b, a)
1958 } else {
1959 (a, b)
1960 });
1961 }
1962 ends.push((sides, another));
1963 }
1964 Ok(Some(ends))
1965}
1966
1967fn along_surface(
1973 surface: &ogeom_geom::SurfaceGeometry,
1974 a: Point2,
1975 b: Point2,
1976 tol: Tolerances,
1977) -> Option<f64> {
1978 const PIECES: u32 = 8;
1979 if a.distance(b) <= tol.parametric() {
1980 return Some(0.0);
1981 }
1982 let at = |t: f64| lifted_in_domain(surface, a + (b - a) * t, tol);
1983 let mut last = at(0.0).ok()?;
1984 let mut length = 0.0;
1985 for i in 1..=PIECES {
1986 let next = at(f64::from(i) / f64::from(PIECES)).ok()?;
1987 length += last.distance(next);
1988 last = next;
1989 }
1990 Some(length)
1991}
1992
1993fn lifted_in_domain(
2001 surface: &ogeom_geom::SurfaceGeometry,
2002 uv: Point2,
2003 tol: Tolerances,
2004) -> OgeomResult<ogeom_math::Point> {
2005 if let Ok(point) = surface.point_at(uv.x, uv.y, tol) {
2006 return Ok(point);
2007 }
2008 let ((u0, u1), (v0, v1)) = surface.domain();
2009 let u = if surface.is_periodic_u() || surface.is_closed_u(tol) {
2010 uv.x
2011 } else {
2012 uv.x.max(u0).min(u1)
2013 };
2014 let v = if surface.is_periodic_v() || surface.is_closed_v(tol) {
2015 uv.y
2016 } else {
2017 uv.y.max(v0).min(v1)
2018 };
2019 surface.point_at(u, v, tol)
2020}
2021
2022fn check_shell(model: &Model, shell: &Shape, found: &mut Diagnosis) -> OgeomResult<()> {
2029 let mut uses: FastMap<TShapeId, usize> = FastMap::default();
2030 for face in explore(model, shell, Filter::OfType(ShapeType::Face))? {
2031 for wire in model.children_of(&face)? {
2032 for edge in model.children_of(&wire)? {
2033 if model
2034 .node(&edge)
2035 .and_then(|n| n.data().as_edge())
2036 .is_some_and(|d| d.degenerate)
2037 {
2038 continue;
2039 }
2040 *uses.entry(edge.node()).or_default() += 1;
2041 }
2042 }
2043 }
2044 let odd = uses.values().filter(|n| *n % 2 == 1).count();
2045 if odd > 0 {
2046 found.note(
2047 Severity::Suspect,
2048 shell,
2049 ShapeType::Shell,
2050 format!(
2051 "{odd} edge(s) used an odd number of times, so the shell is open \
2052 along them; it encloses no volume"
2053 ),
2054 );
2055 }
2056 for edge in walked_one_way(model, shell)? {
2057 found.note(
2058 Severity::Suspect,
2059 &edge,
2060 ShapeType::Edge,
2061 "walked the same way by both its faces: one of them does not keep \
2062 its material on the left of its rings"
2063 .into(),
2064 );
2065 }
2066 Ok(())
2067}
2068
2069pub(crate) fn walked_one_way(model: &Model, shape: &Shape) -> OgeomResult<Vec<Shape>> {
2079 type Walks = (Shape, usize, usize);
2080 let mut walks: FastMap<(TShapeId, ogeom_topo::Location), Walks> = FastMap::default();
2081 let mut order = Vec::new();
2082 for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
2083 for edge in explore(model, &face, Filter::OfType(ShapeType::Edge))? {
2084 if model
2085 .node(&edge)
2086 .and_then(|n| n.data().as_edge())
2087 .is_some_and(|d| d.degenerate)
2088 {
2089 continue;
2090 }
2091 let key = (edge.node(), edge.location().clone());
2092 let walk = walks.entry(key.clone()).or_insert_with(|| {
2093 order.push(key);
2094 (edge.clone(), 0, 0)
2095 });
2096 walk.1 += 1;
2097 if edge.orientation() == ogeom_topo::Orientation::Forward {
2098 walk.2 += 1;
2099 }
2100 }
2101 }
2102 Ok(order
2103 .into_iter()
2104 .filter_map(|key| {
2105 let (edge, uses, forward) = walks.remove(&key)?;
2106 (uses == 2 && forward != 1).then_some(edge)
2107 })
2108 .collect())
2109}
2110
2111pub fn restore_containment(model: &mut Model, shape: &Shape) -> OgeomResult<usize> {
2131 model.unshare(shape)?;
2132 let bounded: Vec<Shape> = explore_unique(model, shape, ShapeType::Edge)?
2133 .into_iter()
2134 .chain(explore_unique(model, shape, ShapeType::Vertex)?)
2135 .collect();
2136 let before: Vec<f64> = bounded
2137 .iter()
2138 .map(|s| model.tolerance_of(s).map(|t| t.map_or(0.0, |t| t.get())))
2139 .collect::<OgeomResult<_>>()?;
2140 for kind in [ShapeType::Face, ShapeType::Edge] {
2141 for bounding in explore_unique(model, shape, kind)? {
2142 if let Some(own) = model.tolerance_of(&bounding)? {
2143 model.widen(&bounding, own)?;
2144 }
2145 }
2146 }
2147 let mut grown = 0;
2148 for (s, was) in bounded.iter().zip(before) {
2149 if model.tolerance_of(s)?.is_some_and(|t| t.get() > was) {
2150 grown += 1;
2151 }
2152 }
2153 Ok(grown)
2154}
2155
2156fn compare(
2158 model: &Model,
2159 shape: &Shape,
2160 kind: ShapeType,
2161 found: &mut Diagnosis,
2162) -> OgeomResult<()> {
2163 let Some(bounding) = model.tolerance_of(shape)? else {
2164 return Ok(());
2165 };
2166 let mut seen = FastSet::default();
2169 for below in explore(model, shape, Filter::All)? {
2170 if below.is_same(shape) || !seen.insert(ogeom_topo::SameKey(below.clone())) {
2171 continue;
2172 }
2173 let Some(bounded) = model.tolerance_of(&below)? else {
2174 continue;
2175 };
2176 if bounded.get() < bounding.get() {
2177 found.note(
2178 Severity::Broken,
2179 &below,
2180 model.kind_of(&below)?,
2181 format!(
2182 "tolerance {} is tighter than the {kind:?} that bounds it \
2183 ({}); the bound does not reliably contain what it bounds",
2184 bounded.get(),
2185 bounding.get()
2186 ),
2187 );
2188 }
2189 }
2190 Ok(())
2191}
2192
2193pub fn check_self_intersection(
2208 model: &Model,
2209 shape: &Shape,
2210 tol: Tolerances,
2211) -> OgeomResult<Vec<(Shape, Shape)>> {
2212 crossings_among(model, shape, None, tol)
2213}
2214
2215pub fn check_self_intersection_near(
2223 model: &Model,
2224 shape: &Shape,
2225 near: &[Shape],
2226 tol: Tolerances,
2227) -> OgeomResult<Vec<(Shape, Shape)>> {
2228 let near: ogeom_core::FastSet<TShapeId> = near.iter().map(Shape::node).collect();
2229 crossings_among(model, shape, Some(&near), tol)
2230}
2231
2232fn crossings_among(
2233 model: &Model,
2234 shape: &Shape,
2235 near: Option<&ogeom_core::FastSet<TShapeId>>,
2236 tol: Tolerances,
2237) -> OgeomResult<Vec<(Shape, Shape)>> {
2238 use ogeom_topo::explore_unique;
2239 let faces = explore_unique(model, shape, ShapeType::Face)?;
2240 let asked = |i: usize| near.is_none_or(|n| n.contains(&faces[i].node()));
2241 let prepared = ogeom_core::parallel::map_ordered(&faces, |_, face| {
2245 let mut below = ogeom_core::FastSet::default();
2246 for kind in [ShapeType::Edge, ShapeType::Vertex] {
2247 for sub in explore_unique(model, face, kind)? {
2248 below.insert(sub.node());
2249 }
2250 }
2251 let gathered = crate::proximity::Elements::of(model, face, tol)?;
2252 let bounds = crate::measure::shape_bounds(model, face, tol)?.expanded(tol.confusion());
2253 Ok((below, gathered, bounds))
2254 });
2255 let prepared = prepared
2256 .into_iter()
2257 .collect::<OgeomResult<Vec<(ogeom_core::FastSet<TShapeId>, _, ogeom_math::Aabb)>>>()?;
2258
2259 let mut by_x: Vec<(f64, f64, usize)> = prepared
2264 .iter()
2265 .enumerate()
2266 .filter_map(|(i, (_, _, bounds))| Some((bounds.low()?.x, bounds.high()?.x, i)))
2267 .collect();
2268 by_x.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)));
2269 let mut pairs = Vec::new();
2270 for (k, &(_, high, i)) in by_x.iter().enumerate() {
2271 for &(low, _, j) in &by_x[k + 1..] {
2272 if low > high {
2273 break;
2274 }
2275 let (i, j) = (i.min(j), i.max(j));
2276 if (asked(i) || asked(j))
2277 && prepared[i].2.intersects(&prepared[j].2)
2278 && prepared[i].0.is_disjoint(&prepared[j].0)
2279 {
2280 pairs.push((i, j));
2281 }
2282 }
2283 }
2284 pairs.sort_unstable();
2285
2286 let touching = ogeom_core::parallel::map_ordered(&pairs, |_, &(i, j)| {
2287 ogeom_core::progress::checkpoint()?;
2288 if prepared[i]
2291 .1
2292 .apart_by_more_than(&prepared[j].1, tol.confusion(), tol)
2293 {
2294 return Ok(false);
2295 }
2296 let reach = crate::proximity::distance_between_prepared(
2297 &prepared[i].1,
2298 &prepared[j].1,
2299 ogeom_intersect::ExtremaOptions::default(),
2300 tol,
2301 )?;
2302 Ok(reach.distance <= tol.confusion())
2303 });
2304 let mut crossings = Vec::new();
2305 for (&(i, j), touching) in pairs.iter().zip(touching) {
2306 if touching? {
2307 crossings.push((faces[i].clone(), faces[j].clone()));
2308 }
2309 }
2310 Ok(crossings)
2311}
2312
2313#[cfg(test)]
2316pub(crate) fn edges_walked_one_way(model: &Model, shape: &Shape) -> usize {
2317 walked_one_way(model, shape).map_or(0, |edges| edges.len())
2318}
2319
2320#[cfg(test)]
2321#[allow(clippy::unwrap_used, clippy::expect_used)]
2322mod tests {
2323 use super::*;
2324 use crate::{make_box, make_cylinder, make_sphere, make_torus};
2325 use ogeom_core::Tolerance;
2326 use ogeom_math::{Frame, Point};
2327 use ogeom_topo::NodeData;
2328 use ogeom_topo::VertexData;
2329
2330 const T: Tolerances = Tolerances::millimetres();
2331
2332 #[test]
2333 fn every_primitive_is_valid() {
2334 let mut model = Model::new();
2338 let shapes = [
2339 make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
2340 .unwrap()
2341 .shape,
2342 make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T)
2343 .unwrap()
2344 .shape,
2345 make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap().shape,
2346 make_torus(&mut model, Frame::WORLD, 5.0, 2.0, T)
2347 .unwrap()
2348 .shape,
2349 ];
2350 for shape in &shapes {
2351 let found = check(&model, shape, T).unwrap();
2352 assert!(found.is_valid(), "a primitive was flagged: {found}");
2353 }
2354 }
2355
2356 #[test]
2357 fn a_prism_is_valid() {
2358 use ogeom_math::Vector;
2359 let mut model = Model::new();
2360 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2361 .unwrap()
2362 .shape;
2363 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
2364 let prism = crate::make_prism(&mut model, &face, Vector::new(0.0, 0.0, 2.0), T)
2365 .unwrap()
2366 .shape;
2367
2368 let found = check(&model, &prism, T).unwrap();
2369 assert!(found.is_valid(), "the prism was flagged: {found}");
2370 }
2371
2372 #[test]
2373 fn a_single_face_is_reported_open_but_still_usable() {
2374 let mut model = Model::new();
2378 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2379 .unwrap()
2380 .shape;
2381 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
2382 let shell = crate::build::make_shell(&mut model, std::slice::from_ref(&face))
2383 .unwrap()
2384 .shape;
2385
2386 let found = check(&model, &shell, T).unwrap();
2387 assert!(!found.is_valid(), "an open shell is worth reporting");
2388 assert!(found.is_usable(), "but nothing here answers wrongly");
2389 assert_eq!(found.worst(), Some(Severity::Suspect));
2390 assert_eq!(found.of(Severity::Suspect).len(), 1);
2391 }
2392
2393 #[test]
2394 fn a_vertex_tighter_than_its_edge_is_caught() {
2395 let mut model = Model::new();
2399 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2400 .unwrap()
2401 .shape;
2402 let edge = explore_unique(&model, &solid, ShapeType::Edge).unwrap()[0].clone();
2403
2404 let loose = Tolerance::new(1e-3).unwrap();
2407 if let Some(NodeData::Edge(data)) = model.node_mut(&edge).map(ogeom_topo::TShape::data_mut)
2408 {
2409 data.tolerance = loose;
2410 }
2411
2412 let found = check(&model, &edge, T).unwrap();
2413 assert!(!found.is_usable(), "a broken containment is not usable");
2414 let broken = found.of(Severity::Broken);
2415 assert!(!broken.is_empty());
2416 assert!(broken.iter().all(|p| p.kind == ShapeType::Vertex));
2417 }
2418
2419 #[test]
2420 fn a_face_too_loose_for_its_boundary_names_each_part_once() {
2421 let mut model = Model::new();
2422 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2423 .unwrap()
2424 .shape;
2425 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
2426 if let Some(NodeData::Face(data)) = model.node_mut(&face).map(ogeom_topo::TShape::data_mut)
2427 {
2428 data.tolerance = Tolerance::new(1e-3).unwrap();
2429 }
2430 let broken = check(&model, &face, T).unwrap().of(Severity::Broken).len();
2433 assert_eq!(broken, 8);
2434 }
2435
2436 #[test]
2437 fn an_edge_with_no_curve_and_no_excuse_is_caught() {
2438 let mut model = Model::new();
2439 let v = model.add_vertex(VertexData::new(Point::ORIGIN));
2440 let edge = model
2441 .add_edge(ogeom_topo::EdgeData::new(), &[v.clone(), v])
2442 .unwrap();
2443
2444 let found = check(&model, &edge, T).unwrap();
2445 assert!(!found.is_usable());
2446 assert!(found.problems[0].what.contains("not marked degenerate"));
2447 }
2448
2449 #[test]
2450 fn an_edge_whose_curve_misses_its_vertex_is_caught() {
2451 let mut model = Model::new();
2454 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2455 .unwrap()
2456 .shape;
2457 let vertex = explore_unique(&model, &solid, ShapeType::Vertex).unwrap()[0].clone();
2458 if let Some(NodeData::Vertex(data)) =
2459 model.node_mut(&vertex).map(ogeom_topo::TShape::data_mut)
2460 {
2461 data.point = Point::new(50.0, 50.0, 50.0);
2462 }
2463
2464 let found = check(&model, &solid, T).unwrap();
2465 assert!(!found.is_usable());
2466 assert!(
2467 found
2468 .of(Severity::Broken)
2469 .iter()
2470 .any(|p| p.what.contains("from the vertex it should meet")),
2471 "got {found}"
2472 );
2473 }
2474
2475 #[test]
2476 fn a_face_whose_edge_has_no_pcurve_is_caught() {
2477 let mut model = Model::new();
2480 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2481 .unwrap()
2482 .shape;
2483 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
2484 let edge = model
2485 .children_of(&model.children_of(&face).unwrap()[0])
2486 .unwrap()[0]
2487 .clone();
2488 if let Some(NodeData::Edge(data)) = model.node_mut(&edge).map(ogeom_topo::TShape::data_mut)
2489 {
2490 data.representations.retain(|r| r.is_curve3d());
2491 }
2492
2493 let found = check(&model, &face, T).unwrap();
2494 assert!(!found.is_usable());
2495 assert!(
2496 found
2497 .of(Severity::Broken)
2498 .iter()
2499 .any(|p| p.what.contains("no pcurve on")),
2500 "got {found}"
2501 );
2502 }
2503
2504 #[test]
2505 fn an_edge_whose_pcurve_leaves_its_curve_is_caught() {
2506 let mut model = Model::new();
2512 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2513 .unwrap()
2514 .shape;
2515 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
2516 let face_node = model.node(&face).unwrap().clone();
2517 let NodeData::Face(face_data) = face_node.data() else {
2518 panic!("not a face")
2519 };
2520 let surface = face_data.surface;
2521 let edge = model
2522 .children_of(&model.children_of(&face).unwrap()[0])
2523 .unwrap()[0]
2524 .clone();
2525 assert!(check(&model, &solid, T).unwrap().is_valid());
2526
2527 let NodeData::Edge(data) = model.node(&edge).unwrap().data().clone() else {
2528 panic!("not an edge")
2529 };
2530 let original = data
2531 .representations
2532 .iter()
2533 .find_map(|r| match r {
2534 EdgeRepr::PCurve {
2535 curve, surface: s, ..
2536 } if *s == surface => Some(*curve),
2537 _ => None,
2538 })
2539 .expect("the edge has a pcurve on the face");
2540 let basis = model.geometry().pcurve(original).unwrap().clone();
2541 let moved = model
2542 .geometry_mut()
2543 .add_pcurve(ogeom_geom::PlanarCurve::Offset(Box::new(
2544 ogeom_geom::Offset2d::new(basis, 0.01).unwrap(),
2545 )));
2546 if let Some(NodeData::Edge(data)) = model.node_mut(&edge).map(ogeom_topo::TShape::data_mut)
2547 {
2548 for repr in &mut data.representations {
2549 if let EdgeRepr::PCurve {
2550 curve, surface: s, ..
2551 } = repr
2552 && *s == surface
2553 {
2554 *curve = moved;
2555 }
2556 }
2557 }
2558
2559 let found = check(&model, &solid, T).unwrap();
2560 assert!(!found.is_usable(), "got {found}");
2561 assert!(
2562 found
2563 .of(Severity::Broken)
2564 .iter()
2565 .any(|p| p.kind == ShapeType::Edge && p.what.contains("leaves its curve by")),
2566 "got {found}"
2567 );
2568 }
2569
2570 #[test]
2571 fn a_pcurve_farther_off_than_its_edge_is_long_is_suspect() {
2572 for (offset, reported) in [(0.0015, true), (0.0005, false)] {
2579 let mut model = Model::new();
2580 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 0.001), T)
2581 .unwrap()
2582 .shape;
2583 let edge = explore_unique(&model, &solid, ShapeType::Edge)
2584 .unwrap()
2585 .into_iter()
2586 .find(|edge| {
2587 let (a, b) = crate::build::edge_vertices(&model, edge).unwrap().unwrap();
2588 let at = |v: &Shape| model.node(v).unwrap().data().as_vertex().unwrap().point;
2589 (at(&a).distance(at(&b)) - 0.001).abs() < 1e-12
2590 })
2591 .unwrap();
2592 model.widen(&edge, Tolerance::new(0.002).unwrap()).unwrap();
2593 let NodeData::Edge(data) = model.node(&edge).unwrap().data().clone() else {
2594 panic!("not an edge")
2595 };
2596 let (original, surface) = data
2597 .representations
2598 .iter()
2599 .find_map(|r| match r {
2600 EdgeRepr::PCurve { curve, surface, .. } => Some((*curve, *surface)),
2601 _ => None,
2602 })
2603 .unwrap();
2604 let basis = model.geometry().pcurve(original).unwrap().clone();
2605 let moved = model
2606 .geometry_mut()
2607 .add_pcurve(ogeom_geom::PlanarCurve::Offset(Box::new(
2608 ogeom_geom::Offset2d::new(basis, offset).unwrap(),
2609 )));
2610 if let Some(NodeData::Edge(data)) =
2611 model.node_mut(&edge).map(ogeom_topo::TShape::data_mut)
2612 {
2613 for repr in &mut data.representations {
2614 if let EdgeRepr::PCurve {
2615 curve, surface: s, ..
2616 } = repr
2617 && *s == surface
2618 {
2619 *curve = moved;
2620 }
2621 }
2622 }
2623
2624 let found = check(&model, &solid, T).unwrap();
2625 assert!(found.is_usable(), "got {found}");
2626 let suspect = found.of(Severity::Suspect);
2627 if reported {
2628 assert_eq!(suspect.len(), 1, "got {found}");
2629 assert!(suspect[0].at.is_same(&edge), "got {found}");
2630 } else {
2631 assert!(found.is_valid(), "got {found}");
2632 }
2633 }
2634 }
2635
2636 #[test]
2637 fn an_edge_below_the_confusion_tolerance_is_not_suspect() {
2638 use ogeom_geom::{Curve, Curve2d as _, Curve3d as _, LineCurve, PlaneSurface};
2643 let mut model = Model::new();
2644 let line = LineCurve::segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0), T).unwrap();
2645 let start = line.domain().0;
2646 let edge = crate::make_edge(&mut model, Curve::Line(line), (start, start + 5e-8), T)
2647 .unwrap()
2648 .shape;
2649 let plane = PlaneSurface::over(
2650 ogeom_math::Plane::new(Frame::WORLD),
2651 (-1.0, 1.0),
2652 (-1.0, 1.0),
2653 )
2654 .unwrap();
2655 let surface = model.geometry_mut().add_surface(plane.into());
2656 let beside: ogeom_geom::PlanarCurve =
2657 ogeom_geom::Line2d::segment(Point2::new(0.0, 8e-8), Point2::new(1.0, 8e-8), T)
2658 .unwrap()
2659 .into();
2660 let range = (beside.domain().0, beside.domain().0 + 5e-8);
2661 let pcurve = model.geometry_mut().add_pcurve(beside);
2662 if let Some(NodeData::Edge(data)) = model.node_mut(&edge).map(ogeom_topo::TShape::data_mut)
2663 {
2664 data.representations.push(EdgeRepr::PCurve {
2665 curve: pcurve,
2666 range,
2667 surface,
2668 location: ogeom_topo::Location::identity(),
2669 });
2670 }
2671 let gap = crate::pcurve_gap::edge_pcurve_gap(&model, &edge, T)
2672 .unwrap()
2673 .unwrap();
2674 assert!(gap > 5e-8 && gap < 1e-7, "{gap}");
2675 let found = check(&model, &edge, T).unwrap();
2676 assert!(found.is_valid(), "got {found}");
2677 }
2678
2679 #[test]
2680 fn a_gap_on_a_plane_outside_its_window_is_measured_where_it_stands() {
2681 use ogeom_geom::{Curve2d as _, PlaneSurface};
2686 let square = |shift: f64| {
2687 let mut model = Model::new();
2688 let corners = [(5.0, 5.0), (6.0, 5.0), (6.0, 6.0), (5.0, 6.0)]
2689 .map(|(x, y)| Point::new(x, y, 0.0));
2690 let wire = crate::make_polygon(&mut model, &corners, true, T)
2691 .unwrap()
2692 .shape;
2693 let plane = PlaneSurface::over(
2694 ogeom_math::Plane::new(Frame::WORLD),
2695 (-1.0, 1.0),
2696 (-1.0, 1.0),
2697 )
2698 .unwrap();
2699 let surface = model.geometry_mut().add_surface(plane.into());
2700 let edges = model.ordered_children_of(&wire).unwrap();
2701 for (k, edge) in edges.iter().enumerate() {
2702 let forward = edge.oriented(ogeom_topo::Orientation::Forward);
2703 let (a, b) = crate::build::edge_vertices(&model, &forward)
2704 .unwrap()
2705 .unwrap();
2706 let at = |v: &Shape| {
2707 let p = model
2708 .node(v)
2709 .and_then(|n| n.data().as_vertex())
2710 .unwrap()
2711 .point;
2712 let moved = if k == 0 { shift } else { 0.0 };
2713 Point2::new(p.x + moved, p.y)
2714 };
2715 let line: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::segment(at(&a), at(&b), T)
2716 .unwrap()
2717 .into();
2718 let range = line.domain();
2719 crate::attach_pcurve(
2720 &mut model,
2721 &forward,
2722 line,
2723 surface,
2724 ogeom_topo::Location::identity(),
2725 range,
2726 )
2727 .unwrap();
2728 }
2729 let face = crate::make_face_on(&mut model, surface, &[wire], T)
2730 .unwrap()
2731 .shape;
2732 chart_breaks(&model, &face, &|_| false, T).unwrap()
2733 };
2734 assert!(square(0.0).is_empty(), "{:?}", square(0.0));
2735 assert_eq!(
2736 square(0.3).len(),
2737 1,
2738 "the moved pcurve leaves the square open"
2739 );
2740 }
2741
2742 #[test]
2743 fn a_seam_on_a_plane_is_reported() {
2744 use ogeom_geom::{Curve, Curve3d as _, LineCurve, PlaneSurface};
2749 let seamed = |second: Option<f64>| {
2750 let mut model = Model::new();
2751 let line = LineCurve::segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0), T).unwrap();
2752 let range = line.domain();
2753 let edge = crate::make_edge(&mut model, Curve::Line(line), range, T)
2754 .unwrap()
2755 .shape;
2756 let plane = PlaneSurface::over(
2757 ogeom_math::Plane::new(Frame::WORLD),
2758 (-2.0, 2.0),
2759 (-2.0, 2.0),
2760 )
2761 .unwrap();
2762 let surface = model.geometry_mut().add_surface(plane.into());
2763 let at = |v: f64| -> ogeom_geom::PlanarCurve {
2764 ogeom_geom::Line2d::segment(Point2::new(0.0, v), Point2::new(1.0, v), T)
2765 .unwrap()
2766 .into()
2767 };
2768 let here = ogeom_topo::Location::identity();
2769 match second {
2770 Some(v) => {
2771 crate::attach_seam(&mut model, &edge, at(0.0), at(v), surface, here, range)
2772 }
2773 None => crate::attach_pcurve(&mut model, &edge, at(0.0), surface, here, range),
2774 }
2775 .unwrap();
2776 check(&model, &edge, T).unwrap()
2777 };
2778 assert!(seamed(None).is_valid());
2779 let found = seamed(Some(1.0));
2780 assert!(
2781 found
2782 .problems
2783 .iter()
2784 .any(|p| p.what.contains("seam on a surface that does not close")),
2785 "got {found}"
2786 );
2787 }
2788
2789 #[test]
2790 fn a_diagnosis_reads_as_a_report_rather_than_a_debug_dump() {
2791 let mut model = Model::new();
2792 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2793 .unwrap()
2794 .shape;
2795 assert_eq!(check(&model, &solid, T).unwrap().to_string(), "valid");
2796
2797 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
2798 let shell = crate::build::make_shell(&mut model, std::slice::from_ref(&face))
2799 .unwrap()
2800 .shape;
2801 let text = check(&model, &shell, T).unwrap().to_string();
2802 assert!(text.starts_with("[suspect] Shell:"), "got {text}");
2803 assert!(text.contains("open"), "got {text}");
2804 }
2805
2806 #[test]
2807 fn a_handle_that_does_not_resolve_is_an_error_not_a_finding() {
2808 let mut other = Model::new();
2817 for _ in 0..4 {
2818 other.add_vertex(VertexData::new(Point::ORIGIN));
2819 }
2820 let beyond = other.add_vertex(VertexData::new(Point::ORIGIN));
2821
2822 let empty = Model::new();
2823 assert!(check(&empty, &beyond, T).is_err());
2824 }
2825
2826 fn walked_one_way_named(diagnosis: &Diagnosis) -> Vec<TShapeId> {
2829 diagnosis
2830 .problems
2831 .iter()
2832 .filter(|p| {
2833 p.severity == Severity::Suspect
2834 && p.kind == ShapeType::Edge
2835 && p.what.starts_with("walked the same way by both its faces")
2836 })
2837 .map(|p| p.at.node())
2838 .collect()
2839 }
2840
2841 #[test]
2846 fn an_edge_both_faces_walk_the_same_way_is_named() {
2847 let mut model = Model::new();
2848 let block = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T)
2849 .unwrap()
2850 .shape;
2851 let faces = explore_unique(&model, &block, ShapeType::Face).unwrap();
2852 let face = faces[2].clone();
2853 let data = model.node(&face).unwrap().data().as_face().unwrap().clone();
2854 let stored = face.oriented(ogeom_topo::Orientation::Forward);
2855 let [wire] = &model.ordered_children_of(&stored).unwrap()[..] else {
2856 panic!("a box face has one ring");
2857 };
2858 let ring = model.ordered_children_of(wire).unwrap();
2859 let back: Vec<Shape> = ring.iter().rev().map(Shape::reversed).collect();
2860 let back = crate::build::make_wire(&mut model, &back, T).unwrap().shape;
2861 let walked_back = model
2862 .add_face(data, &[back])
2863 .unwrap()
2864 .oriented(face.orientation());
2865
2866 let mut held = faces.clone();
2867 held[2] = walked_back.clone();
2868 let shell = crate::build::make_shell(&mut model, &held).unwrap().shape;
2869 let solid = crate::build::make_solid(&mut model, &[shell])
2870 .unwrap()
2871 .shape;
2872 let diagnosis = check(&model, &solid, T).unwrap();
2873 let mut named = walked_one_way_named(&diagnosis);
2874 named.sort();
2875 let mut ring_nodes: Vec<TShapeId> = ring.iter().map(Shape::node).collect();
2876 ring_nodes.sort();
2877 assert_eq!(named, ring_nodes, "{diagnosis}");
2878 assert!(diagnosis.is_usable(), "{diagnosis}");
2879
2880 let beside = faces
2881 .iter()
2882 .find(|f| {
2883 let theirs = explore_unique(&model, f, ShapeType::Edge).unwrap();
2884 !f.is_same(&face) && theirs.iter().any(|e| ring_nodes.contains(&e.node()))
2885 })
2886 .unwrap()
2887 .clone();
2888 let sheet = crate::build::make_shell(&mut model, &[walked_back, beside])
2889 .unwrap()
2890 .shape;
2891 assert_eq!(
2892 walked_one_way_named(&check(&model, &sheet, T).unwrap()).len(),
2893 1
2894 );
2895 assert!(walked_one_way_named(&check(&model, &block, T).unwrap()).is_empty());
2896 }
2897
2898 fn spline_sheet(model: &mut Model, lift: f64, bend: f64) -> Shape {
2901 use ogeom_geom::Surface as _;
2902 let n = 11;
2903 let at = |k: i32| -5.0 + 10.0 * f64::from(k) / f64::from(n - 1);
2904 let rows: Vec<Vec<Point>> = (0..n)
2905 .map(|j| {
2906 (0..n)
2907 .map(|i| {
2908 let (x, y) = (at(i), at(j));
2909 Point::new(x, y, bend.mul_add((x * x - y * y) / 20.0, lift))
2910 })
2911 .collect()
2912 })
2913 .collect();
2914 let surface = ogeom_geom::fit::fit_surface_grid(&rows, 3, 1e-6, T)
2915 .unwrap()
2916 .curve;
2917 let ((u0, u1), (v0, v1)) = surface.domain();
2918 let corners: Vec<Shape> = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)]
2919 .iter()
2920 .map(|&(u, v)| crate::make_vertex(model, surface.point_at(u, v, T).unwrap()).shape)
2921 .collect();
2922 let mut iso = |curve: ogeom_geom::BSplineCurve, from: usize, to: usize| {
2923 let range = curve.domain();
2924 crate::make_edge_between(model, curve.into(), range, &corners[from], &corners[to], T)
2925 .unwrap()
2926 .shape
2927 };
2928 let south = iso(surface.iso_v_curve(v0, T).unwrap(), 0, 1);
2929 let east = iso(surface.iso_u_curve(u1, T).unwrap(), 1, 2);
2930 let north = iso(surface.iso_v_curve(v1, T).unwrap(), 3, 2);
2931 let west = iso(surface.iso_u_curve(u0, T).unwrap(), 0, 3);
2932 let wires = [vec![south, east, north.reversed(), west.reversed()]];
2933 crate::make_face_with_pcurves(model, surface.into(), &wires, T)
2934 .unwrap()
2935 .shape
2936 }
2937
2938 #[test]
2939 fn spline_sheets_whose_boxes_overlap_are_told_apart_or_measured() {
2940 let mut model = Model::new();
2941 let saddle = spline_sheet(&mut model, 0.0, 1.0);
2942 let above = spline_sheet(&mut model, 0.05, 1.0);
2945 let flat = spline_sheet(&mut model, 0.0, 0.0);
2948 let elements = |s: &Shape| crate::proximity::Elements::of(&model, s, T).unwrap();
2949 let (saddle_e, above_e, flat_e) = (elements(&saddle), elements(&above), elements(&flat));
2950 assert!(saddle_e.apart_by_more_than(&above_e, T.confusion(), T));
2951 assert!(!saddle_e.apart_by_more_than(&flat_e, T.confusion(), T));
2952
2953 let nested = crate::make_compound(&mut model, &[saddle.clone(), above])
2954 .unwrap()
2955 .shape;
2956 assert!(
2957 check_self_intersection(&model, &nested, T)
2958 .unwrap()
2959 .is_empty()
2960 );
2961 let crossed = crate::make_compound(&mut model, &[saddle, flat])
2962 .unwrap()
2963 .shape;
2964 assert_eq!(
2965 check_self_intersection(&model, &crossed, T).unwrap().len(),
2966 1
2967 );
2968 }
2969
2970 #[test]
2971 fn a_rigid_placement_does_not_change_which_faces_read_inside_out() {
2972 use ogeom_math::{Point, Transform, Vector};
2979 let mut model = Model::new();
2980 let first = make_box(&mut model, Frame::WORLD, (2.0, 1.0, 1.0), T)
2981 .unwrap()
2982 .shape;
2983 let at = Frame::WORLD.with_origin(Point::new(1.0, -1.0, 0.5));
2984 let second = make_box(&mut model, at, (2.0, 3.0, 1.0), T).unwrap().shape;
2985 let shells = [&first, &second]
2986 .map(|s| explore_unique(&model, s, ShapeType::Shell).unwrap()[0].clone());
2987 let solid = model.add_solid(&shells).unwrap();
2988 let named = |model: &Model, solid: &Shape| -> Vec<_> {
2989 inside_out_faces(model, solid, T)
2990 .unwrap()
2991 .iter()
2992 .map(|f| (f.node(), f.orientation()))
2993 .collect()
2994 };
2995 let unplaced = named(&model, &solid);
2996 for step in 1..48 {
2997 let angle = f64::from(step) * 0.131;
2998 let motion = Transform::translation(Vector::new(37.3, -11.9, 70.1))
2999 * Transform::rotation(ogeom_math::Axis::Z, angle)
3000 * Transform::rotation(ogeom_math::Axis::X, angle * 0.7);
3001 let placed = crate::transformed(&mut model, &solid, motion)
3002 .unwrap()
3003 .shape;
3004 assert_eq!(
3005 named(&model, &placed),
3006 unplaced,
3007 "placed by a rotation of {angle}, other faces read inside out"
3008 );
3009 }
3010 }
3011
3012 fn rims_turned(frustum: bool, turn: fn(Frame) -> Frame) -> (Model, Shape, Shape) {
3016 use ogeom_geom::{CircleCurve, Curve};
3017 let mut model = Model::new();
3018 let solid = if frustum {
3019 crate::make_cone(&mut model, Frame::WORLD, 1.0, 0.5, 2.0, T)
3020 } else {
3021 make_cylinder(&mut model, Frame::WORLD, 1.0, 2.0, T)
3022 }
3023 .unwrap()
3024 .shape;
3025 let surface = |_: &ogeom_geom::SurfaceGeometry| Ok(None);
3026 let curve = |c: &Curve, range: (f64, f64)| {
3027 Ok(match c {
3028 Curve::Circle(c) => {
3029 let circle = c.circle();
3030 let circle = ogeom_math::Circle::new(turn(circle.frame()), circle.radius(), T)?;
3031 Some((CircleCurve::new(circle).into(), range))
3032 }
3033 _ => None,
3034 })
3035 };
3036 let turned = crate::restate_geometry(&mut model, &solid, &surface, &curve, T)
3037 .unwrap()
3038 .shape;
3039 (model, solid, turned)
3040 }
3041
3042 fn seam_side_findings(diagnosis: &Diagnosis) -> Vec<&Problem> {
3043 diagnosis
3044 .problems
3045 .iter()
3046 .filter(|p| p.what.contains("the side the face lies against"))
3047 .collect()
3048 }
3049
3050 #[test]
3056 fn rims_turned_over_under_their_uses_leave_the_wall_s_seam_open() {
3057 let turns: [fn(Frame) -> Frame; 2] = [
3058 |f| f.mirrored(),
3059 |f| Frame::from_axes(f.origin(), f.x(), -f.y(), -f.z(), T).unwrap(),
3060 ];
3061 for turn in turns {
3062 for frustum in [false, true] {
3063 let (model, solid, turned) = rims_turned(frustum, turn);
3064 assert!(check(&model, &solid, T).unwrap().is_valid());
3065 let diagnosis = check(&model, &turned, T).unwrap();
3066 let named = seam_side_findings(&diagnosis);
3067 assert_eq!(named.len(), 1, "{diagnosis}");
3068 assert_eq!(named[0].severity, Severity::Broken);
3069 let wall = model.node(&named[0].at).unwrap().data().as_face().unwrap();
3070 assert!(
3071 model
3072 .geometry()
3073 .surface(wall.surface)
3074 .unwrap()
3075 .is_periodic_u(),
3076 "{diagnosis}"
3077 );
3078 }
3079 }
3080 }
3081
3082 #[test]
3087 fn rims_turned_over_on_a_chart_that_does_not_repeat_are_not_named() {
3088 let (mut model, _, turned) = rims_turned(false, |f| f.mirrored());
3089 let splines = crate::to_nurbs(&mut model, &turned, T).unwrap().shape;
3090 let diagnosis = check(&model, &splines, T).unwrap();
3091 assert!(seam_side_findings(&diagnosis).is_empty(), "{diagnosis}");
3092 }
3093
3094 #[test]
3098 fn a_rim_stated_a_turn_off_its_window_is_not_named() {
3099 let mut model = Model::new();
3100 let solid = make_cylinder(&mut model, Frame::WORLD, 1.0, 2.0, T)
3101 .unwrap()
3102 .shape;
3103 let mut moved = 0;
3104 for face in explore_unique(&model, &solid, ShapeType::Face).unwrap() {
3105 let surface = model.node(&face).unwrap().data().as_face().unwrap().surface;
3106 if !model.geometry().surface(surface).unwrap().is_periodic_u() {
3107 continue;
3108 }
3109 for edge in explore_unique(&model, &face, ShapeType::Edge).unwrap() {
3110 let data = model.node(&edge).unwrap().data().as_edge().unwrap();
3111 let Some(EdgeRepr::PCurve { curve, .. }) =
3112 data.pcurve_for(surface, edge.location())
3113 else {
3114 continue;
3115 };
3116 let curve = *curve;
3117 let off = ogeom_math::Transform2::translation(ogeom_math::Vector2::new(
3118 2.0 * core::f64::consts::PI,
3119 0.0,
3120 ));
3121 let shifted = model
3122 .geometry()
3123 .pcurve(curve)
3124 .unwrap()
3125 .transformed(&off, T)
3126 .unwrap();
3127 *model.geometry_mut().pcurve_mut(curve).unwrap() = shifted;
3128 moved += 1;
3129 }
3130 }
3131 assert_eq!(moved, 2);
3132 let diagnosis = check(&model, &solid, T).unwrap();
3133 assert!(seam_side_findings(&diagnosis).is_empty(), "{diagnosis}");
3134 }
3135}
3136
3137#[cfg(test)]
3138#[allow(clippy::unwrap_used, clippy::expect_used)]
3139mod tessellation_tests {
3140 use super::*;
3141 use crate::{make_box, make_cone, make_cylinder, make_sphere, make_torus};
3142 use ogeom_math::{Frame, Point};
3143 use ogeom_topo::{NodeData, VertexData};
3144
3145 const T: Tolerances = Tolerances::millimetres();
3146
3147 fn fine() -> Deflection {
3148 Deflection {
3149 chord: 0.02,
3150 ..Deflection::default()
3151 }
3152 }
3153
3154 #[test]
3155 fn every_primitive_tessellates_into_a_mesh_that_agrees_with_its_topology() {
3156 let mut model = Model::new();
3160 let shapes = [
3161 make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
3162 .unwrap()
3163 .shape,
3164 make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T)
3165 .unwrap()
3166 .shape,
3167 make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap().shape,
3168 make_cone(&mut model, Frame::WORLD, 3.0, 1.0, 4.0, T)
3169 .unwrap()
3170 .shape,
3171 make_cone(&mut model, Frame::WORLD, 3.0, 0.0, 4.0, T)
3172 .unwrap()
3173 .shape,
3174 make_torus(&mut model, Frame::WORLD, 5.0, 2.0, T)
3175 .unwrap()
3176 .shape,
3177 ];
3178 for shape in &shapes {
3179 let found = check_tessellation(&model, shape, fine(), T).unwrap();
3180 assert!(found.is_valid(), "a primitive's mesh came apart: {found}");
3181 }
3182 }
3183
3184 #[test]
3185 fn a_prism_tessellates_into_an_agreeing_mesh_whichever_face_it_swept() {
3186 use ogeom_math::Vector;
3192 for role in [
3193 crate::primitive::roles::FACE_MAX_Z,
3194 crate::primitive::roles::FACE_MIN_Z,
3195 ] {
3196 let mut model = Model::new();
3197 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
3198 .unwrap()
3199 .shape;
3200 let face = explore_unique(&model, &solid, ShapeType::Face)
3201 .unwrap()
3202 .into_iter()
3203 .find(|f| {
3204 model
3205 .provenance_of(f)
3206 .and_then(ogeom_core::Provenance::role)
3207 == Some(role)
3208 })
3209 .expect("the box has a face with that role");
3210 let prism = crate::make_prism(&mut model, &face, Vector::new(0.0, 0.0, 2.0), T)
3211 .unwrap()
3212 .shape;
3213 assert!(
3214 check_tessellation(&model, &prism, fine(), T)
3215 .unwrap()
3216 .is_valid(),
3217 "{role:?}"
3218 );
3219 assert!(check(&model, &prism, T).unwrap().is_valid(), "{role:?}");
3220 }
3221 }
3222
3223 #[test]
3224 fn moving_a_vertex_does_not_move_the_mesh() {
3225 let mut model = Model::new();
3230 let solid = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T)
3231 .unwrap()
3232 .shape;
3233 let before = ogeom_mesh::triangulate(&model, &solid, fine(), T).unwrap();
3234
3235 let vertex = explore_unique(&model, &solid, ShapeType::Vertex).unwrap()[0].clone();
3236 if let Some(NodeData::Vertex(data)) =
3237 model.node_mut(&vertex).map(ogeom_topo::TShape::data_mut)
3238 {
3239 data.point = Point::new(0.5, 0.5, 0.5);
3240 }
3241
3242 let after = ogeom_mesh::triangulate(&model, &solid, fine(), T).unwrap();
3243 assert_eq!(before.positions, after.positions);
3244 assert!(
3245 check_tessellation(&model, &solid, fine(), T)
3246 .unwrap()
3247 .is_valid()
3248 );
3249 assert!(
3250 !check(&model, &solid, T).unwrap().is_usable(),
3251 "check sees it"
3252 );
3253 }
3254
3255 #[test]
3256 fn an_open_shell_is_not_reported_because_it_never_claimed_to_close() {
3257 let mut model = Model::new();
3260 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
3261 .unwrap()
3262 .shape;
3263 let face = explore_unique(&model, &solid, ShapeType::Face).unwrap()[0].clone();
3264 let shell = crate::build::make_shell(&mut model, std::slice::from_ref(&face))
3265 .unwrap()
3266 .shape;
3267 assert!(
3268 check_tessellation(&model, &shell, fine(), T)
3269 .unwrap()
3270 .is_valid()
3271 );
3272 }
3273
3274 #[test]
3275 fn a_shape_with_no_shell_has_nothing_to_disagree_about() {
3276 let mut model = Model::new();
3277 let vertex = model.add_vertex(VertexData::new(Point::ORIGIN));
3278 assert!(
3279 check_tessellation(&model, &vertex, fine(), T)
3280 .unwrap()
3281 .is_valid()
3282 );
3283 }
3284}