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}