Skip to main content

ogeom_algo/
check.rs

1//! Checking a shape against the model's invariants.
2//!
3//! Everything in `docs/DATA_MODEL.md` that can be checked with geometry in
4//! hand, checked in one place. The builders enforce what they can at the moment
5//! of construction; this catches what only becomes wrong later: an edge whose
6//! tolerance was widened past its face's, a shell left open by an operation
7//! that dropped a face, a pcurve that has stopped agreeing with its curve.
8//!
9//! # It reports, it does not judge
10//!
11//! The result is a list of what is wrong and where, not a boolean. A boolean
12//! answers "should I panic", which is never the question: an imported shape is
13//! usually invalid in some specific, fixable way, and healing it needs to know
14//! which way. A caller that only wants the boolean asks
15//! [`Diagnosis::is_valid`].
16//!
17//! # Severity is not a comment
18//!
19//! [`Severity::Broken`] means an algorithm reading this shape will get a wrong
20//! answer rather than an error: an open shell has no inside, so every
21//! containment test against it is a coin toss. [`Severity::Suspect`] means
22//! something is out of order but every operation will still behave: a tolerance
23//! larger than the feature it describes is alarming and not yet wrong.
24//!
25//! The distinction is what lets a pipeline decide. Booleans refuse `Broken`
26//! input because they would produce nonsense from it; they proceed on
27//! `Suspect` because refusing would reject most real imported geometry.
28
29use 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/// How badly a problem breaks the shape.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
39pub enum Severity {
40    /// Out of order, but every operation will still behave.
41    Suspect,
42    /// An algorithm reading this shape will get a wrong answer, not an error.
43    Broken,
44}
45
46/// One thing wrong with a shape.
47#[derive(Debug, Clone, PartialEq)]
48pub struct Problem {
49    /// How badly it breaks things.
50    pub severity: Severity,
51    /// The sub-shape it is about.
52    pub at: Shape,
53    /// What kind of sub-shape that is, so a report reads without a lookup.
54    pub kind: ShapeType,
55    /// What is wrong, in a sentence.
56    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/// Everything wrong with a shape.
70#[derive(Debug, Clone, PartialEq, Default)]
71pub struct Diagnosis {
72    /// The problems found, in the order they were found.
73    pub problems: Vec<Problem>,
74}
75
76impl Diagnosis {
77    /// Whether nothing is wrong at all.
78    #[must_use]
79    pub fn is_valid(&self) -> bool {
80        self.problems.is_empty()
81    }
82
83    /// Whether anything would make an algorithm answer wrongly.
84    ///
85    /// The question a boolean or a mass property should ask before starting.
86    #[must_use]
87    pub fn is_usable(&self) -> bool {
88        !self.problems.iter().any(|p| p.severity == Severity::Broken)
89    }
90
91    /// The worst severity found.
92    #[must_use]
93    pub fn worst(&self) -> Option<Severity> {
94        self.problems.iter().map(|p| p.severity).max()
95    }
96
97    /// The problems of one severity.
98    #[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
131/// Check a shape and everything below it.
132///
133/// # Errors
134///
135/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle does not
136/// resolve. A dangling handle is not a *finding*; it means the shape and the
137/// model do not belong together, and every other answer would be about
138/// something that is not there.
139pub 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
161/// Check that a shape's *tessellation* agrees with its topology.
162///
163/// Separate from [`check`] because it needs a deflection, and because it asks a
164/// different question: not "is this shape well formed" but "do its two
165/// descriptions of itself agree". A shell whose edges are all used twice is
166/// closed as far as the topology knows. If the mesh built from it still has a
167/// boundary, then some face's pcurves do not cover the region its edges claim
168/// to bound, and the topology cannot see that, because the defect is entirely
169/// in parameter space.
170///
171/// That failure is worth its own function because it is *invisible* to every
172/// other check. Face counts look right, the shell closes, each face
173/// triangulates without error, and the solid still has a slit down it. The
174/// first thing to notice is usually a volume that is quietly wrong.
175///
176/// Reports the position of the unshared edges, not just their number: where the
177/// mesh comes apart is the whole diagnosis, and a count sends you looking.
178///
179/// # Errors
180///
181/// As [`ogeom_mesh::triangulate()`].
182pub 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        // An open shell is *meant* to have a boundary, so a mesh with one is
192        // agreement, not disagreement. Only a shell the topology calls closed
193        // makes a claim the mesh can contradict.
194        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
216/// Describe a mesh's unshared edges, or `None` if every edge is shared twice.
217fn 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    // Deterministic: a diagnosis that names a different edge each run is one
235    // nobody can act on.
236    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
261/// An edge's curve must reach the vertices it claims to join, and its
262/// representations must agree with each other.
263fn 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        // A degenerate edge is *supposed* to have no curve. Any other edge
279        // without one has nowhere in space it runs, and every algorithm that
280        // walks a boundary will skip it silently.
281        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        // The junction's own stated tolerance is the radius within which
320        // things meeting it may stray; the same acceptance construction
321        // applies. A checker stricter than the builder would condemn what
322        // the builder rightly admitted and honestly recorded.
323        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    // `same_parameter` is a claim, and a false one is worse than no claim: every
338    // algorithm evaluates whichever representation is cheapest and assumes the
339    // answer is interchangeable.
340    if data.same_parameter() {
341        check_same_parameter(model, edge, data, geometry, *range, reach, tol, found)?;
342    }
343    Ok(())
344}
345
346/// Verify that every pcurve lands where the 3D curve does.
347#[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
410/// A wire's edges must meet end to end.
411fn 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
459/// Every edge of a face needs a pcurve on that face's surface.
460fn 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
511/// A shell is closed when every edge is used an even number of times.
512///
513/// Reported as `Suspect` rather than `Broken` on its own: an open shell is a
514/// perfectly good surface and plenty of operations want one. It becomes
515/// `Broken` only when something asks it to bound a volume, which is a question
516/// this function is not being asked.
517fn 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
548/// Tolerance containment: a face is no looser than its edges, an edge no looser
549/// than its vertices.
550///
551/// The rule is transitive and the check has to be too. Checking one level would
552/// pass a face whose edge is fine and whose *vertex* is tighter than the face,
553/// and the containment claim is about the face reaching the vertex.
554fn 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
564/// Restore tolerance containment below `shape`: every edge widened to at
565/// least the faces it bounds, every vertex to at least the edges it bounds.
566///
567/// The rule [`check`] enforces, established the only way the data model
568/// allows: by raising what is bounded, never lowering what bounds. Each
569/// face and then each edge is widened to its own tolerance through
570/// [`Model::widen`], which cascades to everything below it and leaves
571/// anything already looser as it is. Returns how many entities grew.
572///
573/// An operation that widens an edge's tolerance by writing it directly (a
574/// reader recording how far a pcurve sits from its curve) leaves the
575/// edge's vertices behind; this is the pass that brings them along.
576///
577/// # Errors
578///
579/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the shape,
580/// or anything below it, does not resolve in this model.
581pub 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
606/// Compare one shape's tolerance against everything below it.
607fn 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
640/// Faces of one shape that reach each other without sharing topology:
641/// self-intersection, detected as the interference it is.
642///
643/// Every unordered pair of distinct faces that share no edge and no vertex
644/// node is put through the exact minimum-distance machinery; a pair within
645/// the confusion tolerance of touching is reported. Adjacent faces meet at
646/// their shared boundary by construction and are not interference; a valid
647/// solid therefore reports nothing, and a sheet folded through itself names
648/// the faces that cross.
649///
650/// # Errors
651///
652/// As [`crate::distance_between_shapes`].
653pub 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    // The topology below each face, for the adjacency exclusion.
661    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        // The check earns its keep only if the things known to be right pass
711        // it. A checker that flags a correct box is worse than none, because
712        // every real finding then reads as noise.
713        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        // An open shell is a perfectly good surface, and plenty of operations
751        // want one. Calling it broken would make the checker useless for every
752        // sheet body.
753        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        // The containment rule runs the other way from intuition: the *bound*
772        // is looser, and a vertex tighter than the edge that caps it means the
773        // edge does not reliably reach it.
774        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        // Widen the edge alone, bypassing the cascading repair that exists to
781        // stop exactly this.
782        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        // The failure that leaves a gap in every face built on the wire, and
811        // which nothing notices until something walks the boundary.
812        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        // Without a pcurve the face cannot be split in a boolean or
837        // triangulated at all, and the failure surfaces far from its cause.
838        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        // A dangling handle means the shape and the model do not belong
883        // together. Every finding would then be about something that is not
884        // there, which is worse than no finding.
885        //
886        // A handle from a *different* model is caught too, and by the same
887        // route: arena keys carry the identifier of the arena that issued them,
888        // so a foreign one resolves to nothing rather than to whatever sits at
889        // that index.
890        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        // The regression net for every seam and every pole. A primitive whose
921        // shell closes but whose mesh does not is the failure this exists to
922        // name, and it is invisible to every other check.
923        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        // This check found the defect that made the distinction matter:
951        // sweeping the *downward* face of a box produced four lateral faces
952        // that every one of them failed to triangulate, while the shell still
953        // closed and every other check passed. Both directions are covered
954        // here now, so a regression cannot hide behind the one that worked.
955        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        // Worth pinning, because it is unintuitive and it invalidated an
990        // earlier attempt at a test here. Tessellation reads curves and
991        // pcurves, never vertex positions, so a vertex moved off its edges is
992        // caught by `check` (the curve no longer reaches it) and is invisible
993        // to `check_tessellation`. The two checks genuinely see different
994        // things, which is why both exist.
995        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        // A mesh with a boundary is agreement here, not disagreement. Flagging
1024        // it would make the check useless for every sheet body.
1025        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}