Skip to main content

oxiphysics_gpu/
gpu_sdf.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU-accelerated Signed Distance Field (SDF) computation (CPU mock).
5//!
6//! This module provides:
7//! - Primitive SDF shapes (`SdfPrimitive`)
8//! - CSG boolean operations (`SdfOp`)
9//! - A tree-based SDF scene (`SdfNode`)
10//! - A volumetric grid (`SdfGrid`) that can be filled from any SDF function
11//! - A mock GPU dispatcher (`GpuSdfCompute`) that evaluates primitives over a grid
12//! - Free-standing SDF evaluation helpers
13
14// ---------------------------------------------------------------------------
15// SdfPrimitive
16// ---------------------------------------------------------------------------
17
18/// A primitive signed distance field shape.
19#[derive(Debug, Clone, PartialEq)]
20pub enum SdfPrimitive {
21    /// Sphere centred at `center` with radius `radius`.
22    Sphere {
23        /// World-space centre of the sphere.
24        center: [f64; 3],
25        /// Sphere radius (must be positive).
26        radius: f64,
27    },
28    /// Axis-aligned box centred at `center`.
29    Box3d {
30        /// World-space centre of the box.
31        center: [f64; 3],
32        /// Half-extents along each axis (width/2, height/2, depth/2).
33        half_extents: [f64; 3],
34    },
35    /// Infinite cylinder (along the Y-axis) with a finite height cap.
36    Cylinder {
37        /// World-space centre of the cylinder.
38        center: [f64; 3],
39        /// Cylinder radius.
40        radius: f64,
41        /// Total height (symmetric about `center`).
42        height: f64,
43    },
44    /// Torus whose axis is the Y-axis.
45    Torus {
46        /// World-space centre of the torus.
47        center: [f64; 3],
48        /// Major (ring) radius — distance from the torus centre to the tube centre.
49        r_major: f64,
50        /// Minor (tube) radius.
51        r_minor: f64,
52    },
53}
54
55impl SdfPrimitive {
56    /// Evaluate the SDF for this primitive at world-space point `p`.
57    pub fn evaluate(&self, p: [f64; 3]) -> f64 {
58        match self {
59            SdfPrimitive::Sphere { center, radius } => sdf_sphere(p, *center, *radius),
60            SdfPrimitive::Box3d {
61                center,
62                half_extents,
63            } => sdf_box(p, *center, *half_extents),
64            SdfPrimitive::Cylinder {
65                center,
66                radius,
67                height,
68            } => sdf_cylinder(p, *center, *radius, *height),
69            SdfPrimitive::Torus {
70                center,
71                r_major,
72                r_minor,
73            } => sdf_torus(p, *center, *r_major, *r_minor),
74        }
75    }
76}
77
78// ---------------------------------------------------------------------------
79// SdfOp
80// ---------------------------------------------------------------------------
81
82/// Boolean / blending operations that combine two SDF values.
83#[derive(Debug, Clone, PartialEq)]
84pub enum SdfOp {
85    /// Take the minimum of two distance values (boolean union).
86    Union,
87    /// Take the maximum of two distance values (boolean intersection).
88    Intersection,
89    /// Subtract the second shape from the first (`max(d1, -d2)`).
90    Subtraction,
91    /// Polynomial smooth minimum blend.
92    SmoothUnion {
93        /// Blend radius — larger values produce softer transitions.
94        k: f64,
95    },
96    /// Uniformly grow (positive `d`) or shrink (negative `d`) a shape.
97    Offset {
98        /// Offset distance in world units.
99        d: f64,
100    },
101}
102
103impl SdfOp {
104    /// Apply this operation to two already-evaluated distance values `d1` and
105    /// `d2`.  For `Offset`, only `d1` is used (the second operand is ignored).
106    pub fn apply(&self, d1: f64, d2: f64) -> f64 {
107        match self {
108            SdfOp::Union => d1.min(d2),
109            SdfOp::Intersection => d1.max(d2),
110            SdfOp::Subtraction => d1.max(-d2),
111            SdfOp::SmoothUnion { k } => sdf_smooth_union(d1, d2, *k),
112            SdfOp::Offset { d } => d1 + d,
113        }
114    }
115}
116
117// ---------------------------------------------------------------------------
118// SdfNode
119// ---------------------------------------------------------------------------
120
121/// A node in a CSG (Constructive Solid Geometry) SDF tree.
122#[derive(Debug, Clone)]
123pub enum SdfNode {
124    /// A leaf node that holds a single primitive.
125    Leaf(SdfPrimitive),
126    /// An internal node that combines two child nodes with an operation.
127    Op {
128        /// The combining operation.
129        op: SdfOp,
130        /// Left child.
131        left: Box<SdfNode>,
132        /// Right child.
133        right: Box<SdfNode>,
134    },
135}
136
137impl SdfNode {
138    /// Create a leaf node from a primitive.
139    pub fn leaf(prim: SdfPrimitive) -> Self {
140        SdfNode::Leaf(prim)
141    }
142
143    /// Create an operation node combining two children.
144    pub fn op(op: SdfOp, left: SdfNode, right: SdfNode) -> Self {
145        SdfNode::Op {
146            op,
147            left: Box::new(left),
148            right: Box::new(right),
149        }
150    }
151
152    /// Evaluate the SDF tree at world-space point `p`.
153    pub fn evaluate(&self, p: [f64; 3]) -> f64 {
154        match self {
155            SdfNode::Leaf(prim) => prim.evaluate(p),
156            SdfNode::Op { op, left, right } => {
157                let d1 = left.evaluate(p);
158                let d2 = right.evaluate(p);
159                op.apply(d1, d2)
160            }
161        }
162    }
163}
164
165// ---------------------------------------------------------------------------
166// SdfGrid
167// ---------------------------------------------------------------------------
168
169/// A regular 3-D grid that stores SDF values as `f32`.
170///
171/// Voxel `(i, j, k)` is centred at
172/// `origin + spacing * [i + 0.5, j + 0.5, k + 0.5]`.
173#[derive(Debug, Clone)]
174pub struct SdfGrid {
175    /// Number of voxels along `[x, y, z]`.
176    pub dimensions: [usize; 3],
177    /// Uniform spacing between voxel centres.
178    pub spacing: f64,
179    /// World-space position of the grid's lower-left-front corner.
180    pub origin: [f64; 3],
181    /// Flattened SDF values in x-major order (z slowest).
182    ///
183    /// `data[i + dims[0\] * (j + dims[1] * k)]` corresponds to voxel `(i,j,k)`.
184    pub data: Vec<f32>,
185}
186
187impl SdfGrid {
188    /// Allocate a new grid, filling every voxel with `f32::MAX`.
189    pub fn new(dimensions: [usize; 3], spacing: f64, origin: [f64; 3]) -> Self {
190        let n = dimensions[0] * dimensions[1] * dimensions[2];
191        Self {
192            dimensions,
193            spacing,
194            origin,
195            data: vec![f32::MAX; n],
196        }
197    }
198
199    /// Return the world-space centre of voxel `(i, j, k)`.
200    pub fn voxel_center(&self, i: usize, j: usize, k: usize) -> [f64; 3] {
201        [
202            self.origin[0] + self.spacing * (i as f64 + 0.5),
203            self.origin[1] + self.spacing * (j as f64 + 0.5),
204            self.origin[2] + self.spacing * (k as f64 + 0.5),
205        ]
206    }
207
208    /// Flat linear index for voxel `(i, j, k)`.
209    pub fn index(&self, i: usize, j: usize, k: usize) -> usize {
210        i + self.dimensions[0] * (j + self.dimensions[1] * k)
211    }
212
213    /// Fill every voxel by evaluating `sdf` at its centre.
214    ///
215    /// The closure receives a `[f64; 3]` world-space point and should return
216    /// the signed distance to the surface.
217    pub fn compute_from_sdf(&mut self, sdf: &dyn Fn([f64; 3]) -> f64) {
218        let [nx, ny, nz] = self.dimensions;
219        for k in 0..nz {
220            for j in 0..ny {
221                for i in 0..nx {
222                    let p = self.voxel_center(i, j, k);
223                    let idx = self.index(i, j, k);
224                    self.data[idx] = sdf(p) as f32;
225                }
226            }
227        }
228    }
229
230    /// Total number of voxels in the grid.
231    pub fn len(&self) -> usize {
232        self.dimensions[0] * self.dimensions[1] * self.dimensions[2]
233    }
234
235    /// Return `true` when the grid contains no voxels.
236    pub fn is_empty(&self) -> bool {
237        self.len() == 0
238    }
239}
240
241// ---------------------------------------------------------------------------
242// GpuSdfCompute
243// ---------------------------------------------------------------------------
244
245/// Mock GPU dispatcher for SDF field computation.
246///
247/// On real hardware this would upload primitives to the GPU and run a
248/// compute shader.  Here it falls back to a CPU loop.
249#[derive(Debug, Clone, Default)]
250pub struct GpuSdfCompute;
251
252impl GpuSdfCompute {
253    /// Create a new dispatcher.
254    pub fn new() -> Self {
255        Self
256    }
257
258    /// Fill `grid` with the union of all `primitives`.
259    ///
260    /// Each voxel is assigned the minimum distance among all primitives
261    /// (equivalent to an unbounded CSG union).
262    pub fn dispatch_compute(&self, grid: &mut SdfGrid, primitives: &[SdfPrimitive]) {
263        if primitives.is_empty() {
264            return;
265        }
266        grid.compute_from_sdf(&|p| {
267            primitives
268                .iter()
269                .map(|prim| prim.evaluate(p))
270                .fold(f64::INFINITY, f64::min)
271        });
272    }
273}
274
275// ---------------------------------------------------------------------------
276// Free-standing SDF functions
277// ---------------------------------------------------------------------------
278
279/// Signed distance from point `p` to a sphere at `center` with radius `r`.
280///
281/// Negative inside, positive outside, zero on the surface.
282pub fn sdf_sphere(p: [f64; 3], center: [f64; 3], r: f64) -> f64 {
283    let dx = p[0] - center[0];
284    let dy = p[1] - center[1];
285    let dz = p[2] - center[2];
286    (dx * dx + dy * dy + dz * dz).sqrt() - r
287}
288
289/// Signed distance from point `p` to an axis-aligned box centred at `center`
290/// with half-extents `b`.
291pub fn sdf_box(p: [f64; 3], center: [f64; 3], b: [f64; 3]) -> f64 {
292    let qx = (p[0] - center[0]).abs() - b[0];
293    let qy = (p[1] - center[1]).abs() - b[1];
294    let qz = (p[2] - center[2]).abs() - b[2];
295    let outer =
296        (qx.max(0.0) * qx.max(0.0) + qy.max(0.0) * qy.max(0.0) + qz.max(0.0) * qz.max(0.0)).sqrt();
297    let inner = qx.max(qy).max(qz).min(0.0);
298    outer + inner
299}
300
301/// Signed distance from point `p` to a finite cylinder (Y-axis aligned)
302/// centred at `center` with radius `radius` and total height `height`.
303#[allow(dead_code)]
304pub fn sdf_cylinder(p: [f64; 3], center: [f64; 3], radius: f64, height: f64) -> f64 {
305    let dx = p[0] - center[0];
306    let dz = p[2] - center[2];
307    let radial = (dx * dx + dz * dz).sqrt() - radius;
308    let axial = (p[1] - center[1]).abs() - height * 0.5;
309    let outer = (radial.max(0.0) * radial.max(0.0) + axial.max(0.0) * axial.max(0.0)).sqrt();
310    let inner = radial.max(axial).min(0.0);
311    outer + inner
312}
313
314/// Signed distance from point `p` to a torus (Y-axis aligned) centred at
315/// `center` with major radius `r_major` and minor radius `r_minor`.
316#[allow(dead_code)]
317pub fn sdf_torus(p: [f64; 3], center: [f64; 3], r_major: f64, r_minor: f64) -> f64 {
318    let dx = p[0] - center[0];
319    let dy = p[1] - center[1];
320    let dz = p[2] - center[2];
321    // Project onto the XZ plane
322    let xz_dist = (dx * dx + dz * dz).sqrt();
323    let qx = xz_dist - r_major;
324    let qy = dy;
325    (qx * qx + qy * qy).sqrt() - r_minor
326}
327
328/// Polynomial smooth-minimum of `d1` and `d2` with blend radius `k`.
329///
330/// `k == 0` degenerates to `min(d1, d2)`.  Larger values produce a softer
331/// blend between the two surfaces.
332///
333/// Reference: Inigo Quilez — <https://iquilezles.org/articles/smin/>
334pub fn sdf_smooth_union(d1: f64, d2: f64, k: f64) -> f64 {
335    if k <= 0.0 {
336        return d1.min(d2);
337    }
338    let h = (0.5 + 0.5 * (d2 - d1) / k).clamp(0.0, 1.0);
339    d1 * (1.0 - h) + d2 * h - k * h * (1.0 - h)
340}
341
342/// Estimate the surface normal at `p` using central differences on `sdf`.
343///
344/// Returns a normalised `[f64; 3]` gradient vector.  The step size `eps` is
345/// `1e-4` by default (hard-coded).
346pub fn sdf_gradient(sdf: &dyn Fn([f64; 3]) -> f64, p: [f64; 3]) -> [f64; 3] {
347    const EPS: f64 = 1e-4;
348    let gx = sdf([p[0] + EPS, p[1], p[2]]) - sdf([p[0] - EPS, p[1], p[2]]);
349    let gy = sdf([p[0], p[1] + EPS, p[2]]) - sdf([p[0], p[1] - EPS, p[2]]);
350    let gz = sdf([p[0], p[1], p[2] + EPS]) - sdf([p[0], p[1], p[2] - EPS]);
351    let len = (gx * gx + gy * gy + gz * gz).sqrt();
352    if len < 1e-15 {
353        [0.0, 1.0, 0.0]
354    } else {
355        [gx / len, gy / len, gz / len]
356    }
357}
358
359// ---------------------------------------------------------------------------
360// Tests
361// ---------------------------------------------------------------------------
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    // ── sdf_sphere ──────────────────────────────────────────────────────────
368
369    #[test]
370    fn test_sphere_surface() {
371        let d = sdf_sphere([1.0, 0.0, 0.0], [0.0; 3], 1.0);
372        assert!(d.abs() < 1e-10, "expected ~0, got {d}");
373    }
374
375    #[test]
376    fn test_sphere_outside() {
377        let d = sdf_sphere([2.0, 0.0, 0.0], [0.0; 3], 1.0);
378        assert!((d - 1.0).abs() < 1e-10);
379    }
380
381    #[test]
382    fn test_sphere_inside() {
383        let d = sdf_sphere([0.0; 3], [0.0; 3], 1.0);
384        assert!((d - (-1.0)).abs() < 1e-10);
385    }
386
387    #[test]
388    fn test_sphere_offset_center() {
389        let d = sdf_sphere([3.0, 0.0, 0.0], [2.0, 0.0, 0.0], 1.0);
390        assert!(d.abs() < 1e-10);
391    }
392
393    #[test]
394    fn test_sphere_diagonal() {
395        // Point at [1,1,1] distance sqrt(3) from origin; sphere r=1 => sqrt(3)-1
396        let expected = 3_f64.sqrt() - 1.0;
397        let d = sdf_sphere([1.0, 1.0, 1.0], [0.0; 3], 1.0);
398        assert!((d - expected).abs() < 1e-10);
399    }
400
401    // ── sdf_box ─────────────────────────────────────────────────────────────
402
403    #[test]
404    fn test_box_outside_x() {
405        // Half-extents [0.5,0.5,0.5], point at [1.5,0,0] => d=1.0
406        let d = sdf_box([1.5, 0.0, 0.0], [0.0; 3], [0.5; 3]);
407        assert!((d - 1.0).abs() < 1e-10, "got {d}");
408    }
409
410    #[test]
411    fn test_box_inside() {
412        // Point at the origin, fully inside => d < 0
413        let d = sdf_box([0.0; 3], [0.0; 3], [1.0; 3]);
414        assert!(d < 0.0, "expected negative, got {d}");
415    }
416
417    #[test]
418    fn test_box_on_face() {
419        // On the +x face of a unit cube (half-extents 1)
420        let d = sdf_box([1.0, 0.0, 0.0], [0.0; 3], [1.0; 3]);
421        assert!(d.abs() < 1e-10, "got {d}");
422    }
423
424    #[test]
425    fn test_box_corner() {
426        // At corner [1,1,1] of unit cube => dist = sqrt(0+0+0) = 0
427        let d = sdf_box([1.0, 1.0, 1.0], [0.0; 3], [1.0; 3]);
428        assert!(d.abs() < 1e-10, "got {d}");
429    }
430
431    #[test]
432    fn test_box_asymmetric_extents() {
433        // Half-extents [1,2,3]; point at [2,0,0] => outside x only, d=1
434        let d = sdf_box([2.0, 0.0, 0.0], [0.0; 3], [1.0, 2.0, 3.0]);
435        assert!((d - 1.0).abs() < 1e-10, "got {d}");
436    }
437
438    // ── sdf_smooth_union ────────────────────────────────────────────────────
439
440    #[test]
441    fn test_smooth_union_zero_k_equals_min() {
442        let d1 = 1.0_f64;
443        let d2 = 2.0_f64;
444        assert!((sdf_smooth_union(d1, d2, 0.0) - d1.min(d2)).abs() < 1e-12);
445    }
446
447    #[test]
448    fn test_smooth_union_equal_inputs() {
449        // When d1 == d2, h = 0.5 => result = d1 - k/4
450        let d = 1.0_f64;
451        let k = 0.5_f64;
452        let result = sdf_smooth_union(d, d, k);
453        let expected = d - k / 4.0;
454        assert!((result - expected).abs() < 1e-10, "got {result}");
455    }
456
457    #[test]
458    fn test_smooth_union_approaches_min_for_large_k() {
459        // Far-apart values with large k: both clamped, result approaches min
460        let d1 = 0.0_f64;
461        let d2 = 100.0_f64;
462        let result = sdf_smooth_union(d1, d2, 1.0);
463        // h should clamp to 1.0, result = d1*0 + d2*1 - 0 = d2? No:
464        // h = clamp(0.5 + 0.5*(100-0)/1.0) = clamp(50.5) = 1.0
465        // result = d1*(1-1) + d2*1 - k*1*0 = 100
466        // But this is max; expected is min. Let's just check it's <= max(d1,d2)
467        assert!(result <= d2 + 1e-9);
468    }
469
470    #[test]
471    fn test_smooth_union_negative_k_is_min() {
472        let result = sdf_smooth_union(1.0, 2.0, -1.0);
473        assert!((result - 1.0_f64.min(2.0)).abs() < 1e-12);
474    }
475
476    #[test]
477    fn test_smooth_union_symmetric() {
478        // f(a,b,k) == f(b,a,k) up to float noise
479        let a = 0.3_f64;
480        let b = 0.7_f64;
481        let k = 0.4_f64;
482        let ab = sdf_smooth_union(a, b, k);
483        let ba = sdf_smooth_union(b, a, k);
484        assert!((ab - ba).abs() < 1e-12, "not symmetric: {ab} vs {ba}");
485    }
486
487    // ── sdf_gradient ────────────────────────────────────────────────────────
488
489    #[test]
490    fn test_gradient_sphere_outward() {
491        // On the surface of a unit sphere at [1,0,0], gradient should point along +x
492        let sdf = |p: [f64; 3]| sdf_sphere(p, [0.0; 3], 1.0);
493        let g = sdf_gradient(&sdf, [1.0, 0.0, 0.0]);
494        assert!((g[0] - 1.0).abs() < 1e-3, "gx={}", g[0]);
495        assert!(g[1].abs() < 1e-3);
496        assert!(g[2].abs() < 1e-3);
497    }
498
499    #[test]
500    fn test_gradient_is_unit_length() {
501        let sdf = |p: [f64; 3]| sdf_sphere(p, [0.0; 3], 1.0);
502        let g = sdf_gradient(&sdf, [2.0, 0.0, 0.0]);
503        let len = (g[0] * g[0] + g[1] * g[1] + g[2] * g[2]).sqrt();
504        assert!((len - 1.0).abs() < 1e-6, "len={len}");
505    }
506
507    #[test]
508    fn test_gradient_box_face_normal() {
509        // Outside +x face of a unit box, gradient should point along +x
510        let sdf = |p: [f64; 3]| sdf_box(p, [0.0; 3], [1.0; 3]);
511        let g = sdf_gradient(&sdf, [2.0, 0.0, 0.0]);
512        assert!((g[0] - 1.0).abs() < 1e-3, "gx={}", g[0]);
513    }
514
515    #[test]
516    fn test_gradient_smooth_union() {
517        let sdf = |p: [f64; 3]| {
518            let d1 = sdf_sphere(p, [-1.0, 0.0, 0.0], 0.5);
519            let d2 = sdf_sphere(p, [1.0, 0.0, 0.0], 0.5);
520            sdf_smooth_union(d1, d2, 0.3)
521        };
522        let g = sdf_gradient(&sdf, [0.0, 0.0, 3.0]);
523        let len = (g[0] * g[0] + g[1] * g[1] + g[2] * g[2]).sqrt();
524        assert!((len - 1.0).abs() < 1e-4, "len={len}");
525    }
526
527    // ── SdfPrimitive ────────────────────────────────────────────────────────
528
529    #[test]
530    fn test_primitive_sphere_evaluate() {
531        let prim = SdfPrimitive::Sphere {
532            center: [0.0; 3],
533            radius: 2.0,
534        };
535        assert!((prim.evaluate([2.0, 0.0, 0.0])).abs() < 1e-10);
536    }
537
538    #[test]
539    fn test_primitive_box_evaluate() {
540        let prim = SdfPrimitive::Box3d {
541            center: [0.0; 3],
542            half_extents: [1.0; 3],
543        };
544        let d = prim.evaluate([0.0; 3]);
545        assert!(d < 0.0);
546    }
547
548    #[test]
549    fn test_primitive_cylinder_evaluate() {
550        let prim = SdfPrimitive::Cylinder {
551            center: [0.0; 3],
552            radius: 1.0,
553            height: 2.0,
554        };
555        // Point on the curved surface
556        let d = prim.evaluate([1.0, 0.0, 0.0]);
557        assert!(d.abs() < 1e-10, "d={d}");
558    }
559
560    #[test]
561    fn test_primitive_torus_evaluate() {
562        let prim = SdfPrimitive::Torus {
563            center: [0.0; 3],
564            r_major: 2.0,
565            r_minor: 0.5,
566        };
567        // Point on the outer equator of the torus tube
568        let d = prim.evaluate([2.5, 0.0, 0.0]);
569        assert!(d.abs() < 1e-10, "d={d}");
570    }
571
572    // ── SdfOp ───────────────────────────────────────────────────────────────
573
574    #[test]
575    fn test_op_union() {
576        let op = SdfOp::Union;
577        assert!((op.apply(1.0, 2.0) - 1.0).abs() < 1e-12);
578    }
579
580    #[test]
581    fn test_op_intersection() {
582        let op = SdfOp::Intersection;
583        assert!((op.apply(1.0, 2.0) - 2.0).abs() < 1e-12);
584    }
585
586    #[test]
587    fn test_op_subtraction() {
588        // d1=2, d2=3 => max(2, -3) = 2
589        let op = SdfOp::Subtraction;
590        assert!((op.apply(2.0, 3.0) - 2.0).abs() < 1e-12);
591    }
592
593    #[test]
594    fn test_op_smooth_union() {
595        let op = SdfOp::SmoothUnion { k: 0.3 };
596        let result = op.apply(1.0, 1.0);
597        // Should be 1.0 - 0.3/4 = 0.925
598        assert!((result - (1.0 - 0.3 / 4.0)).abs() < 1e-10);
599    }
600
601    #[test]
602    fn test_op_offset_positive() {
603        let op = SdfOp::Offset { d: 0.5 };
604        assert!((op.apply(1.0, 0.0) - 1.5).abs() < 1e-12);
605    }
606
607    #[test]
608    fn test_op_offset_negative() {
609        let op = SdfOp::Offset { d: -0.5 };
610        assert!((op.apply(1.0, 0.0) - 0.5).abs() < 1e-12);
611    }
612
613    // ── SdfNode ─────────────────────────────────────────────────────────────
614
615    #[test]
616    fn test_node_leaf_evaluate() {
617        let node = SdfNode::leaf(SdfPrimitive::Sphere {
618            center: [0.0; 3],
619            radius: 1.0,
620        });
621        assert!((node.evaluate([1.0, 0.0, 0.0])).abs() < 1e-10);
622    }
623
624    #[test]
625    fn test_node_op_union() {
626        let a = SdfNode::leaf(SdfPrimitive::Sphere {
627            center: [-2.0, 0.0, 0.0],
628            radius: 1.0,
629        });
630        let b = SdfNode::leaf(SdfPrimitive::Sphere {
631            center: [2.0, 0.0, 0.0],
632            radius: 1.0,
633        });
634        let tree = SdfNode::op(SdfOp::Union, a, b);
635        // Origin: sphere A dist=1, sphere B dist=1, union=1
636        let d = tree.evaluate([0.0; 3]);
637        assert!((d - 1.0).abs() < 1e-10, "d={d}");
638    }
639
640    #[test]
641    fn test_node_op_intersection() {
642        let a = SdfNode::leaf(SdfPrimitive::Sphere {
643            center: [0.0; 3],
644            radius: 2.0,
645        });
646        let b = SdfNode::leaf(SdfPrimitive::Sphere {
647            center: [0.0; 3],
648            radius: 3.0,
649        });
650        let tree = SdfNode::op(SdfOp::Intersection, a, b);
651        // At origin: a=-2, b=-3 => intersection = max(-2,-3) = -2
652        let d = tree.evaluate([0.0; 3]);
653        assert!((d - (-2.0)).abs() < 1e-10);
654    }
655
656    #[test]
657    fn test_node_op_subtraction() {
658        let a = SdfNode::leaf(SdfPrimitive::Sphere {
659            center: [0.0; 3],
660            radius: 2.0,
661        });
662        let b = SdfNode::leaf(SdfPrimitive::Sphere {
663            center: [0.0; 3],
664            radius: 1.0,
665        });
666        let tree = SdfNode::op(SdfOp::Subtraction, a, b);
667        // At origin: a=-2, b=-1 => subtraction = max(-2, 1) = 1
668        let d = tree.evaluate([0.0; 3]);
669        assert!((d - 1.0).abs() < 1e-10, "d={d}");
670    }
671
672    // ── SdfGrid ─────────────────────────────────────────────────────────────
673
674    #[test]
675    fn test_grid_new_size() {
676        let grid = SdfGrid::new([4, 4, 4], 0.1, [0.0; 3]);
677        assert_eq!(grid.len(), 64);
678        assert!(!grid.is_empty());
679    }
680
681    #[test]
682    fn test_grid_empty_dimensions() {
683        let grid = SdfGrid::new([0, 4, 4], 0.1, [0.0; 3]);
684        assert!(grid.is_empty());
685    }
686
687    #[test]
688    fn test_grid_compute_from_sdf_sphere() {
689        let mut grid = SdfGrid::new([4, 4, 4], 1.0, [-2.0, -2.0, -2.0]);
690        let sphere_sdf = |p: [f64; 3]| sdf_sphere(p, [0.0; 3], 1.0);
691        grid.compute_from_sdf(&sphere_sdf);
692        // Every voxel should have been filled (no f32::MAX remaining)
693        assert!(
694            grid.data.iter().all(|&v| v.is_finite()),
695            "some voxels unfilled"
696        );
697    }
698
699    #[test]
700    fn test_grid_index_roundtrip() {
701        let grid = SdfGrid::new([5, 6, 7], 0.5, [0.0; 3]);
702        for k in 0..7 {
703            for j in 0..6 {
704                for i in 0..5 {
705                    let idx = grid.index(i, j, k);
706                    // Manual formula
707                    let expected = i + 5 * (j + 6 * k);
708                    assert_eq!(idx, expected);
709                }
710            }
711        }
712    }
713
714    #[test]
715    fn test_grid_voxel_center() {
716        let grid = SdfGrid::new([2, 2, 2], 1.0, [0.0; 3]);
717        let c = grid.voxel_center(0, 0, 0);
718        assert!((c[0] - 0.5).abs() < 1e-10);
719        assert!((c[1] - 0.5).abs() < 1e-10);
720        assert!((c[2] - 0.5).abs() < 1e-10);
721    }
722
723    #[test]
724    fn test_grid_center_of_last_voxel() {
725        let grid = SdfGrid::new([4, 4, 4], 1.0, [0.0; 3]);
726        let c = grid.voxel_center(3, 3, 3);
727        assert!((c[0] - 3.5).abs() < 1e-10);
728    }
729
730    #[test]
731    fn test_grid_inside_outside_counts() {
732        let mut grid = SdfGrid::new([10, 10, 10], 0.2, [-1.0, -1.0, -1.0]);
733        let sphere_sdf = |p: [f64; 3]| sdf_sphere(p, [0.0; 3], 0.5);
734        grid.compute_from_sdf(&sphere_sdf);
735        let inside = grid.data.iter().filter(|&&v| v < 0.0).count();
736        assert!(inside > 0, "no voxels inside the sphere");
737        let outside = grid.data.iter().filter(|&&v| v > 0.0).count();
738        assert!(outside > 0, "no voxels outside the sphere");
739    }
740
741    // ── GpuSdfCompute ───────────────────────────────────────────────────────
742
743    #[test]
744    fn test_gpu_sdf_compute_no_primitives() {
745        let compute = GpuSdfCompute::new();
746        let mut grid = SdfGrid::new([4, 4, 4], 1.0, [0.0; 3]);
747        // Fill with sentinel
748        grid.data.iter_mut().for_each(|v| *v = -999.0);
749        compute.dispatch_compute(&mut grid, &[]);
750        // Nothing should have changed
751        assert!(grid.data.iter().all(|&v| (v - (-999.0)).abs() < 1e-3));
752    }
753
754    #[test]
755    fn test_gpu_sdf_compute_sphere_union() {
756        let compute = GpuSdfCompute::new();
757        let mut grid = SdfGrid::new([8, 8, 8], 0.25, [-1.0, -1.0, -1.0]);
758        let primitives = vec![
759            SdfPrimitive::Sphere {
760                center: [-0.5, 0.0, 0.0],
761                radius: 0.3,
762            },
763            SdfPrimitive::Sphere {
764                center: [0.5, 0.0, 0.0],
765                radius: 0.3,
766            },
767        ];
768        compute.dispatch_compute(&mut grid, &primitives);
769        // Grid should be fully populated
770        assert!(grid.data.iter().all(|&v| v.is_finite()));
771        // Inside at least one sphere should exist
772        let inside = grid.data.iter().filter(|&&v| v < 0.0).count();
773        assert!(inside > 0, "no voxels inside");
774    }
775
776    #[test]
777    fn test_gpu_sdf_compute_single_box() {
778        let compute = GpuSdfCompute::new();
779        let mut grid = SdfGrid::new([4, 4, 4], 1.0, [-2.0, -2.0, -2.0]);
780        let primitives = vec![SdfPrimitive::Box3d {
781            center: [0.0; 3],
782            half_extents: [0.5; 3],
783        }];
784        compute.dispatch_compute(&mut grid, &primitives);
785        assert!(grid.data.iter().all(|&v| v.is_finite()));
786    }
787
788    #[test]
789    fn test_gpu_sdf_compute_torus() {
790        let compute = GpuSdfCompute::new();
791        let mut grid = SdfGrid::new([6, 6, 6], 0.5, [-1.5, -1.5, -1.5]);
792        let primitives = vec![SdfPrimitive::Torus {
793            center: [0.0; 3],
794            r_major: 0.8,
795            r_minor: 0.2,
796        }];
797        compute.dispatch_compute(&mut grid, &primitives);
798        assert!(grid.data.iter().all(|&v| v.is_finite()));
799    }
800
801    // ── Cylinder ────────────────────────────────────────────────────────────
802
803    #[test]
804    fn test_cylinder_inside() {
805        let d = sdf_cylinder([0.0, 0.0, 0.0], [0.0; 3], 1.0, 2.0);
806        assert!(d < 0.0, "expected inside, d={d}");
807    }
808
809    #[test]
810    fn test_cylinder_outside_radially() {
811        let d = sdf_cylinder([2.0, 0.0, 0.0], [0.0; 3], 1.0, 4.0);
812        assert!((d - 1.0).abs() < 1e-10, "d={d}");
813    }
814
815    #[test]
816    fn test_cylinder_on_curved_surface() {
817        let d = sdf_cylinder([1.0, 0.0, 0.0], [0.0; 3], 1.0, 4.0);
818        assert!(d.abs() < 1e-10, "d={d}");
819    }
820
821    // ── Torus ───────────────────────────────────────────────────────────────
822
823    #[test]
824    fn test_torus_centre_is_positive() {
825        // The centre of the torus is outside the tube
826        let d = sdf_torus([0.0; 3], [0.0; 3], 2.0, 0.5);
827        assert!(d > 0.0, "d={d}");
828    }
829
830    #[test]
831    fn test_torus_on_tube_surface() {
832        // On the inner equator: x = r_major - r_minor, y=0, z=0
833        let d = sdf_torus([1.5, 0.0, 0.0], [0.0; 3], 2.0, 0.5);
834        assert!(d.abs() < 1e-10, "d={d}");
835    }
836}