1pub(crate) mod boundary;
8pub(crate) mod ray_surface;
9pub(crate) mod winding;
10
11use brepkit_math::vec::{Point3, Vec3};
12use brepkit_topology::Topology;
13use brepkit_topology::face::{FaceId, FaceSurface};
14use brepkit_topology::solid::SolidId;
15
16use crate::CheckError;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PointClassification {
21 Inside,
23 Outside,
25 OnBoundary,
27}
28
29#[derive(Debug, Clone)]
31pub struct ClassifyOptions {
32 pub tolerance: f64,
34 pub max_recovery_attempts: usize,
36}
37
38impl Default for ClassifyOptions {
39 fn default() -> Self {
40 Self {
41 tolerance: 1e-6,
42 max_recovery_attempts: 10,
43 }
44 }
45}
46
47#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
58pub fn classify_point(
59 topo: &Topology,
60 solid: SolidId,
61 point: Point3,
62 options: &ClassifyOptions,
63) -> Result<PointClassification, CheckError> {
64 let solid_data = topo.solid(solid)?;
65 let shell = topo.shell(solid_data.outer_shell())?;
66
67 if is_on_boundary(topo, shell.faces(), point, options.tolerance)? {
68 return Ok(PointClassification::OnBoundary);
69 }
70
71 let base_dirs = [
74 Vec3::new(
75 0.573_576_436_351_046,
76 0.740_535_693_464_567_5,
77 0.350_889_803_483_932_2,
78 ),
79 Vec3::new(
80 -0.350_889_803_483_932_2,
81 0.573_576_436_351_046,
82 0.740_535_693_464_567_5,
83 ),
84 Vec3::new(
85 0.740_535_693_464_567_5,
86 -0.350_889_803_483_932_2,
87 0.573_576_436_351_046,
88 ),
89 ];
90
91 let mut inside_votes = 0u32;
92 let mut outside_votes = 0u32;
93
94 for &dir in &base_dirs {
95 let crossings = count_ray_crossings(topo, shell.faces(), point, dir)?;
96 if crossings % 2 == 1 {
97 inside_votes += 1;
98 } else {
99 outside_votes += 1;
100 }
101 if inside_votes >= 2 {
103 return Ok(PointClassification::Inside);
104 }
105 if outside_votes >= 2 {
106 return Ok(PointClassification::Outside);
107 }
108 }
109
110 for attempt in 0..options.max_recovery_attempts {
112 let seed = (attempt as f64 + 1.0) * 0.618_033_988_749_895;
114 let theta = seed * std::f64::consts::TAU;
115 let phi = (seed * std::f64::consts::E).fract() * std::f64::consts::PI;
116 let dir = Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
117
118 let crossings = count_ray_crossings(topo, shell.faces(), point, dir)?;
119 if crossings % 2 == 1 {
120 inside_votes += 1;
121 } else {
122 outside_votes += 1;
123 }
124 let remaining = options.max_recovery_attempts as u32 - attempt as u32;
125 if inside_votes > outside_votes + remaining {
126 return Ok(PointClassification::Inside);
127 }
128 if outside_votes > inside_votes + remaining {
129 return Ok(PointClassification::Outside);
130 }
131 }
132
133 if inside_votes > outside_votes {
135 Ok(PointClassification::Inside)
136 } else {
137 Ok(PointClassification::Outside)
138 }
139}
140
141fn is_on_boundary(
146 topo: &Topology,
147 faces: &[FaceId],
148 point: Point3,
149 tolerance: f64,
150) -> Result<bool, CheckError> {
151 for &fid in faces {
152 let face = topo.face(fid)?;
153 let dist = match face.surface() {
154 FaceSurface::Plane { normal, d } => {
155 let pv = Vec3::new(point.x(), point.y(), point.z());
156 (normal.dot(pv) - d).abs()
157 }
158 FaceSurface::Cylinder(cyl) => {
159 let (u, v) = cyl.project_point(point);
160 let on_surface = cyl.evaluate(u, v);
161 (point - on_surface).length()
162 }
163 FaceSurface::Cone(cone) => {
164 let (u, v) = cone.project_point(point);
165 let on_surface = cone.evaluate(u, v);
166 (point - on_surface).length()
167 }
168 FaceSurface::Sphere(sph) => {
169 let (u, v) = sph.project_point(point);
170 let on_surface = sph.evaluate(u, v);
171 (point - on_surface).length()
172 }
173 FaceSurface::Torus(tor) => {
174 let (u, v) = tor.project_point(point);
175 let on_surface = tor.evaluate(u, v);
176 (point - on_surface).length()
177 }
178 FaceSurface::Nurbs(nurbs) => {
179 match brepkit_math::nurbs::projection::project_point_to_surface(
180 nurbs, point, tolerance,
181 ) {
182 Ok(proj) => proj.distance,
183 Err(_) => f64::INFINITY,
184 }
185 }
186 };
187 if dist < tolerance {
188 let polygon = crate::util::face_polygon(topo, fid)?;
189 if polygon.len() >= 3 {
190 let normal = boundary::polygon_normal(&polygon);
191 if crate::util::point_in_polygon_3d(&point, &polygon, &normal) {
192 return Ok(true);
193 }
194 } else {
195 return Ok(true);
197 }
198 }
199 }
200 Ok(false)
201}
202
203pub fn classify_point_winding(
213 topo: &Topology,
214 solid: SolidId,
215 point: Point3,
216 options: &ClassifyOptions,
217) -> Result<PointClassification, CheckError> {
218 let solid_data = topo.solid(solid)?;
219 let shell = topo.shell(solid_data.outer_shell())?;
220 if is_on_boundary(topo, shell.faces(), point, options.tolerance)? {
221 return Ok(PointClassification::OnBoundary);
222 }
223
224 let w = winding::winding_number(topo, solid, point)?;
225 if w > 0.5 {
226 Ok(PointClassification::Inside)
227 } else {
228 Ok(PointClassification::Outside)
229 }
230}
231
232pub fn classify_point_robust(
242 topo: &Topology,
243 solid: SolidId,
244 point: Point3,
245 options: &ClassifyOptions,
246) -> Result<PointClassification, CheckError> {
247 let solid_data = topo.solid(solid)?;
248 let shell = topo.shell(solid_data.outer_shell())?;
249 if is_on_boundary(topo, shell.faces(), point, options.tolerance)? {
250 return Ok(PointClassification::OnBoundary);
251 }
252
253 let w = winding::winding_number(topo, solid, point)?;
254 if w > 0.6 {
255 return Ok(PointClassification::Inside);
256 }
257 if w < 0.4 {
258 return Ok(PointClassification::Outside);
259 }
260 classify_point(topo, solid, point, options)
261}
262
263fn count_ray_crossings(
268 topo: &Topology,
269 faces: &[FaceId],
270 origin: Point3,
271 direction: Vec3,
272) -> Result<u32, CheckError> {
273 use brepkit_math::bvh::Bvh;
274
275 let face_aabbs: Vec<(usize, brepkit_math::aabb::Aabb3)> = faces
276 .iter()
277 .enumerate()
278 .filter_map(|(i, &fid)| crate::util::face_aabb(topo, fid).ok().map(|aabb| (i, aabb)))
279 .collect();
280 let bvh = Bvh::build(&face_aabbs);
281
282 let candidates = bvh.query_ray(origin, direction);
285
286 let mut crossings = 0u32;
287 for face_idx in candidates {
288 crossings += boundary::count_face_ray_crossings(topo, faces[face_idx], origin, direction)?;
289 }
290 Ok(crossings)
291}
292
293#[cfg(test)]
298#[allow(clippy::unwrap_used, clippy::expect_used)]
299mod tests {
300 use super::winding;
301 use super::*;
302 use brepkit_topology::test_utils::make_unit_cube_manifold;
303
304 #[test]
305 fn point_inside_box() {
306 let mut topo = Topology::new();
307 let solid = make_unit_cube_manifold(&mut topo);
308 let center = Point3::new(0.5, 0.5, 0.5);
309 let opts = ClassifyOptions::default();
310
311 let result = classify_point(&topo, solid, center, &opts).unwrap();
312 assert_eq!(result, PointClassification::Inside);
313 }
314
315 #[test]
316 fn point_outside_box() {
317 let mut topo = Topology::new();
318 let solid = make_unit_cube_manifold(&mut topo);
319 let far = Point3::new(5.0, 5.0, 5.0);
320 let opts = ClassifyOptions::default();
321
322 let result = classify_point(&topo, solid, far, &opts).unwrap();
323 assert_eq!(result, PointClassification::Outside);
324 }
325
326 #[test]
327 fn point_on_boundary_box() {
328 let mut topo = Topology::new();
329 let solid = make_unit_cube_manifold(&mut topo);
330 let on_face = Point3::new(0.5, 0.5, 1.0);
332 let opts = ClassifyOptions::default();
333
334 let result = classify_point(&topo, solid, on_face, &opts).unwrap();
335 assert_eq!(result, PointClassification::OnBoundary);
336 }
337
338 #[test]
339 fn point_near_edge_outside() {
340 let mut topo = Topology::new();
341 let solid = make_unit_cube_manifold(&mut topo);
342 let outside = Point3::new(1.001, 0.5, 0.5);
344 let opts = ClassifyOptions::default();
345
346 let result = classify_point(&topo, solid, outside, &opts).unwrap();
347 assert_eq!(result, PointClassification::Outside);
348 }
349
350 #[test]
351 fn point_at_corner_boundary() {
352 let mut topo = Topology::new();
353 let solid = make_unit_cube_manifold(&mut topo);
354 let near_corner = Point3::new(0.0, 0.0, 0.0);
356 let opts = ClassifyOptions::default();
357
358 let result = classify_point(&topo, solid, near_corner, &opts).unwrap();
359 assert_eq!(result, PointClassification::OnBoundary);
360 }
361
362 #[test]
363 fn winding_inside_box() {
364 let mut topo = Topology::new();
365 let solid = make_unit_cube_manifold(&mut topo);
366 let center = Point3::new(0.5, 0.5, 0.5);
367
368 let w = winding::winding_number(&topo, solid, center).unwrap();
369 assert!(
370 w > 0.5,
371 "winding number for interior point should be > 0.5, got {w}"
372 );
373 }
374
375 #[test]
376 fn winding_outside_box() {
377 let mut topo = Topology::new();
378 let solid = make_unit_cube_manifold(&mut topo);
379 let far = Point3::new(5.0, 5.0, 5.0);
380
381 let w = winding::winding_number(&topo, solid, far).unwrap();
382 assert!(
383 w < 0.5,
384 "winding number for exterior point should be < 0.5, got {w}"
385 );
386 }
387
388 #[test]
389 fn classify_winding_matches_ray() {
390 let mut topo = Topology::new();
391 let solid = make_unit_cube_manifold(&mut topo);
392 let center = Point3::new(0.5, 0.5, 0.5);
393 let opts = ClassifyOptions::default();
394
395 let ray_result = classify_point(&topo, solid, center, &opts).unwrap();
396 let winding_result = classify_point_winding(&topo, solid, center, &opts).unwrap();
397 assert_eq!(ray_result, winding_result);
398 }
399
400 #[test]
401 fn point_negative_quadrant_outside() {
402 let mut topo = Topology::new();
403 let solid = make_unit_cube_manifold(&mut topo);
404 let neg = Point3::new(-1.0, -1.0, -1.0);
405 let opts = ClassifyOptions::default();
406
407 let result = classify_point(&topo, solid, neg, &opts).unwrap();
408 assert_eq!(result, PointClassification::Outside);
409 }
410
411 fn make_partial_torus_band(topo: &mut Topology, big_r: f64, rho: f64, angle: f64) -> SolidId {
416 use brepkit_math::curves::Circle3D;
417 use brepkit_math::surfaces::ToroidalSurface;
418 use brepkit_topology::edge::{Edge, EdgeCurve};
419 use brepkit_topology::face::{Face, FaceSurface};
420 use brepkit_topology::shell::Shell;
421 use brepkit_topology::solid::Solid;
422 use brepkit_topology::vertex::Vertex;
423 use brepkit_topology::wire::{OrientedEdge, Wire};
424
425 let (sin_a, cos_a) = angle.sin_cos();
426 let v1 = topo.add_vertex(Vertex::new(Point3::new(big_r, 0.0, -rho), 1e-7));
427 let v2 = topo.add_vertex(Vertex::new(
428 Point3::new(big_r * cos_a, big_r * sin_a, -rho),
429 1e-7,
430 ));
431
432 let rim1 =
433 Circle3D::new(Point3::new(big_r, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), rho).unwrap();
434 let rim2 = Circle3D::new(
435 Point3::new(big_r * cos_a, big_r * sin_a, 0.0),
436 Vec3::new(-sin_a, cos_a, 0.0),
437 rho,
438 )
439 .unwrap();
440 let seam =
441 Circle3D::new(Point3::new(0.0, 0.0, -rho), Vec3::new(0.0, 0.0, 1.0), big_r).unwrap();
442
443 let e_rim1 = topo.add_edge(Edge::new(v1, v1, EdgeCurve::Circle(rim1)));
444 let e_rim2 = topo.add_edge(Edge::new(v2, v2, EdgeCurve::Circle(rim2)));
445 let e_seam = topo.add_edge(Edge::new(v1, v2, EdgeCurve::Circle(seam)));
446
447 let band_wire = topo.add_wire(
448 Wire::new(
449 vec![
450 OrientedEdge::new(e_rim1, true),
451 OrientedEdge::new(e_seam, true),
452 OrientedEdge::new(e_rim2, false),
453 OrientedEdge::new(e_seam, false),
454 ],
455 true,
456 )
457 .unwrap(),
458 );
459 let torus = ToroidalSurface::with_axis(
460 Point3::new(0.0, 0.0, 0.0),
461 big_r,
462 rho,
463 Vec3::new(0.0, 0.0, 1.0),
464 )
465 .unwrap();
466 let band = topo.add_face(Face::new(band_wire, vec![], FaceSurface::Torus(torus)));
467
468 let cap1_wire =
469 topo.add_wire(Wire::new(vec![OrientedEdge::new(e_rim1, false)], true).unwrap());
470 let cap1 = topo.add_face(Face::new(
471 cap1_wire,
472 vec![],
473 FaceSurface::Plane {
474 normal: Vec3::new(0.0, 1.0, 0.0),
475 d: 0.0,
476 },
477 ));
478 let cap2_wire =
479 topo.add_wire(Wire::new(vec![OrientedEdge::new(e_rim2, true)], true).unwrap());
480 let cap2 = topo.add_face(Face::new(
481 cap2_wire,
482 vec![],
483 FaceSurface::Plane {
484 normal: Vec3::new(-sin_a, cos_a, 0.0),
485 d: 0.0,
486 },
487 ));
488
489 let shell = topo.add_shell(Shell::new(vec![band, cap1, cap2]).unwrap());
490 topo.add_solid(Solid::new(shell, vec![]))
491 }
492
493 #[test]
500 fn partial_torus_band_interior_points() {
501 let (big_r, rho, angle) = (6.0_f64, 2.0_f64, 2.0 * std::f64::consts::PI / 3.0);
502 let mut topo = Topology::new();
503 let solid = make_partial_torus_band(&mut topo, big_r, rho, angle);
504 let opts = ClassifyOptions::default();
505
506 let mid = angle / 2.0;
507 let inside = [
508 Point3::new(big_r * mid.cos(), big_r * mid.sin(), 0.0),
509 Point3::new(big_r * mid.cos(), big_r * mid.sin(), 1.0),
510 Point3::new(big_r * mid.cos(), big_r * mid.sin(), -1.0),
511 Point3::new(big_r * 0.05f64.cos(), big_r * 0.05f64.sin(), 0.0),
512 Point3::new(
513 big_r * (angle - 0.05).cos(),
514 big_r * (angle - 0.05).sin(),
515 0.0,
516 ),
517 Point3::new((big_r - 1.5) * mid.cos(), (big_r - 1.5) * mid.sin(), 0.0),
518 Point3::new((big_r + 1.5) * mid.cos(), (big_r + 1.5) * mid.sin(), 0.0),
519 ];
520 for p in inside {
521 let result = classify_point(&topo, solid, p, &opts).unwrap();
522 assert_eq!(result, PointClassification::Inside, "probe {p:?}");
523 }
524
525 let outside = [
526 Point3::new(big_r * mid.cos(), big_r * mid.sin(), 2.5),
527 Point3::new(0.0, 0.0, 0.0),
528 Point3::new(-big_r, 0.0, 0.0),
529 Point3::new(
530 big_r * (angle + 0.1).cos(),
531 big_r * (angle + 0.1).sin(),
532 0.0,
533 ),
534 Point3::new(big_r * (-0.1f64).cos(), big_r * (-0.1f64).sin(), 0.0),
535 ];
536 for p in outside {
537 let result = classify_point(&topo, solid, p, &opts).unwrap();
538 assert_eq!(result, PointClassification::Outside, "probe {p:?}");
539 }
540 }
541
542 #[test]
546 fn face_aabb_covers_closed_circle_boundary() {
547 let (big_r, rho, angle) = (6.0_f64, 2.0_f64, 2.0 * std::f64::consts::PI / 3.0);
548 let mut topo = Topology::new();
549 let solid = make_partial_torus_band(&mut topo, big_r, rho, angle);
550 let shell = topo
551 .shell(topo.solid(solid).unwrap().outer_shell())
552 .unwrap();
553
554 let cap = shell.faces()[1];
557 let aabb = crate::util::face_aabb(&topo, cap).unwrap();
558 assert!(
559 aabb.min.x() < 4.0 + 1e-9 && aabb.max.x() > 8.0 - 1e-9,
560 "cap AABB x-span collapsed: {aabb:?}"
561 );
562 assert!(
563 aabb.min.z() < -2.0 + 1e-9 && aabb.max.z() > 2.0 - 1e-9,
564 "cap AABB z-span collapsed: {aabb:?}"
565 );
566 }
567}