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
704/// A toroidal surface.
705///
706/// Parameterized as `P(u, v) = center + (R + r*cos(v))*(cos(u)*x + sin(u)*y) + r*sin(v)*z`
707/// where `R` is the major radius, `r` is the minor radius,
708/// `u ∈ [0, 2π)` (around the tube) and `v ∈ [0, 2π)` (around the cross-section).
709#[derive(Debug, Clone)]
710#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
711pub struct ToroidalSurface {
712    center: Point3,
713    major_radius: f64,
714    minor_radius: f64,
715    x_axis: Vec3,
716    y_axis: Vec3,
717    z_axis: Vec3,
718}
719
720impl ToroidalSurface {
721    /// Creates a new toroidal surface.
722    ///
723    /// # Errors
724    /// Returns an error if either radius is not positive or `minor_radius > major_radius`.
725    pub fn new(center: Point3, major_radius: f64, minor_radius: f64) -> Result<Self, MathError> {
726        if major_radius <= 0.0 {
727            return Err(MathError::ParameterOutOfRange {
728                value: major_radius,
729                min: f64::EPSILON,
730                max: f64::MAX,
731            });
732        }
733        if minor_radius <= 0.0 {
734            return Err(MathError::ParameterOutOfRange {
735                value: minor_radius,
736                min: f64::EPSILON,
737                max: f64::MAX,
738            });
739        }
740        Ok(Self {
741            center,
742            major_radius,
743            minor_radius,
744            x_axis: Vec3::new(1.0, 0.0, 0.0),
745            y_axis: Vec3::new(0.0, 1.0, 0.0),
746            z_axis: Vec3::new(0.0, 0.0, 1.0),
747        })
748    }
749
750    /// Creates a toroidal surface with a specified axis direction.
751    ///
752    /// The axis is the central symmetry axis of the torus. The local
753    /// coordinate frame is derived from it.
754    ///
755    /// # Errors
756    /// Returns an error if either radius is not positive or axis is zero.
757    pub fn with_axis(
758        center: Point3,
759        major_radius: f64,
760        minor_radius: f64,
761        z_axis: Vec3,
762    ) -> Result<Self, MathError> {
763        if major_radius <= 0.0 {
764            return Err(MathError::ParameterOutOfRange {
765                value: major_radius,
766                min: f64::EPSILON,
767                max: f64::MAX,
768            });
769        }
770        if minor_radius <= 0.0 {
771            return Err(MathError::ParameterOutOfRange {
772                value: minor_radius,
773                min: f64::EPSILON,
774                max: f64::MAX,
775            });
776        }
777        let f = Frame3::from_normal(center, z_axis)?;
778        Ok(Self {
779            center,
780            major_radius,
781            minor_radius,
782            x_axis: f.x,
783            y_axis: f.y,
784            z_axis: f.z,
785        })
786    }
787
788    /// Create a torus with explicit axis and reference direction.
789    ///
790    /// `ref_dir` defines the x-axis of the local frame (projected
791    /// perpendicular to `z_axis`). This preserves the parametric
792    /// orientation from STEP `AXIS2_PLACEMENT_3D`.
793    ///
794    /// # Errors
795    ///
796    /// Returns [`MathError::ParameterOutOfRange`] if either radius is
797    /// non-positive, or [`MathError::ZeroVector`] if `z_axis` is zero.
798    ///
799    /// # Panics
800    ///
801    /// Panics if the fallback perpendicular vector cannot be normalized
802    /// (should not occur for any valid unit `z_axis`).
803    pub fn with_axis_and_ref_dir(
804        center: Point3,
805        major_radius: f64,
806        minor_radius: f64,
807        z_axis: Vec3,
808        ref_dir: Vec3,
809    ) -> Result<Self, MathError> {
810        if major_radius <= 0.0 {
811            return Err(MathError::ParameterOutOfRange {
812                value: major_radius,
813                min: f64::EPSILON,
814                max: f64::MAX,
815            });
816        }
817        if minor_radius <= 0.0 {
818            return Err(MathError::ParameterOutOfRange {
819                value: minor_radius,
820                min: f64::EPSILON,
821                max: f64::MAX,
822            });
823        }
824        let f = Frame3::from_normal_and_ref(center, z_axis, ref_dir)?;
825        Ok(Self {
826            center,
827            major_radius,
828            minor_radius,
829            x_axis: f.x,
830            y_axis: f.y,
831            z_axis: f.z,
832        })
833    }
834
835    /// Evaluates the surface at parameters `(u, v)`.
836    #[must_use]
837    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
838        let (sin_u, cos_u) = u.sin_cos();
839        let (sin_v, cos_v) = v.sin_cos();
840        let tube_radius = self.minor_radius.mul_add(cos_v, self.major_radius);
841        self.center
842            + self.x_axis * (tube_radius * cos_u)
843            + self.y_axis * (tube_radius * sin_u)
844            + self.z_axis * (self.minor_radius * sin_v)
845    }
846
847    /// Returns the outward surface normal at parameters `(u, v)`.
848    #[must_use]
849    pub fn normal(&self, u: f64, v: f64) -> Vec3 {
850        let (sin_u, cos_u) = u.sin_cos();
851        let (sin_v, cos_v) = v.sin_cos();
852        let radial = self.x_axis * cos_u + self.y_axis * sin_u;
853        radial * cos_v + self.z_axis * sin_v
854    }
855
856    /// Returns the center.
857    #[must_use]
858    pub const fn center(&self) -> Point3 {
859        self.center
860    }
861
862    /// Returns a copy of this torus with its center translated by `offset`.
863    #[must_use]
864    pub fn translated(&self, offset: Vec3) -> Self {
865        Self {
866            center: self.center + offset,
867            ..self.clone()
868        }
869    }
870
871    /// This surface with its `u` origin turned `angle` about the axis: the
872    /// same points, its `evaluate(u, v)` being this one's
873    /// `evaluate(u + angle, v)`.
874    #[must_use]
875    pub fn turned(&self, angle: f64) -> Self {
876        let (sin_a, cos_a) = angle.sin_cos();
877        Self {
878            x_axis: self.x_axis * cos_a + self.y_axis * sin_a,
879            y_axis: self.y_axis * cos_a - self.x_axis * sin_a,
880            ..self.clone()
881        }
882    }
883
884    /// Axis-aligned bounding box of the full torus. The half-extent along each
885    /// world axis sums the ring's projection onto the plane perpendicular to the
886    /// torus axis (`(major+minor)·‖(x·ê, y·ê)‖`) and the tube's projection onto
887    /// the axis (`minor·|z·ê|`); `x/y/z_axis` are orthonormal. A sound superset
888    /// used by the boolean broad-phase — a full torus's boundary is degenerate
889    /// seam points, so a boundary-only bbox collapses to a point.
890    #[must_use]
891    pub fn aabb(&self) -> Aabb3 {
892        let rr = self.major_radius + self.minor_radius;
893        let r = self.minor_radius;
894        let hx = rr * self.x_axis.x().hypot(self.y_axis.x()) + r * self.z_axis.x().abs();
895        let hy = rr * self.x_axis.y().hypot(self.y_axis.y()) + r * self.z_axis.y().abs();
896        let hz = rr * self.x_axis.z().hypot(self.y_axis.z()) + r * self.z_axis.z().abs();
897        Aabb3 {
898            min: Point3::new(
899                self.center.x() - hx,
900                self.center.y() - hy,
901                self.center.z() - hz,
902            ),
903            max: Point3::new(
904                self.center.x() + hx,
905                self.center.y() + hy,
906                self.center.z() + hz,
907            ),
908        }
909    }
910
911    /// Returns the major radius (distance from center to tube center).
912    #[must_use]
913    pub const fn major_radius(&self) -> f64 {
914        self.major_radius
915    }
916
917    /// Returns the minor radius (tube cross-section radius).
918    #[must_use]
919    pub const fn minor_radius(&self) -> f64 {
920        self.minor_radius
921    }
922
923    /// Returns the local X axis.
924    #[must_use]
925    pub const fn x_axis(&self) -> Vec3 {
926        self.x_axis
927    }
928
929    /// Returns the local Y axis.
930    #[must_use]
931    pub const fn y_axis(&self) -> Vec3 {
932        self.y_axis
933    }
934
935    /// Returns the torus axis direction (perpendicular to the ring plane).
936    #[must_use]
937    pub const fn z_axis(&self) -> Vec3 {
938        self.z_axis
939    }
940
941    /// Project a 3D point onto the torus surface, returning `(u, v)` parameters.
942    ///
943    /// `u ∈ [0, 2π)` is the angle around the major circle.
944    /// `v ∈ [0, 2π)` is the angle around the tube cross-section.
945    #[must_use]
946    pub fn project_point(&self, point: Point3) -> (f64, f64) {
947        let to_pt = Vec3::new(
948            point.x() - self.center.x(),
949            point.y() - self.center.y(),
950            point.z() - self.center.z(),
951        );
952
953        let x_comp = self.x_axis.dot(to_pt);
954        let y_comp = self.y_axis.dot(to_pt);
955        let u = y_comp.atan2(x_comp).rem_euclid(std::f64::consts::TAU);
956
957        let (sin_u, cos_u) = u.sin_cos();
958        let tube_center = self.center
959            + self.x_axis * (self.major_radius * cos_u)
960            + self.y_axis * (self.major_radius * sin_u);
961
962        let to_tube = Vec3::new(
963            point.x() - tube_center.x(),
964            point.y() - tube_center.y(),
965            point.z() - tube_center.z(),
966        );
967
968        let radial_dir = self.x_axis * cos_u + self.y_axis * sin_u;
969        let r_comp = radial_dir.dot(to_tube);
970        let z_comp = self.z_axis.dot(to_tube);
971
972        let v = z_comp.atan2(r_comp).rem_euclid(std::f64::consts::TAU);
973        (u, v)
974    }
975
976    /// Convert to an approximate NURBS surface.
977    ///
978    /// # Errors
979    ///
980    /// Returns an error if `NurbsSurface` construction fails.
981    pub fn to_nurbs(&self) -> Result<NurbsSurface, MathError> {
982        analytic_to_nurbs_sampled(
983            |u, v| self.evaluate(u, v),
984            (0.0, std::f64::consts::TAU),
985            (0.0, std::f64::consts::TAU),
986        )
987    }
988
989    /// The torus exactly, as a rational biquadratic NURBS: the tube's
990    /// circle swept round the ring's, each the 9-point rational circle.
991    /// Its parameters are not the torus's angles. The sampled
992    /// [`Self::to_nurbs`] (eight bilinear spans round the tube) sags off it
993    /// by 7.6% of the tube's radius between rows.
994    ///
995    /// # Errors
996    ///
997    /// Returns an error if `NurbsSurface` construction fails.
998    pub fn to_rational_nurbs(&self) -> Result<NurbsSurface, MathError> {
999        use std::f64::consts::{FRAC_1_SQRT_2, TAU};
1000        let dirs: [(f64, f64); 9] = [
1001            (1.0, 0.0),
1002            (1.0, 1.0),
1003            (0.0, 1.0),
1004            (-1.0, 1.0),
1005            (-1.0, 0.0),
1006            (-1.0, -1.0),
1007            (0.0, -1.0),
1008            (1.0, -1.0),
1009            (1.0, 0.0),
1010        ];
1011        let weight = |i: usize| if i % 2 == 1 { FRAC_1_SQRT_2 } else { 1.0 };
1012        let mut cps = Vec::with_capacity(9);
1013        let mut weights = Vec::with_capacity(9);
1014        for (i, &(ux, uy)) in dirs.iter().enumerate() {
1015            let mut row = Vec::with_capacity(9);
1016            let mut w_row = Vec::with_capacity(9);
1017            for (j, &(dx, dy)) in dirs.iter().enumerate() {
1018                let radial = dx.mul_add(self.minor_radius, self.major_radius);
1019                row.push(
1020                    self.center
1021                        + self.x_axis * (ux * radial)
1022                        + self.y_axis * (uy * radial)
1023                        + self.z_axis * (dy * self.minor_radius),
1024                );
1025                w_row.push(weight(i) * weight(j));
1026            }
1027            cps.push(row);
1028            weights.push(w_row);
1029        }
1030        let knots = vec![
1031            0.0,
1032            0.0,
1033            0.0,
1034            TAU * 0.25,
1035            TAU * 0.25,
1036            TAU * 0.5,
1037            TAU * 0.5,
1038            TAU * 0.75,
1039            TAU * 0.75,
1040            TAU,
1041            TAU,
1042            TAU,
1043        ];
1044        NurbsSurface::new(2, 2, knots.clone(), knots, cps, weights)
1045    }
1046}
1047
1048/// A surface of revolution created by revolving a curve around an axis.
1049///
1050/// Parameterized as `P(u, v) = origin + (curve(v) ⊗ rotation(u, axis))`
1051/// where `u ∈ [0, 2π)` is the revolution angle and `v` parameterizes
1052/// the generatrix curve.
1053#[derive(Debug, Clone)]
1054pub struct RevolutionSurface {
1055    origin: Point3,
1056    axis: Vec3,
1057    x_axis: Vec3,
1058    y_axis: Vec3,
1059    /// The generatrix (meridian) curve in the `(distance_from_axis, height)` plane.
1060    generatrix_radii: Vec<f64>,
1061    generatrix_heights: Vec<f64>,
1062}
1063
1064impl RevolutionSurface {
1065    /// Creates a surface of revolution from a set of meridian profile points.
1066    ///
1067    /// Each point `(radius, height)` defines the generatrix in the rotation plane.
1068    ///
1069    /// # Errors
1070    /// Returns an error if the profile is empty or the axis is zero.
1071    pub fn new(
1072        origin: Point3,
1073        axis: Vec3,
1074        radii: Vec<f64>,
1075        heights: Vec<f64>,
1076    ) -> Result<Self, MathError> {
1077        if radii.is_empty() || heights.is_empty() {
1078            return Err(MathError::EmptyInput);
1079        }
1080        if radii.len() != heights.len() {
1081            return Err(MathError::InvalidWeights {
1082                expected: radii.len(),
1083                got: heights.len(),
1084            });
1085        }
1086        let f = Frame3::from_normal(origin, axis)?;
1087        Ok(Self {
1088            origin,
1089            axis: f.z,
1090            x_axis: f.x,
1091            y_axis: f.y,
1092            generatrix_radii: radii,
1093            generatrix_heights: heights,
1094        })
1095    }
1096
1097    /// Evaluates at `(u, v)` where `u` is the revolution angle and `v ∈ [0, 1]`
1098    /// parameterizes the generatrix via linear interpolation.
1099    #[must_use]
1100    #[allow(
1101        clippy::cast_precision_loss,
1102        clippy::cast_possible_truncation,
1103        clippy::cast_sign_loss
1104    )]
1105    pub fn evaluate(&self, u: f64, v: f64) -> Point3 {
1106        let num_pts = self.generatrix_radii.len();
1107        let param = v.clamp(0.0, 1.0) * (num_pts - 1) as f64;
1108        let idx = (param as usize).min(num_pts - 2);
1109        let frac = param - idx as f64;
1110
1111        let r = frac.mul_add(
1112            self.generatrix_radii[idx + 1] - self.generatrix_radii[idx],
1113            self.generatrix_radii[idx],
1114        );
1115        let height = frac.mul_add(
1116            self.generatrix_heights[idx + 1] - self.generatrix_heights[idx],
1117            self.generatrix_heights[idx],
1118        );
1119
1120        let (sin_u, cos_u) = u.sin_cos();
1121        self.origin + self.x_axis * (r * cos_u) + self.y_axis * (r * sin_u) + self.axis * height
1122    }
1123
1124    /// Returns the origin.
1125    #[must_use]
1126    pub const fn origin(&self) -> Point3 {
1127        self.origin
1128    }
1129
1130    /// Returns the axis.
1131    #[must_use]
1132    pub const fn axis(&self) -> Vec3 {
1133        self.axis
1134    }
1135}
1136
1137// ---------------------------------------------------------------------------
1138// Analytic → NURBS conversion helper
1139// ---------------------------------------------------------------------------
1140
1141/// Sample an analytic surface on a grid and build a degree (1,1) NURBS surface.
1142///
1143/// APPROXIMATE: piecewise-bilinear interpolation through a 33×9 grid.
1144/// Max chord-height error ≈ 0.5% of surface radius (R × (1-cos(π/32))).
1145/// Used only for intersection seed-finding — the output face retains
1146/// the original analytic `FaceSurface`, so final geometry is exact.
1147fn analytic_to_nurbs_sampled(
1148    surface_fn: impl Fn(f64, f64) -> Point3,
1149    u_range: (f64, f64),
1150    v_range: (f64, f64),
1151) -> Result<NurbsSurface, MathError> {
1152    // Dense sampling reduces chord-height error. For angular coordinates
1153    // (u on cylinder/sphere), 32 spans → max error R*(1-cos(π/32)) ≈ 0.005*R.
1154    // For v (latitude/height), 8 spans keeps error under 0.02*R.
1155    let nu = 33;
1156    let nv = 9;
1157
1158    let mut cps = Vec::with_capacity(nu);
1159    let mut weights = Vec::with_capacity(nu);
1160
1161    #[allow(clippy::cast_precision_loss)]
1162    for iu in 0..nu {
1163        let u = u_range.0 + (u_range.1 - u_range.0) * (iu as f64 / (nu - 1) as f64);
1164        let mut row = Vec::with_capacity(nv);
1165        let mut w_row = Vec::with_capacity(nv);
1166        for iv in 0..nv {
1167            let v = v_range.0 + (v_range.1 - v_range.0) * (iv as f64 / (nv - 1) as f64);
1168            row.push(surface_fn(u, v));
1169            w_row.push(1.0);
1170        }
1171        cps.push(row);
1172        weights.push(w_row);
1173    }
1174
1175    // Uniform clamped knot vectors for degree 1 (bilinear interpolation
1176    // through the sample grid — control points ARE surface points).
1177    let knots_u = uniform_clamped_knots(nu, 1);
1178    let knots_v = uniform_clamped_knots(nv, 1);
1179
1180    NurbsSurface::new(1, 1, knots_u, knots_v, cps, weights)
1181}
1182
1183/// Build a uniform clamped knot vector for `n` control points at the given degree.
1184///
1185/// Produces `degree+1` zeros, then evenly spaced interior knots, then `degree+1` ones.
1186/// For degree-1 NURBS this gives bilinear interpolation through all control points.
1187#[allow(clippy::cast_precision_loss)]
1188fn uniform_clamped_knots(n: usize, degree: usize) -> Vec<f64> {
1189    let mut k = vec![0.0; degree + 1];
1190    for i in 1..n - degree {
1191        k.push(i as f64 / (n - degree) as f64);
1192    }
1193    k.extend(vec![1.0; degree + 1]);
1194    k
1195}
1196
1197#[cfg(test)]
1198#[allow(clippy::unwrap_used, clippy::expect_used)]
1199mod tests;