Skip to main content

brepkit_check/distance/
mod.rs

1//! Minimum distance and extrema between shapes.
2
3#![allow(
4    clippy::many_single_char_names,
5    clippy::similar_names,
6    clippy::suboptimal_flops
7)]
8
9pub(crate) mod analytic;
10pub(crate) mod edge;
11
12use std::collections::HashSet;
13
14use brepkit_math::aabb::Aabb3;
15use brepkit_math::bvh::Bvh;
16use brepkit_math::vec::{Point3, Vec3};
17use brepkit_topology::Topology;
18use brepkit_topology::face::{FaceId, FaceSurface};
19use brepkit_topology::solid::SolidId;
20
21use crate::CheckError;
22
23/// Which topological element supports a closest point.
24#[derive(Debug, Clone, Copy)]
25pub enum SupportElement {
26    /// Closest point is on a face.
27    Face(FaceId, f64, f64),
28}
29
30/// A single distance solution.
31#[derive(Debug, Clone)]
32pub struct DistanceResult {
33    /// The minimum distance.
34    pub distance: f64,
35    /// Closest point on shape A (or the query point).
36    pub point_a: Point3,
37    /// Closest point on shape B.
38    pub point_b: Point3,
39}
40
41/// Compute the minimum distance from a point to a solid.
42///
43/// Uses BVH over face AABBs for acceleration. Dispatches per face type:
44/// planar (point-to-polygon), analytic (closed-form), NURBS (Newton projection).
45///
46/// # Errors
47///
48/// Returns an error if any topology entity is missing.
49#[allow(clippy::too_many_lines)]
50pub fn point_to_solid(
51    topo: &Topology,
52    point: Point3,
53    solid: SolidId,
54) -> Result<DistanceResult, CheckError> {
55    let face_ids = collect_solid_faces(topo, solid)?;
56
57    let mut face_aabbs: Vec<(usize, Aabb3)> = Vec::with_capacity(face_ids.len());
58    for (i, &fid) in face_ids.iter().enumerate() {
59        let aabb = crate::util::face_aabb(topo, fid)?;
60        face_aabbs.push((i, aabb));
61    }
62    let bvh = Bvh::build(&face_aabbs);
63
64    let mut best_dist = f64::INFINITY;
65    let mut best_point = point;
66
67    // Sort candidates by AABB distance to point for early termination.
68    let mut candidates: Vec<usize> = (0..face_aabbs.len()).collect();
69    candidates.sort_by(|&a, &b| {
70        let da = face_aabbs[a].1.distance_squared_to_point(point);
71        let db = face_aabbs[b].1.distance_squared_to_point(point);
72        da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
73    });
74
75    if let Some(closest_idx) = bvh.query_closest(point)
76        && let Some(pos) = candidates
77            .iter()
78            .position(|&i| face_aabbs[i].0 == closest_idx)
79    {
80        candidates.swap(0, pos);
81    }
82
83    for idx in candidates {
84        let aabb_dist_sq = face_aabbs[idx].1.distance_squared_to_point(point);
85        if aabb_dist_sq > best_dist * best_dist {
86            continue; // not break — BVH swap may have reordered candidates
87        }
88        let face_idx = face_aabbs[idx].0;
89        let fid = face_ids[face_idx];
90        if let Ok(Some((dist, closest))) = point_to_face(topo, point, fid)
91            && dist < best_dist
92        {
93            best_dist = dist;
94            best_point = closest;
95        }
96    }
97
98    Ok(DistanceResult {
99        distance: best_dist,
100        point_a: point,
101        point_b: best_point,
102    })
103}
104
105/// Compute distance from a point to a single face, dispatching by surface type.
106///
107/// # Errors
108///
109/// Returns an error if the face lookup fails.
110pub fn point_to_face(
111    topo: &Topology,
112    point: Point3,
113    face_id: FaceId,
114) -> Result<Option<(f64, Point3)>, CheckError> {
115    let face = topo.face(face_id)?;
116    match face.surface() {
117        FaceSurface::Plane { normal, d } => {
118            let polygon = crate::util::face_polygon(topo, face_id)?;
119            Ok(point_to_polygon_distance(point, &polygon, *normal, *d))
120        }
121        FaceSurface::Cylinder(cyl) => {
122            let (dist, closest) = analytic::point_to_cylinder(point, cyl);
123            if is_point_in_face_boundary(topo, face_id, closest)? {
124                Ok(Some((dist, closest)))
125            } else {
126                Ok(closest_point_on_wire_edges(topo, face_id, point)?)
127            }
128        }
129        FaceSurface::Cone(cone) => {
130            let (dist, closest) = analytic::point_to_cone(point, cone);
131            if is_point_in_face_boundary(topo, face_id, closest)? {
132                Ok(Some((dist, closest)))
133            } else {
134                Ok(closest_point_on_wire_edges(topo, face_id, point)?)
135            }
136        }
137        FaceSurface::Sphere(sph) => {
138            let (dist, closest) = analytic::point_to_sphere(point, sph);
139            if is_point_in_face_boundary(topo, face_id, closest)? {
140                Ok(Some((dist, closest)))
141            } else {
142                Ok(closest_point_on_wire_edges(topo, face_id, point)?)
143            }
144        }
145        FaceSurface::Torus(tor) => {
146            let (dist, closest) = analytic::point_to_torus(point, tor);
147            if is_point_in_face_boundary(topo, face_id, closest)? {
148                Ok(Some((dist, closest)))
149            } else {
150                Ok(closest_point_on_wire_edges(topo, face_id, point)?)
151            }
152        }
153        FaceSurface::Nurbs(nurbs) => {
154            match brepkit_math::nurbs::projection::project_point_to_surface(nurbs, point, 1e-7) {
155                Ok(proj) => {
156                    if is_point_in_face_boundary(topo, face_id, proj.point)? {
157                        Ok(Some((proj.distance, proj.point)))
158                    } else {
159                        closest_point_on_wire_edges(topo, face_id, point)
160                    }
161                }
162                Err(_) => Ok(None),
163            }
164        }
165    }
166}
167
168/// Compute the minimum distance between two solids.
169///
170/// Checks vertex-to-vertex, vertex-to-face, and edge-to-edge pairs
171/// with AABB pruning for acceleration.
172///
173/// # Errors
174///
175/// Returns an error if any topology entity is missing.
176#[allow(clippy::too_many_lines)]
177pub fn solid_to_solid(
178    topo: &Topology,
179    solid_a: SolidId,
180    solid_b: SolidId,
181) -> Result<DistanceResult, CheckError> {
182    let verts_a = collect_solid_vertices(topo, solid_a)?;
183    let verts_b = collect_solid_vertices(topo, solid_b)?;
184
185    let mut best_dist = f64::INFINITY;
186    let mut best_a = Point3::new(0.0, 0.0, 0.0);
187    let mut best_b = Point3::new(0.0, 0.0, 0.0);
188
189    // Pass 1: Vertex-vertex (cheap upper bound).
190    for &pa in &verts_a {
191        for &pb in &verts_b {
192            let dist = (pa - pb).length();
193            if dist < best_dist {
194                best_dist = dist;
195                best_a = pa;
196                best_b = pb;
197            }
198        }
199    }
200
201    // Pass 2: Vertices of A against faces of B.
202    let faces_b = collect_solid_faces(topo, solid_b)?;
203    let mut aabbs_b: Vec<(usize, Aabb3)> = Vec::with_capacity(faces_b.len());
204    for (i, &fid) in faces_b.iter().enumerate() {
205        let aabb = crate::util::face_aabb(topo, fid)?;
206        aabbs_b.push((i, aabb));
207    }
208
209    for &pa in &verts_a {
210        for &(idx, ref aabb) in &aabbs_b {
211            if aabb.distance_squared_to_point(pa) > best_dist * best_dist {
212                continue;
213            }
214            if let Ok(Some((dist, closest))) = point_to_face(topo, pa, faces_b[idx])
215                && dist < best_dist
216            {
217                best_dist = dist;
218                best_a = pa;
219                best_b = closest;
220            }
221        }
222    }
223
224    // Pass 3: Vertices of B against faces of A.
225    let faces_a = collect_solid_faces(topo, solid_a)?;
226    let mut aabbs_a: Vec<(usize, Aabb3)> = Vec::with_capacity(faces_a.len());
227    for (i, &fid) in faces_a.iter().enumerate() {
228        let aabb = crate::util::face_aabb(topo, fid)?;
229        aabbs_a.push((i, aabb));
230    }
231
232    for &pb in &verts_b {
233        for &(idx, ref aabb) in &aabbs_a {
234            if aabb.distance_squared_to_point(pb) > best_dist * best_dist {
235                continue;
236            }
237            if let Ok(Some((dist, closest))) = point_to_face(topo, pb, faces_a[idx])
238                && dist < best_dist
239            {
240                best_dist = dist;
241                best_b = pb;
242                best_a = closest;
243            }
244        }
245    }
246
247    // Pass 4: Edge-edge with AABB pruning.
248    let edges_a = collect_solid_edge_segments(topo, solid_a)?;
249    let edges_b = collect_solid_edge_segments(topo, solid_b)?;
250
251    for &(p0a, p1a) in &edges_a {
252        let aabb_a = Aabb3::try_from_points([p0a, p1a].iter().copied())
253            .unwrap_or(Aabb3 { min: p0a, max: p0a });
254        for &(p0b, p1b) in &edges_b {
255            let aabb_b = Aabb3::try_from_points([p0b, p1b].iter().copied())
256                .unwrap_or(Aabb3 { min: p0b, max: p0b });
257            if aabb_distance(&aabb_a, &aabb_b) > best_dist {
258                continue;
259            }
260            let (dist, ca, cb) = edge::segment_segment_distance(p0a, p1a, p0b, p1b);
261            if dist < best_dist {
262                best_dist = dist;
263                best_a = ca;
264                best_b = cb;
265            }
266        }
267    }
268
269    Ok(DistanceResult {
270        distance: best_dist,
271        point_a: best_a,
272        point_b: best_b,
273    })
274}
275
276/// Collect all unique vertex positions from a solid (outer + inner shells).
277fn collect_solid_vertices(topo: &Topology, solid: SolidId) -> Result<Vec<Point3>, CheckError> {
278    let solid_data = topo.solid(solid)?;
279    let mut seen = HashSet::new();
280    let mut points = Vec::new();
281    let shell_ids: Vec<_> = std::iter::once(solid_data.outer_shell())
282        .chain(solid_data.inner_shells().iter().copied())
283        .collect();
284    for sid in shell_ids {
285        let shell = topo.shell(sid)?;
286        for &fid in shell.faces() {
287            let face = topo.face(fid)?;
288            let mut wire_ids = vec![face.outer_wire()];
289            wire_ids.extend(face.inner_wires().iter().copied());
290            for wid in wire_ids {
291                let wire = topo.wire(wid)?;
292                for oe in wire.edges() {
293                    let edge_data = topo.edge(oe.edge())?;
294                    for vid in [edge_data.start(), edge_data.end()] {
295                        if seen.insert(vid) {
296                            points.push(topo.vertex(vid)?.point());
297                        }
298                    }
299                }
300            }
301        }
302    }
303    Ok(points)
304}
305
306/// Collect all face IDs from a solid (outer + inner shells).
307fn collect_solid_faces(topo: &Topology, solid: SolidId) -> Result<Vec<FaceId>, CheckError> {
308    let solid_data = topo.solid(solid)?;
309    let mut faces = Vec::new();
310    let shell_ids: Vec<_> = std::iter::once(solid_data.outer_shell())
311        .chain(solid_data.inner_shells().iter().copied())
312        .collect();
313    for sid in shell_ids {
314        let shell = topo.shell(sid)?;
315        faces.extend(shell.faces().iter().copied());
316    }
317    Ok(faces)
318}
319
320/// Collect edge segments as polylines for edge-edge distance computation.
321///
322/// Line edges produce a single segment. Curved edges (circle, ellipse, NURBS)
323/// are sampled at multiple points to capture the curve geometry.
324#[allow(clippy::cast_precision_loss)]
325fn collect_solid_edge_segments(
326    topo: &Topology,
327    solid: SolidId,
328) -> Result<Vec<(Point3, Point3)>, CheckError> {
329    use brepkit_topology::edge::EdgeCurve;
330
331    let solid_data = topo.solid(solid)?;
332    let mut seen = HashSet::new();
333    let mut segments = Vec::new();
334
335    let n_samples = 8usize;
336
337    let shell_ids: Vec<_> = std::iter::once(solid_data.outer_shell())
338        .chain(solid_data.inner_shells().iter().copied())
339        .collect();
340    for sid in shell_ids {
341        let shell = topo.shell(sid)?;
342        for &fid in shell.faces() {
343            let face = topo.face(fid)?;
344            let mut wire_ids = vec![face.outer_wire()];
345            wire_ids.extend(face.inner_wires().iter().copied());
346            for wid in wire_ids {
347                let wire = topo.wire(wid)?;
348                for oe in wire.edges() {
349                    let eid = oe.edge();
350                    if !seen.insert(eid) {
351                        continue;
352                    }
353                    let edge_data = topo.edge(eid)?;
354                    let start_pt = topo.vertex(edge_data.start())?.point();
355                    let end_pt = topo.vertex(edge_data.end())?.point();
356
357                    match edge_data.curve() {
358                        EdgeCurve::Line => {
359                            segments.push((start_pt, end_pt));
360                        }
361                        EdgeCurve::Circle(c) => {
362                            let is_closed = edge_data.start() == edge_data.end();
363                            let (t0, t1) = if is_closed {
364                                (0.0, std::f64::consts::TAU)
365                            } else {
366                                let t0 = c.project(start_pt);
367                                let mut t1 = c.project(end_pt);
368                                if t1 <= t0 {
369                                    t1 += std::f64::consts::TAU;
370                                }
371                                (t0, t1)
372                            };
373                            let mut prev = c.evaluate(t0);
374                            for i in 1..=n_samples {
375                                let t = t0 + (t1 - t0) * (i as f64) / (n_samples as f64);
376                                let curr = c.evaluate(t);
377                                segments.push((prev, curr));
378                                prev = curr;
379                            }
380                        }
381                        EdgeCurve::Ellipse(e) => {
382                            let is_closed = edge_data.start() == edge_data.end();
383                            let (t0, t1) = if is_closed {
384                                (0.0, std::f64::consts::TAU)
385                            } else {
386                                let t0 = e.project(start_pt);
387                                let mut t1 = e.project(end_pt);
388                                if t1 <= t0 {
389                                    t1 += std::f64::consts::TAU;
390                                }
391                                (t0, t1)
392                            };
393                            let mut prev = e.evaluate(t0);
394                            for i in 1..=n_samples {
395                                let t = t0 + (t1 - t0) * (i as f64) / (n_samples as f64);
396                                let curr = e.evaluate(t);
397                                segments.push((prev, curr));
398                                prev = curr;
399                            }
400                        }
401                        EdgeCurve::NurbsCurve(nc) => {
402                            let (t0, t1) = nc.domain();
403                            let mut prev = nc.evaluate(t0);
404                            for i in 1..=n_samples {
405                                let t = t0 + (t1 - t0) * (i as f64) / (n_samples as f64);
406                                let curr = nc.evaluate(t);
407                                segments.push((prev, curr));
408                                prev = curr;
409                            }
410                        }
411                    }
412                }
413            }
414        }
415    }
416
417    Ok(segments)
418}
419
420/// Check if a point lies within the face's boundary polygon.
421fn is_point_in_face_boundary(
422    topo: &Topology,
423    face_id: FaceId,
424    point: Point3,
425) -> Result<bool, CheckError> {
426    let polygon = crate::util::face_polygon(topo, face_id)?;
427    if polygon.len() < 3 {
428        return Ok(true); // Full-surface face
429    }
430    let normal = crate::util::polygon_normal(&polygon);
431    Ok(crate::util::point_in_polygon_3d(&point, &polygon, &normal))
432}
433
434/// Find the closest point on the wire edges of a face to a given point.
435///
436/// Iterates both the outer wire and inner wires (holes).
437fn closest_point_on_wire_edges(
438    topo: &Topology,
439    face_id: FaceId,
440    point: Point3,
441) -> Result<Option<(f64, Point3)>, CheckError> {
442    let face = topo.face(face_id)?;
443    let mut best_dist = f64::INFINITY;
444    let mut best_pt = point;
445
446    let mut wire_ids = vec![face.outer_wire()];
447    wire_ids.extend(face.inner_wires().iter().copied());
448
449    for wid in wire_ids {
450        let wire = topo.wire(wid)?;
451        for oe in wire.edges() {
452            let edge_data = topo.edge(oe.edge())?;
453            let p0 = topo.vertex(edge_data.start())?.point();
454            let p1 = topo.vertex(edge_data.end())?.point();
455            let (dist, closest) = point_to_segment(point, p0, p1);
456            if dist < best_dist {
457                best_dist = dist;
458                best_pt = closest;
459            }
460        }
461    }
462    if best_dist < f64::INFINITY {
463        Ok(Some((best_dist, best_pt)))
464    } else {
465        Ok(None)
466    }
467}
468
469/// Point-to-polygon distance for planar faces.
470///
471/// Projects the point onto the plane, checks if inside polygon, otherwise
472/// finds the closest point on polygon edges.
473fn point_to_polygon_distance(
474    point: Point3,
475    polygon: &[Point3],
476    normal: Vec3,
477    d: f64,
478) -> Option<(f64, Point3)> {
479    if polygon.len() < 3 {
480        return None;
481    }
482
483    let (_, projected) = analytic::point_to_plane(point, normal, d);
484
485    if crate::util::point_in_polygon_3d(&projected, polygon, &normal) {
486        let dist = (point - projected).length();
487        return Some((dist, projected));
488    }
489
490    let mut best_dist = f64::INFINITY;
491    let mut best_pt = polygon[0];
492    let n = polygon.len();
493    for i in 0..n {
494        let j = (i + 1) % n;
495        let (dist, closest) = point_to_segment(point, polygon[i], polygon[j]);
496        if dist < best_dist {
497            best_dist = dist;
498            best_pt = closest;
499        }
500    }
501    Some((best_dist, best_pt))
502}
503
504/// Distance from point to line segment.
505fn point_to_segment(point: Point3, a: Point3, b: Point3) -> (f64, Point3) {
506    let ab = b - a;
507    let ap = point - a;
508    let len_sq = ab.length_squared();
509    if len_sq < 1e-30 {
510        return ((point - a).length(), a);
511    }
512    let t = (ap.dot(ab) / len_sq).clamp(0.0, 1.0);
513    let closest = Point3::new(
514        ab.x().mul_add(t, a.x()),
515        ab.y().mul_add(t, a.y()),
516        ab.z().mul_add(t, a.z()),
517    );
518    ((point - closest).length(), closest)
519}
520
521/// Compute minimum distance between two AABBs.
522fn aabb_distance(a: &Aabb3, b: &Aabb3) -> f64 {
523    let dx = (a.min.x() - b.max.x()).max(b.min.x() - a.max.x()).max(0.0);
524    let dy = (a.min.y() - b.max.y()).max(b.min.y() - a.max.y()).max(0.0);
525    let dz = (a.min.z() - b.max.z()).max(b.min.z() - a.max.z()).max(0.0);
526    (dx.mul_add(dx, dy.mul_add(dy, dz * dz))).sqrt()
527}
528
529#[cfg(test)]
530mod tests {
531    #![allow(clippy::unwrap_used, clippy::expect_used)]
532
533    use super::*;
534    use brepkit_math::surfaces::{CylindricalSurface, SphericalSurface, ToroidalSurface};
535
536    #[test]
537    fn point_to_sphere_outside() {
538        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 1.0).unwrap();
539        let (dist, closest) = analytic::point_to_sphere(Point3::new(3.0, 0.0, 0.0), &sphere);
540        assert!(
541            (dist - 2.0).abs() < 1e-10,
542            "distance should be 2.0, got {dist}"
543        );
544        assert!((closest.x() - 1.0).abs() < 1e-10);
545        assert!(closest.y().abs() < 1e-10);
546        assert!(closest.z().abs() < 1e-10);
547    }
548
549    #[test]
550    fn point_to_sphere_inside() {
551        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 1.0).unwrap();
552        let (dist, closest) = analytic::point_to_sphere(Point3::new(0.5, 0.0, 0.0), &sphere);
553        assert!(
554            (dist - 0.5).abs() < 1e-10,
555            "distance should be 0.5, got {dist}"
556        );
557        assert!((closest.x() - 1.0).abs() < 1e-10);
558    }
559
560    #[test]
561    fn point_to_cylinder_outside() {
562        let cyl =
563            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
564                .unwrap();
565        let (dist, closest) = analytic::point_to_cylinder(Point3::new(2.0, 0.0, 0.0), &cyl);
566        assert!(
567            (dist - 1.0).abs() < 1e-10,
568            "distance should be 1.0, got {dist}"
569        );
570        assert!((closest.x() - 1.0).abs() < 1e-10);
571        assert!(closest.y().abs() < 1e-10);
572        assert!(closest.z().abs() < 1e-10);
573    }
574
575    #[test]
576    fn point_to_plane_above() {
577        let normal = Vec3::new(0.0, 0.0, 1.0);
578        let d = 0.0;
579        let (dist, closest) = analytic::point_to_plane(Point3::new(0.0, 0.0, 5.0), normal, d);
580        assert!(
581            (dist - 5.0).abs() < 1e-10,
582            "distance should be 5.0, got {dist}"
583        );
584        assert!(closest.x().abs() < 1e-10);
585        assert!(closest.y().abs() < 1e-10);
586        assert!(closest.z().abs() < 1e-10);
587    }
588
589    #[test]
590    fn segment_segment_parallel() {
591        // Two parallel segments along X, separated by 2.0 in Y.
592        let (dist, _, _) = edge::segment_segment_distance(
593            Point3::new(0.0, 0.0, 0.0),
594            Point3::new(1.0, 0.0, 0.0),
595            Point3::new(0.0, 2.0, 0.0),
596            Point3::new(1.0, 2.0, 0.0),
597        );
598        assert!(
599            (dist - 2.0).abs() < 1e-10,
600            "parallel segment distance should be 2.0, got {dist}"
601        );
602    }
603
604    #[test]
605    fn segment_segment_crossing() {
606        // Two segments that cross: one along X, one along Y, both through origin.
607        let (dist, _, _) = edge::segment_segment_distance(
608            Point3::new(-1.0, 0.0, 0.0),
609            Point3::new(1.0, 0.0, 0.0),
610            Point3::new(0.0, -1.0, 0.0),
611            Point3::new(0.0, 1.0, 0.0),
612        );
613        assert!(
614            dist < 1e-10,
615            "crossing segment distance should be ~0, got {dist}"
616        );
617    }
618
619    #[test]
620    fn segment_segment_skew() {
621        // Two skew segments separated by 3.0 in Z.
622        let (dist, ca, cb) = edge::segment_segment_distance(
623            Point3::new(0.0, 0.0, 0.0),
624            Point3::new(1.0, 0.0, 0.0),
625            Point3::new(0.0, 0.0, 3.0),
626            Point3::new(0.0, 1.0, 3.0),
627        );
628        assert!(
629            (dist - 3.0).abs() < 1e-10,
630            "skew segment distance should be 3.0, got {dist}"
631        );
632        assert!(ca.z().abs() < 1e-10);
633        assert!((cb.z() - 3.0).abs() < 1e-10);
634    }
635
636    #[test]
637    fn solid_to_solid_separated() {
638        use brepkit_topology::test_utils::make_unit_cube_manifold_at;
639        let mut topo = Topology::new();
640        // Two unit cubes: one at origin, one at (3, 0, 0). Gap of 2.0 in X.
641        let a = make_unit_cube_manifold_at(&mut topo, 0.0, 0.0, 0.0);
642        let b = make_unit_cube_manifold_at(&mut topo, 3.0, 0.0, 0.0);
643        let result = solid_to_solid(&topo, a, b).unwrap();
644        assert!(
645            (result.distance - 2.0).abs() < 1e-10,
646            "distance should be 2.0, got {}",
647            result.distance
648        );
649    }
650
651    #[test]
652    fn point_to_torus_outside() {
653        // Torus at origin with major_radius=3, minor_radius=1, Z-axis.
654        let torus = ToroidalSurface::new(Point3::new(0.0, 0.0, 0.0), 3.0, 1.0).unwrap();
655        // Point at (6, 0, 0): major circle closest is (3,0,0), tube dist = 3, minor_r = 1.
656        let (dist, closest) = analytic::point_to_torus(Point3::new(6.0, 0.0, 0.0), &torus);
657        assert!(
658            (dist - 2.0).abs() < 1e-10,
659            "distance should be 2.0, got {dist}"
660        );
661        assert!((closest.x() - 4.0).abs() < 1e-10);
662        assert!(closest.y().abs() < 1e-10);
663        assert!(closest.z().abs() < 1e-10);
664    }
665}