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::{Deflection, face_boundary, inside_boundary, triangulate};
25use ogeom_topo::{Model, NodeData, Shape, ShapeType};
26
27use crate::measure::project_on_surface;
28
29/// Where a point sits relative to a shape.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Containment {
32    /// Strictly inside.
33    In,
34    /// On the boundary, within tolerance of it.
35    On,
36    /// Strictly outside.
37    Out,
38}
39
40impl Containment {
41    /// Whether the point is inside or on the boundary.
42    #[must_use]
43    pub const fn is_inside_or_on(self) -> bool {
44        matches!(self, Self::In | Self::On)
45    }
46
47    /// The classification of the same point against the complement.
48    ///
49    /// `In` and `Out` swap; `On` is its own opposite, since a boundary is
50    /// shared by both sides.
51    #[must_use]
52    pub const fn inverted(self) -> Self {
53        match self {
54            Self::In => Self::Out,
55            Self::On => Self::On,
56            Self::Out => Self::In,
57        }
58    }
59}
60
61/// Where a point sits relative to a face.
62///
63/// A point off the face's surface is [`Containment::Out`]: a face is a patch of
64/// surface, so "inside" can only mean inside its trimming, and a point in space
65/// that does not lie on the surface at all is not inside anything.
66///
67/// # Errors
68///
69/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `face` is not a
70/// face, or the deflection settings are unusable;
71/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle fails to
72/// resolve.
73pub fn classify_on_face(
74    model: &Model,
75    face: &Shape,
76    point: Point,
77    deflection: Deflection,
78    tol: Tolerances,
79) -> OgeomResult<Containment> {
80    deflection.validate()?;
81    if model.kind_of(face)? != ShapeType::Face {
82        ogeom_bail!(Construction, "expected a face");
83    }
84    let Some(node) = model.node(face) else {
85        ogeom_bail!(Dangling, "face is not in this model");
86    };
87    let NodeData::Face(data) = node.data() else {
88        ogeom_bail!(Construction, "face node holds no face data");
89    };
90    let Some(surface) = model.geometry().surface(data.surface) else {
91        ogeom_bail!(Dangling, "face refers to a surface not in this model");
92    };
93
94    // Into the surface's own frame first: the trimming lives in parameter
95    // space, and the face may be placed anywhere.
96    let placement = face.transform(model.datums())?;
97    let local = placement.inverse()?.apply(point);
98
99    // A grid dense enough to bracket a foot point on a surface that folds:
100    // too coarse and Newton starts in the wrong basin and converges on a far
101    // side of a cylinder.
102    let projection = project_on_surface(surface, local, 32, tol)?;
103    let reach = tol.confusion().max(data.tolerance.get());
104    if projection.distance > reach {
105        return Ok(Containment::Out);
106    }
107
108    let rings = face_boundary(model, face, deflection, tol)?;
109    let (u, v) = projection.parameters;
110    let at = fold_toward_rings(surface, &rings, Point2::new(u, v));
111
112    // The uncertain band, converted from a distance in space into one in
113    // parameter units through the surface's own scale. A fixed parameter
114    // tolerance would be metres wide at a sphere's equator and nothing at its
115    // pole. The rings are polylines drawn at the caller's deflection, so the
116    // band carries that sag too: without it, a point between a tangent chord
117    // and its arc (inside the true trim, outside the sampled one) would
118    // read as Out when the honest answer at this resolution is On.
119    let band = parametric_band(surface, (u, v), reach + deflection.chord, tol);
120    if distance_to_rings(&rings, at) <= band {
121        return Ok(Containment::On);
122    }
123    Ok(if inside_boundary(&rings, at) {
124        Containment::In
125    } else {
126        Containment::Out
127    })
128}
129
130/// Where a point sits relative to a closed shell or solid.
131///
132/// Ray casting against the tessellation: a ray from the point crosses the
133/// boundary an odd number of times if and only if it started inside.
134///
135/// # Errors
136///
137/// As [`triangulate()`], plus
138/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the boundary is
139/// not closed (an open shell has no inside), and
140/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if every ray tried hit an
141/// edge or a vertex, where the crossing count is ambiguous.
142pub fn classify_in_solid(
143    model: &Model,
144    solid: &Shape,
145    point: Point,
146    deflection: Deflection,
147    tol: Tolerances,
148) -> OgeomResult<Containment> {
149    deflection.validate()?;
150    let mesh = triangulate(model, solid, deflection, tol)?;
151    if mesh.is_empty() || !mesh.is_closed() {
152        ogeom_bail!(
153            Construction,
154            "the boundary is not closed, so there is no inside to be in"
155        );
156    }
157
158    let triangles: Vec<[Point; 3]> = mesh
159        .triangles
160        .iter()
161        .map(|t| t.map(|i| mesh.positions[i as usize]))
162        .collect();
163
164    // On the boundary beats either side, and is decided in space rather than
165    // along a ray: a point sitting on a face is on the boundary from every
166    // direction, and no crossing count says so.
167    let reach = tol.confusion() + deflection.chord;
168    for t in &triangles {
169        if distance_to_triangle(point, *t) <= reach {
170            return Ok(Containment::On);
171        }
172    }
173
174    // A ray that grazes an edge or passes through a vertex is counted once by
175    // one triangle and twice by its neighbour, or not at all. Rather than
176    // patch the count, notice the near-miss and cast again somewhere else.
177    for direction in RAY_DIRECTIONS {
178        let ray = Direction::new(Vector::new(direction[0], direction[1], direction[2]), tol)?;
179        if let Some(crossings) = count_crossings(&triangles, point, ray, tol) {
180            return Ok(if crossings % 2 == 1 {
181                Containment::In
182            } else {
183                Containment::Out
184            });
185        }
186    }
187    ogeom_bail!(
188        NotDone,
189        "every ray tried met an edge or a vertex, where the crossing count is \
190         ambiguous"
191    )
192}
193
194/// Where a point sits relative to a closed shell or solid, decided against
195/// the true surfaces.
196///
197/// This is the promise the tessellated classifier's documentation makes on
198/// behalf of "a later layer", kept: rays are cast against each face's actual
199/// geometry through the curve/surface intersector, so the band where the
200/// answer is *On* rather than a side is the tolerance, not the deflection. A
201/// point a micron off a sphere's wall classifies as the side it is on;
202/// [`classify_in_solid`] at any practical deflection could only say *On*.
203///
204/// The crossing-parity argument is the same as the tessellated one, and so is
205/// the discipline about degenerate hits. A ray that grazes a surface
206/// tangentially, meets a face too near its boundary to be sure which side of
207/// the trim it crossed, lies *in* a face's surface, or passes through a
208/// pole or an apex, is not patched into a count; the ray is abandoned and
209/// the next direction tried. Six directions, deterministic, none axis-aligned.
210///
211/// # Errors
212///
213/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the shape is
214/// not a solid or a shell, or its boundary is not closed;
215/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if every ray met a
216/// degeneracy, which a handful of deliberately skew directions makes an
217/// engineered case rather than an encountered one.
218pub fn classify_in_solid_exact(
219    model: &Model,
220    solid: &Shape,
221    point: Point,
222    tol: Tolerances,
223) -> OgeomResult<Containment> {
224    classify_in_solid_exact_banded(model, solid, point, tol.confusion() * 1e4, tol)
225}
226
227/// [`classify_in_solid_exact`] with the boundary band under the caller's
228/// control.
229///
230/// The band is the ring polylines' chord tolerance, and with it the width of
231/// the region that answers `On`. The default is generous (a boolean wants a
232/// piece near a boundary called On and resolved against its partner), but a
233/// caller that got On *without* a partner to resolve against needs to ask
234/// again at a width where proximity stops impersonating coincidence.
235///
236/// # Errors
237///
238/// As [`classify_in_solid_exact`].
239pub fn classify_in_solid_exact_banded(
240    model: &Model,
241    solid: &Shape,
242    point: Point,
243    ring_chord: f64,
244    tol: Tolerances,
245) -> OgeomResult<Containment> {
246    SolidBoundary::of(model, solid, ring_chord, tol)?.holds(model, point, tol)
247}
248
249/// One face of a prepared boundary, with everything about it that does not
250/// depend on the point being classified.
251#[derive(Debug)]
252struct PreparedFace {
253    face: Shape,
254    surface: ogeom_geom::SurfaceGeometry,
255    /// The placement's inverse, for carrying a point into the surface's frame.
256    inverse: ogeom_math::Transform,
257    /// The trimming rings, polylined at the boundary's stated chord.
258    rings: Vec<Vec<Point2>>,
259    /// Where the face can be, padded past anything its bound could miss: a
260    /// point outside is not on it, and a ray missing it does not cross it.
261    bound: Aabb,
262}
263
264/// A solid's boundary, prepared once and asked about many points.
265///
266/// Classifying a point casts rays and counts crossings, which is cheap. What
267/// is not cheap is what the rays are cast *against*: every face's trimming
268/// rings, polylined, plus its placement's inverse. That work depends on the
269/// solid and the chord, never on the point, and asked point by point it was
270/// redone from scratch every time, which for a boolean means once per face
271/// piece. Measured on a four-hole cut, preparing took 3.5 ms against 5.6 µs
272/// of ray casting: six hundred times the work of the question being asked.
273///
274/// `docs/PLAN.md` §A named this as owed and said what it was worth.
275#[derive(Debug)]
276pub struct SolidBoundary {
277    faces: Vec<PreparedFace>,
278    bound: ogeom_math::Aabb,
279    centre: Point,
280    diagonal: f64,
281    ring_chord: f64,
282}
283
284impl SolidBoundary {
285    /// Prepare a solid's boundary for classification at a given ring chord.
286    ///
287    /// # Errors
288    ///
289    /// As [`classify_in_solid_exact`].
290    pub fn of(model: &Model, solid: &Shape, ring_chord: f64, tol: Tolerances) -> OgeomResult<Self> {
291        Self::prepare(model, solid, ring_chord, tol)
292    }
293
294    fn prepare(
295        model: &Model,
296        solid: &Shape,
297        ring_chord: f64,
298        tol: Tolerances,
299    ) -> OgeomResult<Self> {
300        let kind = model.kind_of(solid)?;
301        if !matches!(kind, ShapeType::Solid | ShapeType::Shell) {
302            ogeom_bail!(Construction, "expected a solid or a shell, got {kind:?}");
303        }
304        let shells = if kind == ShapeType::Shell {
305            vec![solid.clone()]
306        } else {
307            ogeom_topo::explore_unique(model, solid, ShapeType::Shell)?
308        };
309        if shells.is_empty() {
310            ogeom_bail!(Construction, "the shape has no shell, so no boundary");
311        }
312        for shell in &shells {
313            if !crate::build::is_shell_closed(model, shell)? {
314                ogeom_bail!(
315                    Construction,
316                    "the boundary is not closed, so there is no inside to be in"
317                );
318            }
319        }
320
321        // Anything outside the shape's bound is outside the shape, and the bound
322        // also sets how long a ray must be to have left everything behind.
323        let bound = crate::measure::shape_bounds(model, solid, tol)?;
324        let (Some(centre), diagonal) = (bound.centre(), bound.diagonal()) else {
325            ogeom_bail!(Construction, "the boundary bounds nothing");
326        };
327
328        // The rings' own polylining error, spatially: they are only used to
329        // decide which side of a face's trim a crossing landed, and a crossing
330        // nearer the ring than this is ambiguous rather than decided.
331        let ring_deflection = Deflection {
332            chord: ring_chord,
333            angular: 0.05,
334            ..Deflection::default()
335        };
336
337        let faces = ogeom_topo::explore_unique(model, solid, ShapeType::Face)?;
338        // One prepared face per face, in face order, computed in parallel:
339        // each preparation reads the model and writes nothing, and walking a
340        // face's trimming rings is the whole cost of building a boundary:
341        // 84% of the boolean's split stage before this ran wide.
342        let prepared = ogeom_core::parallel::map_ordered(&faces, |_, face| {
343            ogeom_core::progress::checkpoint()?;
344            let Some(node) = model.node(face) else {
345                ogeom_bail!(Dangling, "face is not in this model");
346            };
347            let NodeData::Face(data) = node.data() else {
348                ogeom_bail!(Construction, "face node holds no face data");
349            };
350            let Some(surface) = model.geometry().surface(data.surface) else {
351                ogeom_bail!(Dangling, "face refers to a surface not in this model");
352            };
353            let inverse = face.transform(model.datums())?.inverse()?;
354            let rings = face_boundary(model, face, ring_deflection, tol)?;
355            let own = crate::measure::shape_bounds(model, face, tol)?;
356            let bound = own.expanded(
357                ring_chord + data.tolerance.get() + tol.confusion() * 1e2 + own.diagonal() * 0.02,
358            );
359            Ok(PreparedFace {
360                face: face.clone(),
361                surface: surface.clone(),
362                inverse,
363                rings,
364                bound,
365            })
366        })
367        .into_iter()
368        .collect::<OgeomResult<Vec<_>>>()?;
369        Ok(Self {
370            faces: prepared,
371            bound,
372            centre,
373            diagonal,
374            ring_chord,
375        })
376    }
377
378    /// Where a point stands against this boundary.
379    ///
380    /// # Errors
381    ///
382    /// As [`classify_in_solid_exact`].
383    pub fn holds(&self, model: &Model, point: Point, tol: Tolerances) -> OgeomResult<Containment> {
384        let ring_chord = self.ring_chord;
385        let ring_deflection = Deflection {
386            chord: ring_chord,
387            angular: 0.05,
388            ..Deflection::default()
389        };
390        let reach = tol.confusion();
391        if !self.bound.expanded(reach).contains(point) {
392            return Ok(Containment::Out);
393        }
394        let length = point.distance(self.centre) + self.diagonal + 1.0;
395
396        // On the boundary beats either side, and each face answers exactly:
397        // projection distance against the true surface, trimming in parameter
398        // space.
399        for prepared in &self.faces {
400            if !prepared.bound.contains(point) {
401                continue;
402            }
403            if classify_on_face(model, &prepared.face, point, ring_deflection, tol)?
404                != Containment::Out
405            {
406                return Ok(Containment::On);
407            }
408        }
409        'directions: for direction in RAY_DIRECTIONS {
410            let along = Vector::new(direction[0], direction[1], direction[2]);
411            let far = point + along * length;
412            let mut crossings = 0_usize;
413
414            for PreparedFace {
415                surface,
416                inverse,
417                rings,
418                bound,
419                ..
420            } in &self.faces
421            {
422                if !segment_meets(bound, point, far) {
423                    continue;
424                }
425                // Into the face's frame, as two points rather than a direction, so
426                // a placement that scales still carries the ray faithfully.
427                let from = inverse.apply(point);
428                let to = inverse.apply(far);
429                let ray: ogeom_geom::Curve = ogeom_geom::LineCurve::segment(from, to, tol)?.into();
430                let found = ogeom_intersect::intersect_curve_surface(
431                    &ray,
432                    surface,
433                    ogeom_intersect::CurveSurfaceOptions::default(),
434                    tol,
435                )?;
436                if !found.lying.is_empty() {
437                    // The ray runs in this face's surface: it crosses nothing and
438                    // touches everything, which no parity expresses.
439                    continue 'directions;
440                }
441                for hit in &found.crossings {
442                    if hit.on_curve <= tol.confusion() {
443                        // At the very start: the probe lies in this face's
444                        // *surface*. Whether that matters depends on the trim:
445                        // the boundary test above already said the point is off
446                        // every face, so a start-crossing far from this face's
447                        // rings is the unbounded surface talking, not the face,
448                        // and it neither counts nor poisons the ray.
449                        let (u, v) = hit.on_surface;
450                        let at = fold_toward_rings(surface, rings, Point2::new(u, v));
451                        let band = parametric_band(surface, (u, v), reach + ring_chord, tol);
452                        if distance_to_rings(rings, at) <= band || inside_boundary(rings, at) {
453                            continue 'directions;
454                        }
455                        continue;
456                    }
457                    let (u, v) = hit.on_surface;
458                    use ogeom_geom::Surface as _;
459                    let Ok((du, dv)) = surface.d1_at(u, v, tol) else {
460                        continue 'directions;
461                    };
462                    let normal = du.cross(dv);
463                    if normal.magnitude() <= tol.confusion() {
464                        // A pole or an apex: no normal, no transversality.
465                        continue 'directions;
466                    }
467                    let ray_direction = (to - from) / (to - from).magnitude();
468                    if normal.dot(ray_direction).abs() <= GRAZING * normal.magnitude() {
469                        // Tangential. A grazing contact counts once where parity
470                        // needs zero or two; abandon the ray rather than guess.
471                        continue 'directions;
472                    }
473                    let at = fold_toward_rings(surface, rings, Point2::new(u, v));
474                    let band = parametric_band(surface, (u, v), reach + ring_chord, tol);
475                    if distance_to_rings(rings, at) <= band {
476                        // Too near the face's boundary to know which side of the
477                        // trim it crossed, and a shared edge would be counted by
478                        // both faces or neither.
479                        continue 'directions;
480                    }
481                    if inside_boundary(rings, at) {
482                        crossings += 1;
483                    }
484                }
485            }
486            return Ok(if crossings % 2 == 1 {
487                Containment::In
488            } else {
489                Containment::Out
490            });
491        }
492        ogeom_bail!(
493            NotDone,
494            "every ray tried met a tangency, a boundary, or a degenerate point, \
495             where the crossing count is ambiguous"
496        )
497    }
498}
499
500/// The sine of the shallowest crossing angle a counted ray/surface crossing
501/// may make.
502///
503/// Below this the hit is treated as a graze: a tangential contact is one
504/// crossing where parity arithmetic needs zero or two, and the seed of the
505/// threshold is the same as the marching intersector's `SHALLOWEST`: beneath
506/// a microradian, rounding in the evaluated normal can no longer tell a
507/// crossing from a touch.
508const GRAZING: f64 = 1e-6;
509
510/// Directions to cast rays along, tried in order.
511///
512/// Deterministic, not random: a classifier that gives different answers on
513/// different runs is worse than one that fails, because the failure can be
514/// handled and the inconsistency cannot. They are deliberately not axis-aligned
515/// and share no common plane, so a mesh built on a regular grid (where an
516/// axis-aligned ray runs along a whole row of edges) does not defeat all of
517/// them at once.
518const RAY_DIRECTIONS: [[f64; 3]; 6] = [
519    [0.577_35, 0.577_35, 0.577_35],
520    [-0.301_5, 0.904_5, 0.301_5],
521    [0.727_6, -0.485_1, 0.485_1],
522    [0.259_5, 0.259_5, -0.930_0],
523    [-0.816_5, -0.408_2, 0.408_2],
524    [0.132_5, -0.662_3, -0.737_5],
525];
526
527/// Count how many triangles a ray from `from` crosses, or `None` if any hit was
528/// too close to an edge or vertex to count reliably.
529fn count_crossings(
530    triangles: &[[Point; 3]],
531    from: Point,
532    along: Direction,
533    tol: Tolerances,
534) -> Option<usize> {
535    let mut crossings = 0;
536    for t in triangles {
537        match ray_hits_triangle(from, along, *t, tol) {
538            Hit::Crosses => crossings += 1,
539            Hit::Misses => {}
540            Hit::Ambiguous => return None,
541        }
542    }
543    Some(crossings)
544}
545
546/// What a ray did to a triangle.
547enum Hit {
548    /// Passed through its interior, ahead of the start.
549    Crosses,
550    /// Did not meet it.
551    Misses,
552    /// Met an edge, a vertex, or the plane edge-on, where counting it once is
553    /// as defensible as counting it twice or not at all.
554    Ambiguous,
555}
556
557/// Möller–Trumbore, with the degenerate cases separated out rather than
558/// rounded away.
559fn ray_hits_triangle(from: Point, along: Direction, t: [Point; 3], tol: Tolerances) -> Hit {
560    let direction = along.vector();
561    let (e1, e2) = (t[1] - t[0], t[2] - t[0]);
562    let h = direction.cross(e2);
563    let determinant = e1.dot(h);
564
565    // Scale the comparison by the triangle: a determinant is a volume, so a
566    // fixed threshold rejects small triangles and accepts edge-on hits on
567    // large ones.
568    let scale = e1.magnitude() * e2.magnitude();
569    let flat = tol.confusion() * scale;
570    if determinant.abs() <= flat {
571        // Edge-on. It cannot cross cleanly, but it may lie in the plane and
572        // touch, which no crossing count expresses.
573        let normal = e1.cross(e2);
574        let reach = tol.confusion() * scale;
575        return if normal.dot(from - t[0]).abs() <= reach {
576            Hit::Ambiguous
577        } else {
578            Hit::Misses
579        };
580    }
581
582    let inverse = 1.0 / determinant;
583    let s = from - t[0];
584    let u = inverse * s.dot(h);
585    let q = s.cross(e1);
586    let v = inverse * direction.dot(q);
587    let w = 1.0 - u - v;
588
589    // Barycentric coordinates near zero mean the ray passed along an edge, and
590    // near one that it went through a vertex.
591    let edge = tol.confusion();
592    if [u, v, w].iter().any(|c| c.abs() <= edge) {
593        // Only ambiguous if the ray would otherwise have hit: a grazing miss
594        // well outside the triangle is a miss.
595        return if u >= -edge && v >= -edge && w >= -edge {
596            Hit::Ambiguous
597        } else {
598            Hit::Misses
599        };
600    }
601    if u < 0.0 || v < 0.0 || w < 0.0 {
602        return Hit::Misses;
603    }
604
605    let distance = inverse * e2.dot(q);
606    if distance <= tol.confusion() {
607        // Behind the start, or right at it, and right at it was already ruled
608        // out by the on-boundary test before any ray was cast.
609        return Hit::Misses;
610    }
611    Hit::Crosses
612}
613
614/// The distance from a point to a triangle.
615fn distance_to_triangle(p: Point, t: [Point; 3]) -> f64 {
616    // Clamp the projection onto the triangle's plane into the triangle, by
617    // checking the three edge regions and the interior. Solving the 2×2 normal
618    // equations directly and clamping is shorter than a region case analysis
619    // and gives the same closest point.
620    let (e1, e2) = (t[1] - t[0], t[2] - t[0]);
621    let d = t[0] - p;
622    let (a, b, c) = (e1.dot(e1), e1.dot(e2), e2.dot(e2));
623    let (dd, e) = (e1.dot(d), e2.dot(d));
624    let determinant = b.mul_add(-b, a * c);
625
626    if determinant.abs() <= f64::MIN_POSITIVE {
627        // A degenerate triangle is a segment; its edges still answer.
628        return edge_distance(p, t);
629    }
630    let mut s = b.mul_add(e, -(c * dd)) / determinant;
631    let mut u = b.mul_add(dd, -(a * e)) / determinant;
632
633    if s >= 0.0 && u >= 0.0 && s + u <= 1.0 {
634        let closest = t[0] + e1 * s + e2 * u;
635        return p.distance(closest);
636    }
637    // Outside: the closest point is on an edge.
638    s = s.clamp(0.0, 1.0);
639    u = u.clamp(0.0, 1.0);
640    let _ = (s, u);
641    edge_distance(p, t)
642}
643
644/// The distance from a point to the nearest of a triangle's three edges.
645fn edge_distance(p: Point, t: [Point; 3]) -> f64 {
646    let mut best = f64::INFINITY;
647    for i in 0..3 {
648        best = best.min(segment_distance(p, t[i], t[(i + 1) % 3]));
649    }
650    best
651}
652
653/// The distance from a point to a segment.
654fn segment_distance(p: Point, a: Point, b: Point) -> f64 {
655    let d = b - a;
656    let squared = d.dot(d);
657    if squared <= f64::MIN_POSITIVE {
658        return p.distance(a);
659    }
660    let t = ((p - a).dot(d) / squared).clamp(0.0, 1.0);
661    p.distance(a + d * t)
662}
663
664/// The distance in parameter space from a point to the nearest ring.
665pub(crate) fn distance_to_rings(rings: &[Vec<Point2>], p: Point2) -> f64 {
666    let mut best = f64::INFINITY;
667    for ring in rings {
668        for i in 0..ring.len() {
669            let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
670            best = best.min(segment_distance_2d(p, a, b));
671        }
672    }
673    best
674}
675
676/// The distance from a 2D point to a 2D segment.
677fn segment_distance_2d(p: Point2, a: Point2, b: Point2) -> f64 {
678    let d = b - a;
679    let squared = d.dot(d);
680    if squared <= f64::MIN_POSITIVE {
681        return p.distance(a);
682    }
683    let t = ((p - a).dot(d) / squared).clamp(0.0, 1.0);
684    p.distance(a + d * t)
685}
686
687/// How wide, in parameter units, a distance of `reach` in space is at `(u, v)`.
688///
689/// The surface's tangents give the conversion. Where a tangent vanishes (a
690/// sphere's pole, a cone's apex), no parameter distance corresponds to a
691/// spatial one, and the band opens to cover the whole neighbourhood rather than
692/// closing to nothing.
693/// Fold a chart point toward the rings' own window, one period at a time.
694///
695/// Projection and intersection answer parameters in a surface's principal
696/// range, but a face's trim may live in any window of a periodic chart: a
697/// band anchored where its rings happened to start. The trim tests compare
698/// against the rings, so the point folds to them, not the other way round.
699pub(crate) fn fold_toward_rings(
700    surface: &ogeom_geom::SurfaceGeometry,
701    rings: &[Vec<Point2>],
702    mut at: Point2,
703) -> Point2 {
704    use ogeom_geom::SurfaceGeometry as S;
705    let tau = core::f64::consts::TAU;
706    let (u_period, v_period) = match surface {
707        S::Cylinder(_) | S::Cone(_) => (Some(tau), None),
708        S::Sphere(_) => (Some(tau), None),
709        S::Torus(_) => (Some(tau), Some(tau)),
710        _ => (None, None),
711    };
712    let fold = |x: f64, lo: f64, hi: f64, period: f64| -> f64 {
713        let mut x = x;
714        while x < lo && x + period <= hi + period {
715            x += period;
716            if x >= lo {
717                break;
718            }
719        }
720        while x > hi && x - period >= lo - period {
721            x -= period;
722            if x <= hi {
723                break;
724            }
725        }
726        x
727    };
728    if let Some(period) = u_period {
729        let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
730        for ring in rings {
731            for q in ring {
732                lo = lo.min(q.x);
733                hi = hi.max(q.x);
734            }
735        }
736        if lo.is_finite() {
737            at.x = fold(at.x, lo, hi, period);
738        }
739    }
740    if let Some(period) = v_period {
741        let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
742        for ring in rings {
743            for q in ring {
744                lo = lo.min(q.y);
745                hi = hi.max(q.y);
746            }
747        }
748        if lo.is_finite() {
749            at.y = fold(at.y, lo, hi, period);
750        }
751    }
752    // The window alone cannot say which side of a seam a point is on when
753    // the seam runs diagonally round (a band opened along the widest gap
754    // its mesh left): both a point and its copy a period over lie within
755    // the rings' extent, only one inside them. The one inside is the face's.
756    if !inside_boundary(rings, at) {
757        let shifts = [
758            u_period.map(|p| (p, 0.0)),
759            u_period.map(|p| (-p, 0.0)),
760            v_period.map(|p| (0.0, p)),
761            v_period.map(|p| (0.0, -p)),
762        ];
763        if let Some(inside) = shifts
764            .into_iter()
765            .flatten()
766            .map(|(du, dv)| Point2::new(at.x + du, at.y + dv))
767            .find(|q| inside_boundary(rings, *q))
768        {
769            return inside;
770        }
771    }
772    at
773}
774
775pub(crate) fn parametric_band(
776    surface: &ogeom_geom::SurfaceGeometry,
777    at: (f64, f64),
778    reach: f64,
779    tol: Tolerances,
780) -> f64 {
781    use ogeom_geom::Surface;
782    let Ok((du, dv)) = surface.d1_at(at.0, at.1, tol) else {
783        return reach;
784    };
785    let scale = du.magnitude().min(dv.magnitude());
786    if scale <= tol.confusion() {
787        return f64::INFINITY;
788    }
789    reach / scale
790}
791
792/// Whether the segment from `a` to `b` passes through the box, by slabs.
793fn segment_meets(bound: &Aabb, a: Point, b: Point) -> bool {
794    let (Some(low), Some(high)) = (bound.low(), bound.high()) else {
795        return false;
796    };
797    let (mut enter, mut leave) = (0.0_f64, 1.0_f64);
798    for (from, to, lo, hi) in [
799        (a.x, b.x, low.x, high.x),
800        (a.y, b.y, low.y, high.y),
801        (a.z, b.z, low.z, high.z),
802    ] {
803        let d = to - from;
804        if d.abs() <= f64::EPSILON * (from.abs() + to.abs() + 1.0) {
805            if from < lo || from > hi {
806                return false;
807            }
808            continue;
809        }
810        let (t0, t1) = ((lo - from) / d, (hi - from) / d);
811        let (t0, t1) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
812        enter = enter.max(t0);
813        leave = leave.min(t1);
814        if enter > leave {
815            return false;
816        }
817    }
818    true
819}
820
821#[cfg(test)]
822#[allow(clippy::unwrap_used, clippy::expect_used)]
823mod tests {
824    use super::*;
825    use crate::make_box;
826    use ogeom_math::Frame;
827    use ogeom_topo::{ShapeType, explore_unique};
828
829    const T: Tolerances = Tolerances::millimetres();
830
831    fn fine() -> Deflection {
832        Deflection {
833            chord: 1e-3,
834            angular: 0.05,
835            ..Deflection::default()
836        }
837    }
838
839    #[test]
840    fn a_point_inside_a_box_is_inside_it() {
841        let mut model = Model::new();
842        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
843
844        for p in [
845            Point::new(1.0, 1.0, 1.0),
846            Point::new(0.1, 0.1, 0.1),
847            Point::new(1.9, 1.9, 1.9),
848        ] {
849            assert_eq!(
850                classify_in_solid(&model, &built.shape, p, fine(), T).unwrap(),
851                Containment::In,
852                "{p:?} should be inside"
853            );
854        }
855    }
856
857    #[test]
858    fn a_point_outside_a_box_is_outside_it() {
859        let mut model = Model::new();
860        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
861
862        for p in [
863            Point::new(3.0, 1.0, 1.0),
864            Point::new(-1.0, 1.0, 1.0),
865            Point::new(1.0, 1.0, -0.5),
866            Point::new(-5.0, -5.0, -5.0),
867        ] {
868            assert_eq!(
869                classify_in_solid(&model, &built.shape, p, fine(), T).unwrap(),
870                Containment::Out,
871                "{p:?} should be outside"
872            );
873        }
874    }
875
876    #[test]
877    fn a_point_on_a_boxs_face_is_on_it_rather_than_forced_to_a_side() {
878        // The case a two-valued classifier has to guess at, and the case a
879        // boolean spends all its time in.
880        let mut model = Model::new();
881        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
882
883        for p in [
884            Point::new(1.0, 1.0, 0.0), // face centre
885            Point::new(0.0, 1.0, 1.0), // another face
886            Point::new(2.0, 2.0, 1.0), // an edge
887            Point::ORIGIN,             // a vertex
888            Point::new(2.0, 2.0, 2.0), // the far vertex
889        ] {
890            assert_eq!(
891                classify_in_solid(&model, &built.shape, p, fine(), T).unwrap(),
892                Containment::On,
893                "{p:?} should be on the boundary"
894            );
895        }
896    }
897
898    #[test]
899    fn a_ray_along_a_grid_of_edges_does_not_defeat_the_classifier() {
900        // A box tessellated into two triangles per face has a diagonal across
901        // every face and an edge along every side. An axis-aligned ray from the
902        // centre runs straight into a face centre; a diagonal one can run along
903        // a triangle edge. The retry is what makes either survivable.
904        let mut model = Model::new();
905        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
906
907        // The centre of a cube: every axis-aligned ray hits a face centre, and
908        // the main diagonal goes through a vertex.
909        assert_eq!(
910            classify_in_solid(&model, &built.shape, Point::new(1.0, 1.0, 1.0), fine(), T).unwrap(),
911            Containment::In
912        );
913    }
914
915    #[test]
916    fn an_open_shell_has_no_inside() {
917        let mut model = Model::new();
918        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
919        let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
920        assert!(classify_in_solid(&model, &face, Point::ORIGIN, fine(), T).is_err());
921    }
922
923    #[test]
924    fn a_point_on_a_face_is_inside_its_trimming_or_not() {
925        let mut model = Model::new();
926        let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
927        // The face at z = 0, spanning x in [0, 2] and y in [0, 3]. Found by
928        // the role make_box gave it, which is what provenance is for.
929        let bottom = explore_unique(&model, &built.shape, ShapeType::Face)
930            .unwrap()
931            .into_iter()
932            .find(|f| {
933                model
934                    .provenance_of(f)
935                    .and_then(ogeom_core::Provenance::role)
936                    == Some(crate::primitive::roles::FACE_MIN_Z)
937            })
938            .expect("the box has a face at z = 0");
939
940        assert_eq!(
941            classify_on_face(&model, &bottom, Point::new(1.0, 1.5, 0.0), fine(), T).unwrap(),
942            Containment::In
943        );
944        assert_eq!(
945            classify_on_face(&model, &bottom, Point::new(5.0, 1.5, 0.0), fine(), T).unwrap(),
946            Containment::Out,
947            "on the surface's plane but outside the trimming"
948        );
949        assert_eq!(
950            classify_on_face(&model, &bottom, Point::new(1.0, 1.5, 1.0), fine(), T).unwrap(),
951            Containment::Out,
952            "off the surface entirely"
953        );
954        assert_eq!(
955            classify_on_face(&model, &bottom, Point::new(0.0, 1.5, 0.0), fine(), T).unwrap(),
956            Containment::On,
957            "on the trimming boundary"
958        );
959    }
960
961    #[test]
962    fn the_answers_invert_the_way_a_complement_does() {
963        assert_eq!(Containment::In.inverted(), Containment::Out);
964        assert_eq!(Containment::Out.inverted(), Containment::In);
965        // A boundary belongs to both sides, so complementing leaves it alone.
966        assert_eq!(Containment::On.inverted(), Containment::On);
967
968        assert!(Containment::In.is_inside_or_on());
969        assert!(Containment::On.is_inside_or_on());
970        assert!(!Containment::Out.is_inside_or_on());
971    }
972
973    #[test]
974    fn a_translated_box_classifies_the_same_way_translated_points() {
975        let offset = Vector::new(10.0, -20.0, 30.0);
976        let mut model = Model::new();
977        let frame = Frame::new(Point::ORIGIN + offset, Direction::Z, Direction::X, T).unwrap();
978        let built = make_box(&mut model, frame, (2.0, 2.0, 2.0), T).unwrap();
979
980        assert_eq!(
981            classify_in_solid(
982                &model,
983                &built.shape,
984                Point::new(1.0, 1.0, 1.0) + offset,
985                fine(),
986                T
987            )
988            .unwrap(),
989            Containment::In
990        );
991        assert_eq!(
992            classify_in_solid(&model, &built.shape, Point::new(1.0, 1.0, 1.0), fine(), T).unwrap(),
993            Containment::Out,
994            "the untranslated point is nowhere near the translated box"
995        );
996    }
997
998    #[test]
999    fn the_exact_classifier_agrees_with_the_tessellated_one_on_a_box() {
1000        let mut model = Model::new();
1001        let built = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
1002
1003        for (p, want) in [
1004            (Point::new(1.0, 1.0, 1.0), Containment::In),
1005            (Point::new(0.1, 0.1, 0.1), Containment::In),
1006            (Point::new(3.0, 1.0, 1.0), Containment::Out),
1007            (Point::new(1.0, 1.0, -0.5), Containment::Out),
1008            (Point::new(-50.0, -50.0, -50.0), Containment::Out),
1009            (Point::new(1.0, 1.0, 0.0), Containment::On),
1010            (Point::new(2.0, 2.0, 1.0), Containment::On),
1011            (Point::ORIGIN, Containment::On),
1012        ] {
1013            assert_eq!(
1014                classify_in_solid_exact(&model, &built.shape, p, T).unwrap(),
1015                want,
1016                "{p:?}"
1017            );
1018        }
1019    }
1020
1021    #[test]
1022    fn the_exact_classifier_resolves_what_the_deflection_band_cannot() {
1023        // The reason this function exists. A point a micron off a sphere's
1024        // wall is far inside any practical deflection band; the tessellated
1025        // classifier must say On, because against a mesh it genuinely cannot
1026        // tell. Against the true sphere the side is knowable, and known.
1027        let mut model = Model::new();
1028        let built = crate::make_sphere(&mut model, Frame::WORLD, 2.0, T).unwrap();
1029
1030        let barely_in = Point::new(0.0, 0.0, 2.0 - 1e-5);
1031        let barely_out = Point::new(0.0, 0.0, 2.0 + 1e-5);
1032
1033        assert_eq!(
1034            classify_in_solid(&model, &built.shape, barely_in, fine(), T).unwrap(),
1035            Containment::On,
1036            "the mesh cannot tell a micron from the wall"
1037        );
1038        assert_eq!(
1039            classify_in_solid_exact(&model, &built.shape, barely_in, T).unwrap(),
1040            Containment::In
1041        );
1042        assert_eq!(
1043            classify_in_solid_exact(&model, &built.shape, barely_out, T).unwrap(),
1044            Containment::Out
1045        );
1046        // Exactly on the wall: On, decided by projection, not by a ray.
1047        assert_eq!(
1048            classify_in_solid_exact(&model, &built.shape, Point::new(0.0, 0.0, 2.0), T).unwrap(),
1049            Containment::On
1050        );
1051    }
1052
1053    #[test]
1054    fn the_exact_classifier_handles_a_cylinder_wall_and_caps() {
1055        let mut model = Model::new();
1056        let built = crate::make_cylinder(&mut model, Frame::WORLD, 1.5, 4.0, T).unwrap();
1057
1058        for (p, want) in [
1059            (Point::new(0.0, 0.0, 2.0), Containment::In),
1060            (Point::new(1.5 - 1e-5, 0.0, 2.0), Containment::In),
1061            (Point::new(1.5 + 1e-5, 0.0, 2.0), Containment::Out),
1062            (Point::new(0.3, 0.4, 4.0 - 1e-5), Containment::In),
1063            (Point::new(0.3, 0.4, 4.0 + 1e-5), Containment::Out),
1064            (Point::new(1.5, 0.0, 2.0), Containment::On),
1065            (Point::new(0.3, 0.4, 0.0), Containment::On),
1066        ] {
1067            assert_eq!(
1068                classify_in_solid_exact(&model, &built.shape, p, T).unwrap(),
1069                want,
1070                "{p:?}"
1071            );
1072        }
1073    }
1074
1075    #[test]
1076    fn the_exact_classifier_walks_the_general_path_through_a_torus() {
1077        // No analytic ray/torus case exists, so every crossing here came from
1078        // the seeded Newton path, and a torus also puts the hole in the
1079        // middle, where a ray to the outside crosses the tube wall twice.
1080        let mut model = Model::new();
1081        let built = crate::make_torus(&mut model, Frame::WORLD, 3.0, 1.0, T).unwrap();
1082
1083        for (p, want) in [
1084            (Point::new(3.0, 0.0, 0.0), Containment::In),
1085            (Point::new(3.0, 0.0, 0.9), Containment::In),
1086            (Point::ORIGIN, Containment::Out),
1087            (Point::new(3.0, 0.0, 1.5), Containment::Out),
1088            (Point::new(5.0, 5.0, 0.0), Containment::Out),
1089            (Point::new(3.0, 0.0, 1.0), Containment::On),
1090        ] {
1091            assert_eq!(
1092                classify_in_solid_exact(&model, &built.shape, p, T).unwrap(),
1093                want,
1094                "{p:?}"
1095            );
1096        }
1097    }
1098
1099    #[test]
1100    fn the_exact_classifier_respects_a_placed_solid() {
1101        let offset = Vector::new(10.0, -20.0, 30.0);
1102        let mut model = Model::new();
1103        let frame = Frame::new(Point::ORIGIN + offset, Direction::Z, Direction::X, T).unwrap();
1104        let built = make_box(&mut model, frame, (2.0, 2.0, 2.0), T).unwrap();
1105
1106        assert_eq!(
1107            classify_in_solid_exact(&model, &built.shape, Point::new(1.0, 1.0, 1.0) + offset, T)
1108                .unwrap(),
1109            Containment::In
1110        );
1111        assert_eq!(
1112            classify_in_solid_exact(&model, &built.shape, Point::new(1.0, 1.0, 1.0), T).unwrap(),
1113            Containment::Out
1114        );
1115    }
1116
1117    #[test]
1118    fn the_exact_classifier_refuses_an_open_boundary() {
1119        let mut model = Model::new();
1120        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1121        let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
1122        assert!(classify_in_solid_exact(&model, &face, Point::ORIGIN, T).is_err());
1123    }
1124
1125    #[test]
1126    fn distance_to_a_triangle_is_measured_from_the_nearest_part_of_it() {
1127        let t = [
1128            Point::ORIGIN,
1129            Point::new(1.0, 0.0, 0.0),
1130            Point::new(0.0, 1.0, 0.0),
1131        ];
1132        // Above the interior: the plane distance.
1133        assert!((distance_to_triangle(Point::new(0.25, 0.25, 2.0), t) - 2.0).abs() < 1e-12);
1134        // Beyond a vertex: the distance to that vertex.
1135        assert!((distance_to_triangle(Point::new(-3.0, 0.0, 0.0), t) - 3.0).abs() < 1e-12);
1136        // On it: nothing.
1137        assert!(distance_to_triangle(Point::new(0.25, 0.25, 0.0), t) < 1e-12);
1138    }
1139
1140    #[test]
1141    fn an_unusable_deflection_is_refused() {
1142        let mut model = Model::new();
1143        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1144        let bad = Deflection {
1145            chord: f64::NAN,
1146            ..Deflection::default()
1147        };
1148        assert!(classify_in_solid(&model, &built.shape, Point::ORIGIN, bad, T).is_err());
1149    }
1150}