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