Skip to main content

geometry_strategy/
densify.rs

1//! Densification strategies.
2//!
3//! Mirrors `boost::geometry::strategy::densify::*` from
4//! `boost/geometry/strategies/densify/cartesian.hpp`. The Cartesian
5//! impl walks each segment and inserts evenly-spaced intermediate
6//! points whenever the segment length exceeds `max_distance`.
7//!
8//! Spherical / geographic densify (interpolate along great-circle
9//! arcs / geodesics) lands later via the matching CS variants of
10//! [`crate::Pythagoras`] and the corresponding `transform` strategies
11//! — out of scope here.
12
13use alloc::vec::Vec;
14
15use geometry_cs::{CartesianFamily, CoordinateSystem};
16use geometry_model::Linestring;
17use geometry_tag::SameAs;
18use geometry_trait::{Linestring as LinestringTrait, Point, PointMut};
19
20use crate::cartesian::Pythagoras;
21use crate::distance::DistanceStrategy;
22
23/// A strategy for densifying a geometry — inserting intermediate
24/// vertices so no segment exceeds `max_distance`.
25///
26/// Mirrors the per-coordinate-system densify-strategy concept from
27/// `boost/geometry/strategies/densify.hpp`. `densify` returns a *new*
28/// geometry of the same kind.
29pub trait DensifyStrategy<G> {
30    /// The densified geometry type.
31    type Output;
32
33    /// Return a densified copy of `g` where every output segment is
34    /// no longer than `max_distance`.
35    fn densify(&self, g: &G, max_distance: f64) -> Self::Output;
36}
37
38/// Cartesian densify — straight-line interpolation between
39/// consecutive points.
40///
41/// Mirrors `boost::geometry::strategy::densify::cartesian` from
42/// `boost/geometry/strategies/densify/cartesian.hpp`.
43#[derive(Debug, Default, Clone, Copy)]
44pub struct CartesianDensify;
45
46impl<L, P> DensifyStrategy<L> for CartesianDensify
47where
48    L: LinestringTrait<Point = P>,
49    P: Point<Scalar = f64> + PointMut + Default + Copy,
50    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
51    Pythagoras: DistanceStrategy<P, P, Out = f64>,
52{
53    type Output = Linestring<P>;
54
55    fn densify(&self, ls: &L, max_distance: f64) -> Self::Output {
56        let pts: Vec<P> = ls.points().copied().collect();
57        // A non-positive (or NaN) threshold cannot subdivide anything:
58        // Boost's algorithm layer rejects `max_distance <= 0` with
59        // `invalid_input_exception` before the strategy ever runs
60        // (`algorithms/densify.hpp`), and the port's algorithm-layer
61        // `densify` panics likewise. Guarding here as well keeps a
62        // direct strategy call from computing `d_total / 0.0 == inf`,
63        // whose saturating cast yields `n == usize::MAX` — a debug
64        // overflow panic at `n + 1` and an unbounded push loop in
65        // release. Copy-through mirrors the negative-tolerance stance
66        // of `DouglasPeucker::simplify` (`simplify.rs:70`).
67        #[allow(
68            clippy::neg_cmp_op_on_partial_ord,
69            reason = "NaN must take the guard branch"
70        )]
71        if !(max_distance > 0.0) {
72            return Linestring::from_vec(pts);
73        }
74        let mut out: Vec<P> = Vec::with_capacity(pts.len() * 2);
75        if pts.is_empty() {
76            return Linestring::from_vec(out);
77        }
78
79        for w in pts.windows(2) {
80            out.push(w[0]);
81            let d_total = Pythagoras.distance(&w[0], &w[1]);
82            // Number of *intermediate* points inserted on this edge.
83            // Mirrors `densify/cartesian.hpp::apply` exactly:
84            // `n = int(len / threshold)` (a truncation = floor for the
85            // positive `len`), then the edge is divided into `n + 1`
86            // equal sub-segments with the intermediate points placed at
87            // `i / (n + 1)` for `i in 1..=n`. Using `ceil` here would
88            // diverge from Boost on exact-integer ratios (e.g.
89            // `len == 2·max` would emit one fewer point and leave a
90            // sub-segment exactly equal to `max` instead of shorter).
91            #[allow(
92                clippy::cast_possible_truncation,
93                clippy::cast_sign_loss,
94                clippy::cast_precision_loss,
95                reason = "d_total / max_distance >= 0 keeps the floor non-negative and small; \
96                          the fraction cast loses no meaningful precision for realistic counts."
97            )]
98            {
99                let n = (d_total / max_distance) as usize;
100                if n > 0 {
101                    let den = (n + 1) as f64;
102                    for i in 1..=n {
103                        let t = i as f64 / den;
104                        out.push(interpolate(&w[0], &w[1], t));
105                    }
106                }
107            }
108        }
109        out.push(*pts.last().unwrap());
110        Linestring::from_vec(out)
111    }
112}
113
114/// Linear per-dimension interpolation: `out[D] = a[D] + t·(b[D] − a[D])`
115/// for each dimension `D ∈ 0..P::DIM`.
116///
117/// Mirrors the point-blend inside `densify/cartesian.hpp::apply` that
118/// walks each coordinate of the two endpoints.
119#[inline]
120fn interpolate<P>(a: &P, b: &P, t: f64) -> P
121where
122    P: Point<Scalar = f64> + PointMut + Default,
123{
124    let mut out = P::default();
125    geometry_trait::fold_dims((), a, |(), _p, d| {
126        let av = match d {
127            0 => a.get::<0>(),
128            1 => a.get::<1>(),
129            2 => a.get::<2>(),
130            3 => a.get::<3>(),
131            _ => unreachable!(),
132        };
133        let bv = match d {
134            0 => b.get::<0>(),
135            1 => b.get::<1>(),
136            2 => b.get::<2>(),
137            3 => b.get::<3>(),
138            _ => unreachable!(),
139        };
140        let v = av + t * (bv - av);
141        match d {
142            0 => out.set::<0>(v),
143            1 => out.set::<1>(v),
144            2 => out.set::<2>(v),
145            3 => out.set::<3>(v),
146            _ => unreachable!(),
147        }
148    });
149    out
150}
151
152#[cfg(test)]
153#[allow(
154    clippy::float_cmp,
155    reason = "Densified coordinates are exact literals."
156)]
157mod tests {
158    //! Reference behaviour from
159    //! `boost/geometry/test/algorithms/densify.cpp:42-65`: a segment
160    //! is cut into equal sub-segments none of which exceeds
161    //! `max_distance`, and total length is preserved.
162
163    use super::{CartesianDensify, DensifyStrategy};
164    use crate::cartesian::Pythagoras;
165    use crate::distance::DistanceStrategy;
166    use geometry_cs::Cartesian;
167    use geometry_model::{Linestring, Point2D, linestring};
168    use geometry_trait::{Linestring as _, Point as _};
169
170    type Pt = Point2D<f64, Cartesian>;
171
172    #[test]
173    fn segment_of_length_10_max_2_5_yields_6_points() {
174        // Boost: `n = int(10 / 2.5) = 4` intermediate points, dividing
175        // the edge into `n + 1 = 5` sub-segments of length 2 each
176        // (strictly below `max = 2.5`). Points at i/5 for i in 1..=4.
177        // A `ceil`-based count would wrongly yield only 5 points with
178        // sub-segments of exactly 2.5.
179        let ls: Linestring<Pt> = linestring![(0., 0.), (10., 0.)];
180        let out = CartesianDensify.densify(&ls, 2.5);
181        let xs: alloc::vec::Vec<f64> = out.points().map(Pt::get::<0>).collect();
182        assert_eq!(xs, alloc::vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0]);
183    }
184
185    #[test]
186    fn exact_integer_ratio_matches_boost_denominator() {
187        // Regression: `len == 3·max` (an exact-integer ratio) is the
188        // case where `ceil` and Boost's `floor`+1 diverge. Boost:
189        // `n = int(6 / 2) = 3` → 4 sub-segments, points at 1.5/3/4.5.
190        let ls: Linestring<Pt> = linestring![(0., 0.), (6., 0.)];
191        let out = CartesianDensify.densify(&ls, 2.0);
192        let xs: alloc::vec::Vec<f64> = out.points().map(Pt::get::<0>).collect();
193        assert_eq!(xs, alloc::vec![0.0, 1.5, 3.0, 4.5, 6.0]);
194    }
195
196    #[test]
197    fn no_output_segment_exceeds_max_distance() {
198        let ls: Linestring<Pt> = linestring![(0., 0.), (10., 0.), (10., 7.)];
199        let out = CartesianDensify.densify(&ls, 1.0);
200        let pts: alloc::vec::Vec<&Pt> = out.points().collect();
201        for w in pts.windows(2) {
202            assert!(Pythagoras.distance(w[0], w[1]) <= 1.0 + 1e-9);
203        }
204    }
205
206    #[test]
207    fn non_positive_max_distance_copies_through_without_hanging() {
208        // Regression: `max_distance == 0.0` used to drive
209        // `d_total / 0.0 == inf`, whose saturating cast made
210        // `n == usize::MAX` — a debug overflow panic at `n + 1` and an
211        // unbounded release-mode push loop. The strategy now copies the
212        // input through unchanged for zero / negative / NaN thresholds.
213        let ls: Linestring<Pt> = linestring![(0., 0.), (10., 0.)];
214        for bad in [0.0, -1.0, f64::NAN] {
215            let out = CartesianDensify.densify(&ls, bad);
216            let xs: alloc::vec::Vec<f64> = out.points().map(Pt::get::<0>).collect();
217            assert_eq!(xs, alloc::vec![0.0, 10.0], "max_distance = {bad}");
218        }
219    }
220}