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    /// This surface with its `u` origin turned `angle` about the axis: the
138    /// same points, its `evaluate(u, v)` being this one's
139    /// `evaluate(u + angle, v)`.
140    #[must_use]
141    pub fn turned(&self, angle: f64) -> Self {
142        let (sin_a, cos_a) = angle.sin_cos();
143        Self {
144            x_axis: self.x_axis * cos_a + self.y_axis * sin_a,
145            y_axis: self.y_axis * cos_a - self.x_axis * sin_a,
146            ..self.clone()
147        }
148    }
149
150    /// Project a 3D point onto the cylinder surface, returning (u, v) parameters.
151    ///
152    /// `u` is the angular parameter [0, 2π), `v` is the axial parameter.
153    #[must_use]
154    pub fn project_point(&self, point: Point3) -> (f64, f64) {
155        let to_pt = Vec3::new(
156            point.x() - self.origin.x(),
157            point.y() - self.origin.y(),
158            point.z() - self.origin.z(),
159        );
160        let v = self.axis.dot(to_pt);
161        let radial = to_pt - self.axis * v;
162        let x = self.x_axis.dot(radial);
163        let y = self.y_axis.dot(radial);
164        let u = y.atan2(x).rem_euclid(std::f64::consts::TAU);
165        (u, v)
166    }
167
168    /// Convert to an exact rational NURBS surface over the given v-range.
169    ///
170    /// Uses degree (2, 1) with 9 control points per ring (standard rational
171    /// representation of a full circle). The result is geometrically exact.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if `NurbsSurface` construction fails.
176    pub fn to_nurbs(&self, v_min: f64, v_max: f64) -> Result<NurbsSurface, MathError> {
177        // 9 CPs for a full circle (degree 2, 4 arcs of 90°).
178        let w1 = std::f64::consts::FRAC_1_SQRT_2;
179        let circle_weights = [1.0, w1, 1.0, w1, 1.0, w1, 1.0, w1, 1.0];
180        // Directions at 0°, 45°, 90°, ... 360° in the (x_axis, y_axis) plane.
181        let dirs: [(f64, f64); 9] = [
182            (1.0, 0.0),
183            (1.0, 1.0),
184            (0.0, 1.0),
185            (-1.0, 1.0),
186            (-1.0, 0.0),
187            (-1.0, -1.0),
188            (0.0, -1.0),
189            (1.0, -1.0),
190            (1.0, 0.0),
191        ];
192
193        let mut cps = Vec::with_capacity(9);
194        let mut ws = Vec::with_capacity(9);
195        for (i, &(dx, dy)) in dirs.iter().enumerate() {
196            let radial = self.x_axis * (self.radius * dx) + self.y_axis * (self.radius * dy);
197            let p_bot = self.origin + radial + self.axis * v_min;
198            let p_top = self.origin + radial + self.axis * v_max;
199            cps.push(vec![p_bot, p_top]);
200            ws.push(vec![circle_weights[i], circle_weights[i]]);
201        }
202
203        let knots_u = vec![
204            0.0, 0.0, 0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0, 1.0, 1.0,
205        ];
206        let knots_v = vec![0.0, 0.0, 1.0, 1.0];
207        NurbsSurface::new(2, 1, knots_u, knots_v, cps, ws)
208    }
209}
210
211/// An infinite conical surface.
212///
213/// Parameterized as `P(u, v) = apex + v*(cos(half_angle)*(cos(u)*x_axis + sin(u)*y_axis) + sin(half_angle)*axis)`
214/// where `u ∈ [0, 2π)` and `v ∈ [0, +∞)`.
215#[derive(Debug, Clone)]
216#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
217pub struct ConicalSurface {
218    apex: Point3,
219    axis: Vec3,
220    half_angle: f64,
221    x_axis: Vec3,
222    y_axis: Vec3,
223}
224
225impl ConicalSurface {
226    /// Creates a new conical surface.
227    ///
228    /// `half_angle` is the angle from the radial plane to the cone's surface
229    /// generator (radians). Small angles produce wide/flat cones; angles near
230    /// π/2 produce narrow/spike cones. In the evaluate formula
231    /// `P(u,v) = apex + v*(cos(a)*radial + sin(a)*axis)`, `a` is this angle.
232    ///
233    /// # Errors
234    /// Returns an error if half-angle is not in `(0, π/2)` or axis is zero.
235    pub fn new(apex: Point3, axis: Vec3, half_angle: f64) -> Result<Self, MathError> {
236        if half_angle <= 0.0 || half_angle >= std::f64::consts::FRAC_PI_2 {
237            return Err(MathError::ParameterOutOfRange {
238                value: half_angle,
239                min: f64::EPSILON,
240                max: std::f64::consts::FRAC_PI_2,
241            });
242        }
243        let f = Frame3::from_normal(apex, axis)?;
244        Ok(Self {
245            apex,
246            axis: f.z,
247            half_angle,
248            x_axis: f.x,
249            y_axis: f.y,
250        })
251    }
252
253    /// Evaluates the surface at parameters `(u, v)`.
254    #[must_use]
255    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
256        let (sin_u, cos_u) = u.sin_cos();
257        let (sin_a, cos_a) = self.half_angle.sin_cos();
258        let radial = self.x_axis * cos_u + self.y_axis * sin_u;
259        self.apex + (radial * cos_a + self.axis * sin_a) * v
260    }
261
262    /// Returns the surface normal at parameters `(u, v)`.
263    #[must_use]
264    pub fn normal(&self, u: f64, _v: f64) -> Vec3 {
265        let (sin_u, cos_u) = u.sin_cos();
266        let (sin_a, cos_a) = self.half_angle.sin_cos();
267        let radial = self.x_axis * cos_u + self.y_axis * sin_u;
268        // Normal points outward: radial * sin(a) - axis * cos(a)
269        radial * sin_a + self.axis * (-cos_a)
270    }
271
272    /// Returns the apex point.
273    #[must_use]
274    pub const fn apex(&self) -> Point3 {
275        self.apex
276    }
277
278    /// Returns the axis direction.
279    #[must_use]
280    pub const fn axis(&self) -> Vec3 {
281        self.axis
282    }
283
284    /// Returns the half-angle in radians.
285    #[must_use]
286    pub const fn half_angle(&self) -> f64 {
287        self.half_angle
288    }
289
290    /// Returns the local X axis (first radial direction in the parametric frame).
291    #[must_use]
292    pub const fn x_axis(&self) -> Vec3 {
293        self.x_axis
294    }
295
296    /// Returns the local Y axis (second radial direction in the parametric frame).
297    #[must_use]
298    pub const fn y_axis(&self) -> Vec3 {
299        self.y_axis
300    }
301
302    /// Creates a conical surface with a specified reference direction.
303    ///
304    /// `ref_dir` defines the x-axis of the parametric frame (projected
305    /// perpendicular to `axis`). This preserves the parametric orientation
306    /// from STEP `AXIS2_PLACEMENT_3D` or BREP round-trips.
307    ///
308    /// # Errors
309    /// Returns an error if half-angle is not in `(0, π/2)` or axis is zero.
310    pub fn with_ref_dir(
311        apex: Point3,
312        axis: Vec3,
313        half_angle: f64,
314        ref_dir: Vec3,
315    ) -> Result<Self, MathError> {
316        if half_angle <= 0.0 || half_angle >= std::f64::consts::FRAC_PI_2 {
317            return Err(MathError::ParameterOutOfRange {
318                value: half_angle,
319                min: f64::EPSILON,
320                max: std::f64::consts::FRAC_PI_2,
321            });
322        }
323        let f = Frame3::from_normal_and_ref(apex, axis, ref_dir)?;
324        Ok(Self {
325            apex,
326            axis: f.z,
327            half_angle,
328            x_axis: f.x,
329            y_axis: f.y,
330        })
331    }
332
333    /// Returns a copy of this cone with its apex translated by `offset`.
334    #[must_use]
335    pub fn translated(&self, offset: Vec3) -> Self {
336        Self {
337            apex: self.apex + offset,
338            ..self.clone()
339        }
340    }
341
342    /// This surface with its `u` origin turned `angle` about the axis: the
343    /// same points, its `evaluate(u, v)` being this one's
344    /// `evaluate(u + angle, v)`.
345    #[must_use]
346    pub fn turned(&self, angle: f64) -> Self {
347        let (sin_a, cos_a) = angle.sin_cos();
348        Self {
349            x_axis: self.x_axis * cos_a + self.y_axis * sin_a,
350            y_axis: self.y_axis * cos_a - self.x_axis * sin_a,
351            ..self.clone()
352        }
353    }
354
355    /// Returns the radius at a given distance `v` along the axis from the apex.
356    #[must_use]
357    pub fn radius_at(&self, v: f64) -> f64 {
358        v * self.half_angle.cos()
359    }
360
361    /// Project a 3D point onto the cone surface, returning `(u, v)` parameters.
362    ///
363    /// `u` is the angular parameter `[0, 2π)`, `v` is the distance from the
364    /// apex along the cone surface generator line.
365    #[must_use]
366    pub fn project_point(&self, point: Point3) -> (f64, f64) {
367        let to_pt = Vec3::new(
368            point.x() - self.apex.x(),
369            point.y() - self.apex.y(),
370            point.z() - self.apex.z(),
371        );
372
373        let h = self.axis.dot(to_pt);
374        let radial = to_pt - self.axis * h;
375        let x = self.x_axis.dot(radial);
376        let y = self.y_axis.dot(radial);
377
378        let u = y.atan2(x).rem_euclid(std::f64::consts::TAU);
379
380        let sin_a = self.half_angle.sin();
381        let v = if sin_a.abs() > 1e-15 {
382            h / sin_a
383        } else {
384            let cos_a = self.half_angle.cos();
385            if cos_a.abs() > 1e-15 {
386                radial.length() / cos_a
387            } else {
388                0.0
389            }
390        };
391
392        (u, v)
393    }
394
395    /// Convert to an approximate NURBS surface over the given v-range.
396    ///
397    /// # Errors
398    ///
399    /// Returns an error if `NurbsSurface` construction fails.
400    pub fn to_nurbs(&self, v_min: f64, v_max: f64) -> Result<NurbsSurface, MathError> {
401        analytic_to_nurbs_sampled(
402            |u, v| self.evaluate(u, v),
403            (0.0, std::f64::consts::TAU),
404            (v_min, v_max),
405        )
406    }
407
408    /// The cone between `v_min` and `v_max` exactly, as a rational NURBS of
409    /// degree (2, 1): the 9-point rational circle at each end, ruled between
410    /// them (a cone's point is linear in `v` along each direction). Its `u`
411    /// is not the cone's angle. The sampled [`Self::to_nurbs`] (32 bilinear
412    /// spans round the axis) sags off the cone by half a percent of its
413    /// radius between columns.
414    ///
415    /// # Errors
416    ///
417    /// Returns an error if `NurbsSurface` construction fails.
418    pub fn to_rational_nurbs(&self, v_min: f64, v_max: f64) -> Result<NurbsSurface, MathError> {
419        let w1 = std::f64::consts::FRAC_1_SQRT_2;
420        let circle_weights = [1.0, w1, 1.0, w1, 1.0, w1, 1.0, w1, 1.0];
421        let dirs: [(f64, f64); 9] = [
422            (1.0, 0.0),
423            (1.0, 1.0),
424            (0.0, 1.0),
425            (-1.0, 1.0),
426            (-1.0, 0.0),
427            (-1.0, -1.0),
428            (0.0, -1.0),
429            (1.0, -1.0),
430            (1.0, 0.0),
431        ];
432        let (sin_a, cos_a) = self.half_angle.sin_cos();
433        let ring = |v: f64, dx: f64, dy: f64| {
434            let rho = v * cos_a;
435            self.apex
436                + self.axis * (v * sin_a)
437                + self.x_axis * (rho * dx)
438                + self.y_axis * (rho * dy)
439        };
440        let mut cps = Vec::with_capacity(9);
441        let mut ws = Vec::with_capacity(9);
442        for (i, &(dx, dy)) in dirs.iter().enumerate() {
443            cps.push(vec![ring(v_min, dx, dy), ring(v_max, dx, dy)]);
444            ws.push(vec![circle_weights[i], circle_weights[i]]);
445        }
446        let knots_u = vec![
447            0.0, 0.0, 0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0, 1.0, 1.0,
448        ];
449        let knots_v = vec![0.0, 0.0, 1.0, 1.0];
450        NurbsSurface::new(2, 1, knots_u, knots_v, cps, ws)
451    }
452}
453
454/// An infinite spherical surface (actually a sphere).
455///
456/// Parameterized as `P(u, v) = center + radius*(cos(v)*cos(u)*x + cos(v)*sin(u)*y + sin(v)*z)`
457/// where `u ∈ [0, 2π)` (longitude) and `v ∈ [-π/2, π/2]` (latitude).
458#[derive(Debug, Clone)]
459#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
460pub struct SphericalSurface {
461    center: Point3,
462    radius: f64,
463    x_axis: Vec3,
464    y_axis: Vec3,
465    z_axis: Vec3,
466}
467
468impl SphericalSurface {
469    /// Creates a new spherical surface.
470    ///
471    /// # Errors
472    /// Returns an error if radius is not positive.
473    pub fn new(center: Point3, radius: f64) -> Result<Self, MathError> {
474        if radius <= 0.0 {
475            return Err(MathError::ParameterOutOfRange {
476                value: radius,
477                min: f64::EPSILON,
478                max: f64::MAX,
479            });
480        }
481        Ok(Self {
482            center,
483            radius,
484            x_axis: Vec3::new(1.0, 0.0, 0.0),
485            y_axis: Vec3::new(0.0, 1.0, 0.0),
486            z_axis: Vec3::new(0.0, 0.0, 1.0),
487        })
488    }
489
490    /// Creates a spherical surface with a custom orientation.
491    ///
492    /// # Errors
493    /// Returns an error if radius is not positive or the z-axis is zero.
494    pub fn with_axis(center: Point3, radius: f64, z_axis: Vec3) -> Result<Self, MathError> {
495        if radius <= 0.0 {
496            return Err(MathError::ParameterOutOfRange {
497                value: radius,
498                min: f64::EPSILON,
499                max: f64::MAX,
500            });
501        }
502        let f = Frame3::from_normal(center, z_axis)?;
503        Ok(Self {
504            center,
505            radius,
506            x_axis: f.x,
507            y_axis: f.y,
508            z_axis: f.z,
509        })
510    }
511
512    /// Creates a spherical surface with an explicit pole axis and reference
513    /// direction.
514    ///
515    /// `ref_dir` defines the x-axis of the local frame (projected
516    /// perpendicular to `z_axis`), which fixes where `u = 0` lies, as
517    /// [`ToroidalSurface::with_axis_and_ref_dir`] does for a torus.
518    ///
519    /// # Errors
520    /// Returns an error if radius is not positive or `z_axis` is zero.
521    pub fn with_axis_and_ref_dir(
522        center: Point3,
523        radius: f64,
524        z_axis: Vec3,
525        ref_dir: Vec3,
526    ) -> Result<Self, MathError> {
527        if radius <= 0.0 {
528            return Err(MathError::ParameterOutOfRange {
529                value: radius,
530                min: f64::EPSILON,
531                max: f64::MAX,
532            });
533        }
534        let f = Frame3::from_normal_and_ref(center, z_axis, ref_dir)?;
535        Ok(Self {
536            center,
537            radius,
538            x_axis: f.x,
539            y_axis: f.y,
540            z_axis: f.z,
541        })
542    }
543
544    /// Evaluates the surface at parameters `(u, v)`.
545    #[must_use]
546    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
547        let (sin_u, cos_u) = u.sin_cos();
548        let (sin_v, cos_v) = v.sin_cos();
549        self.center
550            + self.x_axis * (self.radius * cos_v * cos_u)
551            + self.y_axis * (self.radius * cos_v * sin_u)
552            + self.z_axis * (self.radius * sin_v)
553    }
554
555    /// Returns the outward normal at parameters `(u, v)`.
556    #[must_use]
557    pub fn normal(&self, u: f64, v: f64) -> Vec3 {
558        let (sin_u, cos_u) = u.sin_cos();
559        let (sin_v, cos_v) = v.sin_cos();
560        self.x_axis * (cos_v * cos_u) + self.y_axis * (cos_v * sin_u) + self.z_axis * sin_v
561    }
562
563    /// Returns the center.
564    #[must_use]
565    pub const fn center(&self) -> Point3 {
566        self.center
567    }
568
569    /// Returns the radius.
570    #[must_use]
571    pub const fn radius(&self) -> f64 {
572        self.radius
573    }
574
575    /// Returns the local X axis.
576    #[must_use]
577    pub const fn x_axis(&self) -> Vec3 {
578        self.x_axis
579    }
580
581    /// Returns the local Y axis.
582    #[must_use]
583    pub const fn y_axis(&self) -> Vec3 {
584        self.y_axis
585    }
586
587    /// Returns the local Z axis (pole direction).
588    #[must_use]
589    pub const fn z_axis(&self) -> Vec3 {
590        self.z_axis
591    }
592
593    /// Returns a copy of this sphere with its center translated by `offset`.
594    #[must_use]
595    pub fn translated(&self, offset: Vec3) -> Self {
596        Self {
597            center: self.center + offset,
598            ..self.clone()
599        }
600    }
601
602    /// This surface with its `u` origin turned `angle` about the axis: the
603    /// same points, its `evaluate(u, v)` being this one's
604    /// `evaluate(u + angle, v)`.
605    #[must_use]
606    pub fn turned(&self, angle: f64) -> Self {
607        let (sin_a, cos_a) = angle.sin_cos();
608        Self {
609            x_axis: self.x_axis * cos_a + self.y_axis * sin_a,
610            y_axis: self.y_axis * cos_a - self.x_axis * sin_a,
611            ..self.clone()
612        }
613    }
614
615    /// Axis-aligned bounding box of the full sphere (`center ± radius` on every
616    /// axis). Orientation-independent and a sound superset of any spherical
617    /// patch — the boolean broad-phase uses it because a face's boundary-only
618    /// bbox misses the surface bulge between its boundary edges (a hemisphere's
619    /// only boundary is its equator).
620    #[must_use]
621    pub fn aabb(&self) -> Aabb3 {
622        let r = self.radius;
623        Aabb3 {
624            min: Point3::new(
625                self.center.x() - r,
626                self.center.y() - r,
627                self.center.z() - r,
628            ),
629            max: Point3::new(
630                self.center.x() + r,
631                self.center.y() + r,
632                self.center.z() + r,
633            ),
634        }
635    }
636
637    /// Axis-aligned bounding box of the hemisphere on the `pole`-axis side —
638    /// the half-ball `{center + radius·d : d·pole ≥ 0}`. A tight, sound superset
639    /// of any spherical patch lying on that side; the boolean broad-phase uses
640    /// it so that one hemisphere face's box does not admit the other's sections
641    /// (the full-sphere `aabb` would). Falls back to the full sphere when `pole`
642    /// is degenerate (side ambiguous).
643    #[must_use]
644    pub fn aabb_region(&self, pole: Vec3) -> Aabb3 {
645        let Ok(n) = pole.normalize() else {
646            return self.aabb();
647        };
648        let c = self.center;
649        let r = self.radius;
650        // For world axis ê, the extreme of d·ê over {d·n ≥ 0, |d| = 1} is 1 when
651        // ê·n ≥ 0 (the axis itself is in the half-space), else the equator-plane
652        // projection √(1 − (ê·n)²); the minimum is the mirror image.
653        let span = |en: f64| -> (f64, f64) {
654            let s = (1.0 - en * en).max(0.0).sqrt();
655            let hi = if en >= 0.0 { 1.0 } else { s };
656            let lo = if en <= 0.0 { -1.0 } else { -s };
657            (lo, hi)
658        };
659        let (lx, hx) = span(n.x());
660        let (ly, hy) = span(n.y());
661        let (lz, hz) = span(n.z());
662        Aabb3 {
663            min: Point3::new(c.x() + r * lx, c.y() + r * ly, c.z() + r * lz),
664            max: Point3::new(c.x() + r * hx, c.y() + r * hy, c.z() + r * hz),
665        }
666    }
667
668    /// Project a 3D point onto the sphere, returning (u, v) parameters.
669    ///
670    /// `u` is the longitudinal angle [0, 2π), `v` is the latitude [-π/2, π/2].
671    #[must_use]
672    pub fn project_point(&self, point: Point3) -> (f64, f64) {
673        let to_pt = Vec3::new(
674            point.x() - self.center.x(),
675            point.y() - self.center.y(),
676            point.z() - self.center.z(),
677        );
678        let r = to_pt.length();
679        if r < 1e-15 {
680            return (0.0, 0.0);
681        }
682        let x = self.x_axis.dot(to_pt);
683        let y = self.y_axis.dot(to_pt);
684        let z = self.z_axis.dot(to_pt);
685        let u = y.atan2(x).rem_euclid(std::f64::consts::TAU);
686        let v = (z / r).clamp(-1.0, 1.0).asin();
687        (u, v)
688    }
689
690    /// Convert to an approximate NURBS surface.
691    ///
692    /// # Errors
693    ///
694    /// Returns an error if `NurbsSurface` construction fails.
695    pub fn to_nurbs(&self) -> Result<NurbsSurface, MathError> {
696        analytic_to_nurbs_sampled(
697            |u, v| self.evaluate(u, v),
698            (0.0, std::f64::consts::TAU),
699            (-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2),
700        )
701    }
702
703    /// The sphere exactly, as a rational biquadratic NURBS: the 5-point
704    /// rational half circle from pole to pole swept round the 9-point
705    /// rational circle about the axis. Its parameters are not the sphere's
706    /// angles. The sampled [`Self::to_nurbs`] (eight bilinear spans from pole
707    /// to pole) sags off it by up to 2.4% of the radius.
708    ///
709    /// # Errors
710    ///
711    /// Returns an error if `NurbsSurface` construction fails.
712    pub fn to_rational_nurbs(&self) -> Result<NurbsSurface, MathError> {
713        use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, TAU};
714        let round: [(f64, f64); 9] = [
715            (1.0, 0.0),
716            (1.0, 1.0),
717            (0.0, 1.0),
718            (-1.0, 1.0),
719            (-1.0, 0.0),
720            (-1.0, -1.0),
721            (0.0, -1.0),
722            (1.0, -1.0),
723            (1.0, 0.0),
724        ];
725        let meridian: [(f64, f64); 5] =
726            [(0.0, -1.0), (1.0, -1.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
727        let weight = |i: usize| if i % 2 == 1 { FRAC_1_SQRT_2 } else { 1.0 };
728        let r = self.radius;
729        let mut cps = Vec::with_capacity(9);
730        let mut weights = Vec::with_capacity(9);
731        for (i, &(ux, uy)) in round.iter().enumerate() {
732            let mut row = Vec::with_capacity(5);
733            let mut w_row = Vec::with_capacity(5);
734            for (j, &(rho, z)) in meridian.iter().enumerate() {
735                row.push(
736                    self.center
737                        + self.x_axis * (ux * rho * r)
738                        + self.y_axis * (uy * rho * r)
739                        + self.z_axis * (z * r),
740                );
741                w_row.push(weight(i) * weight(j));
742            }
743            cps.push(row);
744            weights.push(w_row);
745        }
746        let knots_u = vec![
747            0.0,
748            0.0,
749            0.0,
750            TAU * 0.25,
751            TAU * 0.25,
752            TAU * 0.5,
753            TAU * 0.5,
754            TAU * 0.75,
755            TAU * 0.75,
756            TAU,
757            TAU,
758            TAU,
759        ];
760        let knots_v = vec![
761            -FRAC_PI_2, -FRAC_PI_2, -FRAC_PI_2, 0.0, 0.0, FRAC_PI_2, FRAC_PI_2, FRAC_PI_2,
762        ];
763        NurbsSurface::new(2, 2, knots_u, knots_v, cps, weights)
764    }
765}
766
767/// A toroidal surface.
768///
769/// Parameterized as `P(u, v) = center + (R + r*cos(v))*(cos(u)*x + sin(u)*y) + r*sin(v)*z`
770/// where `R` is the major radius, `r` is the minor radius,
771/// `u ∈ [0, 2π)` (around the tube) and `v ∈ [0, 2π)` (around the cross-section).
772#[derive(Debug, Clone)]
773#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
774pub struct ToroidalSurface {
775    center: Point3,
776    major_radius: f64,
777    minor_radius: f64,
778    x_axis: Vec3,
779    y_axis: Vec3,
780    z_axis: Vec3,
781}
782
783impl ToroidalSurface {
784    /// Creates a new toroidal surface.
785    ///
786    /// # Errors
787    /// Returns an error if either radius is not positive or `minor_radius > major_radius`.
788    pub fn new(center: Point3, major_radius: f64, minor_radius: f64) -> Result<Self, MathError> {
789        if major_radius <= 0.0 {
790            return Err(MathError::ParameterOutOfRange {
791                value: major_radius,
792                min: f64::EPSILON,
793                max: f64::MAX,
794            });
795        }
796        if minor_radius <= 0.0 {
797            return Err(MathError::ParameterOutOfRange {
798                value: minor_radius,
799                min: f64::EPSILON,
800                max: f64::MAX,
801            });
802        }
803        Ok(Self {
804            center,
805            major_radius,
806            minor_radius,
807            x_axis: Vec3::new(1.0, 0.0, 0.0),
808            y_axis: Vec3::new(0.0, 1.0, 0.0),
809            z_axis: Vec3::new(0.0, 0.0, 1.0),
810        })
811    }
812
813    /// Creates a toroidal surface with a specified axis direction.
814    ///
815    /// The axis is the central symmetry axis of the torus. The local
816    /// coordinate frame is derived from it.
817    ///
818    /// # Errors
819    /// Returns an error if either radius is not positive or axis is zero.
820    pub fn with_axis(
821        center: Point3,
822        major_radius: f64,
823        minor_radius: f64,
824        z_axis: Vec3,
825    ) -> Result<Self, MathError> {
826        if major_radius <= 0.0 {
827            return Err(MathError::ParameterOutOfRange {
828                value: major_radius,
829                min: f64::EPSILON,
830                max: f64::MAX,
831            });
832        }
833        if minor_radius <= 0.0 {
834            return Err(MathError::ParameterOutOfRange {
835                value: minor_radius,
836                min: f64::EPSILON,
837                max: f64::MAX,
838            });
839        }
840        let f = Frame3::from_normal(center, z_axis)?;
841        Ok(Self {
842            center,
843            major_radius,
844            minor_radius,
845            x_axis: f.x,
846            y_axis: f.y,
847            z_axis: f.z,
848        })
849    }
850
851    /// Create a torus with explicit axis and reference direction.
852    ///
853    /// `ref_dir` defines the x-axis of the local frame (projected
854    /// perpendicular to `z_axis`). This preserves the parametric
855    /// orientation from STEP `AXIS2_PLACEMENT_3D`.
856    ///
857    /// # Errors
858    ///
859    /// Returns [`MathError::ParameterOutOfRange`] if either radius is
860    /// non-positive, or [`MathError::ZeroVector`] if `z_axis` is zero.
861    ///
862    /// # Panics
863    ///
864    /// Panics if the fallback perpendicular vector cannot be normalized
865    /// (should not occur for any valid unit `z_axis`).
866    pub fn with_axis_and_ref_dir(
867        center: Point3,
868        major_radius: f64,
869        minor_radius: f64,
870        z_axis: Vec3,
871        ref_dir: Vec3,
872    ) -> Result<Self, MathError> {
873        if major_radius <= 0.0 {
874            return Err(MathError::ParameterOutOfRange {
875                value: major_radius,
876                min: f64::EPSILON,
877                max: f64::MAX,
878            });
879        }
880        if minor_radius <= 0.0 {
881            return Err(MathError::ParameterOutOfRange {
882                value: minor_radius,
883                min: f64::EPSILON,
884                max: f64::MAX,
885            });
886        }
887        let f = Frame3::from_normal_and_ref(center, z_axis, ref_dir)?;
888        Ok(Self {
889            center,
890            major_radius,
891            minor_radius,
892            x_axis: f.x,
893            y_axis: f.y,
894            z_axis: f.z,
895        })
896    }
897
898    /// Evaluates the surface at parameters `(u, v)`.
899    #[must_use]
900    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
901        let (sin_u, cos_u) = u.sin_cos();
902        let (sin_v, cos_v) = v.sin_cos();
903        let tube_radius = self.minor_radius.mul_add(cos_v, self.major_radius);
904        self.center
905            + self.x_axis * (tube_radius * cos_u)
906            + self.y_axis * (tube_radius * sin_u)
907            + self.z_axis * (self.minor_radius * sin_v)
908    }
909
910    /// Returns the outward surface normal at parameters `(u, v)`.
911    #[must_use]
912    pub fn normal(&self, u: f64, v: f64) -> Vec3 {
913        let (sin_u, cos_u) = u.sin_cos();
914        let (sin_v, cos_v) = v.sin_cos();
915        let radial = self.x_axis * cos_u + self.y_axis * sin_u;
916        radial * cos_v + self.z_axis * sin_v
917    }
918
919    /// Returns the center.
920    #[must_use]
921    pub const fn center(&self) -> Point3 {
922        self.center
923    }
924
925    /// Returns a copy of this torus with its center translated by `offset`.
926    #[must_use]
927    pub fn translated(&self, offset: Vec3) -> Self {
928        Self {
929            center: self.center + offset,
930            ..self.clone()
931        }
932    }
933
934    /// This surface with its `u` origin turned `angle` about the axis: the
935    /// same points, its `evaluate(u, v)` being this one's
936    /// `evaluate(u + angle, v)`.
937    #[must_use]
938    pub fn turned(&self, angle: f64) -> Self {
939        let (sin_a, cos_a) = angle.sin_cos();
940        Self {
941            x_axis: self.x_axis * cos_a + self.y_axis * sin_a,
942            y_axis: self.y_axis * cos_a - self.x_axis * sin_a,
943            ..self.clone()
944        }
945    }
946
947    /// Axis-aligned bounding box of the full torus. The half-extent along each
948    /// world axis sums the ring's projection onto the plane perpendicular to the
949    /// torus axis (`(major+minor)·‖(x·ê, y·ê)‖`) and the tube's projection onto
950    /// the axis (`minor·|z·ê|`); `x/y/z_axis` are orthonormal. A sound superset
951    /// used by the boolean broad-phase — a full torus's boundary is degenerate
952    /// seam points, so a boundary-only bbox collapses to a point.
953    #[must_use]
954    pub fn aabb(&self) -> Aabb3 {
955        let rr = self.major_radius + self.minor_radius;
956        let r = self.minor_radius;
957        let hx = rr * self.x_axis.x().hypot(self.y_axis.x()) + r * self.z_axis.x().abs();
958        let hy = rr * self.x_axis.y().hypot(self.y_axis.y()) + r * self.z_axis.y().abs();
959        let hz = rr * self.x_axis.z().hypot(self.y_axis.z()) + r * self.z_axis.z().abs();
960        Aabb3 {
961            min: Point3::new(
962                self.center.x() - hx,
963                self.center.y() - hy,
964                self.center.z() - hz,
965            ),
966            max: Point3::new(
967                self.center.x() + hx,
968                self.center.y() + hy,
969                self.center.z() + hz,
970            ),
971        }
972    }
973
974    /// Returns the major radius (distance from center to tube center).
975    #[must_use]
976    pub const fn major_radius(&self) -> f64 {
977        self.major_radius
978    }
979
980    /// Returns the minor radius (tube cross-section radius).
981    #[must_use]
982    pub const fn minor_radius(&self) -> f64 {
983        self.minor_radius
984    }
985
986    /// Returns the local X axis.
987    #[must_use]
988    pub const fn x_axis(&self) -> Vec3 {
989        self.x_axis
990    }
991
992    /// Returns the local Y axis.
993    #[must_use]
994    pub const fn y_axis(&self) -> Vec3 {
995        self.y_axis
996    }
997
998    /// Returns the torus axis direction (perpendicular to the ring plane).
999    #[must_use]
1000    pub const fn z_axis(&self) -> Vec3 {
1001        self.z_axis
1002    }
1003
1004    /// Project a 3D point onto the torus surface, returning `(u, v)` parameters.
1005    ///
1006    /// `u ∈ [0, 2π)` is the angle around the major circle.
1007    /// `v ∈ [0, 2π)` is the angle around the tube cross-section.
1008    #[must_use]
1009    pub fn project_point(&self, point: Point3) -> (f64, f64) {
1010        let to_pt = Vec3::new(
1011            point.x() - self.center.x(),
1012            point.y() - self.center.y(),
1013            point.z() - self.center.z(),
1014        );
1015
1016        let x_comp = self.x_axis.dot(to_pt);
1017        let y_comp = self.y_axis.dot(to_pt);
1018        let u = y_comp.atan2(x_comp).rem_euclid(std::f64::consts::TAU);
1019
1020        let (sin_u, cos_u) = u.sin_cos();
1021        let tube_center = self.center
1022            + self.x_axis * (self.major_radius * cos_u)
1023            + self.y_axis * (self.major_radius * sin_u);
1024
1025        let to_tube = Vec3::new(
1026            point.x() - tube_center.x(),
1027            point.y() - tube_center.y(),
1028            point.z() - tube_center.z(),
1029        );
1030
1031        let radial_dir = self.x_axis * cos_u + self.y_axis * sin_u;
1032        let r_comp = radial_dir.dot(to_tube);
1033        let z_comp = self.z_axis.dot(to_tube);
1034
1035        let v = z_comp.atan2(r_comp).rem_euclid(std::f64::consts::TAU);
1036        (u, v)
1037    }
1038
1039    /// Convert to an approximate NURBS surface.
1040    ///
1041    /// # Errors
1042    ///
1043    /// Returns an error if `NurbsSurface` construction fails.
1044    pub fn to_nurbs(&self) -> Result<NurbsSurface, MathError> {
1045        analytic_to_nurbs_sampled(
1046            |u, v| self.evaluate(u, v),
1047            (0.0, std::f64::consts::TAU),
1048            (0.0, std::f64::consts::TAU),
1049        )
1050    }
1051
1052    /// The torus exactly, as a rational biquadratic NURBS: the tube's
1053    /// circle swept round the ring's, each the 9-point rational circle.
1054    /// Its parameters are not the torus's angles. The sampled
1055    /// [`Self::to_nurbs`] (eight bilinear spans round the tube) sags off it
1056    /// by 7.6% of the tube's radius between rows.
1057    ///
1058    /// # Errors
1059    ///
1060    /// Returns an error if `NurbsSurface` construction fails.
1061    pub fn to_rational_nurbs(&self) -> Result<NurbsSurface, MathError> {
1062        use std::f64::consts::{FRAC_1_SQRT_2, TAU};
1063        let dirs: [(f64, f64); 9] = [
1064            (1.0, 0.0),
1065            (1.0, 1.0),
1066            (0.0, 1.0),
1067            (-1.0, 1.0),
1068            (-1.0, 0.0),
1069            (-1.0, -1.0),
1070            (0.0, -1.0),
1071            (1.0, -1.0),
1072            (1.0, 0.0),
1073        ];
1074        let weight = |i: usize| if i % 2 == 1 { FRAC_1_SQRT_2 } else { 1.0 };
1075        let mut cps = Vec::with_capacity(9);
1076        let mut weights = Vec::with_capacity(9);
1077        for (i, &(ux, uy)) in dirs.iter().enumerate() {
1078            let mut row = Vec::with_capacity(9);
1079            let mut w_row = Vec::with_capacity(9);
1080            for (j, &(dx, dy)) in dirs.iter().enumerate() {
1081                let radial = dx.mul_add(self.minor_radius, self.major_radius);
1082                row.push(
1083                    self.center
1084                        + self.x_axis * (ux * radial)
1085                        + self.y_axis * (uy * radial)
1086                        + self.z_axis * (dy * self.minor_radius),
1087                );
1088                w_row.push(weight(i) * weight(j));
1089            }
1090            cps.push(row);
1091            weights.push(w_row);
1092        }
1093        let knots = vec![
1094            0.0,
1095            0.0,
1096            0.0,
1097            TAU * 0.25,
1098            TAU * 0.25,
1099            TAU * 0.5,
1100            TAU * 0.5,
1101            TAU * 0.75,
1102            TAU * 0.75,
1103            TAU,
1104            TAU,
1105            TAU,
1106        ];
1107        NurbsSurface::new(2, 2, knots.clone(), knots, cps, weights)
1108    }
1109}
1110
1111/// A surface of revolution created by revolving a curve around an axis.
1112///
1113/// Parameterized as `P(u, v) = origin + (curve(v) ⊗ rotation(u, axis))`
1114/// where `u ∈ [0, 2π)` is the revolution angle and `v` parameterizes
1115/// the generatrix curve.
1116#[derive(Debug, Clone)]
1117pub struct RevolutionSurface {
1118    origin: Point3,
1119    axis: Vec3,
1120    x_axis: Vec3,
1121    y_axis: Vec3,
1122    /// The generatrix (meridian) curve in the `(distance_from_axis, height)` plane.
1123    generatrix_radii: Vec<f64>,
1124    generatrix_heights: Vec<f64>,
1125}
1126
1127impl RevolutionSurface {
1128    /// Creates a surface of revolution from a set of meridian profile points.
1129    ///
1130    /// Each point `(radius, height)` defines the generatrix in the rotation plane.
1131    ///
1132    /// # Errors
1133    /// Returns an error if the profile is empty or the axis is zero.
1134    pub fn new(
1135        origin: Point3,
1136        axis: Vec3,
1137        radii: Vec<f64>,
1138        heights: Vec<f64>,
1139    ) -> Result<Self, MathError> {
1140        if radii.is_empty() || heights.is_empty() {
1141            return Err(MathError::EmptyInput);
1142        }
1143        if radii.len() != heights.len() {
1144            return Err(MathError::InvalidWeights {
1145                expected: radii.len(),
1146                got: heights.len(),
1147            });
1148        }
1149        let f = Frame3::from_normal(origin, axis)?;
1150        Ok(Self {
1151            origin,
1152            axis: f.z,
1153            x_axis: f.x,
1154            y_axis: f.y,
1155            generatrix_radii: radii,
1156            generatrix_heights: heights,
1157        })
1158    }
1159
1160    /// Evaluates at `(u, v)` where `u` is the revolution angle and `v ∈ [0, 1]`
1161    /// parameterizes the generatrix via linear interpolation.
1162    #[must_use]
1163    #[allow(
1164        clippy::cast_precision_loss,
1165        clippy::cast_possible_truncation,
1166        clippy::cast_sign_loss
1167    )]
1168    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
1169        let num_pts = self.generatrix_radii.len();
1170        let param = v.clamp(0.0, 1.0) * (num_pts - 1) as f64;
1171        let idx = (param as usize).min(num_pts - 2);
1172        let frac = param - idx as f64;
1173
1174        let r = frac.mul_add(
1175            self.generatrix_radii[idx + 1] - self.generatrix_radii[idx],
1176            self.generatrix_radii[idx],
1177        );
1178        let height = frac.mul_add(
1179            self.generatrix_heights[idx + 1] - self.generatrix_heights[idx],
1180            self.generatrix_heights[idx],
1181        );
1182
1183        let (sin_u, cos_u) = u.sin_cos();
1184        self.origin + self.x_axis * (r * cos_u) + self.y_axis * (r * sin_u) + self.axis * height
1185    }
1186
1187    /// Returns the origin.
1188    #[must_use]
1189    pub const fn origin(&self) -> Point3 {
1190        self.origin
1191    }
1192
1193    /// Returns the axis.
1194    #[must_use]
1195    pub const fn axis(&self) -> Vec3 {
1196        self.axis
1197    }
1198}
1199
1200// ---------------------------------------------------------------------------
1201// Analytic → NURBS conversion helper
1202// ---------------------------------------------------------------------------
1203
1204/// Sample an analytic surface on a grid and build a degree (1,1) NURBS surface.
1205///
1206/// APPROXIMATE: piecewise-bilinear interpolation through a 33×9 grid.
1207/// Max chord-height error ≈ 0.5% of surface radius (R × (1-cos(π/32))).
1208/// Used only for intersection seed-finding — the output face retains
1209/// the original analytic `FaceSurface`, so final geometry is exact.
1210fn analytic_to_nurbs_sampled(
1211    surface_fn: impl Fn(f64, f64) -> Point3,
1212    u_range: (f64, f64),
1213    v_range: (f64, f64),
1214) -> Result<NurbsSurface, MathError> {
1215    // Dense sampling reduces chord-height error. For angular coordinates
1216    // (u on cylinder/sphere), 32 spans → max error R*(1-cos(π/32)) ≈ 0.005*R.
1217    // For v (latitude/height), 8 spans keeps error under 0.02*R.
1218    let nu = 33;
1219    let nv = 9;
1220
1221    let mut cps = Vec::with_capacity(nu);
1222    let mut weights = Vec::with_capacity(nu);
1223
1224    #[allow(clippy::cast_precision_loss)]
1225    for iu in 0..nu {
1226        let u = u_range.0 + (u_range.1 - u_range.0) * (iu as f64 / (nu - 1) as f64);
1227        let mut row = Vec::with_capacity(nv);
1228        let mut w_row = Vec::with_capacity(nv);
1229        for iv in 0..nv {
1230            let v = v_range.0 + (v_range.1 - v_range.0) * (iv as f64 / (nv - 1) as f64);
1231            row.push(surface_fn(u, v));
1232            w_row.push(1.0);
1233        }
1234        cps.push(row);
1235        weights.push(w_row);
1236    }
1237
1238    // Uniform clamped knot vectors for degree 1 (bilinear interpolation
1239    // through the sample grid — control points ARE surface points).
1240    let knots_u = uniform_clamped_knots(nu, 1);
1241    let knots_v = uniform_clamped_knots(nv, 1);
1242
1243    NurbsSurface::new(1, 1, knots_u, knots_v, cps, weights)
1244}
1245
1246/// Build a uniform clamped knot vector for `n` control points at the given degree.
1247///
1248/// Produces `degree+1` zeros, then evenly spaced interior knots, then `degree+1` ones.
1249/// For degree-1 NURBS this gives bilinear interpolation through all control points.
1250#[allow(clippy::cast_precision_loss)]
1251fn uniform_clamped_knots(n: usize, degree: usize) -> Vec<f64> {
1252    let mut k = vec![0.0; degree + 1];
1253    for i in 1..n - degree {
1254        k.push(i as f64 / (n - degree) as f64);
1255    }
1256    k.extend(vec![1.0; degree + 1]);
1257    k
1258}
1259
1260#[cfg(test)]
1261#[allow(clippy::unwrap_used, clippy::expect_used)]
1262mod tests;