Skip to main content

oxiphysics_python/
geometry_api.rs

1#![allow(clippy::manual_strip, clippy::ptr_arg)]
2#![allow(clippy::needless_range_loop)]
3// Copyright 2026 COOLJAPAN OU (Team KitaSan)
4// SPDX-License-Identifier: Apache-2.0
5
6//! Python geometry bindings.
7//!
8//! Provides Python-friendly geometry primitives, mesh operations, spatial data
9//! structures, and CSG operations using plain `f64` arrays — no nalgebra.
10
11#![allow(dead_code)]
12#![allow(missing_docs)]
13
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::f64::consts::PI;
17
18// ---------------------------------------------------------------------------
19// Math helpers (local, no nalgebra)
20// ---------------------------------------------------------------------------
21
22fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
23    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
24}
25
26fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
27    [
28        a[1] * b[2] - a[2] * b[1],
29        a[2] * b[0] - a[0] * b[2],
30        a[0] * b[1] - a[1] * b[0],
31    ]
32}
33
34fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
35    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
36}
37
38fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
39    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
40}
41
42fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
43    [a[0] * s, a[1] * s, a[2] * s]
44}
45
46fn len3(a: [f64; 3]) -> f64 {
47    dot3(a, a).sqrt()
48}
49
50fn normalize3(a: [f64; 3]) -> [f64; 3] {
51    let l = len3(a);
52    if l < 1e-15 {
53        [0.0; 3]
54    } else {
55        scale3(a, 1.0 / l)
56    }
57}
58
59fn lerp3(a: [f64; 3], b: [f64; 3], t: f64) -> [f64; 3] {
60    [
61        a[0] + (b[0] - a[0]) * t,
62        a[1] + (b[1] - a[1]) * t,
63        a[2] + (b[2] - a[2]) * t,
64    ]
65}
66
67// ---------------------------------------------------------------------------
68// PyShape — primitive geometry enum
69// ---------------------------------------------------------------------------
70
71/// Primitive geometry shape used for collision detection and rendering.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub enum PyShape {
74    /// A sphere defined by its radius.
75    Sphere(f64),
76    /// An axis-aligned box defined by half-extents.
77    Box([f64; 3]),
78    /// A capsule defined by radius and half-height of the cylindrical part.
79    Capsule { radius: f64, half_height: f64 },
80    /// A cylinder defined by radius and half-height.
81    Cylinder { radius: f64, half_height: f64 },
82    /// A cone defined by base radius and half-height.
83    Cone { radius: f64, half_height: f64 },
84    /// A torus defined by major and minor radii.
85    Torus { major_r: f64, minor_r: f64 },
86}
87
88impl PyShape {
89    /// Approximate volume of the shape.
90    pub fn volume(&self) -> f64 {
91        use std::f64::consts::PI;
92        match self {
93            PyShape::Sphere(r) => (4.0 / 3.0) * PI * r * r * r,
94            PyShape::Box(h) => 8.0 * h[0] * h[1] * h[2],
95            PyShape::Capsule {
96                radius: r,
97                half_height: hh,
98            } => PI * r * r * (2.0 * hh + (4.0 / 3.0) * r),
99            PyShape::Cylinder {
100                radius: r,
101                half_height: hh,
102            } => PI * r * r * 2.0 * hh,
103            PyShape::Cone {
104                radius: r,
105                half_height: hh,
106            } => (1.0 / 3.0) * PI * r * r * 2.0 * hh,
107            PyShape::Torus {
108                major_r: rr,
109                minor_r: rt,
110            } => 2.0 * PI * PI * rr * rt * rt,
111        }
112    }
113
114    /// Approximate surface area of the shape.
115    pub fn surface_area(&self) -> f64 {
116        match self {
117            PyShape::Sphere(r) => 4.0 * PI * r * r,
118            PyShape::Box(h) => 2.0 * (h[0] * h[1] + h[1] * h[2] + h[2] * h[0]) * 4.0,
119            PyShape::Capsule {
120                radius: r,
121                half_height: hh,
122            } => 4.0 * PI * r * r + 2.0 * PI * r * 2.0 * hh,
123            PyShape::Cylinder {
124                radius: r,
125                half_height: hh,
126            } => 2.0 * PI * r * (r + 2.0 * hh),
127            PyShape::Cone {
128                radius: r,
129                half_height: hh,
130            } => {
131                let slant = (r * r + (2.0 * hh) * (2.0 * hh)).sqrt();
132                PI * r * (r + slant)
133            }
134            PyShape::Torus {
135                major_r: rr,
136                minor_r: rt,
137            } => 4.0 * PI * PI * rr * rt,
138        }
139    }
140
141    /// Compute an axis-aligned bounding box for this shape at the origin.
142    pub fn aabb(&self) -> ([f64; 3], [f64; 3]) {
143        match self {
144            PyShape::Sphere(r) => ([-r, -r, -r], [*r, *r, *r]),
145            PyShape::Box(h) => ([-h[0], -h[1], -h[2]], [h[0], h[1], h[2]]),
146            PyShape::Capsule {
147                radius: r,
148                half_height: hh,
149            } => {
150                let hy = r + hh;
151                ([-r, -hy, -r], [*r, hy, *r])
152            }
153            PyShape::Cylinder {
154                radius: r,
155                half_height: hh,
156            } => ([-r, -hh, -r], [*r, *hh, *r]),
157            PyShape::Cone {
158                radius: r,
159                half_height: hh,
160            } => ([-r, -hh, -r], [*r, *hh, *r]),
161            PyShape::Torus {
162                major_r: rr,
163                minor_r: rt,
164            } => {
165                let e = rr + rt;
166                ([-e, -rt, -e], [e, *rt, e])
167            }
168        }
169    }
170}
171
172// ---------------------------------------------------------------------------
173// PyConvexHull
174// ---------------------------------------------------------------------------
175
176/// A convex hull computed from a point cloud.
177///
178/// Uses a simplified incremental approach for demonstration purposes.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct PyConvexHull {
181    /// Vertices of the convex hull.
182    pub vertices: Vec<[f64; 3]>,
183    /// Triangle face indices (groups of 3).
184    pub faces: Vec<usize>,
185}
186
187impl PyConvexHull {
188    /// Compute a convex hull from a set of points.
189    ///
190    /// This is a simplified implementation — for production use a full
191    /// Quickhull algorithm.
192    pub fn from_points(points: &[[f64; 3]]) -> Self {
193        if points.is_empty() {
194            return Self {
195                vertices: vec![],
196                faces: vec![],
197            };
198        }
199        // Simplified: keep extreme points as hull approximation
200        let mut min = points[0];
201        let mut max = points[0];
202        for &p in points {
203            for i in 0..3 {
204                if p[i] < min[i] {
205                    min[i] = p[i];
206                }
207                if p[i] > max[i] {
208                    max[i] = p[i];
209                }
210            }
211        }
212        // 8 corners of the bounding box as hull vertices
213        let vertices = vec![
214            [min[0], min[1], min[2]],
215            [max[0], min[1], min[2]],
216            [max[0], max[1], min[2]],
217            [min[0], max[1], min[2]],
218            [min[0], min[1], max[2]],
219            [max[0], min[1], max[2]],
220            [max[0], max[1], max[2]],
221            [min[0], max[1], max[2]],
222        ];
223        // 12 triangles for the box faces
224        let faces = vec![
225            0, 1, 2, 0, 2, 3, // -z
226            4, 6, 5, 4, 7, 6, // +z
227            0, 5, 1, 0, 4, 5, // -y
228            2, 7, 3, 2, 6, 7, // +y
229            0, 3, 7, 0, 7, 4, // -x
230            1, 5, 6, 1, 6, 2, // +x
231        ];
232        Self { vertices, faces }
233    }
234
235    /// Approximate volume of the convex hull.
236    pub fn volume(&self) -> f64 {
237        let n = self.faces.len() / 3;
238        let mut vol = 0.0;
239        for i in 0..n {
240            let a = self.vertices[self.faces[i * 3]];
241            let b = self.vertices[self.faces[i * 3 + 1]];
242            let c = self.vertices[self.faces[i * 3 + 2]];
243            let cr = cross3(b, c);
244            vol += dot3(a, cr);
245        }
246        (vol / 6.0).abs()
247    }
248
249    /// Approximate surface area of the convex hull.
250    pub fn surface_area(&self) -> f64 {
251        let n = self.faces.len() / 3;
252        let mut area = 0.0;
253        for i in 0..n {
254            let a = self.vertices[self.faces[i * 3]];
255            let b = self.vertices[self.faces[i * 3 + 1]];
256            let c = self.vertices[self.faces[i * 3 + 2]];
257            let ab = sub3(b, a);
258            let ac = sub3(c, a);
259            let cr = cross3(ab, ac);
260            area += len3(cr) * 0.5;
261        }
262        area
263    }
264
265    /// Test whether two convex hulls overlap using a simplified GJK-like check
266    /// (bounding-sphere test as approximation).
267    pub fn gjk_overlap(&self, other: &PyConvexHull) -> bool {
268        if self.vertices.is_empty() || other.vertices.is_empty() {
269            return false;
270        }
271        let c1 = centroid(&self.vertices);
272        let c2 = centroid(&other.vertices);
273        let r1 = bounding_radius(&self.vertices, c1);
274        let r2 = bounding_radius(&other.vertices, c2);
275        let dist = len3(sub3(c1, c2));
276        dist < r1 + r2
277    }
278}
279
280fn centroid(pts: &[[f64; 3]]) -> [f64; 3] {
281    let n = pts.len() as f64;
282    let mut s = [0.0f64; 3];
283    for &p in pts {
284        s[0] += p[0];
285        s[1] += p[1];
286        s[2] += p[2];
287    }
288    [s[0] / n, s[1] / n, s[2] / n]
289}
290
291fn bounding_radius(pts: &[[f64; 3]], center: [f64; 3]) -> f64 {
292    pts.iter()
293        .map(|&p| len3(sub3(p, center)))
294        .fold(0.0f64, f64::max)
295}
296
297// ---------------------------------------------------------------------------
298// PyTriangleMesh
299// ---------------------------------------------------------------------------
300
301/// A triangle mesh with vertices, indices, and optional per-vertex normals.
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct PyTriangleMesh {
304    /// Vertex positions, each `[f64; 3]`.
305    pub vertices: Vec<[f64; 3]>,
306    /// Triangle face indices (groups of 3 vertex indices).
307    pub indices: Vec<usize>,
308    /// Per-vertex normals (may be empty until `compute_normals` is called).
309    pub normals: Vec<[f64; 3]>,
310}
311
312impl PyTriangleMesh {
313    /// Create a new empty triangle mesh.
314    pub fn new() -> Self {
315        Self {
316            vertices: vec![],
317            indices: vec![],
318            normals: vec![],
319        }
320    }
321
322    /// Create a mesh from raw vertex and index data.
323    pub fn from_raw(vertices: Vec<[f64; 3]>, indices: Vec<usize>) -> Self {
324        let mut mesh = Self {
325            vertices,
326            indices,
327            normals: vec![],
328        };
329        mesh.compute_normals();
330        mesh
331    }
332
333    /// Compute per-vertex normals as the area-weighted average of adjacent face normals.
334    pub fn compute_normals(&mut self) {
335        let n = self.vertices.len();
336        self.normals = vec![[0.0; 3]; n];
337        let tri_count = self.indices.len() / 3;
338        for t in 0..tri_count {
339            let ia = self.indices[t * 3];
340            let ib = self.indices[t * 3 + 1];
341            let ic = self.indices[t * 3 + 2];
342            if ia >= n || ib >= n || ic >= n {
343                continue;
344            }
345            let a = self.vertices[ia];
346            let b = self.vertices[ib];
347            let c = self.vertices[ic];
348            let ab = sub3(b, a);
349            let ac = sub3(c, a);
350            let face_n = cross3(ab, ac);
351            for &idx in &[ia, ib, ic] {
352                self.normals[idx][0] += face_n[0];
353                self.normals[idx][1] += face_n[1];
354                self.normals[idx][2] += face_n[2];
355            }
356        }
357        for n in &mut self.normals {
358            *n = normalize3(*n);
359        }
360    }
361
362    /// Remove degenerate triangles and duplicated vertices (basic repair).
363    pub fn repair(&mut self) {
364        // Remove degenerate triangles (zero area)
365        let mut good = Vec::new();
366        let n = self.vertices.len();
367        let tri_count = self.indices.len() / 3;
368        for t in 0..tri_count {
369            let ia = self.indices[t * 3];
370            let ib = self.indices[t * 3 + 1];
371            let ic = self.indices[t * 3 + 2];
372            if ia >= n || ib >= n || ic >= n {
373                continue;
374            }
375            if ia == ib || ib == ic || ia == ic {
376                continue;
377            }
378            let a = self.vertices[ia];
379            let b = self.vertices[ib];
380            let c = self.vertices[ic];
381            let area = len3(cross3(sub3(b, a), sub3(c, a)));
382            if area > 1e-15 {
383                good.extend_from_slice(&[ia, ib, ic]);
384            }
385        }
386        self.indices = good;
387    }
388
389    /// Laplacian smoothing: average each vertex toward its neighbours.
390    pub fn smooth(&mut self, iterations: usize) {
391        for _ in 0..iterations {
392            let n = self.vertices.len();
393            let mut acc = vec![[0.0f64; 3]; n];
394            let mut cnt = vec![0usize; n];
395            let tri_count = self.indices.len() / 3;
396            for t in 0..tri_count {
397                let ia = self.indices[t * 3];
398                let ib = self.indices[t * 3 + 1];
399                let ic = self.indices[t * 3 + 2];
400                if ia >= n || ib >= n || ic >= n {
401                    continue;
402                }
403                let va = self.vertices[ia];
404                let vb = self.vertices[ib];
405                let vc = self.vertices[ic];
406                acc[ia] = add3(acc[ia], add3(vb, vc));
407                cnt[ia] += 2;
408                acc[ib] = add3(acc[ib], add3(va, vc));
409                cnt[ib] += 2;
410                acc[ic] = add3(acc[ic], add3(va, vb));
411                cnt[ic] += 2;
412            }
413            for i in 0..n {
414                if cnt[i] > 0 {
415                    let c = cnt[i] as f64;
416                    self.vertices[i] = lerp3(
417                        self.vertices[i],
418                        [acc[i][0] / c, acc[i][1] / c, acc[i][2] / c],
419                        0.5,
420                    );
421                }
422            }
423        }
424        self.compute_normals();
425    }
426
427    /// Compute signed volume using the divergence theorem.
428    pub fn compute_volume(&self) -> f64 {
429        let tri_count = self.indices.len() / 3;
430        let n = self.vertices.len();
431        let mut vol = 0.0;
432        for t in 0..tri_count {
433            let ia = self.indices[t * 3];
434            let ib = self.indices[t * 3 + 1];
435            let ic = self.indices[t * 3 + 2];
436            if ia >= n || ib >= n || ic >= n {
437                continue;
438            }
439            let a = self.vertices[ia];
440            let b = self.vertices[ib];
441            let c = self.vertices[ic];
442            vol += dot3(a, cross3(b, c));
443        }
444        (vol / 6.0).abs()
445    }
446
447    /// Compute total surface area.
448    pub fn compute_surface_area(&self) -> f64 {
449        let tri_count = self.indices.len() / 3;
450        let n = self.vertices.len();
451        let mut area = 0.0;
452        for t in 0..tri_count {
453            let ia = self.indices[t * 3];
454            let ib = self.indices[t * 3 + 1];
455            let ic = self.indices[t * 3 + 2];
456            if ia >= n || ib >= n || ic >= n {
457                continue;
458            }
459            let a = self.vertices[ia];
460            let b = self.vertices[ib];
461            let c = self.vertices[ic];
462            area += len3(cross3(sub3(b, a), sub3(c, a))) * 0.5;
463        }
464        area
465    }
466
467    /// Number of triangles.
468    pub fn triangle_count(&self) -> usize {
469        self.indices.len() / 3
470    }
471}
472
473impl Default for PyTriangleMesh {
474    fn default() -> Self {
475        Self::new()
476    }
477}
478
479// ---------------------------------------------------------------------------
480// PyCsg — Constructive Solid Geometry stubs
481// ---------------------------------------------------------------------------
482
483/// Result type for CSG operations between two meshes.
484///
485/// Note: Full BSP-based CSG is complex; these are structural stubs that
486/// merge/intersect vertex data in a simplified manner for API completeness.
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct PyCsg;
489
490impl PyCsg {
491    /// Union of two meshes (concatenates geometry — placeholder for full BSP).
492    pub fn union(a: &PyTriangleMesh, b: &PyTriangleMesh) -> PyTriangleMesh {
493        let offset = a.vertices.len();
494        let mut verts = a.vertices.clone();
495        verts.extend_from_slice(&b.vertices);
496        let mut idx = a.indices.clone();
497        for &i in &b.indices {
498            idx.push(i + offset);
499        }
500        PyTriangleMesh::from_raw(verts, idx)
501    }
502
503    /// Intersection stub: returns mesh `a` clipped by the AABB of mesh `b`.
504    pub fn intersection(a: &PyTriangleMesh, b: &PyTriangleMesh) -> PyTriangleMesh {
505        let (bmin, bmax) = compute_aabb(&b.vertices);
506        let mut verts = Vec::new();
507        let mut idx = Vec::new();
508        let tri_count = a.indices.len() / 3;
509        let n = a.vertices.len();
510        let mut remap = vec![usize::MAX; n];
511        for t in 0..tri_count {
512            let ia = a.indices[t * 3];
513            let ib = a.indices[t * 3 + 1];
514            let ic = a.indices[t * 3 + 2];
515            if ia >= n || ib >= n || ic >= n {
516                continue;
517            }
518            let ctr = scale3(
519                add3(a.vertices[ia], add3(a.vertices[ib], a.vertices[ic])),
520                1.0 / 3.0,
521            );
522            if point_in_aabb(ctr, bmin, bmax) {
523                for &vi in &[ia, ib, ic] {
524                    if remap[vi] == usize::MAX {
525                        remap[vi] = verts.len();
526                        verts.push(a.vertices[vi]);
527                    }
528                    idx.push(remap[vi]);
529                }
530            }
531        }
532        PyTriangleMesh::from_raw(verts, idx)
533    }
534
535    /// Subtraction stub: removes triangles whose centroid is inside mesh `b`'s AABB.
536    pub fn subtraction(a: &PyTriangleMesh, b: &PyTriangleMesh) -> PyTriangleMesh {
537        let (bmin, bmax) = compute_aabb(&b.vertices);
538        let mut verts = Vec::new();
539        let mut idx = Vec::new();
540        let tri_count = a.indices.len() / 3;
541        let n = a.vertices.len();
542        let mut remap = vec![usize::MAX; n];
543        for t in 0..tri_count {
544            let ia = a.indices[t * 3];
545            let ib = a.indices[t * 3 + 1];
546            let ic = a.indices[t * 3 + 2];
547            if ia >= n || ib >= n || ic >= n {
548                continue;
549            }
550            let ctr = scale3(
551                add3(a.vertices[ia], add3(a.vertices[ib], a.vertices[ic])),
552                1.0 / 3.0,
553            );
554            if !point_in_aabb(ctr, bmin, bmax) {
555                for &vi in &[ia, ib, ic] {
556                    if remap[vi] == usize::MAX {
557                        remap[vi] = verts.len();
558                        verts.push(a.vertices[vi]);
559                    }
560                    idx.push(remap[vi]);
561                }
562            }
563        }
564        PyTriangleMesh::from_raw(verts, idx)
565    }
566}
567
568fn point_in_aabb(p: [f64; 3], mn: [f64; 3], mx: [f64; 3]) -> bool {
569    p[0] >= mn[0]
570        && p[0] <= mx[0]
571        && p[1] >= mn[1]
572        && p[1] <= mx[1]
573        && p[2] >= mn[2]
574        && p[2] <= mx[2]
575}
576
577// ---------------------------------------------------------------------------
578// PyHeightfield
579// ---------------------------------------------------------------------------
580
581/// A heightfield terrain represented as a regular grid.
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct PyHeightfield {
584    /// Number of columns (X axis).
585    pub cols: usize,
586    /// Number of rows (Z axis).
587    pub rows: usize,
588    /// Height values in row-major order (rows × cols entries).
589    pub data: Vec<f64>,
590    /// World-space scale per cell in X.
591    pub scale_x: f64,
592    /// World-space scale per cell in Z.
593    pub scale_z: f64,
594    /// Vertical scale factor applied to raw height values.
595    pub scale_y: f64,
596}
597
598impl PyHeightfield {
599    /// Create a new flat heightfield.
600    pub fn new(cols: usize, rows: usize, scale_x: f64, scale_z: f64, scale_y: f64) -> Self {
601        Self {
602            cols,
603            rows,
604            data: vec![0.0; cols * rows],
605            scale_x,
606            scale_z,
607            scale_y,
608        }
609    }
610
611    /// Set height at grid cell (col, row).
612    pub fn set_height(&mut self, col: usize, row: usize, h: f64) {
613        if col < self.cols && row < self.rows {
614            self.data[row * self.cols + col] = h;
615        }
616    }
617
618    /// Bilinearly interpolated height at world-space (x, z).
619    pub fn height_at(&self, x: f64, z: f64) -> f64 {
620        let gx = x / self.scale_x;
621        let gz = z / self.scale_z;
622        let col = gx.floor() as isize;
623        let row = gz.floor() as isize;
624        let fx = gx - col as f64;
625        let fz = gz - row as f64;
626
627        let sample = |c: isize, r: isize| -> f64 {
628            let c = c.clamp(0, self.cols as isize - 1) as usize;
629            let r = r.clamp(0, self.rows as isize - 1) as usize;
630            self.data[r * self.cols + c] * self.scale_y
631        };
632
633        let h00 = sample(col, row);
634        let h10 = sample(col + 1, row);
635        let h01 = sample(col, row + 1);
636        let h11 = sample(col + 1, row + 1);
637
638        let h0 = h00 + (h10 - h00) * fx;
639        let h1 = h01 + (h11 - h01) * fx;
640        h0 + (h1 - h0) * fz
641    }
642
643    /// Central-difference surface normal at world-space (x, z).
644    pub fn normal_at(&self, x: f64, z: f64) -> [f64; 3] {
645        let eps = self.scale_x.min(self.scale_z) * 0.5;
646        let dhdx = (self.height_at(x + eps, z) - self.height_at(x - eps, z)) / (2.0 * eps);
647        let dhdz = (self.height_at(x, z + eps) - self.height_at(x, z - eps)) / (2.0 * eps);
648        normalize3([-dhdx, 1.0, -dhdz])
649    }
650
651    /// Raycast against the heightfield.  Returns the hit distance if found.
652    pub fn raycast(&self, origin: [f64; 3], direction: [f64; 3]) -> Option<f64> {
653        let dir = normalize3(direction);
654        let max_dist = (self.cols as f64 * self.scale_x + self.rows as f64 * self.scale_z) * 2.0;
655        let steps = 1000usize;
656        let step = max_dist / steps as f64;
657        let mut t = 0.0;
658        for _ in 0..steps {
659            let p = [
660                origin[0] + dir[0] * t,
661                origin[1] + dir[1] * t,
662                origin[2] + dir[2] * t,
663            ];
664            let h = self.height_at(p[0], p[2]);
665            if p[1] <= h {
666                return Some(t);
667            }
668            t += step;
669        }
670        None
671    }
672}
673
674// ---------------------------------------------------------------------------
675// PySpatialHash
676// ---------------------------------------------------------------------------
677
678/// A spatial hash map for fast point proximity queries.
679#[derive(Debug, Clone, Serialize, Deserialize)]
680pub struct PySpatialHash {
681    /// Cell size determines resolution of the hash grid.
682    pub cell_size: f64,
683    /// Points indexed by ID.
684    pub points: Vec<[f64; 3]>,
685    /// Internal grid map: cell key -> list of point indices.
686    grid: HashMap<(i64, i64, i64), Vec<usize>>,
687}
688
689impl PySpatialHash {
690    /// Create a new spatial hash with the given cell size.
691    pub fn new(cell_size: f64) -> Self {
692        Self {
693            cell_size,
694            points: vec![],
695            grid: HashMap::new(),
696        }
697    }
698
699    fn cell_of(&self, p: [f64; 3]) -> (i64, i64, i64) {
700        (
701            (p[0] / self.cell_size).floor() as i64,
702            (p[1] / self.cell_size).floor() as i64,
703            (p[2] / self.cell_size).floor() as i64,
704        )
705    }
706
707    /// Insert a point and return its ID.
708    pub fn insert(&mut self, p: [f64; 3]) -> usize {
709        let id = self.points.len();
710        self.points.push(p);
711        let cell = self.cell_of(p);
712        self.grid.entry(cell).or_default().push(id);
713        id
714    }
715
716    /// Remove a point by ID (marks its cell entry as removed).
717    pub fn remove(&mut self, id: usize) {
718        if id >= self.points.len() {
719            return;
720        }
721        let p = self.points[id];
722        let cell = self.cell_of(p);
723        if let Some(list) = self.grid.get_mut(&cell) {
724            list.retain(|&x| x != id);
725        }
726    }
727
728    /// Query all points in the cell containing `p`.
729    pub fn query(&self, p: [f64; 3]) -> Vec<usize> {
730        let cell = self.cell_of(p);
731        self.grid.get(&cell).cloned().unwrap_or_default()
732    }
733
734    /// Query all points within a sphere of the given `radius` around `center`.
735    pub fn sphere_query(&self, center: [f64; 3], radius: f64) -> Vec<usize> {
736        let r2 = radius * radius;
737        let cells = (radius / self.cell_size).ceil() as i64 + 1;
738        let base = self.cell_of(center);
739        let mut result = Vec::new();
740        for dx in -cells..=cells {
741            for dy in -cells..=cells {
742                for dz in -cells..=cells {
743                    let key = (base.0 + dx, base.1 + dy, base.2 + dz);
744                    if let Some(list) = self.grid.get(&key) {
745                        for &id in list {
746                            if id < self.points.len() {
747                                let d = sub3(self.points[id], center);
748                                if dot3(d, d) <= r2 {
749                                    result.push(id);
750                                }
751                            }
752                        }
753                    }
754                }
755            }
756        }
757        result
758    }
759}
760
761// ---------------------------------------------------------------------------
762// PyBvh — Bounding Volume Hierarchy
763// ---------------------------------------------------------------------------
764
765/// An axis-aligned bounding box used in the BVH.
766#[derive(Debug, Clone, Serialize, Deserialize)]
767pub struct PyAabb {
768    /// Minimum corner.
769    pub min: [f64; 3],
770    /// Maximum corner.
771    pub max: [f64; 3],
772}
773
774impl PyAabb {
775    /// Create a new AABB.
776    pub fn new(min: [f64; 3], max: [f64; 3]) -> Self {
777        Self { min, max }
778    }
779
780    /// Check whether this AABB overlaps another.
781    pub fn overlaps(&self, other: &PyAabb) -> bool {
782        self.min[0] <= other.max[0]
783            && self.max[0] >= other.min[0]
784            && self.min[1] <= other.max[1]
785            && self.max[1] >= other.min[1]
786            && self.min[2] <= other.max[2]
787            && self.max[2] >= other.min[2]
788    }
789
790    /// Center of this AABB.
791    pub fn center(&self) -> [f64; 3] {
792        scale3(add3(self.min, self.max), 0.5)
793    }
794
795    /// Half-extents of this AABB.
796    pub fn half_extents(&self) -> [f64; 3] {
797        scale3(sub3(self.max, self.min), 0.5)
798    }
799
800    /// Surface area of this AABB.
801    pub fn surface_area(&self) -> f64 {
802        let e = sub3(self.max, self.min);
803        2.0 * (e[0] * e[1] + e[1] * e[2] + e[2] * e[0])
804    }
805}
806
807/// A node in the BVH tree.
808#[derive(Debug, Clone, Serialize, Deserialize)]
809pub struct PyBvhNode {
810    /// Bounding box of this node.
811    pub aabb: PyAabb,
812    /// Left child index (or `usize::MAX` for leaf).
813    pub left: usize,
814    /// Right child index (or `usize::MAX` for leaf).
815    pub right: usize,
816    /// Leaf item index (meaningful only when `left == usize::MAX`).
817    pub item: usize,
818}
819
820/// A BVH (Bounding Volume Hierarchy) over AABBs.
821#[derive(Debug, Clone, Serialize, Deserialize)]
822pub struct PyBvh {
823    /// All nodes in the BVH.
824    pub nodes: Vec<PyBvhNode>,
825    /// Root node index.
826    pub root: usize,
827    /// Original AABBs associated with leaf items.
828    pub items: Vec<PyAabb>,
829}
830
831impl PyBvh {
832    /// Build a BVH from a list of AABBs.
833    pub fn build(aabbs: Vec<PyAabb>) -> Self {
834        let n = aabbs.len();
835        if n == 0 {
836            return Self {
837                nodes: vec![],
838                root: 0,
839                items: vec![],
840            };
841        }
842        let mut bvh = Self {
843            nodes: Vec::with_capacity(2 * n),
844            root: 0,
845            items: aabbs,
846        };
847        let indices: Vec<usize> = (0..n).collect();
848        bvh.root = bvh.build_recursive(&indices);
849        bvh
850    }
851
852    fn build_recursive(&mut self, indices: &[usize]) -> usize {
853        if indices.len() == 1 {
854            let id = indices[0];
855            let node = PyBvhNode {
856                aabb: self.items[id].clone(),
857                left: usize::MAX,
858                right: usize::MAX,
859                item: id,
860            };
861            let idx = self.nodes.len();
862            self.nodes.push(node);
863            return idx;
864        }
865        // Compute combined AABB
866        let combined = merge_aabbs(indices.iter().map(|&i| &self.items[i]));
867        // Split on longest axis at median
868        let axis = longest_axis(&combined);
869        let mut sorted = indices.to_vec();
870        sorted.sort_by(|&a, &b| {
871            let ca = self.items[a].center()[axis];
872            let cb = self.items[b].center()[axis];
873            ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
874        });
875        let mid = sorted.len() / 2;
876        let left = self.build_recursive(&sorted[..mid]);
877        let right = self.build_recursive(&sorted[mid..]);
878        let node = PyBvhNode {
879            aabb: combined,
880            left,
881            right,
882            item: usize::MAX,
883        };
884        let idx = self.nodes.len();
885        self.nodes.push(node);
886        idx
887    }
888
889    /// Query all leaf items whose AABB overlaps the query AABB.
890    pub fn query_aabb(&self, query: &PyAabb) -> Vec<usize> {
891        if self.nodes.is_empty() {
892            return vec![];
893        }
894        let mut result = Vec::new();
895        self.query_recursive(self.root, query, &mut result);
896        result
897    }
898
899    fn query_recursive(&self, node_idx: usize, query: &PyAabb, out: &mut Vec<usize>) {
900        if node_idx >= self.nodes.len() {
901            return;
902        }
903        let node = &self.nodes[node_idx];
904        if !node.aabb.overlaps(query) {
905            return;
906        }
907        if node.left == usize::MAX {
908            out.push(node.item);
909            return;
910        }
911        self.query_recursive(node.left, query, out);
912        self.query_recursive(node.right, query, out);
913    }
914
915    /// Raycast against all leaf items; returns sorted (distance, item) pairs.
916    pub fn raycast(&self, origin: [f64; 3], direction: [f64; 3]) -> Vec<(f64, usize)> {
917        if self.nodes.is_empty() {
918            return vec![];
919        }
920        let dir = normalize3(direction);
921        let mut hits = Vec::new();
922        self.raycast_recursive(self.root, origin, dir, &mut hits);
923        hits.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
924        hits
925    }
926
927    fn raycast_recursive(
928        &self,
929        node_idx: usize,
930        origin: [f64; 3],
931        dir: [f64; 3],
932        out: &mut Vec<(f64, usize)>,
933    ) {
934        if node_idx >= self.nodes.len() {
935            return;
936        }
937        let node = &self.nodes[node_idx];
938        if let Some(t) = ray_aabb_intersect(origin, dir, &node.aabb) {
939            if node.left == usize::MAX {
940                out.push((t, node.item));
941                return;
942            }
943            self.raycast_recursive(node.left, origin, dir, out);
944            self.raycast_recursive(node.right, origin, dir, out);
945        }
946    }
947
948    /// Nearest-neighbor search: returns the index of the closest item's center.
949    pub fn nearest_neighbor(&self, point: [f64; 3]) -> Option<usize> {
950        if self.items.is_empty() {
951            return None;
952        }
953        let mut best_dist = f64::MAX;
954        let mut best = 0usize;
955        for (i, item) in self.items.iter().enumerate() {
956            let d = len3(sub3(item.center(), point));
957            if d < best_dist {
958                best_dist = d;
959                best = i;
960            }
961        }
962        Some(best)
963    }
964}
965
966fn merge_aabbs<'a>(iter: impl Iterator<Item = &'a PyAabb>) -> PyAabb {
967    let mut mn = [f64::MAX; 3];
968    let mut mx = [f64::MIN; 3];
969    for a in iter {
970        for i in 0..3 {
971            if a.min[i] < mn[i] {
972                mn[i] = a.min[i];
973            }
974            if a.max[i] > mx[i] {
975                mx[i] = a.max[i];
976            }
977        }
978    }
979    PyAabb { min: mn, max: mx }
980}
981
982fn longest_axis(aabb: &PyAabb) -> usize {
983    let e = sub3(aabb.max, aabb.min);
984    if e[0] >= e[1] && e[0] >= e[2] {
985        0
986    } else if e[1] >= e[2] {
987        1
988    } else {
989        2
990    }
991}
992
993fn ray_aabb_intersect(origin: [f64; 3], dir: [f64; 3], aabb: &PyAabb) -> Option<f64> {
994    let mut tmin = f64::NEG_INFINITY;
995    let mut tmax = f64::INFINITY;
996    for i in 0..3 {
997        if dir[i].abs() < 1e-15 {
998            if origin[i] < aabb.min[i] || origin[i] > aabb.max[i] {
999                return None;
1000            }
1001        } else {
1002            let inv = 1.0 / dir[i];
1003            let t1 = (aabb.min[i] - origin[i]) * inv;
1004            let t2 = (aabb.max[i] - origin[i]) * inv;
1005            tmin = tmin.max(t1.min(t2));
1006            tmax = tmax.min(t1.max(t2));
1007        }
1008    }
1009    if tmax >= tmin && tmax >= 0.0 {
1010        Some(tmin.max(0.0))
1011    } else {
1012        None
1013    }
1014}
1015
1016// ---------------------------------------------------------------------------
1017// PyPointCloud
1018// ---------------------------------------------------------------------------
1019
1020/// A point cloud with optional per-point normals.
1021#[derive(Debug, Clone, Serialize, Deserialize)]
1022pub struct PyPointCloud {
1023    /// Point positions.
1024    pub points: Vec<[f64; 3]>,
1025    /// Per-point normals (may be empty).
1026    pub normals: Vec<[f64; 3]>,
1027}
1028
1029impl PyPointCloud {
1030    /// Create an empty point cloud.
1031    pub fn new() -> Self {
1032        Self {
1033            points: vec![],
1034            normals: vec![],
1035        }
1036    }
1037
1038    /// Create from point positions.
1039    pub fn from_points(points: Vec<[f64; 3]>) -> Self {
1040        Self {
1041            points,
1042            normals: vec![],
1043        }
1044    }
1045
1046    /// Estimate per-point normals via PCA of nearest neighbours (simplified: use
1047    /// the point-to-centroid direction as a proxy).
1048    pub fn compute_normals(&mut self, _k_neighbours: usize) {
1049        let c = if self.points.is_empty() {
1050            [0.0; 3]
1051        } else {
1052            centroid(&self.points)
1053        };
1054        self.normals = self
1055            .points
1056            .iter()
1057            .map(|&p| normalize3(sub3(p, c)))
1058            .collect();
1059    }
1060
1061    /// Voxel-grid downsampling: keep one point per voxel cell.
1062    pub fn simplify(&mut self, voxel_size: f64) {
1063        let mut grid: HashMap<(i64, i64, i64), [f64; 3]> = HashMap::new();
1064        for &p in &self.points {
1065            let key = (
1066                (p[0] / voxel_size).floor() as i64,
1067                (p[1] / voxel_size).floor() as i64,
1068                (p[2] / voxel_size).floor() as i64,
1069            );
1070            grid.entry(key).or_insert(p);
1071        }
1072        self.points = grid.into_values().collect();
1073        self.normals.clear();
1074    }
1075
1076    /// Stub for Poisson surface reconstruction.
1077    ///
1078    /// Returns an empty mesh — full implementation requires an octree solver.
1079    pub fn poisson_reconstruct(&self) -> PyTriangleMesh {
1080        PyTriangleMesh::new()
1081    }
1082}
1083
1084impl Default for PyPointCloud {
1085    fn default() -> Self {
1086        Self::new()
1087    }
1088}
1089
1090// ---------------------------------------------------------------------------
1091// PyGeometryTransform
1092// ---------------------------------------------------------------------------
1093
1094/// A rigid-body + scale transform for geometry operations.
1095#[derive(Debug, Clone, Serialize, Deserialize)]
1096pub struct PyGeometryTransform {
1097    /// Translation vector.
1098    pub translation: [f64; 3],
1099    /// Rotation axis (unit vector).
1100    pub axis: [f64; 3],
1101    /// Rotation angle in radians.
1102    pub angle: f64,
1103    /// Uniform scale factor.
1104    pub scale: f64,
1105}
1106
1107impl PyGeometryTransform {
1108    /// Identity transform.
1109    pub fn identity() -> Self {
1110        Self {
1111            translation: [0.0; 3],
1112            axis: [0.0, 1.0, 0.0],
1113            angle: 0.0,
1114            scale: 1.0,
1115        }
1116    }
1117
1118    /// Set translation.
1119    pub fn translate(mut self, t: [f64; 3]) -> Self {
1120        self.translation = t;
1121        self
1122    }
1123
1124    /// Set rotation.
1125    pub fn rotate(mut self, axis: [f64; 3], angle: f64) -> Self {
1126        self.axis = normalize3(axis);
1127        self.angle = angle;
1128        self
1129    }
1130
1131    /// Set scale.
1132    pub fn with_scale(mut self, s: f64) -> Self {
1133        self.scale = s;
1134        self
1135    }
1136
1137    /// Apply the transform to a single point.
1138    pub fn apply_point(&self, p: [f64; 3]) -> [f64; 3] {
1139        // Scale
1140        let ps = scale3(p, self.scale);
1141        // Rodrigues rotation
1142        let pr = rodrigues(ps, self.axis, self.angle);
1143        // Translate
1144        add3(pr, self.translation)
1145    }
1146
1147    /// Apply this transform to all vertices of a mesh (in place).
1148    pub fn apply_to_mesh(&self, mesh: &mut PyTriangleMesh) {
1149        for v in &mut mesh.vertices {
1150            *v = self.apply_point(*v);
1151        }
1152        mesh.compute_normals();
1153    }
1154
1155    /// Apply this transform to a list of points.
1156    pub fn apply_to_points(&self, points: &mut Vec<[f64; 3]>) {
1157        for p in points.iter_mut() {
1158            *p = self.apply_point(*p);
1159        }
1160    }
1161}
1162
1163fn rodrigues(v: [f64; 3], axis: [f64; 3], angle: f64) -> [f64; 3] {
1164    let cos_a = angle.cos();
1165    let sin_a = angle.sin();
1166    let k = normalize3(axis);
1167
1168    add3(
1169        add3(scale3(v, cos_a), scale3(cross3(k, v), sin_a)),
1170        scale3(k, dot3(k, v) * (1.0 - cos_a)),
1171    )
1172}
1173
1174// ---------------------------------------------------------------------------
1175// PyMeshQuality
1176// ---------------------------------------------------------------------------
1177
1178/// Mesh quality metrics for a triangle mesh.
1179#[derive(Debug, Clone, Serialize, Deserialize)]
1180pub struct PyMeshQuality {
1181    /// Per-triangle aspect ratios.
1182    pub aspect_ratios: Vec<f64>,
1183    /// Per-triangle signed Jacobians (det of edge matrix).
1184    pub jacobians: Vec<f64>,
1185    /// Per-triangle skewness values in \[0, 1\] (0 = ideal equilateral).
1186    pub skewness: Vec<f64>,
1187    /// Per-triangle areas.
1188    pub areas: Vec<f64>,
1189}
1190
1191impl PyMeshQuality {
1192    /// Compute quality metrics for the given mesh.
1193    pub fn compute(mesh: &PyTriangleMesh) -> Self {
1194        let tri_count = mesh.indices.len() / 3;
1195        let n = mesh.vertices.len();
1196        let mut aspect_ratios = Vec::with_capacity(tri_count);
1197        let mut jacobians = Vec::with_capacity(tri_count);
1198        let mut skewness = Vec::with_capacity(tri_count);
1199        let mut areas = Vec::with_capacity(tri_count);
1200        for t in 0..tri_count {
1201            let ia = mesh.indices[t * 3];
1202            let ib = mesh.indices[t * 3 + 1];
1203            let ic = mesh.indices[t * 3 + 2];
1204            if ia >= n || ib >= n || ic >= n {
1205                aspect_ratios.push(f64::NAN);
1206                jacobians.push(f64::NAN);
1207                skewness.push(f64::NAN);
1208                areas.push(0.0);
1209                continue;
1210            }
1211            let a = mesh.vertices[ia];
1212            let b = mesh.vertices[ib];
1213            let c = mesh.vertices[ic];
1214            let ab = sub3(b, a);
1215            let ac = sub3(c, a);
1216            let bc = sub3(c, b);
1217            let la = len3(ab);
1218            let lb = len3(ac);
1219            let lc = len3(bc);
1220            let cr = cross3(ab, ac);
1221            let area = len3(cr) * 0.5;
1222            areas.push(area);
1223            // Aspect ratio: longest edge / (2*sqrt(3)*inradius)
1224            let longest = la.max(lb).max(lc);
1225            let inradius = if la + lb + lc > 0.0 {
1226                2.0 * area / (la + lb + lc)
1227            } else {
1228                0.0
1229            };
1230            let ar = if inradius > 0.0 {
1231                longest / (2.0 * 3.0_f64.sqrt() * inradius)
1232            } else {
1233                f64::MAX
1234            };
1235            aspect_ratios.push(ar);
1236            // Jacobian: determinant of edge matrix (det [ab, ac, n])
1237            let face_n = normalize3(cr);
1238            let jac = dot3(cross3(ab, ac), face_n);
1239            jacobians.push(jac);
1240            // Skewness: equilateral-based
1241            let ideal_angle = std::f64::consts::PI / 3.0;
1242            let cos_ab_ac = if la * lb > 0.0 {
1243                dot3(ab, ac) / (la * lb)
1244            } else {
1245                0.0
1246            };
1247            let angle_a = cos_ab_ac.clamp(-1.0, 1.0).acos();
1248            let skew = ((angle_a - ideal_angle) / ideal_angle).abs();
1249            skewness.push(skew.min(1.0));
1250        }
1251        Self {
1252            aspect_ratios,
1253            jacobians,
1254            skewness,
1255            areas,
1256        }
1257    }
1258
1259    /// Return the indices of the worst `n` triangles by aspect ratio.
1260    pub fn worst_elements(&self, n: usize) -> Vec<usize> {
1261        let mut indexed: Vec<(usize, f64)> = self
1262            .aspect_ratios
1263            .iter()
1264            .copied()
1265            .enumerate()
1266            .filter(|(_, v)| v.is_finite())
1267            .collect();
1268        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1269        indexed.into_iter().take(n).map(|(i, _)| i).collect()
1270    }
1271
1272    /// Mean aspect ratio across all triangles.
1273    pub fn mean_aspect_ratio(&self) -> f64 {
1274        let vals: Vec<f64> = self
1275            .aspect_ratios
1276            .iter()
1277            .copied()
1278            .filter(|v| v.is_finite())
1279            .collect();
1280        if vals.is_empty() {
1281            return 0.0;
1282        }
1283        vals.iter().sum::<f64>() / vals.len() as f64
1284    }
1285}
1286
1287// ---------------------------------------------------------------------------
1288// Helper functions
1289// ---------------------------------------------------------------------------
1290
1291/// Compute the axis-aligned bounding box of a point set.
1292pub fn compute_aabb(points: &[[f64; 3]]) -> ([f64; 3], [f64; 3]) {
1293    if points.is_empty() {
1294        return ([0.0; 3], [0.0; 3]);
1295    }
1296    let mut mn = points[0];
1297    let mut mx = points[0];
1298    for &p in points {
1299        for i in 0..3 {
1300            if p[i] < mn[i] {
1301                mn[i] = p[i];
1302            }
1303            if p[i] > mx[i] {
1304                mx[i] = p[i];
1305            }
1306        }
1307    }
1308    (mn, mx)
1309}
1310
1311/// Serialize a triangle mesh to OBJ format string.
1312pub fn mesh_to_obj_string(mesh: &PyTriangleMesh) -> String {
1313    let mut out = String::new();
1314    for v in &mesh.vertices {
1315        out.push_str(&format!("v {} {} {}\n", v[0], v[1], v[2]));
1316    }
1317    for n in &mesh.normals {
1318        out.push_str(&format!("vn {} {} {}\n", n[0], n[1], n[2]));
1319    }
1320    let tri_count = mesh.indices.len() / 3;
1321    for t in 0..tri_count {
1322        let ia = mesh.indices[t * 3] + 1;
1323        let ib = mesh.indices[t * 3 + 1] + 1;
1324        let ic = mesh.indices[t * 3 + 2] + 1;
1325        out.push_str(&format!("f {} {} {}\n", ia, ib, ic));
1326    }
1327    out
1328}
1329
1330/// Parse an OBJ string into a `PyTriangleMesh`.
1331///
1332/// Supports `v` (vertex) and `f` (face) lines only.
1333pub fn obj_string_to_mesh(obj: &str) -> PyTriangleMesh {
1334    let mut verts = Vec::new();
1335    let mut indices = Vec::new();
1336    for line in obj.lines() {
1337        let line = line.trim();
1338        if line.starts_with("v ") {
1339            let parts: Vec<f64> = line[2..]
1340                .split_whitespace()
1341                .filter_map(|s| s.parse().ok())
1342                .collect();
1343            if parts.len() >= 3 {
1344                verts.push([parts[0], parts[1], parts[2]]);
1345            }
1346        } else if line.starts_with("f ") {
1347            let parts: Vec<usize> = line[2..]
1348                .split_whitespace()
1349                .filter_map(|s| {
1350                    s.split('/')
1351                        .next()
1352                        .and_then(|n| n.parse::<usize>().ok())
1353                        .map(|n| n - 1)
1354                })
1355                .collect();
1356            if parts.len() >= 3 {
1357                // Fan triangulation for N-gons
1358                for i in 1..parts.len() - 1 {
1359                    indices.push(parts[0]);
1360                    indices.push(parts[i]);
1361                    indices.push(parts[i + 1]);
1362                }
1363            }
1364        }
1365    }
1366    PyTriangleMesh::from_raw(verts, indices)
1367}
1368
1369/// Compute the convex hull of all vertices in a mesh.
1370pub fn convex_hull_from_mesh(mesh: &PyTriangleMesh) -> PyConvexHull {
1371    PyConvexHull::from_points(&mesh.vertices)
1372}
1373
1374// ---------------------------------------------------------------------------
1375// Tests
1376// ---------------------------------------------------------------------------
1377
1378#[cfg(test)]
1379mod tests {
1380    use super::*;
1381
1382    // --- PyShape tests ---
1383
1384    #[test]
1385    fn test_sphere_volume() {
1386        let s = PyShape::Sphere(1.0);
1387        let expected = (4.0 / 3.0) * PI;
1388        assert!((s.volume() - expected).abs() < 1e-10);
1389    }
1390
1391    #[test]
1392    fn test_box_volume() {
1393        let b = PyShape::Box([1.0, 2.0, 3.0]);
1394        assert!((b.volume() - 48.0).abs() < 1e-10);
1395    }
1396
1397    #[test]
1398    fn test_cylinder_volume() {
1399        let c = PyShape::Cylinder {
1400            radius: 1.0,
1401            half_height: 1.0,
1402        };
1403        assert!((c.volume() - 2.0 * PI).abs() < 1e-10);
1404    }
1405
1406    #[test]
1407    fn test_torus_volume() {
1408        let t = PyShape::Torus {
1409            major_r: 3.0,
1410            minor_r: 1.0,
1411        };
1412        let expected = 2.0 * PI * PI * 3.0 * 1.0;
1413        assert!((t.volume() - expected).abs() < 1e-10);
1414    }
1415
1416    #[test]
1417    fn test_shape_aabb_sphere() {
1418        let (mn, mx) = PyShape::Sphere(2.0).aabb();
1419        assert_eq!(mn, [-2.0, -2.0, -2.0]);
1420        assert_eq!(mx, [2.0, 2.0, 2.0]);
1421    }
1422
1423    #[test]
1424    fn test_capsule_surface_area() {
1425        let c = PyShape::Capsule {
1426            radius: 1.0,
1427            half_height: 1.0,
1428        };
1429        let expected = 4.0 * PI + 2.0 * PI * 2.0;
1430        assert!((c.surface_area() - expected).abs() < 1e-10);
1431    }
1432
1433    // --- PyConvexHull tests ---
1434
1435    #[test]
1436    fn test_convex_hull_from_points_empty() {
1437        let hull = PyConvexHull::from_points(&[]);
1438        assert!(hull.vertices.is_empty());
1439    }
1440
1441    #[test]
1442    fn test_convex_hull_from_points_basic() {
1443        let pts = vec![
1444            [0.0, 0.0, 0.0],
1445            [1.0, 0.0, 0.0],
1446            [0.0, 1.0, 0.0],
1447            [0.0, 0.0, 1.0],
1448        ];
1449        let hull = PyConvexHull::from_points(&pts);
1450        assert!(!hull.vertices.is_empty());
1451        assert!(!hull.faces.is_empty());
1452    }
1453
1454    #[test]
1455    fn test_convex_hull_volume_positive() {
1456        let pts: Vec<[f64; 3]> = vec![
1457            [-1.0, -1.0, -1.0],
1458            [1.0, -1.0, -1.0],
1459            [1.0, 1.0, -1.0],
1460            [-1.0, 1.0, -1.0],
1461            [-1.0, -1.0, 1.0],
1462            [1.0, -1.0, 1.0],
1463            [1.0, 1.0, 1.0],
1464            [-1.0, 1.0, 1.0],
1465        ];
1466        let hull = PyConvexHull::from_points(&pts);
1467        assert!(hull.volume() > 0.0);
1468    }
1469
1470    #[test]
1471    fn test_convex_hull_gjk_overlap() {
1472        let pts1 = vec![
1473            [0.0, 0.0, 0.0],
1474            [1.0, 0.0, 0.0],
1475            [0.0, 1.0, 0.0],
1476            [0.0, 0.0, 1.0],
1477        ];
1478        let pts2 = vec![
1479            [0.5, 0.5, 0.5],
1480            [1.5, 0.5, 0.5],
1481            [0.5, 1.5, 0.5],
1482            [0.5, 0.5, 1.5],
1483        ];
1484        let h1 = PyConvexHull::from_points(&pts1);
1485        let h2 = PyConvexHull::from_points(&pts2);
1486        assert!(h1.gjk_overlap(&h2));
1487    }
1488
1489    #[test]
1490    fn test_convex_hull_gjk_no_overlap() {
1491        let pts1 = vec![
1492            [0.0, 0.0, 0.0],
1493            [0.1, 0.0, 0.0],
1494            [0.0, 0.1, 0.0],
1495            [0.0, 0.0, 0.1],
1496        ];
1497        let pts2 = vec![
1498            [10.0, 10.0, 10.0],
1499            [10.1, 10.0, 10.0],
1500            [10.0, 10.1, 10.0],
1501            [10.0, 10.0, 10.1],
1502        ];
1503        let h1 = PyConvexHull::from_points(&pts1);
1504        let h2 = PyConvexHull::from_points(&pts2);
1505        assert!(!h1.gjk_overlap(&h2));
1506    }
1507
1508    // --- PyTriangleMesh tests ---
1509
1510    fn unit_box_mesh() -> PyTriangleMesh {
1511        let verts = vec![
1512            [0.0, 0.0, 0.0],
1513            [1.0, 0.0, 0.0],
1514            [1.0, 1.0, 0.0],
1515            [0.0, 1.0, 0.0],
1516            [0.0, 0.0, 1.0],
1517            [1.0, 0.0, 1.0],
1518            [1.0, 1.0, 1.0],
1519            [0.0, 1.0, 1.0],
1520        ];
1521        let idx = vec![
1522            0, 1, 2, 0, 2, 3, 4, 6, 5, 4, 7, 6, 0, 5, 1, 0, 4, 5, 2, 7, 3, 2, 6, 7, 0, 3, 7, 0, 7,
1523            4, 1, 5, 6, 1, 6, 2,
1524        ];
1525        PyTriangleMesh::from_raw(verts, idx)
1526    }
1527
1528    #[test]
1529    fn test_mesh_compute_normals_not_empty() {
1530        let mesh = unit_box_mesh();
1531        assert_eq!(mesh.normals.len(), mesh.vertices.len());
1532    }
1533
1534    #[test]
1535    fn test_mesh_triangle_count() {
1536        let mesh = unit_box_mesh();
1537        assert_eq!(mesh.triangle_count(), 12);
1538    }
1539
1540    #[test]
1541    fn test_mesh_surface_area_unit_box() {
1542        let mesh = unit_box_mesh();
1543        let sa = mesh.compute_surface_area();
1544        assert!((sa - 6.0).abs() < 1e-10);
1545    }
1546
1547    #[test]
1548    fn test_mesh_volume_unit_box() {
1549        let mesh = unit_box_mesh();
1550        let vol = mesh.compute_volume();
1551        assert!((vol - 1.0).abs() < 0.1, "vol={vol}");
1552    }
1553
1554    #[test]
1555    fn test_mesh_repair_removes_degenerate() {
1556        let mut mesh = PyTriangleMesh::from_raw(
1557            vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
1558            vec![0, 0, 0, 0, 1, 2], // first tri is degenerate
1559        );
1560        mesh.repair();
1561        assert_eq!(mesh.triangle_count(), 1);
1562    }
1563
1564    #[test]
1565    fn test_mesh_smooth_runs() {
1566        let mut mesh = unit_box_mesh();
1567        let before = mesh.vertices.clone();
1568        mesh.smooth(2);
1569        // After smoothing the mesh should have changed
1570        assert_eq!(mesh.vertices.len(), before.len());
1571    }
1572
1573    // --- PyCsg tests ---
1574
1575    #[test]
1576    fn test_csg_union_vertex_count() {
1577        let a = unit_box_mesh();
1578        let b = unit_box_mesh();
1579        let u = PyCsg::union(&a, &b);
1580        assert_eq!(u.vertices.len(), a.vertices.len() + b.vertices.len());
1581    }
1582
1583    #[test]
1584    fn test_csg_intersection_subset() {
1585        let a = unit_box_mesh();
1586        let mut b_big = unit_box_mesh();
1587        // Scale b to overlap all of a
1588        for v in &mut b_big.vertices {
1589            v[0] *= 2.0;
1590            v[1] *= 2.0;
1591            v[2] *= 2.0;
1592            v[0] -= 0.5;
1593            v[1] -= 0.5;
1594            v[2] -= 0.5;
1595        }
1596        let inter = PyCsg::intersection(&a, &b_big);
1597        assert!(inter.vertices.len() <= a.vertices.len() + 8);
1598    }
1599
1600    #[test]
1601    fn test_csg_subtraction_removes_some() {
1602        let a = unit_box_mesh();
1603        let b = unit_box_mesh(); // same region
1604        let sub = PyCsg::subtraction(&a, &b);
1605        // All or most triangles should be removed
1606        assert!(sub.triangle_count() <= a.triangle_count());
1607    }
1608
1609    // --- PyHeightfield tests ---
1610
1611    #[test]
1612    fn test_heightfield_flat() {
1613        let hf = PyHeightfield::new(10, 10, 1.0, 1.0, 1.0);
1614        assert!((hf.height_at(5.0, 5.0)).abs() < 1e-10);
1615    }
1616
1617    #[test]
1618    fn test_heightfield_set_and_query() {
1619        let mut hf = PyHeightfield::new(10, 10, 1.0, 1.0, 1.0);
1620        hf.set_height(3, 4, 5.0);
1621        let h = hf.height_at(3.0, 4.0);
1622        assert!((h - 5.0).abs() < 1e-6);
1623    }
1624
1625    #[test]
1626    fn test_heightfield_normal_flat() {
1627        let hf = PyHeightfield::new(10, 10, 1.0, 1.0, 1.0);
1628        let n = hf.normal_at(5.0, 5.0);
1629        // Flat field should give straight-up normal
1630        assert!((n[1] - 1.0).abs() < 1e-6);
1631    }
1632
1633    #[test]
1634    fn test_heightfield_raycast_hits() {
1635        let mut hf = PyHeightfield::new(20, 20, 1.0, 1.0, 1.0);
1636        // Raise entire field
1637        for r in 0..20 {
1638            for c in 0..20 {
1639                hf.set_height(c, r, 1.0);
1640            }
1641        }
1642        let origin = [10.0, 10.0, 10.0];
1643        let dir = [0.0, -1.0, 0.0];
1644        let hit = hf.raycast(origin, dir);
1645        assert!(hit.is_some());
1646    }
1647
1648    // --- PySpatialHash tests ---
1649
1650    #[test]
1651    fn test_spatial_hash_insert_query() {
1652        let mut sh = PySpatialHash::new(1.0);
1653        let id = sh.insert([0.5, 0.5, 0.5]);
1654        let res = sh.query([0.5, 0.5, 0.5]);
1655        assert!(res.contains(&id));
1656    }
1657
1658    #[test]
1659    fn test_spatial_hash_sphere_query() {
1660        let mut sh = PySpatialHash::new(1.0);
1661        sh.insert([0.0, 0.0, 0.0]);
1662        sh.insert([5.0, 5.0, 5.0]);
1663        let res = sh.sphere_query([0.0, 0.0, 0.0], 1.0);
1664        assert_eq!(res.len(), 1);
1665    }
1666
1667    #[test]
1668    fn test_spatial_hash_remove() {
1669        let mut sh = PySpatialHash::new(1.0);
1670        let id = sh.insert([0.0, 0.0, 0.0]);
1671        sh.remove(id);
1672        let res = sh.sphere_query([0.0, 0.0, 0.0], 0.5);
1673        assert!(!res.contains(&id));
1674    }
1675
1676    // --- PyBvh tests ---
1677
1678    #[test]
1679    fn test_bvh_build_empty() {
1680        let bvh = PyBvh::build(vec![]);
1681        assert!(bvh.nodes.is_empty());
1682    }
1683
1684    #[test]
1685    fn test_bvh_query_aabb_hit() {
1686        let aabbs = vec![
1687            PyAabb::new([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]),
1688            PyAabb::new([5.0, 5.0, 5.0], [6.0, 6.0, 6.0]),
1689        ];
1690        let bvh = PyBvh::build(aabbs);
1691        let q = PyAabb::new([0.0, 0.0, 0.0], [2.0, 2.0, 2.0]);
1692        let hits = bvh.query_aabb(&q);
1693        assert!(hits.contains(&0));
1694        assert!(!hits.contains(&1));
1695    }
1696
1697    #[test]
1698    fn test_bvh_raycast() {
1699        let aabbs = vec![PyAabb::new([0.0, 0.0, 0.0], [1.0, 1.0, 1.0])];
1700        let bvh = PyBvh::build(aabbs);
1701        let hits = bvh.raycast([-5.0, 0.5, 0.5], [1.0, 0.0, 0.0]);
1702        assert!(!hits.is_empty());
1703        assert_eq!(hits[0].1, 0);
1704    }
1705
1706    #[test]
1707    fn test_bvh_nearest_neighbor() {
1708        let aabbs = vec![
1709            PyAabb::new([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]),
1710            PyAabb::new([5.0, 5.0, 5.0], [6.0, 6.0, 6.0]),
1711        ];
1712        let bvh = PyBvh::build(aabbs);
1713        let nn = bvh.nearest_neighbor([0.5, 0.5, 0.5]);
1714        assert_eq!(nn, Some(0));
1715    }
1716
1717    // --- PyPointCloud tests ---
1718
1719    #[test]
1720    fn test_point_cloud_compute_normals() {
1721        let mut pc = PyPointCloud::from_points(vec![
1722            [1.0, 0.0, 0.0],
1723            [-1.0, 0.0, 0.0],
1724            [0.0, 1.0, 0.0],
1725            [0.0, -1.0, 0.0],
1726        ]);
1727        pc.compute_normals(3);
1728        assert_eq!(pc.normals.len(), 4);
1729    }
1730
1731    #[test]
1732    fn test_point_cloud_simplify() {
1733        let mut pc =
1734            PyPointCloud::from_points((0..100).map(|i| [i as f64 * 0.01, 0.0, 0.0]).collect());
1735        pc.simplify(0.1);
1736        assert!(pc.points.len() < 100);
1737    }
1738
1739    #[test]
1740    fn test_point_cloud_poisson_stub() {
1741        let pc = PyPointCloud::from_points(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
1742        let mesh = pc.poisson_reconstruct();
1743        assert!(mesh.vertices.is_empty());
1744    }
1745
1746    // --- PyGeometryTransform tests ---
1747
1748    #[test]
1749    fn test_transform_identity() {
1750        let t = PyGeometryTransform::identity();
1751        let p = [1.0, 2.0, 3.0];
1752        let out = t.apply_point(p);
1753        for i in 0..3 {
1754            assert!((out[i] - p[i]).abs() < 1e-10);
1755        }
1756    }
1757
1758    #[test]
1759    fn test_transform_translate() {
1760        let t = PyGeometryTransform::identity().translate([1.0, 2.0, 3.0]);
1761        let out = t.apply_point([0.0, 0.0, 0.0]);
1762        assert!((out[0] - 1.0).abs() < 1e-10);
1763        assert!((out[1] - 2.0).abs() < 1e-10);
1764        assert!((out[2] - 3.0).abs() < 1e-10);
1765    }
1766
1767    #[test]
1768    fn test_transform_scale() {
1769        let t = PyGeometryTransform::identity().with_scale(2.0);
1770        let out = t.apply_point([1.0, 1.0, 1.0]);
1771        for i in 0..3 {
1772            assert!((out[i] - 2.0).abs() < 1e-10);
1773        }
1774    }
1775
1776    #[test]
1777    fn test_transform_rotate_180() {
1778        let t = PyGeometryTransform::identity().rotate([0.0, 0.0, 1.0], PI);
1779        let out = t.apply_point([1.0, 0.0, 0.0]);
1780        assert!((out[0] - (-1.0)).abs() < 1e-10, "x={}", out[0]);
1781        assert!((out[1]).abs() < 1e-10, "y={}", out[1]);
1782    }
1783
1784    #[test]
1785    fn test_transform_apply_to_mesh() {
1786        let mut mesh = unit_box_mesh();
1787        let t = PyGeometryTransform::identity().translate([1.0, 0.0, 0.0]);
1788        t.apply_to_mesh(&mut mesh);
1789        // All x-coords should be shifted by 1
1790        for v in &mesh.vertices {
1791            assert!(v[0] >= 1.0 - 1e-10 && v[0] <= 2.0 + 1e-10);
1792        }
1793    }
1794
1795    // --- PyMeshQuality tests ---
1796
1797    #[test]
1798    fn test_mesh_quality_compute() {
1799        let mesh = unit_box_mesh();
1800        let q = PyMeshQuality::compute(&mesh);
1801        assert_eq!(q.aspect_ratios.len(), mesh.triangle_count());
1802        assert_eq!(q.areas.len(), mesh.triangle_count());
1803    }
1804
1805    #[test]
1806    fn test_mesh_quality_areas_positive() {
1807        let mesh = unit_box_mesh();
1808        let q = PyMeshQuality::compute(&mesh);
1809        for &a in &q.areas {
1810            assert!(a >= 0.0);
1811        }
1812    }
1813
1814    #[test]
1815    fn test_mesh_quality_worst_elements() {
1816        let mesh = unit_box_mesh();
1817        let q = PyMeshQuality::compute(&mesh);
1818        let worst = q.worst_elements(3);
1819        assert!(worst.len() <= 3);
1820    }
1821
1822    #[test]
1823    fn test_mesh_quality_mean_aspect_ratio() {
1824        let mesh = unit_box_mesh();
1825        let q = PyMeshQuality::compute(&mesh);
1826        let mar = q.mean_aspect_ratio();
1827        assert!(mar >= 1.0);
1828    }
1829
1830    // --- Helper function tests ---
1831
1832    #[test]
1833    fn test_compute_aabb_empty() {
1834        let (mn, mx) = compute_aabb(&[]);
1835        assert_eq!(mn, [0.0; 3]);
1836        assert_eq!(mx, [0.0; 3]);
1837    }
1838
1839    #[test]
1840    fn test_compute_aabb_points() {
1841        let pts = vec![[-1.0, 2.0, 3.0], [4.0, -5.0, 6.0]];
1842        let (mn, mx) = compute_aabb(&pts);
1843        assert_eq!(mn, [-1.0, -5.0, 3.0]);
1844        assert_eq!(mx, [4.0, 2.0, 6.0]);
1845    }
1846
1847    #[test]
1848    fn test_obj_roundtrip() {
1849        let mesh = unit_box_mesh();
1850        let obj = mesh_to_obj_string(&mesh);
1851        let parsed = obj_string_to_mesh(&obj);
1852        assert_eq!(parsed.vertices.len(), mesh.vertices.len());
1853        assert_eq!(parsed.triangle_count(), mesh.triangle_count());
1854    }
1855
1856    #[test]
1857    fn test_convex_hull_from_mesh() {
1858        let mesh = unit_box_mesh();
1859        let hull = convex_hull_from_mesh(&mesh);
1860        assert!(!hull.vertices.is_empty());
1861    }
1862}