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::{
22    set_cube, set_rect, set_rect_with_colfunc, set_sphere, set_sphere_with_colfunc,
23};
24
25use crate::addr::{voxel_split, GridLocalPos};
26use crate::{Grid, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
27
28/// Re-export of [`roxlap_formats::edit::SpanOp`] so scene callers can
29/// name the add-vs-carve flag without depending on `roxlap-formats`
30/// directly. Used by [`Grid::set_sphere_with_colfunc`] /
31/// [`Grid::set_rect_with_colfunc`].
32pub use roxlap_formats::edit::SpanOp;
33
34/// Per-axis chunk size as an [`IVec3`]. Duplicated from
35/// [`crate::addr`]'s private helper; kept local because exposing
36/// it would leak an implementation detail.
37#[inline]
38fn chunk_size_ivec3() -> IVec3 {
39    #[allow(clippy::cast_possible_wrap)]
40    IVec3::new(
41        CHUNK_SIZE_XY as i32,
42        CHUNK_SIZE_XY as i32,
43        CHUNK_SIZE_Z as i32,
44    )
45}
46
47impl Grid {
48    /// Set or carve a single voxel at grid-local coordinate
49    /// `voxel`. `color = Some(c)` inserts a solid voxel of colour
50    /// `c`; `color = None` carves to air.
51    ///
52    /// Inserting in an implicit-air chunk materialises that chunk
53    /// (allocates a fresh [`Vxl`]); carving from a missing chunk
54    /// is a no-op.
55    ///
56    /// [`Vxl`]: roxlap_formats::vxl::Vxl
57    pub fn set_voxel(&mut self, voxel: IVec3, color: Option<u32>) {
58        // S6.2: any edit invalidates the billboard impostor cache;
59        // S6.3 will rebuild on next Far-tier use.
60        self.billboards = None;
61        let (chunk_idx, in_chunk) = voxel_split(voxel);
62        if color.is_some() {
63            let vxl = self.ensure_chunk(chunk_idx);
64            #[allow(clippy::cast_possible_wrap)]
65            set_cube(
66                vxl,
67                in_chunk.x as i32,
68                in_chunk.y as i32,
69                in_chunk.z as i32,
70                color,
71            );
72            // S7.2: bump only on actual write. Insert always writes.
73            // PF.12 — single-voxel extent (`dirty_pad` covers the
74            // adjacent columns whose exposed faces the edit rewrites).
75            let (lo, hi) = dirty_pad(in_chunk.as_ivec3(), in_chunk.as_ivec3());
76            self.bump_chunk_version_bbox(chunk_idx, lo, hi);
77        } else if let Some(vxl) = self.chunks.get_mut(&chunk_idx) {
78            #[allow(clippy::cast_possible_wrap)]
79            set_cube(
80                vxl,
81                in_chunk.x as i32,
82                in_chunk.y as i32,
83                in_chunk.z as i32,
84                None,
85            );
86            // S7.2: carve only writes when the chunk pre-existed
87            // (we're inside the `if let Some` branch).
88            let (lo, hi) = dirty_pad(in_chunk.as_ivec3(), in_chunk.as_ivec3());
89            self.bump_chunk_version_bbox(chunk_idx, lo, hi);
90        }
91    }
92
93    /// Set or carve an axis-aligned box `[lo, hi]` in grid-local
94    /// voxel coordinates. Inclusive on both ends, like
95    /// [`roxlap_formats::edit::set_rect`].
96    ///
97    /// The box is decomposed per chunk: each touched chunk receives
98    /// a `set_rect` call with the box clipped to its footprint and
99    /// translated to chunk-local. Inserts materialise missing
100    /// chunks; carves skip them.
101    ///
102    /// `lo` and `hi` may be in any order on each axis — the
103    /// decomposition normalises them.
104    pub fn set_rect(&mut self, lo: IVec3, hi: IVec3, color: Option<u32>) {
105        // S6.2: edit invalidates billboard cache (see set_voxel doc).
106        self.billboards = None;
107        let lo_n = lo.min(hi);
108        let hi_n = lo.max(hi);
109        let (lo_c, _) = voxel_split(lo_n);
110        let (hi_c, _) = voxel_split(hi_n);
111        let cs = chunk_size_ivec3();
112
113        for cz in lo_c.z..=hi_c.z {
114            for cy in lo_c.y..=hi_c.y {
115                for cx in lo_c.x..=hi_c.x {
116                    let chunk_idx = IVec3::new(cx, cy, cz);
117                    let chunk_origin = chunk_idx * cs;
118                    let chunk_end = chunk_origin + cs - IVec3::ONE;
119                    let local_lo = lo_n.max(chunk_origin) - chunk_origin;
120                    let local_hi = hi_n.min(chunk_end) - chunk_origin;
121                    apply_set_rect(self, chunk_idx, local_lo, local_hi, color);
122                }
123            }
124        }
125    }
126
127    /// Set or carve a sphere of voxels at grid-local centre
128    /// `centre` with the given `radius`. Euclidean distance, like
129    /// [`roxlap_formats::edit::set_sphere`].
130    ///
131    /// The bounding box of the sphere is enumerated chunk by chunk;
132    /// each touched chunk receives a `set_sphere` call with the
133    /// centre re-expressed in chunk-local coords (the per-chunk
134    /// call clips the sphere to the chunk's footprint internally).
135    /// Chunks the sphere doesn't actually reach get materialised
136    /// only if `color.is_some()` and they fall within the AABB —
137    /// the per-chunk `set_sphere` is a no-op for non-overlapping
138    /// chunks but the materialisation cost remains. A subsequent
139    /// pre-pass that filters chunks against `radius²` could avoid
140    /// this; out of scope for v1.
141    pub fn set_sphere(&mut self, centre: IVec3, radius: u32, color: Option<u32>) {
142        // S6.2: edit invalidates billboard cache (see set_voxel doc).
143        self.billboards = None;
144        #[allow(clippy::cast_possible_wrap)]
145        let r_i = radius as i32;
146        let lo = centre - IVec3::splat(r_i);
147        let hi = centre + IVec3::splat(r_i);
148        let (lo_c, _) = voxel_split(lo);
149        let (hi_c, _) = voxel_split(hi);
150        let cs = chunk_size_ivec3();
151
152        for cz in lo_c.z..=hi_c.z {
153            for cy in lo_c.y..=hi_c.y {
154                for cx in lo_c.x..=hi_c.x {
155                    let chunk_idx = IVec3::new(cx, cy, cz);
156                    let chunk_origin = chunk_idx * cs;
157                    let local_centre = centre - chunk_origin;
158                    apply_set_sphere(self, chunk_idx, local_centre, radius, color);
159                }
160            }
161        }
162    }
163
164    /// Carve or insert a sphere with a per-voxel colour callback —
165    /// the colfunc counterpart of [`Grid::set_sphere`], forwarding to
166    /// [`roxlap_formats::edit::set_sphere_with_colfunc`].
167    ///
168    /// Use this (with [`SpanOp::Carve`]) to control the colour of the
169    /// interior surface a carve newly exposes: a plain `set_sphere`
170    /// carve paints those walls colour `0` (black), whereas this lets
171    /// the closure return a crater colour, a depth gradient, jitter,
172    /// or a texture lookup. With [`SpanOp::Insert`] the closure colours
173    /// the inserted voxels.
174    ///
175    /// `colfunc(x, y, z)` receives **grid-local** voxel coordinates
176    /// (not chunk-local) and returns a voxlap-packed BGRA colour as
177    /// `i32` — the per-chunk decomposition translates coordinates back
178    /// to grid-local before invoking the closure, so a position- or
179    /// depth-dependent colour stays continuous across chunk seams.
180    ///
181    /// Like [`Grid::set_sphere`]: [`SpanOp::Insert`] materialises
182    /// missing chunks; [`SpanOp::Carve`] skips chunks that don't yet
183    /// exist (carving implicit air is a no-op).
184    pub fn set_sphere_with_colfunc<F>(
185        &mut self,
186        centre: IVec3,
187        radius: u32,
188        op: SpanOp,
189        mut colfunc: F,
190    ) where
191        F: FnMut(i32, i32, i32) -> i32,
192    {
193        // S6.2: edit invalidates billboard cache (see set_voxel doc).
194        self.billboards = None;
195        #[allow(clippy::cast_possible_wrap)]
196        let r_i = radius as i32;
197        let lo = centre - IVec3::splat(r_i);
198        let hi = centre + IVec3::splat(r_i);
199        let (lo_c, _) = voxel_split(lo);
200        let (hi_c, _) = voxel_split(hi);
201        let cs = chunk_size_ivec3();
202        let inserting = op == SpanOp::Insert;
203
204        for cz in lo_c.z..=hi_c.z {
205            for cy in lo_c.y..=hi_c.y {
206                for cx in lo_c.x..=hi_c.x {
207                    let chunk_idx = IVec3::new(cx, cy, cz);
208                    let chunk_origin = chunk_idx * cs;
209                    let local_centre = centre - chunk_origin;
210                    let (ox, oy, oz) = (chunk_origin.x, chunk_origin.y, chunk_origin.z);
211                    // Translate chunk-local coords back to grid-local
212                    // so the user's closure sees a continuous frame.
213                    let mut shim = |lx: i32, ly: i32, lz: i32| colfunc(lx + ox, ly + oy, lz + oz);
214                    let mut wrote = false;
215                    if inserting {
216                        let vxl = self.ensure_chunk(chunk_idx);
217                        set_sphere_with_colfunc(vxl, local_centre.into(), radius, op, &mut shim);
218                        wrote = true;
219                    } else if let Some(vxl) = self.chunks.get_mut(&chunk_idx) {
220                        set_sphere_with_colfunc(vxl, local_centre.into(), radius, op, &mut shim);
221                        wrote = true;
222                    }
223                    if wrote {
224                        self.bump_chunk_version(chunk_idx);
225                    }
226                }
227            }
228        }
229    }
230
231    /// Carve or insert an axis-aligned box `[lo, hi]` (inclusive) with
232    /// a per-voxel colour callback — the colfunc counterpart of
233    /// [`Grid::set_rect`], forwarding to
234    /// [`roxlap_formats::edit::set_rect_with_colfunc`]. See
235    /// [`Grid::set_sphere_with_colfunc`] for the coordinate and
236    /// chunk-materialisation contract; `colfunc` likewise receives
237    /// grid-local coordinates.
238    pub fn set_rect_with_colfunc<F>(&mut self, lo: IVec3, hi: IVec3, op: SpanOp, mut colfunc: F)
239    where
240        F: FnMut(i32, i32, i32) -> i32,
241    {
242        // S6.2: edit invalidates billboard cache (see set_voxel doc).
243        self.billboards = None;
244        let lo_n = lo.min(hi);
245        let hi_n = lo.max(hi);
246        let (lo_c, _) = voxel_split(lo_n);
247        let (hi_c, _) = voxel_split(hi_n);
248        let cs = chunk_size_ivec3();
249        let inserting = op == SpanOp::Insert;
250
251        for cz in lo_c.z..=hi_c.z {
252            for cy in lo_c.y..=hi_c.y {
253                for cx in lo_c.x..=hi_c.x {
254                    let chunk_idx = IVec3::new(cx, cy, cz);
255                    let chunk_origin = chunk_idx * cs;
256                    let chunk_end = chunk_origin + cs - IVec3::ONE;
257                    let local_lo = lo_n.max(chunk_origin) - chunk_origin;
258                    let local_hi = hi_n.min(chunk_end) - chunk_origin;
259                    let (ox, oy, oz) = (chunk_origin.x, chunk_origin.y, chunk_origin.z);
260                    let mut shim = |lx: i32, ly: i32, lz: i32| colfunc(lx + ox, ly + oy, lz + oz);
261                    let mut wrote = false;
262                    if inserting {
263                        let vxl = self.ensure_chunk(chunk_idx);
264                        set_rect_with_colfunc(vxl, local_lo.into(), local_hi.into(), op, &mut shim);
265                        wrote = true;
266                    } else if let Some(vxl) = self.chunks.get_mut(&chunk_idx) {
267                        set_rect_with_colfunc(vxl, local_lo.into(), local_hi.into(), op, &mut shim);
268                        wrote = true;
269                    }
270                    if wrote {
271                        self.bump_chunk_version(chunk_idx);
272                    }
273                }
274            }
275        }
276    }
277}
278
279fn apply_set_rect(
280    grid: &mut Grid,
281    chunk_idx: IVec3,
282    local_lo: IVec3,
283    local_hi: IVec3,
284    color: Option<u32>,
285) {
286    let mut wrote = false;
287    if color.is_some() {
288        let vxl = grid.ensure_chunk(chunk_idx);
289        set_rect(vxl, local_lo.into(), local_hi.into(), color);
290        wrote = true;
291    } else if let Some(vxl) = grid.chunks.get_mut(&chunk_idx) {
292        set_rect(vxl, local_lo.into(), local_hi.into(), None);
293        wrote = true;
294    }
295    if wrote {
296        // S7.2: only writes bump. Carve on a missing chunk is a
297        // pure no-op (no entry to advance). PF.12 — chunk-local extent.
298        let (lo, hi) = dirty_pad(local_lo, local_hi);
299        grid.bump_chunk_version_bbox(chunk_idx, lo, hi);
300    }
301}
302
303fn apply_set_sphere(
304    grid: &mut Grid,
305    chunk_idx: IVec3,
306    local_centre: IVec3,
307    radius: u32,
308    color: Option<u32>,
309) {
310    let mut wrote = false;
311    if color.is_some() {
312        let vxl = grid.ensure_chunk(chunk_idx);
313        set_sphere(vxl, local_centre.into(), radius, color);
314        wrote = true;
315    } else if let Some(vxl) = grid.chunks.get_mut(&chunk_idx) {
316        set_sphere(vxl, local_centre.into(), radius, None);
317        wrote = true;
318    }
319    if wrote {
320        // S7.2: see apply_set_rect rationale. PF.12 — the sphere's
321        // chunk-local AABB.
322        #[allow(clippy::cast_possible_wrap)]
323        let r = radius as i32;
324        let (lo, hi) = dirty_pad(
325            local_centre - IVec3::splat(r),
326            local_centre + IVec3::splat(r),
327        );
328        grid.bump_chunk_version_bbox(chunk_idx, lo, hi);
329    }
330}
331
332/// PF.12 — pad an edit's geometric extent by one voxel (an edit rewrites
333/// the exposed-face records of ADJACENT columns/voxels too) and clamp it
334/// to the chunk footprint.
335fn dirty_pad(lo: IVec3, hi: IVec3) -> (IVec3, IVec3) {
336    let cs = chunk_size_ivec3();
337    (
338        (lo - IVec3::ONE).max(IVec3::ZERO),
339        (hi + IVec3::ONE).min(cs - IVec3::ONE),
340    )
341}
342
343/// Convenience: forward a [`GridLocalPos`]-style decomposition
344/// back to [`voxel_global`] for callers that already hold one.
345/// Stays here rather than in [`crate::addr`] because it's only
346/// useful in the edit-API call shape.
347///
348/// [`voxel_global`]: crate::addr::voxel_global
349#[must_use]
350pub fn voxel_at(local: &GridLocalPos) -> IVec3 {
351    crate::addr::voxel_global(local.chunk, local.voxel)
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::chunks::tests::voxel_is_solid;
358    use crate::GridTransform;
359
360    const TEST_COL: u32 = 0x80_aa_bb_cc;
361
362    #[test]
363    fn set_voxel_inserts_in_correct_chunk() {
364        // Voxel at grid-local (5, 6, 7) sits in chunk (0, 0, 0)
365        // at local (5, 6, 7).
366        let mut g = Grid::new(GridTransform::identity());
367        g.set_voxel(IVec3::new(5, 6, 7), Some(TEST_COL));
368        let vxl = g.chunk(IVec3::ZERO).expect("chunk created");
369        assert!(voxel_is_solid(vxl, 5, 6, 7));
370        // Adjacent voxel still air.
371        assert!(!voxel_is_solid(vxl, 5, 6, 8));
372        assert_eq!(g.chunk_count(), 1);
373    }
374
375    #[test]
376    fn set_voxel_negative_coords_use_neg_chunk() {
377        // Voxel (-1, 0, 0) sits in chunk (-1, 0, 0) at local
378        // (CHUNK_SIZE_XY - 1, 0, 0).
379        let mut g = Grid::new(GridTransform::identity());
380        g.set_voxel(IVec3::new(-1, 0, 0), Some(TEST_COL));
381        assert!(g.chunk(IVec3::new(-1, 0, 0)).is_some());
382        let vxl = g.chunk(IVec3::new(-1, 0, 0)).unwrap();
383        assert!(voxel_is_solid(vxl, CHUNK_SIZE_XY - 1, 0, 0));
384        // Chunk (0, 0, 0) was NOT created.
385        assert!(g.chunk(IVec3::ZERO).is_none());
386    }
387
388    #[test]
389    fn set_voxel_carve_then_insert_round_trips() {
390        let mut g = Grid::new(GridTransform::identity());
391        g.set_voxel(IVec3::new(10, 10, 10), Some(TEST_COL));
392        assert!(voxel_is_solid(g.chunk(IVec3::ZERO).unwrap(), 10, 10, 10));
393        g.set_voxel(IVec3::new(10, 10, 10), None);
394        assert!(!voxel_is_solid(g.chunk(IVec3::ZERO).unwrap(), 10, 10, 10));
395    }
396
397    #[test]
398    fn set_voxel_carve_in_missing_chunk_is_noop() {
399        // Carving in a chunk that doesn't exist should NOT create
400        // it (it's already implicit-air; nothing to do).
401        let mut g = Grid::new(GridTransform::identity());
402        g.set_voxel(IVec3::new(5, 5, 5), None);
403        assert_eq!(g.chunk_count(), 0);
404    }
405
406    /// AO regression on real `set_rect` geometry (floor + pillar): AO must
407    /// darken **only concave** edges — the pillar's convex top + its flat
408    /// vertical faces (above the floor contact) stay open; only the floor /
409    /// pillar-base contact occludes. Guards the "pillow border on every edge"
410    /// bug (the estnorm normal tilts near a convex edge and used to count the
411    /// voxel's own folded surface as occlusion).
412    #[test]
413    fn ao_only_concave_on_setrect_pillar() {
414        let mut g = Grid::new(GridTransform::identity());
415        g.set_rect(
416            IVec3::new(0, 0, 60),
417            IVec3::new(64, 64, 63),
418            Some(0x80_4d_8a_3a),
419        ); // floor z60..62
420        g.set_rect(
421            IVec3::new(20, 20, 30),
422            IVec3::new(30, 30, 60),
423            Some(0x80_8a_8a_92),
424        ); // pillar z30..59
425        let vxl = g.chunk(IVec3::ZERO).expect("chunk");
426        let cache = roxlap_core::EstNormCache::build(
427            &vxl.data,
428            &vxl.column_offset,
429            CHUNK_SIZE_XY,
430            16,
431            16,
432            40,
433            40,
434        );
435        let ao = |x, y, z| cache.ambient_occlusion(x, y, z, 1);
436
437        // Convex top face (z=30) + flat vertical faces (z 31..57, clear of the
438        // floor at z60) must NOT occlude.
439        for x in 20..30 {
440            for y in 20..30 {
441                assert!(
442                    ao(x, y, 30) < 0.01,
443                    "convex top ({x},{y},30) occluded: {}",
444                    ao(x, y, 30)
445                );
446            }
447            for z in 31..57 {
448                let a = ao(x, 20, z);
449                assert!(
450                    a < 0.01,
451                    "flat front face ({x},20,{z}) occluded (pillow): {a}"
452                );
453            }
454        }
455        // Concave floor-to-pillar contact occludes.
456        assert!(
457            ao(19, 24, 60) > 0.1,
458            "concave base must occlude: {}",
459            ao(19, 24, 60)
460        );
461    }
462
463    #[test]
464    fn set_rect_within_one_chunk() {
465        let mut g = Grid::new(GridTransform::identity());
466        g.set_rect(IVec3::new(0, 0, 0), IVec3::new(3, 3, 3), Some(TEST_COL));
467        assert_eq!(g.chunk_count(), 1);
468        let vxl = g.chunk(IVec3::ZERO).unwrap();
469        for z in 0..=3 {
470            for y in 0..=3 {
471                for x in 0..=3 {
472                    assert!(voxel_is_solid(vxl, x, y, z), "({x},{y},{z}) air");
473                }
474            }
475        }
476        // Just outside the rect.
477        assert!(!voxel_is_solid(vxl, 4, 0, 0));
478        assert!(!voxel_is_solid(vxl, 0, 4, 0));
479        assert!(!voxel_is_solid(vxl, 0, 0, 4));
480    }
481
482    #[test]
483    fn set_rect_spans_two_chunks_x() {
484        // Box [(126, 0, 0) .. (129, 0, 0)] crosses the chunk-0 /
485        // chunk-1 boundary on x at 128.
486        let mut g = Grid::new(GridTransform::identity());
487        g.set_rect(IVec3::new(126, 0, 0), IVec3::new(129, 0, 0), Some(TEST_COL));
488        assert_eq!(g.chunk_count(), 2);
489
490        // Chunk (0,0,0): voxels x=126, 127 at (y=0, z=0) solid.
491        let v0 = g.chunk(IVec3::ZERO).unwrap();
492        assert!(voxel_is_solid(v0, 126, 0, 0));
493        assert!(voxel_is_solid(v0, 127, 0, 0));
494        assert!(!voxel_is_solid(v0, 125, 0, 0));
495
496        // Chunk (1,0,0): voxels x=0, 1 at (y=0, z=0) solid.
497        let v1 = g.chunk(IVec3::new(1, 0, 0)).unwrap();
498        assert!(voxel_is_solid(v1, 0, 0, 0));
499        assert!(voxel_is_solid(v1, 1, 0, 0));
500        assert!(!voxel_is_solid(v1, 2, 0, 0));
501    }
502
503    #[test]
504    fn set_rect_spans_z_boundary() {
505        // Box at z=255..256 crosses chunk boundary on z (256 = 1
506        // chunk on z-axis).
507        let mut g = Grid::new(GridTransform::identity());
508        g.set_rect(IVec3::new(0, 0, 254), IVec3::new(0, 0, 257), Some(TEST_COL));
509        assert_eq!(g.chunk_count(), 2);
510        let v0 = g.chunk(IVec3::ZERO).unwrap();
511        assert!(voxel_is_solid(v0, 0, 0, 254));
512        assert!(voxel_is_solid(v0, 0, 0, 255));
513        let v1 = g.chunk(IVec3::new(0, 0, 1)).unwrap();
514        assert!(voxel_is_solid(v1, 0, 0, 0));
515        assert!(voxel_is_solid(v1, 0, 0, 1));
516        assert!(!voxel_is_solid(v1, 0, 0, 2));
517    }
518
519    #[test]
520    fn set_rect_unsorted_lo_hi_normalised() {
521        // Passing hi < lo should produce the same result as lo < hi.
522        let mut g1 = Grid::new(GridTransform::identity());
523        let mut g2 = Grid::new(GridTransform::identity());
524        g1.set_rect(IVec3::new(0, 0, 0), IVec3::new(3, 3, 3), Some(TEST_COL));
525        g2.set_rect(IVec3::new(3, 3, 3), IVec3::new(0, 0, 0), Some(TEST_COL));
526        let v1 = g1.chunk(IVec3::ZERO).unwrap();
527        let v2 = g2.chunk(IVec3::ZERO).unwrap();
528        for z in 0..=3 {
529            for y in 0..=3 {
530                for x in 0..=3 {
531                    assert_eq!(voxel_is_solid(v1, x, y, z), voxel_is_solid(v2, x, y, z));
532                }
533            }
534        }
535    }
536
537    #[test]
538    fn set_sphere_within_one_chunk() {
539        let mut g = Grid::new(GridTransform::identity());
540        g.set_sphere(IVec3::new(64, 64, 100), 5, Some(TEST_COL));
541        assert_eq!(g.chunk_count(), 1);
542        let vxl = g.chunk(IVec3::ZERO).unwrap();
543        // Centre is solid.
544        assert!(voxel_is_solid(vxl, 64, 64, 100));
545        // 1 voxel from centre is solid (radius 5).
546        assert!(voxel_is_solid(vxl, 65, 64, 100));
547        assert!(voxel_is_solid(vxl, 64, 64, 105));
548        // Just outside radius is air.
549        assert!(!voxel_is_solid(vxl, 70, 64, 100));
550    }
551
552    #[test]
553    fn set_sphere_spans_chunk_boundary() {
554        // Centre at (127, 64, 100), radius 4 → reaches into chunk
555        // (1,0,0) on the +x side.
556        let mut g = Grid::new(GridTransform::identity());
557        g.set_sphere(IVec3::new(127, 64, 100), 4, Some(TEST_COL));
558        // 2 chunks: (0,0,0) and (1,0,0).
559        assert_eq!(g.chunk_count(), 2);
560
561        let v0 = g.chunk(IVec3::ZERO).unwrap();
562        // (127, 64, 100) is the centre, in chunk 0 at local
563        // (127, 64, 100).
564        assert!(voxel_is_solid(v0, 127, 64, 100));
565        // (124, 64, 100) is 3 voxels away, inside the sphere.
566        assert!(voxel_is_solid(v0, 124, 64, 100));
567
568        let v1 = g.chunk(IVec3::new(1, 0, 0)).unwrap();
569        // Voxel (128, 64, 100) is centre + 1x → in chunk 1 at
570        // local (0, 64, 100).
571        assert!(voxel_is_solid(v1, 0, 64, 100));
572        // Voxel (130, 64, 100) is 3 voxels from centre, still inside.
573        assert!(voxel_is_solid(v1, 2, 64, 100));
574    }
575
576    // ---- S6.2: billboard cache invalidation ----
577
578    /// Helper: stamp a sentinel billboard cache onto a grid so the
579    /// invalidation tests can detect when the edit cleared it. We
580    /// use a 32-resolution `new_empty` cache — populating real
581    /// snapshots would work too but is needlessly expensive when
582    /// the test only cares about Some/None state.
583    fn stamp_sentinel_cache(g: &mut Grid) {
584        g.billboards = Some(crate::BillboardCache::new_empty(32));
585    }
586
587    #[test]
588    fn set_voxel_invalidates_billboard_cache() {
589        let mut g = Grid::new(GridTransform::identity());
590        stamp_sentinel_cache(&mut g);
591        assert!(g.billboards.is_some());
592        g.set_voxel(IVec3::new(5, 5, 5), Some(TEST_COL));
593        assert!(
594            g.billboards.is_none(),
595            "set_voxel should clear the billboard cache"
596        );
597    }
598
599    #[test]
600    fn set_voxel_carve_also_invalidates() {
601        // Even no-op carves invalidate — the cache must be conservative.
602        let mut g = Grid::new(GridTransform::identity());
603        stamp_sentinel_cache(&mut g);
604        g.set_voxel(IVec3::new(5, 5, 5), None); // missing-chunk no-op
605        assert!(
606            g.billboards.is_none(),
607            "carve should clear the cache (conservative)"
608        );
609    }
610
611    #[test]
612    fn set_rect_invalidates_billboard_cache() {
613        let mut g = Grid::new(GridTransform::identity());
614        stamp_sentinel_cache(&mut g);
615        g.set_rect(IVec3::new(0, 0, 0), IVec3::new(3, 3, 3), Some(TEST_COL));
616        assert!(g.billboards.is_none(), "set_rect should clear the cache");
617    }
618
619    #[test]
620    fn set_sphere_invalidates_billboard_cache() {
621        let mut g = Grid::new(GridTransform::identity());
622        stamp_sentinel_cache(&mut g);
623        g.set_sphere(IVec3::new(64, 64, 100), 5, Some(TEST_COL));
624        assert!(g.billboards.is_none(), "set_sphere should clear the cache");
625    }
626
627    #[test]
628    fn set_voxel_dispatches_to_correct_chunk_on_y_z_axes() {
629        // Sanity check the y / z chunk dispatches use the right
630        // chunk size (XY=128, Z=256). voxel (200, 300, 500)
631        // should go to chunk (1, 2, 1), local (72, 44, 244).
632        let mut g = Grid::new(GridTransform::identity());
633        g.set_voxel(IVec3::new(200, 300, 500), Some(TEST_COL));
634        let vxl = g
635            .chunk(IVec3::new(1, 2, 1))
636            .expect("expected chunk (1, 2, 1)");
637        assert!(voxel_is_solid(vxl, 72, 44, 244));
638    }
639
640    // ---- S7.2: chunk version counter bumps ----
641
642    #[test]
643    fn chunk_version_defaults_to_zero_for_missing() {
644        let g = Grid::new(GridTransform::identity());
645        assert_eq!(g.chunk_version(IVec3::ZERO), 0);
646        assert_eq!(g.chunk_version(IVec3::new(7, -3, 12)), 0);
647    }
648
649    #[test]
650    fn set_voxel_insert_bumps_to_one() {
651        let mut g = Grid::new(GridTransform::identity());
652        assert_eq!(g.chunk_version(IVec3::ZERO), 0);
653        g.set_voxel(IVec3::new(5, 5, 5), Some(TEST_COL));
654        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
655    }
656
657    #[test]
658    fn set_voxel_carve_in_existing_chunk_bumps() {
659        // Sequence (insert, carve) → version 2.
660        let mut g = Grid::new(GridTransform::identity());
661        g.set_voxel(IVec3::new(5, 5, 5), Some(TEST_COL));
662        g.set_voxel(IVec3::new(5, 5, 5), None);
663        assert_eq!(g.chunk_version(IVec3::ZERO), 2);
664    }
665
666    #[test]
667    fn set_voxel_carve_in_missing_chunk_does_not_bump() {
668        // No-op edit path — no chunk created, no version bump.
669        let mut g = Grid::new(GridTransform::identity());
670        g.set_voxel(IVec3::new(5, 5, 5), None);
671        assert_eq!(g.chunk_version(IVec3::ZERO), 0);
672        assert!(g.chunk_versions.is_empty());
673    }
674
675    #[test]
676    fn set_rect_multi_chunk_bumps_every_touched_chunk() {
677        // Box crossing the x=128 boundary → two chunks both bumped.
678        let mut g = Grid::new(GridTransform::identity());
679        g.set_rect(IVec3::new(126, 0, 0), IVec3::new(129, 0, 0), Some(TEST_COL));
680        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
681        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
682        // No other chunks touched.
683        assert_eq!(g.chunk_versions.len(), 2);
684    }
685
686    #[test]
687    fn set_rect_carve_bumps_only_existing_chunks() {
688        // Insert into chunk 0; then carve a 2-chunk-wide rect that
689        // overlaps chunk 0 + chunk 1. Chunk 0 (exists) should bump
690        // again; chunk 1 (still implicit-air) should NOT.
691        let mut g = Grid::new(GridTransform::identity());
692        g.set_voxel(IVec3::new(0, 0, 0), Some(TEST_COL));
693        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
694        g.set_rect(IVec3::new(126, 0, 0), IVec3::new(129, 0, 0), None);
695        assert_eq!(g.chunk_version(IVec3::ZERO), 2);
696        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 0);
697    }
698
699    // ---- variant (a): colfunc carve/insert on Grid ----
700
701    /// Carving a sphere with a colfunc paints the newly-exposed
702    /// interior walls with the closure's colour, whereas a plain
703    /// `set_sphere(None)` carve leaves them colour 0 (read back as
704    /// `None` / untextured by `voxel_color`).
705    #[test]
706    fn set_sphere_with_colfunc_paints_exposed_interior() {
707        const CRATER: i32 = 0x00_44_55_66;
708        // A solid block; (64,64,55) starts as a buried interior voxel.
709        let mut g = Grid::new(GridTransform::identity());
710        g.set_rect(
711            IVec3::new(40, 40, 40),
712            IVec3::new(90, 90, 90),
713            Some(TEST_COL),
714        );
715        assert!(g.voxel_color(IVec3::new(64, 64, 55)).is_none()); // interior: not a surface texel yet
716
717        g.set_sphere_with_colfunc(IVec3::new(64, 64, 64), 8, SpanOp::Carve, |_x, _y, _z| {
718            CRATER
719        });
720
721        // Centre carved away.
722        assert!(!g.voxel_solid(IVec3::new(64, 64, 64)));
723        // (64,64,55) is just below the carved z-range [56,72]: still
724        // solid, now exposed upward, and painted CRATER.
725        assert!(g.voxel_solid(IVec3::new(64, 64, 55)));
726        assert_eq!(g.voxel_color(IVec3::new(64, 64, 55)), Some(CRATER as u32));
727
728        // Contrast: a plain None-carve exposes the same voxel as
729        // solid-but-black (voxel_color → None).
730        let mut g2 = Grid::new(GridTransform::identity());
731        g2.set_rect(
732            IVec3::new(40, 40, 40),
733            IVec3::new(90, 90, 90),
734            Some(TEST_COL),
735        );
736        g2.set_sphere(IVec3::new(64, 64, 64), 8, None);
737        assert!(g2.voxel_solid(IVec3::new(64, 64, 55)));
738        assert_eq!(g2.voxel_color(IVec3::new(64, 64, 55)), None);
739    }
740
741    /// The colfunc must receive **grid-local** coordinates, not the
742    /// per-chunk-local coordinates the decomposition uses internally.
743    /// Carve a sphere straddling the x=128 chunk seam and encode the
744    /// coordinate into the colour: an exposed voxel in chunk (1,0,0)
745    /// must read back its grid-local position, not its chunk-local one.
746    #[test]
747    fn set_sphere_with_colfunc_uses_grid_local_coords_across_chunks() {
748        // Encode grid-local (x,y,z) into the low 24 bits.
749        #[allow(clippy::cast_sign_loss)]
750        let encode = |x: i32, y: i32, z: i32| (x << 16) | (y << 8) | z;
751
752        let mut g = Grid::new(GridTransform::identity());
753        // Solid block spanning chunks (0,0,0) and (1,0,0) (seam at 128).
754        g.set_rect(
755            IVec3::new(120, 60, 60),
756            IVec3::new(140, 80, 80),
757            Some(TEST_COL),
758        );
759        // Centre on the seam; reaches into chunk 1 (+x side).
760        g.set_sphere_with_colfunc(IVec3::new(128, 70, 70), 5, SpanOp::Carve, |x, y, z| {
761            encode(x, y, z)
762        });
763
764        // Column (130,70) in chunk 1 (local x=2) carves z∈[66,74];
765        // z=65 is just below → exposed, solid, painted with its
766        // GRID-LOCAL coords. The chunk-local bug would store
767        // encode(2,70,65) instead.
768        let p = IVec3::new(130, 70, 65);
769        assert!(g.voxel_solid(p));
770        #[allow(clippy::cast_sign_loss)]
771        let want = encode(130, 70, 65) as u32;
772        assert_eq!(g.voxel_color(p), Some(want));
773        // Sanity: it is NOT the chunk-local encoding.
774        #[allow(clippy::cast_sign_loss)]
775        let chunk_local = encode(2, 70, 65) as u32;
776        assert_ne!(g.voxel_color(p), Some(chunk_local));
777    }
778
779    #[test]
780    fn set_rect_with_colfunc_carve_paints_exposed_face() {
781        const WALL: i32 = 0x00_12_34_56;
782        let mut g = Grid::new(GridTransform::identity());
783        g.set_rect(
784            IVec3::new(40, 40, 40),
785            IVec3::new(90, 90, 90),
786            Some(TEST_COL),
787        );
788        // Carve a box out of the middle; (64,64,49) sits just below it.
789        g.set_rect_with_colfunc(
790            IVec3::new(50, 50, 50),
791            IVec3::new(80, 80, 80),
792            SpanOp::Carve,
793            |_x, _y, _z| WALL,
794        );
795        assert!(!g.voxel_solid(IVec3::new(64, 64, 64)));
796        assert!(g.voxel_solid(IVec3::new(64, 64, 49)));
797        assert_eq!(g.voxel_color(IVec3::new(64, 64, 49)), Some(WALL as u32));
798    }
799
800    #[test]
801    fn set_sphere_with_colfunc_invalidates_billboard_cache() {
802        let mut g = Grid::new(GridTransform::identity());
803        g.set_rect(
804            IVec3::new(40, 40, 40),
805            IVec3::new(90, 90, 90),
806            Some(TEST_COL),
807        );
808        stamp_sentinel_cache(&mut g);
809        g.set_sphere_with_colfunc(IVec3::new(64, 64, 64), 6, SpanOp::Carve, |_, _, _| 1);
810        assert!(g.billboards.is_none());
811    }
812
813    #[test]
814    fn set_sphere_multi_chunk_bumps_every_written_chunk() {
815        // Sphere centred on (127, 64, 100) radius 4 → touches
816        // chunks (0,0,0) and (1,0,0).
817        let mut g = Grid::new(GridTransform::identity());
818        g.set_sphere(IVec3::new(127, 64, 100), 4, Some(TEST_COL));
819        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
820        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
821    }
822}