Skip to content

Commit e2edb18

Browse files
committed
Merge master into consolidate_test_utilities
2 parents 9f42f53 + 5365ba4 commit e2edb18

4 files changed

Lines changed: 127 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
- Add `NewEnvelopeXY` constructor for building an `Envelope` from variadic x/y
66
coordinate pairs, following the existing `New*XY` constructor pattern.
77

8+
## v0.59.0
9+
10+
2026-03-27
11+
12+
- Add `geos.ClipByRect` function that clips a geometry to an axis-aligned
13+
rectangle (defined by a `geom.Envelope`). This wraps the GEOS
14+
`GEOSClipByRect` operation, which is faster than computing a full
15+
`Intersection` with a rectangular polygon.
16+
817
## v0.58.0
918

1019
2026-02-15

geos/entrypoints.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,3 +331,16 @@ func UnaryUnion(g geom.Geometry) (geom.Geometry, error) {
331331
func ConcaveHull(g geom.Geometry, concavenessRatio float64, allowHoles bool) (geom.Geometry, error) {
332332
return rawgeos.ConcaveHull(g, concavenessRatio, allowHoles)
333333
}
334+
335+
// ClipByRect clips a geometry to an axis-aligned rectangle defined by the
336+
// given [geom.Envelope]. If the envelope is empty, then an empty
337+
// [geom.GeometryCollection] is returned.
338+
//
339+
// The validity of the result is not checked.
340+
func ClipByRect(g geom.Geometry, rect geom.Envelope) (geom.Geometry, error) {
341+
lo, hi, ok := rect.MinMaxXYs()
342+
if !ok {
343+
return geom.Geometry{}, nil
344+
}
345+
return rawgeos.ClipByRect(g, lo.X, lo.Y, hi.X, hi.Y)
346+
}

geos/entrypoints_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,3 +972,86 @@ func TestCoverageIsValid(t *testing.T) {
972972
})
973973
}
974974
}
975+
976+
func TestClipByRect(t *testing.T) {
977+
for _, tc := range []struct {
978+
name string
979+
input string
980+
rect geom.Envelope
981+
want string
982+
}{
983+
{
984+
name: "polygon fully inside rect",
985+
input: "POLYGON((1 1,1 2,2 2,2 1,1 1))",
986+
rect: geom.NewEnvelope(geom.XY{X: 0, Y: 0}, geom.XY{X: 3, Y: 3}),
987+
want: "POLYGON((1 1,1 2,2 2,2 1,1 1))",
988+
},
989+
{
990+
name: "polygon partially overlapping rect",
991+
input: "POLYGON((0 0,0 4,4 4,4 0,0 0))",
992+
rect: geom.NewEnvelope(geom.XY{X: 1, Y: 1}, geom.XY{X: 3, Y: 3}),
993+
want: "POLYGON((1 1,1 3,3 3,3 1,1 1))",
994+
},
995+
{
996+
name: "polygon fully outside rect",
997+
input: "POLYGON((0 0,0 1,1 1,1 0,0 0))",
998+
rect: geom.NewEnvelope(geom.XY{X: 5, Y: 5}, geom.XY{X: 6, Y: 6}),
999+
want: "GEOMETRYCOLLECTION EMPTY",
1000+
},
1001+
{
1002+
name: "linestring clipped by rect",
1003+
input: "LINESTRING(0 0,4 4)",
1004+
rect: geom.NewEnvelope(geom.XY{X: 1, Y: 1}, geom.XY{X: 3, Y: 3}),
1005+
want: "LINESTRING(1 1,3 3)",
1006+
},
1007+
{
1008+
name: "point inside rect",
1009+
input: "POINT(2 2)",
1010+
rect: geom.NewEnvelope(geom.XY{X: 1, Y: 1}, geom.XY{X: 3, Y: 3}),
1011+
want: "POINT(2 2)",
1012+
},
1013+
{
1014+
name: "point outside rect",
1015+
input: "POINT(0 0)",
1016+
rect: geom.NewEnvelope(geom.XY{X: 1, Y: 1}, geom.XY{X: 3, Y: 3}),
1017+
want: "GEOMETRYCOLLECTION EMPTY",
1018+
},
1019+
{
1020+
name: "empty input geometry",
1021+
input: "GEOMETRYCOLLECTION EMPTY",
1022+
rect: geom.NewEnvelope(geom.XY{X: 0, Y: 0}, geom.XY{X: 1, Y: 1}),
1023+
want: "GEOMETRYCOLLECTION EMPTY",
1024+
},
1025+
{
1026+
name: "u-shaped polygon clipped through both arms produces multipolygon",
1027+
input: "POLYGON((0 0,4 0,4 3,3 3,3 1,1 1,1 3,0 3,0 0))",
1028+
rect: geom.NewEnvelope(geom.XY{X: 0, Y: 2}, geom.XY{X: 4, Y: 4}),
1029+
want: "MULTIPOLYGON(((0 2,0 3,1 3,1 2,0 2)),((3 2,3 3,4 3,4 2,3 2)))",
1030+
},
1031+
{
1032+
name: "polygon with hole inside rect",
1033+
input: "POLYGON((0 0,0 6,6 6,6 0,0 0),(2 2,4 2,4 4,2 4,2 2))",
1034+
rect: geom.NewEnvelope(geom.XY{X: 1, Y: 1}, geom.XY{X: 5, Y: 5}),
1035+
want: "POLYGON((1 1,1 5,5 5,5 1,1 1),(2 2,4 2,4 4,2 4,2 2))",
1036+
},
1037+
{
1038+
name: "polygon with hole partially outside rect removes hole",
1039+
input: "POLYGON((0 0,0 6,6 6,6 0,0 0),(1 1,3 1,3 3,1 3,1 1))",
1040+
rect: geom.NewEnvelope(geom.XY{X: 2, Y: 2}, geom.XY{X: 5, Y: 5}),
1041+
want: "POLYGON((2 3,2 5,5 5,5 2,3 2,3 3,2 3))",
1042+
},
1043+
{
1044+
name: "empty envelope",
1045+
input: "POLYGON((0 0,0 1,1 1,1 0,0 0))",
1046+
rect: geom.Envelope{},
1047+
want: "GEOMETRYCOLLECTION EMPTY",
1048+
},
1049+
} {
1050+
t.Run(tc.name, func(t *testing.T) {
1051+
got, err := geos.ClipByRect(geomFromWKT(t, tc.input), tc.rect)
1052+
skipIfUnsupported(t, err)
1053+
expectNoErr(t, err)
1054+
expectGeomEq(t, got, geomFromWKT(t, tc.want), geom.IgnoreOrder)
1055+
})
1056+
}
1057+
}

