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 circle.
388    ///
389    /// Returns up to 2 intersection points along with their angle parameter
390    /// `t` on `self`. Circles in skew planes meet only on the planes' common
391    /// line (two circles on one sphere, a latitude and a great circle). Circles
392    /// in parallel but offset planes, and coincident or concentric coplanar
393    /// pairs, return no points — callers own those configurations separately.
394    ///
395    /// Near-tangent conditioning: when the circles graze (the chord implied
396    /// by the root pair penetrates by less than `tol`), the two roots are
397    /// noise straddling the tangency foot — position error grows as
398    /// `sqrt(2·r·δ)`, the recurring tangential-contact class. The
399    /// well-conditioned double root (the foot on the center line) is emitted
400    /// instead of the pair.
401    #[must_use]
402    pub fn intersect_circle(&self, other: &Self, tol: f64) -> Vec<(Point3, f64)> {
403        let mut out = Vec::new();
404        if self.normal.cross(other.normal).length() > 1e-9 {
405            return self.intersect_skew_circle(other, tol);
406        }
407        let dvec = other.center - self.center;
408        if dvec.dot(self.normal).abs() > tol {
409            return out; // Parallel but offset planes.
410        }
411        let du = dvec.dot(self.u_axis);
412        let dv = dvec.dot(self.v_axis);
413        let d2 = du * du + dv * dv;
414        let d = d2.sqrt();
415        if d < tol {
416            return out; // Concentric (incl. coincident) — no discrete crossings.
417        }
418        let (r1, r2) = (self.radius, other.radius);
419        let a = (d2 + r1 * r1 - r2 * r2) / (2.0 * d);
420        let h2 = r1 * r1 - a * a;
421        let r_eff = r1.min(r2);
422        if h2 < -2.0 * r_eff * tol {
423            return out; // Separated (or nested) beyond the tangency well.
424        }
425        let ux = Vec3::new(
426            (self.u_axis.x() * du + self.v_axis.x() * dv) / d,
427            (self.u_axis.y() * du + self.v_axis.y() * dv) / d,
428            (self.u_axis.z() * du + self.v_axis.z() * dv) / d,
429        );
430        let vx = self.normal.cross(ux);
431        let foot = self.center + ux * a;
432        let mut push = |p: Point3| {
433            let v = p - self.center;
434            let mut t = v.dot(self.v_axis).atan2(v.dot(self.u_axis));
435            if t < 0.0 {
436                t += std::f64::consts::TAU;
437            }
438            if !out
439                .iter()
440                .any(|(q, _): &(Point3, f64)| (*q - p).length() < tol)
441            {
442                out.push((p, t));
443            }
444        };
445        if h2 <= 2.0 * r_eff * tol {
446            push(foot);
447        } else {
448            let h = h2.sqrt();
449            push(foot + vx * h);
450            push(foot - vx * h);
451        }
452        out
453    }
454
455    /// [`Self::intersect_circle`] for a circle whose plane crosses this one's:
456    /// the points of the planes' common line at this circle's radius that
457    /// also lie on `other`, a grazing pair collapsed to its foot.
458    fn intersect_skew_circle(&self, other: &Self, tol: f64) -> Vec<(Point3, f64)> {
459        let mut out: Vec<(Point3, f64)> = Vec::new();
460        let (n1, n2) = (self.normal, other.normal);
461        let Ok(dir) = n1.cross(n2).normalize() else {
462            return out;
463        };
464        // The point of the common line nearest the origin, from the two
465        // plane equations `n · x = h`.
466        let (h1, h2) = (
467            n1.dot(Vec3::new(self.center.x(), self.center.y(), self.center.z())),
468            n2.dot(Vec3::new(
469                other.center.x(),
470                other.center.y(),
471                other.center.z(),
472            )),
473        );
474        let (a, b, c) = (n1.dot(n1), n2.dot(n2), n1.dot(n2));
475        let det = a.mul_add(b, -(c * c));
476        let base = n1 * ((h1 * b - h2 * c) / det) + n2 * ((h2 * a - h1 * c) / det);
477        let base = Point3::new(base.x(), base.y(), base.z());
478        let off = base - self.center;
479        let half_b = dir.dot(off);
480        let disc = half_b.mul_add(half_b, -(off.dot(off) - self.radius * self.radius));
481        let well = 2.0 * self.radius * tol;
482        if disc < -well {
483            return out;
484        }
485        let roots: Vec<f64> = if disc <= well {
486            vec![-half_b]
487        } else {
488            let root = disc.sqrt();
489            vec![-half_b - root, -half_b + root]
490        };
491        for s in roots {
492            let p = base + dir * s;
493            if ((p - other.center).length() - other.radius).abs() > tol {
494                continue;
495            }
496            let v = p - self.center;
497            let mut t = v.dot(self.v_axis).atan2(v.dot(self.u_axis));
498            if t < 0.0 {
499                t += std::f64::consts::TAU;
500            }
501            if !out.iter().any(|(q, _)| (*q - p).length() < tol) {
502                out.push((p, t));
503            }
504        }
505        out
506    }
507}
508
509// ── Ellipse3D ──────────────────────────────────────────────────────
510
511/// A 3D ellipse defined by center, normal, and two semi-axis lengths.
512///
513/// Parameterized as `P(t) = center + a*cos(t)*u + b*sin(t)*v`.
514#[derive(Debug, Clone)]
515#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
516pub struct Ellipse3D {
517    center: Point3,
518    normal: Vec3,
519    semi_major: f64,
520    semi_minor: f64,
521    u_axis: Vec3,
522    v_axis: Vec3,
523}
524
525impl Ellipse3D {
526    /// Create a new ellipse.
527    ///
528    /// `semi_major` is the larger radius, `semi_minor` the smaller.
529    /// The major axis lies along the `u_axis` direction (computed from normal).
530    ///
531    /// # Errors
532    ///
533    /// Returns an error if either semi-axis is non-positive.
534    pub fn new(
535        center: Point3,
536        normal: Vec3,
537        semi_major: f64,
538        semi_minor: f64,
539    ) -> Result<Self, MathError> {
540        if semi_major <= 0.0 || semi_minor <= 0.0 {
541            return Err(MathError::ParameterOutOfRange {
542                value: semi_major.min(semi_minor),
543                min: 0.0,
544                max: f64::INFINITY,
545            });
546        }
547        if semi_minor > semi_major {
548            return Err(MathError::ParameterOutOfRange {
549                value: semi_minor,
550                min: 0.0,
551                max: semi_major,
552            });
553        }
554        let f = Frame3::from_normal(center, normal)?;
555        Ok(Self {
556            center,
557            normal: f.z,
558            semi_major,
559            semi_minor,
560            u_axis: f.x,
561            v_axis: f.y,
562        })
563    }
564
565    /// Create a new ellipse with a caller-supplied reference major-axis direction.
566    ///
567    /// `ref_dir` is projected onto the plane perpendicular to `normal` to
568    /// produce `u_axis` (which carries the `semi_major` extent). If
569    /// `ref_dir` is parallel to `normal`, falls back to an arbitrary
570    /// perpendicular choice per [`Frame3::from_normal_and_ref`].
571    ///
572    /// # Errors
573    ///
574    /// Returns an error if either semi-axis is non-positive, `semi_minor`
575    /// exceeds `semi_major`, or `normal` is zero.
576    pub fn new_with_ref(
577        center: Point3,
578        normal: Vec3,
579        semi_major: f64,
580        semi_minor: f64,
581        ref_dir: Vec3,
582    ) -> Result<Self, MathError> {
583        if semi_major <= 0.0 || semi_minor <= 0.0 {
584            return Err(MathError::ParameterOutOfRange {
585                value: semi_major.min(semi_minor),
586                min: 0.0,
587                max: f64::INFINITY,
588            });
589        }
590        if semi_minor > semi_major {
591            return Err(MathError::ParameterOutOfRange {
592                value: semi_minor,
593                min: 0.0,
594                max: semi_major,
595            });
596        }
597        let f = Frame3::from_normal_and_ref(center, normal, ref_dir)?;
598        Ok(Self {
599            center,
600            normal: f.z,
601            semi_major,
602            semi_minor,
603            u_axis: f.x,
604            v_axis: f.y,
605        })
606    }
607
608    /// Evaluate the ellipse at angle `t`.
609    #[must_use]
610    pub fn evaluate(&self, t: f64) -> Point3 {
611        let cos_t = t.cos();
612        let sin_t = t.sin();
613        self.center
614            + self.u_axis * (self.semi_major * cos_t)
615            + self.v_axis * (self.semi_minor * sin_t)
616    }
617
618    /// Tangent at angle `t` (not unit-length).
619    #[must_use]
620    pub fn tangent(&self, t: f64) -> Vec3 {
621        let cos_t = t.cos();
622        let sin_t = t.sin();
623        self.u_axis * (-self.semi_major * sin_t) + self.v_axis * (self.semi_minor * cos_t)
624    }
625
626    /// The ellipse center.
627    #[must_use]
628    pub const fn center(&self) -> Point3 {
629        self.center
630    }
631
632    /// Semi-major axis length.
633    #[must_use]
634    pub const fn semi_major(&self) -> f64 {
635        self.semi_major
636    }
637
638    /// Semi-minor axis length.
639    #[must_use]
640    pub const fn semi_minor(&self) -> f64 {
641        self.semi_minor
642    }
643
644    /// The ellipse normal (axis direction).
645    #[must_use]
646    pub const fn normal(&self) -> Vec3 {
647        self.normal
648    }
649
650    /// Approximate circumference using Ramanujan's formula.
651    #[must_use]
652    pub fn approximate_circumference(&self) -> f64 {
653        let a = self.semi_major;
654        let b = self.semi_minor;
655        let h = (a - b) * (a - b) / ((a + b) * (a + b));
656        PI * (a + b) * (1.0 + 3.0 * h / (10.0 + (3.0f64.mul_add(-h, 4.0)).sqrt()))
657    }
658
659    /// Project a point onto the ellipse, returning the angle parameter.
660    #[must_use]
661    pub fn project(&self, point: Point3) -> f64 {
662        let v = point - self.center;
663        let u_comp = self.u_axis.dot(v) / self.semi_major;
664        let v_comp = self.v_axis.dot(v) / self.semi_minor;
665        v_comp.atan2(u_comp)
666    }
667
668    /// The u-axis direction (major axis direction).
669    #[must_use]
670    pub const fn u_axis(&self) -> Vec3 {
671        self.u_axis
672    }
673
674    /// The v-axis direction (minor axis direction).
675    #[must_use]
676    pub const fn v_axis(&self) -> Vec3 {
677        self.v_axis
678    }
679
680    /// Create an ellipse with explicit basis vectors (for transform/copy).
681    ///
682    /// # Errors
683    ///
684    /// Returns an error if either semi-axis is non-positive.
685    pub fn with_axes(
686        center: Point3,
687        normal: Vec3,
688        semi_major: f64,
689        semi_minor: f64,
690        u_axis: Vec3,
691        v_axis: Vec3,
692    ) -> Result<Self, MathError> {
693        if semi_major <= 0.0 || semi_minor <= 0.0 {
694            return Err(MathError::ParameterOutOfRange {
695                value: semi_major.min(semi_minor),
696                min: 0.0,
697                max: f64::INFINITY,
698            });
699        }
700        Ok(Self {
701            center,
702            normal,
703            semi_major,
704            semi_minor,
705            u_axis,
706            v_axis,
707        })
708    }
709}
710
711/// A 3D parabola defined by vertex, axis direction, and focal length.
712///
713/// Parameterized as `P(t) = vertex + (t²/(4f)) * axis_dir + t * u_axis`
714/// where `f` is the focal length and `u_axis` is perpendicular to the axis
715/// in the parabola plane.
716///
717/// The parameter `t` ranges over all reals; `t = 0` is the vertex.
718#[derive(Debug, Clone)]
719pub struct Parabola3D {
720    vertex: Point3,
721    axis_dir: Vec3,
722    focal_length: f64,
723    u_axis: Vec3,
724}
725
726impl Parabola3D {
727    /// Creates a new parabola.
728    ///
729    /// `axis_dir` is the direction from vertex toward the interior of the
730    /// parabola (the axis of symmetry). `focal_length` is the distance
731    /// from vertex to focus.
732    ///
733    /// # Errors
734    /// Returns an error if `focal_length` is not positive or `axis_dir` is zero.
735    pub fn new(vertex: Point3, axis_dir: Vec3, focal_length: f64) -> Result<Self, MathError> {
736        if focal_length <= 0.0 {
737            return Err(MathError::ParameterOutOfRange {
738                value: focal_length,
739                min: f64::EPSILON,
740                max: f64::MAX,
741            });
742        }
743        let f = Frame3::from_normal(vertex, axis_dir)?;
744        Ok(Self {
745            vertex,
746            axis_dir: f.z,
747            focal_length,
748            u_axis: f.x,
749        })
750    }
751
752    /// Evaluates the parabola at parameter `t`.
753    ///
754    /// At `t = 0` this returns the vertex.
755    #[must_use]
756    pub fn evaluate(&self, t: f64) -> Point3 {
757        let along_axis = (t * t) / (4.0 * self.focal_length);
758        self.vertex + self.axis_dir * along_axis + self.u_axis * t
759    }
760
761    /// Returns the tangent vector at parameter `t`.
762    #[must_use]
763    pub fn tangent(&self, t: f64) -> Vec3 {
764        let d_axis = t / (2.0 * self.focal_length);
765        self.axis_dir * d_axis + self.u_axis
766    }
767
768    /// Returns the curvature at parameter `t`.
769    #[must_use]
770    pub fn curvature(&self, t: f64) -> f64 {
771        let two_f = 2.0 * self.focal_length;
772        let ratio = t / two_f;
773        let denom = ratio.mul_add(ratio, 1.0);
774        1.0 / (two_f * denom.powf(1.5))
775    }
776
777    /// Returns the vertex.
778    #[must_use]
779    pub const fn vertex(&self) -> Point3 {
780        self.vertex
781    }
782
783    /// Returns the focal length.
784    #[must_use]
785    pub const fn focal_length(&self) -> f64 {
786        self.focal_length
787    }
788
789    /// Returns the axis direction (normalized).
790    #[must_use]
791    pub const fn axis_dir(&self) -> Vec3 {
792        self.axis_dir
793    }
794
795    /// Returns the in-plane u-axis (perpendicular to `axis_dir`).
796    /// At parameter `t`, the parabola is offset by `t * u_axis` from
797    /// the symmetry axis.
798    #[must_use]
799    pub const fn u_axis(&self) -> Vec3 {
800        self.u_axis
801    }
802
803    /// Returns the focus point.
804    #[must_use]
805    pub fn focus(&self) -> Point3 {
806        self.vertex + self.axis_dir * self.focal_length
807    }
808}
809
810/// A 3D hyperbola defined by center, axis, and two semi-axis lengths.
811///
812/// Parameterized as `P(t) = center + a * cosh(t) * u_axis + b * sinh(t) * v_axis`.
813///
814/// The parameter `t` ranges over all reals; `t = 0` gives the vertex
815/// closest to center on the positive branch.
816#[derive(Debug, Clone)]
817pub struct Hyperbola3D {
818    center: Point3,
819    normal: Vec3,
820    semi_major: f64,
821    semi_minor: f64,
822    u_axis: Vec3,
823    v_axis: Vec3,
824}
825
826impl Hyperbola3D {
827    /// Creates a new hyperbola.
828    ///
829    /// `semi_major` is the real semi-axis (distance from center to vertex),
830    /// `semi_minor` is the imaginary semi-axis.
831    ///
832    /// # Errors
833    /// Returns an error if either semi-axis is non-positive.
834    pub fn new(
835        center: Point3,
836        normal: Vec3,
837        semi_major: f64,
838        semi_minor: f64,
839    ) -> Result<Self, MathError> {
840        if semi_major <= 0.0 || semi_minor <= 0.0 {
841            return Err(MathError::ParameterOutOfRange {
842                value: semi_major.min(semi_minor),
843                min: f64::EPSILON,
844                max: f64::MAX,
845            });
846        }
847        let f = Frame3::from_normal(center, normal)?;
848        Ok(Self {
849            center,
850            normal: f.z,
851            semi_major,
852            semi_minor,
853            u_axis: f.x,
854            v_axis: f.y,
855        })
856    }
857
858    /// Evaluates the hyperbola at parameter `t`.
859    #[must_use]
860    pub fn evaluate(&self, t: f64) -> Point3 {
861        self.center
862            + self.u_axis * (self.semi_major * t.cosh())
863            + self.v_axis * (self.semi_minor * t.sinh())
864    }
865
866    /// Returns the tangent vector at parameter `t`.
867    #[must_use]
868    pub fn tangent(&self, t: f64) -> Vec3 {
869        self.u_axis * (self.semi_major * t.sinh()) + self.v_axis * (self.semi_minor * t.cosh())
870    }
871
872    /// Returns the center.
873    #[must_use]
874    pub const fn center(&self) -> Point3 {
875        self.center
876    }
877
878    /// Returns the semi-major axis (real axis).
879    #[must_use]
880    pub const fn semi_major(&self) -> f64 {
881        self.semi_major
882    }
883
884    /// Returns the semi-minor axis (imaginary axis).
885    #[must_use]
886    pub const fn semi_minor(&self) -> f64 {
887        self.semi_minor
888    }
889
890    /// Returns the normal (axis perpendicular to the hyperbola plane).
891    #[must_use]
892    pub const fn normal(&self) -> Vec3 {
893        self.normal
894    }
895
896    /// Returns the in-plane u-axis (real semi-axis direction).
897    /// At parameter `t`, the hyperbola is at offset
898    /// `semi_major * cosh(t) * u_axis + semi_minor * sinh(t) * v_axis`
899    /// from the center.
900    #[must_use]
901    pub const fn u_axis(&self) -> Vec3 {
902        self.u_axis
903    }
904
905    /// Returns the in-plane v-axis (imaginary semi-axis direction).
906    #[must_use]
907    pub const fn v_axis(&self) -> Vec3 {
908        self.v_axis
909    }
910
911    /// Returns the eccentricity: `e = sqrt(1 + (b/a)²)`.
912    #[must_use]
913    pub fn eccentricity(&self) -> f64 {
914        let ratio = self.semi_minor / self.semi_major;
915        ratio.mul_add(ratio, 1.0).sqrt()
916    }
917
918    /// Returns the two foci.
919    #[must_use]
920    pub fn foci(&self) -> (Point3, Point3) {
921        let c = self.semi_major.hypot(self.semi_minor);
922        (
923            self.center + self.u_axis * c,
924            self.center + self.u_axis * (-c),
925        )
926    }
927}
928
929#[cfg(test)]
930mod tests;