Skip to main content

ogeom_algo/
proximity.rs

1//! Minimum distance between shapes.
2//!
3//! *Elsewhere* this is `BRepExtrema_DistShapeShape`. The geometry-level
4//! extrema in `ogeom-intersect` answer where two curves or surfaces come
5//! nearest; this module assembles those answers for topology, where a shape
6//! is vertices, edges and faces and the nearest approach may land on any of
7//! them.
8//!
9//! # The assembly argument
10//!
11//! The nearest distance between two shapes is attained either at an interior
12//! stationary approach of a pair of elements, or on some element's boundary,
13//! and an element's boundary is itself an element: a face's boundary is its
14//! edges, an edge's boundary is its vertices. So walking every pair of
15//! elements (vertex against vertex, edge and face; edge against edge and
16//! face; face against face) with stationary approaches for the interiors and
17//! projections for the points covers every candidate, and the geometry level
18//! is allowed to answer "no interior approach" honestly because the pair that
19//! owns the boundary case is in the same sweep.
20//!
21//! A face's interior approach is accepted only where its foot lands inside
22//! the face's trimming; a foot outside or too near the boundary is dropped,
23//! because the true nearest point of that configuration is on an edge and the
24//! edge pairs find it exactly.
25//!
26//! # What the distance is between
27//!
28//! Boundaries. A shape strictly inside another reports the gap between their
29//! boundaries, not zero: whether a point is *inside* a solid is
30//! [`classify_in_solid_exact`](crate::classify_in_solid_exact)'s question,
31//! and conflating the two would make this answer wrong for the shells it is
32//! right for.
33
34use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
35use ogeom_geom::{Curve, SurfaceGeometry, Transformable, TrimmedCurve};
36use ogeom_math::{Point, Point2, Transform};
37use ogeom_mesh::{Deflection, face_boundary, inside_boundary};
38use ogeom_topo::{EdgeRepr, Model, NodeData, Shape, ShapeType, explore_unique};
39
40use crate::classify::{distance_to_rings, parametric_band};
41use crate::measure::{project_on_curve, project_on_surface};
42use ogeom_intersect::ExtremaOptions;
43
44/// One pair of nearest points, with the elements they lie on.
45#[derive(Debug, Clone)]
46pub struct ClosestPair {
47    /// The nearest point on the first shape.
48    pub point_a: Point,
49    /// The nearest point on the second.
50    pub point_b: Point,
51    /// The vertex, edge or face of the first shape the point lies on.
52    pub support_a: Shape,
53    /// The same for the second shape.
54    pub support_b: Shape,
55}
56
57/// The minimum distance between two shapes, with everywhere it is attained.
58#[derive(Debug, Clone)]
59pub struct ShapeDistance {
60    /// The distance.
61    pub distance: f64,
62    /// Every pair of nearest points found within tolerance of the minimum.
63    /// Parallel walls meet at a representative pair, not at every point of
64    /// the overlap.
65    pub pairs: Vec<ClosestPair>,
66}
67
68/// One element of a shape, with its geometry carried into world space.
69enum Element {
70    Vertex(Shape, Point),
71    Edge(Shape, Box<Curve>),
72    Face(Shape, Box<Prepared>),
73}
74
75/// A face's surface twice over: in world space for the extrema, and local
76/// with its placement and rings for the trim test. Parameters on a
77/// transformed surface need not match the rings, which live in the stored
78/// surface's parameter space, so the trim question is always asked locally.
79struct Prepared {
80    world: SurfaceGeometry,
81    local: SurfaceGeometry,
82    to_local: Transform,
83    rings: Vec<Vec<Point2>>,
84}
85
86/// The minimum distance between two shapes' boundaries.
87///
88/// # Errors
89///
90/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if either shape
91/// has no vertices, edges or faces to measure to;
92/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle fails to
93/// resolve.
94pub fn distance_between_shapes(
95    model: &Model,
96    a: &Shape,
97    b: &Shape,
98    options: ExtremaOptions,
99    tol: Tolerances,
100) -> OgeomResult<ShapeDistance> {
101    let ea = elements(model, a, tol)?;
102    let eb = elements(model, b, tol)?;
103    if ea.is_empty() || eb.is_empty() {
104        ogeom_bail!(Construction, "a shape with no elements has no distance");
105    }
106
107    let mut candidates: Vec<(f64, ClosestPair)> = Vec::new();
108    for element_a in &ea {
109        for element_b in &eb {
110            approach(element_a, element_b, options, tol, &mut candidates)?;
111        }
112    }
113    let Some(least) = candidates
114        .iter()
115        .map(|(d, _)| *d)
116        .min_by(|x, y| x.partial_cmp(y).unwrap_or(core::cmp::Ordering::Equal))
117    else {
118        ogeom_bail!(
119            NotDone,
120            "no candidate approach was found between these shapes"
121        );
122    };
123
124    let mut pairs: Vec<ClosestPair> = Vec::new();
125    for (d, pair) in candidates {
126        if d - least > tol.confusion() {
127            continue;
128        }
129        // The same nearest pair arrives from several element pairs: a corner
130        // is on a vertex, three edges and three faces at once. Keep the first
131        // at each location.
132        if pairs.iter().any(|known| {
133            known.point_a.distance(pair.point_a) <= tol.confusion() * 1e2
134                && known.point_b.distance(pair.point_b) <= tol.confusion() * 1e2
135        }) {
136            continue;
137        }
138        pairs.push(pair);
139    }
140    Ok(ShapeDistance {
141        distance: least,
142        pairs,
143    })
144}
145
146/// Candidate approaches between one pair of elements.
147fn approach(
148    a: &Element,
149    b: &Element,
150    options: ExtremaOptions,
151    tol: Tolerances,
152    out: &mut Vec<(f64, ClosestPair)>,
153) -> OgeomResult<()> {
154    let mut push = |distance: f64, pa: Point, pb: Point, sa: &Shape, sb: &Shape| {
155        out.push((
156            distance,
157            ClosestPair {
158                point_a: pa,
159                point_b: pb,
160                support_a: sa.clone(),
161                support_b: sb.clone(),
162            },
163        ));
164    };
165    match (a, b) {
166        (Element::Vertex(sa, pa), Element::Vertex(sb, pb)) => {
167            push(pa.distance(*pb), *pa, *pb, sa, sb);
168        }
169        (Element::Vertex(sa, pa), Element::Edge(sb, curve)) => {
170            let foot = project_on_curve(curve, *pa, 64, tol)?;
171            push(foot.distance, *pa, foot.point, sa, sb);
172        }
173        (Element::Edge(sa, curve), Element::Vertex(sb, pb)) => {
174            let foot = project_on_curve(curve, *pb, 64, tol)?;
175            push(foot.distance, foot.point, *pb, sa, sb);
176        }
177        (Element::Vertex(sa, pa), Element::Face(sb, face)) => {
178            let foot = project_on_surface(&face.world, *pa, 32, tol)?;
179            if inside_trim(face, foot.point, tol)? {
180                push(foot.distance, *pa, foot.point, sa, sb);
181            }
182        }
183        (Element::Face(sa, face), Element::Vertex(sb, pb)) => {
184            let foot = project_on_surface(&face.world, *pb, 32, tol)?;
185            if inside_trim(face, foot.point, tol)? {
186                push(foot.distance, foot.point, *pb, sa, sb);
187            }
188        }
189        (Element::Edge(sa, ca), Element::Edge(sb, cb)) => {
190            let found = ogeom_intersect::extrema_curve_curve(ca, cb, options, tol)?;
191            for near in &found.approaches {
192                push(near.distance, near.point_a, near.point_b, sa, sb);
193            }
194        }
195        (Element::Edge(sa, curve), Element::Face(sb, face)) => {
196            let found = ogeom_intersect::extrema_curve_surface(curve, &face.world, options, tol)?;
197            for near in &found.approaches {
198                if inside_trim(face, near.point_b, tol)? {
199                    push(near.distance, near.point_a, near.point_b, sa, sb);
200                }
201            }
202        }
203        (Element::Face(sa, face), Element::Edge(sb, curve)) => {
204            let found = ogeom_intersect::extrema_curve_surface(curve, &face.world, options, tol)?;
205            for near in &found.approaches {
206                if inside_trim(face, near.point_b, tol)? {
207                    push(near.distance, near.point_b, near.point_a, sa, sb);
208                }
209            }
210        }
211        (Element::Face(sa, fa), Element::Face(sb, fb)) => {
212            let found =
213                ogeom_intersect::extrema_surface_surface(&fa.world, &fb.world, options, tol)?;
214            for near in &found.approaches {
215                if inside_trim(fa, near.point_a, tol)? && inside_trim(fb, near.point_b, tol)? {
216                    push(near.distance, near.point_a, near.point_b, sa, sb);
217                }
218            }
219        }
220    }
221    Ok(())
222}
223
224/// Whether a world-space point on a face's surface lands inside its trimming.
225///
226/// Asked in the stored surface's own parameter space: the point is carried
227/// into the face's frame and projected there, exactly as `classify_on_face`
228/// does it, because rings and world-surface parameters need not agree under a
229/// placement that scales. Too near the boundary counts as outside: the edge
230/// pairs own that candidate and answer it exactly.
231fn inside_trim(face: &Prepared, world_point: Point, tol: Tolerances) -> OgeomResult<bool> {
232    let local = face.to_local.apply(world_point);
233    let projection = project_on_surface(&face.local, local, 32, tol)?;
234    let (u, v) = projection.parameters;
235    let at = Point2::new(u, v);
236    let band = parametric_band(&face.local, (u, v), tol.confusion() + RING_CHORD, tol);
237    if distance_to_rings(&face.rings, at) <= band {
238        return Ok(false);
239    }
240    Ok(inside_boundary(&face.rings, at))
241}
242
243/// The rings' polylining error, spatially: the trim test's uncertainty band.
244const RING_CHORD: f64 = 1e-3;
245
246/// Every element of a shape, with world-space geometry.
247fn elements(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Vec<Element>> {
248    let mut out = Vec::new();
249    for vertex in explore_unique(model, shape, ShapeType::Vertex)? {
250        let Some(node) = model.node(&vertex) else {
251            ogeom_bail!(Dangling, "vertex is not in this model");
252        };
253        let Some(data) = node.data().as_vertex() else {
254            ogeom_bail!(Construction, "vertex node holds no vertex data");
255        };
256        let placed = vertex.transform(model.datums())?.apply(data.point);
257        out.push(Element::Vertex(vertex, placed));
258    }
259    for edge in explore_unique(model, shape, ShapeType::Edge)? {
260        let Some(node) = model.node(&edge) else {
261            ogeom_bail!(Dangling, "edge is not in this model");
262        };
263        let NodeData::Edge(data) = node.data() else {
264            ogeom_bail!(Construction, "edge node holds no edge data");
265        };
266        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
267            // A degenerate edge has no extent of its own; its vertex and its
268            // face carry its geometry.
269            continue;
270        };
271        let Some(geometry) = model.geometry().curve(*curve) else {
272            ogeom_bail!(Dangling, "curve is not in this model");
273        };
274        let placement = edge.transform(model.datums())?;
275        let trimmed: Curve = if (range.0, range.1) == {
276            use ogeom_geom::Curve3d as _;
277            geometry.domain()
278        } {
279            geometry.clone()
280        } else {
281            TrimmedCurve::new(geometry.clone(), range.0, range.1, tol)?.into()
282        };
283        out.push(Element::Edge(
284            edge,
285            Box::new(trimmed.transformed(&placement, tol)?),
286        ));
287    }
288    let ring_deflection = Deflection {
289        chord: RING_CHORD,
290        angular: 0.05,
291        ..Deflection::default()
292    };
293    for face in explore_unique(model, shape, ShapeType::Face)? {
294        let Some(node) = model.node(&face) else {
295            ogeom_bail!(Dangling, "face is not in this model");
296        };
297        let NodeData::Face(data) = node.data() else {
298            ogeom_bail!(Construction, "face node holds no face data");
299        };
300        let Some(surface) = model.geometry().surface(data.surface) else {
301            ogeom_bail!(Dangling, "face refers to a surface not in this model");
302        };
303        let placement = face.transform(model.datums())?;
304        let rings = face_boundary(model, &face, ring_deflection, tol)?;
305        // The stored surface may declare an enormous domain (a plane spans
306        // ±1e9), and the extrema layer rightly refuses to sample that. The
307        // face only uses what its rings enclose, so the surface handed over
308        // is trimmed to their parameter bound, with a margin for the rings'
309        // own polylining, before being carried into world space.
310        let restricted = restrict_to_rings(surface, &rings, tol)?;
311        out.push(Element::Face(
312            face,
313            Box::new(Prepared {
314                world: restricted.transformed(&placement, tol)?,
315                local: surface.clone(),
316                to_local: placement.inverse()?,
317                rings,
318            }),
319        ));
320    }
321    Ok(out)
322}
323
324/// The surface restricted to the parameter rectangle its rings enclose.
325///
326/// The margin is proportional to the used span: the exact boundary lies
327/// within the rings' polylining of it, and `inside_trim` already treats the
328/// near-boundary band as the edges' territory, so the margin only has to
329/// keep the whole face inside the restriction; it does not have to be
330/// tight.
331fn restrict_to_rings(
332    surface: &SurfaceGeometry,
333    rings: &[Vec<Point2>],
334    tol: Tolerances,
335) -> OgeomResult<SurfaceGeometry> {
336    use ogeom_geom::Surface as _;
337    let ((ua, ub), (va, vb)) = surface.domain();
338    let mut u = (f64::INFINITY, f64::NEG_INFINITY);
339    let mut v = (f64::INFINITY, f64::NEG_INFINITY);
340    for ring in rings {
341        for p in ring {
342            u = (u.0.min(p.x), u.1.max(p.x));
343            v = (v.0.min(p.y), v.1.max(p.y));
344        }
345    }
346    if u.0 > u.1 || v.0 > v.1 {
347        // No rings: a naturally closed face uses its whole domain.
348        return Ok(surface.clone());
349    }
350    let margin_u = (u.1 - u.0).mul_add(0.05, tol.parametric());
351    let margin_v = (v.1 - v.0).mul_add(0.05, tol.parametric());
352    let lo_u = (u.0 - margin_u).max(ua);
353    let hi_u = (u.1 + margin_u).min(ub);
354    let lo_v = (v.0 - margin_v).max(va);
355    let hi_v = (v.1 + margin_v).min(vb);
356    if lo_u >= hi_u || lo_v >= hi_v {
357        return Ok(surface.clone());
358    }
359    Ok(ogeom_geom::TrimmedSurface::new(surface.clone(), (lo_u, hi_u), (lo_v, hi_v), tol)?.into())
360}
361
362#[cfg(test)]
363#[allow(clippy::unwrap_used)]
364mod tests {
365    use super::*;
366    use crate::{make_box, make_cylinder, make_sphere};
367    use ogeom_math::{Direction, Frame, Vector};
368
369    const T: Tolerances = Tolerances::millimetres();
370
371    fn frame_at(origin: Point) -> Frame {
372        Frame::new(origin, Direction::Z, Direction::X, T).unwrap()
373    }
374
375    #[test]
376    fn parallel_box_walls_meet_at_the_gap_between_them() {
377        let mut model = Model::new();
378        let a = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
379        let b = make_box(
380            &mut model,
381            frame_at(Point::new(5.0, 0.0, 0.0)),
382            (2.0, 2.0, 2.0),
383            T,
384        )
385        .unwrap();
386        let found =
387            distance_between_shapes(&model, &a.shape, &b.shape, ExtremaOptions::default(), T)
388                .unwrap();
389        assert!((found.distance - 3.0).abs() < 1e-9, "{}", found.distance);
390        assert!(!found.pairs.is_empty());
391        for pair in &found.pairs {
392            assert!((pair.point_a.distance(pair.point_b) - found.distance).abs() < 1e-9);
393        }
394    }
395
396    #[test]
397    fn diagonal_boxes_meet_corner_to_corner() {
398        // Offset along all three axes: the nearest points are two vertices,
399        // exactly the candidates the geometry level declines to invent.
400        let mut model = Model::new();
401        let a = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
402        let b = make_box(
403            &mut model,
404            frame_at(Point::new(3.0, 3.0, 3.0)),
405            (1.0, 1.0, 1.0),
406            T,
407        )
408        .unwrap();
409        let found =
410            distance_between_shapes(&model, &a.shape, &b.shape, ExtremaOptions::default(), T)
411                .unwrap();
412        let exact = (3.0_f64 * 4.0).sqrt(); // corner (1,1,1) to corner (3,3,3)
413        assert!((found.distance - exact).abs() < 1e-9);
414        let pair = &found.pairs[0];
415        assert!(pair.point_a.is_equal(Point::new(1.0, 1.0, 1.0), T));
416        assert!(pair.point_b.is_equal(Point::new(3.0, 3.0, 3.0), T));
417        assert_eq!(model.kind_of(&pair.support_a).unwrap(), ShapeType::Vertex);
418        assert_eq!(model.kind_of(&pair.support_b).unwrap(), ShapeType::Vertex);
419    }
420
421    #[test]
422    fn a_sphere_over_a_box_measures_to_the_top_face() {
423        let mut model = Model::new();
424        let block = make_box(&mut model, Frame::WORLD, (4.0, 4.0, 1.0), T).unwrap();
425        let ball = make_sphere(&mut model, frame_at(Point::new(2.0, 2.0, 4.0)), 1.0, T).unwrap();
426        let found = distance_between_shapes(
427            &model,
428            &block.shape,
429            &ball.shape,
430            ExtremaOptions::default(),
431            T,
432        )
433        .unwrap();
434        assert!((found.distance - 2.0).abs() < 1e-7, "{}", found.distance);
435        let pair = &found.pairs[0];
436        assert!(pair.point_a.is_equal(Point::new(2.0, 2.0, 1.0), T));
437        assert!(pair.point_b.is_equal(Point::new(2.0, 2.0, 3.0), T));
438    }
439
440    #[test]
441    fn parallel_cylinders_meet_wall_to_wall() {
442        // The nearest locus is a pair of facing rulings: a family at the
443        // geometry level, a representative pair here, with the distance exact.
444        let mut model = Model::new();
445        let a = make_cylinder(&mut model, Frame::WORLD, 1.0, 4.0, T).unwrap();
446        let b =
447            make_cylinder(&mut model, frame_at(Point::new(5.0, 0.0, 0.0)), 1.0, 4.0, T).unwrap();
448        let found =
449            distance_between_shapes(&model, &a.shape, &b.shape, ExtremaOptions::default(), T)
450                .unwrap();
451        assert!((found.distance - 3.0).abs() < 1e-7, "{}", found.distance);
452    }
453
454    #[test]
455    fn touching_boxes_report_zero() {
456        let mut model = Model::new();
457        let a = make_box(&mut model, Frame::WORLD, (2.0, 2.0, 2.0), T).unwrap();
458        let b = make_box(
459            &mut model,
460            frame_at(Point::new(2.0, 0.0, 0.0)),
461            (2.0, 2.0, 2.0),
462            T,
463        )
464        .unwrap();
465        let found =
466            distance_between_shapes(&model, &a.shape, &b.shape, ExtremaOptions::default(), T)
467                .unwrap();
468        assert!(found.distance < 1e-9, "{}", found.distance);
469    }
470
471    #[test]
472    fn a_box_inside_a_box_measures_boundary_to_boundary() {
473        // Containment is the classifier's question. Distance is between
474        // boundaries, and the gap between nested walls is what comes back.
475        let mut model = Model::new();
476        let outer = make_box(&mut model, Frame::WORLD, (6.0, 6.0, 6.0), T).unwrap();
477        let inner = make_box(
478            &mut model,
479            frame_at(Point::new(2.0, 2.0, 2.0)),
480            (2.0, 2.0, 2.0),
481            T,
482        )
483        .unwrap();
484        let found = distance_between_shapes(
485            &model,
486            &outer.shape,
487            &inner.shape,
488            ExtremaOptions::default(),
489            T,
490        )
491        .unwrap();
492        assert!((found.distance - 2.0).abs() < 1e-9, "{}", found.distance);
493    }
494
495    #[test]
496    fn a_rotated_box_measures_edge_to_edge() {
497        // Roll one box forty-five degrees about x and lift it: what faces the
498        // top of the lower box is a single edge, and the nearest pair is that
499        // edge against the top face.
500        let mut model = Model::new();
501        let a = make_box(&mut model, Frame::WORLD, (4.0, 4.0, 1.0), T).unwrap();
502        let tilted = Frame::new(
503            Point::new(2.0, 2.0, 3.0),
504            Direction::new(Vector::new(0.0, 1.0, 1.0), T).unwrap(),
505            Direction::X,
506            T,
507        )
508        .unwrap();
509        let b = make_box(&mut model, tilted, (1.0, 1.0, 1.0), T).unwrap();
510        let found =
511            distance_between_shapes(&model, &a.shape, &b.shape, ExtremaOptions::default(), T)
512                .unwrap();
513        // The tilted box's lowest feature is the edge its roll brings down to
514        // z = 3 - 1/sqrt(2), facing the top face at z = 1.
515        let exact = 2.0 - core::f64::consts::FRAC_1_SQRT_2;
516        assert!((found.distance - exact).abs() < 1e-7, "{}", found.distance);
517    }
518}