Skip to main content

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}