Skip to main content

sectorsync_core/
spatial.rs

1//! 3D spatial primitives and uniform-grid helpers.
2
3/// 3D world-space position.
4#[derive(Clone, Copy, Debug, Default, PartialEq)]
5pub struct Position3 {
6    /// X coordinate.
7    pub x: f32,
8    /// Y coordinate.
9    pub y: f32,
10    /// Z coordinate.
11    pub z: f32,
12}
13
14impl Position3 {
15    /// Creates a new position.
16    pub const fn new(x: f32, y: f32, z: f32) -> Self {
17        Self { x, y, z }
18    }
19
20    /// Squared distance to another position.
21    pub fn distance_squared(self, other: Self) -> f32 {
22        let dx = self.x - other.x;
23        let dy = self.y - other.y;
24        let dz = self.z - other.z;
25        dx.mul_add(dx, dy.mul_add(dy, dz * dz))
26    }
27
28    /// Returns this position as a vector from the origin.
29    pub const fn to_vec3(self) -> Vec3 {
30        Vec3::new(self.x, self.y, self.z)
31    }
32}
33
34/// 3D vector used for extents and offsets.
35#[derive(Clone, Copy, Debug, Default, PartialEq)]
36pub struct Vec3 {
37    /// X component.
38    pub x: f32,
39    /// Y component.
40    pub y: f32,
41    /// Z component.
42    pub z: f32,
43}
44
45impl Vec3 {
46    /// Creates a new vector.
47    pub const fn new(x: f32, y: f32, z: f32) -> Self {
48        Self { x, y, z }
49    }
50
51    /// Dot product with another vector.
52    pub fn dot(self, other: Self) -> f32 {
53        self.x
54            .mul_add(other.x, self.y.mul_add(other.y, self.z * other.z))
55    }
56}
57
58/// Axis-aligned bounding box.
59#[derive(Clone, Copy, Debug, PartialEq)]
60pub struct Aabb3 {
61    /// Minimum corner.
62    pub min: Position3,
63    /// Maximum corner.
64    pub max: Position3,
65}
66
67impl Aabb3 {
68    /// Creates a new AABB.
69    pub const fn new(min: Position3, max: Position3) -> Self {
70        Self { min, max }
71    }
72
73    /// Creates an AABB centered on `center`.
74    pub fn from_center_half_extents(center: Position3, half_extents: Vec3) -> Self {
75        Self {
76            min: Position3::new(
77                center.x - half_extents.x,
78                center.y - half_extents.y,
79                center.z - half_extents.z,
80            ),
81            max: Position3::new(
82                center.x + half_extents.x,
83                center.y + half_extents.y,
84                center.z + half_extents.z,
85            ),
86        }
87    }
88
89    /// Returns whether two AABBs overlap.
90    pub fn overlaps(self, other: Self) -> bool {
91        self.min.x <= other.max.x
92            && self.max.x >= other.min.x
93            && self.min.y <= other.max.y
94            && self.max.y >= other.min.y
95            && self.min.z <= other.max.z
96            && self.max.z >= other.min.z
97    }
98}
99
100/// Plane represented as `normal dot position + distance >= 0`.
101#[derive(Clone, Copy, Debug, PartialEq)]
102pub struct Plane3 {
103    /// Inward-facing plane normal.
104    pub normal: Vec3,
105    /// Signed distance term.
106    pub distance: f32,
107}
108
109impl Plane3 {
110    /// Creates a plane from a normal and distance term.
111    pub const fn new(normal: Vec3, distance: f32) -> Self {
112        Self { normal, distance }
113    }
114
115    /// Creates a plane passing through a point.
116    pub fn from_normal_and_point(normal: Vec3, point: Position3) -> Self {
117        Self {
118            normal,
119            distance: -normal.dot(point.to_vec3()),
120        }
121    }
122
123    /// Signed distance from a position to this plane.
124    pub fn signed_distance_to_position(self, position: Position3) -> f32 {
125        self.normal.dot(position.to_vec3()) + self.distance
126    }
127
128    /// Returns whether an AABB intersects this plane's positive half-space.
129    pub fn intersects_aabb(self, aabb: Aabb3) -> bool {
130        let positive = Position3::new(
131            if self.normal.x >= 0.0 {
132                aabb.max.x
133            } else {
134                aabb.min.x
135            },
136            if self.normal.y >= 0.0 {
137                aabb.max.y
138            } else {
139                aabb.min.y
140            },
141            if self.normal.z >= 0.0 {
142                aabb.max.z
143            } else {
144                aabb.min.z
145            },
146        );
147        self.signed_distance_to_position(positive) >= 0.0
148    }
149}
150
151/// Six-plane 3D visibility volume.
152#[derive(Clone, Copy, Debug, PartialEq)]
153pub struct Frustum3 {
154    /// Inward-facing clipping planes.
155    pub planes: [Plane3; 6],
156}
157
158impl Frustum3 {
159    /// Creates a frustum from six inward-facing planes.
160    pub const fn from_planes(planes: [Plane3; 6]) -> Self {
161        Self { planes }
162    }
163
164    /// Creates an axis-aligned six-plane volume from an AABB.
165    pub fn from_aabb(aabb: Aabb3) -> Self {
166        Self::from_planes([
167            Plane3::new(Vec3::new(1.0, 0.0, 0.0), -aabb.min.x),
168            Plane3::new(Vec3::new(-1.0, 0.0, 0.0), aabb.max.x),
169            Plane3::new(Vec3::new(0.0, 1.0, 0.0), -aabb.min.y),
170            Plane3::new(Vec3::new(0.0, -1.0, 0.0), aabb.max.y),
171            Plane3::new(Vec3::new(0.0, 0.0, 1.0), -aabb.min.z),
172            Plane3::new(Vec3::new(0.0, 0.0, -1.0), aabb.max.z),
173        ])
174    }
175
176    /// Returns whether an AABB intersects every clipping half-space.
177    pub fn intersects_aabb(self, aabb: Aabb3) -> bool {
178        self.planes.iter().all(|plane| plane.intersects_aabb(aabb))
179    }
180
181    /// Returns whether bounds at a position intersect this frustum.
182    pub fn intersects_bounds(self, position: Position3, bounds: Bounds) -> bool {
183        self.intersects_aabb(bounds.to_aabb(position))
184    }
185}
186
187/// Entity bounds for spatial indexing and AOI overlap.
188#[derive(Clone, Copy, Debug, Default, PartialEq)]
189pub enum Bounds {
190    /// Point-sized entity.
191    #[default]
192    Point,
193    /// Spherical entity bounds.
194    Sphere {
195        /// Sphere radius in world units.
196        radius: f32,
197    },
198    /// Axis-aligned entity bounds represented by half extents.
199    Aabb {
200        /// Half extents in world units.
201        half_extents: Vec3,
202    },
203}
204
205impl Bounds {
206    /// Converts bounds at `position` into an AABB.
207    pub fn to_aabb(self, position: Position3) -> Aabb3 {
208        match self {
209            Self::Point => Aabb3::new(position, position),
210            Self::Sphere { radius } => {
211                Aabb3::from_center_half_extents(position, Vec3::new(radius, radius, radius))
212            }
213            Self::Aabb { half_extents } => Aabb3::from_center_half_extents(position, half_extents),
214        }
215    }
216}
217
218/// Integer 3D cell coordinate.
219#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
220pub struct CellCoord3 {
221    /// X cell coordinate.
222    pub x: i32,
223    /// Y cell coordinate.
224    pub y: i32,
225    /// Z cell coordinate.
226    pub z: i32,
227}
228
229impl CellCoord3 {
230    /// Creates a new cell coordinate.
231    pub const fn new(x: i32, y: i32, z: i32) -> Self {
232        Self { x, y, z }
233    }
234}
235
236/// Uniform 3D grid configuration.
237#[derive(Clone, Copy, Debug, PartialEq)]
238pub struct GridSpec {
239    cell_size: f32,
240}
241
242impl GridSpec {
243    /// Creates a grid spec when `cell_size` is finite and positive.
244    pub fn new(cell_size: f32) -> Result<Self, GridSpecError> {
245        if cell_size.is_finite() && cell_size > 0.0 {
246            Ok(Self { cell_size })
247        } else {
248            Err(GridSpecError::InvalidCellSize)
249        }
250    }
251
252    /// Returns cell size in world units.
253    pub const fn cell_size(self) -> f32 {
254        self.cell_size
255    }
256
257    /// Maps a world-space position to a cell coordinate.
258    pub fn cell_at(self, position: Position3) -> CellCoord3 {
259        let inv = 1.0 / self.cell_size;
260        CellCoord3::new(
261            floor_to_cell(position.x * inv),
262            floor_to_cell(position.y * inv),
263            floor_to_cell(position.z * inv),
264        )
265    }
266
267    /// Returns all cells touched by an AABB.
268    pub fn cells_for_aabb(self, aabb: Aabb3) -> Vec<CellCoord3> {
269        let min = self.cell_at(aabb.min);
270        let max = self.cell_at(aabb.max);
271        let capacity = axis_cell_count(min.x, max.x)
272            .saturating_mul(axis_cell_count(min.y, max.y))
273            .saturating_mul(axis_cell_count(min.z, max.z));
274        let mut cells = Vec::with_capacity(capacity);
275
276        for x in min.x..=max.x {
277            for y in min.y..=max.y {
278                for z in min.z..=max.z {
279                    cells.push(CellCoord3::new(x, y, z));
280                }
281            }
282        }
283
284        cells
285    }
286
287    /// Returns all cells touched by entity bounds at `position`.
288    pub fn cells_for_bounds(self, position: Position3, bounds: Bounds) -> Vec<CellCoord3> {
289        self.cells_for_aabb(bounds.to_aabb(position))
290    }
291}
292
293#[allow(clippy::cast_possible_truncation)]
294fn floor_to_cell(value: f32) -> i32 {
295    // Rust float-to-int casts saturate infinities and map NaN to zero.
296    value.floor() as i32
297}
298
299fn axis_cell_count(min: i32, max: i32) -> usize {
300    if max < min {
301        return 0;
302    }
303    usize::try_from(i64::from(max) - i64::from(min) + 1).unwrap_or(usize::MAX)
304}
305
306/// Grid configuration error.
307#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub enum GridSpecError {
309    /// Cell size must be positive and finite.
310    InvalidCellSize,
311}
312
313impl core::fmt::Display for GridSpecError {
314    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
315        match self {
316            Self::InvalidCellSize => f.write_str("cell size must be finite and positive"),
317        }
318    }
319}
320
321impl std::error::Error for GridSpecError {}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn frustum_from_aabb_accepts_intersecting_bounds_and_rejects_outside() {
329        let frustum = Frustum3::from_aabb(Aabb3::new(
330            Position3::new(0.0, -10.0, -10.0),
331            Position3::new(50.0, 10.0, 10.0),
332        ));
333
334        assert!(frustum.intersects_bounds(Position3::new(25.0, 0.0, 0.0), Bounds::Point));
335        assert!(frustum.intersects_bounds(
336            Position3::new(55.0, 0.0, 0.0),
337            Bounds::Sphere { radius: 8.0 }
338        ));
339        assert!(!frustum.intersects_bounds(Position3::new(-5.0, 0.0, 0.0), Bounds::Point));
340        assert!(!frustum.intersects_bounds(Position3::new(25.0, 20.0, 0.0), Bounds::Point));
341    }
342}