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        // S6.2: any edit invalidates the billboard impostor cache;
51        // S6.3 will rebuild on next Far-tier use.
52        self.billboards = None;
53        let (chunk_idx, in_chunk) = voxel_split(voxel);
54        if color.is_some() {
55            let vxl = self.ensure_chunk(chunk_idx);
56            #[allow(clippy::cast_possible_wrap)]
57            set_cube(
58                vxl,
59                in_chunk.x as i32,
60                in_chunk.y as i32,
61                in_chunk.z as i32,
62                color,
63            );
64            // S7.2: bump only on actual write. Insert always writes.
65            self.bump_chunk_version(chunk_idx);
66        } else if let Some(vxl) = self.chunks.get_mut(&chunk_idx) {
67            #[allow(clippy::cast_possible_wrap)]
68            set_cube(
69                vxl,
70                in_chunk.x as i32,
71                in_chunk.y as i32,
72                in_chunk.z as i32,
73                None,
74            );
75            // S7.2: carve only writes when the chunk pre-existed
76            // (we're inside the `if let Some` branch).
77            self.bump_chunk_version(chunk_idx);
78        }
79    }
80
81    /// Set or carve an axis-aligned box `[lo, hi]` in grid-local
82    /// voxel coordinates. Inclusive on both ends, like
83    /// [`roxlap_formats::edit::set_rect`].
84    ///
85    /// The box is decomposed per chunk: each touched chunk receives
86    /// a `set_rect` call with the box clipped to its footprint and
87    /// translated to chunk-local. Inserts materialise missing
88    /// chunks; carves skip them.
89    ///
90    /// `lo` and `hi` may be in any order on each axis — the
91    /// decomposition normalises them.
92    pub fn set_rect(&mut self, lo: IVec3, hi: IVec3, color: Option<u32>) {
93        // S6.2: edit invalidates billboard cache (see set_voxel doc).
94        self.billboards = None;
95        let lo_n = lo.min(hi);
96        let hi_n = lo.max(hi);
97        let (lo_c, _) = voxel_split(lo_n);
98        let (hi_c, _) = voxel_split(hi_n);
99        let cs = chunk_size_ivec3();
100
101        for cz in lo_c.z..=hi_c.z {
102            for cy in lo_c.y..=hi_c.y {
103                for cx in lo_c.x..=hi_c.x {
104                    let chunk_idx = IVec3::new(cx, cy, cz);
105                    let chunk_origin = chunk_idx * cs;
106                    let chunk_end = chunk_origin + cs - IVec3::ONE;
107                    let local_lo = lo_n.max(chunk_origin) - chunk_origin;
108                    let local_hi = hi_n.min(chunk_end) - chunk_origin;
109                    apply_set_rect(self, chunk_idx, local_lo, local_hi, color);
110                }
111            }
112        }
113    }
114
115    /// Set or carve a sphere of voxels at grid-local centre
116    /// `centre` with the given `radius`. Euclidean distance, like
117    /// [`roxlap_formats::edit::set_sphere`].
118    ///
119    /// The bounding box of the sphere is enumerated chunk by chunk;
120    /// each touched chunk receives a `set_sphere` call with the
121    /// centre re-expressed in chunk-local coords (the per-chunk
122    /// call clips the sphere to the chunk's footprint internally).
123    /// Chunks the sphere doesn't actually reach get materialised
124    /// only if `color.is_some()` and they fall within the AABB —
125    /// the per-chunk `set_sphere` is a no-op for non-overlapping
126    /// chunks but the materialisation cost remains. A subsequent
127    /// pre-pass that filters chunks against `radius²` could avoid
128    /// this; out of scope for v1.
129    pub fn set_sphere(&mut self, centre: IVec3, radius: u32, color: Option<u32>) {
130        // S6.2: edit invalidates billboard cache (see set_voxel doc).
131        self.billboards = None;
132        #[allow(clippy::cast_possible_wrap)]
133        let r_i = radius as i32;
134        let lo = centre - IVec3::splat(r_i);
135        let hi = centre + IVec3::splat(r_i);
136        let (lo_c, _) = voxel_split(lo);
137        let (hi_c, _) = voxel_split(hi);
138        let cs = chunk_size_ivec3();
139
140        for cz in lo_c.z..=hi_c.z {
141            for cy in lo_c.y..=hi_c.y {
142                for cx in lo_c.x..=hi_c.x {
143                    let chunk_idx = IVec3::new(cx, cy, cz);
144                    let chunk_origin = chunk_idx * cs;
145                    let local_centre = centre - chunk_origin;
146                    apply_set_sphere(self, chunk_idx, local_centre, radius, color);
147                }
148            }
149        }
150    }
151}
152
153fn apply_set_rect(
154    grid: &mut Grid,
155    chunk_idx: IVec3,
156    local_lo: IVec3,
157    local_hi: IVec3,
158    color: Option<u32>,
159) {
160    let mut wrote = false;
161    if color.is_some() {
162        let vxl = grid.ensure_chunk(chunk_idx);
163        set_rect(vxl, local_lo.into(), local_hi.into(), color);
164        wrote = true;
165    } else if let Some(vxl) = grid.chunks.get_mut(&chunk_idx) {
166        set_rect(vxl, local_lo.into(), local_hi.into(), None);
167        wrote = true;
168    }
169    if wrote {
170        // S7.2: only writes bump. Carve on a missing chunk is a
171        // pure no-op (no entry to advance).
172        grid.bump_chunk_version(chunk_idx);
173    }
174}
175
176fn apply_set_sphere(
177    grid: &mut Grid,
178    chunk_idx: IVec3,
179    local_centre: IVec3,
180    radius: u32,
181    color: Option<u32>,
182) {
183    let mut wrote = false;
184    if color.is_some() {
185        let vxl = grid.ensure_chunk(chunk_idx);
186        set_sphere(vxl, local_centre.into(), radius, color);
187        wrote = true;
188    } else if let Some(vxl) = grid.chunks.get_mut(&chunk_idx) {
189        set_sphere(vxl, local_centre.into(), radius, None);
190        wrote = true;
191    }
192    if wrote {
193        // S7.2: see apply_set_rect rationale.
194        grid.bump_chunk_version(chunk_idx);
195    }
196}
197
198/// Convenience: forward a [`GridLocalPos`]-style decomposition
199/// back to [`voxel_global`] for callers that already hold one.
200/// Stays here rather than in [`crate::addr`] because it's only
201/// useful in the edit-API call shape.
202///
203/// [`voxel_global`]: crate::addr::voxel_global
204#[must_use]
205pub fn voxel_at(local: &GridLocalPos) -> IVec3 {
206    crate::addr::voxel_global(local.chunk, local.voxel)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::chunks::tests::voxel_is_solid;
213    use crate::GridTransform;
214
215    const TEST_COL: u32 = 0x80_aa_bb_cc;
216
217    #[test]
218    fn set_voxel_inserts_in_correct_chunk() {
219        // Voxel at grid-local (5, 6, 7) sits in chunk (0, 0, 0)
220        // at local (5, 6, 7).
221        let mut g = Grid::new(GridTransform::identity());
222        g.set_voxel(IVec3::new(5, 6, 7), Some(TEST_COL));
223        let vxl = g.chunk(IVec3::ZERO).expect("chunk created");
224        assert!(voxel_is_solid(vxl, 5, 6, 7));
225        // Adjacent voxel still air.
226        assert!(!voxel_is_solid(vxl, 5, 6, 8));
227        assert_eq!(g.chunk_count(), 1);
228    }
229
230    #[test]
231    fn set_voxel_negative_coords_use_neg_chunk() {
232        // Voxel (-1, 0, 0) sits in chunk (-1, 0, 0) at local
233        // (CHUNK_SIZE_XY - 1, 0, 0).
234        let mut g = Grid::new(GridTransform::identity());
235        g.set_voxel(IVec3::new(-1, 0, 0), Some(TEST_COL));
236        assert!(g.chunk(IVec3::new(-1, 0, 0)).is_some());
237        let vxl = g.chunk(IVec3::new(-1, 0, 0)).unwrap();
238        assert!(voxel_is_solid(vxl, CHUNK_SIZE_XY - 1, 0, 0));
239        // Chunk (0, 0, 0) was NOT created.
240        assert!(g.chunk(IVec3::ZERO).is_none());
241    }
242
243    #[test]
244    fn set_voxel_carve_then_insert_round_trips() {
245        let mut g = Grid::new(GridTransform::identity());
246        g.set_voxel(IVec3::new(10, 10, 10), Some(TEST_COL));
247        assert!(voxel_is_solid(g.chunk(IVec3::ZERO).unwrap(), 10, 10, 10));
248        g.set_voxel(IVec3::new(10, 10, 10), None);
249        assert!(!voxel_is_solid(g.chunk(IVec3::ZERO).unwrap(), 10, 10, 10));
250    }
251
252    #[test]
253    fn set_voxel_carve_in_missing_chunk_is_noop() {
254        // Carving in a chunk that doesn't exist should NOT create
255        // it (it's already implicit-air; nothing to do).
256        let mut g = Grid::new(GridTransform::identity());
257        g.set_voxel(IVec3::new(5, 5, 5), None);
258        assert_eq!(g.chunk_count(), 0);
259    }
260
261    #[test]
262    fn set_rect_within_one_chunk() {
263        let mut g = Grid::new(GridTransform::identity());
264        g.set_rect(IVec3::new(0, 0, 0), IVec3::new(3, 3, 3), Some(TEST_COL));
265        assert_eq!(g.chunk_count(), 1);
266        let vxl = g.chunk(IVec3::ZERO).unwrap();
267        for z in 0..=3 {
268            for y in 0..=3 {
269                for x in 0..=3 {
270                    assert!(voxel_is_solid(vxl, x, y, z), "({x},{y},{z}) air");
271                }
272            }
273        }
274        // Just outside the rect.
275        assert!(!voxel_is_solid(vxl, 4, 0, 0));
276        assert!(!voxel_is_solid(vxl, 0, 4, 0));
277        assert!(!voxel_is_solid(vxl, 0, 0, 4));
278    }
279
280    #[test]
281    fn set_rect_spans_two_chunks_x() {
282        // Box [(126, 0, 0) .. (129, 0, 0)] crosses the chunk-0 /
283        // chunk-1 boundary on x at 128.
284        let mut g = Grid::new(GridTransform::identity());
285        g.set_rect(IVec3::new(126, 0, 0), IVec3::new(129, 0, 0), Some(TEST_COL));
286        assert_eq!(g.chunk_count(), 2);
287
288        // Chunk (0,0,0): voxels x=126, 127 at (y=0, z=0) solid.
289        let v0 = g.chunk(IVec3::ZERO).unwrap();
290        assert!(voxel_is_solid(v0, 126, 0, 0));
291        assert!(voxel_is_solid(v0, 127, 0, 0));
292        assert!(!voxel_is_solid(v0, 125, 0, 0));
293
294        // Chunk (1,0,0): voxels x=0, 1 at (y=0, z=0) solid.
295        let v1 = g.chunk(IVec3::new(1, 0, 0)).unwrap();
296        assert!(voxel_is_solid(v1, 0, 0, 0));
297        assert!(voxel_is_solid(v1, 1, 0, 0));
298        assert!(!voxel_is_solid(v1, 2, 0, 0));
299    }
300
301    #[test]
302    fn set_rect_spans_z_boundary() {
303        // Box at z=255..256 crosses chunk boundary on z (256 = 1
304        // chunk on z-axis).
305        let mut g = Grid::new(GridTransform::identity());
306        g.set_rect(IVec3::new(0, 0, 254), IVec3::new(0, 0, 257), Some(TEST_COL));
307        assert_eq!(g.chunk_count(), 2);
308        let v0 = g.chunk(IVec3::ZERO).unwrap();
309        assert!(voxel_is_solid(v0, 0, 0, 254));
310        assert!(voxel_is_solid(v0, 0, 0, 255));
311        let v1 = g.chunk(IVec3::new(0, 0, 1)).unwrap();
312        assert!(voxel_is_solid(v1, 0, 0, 0));
313        assert!(voxel_is_solid(v1, 0, 0, 1));
314        assert!(!voxel_is_solid(v1, 0, 0, 2));
315    }
316
317    #[test]
318    fn set_rect_unsorted_lo_hi_normalised() {
319        // Passing hi < lo should produce the same result as lo < hi.
320        let mut g1 = Grid::new(GridTransform::identity());
321        let mut g2 = Grid::new(GridTransform::identity());
322        g1.set_rect(IVec3::new(0, 0, 0), IVec3::new(3, 3, 3), Some(TEST_COL));
323        g2.set_rect(IVec3::new(3, 3, 3), IVec3::new(0, 0, 0), Some(TEST_COL));
324        let v1 = g1.chunk(IVec3::ZERO).unwrap();
325        let v2 = g2.chunk(IVec3::ZERO).unwrap();
326        for z in 0..=3 {
327            for y in 0..=3 {
328                for x in 0..=3 {
329                    assert_eq!(voxel_is_solid(v1, x, y, z), voxel_is_solid(v2, x, y, z));
330                }
331            }
332        }
333    }
334
335    #[test]
336    fn set_sphere_within_one_chunk() {
337        let mut g = Grid::new(GridTransform::identity());
338        g.set_sphere(IVec3::new(64, 64, 100), 5, Some(TEST_COL));
339        assert_eq!(g.chunk_count(), 1);
340        let vxl = g.chunk(IVec3::ZERO).unwrap();
341        // Centre is solid.
342        assert!(voxel_is_solid(vxl, 64, 64, 100));
343        // 1 voxel from centre is solid (radius 5).
344        assert!(voxel_is_solid(vxl, 65, 64, 100));
345        assert!(voxel_is_solid(vxl, 64, 64, 105));
346        // Just outside radius is air.
347        assert!(!voxel_is_solid(vxl, 70, 64, 100));
348    }
349
350    #[test]
351    fn set_sphere_spans_chunk_boundary() {
352        // Centre at (127, 64, 100), radius 4 → reaches into chunk
353        // (1,0,0) on the +x side.
354        let mut g = Grid::new(GridTransform::identity());
355        g.set_sphere(IVec3::new(127, 64, 100), 4, Some(TEST_COL));
356        // 2 chunks: (0,0,0) and (1,0,0).
357        assert_eq!(g.chunk_count(), 2);
358
359        let v0 = g.chunk(IVec3::ZERO).unwrap();
360        // (127, 64, 100) is the centre, in chunk 0 at local
361        // (127, 64, 100).
362        assert!(voxel_is_solid(v0, 127, 64, 100));
363        // (124, 64, 100) is 3 voxels away, inside the sphere.
364        assert!(voxel_is_solid(v0, 124, 64, 100));
365
366        let v1 = g.chunk(IVec3::new(1, 0, 0)).unwrap();
367        // Voxel (128, 64, 100) is centre + 1x → in chunk 1 at
368        // local (0, 64, 100).
369        assert!(voxel_is_solid(v1, 0, 64, 100));
370        // Voxel (130, 64, 100) is 3 voxels from centre, still inside.
371        assert!(voxel_is_solid(v1, 2, 64, 100));
372    }
373
374    // ---- S6.2: billboard cache invalidation ----
375
376    /// Helper: stamp a sentinel billboard cache onto a grid so the
377    /// invalidation tests can detect when the edit cleared it. We
378    /// use a 32-resolution `new_empty` cache — populating real
379    /// snapshots would work too but is needlessly expensive when
380    /// the test only cares about Some/None state.
381    fn stamp_sentinel_cache(g: &mut Grid) {
382        g.billboards = Some(crate::BillboardCache::new_empty(32));
383    }
384
385    #[test]
386    fn set_voxel_invalidates_billboard_cache() {
387        let mut g = Grid::new(GridTransform::identity());
388        stamp_sentinel_cache(&mut g);
389        assert!(g.billboards.is_some());
390        g.set_voxel(IVec3::new(5, 5, 5), Some(TEST_COL));
391        assert!(
392            g.billboards.is_none(),
393            "set_voxel should clear the billboard cache"
394        );
395    }
396
397    #[test]
398    fn set_voxel_carve_also_invalidates() {
399        // Even no-op carves invalidate — the cache must be conservative.
400        let mut g = Grid::new(GridTransform::identity());
401        stamp_sentinel_cache(&mut g);
402        g.set_voxel(IVec3::new(5, 5, 5), None); // missing-chunk no-op
403        assert!(
404            g.billboards.is_none(),
405            "carve should clear the cache (conservative)"
406        );
407    }
408
409    #[test]
410    fn set_rect_invalidates_billboard_cache() {
411        let mut g = Grid::new(GridTransform::identity());
412        stamp_sentinel_cache(&mut g);
413        g.set_rect(IVec3::new(0, 0, 0), IVec3::new(3, 3, 3), Some(TEST_COL));
414        assert!(g.billboards.is_none(), "set_rect should clear the cache");
415    }
416
417    #[test]
418    fn set_sphere_invalidates_billboard_cache() {
419        let mut g = Grid::new(GridTransform::identity());
420        stamp_sentinel_cache(&mut g);
421        g.set_sphere(IVec3::new(64, 64, 100), 5, Some(TEST_COL));
422        assert!(g.billboards.is_none(), "set_sphere should clear the cache");
423    }
424
425    #[test]
426    fn set_voxel_dispatches_to_correct_chunk_on_y_z_axes() {
427        // Sanity check the y / z chunk dispatches use the right
428        // chunk size (XY=128, Z=256). voxel (200, 300, 500)
429        // should go to chunk (1, 2, 1), local (72, 44, 244).
430        let mut g = Grid::new(GridTransform::identity());
431        g.set_voxel(IVec3::new(200, 300, 500), Some(TEST_COL));
432        let vxl = g
433            .chunk(IVec3::new(1, 2, 1))
434            .expect("expected chunk (1, 2, 1)");
435        assert!(voxel_is_solid(vxl, 72, 44, 244));
436    }
437
438    // ---- S7.2: chunk version counter bumps ----
439
440    #[test]
441    fn chunk_version_defaults_to_zero_for_missing() {
442        let g = Grid::new(GridTransform::identity());
443        assert_eq!(g.chunk_version(IVec3::ZERO), 0);
444        assert_eq!(g.chunk_version(IVec3::new(7, -3, 12)), 0);
445    }
446
447    #[test]
448    fn set_voxel_insert_bumps_to_one() {
449        let mut g = Grid::new(GridTransform::identity());
450        assert_eq!(g.chunk_version(IVec3::ZERO), 0);
451        g.set_voxel(IVec3::new(5, 5, 5), Some(TEST_COL));
452        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
453    }
454
455    #[test]
456    fn set_voxel_carve_in_existing_chunk_bumps() {
457        // Sequence (insert, carve) → version 2.
458        let mut g = Grid::new(GridTransform::identity());
459        g.set_voxel(IVec3::new(5, 5, 5), Some(TEST_COL));
460        g.set_voxel(IVec3::new(5, 5, 5), None);
461        assert_eq!(g.chunk_version(IVec3::ZERO), 2);
462    }
463
464    #[test]
465    fn set_voxel_carve_in_missing_chunk_does_not_bump() {
466        // No-op edit path — no chunk created, no version bump.
467        let mut g = Grid::new(GridTransform::identity());
468        g.set_voxel(IVec3::new(5, 5, 5), None);
469        assert_eq!(g.chunk_version(IVec3::ZERO), 0);
470        assert!(g.chunk_versions.is_empty());
471    }
472
473    #[test]
474    fn set_rect_multi_chunk_bumps_every_touched_chunk() {
475        // Box crossing the x=128 boundary → two chunks both bumped.
476        let mut g = Grid::new(GridTransform::identity());
477        g.set_rect(IVec3::new(126, 0, 0), IVec3::new(129, 0, 0), Some(TEST_COL));
478        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
479        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
480        // No other chunks touched.
481        assert_eq!(g.chunk_versions.len(), 2);
482    }
483
484    #[test]
485    fn set_rect_carve_bumps_only_existing_chunks() {
486        // Insert into chunk 0; then carve a 2-chunk-wide rect that
487        // overlaps chunk 0 + chunk 1. Chunk 0 (exists) should bump
488        // again; chunk 1 (still implicit-air) should NOT.
489        let mut g = Grid::new(GridTransform::identity());
490        g.set_voxel(IVec3::new(0, 0, 0), Some(TEST_COL));
491        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
492        g.set_rect(IVec3::new(126, 0, 0), IVec3::new(129, 0, 0), None);
493        assert_eq!(g.chunk_version(IVec3::ZERO), 2);
494        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 0);
495    }
496
497    #[test]
498    fn set_sphere_multi_chunk_bumps_every_written_chunk() {
499        // Sphere centred on (127, 64, 100) radius 4 → touches
500        // chunks (0,0,0) and (1,0,0).
501        let mut g = Grid::new(GridTransform::identity());
502        g.set_sphere(IVec3::new(127, 64, 100), 4, Some(TEST_COL));
503        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
504        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
505    }
506}