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    /// Where an ellipse meets this circle, with this circle's parameter at
574    /// each point: the ellipse's crossings of this circle's plane that lie at
575    /// its radius, as where a wall's circle meets its oblique ellipse rim. In
576    /// this circle's plane, the points where the ellipse crosses the circle
577    /// (a touch without a crossing is missed); in a parallel plane, none.
578    #[must_use]
579    pub fn intersect_ellipse(&self, ellipse: &Ellipse3D, tol: f64) -> Vec<(Point3, f64)> {
580        let mut out: Vec<(Point3, f64)> = Vec::new();
581        let mut push = |p: Point3| {
582            let v = p - self.center;
583            let mut t = v.dot(self.v_axis).atan2(v.dot(self.u_axis));
584            if t < 0.0 {
585                t += std::f64::consts::TAU;
586            }
587            if !out
588                .iter()
589                .any(|(q, _): &(Point3, f64)| (*q - p).length() < tol)
590            {
591                out.push((p, t));
592            }
593        };
594        // The ellipse's height over this plane is h + a cos s + b sin s.
595        let n = self.normal;
596        let a = ellipse.semi_major() * ellipse.u_axis().dot(n);
597        let b = ellipse.semi_minor() * ellipse.v_axis().dot(n);
598        let h = (ellipse.center() - self.center).dot(n);
599        let amp = a.hypot(b);
600        if amp <= tol {
601            if h.abs() <= tol {
602                // Coplanar: the sign changes of |e(s) - c|^2 - r^2 around the
603                // ellipse, each narrowed by bisection.
604                const STEPS: u32 = 256;
605                let g = |s: f64| {
606                    let d = ellipse.evaluate(s) - self.center;
607                    self.radius.mul_add(-self.radius, d.dot(d))
608                };
609                let mut prev = (0.0, g(0.0));
610                for k in 1..=STEPS {
611                    let s = std::f64::consts::TAU * f64::from(k) / f64::from(STEPS);
612                    let cur = (s, g(s));
613                    if (prev.1 <= 0.0) != (cur.1 <= 0.0) {
614                        let (mut lo, mut hi) = (prev, cur);
615                        for _ in 0..60 {
616                            let mid = f64::midpoint(lo.0, hi.0);
617                            let gm = g(mid);
618                            if (gm <= 0.0) == (lo.1 <= 0.0) {
619                                lo = (mid, gm);
620                            } else {
621                                hi = (mid, gm);
622                            }
623                        }
624                        push(ellipse.evaluate(f64::midpoint(lo.0, hi.0)));
625                    }
626                    prev = cur;
627                }
628            }
629            return out;
630        }
631        if h.abs() > amp + tol {
632            return out;
633        }
634        let (phi, spread) = (b.atan2(a), (-h / amp).clamp(-1.0, 1.0).acos());
635        for s in [phi - spread, phi + spread] {
636            let p = ellipse.evaluate(s);
637            if ((p - self.center).length() - self.radius).abs() <= tol {
638                push(p);
639            }
640        }
641        out
642    }
643}
644
645// ── Ellipse3D ──────────────────────────────────────────────────────
646
647/// A 3D ellipse defined by center, normal, and two semi-axis lengths.
648///
649/// Parameterized as `P(t) = center + a*cos(t)*u + b*sin(t)*v`.
650#[derive(Debug, Clone)]
651#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
652pub struct Ellipse3D {
653    center: Point3,
654    normal: Vec3,
655    semi_major: f64,
656    semi_minor: f64,
657    u_axis: Vec3,
658    v_axis: Vec3,
659}
660
661impl Ellipse3D {
662    /// Create a new ellipse.
663    ///
664    /// `semi_major` is the larger radius, `semi_minor` the smaller.
665    /// The major axis lies along the `u_axis` direction (computed from normal).
666    ///
667    /// # Errors
668    ///
669    /// Returns an error if either semi-axis is non-positive.
670    pub fn new(
671        center: Point3,
672        normal: Vec3,
673        semi_major: f64,
674        semi_minor: f64,
675    ) -> Result<Self, MathError> {
676        if semi_major <= 0.0 || semi_minor <= 0.0 {
677            return Err(MathError::ParameterOutOfRange {
678                value: semi_major.min(semi_minor),
679                min: 0.0,
680                max: f64::INFINITY,
681            });
682        }
683        if semi_minor > semi_major {
684            return Err(MathError::ParameterOutOfRange {
685                value: semi_minor,
686                min: 0.0,
687                max: semi_major,
688            });
689        }
690        let f = Frame3::from_normal(center, normal)?;
691        Ok(Self {
692            center,
693            normal: f.z,
694            semi_major,
695            semi_minor,
696            u_axis: f.x,
697            v_axis: f.y,
698        })
699    }
700
701    /// Create a new ellipse with a caller-supplied reference major-axis direction.
702    ///
703    /// `ref_dir` is projected onto the plane perpendicular to `normal` to
704    /// produce `u_axis` (which carries the `semi_major` extent). If
705    /// `ref_dir` is parallel to `normal`, falls back to an arbitrary
706    /// perpendicular choice per [`Frame3::from_normal_and_ref`].
707    ///
708    /// # Errors
709    ///
710    /// Returns an error if either semi-axis is non-positive, `semi_minor`
711    /// exceeds `semi_major`, or `normal` is zero.
712    pub fn new_with_ref(
713        center: Point3,
714        normal: Vec3,
715        semi_major: f64,
716        semi_minor: f64,
717        ref_dir: Vec3,
718    ) -> Result<Self, MathError> {
719        if semi_major <= 0.0 || semi_minor <= 0.0 {
720            return Err(MathError::ParameterOutOfRange {
721                value: semi_major.min(semi_minor),
722                min: 0.0,
723                max: f64::INFINITY,
724            });
725        }
726        if semi_minor > semi_major {
727            return Err(MathError::ParameterOutOfRange {
728                value: semi_minor,
729                min: 0.0,
730                max: semi_major,
731            });
732        }
733        let f = Frame3::from_normal_and_ref(center, normal, ref_dir)?;
734        Ok(Self {
735            center,
736            normal: f.z,
737            semi_major,
738            semi_minor,
739            u_axis: f.x,
740            v_axis: f.y,
741        })
742    }
743
744    /// Evaluate the ellipse at angle `t`.
745    #[must_use]
746    pub fn evaluate(&self, t: f64) -> Point3 {
747        let cos_t = t.cos();
748        let sin_t = t.sin();
749        self.center
750            + self.u_axis * (self.semi_major * cos_t)
751            + self.v_axis * (self.semi_minor * sin_t)
752    }
753
754    /// Tangent at angle `t` (not unit-length).
755    #[must_use]
756    pub fn tangent(&self, t: f64) -> Vec3 {
757        let cos_t = t.cos();
758        let sin_t = t.sin();
759        self.u_axis * (-self.semi_major * sin_t) + self.v_axis * (self.semi_minor * cos_t)
760    }
761
762    /// The ellipse center.
763    #[must_use]
764    pub const fn center(&self) -> Point3 {
765        self.center
766    }
767
768    /// Semi-major axis length.
769    #[must_use]
770    pub const fn semi_major(&self) -> f64 {
771        self.semi_major
772    }
773
774    /// Semi-minor axis length.
775    #[must_use]
776    pub const fn semi_minor(&self) -> f64 {
777        self.semi_minor
778    }
779
780    /// The ellipse normal (axis direction).
781    #[must_use]
782    pub const fn normal(&self) -> Vec3 {
783        self.normal
784    }
785
786    /// Approximate circumference using Ramanujan's formula.
787    #[must_use]
788    pub fn approximate_circumference(&self) -> f64 {
789        let a = self.semi_major;
790        let b = self.semi_minor;
791        let h = (a - b) * (a - b) / ((a + b) * (a + b));
792        PI * (a + b) * (1.0 + 3.0 * h / (10.0 + (3.0f64.mul_add(-h, 4.0)).sqrt()))
793    }
794
795    /// Project a point onto the ellipse, returning the angle parameter.
796    #[must_use]
797    pub fn project(&self, point: Point3) -> f64 {
798        let v = point - self.center;
799        let u_comp = self.u_axis.dot(v) / self.semi_major;
800        let v_comp = self.v_axis.dot(v) / self.semi_minor;
801        v_comp.atan2(u_comp)
802    }
803
804    /// The u-axis direction (major axis direction).
805    #[must_use]
806    pub const fn u_axis(&self) -> Vec3 {
807        self.u_axis
808    }
809
810    /// The v-axis direction (minor axis direction).
811    #[must_use]
812    pub const fn v_axis(&self) -> Vec3 {
813        self.v_axis
814    }
815
816    /// The whole ellipse's axis-aligned bounding box, which also bounds any
817    /// arc of it.
818    #[must_use]
819    pub fn aabb(&self) -> Aabb3 {
820        conic_aabb(
821            self.center,
822            (self.semi_major, self.u_axis),
823            (self.semi_minor, self.v_axis),
824        )
825    }
826
827    /// The axis-aligned bounding box of the arc from angle `t0` to `t1`.
828    #[must_use]
829    pub fn arc_aabb(&self, t0: f64, t1: f64) -> Aabb3 {
830        conic_arc_aabb(
831            self.center,
832            (self.semi_major, self.u_axis),
833            (self.semi_minor, self.v_axis),
834            (t0, t1),
835        )
836    }
837
838    /// Create an ellipse with explicit basis vectors (for transform/copy).
839    ///
840    /// # Errors
841    ///
842    /// Returns an error if either semi-axis is non-positive.
843    pub fn with_axes(
844        center: Point3,
845        normal: Vec3,
846        semi_major: f64,
847        semi_minor: f64,
848        u_axis: Vec3,
849        v_axis: Vec3,
850    ) -> Result<Self, MathError> {
851        if semi_major <= 0.0 || semi_minor <= 0.0 {
852            return Err(MathError::ParameterOutOfRange {
853                value: semi_major.min(semi_minor),
854                min: 0.0,
855                max: f64::INFINITY,
856            });
857        }
858        Ok(Self {
859            center,
860            normal,
861            semi_major,
862            semi_minor,
863            u_axis,
864            v_axis,
865        })
866    }
867}
868
869/// A 3D parabola defined by vertex, axis direction, and focal length.
870///
871/// Parameterized as `P(t) = vertex + (t²/(4f)) * axis_dir + t * u_axis`
872/// where `f` is the focal length and `u_axis` is perpendicular to the axis
873/// in the parabola plane.
874///
875/// The parameter `t` ranges over all reals; `t = 0` is the vertex.
876#[derive(Debug, Clone)]
877pub struct Parabola3D {
878    vertex: Point3,
879    axis_dir: Vec3,
880    focal_length: f64,
881    u_axis: Vec3,
882}
883
884impl Parabola3D {
885    /// Creates a new parabola.
886    ///
887    /// `axis_dir` is the direction from vertex toward the interior of the
888    /// parabola (the axis of symmetry). `focal_length` is the distance
889    /// from vertex to focus.
890    ///
891    /// # Errors
892    /// Returns an error if `focal_length` is not positive or `axis_dir` is zero.
893    pub fn new(vertex: Point3, axis_dir: Vec3, focal_length: f64) -> Result<Self, MathError> {
894        if focal_length <= 0.0 {
895            return Err(MathError::ParameterOutOfRange {
896                value: focal_length,
897                min: f64::EPSILON,
898                max: f64::MAX,
899            });
900        }
901        let f = Frame3::from_normal(vertex, axis_dir)?;
902        Ok(Self {
903            vertex,
904            axis_dir: f.z,
905            focal_length,
906            u_axis: f.x,
907        })
908    }
909
910    /// Evaluates the parabola at parameter `t`.
911    ///
912    /// At `t = 0` this returns the vertex.
913    #[must_use]
914    pub fn evaluate(&self, t: f64) -> Point3 {
915        let along_axis = (t * t) / (4.0 * self.focal_length);
916        self.vertex + self.axis_dir * along_axis + self.u_axis * t
917    }
918
919    /// Returns the tangent vector at parameter `t`.
920    #[must_use]
921    pub fn tangent(&self, t: f64) -> Vec3 {
922        let d_axis = t / (2.0 * self.focal_length);
923        self.axis_dir * d_axis + self.u_axis
924    }
925
926    /// Returns the curvature at parameter `t`.
927    #[must_use]
928    pub fn curvature(&self, t: f64) -> f64 {
929        let two_f = 2.0 * self.focal_length;
930        let ratio = t / two_f;
931        let denom = ratio.mul_add(ratio, 1.0);
932        1.0 / (two_f * denom.powf(1.5))
933    }
934
935    /// Returns the vertex.
936    #[must_use]
937    pub const fn vertex(&self) -> Point3 {
938        self.vertex
939    }
940
941    /// Returns the focal length.
942    #[must_use]
943    pub const fn focal_length(&self) -> f64 {
944        self.focal_length
945    }
946
947    /// Returns the axis direction (normalized).
948    #[must_use]
949    pub const fn axis_dir(&self) -> Vec3 {
950        self.axis_dir
951    }
952
953    /// Returns the in-plane u-axis (perpendicular to `axis_dir`).
954    /// At parameter `t`, the parabola is offset by `t * u_axis` from
955    /// the symmetry axis.
956    #[must_use]
957    pub const fn u_axis(&self) -> Vec3 {
958        self.u_axis
959    }
960
961    /// Returns the focus point.
962    #[must_use]
963    pub fn focus(&self) -> Point3 {
964        self.vertex + self.axis_dir * self.focal_length
965    }
966}
967
968/// A 3D hyperbola defined by center, axis, and two semi-axis lengths.
969///
970/// Parameterized as `P(t) = center + a * cosh(t) * u_axis + b * sinh(t) * v_axis`.
971///
972/// The parameter `t` ranges over all reals; `t = 0` gives the vertex
973/// closest to center on the positive branch.
974#[derive(Debug, Clone)]
975pub struct Hyperbola3D {
976    center: Point3,
977    normal: Vec3,
978    semi_major: f64,
979    semi_minor: f64,
980    u_axis: Vec3,
981    v_axis: Vec3,
982}
983
984impl Hyperbola3D {
985    /// Creates a new hyperbola.
986    ///
987    /// `semi_major` is the real semi-axis (distance from center to vertex),
988    /// `semi_minor` is the imaginary semi-axis.
989    ///
990    /// # Errors
991    /// Returns an error if either semi-axis is non-positive.
992    pub fn new(
993        center: Point3,
994        normal: Vec3,
995        semi_major: f64,
996        semi_minor: f64,
997    ) -> Result<Self, MathError> {
998        if semi_major <= 0.0 || semi_minor <= 0.0 {
999            return Err(MathError::ParameterOutOfRange {
1000                value: semi_major.min(semi_minor),
1001                min: f64::EPSILON,
1002                max: f64::MAX,
1003            });
1004        }
1005        let f = Frame3::from_normal(center, normal)?;
1006        Ok(Self {
1007            center,
1008            normal: f.z,
1009            semi_major,
1010            semi_minor,
1011            u_axis: f.x,
1012            v_axis: f.y,
1013        })
1014    }
1015
1016    /// Evaluates the hyperbola at parameter `t`.
1017    #[must_use]
1018    pub fn evaluate(&self, t: f64) -> Point3 {
1019        self.center
1020            + self.u_axis * (self.semi_major * t.cosh())
1021            + self.v_axis * (self.semi_minor * t.sinh())
1022    }
1023
1024    /// Returns the tangent vector at parameter `t`.
1025    #[must_use]
1026    pub fn tangent(&self, t: f64) -> Vec3 {
1027        self.u_axis * (self.semi_major * t.sinh()) + self.v_axis * (self.semi_minor * t.cosh())
1028    }
1029
1030    /// Returns the center.
1031    #[must_use]
1032    pub const fn center(&self) -> Point3 {
1033        self.center
1034    }
1035
1036    /// Returns the semi-major axis (real axis).
1037    #[must_use]
1038    pub const fn semi_major(&self) -> f64 {
1039        self.semi_major
1040    }
1041
1042    /// Returns the semi-minor axis (imaginary axis).
1043    #[must_use]
1044    pub const fn semi_minor(&self) -> f64 {
1045        self.semi_minor
1046    }
1047
1048    /// Returns the normal (axis perpendicular to the hyperbola plane).
1049    #[must_use]
1050    pub const fn normal(&self) -> Vec3 {
1051        self.normal
1052    }
1053
1054    /// Returns the in-plane u-axis (real semi-axis direction).
1055    /// At parameter `t`, the hyperbola is at offset
1056    /// `semi_major * cosh(t) * u_axis + semi_minor * sinh(t) * v_axis`
1057    /// from the center.
1058    #[must_use]
1059    pub const fn u_axis(&self) -> Vec3 {
1060        self.u_axis
1061    }
1062
1063    /// Returns the in-plane v-axis (imaginary semi-axis direction).
1064    #[must_use]
1065    pub const fn v_axis(&self) -> Vec3 {
1066        self.v_axis
1067    }
1068
1069    /// Returns the eccentricity: `e = sqrt(1 + (b/a)²)`.
1070    #[must_use]
1071    pub fn eccentricity(&self) -> f64 {
1072        let ratio = self.semi_minor / self.semi_major;
1073        ratio.mul_add(ratio, 1.0).sqrt()
1074    }
1075
1076    /// Returns the two foci.
1077    #[must_use]
1078    pub fn foci(&self) -> (Point3, Point3) {
1079        let c = self.semi_major.hypot(self.semi_minor);
1080        (
1081            self.center + self.u_axis * c,
1082            self.center + self.u_axis * (-c),
1083        )
1084    }
1085}
1086
1087#[cfg(test)]
1088mod tests;