axiolid_evaluate/
intrinsic_relation.rs

1//! Relations over `Intrinsic3`: trimming, offsetting, and joining.
2//!
3//! # What is exact and what is refused
4//!
5//! An `Intrinsic3` stores laws against ARC LENGTH. That is what makes some
6//! relations exact and others impossible without changing the value's
7//! meaning.
8//!
9//! **Trim is exact.** Restricting to `[a, b]` does not refit anything: the
10//! curvature and torsion laws are re-anchored with `CurvatureLaw::shifted`,
11//! which the family is closed under, and the start frame is moved to the
12//! curve's own point and frame at `a`. The only numerical step is finding
13//! that anchor, which is the same quadrature the evaluator already performs
14//! -- the SHAPE stays exact.
15//!
16//! **Offset is exact only for a helix, and otherwise refused.** The normal
17//! offset `q(s) = p(s) + d * N(s)` is not unit speed: differentiating gives
18//! `|q'| = |1 - d*k(s)|`, so the offset advances at a different rate than
19//! the base. An `Intrinsic3` stores laws in arc length, so representing the
20//! offset requires reparameterising by ITS arc length. That reparameterisation
21//! is a closed-form rescale only when `k` is constant; for a varying law it is
22//! the inverse of a non-elementary integral, and writing a law in the family
23//! would be a fit, not the curve. Measured: for `k(s) = 0.20 + 0.05 s` and
24//! `d = 0.8` the offset speed sweeps `0.72 .. 0.84` across the span.
25//!
26//! For a helix the closure is genuine, and pleasant: the normal points at the
27//! axis, so offsetting slides the curve to a coaxial helix of radius `a - d`
28//! with the SAME pitch and the SAME angular rate. With `c = hypot(a, b)` and
29//! `c2 = hypot(a - d, b)`, the offset has `k2 = (a-d)/c2^2`, `tau2 = b/c2^2`,
30//! and its arc length runs at `c2/c` times the base's.
31//!
32//! **Join is exact when the ends actually meet.** Two curves compose into one
33//! `Piecewise` law when the second one's start frame is the first one's end
34//! frame; otherwise the join is a fiction and is refused by name.
35
36use axiolid_contracts::{BackendId, GeomError, GeomResult, Operation};
37use axiolid_core::{Frame3, Point3, Scalar, Vec3};
38use axiolid_curve::{CurvatureLaw, Intrinsic3};
39
40use crate::frenet::{frenet_frame, frenet_point};
41
42fn unsupported() -> GeomError {
43    GeomError::Unsupported {
44        backend: BackendId::new("axiolid-evaluate"),
45        operation: Operation::CurveEvaluation,
46    }
47}
48
49fn invalid(detail: &str) -> GeomError {
50    GeomError::InvalidInput(detail.to_owned())
51}
52
53/// Restrict a space curve to `[start, end]` of its arc length.
54///
55/// Exact in shape: the laws are re-anchored, not refitted. The returned curve
56/// has length `end - start` and its own start frame is the base curve's frame
57/// at `start`.
58pub fn trim_intrinsic3(curve: &Intrinsic3, start: Scalar, end: Scalar) -> GeomResult<Intrinsic3> {
59    if !start.is_finite() || !end.is_finite() {
60        return Err(invalid("trim bounds must be finite"));
61    }
62    if end <= start {
63        return Err(invalid("trim end must exceed trim start"));
64    }
65    if start < 0.0 || end > curve.length {
66        return Err(invalid("trim bounds must lie within the curve"));
67    }
68    let anchor = frenet_frame(curve, start)?;
69    let curvature = curve
70        .curvature
71        .shifted(start)
72        .ok_or_else(|| invalid("curvature law cannot be re-anchored at the trim start"))?;
73    let torsion = curve
74        .torsion
75        .shifted(start)
76        .ok_or_else(|| invalid("torsion law cannot be re-anchored at the trim start"))?;
77    Ok(Intrinsic3::new(anchor, curvature, torsion, end - start))
78}
79
80/// A helix's radius and pitch parameters, recovered from its laws.
81///
82/// For constant `k` and `tau`, `a = k / (k^2 + tau^2)` and
83/// `b = tau / (k^2 + tau^2)`; `hypot(a, b) = 1 / hypot(k, tau)`.
84fn helix_parameters(curve: &Intrinsic3) -> Option<(Scalar, Scalar)> {
85    let k = curve.curvature.constant_value()?;
86    let tau = curve.torsion.constant_value()?;
87    let denominator = k * k + tau * tau;
88    if denominator <= 0.0 || !denominator.is_finite() {
89        return None;
90    }
91    Some((k / denominator, tau / denominator))
92}
93
94/// Offset a space curve by `distance` along its principal normal.
95///
96/// Exact for a helix, where the offset is a coaxial helix. Refused for any
97/// varying law, because the offset is not unit speed and an `Intrinsic3`
98/// stores arc length: re-fitting would silently change the curve.
99pub fn offset_intrinsic3(curve: &Intrinsic3, distance: Scalar) -> GeomResult<Intrinsic3> {
100    if !distance.is_finite() {
101        return Err(invalid("offset distance must be finite"));
102    }
103    if distance == 0.0 {
104        return Ok(curve.clone());
105    }
106    if !curve.is_helical() {
107        // Not a representational gap: the offset of a varying-curvature space
108        // curve is not an intrinsic curve in its own arc length at all.
109        return Err(unsupported());
110    }
111    let (a, b) = helix_parameters(curve).ok_or_else(|| invalid("degenerate helix parameters"))?;
112    let radius = a - distance;
113    let c2 = radius.hypot(b);
114    if c2 <= 0.0 || !c2.is_finite() {
115        // The offset collapsed onto the axis: there is no curve to return.
116        return Err(invalid("offset distance collapses the helix onto its axis"));
117    }
118    let base = a.hypot(b);
119    if base <= 0.0 || !base.is_finite() {
120        return Err(invalid("degenerate helix parameters"));
121    }
122    let curvature = CurvatureLaw::circular(radius / (c2 * c2));
123    let torsion = CurvatureLaw::circular(b / (c2 * c2));
124
125    // The offset curve's own start frame. Its tangent is NOT the base
126    // tangent: the offset point travels at the same angular rate about the
127    // axis but on a different radius, so the tangential/axial mix changes.
128    let start = offset_start_frame(curve, distance, a, b)?;
129    // Arc length along the offset runs at c2/base times the base's.
130    Ok(Intrinsic3::new(
131        start,
132        curvature,
133        torsion,
134        curve.length * c2 / base,
135    ))
136}
137
138/// Start frame of the normal-offset of a helix.
139fn offset_start_frame(
140    curve: &Intrinsic3,
141    distance: Scalar,
142    a: Scalar,
143    b: Scalar,
144) -> GeomResult<Frame3> {
145    let base = a.hypot(b);
146    let tangent = curve.start.x;
147    let normal = curve.start.y;
148    let binormal = curve.start.z;
149    // Darboux axis in world coordinates: (tau * T + k * B) / |(k, tau)|.
150    // With a and b as above this is (b * T + a * B) / base.
151    let axis = (tangent * b + binormal * a) / base;
152    // Tangential direction: the part of the base tangent perpendicular to
153    // the axis, normalised.
154    let axial_component = tangent.dot(axis);
155    let perpendicular = tangent - axis * axial_component;
156    let perpendicular_length = perpendicular.length();
157    if perpendicular_length <= 0.0 || !perpendicular_length.is_finite() {
158        return Err(invalid("helix axis is parallel to its tangent"));
159    }
160    let tangential = perpendicular / perpendicular_length;
161    // Offset point keeps the axial speed and scales the tangential one by
162    // the radius ratio.
163    let radius = a - distance;
164    let velocity = tangential * radius + axis * b;
165    let speed = velocity.length();
166    if speed <= 0.0 || !speed.is_finite() {
167        return Err(invalid("offset has no well-defined tangent"));
168    }
169    let new_tangent = velocity / speed;
170    // The normal still points at the axis; it flips when the offset crosses
171    // the axis and the curve winds the other way round.
172    let new_normal = if radius >= 0.0 { normal } else { -normal };
173    let new_binormal = new_tangent.cross(new_normal);
174    let binormal_length = new_binormal.length();
175    if binormal_length <= 0.0 || !binormal_length.is_finite() {
176        return Err(invalid("offset frame is degenerate"));
177    }
178    Ok(Frame3 {
179        origin: curve.start.origin + normal * distance,
180        x: new_tangent,
181        y: new_normal,
182        z: new_binormal / binormal_length,
183    })
184}
185
186/// Join two space curves into one, when the second continues the first.
187///
188/// Exact: the result carries both laws as a `Piecewise` seam at the first
189/// curve's length. Refused when the curves do not actually meet, because a
190/// joined curve that jumps is not the curve either input described.
191pub fn join_intrinsic3(
192    first: &Intrinsic3,
193    second: &Intrinsic3,
194    position_tolerance: Scalar,
195    direction_tolerance: Scalar,
196) -> GeomResult<Intrinsic3> {
197    if !position_tolerance.is_finite() || position_tolerance < 0.0 {
198        return Err(invalid(
199            "position tolerance must be finite and non-negative",
200        ));
201    }
202    if !direction_tolerance.is_finite() || direction_tolerance < 0.0 {
203        return Err(invalid(
204            "direction tolerance must be finite and non-negative",
205        ));
206    }
207    let end_point = frenet_point(first, first.length)?;
208    let end_frame = frenet_frame(first, first.length)?;
209    if !meets(end_point, second.start.origin, position_tolerance) {
210        return Err(invalid(
211            "curves do not meet: the second does not start where the first ends",
212        ));
213    }
214    if !aligned(end_frame.x, second.start.x, direction_tolerance) {
215        return Err(invalid(
216            "curves meet but their tangents disagree, so the join would kink",
217        ));
218    }
219    let curvature = CurvatureLaw::piecewise(
220        vec![first.length],
221        vec![first.curvature.clone(), second.curvature.clone()],
222    );
223    let torsion = CurvatureLaw::piecewise(
224        vec![first.length],
225        vec![first.torsion.clone(), second.torsion.clone()],
226    );
227    Ok(Intrinsic3::new(
228        first.start,
229        curvature,
230        torsion,
231        first.length + second.length,
232    ))
233}
234
235fn meets(a: Point3, b: Point3, tolerance: Scalar) -> bool {
236    (a - b).length() <= tolerance
237}
238
239fn aligned(a: Vec3, b: Vec3, tolerance: Scalar) -> bool {
240    (a - b).length() <= tolerance
241}