Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 1 addition & 3 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ linters-settings:
errcheck:
exclude-functions:
- io.Copy(os.Stdout)
- (*github.com/peterstace/simplefeatures/rtree.RTree).RangeSearch
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

errcheck seems to get a bit confused when generics are introduced... I can longer specify funcs in exclude-functions section. Instead, I've opted to just use _ = ... whenever I want to ignore an error.

- (*github.com/peterstace/simplefeatures/rtree.RTree).PrioritySearch

# NOTE: every linter supported by golangci-lint is either explicitly included
# or excluded.
Expand Down Expand Up @@ -79,7 +77,6 @@ linters:
- importas
- ineffassign
- intrange
- ireturn
- loggercheck
- makezero
- mirror
Expand Down Expand Up @@ -143,6 +140,7 @@ linters:
- gomnd
- inamedparam
- interfacebloat
- ireturn
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I return has a false positive when using generics. Seems like not really that useful for this project, so disabled.

- lll
- maintidx
- nestif
Expand Down
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## Unreleased

- **Breaking change:** The `rtree` package types and functions are now generic
over the record type. The `RTree` type is now `RTree[T]`, `BulkItem` is now
`BulkItem[T]`, and `BulkLoad` is now `BulkLoad[T]`. The `RecordID int` field
in `BulkItem` has been renamed to `Record T`. This allows users to store
their records directly in the tree rather than maintaining separate mappings
between integer IDs and records. Users can upgrade by adding type parameters
to their rtree usage (e.g., `RTree[int]` to maintain existing behavior with
integer IDs, or use a custom type like `RTree[MyRecord]` to store records
directly). The `RecordID` field in `BulkItem` should be renamed to `Record`,
and callback function signatures should change from `func(recordID int)` to
`func(record T)` where `T` is the type parameter.

## v0.56.0

2025-11-21
Expand All @@ -15,7 +29,7 @@
assertions.

- **Breaking change:** The minimum required Go version is now 1.18 (previously
1.17). This is required to support the `any` keyword.
1.17). This is required to support the `any` keyword and generics.

## v0.55.0

