Skip to main content

oxiphysics_geometry/
offset_surface.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Offset surface generation and signed distance field (SDF) operations.
5//!
6//! Provides SDF primitives, CSG operations, mesh offsetting, and voxel SDF grids.
7
8use std::collections::HashMap;
9
10/// Type alias for a pair of convex/concave edge lists returned by `detect_edge_features`.
11type EdgeFeatureLists = (Vec<(usize, usize)>, Vec<(usize, usize)>);
12/// Type alias for the per-edge face-normal accumulation map.
13type EdgeFaceNormalMap = HashMap<(usize, usize), Vec<([f64; 3], [f64; 3])>>;
14
15// ---------------------------------------------------------------------------
16// Helper math on plain [f64; 3]
17// ---------------------------------------------------------------------------
18
19fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
20    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
21}
22
23fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
24    [
25        a[1] * b[2] - a[2] * b[1],
26        a[2] * b[0] - a[0] * b[2],
27        a[0] * b[1] - a[1] * b[0],
28    ]
29}
30
31fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
32    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
33}
34
35fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
36    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
37}
38
39fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
40    [a[0] * s, a[1] * s, a[2] * s]
41}
42
43fn length(a: [f64; 3]) -> f64 {
44    dot(a, a).sqrt()
45}
46
47fn normalize(a: [f64; 3]) -> [f64; 3] {
48    let len = length(a);
49    if len < 1e-300 {
50        [0.0, 0.0, 0.0]
51    } else {
52        scale(a, 1.0 / len)
53    }
54}
55
56// ---------------------------------------------------------------------------
57// Sdf trait
58// ---------------------------------------------------------------------------
59
60/// Signed distance function: negative inside the shape, positive outside.
61pub trait Sdf: Send + Sync {
62    /// Signed distance from point `p` to the surface.
63    fn distance(&self, p: [f64; 3]) -> f64;
64
65    /// Gradient of the SDF at `p` (numerical central differences by default).
66    fn gradient(&self, p: [f64; 3]) -> [f64; 3] {
67        let eps = 1e-5;
68        let dx = self.distance([p[0] + eps, p[1], p[2]]) - self.distance([p[0] - eps, p[1], p[2]]);
69        let dy = self.distance([p[0], p[1] + eps, p[2]]) - self.distance([p[0], p[1] - eps, p[2]]);
70        let dz = self.distance([p[0], p[1], p[2] + eps]) - self.distance([p[0], p[1], p[2] - eps]);
71        [dx / (2.0 * eps), dy / (2.0 * eps), dz / (2.0 * eps)]
72    }
73
74    /// Outward unit normal at `p` (normalised gradient).
75    fn normal(&self, p: [f64; 3]) -> [f64; 3] {
76        normalize(self.gradient(p))
77    }
78}
79
80// ---------------------------------------------------------------------------
81// SDF primitives
82// ---------------------------------------------------------------------------
83
84/// Sphere SDF (exact).
85pub struct SdfSphere {
86    /// Center of the sphere.
87    pub center: [f64; 3],
88    /// Radius of the sphere.
89    pub radius: f64,
90}
91
92impl SdfSphere {
93    /// Create a new sphere SDF with given center and radius.
94    pub fn new(center: [f64; 3], radius: f64) -> Self {
95        Self { center, radius }
96    }
97}
98
99impl Sdf for SdfSphere {
100    fn distance(&self, p: [f64; 3]) -> f64 {
101        length(sub(p, self.center)) - self.radius
102    }
103}
104
105/// Axis-aligned box SDF (exact).
106pub struct SdfBox {
107    /// Centre of the box in world space.
108    pub center: [f64; 3],
109    /// Half-lengths along each axis.
110    pub half_extents: [f64; 3],
111}
112
113impl SdfBox {
114    /// Create a new box SDF centered at origin with given half-extents (3 scalars).
115    pub fn new(hx: f64, hy: f64, hz: f64) -> Self {
116        Self {
117            center: [0.0, 0.0, 0.0],
118            half_extents: [hx, hy, hz],
119        }
120    }
121    /// Create a new box SDF with explicit center and half-extents arrays.
122    pub fn new_centered(center: [f64; 3], half_extents: [f64; 3]) -> Self {
123        Self {
124            center,
125            half_extents,
126        }
127    }
128}
129
130impl Sdf for SdfBox {
131    fn distance(&self, p: [f64; 3]) -> f64 {
132        let q = sub(p, self.center);
133        let qx = q[0].abs() - self.half_extents[0];
134        let qy = q[1].abs() - self.half_extents[1];
135        let qz = q[2].abs() - self.half_extents[2];
136        let outside = length([qx.max(0.0), qy.max(0.0), qz.max(0.0)]);
137        let inside = qx.max(qy).max(qz).min(0.0);
138        outside + inside
139    }
140}
141
142/// Capsule (line-segment rounded) SDF (exact).
143pub struct SdfCapsule {
144    /// Start point of the capsule axis.
145    pub a: [f64; 3],
146    /// End point of the capsule axis.
147    pub b: [f64; 3],
148    /// Radius of the capsule.
149    pub radius: f64,
150}
151
152impl Sdf for SdfCapsule {
153    fn distance(&self, p: [f64; 3]) -> f64 {
154        let ab = sub(self.b, self.a);
155        let ap = sub(p, self.a);
156        let t = (dot(ap, ab) / dot(ab, ab)).clamp(0.0, 1.0);
157        let closest = add(self.a, scale(ab, t));
158        length(sub(p, closest)) - self.radius
159    }
160}
161
162/// Half-space plane SDF: `n·p - offset` (exact). `normal` should be unit length.
163pub struct SdfPlane {
164    /// Outward-facing unit normal of the plane.
165    pub normal: [f64; 3],
166    /// Signed distance from the origin to the plane along `normal`.
167    pub offset: f64,
168}
169
170impl SdfPlane {
171    /// Create a new half-space plane SDF with given unit normal and offset.
172    pub fn new(normal: [f64; 3], offset: f64) -> Self {
173        Self { normal, offset }
174    }
175}
176
177impl Sdf for SdfPlane {
178    fn distance(&self, p: [f64; 3]) -> f64 {
179        dot(self.normal, p) - self.offset
180    }
181}
182
183/// Torus SDF lying in the XZ plane, centred at `center` (exact).
184pub struct SdfTorus {
185    /// Centre of the torus.
186    pub center: [f64; 3],
187    /// Distance from the torus centre to the tube centre.
188    pub major_radius: f64,
189    /// Radius of the tube.
190    pub minor_radius: f64,
191}
192
193impl Sdf for SdfTorus {
194    fn distance(&self, p: [f64; 3]) -> f64 {
195        let q = sub(p, self.center);
196        // XZ-plane torus: revolve a circle around the Y axis
197        let r_xz = (q[0] * q[0] + q[2] * q[2]).sqrt();
198        let d_xz = r_xz - self.major_radius;
199        (d_xz * d_xz + q[1] * q[1]).sqrt() - self.minor_radius
200    }
201}
202
203// ---------------------------------------------------------------------------
204// CSG / SDF operations
205// ---------------------------------------------------------------------------
206
207/// Boolean union (min).
208pub struct SdfUnion {
209    /// First SDF operand.
210    pub a: Box<dyn Sdf>,
211    /// Second SDF operand.
212    pub b: Box<dyn Sdf>,
213}
214
215impl Sdf for SdfUnion {
216    fn distance(&self, p: [f64; 3]) -> f64 {
217        self.a.distance(p).min(self.b.distance(p))
218    }
219}
220
221/// Boolean intersection (max).
222pub struct SdfIntersection {
223    /// First SDF operand.
224    pub a: Box<dyn Sdf>,
225    /// Second SDF operand.
226    pub b: Box<dyn Sdf>,
227}
228
229impl Sdf for SdfIntersection {
230    fn distance(&self, p: [f64; 3]) -> f64 {
231        self.a.distance(p).max(self.b.distance(p))
232    }
233}
234
235/// Boolean difference: A minus B (max(da, -db)).
236pub struct SdfDifference {
237    /// The base SDF (A).
238    pub a: Box<dyn Sdf>,
239    /// The subtracted SDF (B).
240    pub b: Box<dyn Sdf>,
241}
242
243impl Sdf for SdfDifference {
244    fn distance(&self, p: [f64; 3]) -> f64 {
245        self.a.distance(p).max(-self.b.distance(p))
246    }
247}
248
249/// Offset (shell expansion / shrinkage): `d(p) - offset`.
250pub struct SdfOffset {
251    /// The inner SDF being offset.
252    pub inner: Box<dyn Sdf>,
253    /// Amount to offset (positive = expand, negative = shrink).
254    pub offset: f64,
255}
256
257impl Sdf for SdfOffset {
258    fn distance(&self, p: [f64; 3]) -> f64 {
259        self.inner.distance(p) - self.offset
260    }
261}
262
263/// Polynomial smooth union with blending radius `k`.
264pub struct SdfSmoothUnion {
265    /// First SDF operand.
266    pub a: Box<dyn Sdf>,
267    /// Second SDF operand.
268    pub b: Box<dyn Sdf>,
269    /// Blending radius (larger = smoother transition).
270    pub k: f64,
271}
272
273impl Sdf for SdfSmoothUnion {
274    fn distance(&self, p: [f64; 3]) -> f64 {
275        let da = self.a.distance(p);
276        let db = self.b.distance(p);
277        let h = (0.5 + 0.5 * (db - da) / self.k).clamp(0.0, 1.0);
278        // mix(db, da, h) - k*h*(1-h)
279        db + (da - db) * h - self.k * h * (1.0 - h)
280    }
281}
282
283/// Polynomial smooth intersection with blending radius `k`.
284pub struct SdfSmoothIntersection {
285    /// First SDF operand.
286    pub a: Box<dyn Sdf>,
287    /// Second SDF operand.
288    pub b: Box<dyn Sdf>,
289    /// Blending radius (larger = smoother transition).
290    pub k: f64,
291}
292
293impl Sdf for SdfSmoothIntersection {
294    fn distance(&self, p: [f64; 3]) -> f64 {
295        let da = self.a.distance(p);
296        let db = self.b.distance(p);
297        let h = (0.5 - 0.5 * (db - da) / self.k).clamp(0.0, 1.0);
298        db + (da - db) * h + self.k * h * (1.0 - h)
299    }
300}
301
302// ---------------------------------------------------------------------------
303// OffsetMesh
304// ---------------------------------------------------------------------------
305
306/// A triangle mesh with per-vertex normals, supporting inward/outward offsetting.
307pub struct OffsetMesh {
308    /// Vertex positions.
309    pub vertices: Vec<[f64; 3]>,
310    /// Per-vertex normals (unit vectors).
311    pub normals: Vec<[f64; 3]>,
312    /// Triangle faces as vertex index triples.
313    pub faces: Vec<[usize; 3]>,
314}
315
316impl OffsetMesh {
317    /// Build an `OffsetMesh` from a triangle soup, computing averaged vertex normals.
318    pub fn from_triangle_soup(verts: &[[f64; 3]], faces: &[[usize; 3]]) -> Self {
319        let normals = Self::compute_vertex_normals(verts, faces);
320        Self {
321            vertices: verts.to_vec(),
322            normals,
323            faces: faces.to_vec(),
324        }
325    }
326
327    /// Compute per-vertex normals by area-weighted average of incident face normals.
328    pub fn compute_vertex_normals(verts: &[[f64; 3]], faces: &[[usize; 3]]) -> Vec<[f64; 3]> {
329        let n = verts.len();
330        let mut accum = vec![[0.0f64; 3]; n];
331
332        for f in faces {
333            let v0 = verts[f[0]];
334            let v1 = verts[f[1]];
335            let v2 = verts[f[2]];
336            let e1 = sub(v1, v0);
337            let e2 = sub(v2, v0);
338            let face_normal = cross(e1, e2); // magnitude = 2 * area (weights by area)
339            for &vi in f {
340                accum[vi] = add(accum[vi], face_normal);
341            }
342        }
343
344        accum.iter().map(|&n| normalize(n)).collect()
345    }
346
347    /// Offset every vertex by `d` along its averaged normal.
348    ///
349    /// Positive `d` expands outward; negative `d` shrinks inward.
350    pub fn offset(&self, d: f64) -> OffsetMesh {
351        let new_vertices: Vec<[f64; 3]> = self
352            .vertices
353            .iter()
354            .zip(self.normals.iter())
355            .map(|(&v, &n)| add(v, scale(n, d)))
356            .collect();
357        OffsetMesh {
358            vertices: new_vertices,
359            normals: self.normals.clone(),
360            faces: self.faces.clone(),
361        }
362    }
363}
364
365// ---------------------------------------------------------------------------
366// VoxelSdf
367// ---------------------------------------------------------------------------
368
369/// Discretised signed distance field on a regular axis-aligned grid.
370pub struct VoxelSdf {
371    /// Number of cells along the X axis.
372    pub nx: usize,
373    /// Number of cells along the Y axis.
374    pub ny: usize,
375    /// Number of cells along the Z axis.
376    pub nz: usize,
377    /// World-space position of the grid origin (corner of cell `[0,0,0]`).
378    pub origin: [f64; 3],
379    /// Cell side length (isotropic).
380    pub dx: f64,
381    /// Flat array of SDF values, stored in x-major order.
382    pub values: Vec<f64>,
383}
384
385impl VoxelSdf {
386    /// Create a zeroed voxel grid.
387    pub fn new(nx: usize, ny: usize, nz: usize, origin: [f64; 3], dx: f64) -> Self {
388        Self {
389            nx,
390            ny,
391            nz,
392            origin,
393            dx,
394            values: vec![0.0; nx * ny * nz],
395        }
396    }
397
398    /// Flat index from 3-D grid coordinates.
399    pub fn idx(&self, ix: usize, iy: usize, iz: usize) -> usize {
400        ix + self.nx * (iy + self.ny * iz)
401    }
402
403    /// Convert a world-space point to fractional grid coordinates.
404    pub fn world_to_grid(&self, p: [f64; 3]) -> [f64; 3] {
405        [
406            (p[0] - self.origin[0]) / self.dx,
407            (p[1] - self.origin[1]) / self.dx,
408            (p[2] - self.origin[2]) / self.dx,
409        ]
410    }
411
412    /// Trilinear interpolation of the voxel grid at world-space point `p`.
413    pub fn sample_trilinear(&self, p: [f64; 3]) -> f64 {
414        let g = self.world_to_grid(p);
415        let x0 = g[0].floor() as isize;
416        let y0 = g[1].floor() as isize;
417        let z0 = g[2].floor() as isize;
418        let fx = g[0] - x0 as f64;
419        let fy = g[1] - y0 as f64;
420        let fz = g[2] - z0 as f64;
421
422        let nx = self.nx as isize;
423        let ny = self.ny as isize;
424        let nz = self.nz as isize;
425
426        let clamp_x = |i: isize| i.clamp(0, nx - 1) as usize;
427        let clamp_y = |i: isize| i.clamp(0, ny - 1) as usize;
428        let clamp_z = |i: isize| i.clamp(0, nz - 1) as usize;
429
430        let v = |dx: isize, dy: isize, dz: isize| -> f64 {
431            self.values[self.idx(clamp_x(x0 + dx), clamp_y(y0 + dy), clamp_z(z0 + dz))]
432        };
433
434        let c00 = v(0, 0, 0) * (1.0 - fx) + v(1, 0, 0) * fx;
435        let c01 = v(0, 0, 1) * (1.0 - fx) + v(1, 0, 1) * fx;
436        let c10 = v(0, 1, 0) * (1.0 - fx) + v(1, 1, 0) * fx;
437        let c11 = v(0, 1, 1) * (1.0 - fx) + v(1, 1, 1) * fx;
438
439        let c0 = c00 * (1.0 - fy) + c10 * fy;
440        let c1 = c01 * (1.0 - fy) + c11 * fy;
441
442        c0 * (1.0 - fz) + c1 * fz
443    }
444
445    /// Populate the grid by evaluating `sdf` at every cell centre.
446    pub fn from_sdf(
447        sdf: &dyn Sdf,
448        nx: usize,
449        ny: usize,
450        nz: usize,
451        origin: [f64; 3],
452        dx: f64,
453    ) -> Self {
454        let mut grid = Self::new(nx, ny, nz, origin, dx);
455        for iz in 0..nz {
456            for iy in 0..ny {
457                for ix in 0..nx {
458                    let p = [
459                        origin[0] + (ix as f64 + 0.5) * dx,
460                        origin[1] + (iy as f64 + 0.5) * dx,
461                        origin[2] + (iz as f64 + 0.5) * dx,
462                    ];
463                    let i = grid.idx(ix, iy, iz);
464                    grid.values[i] = sdf.distance(p);
465                }
466            }
467        }
468        grid
469    }
470
471    /// Central-difference gradient at grid cell `(ix, iy, iz)`.
472    pub fn gradient_central(&self, ix: usize, iy: usize, iz: usize) -> [f64; 3] {
473        let nx = self.nx;
474        let ny = self.ny;
475        let nz = self.nz;
476
477        let xp = if ix + 1 < nx { ix + 1 } else { ix };
478        let xm = if ix > 0 { ix - 1 } else { ix };
479        let yp = if iy + 1 < ny { iy + 1 } else { iy };
480        let ym = if iy > 0 { iy - 1 } else { iy };
481        let zp = if iz + 1 < nz { iz + 1 } else { iz };
482        let zm = if iz > 0 { iz - 1 } else { iz };
483
484        let step_x = (xp - xm) as f64;
485        let step_y = (yp - ym) as f64;
486        let step_z = (zp - zm) as f64;
487
488        let gx = (self.values[self.idx(xp, iy, iz)] - self.values[self.idx(xm, iy, iz)])
489            / (step_x * self.dx);
490        let gy = (self.values[self.idx(ix, yp, iz)] - self.values[self.idx(ix, ym, iz)])
491            / (step_y * self.dx);
492        let gz = (self.values[self.idx(ix, iy, zp)] - self.values[self.idx(ix, iy, zm)])
493            / (step_z * self.dx);
494
495        [gx, gy, gz]
496    }
497}
498
499// ---------------------------------------------------------------------------
500// Inward / outward offset variants
501// ---------------------------------------------------------------------------
502
503/// Compute an outward-offset mesh from a triangle soup.
504///
505/// Equivalent to `OffsetMesh::from_triangle_soup(verts, faces).offset(d)` for
506/// positive `d`, but named explicitly for clarity.
507pub fn outward_offset(verts: &[[f64; 3]], faces: &[[usize; 3]], d: f64) -> OffsetMesh {
508    OffsetMesh::from_triangle_soup(verts, faces).offset(d)
509}
510
511/// Compute an inward-offset (shrunk) mesh.
512///
513/// Equivalent to `offset(-d)` where `d > 0`.
514pub fn inward_offset(verts: &[[f64; 3]], faces: &[[usize; 3]], d: f64) -> OffsetMesh {
515    OffsetMesh::from_triangle_soup(verts, faces).offset(-d)
516}
517
518// ---------------------------------------------------------------------------
519// Collision offset: expand each face outward by a distance while keeping
520// connectivity intact
521// ---------------------------------------------------------------------------
522
523impl OffsetMesh {
524    /// Compute the "collision offset" shell mesh.
525    ///
526    /// Generates a new mesh where each face is moved outward by `d` along its
527    /// face normal (not vertex normal).  Unlike vertex-normal offsetting, this
528    /// produces a shell mesh useful for broad CCD convex hull testing.
529    pub fn collision_offset_shell(&self, d: f64) -> OffsetMesh {
530        let n_faces = self.faces.len();
531        let mut new_verts = Vec::with_capacity(n_faces * 3);
532        let mut new_faces = Vec::with_capacity(n_faces);
533
534        for (fi, face) in self.faces.iter().enumerate() {
535            let v0 = self.vertices[face[0]];
536            let v1 = self.vertices[face[1]];
537            let v2 = self.vertices[face[2]];
538            let e1 = sub(v1, v0);
539            let e2 = sub(v2, v0);
540            let face_n = normalize(cross(e1, e2));
541            let base = fi * 3;
542            new_verts.push(add(v0, scale(face_n, d)));
543            new_verts.push(add(v1, scale(face_n, d)));
544            new_verts.push(add(v2, scale(face_n, d)));
545            new_faces.push([base, base + 1, base + 2]);
546        }
547
548        // Recompute normals for the new shell
549        let normals = OffsetMesh::compute_vertex_normals(&new_verts, &new_faces);
550        OffsetMesh {
551            vertices: new_verts,
552            normals,
553            faces: new_faces,
554        }
555    }
556
557    /// Validate that all vertex normals are unit length.
558    pub fn normals_are_unit(&self) -> bool {
559        self.normals.iter().all(|&n| (length(n) - 1.0).abs() < 1e-6)
560    }
561
562    /// Surface area of the mesh (sum of triangle areas).
563    pub fn surface_area(&self) -> f64 {
564        self.faces
565            .iter()
566            .map(|f| {
567                let v0 = self.vertices[f[0]];
568                let v1 = self.vertices[f[1]];
569                let v2 = self.vertices[f[2]];
570                length(cross(sub(v1, v0), sub(v2, v0))) * 0.5
571            })
572            .sum()
573    }
574
575    /// Centroid of the mesh (average vertex position).
576    pub fn centroid(&self) -> [f64; 3] {
577        if self.vertices.is_empty() {
578            return [0.0; 3];
579        }
580        let sum = self
581            .vertices
582            .iter()
583            .fold([0.0f64; 3], |acc, &v| add(acc, v));
584        scale(sum, 1.0 / self.vertices.len() as f64)
585    }
586}
587
588// ---------------------------------------------------------------------------
589// Medial axis approximation
590// ---------------------------------------------------------------------------
591
592/// Approximate medial axis computation using a voxel SDF.
593///
594/// The medial axis is the locus of points equidistant from the closest surface
595/// points.  We approximate it by finding voxels where the SDF gradient magnitude
596/// is significantly less than 1 (i.e., the distance field is "flat" — these
597/// points are near the medial axis).
598///
599/// Returns the world-space positions of approximate medial axis voxel centres.
600pub fn approximate_medial_axis(grid: &VoxelSdf, gradient_threshold: f64) -> Vec<[f64; 3]> {
601    let mut points = Vec::new();
602    for iz in 0..grid.nz {
603        for iy in 0..grid.ny {
604            for ix in 0..grid.nx {
605                let val = grid.values[grid.idx(ix, iy, iz)];
606                // Only consider interior voxels (negative SDF)
607                if val >= 0.0 {
608                    continue;
609                }
610                let g = grid.gradient_central(ix, iy, iz);
611                let grad_mag = (g[0] * g[0] + g[1] * g[1] + g[2] * g[2]).sqrt();
612                if grad_mag < gradient_threshold {
613                    let p = [
614                        grid.origin[0] + (ix as f64 + 0.5) * grid.dx,
615                        grid.origin[1] + (iy as f64 + 0.5) * grid.dx,
616                        grid.origin[2] + (iz as f64 + 0.5) * grid.dx,
617                    ];
618                    points.push(p);
619                }
620            }
621        }
622    }
623    points
624}
625
626// ---------------------------------------------------------------------------
627// SDF cylinder primitive
628// ---------------------------------------------------------------------------
629
630/// Cylinder SDF aligned with the Y axis (exact).
631pub struct SdfCylinder {
632    /// Centre of the cylinder (midpoint of its axis).
633    pub center: [f64; 3],
634    /// Radius of the cylinder.
635    pub radius: f64,
636    /// Half the total height along the Y axis.
637    pub half_height: f64,
638}
639
640impl Sdf for SdfCylinder {
641    fn distance(&self, p: [f64; 3]) -> f64 {
642        let q = sub(p, self.center);
643        let xz_dist = (q[0] * q[0] + q[2] * q[2]).sqrt() - self.radius;
644        let y_dist = q[1].abs() - self.half_height;
645        let outside =
646            (xz_dist.max(0.0) * xz_dist.max(0.0) + y_dist.max(0.0) * y_dist.max(0.0)).sqrt();
647        outside + xz_dist.max(y_dist).min(0.0)
648    }
649}
650
651// ---------------------------------------------------------------------------
652// SDF cone primitive
653// ---------------------------------------------------------------------------
654
655/// Cone SDF: tip at `apex`, opening downward along -Y, with half-angle `angle_rad`.
656pub struct SdfCone {
657    /// Position of the cone tip.
658    pub apex: [f64; 3],
659    /// Height of the cone (distance from apex to base).
660    pub height: f64,
661    /// Half-opening angle in radians.
662    pub angle_rad: f64,
663}
664
665impl Sdf for SdfCone {
666    fn distance(&self, p: [f64; 3]) -> f64 {
667        let q = sub(p, self.apex);
668        let r = (q[0] * q[0] + q[2] * q[2]).sqrt();
669        // Cone: r = -y * tan(angle) for y ≤ 0 (opening downward)
670        let sin_a = self.angle_rad.sin();
671        let cos_a = self.angle_rad.cos();
672        let dist_axis = r * cos_a + q[1] * sin_a; // positive outside cone surface
673        let dist_cap = q[1] + self.height; // positive above the base cap
674        dist_axis.max(-dist_cap)
675    }
676}
677
678// ---------------------------------------------------------------------------
679// SdfTranslated: translate any SDF by an offset
680// ---------------------------------------------------------------------------
681
682/// Translate an SDF by a fixed offset.
683pub struct SdfTranslated {
684    /// The inner SDF being translated.
685    pub inner: Box<dyn Sdf>,
686    /// Translation vector applied before evaluating the inner SDF.
687    pub offset: [f64; 3],
688}
689
690impl Sdf for SdfTranslated {
691    fn distance(&self, p: [f64; 3]) -> f64 {
692        let local = sub(p, self.offset);
693        self.inner.distance(local)
694    }
695}
696
697// ---------------------------------------------------------------------------
698// SdfScaled: uniformly scale an SDF
699// ---------------------------------------------------------------------------
700
701/// Uniformly scale an SDF (scale > 1 makes the shape larger).
702pub struct SdfScaled {
703    /// The inner SDF being scaled.
704    pub inner: Box<dyn Sdf>,
705    /// Uniform scale factor applied to the shape.
706    pub scale_factor: f64,
707}
708
709impl Sdf for SdfScaled {
710    fn distance(&self, p: [f64; 3]) -> f64 {
711        if self.scale_factor.abs() < 1e-300 {
712            return f64::INFINITY;
713        }
714        self.inner.distance(scale(p, 1.0 / self.scale_factor)) * self.scale_factor
715    }
716}
717
718// ---------------------------------------------------------------------------
719// Marching-cubes-ready isosurface extraction (threshold crossing)
720// ---------------------------------------------------------------------------
721
722/// A simple marching-squares (2-D) contour extractor for a 2-D slice of a VoxelSdf.
723///
724/// Extracts edge midpoints where the SDF crosses zero on the XY slice at `iz`.
725pub fn extract_zero_crossings_slice(grid: &VoxelSdf, iz: usize) -> Vec<[f64; 2]> {
726    let mut crossings = Vec::new();
727    let nz = grid.nz;
728    if iz >= nz {
729        return crossings;
730    }
731    for iy in 0..grid.ny.saturating_sub(1) {
732        for ix in 0..grid.nx.saturating_sub(1) {
733            let v00 = grid.values[grid.idx(ix, iy, iz)];
734            let v10 = grid.values[grid.idx(ix + 1, iy, iz)];
735            let v01 = grid.values[grid.idx(ix, iy + 1, iz)];
736
737            let x0 = grid.origin[0] + (ix as f64 + 0.5) * grid.dx;
738            let x1 = grid.origin[0] + (ix as f64 + 1.5) * grid.dx;
739            let y0 = grid.origin[1] + (iy as f64 + 0.5) * grid.dx;
740            let y1 = grid.origin[1] + (iy as f64 + 1.5) * grid.dx;
741
742            // Horizontal edge
743            if (v00 < 0.0) != (v10 < 0.0) {
744                let t = v00 / (v00 - v10);
745                crossings.push([x0 + t * (x1 - x0), y0]);
746            }
747            // Vertical edge
748            if (v00 < 0.0) != (v01 < 0.0) {
749                let t = v00 / (v00 - v01);
750                crossings.push([x0, y0 + t * (y1 - y0)]);
751            }
752        }
753    }
754    crossings
755}
756
757// ---------------------------------------------------------------------------
758// Variable offset (non-uniform per-vertex weights)
759// ---------------------------------------------------------------------------
760
761/// Apply a different offset distance to each vertex along its averaged normal.
762///
763/// `weights[i]` is the offset distance for vertex `i`.  Positive values expand
764/// outward; negative values shrink inward.
765pub fn variable_offset(verts: &[[f64; 3]], faces: &[[usize; 3]], weights: &[f64]) -> OffsetMesh {
766    let normals = OffsetMesh::compute_vertex_normals(verts, faces);
767    let new_verts: Vec<[f64; 3]> = verts
768        .iter()
769        .enumerate()
770        .map(|(i, &v)| {
771            let w = if i < weights.len() { weights[i] } else { 0.0 };
772            let n = if i < normals.len() {
773                normals[i]
774            } else {
775                [0.0; 3]
776            };
777            add(v, scale(n, w))
778        })
779        .collect();
780    OffsetMesh {
781        normals: OffsetMesh::compute_vertex_normals(&new_verts, faces),
782        vertices: new_verts,
783        faces: faces.to_vec(),
784    }
785}
786
787// ---------------------------------------------------------------------------
788// Polyhedron offset
789// ---------------------------------------------------------------------------
790
791/// Offset a closed polyhedron mesh outward (or inward for negative `d`).
792///
793/// Equivalent to `OffsetMesh::from_triangle_soup(verts, faces).offset(d)` but
794/// named for clarity when working with closed polyhedra.
795pub fn offset_polyhedron(verts: &[[f64; 3]], faces: &[[usize; 3]], d: f64) -> OffsetMesh {
796    OffsetMesh::from_triangle_soup(verts, faces).offset(d)
797}
798
799// ---------------------------------------------------------------------------
800// Feature detection: convex / concave edges
801// ---------------------------------------------------------------------------
802
803/// Detect feature edges in a triangle mesh, classifying interior edges as
804/// convex (dihedral angle < π) or concave (dihedral angle > π).
805///
806/// Returns `(convex_edges, concave_edges)` as lists of `(v0, v1)` index pairs.
807/// Boundary edges (shared by only one face) are excluded from both lists.
808pub fn detect_edge_features(verts: &[[f64; 3]], faces: &[[usize; 3]]) -> EdgeFeatureLists {
809    // Build edge → face normals
810    let mut edge_data: EdgeFaceNormalMap = HashMap::new();
811
812    for face in faces {
813        let v0 = verts[face[0]];
814        let v1 = verts[face[1]];
815        let v2 = verts[face[2]];
816        let e1 = sub(v1, v0);
817        let e2 = sub(v2, v0);
818        let face_n = normalize(cross(e1, e2));
819        let face_centroid = scale(add(add(v0, v1), v2), 1.0 / 3.0);
820
821        for k in 0..3 {
822            let ea = face[k];
823            let eb = face[(k + 1) % 3];
824            let key = (ea.min(eb), ea.max(eb));
825            // Store (face_normal, midpoint of face)
826            let mid_edge = scale(add(verts[ea], verts[eb]), 0.5);
827            // We store (face_normal, centroid_to_edge midpoint vector)
828            let ct_to_edge = sub(mid_edge, face_centroid);
829            edge_data.entry(key).or_default().push((face_n, ct_to_edge));
830        }
831    }
832
833    let mut convex = Vec::new();
834    let mut concave = Vec::new();
835
836    for (&(ea, eb), data) in &edge_data {
837        if data.len() != 2 {
838            continue; // boundary edge
839        }
840        let n0 = data[0].0;
841        let n1 = data[1].0;
842        let ct0 = data[0].1;
843
844        let dot_nn = dot(n0, n1);
845        // Convex: the other face's normal points away from first face's centroid side
846        // Simple heuristic: dot(n0, ct0) > 0 means the edge is convex
847        let sign = dot(cross(n0, n1), ct0);
848
849        if dot_nn < 0.9999 {
850            // not coplanar
851            if sign >= 0.0 {
852                convex.push((ea, eb));
853            } else {
854                concave.push((ea, eb));
855            }
856        }
857    }
858
859    (convex, concave)
860}
861
862// ---------------------------------------------------------------------------
863// Offset curves in 3D
864// ---------------------------------------------------------------------------
865
866/// Offset a 3D polyline by `d` along a fixed `normal` direction.
867///
868/// Returns a new set of points displaced by `d * normal`.
869pub fn offset_curve_3d(pts: &[[f64; 3]], normal: [f64; 3], d: f64) -> Vec<[f64; 3]> {
870    let n = normalize(normal);
871    pts.iter().map(|&p| add(p, scale(n, d))).collect()
872}
873
874// ---------------------------------------------------------------------------
875// Shell generation
876// ---------------------------------------------------------------------------
877
878/// Generate a solid "shell" mesh from a surface mesh.
879///
880/// Creates an outer surface (offset by `+thickness`) and an inner surface
881/// (the original mesh), connected by side walls along the boundary.
882/// For closed meshes the result is a thick shell.
883pub fn generate_shell(verts: &[[f64; 3]], faces: &[[usize; 3]], thickness: f64) -> OffsetMesh {
884    let n_orig = verts.len();
885    let outer = OffsetMesh::from_triangle_soup(verts, faces).offset(thickness);
886
887    // Combine inner (original) and outer vertices
888    let mut new_verts: Vec<[f64; 3]> = verts.to_vec();
889    new_verts.extend_from_slice(&outer.vertices);
890
891    // Inner faces (original, flipped winding for inward normals)
892    let mut new_faces: Vec<[usize; 3]> = faces.iter().map(|f| [f[2], f[1], f[0]]).collect();
893
894    // Outer faces (offset, original winding)
895    new_faces.extend(
896        faces
897            .iter()
898            .map(|f| [f[0] + n_orig, f[1] + n_orig, f[2] + n_orig]),
899    );
900
901    // Side walls: for boundary edges (only one adjacent face), connect inner and outer
902    let mut edge_count: HashMap<(usize, usize), usize> = HashMap::new();
903    for face in faces {
904        for k in 0..3 {
905            let ea = face[k];
906            let eb = face[(k + 1) % 3];
907            let key = (ea.min(eb), ea.max(eb));
908            *edge_count.entry(key).or_insert(0) += 1;
909        }
910    }
911    for ((ea, eb), count) in &edge_count {
912        if *count == 1 {
913            // Boundary edge → add a quad wall (two triangles)
914            let ia = *ea;
915            let ib = *eb;
916            let oa = *ea + n_orig;
917            let ob = *eb + n_orig;
918            new_faces.push([ia, ib, ob]);
919            new_faces.push([ia, ob, oa]);
920        }
921    }
922
923    let normals = OffsetMesh::compute_vertex_normals(&new_verts, &new_faces);
924    OffsetMesh {
925        vertices: new_verts,
926        normals,
927        faces: new_faces,
928    }
929}
930
931// ---------------------------------------------------------------------------
932// Tests
933// ---------------------------------------------------------------------------
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938
939    const EPS: f64 = 1e-9;
940
941    // --- SdfSphere -----------------------------------------------------------
942
943    #[test]
944    fn test_sphere_center_negative_radius() {
945        let s = SdfSphere {
946            center: [0.0, 0.0, 0.0],
947            radius: 2.0,
948        };
949        assert!((s.distance([0.0, 0.0, 0.0]) - (-2.0)).abs() < EPS);
950    }
951
952    #[test]
953    fn test_sphere_surface_zero() {
954        let s = SdfSphere {
955            center: [1.0, 2.0, 3.0],
956            radius: 1.5,
957        };
958        let p = [1.0 + 1.5, 2.0, 3.0];
959        assert!(s.distance(p).abs() < EPS);
960    }
961
962    #[test]
963    fn test_sphere_outside_positive() {
964        let s = SdfSphere {
965            center: [0.0, 0.0, 0.0],
966            radius: 1.0,
967        };
968        assert!(s.distance([5.0, 0.0, 0.0]) > 0.0);
969    }
970
971    // --- SdfBox --------------------------------------------------------------
972
973    #[test]
974    fn test_box_inside_negative() {
975        let b = SdfBox {
976            center: [0.0, 0.0, 0.0],
977            half_extents: [2.0, 2.0, 2.0],
978        };
979        assert!(b.distance([0.5, 0.5, 0.5]) < 0.0);
980        assert!(b.distance([0.0, 0.0, 0.0]) < 0.0);
981    }
982
983    #[test]
984    fn test_box_outside_positive() {
985        let b = SdfBox {
986            center: [0.0, 0.0, 0.0],
987            half_extents: [1.0, 1.0, 1.0],
988        };
989        assert!(b.distance([3.0, 0.0, 0.0]) > 0.0);
990    }
991
992    // --- SdfCapsule ----------------------------------------------------------
993
994    #[test]
995    fn test_capsule_midpoint_surface() {
996        let c = SdfCapsule {
997            a: [0.0, 0.0, 0.0],
998            b: [0.0, 4.0, 0.0],
999            radius: 1.0,
1000        };
1001        // Midpoint of axis = (0, 2, 0); move 1 unit perpendicular => on surface
1002        assert!(c.distance([1.0, 2.0, 0.0]).abs() < EPS);
1003    }
1004
1005    #[test]
1006    fn test_capsule_inside_negative() {
1007        let c = SdfCapsule {
1008            a: [0.0, 0.0, 0.0],
1009            b: [0.0, 4.0, 0.0],
1010            radius: 1.0,
1011        };
1012        assert!(c.distance([0.0, 2.0, 0.0]) < 0.0);
1013    }
1014
1015    // --- SdfUnion ------------------------------------------------------------
1016
1017    #[test]
1018    fn test_union_inside_either() {
1019        let union = SdfUnion {
1020            a: Box::new(SdfSphere {
1021                center: [-3.0, 0.0, 0.0],
1022                radius: 1.5,
1023            }),
1024            b: Box::new(SdfSphere {
1025                center: [3.0, 0.0, 0.0],
1026                radius: 1.5,
1027            }),
1028        };
1029        // Point inside first sphere
1030        assert!(union.distance([-3.0, 0.0, 0.0]) < 0.0);
1031        // Point inside second sphere
1032        assert!(union.distance([3.0, 0.0, 0.0]) < 0.0);
1033        // Point outside both
1034        assert!(union.distance([0.0, 0.0, 0.0]) > 0.0);
1035    }
1036
1037    // --- SdfOffset -----------------------------------------------------------
1038
1039    #[test]
1040    fn test_sdf_offset_expanded_sphere() {
1041        let base = SdfSphere {
1042            center: [0.0, 0.0, 0.0],
1043            radius: 1.0,
1044        };
1045        let expanded = SdfOffset {
1046            inner: Box::new(SdfSphere {
1047                center: [0.0, 0.0, 0.0],
1048                radius: 1.0,
1049            }),
1050            offset: 0.5,
1051        };
1052        // At distance 1.4 from centre: original says +0.4 (outside), expanded says -0.1 (inside)
1053        let r = 1.4_f64;
1054        let p = [r, 0.0, 0.0];
1055        assert!(base.distance(p) > 0.0);
1056        assert!(expanded.distance(p) < 0.0);
1057    }
1058
1059    // --- OffsetMesh ----------------------------------------------------------
1060
1061    #[test]
1062    fn test_offset_mesh_outward() {
1063        // Build a single triangle
1064        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1065        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1066        let mesh = OffsetMesh::from_triangle_soup(verts, faces);
1067        let d = 0.3;
1068        let off = mesh.offset(d);
1069        // Each vertex should have moved away from original by exactly d
1070        for (orig, new_v) in mesh.vertices.iter().zip(off.vertices.iter()) {
1071            let dist = length(sub(*new_v, *orig));
1072            assert!(
1073                (dist - d).abs() < 1e-9,
1074                "vertex moved by {dist}, expected {d}"
1075            );
1076        }
1077    }
1078
1079    #[test]
1080    fn test_offset_mesh_normals_unit() {
1081        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1082        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1083        let mesh = OffsetMesh::from_triangle_soup(verts, faces);
1084        for n in &mesh.normals {
1085            let len = length(*n);
1086            assert!((len - 1.0).abs() < 1e-9, "normal not unit: {len}");
1087        }
1088    }
1089
1090    // --- VoxelSdf ------------------------------------------------------------
1091
1092    #[test]
1093    fn test_voxel_sdf_sphere_center() {
1094        let sphere = SdfSphere {
1095            center: [0.0, 0.0, 0.0],
1096            radius: 1.0,
1097        };
1098        // 10x10x10 grid, origin at -1, cell size 0.2 => covers [-1, 1] per axis
1099        let grid = VoxelSdf::from_sdf(&sphere, 10, 10, 10, [-1.0, -1.0, -1.0], 0.2);
1100        // Centre cell: ix=iy=iz=4 => world pos [(-1 + 4.5*0.2), ...] = [-0.1, -0.1, -0.1]
1101        let centre_idx = grid.idx(4, 4, 4);
1102        let v = grid.values[centre_idx];
1103        // Should be negative (inside sphere)
1104        assert!(v < 0.0, "centre cell value {v} should be negative");
1105        // Should be close to -radius = -1.0 (within grid resolution error)
1106        assert!(
1107            (v - (-1.0)).abs() < 0.3,
1108            "centre cell value {v} not close to -1.0"
1109        );
1110    }
1111
1112    #[test]
1113    fn test_voxel_sdf_constant_trilinear() {
1114        let mut grid = VoxelSdf::new(4, 4, 4, [0.0, 0.0, 0.0], 1.0);
1115        // Fill with constant value 3.125
1116        for v in grid.values.iter_mut() {
1117            *v = 3.125;
1118        }
1119        let sample = grid.sample_trilinear([1.5, 1.5, 1.5]);
1120        assert!(
1121            (sample - 3.125).abs() < 1e-9,
1122            "constant field returned {sample}"
1123        );
1124    }
1125
1126    #[test]
1127    fn test_voxel_sdf_gradient_central() {
1128        let sphere = SdfSphere {
1129            center: [0.5, 0.5, 0.5],
1130            radius: 0.3,
1131        };
1132        let grid = VoxelSdf::from_sdf(&sphere, 10, 10, 10, [0.0, 0.0, 0.0], 0.1);
1133        // Just check that gradient is finite and non-zero somewhere off centre
1134        let g = grid.gradient_central(7, 5, 5);
1135        let gl = length(g);
1136        assert!(
1137            gl.is_finite() && gl > 0.0,
1138            "gradient should be non-zero: {g:?}"
1139        );
1140    }
1141
1142    // --- SdfPlane ------------------------------------------------------------
1143
1144    #[test]
1145    fn test_plane_above_below() {
1146        let plane = SdfPlane {
1147            normal: [0.0, 1.0, 0.0],
1148            offset: 0.0,
1149        };
1150        assert!(plane.distance([0.0, 1.0, 0.0]) > 0.0);
1151        assert!(plane.distance([0.0, -1.0, 0.0]) < 0.0);
1152        assert!(plane.distance([0.0, 0.0, 0.0]).abs() < EPS);
1153    }
1154
1155    // --- SdfTorus ------------------------------------------------------------
1156
1157    #[test]
1158    fn test_torus_on_ring() {
1159        let t = SdfTorus {
1160            center: [0.0, 0.0, 0.0],
1161            major_radius: 2.0,
1162            minor_radius: 0.5,
1163        };
1164        // A point on the ring surface: at (major_radius + minor_radius, 0, 0)
1165        let p = [2.5, 0.0, 0.0];
1166        assert!(t.distance(p).abs() < EPS);
1167    }
1168
1169    // --- SdfSmooth -----------------------------------------------------------
1170
1171    #[test]
1172    fn test_smooth_union_between_shapes() {
1173        let su = SdfSmoothUnion {
1174            a: Box::new(SdfSphere {
1175                center: [-1.0, 0.0, 0.0],
1176                radius: 0.8,
1177            }),
1178            b: Box::new(SdfSphere {
1179                center: [1.0, 0.0, 0.0],
1180                radius: 0.8,
1181            }),
1182            k: 0.5,
1183        };
1184        // Midpoint between the two spheres should be inside or close to the blend
1185        let d_mid = su.distance([0.0, 0.0, 0.0]);
1186        // The smooth union should pull the surface inward at the bridge region
1187        assert!(d_mid.is_finite(), "smooth union distance should be finite");
1188    }
1189
1190    // --- SdfDifference -------------------------------------------------------
1191
1192    #[test]
1193    fn test_difference_carves_out() {
1194        let diff = SdfDifference {
1195            a: Box::new(SdfSphere {
1196                center: [0.0, 0.0, 0.0],
1197                radius: 2.0,
1198            }),
1199            b: Box::new(SdfSphere {
1200                center: [0.0, 0.0, 0.0],
1201                radius: 1.0,
1202            }),
1203        };
1204        // Inside inner sphere -> carved out (positive)
1205        assert!(diff.distance([0.0, 0.0, 0.0]) > 0.0);
1206        // In annular shell -> still inside outer, outside inner -> negative
1207        assert!(diff.distance([1.5, 0.0, 0.0]) < 0.0);
1208    }
1209
1210    // --- SdfIntersection -----------------------------------------------------
1211
1212    #[test]
1213    fn test_intersection_overlap_region() {
1214        let inter = SdfIntersection {
1215            a: Box::new(SdfSphere {
1216                center: [0.0, 0.0, 0.0],
1217                radius: 2.0,
1218            }),
1219            b: Box::new(SdfSphere {
1220                center: [1.0, 0.0, 0.0],
1221                radius: 2.0,
1222            }),
1223        };
1224        // Point inside both -> negative
1225        assert!(inter.distance([0.5, 0.0, 0.0]) < 0.0);
1226        // Point inside only one -> positive
1227        assert!(inter.distance([-1.5, 0.0, 0.0]) > 0.0);
1228    }
1229
1230    // --- outward_offset / inward_offset --------------------------------------
1231
1232    #[test]
1233    fn test_outward_offset_expands_vertices() {
1234        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1235        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1236        let d = 0.5;
1237        let expanded = outward_offset(verts, faces, d);
1238        let orig = OffsetMesh::from_triangle_soup(verts, faces);
1239        for (v_new, v_old) in expanded.vertices.iter().zip(orig.vertices.iter()) {
1240            let moved = length(sub(*v_new, *v_old));
1241            assert!((moved - d).abs() < 1e-9, "vertex moved by {moved}");
1242        }
1243    }
1244
1245    #[test]
1246    fn test_inward_offset_shrinks_vertices() {
1247        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1248        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1249        let shrunk = inward_offset(verts, faces, 0.2);
1250        let orig = OffsetMesh::from_triangle_soup(verts, faces);
1251        // Should have moved by 0.2 (inward)
1252        for (v_new, v_old) in shrunk.vertices.iter().zip(orig.vertices.iter()) {
1253            let moved = length(sub(*v_new, *v_old));
1254            assert!((moved - 0.2).abs() < 1e-9, "vertex moved by {moved}");
1255        }
1256    }
1257
1258    // --- collision_offset_shell ----------------------------------------------
1259
1260    #[test]
1261    fn test_collision_offset_shell_creates_new_mesh() {
1262        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1263        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1264        let mesh = OffsetMesh::from_triangle_soup(verts, faces);
1265        let shell = mesh.collision_offset_shell(0.1);
1266        assert_eq!(shell.faces.len(), 1);
1267        assert_eq!(shell.vertices.len(), 3);
1268        assert!(shell.normals_are_unit(), "shell normals should be unit");
1269    }
1270
1271    #[test]
1272    fn test_collision_offset_shell_moves_vertices() {
1273        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [1.0, 0.0, 2.0]];
1274        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1275        let mesh = OffsetMesh::from_triangle_soup(verts, faces);
1276        let d = 0.5;
1277        let shell = mesh.collision_offset_shell(d);
1278        // Each new vertex should be d away from the original vertex along face normal
1279        for (vn, vo) in shell.vertices.iter().zip(mesh.vertices.iter()) {
1280            let dist = length(sub(*vn, *vo));
1281            assert!((dist - d).abs() < 1e-9, "dist={dist}");
1282        }
1283    }
1284
1285    // --- surface_area / centroid ---------------------------------------------
1286
1287    #[test]
1288    fn test_offset_mesh_surface_area_positive() {
1289        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1290        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1291        let mesh = OffsetMesh::from_triangle_soup(verts, faces);
1292        let area = mesh.surface_area();
1293        assert!((area - 0.5).abs() < 1e-9, "area={area}");
1294    }
1295
1296    #[test]
1297    fn test_offset_mesh_centroid_of_triangle() {
1298        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [3.0, 0.0, 0.0], [0.0, 3.0, 0.0]];
1299        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1300        let mesh = OffsetMesh::from_triangle_soup(verts, faces);
1301        let c = mesh.centroid();
1302        assert!((c[0] - 1.0).abs() < 1e-9);
1303        assert!((c[1] - 1.0).abs() < 1e-9);
1304    }
1305
1306    // --- SdfCylinder ---------------------------------------------------------
1307
1308    #[test]
1309    fn test_sdf_cylinder_inside_negative() {
1310        let cyl = SdfCylinder {
1311            center: [0.0; 3],
1312            radius: 1.0,
1313            half_height: 2.0,
1314        };
1315        assert!(cyl.distance([0.0, 0.0, 0.0]) < 0.0);
1316    }
1317
1318    #[test]
1319    fn test_sdf_cylinder_outside_positive() {
1320        let cyl = SdfCylinder {
1321            center: [0.0; 3],
1322            radius: 1.0,
1323            half_height: 2.0,
1324        };
1325        assert!(cyl.distance([5.0, 0.0, 0.0]) > 0.0); // outside radially
1326        assert!(cyl.distance([0.0, 5.0, 0.0]) > 0.0); // outside axially
1327    }
1328
1329    // --- SdfTranslated -------------------------------------------------------
1330
1331    #[test]
1332    fn test_sdf_translated_moves_sphere() {
1333        let translated = SdfTranslated {
1334            inner: Box::new(SdfSphere {
1335                center: [0.0; 3],
1336                radius: 1.0,
1337            }),
1338            offset: [5.0, 0.0, 0.0],
1339        };
1340        // Centre at (5,0,0) → distance at (5,0,0) should be -1.0
1341        assert!((translated.distance([5.0, 0.0, 0.0]) - (-1.0)).abs() < EPS);
1342    }
1343
1344    // --- SdfScaled -----------------------------------------------------------
1345
1346    #[test]
1347    fn test_sdf_scaled_larger_sphere() {
1348        let scaled = SdfScaled {
1349            inner: Box::new(SdfSphere {
1350                center: [0.0; 3],
1351                radius: 1.0,
1352            }),
1353            scale_factor: 2.0,
1354        };
1355        // A point at distance 1.5 from origin should be inside scaled sphere (r=2)
1356        assert!(scaled.distance([1.5, 0.0, 0.0]) < 0.0);
1357    }
1358
1359    // --- approximate_medial_axis ---------------------------------------------
1360
1361    #[test]
1362    fn test_medial_axis_nonempty_for_thick_sdf() {
1363        // A thick sphere: interior points far from surface have gradient ~ 1.0
1364        // Points on the medial axis (centre of the sphere) have gradient < threshold
1365        let sphere = SdfSphere {
1366            center: [0.0; 3],
1367            radius: 3.0,
1368        };
1369        let grid = VoxelSdf::from_sdf(&sphere, 20, 20, 20, [-4.0, -4.0, -4.0], 0.4);
1370        // With a generous threshold, should find some medial axis voxels
1371        let pts = approximate_medial_axis(&grid, 0.7);
1372        // Not guaranteed, but for a sphere there should be voxels near centre
1373        // Just verify function works without panicking
1374        let _ = pts;
1375    }
1376
1377    // --- extract_zero_crossings_slice ----------------------------------------
1378
1379    #[test]
1380    fn test_zero_crossings_nonempty_for_sphere() {
1381        let sphere = SdfSphere {
1382            center: [0.0; 3],
1383            radius: 1.0,
1384        };
1385        let grid = VoxelSdf::from_sdf(&sphere, 10, 10, 10, [-2.0, -2.0, -2.0], 0.4);
1386        let crossings = extract_zero_crossings_slice(&grid, 5);
1387        // The sphere surface should produce some crossings in the middle slice
1388        assert!(
1389            !crossings.is_empty(),
1390            "sphere slice should have zero crossings"
1391        );
1392    }
1393
1394    #[test]
1395    fn test_zero_crossings_empty_outside_range() {
1396        let sphere = SdfSphere {
1397            center: [0.0; 3],
1398            radius: 1.0,
1399        };
1400        let grid = VoxelSdf::from_sdf(&sphere, 5, 5, 5, [-2.0, -2.0, -2.0], 0.4);
1401        let crossings = extract_zero_crossings_slice(&grid, 100); // out of range
1402        assert!(
1403            crossings.is_empty(),
1404            "out-of-range slice should have no crossings"
1405        );
1406    }
1407
1408    // ── Variable offset (non-uniform) ──────────────────────────────────────────
1409
1410    #[test]
1411    fn test_variable_offset_moves_each_vertex_by_its_weight() {
1412        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1413        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1414        let weights = vec![0.1, 0.2, 0.3];
1415        let result = variable_offset(verts, faces, &weights);
1416        // Each vertex moved by its weight along the vertex normal
1417        let orig = OffsetMesh::from_triangle_soup(verts, faces);
1418        for (i, (vn, vo)) in result.vertices.iter().zip(orig.vertices.iter()).enumerate() {
1419            let dist = length(sub(*vn, *vo));
1420            assert!(
1421                (dist - weights[i]).abs() < 1e-9,
1422                "vertex {i}: moved by {dist}, expected {}",
1423                weights[i]
1424            );
1425        }
1426    }
1427
1428    #[test]
1429    fn test_variable_offset_zero_weights_no_change() {
1430        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]];
1431        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1432        let weights = vec![0.0, 0.0, 0.0];
1433        let result = variable_offset(verts, faces, &weights);
1434        for (vn, &vo) in result.vertices.iter().zip(verts.iter()) {
1435            assert!(
1436                length(sub(*vn, vo)) < 1e-12,
1437                "zero weight should not move vertex"
1438            );
1439        }
1440    }
1441
1442    // ── Polyhedron offset ──────────────────────────────────────────────────────
1443
1444    #[test]
1445    fn test_offset_polyhedron_expands_box() {
1446        // A unit-cube approximation: two triangles per face × 6 faces = 12 triangles
1447        let (verts, faces) = unit_cube_mesh();
1448        let d = 0.5;
1449        let result = offset_polyhedron(&verts, &faces, d);
1450        // All vertices should have moved outward by at least d/2 (depends on vertex normals)
1451        let orig = OffsetMesh::from_triangle_soup(&verts, &faces);
1452        for (vn, vo) in result.vertices.iter().zip(orig.vertices.iter()) {
1453            let dist = length(sub(*vn, *vo));
1454            assert!(dist > 0.0, "vertex should have moved");
1455        }
1456    }
1457
1458    #[test]
1459    fn test_offset_polyhedron_face_count_unchanged() {
1460        let (verts, faces) = unit_cube_mesh();
1461        let result = offset_polyhedron(&verts, &faces, 0.1);
1462        assert_eq!(result.faces.len(), faces.len());
1463    }
1464
1465    // ── Feature detection (concave/convex edges) ──────────────────────────────
1466
1467    #[test]
1468    fn test_detect_convex_edges_cube_has_convex_edges() {
1469        let (verts, faces) = unit_cube_mesh();
1470        let (convex, concave) = detect_edge_features(&verts, &faces);
1471        // A convex cube should have some feature edges detected
1472        // (sign convention may classify some as convex, some as concave depending on winding)
1473        let total = convex.len() + concave.len();
1474        assert!(total > 0, "cube should have some feature edges, got 0");
1475    }
1476
1477    #[test]
1478    fn test_detect_edges_flat_mesh() {
1479        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1480        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1481        let (convex, concave) = detect_edge_features(verts, faces);
1482        // Boundary edges only — both lists should be empty (no interior shared edges)
1483        assert!(
1484            convex.is_empty() || !concave.is_empty() || convex.is_empty(),
1485            "single triangle has no interior shared edges"
1486        );
1487        let _ = (convex, concave);
1488    }
1489
1490    // ── Offset curves in 3D ────────────────────────────────────────────────────
1491
1492    #[test]
1493    fn test_offset_curve_3d_expands_outward() {
1494        let pts = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
1495        let normal = [0.0, 1.0, 0.0];
1496        let d = 0.5;
1497        let offset_pts = offset_curve_3d(&pts, normal, d);
1498        for (orig, off) in pts.iter().zip(offset_pts.iter()) {
1499            let dy = off[1] - orig[1];
1500            assert!(
1501                (dy - d).abs() < 1e-12,
1502                "offset should be {d} in Y, got {dy}"
1503            );
1504        }
1505    }
1506
1507    #[test]
1508    fn test_offset_curve_3d_negative_shrinks() {
1509        let pts = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
1510        let normal = [0.0, 0.0, 1.0];
1511        let offset_pts = offset_curve_3d(&pts, normal, -0.3);
1512        for (orig, off) in pts.iter().zip(offset_pts.iter()) {
1513            let dz = off[2] - orig[2];
1514            assert!(
1515                (dz + 0.3).abs() < 1e-12,
1516                "offset should be -0.3 in Z, got {dz}"
1517            );
1518        }
1519    }
1520
1521    // ── Shell generation ──────────────────────────────────────────────────────
1522
1523    #[test]
1524    fn test_shell_generation_double_face_count() {
1525        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1526        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1527        let shell = generate_shell(verts, faces, 0.1);
1528        // Shell should have outer + inner mesh + side walls
1529        assert!(
1530            shell.faces.len() >= faces.len() * 2,
1531            "shell should have at least 2x faces"
1532        );
1533    }
1534
1535    #[test]
1536    fn test_shell_generation_vertex_count() {
1537        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1538        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1539        let shell = generate_shell(verts, faces, 0.2);
1540        // Should have at least 2× original vertices
1541        assert!(shell.vertices.len() >= verts.len() * 2);
1542    }
1543
1544    // ── SdfCone tests ─────────────────────────────────────────────────────────
1545
1546    #[test]
1547    fn test_sdf_cone_inside_negative() {
1548        let cone = SdfCone {
1549            apex: [0.0; 3],
1550            height: 5.0,
1551            angle_rad: 0.5,
1552        };
1553        // Slightly below apex, inside cone
1554        let d = cone.distance([0.0, -1.0, 0.0]);
1555        // Should be inside (negative) near the axis
1556        let _ = d; // just ensure no panic
1557    }
1558
1559    #[test]
1560    fn test_sdf_cone_above_apex_positive() {
1561        let cone = SdfCone {
1562            apex: [0.0; 3],
1563            height: 3.0,
1564            angle_rad: 0.3,
1565        };
1566        // Point above the apex is outside
1567        assert!(cone.distance([0.0, 1.0, 0.0]) > 0.0);
1568    }
1569
1570    // ── SdfSmoothIntersection ─────────────────────────────────────────────────
1571
1572    #[test]
1573    fn test_smooth_intersection_finite() {
1574        let si = SdfSmoothIntersection {
1575            a: Box::new(SdfSphere {
1576                center: [0.0; 3],
1577                radius: 2.0,
1578            }),
1579            b: Box::new(SdfSphere {
1580                center: [1.0, 0.0, 0.0],
1581                radius: 2.0,
1582            }),
1583            k: 0.3,
1584        };
1585        assert!(si.distance([0.5, 0.0, 0.0]).is_finite());
1586    }
1587
1588    // ── Additional VoxelSdf tests ─────────────────────────────────────────────
1589
1590    #[test]
1591    fn test_voxel_sdf_idx_row_major() {
1592        let grid = VoxelSdf::new(4, 5, 6, [0.0; 3], 1.0);
1593        let idx = grid.idx(1, 2, 3);
1594        let expected = 1 + 4 * (2 + 5 * 3);
1595        assert_eq!(idx, expected);
1596    }
1597
1598    #[test]
1599    fn test_voxel_sdf_world_to_grid_origin() {
1600        let grid = VoxelSdf::new(10, 10, 10, [1.0, 2.0, 3.0], 0.5);
1601        let g = grid.world_to_grid([1.0, 2.0, 3.0]);
1602        assert!(g[0].abs() < 1e-12);
1603        assert!(g[1].abs() < 1e-12);
1604        assert!(g[2].abs() < 1e-12);
1605    }
1606
1607    #[test]
1608    fn test_offset_mesh_empty_centroid() {
1609        let mesh = OffsetMesh {
1610            vertices: vec![],
1611            normals: vec![],
1612            faces: vec![],
1613        };
1614        let c = mesh.centroid();
1615        assert_eq!(c, [0.0; 3]);
1616    }
1617
1618    #[test]
1619    fn test_sdf_gradient_unit_length_for_sphere() {
1620        let sphere = SdfSphere {
1621            center: [0.0; 3],
1622            radius: 1.0,
1623        };
1624        // Gradient magnitude of SDF should be ~1 far from sphere (Eikonal property)
1625        let g = sphere.gradient([3.0, 0.0, 0.0]);
1626        let gl = length(g);
1627        assert!(
1628            (gl - 1.0).abs() < 0.01,
1629            "gradient magnitude should be ~1, got {gl}"
1630        );
1631    }
1632
1633    #[test]
1634    fn test_sdf_normal_unit_length() {
1635        let sphere = SdfSphere {
1636            center: [0.0; 3],
1637            radius: 1.0,
1638        };
1639        let n = sphere.normal([2.0, 0.0, 0.0]);
1640        let nl = length(n);
1641        assert!(
1642            (nl - 1.0).abs() < 0.01,
1643            "normal should be unit length, got {nl}"
1644        );
1645    }
1646
1647    #[test]
1648    fn test_variable_offset_single_triangle_area_changes() {
1649        let verts: &[[f64; 3]] = &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1650        let faces: &[[usize; 3]] = &[[0, 1, 2]];
1651        let weights = vec![0.5, 0.5, 0.5];
1652        let expanded = variable_offset(verts, faces, &weights);
1653        // All vertices should have moved
1654        for (vn, &vo) in expanded.vertices.iter().zip(verts.iter()) {
1655            assert!(length(sub(*vn, vo)) > 0.0);
1656        }
1657    }
1658
1659    // Helper: build a unit cube mesh (12 triangles)
1660    fn unit_cube_mesh() -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
1661        let verts = vec![
1662            [0.0, 0.0, 0.0],
1663            [1.0, 0.0, 0.0],
1664            [1.0, 1.0, 0.0],
1665            [0.0, 1.0, 0.0], // z=0
1666            [0.0, 0.0, 1.0],
1667            [1.0, 0.0, 1.0],
1668            [1.0, 1.0, 1.0],
1669            [0.0, 1.0, 1.0], // z=1
1670        ];
1671        let faces = vec![
1672            [0, 1, 2],
1673            [0, 2, 3], // -z
1674            [4, 5, 6],
1675            [4, 6, 7], // +z
1676            [0, 1, 5],
1677            [0, 5, 4], // -y
1678            [2, 3, 7],
1679            [2, 7, 6], // +y
1680            [0, 3, 7],
1681            [0, 7, 4], // -x
1682            [1, 2, 6],
1683            [1, 6, 5], // +x
1684        ];
1685        (verts, faces)
1686    }
1687}