Skip to main content

brepkit_math/
surfaces.rs

1//! Analytic surface types for exact geometric computations.
2//!
3//! These surfaces complement NURBS surfaces by providing exact parameterizations
4//! for common shapes (cylinder, cone, sphere, torus). This enables exact
5//! intersection algorithms (e.g., plane-cylinder = ellipse) without sampling.
6
7use crate::MathError;
8use crate::aabb::Aabb3;
9use crate::frame::Frame3;
10use crate::nurbs::surface::NurbsSurface;
11use crate::vec::{Point3, Vec3};
12
13/// An infinite cylindrical surface.
14///
15/// Parameterized as `P(u, v) = origin + radius*(cos(u)*x_axis + sin(u)*y_axis) + v*axis`
16/// where `u ∈ [0, 2π)` and `v ∈ (-∞, +∞)`.
17#[derive(Debug, Clone)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub struct CylindricalSurface {
20    origin: Point3,
21    axis: Vec3,
22    radius: f64,
23    x_axis: Vec3,
24    y_axis: Vec3,
25}
26
27impl CylindricalSurface {
28    /// Creates a new cylindrical surface.
29    ///
30    /// # Errors
31    /// Returns an error if radius is not positive or axis is zero.
32    pub fn new(origin: Point3, axis: Vec3, radius: f64) -> Result<Self, MathError> {
33        if radius <= 0.0 {
34            return Err(MathError::ParameterOutOfRange {
35                value: radius,
36                min: f64::EPSILON,
37                max: f64::MAX,
38            });
39        }
40        let f = Frame3::from_normal(origin, axis)?;
41        Ok(Self {
42            origin,
43            axis: f.z,
44            radius,
45            x_axis: f.x,
46            y_axis: f.y,
47        })
48    }
49
50    /// Evaluates the surface at parameters `(u, v)`.
51    #[must_use]
52    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
53        let (sin_u, cos_u) = u.sin_cos();
54        self.origin
55            + self.x_axis * (self.radius * cos_u)
56            + self.y_axis * (self.radius * sin_u)
57            + self.axis * v
58    }
59
60    /// Returns the surface normal at parameters `(u, v)`.
61    #[must_use]
62    pub fn normal(&self, u: f64, _v: f64) -> Vec3 {
63        let (sin_u, cos_u) = u.sin_cos();
64        self.x_axis * cos_u + self.y_axis * sin_u
65    }
66
67    /// Returns the origin.
68    #[must_use]
69    pub const fn origin(&self) -> Point3 {
70        self.origin
71    }
72
73    /// Returns the axis direction.
74    #[must_use]
75    pub const fn axis(&self) -> Vec3 {
76        self.axis
77    }
78
79    /// Returns the radius.
80    #[must_use]
81    pub const fn radius(&self) -> f64 {
82        self.radius
83    }
84
85    /// Returns the local X axis (first radial direction in the parametric frame).
86    #[must_use]
87    pub const fn x_axis(&self) -> Vec3 {
88        self.x_axis
89    }
90
91    /// Returns the local Y axis (second radial direction in the parametric frame).
92    #[must_use]
93    pub const fn y_axis(&self) -> Vec3 {
94        self.y_axis
95    }
96
97    /// Creates a cylindrical surface with a specified reference direction.
98    ///
99    /// `ref_dir` defines the x-axis of the parametric frame (projected
100    /// perpendicular to `axis`). This preserves the parametric orientation
101    /// from STEP `AXIS2_PLACEMENT_3D` or BREP round-trips.
102    ///
103    /// # Errors
104    /// Returns an error if radius is not positive or axis is zero.
105    pub fn with_ref_dir(
106        origin: Point3,
107        axis: Vec3,
108        radius: f64,
109        ref_dir: Vec3,
110    ) -> Result<Self, MathError> {
111        if radius <= 0.0 {
112            return Err(MathError::ParameterOutOfRange {
113                value: radius,
114                min: f64::EPSILON,
115                max: f64::MAX,
116            });
117        }
118        let f = Frame3::from_normal_and_ref(origin, axis, ref_dir)?;
119        Ok(Self {
120            origin,
121            axis: f.z,
122            radius,
123            x_axis: f.x,
124            y_axis: f.y,
125        })
126    }
127
128    /// Returns a copy of this cylinder with its origin translated by `offset`.
129    #[must_use]
130    pub fn translated(&self, offset: Vec3) -> Self {
131        Self {
132            origin: self.origin + offset,
133            ..self.clone()
134        }
135    }
136
137    /// Project a 3D point onto the cylinder surface, returning (u, v) parameters.
138    ///
139    /// `u` is the angular parameter [0, 2π), `v` is the axial parameter.
140    #[must_use]
141    pub fn project_point(&self, point: Point3) -> (f64, f64) {
142        let to_pt = Vec3::new(
143            point.x() - self.origin.x(),
144            point.y() - self.origin.y(),
145            point.z() - self.origin.z(),
146        );
147        let v = self.axis.dot(to_pt);
148        let radial = to_pt - self.axis * v;
149        let x = self.x_axis.dot(radial);
150        let y = self.y_axis.dot(radial);
151        let u = y.atan2(x).rem_euclid(std::f64::consts::TAU);
152        (u, v)
153    }
154
155    /// Convert to an exact rational NURBS surface over the given v-range.
156    ///
157    /// Uses degree (2, 1) with 9 control points per ring (standard rational
158    /// representation of a full circle). The result is geometrically exact.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if `NurbsSurface` construction fails.
163    pub fn to_nurbs(&self, v_min: f64, v_max: f64) -> Result<NurbsSurface, MathError> {
164        // 9 CPs for a full circle (degree 2, 4 arcs of 90°).
165        let w1 = std::f64::consts::FRAC_1_SQRT_2;
166        let circle_weights = [1.0, w1, 1.0, w1, 1.0, w1, 1.0, w1, 1.0];
167        // Directions at 0°, 45°, 90°, ... 360° in the (x_axis, y_axis) plane.
168        let dirs: [(f64, f64); 9] = [
169            (1.0, 0.0),
170            (1.0, 1.0),
171            (0.0, 1.0),
172            (-1.0, 1.0),
173            (-1.0, 0.0),
174            (-1.0, -1.0),
175            (0.0, -1.0),
176            (1.0, -1.0),
177            (1.0, 0.0),
178        ];
179
180        let mut cps = Vec::with_capacity(9);
181        let mut ws = Vec::with_capacity(9);
182        for (i, &(dx, dy)) in dirs.iter().enumerate() {
183            let radial = self.x_axis * (self.radius * dx) + self.y_axis * (self.radius * dy);
184            let p_bot = self.origin + radial + self.axis * v_min;
185            let p_top = self.origin + radial + self.axis * v_max;
186            cps.push(vec![p_bot, p_top]);
187            ws.push(vec![circle_weights[i], circle_weights[i]]);
188        }
189
190        let knots_u = vec![
191            0.0, 0.0, 0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0, 1.0, 1.0,
192        ];
193        let knots_v = vec![0.0, 0.0, 1.0, 1.0];
194        NurbsSurface::new(2, 1, knots_u, knots_v, cps, ws)
195    }
196}
197
198/// An infinite conical surface.
199///
200/// Parameterized as `P(u, v) = apex + v*(cos(half_angle)*(cos(u)*x_axis + sin(u)*y_axis) + sin(half_angle)*axis)`
201/// where `u ∈ [0, 2π)` and `v ∈ [0, +∞)`.
202#[derive(Debug, Clone)]
203#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
204pub struct ConicalSurface {
205    apex: Point3,
206    axis: Vec3,
207    half_angle: f64,
208    x_axis: Vec3,
209    y_axis: Vec3,
210}
211
212impl ConicalSurface {
213    /// Creates a new conical surface.
214    ///
215    /// `half_angle` is the angle from the radial plane to the cone's surface
216    /// generator (radians). Small angles produce wide/flat cones; angles near
217    /// π/2 produce narrow/spike cones. In the evaluate formula
218    /// `P(u,v) = apex + v*(cos(a)*radial + sin(a)*axis)`, `a` is this angle.
219    ///
220    /// # Errors
221    /// Returns an error if half-angle is not in `(0, π/2)` or axis is zero.
222    pub fn new(apex: Point3, axis: Vec3, half_angle: f64) -> Result<Self, MathError> {
223        if half_angle <= 0.0 || half_angle >= std::f64::consts::FRAC_PI_2 {
224            return Err(MathError::ParameterOutOfRange {
225                value: half_angle,
226                min: f64::EPSILON,
227                max: std::f64::consts::FRAC_PI_2,
228            });
229        }
230        let f = Frame3::from_normal(apex, axis)?;
231        Ok(Self {
232            apex,
233            axis: f.z,
234            half_angle,
235            x_axis: f.x,
236            y_axis: f.y,
237        })
238    }
239
240    /// Evaluates the surface at parameters `(u, v)`.
241    #[must_use]
242    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
243        let (sin_u, cos_u) = u.sin_cos();
244        let (sin_a, cos_a) = self.half_angle.sin_cos();
245        let radial = self.x_axis * cos_u + self.y_axis * sin_u;
246        self.apex + (radial * cos_a + self.axis * sin_a) * v
247    }
248
249    /// Returns the surface normal at parameters `(u, v)`.
250    #[must_use]
251    pub fn normal(&self, u: f64, _v: f64) -> Vec3 {
252        let (sin_u, cos_u) = u.sin_cos();
253        let (sin_a, cos_a) = self.half_angle.sin_cos();
254        let radial = self.x_axis * cos_u + self.y_axis * sin_u;
255        // Normal points outward: radial * sin(a) - axis * cos(a)
256        radial * sin_a + self.axis * (-cos_a)
257    }
258
259    /// Returns the apex point.
260    #[must_use]
261    pub const fn apex(&self) -> Point3 {
262        self.apex
263    }
264
265    /// Returns the axis direction.
266    #[must_use]
267    pub const fn axis(&self) -> Vec3 {
268        self.axis
269    }
270
271    /// Returns the half-angle in radians.
272    #[must_use]
273    pub const fn half_angle(&self) -> f64 {
274        self.half_angle
275    }
276
277    /// Returns the local X axis (first radial direction in the parametric frame).
278    #[must_use]
279    pub const fn x_axis(&self) -> Vec3 {
280        self.x_axis
281    }
282
283    /// Returns the local Y axis (second radial direction in the parametric frame).
284    #[must_use]
285    pub const fn y_axis(&self) -> Vec3 {
286        self.y_axis
287    }
288
289    /// Creates a conical surface with a specified reference direction.
290    ///
291    /// `ref_dir` defines the x-axis of the parametric frame (projected
292    /// perpendicular to `axis`). This preserves the parametric orientation
293    /// from STEP `AXIS2_PLACEMENT_3D` or BREP round-trips.
294    ///
295    /// # Errors
296    /// Returns an error if half-angle is not in `(0, π/2)` or axis is zero.
297    pub fn with_ref_dir(
298        apex: Point3,
299        axis: Vec3,
300        half_angle: f64,
301        ref_dir: Vec3,
302    ) -> Result<Self, MathError> {
303        if half_angle <= 0.0 || half_angle >= std::f64::consts::FRAC_PI_2 {
304            return Err(MathError::ParameterOutOfRange {
305                value: half_angle,
306                min: f64::EPSILON,
307                max: std::f64::consts::FRAC_PI_2,
308            });
309        }
310        let f = Frame3::from_normal_and_ref(apex, axis, ref_dir)?;
311        Ok(Self {
312            apex,
313            axis: f.z,
314            half_angle,
315            x_axis: f.x,
316            y_axis: f.y,
317        })
318    }
319
320    /// Returns a copy of this cone with its apex translated by `offset`.
321    #[must_use]
322    pub fn translated(&self, offset: Vec3) -> Self {
323        Self {
324            apex: self.apex + offset,
325            ..self.clone()
326        }
327    }
328
329    /// Returns the radius at a given distance `v` along the axis from the apex.
330    #[must_use]
331    pub fn radius_at(&self, v: f64) -> f64 {
332        v * self.half_angle.cos()
333    }
334
335    /// Project a 3D point onto the cone surface, returning `(u, v)` parameters.
336    ///
337    /// `u` is the angular parameter `[0, 2π)`, `v` is the distance from the
338    /// apex along the cone surface generator line.
339    #[must_use]
340    pub fn project_point(&self, point: Point3) -> (f64, f64) {
341        let to_pt = Vec3::new(
342            point.x() - self.apex.x(),
343            point.y() - self.apex.y(),
344            point.z() - self.apex.z(),
345        );
346
347        let h = self.axis.dot(to_pt);
348        let radial = to_pt - self.axis * h;
349        let x = self.x_axis.dot(radial);
350        let y = self.y_axis.dot(radial);
351
352        let u = y.atan2(x).rem_euclid(std::f64::consts::TAU);
353
354        let sin_a = self.half_angle.sin();
355        let v = if sin_a.abs() > 1e-15 {
356            h / sin_a
357        } else {
358            let cos_a = self.half_angle.cos();
359            if cos_a.abs() > 1e-15 {
360                radial.length() / cos_a
361            } else {
362                0.0
363            }
364        };
365
366        (u, v)
367    }
368
369    /// Convert to an approximate NURBS surface over the given v-range.
370    ///
371    /// # Errors
372    ///
373    /// Returns an error if `NurbsSurface` construction fails.
374    pub fn to_nurbs(&self, v_min: f64, v_max: f64) -> Result<NurbsSurface, MathError> {
375        analytic_to_nurbs_sampled(
376            |u, v| self.evaluate(u, v),
377            (0.0, std::f64::consts::TAU),
378            (v_min, v_max),
379        )
380    }
381}
382
383/// An infinite spherical surface (actually a sphere).
384///
385/// Parameterized as `P(u, v) = center + radius*(cos(v)*cos(u)*x + cos(v)*sin(u)*y + sin(v)*z)`
386/// where `u ∈ [0, 2π)` (longitude) and `v ∈ [-π/2, π/2]` (latitude).
387#[derive(Debug, Clone)]
388#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
389pub struct SphericalSurface {
390    center: Point3,
391    radius: f64,
392    x_axis: Vec3,
393    y_axis: Vec3,
394    z_axis: Vec3,
395}
396
397impl SphericalSurface {
398    /// Creates a new spherical surface.
399    ///
400    /// # Errors
401    /// Returns an error if radius is not positive.
402    pub fn new(center: Point3, radius: f64) -> Result<Self, MathError> {
403        if radius <= 0.0 {
404            return Err(MathError::ParameterOutOfRange {
405                value: radius,
406                min: f64::EPSILON,
407                max: f64::MAX,
408            });
409        }
410        Ok(Self {
411            center,
412            radius,
413            x_axis: Vec3::new(1.0, 0.0, 0.0),
414            y_axis: Vec3::new(0.0, 1.0, 0.0),
415            z_axis: Vec3::new(0.0, 0.0, 1.0),
416        })
417    }
418
419    /// Creates a spherical surface with a custom orientation.
420    ///
421    /// # Errors
422    /// Returns an error if radius is not positive or the z-axis is zero.
423    pub fn with_axis(center: Point3, radius: f64, z_axis: Vec3) -> Result<Self, MathError> {
424        if radius <= 0.0 {
425            return Err(MathError::ParameterOutOfRange {
426                value: radius,
427                min: f64::EPSILON,
428                max: f64::MAX,
429            });
430        }
431        let f = Frame3::from_normal(center, z_axis)?;
432        Ok(Self {
433            center,
434            radius,
435            x_axis: f.x,
436            y_axis: f.y,
437            z_axis: f.z,
438        })
439    }
440
441    /// Evaluates the surface at parameters `(u, v)`.
442    #[must_use]
443    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
444        let (sin_u, cos_u) = u.sin_cos();
445        let (sin_v, cos_v) = v.sin_cos();
446        self.center
447            + self.x_axis * (self.radius * cos_v * cos_u)
448            + self.y_axis * (self.radius * cos_v * sin_u)
449            + self.z_axis * (self.radius * sin_v)
450    }
451
452    /// Returns the outward normal at parameters `(u, v)`.
453    #[must_use]
454    pub fn normal(&self, u: f64, v: f64) -> Vec3 {
455        let (sin_u, cos_u) = u.sin_cos();
456        let (sin_v, cos_v) = v.sin_cos();
457        self.x_axis * (cos_v * cos_u) + self.y_axis * (cos_v * sin_u) + self.z_axis * sin_v
458    }
459
460    /// Returns the center.
461    #[must_use]
462    pub const fn center(&self) -> Point3 {
463        self.center
464    }
465
466    /// Returns the radius.
467    #[must_use]
468    pub const fn radius(&self) -> f64 {
469        self.radius
470    }
471
472    /// Returns the local X axis.
473    #[must_use]
474    pub const fn x_axis(&self) -> Vec3 {
475        self.x_axis
476    }
477
478    /// Returns the local Y axis.
479    #[must_use]
480    pub const fn y_axis(&self) -> Vec3 {
481        self.y_axis
482    }
483
484    /// Returns the local Z axis (pole direction).
485    #[must_use]
486    pub const fn z_axis(&self) -> Vec3 {
487        self.z_axis
488    }
489
490    /// Returns a copy of this sphere with its center translated by `offset`.
491    #[must_use]
492    pub fn translated(&self, offset: Vec3) -> Self {
493        Self {
494            center: self.center + offset,
495            ..self.clone()
496        }
497    }
498
499    /// Axis-aligned bounding box of the full sphere (`center ± radius` on every
500    /// axis). Orientation-independent and a sound superset of any spherical
501    /// patch — the boolean broad-phase uses it because a face's boundary-only
502    /// bbox misses the surface bulge between its boundary edges (a hemisphere's
503    /// only boundary is its equator).
504    #[must_use]
505    pub fn aabb(&self) -> Aabb3 {
506        let r = self.radius;
507        Aabb3 {
508            min: Point3::new(
509                self.center.x() - r,
510                self.center.y() - r,
511                self.center.z() - r,
512            ),
513            max: Point3::new(
514                self.center.x() + r,
515                self.center.y() + r,
516                self.center.z() + r,
517            ),
518        }
519    }
520
521    /// Axis-aligned bounding box of the hemisphere on the `pole`-axis side —
522    /// the half-ball `{center + radius·d : d·pole ≥ 0}`. A tight, sound superset
523    /// of any spherical patch lying on that side; the boolean broad-phase uses
524    /// it so that one hemisphere face's box does not admit the other's sections
525    /// (the full-sphere `aabb` would). Falls back to the full sphere when `pole`
526    /// is degenerate (side ambiguous).
527    #[must_use]
528    pub fn aabb_region(&self, pole: Vec3) -> Aabb3 {
529        let Ok(n) = pole.normalize() else {
530            return self.aabb();
531        };
532        let c = self.center;
533        let r = self.radius;
534        // For world axis ê, the extreme of d·ê over {d·n ≥ 0, |d| = 1} is 1 when
535        // ê·n ≥ 0 (the axis itself is in the half-space), else the equator-plane
536        // projection √(1 − (ê·n)²); the minimum is the mirror image.
537        let span = |en: f64| -> (f64, f64) {
538            let s = (1.0 - en * en).max(0.0).sqrt();
539            let hi = if en >= 0.0 { 1.0 } else { s };
540            let lo = if en <= 0.0 { -1.0 } else { -s };
541            (lo, hi)
542        };
543        let (lx, hx) = span(n.x());
544        let (ly, hy) = span(n.y());
545        let (lz, hz) = span(n.z());
546        Aabb3 {
547            min: Point3::new(c.x() + r * lx, c.y() + r * ly, c.z() + r * lz),
548            max: Point3::new(c.x() + r * hx, c.y() + r * hy, c.z() + r * hz),
549        }
550    }
551
552    /// Project a 3D point onto the sphere, returning (u, v) parameters.
553    ///
554    /// `u` is the longitudinal angle [0, 2π), `v` is the latitude [-π/2, π/2].
555    #[must_use]
556    pub fn project_point(&self, point: Point3) -> (f64, f64) {
557        let to_pt = Vec3::new(
558            point.x() - self.center.x(),
559            point.y() - self.center.y(),
560            point.z() - self.center.z(),
561        );
562        let r = to_pt.length();
563        if r < 1e-15 {
564            return (0.0, 0.0);
565        }
566        let x = self.x_axis.dot(to_pt);
567        let y = self.y_axis.dot(to_pt);
568        let z = self.z_axis.dot(to_pt);
569        let u = y.atan2(x).rem_euclid(std::f64::consts::TAU);
570        let v = (z / r).clamp(-1.0, 1.0).asin();
571        (u, v)
572    }
573
574    /// Convert to an approximate NURBS surface.
575    ///
576    /// # Errors
577    ///
578    /// Returns an error if `NurbsSurface` construction fails.
579    pub fn to_nurbs(&self) -> Result<NurbsSurface, MathError> {
580        analytic_to_nurbs_sampled(
581            |u, v| self.evaluate(u, v),
582            (0.0, std::f64::consts::TAU),
583            (-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2),
584        )
585    }
586}
587
588/// A toroidal surface.
589///
590/// Parameterized as `P(u, v) = center + (R + r*cos(v))*(cos(u)*x + sin(u)*y) + r*sin(v)*z`
591/// where `R` is the major radius, `r` is the minor radius,
592/// `u ∈ [0, 2π)` (around the tube) and `v ∈ [0, 2π)` (around the cross-section).
593#[derive(Debug, Clone)]
594#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
595pub struct ToroidalSurface {
596    center: Point3,
597    major_radius: f64,
598    minor_radius: f64,
599    x_axis: Vec3,
600    y_axis: Vec3,
601    z_axis: Vec3,
602}
603
604impl ToroidalSurface {
605    /// Creates a new toroidal surface.
606    ///
607    /// # Errors
608    /// Returns an error if either radius is not positive or `minor_radius > major_radius`.
609    pub fn new(center: Point3, major_radius: f64, minor_radius: f64) -> Result<Self, MathError> {
610        if major_radius <= 0.0 {
611            return Err(MathError::ParameterOutOfRange {
612                value: major_radius,
613                min: f64::EPSILON,
614                max: f64::MAX,
615            });
616        }
617        if minor_radius <= 0.0 {
618            return Err(MathError::ParameterOutOfRange {
619                value: minor_radius,
620                min: f64::EPSILON,
621                max: f64::MAX,
622            });
623        }
624        Ok(Self {
625            center,
626            major_radius,
627            minor_radius,
628            x_axis: Vec3::new(1.0, 0.0, 0.0),
629            y_axis: Vec3::new(0.0, 1.0, 0.0),
630            z_axis: Vec3::new(0.0, 0.0, 1.0),
631        })
632    }
633
634    /// Creates a toroidal surface with a specified axis direction.
635    ///
636    /// The axis is the central symmetry axis of the torus. The local
637    /// coordinate frame is derived from it.
638    ///
639    /// # Errors
640    /// Returns an error if either radius is not positive or axis is zero.
641    pub fn with_axis(
642        center: Point3,
643        major_radius: f64,
644        minor_radius: f64,
645        z_axis: Vec3,
646    ) -> Result<Self, MathError> {
647        if major_radius <= 0.0 {
648            return Err(MathError::ParameterOutOfRange {
649                value: major_radius,
650                min: f64::EPSILON,
651                max: f64::MAX,
652            });
653        }
654        if minor_radius <= 0.0 {
655            return Err(MathError::ParameterOutOfRange {
656                value: minor_radius,
657                min: f64::EPSILON,
658                max: f64::MAX,
659            });
660        }
661        let f = Frame3::from_normal(center, z_axis)?;
662        Ok(Self {
663            center,
664            major_radius,
665            minor_radius,
666            x_axis: f.x,
667            y_axis: f.y,
668            z_axis: f.z,
669        })
670    }
671
672    /// Create a torus with explicit axis and reference direction.
673    ///
674    /// `ref_dir` defines the x-axis of the local frame (projected
675    /// perpendicular to `z_axis`). This preserves the parametric
676    /// orientation from STEP `AXIS2_PLACEMENT_3D`.
677    ///
678    /// # Errors
679    ///
680    /// Returns [`MathError::ParameterOutOfRange`] if either radius is
681    /// non-positive, or [`MathError::ZeroVector`] if `z_axis` is zero.
682    ///
683    /// # Panics
684    ///
685    /// Panics if the fallback perpendicular vector cannot be normalized
686    /// (should not occur for any valid unit `z_axis`).
687    pub fn with_axis_and_ref_dir(
688        center: Point3,
689        major_radius: f64,
690        minor_radius: f64,
691        z_axis: Vec3,
692        ref_dir: Vec3,
693    ) -> Result<Self, MathError> {
694        if major_radius <= 0.0 {
695            return Err(MathError::ParameterOutOfRange {
696                value: major_radius,
697                min: f64::EPSILON,
698                max: f64::MAX,
699            });
700        }
701        if minor_radius <= 0.0 {
702            return Err(MathError::ParameterOutOfRange {
703                value: minor_radius,
704                min: f64::EPSILON,
705                max: f64::MAX,
706            });
707        }
708        let f = Frame3::from_normal_and_ref(center, z_axis, ref_dir)?;
709        Ok(Self {
710            center,
711            major_radius,
712            minor_radius,
713            x_axis: f.x,
714            y_axis: f.y,
715            z_axis: f.z,
716        })
717    }
718
719    /// Evaluates the surface at parameters `(u, v)`.
720    #[must_use]
721    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
722        let (sin_u, cos_u) = u.sin_cos();
723        let (sin_v, cos_v) = v.sin_cos();
724        let tube_radius = self.minor_radius.mul_add(cos_v, self.major_radius);
725        self.center
726            + self.x_axis * (tube_radius * cos_u)
727            + self.y_axis * (tube_radius * sin_u)
728            + self.z_axis * (self.minor_radius * sin_v)
729    }
730
731    /// Returns the outward surface normal at parameters `(u, v)`.
732    #[must_use]
733    pub fn normal(&self, u: f64, v: f64) -> Vec3 {
734        let (sin_u, cos_u) = u.sin_cos();
735        let (sin_v, cos_v) = v.sin_cos();
736        let radial = self.x_axis * cos_u + self.y_axis * sin_u;
737        radial * cos_v + self.z_axis * sin_v
738    }
739
740    /// Returns the center.
741    #[must_use]
742    pub const fn center(&self) -> Point3 {
743        self.center
744    }
745
746    /// Returns a copy of this torus with its center translated by `offset`.
747    #[must_use]
748    pub fn translated(&self, offset: Vec3) -> Self {
749        Self {
750            center: self.center + offset,
751            ..self.clone()
752        }
753    }
754
755    /// Axis-aligned bounding box of the full torus. The half-extent along each
756    /// world axis sums the ring's projection onto the plane perpendicular to the
757    /// torus axis (`(major+minor)·‖(x·ê, y·ê)‖`) and the tube's projection onto
758    /// the axis (`minor·|z·ê|`); `x/y/z_axis` are orthonormal. A sound superset
759    /// used by the boolean broad-phase — a full torus's boundary is degenerate
760    /// seam points, so a boundary-only bbox collapses to a point.
761    #[must_use]
762    pub fn aabb(&self) -> Aabb3 {
763        let rr = self.major_radius + self.minor_radius;
764        let r = self.minor_radius;
765        let hx = rr * self.x_axis.x().hypot(self.y_axis.x()) + r * self.z_axis.x().abs();
766        let hy = rr * self.x_axis.y().hypot(self.y_axis.y()) + r * self.z_axis.y().abs();
767        let hz = rr * self.x_axis.z().hypot(self.y_axis.z()) + r * self.z_axis.z().abs();
768        Aabb3 {
769            min: Point3::new(
770                self.center.x() - hx,
771                self.center.y() - hy,
772                self.center.z() - hz,
773            ),
774            max: Point3::new(
775                self.center.x() + hx,
776                self.center.y() + hy,
777                self.center.z() + hz,
778            ),
779        }
780    }
781
782    /// Returns the major radius (distance from center to tube center).
783    #[must_use]
784    pub const fn major_radius(&self) -> f64 {
785        self.major_radius
786    }
787
788    /// Returns the minor radius (tube cross-section radius).
789    #[must_use]
790    pub const fn minor_radius(&self) -> f64 {
791        self.minor_radius
792    }
793
794    /// Returns the local X axis.
795    #[must_use]
796    pub const fn x_axis(&self) -> Vec3 {
797        self.x_axis
798    }
799
800    /// Returns the local Y axis.
801    #[must_use]
802    pub const fn y_axis(&self) -> Vec3 {
803        self.y_axis
804    }
805
806    /// Returns the torus axis direction (perpendicular to the ring plane).
807    #[must_use]
808    pub const fn z_axis(&self) -> Vec3 {
809        self.z_axis
810    }
811
812    /// Project a 3D point onto the torus surface, returning `(u, v)` parameters.
813    ///
814    /// `u ∈ [0, 2π)` is the angle around the major circle.
815    /// `v ∈ [0, 2π)` is the angle around the tube cross-section.
816    #[must_use]
817    pub fn project_point(&self, point: Point3) -> (f64, f64) {
818        let to_pt = Vec3::new(
819            point.x() - self.center.x(),
820            point.y() - self.center.y(),
821            point.z() - self.center.z(),
822        );
823
824        let x_comp = self.x_axis.dot(to_pt);
825        let y_comp = self.y_axis.dot(to_pt);
826        let u = y_comp.atan2(x_comp).rem_euclid(std::f64::consts::TAU);
827
828        let (sin_u, cos_u) = u.sin_cos();
829        let tube_center = self.center
830            + self.x_axis * (self.major_radius * cos_u)
831            + self.y_axis * (self.major_radius * sin_u);
832
833        let to_tube = Vec3::new(
834            point.x() - tube_center.x(),
835            point.y() - tube_center.y(),
836            point.z() - tube_center.z(),
837        );
838
839        let radial_dir = self.x_axis * cos_u + self.y_axis * sin_u;
840        let r_comp = radial_dir.dot(to_tube);
841        let z_comp = self.z_axis.dot(to_tube);
842
843        let v = z_comp.atan2(r_comp).rem_euclid(std::f64::consts::TAU);
844        (u, v)
845    }
846
847    /// Convert to an approximate NURBS surface.
848    ///
849    /// # Errors
850    ///
851    /// Returns an error if `NurbsSurface` construction fails.
852    pub fn to_nurbs(&self) -> Result<NurbsSurface, MathError> {
853        analytic_to_nurbs_sampled(
854            |u, v| self.evaluate(u, v),
855            (0.0, std::f64::consts::TAU),
856            (0.0, std::f64::consts::TAU),
857        )
858    }
859}
860
861/// A surface of revolution created by revolving a curve around an axis.
862///
863/// Parameterized as `P(u, v) = origin + (curve(v) ⊗ rotation(u, axis))`
864/// where `u ∈ [0, 2π)` is the revolution angle and `v` parameterizes
865/// the generatrix curve.
866#[derive(Debug, Clone)]
867pub struct RevolutionSurface {
868    origin: Point3,
869    axis: Vec3,
870    x_axis: Vec3,
871    y_axis: Vec3,
872    /// The generatrix (meridian) curve in the `(distance_from_axis, height)` plane.
873    generatrix_radii: Vec<f64>,
874    generatrix_heights: Vec<f64>,
875}
876
877impl RevolutionSurface {
878    /// Creates a surface of revolution from a set of meridian profile points.
879    ///
880    /// Each point `(radius, height)` defines the generatrix in the rotation plane.
881    ///
882    /// # Errors
883    /// Returns an error if the profile is empty or the axis is zero.
884    pub fn new(
885        origin: Point3,
886        axis: Vec3,
887        radii: Vec<f64>,
888        heights: Vec<f64>,
889    ) -> Result<Self, MathError> {
890        if radii.is_empty() || heights.is_empty() {
891            return Err(MathError::EmptyInput);
892        }
893        if radii.len() != heights.len() {
894            return Err(MathError::InvalidWeights {
895                expected: radii.len(),
896                got: heights.len(),
897            });
898        }
899        let f = Frame3::from_normal(origin, axis)?;
900        Ok(Self {
901            origin,
902            axis: f.z,
903            x_axis: f.x,
904            y_axis: f.y,
905            generatrix_radii: radii,
906            generatrix_heights: heights,
907        })
908    }
909
910    /// Evaluates at `(u, v)` where `u` is the revolution angle and `v ∈ [0, 1]`
911    /// parameterizes the generatrix via linear interpolation.
912    #[must_use]
913    #[allow(
914        clippy::cast_precision_loss,
915        clippy::cast_possible_truncation,
916        clippy::cast_sign_loss
917    )]
918    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
919        let num_pts = self.generatrix_radii.len();
920        let param = v.clamp(0.0, 1.0) * (num_pts - 1) as f64;
921        let idx = (param as usize).min(num_pts - 2);
922        let frac = param - idx as f64;
923
924        let r = frac.mul_add(
925            self.generatrix_radii[idx + 1] - self.generatrix_radii[idx],
926            self.generatrix_radii[idx],
927        );
928        let height = frac.mul_add(
929            self.generatrix_heights[idx + 1] - self.generatrix_heights[idx],
930            self.generatrix_heights[idx],
931        );
932
933        let (sin_u, cos_u) = u.sin_cos();
934        self.origin + self.x_axis * (r * cos_u) + self.y_axis * (r * sin_u) + self.axis * height
935    }
936
937    /// Returns the origin.
938    #[must_use]
939    pub const fn origin(&self) -> Point3 {
940        self.origin
941    }
942
943    /// Returns the axis.
944    #[must_use]
945    pub const fn axis(&self) -> Vec3 {
946        self.axis
947    }
948}
949
950// ---------------------------------------------------------------------------
951// Analytic → NURBS conversion helper
952// ---------------------------------------------------------------------------
953
954/// Sample an analytic surface on a grid and build a degree (1,1) NURBS surface.
955///
956/// APPROXIMATE: piecewise-bilinear interpolation through a 33×9 grid.
957/// Max chord-height error ≈ 0.5% of surface radius (R × (1-cos(π/32))).
958/// Used only for intersection seed-finding — the output face retains
959/// the original analytic `FaceSurface`, so final geometry is exact.
960fn analytic_to_nurbs_sampled(
961    surface_fn: impl Fn(f64, f64) -> Point3,
962    u_range: (f64, f64),
963    v_range: (f64, f64),
964) -> Result<NurbsSurface, MathError> {
965    // Dense sampling reduces chord-height error. For angular coordinates
966    // (u on cylinder/sphere), 32 spans → max error R*(1-cos(π/32)) ≈ 0.005*R.
967    // For v (latitude/height), 8 spans keeps error under 0.02*R.
968    let nu = 33;
969    let nv = 9;
970
971    let mut cps = Vec::with_capacity(nu);
972    let mut weights = Vec::with_capacity(nu);
973
974    #[allow(clippy::cast_precision_loss)]
975    for iu in 0..nu {
976        let u = u_range.0 + (u_range.1 - u_range.0) * (iu as f64 / (nu - 1) as f64);
977        let mut row = Vec::with_capacity(nv);
978        let mut w_row = Vec::with_capacity(nv);
979        for iv in 0..nv {
980            let v = v_range.0 + (v_range.1 - v_range.0) * (iv as f64 / (nv - 1) as f64);
981            row.push(surface_fn(u, v));
982            w_row.push(1.0);
983        }
984        cps.push(row);
985        weights.push(w_row);
986    }
987
988    // Uniform clamped knot vectors for degree 1 (bilinear interpolation
989    // through the sample grid — control points ARE surface points).
990    let knots_u = uniform_clamped_knots(nu, 1);
991    let knots_v = uniform_clamped_knots(nv, 1);
992
993    NurbsSurface::new(1, 1, knots_u, knots_v, cps, weights)
994}
995
996/// Build a uniform clamped knot vector for `n` control points at the given degree.
997///
998/// Produces `degree+1` zeros, then evenly spaced interior knots, then `degree+1` ones.
999/// For degree-1 NURBS this gives bilinear interpolation through all control points.
1000#[allow(clippy::cast_precision_loss)]
1001fn uniform_clamped_knots(n: usize, degree: usize) -> Vec<f64> {
1002    let mut k = vec![0.0; degree + 1];
1003    for i in 1..n - degree {
1004        k.push(i as f64 / (n - degree) as f64);
1005    }
1006    k.extend(vec![1.0; degree + 1]);
1007    k
1008}
1009
1010#[cfg(test)]
1011#[allow(clippy::unwrap_used, clippy::expect_used)]
1012mod tests;