Skip to main content

ogeom_algo/
classify.rs

1//! Where a point sits relative to a shape.
2//!
3//! Three answers, never two: inside, outside, or *on* the boundary within
4//! tolerance. The third is not a hedge. Geometry that meets is the normal case
5//! in a kernel (a boolean's whole job is finding it), and a classifier that
6//! forces every point to one side has to pick, silently, for exactly the points
7//! where the choice matters most.
8//!
9//! # Accuracy
10//!
11//! [`classify_in_solid`] and [`classify_on_face`] work from the tessellation,
12//! so a point nearer the boundary than the deflection cannot be told from one
13//! on it. That is reported as [`Containment::On`] rather than guessed: the
14//! band the answer is uncertain within is the deflection, and saying so is the
15//! difference between an approximate answer and a wrong one.
16//!
17//! Tightening the deflection narrows the band. It never removes it: the
18//! exact question needs ray/surface intersection, which is
19//! [`classify_in_solid_exact`]: rays cast against the faces' true surfaces,
20//! where the uncertain band shrinks from the deflection to the tolerance.
21
22use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
23use ogeom_math::{Aabb, Direction, Point, Point2, Vector};
24use ogeom_mesh::{
25    Deflection, face_boundary_in_chart, inside_boundary, open_chart_ring, triangulate,
26};
27use ogeom_topo::{Model, NodeData, Shape, ShapeType};
28
29use crate::measure::project_on_surface;
30
31/// Where a point sits relative to a shape.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Containment {
34    /// Strictly inside.
35    In,
36    /// On the boundary, within tolerance of it.
37    On,
38    /// Strictly outside.
39    Out,
40}
41
42impl Containment {
43    /// Whether the point is inside or on the boundary.
44    #[must_use]
45    pub const fn is_inside_or_on(self) -> bool {
46        matches!(self, Self::In | Self::On)
47    }
48
49    /// The classification of the same point against the complement.
50    ///
51    /// `In` and `Out` swap; `On` is its own opposite, since a boundary is
52    /// shared by both sides.
53    #[must_use]
54    pub const fn inverted(self) -> Self {
55        match self {
56            Self::In => Self::Out,
57            Self::On => Self::On,
58            Self::Out => Self::In,
59        }
60    }
61}
62
63/// Where a point sits relative to a face.
64///
65/// A point off the face's surface is [`Containment::Out`]: a face is a patch of
66/// surface, so "inside" can only mean inside its trimming, and a point in space
67/// that does not lie on the surface at all is not inside anything.
68///
69/// # Errors
70///
71/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `face` is not a
72/// face, or the deflection settings are unusable;
73/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle fails to
74/// resolve.
75pub fn classify_on_face(
76    model: &Model,
77    face: &Shape,
78    point: Point,
79    deflection: Deflection,
80    tol: Tolerances,
81) -> OgeomResult<Containment> {
82    deflection.validate()?;
83    if model.kind_of(face)? != ShapeType::Face {
84        ogeom_bail!(Construction, "expected a face");
85    }
86    let Some(node) = model.node(face) else {
87        ogeom_bail!(Dangling, "face is not in this model");
88    };
89    let NodeData::Face(data) = node.data() else {
90        ogeom_bail!(Construction, "face node holds no face data");
91    };
92    let Some(surface) = model.geometry().surface(data.surface) else {
93        ogeom_bail!(Dangling, "face refers to a surface not in this model");
94    };
95
96    // Into the surface's own frame first: the trimming lives in parameter
97    // space, and the face may be placed anywhere.
98    let placement = face.transform(model.datums())?;
99    let local = placement.inverse()?.apply(point);
100
101    // A grid dense enough to bracket a foot point on a surface that folds:
102    // too coarse and Newton starts in the wrong basin and converges on a far
103    // side of a cylinder.
104    let projection = project_on_surface(surface, local, 32, tol)?;
105    let reach = tol.confusion().max(data.tolerance.get());
106    if projection.distance > reach {
107        return Ok(Containment::Out);
108    }
109
110    // A plane's holes are drawn only near the point: a face with hundreds
111    // of holes is asked about beside a few of them.
112    let rings = Rings::of(model, face, surface, deflection, tol)?;
113    let (u, v) = projection.parameters;
114    let at = rings.place(surface, Point2::new(u, v), tol);
115    // The uncertain band, converted from a distance in space into one in
116    // parameter units through the surface's own scale. A fixed parameter
117    // tolerance would be metres wide at a sphere's equator and nothing at its
118    // pole. The rings are polylines drawn at the caller's deflection, so the
119    // band carries that sag too: without it, a point between a tangent chord
120    // and its arc (inside the true trim, outside the sampled one) would
121    // read as Out when the honest answer at this resolution is On.
122    let band = parametric_band(surface, (u, v), reach + deflection.chord, tol);
123    if rings.within(model, face, deflection, at, band, tol)? {
124        return Ok(Containment::On);
125    }
126    Ok(if rings.inside(model, face, deflection, at, tol)? {
127        Containment::In
128    } else {
129        Containment::Out
130    })
131}
132
133/// A face's trim drawn once, for asking where many chart points of it fall,
134/// as [`classify_on_face`] places a point it has projected.
135pub struct FaceTrim<'m> {
136    face: &'m Shape,
137    surface: &'m ogeom_geom::SurfaceGeometry,
138    rings: Rings,
139    deflection: Deflection,
140}
141
142impl core::fmt::Debug for FaceTrim<'_> {
143    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
144        f.debug_struct("FaceTrim")
145            .field("face", self.face)
146            .field("deflection", &self.deflection)
147            .finish_non_exhaustive()
148    }
149}
150
151impl<'m> FaceTrim<'m> {
152    /// The trim of `face`, its rings drawn at `deflection`.
153    ///
154    /// # Errors
155    ///
156    /// As [`classify_on_face`].
157    pub fn of(
158        model: &'m Model,
159        face: &'m Shape,
160        deflection: Deflection,
161        tol: Tolerances,
162    ) -> OgeomResult<Self> {
163        deflection.validate()?;
164        let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
165            ogeom_bail!(Construction, "expected a face");
166        };
167        let Some(surface) = model.geometry().surface(data.surface) else {
168            ogeom_bail!(Dangling, "face refers to a surface not in this model");
169        };
170        let rings = Rings::of(model, face, surface, deflection, tol)?;
171        Ok(Self {
172            face,
173            surface,
174            rings,
175            deflection,
176        })
177    }
178
179    /// Where the chart point `at` falls: [`Containment::On`] within `reach`
180    /// in space of the trim, plus the sag of the rings' chords.
181    ///
182    /// # Errors
183    ///
184    /// As [`classify_on_face`].
185    pub fn holds(
186        &self,
187        model: &Model,
188        at: (f64, f64),
189        reach: f64,
190        tol: Tolerances,
191    ) -> OgeomResult<Containment> {
192        let placed = self.rings.place(self.surface, Point2::new(at.0, at.1), tol);
193        let band = parametric_band(self.surface, at, reach + self.deflection.chord, tol);
194        if self
195            .rings
196            .within(model, self.face, self.deflection, placed, band, tol)?
197        {
198            return Ok(Containment::On);
199        }
200        Ok(
201            if self
202                .rings
203                .inside(model, self.face, self.deflection, placed, tol)?
204            {
205                Containment::In
206            } else {
207                Containment::Out
208            },
209        )
210    }
211}
212
213/// Where a point sits relative to a closed shell or solid.
214///
215/// Ray casting against the tessellation: a ray from the point crosses the
216/// boundary an odd number of times if and only if it started inside. Where
217/// every ray meets an edge or a vertex, the point's winding number against
218/// the tessellation answers instead, where it is within a hundredth of zero
219/// or one.
220///
221/// # Errors
222///
223/// As [`triangulate()`], plus
224/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the boundary is
225/// not closed (an open shell has no inside), and
226/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if every ray tried hit an
227/// edge or a vertex, where the crossing count is ambiguous, and the winding
228/// number reads neither zero nor one.
229pub fn classify_in_solid(
230    model: &Model,
231    solid: &Shape,
232    point: Point,
233    deflection: Deflection,
234    tol: Tolerances,
235) -> OgeomResult<Containment> {
236    SolidMesh::of(model, solid, deflection, tol)?.holds(point, tol)
237}
238
239/// A solid's boundary meshed once, to classify many points against.
240///
241/// [`classify_in_solid`] triangulates the whole solid for every point it is
242/// asked about; a caller stepping a probe along a ray (a fillet feeling for
243/// where its ball runs out of material) asks dozens of times about one
244/// solid, and pays for dozens of meshes. This keeps the one.
245#[derive(Debug, Clone)]
246pub struct SolidMesh {
247    triangles: Vec<[Point; 3]>,
248    bound: ogeom_math::Aabb,
249    reach: f64,
250}
251
252impl SolidMesh {
253    /// Mesh `solid` at `deflection`.
254    ///
255    /// # Errors
256    ///
257    /// As [`classify_in_solid`].
258    pub fn of(
259        model: &Model,
260        solid: &Shape,
261        deflection: Deflection,
262        tol: Tolerances,
263    ) -> OgeomResult<Self> {
264        deflection.validate()?;
265        let mesh = triangulate(model, solid, deflection, tol)?;
266        if mesh.is_empty() || !mesh.is_closed() {
267            ogeom_bail!(
268                Construction,
269                "the boundary is not closed, so there is no inside to be in"
270            );
271        }
272        let triangles: Vec<[Point; 3]> = mesh
273            .triangles
274            .iter()
275            .map(|t| t.map(|i| mesh.positions[i as usize]))
276            .collect();
277        let bound = ogeom_math::Aabb::of_points(&mesh.positions);
278        Ok(Self {
279            triangles,
280            bound,
281            reach: tol.confusion() + deflection.chord,
282        })
283    }
284
285    /// Where `point` stands against the meshed boundary.
286    ///
287    /// # Errors
288    ///
289    /// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if every ray
290    /// tried hit an edge or a vertex, where the crossing count is ambiguous,
291    /// and the winding number reads neither zero nor one.
292    pub fn holds(&self, point: Point, tol: Tolerances) -> OgeomResult<Containment> {
293        // Outside the mesh's box by more than the boundary band, nothing is
294        // near enough to be on it and no ray can cross it.
295        if !self.bound.expanded(self.reach).contains(point) {
296            return Ok(Containment::Out);
297        }
298        // On the boundary beats either side, and is decided in space rather
299        // than along a ray: a point sitting on a face is on the boundary from
300        // every direction, and no crossing count says so.
301        for t in &self.triangles {
302            if distance_to_triangle(point, *t) <= self.reach {
303                return Ok(Containment::On);
304            }
305        }
306        // A ray that grazes an edge or passes through a vertex is counted
307        // once by one triangle and twice by its neighbour, or not at all.
308        // Rather than patch the count, notice the near-miss and cast again
309        // somewhere else.
310        for direction in RAY_DIRECTIONS {
311            let ray = Direction::new(Vector::new(direction[0], direction[1], direction[2]), tol)?;
312            if let Some(crossings) = count_crossings(&self.triangles, point, ray, tol) {
313                return Ok(if crossings % 2 == 1 {
314                    Containment::In
315                } else {
316                    Containment::Out
317                });
318            }
319        }
320        // No ray counts, but the point stands off every triangle, and its
321        // winding number against the closed mesh is an integer whatever the
322        // rays met.
323        if let Some(read) =
324            crate::winding::reading(crate::winding::winding_number(point, self.triangles.iter()))
325        {
326            return Ok(read);
327        }
328        ogeom_bail!(
329            NotDone,
330            "every ray tried met an edge or a vertex, where the crossing count is \
331             ambiguous, and the winding number is not near zero or one"
332        )
333    }
334}
335
336/// Where a point sits relative to a closed shell or solid, decided against
337/// the true surfaces.
338///
339/// Rays are cast against each face's actual geometry through the
340/// curve/surface intersector, so the band where the answer is *On* rather
341/// than a side is the tolerance, not the deflection. A
342/// point a micron off a sphere's wall classifies as the side it is on;
343/// [`classify_in_solid`] at any practical deflection could only say *On*.
344///
345/// The crossing-parity argument is the same as the tessellated one, and so is
346/// the discipline about degenerate hits. A ray that grazes a surface
347/// tangentially, meets a face too near its boundary to be sure which side of
348/// the trim it crossed, lies *in* a face's surface, or passes through a
349/// pole or an apex, is not patched into a count; the ray is abandoned and
350/// the next direction tried. Six directions, deterministic, none axis-aligned.
351/// Where all six are abandoned, the point's winding number against the
352/// faces' meshes answers, where the point stands clear of the band between
353/// each face and its mesh and the number is within a hundredth of zero or
354/// one.
355///
356/// # Errors
357///
358/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the shape is
359/// not a solid or a shell, or its boundary is not closed;
360/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if every ray met a
361/// degeneracy and the winding number could not be read: the point stands
362/// within a curved face's mesh band at the finest chord tried, or the
363/// boundary does not wind zero or one times about it.
364pub fn classify_in_solid_exact(
365    model: &Model,
366    solid: &Shape,
367    point: Point,
368    tol: Tolerances,
369) -> OgeomResult<Containment> {
370    classify_in_solid_exact_banded(model, solid, point, exact_ring_chord(tol), tol)
371}
372
373/// The ring chord [`classify_in_solid_exact`] prepares a boundary at.
374pub(crate) fn exact_ring_chord(tol: Tolerances) -> f64 {
375    tol.confusion() * 1e4
376}
377
378/// [`classify_in_solid_exact`] with the boundary band under the caller's
379/// control.
380///
381/// The band is the ring polylines' chord tolerance, and with it the width of
382/// the region that answers `On`. The default is generous (a boolean wants a
383/// piece near a boundary called On and resolved against its partner), but a
384/// caller that got On *without* a partner to resolve against needs to ask
385/// again at a width where proximity stops impersonating coincidence.
386///
387/// # Errors
388///
389/// As [`classify_in_solid_exact`].
390pub fn classify_in_solid_exact_banded(
391    model: &Model,
392    solid: &Shape,
393    point: Point,
394    ring_chord: f64,
395    tol: Tolerances,
396) -> OgeomResult<Containment> {
397    SolidBoundary::of(model, solid, ring_chord, tol)?.holds(model, point, tol)
398}
399
400/// One face of a prepared boundary, with everything about it that does not
401/// depend on the point being classified.
402#[derive(Debug)]
403struct PreparedFace {
404    /// The surface, and the placement's inverse for carrying a point into
405    /// the surface's frame, read the first time a point or a ray comes near
406    /// the face.
407    placed: std::sync::OnceLock<OgeomResult<(ogeom_geom::SurfaceGeometry, ogeom_math::Transform)>>,
408    /// The face itself, for drawing its rings when first asked.
409    face: Shape,
410    /// The trimming rings, polylined at the boundary's stated chord, drawn
411    /// the first time a point or a ray comes near the face: a boolean asks
412    /// about a few faces of a large solid, and drawing every face's rings
413    /// up front cost more than all its questions.
414    rings: std::sync::OnceLock<OgeomResult<Rings>>,
415    /// The rings drawn ten times finer, for a ray that meets the face
416    /// near its trim (see [`PreparedFace::fine_rings`]).
417    fine_rings: std::sync::OnceLock<OgeomResult<Rings>>,
418    /// The face's surface in the model's store, under which rays cast
419    /// against it are kept.
420    surface: ogeom_topo::SurfaceId,
421    /// Where the face can be, padded past anything its bound could miss: a
422    /// point outside is not on it, and a ray missing it does not cross it.
423    bound: Aabb,
424    /// The face's own box, that `bound` pads.
425    own: Aabb,
426    /// How far off the surface a point still lies on the face: the face's
427    /// own tolerance, never under the confusion distance.
428    reach: f64,
429    /// The widest tolerance among the face's edges and vertices, read the
430    /// first time a ray meets the face (see [`PreparedFace::rim`]).
431    rim: std::sync::OnceLock<OgeomResult<f64>>,
432}
433
434/// How many bands from a curved face's trim a ray's hit is read again
435/// against the rings drawn finer.
436const NEAR_TRIM: f64 = 50.0;
437
438/// A face's trimming rings at one chord.
439#[derive(Debug)]
440enum Rings {
441    /// Every ring, drawn together.
442    All(Vec<Vec<Point2>>),
443    /// A plane's outer ring, and its holes each drawn the first time a
444    /// point comes near it: a face with hundreds of holes is asked about
445    /// near a few.
446    Open {
447        outer: Vec<Point2>,
448        holes: Vec<Hole>,
449    },
450}
451
452/// A hole of a plane face, its ring drawn when first needed.
453#[derive(Debug)]
454struct Hole {
455    wire: Shape,
456    /// A chart box holding every point its ring can have.
457    low: Point2,
458    high: Point2,
459    ring: std::sync::OnceLock<OgeomResult<Option<Vec<Point2>>>>,
460}
461
462impl Hole {
463    fn ring(
464        &self,
465        model: &Model,
466        face: &Shape,
467        deflection: Deflection,
468        tol: Tolerances,
469    ) -> OgeomResult<Option<&[Point2]>> {
470        match self
471            .ring
472            .get_or_init(|| open_chart_ring(model, face, &self.wire, false, deflection, tol))
473        {
474            Ok(ring) => Ok(ring.as_deref()),
475            Err(e) => Err(e.clone()),
476        }
477    }
478
479    /// How far `p` stands from the box, nothing inside it.
480    fn box_distance(&self, p: Point2) -> f64 {
481        let dx = (self.low.x - p.x).max(p.x - self.high.x).max(0.0);
482        let dy = (self.low.y - p.y).max(p.y - self.high.y).max(0.0);
483        dx.hypot(dy)
484    }
485}
486
487impl Rings {
488    /// A face's rings as [`face_boundary_in_chart`] draws them: a plane's with
489    /// several wires one wire at a time, where each hole's box can be read
490    /// from its pcurves and is smaller than the outer ring, so the outer
491    /// ring is the largest, as drawing all of them would find.
492    fn of(
493        model: &Model,
494        face: &Shape,
495        surface: &ogeom_geom::SurfaceGeometry,
496        deflection: Deflection,
497        tol: Tolerances,
498    ) -> OgeomResult<Self> {
499        let all = || face_boundary_in_chart(model, face, deflection, tol).map(Rings::All);
500        let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
501            return all();
502        };
503        // Stored order, the outer wire first: the walk of a reversed face
504        // lists its holes first.
505        let wires = model.children_of(face)?;
506        if !matches!(surface, ogeom_geom::SurfaceGeometry::Plane(_)) || wires.len() < 2 {
507            return all();
508        }
509        let Some(outer) = open_chart_ring(model, face, &wires[0], true, deflection, tol)? else {
510            return all();
511        };
512        // About the ring's first point: the plane's origin may stand far
513        // off, where the sum is a difference of huge products.
514        let mut area = 0.0;
515        if let Some(&anchor) = outer.first() {
516            for i in 0..outer.len() {
517                area += (outer[i] - anchor).cross(outer[(i + 1) % outer.len()] - anchor);
518            }
519        }
520        let area = area.abs() * 0.5;
521        let mut holes = Vec::with_capacity(wires.len() - 1);
522        for wire in &wires[1..] {
523            let Some((low, high)) = chart_box(model, data.surface, wire, tol) else {
524                return all();
525            };
526            if (high.x - low.x) * (high.y - low.y) >= area {
527                return all();
528            }
529            holes.push(Hole {
530                wire: wire.clone(),
531                low,
532                high,
533                ring: std::sync::OnceLock::new(),
534            });
535        }
536        Ok(Rings::Open { outer, holes })
537    }
538
539    /// [`place_on_rings`]: on a plane the point stands where it is.
540    fn place(&self, surface: &ogeom_geom::SurfaceGeometry, at: Point2, tol: Tolerances) -> Point2 {
541        match self {
542            Rings::All(rings) => place_on_rings(surface, rings, at, tol),
543            Rings::Open { .. } => at,
544        }
545    }
546
547    /// Whether `at` lies within `band` of a ring.
548    fn within(
549        &self,
550        model: &Model,
551        face: &Shape,
552        deflection: Deflection,
553        at: Point2,
554        band: ChartBand,
555        tol: Tolerances,
556    ) -> OgeomResult<bool> {
557        match self {
558            Rings::All(rings) => Ok(band.meets_rings(rings, at)),
559            Rings::Open { outer, holes } => {
560                if band.meets_ring(outer, at) {
561                    return Ok(true);
562                }
563                for hole in holes {
564                    if hole.box_distance(at) <= band.radius()
565                        && let Some(ring) = hole.ring(model, face, deflection, tol)?
566                        && band.meets_ring(ring, at)
567                    {
568                        return Ok(true);
569                    }
570                }
571                Ok(false)
572            }
573        }
574    }
575
576    /// Whether `at` lies inside the rings, by the even-odd count.
577    fn inside(
578        &self,
579        model: &Model,
580        face: &Shape,
581        deflection: Deflection,
582        at: Point2,
583        tol: Tolerances,
584    ) -> OgeomResult<bool> {
585        match self {
586            Rings::All(rings) => Ok(inside_boundary(rings, at)),
587            Rings::Open { outer, holes } => {
588                let mut inside = inside_boundary(core::slice::from_ref(outer), at);
589                for hole in holes {
590                    if hole.box_distance(at) <= 0.0
591                        && let Some(ring) = hole.ring(model, face, deflection, tol)?
592                        && inside_boundary(&[ring.to_vec()], at)
593                    {
594                        inside = !inside;
595                    }
596                }
597                Ok(inside)
598            }
599        }
600    }
601}
602
603/// A chart box holding every point a wire's pcurves on `surface` reach,
604/// for lines and circles; `None` for a wire with another kind of pcurve.
605fn chart_box(
606    model: &Model,
607    surface: ogeom_topo::SurfaceId,
608    wire: &Shape,
609    tol: Tolerances,
610) -> Option<(Point2, Point2)> {
611    use ogeom_geom::Curve2d as _;
612    use ogeom_geom::PlanarCurve;
613    let mut low = Point2::new(f64::INFINITY, f64::INFINITY);
614    let mut high = Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
615    let mut take = |p: Point2, rx: f64, ry: f64| {
616        low = Point2::new(low.x.min(p.x - rx), low.y.min(p.y - ry));
617        high = Point2::new(high.x.max(p.x + rx), high.y.max(p.y + ry));
618    };
619    // How far a ring's ends may be moved onto the vertices they belong to:
620    // a vertex lies within its tolerance of its edge's end, and the edge
621    // within its own of its pcurve.
622    let mut loose = tol.confusion();
623    for edge in model.ordered_children_of(wire).ok()? {
624        let data = model.node(&edge)?.data().as_edge()?;
625        loose = loose.max(data.tolerance.get());
626        for vertex in model.children_of(&edge).ok()? {
627            if let Some(v) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
628                loose = loose.max(v.tolerance.get());
629            }
630        }
631        let ogeom_topo::EdgeRepr::PCurve { curve, range, .. } =
632            data.pcurve_for(surface, edge.location())?
633        else {
634            return None;
635        };
636        match model.geometry().pcurve(*curve)? {
637            line @ PlanarCurve::Line(_) => {
638                take(line.point_at(range.0, tol).ok()?, 0.0, 0.0);
639                take(line.point_at(range.1, tol).ok()?, 0.0, 0.0);
640            }
641            PlanarCurve::Circle(arc) => {
642                let circle = arc.circle();
643                let (x, y) = (circle.frame().x().vector(), circle.frame().y().vector());
644                let r = circle.radius();
645                take(
646                    circle.centre(),
647                    r * (x.x.abs() + y.x.abs()),
648                    r * (x.y.abs() + y.y.abs()),
649                );
650            }
651            _ => return None,
652        }
653    }
654    if !(low.x.is_finite() && low.y.is_finite() && high.x.is_finite() && high.y.is_finite()) {
655        return None;
656    }
657    // Those moves twice over, and rounding in the points drawn and in a
658    // line's points just past its ends.
659    let slack = loose.mul_add(
660        4.0,
661        1e-9 * (1.0
662            + low
663                .x
664                .abs()
665                .max(low.y.abs())
666                .max(high.x.abs())
667                .max(high.y.abs())),
668    );
669    Some((
670        Point2::new(low.x - slack, low.y - slack),
671        Point2::new(high.x + slack, high.y + slack),
672    ))
673}
674
675impl PreparedFace {
676    /// The face's surface and its placement's inverse, read once.
677    fn placed(
678        &self,
679        model: &Model,
680    ) -> OgeomResult<&(ogeom_geom::SurfaceGeometry, ogeom_math::Transform)> {
681        let read = || -> OgeomResult<(ogeom_geom::SurfaceGeometry, ogeom_math::Transform)> {
682            let Some(NodeData::Face(data)) = model.node(&self.face).map(|n| n.data()) else {
683                ogeom_bail!(Dangling, "face is not in this model");
684            };
685            let Some(surface) = model.geometry().surface(data.surface) else {
686                ogeom_bail!(Dangling, "face refers to a surface not in this model");
687            };
688            let inverse = self.face.transform(model.datums())?.inverse()?;
689            Ok((surface.clone(), inverse))
690        };
691        match self.placed.get_or_init(read) {
692            Ok(placed) => Ok(placed),
693            Err(e) => Err(e.clone()),
694        }
695    }
696
697    /// The face's trimming rings at `deflection`, drawn once.
698    fn rings(&self, model: &Model, deflection: Deflection, tol: Tolerances) -> OgeomResult<&Rings> {
699        let (surface, _) = self.placed(model)?;
700        match self
701            .rings
702            .get_or_init(|| Rings::of(model, &self.face, surface, deflection, tol))
703        {
704            Ok(rings) => Ok(rings),
705            Err(e) => Err(e.clone()),
706        }
707    }
708
709    /// The rings drawn at a tenth of `deflection`'s chord. A ring
710    /// is a polyline in the chart, straight between points its edges are
711    /// drawn at in space, and where the chart stretches (beside a sphere's
712    /// pole) a chord that keeps to its edge in space strays from it in the
713    /// chart by many times the band a hit near it is held to. The chord
714    /// holds the chart, and the angle is the coarse rings' own: a thread's
715    /// edges wind round every turn, and a tenth of the angle would draw
716    /// them in hundreds of thousands of points. Drawn when a ray first
717    /// meets the face near its trim.
718    fn fine_rings(
719        &self,
720        model: &Model,
721        deflection: Deflection,
722        tol: Tolerances,
723    ) -> OgeomResult<&Rings> {
724        let (surface, _) = self.placed(model)?;
725        let fine = Deflection {
726            chord: deflection.chord * 0.1,
727            ..deflection
728        };
729        match self
730            .fine_rings
731            .get_or_init(|| Rings::of(model, &self.face, surface, fine, tol))
732        {
733            Ok(rings) => Ok(rings),
734            Err(e) => Err(e.clone()),
735        }
736    }
737
738    /// The widest tolerance among the face's edges and vertices: how far
739    /// the face's trim may stand from where its neighbours' trims meet it.
740    /// Two faces sharing an edge lie within the edge's tolerance of it, not
741    /// on it, and between them the boundary overlaps itself or leaves a gap
742    /// that wide: a ray crossing either face that near its trim may cross
743    /// both or neither.
744    fn rim(&self, model: &Model) -> OgeomResult<f64> {
745        let read = || -> OgeomResult<f64> {
746            let mut widest = 0.0_f64;
747            for kind in [ShapeType::Edge, ShapeType::Vertex] {
748                for shape in ogeom_topo::explore_unique(model, &self.face, kind)? {
749                    widest = widest.max(match model.node(&shape).map(|n| n.data()) {
750                        Some(NodeData::Edge(data)) => data.tolerance.get(),
751                        Some(NodeData::Vertex(data)) => data.tolerance.get(),
752                        _ => 0.0,
753                    });
754                }
755            }
756            Ok(widest)
757        };
758        match self.rim.get_or_init(read) {
759            Ok(rim) => Ok(*rim),
760            Err(e) => Err(e.clone()),
761        }
762    }
763
764    /// Where a point sits against this face: [`classify_on_face`] on what
765    /// was prepared, with no surface lookup, placement or ring walk per
766    /// question.
767    fn holds(
768        &self,
769        model: &Model,
770        point: Point,
771        deflection: Deflection,
772        tol: Tolerances,
773    ) -> OgeomResult<Containment> {
774        let (surface, inverse) = self.placed(model)?;
775        let local = inverse.apply(point);
776        let projection = project_on_surface(surface, local, 32, tol)?;
777        if projection.distance > self.reach {
778            return Ok(Containment::Out);
779        }
780        // Against the rings drawn so far.
781        let rings = self.rings(model, deflection, tol)?;
782        let (u, v) = projection.parameters;
783        let at = rings.place(surface, Point2::new(u, v), tol);
784        let band = parametric_band(surface, (u, v), self.reach + deflection.chord, tol);
785        if rings.within(model, &self.face, deflection, at, band, tol)? {
786            return Ok(Containment::On);
787        }
788        Ok(if rings.inside(model, &self.face, deflection, at, tol)? {
789            Containment::In
790        } else {
791            Containment::Out
792        })
793    }
794}
795
796/// A solid's boundary, prepared once and asked about many points.
797///
798/// Classifying a point casts rays and counts crossings, which is cheap. What
799/// is not cheap is what the rays are cast *against*: every face's trimming
800/// rings, polylined, plus its placement's inverse. That work depends on the
801/// solid and the chord, never on the point, and a boolean asks once per face
802/// piece. Preparing costs hundreds of times the ray casting it serves, so it
803/// is done once.
804#[derive(Debug)]
805pub struct SolidBoundary {
806    /// The faces prepared, which a boundary kept in the model shares with
807    /// every boundary of the same solid read back from it.
808    prepared: std::sync::Arc<Prepared>,
809    /// Per face, the kept surface rays are cast against, looked up the
810    /// first time a ray comes near the face; `None` inside for a surface a
811    /// line meets in closed form.
812    rays: Vec<std::sync::OnceLock<Option<std::sync::Arc<KeptSurface>>>>,
813    /// What the rays cast against spline faces found, shared with whoever
814    /// handed the cache over.
815    kept: ProbeCache,
816}
817
818/// A solid's faces prepared, with what is drawn of them as points and
819/// rays come near.
820///
821/// Everything prepared of a face depends only on the nodes below it, the
822/// chord and the tolerances, so a solid prepared beside a kept one holding
823/// the same face shares it, with what has been drawn of it.
824#[derive(Debug)]
825struct Prepared {
826    faces: Vec<std::sync::Arc<PreparedFace>>,
827    /// The tolerances the boundary was prepared at, which its kept rays
828    /// are cast at.
829    tol: Tolerances,
830    bound: ogeom_math::Aabb,
831    centre: Point,
832    diagonal: f64,
833    ring_chord: f64,
834    /// Whether the solid's shells were found closed here.
835    checked: bool,
836    /// The solid, meshed for winding numbers when a point no ray reads
837    /// first asks.
838    solid: Shape,
839    /// Its faces meshed at [`Prepared::winding_chord`], or `None` where
840    /// they would not mesh.
841    winding: std::sync::OnceLock<Option<std::sync::Arc<crate::winding::WindingMesh>>>,
842    /// Each face's place among `faces`, found when first asked.
843    places: std::sync::OnceLock<ogeom_core::FastMap<ogeom_topo::SameKey, u32>>,
844    /// The faces' bounds in a tree, built when first asked.
845    boxes: std::sync::OnceLock<FaceBoxes>,
846}
847
848impl Prepared {
849    /// The faces' bounds in a tree: see [`FaceBoxes`].
850    fn boxes(&self) -> &FaceBoxes {
851        self.boxes.get_or_init(|| {
852            let bounds: Vec<Aabb> = self.faces.iter().map(|f| f.bound).collect();
853            FaceBoxes::of(&bounds)
854        })
855    }
856
857    /// The face prepared among these, as the solid holds it.
858    fn face_of(&self, face: &Shape) -> Option<&std::sync::Arc<PreparedFace>> {
859        let places = self.places.get_or_init(|| {
860            self.faces
861                .iter()
862                .enumerate()
863                .map(|(i, f)| {
864                    let at = u32::try_from(i).unwrap_or(u32::MAX);
865                    (ogeom_topo::SameKey(f.face.clone()), at)
866                })
867                .collect()
868        });
869        let at = *places.get(&ogeom_topo::SameKey(face.clone()))?;
870        let prepared = self.faces.get(at as usize)?;
871        (prepared.face == *face).then_some(prepared)
872    }
873
874    /// Whether two preparations agree in everything prepared up front.
875    fn same(&self, other: &Self) -> bool {
876        self.faces.len() == other.faces.len()
877            && self.faces.iter().zip(&other.faces).all(|(a, b)| {
878                a.face == b.face
879                    && a.surface == b.surface
880                    && a.bound == b.bound
881                    && a.own == b.own
882                    && a.reach.to_bits() == b.reach.to_bits()
883            })
884            && self.tol == other.tol
885            && self.bound == other.bound
886            && self.centre == other.centre
887            && self.diagonal.to_bits() == other.diagonal.to_bits()
888            && self.ring_chord.to_bits() == other.ring_chord.to_bits()
889            && self.checked == other.checked
890            && self.solid == other.solid
891    }
892}
893
894/// The fewest faces a solid has for its prepared boundary to be kept in
895/// the model: below that, preparing it again costs about what keeping it
896/// does.
897const KEPT_FACES: usize = 64;
898
899impl SolidBoundary {
900    /// Prepare a solid's boundary for classification at a given ring chord.
901    ///
902    /// # Errors
903    ///
904    /// As [`classify_in_solid_exact`].
905    pub fn of(model: &Model, solid: &Shape, ring_chord: f64, tol: Tolerances) -> OgeomResult<Self> {
906        Self::prepare(model, solid, ring_chord, true, tol)
907    }
908
909    /// [`SolidBoundary::of`] for a solid whose shells the caller has
910    /// already found closed: their closure is not asked again, and a shell
911    /// that is open after all classifies as its faces fall.
912    ///
913    /// # Errors
914    ///
915    /// As [`SolidBoundary::of`], but for an open shell.
916    pub fn of_closed(
917        model: &Model,
918        solid: &Shape,
919        ring_chord: f64,
920        tol: Tolerances,
921    ) -> OgeomResult<Self> {
922        Self::prepare(model, solid, ring_chord, false, tol)
923    }
924
925    /// [`SolidBoundary::of`], or [`SolidBoundary::of_closed`] where
926    /// `closed` says the caller has found the shells closed, kept in the
927    /// model for a solid of many faces: a later boundary of the solid
928    /// unchanged, at the same chord and tolerances, shares the faces
929    /// prepared and the rings drawn of them. A solid prepared for the first
930    /// time shares each face it holds as a solid kept so holds it (a
931    /// boolean's result, the faces it passed through from its operand).
932    ///
933    /// The rings are drawn at the tolerances a point is asked about at,
934    /// the first time one comes near: the boundary is to be asked at
935    /// `tol`.
936    ///
937    /// # Errors
938    ///
939    /// As [`SolidBoundary::of`], or [`SolidBoundary::of_closed`] where
940    /// `closed`.
941    pub fn of_kept(
942        model: &Model,
943        solid: &Shape,
944        ring_chord: f64,
945        closed: bool,
946        tol: Tolerances,
947    ) -> OgeomResult<Self> {
948        let check_closed = !closed;
949        let prepared = model.kept_read(
950            solid,
951            |p: &Prepared| {
952                p.ring_chord.to_bits() == ring_chord.to_bits()
953                    && p.tol == tol
954                    && (p.checked || !check_closed)
955            },
956            || {
957                // The faces of a solid prepared and kept at this chord and
958                // these tolerances, however its shells were checked.
959                let kept = model.kept_reads(|p: &Prepared| {
960                    p.ring_chord.to_bits() == ring_chord.to_bits() && p.tol == tol
961                });
962                let (prepared, boxed, taken) =
963                    Self::prepared(model, solid, ring_chord, check_closed, tol, &kept)?;
964                if taken > 0 {
965                    crate::derived::note_derived(taken);
966                    if crate::derived::checks_derived_reads() {
967                        let (fresh, fresh_boxed, _) =
968                            Self::prepared(model, solid, ring_chord, check_closed, tol, &[])?;
969                        assert!(
970                            boxed == fresh_boxed && prepared.same(&fresh),
971                            "a boundary prepared from kept faces differs from its fresh one"
972                        );
973                    }
974                }
975                let keep = boxed && prepared.faces.len() >= KEPT_FACES;
976                Ok((prepared, keep))
977            },
978        )?;
979        Ok(Self::sharing(prepared))
980    }
981
982    /// A boundary over faces prepared, with no ray cast yet.
983    fn sharing(prepared: std::sync::Arc<Prepared>) -> Self {
984        let rays = (0..prepared.faces.len())
985            .map(|_| std::sync::OnceLock::new())
986            .collect();
987        Self {
988            prepared,
989            rays,
990            kept: ProbeCache::default(),
991        }
992    }
993
994    fn prepare(
995        model: &Model,
996        solid: &Shape,
997        ring_chord: f64,
998        check_closed: bool,
999        tol: Tolerances,
1000    ) -> OgeomResult<Self> {
1001        let (prepared, _, _) = Self::prepared(model, solid, ring_chord, check_closed, tol, &[])?;
1002        Ok(Self::sharing(std::sync::Arc::new(prepared)))
1003    }
1004
1005    /// The faces of `solid` prepared; whether every face's box was the one
1006    /// the model keeps for it; and how many faces were taken from `kept`.
1007    ///
1008    /// `kept` are faces prepared at the same chord and tolerances, kept
1009    /// for solids whose nodes stand as they were read: a face one of them
1010    /// holds as this solid does is shared with it, as it would be prepared
1011    /// again.
1012    fn prepared(
1013        model: &Model,
1014        solid: &Shape,
1015        ring_chord: f64,
1016        check_closed: bool,
1017        tol: Tolerances,
1018        kept: &[std::sync::Arc<Prepared>],
1019    ) -> OgeomResult<(Prepared, bool, usize)> {
1020        let kind = model.kind_of(solid)?;
1021        // A compound of solids bounds their union: each lump's shells are
1022        // boundary, and a ray counts the crossings of all of them.
1023        let lumps = kind == ShapeType::Compound
1024            && model
1025                .children_of(solid)?
1026                .iter()
1027                .all(|part| model.kind_of(part).is_ok_and(|k| k == ShapeType::Solid));
1028        if !matches!(kind, ShapeType::Solid | ShapeType::Shell) && !lumps {
1029            ogeom_bail!(Construction, "expected a solid or a shell, got {kind:?}");
1030        }
1031        let shells = if kind == ShapeType::Shell {
1032            vec![solid.clone()]
1033        } else {
1034            ogeom_topo::explore_unique(model, solid, ShapeType::Shell)?
1035        };
1036        if shells.is_empty() {
1037            ogeom_bail!(Construction, "the shape has no shell, so no boundary");
1038        }
1039        for shell in shells.iter().filter(|_| check_closed) {
1040            if !crate::build::is_shell_closed(model, shell)? {
1041                ogeom_bail!(
1042                    Construction,
1043                    "the boundary is not closed, so there is no inside to be in"
1044                );
1045            }
1046        }
1047
1048        let faces = ogeom_topo::explore_unique(model, solid, ShapeType::Face)?;
1049        // One prepared face per face, in face order: its box, and what a
1050        // point or a ray coming near it reads of it then.
1051        let prepare = |face: &Shape| -> OgeomResult<std::sync::Arc<PreparedFace>> {
1052            ogeom_core::progress::checkpoint()?;
1053            let Some(node) = model.node(face) else {
1054                ogeom_bail!(Dangling, "face is not in this model");
1055            };
1056            let NodeData::Face(data) = node.data() else {
1057                ogeom_bail!(Construction, "face node holds no face data");
1058            };
1059            let Some(surface) = model.geometry().surface(data.surface) else {
1060                ogeom_bail!(Dangling, "face refers to a surface not in this model");
1061            };
1062            // The box the model keeps for the face where it keeps one: found
1063            // on a mesh for a surface without a closed form, it may fall
1064            // short by the mesh's chord.
1065            let own = match model.face_bounds(face) {
1066                Some(kept) if closed_form(surface) => kept,
1067                Some(kept) => kept.expanded(Deflection::default().chord),
1068                None => crate::measure::shape_bounds(model, face, tol)?,
1069            };
1070            let bound = own.expanded(
1071                ring_chord + data.tolerance.get() + tol.confusion() * 1e2 + own.diagonal() * 0.02,
1072            );
1073            Ok(std::sync::Arc::new(PreparedFace {
1074                placed: std::sync::OnceLock::new(),
1075                face: face.clone(),
1076                rings: std::sync::OnceLock::new(),
1077                fine_rings: std::sync::OnceLock::new(),
1078                surface: data.surface,
1079                bound,
1080                own,
1081                reach: tol.confusion().max(data.tolerance.get()),
1082                rim: std::sync::OnceLock::new(),
1083            }))
1084        };
1085        // Where every face keeps its box, preparing is a read per face. A
1086        // face without one is bounded from its geometry, which is the cost
1087        // worth spreading over threads.
1088        let boxed = faces.iter().all(|face| model.face_bounds(face).is_some());
1089        let shared: Vec<Option<std::sync::Arc<PreparedFace>>> = faces
1090            .iter()
1091            .map(|face| kept.iter().find_map(|k| k.face_of(face)).cloned())
1092            .collect();
1093        let taken = shared.iter().flatten().count();
1094        let each = |face: &Shape, shared: &Option<std::sync::Arc<PreparedFace>>| match shared {
1095            Some(shared) => Ok(std::sync::Arc::clone(shared)),
1096            None => prepare(face),
1097        };
1098        let prepared = if boxed {
1099            faces
1100                .iter()
1101                .zip(&shared)
1102                .map(|(face, shared)| each(face, shared))
1103                .collect::<OgeomResult<Vec<_>>>()?
1104        } else {
1105            ogeom_core::parallel::map_ordered(&faces, |i, face| each(face, &shared[i]))
1106                .into_iter()
1107                .collect::<OgeomResult<Vec<_>>>()?
1108        };
1109        // Anything outside the shape's bound is outside the shape, and the bound
1110        // also sets how long a ray must be to have left everything behind. A
1111        // solid holds shells and a shell faces, so the faces' bounds together
1112        // are the shape's.
1113        let bound = prepared
1114            .iter()
1115            .fold(Aabb::EMPTY, |bound, face| bound.union(&face.own));
1116        let (Some(centre), diagonal) = (bound.centre(), bound.diagonal()) else {
1117            ogeom_bail!(Construction, "the boundary bounds nothing");
1118        };
1119        Ok((
1120            Prepared {
1121                faces: prepared,
1122                tol,
1123                bound,
1124                centre,
1125                diagonal,
1126                ring_chord,
1127                checked: check_closed,
1128                solid: solid.clone(),
1129                winding: std::sync::OnceLock::new(),
1130                places: std::sync::OnceLock::new(),
1131                boxes: std::sync::OnceLock::new(),
1132            },
1133            boxed,
1134            taken,
1135        ))
1136    }
1137
1138    /// The cache this boundary casts its rays through.
1139    pub(crate) const fn kept(&self) -> &ProbeCache {
1140        &self.kept
1141    }
1142
1143    /// This boundary, casting its rays through `kept`.
1144    pub(crate) fn keeping(mut self, kept: &ProbeCache) -> Self {
1145        self.kept = kept.clone();
1146        self
1147    }
1148
1149    /// Where a point stands against this boundary.
1150    ///
1151    /// # Errors
1152    ///
1153    /// As [`classify_in_solid_exact`].
1154    pub fn holds(&self, model: &Model, point: Point, tol: Tolerances) -> OgeomResult<Containment> {
1155        let Prepared {
1156            faces,
1157            bound,
1158            ring_chord,
1159            ..
1160        } = &*self.prepared;
1161        let ring_deflection = Deflection {
1162            chord: *ring_chord,
1163            angular: 0.05,
1164            ..Deflection::default()
1165        };
1166        if !bound.expanded(tol.confusion()).contains(point) {
1167            return Ok(Containment::Out);
1168        }
1169
1170        // On the boundary beats either side, and each face answers exactly:
1171        // projection distance against the true surface, trimming in parameter
1172        // space.
1173        for k in self.prepared.boxes().holding(point) {
1174            let prepared = &faces[k];
1175            if !prepared.bound.contains(point) {
1176                continue;
1177            }
1178            if prepared.holds(model, point, ring_deflection, tol)? != Containment::Out {
1179                return Ok(Containment::On);
1180            }
1181        }
1182        if let Some(read) = self.cast(model, point, tol)? {
1183            return Ok(read);
1184        }
1185        if let Some(read) = self.reached(model, point, tol)? {
1186            return Ok(read);
1187        }
1188        if let Some(read) = self.wound(model, point, tol) {
1189            return Ok(read);
1190        }
1191        ogeom_bail!(
1192            NotDone,
1193            "every ray tried met a tangency, a boundary, or a degenerate point, \
1194             where the crossing count is ambiguous, and the winding number is \
1195             not near zero or one"
1196        )
1197    }
1198
1199    /// Whether the segment from `from` to `to` meets no face of the boundary
1200    /// but `except`: it crosses none inside its trim, comes within the
1201    /// rings' band of none's boundary, and lies in none's surface.
1202    ///
1203    /// A probe stepping off a face reads that face's two sides only along
1204    /// such a segment; one that meets another face on the way reads
1205    /// whatever that face bounds.
1206    ///
1207    /// # Errors
1208    ///
1209    /// As [`classify_in_solid_exact`].
1210    pub(crate) fn clear_between(
1211        &self,
1212        model: &Model,
1213        from: Point,
1214        to: Point,
1215        except: &Shape,
1216        tol: Tolerances,
1217    ) -> OgeomResult<bool> {
1218        self.clear_of(model, (from, to), std::slice::from_ref(except), false, tol)
1219    }
1220
1221    /// Whether the segment from `from` to `to` meets no face of the boundary
1222    /// past the faces `from` lies on, `on`: it crosses none inside its trim
1223    /// but those at its start, comes within the rings' band of none's
1224    /// boundary, and lies in none's surface.
1225    ///
1226    /// A step off faces lying together reads their two sides only along
1227    /// such a segment. A face of `on` met again further along (the far
1228    /// wall of a drum stepped across) is met.
1229    ///
1230    /// # Errors
1231    ///
1232    /// As [`classify_in_solid_exact`].
1233    pub fn clear_from(
1234        &self,
1235        model: &Model,
1236        from: Point,
1237        to: Point,
1238        on: &[Shape],
1239        tol: Tolerances,
1240    ) -> OgeomResult<bool> {
1241        self.clear_of(model, (from, to), on, true, tol)
1242    }
1243
1244    /// [`SolidBoundary::clear_between`] past `except`: wholly, or only at
1245    /// the segment's start where `at_start`.
1246    fn clear_of(
1247        &self,
1248        model: &Model,
1249        (from, to): (Point, Point),
1250        except: &[Shape],
1251        at_start: bool,
1252        tol: Tolerances,
1253    ) -> OgeomResult<bool> {
1254        let Prepared {
1255            faces, ring_chord, ..
1256        } = &*self.prepared;
1257        let ring_chord = *ring_chord;
1258        let ring_deflection = Deflection {
1259            chord: ring_chord,
1260            angular: 0.05,
1261            ..Deflection::default()
1262        };
1263        let reach = tol.confusion();
1264        for k in self.prepared.boxes().meeting(from, to) {
1265            let (prepared, rays) = (&faces[k], &self.rays[k]);
1266            let excepted = except.iter().any(|face| prepared.face.is_same(face));
1267            if (excepted && !at_start) || !segment_meets(&prepared.bound, from, to) {
1268                continue;
1269            }
1270            let (surface, inverse) = prepared.placed(model)?;
1271            let (start, end) = (inverse.apply(from), inverse.apply(to));
1272            // The segment's parameter runs with distance in the face's
1273            // frame, which a scaling placement stretches.
1274            let scale = (end - start).magnitude() / (to - from).magnitude();
1275            let found = match rays.get_or_init(|| {
1276                self.kept
1277                    .surface(prepared.surface, surface, self.prepared.tol)
1278            }) {
1279                Some(kept) if kept.tol == tol => kept.cross((start, end))?,
1280                _ => {
1281                    let ray: ogeom_geom::Curve =
1282                        ogeom_geom::LineCurve::segment(start, end, tol)?.into();
1283                    ogeom_intersect::intersect_curve_surface(
1284                        &ray,
1285                        surface,
1286                        ogeom_intersect::CurveSurfaceOptions::default(),
1287                        tol,
1288                    )?
1289                }
1290            };
1291            if !found.lying.is_empty() {
1292                return Ok(false);
1293            }
1294            for hit in &found.crossings {
1295                if excepted && hit.on_curve <= (reach + ring_chord) * scale {
1296                    continue;
1297                }
1298                let (u, v) = hit.on_surface;
1299                let rings = prepared.rings(model, ring_deflection, tol)?;
1300                let at = rings.place(surface, Point2::new(u, v), tol);
1301                let band = parametric_band(surface, (u, v), reach + ring_chord, tol);
1302                let face = &prepared.face;
1303                if rings.within(model, face, ring_deflection, at, band, tol)?
1304                    || rings.inside(model, face, ring_deflection, at, tol)?
1305                {
1306                    return Ok(false);
1307                }
1308            }
1309        }
1310        Ok(true)
1311    }
1312
1313    /// The distance from `from` along the unit `along` to the first face
1314    /// the ray meets within `max`, past the faces `from` lies on, `on`, at
1315    /// its start; `None` where it meets none.
1316    ///
1317    /// A face is met where the ray crosses its surface inside its trim, or
1318    /// within the rings' band of its boundary, at an angle to the surface
1319    /// whose sine is larger than `sin`: a crossing at no larger an angle,
1320    /// and a surface the ray runs in, are passed over. A face of `on` met
1321    /// again further along is met.
1322    ///
1323    /// # Errors
1324    ///
1325    /// As [`classify_in_solid_exact`].
1326    pub(crate) fn first_crossing(
1327        &self,
1328        model: &Model,
1329        (from, along): (Point, Vector),
1330        max: f64,
1331        on: &[Shape],
1332        sin: f64,
1333        tol: Tolerances,
1334    ) -> OgeomResult<Option<f64>> {
1335        let Prepared {
1336            faces,
1337            centre,
1338            diagonal,
1339            ring_chord,
1340            ..
1341        } = &*self.prepared;
1342        let ring_chord = *ring_chord;
1343        let ring_deflection = Deflection {
1344            chord: ring_chord,
1345            angular: 0.05,
1346            ..Deflection::default()
1347        };
1348        let reach = tol.confusion();
1349        // Past the far side of the boundary's box, nothing is met.
1350        let length = max.min(from.distance(*centre) + diagonal + 1.0);
1351        if length.is_nan() || length <= 0.0 {
1352            return Ok(None);
1353        }
1354        let to = from + along * length;
1355        let mut first: Option<f64> = None;
1356        for k in self.prepared.boxes().meeting(from, to) {
1357            let (prepared, rays) = (&faces[k], &self.rays[k]);
1358            if !segment_meets(&prepared.bound, from, to) {
1359                continue;
1360            }
1361            let at_start = on.iter().any(|face| prepared.face.is_same(face));
1362            let (surface, inverse) = prepared.placed(model)?;
1363            let (start, end) = (inverse.apply(from), inverse.apply(to));
1364            // The segment's parameter runs with distance in the face's
1365            // frame, which a scaling placement stretches.
1366            let scale = (end - start).magnitude() / length;
1367            let direction = (end - start) / (end - start).magnitude();
1368            let found = match rays.get_or_init(|| {
1369                self.kept
1370                    .surface(prepared.surface, surface, self.prepared.tol)
1371            }) {
1372                Some(kept) if kept.tol == tol => kept.cross((start, end))?,
1373                _ => {
1374                    let ray: ogeom_geom::Curve =
1375                        ogeom_geom::LineCurve::segment(start, end, tol)?.into();
1376                    ogeom_intersect::intersect_curve_surface(
1377                        &ray,
1378                        surface,
1379                        ogeom_intersect::CurveSurfaceOptions::default(),
1380                        tol,
1381                    )?
1382                }
1383            };
1384            for hit in &found.crossings {
1385                let distance = hit.on_curve / scale;
1386                if (at_start && hit.on_curve <= (reach + ring_chord) * scale)
1387                    || first.is_some_and(|held| distance >= held)
1388                {
1389                    continue;
1390                }
1391                let (u, v) = hit.on_surface;
1392                use ogeom_geom::Surface as _;
1393                if let Ok((du, dv)) = surface.d1_at(u, v, tol) {
1394                    let normal = du.cross(dv);
1395                    let size = normal.magnitude();
1396                    // A pole or an apex has no normal to be edge-on to.
1397                    if size > tol.confusion() && normal.dot(direction).abs() <= sin * size {
1398                        continue;
1399                    }
1400                }
1401                let rings = prepared.rings(model, ring_deflection, tol)?;
1402                let at = rings.place(surface, Point2::new(u, v), tol);
1403                let band = parametric_band(surface, (u, v), reach + ring_chord, tol);
1404                let face = &prepared.face;
1405                if rings.within(model, face, ring_deflection, at, band, tol)?
1406                    || rings.inside(model, face, ring_deflection, at, tol)?
1407                {
1408                    first = Some(distance);
1409                }
1410            }
1411        }
1412        Ok(first)
1413    }
1414
1415    /// Where `point`, off every face, stands by the parity of a ray's
1416    /// crossings, or `None` where every ray met a tangency, a face's
1417    /// boundary or a degenerate point.
1418    fn cast(
1419        &self,
1420        model: &Model,
1421        point: Point,
1422        tol: Tolerances,
1423    ) -> OgeomResult<Option<Containment>> {
1424        Ok(self.crossed(model, point, tol)?.map(|(crossings, _)| {
1425            if crossings % 2 == 1 {
1426                Containment::In
1427            } else {
1428                Containment::Out
1429            }
1430        }))
1431    }
1432
1433    /// The winding number of the boundary about `point`: how many times
1434    /// more a ray from it leaves through a face, along the face's outward
1435    /// normal, than it enters through one. One inside a solid and zero
1436    /// outside; past one where a boundary passing through itself encloses
1437    /// a region twice. `None` where `point` lies on a face, or where every
1438    /// ray met a tangency, a face's boundary or a degenerate point.
1439    ///
1440    /// # Errors
1441    ///
1442    /// As [`classify_in_solid_exact`].
1443    pub fn winding(
1444        &self,
1445        model: &Model,
1446        point: Point,
1447        tol: Tolerances,
1448    ) -> OgeomResult<Option<i64>> {
1449        if !self.faces_at(model, point, tol)?.is_empty() {
1450            return Ok(None);
1451        }
1452        Ok(self.crossed(model, point, tol)?.map(|(_, winding)| winding))
1453    }
1454
1455    /// The faces `point` lies on, within each one's own reach.
1456    ///
1457    /// # Errors
1458    ///
1459    /// As [`classify_in_solid_exact`].
1460    pub fn faces_at(
1461        &self,
1462        model: &Model,
1463        point: Point,
1464        tol: Tolerances,
1465    ) -> OgeomResult<Vec<Shape>> {
1466        Ok(self
1467            .faces_holding(model, point, tol)?
1468            .into_iter()
1469            .map(|(face, _)| face)
1470            .collect())
1471    }
1472
1473    /// [`SolidBoundary::faces_at`], with how each face holds `point`:
1474    /// [`Containment::On`] where it lies within the band of the face's
1475    /// trim, [`Containment::In`] where it lies inside the trim clear of it.
1476    pub(crate) fn faces_holding(
1477        &self,
1478        model: &Model,
1479        point: Point,
1480        tol: Tolerances,
1481    ) -> OgeomResult<Vec<(Shape, Containment)>> {
1482        let Prepared {
1483            faces, ring_chord, ..
1484        } = &*self.prepared;
1485        let ring_deflection = Deflection {
1486            chord: *ring_chord,
1487            angular: 0.05,
1488            ..Deflection::default()
1489        };
1490        let mut on = Vec::new();
1491        for k in self.prepared.boxes().holding(point) {
1492            let prepared = &faces[k];
1493            if !prepared.bound.contains(point) {
1494                continue;
1495            }
1496            let held = prepared.holds(model, point, ring_deflection, tol)?;
1497            if held != Containment::Out {
1498                on.push((prepared.face.clone(), held));
1499            }
1500        }
1501        Ok(on)
1502    }
1503
1504    /// The first ray from `point` that crosses every face it meets
1505    /// transversally and clear of its trim: how many faces it crosses, and
1506    /// how many more of those it leaves through along their outward normal
1507    /// than it enters through. `None` where every ray met a tangency, a
1508    /// face's boundary or a degenerate point.
1509    fn crossed(
1510        &self,
1511        model: &Model,
1512        point: Point,
1513        tol: Tolerances,
1514    ) -> OgeomResult<Option<(usize, i64)>> {
1515        let Prepared {
1516            faces,
1517            centre,
1518            diagonal,
1519            ring_chord,
1520            ..
1521        } = &*self.prepared;
1522        let ring_chord = *ring_chord;
1523        let ring_deflection = Deflection {
1524            chord: ring_chord,
1525            angular: 0.05,
1526            ..Deflection::default()
1527        };
1528        let reach = tol.confusion();
1529        let length = point.distance(*centre) + diagonal + 1.0;
1530        'directions: for direction in RAY_DIRECTIONS {
1531            let along = Vector::new(direction[0], direction[1], direction[2]);
1532            let far = point + along * length;
1533            let mut crossings = 0_usize;
1534            let mut winding = 0_i64;
1535
1536            for k in self.prepared.boxes().meeting(point, far) {
1537                let (prepared, rays) = (&faces[k], &self.rays[k]);
1538                if !segment_meets(&prepared.bound, point, far) {
1539                    continue;
1540                }
1541                let (surface, inverse) = prepared.placed(model)?;
1542                // Into the face's frame, as two points rather than a direction, so
1543                // a placement that scales still carries the ray faithfully.
1544                let from = inverse.apply(point);
1545                let to = inverse.apply(far);
1546                let found = match rays.get_or_init(|| {
1547                    self.kept
1548                        .surface(prepared.surface, surface, self.prepared.tol)
1549                }) {
1550                    Some(kept) if kept.tol == tol => kept.cross((from, to))?,
1551                    _ => {
1552                        let ray: ogeom_geom::Curve =
1553                            ogeom_geom::LineCurve::segment(from, to, tol)?.into();
1554                        ogeom_intersect::intersect_curve_surface(
1555                            &ray,
1556                            surface,
1557                            ogeom_intersect::CurveSurfaceOptions::default(),
1558                            tol,
1559                        )?
1560                    }
1561                };
1562                if !found.lying.is_empty() {
1563                    // The ray runs in this face's surface: it crosses nothing and
1564                    // touches everything, which no parity expresses.
1565                    continue 'directions;
1566                }
1567                for hit in &found.crossings {
1568                    if hit.on_curve <= tol.confusion() {
1569                        // At the very start: the probe lies in this face's
1570                        // *surface*. Whether that matters depends on the trim:
1571                        // the boundary test above already said the point is off
1572                        // every face, so a start-crossing far from this face's
1573                        // rings is the unbounded surface talking, not the face,
1574                        // and it neither counts nor poisons the ray.
1575                        let (u, v) = hit.on_surface;
1576                        let rings = prepared.rings(model, ring_deflection, tol)?;
1577                        let at = rings.place(surface, Point2::new(u, v), tol);
1578                        let band = parametric_band(surface, (u, v), reach + ring_chord, tol);
1579                        let face = &prepared.face;
1580                        if rings.within(model, face, ring_deflection, at, band, tol)?
1581                            || rings.inside(model, face, ring_deflection, at, tol)?
1582                        {
1583                            continue 'directions;
1584                        }
1585                        continue;
1586                    }
1587                    let (u, v) = hit.on_surface;
1588                    use ogeom_geom::Surface as _;
1589                    let Ok((du, dv)) = surface.d1_at(u, v, tol) else {
1590                        continue 'directions;
1591                    };
1592                    let normal = du.cross(dv);
1593                    if normal.magnitude() <= tol.confusion() {
1594                        // A pole or an apex: no normal, no transversality.
1595                        continue 'directions;
1596                    }
1597                    let ray_direction = (to - from) / (to - from).magnitude();
1598                    if normal.dot(ray_direction).abs() <= GRAZING * normal.magnitude() {
1599                        // Tangential. A grazing contact counts once where parity
1600                        // needs zero or two; abandon the ray rather than guess.
1601                        continue 'directions;
1602                    }
1603                    let mut rings = prepared.rings(model, ring_deflection, tol)?;
1604                    let mut at = rings.place(surface, Point2::new(u, v), tol);
1605                    let band = parametric_band(
1606                        surface,
1607                        (u, v),
1608                        reach.max(prepared.rim(model)?) + ring_chord,
1609                        tol,
1610                    );
1611                    let face = &prepared.face;
1612                    // Near the trim on a curved face, the rings are read again
1613                    // drawn finer: drawn straight in a stretched chart, they
1614                    // can stray from the trim by more than the band.
1615                    if !matches!(surface, ogeom_geom::SurfaceGeometry::Plane(_))
1616                        && rings.within(
1617                            model,
1618                            face,
1619                            ring_deflection,
1620                            at,
1621                            parametric_band(surface, (u, v), (reach + ring_chord) * NEAR_TRIM, tol),
1622                            tol,
1623                        )?
1624                    {
1625                        rings = prepared.fine_rings(model, ring_deflection, tol)?;
1626                        at = rings.place(surface, Point2::new(u, v), tol);
1627                    }
1628                    if rings.within(model, face, ring_deflection, at, band, tol)? {
1629                        // Too near the face's boundary to know which side of the
1630                        // trim it crossed, and a shared edge would be counted by
1631                        // both faces or neither: within the ring's chord, or
1632                        // within the tolerance its edges and vertices are
1633                        // known to.
1634                        continue 'directions;
1635                    }
1636                    if rings.inside(model, face, ring_deflection, at, tol)? {
1637                        crossings += 1;
1638                        // The face's own frame reads the sign alike under a
1639                        // reflecting placement, which turns the normal and
1640                        // the ray together.
1641                        let leaves = (normal.dot(ray_direction) > 0.0)
1642                            != (face.orientation() == ogeom_topo::Orientation::Reversed);
1643                        winding += if leaves { 1 } else { -1 };
1644                    }
1645                }
1646            }
1647            return Ok(Some((crossings, winding)));
1648        }
1649        Ok(None)
1650    }
1651
1652    /// Where `point`, off every face, stands by the segment to a face's own
1653    /// point nearest it: where that segment meets no face before its end,
1654    /// and meets the face there transversally and clear of its trim, the
1655    /// point lies on the side of that face the segment comes from, In when
1656    /// the segment runs out through the face along its outward normal.
1657    ///
1658    /// No ray direction is guessed: each segment ends inside one face, and
1659    /// is taken only where every other face it passes is shown to be
1660    /// missed. `None` where no face offers such a segment.
1661    fn reached(
1662        &self,
1663        model: &Model,
1664        point: Point,
1665        tol: Tolerances,
1666    ) -> OgeomResult<Option<Containment>> {
1667        use ogeom_geom::Surface as _;
1668        let Prepared {
1669            faces, ring_chord, ..
1670        } = &*self.prepared;
1671        let ring_chord = *ring_chord;
1672        let ring_deflection = Deflection {
1673            chord: ring_chord,
1674            angular: 0.05,
1675            ..Deflection::default()
1676        };
1677        let reach = tol.confusion();
1678        // Each face's own nearest point, inside its trim and clear of it,
1679        // nearest first: the face's index, the distance, the point in
1680        // world space, the face's chart point and its normal there.
1681        let mut feet = Vec::new();
1682        for (k, prepared) in faces.iter().enumerate() {
1683            let (surface, inverse) = prepared.placed(model)?;
1684            let local = inverse.apply(point);
1685            let forward = inverse.inverse()?;
1686            // The face's point nearest the probe, and where that falls on
1687            // or past its trim, the middles of its mesh's triangles nearest
1688            // the probe: points a ray can aim at inside the face.
1689            let mut aims: Vec<(f64, f64)> = Vec::new();
1690            if let Ok(found) = project_on_surface(surface, local, 16, tol) {
1691                aims.push(found.parameters);
1692            }
1693            if let Ok(mesh) =
1694                ogeom_mesh::triangulate_face(model, &prepared.face, ring_deflection, tol)
1695                && mesh.parameters.len() == mesh.positions.len()
1696            {
1697                let mut middles: Vec<(f64, (f64, f64))> = mesh
1698                    .triangles
1699                    .iter()
1700                    .map(|t| {
1701                        let [a, b, c] = t.map(|i| i as usize);
1702                        let (pa, pb, pc) =
1703                            (mesh.parameters[a], mesh.parameters[b], mesh.parameters[c]);
1704                        let uv = ((pa.0 + pb.0 + pc.0) / 3.0, (pa.1 + pb.1 + pc.1) / 3.0);
1705                        let at = Point::new(
1706                            (mesh.positions[a].x + mesh.positions[b].x + mesh.positions[c].x) / 3.0,
1707                            (mesh.positions[a].y + mesh.positions[b].y + mesh.positions[c].y) / 3.0,
1708                            (mesh.positions[a].z + mesh.positions[b].z + mesh.positions[c].z) / 3.0,
1709                        );
1710                        (at.distance(point), uv)
1711                    })
1712                    .collect();
1713                middles.sort_by(|a, b| a.0.total_cmp(&b.0));
1714                aims.extend(middles.into_iter().take(AIMS_PER_FACE).map(|(_, uv)| uv));
1715            }
1716            let rings = prepared.rings(model, ring_deflection, tol)?;
1717            for (u, v) in aims {
1718                let Ok((du, dv)) = surface.d1_at(u, v, tol) else {
1719                    continue;
1720                };
1721                let normal = du.cross(dv);
1722                if normal.magnitude() <= tol.confusion() {
1723                    continue;
1724                }
1725                let at = rings.place(surface, Point2::new(u, v), tol);
1726                let band = parametric_band(surface, (u, v), reach + ring_chord, tol);
1727                let face = &prepared.face;
1728                if rings.within(model, face, ring_deflection, at, band, tol)?
1729                    || !rings.inside(model, face, ring_deflection, at, tol)?
1730                {
1731                    continue;
1732                }
1733                let foot = forward.apply(surface.point_at(u, v, tol)?);
1734                feet.push((point.distance(foot), k, foot, (u, v), normal));
1735            }
1736        }
1737        feet.sort_by(|a, b| a.0.total_cmp(&b.0));
1738        'feet: for (length, k, foot, _, normal) in feet {
1739            if length <= reach {
1740                continue;
1741            }
1742            // Past the foot by half the segment again, so the face's own
1743            // crossing stands inside the segment rather than at its end.
1744            let along = (foot - point) / length;
1745            let far = point + along * (length * 1.5);
1746            let margin = reach + ring_chord;
1747            for j in self.prepared.boxes().meeting(point, far) {
1748                let prepared = &faces[j];
1749                if !segment_meets(&prepared.bound, point, far) {
1750                    continue;
1751                }
1752                let (surface, inverse) = prepared.placed(model)?;
1753                let (from, to) = (inverse.apply(point), inverse.apply(far));
1754                let ray: ogeom_geom::Curve = ogeom_geom::LineCurve::segment(from, to, tol)?.into();
1755                let found = ogeom_intersect::intersect_curve_surface(
1756                    &ray,
1757                    surface,
1758                    ogeom_intersect::CurveSurfaceOptions::default(),
1759                    tol,
1760                )?;
1761                if !found.lying.is_empty() {
1762                    continue 'feet;
1763                }
1764                // The segment's parameter runs with distance in the face's
1765                // frame; the foot stands `length` along it in world space,
1766                // which a scaling placement stretches alike.
1767                let scale = (to - from).magnitude() / (length * 1.5);
1768                let at_foot = length * scale;
1769                for hit in &found.crossings {
1770                    let (hu, hv) = hit.on_surface;
1771                    if j == k && (hit.on_curve - at_foot).abs() <= margin * scale {
1772                        // The segment's own end on this face: it crosses
1773                        // there, not along it.
1774                        let Ok((du, dv)) = surface.d1_at(hu, hv, tol) else {
1775                            continue 'feet;
1776                        };
1777                        let n = du.cross(dv);
1778                        let d = (to - from) / (to - from).magnitude();
1779                        if n.dot(d).abs() <= GRAZING * n.magnitude() {
1780                            continue 'feet;
1781                        }
1782                        continue;
1783                    }
1784                    if hit.on_curve > at_foot + margin * scale {
1785                        continue;
1786                    }
1787                    // Any other face met before the foot, or at it, must be
1788                    // met outside its trim and clear of it.
1789                    let rings = prepared.rings(model, ring_deflection, tol)?;
1790                    let at = rings.place(surface, Point2::new(hu, hv), tol);
1791                    let band = parametric_band(surface, (hu, hv), margin, tol);
1792                    let face = &prepared.face;
1793                    if rings.within(model, face, ring_deflection, at, band, tol)?
1794                        || rings.inside(model, face, ring_deflection, at, tol)?
1795                    {
1796                        continue 'feet;
1797                    }
1798                }
1799            }
1800            // Out through the face along its outward normal: inside. The
1801            // face's own frame reads the sign alike under a reflecting
1802            // placement, which turns the normal and the segment together.
1803            let prepared = &faces[k];
1804            let (_, inverse) = prepared.placed(model)?;
1805            let side = normal.dot(inverse.apply(foot) - inverse.apply(point));
1806            let outward = if prepared.face.orientation() == ogeom_topo::Orientation::Reversed {
1807                -side
1808            } else {
1809                side
1810            };
1811            if outward.abs() <= GRAZING * normal.magnitude() * length {
1812                continue;
1813            }
1814            return Ok(Some(if outward > 0.0 {
1815                Containment::In
1816            } else {
1817                Containment::Out
1818            }));
1819        }
1820        Ok(None)
1821    }
1822
1823    /// Where `point`, off every face, stands by its winding number against
1824    /// the faces' meshes, or `None` where that reads neither in nor out.
1825    ///
1826    /// The mesh's number is the solid's where the point stands clear of the
1827    /// band between each face and its mesh (see [`crate::winding`]). A
1828    /// point within a curved face's band asks again at a chord a quarter of
1829    /// its distance from the mesh, twice at most, and no finer than
1830    /// [`WINDING_FINEST`] of the solid's size.
1831    fn wound(&self, model: &Model, point: Point, tol: Tolerances) -> Option<Containment> {
1832        self.wound_finer(model, point, 3, tol)
1833    }
1834
1835    /// [`SolidBoundary::wound`] at the first chord alone, never meshing the
1836    /// faces finer: `None` also where the point stands within a curved
1837    /// face's band at that chord.
1838    pub(crate) fn wound_coarsely(
1839        &self,
1840        model: &Model,
1841        point: Point,
1842        tol: Tolerances,
1843    ) -> Option<Containment> {
1844        self.wound_finer(model, point, 1, tol)
1845    }
1846
1847    /// [`SolidBoundary::wound`], meshing the faces at most `meshes` times.
1848    fn wound_finer(
1849        &self,
1850        model: &Model,
1851        point: Point,
1852        meshes: usize,
1853        tol: Tolerances,
1854    ) -> Option<Containment> {
1855        use crate::winding::{WindingMesh, Wound};
1856        let prepared = &*self.prepared;
1857        let finest = (prepared.diagonal * WINDING_FINEST).max(tol.confusion() * 1e3);
1858        let mesh = |chord: f64| {
1859            WindingMesh::of(model, &prepared.solid, chord, Some(self.kept.meshes()), tol)
1860                .ok()
1861                .map(std::sync::Arc::new)
1862        };
1863        let mut meshed = prepared
1864            .winding
1865            .get_or_init(|| mesh(prepared.winding_chord(tol)))
1866            .clone()?;
1867        for asked in 1..=meshes {
1868            match meshed.wind(point) {
1869                Wound::Reads(read) => return Some(read),
1870                Wound::Unread => return None,
1871                Wound::Near(distance) => {
1872                    let chord = distance * 0.25;
1873                    if asked == meshes || chord < finest || chord >= meshed.chord() {
1874                        return None;
1875                    }
1876                    meshed = mesh(chord)?;
1877                }
1878            }
1879        }
1880        None
1881    }
1882}
1883
1884/// The finest chord, over the solid's diagonal, a winding number meshes
1885/// its faces at.
1886const WINDING_FINEST: f64 = 1e-5;
1887
1888impl Prepared {
1889    /// The chord the faces are first meshed at for winding numbers: a
1890    /// thousandth of the solid's size.
1891    fn winding_chord(&self, tol: Tolerances) -> f64 {
1892        (self.diagonal * 1e-3).max(tol.confusion() * 1e3)
1893    }
1894}
1895
1896/// What probing a model's solids finds that probing them again would find
1897/// the same: the faces' meshes, and where each ray cast meets a surface
1898/// that has no closed form against a line.
1899///
1900/// Kept by what each answer reads: a mesh by the face's content (see
1901/// [`ogeom_mesh::FaceMeshCache`]), a ray by its two ends to the bit and the
1902/// surface it was cast against, compared whole. An answer read back is the
1903/// one asking again gives, so one cache serves probes of a model before and
1904/// after it is mended, and only what the mend left alone is read back.
1905/// Clones share what is kept.
1906#[derive(Debug, Clone, Default)]
1907pub struct ProbeCache(std::sync::Arc<Kept>);
1908
1909#[derive(Debug, Default)]
1910struct Kept {
1911    meshes: ogeom_mesh::FaceMeshCache,
1912    rays: std::sync::Mutex<ogeom_core::FastMap<ogeom_topo::SurfaceId, std::sync::Arc<KeptSurface>>>,
1913}
1914
1915/// One surface readied for rays, and the rays cast against it.
1916#[derive(Debug)]
1917struct KeptSurface {
1918    readied: ogeom_intersect::PreparedSurface,
1919    tol: Tolerances,
1920    answers:
1921        std::sync::Mutex<ogeom_core::FastMap<[u64; 6], ogeom_intersect::CurveSurfaceIntersection>>,
1922}
1923
1924impl ProbeCache {
1925    /// An empty cache.
1926    #[must_use]
1927    pub fn new() -> Self {
1928        Self::default()
1929    }
1930
1931    /// The face meshes kept.
1932    pub(crate) fn meshes(&self) -> &ogeom_mesh::FaceMeshCache {
1933        &self.0.meshes
1934    }
1935
1936    /// The surface stored under `id`, readied for rays at `tol`: the one
1937    /// kept where it is still the same surface, else a fresh one, kept
1938    /// from then on. `None` for a surface a line meets in closed form,
1939    /// where asking costs less than looking up.
1940    fn surface(
1941        &self,
1942        id: ogeom_topo::SurfaceId,
1943        surface: &ogeom_geom::SurfaceGeometry,
1944        tol: Tolerances,
1945    ) -> Option<std::sync::Arc<KeptSurface>> {
1946        use ogeom_geom::SurfaceGeometry as S;
1947        if matches!(
1948            surface,
1949            S::Plane(_) | S::Sphere(_) | S::Cylinder(_) | S::Cone(_) | S::Torus(_)
1950        ) {
1951            return None;
1952        }
1953        let mut rays = self
1954            .0
1955            .rays
1956            .lock()
1957            .unwrap_or_else(std::sync::PoisonError::into_inner);
1958        if let Some(kept) = rays.get(&id)
1959            && kept.tol == tol
1960            && kept.readied.surface() == surface
1961        {
1962            return Some(kept.clone());
1963        }
1964        let options = ogeom_intersect::CurveSurfaceOptions::default();
1965        let kept = std::sync::Arc::new(KeptSurface {
1966            readied: ogeom_intersect::PreparedSurface::new(surface.clone(), options, tol),
1967            tol,
1968            answers: std::sync::Mutex::default(),
1969        });
1970        rays.insert(id, kept.clone());
1971        Some(kept)
1972    }
1973}
1974
1975impl KeptSurface {
1976    /// Where the segment from `from` to `to` pierces the surface, as
1977    /// [`ogeom_intersect::intersect_curve_surface`] at its default options
1978    /// answers.
1979    fn cross(
1980        &self,
1981        (from, to): (Point, Point),
1982    ) -> OgeomResult<ogeom_intersect::CurveSurfaceIntersection> {
1983        let key = [from.x, from.y, from.z, to.x, to.y, to.z].map(f64::to_bits);
1984        let answers = || {
1985            self.answers
1986                .lock()
1987                .unwrap_or_else(std::sync::PoisonError::into_inner)
1988        };
1989        if let Some(found) = answers().get(&key) {
1990            return Ok(found.clone());
1991        }
1992        let ray: ogeom_geom::Curve = ogeom_geom::LineCurve::segment(from, to, self.tol)?.into();
1993        let found = self.readied.intersect(&ray)?;
1994        answers().insert(key, found.clone());
1995        Ok(found)
1996    }
1997}
1998
1999/// The sine of the shallowest crossing angle a counted ray/surface crossing
2000/// may make.
2001///
2002/// Below this the hit is treated as a graze: a tangential contact is one
2003/// crossing where parity arithmetic needs zero or two, and the seed of the
2004/// threshold is the same as the marching intersector's `SHALLOWEST`: beneath
2005/// a microradian, rounding in the evaluated normal can no longer tell a
2006/// crossing from a touch.
2007const GRAZING: f64 = 1e-6;
2008
2009/// How many of a face's mesh triangles, nearest the probe first, offer
2010/// their middles for a segment to aim at when the face's own nearest
2011/// point lies on or past its trim.
2012const AIMS_PER_FACE: usize = 3;
2013
2014/// Directions to cast rays along, tried in order.
2015///
2016/// Deterministic, not random: a classifier that gives different answers on
2017/// different runs is worse than one that fails, because the failure can be
2018/// handled and the inconsistency cannot. They are deliberately not axis-aligned
2019/// and share no common plane, so a mesh built on a regular grid (where an
2020/// axis-aligned ray runs along a whole row of edges) does not defeat all of
2021/// them at once.
2022const RAY_DIRECTIONS: [[f64; 3]; 6] = [
2023    [0.577_35, 0.577_35, 0.577_35],
2024    [-0.301_5, 0.904_5, 0.301_5],
2025    [0.727_6, -0.485_1, 0.485_1],
2026    [0.259_5, 0.259_5, -0.930_0],
2027    [-0.816_5, -0.408_2, 0.408_2],
2028    [0.132_5, -0.662_3, -0.737_5],
2029];
2030
2031/// Count how many triangles a ray from `from` crosses, or `None` if any hit was
2032/// too close to an edge or vertex to count reliably.
2033fn count_crossings(
2034    triangles: &[[Point; 3]],
2035    from: Point,
2036    along: Direction,
2037    tol: Tolerances,
2038) -> Option<usize> {
2039    let mut crossings = 0;
2040    for t in triangles {
2041        match ray_hits_triangle(from, along, *t, tol) {
2042            Hit::Crosses => crossings += 1,
2043            Hit::Misses => {}
2044            Hit::Ambiguous => return None,
2045        }
2046    }
2047    Some(crossings)
2048}
2049
2050/// What a ray did to a triangle.
2051enum Hit {
2052    /// Passed through its interior, ahead of the start.
2053    Crosses,
2054    /// Did not meet it.
2055    Misses,
2056    /// Met an edge, a vertex, or the plane edge-on, where counting it once is
2057    /// as defensible as counting it twice or not at all.
2058    Ambiguous,
2059}
2060
2061/// Möller-Trumbore, with the degenerate cases separated out rather than
2062/// rounded away.
2063fn ray_hits_triangle(from: Point, along: Direction, t: [Point; 3], tol: Tolerances) -> Hit {
2064    let direction = along.vector();
2065    let (e1, e2) = (t[1] - t[0], t[2] - t[0]);
2066    let h = direction.cross(e2);
2067    let determinant = e1.dot(h);
2068
2069    // Scale the comparison by the triangle: a determinant is a volume, so a
2070    // fixed threshold rejects small triangles and accepts edge-on hits on
2071    // large ones.
2072    let scale = e1.magnitude() * e2.magnitude();
2073    let flat = tol.confusion() * scale;
2074    if determinant.abs() <= flat {
2075        // Edge-on. It cannot cross cleanly, but it may lie in the plane and
2076        // touch, which no crossing count expresses.
2077        let normal = e1.cross(e2);
2078        let reach = tol.confusion() * scale;
2079        return if normal.dot(from - t[0]).abs() <= reach {
2080            Hit::Ambiguous
2081        } else {
2082            Hit::Misses
2083        };
2084    }
2085
2086    let inverse = 1.0 / determinant;
2087    let s = from - t[0];
2088    let u = inverse * s.dot(h);
2089    let q = s.cross(e1);
2090    let v = inverse * direction.dot(q);
2091    let w = 1.0 - u - v;
2092
2093    // Barycentric coordinates near zero mean the ray passed along an edge, and
2094    // near one that it went through a vertex.
2095    let edge = tol.confusion();
2096    if [u, v, w].iter().any(|c| c.abs() <= edge) {
2097        // Only ambiguous if the ray would otherwise have hit: a grazing miss
2098        // well outside the triangle is a miss.
2099        return if u >= -edge && v >= -edge && w >= -edge {
2100            Hit::Ambiguous
2101        } else {
2102            Hit::Misses
2103        };
2104    }
2105    if u < 0.0 || v < 0.0 || w < 0.0 {
2106        return Hit::Misses;
2107    }
2108
2109    let distance = inverse * e2.dot(q);
2110    if distance <= tol.confusion() {
2111        // Behind the start, or right at it, and right at it was already ruled
2112        // out by the on-boundary test before any ray was cast.
2113        return Hit::Misses;
2114    }
2115    Hit::Crosses
2116}
2117
2118/// The distance from a point to a triangle.
2119pub(crate) fn distance_to_triangle(p: Point, t: [Point; 3]) -> f64 {
2120    // Clamp the projection onto the triangle's plane into the triangle, by
2121    // checking the three edge regions and the interior. Solving the 2×2 normal
2122    // equations directly and clamping is shorter than a region case analysis
2123    // and gives the same closest point.
2124    let (e1, e2) = (t[1] - t[0], t[2] - t[0]);
2125    let d = t[0] - p;
2126    let (a, b, c) = (e1.dot(e1), e1.dot(e2), e2.dot(e2));
2127    let (dd, e) = (e1.dot(d), e2.dot(d));
2128    let determinant = b.mul_add(-b, a * c);
2129
2130    if determinant.abs() <= f64::MIN_POSITIVE {
2131        // A degenerate triangle is a segment; its edges still answer.
2132        return edge_distance(p, t);
2133    }
2134    let mut s = b.mul_add(e, -(c * dd)) / determinant;
2135    let mut u = b.mul_add(dd, -(a * e)) / determinant;
2136
2137    if s >= 0.0 && u >= 0.0 && s + u <= 1.0 {
2138        let closest = t[0] + e1 * s + e2 * u;
2139        return p.distance(closest);
2140    }
2141    // Outside: the closest point is on an edge.
2142    s = s.clamp(0.0, 1.0);
2143    u = u.clamp(0.0, 1.0);
2144    let _ = (s, u);
2145    edge_distance(p, t)
2146}
2147
2148/// The distance from a point to the nearest of a triangle's three edges.
2149fn edge_distance(p: Point, t: [Point; 3]) -> f64 {
2150    let mut best = f64::INFINITY;
2151    for i in 0..3 {
2152        best = best.min(segment_distance(p, t[i], t[(i + 1) % 3]));
2153    }
2154    best
2155}
2156
2157/// The distance from a point to a segment.
2158pub(crate) fn segment_distance(p: Point, a: Point, b: Point) -> f64 {
2159    let d = b - a;
2160    let squared = d.dot(d);
2161    if squared <= f64::MIN_POSITIVE {
2162        return p.distance(a);
2163    }
2164    let t = ((p - a).dot(d) / squared).clamp(0.0, 1.0);
2165    p.distance(a + d * t)
2166}
2167
2168/// The distance from a 2D point to a 2D segment.
2169fn segment_distance_2d(p: Point2, a: Point2, b: Point2) -> f64 {
2170    let d = b - a;
2171    let squared = d.dot(d);
2172    if squared <= f64::MIN_POSITIVE {
2173        return p.distance(a);
2174    }
2175    let t = ((p - a).dot(d) / squared).clamp(0.0, 1.0);
2176    p.distance(a + d * t)
2177}
2178
2179/// Fold a chart point toward the rings' own window, one period at a time.
2180///
2181/// Projection and intersection answer parameters in a surface's principal
2182/// range, but a face's trim may live in any window of a periodic chart: a
2183/// band anchored where its rings happened to start. The trim tests compare
2184/// against the rings, so the point folds to them, not the other way round.
2185pub(crate) fn fold_toward_rings(
2186    surface: &ogeom_geom::SurfaceGeometry,
2187    rings: &[Vec<Point2>],
2188    mut at: Point2,
2189) -> Point2 {
2190    use ogeom_geom::Surface as _;
2191    // Whatever repeats: the canonical surfaces' angles, a whole turn of a
2192    // revolution, a periodic patch.
2193    let ((ua, ub), (va, vb)) = surface.domain();
2194    let u_period = (surface.is_periodic_u() && ub > ua).then_some(ub - ua);
2195    let v_period = (surface.is_periodic_v() && vb > va).then_some(vb - va);
2196    let fold = |x: f64, lo: f64, hi: f64, period: f64| -> f64 {
2197        let mut x = x;
2198        while x < lo && x + period <= hi + period {
2199            x += period;
2200            if x >= lo {
2201                break;
2202            }
2203        }
2204        while x > hi && x - period >= lo - period {
2205            x -= period;
2206            if x <= hi {
2207                break;
2208            }
2209        }
2210        x
2211    };
2212    if let Some(period) = u_period {
2213        let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
2214        for ring in rings {
2215            for q in ring {
2216                lo = lo.min(q.x);
2217                hi = hi.max(q.x);
2218            }
2219        }
2220        if lo.is_finite() {
2221            at.x = fold(at.x, lo, hi, period);
2222        }
2223    }
2224    if let Some(period) = v_period {
2225        let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
2226        for ring in rings {
2227            for q in ring {
2228                lo = lo.min(q.y);
2229                hi = hi.max(q.y);
2230            }
2231        }
2232        if lo.is_finite() {
2233            at.y = fold(at.y, lo, hi, period);
2234        }
2235    }
2236    // The window alone cannot say which side of a seam a point is on when
2237    // the seam runs diagonally round (a band opened along the widest gap
2238    // its mesh left): both a point and its copy a period over lie within
2239    // the rings' extent, only one inside them. Nor which turn of a strip
2240    // winding round many times (a thread's flank on its drum): every copy
2241    // of the point a whole period over within the strip's extent is a
2242    // candidate, and one of them lies on the strip. The one inside is the
2243    // face's, the nearest first.
2244    if !inside_boundary(rings, at) {
2245        let (low, high) = rings.iter().flatten().fold(
2246            (
2247                Point2::new(f64::INFINITY, f64::INFINITY),
2248                Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
2249            ),
2250            |(lo, hi), q| {
2251                (
2252                    Point2::new(lo.x.min(q.x), lo.y.min(q.y)),
2253                    Point2::new(hi.x.max(q.x), hi.y.max(q.y)),
2254                )
2255            },
2256        );
2257        // The whole periods that carry `x` into [lo, hi], and one further
2258        // either way, nearest first; none where the extent is not finite.
2259        let turns = |x: f64, lo: f64, hi: f64, period: f64| -> Vec<f64> {
2260            if !(lo.is_finite() && hi.is_finite()) {
2261                return Vec::new();
2262            }
2263            let first = ((lo - x) / period).ceil() - 1.0;
2264            let last = ((hi - x) / period).floor() + 1.0;
2265            if !(first.is_finite() && last.is_finite()) || last - first > 4096.0 {
2266                return vec![-1.0, 1.0];
2267            }
2268            #[allow(clippy::cast_possible_truncation, reason = "at most 4096 turns")]
2269            let (first, last) = (first as i64, last as i64);
2270            #[allow(clippy::cast_precision_loss, reason = "at most 4096 turns")]
2271            let mut all: Vec<f64> = (first..=last)
2272                .filter(|k| *k != 0)
2273                .map(|k| k as f64)
2274                .collect();
2275            all.sort_by(|a, b| a.abs().total_cmp(&b.abs()));
2276            all
2277        };
2278        let mut shifts: Vec<(f64, f64)> = Vec::new();
2279        if let Some(p) = u_period {
2280            shifts.extend(
2281                turns(at.x, low.x, high.x, p)
2282                    .into_iter()
2283                    .map(|k| (k * p, 0.0)),
2284            );
2285        }
2286        if let Some(p) = v_period {
2287            shifts.extend(
2288                turns(at.y, low.y, high.y, p)
2289                    .into_iter()
2290                    .map(|k| (0.0, k * p)),
2291            );
2292        }
2293        if let Some(inside) = shifts
2294            .into_iter()
2295            .map(|(du, dv)| Point2::new(at.x + du, at.y + dv))
2296            .find(|q| inside_boundary(rings, *q))
2297        {
2298            return inside;
2299        }
2300    }
2301    at
2302}
2303
2304/// [`fold_toward_rings`], and where the point folds outside them on a
2305/// revolution, its other place in the chart.
2306///
2307/// A profile crossing the axis (a whole circle revolved) covers its surface
2308/// twice: the point at `(u, v)` is also at `u` a half turn over, on the
2309/// profile's far side. A projection or a crossing answers either, and a face
2310/// using one half of the profile holds only one of them.
2311fn place_on_rings(
2312    surface: &ogeom_geom::SurfaceGeometry,
2313    rings: &[Vec<Point2>],
2314    at: Point2,
2315    tol: Tolerances,
2316) -> Point2 {
2317    let folded = fold_toward_rings(surface, rings, at);
2318    if inside_boundary(rings, folded) {
2319        return folded;
2320    }
2321    let ogeom_geom::SurfaceGeometry::Revolution(revolution) = surface else {
2322        return folded;
2323    };
2324    let other = (|| -> Option<Point2> {
2325        use ogeom_geom::Surface as _;
2326        let point = surface.point_at(at.x, at.y, tol).ok()?;
2327        let ((u0, u1), _) = surface.domain();
2328        let half = core::f64::consts::PI;
2329        let u = [at.x + half, at.x - half]
2330            .into_iter()
2331            .find(|u| *u >= u0 - tol.angular() && *u <= u1 + tol.angular())?;
2332        let back = ogeom_math::Transform::rotation(revolution.axis(), -u).apply(point);
2333        let found = crate::measure::project_on_curve(revolution.curve(), back, 64, tol).ok()?;
2334        (found.distance <= tol.confusion()).then(|| Point2::new(u, found.parameter))
2335    })();
2336    other
2337        .map(|q| fold_toward_rings(surface, rings, q))
2338        .filter(|q| inside_boundary(rings, *q))
2339        .unwrap_or(folded)
2340}
2341
2342/// A distance of `reach` in space about a chart point, read in the chart.
2343///
2344/// Each chart direction keeps its own scale, the length of the surface's
2345/// tangent along it: near a sphere's pole a step in `u` covers a sliver of
2346/// the space a step in `v` does, and one scale for both would either miss
2347/// rings along `v` or reach far past them across it. A chart step
2348/// `(du, dv)` is within the band when `(du * su, dv * sv)` is within
2349/// `reach`. Where both tangents vanish (a cone's apex) no chart distance
2350/// corresponds to a spatial one, and the band covers the whole
2351/// neighbourhood rather than closing to nothing.
2352#[derive(Debug, Clone, Copy)]
2353pub(crate) struct ChartBand {
2354    reach: f64,
2355    su: f64,
2356    sv: f64,
2357}
2358
2359impl ChartBand {
2360    /// The widest the band reaches along any chart direction, in chart
2361    /// units.
2362    pub(crate) fn radius(self) -> f64 {
2363        let scale = self.su.min(self.sv);
2364        if scale <= 0.0 {
2365            f64::INFINITY
2366        } else {
2367            self.reach / scale
2368        }
2369    }
2370
2371    /// Whether a ring passes within the band about `p`.
2372    pub(crate) fn meets_ring(self, ring: &[Point2], p: Point2) -> bool {
2373        if self.su <= 0.0 && self.sv <= 0.0 {
2374            return true;
2375        }
2376        let scaled = |q: Point2| Point2::new((q.x - p.x) * self.su, (q.y - p.y) * self.sv);
2377        let origin = Point2::new(0.0, 0.0);
2378        (0..ring.len()).any(|i| {
2379            let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
2380            segment_distance_2d(origin, scaled(a), scaled(b)) <= self.reach
2381        })
2382    }
2383
2384    /// Whether any of the rings passes within the band about `p`.
2385    pub(crate) fn meets_rings(self, rings: &[Vec<Point2>], p: Point2) -> bool {
2386        rings.iter().any(|ring| self.meets_ring(ring, p))
2387    }
2388}
2389
2390/// The band a distance of `reach` in space makes about `(u, v)` in the
2391/// chart, scaled by the surface's tangents there.
2392pub(crate) fn parametric_band(
2393    surface: &ogeom_geom::SurfaceGeometry,
2394    at: (f64, f64),
2395    reach: f64,
2396    tol: Tolerances,
2397) -> ChartBand {
2398    use ogeom_geom::Surface;
2399    let Ok((du, dv)) = surface.d1_at(at.0, at.1, tol) else {
2400        return ChartBand {
2401            reach,
2402            su: 1.0,
2403            sv: 1.0,
2404        };
2405    };
2406    // A tangent shorter than the confusion distance is a collapsed one.
2407    let scale = |t: ogeom_math::Vector| {
2408        let m = t.magnitude();
2409        if m <= tol.confusion() { 0.0 } else { m }
2410    };
2411    ChartBand {
2412        reach,
2413        su: scale(du),
2414        sv: scale(dv),
2415    }
2416}
2417
2418/// Whether the box kept for a face on `surface` is found exactly, not
2419/// from a mesh.
2420fn closed_form(surface: &ogeom_geom::SurfaceGeometry) -> bool {
2421    use ogeom_geom::SurfaceGeometry as S;
2422    matches!(
2423        surface,
2424        S::Plane(_) | S::Cylinder(_) | S::Cone(_) | S::Sphere(_) | S::Torus(_)
2425    )
2426}
2427
2428/// Whether the segment from `a` to `b` passes through the box, by slabs.
2429/// Faces' bounds in a tree, for the faces a segment or a point may meet
2430/// without asking each face's bound.
2431///
2432/// Each node's box is the union of its children's. [`segment_meets`] and
2433/// [`Aabb::contains`] are monotone in the box: a box inside another gives
2434/// a slab interval inside the other's, rounded alike, and passes every
2435/// comparison the larger passes. So a node whose box a segment or point
2436/// does not meet holds no face's bound that it meets, and every face the
2437/// test would pass is among the candidates; a bound with a coordinate that
2438/// is not finite, outside that ordering, is always a candidate. Callers
2439/// still ask each candidate's own bound, in the faces' order, so they ask
2440/// exactly what they asked of every face, of the same faces, in the same
2441/// order.
2442#[derive(Debug)]
2443struct FaceBoxes {
2444    nodes: Vec<BoxNode>,
2445    /// The faces with a bound, ordered so that each leaf holds a run.
2446    order: Vec<u32>,
2447    /// The faces whose bound has a coordinate that is not finite.
2448    always: Vec<u32>,
2449}
2450
2451/// A node of [`FaceBoxes`]: its box, and either its two children or the
2452/// run of `order` it holds.
2453#[derive(Debug, Clone, Copy)]
2454struct BoxNode {
2455    bound: Aabb,
2456    children: Option<(u32, u32)>,
2457    run: (u32, u32),
2458}
2459
2460/// The most faces a leaf of [`FaceBoxes`] holds.
2461const LEAF_FACES: usize = 8;
2462
2463impl FaceBoxes {
2464    fn of(bounds: &[Aabb]) -> Self {
2465        let finite = |b: &Aabb| {
2466            b.low()
2467                .zip(b.high())
2468                .is_some_and(|(l, h)| [l.x, l.y, l.z, h.x, h.y, h.z].iter().all(|c| c.is_finite()))
2469        };
2470        let mut order = Vec::new();
2471        let mut always = Vec::new();
2472        for (k, b) in bounds.iter().enumerate() {
2473            let k = u32::try_from(k).unwrap_or(u32::MAX);
2474            if finite(b) {
2475                order.push(k);
2476            } else if !b.is_empty() {
2477                always.push(k);
2478            }
2479        }
2480        let mut tree = Self {
2481            nodes: Vec::new(),
2482            order,
2483            always,
2484        };
2485        if !tree.order.is_empty() {
2486            tree.split(bounds, 0, tree.order.len());
2487        }
2488        tree
2489    }
2490
2491    /// The node over `order[from..to]`, split at the median of the boxes'
2492    /// middles along their widest spread; its index.
2493    fn split(&mut self, bounds: &[Aabb], from: usize, to: usize) -> u32 {
2494        let middle = |k: u32| bounds[k as usize].centre().unwrap_or(Point::ORIGIN);
2495        let bound = self.order[from..to]
2496            .iter()
2497            .fold(Aabb::EMPTY, |b, &k| b.union(&bounds[k as usize]));
2498        let at = u32::try_from(self.nodes.len()).unwrap_or(u32::MAX);
2499        let run = (
2500            u32::try_from(from).unwrap_or(u32::MAX),
2501            u32::try_from(to).unwrap_or(u32::MAX),
2502        );
2503        self.nodes.push(BoxNode {
2504            bound,
2505            children: None,
2506            run,
2507        });
2508        if to - from <= LEAF_FACES {
2509            return at;
2510        }
2511        let spread = self.order[from..to]
2512            .iter()
2513            .fold(Aabb::EMPTY, |b, &k| b.with_point(middle(k)))
2514            .size();
2515        let axis = if spread.x >= spread.y && spread.x >= spread.z {
2516            0
2517        } else if spread.y >= spread.z {
2518            1
2519        } else {
2520            2
2521        };
2522        let key = |k: u32| {
2523            let m = middle(k);
2524            [m.x, m.y, m.z][axis]
2525        };
2526        let half = from + (to - from) / 2;
2527        self.order[from..to].select_nth_unstable_by(half - from, |&a, &b| {
2528            key(a).total_cmp(&key(b)).then(a.cmp(&b))
2529        });
2530        let left = self.split(bounds, from, half);
2531        let right = self.split(bounds, half, to);
2532        self.nodes[at as usize].children = Some((left, right));
2533        at
2534    }
2535
2536    /// The faces whose bound the test may pass, in the faces' order.
2537    fn candidates(&self, meets: impl Fn(&Aabb) -> bool) -> Vec<usize> {
2538        let mut out: Vec<usize> = self.always.iter().map(|&k| k as usize).collect();
2539        if !self.nodes.is_empty() {
2540            let mut stack = vec![0_u32];
2541            while let Some(n) = stack.pop() {
2542                let node = &self.nodes[n as usize];
2543                if !meets(&node.bound) {
2544                    continue;
2545                }
2546                match node.children {
2547                    Some((left, right)) => {
2548                        stack.push(right);
2549                        stack.push(left);
2550                    }
2551                    None => out.extend(
2552                        self.order[node.run.0 as usize..node.run.1 as usize]
2553                            .iter()
2554                            .map(|&k| k as usize),
2555                    ),
2556                }
2557            }
2558        }
2559        out.sort_unstable();
2560        out
2561    }
2562
2563    /// The faces whose bound the segment from `a` to `b` may meet.
2564    fn meeting(&self, a: Point, b: Point) -> Vec<usize> {
2565        self.candidates(|bound| segment_meets(bound, a, b))
2566    }
2567
2568    /// The faces whose bound may hold `point`.
2569    fn holding(&self, point: Point) -> Vec<usize> {
2570        self.candidates(|bound| bound.contains(point))
2571    }
2572}
2573
2574fn segment_meets(bound: &Aabb, a: Point, b: Point) -> bool {
2575    let (Some(low), Some(high)) = (bound.low(), bound.high()) else {
2576        return false;
2577    };
2578    let (mut enter, mut leave) = (0.0_f64, 1.0_f64);
2579    for (from, to, lo, hi) in [
2580        (a.x, b.x, low.x, high.x),
2581        (a.y, b.y, low.y, high.y),
2582        (a.z, b.z, low.z, high.z),
2583    ] {
2584        let d = to - from;
2585        if d.abs() <= f64::EPSILON * (from.abs() + to.abs() + 1.0) {
2586            if from < lo || from > hi {
2587                return false;
2588            }
2589            continue;
2590        }
2591        let (t0, t1) = ((lo - from) / d, (hi - from) / d);
2592        let (t0, t1) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
2593        enter = enter.max(t0);
2594        leave = leave.min(t1);
2595        if enter > leave {
2596            return false;
2597        }
2598    }
2599    true
2600}
2601
2602#[cfg(test)]
2603#[allow(clippy::unwrap_used, clippy::expect_used)]
2604mod tests {
2605    use super::*;
2606
2607    /// The tree's candidates hold every face whose bound a segment or a
2608    /// point meets, in the faces' order: on boxes of every size scattered
2609    /// by a fixed generator, among them empty ones, flat ones and one with
2610    /// a coordinate that is not finite, against segments short and long,
2611    /// along an axis and not, and points on box faces.
2612    #[test]
2613    fn face_boxes_hold_every_bound_a_probe_meets() {
2614        let mut state = 0x9e37_79b9_7f4a_7c15_u64;
2615        let mut step = || {
2616            state ^= state << 13;
2617            state ^= state >> 7;
2618            state ^= state << 17;
2619            u32::try_from(state >> 40).unwrap()
2620        };
2621        let unit = |n: u32| f64::from(n) / f64::from(1_u32 << 24);
2622        let mut bounds = Vec::new();
2623        for k in 0..600 {
2624            let low = Point::new(
2625                unit(step()) * 100.0,
2626                unit(step()) * 100.0,
2627                unit(step()) * 100.0,
2628            );
2629            let size = if k % 50 == 0 {
2630                0.0
2631            } else {
2632                unit(step()) * 20.0
2633            };
2634            let high = Point::new(low.x + size, low.y + unit(step()) * 20.0, low.z);
2635            bounds.push(if k % 97 == 0 {
2636                Aabb::EMPTY
2637            } else {
2638                Aabb::of_corners(low, high)
2639            });
2640        }
2641        bounds.push(Aabb::of_corners(
2642            Point::new(f64::NAN, 0.0, 0.0),
2643            Point::new(1.0, 1.0, 1.0),
2644        ));
2645        let tree = FaceBoxes::of(&bounds);
2646        for _ in 0..400 {
2647            let a = Point::new(
2648                unit(step()) * 120.0 - 10.0,
2649                unit(step()) * 120.0 - 10.0,
2650                unit(step()) * 120.0 - 10.0,
2651            );
2652            let b = if unit(step()) < 0.3 {
2653                Point::new(a.x, a.y, unit(step()) * 120.0 - 10.0)
2654            } else {
2655                Point::new(
2656                    unit(step()) * 120.0 - 10.0,
2657                    unit(step()) * 120.0 - 10.0,
2658                    unit(step()) * 120.0 - 10.0,
2659                )
2660            };
2661            let along = tree.meeting(a, b);
2662            assert!(along.windows(2).all(|w| w[0] < w[1]));
2663            for (k, bound) in bounds.iter().enumerate() {
2664                if segment_meets(bound, a, b) {
2665                    assert!(
2666                        along.binary_search(&k).is_ok(),
2667                        "face {k} missed by {a:?}-{b:?}"
2668                    );
2669                }
2670            }
2671            let pick = usize::try_from(step() % 600).unwrap();
2672            let at = bounds[pick].high().unwrap_or(a);
2673            let held = tree.holding(at);
2674            assert!(held.windows(2).all(|w| w[0] < w[1]));
2675            for (k, bound) in bounds.iter().enumerate() {
2676                if bound.contains(at) {
2677                    assert!(held.binary_search(&k).is_ok(), "face {k} missed at {at:?}");
2678                }
2679            }
2680        }
2681    }
2682
2683    /// A strip winding fourteen turns up a drum, as a thread's flank does,
2684    /// unrolled in the drum's chart: a parallelogram rising 0.7 a turn,
2685    /// 0.2 high. A point on the strip near its top, named in the chart's
2686    /// first turn, is the same point thirteen turns over, and that copy is
2687    /// the one inside the ring.
2688    #[test]
2689    fn a_point_on_a_strip_winding_many_turns_folds_onto_its_own_turn() {
2690        use core::f64::consts::TAU;
2691        use ogeom_geom::{CylinderSurface, SurfaceGeometry};
2692        use ogeom_math::{Cylinder, Frame};
2693        let t = Tolerances::millimetres();
2694        let drum: SurfaceGeometry =
2695            CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.6, t).unwrap(), (-10.0, 10.0))
2696                .unwrap()
2697                .into();
2698        let (turns, rise, height) = (14.0, 0.7, 0.2);
2699        let (u0, u1) = (0.3, 0.3 + turns * TAU);
2700        let v = |u: f64| -5.0 + (u - u0) / TAU * rise;
2701        let ring = vec![
2702            Point2::new(u0, v(u0)),
2703            Point2::new(u1, v(u1)),
2704            Point2::new(u1, v(u1) + height),
2705            Point2::new(u0, v(u0) + height),
2706        ];
2707        let rings = vec![ring];
2708        // On the strip in its thirteenth turn, a tenth of its height up.
2709        let on = 0.3 + 13.5 * TAU;
2710        let here = Point2::new(on, v(on) + height * 0.1);
2711        assert!(inside_boundary(&rings, here));
2712        let named = Point2::new(here.x - 13.0 * TAU, here.y);
2713        let folded = fold_toward_rings(&drum, &rings, named);
2714        assert!(inside_boundary(&rings, folded), "{folded:?}");
2715        assert!(
2716            (folded.x - here.x).abs() < 1e-9,
2717            "{folded:?} against {here:?}"
2718        );
2719        // Off the strip at that height in every turn: no copy is inside,
2720        // and the point comes back as it was asked.
2721        let off = Point2::new(named.x + 0.5 * TAU, named.y);
2722        let kept = fold_toward_rings(&drum, &rings, off);
2723        assert!(!inside_boundary(&rings, kept));
2724    }
2725    use crate::make_box;
2726    use ogeom_math::Frame;
2727    use ogeom_topo::{ShapeType, explore_unique};
2728
2729    const T: Tolerances = Tolerances::millimetres();
2730
2731    fn fine() -> Deflection {
2732        Deflection {
2733            chord: 1e-3,
2734            angular: 0.05,
2735            ..Deflection::default()
2736        }
2737    }
2738
2739    #[test]
2740    fn a_point_inside_a_box_is_inside_it() {
2741        let mut model = Model::new();
2742        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
2743
2744        for p in [
2745            Point::new(1.0, 1.0, 1.0),
2746            Point::new(0.1, 0.1, 0.1),
2747            Point::new(1.9, 1.9, 1.9),
2748        ] {
2749            assert_eq!(
2750                classify_in_solid(&model, &built.shape, p, fine(), T).unwrap(),
2751                Containment::In,
2752                "{p:?} should be inside"
2753            );
2754        }
2755    }
2756
2757    /// A box with a spherical void bounded by a sphere's face turned inside
2758    /// out: a point in the void is out of the material, exactly and by mesh.
2759    #[test]
2760    fn a_point_in_a_spherical_void_is_outside_the_material() {
2761        let mut model = Model::new();
2762        let corner = Frame::new(
2763            Point::new(-20.0, -20.0, -20.0),
2764            ogeom_math::Direction::Z,
2765            ogeom_math::Direction::X,
2766            T,
2767        )
2768        .unwrap();
2769        let block = make_box(&mut model, corner, (40.0, 40.0, 40.0), T).unwrap();
2770        let ball = crate::make_sphere(&mut model, Frame::WORLD, 10.0, T).unwrap();
2771        let outer = explore_unique(&model, &block.shape, ShapeType::Shell).unwrap()[0].clone();
2772        let skin = explore_unique(&model, &ball.shape, ShapeType::Face).unwrap()[0].clone();
2773        let void = model.add_shell(&[skin.reversed()]).unwrap();
2774        let holed = crate::make_solid(&mut model, &[outer, void]).unwrap().shape;
2775        for (p, want) in [
2776            (Point::new(0.0, 0.0, 0.0), Containment::Out),
2777            (Point::new(3.0, 3.0, 3.0), Containment::Out),
2778            (Point::new(-3.0, -3.0, -3.0), Containment::Out),
2779            (Point::new(15.0, 0.0, 0.0), Containment::In),
2780        ] {
2781            assert_eq!(
2782                classify_in_solid_exact(&model, &holed, p, T).unwrap(),
2783                want,
2784                "{p:?}"
2785            );
2786            assert_eq!(
2787                classify_in_solid(&model, &holed, p, fine(), T).unwrap(),
2788                want,
2789                "{p:?}"
2790            );
2791        }
2792    }
2793
2794    /// A sphere's cap whose rim runs half a millimetre from the pole: near
2795    /// the pole a step round the chart covers a twentieth of the space a
2796    /// step toward it does, and the boundary band is read in space along
2797    /// each, so points a tenth of a millimetre either side of the rim are in
2798    /// and out, and only a point within the band is on it.
2799    #[test]
2800    fn points_beside_a_rim_near_a_sphere_s_pole_are_in_or_out() {
2801        use core::f64::consts::TAU;
2802        let mut model = Model::new();
2803        let radius = 10.0;
2804        let sphere = ogeom_math::Sphere::new(Frame::WORLD, radius, T).unwrap();
2805        let surface: ogeom_geom::SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
2806        // The rim: the parallel at a polar distance of 0.5.
2807        let height = |off: f64| (radius * radius - off * off).sqrt();
2808        let rim_frame = Frame::new(
2809            Point::new(0.0, 0.0, height(0.5)),
2810            ogeom_math::Direction::Z,
2811            ogeom_math::Direction::X,
2812            T,
2813        )
2814        .unwrap();
2815        let circle = ogeom_math::Circle::new(rim_frame, 0.5, T).unwrap();
2816        let start = crate::make_vertex(&mut model, Point::new(0.5, 0.0, height(0.5))).shape;
2817        let rim = crate::make_edge_between(
2818            &mut model,
2819            ogeom_geom::CircleCurve::new(circle).into(),
2820            (0.0, TAU),
2821            &start,
2822            &start,
2823            T,
2824        )
2825        .unwrap()
2826        .shape;
2827        let top = crate::make_vertex(&mut model, Point::new(0.0, 0.0, radius)).shape;
2828        let mut data = ogeom_topo::EdgeData::new();
2829        data.degenerate = true;
2830        let pole = model.add_edge(data, &[top.clone(), top]).unwrap();
2831        let cap = crate::make_revolution_band(&mut model, &surface, &rim, &pole, T).unwrap();
2832        // The side probe's own resolution: a chord of a hundredth.
2833        let deflection = Deflection {
2834            chord: 1e-2,
2835            ..Deflection::default()
2836        };
2837        // Across the rim, on the side away from the seam.
2838        let at = |off: f64| Point::new(-off, 0.0, height(off));
2839        for (off, want) in [
2840            (0.4, Containment::In),
2841            (0.45, Containment::In),
2842            (0.5, Containment::On),
2843            (0.505, Containment::On),
2844            (0.55, Containment::Out),
2845            (0.6, Containment::Out),
2846        ] {
2847            assert_eq!(
2848                classify_on_face(&model, &cap, at(off), deflection, T).unwrap(),
2849                want,
2850                "{off} from the pole"
2851            );
2852        }
2853    }
2854
2855    #[test]
2856    fn a_point_outside_a_box_is_outside_it() {
2857        let mut model = Model::new();
2858        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
2859
2860        for p in [
2861            Point::new(3.0, 1.0, 1.0),
2862            Point::new(-1.0, 1.0, 1.0),
2863            Point::new(1.0, 1.0, -0.5),
2864            Point::new(-5.0, -5.0, -5.0),
2865        ] {
2866            assert_eq!(
2867                classify_in_solid(&model, &built.shape, p, fine(), T).unwrap(),
2868                Containment::Out,
2869                "{p:?} should be outside"
2870            );
2871        }
2872    }
2873
2874    #[test]
2875    fn a_point_on_a_boxs_face_is_on_it_rather_than_forced_to_a_side() {
2876        // The case a two-valued classifier has to guess at, and the case a
2877        // boolean spends all its time in.
2878        let mut model = Model::new();
2879        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
2880
2881        for p in [
2882            Point::new(1.0, 1.0, 0.0), // face centre
2883            Point::new(0.0, 1.0, 1.0), // another face
2884            Point::new(2.0, 2.0, 1.0), // an edge
2885            Point::ORIGIN,             // a vertex
2886            Point::new(2.0, 2.0, 2.0), // the far vertex
2887        ] {
2888            assert_eq!(
2889                classify_in_solid(&model, &built.shape, p, fine(), T).unwrap(),
2890                Containment::On,
2891                "{p:?} should be on the boundary"
2892            );
2893        }
2894    }
2895
2896    #[test]
2897    fn a_ray_along_a_grid_of_edges_does_not_defeat_the_classifier() {
2898        // A box tessellated into two triangles per face has a diagonal across
2899        // every face and an edge along every side. An axis-aligned ray from the
2900        // centre runs straight into a face centre; a diagonal one can run along
2901        // a triangle edge. The retry is what makes either survivable.
2902        let mut model = Model::new();
2903        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
2904
2905        // The centre of a cube: every axis-aligned ray hits a face centre, and
2906        // the main diagonal goes through a vertex.
2907        assert_eq!(
2908            classify_in_solid(&model, &built.shape, Point::new(1.0, 1.0, 1.0), fine(), T).unwrap(),
2909            Containment::In
2910        );
2911    }
2912
2913    #[test]
2914    fn an_open_shell_has_no_inside() {
2915        let mut model = Model::new();
2916        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
2917        let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
2918        assert!(classify_in_solid(&model, &face, Point::ORIGIN, fine(), T).is_err());
2919    }
2920
2921    #[test]
2922    fn a_point_on_a_face_is_inside_its_trimming_or_not() {
2923        let mut model = Model::new();
2924        let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
2925        // The face at z = 0, spanning x in [0, 2] and y in [0, 3]. Found by
2926        // the role make_box gave it, which is what provenance is for.
2927        let bottom = explore_unique(&model, &built.shape, ShapeType::Face)
2928            .unwrap()
2929            .into_iter()
2930            .find(|f| {
2931                model
2932                    .provenance_of(f)
2933                    .and_then(ogeom_core::Provenance::role)
2934                    == Some(crate::primitive::roles::FACE_MIN_Z)
2935            })
2936            .expect("the box has a face at z = 0");
2937
2938        assert_eq!(
2939            classify_on_face(&model, &bottom, Point::new(1.0, 1.5, 0.0), fine(), T).unwrap(),
2940            Containment::In
2941        );
2942        assert_eq!(
2943            classify_on_face(&model, &bottom, Point::new(5.0, 1.5, 0.0), fine(), T).unwrap(),
2944            Containment::Out,
2945            "on the surface's plane but outside the trimming"
2946        );
2947        assert_eq!(
2948            classify_on_face(&model, &bottom, Point::new(1.0, 1.5, 1.0), fine(), T).unwrap(),
2949            Containment::Out,
2950            "off the surface entirely"
2951        );
2952        assert_eq!(
2953            classify_on_face(&model, &bottom, Point::new(0.0, 1.5, 0.0), fine(), T).unwrap(),
2954            Containment::On,
2955            "on the trimming boundary"
2956        );
2957    }
2958
2959    #[test]
2960    fn the_answers_invert_the_way_a_complement_does() {
2961        assert_eq!(Containment::In.inverted(), Containment::Out);
2962        assert_eq!(Containment::Out.inverted(), Containment::In);
2963        // A boundary belongs to both sides, so complementing leaves it alone.
2964        assert_eq!(Containment::On.inverted(), Containment::On);
2965
2966        assert!(Containment::In.is_inside_or_on());
2967        assert!(Containment::On.is_inside_or_on());
2968        assert!(!Containment::Out.is_inside_or_on());
2969    }
2970
2971    #[test]
2972    fn a_translated_box_classifies_the_same_way_translated_points() {
2973        let offset = Vector::new(10.0, -20.0, 30.0);
2974        let mut model = Model::new();
2975        let frame = Frame::new(Point::ORIGIN + offset, Direction::Z, Direction::X, T).unwrap();
2976        let built = make_box(&mut model, frame, (2.0, 2.0, 2.0), T).unwrap();
2977
2978        assert_eq!(
2979            classify_in_solid(
2980                &model,
2981                &built.shape,
2982                Point::new(1.0, 1.0, 1.0) + offset,
2983                fine(),
2984                T
2985            )
2986            .unwrap(),
2987            Containment::In
2988        );
2989        assert_eq!(
2990            classify_in_solid(&model, &built.shape, Point::new(1.0, 1.0, 1.0), fine(), T).unwrap(),
2991            Containment::Out,
2992            "the untranslated point is nowhere near the translated box"
2993        );
2994    }
2995
2996    #[test]
2997    fn the_exact_classifier_agrees_with_the_tessellated_one_on_a_box() {
2998        let mut model = Model::new();
2999        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
3000
3001        for (p, want) in [
3002            (Point::new(1.0, 1.0, 1.0), Containment::In),
3003            (Point::new(0.1, 0.1, 0.1), Containment::In),
3004            (Point::new(3.0, 1.0, 1.0), Containment::Out),
3005            (Point::new(1.0, 1.0, -0.5), Containment::Out),
3006            (Point::new(-50.0, -50.0, -50.0), Containment::Out),
3007            (Point::new(1.0, 1.0, 0.0), Containment::On),
3008            (Point::new(2.0, 2.0, 1.0), Containment::On),
3009            (Point::ORIGIN, Containment::On),
3010        ] {
3011            assert_eq!(
3012                classify_in_solid_exact(&model, &built.shape, p, T).unwrap(),
3013                want,
3014                "{p:?}"
3015            );
3016        }
3017    }
3018
3019    #[test]
3020    fn the_exact_classifier_resolves_what_the_deflection_band_cannot() {
3021        // The reason this function exists. A point a micron off a sphere's
3022        // wall is far inside any practical deflection band; the tessellated
3023        // classifier must say On, because against a mesh it genuinely cannot
3024        // tell. Against the true sphere the side is knowable, and known.
3025        let mut model = Model::new();
3026        let built = crate::make_sphere(&mut model, Frame::WORLD, 2.0, T).unwrap();
3027
3028        let barely_in = Point::new(0.0, 0.0, 2.0 - 1e-5);
3029        let barely_out = Point::new(0.0, 0.0, 2.0 + 1e-5);
3030
3031        assert_eq!(
3032            classify_in_solid(&model, &built.shape, barely_in, fine(), T).unwrap(),
3033            Containment::On,
3034            "the mesh cannot tell a micron from the wall"
3035        );
3036        assert_eq!(
3037            classify_in_solid_exact(&model, &built.shape, barely_in, T).unwrap(),
3038            Containment::In
3039        );
3040        assert_eq!(
3041            classify_in_solid_exact(&model, &built.shape, barely_out, T).unwrap(),
3042            Containment::Out
3043        );
3044        // Exactly on the wall: On, decided by projection, not by a ray.
3045        assert_eq!(
3046            classify_in_solid_exact(&model, &built.shape, Point::new(0.0, 0.0, 2.0), T).unwrap(),
3047            Containment::On
3048        );
3049    }
3050
3051    #[test]
3052    fn the_exact_classifier_handles_a_cylinder_wall_and_caps() {
3053        let mut model = Model::new();
3054        let built = crate::make_cylinder(&mut model, Frame::WORLD, 1.5, 4.0, T).unwrap();
3055
3056        for (p, want) in [
3057            (Point::new(0.0, 0.0, 2.0), Containment::In),
3058            (Point::new(1.5 - 1e-5, 0.0, 2.0), Containment::In),
3059            (Point::new(1.5 + 1e-5, 0.0, 2.0), Containment::Out),
3060            (Point::new(0.3, 0.4, 4.0 - 1e-5), Containment::In),
3061            (Point::new(0.3, 0.4, 4.0 + 1e-5), Containment::Out),
3062            (Point::new(1.5, 0.0, 2.0), Containment::On),
3063            (Point::new(0.3, 0.4, 0.0), Containment::On),
3064        ] {
3065            assert_eq!(
3066                classify_in_solid_exact(&model, &built.shape, p, T).unwrap(),
3067                want,
3068                "{p:?}"
3069            );
3070        }
3071    }
3072
3073    #[test]
3074    fn the_exact_classifier_walks_the_general_path_through_a_torus() {
3075        // No analytic ray/torus case exists, so every crossing here came from
3076        // the seeded Newton path, and a torus also puts the hole in the
3077        // middle, where a ray to the outside crosses the tube wall twice.
3078        let mut model = Model::new();
3079        let built = crate::make_torus(&mut model, Frame::WORLD, 3.0, 1.0, T).unwrap();
3080
3081        for (p, want) in [
3082            (Point::new(3.0, 0.0, 0.0), Containment::In),
3083            (Point::new(3.0, 0.0, 0.9), Containment::In),
3084            (Point::ORIGIN, Containment::Out),
3085            (Point::new(3.0, 0.0, 1.5), Containment::Out),
3086            (Point::new(5.0, 5.0, 0.0), Containment::Out),
3087            (Point::new(3.0, 0.0, 1.0), Containment::On),
3088        ] {
3089            assert_eq!(
3090                classify_in_solid_exact(&model, &built.shape, p, T).unwrap(),
3091                want,
3092                "{p:?}"
3093            );
3094        }
3095    }
3096
3097    #[test]
3098    fn the_exact_classifier_respects_a_placed_solid() {
3099        let offset = Vector::new(10.0, -20.0, 30.0);
3100        let mut model = Model::new();
3101        let frame = Frame::new(Point::ORIGIN + offset, Direction::Z, Direction::X, T).unwrap();
3102        let built = make_box(&mut model, frame, (2.0, 2.0, 2.0), T).unwrap();
3103
3104        assert_eq!(
3105            classify_in_solid_exact(&model, &built.shape, Point::new(1.0, 1.0, 1.0) + offset, T)
3106                .unwrap(),
3107            Containment::In
3108        );
3109        assert_eq!(
3110            classify_in_solid_exact(&model, &built.shape, Point::new(1.0, 1.0, 1.0), T).unwrap(),
3111            Containment::Out
3112        );
3113    }
3114
3115    #[test]
3116    fn the_exact_classifier_refuses_an_open_boundary() {
3117        let mut model = Model::new();
3118        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
3119        let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
3120        assert!(classify_in_solid_exact(&model, &face, Point::ORIGIN, T).is_err());
3121    }
3122
3123    #[test]
3124    fn distance_to_a_triangle_is_measured_from_the_nearest_part_of_it() {
3125        let t = [
3126            Point::ORIGIN,
3127            Point::new(1.0, 0.0, 0.0),
3128            Point::new(0.0, 1.0, 0.0),
3129        ];
3130        // Above the interior: the plane distance.
3131        assert!((distance_to_triangle(Point::new(0.25, 0.25, 2.0), t) - 2.0).abs() < 1e-12);
3132        // Beyond a vertex: the distance to that vertex.
3133        assert!((distance_to_triangle(Point::new(-3.0, 0.0, 0.0), t) - 3.0).abs() < 1e-12);
3134        // On it: nothing.
3135        assert!(distance_to_triangle(Point::new(0.25, 0.25, 0.0), t) < 1e-12);
3136    }
3137
3138    /// A solid whose vertices stand one along each of the six ray
3139    /// directions from `apex`, about which it is convex: every ray from
3140    /// `apex` leaves through a vertex.
3141    fn every_ray_through_a_vertex(model: &mut Model, apex: Point) -> Shape {
3142        let positions: Vec<Point> = RAY_DIRECTIONS
3143            .iter()
3144            .map(|d| apex + Vector::new(d[0], d[1], d[2]))
3145            .collect();
3146        // The convex hull of the six directions, its facets each wound
3147        // outward, away from the apex inside it.
3148        let hull: [[u32; 3]; 8] = [
3149            [3, 1, 4],
3150            [5, 2, 4],
3151            [5, 3, 4],
3152            [5, 3, 2],
3153            [0, 2, 4],
3154            [0, 1, 4],
3155            [0, 3, 2],
3156            [0, 3, 1],
3157        ];
3158        let triangles = hull
3159            .iter()
3160            .map(|&[a, b, c]| {
3161                let [pa, pb, pc] = [a, b, c].map(|i| positions[i as usize]);
3162                if (pb - pa).cross(pc - pa).dot(pa - apex) > 0.0 {
3163                    [a, b, c]
3164                } else {
3165                    [a, c, b]
3166                }
3167            })
3168            .collect();
3169        let mesh = ogeom_topo::Triangulation {
3170            positions,
3171            triangles,
3172            ..ogeom_topo::Triangulation::default()
3173        };
3174        let options = crate::mesh_solid::MeshSolidOptions {
3175            recognize: false,
3176            ..crate::mesh_solid::MeshSolidOptions::default()
3177        };
3178        crate::mesh_solid::solid_from_mesh(model, &mesh, &options, T)
3179            .unwrap()
3180            .shape
3181    }
3182
3183    /// No ray from a point inside a solid counts, each meeting a vertex;
3184    /// the winding number answers instead. The point is inside by
3185    /// construction, a third of a unit from every facet of the convex
3186    /// solid.
3187    #[test]
3188    fn a_point_every_ray_from_which_meets_a_vertex_is_read_by_its_winding_number() {
3189        let mut model = Model::new();
3190        let apex = Point::new(0.3, 0.2, 0.1);
3191        let solid = every_ray_through_a_vertex(&mut model, apex);
3192
3193        let boundary = SolidBoundary::of(&model, &solid, T.confusion() * 1e4, T).unwrap();
3194        assert_eq!(boundary.cast(&model, apex, T).unwrap(), None);
3195        assert_eq!(boundary.holds(&model, apex, T).unwrap(), Containment::In);
3196
3197        let meshed = SolidMesh::of(&model, &solid, fine(), T).unwrap();
3198        for d in RAY_DIRECTIONS {
3199            let ray = Direction::new(Vector::new(d[0], d[1], d[2]), T).unwrap();
3200            assert_eq!(count_crossings(&meshed.triangles, apex, ray, T), None);
3201        }
3202        assert_eq!(meshed.holds(apex, T).unwrap(), Containment::In);
3203
3204        // A facet's centroid is on the boundary, whatever its rays meet.
3205        let [a, b, c] = [0, 1, 3].map(|i| {
3206            let d = RAY_DIRECTIONS[i];
3207            apex + Vector::new(d[0], d[1], d[2])
3208        });
3209        let on = Point::new(
3210            (a.x + b.x + c.x) / 3.0,
3211            (a.y + b.y + c.y) / 3.0,
3212            (a.z + b.z + c.z) / 3.0,
3213        );
3214        assert_eq!(boundary.holds(&model, on, T).unwrap(), Containment::On);
3215        assert_eq!(meshed.holds(on, T).unwrap(), Containment::On);
3216    }
3217
3218    /// A boundary with a face missing winds a fraction of a turn about a
3219    /// point it half encloses, and two solids overlapping wind twice about
3220    /// their common part: neither is read as a side.
3221    #[test]
3222    fn an_open_or_doubled_boundary_is_not_read_by_its_winding_number() {
3223        let mut model = Model::new();
3224        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
3225        let faces = explore_unique(&model, &built.shape, ShapeType::Face).unwrap();
3226        let open = crate::make_shell(&mut model, &faces[1..]).unwrap().shape;
3227        let boundary = SolidBoundary::of_closed(&model, &open, T.confusion() * 1e4, T).unwrap();
3228        assert_eq!(boundary.wound(&model, Point::new(1.0, 1.0, 1.0), T), None);
3229
3230        let moved = Frame::WORLD.with_origin(Point::new(1.0, 1.0, 1.0));
3231        let other = make_box(&mut model, moved, (2.0, 2.0, 2.0), T).unwrap();
3232        let both = crate::make_compound(&mut model, &[built.shape.clone(), other.shape])
3233            .unwrap()
3234            .shape;
3235        let boundary = SolidBoundary::of(&model, &both, T.confusion() * 1e4, T).unwrap();
3236        assert_eq!(boundary.wound(&model, Point::new(1.5, 1.5, 1.5), T), None);
3237        // Inside one of the two only, it winds once.
3238        assert_eq!(
3239            boundary.wound(&model, Point::new(0.5, 0.5, 0.5), T),
3240            Some(Containment::In)
3241        );
3242        assert_eq!(
3243            boundary.wound(&model, Point::new(3.5, 0.5, 0.5), T),
3244            Some(Containment::Out)
3245        );
3246    }
3247
3248    #[test]
3249    fn an_unusable_deflection_is_refused() {
3250        let mut model = Model::new();
3251        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
3252        let bad = Deflection {
3253            chord: f64::NAN,
3254            ..Deflection::default()
3255        };
3256        assert!(classify_in_solid(&model, &built.shape, Point::ORIGIN, bad, T).is_err());
3257    }
3258}