Skip to main content

roxlap_scene/
edit.rs

1//! Multi-chunk edit API on [`Grid`].
2//!
3//! Region-shape edit operations ([`Grid::set_voxel`],
4//! [`Grid::set_rect`], [`Grid::set_sphere`]) take grid-local voxel
5//! coordinates spanning any number of chunks. The implementation:
6//!
7//! 1. Computes the chunk index range the operation touches.
8//! 2. For each chunk in that range, intersects the edit region
9//!    with the chunk's voxel footprint and translates to
10//!    chunk-local coordinates.
11//! 3. Calls the corresponding [`roxlap_formats::edit`] primitive
12//!    with the per-chunk slice of the edit.
13//!
14//! Implicit-air chunks are materialised on demand via
15//! [`Grid::ensure_chunk`] when the operation inserts voxels
16//! (`color = Some(_)`); pure-carve operations (`color = None`)
17//! skip materialisation for chunks that don't already exist —
18//! carving from already-air voxels is a no-op.
19
20use glam::IVec3;
21use roxlap_formats::edit::{set_cube, set_rect, set_sphere};
22
23use crate::addr::{voxel_split, GridLocalPos};
24use crate::{Grid, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
25
26/// Per-axis chunk size as an [`IVec3`]. Duplicated from
27/// [`crate::addr`]'s private helper; kept local because exposing
28/// it would leak an implementation detail.
29#[inline]
30fn chunk_size_ivec3() -> IVec3 {
31    #[allow(clippy::cast_possible_wrap)]
32    IVec3::new(
33        CHUNK_SIZE_XY as i32,
34        CHUNK_SIZE_XY as i32,
35        CHUNK_SIZE_Z as i32,
36    )
37}
38
39impl Grid {
40    /// Set or carve a single voxel at grid-local coordinate
41    /// `voxel`. `color = Some(c)` inserts a solid voxel of colour
42    /// `c`; `color = None` carves to air.
43    ///
44    /// Inserting in an implicit-air chunk materialises that chunk
45    /// (allocates a fresh [`Vxl`]); carving from a missing chunk
46    /// is a no-op.
47    ///
48    /// [`Vxl`]: roxlap_formats::vxl::Vxl
49    pub fn set_voxel(&mut self, voxel: IVec3, color: Option<u32>) {
50        let (chunk_idx, in_chunk) = voxel_split(voxel);
51        if color.is_some() {
52            let vxl = self.ensure_chunk(chunk_idx);
53            #[allow(clippy::cast_possible_wrap)]
54            set_cube(
55                vxl,
56                in_chunk.x as i32,
57                in_chunk.y as i32,
58                in_chunk.z as i32,
59                color,
60            );
61        } else if let Some(vxl) = self.chunks.get_mut(&chunk_idx) {
62            #[allow(clippy::cast_possible_wrap)]
63            set_cube(
64                vxl,
65                in_chunk.x as i32,
66                in_chunk.y as i32,
67                in_chunk.z as i32,
68                None,
69            );
70        }
71    }
72
73    /// Set or carve an axis-aligned box `[lo, hi]` in grid-local
74    /// voxel coordinates. Inclusive on both ends, like
75    /// [`roxlap_formats::edit::set_rect`].
76    ///
77    /// The box is decomposed per chunk: each touched chunk receives
78    /// a `set_rect` call with the box clipped to its footprint and
79    /// translated to chunk-local. Inserts materialise missing
80    /// chunks; carves skip them.
81    ///
82    /// `lo` and `hi` may be in any order on each axis — the
83    /// decomposition normalises them.
84    pub fn set_rect(&mut self, lo: IVec3, hi: IVec3, color: Option<u32>) {
85        let lo_n = lo.min(hi);
86        let hi_n = lo.max(hi);
87        let (lo_c, _) = voxel_split(lo_n);
88        let (hi_c, _) = voxel_split(hi_n);
89        let cs = chunk_size_ivec3();
90
91        for cz in lo_c.z..=hi_c.z {
92            for cy in lo_c.y..=hi_c.y {
93                for cx in lo_c.x..=hi_c.x {
94                    let chunk_idx = IVec3::new(cx, cy, cz);
95                    let chunk_origin = chunk_idx * cs;
96                    let chunk_end = chunk_origin + cs - IVec3::ONE;
97                    let local_lo = lo_n.max(chunk_origin) - chunk_origin;
98                    let local_hi = hi_n.min(chunk_end) - chunk_origin;
99                    apply_set_rect(self, chunk_idx, local_lo, local_hi, color);
100                }
101            }
102        }
103    }
104
105    /// Set or carve a sphere of voxels at grid-local centre
106    /// `centre` with the given `radius`. Euclidean distance, like
107    /// [`roxlap_formats::edit::set_sphere`].
108    ///
109    /// The bounding box of the sphere is enumerated chunk by chunk;
110    /// each touched chunk receives a `set_sphere` call with the
111    /// centre re-expressed in chunk-local coords (the per-chunk
112    /// call clips the sphere to the chunk's footprint internally).
113    /// Chunks the sphere doesn't actually reach get materialised
114    /// only if `color.is_some()` and they fall within the AABB —
115    /// the per-chunk `set_sphere` is a no-op for non-overlapping
116    /// chunks but the materialisation cost remains. A subsequent
117    /// pre-pass that filters chunks against `radius²` could avoid
118    /// this; out of scope for v1.
119    pub fn set_sphere(&mut self, centre: IVec3, radius: u32, color: Option<u32>) {
120        #[allow(clippy::cast_possible_wrap)]
121        let r_i = radius as i32;
122        let lo = centre - IVec3::splat(r_i);
123        let hi = centre + IVec3::splat(r_i);
124        let (lo_c, _) = voxel_split(lo);
125        let (hi_c, _) = voxel_split(hi);
126        let cs = chunk_size_ivec3();
127
128        for cz in lo_c.z..=hi_c.z {
129            for cy in lo_c.y..=hi_c.y {
130                for cx in lo_c.x..=hi_c.x {
131                    let chunk_idx = IVec3::new(cx, cy, cz);
132                    let chunk_origin = chunk_idx * cs;
133                    let local_centre = centre - chunk_origin;
134                    apply_set_sphere(self, chunk_idx, local_centre, radius, color);
135                }
136            }
137        }
138    }
139}
140
141fn apply_set_rect(
142    grid: &mut Grid,
143    chunk_idx: IVec3,
144    local_lo: IVec3,
145    local_hi: IVec3,
146    color: Option<u32>,
147) {
148    if color.is_some() {
149        let vxl = grid.ensure_chunk(chunk_idx);
150        set_rect(vxl, local_lo.into(), local_hi.into(), color);
151    } else if let Some(vxl) = grid.chunks.get_mut(&chunk_idx) {
152        set_rect(vxl, local_lo.into(), local_hi.into(), None);
153    }
154}
155
156fn apply_set_sphere(
157    grid: &mut Grid,
158    chunk_idx: IVec3,
159    local_centre: IVec3,
160    radius: u32,
161    color: Option<u32>,
162) {
163    if color.is_some() {
164        let vxl = grid.ensure_chunk(chunk_idx);
165        set_sphere(vxl, local_centre.into(), radius, color);
166    } else if let Some(vxl) = grid.chunks.get_mut(&chunk_idx) {
167        set_sphere(vxl, local_centre.into(), radius, None);
168    }
169}
170
171/// Convenience: forward a [`GridLocalPos`]-style decomposition
172/// back to [`voxel_global`] for callers that already hold one.
173/// Stays here rather than in [`crate::addr`] because it's only
174/// useful in the edit-API call shape.
175///
176/// [`voxel_global`]: crate::addr::voxel_global
177#[must_use]
178pub fn voxel_at(local: &GridLocalPos) -> IVec3 {
179    crate::addr::voxel_global(local.chunk, local.voxel)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::chunks::tests::voxel_is_solid;
186    use crate::GridTransform;
187
188    const TEST_COL: u32 = 0x80_aa_bb_cc;
189
190    #[test]
191    fn set_voxel_inserts_in_correct_chunk() {
192        // Voxel at grid-local (5, 6, 7) sits in chunk (0, 0, 0)
193        // at local (5, 6, 7).
194        let mut g = Grid::new(GridTransform::identity());
195        g.set_voxel(IVec3::new(5, 6, 7), Some(TEST_COL));
196        let vxl = g.chunk(IVec3::ZERO).expect("chunk created");
197        assert!(voxel_is_solid(vxl, 5, 6, 7));
198        // Adjacent voxel still air.
199        assert!(!voxel_is_solid(vxl, 5, 6, 8));
200        assert_eq!(g.chunk_count(), 1);
201    }
202
203    #[test]
204    fn set_voxel_negative_coords_use_neg_chunk() {
205        // Voxel (-1, 0, 0) sits in chunk (-1, 0, 0) at local
206        // (CHUNK_SIZE_XY - 1, 0, 0).
207        let mut g = Grid::new(GridTransform::identity());
208        g.set_voxel(IVec3::new(-1, 0, 0), Some(TEST_COL));
209        assert!(g.chunk(IVec3::new(-1, 0, 0)).is_some());
210        let vxl = g.chunk(IVec3::new(-1, 0, 0)).unwrap();
211        assert!(voxel_is_solid(vxl, CHUNK_SIZE_XY - 1, 0, 0));
212        // Chunk (0, 0, 0) was NOT created.
213        assert!(g.chunk(IVec3::ZERO).is_none());
214    }
215
216    #[test]
217    fn set_voxel_carve_then_insert_round_trips() {
218        let mut g = Grid::new(GridTransform::identity());
219        g.set_voxel(IVec3::new(10, 10, 10), Some(TEST_COL));
220        assert!(voxel_is_solid(g.chunk(IVec3::ZERO).unwrap(), 10, 10, 10));
221        g.set_voxel(IVec3::new(10, 10, 10), None);
222        assert!(!voxel_is_solid(g.chunk(IVec3::ZERO).unwrap(), 10, 10, 10));
223    }
224
225    #[test]
226    fn set_voxel_carve_in_missing_chunk_is_noop() {
227        // Carving in a chunk that doesn't exist should NOT create
228        // it (it's already implicit-air; nothing to do).
229        let mut g = Grid::new(GridTransform::identity());
230        g.set_voxel(IVec3::new(5, 5, 5), None);
231        assert_eq!(g.chunk_count(), 0);
232    }
233
234    #[test]
235    fn set_rect_within_one_chunk() {
236        let mut g = Grid::new(GridTransform::identity());
237        g.set_rect(IVec3::new(0, 0, 0), IVec3::new(3, 3, 3), Some(TEST_COL));
238        assert_eq!(g.chunk_count(), 1);
239        let vxl = g.chunk(IVec3::ZERO).unwrap();
240        for z in 0..=3 {
241            for y in 0..=3 {
242                for x in 0..=3 {
243                    assert!(voxel_is_solid(vxl, x, y, z), "({x},{y},{z}) air");
244                }
245            }
246        }
247        // Just outside the rect.
248        assert!(!voxel_is_solid(vxl, 4, 0, 0));
249        assert!(!voxel_is_solid(vxl, 0, 4, 0));
250        assert!(!voxel_is_solid(vxl, 0, 0, 4));
251    }
252
253    #[test]
254    fn set_rect_spans_two_chunks_x() {
255        // Box [(126, 0, 0) .. (129, 0, 0)] crosses the chunk-0 /
256        // chunk-1 boundary on x at 128.
257        let mut g = Grid::new(GridTransform::identity());
258        g.set_rect(IVec3::new(126, 0, 0), IVec3::new(129, 0, 0), Some(TEST_COL));
259        assert_eq!(g.chunk_count(), 2);
260
261        // Chunk (0,0,0): voxels x=126, 127 at (y=0, z=0) solid.
262        let v0 = g.chunk(IVec3::ZERO).unwrap();
263        assert!(voxel_is_solid(v0, 126, 0, 0));
264        assert!(voxel_is_solid(v0, 127, 0, 0));
265        assert!(!voxel_is_solid(v0, 125, 0, 0));
266
267        // Chunk (1,0,0): voxels x=0, 1 at (y=0, z=0) solid.
268        let v1 = g.chunk(IVec3::new(1, 0, 0)).unwrap();
269        assert!(voxel_is_solid(v1, 0, 0, 0));
270        assert!(voxel_is_solid(v1, 1, 0, 0));
271        assert!(!voxel_is_solid(v1, 2, 0, 0));
272    }
273
274    #[test]
275    fn set_rect_spans_z_boundary() {
276        // Box at z=255..256 crosses chunk boundary on z (256 = 1
277        // chunk on z-axis).
278        let mut g = Grid::new(GridTransform::identity());
279        g.set_rect(IVec3::new(0, 0, 254), IVec3::new(0, 0, 257), Some(TEST_COL));
280        assert_eq!(g.chunk_count(), 2);
281        let v0 = g.chunk(IVec3::ZERO).unwrap();
282        assert!(voxel_is_solid(v0, 0, 0, 254));
283        assert!(voxel_is_solid(v0, 0, 0, 255));
284        let v1 = g.chunk(IVec3::new(0, 0, 1)).unwrap();
285        assert!(voxel_is_solid(v1, 0, 0, 0));
286        assert!(voxel_is_solid(v1, 0, 0, 1));
287        assert!(!voxel_is_solid(v1, 0, 0, 2));
288    }
289
290    #[test]
291    fn set_rect_unsorted_lo_hi_normalised() {
292        // Passing hi < lo should produce the same result as lo < hi.
293        let mut g1 = Grid::new(GridTransform::identity());
294        let mut g2 = Grid::new(GridTransform::identity());
295        g1.set_rect(IVec3::new(0, 0, 0), IVec3::new(3, 3, 3), Some(TEST_COL));
296        g2.set_rect(IVec3::new(3, 3, 3), IVec3::new(0, 0, 0), Some(TEST_COL));
297        let v1 = g1.chunk(IVec3::ZERO).unwrap();
298        let v2 = g2.chunk(IVec3::ZERO).unwrap();
299        for z in 0..=3 {
300            for y in 0..=3 {
301                for x in 0..=3 {
302                    assert_eq!(voxel_is_solid(v1, x, y, z), voxel_is_solid(v2, x, y, z));
303                }
304            }
305        }
306    }
307
308    #[test]
309    fn set_sphere_within_one_chunk() {
310        let mut g = Grid::new(GridTransform::identity());
311        g.set_sphere(IVec3::new(64, 64, 100), 5, Some(TEST_COL));
312        assert_eq!(g.chunk_count(), 1);
313        let vxl = g.chunk(IVec3::ZERO).unwrap();
314        // Centre is solid.
315        assert!(voxel_is_solid(vxl, 64, 64, 100));
316        // 1 voxel from centre is solid (radius 5).
317        assert!(voxel_is_solid(vxl, 65, 64, 100));
318        assert!(voxel_is_solid(vxl, 64, 64, 105));
319        // Just outside radius is air.
320        assert!(!voxel_is_solid(vxl, 70, 64, 100));
321    }
322
323    #[test]
324    fn set_sphere_spans_chunk_boundary() {
325        // Centre at (127, 64, 100), radius 4 → reaches into chunk
326        // (1,0,0) on the +x side.
327        let mut g = Grid::new(GridTransform::identity());
328        g.set_sphere(IVec3::new(127, 64, 100), 4, Some(TEST_COL));
329        // 2 chunks: (0,0,0) and (1,0,0).
330        assert_eq!(g.chunk_count(), 2);
331
332        let v0 = g.chunk(IVec3::ZERO).unwrap();
333        // (127, 64, 100) is the centre, in chunk 0 at local
334        // (127, 64, 100).
335        assert!(voxel_is_solid(v0, 127, 64, 100));
336        // (124, 64, 100) is 3 voxels away, inside the sphere.
337        assert!(voxel_is_solid(v0, 124, 64, 100));
338
339        let v1 = g.chunk(IVec3::new(1, 0, 0)).unwrap();
340        // Voxel (128, 64, 100) is centre + 1x → in chunk 1 at
341        // local (0, 64, 100).
342        assert!(voxel_is_solid(v1, 0, 64, 100));
343        // Voxel (130, 64, 100) is 3 voxels from centre, still inside.
344        assert!(voxel_is_solid(v1, 2, 64, 100));
345    }
346
347    #[test]
348    fn set_voxel_dispatches_to_correct_chunk_on_y_z_axes() {
349        // Sanity check the y / z chunk dispatches use the right
350        // chunk size (XY=128, Z=256). voxel (200, 300, 500)
351        // should go to chunk (1, 2, 1), local (72, 44, 244).
352        let mut g = Grid::new(GridTransform::identity());
353        g.set_voxel(IVec3::new(200, 300, 500), Some(TEST_COL));
354        let vxl = g
355            .chunk(IVec3::new(1, 2, 1))
356            .expect("expected chunk (1, 2, 1)");
357        assert!(voxel_is_solid(vxl, 72, 44, 244));
358    }
359}