1use std::collections::{BTreeMap, BTreeSet};
15
16use axioval_ir::ObjectId;
17
18use crate::proximity::{Bounds3, ObjectBounds, ProximityProjection, VerticalDirection};
19
20#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
22pub enum CandidateSearchError {
23 #[error("candidate search margin must be finite and non-negative")]
25 InvalidMargin,
26 #[error("object {0} was supplied with conflicting bounds")]
28 ConflictingBounds(ObjectId),
29}
30
31#[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
57pub 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 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 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 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
142pub 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 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
233fn 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 #[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 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 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 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 #[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 #[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 #[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 #[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}