Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3e32b3a
fix(overlay): split result lobes that meet at a single point
pentatonick Sep 3, 2026
f598fcc
fix(overlay): report wrong orientation before self-intersection
pentatonick Sep 3, 2026
480d277
fix(algorithm): collapse repeated vertices in remove_spikes
pentatonick Sep 3, 2026
ad97471
feat(strategy): centroid for multi-polygons, accumulated as Boost does
pentatonick Sep 3, 2026
ac9bcea
fix(overlay): distinguish the two ways multi-polygon members can be w…
pentatonick Sep 3, 2026
764b154
feat(overlay): multi-polygon operands for the Boolean operations
pentatonick Sep 3, 2026
01cfdde
fix(overlay): a hole sharing an edge with the exterior is a self-inte…
pentatonick Sep 3, 2026
46d074d
fix(predicate): compute a segment crossing parametrically, not by det…
pentatonick Sep 3, 2026
cee26e9
fix(overlay): emit rings, and their vertices, in Boost's order
pentatonick Sep 3, 2026
e24204b
fix(overlay): attach a turn to the segment it terminates
pentatonick Sep 3, 2026
5cbd230
fix(overlay): clean the traversed ring, and order turns by both operands
pentatonick Sep 3, 2026
5cd2c06
fix(overlay): do not emit a point the walked operand runs straight past
pentatonick Sep 3, 2026
e70c423
fix(overlay): append a turn the way the traversal does, and order tur…
pentatonick Sep 4, 2026
e425fee
fix(overlay): read the second operand backwards for a difference
pentatonick Sep 4, 2026
0fc83ca
fix(overlay): a ring no turn lands on keeps every vertex it has
pentatonick Sep 4, 2026
025ce64
fix(coords): two points within an epsilon are one point
pentatonick Sep 4, 2026
ddfaf10
fix(overlay): order the result rings the way `add_rings` does
pentatonick Sep 4, 2026
e60e4fe
feat(buffer): a zero-width buffer of a polygon, which is not a no-op
pentatonick Sep 4, 2026
cad13f9
chore: release v0.0.9
pentatonick Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/geometry-adapt-geo-types/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.0.9](https://github.com/pentatonick/boost_geometry/compare/geometry-adapt-geo-types-v0.0.8...geometry-adapt-geo-types-v0.0.9) - 2026-09-05

### Other

- updated the following local packages: geometry-coords, geometry-cs, geometry-trait, geometry-model

## [0.0.8](https://github.com/pentatonick/boost_geometry/compare/geometry-adapt-geo-types-v0.0.7...geometry-adapt-geo-types-v0.0.8) - 2026-07-15

### Other
Expand Down
10 changes: 5 additions & 5 deletions crates/geometry-adapt-geo-types/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "geometry-adapt-geo-types"
version = "0.0.8"
version = "0.0.9"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
Expand All @@ -12,10 +12,10 @@ readme = "README.md"

[dependencies]
geometry-tag = { version = "0.0.7", path = "../geometry-tag" }
geometry-coords = { version = "0.0.8", path = "../geometry-coords" }
geometry-cs = { version = "0.0.8", path = "../geometry-cs" }
geometry-trait = { version = "0.0.8", path = "../geometry-trait" }
geometry-model = { version = "0.0.8", path = "../geometry-model" }
geometry-coords = { version = "0.0.9", path = "../geometry-coords" }
geometry-cs = { version = "0.0.9", path = "../geometry-cs" }
geometry-trait = { version = "0.0.9", path = "../geometry-trait" }
geometry-model = { version = "0.0.9", path = "../geometry-model" }
geo-types = "0.7"

[dev-dependencies]
Expand Down
6 changes: 6 additions & 0 deletions crates/geometry-adapt-nalgebra/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.0.9](https://github.com/pentatonick/boost_geometry/compare/geometry-adapt-nalgebra-v0.0.8...geometry-adapt-nalgebra-v0.0.9) - 2026-09-05

### Other

- updated the following local packages: geometry-coords, geometry-cs, geometry-trait

## [0.0.8](https://github.com/pentatonick/boost_geometry/compare/geometry-adapt-nalgebra-v0.0.7...geometry-adapt-nalgebra-v0.0.8) - 2026-07-15

### Other
Expand Down
8 changes: 4 additions & 4 deletions crates/geometry-adapt-nalgebra/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "geometry-adapt-nalgebra"
version = "0.0.8"
version = "0.0.9"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
Expand All @@ -12,9 +12,9 @@ readme = "README.md"

[dependencies]
geometry-tag = { version = "0.0.7", path = "../geometry-tag" }
geometry-coords = { version = "0.0.8", path = "../geometry-coords" }
geometry-cs = { version = "0.0.8", path = "../geometry-cs" }
geometry-trait = { version = "0.0.8", path = "../geometry-trait" }
geometry-coords = { version = "0.0.9", path = "../geometry-coords" }
geometry-cs = { version = "0.0.9", path = "../geometry-cs" }
geometry-trait = { version = "0.0.9", path = "../geometry-trait" }
nalgebra = "0.32"

[dev-dependencies]
Expand Down
6 changes: 6 additions & 0 deletions crates/geometry-adapt/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.0.9](https://github.com/pentatonick/boost_geometry/compare/geometry-adapt-v0.0.8...geometry-adapt-v0.0.9) - 2026-09-05

### Other

- updated the following local packages: geometry-coords, geometry-cs, geometry-trait

## [0.0.8](https://github.com/pentatonick/boost_geometry/compare/geometry-adapt-v0.0.7...geometry-adapt-v0.0.8) - 2026-07-15

### Other
Expand Down
8 changes: 4 additions & 4 deletions crates/geometry-adapt/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "geometry-adapt"
version = "0.0.8"
version = "0.0.9"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
Expand All @@ -24,9 +24,9 @@ libm = [

[dependencies]
geometry-tag = { version = "0.0.7", path = "../geometry-tag" }
geometry-coords = { version = "0.0.8", path = "../geometry-coords", default-features = false }
geometry-cs = { version = "0.0.8", path = "../geometry-cs", default-features = false }
geometry-trait = { version = "0.0.8", path = "../geometry-trait", default-features = false }
geometry-coords = { version = "0.0.9", path = "../geometry-coords", default-features = false }
geometry-cs = { version = "0.0.9", path = "../geometry-cs", default-features = false }
geometry-trait = { version = "0.0.9", path = "../geometry-trait", default-features = false }

[dev-dependencies]
geometry-algorithm = { path = "../geometry-algorithm" }
Expand Down
7 changes: 7 additions & 0 deletions crates/geometry-algorithm/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.0.9](https://github.com/pentatonick/boost_geometry/compare/geometry-algorithm-v0.0.8...geometry-algorithm-v0.0.9) - 2026-09-05

### Fixed

- *(coords)* two points within an epsilon are one point
- *(algorithm)* collapse repeated vertices in remove_spikes

## [0.0.8](https://github.com/pentatonick/boost_geometry/compare/geometry-algorithm-v0.0.7...geometry-algorithm-v0.0.8) - 2026-07-15

### Added
Expand Down
12 changes: 6 additions & 6 deletions crates/geometry-algorithm/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "geometry-algorithm"
version = "0.0.8"
version = "0.0.9"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
Expand All @@ -27,12 +27,12 @@ libm = [
]

[dependencies]
geometry-coords = { version = "0.0.8", path = "../geometry-coords", default-features = false }
geometry-coords = { version = "0.0.9", path = "../geometry-coords", default-features = false }
geometry-tag = { version = "0.0.7", path = "../geometry-tag", default-features = false }
geometry-cs = { version = "0.0.8", path = "../geometry-cs", default-features = false }
geometry-trait = { version = "0.0.8", path = "../geometry-trait", default-features = false }
geometry-strategy = { version = "0.0.8", path = "../geometry-strategy", default-features = false }
geometry-model = { version = "0.0.8", path = "../geometry-model", default-features = false }
geometry-cs = { version = "0.0.9", path = "../geometry-cs", default-features = false }
geometry-trait = { version = "0.0.9", path = "../geometry-trait", default-features = false }
geometry-strategy = { version = "0.0.9", path = "../geometry-strategy", default-features = false }
geometry-model = { version = "0.0.9", path = "../geometry-model", default-features = false }

[dev-dependencies]
geometry-adapt = { path = "../geometry-adapt" }
Expand Down
193 changes: 179 additions & 14 deletions crates/geometry-algorithm/src/remove_spikes.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
//! `remove_spikes(&mut g)` β€” drop collinear-and-reversed vertices.
//!
//! Mirrors `boost::geometry::remove_spikes` from
//! `boost/geometry/algorithms/remove_spikes.hpp`. A spike is a triple
//! `(a, b, c)` where `(b-a) Γ— (c-b) == 0` (collinear) AND
//! `(b-a) Β· (c-b) < 0` (reversed). The middle vertex `b` is removed;
//! the walk repeats until no spike remains, because collapsing one
//! spike can create a new one at the now-adjacent pair.
//! `boost/geometry/algorithms/remove_spikes.hpp`. The predicate is
//! Boost's `point_is_spike_or_equal`, and the `or_equal` half carries
//! its weight: a triple `(a, b, c)` qualifies when `(b-a) Γ— (c-b) == 0`
//! (collinear) and `(b-a) Β· (c-b) <= 0`, which covers both a reversal
//! and a zero-length step β€” that is, a repeated vertex. The middle
//! vertex `b` is removed; the walk repeats until nothing qualifies,
//! because collapsing one spike can create a new one at the
//! now-adjacent pair, and peeling a spike off a ring routinely leaves a
//! repeated vertex behind.
//!
//! Per-kind:
//! * `Linestring`, `Ring` β†’ spike-walk the backing `Vec<P>`
Expand Down Expand Up @@ -34,17 +38,41 @@ pub trait RemoveSpikes {
fn remove_spikes(&mut self);
}

/// True iff `b` is a spike between `a` and `c` (2D cross `== 0` AND
/// dot `< 0`).
fn is_spike_2d<P: PointTrait>(a: &P, b: &P, c: &P) -> bool {
/// True iff `b` is a spike between `a` and `c`, **or** duplicates one of
/// them: 2D cross `== 0` and dot `<= 0`.
///
/// Mirrors `detail::point_is_spike_or_equal`
/// (`algorithms/detail/point_is_spike_or_equal.hpp`). Requiring `dot < 0`
/// instead would leave every repeated vertex in place, including the ones
/// this function creates: removing the apex of `(4,0) (6,0) (4,0)` leaves
/// `(4,0) (4,0)` adjacent, and Boost collapses that.
///
/// `dot <= 0` cannot over-match. Two non-zero vectors that are both
/// parallel (`cross == 0`) and perpendicular (`dot == 0`) do not exist, so
/// the equality arm fires only when one of the steps has zero length.
fn is_spike_or_equal_2d<P: PointTrait>(a: &P, b: &P, c: &P) -> bool {
let ux = b.get::<0>() - a.get::<0>();
let uy = b.get::<1>() - a.get::<1>();
let vx = c.get::<0>() - b.get::<0>();
let vy = c.get::<1>() - b.get::<1>();
let cross = ux * vy - uy * vx;
let dot = ux * vx + uy * vy;
let zero = <P::Scalar as CoordinateScalar>::ZERO;
cross == zero && dot < zero
// The collinearity half is Boost's `side_by_triangle`, which calls three
// points collinear whenever any *two* of them are equal by `math::equals`
// β€” a relative epsilon β€” before it looks at any determinant
// (`side_by_triangle.hpp:150-164`). A hairline whose two ends are a few
// last bits apart at a large coordinate is a spike to Boost and a genuine
// sliver to an exact cross product, which is how one survived into a tile
// that the reference drew as nothing.
let same = |ax: P::Scalar, ay: P::Scalar, bx: P::Scalar, by: P::Scalar| {
ax.tolerant_eq(bx) && ay.tolerant_eq(by)
};
let collinear = cross == zero
|| same(a.get::<0>(), a.get::<1>(), b.get::<0>(), b.get::<1>())
|| same(a.get::<0>(), a.get::<1>(), c.get::<0>(), c.get::<1>())
|| same(b.get::<0>(), b.get::<1>(), c.get::<0>(), c.get::<1>());
collinear && dot <= zero
}

fn walk_spikes<P: PointTrait>(pts: &mut alloc::vec::Vec<P>) {
Expand All @@ -53,7 +81,7 @@ fn walk_spikes<P: PointTrait>(pts: &mut alloc::vec::Vec<P>) {
changed = false;
let mut i = 1;
while i + 1 < pts.len() {
if is_spike_2d(&pts[i - 1], &pts[i], &pts[i + 1]) {
if is_spike_or_equal_2d(&pts[i - 1], &pts[i], &pts[i + 1]) {
pts.remove(i);
changed = true;
// Do not advance `i`: the new `pts[i]` (was `pts[i+1]`)
Expand Down Expand Up @@ -105,12 +133,14 @@ fn walk_ring_spikes<P: PointTrait + Copy>(pts: &mut alloc::vec::Vec<P>, closed:
while found {
found = false;
// Spike at the first point: (prev = back-1, back, front).
while pts.len() >= 3 && is_spike_2d(&pts[pts.len() - 2], &pts[pts.len() - 1], &pts[0]) {
while pts.len() >= 3
&& is_spike_or_equal_2d(&pts[pts.len() - 2], &pts[pts.len() - 1], &pts[0])
{
pts.pop();
found = true;
}
// Spike at the second point: (back, front, front+1).
while pts.len() >= 3 && is_spike_2d(&pts[pts.len() - 1], &pts[0], &pts[1]) {
while pts.len() >= 3 && is_spike_or_equal_2d(&pts[pts.len() - 1], &pts[0], &pts[1]) {
pts.remove(0);
found = true;
}
Expand Down Expand Up @@ -160,11 +190,54 @@ mod tests {

use super::remove_spikes;
use geometry_cs::Cartesian;
use geometry_model::{Point2D, linestring};
use geometry_trait::Linestring as _;
use geometry_model::{Point2D, Ring, linestring};
use geometry_trait::{Linestring as _, Point as _, Ring as _};

type P = Point2D<f64, Cartesian>;

fn spike_ring(points: &[(f64, f64)]) -> Ring<P> {
let mut ring = Ring::new();
for &(x, y) in points {
ring.push(P::new(x, y));
}
ring
}

/// A hairline whose two ends are four last bits apart at a coordinate of
/// 3540, which is inside one epsilon of it.
///
/// C++: `side_by_triangle` calls three points collinear when any two of
/// them are `math::equals` β€” a *relative* epsilon β€” before it computes any
/// determinant, so Boost sees a spike here and collapses the ring to a
/// single repeated point. An exact cross product sees a sliver with real
/// area and keeps it, which is how one survived into a monaco tile the
/// reference drew as nothing.
#[test]
fn a_hairline_within_an_epsilon_is_a_spike() {
let mut ring = spike_ring(&[
(3_539.999_999_999_999_5, 482.199_999_999_999_76),
(3540.0, 482.199_999_999_999_8),
(3540.0, 479.0),
(3_539.999_999_999_999_5, 482.199_999_999_999_76),
]);
remove_spikes(&mut ring);
assert_eq!(ring.0.len(), 2, "{:?}", ring.0);
}

/// The same ring with its two ends far enough apart to be two points,
/// where the sliver has real area and stays.
#[test]
fn a_sliver_wider_than_an_epsilon_is_kept() {
let mut ring = spike_ring(&[
(3_539.999_999_9, 482.199_999_9),
(3540.0, 482.2),
(3540.0, 479.0),
(3_539.999_999_9, 482.199_999_9),
]);
remove_spikes(&mut ring);
assert_eq!(ring.0.len(), 4, "{:?}", ring.0);
}

#[test]
fn out_and_back_spur_is_removed() {
// (0,0) β†’ (1,0) β†’ (3,0) β†’ (2,0): the tip (3,0) is a reversed
Expand Down Expand Up @@ -306,4 +379,96 @@ mod tests {
assert!(r.points().count() >= 4);
assert_eq!(pts.first(), pts.last(), "ring must remain closed");
}

/// Boost collapses a repeated vertex the same way it collapses a
/// spike β€” `point_is_spike_or_equal` covers both. Expected values from
/// `boost::geometry::remove_spikes` on a clockwise `model::polygon`
/// (Boost 1.83):
///
/// ```text
/// consecutive dup -> (0,0) (0,4) (4,4) (4,0) (0,0)
/// dup at start -> (0,0) (0,4) (4,4) (4,0) (0,0)
/// triple dup -> (0,0) (0,4) (4,4) (4,0) (0,0)
/// real spike -> (0,0) (0,4) (4,4) (4,0) (0,0)
/// dup + spike -> (0,0) (0,4) (4,4) (4,0) (0,0)
/// ```
///
/// The `real spike` row is the one that shows why: removing the apex
/// of `(4,0) (6,0) (4,0)` leaves `(4,0) (4,0)` adjacent, so a
/// spike-only predicate makes duplicates out of its own output.
#[test]
fn repeated_vertices_are_collapsed() {
let square = [(0.0, 0.0), (0.0, 4.0), (4.0, 4.0), (4.0, 0.0), (0.0, 0.0)];

for (name, input) in [
(
"consecutive dup",
vec![
(0.0, 0.0),
(0.0, 4.0),
(4.0, 4.0),
(4.0, 4.0),
(4.0, 0.0),
(0.0, 0.0),
],
),
(
"dup at start",
vec![
(0.0, 0.0),
(0.0, 0.0),
(0.0, 4.0),
(4.0, 4.0),
(4.0, 0.0),
(0.0, 0.0),
],
),
(
"triple dup",
vec![
(0.0, 0.0),
(0.0, 4.0),
(4.0, 4.0),
(4.0, 4.0),
(4.0, 4.0),
(4.0, 0.0),
(0.0, 0.0),
],
),
(
"real spike",
vec![
(0.0, 0.0),
(0.0, 4.0),
(4.0, 4.0),
(4.0, 0.0),
(6.0, 0.0),
(4.0, 0.0),
(0.0, 0.0),
],
),
(
"dup + spike",
vec![
(0.0, 0.0),
(0.0, 4.0),
(4.0, 4.0),
(4.0, 4.0),
(4.0, 0.0),
(6.0, 0.0),
(4.0, 0.0),
(0.0, 0.0),
],
),
] {
let mut ring: Ring<P> =
Ring::from_vec(input.iter().map(|&(x, y)| P::new(x, y)).collect());
remove_spikes(&mut ring);
let pts: Vec<(f64, f64)> = ring
.points()
.map(|p| (p.get::<0>(), p.get::<1>()))
.collect();
assert_eq!(pts, square, "{name}");
}
}
}
Loading
Loading