Skip to main content

brepkit_math/
curves.rs

1//! Analytic 3D curve types: lines, circles, and ellipses.
2//!
3//! These provide exact evaluation (no NURBS approximation) for the
4//! most common curve types in CAD.
5
6use std::f64::consts::PI;
7
8use crate::MathError;
9use crate::frame::Frame3;
10use crate::vec::{Point3, Vec3};
11
12// ── Line3D ─────────────────────────────────────────────────────────
13
14/// A 3D line defined by origin and direction.
15///
16/// Parameterized as `P(t) = origin + t * direction`.
17#[derive(Debug, Clone)]
18pub struct Line3D {
19    origin: Point3,
20    direction: Vec3,
21}
22
23impl Line3D {
24    /// Create a new line.
25    ///
26    /// # Errors
27    ///
28    /// Returns an error if `direction` is zero-length.
29    pub fn new(origin: Point3, direction: Vec3) -> Result<Self, MathError> {
30        let len = direction.length();
31        if len < 1e-15 {
32            return Err(MathError::ZeroVector);
33        }
34        Ok(Self {
35            origin,
36            direction: Vec3::new(
37                direction.x() / len,
38                direction.y() / len,
39                direction.z() / len,
40            ),
41        })
42    }
43
44    /// Evaluate the line at parameter `t`.
45    #[must_use]
46    pub fn evaluate(&self, t: f64) -> Point3 {
47        self.origin + self.direction * t
48    }
49
50    /// The tangent direction (constant for a line).
51    #[must_use]
52    pub const fn tangent(&self) -> Vec3 {
53        self.direction
54    }
55
56    /// Project a point onto the line, returning the parameter.
57    #[must_use]
58    pub fn project(&self, point: Point3) -> f64 {
59        let v = point - self.origin;
60        self.direction.dot(v)
61    }
62
63    /// Distance from a point to the line.
64    #[must_use]
65    pub fn distance_to_point(&self, point: Point3) -> f64 {
66        let v = point - self.origin;
67        let proj = self.direction * self.direction.dot(v);
68        (v - proj).length()
69    }
70
71    /// The line origin.
72    #[must_use]
73    pub const fn origin(&self) -> Point3 {
74        self.origin
75    }
76
77    /// The unit direction.
78    #[must_use]
79    pub const fn direction(&self) -> Vec3 {
80        self.direction
81    }
82}
83
84// ── Circle3D ───────────────────────────────────────────────────────
85
86/// A 3D circle defined by center, normal (axis), and radius.
87///
88/// Parameterized as `P(t) = center + radius*(cos(t)*u + sin(t)*v)`
89/// where `u` and `v` form an orthonormal basis in the circle plane.
90/// `t` ranges from 0 to 2π for a full circle.
91#[derive(Debug, Clone)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93pub struct Circle3D {
94    center: Point3,
95    normal: Vec3,
96    radius: f64,
97    u_axis: Vec3,
98    v_axis: Vec3,
99}
100
101impl Circle3D {
102    /// Create a new circle.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if `radius` is non-positive or `normal` is zero.
107    pub fn new(center: Point3, normal: Vec3, radius: f64) -> Result<Self, MathError> {
108        if radius <= 0.0 {
109            return Err(MathError::ParameterOutOfRange {
110                value: radius,
111                min: 0.0,
112                max: f64::INFINITY,
113            });
114        }
115        let f = Frame3::from_normal(center, normal)?;
116        Ok(Self {
117            center,
118            normal: f.z,
119            radius,
120            u_axis: f.x,
121            v_axis: f.y,
122        })
123    }
124
125    /// Create a new circle with a caller-supplied reference x-direction.
126    ///
127    /// `ref_dir` is projected onto the plane perpendicular to `normal` to
128    /// produce `u_axis`. Circles are radially symmetric so the choice of
129    /// `u_axis` has no geometric effect — but it does fix the seam vertex
130    /// at `evaluate(0.0)`, which downstream code (closed-edge construction,
131    /// PCurve computation) can depend on.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if `radius` is non-positive or `normal` is zero.
136    pub fn new_with_ref(
137        center: Point3,
138        normal: Vec3,
139        radius: f64,
140        ref_dir: Vec3,
141    ) -> Result<Self, MathError> {
142        if radius <= 0.0 {
143            return Err(MathError::ParameterOutOfRange {
144                value: radius,
145                min: 0.0,
146                max: f64::INFINITY,
147            });
148        }
149        let f = Frame3::from_normal_and_ref(center, normal, ref_dir)?;
150        Ok(Self {
151            center,
152            normal: f.z,
153            radius,
154            u_axis: f.x,
155            v_axis: f.y,
156        })
157    }
158
159    /// Evaluate the circle at angle `t` (radians).
160    #[must_use]
161    pub fn evaluate(&self, t: f64) -> Point3 {
162        let cos_t = t.cos();
163        let sin_t = t.sin();
164        self.center + self.u_axis * (self.radius * cos_t) + self.v_axis * (self.radius * sin_t)
165    }
166
167    /// Tangent at angle `t` (unit-length).
168    #[must_use]
169    pub fn tangent(&self, t: f64) -> Vec3 {
170        let cos_t = t.cos();
171        let sin_t = t.sin();
172        self.u_axis * (-sin_t) + self.v_axis * cos_t
173    }
174
175    /// The circle circumference.
176    #[must_use]
177    pub fn circumference(&self) -> f64 {
178        2.0 * PI * self.radius
179    }
180
181    /// The circle center.
182    #[must_use]
183    pub const fn center(&self) -> Point3 {
184        self.center
185    }
186
187    /// The circle radius.
188    #[must_use]
189    pub const fn radius(&self) -> f64 {
190        self.radius
191    }
192
193    /// The circle normal (axis direction).
194    #[must_use]
195    pub const fn normal(&self) -> Vec3 {
196        self.normal
197    }
198
199    /// Project a point onto the circle, returning the angle parameter.
200    #[must_use]
201    pub fn project(&self, point: Point3) -> f64 {
202        let v = point - self.center;
203        let u_comp = self.u_axis.dot(v);
204        let v_comp = self.v_axis.dot(v);
205        v_comp.atan2(u_comp)
206    }
207
208    /// The u-axis direction (major axis in the circle plane).
209    #[must_use]
210    pub const fn u_axis(&self) -> Vec3 {
211        self.u_axis
212    }
213
214    /// The v-axis direction (minor axis in the circle plane).
215    #[must_use]
216    pub const fn v_axis(&self) -> Vec3 {
217        self.v_axis
218    }
219
220    /// Create a circle with explicit basis vectors (for transform/copy).
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if `radius` is non-positive.
225    pub fn with_axes(
226        center: Point3,
227        normal: Vec3,
228        radius: f64,
229        u_axis: Vec3,
230        v_axis: Vec3,
231    ) -> Result<Self, MathError> {
232        if radius <= 0.0 {
233            return Err(MathError::ParameterOutOfRange {
234                value: radius,
235                min: 0.0,
236                max: f64::INFINITY,
237            });
238        }
239        Ok(Self {
240            center,
241            normal,
242            radius,
243            u_axis,
244            v_axis,
245        })
246    }
247
248    /// Intersect the circle with a 3D line segment.
249    ///
250    /// Returns up to 2 intersection points along with their angle parameter
251    /// `t` on the circle. Points returned are restricted to the segment
252    /// `[seg_start, seg_end]` (with `tol` slack on the endpoints).
253    ///
254    /// Cases:
255    /// - Segment crosses the circle's plane at one point: at most 1
256    ///   intersection (when that crossing is on the circle, within `tol`).
257    /// - Segment lies in the circle's plane: up to 2 intersections.
258    /// - Segment is parallel to the plane but offset: 0 intersections.
259    ///
260    /// `tol` is the absolute linear tolerance for "on the plane" and
261    /// "on the circle" tests, and for clamping the segment parameter.
262    #[must_use]
263    pub fn intersect_segment(
264        &self,
265        seg_start: Point3,
266        seg_end: Point3,
267        tol: f64,
268    ) -> Vec<(Point3, f64)> {
269        let mut out = Vec::new();
270        let d = seg_end - seg_start;
271        let seg_len_sq = d.length_squared();
272        if seg_len_sq < tol * tol {
273            return out;
274        }
275
276        // Signed distance of each endpoint to the circle's plane.
277        let h0 = (seg_start - self.center).dot(self.normal);
278        let h1 = (seg_end - self.center).dot(self.normal);
279
280        let on_plane = |p: Point3| -> bool {
281            let v = p - self.center;
282            let in_plane = v.dot(self.normal).abs() < tol;
283            let r = v.length();
284            in_plane && (r - self.radius).abs() < tol
285        };
286
287        // Helper: append `t_seg` (segment parameter) → intersection point with
288        // `tol` slack on the endpoints; drop duplicates within `tol`.
289        let mut push_if_unique = |p: Point3| {
290            let v = p - self.center;
291            // angle in [0, 2π)
292            let mut t = v.dot(self.v_axis).atan2(v.dot(self.u_axis));
293            if t < 0.0 {
294                t += std::f64::consts::TAU;
295            }
296            if out
297                .iter()
298                .any(|(q, _): &(Point3, f64)| (*q - p).length() < tol)
299            {
300                return;
301            }
302            out.push((p, t));
303        };
304
305        if h0.abs() < tol && h1.abs() < tol {
306            // Segment lies in the circle's plane: solve 2D line-circle.
307            // Project everything into UV coordinates centered at the circle.
308            let p0_u = (seg_start - self.center).dot(self.u_axis);
309            let p0_v = (seg_start - self.center).dot(self.v_axis);
310            let p1_u = (seg_end - self.center).dot(self.u_axis);
311            let p1_v = (seg_end - self.center).dot(self.v_axis);
312            let du = p1_u - p0_u;
313            let dv = p1_v - p0_v;
314            // |P0 + s*(P1-P0)|² = r²
315            // a*s² + 2*b*s + c = 0 where
316            //   a = du² + dv²
317            //   b = p0_u*du + p0_v*dv
318            //   c = p0_u² + p0_v² - r²
319            let a = du * du + dv * dv;
320            let b = p0_u * du + p0_v * dv;
321            let c = p0_u * p0_u + p0_v * p0_v - self.radius * self.radius;
322            let disc = b * b - a * c;
323            // `disc` has units of length^4 (it's b² - a·c, both products of
324            // squared coordinates). Compare against a scale-aware threshold
325            // `(tol² · a)` rather than raw `tol` (which is length).
326            // Negative discriminants smaller than this in magnitude are
327            // floating-point noise on a tangent intersection — clamp to 0.
328            if a < tol * tol || disc < -tol * tol * a {
329                return out;
330            }
331            let disc = disc.max(0.0);
332            let s_slack = tol / seg_len_sq.sqrt();
333            // Near-tangent collapse. The two roots straddle the foot of the
334            // circle center on the line by half_chord = sqrt(disc/a); the
335            // line's penetration into the circle is δ ≈ half_chord²/(2r).
336            // When δ ≤ tol the configuration is tangent AT TOLERANCE and the
337            // separate roots are conditioning noise (position error grows as
338            // sqrt(2rδ): a 1e-13 residual at r=4 already shifts each root a
339            // full micron, minting near-duplicate vertices next to an exact
340            // tangency vertex). Emit the well-conditioned double root — the
341            // foot itself — instead of the noise pair.
342            let sqrt_disc = disc.sqrt();
343            let roots: &[f64] = if disc <= 2.0 * self.radius * tol * a {
344                &[-b / a]
345            } else {
346                &[(-b - sqrt_disc) / a, (-b + sqrt_disc) / a]
347            };
348            for &s in roots {
349                if s >= -s_slack && s <= 1.0 + s_slack {
350                    let s = s.clamp(0.0, 1.0);
351                    let p = Point3::new(
352                        seg_start.x() + s * d.x(),
353                        seg_start.y() + s * d.y(),
354                        seg_start.z() + s * d.z(),
355                    );
356                    push_if_unique(p);
357                }
358            }
359        } else if h0 * h1 <= tol * tol {
360            // Segment crosses the circle's plane (or touches it). Solve
361            // for the unique s where signed-distance = 0:
362            //   h0 + s*(h1 - h0) = 0  →  s = h0 / (h0 - h1)
363            let denom = h0 - h1;
364            if denom.abs() < tol {
365                return out;
366            }
367            let s = h0 / denom;
368            let s_slack = tol / seg_len_sq.sqrt();
369            if s < -s_slack || s > 1.0 + s_slack {
370                return out;
371            }
372            let s = s.clamp(0.0, 1.0);
373            let p = Point3::new(
374                seg_start.x() + s * d.x(),
375                seg_start.y() + s * d.y(),
376                seg_start.z() + s * d.z(),
377            );
378            if on_plane(p) {
379                push_if_unique(p);
380            }
381        }
382        // else: segment is on one side of the plane → no crossings.
383
384        out
385    }
386
387    /// Intersect the circle with another COPLANAR circle.
388    ///
389    /// Returns up to 2 intersection points along with their angle parameter
390    /// `t` on `self`. Non-coplanar pairs (skew or offset planes) and
391    /// coincident/concentric pairs return no points — callers own those
392    /// configurations separately.
393    ///
394    /// Near-tangent conditioning: when the circles graze (the chord implied
395    /// by the root pair penetrates by less than `tol`), the two roots are
396    /// noise straddling the tangency foot — position error grows as
397    /// `sqrt(2·r·δ)`, the recurring tangential-contact class. The
398    /// well-conditioned double root (the foot on the center line) is emitted
399    /// instead of the pair.
400    #[must_use]
401    pub fn intersect_circle(&self, other: &Self, tol: f64) -> Vec<(Point3, f64)> {
402        let mut out = Vec::new();
403        if self.normal.cross(other.normal).length() > 1e-9 {
404            return out; // Skew planes — not this primitive's case.
405        }
406        let dvec = other.center - self.center;
407        if dvec.dot(self.normal).abs() > tol {
408            return out; // Parallel but offset planes.
409        }
410        let du = dvec.dot(self.u_axis);
411        let dv = dvec.dot(self.v_axis);
412        let d2 = du * du + dv * dv;
413        let d = d2.sqrt();
414        if d < tol {
415            return out; // Concentric (incl. coincident) — no discrete crossings.
416        }
417        let (r1, r2) = (self.radius, other.radius);
418        let a = (d2 + r1 * r1 - r2 * r2) / (2.0 * d);
419        let h2 = r1 * r1 - a * a;
420        let r_eff = r1.min(r2);
421        if h2 < -2.0 * r_eff * tol {
422            return out; // Separated (or nested) beyond the tangency well.
423        }
424        let ux = Vec3::new(
425            (self.u_axis.x() * du + self.v_axis.x() * dv) / d,
426            (self.u_axis.y() * du + self.v_axis.y() * dv) / d,
427            (self.u_axis.z() * du + self.v_axis.z() * dv) / d,
428        );
429        let vx = self.normal.cross(ux);
430        let foot = self.center + ux * a;
431        let mut push = |p: Point3| {
432            let v = p - self.center;
433            let mut t = v.dot(self.v_axis).atan2(v.dot(self.u_axis));
434            if t < 0.0 {
435                t += std::f64::consts::TAU;
436            }
437            if !out
438                .iter()
439                .any(|(q, _): &(Point3, f64)| (*q - p).length() < tol)
440            {
441                out.push((p, t));
442            }
443        };
444        if h2 <= 2.0 * r_eff * tol {
445            push(foot);
446        } else {
447            let h = h2.sqrt();
448            push(foot + vx * h);
449            push(foot - vx * h);
450        }
451        out
452    }
453}
454
455// ── Ellipse3D ──────────────────────────────────────────────────────
456
457/// A 3D ellipse defined by center, normal, and two semi-axis lengths.
458///
459/// Parameterized as `P(t) = center + a*cos(t)*u + b*sin(t)*v`.
460#[derive(Debug, Clone)]
461#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
462pub struct Ellipse3D {
463    center: Point3,
464    normal: Vec3,
465    semi_major: f64,
466    semi_minor: f64,
467    u_axis: Vec3,
468    v_axis: Vec3,
469}
470
471impl Ellipse3D {
472    /// Create a new ellipse.
473    ///
474    /// `semi_major` is the larger radius, `semi_minor` the smaller.
475    /// The major axis lies along the `u_axis` direction (computed from normal).
476    ///
477    /// # Errors
478    ///
479    /// Returns an error if either semi-axis is non-positive.
480    pub fn new(
481        center: Point3,
482        normal: Vec3,
483        semi_major: f64,
484        semi_minor: f64,
485    ) -> Result<Self, MathError> {
486        if semi_major <= 0.0 || semi_minor <= 0.0 {
487            return Err(MathError::ParameterOutOfRange {
488                value: semi_major.min(semi_minor),
489                min: 0.0,
490                max: f64::INFINITY,
491            });
492        }
493        if semi_minor > semi_major {
494            return Err(MathError::ParameterOutOfRange {
495                value: semi_minor,
496                min: 0.0,
497                max: semi_major,
498            });
499        }
500        let f = Frame3::from_normal(center, normal)?;
501        Ok(Self {
502            center,
503            normal: f.z,
504            semi_major,
505            semi_minor,
506            u_axis: f.x,
507            v_axis: f.y,
508        })
509    }
510
511    /// Create a new ellipse with a caller-supplied reference major-axis direction.
512    ///
513    /// `ref_dir` is projected onto the plane perpendicular to `normal` to
514    /// produce `u_axis` (which carries the `semi_major` extent). If
515    /// `ref_dir` is parallel to `normal`, falls back to an arbitrary
516    /// perpendicular choice per [`Frame3::from_normal_and_ref`].
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if either semi-axis is non-positive, `semi_minor`
521    /// exceeds `semi_major`, or `normal` is zero.
522    pub fn new_with_ref(
523        center: Point3,
524        normal: Vec3,
525        semi_major: f64,
526        semi_minor: f64,
527        ref_dir: Vec3,
528    ) -> Result<Self, MathError> {
529        if semi_major <= 0.0 || semi_minor <= 0.0 {
530            return Err(MathError::ParameterOutOfRange {
531                value: semi_major.min(semi_minor),
532                min: 0.0,
533                max: f64::INFINITY,
534            });
535        }
536        if semi_minor > semi_major {
537            return Err(MathError::ParameterOutOfRange {
538                value: semi_minor,
539                min: 0.0,
540                max: semi_major,
541            });
542        }
543        let f = Frame3::from_normal_and_ref(center, normal, ref_dir)?;
544        Ok(Self {
545            center,
546            normal: f.z,
547            semi_major,
548            semi_minor,
549            u_axis: f.x,
550            v_axis: f.y,
551        })
552    }
553
554    /// Evaluate the ellipse at angle `t`.
555    #[must_use]
556    pub fn evaluate(&self, t: f64) -> Point3 {
557        let cos_t = t.cos();
558        let sin_t = t.sin();
559        self.center
560            + self.u_axis * (self.semi_major * cos_t)
561            + self.v_axis * (self.semi_minor * sin_t)
562    }
563
564    /// Tangent at angle `t` (not unit-length).
565    #[must_use]
566    pub fn tangent(&self, t: f64) -> Vec3 {
567        let cos_t = t.cos();
568        let sin_t = t.sin();
569        self.u_axis * (-self.semi_major * sin_t) + self.v_axis * (self.semi_minor * cos_t)
570    }
571
572    /// The ellipse center.
573    #[must_use]
574    pub const fn center(&self) -> Point3 {
575        self.center
576    }
577
578    /// Semi-major axis length.
579    #[must_use]
580    pub const fn semi_major(&self) -> f64 {
581        self.semi_major
582    }
583
584    /// Semi-minor axis length.
585    #[must_use]
586    pub const fn semi_minor(&self) -> f64 {
587        self.semi_minor
588    }
589
590    /// The ellipse normal (axis direction).
591    #[must_use]
592    pub const fn normal(&self) -> Vec3 {
593        self.normal
594    }
595
596    /// Approximate circumference using Ramanujan's formula.
597    #[must_use]
598    pub fn approximate_circumference(&self) -> f64 {
599        let a = self.semi_major;
600        let b = self.semi_minor;
601        let h = (a - b) * (a - b) / ((a + b) * (a + b));
602        PI * (a + b) * (1.0 + 3.0 * h / (10.0 + (3.0f64.mul_add(-h, 4.0)).sqrt()))
603    }
604
605    /// Project a point onto the ellipse, returning the angle parameter.
606    #[must_use]
607    pub fn project(&self, point: Point3) -> f64 {
608        let v = point - self.center;
609        let u_comp = self.u_axis.dot(v) / self.semi_major;
610        let v_comp = self.v_axis.dot(v) / self.semi_minor;
611        v_comp.atan2(u_comp)
612    }
613
614    /// The u-axis direction (major axis direction).
615    #[must_use]
616    pub const fn u_axis(&self) -> Vec3 {
617        self.u_axis
618    }
619
620    /// The v-axis direction (minor axis direction).
621    #[must_use]
622    pub const fn v_axis(&self) -> Vec3 {
623        self.v_axis
624    }
625
626    /// Create an ellipse with explicit basis vectors (for transform/copy).
627    ///
628    /// # Errors
629    ///
630    /// Returns an error if either semi-axis is non-positive.
631    pub fn with_axes(
632        center: Point3,
633        normal: Vec3,
634        semi_major: f64,
635        semi_minor: f64,
636        u_axis: Vec3,
637        v_axis: Vec3,
638    ) -> Result<Self, MathError> {
639        if semi_major <= 0.0 || semi_minor <= 0.0 {
640            return Err(MathError::ParameterOutOfRange {
641                value: semi_major.min(semi_minor),
642                min: 0.0,
643                max: f64::INFINITY,
644            });
645        }
646        Ok(Self {
647            center,
648            normal,
649            semi_major,
650            semi_minor,
651            u_axis,
652            v_axis,
653        })
654    }
655}
656
657/// A 3D parabola defined by vertex, axis direction, and focal length.
658///
659/// Parameterized as `P(t) = vertex + (t²/(4f)) * axis_dir + t * u_axis`
660/// where `f` is the focal length and `u_axis` is perpendicular to the axis
661/// in the parabola plane.
662///
663/// The parameter `t` ranges over all reals; `t = 0` is the vertex.
664#[derive(Debug, Clone)]
665pub struct Parabola3D {
666    vertex: Point3,
667    axis_dir: Vec3,
668    focal_length: f64,
669    u_axis: Vec3,
670}
671
672impl Parabola3D {
673    /// Creates a new parabola.
674    ///
675    /// `axis_dir` is the direction from vertex toward the interior of the
676    /// parabola (the axis of symmetry). `focal_length` is the distance
677    /// from vertex to focus.
678    ///
679    /// # Errors
680    /// Returns an error if `focal_length` is not positive or `axis_dir` is zero.
681    pub fn new(vertex: Point3, axis_dir: Vec3, focal_length: f64) -> Result<Self, MathError> {
682        if focal_length <= 0.0 {
683            return Err(MathError::ParameterOutOfRange {
684                value: focal_length,
685                min: f64::EPSILON,
686                max: f64::MAX,
687            });
688        }
689        let f = Frame3::from_normal(vertex, axis_dir)?;
690        Ok(Self {
691            vertex,
692            axis_dir: f.z,
693            focal_length,
694            u_axis: f.x,
695        })
696    }
697
698    /// Evaluates the parabola at parameter `t`.
699    ///
700    /// At `t = 0` this returns the vertex.
701    #[must_use]
702    pub fn evaluate(&self, t: f64) -> Point3 {
703        let along_axis = (t * t) / (4.0 * self.focal_length);
704        self.vertex + self.axis_dir * along_axis + self.u_axis * t
705    }
706
707    /// Returns the tangent vector at parameter `t`.
708    #[must_use]
709    pub fn tangent(&self, t: f64) -> Vec3 {
710        let d_axis = t / (2.0 * self.focal_length);
711        self.axis_dir * d_axis + self.u_axis
712    }
713
714    /// Returns the curvature at parameter `t`.
715    #[must_use]
716    pub fn curvature(&self, t: f64) -> f64 {
717        let two_f = 2.0 * self.focal_length;
718        let ratio = t / two_f;
719        let denom = ratio.mul_add(ratio, 1.0);
720        1.0 / (two_f * denom.powf(1.5))
721    }
722
723    /// Returns the vertex.
724    #[must_use]
725    pub const fn vertex(&self) -> Point3 {
726        self.vertex
727    }
728
729    /// Returns the focal length.
730    #[must_use]
731    pub const fn focal_length(&self) -> f64 {
732        self.focal_length
733    }
734
735    /// Returns the axis direction (normalized).
736    #[must_use]
737    pub const fn axis_dir(&self) -> Vec3 {
738        self.axis_dir
739    }
740
741    /// Returns the in-plane u-axis (perpendicular to `axis_dir`).
742    /// At parameter `t`, the parabola is offset by `t * u_axis` from
743    /// the symmetry axis.
744    #[must_use]
745    pub const fn u_axis(&self) -> Vec3 {
746        self.u_axis
747    }
748
749    /// Returns the focus point.
750    #[must_use]
751    pub fn focus(&self) -> Point3 {
752        self.vertex + self.axis_dir * self.focal_length
753    }
754}
755
756/// A 3D hyperbola defined by center, axis, and two semi-axis lengths.
757///
758/// Parameterized as `P(t) = center + a * cosh(t) * u_axis + b * sinh(t) * v_axis`.
759///
760/// The parameter `t` ranges over all reals; `t = 0` gives the vertex
761/// closest to center on the positive branch.
762#[derive(Debug, Clone)]
763pub struct Hyperbola3D {
764    center: Point3,
765    normal: Vec3,
766    semi_major: f64,
767    semi_minor: f64,
768    u_axis: Vec3,
769    v_axis: Vec3,
770}
771
772impl Hyperbola3D {
773    /// Creates a new hyperbola.
774    ///
775    /// `semi_major` is the real semi-axis (distance from center to vertex),
776    /// `semi_minor` is the imaginary semi-axis.
777    ///
778    /// # Errors
779    /// Returns an error if either semi-axis is non-positive.
780    pub fn new(
781        center: Point3,
782        normal: Vec3,
783        semi_major: f64,
784        semi_minor: f64,
785    ) -> Result<Self, MathError> {
786        if semi_major <= 0.0 || semi_minor <= 0.0 {
787            return Err(MathError::ParameterOutOfRange {
788                value: semi_major.min(semi_minor),
789                min: f64::EPSILON,
790                max: f64::MAX,
791            });
792        }
793        let f = Frame3::from_normal(center, normal)?;
794        Ok(Self {
795            center,
796            normal: f.z,
797            semi_major,
798            semi_minor,
799            u_axis: f.x,
800            v_axis: f.y,
801        })
802    }
803
804    /// Evaluates the hyperbola at parameter `t`.
805    #[must_use]
806    pub fn evaluate(&self, t: f64) -> Point3 {
807        self.center
808            + self.u_axis * (self.semi_major * t.cosh())
809            + self.v_axis * (self.semi_minor * t.sinh())
810    }
811
812    /// Returns the tangent vector at parameter `t`.
813    #[must_use]
814    pub fn tangent(&self, t: f64) -> Vec3 {
815        self.u_axis * (self.semi_major * t.sinh()) + self.v_axis * (self.semi_minor * t.cosh())
816    }
817
818    /// Returns the center.
819    #[must_use]
820    pub const fn center(&self) -> Point3 {
821        self.center
822    }
823
824    /// Returns the semi-major axis (real axis).
825    #[must_use]
826    pub const fn semi_major(&self) -> f64 {
827        self.semi_major
828    }
829
830    /// Returns the semi-minor axis (imaginary axis).
831    #[must_use]
832    pub const fn semi_minor(&self) -> f64 {
833        self.semi_minor
834    }
835
836    /// Returns the normal (axis perpendicular to the hyperbola plane).
837    #[must_use]
838    pub const fn normal(&self) -> Vec3 {
839        self.normal
840    }
841
842    /// Returns the in-plane u-axis (real semi-axis direction).
843    /// At parameter `t`, the hyperbola is at offset
844    /// `semi_major * cosh(t) * u_axis + semi_minor * sinh(t) * v_axis`
845    /// from the center.
846    #[must_use]
847    pub const fn u_axis(&self) -> Vec3 {
848        self.u_axis
849    }
850
851    /// Returns the in-plane v-axis (imaginary semi-axis direction).
852    #[must_use]
853    pub const fn v_axis(&self) -> Vec3 {
854        self.v_axis
855    }
856
857    /// Returns the eccentricity: `e = sqrt(1 + (b/a)²)`.
858    #[must_use]
859    pub fn eccentricity(&self) -> f64 {
860        let ratio = self.semi_minor / self.semi_major;
861        ratio.mul_add(ratio, 1.0).sqrt()
862    }
863
864    /// Returns the two foci.
865    #[must_use]
866    pub fn foci(&self) -> (Point3, Point3) {
867        let c = self.semi_major.hypot(self.semi_minor);
868        (
869            self.center + self.u_axis * c,
870            self.center + self.u_axis * (-c),
871        )
872    }
873}
874
875#[cfg(test)]
876mod tests;