Skip to main content

brepkit_check/classify/
mod.rs

1//! Point-in-solid classification (ray casting + winding numbers).
2//!
3//! The primary entry point is [`classify_point`], which uses analytic ray
4//! casting with UV boundary containment to determine whether a 3D point
5//! lies inside, outside, or on the boundary of a B-Rep solid.
6
7pub(crate) mod boundary;
8pub(crate) mod ray_surface;
9pub(crate) mod winding;
10
11use brepkit_math::vec::{Point3, Vec3};
12use brepkit_topology::Topology;
13use brepkit_topology::face::{FaceId, FaceSurface};
14use brepkit_topology::solid::SolidId;
15
16use crate::CheckError;
17
18/// Result of classifying a point relative to a solid.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PointClassification {
21    /// The point is inside the solid.
22    Inside,
23    /// The point is outside the solid.
24    Outside,
25    /// The point is on the boundary (within tolerance).
26    OnBoundary,
27}
28
29/// Options controlling the classification algorithm.
30#[derive(Debug, Clone)]
31pub struct ClassifyOptions {
32    /// Distance threshold for "on boundary" detection.
33    pub tolerance: f64,
34    /// Maximum recovery attempts when ray hits face boundary.
35    pub max_recovery_attempts: usize,
36}
37
38impl Default for ClassifyOptions {
39    fn default() -> Self {
40        Self {
41            tolerance: 1e-6,
42            max_recovery_attempts: 10,
43        }
44    }
45}
46
47/// Classify a point relative to a solid using analytic ray casting.
48///
49/// Uses three irrational ray directions for majority-vote consensus.
50/// If the first two agree, the third is skipped. If all three disagree
51/// (very rare — indicates grazing rays), perturbed recovery directions
52/// are tried.
53///
54/// # Errors
55///
56/// Returns an error if the solid or its faces contain invalid topology references.
57#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
58pub fn classify_point(
59    topo: &Topology,
60    solid: SolidId,
61    point: Point3,
62    options: &ClassifyOptions,
63) -> Result<PointClassification, CheckError> {
64    let solid_data = topo.solid(solid)?;
65    let shell = topo.shell(solid_data.outer_shell())?;
66
67    if is_on_boundary(topo, shell.faces(), point, options.tolerance)? {
68        return Ok(PointClassification::OnBoundary);
69    }
70
71    // Three irrational ray directions for majority-vote consensus.
72    // If the first two agree, the third breaks no tie and we exit early.
73    let base_dirs = [
74        Vec3::new(
75            0.573_576_436_351_046,
76            0.740_535_693_464_567_5,
77            0.350_889_803_483_932_2,
78        ),
79        Vec3::new(
80            -0.350_889_803_483_932_2,
81            0.573_576_436_351_046,
82            0.740_535_693_464_567_5,
83        ),
84        Vec3::new(
85            0.740_535_693_464_567_5,
86            -0.350_889_803_483_932_2,
87            0.573_576_436_351_046,
88        ),
89    ];
90
91    let mut inside_votes = 0u32;
92    let mut outside_votes = 0u32;
93
94    for &dir in &base_dirs {
95        let crossings = count_ray_crossings(topo, shell.faces(), point, dir)?;
96        if crossings % 2 == 1 {
97            inside_votes += 1;
98        } else {
99            outside_votes += 1;
100        }
101        // Early exit: if 2 rays agree, that's the answer.
102        if inside_votes >= 2 {
103            return Ok(PointClassification::Inside);
104        }
105        if outside_votes >= 2 {
106            return Ok(PointClassification::Outside);
107        }
108    }
109
110    // All three disagreed (very rare). Try perturbed directions as recovery.
111    for attempt in 0..options.max_recovery_attempts {
112        // Generate a pseudo-random direction from attempt index using golden ratio.
113        let seed = (attempt as f64 + 1.0) * 0.618_033_988_749_895;
114        let theta = seed * std::f64::consts::TAU;
115        let phi = (seed * std::f64::consts::E).fract() * std::f64::consts::PI;
116        let dir = Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
117
118        let crossings = count_ray_crossings(topo, shell.faces(), point, dir)?;
119        if crossings % 2 == 1 {
120            inside_votes += 1;
121        } else {
122            outside_votes += 1;
123        }
124        let remaining = options.max_recovery_attempts as u32 - attempt as u32;
125        if inside_votes > outside_votes + remaining {
126            return Ok(PointClassification::Inside);
127        }
128        if outside_votes > inside_votes + remaining {
129            return Ok(PointClassification::Outside);
130        }
131    }
132
133    // Majority vote from all attempts.
134    if inside_votes > outside_votes {
135        Ok(PointClassification::Inside)
136    } else {
137        Ok(PointClassification::Outside)
138    }
139}
140
141/// Checks if a point is within `tolerance` of any face boundary.
142///
143/// Uses analytic point-to-surface distance for all surface types, then
144/// verifies the projection falls within the face polygon.
145fn is_on_boundary(
146    topo: &Topology,
147    faces: &[FaceId],
148    point: Point3,
149    tolerance: f64,
150) -> Result<bool, CheckError> {
151    for &fid in faces {
152        let face = topo.face(fid)?;
153        let dist = match face.surface() {
154            FaceSurface::Plane { normal, d } => {
155                let pv = Vec3::new(point.x(), point.y(), point.z());
156                (normal.dot(pv) - d).abs()
157            }
158            FaceSurface::Cylinder(cyl) => {
159                let (u, v) = cyl.project_point(point);
160                let on_surface = cyl.evaluate(u, v);
161                (point - on_surface).length()
162            }
163            FaceSurface::Cone(cone) => {
164                let (u, v) = cone.project_point(point);
165                let on_surface = cone.evaluate(u, v);
166                (point - on_surface).length()
167            }
168            FaceSurface::Sphere(sph) => {
169                let (u, v) = sph.project_point(point);
170                let on_surface = sph.evaluate(u, v);
171                (point - on_surface).length()
172            }
173            FaceSurface::Torus(tor) => {
174                let (u, v) = tor.project_point(point);
175                let on_surface = tor.evaluate(u, v);
176                (point - on_surface).length()
177            }
178            FaceSurface::Nurbs(nurbs) => {
179                match brepkit_math::nurbs::projection::project_point_to_surface(
180                    nurbs, point, tolerance,
181                ) {
182                    Ok(proj) => proj.distance,
183                    Err(_) => f64::INFINITY,
184                }
185            }
186        };
187        if dist < tolerance {
188            let polygon = crate::util::face_polygon(topo, fid)?;
189            if polygon.len() >= 3 {
190                let normal = boundary::polygon_normal(&polygon);
191                if crate::util::point_in_polygon_3d(&point, &polygon, &normal) {
192                    return Ok(true);
193                }
194            } else {
195                // Full-surface face (like torus with seam edges only).
196                return Ok(true);
197            }
198        }
199    }
200    Ok(false)
201}
202
203/// Classify a point relative to a solid using generalized winding numbers.
204///
205/// More robust than ray casting for imperfect geometry (small gaps,
206/// T-junctions). Sums the signed solid angles of triangulated faces and
207/// classifies based on the resulting winding number.
208///
209/// # Errors
210///
211/// Returns an error if the solid or its faces contain invalid topology references.
212pub fn classify_point_winding(
213    topo: &Topology,
214    solid: SolidId,
215    point: Point3,
216    options: &ClassifyOptions,
217) -> Result<PointClassification, CheckError> {
218    let solid_data = topo.solid(solid)?;
219    let shell = topo.shell(solid_data.outer_shell())?;
220    if is_on_boundary(topo, shell.faces(), point, options.tolerance)? {
221        return Ok(PointClassification::OnBoundary);
222    }
223
224    let w = winding::winding_number(topo, solid, point)?;
225    if w > 0.5 {
226        Ok(PointClassification::Inside)
227    } else {
228        Ok(PointClassification::Outside)
229    }
230}
231
232/// Robust classification combining winding numbers and ray casting.
233///
234/// Uses winding numbers first, falling back to ray casting when the
235/// winding number is ambiguous (between 0.4 and 0.6). This provides the
236/// best accuracy for both clean and imperfect geometry.
237///
238/// # Errors
239///
240/// Returns an error if the solid or its faces contain invalid topology references.
241pub fn classify_point_robust(
242    topo: &Topology,
243    solid: SolidId,
244    point: Point3,
245    options: &ClassifyOptions,
246) -> Result<PointClassification, CheckError> {
247    let solid_data = topo.solid(solid)?;
248    let shell = topo.shell(solid_data.outer_shell())?;
249    if is_on_boundary(topo, shell.faces(), point, options.tolerance)? {
250        return Ok(PointClassification::OnBoundary);
251    }
252
253    let w = winding::winding_number(topo, solid, point)?;
254    if w > 0.6 {
255        return Ok(PointClassification::Inside);
256    }
257    if w < 0.4 {
258        return Ok(PointClassification::Outside);
259    }
260    classify_point(topo, solid, point, options)
261}
262
263/// Count total ray crossings across all faces of a shell.
264///
265/// Builds a BVH over face AABBs to skip faces whose bounding box
266/// the ray does not intersect.
267fn count_ray_crossings(
268    topo: &Topology,
269    faces: &[FaceId],
270    origin: Point3,
271    direction: Vec3,
272) -> Result<u32, CheckError> {
273    use brepkit_math::bvh::Bvh;
274
275    let face_aabbs: Vec<(usize, brepkit_math::aabb::Aabb3)> = faces
276        .iter()
277        .enumerate()
278        .filter_map(|(i, &fid)| crate::util::face_aabb(topo, fid).ok().map(|aabb| (i, aabb)))
279        .collect();
280    let bvh = Bvh::build(&face_aabbs);
281
282    // query_ray returns the primitive IDs (the `i` values), which are
283    // indices into the original `faces` slice.
284    let candidates = bvh.query_ray(origin, direction);
285
286    let mut crossings = 0u32;
287    for face_idx in candidates {
288        crossings += boundary::count_face_ray_crossings(topo, faces[face_idx], origin, direction)?;
289    }
290    Ok(crossings)
291}
292
293// ===========================================================================
294// Tests
295// ===========================================================================
296
297#[cfg(test)]
298#[allow(clippy::unwrap_used, clippy::expect_used)]
299mod tests {
300    use super::winding;
301    use super::*;
302    use brepkit_topology::test_utils::make_unit_cube_manifold;
303
304    #[test]
305    fn point_inside_box() {
306        let mut topo = Topology::new();
307        let solid = make_unit_cube_manifold(&mut topo);
308        let center = Point3::new(0.5, 0.5, 0.5);
309        let opts = ClassifyOptions::default();
310
311        let result = classify_point(&topo, solid, center, &opts).unwrap();
312        assert_eq!(result, PointClassification::Inside);
313    }
314
315    #[test]
316    fn point_outside_box() {
317        let mut topo = Topology::new();
318        let solid = make_unit_cube_manifold(&mut topo);
319        let far = Point3::new(5.0, 5.0, 5.0);
320        let opts = ClassifyOptions::default();
321
322        let result = classify_point(&topo, solid, far, &opts).unwrap();
323        assert_eq!(result, PointClassification::Outside);
324    }
325
326    #[test]
327    fn point_on_boundary_box() {
328        let mut topo = Topology::new();
329        let solid = make_unit_cube_manifold(&mut topo);
330        // Center of the top face (z=1).
331        let on_face = Point3::new(0.5, 0.5, 1.0);
332        let opts = ClassifyOptions::default();
333
334        let result = classify_point(&topo, solid, on_face, &opts).unwrap();
335        assert_eq!(result, PointClassification::OnBoundary);
336    }
337
338    #[test]
339    fn point_near_edge_outside() {
340        let mut topo = Topology::new();
341        let solid = make_unit_cube_manifold(&mut topo);
342        // Just outside the box along the x-axis.
343        let outside = Point3::new(1.001, 0.5, 0.5);
344        let opts = ClassifyOptions::default();
345
346        let result = classify_point(&topo, solid, outside, &opts).unwrap();
347        assert_eq!(result, PointClassification::Outside);
348    }
349
350    #[test]
351    fn point_at_corner_boundary() {
352        let mut topo = Topology::new();
353        let solid = make_unit_cube_manifold(&mut topo);
354        // Very close to a vertex of the box.
355        let near_corner = Point3::new(0.0, 0.0, 0.0);
356        let opts = ClassifyOptions::default();
357
358        let result = classify_point(&topo, solid, near_corner, &opts).unwrap();
359        assert_eq!(result, PointClassification::OnBoundary);
360    }
361
362    #[test]
363    fn winding_inside_box() {
364        let mut topo = Topology::new();
365        let solid = make_unit_cube_manifold(&mut topo);
366        let center = Point3::new(0.5, 0.5, 0.5);
367
368        let w = winding::winding_number(&topo, solid, center).unwrap();
369        assert!(
370            w > 0.5,
371            "winding number for interior point should be > 0.5, got {w}"
372        );
373    }
374
375    #[test]
376    fn winding_outside_box() {
377        let mut topo = Topology::new();
378        let solid = make_unit_cube_manifold(&mut topo);
379        let far = Point3::new(5.0, 5.0, 5.0);
380
381        let w = winding::winding_number(&topo, solid, far).unwrap();
382        assert!(
383            w < 0.5,
384            "winding number for exterior point should be < 0.5, got {w}"
385        );
386    }
387
388    #[test]
389    fn classify_winding_matches_ray() {
390        let mut topo = Topology::new();
391        let solid = make_unit_cube_manifold(&mut topo);
392        let center = Point3::new(0.5, 0.5, 0.5);
393        let opts = ClassifyOptions::default();
394
395        let ray_result = classify_point(&topo, solid, center, &opts).unwrap();
396        let winding_result = classify_point_winding(&topo, solid, center, &opts).unwrap();
397        assert_eq!(ray_result, winding_result);
398    }
399
400    #[test]
401    fn point_negative_quadrant_outside() {
402        let mut topo = Topology::new();
403        let solid = make_unit_cube_manifold(&mut topo);
404        let neg = Point3::new(-1.0, -1.0, -1.0);
405        let opts = ClassifyOptions::default();
406
407        let result = classify_point(&topo, solid, neg, &opts).unwrap();
408        assert_eq!(result, PointClassification::Outside);
409    }
410
411    /// Build the 3-face solid a partial-turn circle revolve produces: one
412    /// trimmed torus band (u in `[0, angle]`, full tube wrap; wire = two
413    /// closed rim circles + a doubled seam arc, only 2 distinct vertices)
414    /// plus two planar disc caps each bounded by a single closed circle.
415    fn make_partial_torus_band(topo: &mut Topology, big_r: f64, rho: f64, angle: f64) -> SolidId {
416        use brepkit_math::curves::Circle3D;
417        use brepkit_math::surfaces::ToroidalSurface;
418        use brepkit_topology::edge::{Edge, EdgeCurve};
419        use brepkit_topology::face::{Face, FaceSurface};
420        use brepkit_topology::shell::Shell;
421        use brepkit_topology::solid::Solid;
422        use brepkit_topology::vertex::Vertex;
423        use brepkit_topology::wire::{OrientedEdge, Wire};
424
425        let (sin_a, cos_a) = angle.sin_cos();
426        let v1 = topo.add_vertex(Vertex::new(Point3::new(big_r, 0.0, -rho), 1e-7));
427        let v2 = topo.add_vertex(Vertex::new(
428            Point3::new(big_r * cos_a, big_r * sin_a, -rho),
429            1e-7,
430        ));
431
432        let rim1 =
433            Circle3D::new(Point3::new(big_r, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), rho).unwrap();
434        let rim2 = Circle3D::new(
435            Point3::new(big_r * cos_a, big_r * sin_a, 0.0),
436            Vec3::new(-sin_a, cos_a, 0.0),
437            rho,
438        )
439        .unwrap();
440        let seam =
441            Circle3D::new(Point3::new(0.0, 0.0, -rho), Vec3::new(0.0, 0.0, 1.0), big_r).unwrap();
442
443        let e_rim1 = topo.add_edge(Edge::new(v1, v1, EdgeCurve::Circle(rim1)));
444        let e_rim2 = topo.add_edge(Edge::new(v2, v2, EdgeCurve::Circle(rim2)));
445        let e_seam = topo.add_edge(Edge::new(v1, v2, EdgeCurve::Circle(seam)));
446
447        let band_wire = topo.add_wire(
448            Wire::new(
449                vec![
450                    OrientedEdge::new(e_rim1, true),
451                    OrientedEdge::new(e_seam, true),
452                    OrientedEdge::new(e_rim2, false),
453                    OrientedEdge::new(e_seam, false),
454                ],
455                true,
456            )
457            .unwrap(),
458        );
459        let torus = ToroidalSurface::with_axis(
460            Point3::new(0.0, 0.0, 0.0),
461            big_r,
462            rho,
463            Vec3::new(0.0, 0.0, 1.0),
464        )
465        .unwrap();
466        let band = topo.add_face(Face::new(band_wire, vec![], FaceSurface::Torus(torus)));
467
468        let cap1_wire =
469            topo.add_wire(Wire::new(vec![OrientedEdge::new(e_rim1, false)], true).unwrap());
470        let cap1 = topo.add_face(Face::new(
471            cap1_wire,
472            vec![],
473            FaceSurface::Plane {
474                normal: Vec3::new(0.0, 1.0, 0.0),
475                d: 0.0,
476            },
477        ));
478        let cap2_wire =
479            topo.add_wire(Wire::new(vec![OrientedEdge::new(e_rim2, true)], true).unwrap());
480        let cap2 = topo.add_face(Face::new(
481            cap2_wire,
482            vec![],
483            FaceSurface::Plane {
484                normal: Vec3::new(-sin_a, cos_a, 0.0),
485                d: 0.0,
486            },
487        ));
488
489        let shell = topo.add_shell(Shell::new(vec![band, cap1, cap2]).unwrap());
490        topo.add_solid(Solid::new(shell, vec![]))
491    }
492
493    /// Regression: interior points of a partial-turn torus band read Outside.
494    /// Two stacked roots: the local Ferrari ray-torus quartic missed real
495    /// roots and emitted off-surface spurious ones, and `face_aabb` collapsed
496    /// each cap disc (single closed-circle wire, one vertex) to a point AABB,
497    /// so the BVH prefilter never offered the caps and their crossings were
498    /// dropped from the parity count.
499    #[test]
500    fn partial_torus_band_interior_points() {
501        let (big_r, rho, angle) = (6.0_f64, 2.0_f64, 2.0 * std::f64::consts::PI / 3.0);
502        let mut topo = Topology::new();
503        let solid = make_partial_torus_band(&mut topo, big_r, rho, angle);
504        let opts = ClassifyOptions::default();
505
506        let mid = angle / 2.0;
507        let inside = [
508            Point3::new(big_r * mid.cos(), big_r * mid.sin(), 0.0),
509            Point3::new(big_r * mid.cos(), big_r * mid.sin(), 1.0),
510            Point3::new(big_r * mid.cos(), big_r * mid.sin(), -1.0),
511            Point3::new(big_r * 0.05f64.cos(), big_r * 0.05f64.sin(), 0.0),
512            Point3::new(
513                big_r * (angle - 0.05).cos(),
514                big_r * (angle - 0.05).sin(),
515                0.0,
516            ),
517            Point3::new((big_r - 1.5) * mid.cos(), (big_r - 1.5) * mid.sin(), 0.0),
518            Point3::new((big_r + 1.5) * mid.cos(), (big_r + 1.5) * mid.sin(), 0.0),
519        ];
520        for p in inside {
521            let result = classify_point(&topo, solid, p, &opts).unwrap();
522            assert_eq!(result, PointClassification::Inside, "probe {p:?}");
523        }
524
525        let outside = [
526            Point3::new(big_r * mid.cos(), big_r * mid.sin(), 2.5),
527            Point3::new(0.0, 0.0, 0.0),
528            Point3::new(-big_r, 0.0, 0.0),
529            Point3::new(
530                big_r * (angle + 0.1).cos(),
531                big_r * (angle + 0.1).sin(),
532                0.0,
533            ),
534            Point3::new(big_r * (-0.1f64).cos(), big_r * (-0.1f64).sin(), 0.0),
535        ];
536        for p in outside {
537            let result = classify_point(&topo, solid, p, &opts).unwrap();
538            assert_eq!(result, PointClassification::Outside, "probe {p:?}");
539        }
540    }
541
542    /// A cap disc bounded by a single closed circle edge must get a full-disc
543    /// AABB, not a point box at its lone seam vertex (the collapsed box
544    /// starved the classifier's BVH prefilter).
545    #[test]
546    fn face_aabb_covers_closed_circle_boundary() {
547        let (big_r, rho, angle) = (6.0_f64, 2.0_f64, 2.0 * std::f64::consts::PI / 3.0);
548        let mut topo = Topology::new();
549        let solid = make_partial_torus_band(&mut topo, big_r, rho, angle);
550        let shell = topo
551            .shell(topo.solid(solid).unwrap().outer_shell())
552            .unwrap();
553
554        // Face index 1 is the y=0 cap: disc center (6,0,0) radius 2 in the
555        // xz-plane, so the AABB must span x in [4,8] and z in [-2,2].
556        let cap = shell.faces()[1];
557        let aabb = crate::util::face_aabb(&topo, cap).unwrap();
558        assert!(
559            aabb.min.x() < 4.0 + 1e-9 && aabb.max.x() > 8.0 - 1e-9,
560            "cap AABB x-span collapsed: {aabb:?}"
561        );
562        assert!(
563            aabb.min.z() < -2.0 + 1e-9 && aabb.max.z() > 2.0 - 1e-9,
564            "cap AABB z-span collapsed: {aabb:?}"
565        );
566    }
567}