Skip to main content

condor_grid/algorithms/anya/
geometry.rs

1//! Interval geometry for the row-interval Anya candidate: keys, cones, flat spans.
2//!
3//! Independent of public Theta* LOS helpers except where sampling legality is
4//! intentionally shared. Used by [`super::successors`] and inspect-mode search.
5
6use std::cmp::Ordering;
7
8use crate::{
9    Grid,
10    any_angle::geometry::{
11        approximately_equal, canonicalize_grid_vertex, is_endpoint_valid, retain_as_corner,
12        sampling_segment_is_legal, validate_sampling_path,
13    },
14};
15use condor_core::Point2;
16
17/// Whether an interval state projects a flat span or a root cone.
18///
19/// Flat spans cover open runs with a root on the same row; cones project from a
20/// root on an adjacent row through turning geometry.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum IntervalKind {
23    /// Open horizontal span visible from a co-row root (no cone projection).
24    Flat,
25    /// Visibility cone from an off-row root through an interval on another row.
26    Cone,
27}
28
29impl PartialOrd for IntervalKind {
30    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
31        Some(self.cmp(other))
32    }
33}
34
35impl Ord for IntervalKind {
36    fn cmp(&self, other: &Self) -> Ordering {
37        match (self, other) {
38            (Self::Flat, Self::Flat) | (Self::Cone, Self::Cone) => Ordering::Equal,
39            (Self::Flat, Self::Cone) => Ordering::Less,
40            (Self::Cone, Self::Flat) => Ordering::Greater,
41        }
42    }
43}
44
45/// Total order for `f64` keys; non-finite values sort after all finite keys.
46#[must_use]
47pub fn compare_f64_total(left: f64, right: f64) -> Ordering {
48    match (left.is_finite(), right.is_finite()) {
49        (false, false) => Ordering::Equal,
50        (false, true) => Ordering::Greater,
51        (true, false) => Ordering::Less,
52        (true, true) => {
53            if left < right {
54                Ordering::Less
55            } else if left > right {
56                Ordering::Greater
57            } else {
58                Ordering::Equal
59            }
60        }
61    }
62}
63
64/// Admissible lower bound for an interval state.
65#[must_use]
66pub fn interval_state_key(
67    grid: &Grid,
68    root: Point2,
69    root_g: f64,
70    row: f64,
71    left: f64,
72    right: f64,
73    goal: Point2,
74) -> f64 {
75    if !root_g.is_finite() {
76        return f64::INFINITY;
77    }
78    let Some(best_p) = minimizing_legal_point_on_interval(grid, root, row, left, right, goal)
79    else {
80        return f64::INFINITY;
81    };
82    root_g + root.distance_to(best_p) + best_p.distance_to(goal)
83}
84
85/// Best point on `[left,right]` at `row` for a final leg to `goal` after reaching `root`.
86#[must_use]
87pub fn best_interval_goal_candidate(
88    grid: &Grid,
89    root: Point2,
90    root_g: f64,
91    row: f64,
92    left: f64,
93    right: f64,
94    goal: Point2,
95) -> Option<(Point2, f64)> {
96    let best_p = minimizing_legal_point_on_interval(grid, root, row, left, right, goal)?;
97    let cost = root_g + root.distance_to(best_p) + best_p.distance_to(goal);
98    Some((best_p, cost))
99}
100
101/// Maximal open horizontal span from `corner`, clipped to `[clip_left, clip_right]`.
102#[must_use]
103pub fn visible_flat_span_from_corner(
104    grid: &Grid,
105    corner: Point2,
106    clip_left: f64,
107    clip_right: f64,
108) -> Option<(f64, f64)> {
109    let row_y = corner.y;
110    let cx = corner.x.round() as i32;
111    let left_bound = clip_left.round() as i32;
112    let right_bound = clip_right.round() as i32;
113
114    let mut lo = cx;
115    while lo > left_bound {
116        let prev = Point2::new((lo - 1) as f64, row_y);
117        if !segment_legal(grid, corner, prev) {
118            break;
119        }
120        lo -= 1;
121    }
122
123    let mut hi = cx;
124    while hi < right_bound {
125        let next = Point2::new((hi + 1) as f64, row_y);
126        if !segment_legal(grid, corner, next) {
127            break;
128        }
129        hi += 1;
130    }
131
132    let left = (lo as f64).max(clip_left);
133    let right = (hi as f64).min(clip_right);
134    if right + 1e-12 < left {
135        None
136    } else {
137        Some((left, right))
138    }
139}
140
141fn minimizing_legal_point_on_interval(
142    grid: &Grid,
143    root: Point2,
144    row: f64,
145    left: f64,
146    right: f64,
147    goal: Point2,
148) -> Option<Point2> {
149    let mut best: Option<Point2> = None;
150    let mut best_cost = f64::INFINITY;
151
152    let mut consider = |candidate: Point2| {
153        if candidate.x + 1e-12 < left || candidate.x > right + 1e-12 {
154            return;
155        }
156        if !segment_legal(grid, root, candidate) {
157            return;
158        }
159        let cost = point_goal_cost(root, candidate, goal);
160        if cost < best_cost {
161            best = Some(candidate);
162            best_cost = cost;
163        }
164    };
165
166    consider(Point2::new(left, row));
167    consider(Point2::new(right, row));
168
169    // The reflected construction degenerates when either endpoint lies on the
170    // interval row.  Retain the projected endpoint positions explicitly: in
171    // particular, a state whose root and goal share a row may reach the goal
172    // directly, while the interval endpoints alone can be arbitrarily worse.
173    if approximately_equal(root.y, row) {
174        consider(Point2::new(root.x.clamp(left, right), row));
175    }
176    if approximately_equal(goal.y, row) {
177        consider(Point2::new(goal.x.clamp(left, right), row));
178    }
179
180    // Unfold the final leg across this horizontal row.  The stationary point of
181    // |root-p| + |p-goal| is where `root -> reflect(goal, row)` crosses the
182    // row, not where the unreflected root-goal segment happens to cross it.
183    // The latter can overestimate the interval key and invalidate A*'s early
184    // termination condition.
185    let reflected_goal_y = 2.0 * row - goal.y;
186    let dy = reflected_goal_y - root.y;
187    if dy.abs() > 1e-15 {
188        let t = (row - root.y) / dy;
189        let x = root.x + t * (goal.x - root.x);
190        if x.is_finite() {
191            consider(Point2::new(x.clamp(left, right), row));
192        }
193    }
194
195    best
196}
197
198fn point_goal_cost(root: Point2, point: Point2, goal: Point2) -> f64 {
199    root.distance_to(point) + point.distance_to(goal)
200}
201
202/// Projects the visibility cone from `root` through `[left,right]` on `row` onto `target_row`.
203///
204/// When `root` lies on `row`, interval endpoint rays can be horizontal (degenerate for
205/// vertical projection). Unconstrained sides use infinities and are clipped later against
206/// open runs.
207#[must_use]
208pub fn project_cone_to_row(
209    root: Point2,
210    row: f64,
211    left: f64,
212    right: f64,
213    target_row: f64,
214) -> Option<(f64, f64)> {
215    if approximately_equal(row, target_row) {
216        return Some((left, right));
217    }
218
219    let x0 = cone_bound_at_row(root, row, left, target_row);
220    let x1 = cone_bound_at_row(root, row, right, target_row);
221    let lo = x0.min(x1);
222    let hi = x0.max(x1);
223    if hi + 1e-12 >= lo {
224        Some((lo, hi))
225    } else {
226        None
227    }
228}
229
230fn cone_bound_at_row(root: Point2, interval_row: f64, endpoint_x: f64, target_row: f64) -> f64 {
231    if approximately_equal(interval_row, root.y) && approximately_equal(endpoint_x, root.x) {
232        return root.x;
233    }
234
235    let endpoint = Point2::new(endpoint_x, interval_row);
236    if let Some(x) = ray_intersect_row(root, endpoint, target_row) {
237        return x;
238    }
239
240    // Endpoint shares the root row but differs in x: horizontal ray does not bound vertical reach.
241    if approximately_equal(interval_row, root.y) {
242        if target_row > root.y {
243            if endpoint_x + 1e-12 < root.x {
244                return f64::NEG_INFINITY;
245            }
246            if endpoint_x > root.x + 1e-12 {
247                return f64::INFINITY;
248            }
249        } else if target_row < root.y {
250            if endpoint_x + 1e-12 < root.x {
251                return f64::INFINITY;
252            }
253            if endpoint_x > root.x + 1e-12 {
254                return f64::NEG_INFINITY;
255            }
256        }
257    }
258
259    root.x
260}
261
262fn ray_intersect_row(origin: Point2, through: Point2, row_y: f64) -> Option<f64> {
263    let dy = through.y - origin.y;
264    if dy.abs() <= 1e-15 {
265        return None;
266    }
267    let t = (row_y - origin.y) / dy;
268    if t < -1e-12 {
269        return None;
270    }
271    Some(origin.x + t * (through.x - origin.x))
272}
273
274/// Whether the open segment from `start` to `end` is legal under Anya sampling LOS.
275#[must_use]
276pub fn segment_legal(grid: &Grid, start: Point2, end: Point2) -> bool {
277    sampling_segment_is_legal(grid, start, end)
278}
279
280/// Whether every consecutive polyline segment is legal under Anya sampling LOS.
281#[must_use]
282pub fn validate_path(grid: &Grid, points: &[Point2]) -> bool {
283    validate_sampling_path(grid, points)
284}
285
286/// Canonicalize and validate request endpoints.
287pub fn parse_request(
288    grid: &Grid,
289    start: Point2,
290    goal: Point2,
291) -> Result<(Point2, Point2), crate::AnyAngleSearchError> {
292    let Some(start) = canonicalize_grid_vertex(start) else {
293        return Err(crate::AnyAngleSearchError::InvalidStart { point: start });
294    };
295    let Some(goal) = canonicalize_grid_vertex(goal) else {
296        return Err(crate::AnyAngleSearchError::InvalidGoal { point: goal });
297    };
298    if !is_endpoint_valid(grid, start) {
299        return Err(crate::AnyAngleSearchError::InvalidStart { point: start });
300    }
301    if !is_endpoint_valid(grid, goal) {
302        return Err(crate::AnyAngleSearchError::InvalidGoal { point: goal });
303    }
304    Ok((start, goal))
305}
306
307/// Whether a grid vertex can anchor a turning successor.
308///
309/// Retains every mixed-mask vertex (including pinch-adjacent corners). Legality
310/// is enforced separately via segment checks.
311#[must_use]
312pub fn is_turn_candidate(grid: &Grid, vx: i32, vy: i32) -> bool {
313    retain_as_corner(grid, vx, vy)
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::{Grid, grid::Cell, point::Point};
320    use condor_core::Point2;
321
322    #[test]
323    fn project_cone_from_root_on_row_reaches_adjacent_rows() {
324        let root = Point2::new(0.0, 0.0);
325        let projected = project_cone_to_row(root, 0.0, 0.0, 63.0, 1.0).expect("projection");
326        assert!(projected.0 <= 0.0 + 1e-9);
327        assert!(projected.1 >= 63.0 - 1e-9);
328    }
329
330    #[test]
331    fn checkerboard_direct_diagonal_is_illegal() {
332        let mut grid = Grid::new(5, 5).expect("grid");
333        for (x, y) in [
334            (1, 0),
335            (3, 0),
336            (0, 1),
337            (2, 1),
338            (4, 1),
339            (1, 2),
340            (3, 2),
341            (0, 3),
342            (2, 3),
343            (4, 3),
344            (1, 4),
345            (3, 4),
346        ] {
347            grid.set_cell(Point::new(x, y), Cell::Blocked)
348                .expect("block");
349        }
350        assert!(!segment_legal(
351            &grid,
352            Point2::new(0.0, 0.0),
353            Point2::new(4.0, 4.0)
354        ));
355    }
356
357    #[test]
358    fn staircase_concave_witness_segments_are_legal() {
359        let mut grid = Grid::new(6, 4).expect("grid");
360        for (x, y) in [
361            (1, 0),
362            (2, 0),
363            (2, 1),
364            (3, 1),
365            (4, 1),
366            (4, 2),
367            (5, 2),
368            (5, 3),
369        ] {
370            grid.set_cell(Point::new(x, y), Cell::Blocked)
371                .expect("block");
372        }
373        let pts = [
374            Point2::new(0.0, 3.0),
375            Point2::new(2.0, 1.0),
376            Point2::new(3.0, 1.0),
377            Point2::new(5.0, 0.0),
378        ];
379        for pair in pts.windows(2) {
380            assert!(
381                segment_legal(&grid, pair[0], pair[1]),
382                "{:?} -> {:?}",
383                pair[0],
384                pair[1]
385            );
386        }
387        assert!(is_turn_candidate(&grid, 2, 1));
388        assert!(is_turn_candidate(&grid, 3, 1));
389        assert!(segment_legal(
390            &grid,
391            Point2::new(0.0, 3.0),
392            Point2::new(2.0, 1.0)
393        ));
394    }
395
396    #[test]
397    fn forbidden_pinch_corner_geometry() {
398        let mut grid = Grid::new(4, 4).expect("grid");
399        grid.set_cell(Point::new(1, 1), Cell::Blocked)
400            .expect("block");
401        grid.set_cell(Point::new(2, 2), Cell::Blocked)
402            .expect("block");
403
404        assert!(segment_legal(
405            &grid,
406            Point2::new(0.0, 0.0),
407            Point2::new(1.0, 2.0)
408        ));
409        assert!(segment_legal(
410            &grid,
411            Point2::new(1.0, 2.0),
412            Point2::new(2.0, 3.0)
413        ));
414        assert!(is_turn_candidate(&grid, 1, 2));
415
416        let key = interval_state_key(
417            &grid,
418            Point2::new(1.0, 2.0),
419            2.0_f64.sqrt(),
420            2.0,
421            1.0,
422            3.0,
423            Point2::new(3.0, 3.0),
424        );
425        assert!(
426            key < 5.0,
427            "turn key should beat suboptimal detour, got {key}"
428        );
429    }
430}