Skip to main content

brepkit_operations/
classify.rs

1//! Point-in-solid classification via ray casting and generalized winding numbers.
2//!
3//! Determines whether a 3D point is inside, outside, or on the boundary
4//! of a solid.
5//!
6//! Three classifiers are provided:
7//! - [`classify_point`]: analytic ray casting (fast, no tessellation for analytic faces)
8//! - [`classify_point_winding`]: generalized winding numbers (robust to gaps, uses tessellation)
9//! - [`classify_point_robust`]: winding numbers with ray-casting fallback
10
11use brepkit_math::predicates::point_in_polygon;
12use brepkit_math::tolerance::Tolerance;
13use brepkit_math::traits::ParametricSurface;
14use brepkit_math::vec::{Point2, Point3, Vec3};
15use brepkit_topology::Topology;
16use brepkit_topology::face::{FaceId, FaceSurface};
17use brepkit_topology::solid::SolidId;
18
19use std::f64::consts::PI;
20
21use crate::OperationsError;
22use crate::boolean::face_polygon;
23use crate::distance::{point_in_polygon_3d, point_to_face_distance};
24
25// Grouped here so they can be tuned together. These are near-zero guards
26// for floating-point arithmetic, NOT geometric tolerance (use `Tolerance`
27// struct for that).
28
29/// Near-zero threshold for floating-point denominators and discriminants.
30const NEAR_ZERO: f64 = 1e-15;
31
32/// Minimum positive ray parameter to count as a forward hit (avoids self-intersection).
33const RAY_T_MIN: f64 = 1e-12;
34
35/// Threshold for half-space sign test (negative side rejection).
36const HALF_SPACE_EPS: f64 = 1e-10;
37
38/// Near-zero threshold for degenerate vector length (e.g. polygon normal).
39const DEGENERATE_LEN: f64 = 1e-30;
40
41/// Threshold for coincident vertex detection (squared distance).
42const COINCIDENT_SQ: f64 = 1e-12;
43
44/// Result of classifying a point relative to a solid.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum PointClassification {
47    /// The point is inside the solid.
48    Inside,
49    /// The point is outside the solid.
50    Outside,
51    /// The point is on the boundary (within tolerance).
52    OnBoundary,
53}
54
55/// Classifies a point relative to a solid using analytic ray casting.
56///
57/// Shoots a ray from `point` and counts crossings with the solid's
58/// boundary faces. Uses direct ray-surface intersection for analytic
59/// faces (plane, cylinder, cone, sphere, torus) and tessellation
60/// only for NURBS faces.
61///
62/// `deflection` controls tessellation quality for NURBS faces.
63/// `tolerance` is the distance threshold for "on boundary" classification.
64///
65/// # Errors
66/// Returns an error if the solid or its faces are invalid.
67pub fn classify_point(
68    topo: &Topology,
69    solid: SolidId,
70    point: Point3,
71    deflection: f64,
72    tolerance: f64,
73) -> Result<PointClassification, OperationsError> {
74    let solid_data = topo.solid(solid)?;
75    let shell = topo.shell(solid_data.outer_shell())?;
76
77    if is_on_boundary(topo, shell.faces(), point, tolerance)? {
78        return Ok(PointClassification::OnBoundary);
79    }
80
81    // Two perpendicular irrational ray directions for dual-ray consensus.
82    let ray_dirs = [
83        Vec3::new(
84            0.573_576_436_351_046,
85            0.740_535_693_464_567_5,
86            0.350_889_803_483_932_2,
87        ),
88        Vec3::new(
89            -0.350_889_803_483_932_2,
90            0.573_576_436_351_046,
91            0.740_535_693_464_567_5,
92        ),
93    ];
94
95    let mut inside_votes = 0u32;
96    for &dir in &ray_dirs {
97        let crossings = count_ray_crossings(topo, shell.faces(), point, dir, deflection)?;
98        if crossings % 2 == 1 {
99            inside_votes += 1;
100        }
101    }
102
103    if inside_votes >= 2 {
104        Ok(PointClassification::Inside)
105    } else {
106        Ok(PointClassification::Outside)
107    }
108}
109
110/// Classifies a point relative to a solid using generalized winding numbers.
111///
112/// For each triangle on the solid's boundary, computes the signed solid angle
113/// subtended at the query point. The sum divided by 4pi gives the winding
114/// number: > 0.5 means inside, < 0.5 means outside.
115///
116/// This method is inherently robust to mesh defects (small gaps, non-manifold
117/// edges) because it integrates a continuous function rather than counting
118/// discrete crossings.
119///
120/// `deflection` controls tessellation quality.
121/// `tolerance` is the distance threshold for "on boundary" classification.
122///
123/// # Errors
124/// Returns an error if the solid or its faces are invalid.
125pub fn classify_point_winding(
126    topo: &Topology,
127    solid: SolidId,
128    point: Point3,
129    deflection: f64,
130    tolerance: f64,
131) -> Result<PointClassification, OperationsError> {
132    let (winding, on_boundary) = compute_winding_number(topo, solid, point, deflection, tolerance)?;
133    if on_boundary {
134        return Ok(PointClassification::OnBoundary);
135    }
136    if winding > 0.5 {
137        Ok(PointClassification::Inside)
138    } else {
139        Ok(PointClassification::Outside)
140    }
141}
142
143/// Robust point classification combining winding numbers and ray casting.
144///
145/// Tries generalized winding numbers first (more robust to mesh defects),
146/// then falls back to analytic ray casting if the winding number is ambiguous
147/// (within 0.1 of the 0.5 threshold).
148///
149/// # Errors
150/// Returns an error if the solid or its faces are invalid.
151pub fn classify_point_robust(
152    topo: &Topology,
153    solid: SolidId,
154    point: Point3,
155    deflection: f64,
156    tolerance: f64,
157) -> Result<PointClassification, OperationsError> {
158    let (winding, on_boundary) = compute_winding_number(topo, solid, point, deflection, tolerance)?;
159    if on_boundary {
160        return Ok(PointClassification::OnBoundary);
161    }
162
163    if winding > 0.6 {
164        return Ok(PointClassification::Inside);
165    }
166    if winding < 0.4 {
167        return Ok(PointClassification::Outside);
168    }
169
170    // Ambiguous region (0.4..=0.6): fall back to ray casting
171    classify_point(topo, solid, point, deflection, tolerance)
172}
173
174/// Checks if a point is within `tolerance` of any face boundary.
175///
176/// Uses analytic point-to-surface distance for all surface types.
177fn is_on_boundary(
178    topo: &Topology,
179    faces: &[FaceId],
180    point: Point3,
181    tolerance: f64,
182) -> Result<bool, OperationsError> {
183    let tol = Tolerance::new();
184    for &fid in faces {
185        if let Some((dist, _)) = point_to_face_distance(topo, point, fid, tol)?
186            && dist < tolerance
187        {
188            return Ok(true);
189        }
190    }
191    Ok(false)
192}
193
194/// Counts the number of times a ray crosses the solid's boundary.
195fn count_ray_crossings(
196    topo: &Topology,
197    faces: &[FaceId],
198    origin: Point3,
199    direction: Vec3,
200    deflection: f64,
201) -> Result<u32, OperationsError> {
202    let mut crossings = 0u32;
203    for &fid in faces {
204        crossings += count_face_ray_crossings(topo, fid, origin, direction, deflection)?;
205    }
206    Ok(crossings)
207}
208
209/// Count ray crossings for a single face, dispatching by surface type.
210#[allow(clippy::too_many_lines)]
211fn count_face_ray_crossings(
212    topo: &Topology,
213    face_id: FaceId,
214    origin: Point3,
215    direction: Vec3,
216    _deflection: f64,
217) -> Result<u32, OperationsError> {
218    let face = topo.face(face_id)?;
219    match face.surface() {
220        FaceSurface::Plane { normal, d } => {
221            ray_plane_crossings(topo, face_id, origin, direction, *normal, *d)
222        }
223        FaceSurface::Cylinder(cyl) => {
224            let cyl = cyl.clone();
225            let roots = ray_cylinder_roots(origin, direction, &cyl);
226            count_analytic_crossings(
227                topo,
228                face_id,
229                origin,
230                direction,
231                &roots,
232                |p| cyl.project_point(p),
233                false,
234            )
235        }
236        FaceSurface::Cone(cone) => {
237            let cone = cone.clone();
238            let roots = ray_cone_roots(origin, direction, &cone);
239            count_analytic_crossings(
240                topo,
241                face_id,
242                origin,
243                direction,
244                &roots,
245                |p| cone.project_point(p),
246                false,
247            )
248        }
249        FaceSurface::Sphere(sph) => {
250            // Sphere boundaries are planar (equator, small circles), so
251            // point_in_polygon_3d works. UV projection fails at poles.
252            let sph = sph.clone();
253            let roots = ray_sphere_roots(origin, direction, &sph);
254            count_3d_polygon_crossings(topo, face_id, origin, direction, &roots)
255        }
256        FaceSurface::Torus(tor) => {
257            let tor = tor.clone();
258            let roots = ray_torus_roots(origin, direction, &tor);
259            count_analytic_crossings(
260                topo,
261                face_id,
262                origin,
263                direction,
264                &roots,
265                |p| tor.project_point(p),
266                true,
267            )
268        }
269        FaceSurface::Nurbs(surface) => {
270            ray_crossings_nurbs(topo, face_id, origin, direction, surface)
271        }
272    }
273}
274
275/// Whether a hit inside the outer wire actually lands in one of the face's
276/// holes.
277///
278/// A ray leaving a solid through the mouth of a pocket passes through the hole
279/// of the ring face around it. Without this test that hole counts as a
280/// crossing, and the extra count flips the parity: an open pocket reads as
281/// solid material.
282fn hit_in_inner_wire_3d(
283    topo: &Topology,
284    face_id: FaceId,
285    hit: Point3,
286    normal: &Vec3,
287) -> Result<bool, OperationsError> {
288    for &iw in topo.face(face_id)?.inner_wires() {
289        let hole = brepkit_check::util::wire_polygon(topo, iw)?;
290        if hole.len() >= 3 && point_in_polygon_3d(&hit, &hole, normal) {
291            return Ok(true);
292        }
293    }
294    Ok(false)
295}
296
297/// UV-space counterpart of [`hit_in_inner_wire_3d`] for curved faces.
298fn hit_in_inner_wire_uv<F>(
299    topo: &Topology,
300    face_id: FaceId,
301    hit_u: f64,
302    hit_v: f64,
303    project: &F,
304    v_periodic: bool,
305) -> Result<bool, OperationsError>
306where
307    F: Fn(Point3) -> (f64, f64),
308{
309    for &iw in topo.face(face_id)?.inner_wires() {
310        let hole = brepkit_check::util::wire_polygon(topo, iw)?;
311        if hole.len() < 3 {
312            continue;
313        }
314        let uv_hole = build_uv_boundary(&hole, project, v_periodic);
315        if point_in_uv_boundary(hit_u, hit_v, &uv_hole, v_periodic) {
316            return Ok(true);
317        }
318    }
319    Ok(false)
320}
321
322/// Ray-plane intersection with point-in-polygon boundary test.
323fn ray_plane_crossings(
324    topo: &Topology,
325    face_id: FaceId,
326    origin: Point3,
327    direction: Vec3,
328    normal: Vec3,
329    d: f64,
330) -> Result<u32, OperationsError> {
331    let denom = normal.dot(direction);
332    if denom.abs() < NEAR_ZERO {
333        return Ok(0);
334    }
335
336    let t = (d - normal.dot(Vec3::new(origin.x(), origin.y(), origin.z()))) / denom;
337    if t <= RAY_T_MIN {
338        return Ok(0);
339    }
340
341    let hit = origin + direction * t;
342    // The check-crate polygon samples OPEN curved edges too (the boolean-side
343    // `face_polygon` chords them for its calibrated fragment-sharing
344    // consumers): a plane face bitten by a marched conic arch would otherwise
345    // count hits inside the removed bite — the winding-chain wall lobes
346    // misclassified through exactly that.
347    let verts = brepkit_check::util::face_polygon(topo, face_id)?;
348    if verts.len() < 3 {
349        return Ok(0);
350    }
351
352    if point_in_polygon_3d(&hit, &verts, &normal)
353        && !hit_in_inner_wire_3d(topo, face_id, hit, &normal)?
354    {
355        Ok(1)
356    } else {
357        Ok(0)
358    }
359}
360
361/// Count crossings using 3D polygon containment (for faces with planar boundaries,
362/// e.g. sphere hemispheres where UV projection has pole singularities).
363///
364/// The polygon normal (from Newell's method) indicates which side of the boundary
365/// plane the face extends into. A hit point must be on that side AND project
366/// inside the boundary polygon.
367/// Half-space representation of a plane-convex sphere patch with a
368/// NON-planar boundary: one (circle center, unit normal, interior sign) per
369/// boundary arc. Returns `None` for planar boundaries (the calibrated
370/// single-plane path handles those), holed faces, or non-circle edges.
371fn nonplanar_sphere_arc_halfspaces(
372    topo: &Topology,
373    face_id: FaceId,
374    verts: &[Point3],
375) -> Option<Vec<(Point3, Vec3, f64)>> {
376    let face = topo.face(face_id).ok()?;
377    if !face.inner_wires().is_empty() {
378        return None;
379    }
380    let wire = topo.wire(face.outer_wire()).ok()?;
381    let mut planes: Vec<(Point3, Vec3)> = Vec::new();
382    for oe in wire.edges() {
383        let e = topo.edge(oe.edge()).ok()?;
384        let brepkit_topology::edge::EdgeCurve::Circle(c) = e.curve() else {
385            return None;
386        };
387        planes.push((c.center(), c.normal().normalize().ok()?));
388    }
389    if planes.len() < 2 {
390        return None;
391    }
392    // Non-planar means the arcs span at least two DISTINCT planes. The
393    // sampled polygon cannot decide this (a three-arc patch samples only
394    // its three coplanar corners).
395    let tol = Tolerance::new();
396    let (c0, n0) = planes[0];
397    let coplanar = planes
398        .iter()
399        .all(|&(c, n)| n.cross(n0).length() <= 1e-9 && (c - c0).dot(n0).abs() <= tol.linear);
400    if coplanar {
401        return None;
402    }
403    // Interior reference: the boundary centroid pushed onto the sphere.
404    let mut cx = 0.0;
405    let mut cy = 0.0;
406    let mut cz = 0.0;
407    #[allow(clippy::cast_precision_loss)]
408    let inv = 1.0 / verts.len() as f64;
409    for v in verts {
410        cx += v.x() * inv;
411        cy += v.y() * inv;
412        cz += v.z() * inv;
413    }
414    let centroid = Point3::new(cx, cy, cz);
415    let FaceSurface::Sphere(sph) = face.surface() else {
416        return None;
417    };
418    let dir = (centroid - sph.center()).normalize().ok()?;
419    let p_ref = sph.center() + dir * sph.radius();
420    let mut out = Vec::with_capacity(planes.len());
421    for (c, n) in planes {
422        let side = (p_ref - c).dot(n);
423        if side.abs() <= tol.linear {
424            return None;
425        }
426        out.push((c, n, side.signum()));
427    }
428    Some(out)
429}
430
431fn count_3d_polygon_crossings(
432    topo: &Topology,
433    face_id: FaceId,
434    origin: Point3,
435    direction: Vec3,
436    roots: &[f64],
437) -> Result<u32, OperationsError> {
438    if roots.is_empty() {
439        return Ok(0);
440    }
441
442    let verts = face_polygon(topo, face_id)?;
443    if verts.len() < 3 {
444        return Ok(0);
445    }
446    // A sphere patch whose boundary arcs lie in DIFFERENT planes (an octant
447    // patch: three quarter-arcs in three orthogonal planes) has a non-planar
448    // boundary polygon, and the single-plane containment below discards
449    // genuine hits — the whole face read as never-crossed. Such a patch is
450    // plane-convex: exactly the sphere points on the interior side of every
451    // boundary arc's plane, with the side calibrated from the boundary
452    // centroid pushed onto the sphere.
453    if let Some(halfspaces) = nonplanar_sphere_arc_halfspaces(topo, face_id, &verts) {
454        let mut crossings = 0u32;
455        for &t in roots {
456            if t <= RAY_T_MIN {
457                continue;
458            }
459            let hit = origin + direction * t;
460            if halfspaces
461                .iter()
462                .all(|&(c, n, sign)| (hit - c).dot(n) * sign >= -HALF_SPACE_EPS)
463            {
464                crossings += 1;
465            }
466        }
467        return Ok(crossings);
468    }
469    let mut normal = polygon_normal(&verts);
470    // If the face is reversed, the surface normal is flipped — the face
471    // extends into the opposite side of the boundary plane.
472    let face = topo.face(face_id)?;
473    if face.is_reversed() {
474        normal = -normal;
475    }
476    // A reference point on the boundary plane.
477    let ref_pt = verts[0];
478
479    let mut crossings = 0u32;
480    for &t in roots {
481        if t <= RAY_T_MIN {
482            continue;
483        }
484        let hit = origin + direction * t;
485
486        // The hit must be on the face's side of the boundary plane.
487        // The polygon normal (from wire winding) points toward the face interior.
488        let side = (hit - ref_pt).dot(normal);
489        if side < -HALF_SPACE_EPS {
490            continue;
491        }
492
493        if point_in_polygon_3d(&hit, &verts, &normal)
494            && !hit_in_inner_wire_3d(topo, face_id, hit, &normal)?
495        {
496            crossings += 1;
497        }
498    }
499
500    Ok(crossings)
501}
502
503/// Count crossings for analytic (non-planar) faces using UV containment.
504///
505/// Given ray parameter roots (where the ray hits the infinite surface),
506/// checks whether each hit point falls within the face's trimming boundary
507/// by projecting to the surface's (u,v) parameter space.
508///
509/// If the face boundary is degenerate (all vertices coincide, as in a full
510/// torus face with seam edges), every positive-t root is counted as a crossing.
511fn count_analytic_crossings<F>(
512    topo: &Topology,
513    face_id: FaceId,
514    origin: Point3,
515    direction: Vec3,
516    roots: &[f64],
517    project: F,
518    v_periodic: bool,
519) -> Result<u32, OperationsError>
520where
521    F: Fn(Point3) -> (f64, f64),
522{
523    if roots.is_empty() {
524        return Ok(0);
525    }
526
527    // The UV boundary needs seam-anchored sampling: `boolean::face_polygon`
528    // samples closed edges from the curve's own parameter origin, so a wire
529    // chaining two rim circles (a partial-revolve torus band) enters the
530    // periodic unwrap at incoherent phases and the UV polygon shears into a
531    // self-inconsistent parallelogram that rejects real hits. The check
532    // crate's sampler anchors each closed edge at its seam vertex, keeping
533    // consecutive edges phase-coherent through the unwrap.
534    let verts = brepkit_check::util::face_polygon(topo, face_id)?;
535
536    // Detect degenerate boundary: a "full-surface" face whose wire has fewer than
537    // 3 distinct vertices (e.g. a torus with only seam edges, where all boundary
538    // vertices project to the same point). Every positive-t root is a crossing.
539    let is_full_surface = verts.len() < 3 || {
540        let ref_pt = verts[0];
541        verts
542            .iter()
543            .all(|v| (*v - ref_pt).length_squared() < COINCIDENT_SQ)
544    };
545    if is_full_surface {
546        return Ok(roots.iter().filter(|&&t| t > RAY_T_MIN).count() as u32);
547    }
548
549    let uv_boundary = build_uv_boundary(&verts, &project, v_periodic);
550
551    let mut crossings = 0u32;
552    for &t in roots {
553        if t <= RAY_T_MIN {
554            continue;
555        }
556        let hit = origin + direction * t;
557        let (hit_u, hit_v) = project(hit);
558
559        if point_in_uv_boundary(hit_u, hit_v, &uv_boundary, v_periodic)
560            && !hit_in_inner_wire_uv(topo, face_id, hit_u, hit_v, &project, v_periodic)?
561        {
562            crossings += 1;
563        }
564    }
565
566    Ok(crossings)
567}
568
569/// Unwrap a step in a periodic (angular) coordinate.
570///
571/// Given the previous unwrapped value `prev` and the next raw value `next`,
572/// returns the next value adjusted so the step lies in `[-PI, PI)`.
573/// This keeps a sequence of angular coordinates continuous (no ±TAU jumps).
574#[inline]
575fn unwrap_angle(prev: f64, next: f64) -> f64 {
576    let tau = std::f64::consts::TAU;
577    let diff = next - prev;
578    prev + diff - tau * ((diff + PI) / tau).floor()
579}
580
581/// Build a UV boundary polygon from 3D face boundary vertices,
582/// with proper unwrapping of periodic coordinates.
583///
584/// `v_periodic`: whether the v-coordinate is periodic (e.g. torus). Cylinder
585/// and cone have linear v (height / distance), so only u is unwrapped for them.
586fn build_uv_boundary<F>(verts: &[Point3], project: &F, v_periodic: bool) -> Vec<(f64, f64)>
587where
588    F: Fn(Point3) -> (f64, f64),
589{
590    let mut uv: Vec<(f64, f64)> = verts.iter().map(|&p| project(p)).collect();
591
592    for i in 1..uv.len() {
593        // u is always periodic (angular coordinate for all analytic surfaces).
594        uv[i].0 = unwrap_angle(uv[i - 1].0, uv[i].0);
595
596        // v is periodic only for doubly-periodic surfaces (torus).
597        if v_periodic {
598            uv[i].1 = unwrap_angle(uv[i - 1].1, uv[i].1);
599        }
600    }
601
602    uv
603}
604
605/// Test if a (u,v) point is inside the UV boundary polygon.
606///
607/// Adjusts the test point's u coordinate (and v when periodic) to lie within
608/// the unwrapped polygon's coordinate range before testing.
609fn point_in_uv_boundary(
610    hit_u: f64,
611    hit_v: f64,
612    uv_boundary: &[(f64, f64)],
613    v_periodic: bool,
614) -> bool {
615    // Find the u range of the unwrapped boundary.
616    let u_min = uv_boundary
617        .iter()
618        .map(|(u, _)| *u)
619        .fold(f64::INFINITY, f64::min);
620    let u_max = uv_boundary
621        .iter()
622        .map(|(u, _)| *u)
623        .fold(f64::NEG_INFINITY, f64::max);
624    let u_center = (u_min + u_max) * 0.5;
625
626    // Shift hit_u to be closest to the polygon's u center.
627    let hu = unwrap_angle(u_center, hit_u);
628
629    // For doubly-periodic surfaces (torus), also shift hit_v.
630    let hv = if v_periodic {
631        let v_min = uv_boundary
632            .iter()
633            .map(|(_, v)| *v)
634            .fold(f64::INFINITY, f64::min);
635        let v_max = uv_boundary
636            .iter()
637            .map(|(_, v)| *v)
638            .fold(f64::NEG_INFINITY, f64::max);
639        let v_center = (v_min + v_max) * 0.5;
640        unwrap_angle(v_center, hit_v)
641    } else {
642        hit_v
643    };
644
645    let poly: Vec<Point2> = uv_boundary
646        .iter()
647        .map(|(u, v)| Point2::new(*u, *v))
648        .collect();
649    let test = Point2::new(hu, hv);
650    point_in_polygon(test, &poly)
651}
652
653/// Compute ray-cylinder intersection parameters.
654fn ray_cylinder_roots(
655    origin: Point3,
656    direction: Vec3,
657    cyl: &brepkit_math::surfaces::CylindricalSurface,
658) -> Vec<f64> {
659    let ov = origin - cyl.origin();
660    let axis = cyl.axis();
661
662    // Project origin and direction onto plane perpendicular to axis.
663    let ov_perp = ov - axis * ov.dot(axis);
664    let d_perp = direction - axis * direction.dot(axis);
665
666    let a = d_perp.dot(d_perp);
667    let b = 2.0 * ov_perp.dot(d_perp);
668    let c = ov_perp.dot(ov_perp) - cyl.radius() * cyl.radius();
669
670    solve_quadratic(a, b, c)
671}
672
673/// Compute ray-cone intersection parameters.
674fn ray_cone_roots(
675    origin: Point3,
676    direction: Vec3,
677    cone: &brepkit_math::surfaces::ConicalSurface,
678) -> Vec<f64> {
679    let ov = origin - cone.apex();
680    let axis = cone.axis();
681    let cos_a = cone.half_angle().cos();
682    let cos2 = cos_a * cos_a;
683
684    let d_dot_a = direction.dot(axis);
685    let ov_dot_a = ov.dot(axis);
686
687    // Cone equation: (P·axis)² cos²θ = |P|² sin²θ
688    // Rearranged: (P·axis)² - |P|² tan²θ = 0
689    // Or equivalently: (d·a)²·t² + 2(d·a)(ov·a)·t + (ov·a)² - (d·d·t² + 2·ov·d·t + ov·ov)·tan²θ
690    // = (cos²θ(d·a)² - (d·d)(1-cos²θ))·t² + ...
691    // Simplify: a = cos²(d·a)² - d·d·sin², etc.
692    let sin2 = 1.0 - cos2;
693
694    let a = cos2 * d_dot_a * d_dot_a - sin2 * (direction.dot(direction) - d_dot_a * d_dot_a);
695    let half_b = cos2 * d_dot_a * ov_dot_a - sin2 * (direction.dot(ov) - d_dot_a * ov_dot_a);
696    let c = cos2 * ov_dot_a * ov_dot_a - sin2 * (ov.dot(ov) - ov_dot_a * ov_dot_a);
697
698    solve_quadratic(a, 2.0 * half_b, c)
699}
700
701/// Compute ray-sphere intersection parameters.
702fn ray_sphere_roots(
703    origin: Point3,
704    direction: Vec3,
705    sph: &brepkit_math::surfaces::SphericalSurface,
706) -> Vec<f64> {
707    let ov = origin - sph.center();
708
709    let a = direction.dot(direction);
710    let b = 2.0 * ov.dot(direction);
711    let c = ov.dot(ov) - sph.radius() * sph.radius();
712
713    solve_quadratic(a, b, c)
714}
715
716/// Compute ray-torus intersection parameters (quartic).
717///
718/// Delegates to the residual-verified quartic root finder in `brepkit_math` —
719/// a local Ferrari solver previously both missed real roots and emitted
720/// off-surface spurious ones for oblique rays at moderate radii, flipping
721/// crossing parity.
722fn ray_torus_roots(
723    origin: Point3,
724    direction: Vec3,
725    tor: &brepkit_math::surfaces::ToroidalSurface,
726) -> Vec<f64> {
727    brepkit_math::analytic_intersection::intersect_line_torus(tor, origin, direction)
728}
729
730/// Count ray crossings for a NURBS face using ray-surface intersection.
731///
732/// Uses `intersect_line_nurbs` to find ray-surface hits, then tests each
733/// hit against the face's UV boundary polygon.
734fn ray_crossings_nurbs(
735    topo: &Topology,
736    face_id: FaceId,
737    origin: Point3,
738    direction: Vec3,
739    surface: &brepkit_math::nurbs::surface::NurbsSurface,
740) -> Result<u32, OperationsError> {
741    use brepkit_math::nurbs::intersection::intersect_line_nurbs;
742
743    let hits = intersect_line_nurbs(surface, origin, direction, 20)?;
744    if hits.is_empty() {
745        return Ok(0);
746    }
747
748    let verts = face_polygon(topo, face_id)?;
749    if verts.len() < 3 {
750        // Full-surface face — every forward hit is a crossing.
751        return Ok(hits
752            .iter()
753            .filter(|h| {
754                let diff = h.point - origin;
755                let t = Vec3::new(diff.x(), diff.y(), diff.z()).dot(direction);
756                t > RAY_T_MIN
757            })
758            .count() as u32);
759    }
760
761    let project = |p: Point3| -> (f64, f64) { surface.project_point(p) };
762    let uv_boundary = build_uv_boundary(&verts, &project, false);
763
764    let mut crossings = 0u32;
765    for hit in &hits {
766        // Check ray parameter is positive (forward hit).
767        let diff = hit.point - origin;
768        let t = Vec3::new(diff.x(), diff.y(), diff.z()).dot(direction) / direction.dot(direction);
769        if t <= RAY_T_MIN {
770            continue;
771        }
772
773        // Use the UV parameters from the intersection result.
774        let (hit_u, hit_v) = hit.param1;
775        if point_in_uv_boundary(hit_u, hit_v, &uv_boundary, false)
776            && !hit_in_inner_wire_uv(topo, face_id, hit_u, hit_v, &project, false)?
777        {
778            crossings += 1;
779        }
780    }
781
782    Ok(crossings)
783}
784
785/// Computes the generalized winding number of a point relative to a solid.
786///
787/// Returns `(winding_number, is_on_boundary)`.
788///
789/// Uses ray casting to determine inside/outside classification. Counts
790/// total ray crossings across all faces using the same analytic + NURBS
791/// dispatch as `count_face_ray_crossings`.
792#[allow(clippy::similar_names)]
793fn compute_winding_number(
794    topo: &Topology,
795    solid: SolidId,
796    point: Point3,
797    deflection: f64,
798    tolerance: f64,
799) -> Result<(f64, bool), OperationsError> {
800    let solid_data = topo.solid(solid)?;
801    let shell = topo.shell(solid_data.outer_shell())?;
802
803    if is_on_boundary(topo, shell.faces(), point, tolerance)? {
804        return Ok((0.0, true));
805    }
806
807    let direction = Vec3::new(1.0, 0.3, 0.1); // avoid axis-aligned rays
808    let mut crossings = 0u32;
809    for &fid in shell.faces() {
810        crossings += count_face_ray_crossings(topo, fid, point, direction, deflection)?;
811    }
812
813    // Odd crossings = inside (winding ~1.0), even = outside (winding ~0.0).
814    let winding = if crossings % 2 == 1 { 1.0 } else { 0.0 };
815    Ok((winding, false))
816}
817
818/// Compute the normal of a polygon via Newell's method.
819fn polygon_normal(verts: &[Point3]) -> Vec3 {
820    let mut nx = 0.0;
821    let mut ny = 0.0;
822    let mut nz = 0.0;
823    let n = verts.len();
824    for i in 0..n {
825        let j = (i + 1) % n;
826        let vi = verts[i];
827        let vj = verts[j];
828        nx += (vi.y() - vj.y()) * (vi.z() + vj.z());
829        ny += (vi.z() - vj.z()) * (vi.x() + vj.x());
830        nz += (vi.x() - vj.x()) * (vi.y() + vj.y());
831    }
832    let len = (nx * nx + ny * ny + nz * nz).sqrt();
833    if len < DEGENERATE_LEN {
834        Vec3::new(0.0, 0.0, 1.0)
835    } else {
836        Vec3::new(nx / len, ny / len, nz / len)
837    }
838}
839
840/// Solve a·t² + b·t + c = 0, returning real roots.
841fn solve_quadratic(a: f64, b: f64, c: f64) -> Vec<f64> {
842    if a.abs() < NEAR_ZERO {
843        if b.abs() < NEAR_ZERO {
844            return Vec::new();
845        }
846        return vec![-c / b];
847    }
848
849    let disc = b * b - 4.0 * a * c;
850    if disc < -RAY_T_MIN {
851        return Vec::new();
852    }
853    if disc < RAY_T_MIN {
854        return vec![-b / (2.0 * a)];
855    }
856
857    let sqrt_disc = disc.sqrt();
858    let q = if b >= 0.0 {
859        -0.5 * (b + sqrt_disc)
860    } else {
861        -0.5 * (b - sqrt_disc)
862    };
863
864    let mut roots = Vec::with_capacity(2);
865    roots.push(q / a);
866    if q.abs() > NEAR_ZERO {
867        roots.push(c / q);
868    }
869    roots
870}
871
872#[cfg(test)]
873#[allow(clippy::unwrap_used, clippy::expect_used)]
874mod tests {
875    use super::*;
876    use crate::primitives::{make_box, make_cone, make_cylinder, make_sphere, make_torus};
877
878    #[test]
879    fn point_inside_box() {
880        let mut topo = Topology::new();
881        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
882
883        let result = classify_point(&topo, solid, Point3::new(1.0, 1.0, 1.0), 0.1, 1e-6).unwrap();
884        assert_eq!(result, PointClassification::Inside);
885    }
886
887    #[test]
888    fn point_outside_box() {
889        let mut topo = Topology::new();
890        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
891
892        let result = classify_point(&topo, solid, Point3::new(5.0, 5.0, 5.0), 0.1, 1e-6).unwrap();
893        assert_eq!(result, PointClassification::Outside);
894    }
895
896    #[test]
897    fn point_on_boundary_box() {
898        let mut topo = Topology::new();
899        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
900
901        let result = classify_point(&topo, solid, Point3::new(1.0, 1.0, 2.0), 0.1, 1e-3).unwrap();
902        assert_eq!(result, PointClassification::OnBoundary);
903    }
904
905    #[test]
906    fn point_outside_negative_direction() {
907        let mut topo = Topology::new();
908        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
909
910        let result =
911            classify_point(&topo, solid, Point3::new(-5.0, -5.0, -5.0), 0.1, 1e-6).unwrap();
912        assert_eq!(result, PointClassification::Outside);
913    }
914
915    /// A point floating in an open pocket is outside the solid.
916    ///
917    /// The pocket makes the top face a ring with an inner wire, and the ray
918    /// leaves through the middle of that hole. Counting the hole as a crossing
919    /// flips the parity and reports the empty pocket as solid material.
920    #[test]
921    fn point_in_open_pocket_is_outside() {
922        let mut topo = Topology::new();
923        let plate = make_box(&mut topo, 100.0, 100.0, 10.0).unwrap();
924        let tool = make_box(&mut topo, 60.0, 60.0, 4.0).unwrap();
925        crate::transform::transform_solid(
926            &mut topo,
927            tool,
928            &brepkit_math::mat::Mat4::translation(20.0, 20.0, 6.0),
929        )
930        .unwrap();
931        let pocketed =
932            crate::boolean::boolean(&mut topo, crate::boolean::BooleanOp::Cut, plate, tool)
933                .unwrap();
934
935        let result =
936            classify_point(&topo, pocketed, Point3::new(50.0, 50.0, 8.0), 0.1, 1e-6).unwrap();
937        assert_eq!(result, PointClassification::Outside);
938    }
939
940    #[test]
941    fn point_near_corner() {
942        let mut topo = Topology::new();
943        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
944
945        let result = classify_point(&topo, solid, Point3::new(0.9, 0.9, 0.9), 0.1, 1e-6).unwrap();
946        assert_eq!(result, PointClassification::Inside);
947    }
948
949    #[test]
950    fn point_inside_cylinder() {
951        let mut topo = Topology::new();
952        let solid = make_cylinder(&mut topo, 2.0, 5.0).unwrap();
953
954        let result = classify_point(&topo, solid, Point3::new(0.0, 0.0, 2.5), 0.1, 1e-6).unwrap();
955        assert_eq!(result, PointClassification::Inside);
956    }
957
958    #[test]
959    fn point_outside_cylinder() {
960        let mut topo = Topology::new();
961        let solid = make_cylinder(&mut topo, 2.0, 5.0).unwrap();
962
963        let result = classify_point(&topo, solid, Point3::new(10.0, 0.0, 2.5), 0.1, 1e-6).unwrap();
964        assert_eq!(result, PointClassification::Outside);
965    }
966
967    #[test]
968    fn point_inside_sphere() {
969        let mut topo = Topology::new();
970        let solid = make_sphere(&mut topo, 3.0, 32).unwrap();
971
972        let result = classify_point(&topo, solid, Point3::new(0.0, 0.0, 0.0), 0.1, 1e-6).unwrap();
973        assert_eq!(result, PointClassification::Inside);
974    }
975
976    #[test]
977    fn point_outside_sphere() {
978        let mut topo = Topology::new();
979        let solid = make_sphere(&mut topo, 3.0, 32).unwrap();
980
981        let result = classify_point(&topo, solid, Point3::new(5.0, 0.0, 0.0), 0.1, 1e-6).unwrap();
982        assert_eq!(result, PointClassification::Outside);
983    }
984
985    #[test]
986    fn point_inside_cone() {
987        let mut topo = Topology::new();
988        let solid = make_cone(&mut topo, 2.0, 1.0, 5.0).unwrap();
989
990        // Point on the axis, inside the cone
991        let result = classify_point(&topo, solid, Point3::new(0.0, 0.0, 2.5), 0.1, 1e-6).unwrap();
992        assert_eq!(result, PointClassification::Inside);
993    }
994
995    #[test]
996    fn point_outside_cone() {
997        let mut topo = Topology::new();
998        let solid = make_cone(&mut topo, 2.0, 1.0, 5.0).unwrap();
999
1000        let result = classify_point(&topo, solid, Point3::new(10.0, 0.0, 2.5), 0.1, 1e-6).unwrap();
1001        assert_eq!(result, PointClassification::Outside);
1002    }
1003
1004    #[test]
1005    fn point_inside_torus() {
1006        let mut topo = Topology::new();
1007        // major=3, minor=1 → tube center at distance 3 from origin
1008        let solid = make_torus(&mut topo, 3.0, 1.0, 32).unwrap();
1009
1010        // Point inside the tube (on the x-axis at distance 3 from origin)
1011        let result = classify_point(&topo, solid, Point3::new(3.0, 0.0, 0.0), 0.1, 1e-6).unwrap();
1012        assert_eq!(result, PointClassification::Inside);
1013    }
1014
1015    #[test]
1016    fn point_outside_torus() {
1017        let mut topo = Topology::new();
1018        let solid = make_torus(&mut topo, 3.0, 1.0, 32).unwrap();
1019
1020        // Point at origin — in the hole of the torus
1021        let result = classify_point(&topo, solid, Point3::new(0.0, 0.0, 0.0), 0.1, 1e-6).unwrap();
1022        assert_eq!(result, PointClassification::Outside);
1023    }
1024
1025    #[test]
1026    fn point_outside_torus_far() {
1027        let mut topo = Topology::new();
1028        let solid = make_torus(&mut topo, 3.0, 1.0, 32).unwrap();
1029
1030        // Point far from torus
1031        let result = classify_point(&topo, solid, Point3::new(10.0, 0.0, 0.0), 0.1, 1e-6).unwrap();
1032        assert_eq!(result, PointClassification::Outside);
1033    }
1034
1035    /// Build the partial-turn revolve of a circle profile: one trimmed torus
1036    /// band (wire = 2 closed rims + doubled seam) plus 2 planar disc caps.
1037    fn make_partial_torus(
1038        topo: &mut Topology,
1039        big_r: f64,
1040        rho: f64,
1041        angle: f64,
1042    ) -> brepkit_topology::solid::SolidId {
1043        use brepkit_math::curves::Circle3D;
1044        use brepkit_topology::edge::{Edge, EdgeCurve};
1045        use brepkit_topology::face::Face;
1046        use brepkit_topology::vertex::Vertex;
1047        use brepkit_topology::wire::{OrientedEdge, Wire};
1048
1049        let circ =
1050            Circle3D::new(Point3::new(big_r, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), rho).unwrap();
1051        let p0 = circ.evaluate(0.0);
1052        let v0 = topo.add_vertex(Vertex::new(p0, 1e-7));
1053        let eid = topo.add_edge(Edge::new(v0, v0, EdgeCurve::Circle(circ)));
1054        let wire = Wire::new(vec![OrientedEdge::new(eid, true)], true).unwrap();
1055        let wid = topo.add_wire(wire);
1056        let face = topo.add_face(Face::new(
1057            wid,
1058            vec![],
1059            FaceSurface::Plane {
1060                normal: Vec3::new(0.0, 1.0, 0.0),
1061                d: 0.0,
1062            },
1063        ));
1064        crate::revolve::revolve(
1065            topo,
1066            face,
1067            Point3::new(0.0, 0.0, 0.0),
1068            Vec3::new(0.0, 0.0, 1.0),
1069            angle,
1070        )
1071        .unwrap()
1072    }
1073
1074    /// Regression: the trimmed-torus band of a partial-turn revolve. Two
1075    /// stacked defects made every interior point read Outside: the local
1076    /// Ferrari ray-torus quartic missed real roots and emitted off-surface
1077    /// spurious ones, and the UV boundary sampled closed rim circles from the
1078    /// curve's parameter origin, so the two rims entered the periodic unwrap
1079    /// at incoherent phases and the UV polygon rejected real band hits.
1080    #[test]
1081    fn partial_turn_torus_band_classification() {
1082        let (big_r, rho, angle) = (6.0_f64, 2.0_f64, 2.0 * PI / 3.0);
1083        let mut topo = Topology::new();
1084        let solid = make_partial_torus(&mut topo, big_r, rho, angle);
1085
1086        let mid = angle / 2.0;
1087        let inside = [
1088            Point3::new(big_r * mid.cos(), big_r * mid.sin(), 0.0),
1089            Point3::new(big_r * mid.cos(), big_r * mid.sin(), 1.0),
1090            Point3::new(big_r * mid.cos(), big_r * mid.sin(), -1.0),
1091            Point3::new(big_r * 0.05f64.cos(), big_r * 0.05f64.sin(), 0.0),
1092            Point3::new(
1093                big_r * (angle - 0.05).cos(),
1094                big_r * (angle - 0.05).sin(),
1095                0.0,
1096            ),
1097            Point3::new((big_r - 1.5) * mid.cos(), (big_r - 1.5) * mid.sin(), 0.0),
1098            Point3::new((big_r + 1.5) * mid.cos(), (big_r + 1.5) * mid.sin(), 0.0),
1099        ];
1100        for p in inside {
1101            let result = classify_point(&topo, solid, p, 0.05, 1e-6).unwrap();
1102            assert_eq!(result, PointClassification::Inside, "probe {p:?}");
1103        }
1104
1105        let outside = [
1106            Point3::new(big_r * mid.cos(), big_r * mid.sin(), 2.5),
1107            Point3::new(0.0, 0.0, 0.0),
1108            Point3::new(-big_r, 0.0, 0.0),
1109            Point3::new(
1110                big_r * (angle + 0.1).cos(),
1111                big_r * (angle + 0.1).sin(),
1112                0.0,
1113            ),
1114            Point3::new(big_r * (-0.1f64).cos(), big_r * (-0.1f64).sin(), 0.0),
1115        ];
1116        for p in outside {
1117            let result = classify_point(&topo, solid, p, 0.05, 1e-6).unwrap();
1118            assert_eq!(result, PointClassification::Outside, "probe {p:?}");
1119        }
1120    }
1121
1122    /// A full-turn revolve (single closed torus face, seam edges only) must
1123    /// keep classifying correctly alongside the partial-band fix.
1124    #[test]
1125    fn full_turn_torus_classification() {
1126        let (big_r, rho) = (6.0_f64, 2.0_f64);
1127        let mut topo = Topology::new();
1128        let solid = make_partial_torus(&mut topo, big_r, rho, 2.0 * PI);
1129
1130        for theta in [0.0_f64, 1.0, 2.5, 4.0, 5.5] {
1131            let p = Point3::new(big_r * theta.cos(), big_r * theta.sin(), 0.0);
1132            let result = classify_point(&topo, solid, p, 0.05, 1e-6).unwrap();
1133            assert_eq!(result, PointClassification::Inside, "tube center {theta}");
1134        }
1135        for p in [
1136            Point3::new(0.0, 0.0, 0.0),
1137            Point3::new(big_r, 0.0, 2.5),
1138            Point3::new(2.0 * big_r, 0.0, 0.0),
1139        ] {
1140            let result = classify_point(&topo, solid, p, 0.05, 1e-6).unwrap();
1141            assert_eq!(result, PointClassification::Outside, "probe {p:?}");
1142        }
1143    }
1144
1145    #[test]
1146    fn winding_point_inside_box() {
1147        let mut topo = Topology::new();
1148        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
1149
1150        let result =
1151            classify_point_winding(&topo, solid, Point3::new(1.0, 1.0, 1.0), 0.1, 1e-6).unwrap();
1152        assert_eq!(result, PointClassification::Inside);
1153    }
1154
1155    #[test]
1156    fn winding_point_outside_box() {
1157        let mut topo = Topology::new();
1158        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
1159
1160        let result =
1161            classify_point_winding(&topo, solid, Point3::new(5.0, 5.0, 5.0), 0.1, 1e-6).unwrap();
1162        assert_eq!(result, PointClassification::Outside);
1163    }
1164
1165    #[test]
1166    fn robust_point_inside_box() {
1167        let mut topo = Topology::new();
1168        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
1169
1170        let result =
1171            classify_point_robust(&topo, solid, Point3::new(1.0, 1.0, 1.0), 0.1, 1e-6).unwrap();
1172        assert_eq!(result, PointClassification::Inside);
1173    }
1174
1175    #[test]
1176    fn robust_point_outside_box() {
1177        let mut topo = Topology::new();
1178        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
1179
1180        let result =
1181            classify_point_robust(&topo, solid, Point3::new(5.0, 5.0, 5.0), 0.1, 1e-6).unwrap();
1182        assert_eq!(result, PointClassification::Outside);
1183    }
1184
1185    #[test]
1186    fn quadratic_two_roots() {
1187        let mut roots = solve_quadratic(1.0, -5.0, 6.0);
1188        assert_eq!(roots.len(), 2);
1189        roots.sort_by(|a, b| a.partial_cmp(b).unwrap());
1190        let sorted = roots;
1191        assert!((sorted[0] - 2.0).abs() < 1e-10);
1192        assert!((sorted[1] - 3.0).abs() < 1e-10);
1193    }
1194
1195    #[test]
1196    fn quadratic_no_roots() {
1197        let roots = solve_quadratic(1.0, 0.0, 1.0);
1198        assert!(roots.is_empty());
1199    }
1200}