Expand Down
107 changes: 45 additions & 62 deletions geom/alg_distance.go
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODOs:

  • Run benchmarks tests to check for a performance regression.
  • Update geom package to make better use of the generic RTree (we shouldn't need the separate arrays to hold the records any more!).

Original file line number Diff line number Diff line change
Expand Up @@ -37,60 +37,42 @@ func Distance(g1, g2 Geometry) (float64, bool) {
lns1, lns2 = lns2, lns1
}

tr := loadTree(xys2, lns2)
xyTree := loadXYTree(xys2)
lnTree := loadLineTree(lns2)
minDist := math.Inf(+1)

searchBody := func(
env Envelope,
recordID int,
xyDist func(int) float64,
lnDist func(int) float64,
) error {
// Convert recordID back to array indexes.
xyIdx := recordID - 1
lnIdx := -recordID - 1

// Abort the search if we're gone further away compared to our best
// distance so far.
var recordEnv Envelope
if recordID > 0 {
recordEnv = xys2[xyIdx].uncheckedEnvelope()
} else {
recordEnv = lns2[lnIdx].uncheckedEnvelope()
}
if d, ok := recordEnv.Distance(env); ok && d > minDist {
return rtree.Stop
}

// See if the current item in the tree is better than our current best
// distance.
if recordID > 0 {
minDist = fastMin(minDist, xyDist(xyIdx))
} else {
minDist = fastMin(minDist, lnDist(lnIdx))
}
return nil
}
for _, xy := range xys1 {
xyEnv := xy.uncheckedEnvelope()
tr.PrioritySearch(xy.box(), func(recordID int) error {
return searchBody(
xyEnv,
recordID,
func(i int) float64 { return distBetweenXYs(xy, xys2[i]) },
func(i int) float64 { return distBetweenXYAndLine(xy, lns2[i]) },
)
for _, xy1 := range xys1 {
xy1Env := xy1.uncheckedEnvelope()
_ = xyTree.PrioritySearch(xy1.box(), func(xy2 XY) error {
if d, ok := xy2.uncheckedEnvelope().Distance(xy1Env); ok && d > minDist {
return rtree.Stop
}
minDist = fastMin(minDist, distBetweenXYs(xy1, xy2))
return nil
})
_ = lnTree.PrioritySearch(xy1.box(), func(ln2 line) error {
if d, ok := ln2.uncheckedEnvelope().Distance(xy1Env); ok && d > minDist {
return rtree.Stop
}
minDist = fastMin(minDist, distBetweenXYAndLine(xy1, ln2))
return nil
})
}
for _, ln := range lns1 {
lnEnv := ln.uncheckedEnvelope()
tr.PrioritySearch(ln.box(), func(recordID int) error {
return searchBody(
lnEnv,
recordID,
func(i int) float64 { return distBetweenXYAndLine(xys2[i], ln) },
func(i int) float64 { return distBetweenLineAndLine(lns2[i], ln) },
)
for _, ln1 := range lns1 {
ln1Env := ln1.uncheckedEnvelope()
_ = xyTree.PrioritySearch(ln1.box(), func(xy2 XY) error {
if d, ok := xy2.uncheckedEnvelope().Distance(ln1Env); ok && d > minDist {
return rtree.Stop
}
minDist = fastMin(minDist, distBetweenXYAndLine(xy2, ln1))
return nil
})
_ = lnTree.PrioritySearch(ln1.box(), func(ln2 line) error {
if d, ok := ln2.uncheckedEnvelope().Distance(ln1Env); ok && d > minDist {
return rtree.Stop
}
minDist = fastMin(minDist, distBetweenLineAndLine(ln1, ln2))
return nil
})
}

Expand Down Expand Up @@ -128,22 +110,23 @@ func extractXYsAndLines(g Geometry) ([]XY, []line) {
}
}

// loadTree creates a new RTree that indexes both the XYs and the lines. It
// uses positive record IDs to refer to the XYs, and negative recordIDs to
// refer to the lines. Because +0 and -0 are the same, indexing is 1-based and
// recordID 0 is not used.
func loadTree(xys []XY, lns []line) *rtree.RTree {
items := make([]rtree.BulkItem, len(xys)+len(lns))
func loadXYTree(xys []XY) *rtree.RTree[XY] {
items := make([]rtree.BulkItem[XY], len(xys))
for i, xy := range xys {
items[i] = rtree.BulkItem{
Box: xy.box(),
RecordID: i + 1,
items[i] = rtree.BulkItem[XY]{
Box: xy.box(),
Record: xy,
}
}
return rtree.BulkLoad(items)
}

func loadLineTree(lns []line) *rtree.RTree[line] {
items := make([]rtree.BulkItem[line], len(lns))
for i, ln := range lns {
items[i+len(xys)] = rtree.BulkItem{
Box: ln.box(),
RecordID: -(i + 1),
items[i] = rtree.BulkItem[line]{
Box: ln.box(),
Record: ln,
}
}
return rtree.BulkLoad(items)
Expand Down
2 changes: 1 addition & 1 deletion geom/alg_intersection.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ func intersectionOfIndexedLines(
var pts []Point
seen := make(map[XY]bool)
for i := range lines1.lines {
lines2.tree.RangeSearch(lines1.lines[i].box(), func(j int) error {
_ = lines2.tree.RangeSearch(lines1.lines[i].box(), func(j int) error {
inter := lines1.lines[i].intersectLine(lines2.lines[j])
if inter.empty {
return nil
Expand Down
10 changes: 5 additions & 5 deletions geom/alg_intersects.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,11 @@ func hasIntersectionBetweenLines(
lines1, lines2 = lines2, lines1
}

bulk := make([]rtree.BulkItem, len(lines1))
bulk := make([]rtree.BulkItem[int], len(lines1))
for i, ln := range lines1 {
bulk[i] = rtree.BulkItem{
Box: ln.box(),
RecordID: i,
bulk[i] = rtree.BulkItem[int]{
Box: ln.box(),
Record: i,
}
}
tree := rtree.BulkLoad(bulk)
Expand All @@ -209,7 +209,7 @@ func hasIntersectionBetweenLines(
var env Envelope

for _, lnA := range lines2 {
tree.RangeSearch(lnA.box(), func(i int) error {
_ = tree.RangeSearch(lnA.box(), func(i int) error {
lnB := lines1[i]
inter := lnA.intersectLine(lnB)
if inter.empty {
Expand Down
2 changes: 1 addition & 1 deletion geom/alg_point_in_ring.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func relatePointToPolygon(pt XY, polyBoundary indexedLines) side {
}
var onBound bool
var count int
polyBoundary.tree.RangeSearch(box, func(i int) error {
_ = polyBoundary.tree.RangeSearch(box, func(i int) error {
ln := polyBoundary.lines[i]
crossing, onLine := hasCrossing(pt, ln)
if onLine {
Expand Down
6 changes: 3 additions & 3 deletions geom/dcel_ghosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ func spanningTree(xys []XY) MultiLineString {

// Load points into r-tree.
xys = sortAndUniquifyXYs(xys)
items := make([]rtree.BulkItem, len(xys))
items := make([]rtree.BulkItem[int], len(xys))
for i, xy := range xys {
items[i] = rtree.BulkItem{Box: xy.box(), RecordID: i}
items[i] = rtree.BulkItem[int]{Box: xy.box(), Record: i}
}
tree := rtree.BulkLoad(items)

Expand All @@ -49,7 +49,7 @@ func spanningTree(xys []XY) MultiLineString {
// of being the closest to another point.
continue
}
tree.PrioritySearch(xyi.box(), func(j int) error {
_ = tree.PrioritySearch(xyi.box(), func(j int) error {
// We don't want to include a new edge in the spanning tree if it
// would cause a cycle (i.e. the two endpoints are already in the
// same tree). This is checked via dset.
Expand Down
4 changes: 2 additions & 2 deletions geom/dcel_re_noding.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func reNodeGeometries(g1, g2 Geometry, mls MultiLineString) (Geometry, Geometry,
// Create new nodes for point/line intersections.
ptIndex := newIndexedPoints(nodes.list())
appendCutsForPointXLine := func(ln line, cuts []XY) []XY {
ptIndex.tree.RangeSearch(ln.box(), func(i int) error {
_ = ptIndex.tree.RangeSearch(ln.box(), func(i int) error {
xy := ptIndex.points[i]
if !ln.hasEndpoint(xy) && distBetweenXYAndLine(xy, ln) < ulp*0x200 {
cuts = append(cuts, xy)
Expand All @@ -64,7 +64,7 @@ func reNodeGeometries(g1, g2 Geometry, mls MultiLineString) (Geometry, Geometry,
// Create new nodes for line/line intersections.
lnIndex := newIndexedLines(appendLines(nil, all()))
appendCutsLineXLine := func(ln line, cuts []XY) []XY {
lnIndex.tree.RangeSearch(ln.box(), func(i int) error {
_ = lnIndex.tree.RangeSearch(ln.box(), func(i int) error {
other := lnIndex.lines[i]

// TODO: This is a hacky approach (re-orders inputs, rather than
Expand Down
44 changes: 34 additions & 10 deletions geom/rtree.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,44 @@ package geom

import "github.com/peterstace/simplefeatures/rtree"

// TODO: Use this instead of indexedLines/Points where possible.
func newLineRTree(lines []line) *rtree.RTree[line] { //nolint:unused
items := make([]rtree.BulkItem[line], len(lines))
for i, ln := range lines {
items[i] = rtree.BulkItem[line]{
Box: ln.box(),
Record: ln,
}
}
return rtree.BulkLoad(items)
}

// TODO: Use this instead of indexedLines/Points where possible.
func newPointRTree(points []XY) *rtree.RTree[XY] { //nolint:unused
items := make([]rtree.BulkItem[XY], len(points))
for i, pt := range points {
items[i] = rtree.BulkItem[XY]{
Box: pt.box(),
Record: pt,
}
}
return rtree.BulkLoad(items)
}

// indexedLines is a simple container to hold a list of lines, and a r-tree
// structure indexing those lines. The record IDs in the rtree correspond to
// the indices of the lines slice.
type indexedLines struct {
lines []line
tree *rtree.RTree
tree *rtree.RTree[int]
}

func newIndexedLines(lines []line) indexedLines {
bulk := make([]rtree.BulkItem, len(lines))
bulk := make([]rtree.BulkItem[int], len(lines))
for i, ln := range lines {
bulk[i] = rtree.BulkItem{
Box: ln.box(),
RecordID: i,
bulk[i] = rtree.BulkItem[int]{
Box: ln.box(),
Record: i,
}
}
return indexedLines{lines, rtree.BulkLoad(bulk)}
Expand All @@ -26,15 +50,15 @@ func newIndexedLines(lines []line) indexedLines {
// the indices of the points slice.
type indexedPoints struct {
points []XY
tree *rtree.RTree
tree *rtree.RTree[int]
}

func newIndexedPoints(points []XY) indexedPoints {
bulk := make([]rtree.BulkItem, len(points))
bulk := make([]rtree.BulkItem[int], len(points))
for i, pt := range points {
bulk[i] = rtree.BulkItem{
Box: rtree.Box{MinX: pt.X, MaxX: pt.X, MinY: pt.Y, MaxY: pt.Y},
RecordID: i,
bulk[i] = rtree.BulkItem[int]{
Box: rtree.Box{MinX: pt.X, MaxX: pt.X, MinY: pt.Y, MaxY: pt.Y},
Record: i,
}
}
return indexedPoints{points, rtree.BulkLoad(bulk)}
Expand Down
6 changes: 3 additions & 3 deletions geom/type_line_string.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,13 @@ func (s LineString) IsSimple() bool {
}

n := s.seq.Length()
items := make([]rtree.BulkItem, 0, n-1)
items := make([]rtree.BulkItem[int], 0, n-1)
for i := 0; i < n; i++ {
ln, ok := getLine(s.seq, i)
if !ok {
continue
}
items = append(items, rtree.BulkItem{Box: ln.box(), RecordID: i})
items = append(items, rtree.BulkItem[int]{Box: ln.box(), Record: i})
}
tree := rtree.BulkLoad(items)

Expand All @@ -142,7 +142,7 @@ func (s LineString) IsSimple() bool {
}

simple := true // assume simple until proven otherwise
tree.RangeSearch(ln.box(), func(j int) error {
_ = tree.RangeSearch(ln.box(), func(j int) error {
// Skip finding the original line (i == j) or cases where we have
// already checked that pair (i > j).
if i >= j {
Expand Down
Loading