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