Skip to main content

oxiphysics_geometry/
cylinder.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cylinder shape (Y-axis aligned).
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 cylinder defined by radius and half-height along the Y axis.
12#[derive(Debug, Clone)]
13pub struct Cylinder {
14    /// Radius of the cylinder.
15    pub radius: Real,
16    /// Half-height along the Y axis.
17    pub half_height: Real,
18}
19
20impl Cylinder {
21    /// Create a new cylinder.
22    pub fn new(radius: Real, half_height: Real) -> Self {
23        Self {
24            radius,
25            half_height,
26        }
27    }
28
29    /// Surface area: 2πr² + 2πrh (two caps + lateral surface).
30    #[allow(dead_code)]
31    pub fn surface_area(&self) -> Real {
32        let h = 2.0 * self.half_height;
33        2.0 * PI * self.radius * self.radius + 2.0 * PI * self.radius * h
34    }
35
36    /// Volume: πr²h (full height = 2 * half_height).
37    #[allow(dead_code)]
38    pub fn volume_explicit(&self) -> Real {
39        PI * self.radius * self.radius * 2.0 * self.half_height
40    }
41
42    /// Inertia tensor as \[\[f64;3\\];3] row-major.
43    /// Ixx = Izz = m(3r²+h²)/12, Iyy = mr²/2 (Y is symmetry axis).
44    #[allow(dead_code)]
45    pub fn inertia_tensor_array(&self, mass: f64) -> [[f64; 3]; 3] {
46        let r2 = self.radius * self.radius;
47        let h = 2.0 * self.half_height;
48        let h2 = h * h;
49        let iy = 0.5 * mass * r2;
50        let ixz = mass * (3.0 * r2 + h2) / 12.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    /// Returns `None` if no intersection within `max_toi`.
56    #[allow(dead_code)]
57    pub fn ray_cast_array(
58        &self,
59        origin: [f64; 3],
60        direction: [f64; 3],
61        max_toi: f64,
62    ) -> Option<(f64, [f64; 3])> {
63        let o = Vec3::new(origin[0], origin[1], origin[2]);
64        let d = Vec3::new(direction[0], direction[1], direction[2]);
65        let hit = self.ray_cast(&o, &d, max_toi)?;
66        Some((hit.toi, [hit.normal.x, hit.normal.y, hit.normal.z]))
67    }
68
69    /// Closest point on (or inside) the cylinder to point `p`.
70    #[allow(dead_code)]
71    pub fn closest_point(&self, p: [f64; 3]) -> [f64; 3] {
72        let px = p[0];
73        let py = p[1];
74        let pz = p[2];
75
76        // Clamp Y to cylinder height range
77        let cy = py.clamp(-self.half_height, self.half_height);
78
79        // Project onto XZ and clamp to disk radius
80        let xz_len = (px * px + pz * pz).sqrt();
81        let (cx, cz) = if xz_len <= self.radius {
82            (px, pz)
83        } else {
84            let s = self.radius / xz_len;
85            (px * s, pz * s)
86        };
87
88        // Now find whether the closest point is on the cap or side
89        let inside_xz = xz_len <= self.radius;
90        let inside_y = py.abs() <= self.half_height;
91
92        if inside_xz && inside_y {
93            // Point is inside — find nearest surface
94            let dist_to_top = self.half_height - py;
95            let dist_to_bot = py + self.half_height;
96            let dist_to_side = self.radius - xz_len;
97            let min_d = dist_to_top.min(dist_to_bot).min(dist_to_side);
98
99            if min_d == dist_to_side {
100                let s = self.radius / xz_len.max(1e-30);
101                [px * s, py, pz * s]
102            } else if dist_to_top <= dist_to_bot {
103                [px, self.half_height, pz]
104            } else {
105                [px, -self.half_height, pz]
106            }
107        } else {
108            [cx, cy, cz]
109        }
110    }
111
112    /// Returns true if point `p` is strictly inside the cylinder.
113    #[allow(dead_code)]
114    pub fn contains_point(&self, p: [f64; 3]) -> bool {
115        let xz2 = p[0] * p[0] + p[2] * p[2];
116        xz2 <= self.radius * self.radius && p[1].abs() <= self.half_height
117    }
118
119    // ── New methods ──
120
121    /// GJK support function using plain arrays.
122    /// Returns the farthest point on the cylinder in the given direction.
123    #[allow(dead_code)]
124    pub fn support(&self, direction: [f64; 3]) -> [f64; 3] {
125        let xz_len = (direction[0] * direction[0] + direction[2] * direction[2]).sqrt();
126        let (sx, sz) = if xz_len > 1e-10 {
127            (
128                self.radius * direction[0] / xz_len,
129                self.radius * direction[2] / xz_len,
130            )
131        } else {
132            (self.radius, 0.0)
133        };
134        let sy = self.half_height.copysign(direction[1]);
135        [sx, sy, sz]
136    }
137
138    /// Signed distance from a point to the cylinder surface.
139    /// Negative if inside, positive if outside.
140    #[allow(dead_code)]
141    pub fn signed_distance(&self, p: [f64; 3]) -> f64 {
142        let xz_len = (p[0] * p[0] + p[2] * p[2]).sqrt();
143        let dist_side = xz_len - self.radius;
144        let dist_cap = p[1].abs() - self.half_height;
145
146        if dist_side <= 0.0 && dist_cap <= 0.0 {
147            // Inside: return the larger (less negative) distance
148            dist_side.max(dist_cap)
149        } else if dist_side > 0.0 && dist_cap > 0.0 {
150            // Outside corner: Euclidean distance to edge
151            (dist_side * dist_side + dist_cap * dist_cap).sqrt()
152        } else {
153            // Outside one dimension, inside the other
154            dist_side.max(dist_cap)
155        }
156    }
157
158    /// Closest point on the cylinder to a line segment from `a` to `b`.
159    /// Returns the closest point on the cylinder surface.
160    #[allow(dead_code)]
161    pub fn closest_point_to_segment(&self, a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
162        // Sample several points on the segment and find the one whose
163        // closest-point on cylinder is nearest.
164        let n_samples = 16;
165        let mut best_dist_sq = f64::INFINITY;
166        let mut best_cp = [0.0; 3];
167
168        for i in 0..=n_samples {
169            let t = i as f64 / n_samples as f64;
170            let seg_pt = [
171                a[0] + t * (b[0] - a[0]),
172                a[1] + t * (b[1] - a[1]),
173                a[2] + t * (b[2] - a[2]),
174            ];
175            let cp = self.closest_point(seg_pt);
176            let dx = cp[0] - seg_pt[0];
177            let dy = cp[1] - seg_pt[1];
178            let dz = cp[2] - seg_pt[2];
179            let d2 = dx * dx + dy * dy + dz * dz;
180            if d2 < best_dist_sq {
181                best_dist_sq = d2;
182                best_cp = cp;
183            }
184        }
185        best_cp
186    }
187
188    /// Ray cast against only the top cap (y = +half_height).
189    /// Returns `Some((t, normal))` if hit.
190    #[allow(dead_code)]
191    pub fn ray_cast_top_cap(
192        &self,
193        origin: [f64; 3],
194        direction: [f64; 3],
195        max_toi: f64,
196    ) -> Option<(f64, [f64; 3])> {
197        if direction[1].abs() < 1e-12 {
198            return None;
199        }
200        let t = (self.half_height - origin[1]) / direction[1];
201        if t < 0.0 || t > max_toi {
202            return None;
203        }
204        let px = origin[0] + t * direction[0];
205        let pz = origin[2] + t * direction[2];
206        if px * px + pz * pz <= self.radius * self.radius {
207            Some((t, [0.0, 1.0, 0.0]))
208        } else {
209            None
210        }
211    }
212
213    /// Ray cast against only the bottom cap (y = -half_height).
214    /// Returns `Some((t, normal))` if hit.
215    #[allow(dead_code)]
216    pub fn ray_cast_bottom_cap(
217        &self,
218        origin: [f64; 3],
219        direction: [f64; 3],
220        max_toi: f64,
221    ) -> Option<(f64, [f64; 3])> {
222        if direction[1].abs() < 1e-12 {
223            return None;
224        }
225        let t = (-self.half_height - origin[1]) / direction[1];
226        if t < 0.0 || t > max_toi {
227            return None;
228        }
229        let px = origin[0] + t * direction[0];
230        let pz = origin[2] + t * direction[2];
231        if px * px + pz * pz <= self.radius * self.radius {
232            Some((t, [0.0, -1.0, 0.0]))
233        } else {
234            None
235        }
236    }
237
238    /// Ray cast against only the lateral (curved) surface.
239    /// Returns `Some((t, normal))` if hit.
240    #[allow(dead_code)]
241    pub fn ray_cast_lateral(
242        &self,
243        origin: [f64; 3],
244        direction: [f64; 3],
245        max_toi: f64,
246    ) -> Option<(f64, [f64; 3])> {
247        let a = direction[0] * direction[0] + direction[2] * direction[2];
248        if a < 1e-12 {
249            return None;
250        }
251        let b = 2.0 * (origin[0] * direction[0] + origin[2] * direction[2]);
252        let c = origin[0] * origin[0] + origin[2] * origin[2] - self.radius * self.radius;
253        let disc = b * b - 4.0 * a * c;
254        if disc < 0.0 {
255            return None;
256        }
257        let sqrt_disc = disc.sqrt();
258        for sign in [-1.0, 1.0] {
259            let t = (-b + sign * sqrt_disc) / (2.0 * a);
260            if t >= 0.0 && t <= max_toi {
261                let py = origin[1] + t * direction[1];
262                if py.abs() <= self.half_height {
263                    let px = origin[0] + t * direction[0];
264                    let pz = origin[2] + t * direction[2];
265                    let xz_len = (px * px + pz * pz).sqrt();
266                    let nx = if xz_len > 1e-12 { px / xz_len } else { 1.0 };
267                    let nz = if xz_len > 1e-12 { pz / xz_len } else { 0.0 };
268                    return Some((t, [nx, 0.0, nz]));
269                }
270            }
271        }
272        None
273    }
274
275    /// Compute the lateral surface area (excluding caps).
276    #[allow(dead_code)]
277    pub fn lateral_surface_area(&self) -> f64 {
278        2.0 * PI * self.radius * 2.0 * self.half_height
279    }
280
281    /// Compute the cap area (one cap).
282    #[allow(dead_code)]
283    pub fn cap_area(&self) -> f64 {
284        PI * self.radius * self.radius
285    }
286
287    /// Project a point onto the cylinder axis (the Y-axis segment).
288    /// Returns the clamped Y coordinate.
289    #[allow(dead_code)]
290    pub fn project_on_axis(&self, p: [f64; 3]) -> f64 {
291        p[1].clamp(-self.half_height, self.half_height)
292    }
293
294    /// Distance from a point to the cylinder axis (the Y axis segment).
295    #[allow(dead_code)]
296    pub fn distance_to_axis(&self, p: [f64; 3]) -> f64 {
297        let clamped_y = p[1].clamp(-self.half_height, self.half_height);
298        let dy = p[1] - clamped_y;
299        let xz2 = p[0] * p[0] + p[2] * p[2];
300        (xz2 + dy * dy).sqrt()
301    }
302
303    // ── New expanded methods ──
304
305    /// Cylinder vs infinite plane intersection test.
306    ///
307    /// Plane: `dot(plane_normal, x) = plane_d`.
308    /// Returns `true` if any part of the finite cylinder intersects the plane.
309    #[allow(dead_code)]
310    pub fn intersects_plane(&self, plane_normal: [f64; 3], plane_d: f64) -> bool {
311        // The most positive and negative signed distances are attained at the
312        // four extreme points: (±r_proj, ±hh, 0) projected onto the plane normal.
313        // XZ projection contribution: r * sqrt(nx² + nz²)
314        let xz_proj =
315            (plane_normal[0] * plane_normal[0] + plane_normal[2] * plane_normal[2]).sqrt();
316        let y_contrib_top = plane_normal[1] * self.half_height;
317        let y_contrib_bot = -plane_normal[1] * self.half_height;
318        let r_contrib = self.radius * xz_proj;
319
320        let max_sd = y_contrib_top.max(y_contrib_bot) + r_contrib;
321        let min_sd = y_contrib_top.min(y_contrib_bot) - r_contrib;
322
323        min_sd <= plane_d && plane_d <= max_sd
324    }
325
326    /// Cylinder SDF (Signed Distance Function).
327    ///
328    /// Identical to `signed_distance` but exposed under the SDF naming convention.
329    #[allow(dead_code)]
330    pub fn sdf(&self, p: [f64; 3]) -> f64 {
331        self.signed_distance(p)
332    }
333
334    /// Cylinder-sphere intersection test.
335    ///
336    /// Returns `true` if the sphere (center + radius) overlaps the finite cylinder.
337    #[allow(dead_code)]
338    pub fn intersects_sphere(&self, sphere_center: [f64; 3], sphere_radius: f64) -> bool {
339        let cp = self.closest_point(sphere_center);
340        let dx = sphere_center[0] - cp[0];
341        let dy = sphere_center[1] - cp[1];
342        let dz = sphere_center[2] - cp[2];
343        dx * dx + dy * dy + dz * dz <= sphere_radius * sphere_radius
344    }
345
346    /// Test a finite cylinder against an infinite cylinder (same Y axis, different radius).
347    ///
348    /// An infinite cylinder has the same Y axis but extends infinitely in ±Y.
349    /// They intersect if the radii overlap (i.e., `|r_finite - r_infinite| ≤ lateral dist`).
350    /// Since both are Y-aligned and centered at origin, this reduces to a 2-D circle test.
351    #[allow(dead_code)]
352    pub fn intersects_infinite_cylinder(&self, infinite_radius: f64, center_xz: [f64; 2]) -> bool {
353        // Distance between the two Y-axis lines in XZ
354        let dist = (center_xz[0] * center_xz[0] + center_xz[1] * center_xz[1]).sqrt();
355        // Overlap when distance < sum of radii
356        dist < self.radius + infinite_radius
357    }
358
359    /// Generate `n` uniformly distributed random points on the cylinder surface
360    /// using a deterministic xorshift64 PRNG.
361    #[allow(dead_code)]
362    pub fn random_surface_points(&self, n: usize, seed: u64) -> Vec<[f64; 3]> {
363        let mut points = Vec::with_capacity(n);
364        let lat = self.lateral_surface_area();
365        let cap = self.cap_area();
366        let total = lat + 2.0 * cap;
367        let p_lat = lat / total;
368        let p_top = cap / total;
369
370        let mut state = seed;
371        let next = |s: &mut u64| -> f64 {
372            *s ^= *s << 13;
373            *s ^= *s >> 7;
374            *s ^= *s << 17;
375            (*s as f64) / (u64::MAX as f64)
376        };
377
378        while points.len() < n {
379            let u = next(&mut state);
380            let v = next(&mut state);
381            let w = next(&mut state);
382
383            if u < p_lat {
384                let theta = v * 2.0 * PI;
385                let y = (w * 2.0 - 1.0) * self.half_height;
386                points.push([self.radius * theta.cos(), y, self.radius * theta.sin()]);
387            } else if u < p_lat + p_top {
388                // Top cap: uniform disk
389                let r = self.radius * v.sqrt();
390                let theta = w * 2.0 * PI;
391                points.push([r * theta.cos(), self.half_height, r * theta.sin()]);
392            } else {
393                // Bottom cap
394                let r = self.radius * v.sqrt();
395                let theta = w * 2.0 * PI;
396                points.push([r * theta.cos(), -self.half_height, r * theta.sin()]);
397            }
398        }
399        points
400    }
401
402    /// Cylinder support function (plain array, same as `support` but for trait-like usage).
403    #[allow(dead_code)]
404    pub fn support_array(&self, direction: [f64; 3]) -> [f64; 3] {
405        self.support(direction)
406    }
407
408    /// Full height of the cylinder (2 × half_height).
409    #[allow(dead_code)]
410    pub fn full_height(&self) -> f64 {
411        2.0 * self.half_height
412    }
413
414    /// Radius of gyration about the Y (symmetry) axis.
415    ///
416    /// k = sqrt(Iy / m) = sqrt(r²/2) = r / sqrt(2).
417    #[allow(dead_code)]
418    pub fn radius_of_gyration_y(&self) -> f64 {
419        self.radius / 2.0_f64.sqrt()
420    }
421
422    /// Radius of gyration about the X (transverse) axis.
423    ///
424    /// k = sqrt(Ix / m) = sqrt((3r² + h²) / 12).
425    #[allow(dead_code)]
426    pub fn radius_of_gyration_x(&self, _mass: f64) -> f64 {
427        let r2 = self.radius * self.radius;
428        let h = 2.0 * self.half_height;
429        let h2 = h * h;
430        ((3.0 * r2 + h2) / 12.0).sqrt()
431    }
432
433    // ── Extended geometry: hollow, frustum, oblique, ruled surface, UV cap ──
434
435    /// Volume of the swept region when the cylinder moves by translation `delta`.
436    ///
437    /// For a cylinder swept along a vector `delta` (not along its own axis),
438    /// this returns the approximate swept volume: V_cyl + A_cap * |delta|.
439    #[allow(dead_code)]
440    pub fn volume_swept(&self, delta: [f64; 3]) -> f64 {
441        let dist = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
442        self.volume() + self.cap_area() * dist
443    }
444
445    /// UV mapping for the cylinder cap at `+half_height`.
446    ///
447    /// Maps a 3-D point `p` on the cap disk to UV in `[0, 1]²`.
448    /// Uses a radial projection: `u = 0.5 + x/(2r)`, `v = 0.5 + z/(2r)`.
449    #[allow(dead_code)]
450    pub fn cap_uv(&self, p: [f64; 3]) -> [f64; 2] {
451        let u = 0.5 + p[0] / (2.0 * self.radius);
452        let v = 0.5 + p[2] / (2.0 * self.radius);
453        [u.clamp(0.0, 1.0), v.clamp(0.0, 1.0)]
454    }
455
456    /// UV mapping for the lateral (rolled-out) surface.
457    ///
458    /// `u` is the normalised circumferential angle (azimuth), `v` is the
459    /// normalised height. Both in `[0, 1]`.
460    #[allow(dead_code)]
461    pub fn lateral_uv(&self, p: [f64; 3]) -> [f64; 2] {
462        let theta = p[2].atan2(p[0]); // in (-π, π]
463        let u = ((theta / (2.0 * PI)) + 1.0) % 1.0;
464        let v = (p[1] + self.half_height) / (2.0 * self.half_height);
465        [u, v.clamp(0.0, 1.0)]
466    }
467
468    /// Hollow cylinder check: ring-shaped cross-section.
469    ///
470    /// Returns `true` if `p` is inside the hollow cylinder with inner radius
471    /// `inner_r` (a tube with outer radius = `self.radius`).
472    #[allow(dead_code)]
473    pub fn contains_point_hollow(&self, p: [f64; 3], inner_r: f64) -> bool {
474        let xz2 = p[0] * p[0] + p[2] * p[2];
475        let r_inner = inner_r.max(0.0).min(self.radius);
476        xz2 <= self.radius * self.radius
477            && xz2 >= r_inner * r_inner
478            && p[1].abs() <= self.half_height
479    }
480
481    /// Volume of the hollow cylinder (annular cylinder).
482    ///
483    /// Volume = π(R² - r²) * h where R = outer, r = inner, h = full height.
484    #[allow(dead_code)]
485    pub fn volume_hollow(&self, inner_radius: f64) -> f64 {
486        let r = inner_radius.max(0.0).min(self.radius);
487        PI * (self.radius * self.radius - r * r) * 2.0 * self.half_height
488    }
489
490    /// Frustum (truncated cone) slant height given top and bottom radii.
491    ///
492    /// `r_top` and `r_bottom` are radii at `+half_height` and `-half_height`.
493    /// Returns the slant height of the frustum.
494    #[allow(dead_code)]
495    pub fn frustum_slant_height(&self, r_top: f64, r_bottom: f64) -> f64 {
496        let h = 2.0 * self.half_height;
497        let dr = (r_top - r_bottom).abs();
498        (h * h + dr * dr).sqrt()
499    }
500
501    /// Lateral area of a frustum with given top and bottom radii.
502    ///
503    /// Lateral area = π * (r_top + r_bottom) * slant_height.
504    #[allow(dead_code)]
505    pub fn frustum_lateral_area(&self, r_top: f64, r_bottom: f64) -> f64 {
506        let slant = self.frustum_slant_height(r_top, r_bottom);
507        PI * (r_top + r_bottom) * slant
508    }
509
510    /// Volume of a frustum with given top and bottom radii.
511    ///
512    /// V = π h (r_top² + r_top*r_bottom + r_bottom²) / 3.
513    #[allow(dead_code)]
514    pub fn frustum_volume(&self, r_top: f64, r_bottom: f64) -> f64 {
515        let h = 2.0 * self.half_height;
516        PI * h * (r_top * r_top + r_top * r_bottom + r_bottom * r_bottom) / 3.0
517    }
518
519    /// Oblique cylinder end-cap area at a given tilt.
520    ///
521    /// When the top cap is tilted at `tilt_angle` radians from horizontal,
522    /// the effective cap area becomes `π r² / cos(tilt_angle)`.
523    #[allow(dead_code)]
524    pub fn oblique_cap_area(&self, tilt_angle: f64) -> f64 {
525        let cos_a = tilt_angle.cos().abs().max(1e-12);
526        PI * self.radius * self.radius / cos_a
527    }
528
529    /// Ruled surface interpolation: return a point on the ruled surface
530    /// between two guide curves (bottom circle at `-half_height` and top
531    /// circle at `+half_height`).
532    ///
533    /// `u ∈ [0, 2π)` is the azimuth, `t ∈ [0, 1\]` is the height parameter.
534    /// For a straight cylinder both circles have the same radius, so the
535    /// ruled surface is just the lateral surface.
536    #[allow(dead_code)]
537    pub fn ruled_surface_point(&self, u: f64, t: f64) -> [f64; 3] {
538        let y = -self.half_height + t * 2.0 * self.half_height;
539        [self.radius * u.cos(), y, self.radius * u.sin()]
540    }
541
542    /// Stack two cylinders and return the combined volume.
543    ///
544    /// If the second cylinder has the same radius but different height it is
545    /// stacked end-to-end on top of this cylinder.  Returns the sum of their
546    /// volumes.
547    #[allow(dead_code)]
548    pub fn stacked_volume(&self, other: &Cylinder) -> f64 {
549        self.volume() + other.volume()
550    }
551
552    /// Stack two cylinders and return the combined bounding half-height.
553    ///
554    /// Returns the total half-height of the axis-aligned bounding box of the
555    /// two cylinders stacked along the Y axis.
556    #[allow(dead_code)]
557    pub fn stacked_half_height(&self, other: &Cylinder) -> f64 {
558        self.half_height + other.half_height
559    }
560
561    /// Combined bounding radius when two cylinders are stacked.
562    ///
563    /// Returns the maximum of the two radii.
564    #[allow(dead_code)]
565    pub fn stacked_bounding_radius(&self, other: &Cylinder) -> f64 {
566        self.radius.max(other.radius)
567    }
568
569    /// Surface normal at a lateral point `p` (unit outward normal in XZ).
570    ///
571    /// Ignores the Y component; suitable for points on the curved surface.
572    #[allow(dead_code)]
573    pub fn lateral_normal_at(&self, p: [f64; 3]) -> [f64; 3] {
574        let len = (p[0] * p[0] + p[2] * p[2]).sqrt();
575        if len < 1e-12 {
576            return [1.0, 0.0, 0.0];
577        }
578        [p[0] / len, 0.0, p[2] / len]
579    }
580
581    /// Cylinder SDF gradient (approximate unit normal direction).
582    ///
583    /// Returns the gradient of the SDF at `p`, i.e. the direction of
584    /// fastest increase.
585    #[allow(dead_code)]
586    pub fn sdf_gradient(&self, p: [f64; 3]) -> [f64; 3] {
587        let eps = 1e-5;
588        let sdf0 = self.sdf(p);
589        let gx = (self.sdf([p[0] + eps, p[1], p[2]]) - sdf0) / eps;
590        let gy = (self.sdf([p[0], p[1] + eps, p[2]]) - sdf0) / eps;
591        let gz = (self.sdf([p[0], p[1], p[2] + eps]) - sdf0) / eps;
592        let len = (gx * gx + gy * gy + gz * gz).sqrt().max(1e-14);
593        [gx / len, gy / len, gz / len]
594    }
595
596    /// Count the number of times a ray intersects the infinite cylinder
597    /// (lateral surface only, ignoring caps).
598    ///
599    /// Returns 0, 1, or 2.
600    #[allow(dead_code)]
601    pub fn lateral_ray_intersection_count(&self, origin: [f64; 3], direction: [f64; 3]) -> usize {
602        let a = direction[0] * direction[0] + direction[2] * direction[2];
603        if a < 1e-14 {
604            return 0;
605        }
606        let b = 2.0 * (origin[0] * direction[0] + origin[2] * direction[2]);
607        let c = origin[0] * origin[0] + origin[2] * origin[2] - self.radius * self.radius;
608        let disc = b * b - 4.0 * a * c;
609        if disc < 0.0 {
610            return 0;
611        }
612        if disc < 1e-14 {
613            return 1;
614        }
615        2
616    }
617
618    /// Generate `n` points uniformly distributed along the top cap edge (rim).
619    ///
620    /// Returns points on the circle of radius `r` at `y = +half_height`.
621    #[allow(dead_code)]
622    pub fn top_rim_points(&self, n: usize) -> Vec<[f64; 3]> {
623        let n = n.max(2);
624        (0..n)
625            .map(|i| {
626                let t = 2.0 * PI * i as f64 / n as f64;
627                [
628                    self.radius * t.cos(),
629                    self.half_height,
630                    self.radius * t.sin(),
631                ]
632            })
633            .collect()
634    }
635}
636
637impl Shape for Cylinder {
638    fn bounding_box(&self) -> Aabb {
639        Aabb::new(
640            Vec3::new(-self.radius, -self.half_height, -self.radius),
641            Vec3::new(self.radius, self.half_height, self.radius),
642        )
643    }
644
645    fn support_point(&self, direction: &Vec3) -> Vec3 {
646        let xz_len = (direction.x * direction.x + direction.z * direction.z).sqrt();
647        let (sx, sz) = if xz_len > 1e-10 {
648            (
649                self.radius * direction.x / xz_len,
650                self.radius * direction.z / xz_len,
651            )
652        } else {
653            (self.radius, 0.0)
654        };
655        let sy = self.half_height.copysign(direction.y);
656        Vec3::new(sx, sy, sz)
657    }
658
659    fn volume(&self) -> Real {
660        PI * self.radius * self.radius * 2.0 * self.half_height
661    }
662
663    fn center_of_mass(&self) -> Vec3 {
664        Vec3::zeros()
665    }
666
667    fn inertia_tensor(&self, mass: Real) -> Mat3 {
668        let r2 = self.radius * self.radius;
669        let h2 = (2.0 * self.half_height).powi(2);
670        let iy = 0.5 * mass * r2;
671        let ixz = mass * (3.0 * r2 + h2) / 12.0;
672        Mat3::new(ixz, 0.0, 0.0, 0.0, iy, 0.0, 0.0, 0.0, ixz)
673    }
674
675    fn ray_cast(&self, ray_origin: &Vec3, ray_direction: &Vec3, max_toi: Real) -> Option<RayHit> {
676        let mut best: Option<RayHit> = None;
677
678        // Infinite cylinder in XZ
679        let a = ray_direction.x.powi(2) + ray_direction.z.powi(2);
680        let b = 2.0 * (ray_origin.x * ray_direction.x + ray_origin.z * ray_direction.z);
681        let c = ray_origin.x.powi(2) + ray_origin.z.powi(2) - self.radius.powi(2);
682
683        if a > 1e-12 {
684            let disc = b * b - 4.0 * a * c;
685            if disc >= 0.0 {
686                let sqrt_disc = disc.sqrt();
687                for sign in [-1.0, 1.0] {
688                    let t = (-b + sign * sqrt_disc) / (2.0 * a);
689                    if t >= 0.0 && t <= max_toi {
690                        let p = ray_origin + ray_direction * t;
691                        if p.y.abs() <= self.half_height {
692                            let normal = Vec3::new(p.x, 0.0, p.z).normalize();
693                            if best.as_ref().is_none_or(|prev| t < prev.toi) {
694                                best = Some(RayHit {
695                                    point: p,
696                                    normal,
697                                    toi: t,
698                                });
699                            }
700                        }
701                    }
702                }
703            }
704        }
705
706        // Top and bottom caps
707        if ray_direction.y.abs() > 1e-12 {
708            for &cap_y in &[self.half_height, -self.half_height] {
709                let t = (cap_y - ray_origin.y) / ray_direction.y;
710                if t >= 0.0 && t <= max_toi {
711                    let p = ray_origin + ray_direction * t;
712                    if p.x.powi(2) + p.z.powi(2) <= self.radius.powi(2) {
713                        let normal = Vec3::new(0.0, cap_y.signum(), 0.0);
714                        if best.as_ref().is_none_or(|prev| t < prev.toi) {
715                            best = Some(RayHit {
716                                point: p,
717                                normal,
718                                toi: t,
719                            });
720                        }
721                    }
722                }
723            }
724        }
725
726        best
727    }
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733
734    #[test]
735    fn test_cylinder_volume() {
736        let c = Cylinder::new(1.0, 1.0);
737        let expected = PI * 2.0;
738        assert!((c.volume() - expected).abs() < 1e-10);
739    }
740
741    #[test]
742    fn test_cylinder_surface_area() {
743        // r=1, h=2 (half_height=1): 2π*1 + 2π*1*2 = 2π + 4π = 6π
744        let c = Cylinder::new(1.0, 1.0);
745        let expected = 6.0 * PI;
746        assert!((c.surface_area() - expected).abs() < 1e-10);
747    }
748
749    #[test]
750    fn test_cylinder_inertia_symmetry() {
751        let c = Cylinder::new(1.5, 2.0);
752        let it = c.inertia_tensor(3.0);
753        // I_xx == I_zz (symmetry around Y axis)
754        let diff = (it[(0, 0)] - it[(2, 2)]).abs();
755        assert!(
756            diff < 1e-10,
757            "I_xx != I_zz: {} vs {}",
758            it[(0, 0)],
759            it[(2, 2)]
760        );
761        // Off-diagonal should be zero
762        assert!(it[(0, 1)].abs() < 1e-10);
763        assert!(it[(1, 0)].abs() < 1e-10);
764        assert!(it[(0, 2)].abs() < 1e-10);
765    }
766
767    #[test]
768    fn test_cylinder_inertia_array() {
769        let c = Cylinder::new(1.0, 0.5);
770        let it = c.inertia_tensor_array(2.0);
771        // Iyy = mr²/2 = 2*1/2 = 1.0
772        assert!((it[1][1] - 1.0).abs() < 1e-10);
773        // Ixx = m(3r²+h²)/12 = 2*(3+1)/12 = 8/12 = 2/3
774        let h = 1.0_f64; // full height
775        let ixx = 2.0 * (3.0 + h * h) / 12.0;
776        assert!((it[0][0] - ixx).abs() < 1e-10);
777    }
778
779    #[test]
780    fn test_cylinder_raycast() {
781        let c = Cylinder::new(1.0, 2.0);
782        let origin = Vec3::new(-5.0, 0.0, 0.0);
783        let dir = Vec3::new(1.0, 0.0, 0.0);
784        let hit = c.ray_cast(&origin, &dir, 100.0).unwrap();
785        assert!((hit.toi - 4.0).abs() < 1e-10);
786    }
787
788    #[test]
789    fn test_cylinder_raycast_array() {
790        let c = Cylinder::new(1.0, 2.0);
791        let (t, n) = c
792            .ray_cast_array([-5.0, 0.0, 0.0], [1.0, 0.0, 0.0], 100.0)
793            .unwrap();
794        assert!((t - 4.0).abs() < 1e-10);
795        assert!((n[0] + 1.0).abs() < 1e-10); // normal points -X
796    }
797
798    #[test]
799    fn test_cylinder_raycast_cap() {
800        let c = Cylinder::new(1.0, 2.0);
801        let origin = Vec3::new(0.0, 10.0, 0.0);
802        let dir = Vec3::new(0.0, -1.0, 0.0);
803        let hit = c.ray_cast(&origin, &dir, 100.0).unwrap();
804        assert!((hit.toi - 8.0).abs() < 1e-10);
805        assert!((hit.normal.y - 1.0).abs() < 1e-10);
806    }
807
808    #[test]
809    fn test_cylinder_contains_point() {
810        let c = Cylinder::new(1.0, 2.0);
811        assert!(c.contains_point([0.0, 0.0, 0.0]));
812        assert!(c.contains_point([0.9, 1.9, 0.0]));
813        assert!(!c.contains_point([0.0, 2.1, 0.0]));
814        assert!(!c.contains_point([1.1, 0.0, 0.0]));
815    }
816
817    #[test]
818    fn test_cylinder_closest_point_outside_side() {
819        let c = Cylinder::new(1.0, 2.0);
820        let cp = c.closest_point([3.0, 0.0, 0.0]);
821        assert!((cp[0] - 1.0).abs() < 1e-10);
822        assert!((cp[1]).abs() < 1e-10);
823        assert!((cp[2]).abs() < 1e-10);
824    }
825
826    #[test]
827    fn test_cylinder_closest_point_above_cap() {
828        let c = Cylinder::new(1.0, 2.0);
829        let cp = c.closest_point([0.0, 5.0, 0.0]);
830        assert!((cp[1] - 2.0).abs() < 1e-10);
831    }
832
833    // ── New tests ──
834
835    #[test]
836    fn test_cylinder_support_array() {
837        let c = Cylinder::new(2.0, 3.0);
838        let sp = c.support([1.0, 0.0, 0.0]);
839        assert!((sp[0] - 2.0).abs() < 1e-10);
840        assert!((sp[1] - 3.0).abs() < 1e-10); // positive Y for positive-ish Y (copysign(0) = +)
841    }
842
843    #[test]
844    fn test_cylinder_support_negative_y() {
845        let c = Cylinder::new(1.0, 2.0);
846        let sp = c.support([0.0, -1.0, 0.0]);
847        assert!((sp[1] + 2.0).abs() < 1e-10);
848    }
849
850    #[test]
851    fn test_cylinder_support_diagonal() {
852        let c = Cylinder::new(1.0, 2.0);
853        let sp = c.support([1.0, 1.0, 0.0]);
854        assert!((sp[0] - 1.0).abs() < 1e-10); // full radius in XZ direction
855        assert!((sp[1] - 2.0).abs() < 1e-10); // top cap
856    }
857
858    #[test]
859    fn test_cylinder_signed_distance_inside() {
860        let c = Cylinder::new(2.0, 3.0);
861        let d = c.signed_distance([0.0, 0.0, 0.0]);
862        assert!(d < 0.0); // center is inside
863        // Distance to nearest surface = min(2, 3) = 2 (radial)
864        assert!((d + 2.0).abs() < 1e-10);
865    }
866
867    #[test]
868    fn test_cylinder_signed_distance_outside_side() {
869        let c = Cylinder::new(1.0, 2.0);
870        let d = c.signed_distance([3.0, 0.0, 0.0]);
871        assert!((d - 2.0).abs() < 1e-10); // 3 - 1 = 2
872    }
873
874    #[test]
875    fn test_cylinder_signed_distance_outside_cap() {
876        let c = Cylinder::new(1.0, 2.0);
877        let d = c.signed_distance([0.0, 4.0, 0.0]);
878        assert!((d - 2.0).abs() < 1e-10); // 4 - 2 = 2
879    }
880
881    #[test]
882    fn test_cylinder_signed_distance_outside_corner() {
883        let c = Cylinder::new(1.0, 1.0);
884        // Outside both radially and vertically
885        let d = c.signed_distance([2.0, 2.0, 0.0]);
886        // dist_side=1, dist_cap=1, corner distance = sqrt(2)
887        assert!((d - 2.0_f64.sqrt()).abs() < 1e-10);
888    }
889
890    #[test]
891    fn test_cylinder_ray_cast_top_cap() {
892        let c = Cylinder::new(1.0, 2.0);
893        let result = c.ray_cast_top_cap([0.0, 5.0, 0.0], [0.0, -1.0, 0.0], 100.0);
894        assert!(result.is_some());
895        let (t, n) = result.unwrap();
896        assert!((t - 3.0).abs() < 1e-10);
897        assert!((n[1] - 1.0).abs() < 1e-10);
898    }
899
900    #[test]
901    fn test_cylinder_ray_cast_top_cap_miss() {
902        let c = Cylinder::new(1.0, 2.0);
903        // Ray misses the cap disk
904        let result = c.ray_cast_top_cap([3.0, 5.0, 0.0], [0.0, -1.0, 0.0], 100.0);
905        assert!(result.is_none());
906    }
907
908    #[test]
909    fn test_cylinder_ray_cast_bottom_cap() {
910        let c = Cylinder::new(1.0, 2.0);
911        let result = c.ray_cast_bottom_cap([0.0, -5.0, 0.0], [0.0, 1.0, 0.0], 100.0);
912        assert!(result.is_some());
913        let (t, n) = result.unwrap();
914        assert!((t - 3.0).abs() < 1e-10);
915        assert!((n[1] + 1.0).abs() < 1e-10);
916    }
917
918    #[test]
919    fn test_cylinder_ray_cast_lateral() {
920        let c = Cylinder::new(1.0, 2.0);
921        let result = c.ray_cast_lateral([-5.0, 0.0, 0.0], [1.0, 0.0, 0.0], 100.0);
922        assert!(result.is_some());
923        let (t, n) = result.unwrap();
924        assert!((t - 4.0).abs() < 1e-10);
925        assert!((n[0] + 1.0).abs() < 1e-10);
926    }
927
928    #[test]
929    fn test_cylinder_ray_cast_lateral_miss_height() {
930        let c = Cylinder::new(1.0, 1.0);
931        // Ray at y=5, which is above the cylinder
932        let result = c.ray_cast_lateral([-5.0, 5.0, 0.0], [1.0, 0.0, 0.0], 100.0);
933        assert!(result.is_none());
934    }
935
936    #[test]
937    fn test_cylinder_lateral_surface_area() {
938        let c = Cylinder::new(1.0, 1.0);
939        // 2π * r * h = 2π * 1 * 2 = 4π
940        assert!((c.lateral_surface_area() - 4.0 * PI).abs() < 1e-10);
941    }
942
943    #[test]
944    fn test_cylinder_cap_area() {
945        let c = Cylinder::new(2.0, 1.0);
946        assert!((c.cap_area() - 4.0 * PI).abs() < 1e-10);
947    }
948
949    #[test]
950    fn test_cylinder_surface_area_decomposition() {
951        let c = Cylinder::new(1.5, 2.5);
952        let total = c.surface_area();
953        let decomposed = c.lateral_surface_area() + 2.0 * c.cap_area();
954        assert!((total - decomposed).abs() < 1e-10);
955    }
956
957    #[test]
958    fn test_cylinder_project_on_axis() {
959        let c = Cylinder::new(1.0, 2.0);
960        assert!((c.project_on_axis([0.0, 5.0, 0.0]) - 2.0).abs() < 1e-10);
961        assert!((c.project_on_axis([0.0, -5.0, 0.0]) + 2.0).abs() < 1e-10);
962        assert!((c.project_on_axis([3.0, 1.0, 0.0]) - 1.0).abs() < 1e-10);
963    }
964
965    #[test]
966    fn test_cylinder_distance_to_axis() {
967        let c = Cylinder::new(1.0, 2.0);
968        // Point on axis
969        assert!(c.distance_to_axis([0.0, 0.0, 0.0]).abs() < 1e-10);
970        // Point at (3,0,0), on the midplane
971        assert!((c.distance_to_axis([3.0, 0.0, 0.0]) - 3.0).abs() < 1e-10);
972        // Point above cap, on Y axis
973        assert!((c.distance_to_axis([0.0, 5.0, 0.0]) - 3.0).abs() < 1e-10);
974    }
975
976    #[test]
977    fn test_cylinder_closest_point_to_segment() {
978        let c = Cylinder::new(1.0, 2.0);
979        // Segment far away in X
980        let cp = c.closest_point_to_segment([5.0, 0.0, 0.0], [5.0, 1.0, 0.0]);
981        assert!((cp[0] - 1.0).abs() < 1e-10);
982    }
983
984    #[test]
985    fn test_cylinder_volume_explicit_matches() {
986        let c = Cylinder::new(1.5, 2.5);
987        assert!((c.volume_explicit() - c.volume()).abs() < 1e-10);
988    }
989
990    // ── Expanded tests ──
991
992    #[test]
993    fn test_cylinder_intersects_plane_through_center() {
994        let c = Cylinder::new(1.0, 2.0);
995        // Horizontal plane at y=0: normal=(0,1,0), d=0
996        assert!(c.intersects_plane([0.0, 1.0, 0.0], 0.0));
997    }
998
999    #[test]
1000    fn test_cylinder_intersects_plane_above() {
1001        let c = Cylinder::new(1.0, 2.0);
1002        // Plane far above: normal=(0,1,0), d=10 → cylinder max y contribution = 2, so no hit
1003        assert!(!c.intersects_plane([0.0, 1.0, 0.0], 10.0));
1004    }
1005
1006    #[test]
1007    fn test_cylinder_intersects_plane_lateral() {
1008        let c = Cylinder::new(2.0, 1.0);
1009        // Vertical plane at x=1: normal=(1,0,0), d=1 → radius=2, should intersect
1010        assert!(c.intersects_plane([1.0, 0.0, 0.0], 1.0));
1011        // Plane at x=3: outside radius
1012        assert!(!c.intersects_plane([1.0, 0.0, 0.0], 3.0));
1013    }
1014
1015    #[test]
1016    fn test_cylinder_sdf_matches_signed_distance() {
1017        let c = Cylinder::new(1.0, 2.0);
1018        let p = [3.0, 0.0, 0.0];
1019        assert!((c.sdf(p) - c.signed_distance(p)).abs() < 1e-12);
1020    }
1021
1022    #[test]
1023    fn test_cylinder_intersects_sphere_yes() {
1024        let c = Cylinder::new(1.0, 2.0);
1025        assert!(c.intersects_sphere([0.0, 0.0, 0.0], 0.5));
1026        assert!(c.intersects_sphere([1.5, 0.0, 0.0], 0.6)); // slightly outside, sphere big enough
1027    }
1028
1029    #[test]
1030    fn test_cylinder_intersects_sphere_no() {
1031        let c = Cylinder::new(1.0, 2.0);
1032        assert!(!c.intersects_sphere([5.0, 0.0, 0.0], 0.5));
1033    }
1034
1035    #[test]
1036    fn test_cylinder_intersects_infinite_cylinder_overlap() {
1037        let c = Cylinder::new(1.0, 2.0);
1038        // Infinite cylinder centered at (0,0) with radius 1 → total 2, distance 0 → overlap
1039        assert!(c.intersects_infinite_cylinder(1.0, [0.0, 0.0]));
1040    }
1041
1042    #[test]
1043    fn test_cylinder_intersects_infinite_cylinder_no_overlap() {
1044        let c = Cylinder::new(1.0, 2.0);
1045        // Infinite cylinder centered far away
1046        assert!(!c.intersects_infinite_cylinder(0.5, [5.0, 0.0]));
1047    }
1048
1049    #[test]
1050    fn test_cylinder_random_surface_points_count() {
1051        let c = Cylinder::new(1.0, 2.0);
1052        let pts = c.random_surface_points(50, 12345);
1053        assert_eq!(pts.len(), 50);
1054    }
1055
1056    #[test]
1057    fn test_cylinder_random_surface_points_on_surface() {
1058        let c = Cylinder::new(1.0, 2.0);
1059        let pts = c.random_surface_points(80, 99);
1060        for p in &pts {
1061            let sdf = c.sdf(*p);
1062            assert!(sdf.abs() < 0.01, "point {:?} has sdf={sdf}", p);
1063        }
1064    }
1065
1066    #[test]
1067    fn test_cylinder_support_array_matches_support() {
1068        let c = Cylinder::new(2.0, 3.0);
1069        let d = [1.0, 1.0, 0.0];
1070        let a = c.support_array(d);
1071        let b = c.support(d);
1072        assert_eq!(a, b);
1073    }
1074
1075    #[test]
1076    fn test_cylinder_full_height() {
1077        let c = Cylinder::new(1.0, 3.0);
1078        assert!((c.full_height() - 6.0).abs() < 1e-12);
1079    }
1080
1081    #[test]
1082    fn test_cylinder_radius_of_gyration_y() {
1083        let c = Cylinder::new(2.0, 1.0);
1084        // k_y = r/sqrt(2) = 2/sqrt(2) = sqrt(2)
1085        assert!((c.radius_of_gyration_y() - 2.0_f64.sqrt()).abs() < 1e-12);
1086    }
1087
1088    #[test]
1089    fn test_cylinder_radius_of_gyration_x() {
1090        let c = Cylinder::new(1.0, 1.0); // r=1, h=2
1091        // k_x = sqrt((3 + 4)/12) = sqrt(7/12)
1092        let expected = (7.0_f64 / 12.0).sqrt();
1093        assert!((c.radius_of_gyration_x(1.0) - expected).abs() < 1e-12);
1094    }
1095
1096    // ── Extended tests for new methods ──────────────────────────────────────
1097
1098    #[test]
1099    fn test_cylinder_volume_swept_zero_delta() {
1100        let c = Cylinder::new(1.0, 2.0);
1101        // Delta = 0 → swept volume = cylinder volume
1102        let swept = c.volume_swept([0.0, 0.0, 0.0]);
1103        assert!((swept - c.volume()).abs() < 1e-10);
1104    }
1105
1106    #[test]
1107    fn test_cylinder_volume_swept_positive_delta() {
1108        let c = Cylinder::new(1.0, 1.0);
1109        let swept = c.volume_swept([1.0, 0.0, 0.0]);
1110        // Additional contribution: π*r²*1 = π
1111        assert!(swept > c.volume());
1112        assert!((swept - c.volume() - PI).abs() < 1e-10);
1113    }
1114
1115    #[test]
1116    fn test_cylinder_cap_uv_center() {
1117        let c = Cylinder::new(2.0, 1.0);
1118        let uv = c.cap_uv([0.0, 2.0, 0.0]);
1119        assert!((uv[0] - 0.5).abs() < 1e-10);
1120        assert!((uv[1] - 0.5).abs() < 1e-10);
1121    }
1122
1123    #[test]
1124    fn test_cylinder_cap_uv_edge() {
1125        let c = Cylinder::new(2.0, 1.0);
1126        // Point at (r, h, 0) → u = 0.5 + 2/4 = 0.5 + 0.5 = 1.0 → clamped to 1
1127        let uv = c.cap_uv([2.0, 1.0, 0.0]);
1128        assert!((uv[0] - 1.0).abs() < 1e-10);
1129        assert!((uv[1] - 0.5).abs() < 1e-10);
1130    }
1131
1132    #[test]
1133    fn test_cylinder_lateral_uv_bottom_left() {
1134        let c = Cylinder::new(1.0, 1.0);
1135        // At θ=0 (x=1, z=0), y=-half_height: u≈0, v=0
1136        let uv = c.lateral_uv([1.0, -1.0, 0.0]);
1137        assert!(uv[1].abs() < 1e-10, "v={}", uv[1]);
1138    }
1139
1140    #[test]
1141    fn test_cylinder_lateral_uv_top() {
1142        let c = Cylinder::new(1.0, 1.0);
1143        let uv = c.lateral_uv([1.0, 1.0, 0.0]);
1144        assert!((uv[1] - 1.0).abs() < 1e-10, "v={}", uv[1]);
1145    }
1146
1147    #[test]
1148    fn test_cylinder_hollow_inside_ring() {
1149        let c = Cylinder::new(2.0, 1.0);
1150        // Point at r=1.5 (between inner=1 and outer=2)
1151        assert!(c.contains_point_hollow([1.5, 0.0, 0.0], 1.0));
1152    }
1153
1154    #[test]
1155    fn test_cylinder_hollow_outside_outer() {
1156        let c = Cylinder::new(2.0, 1.0);
1157        assert!(!c.contains_point_hollow([3.0, 0.0, 0.0], 1.0));
1158    }
1159
1160    #[test]
1161    fn test_cylinder_hollow_inside_inner_hole() {
1162        let c = Cylinder::new(2.0, 1.0);
1163        // Point at r=0.5, inside the hole
1164        assert!(!c.contains_point_hollow([0.5, 0.0, 0.0], 1.0));
1165    }
1166
1167    #[test]
1168    fn test_cylinder_volume_hollow_full_solid() {
1169        // Inner radius = 0 → volume should equal solid volume
1170        let c = Cylinder::new(1.0, 1.0);
1171        assert!((c.volume_hollow(0.0) - c.volume()).abs() < 1e-10);
1172    }
1173
1174    #[test]
1175    fn test_cylinder_volume_hollow_positive() {
1176        let c = Cylinder::new(2.0, 1.0);
1177        let v = c.volume_hollow(1.0);
1178        assert!(v > 0.0);
1179        // π(4-1)*2 = 6π
1180        assert!((v - 6.0 * PI).abs() < 1e-10);
1181    }
1182
1183    #[test]
1184    fn test_cylinder_frustum_slant_height_equal_radii() {
1185        let c = Cylinder::new(1.0, 1.0);
1186        // Equal radii → slant = full height = 2
1187        let sl = c.frustum_slant_height(1.0, 1.0);
1188        assert!((sl - 2.0).abs() < 1e-10);
1189    }
1190
1191    #[test]
1192    fn test_cylinder_frustum_slant_height_cone() {
1193        let c = Cylinder::new(1.0, 1.0);
1194        // r_top=0, r_bottom=1: slant = sqrt(4+1) = sqrt(5)
1195        let sl = c.frustum_slant_height(0.0, 1.0);
1196        assert!((sl - 5.0_f64.sqrt()).abs() < 1e-10);
1197    }
1198
1199    #[test]
1200    fn test_cylinder_frustum_lateral_area() {
1201        let c = Cylinder::new(1.0, 1.0); // h=2
1202        // Equal radii r=1: lateral area = 2πr*h = 2π*1*2 = 4π
1203        let area = c.frustum_lateral_area(1.0, 1.0);
1204        assert!((area - 4.0 * PI).abs() < 1e-10);
1205    }
1206
1207    #[test]
1208    fn test_cylinder_frustum_volume_cylinder() {
1209        let c = Cylinder::new(1.0, 1.0);
1210        // Both radii equal to cylinder radius → frustum volume = cylinder volume
1211        let fv = c.frustum_volume(1.0, 1.0);
1212        assert!((fv - c.volume()).abs() < 1e-10);
1213    }
1214
1215    #[test]
1216    fn test_cylinder_frustum_volume_cone() {
1217        let c = Cylinder::new(1.0, 1.0);
1218        // r_top=0, r_bottom=1: frustum volume = cone volume = πr²h/3 = π*1*2/3
1219        let fv = c.frustum_volume(0.0, 1.0);
1220        let expected = PI * 1.0 * 2.0 / 3.0;
1221        assert!((fv - expected).abs() < 1e-10);
1222    }
1223
1224    #[test]
1225    fn test_cylinder_oblique_cap_area_zero_tilt() {
1226        let c = Cylinder::new(1.0, 1.0);
1227        // Zero tilt: cap area = π r²
1228        assert!((c.oblique_cap_area(0.0) - PI).abs() < 1e-10);
1229    }
1230
1231    #[test]
1232    fn test_cylinder_oblique_cap_area_larger_at_tilt() {
1233        let c = Cylinder::new(1.0, 1.0);
1234        assert!(c.oblique_cap_area(0.5) > c.oblique_cap_area(0.0));
1235    }
1236
1237    #[test]
1238    fn test_cylinder_ruled_surface_point_on_surface() {
1239        let c = Cylinder::new(1.0, 2.0);
1240        // Check a few points
1241        for i in 0..8 {
1242            let u = 2.0 * PI * i as f64 / 8.0;
1243            for j in 0..=4 {
1244                let t = j as f64 / 4.0;
1245                let p = c.ruled_surface_point(u, t);
1246                let sdf = c.sdf(p);
1247                assert!(sdf.abs() < 1e-9, "ruled sdf={sdf}");
1248            }
1249        }
1250    }
1251
1252    #[test]
1253    fn test_cylinder_stacked_volume() {
1254        let c1 = Cylinder::new(1.0, 1.0);
1255        let c2 = Cylinder::new(1.0, 2.0);
1256        let combined = c1.stacked_volume(&c2);
1257        assert!((combined - c1.volume() - c2.volume()).abs() < 1e-10);
1258    }
1259
1260    #[test]
1261    fn test_cylinder_stacked_half_height() {
1262        let c1 = Cylinder::new(1.0, 1.0);
1263        let c2 = Cylinder::new(1.0, 2.0);
1264        assert!((c1.stacked_half_height(&c2) - 3.0).abs() < 1e-12);
1265    }
1266
1267    #[test]
1268    fn test_cylinder_stacked_bounding_radius() {
1269        let c1 = Cylinder::new(1.0, 1.0);
1270        let c2 = Cylinder::new(3.0, 0.5);
1271        assert!((c1.stacked_bounding_radius(&c2) - 3.0).abs() < 1e-12);
1272    }
1273
1274    #[test]
1275    fn test_cylinder_lateral_normal_at_x_axis() {
1276        let c = Cylinder::new(1.0, 2.0);
1277        let n = c.lateral_normal_at([1.0, 0.5, 0.0]);
1278        assert!((n[0] - 1.0).abs() < 1e-10);
1279        assert!(n[1].abs() < 1e-12);
1280        assert!(n[2].abs() < 1e-12);
1281    }
1282
1283    #[test]
1284    fn test_cylinder_lateral_normal_unit() {
1285        let c = Cylinder::new(1.0, 2.0);
1286        let n = c.lateral_normal_at([0.6, 1.0, 0.8]);
1287        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1288        assert!((len - 1.0).abs() < 1e-9);
1289    }
1290
1291    #[test]
1292    fn test_cylinder_sdf_gradient_outside_points_outward() {
1293        let c = Cylinder::new(1.0, 2.0);
1294        let g = c.sdf_gradient([3.0, 0.0, 0.0]);
1295        // Gradient should point outward: positive x component
1296        assert!(g[0] > 0.5, "gx={}", g[0]);
1297    }
1298
1299    #[test]
1300    fn test_cylinder_lateral_ray_intersection_count_two() {
1301        let c = Cylinder::new(1.0, 2.0);
1302        let count = c.lateral_ray_intersection_count([-5.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
1303        assert_eq!(count, 2);
1304    }
1305
1306    #[test]
1307    fn test_cylinder_lateral_ray_intersection_count_miss() {
1308        let c = Cylinder::new(1.0, 2.0);
1309        let count = c.lateral_ray_intersection_count([0.0, 5.0, 0.0], [0.0, 1.0, 0.0]);
1310        assert_eq!(count, 0);
1311    }
1312
1313    #[test]
1314    fn test_cylinder_top_rim_points_count() {
1315        let c = Cylinder::new(1.0, 2.0);
1316        let pts = c.top_rim_points(16);
1317        assert_eq!(pts.len(), 16);
1318    }
1319
1320    #[test]
1321    fn test_cylinder_top_rim_points_at_top() {
1322        let c = Cylinder::new(1.0, 2.0);
1323        let pts = c.top_rim_points(8);
1324        for p in &pts {
1325            assert!((p[1] - c.half_height).abs() < 1e-12);
1326            let xz = (p[0] * p[0] + p[2] * p[2]).sqrt();
1327            assert!((xz - c.radius).abs() < 1e-9);
1328        }
1329    }
1330}