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`.
303pub fn sdf_cylinder(p: [f64; 3], center: [f64; 3], radius: f64, height: f64) -> f64 {
304    let dx = p[0] - center[0];
305    let dz = p[2] - center[2];
306    let radial = (dx * dx + dz * dz).sqrt() - radius;
307    let axial = (p[1] - center[1]).abs() - height * 0.5;
308    let outer = (radial.max(0.0) * radial.max(0.0) + axial.max(0.0) * axial.max(0.0)).sqrt();
309    let inner = radial.max(axial).min(0.0);
310    outer + inner
311}
312
313/// Signed distance from point `p` to a torus (Y-axis aligned) centred at
314/// `center` with major radius `r_major` and minor radius `r_minor`.
315pub fn sdf_torus(p: [f64; 3], center: [f64; 3], r_major: f64, r_minor: f64) -> f64 {
316    let dx = p[0] - center[0];
317    let dy = p[1] - center[1];
318    let dz = p[2] - center[2];
319    // Project onto the XZ plane
320    let xz_dist = (dx * dx + dz * dz).sqrt();
321    let qx = xz_dist - r_major;
322    let qy = dy;
323    (qx * qx + qy * qy).sqrt() - r_minor
324}
325
326/// Polynomial smooth-minimum of `d1` and `d2` with blend radius `k`.
327///
328/// `k == 0` degenerates to `min(d1, d2)`.  Larger values produce a softer
329/// blend between the two surfaces.
330///
331/// Reference: Inigo Quilez — <https://iquilezles.org/articles/smin/>
332pub fn sdf_smooth_union(d1: f64, d2: f64, k: f64) -> f64 {
333    if k <= 0.0 {
334        return d1.min(d2);
335    }
336    let h = (0.5 + 0.5 * (d2 - d1) / k).clamp(0.0, 1.0);
337    d1 * (1.0 - h) + d2 * h - k * h * (1.0 - h)
338}
339
340/// Estimate the surface normal at `p` using central differences on `sdf`.
341///
342/// Returns a normalised `[f64; 3]` gradient vector.  The step size `eps` is
343/// `1e-4` by default (hard-coded).
344pub fn sdf_gradient(sdf: &dyn Fn([f64; 3]) -> f64, p: [f64; 3]) -> [f64; 3] {
345    const EPS: f64 = 1e-4;
346    let gx = sdf([p[0] + EPS, p[1], p[2]]) - sdf([p[0] - EPS, p[1], p[2]]);
347    let gy = sdf([p[0], p[1] + EPS, p[2]]) - sdf([p[0], p[1] - EPS, p[2]]);
348    let gz = sdf([p[0], p[1], p[2] + EPS]) - sdf([p[0], p[1], p[2] - EPS]);
349    let len = (gx * gx + gy * gy + gz * gz).sqrt();
350    if len < 1e-15 {
351        [0.0, 1.0, 0.0]
352    } else {
353        [gx / len, gy / len, gz / len]
354    }
355}
356
357// ---------------------------------------------------------------------------
358// Tests
359// ---------------------------------------------------------------------------
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    // ── sdf_sphere ──────────────────────────────────────────────────────────
366
367    #[test]
368    fn test_sphere_surface() {
369        let d = sdf_sphere([1.0, 0.0, 0.0], [0.0; 3], 1.0);
370        assert!(d.abs() < 1e-10, "expected ~0, got {d}");
371    }
372
373    #[test]
374    fn test_sphere_outside() {
375        let d = sdf_sphere([2.0, 0.0, 0.0], [0.0; 3], 1.0);
376        assert!((d - 1.0).abs() < 1e-10);
377    }
378
379    #[test]
380    fn test_sphere_inside() {
381        let d = sdf_sphere([0.0; 3], [0.0; 3], 1.0);
382        assert!((d - (-1.0)).abs() < 1e-10);
383    }
384
385    #[test]
386    fn test_sphere_offset_center() {
387        let d = sdf_sphere([3.0, 0.0, 0.0], [2.0, 0.0, 0.0], 1.0);
388        assert!(d.abs() < 1e-10);
389    }
390
391    #[test]
392    fn test_sphere_diagonal() {
393        // Point at [1,1,1] distance sqrt(3) from origin; sphere r=1 => sqrt(3)-1
394        let expected = 3_f64.sqrt() - 1.0;
395        let d = sdf_sphere([1.0, 1.0, 1.0], [0.0; 3], 1.0);
396        assert!((d - expected).abs() < 1e-10);
397    }
398
399    // ── sdf_box ─────────────────────────────────────────────────────────────
400
401    #[test]
402    fn test_box_outside_x() {
403        // Half-extents [0.5,0.5,0.5], point at [1.5,0,0] => d=1.0
404        let d = sdf_box([1.5, 0.0, 0.0], [0.0; 3], [0.5; 3]);
405        assert!((d - 1.0).abs() < 1e-10, "got {d}");
406    }
407
408    #[test]
409    fn test_box_inside() {
410        // Point at the origin, fully inside => d < 0
411        let d = sdf_box([0.0; 3], [0.0; 3], [1.0; 3]);
412        assert!(d < 0.0, "expected negative, got {d}");
413    }
414
415    #[test]
416    fn test_box_on_face() {
417        // On the +x face of a unit cube (half-extents 1)
418        let d = sdf_box([1.0, 0.0, 0.0], [0.0; 3], [1.0; 3]);
419        assert!(d.abs() < 1e-10, "got {d}");
420    }
421
422    #[test]
423    fn test_box_corner() {
424        // At corner [1,1,1] of unit cube => dist = sqrt(0+0+0) = 0
425        let d = sdf_box([1.0, 1.0, 1.0], [0.0; 3], [1.0; 3]);
426        assert!(d.abs() < 1e-10, "got {d}");
427    }
428
429    #[test]
430    fn test_box_asymmetric_extents() {
431        // Half-extents [1,2,3]; point at [2,0,0] => outside x only, d=1
432        let d = sdf_box([2.0, 0.0, 0.0], [0.0; 3], [1.0, 2.0, 3.0]);
433        assert!((d - 1.0).abs() < 1e-10, "got {d}");
434    }
435
436    // ── sdf_smooth_union ────────────────────────────────────────────────────
437
438    #[test]
439    fn test_smooth_union_zero_k_equals_min() {
440        let d1 = 1.0_f64;
441        let d2 = 2.0_f64;
442        assert!((sdf_smooth_union(d1, d2, 0.0) - d1.min(d2)).abs() < 1e-12);
443    }
444
445    #[test]
446    fn test_smooth_union_equal_inputs() {
447        // When d1 == d2, h = 0.5 => result = d1 - k/4
448        let d = 1.0_f64;
449        let k = 0.5_f64;
450        let result = sdf_smooth_union(d, d, k);
451        let expected = d - k / 4.0;
452        assert!((result - expected).abs() < 1e-10, "got {result}");
453    }
454
455    #[test]
456    fn test_smooth_union_approaches_min_for_large_k() {
457        // Far-apart values with large k: both clamped, result approaches min
458        let d1 = 0.0_f64;
459        let d2 = 100.0_f64;
460        let result = sdf_smooth_union(d1, d2, 1.0);
461        // h should clamp to 1.0, result = d1*0 + d2*1 - 0 = d2? No:
462        // h = clamp(0.5 + 0.5*(100-0)/1.0) = clamp(50.5) = 1.0
463        // result = d1*(1-1) + d2*1 - k*1*0 = 100
464        // But this is max; expected is min. Let's just check it's <= max(d1,d2)
465        assert!(result <= d2 + 1e-9);
466    }
467
468    #[test]
469    fn test_smooth_union_negative_k_is_min() {
470        let result = sdf_smooth_union(1.0, 2.0, -1.0);
471        assert!((result - 1.0_f64.min(2.0)).abs() < 1e-12);
472    }
473
474    #[test]
475    fn test_smooth_union_symmetric() {
476        // f(a,b,k) == f(b,a,k) up to float noise
477        let a = 0.3_f64;
478        let b = 0.7_f64;
479        let k = 0.4_f64;
480        let ab = sdf_smooth_union(a, b, k);
481        let ba = sdf_smooth_union(b, a, k);
482        assert!((ab - ba).abs() < 1e-12, "not symmetric: {ab} vs {ba}");
483    }
484
485    // ── sdf_gradient ────────────────────────────────────────────────────────
486
487    #[test]
488    fn test_gradient_sphere_outward() {
489        // On the surface of a unit sphere at [1,0,0], gradient should point along +x
490        let sdf = |p: [f64; 3]| sdf_sphere(p, [0.0; 3], 1.0);
491        let g = sdf_gradient(&sdf, [1.0, 0.0, 0.0]);
492        assert!((g[0] - 1.0).abs() < 1e-3, "gx={}", g[0]);
493        assert!(g[1].abs() < 1e-3);
494        assert!(g[2].abs() < 1e-3);
495    }
496
497    #[test]
498    fn test_gradient_is_unit_length() {
499        let sdf = |p: [f64; 3]| sdf_sphere(p, [0.0; 3], 1.0);
500        let g = sdf_gradient(&sdf, [2.0, 0.0, 0.0]);
501        let len = (g[0] * g[0] + g[1] * g[1] + g[2] * g[2]).sqrt();
502        assert!((len - 1.0).abs() < 1e-6, "len={len}");
503    }
504
505    #[test]
506    fn test_gradient_box_face_normal() {
507        // Outside +x face of a unit box, gradient should point along +x
508        let sdf = |p: [f64; 3]| sdf_box(p, [0.0; 3], [1.0; 3]);
509        let g = sdf_gradient(&sdf, [2.0, 0.0, 0.0]);
510        assert!((g[0] - 1.0).abs() < 1e-3, "gx={}", g[0]);
511    }
512
513    #[test]
514    fn test_gradient_smooth_union() {
515        let sdf = |p: [f64; 3]| {
516            let d1 = sdf_sphere(p, [-1.0, 0.0, 0.0], 0.5);
517            let d2 = sdf_sphere(p, [1.0, 0.0, 0.0], 0.5);
518            sdf_smooth_union(d1, d2, 0.3)
519        };
520        let g = sdf_gradient(&sdf, [0.0, 0.0, 3.0]);
521        let len = (g[0] * g[0] + g[1] * g[1] + g[2] * g[2]).sqrt();
522        assert!((len - 1.0).abs() < 1e-4, "len={len}");
523    }
524
525    // ── SdfPrimitive ────────────────────────────────────────────────────────
526
527    #[test]
528    fn test_primitive_sphere_evaluate() {
529        let prim = SdfPrimitive::Sphere {
530            center: [0.0; 3],
531            radius: 2.0,
532        };
533        assert!((prim.evaluate([2.0, 0.0, 0.0])).abs() < 1e-10);
534    }
535
536    #[test]
537    fn test_primitive_box_evaluate() {
538        let prim = SdfPrimitive::Box3d {
539            center: [0.0; 3],
540            half_extents: [1.0; 3],
541        };
542        let d = prim.evaluate([0.0; 3]);
543        assert!(d < 0.0);
544    }
545
546    #[test]
547    fn test_primitive_cylinder_evaluate() {
548        let prim = SdfPrimitive::Cylinder {
549            center: [0.0; 3],
550            radius: 1.0,
551            height: 2.0,
552        };
553        // Point on the curved surface
554        let d = prim.evaluate([1.0, 0.0, 0.0]);
555        assert!(d.abs() < 1e-10, "d={d}");
556    }
557
558    #[test]
559    fn test_primitive_torus_evaluate() {
560        let prim = SdfPrimitive::Torus {
561            center: [0.0; 3],
562            r_major: 2.0,
563            r_minor: 0.5,
564        };
565        // Point on the outer equator of the torus tube
566        let d = prim.evaluate([2.5, 0.0, 0.0]);
567        assert!(d.abs() < 1e-10, "d={d}");
568    }
569
570    // ── SdfOp ───────────────────────────────────────────────────────────────
571
572    #[test]
573    fn test_op_union() {
574        let op = SdfOp::Union;
575        assert!((op.apply(1.0, 2.0) - 1.0).abs() < 1e-12);
576    }
577
578    #[test]
579    fn test_op_intersection() {
580        let op = SdfOp::Intersection;
581        assert!((op.apply(1.0, 2.0) - 2.0).abs() < 1e-12);
582    }
583
584    #[test]
585    fn test_op_subtraction() {
586        // d1=2, d2=3 => max(2, -3) = 2
587        let op = SdfOp::Subtraction;
588        assert!((op.apply(2.0, 3.0) - 2.0).abs() < 1e-12);
589    }
590
591    #[test]
592    fn test_op_smooth_union() {
593        let op = SdfOp::SmoothUnion { k: 0.3 };
594        let result = op.apply(1.0, 1.0);
595        // Should be 1.0 - 0.3/4 = 0.925
596        assert!((result - (1.0 - 0.3 / 4.0)).abs() < 1e-10);
597    }
598
599    #[test]
600    fn test_op_offset_positive() {
601        let op = SdfOp::Offset { d: 0.5 };
602        assert!((op.apply(1.0, 0.0) - 1.5).abs() < 1e-12);
603    }
604
605    #[test]
606    fn test_op_offset_negative() {
607        let op = SdfOp::Offset { d: -0.5 };
608        assert!((op.apply(1.0, 0.0) - 0.5).abs() < 1e-12);
609    }
610
611    // ── SdfNode ─────────────────────────────────────────────────────────────
612
613    #[test]
614    fn test_node_leaf_evaluate() {
615        let node = SdfNode::leaf(SdfPrimitive::Sphere {
616            center: [0.0; 3],
617            radius: 1.0,
618        });
619        assert!((node.evaluate([1.0, 0.0, 0.0])).abs() < 1e-10);
620    }
621
622    #[test]
623    fn test_node_op_union() {
624        let a = SdfNode::leaf(SdfPrimitive::Sphere {
625            center: [-2.0, 0.0, 0.0],
626            radius: 1.0,
627        });
628        let b = SdfNode::leaf(SdfPrimitive::Sphere {
629            center: [2.0, 0.0, 0.0],
630            radius: 1.0,
631        });
632        let tree = SdfNode::op(SdfOp::Union, a, b);
633        // Origin: sphere A dist=1, sphere B dist=1, union=1
634        let d = tree.evaluate([0.0; 3]);
635        assert!((d - 1.0).abs() < 1e-10, "d={d}");
636    }
637
638    #[test]
639    fn test_node_op_intersection() {
640        let a = SdfNode::leaf(SdfPrimitive::Sphere {
641            center: [0.0; 3],
642            radius: 2.0,
643        });
644        let b = SdfNode::leaf(SdfPrimitive::Sphere {
645            center: [0.0; 3],
646            radius: 3.0,
647        });
648        let tree = SdfNode::op(SdfOp::Intersection, a, b);
649        // At origin: a=-2, b=-3 => intersection = max(-2,-3) = -2
650        let d = tree.evaluate([0.0; 3]);
651        assert!((d - (-2.0)).abs() < 1e-10);
652    }
653
654    #[test]
655    fn test_node_op_subtraction() {
656        let a = SdfNode::leaf(SdfPrimitive::Sphere {
657            center: [0.0; 3],
658            radius: 2.0,
659        });
660        let b = SdfNode::leaf(SdfPrimitive::Sphere {
661            center: [0.0; 3],
662            radius: 1.0,
663        });
664        let tree = SdfNode::op(SdfOp::Subtraction, a, b);
665        // At origin: a=-2, b=-1 => subtraction = max(-2, 1) = 1
666        let d = tree.evaluate([0.0; 3]);
667        assert!((d - 1.0).abs() < 1e-10, "d={d}");
668    }
669
670    // ── SdfGrid ─────────────────────────────────────────────────────────────
671
672    #[test]
673    fn test_grid_new_size() {
674        let grid = SdfGrid::new([4, 4, 4], 0.1, [0.0; 3]);
675        assert_eq!(grid.len(), 64);
676        assert!(!grid.is_empty());
677    }
678
679    #[test]
680    fn test_grid_empty_dimensions() {
681        let grid = SdfGrid::new([0, 4, 4], 0.1, [0.0; 3]);
682        assert!(grid.is_empty());
683    }
684
685    #[test]
686    fn test_grid_compute_from_sdf_sphere() {
687        let mut grid = SdfGrid::new([4, 4, 4], 1.0, [-2.0, -2.0, -2.0]);
688        let sphere_sdf = |p: [f64; 3]| sdf_sphere(p, [0.0; 3], 1.0);
689        grid.compute_from_sdf(&sphere_sdf);
690        // Every voxel should have been filled (no f32::MAX remaining)
691        assert!(
692            grid.data.iter().all(|&v| v.is_finite()),
693            "some voxels unfilled"
694        );
695    }
696
697    #[test]
698    fn test_grid_index_roundtrip() {
699        let grid = SdfGrid::new([5, 6, 7], 0.5, [0.0; 3]);
700        for k in 0..7 {
701            for j in 0..6 {
702                for i in 0..5 {
703                    let idx = grid.index(i, j, k);
704                    // Manual formula
705                    let expected = i + 5 * (j + 6 * k);
706                    assert_eq!(idx, expected);
707                }
708            }
709        }
710    }
711
712    #[test]
713    fn test_grid_voxel_center() {
714        let grid = SdfGrid::new([2, 2, 2], 1.0, [0.0; 3]);
715        let c = grid.voxel_center(0, 0, 0);
716        assert!((c[0] - 0.5).abs() < 1e-10);
717        assert!((c[1] - 0.5).abs() < 1e-10);
718        assert!((c[2] - 0.5).abs() < 1e-10);
719    }
720
721    #[test]
722    fn test_grid_center_of_last_voxel() {
723        let grid = SdfGrid::new([4, 4, 4], 1.0, [0.0; 3]);
724        let c = grid.voxel_center(3, 3, 3);
725        assert!((c[0] - 3.5).abs() < 1e-10);
726    }
727
728    #[test]
729    fn test_grid_inside_outside_counts() {
730        let mut grid = SdfGrid::new([10, 10, 10], 0.2, [-1.0, -1.0, -1.0]);
731        let sphere_sdf = |p: [f64; 3]| sdf_sphere(p, [0.0; 3], 0.5);
732        grid.compute_from_sdf(&sphere_sdf);
733        let inside = grid.data.iter().filter(|&&v| v < 0.0).count();
734        assert!(inside > 0, "no voxels inside the sphere");
735        let outside = grid.data.iter().filter(|&&v| v > 0.0).count();
736        assert!(outside > 0, "no voxels outside the sphere");
737    }
738
739    // ── GpuSdfCompute ───────────────────────────────────────────────────────
740
741    #[test]
742    fn test_gpu_sdf_compute_no_primitives() {
743        let compute = GpuSdfCompute::new();
744        let mut grid = SdfGrid::new([4, 4, 4], 1.0, [0.0; 3]);
745        // Fill with sentinel
746        grid.data.iter_mut().for_each(|v| *v = -999.0);
747        compute.dispatch_compute(&mut grid, &[]);
748        // Nothing should have changed
749        assert!(grid.data.iter().all(|&v| (v - (-999.0)).abs() < 1e-3));
750    }
751
752    #[test]
753    fn test_gpu_sdf_compute_sphere_union() {
754        let compute = GpuSdfCompute::new();
755        let mut grid = SdfGrid::new([8, 8, 8], 0.25, [-1.0, -1.0, -1.0]);
756        let primitives = vec![
757            SdfPrimitive::Sphere {
758                center: [-0.5, 0.0, 0.0],
759                radius: 0.3,
760            },
761            SdfPrimitive::Sphere {
762                center: [0.5, 0.0, 0.0],
763                radius: 0.3,
764            },
765        ];
766        compute.dispatch_compute(&mut grid, &primitives);
767        // Grid should be fully populated
768        assert!(grid.data.iter().all(|&v| v.is_finite()));
769        // Inside at least one sphere should exist
770        let inside = grid.data.iter().filter(|&&v| v < 0.0).count();
771        assert!(inside > 0, "no voxels inside");
772    }
773
774    #[test]
775    fn test_gpu_sdf_compute_single_box() {
776        let compute = GpuSdfCompute::new();
777        let mut grid = SdfGrid::new([4, 4, 4], 1.0, [-2.0, -2.0, -2.0]);
778        let primitives = vec![SdfPrimitive::Box3d {
779            center: [0.0; 3],
780            half_extents: [0.5; 3],
781        }];
782        compute.dispatch_compute(&mut grid, &primitives);
783        assert!(grid.data.iter().all(|&v| v.is_finite()));
784    }
785
786    #[test]
787    fn test_gpu_sdf_compute_torus() {
788        let compute = GpuSdfCompute::new();
789        let mut grid = SdfGrid::new([6, 6, 6], 0.5, [-1.5, -1.5, -1.5]);
790        let primitives = vec![SdfPrimitive::Torus {
791            center: [0.0; 3],
792            r_major: 0.8,
793            r_minor: 0.2,
794        }];
795        compute.dispatch_compute(&mut grid, &primitives);
796        assert!(grid.data.iter().all(|&v| v.is_finite()));
797    }
798
799    // ── Cylinder ────────────────────────────────────────────────────────────
800
801    #[test]
802    fn test_cylinder_inside() {
803        let d = sdf_cylinder([0.0, 0.0, 0.0], [0.0; 3], 1.0, 2.0);
804        assert!(d < 0.0, "expected inside, d={d}");
805    }
806
807    #[test]
808    fn test_cylinder_outside_radially() {
809        let d = sdf_cylinder([2.0, 0.0, 0.0], [0.0; 3], 1.0, 4.0);
810        assert!((d - 1.0).abs() < 1e-10, "d={d}");
811    }
812
813    #[test]
814    fn test_cylinder_on_curved_surface() {
815        let d = sdf_cylinder([1.0, 0.0, 0.0], [0.0; 3], 1.0, 4.0);
816        assert!(d.abs() < 1e-10, "d={d}");
817    }
818
819    // ── Torus ───────────────────────────────────────────────────────────────
820
821    #[test]
822    fn test_torus_centre_is_positive() {
823        // The centre of the torus is outside the tube
824        let d = sdf_torus([0.0; 3], [0.0; 3], 2.0, 0.5);
825        assert!(d > 0.0, "d={d}");
826    }
827
828    #[test]
829    fn test_torus_on_tube_surface() {
830        // On the inner equator: x = r_major - r_minor, y=0, z=0
831        let d = sdf_torus([1.5, 0.0, 0.0], [0.0; 3], 2.0, 0.5);
832        assert!(d.abs() < 1e-10, "d={d}");
833    }
834}