axiolid_field_ops/
navigate.rs

1//! Geometry-only traversal primitives over a layered field.
2//!
3//! # Scope
4//!
5//! This module answers geometric questions: does a route exist under an
6//! explicit envelope, how long is it, and which geometric constraint blocked
7//! it. It must never answer regulatory or product questions. Axiolid may say
8//! "no route under this envelope"; it may not say "not wheelchair accessible", NOT-A-VERDICT
9//! "ADA compliant", "valid escape route", or "rule violation". Those are NOT-A-VERDICT
10//! consumer-owned interpretations of the numbers reported here.
11//!
12//! # Promotion status
13//!
14//! This is behind the non-default `navigation` feature. The house rule is that
15//! a shared contract is promoted once at least two consumers need the same
16//! neutral shape; until a second consumer exists, this stays opt-in so it can
17//! change without breaking the default surface.
18
19use std::collections::BinaryHeap;
20
21use axiolid_core::Scalar;
22
23use crate::{
24    morphology::radius_in_cells, FieldChannel, FieldConfig, LayeredField, LayeredFieldError,
25    PlanarMask,
26};
27
28/// Explicit geometric envelope for traversal. Every field is caller-supplied.
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct TraversalEnvelope {
31    /// Lateral half-width kept free of blocking geometry, in local units.
32    pub agent_radius: Scalar,
33    /// Free span required above a support layer, in local units.
34    pub agent_height: Scalar,
35    /// Largest abrupt layer change accepted between adjacent cells.
36    pub max_step: Scalar,
37    /// Largest accepted rise over run between adjacent cells.
38    pub max_slope: Scalar,
39}
40
41impl TraversalEnvelope {
42    /// Validate the envelope. All values must be finite and non-negative.
43    pub fn validate(&self) -> Result<(), LayeredFieldError> {
44        let values = [
45            self.agent_radius,
46            self.agent_height,
47            self.max_step,
48            self.max_slope,
49        ];
50        if values.iter().any(|v| !v.is_finite() || *v < 0.0) {
51            return Err(LayeredFieldError::InvalidEnvelope);
52        }
53        Ok(())
54    }
55}
56
57/// A candidate support location: a cell plus the layer standing on it.
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct SupportNode {
60    /// Cell column.
61    pub x: usize,
62    /// Cell row.
63    pub y: usize,
64    /// Layer coordinate of the supporting crossing.
65    pub w: Scalar,
66}
67
68/// Structured geometric facts about a graph build and route query.
69#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
70#[non_exhaustive]
71pub struct TraversalEvidence {
72    /// Support nodes retained after clearance and inflation filtering.
73    pub nodes: usize,
74    /// Undirected adjacency pairs retained.
75    pub edges: usize,
76    /// Candidate supports dropped for insufficient free span above.
77    pub rejected_by_height: usize,
78    /// Candidate supports dropped by lateral inflation.
79    pub rejected_by_radius: usize,
80    /// Adjacencies dropped because the layer change exceeded `max_step`.
81    pub rejected_by_step: usize,
82    /// Adjacencies dropped because the gradient exceeded `max_slope`.
83    pub rejected_by_slope: usize,
84    /// Connected components in the retained graph.
85    pub components: usize,
86}
87
88/// Result of a route query. Both variants are geometric statements.
89#[derive(Debug, Clone, PartialEq)]
90pub enum RouteOutcome {
91    /// A route exists under the supplied envelope.
92    Route {
93        /// Ordered support nodes from start to goal.
94        nodes: Vec<SupportNode>,
95        /// Accumulated 3D path length in local units.
96        length: Scalar,
97        /// Geometric facts about the search.
98        evidence: TraversalEvidence,
99    },
100    /// No route exists under this envelope. This is not a compliance verdict. NOT-A-VERDICT
101    NoRouteUnderEnvelope {
102        /// Geometric facts explaining what was filtered away.
103        evidence: TraversalEvidence,
104    },
105}
106
107/// Traversal graph derived from a field under one explicit envelope.
108#[derive(Debug, Clone)]
109pub struct TraversalGraph {
110    width: usize,
111    cell_size: Scalar,
112    nodes: Vec<Option<SupportNode>>,
113    adjacency: Vec<Vec<usize>>,
114    evidence: TraversalEvidence,
115}
116
117impl TraversalGraph {
118    /// Build the graph from a sampled field.
119    ///
120    /// Support candidates are the lowest crossing in each cell. A candidate
121    /// survives when the free span above it reaches `agent_height` and it is
122    /// still set after the blocking mask is inflated by `agent_radius`.
123    pub fn build(
124        field: &LayeredField,
125        config: &FieldConfig,
126        envelope: &TraversalEnvelope,
127    ) -> Result<Self, LayeredFieldError> {
128        envelope.validate()?;
129        let (width, height) = field.dimensions();
130        let linear = config.tolerance().linear();
131        let mut evidence = TraversalEvidence::default();
132
133        // Lateral inflation: grow the blocking mask, then keep only cells that
134        // remain outside it. Erosion of the free mask would be equivalent; the
135        // inflated form is reported so the caller can inspect the obstacle set.
136        let blocking = PlanarMask::from_field(field, FieldChannel::SurfacePresence).inverted();
137        let inflated = blocking.dilate(config, envelope.agent_radius)?;
138        let reach = radius_in_cells(config, envelope.agent_radius)?;
139
140        let mut nodes: Vec<Option<SupportNode>> = vec![None; width * height];
141        for y in 0..height {
142            for x in 0..width {
143                let cell = field
144                    .cell(x, y)
145                    .ok_or(LayeredFieldError::NodeOutsideField)?;
146                let Some(support) = cell.surfaces().first() else {
147                    continue;
148                };
149                let report = crate::clearance_above(field, config, x, y, support.w())?;
150                if report.distance + linear < envelope.agent_height {
151                    evidence.rejected_by_height += 1;
152                    continue;
153                }
154                if reach > 0 && inflated.get(x, y).unwrap_or(true) {
155                    evidence.rejected_by_radius += 1;
156                    continue;
157                }
158                nodes[y * width + x] = Some(SupportNode {
159                    x,
160                    y,
161                    w: support.w(),
162                });
163            }
164        }
165        evidence.nodes = nodes.iter().filter(|node| node.is_some()).count();
166
167        let run = config.cell_size();
168        let mut adjacency = vec![Vec::new(); nodes.len()];
169        for y in 0..height {
170            for x in 0..width {
171                let index = y * width + x;
172                let Some(from) = nodes[index] else { continue };
173                // Only forward neighbours, so each undirected pair is judged once.
174                for (nx, ny) in [(x + 1, y), (x, y + 1)] {
175                    if nx >= width || ny >= height {
176                        continue;
177                    }
178                    let neighbor = ny * width + nx;
179                    let Some(to) = nodes[neighbor] else { continue };
180                    let rise = (to.w - from.w).abs();
181                    if rise > envelope.max_step + linear {
182                        evidence.rejected_by_step += 1;
183                        continue;
184                    }
185                    if rise / run > envelope.max_slope + linear {
186                        evidence.rejected_by_slope += 1;
187                        continue;
188                    }
189                    adjacency[index].push(neighbor);
190                    adjacency[neighbor].push(index);
191                    evidence.edges += 1;
192                }
193            }
194        }
195
196        let mut graph = Self {
197            width,
198            cell_size: config.cell_size(),
199            nodes,
200            adjacency,
201            evidence,
202        };
203        graph.evidence.components = graph.count_components();
204        Ok(graph)
205    }
206
207    /// Geometric facts about the build.
208    pub const fn evidence(&self) -> TraversalEvidence {
209        self.evidence
210    }
211
212    /// Support node retained at `(x, y)`, if any.
213    pub fn node(&self, x: usize, y: usize) -> Option<SupportNode> {
214        self.nodes.get(y * self.width + x).copied().flatten()
215    }
216
217    /// Whether two cells lie in the same connected component.
218    pub fn connected(&self, from: (usize, usize), to: (usize, usize)) -> bool {
219        let (Some(start), Some(goal)) = (self.index_of(from), self.index_of(to)) else {
220            return false;
221        };
222        self.component_labels()[start]
223            .zip(self.component_labels()[goal])
224            .is_some_and(|(a, b)| a == b)
225    }
226
227    /// Shortest 3D-length route under the envelope used to build this graph.
228    ///
229    /// Ties are broken by the lower row-major node index, so the returned path
230    /// is identical across runs and platforms.
231    pub fn find_route(
232        &self,
233        from: (usize, usize),
234        to: (usize, usize),
235    ) -> Result<RouteOutcome, LayeredFieldError> {
236        let start = self
237            .index_of(from)
238            .ok_or(LayeredFieldError::NodeOutsideField)?;
239        let goal = self
240            .index_of(to)
241            .ok_or(LayeredFieldError::NodeOutsideField)?;
242
243        let mut best = vec![Scalar::INFINITY; self.nodes.len()];
244        let mut previous = vec![usize::MAX; self.nodes.len()];
245        let mut queue = BinaryHeap::new();
246        best[start] = 0.0;
247        queue.push(Candidate {
248            cost: 0.0,
249            index: start,
250        });
251
252        while let Some(Candidate { cost, index }) = queue.pop() {
253            if index == goal {
254                break;
255            }
256            if cost > best[index] {
257                continue;
258            }
259            for neighbor in &self.adjacency[index] {
260                let step = self.edge_length(index, *neighbor);
261                let candidate = cost + step;
262                // Strictly-better relaxation plus index tie-break keeps the
263                // chosen predecessor deterministic for equal-cost paths.
264                let improves = candidate < best[*neighbor]
265                    || (candidate == best[*neighbor] && index < previous[*neighbor]);
266                if improves {
267                    best[*neighbor] = candidate;
268                    previous[*neighbor] = index;
269                    queue.push(Candidate {
270                        cost: candidate,
271                        index: *neighbor,
272                    });
273                }
274            }
275        }
276
277        if !best[goal].is_finite() {
278            return Ok(RouteOutcome::NoRouteUnderEnvelope {
279                evidence: self.evidence,
280            });
281        }
282
283        let mut chain = vec![goal];
284        let mut cursor = goal;
285        while cursor != start {
286            cursor = previous[cursor];
287            if cursor == usize::MAX {
288                return Ok(RouteOutcome::NoRouteUnderEnvelope {
289                    evidence: self.evidence,
290                });
291            }
292            chain.push(cursor);
293        }
294        chain.reverse();
295
296        Ok(RouteOutcome::Route {
297            nodes: chain
298                .iter()
299                .map(|index| self.nodes[*index].expect("route only visits retained nodes"))
300                .collect(),
301            length: best[goal],
302            evidence: self.evidence,
303        })
304    }
305
306    fn edge_length(&self, from: usize, to: usize) -> Scalar {
307        let (Some(a), Some(b)) = (self.nodes[from], self.nodes[to]) else {
308            return Scalar::INFINITY;
309        };
310        let run = self.run_between(a, b);
311        (run * run + (b.w - a.w) * (b.w - a.w)).sqrt()
312    }
313
314    fn run_between(&self, a: SupportNode, b: SupportNode) -> Scalar {
315        let dx = (a.x as Scalar) - (b.x as Scalar);
316        let dy = (a.y as Scalar) - (b.y as Scalar);
317        (dx * dx + dy * dy).sqrt() * self.cell_run()
318    }
319
320    fn cell_run(&self) -> Scalar {
321        self.cell_size
322    }
323
324    fn index_of(&self, cell: (usize, usize)) -> Option<usize> {
325        let index = cell.1 * self.width + cell.0;
326        (cell.0 < self.width && index < self.nodes.len() && self.nodes[index].is_some())
327            .then_some(index)
328    }
329
330    fn component_labels(&self) -> Vec<Option<usize>> {
331        let mut labels = vec![None; self.nodes.len()];
332        let mut next = 0usize;
333        let mut stack = Vec::new();
334        for start in 0..self.nodes.len() {
335            if self.nodes[start].is_none() || labels[start].is_some() {
336                continue;
337            }
338            let label = next;
339            next += 1;
340            labels[start] = Some(label);
341            stack.push(start);
342            while let Some(index) = stack.pop() {
343                for neighbor in &self.adjacency[index] {
344                    if labels[*neighbor].is_none() {
345                        labels[*neighbor] = Some(label);
346                        stack.push(*neighbor);
347                    }
348                }
349            }
350        }
351        labels
352    }
353
354    fn count_components(&self) -> usize {
355        self.component_labels()
356            .iter()
357            .filter_map(|label| *label)
358            .max()
359            .map_or(0, |max| max + 1)
360    }
361}
362
363struct Candidate {
364    cost: Scalar,
365    index: usize,
366}
367
368impl PartialEq for Candidate {
369    fn eq(&self, other: &Self) -> bool {
370        self.cost == other.cost && self.index == other.index
371    }
372}
373
374impl Eq for Candidate {}
375
376impl Ord for Candidate {
377    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
378        // Min-heap on cost; lower index wins ties so pops are deterministic.
379        other
380            .cost
381            .total_cmp(&self.cost)
382            .then_with(|| other.index.cmp(&self.index))
383    }
384}
385
386impl PartialOrd for Candidate {
387    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
388        Some(self.cmp(other))
389    }
390}