Skip to main content

axiolid_evaluate/
arc_length.rs

1//! Arc-length evaluation of intrinsic (natural-equation) curves and of the
2//! planar-plus-elevation composition.
3//!
4//! # Why quadrature, and why that is not an approximation of the VALUE
5//!
6//! An intrinsic curve stores curvature as a function of arc length. Its
7//! heading is the integral of that law and is exact in closed form, but its
8//! POSITION is the integral of `(cos th, sin th)` and has no elementary
9//! antiderivative -- for the clothoid it is the Fresnel integral. The stored
10//! value stays exact; only reading a point out of it needs numerical work.
11//! That is the same bargain as evaluating `sin`: the curve is not approximated,
12//! its evaluation is computed to tolerance.
13//!
14//! Gauss-Legendre is used because the integrand is smooth. An 8-point rule
15//! integrates a degree-15 polynomial exactly, and against the Fresnel closed
16//! form it reproduces a 120 m clothoid to R=300 with zero error at machine
17//! precision on a single panel, versus roughly 1e-3 relative for a comparable
18//! trapezoid budget. Panels are subdivided by total turning so a tight spiral
19//! gets more of them.
20
21use axiolid_contracts::{BackendId, GeomError, GeomResult, Operation};
22use axiolid_core::{Frame2, Point2, Point3, Scalar, Vec2, Vec3};
23use axiolid_curve::{Curve2, Elevated3, Intrinsic2};
24
25/// Nodes and weights of the 8-point Gauss-Legendre rule on `[-1, 1]`.
26///
27/// Exact for polynomials up to degree 15. Written out rather than computed:
28/// they are constants, and a Newton solve at startup would be slower and no
29/// more accurate.
30const GAUSS_NODES: [Scalar; 8] = [
31    -0.960_289_856_497_536_2,
32    -0.796_666_477_413_626_7,
33    -0.525_532_409_916_328_9,
34    -0.183_434_642_495_649_8,
35    0.183_434_642_495_649_8,
36    0.525_532_409_916_328_9,
37    0.796_666_477_413_626_7,
38    0.960_289_856_497_536_2,
39];
40
41/// Weights matching [`GAUSS_NODES`].
42const GAUSS_WEIGHTS: [Scalar; 8] = [
43    0.101_228_536_290_376_3,
44    0.222_381_034_453_374_5,
45    0.313_706_645_877_887_3,
46    0.362_683_783_378_361_9,
47    0.362_683_783_378_361_9,
48    0.313_706_645_877_887_3,
49    0.222_381_034_453_374_5,
50    0.101_228_536_290_376_3,
51];
52
53/// Panels per radian of total turning, above a floor of one panel.
54///
55/// A straight or gently curving run needs one panel; a spiral that turns
56/// through a radian gets four. Bounded so a pathological law cannot ask for an
57/// unbounded amount of work.
58const PANELS_PER_RADIAN: Scalar = 4.0;
59
60/// Upper bound on panels, so a malformed law refuses rather than hangs.
61const MAX_PANELS: usize = 4096;
62
63fn unsupported() -> GeomError {
64    GeomError::Unsupported {
65        backend: BackendId::new("axiolid-evaluate"),
66        operation: Operation::CurveEvaluation,
67    }
68}
69
70fn invalid(detail: &str) -> GeomError {
71    GeomError::InvalidInput(detail.to_owned())
72}
73
74/// How many panels to spend integrating `[0, s]`.
75///
76/// Budgeted from the TOTAL VARIATION of heading, not from `total_turning`.
77/// The signed integral is zero over a whole number of periods of a zero-mean
78/// oscillation, so budgeting from it would spend one panel on a curve that
79/// swings through many radians. Measured on `k(s) = 2 sin(10 s)` over
80/// `[0, pi]`: signed turning is 0, which bought one panel and left the
81/// endpoint 2.1e-1 wrong; the variation bound buys 26 panels and lands it
82/// to 4.9e-15.
83fn panel_count(curve: &Intrinsic2, s: Scalar) -> GeomResult<usize> {
84    let variation = curve
85        .turning_variation_bound(s)
86        .ok_or_else(|| invalid("curvature law does not integrate over the requested span"))?;
87    let wanted = (variation * PANELS_PER_RADIAN).ceil().max(1.0);
88    if !wanted.is_finite() || wanted > MAX_PANELS as Scalar {
89        return Err(invalid("curvature law needs an unbounded number of panels"));
90    }
91    Ok(wanted as usize)
92}
93
94/// Position on an intrinsic curve at arc length `s` from its start.
95///
96/// The heading is exact; the position is Gauss-Legendre quadrature of the unit
97/// tangent, which is where the non-elementary integral is discharged.
98pub fn intrinsic_point(curve: &Intrinsic2, s: Scalar) -> GeomResult<Point2> {
99    if !s.is_finite() {
100        return Err(invalid("arc length must be finite"));
101    }
102    // Panels must break at curvature seams: Gauss-Legendre assumes a smooth
103    // integrand across a panel, and a piecewise law is only piecewise-smooth.
104    let mut bounds = vec![0.0];
105    bounds.extend(curve.curvature.seams_within(s));
106    bounds.push(s);
107    let mut x = 0.0;
108    let mut y = 0.0;
109    for window in bounds.windows(2) {
110        let (lo, hi) = (window[0], window[1]);
111        if hi <= lo {
112            continue;
113        }
114        let span = hi - lo;
115        // Budget from variation over [0, hi]: an upper bound for this
116        // sub-span, since variation is monotone in the span. Never from
117        // `span` alone, which would ignore how far along the curve we are.
118        let panels = panel_count(curve, hi)?.max(1);
119        let step = span / panels as Scalar;
120        for panel in 0..panels {
121            let a = lo + step * panel as Scalar;
122            let half = step / 2.0;
123            let mid = a + half;
124            for (node, weight) in GAUSS_NODES.iter().zip(GAUSS_WEIGHTS.iter()) {
125                let u = mid + half * node;
126                let heading = curve
127                    .heading_at(u)
128                    .ok_or_else(|| invalid("curvature law does not integrate to the sample"))?;
129                x += weight * half * heading.cos();
130                y += weight * half * heading.sin();
131            }
132        }
133    }
134    // The integral is taken in the start frame, whose x axis is the start
135    // tangent, then placed into world coordinates by that frame.
136    Ok(place2(&curve.start, Vec2::new(x, y)))
137}
138
139/// Unit tangent of an intrinsic curve at arc length `s`.
140///
141/// Exact: this is the closed-form heading, no quadrature involved.
142pub fn intrinsic_tangent(curve: &Intrinsic2, s: Scalar) -> GeomResult<Vec2> {
143    if !s.is_finite() {
144        return Err(invalid("arc length must be finite"));
145    }
146    let heading = curve
147        .heading_at(s)
148        .ok_or_else(|| invalid("curvature law does not integrate to the requested arc length"))?;
149    let local = Vec2::new(heading.cos(), heading.sin());
150    Ok(rotate2(&curve.start, local))
151}
152
153/// Place a local offset into world coordinates through a 2D frame.
154fn place2(frame: &Frame2, local: Vec2) -> Point2 {
155    Point2::new(
156        frame.origin.x + frame.x.x * local.x + frame.y.x * local.y,
157        frame.origin.y + frame.x.y * local.x + frame.y.y * local.y,
158    )
159}
160
161/// Rotate a local direction into world coordinates through a 2D frame.
162fn rotate2(frame: &Frame2, local: Vec2) -> Vec2 {
163    Vec2::new(
164        frame.x.x * local.x + frame.y.x * local.y,
165        frame.x.y * local.x + frame.y.y * local.y,
166    )
167}
168
169/// Position on the plan at plan distance `d`.
170///
171/// Only families whose parameter IS arc length can carry an elevation law,
172/// because the law is written against distance along the plan. A line and an
173/// intrinsic curve qualify; a B-spline's parameter is not arc length, so
174/// pairing one would silently mean something else and is refused.
175fn plan_point(plan: &Curve2, d: Scalar) -> GeomResult<Point2> {
176    match plan {
177        Curve2::Line(line) => {
178            let direction = unit2(line.direction)?;
179            Ok(Point2::new(
180                line.origin.x + direction.x * d,
181                line.origin.y + direction.y * d,
182            ))
183        }
184        Curve2::Circle(circle) => {
185            // Arc length d subtends d / r, so the angle is exact.
186            if circle.radius <= 0.0 || !circle.radius.is_finite() {
187                return Err(invalid("circle radius must be positive and finite"));
188            }
189            let angle = d / circle.radius;
190            Ok(place2(
191                &circle.frame,
192                Vec2::new(circle.radius * angle.cos(), circle.radius * angle.sin()),
193            ))
194        }
195        Curve2::Intrinsic(intrinsic) => intrinsic_point(intrinsic, d),
196        _ => Err(unsupported()),
197    }
198}
199
200/// Unit tangent of the plan at plan distance `d`.
201fn plan_tangent(plan: &Curve2, d: Scalar) -> GeomResult<Vec2> {
202    match plan {
203        Curve2::Line(line) => unit2(line.direction),
204        Curve2::Circle(circle) => {
205            if circle.radius <= 0.0 || !circle.radius.is_finite() {
206                return Err(invalid("circle radius must be positive and finite"));
207            }
208            let angle = d / circle.radius;
209            Ok(rotate2(&circle.frame, Vec2::new(-angle.sin(), angle.cos())))
210        }
211        Curve2::Intrinsic(intrinsic) => intrinsic_tangent(intrinsic, d),
212        _ => Err(unsupported()),
213    }
214}
215
216fn unit2(v: Vec2) -> GeomResult<Vec2> {
217    let length = (v.x * v.x + v.y * v.y).sqrt();
218    if !length.is_finite() || length == 0.0 {
219        return Err(invalid("direction must be finite and non-zero"));
220    }
221    Ok(Vec2::new(v.x / length, v.y / length))
222}
223
224/// Position on an elevated curve at plan distance `d`.
225///
226/// The plan supplies `x`/`y`, the elevation law supplies `z`. Both halves are
227/// read at the SAME plan distance, which is the convention `ElevationLaw`
228/// documents.
229pub fn elevated_point(curve: &Elevated3, d: Scalar) -> GeomResult<Point3> {
230    if !d.is_finite() {
231        return Err(invalid("plan distance must be finite"));
232    }
233    let planar = plan_point(&curve.plan, d)?;
234    let height = curve
235        .elevation
236        .height_at(d)
237        .ok_or_else(|| invalid("elevation law has no height at that distance"))?;
238    Ok(Point3::new(planar.x, planar.y, height))
239}
240
241/// Unit tangent of an elevated curve at plan distance `d`.
242///
243/// The plan tangent is horizontal and the grade lifts it, so the 3D tangent is
244/// `(t.x, t.y, g)` normalised -- the `sqrt(1 + g^2)` factor by which 3D arc
245/// length runs ahead of plan distance.
246pub fn elevated_tangent(curve: &Elevated3, d: Scalar) -> GeomResult<Vec3> {
247    if !d.is_finite() {
248        return Err(invalid("plan distance must be finite"));
249    }
250    let planar = plan_tangent(&curve.plan, d)?;
251    let grade = curve
252        .elevation
253        .grade_at(d)
254        .ok_or_else(|| invalid("elevation law has no grade at that distance"))?;
255    let scale = (1.0 + grade * grade).sqrt();
256    if !scale.is_finite() || scale == 0.0 {
257        return Err(invalid("grade does not give a finite tangent"));
258    }
259    Ok(Vec3::new(planar.x / scale, planar.y / scale, grade / scale))
260}