internal/rawgeos/entrypoints.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ GEOSGeometry *GEOSCoverageSimplifyVW_r(GEOSContextHandle_t handle, const GEOSGeo
4444
int GEOSCoverageIsValid_r(GEOSContextHandle_t handle, const GEOSGeometry* g, double gapWidth, GEOSGeometry** invalidEdges) { return 2; }
4545
#endif
4646
47+
#define CLIP_BY_RECT_MIN_VERSION "3.5.0"
48+
#define CLIP_BY_RECT_MISSING ( \
49+
GEOS_VERSION_MAJOR < 3 || \
50+
(GEOS_VERSION_MAJOR == 3 && GEOS_VERSION_MINOR < 5) \
51+
)
52+
#if CLIP_BY_RECT_MISSING
53+
// This stub implementation always fails:
54+
GEOSGeometry *GEOSClipByRect_r(GEOSContextHandle_t handle, const GEOSGeometry* g, double xmin, double ymin, double xmax, double ymax) { return NULL; }
55+
#endif
56+
4757
#define CONCAVE_HULL_MIN_VERSION "3.11.0"
4858
#define CONCAVE_HULL_MISSING ( \
4959
GEOS_VERSION_MAJOR < 3 || \
@@ -441,6 +451,18 @@ func Envelope(g geom.Geometry) (geom.Geometry, error) {
441451
return result, wrap(err, "executing GEOSEnvelope_r")
442452
}
443453

454+
func ClipByRect(g geom.Geometry, xmin, ymin, xmax, ymax float64) (geom.Geometry, error) {
455+
if C.CLIP_BY_RECT_MISSING != 0 {
456+
return geom.Geometry{}, UnsupportedGEOSVersionError{
457+
C.CLIP_BY_RECT_MIN_VERSION, "ClipByRect",
458+
}
459+
}
460+
result, err := unaryOpG(g, func(ctx C.GEOSContextHandle_t, g *C.GEOSGeometry) *C.GEOSGeometry {
461+
return C.GEOSClipByRect_r(ctx, g, C.double(xmin), C.double(ymin), C.double(xmax), C.double(ymax))
462+
})
463+
return result, wrap(err, "executing GEOSClipByRect_r")
464+
}
465+
444466
func Area(g geom.Geometry) (float64, error) {
445467
result, err := unaryOpF(g, func(h C.GEOSContextHandle_t, g *C.GEOSGeometry, d *C.double) C.int {
446468
return C.GEOSArea_r(h, g, d)

0 commit comments

Comments
 (0)