Skip to main content

condor_geometry/
topological_fracture_search.rs

1//! Exact polygonal pathfinder using topological fracture search (TFS).
2//!
3//! # Surface
4//!
5//! Online [`crate::continuous::PolygonPathfinder`] alternative to the visibility
6//! graph: no all-pairs edge materialization. Expands a best-first frontier of
7//! **taut** free-space polylines:
8//!
9//! 1. Start from the straight start→goal candidate (tautened against free space).
10//! 2. When the first obstructed segment collides with an obstacle, **fracture**
11//!    that segment around the obstacle boundary by splicing vertex chains.
12//! 3. Deduplicate taut path keys, expand cheapest incomplete path first, and
13//!    stop when a fully walkable taut path is dequeued.
14//!
15//! # Cost and behavior
16//!
17//! **Cost model**: Euclidean length of the taut polyline. **Exactness**: same
18//! free-space contract as [`crate::visibility_graph::VisibilityGraph`]. Often
19//! cheaper on sparse pillar forests; denser all-pairs visibility graphs can
20//! still win on many pack scenes.
21//!
22//! Use [`TopologicalFractureSearch::inspect`] for expansion counters
23//! (keep/revert); plain [`PolygonPathfinder::search`] stays lean.
24
25use 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/// Online exact continuous pathfinder via topological fracture search.
34///
35/// Stateless solver unit: each `search` / `inspect` owns its frontier. Invalid
36/// endpoints use the same typed errors as other polygon pathfinders; no-path
37/// after frontier exhaustion means no free-space route under the geometry model.
38#[derive(Debug, Clone, Copy, Default)]
39pub struct TopologicalFractureSearch;
40
41/// Expansion counters collected only by [`TopologicalFractureSearch::inspect`].
42///
43/// Use for relative keep/revert comparisons on the same pack; not wall-clock
44/// latency. `memory_proxy` is a node-count proxy, not heap byte size:
45/// `peak_frontier_len + visited_path_keys`.
46#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
47pub struct TfsDiagnostics {
48    /// Nodes dequeued from the best-first frontier (including the solution node).
49    pub visited_nodes: usize,
50    /// Times an obstructed taut path was fractured against an obstacle.
51    pub fractures: usize,
52    /// Child candidates produced by fracture (before visited-set filtering).
53    pub children_generated: usize,
54    /// Children inserted into the frontier (new taut path keys only).
55    pub children_admitted: usize,
56    /// Peak anchor-chain length observed on any node.
57    pub max_anchors: usize,
58    /// Peak taut polyline vertex count observed on any node.
59    pub max_taut_len: usize,
60    /// Peak binary-heap size during search.
61    pub peak_frontier_len: usize,
62    /// `peak_frontier_len + |visited path keys|` for relative memory ranking.
63    pub memory_proxy: usize,
64}
65
66/// Instrumented TFS outcome: pathfinder result plus expansion diagnostics.
67#[derive(Debug, Clone, PartialEq)]
68pub struct TfsInspection {
69    /// Same found / no-path / invalid-endpoint shape as [`PolygonPathfinder::search`].
70    pub result: PolygonSearchResult,
71    /// Counters for the run; zeroed on early invalid-endpoint returns.
72    pub diagnostics: TfsDiagnostics,
73}
74
75impl TopologicalFractureSearch {
76    /// Search with instrumentation for stress-pack keep/revert analysis.
77    ///
78    /// Invalid endpoints are reported through [`TfsInspection::result`] with the
79    /// same errors as [`PolygonPathfinder::search`]. Diagnostics remain default
80    /// when search aborts before expansion.
81    #[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    /// Exact free-space path via taut-path fracture expansion (no diagnostics).
113    ///
114    /// Prefer [`Self::inspect`] when expansion counters are needed for keep/revert.
115    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 &parameters {
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}