Skip to main content

oxiphysics_geometry/
cone.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cone shape (Y-axis aligned, apex at top).
5
6use crate::shape::{RayHit, Shape};
7use oxiphysics_core::Aabb;
8use oxiphysics_core::math::{Mat3, Real, Vec3};
9use std::f64::consts::PI;
10
11/// A cone defined by radius and half-height along the Y axis.
12/// Base is at y = -half_height, apex at y = +half_height.
13#[derive(Debug, Clone)]
14pub struct Cone {
15    /// Radius of the base.
16    pub radius: Real,
17    /// Half-height along the Y axis.
18    pub half_height: Real,
19}
20
21impl Cone {
22    /// Create a new cone.
23    pub fn new(radius: Real, half_height: Real) -> Self {
24        Self {
25            radius,
26            half_height,
27        }
28    }
29
30    /// Volume: πr²h/3 (h = full height = 2*half_height).
31    #[allow(dead_code)]
32    pub fn volume_explicit(&self) -> Real {
33        let h = 2.0 * self.half_height;
34        PI * self.radius * self.radius * h / 3.0
35    }
36
37    /// Surface area: base disk + lateral surface = πr² + πr*slant.
38    /// slant = sqrt(r² + h²) where h = full height.
39    #[allow(dead_code)]
40    pub fn surface_area(&self) -> Real {
41        let r = self.radius;
42        let h = 2.0 * self.half_height;
43        let slant = (r * r + h * h).sqrt();
44        PI * r * r + PI * r * slant
45    }
46
47    /// Inertia tensor as \[\[f64;3\\];3] row-major.
48    #[allow(dead_code)]
49    pub fn inertia_tensor_array(&self, mass: f64) -> [[f64; 3]; 3] {
50        let r2 = self.radius * self.radius;
51        let h2 = (2.0 * self.half_height).powi(2);
52        let iy = 3.0 * mass * r2 / 10.0;
53        let ixz = mass * (3.0 * r2 + 2.0 * h2) / 20.0;
54        [[ixz, 0.0, 0.0], [0.0, iy, 0.0], [0.0, 0.0, ixz]]
55    }
56
57    /// Ray cast returning (t, normal) as plain arrays.
58    #[allow(dead_code)]
59    pub fn ray_cast_array(
60        &self,
61        origin: [f64; 3],
62        direction: [f64; 3],
63        max_toi: f64,
64    ) -> Option<(f64, [f64; 3])> {
65        let o = Vec3::new(origin[0], origin[1], origin[2]);
66        let d = Vec3::new(direction[0], direction[1], direction[2]);
67        let hit = self.ray_cast(&o, &d, max_toi)?;
68        Some((hit.toi, [hit.normal.x, hit.normal.y, hit.normal.z]))
69    }
70
71    /// Closest point on (or inside) the cone to point `p`.
72    #[allow(dead_code)]
73    pub fn closest_point(&self, p: [f64; 3]) -> [f64; 3] {
74        let px = p[0];
75        let py = p[1];
76        let pz = p[2];
77
78        // Clamp Y to cone range
79        let cy = py.clamp(-self.half_height, self.half_height);
80
81        // Radius at height cy: from apex (half_height) to base (-half_height)
82        let h = 2.0 * self.half_height;
83        let r_at_cy = self.radius * (self.half_height - cy) / h;
84
85        // XZ distance
86        let xz_len = (px * px + pz * pz).sqrt();
87
88        if xz_len <= r_at_cy {
89            // Project onto closest surface
90            // Distance to base cap
91            let dist_to_base = py - (-self.half_height);
92            // Distance to lateral surface (approximate)
93            let dist_to_side = r_at_cy - xz_len;
94            if dist_to_base <= dist_to_side {
95                [px, -self.half_height, pz]
96            } else {
97                // Clamp to cone surface at given Y
98                if xz_len < 1e-12 {
99                    [r_at_cy, cy, 0.0]
100                } else {
101                    let s = r_at_cy / xz_len;
102                    [px * s, cy, pz * s]
103                }
104            }
105        } else {
106            // Outside XZ — project to cone side at clamped Y
107            if xz_len < 1e-12 {
108                [r_at_cy, cy, 0.0]
109            } else {
110                let s = r_at_cy / xz_len;
111                [px * s, cy, pz * s]
112            }
113        }
114    }
115
116    // ── New methods ──
117
118    /// GJK support function using plain arrays.
119    /// Returns the farthest point on the cone in the given direction.
120    #[allow(dead_code)]
121    pub fn support(&self, direction: [f64; 3]) -> [f64; 3] {
122        let h = 2.0 * self.half_height;
123        let xz_len = (direction[0] * direction[0] + direction[2] * direction[2]).sqrt();
124
125        let apex_dot = direction[1] * self.half_height;
126        let base_center_dot = -direction[1] * self.half_height;
127        let base_rim_dot = base_center_dot + self.radius * xz_len;
128
129        if apex_dot >= base_rim_dot {
130            [0.0, self.half_height, 0.0]
131        } else if xz_len > 1e-10 {
132            let _sin_angle = self.radius / (self.radius * self.radius + h * h).sqrt();
133            [
134                self.radius * direction[0] / xz_len,
135                -self.half_height,
136                self.radius * direction[2] / xz_len,
137            ]
138        } else {
139            [self.radius, -self.half_height, 0.0]
140        }
141    }
142
143    /// Slant height of the cone: sqrt(r² + h²).
144    #[allow(dead_code)]
145    pub fn slant_height(&self) -> f64 {
146        let h = 2.0 * self.half_height;
147        (self.radius * self.radius + h * h).sqrt()
148    }
149
150    /// Half angle of the cone in radians: atan(r / h).
151    #[allow(dead_code)]
152    pub fn half_angle(&self) -> f64 {
153        let h = 2.0 * self.half_height;
154        (self.radius / h).atan()
155    }
156
157    /// Lateral (side) surface area only: πr * slant.
158    #[allow(dead_code)]
159    pub fn lateral_surface_area(&self) -> f64 {
160        PI * self.radius * self.slant_height()
161    }
162
163    /// Base area: πr².
164    #[allow(dead_code)]
165    pub fn base_area(&self) -> f64 {
166        PI * self.radius * self.radius
167    }
168
169    /// Returns true if `p` is inside the cone.
170    #[allow(dead_code)]
171    pub fn contains_point(&self, p: [f64; 3]) -> bool {
172        if p[1] < -self.half_height || p[1] > self.half_height {
173            return false;
174        }
175        let h = 2.0 * self.half_height;
176        let r_at_y = self.radius * (self.half_height - p[1]) / h;
177        let xz2 = p[0] * p[0] + p[2] * p[2];
178        xz2 <= r_at_y * r_at_y
179    }
180
181    /// Signed distance from a point to the cone surface.
182    /// Negative inside, positive outside.
183    #[allow(dead_code)]
184    pub fn signed_distance(&self, p: [f64; 3]) -> f64 {
185        let cp = self.closest_point(p);
186        let dx = p[0] - cp[0];
187        let dy = p[1] - cp[1];
188        let dz = p[2] - cp[2];
189        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
190        if self.contains_point(p) { -dist } else { dist }
191    }
192
193    /// Radius at a given y coordinate (clamped to cone range).
194    #[allow(dead_code)]
195    pub fn radius_at_y(&self, y: f64) -> f64 {
196        let clamped = y.clamp(-self.half_height, self.half_height);
197        let h = 2.0 * self.half_height;
198        self.radius * (self.half_height - clamped) / h
199    }
200
201    /// Create a truncated cone (frustum) by cutting at a given height ratio.
202    /// `cut_ratio` is in \[0, 1\] where 0 = base, 1 = apex.
203    /// Returns `(top_radius, bottom_radius, half_height)`.
204    #[allow(dead_code)]
205    pub fn truncated_cone(&self, cut_ratio: f64) -> (f64, f64, f64) {
206        let ratio = cut_ratio.clamp(0.0, 1.0);
207        let top_r = self.radius * (1.0 - ratio);
208        let bottom_r = self.radius;
209        let hh = self.half_height * ratio;
210        (top_r, bottom_r, hh)
211    }
212
213    /// Volume of a truncated cone (frustum).
214    /// `cut_ratio` is in \[0, 1\].
215    #[allow(dead_code)]
216    pub fn truncated_cone_volume(&self, cut_ratio: f64) -> f64 {
217        let (r1, r2, _) = self.truncated_cone(cut_ratio);
218        let h = 2.0 * self.half_height * cut_ratio.clamp(0.0, 1.0);
219        PI * h * (r1 * r1 + r1 * r2 + r2 * r2) / 3.0
220    }
221
222    /// Ray cast against only the base cap at y = -half_height.
223    #[allow(dead_code)]
224    pub fn ray_cast_base_cap(
225        &self,
226        origin: [f64; 3],
227        direction: [f64; 3],
228        max_toi: f64,
229    ) -> Option<(f64, [f64; 3])> {
230        if direction[1].abs() < 1e-12 {
231            return None;
232        }
233        let t = (-self.half_height - origin[1]) / direction[1];
234        if t < 0.0 || t > max_toi {
235            return None;
236        }
237        let px = origin[0] + t * direction[0];
238        let pz = origin[2] + t * direction[2];
239        if px * px + pz * pz <= self.radius * self.radius {
240            Some((t, [0.0, -1.0, 0.0]))
241        } else {
242            None
243        }
244    }
245
246    /// Closest point on the cone's lateral surface to a point.
247    /// This projects onto the side surface only (ignoring base cap).
248    #[allow(dead_code)]
249    pub fn closest_point_on_lateral(&self, p: [f64; 3]) -> [f64; 3] {
250        let xz_len = (p[0] * p[0] + p[2] * p[2]).sqrt();
251        let h = 2.0 * self.half_height;
252
253        let side_len = self.slant_height();
254        let side_dx = self.radius / side_len;
255        let side_dy = -h / side_len;
256
257        let vx = xz_len;
258        let vy = p[1] - self.half_height;
259
260        let t = (vx * side_dx + vy * side_dy).clamp(0.0, side_len);
261        let proj_xz = t * side_dx;
262        let proj_y = self.half_height + t * side_dy;
263
264        if xz_len < 1e-12 {
265            [proj_xz, proj_y, 0.0]
266        } else {
267            let s = proj_xz / xz_len;
268            [p[0] * s, proj_y, p[2] * s]
269        }
270    }
271
272    // ── New expanded methods ──
273
274    /// Analytic ray-cone intersection (same as `ray_cast` but returns plain arrays).
275    /// Identical to `ray_cast_array` but additionally returns a boolean indicating
276    /// whether the hit is on the lateral surface (`true`) or base cap (`false`).
277    #[allow(dead_code)]
278    pub fn ray_cone_analytic(
279        &self,
280        origin: [f64; 3],
281        direction: [f64; 3],
282        max_toi: f64,
283    ) -> Option<(f64, [f64; 3], bool)> {
284        let o = Vec3::new(origin[0], origin[1], origin[2]);
285        let d = Vec3::new(direction[0], direction[1], direction[2]);
286
287        let h = 2.0 * self.half_height;
288        let k = self.radius / h;
289        let k2 = k * k;
290        let apex_y = self.half_height;
291
292        let fy = apex_y - o.y;
293        let a = d.x * d.x + d.z * d.z - k2 * d.y * d.y;
294        let b = 2.0 * (o.x * d.x + o.z * d.z + k2 * fy * d.y);
295        let c_val = o.x * o.x + o.z * o.z - k2 * fy * fy;
296
297        let mut best_t = f64::INFINITY;
298        let mut best_is_lateral = true;
299
300        if a.abs() > 1e-12 {
301            let disc = b * b - 4.0 * a * c_val;
302            if disc >= 0.0 {
303                let sqrt_disc = disc.sqrt();
304                for t in [(-b - sqrt_disc) / (2.0 * a), (-b + sqrt_disc) / (2.0 * a)] {
305                    if t >= 0.0 && t <= max_toi {
306                        let py = o.y + t * d.y;
307                        if py >= -self.half_height && py <= self.half_height && t < best_t {
308                            best_t = t;
309                            best_is_lateral = true;
310                        }
311                    }
312                }
313            }
314        }
315
316        // Base cap
317        if d.y.abs() > 1e-12 {
318            let t = (-self.half_height - o.y) / d.y;
319            if t >= 0.0 && t <= max_toi {
320                let px = o.x + t * d.x;
321                let pz = o.z + t * d.z;
322                if px * px + pz * pz <= self.radius * self.radius && t < best_t {
323                    best_t = t;
324                    best_is_lateral = false;
325                }
326            }
327        }
328
329        if best_t.is_infinite() {
330            return None;
331        }
332
333        let hit_pt = [
334            origin[0] + best_t * direction[0],
335            origin[1] + best_t * direction[1],
336            origin[2] + best_t * direction[2],
337        ];
338        let normal = if best_is_lateral {
339            let xz_len = (hit_pt[0] * hit_pt[0] + hit_pt[2] * hit_pt[2])
340                .sqrt()
341                .max(1e-30);
342            let slope = self.radius / h;
343            let nx = hit_pt[0] / xz_len;
344            let nz = hit_pt[2] / xz_len;
345            let len = (nx * nx + slope * slope + nz * nz).sqrt();
346            [nx / len, slope / len, nz / len]
347        } else {
348            [0.0, -1.0, 0.0]
349        };
350
351        Some((best_t, normal, best_is_lateral))
352    }
353
354    /// Cone-plane intersection.
355    ///
356    /// Tests whether the cone intersects a plane defined by `plane_normal` (unit)
357    /// and `plane_d` (signed distance from origin: dot(n, x) = plane_d).
358    ///
359    /// Returns `true` if any part of the cone (including base disk) is on or
360    /// crosses the plane.
361    #[allow(dead_code)]
362    pub fn intersects_plane(&self, plane_normal: [f64; 3], plane_d: f64) -> bool {
363        // Evaluate the signed distance of the apex and the base rim extremes.
364        let apex = [0.0_f64, self.half_height, 0.0];
365        let apex_sd =
366            plane_normal[0] * apex[0] + plane_normal[1] * apex[1] + plane_normal[2] * apex[2]
367                - plane_d;
368
369        // Base center
370        let base_center_sd = -plane_normal[1] * self.half_height - plane_d;
371
372        // Farthest base rim point in plane_normal direction
373        let xz_proj =
374            (plane_normal[0] * plane_normal[0] + plane_normal[2] * plane_normal[2]).sqrt();
375        let rim_sd = base_center_sd + self.radius * xz_proj;
376        let rim_sd_neg = base_center_sd - self.radius * xz_proj;
377
378        // Intersection if any signed distance changes sign
379        let signs = [apex_sd, rim_sd, rim_sd_neg];
380        let any_pos = signs.iter().any(|&s| s >= 0.0);
381        let any_neg = signs.iter().any(|&s| s <= 0.0);
382        any_pos && any_neg
383    }
384
385    /// Compute a frustum (truncated cone) from this cone.
386    ///
387    /// Cuts the cone at heights `y_bottom` and `y_top` (in cone-local space,
388    /// where -half_height ≤ y_bottom < y_top ≤ half_height).
389    ///
390    /// Returns `(bottom_radius, top_radius, frustum_half_height)`.
391    #[allow(dead_code)]
392    pub fn frustum_from_cone(&self, y_bottom: f64, y_top: f64) -> (f64, f64, f64) {
393        let yb = y_bottom.clamp(-self.half_height, self.half_height);
394        let yt = y_top.clamp(yb, self.half_height);
395        let r_bottom = self.radius_at_y(yb);
396        let r_top = self.radius_at_y(yt);
397        let frustum_hh = (yt - yb) * 0.5;
398        (r_bottom, r_top, frustum_hh)
399    }
400
401    /// Cone SDF (Signed Distance Function).
402    ///
403    /// Returns negative inside the cone, positive outside.
404    /// Same as `signed_distance` but exposed under the SDF naming convention.
405    #[allow(dead_code)]
406    pub fn sdf(&self, p: [f64; 3]) -> f64 {
407        self.signed_distance(p)
408    }
409
410    /// Cone-sphere intersection test.
411    ///
412    /// Returns `true` if the sphere (center + radius) overlaps the cone.
413    /// Uses the closest-point approach: if the closest point on the cone to
414    /// the sphere center is within `sphere_radius`, they intersect.
415    #[allow(dead_code)]
416    pub fn intersects_sphere(&self, sphere_center: [f64; 3], sphere_radius: f64) -> bool {
417        let cp = self.closest_point(sphere_center);
418        let dx = sphere_center[0] - cp[0];
419        let dy = sphere_center[1] - cp[1];
420        let dz = sphere_center[2] - cp[2];
421        let dist_sq = dx * dx + dy * dy + dz * dz;
422        dist_sq <= sphere_radius * sphere_radius
423    }
424
425    /// Lateral surface area of a frustum derived from this cone.
426    ///
427    /// `y_bottom` and `y_top` are in cone-local Y coordinates.
428    /// Lateral area = π * (r_bottom + r_top) * slant_height_frustum.
429    #[allow(dead_code)]
430    pub fn frustum_lateral_area(&self, y_bottom: f64, y_top: f64) -> f64 {
431        let (r_bottom, r_top, _hh) = self.frustum_from_cone(y_bottom, y_top);
432        let dy = (y_top - y_bottom).abs();
433        let dr = (r_bottom - r_top).abs();
434        let slant = (dy * dy + dr * dr).sqrt();
435        PI * (r_bottom + r_top) * slant
436    }
437
438    // ── Extended geometry: double cone, oblique, unfolding, bounding cone ──
439
440    /// Apex angle of the cone (full angle at the apex), in radians.
441    ///
442    /// The full apex angle is `2 * atan(r / h)` where h is the full height.
443    #[allow(dead_code)]
444    pub fn apex_angle(&self) -> f64 {
445        2.0 * self.half_angle()
446    }
447
448    /// Create a double cone (bicone) by reflecting this cone across its base.
449    ///
450    /// Returns the parameters of the upper half (this cone) and the lower half
451    /// (same dimensions, mirrored).  Both have apex at `±half_height`.
452    #[allow(dead_code)]
453    pub fn double_cone_params(&self) -> (f64, f64) {
454        (self.radius, self.half_height)
455    }
456
457    /// Returns `true` if `p` is inside the double cone (both upper and lower).
458    ///
459    /// The double cone is symmetric about y=0.  Upper half: apex at
460    /// `+half_height`, lower half: apex at `-half_height`.
461    #[allow(dead_code)]
462    pub fn contains_point_double_cone(&self, p: [f64; 3]) -> bool {
463        let h = 2.0 * self.half_height;
464        let xz2 = p[0] * p[0] + p[2] * p[2];
465        let y_abs = p[1].abs();
466        if y_abs > self.half_height {
467            return false;
468        }
469        // At y_abs, radius = r * y_abs / half_height
470        let r_at = self.radius * y_abs / self.half_height;
471        xz2 <= r_at * r_at + 1e-14 * h
472    }
473
474    /// Lateral surface unfolding: convert `(u, t)` to a point in the unrolled
475    /// lateral surface plane.
476    ///
477    /// In the unrolled sector the slant height becomes the radial distance and
478    /// the azimuth `u` is scaled by `sin(half_angle)`.  Returns `(x_unrolled, y_unrolled)`.
479    #[allow(dead_code)]
480    pub fn unfold_lateral(&self, u: f64, t_slant: f64) -> [f64; 2] {
481        // When unrolled, the cone becomes a sector with radius = slant_height
482        // and arc angle = 2π * sin(half_angle).
483        let sin_alpha = self.radius / self.slant_height().max(1e-14);
484        let phi = u * sin_alpha;
485        [t_slant * phi.cos(), t_slant * phi.sin()]
486    }
487
488    /// Bounding cone for a set of points: returns the smallest half-angle cone
489    /// with its apex at the origin and axis along +Y that contains all points.
490    ///
491    /// Returns `(axis_half_angle_radians, axis_elevation)` where the axis is
492    /// fixed to +Y.
493    #[allow(dead_code)]
494    pub fn bounding_cone_of_points(points: &[[f64; 3]]) -> (f64, f64) {
495        if points.is_empty() {
496            return (0.0, 0.0);
497        }
498        let mut max_half_angle: f64 = 0.0;
499        let mut max_elev: f64 = 0.0;
500        for &p in points {
501            let len = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
502            if len < 1e-14 {
503                continue;
504            }
505            // Half-angle from +Y axis
506            let cos_a = (p[1] / len).clamp(-1.0, 1.0);
507            let half_angle = cos_a.acos();
508            let elev = p[1] / len;
509            if half_angle > max_half_angle {
510                max_half_angle = half_angle;
511                max_elev = elev;
512            }
513        }
514        (max_half_angle, max_elev)
515    }
516
517    /// Volume swept when the cone translates by vector `delta`.
518    ///
519    /// Approximate: V_cone + base_area * |delta|.
520    #[allow(dead_code)]
521    pub fn volume_swept(&self, delta: [f64; 3]) -> f64 {
522        let dist = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
523        self.volume() + self.base_area() * dist
524    }
525
526    /// Cone SDF gradient (approximate unit normal direction).
527    ///
528    /// Returns the gradient of the SDF at `p`, suitable as the collision normal.
529    #[allow(dead_code)]
530    pub fn sdf_gradient(&self, p: [f64; 3]) -> [f64; 3] {
531        let eps = 1e-5;
532        let s0 = self.sdf(p);
533        let gx = (self.sdf([p[0] + eps, p[1], p[2]]) - s0) / eps;
534        let gy = (self.sdf([p[0], p[1] + eps, p[2]]) - s0) / eps;
535        let gz = (self.sdf([p[0], p[1], p[2] + eps]) - s0) / eps;
536        let len = (gx * gx + gy * gy + gz * gz).sqrt().max(1e-14);
537        [gx / len, gy / len, gz / len]
538    }
539
540    /// Ray-cone intersection count (lateral + base cap).
541    ///
542    /// Returns the number of valid positive-t intersections within `[0, max_toi]`.
543    #[allow(dead_code)]
544    pub fn ray_intersection_count(
545        &self,
546        origin: [f64; 3],
547        direction: [f64; 3],
548        max_toi: f64,
549    ) -> usize {
550        let mut count = 0usize;
551        let h = 2.0 * self.half_height;
552        let k = self.radius / h;
553        let k2 = k * k;
554        let apex_y = self.half_height;
555        let fy = apex_y - origin[1];
556        let a = direction[0] * direction[0] + direction[2] * direction[2]
557            - k2 * direction[1] * direction[1];
558        let b =
559            2.0 * (origin[0] * direction[0] + origin[2] * direction[2] + k2 * fy * direction[1]);
560        let c = origin[0] * origin[0] + origin[2] * origin[2] - k2 * fy * fy;
561        if a.abs() > 1e-12 {
562            let disc = b * b - 4.0 * a * c;
563            if disc >= 0.0 {
564                let sq = disc.sqrt();
565                for t in [(-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a)] {
566                    if t >= 0.0 && t <= max_toi {
567                        let py = origin[1] + t * direction[1];
568                        if py >= -self.half_height && py <= self.half_height {
569                            count += 1;
570                        }
571                    }
572                }
573            }
574        }
575        // Base cap
576        if direction[1].abs() > 1e-12 {
577            let t = (-self.half_height - origin[1]) / direction[1];
578            if t >= 0.0 && t <= max_toi {
579                let px = origin[0] + t * direction[0];
580                let pz = origin[2] + t * direction[2];
581                if px * px + pz * pz <= self.radius * self.radius {
582                    count += 1;
583                }
584            }
585        }
586        count
587    }
588
589    /// Compute the solid angle subtended by the cone at the apex.
590    ///
591    /// For a cone with half-angle `α`, the solid angle is `2π(1 - cos α)`.
592    #[allow(dead_code)]
593    pub fn solid_angle(&self) -> f64 {
594        let alpha = self.half_angle();
595        2.0 * PI * (1.0 - alpha.cos())
596    }
597
598    /// Generate points on the base circle of the cone.
599    ///
600    /// Returns `n` equally-spaced points on the base rim at `y = -half_height`.
601    #[allow(dead_code)]
602    pub fn base_rim_points(&self, n: usize) -> Vec<[f64; 3]> {
603        let n = n.max(3);
604        (0..n)
605            .map(|i| {
606                let t = 2.0 * PI * i as f64 / n as f64;
607                [
608                    self.radius * t.cos(),
609                    -self.half_height,
610                    self.radius * t.sin(),
611                ]
612            })
613            .collect()
614    }
615
616    /// Test whether a sphere at `center` with `sphere_radius` is entirely
617    /// inside the cone (a stricter test than `intersects_sphere`).
618    #[allow(dead_code)]
619    pub fn sphere_inside_cone(&self, center: [f64; 3], sphere_radius: f64) -> bool {
620        // The sphere is inside if all points on the sphere are inside the cone.
621        // Approximate: check the sphere center is inside and the distance to
622        // the cone surface is at least sphere_radius.
623        let sd = self.sdf(center);
624        sd + sphere_radius <= 0.0
625    }
626
627    /// Sample a random point on the cone's lateral surface using a deterministic PRNG.
628    #[allow(dead_code)]
629    pub fn random_lateral_surface_point(&self, seed: u64) -> [f64; 3] {
630        // Use a simple xorshift64 to get reproducible results
631        let mut state = seed;
632        let xorshift = |s: &mut u64| -> f64 {
633            *s ^= *s << 13;
634            *s ^= *s >> 7;
635            *s ^= *s << 17;
636            (*s as f64) / (u64::MAX as f64)
637        };
638
639        // Sample t along slant height proportional to circumference (inverse CDF)
640        // Circumference at height t: 2π * r(t), where r(t) = r*(1 - t/h)
641        // CDF of area up to t: t - t²/(2h), so sample with u = t/h - (t/h)²/2
642        // Invert: t/h = 1 - sqrt(1-u) (for top-aligned cone where apex has r=0)
643        let u = xorshift(&mut state);
644        let frac_t = 1.0 - (1.0 - u).sqrt(); // normalized height fraction from apex
645        let y = self.half_height - frac_t * 2.0 * self.half_height;
646        let r_at_y = self.radius_at_y(y);
647        let theta = xorshift(&mut state) * 2.0 * PI;
648        [r_at_y * theta.cos(), y, r_at_y * theta.sin()]
649    }
650}
651
652impl Shape for Cone {
653    fn bounding_box(&self) -> Aabb {
654        Aabb::new(
655            Vec3::new(-self.radius, -self.half_height, -self.radius),
656            Vec3::new(self.radius, self.half_height, self.radius),
657        )
658    }
659
660    fn support_point(&self, direction: &Vec3) -> Vec3 {
661        let h = 2.0 * self.half_height;
662        let xz_len = (direction.x * direction.x + direction.z * direction.z).sqrt();
663
664        // Check if apex is more extreme
665        let apex_dot = direction.y * self.half_height;
666        let base_center_dot = -direction.y * self.half_height;
667        let base_rim_dot = base_center_dot + self.radius * xz_len;
668
669        if apex_dot >= base_rim_dot {
670            Vec3::new(0.0, self.half_height, 0.0)
671        } else if xz_len > 1e-10 {
672            let sin_angle = self.radius / (self.radius * self.radius + h * h).sqrt();
673            let _ = sin_angle; // used for more precise support, but rim suffices
674            Vec3::new(
675                self.radius * direction.x / xz_len,
676                -self.half_height,
677                self.radius * direction.z / xz_len,
678            )
679        } else {
680            Vec3::new(self.radius, -self.half_height, 0.0)
681        }
682    }
683
684    fn volume(&self) -> Real {
685        let h = 2.0 * self.half_height;
686        PI * self.radius * self.radius * h / 3.0
687    }
688
689    fn center_of_mass(&self) -> Vec3 {
690        // Center of mass is at 1/4 height from the base
691        Vec3::new(0.0, -self.half_height * 0.5, 0.0)
692    }
693
694    fn inertia_tensor(&self, mass: Real) -> Mat3 {
695        let r2 = self.radius * self.radius;
696        // Full height h = 2 * half_height
697        let h2 = (2.0 * self.half_height).powi(2);
698        // I_zz (spin axis) = 3*m*r²/10
699        let iy = 3.0 * mass * r2 / 10.0;
700        // I_xx = I_yy = m*(3r² + 2h²)/20  (h = full height)
701        let ixz = mass * (3.0 * r2 + 2.0 * h2) / 20.0;
702        Mat3::new(ixz, 0.0, 0.0, 0.0, iy, 0.0, 0.0, 0.0, ixz)
703    }
704
705    fn ray_cast(&self, ray_origin: &Vec3, ray_direction: &Vec3, max_toi: Real) -> Option<RayHit> {
706        // Cone: apex at y=+half_height, base at y=-half_height, radius r.
707        // For a point P on the cone surface: r_at_y = radius * (half_height - y) / (2*half_height)
708        // Constraint: x² + z² = [r * (h - y) / (2h)]²
709        // Let k = r / (2*half_height), apex_y = half_height
710        // (ox + t*dx)² + (oz + t*dz)² = [k*(apex_y - (oy + t*dy))]²
711        let h = 2.0 * self.half_height;
712        let k = self.radius / h;
713        let k2 = k * k;
714        let apex_y = self.half_height;
715
716        let ox = ray_origin.x;
717        let oy = ray_origin.y;
718        let oz = ray_origin.z;
719        let dx = ray_direction.x;
720        let dy = ray_direction.y;
721        let dz = ray_direction.z;
722
723        // (dx²+dz² - k²*dy²) t² + 2*(ox*dx+oz*dz + k²*(apex_y-oy)*dy) t +
724        //   (ox²+oz² - k²*(apex_y-oy)²) = 0
725        let fy = apex_y - oy;
726        let a = dx * dx + dz * dz - k2 * dy * dy;
727        let b = 2.0 * (ox * dx + oz * dz + k2 * fy * dy);
728        let c_val = ox * ox + oz * oz - k2 * fy * fy;
729
730        let mut best: Option<RayHit> = None;
731
732        let try_t = |t: Real, best: &mut Option<RayHit>| {
733            if t < 0.0 || t > max_toi {
734                return;
735            }
736            let p = ray_origin + ray_direction * t;
737            if p.y < -self.half_height || p.y > self.half_height {
738                return;
739            }
740            // Normal: outward in XZ, with slope component
741            let xz_len = (p.x * p.x + p.z * p.z).sqrt();
742            let normal = if xz_len > 1e-12 {
743                let slope = self.radius / h;
744                Vec3::new(p.x / xz_len, slope, p.z / xz_len).normalize()
745            } else {
746                Vec3::new(0.0, 1.0, 0.0)
747            };
748            if best.as_ref().is_none_or(|prev| t < prev.toi) {
749                *best = Some(RayHit {
750                    point: p,
751                    normal,
752                    toi: t,
753                });
754            }
755        };
756
757        if a.abs() > 1e-12 {
758            let disc = b * b - 4.0 * a * c_val;
759            if disc >= 0.0 {
760                let sqrt_disc = disc.sqrt();
761                try_t((-b - sqrt_disc) / (2.0 * a), &mut best);
762                try_t((-b + sqrt_disc) / (2.0 * a), &mut best);
763            }
764        } else if b.abs() > 1e-12 {
765            try_t(-c_val / b, &mut best);
766        }
767
768        // Base cap at y = -half_height
769        if ray_direction.y.abs() > 1e-12 {
770            let t = (-self.half_height - ray_origin.y) / ray_direction.y;
771            if t >= 0.0 && t <= max_toi {
772                let p = ray_origin + ray_direction * t;
773                if p.x * p.x + p.z * p.z <= self.radius * self.radius {
774                    let normal = Vec3::new(0.0, -1.0, 0.0);
775                    if best.as_ref().is_none_or(|prev| t < prev.toi) {
776                        best = Some(RayHit {
777                            point: p,
778                            normal,
779                            toi: t,
780                        });
781                    }
782                }
783            }
784        }
785
786        best
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    #[test]
795    fn test_cone_volume() {
796        let c = Cone::new(1.0, 1.5);
797        let expected = PI * 1.0 * 3.0 / 3.0;
798        assert!((c.volume() - expected).abs() < 1e-10);
799    }
800
801    #[test]
802    fn test_cone_surface_area() {
803        // r=1, h=2 (half_height=1): base=π, slant=sqrt(1+4)=sqrt(5), lateral=π*sqrt(5)
804        let c = Cone::new(1.0, 1.0);
805        let slant = (1.0_f64 + 4.0_f64).sqrt();
806        let expected = PI + PI * slant;
807        assert!((c.surface_area() - expected).abs() < 1e-10);
808    }
809
810    #[test]
811    fn test_cone_support_apex() {
812        let c = Cone::new(1.0, 2.0);
813        let sp = c.support_point(&Vec3::new(0.0, 1.0, 0.0));
814        assert!((sp.y - 2.0).abs() < 1e-10);
815    }
816
817    #[test]
818    fn test_cone_inertia_symmetry() {
819        let c = Cone::new(1.5, 2.0);
820        let it = c.inertia_tensor(3.0);
821        // I_xx == I_zz (both are ixz), I_yy is spin axis
822        let diff = (it[(0, 0)] - it[(2, 2)]).abs();
823        assert!(
824            diff < 1e-10,
825            "I_xx != I_zz: {} vs {}",
826            it[(0, 0)],
827            it[(2, 2)]
828        );
829        // Off-diagonal should be zero
830        assert!(it[(0, 1)].abs() < 1e-10);
831        assert!(it[(1, 0)].abs() < 1e-10);
832    }
833
834    #[test]
835    fn test_cone_inertia_array() {
836        let c = Cone::new(1.0, 1.0);
837        let it = c.inertia_tensor_array(1.0);
838        assert!(it[0][0] > 0.0);
839        assert!(it[1][1] > 0.0);
840        assert!((it[0][0] - it[2][2]).abs() < 1e-10);
841    }
842
843    #[test]
844    fn test_cone_raycast_side() {
845        // Ray along +X hitting the side of the cone
846        let c = Cone::new(1.0, 2.0); // apex at y=2, base at y=-2, radius 1
847        // Ray from far left at y=0 (midpoint height), should intersect side
848        let origin = Vec3::new(-5.0, 0.0, 0.0);
849        let dir = Vec3::new(1.0, 0.0, 0.0);
850        // At y=0, the cone radius is r*(h - y)/(2h) = 1*(2-0)/4 = 0.5
851        let hit = c.ray_cast(&origin, &dir, 100.0);
852        assert!(hit.is_some(), "expected a hit on cone side");
853        let hit = hit.unwrap();
854        assert!(hit.toi > 0.0);
855        assert!(
856            (hit.point.x + 0.5).abs() < 1e-6,
857            "expected hit at x=-0.5, got x={}",
858            hit.point.x
859        );
860    }
861
862    #[test]
863    fn test_cone_raycast_base_cap() {
864        // Ray from below hitting the base cap
865        let c = Cone::new(1.0, 1.0); // apex at y=1, base at y=-1
866        let origin = Vec3::new(0.0, -5.0, 0.0);
867        let dir = Vec3::new(0.0, 1.0, 0.0);
868        let hit = c.ray_cast(&origin, &dir, 100.0);
869        assert!(hit.is_some(), "expected a hit on cone base");
870        let hit = hit.unwrap();
871        assert!(
872            (hit.toi - 4.0).abs() < 1e-6,
873            "expected toi=4, got {}",
874            hit.toi
875        );
876        assert!((hit.normal.y + 1.0).abs() < 1e-10);
877    }
878
879    #[test]
880    fn test_cone_closest_point_outside() {
881        let c = Cone::new(1.0, 1.0); // apex y=1, base y=-1, r=1
882        // Point far in X at y=0: cone radius at y=0 is 0.5
883        let cp = c.closest_point([5.0, 0.0, 0.0]);
884        // Should project to cone side at y=0, x=0.5
885        assert!(
886            (cp[0] - 0.5).abs() < 1e-6,
887            "expected cp[0]=0.5, got {}",
888            cp[0]
889        );
890        assert!((cp[1]).abs() < 1e-6);
891    }
892
893    // ── New tests ──
894
895    #[test]
896    fn test_cone_support_array_apex() {
897        let c = Cone::new(1.0, 2.0);
898        let sp = c.support([0.0, 1.0, 0.0]);
899        assert!((sp[1] - 2.0).abs() < 1e-10);
900    }
901
902    #[test]
903    fn test_cone_support_array_base_rim() {
904        let c = Cone::new(1.0, 2.0);
905        let sp = c.support([1.0, -1.0, 0.0]);
906        assert!((sp[0] - 1.0).abs() < 1e-10);
907        assert!((sp[1] + 2.0).abs() < 1e-10);
908    }
909
910    #[test]
911    fn test_cone_slant_height() {
912        let c = Cone::new(3.0, 2.0); // h=4
913        let expected = (9.0 + 16.0_f64).sqrt(); // 5
914        assert!((c.slant_height() - expected).abs() < 1e-10);
915    }
916
917    #[test]
918    fn test_cone_half_angle() {
919        let c = Cone::new(1.0, 1.0); // r=1, h=2
920        let expected = (0.5_f64).atan(); // atan(r/h) = atan(0.5)
921        assert!((c.half_angle() - expected).abs() < 1e-10);
922    }
923
924    #[test]
925    fn test_cone_lateral_surface_area() {
926        let c = Cone::new(1.0, 1.0);
927        let slant = c.slant_height();
928        let expected = PI * 1.0 * slant;
929        assert!((c.lateral_surface_area() - expected).abs() < 1e-10);
930    }
931
932    #[test]
933    fn test_cone_base_area() {
934        let c = Cone::new(2.0, 1.0);
935        assert!((c.base_area() - 4.0 * PI).abs() < 1e-10);
936    }
937
938    #[test]
939    fn test_cone_surface_area_decomposition() {
940        let c = Cone::new(1.5, 2.0);
941        let total = c.surface_area();
942        let decomposed = c.lateral_surface_area() + c.base_area();
943        assert!((total - decomposed).abs() < 1e-10);
944    }
945
946    #[test]
947    fn test_cone_contains_point() {
948        let c = Cone::new(1.0, 1.0); // apex y=1, base y=-1, r=1
949        assert!(c.contains_point([0.0, 0.0, 0.0])); // center, r_at_0 = 0.5
950        assert!(c.contains_point([0.0, -1.0, 0.0])); // base center
951        assert!(!c.contains_point([0.0, 2.0, 0.0])); // above apex
952        assert!(!c.contains_point([1.0, 0.0, 0.0])); // at y=0, r=0.5, x=1 > 0.5
953    }
954
955    #[test]
956    fn test_cone_contains_point_base_rim() {
957        let c = Cone::new(1.0, 1.0);
958        // At y=-1, r_at_y = 1.0, so (1,0) should be on boundary
959        assert!(c.contains_point([1.0, -1.0, 0.0]));
960        assert!(!c.contains_point([1.1, -1.0, 0.0]));
961    }
962
963    #[test]
964    fn test_cone_signed_distance_inside() {
965        let c = Cone::new(2.0, 2.0);
966        let d = c.signed_distance([0.0, -2.0, 0.0]); // base center
967        assert!(d <= 0.0);
968    }
969
970    #[test]
971    fn test_cone_signed_distance_outside() {
972        let c = Cone::new(1.0, 1.0);
973        let d = c.signed_distance([5.0, 0.0, 0.0]);
974        assert!(d > 0.0);
975    }
976
977    #[test]
978    fn test_cone_radius_at_y() {
979        let c = Cone::new(2.0, 2.0); // h=4
980        assert!((c.radius_at_y(-2.0) - 2.0).abs() < 1e-10); // base
981        assert!((c.radius_at_y(2.0)).abs() < 1e-10); // apex
982        assert!((c.radius_at_y(0.0) - 1.0).abs() < 1e-10); // midpoint
983    }
984
985    #[test]
986    fn test_cone_truncated_cone() {
987        let c = Cone::new(2.0, 2.0);
988        let (top_r, bot_r, hh) = c.truncated_cone(0.5);
989        assert!((bot_r - 2.0).abs() < 1e-10);
990        assert!((top_r - 1.0).abs() < 1e-10); // 2 * (1 - 0.5)
991        assert!((hh - 1.0).abs() < 1e-10); // 2 * 0.5
992    }
993
994    #[test]
995    fn test_cone_truncated_cone_full() {
996        let c = Cone::new(2.0, 2.0);
997        let (top_r, _, _) = c.truncated_cone(1.0);
998        assert!(top_r.abs() < 1e-10); // full cone, top radius = 0
999    }
1000
1001    #[test]
1002    fn test_cone_truncated_cone_volume() {
1003        let c = Cone::new(1.0, 1.0);
1004        // Full cone volume = π * 1 * 2 / 3
1005        let full_vol = c.volume();
1006        // Truncated at ratio=1.0 should give the full cone volume
1007        let trunc_vol = c.truncated_cone_volume(1.0);
1008        assert!((trunc_vol - full_vol).abs() < 1e-10);
1009    }
1010
1011    #[test]
1012    fn test_cone_ray_cast_base_cap_array() {
1013        let c = Cone::new(1.0, 1.0);
1014        let result = c.ray_cast_base_cap([0.0, -5.0, 0.0], [0.0, 1.0, 0.0], 100.0);
1015        assert!(result.is_some());
1016        let (t, n) = result.unwrap();
1017        assert!((t - 4.0).abs() < 1e-10);
1018        assert!((n[1] + 1.0).abs() < 1e-10);
1019    }
1020
1021    #[test]
1022    fn test_cone_ray_cast_base_cap_miss() {
1023        let c = Cone::new(1.0, 1.0);
1024        let result = c.ray_cast_base_cap([5.0, -5.0, 0.0], [0.0, 1.0, 0.0], 100.0);
1025        assert!(result.is_none());
1026    }
1027
1028    #[test]
1029    fn test_cone_closest_point_on_lateral() {
1030        let c = Cone::new(1.0, 1.0);
1031        // Point far out along X at y=0
1032        let cp = c.closest_point_on_lateral([5.0, 0.0, 0.0]);
1033        // At y=0 on the side, r should be about 0.5
1034        // But closest_point_on_lateral projects onto the actual side line
1035        assert!(cp[0] > 0.0);
1036        // The point should be on the cone side
1037        let r = (cp[0] * cp[0] + cp[2] * cp[2]).sqrt();
1038        let expected_r = c.radius_at_y(cp[1]);
1039        assert!((r - expected_r).abs() < 0.1);
1040    }
1041
1042    #[test]
1043    fn test_cone_closest_point_on_lateral_apex() {
1044        let c = Cone::new(1.0, 1.0);
1045        // Point above the apex
1046        let cp = c.closest_point_on_lateral([0.0, 5.0, 0.0]);
1047        // Should project to apex
1048        assert!((cp[1] - 1.0).abs() < 1e-10);
1049        assert!(cp[0].abs() < 1e-10);
1050    }
1051
1052    #[test]
1053    fn test_cone_volume_explicit_matches() {
1054        let c = Cone::new(1.5, 2.5);
1055        assert!((c.volume_explicit() - c.volume()).abs() < 1e-10);
1056    }
1057
1058    // ── Expanded tests ──
1059
1060    #[test]
1061    fn test_cone_ray_analytic_lateral() {
1062        let c = Cone::new(1.0, 2.0);
1063        let result = c.ray_cone_analytic([-5.0, 0.0, 0.0], [1.0, 0.0, 0.0], 100.0);
1064        assert!(result.is_some(), "should hit lateral surface");
1065        let (t, n, is_lateral) = result.unwrap();
1066        assert!(t > 0.0);
1067        assert!(is_lateral, "should be lateral hit");
1068        assert!(
1069            n[1] > 0.0,
1070            "normal should have positive Y component for lateral cone"
1071        );
1072    }
1073
1074    #[test]
1075    fn test_cone_ray_analytic_base() {
1076        let c = Cone::new(1.0, 1.0);
1077        let result = c.ray_cone_analytic([0.0, -5.0, 0.0], [0.0, 1.0, 0.0], 100.0);
1078        assert!(result.is_some());
1079        let (t, n, is_lateral) = result.unwrap();
1080        assert!((t - 4.0).abs() < 1e-6, "toi should be 4, got {t}");
1081        assert!(!is_lateral);
1082        assert!((n[1] + 1.0).abs() < 1e-10);
1083    }
1084
1085    #[test]
1086    fn test_cone_plane_intersection_horizontal_through_base() {
1087        let c = Cone::new(1.0, 1.0);
1088        // Horizontal plane at y = -1 (exactly the base) – normal is (0,1,0), d=-1
1089        let hit = c.intersects_plane([0.0, 1.0, 0.0], -1.0);
1090        assert!(hit, "plane through base should intersect cone");
1091    }
1092
1093    #[test]
1094    fn test_cone_plane_intersection_above_apex() {
1095        let c = Cone::new(1.0, 1.0);
1096        // Plane far above apex: normal (0,1,0), d=5 → all cone points have y·1 < 5
1097        let hit = c.intersects_plane([0.0, 1.0, 0.0], 5.0);
1098        assert!(!hit, "plane far above should not intersect cone");
1099    }
1100
1101    #[test]
1102    fn test_cone_frustum_from_cone() {
1103        let c = Cone::new(2.0, 2.0); // apex y=2, base y=-2, r=2, h=4
1104        // Cut from y=-1 to y=1
1105        let (r_bot, r_top, hh) = c.frustum_from_cone(-1.0, 1.0);
1106        // r_at(-1) = 2*(2-(-1))/4 = 2*3/4 = 1.5
1107        assert!((r_bot - 1.5).abs() < 1e-10, "r_bot={r_bot}");
1108        // r_at(1) = 2*(2-1)/4 = 0.5
1109        assert!((r_top - 0.5).abs() < 1e-10, "r_top={r_top}");
1110        assert!((hh - 1.0).abs() < 1e-10, "hh={hh}");
1111    }
1112
1113    #[test]
1114    fn test_cone_sdf_matches_signed_distance() {
1115        let c = Cone::new(1.0, 1.0);
1116        let p = [3.0, 0.0, 0.0];
1117        assert!((c.sdf(p) - c.signed_distance(p)).abs() < 1e-12);
1118    }
1119
1120    #[test]
1121    fn test_cone_intersects_sphere_overlap() {
1122        let c = Cone::new(1.0, 1.0);
1123        // Large sphere centered at origin should overlap the cone
1124        assert!(c.intersects_sphere([0.0, 0.0, 0.0], 5.0));
1125    }
1126
1127    #[test]
1128    fn test_cone_intersects_sphere_no_overlap() {
1129        let c = Cone::new(1.0, 1.0);
1130        // Small sphere far away
1131        assert!(!c.intersects_sphere([10.0, 0.0, 0.0], 0.1));
1132    }
1133
1134    #[test]
1135    fn test_cone_frustum_lateral_area_full_cone() {
1136        let c = Cone::new(1.0, 1.0); // half_height=1, radius=1
1137        // Full lateral area as frustum from -1 to 1 (base to apex)
1138        let area = c.frustum_lateral_area(-1.0, 1.0);
1139        let expected = c.lateral_surface_area();
1140        // The frustum from base to apex should match full lateral area
1141        assert!(
1142            (area - expected).abs() < 1e-9,
1143            "frustum area {area} vs lateral {expected}"
1144        );
1145    }
1146
1147    #[test]
1148    fn test_cone_random_lateral_surface_point_on_surface() {
1149        let c = Cone::new(1.0, 2.0);
1150        for seed in [1u64, 42, 99, 1337, 65535] {
1151            let p = c.random_lateral_surface_point(seed);
1152            // Point should be within cone Y range
1153            assert!(
1154                p[1] >= -c.half_height - 1e-9 && p[1] <= c.half_height + 1e-9,
1155                "y={} out of range",
1156                p[1]
1157            );
1158            // XZ radius should match cone radius at that Y
1159            let xz = (p[0] * p[0] + p[2] * p[2]).sqrt();
1160            let r_at = c.radius_at_y(p[1]);
1161            assert!((xz - r_at).abs() < 1e-9, "xz={xz}, r_at={r_at}");
1162        }
1163    }
1164
1165    #[test]
1166    fn test_cone_ray_analytic_miss() {
1167        let c = Cone::new(1.0, 1.0);
1168        // Ray going away from cone
1169        let result = c.ray_cone_analytic([0.0, 10.0, 0.0], [0.0, 1.0, 0.0], 5.0);
1170        assert!(result.is_none(), "ray going away should miss");
1171    }
1172
1173    // ── Extended tests for new methods ──────────────────────────────────────
1174
1175    #[test]
1176    fn test_cone_apex_angle() {
1177        let c = Cone::new(1.0, 1.0); // r=1, h=2
1178        // apex angle = 2 * atan(1/2)
1179        let expected = 2.0 * (0.5_f64).atan();
1180        assert!((c.apex_angle() - expected).abs() < 1e-10);
1181    }
1182
1183    #[test]
1184    fn test_cone_apex_angle_90_degrees() {
1185        // r=h → half_angle = 45° → apex_angle = 90°
1186        let c = Cone::new(1.0, 0.5); // h = 1.0
1187        // half_angle = atan(1/1) = π/4 → apex = π/2
1188        let expected = PI / 2.0;
1189        assert!((c.apex_angle() - expected).abs() < 1e-10);
1190    }
1191
1192    #[test]
1193    fn test_cone_double_cone_params() {
1194        let c = Cone::new(1.5, 2.0);
1195        let (r, hh) = c.double_cone_params();
1196        assert!((r - 1.5).abs() < 1e-12);
1197        assert!((hh - 2.0).abs() < 1e-12);
1198    }
1199
1200    #[test]
1201    fn test_cone_contains_point_double_cone_at_origin() {
1202        // Double cone: at y=0 the radius is 0, so origin is on the waist
1203        let c = Cone::new(1.0, 1.0);
1204        assert!(c.contains_point_double_cone([0.0, 0.0, 0.0]));
1205    }
1206
1207    #[test]
1208    fn test_cone_contains_point_double_cone_above_apex() {
1209        let c = Cone::new(1.0, 1.0);
1210        assert!(!c.contains_point_double_cone([0.0, 1.5, 0.0]));
1211    }
1212
1213    #[test]
1214    fn test_cone_contains_point_double_cone_on_surface() {
1215        let c = Cone::new(1.0, 1.0);
1216        // At |y| = half_height, r = radius
1217        assert!(c.contains_point_double_cone([1.0, 1.0, 0.0]));
1218    }
1219
1220    #[test]
1221    fn test_cone_unfold_lateral_origin() {
1222        let c = Cone::new(1.0, 1.0);
1223        // t_slant=0 (apex) should map to origin
1224        let [x, y] = c.unfold_lateral(0.0, 0.0);
1225        assert!(x.abs() < 1e-12);
1226        assert!(y.abs() < 1e-12);
1227    }
1228
1229    #[test]
1230    fn test_cone_unfold_lateral_nonzero() {
1231        let c = Cone::new(1.0, 1.0);
1232        let [x, y] = c.unfold_lateral(0.0, 1.0);
1233        let len = (x * x + y * y).sqrt();
1234        assert!((len - 1.0).abs() < 1e-10, "len={len}");
1235    }
1236
1237    #[test]
1238    fn test_cone_bounding_cone_of_points_empty() {
1239        let (ha, _) = Cone::bounding_cone_of_points(&[]);
1240        assert_eq!(ha, 0.0);
1241    }
1242
1243    #[test]
1244    fn test_cone_bounding_cone_of_points_single() {
1245        let pts = [[1.0, 1.0, 0.0]];
1246        let (ha, _) = Cone::bounding_cone_of_points(&pts);
1247        let expected = PI / 4.0; // 45° from +Y for (1,1,0) normalized
1248        assert!((ha - expected).abs() < 1e-9, "ha={ha} expected={expected}");
1249    }
1250
1251    #[test]
1252    fn test_cone_bounding_cone_contains_all_points() {
1253        let pts = [[0.1, 1.0, 0.0], [0.0, 1.0, 0.1], [0.0, 1.0, -0.1]];
1254        let (max_ha, _) = Cone::bounding_cone_of_points(&pts);
1255        // All points within the bounding cone
1256        for &p in &pts {
1257            let len = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
1258            let cos_a = (p[1] / len).clamp(-1.0, 1.0);
1259            let ha = cos_a.acos();
1260            assert!(ha <= max_ha + 1e-12, "ha={ha} > max={max_ha}");
1261        }
1262    }
1263
1264    #[test]
1265    fn test_cone_volume_swept_zero_delta() {
1266        let c = Cone::new(1.0, 1.0);
1267        let swept = c.volume_swept([0.0, 0.0, 0.0]);
1268        assert!((swept - c.volume()).abs() < 1e-10);
1269    }
1270
1271    #[test]
1272    fn test_cone_volume_swept_positive_delta() {
1273        let c = Cone::new(1.0, 1.0);
1274        let swept = c.volume_swept([1.0, 0.0, 0.0]);
1275        assert!(swept > c.volume());
1276        // Extra: base_area * 1 = π
1277        assert!((swept - c.volume() - PI).abs() < 1e-10);
1278    }
1279
1280    #[test]
1281    fn test_cone_sdf_gradient_outside_outward() {
1282        let c = Cone::new(1.0, 1.0);
1283        let g = c.sdf_gradient([5.0, 0.0, 0.0]);
1284        // Should point away from cone
1285        assert!(g[0] > 0.0, "gx={}", g[0]);
1286    }
1287
1288    #[test]
1289    fn test_cone_sdf_gradient_unit_length() {
1290        let c = Cone::new(1.0, 1.0);
1291        let g = c.sdf_gradient([3.0, 0.0, 0.0]);
1292        let len = (g[0] * g[0] + g[1] * g[1] + g[2] * g[2]).sqrt();
1293        assert!((len - 1.0).abs() < 1e-6, "gradient not unit: len={len}");
1294    }
1295
1296    #[test]
1297    fn test_cone_ray_intersection_count_lateral() {
1298        let c = Cone::new(1.0, 2.0);
1299        // Ray along X at y=0: should hit lateral surface once on entry
1300        let count = c.ray_intersection_count([-5.0, 0.0, 0.0], [1.0, 0.0, 0.0], 100.0);
1301        assert!(count >= 1, "expected >=1 hit, got {count}");
1302    }
1303
1304    #[test]
1305    fn test_cone_ray_intersection_count_miss() {
1306        let c = Cone::new(1.0, 1.0);
1307        let count = c.ray_intersection_count([0.0, 10.0, 0.0], [0.0, 1.0, 0.0], 5.0);
1308        assert_eq!(count, 0);
1309    }
1310
1311    #[test]
1312    fn test_cone_solid_angle_full_sphere() {
1313        // half_angle ≈ π → solid_angle ≈ 4π (full sphere)
1314        // half_angle = π/2 → solid_angle = 2π (hemisphere)
1315        let c = Cone::new(1.0, 0.5); // h=1, r=1 → half_angle = atan(1) = π/4
1316        let sa = c.solid_angle();
1317        let expected = 2.0 * PI * (1.0 - (PI / 4.0).cos());
1318        assert!((sa - expected).abs() < 1e-10);
1319    }
1320
1321    #[test]
1322    fn test_cone_solid_angle_positive() {
1323        let c = Cone::new(1.0, 1.0);
1324        assert!(c.solid_angle() > 0.0);
1325    }
1326
1327    #[test]
1328    fn test_cone_base_rim_points_count() {
1329        let c = Cone::new(1.0, 2.0);
1330        let pts = c.base_rim_points(12);
1331        assert_eq!(pts.len(), 12);
1332    }
1333
1334    #[test]
1335    fn test_cone_base_rim_points_on_base() {
1336        let c = Cone::new(1.5, 2.0);
1337        let pts = c.base_rim_points(16);
1338        for p in &pts {
1339            assert!((p[1] + c.half_height).abs() < 1e-12);
1340            let xz = (p[0] * p[0] + p[2] * p[2]).sqrt();
1341            assert!((xz - c.radius).abs() < 1e-9);
1342        }
1343    }
1344
1345    #[test]
1346    fn test_cone_sphere_inside_cone_yes() {
1347        let c = Cone::new(2.0, 2.0);
1348        // Small sphere deep inside the base
1349        assert!(c.sphere_inside_cone([0.0, -1.9, 0.0], 0.05));
1350    }
1351
1352    #[test]
1353    fn test_cone_sphere_inside_cone_no() {
1354        let c = Cone::new(1.0, 1.0);
1355        // Large sphere centered outside
1356        assert!(!c.sphere_inside_cone([5.0, 0.0, 0.0], 1.0));
1357    }
1358
1359    #[test]
1360    fn test_cone_unfold_lateral_angle_scaling() {
1361        let c = Cone::new(1.0, 1.0);
1362        // Two points at same slant height but different u should differ in angle
1363        let p1 = c.unfold_lateral(0.0, 2.0);
1364        let p2 = c.unfold_lateral(PI / 2.0, 2.0);
1365        let d = ((p1[0] - p2[0]).powi(2) + (p1[1] - p2[1]).powi(2)).sqrt();
1366        assert!(d > 0.1, "unrolled points should differ");
1367    }
1368
1369    #[test]
1370    fn test_cone_double_cone_waist_radius_zero() {
1371        let c = Cone::new(2.0, 1.0);
1372        // At y=0, double cone has zero radius (at the center)
1373        // contains_point_double_cone([0,0,0]) should be true (on axis)
1374        assert!(c.contains_point_double_cone([0.0, 0.0, 0.0]));
1375    }
1376}