axiolid_field_ops/
morphology.rs

1//! Planar masks and metric morphology over a layered field.
2//!
3//! Morphology here is a geometric set operation on a 2D support mask. The
4//! caller chooses which channel forms the mask and what radius to inflate by;
5//! this module attaches no meaning such as "obstacle", "walkable", or "clear". NOT-A-VERDICT
6
7use axiolid_core::Scalar;
8
9use crate::{FieldConfig, LayeredField, LayeredFieldError};
10
11/// Which channel of a cell contributes to a planar mask.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum FieldChannel {
14    /// A cell is set when it holds at least one surface crossing.
15    SurfacePresence,
16    /// A cell is set when it holds at least one occupancy span.
17    OccupancyPresence,
18}
19
20/// A dense row-major boolean mask aligned with a field's cell grid.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct PlanarMask {
23    width: usize,
24    height: usize,
25    bits: Vec<bool>,
26}
27
28impl PlanarMask {
29    /// Construct an all-false mask.
30    pub fn empty(width: usize, height: usize) -> Result<Self, LayeredFieldError> {
31        let count = width
32            .checked_mul(height)
33            .ok_or(LayeredFieldError::InvalidDimensions)?;
34        if count == 0 {
35            return Err(LayeredFieldError::InvalidDimensions);
36        }
37        Ok(Self {
38            width,
39            height,
40            bits: vec![false; count],
41        })
42    }
43
44    /// Derive a mask from one channel of a field.
45    pub fn from_field(field: &LayeredField, channel: FieldChannel) -> Self {
46        let (width, height) = field.dimensions();
47        let bits = field
48            .cells()
49            .iter()
50            .map(|cell| match channel {
51                FieldChannel::SurfacePresence => !cell.surfaces().is_empty(),
52                FieldChannel::OccupancyPresence => !cell.occupancy().is_empty(),
53            })
54            .collect();
55        Self {
56            width,
57            height,
58            bits,
59        }
60    }
61
62    /// Mask dimensions.
63    pub const fn dimensions(&self) -> (usize, usize) {
64        (self.width, self.height)
65    }
66
67    /// Number of set cells.
68    pub fn count(&self) -> usize {
69        self.bits.iter().filter(|bit| **bit).count()
70    }
71
72    /// Read a cell, or `None` when outside the mask.
73    pub fn get(&self, x: usize, y: usize) -> Option<bool> {
74        self.index(x, y).map(|index| self.bits[index])
75    }
76
77    /// Write a cell.
78    pub fn set(&mut self, x: usize, y: usize, value: bool) -> Result<(), LayeredFieldError> {
79        let index = self
80            .index(x, y)
81            .ok_or(LayeredFieldError::NodeOutsideField)?;
82        self.bits[index] = value;
83        Ok(())
84    }
85
86    /// Set-complement.
87    pub fn inverted(&self) -> Self {
88        Self {
89            width: self.width,
90            height: self.height,
91            bits: self.bits.iter().map(|bit| !bit).collect(),
92        }
93    }
94
95    /// Set-intersection with an identically sized mask.
96    pub fn intersect(&self, other: &Self) -> Result<Self, LayeredFieldError> {
97        if self.width != other.width || self.height != other.height {
98            return Err(LayeredFieldError::DimensionMismatch);
99        }
100        Ok(Self {
101            width: self.width,
102            height: self.height,
103            bits: self
104                .bits
105                .iter()
106                .zip(&other.bits)
107                .map(|(left, right)| *left && *right)
108                .collect(),
109        })
110    }
111
112    /// Metric dilation by `radius` in local units.
113    ///
114    /// The radius is converted to a cell count by ceiling division on the
115    /// configured cell size, so the result never under-covers the requested
116    /// distance. A zero radius is the identity.
117    pub fn dilate(&self, config: &FieldConfig, radius: Scalar) -> Result<Self, LayeredFieldError> {
118        self.morph(config, radius, true)
119    }
120
121    /// Metric erosion by `radius` in local units.
122    pub fn erode(&self, config: &FieldConfig, radius: Scalar) -> Result<Self, LayeredFieldError> {
123        self.morph(config, radius, false)
124    }
125
126    fn morph(
127        &self,
128        config: &FieldConfig,
129        radius: Scalar,
130        dilate: bool,
131    ) -> Result<Self, LayeredFieldError> {
132        if !radius.is_finite() || radius < 0.0 {
133            return Err(LayeredFieldError::InvalidEnvelope);
134        }
135        let steps = radius_in_cells(config, radius)?;
136        if steps == 0 {
137            return Ok(self.clone());
138        }
139        let reach = radius / config.cell_size();
140        let reach_squared = reach * reach;
141
142        // The Euclidean structuring element is the same disc for every cell, so
143        // compute its half-width per row ONCE instead of re-testing
144        // `dx*dx + dy*dy` at every cell. Each row `dy` contributes the span
145        // `dx in -half..=half` where `half = floor(sqrt(reach^2 - dy^2))`,
146        // which turns the inner test into a contiguous range walk and drops
147        // the per-cell cost from O(steps^2) to O(steps).
148        //
149        // This is the identical set of offsets the nested test accepted --
150        // `dx*dx <= reach^2 - dy^2` is exactly `|dx| <= sqrt(reach^2 - dy^2)`
151        // for integers -- so the mask produced is bit-identical, not an
152        // approximation of it.
153        let steps_isize = steps as isize;
154        let half_widths: Vec<isize> = (-steps_isize..=steps_isize)
155            .map(|dy| {
156                let remaining = reach_squared - (dy * dy) as Scalar;
157                if remaining < 0.0 {
158                    -1
159                } else {
160                    remaining.sqrt().floor() as isize
161                }
162            })
163            .collect();
164
165        let mut out = Self::empty(self.width, self.height)?;
166        for y in 0..self.height {
167            for x in 0..self.width {
168                let mut value = !dilate;
169                'window: for (index, dy) in (-steps_isize..=steps_isize).enumerate() {
170                    let half = half_widths[index];
171                    if half < 0 {
172                        continue;
173                    }
174                    let ny = y as isize + dy;
175                    // Erosion treats outside-the-field as unset, so a mask
176                    // touching the border erodes inward rather than pretending
177                    // the world continues. A row wholly outside the grid is
178                    // therefore all-unset: it cannot satisfy dilation, and it
179                    // immediately fails erosion.
180                    if ny < 0 || ny as usize >= self.height {
181                        if !dilate {
182                            value = false;
183                            break 'window;
184                        }
185                        continue;
186                    }
187                    let row = ny as usize * self.width;
188                    for dx in -half..=half {
189                        let nx = x as isize + dx;
190                        let neighbor = if nx < 0 || nx as usize >= self.width {
191                            false
192                        } else {
193                            self.bits[row + nx as usize]
194                        };
195                        if dilate && neighbor {
196                            value = true;
197                            break 'window;
198                        }
199                        if !dilate && !neighbor {
200                            value = false;
201                            break 'window;
202                        }
203                    }
204                }
205                out.bits[y * self.width + x] = value;
206            }
207        }
208        Ok(out)
209    }
210
211    /// Label 4-connected components of set cells.
212    ///
213    /// Labels are assigned in row-major discovery order, so the labelling is
214    /// stable across runs.
215    pub fn connected_components(&self) -> ComponentLabels {
216        let mut labels = vec![None; self.bits.len()];
217        let mut next = 0usize;
218        let mut stack = Vec::new();
219        for start in 0..self.bits.len() {
220            if !self.bits[start] || labels[start].is_some() {
221                continue;
222            }
223            let label = next;
224            next += 1;
225            labels[start] = Some(label);
226            stack.push(start);
227            while let Some(index) = stack.pop() {
228                let x = index % self.width;
229                let y = index / self.width;
230                for (nx, ny) in neighbors(x, y, self.width, self.height) {
231                    let neighbor = ny * self.width + nx;
232                    if self.bits[neighbor] && labels[neighbor].is_none() {
233                        labels[neighbor] = Some(label);
234                        stack.push(neighbor);
235                    }
236                }
237            }
238        }
239        ComponentLabels {
240            width: self.width,
241            height: self.height,
242            labels,
243            count: next,
244        }
245    }
246
247    fn index(&self, x: usize, y: usize) -> Option<usize> {
248        (x < self.width && y < self.height).then_some(y * self.width + x)
249    }
250}
251
252/// Component identifiers for every cell of a mask.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct ComponentLabels {
255    width: usize,
256    height: usize,
257    labels: Vec<Option<usize>>,
258    count: usize,
259}
260
261impl ComponentLabels {
262    /// Number of distinct components.
263    pub const fn count(&self) -> usize {
264        self.count
265    }
266
267    /// Component containing `(x, y)`, or `None` for an unset or outside cell.
268    pub fn label(&self, x: usize, y: usize) -> Option<usize> {
269        if x >= self.width || y >= self.height {
270            return None;
271        }
272        self.labels[y * self.width + x]
273    }
274
275    /// Whether two cells share one component.
276    pub fn same_component(&self, from: (usize, usize), to: (usize, usize)) -> bool {
277        match (self.label(from.0, from.1), self.label(to.0, to.1)) {
278            (Some(left), Some(right)) => left == right,
279            _ => false,
280        }
281    }
282}
283
284pub(crate) fn radius_in_cells(
285    config: &FieldConfig,
286    radius: Scalar,
287) -> Result<usize, LayeredFieldError> {
288    if !radius.is_finite() || radius < 0.0 {
289        return Err(LayeredFieldError::InvalidEnvelope);
290    }
291    let steps = (radius / config.cell_size()).ceil();
292    if !steps.is_finite() || steps > usize::MAX as Scalar {
293        return Err(LayeredFieldError::InvalidEnvelope);
294    }
295    Ok(steps as usize)
296}
297
298pub(crate) fn neighbors(
299    x: usize,
300    y: usize,
301    width: usize,
302    height: usize,
303) -> impl Iterator<Item = (usize, usize)> {
304    let mut out = Vec::with_capacity(4);
305    if x > 0 {
306        out.push((x - 1, y));
307    }
308    if y > 0 {
309        out.push((x, y - 1));
310    }
311    if x + 1 < width {
312        out.push((x + 1, y));
313    }
314    if y + 1 < height {
315        out.push((x, y + 1));
316    }
317    out.into_iter()
318}