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)]
49#[cfg_attr(
50 feature = "serde",
51 derive(serde::Serialize, serde::Deserialize),
52 serde(deny_unknown_fields)
53)]
54pub struct Intrinsic3 {
55 /// Start frame: origin at the curve start, `x` along the start tangent,
56 /// `y` along the start normal, `z` along the start binormal.
57 pub start: Frame3,
58 /// Curvature as a function of arc length from `start`.
59 pub curvature: CurvatureLaw,
60 /// Torsion as a function of arc length from `start`.
61 pub torsion: CurvatureLaw,
62 /// Arc length the laws are defined over.
63 pub length: Scalar,
64}
65
66impl Intrinsic3 {
67 /// Anchor a curvature and a torsion law to a start frame over an arc length.
68 #[must_use]
69 pub const fn new(
70 start: Frame3,
71 curvature: CurvatureLaw,
72 torsion: CurvatureLaw,
73 length: Scalar,
74 ) -> Self {
75 Self {
76 start,
77 curvature,
78 torsion,
79 length,
80 }
81 }
82
83 /// Whether the curve is planar: torsion identically zero.
84 ///
85 /// A planar space curve is exactly a 2D curve embedded in the start
86 /// frame's plane, so this is the predicate that decides whether the
87 /// cheaper 2D path applies.
88 #[must_use]
89 pub fn is_planar(&self) -> bool {
90 self.torsion.is_straight()
91 }
92
93 /// Whether the curve is a helix: curvature and torsion both constant.
94 ///
95 /// This is the case where the Frenet generators commute at every arc
96 /// length, so the product integral collapses to a single matrix
97 /// exponential and the curve has an elementary closed form. Worth naming
98 /// because it is both the classical example and the exactly-solvable case.
99 #[must_use]
100 pub fn is_helical(&self) -> bool {
101 self.curvature.is_constant() && self.torsion.is_constant()
102 }
103
104 /// Total tangent turning over the curve, in radians, in closed form.
105 ///
106 /// The integral of `k` -- exact, as in 2D. Note this is NOT enough to
107 /// recover the frame in space, only the amount the tangent has swung.
108 #[must_use]
109 pub fn total_turning(&self) -> Option<Scalar> {
110 if !self.length.is_finite() {
111 return None;
112 }
113 crate::Intrinsic2::new(
114 axiolid_core::Frame2 {
115 origin: axiolid_core::Point2::new(0.0, 0.0),
116 x: axiolid_core::Vec2::X,
117 y: axiolid_core::Vec2::Y,
118 },
119 self.curvature.clone(),
120 self.length,
121 )
122 .total_turning()
123 }
124
125 /// Total torsion over the curve, in radians, in closed form.
126 ///
127 /// The integral of `tau`. For a helix this is the angle the binormal has
128 /// swung about the axis.
129 #[must_use]
130 pub fn total_torsion(&self) -> Option<Scalar> {
131 if !self.length.is_finite() {
132 return None;
133 }
134 crate::Intrinsic2::new(
135 axiolid_core::Frame2 {
136 origin: axiolid_core::Point2::new(0.0, 0.0),
137 x: axiolid_core::Vec2::X,
138 y: axiolid_core::Vec2::Y,
139 },
140 self.torsion.clone(),
141 self.length,
142 )
143 .total_turning()
144 }
145}