Skip to main content

molgfx_math/curves/
transport.rs

1//! Twist-free parallel-transport frames along a sampled curve.
2
3use crate::{CurveSample, Quat, Vec3};
4
5#[cfg(test)]
6#[path = "transport_tests.rs"]
7mod tests;
8
9/// Orthonormal frame carried along a curve.
10#[derive(Clone, Copy, PartialEq, Debug)]
11pub struct TransportFrame {
12    /// Curve direction.
13    pub tangent: Vec3,
14    /// Ribbon-width axis.
15    pub normal: Vec3,
16    /// Ribbon-thickness axis.
17    pub binormal: Vec3,
18}
19
20/// Builds minimal-rotation frames into caller-owned storage. The initial axis
21/// is selected deterministically to avoid degeneracy near a world axis.
22pub fn parallel_transport(samples: &[CurveSample], out: &mut Vec<TransportFrame>) {
23    out.clear();
24    let Some(first) = samples.first() else {
25        return;
26    };
27    let tangent = normalized(first.tangent, Vec3::Z);
28    let seed = least_aligned_axis(tangent);
29    let normal = normalized(seed.cross(tangent), Vec3::X);
30    out.push(frame(tangent, normal));
31    for sample in &samples[1..] {
32        let Some(previous) = out.last().copied() else {
33            break;
34        };
35        let next_tangent = normalized(sample.tangent, previous.tangent);
36        let rotation = Quat::from_rotation_arc(previous.tangent, next_tangent);
37        // The rotation carries the previous normal to one perpendicular to the
38        // new tangent. Renormalizing that single vector each step bounds
39        // floating-point drift; the binormal is then an exact unit cross of two
40        // orthonormal vectors, so no further Gram-Schmidt pass is needed.
41        let normal = normalized(rotation * previous.normal, previous.normal);
42        out.push(TransportFrame {
43            tangent: next_tangent,
44            normal,
45            binormal: next_tangent.cross(normal),
46        });
47    }
48}
49
50#[inline]
51fn frame(tangent: Vec3, normal_hint: Vec3) -> TransportFrame {
52    let normal = normalized(normal_hint - tangent * normal_hint.dot(tangent), Vec3::X);
53    let binormal = normalized(tangent.cross(normal), Vec3::Y);
54    TransportFrame {
55        tangent,
56        normal: normalized(binormal.cross(tangent), normal),
57        binormal,
58    }
59}
60
61#[inline]
62fn normalized(value: Vec3, fallback: Vec3) -> Vec3 {
63    match value.try_normalize() {
64        Some(unit) => unit,
65        None => fallback,
66    }
67}
68
69#[inline]
70fn least_aligned_axis(tangent: Vec3) -> Vec3 {
71    let absolute = tangent.abs();
72    if absolute.x <= absolute.y && absolute.x <= absolute.z {
73        Vec3::X
74    } else if absolute.y <= absolute.z {
75        Vec3::Y
76    } else {
77        Vec3::Z
78    }
79}