axiolid_evaluate/
provider.rs

1//! The reference curve-evaluation provider.
2//!
3//! Implements [`CurveEvaluator`] over the families where a DISTANCE can
4//! be honoured exactly, and refuses by name where it cannot. See
5//! `docs/adr/0063-curve-evaluation-contract.md`.
6
7use axiolid_contracts::{
8    Backend, BackendDescriptor, BackendId, Determinism, ExecutionTarget, GeomError, GeomResult,
9};
10use axiolid_core::{Frame3, Point3, Scalar, Vec3};
11use axiolid_curve::Curve3;
12use axiolid_curve_evaluate_contract::{CurveEvaluator, CurveMeasure, DistanceConvention};
13
14use crate::arc_length::{elevated_point, elevated_tangent};
15use crate::frenet::{frenet_point, frenet_tangent};
16use crate::polyline_length::polyline_parameter;
17
18/// Reference curve evaluator.
19///
20/// `up` is the reference direction the oriented frame is built against.
21/// It is carried by the provider rather than passed per call so that one
22/// consumer cannot silently place two objects against different
23/// conventions on the same alignment.
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct ReferenceCurveEvaluator {
26    up: Vec3,
27}
28
29impl ReferenceCurveEvaluator {
30    /// Backend identity.
31    pub const ID: BackendId = BackendId::new("axiolid-evaluate");
32
33    /// Evaluator whose reference up is global `+Z`.
34    ///
35    /// The right default for civil and building work, where `z` is up by
36    /// construction.
37    #[must_use]
38    pub const fn new() -> Self {
39        Self { up: Vec3::Z }
40    }
41
42    /// Evaluator with an explicit reference up direction.
43    ///
44    /// Returns `None` for a non-finite or zero vector, which cannot
45    /// define a roll convention.
46    #[must_use]
47    pub fn with_up(up: Vec3) -> Option<Self> {
48        let length = up.length();
49        if !length.is_finite() || length <= 0.0 {
50            return None;
51        }
52        Some(Self { up: up / length })
53    }
54
55    /// The reference up direction this evaluator builds frames against.
56    #[must_use]
57    pub const fn up(&self) -> Vec3 {
58        self.up
59    }
60}
61
62impl Default for ReferenceCurveEvaluator {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl Backend for ReferenceCurveEvaluator {
69    fn descriptor(&self) -> BackendDescriptor {
70        BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
71    }
72}
73
74fn unsupported() -> GeomError {
75    GeomError::Unsupported {
76        backend: ReferenceCurveEvaluator::ID,
77        operation: axiolid_contracts::Operation::CurveEvaluation,
78    }
79}
80
81fn invalid(detail: &str) -> GeomError {
82    GeomError::InvalidInput(detail.into())
83}
84
85/// The carried number, rejected here if it is not finite.
86///
87/// A NaN parameter would otherwise reach the evaluators and be refused
88/// as a `curve parameter`, naming an internal concept rather than what
89/// the caller actually passed.
90fn finite_value(at: CurveMeasure) -> GeomResult<Scalar> {
91    let value = at.value();
92    if !value.is_finite() {
93        return Err(invalid("curve measure must be finite"));
94    }
95    Ok(value)
96}
97/// Which distance this provider can honour for `curve`.
98///
99/// The families divide by whether distance is RECOVERABLE in closed
100/// form, not by whether they can be evaluated at all:
101///
102/// - `Intrinsic` is already parameterised by arc length, so distance is
103///   the native parameter -- nothing to convert.
104/// - `Line` and `Circle` have an elementary arc length, so a distance
105///   maps to a parameter exactly (`d / |direction|`, `d / radius`).
106/// - `Elevated` is authored against PLAN distance, and that is what it
107///   is reported as. It is deliberately NOT called arc length: on a 5%
108///   grade the true 3D length exceeds the plan distance by 0.125 m per
109///   100 m, and quietly conflating the two would misplace an object by
110///   that much.
111/// - `Polyline` has an exact arc length: a finite sum of segment
112///   lengths, located by a running sum and one linear interpolation. Its
113///   only transcendental is the same per-segment `sqrt` that `Line`
114///   already reports as `ArcLength3d`, so refusing the sequence while
115///   accepting each element was not defensible (kernel#107). Seam,
116///   degenerate-segment and closed-wrap behaviour is pinned in
117///   `polyline_length`.
118/// - `Ellipse` and `BSpline` have no closed-form arc length (the ellipse
119///   needs an elliptic integral, the spline needs quadrature plus
120///   numeric inversion), so distance is refused rather than approximated
121///   behind an exact-looking signature.
122fn convention_for(curve: &Curve3) -> DistanceConvention {
123    match curve {
124        Curve3::Intrinsic(_) | Curve3::Line(_) | Curve3::Circle(_) | Curve3::Polyline(_) => {
125            DistanceConvention::ArcLength3d
126        }
127        Curve3::Elevated(_) => DistanceConvention::PlanDistance,
128        _ => DistanceConvention::Unsupported,
129    }
130}
131
132/// Convert a distance to the curve's native parameter, exactly.
133fn parameter_for(curve: &Curve3, distance: Scalar) -> GeomResult<Scalar> {
134    if !distance.is_finite() {
135        return Err(invalid("distance along a curve must be finite"));
136    }
137    match curve {
138        // Already arc length.
139        Curve3::Intrinsic(_) | Curve3::Elevated(_) => Ok(distance),
140        // The parameter advances |direction| per unit, so a caller-facing
141        // distance must be divided by it. Import adapters may preserve a
142        // non-unit direction, so this is not a no-op in practice.
143        Curve3::Line(l) => {
144            let speed = l.direction.length();
145            if !speed.is_finite() || speed <= 0.0 {
146                return Err(invalid("line has no direction, so no distance along it"));
147            }
148            Ok(distance / speed)
149        }
150        // Angle = arc / radius.
151        Curve3::Circle(c) => {
152            if !c.radius.is_finite() || c.radius <= 0.0 {
153                return Err(invalid("circle has no positive radius"));
154            }
155            Ok(distance / c.radius)
156        }
157        // Running sum over segment lengths; exact, no iteration.
158        Curve3::Polyline(p) => polyline_parameter(p, distance),
159        _ => Err(unsupported()),
160    }
161}
162
163impl CurveEvaluator for ReferenceCurveEvaluator {
164    fn distance_convention(&self, curve: &Curve3) -> DistanceConvention {
165        convention_for(curve)
166    }
167
168    /// Bitwise: every path is deterministic floating-point arithmetic
169    /// with no hashing, threading or iteration-order dependence.
170    fn determinism(&self) -> Determinism {
171        Determinism::Bitwise
172    }
173
174    fn point_at(&self, curve: &Curve3, at: CurveMeasure) -> GeomResult<Point3> {
175        // A native parameter needs no conversion and no convention, so it
176        // works for EVERY family -- including the ones whose arc length has
177        // no closed form and whose distance route is refused.
178        let CurveMeasure::Distance(distance) = at else {
179            return crate::curve::evaluate3(curve, finite_value(at)?);
180        };
181        match curve {
182            // Native arc-length families: straight through, no conversion.
183            Curve3::Intrinsic(i) => frenet_point(i, distance),
184            Curve3::Elevated(e) => elevated_point(e, distance),
185            Curve3::Line(_) | Curve3::Circle(_) | Curve3::Polyline(_) => {
186                crate::curve::evaluate3(curve, parameter_for(curve, distance)?)
187            }
188            _ => Err(unsupported()),
189        }
190    }
191
192    fn tangent_at(&self, curve: &Curve3, at: CurveMeasure) -> GeomResult<Vec3> {
193        let raw = match at {
194            CurveMeasure::Parameter(_) => crate::curve::derivative3(curve, finite_value(at)?)?,
195            CurveMeasure::Distance(distance) => match curve {
196                Curve3::Intrinsic(i) => frenet_tangent(i, distance)?,
197                Curve3::Elevated(e) => elevated_tangent(e, distance)?,
198                Curve3::Line(_) | Curve3::Circle(_) | Curve3::Polyline(_) => {
199                    crate::curve::derivative3(curve, parameter_for(curve, distance)?)?
200                }
201                _ => return Err(unsupported()),
202            },
203            // `CurveMeasure` is #[non_exhaustive]. An unknown method of
204            // measurement is refused by name rather than guessed at.
205            _ => return Err(unsupported()),
206        };
207        // The contract promises a UNIT tangent. The intrinsic and elevated
208        // evaluators already return one; a line's derivative is its raw
209        // direction and a circle's scales with radius, so normalising here
210        // is what makes the families interchangeable to a caller.
211        let length = raw.length();
212        if !length.is_finite() || length <= 0.0 {
213            return Err(invalid("curve has no tangent direction there"));
214        }
215        Ok(raw / length)
216    }
217
218    fn frame_at(&self, curve: &Curve3, at: CurveMeasure) -> GeomResult<Frame3> {
219        let origin = self.point_at(curve, at)?;
220        let tangent = self.tangent_at(curve, at)?;
221        // Reference-up construction. `right` is perpendicular to both the
222        // tangent and the reference direction; `up` is then recovered from
223        // those two so the triad is exactly orthonormal even when the
224        // tangent is not perpendicular to the reference direction.
225        let right = tangent.cross(self.up);
226        let magnitude = right.length();
227        // Parallel tangent and reference: roll is genuinely undetermined.
228        // Refuse rather than invent one, because a silently arbitrary roll
229        // rotates whatever is placed by it.
230        if !magnitude.is_finite() || magnitude <= 1e-12 {
231            return Err(invalid(
232                "tangent is parallel to the reference up direction, so roll is undefined",
233            ));
234        }
235        let right = right / magnitude;
236        let up = right.cross(tangent);
237        Ok(Frame3 {
238            origin,
239            x: tangent,
240            y: up,
241            z: right,
242        })
243    }
244}