Skip to main content

brep_kernel/brep/classification/
solid_classifier.rs

1use super::*;
2
3fn face_normal(face: &FaceRecord, u: f64, v: f64) -> Result<Vec3, String> {
4    let normal = match face.surface.normal(u, v) {
5        Ok(normal) => normal,
6        Err(error) => {
7            // POLE RESCUE: at a collapsed parameterization pole (sphere pole,
8            // cone apex) du×dv vanishes and `normal()` errors — which used to
9            // propagate and kill the ENTIRE boolean (trial 9: a sphere placed
10            // TANGENT at its pole dies with "Vec3.normalized: zero-length
11            // vector" before any fragment is classified). The GEOMETRIC
12            // normal is well-defined and continuous there; recover it by
13            // stepping slightly inside the domain (at the pole every azimuth
14            // shares the same limit normal, so the offset direction does not
15            // matter). Escape hatch: BREP_POLE_NORMAL_RESCUE=0.
16            if !error.contains("zero-length")
17                || std::env::var("BREP_POLE_NORMAL_RESCUE").as_deref() == Ok("0")
18            {
19                return Err(error);
20            }
21            let [u0, u1] = face.surface.domain_u()?;
22            let [v0, v1] = face.surface.domain_v()?;
23            let step_u = (u1 - u0) * 1e-4;
24            let step_v = (v1 - v0) * 1e-4;
25            let inner_u = u.clamp(u0 + step_u, u1 - step_u);
26            let inner_v = v.clamp(v0 + step_v, v1 - step_v);
27            let mut recovered = None;
28            for (cu, cv) in [(u, inner_v), (inner_u, v), (inner_u, inner_v)] {
29                if let Ok(normal) = face.surface.normal(cu, cv) {
30                    recovered = Some(normal);
31                    break;
32                }
33            }
34            match recovered {
35                Some(normal) => normal,
36                None => return Err(error),
37            }
38        }
39    };
40    Ok(if face.same_sense {
41        normal
42    } else {
43        normal.scale(-1.0)
44    })
45}
46
47/// UV band equivalent to a spatial band of `spatial` at (u, v), derived
48/// from the local surface derivative magnitudes (`tolerance.rs`
49/// `surface_uv_tolerance`).  Capped to a fraction of the smaller domain
50/// span so a pole or collapsed direction cannot widen the band into the
51/// whole face.
52fn face_uv_tolerance(face: &FaceRecord, u: f64, v: f64, spatial: f64) -> f64 {
53    surface_uv_band(&face.surface, u, v, spatial)
54}
55
56/// [`face_uv_tolerance`] for a bare carrier.
57pub(crate) fn surface_uv_band(surface: &crate::NurbsSurface, u: f64, v: f64, spatial: f64) -> f64 {
58    let Ok(derivatives) = surface.derivatives(u, v, 1) else {
59        return spatial;
60    };
61    let band = crate::tolerance::surface_uv_tolerance(
62        spatial,
63        derivatives[1][0].length(),
64        derivatives[0][1].length(),
65    );
66    let cap = match (surface.domain_u(), surface.domain_v()) {
67        (Ok([u0, u1]), Ok([v0, v1])) => ((u1 - u0).min(v1 - v0) * 0.05).max(1e-12),
68        _ => f64::INFINITY,
69    };
70    band.min(cap)
71}
72
73#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
74#[serde(rename_all = "lowercase")]
75pub enum PointClass {
76    In,
77    Out,
78    On,
79}
80
81#[derive(Clone, Copy, Debug, Serialize)]
82pub struct PointClassification {
83    pub class: PointClass,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub on_normal: Option<Vec3>,
86}
87
88/// Per-thread counters for the classifier, printed by
89/// [`classify_profile_report`] when `BREP_PROFILE` is set: how many point
90/// queries ran, how many face projections and trim tests the On band cost,
91/// how many rays were cast and how many face intersections and trim tests
92/// they cost, and the wall time of each part. When the switch is off every
93/// counter is one thread-local flag test.
94#[derive(Default, Clone, Copy)]
95pub(crate) struct ClassifyProfile {
96    pub enabled: bool,
97    pub classify_calls: u64,
98    pub band_candidates: u64,
99    pub band_project_ms: f64,
100    pub band_trim_tests: u64,
101    pub band_trim_ms: f64,
102    pub rays: u64,
103    pub ray_faces: u64,
104    pub ray_csi_ms: f64,
105    /// Ray-face intersections the exact lane seeded (`exact_ray_enabled`) and
106    /// the ones it declined to the sampled search.
107    pub ray_exact_faces: u64,
108    pub ray_fallback_faces: u64,
109    pub ray_trim_tests: u64,
110    pub ray_trim_ms: f64,
111    pub coincident_calls: u64,
112    pub coincident_ms: f64,
113    pub classifier_builds: u64,
114    pub classifier_build_ms: f64,
115}
116
117thread_local! {
118    static CLASSIFY_PROFILE: std::cell::Cell<ClassifyProfile> =
119        const { std::cell::Cell::new(ClassifyProfile {
120            enabled: false, classify_calls: 0, band_candidates: 0, band_project_ms: 0.0,
121            band_trim_tests: 0, band_trim_ms: 0.0, rays: 0, ray_faces: 0, ray_csi_ms: 0.0,
122            ray_exact_faces: 0, ray_fallback_faces: 0, ray_trim_tests: 0, ray_trim_ms: 0.0, coincident_calls: 0, coincident_ms: 0.0,
123            classifier_builds: 0, classifier_build_ms: 0.0,
124        }) };
125}
126
127impl crate::profile::Counters for ClassifyProfile {
128    fn armed(&self) -> bool {
129        self.enabled
130    }
131}
132
133fn classify_profile(update: impl FnOnce(&mut ClassifyProfile)) {
134    crate::profile::update(&CLASSIFY_PROFILE, update)
135}
136
137fn classify_profile_started() -> Option<web_time::Instant> {
138    crate::profile::started(&CLASSIFY_PROFILE)
139}
140
141use crate::profile::elapsed_ms as classify_elapsed_ms;
142
143/// Arm the classifier counters (reset to zero) when `BREP_PROFILE` is set.
144pub(crate) fn classify_profile_begin() {
145    let enabled = crate::profile::profile_enabled();
146    CLASSIFY_PROFILE.with(|cell| {
147        cell.set(ClassifyProfile {
148            enabled,
149            ..ClassifyProfile::default()
150        })
151    });
152}
153
154/// Print the counters gathered since [`classify_profile_begin`] under
155/// `label` and disarm them. Silent when profiling is off.
156pub(crate) fn classify_profile_report(label: &str) {
157    let p = CLASSIFY_PROFILE.with(|cell| cell.get());
158    if !p.enabled {
159        return;
160    }
161    profile_line!(
162        "classify.profile {label} calls={} builds={} build_ms={:.2} band: candidates={} project_ms={:.2} trim_tests={} trim_ms={:.2} rays={} ray_faces={} csi_ms={:.2} exact_faces={} fallback_faces={} ray_trim_tests={} ray_trim_ms={:.2} coincident: calls={} ms={:.2}",
163        p.classify_calls, p.classifier_builds, p.classifier_build_ms, p.band_candidates,
164        p.band_project_ms, p.band_trim_tests, p.band_trim_ms, p.rays, p.ray_faces,
165        p.ray_csi_ms, p.ray_exact_faces, p.ray_fallback_faces, p.ray_trim_tests, p.ray_trim_ms, p.coincident_calls, p.coincident_ms,
166    );
167    CLASSIFY_PROFILE.with(|cell| cell.set(ClassifyProfile::default()));
168}
169
170/// The exact ray lane (lane B row 1, 2026-10-17): a ray against an ANALYTIC
171/// face is seeded with its closed-form roots (`crate::exact_line_surface`)
172/// instead of the sampled search's 32-sample scan, then solved and filtered
173/// by the same code. Default ON since its on/off census (production 96/96,
174/// bench 52/52 bodies and 1197/1197 lines) read identical;
175/// `BREP_CLASSIFY_EXACT_RAY=0` (or `off`) restores the sampled search.
176fn exact_ray_enabled() -> bool {
177    switch!("BREP_CLASSIFY_EXACT_RAY", true)
178}
179
180
181/// One ray-face intersection of the ray pass: the exact lane's seeds when it
182/// is on and the face admits them, the sampled search otherwise. Counted on
183/// the profile as `exact_faces` / `fallback_faces` (never timed).
184fn ray_face_intersections(
185    sub_ray: &NurbsCurve,
186    surface: &crate::NurbsSurface,
187    tolerance: f64,
188) -> Result<Vec<crate::CurveSurfaceIntersection>, String> {
189    if exact_ray_enabled() {
190        if let Some(seeds) = crate::exact_line_surface::exact_line_surface_seeds(sub_ray, surface, tolerance) {
191            classify_profile(|p| p.ray_exact_faces += 1);
192            return crate::curve_surface_intersection::intersect_curve_surface_from_seeds(sub_ray, surface, tolerance, seeds);
193        }
194        classify_profile(|p| p.ray_fallback_faces += 1);
195    }
196    intersect_curve_surface(sub_ray, surface, tolerance)
197}
198
199/// Point-in-solid classification with per-solid precomputation: face
200/// bounds, the solid box, and a face BVH are built once so repeated
201/// queries (boolean fragment selection asks once per fragment) prune to
202/// the few faces a probe point or ray can actually touch.
203pub struct SolidClassifier<'a> {
204    faces: Vec<&'a FaceRecord>,
205    face_boxes: Vec<Aabb>,
206    bounds: Aabb,
207    bvh: Bvh,
208    tolerance: f64,
209    /// `(region, cap)`: within `region` the On and coincident-On bands reach
210    /// at most `cap` (the feature-scale design's B6/B6′; empty unless the boolean's
211    /// `BREP_FEATURE_SCALE_SELECT` sets it with [`Self::with_coincident_caps`]).
212    coincident_caps: Vec<(Aabb, f64)>,
213}
214
215/// Clip the segment `start + s·direction, s ∈ [0, length]` to `bounds`
216/// (slab test).  Returns the clipped `[s_entry, s_exit]` span, or None when
217/// the segment misses the box.
218fn clip_segment_to_aabb(
219    start: Vec3,
220    direction: Vec3,
221    length: f64,
222    bounds: &Aabb,
223) -> Option<[f64; 2]> {
224    let mut s0 = 0.0f64;
225    let mut s1 = length;
226    for axis in 0..3 {
227        let (origin, delta, minimum, maximum) = match axis {
228            0 => (start.x, direction.x, bounds.minimum.x, bounds.maximum.x),
229            1 => (start.y, direction.y, bounds.minimum.y, bounds.maximum.y),
230            _ => (start.z, direction.z, bounds.minimum.z, bounds.maximum.z),
231        };
232        if delta.abs() <= 1e-15 {
233            if origin < minimum || origin > maximum {
234                return None;
235            }
236            continue;
237        }
238        let mut near = (minimum - origin) / delta;
239        let mut far = (maximum - origin) / delta;
240        if near > far {
241            std::mem::swap(&mut near, &mut far);
242        }
243        s0 = s0.max(near);
244        s1 = s1.min(far);
245        if s0 > s1 {
246            return None;
247        }
248    }
249    Some([s0, s1])
250}
251
252impl<'a> SolidClassifier<'a> {
253    pub fn new(solid: &'a BrepSolid, tolerance: f64) -> Result<Self, String> {
254        let build_started = classify_profile_started();
255        let faces: Vec<&FaceRecord> = solid.shells.iter().flat_map(|shell| &shell.faces).collect();
256        let face_boxes = faces
257            .iter()
258            .map(|face| Aabb::from_surface_controls(&face.surface))
259            .collect::<Result<Vec<_>, _>>()?;
260        let mut bounds = Aabb::empty();
261        for face_box in &face_boxes {
262            bounds.include(*face_box);
263        }
264        let bvh = Bvh::build(&face_boxes);
265        classify_profile(|p| {
266            p.classifier_builds += 1;
267            p.classifier_build_ms += classify_elapsed_ms(build_started);
268        });
269        Ok(Self {
270            faces,
271            face_boxes,
272            bounds,
273            bvh,
274            tolerance,
275            coincident_caps: Vec::new(),
276        })
277    }
278
279    /// Cheap On-band probe: is the point within the classifier's On
280    /// tolerance of ANY face carrier (trim not checked — conservative)?
281    /// Used to guard adjacency propagation: fragments near the other
282    /// solid's surface need the full normal-based On decision, everything
283    /// else can inherit its fate from a neighbor.
284    pub fn near_surface(&self, point: Vec3) -> Result<bool, String> {
285        let on_tolerance = self.tolerance * 10.0;
286        if !self.bounds.expanded(on_tolerance).contains(point) {
287            return Ok(false);
288        }
289        let mut candidates = Vec::new();
290        self.bvh
291            .containing_point(point, on_tolerance, &mut candidates);
292        for &index in &candidates {
293            let projection = project_point_to_surface(&self.faces[index].surface, point)?;
294            if projection.distance <= on_tolerance {
295                return Ok(true);
296            }
297        }
298        Ok(false)
299    }
300
301    /// Conservative carrier-proximity probe: is `point` within `band` of ANY
302    /// face's carrier surface (trim NOT checked)?  A strict superset of the
303    /// `On` verdict (which additionally requires the point to fall in a face's
304    /// trim), so callers that want to skip every point that *might* be on or
305    /// near the boundary — the semantic oracle's On-skip — can rely on a `true`
306    /// here to mean "not safely In/Out".  `band` is taken explicitly so the
307    /// caller can widen it to a size-relative width and absorb near-coincidence
308    /// gaps (Golovanov §4.13 derived tolerances).
309    pub fn within_band(&self, point: Vec3, band: f64) -> Result<bool, String> {
310        if !self.bounds.expanded(band).contains(point) {
311            return Ok(false);
312        }
313        let mut candidates = Vec::new();
314        self.bvh.containing_point(point, band, &mut candidates);
315        for &index in &candidates {
316            let projection = project_point_to_surface(&self.faces[index].surface, point)?;
317            if projection.distance <= band {
318                return Ok(true);
319            }
320        }
321        Ok(false)
322    }
323
324    pub fn classify(&self, point: Vec3) -> Result<PointClassification, String> {
325    let _wall = crate::wall_profile::Scope::enter(crate::wall_profile::CLASSIFY);
326        classify_profile(|p| p.classify_calls += 1);
327        let tolerance = self.tolerance;
328        if !self.bounds.expanded(tolerance).contains(point) {
329            return Ok(PointClassification {
330                class: PointClass::Out,
331                on_normal: None,
332            });
333        }
334        // FEATURE SCALE B6′ (`BREP_FEATURE_SCALE_SELECT`; no caps without it):
335        // the On band (1e-6) reaches across a slab 1e-6 thick, so the middle
336        // of the hairline slot's strips read On its walls, not In the saw.
337        let on_tolerance = self.capped_band(point, tolerance * 10.0);
338        if !self.coincident_caps.is_empty() && env_read!("BREP_DEBUG_FEATURE_SCALE").is_some() {
339            eprintln!("feature_scale: classify at {point:?} on band {on_tolerance:.3e} (uncapped {:.3e}, {} caps)", tolerance * 10.0, self.coincident_caps.len());
340        }
341        let mut candidates = Vec::new();
342        self.bvh
343            .containing_point(point, on_tolerance, &mut candidates);
344        candidates.sort_unstable();
345        // All faces the point lies On, split by whether it sits in the trim
346        // interior or within the boundary band.  Where the point is on an
347        // edge or vertex shared by several faces, the classification normal
348        // is the normalized sum of their normals (Golovanov §4.11/§6.3) —
349        // a single face's normal is ambiguous there, and falling through to
350        // ray casting made the answer direction-dependent.
351        let mut interior_normals: Vec<Vec3> = Vec::new();
352        let mut boundary_normals: Vec<Vec3> = Vec::new();
353        for &index in &candidates {
354            let face = self.faces[index];
355            let project_started = classify_profile_started();
356            let projection = project_point_to_surface(&face.surface, point)?;
357            classify_profile(|p| {
358                p.band_candidates += 1;
359                p.band_project_ms += classify_elapsed_ms(project_started);
360            });
361            if projection.distance > on_tolerance {
362                continue;
363            }
364            let uv_tolerance = face_uv_tolerance(face, projection.u, projection.v, on_tolerance);
365            let trim_started = classify_profile_started();
366            let trim = parameter_point_in_face(
367                face,
368                Vec2 {
369                    x: projection.u,
370                    y: projection.v,
371                },
372                uv_tolerance,
373            )?;
374            classify_profile(|p| {
375                p.band_trim_tests += 1;
376                p.band_trim_ms += classify_elapsed_ms(trim_started);
377            });
378            match trim {
379                PolygonClass::Inside => {
380                    interior_normals.push(face_normal(face, projection.u, projection.v)?)
381                }
382                PolygonClass::Boundary => {
383                    boundary_normals.push(face_normal(face, projection.u, projection.v)?)
384                }
385                PolygonClass::Outside => {}
386            }
387        }
388        let pool = if interior_normals.is_empty() {
389            &boundary_normals
390        } else {
391            &interior_normals
392        };
393        if !pool.is_empty() {
394            let mut sum = Vec3::default();
395            for normal in pool {
396                sum = sum.add(*normal);
397            }
398            // A near-zero sum means opposed normals (knife edge, coincident
399            // back-to-back faces): genuinely ambiguous, let the ray casting
400            // below decide.
401            if sum.length() > 1e-3 {
402                return Ok(PointClassification {
403                    class: PointClass::On,
404                    on_normal: Some(sum.normalized()?),
405                });
406            }
407        }
408        let directions = [
409            Vec3::new(0.577215, 0.618034, 0.532088),
410            Vec3::new(-0.707107, 0.267949, 0.654321),
411            Vec3::new(0.316228, -0.741657, 0.585786),
412            Vec3::new(-0.414214, -0.552786, -0.723607),
413            Vec3::new(0.9482, 0.11893, -0.29456),
414            Vec3::new(-0.13947, 0.90271, -0.40718),
415            Vec3::new(0.62361, -0.33912, -0.70414),
416            Vec3::new(0.20912, 0.51293, 0.83261),
417        ];
418        let ray_length = self.bounds.diagonal() * 3.0 + 1.0;
419        // RAY-AGREEMENT VOTING: a single ray's parity flips when the root
420        // finder loses one of two close crossings through a thin feature
421        // (trial 533's 00000231-p4: a ray pierced a curved flange twice ~3 mm
422        // apart, intersect_curve_surface returned one root, and a point 2 mm
423        // clear of the material classified In). One lost root is a
424        // direction-specific accident, so require TWO clean directions to
425        // AGREE before trusting the parity; on disagreement keep sampling
426        // directions and return the first verdict confirmed twice. Escape
427        // hatch: BREP_CLASSIFY_RAY_AGREE=0 restores first-clean-ray.
428        let require_agreement = std::env::var("BREP_CLASSIFY_RAY_AGREE").as_deref() != Ok("0");
429        let mut verdicts: Vec<PointClass> = Vec::new();
430        'directions: for direction in directions {
431            classify_profile(|p| p.rays += 1);
432            let direction = direction.normalized()?;
433            let ray_end = point.add(direction.scale(ray_length));
434            candidates.clear();
435            self.bvh
436                .intersecting_segment(point, ray_end, on_tolerance, &mut candidates);
437            candidates.sort_unstable();
438            let mut crossings = 0;
439            for &index in &candidates {
440                let face = self.faces[index];
441                // Intersect the ray with this face over the SHORT span the
442                // ray actually spends near the face's bounding box, not the
443                // whole solid-diagonal-scale ray.  The root finder seeds one
444                // Newton start per curve sample segment, so a long ray gives
445                // a small face a single seed — a ray that crosses a small
446                // curved face twice within one sample segment (e.g. a probe
447                // from a tangency line through a blend tube) silently loses
448                // a crossing and flips the parity.  Clipping the ray to the
449                // face box makes the seed density match the face scale.
450                let margin = on_tolerance.max(self.face_boxes[index].diagonal() * 1e-3);
451                let Some([span_start, span_end]) = clip_segment_to_aabb(
452                    point,
453                    direction,
454                    ray_length,
455                    &self.face_boxes[index].expanded(margin),
456                ) else {
457                    continue;
458                };
459                let span_start = (span_start - margin).max(0.0);
460                let span_end = (span_end + margin).min(ray_length);
461                if span_end - span_start <= 1e-12 {
462                    continue;
463                }
464                let sub_ray = make_line(
465                    point.add(direction.scale(span_start)),
466                    point.add(direction.scale(span_end)),
467                )?;
468                let csi_started = classify_profile_started();
469                let intersections = ray_face_intersections(&sub_ray, &face.surface, tolerance)?;
470                classify_profile(|p| {
471                    p.ray_faces += 1;
472                    p.ray_csi_ms += classify_elapsed_ms(csi_started);
473                });
474                for intersection in intersections {
475                    if intersection.point.sub(point).length() <= on_tolerance {
476                        let trim_started = classify_profile_started();
477                        let trim = parameter_point_in_face(
478                            face,
479                            Vec2 {
480                                x: intersection.u,
481                                y: intersection.v,
482                            },
483                            face_uv_tolerance(face, intersection.u, intersection.v, on_tolerance),
484                        )?;
485                        classify_profile(|p| {
486                            p.ray_trim_tests += 1;
487                            p.ray_trim_ms += classify_elapsed_ms(trim_started);
488                        });
489                        if trim != PolygonClass::Outside {
490                            return Ok(PointClassification {
491                                class: PointClass::On,
492                                on_normal: Some(face_normal(face, intersection.u, intersection.v)?),
493                            });
494                        }
495                        continue;
496                    }
497                    if intersection.tangential {
498                        continue 'directions;
499                    }
500                    let trim_started = classify_profile_started();
501                    let trim_class = parameter_point_in_face(
502                        face,
503                        Vec2 {
504                            x: intersection.u,
505                            y: intersection.v,
506                        },
507                        face_uv_tolerance(face, intersection.u, intersection.v, on_tolerance),
508                    )?;
509                    classify_profile(|p| {
510                        p.ray_trim_tests += 1;
511                        p.ray_trim_ms += classify_elapsed_ms(trim_started);
512                    });
513                    if std::env::var("BREP_DEBUG_CLASSIFY").is_ok() {
514                        eprintln!(
515                            "classify ray dir=({:.3},{:.3},{:.3}) face={} hit=({:.4},{:.4},{:.4}) t3d={:.4} uv=({:.6},{:.6}) trim={:?}",
516                            direction.x, direction.y, direction.z,
517                            face.id,
518                            intersection.point.x, intersection.point.y, intersection.point.z,
519                            intersection.point.sub(point).length(),
520                            intersection.u, intersection.v,
521                            trim_class
522                        );
523                    }
524                    match trim_class {
525                        PolygonClass::Boundary => continue 'directions,
526                        PolygonClass::Inside => crossings += 1,
527                        PolygonClass::Outside => {}
528                    }
529                }
530            }
531            let verdict = if crossings % 2 == 1 {
532                PointClass::In
533            } else {
534                PointClass::Out
535            };
536            if std::env::var("BREP_DEBUG_CLASSIFY").is_ok() {
537                eprintln!(
538                    "classify verdict dir=({:.3},{:.3},{:.3}) crossings={crossings} -> {verdict:?}",
539                    direction.x, direction.y, direction.z
540                );
541            }
542            if !require_agreement || verdicts.contains(&verdict) {
543                return Ok(PointClassification {
544                    class: verdict,
545                    on_normal: None,
546                });
547            }
548            verdicts.push(verdict);
549        }
550        // Every direction bailed (tangential/boundary) or, under agreement
551        // voting, the clean directions never confirmed one another. A single
552        // unconfirmed verdict is still far better than an error.
553        if let Some(&verdict) = verdicts.last() {
554            return Ok(PointClassification {
555                class: verdict,
556                on_normal: None,
557            });
558        }
559        Err("classifyPointVsSolid: no clean ray direction found".into())
560    }
561
562    /// Wider-band On probe for near-coincident faces.  A model built from
563    /// noisy input carries faces that are geometrically coincident with the
564    /// other solid's boundary yet separated by a gap that scales with the
565    /// model (a few microns), not with the absolute model tolerance.  Such a
566    /// gap slips a face fragment's interior test point past the fixed On band
567    /// (`classify`), so it reads as a stray In/Out.  This probe re-checks
568    /// whether `point` sits in the trim INTERIOR of a coincident boundary
569    /// face within a band derived from the solid's size (Golovanov §4.13
570    /// derived tolerances), returning the mean outward normal of the
571    /// coincident faces when it does.  It only ever RESCUES an On verdict —
572    /// callers keep the tight-band In/Out otherwise — so the general
573    /// classification path is unchanged.  Interior-only (boundary hits are
574    /// ignored) so it fires only on a genuine surface overlap, never on mere
575    /// proximity to an edge.
576    /// Caps the coincident-On band within each region (see
577    /// `coincident_caps`): a test point a sliver's width from the other
578    /// solid's face is a feature, not noise to rescue to On.
579    pub fn with_coincident_caps(mut self, caps: Vec<(Aabb, f64)>) -> Self {
580        self.coincident_caps = caps;
581        self
582    }
583
584    /// `band` capped by every `coincident_caps` region within its reach of
585    /// `point`, strictly (a point exactly at a cap's distance is a feature).
586    fn capped_band(&self, point: Vec3, band: f64) -> f64 {
587        self.coincident_caps
588            .iter()
589            .filter(|(region, _)| region.expanded(band).contains(point))
590            .fold(band, |band, &(_, cap)| band.min(cap * (1.0 - 1e-9)))
591    }
592
593    pub fn coincident_on_normal(&self, point: Vec3) -> Result<Option<Vec3>, String> {
594        let started = classify_profile_started();
595        let result = self.coincident_on_normal_inner(point);
596        classify_profile(|p| {
597            p.coincident_calls += 1;
598            p.coincident_ms += classify_elapsed_ms(started);
599        });
600        result
601    }
602
603    fn coincident_on_normal_inner(&self, point: Vec3) -> Result<Option<Vec3>, String> {
604        let band = (self.tolerance * 10.0).max(self.bounds.diagonal() * 1e-7);
605        // A point at a capped band's edge is a feature, not noise: strictly
606        // inside the cap only (`< cap`, as the projection test below reads
607        // `> band` as off).
608        let band = self.capped_band(point, band);
609        if !self.bounds.expanded(band).contains(point) {
610            return Ok(None);
611        }
612        let mut candidates = Vec::new();
613        self.bvh.containing_point(point, band, &mut candidates);
614        let mut interior_normals: Vec<Vec3> = Vec::new();
615        for &index in &candidates {
616            let face = self.faces[index];
617            let projection = project_point_to_surface(&face.surface, point)?;
618            if projection.distance > band {
619                continue;
620            }
621            let uv_tolerance = face_uv_tolerance(face, projection.u, projection.v, band);
622            if let PolygonClass::Inside = parameter_point_in_face(
623                face,
624                Vec2 {
625                    x: projection.u,
626                    y: projection.v,
627                },
628                uv_tolerance,
629            )? {
630                interior_normals.push(face_normal(face, projection.u, projection.v)?);
631            }
632        }
633        if interior_normals.is_empty() {
634            return Ok(None);
635        }
636        let mut sum = Vec3::default();
637        for normal in &interior_normals {
638            sum = sum.add(*normal);
639        }
640        if sum.length() <= 1e-3 {
641            return Ok(None);
642        }
643        Ok(Some(sum.normalized()?))
644    }
645}
646
647pub fn classify_point(
648    point: Vec3,
649    solid: &BrepSolid,
650    tolerance: f64,
651) -> Result<PointClassification, String> {
652    let _wall = crate::wall_profile::Scope::enter(crate::wall_profile::CLASSIFY);
653    classify_profile_begin();
654    let result = SolidClassifier::new(solid, tolerance)?.classify(point);
655    classify_profile_report("classify_point");
656    result
657}