Skip to main content

axiolid_measure/
polyline_hausdorff.rs

1//! Hausdorff distance between polylines, and a certified Fréchet decision
2//! (#147, ledger row H4).
3//!
4//! The Hausdorff distance treats both curves as continuous piecewise-linear
5//! point sets (not just their vertices). The one-sided distance from `a` to
6//! `b` is the supremum, over every point of `a`, of that point's distance to
7//! the nearest point of `b`; the two-sided distance is the larger of the two
8//! one-sided distances. Unlike the Fréchet distance, it has no notion of a
9//! monotone walk, so it can be much smaller than the Fréchet distance for
10//! curves that pass close to each other out of step, and it is not
11//! symmetric in general -- see [`one_sided_polyline_hausdorff_distance`].
12//!
13//! # Algorithm
14//!
15//! For one segment of `a`, parametrised `a(t) = p0 + t * d` for `t` in
16//! `[0, 1]`, the distance to `b` is `f(t) = min` over every feature of `b`
17//! (a vertex, or a segment's infinite line restricted to where the foot of
18//! `a(t)` falls inside the segment) of `dist(a(t), feature)`. Distance
19//! squared to a fixed point, or to an infinite line, is a quadratic function
20//! of `t` (an affine image of a convex distance), so on any sub-interval
21//! where a single feature is uniquely the minimum, `f` is convex there and
22//! has no interior local maximum: its maximum on such a sub-interval sits at
23//! an endpoint. The candidates for the segment's supremum are therefore the
24//! segment's own endpoints, `t = 0` and `t = 1`, plus every `t` where two
25//! features of `b` are simultaneously the minimum -- a tie found by solving
26//! a (at most) quadratic equation per pair of features, restricted to any
27//! line feature's activity window. At every candidate, the true minimum
28//! distance over all of `b`'s features is evaluated (ordinary clamped
29//! point-to-segment distance, minimised over every segment of `b`), and the
30//! largest of these is the segment's contribution. This is `O(m^2)` per
31//! segment of `a` against `m` segments of `b`, so `O(n * m^2)` for one
32//! direction -- worth revisiting if a crate consumer needs it faster, but
33//! correctness came first here.
34//!
35//! # Rounding
36//!
37//! Everything is `f64`. Tie candidates are roots of quadratics solved in
38//! floating point, so a genuinely exact tie can be missed, or found a few
39//! ULPs from its true parameter; the evaluation step then reads the true
40//! minimum at that (slightly off) point, so the reported supremum can be a
41//! few ULPs short of the exact answer. Nothing here is certified.
42
43use axiolid_core::{Point2, Point3};
44
45use crate::frechet::{check, decide, distance, foot, lift, FrechetError};
46
47/// The supremum, over every point of polyline `a`, of that point's distance
48/// to the nearest point of polyline `b`. Both curves are treated as
49/// continuous piecewise-linear point sets, not just their vertices.
50///
51/// This is not symmetric: `a` can lie entirely close to `b` while `b` has
52/// stretches far from `a` (for example, a short segment sitting directly
53/// above the middle of a much longer one). Use [`polyline_hausdorff_distance`] for
54/// the symmetric, two-sided distance.
55///
56/// # Errors
57///
58/// An empty polyline or a non-finite coordinate.
59pub fn one_sided_polyline_hausdorff_distance(
60    a: &[Point3],
61    b: &[Point3],
62) -> Result<f64, FrechetError> {
63    check(a)?;
64    check(b)?;
65    if a.len() == 1 {
66        return Ok(point_to_polyline_distance(a[0], b));
67    }
68    let sup = a
69        .windows(2)
70        .map(|s| segment_sup(s[0], s[1], b))
71        .fold(f64::NEG_INFINITY, f64::max);
72    Ok(sup)
73}
74
75/// [`one_sided_polyline_hausdorff_distance`] in the plane.
76///
77/// # Errors
78///
79/// As [`one_sided_polyline_hausdorff_distance`].
80pub fn one_sided_polyline_hausdorff_distance_2d(
81    a: &[Point2],
82    b: &[Point2],
83) -> Result<f64, FrechetError> {
84    one_sided_polyline_hausdorff_distance(&lift(a), &lift(b))
85}
86
87/// The symmetric Hausdorff distance between polylines `a` and `b`: the
88/// larger of the two one-sided distances (see [`one_sided_polyline_hausdorff_distance`]).
89///
90/// # Errors
91///
92/// An empty polyline or a non-finite coordinate.
93pub fn polyline_hausdorff_distance(a: &[Point3], b: &[Point3]) -> Result<f64, FrechetError> {
94    let a_to_b = one_sided_polyline_hausdorff_distance(a, b)?;
95    let b_to_a = one_sided_polyline_hausdorff_distance(b, a)?;
96    Ok(a_to_b.max(b_to_a))
97}
98
99/// [`polyline_hausdorff_distance`] in the plane.
100///
101/// # Errors
102///
103/// As [`polyline_hausdorff_distance`].
104pub fn polyline_hausdorff_distance_2d(a: &[Point2], b: &[Point2]) -> Result<f64, FrechetError> {
105    polyline_hausdorff_distance(&lift(a), &lift(b))
106}
107
108/// Distance from `p` to the nearest point of polyline `b` (a single point
109/// when `b` has one vertex).
110fn point_to_polyline_distance(p: Point3, b: &[Point3]) -> f64 {
111    if b.len() == 1 {
112        return distance(p, b[0]);
113    }
114    b.windows(2)
115        .map(|s| point_segment_distance(p, s[0], s[1]))
116        .fold(f64::INFINITY, f64::min)
117}
118
119/// Ordinary clamped point-to-segment distance.
120fn point_segment_distance(p: Point3, a: Point3, b: Point3) -> f64 {
121    match foot(p, a, b) {
122        None => distance(p, a),
123        Some((t, h)) => {
124            if t <= 0.0 {
125                distance(p, a)
126            } else if t >= 1.0 {
127                distance(p, b)
128            } else {
129                h
130            }
131        }
132    }
133}
134
135/// `(c1, c0)` such that `dist(a(t), v)^2 = c2 * t^2 + c1 * t + c0`, where
136/// `a(t) = p0 + t * d` and `c2 = d.dot(d)` is shared by every point feature
137/// of the same segment.
138fn point_linear_coeffs(p0: Point3, d: Point3, v: Point3) -> (f64, f64) {
139    let diff = p0 - v;
140    (2.0 * d.dot(diff), diff.dot(diff))
141}
142
143/// `(c2, c1, c0)` such that the squared distance from `a(t) = p0 + t * d` to
144/// the infinite line through segment `(u, w)` is `c2 * t^2 + c1 * t + c0`.
145/// `None` for a degenerate (zero-length) segment, which has no line feature.
146fn line_quadratic_coeffs(p0: Point3, d: Point3, u: Point3, w: Point3) -> Option<(f64, f64, f64)> {
147    let e = w - u;
148    let e2 = e.dot(e);
149    if e2 == 0.0 {
150        return None;
151    }
152    let q0 = p0 - u;
153    // |q(t)|^2 coefficients, q(t) = q0 + t * d.
154    let big_a = q0.dot(q0);
155    let big_b = 2.0 * d.dot(q0);
156    let big_c = d.dot(d);
157    // dot(q(t), e) coefficients (linear).
158    let big_e = q0.dot(e);
159    let big_f = d.dot(e);
160    Some((
161        big_c - big_f * big_f / e2,
162        big_b - 2.0 * big_e * big_f / e2,
163        big_a - big_e * big_e / e2,
164    ))
165}
166
167/// Whether the foot of `a(t) = p0 + t * d` onto segment `(u, w)` lands
168/// inside the segment (parameter in `[0, 1]`). `false` for a degenerate
169/// segment, which has no line feature to be active.
170fn foot_active(p0: Point3, d: Point3, t: f64, u: Point3, w: Point3) -> bool {
171    match foot(p0 + d * t, u, w) {
172        Some((s, _)) => (0.0..=1.0).contains(&s),
173        None => false,
174    }
175}
176
177/// Roots of `c2 * t^2 + c1 * t + c0 = 0`, handling the near-linear and
178/// no-real-root cases. Deliberately permissive: any spurious root is just an
179/// extra evaluation point, which cannot corrupt the supremum.
180fn quadratic_roots(c2: f64, c1: f64, c0: f64) -> Vec<f64> {
181    let scale = c2.abs().max(c1.abs()).max(c0.abs());
182    if scale == 0.0 {
183        return Vec::new();
184    }
185    let threshold = 1e-12 * scale;
186    if c2.abs() <= threshold {
187        return if c1.abs() <= threshold {
188            Vec::new()
189        } else {
190            vec![-c0 / c1]
191        };
192    }
193    let discriminant = c1 * c1 - 4.0 * c2 * c0;
194    if discriminant < 0.0 {
195        return Vec::new();
196    }
197    let root = discriminant.sqrt();
198    vec![(-c1 - root) / (2.0 * c2), (-c1 + root) / (2.0 * c2)]
199}
200
201/// The supremum of the distance to polyline `b`, over the segment from `p0`
202/// to `p1` of `a`. See the module's `# Algorithm` section.
203fn segment_sup(p0: Point3, p1: Point3, b: &[Point3]) -> f64 {
204    let d = p1 - p0;
205    let mut candidates: Vec<f64> = vec![0.0, 1.0];
206
207    if b.len() >= 2 {
208        let c2 = d.dot(d);
209        // Vertex-vertex ties: same c2, so the quadratic collapses to linear.
210        for (i, &vi) in b.iter().enumerate() {
211            let (c1i, c0i) = point_linear_coeffs(p0, d, vi);
212            for &vj in b.iter().skip(i + 1) {
213                let (c1j, c0j) = point_linear_coeffs(p0, d, vj);
214                let slope = c1i - c1j;
215                if slope != 0.0 {
216                    let t = (c0j - c0i) / slope;
217                    if (0.0..=1.0).contains(&t) {
218                        candidates.push(t);
219                    }
220                }
221            }
222        }
223        let segments: Vec<(Point3, Point3)> = b.windows(2).map(|s| (s[0], s[1])).collect();
224        let lines: Vec<Option<(f64, f64, f64)>> = segments
225            .iter()
226            .map(|&(u, w)| line_quadratic_coeffs(p0, d, u, w))
227            .collect();
228        for (j, &(u, w)) in segments.iter().enumerate() {
229            let Some((c2l, c1l, c0l)) = lines[j] else {
230                continue;
231            };
232            // Vertex-vs-line ties.
233            for &v in b {
234                let (c1v, c0v) = point_linear_coeffs(p0, d, v);
235                for t in quadratic_roots(c2 - c2l, c1v - c1l, c0v - c0l) {
236                    if (0.0..=1.0).contains(&t) && foot_active(p0, d, t, u, w) {
237                        candidates.push(t);
238                    }
239                }
240            }
241            // Line-vs-line ties.
242            for (k, &(u2, w2)) in segments.iter().enumerate().skip(j + 1) {
243                let Some((c2l2, c1l2, c0l2)) = lines[k] else {
244                    continue;
245                };
246                for t in quadratic_roots(c2l - c2l2, c1l - c1l2, c0l - c0l2) {
247                    if (0.0..=1.0).contains(&t)
248                        && foot_active(p0, d, t, u, w)
249                        && foot_active(p0, d, t, u2, w2)
250                    {
251                        candidates.push(t);
252                    }
253                }
254            }
255        }
256    }
257
258    candidates
259        .into_iter()
260        .map(|t| point_to_polyline_distance(p0 + d * t.clamp(0.0, 1.0), b))
261        .fold(f64::NEG_INFINITY, f64::max)
262}
263
264/// A certified answer to the Fréchet decision problem: whether the
265/// continuous Fréchet distance is at most `eps`.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum FrechetDecision {
268    /// The Fréchet distance is certainly at most `eps`.
269    AtMost,
270    /// The Fréchet distance is certainly more than `eps`.
271    MoreThan,
272    /// `eps` is within the computed error margin of the true distance: the
273    /// sign cannot be certified from `f64` arithmetic alone.
274    Undecided,
275}
276
277/// A conservative bound on the absolute error in the decision's distance and
278/// foot computations, for coordinates up to magnitude `r` and a query up to
279/// `eps`. `k = 64.0` is a deliberately generous constant covering the
280/// handful of subtractions, dot products and one square root in `distance`
281/// and `foot`'s short, fixed-length chains, not a tight one.
282fn error_margin(a: &[Point3], b: &[Point3], eps: f64) -> f64 {
283    const K: f64 = 64.0;
284    let r = a
285        .iter()
286        .chain(b.iter())
287        .flat_map(|p| [p.x, p.y, p.z])
288        .fold(0.0_f64, |m, c| m.max(c.abs()));
289    let r = if r == 0.0 { 1.0 } else { r };
290    K * (r + eps.abs() + 1.0) * f64::EPSILON
291}
292
293/// A certified decision for whether the continuous Fréchet distance between
294/// `a` and `b` is at most `eps`, unlike [`frechet_at_most`](crate::frechet_at_most)
295/// (cheaper, but its module docs say plainly it is not certified).
296///
297/// # Design
298///
299/// The decision is monotone non-decreasing in `eps`, and every distance or
300/// foot value it reads is within a conservative bound `margin` of the true
301/// value. If the *computed* free space already permits a monotone
302/// path at the smaller leash `eps - margin`, the *true* free space at `eps`
303/// -- a superset, since a true distance is at most `margin` above its
304/// computed value -- permits it too, so the answer is certainly
305/// [`FrechetDecision::AtMost`]. Symmetrically, if even the *computed* free
306/// space at the larger leash `eps + margin` -- a superset of the true free
307/// space at `eps` -- fails to permit a path, the true one certainly does
308/// not, so the answer is certainly [`FrechetDecision::MoreThan`]. Otherwise
309/// the true distance is provably within `margin` of `eps` and the decision
310/// is [`FrechetDecision::Undecided`].
311///
312/// # Errors
313///
314/// An empty polyline, a non-finite coordinate, or a negative or non-finite
315/// `eps`.
316pub fn frechet_decide_certified(
317    a: &[Point3],
318    b: &[Point3],
319    eps: f64,
320) -> Result<FrechetDecision, FrechetError> {
321    check(a)?;
322    check(b)?;
323    if !eps.is_finite() || eps < 0.0 {
324        return Err(FrechetError::InvalidBound);
325    }
326    let margin = error_margin(a, b, eps);
327    if eps >= margin && decide(a, b, eps - margin) {
328        return Ok(FrechetDecision::AtMost);
329    }
330    if !decide(a, b, eps + margin) {
331        return Ok(FrechetDecision::MoreThan);
332    }
333    Ok(FrechetDecision::Undecided)
334}
335
336/// [`frechet_decide_certified`] in the plane.
337///
338/// # Errors
339///
340/// As [`frechet_decide_certified`].
341pub fn frechet_decide_certified_2d(
342    a: &[Point2],
343    b: &[Point2],
344    eps: f64,
345) -> Result<FrechetDecision, FrechetError> {
346    frechet_decide_certified(&lift(a), &lift(b), eps)
347}