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    let Ok(derivatives) = face.surface.derivatives(u, v, 1) else {
54        return spatial;
55    };
56    let band = crate::tolerance::surface_uv_tolerance(
57        spatial,
58        derivatives[1][0].length(),
59        derivatives[0][1].length(),
60    );
61    let cap = match (face.surface.domain_u(), face.surface.domain_v()) {
62        (Ok([u0, u1]), Ok([v0, v1])) => ((u1 - u0).min(v1 - v0) * 0.05).max(1e-12),
63        _ => f64::INFINITY,
64    };
65    band.min(cap)
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
69#[serde(rename_all = "lowercase")]
70pub enum PointClass {
71    In,
72    Out,
73    On,
74}
75
76#[derive(Clone, Copy, Debug, Serialize)]
77pub struct PointClassification {
78    pub class: PointClass,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub on_normal: Option<Vec3>,
81}
82
83/// Point-in-solid classification with per-solid precomputation: face
84/// bounds, the solid box, and a face BVH are built once so repeated
85/// queries (boolean fragment selection asks once per fragment) prune to
86/// the few faces a probe point or ray can actually touch.
87pub struct SolidClassifier<'a> {
88    faces: Vec<&'a FaceRecord>,
89    face_boxes: Vec<Aabb>,
90    bounds: Aabb,
91    bvh: Bvh,
92    tolerance: f64,
93}
94
95/// Clip the segment `start + s·direction, s ∈ [0, length]` to `bounds`
96/// (slab test).  Returns the clipped `[s_entry, s_exit]` span, or None when
97/// the segment misses the box.
98fn clip_segment_to_aabb(
99    start: Vec3,
100    direction: Vec3,
101    length: f64,
102    bounds: &Aabb,
103) -> Option<[f64; 2]> {
104    let mut s0 = 0.0f64;
105    let mut s1 = length;
106    for axis in 0..3 {
107        let (origin, delta, minimum, maximum) = match axis {
108            0 => (start.x, direction.x, bounds.minimum.x, bounds.maximum.x),
109            1 => (start.y, direction.y, bounds.minimum.y, bounds.maximum.y),
110            _ => (start.z, direction.z, bounds.minimum.z, bounds.maximum.z),
111        };
112        if delta.abs() <= 1e-15 {
113            if origin < minimum || origin > maximum {
114                return None;
115            }
116            continue;
117        }
118        let mut near = (minimum - origin) / delta;
119        let mut far = (maximum - origin) / delta;
120        if near > far {
121            std::mem::swap(&mut near, &mut far);
122        }
123        s0 = s0.max(near);
124        s1 = s1.min(far);
125        if s0 > s1 {
126            return None;
127        }
128    }
129    Some([s0, s1])
130}
131
132impl<'a> SolidClassifier<'a> {
133    pub fn new(solid: &'a BrepSolid, tolerance: f64) -> Result<Self, String> {
134        let faces: Vec<&FaceRecord> = solid.shells.iter().flat_map(|shell| &shell.faces).collect();
135        let face_boxes = faces
136            .iter()
137            .map(|face| Aabb::from_surface_controls(&face.surface))
138            .collect::<Result<Vec<_>, _>>()?;
139        let mut bounds = Aabb::empty();
140        for face_box in &face_boxes {
141            bounds.include(*face_box);
142        }
143        let bvh = Bvh::build(&face_boxes);
144        Ok(Self {
145            faces,
146            face_boxes,
147            bounds,
148            bvh,
149            tolerance,
150        })
151    }
152
153    /// Cheap On-band probe: is the point within the classifier's On
154    /// tolerance of ANY face carrier (trim not checked — conservative)?
155    /// Used to guard adjacency propagation: fragments near the other
156    /// solid's surface need the full normal-based On decision, everything
157    /// else can inherit its fate from a neighbor.
158    pub fn near_surface(&self, point: Vec3) -> Result<bool, String> {
159        let on_tolerance = self.tolerance * 10.0;
160        if !self.bounds.expanded(on_tolerance).contains(point) {
161            return Ok(false);
162        }
163        let mut candidates = Vec::new();
164        self.bvh
165            .containing_point(point, on_tolerance, &mut candidates);
166        for &index in &candidates {
167            let projection = project_point_to_surface(&self.faces[index].surface, point)?;
168            if projection.distance <= on_tolerance {
169                return Ok(true);
170            }
171        }
172        Ok(false)
173    }
174
175    /// Conservative carrier-proximity probe: is `point` within `band` of ANY
176    /// face's carrier surface (trim NOT checked)?  A strict superset of the
177    /// `On` verdict (which additionally requires the point to fall in a face's
178    /// trim), so callers that want to skip every point that *might* be on or
179    /// near the boundary — the semantic oracle's On-skip — can rely on a `true`
180    /// here to mean "not safely In/Out".  `band` is taken explicitly so the
181    /// caller can widen it to a size-relative width and absorb near-coincidence
182    /// gaps (Golovanov §4.13 derived tolerances).
183    pub fn within_band(&self, point: Vec3, band: f64) -> Result<bool, String> {
184        if !self.bounds.expanded(band).contains(point) {
185            return Ok(false);
186        }
187        let mut candidates = Vec::new();
188        self.bvh.containing_point(point, band, &mut candidates);
189        for &index in &candidates {
190            let projection = project_point_to_surface(&self.faces[index].surface, point)?;
191            if projection.distance <= band {
192                return Ok(true);
193            }
194        }
195        Ok(false)
196    }
197
198    pub fn classify(&self, point: Vec3) -> Result<PointClassification, String> {
199        let tolerance = self.tolerance;
200        if !self.bounds.expanded(tolerance).contains(point) {
201            return Ok(PointClassification {
202                class: PointClass::Out,
203                on_normal: None,
204            });
205        }
206        let on_tolerance = tolerance * 10.0;
207        let mut candidates = Vec::new();
208        self.bvh
209            .containing_point(point, on_tolerance, &mut candidates);
210        candidates.sort_unstable();
211        // All faces the point lies On, split by whether it sits in the trim
212        // interior or within the boundary band.  Where the point is on an
213        // edge or vertex shared by several faces, the classification normal
214        // is the normalized sum of their normals (Golovanov §4.11/§6.3) —
215        // a single face's normal is ambiguous there, and falling through to
216        // ray casting made the answer direction-dependent.
217        let mut interior_normals: Vec<Vec3> = Vec::new();
218        let mut boundary_normals: Vec<Vec3> = Vec::new();
219        for &index in &candidates {
220            let face = self.faces[index];
221            let projection = project_point_to_surface(&face.surface, point)?;
222            if projection.distance > on_tolerance {
223                continue;
224            }
225            let uv_tolerance = face_uv_tolerance(face, projection.u, projection.v, on_tolerance);
226            match parameter_point_in_face(
227                face,
228                Vec2 {
229                    x: projection.u,
230                    y: projection.v,
231                },
232                uv_tolerance,
233            )? {
234                PolygonClass::Inside => {
235                    interior_normals.push(face_normal(face, projection.u, projection.v)?)
236                }
237                PolygonClass::Boundary => {
238                    boundary_normals.push(face_normal(face, projection.u, projection.v)?)
239                }
240                PolygonClass::Outside => {}
241            }
242        }
243        let pool = if interior_normals.is_empty() {
244            &boundary_normals
245        } else {
246            &interior_normals
247        };
248        if !pool.is_empty() {
249            let mut sum = Vec3::default();
250            for normal in pool {
251                sum = sum.add(*normal);
252            }
253            // A near-zero sum means opposed normals (knife edge, coincident
254            // back-to-back faces): genuinely ambiguous, let the ray casting
255            // below decide.
256            if sum.length() > 1e-3 {
257                return Ok(PointClassification {
258                    class: PointClass::On,
259                    on_normal: Some(sum.normalized()?),
260                });
261            }
262        }
263        let directions = [
264            Vec3::new(0.577215, 0.618034, 0.532088),
265            Vec3::new(-0.707107, 0.267949, 0.654321),
266            Vec3::new(0.316228, -0.741657, 0.585786),
267            Vec3::new(-0.414214, -0.552786, -0.723607),
268            Vec3::new(0.9482, 0.11893, -0.29456),
269            Vec3::new(-0.13947, 0.90271, -0.40718),
270            Vec3::new(0.62361, -0.33912, -0.70414),
271            Vec3::new(0.20912, 0.51293, 0.83261),
272        ];
273        let ray_length = self.bounds.diagonal() * 3.0 + 1.0;
274        // RAY-AGREEMENT VOTING: a single ray's parity flips when the root
275        // finder loses one of two close crossings through a thin feature
276        // (trial 533's 00000231-p4: a ray pierced a curved flange twice ~3 mm
277        // apart, intersect_curve_surface returned one root, and a point 2 mm
278        // clear of the material classified In). One lost root is a
279        // direction-specific accident, so require TWO clean directions to
280        // AGREE before trusting the parity; on disagreement keep sampling
281        // directions and return the first verdict confirmed twice. Escape
282        // hatch: BREP_CLASSIFY_RAY_AGREE=0 restores first-clean-ray.
283        let require_agreement = std::env::var("BREP_CLASSIFY_RAY_AGREE").as_deref() != Ok("0");
284        let mut verdicts: Vec<PointClass> = Vec::new();
285        'directions: for direction in directions {
286            let direction = direction.normalized()?;
287            let ray_end = point.add(direction.scale(ray_length));
288            candidates.clear();
289            self.bvh
290                .intersecting_segment(point, ray_end, on_tolerance, &mut candidates);
291            candidates.sort_unstable();
292            let mut crossings = 0;
293            for &index in &candidates {
294                let face = self.faces[index];
295                // Intersect the ray with this face over the SHORT span the
296                // ray actually spends near the face's bounding box, not the
297                // whole solid-diagonal-scale ray.  The root finder seeds one
298                // Newton start per curve sample segment, so a long ray gives
299                // a small face a single seed — a ray that crosses a small
300                // curved face twice within one sample segment (e.g. a probe
301                // from a tangency line through a blend tube) silently loses
302                // a crossing and flips the parity.  Clipping the ray to the
303                // face box makes the seed density match the face scale.
304                let margin = on_tolerance.max(self.face_boxes[index].diagonal() * 1e-3);
305                let Some([span_start, span_end]) = clip_segment_to_aabb(
306                    point,
307                    direction,
308                    ray_length,
309                    &self.face_boxes[index].expanded(margin),
310                ) else {
311                    continue;
312                };
313                let span_start = (span_start - margin).max(0.0);
314                let span_end = (span_end + margin).min(ray_length);
315                if span_end - span_start <= 1e-12 {
316                    continue;
317                }
318                let sub_ray = make_line(
319                    point.add(direction.scale(span_start)),
320                    point.add(direction.scale(span_end)),
321                )?;
322                for intersection in intersect_curve_surface(&sub_ray, &face.surface, tolerance)? {
323                    if intersection.point.sub(point).length() <= tolerance * 10.0 {
324                        let trim = parameter_point_in_face(
325                            face,
326                            Vec2 {
327                                x: intersection.u,
328                                y: intersection.v,
329                            },
330                            face_uv_tolerance(face, intersection.u, intersection.v, on_tolerance),
331                        )?;
332                        if trim != PolygonClass::Outside {
333                            return Ok(PointClassification {
334                                class: PointClass::On,
335                                on_normal: Some(face_normal(face, intersection.u, intersection.v)?),
336                            });
337                        }
338                        continue;
339                    }
340                    if intersection.tangential {
341                        continue 'directions;
342                    }
343                    let trim_class = parameter_point_in_face(
344                        face,
345                        Vec2 {
346                            x: intersection.u,
347                            y: intersection.v,
348                        },
349                        face_uv_tolerance(face, intersection.u, intersection.v, on_tolerance),
350                    )?;
351                    if std::env::var("BREP_DEBUG_CLASSIFY").is_ok() {
352                        eprintln!(
353                            "classify ray dir=({:.3},{:.3},{:.3}) face={} hit=({:.4},{:.4},{:.4}) t3d={:.4} uv=({:.6},{:.6}) trim={:?}",
354                            direction.x, direction.y, direction.z,
355                            face.id,
356                            intersection.point.x, intersection.point.y, intersection.point.z,
357                            intersection.point.sub(point).length(),
358                            intersection.u, intersection.v,
359                            trim_class
360                        );
361                    }
362                    match trim_class {
363                        PolygonClass::Boundary => continue 'directions,
364                        PolygonClass::Inside => crossings += 1,
365                        PolygonClass::Outside => {}
366                    }
367                }
368            }
369            let verdict = if crossings % 2 == 1 {
370                PointClass::In
371            } else {
372                PointClass::Out
373            };
374            if std::env::var("BREP_DEBUG_CLASSIFY").is_ok() {
375                eprintln!(
376                    "classify verdict dir=({:.3},{:.3},{:.3}) crossings={crossings} -> {verdict:?}",
377                    direction.x, direction.y, direction.z
378                );
379            }
380            if !require_agreement || verdicts.contains(&verdict) {
381                return Ok(PointClassification {
382                    class: verdict,
383                    on_normal: None,
384                });
385            }
386            verdicts.push(verdict);
387        }
388        // Every direction bailed (tangential/boundary) or, under agreement
389        // voting, the clean directions never confirmed one another. A single
390        // unconfirmed verdict is still far better than an error.
391        if let Some(&verdict) = verdicts.last() {
392            return Ok(PointClassification {
393                class: verdict,
394                on_normal: None,
395            });
396        }
397        Err("classifyPointVsSolid: no clean ray direction found".into())
398    }
399
400    /// Wider-band On probe for near-coincident faces.  A model built from
401    /// noisy input carries faces that are geometrically coincident with the
402    /// other solid's boundary yet separated by a gap that scales with the
403    /// model (a few microns), not with the absolute model tolerance.  Such a
404    /// gap slips a face fragment's interior test point past the fixed On band
405    /// (`classify`), so it reads as a stray In/Out.  This probe re-checks
406    /// whether `point` sits in the trim INTERIOR of a coincident boundary
407    /// face within a band derived from the solid's size (Golovanov §4.13
408    /// derived tolerances), returning the mean outward normal of the
409    /// coincident faces when it does.  It only ever RESCUES an On verdict —
410    /// callers keep the tight-band In/Out otherwise — so the general
411    /// classification path is unchanged.  Interior-only (boundary hits are
412    /// ignored) so it fires only on a genuine surface overlap, never on mere
413    /// proximity to an edge.
414    pub fn coincident_on_normal(&self, point: Vec3) -> Result<Option<Vec3>, String> {
415        let band = (self.tolerance * 10.0).max(self.bounds.diagonal() * 1e-7);
416        if !self.bounds.expanded(band).contains(point) {
417            return Ok(None);
418        }
419        let mut candidates = Vec::new();
420        self.bvh.containing_point(point, band, &mut candidates);
421        let mut interior_normals: Vec<Vec3> = Vec::new();
422        for &index in &candidates {
423            let face = self.faces[index];
424            let projection = project_point_to_surface(&face.surface, point)?;
425            if projection.distance > band {
426                continue;
427            }
428            let uv_tolerance = face_uv_tolerance(face, projection.u, projection.v, band);
429            if let PolygonClass::Inside = parameter_point_in_face(
430                face,
431                Vec2 {
432                    x: projection.u,
433                    y: projection.v,
434                },
435                uv_tolerance,
436            )? {
437                interior_normals.push(face_normal(face, projection.u, projection.v)?);
438            }
439        }
440        if interior_normals.is_empty() {
441            return Ok(None);
442        }
443        let mut sum = Vec3::default();
444        for normal in &interior_normals {
445            sum = sum.add(*normal);
446        }
447        if sum.length() <= 1e-3 {
448            return Ok(None);
449        }
450        Ok(Some(sum.normalized()?))
451    }
452}
453
454pub fn classify_point(
455    point: Vec3,
456    solid: &BrepSolid,
457    tolerance: f64,
458) -> Result<PointClassification, String> {
459    SolidClassifier::new(solid, tolerance)?.classify(point)
460}