Skip to main content

axioval_engine/
pairwise.rs

1//! Broad-phase candidate search for pairwise spatial checks.
2//!
3//! Measuring every subject against every counterpart is quadratic in model
4//! size and almost entirely wasted: most pairs are metres apart. This search
5//! discards pairs by their enclosing boxes and hands only the rest to a
6//! narrow-phase measurement.
7//!
8//! Discarding must never lose a real pair, so it works on
9//! [`ObjectBounds::enclosing`] boxes, which contain the true body even for
10//! tessellated geometry, and on the box gap, which never exceeds the gap
11//! between the bodies inside. The result is therefore complete: a pair absent
12//! from it is proven farther apart than the margin.
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use axioval_ir::ObjectId;
17
18use crate::proximity::{Bounds3, ObjectBounds, ProximityProjection, VerticalDirection};
19
20/// Why a candidate search could not run.
21#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
22pub enum CandidateSearchError {
23    /// The search margin is negative or non-finite.
24    #[error("candidate search margin must be finite and non-negative")]
25    InvalidMargin,
26    /// One object was supplied twice with different extents.
27    #[error("object {0} was supplied with conflicting bounds")]
28    ConflictingBounds(ObjectId),
29}
30
31/// A subject and a counterpart whose enclosing boxes lie within the margin.
32///
33/// When both objects belong to both groups the pair is reported once, with
34/// the lesser identity as the subject.
35#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
36pub struct CandidatePair {
37    subject: ObjectId,
38    counterpart: ObjectId,
39}
40
41impl CandidatePair {
42    pub fn subject(&self) -> &ObjectId {
43        &self.subject
44    }
45    pub fn counterpart(&self) -> &ObjectId {
46        &self.counterpart
47    }
48}
49
50struct Entry<'a> {
51    id: &'a ObjectId,
52    enclosing: Bounds3,
53    subject: bool,
54    counterpart: bool,
55}
56
57/// Every subject/counterpart pair whose enclosing boxes lie within
58/// `margin_metres` of each other, in identity order.
59///
60/// An object may appear in both groups; it is never paired with itself.
61/// Sweep-and-prune along x keeps the cost near-linear in the object count plus
62/// the number of pairs reported.
63pub fn candidate_pairs(
64    subjects: &[ObjectBounds],
65    counterparts: &[ObjectBounds],
66    margin_metres: f64,
67) -> Result<Vec<CandidatePair>, CandidateSearchError> {
68    if !margin_metres.is_finite() || margin_metres < 0.0 {
69        return Err(CandidateSearchError::InvalidMargin);
70    }
71    let mut entries: BTreeMap<&ObjectId, Entry<'_>> = BTreeMap::new();
72    for (bounds, is_subject) in subjects
73        .iter()
74        .map(|b| (b, true))
75        .chain(counterparts.iter().map(|b| (b, false)))
76    {
77        let entry = entries.entry(bounds.object()).or_insert_with(|| Entry {
78            id: bounds.object(),
79            enclosing: bounds.enclosing(),
80            subject: false,
81            counterpart: false,
82        });
83        if entry.enclosing != bounds.enclosing() {
84            return Err(CandidateSearchError::ConflictingBounds(
85                bounds.object().clone(),
86            ));
87        }
88        if is_subject {
89            entry.subject = true;
90        } else {
91            entry.counterpart = true;
92        }
93    }
94
95    // Sort by the lower x of each box grown by the margin, identity breaking
96    // ties so the sweep itself is deterministic.
97    let mut sweep: Vec<Entry<'_>> = entries.into_values().collect();
98    sweep.sort_by(|a, b| {
99        a.enclosing.min()[0]
100            .total_cmp(&b.enclosing.min()[0])
101            .then_with(|| a.id.cmp(b.id))
102    });
103
104    let mut pairs = Vec::new();
105    let mut active: Vec<usize> = Vec::new();
106    for (index, entry) in sweep.iter().enumerate() {
107        // Anything whose x-extent ends more than the margin before this box
108        // begins can meet nothing later in the sweep either.
109        active.retain(|&open| {
110            sweep[open].enclosing.max()[0] + margin_metres >= entry.enclosing.min()[0]
111        });
112        for &open in &active {
113            let other = &sweep[open];
114            let eligible =
115                (entry.subject && other.counterpart) || (entry.counterpart && other.subject);
116            if !eligible || entry.enclosing.gap(&other.enclosing) > margin_metres {
117                continue;
118            }
119            let (first, second) = if entry.id < other.id {
120                (entry, other)
121            } else {
122                (other, entry)
123            };
124            // Prefer the lesser identity as subject whenever that orientation
125            // is allowed, so a symmetric search reports each pair once.
126            let (subject, counterpart) = if first.subject && second.counterpart {
127                (first.id, second.id)
128            } else {
129                (second.id, first.id)
130            };
131            pairs.push(CandidatePair {
132                subject: subject.clone(),
133                counterpart: counterpart.clone(),
134            });
135        }
136        active.push(index);
137    }
138    pairs.sort();
139    Ok(pairs)
140}
141
142/// Every subject/counterpart pair that may lie within `margin_metres` of
143/// each other in `projection`, in identity order.
144///
145/// Each projection prunes by the gap that bounds its own distance from below,
146/// so the search stays complete:
147///
148/// - `Minimum3d`: the Euclidean box gap, as [`candidate_pairs`].
149/// - `Horizontal`: the plan box gap. Two bodies on different storeys can be
150///   close in plan, so the vertical gap discards nothing.
151/// - `PlanOverlap`: footprints that overlap have boxes that meet in plan, so
152///   the plan margin is zero whatever `margin_metres` says.
153/// - `Vertical`: the bodies are related only when their plan box gap is within
154///   the footprint offset, and their distance is at least the vertical gap
155///   between their boxes, which must be within the margin. With a direction
156///   the gap is one-sided (subject top up to counterpart bottom for `Above`),
157///   and a counterpart whose box lies wholly on the other side of the
158///   subject's box is certainly not in that direction. A pair is kept when
159///   either orientation the groups allow qualifies, since a capability
160///   measures a pair reported once from both of its ends.
161pub fn projected_candidate_pairs(
162    subjects: &[ObjectBounds],
163    counterparts: &[ObjectBounds],
164    projection: ProximityProjection,
165    margin_metres: f64,
166) -> Result<Vec<CandidatePair>, CandidateSearchError> {
167    if !margin_metres.is_finite() || margin_metres < 0.0 {
168        return Err(CandidateSearchError::InvalidMargin);
169    }
170    let plan_margin = match projection {
171        ProximityProjection::Minimum3d => {
172            return candidate_pairs(subjects, counterparts, margin_metres);
173        }
174        ProximityProjection::Horizontal => margin_metres,
175        ProximityProjection::PlanOverlap => 0.0,
176        ProximityProjection::Vertical {
177            footprint_offset_metres,
178            ..
179        } => footprint_offset_metres,
180    };
181    // Flattening loses the vertical extent, so refuse conflicting inputs
182    // before it could hide a conflict in height.
183    let mut enclosing: BTreeMap<&ObjectId, Bounds3> = BTreeMap::new();
184    for bounds in subjects.iter().chain(counterparts) {
185        let known = enclosing
186            .entry(bounds.object())
187            .or_insert_with(|| bounds.enclosing());
188        if *known != bounds.enclosing() {
189            return Err(CandidateSearchError::ConflictingBounds(
190                bounds.object().clone(),
191            ));
192        }
193    }
194    let flat = |group: &[ObjectBounds]| -> Result<Vec<ObjectBounds>, CandidateSearchError> {
195        group
196            .iter()
197            .map(|bounds| {
198                let (min, max) = (bounds.bounds().min(), bounds.bounds().max());
199                Bounds3::try_new([min[0], min[1], 0.0], [max[0], max[1], 0.0])
200                    .and_then(|flat| {
201                        ObjectBounds::try_new(bounds.object().clone(), flat, bounds.fidelity())
202                    })
203                    .map_err(|_| CandidateSearchError::InvalidMargin)
204            })
205            .collect()
206    };
207    let pairs = candidate_pairs(&flat(subjects)?, &flat(counterparts)?, plan_margin)?;
208    let ProximityProjection::Vertical { direction, .. } = projection else {
209        return Ok(pairs);
210    };
211    let in_subjects: BTreeSet<&ObjectId> = subjects.iter().map(ObjectBounds::object).collect();
212    let in_counterparts: BTreeSet<&ObjectId> =
213        counterparts.iter().map(ObjectBounds::object).collect();
214    let keep = |subject: &ObjectId, counterpart: &ObjectId, direction| {
215        vertically_within(
216            &enclosing[subject],
217            &enclosing[counterpart],
218            direction,
219            margin_metres,
220        )
221    };
222    Ok(pairs
223        .into_iter()
224        .filter(|pair| {
225            keep(pair.subject(), pair.counterpart(), direction)
226                || (in_subjects.contains(pair.counterpart())
227                    && in_counterparts.contains(pair.subject())
228                    && keep(pair.counterpart(), pair.subject(), direction))
229        })
230        .collect())
231}
232
233/// Whether a counterpart in `counterpart` may lie within `margin` of a
234/// subject in `subject` in `direction`, judged on enclosing boxes.
235///
236/// The one-sided box gap bounds the one-sided distance from below. A box
237/// wholly below the subject's (its top under the subject's bottom) holds a
238/// body lower at both ends than the subject, so certainly not above it.
239fn vertically_within(
240    subject: &Bounds3,
241    counterpart: &Bounds3,
242    direction: VerticalDirection,
243    margin: f64,
244) -> bool {
245    let rise = counterpart.min()[2] - subject.max()[2];
246    let drop = subject.min()[2] - counterpart.max()[2];
247    match direction {
248        VerticalDirection::Either => rise.max(drop) <= margin,
249        VerticalDirection::Above => rise <= margin && drop <= 0.0,
250        VerticalDirection::Below => drop <= margin && rise <= 0.0,
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::proximity::GeometryFidelity;
258    use axioval_ir::SourceId;
259
260    fn id(local: &str) -> ObjectId {
261        ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
262    }
263    fn unit_box(local: &str, x: f64, fidelity: GeometryFidelity) -> ObjectBounds {
264        ObjectBounds::try_new(
265            id(local),
266            Bounds3::try_new([x, 0.0, 0.0], [x + 1.0, 1.0, 1.0]).unwrap(),
267            fidelity,
268        )
269        .unwrap()
270    }
271    fn exact(local: &str, x: f64) -> ObjectBounds {
272        unit_box(local, x, GeometryFidelity::Exact)
273    }
274    fn names(pairs: &[CandidatePair]) -> Vec<(String, String)> {
275        pairs
276            .iter()
277            .map(|p| (p.subject.local_id.clone(), p.counterpart.local_id.clone()))
278            .collect()
279    }
280
281    /// The broad phase must agree with an exhaustive search. Any pair it drops
282    /// is a clash the narrow phase never sees.
283    #[test]
284    fn sweep_matches_exhaustive_search() {
285        let objects: Vec<ObjectBounds> = (0..40)
286            .map(|i| {
287                let x = f64::from((i * 37) % 23) * 0.7;
288                let y = f64::from((i * 11) % 7) * 0.9;
289                ObjectBounds::try_new(
290                    id(&format!("o{i:02}")),
291                    Bounds3::try_new([x, y, 0.0], [x + 1.0, y + 0.5, 1.0]).unwrap(),
292                    GeometryFidelity::Exact,
293                )
294                .unwrap()
295            })
296            .collect();
297        let (subjects, counterparts) = objects.split_at(15);
298        for margin in [0.0, 0.3, 2.0] {
299            let found = candidate_pairs(subjects, counterparts, margin).unwrap();
300            let mut expected = Vec::new();
301            for s in subjects {
302                for c in counterparts {
303                    if s.enclosing().gap(&c.enclosing()) <= margin {
304                        expected.push(CandidatePair {
305                            subject: s.object().clone(),
306                            counterpart: c.object().clone(),
307                        });
308                    }
309                }
310            }
311            expected.sort();
312            assert_eq!(found, expected, "margin {margin}");
313        }
314    }
315
316    /// The pairs an exhaustive search keeps, each checked on its own.
317    fn exhaustive_pairs(
318        subjects: &[ObjectBounds],
319        counterparts: &[ObjectBounds],
320        projection: ProximityProjection,
321        margin: f64,
322    ) -> Vec<CandidatePair> {
323        let gap = |a: &Bounds3, b: &Bounds3, axis: usize| {
324            (b.min()[axis] - a.max()[axis])
325                .max(a.min()[axis] - b.max()[axis])
326                .max(0.0)
327        };
328        let plan_gap = |a: &Bounds3, b: &Bounds3| gap(a, b, 0).hypot(gap(a, b, 1));
329        // Upward gap from the subject's top to the counterpart's bottom.
330        let up = |a: &Bounds3, b: &Bounds3| (b.min()[2] - a.max()[2]).max(0.0);
331        let wholly_below = |a: &Bounds3, b: &Bounds3| b.max()[2] < a.min()[2];
332        let keep = |s: &ObjectBounds, c: &ObjectBounds| {
333            let (a, b) = (s.enclosing(), c.enclosing());
334            match projection {
335                ProximityProjection::Minimum3d => a.gap(&b) <= margin,
336                ProximityProjection::Horizontal => plan_gap(&a, &b) <= margin,
337                ProximityProjection::PlanOverlap => plan_gap(&a, &b) <= 0.0,
338                ProximityProjection::Vertical {
339                    footprint_offset_metres,
340                    direction,
341                    ..
342                } => {
343                    plan_gap(&a, &b) <= footprint_offset_metres
344                        && match direction {
345                            VerticalDirection::Either => gap(&a, &b, 2) <= margin,
346                            VerticalDirection::Above => {
347                                up(&a, &b) <= margin && !wholly_below(&a, &b)
348                            }
349                            VerticalDirection::Below => {
350                                up(&b, &a) <= margin && !wholly_below(&b, &a)
351                            }
352                        }
353                }
354            }
355        };
356        let mut expected = BTreeSet::new();
357        for s in subjects {
358            for c in counterparts {
359                if s.object() == c.object() || !keep(s, c) {
360                    continue;
361                }
362                // Reported once, the lesser identity as subject when the
363                // groups allow it.
364                let reversible = subjects.iter().any(|o| o.object() == c.object())
365                    && counterparts.iter().any(|o| o.object() == s.object());
366                let (subject, counterpart) = if reversible && c.object() < s.object() {
367                    (c.object(), s.object())
368                } else {
369                    (s.object(), c.object())
370                };
371                expected.insert(CandidatePair {
372                    subject: subject.clone(),
373                    counterpart: counterpart.clone(),
374                });
375            }
376        }
377        expected.into_iter().collect()
378    }
379
380    /// Each projection must keep every pair whose projected box gap is within
381    /// its margin: that gap bounds the projected distance from below. A
382    /// vertical direction may drop only pairs whose boxes put the
383    /// counterpart certainly on the other side. Disjoint groups keep their
384    /// roles; one group checked against itself keeps a pair when either
385    /// orientation qualifies.
386    #[test]
387    fn projected_sweeps_match_exhaustive_search() {
388        let objects: Vec<ObjectBounds> = (0..40)
389            .map(|i| {
390                let x = f64::from((i * 37) % 23) * 0.7;
391                let y = f64::from((i * 11) % 7) * 0.9;
392                let z = f64::from((i * 5) % 4) * 3.0 + f64::from(i % 3) * 0.2;
393                let height = 0.3 + f64::from(i % 5);
394                ObjectBounds::try_new(
395                    id(&format!("o{i:02}")),
396                    Bounds3::try_new([x, y, z], [x + 1.0, y + 0.5, z + height]).unwrap(),
397                    if i % 3 == 0 {
398                        GeometryFidelity::tessellated(0.05).unwrap()
399                    } else {
400                        GeometryFidelity::Exact
401                    },
402                )
403                .unwrap()
404            })
405            .collect();
406        let mut projections = vec![
407            ProximityProjection::Minimum3d,
408            ProximityProjection::Horizontal,
409            ProximityProjection::PlanOverlap,
410        ];
411        for direction in [
412            VerticalDirection::Either,
413            VerticalDirection::Above,
414            VerticalDirection::Below,
415        ] {
416            for footprint_offset_metres in [0.0, 1.0] {
417                projections.push(ProximityProjection::Vertical {
418                    footprint_offset_metres,
419                    direction,
420                    surfaces: crate::VerticalSurfaces::Extents,
421                });
422            }
423        }
424        let groups = [
425            objects.split_at(15),
426            (&objects[..25], &objects[..25]),
427            (&objects[10..30], &objects[20..]),
428        ];
429        for (subjects, counterparts) in groups {
430            for margin in [0.0, 0.3, 2.0, 4.0] {
431                for &projection in &projections {
432                    assert_eq!(
433                        projected_candidate_pairs(subjects, counterparts, projection, margin)
434                            .unwrap(),
435                        exhaustive_pairs(subjects, counterparts, projection, margin),
436                        "{projection:?} margin {margin}"
437                    );
438                }
439            }
440        }
441    }
442
443    /// A sprinkler under a ceiling and over a floor: each direction keeps
444    /// only its own side, within the margin.
445    #[test]
446    fn a_vertical_direction_keeps_only_its_side() {
447        let body = |local: &str, z: [f64; 2]| {
448            ObjectBounds::try_new(
449                id(local),
450                Bounds3::try_new([0.0, 0.0, z[0]], [1.0, 1.0, z[1]]).unwrap(),
451                GeometryFidelity::Exact,
452            )
453            .unwrap()
454        };
455        let sprinkler = body("sprinkler", [2.6, 2.7]);
456        let others = [
457            body("ceiling", [3.0, 3.2]),
458            body("floor", [-0.2, 0.0]),
459            body("riser", [-1.0, 4.0]),
460        ];
461        let kept = |direction, margin| {
462            names(
463                &projected_candidate_pairs(
464                    std::slice::from_ref(&sprinkler),
465                    &others,
466                    ProximityProjection::Vertical {
467                        footprint_offset_metres: 0.0,
468                        direction,
469                        surfaces: crate::VerticalSurfaces::Extents,
470                    },
471                    margin,
472                )
473                .unwrap(),
474            )
475            .into_iter()
476            .map(|(_, counterpart)| counterpart)
477            .collect::<Vec<_>>()
478        };
479        assert_eq!(kept(VerticalDirection::Above, 0.5), ["ceiling", "riser"]);
480        assert_eq!(kept(VerticalDirection::Below, 0.5), ["riser"]);
481        assert_eq!(kept(VerticalDirection::Below, 3.0), ["floor", "riser"]);
482        assert_eq!(kept(VerticalDirection::Either, 0.5), ["ceiling", "riser"]);
483    }
484
485    /// Bodies on different storeys are close in plan and above one another.
486    #[test]
487    fn projections_ignore_the_height_that_does_not_measure_them() {
488        let low = exact("low", 0.0);
489        let high = ObjectBounds::try_new(
490            id("high"),
491            Bounds3::try_new([0.5, 0.0, 10.0], [1.5, 1.0, 11.0]).unwrap(),
492            GeometryFidelity::Exact,
493        )
494        .unwrap();
495        let (subjects, counterparts) = (&[low][..], &[high][..]);
496        assert!(
497            candidate_pairs(subjects, counterparts, 1.0)
498                .unwrap()
499                .is_empty()
500        );
501        for projection in [
502            ProximityProjection::Horizontal,
503            ProximityProjection::PlanOverlap,
504        ] {
505            assert_eq!(
506                projected_candidate_pairs(subjects, counterparts, projection, 0.0)
507                    .unwrap()
508                    .len(),
509                1
510            );
511        }
512        let vertical = ProximityProjection::Vertical {
513            footprint_offset_metres: 0.0,
514            direction: VerticalDirection::Either,
515            surfaces: crate::VerticalSurfaces::Extents,
516        };
517        assert_eq!(
518            projected_candidate_pairs(subjects, counterparts, vertical, 9.0)
519                .unwrap()
520                .len(),
521            1
522        );
523        assert!(
524            projected_candidate_pairs(subjects, counterparts, vertical, 8.5)
525                .unwrap()
526                .is_empty()
527        );
528        let conflicting = ObjectBounds::try_new(
529            id("low"),
530            Bounds3::try_new([0.0, 0.0, 5.0], [1.0, 1.0, 6.0]).unwrap(),
531            GeometryFidelity::Exact,
532        )
533        .unwrap();
534        assert_eq!(
535            projected_candidate_pairs(
536                subjects,
537                &[conflicting],
538                ProximityProjection::Horizontal,
539                0.0
540            ),
541            Err(CandidateSearchError::ConflictingBounds(id("low")))
542        );
543    }
544
545    #[test]
546    fn a_symmetric_search_reports_each_pair_once_and_never_self() {
547        let all = [exact("a", 0.0), exact("b", 0.5), exact("c", 5.0)];
548        let pairs = candidate_pairs(&all, &all, 0.0).unwrap();
549        assert_eq!(names(&pairs), vec![("a".into(), "b".into())]);
550    }
551
552    #[test]
553    fn subject_and_counterpart_roles_are_kept() {
554        let pairs = candidate_pairs(&[exact("z", 0.0)], &[exact("a", 0.5)], 0.0).unwrap();
555        assert_eq!(names(&pairs), vec![("z".into(), "a".into())]);
556    }
557
558    /// A tessellated cylinder's true surface extends past its mesh. The pair
559    /// must survive even though the mesh boxes are apart.
560    #[test]
561    fn tessellation_deviation_widens_the_search() {
562        let pipe = unit_box("pipe", 0.0, GeometryFidelity::tessellated(0.01).unwrap());
563        let wall = exact("wall", 1.005);
564        assert_eq!(candidate_pairs(&[pipe], &[wall], 0.0).unwrap().len(), 1);
565    }
566
567    #[test]
568    fn conflicting_bounds_and_bad_margins_are_refused() {
569        assert_eq!(
570            candidate_pairs(&[exact("a", 0.0)], &[exact("a", 3.0)], 0.0),
571            Err(CandidateSearchError::ConflictingBounds(id("a")))
572        );
573        for margin in [-1.0, f64::NAN, f64::INFINITY] {
574            assert_eq!(
575                candidate_pairs(&[], &[], margin),
576                Err(CandidateSearchError::InvalidMargin)
577            );
578        }
579    }
580}