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.min(h1) <= tol && h0.max(h1) >= -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            // Each end's own distance decides whether it reaches the plane,
429            // not their product: a ruling ending on the circle has `h0 * h1`
430            // of order its length times that end's rounding.
431            let denom = h0 - h1;
432            if denom.abs() < tol {
433                return out;
434            }
435            let s = h0 / denom;
436            let s_slack = tol / seg_len_sq.sqrt();
437            if s < -s_slack || s > 1.0 + s_slack {
438                return out;
439            }
440            let s = s.clamp(0.0, 1.0);
441            let p = Point3::new(
442                seg_start.x() + s * d.x(),
443                seg_start.y() + s * d.y(),
444                seg_start.z() + s * d.z(),
445            );
446            if on_plane(p) {
447                push_if_unique(p);
448            }
449        }
450        // else: segment is on one side of the plane → no crossings.
451
452        out
453    }
454
455    /// Intersect the circle with another circle.
456    ///
457    /// Returns up to 2 intersection points along with their angle parameter
458    /// `t` on `self`. Circles in skew planes meet only on the planes' common
459    /// line (two circles on one sphere, a latitude and a great circle). Circles
460    /// in parallel but offset planes, and coincident or concentric coplanar
461    /// pairs, return no points — callers own those configurations separately.
462    ///
463    /// Near-tangent conditioning: when the circles graze (the chord implied
464    /// by the root pair penetrates by less than `tol`), the two roots are
465    /// noise straddling the tangency foot — position error grows as
466    /// `sqrt(2·r·δ)`, the recurring tangential-contact class. The
467    /// well-conditioned double root (the foot on the center line) is emitted
468    /// instead of the pair.
469    #[must_use]
470    pub fn intersect_circle(&self, other: &Self, tol: f64) -> Vec<(Point3, f64)> {
471        let mut out = Vec::new();
472        if self.normal.cross(other.normal).length() > 1e-9 {
473            return self.intersect_skew_circle(other, tol);
474        }
475        let dvec = other.center - self.center;
476        if dvec.dot(self.normal).abs() > tol {
477            return out; // Parallel but offset planes.
478        }
479        let du = dvec.dot(self.u_axis);
480        let dv = dvec.dot(self.v_axis);
481        let d2 = du * du + dv * dv;
482        let d = d2.sqrt();
483        if d < tol {
484            return out; // Concentric (incl. coincident) — no discrete crossings.
485        }
486        let (r1, r2) = (self.radius, other.radius);
487        let a = (d2 + r1 * r1 - r2 * r2) / (2.0 * d);
488        let h2 = r1 * r1 - a * a;
489        let r_eff = r1.min(r2);
490        if h2 < -2.0 * r_eff * tol {
491            return out; // Separated (or nested) beyond the tangency well.
492        }
493        let ux = Vec3::new(
494            (self.u_axis.x() * du + self.v_axis.x() * dv) / d,
495            (self.u_axis.y() * du + self.v_axis.y() * dv) / d,
496            (self.u_axis.z() * du + self.v_axis.z() * dv) / d,
497        );
498        let vx = self.normal.cross(ux);
499        let foot = self.center + ux * a;
500        let mut push = |p: Point3| {
501            let v = p - self.center;
502            let mut t = v.dot(self.v_axis).atan2(v.dot(self.u_axis));
503            if t < 0.0 {
504                t += std::f64::consts::TAU;
505            }
506            if !out
507                .iter()
508                .any(|(q, _): &(Point3, f64)| (*q - p).length() < tol)
509            {
510                out.push((p, t));
511            }
512        };
513        if h2 <= 2.0 * r_eff * tol {
514            push(foot);
515        } else {
516            let h = h2.sqrt();
517            push(foot + vx * h);
518            push(foot - vx * h);
519        }
520        out
521    }
522
523    /// [`Self::intersect_circle`] for a circle whose plane crosses this one's:
524    /// the points of the planes' common line at this circle's radius that
525    /// also lie on `other`, a grazing pair collapsed to its foot.
526    fn intersect_skew_circle(&self, other: &Self, tol: f64) -> Vec<(Point3, f64)> {
527        let mut out: Vec<(Point3, f64)> = Vec::new();
528        let (n1, n2) = (self.normal, other.normal);
529        let Ok(dir) = n1.cross(n2).normalize() else {
530            return out;
531        };
532        // The point of the common line nearest the origin, from the two
533        // plane equations `n · x = h`.
534        let (h1, h2) = (
535            n1.dot(Vec3::new(self.center.x(), self.center.y(), self.center.z())),
536            n2.dot(Vec3::new(
537                other.center.x(),
538                other.center.y(),
539                other.center.z(),
540            )),
541        );
542        let (a, b, c) = (n1.dot(n1), n2.dot(n2), n1.dot(n2));
543        let det = a.mul_add(b, -(c * c));
544        let base = n1 * ((h1 * b - h2 * c) / det) + n2 * ((h2 * a - h1 * c) / det);
545        let base = Point3::new(base.x(), base.y(), base.z());
546        let off = base - self.center;
547        let half_b = dir.dot(off);
548        let disc = half_b.mul_add(half_b, -(off.dot(off) - self.radius * self.radius));
549        let well = 2.0 * self.radius * tol;
550        if disc < -well {
551            return out;
552        }
553        let roots: Vec<f64> = if disc <= well {
554            vec![-half_b]
555        } else {
556            let root = disc.sqrt();
557            vec![-half_b - root, -half_b + root]
558        };
559        for s in roots {
560            let p = base + dir * s;
561            if ((p - other.center).length() - other.radius).abs() > tol {
562                continue;
563            }
564            let v = p - self.center;
565            let mut t = v.dot(self.v_axis).atan2(v.dot(self.u_axis));
566            if t < 0.0 {
567                t += std::f64::consts::TAU;
568            }
569            if !out.iter().any(|(q, _)| (*q - p).length() < tol) {
570                out.push((p, t));
571            }
572        }
573        out
574    }
575
576    /// Where an ellipse meets this circle, with this circle's parameter at
577    /// each point: the ellipse's crossings of this circle's plane that lie at
578    /// its radius, as where a wall's circle meets its oblique ellipse rim. In
579    /// this circle's plane, the points where the ellipse crosses the circle
580    /// (a touch without a crossing is missed); in a parallel plane, none.
581    #[must_use]
582    pub fn intersect_ellipse(&self, ellipse: &Ellipse3D, tol: f64) -> Vec<(Point3, f64)> {
583        let mut out: Vec<(Point3, f64)> = Vec::new();
584        let mut push = |p: Point3| {
585            let v = p - self.center;
586            let mut t = v.dot(self.v_axis).atan2(v.dot(self.u_axis));
587            if t < 0.0 {
588                t += std::f64::consts::TAU;
589            }
590            if !out
591                .iter()
592                .any(|(q, _): &(Point3, f64)| (*q - p).length() < tol)
593            {
594                out.push((p, t));
595            }
596        };
597        // The ellipse's height over this plane is h + a cos s + b sin s.
598        let n = self.normal;
599        let a = ellipse.semi_major() * ellipse.u_axis().dot(n);
600        let b = ellipse.semi_minor() * ellipse.v_axis().dot(n);
601        let h = (ellipse.center() - self.center).dot(n);
602        let amp = a.hypot(b);
603        if amp <= tol {
604            if h.abs() <= tol {
605                // Coplanar: the sign changes of |e(s) - c|^2 - r^2 around the
606                // ellipse, each narrowed by bisection.
607                const STEPS: u32 = 256;
608                let g = |s: f64| {
609                    let d = ellipse.evaluate(s) - self.center;
610                    self.radius.mul_add(-self.radius, d.dot(d))
611                };
612                let mut prev = (0.0, g(0.0));
613                for k in 1..=STEPS {
614                    let s = std::f64::consts::TAU * f64::from(k) / f64::from(STEPS);
615                    let cur = (s, g(s));
616                    if (prev.1 <= 0.0) != (cur.1 <= 0.0) {
617                        let (mut lo, mut hi) = (prev, cur);
618                        for _ in 0..60 {
619                            let mid = f64::midpoint(lo.0, hi.0);
620                            let gm = g(mid);
621                            if (gm <= 0.0) == (lo.1 <= 0.0) {
622                                lo = (mid, gm);
623                            } else {
624                                hi = (mid, gm);
625                            }
626                        }
627                        push(ellipse.evaluate(f64::midpoint(lo.0, hi.0)));
628                    }
629                    prev = cur;
630                }
631            }
632            return out;
633        }
634        if h.abs() > amp + tol {
635            return out;
636        }
637        let (phi, spread) = (b.atan2(a), (-h / amp).clamp(-1.0, 1.0).acos());
638        for s in [phi - spread, phi + spread] {
639            let p = ellipse.evaluate(s);
640            if ((p - self.center).length() - self.radius).abs() <= tol {
641                push(p);
642            }
643        }
644        out
645    }
646}
647
648// ── Ellipse3D ──────────────────────────────────────────────────────
649
650/// A 3D ellipse defined by center, normal, and two semi-axis lengths.
651///
652/// Parameterized as `P(t) = center + a*cos(t)*u + b*sin(t)*v`.
653#[derive(Debug, Clone)]
654#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
655pub struct Ellipse3D {
656    center: Point3,
657    normal: Vec3,
658    semi_major: f64,
659    semi_minor: f64,
660    u_axis: Vec3,
661    v_axis: Vec3,
662}
663
664impl Ellipse3D {
665    /// Create a new ellipse.
666    ///
667    /// `semi_major` is the larger radius, `semi_minor` the smaller.
668    /// The major axis lies along the `u_axis` direction (computed from normal).
669    ///
670    /// # Errors
671    ///
672    /// Returns an error if either semi-axis is non-positive.
673    pub fn new(
674        center: Point3,
675        normal: Vec3,
676        semi_major: f64,
677        semi_minor: f64,
678    ) -> Result<Self, MathError> {
679        if semi_major <= 0.0 || semi_minor <= 0.0 {
680            return Err(MathError::ParameterOutOfRange {
681                value: semi_major.min(semi_minor),
682                min: 0.0,
683                max: f64::INFINITY,
684            });
685        }
686        if semi_minor > semi_major {
687            return Err(MathError::ParameterOutOfRange {
688                value: semi_minor,
689                min: 0.0,
690                max: semi_major,
691            });
692        }
693        let f = Frame3::from_normal(center, normal)?;
694        Ok(Self {
695            center,
696            normal: f.z,
697            semi_major,
698            semi_minor,
699            u_axis: f.x,
700            v_axis: f.y,
701        })
702    }
703
704    /// Create a new ellipse with a caller-supplied reference major-axis direction.
705    ///
706    /// `ref_dir` is projected onto the plane perpendicular to `normal` to
707    /// produce `u_axis` (which carries the `semi_major` extent). If
708    /// `ref_dir` is parallel to `normal`, falls back to an arbitrary
709    /// perpendicular choice per [`Frame3::from_normal_and_ref`].
710    ///
711    /// # Errors
712    ///
713    /// Returns an error if either semi-axis is non-positive, `semi_minor`
714    /// exceeds `semi_major`, or `normal` is zero.
715    pub fn new_with_ref(
716        center: Point3,
717        normal: Vec3,
718        semi_major: f64,
719        semi_minor: f64,
720        ref_dir: Vec3,
721    ) -> Result<Self, MathError> {
722        if semi_major <= 0.0 || semi_minor <= 0.0 {
723            return Err(MathError::ParameterOutOfRange {
724                value: semi_major.min(semi_minor),
725                min: 0.0,
726                max: f64::INFINITY,
727            });
728        }
729        if semi_minor > semi_major {
730            return Err(MathError::ParameterOutOfRange {
731                value: semi_minor,
732                min: 0.0,
733                max: semi_major,
734            });
735        }
736        let f = Frame3::from_normal_and_ref(center, normal, ref_dir)?;
737        Ok(Self {
738            center,
739            normal: f.z,
740            semi_major,
741            semi_minor,
742            u_axis: f.x,
743            v_axis: f.y,
744        })
745    }
746
747    /// Evaluate the ellipse at angle `t`.
748    #[must_use]
749    pub fn evaluate(&self, t: f64) -> Point3 {
750        let cos_t = t.cos();
751        let sin_t = t.sin();
752        self.center
753            + self.u_axis * (self.semi_major * cos_t)
754            + self.v_axis * (self.semi_minor * sin_t)
755    }
756
757    /// Tangent at angle `t` (not unit-length).
758    #[must_use]
759    pub fn tangent(&self, t: f64) -> Vec3 {
760        let cos_t = t.cos();
761        let sin_t = t.sin();
762        self.u_axis * (-self.semi_major * sin_t) + self.v_axis * (self.semi_minor * cos_t)
763    }
764
765    /// The ellipse center.
766    #[must_use]
767    pub const fn center(&self) -> Point3 {
768        self.center
769    }
770
771    /// Semi-major axis length.
772    #[must_use]
773    pub const fn semi_major(&self) -> f64 {
774        self.semi_major
775    }
776
777    /// Semi-minor axis length.
778    #[must_use]
779    pub const fn semi_minor(&self) -> f64 {
780        self.semi_minor
781    }
782
783    /// The ellipse normal (axis direction).
784    #[must_use]
785    pub const fn normal(&self) -> Vec3 {
786        self.normal
787    }
788
789    /// Approximate circumference using Ramanujan's formula.
790    #[must_use]
791    pub fn approximate_circumference(&self) -> f64 {
792        let a = self.semi_major;
793        let b = self.semi_minor;
794        let h = (a - b) * (a - b) / ((a + b) * (a + b));
795        PI * (a + b) * (1.0 + 3.0 * h / (10.0 + (3.0f64.mul_add(-h, 4.0)).sqrt()))
796    }
797
798    /// Project a point onto the ellipse, returning the angle parameter.
799    #[must_use]
800    pub fn project(&self, point: Point3) -> f64 {
801        let v = point - self.center;
802        let u_comp = self.u_axis.dot(v) / self.semi_major;
803        let v_comp = self.v_axis.dot(v) / self.semi_minor;
804        v_comp.atan2(u_comp)
805    }
806
807    /// The u-axis direction (major axis direction).
808    #[must_use]
809    pub const fn u_axis(&self) -> Vec3 {
810        self.u_axis
811    }
812
813    /// The v-axis direction (minor axis direction).
814    #[must_use]
815    pub const fn v_axis(&self) -> Vec3 {
816        self.v_axis
817    }
818
819    /// The whole ellipse's axis-aligned bounding box, which also bounds any
820    /// arc of it.
821    #[must_use]
822    pub fn aabb(&self) -> Aabb3 {
823        conic_aabb(
824            self.center,
825            (self.semi_major, self.u_axis),
826            (self.semi_minor, self.v_axis),
827        )
828    }
829
830    /// The axis-aligned bounding box of the arc from angle `t0` to `t1`.
831    #[must_use]
832    pub fn arc_aabb(&self, t0: f64, t1: f64) -> Aabb3 {
833        conic_arc_aabb(
834            self.center,
835            (self.semi_major, self.u_axis),
836            (self.semi_minor, self.v_axis),
837            (t0, t1),
838        )
839    }
840
841    /// Create an ellipse with explicit basis vectors (for transform/copy).
842    ///
843    /// # Errors
844    ///
845    /// Returns an error if either semi-axis is non-positive.
846    pub fn with_axes(
847        center: Point3,
848        normal: Vec3,
849        semi_major: f64,
850        semi_minor: f64,
851        u_axis: Vec3,
852        v_axis: Vec3,
853    ) -> Result<Self, MathError> {
854        if semi_major <= 0.0 || semi_minor <= 0.0 {
855            return Err(MathError::ParameterOutOfRange {
856                value: semi_major.min(semi_minor),
857                min: 0.0,
858                max: f64::INFINITY,
859            });
860        }
861        Ok(Self {
862            center,
863            normal,
864            semi_major,
865            semi_minor,
866            u_axis,
867            v_axis,
868        })
869    }
870}
871
872/// A 3D parabola defined by vertex, axis direction, and focal length.
873///
874/// Parameterized as `P(t) = vertex + (t²/(4f)) * axis_dir + t * u_axis`
875/// where `f` is the focal length and `u_axis` is perpendicular to the axis
876/// in the parabola plane.
877///
878/// The parameter `t` ranges over all reals; `t = 0` is the vertex.
879#[derive(Debug, Clone)]
880pub struct Parabola3D {
881    vertex: Point3,
882    axis_dir: Vec3,
883    focal_length: f64,
884    u_axis: Vec3,
885}
886
887impl Parabola3D {
888    /// Creates a new parabola.
889    ///
890    /// `axis_dir` is the direction from vertex toward the interior of the
891    /// parabola (the axis of symmetry). `focal_length` is the distance
892    /// from vertex to focus.
893    ///
894    /// # Errors
895    /// Returns an error if `focal_length` is not positive or `axis_dir` is zero.
896    pub fn new(vertex: Point3, axis_dir: Vec3, focal_length: f64) -> Result<Self, MathError> {
897        if focal_length <= 0.0 {
898            return Err(MathError::ParameterOutOfRange {
899                value: focal_length,
900                min: f64::EPSILON,
901                max: f64::MAX,
902            });
903        }
904        let f = Frame3::from_normal(vertex, axis_dir)?;
905        Ok(Self {
906            vertex,
907            axis_dir: f.z,
908            focal_length,
909            u_axis: f.x,
910        })
911    }
912
913    /// Evaluates the parabola at parameter `t`.
914    ///
915    /// At `t = 0` this returns the vertex.
916    #[must_use]
917    pub fn evaluate(&self, t: f64) -> Point3 {
918        let along_axis = (t * t) / (4.0 * self.focal_length);
919        self.vertex + self.axis_dir * along_axis + self.u_axis * t
920    }
921
922    /// Returns the tangent vector at parameter `t`.
923    #[must_use]
924    pub fn tangent(&self, t: f64) -> Vec3 {
925        let d_axis = t / (2.0 * self.focal_length);
926        self.axis_dir * d_axis + self.u_axis
927    }
928
929    /// Returns the curvature at parameter `t`.
930    #[must_use]
931    pub fn curvature(&self, t: f64) -> f64 {
932        let two_f = 2.0 * self.focal_length;
933        let ratio = t / two_f;
934        let denom = ratio.mul_add(ratio, 1.0);
935        1.0 / (two_f * denom.powf(1.5))
936    }
937
938    /// Returns the vertex.
939    #[must_use]
940    pub const fn vertex(&self) -> Point3 {
941        self.vertex
942    }
943
944    /// Returns the focal length.
945    #[must_use]
946    pub const fn focal_length(&self) -> f64 {
947        self.focal_length
948    }
949
950    /// Returns the axis direction (normalized).
951    #[must_use]
952    pub const fn axis_dir(&self) -> Vec3 {
953        self.axis_dir
954    }
955
956    /// Returns the in-plane u-axis (perpendicular to `axis_dir`).
957    /// At parameter `t`, the parabola is offset by `t * u_axis` from
958    /// the symmetry axis.
959    #[must_use]
960    pub const fn u_axis(&self) -> Vec3 {
961        self.u_axis
962    }
963
964    /// Returns the focus point.
965    #[must_use]
966    pub fn focus(&self) -> Point3 {
967        self.vertex + self.axis_dir * self.focal_length
968    }
969}
970
971/// A 3D hyperbola defined by center, axis, and two semi-axis lengths.
972///
973/// Parameterized as `P(t) = center + a * cosh(t) * u_axis + b * sinh(t) * v_axis`.
974///
975/// The parameter `t` ranges over all reals; `t = 0` gives the vertex
976/// closest to center on the positive branch.
977#[derive(Debug, Clone)]
978pub struct Hyperbola3D {
979    center: Point3,
980    normal: Vec3,
981    semi_major: f64,
982    semi_minor: f64,
983    u_axis: Vec3,
984    v_axis: Vec3,
985}
986
987impl Hyperbola3D {
988    /// Creates a new hyperbola.
989    ///
990    /// `semi_major` is the real semi-axis (distance from center to vertex),
991    /// `semi_minor` is the imaginary semi-axis.
992    ///
993    /// # Errors
994    /// Returns an error if either semi-axis is non-positive.
995    pub fn new(
996        center: Point3,
997        normal: Vec3,
998        semi_major: f64,
999        semi_minor: f64,
1000    ) -> Result<Self, MathError> {
1001        if semi_major <= 0.0 || semi_minor <= 0.0 {
1002            return Err(MathError::ParameterOutOfRange {
1003                value: semi_major.min(semi_minor),
1004                min: f64::EPSILON,
1005                max: f64::MAX,
1006            });
1007        }
1008        let f = Frame3::from_normal(center, normal)?;
1009        Ok(Self {
1010            center,
1011            normal: f.z,
1012            semi_major,
1013            semi_minor,
1014            u_axis: f.x,
1015            v_axis: f.y,
1016        })
1017    }
1018
1019    /// Evaluates the hyperbola at parameter `t`.
1020    #[must_use]
1021    pub fn evaluate(&self, t: f64) -> Point3 {
1022        self.center
1023            + self.u_axis * (self.semi_major * t.cosh())
1024            + self.v_axis * (self.semi_minor * t.sinh())
1025    }
1026
1027    /// Returns the tangent vector at parameter `t`.
1028    #[must_use]
1029    pub fn tangent(&self, t: f64) -> Vec3 {
1030        self.u_axis * (self.semi_major * t.sinh()) + self.v_axis * (self.semi_minor * t.cosh())
1031    }
1032
1033    /// Returns the center.
1034    #[must_use]
1035    pub const fn center(&self) -> Point3 {
1036        self.center
1037    }
1038
1039    /// Returns the semi-major axis (real axis).
1040    #[must_use]
1041    pub const fn semi_major(&self) -> f64 {
1042        self.semi_major
1043    }
1044
1045    /// Returns the semi-minor axis (imaginary axis).
1046    #[must_use]
1047    pub const fn semi_minor(&self) -> f64 {
1048        self.semi_minor
1049    }
1050
1051    /// Returns the normal (axis perpendicular to the hyperbola plane).
1052    #[must_use]
1053    pub const fn normal(&self) -> Vec3 {
1054        self.normal
1055    }
1056
1057    /// Returns the in-plane u-axis (real semi-axis direction).
1058    /// At parameter `t`, the hyperbola is at offset
1059    /// `semi_major * cosh(t) * u_axis + semi_minor * sinh(t) * v_axis`
1060    /// from the center.
1061    #[must_use]
1062    pub const fn u_axis(&self) -> Vec3 {
1063        self.u_axis
1064    }
1065
1066    /// Returns the in-plane v-axis (imaginary semi-axis direction).
1067    #[must_use]
1068    pub const fn v_axis(&self) -> Vec3 {
1069        self.v_axis
1070    }
1071
1072    /// Returns the eccentricity: `e = sqrt(1 + (b/a)²)`.
1073    #[must_use]
1074    pub fn eccentricity(&self) -> f64 {
1075        let ratio = self.semi_minor / self.semi_major;
1076        ratio.mul_add(ratio, 1.0).sqrt()
1077    }
1078
1079    /// Returns the two foci.
1080    #[must_use]
1081    pub fn foci(&self) -> (Point3, Point3) {
1082        let c = self.semi_major.hypot(self.semi_minor);
1083        (
1084            self.center + self.u_axis * c,
1085            self.center + self.u_axis * (-c),
1086        )
1087    }
1088}
1089
1090#[cfg(test)]
1091mod tests;