Skip to main content

oxiphysics_geometry/
offset_surface.rs

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