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/// Ray-plane intersection with point-in-polygon boundary test.
276fn ray_plane_crossings(
277    topo: &Topology,
278    face_id: FaceId,
279    origin: Point3,
280    direction: Vec3,
281    normal: Vec3,
282    d: f64,
283) -> Result<u32, OperationsError> {
284    let denom = normal.dot(direction);
285    if denom.abs() < NEAR_ZERO {
286        return Ok(0);
287    }
288
289    let t = (d - normal.dot(Vec3::new(origin.x(), origin.y(), origin.z()))) / denom;
290    if t <= RAY_T_MIN {
291        return Ok(0);
292    }
293
294    let hit = origin + direction * t;
295    // The check-crate polygon samples OPEN curved edges too (the boolean-side
296    // `face_polygon` chords them for its calibrated fragment-sharing
297    // consumers): a plane face bitten by a marched conic arch would otherwise
298    // count hits inside the removed bite — the winding-chain wall lobes
299    // misclassified through exactly that.
300    let verts = brepkit_check::util::face_polygon(topo, face_id)?;
301    if verts.len() < 3 {
302        return Ok(0);
303    }
304
305    if point_in_polygon_3d(&hit, &verts, &normal) {
306        Ok(1)
307    } else {
308        Ok(0)
309    }
310}
311
312/// Count crossings using 3D polygon containment (for faces with planar boundaries,
313/// e.g. sphere hemispheres where UV projection has pole singularities).
314///
315/// The polygon normal (from Newell's method) indicates which side of the boundary
316/// plane the face extends into. A hit point must be on that side AND project
317/// inside the boundary polygon.
318/// Half-space representation of a plane-convex sphere patch with a
319/// NON-planar boundary: one (circle center, unit normal, interior sign) per
320/// boundary arc. Returns `None` for planar boundaries (the calibrated
321/// single-plane path handles those), holed faces, or non-circle edges.
322fn nonplanar_sphere_arc_halfspaces(
323    topo: &Topology,
324    face_id: FaceId,
325    verts: &[Point3],
326) -> Option<Vec<(Point3, Vec3, f64)>> {
327    let face = topo.face(face_id).ok()?;
328    if !face.inner_wires().is_empty() {
329        return None;
330    }
331    let wire = topo.wire(face.outer_wire()).ok()?;
332    let mut planes: Vec<(Point3, Vec3)> = Vec::new();
333    for oe in wire.edges() {
334        let e = topo.edge(oe.edge()).ok()?;
335        let brepkit_topology::edge::EdgeCurve::Circle(c) = e.curve() else {
336            return None;
337        };
338        planes.push((c.center(), c.normal().normalize().ok()?));
339    }
340    if planes.len() < 2 {
341        return None;
342    }
343    // Non-planar means the arcs span at least two DISTINCT planes. The
344    // sampled polygon cannot decide this (a three-arc patch samples only
345    // its three coplanar corners).
346    let tol = Tolerance::new();
347    let (c0, n0) = planes[0];
348    let coplanar = planes
349        .iter()
350        .all(|&(c, n)| n.cross(n0).length() <= 1e-9 && (c - c0).dot(n0).abs() <= tol.linear);
351    if coplanar {
352        return None;
353    }
354    // Interior reference: the boundary centroid pushed onto the sphere.
355    let mut cx = 0.0;
356    let mut cy = 0.0;
357    let mut cz = 0.0;
358    #[allow(clippy::cast_precision_loss)]
359    let inv = 1.0 / verts.len() as f64;
360    for v in verts {
361        cx += v.x() * inv;
362        cy += v.y() * inv;
363        cz += v.z() * inv;
364    }
365    let centroid = Point3::new(cx, cy, cz);
366    let FaceSurface::Sphere(sph) = face.surface() else {
367        return None;
368    };
369    let dir = (centroid - sph.center()).normalize().ok()?;
370    let p_ref = sph.center() + dir * sph.radius();
371    let mut out = Vec::with_capacity(planes.len());
372    for (c, n) in planes {
373        let side = (p_ref - c).dot(n);
374        if side.abs() <= tol.linear {
375            return None;
376        }
377        out.push((c, n, side.signum()));
378    }
379    Some(out)
380}
381
382fn count_3d_polygon_crossings(
383    topo: &Topology,
384    face_id: FaceId,
385    origin: Point3,
386    direction: Vec3,
387    roots: &[f64],
388) -> Result<u32, OperationsError> {
389    if roots.is_empty() {
390        return Ok(0);
391    }
392
393    let verts = face_polygon(topo, face_id)?;
394    if verts.len() < 3 {
395        return Ok(0);
396    }
397    // A sphere patch whose boundary arcs lie in DIFFERENT planes (an octant
398    // patch: three quarter-arcs in three orthogonal planes) has a non-planar
399    // boundary polygon, and the single-plane containment below discards
400    // genuine hits — the whole face read as never-crossed. Such a patch is
401    // plane-convex: exactly the sphere points on the interior side of every
402    // boundary arc's plane, with the side calibrated from the boundary
403    // centroid pushed onto the sphere.
404    if let Some(halfspaces) = nonplanar_sphere_arc_halfspaces(topo, face_id, &verts) {
405        let mut crossings = 0u32;
406        for &t in roots {
407            if t <= RAY_T_MIN {
408                continue;
409            }
410            let hit = origin + direction * t;
411            if halfspaces
412                .iter()
413                .all(|&(c, n, sign)| (hit - c).dot(n) * sign >= -HALF_SPACE_EPS)
414            {
415                crossings += 1;
416            }
417        }
418        return Ok(crossings);
419    }
420    let mut normal = polygon_normal(&verts);
421    // If the face is reversed, the surface normal is flipped — the face
422    // extends into the opposite side of the boundary plane.
423    let face = topo.face(face_id)?;
424    if face.is_reversed() {
425        normal = -normal;
426    }
427    // A reference point on the boundary plane.
428    let ref_pt = verts[0];
429
430    let mut crossings = 0u32;
431    for &t in roots {
432        if t <= RAY_T_MIN {
433            continue;
434        }
435        let hit = origin + direction * t;
436
437        // The hit must be on the face's side of the boundary plane.
438        // The polygon normal (from wire winding) points toward the face interior.
439        let side = (hit - ref_pt).dot(normal);
440        if side < -HALF_SPACE_EPS {
441            continue;
442        }
443
444        if point_in_polygon_3d(&hit, &verts, &normal) {
445            crossings += 1;
446        }
447    }
448
449    Ok(crossings)
450}
451
452/// Count crossings for analytic (non-planar) faces using UV containment.
453///
454/// Given ray parameter roots (where the ray hits the infinite surface),
455/// checks whether each hit point falls within the face's trimming boundary
456/// by projecting to the surface's (u,v) parameter space.
457///
458/// If the face boundary is degenerate (all vertices coincide, as in a full
459/// torus face with seam edges), every positive-t root is counted as a crossing.
460fn count_analytic_crossings<F>(
461    topo: &Topology,
462    face_id: FaceId,
463    origin: Point3,
464    direction: Vec3,
465    roots: &[f64],
466    project: F,
467    v_periodic: bool,
468) -> Result<u32, OperationsError>
469where
470    F: Fn(Point3) -> (f64, f64),
471{
472    if roots.is_empty() {
473        return Ok(0);
474    }
475
476    // The UV boundary needs seam-anchored sampling: `boolean::face_polygon`
477    // samples closed edges from the curve's own parameter origin, so a wire
478    // chaining two rim circles (a partial-revolve torus band) enters the
479    // periodic unwrap at incoherent phases and the UV polygon shears into a
480    // self-inconsistent parallelogram that rejects real hits. The check
481    // crate's sampler anchors each closed edge at its seam vertex, keeping
482    // consecutive edges phase-coherent through the unwrap.
483    let verts = brepkit_check::util::face_polygon(topo, face_id)?;
484
485    // Detect degenerate boundary: a "full-surface" face whose wire has fewer than
486    // 3 distinct vertices (e.g. a torus with only seam edges, where all boundary
487    // vertices project to the same point). Every positive-t root is a crossing.
488    let is_full_surface = verts.len() < 3 || {
489        let ref_pt = verts[0];
490        verts
491            .iter()
492            .all(|v| (*v - ref_pt).length_squared() < COINCIDENT_SQ)
493    };
494    if is_full_surface {
495        return Ok(roots.iter().filter(|&&t| t > RAY_T_MIN).count() as u32);
496    }
497
498    let uv_boundary = build_uv_boundary(&verts, &project, v_periodic);
499
500    let mut crossings = 0u32;
501    for &t in roots {
502        if t <= RAY_T_MIN {
503            continue;
504        }
505        let hit = origin + direction * t;
506        let (hit_u, hit_v) = project(hit);
507
508        if point_in_uv_boundary(hit_u, hit_v, &uv_boundary, v_periodic) {
509            crossings += 1;
510        }
511    }
512
513    Ok(crossings)
514}
515
516/// Unwrap a step in a periodic (angular) coordinate.
517///
518/// Given the previous unwrapped value `prev` and the next raw value `next`,
519/// returns the next value adjusted so the step lies in `[-PI, PI)`.
520/// This keeps a sequence of angular coordinates continuous (no ±TAU jumps).
521#[inline]
522fn unwrap_angle(prev: f64, next: f64) -> f64 {
523    let tau = std::f64::consts::TAU;
524    let diff = next - prev;
525    prev + diff - tau * ((diff + PI) / tau).floor()
526}
527
528/// Build a UV boundary polygon from 3D face boundary vertices,
529/// with proper unwrapping of periodic coordinates.
530///
531/// `v_periodic`: whether the v-coordinate is periodic (e.g. torus). Cylinder
532/// and cone have linear v (height / distance), so only u is unwrapped for them.
533fn build_uv_boundary<F>(verts: &[Point3], project: &F, v_periodic: bool) -> Vec<(f64, f64)>
534where
535    F: Fn(Point3) -> (f64, f64),
536{
537    let mut uv: Vec<(f64, f64)> = verts.iter().map(|&p| project(p)).collect();
538
539    for i in 1..uv.len() {
540        // u is always periodic (angular coordinate for all analytic surfaces).
541        uv[i].0 = unwrap_angle(uv[i - 1].0, uv[i].0);
542
543        // v is periodic only for doubly-periodic surfaces (torus).
544        if v_periodic {
545            uv[i].1 = unwrap_angle(uv[i - 1].1, uv[i].1);
546        }
547    }
548
549    uv
550}
551
552/// Test if a (u,v) point is inside the UV boundary polygon.
553///
554/// Adjusts the test point's u coordinate (and v when periodic) to lie within
555/// the unwrapped polygon's coordinate range before testing.
556fn point_in_uv_boundary(
557    hit_u: f64,
558    hit_v: f64,
559    uv_boundary: &[(f64, f64)],
560    v_periodic: bool,
561) -> bool {
562    // Find the u range of the unwrapped boundary.
563    let u_min = uv_boundary
564        .iter()
565        .map(|(u, _)| *u)
566        .fold(f64::INFINITY, f64::min);
567    let u_max = uv_boundary
568        .iter()
569        .map(|(u, _)| *u)
570        .fold(f64::NEG_INFINITY, f64::max);
571    let u_center = (u_min + u_max) * 0.5;
572
573    // Shift hit_u to be closest to the polygon's u center.
574    let hu = unwrap_angle(u_center, hit_u);
575
576    // For doubly-periodic surfaces (torus), also shift hit_v.
577    let hv = if v_periodic {
578        let v_min = uv_boundary
579            .iter()
580            .map(|(_, v)| *v)
581            .fold(f64::INFINITY, f64::min);
582        let v_max = uv_boundary
583            .iter()
584            .map(|(_, v)| *v)
585            .fold(f64::NEG_INFINITY, f64::max);
586        let v_center = (v_min + v_max) * 0.5;
587        unwrap_angle(v_center, hit_v)
588    } else {
589        hit_v
590    };
591
592    let poly: Vec<Point2> = uv_boundary
593        .iter()
594        .map(|(u, v)| Point2::new(*u, *v))
595        .collect();
596    let test = Point2::new(hu, hv);
597    point_in_polygon(test, &poly)
598}
599
600/// Compute ray-cylinder intersection parameters.
601fn ray_cylinder_roots(
602    origin: Point3,
603    direction: Vec3,
604    cyl: &brepkit_math::surfaces::CylindricalSurface,
605) -> Vec<f64> {
606    let ov = origin - cyl.origin();
607    let axis = cyl.axis();
608
609    // Project origin and direction onto plane perpendicular to axis.
610    let ov_perp = ov - axis * ov.dot(axis);
611    let d_perp = direction - axis * direction.dot(axis);
612
613    let a = d_perp.dot(d_perp);
614    let b = 2.0 * ov_perp.dot(d_perp);
615    let c = ov_perp.dot(ov_perp) - cyl.radius() * cyl.radius();
616
617    solve_quadratic(a, b, c)
618}
619
620/// Compute ray-cone intersection parameters.
621fn ray_cone_roots(
622    origin: Point3,
623    direction: Vec3,
624    cone: &brepkit_math::surfaces::ConicalSurface,
625) -> Vec<f64> {
626    let ov = origin - cone.apex();
627    let axis = cone.axis();
628    let cos_a = cone.half_angle().cos();
629    let cos2 = cos_a * cos_a;
630
631    let d_dot_a = direction.dot(axis);
632    let ov_dot_a = ov.dot(axis);
633
634    // Cone equation: (P·axis)² cos²θ = |P|² sin²θ
635    // Rearranged: (P·axis)² - |P|² tan²θ = 0
636    // Or equivalently: (d·a)²·t² + 2(d·a)(ov·a)·t + (ov·a)² - (d·d·t² + 2·ov·d·t + ov·ov)·tan²θ
637    // = (cos²θ(d·a)² - (d·d)(1-cos²θ))·t² + ...
638    // Simplify: a = cos²(d·a)² - d·d·sin², etc.
639    let sin2 = 1.0 - cos2;
640
641    let a = cos2 * d_dot_a * d_dot_a - sin2 * (direction.dot(direction) - d_dot_a * d_dot_a);
642    let half_b = cos2 * d_dot_a * ov_dot_a - sin2 * (direction.dot(ov) - d_dot_a * ov_dot_a);
643    let c = cos2 * ov_dot_a * ov_dot_a - sin2 * (ov.dot(ov) - ov_dot_a * ov_dot_a);
644
645    solve_quadratic(a, 2.0 * half_b, c)
646}
647
648/// Compute ray-sphere intersection parameters.
649fn ray_sphere_roots(
650    origin: Point3,
651    direction: Vec3,
652    sph: &brepkit_math::surfaces::SphericalSurface,
653) -> Vec<f64> {
654    let ov = origin - sph.center();
655
656    let a = direction.dot(direction);
657    let b = 2.0 * ov.dot(direction);
658    let c = ov.dot(ov) - sph.radius() * sph.radius();
659
660    solve_quadratic(a, b, c)
661}
662
663/// Compute ray-torus intersection parameters (quartic).
664///
665/// Delegates to the residual-verified quartic root finder in `brepkit_math` —
666/// a local Ferrari solver previously both missed real roots and emitted
667/// off-surface spurious ones for oblique rays at moderate radii, flipping
668/// crossing parity.
669fn ray_torus_roots(
670    origin: Point3,
671    direction: Vec3,
672    tor: &brepkit_math::surfaces::ToroidalSurface,
673) -> Vec<f64> {
674    brepkit_math::analytic_intersection::intersect_line_torus(tor, origin, direction)
675}
676
677/// Count ray crossings for a NURBS face using ray-surface intersection.
678///
679/// Uses `intersect_line_nurbs` to find ray-surface hits, then tests each
680/// hit against the face's UV boundary polygon.
681fn ray_crossings_nurbs(
682    topo: &Topology,
683    face_id: FaceId,
684    origin: Point3,
685    direction: Vec3,
686    surface: &brepkit_math::nurbs::surface::NurbsSurface,
687) -> Result<u32, OperationsError> {
688    use brepkit_math::nurbs::intersection::intersect_line_nurbs;
689
690    let hits = intersect_line_nurbs(surface, origin, direction, 20)?;
691    if hits.is_empty() {
692        return Ok(0);
693    }
694
695    let verts = face_polygon(topo, face_id)?;
696    if verts.len() < 3 {
697        // Full-surface face — every forward hit is a crossing.
698        return Ok(hits
699            .iter()
700            .filter(|h| {
701                let diff = h.point - origin;
702                let t = Vec3::new(diff.x(), diff.y(), diff.z()).dot(direction);
703                t > RAY_T_MIN
704            })
705            .count() as u32);
706    }
707
708    let project = |p: Point3| -> (f64, f64) { surface.project_point(p) };
709    let uv_boundary = build_uv_boundary(&verts, &project, false);
710
711    let mut crossings = 0u32;
712    for hit in &hits {
713        // Check ray parameter is positive (forward hit).
714        let diff = hit.point - origin;
715        let t = Vec3::new(diff.x(), diff.y(), diff.z()).dot(direction) / direction.dot(direction);
716        if t <= RAY_T_MIN {
717            continue;
718        }
719
720        // Use the UV parameters from the intersection result.
721        let (hit_u, hit_v) = hit.param1;
722        if point_in_uv_boundary(hit_u, hit_v, &uv_boundary, false) {
723            crossings += 1;
724        }
725    }
726
727    Ok(crossings)
728}
729
730/// Computes the generalized winding number of a point relative to a solid.
731///
732/// Returns `(winding_number, is_on_boundary)`.
733///
734/// Uses ray casting to determine inside/outside classification. Counts
735/// total ray crossings across all faces using the same analytic + NURBS
736/// dispatch as `count_face_ray_crossings`.
737#[allow(clippy::similar_names)]
738fn compute_winding_number(
739    topo: &Topology,
740    solid: SolidId,
741    point: Point3,
742    deflection: f64,
743    tolerance: f64,
744) -> Result<(f64, bool), OperationsError> {
745    let solid_data = topo.solid(solid)?;
746    let shell = topo.shell(solid_data.outer_shell())?;
747
748    if is_on_boundary(topo, shell.faces(), point, tolerance)? {
749        return Ok((0.0, true));
750    }
751
752    let direction = Vec3::new(1.0, 0.3, 0.1); // avoid axis-aligned rays
753    let mut crossings = 0u32;
754    for &fid in shell.faces() {
755        crossings += count_face_ray_crossings(topo, fid, point, direction, deflection)?;
756    }
757
758    // Odd crossings = inside (winding ~1.0), even = outside (winding ~0.0).
759    let winding = if crossings % 2 == 1 { 1.0 } else { 0.0 };
760    Ok((winding, false))
761}
762
763/// Compute the normal of a polygon via Newell's method.
764fn polygon_normal(verts: &[Point3]) -> Vec3 {
765    let mut nx = 0.0;
766    let mut ny = 0.0;
767    let mut nz = 0.0;
768    let n = verts.len();
769    for i in 0..n {
770        let j = (i + 1) % n;
771        let vi = verts[i];
772        let vj = verts[j];
773        nx += (vi.y() - vj.y()) * (vi.z() + vj.z());
774        ny += (vi.z() - vj.z()) * (vi.x() + vj.x());
775        nz += (vi.x() - vj.x()) * (vi.y() + vj.y());
776    }
777    let len = (nx * nx + ny * ny + nz * nz).sqrt();
778    if len < DEGENERATE_LEN {
779        Vec3::new(0.0, 0.0, 1.0)
780    } else {
781        Vec3::new(nx / len, ny / len, nz / len)
782    }
783}
784
785/// Solve a·t² + b·t + c = 0, returning real roots.
786fn solve_quadratic(a: f64, b: f64, c: f64) -> Vec<f64> {
787    if a.abs() < NEAR_ZERO {
788        if b.abs() < NEAR_ZERO {
789            return Vec::new();
790        }
791        return vec![-c / b];
792    }
793
794    let disc = b * b - 4.0 * a * c;
795    if disc < -RAY_T_MIN {
796        return Vec::new();
797    }
798    if disc < RAY_T_MIN {
799        return vec![-b / (2.0 * a)];
800    }
801
802    let sqrt_disc = disc.sqrt();
803    let q = if b >= 0.0 {
804        -0.5 * (b + sqrt_disc)
805    } else {
806        -0.5 * (b - sqrt_disc)
807    };
808
809    let mut roots = Vec::with_capacity(2);
810    roots.push(q / a);
811    if q.abs() > NEAR_ZERO {
812        roots.push(c / q);
813    }
814    roots
815}
816
817#[cfg(test)]
818#[allow(clippy::unwrap_used, clippy::expect_used)]
819mod tests {
820    use super::*;
821    use crate::primitives::{make_box, make_cone, make_cylinder, make_sphere, make_torus};
822
823    #[test]
824    fn point_inside_box() {
825        let mut topo = Topology::new();
826        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
827
828        let result = classify_point(&topo, solid, Point3::new(1.0, 1.0, 1.0), 0.1, 1e-6).unwrap();
829        assert_eq!(result, PointClassification::Inside);
830    }
831
832    #[test]
833    fn point_outside_box() {
834        let mut topo = Topology::new();
835        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
836
837        let result = classify_point(&topo, solid, Point3::new(5.0, 5.0, 5.0), 0.1, 1e-6).unwrap();
838        assert_eq!(result, PointClassification::Outside);
839    }
840
841    #[test]
842    fn point_on_boundary_box() {
843        let mut topo = Topology::new();
844        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
845
846        let result = classify_point(&topo, solid, Point3::new(1.0, 1.0, 2.0), 0.1, 1e-3).unwrap();
847        assert_eq!(result, PointClassification::OnBoundary);
848    }
849
850    #[test]
851    fn point_outside_negative_direction() {
852        let mut topo = Topology::new();
853        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
854
855        let result =
856            classify_point(&topo, solid, Point3::new(-5.0, -5.0, -5.0), 0.1, 1e-6).unwrap();
857        assert_eq!(result, PointClassification::Outside);
858    }
859
860    #[test]
861    fn point_near_corner() {
862        let mut topo = Topology::new();
863        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
864
865        let result = classify_point(&topo, solid, Point3::new(0.9, 0.9, 0.9), 0.1, 1e-6).unwrap();
866        assert_eq!(result, PointClassification::Inside);
867    }
868
869    #[test]
870    fn point_inside_cylinder() {
871        let mut topo = Topology::new();
872        let solid = make_cylinder(&mut topo, 2.0, 5.0).unwrap();
873
874        let result = classify_point(&topo, solid, Point3::new(0.0, 0.0, 2.5), 0.1, 1e-6).unwrap();
875        assert_eq!(result, PointClassification::Inside);
876    }
877
878    #[test]
879    fn point_outside_cylinder() {
880        let mut topo = Topology::new();
881        let solid = make_cylinder(&mut topo, 2.0, 5.0).unwrap();
882
883        let result = classify_point(&topo, solid, Point3::new(10.0, 0.0, 2.5), 0.1, 1e-6).unwrap();
884        assert_eq!(result, PointClassification::Outside);
885    }
886
887    #[test]
888    fn point_inside_sphere() {
889        let mut topo = Topology::new();
890        let solid = make_sphere(&mut topo, 3.0, 32).unwrap();
891
892        let result = classify_point(&topo, solid, Point3::new(0.0, 0.0, 0.0), 0.1, 1e-6).unwrap();
893        assert_eq!(result, PointClassification::Inside);
894    }
895
896    #[test]
897    fn point_outside_sphere() {
898        let mut topo = Topology::new();
899        let solid = make_sphere(&mut topo, 3.0, 32).unwrap();
900
901        let result = classify_point(&topo, solid, Point3::new(5.0, 0.0, 0.0), 0.1, 1e-6).unwrap();
902        assert_eq!(result, PointClassification::Outside);
903    }
904
905    #[test]
906    fn point_inside_cone() {
907        let mut topo = Topology::new();
908        let solid = make_cone(&mut topo, 2.0, 1.0, 5.0).unwrap();
909
910        // Point on the axis, inside the cone
911        let result = classify_point(&topo, solid, Point3::new(0.0, 0.0, 2.5), 0.1, 1e-6).unwrap();
912        assert_eq!(result, PointClassification::Inside);
913    }
914
915    #[test]
916    fn point_outside_cone() {
917        let mut topo = Topology::new();
918        let solid = make_cone(&mut topo, 2.0, 1.0, 5.0).unwrap();
919
920        let result = classify_point(&topo, solid, Point3::new(10.0, 0.0, 2.5), 0.1, 1e-6).unwrap();
921        assert_eq!(result, PointClassification::Outside);
922    }
923
924    #[test]
925    fn point_inside_torus() {
926        let mut topo = Topology::new();
927        // major=3, minor=1 → tube center at distance 3 from origin
928        let solid = make_torus(&mut topo, 3.0, 1.0, 32).unwrap();
929
930        // Point inside the tube (on the x-axis at distance 3 from origin)
931        let result = classify_point(&topo, solid, Point3::new(3.0, 0.0, 0.0), 0.1, 1e-6).unwrap();
932        assert_eq!(result, PointClassification::Inside);
933    }
934
935    #[test]
936    fn point_outside_torus() {
937        let mut topo = Topology::new();
938        let solid = make_torus(&mut topo, 3.0, 1.0, 32).unwrap();
939
940        // Point at origin — in the hole of the torus
941        let result = classify_point(&topo, solid, Point3::new(0.0, 0.0, 0.0), 0.1, 1e-6).unwrap();
942        assert_eq!(result, PointClassification::Outside);
943    }
944
945    #[test]
946    fn point_outside_torus_far() {
947        let mut topo = Topology::new();
948        let solid = make_torus(&mut topo, 3.0, 1.0, 32).unwrap();
949
950        // Point far from torus
951        let result = classify_point(&topo, solid, Point3::new(10.0, 0.0, 0.0), 0.1, 1e-6).unwrap();
952        assert_eq!(result, PointClassification::Outside);
953    }
954
955    /// Build the partial-turn revolve of a circle profile: one trimmed torus
956    /// band (wire = 2 closed rims + doubled seam) plus 2 planar disc caps.
957    fn make_partial_torus(
958        topo: &mut Topology,
959        big_r: f64,
960        rho: f64,
961        angle: f64,
962    ) -> brepkit_topology::solid::SolidId {
963        use brepkit_math::curves::Circle3D;
964        use brepkit_topology::edge::{Edge, EdgeCurve};
965        use brepkit_topology::face::Face;
966        use brepkit_topology::vertex::Vertex;
967        use brepkit_topology::wire::{OrientedEdge, Wire};
968
969        let circ =
970            Circle3D::new(Point3::new(big_r, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), rho).unwrap();
971        let p0 = circ.evaluate(0.0);
972        let v0 = topo.add_vertex(Vertex::new(p0, 1e-7));
973        let eid = topo.add_edge(Edge::new(v0, v0, EdgeCurve::Circle(circ)));
974        let wire = Wire::new(vec![OrientedEdge::new(eid, true)], true).unwrap();
975        let wid = topo.add_wire(wire);
976        let face = topo.add_face(Face::new(
977            wid,
978            vec![],
979            FaceSurface::Plane {
980                normal: Vec3::new(0.0, 1.0, 0.0),
981                d: 0.0,
982            },
983        ));
984        crate::revolve::revolve(
985            topo,
986            face,
987            Point3::new(0.0, 0.0, 0.0),
988            Vec3::new(0.0, 0.0, 1.0),
989            angle,
990        )
991        .unwrap()
992    }
993
994    /// Regression: the trimmed-torus band of a partial-turn revolve. Two
995    /// stacked defects made every interior point read Outside: the local
996    /// Ferrari ray-torus quartic missed real roots and emitted off-surface
997    /// spurious ones, and the UV boundary sampled closed rim circles from the
998    /// curve's parameter origin, so the two rims entered the periodic unwrap
999    /// at incoherent phases and the UV polygon rejected real band hits.
1000    #[test]
1001    fn partial_turn_torus_band_classification() {
1002        let (big_r, rho, angle) = (6.0_f64, 2.0_f64, 2.0 * PI / 3.0);
1003        let mut topo = Topology::new();
1004        let solid = make_partial_torus(&mut topo, big_r, rho, angle);
1005
1006        let mid = angle / 2.0;
1007        let inside = [
1008            Point3::new(big_r * mid.cos(), big_r * mid.sin(), 0.0),
1009            Point3::new(big_r * mid.cos(), big_r * mid.sin(), 1.0),
1010            Point3::new(big_r * mid.cos(), big_r * mid.sin(), -1.0),
1011            Point3::new(big_r * 0.05f64.cos(), big_r * 0.05f64.sin(), 0.0),
1012            Point3::new(
1013                big_r * (angle - 0.05).cos(),
1014                big_r * (angle - 0.05).sin(),
1015                0.0,
1016            ),
1017            Point3::new((big_r - 1.5) * mid.cos(), (big_r - 1.5) * mid.sin(), 0.0),
1018            Point3::new((big_r + 1.5) * mid.cos(), (big_r + 1.5) * mid.sin(), 0.0),
1019        ];
1020        for p in inside {
1021            let result = classify_point(&topo, solid, p, 0.05, 1e-6).unwrap();
1022            assert_eq!(result, PointClassification::Inside, "probe {p:?}");
1023        }
1024
1025        let outside = [
1026            Point3::new(big_r * mid.cos(), big_r * mid.sin(), 2.5),
1027            Point3::new(0.0, 0.0, 0.0),
1028            Point3::new(-big_r, 0.0, 0.0),
1029            Point3::new(
1030                big_r * (angle + 0.1).cos(),
1031                big_r * (angle + 0.1).sin(),
1032                0.0,
1033            ),
1034            Point3::new(big_r * (-0.1f64).cos(), big_r * (-0.1f64).sin(), 0.0),
1035        ];
1036        for p in outside {
1037            let result = classify_point(&topo, solid, p, 0.05, 1e-6).unwrap();
1038            assert_eq!(result, PointClassification::Outside, "probe {p:?}");
1039        }
1040    }
1041
1042    /// A full-turn revolve (single closed torus face, seam edges only) must
1043    /// keep classifying correctly alongside the partial-band fix.
1044    #[test]
1045    fn full_turn_torus_classification() {
1046        let (big_r, rho) = (6.0_f64, 2.0_f64);
1047        let mut topo = Topology::new();
1048        let solid = make_partial_torus(&mut topo, big_r, rho, 2.0 * PI);
1049
1050        for theta in [0.0_f64, 1.0, 2.5, 4.0, 5.5] {
1051            let p = Point3::new(big_r * theta.cos(), big_r * theta.sin(), 0.0);
1052            let result = classify_point(&topo, solid, p, 0.05, 1e-6).unwrap();
1053            assert_eq!(result, PointClassification::Inside, "tube center {theta}");
1054        }
1055        for p in [
1056            Point3::new(0.0, 0.0, 0.0),
1057            Point3::new(big_r, 0.0, 2.5),
1058            Point3::new(2.0 * big_r, 0.0, 0.0),
1059        ] {
1060            let result = classify_point(&topo, solid, p, 0.05, 1e-6).unwrap();
1061            assert_eq!(result, PointClassification::Outside, "probe {p:?}");
1062        }
1063    }
1064
1065    #[test]
1066    fn winding_point_inside_box() {
1067        let mut topo = Topology::new();
1068        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
1069
1070        let result =
1071            classify_point_winding(&topo, solid, Point3::new(1.0, 1.0, 1.0), 0.1, 1e-6).unwrap();
1072        assert_eq!(result, PointClassification::Inside);
1073    }
1074
1075    #[test]
1076    fn winding_point_outside_box() {
1077        let mut topo = Topology::new();
1078        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
1079
1080        let result =
1081            classify_point_winding(&topo, solid, Point3::new(5.0, 5.0, 5.0), 0.1, 1e-6).unwrap();
1082        assert_eq!(result, PointClassification::Outside);
1083    }
1084
1085    #[test]
1086    fn robust_point_inside_box() {
1087        let mut topo = Topology::new();
1088        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
1089
1090        let result =
1091            classify_point_robust(&topo, solid, Point3::new(1.0, 1.0, 1.0), 0.1, 1e-6).unwrap();
1092        assert_eq!(result, PointClassification::Inside);
1093    }
1094
1095    #[test]
1096    fn robust_point_outside_box() {
1097        let mut topo = Topology::new();
1098        let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
1099
1100        let result =
1101            classify_point_robust(&topo, solid, Point3::new(5.0, 5.0, 5.0), 0.1, 1e-6).unwrap();
1102        assert_eq!(result, PointClassification::Outside);
1103    }
1104
1105    #[test]
1106    fn quadratic_two_roots() {
1107        let mut roots = solve_quadratic(1.0, -5.0, 6.0);
1108        assert_eq!(roots.len(), 2);
1109        roots.sort_by(|a, b| a.partial_cmp(b).unwrap());
1110        let sorted = roots;
1111        assert!((sorted[0] - 2.0).abs() < 1e-10);
1112        assert!((sorted[1] - 3.0).abs() < 1e-10);
1113    }
1114
1115    #[test]
1116    fn quadratic_no_roots() {
1117        let roots = solve_quadratic(1.0, 0.0, 1.0);
1118        assert!(roots.is_empty());
1119    }
1120}