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