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//!
7//! The sided queries (`point_at_on`, `tangent_at_on`, `frame_at_on`,
8//! #286) answer a measure ON a seam with the station reading of that side
9//! ([`crate::station::station_section3_on`]), whose seam rule and
10//! tolerance they reuse rather than restate; every other measure is
11//! answered side-lessly. See the ADR 0082 amendment for #286.
12//!
13//! The curve-path queries (`path_point_at_on`, `path_tangent_at_on`,
14//! `path_frame_at_on`, #290) read a path through
15//! [`CompositeBasis::from_path`] and [`CompositeBasis::section_on`], the
16//! reading a composite station basis gets (#285), so a placement framed
17//! through the contract on a curve relation agrees with the same station
18//! lowered as geometry, bitwise against `+Z`. See the ADR 0082 amendment
19//! for #290.
20
21use axiolid_contracts::{
22 Backend, BackendDescriptor, BackendId, Determinism, ExecutionTarget, GeomError, GeomResult,
23};
24use axiolid_core::{Frame3, Point3, Scalar, Vec3};
25use axiolid_curve::{Curve3, CurvePath, SeamSide};
26use axiolid_curve_evaluate_contract::{
27 CurveEvaluator, CurveMeasure, DistanceConvention, DistanceInterval,
28};
29
30use crate::arc_length::{elevated_point, elevated_tangent};
31use crate::banked::{banked_point, banked_section, banked_tangent};
32use crate::frenet::{frenet_point, frenet_tangent};
33use crate::polyline_length::polyline_parameter;
34use crate::station::{
35 station_on_seam3, station_section3_on, CompositeBasis, SectionFrame, StationCurve,
36};
37
38/// Reference curve evaluator.
39///
40/// `up` is the reference direction the oriented frame is built against.
41/// It is carried by the provider rather than passed per call so that one
42/// consumer cannot silently place two objects against different
43/// conventions on the same alignment.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct ReferenceCurveEvaluator {
46 up: Vec3,
47}
48
49impl ReferenceCurveEvaluator {
50 /// Backend identity.
51 pub const ID: BackendId = BackendId::new("axiolid-evaluate");
52
53 /// Evaluator whose reference up is global `+Z`.
54 ///
55 /// The right default for civil and building work, where `z` is up by
56 /// construction.
57 #[must_use]
58 pub const fn new() -> Self {
59 Self { up: Vec3::Z }
60 }
61
62 /// Evaluator with an explicit reference up direction.
63 ///
64 /// Returns `None` for a non-finite or zero vector, which cannot
65 /// define a roll convention.
66 #[must_use]
67 pub fn with_up(up: Vec3) -> Option<Self> {
68 let length = up.length();
69 if !length.is_finite() || length <= 0.0 {
70 return None;
71 }
72 Some(Self { up: up / length })
73 }
74
75 /// The reference up direction this evaluator builds frames against.
76 #[must_use]
77 pub const fn up(&self) -> Vec3 {
78 self.up
79 }
80}
81
82impl ReferenceCurveEvaluator {
83 /// A banked curve carries its own roll, so its frame is its section
84 /// frame rather than the reference-up one (ADR 0081). Its cant is
85 /// measured against `+Z`, so an evaluator built against another up
86 /// refuses rather than mix the two.
87 fn banked_frame(&self, curve: &axiolid_curve::Banked3, at: CurveMeasure) -> GeomResult<Frame3> {
88 self.banked_up()?;
89 let distance = match at {
90 CurveMeasure::Distance(distance) | CurveMeasure::Parameter(distance) => distance,
91 _ => return Err(unsupported()),
92 };
93 if !distance.is_finite() {
94 return Err(invalid("curve measure must be finite"));
95 }
96 Ok(banked_section(curve, distance)?.frame())
97 }
98
99 /// A banked curve's roll is measured against `+Z`: refuse to frame
100 /// one against another reference up rather than mix the two.
101 fn banked_up(&self) -> GeomResult<()> {
102 if self.up != Vec3::Z {
103 return Err(invalid(
104 "a banked curve's cant is measured against +Z; this evaluator's reference up differs",
105 ));
106 }
107 Ok(())
108 }
109
110 /// The reference-up frame at `origin` with unit `tangent`: `right =
111 /// normalise(tangent x up)`, `up' = right x tangent`, laid out `x`
112 /// tangent, `y` up, `z` right.
113 fn reference_frame(&self, origin: Point3, tangent: Vec3) -> GeomResult<Frame3> {
114 // `right` is perpendicular to both the tangent and the reference
115 // direction; `up` is then recovered from those two so the triad is
116 // exactly orthonormal even when the tangent is not perpendicular
117 // to the reference direction.
118 let right = tangent.cross(self.up);
119 let magnitude = right.length();
120 // Parallel tangent and reference: roll is genuinely undetermined.
121 // Refuse rather than invent one, because a silently arbitrary roll
122 // rotates whatever is placed by it.
123 if !magnitude.is_finite() || magnitude <= 1e-12 {
124 return Err(invalid(
125 "tangent is parallel to the reference up direction, so roll is undefined",
126 ));
127 }
128 let right = right / magnitude;
129 let up = right.cross(tangent);
130 Ok(Frame3 {
131 origin,
132 x: tangent,
133 y: up,
134 z: right,
135 })
136 }
137
138 /// The station section a sided query reads where `at` lies ON a seam
139 /// (#286), `None` where the side-less query answers.
140 ///
141 /// The seam and its tolerance are the station rule's (ADR 0082, #263):
142 /// a measure within `ARC_LENGTH_TOLERANCE * max(1, s)` of a seam whose
143 /// frame may jump is read at the seam from `side`'s piece by
144 /// [`station_section3_on`], for both sides. A distance is located in
145 /// this provider's convention for the curve, which is the station
146 /// measure on every family it measures (plan distance on an elevated or
147 /// banked curve, arc length on a polyline), as is a native parameter of
148 /// an elevated or banked curve. A native parameter of a polyline or a
149 /// B-spline is not located: `Incoming` is refused there by name and
150 /// `Outgoing` is the side-less reading.
151 fn seam_section(
152 &self,
153 curve: &Curve3,
154 at: CurveMeasure,
155 side: SeamSide,
156 ) -> GeomResult<Option<SectionFrame>> {
157 // `SeamSide` is #[non_exhaustive]: an unknown side is refused by
158 // name rather than read as either known one.
159 if !matches!(side, SeamSide::Outgoing | SeamSide::Incoming) {
160 return Err(unsupported());
161 }
162 let distance = match at {
163 CurveMeasure::Distance(distance) if convention_for(curve).is_supported() => distance,
164 CurveMeasure::Parameter(parameter)
165 if matches!(curve, Curve3::Elevated(_) | Curve3::Banked(_)) =>
166 {
167 parameter
168 }
169 CurveMeasure::Parameter(_)
170 if side == SeamSide::Incoming
171 && matches!(curve, Curve3::Polyline(_) | Curve3::BSpline(_)) =>
172 {
173 return Err(GeomError::UnsupportedInput {
174 backend: Self::ID,
175 operation: axiolid_contracts::Operation::CurveEvaluation,
176 input: PARAMETER_SIDE,
177 });
178 }
179 // Families with no seam a side reads, an unsupported distance
180 // and an unknown measure: the side-less query answers or
181 // refuses by name.
182 _ => return Ok(None),
183 };
184 if !distance.is_finite() || !station_on_seam3(curve, distance)? {
185 return Ok(None);
186 }
187 station_section3_on(curve, distance, side).map(Some)
188 }
189}
190
191impl ReferenceCurveEvaluator {
192 /// The distance a path query locates: a [`CurveMeasure::Distance`],
193 /// finite. A path has no native parameter (its pieces' parameters do
194 /// not run end to end), so a [`CurveMeasure::Parameter`] is refused by
195 /// name; an unknown side or measure as unsupported.
196 fn path_distance(at: CurveMeasure, side: SeamSide) -> GeomResult<Scalar> {
197 if !matches!(side, SeamSide::Outgoing | SeamSide::Incoming) {
198 return Err(unsupported());
199 }
200 match at {
201 CurveMeasure::Distance(distance) if distance.is_finite() => Ok(distance),
202 CurveMeasure::Distance(_) => Err(invalid("distance along a curve path must be finite")),
203 CurveMeasure::Parameter(_) => Err(GeomError::UnsupportedInput {
204 backend: Self::ID,
205 operation: axiolid_contracts::Operation::CurveEvaluation,
206 input: PATH_PARAMETER,
207 }),
208 _ => Err(unsupported()),
209 }
210 }
211
212 /// The composite basis of `path` and the station section `side` reads
213 /// at `at` along it (#290).
214 fn path_section<'c>(
215 path: &'c CurvePath,
216 at: CurveMeasure,
217 side: SeamSide,
218 ) -> GeomResult<(CompositeBasis<'c>, Scalar, SectionFrame)> {
219 let distance = Self::path_distance(at, side)?;
220 let basis = CompositeBasis::from_path(path)?;
221 let section = basis.section_on(distance, side)?;
222 Ok((basis, distance, section))
223 }
224}
225
226/// The input a path query by native parameter is refused for.
227const PATH_PARAMETER: &str =
228 "a native parameter along a curve path: its pieces' parameters do not run end to end, so a \
229 path is located by station distance";
230
231/// The input a sided query on a polyline's or a B-spline's native
232/// parameter is refused for.
233const PARAMETER_SIDE: &str =
234 "the incoming side at a native parameter of a polyline or a B-spline: seams are located \
235 by station distance";
236
237impl Default for ReferenceCurveEvaluator {
238 fn default() -> Self {
239 Self::new()
240 }
241}
242
243impl Backend for ReferenceCurveEvaluator {
244 fn descriptor(&self) -> BackendDescriptor {
245 BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
246 }
247}
248
249fn unsupported() -> GeomError {
250 GeomError::Unsupported {
251 backend: ReferenceCurveEvaluator::ID,
252 operation: axiolid_contracts::Operation::CurveEvaluation,
253 }
254}
255
256fn invalid(detail: &str) -> GeomError {
257 GeomError::InvalidInput(detail.into())
258}
259
260/// The carried number, rejected here if it is not finite.
261///
262/// A NaN parameter would otherwise reach the evaluators and be refused
263/// as a `curve parameter`, naming an internal concept rather than what
264/// the caller actually passed.
265fn finite_value(at: CurveMeasure) -> GeomResult<Scalar> {
266 let value = at.value();
267 if !value.is_finite() {
268 return Err(invalid("curve measure must be finite"));
269 }
270 Ok(value)
271}
272/// Which distance this provider can honour for `curve`.
273///
274/// The families divide by whether distance is RECOVERABLE in closed
275/// form, not by whether they can be evaluated at all:
276///
277/// - `Intrinsic` is already parameterised by arc length, so distance is
278/// the native parameter -- nothing to convert.
279/// - `Line` and `Circle` have an elementary arc length, so a distance
280/// maps to a parameter exactly (`d / |direction|`, `d / radius`).
281/// - `Elevated` is authored against PLAN distance, and that is what it
282/// is reported as. It is deliberately NOT called arc length: on a 5%
283/// grade the true 3D length exceeds the plan distance by 0.125 m per
284/// 100 m, and quietly conflating the two would misplace an object by
285/// that much.
286/// - `Banked` is its centreline's: PLAN distance, for the same reason
287/// (ADR 0081).
288/// - `Polyline` has an exact arc length: a finite sum of segment
289/// lengths, located by a running sum and one linear interpolation. Its
290/// only transcendental is the same per-segment `sqrt` that `Line`
291/// already reports as `ArcLength3d`, so refusing the sequence while
292/// accepting each element was not defensible (kernel#107). Seam,
293/// degenerate-segment and closed-wrap behaviour is pinned in
294/// `polyline_length`.
295/// - `Ellipse` and `BSpline` have no closed-form arc length (the ellipse
296/// needs an elliptic integral, the spline needs quadrature plus
297/// numeric inversion), so distance is refused rather than approximated
298/// behind an exact-looking signature.
299fn convention_for(curve: &Curve3) -> DistanceConvention {
300 match curve {
301 Curve3::Intrinsic(_) | Curve3::Line(_) | Curve3::Circle(_) | Curve3::Polyline(_) => {
302 DistanceConvention::ArcLength3d
303 }
304 Curve3::Elevated(_) | Curve3::Banked(_) => DistanceConvention::PlanDistance,
305 _ => DistanceConvention::Unsupported,
306 }
307}
308
309/// Convert a distance to the curve's native parameter, exactly.
310fn parameter_for(curve: &Curve3, distance: Scalar) -> GeomResult<Scalar> {
311 if !distance.is_finite() {
312 return Err(invalid("distance along a curve must be finite"));
313 }
314 match curve {
315 // Already arc length.
316 Curve3::Intrinsic(_) | Curve3::Elevated(_) | Curve3::Banked(_) => Ok(distance),
317 // The parameter advances |direction| per unit, so a caller-facing
318 // distance must be divided by it. Import adapters may preserve a
319 // non-unit direction, so this is not a no-op in practice.
320 Curve3::Line(l) => {
321 let speed = l.direction.length();
322 if !speed.is_finite() || speed <= 0.0 {
323 return Err(invalid("line has no direction, so no distance along it"));
324 }
325 Ok(distance / speed)
326 }
327 // Angle = arc / radius.
328 Curve3::Circle(c) => {
329 if !c.radius.is_finite() || c.radius <= 0.0 {
330 return Err(invalid("circle has no positive radius"));
331 }
332 Ok(distance / c.radius)
333 }
334 // Running sum over segment lengths; exact, no iteration.
335 Curve3::Polyline(p) => polyline_parameter(p, distance),
336 _ => Err(unsupported()),
337 }
338}
339
340impl CurveEvaluator for ReferenceCurveEvaluator {
341 fn distance_convention(&self, curve: &Curve3) -> DistanceConvention {
342 convention_for(curve)
343 }
344
345 /// Bitwise: every path is deterministic floating-point arithmetic
346 /// with no hashing, threading or iteration-order dependence.
347 fn determinism(&self) -> Determinism {
348 Determinism::Bitwise
349 }
350
351 fn point_at(&self, curve: &Curve3, at: CurveMeasure) -> GeomResult<Point3> {
352 // A native parameter needs no conversion and no convention, so it
353 // works for EVERY family -- including the ones whose arc length has
354 // no closed form and whose distance route is refused.
355 let CurveMeasure::Distance(distance) = at else {
356 return crate::curve::evaluate3(curve, finite_value(at)?);
357 };
358 match curve {
359 // Native arc-length families: straight through, no conversion.
360 Curve3::Intrinsic(i) => frenet_point(i, distance),
361 Curve3::Elevated(e) => elevated_point(e, distance),
362 Curve3::Banked(b) => banked_point(b, distance),
363 Curve3::Line(_) | Curve3::Circle(_) | Curve3::Polyline(_) => {
364 crate::curve::evaluate3(curve, parameter_for(curve, distance)?)
365 }
366 _ => Err(unsupported()),
367 }
368 }
369
370 fn tangent_at(&self, curve: &Curve3, at: CurveMeasure) -> GeomResult<Vec3> {
371 let raw = match at {
372 CurveMeasure::Parameter(_) => crate::curve::derivative3(curve, finite_value(at)?)?,
373 CurveMeasure::Distance(distance) => match curve {
374 Curve3::Intrinsic(i) => frenet_tangent(i, distance)?,
375 Curve3::Elevated(e) => elevated_tangent(e, distance)?,
376 Curve3::Banked(b) => banked_tangent(b, distance)?,
377 Curve3::Line(_) | Curve3::Circle(_) | Curve3::Polyline(_) => {
378 crate::curve::derivative3(curve, parameter_for(curve, distance)?)?
379 }
380 _ => return Err(unsupported()),
381 },
382 // `CurveMeasure` is #[non_exhaustive]. An unknown method of
383 // measurement is refused by name rather than guessed at.
384 _ => return Err(unsupported()),
385 };
386 // The contract promises a UNIT tangent. The intrinsic and elevated
387 // evaluators already return one; a line's derivative is its raw
388 // direction and a circle's scales with radius, so normalising here
389 // is what makes the families interchangeable to a caller.
390 let length = raw.length();
391 if !length.is_finite() || length <= 0.0 {
392 return Err(invalid("curve has no tangent direction there"));
393 }
394 Ok(raw / length)
395 }
396
397 fn frame_at(&self, curve: &Curve3, at: CurveMeasure) -> GeomResult<Frame3> {
398 if let Curve3::Banked(banked) = curve {
399 return self.banked_frame(banked, at);
400 }
401 let origin = self.point_at(curve, at)?;
402 let tangent = self.tangent_at(curve, at)?;
403 self.reference_frame(origin, tangent)
404 }
405
406 /// On a seam (ADR 0082's rule, both sides), the point
407 /// [`station_section3_on`] reads there; elsewhere
408 /// [`point_at`](CurveEvaluator::point_at).
409 fn point_at_on(&self, curve: &Curve3, at: CurveMeasure, side: SeamSide) -> GeomResult<Point3> {
410 match self.seam_section(curve, at, side)? {
411 Some(section) => Ok(section.point),
412 None => self.point_at(curve, at),
413 }
414 }
415
416 /// On a seam, the tangent of `side`'s piece as [`station_section3_on`]
417 /// reads it; elsewhere [`tangent_at`](CurveEvaluator::tangent_at).
418 fn tangent_at_on(&self, curve: &Curve3, at: CurveMeasure, side: SeamSide) -> GeomResult<Vec3> {
419 match self.seam_section(curve, at, side)? {
420 Some(section) => Ok(section.tangent),
421 None => self.tangent_at(curve, at),
422 }
423 }
424
425 /// On a seam, [`station_section3_on`]'s frame of `side`'s piece in this
426 /// layout: a banked curve's rolled section (refused, as by
427 /// [`frame_at`](CurveEvaluator::frame_at), for a reference up other
428 /// than `+Z`), every other curve's reference-up frame against this
429 /// evaluator's up, which for `+Z` is the station frame itself.
430 /// Elsewhere [`frame_at`](CurveEvaluator::frame_at).
431 fn frame_at_on(&self, curve: &Curve3, at: CurveMeasure, side: SeamSide) -> GeomResult<Frame3> {
432 let Some(section) = self.seam_section(curve, at, side)? else {
433 return self.frame_at(curve, at);
434 };
435 if let Curve3::Banked(_) = curve {
436 self.banked_up()?;
437 return Ok(section.frame());
438 }
439 if self.up == Vec3::Z {
440 return Ok(section.frame());
441 }
442 self.reference_frame(section.point, section.tangent)
443 }
444
445 /// The composite's convention ([`CompositeBasis::convention`]), or
446 /// [`DistanceConvention::Unsupported`] for a path
447 /// [`CompositeBasis::from_path`] refuses.
448 fn path_distance_convention(&self, path: &CurvePath) -> DistanceConvention {
449 CompositeBasis::from_path(path)
450 .map_or(DistanceConvention::Unsupported, |basis| basis.convention())
451 }
452
453 /// The point of [`CompositeBasis::section_on`] with `side`.
454 fn path_point_at_on(
455 &self,
456 path: &CurvePath,
457 at: CurveMeasure,
458 side: SeamSide,
459 ) -> GeomResult<Point3> {
460 Ok(Self::path_section(path, at, side)?.2.point)
461 }
462
463 /// The tangent of [`CompositeBasis::section_on`] with `side`.
464 fn path_tangent_at_on(
465 &self,
466 path: &CurvePath,
467 at: CurveMeasure,
468 side: SeamSide,
469 ) -> GeomResult<Vec3> {
470 Ok(Self::path_section(path, at, side)?.2.tangent)
471 }
472
473 /// [`CompositeBasis::section_on`]'s frame with `side`, in this layout:
474 /// the station section itself against `+Z`; against another reference
475 /// up, the reference-up frame of the section's point and tangent,
476 /// except on a banked piece, whose cant is measured against `+Z` and
477 /// which is refused, as by [`frame_at`](CurveEvaluator::frame_at).
478 fn path_frame_at_on(
479 &self,
480 path: &CurvePath,
481 at: CurveMeasure,
482 side: SeamSide,
483 ) -> GeomResult<Frame3> {
484 let (basis, distance, section) = Self::path_section(path, at, side)?;
485 if self.up == Vec3::Z {
486 return Ok(section.frame());
487 }
488 if let StationCurve::Three(Curve3::Banked(_)) = basis.piece_read(distance, side)?.curve {
489 self.banked_up()?;
490 }
491 self.reference_frame(section.point, section.tangent)
492 }
493
494 /// [`CompositeBasis::frame_is_exact_at`] for a distance along a path
495 /// [`CompositeBasis::from_path`] accepts; `false` otherwise.
496 fn path_frame_is_exact_at(&self, path: &CurvePath, at: CurveMeasure, side: SeamSide) -> bool {
497 let Ok(distance) = Self::path_distance(at, side) else {
498 return false;
499 };
500 CompositeBasis::from_path(path).is_ok_and(|basis| basis.frame_is_exact_at(distance, side))
501 }
502
503 /// [`CompositeBasis::joint_intervals`] of the path (#294): exact
504 /// lengths summed as points, numerical ones bounded by chords and
505 /// turning or by a certified midpoint rule (`crate::station::certify`).
506 fn path_joint_distances(
507 &self,
508 path: &CurvePath,
509 tolerance: Scalar,
510 ) -> GeomResult<Vec<DistanceInterval>> {
511 CompositeBasis::from_path(path)?.joint_intervals(tolerance)
512 }
513
514 /// [`CompositeBasis::joint_intervals_within`] of the path (#305): the
515 /// joints whose certified interval meets the window, each bitwise as
516 /// [`Self::path_joint_distances`] gives it, certifying only the pieces
517 /// before and within the window.
518 fn path_joint_distances_within(
519 &self,
520 path: &CurvePath,
521 around: Scalar,
522 window: Scalar,
523 tolerance: Scalar,
524 ) -> GeomResult<Vec<(usize, DistanceInterval)>> {
525 CompositeBasis::from_path(path)?.joint_intervals_within(around, window, tolerance)
526 }
527}