axiolid_curve_evaluate_contract/
conformance.rs

1//! Conformance suite every curve-evaluation provider must pass.
2//!
3//! These are the properties a CALLER is entitled to assume. A provider
4//! that passes them can be swapped for another without a consumer
5//! noticing, which is the whole point of naming the capability.
6
7use axiolid_core::{Frame3, Point3, Scalar, Vec3};
8use axiolid_curve::{Circle3, CurvatureLaw, Curve3, Intrinsic3, Line3};
9
10use crate::{CurveEvaluator, CurveMeasure};
11
12/// One failed conformance expectation.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ConformanceFailure {
15    /// Which expectation failed.
16    pub check: &'static str,
17    /// What went wrong.
18    pub detail: String,
19}
20
21fn fail(check: &'static str, detail: impl Into<String>) -> ConformanceFailure {
22    ConformanceFailure {
23        check,
24        detail: detail.into(),
25    }
26}
27
28/// Run every conformance check against `provider`.
29///
30/// Returns the failures; an empty vector means conformant.
31#[must_use]
32pub fn check<E: CurveEvaluator>(provider: &E) -> Vec<ConformanceFailure> {
33    let mut out = Vec::new();
34    out.extend(check_line(provider));
35    out.extend(check_circle(provider));
36    out.extend(check_tangent_is_unit(provider));
37    out.extend(check_frame_is_orthonormal(provider));
38    out.extend(check_refusals(provider));
39    out.extend(check_measure_routes_differ(provider));
40    out
41}
42
43/// A line with a NON-UNIT direction must still advance true distance.
44///
45/// This is the check that catches a provider handing back the native
46/// parameter as though it were a distance: with `|direction| = 2` the two
47/// differ by a factor of two, while a unit direction would hide the bug.
48fn check_line<E: CurveEvaluator>(provider: &E) -> Vec<ConformanceFailure> {
49    let mut out = Vec::new();
50    let line = Curve3::Line(Line3 {
51        origin: Point3::new(1.0, 2.0, 3.0),
52        direction: Vec3::new(2.0, 0.0, 0.0),
53    });
54    if !provider.distance_convention(&line).is_supported() {
55        return out;
56    }
57    match provider.point_at(&line, CurveMeasure::Distance(5.0)) {
58        Ok(p) => {
59            let moved = (p - Point3::new(1.0, 2.0, 3.0)).length();
60            if (moved - 5.0).abs() > 1e-9 {
61                out.push(fail(
62                    "line advances true distance",
63                    format!("distance 5 moved {moved}, not 5"),
64                ));
65            }
66        }
67        Err(e) => out.push(fail("line advances true distance", format!("{e:?}"))),
68    }
69    out
70}
71
72/// A circle of radius r must reach angle d/r at distance d.
73fn check_circle<E: CurveEvaluator>(provider: &E) -> Vec<ConformanceFailure> {
74    let mut out = Vec::new();
75    let radius = 4.0;
76    let circle = Curve3::Circle(Circle3 {
77        frame: Frame3 {
78            origin: Point3::ZERO,
79            x: Vec3::X,
80            y: Vec3::Y,
81            z: Vec3::Z,
82        },
83        radius,
84    });
85    if !provider.distance_convention(&circle).is_supported() {
86        return out;
87    }
88    // A quarter of the circumference must land on the +y axis.
89    let quarter = core::f64::consts::FRAC_PI_2 * radius;
90    match provider.point_at(&circle, CurveMeasure::Distance(quarter)) {
91        Ok(p) => {
92            let want = Point3::new(0.0, radius, 0.0);
93            let error = (p - want).length();
94            if error > 1e-9 {
95                out.push(fail(
96                    "circle reaches angle d/r",
97                    format!("quarter circumference landed {error:e} away from the +y axis"),
98                ));
99            }
100        }
101        Err(e) => out.push(fail("circle reaches angle d/r", format!("{e:?}"))),
102    }
103    out
104}
105
106fn sample_curves() -> Vec<Curve3> {
107    vec![
108        Curve3::Line(Line3 {
109            origin: Point3::new(0.0, 0.0, 0.0),
110            direction: Vec3::new(3.0, 4.0, 0.0),
111        }),
112        Curve3::Circle(Circle3 {
113            frame: Frame3 {
114                origin: Point3::new(2.0, 0.0, 1.0),
115                x: Vec3::X,
116                y: Vec3::Y,
117                z: Vec3::Z,
118            },
119            radius: 3.0,
120        }),
121        // An arc-length-native family. Included because a provider may route
122        // it past the distance-to-parameter conversion entirely, so its
123        // guards would otherwise go unexercised by this suite.
124        Curve3::Intrinsic(Intrinsic3::new(
125            Frame3 {
126                origin: Point3::ZERO,
127                x: Vec3::X,
128                y: Vec3::Y,
129                z: Vec3::Z,
130            },
131            CurvatureLaw::circular(0.1),
132            CurvatureLaw::circular(0.04),
133            10.0,
134        )),
135    ]
136}
137
138/// The tangent must be unit length wherever it is defined.
139fn check_tangent_is_unit<E: CurveEvaluator>(provider: &E) -> Vec<ConformanceFailure> {
140    let mut out = Vec::new();
141    for curve in sample_curves() {
142        if !provider.distance_convention(&curve).is_supported() {
143            continue;
144        }
145        for distance in [0.0, 1.5, 4.0] {
146            if let Ok(t) = provider.tangent_at(&curve, CurveMeasure::Distance(distance)) {
147                if (t.length() - 1.0).abs() > 1e-9 {
148                    out.push(fail(
149                        "tangent is unit length",
150                        format!("|t| = {} at distance {distance}", t.length()),
151                    ));
152                }
153            }
154        }
155    }
156    out
157}
158
159/// The frame must be right-handed orthonormal with `x` on the tangent.
160///
161/// A caller builds a rotation from this directly, so a frame that is
162/// merely close to orthonormal shears whatever it places.
163fn check_frame_is_orthonormal<E: CurveEvaluator>(provider: &E) -> Vec<ConformanceFailure> {
164    let mut out = Vec::new();
165    for curve in sample_curves() {
166        if !provider.distance_convention(&curve).is_supported() {
167            continue;
168        }
169        for distance in [0.0, 1.5, 4.0] {
170            let Ok(frame) = provider.frame_at(&curve, CurveMeasure::Distance(distance)) else {
171                continue;
172            };
173            let checks = [
174                ("x unit", frame.x.length() - 1.0),
175                ("y unit", frame.y.length() - 1.0),
176                ("z unit", frame.z.length() - 1.0),
177                ("x.y", frame.x.dot(frame.y)),
178                ("x.z", frame.x.dot(frame.z)),
179                ("y.z", frame.y.dot(frame.z)),
180            ];
181            for (name, value) in checks {
182                if value.abs() > 1e-9 {
183                    out.push(fail(
184                        "frame is orthonormal",
185                        format!("{name} off by {value:e} at distance {distance}"),
186                    ));
187                }
188            }
189            // Right-handed: x cross y must be z, not -z.
190            let handed = frame.x.cross(frame.y).dot(frame.z);
191            if (handed - 1.0).abs() > 1e-9 {
192                out.push(fail(
193                    "frame is right-handed",
194                    format!("x cross y . z = {handed} at distance {distance}"),
195                ));
196            }
197            // The frame must sit ON the curve.
198            if let Ok(point) = provider.point_at(&curve, CurveMeasure::Distance(distance)) {
199                let off = (frame.origin - point).length();
200                if off > 1e-9 {
201                    out.push(fail(
202                        "frame origin is the curve point",
203                        format!("origin {off:e} away at distance {distance}"),
204                    ));
205                }
206            }
207            // And its x axis must be the tangent.
208            if let Ok(tangent) = provider.tangent_at(&curve, CurveMeasure::Distance(distance)) {
209                let off = (frame.x - tangent).length();
210                if off > 1e-9 {
211                    out.push(fail(
212                        "frame x is the tangent",
213                        format!("x differs from tangent by {off:e}"),
214                    ));
215                }
216            }
217        }
218    }
219    out
220}
221
222/// Bad input must be refused, not answered with a guess.
223///
224/// A NaN distance that returns a NaN point is the dangerous case: it
225/// propagates silently into a placement instead of failing at the call.
226fn check_refusals<E: CurveEvaluator>(provider: &E) -> Vec<ConformanceFailure> {
227    let mut out = Vec::new();
228    for curve in sample_curves() {
229        if !provider.distance_convention(&curve).is_supported() {
230            continue;
231        }
232        for bad in [Scalar::NAN, Scalar::INFINITY, Scalar::NEG_INFINITY] {
233            if provider
234                .point_at(&curve, CurveMeasure::Distance(bad))
235                .is_ok()
236            {
237                out.push(fail(
238                    "non-finite distance is refused",
239                    format!("point_at accepted {bad}"),
240                ));
241            }
242            if provider
243                .tangent_at(&curve, CurveMeasure::Distance(bad))
244                .is_ok()
245            {
246                out.push(fail(
247                    "non-finite distance is refused",
248                    format!("tangent_at accepted {bad}"),
249                ));
250            }
251            if provider
252                .frame_at(&curve, CurveMeasure::Distance(bad))
253                .is_ok()
254            {
255                out.push(fail(
256                    "non-finite distance is refused",
257                    format!("frame_at accepted {bad}"),
258                ));
259            }
260        }
261    }
262    // A provider must not claim a convention it cannot honour: if it
263    // reports Unsupported it must actually refuse.
264    let vertical = Curve3::Line(Line3 {
265        origin: Point3::ZERO,
266        direction: Vec3::ZERO,
267    });
268    if provider.distance_convention(&vertical).is_supported()
269        && provider
270            .point_at(&vertical, CurveMeasure::Distance(1.0))
271            .is_ok()
272    {
273        out.push(fail(
274            "degenerate curve is refused",
275            "a zero-direction line was evaluated instead of refused",
276        ));
277    }
278    out
279}
280
281/// A parameter and a distance must not be silently interchangeable.
282///
283/// On a circle of radius 4 the same number means two different places:
284/// `Parameter(1.5)` is 1.5 radians round, `Distance(1.5)` is 1.5 m along,
285/// i.e. 0.375 rad. A provider that ignored the method of measurement would
286/// return the same point for both -- wrong, finite, and plausible.
287///
288/// Also pins that the parameter route stays OPEN where the distance route
289/// is refused: an ellipse has no closed-form arc length, but its native
290/// parameter is perfectly meaningful, and a consumer holding an authored
291/// parameter must still be able to evaluate it.
292fn check_measure_routes_differ<E: CurveEvaluator>(provider: &E) -> Vec<ConformanceFailure> {
293    let mut out = Vec::new();
294    let radius = 4.0;
295    let circle = Curve3::Circle(Circle3 {
296        frame: Frame3 {
297            origin: Point3::ZERO,
298            x: Vec3::X,
299            y: Vec3::Y,
300            z: Vec3::Z,
301        },
302        radius,
303    });
304    let value = 1.5;
305    let by_parameter = provider.point_at(&circle, CurveMeasure::Parameter(value));
306    let by_distance = provider.point_at(&circle, CurveMeasure::Distance(value));
307    if let (Ok(p), Ok(d)) = (by_parameter, by_distance) {
308        if (p - d).length() < 1e-9 {
309            out.push(fail(
310                "parameter and distance are distinct",
311                format!("{value} gave the same point as a parameter and as a distance"),
312            ));
313        }
314    }
315    out
316}