Skip to main content

proof_engine/svogi/
voxelize.rs

1use glam::{Vec3, Vec4, IVec3, UVec3};
2use super::octree::{Aabb, VoxelData, VoxelGrid};
3
4/// Configuration for voxelization.
5#[derive(Debug, Clone)]
6pub struct VoxelizeConfig {
7    pub resolution: u32,
8    pub world_bounds: Aabb,
9    pub conservative: bool,
10}
11
12/// A triangle with vertex positions, normal, color, and emission.
13#[derive(Debug, Clone, Copy)]
14pub struct Triangle {
15    pub v0: Vec3,
16    pub v1: Vec3,
17    pub v2: Vec3,
18    pub normal: Vec3,
19    pub color: Vec4,
20    pub emission: Vec4,
21}
22
23impl Triangle {
24    pub fn aabb(&self) -> Aabb {
25        Aabb {
26            min: self.v0.min(self.v1).min(self.v2),
27            max: self.v0.max(self.v1).max(self.v2),
28        }
29    }
30
31    pub fn compute_normal(&self) -> Vec3 {
32        let e1 = self.v1 - self.v0;
33        let e2 = self.v2 - self.v0;
34        e1.cross(e2).normalize_or_zero()
35    }
36}
37
38/// Statistics from voxelization.
39#[derive(Debug, Clone, Default)]
40pub struct VoxelizeStats {
41    pub triangles_processed: u32,
42    pub voxels_written: u32,
43    pub fill_ratio: f32,
44}
45
46/// GPU voxelization uniform parameters (for compute shader path).
47#[derive(Debug, Clone, Copy)]
48pub struct GpuVoxelizeParams {
49    pub resolution: u32,
50    pub world_min: Vec3,
51    pub world_max: Vec3,
52    pub proj_x: glam::Mat4,
53    pub proj_y: glam::Mat4,
54    pub proj_z: glam::Mat4,
55}
56
57impl GpuVoxelizeParams {
58    pub fn from_config(config: &VoxelizeConfig) -> Self {
59        let size = config.world_bounds.size();
60        let center = config.world_bounds.center();
61        let half = size * 0.5;
62
63        // Orthographic projections along each axis
64        let proj_x = glam::Mat4::orthographic_rh(
65            -half.y, half.y, -half.z, half.z, 0.0, size.x,
66        );
67        let proj_y = glam::Mat4::orthographic_rh(
68            -half.x, half.x, -half.z, half.z, 0.0, size.y,
69        );
70        let proj_z = glam::Mat4::orthographic_rh(
71            -half.x, half.x, -half.y, half.y, 0.0, size.z,
72        );
73
74        Self {
75            resolution: config.resolution,
76            world_min: config.world_bounds.min,
77            world_max: config.world_bounds.max,
78            proj_x,
79            proj_y,
80            proj_z,
81        }
82    }
83}
84
85/// Voxelize a set of triangles into a dense grid.
86pub fn voxelize_triangles(triangles: &[Triangle], config: &VoxelizeConfig) -> VoxelGrid {
87    let res = config.resolution;
88    let mut grid = VoxelGrid::new(UVec3::new(res, res, res));
89    let world_size = config.world_bounds.size();
90    let voxel_size = world_size / Vec3::splat(res as f32);
91    let half_voxel = voxel_size * 0.5;
92
93    for tri in triangles {
94        if config.conservative {
95            let voxels = conservative_voxelize(tri, config);
96            for iv in voxels {
97                if grid.in_bounds(iv.x, iv.y, iv.z) {
98                    let vd = grid.get_mut(iv.x as u32, iv.y as u32, iv.z as u32);
99                    vd.radiance = tri.color + tri.emission;
100                    vd.normal = tri.normal;
101                    vd.opacity = tri.color.w;
102                }
103            }
104        } else {
105            let tri_aabb = tri.aabb();
106
107            // Convert triangle AABB to voxel coordinates
108            let vmin = ((tri_aabb.min - config.world_bounds.min) / voxel_size).floor();
109            let vmax = ((tri_aabb.max - config.world_bounds.min) / voxel_size).ceil();
110
111            let ix_min = (vmin.x as i32).max(0);
112            let iy_min = (vmin.y as i32).max(0);
113            let iz_min = (vmin.z as i32).max(0);
114            let ix_max = (vmax.x as i32).min(res as i32 - 1);
115            let iy_max = (vmax.y as i32).min(res as i32 - 1);
116            let iz_max = (vmax.z as i32).min(res as i32 - 1);
117
118            for z in iz_min..=iz_max {
119                for y in iy_min..=iy_max {
120                    for x in ix_min..=ix_max {
121                        let box_center = config.world_bounds.min + Vec3::new(
122                            (x as f32 + 0.5) * voxel_size.x,
123                            (y as f32 + 0.5) * voxel_size.y,
124                            (z as f32 + 0.5) * voxel_size.z,
125                        );
126                        if triangle_box_intersection(tri, box_center, half_voxel) {
127                            let vd = grid.get_mut(x as u32, y as u32, z as u32);
128                            vd.radiance = tri.color + tri.emission;
129                            vd.normal = tri.normal;
130                            vd.opacity = tri.color.w;
131                        }
132                    }
133                }
134            }
135        }
136    }
137
138    grid
139}
140
141/// SAT-based triangle-box intersection test.
142pub fn triangle_box_intersection(tri: &Triangle, box_center: Vec3, box_half: Vec3) -> bool {
143    // Translate triangle so box center is at origin
144    let v0 = tri.v0 - box_center;
145    let v1 = tri.v1 - box_center;
146    let v2 = tri.v2 - box_center;
147
148    let e0 = v1 - v0;
149    let e1 = v2 - v1;
150    let e2 = v0 - v2;
151
152    let h = box_half;
153
154    // Test 9 cross-product axes (edge x box-face-normal)
155    let axes = [
156        Vec3::new(0.0, -e0.z, e0.y),
157        Vec3::new(0.0, -e1.z, e1.y),
158        Vec3::new(0.0, -e2.z, e2.y),
159        Vec3::new(e0.z, 0.0, -e0.x),
160        Vec3::new(e1.z, 0.0, -e1.x),
161        Vec3::new(e2.z, 0.0, -e2.x),
162        Vec3::new(-e0.y, e0.x, 0.0),
163        Vec3::new(-e1.y, e1.x, 0.0),
164        Vec3::new(-e2.y, e2.x, 0.0),
165    ];
166
167    for axis in &axes {
168        let p0 = v0.dot(*axis);
169        let p1 = v1.dot(*axis);
170        let p2 = v2.dot(*axis);
171        let r = h.x * axis.x.abs() + h.y * axis.y.abs() + h.z * axis.z.abs();
172        let min_p = p0.min(p1).min(p2);
173        let max_p = p0.max(p1).max(p2);
174        if min_p > r || max_p < -r {
175            return false;
176        }
177    }
178
179    // Test 3 box face normals (AABB axes)
180    if v0.x.min(v1.x).min(v2.x) > h.x || v0.x.max(v1.x).max(v2.x) < -h.x {
181        return false;
182    }
183    if v0.y.min(v1.y).min(v2.y) > h.y || v0.y.max(v1.y).max(v2.y) < -h.y {
184        return false;
185    }
186    if v0.z.min(v1.z).min(v2.z) > h.z || v0.z.max(v1.z).max(v2.z) < -h.z {
187        return false;
188    }
189
190    // Test triangle normal
191    let tri_normal = e0.cross(e1);
192    let d = tri_normal.dot(v0);
193    let r = h.x * tri_normal.x.abs() + h.y * tri_normal.y.abs() + h.z * tri_normal.z.abs();
194    if d.abs() > r {
195        return false;
196    }
197
198    true
199}
200
201/// Conservative voxelization: dilated rasterization producing all voxels a triangle might touch.
202pub fn conservative_voxelize(tri: &Triangle, config: &VoxelizeConfig) -> Vec<IVec3> {
203    let res = config.resolution;
204    let world_size = config.world_bounds.size();
205    let voxel_size = world_size / Vec3::splat(res as f32);
206    let half_voxel = voxel_size * 0.5;
207    // Expand the triangle AABB by one voxel in each direction for conservative coverage
208    let tri_aabb = tri.aabb();
209    let expanded_min = tri_aabb.min - voxel_size;
210    let expanded_max = tri_aabb.max + voxel_size;
211
212    let vmin = ((expanded_min - config.world_bounds.min) / voxel_size).floor();
213    let vmax = ((expanded_max - config.world_bounds.min) / voxel_size).ceil();
214
215    let ix_min = (vmin.x as i32).max(0);
216    let iy_min = (vmin.y as i32).max(0);
217    let iz_min = (vmin.z as i32).max(0);
218    let ix_max = (vmax.x as i32).min(res as i32 - 1);
219    let iy_max = (vmax.y as i32).min(res as i32 - 1);
220    let iz_max = (vmax.z as i32).min(res as i32 - 1);
221
222    let mut result = Vec::new();
223    for z in iz_min..=iz_max {
224        for y in iy_min..=iy_max {
225            for x in ix_min..=ix_max {
226                let box_center = config.world_bounds.min + Vec3::new(
227                    (x as f32 + 0.5) * voxel_size.x,
228                    (y as f32 + 0.5) * voxel_size.y,
229                    (z as f32 + 0.5) * voxel_size.z,
230                );
231                // Use slightly expanded half-voxel for conservative test
232                let expanded_half = half_voxel * 1.5;
233                if triangle_box_intersection(tri, box_center, expanded_half) {
234                    result.push(IVec3::new(x, y, z));
235                }
236            }
237        }
238    }
239    result
240}
241
242/// Voxelize spheres into a grid.
243pub fn voxelize_spheres(spheres: &[(Vec3, f32, Vec4)], config: &VoxelizeConfig) -> VoxelGrid {
244    let res = config.resolution;
245    let mut grid = VoxelGrid::new(UVec3::new(res, res, res));
246    let world_size = config.world_bounds.size();
247    let voxel_size = world_size / Vec3::splat(res as f32);
248
249    for &(center, radius, color) in spheres {
250        let vmin = ((center - Vec3::splat(radius) - config.world_bounds.min) / voxel_size).floor();
251        let vmax = ((center + Vec3::splat(radius) - config.world_bounds.min) / voxel_size).ceil();
252
253        let ix_min = (vmin.x as i32).max(0);
254        let iy_min = (vmin.y as i32).max(0);
255        let iz_min = (vmin.z as i32).max(0);
256        let ix_max = (vmax.x as i32).min(res as i32 - 1);
257        let iy_max = (vmax.y as i32).min(res as i32 - 1);
258        let iz_max = (vmax.z as i32).min(res as i32 - 1);
259
260        for z in iz_min..=iz_max {
261            for y in iy_min..=iy_max {
262                for x in ix_min..=ix_max {
263                    let world_pos = config.world_bounds.min + Vec3::new(
264                        (x as f32 + 0.5) * voxel_size.x,
265                        (y as f32 + 0.5) * voxel_size.y,
266                        (z as f32 + 0.5) * voxel_size.z,
267                    );
268                    let dist = (world_pos - center).length();
269                    if dist <= radius {
270                        let normal = if dist > 1e-6 {
271                            (world_pos - center).normalize()
272                        } else {
273                            Vec3::Y
274                        };
275                        let vd = grid.get_mut(x as u32, y as u32, z as u32);
276                        vd.radiance = color;
277                        vd.normal = normal;
278                        vd.opacity = color.w;
279                    }
280                }
281            }
282        }
283    }
284    grid
285}
286
287/// Voxelize a point cloud.
288pub fn voxelize_point_cloud(points: &[(Vec3, Vec4)], config: &VoxelizeConfig) -> VoxelGrid {
289    let res = config.resolution;
290    let mut grid = VoxelGrid::new(UVec3::new(res, res, res));
291    let world_size = config.world_bounds.size();
292    let voxel_size = world_size / Vec3::splat(res as f32);
293
294    for &(pos, color) in points {
295        if !config.world_bounds.contains(pos) {
296            continue;
297        }
298        let voxel_pos = ((pos - config.world_bounds.min) / voxel_size).floor();
299        let x = (voxel_pos.x as u32).min(res - 1);
300        let y = (voxel_pos.y as u32).min(res - 1);
301        let z = (voxel_pos.z as u32).min(res - 1);
302
303        let vd = grid.get_mut(x, y, z);
304        vd.radiance = color;
305        vd.normal = Vec3::Y; // default normal for points
306        vd.opacity = color.w;
307    }
308    grid
309}
310
311/// Merge two voxel grids of the same resolution. Non-empty voxels from b overwrite a.
312pub fn merge_grids(a: &VoxelGrid, b: &VoxelGrid) -> VoxelGrid {
313    assert_eq!(a.resolution, b.resolution, "Grids must have same resolution");
314    let mut result = a.clone();
315    for i in 0..result.data.len() {
316        if !b.data[i].is_empty() {
317            if result.data[i].is_empty() {
318                result.data[i] = b.data[i];
319            } else {
320                // Blend: average
321                result.data[i].radiance = (result.data[i].radiance + b.data[i].radiance) * 0.5;
322                result.data[i].normal = (result.data[i].normal + b.data[i].normal).normalize_or_zero();
323                result.data[i].opacity = (result.data[i].opacity + b.data[i].opacity) * 0.5;
324            }
325        }
326    }
327    result
328}
329
330pub fn compute_voxelize_stats(grid: &VoxelGrid, triangles_processed: u32) -> VoxelizeStats {
331    let total = (grid.resolution.x * grid.resolution.y * grid.resolution.z) as u32;
332    let filled = grid.filled_count() as u32;
333    VoxelizeStats {
334        triangles_processed,
335        voxels_written: filled,
336        fill_ratio: if total > 0 { filled as f32 / total as f32 } else { 0.0 },
337    }
338}
339
340/// Embedded compute shader source for GPU voxelization.
341pub const VOXELIZE_COMP_SRC: &str = r#"
342#version 450
343layout(local_size_x = 64) in;
344
345struct Triangle {
346    vec4 v0;
347    vec4 v1;
348    vec4 v2;
349    vec4 normal;
350    vec4 color;
351    vec4 emission;
352};
353
354layout(std430, binding = 0) readonly buffer TriangleBuffer {
355    Triangle triangles[];
356};
357
358layout(r32ui, binding = 1) uniform uimage3D voxelGrid;
359
360uniform mat4 projX;
361uniform mat4 projY;
362uniform mat4 projZ;
363uniform uint resolution;
364uniform vec3 worldMin;
365uniform vec3 worldSize;
366
367void main() {
368    uint triIdx = gl_GlobalInvocationID.x;
369    if (triIdx >= triangles.length()) return;
370
371    Triangle tri = triangles[triIdx];
372    vec3 v0 = tri.v0.xyz;
373    vec3 v1 = tri.v1.xyz;
374    vec3 v2 = tri.v2.xyz;
375
376    // Choose dominant axis for projection (conservative rasterization)
377    vec3 n = abs(tri.normal.xyz);
378    mat4 proj;
379    if (n.x >= n.y && n.x >= n.z) {
380        proj = projX;
381    } else if (n.y >= n.z) {
382        proj = projY;
383    } else {
384        proj = projZ;
385    }
386
387    // Compute AABB in voxel space
388    vec3 voxelSize = worldSize / float(resolution);
389    vec3 minV = min(min(v0, v1), v2);
390    vec3 maxV = max(max(v0, v1), v2);
391    // Conservative expansion
392    minV -= voxelSize;
393    maxV += voxelSize;
394
395    ivec3 iMin = ivec3(clamp((minV - worldMin) / voxelSize, vec3(0), vec3(resolution - 1)));
396    ivec3 iMax = ivec3(clamp((maxV - worldMin) / voxelSize, vec3(0), vec3(resolution - 1)));
397
398    for (int z = iMin.z; z <= iMax.z; z++) {
399        for (int y = iMin.y; y <= iMax.y; y++) {
400            for (int x = iMin.x; x <= iMax.x; x++) {
401                // Pack color into uint for atomic write
402                uint packed = (uint(tri.color.r * 255.0) << 24) |
403                              (uint(tri.color.g * 255.0) << 16) |
404                              (uint(tri.color.b * 255.0) << 8) |
405                              (uint(tri.color.a * 255.0));
406                imageAtomicMax(voxelGrid, ivec3(x, y, z), packed);
407            }
408        }
409    }
410}
411"#;
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    fn make_config(res: u32) -> VoxelizeConfig {
418        VoxelizeConfig {
419            resolution: res,
420            world_bounds: Aabb::new(Vec3::ZERO, Vec3::splat(res as f32)),
421            conservative: false,
422        }
423    }
424
425    #[test]
426    fn test_single_triangle_voxelizes() {
427        let config = make_config(8);
428        let tri = Triangle {
429            v0: Vec3::new(2.0, 2.0, 4.0),
430            v1: Vec3::new(6.0, 2.0, 4.0),
431            v2: Vec3::new(4.0, 6.0, 4.0),
432            normal: Vec3::Z,
433            color: Vec4::new(1.0, 0.0, 0.0, 1.0),
434            emission: Vec4::ZERO,
435        };
436
437        let grid = voxelize_triangles(&[tri], &config);
438        let filled = grid.filled_count();
439        assert!(filled > 0, "Triangle should produce at least one voxel, got {filled}");
440    }
441
442    #[test]
443    fn test_triangle_box_intersection_basic() {
444        let tri = Triangle {
445            v0: Vec3::new(-1.0, 0.0, 0.0),
446            v1: Vec3::new(1.0, 0.0, 0.0),
447            v2: Vec3::new(0.0, 1.0, 0.0),
448            normal: Vec3::Z,
449            color: Vec4::ONE,
450            emission: Vec4::ZERO,
451        };
452        // Box centered at origin with half-size 0.5
453        assert!(triangle_box_intersection(&tri, Vec3::ZERO, Vec3::splat(0.5)));
454        // Box far away
455        assert!(!triangle_box_intersection(&tri, Vec3::splat(10.0), Vec3::splat(0.5)));
456    }
457
458    #[test]
459    fn test_sphere_voxelization() {
460        let config = make_config(16);
461        let spheres = vec![(Vec3::splat(8.0), 3.0, Vec4::new(0.0, 1.0, 0.0, 1.0))];
462        let grid = voxelize_spheres(&spheres, &config);
463        let filled = grid.filled_count();
464        assert!(filled > 10, "Sphere should fill many voxels, got {filled}");
465
466        // Check a voxel at the center is filled
467        let center_voxel = grid.get(8, 8, 8);
468        assert!(!center_voxel.is_empty());
469    }
470
471    #[test]
472    fn test_point_cloud_voxelization() {
473        let config = make_config(8);
474        let points = vec![
475            (Vec3::new(1.0, 1.0, 1.0), Vec4::new(1.0, 0.0, 0.0, 1.0)),
476            (Vec3::new(5.0, 5.0, 5.0), Vec4::new(0.0, 1.0, 0.0, 1.0)),
477        ];
478        let grid = voxelize_point_cloud(&points, &config);
479        assert_eq!(grid.filled_count(), 2);
480    }
481
482    #[test]
483    fn test_empty_scene_empty_grid() {
484        let config = make_config(4);
485        let grid = voxelize_triangles(&[], &config);
486        assert_eq!(grid.filled_count(), 0);
487    }
488
489    #[test]
490    fn test_conservative_voxelize() {
491        let config = VoxelizeConfig {
492            resolution: 8,
493            world_bounds: Aabb::new(Vec3::ZERO, Vec3::splat(8.0)),
494            conservative: true,
495        };
496        let tri = Triangle {
497            v0: Vec3::new(3.0, 3.0, 4.0),
498            v1: Vec3::new(5.0, 3.0, 4.0),
499            v2: Vec3::new(4.0, 5.0, 4.0),
500            normal: Vec3::Z,
501            color: Vec4::ONE,
502            emission: Vec4::ZERO,
503        };
504        let grid = voxelize_triangles(&[tri], &config);
505        let non_conservative_config = VoxelizeConfig {
506            conservative: false,
507            ..config.clone()
508        };
509        let grid_nc = voxelize_triangles(&[tri], &non_conservative_config);
510        // Conservative should produce at least as many voxels
511        assert!(grid.filled_count() >= grid_nc.filled_count());
512    }
513
514    #[test]
515    fn test_merge_grids() {
516        let mut a = VoxelGrid::new(UVec3::new(4, 4, 4));
517        let mut b = VoxelGrid::new(UVec3::new(4, 4, 4));
518        a.set(0, 0, 0, VoxelData {
519            radiance: Vec4::new(1.0, 0.0, 0.0, 1.0),
520            normal: Vec3::Y,
521            opacity: 1.0,
522            sh_coeffs: [0.0; 9],
523        });
524        b.set(1, 1, 1, VoxelData {
525            radiance: Vec4::new(0.0, 0.0, 1.0, 1.0),
526            normal: Vec3::X,
527            opacity: 1.0,
528            sh_coeffs: [0.0; 9],
529        });
530        let merged = merge_grids(&a, &b);
531        assert_eq!(merged.filled_count(), 2);
532    }
533
534    #[test]
535    fn test_voxelize_stats() {
536        let config = make_config(4);
537        let tri = Triangle {
538            v0: Vec3::new(0.5, 0.5, 2.0),
539            v1: Vec3::new(3.5, 0.5, 2.0),
540            v2: Vec3::new(2.0, 3.5, 2.0),
541            normal: Vec3::Z,
542            color: Vec4::ONE,
543            emission: Vec4::ZERO,
544        };
545        let grid = voxelize_triangles(&[tri], &config);
546        let stats = compute_voxelize_stats(&grid, 1);
547        assert_eq!(stats.triangles_processed, 1);
548        assert!(stats.voxels_written > 0);
549        assert!(stats.fill_ratio > 0.0);
550        assert!(stats.fill_ratio <= 1.0);
551    }
552
553    #[test]
554    fn test_gpu_params() {
555        let config = make_config(64);
556        let params = GpuVoxelizeParams::from_config(&config);
557        assert_eq!(params.resolution, 64);
558    }
559
560    #[test]
561    fn test_triangle_aabb() {
562        let tri = Triangle {
563            v0: Vec3::new(1.0, 2.0, 3.0),
564            v1: Vec3::new(4.0, 1.0, 2.0),
565            v2: Vec3::new(2.0, 5.0, 1.0),
566            normal: Vec3::Z,
567            color: Vec4::ONE,
568            emission: Vec4::ZERO,
569        };
570        let aabb = tri.aabb();
571        assert!((aabb.min.x - 1.0).abs() < 1e-5);
572        assert!((aabb.max.x - 4.0).abs() < 1e-5);
573        assert!((aabb.min.y - 1.0).abs() < 1e-5);
574        assert!((aabb.max.y - 5.0).abs() < 1e-5);
575    }
576
577    #[test]
578    fn test_triangle_compute_normal() {
579        let tri = Triangle {
580            v0: Vec3::new(0.0, 0.0, 0.0),
581            v1: Vec3::new(1.0, 0.0, 0.0),
582            v2: Vec3::new(0.0, 1.0, 0.0),
583            normal: Vec3::Z,
584            color: Vec4::ONE,
585            emission: Vec4::ZERO,
586        };
587        let n = tri.compute_normal();
588        assert!((n.z - 1.0).abs() < 1e-5 || (n.z + 1.0).abs() < 1e-5);
589    }
590
591    #[test]
592    fn test_multiple_triangles() {
593        let config = make_config(16);
594        let tris = vec![
595            Triangle {
596                v0: Vec3::new(1.0, 1.0, 8.0),
597                v1: Vec3::new(5.0, 1.0, 8.0),
598                v2: Vec3::new(3.0, 5.0, 8.0),
599                normal: Vec3::Z,
600                color: Vec4::new(1.0, 0.0, 0.0, 1.0),
601                emission: Vec4::ZERO,
602            },
603            Triangle {
604                v0: Vec3::new(8.0, 8.0, 8.0),
605                v1: Vec3::new(12.0, 8.0, 8.0),
606                v2: Vec3::new(10.0, 12.0, 8.0),
607                normal: Vec3::Z,
608                color: Vec4::new(0.0, 0.0, 1.0, 1.0),
609                emission: Vec4::ZERO,
610            },
611        ];
612        let grid = voxelize_triangles(&tris, &config);
613        assert!(grid.filled_count() > 2, "Two triangles should voxelize to more than 2 voxels");
614    }
615
616    #[test]
617    fn test_sphere_at_boundary() {
618        let config = make_config(8);
619        let spheres = vec![(Vec3::ZERO, 1.0, Vec4::ONE)];
620        let grid = voxelize_spheres(&spheres, &config);
621        // Sphere is partially outside the grid, should still have some voxels
622        let count = grid.filled_count();
623        assert!(count > 0, "Sphere at boundary should produce some voxels");
624    }
625
626    #[test]
627    fn test_point_cloud_outside_bounds() {
628        let config = make_config(4);
629        let points = vec![
630            (Vec3::splat(-10.0), Vec4::ONE), // Outside bounds
631            (Vec3::splat(2.0), Vec4::ONE),   // Inside bounds
632        ];
633        let grid = voxelize_point_cloud(&points, &config);
634        assert_eq!(grid.filled_count(), 1, "Only the in-bounds point should be voxelized");
635    }
636
637    #[test]
638    fn test_merge_overlapping_grids() {
639        let mut a = VoxelGrid::new(UVec3::new(4, 4, 4));
640        let mut b = VoxelGrid::new(UVec3::new(4, 4, 4));
641        // Both grids have a voxel at the same position
642        a.set(1, 1, 1, VoxelData {
643            radiance: Vec4::new(1.0, 0.0, 0.0, 1.0),
644            normal: Vec3::Y,
645            opacity: 1.0,
646            sh_coeffs: [0.0; 9],
647        });
648        b.set(1, 1, 1, VoxelData {
649            radiance: Vec4::new(0.0, 0.0, 1.0, 1.0),
650            normal: Vec3::X,
651            opacity: 1.0,
652            sh_coeffs: [0.0; 9],
653        });
654        let merged = merge_grids(&a, &b);
655        let v = merged.get(1, 1, 1);
656        // Should be blended
657        assert!((v.radiance.x - 0.5).abs() < 1e-5);
658        assert!((v.radiance.z - 0.5).abs() < 1e-5);
659    }
660}