axiolid_field/
cell.rs

1//! One `(x, y)` column of the field: surface crossings and occupancy spans.
2//!
3//! A triangle is a zero-thickness surface, so triangle coverage produces
4//! [`SurfaceHit`]s. Positive-length [`Interval`]s are only produced by an
5//! explicit occupancy construction over a closed shell. Keeping the two
6//! channels separate stops a single facet from being mistaken for filled space.
7
8use axiolid_core::{Interval, Scalar, Tolerance};
9
10use crate::LayeredFieldError;
11
12/// Orientation of a surface crossing relative to the field's local `z` axis.
13///
14/// This is a geometric fact about winding, not a claim that a facet is a floor,
15/// a ceiling, or anything walkable. NOT-A-VERDICT
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum SurfaceFacing {
18    /// The triangle normal opposes the sampling direction (an entering crossing).
19    AgainstNormal,
20    /// The triangle normal agrees with the sampling direction (an exiting crossing).
21    WithNormal,
22}
23
24/// A zero-thickness crossing of a sampling line at local coordinate `w`.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct SurfaceHit {
27    w: Scalar,
28    facing: SurfaceFacing,
29}
30
31impl SurfaceHit {
32    /// Construct a crossing. `w` is validated when the cell is built.
33    pub const fn new(w: Scalar, facing: SurfaceFacing) -> Self {
34        Self { w, facing }
35    }
36
37    /// Layer coordinate along the field's local `z` axis.
38    pub const fn w(self) -> Scalar {
39        self.w
40    }
41
42    /// Winding orientation of the crossing.
43    pub const fn facing(self) -> SurfaceFacing {
44        self.facing
45    }
46}
47
48/// Ordered surface crossings plus sorted, strictly disjoint occupancy spans.
49///
50/// Both channels may be empty, hold one entry, or hold many; a cell never
51/// selects a "primary" layer.
52#[derive(Debug, Clone, Default, PartialEq)]
53pub struct LayeredCell {
54    surfaces: Vec<SurfaceHit>,
55    occupancy: Vec<Interval>,
56}
57
58impl LayeredCell {
59    /// An empty column.
60    pub const fn empty() -> Self {
61        Self {
62            surfaces: Vec::new(),
63            occupancy: Vec::new(),
64        }
65    }
66
67    /// Construct an occupancy-only column.
68    pub fn new(occupancy: Vec<Interval>) -> Result<Self, LayeredFieldError> {
69        Self::with_layers(Vec::new(), occupancy)
70    }
71
72    /// Construct a validated column with both channels.
73    ///
74    /// Surfaces are sorted by `w`, then by facing so equal-coordinate ties are
75    /// deterministic. Occupancy is sorted and must be strictly disjoint;
76    /// touching or overlapping spans are rejected rather than merged, because a
77    /// merge would silently erase a topology fact the caller may need.
78    pub fn with_layers(
79        mut surfaces: Vec<SurfaceHit>,
80        mut occupancy: Vec<Interval>,
81    ) -> Result<Self, LayeredFieldError> {
82        if surfaces.iter().any(|hit| !hit.w.is_finite()) {
83            return Err(LayeredFieldError::InvalidInterval);
84        }
85        if occupancy
86            .iter()
87            .any(|span| !span.start.is_finite() || !span.end.is_finite() || span.start >= span.end)
88        {
89            return Err(LayeredFieldError::InvalidInterval);
90        }
91        surfaces.sort_by(|left, right| {
92            left.w
93                .total_cmp(&right.w)
94                .then_with(|| left.facing.cmp(&right.facing))
95        });
96        occupancy.sort_by(|left, right| {
97            left.start
98                .total_cmp(&right.start)
99                .then_with(|| left.end.total_cmp(&right.end))
100        });
101        if occupancy
102            .windows(2)
103            .any(|pair| pair[0].end >= pair[1].start)
104        {
105            return Err(LayeredFieldError::NonDisjointIntervals);
106        }
107        Ok(Self {
108            surfaces,
109            occupancy,
110        })
111    }
112
113    /// Crossings in increasing layer order.
114    pub fn surfaces(&self) -> &[SurfaceHit] {
115        &self.surfaces
116    }
117
118    /// Occupied spans in increasing layer order.
119    pub fn occupancy(&self) -> &[Interval] {
120        &self.occupancy
121    }
122
123    /// Total stored layers in both channels.
124    pub fn layer_count(&self) -> usize {
125        self.surfaces.len() + self.occupancy.len()
126    }
127
128    /// Whether both channels are empty.
129    pub fn is_empty(&self) -> bool {
130        self.surfaces.is_empty() && self.occupancy.is_empty()
131    }
132
133    /// Pair alternating crossings into occupancy spans for a closed shell.
134    ///
135    /// The sequence must alternate `AgainstNormal` (enter) then `WithNormal`
136    /// (exit); anything else is reported as [`LayeredFieldError::UnbalancedCrossings`]
137    /// rather than guessed. A pair whose span is within the linear tolerance is
138    /// reported as [`LayeredFieldError::DegenerateOccupancy`].
139    pub fn derive_occupancy(&self, tolerance: Tolerance) -> Result<Self, LayeredFieldError> {
140        if self.surfaces.len() % 2 != 0 {
141            return Err(LayeredFieldError::UnbalancedCrossings);
142        }
143        let mut spans = Vec::with_capacity(self.surfaces.len() / 2);
144        for pair in self.surfaces.chunks_exact(2) {
145            if pair[0].facing != SurfaceFacing::AgainstNormal
146                || pair[1].facing != SurfaceFacing::WithNormal
147            {
148                return Err(LayeredFieldError::UnbalancedCrossings);
149            }
150            if (pair[1].w - pair[0].w).abs() <= tolerance.linear() {
151                return Err(LayeredFieldError::DegenerateOccupancy);
152            }
153            spans.push(Interval::new(pair[0].w, pair[1].w));
154        }
155        Self::with_layers(self.surfaces.clone(), spans)
156    }
157
158    /// Largest unoccupied span strictly inside `search`, or `None` when the
159    /// window is fully occupied.
160    ///
161    /// This reports a distance. It does not decide whether that distance is
162    /// sufficient for any purpose.
163    pub fn largest_free_span(&self, search: Interval) -> Option<Interval> {
164        let (low, high) = if search.start <= search.end {
165            (search.start, search.end)
166        } else {
167            (search.end, search.start)
168        };
169        let mut cursor = low;
170        let mut best: Option<Interval> = None;
171        for span in &self.occupancy {
172            let start = span.start.max(low);
173            let end = span.end.min(high);
174            if start >= end {
175                continue;
176            }
177            if start > cursor {
178                best = keep_longer(best, Interval::new(cursor, start));
179            }
180            cursor = cursor.max(end);
181        }
182        if cursor < high {
183            best = keep_longer(best, Interval::new(cursor, high));
184        }
185        best
186    }
187}
188
189fn keep_longer(best: Option<Interval>, candidate: Interval) -> Option<Interval> {
190    match best {
191        Some(current) if current.length() >= candidate.length() => Some(current),
192        _ => Some(candidate),
193    }
194}