axiolid_curve/intrinsic3.rs
1//! Space curves given by their natural equations: curvature AND torsion as
2//! functions of arc length, anchored to a start frame.
3//!
4//! # Why this is a separate type from `Intrinsic2`
5//!
6//! In the plane a curve's shape is fixed by curvature alone, and the frame is
7//! a single angle: `theta(s) = theta_0 + int k`. Angles commute, so the
8//! heading has an elementary closed form and only POSITION needs quadrature.
9//!
10//! In space the frame is a rotation, and rotations do NOT commute. The
11//! Frenet-Serret system
12//!
13//! ```text
14//! T' = k N
15//! N' = -k T + tau B
16//! B' = -tau N
17//! ```
18//!
19//! is a matrix ODE `R'(s) = R(s) Omega(s)` on SO(3). Its solution is a
20//! product integral, not `exp(int Omega)` -- those agree only when the
21//! generators at different arc lengths commute, which happens exactly when
22//! the ratio `tau/k` is constant (a helix, or a plane curve). So a space
23//! curve cannot reuse the 2D trick: the FRAME needs integration too, not
24//! just the position.
25//!
26//! This is why `Intrinsic3` is its own type rather than an `Intrinsic2` with
27//! a torsion field bolted on -- the mathematics of recovering a frame is
28//! different in kind, not in degree.
29
30use crate::intrinsic::CurvatureLaw;
31use axiolid_core::{Frame3, Scalar};
32
33/// A space curve given by its natural equations.
34///
35/// `curvature` is `k(s) >= 0` by the Frenet convention; a signed law is
36/// storable, because dirty imported data stays representable here as
37/// everywhere else in this crate, and naming it is a validator's job.
38///
39/// `torsion` is `tau(s)`, signed: positive is a right-handed screw. Torsion
40/// identically zero is a plane curve, and the plane is the one spanned by
41/// the start frame's `x` and `y` axes.
42///
43/// Both laws reuse `CurvatureLaw`, which is the general "scalar function of
44/// arc length" in this crate: constant, polynomial, sinusoid, their sum, and
45/// piecewise combinations. Nothing about it is curvature-specific, and a
46/// second near-identical enum for torsion would be duplication with a
47/// different name.
48#[derive(Debug, Clone, PartialEq)]
49pub struct Intrinsic3 {
50 /// Start frame: origin at the curve start, `x` along the start tangent,
51 /// `y` along the start normal, `z` along the start binormal.
52 pub start: Frame3,
53 /// Curvature as a function of arc length from `start`.
54 pub curvature: CurvatureLaw,
55 /// Torsion as a function of arc length from `start`.
56 pub torsion: CurvatureLaw,
57 /// Arc length the laws are defined over.
58 pub length: Scalar,
59}
60
61impl Intrinsic3 {
62 /// Anchor a curvature and a torsion law to a start frame over an arc length.
63 #[must_use]
64 pub const fn new(
65 start: Frame3,
66 curvature: CurvatureLaw,
67 torsion: CurvatureLaw,
68 length: Scalar,
69 ) -> Self {
70 Self {
71 start,
72 curvature,
73 torsion,
74 length,
75 }
76 }
77
78 /// Whether the curve is planar: torsion identically zero.
79 ///
80 /// A planar space curve is exactly a 2D curve embedded in the start
81 /// frame's plane, so this is the predicate that decides whether the
82 /// cheaper 2D path applies.
83 #[must_use]
84 pub fn is_planar(&self) -> bool {
85 self.torsion.is_straight()
86 }
87
88 /// Whether the curve is a helix: curvature and torsion both constant.
89 ///
90 /// This is the case where the Frenet generators commute at every arc
91 /// length, so the product integral collapses to a single matrix
92 /// exponential and the curve has an elementary closed form. Worth naming
93 /// because it is both the classical example and the exactly-solvable case.
94 #[must_use]
95 pub fn is_helical(&self) -> bool {
96 self.curvature.is_constant() && self.torsion.is_constant()
97 }
98
99 /// Total tangent turning over the curve, in radians, in closed form.
100 ///
101 /// The integral of `k` -- exact, as in 2D. Note this is NOT enough to
102 /// recover the frame in space, only the amount the tangent has swung.
103 #[must_use]
104 pub fn total_turning(&self) -> Option<Scalar> {
105 if !self.length.is_finite() {
106 return None;
107 }
108 crate::Intrinsic2::new(
109 axiolid_core::Frame2 {
110 origin: axiolid_core::Point2::new(0.0, 0.0),
111 x: axiolid_core::Vec2::X,
112 y: axiolid_core::Vec2::Y,
113 },
114 self.curvature.clone(),
115 self.length,
116 )
117 .total_turning()
118 }
119
120 /// Total torsion over the curve, in radians, in closed form.
121 ///
122 /// The integral of `tau`. For a helix this is the angle the binormal has
123 /// swung about the axis.
124 #[must_use]
125 pub fn total_torsion(&self) -> Option<Scalar> {
126 if !self.length.is_finite() {
127 return None;
128 }
129 crate::Intrinsic2::new(
130 axiolid_core::Frame2 {
131 origin: axiolid_core::Point2::new(0.0, 0.0),
132 x: axiolid_core::Vec2::X,
133 y: axiolid_core::Vec2::Y,
134 },
135 self.torsion.clone(),
136 self.length,
137 )
138 .total_turning()
139 }
140}