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