Skip to main content

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}