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