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 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/// How badly a problem breaks the shape.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub enum Severity {
41    /// Out of order, but every operation will still behave.
42    Suspect,
43    /// An algorithm reading this shape will get a wrong answer, not an error.
44    Broken,
45}
46
47/// One thing wrong with a shape.
48#[derive(Debug, Clone, PartialEq)]
49pub struct Problem {
50    /// How badly it breaks things.
51    pub severity: Severity,
52    /// The sub-shape it is about.
53    pub at: Shape,
54    /// What kind of sub-shape that is, so a report reads without a lookup.
55    pub kind: ShapeType,
56    /// What is wrong, in a sentence.
57    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/// Everything wrong with a shape.
71#[derive(Debug, Clone, PartialEq, Default)]
72pub struct Diagnosis {
73    /// The problems found, in the order they were found.
74    pub problems: Vec<Problem>,
75}
76
77impl Diagnosis {
78    /// Whether nothing is wrong at all.
79    #[must_use]
80    pub fn is_valid(&self) -> bool {
81        self.problems.is_empty()
82    }
83
84    /// Whether anything would make an algorithm answer wrongly.
85    ///
86    /// The question a boolean or a mass property should ask before starting.
87    #[must_use]
88    pub fn is_usable(&self) -> bool {
89        !self.problems.iter().any(|p| p.severity == Severity::Broken)
90    }
91
92    /// The worst severity found.
93    #[must_use]
94    pub fn worst(&self) -> Option<Severity> {
95        self.problems.iter().map(|p| p.severity).max()
96    }
97
98    /// The problems of one severity.
99    #[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
132/// Check a shape and everything below it.
133///
134/// # Errors
135///
136/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle does not
137/// resolve. A dangling handle is not a *finding*; it means the shape and the
138/// model do not belong together, and every other answer would be about
139/// something that is not there.
140pub fn check(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Diagnosis> {
141    check_kept(model, shape, tol, &crate::ProbeCache::new())
142}
143
144/// [`check`], probing faces' orientation through `kept`: a caller that
145/// checks a model, mends it and checks it again pays once for what the
146/// mend left alone.
147///
148/// # Errors
149///
150/// As [`check`].
151pub 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    // Every distinct sub-shape from one walk, by type. The walk is
163    // pre-order, so each type's shapes come in the order a walk for that
164    // type alone would give.
165    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    // Tolerance containment: a face is no looser than its edges, an edge no
195    // looser than its vertices, checked through every level below each.
196    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
205/// Run one check over every shape in parallel, each into its own findings,
206/// and append them to `found` in shape order: the diagnosis a serial walk
207/// gives. The first shape in order that fails fails the whole.
208fn 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
223/// Report every face of a solid that faces into its material, and every
224/// face that reads facing out of it at one place and into it at another.
225///
226/// A face is read at two places spread over it, not one: on a boundary that
227/// passes through itself or overlaps, a face can read out over part of its
228/// extent and in over the rest, and which part a single probe lands on
229/// would decide the answer. A probe can misread, though, and a face of a
230/// sound solid must not be named on a misreading. A face read both ways is
231/// named where the solid's boundary was found `crossed`, where its first
232/// place (the one a single probe asks) reads it facing in, or where the
233/// solid's winding number bears out a place reading it facing in; else the
234/// inward reading is taken for the probe's and nothing is named.
235fn 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
293/// How many places `check` reads each face at: the largest triangle's
294/// middle and that of the large triangle farthest from it. Each place costs a
295/// probe's rays, and the faces of a boundary crossing itself are named by
296/// the crossing as well.
297const PLACES_READ: usize = 2;
298
299/// How many places, over a shell's faces, must agree before they say which
300/// way the shell faces, where one probe is not to decide it.
301pub(crate) const AGREEING: usize = 3;
302
303/// What the probes of a face read: at how many places its outside was
304/// found outside the material, and at how many inside it.
305#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
306pub(crate) struct Readings {
307    /// Places where the face faces out of the material.
308    pub(crate) out: usize,
309    /// Places where it faces into it.
310    pub(crate) inward: usize,
311}
312
313impl Readings {
314    /// What `places` read, counted.
315    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    /// Whether the face faces into the material: `None` where nothing
324    /// read, or the places read disagree.
325    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    /// Whether some places read out and others in.
334    pub(crate) const fn disagree(self) -> bool {
335        self.out > 0 && self.inward > 0
336    }
337
338    /// How many places read at all.
339    pub(crate) const fn count(self) -> usize {
340        self.out + self.inward
341    }
342}
343
344/// Note every pair of consecutive edges that runs out and back along one
345/// path ([`out_and_back`](crate::out_and_back)): the face's region is the
346/// same without that stretch of its boundary, but its tip is a vertex that
347/// bounds no corner, which every boolean through the face meets as a
348/// strand and a mesher has to strip.
349fn 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
374/// Report every place where a solid's boundary passes through itself: a
375/// face folded through itself, or two faces crossing away from the edges
376/// they share.
377fn 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
398/// The faces of a solid that face into its material.
399///
400/// A face's flag is the only thing that says which side of its surface the
401/// material is on, and a flipped one leaves every edge used twice and the
402/// shell closed: nothing topological sees it. Nor do the flags asked about
403/// each other along shared edges: that walk reads which side of an edge a
404/// face lies on from its chart, and where the chart cannot say (an apex, a
405/// boundary whose middle is not inside it) it has to take the flags as they
406/// come. So every face is probed: a point a little off it along its outward
407/// normal must lie outside the material and the point as far the other way
408/// inside it. A face whose outside is inside is named; a probe that cannot
409/// tell names nothing, so a face is never named on a guess. A face whose
410/// chart cannot be walked is not asked at all.
411///
412/// # Errors
413///
414/// [`OgeomError::Cancelled`](ogeom_core::OgeomError::Cancelled) and
415/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) only; a
416/// question the geometry cannot answer names no face.
417pub 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
421/// [`inside_out_faces`], through `kept`: see [`check_kept`].
422///
423/// # Errors
424///
425/// As [`inside_out_faces`].
426pub 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        // A question the geometry cannot answer (an open shell, a chart
435        // walk off its domain) is no finding; only cancellation and a
436        // broken model are errors.
437        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
446/// The boundary of `solid` its faces are probed against, as
447/// [`inside_out_faces`] builds it.
448pub(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
456/// The faces of `faces` whose probe against `boundary` finds material on
457/// their outside, probed in parallel.
458fn 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
476/// Whether a face's outward normal points into the solid's material; `None`
477/// where the probe cannot tell: the first place of [`face_readings`] that
478/// reads decides, among as many triangles as a reading of three places
479/// tries.
480pub(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
492/// Which way a face faces at up to `wanted` places spread over it.
493///
494/// Probed at the middle of the face's largest mesh triangle, then at the
495/// middles of the large triangles farthest from those already probed, so
496/// that the places read spread over the face rather than crowd where its
497/// largest triangles lie. A place that cannot tell is passed over, and a
498/// few more are tried than wanted. Each place is read by [`read_place`],
499/// its shortest step well clear of the band within which a point counts as
500/// on the face.
501pub(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
518/// How many places beyond those wanted are tried before giving up.
519const SPARE: usize = 2;
520
521/// The places of [`face_readings`], up to `wanted` of them among the first
522/// `tried` triangles, each with what it read, in the order they were read.
523fn 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    /// How many of the largest triangles the places are chosen among.
532    const CHOSEN_AMONG: usize = 32;
533    // Meshed where the face stands in its own frame: a placement moves the
534    // probe points with the material, but placed positions round, and the
535    // rounding reorders triangles of one area. Where the face reads
536    // differently over its extent, that would let a rigid motion change
537    // the answer.
538    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    // The largest first, then each time the one farthest from every
570    // middle already taken; ties to the larger.
571    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    // Twenty times the band a point counts as on the face within, and
592    // never below a thousand confusion distances.
593    let shortest = data.tolerance.get().max(tol.confusion() * 1e3) * 20.0;
594    // The band a point counts as on the face's boundary within.
595    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        // The chart middle of a triangle lies on the face only where the
612        // face's region is convex there: a thin ring's longest triangles
613        // span its hole, and their middles stand in it, off the face, where
614        // the two sides read whatever surrounds the hole. The face's trim is
615        // drawn at the boundary's own ring chord, whose band a face narrower
616        // than it (a thin sheet's rim) lies within whole, and drawn once for
617        // every place asked.
618        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/// One place a face was read at, and whether it read facing into the
651/// material there.
652#[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/// The steps a place is read at: from `first` down by tenths to `shortest`.
661#[derive(Debug, Clone, Copy)]
662struct Ladder {
663    first: f64,
664    shortest: f64,
665}
666
667/// Whether the face faces into the material at a place, read a step off it
668/// each way along its outward normal: by the boundary's rays, or by its
669/// winding number at the chord it was first meshed at where `by_winding`
670/// says; `None` where no step tells.
671///
672/// The first step is a fraction of the probed triangle's size, and shorter
673/// steps follow: a step longer than the solid is thick lands past its far
674/// side, and one across a gap narrower than the step lands in the material
675/// beyond, and either leaves both points on the same side.
676fn 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        // A step reads the face's two sides only where it meets no
696        // other face on the way: off a sliver folded back between its
697        // neighbours, a step longer than the sliver is wide passes under
698        // one of them and reads the other's side. Off a sheet's rim the
699        // same step runs between the broad faces and meets none.
700        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
717/// Check that a shape's *tessellation* agrees with its topology.
718///
719/// Separate from [`check`] because it needs a deflection, and because it asks a
720/// different question: not "is this shape well formed" but "do its two
721/// descriptions of itself agree". A shell whose edges are all used twice is
722/// closed as far as the topology knows. If the mesh built from it still has a
723/// boundary, then some face's pcurves do not cover the region its edges claim
724/// to bound, and the topology cannot see that, because the defect is entirely
725/// in parameter space.
726///
727/// That failure is worth its own function because it is *invisible* to every
728/// other check. Face counts look right, the shell closes, each face
729/// triangulates without error, and the solid still has a slit down it. The
730/// first thing to notice is usually a volume that is quietly wrong.
731///
732/// Reports the position of the unshared edges, not just their number: where the
733/// mesh comes apart is the whole diagnosis, and a count sends you looking.
734///
735/// # Errors
736///
737/// As [`ogeom_mesh::triangulate()`].
738pub 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        // An open shell is *meant* to have a boundary, so a mesh with one is
748        // agreement, not disagreement. Only a shell the topology calls closed
749        // makes a claim the mesh can contradict.
750        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
772/// Describe a mesh's unshared edges, or `None` if every edge is shared twice.
773fn 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    // Deterministic: a diagnosis that names a different edge each run is one
791    // nobody can act on.
792    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
817/// An edge's curve must reach the vertices it claims to join, and its
818/// representations must agree with each other.
819fn 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        // A degenerate edge is *supposed* to have no curve. Any other edge
835        // without one has nowhere in space it runs, and every algorithm that
836        // walks a boundary will skip it silently.
837        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        // The junction's own stated tolerance is the radius within which
876        // things meeting it may stray; the same acceptance construction
877        // applies. A checker stricter than the builder would condemn what
878        // the builder rightly admitted and honestly recorded.
879        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
919/// An edge whose pcurve stands farther off its curve than the edge is long
920/// is reported suspect.
921///
922/// The pcurve then describes some other stretch of its surface, one the
923/// edge does not reach at its own scale: a fit whose end landed on the far
924/// side of a closed chart runs the far way round it. Its tolerance covers
925/// the gap, as it must for the edge to pass the containment tests, and is
926/// then wider than the edge is long. A gap past the tolerance is broken
927/// and reported by [`check_pcurves`], so only an edge no longer than its
928/// tolerance is measured here.
929///
930/// An exact pcurve, paced like its curve or not, stands off it by no more
931/// than the chord between two of its points, and is never reported. One
932/// that is only within its tolerance of the edge can be: the test is a
933/// heuristic, which is why the finding is suspect and not broken.
934fn 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
961/// The length of an edge whose pcurves stand farther off its curve than
962/// it is long, as [`check`](crate::check()) reports it suspect; `None`
963/// where the edge is not reported, or has no curve.
964///
965/// # Errors
966///
967/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the edge
968/// or its geometry is not in `model`; as the curve's evaluation.
969pub(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
995/// The gap and the length of an edge at most `tolerance` long whose
996/// pcurves stand farther off its curve than it is long, by more than the
997/// confusion distance.
998fn 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    // A polyline through points of the edge in order is no longer than
1007    // the edge: where it is longer than the tolerance, so is the edge. The
1008    // chord between the ends is the one-piece polyline, and turns most
1009    // edges away for two evaluations.
1010    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    // Below the confusion tolerance a gap and a length are rounding: an
1036    // edge with no length has a gap of a few ulps.
1037    Ok((gap > length + tol.confusion()).then_some((gap, length)))
1038}
1039
1040/// Verify that every pcurve lands where the 3D curve does, to the edge's
1041/// tolerance.
1042///
1043/// The tolerance is the radius about the curve within which every
1044/// description of the edge lies, so each pcurve lifted through its surface
1045/// stays inside it all along the edge, not only at its ends. An edge
1046/// claiming `same_parameter` is held to the claim: the lifted point at each
1047/// parameter within the tolerance of the curve's point at the same
1048/// parameter, since every algorithm evaluates whichever representation is
1049/// cheapest and assumes the answer interchangeable. An edge making no claim
1050/// may pace its pcurves differently, so a lifted point is measured against
1051/// the nearest point of the curve's stretch. A pcurve at another placement
1052/// than the curve describes the edge where that occurrence stands, and is
1053/// not compared.
1054fn 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
1136/// A seam's two sides stand a period apart across a direction its surface
1137/// closes in, so that the face walking both reads one place twice. On a
1138/// surface that does not close across them, the side a face does not walk
1139/// names another stretch of the surface, or none, and a face reading the
1140/// edge by its orientation alone can read that one.
1141fn 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
1179/// How far a pcurve, lifted through its surface, leaves its edge's curve
1180/// beyond `reach`: the widest gap and where along the range it stands, or
1181/// `None` where it keeps within `reach` everywhere sampled. Held to the
1182/// same parameter where `paced`; otherwise a lifted point is measured
1183/// against the nearest point of the curve's stretch.
1184pub(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    // A pcurve fitted through its edge's points strays most between them,
1193    // so the samples are many more than a fit takes per span.
1194    const SAMPLES: u32 = 32;
1195    // A gap stated where it was measured reads a few ulps wider measured
1196    // again here, through another chain of evaluations: past the tolerance
1197    // by rounding alone is within it.
1198    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
1220/// A wire's edges must meet end to end.
1221pub(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
1269/// Every edge of a face needs a pcurve on that face's surface, and the
1270/// boundary those pcurves draw must not cross itself in the surface's chart
1271/// ([`chart_crossings`](crate::chart_crossings)): a figure eight encloses no
1272/// single region, and the mesher, the classifier and a boolean each read it
1273/// differently.
1274fn 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
1340/// An edge's plain pcurves on one surface, at one placement, must agree.
1341///
1342/// A face finds its edge's pcurve by surface and placement, and the lookup
1343/// answers with the first that matches. Where two plain pcurves stand on
1344/// one surface and part (the two columns of a closed surface's seam,
1345/// attached as two plain pcurves for two faces on that surface), every face
1346/// reads the first, and a face that walks the other column reads its
1347/// boundary a period away. Apart is measured along the surface between
1348/// them: two fits of one edge stay within its tolerance of each other,
1349/// two columns of a seam stand the surface's girth apart.
1350fn 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
1403/// The widest distance between two pcurves, each sampled evenly over its
1404/// own range, measured along the surface between them.
1405fn 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
1424/// Where a face's boundary does not close in its surface's chart, each in
1425/// a sentence; none where it closes. The test [`check`] makes of every
1426/// face, for a builder asking it of the faces it made. A wire `kept` names
1427/// is one the builder took over as it stood, and is not walked.
1428///
1429/// # Errors
1430///
1431/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the face
1432/// names a surface not in the model.
1433pub 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
1444/// A face's boundary, read edge by edge through the pcurve lookup the
1445/// boolean and the mesher use, must close in its surface's chart.
1446///
1447/// Each edge is read as its face walks it: a plain pcurve from its start to
1448/// its end, or reversed; a seam on the side whose start continues the point
1449/// walked to, as the mesher picks it. At each joint, one edge's end and the
1450/// next edge's start stand within the joint's tolerance of each other,
1451/// measured along the surface between them, or a whole period apart along
1452/// a direction the surface closes in. Otherwise the boundary does not close
1453/// there.
1454///
1455/// A ring that goes once round the surface with no seam (a band's rim, one
1456/// circle) closes by a single period's jump, and each such jump counts
1457/// toward the ring's turns round the surface. Jumps that cancel (a period
1458/// one way at one joint, back at another) at an edge that holds another
1459/// plain pcurve on the surface close nothing: the lookup found the first,
1460/// and the face walks the other, on the far column of the surface's seam.
1461/// Cancelling jumps at edges with one pcurve each are a ring folding across
1462/// the chart's join, which the mesher folds back, and are not reported.
1463///
1464/// Either way a boolean through the face finds a strand that dangles. A
1465/// wire with an edge that has no pcurve on the face is reported by
1466/// [`check_face`] and not walked.
1467fn 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        // The walk starts off a seam: a seam's side is picked by the point
1496        // already walked to.
1497        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        // Whole periods jumped at the joints, signed and in all, per
1514        // direction.
1515        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            // The next edge's start brought to the end's copy of the chart.
1529            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
1586/// How far apart, along the surface, one edge's end and the next edge's
1587/// start may stand in a face's chart before the boundary is open there:
1588/// [`CHART_GAP_SLACK`] times the widest of the two edges' tolerances and
1589/// the joint vertex's.
1590fn 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
1602/// One edge of a ring as [`check_seam_sides`] walks it: its ends in the
1603/// chart as the ring walks it, the pcurve and range they come from and the
1604/// move that takes the pcurve to them, and, for a seam the ring walks both
1605/// ways, the direction across which its two sides stand apart.
1606struct 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
1615/// A ring that walks a seam both ways across a direction its surface
1616/// repeats in must chain, in the surface's chart, from each of the seam's
1617/// sides to the other through the edges between.
1618///
1619/// Every edge keeps the face on its left, so of a seam's two sides, which
1620/// stand a period apart (a far side stated whole periods further is read
1621/// a period from the near one) and bound the window the face wraps, an
1622/// occurrence walks the one from which the other lies on its left: the one
1623/// the face lies against. That reading is the boolean's, and it does not
1624/// trust the seam's stored designation, which a file may state swapped.
1625/// Every other edge may be stated any whole number of periods off and is
1626/// moved to start where the ring has reached: on a repeating chart no copy
1627/// is the stated one, and the ring's own direction is all that places an
1628/// edge between the seam's sides. On a chart that closes without repeating the
1629/// domain places every pcurve, a reader takes the ring as stated, and the
1630/// ring is not read here.
1631///
1632/// Where the edges between two such occurrences meet the seam's column
1633/// only at their two ends, they lie
1634/// between two neighbouring copies of the column and run from one
1635/// occurrence's side to the next one's side in one copy of the chart: if
1636/// they arrive a whole period off it, they leave the column on the side
1637/// away from the face, and the boundary does not close with the face on
1638/// its left. Rims turned to run the other way round a drum, their uses
1639/// kept, do that; read on the nearer side, as [`check_face_chart`] reads
1640/// a seam, the same ring closes, round the window the wrong way.
1641///
1642/// A boolean folds such a ring onto the seam's sides and finds a strand
1643/// that dangles. Edges between that meet the column elsewhere (a ring
1644/// round the end of a seam that stops inside the face), occurrences of one
1645/// seam that meet directly (across the join, at a pole or at a slit's
1646/// tip), a seam walked once in the ring, and a ring that does not close
1647/// at all, which [`check_face_chart`] reports, are not read. A joint
1648/// bridged in space rather than in the chart (at a pole, or within its
1649/// tolerance) places the next edge only to within its chart gap, so an
1650/// arrival is named only where it stands off the next side by more than
1651/// half a period beyond the gaps summed.
1652fn 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                    // Sides that do not stand apart across the run name no
1728                    // side the face lies against.
1729                    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                    // The far side read a period from the near one, towards
1737                    // it: a file may state it whole periods further.
1738                    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                // Which gap between neighbouring copies of the column a
1775                // point lies in, or `None` on the column itself.
1776                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
1863/// How finely [`check_seam_sides`] samples an edge between two sides of a
1864/// seam for where it meets the seam's column: a crossing shows as two
1865/// neighbouring samples in different gaps between the column's copies.
1866const SEAM_SIDE_SAMPLES: u32 = 16;
1867
1868/// The whole periods that bring the start `next` of an edge to where the
1869/// ring has reached, `last`, after the edge `edges.0`, and the chart gap
1870/// left at the joint: where the joint meets in space within its reach,
1871/// without a move or with one; `None` where it does not.
1872fn 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
1897/// How many times its joint's tolerance a gap in a face's chart may span
1898/// before the boundary is open there. A fitted trim's ends wander from its
1899/// vertex by about the tolerance its edge states, and never less than the
1900/// fitting accuracy, which is where the tolerance taken starts.
1901pub(crate) const CHART_GAP_SLACK: f64 = 10.0;
1902
1903/// An edge's ends in a face's chart as the face walks it (one pair for a
1904/// pcurve, one per side for a seam), and whether the edge holds another
1905/// plain pcurve on the surface at the same placement.
1906type ChartEnds = (Vec<(Point2, Point2)>, bool);
1907
1908/// Each edge's [`ChartEnds`]; `None` where an edge has no pcurve on the
1909/// surface.
1910fn 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
1967/// The length of the straight chart segment from `a` to `b`, lifted onto
1968/// the surface: nothing where the two meet at a pole, a period's worth of
1969/// surface where they stand on two columns of a seam, and what lies past
1970/// the end of a direction the surface stops in measured from that end.
1971/// `None` where the surface cannot be evaluated along it.
1972fn 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
1993/// The surface's point at `uv`; where the surface refuses it, a parameter
1994/// past the end of a direction that neither repeats nor closes taken back
1995/// to that end: the point of the surface nearest in its chart. A joint
1996/// between pcurve ends standing past a sphere's pole is then measured from
1997/// where the surface stops, instead of going unread. A surface that
1998/// evaluates past its stated domain (a plane, whose domain is a window
1999/// onto it) is measured where the parameters stand.
2000fn 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
2022/// A shell is closed when every edge is used an even number of times.
2023///
2024/// Reported as `Suspect` rather than `Broken` on its own: an open shell is a
2025/// perfectly good surface and plenty of operations want one. It becomes
2026/// `Broken` only when something asks it to bound a volume, which is a question
2027/// this function is not being asked.
2028fn 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
2069/// The edges between two faces of `shape` that both faces walk the same
2070/// way, in the order the walk first meets them.
2071///
2072/// Each face keeps its material on the left of its rings about its outward
2073/// normal, so an edge two faces share is walked once each way, senses
2074/// composed. That holds for any edge with two faces, closed shell or sheet;
2075/// an edge with one face or more than two says nothing. A degenerate edge
2076/// (a pole, an apex) bounds nothing and is not asked, and a seam, walked
2077/// both ways by its one face, passes.
2078pub(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
2111/// Restore tolerance containment below `shape`: every edge widened to at
2112/// least the faces it bounds, every vertex to at least the edges it bounds.
2113///
2114/// The rule [`check`] enforces, established the only way the data model
2115/// allows: by raising what is bounded, never lowering what bounds. Each
2116/// face and then each edge is widened to its own tolerance through
2117/// [`Model::widen`], which cascades to everything below it and leaves
2118/// anything already looser as it is. Returns how many entities grew.
2119///
2120/// An operation that widens an edge's tolerance by writing it directly (a
2121/// reader recording how far a pcurve sits from its curve) leaves the
2122/// edge's vertices behind; this is the pass that brings them along. What
2123/// `shape` shares with other shapes is copied first ([`Model::unshare`]),
2124/// so their tolerances stay as they are.
2125///
2126/// # Errors
2127///
2128/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the shape,
2129/// or anything below it, does not resolve in this model.
2130pub 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
2156/// Compare one shape's tolerance against everything below it.
2157fn 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    // Each occurrence once: a vertex reached through both its edges is one
2167    // containment claim, not two.
2168    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
2193/// Faces of one shape that reach each other without sharing topology:
2194/// self-intersection, detected as the interference it is.
2195///
2196/// Every unordered pair of distinct faces that share no edge and no vertex
2197/// node, and that hulls holding their geometry do not prove apart, is put
2198/// through the exact minimum-distance machinery; a pair within the
2199/// confusion tolerance of touching is reported. Adjacent faces meet at
2200/// their shared boundary by construction and are not interference; a valid
2201/// solid therefore reports nothing, and a sheet folded through itself names
2202/// the faces that cross.
2203///
2204/// # Errors
2205///
2206/// As [`crate::distance_between_shapes`].
2207pub 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
2215/// [`check_self_intersection`] over the pairs holding at least one of
2216/// `near`: after an edit that moved some faces, the pairs of faces it left
2217/// alone cannot have begun to cross.
2218///
2219/// # Errors
2220///
2221/// As [`check_self_intersection`].
2222pub 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    // The topology below each face, for the adjacency exclusion, its
2242    // elements and its box: each face gathered and bounded once, not once
2243    // per pair it is in.
2244    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    // Candidate pairs by sweeping the boxes along x: a pair whose x
2260    // intervals do not overlap cannot have boxes that meet. Sorted back
2261    // into (i, j) order, so the answer lists pairs as a scan of every pair
2262    // would.
2263    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        // Faces whose boxes meet are often proven apart by the hulls of
2289        // their pieces, at a fraction of the cost of measuring them.
2290        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/// How many edges between two faces of `shape` both faces walk the same
2314/// way: none where every face keeps its material on the left of its rings.
2315#[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        // The check earns its keep only if the things known to be right pass
2335        // it. A checker that flags a correct box is worse than none, because
2336        // every real finding then reads as noise.
2337        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        // An open shell is a perfectly good surface, and plenty of operations
2375        // want one. Calling it broken would make the checker useless for every
2376        // sheet body.
2377        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        // The containment rule runs the other way from intuition: the *bound*
2396        // is looser, and a vertex tighter than the edge that caps it means the
2397        // edge does not reliably reach it.
2398        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        // Widen the edge alone, bypassing the cascading repair that exists to
2405        // stop exactly this.
2406        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        // Four edges and four corners, each reached through two edges and
2431        // named once.
2432        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        // The failure that leaves a gap in every face built on the wire, and
2452        // which nothing notices until something walks the boundary.
2453        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        // Without a pcurve the face cannot be split in a boolean or
2478        // triangulated at all, and the failure surfaces far from its cause.
2479        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        // A pcurve standing off its edge's curve by more than the edge's
2507        // tolerance parts the face it bounds from the neighbour following
2508        // the curve, whether or not the edge claims the two share a
2509        // parameter. A box edge's pcurve on one face is moved a hundredth
2510        // sideways in that face's chart.
2511        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        // A box a thousandth tall: one of its upright edges, 0.001 long,
2573        // widened to 0.002 with its vertices, has its pcurve on one face
2574        // moved sideways in that face's chart, where a plane's chart
2575        // measures as space does. Moved 0.0015 the pcurve stands farther
2576        // off than the edge is long; moved 0.0005 it does not. Either way
2577        // the tolerance covers it, and nothing is broken.
2578        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        // An edge 5e-8 long on the x axis, with a pcurve on the plane z = 0
2639        // running beside it 8e-8 off: farther off than the edge is long,
2640        // and within its tolerance, but both below the confusion
2641        // tolerance, where a gap and a length are rounding.
2642        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        // A unit square at x, y in [5, 6] on a plane whose stated domain is
2682        // the window [-1, 1] squared: a plane evaluates anywhere, and one
2683        // edge's pcurve moved 0.3 along x leaves the boundary open by that
2684        // much at both its ends, however far outside the window it stands.
2685        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        // An edge on the x axis carrying a seam on the plane z = 0, its
2745        // two sides one unit apart in v: a plane closes in neither
2746        // direction, so the second side is another line of it. The same
2747        // edge with one side as a plain pcurve is sound.
2748        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        // A dangling handle means the shape and the model do not belong
2809        // together. Every finding would then be about something that is not
2810        // there, which is worse than no finding.
2811        //
2812        // A handle from a *different* model is caught too, and by the same
2813        // route: arena keys carry the identifier of the arena that issued them,
2814        // so a foreign one resolves to nothing rather than to whatever sits at
2815        // that index.
2816        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    /// The edges of `diagnosis` named as walked the same way by both their
2827    /// faces.
2828    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    /// A face of a box rebuilt with its ring walked back, its sense kept,
2842    /// walks each of its four edges the same way as the face beside it:
2843    /// `check` names those four edges, in the closed box and in a sheet of
2844    /// that face and one neighbour, where it names the one edge they share.
2845    #[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    /// A B-spline sheet over [-5, 5]^2 at height `lift + bend (x^2 - y^2) / 20`,
2899    /// bounded by its border iso-curves.
2900    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        // The same saddle a twentieth above: every box of the two meets,
2943        // and the finer boxes prove them apart.
2944        let above = spline_sheet(&mut model, 0.05, 1.0);
2945        // A flat sheet through the saddle's middle crosses it along both
2946        // diagonals.
2947        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        // Two overlapping boxes as the shells of one solid: the second
2973        // covers half of the first's top face, so that face reads facing
2974        // out over one half and in over the other, and its two triangles,
2975        // of one area, are probed on opposite halves. A rigid motion moves
2976        // the material with the face; it must not move which half is asked
2977        // by the rounding of the triangles' placed areas.
2978        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    /// A drum or a frustum with each rim circle restated on its frame
3013    /// mirrored (`y` reversed) or turned half a turn about `x`, so that it
3014    /// runs the other way round, its uses in the faces kept.
3015    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    /// Rims turned to run the other way round, their uses kept: the wall's
3051    /// ring, each seam occurrence read on the side the face lies against,
3052    /// runs from one side to the other a whole period the wrong way, which
3053    /// a boolean through the wall reads as a strand that dangles. The wall
3054    /// is named, and nothing else; the drum as built is valid.
3055    #[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    /// The same turned drum restated as splines: the wall's chart closes
3083    /// without repeating, so every pcurve stands where its domain puts it
3084    /// and the ring is read as stated, as the boolean reads it. It is not
3085    /// named.
3086    #[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    /// A rim stated a whole turn off the wall's window, still running the
3095    /// way the face needs, chains onto the seam's sides once moved by the
3096    /// turn, and is not named.
3097    #[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        // The regression net for every seam and every pole. A primitive whose
3157        // shell closes but whose mesh does not is the failure this exists to
3158        // name, and it is invisible to every other check.
3159        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        // A sweep of a box's downward face must triangulate as well as one of
3187        // its upward face: lateral walls wound the wrong way fail to
3188        // triangulate while the shell still closes and every other check
3189        // passes. Both directions are covered, so a regression cannot hide
3190        // behind the one that works.
3191        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        // Tessellation reads curves and pcurves, never vertex positions, so a
3226        // vertex moved off its edges is caught by `check` (the curve no longer
3227        // reaches it) and is invisible to `check_tessellation`. The two checks
3228        // see different things, which is why both exist.
3229        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        // A mesh with a boundary is agreement here, not disagreement. Flagging
3258        // it would make the check useless for every sheet body.
3259        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}