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
127fn classify_profile(update: impl FnOnce(&mut ClassifyProfile)) {
128 CLASSIFY_PROFILE.with(|cell| {
129 let mut profile = cell.get();
130 if profile.enabled {
131 update(&mut profile);
132 cell.set(profile);
133 }
134 });
135}
136
137fn classify_profile_started() -> Option<web_time::Instant> {
138 CLASSIFY_PROFILE
139 .with(|cell| cell.get().enabled)
140 .then(web_time::Instant::now)
141}
142
143fn classify_elapsed_ms(started: Option<web_time::Instant>) -> f64 {
144 started.map_or(0.0, |s| s.elapsed().as_secs_f64() * 1_000.0)
145}
146
147pub(crate) fn classify_profile_begin() {
149 let enabled = std::env::var("BREP_PROFILE").is_ok();
150 CLASSIFY_PROFILE.with(|cell| {
151 cell.set(ClassifyProfile {
152 enabled,
153 ..ClassifyProfile::default()
154 })
155 });
156}
157
158pub(crate) fn classify_profile_report(label: &str) {
161 let p = CLASSIFY_PROFILE.with(|cell| cell.get());
162 if !p.enabled {
163 return;
164 }
165 eprintln!(
166 "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}",
167 p.classify_calls, p.classifier_builds, p.classifier_build_ms, p.band_candidates,
168 p.band_project_ms, p.band_trim_tests, p.band_trim_ms, p.rays, p.ray_faces,
169 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,
170 );
171 CLASSIFY_PROFILE.with(|cell| cell.set(ClassifyProfile::default()));
172}
173
174fn exact_ray_enabled() -> bool {
181 static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
182 let enabled = *ENABLED.get_or_init(|| {
183 !matches!(std::env::var("BREP_CLASSIFY_EXACT_RAY").as_deref(), Ok("0") | Ok("off"))
184 });
185 enabled
186}
187
188
189fn ray_face_intersections(
193 sub_ray: &NurbsCurve,
194 surface: &crate::NurbsSurface,
195 tolerance: f64,
196) -> Result<Vec<crate::CurveSurfaceIntersection>, String> {
197 if exact_ray_enabled() {
198 if let Some(seeds) = crate::exact_line_surface::exact_line_surface_seeds(sub_ray, surface, tolerance) {
199 classify_profile(|p| p.ray_exact_faces += 1);
200 return crate::curve_surface_intersection::intersect_curve_surface_from_seeds(sub_ray, surface, tolerance, seeds);
201 }
202 classify_profile(|p| p.ray_fallback_faces += 1);
203 }
204 intersect_curve_surface(sub_ray, surface, tolerance)
205}
206
207pub struct SolidClassifier<'a> {
212 faces: Vec<&'a FaceRecord>,
213 face_boxes: Vec<Aabb>,
214 bounds: Aabb,
215 bvh: Bvh,
216 tolerance: f64,
217}
218
219fn clip_segment_to_aabb(
223 start: Vec3,
224 direction: Vec3,
225 length: f64,
226 bounds: &Aabb,
227) -> Option<[f64; 2]> {
228 let mut s0 = 0.0f64;
229 let mut s1 = length;
230 for axis in 0..3 {
231 let (origin, delta, minimum, maximum) = match axis {
232 0 => (start.x, direction.x, bounds.minimum.x, bounds.maximum.x),
233 1 => (start.y, direction.y, bounds.minimum.y, bounds.maximum.y),
234 _ => (start.z, direction.z, bounds.minimum.z, bounds.maximum.z),
235 };
236 if delta.abs() <= 1e-15 {
237 if origin < minimum || origin > maximum {
238 return None;
239 }
240 continue;
241 }
242 let mut near = (minimum - origin) / delta;
243 let mut far = (maximum - origin) / delta;
244 if near > far {
245 std::mem::swap(&mut near, &mut far);
246 }
247 s0 = s0.max(near);
248 s1 = s1.min(far);
249 if s0 > s1 {
250 return None;
251 }
252 }
253 Some([s0, s1])
254}
255
256impl<'a> SolidClassifier<'a> {
257 pub fn new(solid: &'a BrepSolid, tolerance: f64) -> Result<Self, String> {
258 let build_started = classify_profile_started();
259 let faces: Vec<&FaceRecord> = solid.shells.iter().flat_map(|shell| &shell.faces).collect();
260 let face_boxes = faces
261 .iter()
262 .map(|face| Aabb::from_surface_controls(&face.surface))
263 .collect::<Result<Vec<_>, _>>()?;
264 let mut bounds = Aabb::empty();
265 for face_box in &face_boxes {
266 bounds.include(*face_box);
267 }
268 let bvh = Bvh::build(&face_boxes);
269 classify_profile(|p| {
270 p.classifier_builds += 1;
271 p.classifier_build_ms += classify_elapsed_ms(build_started);
272 });
273 Ok(Self {
274 faces,
275 face_boxes,
276 bounds,
277 bvh,
278 tolerance,
279 })
280 }
281
282 pub fn near_surface(&self, point: Vec3) -> Result<bool, String> {
288 let on_tolerance = self.tolerance * 10.0;
289 if !self.bounds.expanded(on_tolerance).contains(point) {
290 return Ok(false);
291 }
292 let mut candidates = Vec::new();
293 self.bvh
294 .containing_point(point, on_tolerance, &mut candidates);
295 for &index in &candidates {
296 let projection = project_point_to_surface(&self.faces[index].surface, point)?;
297 if projection.distance <= on_tolerance {
298 return Ok(true);
299 }
300 }
301 Ok(false)
302 }
303
304 pub fn within_band(&self, point: Vec3, band: f64) -> Result<bool, String> {
313 if !self.bounds.expanded(band).contains(point) {
314 return Ok(false);
315 }
316 let mut candidates = Vec::new();
317 self.bvh.containing_point(point, band, &mut candidates);
318 for &index in &candidates {
319 let projection = project_point_to_surface(&self.faces[index].surface, point)?;
320 if projection.distance <= band {
321 return Ok(true);
322 }
323 }
324 Ok(false)
325 }
326
327 pub fn classify(&self, point: Vec3) -> Result<PointClassification, String> {
328 classify_profile(|p| p.classify_calls += 1);
329 let tolerance = self.tolerance;
330 if !self.bounds.expanded(tolerance).contains(point) {
331 return Ok(PointClassification {
332 class: PointClass::Out,
333 on_normal: None,
334 });
335 }
336 let on_tolerance = tolerance * 10.0;
337 let mut candidates = Vec::new();
338 self.bvh
339 .containing_point(point, on_tolerance, &mut candidates);
340 candidates.sort_unstable();
341 let mut interior_normals: Vec<Vec3> = Vec::new();
348 let mut boundary_normals: Vec<Vec3> = Vec::new();
349 for &index in &candidates {
350 let face = self.faces[index];
351 let project_started = classify_profile_started();
352 let projection = project_point_to_surface(&face.surface, point)?;
353 classify_profile(|p| {
354 p.band_candidates += 1;
355 p.band_project_ms += classify_elapsed_ms(project_started);
356 });
357 if projection.distance > on_tolerance {
358 continue;
359 }
360 let uv_tolerance = face_uv_tolerance(face, projection.u, projection.v, on_tolerance);
361 let trim_started = classify_profile_started();
362 let trim = parameter_point_in_face(
363 face,
364 Vec2 {
365 x: projection.u,
366 y: projection.v,
367 },
368 uv_tolerance,
369 )?;
370 classify_profile(|p| {
371 p.band_trim_tests += 1;
372 p.band_trim_ms += classify_elapsed_ms(trim_started);
373 });
374 match trim {
375 PolygonClass::Inside => {
376 interior_normals.push(face_normal(face, projection.u, projection.v)?)
377 }
378 PolygonClass::Boundary => {
379 boundary_normals.push(face_normal(face, projection.u, projection.v)?)
380 }
381 PolygonClass::Outside => {}
382 }
383 }
384 let pool = if interior_normals.is_empty() {
385 &boundary_normals
386 } else {
387 &interior_normals
388 };
389 if !pool.is_empty() {
390 let mut sum = Vec3::default();
391 for normal in pool {
392 sum = sum.add(*normal);
393 }
394 if sum.length() > 1e-3 {
398 return Ok(PointClassification {
399 class: PointClass::On,
400 on_normal: Some(sum.normalized()?),
401 });
402 }
403 }
404 let directions = [
405 Vec3::new(0.577215, 0.618034, 0.532088),
406 Vec3::new(-0.707107, 0.267949, 0.654321),
407 Vec3::new(0.316228, -0.741657, 0.585786),
408 Vec3::new(-0.414214, -0.552786, -0.723607),
409 Vec3::new(0.9482, 0.11893, -0.29456),
410 Vec3::new(-0.13947, 0.90271, -0.40718),
411 Vec3::new(0.62361, -0.33912, -0.70414),
412 Vec3::new(0.20912, 0.51293, 0.83261),
413 ];
414 let ray_length = self.bounds.diagonal() * 3.0 + 1.0;
415 let require_agreement = std::env::var("BREP_CLASSIFY_RAY_AGREE").as_deref() != Ok("0");
425 let mut verdicts: Vec<PointClass> = Vec::new();
426 'directions: for direction in directions {
427 classify_profile(|p| p.rays += 1);
428 let direction = direction.normalized()?;
429 let ray_end = point.add(direction.scale(ray_length));
430 candidates.clear();
431 self.bvh
432 .intersecting_segment(point, ray_end, on_tolerance, &mut candidates);
433 candidates.sort_unstable();
434 let mut crossings = 0;
435 for &index in &candidates {
436 let face = self.faces[index];
437 let margin = on_tolerance.max(self.face_boxes[index].diagonal() * 1e-3);
447 let Some([span_start, span_end]) = clip_segment_to_aabb(
448 point,
449 direction,
450 ray_length,
451 &self.face_boxes[index].expanded(margin),
452 ) else {
453 continue;
454 };
455 let span_start = (span_start - margin).max(0.0);
456 let span_end = (span_end + margin).min(ray_length);
457 if span_end - span_start <= 1e-12 {
458 continue;
459 }
460 let sub_ray = make_line(
461 point.add(direction.scale(span_start)),
462 point.add(direction.scale(span_end)),
463 )?;
464 let csi_started = classify_profile_started();
465 let intersections = ray_face_intersections(&sub_ray, &face.surface, tolerance)?;
466 classify_profile(|p| {
467 p.ray_faces += 1;
468 p.ray_csi_ms += classify_elapsed_ms(csi_started);
469 });
470 for intersection in intersections {
471 if intersection.point.sub(point).length() <= tolerance * 10.0 {
472 let trim_started = classify_profile_started();
473 let trim = parameter_point_in_face(
474 face,
475 Vec2 {
476 x: intersection.u,
477 y: intersection.v,
478 },
479 face_uv_tolerance(face, intersection.u, intersection.v, on_tolerance),
480 )?;
481 classify_profile(|p| {
482 p.ray_trim_tests += 1;
483 p.ray_trim_ms += classify_elapsed_ms(trim_started);
484 });
485 if trim != PolygonClass::Outside {
486 return Ok(PointClassification {
487 class: PointClass::On,
488 on_normal: Some(face_normal(face, intersection.u, intersection.v)?),
489 });
490 }
491 continue;
492 }
493 if intersection.tangential {
494 continue 'directions;
495 }
496 let trim_started = classify_profile_started();
497 let trim_class = parameter_point_in_face(
498 face,
499 Vec2 {
500 x: intersection.u,
501 y: intersection.v,
502 },
503 face_uv_tolerance(face, intersection.u, intersection.v, on_tolerance),
504 )?;
505 classify_profile(|p| {
506 p.ray_trim_tests += 1;
507 p.ray_trim_ms += classify_elapsed_ms(trim_started);
508 });
509 if std::env::var("BREP_DEBUG_CLASSIFY").is_ok() {
510 eprintln!(
511 "classify ray dir=({:.3},{:.3},{:.3}) face={} hit=({:.4},{:.4},{:.4}) t3d={:.4} uv=({:.6},{:.6}) trim={:?}",
512 direction.x, direction.y, direction.z,
513 face.id,
514 intersection.point.x, intersection.point.y, intersection.point.z,
515 intersection.point.sub(point).length(),
516 intersection.u, intersection.v,
517 trim_class
518 );
519 }
520 match trim_class {
521 PolygonClass::Boundary => continue 'directions,
522 PolygonClass::Inside => crossings += 1,
523 PolygonClass::Outside => {}
524 }
525 }
526 }
527 let verdict = if crossings % 2 == 1 {
528 PointClass::In
529 } else {
530 PointClass::Out
531 };
532 if std::env::var("BREP_DEBUG_CLASSIFY").is_ok() {
533 eprintln!(
534 "classify verdict dir=({:.3},{:.3},{:.3}) crossings={crossings} -> {verdict:?}",
535 direction.x, direction.y, direction.z
536 );
537 }
538 if !require_agreement || verdicts.contains(&verdict) {
539 return Ok(PointClassification {
540 class: verdict,
541 on_normal: None,
542 });
543 }
544 verdicts.push(verdict);
545 }
546 if let Some(&verdict) = verdicts.last() {
550 return Ok(PointClassification {
551 class: verdict,
552 on_normal: None,
553 });
554 }
555 Err("classifyPointVsSolid: no clean ray direction found".into())
556 }
557
558 pub fn coincident_on_normal(&self, point: Vec3) -> Result<Option<Vec3>, String> {
573 let started = classify_profile_started();
574 let result = self.coincident_on_normal_inner(point);
575 classify_profile(|p| {
576 p.coincident_calls += 1;
577 p.coincident_ms += classify_elapsed_ms(started);
578 });
579 result
580 }
581
582 fn coincident_on_normal_inner(&self, point: Vec3) -> Result<Option<Vec3>, String> {
583 let band = (self.tolerance * 10.0).max(self.bounds.diagonal() * 1e-7);
584 if !self.bounds.expanded(band).contains(point) {
585 return Ok(None);
586 }
587 let mut candidates = Vec::new();
588 self.bvh.containing_point(point, band, &mut candidates);
589 let mut interior_normals: Vec<Vec3> = Vec::new();
590 for &index in &candidates {
591 let face = self.faces[index];
592 let projection = project_point_to_surface(&face.surface, point)?;
593 if projection.distance > band {
594 continue;
595 }
596 let uv_tolerance = face_uv_tolerance(face, projection.u, projection.v, band);
597 if let PolygonClass::Inside = parameter_point_in_face(
598 face,
599 Vec2 {
600 x: projection.u,
601 y: projection.v,
602 },
603 uv_tolerance,
604 )? {
605 interior_normals.push(face_normal(face, projection.u, projection.v)?);
606 }
607 }
608 if interior_normals.is_empty() {
609 return Ok(None);
610 }
611 let mut sum = Vec3::default();
612 for normal in &interior_normals {
613 sum = sum.add(*normal);
614 }
615 if sum.length() <= 1e-3 {
616 return Ok(None);
617 }
618 Ok(Some(sum.normalized()?))
619 }
620}
621
622pub fn classify_point(
623 point: Vec3,
624 solid: &BrepSolid,
625 tolerance: f64,
626) -> Result<PointClassification, String> {
627 classify_profile_begin();
628 let result = SolidClassifier::new(solid, tolerance)?.classify(point);
629 classify_profile_report("classify_point");
630 result
631}