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