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 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
47fn face_uv_tolerance(face: &FaceRecord, u: f64, v: f64, spatial: f64) -> f64 {
53 surface_uv_band(&face.surface, u, v, spatial)
54}
55
56pub(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#[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 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
143pub(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
154pub(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
170fn exact_ray_enabled() -> bool {
177 switch!("BREP_CLASSIFY_EXACT_RAY", true)
178}
179
180
181fn 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
199pub struct SolidClassifier<'a> {
204 faces: Vec<&'a FaceRecord>,
205 face_boxes: Vec<Aabb>,
206 bounds: Aabb,
207 bvh: Bvh,
208 tolerance: f64,
209 coincident_caps: Vec<(Aabb, f64)>,
213}
214
215fn 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 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 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 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 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 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 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 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 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 pub fn with_coincident_caps(mut self, caps: Vec<(Aabb, f64)>) -> Self {
580 self.coincident_caps = caps;
581 self
582 }
583
584 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 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}