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}