axiolid_field/
config.rs

1//! Explicit sampling frame, bounds, cell size, tolerance, and resource budget.
2//!
3//! Nothing here assumes a world axis. The frame's local `z` is the layering
4//! axis; `x` and `y` address cells. A caller that wants world Z-up passes the
5//! identity frame explicitly, and a caller slicing a wall passes its own frame.
6
7use axiolid_core::{Frame3, Interval, Point3, Scalar, SpaceFrame, Tolerance};
8
9use crate::LayeredFieldError;
10
11/// Caller-owned allocation limits. There is no built-in cap: a library must not
12/// decide how much memory an application may spend.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct FieldResourceBudget {
15    /// Maximum number of addressable `(x, y)` cells.
16    pub max_cells: usize,
17    /// Maximum number of stored layers (surface hits plus occupancy intervals).
18    pub max_intervals: usize,
19}
20
21impl FieldResourceBudget {
22    /// Construct an explicit budget.
23    pub const fn new(max_cells: usize, max_intervals: usize) -> Self {
24        Self {
25            max_cells,
26            max_intervals,
27        }
28    }
29}
30
31/// Local-frame sampling box with `min` strictly below `max` on every axis.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct FieldBounds {
34    min: Point3,
35    max: Point3,
36}
37
38impl FieldBounds {
39    /// Validate a non-degenerate finite local box.
40    pub fn new(min: Point3, max: Point3) -> Result<Self, LayeredFieldError> {
41        if !min.is_finite() || !max.is_finite() {
42            return Err(LayeredFieldError::InvalidBounds);
43        }
44        if min.x >= max.x || min.y >= max.y || min.z >= max.z {
45            return Err(LayeredFieldError::InvalidBounds);
46        }
47        Ok(Self { min, max })
48    }
49
50    /// Lower corner in local coordinates.
51    pub const fn min(self) -> Point3 {
52        self.min
53    }
54
55    /// Upper corner in local coordinates.
56    pub const fn max(self) -> Point3 {
57        self.max
58    }
59
60    /// Layering-axis span as an oriented interval.
61    pub fn normal_span(self) -> Interval {
62        Interval::new(self.min.z, self.max.z)
63    }
64}
65
66/// Validated configuration for one layered field.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct FieldConfig {
69    frame: Frame3,
70    bounds: FieldBounds,
71    cell_size: Scalar,
72    tolerance: Tolerance,
73    budget: FieldResourceBudget,
74    width: usize,
75    height: usize,
76}
77
78impl FieldConfig {
79    /// Validate a frame, bounds, cell size, tolerance, and budget together.
80    ///
81    /// The frame must be finite, unit-length on each axis, mutually orthogonal
82    /// within the angular tolerance, and right-handed. Grid dimensions are
83    /// derived deterministically by ceiling division so a partial trailing cell
84    /// is retained rather than silently dropped.
85    pub fn new(
86        frame: Frame3,
87        bounds: FieldBounds,
88        cell_size: Scalar,
89        tolerance: Tolerance,
90        budget: FieldResourceBudget,
91    ) -> Result<Self, LayeredFieldError> {
92        if !cell_size.is_finite() || cell_size <= 0.0 {
93            return Err(LayeredFieldError::InvalidCellSize);
94        }
95        if !frame_is_orthonormal(frame, tolerance) {
96            return Err(LayeredFieldError::InvalidFrame);
97        }
98        let span = bounds.max() - bounds.min();
99        let width = ceil_cells(span.x, cell_size)?;
100        let height = ceil_cells(span.y, cell_size)?;
101        let cells = width
102            .checked_mul(height)
103            .ok_or(LayeredFieldError::CellBudgetExceeded)?;
104        if cells == 0 {
105            return Err(LayeredFieldError::InvalidDimensions);
106        }
107        if cells > budget.max_cells {
108            return Err(LayeredFieldError::CellBudgetExceeded);
109        }
110        Ok(Self {
111            frame,
112            bounds,
113            cell_size,
114            tolerance,
115            budget,
116            width,
117            height,
118        })
119    }
120
121    /// Sampling frame supplied by the caller.
122    pub const fn frame(&self) -> Frame3 {
123        self.frame
124    }
125
126    /// Local sampling box.
127    pub const fn bounds(&self) -> FieldBounds {
128        self.bounds
129    }
130
131    /// Edge length of one square cell in local units.
132    pub const fn cell_size(&self) -> Scalar {
133        self.cell_size
134    }
135
136    /// Explicit tolerance policy for this field.
137    pub const fn tolerance(&self) -> Tolerance {
138        self.tolerance
139    }
140
141    /// Caller-owned budget.
142    pub const fn budget(&self) -> FieldResourceBudget {
143        self.budget
144    }
145
146    /// Deterministic `(width, height)` cell dimensions.
147    pub const fn dimensions(&self) -> (usize, usize) {
148        (self.width, self.height)
149    }
150
151    /// World-space centre of cell `(x, y)` on the `w = 0` local plane.
152    pub fn cell_center(&self, x: usize, y: usize) -> Point3 {
153        let local_x = self.bounds.min().x + (x as Scalar + 0.5) * self.cell_size;
154        let local_y = self.bounds.min().y + (y as Scalar + 0.5) * self.cell_size;
155        self.frame.origin + self.frame.x * local_x + self.frame.y * local_y
156    }
157
158    /// World-space point for a local `(x, y)` cell centre at layer coordinate `w`.
159    pub fn sample_point(&self, x: usize, y: usize, w: Scalar) -> Point3 {
160        self.cell_center(x, y) + self.frame.z * w
161    }
162}
163
164fn ceil_cells(span: Scalar, cell_size: Scalar) -> Result<usize, LayeredFieldError> {
165    let count = (span / cell_size).ceil();
166    if !count.is_finite() || count <= 0.0 || count > usize::MAX as Scalar {
167        return Err(LayeredFieldError::CellBudgetExceeded);
168    }
169    Ok(count as usize)
170}
171
172/// Whether a frame is a valid orthonormal right-handed basis.
173///
174/// Delegates to the core `SpaceFrame`, which owns the single definition of
175/// frame validity. Keeping a second copy here let this crate and surface
176/// evaluation disagree about what a valid frame was.
177fn frame_is_orthonormal(frame: Frame3, tolerance: Tolerance) -> bool {
178    SpaceFrame::new(frame.origin, frame.x, frame.y, frame.z, tolerance).is_ok()
179}