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}