Skip to main content

oxigdal_algorithms/vector/
generalization.rs

1//! Map generalization operators following the ICA (International Cartographic Association) taxonomy.
2//!
3//! This module implements three canonical generalization operations:
4//!
5//! ## Collapse
6//! Reduces a polygon or short line below a minimum-size threshold to a representative
7//! point (the geometric centroid or midpoint).  This mirrors the ICA "collapse" operator
8//! which converts areal/linear features to point symbols when they are too small to be
9//! rendered at the target scale.
10//!
11//! ## Exaggerate
12//! Scales a polygon or linestring outward (or inward, for scale < 1) from an anchor
13//! point, thereby making the feature visually larger without moving its symbolic
14//! location.  Corresponds to the ICA "exaggeration" operator used e.g. for roads or
15//! buildings that would otherwise disappear at small scales.
16//!
17//! ## Displace
18//! Resolves positional conflicts between point symbols that are too close together by
19//! iteratively pushing overlapping positions apart until all pairwise distances exceed
20//! a minimum separation.  Corresponds to the ICA "displacement" operator.
21//!
22//! # References
23//!
24//! - Mackaness, W., Ruas, A. & Sarjakoski, L.T. (2007) *Generalisation of Geographic
25//!   Information: Cartographic Modelling and Applications*.  ISPRS Book Series, Elsevier.
26//! - Ruas, A. (1998) "OO-Generalisation with a multi-agent system", in *Proceedings
27//!   of the 8th International Symposium on Spatial Data Handling*, pp. 325–337.
28
29use oxigdal_core::vector::{Coordinate, LineString, Polygon};
30
31// ── Collapse ──────────────────────────────────────────────────────────────────
32
33/// Options that govern when a feature is considered small enough to collapse.
34#[derive(Debug, Clone)]
35pub struct CollapseOptions {
36    /// Area threshold (in the polygon's coordinate units squared).
37    /// Polygons whose absolute area is strictly less than this value are collapsed
38    /// to a point.  Use `0.0` to disable polygon collapsing.
39    pub min_polygon_area: f64,
40
41    /// Length threshold for linestrings (in coordinate units).
42    /// When `Some(len)`, linestrings shorter than `len` are collapsed to a point.
43    /// `None` disables linestring collapsing entirely.
44    pub min_linestring_length: Option<f64>,
45}
46
47impl Default for CollapseOptions {
48    fn default() -> Self {
49        Self {
50            min_polygon_area: 0.0,
51            min_linestring_length: None,
52        }
53    }
54}
55
56impl CollapseOptions {
57    /// Creates options with a minimum polygon area threshold and no linestring threshold.
58    pub fn new(min_area: f64) -> Self {
59        Self {
60            min_polygon_area: min_area,
61            min_linestring_length: None,
62        }
63    }
64
65    /// Builder-style setter for the minimum linestring length threshold.
66    pub fn with_min_linestring_length(mut self, min_len: f64) -> Self {
67        self.min_linestring_length = Some(min_len);
68        self
69    }
70}
71
72/// Returns `true` when the polygon's absolute planimetric area is strictly
73/// below `options.min_polygon_area`, i.e. the polygon should be collapsed.
74///
75/// # Arguments
76///
77/// * `poly`    – Reference polygon to test.
78/// * `options` – Threshold configuration.
79pub fn should_collapse_polygon(poly: &Polygon, options: &CollapseOptions) -> bool {
80    polygon_area_abs(poly) < options.min_polygon_area
81}
82
83/// Collapses `poly` to its area-weighted 2-D centroid when the polygon's
84/// absolute area falls below the configured threshold.
85///
86/// Returns `Some(centroid_coord)` when collapse occurs, `None` otherwise.
87///
88/// # Arguments
89///
90/// * `poly`    – Reference polygon.
91/// * `options` – Threshold configuration.
92pub fn collapse_polygon_to_point(poly: &Polygon, options: &CollapseOptions) -> Option<Coordinate> {
93    if should_collapse_polygon(poly, options) {
94        Some(polygon_centroid_2d(poly))
95    } else {
96        None
97    }
98}
99
100/// Collapses `line` to its arc-length midpoint when the line's total
101/// Euclidean length falls below the configured threshold.
102///
103/// Returns `Some(midpoint_coord)` when collapse occurs, `None` otherwise.
104///
105/// # Arguments
106///
107/// * `line`    – Reference linestring.
108/// * `options` – Threshold configuration (uses `min_linestring_length`).
109pub fn collapse_linestring_to_point(
110    line: &LineString,
111    options: &CollapseOptions,
112) -> Option<Coordinate> {
113    if let Some(min_len) = options.min_linestring_length {
114        let len = linestring_length(line);
115        if len < min_len {
116            return Some(linestring_midpoint(line));
117        }
118    }
119    None
120}
121
122// ── Exaggerate ────────────────────────────────────────────────────────────────
123
124/// Specifies the fixed point from which exaggeration is applied.
125#[derive(Debug, Clone)]
126pub enum ExaggerateAnchor {
127    /// Use the area-weighted centroid of the geometry (default, most common).
128    Centroid,
129    /// Use the centre of the axis-aligned bounding box.
130    BoundingBoxCenter,
131    /// Use an arbitrary user-supplied coordinate.
132    Custom(Coordinate),
133}
134
135/// Options controlling the exaggeration transform.
136#[derive(Debug, Clone)]
137pub struct ExaggerateOptions {
138    /// Uniform scale factor applied around the anchor.
139    ///
140    /// * `> 1.0` enlarges the feature.
141    /// * `< 1.0` shrinks the feature.
142    /// * `1.0`   leaves positions unchanged.
143    pub scale_factor: f64,
144
145    /// Anchor point from which scaling emanates.
146    pub anchor: ExaggerateAnchor,
147}
148
149impl Default for ExaggerateOptions {
150    fn default() -> Self {
151        Self {
152            scale_factor: 1.0,
153            anchor: ExaggerateAnchor::Centroid,
154        }
155    }
156}
157
158/// Applies an affine scale-from-anchor transform to a single coordinate.
159///
160/// The new position is: `anchor + scale * (point − anchor)`.
161///
162/// # Arguments
163///
164/// * `point`  – Coordinate to transform.
165/// * `anchor` – Fixed reference point.
166/// * `scale`  – Scale factor.
167pub fn exaggerate_coord(point: &Coordinate, anchor: &Coordinate, scale: f64) -> Coordinate {
168    Coordinate {
169        x: anchor.x + scale * (point.x - anchor.x),
170        y: anchor.y + scale * (point.y - anchor.y),
171        // Preserve optional Z by scaling the offset in 3-D space as well.
172        z: match (point.z, anchor.z) {
173            (Some(pz), Some(az)) => Some(az + scale * (pz - az)),
174            (Some(pz), None) => Some(scale * pz),
175            _ => None,
176        },
177        m: point.m,
178    }
179}
180
181/// Exaggerates every vertex of `line` from its computed (or specified) anchor.
182///
183/// # Arguments
184///
185/// * `line`    – Source linestring.
186/// * `options` – Exaggeration parameters.
187///
188/// # Panics
189///
190/// Cannot panic: the output coordinate count equals the input count, which is
191/// at least 2 (enforced by `LineString::new`).
192pub fn exaggerate_linestring(line: &LineString, options: &ExaggerateOptions) -> LineString {
193    let anchor = resolve_linestring_anchor(line, options);
194    let new_coords: Vec<Coordinate> = line
195        .coords()
196        .iter()
197        .map(|c| exaggerate_coord(c, &anchor, options.scale_factor))
198        .collect();
199    // Safety: new_coords has same length as the original, which is >= 2.
200    LineString::new(new_coords).unwrap_or_else(|_| line.clone())
201}
202
203/// Exaggerates all rings of `poly` uniformly from the polygon's anchor.
204///
205/// Both the exterior ring and every interior (hole) ring are scaled.
206///
207/// # Arguments
208///
209/// * `poly`    – Source polygon.
210/// * `options` – Exaggeration parameters.
211pub fn exaggerate_polygon(poly: &Polygon, options: &ExaggerateOptions) -> Polygon {
212    let anchor = resolve_polygon_anchor(poly, options);
213
214    let new_exterior = exaggerate_ring(&poly.exterior, &anchor, options.scale_factor);
215    let new_holes: Vec<LineString> = poly
216        .interiors
217        .iter()
218        .map(|ring| exaggerate_ring(ring, &anchor, options.scale_factor))
219        .collect();
220
221    Polygon::new(new_exterior, new_holes).unwrap_or_else(|_| poly.clone())
222}
223
224// ── Displace ──────────────────────────────────────────────────────────────────
225
226/// Options for the iterative displacement solver.
227#[derive(Debug, Clone)]
228pub struct DisplaceOptions {
229    /// Minimum required Euclidean distance between any two positions (coordinate units).
230    pub min_distance: f64,
231
232    /// Maximum number of repulsion iterations before stopping.
233    pub max_iterations: usize,
234
235    /// Damping coefficient in `(0, 1]`.
236    /// Smaller values yield more conservative per-iteration movements and
237    /// smoother convergence; values near 1 converge faster but may overshoot.
238    pub damping: f64,
239
240    /// Convergence tolerance: the solver stops early when the largest
241    /// single-step displacement is smaller than this value.
242    pub convergence_tol: f64,
243}
244
245impl Default for DisplaceOptions {
246    fn default() -> Self {
247        Self {
248            min_distance: 1.0,
249            max_iterations: 50,
250            damping: 0.5,
251            convergence_tol: 1e-6,
252        }
253    }
254}
255
256/// Summary statistics returned after the displacement solver finishes.
257#[derive(Debug, Clone)]
258pub struct DisplaceStats {
259    /// Number of iterations actually performed.
260    pub iterations: usize,
261
262    /// Maximum per-position movement in the final iteration (coordinate units).
263    pub final_max_displacement: f64,
264
265    /// `true` when the solver terminated because `final_max_displacement < convergence_tol`.
266    pub converged: bool,
267}
268
269/// Displaces a mutable slice of point coordinates so that all pairwise
270/// distances are at least `options.min_distance`.
271///
272/// The algorithm is a damped repulsion loop (O(n² · iterations)):
273///
274/// 1. For each pair `(i, j)` that violates the minimum distance, compute a
275///    repulsion push proportional to the deficit.
276/// 2. Accumulate all pushes for each position into a delta vector.
277/// 3. Apply all deltas simultaneously (symplectic-style update).
278/// 4. Repeat until convergence or `max_iterations` is reached.
279///
280/// # Arguments
281///
282/// * `positions` – Mutable slice of coordinates; modified in-place.
283/// * `options`   – Solver parameters.
284///
285/// # Returns
286///
287/// [`DisplaceStats`] describing solver behaviour.
288pub fn displace_points(positions: &mut [Coordinate], options: &DisplaceOptions) -> DisplaceStats {
289    if positions.len() < 2 {
290        return DisplaceStats {
291            iterations: 0,
292            final_max_displacement: 0.0,
293            converged: true,
294        };
295    }
296
297    let mut max_disp = 0.0_f64;
298    let mut iter = 0_usize;
299
300    for _ in 0..options.max_iterations {
301        iter += 1;
302        let n = positions.len();
303        let mut deltas = vec![(0.0_f64, 0.0_f64); n];
304
305        // Accumulate repulsion deltas for each violating pair (O(n²)).
306        for i in 0..n {
307            for j in (i + 1)..n {
308                let dx = positions[j].x - positions[i].x;
309                let dy = positions[j].y - positions[i].y;
310                let dist_sq = dx * dx + dy * dy;
311                let dist = dist_sq.sqrt();
312
313                if dist < options.min_distance && dist > 1e-12 {
314                    let deficit = options.min_distance - dist;
315                    // Each side absorbs half the required push, scaled by damping.
316                    let push = deficit * 0.5 * options.damping;
317                    let ux = dx / dist;
318                    let uy = dy / dist;
319
320                    deltas[i].0 -= push * ux;
321                    deltas[i].1 -= push * uy;
322                    deltas[j].0 += push * ux;
323                    deltas[j].1 += push * uy;
324                }
325            }
326        }
327
328        // Apply accumulated deltas and track maximum step size.
329        let mut step_max = 0.0_f64;
330        for (pos, (ddx, ddy)) in positions.iter_mut().zip(deltas.iter()) {
331            pos.x += ddx;
332            pos.y += ddy;
333            let step = (ddx * ddx + ddy * ddy).sqrt();
334            if step > step_max {
335                step_max = step;
336            }
337        }
338        max_disp = step_max;
339
340        if max_disp < options.convergence_tol {
341            break;
342        }
343    }
344
345    DisplaceStats {
346        iterations: iter,
347        final_max_displacement: max_disp,
348        converged: max_disp < options.convergence_tol,
349    }
350}
351
352/// Displaces a mutable slice of polygons by operating on their centroids.
353///
354/// The algorithm:
355///
356/// 1. Computes the centroid of each polygon.
357/// 2. Runs [`displace_points`] on those centroids.
358/// 3. Translates each polygon by the centroid displacement vector.
359///
360/// This preserves each polygon's shape while resolving centroid conflicts.
361///
362/// # Arguments
363///
364/// * `polys`   – Mutable slice of polygons; modified in-place.
365/// * `options` – Solver parameters.
366///
367/// # Returns
368///
369/// [`DisplaceStats`] from the underlying point-displacement solver.
370pub fn displace_polygons_by_centroid(
371    polys: &mut [Polygon],
372    options: &DisplaceOptions,
373) -> DisplaceStats {
374    if polys.is_empty() {
375        return DisplaceStats {
376            iterations: 0,
377            final_max_displacement: 0.0,
378            converged: true,
379        };
380    }
381
382    // Collect centroids before displacement.
383    let mut centroids: Vec<Coordinate> = polys.iter().map(|p| polygon_centroid_2d(p)).collect();
384    let original_centroids = centroids.clone();
385
386    let stats = displace_points(&mut centroids, options);
387
388    // Apply per-polygon translation vectors.
389    for (poly, (orig, new_c)) in polys
390        .iter_mut()
391        .zip(original_centroids.iter().zip(centroids.iter()))
392    {
393        let dx = new_c.x - orig.x;
394        let dy = new_c.y - orig.y;
395        *poly = translate_polygon(poly, dx, dy);
396    }
397
398    stats
399}
400
401// ── Private helpers ───────────────────────────────────────────────────────────
402
403/// Returns the absolute planimetric (shoelace) area of `poly`, accounting for
404/// interior holes.
405fn polygon_area_abs(poly: &Polygon) -> f64 {
406    let exterior_area = ring_signed_area(&poly.exterior.coords).abs();
407    let holes_area: f64 = poly
408        .interiors
409        .iter()
410        .map(|h| ring_signed_area(&h.coords).abs())
411        .sum();
412    // Holes reduce the effective area.
413    (exterior_area - holes_area).abs()
414}
415
416/// Computes the signed area of a closed ring using the shoelace (Gauss) formula.
417///
418/// Positive for counter-clockwise, negative for clockwise orientation.
419fn ring_signed_area(coords: &[Coordinate]) -> f64 {
420    if coords.len() < 3 {
421        return 0.0;
422    }
423    let n = coords.len();
424    let mut acc = 0.0_f64;
425    for i in 0..n {
426        let j = (i + 1) % n;
427        acc += coords[i].x * coords[j].y;
428        acc -= coords[j].x * coords[i].y;
429    }
430    acc * 0.5
431}
432
433/// Computes the area-weighted 2-D centroid of `poly` using the Bourke / Polsby
434/// formula, accounting for interior holes.
435///
436/// Falls back to the simple coordinate average when the exterior ring has zero
437/// signed area (degenerate polygon).
438fn polygon_centroid_2d(poly: &Polygon) -> Coordinate {
439    // ── Exterior ring contribution ─────────────────────────────────────────
440    let ext_coords = &poly.exterior.coords;
441    let (ext_area, ext_cx, ext_cy) = ring_centroid_parts(ext_coords);
442
443    if ext_area.abs() < f64::EPSILON {
444        // Degenerate: fall back to simple average of exterior vertices.
445        return coordinate_average(ext_coords);
446    }
447
448    let mut total_area = ext_area;
449    let mut wx = ext_cx * ext_area;
450    let mut wy = ext_cy * ext_area;
451
452    // ── Subtract hole contributions ────────────────────────────────────────
453    for hole in &poly.interiors {
454        let (h_area, h_cx, h_cy) = ring_centroid_parts(&hole.coords);
455        total_area -= h_area;
456        wx -= h_cx * h_area;
457        wy -= h_cy * h_area;
458    }
459
460    if total_area.abs() < f64::EPSILON {
461        return coordinate_average(ext_coords);
462    }
463
464    Coordinate::new_2d(wx / total_area, wy / total_area)
465}
466
467/// Returns `(signed_area, cx, cy)` for a single closed ring using the
468/// standard area-weighted centroid formula (Bourke, 1988).
469fn ring_centroid_parts(coords: &[Coordinate]) -> (f64, f64, f64) {
470    if coords.len() < 3 {
471        return (0.0, 0.0, 0.0);
472    }
473    let n = coords.len();
474    let mut area = 0.0_f64;
475    let mut cx = 0.0_f64;
476    let mut cy = 0.0_f64;
477
478    for i in 0..n {
479        let j = (i + 1) % n;
480        let cross = coords[i].x * coords[j].y - coords[j].x * coords[i].y;
481        area += cross;
482        cx += (coords[i].x + coords[j].x) * cross;
483        cy += (coords[i].y + coords[j].y) * cross;
484    }
485
486    area *= 0.5;
487
488    if area.abs() < f64::EPSILON {
489        // Zero-area ring: return simple average position.
490        let avg = coordinate_average(coords);
491        return (0.0, avg.x, avg.y);
492    }
493
494    cx /= 6.0 * area;
495    cy /= 6.0 * area;
496
497    (area, cx, cy)
498}
499
500/// Returns the simple arithmetic mean of a coordinate slice.
501fn coordinate_average(coords: &[Coordinate]) -> Coordinate {
502    if coords.is_empty() {
503        return Coordinate::new_2d(0.0, 0.0);
504    }
505    let n = coords.len() as f64;
506    let sum_x: f64 = coords.iter().map(|c| c.x).sum();
507    let sum_y: f64 = coords.iter().map(|c| c.y).sum();
508    Coordinate::new_2d(sum_x / n, sum_y / n)
509}
510
511/// Returns the total Euclidean arc-length of `line`.
512fn linestring_length(line: &LineString) -> f64 {
513    let coords = &line.coords;
514    let n = coords.len();
515    if n < 2 {
516        return 0.0;
517    }
518    let mut len = 0.0_f64;
519    for i in 0..(n - 1) {
520        let dx = coords[i + 1].x - coords[i].x;
521        let dy = coords[i + 1].y - coords[i].y;
522        len += (dx * dx + dy * dy).sqrt();
523    }
524    len
525}
526
527/// Returns the coordinate located at the arc-length midpoint of `line`.
528///
529/// Interpolates linearly within the segment that straddles the half-length
530/// position, preserving sub-segment precision.
531fn linestring_midpoint(line: &LineString) -> Coordinate {
532    let coords = &line.coords;
533    let n = coords.len();
534
535    if n == 0 {
536        return Coordinate::new_2d(0.0, 0.0);
537    }
538    if n == 1 {
539        return coords[0];
540    }
541
542    let total = linestring_length(line);
543    if total < f64::EPSILON {
544        // Degenerate – all points coincide; return the first.
545        return coords[0];
546    }
547
548    let half = total * 0.5;
549    let mut accumulated = 0.0_f64;
550
551    for i in 0..(n - 1) {
552        let dx = coords[i + 1].x - coords[i].x;
553        let dy = coords[i + 1].y - coords[i].y;
554        let seg_len = (dx * dx + dy * dy).sqrt();
555
556        if accumulated + seg_len >= half {
557            // The midpoint lies within this segment.
558            let t = if seg_len > f64::EPSILON {
559                (half - accumulated) / seg_len
560            } else {
561                0.0
562            };
563            return Coordinate::new_2d(coords[i].x + t * dx, coords[i].y + t * dy);
564        }
565        accumulated += seg_len;
566    }
567
568    // Floating-point rounding: return the last vertex.
569    coords[n - 1]
570}
571
572/// Scales every vertex of a closed ring outward from `anchor` by `scale`.
573///
574/// The closure property is preserved because both the first and last
575/// coordinate are identical after transformation (they share the same
576/// input coordinate).
577fn exaggerate_ring(ring: &LineString, anchor: &Coordinate, scale: f64) -> LineString {
578    let new_coords: Vec<Coordinate> = ring
579        .coords
580        .iter()
581        .map(|c| exaggerate_coord(c, anchor, scale))
582        .collect();
583    // new_coords has >= 2 elements because ring validation requires >= 4.
584    LineString::new(new_coords).unwrap_or_else(|_| ring.clone())
585}
586
587/// Resolves the anchor coordinate for a polygon, given the chosen strategy.
588fn resolve_polygon_anchor(poly: &Polygon, options: &ExaggerateOptions) -> Coordinate {
589    match &options.anchor {
590        ExaggerateAnchor::Centroid => polygon_centroid_2d(poly),
591        ExaggerateAnchor::BoundingBoxCenter => {
592            polygon_bbox_center(poly).unwrap_or_else(|| polygon_centroid_2d(poly))
593        }
594        ExaggerateAnchor::Custom(c) => *c,
595    }
596}
597
598/// Resolves the anchor coordinate for a linestring, given the chosen strategy.
599fn resolve_linestring_anchor(line: &LineString, options: &ExaggerateOptions) -> Coordinate {
600    match &options.anchor {
601        ExaggerateAnchor::Centroid => linestring_arc_centroid(line),
602        ExaggerateAnchor::BoundingBoxCenter => {
603            linestring_bbox_center(line).unwrap_or_else(|| linestring_arc_centroid(line))
604        }
605        ExaggerateAnchor::Custom(c) => *c,
606    }
607}
608
609/// Returns the axis-aligned bounding-box centre of `poly`, or `None` if the
610/// polygon has no vertices.
611fn polygon_bbox_center(poly: &Polygon) -> Option<Coordinate> {
612    if poly.exterior.coords.is_empty() {
613        return None;
614    }
615    let mut min_x = f64::INFINITY;
616    let mut min_y = f64::INFINITY;
617    let mut max_x = f64::NEG_INFINITY;
618    let mut max_y = f64::NEG_INFINITY;
619
620    for c in &poly.exterior.coords {
621        if c.x < min_x {
622            min_x = c.x;
623        }
624        if c.x > max_x {
625            max_x = c.x;
626        }
627        if c.y < min_y {
628            min_y = c.y;
629        }
630        if c.y > max_y {
631            max_y = c.y;
632        }
633    }
634    Some(Coordinate::new_2d(
635        (min_x + max_x) * 0.5,
636        (min_y + max_y) * 0.5,
637    ))
638}
639
640/// Returns the axis-aligned bounding-box centre of `line`, or `None` if it
641/// has no vertices.
642fn linestring_bbox_center(line: &LineString) -> Option<Coordinate> {
643    if line.coords.is_empty() {
644        return None;
645    }
646    let mut min_x = f64::INFINITY;
647    let mut min_y = f64::INFINITY;
648    let mut max_x = f64::NEG_INFINITY;
649    let mut max_y = f64::NEG_INFINITY;
650
651    for c in &line.coords {
652        if c.x < min_x {
653            min_x = c.x;
654        }
655        if c.x > max_x {
656            max_x = c.x;
657        }
658        if c.y < min_y {
659            min_y = c.y;
660        }
661        if c.y > max_y {
662            max_y = c.y;
663        }
664    }
665    Some(Coordinate::new_2d(
666        (min_x + max_x) * 0.5,
667        (min_y + max_y) * 0.5,
668    ))
669}
670
671/// Arc-length weighted centroid of a linestring (midpoint of each segment,
672/// weighted by segment length).  Degenerates to the first vertex for a
673/// zero-length linestring.
674fn linestring_arc_centroid(line: &LineString) -> Coordinate {
675    let coords = &line.coords;
676    let n = coords.len();
677
678    if n == 0 {
679        return Coordinate::new_2d(0.0, 0.0);
680    }
681    if n == 1 {
682        return coords[0];
683    }
684
685    let mut total_len = 0.0_f64;
686    let mut wx = 0.0_f64;
687    let mut wy = 0.0_f64;
688
689    for i in 0..(n - 1) {
690        let dx = coords[i + 1].x - coords[i].x;
691        let dy = coords[i + 1].y - coords[i].y;
692        let seg_len = (dx * dx + dy * dy).sqrt();
693
694        if seg_len > f64::EPSILON {
695            let mid_x = (coords[i].x + coords[i + 1].x) * 0.5;
696            let mid_y = (coords[i].y + coords[i + 1].y) * 0.5;
697            wx += mid_x * seg_len;
698            wy += mid_y * seg_len;
699            total_len += seg_len;
700        }
701    }
702
703    if total_len < f64::EPSILON {
704        return coords[0];
705    }
706
707    Coordinate::new_2d(wx / total_len, wy / total_len)
708}
709
710/// Translates every vertex of `poly` by `(dx, dy)`, returning a new polygon.
711///
712/// Both the exterior ring and all interior rings are translated.  Returns a
713/// clone of the original polygon on the (impossible in practice) event that
714/// ring reconstruction fails.
715fn translate_polygon(poly: &Polygon, dx: f64, dy: f64) -> Polygon {
716    let new_exterior = translate_ring(&poly.exterior, dx, dy);
717    let new_holes: Vec<LineString> = poly
718        .interiors
719        .iter()
720        .map(|h| translate_ring(h, dx, dy))
721        .collect();
722    Polygon::new(new_exterior, new_holes).unwrap_or_else(|_| poly.clone())
723}
724
725/// Translates every vertex of `ring` by `(dx, dy)`.
726fn translate_ring(ring: &LineString, dx: f64, dy: f64) -> LineString {
727    let new_coords: Vec<Coordinate> = ring
728        .coords
729        .iter()
730        .map(|c| Coordinate {
731            x: c.x + dx,
732            y: c.y + dy,
733            z: c.z,
734            m: c.m,
735        })
736        .collect();
737    LineString::new(new_coords).unwrap_or_else(|_| ring.clone())
738}
739
740// ── Unit tests ────────────────────────────────────────────────────────────────
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    fn make_square(x0: f64, y0: f64, side: f64) -> Polygon {
747        let coords = vec![
748            Coordinate::new_2d(x0, y0),
749            Coordinate::new_2d(x0 + side, y0),
750            Coordinate::new_2d(x0 + side, y0 + side),
751            Coordinate::new_2d(x0, y0 + side),
752            Coordinate::new_2d(x0, y0),
753        ];
754        let ext = LineString::new(coords).expect("valid square ring");
755        Polygon::new(ext, vec![]).expect("valid square polygon")
756    }
757
758    #[test]
759    fn test_polygon_area_abs_unit_square() {
760        let poly = make_square(0.0, 0.0, 1.0);
761        let area = polygon_area_abs(&poly);
762        assert!((area - 1.0).abs() < 1e-12);
763    }
764
765    #[test]
766    fn test_polygon_centroid_2d_unit_square() {
767        let poly = make_square(0.0, 0.0, 1.0);
768        let c = polygon_centroid_2d(&poly);
769        assert!((c.x - 0.5).abs() < 1e-12);
770        assert!((c.y - 0.5).abs() < 1e-12);
771    }
772
773    #[test]
774    fn test_linestring_length_horizontal() {
775        let coords = vec![
776            Coordinate::new_2d(0.0, 0.0),
777            Coordinate::new_2d(3.0, 0.0),
778            Coordinate::new_2d(3.0, 4.0),
779        ];
780        let line = LineString::new(coords).expect("valid");
781        assert!((linestring_length(&line) - 7.0).abs() < 1e-12);
782    }
783
784    #[test]
785    fn test_linestring_midpoint_two_points() {
786        let coords = vec![Coordinate::new_2d(0.0, 0.0), Coordinate::new_2d(4.0, 0.0)];
787        let line = LineString::new(coords).expect("valid");
788        let mid = linestring_midpoint(&line);
789        assert!((mid.x - 2.0).abs() < 1e-12);
790        assert!((mid.y - 0.0).abs() < 1e-12);
791    }
792
793    #[test]
794    fn test_exaggerate_coord_doubles_from_origin() {
795        let anchor = Coordinate::new_2d(0.0, 0.0);
796        let point = Coordinate::new_2d(1.0, 2.0);
797        let result = exaggerate_coord(&point, &anchor, 2.0);
798        assert!((result.x - 2.0).abs() < 1e-12);
799        assert!((result.y - 4.0).abs() < 1e-12);
800    }
801
802    #[test]
803    fn test_translate_polygon_moves_by_delta() {
804        let poly = make_square(0.0, 0.0, 1.0);
805        let moved = translate_polygon(&poly, 5.0, 3.0);
806        let c = polygon_centroid_2d(&moved);
807        assert!((c.x - 5.5).abs() < 1e-12);
808        assert!((c.y - 3.5).abs() < 1e-12);
809    }
810}