1use std::cmp::Ordering;
26use std::collections::{BinaryHeap, HashSet};
27
28use crate::continuous::{PolygonPath, PolygonPathfinder, PolygonSearchResult};
29use crate::polygonal::{Point2, Polygon, PolygonScene, PolygonSearchRequest, WorldBounds};
30
31const EPSILON: f64 = 1e-9;
32
33#[derive(Debug, Clone, Copy, Default)]
39pub struct TopologicalFractureSearch;
40
41#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
47pub struct TfsDiagnostics {
48 pub visited_nodes: usize,
50 pub fractures: usize,
52 pub children_generated: usize,
54 pub children_admitted: usize,
56 pub max_anchors: usize,
58 pub max_taut_len: usize,
60 pub peak_frontier_len: usize,
62 pub memory_proxy: usize,
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub struct TfsInspection {
69 pub result: PolygonSearchResult,
71 pub diagnostics: TfsDiagnostics,
73}
74
75impl TopologicalFractureSearch {
76 #[must_use]
82 pub fn inspect(&self, scene: &PolygonScene, request: PolygonSearchRequest) -> TfsInspection {
83 if !scene.is_walkable(request.start) {
84 return TfsInspection {
85 result: Err(crate::continuous::PolygonSearchError::InvalidStart {
86 point: request.start,
87 }),
88 diagnostics: TfsDiagnostics::default(),
89 };
90 }
91 if !scene.is_walkable(request.goal) {
92 return TfsInspection {
93 result: Err(crate::continuous::PolygonSearchError::InvalidGoal {
94 point: request.goal,
95 }),
96 diagnostics: TfsDiagnostics::default(),
97 };
98 }
99 let execution = search_impl::<true>(scene, request);
100 TfsInspection {
101 result: execution.result,
102 diagnostics: execution.diagnostics,
103 }
104 }
105}
106
107impl PolygonPathfinder for TopologicalFractureSearch {
108 fn name(&self) -> &'static str {
109 "topological-fracture-search"
110 }
111
112 fn search(&self, scene: &PolygonScene, request: PolygonSearchRequest) -> PolygonSearchResult {
116 if !scene.is_walkable(request.start) {
117 return Err(crate::continuous::PolygonSearchError::InvalidStart {
118 point: request.start,
119 });
120 }
121 if !scene.is_walkable(request.goal) {
122 return Err(crate::continuous::PolygonSearchError::InvalidGoal {
123 point: request.goal,
124 });
125 }
126 search_impl::<false>(scene, request).result
127 }
128}
129
130struct SearchExecution {
131 result: PolygonSearchResult,
132 diagnostics: TfsDiagnostics,
133}
134
135fn search_impl<const TRACK: bool>(
136 scene: &PolygonScene,
137 request: PolygonSearchRequest,
138) -> SearchExecution {
139 let mut diagnostics = TfsDiagnostics::default();
140
141 if scene.validate(request).is_err() {
142 return SearchExecution {
143 result: crate::continuous::not_found(0),
144 diagnostics,
145 };
146 }
147
148 if points_equal(request.start, request.goal) {
149 let result = crate::continuous::found(
150 PolygonPath::from_points(vec![request.start])
151 .expect("polygon path contains at least one point"),
152 1,
153 );
154 if TRACK {
155 diagnostics.visited_nodes = 1;
156 diagnostics.max_anchors = 1;
157 diagnostics.max_taut_len = 1;
158 diagnostics.peak_frontier_len = 1;
159 diagnostics.memory_proxy = 1;
160 }
161 return SearchExecution {
162 result,
163 diagnostics,
164 };
165 }
166
167 let root = FractureNode::new(scene, vec![request.start, request.goal]);
168 let mut frontier = BinaryHeap::from([root]);
169 let mut visited = HashSet::from([path_key(frontier.peek().expect("root node").taut_path())]);
170 let mut visited_nodes = 0usize;
171 let watch = condor_core::BudgetWatch::start(request.budget);
172
173 if TRACK {
174 diagnostics.peak_frontier_len = frontier.len();
175 diagnostics.max_anchors = frontier.peek().map(|node| node.anchors.len()).unwrap_or(0);
176 diagnostics.max_taut_len = frontier
177 .peek()
178 .map(|node| node.taut_path.len())
179 .unwrap_or(0);
180 }
181
182 while let Some(node) = frontier.pop() {
183 visited_nodes += 1;
184 if TRACK {
185 diagnostics.visited_nodes = visited_nodes;
186 diagnostics.max_anchors = diagnostics.max_anchors.max(node.anchors.len());
187 diagnostics.max_taut_len = diagnostics.max_taut_len.max(node.taut_path.len());
188 }
189
190 let Some((segment_index, obstacle_index)) = first_collision(scene, node.taut_path()) else {
191 if TRACK {
192 diagnostics.memory_proxy = diagnostics.peak_frontier_len + visited.len();
193 }
194 return SearchExecution {
195 result: crate::continuous::found(
196 PolygonPath::from_points_with_cost(node.taut_path().to_vec(), node.cost)
197 .expect("polygon path contains at least one point"),
198 visited_nodes,
199 ),
200 diagnostics,
201 };
202 };
203
204 if let Err(reason) = watch.check(visited_nodes) {
205 return SearchExecution {
206 result: Err(crate::continuous::budget_error(reason)),
207 diagnostics,
208 };
209 }
210
211 if TRACK {
212 diagnostics.fractures += 1;
213 }
214
215 let children = fracture_node(scene, &node, segment_index, obstacle_index);
216 if TRACK {
217 diagnostics.children_generated += children.len();
218 }
219 for child in children {
220 let child_key = path_key(child.taut_path());
221 if visited.insert(child_key) {
222 if TRACK {
223 diagnostics.children_admitted += 1;
224 diagnostics.max_anchors = diagnostics.max_anchors.max(child.anchors.len());
225 diagnostics.max_taut_len = diagnostics.max_taut_len.max(child.taut_path.len());
226 }
227 frontier.push(child);
228 }
229 }
230 if TRACK {
231 diagnostics.peak_frontier_len = diagnostics.peak_frontier_len.max(frontier.len());
232 }
233 }
234
235 if TRACK {
236 diagnostics.visited_nodes = visited_nodes;
237 diagnostics.memory_proxy = diagnostics.peak_frontier_len + visited.len();
238 }
239
240 SearchExecution {
241 result: crate::continuous::not_found(visited_nodes),
242 diagnostics,
243 }
244}
245
246#[derive(Debug, Clone, PartialEq)]
247struct FractureNode {
248 anchors: Vec<Point2>,
249 taut_path: Vec<Point2>,
250 cost: f64,
251}
252
253impl FractureNode {
254 fn new(scene: &PolygonScene, anchors: Vec<Point2>) -> Self {
255 let taut_path = tauten_path(scene, &anchors);
256 let cost = polyline_length(&taut_path);
257 Self {
258 anchors,
259 taut_path,
260 cost,
261 }
262 }
263
264 fn taut_path(&self) -> &[Point2] {
265 &self.taut_path
266 }
267}
268
269impl Eq for FractureNode {}
270
271impl Ord for FractureNode {
272 fn cmp(&self, other: &Self) -> Ordering {
273 other
274 .cost
275 .total_cmp(&self.cost)
276 .then_with(|| other.anchors.len().cmp(&self.anchors.len()))
277 }
278}
279
280impl PartialOrd for FractureNode {
281 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
282 Some(self.cmp(other))
283 }
284}
285
286fn fracture_node(
287 scene: &PolygonScene,
288 node: &FractureNode,
289 segment_index: usize,
290 obstacle_index: usize,
291) -> Vec<FractureNode> {
292 let start = node.taut_path()[segment_index];
293 let goal = node.taut_path()[segment_index + 1];
294 let obstacle = &scene.obstacles[obstacle_index];
295 let vertices = obstacle.vertices();
296 let mut children = Vec::new();
297
298 for start_vertex_index in 0..vertices.len() {
299 let branch_start = vertices[start_vertex_index];
300 if segment_collides_obstacle(scene, start, branch_start, obstacle) {
301 continue;
302 }
303
304 for goal_vertex_index in 0..vertices.len() {
305 let branch_goal = vertices[goal_vertex_index];
306 if segment_collides_obstacle(scene, branch_goal, goal, obstacle) {
307 continue;
308 }
309
310 for chain in boundary_chains(vertices, start_vertex_index, goal_vertex_index) {
311 let mut anchors = Vec::new();
312 anchors.extend_from_slice(&node.taut_path()[..=segment_index]);
313 for point in chain {
314 push_unique_point(&mut anchors, point);
315 }
316 for &point in &node.taut_path()[(segment_index + 1)..] {
317 push_unique_point(&mut anchors, point);
318 }
319
320 children.push(FractureNode::new(scene, anchors));
321 }
322 }
323 }
324
325 children
326}
327
328fn tauten_path(scene: &PolygonScene, anchors: &[Point2]) -> Vec<Point2> {
329 let anchors = dedup_points(anchors);
330 if anchors.len() <= 2 {
331 return anchors;
332 }
333
334 let mut taut_path = Vec::with_capacity(anchors.len());
335 let mut index = 0usize;
336 taut_path.push(anchors[index]);
337
338 while index < anchors.len() - 1 {
339 let mut next = anchors.len() - 1;
340 while next > index + 1 && !scene.segment_is_walkable(anchors[index], anchors[next]) {
341 next -= 1;
342 }
343
344 taut_path.push(anchors[next]);
345 index = next;
346 }
347
348 taut_path
349}
350
351fn first_collision(scene: &PolygonScene, path: &[Point2]) -> Option<(usize, usize)> {
352 for (segment_index, pair) in path.windows(2).enumerate() {
353 if scene.segment_is_walkable(pair[0], pair[1]) {
354 continue;
355 }
356
357 for (obstacle_index, obstacle) in scene.obstacles.iter().enumerate() {
358 if segment_collides_obstacle(scene, pair[0], pair[1], obstacle) {
359 return Some((segment_index, obstacle_index));
360 }
361 }
362 }
363
364 None
365}
366
367fn boundary_chains(
368 vertices: &[Point2],
369 start_vertex_index: usize,
370 goal_vertex_index: usize,
371) -> [Vec<Point2>; 2] {
372 [
373 walk_chain(vertices, start_vertex_index, goal_vertex_index, true),
374 walk_chain(vertices, start_vertex_index, goal_vertex_index, false),
375 ]
376}
377
378fn walk_chain(
379 vertices: &[Point2],
380 start_vertex_index: usize,
381 goal_vertex_index: usize,
382 forward: bool,
383) -> Vec<Point2> {
384 let mut index = start_vertex_index;
385 let mut chain = vec![vertices[index]];
386
387 while index != goal_vertex_index {
388 index = if forward {
389 (index + 1) % vertices.len()
390 } else if index == 0 {
391 vertices.len() - 1
392 } else {
393 index - 1
394 };
395 chain.push(vertices[index]);
396 }
397
398 chain
399}
400
401fn segment_collides_obstacle(
402 scene: &PolygonScene,
403 start: Point2,
404 end: Point2,
405 obstacle: &Polygon,
406) -> bool {
407 let mut parameters = vec![0.0, 1.0];
408 for (edge_start, edge_end) in polygon_edges(obstacle.vertices()) {
409 parameters.extend(segment_intersection_parameters(
410 start, end, edge_start, edge_end,
411 ));
412 }
413
414 sort_and_dedup_parameters(&mut parameters);
415
416 for parameter in ¶meters {
417 let point = interpolate_segment(start, end, *parameter);
418 if obstacle.contains_point_strict(point)
419 || point_on_sealed_obstacle_boundary(scene.world_bounds, obstacle, point)
420 {
421 return true;
422 }
423 }
424
425 for interval in parameters.windows(2) {
426 if interval[1] - interval[0] <= EPSILON {
427 continue;
428 }
429
430 let midpoint = interpolate_segment(start, end, (interval[0] + interval[1]) / 2.0);
431 if obstacle.contains_point_strict(midpoint)
432 || point_on_sealed_obstacle_boundary(scene.world_bounds, obstacle, midpoint)
433 {
434 return true;
435 }
436 }
437
438 false
439}
440
441fn point_on_sealed_obstacle_boundary(
442 world_bounds: WorldBounds,
443 obstacle: &Polygon,
444 point: Point2,
445) -> bool {
446 polygon_edges(obstacle.vertices()).any(|(start, end)| {
447 point_on_segment(point, start, end) && edge_lies_on_world_boundary(start, end, world_bounds)
448 })
449}
450
451fn polyline_length(path: &[Point2]) -> f64 {
452 path.windows(2)
453 .map(|pair| pair[0].distance_to(pair[1]))
454 .sum()
455}
456
457fn dedup_points(points: &[Point2]) -> Vec<Point2> {
458 let mut deduped = Vec::with_capacity(points.len());
459 for &point in points {
460 push_unique_point(&mut deduped, point);
461 }
462 deduped
463}
464
465fn push_unique_point(points: &mut Vec<Point2>, point: Point2) {
466 if points.last().is_none_or(|last| !points_equal(*last, point)) {
467 points.push(point);
468 }
469}
470
471fn path_key(path: &[Point2]) -> Vec<(u64, u64)> {
472 path.iter()
473 .map(|point| (point.x.to_bits(), point.y.to_bits()))
474 .collect()
475}
476
477fn polygon_edges(vertices: &[Point2]) -> impl Iterator<Item = (Point2, Point2)> + '_ {
478 vertices
479 .iter()
480 .copied()
481 .zip(vertices.iter().copied().cycle().skip(1))
482 .take(vertices.len())
483}
484
485fn sort_and_dedup_parameters(parameters: &mut Vec<f64>) {
486 parameters.sort_by(f64::total_cmp);
487 parameters.dedup_by(|left, right| (*left - *right).abs() <= EPSILON);
488}
489
490fn interpolate_segment(start: Point2, end: Point2, parameter: f64) -> Point2 {
491 Point2::new(
492 start.x + ((end.x - start.x) * parameter),
493 start.y + ((end.y - start.y) * parameter),
494 )
495}
496
497fn segment_intersection_parameters(
498 a_start: Point2,
499 a_end: Point2,
500 b_start: Point2,
501 b_end: Point2,
502) -> Vec<f64> {
503 let mut parameters = Vec::with_capacity(2);
504 for point in [a_start, a_end, b_start, b_end] {
505 if point_on_segment(point, a_start, a_end) && point_on_segment(point, b_start, b_end) {
506 parameters.push(segment_parameter(point, a_start, a_end));
507 }
508 }
509
510 if !parameters.is_empty() {
511 sort_and_dedup_parameters(&mut parameters);
512 return parameters;
513 }
514
515 if let Some(parameter) = proper_intersection_parameter(a_start, a_end, b_start, b_end) {
516 parameters.push(parameter);
517 }
518
519 parameters
520}
521
522fn segment_parameter(point: Point2, start: Point2, end: Point2) -> f64 {
523 let dx = end.x - start.x;
524 let dy = end.y - start.y;
525 if dx.abs() >= dy.abs() && dx.abs() > EPSILON {
526 ((point.x - start.x) / dx).clamp(0.0, 1.0)
527 } else if dy.abs() > EPSILON {
528 ((point.y - start.y) / dy).clamp(0.0, 1.0)
529 } else {
530 0.0
531 }
532}
533
534fn proper_intersection_parameter(
535 a_start: Point2,
536 a_end: Point2,
537 b_start: Point2,
538 b_end: Point2,
539) -> Option<f64> {
540 let o1 = orientation(a_start, a_end, b_start);
541 let o2 = orientation(a_start, a_end, b_end);
542 let o3 = orientation(b_start, b_end, a_start);
543 let o4 = orientation(b_start, b_end, a_end);
544
545 let properly_crosses = (o1 > EPSILON && o2 < -EPSILON || o1 < -EPSILON && o2 > EPSILON)
546 && (o3 > EPSILON && o4 < -EPSILON || o3 < -EPSILON && o4 > EPSILON);
547 if !properly_crosses {
548 return None;
549 }
550
551 let a_dx = a_end.x - a_start.x;
552 let a_dy = a_end.y - a_start.y;
553 let b_dx = b_end.x - b_start.x;
554 let b_dy = b_end.y - b_start.y;
555 let denominator = cross(a_dx, a_dy, b_dx, b_dy);
556 if denominator.abs() <= EPSILON {
557 return None;
558 }
559
560 let offset_x = b_start.x - a_start.x;
561 let offset_y = b_start.y - a_start.y;
562 Some((cross(offset_x, offset_y, b_dx, b_dy) / denominator).clamp(0.0, 1.0))
563}
564
565fn edge_lies_on_world_boundary(start: Point2, end: Point2, bounds: WorldBounds) -> bool {
566 ((start.x - bounds.min.x).abs() <= EPSILON && (end.x - bounds.min.x).abs() <= EPSILON)
567 || ((start.x - bounds.max.x).abs() <= EPSILON && (end.x - bounds.max.x).abs() <= EPSILON)
568 || ((start.y - bounds.min.y).abs() <= EPSILON && (end.y - bounds.min.y).abs() <= EPSILON)
569 || ((start.y - bounds.max.y).abs() <= EPSILON && (end.y - bounds.max.y).abs() <= EPSILON)
570}
571
572fn point_on_segment(point: Point2, start: Point2, end: Point2) -> bool {
573 let cross =
574 ((point.y - start.y) * (end.x - start.x)) - ((point.x - start.x) * (end.y - start.y));
575 if cross.abs() > EPSILON {
576 return false;
577 }
578
579 let dot = ((point.x - start.x) * (end.x - start.x)) + ((point.y - start.y) * (end.y - start.y));
580 if dot < -EPSILON {
581 return false;
582 }
583
584 let length_sq =
585 ((end.x - start.x) * (end.x - start.x)) + ((end.y - start.y) * (end.y - start.y));
586 dot <= length_sq + EPSILON
587}
588
589fn orientation(start: Point2, end: Point2, point: Point2) -> f64 {
590 ((end.x - start.x) * (point.y - start.y)) - ((end.y - start.y) * (point.x - start.x))
591}
592
593fn cross(ax: f64, ay: f64, bx: f64, by: f64) -> f64 {
594 (ax * by) - (ay * bx)
595}
596
597fn points_equal(left: Point2, right: Point2) -> bool {
598 (left.x - right.x).abs() <= EPSILON && (left.y - right.y).abs() <= EPSILON
599}
600
601#[cfg(test)]
602mod tests {
603 use super::TopologicalFractureSearch;
604 use crate::continuous::PolygonPathfinder;
605 use crate::polygonal::{Point2, Polygon, PolygonScene, PolygonSearchRequest, WorldBounds};
606
607 #[test]
608 fn tfs_finds_direct_path_in_open_space() {
609 let scene = PolygonScene {
610 world_bounds: WorldBounds::new(Point2::new(0.0, 0.0), Point2::new(10.0, 10.0)),
611 obstacles: Vec::new(),
612 };
613 let request = PolygonSearchRequest::new(Point2::new(1.0, 1.0), Point2::new(9.0, 1.0));
614
615 let result = TopologicalFractureSearch.search(&scene, request);
616
617 assert!(result.as_ref().expect("valid search request").is_found());
618 let path = result
619 .as_ref()
620 .expect("valid search request")
621 .path()
622 .expect("path should be present");
623 assert_eq!(path.points(), &[request.start, request.goal]);
624 assert!((path.cost() - 8.0).abs() <= 1e-9);
625 }
626
627 #[test]
628 fn tfs_reports_no_path_for_separator() {
629 let scene = PolygonScene {
630 world_bounds: WorldBounds::new(Point2::new(0.0, 0.0), Point2::new(10.0, 10.0)),
631 obstacles: vec![Polygon::new(vec![
632 Point2::new(4.0, 0.0),
633 Point2::new(6.0, 0.0),
634 Point2::new(6.0, 10.0),
635 Point2::new(4.0, 10.0),
636 ])],
637 };
638 let request = PolygonSearchRequest::new(Point2::new(2.0, 5.0), Point2::new(8.0, 5.0));
639
640 let result = TopologicalFractureSearch.search(&scene, request);
641
642 assert!(!result.as_ref().expect("valid search request").is_found());
643 assert!(
644 result
645 .as_ref()
646 .expect("valid search request")
647 .path()
648 .is_none()
649 );
650 assert_eq!(result.as_ref().expect("valid search request").cost(), None);
651 }
652
653 #[test]
654 fn inspect_reports_zero_fractures_on_open_space() {
655 let scene = PolygonScene {
656 world_bounds: WorldBounds::new(Point2::new(0.0, 0.0), Point2::new(10.0, 10.0)),
657 obstacles: Vec::new(),
658 };
659 let request = PolygonSearchRequest::new(Point2::new(1.0, 1.0), Point2::new(9.0, 1.0));
660 let inspection = TopologicalFractureSearch.inspect(&scene, request);
661 assert!(
662 inspection
663 .result
664 .as_ref()
665 .expect("valid search request")
666 .is_found()
667 );
668 assert_eq!(inspection.diagnostics.fractures, 0);
669 assert_eq!(inspection.diagnostics.children_generated, 0);
670 assert_eq!(inspection.diagnostics.visited_nodes, 1);
671 }
672
673 #[test]
674 fn inspect_reports_fractures_on_blocked_rectangle() {
675 let scene = PolygonScene {
676 world_bounds: WorldBounds::new(Point2::new(0.0, 0.0), Point2::new(10.0, 10.0)),
677 obstacles: vec![Polygon::new(vec![
678 Point2::new(4.0, 3.0),
679 Point2::new(6.0, 3.0),
680 Point2::new(6.0, 7.0),
681 Point2::new(4.0, 7.0),
682 ])],
683 };
684 let request = PolygonSearchRequest::new(Point2::new(1.0, 5.0), Point2::new(9.0, 5.0));
685 let inspection = TopologicalFractureSearch.inspect(&scene, request);
686 assert!(
687 inspection
688 .result
689 .as_ref()
690 .expect("valid search request")
691 .is_found()
692 );
693 assert!(inspection.diagnostics.fractures > 0);
694 assert!(inspection.diagnostics.children_generated > 0);
695 assert!(inspection.diagnostics.children_admitted > 0);
696 assert!(inspection.diagnostics.memory_proxy > 0);
697 }
698}