diff --git a/.ci/run_benchmarks.sh b/.ci/run_benchmarks.sh index c065435f..6fd2ca5e 100755 --- a/.ci/run_benchmarks.sh +++ b/.ci/run_benchmarks.sh @@ -17,6 +17,10 @@ trap "rm -f $new $old" EXIT package="./..." bench="." +pushd "$HOME" +go get golang.org/x/perf/cmd/benchstat +popd + for (( i = 0; i < 15; i++ )); do echo echo "RUN $i" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b20b194e..fe75148b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,6 +11,8 @@ Have you: - Add cmprefimpl tests? (if appropriate?) +- Updated release notes? (if appropriate?) + ## Related Issue - Please link to the related issue(s). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a385d4c2..0007a879 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: build on: - push + push: + pull_request: jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 00000000..d15dbbf3 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,17 @@ +name: golangci-lint +on: + push: + pull_request: +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install dependencies + run: sudo apt update && sudo sudo apt install libgdal-dev libgeos-dev libproj-dev + - name: golangci-lint + uses: golangci/golangci-lint-action@v2 + with: + # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version + version: v1.42 diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 00000000..c939a220 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,16 @@ +run: + timeout: 5m + +linter-settings: + gosimple: + go: "1.16" + checks: ["all"] + +linters: + fast: false + disable: + - errcheck + enable: + - govet + - gofmt + - goimports \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e6f7c1..a5b148fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,127 @@ # Changelog +## v0.33.1 + +__Special thanks to Albert Teoh for contributing to this release.__ + +- Adds a new method `MinMaxXYs (XY, XY, bool)` to the `Envelope` type. The + first two return values are the minimum and maximum XY values in the + envelope, and the third return value indicates whether or not the first two + are defined (they are only defined for non-empty envelopes). + +## v0.33.0 + +2021-10-11 + +__Special thanks to Albert Teoh for contributing to this release.__ + +- **Breaking change**: The `Envelope` type can now be an empty envelope. + Previously, it was only able to represent a rectangle with some area, a + horizontal or vertical line, or a single point. Its `AsGeometry` returns + an empty `GeometryCollection` in the case where it's empty. The result of + `AsGeometry` is unchanged for non-empty envelopes. + +- **Breaking change**: The `NewEnvelope` function signature has changed. It now + accepts a slice of `geom.XY` as the sole argument. The behaviour of the + function is the same as before, except that if no XY values are provided then + an empty envelope is returned without error. + +- **Breaking change**: The `Envelope` type's `EnvelopeFromGeoms` method has + been removed. To replicate the behaviour of this method, users can construct + a `GeometryCollection` and call its `Envelope` method. + +- **Breaking change**: The `Envelope` type's `Min`, `Max`, and `Center` methods + now return `Point`s rather than `XY`s. When the envelope is empty, `Min`, + `Max`, and `Center` return empty points. + +- **Breaking change**: The `Envelope` type's `Distance` method now returns + `(float64, bool)` rather than `float64`. The returned boolean is only true if + the distance between the two envelopes is defined (i.e. when they are both + non-empty). + +- **Breaking change**: The `Envelope` method on the `Geometry`, + `GeometryCollection`, `Point`, `LineString`, `Polygon`, `MultiPoint`, + `MultiLineString`, and `MultiPolygon` types now return `Envelope` instead of + `(Envelope, bool)`. The empty vs non-empty status is encoded inside the + envelope instead of via an explicit boolean. + +- The `Envelope` type now has `IsEmpty`, `IsPoint`, `IsLine`, and + `IsRectanagle` methods. These correspond to the 4 possible envelope + categories. + +## v0.32.0 + +2021-09-08 + +__Special thanks to Albert Teoh for contributing to this release.__ + +- **Breaking change**: Consolidates `MultiPoint` constructors and simplifies + `MultiPoint` internal representation. Removes the `BitSet` type, previously + used for `MultiPoint` construction. Removes the `NewMultiPointFromPoints` and + `NewMultiPointWithEmptyMask` functions. Modifies the `NewMultiPoint` function + to accept a slice of `Point`s rather than a `Sequence`. + +- **Breaking change**: Consolidates `Point` construction. Removes the + `NewPointFromXY` function. It is replaced by a new `AsPoint` method on the + `XY` type. + +- Refactors internal test helpers. + +- Adds linting to CI using `golangci-lint`. + +- **Breaking change**: Renames geometry constructors for consistency. + `NewPolygonFromRings` is renamed to `NewPolygon`. + `NewMultiLineStringFromLineStrings` is renamed to `NewMultiLineString`. + `NewMultiPolygonFromPolygons` is renamed to `NewMultiPolygon`. + +- **Breaking change**: Adds checks for anomalous `float64` values (NaN and +/- + infinity) during geometry construction. + + - The `NewPoint` function now returns `(Point, error)` rather than `Point`. + The returned error is non-nil when the inputs contain anomalous values. + + - The `NewLineString` function's signature doesn't change, but now returns + a non-nil error if the input `Sequence` contains anomalous values. + + - The `OmitInvalid` constructor option now has implications when + constructing `Point` and `MultiPoint` types. + + - The `NewEnvelope` function now returns `(Envelope, error)` rather than + `Envelope`. The returned error is non-nil when when the input XYs contain + anomalous values. + + - The `Envelope` type's `ExtendToIncludePoint` method is renamed to + `ExtendToIncludeXY` (better matching its argument type). It now returns + `(Envelope, erorr)` rather than `Envelope`. The returned error is non-nil + if the inputs contain any anomalous values. + + - The `Envelope` type's `ExpandBy` method is removed due to its limited + utility and complex interactions with anomalous values. + +## v0.31.0 + +2021-08-09 + +__Special thanks to Albert Teoh for contributing to this release.__ + +- Fixes some minor linting (and other similar) issues identified by Go Report + Card. + +- Adds a new `DumpCoordinates` method to geometry types. This method returns a + `Sequence` containing all of the control points that define the geometry. + +- Adds a new `Summary` method to all geometry types. This method gives a short + and human readable summary of geometry values. The summary includes the + geometry type, coordinates type, and component cardinalities where + appropriate (e.g. number of rings in a polygon). + +- Adds a new `String` method to all geometry types, implementing the + `fmt.Stringer` interface. The method returns the same string as that returned + by the `Summary` method. + +- Adds a new `NumRings` method to the `Polygon` type. This method gives the + total number of rings that make the polygon. + ## v0.30.0 2021-07-18 diff --git a/README.md b/README.md index cf5b61bf..1d0a391b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/peterstace/simplefeatures)](https://goreportcard.com/report/github.com/peterstace/simplefeatures) [![Coverage -Status](https://coveralls.io/repos/github/peterstace/simplefeatures/badge.svg?branch=add_coveralls)](https://coveralls.io/github/peterstace/simplefeatures) +Status](https://coveralls.io/repos/github/peterstace/simplefeatures/badge.svg?branch=master)](https://coveralls.io/github/peterstace/simplefeatures?branch=master) Simple Features is a 2D geometry library that provides Go types that model geometries, as well as algorithms that operate on them. @@ -177,7 +177,8 @@ Encoding and decoding WKB directly: ```go // Marshal as WKB -pt := geom.NewPointFromXY(geom.XY{1.5, 2.5}) +coords := geom.Coordinates{XY: geom.XY{1.5, 2.5}} +pt := geom.NewPoint(coords) wkb := pt.AsBinary() fmt.Println(wkb) // Prints: [1 1 0 0 0 0 0 0 0 0 0 248 63 0 0 0 0 0 0 4 64] @@ -199,7 +200,8 @@ db.Exec(` ) // Insert our geometry and population data into PostGIS via WKB. -nyc := geom.NewPointFromXY(geom.XY{-74.0, 40.7}) +coords := geom.Coordinates{XY: geom.XY{-74.0, 40.7}} +nyc := geom.NewPoint(coords) db.Exec(` INSERT INTO my_table (my_geom, population) diff --git a/geom/accessor_test.go b/geom/accessor_test.go index 4cd9151e..5777efa0 100644 --- a/geom/accessor_test.go +++ b/geom/accessor_test.go @@ -25,10 +25,14 @@ func TestLineStringAccessor(t *testing.T) { pt56 := xyCoords(5, 6) t.Run("start", func(t *testing.T) { - expectGeomEq(t, ls.StartPoint().AsGeometry(), NewPoint(pt12).AsGeometry()) + want, err := NewPoint(pt12) + expectNoErr(t, err) + expectGeomEq(t, ls.StartPoint().AsGeometry(), want.AsGeometry()) }) t.Run("end", func(t *testing.T) { - expectGeomEq(t, ls.EndPoint().AsGeometry(), NewPoint(pt56).AsGeometry()) + want, err := NewPoint(pt56) + expectNoErr(t, err) + expectGeomEq(t, ls.EndPoint().AsGeometry(), want.AsGeometry()) }) t.Run("num points", func(t *testing.T) { expectIntEq(t, seq.Length(), 3) diff --git a/geom/alg_convex_hull.go b/geom/alg_convex_hull.go index d96ed430..cf538404 100644 --- a/geom/alg_convex_hull.go +++ b/geom/alg_convex_hull.go @@ -17,7 +17,7 @@ func convexHull(g Geometry) Geometry { // Check for point case: if !hasAtLeast2DistinctPointsInXYs(pts) { - return NewPointFromXY(pts[0]).AsGeometry() + return pts[0].asUncheckedPoint().AsGeometry() } hull := monotoneChain(pts) @@ -40,7 +40,7 @@ func convexHull(g Geometry) Geometry { if err != nil { panic(fmt.Errorf("bug in monotoneChain routine - didn't produce a valid ring: %v", err)) } - poly, err := NewPolygonFromRings([]LineString{ring}) + poly, err := NewPolygon([]LineString{ring}) if err != nil { panic(fmt.Errorf("bug in monotoneChain routine - didn't produce a valid polygon: %v", err)) } diff --git a/geom/alg_distance.go b/geom/alg_distance.go index 958e4b97..e014e30b 100644 --- a/geom/alg_distance.go +++ b/geom/alg_distance.go @@ -35,7 +35,6 @@ func Distance(g1, g2 Geometry) (float64, bool) { if len(xys1)+len(lns1) > len(xys2)+len(lns2) { xys1, xys2 = xys2, xys1 lns1, lns2 = lns2, lns1 - g1, g2 = g2, g1 } tr := loadTree(xys2, lns2) @@ -55,11 +54,11 @@ func Distance(g1, g2 Geometry) (float64, bool) { // distance so far. var recordEnv Envelope if recordID > 0 { - recordEnv = NewEnvelope(xys2[xyIdx]) + recordEnv = xys2[xyIdx].uncheckedEnvelope() } else { - recordEnv = lns2[lnIdx].envelope() + recordEnv = lns2[lnIdx].uncheckedEnvelope() } - if recordEnv.Distance(env) > minDist { + if d, ok := recordEnv.Distance(env); ok && d > minDist { return rtree.Stop } @@ -73,8 +72,8 @@ func Distance(g1, g2 Geometry) (float64, bool) { return nil } for _, xy := range xys1 { - xyEnv := NewEnvelope(xy) - tr.PrioritySearch(xyEnv.box(), func(recordID int) error { + xyEnv := xy.uncheckedEnvelope() + tr.PrioritySearch(xy.box(), func(recordID int) error { return searchBody( xyEnv, recordID, @@ -84,8 +83,8 @@ func Distance(g1, g2 Geometry) (float64, bool) { }) } for _, ln := range lns1 { - lnEnv := ln.envelope() - tr.PrioritySearch(lnEnv.box(), func(recordID int) error { + lnEnv := ln.uncheckedEnvelope() + tr.PrioritySearch(ln.box(), func(recordID int) error { return searchBody( lnEnv, recordID, @@ -137,13 +136,13 @@ func loadTree(xys []XY, lns []line) *rtree.RTree { items := make([]rtree.BulkItem, len(xys)+len(lns)) for i, xy := range xys { items[i] = rtree.BulkItem{ - Box: NewEnvelope(xy).box(), + Box: xy.box(), RecordID: i + 1, } } for i, ln := range lns { items[i+len(xys)] = rtree.BulkItem{ - Box: ln.envelope().box(), + Box: ln.box(), RecordID: -(i + 1), } } diff --git a/geom/alg_intersection.go b/geom/alg_intersection.go index 3a5e0e1c..879c4f92 100644 --- a/geom/alg_intersection.go +++ b/geom/alg_intersection.go @@ -7,18 +7,19 @@ func intersectionOfIndexedLines( ) { // TODO: Investigate potential speed up of swapping lines. var lss []LineString - var ptFloats []float64 + var pts []Point seen := make(map[XY]bool) for i := range lines1.lines { - lines2.tree.RangeSearch(lines1.lines[i].envelope().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 } if inter.ptA == inter.ptB { - if pt := inter.ptA; !seen[pt] { - ptFloats = append(ptFloats, pt.X, pt.Y) - seen[pt] = true + if xy := inter.ptA; !seen[xy] { + pt := xy.asUncheckedPoint() + pts = append(pts, pt) + seen[xy] = true } } else { lss = append(lss, line{inter.ptA, inter.ptB}.asLineString()) @@ -26,8 +27,7 @@ func intersectionOfIndexedLines( return nil }) } - return NewMultiPoint(NewSequence(ptFloats, DimXY)), - NewMultiLineStringFromLineStrings(lss) + return NewMultiPoint(pts), NewMultiLineString(lss) } func intersectionOfMultiPointAndMultiPoint(mp1, mp2 MultiPoint) MultiPoint { @@ -38,12 +38,13 @@ func intersectionOfMultiPointAndMultiPoint(mp1, mp2 MultiPoint) MultiPoint { inMP1[xy] = true } } - var floats []float64 + var pts []Point for i := 0; i < mp2.NumPoints(); i++ { - xy, ok := mp2.PointN(i).XY() + pt := mp2.PointN(i) + xy, ok := pt.XY() if ok && inMP1[xy] { - floats = append(floats, xy.X, xy.Y) + pts = append(pts, pt) } } - return NewMultiPoint(NewSequence(floats, DimXY)) + return NewMultiPoint(pts) } diff --git a/geom/alg_intersects.go b/geom/alg_intersects.go index c35a1666..5d89ee11 100644 --- a/geom/alg_intersects.go +++ b/geom/alg_intersects.go @@ -206,7 +206,7 @@ func hasIntersectionBetweenLines( bulk := make([]rtree.BulkItem, len(lines1)) for i, ln := range lines1 { bulk[i] = rtree.BulkItem{ - Box: ln.envelope().box(), + Box: ln.box(), RecordID: i, } } @@ -215,10 +215,9 @@ func hasIntersectionBetweenLines( // Keep track of an envelope of all of the points that are in the // intersection. var env Envelope - var envPopulated bool for _, lnA := range lines2 { - tree.RangeSearch(lnA.envelope().box(), func(i int) error { + tree.RangeSearch(lnA.box(), func(i int) error { lnB := lines1[i] inter := lnA.intersectLine(lnB) if inter.empty { @@ -226,29 +225,21 @@ func hasIntersectionBetweenLines( } if !populateExtension { - envPopulated = true - env = NewEnvelope(inter.ptA) - env = env.ExtendToIncludePoint(inter.ptB) + env = inter.ptA.uncheckedEnvelope() + env = env.uncheckedExtend(inter.ptB) return rtree.Stop } if inter.ptA != inter.ptB { - envPopulated = true - env = NewEnvelope(inter.ptA) - env = env.ExtendToIncludePoint(inter.ptB) + env = inter.ptA.uncheckedEnvelope() + env = env.uncheckedExtend(inter.ptB) return rtree.Stop } // Single point intersection case from here onwards: - if !envPopulated { - envPopulated = true - env = NewEnvelope(inter.ptA) - return nil - } - - env = env.ExtendToIncludePoint(inter.ptA) - if env.Min() != env.Max() { + env = env.uncheckedExtend(inter.ptA) + if !env.IsPoint() { return rtree.Stop } return nil @@ -257,12 +248,16 @@ func hasIntersectionBetweenLines( var ext mlsWithMLSIntersectsExtension if populateExtension { + var single XY + if xy, ok := env.Min().XY(); ok { + single = xy + } ext = mlsWithMLSIntersectsExtension{ - multiplePoints: envPopulated && env.Min() != env.Max(), - singlePoint: env.Min(), + multiplePoints: !env.IsEmpty() && !env.IsPoint(), + singlePoint: single, } } - return envPopulated, ext + return !env.IsEmpty(), ext } func hasIntersectionMultiLineStringWithMultiPolygon(mls MultiLineString, mp MultiPolygon) bool { diff --git a/geom/alg_point_in_ring.go b/geom/alg_point_in_ring.go index 2f0dcb56..e8b68dc4 100644 --- a/geom/alg_point_in_ring.go +++ b/geom/alg_point_in_ring.go @@ -48,7 +48,7 @@ func hasCrossing(pt XY, ln line) (crossing, onLine bool) { o := orientation(lower, upper, pt) crossing = pt.Y >= lower.Y && pt.Y < upper.Y && o == rightTurn - onLine = ln.envelope().Contains(pt) && o == collinear + onLine = ln.uncheckedEnvelope().Contains(pt) && o == collinear return } diff --git a/geom/alg_point_on_surface.go b/geom/alg_point_on_surface.go index 231c916e..9c0f1835 100644 --- a/geom/alg_point_on_surface.go +++ b/geom/alg_point_on_surface.go @@ -56,11 +56,12 @@ func pointOnAreaSurface(poly Polygon) (Point, float64) { // 5. The PointOnSurface is the midpoint of that largest portion. // Find envelope midpoint. - env, ok := poly.Envelope() + env := poly.Envelope() + mid, ok := env.Center().XY() if !ok { return Point{}, 0 } - midY := env.Center().Y + midY := mid.Y // Adjust mid-y value if a control point has the same Y. var midYMatchesNode bool @@ -82,9 +83,14 @@ func pointOnAreaSurface(poly Polygon) (Point, float64) { } // Create bisector. + envMin, envMax, ok := env.MinMaxXYs() + if !ok { + return Point{}, 0 + } + bisector := line{ - XY{env.Min().X - 1, midY}, - XY{env.Max().X + 1, midY}, + XY{envMin.X - 1, midY}, + XY{envMax.X + 1, midY}, } // Find intersection points between the bisector and the polygon. @@ -127,7 +133,7 @@ func pointOnAreaSurface(poly Polygon) (Point, float64) { } midX := (bestA + bestB) / 2 - return NewPointFromXY(XY{midX, midY}), bestB - bestA + return XY{midX, midY}.asUncheckedPoint(), bestB - bestA } func sortAndUniquifyFloats(fs []float64) []float64 { diff --git a/geom/alg_simplify.go b/geom/alg_simplify.go index 46c3d1b8..4f38787c 100644 --- a/geom/alg_simplify.go +++ b/geom/alg_simplify.go @@ -60,7 +60,7 @@ func (s simplifier) simplifyMultiLineString(mls MultiLineString) (MultiLineStrin lss = append(lss, ls) } } - return NewMultiLineStringFromLineStrings(lss, s.opts...), nil + return NewMultiLineString(lss, s.opts...), nil } func (s simplifier) simplifyPolygon(poly Polygon) (Polygon, error) { @@ -88,7 +88,7 @@ func (s simplifier) simplifyPolygon(poly Polygon) (Polygon, error) { rings = append(rings, interior) } } - return NewPolygonFromRings(rings, s.opts...) + return NewPolygon(rings, s.opts...) } func (s simplifier) simplifyMultiPolygon(mp MultiPolygon) (MultiPolygon, error) { @@ -103,7 +103,7 @@ func (s simplifier) simplifyMultiPolygon(mp MultiPolygon) (MultiPolygon, error) polys = append(polys, poly) } } - return NewMultiPolygonFromPolygons(polys, s.opts...) + return NewMultiPolygon(polys, s.opts...) } func (s simplifier) simplifyGeometryCollection(gc GeometryCollection) (GeometryCollection, error) { diff --git a/geom/attr_test.go b/geom/attr_test.go index 5aed4094..c9589995 100644 --- a/geom/attr_test.go +++ b/geom/attr_test.go @@ -94,16 +94,15 @@ func TestEnvelope(t *testing.T) { t.Run(strconv.Itoa(i), func(t *testing.T) { t.Log("wkt:", tt.wkt) g := geomFromWKT(t, tt.wkt) - env, have := g.Envelope() - if !have { - t.Fatalf("expected to have envelope but didn't") - } - if env.Min() != tt.min { - t.Errorf("min: got=%v want=%v", env.Min(), tt.min) - } - if env.Max() != tt.max { - t.Errorf("max: got=%v want=%v", env.Max(), tt.max) - } + env := g.Envelope() + + gotMin, ok := env.Min().XY() + expectTrue(t, ok) + expectXYEq(t, gotMin, tt.min) + + gotMax, ok := env.Max().XY() + expectTrue(t, ok) + expectXYEq(t, gotMax, tt.max) }) } } @@ -127,9 +126,8 @@ func TestNoEnvelope(t *testing.T) { } { t.Run(wkt, func(t *testing.T) { g := geomFromWKT(t, wkt) - if _, have := g.Envelope(); have { - t.Errorf("have envelope but expected not to") - } + got := g.Envelope() + expectTrue(t, got.IsEmpty()) }) } } @@ -358,16 +356,11 @@ func TestCoordinatesSequence(t *testing.T) { }) t.Run("multipoint", func(t *testing.T) { - seq, empty := geomFromWKT(t, "MULTIPOINT(0 1,2 3,EMPTY,4 5)").AsMultiPoint().Coordinates() - expectIntEq(t, seq.Length(), 4) + seq := geomFromWKT(t, "MULTIPOINT(0 1,2 3,EMPTY,4 5)").AsMultiPoint().Coordinates() + expectIntEq(t, seq.Length(), 3) expectXYEq(t, seq.GetXY(0), XY{0, 1}) expectXYEq(t, seq.GetXY(1), XY{2, 3}) - expectXYEq(t, seq.GetXY(2), XY{0, 0}) - expectXYEq(t, seq.GetXY(3), XY{4, 5}) - expectBoolEq(t, empty.Get(0), false) - expectBoolEq(t, empty.Get(1), false) - expectBoolEq(t, empty.Get(2), true) - expectBoolEq(t, empty.Get(3), false) + expectXYEq(t, seq.GetXY(2), XY{4, 5}) }) t.Run("multilinestring", func(t *testing.T) { seq := geomFromWKT(t, "MULTILINESTRING((0 0,0 10,10 0,0 0),(2 2,2 8,8 2,2 2))").AsMultiLineString().Coordinates() @@ -1170,3 +1163,414 @@ func TestForceWindingDirection(t *testing.T) { }) } } + +func TestSummary(t *testing.T) { + for _, tc := range []struct { + name string + wkt string + wantSummary string + }{ + // POINT + {wkt: "POINT EMPTY", wantSummary: "Point[XY] with 0 points"}, + {wkt: "POINT Z EMPTY", wantSummary: "Point[XYZ] with 0 points"}, + {wkt: "POINT M EMPTY", wantSummary: "Point[XYM] with 0 points"}, + {wkt: "POINT ZM EMPTY", wantSummary: "Point[XYZM] with 0 points"}, + + {wkt: "POINT(0 0)", wantSummary: "Point[XY] with 1 point"}, + {wkt: "POINT Z(0 0 0.5)", wantSummary: "Point[XYZ] with 1 point"}, + {wkt: "POINT M(0 0 0.8)", wantSummary: "Point[XYM] with 1 point"}, + {wkt: "POINT ZM(0 0 0.5 0.8)", wantSummary: "Point[XYZM] with 1 point"}, + + // LINESTRING + {name: "XY 0-point line", wkt: "LINESTRING EMPTY", wantSummary: "LineString[XY] with 0 points"}, + {name: "XYZ 0-point line", wkt: "LINESTRING Z EMPTY", wantSummary: "LineString[XYZ] with 0 points"}, + {name: "XYM 0-point line", wkt: "LINESTRING M EMPTY", wantSummary: "LineString[XYM] with 0 points"}, + {name: "XYZM 0-point line", wkt: "LINESTRING ZM EMPTY", wantSummary: "LineString[XYZM] with 0 points"}, + + {name: "XY 2-point line", wkt: "LINESTRING(0 0, 1 1)", wantSummary: "LineString[XY] with 2 points"}, + {name: "XYZ 2-point line", wkt: "LINESTRING Z(0 0 0.5, 1 1 0.5)", wantSummary: "LineString[XYZ] with 2 points"}, + {name: "XYM 2-point line", wkt: "LINESTRING M(0 0 0.8, 1 1 0.8)", wantSummary: "LineString[XYM] with 2 points"}, + {name: "XYZM 2-point line", wkt: "LINESTRING ZM(0 0 0.5 0.8, 1 1 0.5 0.8)", wantSummary: "LineString[XYZM] with 2 points"}, + + // POLYGON + { + name: "Empty", + wkt: `POLYGON EMPTY`, + wantSummary: "Polygon[XY] with 0 rings consisting of 0 total points", + }, + + // Basic single polygon without inner rings. + { + name: "XY square polygon", + wkt: `POLYGON((-1 1, 1 1, 1 -1, -1 -1, -1 1))`, + wantSummary: "Polygon[XY] with 1 ring consisting of 5 total points", + }, + { + name: "XYZ square polygon", + wkt: `POLYGON Z((-1 1 0.5, 1 1 0.5, 1 -1 0.5, -1 -1 0.5, -1 1 0.5))`, + wantSummary: "Polygon[XYZ] with 1 ring consisting of 5 total points", + }, + { + name: "XYM square polygon", + wkt: `POLYGON M((-1 1 0.8, 1 1 0.8, 1 -1 0.8, -1 -1 0.8, -1 1 0.8))`, + wantSummary: "Polygon[XYM] with 1 ring consisting of 5 total points", + }, + { + name: "XYMZ square polygon", + wkt: `POLYGON ZM((-1 1 0.5 0.8, 1 1 0.5 0.8, 1 -1 0.5 0.8, -1 -1 0.5 0.8, -1 1 0.5 0.8))`, + wantSummary: "Polygon[XYZM] with 1 ring consisting of 5 total points", + }, + + // Polygon with single inner ring. + { + name: "XY 1 square within a square polygon", + wkt: `POLYGON( + (-100 100, 100 100, 100 -100, -100 -100, -100 100), + (-1 1, 1 1, 1 -1, -1 -1, -1 1) + )`, + wantSummary: "Polygon[XY] with 2 rings consisting of 10 total points", + }, + { + name: "XYZ 1 square within a square polygon", + wkt: `POLYGON Z( + (-100 100 0.5, 100 100 0.5, 100 -100 0.5, -100 -100 0.5, -100 100 0.5), + (-1 1 0.5, 1 1 0.5, 1 -1 0.5, -1 -1 0.5, -1 1 0.5) + )`, + wantSummary: "Polygon[XYZ] with 2 rings consisting of 10 total points", + }, + { + name: "XYM 1 square within a square polygon", + wkt: `POLYGON M( + (-100 100 0.8, 100 100 0.8, 100 -100 0.8, -100 -100 0.8, -100 100 0.8), + (-1 1 0.8, 1 1 0.8, 1 -1 0.8, -1 -1 0.8, -1 1 0.8) + )`, + wantSummary: "Polygon[XYM] with 2 rings consisting of 10 total points", + }, + { + name: "XYMZ 1 square within a square polygon", + wkt: `POLYGON ZM( + (-100 100 0.5 0.8, 100 100 0.5 0.8, 100 -100 0.5 0.8, -100 -100 0.5 0.8, -100 100 0.5 0.8), + (-1 1 0.5 0.8, 1 1 0.5 0.8, 1 -1 0.5 0.8, -1 -1 0.5 0.8, -1 1 0.5 0.8) + )`, + wantSummary: "Polygon[XYZM] with 2 rings consisting of 10 total points", + }, + + // Polygon with multiple inner rings. + { + name: "XY 2 squares within a square polygon", + wkt: `POLYGON( + (-100 100, 100 100, 100 -100, -100 -100, -100 100), + (-1 1, 1 1, 1 -1, -1 -1, -1 1), + (10 10, 11 10, 11 9, 10 9, 10 10) + )`, + wantSummary: "Polygon[XY] with 3 rings consisting of 15 total points", + }, + { + name: "XYZ 2 squares within a square polygon", + wkt: `POLYGON Z( + (-100 100 0.5, 100 100 0.5, 100 -100 0.5, -100 -100 0.5, -100 100 0.5), + (-1 1 0.5, 1 1 0.5, 1 -1 0.5, -1 -1 0.5, -1 1 0.5), + (10 10 0.5, 11 10 0.5, 11 9 0.5, 10 9 0.5, 10 10 0.5) + )`, + wantSummary: "Polygon[XYZ] with 3 rings consisting of 15 total points", + }, + { + name: "XYM 2 squares within a square polygon", + wkt: `POLYGON M( + (-100 100 0.8, 100 100 0.8, 100 -100 0.8, -100 -100 0.8, -100 100 0.8), + (-1 1 0.8, 1 1 0.8, 1 -1 0.8, -1 -1 0.8, -1 1 0.8), + (10 10 0.8, 11 10 0.8, 11 9 0.8, 10 9 0.8, 10 10 0.8) + )`, + wantSummary: "Polygon[XYM] with 3 rings consisting of 15 total points", + }, + { + name: "XYMZ 2 squares within a square polygon", + wkt: `POLYGON ZM( + (-100 100 0.5 0.8, 100 100 0.5 0.8, 100 -100 0.5 0.8, -100 -100 0.5 0.8, -100 100 0.5 0.8), + (-1 1 0.5 0.8, 1 1 0.5 0.8, 1 -1 0.5 0.8, -1 -1 0.5 0.8, -1 1 0.5 0.8), + (10 10 0.5 0.8, 11 10 0.5 0.8, 11 9 0.5 0.8, 10 9 0.5 0.8, 10 10 0.5 0.8) + )`, + wantSummary: "Polygon[XYZM] with 3 rings consisting of 15 total points", + }, + + // MULTIPOINT + { + name: "Empty", + wkt: "MULTIPOINT EMPTY", + wantSummary: "MultiPoint[XY] with 0 points", + }, + + // Single point. + { + name: "XY single point", + wkt: "MULTIPOINT ((0 0))", + wantSummary: "MultiPoint[XY] with 1 point", + }, + { + name: "XYZ single point", + wkt: "MULTIPOINT Z((0 0 0.5))", + wantSummary: "MultiPoint[XYZ] with 1 point", + }, + { + name: "XYM single point", + wkt: "MULTIPOINT M((0 0 0.8))", + wantSummary: "MultiPoint[XYM] with 1 point", + }, + + // Multiple points. + { + name: "XY 2 points", + wkt: "MULTIPOINT ((0 0), (1 1))", + wantSummary: "MultiPoint[XY] with 2 points", + }, + { + name: "XYZ 2 points", + wkt: "MULTIPOINT Z((0 0 0.5), (1 1 0.5))", + wantSummary: "MultiPoint[XYZ] with 2 points", + }, + { + name: "XYM 2 points", + wkt: "MULTIPOINT M((0 0 0.8), (1 1 0.8))", + wantSummary: "MultiPoint[XYM] with 2 points", + }, + { + name: "XYZM 2 points", + wkt: "MULTIPOINT ZM((0 0 0.5 0.8), (1 1 0.5 0.8))", + wantSummary: "MultiPoint[XYZM] with 2 points", + }, + { + name: "XY 2 points same coordinates", + wkt: "MULTIPOINT ((0 0), (0 0))", + wantSummary: "MultiPoint[XY] with 2 points", + }, + + // MULTILINESTRING + { + name: "Empty", + wkt: "MULTILINESTRING EMPTY", + wantSummary: "MultiLineString[XY] with 0 linestrings consisting of 0 total points", + }, + + // Single line string. + { + name: "XY single 2-point lines", + wkt: "MULTILINESTRING((0 0, 1 1))", + wantSummary: "MultiLineString[XY] with 1 linestring consisting of 2 total points", + }, + { + name: "XYZ single 2-point lines", + wkt: "MULTILINESTRING Z((0 0 0.5, 1 1 0.5))", + wantSummary: "MultiLineString[XYZ] with 1 linestring consisting of 2 total points", + }, + { + name: "XYM single 2-point lines", + wkt: "MULTILINESTRING M((0 0 0.8, 1 1 0.8))", + wantSummary: "MultiLineString[XYM] with 1 linestring consisting of 2 total points", + }, + { + name: "XYZM single 2-point lines", + wkt: "MULTILINESTRING ZM((0 0 0.5 0.8, 1 1 0.5 0.8))", + wantSummary: "MultiLineString[XYZM] with 1 linestring consisting of 2 total points", + }, + + // Multiple line strings. + { + name: "XY multiple 2-point lines", + wkt: "MULTILINESTRING( (0 0, 1 1), (0 0, -1 -1) )", + wantSummary: "MultiLineString[XY] with 2 linestrings consisting of 4 total points", + }, + { + name: "XYZ single 2-point lines", + wkt: "MULTILINESTRING Z( (0 0 0.5, 1 1 0.5), (0 0 0.5, -1 -1 0.5) )", + wantSummary: "MultiLineString[XYZ] with 2 linestrings consisting of 4 total points", + }, + { + name: "XYM single 2-point lines", + wkt: "MULTILINESTRING M( (0 0 0.8, 1 1 0.8), (0 0 0.8, -1 -1 0.8) )", + wantSummary: "MultiLineString[XYM] with 2 linestrings consisting of 4 total points", + }, + { + name: "XYZM single 2-point lines", + wkt: "MULTILINESTRING ZM( (0 0 0.5 0.8, 1 1 0.5 0.8), (0 0 0.5 0.8, -1 -1 0.5 0.8) )", + wantSummary: "MultiLineString[XYZM] with 2 linestrings consisting of 4 total points", + }, + + // MULTIPOLYGON + { + name: "Empty", + wkt: `MULTIPOLYGON EMPTY`, + wantSummary: "MultiPolygon[XY] with 0 polygons consisting of 0 total rings and 0 total points", + }, + + // Basic single polygon without inner rings. + { + name: "XY 1 square polygon", + wkt: `MULTIPOLYGON(((-1 1, 1 1, 1 -1, -1 -1, -1 1)))`, + wantSummary: "MultiPolygon[XY] with 1 polygon consisting of 1 total ring and 5 total points", + }, + { + name: "XYZ 1 square polygon", + wkt: `MULTIPOLYGON Z(((-1 1 0.5, 1 1 0.5, 1 -1 0.5, -1 -1 0.5, -1 1 0.5)))`, + wantSummary: "MultiPolygon[XYZ] with 1 polygon consisting of 1 total ring and 5 total points", + }, + { + name: "XYM 1 square polygon", + wkt: `MULTIPOLYGON M(((-1 1 0.8, 1 1 0.8, 1 -1 0.8, -1 -1 0.8, -1 1 0.8)))`, + wantSummary: "MultiPolygon[XYM] with 1 polygon consisting of 1 total ring and 5 total points", + }, + { + name: "XYMZ 1 square polygon", + wkt: `MULTIPOLYGON ZM(((-1 1 0.5 0.8, 1 1 0.5 0.8, 1 -1 0.5 0.8, -1 -1 0.5 0.8, -1 1 0.5 0.8)))`, + wantSummary: "MultiPolygon[XYZM] with 1 polygon consisting of 1 total ring and 5 total points", + }, + + // Multiple basic polygon without inner rings. + { + name: "XY 2 square polygons", + wkt: `MULTIPOLYGON( + ((-1 1, 1 1, 1 -1, -1 -1, -1 1)), + ((9 11, 11 11, 11 9, 9 9, 9 11)) + )`, + wantSummary: "MultiPolygon[XY] with 2 polygons consisting of 2 total rings and 10 total points", + }, + { + name: "XYZ 2 square polygons", + wkt: `MULTIPOLYGON Z( + ((-1 1 0.5, 1 1 0.5, 1 -1 0.5, -1 -1 0.5, -1 1 0.5)), + ((9 11 0.5, 11 11 0.5, 11 9 0.5, 9 9 0.5, 9 11 0.5)) + )`, + wantSummary: "MultiPolygon[XYZ] with 2 polygons consisting of 2 total rings and 10 total points", + }, + { + name: "XYM 2 square polygons", + wkt: `MULTIPOLYGON M( + ((-1 1 0.8, 1 1 0.8, 1 -1 0.8, -1 -1 0.8, -1 1 0.8)), + ((9 11 0.8, 11 11 0.8, 11 9 0.8, 9 9 0.8, 9 11 0.8)) + )`, + wantSummary: "MultiPolygon[XYM] with 2 polygons consisting of 2 total rings and 10 total points", + }, + { + name: "XYMZ 2 square polygons", + wkt: `MULTIPOLYGON ZM( + ((-1 1 0.5 0.8, 1 1 0.5 0.8, 1 -1 0.5 0.8, -1 -1 0.5 0.8, -1 1 0.5 0.8)), + ((9 11 0.5 0.8, 11 11 0.5 0.8, 11 9 0.5 0.8, 9 9 0.5 0.8, 9 11 0.5 0.8)) + )`, + wantSummary: "MultiPolygon[XYZM] with 2 polygons consisting of 2 total rings and 10 total points", + }, + + // Single polygons with multiple inner rings. + { + name: "XY 2 squares within a square polygon", + wkt: `MULTIPOLYGON( + ( + (-100 100, 100 100, 100 -100, -100 -100, -100 100), + (-1 1, 1 1, 1 -1, -1 -1, -1 1), + (10 10, 11 10, 11 9, 10 9, 10 10) + ) + )`, + wantSummary: "MultiPolygon[XY] with 1 polygon consisting of 3 total rings and 15 total points", + }, + { + name: "XYZ 2 squares within a square polygon", + wkt: `MULTIPOLYGON Z( + ( + (-100 100 0.5, 100 100 0.5, 100 -100 0.5, -100 -100 0.5, -100 100 0.5), + (-1 1 0.5, 1 1 0.5, 1 -1 0.5, -1 -1 0.5, -1 1 0.5), + (10 10 0.5, 11 10 0.5, 11 9 0.5, 10 9 0.5, 10 10 0.5) + ) + )`, + wantSummary: "MultiPolygon[XYZ] with 1 polygon consisting of 3 total rings and 15 total points", + }, + { + name: "XYM 2 squares within a square polygon", + wkt: `MULTIPOLYGON M( + ( + (-100 100 0.8, 100 100 0.8, 100 -100 0.8, -100 -100 0.8, -100 100 0.8), + (-1 1 0.8, 1 1 0.8, 1 -1 0.8, -1 -1 0.8, -1 1 0.8), + (10 10 0.8, 11 10 0.8, 11 9 0.8, 10 9 0.8, 10 10 0.8) + ) + )`, + wantSummary: "MultiPolygon[XYM] with 1 polygon consisting of 3 total rings and 15 total points", + }, + { + name: "XYMZ 2 squares within a square polygon", + wkt: `MULTIPOLYGON ZM( + ( + (-100 100 0.5 0.8, 100 100 0.5 0.8, 100 -100 0.5 0.8, -100 -100 0.5 0.8, -100 100 0.5 0.8), + (-1 1 0.5 0.8, 1 1 0.5 0.8, 1 -1 0.5 0.8, -1 -1 0.5 0.8, -1 1 0.5 0.8), + (10 10 0.5 0.8, 11 10 0.5 0.8, 11 9 0.5 0.8, 10 9 0.5 0.8, 10 10 0.5 0.8) + ) + )`, + wantSummary: "MultiPolygon[XYZM] with 1 polygon consisting of 3 total rings and 15 total points", + }, + + // Multiple polygons with multiple inner rings. + { + name: "XY 2 squares within each of 2 square polygons", + wkt: `MULTIPOLYGON( + ( + (-100 100, 100 100, 100 -100, -100 -100, -100 100), + (-1 1, 1 1, 1 -1, -1 -1, -1 1), + (10 10, 11 10, 11 9, 10 9, 10 10) + ), + ( + (100 -100, 200 -100, 200 -200, 100 -200, 100 -100), + (101 -101, 102 -101, 102 -102, 101 -102, 101 -101), + (110 -110, 111 -110, 111 -111, 110 -111, 110 -110) + ) + )`, + wantSummary: "MultiPolygon[XY] with 2 polygons consisting of 6 total rings and 30 total points", + }, + + // GEOMETRYCOLLECTION + { + name: "Empty", + wkt: "GEOMETRYCOLLECTION EMPTY", + wantSummary: "GeometryCollection[XY] with 0 child geometries consisting of 0 total points", + }, + { + name: "XY single point", + wkt: "GEOMETRYCOLLECTION(POINT(0 0))", + wantSummary: "GeometryCollection[XY] with 1 child geometry consisting of 1 total point", + }, + { + name: "XYZ single point", + wkt: "GEOMETRYCOLLECTION (POINT Z(0 0 0.5))", + wantSummary: "GeometryCollection[XYZ] with 1 child geometry consisting of 1 total point", + }, + { + name: "XYM single point", + wkt: "GEOMETRYCOLLECTION (POINT M(0 0 0.8))", + wantSummary: "GeometryCollection[XYM] with 1 child geometry consisting of 1 total point", + }, + { + name: "XYZM single point", + wkt: "GEOMETRYCOLLECTION (POINT ZM(0 0 0.5 0.8))", + wantSummary: "GeometryCollection[XYZM] with 1 child geometry consisting of 1 total point", + }, + { + name: "XY single line string", + wkt: "GEOMETRYCOLLECTION(LINESTRING(1 2, 3 4))", + wantSummary: "GeometryCollection[XY] with 1 child geometry consisting of 2 total points", + }, + { + name: "XY 2 line strings", + wkt: "GEOMETRYCOLLECTION(LINESTRING(1 2, 3 4), LINESTRING(1 2, 3 4))", + wantSummary: "GeometryCollection[XY] with 2 child geometries consisting of 4 total points", + }, + { + name: "XY 2 geometry collections containing 2 points each", + wkt: `GEOMETRYCOLLECTION( + GEOMETRYCOLLECTION(POINT(1 2), POINT(3 4)), + GEOMETRYCOLLECTION(POINT(5 6), POINT(7 8)) + )`, + wantSummary: "GeometryCollection[XY] with 6 child geometries consisting of 4 total points", + }, + } { + t.Run(tc.name, func(t *testing.T) { + g := geomFromWKT(t, tc.wkt) + expectStringEq(t, g.Summary(), tc.wantSummary) + expectStringEq(t, g.String(), tc.wantSummary) + }) + } +} diff --git a/geom/bitset.go b/geom/bitset.go deleted file mode 100644 index 26c96e1e..00000000 --- a/geom/bitset.go +++ /dev/null @@ -1,42 +0,0 @@ -package geom - -// BitSet is a set data structure that holds a mapping from non-negative -// integers to boolean values (bits). The zero value is the BitSet with all -// bits set to false. -type BitSet struct { - masks []uint64 -} - -// Get gets the bit as position i. It panics if i is negative. Get returns -// false for bits that haven't been explicitly set. -func (b *BitSet) Get(i int) bool { - idx := i / 64 - if idx >= len(b.masks) { - return false - } - return (b.masks[idx] & (1 << (i % 64))) != 0 -} - -// Set sets the bit in position i to a new value. -func (b *BitSet) Set(i int, newVal bool) { - if newVal { - idx := i / 64 - if idx >= len(b.masks) { - b.masks = append( - b.masks, - make([]uint64, idx-len(b.masks)+1)..., - ) - } - b.masks[idx] |= (1 << (i % 64)) - } else { - idx := i / 64 - if idx < len(b.masks) { - b.masks[idx] &= ^(1 << (i % 64)) - } - } -} - -// Clone makes a deep copy of the BitSet. -func (b *BitSet) Clone() BitSet { - return BitSet{append([]uint64(nil), b.masks...)} -} diff --git a/geom/bitset_test.go b/geom/bitset_test.go deleted file mode 100644 index 042d709a..00000000 --- a/geom/bitset_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package geom_test - -import ( - "fmt" - "math/rand" - "testing" - - "github.com/peterstace/simplefeatures/geom" -) - -func TestBitSet(t *testing.T) { - t.Run("one bit at a time", func(t *testing.T) { - for i := 0; i < 256; i++ { - t.Run(fmt.Sprintf("bit %d", i), func(t *testing.T) { - var s geom.BitSet - expectFalse(t, s.Get(i)) - s.Set(i, true) - expectTrue(t, s.Get(i)) - s.Set(i, false) - expectFalse(t, s.Get(i)) - }) - } - }) - t.Run("many bits at a time", func(t *testing.T) { - const n = 512 - var want [n]bool - rnd := rand.New(rand.NewSource(0)) - var s geom.BitSet - for i := 0; i < n; i++ { - choice := rnd.Intn(n) - want[choice] = !want[choice] - s.Set(choice, want[choice]) - for j := 0; j < n; j++ { - expectBoolEq(t, s.Get(j), want[j]) - } - } - }) -} diff --git a/geom/coordinate_type.go b/geom/coordinate_type.go index 8e857a27..57b5252f 100644 --- a/geom/coordinate_type.go +++ b/geom/coordinate_type.go @@ -1,5 +1,7 @@ package geom +import "fmt" + // CoordinatesType controls the dimensionality and type of data used to encode // a point location. At minimum, a point location is defined by X and Y // coordinates. It may optionally include a Z value, representing height. It @@ -11,19 +13,22 @@ const ( // DimXY coordinates only contain X and Y values. DimXY CoordinatesType = 0b00 - // DimXYZ coordiantes contain X, Y, and Z (height) values. + // DimXYZ coordinates contain X, Y, and Z (height) values. DimXYZ CoordinatesType = 0b01 - // DimXYM coordiantes contain X, Y, and M (measure) values. + // DimXYM coordinates contain X, Y, and M (measure) values. DimXYM CoordinatesType = 0b10 - // DimXYZM coordiantes contain X, Y, Z (height), and M (measure) values. + // DimXYZM coordinates contain X, Y, Z (height), and M (measure) values. DimXYZM CoordinatesType = 0b11 ) // String gives a string representation of a CoordinatesType. func (t CoordinatesType) String() string { - return [4]string{"XY", "XYZ", "XYM", "XYZM"}[t] + if t < 4 { + return [4]string{"XY", "XYZ", "XYM", "XYZM"}[t] + } + return fmt.Sprintf("unknown coordinate type (%d)", t) } // Dimension returns the number of float64 coordinates required to encode a diff --git a/geom/ctor_options.go b/geom/ctor_options.go index 33eeaa86..53d35a31 100644 --- a/geom/ctor_options.go +++ b/geom/ctor_options.go @@ -25,8 +25,10 @@ func DisableAllValidations(o *ctorOptionSet) { // // The behaviour for each geometry type is: // -// * Point and MultiPoint: no effect (because Point and MultiPoint don't have -// geometry constraints). +// * Point: if the Point is invalid, then it is replaced with an empty Point. +// +// * MultiPoint: if a child Point is invalid, then it is replace with an empty +// Point within the MultiPoint. // // * LineString: if the LineString is invalid (e.g. doesn't contain at least 2 // distinct points), then it is replaced with an empty LineString. diff --git a/geom/ctor_options_test.go b/geom/ctor_options_test.go index a90c6222..d37e2fda 100644 --- a/geom/ctor_options_test.go +++ b/geom/ctor_options_test.go @@ -9,7 +9,7 @@ import ( func TestDisableValidation(t *testing.T) { for i, wkt := range []string{ - // Point -- has no validations + // Point -- has no geometric validations "LINESTRING(1 2,1 2)", // same point "LINESTRING(1 2,1 2,1 2)", // same point "POLYGON((1 2,1 2,1 2))", // same point diff --git a/geom/dcel.go b/geom/dcel.go index 17fcc1dd..26c6b980 100644 --- a/geom/dcel.go +++ b/geom/dcel.go @@ -88,7 +88,7 @@ func newDCELFromGeometry(g Geometry, ghosts MultiLineString, operand operand, in mls := g.AsMultiLineString() dcel = newDCELFromMultiLineString(mls, operand, interactions) case TypePoint: - mp := NewMultiPointFromPoints([]Point{g.AsPoint()}) + mp := g.AsPoint().AsMultiPoint() dcel = newDCELFromMultiPoint(mp, operand) case TypeMultiPoint: mp := g.AsMultiPoint() diff --git a/geom/dcel_extract.go b/geom/dcel_extract.go index c25887d6..879b59be 100644 --- a/geom/dcel_extract.go +++ b/geom/dcel_extract.go @@ -12,33 +12,31 @@ func (d *doublyConnectedEdgeList) extractGeometry(include func([2]label) bool) ( if err != nil { return Geometry{}, err } - points := d.extractPoints(include) + points, err := d.extractPoints(include) + if err != nil { + return Geometry{}, err + } switch { case len(areals) > 0 && len(linears) == 0 && len(points) == 0: if len(areals) == 1 { return areals[0].AsGeometry(), nil } - mp, err := NewMultiPolygonFromPolygons(areals) + mp, err := NewMultiPolygon(areals) if err != nil { - return Geometry{}, fmt.Errorf("could not extract areal geometry from DCEL: %v", err) + return Geometry{}, wrap(err, "could not extract areal geometry from DCEL") } return mp.AsGeometry(), nil case len(areals) == 0 && len(linears) > 0 && len(points) == 0: if len(linears) == 1 { return linears[0].AsGeometry(), nil } - return NewMultiLineStringFromLineStrings(linears).AsGeometry(), nil + return NewMultiLineString(linears).AsGeometry(), nil case len(areals) == 0 && len(linears) == 0 && len(points) > 0: if len(points) == 1 { - return NewPointFromXY(points[0]).AsGeometry(), nil - } - coords := make([]float64, 2*len(points)) - for i, xy := range points { - coords[i*2+0] = xy.X - coords[i*2+1] = xy.Y + return points[0].AsGeometry(), nil } - return NewMultiPoint(NewSequence(coords, DimXY)).AsGeometry(), nil + return NewMultiPoint(points).AsGeometry(), nil default: geoms := make([]Geometry, 0, len(areals)+len(linears)+len(points)) for _, poly := range areals { @@ -47,8 +45,8 @@ func (d *doublyConnectedEdgeList) extractGeometry(include func([2]label) bool) ( for _, ls := range linears { geoms = append(geoms, ls.AsGeometry()) } - for _, xy := range points { - geoms = append(geoms, NewPointFromXY(xy).AsGeometry()) + for _, pt := range points { + geoms = append(geoms, pt.AsGeometry()) } return NewGeometryCollection(geoms).AsGeometry(), nil } @@ -100,7 +98,7 @@ func (d *doublyConnectedEdgeList) extractPolygons(include func([2]label) bool) ( // Construct the polygon. orderCCWRingFirst(rings) - poly, err := NewPolygonFromRings(rings) + poly, err := NewPolygon(rings) if err != nil { return nil, err } @@ -220,13 +218,17 @@ func shouldExtractLine(e *halfEdgeRecord, include func([2]label) bool) bool { // extractPoints extracts any vertices in the DCEL that should be part of the // output geometry, but aren't yet represented as part of any previously // extracted geometries. -func (d *doublyConnectedEdgeList) extractPoints(include func([2]label) bool) []XY { - var xys []XY +func (d *doublyConnectedEdgeList) extractPoints(include func([2]label) bool) ([]Point, error) { + var pts []Point for _, vert := range d.vertices { if include(vert.labels) && !vert.extracted { vert.extracted = true - xys = append(xys, vert.coords) + pt, err := vert.coords.AsPoint() + if err != nil { + return nil, err + } + pts = append(pts, pt) } } - return xys + return pts, nil } diff --git a/geom/dcel_ghosts.go b/geom/dcel_ghosts.go index 0b21da86..430ca2ad 100644 --- a/geom/dcel_ghosts.go +++ b/geom/dcel_ghosts.go @@ -53,7 +53,7 @@ func spanningTree(xys []XY) MultiLineString { }) } - return NewMultiLineStringFromLineStrings(lss) + return NewMultiLineString(lss) } func appendXYForPoint(xys []XY, pt Point) []XY { diff --git a/geom/dcel_interaction_points.go b/geom/dcel_interaction_points.go index a0c04b14..db237f4f 100644 --- a/geom/dcel_interaction_points.go +++ b/geom/dcel_interaction_points.go @@ -109,11 +109,10 @@ func addPointInteractions(pt Point, interactions map[XY]struct{}) { } func addMultiPointInteractions(mp MultiPoint, interactions map[XY]struct{}) { - seq, empty := mp.Coordinates() - n := seq.Length() + n := mp.NumPoints() for i := 0; i < n; i++ { - if !empty.Get(i) { - xy := seq.GetXY(i) + xy, ok := mp.PointN(i).XY() + if ok { interactions[xy] = struct{}{} } } diff --git a/geom/dcel_interaction_points_test.go b/geom/dcel_interaction_points_test.go index 749b936e..ed7baec4 100644 --- a/geom/dcel_interaction_points_test.go +++ b/geom/dcel_interaction_points_test.go @@ -151,11 +151,11 @@ func TestFindInteractionPoints(t *testing.T) { } gotXYs := findInteractionPoints(inputs) - gotCoords := make([]float64, 0, 2*len(gotXYs)) + var gotPoints []Point for xy := range gotXYs { - gotCoords = append(gotCoords, xy.X, xy.Y) + gotPoints = append(gotPoints, xy.asUncheckedPoint()) } - got := NewMultiPoint(NewSequence(gotCoords, DimXY)).AsGeometry() + got := NewMultiPoint(gotPoints).AsGeometry() if !ExactEquals(want, got, IgnoreOrder) { for _, input := range tt.inputWKTs { diff --git a/geom/dcel_label.go b/geom/dcel_label.go index 8cb816b2..d70e3a62 100644 --- a/geom/dcel_label.go +++ b/geom/dcel_label.go @@ -61,27 +61,6 @@ func newLocationsOnBoundary(operand operand) [2]location { return locs } -const ( - inputAInSet uint8 = 0b0001 - inputAPopulated uint8 = 0b0010 - inputBInSet uint8 = 0b0100 - inputBPopulated uint8 = 0b1000 - - populatedMask uint8 = 0b1010 - inSetMask uint8 = 0b0101 - - inputAMask uint8 = 0b0011 - inputBMask uint8 = 0b1100 - - extracted uint8 = 0b010000 -) - -const ( - locInterior uint8 = 0b0101 - locBoundary uint8 = 0b1010 - // NOTE: We don't explicitly track exterior locations (they have to be inferred). -) - func assertPresence(labels [2]label) { if !labels[0].populated || !labels[1].populated { panic(fmt.Sprintf("all presence flags in labels not set: %v", labels)) diff --git a/geom/dcel_re_noding.go b/geom/dcel_re_noding.go index 34586d67..004ed5d3 100644 --- a/geom/dcel_re_noding.go +++ b/geom/dcel_re_noding.go @@ -223,12 +223,12 @@ func reNodeLineString(ls LineString, cut cutSet, nodes nodeSet) (LineString, err // Collect cut locations that are *interior* to ln. eps := 0xFF * ulpSizeForLine(ln) var xys []XY - cut.lnIndex.tree.RangeSearch(ln.envelope().box(), func(i int) error { + cut.lnIndex.tree.RangeSearch(ln.box(), func(i int) error { other := cut.lnIndex.lines[i] xys = appendNewNodesFromLineLineIntersection(xys, ln, other, eps, nodes) return nil }) - cut.ptIndex.tree.RangeSearch(ln.envelope().box(), func(i int) error { + cut.ptIndex.tree.RangeSearch(ln.box(), func(i int) error { other := cut.ptIndex.points[i] xys = appendNewNodesFromLinePointIntersection(xys, ln, other, eps, nodes) return nil @@ -271,7 +271,7 @@ func reNodeMultiLineString(mls MultiLineString, cut cutSet, nodes nodeSet) (Mult return MultiLineString{}, err } } - return NewMultiLineStringFromLineStrings(lss, DisableAllValidations), nil + return NewMultiLineString(lss, DisableAllValidations), nil } func reNodePolygon(poly Polygon, cut cutSet, nodes nodeSet) (Polygon, error) { @@ -284,7 +284,7 @@ func reNodePolygon(poly Polygon, cut cutSet, nodes nodeSet) (Polygon, error) { for i := 0; i < n; i++ { rings[i] = reNodedBoundary.LineStringN(i) } - reNodedPoly, err := NewPolygonFromRings(rings, DisableAllValidations) + reNodedPoly, err := NewPolygon(rings, DisableAllValidations) if err != nil { return Polygon{}, err } @@ -301,7 +301,7 @@ func reNodeMultiPolygonString(mp MultiPolygon, cut cutSet, nodes nodeSet) (Multi return MultiPolygon{}, err } } - reNodedMP, err := NewMultiPolygonFromPolygons(polys, DisableAllValidations) + reNodedMP, err := NewMultiPolygon(polys, DisableAllValidations) if err != nil { return MultiPolygon{}, err } diff --git a/geom/dump_coordinates_test.go b/geom/dump_coordinates_test.go new file mode 100644 index 00000000..13404f94 --- /dev/null +++ b/geom/dump_coordinates_test.go @@ -0,0 +1,311 @@ +package geom_test + +import ( + "testing" + + . "github.com/peterstace/simplefeatures/geom" +) + +func TestDumpCoordinatesPoint(t *testing.T) { + for _, tc := range []struct { + description string + inputWKT string + want Sequence + }{ + { + description: "empty", + inputWKT: "POINT EMPTY", + want: NewSequence(nil, DimXY), + }, + { + description: "empty z", + inputWKT: "POINT Z EMPTY", + want: NewSequence(nil, DimXYZ), + }, + { + description: "empty m", + inputWKT: "POINT M EMPTY", + want: NewSequence(nil, DimXYM), + }, + { + description: "empty zm", + inputWKT: "POINT ZM EMPTY", + want: NewSequence(nil, DimXYZM), + }, + { + description: "non-empty", + inputWKT: "POINT(1 2)", + want: NewSequence([]float64{1, 2}, DimXY), + }, + { + description: "non-empty z", + inputWKT: "POINT Z(1 2 3)", + want: NewSequence([]float64{1, 2, 3}, DimXYZ), + }, + { + description: "non-empty m", + inputWKT: "POINT M(1 2 3)", + want: NewSequence([]float64{1, 2, 3}, DimXYM), + }, + { + description: "non-empty zm", + inputWKT: "POINT ZM(1 2 3 4)", + want: NewSequence([]float64{1, 2, 3, 4}, DimXYZM), + }, + } { + t.Run(tc.description, func(t *testing.T) { + got := geomFromWKT(t, tc.inputWKT).AsPoint().DumpCoordinates() + expectSequenceEq(t, got, tc.want) + }) + } +} + +func TestDumpCoordinatesMultiLineString(t *testing.T) { + for _, tc := range []struct { + description string + inputWKT string + want Sequence + }{ + { + description: "empty", + inputWKT: "MULTILINESTRING EMPTY", + want: NewSequence(nil, DimXY), + }, + { + description: "contains empty LineString", + inputWKT: "MULTILINESTRING(EMPTY)", + want: NewSequence(nil, DimXY), + }, + { + description: "single non-empty LineString", + inputWKT: "MULTILINESTRING((1 2,3 4))", + want: NewSequence([]float64{1, 2, 3, 4}, DimXY), + }, + { + description: "multiple non-empty LineStrings", + inputWKT: "MULTILINESTRING((1 2,3 4),(5 6,7 8))", + want: NewSequence([]float64{1, 2, 3, 4, 5, 6, 7, 8}, DimXY), + }, + { + description: "mix of empty and non-empty LineStrings", + inputWKT: "MULTILINESTRING(EMPTY,(1 2,3 4))", + want: NewSequence([]float64{1, 2, 3, 4}, DimXY), + }, + { + description: "Z coordinates", + inputWKT: "MULTILINESTRING Z((1 2 3,3 4 5))", + want: NewSequence([]float64{1, 2, 3, 3, 4, 5}, DimXYZ), + }, + { + description: "M coordinates", + inputWKT: "MULTILINESTRING M((1 2 3,3 4 5))", + want: NewSequence([]float64{1, 2, 3, 3, 4, 5}, DimXYM), + }, + { + description: "ZM coordinates", + inputWKT: "MULTILINESTRING ZM((1 2 3 4,3 4 5 6))", + want: NewSequence([]float64{1, 2, 3, 4, 3, 4, 5, 6}, DimXYZM), + }, + } { + t.Run(tc.description, func(t *testing.T) { + got := geomFromWKT(t, tc.inputWKT).AsMultiLineString().DumpCoordinates() + expectSequenceEq(t, got, tc.want) + }) + } +} + +func TestDumpCoordinatesPolygon(t *testing.T) { + for _, tc := range []struct { + description string + inputWKT string + want Sequence + }{ + { + description: "empty", + inputWKT: "POLYGON EMPTY", + want: NewSequence(nil, DimXY), + }, + { + description: "contains single ring", + inputWKT: "POLYGON((0 0,0 1,1 0,0 0))", + want: NewSequence([]float64{0, 0, 0, 1, 1, 0, 0, 0}, DimXY), + }, + { + description: "multiple rings", + inputWKT: "POLYGON((0 0,0 10,10 0,0 0),(1 1,1 2,2 2,2 1,1 1))", + want: NewSequence([]float64{0, 0, 0, 10, 10, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1}, DimXY), + }, + { + description: "Z coordinates", + inputWKT: "POLYGON Z((0 0 -1,0 10 -1,10 0 -1,0 0 -1),(1 1 -1,1 2 -1,2 2 -1,2 1 -1,1 1 -1))", + want: NewSequence([]float64{ + 0, 0, -1, + 0, 10, -1, + 10, 0, -1, + 0, 0, -1, + 1, 1, -1, + 1, 2, -1, + 2, 2, -1, + 2, 1, -1, + 1, 1, -1, + }, DimXYZ), + }, + { + description: "M coordinates", + inputWKT: "POLYGON M((0 0 10,0 1 10,1 0 10,0 0 10))", + want: NewSequence([]float64{0, 0, 10, 0, 1, 10, 1, 0, 10, 0, 0, 10}, DimXYM), + }, + { + description: "ZM coordinates", + inputWKT: "POLYGON ZM((0 0 10 20,0 1 10 20,1 0 10 20,0 0 10 20))", + want: NewSequence([]float64{0, 0, 10, 20, 0, 1, 10, 20, 1, 0, 10, 20, 0, 0, 10, 20}, DimXYZM), + }, + } { + t.Run(tc.description, func(t *testing.T) { + got := geomFromWKT(t, tc.inputWKT).AsPolygon().DumpCoordinates() + expectSequenceEq(t, got, tc.want) + }) + } +} + +func TestDumpCoordinatesMultiPolygon(t *testing.T) { + for _, tc := range []struct { + description string + inputWKT string + want Sequence + }{ + { + description: "empty", + inputWKT: "MULTIPOLYGON EMPTY", + want: NewSequence(nil, DimXY), + }, + { + description: "multi polygon with empty polygon", + inputWKT: "MULTIPOLYGON(EMPTY)", + want: NewSequence(nil, DimXY), + }, + { + description: "contains single ring", + inputWKT: "MULTIPOLYGON(((0 0,0 1,1 0,0 0)))", + want: NewSequence([]float64{0, 0, 0, 1, 1, 0, 0, 0}, DimXY), + }, + { + description: "multiple rings in a single polygon", + inputWKT: "MULTIPOLYGON(((0 0,0 10,10 0,0 0),(1 1,1 2,2 2,2 1,1 1)))", + want: NewSequence([]float64{0, 0, 0, 10, 10, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 1, 1, 1}, DimXY), + }, + { + description: "multiple polygons", + inputWKT: "MULTIPOLYGON(((0 0,0 1,1 0,0 0)),((10 10,10 11,11 10,10 10)))", + want: NewSequence([]float64{0, 0, 0, 1, 1, 0, 0, 0, 10, 10, 10, 11, 11, 10, 10, 10}, DimXY), + }, + { + description: "Z coordinates", + inputWKT: "MULTIPOLYGON Z(((0 0 10,0 1 10,1 0 10,0 0 10)))", + want: NewSequence([]float64{0, 0, 10, 0, 1, 10, 1, 0, 10, 0, 0, 10}, DimXYZ), + }, + { + description: "M coordinates", + inputWKT: "MULTIPOLYGON M(((0 0 10,0 1 10,1 0 10,0 0 10)))", + want: NewSequence([]float64{0, 0, 10, 0, 1, 10, 1, 0, 10, 0, 0, 10}, DimXYM), + }, + { + description: "ZM coordinates", + inputWKT: "MULTIPOLYGON ZM(((0 0 20 10,0 1 20 10,1 0 20 10,0 0 20 10)))", + want: NewSequence([]float64{0, 0, 20, 10, 0, 1, 20, 10, 1, 0, 20, 10, 0, 0, 20, 10}, DimXYZM), + }, + } { + t.Run(tc.description, func(t *testing.T) { + got := geomFromWKT(t, tc.inputWKT).AsMultiPolygon().DumpCoordinates() + expectSequenceEq(t, got, tc.want) + }) + } +} + +func TestDumpCoordinatesGeometryCollection(t *testing.T) { + for _, tc := range []struct { + description string + inputWKT string + want Sequence + }{ + { + description: "empty", + inputWKT: "GEOMETRYCOLLECTION EMPTY", + want: NewSequence(nil, DimXY), + }, + { + description: "empty z", + inputWKT: "GEOMETRYCOLLECTION Z EMPTY", + want: NewSequence(nil, DimXYZ), + }, + { + description: "single point", + inputWKT: "GEOMETRYCOLLECTION(POINT(1 2))", + want: NewSequence([]float64{1, 2}, DimXY), + }, + { + description: "single point z", + inputWKT: "GEOMETRYCOLLECTION Z(POINT Z(1 2 0))", + want: NewSequence([]float64{1, 2, 0}, DimXYZ), + }, + { + description: "nested", + inputWKT: "GEOMETRYCOLLECTION Z(GEOMETRYCOLLECTION Z(POINT Z(1 2 0)))", + want: NewSequence([]float64{1, 2, 0}, DimXYZ), + }, + } { + t.Run(tc.description, func(t *testing.T) { + got := geomFromWKT(t, tc.inputWKT).AsGeometryCollection().DumpCoordinates() + expectSequenceEq(t, got, tc.want) + }) + } +} + +func TestDumpCoordinatesGeometry(t *testing.T) { + for _, tc := range []struct { + description string + inputWKT string + want Sequence + }{ + { + description: "Point", + inputWKT: "POINT Z(0 1 2)", + want: NewSequence([]float64{0, 1, 2}, DimXYZ), + }, + { + description: "LineString", + inputWKT: "LINESTRING Z(0 1 2,3 4 5)", + want: NewSequence([]float64{0, 1, 2, 3, 4, 5}, DimXYZ), + }, + { + description: "Polygon", + inputWKT: "POLYGON Z((0 0 1,0 1 1,1 0 1,0 0 1))", + want: NewSequence([]float64{0, 0, 1, 0, 1, 1, 1, 0, 1, 0, 0, 1}, DimXYZ), + }, + { + description: "MultiPoint", + inputWKT: "MULTIPOINT Z(0 1 2,3 4 5)", + want: NewSequence([]float64{0, 1, 2, 3, 4, 5}, DimXYZ), + }, + { + description: "MultiLineString", + inputWKT: "MULTILINESTRING Z((0 1 2,3 4 5))", + want: NewSequence([]float64{0, 1, 2, 3, 4, 5}, DimXYZ), + }, + { + description: "MultiPolygon", + inputWKT: "MULTIPOLYGON Z(((0 0 1,0 1 1,1 0 1,0 0 1)))", + want: NewSequence([]float64{0, 0, 1, 0, 1, 1, 1, 0, 1, 0, 0, 1}, DimXYZ), + }, + { + description: "GeometryCollection", + inputWKT: "GEOMETRYCOLLECTION Z(POINT Z(0 1 2))", + want: NewSequence([]float64{0, 1, 2}, DimXYZ), + }, + } { + t.Run(tc.description, func(t *testing.T) { + got := geomFromWKT(t, tc.inputWKT).DumpCoordinates() + expectSequenceEq(t, got, tc.want) + }) + } +} diff --git a/geom/geojson_marshal.go b/geom/geojson_marshal.go index 4baca069..a9368848 100644 --- a/geom/geojson_marshal.go +++ b/geom/geojson_marshal.go @@ -13,20 +13,14 @@ func appendGeoJSONCoordinate(dst []byte, coords Coordinates) []byte { return append(dst, ']') } -func appendGeoJSONSequence(dst []byte, seq Sequence, empty BitSet) []byte { +func appendGeoJSONSequence(dst []byte, seq Sequence) []byte { dst = append(dst, '[') n := seq.Length() - var seenFirst bool for i := 0; i < n; i++ { - if empty.Get(i) { - // GeoJSON doesn't support empty Points within MultiPoints. - continue - } - if seenFirst { + if i > 0 { dst = append(dst, ',') } dst = appendGeoJSONCoordinate(dst, seq.Get(i)) - seenFirst = true } dst = append(dst, ']') return dst @@ -38,7 +32,7 @@ func appendGeoJSONSequences(dst []byte, seqs []Sequence) []byte { if i > 0 { dst = append(dst, ',') } - dst = appendGeoJSONSequence(dst, seq, BitSet{}) + dst = appendGeoJSONSequence(dst, seq) } dst = append(dst, ']') return dst diff --git a/geom/geojson_unmarshal.go b/geom/geojson_unmarshal.go index 745d52ab..9389684b 100644 --- a/geom/geojson_unmarshal.go +++ b/geom/geojson_unmarshal.go @@ -239,7 +239,8 @@ func geojsonNodeToGeometry(node interface{}, ctype CoordinatesType, opts []Const case geojsonPoint: coords, ok := oneDimFloat64sToCoordinates(node.coords, ctype) if ok { - return NewPoint(coords, opts...).AsGeometry(), nil + pt, err := NewPoint(coords, opts...) + return pt.AsGeometry(), err } return NewEmptyPoint(ctype).AsGeometry(), nil case geojsonLineString: @@ -259,12 +260,27 @@ func geojsonNodeToGeometry(node interface{}, ctype CoordinatesType, opts []Const return Geometry{}, err } } - poly, err := NewPolygonFromRings(rings, opts...) + poly, err := NewPolygon(rings, opts...) return poly.AsGeometry(), err case geojsonMultiPoint: // GeoJSON MultiPoints cannot contain empty Points. - seq := twoDimFloat64sToSequence(node.coords, ctype) - return NewMultiPoint(seq, opts...).AsGeometry(), nil + if len(node.coords) == 0 { + return MultiPoint{}.ForceCoordinatesType(ctype).AsGeometry(), nil + } + points := make([]Point, len(node.coords)) + for i, coords := range node.coords { + coords, ok := oneDimFloat64sToCoordinates(coords, ctype) + if ok { + var err error + points[i], err = NewPoint(coords, opts...) + if err != nil { + return Geometry{}, err + } + } else { + points[i] = NewEmptyPoint(ctype) + } + } + return NewMultiPoint(points).AsGeometry(), nil case geojsonMultiLineString: if len(node.coords) == 0 { return MultiLineString{}.ForceCoordinatesType(ctype).AsGeometry(), nil @@ -278,7 +294,7 @@ func geojsonNodeToGeometry(node interface{}, ctype CoordinatesType, opts []Const return Geometry{}, err } } - return NewMultiLineStringFromLineStrings(lss, opts...).AsGeometry(), nil + return NewMultiLineString(lss, opts...).AsGeometry(), nil case geojsonMultiPolygon: if len(node.coords) == 0 { return MultiPolygon{}.ForceCoordinatesType(ctype).AsGeometry(), nil @@ -295,13 +311,13 @@ func geojsonNodeToGeometry(node interface{}, ctype CoordinatesType, opts []Const } } var err error - polys[i], err = NewPolygonFromRings(rings, opts...) + polys[i], err = NewPolygon(rings, opts...) if err != nil { return Geometry{}, err } polys[i] = polys[i].ForceCoordinatesType(ctype) } - mp, err := NewMultiPolygonFromPolygons(polys, opts...) + mp, err := NewMultiPolygon(polys, opts...) return mp.AsGeometry(), err case geojsonGeometryCollection: if len(node.geoms) == 0 { diff --git a/geom/line.go b/geom/line.go index 86a2e678..d794b3c7 100644 --- a/geom/line.go +++ b/geom/line.go @@ -3,6 +3,8 @@ package geom import ( "fmt" "math" + + "github.com/peterstace/simplefeatures/rtree" ) // line represents a line segment between two XY locations. It's an invariant @@ -12,10 +14,25 @@ type line struct { a, b XY } -func (ln line) envelope() Envelope { +// uncheckedEnvelope directly constructs an Envelope that bounds the line. It +// skips envelope validation because line coordinates never come directly from +// users. Instead, line coordinates come directly from pre-validated +// LineStrings, or from operations on pre-validated geometries. +func (ln line) uncheckedEnvelope() Envelope { + ln.a.X, ln.b.X = sortFloat64Pair(ln.a.X, ln.b.X) + ln.a.Y, ln.b.Y = sortFloat64Pair(ln.a.Y, ln.b.Y) + return newUncheckedEnvelope(ln.a, ln.b) +} + +func (ln line) box() rtree.Box { ln.a.X, ln.b.X = sortFloat64Pair(ln.a.X, ln.b.X) ln.a.Y, ln.b.Y = sortFloat64Pair(ln.a.Y, ln.b.Y) - return Envelope{ln.a, ln.b} + return rtree.Box{ + MinX: ln.a.X, + MinY: ln.a.Y, + MaxX: ln.b.X, + MaxY: ln.b.Y, + } } func (ln line) length() float64 { @@ -45,7 +62,7 @@ func (ln line) asLineString() LineString { func (ln line) intersectsXY(xy XY) bool { // Speed is O(1) using a bounding box check then a point-on-line check. - env := ln.envelope() + env := ln.uncheckedEnvelope() if !env.Contains(xy) { return false } diff --git a/geom/perf_internal_test.go b/geom/perf_internal_test.go index f8eeadd7..78ab1d77 100644 --- a/geom/perf_internal_test.go +++ b/geom/perf_internal_test.go @@ -9,14 +9,14 @@ var dummyEnv Envelope func BenchmarkLineEnvelope(b *testing.B) { for i, ln := range []line{ - line{XY{0, 0}, XY{1, 1}}, - line{XY{1, 1}, XY{0, 0}}, - line{XY{0, 1}, XY{1, 0}}, - line{XY{1, 0}, XY{0, 1}}, + {XY{0, 0}, XY{1, 1}}, + {XY{1, 1}, XY{0, 0}}, + {XY{0, 1}, XY{1, 0}}, + {XY{1, 0}, XY{0, 1}}, } { b.Run(strconv.Itoa(i), func(b *testing.B) { for i := 0; i < b.N; i++ { - dummyEnv = ln.envelope() + dummyEnv = ln.uncheckedEnvelope() } }) } diff --git a/geom/perf_test.go b/geom/perf_test.go index 0ce182e9..ed6902a5 100644 --- a/geom/perf_test.go +++ b/geom/perf_test.go @@ -28,7 +28,7 @@ func regularPolygon(center XY, radius float64, sides int) Polygon { if err != nil { panic(err) } - poly, err := NewPolygonFromRings([]LineString{ring}, geom.DisableAllValidations) + poly, err := NewPolygon([]LineString{ring}, geom.DisableAllValidations) if err != nil { panic(err) } @@ -102,13 +102,17 @@ func BenchmarkIntersectsMultiPointWithMultiPoint(b *testing.B) { for _, sz := range []int{10, 100, 1000, 10000} { b.Run(fmt.Sprintf("n=%d", 2*sz), func(b *testing.B) { rnd := rand.New(rand.NewSource(0)) - var coordsA, coordsB []float64 + var pointsA, pointsB []Point for i := 0; i < sz; i++ { - coordsA = append(coordsA, rnd.Float64(), rnd.Float64()) - coordsB = append(coordsB, rnd.Float64(), rnd.Float64()) + ptA, err := XY{X: rnd.Float64(), Y: rnd.Float64()}.AsPoint() + expectNoErr(b, err) + pointsA = append(pointsA, ptA) + ptB, err := XY{X: rnd.Float64(), Y: rnd.Float64()}.AsPoint() + expectNoErr(b, err) + pointsB = append(pointsB, ptB) } - mpA := NewMultiPoint(NewSequence(coordsA, DimXY)).AsGeometry() - mpB := NewMultiPoint(NewSequence(coordsB, DimXY)).AsGeometry() + mpA := NewMultiPoint(pointsA).AsGeometry() + mpB := NewMultiPoint(pointsB).AsGeometry() b.ResetTimer() for i := 0; i < b.N; i++ { if Intersects(mpA, mpB) { @@ -138,7 +142,7 @@ func BenchmarkPolygonSingleRingValidation(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - if _, err := NewPolygonFromRings(rings); err != nil { + if _, err := NewPolygon(rings); err != nil { b.Fatal(err) } } @@ -177,7 +181,7 @@ func BenchmarkPolygonMultipleRingsValidation(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - if _, err := NewPolygonFromRings(rings); err != nil { + if _, err := NewPolygon(rings); err != nil { b.Fatal(err) } } @@ -188,7 +192,9 @@ func BenchmarkPolygonMultipleRingsValidation(b *testing.B) { func BenchmarkPolygonZigZagRingsValidation(b *testing.B) { for _, sz := range []int{10, 100, 1000, 10000} { b.Run(fmt.Sprintf("n=%d", sz), func(b *testing.B) { - outerRing := NewEnvelope(XY{}, XY{7, float64(sz + 1)}).AsGeometry().AsPolygon().ExteriorRing() + outerRingEnv, err := NewEnvelope([]XY{{}, {7, float64(sz + 1)}}) + expectNoErr(b, err) + outerRing := outerRingEnv.AsGeometry().AsPolygon().ExteriorRing() var leftFloats, rightFloats []float64 for i := 0; i < sz; i++ { leftFloats = append(leftFloats, float64(2+(i%2)*2), float64(1+i)) @@ -215,7 +221,7 @@ func BenchmarkPolygonZigZagRingsValidation(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := NewPolygonFromRings([]LineString{outerRing, leftRing, rightRing}) + _, err := NewPolygon([]LineString{outerRing, leftRing, rightRing}) if err != nil { b.Fatal(err) } @@ -232,7 +238,7 @@ func BenchmarkPolygonAnnulusValidation(b *testing.B) { rings := []LineString{outer, inner} b.ResetTimer() for i := 0; i < b.N; i++ { - if _, err := NewPolygonFromRings(rings); err != nil { + if _, err := NewPolygon(rings); err != nil { b.Fatal(err) } } @@ -260,7 +266,7 @@ func BenchmarkMultipolygonValidation(b *testing.B) { if err != nil { b.Fatal(err) } - polys[i], err = NewPolygonFromRings([]LineString{ring}) + polys[i], err = NewPolygon([]LineString{ring}) if err != nil { b.Fatal(err) } @@ -268,7 +274,7 @@ func BenchmarkMultipolygonValidation(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - if _, err := NewMultiPolygonFromPolygons(polys); err != nil { + if _, err := NewMultiPolygon(polys); err != nil { b.Fatal(err) } } @@ -286,7 +292,7 @@ func BenchmarkMultiPolygonTwoCircles(b *testing.B) { } b.ResetTimer() for i := 0; i < b.N; i++ { - if _, err := NewMultiPolygonFromPolygons(polys); err != nil { + if _, err := NewMultiPolygon(polys); err != nil { b.Fatal(err) } } @@ -314,11 +320,11 @@ func BenchmarkMultiPolygonMultipleTouchingPoints(b *testing.B) { if err != nil { b.Fatal(err) } - p1, err := NewPolygonFromRings([]LineString{ls1}) + p1, err := NewPolygon([]LineString{ls1}) if err != nil { b.Fatal(err) } - p2, err := NewPolygonFromRings([]LineString{ls2}) + p2, err := NewPolygon([]LineString{ls2}) if err != nil { b.Fatal(err) } @@ -326,7 +332,7 @@ func BenchmarkMultiPolygonMultipleTouchingPoints(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := NewMultiPolygonFromPolygons(polys) + _, err := NewMultiPolygon(polys) if err != nil { b.Fatal(err) } @@ -406,7 +412,7 @@ func BenchmarkMultiLineStringIsSimpleManyLineStrings(b *testing.B) { } lss = append(lss, ls) } - mls := NewMultiLineStringFromLineStrings(lss) + mls := NewMultiLineString(lss) b.ResetTimer() for i := 0; i < b.N; i++ { diff --git a/geom/rtree.go b/geom/rtree.go index 0d991958..eb4f9d60 100644 --- a/geom/rtree.go +++ b/geom/rtree.go @@ -14,7 +14,7 @@ func newIndexedLines(lines []line) indexedLines { bulk := make([]rtree.BulkItem, len(lines)) for i, ln := range lines { bulk[i] = rtree.BulkItem{ - Box: ln.envelope().box(), + Box: ln.box(), RecordID: i, } } diff --git a/geom/type_coordinates.go b/geom/type_coordinates.go index 1ce00ca0..da0d0f65 100644 --- a/geom/type_coordinates.go +++ b/geom/type_coordinates.go @@ -1,6 +1,13 @@ package geom -// Coordinates represents a point location. +import ( + "strconv" + "strings" +) + +// Coordinates represents a point location. Coordinates values may be +// constructed manually using the type definition directly. Alternatively, one +// of the New(XYZM)Coordinates constructor functions can be used. type Coordinates struct { // XY represents the XY position of the point location. XY @@ -18,3 +25,40 @@ type Coordinates struct { // or not Z and M are populated. Type CoordinatesType } + +// String gives a string representation of the coordinates. +func (c Coordinates) String() string { + var sb strings.Builder + sb.WriteString("Coordinates[") + sb.WriteString(c.Type.String()) + sb.WriteString("] ") + sb.WriteString(strconv.FormatFloat(c.X, 'f', -1, 64)) + sb.WriteRune(' ') + sb.WriteString(strconv.FormatFloat(c.Y, 'f', -1, 64)) + if c.Type.Is3D() { + sb.WriteRune(' ') + sb.WriteString(strconv.FormatFloat(c.Z, 'f', -1, 64)) + } + if c.Type.IsMeasured() { + sb.WriteRune(' ') + sb.WriteString(strconv.FormatFloat(c.M, 'f', -1, 64)) + } + return sb.String() +} + +// appendFloat64s appends the coordinates to dst, taking into +// consideration the coordinate type. +func (c Coordinates) appendFloat64s(dst []float64) []float64 { + switch c.Type { + case DimXY: + return append(dst, c.X, c.Y) + case DimXYZ: + return append(dst, c.X, c.Y, c.Z) + case DimXYM: + return append(dst, c.X, c.Y, c.M) + case DimXYZM: + return append(dst, c.X, c.Y, c.Z, c.M) + default: + panic(c.Type.String()) + } +} diff --git a/geom/type_coordinates_test.go b/geom/type_coordinates_test.go new file mode 100644 index 00000000..a418194a --- /dev/null +++ b/geom/type_coordinates_test.go @@ -0,0 +1,41 @@ +package geom_test + +import ( + "strconv" + "testing" + + . "github.com/peterstace/simplefeatures/geom" +) + +func TestCoordinatesString(t *testing.T) { + for i, tc := range []struct { + coords Coordinates + want string + }{ + { + Coordinates{}, + "Coordinates[XY] 0 0", + }, + { + Coordinates{XY: XY{X: 1, Y: 2}}, + "Coordinates[XY] 1 2", + }, + { + Coordinates{XY: XY{X: 1, Y: 2}, Z: 3, Type: DimXYZ}, + "Coordinates[XYZ] 1 2 3", + }, + { + Coordinates{XY: XY{X: 1, Y: 2}, M: 3, Type: DimXYM}, + "Coordinates[XYM] 1 2 3", + }, + { + Coordinates{XY: XY{X: 1, Y: 2}, Z: 3, M: 4, Type: DimXYZM}, + "Coordinates[XYZM] 1 2 3 4", + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + got := tc.coords.String() + expectStringEq(t, got, tc.want) + }) + } +} diff --git a/geom/type_envelope.go b/geom/type_envelope.go index 989d25c9..13110aa2 100644 --- a/geom/type_envelope.go +++ b/geom/type_envelope.go @@ -7,76 +7,127 @@ import ( "github.com/peterstace/simplefeatures/rtree" ) -// Envelope is an axis-aligned rectangle (also known as an Axis Aligned -// Bounding Box or Minimum Bounding Rectangle). It usually represents a 2D area -// with non-zero width and height, but can also represent degenerate cases -// where the width or height (or both) are zero. +// Envelope is a generalised axis-aligned rectangle (also known as an Axis +// Aligned Bounding Box or Minimum Bounding Rectangle). It usually represents a +// 2D area with non-zero width and height. But it can also represent degenerate +// cases where the width or height (or both) are zero, or the envelope is +// empty. Its bounds are validated so as to not be NaN or +/- Infinity. +// +// An envelope can be thought of as as being similar to a regular geometry, but +// can only represent an empty geometry, a single point, a horizontal or +// vertical line, or an axis aligned rectangle with some area. +// +// The Envelope zero value is the empty envelope. Envelopes are immutable after +// creation. type Envelope struct { - min XY - max XY + // nanXORMinX is the bit pattern of "min X" XORed with the bit pattern of + // NaN. This is so that when Envelope has its zero value, the logical value + // of "min X" is NaN. The logical value of "min X" being NaN is used to + // signify that the Envelope is empty. + nanXORMinX uint64 + + minY float64 + maxX float64 + maxY float64 } -// NewEnvelope returns the smallest envelope that contains all provided points. -func NewEnvelope(first XY, others ...XY) Envelope { - env := Envelope{ - min: first, - max: first, - } - for _, pt := range others { - env = env.ExtendToIncludePoint(pt) - } - return env +var nan = math.Float64bits(math.NaN()) + +// encodeFloat64WithNaN encodes a float64 by XORing it with NaN. +func encodeFloat64WithNaN(f float64) uint64 { + return math.Float64bits(f) ^ nan +} + +// minX decodes the logical value ("min X") of nanXORMinX. +func (e Envelope) minX() float64 { + return math.Float64frombits(e.nanXORMinX ^ nan) } -// EnvelopeFromGeoms returns the smallest envelope that contains all points -// contained by the provided geometries, provided that at least one non-empty -// geometry is given. If no non-empty geometries are given, then the returned -// flag is set to false. -func EnvelopeFromGeoms(geoms ...Geometry) (Envelope, bool) { - envs := make([]Envelope, 0, len(geoms)) - for _, g := range geoms { - env, ok := g.Envelope() - if ok { - envs = append(envs, env) +// NewEnvelope returns the smallest envelope that contains all provided XYs. +// It returns an error if any of the XYs contain NaN or +/- Infinity +// coordinates. +func NewEnvelope(xys []XY) (Envelope, error) { + var env Envelope + for _, xy := range xys { + var err error + env, err = env.ExtendToIncludeXY(xy) + if err != nil { + return Envelope{}, err } } - if len(envs) == 0 { - return Envelope{}, false - } - env := envs[0] - for _, e := range envs[1:] { - env = env.ExpandToIncludeEnvelope(e) + return env, nil +} + +func newUncheckedEnvelope(min, max XY) Envelope { + return Envelope{ + nanXORMinX: encodeFloat64WithNaN(min.X), + minY: min.Y, + maxX: max.X, + maxY: max.Y, } - return env, true +} + +func (e Envelope) min() XY { + return XY{e.minX(), e.minY} +} + +func (e Envelope) max() XY { + return XY{e.maxX, e.maxY} +} + +// IsEmpty returns true if and only if this envelope is empty. +func (e Envelope) IsEmpty() bool { + return math.IsNaN(e.minX()) +} + +// IsPoint returns true if and only if this envelope represents a single point. +func (e Envelope) IsPoint() bool { + return !e.IsEmpty() && e.min() == e.max() +} + +// IsLine returns true if and only if this envelope represents a single line +// (which must be either vertical or horizontal). +func (e Envelope) IsLine() bool { + return !e.IsEmpty() && (e.minX() == e.maxX) != (e.minY == e.maxY) +} + +// IsRectangle returns true if and only if this envelope represents a +// non-degenerate rectangle with some area. +func (e Envelope) IsRectangle() bool { + return !e.IsEmpty() && e.minX() != e.maxX && e.minY != e.maxY } // AsGeometry returns the envelope as a Geometry. In the regular case where the // envelope covers some area, then a Polygon geometry is returned. In // degenerate cases where the envelope only covers a line or a point, a -// LineString or Point geometry is returned. +// LineString or Point geometry is returned. In the case of an empty envelope, +// the zero value Geometry is returned (representing an empty +// GeometryCollection). func (e Envelope) AsGeometry() Geometry { - if e.min == e.max { - return NewPointFromXY(e.min).AsGeometry() - } - - if e.min.X == e.max.X || e.min.Y == e.max.Y { - ln := line{e.min, e.max} + switch { + case e.IsEmpty(): + return Geometry{} + case e.IsPoint(): + return e.min().asUncheckedPoint().AsGeometry() + case e.IsLine(): + ln := line{e.min(), e.max()} return ln.asLineString().AsGeometry() } + minX := e.minX() floats := [...]float64{ - e.min.X, e.min.Y, - e.min.X, e.max.Y, - e.max.X, e.max.Y, - e.max.X, e.min.Y, - e.min.X, e.min.Y, + minX, e.minY, + minX, e.maxY, + e.maxX, e.maxY, + e.maxX, e.minY, + minX, e.minY, } seq := NewSequence(floats[:], DimXY) ls, err := NewLineString(seq) if err != nil { panic(fmt.Sprintf("constructing geometry from envelope: %v", err)) } - poly, err := NewPolygonFromRings([]LineString{ls}) + poly, err := NewPolygon([]LineString{ls}) if err != nil { panic(fmt.Sprintf("constructing geometry from envelope: %v", err)) } @@ -84,111 +135,154 @@ func (e Envelope) AsGeometry() Geometry { } // Min returns the point in the envelope with the minimum X and Y values. -func (e Envelope) Min() XY { - return e.min +func (e Envelope) Min() Point { + if e.IsEmpty() { + return Point{} + } + return e.min().asUncheckedPoint() } // Max returns the point in the envelope with the maximum X and Y values. -func (e Envelope) Max() XY { - return e.max +func (e Envelope) Max() Point { + if e.IsEmpty() { + return Point{} + } + return e.max().asUncheckedPoint() } -// ExtendToIncludePoint returns the smallest envelope that contains all of the -// points in this envelope along with the provided point. -func (e Envelope) ExtendToIncludePoint(point XY) Envelope { - return Envelope{ - min: XY{fastMin(e.min.X, point.X), fastMin(e.min.Y, point.Y)}, - max: XY{fastMax(e.max.X, point.X), fastMax(e.max.Y, point.Y)}, +// MinMaxXYs returns the two XY values in the envelope that contain the minimum +// (first return value) and maximum (second return value) X and Y values in the +// envelope. The third return value is true if and only if the Envelope is +// non-empty and thus the first two return values are populated. +func (e Envelope) MinMaxXYs() (XY, XY, bool) { + if e.IsEmpty() { + return XY{}, XY{}, false + } + return e.min(), e.max(), true +} + +// ExtendToIncludeXY returns the smallest envelope that contains all of the +// points in this envelope along with the provided point. It gives an error if +// the XY contains NaN or +/- Infinite coordinates. +func (e Envelope) ExtendToIncludeXY(xy XY) (Envelope, error) { + if err := xy.validate(); err != nil { + return Envelope{}, err + } + return e.uncheckedExtend(xy), nil +} + +// uncheckedExtend extends the envelope in the same manner as +// ExtendToIncludeXY but doesn't validate the XY. It should only be used +// when the XY doesn't come directly from user input. +func (e Envelope) uncheckedExtend(xy XY) Envelope { + if e.IsEmpty() { + return newUncheckedEnvelope(xy, xy) } + return newUncheckedEnvelope( + XY{fastMin(e.minX(), xy.X), fastMin(e.minY, xy.Y)}, + XY{fastMax(e.maxX, xy.X), fastMax(e.maxY, xy.Y)}, + ) } // ExpandToIncludeEnvelope returns the smallest envelope that contains all of // the points in this envelope and another envelope. -func (e Envelope) ExpandToIncludeEnvelope(other Envelope) Envelope { - return Envelope{ - min: XY{fastMin(e.min.X, other.min.X), fastMin(e.min.Y, other.min.Y)}, - max: XY{fastMax(e.max.X, other.max.X), fastMax(e.max.Y, other.max.Y)}, +func (e Envelope) ExpandToIncludeEnvelope(o Envelope) Envelope { + if e.IsEmpty() { + return o + } + if o.IsEmpty() { + return e } + return newUncheckedEnvelope( + XY{fastMin(e.minX(), o.minX()), fastMin(e.minY, o.minY)}, + XY{fastMax(e.maxX, o.maxX), fastMax(e.maxY, o.maxY)}, + ) } -// Contains returns true iff this envelope contains the given point. +// Contains returns true if and only if this envelope contains the given XY. It +// always returns false in the case where the XY contains NaN or +/- Infinity +// coordinates. func (e Envelope) Contains(p XY) bool { - return true && - p.X >= e.min.X && p.X <= e.max.X && - p.Y >= e.min.Y && p.Y <= e.max.Y + return !e.IsEmpty() && + p.validate() == nil && + p.X >= e.minX() && p.X <= e.maxX && + p.Y >= e.minY && p.Y <= e.maxY } -// Intersects returns true iff this envelope has any points in common with -// another envelope. +// Intersects returns true if and only if this envelope has any points in +// common with another envelope. func (e Envelope) Intersects(o Envelope) bool { - return true && - (e.min.X <= o.max.X) && (e.max.X >= o.min.X) && - (e.min.Y <= o.max.Y) && (e.max.Y >= o.min.Y) + return !e.IsEmpty() && !o.IsEmpty() && + (e.minX() <= o.maxX) && (e.maxX >= o.minX()) && + (e.minY <= o.maxY) && (e.maxY >= o.minY) } // Center returns the center point of the envelope. -func (e Envelope) Center() XY { - return e.min.Add(e.max).Scale(0.5) +func (e Envelope) Center() Point { + if e.IsEmpty() { + return Point{} + } + return e.min(). + Add(e.max()). + Scale(0.5). + asUncheckedPoint() } -// Covers returns true iff and only if this envelope entirely covers another +// Covers returns true if and only if this envelope entirely covers another // envelope (i.e. every point in the other envelope is contained within this -// envelope). +// envelope). An envelope can only cover another if it is non-empty. +// Furthermore, an envelope can only be covered if it is non-empty. func (e Envelope) Covers(o Envelope) bool { - return true && - e.min.X <= o.min.X && e.min.Y <= o.min.Y && - e.max.X >= o.max.X && e.max.Y >= o.max.Y + return !e.IsEmpty() && !o.IsEmpty() && + e.minX() <= o.minX() && e.minY <= o.minY && + e.maxX >= o.maxX && e.maxY >= o.maxY } // Width returns the difference between the maximum and minimum X coordinates // of the envelope. func (e Envelope) Width() float64 { - return e.max.X - e.min.X + if e.IsEmpty() { + return 0 + } + return e.maxX - e.minX() } // Height returns the difference between the maximum and minimum X coordinates // of the envelope. func (e Envelope) Height() float64 { - return e.max.Y - e.min.Y + if e.IsEmpty() { + return 0 + } + return e.maxY - e.minY } // Area returns the area covered by the envelope. func (e Envelope) Area() float64 { - return (e.max.X - e.min.X) * (e.max.Y - e.min.Y) -} - -// ExpandBy calculates a new version of this envelope that is expanded in the x -// and y dimensions. Both the minimum and maximum points in the envelope are -// expanded by the supplied x and y amounts. Positive values increase the size -// of the envelope and negative amounts decrease the size of the envelope. If a -// decrease in envelope size would result in an invalid envelope (where min is -// greater than max), then false is returned and no envelope is calculated. -func (e Envelope) ExpandBy(x, y float64) (Envelope, bool) { - delta := XY{x, y} - env := Envelope{ - min: e.min.Sub(delta), - max: e.max.Add(delta), - } - if env.min.X > env.max.X || env.min.Y > env.max.Y { - return Envelope{}, false + if e.IsEmpty() { + return 0 } - return env, true + return (e.maxX - e.minX()) * (e.maxY - e.minY) } -// Distance calculates the stortest distance between this envelope and another -// envelope. If the envelopes intersect with each other, then the returned -// distance is 0. -func (e Envelope) Distance(o Envelope) float64 { - dx := fastMax(0, fastMax(o.min.X-e.max.X, e.min.X-o.max.X)) - dy := fastMax(0, fastMax(o.min.Y-e.max.Y, e.min.Y-o.max.Y)) - return math.Sqrt(dx*dx + dy*dy) +// Distance calculates the shortest distance between this envelope and another +// envelope, which both must be non-empty for the distance to be well-defined +// (indicated by the bool return being true). If the envelopes are both +// non-empty and intersect with each other, the distance between them is still +// well-defined, but zero. +func (e Envelope) Distance(o Envelope) (float64, bool) { + if e.IsEmpty() || o.IsEmpty() { + return 0, false + } + dx := fastMax(0, fastMax(o.minX()-e.maxX, e.minX()-o.maxX)) + dy := fastMax(0, fastMax(o.minY-e.maxY, e.minY-o.maxY)) + return math.Sqrt(dx*dx + dy*dy), true } -func (e Envelope) box() rtree.Box { +func (e Envelope) box() (rtree.Box, bool) { return rtree.Box{ - MinX: e.min.X, - MinY: e.min.Y, - MaxX: e.max.X, - MaxY: e.max.Y, - } + MinX: e.minX(), + MinY: e.minY, + MaxX: e.maxX, + MaxY: e.maxY, + }, !e.IsEmpty() } diff --git a/geom/type_envelope_test.go b/geom/type_envelope_test.go index d60991f5..64ca934f 100644 --- a/geom/type_envelope_test.go +++ b/geom/type_envelope_test.go @@ -9,193 +9,545 @@ import ( . "github.com/peterstace/simplefeatures/geom" ) -func TestEnvelopeContains(t *testing.T) { - env := NewEnvelope( - XY{12, 4}, - XY{14, 2}, - ) - for x := 11; x <= 15; x++ { - for y := 1; y <= 5; y++ { - t.Run(fmt.Sprintf("%d_%d", x, y), func(t *testing.T) { - want := x >= 12 && x <= 14 && y >= 2 && y <= 4 - pt := XY{float64(x), float64(y)} - got := env.Contains(pt) - if got != want { - t.Errorf("want=%v got=%v", want, got) - } - }) - } +func onePtEnv(x, y float64) Envelope { + env, err := Envelope{}.ExtendToIncludeXY(XY{X: x, Y: y}) + if err != nil { + panic("could not construct env") } + return env } -func TestEnvelopeAsGeometry(t *testing.T) { - for _, tt := range []struct { - env Envelope - wantWKT string - }{ - {NewEnvelope(XY{5, 8}), "POINT(5 8)"}, - {NewEnvelope(XY{1, 2}, XY{5, 2}), "LINESTRING(1 2,5 2)"}, - {NewEnvelope(XY{1, 2}, XY{1, 7}), "LINESTRING(1 2,1 7)"}, - {NewEnvelope(XY{3, 4}, XY{8, 0}), "POLYGON((3 0,3 4,8 4,8 0,3 0))"}, - } { - got := tt.env.AsGeometry() - expectGeomEq(t, got, geomFromWKT(t, tt.wantWKT)) +func twoPtEnv(minX, minY, maxX, maxY float64) Envelope { + if minX > maxX { + panic(fmt.Sprintf("X values out of order: %v %v", minX, maxX)) + } + if minY > maxY { + panic(fmt.Sprintf("Y values out of order: %v %v", minY, maxY)) } + env, err := onePtEnv(minX, minY).ExtendToIncludeXY(XY{X: maxX, Y: maxY}) + if err != nil { + panic("could not construct env") + } + return env } -// env is a helper to create an envelope in a compact way. -func env(x1, y1, x2, y2 float64) Envelope { - return NewEnvelope(XY{x1, y1}, XY{x2, y2}) +func TestEnvelopeNew(t *testing.T) { + for _, tc := range []struct { + desc string + xys []XY + want Envelope + }{ + { + desc: "nil slice", + xys: nil, + want: Envelope{}, + }, + { + desc: "empty slice", + xys: []XY{}, + want: Envelope{}, + }, + { + desc: "single element", + xys: []XY{{1, 2}}, + want: onePtEnv(1, 2), + }, + { + desc: "two same elements", + xys: []XY{{1, 2}, {1, 2}}, + want: onePtEnv(1, 2), + }, + { + desc: "two different elements", + xys: []XY{{1, 2}, {-1, 3}}, + want: twoPtEnv(-1, 2, 1, 3), + }, + } { + t.Run(tc.desc, func(t *testing.T) { + got, err := NewEnvelope(tc.xys) + expectNoErr(t, err) + expectEnvEq(t, got, tc.want) + }) + } } -func TestEnvelopeIntersects(t *testing.T) { - for i, tt := range []struct { - e1, e2 Envelope - want bool +func TestEnvelopeAttributes(t *testing.T) { + for _, tc := range []struct { + description string + env Envelope + isEmpty, isPoint, isLine, isRect bool + area, width, height float64 + center, min, max, geom string }{ - {env(0, 0, 1, 1), env(2, 2, 3, 3), false}, - {env(0, 2, 1, 3), env(2, 0, 3, 1), false}, - {env(0, 0, 1, 1), env(1, 1, 2, 2), true}, - {env(0, 1, 1, 2), env(1, 0, 2, 1), true}, - {env(0, 0, 2, 2), env(1, 1, 3, 3), true}, - {env(0, 1, 2, 3), env(1, 0, 3, 2), true}, - {env(0, 0, 2, 1), env(1, 0, 3, 1), true}, - {env(0, 0, 1, 2), env(0, 1, 1, 3), true}, - {env(0, 0, 2, 2), env(1, -1, 3, 3), true}, - {env(0, 0, 2, 2), env(1, -1, 3, 3), true}, - {env(-1, 0, 2, 1), env(0, -1, 1, 2), true}, - {env(0, 0, 1, 1), env(-1, -1, 2, 2), true}, - {env(0, 0, 1, 1), env(1, 0, 2, 1), true}, - {env(0, 0, 1, 1), env(0, 1, 1, 2), true}, - {env(0, 0, 1, 1), env(2, 0, 3, 1), false}, - {env(0, 0, 1, 1), env(0, 2, 1, 3), false}, - {env(0, 0, 1, 1), env(2, -1, 3, 2), false}, - {env(0, 0, 1, 1), env(-1, -2, 2, -1), false}, + { + description: "empty", + env: Envelope{}, + isEmpty: true, + isPoint: false, + isLine: false, + isRect: false, + area: 0, + width: 0, + height: 0, + center: "POINT EMPTY", + min: "POINT EMPTY", + max: "POINT EMPTY", + geom: "GEOMETRYCOLLECTION EMPTY", + }, + { + description: "single point", + env: onePtEnv(1, 2), + isEmpty: false, + isPoint: true, + isLine: false, + isRect: false, + area: 0, + width: 0, + height: 0, + center: "POINT(1 2)", + min: "POINT(1 2)", + max: "POINT(1 2)", + geom: "POINT(1 2)", + }, + { + description: "two horizontal points", + env: twoPtEnv(1, 4, 3, 4), + isEmpty: false, + isPoint: false, + isLine: true, + isRect: false, + area: 0, + width: 2, + height: 0, + center: "POINT(2 4)", + min: "POINT(1 4)", + max: "POINT(3 4)", + geom: "LINESTRING(1 4,3 4)", + }, + { + description: "two vertical points", + env: twoPtEnv(4, 1, 4, 3), + isEmpty: false, + isPoint: false, + isLine: true, + isRect: false, + area: 0, + width: 0, + height: 2, + center: "POINT(4 2)", + min: "POINT(4 1)", + max: "POINT(4 3)", + geom: "LINESTRING(4 1,4 3)", + }, + { + description: "two diagonal points", + env: twoPtEnv(1, 4, 3, 7), + isEmpty: false, + isPoint: false, + isLine: false, + isRect: true, + area: 6, + width: 2, + height: 3, + center: "POINT(2 5.5)", + min: "POINT(1 4)", + max: "POINT(3 7)", + geom: "POLYGON((1 4,3 4,3 7,1 7,1 4))", + }, } { - t.Run(strconv.Itoa(i), func(t *testing.T) { - got1 := tt.e1.Intersects(tt.e2) - got2 := tt.e2.Intersects(tt.e1) - if got1 != tt.want || got2 != tt.want { - t.Logf("env1: %v", tt.e1) - t.Logf("env2: %v", tt.e2) - t.Errorf("want=%v got1=%v", tt.want, got1) - t.Errorf("want=%v got2=%v", tt.want, got2) - } + t.Run(tc.description, func(t *testing.T) { + t.Run("IsEmpty", func(t *testing.T) { + expectBoolEq(t, tc.env.IsEmpty(), tc.isEmpty) + }) + t.Run("IsPoint", func(t *testing.T) { + expectBoolEq(t, tc.env.IsPoint(), tc.isPoint) + }) + t.Run("IsLine", func(t *testing.T) { + expectBoolEq(t, tc.env.IsLine(), tc.isLine) + }) + t.Run("IsRectangle", func(t *testing.T) { + expectBoolEq(t, tc.env.IsRectangle(), tc.isRect) + }) + t.Run("Area", func(t *testing.T) { + expectFloat64Eq(t, tc.env.Area(), tc.area) + }) + t.Run("Width", func(t *testing.T) { + expectFloat64Eq(t, tc.env.Width(), tc.width) + }) + t.Run("Height", func(t *testing.T) { + expectFloat64Eq(t, tc.env.Height(), tc.height) + }) + t.Run("Center", func(t *testing.T) { + expectGeomEqWKT(t, tc.env.Center().AsGeometry(), tc.center) + }) + t.Run("Min", func(t *testing.T) { + expectGeomEqWKT(t, tc.env.Min().AsGeometry(), tc.min) + }) + t.Run("Max", func(t *testing.T) { + expectGeomEqWKT(t, tc.env.Max().AsGeometry(), tc.max) + }) + t.Run("MinMaxXYs", func(t *testing.T) { + gotMin, gotMax, gotOK := tc.env.MinMaxXYs() + expectBoolEq(t, gotOK, !tc.isEmpty) + if gotOK { + wantMin, minOK := geomFromWKT(t, tc.min).AsPoint().XY() + expectTrue(t, minOK) + expectXYEq(t, gotMin, wantMin) + + wantMax, maxOK := geomFromWKT(t, tc.max).AsPoint().XY() + expectTrue(t, maxOK) + expectXYEq(t, gotMax, wantMax) + } + }) + t.Run("AsGeometry", func(t *testing.T) { + expectGeomEqWKT(t, tc.env.AsGeometry(), tc.geom, IgnoreOrder) + }) }) } } -func TestEnvelopeCenter(t *testing.T) { - for i, tt := range []struct { - env Envelope - want XY +func TestEnvelopeExtendToIncludeXY(t *testing.T) { + t.Run("empty", func(t *testing.T) { + env, err := Envelope{}.ExtendToIncludeXY(XY{1, 2}) + expectNoErr(t, err) + expectGeomEqWKT(t, env.Min().AsGeometry(), "POINT(1 2)") + expectGeomEqWKT(t, env.Max().AsGeometry(), "POINT(1 2)") + }) + t.Run("single point extend to same", func(t *testing.T) { + env, err := onePtEnv(1, 2).ExtendToIncludeXY(XY{1, 2}) + expectNoErr(t, err) + expectGeomEqWKT(t, env.Min().AsGeometry(), "POINT(1 2)") + expectGeomEqWKT(t, env.Max().AsGeometry(), "POINT(1 2)") + }) + t.Run("single point extend to different", func(t *testing.T) { + env, err := onePtEnv(1, 2).ExtendToIncludeXY(XY{-1, 3}) + expectNoErr(t, err) + expectGeomEqWKT(t, env.Min().AsGeometry(), "POINT(-1 2)") + expectGeomEqWKT(t, env.Max().AsGeometry(), "POINT(1 3)") + }) + t.Run("area extend within", func(t *testing.T) { + env, err := twoPtEnv(1, 2, 3, 4).ExtendToIncludeXY(XY{2, 3}) + expectNoErr(t, err) + expectGeomEqWKT(t, env.Min().AsGeometry(), "POINT(1 2)") + expectGeomEqWKT(t, env.Max().AsGeometry(), "POINT(3 4)") + }) + t.Run("area extend outside", func(t *testing.T) { + env, err := twoPtEnv(1, 2, 3, 4).ExtendToIncludeXY(XY{100, 200}) + expectNoErr(t, err) + expectGeomEqWKT(t, env.Min().AsGeometry(), "POINT(1 2)") + expectGeomEqWKT(t, env.Max().AsGeometry(), "POINT(100 200)") + }) +} + +func TestEnvelopeContains(t *testing.T) { + for _, tc := range []struct { + env Envelope + subtests map[XY]bool }{ - {env(2, 6, 1, 5), XY{1.5, 5.5}}, - {env(4, 1, 4, -2), XY{4, -0.5}}, - {env(-3, 10, -3, 10), XY{-3, 10}}, + { + env: Envelope{}, + subtests: map[XY]bool{ + {}: false, + {1, 2}: false, + }, + }, + { + env: onePtEnv(1, 2), + subtests: map[XY]bool{ + {}: false, + {1, 2}: true, + {3, 1}: false, + }, + }, + { + env: twoPtEnv(1, 2, 4, 5), + subtests: func() map[XY]bool { + m := map[XY]bool{} + for x := 0; x <= 5; x++ { + for y := 1; y <= 6; y++ { + m[XY{float64(x), float64(y)}] = x >= 1 && x <= 4 && y >= 2 && y <= 5 + } + } + return m + }(), + }, } { - t.Run(strconv.Itoa(i), func(t *testing.T) { - got := tt.env.Center() - if got != tt.want { - t.Errorf("got=%v want=%v", got, tt.want) + t.Run(fmt.Sprintf("env %v", tc.env.AsGeometry().AsText()), func(t *testing.T) { + for xy, want := range tc.subtests { + t.Run(fmt.Sprintf("xy %v want %v", xy, want), func(t *testing.T) { + got := tc.env.Contains(xy) + expectBoolEq(t, got, want) + }) } }) } } -func TestEnvelopeCovers(t *testing.T) { - for i, tt := range []struct { - env1, env2 Envelope - want bool +func TestEnvelopeExpandToIncludeEnvelope(t *testing.T) { + for _, tc := range []struct { + desc string + e1, e2 Envelope + want Envelope }{ - {env(0, 0, 1, 1), env(2, 0, 3, 1), false}, - {env(0, 0, 2, 2), env(1, 1, 3, 3), false}, - {env(0, 0, 3, 3), env(1, 1, 2, 2), true}, - {env(0, 0, 2, 2), env(1, 1, 2, 2), true}, - {env(1, 1, 2, 2), env(0, 0, 3, 3), false}, - {env(1, 1, 2, 2), env(0, 0, 2, 2), false}, + { + desc: "empty and empty", + e1: Envelope{}, + e2: Envelope{}, + want: Envelope{}, + }, + { + desc: "point and empty", + e1: onePtEnv(1, 2), + e2: Envelope{}, + want: onePtEnv(1, 2), + }, + { + desc: "rect and empty", + e1: twoPtEnv(1, 1, 2, 2), + e2: Envelope{}, + want: twoPtEnv(1, 1, 2, 2), + }, + { + desc: "same point", + e1: onePtEnv(1, 2), + e2: onePtEnv(1, 2), + want: onePtEnv(1, 2), + }, + { + desc: "same rect", + e1: twoPtEnv(1, 1, 2, 2), + e2: twoPtEnv(1, 1, 2, 2), + want: twoPtEnv(1, 1, 2, 2), + }, + { + desc: "point and point", + e1: onePtEnv(1, 2), + e2: onePtEnv(-1, 3), + want: twoPtEnv(-1, 2, 1, 3), + }, + { + desc: "point and rect", + e1: twoPtEnv(1, 1, 2, 2), + e2: onePtEnv(3, 1), + want: twoPtEnv(1, 1, 3, 2), + }, + { + desc: "rect inside other", + e1: twoPtEnv(1, 11, 4, 14), + e2: twoPtEnv(2, 12, 3, 13), + want: twoPtEnv(1, 11, 4, 14), + }, + { + desc: "rect overlapping corner", + e1: twoPtEnv(1, 11, 3, 13), + e2: twoPtEnv(2, 12, 4, 14), + want: twoPtEnv(1, 11, 4, 14), + }, } { - t.Run(strconv.Itoa(i), func(t *testing.T) { - got := tt.env1.Covers(tt.env2) - if got != tt.want { - t.Errorf("got=%v want=%v", got, tt.want) - } + t.Run(tc.desc+" fwd", func(t *testing.T) { + got := tc.e1.ExpandToIncludeEnvelope(tc.e2) + expectEnvEq(t, got, tc.want) + }) + t.Run(tc.desc+" rev", func(t *testing.T) { + got := tc.e2.ExpandToIncludeEnvelope(tc.e1) + expectEnvEq(t, got, tc.want) }) } } -func TestEnvelopeWidthHeightArea(t *testing.T) { - for i, tt := range []struct { - env Envelope - w, h, a float64 - }{ - {env(0, 1, 7, 4), 7, 3, 21}, - {env(4, 6, 4, 2), 0, 4, 0}, - {env(6, 4, 2, 4), 4, 0, 0}, +func TestEnvelopeInvalidXYInteractions(t *testing.T) { + nan := math.NaN() + inf := math.Inf(+1) + for i, tc := range []XY{ + {0, nan}, + {nan, 0}, + {nan, nan}, + {0, inf}, + {inf, 0}, + {inf, inf}, + {0, -inf}, + {-inf, 0}, + {-inf, -inf}, } { - t.Run("w"+strconv.Itoa(i), func(t *testing.T) { - if got := tt.env.Width(); got != tt.w { - t.Errorf("got=%v want=%v", got, tt.w) - } + t.Run(fmt.Sprintf("new_envelope_with_first_arg_invalid_%d", i), func(t *testing.T) { + _, err := NewEnvelope([]XY{tc}) + expectErr(t, err) }) - t.Run("h"+strconv.Itoa(i), func(t *testing.T) { - if got := tt.env.Height(); got != tt.h { - t.Errorf("got=%v want=%v", got, tt.h) - } + t.Run(fmt.Sprintf("new_envelope_with_second_arg_invalid_%d", i), func(t *testing.T) { + _, err := NewEnvelope([]XY{{}, tc}) + expectErr(t, err) }) - t.Run("a"+strconv.Itoa(i), func(t *testing.T) { - if got := tt.env.Area(); got != tt.a { - t.Errorf("got=%v want=%v", got, tt.a) - } + t.Run(fmt.Sprintf("extend_to_include_invalid_xy_%d", i), func(t *testing.T) { + env, err := NewEnvelope([]XY{{-1, -1}, {1, 1}}) + expectNoErr(t, err) + env, err = env.ExtendToIncludeXY(tc) + expectErr(t, err) + }) + t.Run(fmt.Sprintf("contains_invalid_xy_%d", i), func(t *testing.T) { + env, err := NewEnvelope([]XY{{-1, -1}, {1, 1}}) + expectNoErr(t, err) + expectFalse(t, env.Contains(tc)) }) } } -func TestEnvelopeExpandBy(t *testing.T) { +func TestEnvelopeIntersects(t *testing.T) { for i, tt := range []struct { - in Envelope - x, y float64 - wantOK bool - wantEnv Envelope + e1, e2 Envelope + want bool }{ - {env(4, 5, 4, 5), 1.5, 3.5, true, env(2.5, 1.5, 5.5, 8.5)}, - {env(0, 0, 1, 2), -0.5, -1.0, true, env(0.5, 1.0, 0.5, 1.0)}, - {env(0, 0, 1, 2), -0.5, -1.1, false, Envelope{}}, - {env(0, 0, 1, 2), -0.6, -1.0, false, Envelope{}}, + // Empty vs empty. + {Envelope{}, Envelope{}, false}, + + // Empty vs non-empty. + {Envelope{}, onePtEnv(0, 0), false}, + {Envelope{}, twoPtEnv(0, 0, 1, 1), false}, + + // Single pt vs single pt. + {onePtEnv(0, 0), onePtEnv(0, 0), true}, + {onePtEnv(1, 2), onePtEnv(1, 2), true}, + {onePtEnv(1, 2), onePtEnv(1, 3), false}, + {onePtEnv(1, 2), onePtEnv(2, 2), false}, + + // Single pt vs rect. + {onePtEnv(0, 0), twoPtEnv(0, 0, 1, 1), true}, + {onePtEnv(1, 1), twoPtEnv(0, 0, 1, 1), true}, + {onePtEnv(0, 1), twoPtEnv(0, 0, 1, 1), true}, + {onePtEnv(1, 0), twoPtEnv(0, 0, 1, 1), true}, + {onePtEnv(0.5, 0.5), twoPtEnv(0, 0, 1, 1), true}, + {onePtEnv(0.5, 1.5), twoPtEnv(0, 0, 1, 1), false}, + + // Rect vs Rect. + {twoPtEnv(0, 0, 1, 1), twoPtEnv(2, 2, 3, 3), false}, + {twoPtEnv(0, 2, 1, 3), twoPtEnv(2, 0, 3, 1), false}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(1, 1, 2, 2), true}, + {twoPtEnv(0, 1, 1, 2), twoPtEnv(1, 0, 2, 1), true}, + {twoPtEnv(0, 0, 2, 2), twoPtEnv(1, 1, 3, 3), true}, + {twoPtEnv(0, 1, 2, 3), twoPtEnv(1, 0, 3, 2), true}, + {twoPtEnv(0, 0, 2, 1), twoPtEnv(1, 0, 3, 1), true}, + {twoPtEnv(0, 0, 1, 2), twoPtEnv(0, 1, 1, 3), true}, + {twoPtEnv(0, 0, 2, 2), twoPtEnv(1, -1, 3, 3), true}, + {twoPtEnv(0, 0, 2, 2), twoPtEnv(1, -1, 3, 3), true}, + {twoPtEnv(-1, 0, 2, 1), twoPtEnv(0, -1, 1, 2), true}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(-1, -1, 2, 2), true}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(1, 0, 2, 1), true}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(0, 1, 1, 2), true}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(2, 0, 3, 1), false}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(0, 2, 1, 3), false}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(2, -1, 3, 2), false}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(-1, -2, 2, -1), false}, } { t.Run(strconv.Itoa(i), func(t *testing.T) { - got, ok := tt.in.ExpandBy(tt.x, tt.y) - if ok != tt.wantOK { - t.Fatalf("got=%v want=%v", ok, tt.wantOK) - } - if ok && got != tt.wantEnv { - t.Errorf("got=%v want=%v", got, tt.wantEnv) + got1 := tt.e1.Intersects(tt.e2) + got2 := tt.e2.Intersects(tt.e1) + if got1 != tt.want || got2 != tt.want { + t.Logf("env1: %v", tt.e1) + t.Logf("env2: %v", tt.e2) + t.Errorf("want=%v got1=%v", tt.want, got1) + t.Errorf("want=%v got2=%v", tt.want, got2) } }) } } -func TestEnvelopeDistance(t *testing.T) { +func TestEnvelopeCovers(t *testing.T) { for i, tt := range []struct { env1, env2 Envelope - want float64 + want bool }{ - {env(0, 0, 2, 2), env(1, 1, 3, 3), 0}, - {env(0, 0, 1, 1), env(2, 0, 2, 1), 1}, - {env(0, 0, 1, 1), env(0, 3, 1, 4), 2}, - {env(0, 0, 1, 1), env(2, 2, 3, 3), math.Sqrt(2)}, - {env(0, 2, 1, 3), env(2, 0, 3, 1), math.Sqrt(2)}, - {env(0, 0, 1, 1), env(1, 1, 2, 2), 0}, - {env(0, 1, 1, 2), env(1, 0, 2, 1), 0}, - {env(0, 0, 1, 1), env(1, 0, 2, 1), 0}, - {env(0, 0, 1, 1), env(0, 1, 1, 2), 0}, + // Empty vs empty. + {Envelope{}, Envelope{}, false}, + + // Empty vs single pt. + {Envelope{}, onePtEnv(1, 2), false}, + {onePtEnv(1, 2), Envelope{}, false}, + {Envelope{}, onePtEnv(0, 0), false}, + {onePtEnv(0, 0), Envelope{}, false}, + + // Empty vs rect. + {Envelope{}, twoPtEnv(1, 2, 3, 4), false}, + {twoPtEnv(1, 2, 3, 4), Envelope{}, false}, + + // Single pt vs single pt. + {onePtEnv(1, 2), onePtEnv(1, 2), true}, + {onePtEnv(1, 2), onePtEnv(3, 2), false}, + {onePtEnv(1, 2), onePtEnv(1, 3), false}, + {onePtEnv(1, 2), onePtEnv(3, 3), false}, + + // Single pt vs single rect. + {onePtEnv(1, 2), twoPtEnv(1, 2, 3, 4), false}, + {onePtEnv(1, 2), twoPtEnv(0, 0, 3, 3), false}, + {twoPtEnv(0, 0, 3, 3), onePtEnv(1, 2), true}, + {twoPtEnv(0, 0, 3, 3), onePtEnv(0, 0), true}, + {twoPtEnv(0, 0, 3, 3), onePtEnv(3, 3), true}, + {twoPtEnv(0, 0, 3, 3), onePtEnv(0, 3), true}, + {twoPtEnv(0, 0, 3, 3), onePtEnv(3, 4), false}, + {twoPtEnv(0, 0, 3, 3), onePtEnv(4, 3), false}, + + // Rect vs Rect + {twoPtEnv(0, 0, 1, 1), twoPtEnv(2, 0, 3, 1), false}, + {twoPtEnv(0, 0, 2, 2), twoPtEnv(1, 1, 3, 3), false}, + {twoPtEnv(0, 0, 3, 3), twoPtEnv(1, 1, 2, 2), true}, + {twoPtEnv(0, 0, 2, 2), twoPtEnv(1, 1, 2, 2), true}, + {twoPtEnv(1, 1, 2, 2), twoPtEnv(0, 0, 3, 3), false}, + {twoPtEnv(1, 1, 2, 2), twoPtEnv(0, 0, 2, 2), false}, } { t.Run(strconv.Itoa(i), func(t *testing.T) { - got1 := tt.env1.Distance(tt.env2) - got2 := tt.env2.Distance(tt.env1) - if got1 != tt.want || got2 != tt.want { - t.Errorf("got1=%v got2=%v want=%v", got1, got2, tt.want) + got := tt.env1.Covers(tt.env2) + if got != tt.want { + t.Errorf("got=%v want=%v", got, tt.want) } }) } } + +func TestEnvelopeDistance(t *testing.T) { + t.Run("empty", func(t *testing.T) { + t.Run("both", func(t *testing.T) { + _, ok := Envelope{}.Distance(Envelope{}) + expectFalse(t, ok) + }) + t.Run("only one", func(t *testing.T) { + _, ok := Envelope{}.Distance(onePtEnv(1, 2)) + expectFalse(t, ok) + _, ok = onePtEnv(1, 2).Distance(Envelope{}) + expectFalse(t, ok) + }) + }) + t.Run("non-empty", func(t *testing.T) { + for i, tt := range []struct { + env1, env2 Envelope + want float64 + }{ + // Pt vs pt. + {onePtEnv(3, 0), onePtEnv(4, 0), 1}, + {onePtEnv(3, 0), onePtEnv(3, 1), 1}, + {onePtEnv(3, 0), onePtEnv(4, 1), math.Sqrt(2)}, + + // Pt vs rect. + {onePtEnv(2, 1), twoPtEnv(1, 2, 3, 4), 1}, + {onePtEnv(2, 1), twoPtEnv(2, 2, 3, 3), 1}, + {onePtEnv(2, 1), twoPtEnv(3, 2, 4, 3), math.Sqrt(2)}, + + // Rect vs rect. + {twoPtEnv(0, 0, 2, 2), twoPtEnv(1, 1, 3, 3), 0}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(2, 0, 2, 1), 1}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(0, 3, 1, 4), 2}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(2, 2, 3, 3), math.Sqrt(2)}, + {twoPtEnv(0, 2, 1, 3), twoPtEnv(2, 0, 3, 1), math.Sqrt(2)}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(1, 1, 2, 2), 0}, + {twoPtEnv(0, 1, 1, 2), twoPtEnv(1, 0, 2, 1), 0}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(1, 0, 2, 1), 0}, + {twoPtEnv(0, 0, 1, 1), twoPtEnv(0, 1, 1, 2), 0}, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + got1, ok1 := tt.env1.Distance(tt.env2) + got2, ok2 := tt.env2.Distance(tt.env1) + expectTrue(t, ok1) + expectTrue(t, ok2) + expectFloat64Eq(t, got1, got2) + expectFloat64Eq(t, got1, tt.want) + }) + } + }) +} diff --git a/geom/type_geometry.go b/geom/type_geometry.go index 8e48fd24..3e0c1579 100644 --- a/geom/type_geometry.go +++ b/geom/type_geometry.go @@ -382,9 +382,8 @@ func (g Geometry) IsEmpty() bool { } // Envelope returns the axis aligned bounding box that most tightly surrounds -// the geometry. Envelopes are not defined for empty geometries, in which case -// the returned flag will be false. -func (g Geometry) Envelope() (Envelope, bool) { +// the geometry. +func (g Geometry) Envelope() Envelope { switch g.gtype { case TypeGeometryCollection: return g.AsGeometryCollection().Envelope() @@ -767,3 +766,53 @@ func (g Geometry) appendDump(gs []Geometry) []Geometry { } return gs } + +// DumpCoordinates returns the control points making up the geometry as a +// Sequence. +func (g Geometry) DumpCoordinates() Sequence { + switch g.gtype { + case TypeGeometryCollection: + return g.AsGeometryCollection().DumpCoordinates() + case TypePoint: + return g.AsPoint().DumpCoordinates() + case TypeLineString: + return g.AsLineString().Coordinates() + case TypePolygon: + return g.AsPolygon().DumpCoordinates() + case TypeMultiPoint: + return g.AsMultiPoint().Coordinates() + case TypeMultiLineString: + return g.AsMultiLineString().DumpCoordinates() + case TypeMultiPolygon: + return g.AsMultiPolygon().DumpCoordinates() + default: + panic("unknown type: " + g.Type().String()) + } +} + +// Summary returns a text summary of the Geometry following a similar format to https://postgis.net/docs/ST_Summary.html. +func (g Geometry) Summary() string { + switch g.gtype { + case TypeGeometryCollection: + return g.AsGeometryCollection().Summary() + case TypePoint: + return g.AsPoint().Summary() + case TypeLineString: + return g.AsLineString().Summary() + case TypePolygon: + return g.AsPolygon().Summary() + case TypeMultiPoint: + return g.AsMultiPoint().Summary() + case TypeMultiLineString: + return g.AsMultiLineString().Summary() + case TypeMultiPolygon: + return g.AsMultiPolygon().Summary() + default: + panic("unknown type: " + g.Type().String()) + } +} + +// String returns the string representation of the Geometry. +func (g Geometry) String() string { + return g.Summary() +} diff --git a/geom/type_geometry_collection.go b/geom/type_geometry_collection.go index 64ceb41a..6f148863 100644 --- a/geom/type_geometry_collection.go +++ b/geom/type_geometry_collection.go @@ -3,6 +3,7 @@ package geom import ( "database/sql/driver" "encoding/json" + "fmt" "unsafe" ) @@ -10,6 +11,7 @@ import ( // value is the empty GeometryCollection (i.e. a collection of zero // geometries). type GeometryCollection struct { + // Invariant: ctype matches the coordinates type of each geometry. geoms []Geometry ctype CoordinatesType } @@ -43,11 +45,23 @@ func (c GeometryCollection) AsGeometry() Geometry { return Geometry{TypeGeometryCollection, unsafe.Pointer(&c)} } -// NumGeometries gives the number of Geomety elements is the GeometryCollection. +// NumGeometries gives the number of Geometry elements in the GeometryCollection. func (c GeometryCollection) NumGeometries() int { return len(c.geoms) } +// NumTotalGeometries gives the total number of Geometry elements in the GeometryCollection. +// If there are GeometryCollection-type child geometries, this will recursively count its children. +func (c GeometryCollection) NumTotalGeometries() int { + var n int + for _, geom := range c.geoms { + if geom.IsGeometryCollection() { + n += geom.AsGeometryCollection().NumTotalGeometries() + } + } + return n + c.NumGeometries() +} + // GeometryN gives the nth (zero based) Geometry in the GeometryCollection. func (c GeometryCollection) GeometryN(n int) Geometry { return c.geoms[n] @@ -110,18 +124,13 @@ func (c GeometryCollection) walk(fn func(Geometry)) { } } -func (c GeometryCollection) flatten() []Geometry { - var geoms []Geometry - c.walk(func(g Geometry) { - geoms = append(geoms, g) - }) - return geoms -} - -// Envelope returns the Envelope that most tightly surrounds the geometry. If -// the geometry is empty, then false is returned. -func (c GeometryCollection) Envelope() (Envelope, bool) { - return EnvelopeFromGeoms(c.flatten()...) +// Envelope returns the Envelope that most tightly surrounds the geometry. +func (c GeometryCollection) Envelope() Envelope { + var env Envelope + for _, g := range c.geoms { + env = env.ExpandToIncludeEnvelope(g.Envelope()) + } + return env } // Boundary returns the spatial boundary of this GeometryCollection. This is @@ -311,7 +320,7 @@ func (c GeometryCollection) pointCentroid() Point { } } }) - return NewPointFromXY(sumPoints.Scale(1 / float64(numPoints))) + return sumPoints.Scale(1 / float64(numPoints)).asUncheckedPoint() } func (c GeometryCollection) linearCentroid() Point { @@ -342,7 +351,7 @@ func (c GeometryCollection) linearCentroid() Point { } } }) - return NewPointFromXY(weightedCentroid.Scale(1 / lengthSum)) + return weightedCentroid.Scale(1 / lengthSum).asUncheckedPoint() } func (c GeometryCollection) arealCentroid() Point { @@ -365,7 +374,7 @@ func (c GeometryCollection) arealCentroid() Point { centroid.Scale(area / areaSum)) } }) - return NewPointFromXY(weightedCentroid) + return weightedCentroid.asUncheckedPoint() } // CoordinatesType returns the CoordinatesType used to represent points making @@ -448,3 +457,36 @@ func (c GeometryCollection) Dump() []Geometry { } return gs } + +// DumpCoordinates returns a Sequence holding all control points in the +// GeometryCollection. +func (c GeometryCollection) DumpCoordinates() Sequence { + var coords []float64 + for _, g := range c.geoms { + coords = g.DumpCoordinates().appendAllPoints(coords) + } + return NewSequence(coords, c.ctype) +} + +// Summary returns a text summary of the GeometryCollection following a similar format to https://postgis.net/docs/ST_Summary.html. +func (c GeometryCollection) Summary() string { + var pointSuffix string + numPoints := c.DumpCoordinates().Length() + if numPoints != 1 { + pointSuffix = "s" + } + + geometrySuffix := "y" + numGeometries := c.NumTotalGeometries() + if numGeometries != 1 { + geometrySuffix = "ies" + } + + return fmt.Sprintf("%s[%s] with %d child geometr%s consisting of %d total point%s", + c.Type(), c.CoordinatesType(), numGeometries, geometrySuffix, numPoints, pointSuffix) +} + +// String returns the string representation of the GeometryCollection. +func (c GeometryCollection) String() string { + return c.Summary() +} diff --git a/geom/type_geometry_test.go b/geom/type_geometry_test.go index 4e76bb3a..31612a9c 100644 --- a/geom/type_geometry_test.go +++ b/geom/type_geometry_test.go @@ -21,7 +21,9 @@ func TestZeroGeometry(t *testing.T) { expectNoErr(t, err) expectStringEq(t, strings.TrimSpace(buf.String()), `{"type":"GeometryCollection","geometries":[]}`) - z = NewPointFromXY(XY{1, 2}).AsGeometry() // Set away from zero value + pt, err := XY{1, 2}.AsPoint() + expectNoErr(t, err) + z = pt.AsGeometry() // Set away from zero value expectBoolEq(t, z.IsPoint(), true) err = json.NewDecoder(&buf).Decode(&z) expectNoErr(t, err) diff --git a/geom/type_line_string.go b/geom/type_line_string.go index 3a26384b..fbe086f2 100644 --- a/geom/type_line_string.go +++ b/geom/type_line_string.go @@ -2,6 +2,7 @@ package geom import ( "database/sql/driver" + "fmt" "math" "unsafe" @@ -29,16 +30,23 @@ func NewLineString(seq Sequence, opts ...ConstructorOption) (LineString, error) } // Valid non-empty LineStrings must have at least 2 *distinct* points. - if hasAtLeast2DistinctPointsInSeq(seq) { - return LineString{seq}, nil + if !hasAtLeast2DistinctPointsInSeq(seq) { + if ctorOpts.omitInvalid { + return LineString{}, nil + } + return LineString{}, validationError{ + "non-empty linestring contains only one distinct XY value"} } - if ctorOpts.omitInvalid { - return LineString{}, nil + // All XY values must be valid. + if err := seq.validate(); err != nil { + if ctorOpts.omitInvalid { + return LineString{}, nil + } + return LineString{}, validationError{err.Error()} } - return LineString{}, validationError{ - "non-empty linestring contains only one distinct XY value"} + return LineString{seq}, nil } func hasAtLeast2DistinctPointsInSeq(seq Sequence) bool { @@ -71,7 +79,8 @@ func (s LineString) StartPoint() Point { if s.IsEmpty() { return NewEmptyPoint(s.CoordinatesType()) } - return NewPoint(s.seq.Get(0)) + c := s.seq.Get(0) + return newUncheckedPoint(c) } // EndPoint gives the last point of the LineString. If the LineString is empty @@ -80,7 +89,9 @@ func (s LineString) EndPoint() Point { if s.IsEmpty() { return NewEmptyPoint(s.CoordinatesType()) } - return NewPoint(s.seq.Get(s.seq.Length() - 1)) + end := s.seq.Length() - 1 + c := s.seq.Get(end) + return newUncheckedPoint(c) } // AsText returns the WKT (Well Known Text) representation of this geometry. @@ -99,7 +110,7 @@ func (s LineString) appendWKTBody(dst []byte) []byte { if s.IsEmpty() { return appendWKTEmpty(dst) } - return appendWKTSequence(dst, s.seq, false, BitSet{}) + return appendWKTSequence(dst, s.seq, false) } // IsSimple returns true if this geometry contains no anomalous geometry @@ -119,7 +130,7 @@ func (s LineString) IsSimple() bool { if !ok { continue } - items = append(items, rtree.BulkItem{ln.envelope().box(), i}) + items = append(items, rtree.BulkItem{Box: ln.box(), RecordID: i}) } tree := rtree.BulkLoad(items) @@ -139,7 +150,7 @@ func (s LineString) IsSimple() bool { } simple := true // assume simple until proven otherwise - tree.RangeSearch(ln.envelope().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 { @@ -207,18 +218,14 @@ func (s LineString) IsEmpty() bool { return s.seq.Length() == 0 } -// Envelope returns the Envelope that most tightly surrounds the geometry. If -// the geometry is empty, then false is returned. -func (s LineString) Envelope() (Envelope, bool) { +// Envelope returns the Envelope that most tightly surrounds the geometry. +func (s LineString) Envelope() Envelope { + var env Envelope n := s.seq.Length() - if n == 0 { - return Envelope{}, false - } - env := NewEnvelope(s.seq.GetXY(0)) - for i := 1; i < n; i++ { - env = env.ExtendToIncludePoint(s.seq.GetXY(i)) + for i := 0; i < n; i++ { + env = env.uncheckedExtend(s.seq.GetXY(i)) } - return env, true + return env } // Boundary returns the spatial boundary of this LineString. For closed @@ -229,13 +236,10 @@ func (s LineString) Boundary() MultiPoint { if s.IsEmpty() || s.IsClosed() { return MultiPoint{} } - first := s.seq.GetXY(0) - last := s.seq.GetXY(s.seq.Length() - 1) - fs := []float64{ - first.X, first.Y, - last.X, last.Y, - } - return NewMultiPoint(NewSequence(fs, DimXY)) + return NewMultiPoint([]Point{ + s.StartPoint(), + s.EndPoint(), + }) } // Value implements the database/sql/driver.Valuer interface by returning the @@ -283,7 +287,7 @@ func (s LineString) ConvexHull() Geometry { func (s LineString) MarshalJSON() ([]byte, error) { var dst []byte dst = append(dst, `{"type":"LineString","coordinates":`...) - dst = appendGeoJSONSequence(dst, s.seq, BitSet{}) + dst = appendGeoJSONSequence(dst, s.seq) dst = append(dst, '}') return dst, nil } @@ -325,7 +329,7 @@ func (s LineString) Centroid() Point { if sumLength == 0 { return NewEmptyPoint(DimXY) } - return NewPointFromXY(sumXY.Scale(1.0 / sumLength)) + return sumXY.Scale(1.0 / sumLength).asUncheckedPoint() } func sumCentroidAndLengthOfLineString(s LineString) (sumXY XY, sumLength float64) { @@ -346,7 +350,7 @@ func sumCentroidAndLengthOfLineString(s LineString) (sumXY XY, sumLength float64 // AsMultiLineString is a convenience function that converts this LineString // into a MultiLineString. func (s LineString) AsMultiLineString() MultiLineString { - return NewMultiLineStringFromLineStrings([]LineString{s}) + return NewMultiLineString([]LineString{s}) } // Reverse in the case of LineString outputs the coordinates in reverse order. @@ -390,7 +394,7 @@ func (s LineString) PointOnSurface() Point { n := s.seq.Length() nearest := newNearestPointAccumulator(s.Centroid()) for i := 1; i < n-1; i++ { - candidate := NewPointFromXY(s.seq.GetXY(i)) + candidate := s.seq.GetXY(i).asUncheckedPoint() nearest.consider(candidate) } if !nearest.point.IsEmpty() { @@ -401,5 +405,14 @@ func (s LineString) PointOnSurface() Point { nearest.consider(s.StartPoint().Force2D()) nearest.consider(s.EndPoint().Force2D()) return nearest.point +} + +// Summary returns a text summary of the LineString following a similar format to https://postgis.net/docs/ST_Summary.html. +func (s LineString) Summary() string { + return fmt.Sprintf("%s[%s] with %d points", s.Type(), s.CoordinatesType(), s.Coordinates().Length()) +} +// String returns the string representation of the LineString. +func (s LineString) String() string { + return s.Summary() } diff --git a/geom/type_multi_line_string.go b/geom/type_multi_line_string.go index 98631296..7d590624 100644 --- a/geom/type_multi_line_string.go +++ b/geom/type_multi_line_string.go @@ -2,6 +2,7 @@ package geom import ( "database/sql/driver" + "fmt" "unsafe" "github.com/peterstace/simplefeatures/rtree" @@ -12,14 +13,15 @@ import ( // collection of zero LineStrings) of 2D coordinate type. It is immutable after // creation. type MultiLineString struct { + // Invariant: ctype matches the coordinates type of each line. lines []LineString ctype CoordinatesType } -// NewMultiLineStringFromLineStrings creates a MultiLineString from its -// constituent LineStrings. The coordinates type of the MultiLineString is the -// lowest common coordinates type of its LineStrings. -func NewMultiLineStringFromLineStrings(lines []LineString, opts ...ConstructorOption) MultiLineString { +// NewMultiLineString creates a MultiLineString from its constituent +// LineStrings. The coordinates type of the MultiLineString is the lowest +// common coordinates type of its LineStrings. +func NewMultiLineString(lines []LineString, opts ...ConstructorOption) MultiLineString { if len(lines) == 0 { return MultiLineString{} } @@ -120,8 +122,8 @@ func (m MultiLineString) IsSimple() bool { continue } items = append(items, rtree.BulkItem{ - ln.envelope().box(), - toRecordID(i, j), + Box: ln.box(), + RecordID: toRecordID(i, j), }) } } @@ -136,7 +138,7 @@ func (m MultiLineString) IsSimple() bool { continue } isSimple := true // assume simple until proven otherwise - tree.RangeSearch(ln.envelope().box(), func(recordID int) error { + tree.RangeSearch(ln.box(), func(recordID int) error { // Ignore the intersection if it's for the same LineString that we're currently looking up. lineStringIdx, seqIdx := fromRecordID(recordID) if lineStringIdx == i { @@ -165,7 +167,7 @@ func (m MultiLineString) IsSimple() bool { return rtree.Stop } boundary := intersectionOfMultiPointAndMultiPoint(ls.Boundary(), otherLS.Boundary()) - if !hasIntersectionPointWithMultiPoint(NewPointFromXY(inter.ptA), boundary) { + if !hasIntersectionPointWithMultiPoint(inter.ptA.asUncheckedPoint(), boundary) { isSimple = false return rtree.Stop } @@ -190,33 +192,22 @@ func (m MultiLineString) IsEmpty() bool { return true } -// Envelope returns the Envelope that most tightly surrounds the geometry. If -// the geometry is empty, then false is returned. -func (m MultiLineString) Envelope() (Envelope, bool) { +// Envelope returns the Envelope that most tightly surrounds the geometry. +func (m MultiLineString) Envelope() Envelope { var env Envelope - var has bool for _, ls := range m.lines { - e, ok := ls.Envelope() - if !ok { - continue - } - if has { - env = env.ExpandToIncludeEnvelope(e) - } else { - env = e - has = true - } + env = env.ExpandToIncludeEnvelope(ls.Envelope()) } - return env, has + return env } // Boundary returns the spatial boundary of this MultiLineString. This is // calculated using the "mod 2 rule". The rule states that a Point is included -// as part of the boundary if and only if it appears on the boundry of an odd +// as part of the boundary if and only if it appears on the boundary of an odd // number of members in the collection. func (m MultiLineString) Boundary() MultiPoint { counts := make(map[XY]int) - var uniqueEndpoints []XY + var uniqueEndpoints []Point for _, ls := range m.lines { if ls.IsClosed() { continue @@ -230,20 +221,24 @@ func (m MultiLineString) Boundary() MultiPoint { continue } if counts[xy] == 0 { - uniqueEndpoints = append(uniqueEndpoints, xy) + uniqueEndpoints = append(uniqueEndpoints, pt) } counts[xy]++ } } - var floats []float64 - for _, xy := range uniqueEndpoints { + var mod2Points []Point + for _, pt := range uniqueEndpoints { + xy, ok := pt.XY() + if !ok { + // Can't happen, because we already check to make sure pt is not empty. + panic("MultiLineString Boundary internal error") + } if counts[xy]%2 == 1 { - floats = append(floats, xy.X, xy.Y) + mod2Points = append(mod2Points, pt) } } - seq := NewSequence(floats, DimXY) - return NewMultiPoint(seq) + return NewMultiPoint(mod2Points) } // Value implements the database/sql/driver.Valuer interface by returning the @@ -301,7 +296,7 @@ func (m MultiLineString) MarshalJSON() ([]byte, error) { return dst, nil } -// Coordinates returns the coordinates of each constintuent LineString in the +// Coordinates returns the coordinates of each constituent LineString in the // MultiLineString. func (m MultiLineString) Coordinates() []Sequence { n := m.NumLineStrings() @@ -326,7 +321,7 @@ func (m MultiLineString) TransformXY(fn func(XY) XY, opts ...ConstructorOption) return MultiLineString{}, wrapTransformed(err) } } - return NewMultiLineStringFromLineStrings(transformed, opts...), nil + return NewMultiLineString(transformed, opts...), nil } // Length gives the sum of the lengths of the constituent members of the multi @@ -352,7 +347,7 @@ func (m MultiLineString) Centroid() Point { if sumLength == 0 { return NewEmptyPoint(DimXY) } - return NewPointFromXY(sumXY.Scale(1.0 / sumLength)) + return sumXY.Scale(1.0 / sumLength).asUncheckedPoint() } // Reverse in the case of MultiLineString outputs each component line string in their @@ -416,7 +411,7 @@ func (m MultiLineString) PointOnSurface() Point { seq := m.LineStringN(i).Coordinates() n := seq.Length() for j := 1; j < n-1; j++ { - candidate := NewPointFromXY(seq.GetXY(j)) + candidate := seq.GetXY(j).asUncheckedPoint() nearest.consider(candidate) } } @@ -447,3 +442,37 @@ func (m MultiLineString) Dump() []LineString { copy(lss, m.lines) return lss } + +// DumpCoordinates returns the coordinates (as a Sequence) that constitute the +// MultiLineString. +func (m MultiLineString) DumpCoordinates() Sequence { + var n int + for _, ls := range m.lines { + n += ls.seq.Length() * m.ctype.Dimension() + } + coords := make([]float64, 0, n) + for _, ls := range m.lines { + coords = ls.Coordinates().appendAllPoints(coords) + } + seq := NewSequence(coords, m.ctype) + seq.assertNoUnusedCapacity() + return seq +} + +// Summary returns a text summary of the MultiLineString following a similar format to https://postgis.net/docs/ST_Summary.html. +func (m MultiLineString) Summary() string { + numPoints := m.DumpCoordinates().Length() + + var lineStringSuffix string + numLineStrings := m.NumLineStrings() + if numLineStrings != 1 { + lineStringSuffix = "s" + } + return fmt.Sprintf("%s[%s] with %d linestring%s consisting of %d total points", + m.Type(), m.CoordinatesType(), numLineStrings, lineStringSuffix, numPoints) +} + +// String returns the string representation of the MultiLineString. +func (m MultiLineString) String() string { + return m.Summary() +} diff --git a/geom/type_multi_point.go b/geom/type_multi_point.go index 2f56cc7f..b9aa0abf 100644 --- a/geom/type_multi_point.go +++ b/geom/type_multi_point.go @@ -2,6 +2,7 @@ package geom import ( "database/sql/driver" + "fmt" "unsafe" ) @@ -9,14 +10,14 @@ import ( // zero value is the empty MultiPoint (i.e. a collection of zero points) with // 2D coordinates type. It is immutable after creation. type MultiPoint struct { - seq Sequence - empty BitSet + // Invariant: ctype matches the coordinates type of each point. + points []Point + ctype CoordinatesType } -// NewMultiPointFromPoints creates a MultiPoint from a list of Points. The -// coordinate type of the MultiPoint is the lowest common coordinates type of -// its Points. -func NewMultiPointFromPoints(pts []Point, opts ...ConstructorOption) MultiPoint { +// NewMultiPoint creates a MultiPoint from a list of Points. The coordinate +// type of the MultiPoint is the lowest common coordinates type of its Points. +func NewMultiPoint(pts []Point, opts ...ConstructorOption) MultiPoint { if len(pts) == 0 { return MultiPoint{} } @@ -26,39 +27,8 @@ func NewMultiPointFromPoints(pts []Point, opts ...ConstructorOption) MultiPoint ctype &= p.CoordinatesType() } - var empty BitSet - floats := make([]float64, 0, len(pts)*ctype.Dimension()) - for i, pt := range pts { - c, ok := pt.Coordinates() - if !ok { - empty.Set(i, true) - } - floats = append(floats, c.X, c.Y) - if ctype.Is3D() { - floats = append(floats, c.Z) - } - if ctype.IsMeasured() { - floats = append(floats, c.M) - } - } - seq := NewSequence(floats, ctype) - return NewMultiPointWithEmptyMask(seq, empty, opts...) -} - -// NewMultiPoint creates a new MultiPoint from a sequence of Coordinates. -func NewMultiPoint(seq Sequence, opts ...ConstructorOption) MultiPoint { - return MultiPoint{seq, BitSet{}} -} - -// NewMultiPointWithEmptyMask creates a new MultiPoint from a sequence of -// coordinates. If there are any positions set in the BitSet, then these are -// used to indicate that the corresponding point in the sequence is an empty -// point. -func NewMultiPointWithEmptyMask(seq Sequence, empty BitSet, opts ...ConstructorOption) MultiPoint { - return MultiPoint{ - seq, - empty.Clone(), // clone so that the caller doesn't have access to the internal empty set - } + forced := forceCoordinatesTypeOfPointSlice(pts, ctype) + return MultiPoint{forced, ctype} } // Type returns the GeometryType for a MultiPoint @@ -73,16 +43,12 @@ func (m MultiPoint) AsGeometry() Geometry { // NumPoints gives the number of element points making up the MultiPoint. func (m MultiPoint) NumPoints() int { - return m.seq.Length() + return len(m.points) } // PointN gives the nth (zero indexed) Point. func (m MultiPoint) PointN(n int) Point { - if m.empty.Get(n) { - return NewEmptyPoint(m.CoordinatesType()) - } - c := m.seq.Get(n) - return NewPoint(c) + return m.points[n] } // AsText returns the WKT (Well Known Text) representation of this geometry. @@ -94,10 +60,17 @@ func (m MultiPoint) AsText() string { // to the input byte slice. func (m MultiPoint) AppendWKT(dst []byte) []byte { dst = appendWKTHeader(dst, "MULTIPOINT", m.CoordinatesType()) - if m.NumPoints() == 0 { + if len(m.points) == 0 { return appendWKTEmpty(dst) } - return appendWKTSequence(dst, m.seq, true, m.empty) + dst = append(dst, '(') + for i, pt := range m.points { + if i > 0 { + dst = append(dst, ',') + } + dst = pt.appendWKTBody(dst) + } + return append(dst, ')') } // IsSimple returns true if this geometry contains no anomalous geometry @@ -121,32 +94,21 @@ func (m MultiPoint) IsSimple() bool { // IsEmpty return true if and only if this MultiPoint doesn't contain any // Points, or only contains empty Points. func (m MultiPoint) IsEmpty() bool { - for i := 0; i < m.NumPoints(); i++ { - if !m.empty.Get(i) { + for _, pt := range m.points { + if !pt.IsEmpty() { return false } } return true } -// Envelope returns the Envelope that most tightly surrounds the geometry. If -// the geometry is empty, then false is returned. -func (m MultiPoint) Envelope() (Envelope, bool) { - var has bool +// Envelope returns the Envelope that most tightly surrounds the geometry. +func (m MultiPoint) Envelope() Envelope { var env Envelope - for i := 0; i < m.NumPoints(); i++ { - xy, ok := m.PointN(i).XY() - if !ok { - continue - } - if has { - env = env.ExtendToIncludePoint(xy) - } else { - env = NewEnvelope(xy) - has = true - } + for _, pt := range m.points { + env = env.ExpandToIncludeEnvelope(pt.Envelope()) } - return env, has + return env } // Boundary returns the spatial boundary for this MultiPoint, which is always @@ -204,24 +166,51 @@ func (m MultiPoint) ConvexHull() Geometry { // this geometry as a GeoJSON geometry object. func (m MultiPoint) MarshalJSON() ([]byte, error) { var dst []byte - dst = append(dst, `{"type":"MultiPoint","coordinates":`...) - dst = appendGeoJSONSequence(dst, m.seq, m.empty) - dst = append(dst, '}') + dst = append(dst, `{"type":"MultiPoint","coordinates":[`...) + first := true + for _, pt := range m.points { + c, ok := pt.Coordinates() + if ok { + if !first { + dst = append(dst, ',') + } + first = false + dst = appendGeoJSONCoordinate(dst, c) + } + } + dst = append(dst, "]}"...) return dst, nil } -// Coordinates returns the coordinates of the points represented by the -// MultiPoint. If a point has its corresponding bit set to true in the BitSet, -// then that point is empty. -func (m MultiPoint) Coordinates() (seq Sequence, empty BitSet) { - // TODO: If we had a read-only BitSet, then we could avoid the clone here. - return m.seq, m.empty.Clone() +// Coordinates returns the coordinates of the non-empty points represented by +// the MultiPoint. +func (m MultiPoint) Coordinates() Sequence { + ctype := m.CoordinatesType() + coords := make([]float64, 0, len(m.points)*ctype.Dimension()) + for _, pt := range m.points { + if c, ok := pt.Coordinates(); ok { + coords = c.appendFloat64s(coords) + } + } + return NewSequence(coords, ctype) } // TransformXY transforms this MultiPoint into another MultiPoint according to fn. func (m MultiPoint) TransformXY(fn func(XY) XY, opts ...ConstructorOption) (MultiPoint, error) { - transformed := transformSequence(m.seq, fn) - return NewMultiPointWithEmptyMask(transformed, m.empty, opts...), nil + txPoints := make([]Point, len(m.points)) + for i, pt := range m.points { + if c, ok := pt.Coordinates(); ok { + c.XY = fn(c.XY) + var err error + txPoints[i], err = NewPoint(c, opts...) + if err != nil { + return MultiPoint{}, err + } + } else { + txPoints[i] = pt + } + } + return NewMultiPoint(txPoints), nil } // Centroid gives the centroid of the coordinates of the MultiPoint. @@ -238,7 +227,7 @@ func (m MultiPoint) Centroid() Point { if n == 0 { return NewEmptyPoint(DimXY) } - return NewPointFromXY(sum.Scale(1 / float64(n))) + return sum.Scale(1 / float64(n)).asUncheckedPoint() } // Reverse in the case of MultiPoint outputs each component point in their @@ -250,13 +239,24 @@ func (m MultiPoint) Reverse() MultiPoint { // CoordinatesType returns the CoordinatesType used to represent points making // up the geometry. func (m MultiPoint) CoordinatesType() CoordinatesType { - return m.seq.CoordinatesType() + return m.ctype } // ForceCoordinatesType returns a new MultiPoint with a different CoordinatesType. If a // dimension is added, then new values are populated with 0. func (m MultiPoint) ForceCoordinatesType(newCType CoordinatesType) MultiPoint { - return MultiPoint{m.seq.ForceCoordinatesType(newCType), m.empty} + newPoints := forceCoordinatesTypeOfPointSlice(m.points, newCType) + return MultiPoint{newPoints, newCType} +} + +// forceCoordinatesTypeOfPointSlice creates a new slice of Points, each forced +// to a new coordinates type. +func forceCoordinatesTypeOfPointSlice(pts []Point, ctype CoordinatesType) []Point { + cp := make([]Point, len(pts)) + for i, pt := range pts { + cp[i] = pt.ForceCoordinatesType(ctype) + } + return cp } // Force2D returns a copy of the MultiPoint with Z and M values removed. @@ -275,11 +275,10 @@ func (m MultiPoint) PointOnSurface() Point { } func (m MultiPoint) asXYs() []XY { - n := m.seq.Length() - xys := make([]XY, 0, n) - for i := 0; i < n; i++ { - if !m.empty.Get(i) { - xys = append(xys, m.seq.GetXY(i)) + xys := make([]XY, 0, len(m.points)) + for _, pt := range m.points { + if xy, ok := pt.XY(); ok { + xys = append(xys, xy) } } return xys @@ -287,10 +286,36 @@ func (m MultiPoint) asXYs() []XY { // Dump returns the MultiPoint represented as a Point slice. func (m MultiPoint) Dump() []Point { - n := m.seq.Length() - pts := make([]Point, n) - for i := 0; i < n; i++ { - pts[i] = m.PointN(i) - } + pts := make([]Point, len(m.points)) + copy(pts, m.points) return pts } + +// DumpCoordinates returns the non-empty points in a MultiPoint represented as +// a Sequence. +func (m MultiPoint) DumpCoordinates() Sequence { + ctype := m.CoordinatesType() + nonEmpty := make([]float64, 0, len(m.points)*ctype.Dimension()) + for _, pt := range m.points { + if c, ok := pt.Coordinates(); ok { + nonEmpty = c.appendFloat64s(nonEmpty) + } + } + seq := NewSequence(nonEmpty, ctype) + return seq +} + +// Summary returns a text summary of the MultiPoint following a similar format to https://postgis.net/docs/ST_Summary.html. +func (m MultiPoint) Summary() string { + var pointSuffix string + numPoints := m.NumPoints() + if numPoints != 1 { + pointSuffix = "s" + } + return fmt.Sprintf("%s[%s] with %d point%s", m.Type(), m.CoordinatesType(), numPoints, pointSuffix) +} + +// String returns the string representation of the MultiPoint. +func (m MultiPoint) String() string { + return m.Summary() +} diff --git a/geom/type_multi_polygon.go b/geom/type_multi_polygon.go index 41da6cc7..eff8cee1 100644 --- a/geom/type_multi_polygon.go +++ b/geom/type_multi_polygon.go @@ -20,15 +20,16 @@ import ( // // 3. The boundaries of any two polygons may touch only at a finite number of points. type MultiPolygon struct { + // Invariant: ctype matches the coordinates type of each polygon. polys []Polygon ctype CoordinatesType } -// NewMultiPolygonFromPolygons creates a MultiPolygon from its constituent -// Polygons. It gives an error if any of the MultiPolygon assertions are not -// maintained. The coordinates type of the MultiPolygon is the lowest common -// coordinates type its Polygons. -func NewMultiPolygonFromPolygons(polys []Polygon, opts ...ConstructorOption) (MultiPolygon, error) { +// NewMultiPolygon creates a MultiPolygon from its constituent Polygons. It +// gives an error if any of the MultiPolygon assertions are not maintained. The +// coordinates type of the MultiPolygon is the lowest common coordinates type +// its Polygons. +func NewMultiPolygon(polys []Polygon, opts ...ConstructorOption) (MultiPolygon, error) { if len(polys) == 0 { return MultiPolygon{}, nil } @@ -64,9 +65,8 @@ func validateMultiPolygon(polys []Polygon, opts ctorOptionSet) error { boxes := make([]rtree.Box, len(polys)) items := make([]rtree.BulkItem, 0, len(polys)) for i, p := range polys { - env, ok := p.Envelope() - if ok { - boxes[i] = env.box() + if box, ok := p.Envelope().box(); ok { + boxes[i] = box item := rtree.BulkItem{Box: boxes[i], RecordID: i} items = append(items, item) } @@ -77,7 +77,7 @@ func validateMultiPolygon(polys []Polygon, opts ctorOptionSet) error { if polys[i].IsEmpty() { continue } - err := tree.RangeSearch(boxes[i], func(j int) error { + if err := tree.RangeSearch(boxes[i], func(j int) error { // Only consider each pair of polygons once. if i <= j { return nil @@ -126,8 +126,7 @@ func validateMultiPolygon(polys []Polygon, opts ctorOptionSet) error { } } return nil - }) - if err != nil { + }); err != nil { return err } } @@ -143,7 +142,7 @@ func validatePolyNotInsidePoly(p1, p2 indexedLines) error { for j := range p2.lines { // Find intersection points. var pts []XY - p1.tree.RangeSearch(p2.lines[j].envelope().box(), func(i int) error { + p1.tree.RangeSearch(p2.lines[j].box(), func(i int) error { inter := p1.lines[i].intersectLine(p2.lines[j]) if inter.empty { return nil @@ -168,7 +167,7 @@ func validatePolyNotInsidePoly(p1, p2 indexedLines) error { midpoint := pts[k].Add(pts[k+1]).Scale(0.5) if relatePointToPolygon(midpoint, p1) == interior { return validationError{fmt.Sprintf("multipolygon child polygon "+ - "interiors intersect at %s", NewPointFromXY(midpoint).AsText())} + "interiors intersect at %v", midpoint)} } } } @@ -235,24 +234,13 @@ func (m MultiPolygon) IsEmpty() bool { return true } -// Envelope returns the Envelope that most tightly surrounds the geometry. If -// the geometry is empty, then false is returned. -func (m MultiPolygon) Envelope() (Envelope, bool) { +// Envelope returns the Envelope that most tightly surrounds the geometry. +func (m MultiPolygon) Envelope() Envelope { var env Envelope - var has bool for _, poly := range m.polys { - e, ok := poly.Envelope() - if !ok { - continue - } - if has { - env = env.ExpandToIncludeEnvelope(e) - } else { - env = e - has = true - } + env = env.ExpandToIncludeEnvelope(poly.Envelope()) } - return env, has + return env } // Boundary returns the spatial boundary of this MultiPolygon. This is the @@ -268,7 +256,7 @@ func (m MultiPolygon) Boundary() MultiLineString { bounds = append(bounds, r.Force2D()) } } - return NewMultiLineStringFromLineStrings(bounds) + return NewMultiLineString(bounds) } // Value implements the database/sql/driver.Valuer interface by returning the @@ -347,7 +335,7 @@ func (m MultiPolygon) TransformXY(fn func(XY) XY, opts ...ConstructorOption) (Mu } polys[i] = transformed } - mp, err := NewMultiPolygonFromPolygons(polys, opts...) + mp, err := NewMultiPolygon(polys, opts...) return mp.ForceCoordinatesType(m.ctype), wrapTransformed(err) } @@ -383,7 +371,7 @@ func (m MultiPolygon) Centroid() Point { weightedCentroid = weightedCentroid.Add(centroid.Scale(areas[i] / totalArea)) } } - return NewPointFromXY(weightedCentroid) + return weightedCentroid.asUncheckedPoint() } // Reverse in the case of MultiPolygon outputs the component polygons in their original order, @@ -474,3 +462,54 @@ func (m MultiPolygon) Dump() []Polygon { copy(ps, m.polys) return ps } + +// DumpCoordinates returns the points making up the rings in a MultiPolygon as +// a Sequence. +func (m MultiPolygon) DumpCoordinates() Sequence { + var n int + for _, p := range m.polys { + for _, r := range p.rings { + n += r.Coordinates().Length() + } + } + ctype := m.CoordinatesType() + coords := make([]float64, 0, n*ctype.Dimension()) + + for _, p := range m.polys { + for _, r := range p.rings { + coords = r.Coordinates().appendAllPoints(coords) + } + } + + seq := NewSequence(coords, ctype) + seq.assertNoUnusedCapacity() + return seq +} + +// Summary returns a text summary of the MultiPolygon following a similar format to https://postgis.net/docs/ST_Summary.html. +func (m MultiPolygon) Summary() string { + numPoints := m.DumpCoordinates().Length() + + var polygonSuffix string + numPolygons := m.NumPolygons() + if numPolygons != 1 { + polygonSuffix = "s" + } + + var numRings int + for _, polygon := range m.polys { + numRings += polygon.NumRings() + } + + var ringSuffix string + if numRings != 1 { + ringSuffix = "s" + } + return fmt.Sprintf("%s[%s] with %d polygon%s consisting of %d total ring%s and %d total points", + m.Type(), m.CoordinatesType(), numPolygons, polygonSuffix, numRings, ringSuffix, numPoints) +} + +// String returns the string representation of the MultiPolygon. +func (m MultiPolygon) String() string { + return m.Summary() +} diff --git a/geom/type_point.go b/geom/type_point.go index c64afa08..3cb3fd66 100644 --- a/geom/type_point.go +++ b/geom/type_point.go @@ -2,6 +2,7 @@ package geom import ( "database/sql/driver" + "fmt" "math" "unsafe" ) @@ -17,8 +18,38 @@ type Point struct { full bool } -// NewPoint creates a new point gives its Coordinates. -func NewPoint(c Coordinates, _ ...ConstructorOption) Point { +// NewPoint creates a new point given its Coordinates. +func NewPoint(c Coordinates, opts ...ConstructorOption) (Point, error) { + os := newOptionSet(opts) + if os.skipValidations { + return newUncheckedPoint(c), nil + } + if err := c.XY.validate(); err != nil { + if os.omitInvalid { + return NewEmptyPoint(c.Type), nil + } + return Point{}, validationError{err.Error()} + } + return newUncheckedPoint(c), nil +} + +// newUncheckedPoint constructs a point without checking any validations. It +// may be used internally when the caller is sure that the coordinates don't +// come directly from outside the library, or have already otherwise been +// validated. +// +// An examples of valid use: +// +// - The coordinates have just been validated. +// +// - The coordinates are taken directly from the control points of a geometry +// that has been validated. +// +// - The coordinates are derived from calculations based on control points of a +// geometry that has been validated. Technically, these calculations could +// overflow to +/- inf. However if control points are originally close to +// infinity, many of the algorithms will be already broken in many other ways. +func newUncheckedPoint(c Coordinates) Point { return Point{c, true} } @@ -27,11 +58,6 @@ func NewEmptyPoint(ctype CoordinatesType) Point { return Point{Coordinates{Type: ctype}, false} } -// NewPointFromXY creates a new point from an XY. -func NewPointFromXY(xy XY, _ ...ConstructorOption) Point { - return Point{Coordinates{XY: xy, Type: DimXY}, true} -} - // Type returns the GeometryType for a Point func (p Point) Type() GeometryType { return TypePoint @@ -63,6 +89,10 @@ func (p Point) AsText() string { // to the input byte slice. func (p Point) AppendWKT(dst []byte) []byte { dst = appendWKTHeader(dst, "POINT", p.coords.Type) + return p.appendWKTBody(dst) +} + +func (p Point) appendWKTBody(dst []byte) []byte { if !p.full { return appendWKTEmpty(dst) } @@ -81,14 +111,13 @@ func (p Point) IsSimple() bool { return true } -// Envelope returns a zero area Envelope covering the Point. If the Point is -// empty, then false is returned. -func (p Point) Envelope() (Envelope, bool) { - xy, ok := p.XY() - if !ok { - return Envelope{}, false +// Envelope returns the envelope best fitting the Point (either an empty +// envelope, or an envelope covering a single point). +func (p Point) Envelope() Envelope { + if xy, ok := p.XY(); ok { + return Envelope{}.uncheckedExtend(xy) } - return NewEnvelope(xy), true + return Envelope{} } // Boundary returns the spatial boundary for this Point, which is always the @@ -163,7 +192,7 @@ func (p Point) TransformXY(fn func(XY) XY, opts ...ConstructorOption) (Point, er } newC := p.coords newC.XY = fn(newC.XY) - return NewPoint(newC, opts...), nil + return NewPoint(newC, opts...) } // Centroid of a point is that point. @@ -179,7 +208,7 @@ func (p Point) Reverse() Point { // AsMultiPoint is a convenience function that converts this Point into a // MultiPoint. func (p Point) AsMultiPoint() MultiPoint { - return NewMultiPointFromPoints([]Point{p}) + return NewMultiPoint([]Point{p}) } // CoordinatesType returns the CoordinatesType used to represent the Point. @@ -219,3 +248,35 @@ func (p Point) asXYs() []XY { } return nil } + +// DumpCoordinates returns a Sequence representing the point. For an empty +// Point, the Sequence will be empty. For a non-empty Point, the Sequence will +// contain the single set of coordinates representing the point. +func (p Point) DumpCoordinates() Sequence { + ctype := p.CoordinatesType() + var floats []float64 + coords, ok := p.Coordinates() + if ok { + n := ctype.Dimension() + floats = coords.appendFloat64s(make([]float64, 0, n)) + } + seq := NewSequence(floats, ctype) + seq.assertNoUnusedCapacity() + return seq +} + +// Summary returns a text summary of the Point following a similar format to https://postgis.net/docs/ST_Summary.html. +func (p Point) Summary() string { + var pointSuffix string + numPoints := 1 + if p.IsEmpty() { + numPoints = 0 + pointSuffix = "s" + } + return fmt.Sprintf("%s[%s] with %d point%s", p.Type(), p.CoordinatesType(), numPoints, pointSuffix) +} + +// String returns the string representation of the Point. +func (p Point) String() string { + return p.Summary() +} diff --git a/geom/type_polygon.go b/geom/type_polygon.go index 2221a526..6239fe99 100644 --- a/geom/type_polygon.go +++ b/geom/type_polygon.go @@ -2,6 +2,7 @@ package geom import ( "database/sql/driver" + "fmt" "math" "unsafe" @@ -29,11 +30,11 @@ type Polygon struct { ctype CoordinatesType } -// NewPolygonFromRings creates a polygon given its rings. The outer ring is -// first, and any inner rings follow. If no rings are provided, then the -// returned Polygon is the empty Polygon. The coordinate type of the polygon is -// the lowest common coordinate type of its rings. -func NewPolygonFromRings(rings []LineString, opts ...ConstructorOption) (Polygon, error) { +// NewPolygon creates a polygon given its rings. The outer ring is first, and +// any inner rings follow. If no rings are provided, then the returned Polygon +// is the empty Polygon. The coordinate type of the polygon is the lowest +// common coordinate type of its rings. +func NewPolygon(rings []LineString, opts ...ConstructorOption) (Polygon, error) { if len(rings) == 0 { return Polygon{}, nil } @@ -80,13 +81,13 @@ func validatePolygon(rings []LineString, opts ctorOptionSet) error { boxes := make([]rtree.Box, len(rings)) items := make([]rtree.BulkItem, len(rings)) for i, r := range rings { - env, ok := r.Envelope() + box, ok := r.Envelope().box() if !ok { // Cannot occur, because we have already checked to ensure rings // are closed. Closed rings by definition are non-empty. panic("unexpected empty ring") } - boxes[i] = env.box() + boxes[i] = box items[i] = rtree.BulkItem{Box: boxes[i], RecordID: i} } tree := rtree.BulkLoad(items) @@ -180,6 +181,14 @@ func (p Polygon) NumInteriorRings() int { return max(0, len(p.rings)-1) } +// NumRings gives the total number of rings: ExternalRing + NumInteriorRings(). +func (p Polygon) NumRings() int { + if p.IsEmpty() { + return 0 + } + return 1 + p.NumInteriorRings() +} + // InteriorRingN gives the nth (zero indexed) interior ring in the polygon // boundary. It will panic if n is out of bounds with respect to the number of // interior rings. @@ -232,9 +241,8 @@ func (p Polygon) IsEmpty() bool { return len(p.rings) == 0 } -// Envelope returns the Envelope that most tightly surrounds the geometry. If -// the geometry is empty, then false is returned. -func (p Polygon) Envelope() (Envelope, bool) { +// Envelope returns the Envelope that most tightly surrounds the geometry. +func (p Polygon) Envelope() Envelope { return p.ExteriorRing().Envelope() } @@ -242,7 +250,7 @@ func (p Polygon) Envelope() (Envelope, bool) { // Polygons, this is the MultiLineString collection containing all of the // rings. func (p Polygon) Boundary() MultiLineString { - return NewMultiLineStringFromLineStrings(p.rings).Force2D() + return NewMultiLineString(p.rings).Force2D() } // Value implements the database/sql/driver.Valuer interface by returning the @@ -323,7 +331,7 @@ func (p Polygon) TransformXY(fn func(XY) XY, opts ...ConstructorOption) (Polygon return Polygon{}, wrapTransformed(err) } } - poly, err := NewPolygonFromRings(transformed, opts...) + poly, err := NewPolygon(transformed, opts...) return poly.ForceCoordinatesType(p.ctype), wrapTransformed(err) } @@ -422,7 +430,7 @@ func (p Polygon) Centroid() Point { centroid = centroid.Add( weightedCentroid(p.InteriorRingN(i), areas[i+1], sumAreas)) } - return NewPointFromXY(centroid) + return centroid.asUncheckedPoint() } func weightedCentroid(ring LineString, ringArea, totalArea float64) XY { @@ -463,7 +471,7 @@ func (p Polygon) AsMultiPolygon() MultiPolygon { if !p.IsEmpty() { polys = []Polygon{p} } - mp, err := NewMultiPolygonFromPolygons(polys) + mp, err := NewMultiPolygon(polys) if err != nil { // Cannot occur due to construction. A valid polygon will always be a // valid multipolygon. @@ -543,3 +551,38 @@ func (p Polygon) controlPoints() int { } return sum } + +// DumpCoordinates returns the points making up the rings in a Polygon as a +// Sequence. +func (p Polygon) DumpCoordinates() Sequence { + var n int + for _, r := range p.rings { + n += r.Coordinates().Length() + } + ctype := p.CoordinatesType() + coords := make([]float64, 0, n*ctype.Dimension()) + for _, r := range p.rings { + coords = r.Coordinates().appendAllPoints(coords) + } + seq := NewSequence(coords, ctype) + seq.assertNoUnusedCapacity() + return seq +} + +// Summary returns a text summary of the Polygon following a similar format to https://postgis.net/docs/ST_Summary.html. +func (p Polygon) Summary() string { + numPoints := p.DumpCoordinates().Length() + + var ringSuffix string + numRings := p.NumRings() + if numRings != 1 { + ringSuffix = "s" + } + return fmt.Sprintf("%s[%s] with %d ring%s consisting of %d total points", + p.Type(), p.CoordinatesType(), numRings, ringSuffix, numPoints) +} + +// String returns the string representation of the Polygon. +func (p Polygon) String() string { + return p.Summary() +} diff --git a/geom/type_sequence.go b/geom/type_sequence.go index 202a20fd..3e0006d8 100644 --- a/geom/type_sequence.go +++ b/geom/type_sequence.go @@ -1,5 +1,7 @@ package geom +import "fmt" + // Sequence represents a list of point locations. It is immutable after // creation. All locations in the Sequence are specified using the same // coordinates type. @@ -23,7 +25,7 @@ type Sequence struct { // followed by Y, then Z (if using XYZ or XYZM), then M (if using XYM or XYZM). // // The length of the coordinates slice must be a multiple of the dimensionality -// of the coordiantes type. If the length is not a multiple, then this is a +// of the coordinates type. If the length is not a multiple, then this is a // programming error and the function will panic. func NewSequence(coordinates []float64, ctype CoordinatesType) Sequence { if len(coordinates)%ctype.Dimension() != 0 { @@ -32,6 +34,17 @@ func NewSequence(coordinates []float64, ctype CoordinatesType) Sequence { return Sequence{ctype, coordinates} } +// validate checks the X and Y values in the sequence for NaNs and infinities. +func (s Sequence) validate() error { + n := s.Length() + for i := 0; i < n; i++ { + if err := s.GetXY(i).validate(); err != nil { + return wrap(err, "invalid XY at index %d", i) + } + } + return nil +} + // CoordinatesType returns the coordinates type used to represent point // locations in the Sequence. func (s Sequence) CoordinatesType() CoordinatesType { @@ -148,6 +161,15 @@ func (s Sequence) appendPoint(dst []float64, i int) []float64 { return append(dst, s.floats[i*stride:(i+1)*stride]...) } +// assertNoUnusedCapacity panics if the backing slice contains any unused +// capacity. +func (s Sequence) assertNoUnusedCapacity() { + if cap(s.floats)-len(s.floats) != 0 { + panic(fmt.Sprintf("unused capacity assertion "+ + "failure: cap=%d len=%d", cap(s.floats), len(s.floats))) + } +} + // getLine extracts a 2D line segment from a sequence by joining together // adjacent locations in the sequence. It is designed to be called with i equal // to each index in the sequence (from 0 to n-1, both inclusive). The flag diff --git a/geom/util_test.go b/geom/util_test.go index 1dc15f73..7ab63a07 100644 --- a/geom/util_test.go +++ b/geom/util_test.go @@ -8,7 +8,7 @@ import ( . "github.com/peterstace/simplefeatures/geom" ) -func geomFromWKT(t *testing.T, wkt string) Geometry { +func geomFromWKT(t testing.TB, wkt string) Geometry { t.Helper() geom, err := UnmarshalWKT(wkt) if err != nil { @@ -17,7 +17,7 @@ func geomFromWKT(t *testing.T, wkt string) Geometry { return geom } -func geomsFromWKTs(t *testing.T, wkts []string) []Geometry { +func geomsFromWKTs(t testing.TB, wkts []string) []Geometry { t.Helper() var gs []Geometry for _, wkt := range wkts { @@ -58,7 +58,7 @@ func upcastPolygons(ps []Polygon) []Geometry { return gs } -func expectPanics(t *testing.T, fn func()) { +func expectPanics(t testing.TB, fn func()) { t.Helper() defer func() { if r := recover(); r != nil { @@ -69,28 +69,34 @@ func expectPanics(t *testing.T, fn func()) { fn() } -func expectNoErr(t *testing.T, err error) { +func expectNoErr(t testing.TB, err error) { t.Helper() if err != nil { t.Fatalf("unexpected error: %v", err) } } -func expectErr(t *testing.T, err error) { +func expectErr(t testing.TB, err error) { t.Helper() if err == nil { t.Fatal("expected error but got nil") } } -func expectGeomEq(t *testing.T, got, want Geometry, opts ...ExactEqualsOption) { +func expectGeomEq(t testing.TB, got, want Geometry, opts ...ExactEqualsOption) { t.Helper() if !ExactEquals(got, want, opts...) { t.Errorf("\ngot: %v\nwant: %v\n", got.AsText(), want.AsText()) } } -func expectGeomsEq(t *testing.T, got, want []Geometry, opts ...ExactEqualsOption) { +func expectGeomEqWKT(t testing.TB, got Geometry, wantWKT string, opts ...ExactEqualsOption) { + t.Helper() + want := geomFromWKT(t, wantWKT) + expectGeomEq(t, got, want, opts...) +} + +func expectGeomsEq(t testing.TB, got, want []Geometry, opts ...ExactEqualsOption) { t.Helper() if len(got) != len(want) { t.Errorf("\ngot: len %d\nwant: len %d\n", len(got), len(want)) @@ -102,67 +108,119 @@ func expectGeomsEq(t *testing.T, got, want []Geometry, opts ...ExactEqualsOption } } -func expectCoordsEq(t *testing.T, got, want Coordinates) { +func expectCoordsEq(t testing.TB, got, want Coordinates) { t.Helper() if got != want { t.Errorf("\ngot: %v\nwant: %v\n", got, want) } } -func expectStringEq(t *testing.T, got, want string) { +func expectStringEq(t testing.TB, got, want string) { t.Helper() if got != want { t.Errorf("\ngot: %q\nwant: %q\n", got, want) } } -func expectIntEq(t *testing.T, got, want int) { +func expectIntEq(t testing.TB, got, want int) { t.Helper() if got != want { t.Errorf("\ngot: %d\nwant: %d\n", got, want) } } -func expectBoolEq(t *testing.T, got, want bool) { +func expectBoolEq(t testing.TB, got, want bool) { t.Helper() if got != want { t.Errorf("\ngot: %t\nwant: %t\n", got, want) } } -func expectTrue(t *testing.T, got bool) { +func expectTrue(t testing.TB, got bool) { t.Helper() expectBoolEq(t, got, true) } -func expectFalse(t *testing.T, got bool) { +//nolint:deadcode,unused +func expectFalse(t testing.TB, got bool) { t.Helper() expectBoolEq(t, got, false) } -func expectXYEq(t *testing.T, got, want XY) { +func expectXYEq(t testing.TB, got, want XY) { t.Helper() if got != want { t.Errorf("\ngot: %v\nwant: %v\n", got, want) } } -func expectXYWithinTolerance(t *testing.T, got, want XY, tolerance float64) { +func expectXYWithinTolerance(t testing.TB, got, want XY, tolerance float64) { t.Helper() if delta := math.Abs(got.Sub(want).Length()); delta > tolerance { t.Errorf("\ngot: %v\nwant: %v\n", got, want) } } -func expectCoordinatesTypeEq(t *testing.T, got, want CoordinatesType) { +func expectCoordinatesTypeEq(t testing.TB, got, want CoordinatesType) { t.Helper() if got != want { t.Errorf("\ngot: %v\nwant: %v\n", got, want) } } -func expectBytesEq(t *testing.T, got, want []byte) { +func expectBytesEq(t testing.TB, got, want []byte) { t.Helper() if !bytes.Equal(got, want) { t.Errorf("\ngot: %v\nwant: %v\n", got, want) } } + +func expectFloat64Eq(t testing.TB, got, want float64) { + t.Helper() + if got != want { + t.Errorf("\ngot: %v\nwant: %v\n", got, want) + } +} + +func expectEnvEq(t testing.TB, got, want Envelope) { + t.Helper() + if ExactEquals(got.Min().AsGeometry(), want.Min().AsGeometry()) && + ExactEquals(got.Max().AsGeometry(), want.Max().AsGeometry()) { + return + } + t.Errorf( + "\ngot: %v\nwant: %v\n", + got.AsGeometry().AsText(), + want.AsGeometry().AsText(), + ) +} + +func expectSequenceEq(t testing.TB, got, want Sequence) { + t.Helper() + show := func() { + t.Logf("len(got): %d, ct(got): %s", got.Length(), got.CoordinatesType()) + for i := 0; i < got.Length(); i++ { + t.Logf("got[%d]: %v", i, got.Get(i)) + } + t.Logf("len(want): %d, ct(want): %s", want.Length(), want.CoordinatesType()) + for i := 0; i < want.Length(); i++ { + t.Logf("want[%d]: %v", i, want.Get(i)) + } + } + if got.CoordinatesType() != want.CoordinatesType() { + t.Errorf("mismatched coordinate type") + show() + return + } + if got.Length() != want.Length() { + t.Errorf("length mismatch") + show() + return + } + for i := 0; i < got.Length(); i++ { + w := want.Get(i) + g := got.Get(i) + if g != w { + t.Errorf("mismatch at %d: got:%v want:%v", i, g, w) + } + } +} diff --git a/geom/validation_test.go b/geom/validation_test.go index a956eb1c..748da9e3 100644 --- a/geom/validation_test.go +++ b/geom/validation_test.go @@ -2,6 +2,7 @@ package geom_test import ( "fmt" + "math" "strconv" "testing" @@ -12,19 +13,93 @@ func xy(x, y float64) Coordinates { return Coordinates{Type: DimXY, XY: XY{x, y}} } -func TestLineStringValidation(t *testing.T) { +func TestPointValidation(t *testing.T) { + nan := math.NaN() + inf := math.Inf(+1) + for i, tc := range []struct { + wantValid bool + input Coordinates + }{ + {true, xy(0, 0)}, + {false, xy(nan, 0)}, + {false, xy(0, nan)}, + {false, xy(nan, nan)}, + {false, xy(inf, 0)}, + {false, xy(0, inf)}, + {false, xy(inf, inf)}, + {false, xy(-inf, 0)}, + {false, xy(0, -inf)}, + {false, xy(-inf, -inf)}, + } { + t.Run(fmt.Sprintf("point_%d", i), func(t *testing.T) { + _, err := NewPoint(tc.input) + if tc.wantValid { + expectNoErr(t, err) + } else { + expectErr(t, err) + } + }) + } +} + +func TestDisableAllPointValidations(t *testing.T) { + c := xy(2, math.NaN()) + + _, err := NewPoint(c) + expectErr(t, err) + + _, err = NewPoint(c, DisableAllValidations) + expectNoErr(t, err) +} + +func TestOmitInvalidPoint(t *testing.T) { + t.Run("DimXY", func(t *testing.T) { + c := xy(2, math.NaN()) + + _, err := NewPoint(c) + expectErr(t, err) + + pt, err := NewPoint(c, OmitInvalid) + expectNoErr(t, err) + expectTrue(t, pt.IsEmpty()) + }) + t.Run("DimXYZ", func(t *testing.T) { + c := Coordinates{Type: DimXYZ, XY: XY{2, math.NaN()}} + + _, err := NewPoint(c) + expectErr(t, err) + + pt, err := NewPoint(c, OmitInvalid) + expectNoErr(t, err) + expectTrue(t, pt.IsEmpty()) + expectCoordinatesTypeEq(t, pt.CoordinatesType(), DimXYZ) + }) +} + +func TestLineStringValidationInvalidFromRawCoords(t *testing.T) { + nan := math.NaN() + inf := math.Inf(+1) for i, pts := range [][]float64{ - []float64{0, 0}, - []float64{1, 1}, - []float64{0, 0, 0, 0}, - []float64{1, 1, 1, 1}, + {0, 0}, + {1, 1}, + {0, 0, 0, 0}, + {1, 1, 1, 1}, + {0, 0, 1, 1, 2, nan}, + {0, 0, 1, 1, nan, 2}, + {0, 0, 1, 1, 2, inf}, + {0, 0, 1, 1, inf, 2}, + {0, 0, 1, 1, 2, -inf}, + {0, 0, 1, 1, -inf, 2}, } { t.Run(strconv.Itoa(i), func(t *testing.T) { seq := NewSequence(pts, DimXY) _, err := NewLineString(seq) - if err == nil { - t.Error("expected error") - } + expectErr(t, err) + _, err = NewLineString(seq, DisableAllValidations) + expectNoErr(t, err) + ls, err := NewLineString(seq, OmitInvalid) + expectNoErr(t, err) + expectTrue(t, ls.IsEmpty()) }) } } diff --git a/geom/walk.go b/geom/walk.go index 2b7ff12d..93be7f55 100644 --- a/geom/walk.go +++ b/geom/walk.go @@ -18,11 +18,11 @@ func walk(g Geometry, fn func(XY)) { case TypePolygon: walk(g.Boundary(), fn) case TypeMultiPoint: - seq, empty := g.AsMultiPoint().Coordinates() - n := seq.Length() + mp := g.AsMultiPoint() + n := mp.NumPoints() for i := 0; i < n; i++ { - if !empty.Get(i) { - fn(seq.GetXY(i)) + if xy, ok := mp.PointN(i).XY(); ok { + fn(xy) } } case TypeMultiLineString: diff --git a/geom/wkb_parser.go b/geom/wkb_parser.go index 95b858ec..641aec53 100644 --- a/geom/wkb_parser.go +++ b/geom/wkb_parser.go @@ -192,7 +192,7 @@ func (p *wkbParser) parsePoint(ctype CoordinatesType) (Point, error) { if math.IsNaN(c.X) || math.IsNaN(c.Y) { return Point{}, wkbSyntaxError{"point contains mixed NaN values"} } - return NewPoint(c, p.opts...), nil + return NewPoint(c, p.opts...) } func (p *wkbParser) parseLineString(ctype CoordinatesType) (LineString, error) { @@ -258,7 +258,7 @@ func (p *wkbParser) parsePolygon(ctype CoordinatesType) (Polygon, error) { return Polygon{}, err } } - return NewPolygonFromRings(rings, p.opts...) + return NewPolygon(rings, p.opts...) } func (p *wkbParser) parseMultiPoint(ctype CoordinatesType) (MultiPoint, error) { @@ -280,7 +280,7 @@ func (p *wkbParser) parseMultiPoint(ctype CoordinatesType) (MultiPoint, error) { } pts[i] = geom.AsPoint() } - return NewMultiPointFromPoints(pts, p.opts...), nil + return NewMultiPoint(pts, p.opts...), nil } func (p *wkbParser) parseMultiLineString(ctype CoordinatesType) (MultiLineString, error) { @@ -302,7 +302,7 @@ func (p *wkbParser) parseMultiLineString(ctype CoordinatesType) (MultiLineString } lss[i] = geom.AsLineString() } - return NewMultiLineStringFromLineStrings(lss, p.opts...), nil + return NewMultiLineString(lss, p.opts...), nil } func (p *wkbParser) parseMultiPolygon(ctype CoordinatesType) (MultiPolygon, error) { @@ -324,7 +324,7 @@ func (p *wkbParser) parseMultiPolygon(ctype CoordinatesType) (MultiPolygon, erro } polys[i] = geom.AsPolygon() } - return NewMultiPolygonFromPolygons(polys, p.opts...) + return NewMultiPolygon(polys, p.opts...) } func (p *wkbParser) parseGeometryCollection(ctype CoordinatesType) (GeometryCollection, error) { diff --git a/geom/wkt_parser.go b/geom/wkt_parser.go index b7679b62..0b2e07f4 100644 --- a/geom/wkt_parser.go +++ b/geom/wkt_parser.go @@ -60,7 +60,8 @@ func (p *parser) nextGeometryTaggedText() (Geometry, error) { if !ok { return NewEmptyPoint(ctype).AsGeometry(), nil } - return NewPoint(c, p.opts...).AsGeometry(), nil + pt, err := NewPoint(c, p.opts...) + return pt.AsGeometry(), err case "LINESTRING": ls, err := p.nextLineStringText(ctype) return ls.AsGeometry(), err @@ -267,7 +268,7 @@ func (p *parser) nextPolygonText(ctype CoordinatesType) (Polygon, error) { if len(rings) == 0 { return Polygon{}.ForceCoordinatesType(ctype), nil } - return NewPolygonFromRings(rings, p.opts...) + return NewPolygon(rings, p.opts...) } func (p *parser) nextMultiLineString(ctype CoordinatesType) (MultiLineString, error) { @@ -278,7 +279,7 @@ func (p *parser) nextMultiLineString(ctype CoordinatesType) (MultiLineString, er if len(lss) == 0 { return MultiLineString{}.ForceCoordinatesType(ctype), nil } - return NewMultiLineStringFromLineStrings(lss, p.opts...), nil + return NewMultiLineString(lss, p.opts...), nil } func (p *parser) nextPolygonOrMultiLineStringText(ctype CoordinatesType) ([]LineString, error) { @@ -313,31 +314,25 @@ func (p *parser) nextPolygonOrMultiLineStringText(ctype CoordinatesType) ([]Line } func (p *parser) nextMultiPointText(ctype CoordinatesType) (MultiPoint, error) { - var floats []float64 - var empty BitSet tok, err := p.nextEmptySetOrLeftParen() if err != nil { return MultiPoint{}, err } + var points []Point if tok == "(" { - for i := 0; true; i++ { - tok, err := p.lexer.peek() + for { + coords, ok, err := p.nextMultiPointStylePoint(ctype) if err != nil { return MultiPoint{}, err } - if tok == "EMPTY" { - if _, err := p.lexer.next(); err != nil { - return MultiPoint{}, err - } - for j := 0; j < ctype.Dimension(); j++ { - floats = append(floats, 0) - } - empty.Set(i, true) - } else { - floats, err = p.nextMultiPointStylePointAppend(floats, ctype) + if ok { + pt, err := NewPoint(coords, p.opts...) if err != nil { return MultiPoint{}, err } + points = append(points, pt) + } else { + points = append(points, NewEmptyPoint(ctype)) } tok, err = p.nextCommaOrRightParen() if err != nil { @@ -348,36 +343,44 @@ func (p *parser) nextMultiPointText(ctype CoordinatesType) (MultiPoint, error) { } } } - seq := NewSequence(floats, ctype) - return NewMultiPointWithEmptyMask(seq, empty, p.opts...), nil + if len(points) == 0 { + return MultiPoint{}.ForceCoordinatesType(ctype), nil + } + return NewMultiPoint(points, p.opts...), nil } -func (p *parser) nextMultiPointStylePointAppend(dst []float64, ctype CoordinatesType) ([]float64, error) { - // This is an extension of the spec, and is required to handle WKT output - // from non-complying implementations. In particular, PostGIS doesn't - // comply to the spec (it outputs points as part of a multipoint without - // their surrounding parentheses). - var useParens bool +func (p *parser) nextMultiPointStylePoint(ctype CoordinatesType) (Coordinates, bool, error) { + // Allowing parentheses to be omitted is an extension of the spec, and is + // required to handle WKT output from non-complying implementations. In + // particular, PostGIS doesn't comply to the spec (it outputs points as + // part of a multipoint without their surrounding parentheses). tok, err := p.lexer.peek() if err != nil { - return nil, err + return Coordinates{}, false, err } - if tok == "(" { + + var useParens bool + switch tok { + case "(": if _, err := p.lexer.next(); err != nil { - return nil, err + return Coordinates{}, false, err } useParens = true + case "EMPTY": + _, err := p.lexer.next() + return Coordinates{}, false, err } - dst, err = p.nextPointAppend(dst, ctype) + + coords, err := p.nextPoint(ctype) if err != nil { - return nil, err + return Coordinates{}, false, err } if useParens { if err := p.nextRightParen(); err != nil { - return nil, err + return Coordinates{}, false, err } } - return dst, nil + return coords, true, nil } func (p *parser) nextMultiPolygonText(ctype CoordinatesType) (MultiPolygon, error) { @@ -405,7 +408,7 @@ func (p *parser) nextMultiPolygonText(ctype CoordinatesType) (MultiPolygon, erro if len(polys) == 0 { return MultiPolygon{}.ForceCoordinatesType(ctype), nil } - return NewMultiPolygonFromPolygons(polys, p.opts...) + return NewMultiPolygon(polys, p.opts...) } func (p *parser) nextGeometryCollectionText(ctype CoordinatesType) (GeometryCollection, error) { diff --git a/geom/wkt_test.go b/geom/wkt_test.go index 277fbabf..2dea85d6 100644 --- a/geom/wkt_test.go +++ b/geom/wkt_test.go @@ -17,8 +17,29 @@ func TestUnmarshalWKTValidGrammar(t *testing.T) { {"lower case", "point (1 1)"}, {"no space between tag and coord", "point(1 1)"}, {"exponent", "point (1e3 1.5e2)"}, + { + "multipoint with single empty point", + "MULTIPOINT(EMPTY)", + }, + { + "multipoint with empty point and non-empty point, empty first", + "MULTIPOINT(EMPTY,1 2)", + }, + { + "multipoint with empty point and non-empty point, empty second", + "MULTIPOINT(1 2,EMPTY)", + }, + { + "multipoint with empty point and non-empty point with parens, empty first", + "MULTIPOINT(EMPTY,(1 2))", + }, + { + "multipoint with empty point and non-empty point with parens, empty second", + "MULTIPOINT((1 2),EMPTY)", + }, } { t.Run(tt.name, func(t *testing.T) { + t.Logf("WKT: %v", tt.wkt) _, err := UnmarshalWKT(tt.wkt) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -129,6 +150,11 @@ func TestUnmarshalWKTSyntaxErrors(t *testing.T) { // but I think it's ok because this is an extreme edge case. "unexpected EOF", }, + { + "multipoint with parenthesis around empty point", + "MULTIPOINT((1 2),(EMPTY))", + "strconv.ParseFloat: parsing \"EMPTY\": invalid syntax", + }, } { t.Run(tt.description, func(t *testing.T) { _, err := UnmarshalWKT(tt.wkt) diff --git a/geom/wkt_write.go b/geom/wkt_write.go index 58203bd4..0b9c89b4 100644 --- a/geom/wkt_write.go +++ b/geom/wkt_write.go @@ -38,19 +38,14 @@ func appendWKTEmpty(dst []byte) []byte { return append(dst, "EMPTY"...) } -func appendWKTSequence(dst []byte, seq Sequence, parens bool, empty BitSet) []byte { +func appendWKTSequence(dst []byte, seq Sequence, parens bool) []byte { n := seq.Length() dst = append(dst, '(') for i := 0; i < n; i++ { if i > 0 { dst = append(dst, ',') } - if empty.Get(i) { - dst = appendWKTEmpty(dst) - } else { - c := seq.Get(i) - dst = appendWKTCoords(dst, c, parens) - } + dst = appendWKTCoords(dst, seq.Get(i), parens) } dst = append(dst, ')') return dst diff --git a/geom/xy.go b/geom/xy.go index 9673f83b..92a01d81 100644 --- a/geom/xy.go +++ b/geom/xy.go @@ -1,6 +1,7 @@ package geom import ( + "fmt" "math" "github.com/peterstace/simplefeatures/rtree" @@ -12,6 +13,42 @@ type XY struct { X, Y float64 } +// validate checks if the XY value contains NaN, -inf, or +inf. +func (w XY) validate() error { + if math.IsNaN(w.X) || math.IsInf(w.X, 0) { + return fmt.Errorf("invalid X value: %v", w.X) + } + if math.IsNaN(w.Y) || math.IsInf(w.Y, 0) { + return fmt.Errorf("invalid Y value: %v", w.Y) + } + return nil +} + +// AsPoint is a convenience function to convert this XY value into a Point +// geometry. +func (w XY) AsPoint(opts ...ConstructorOption) (Point, error) { + coords := Coordinates{XY: w, Type: DimXY} + return NewPoint(coords, opts...) +} + +// asUncheckedPoint is a convenience function to convert this XY value into a +// Point. The Point is constructed without checking any validations. It may be +// used internally when the caller is sure that the XY value doesn't come +// directly from outside of the library without first being validated. +func (w XY) asUncheckedPoint() Point { + coords := Coordinates{XY: w, Type: DimXY} + return newUncheckedPoint(coords) +} + +// uncheckedEnvelope is a convenience function to convert this XY value into +// a (degenerate) envelope that represents a single XY location (i.e. a zero +// area envelope). It may be used internally when the caller is sure that the +// XY value doesn't come directly from outline the library without first being +// validated. +func (w XY) uncheckedEnvelope() Envelope { + return newUncheckedEnvelope(w, w) +} + // Sub returns the result of subtracting the other XY from this XY (in the same // manner as vector subtraction). func (w XY) Sub(o XY) XY { diff --git a/geom/zero_value_test.go b/geom/zero_value_test.go index fff9ce12..f855ee4d 100644 --- a/geom/zero_value_test.go +++ b/geom/zero_value_test.go @@ -46,23 +46,23 @@ func TestZeroValueGeometries(t *testing.T) { func TestEmptySliceConstructors(t *testing.T) { t.Run("Polygon", func(t *testing.T) { - p, err := NewPolygonFromRings(nil) + p, err := NewPolygon(nil) expectNoErr(t, err) expectBoolEq(t, p.IsEmpty(), true) expectCoordinatesTypeEq(t, p.CoordinatesType(), DimXY) }) t.Run("MultiPoint", func(t *testing.T) { - mp := NewMultiPointFromPoints(nil) + mp := NewMultiPoint(nil) expectIntEq(t, mp.NumPoints(), 0) expectCoordinatesTypeEq(t, mp.CoordinatesType(), DimXY) }) t.Run("MultiLineString", func(t *testing.T) { - mls := NewMultiLineStringFromLineStrings(nil) + mls := NewMultiLineString(nil) expectIntEq(t, mls.NumLineStrings(), 0) expectCoordinatesTypeEq(t, mls.CoordinatesType(), DimXY) }) t.Run("MultiPolygon", func(t *testing.T) { - mp, err := NewMultiPolygonFromPolygons(nil) + mp, err := NewMultiPolygon(nil) expectNoErr(t, err) expectIntEq(t, mp.NumPolygons(), 0) expectCoordinatesTypeEq(t, mp.CoordinatesType(), DimXY) diff --git a/geos/benchmark_test.go b/geos/benchmark_test.go index b5e1c1ab..756cda28 100644 --- a/geos/benchmark_test.go +++ b/geos/benchmark_test.go @@ -26,7 +26,7 @@ func regularPolygon(center geom.XY, radius float64, sides int) geom.Polygon { if err != nil { panic(err) } - poly, err := geom.NewPolygonFromRings([]geom.LineString{ring}, geom.DisableAllValidations) + poly, err := geom.NewPolygon([]geom.LineString{ring}, geom.DisableAllValidations) if err != nil { panic(err) } diff --git a/internal/cmprefimpl/cmpgeos/checks.go b/internal/cmprefimpl/cmpgeos/checks.go index acf1eac5..0031b5df 100644 --- a/internal/cmprefimpl/cmpgeos/checks.go +++ b/internal/cmprefimpl/cmpgeos/checks.go @@ -93,7 +93,7 @@ func unaryChecks(h *Handle, g geom.Geometry, log *log.Logger) error { // differences between libgeos and PostGIS. } -var mismatchErr = errors.New("mismatch") +var errMismatch = errors.New("mismatch") func checkIsValid(h *Handle, g geom.Geometry, log *log.Logger) (bool, error) { wkb := g.AsBinary() @@ -103,7 +103,7 @@ func checkIsValid(h *Handle, g geom.Geometry, log *log.Logger) (bool, error) { } log.Printf("Valid as per simplefeatures: %v", validAsPerSimpleFeatures) - validAsPerLibgeos, err := h.IsValid(g) + validAsPerLibgeos, err := h.isValid(g) if err != nil { // The geometry is _so_ invalid that libgeos can't even tell if it's // invalid or not. @@ -116,7 +116,7 @@ func checkIsValid(h *Handle, g geom.Geometry, log *log.Logger) (bool, error) { ignoreMismatch := hasEmptyRing(g) if !ignoreMismatch && validAsPerLibgeos != validAsPerSimpleFeatures { - return false, mismatchErr + return false, errMismatch } return validAsPerSimpleFeatures, nil } @@ -136,7 +136,7 @@ func checkAsText(h *Handle, g geom.Geometry, log *log.Logger) error { return nil } - want, err := h.AsText(g) + want, err := h.asText(g) if err != nil { return err } @@ -152,7 +152,7 @@ func checkAsText(h *Handle, g geom.Geometry, log *log.Logger) error { log.Printf("WKTs not equal: %v", err) log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } return nil } @@ -201,7 +201,7 @@ func checkFromText(h *Handle, g geom.Geometry, log *log.Logger) error { } wkt := g.AsText() - want, err := h.FromText(wkt) + want, err := h.fromText(wkt) if err != nil { return err } @@ -214,14 +214,14 @@ func checkFromText(h *Handle, g geom.Geometry, log *log.Logger) error { if !geom.ExactEquals(got, want) { log.Printf("want: %v", want.AsText()) log.Printf("got: %v", got.AsText()) - return mismatchErr + return errMismatch } return nil } func checkAsBinary(h *Handle, g geom.Geometry, log *log.Logger) error { var wantDefined bool - want, err := h.AsBinary(g) + want, err := h.asBinary(g) if err == nil { wantDefined = true } @@ -240,10 +240,10 @@ func checkAsBinary(h *Handle, g geom.Geometry, log *log.Logger) error { } got := g.AsBinary() - if bytes.Compare(want, got) != 0 { + if !bytes.Equal(want, got) { log.Printf("want:\n%s", hex.Dump(want)) log.Printf("got:\n%s", hex.Dump(got)) - return mismatchErr + return errMismatch } return nil } @@ -268,7 +268,7 @@ func checkFromBinary(h *Handle, g geom.Geometry, log *log.Logger) error { } } - want, err := h.FromBinary(wkb) + want, err := h.fromBinary(wkb) if err != nil { return err } @@ -279,13 +279,13 @@ func checkFromBinary(h *Handle, g geom.Geometry, log *log.Logger) error { } if !geom.ExactEquals(want, got) { - return mismatchErr + return errMismatch } return nil } func checkIsEmpty(h *Handle, g geom.Geometry, log *log.Logger) error { - want, err := h.IsEmpty(g) + want, err := h.isEmpty(g) if err != nil { return err } @@ -294,7 +294,7 @@ func checkIsEmpty(h *Handle, g geom.Geometry, log *log.Logger) error { if want != got { log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } return nil } @@ -308,7 +308,7 @@ func checkDimension(h *Handle, g geom.Geometry, log *log.Logger) error { // So we don't get 'want' from libgeos in that case (and allow want to // default to 0). var err error - want, err = h.Dimension(g) + want, err = h.dimension(g) if err != nil { return err } @@ -318,32 +318,22 @@ func checkDimension(h *Handle, g geom.Geometry, log *log.Logger) error { if want != got { log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } return nil } func checkEnvelope(h *Handle, g geom.Geometry, log *log.Logger) error { - want, wantDefined, err := h.Envelope(g) + want, err := h.envelope(g) if err != nil { return err } - got, gotDefined := g.Envelope() + got := g.Envelope() - if wantDefined != gotDefined { - log.Println("disagreement about envelope being defined") - log.Printf("simplefeatures: %v", gotDefined) - log.Printf("libgeos: %v", wantDefined) - return mismatchErr - } - - if !wantDefined { - return nil - } if want.Min() != got.Min() || want.Max() != got.Max() { log.Printf("want: %v", want.AsGeometry().AsText()) log.Printf("got: %v", got.AsGeometry().AsText()) - return mismatchErr + return errMismatch } return nil } @@ -356,9 +346,9 @@ var isSimpleFlipResult = map[string]bool{ } func checkIsSimple(h *Handle, g geom.Geometry, log *log.Logger) error { - want, wantDefined, err := h.IsSimple(g) + want, wantDefined, err := h.isSimple(g) if err != nil { - if err == LibgeosCrashError { + if err == errLibgeosCrash { // Skip any tests that would cause libgeos to crash. return nil } @@ -370,7 +360,7 @@ func checkIsSimple(h *Handle, g geom.Geometry, log *log.Logger) error { if wantDefined != gotDefined { log.Printf("want defined: %v", wantDefined) log.Printf("got defined: %v", gotDefined) - return mismatchErr + return errMismatch } if !gotDefined { return nil @@ -379,13 +369,13 @@ func checkIsSimple(h *Handle, g geom.Geometry, log *log.Logger) error { if want != got { log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } return nil } func checkBoundary(h *Handle, g geom.Geometry, log *log.Logger) error { - want, wantDefined, err := h.Boundary(g) + want, wantDefined, err := h.boundary(g) if err != nil { return err } @@ -409,13 +399,13 @@ func checkBoundary(h *Handle, g geom.Geometry, log *log.Logger) error { if !geom.ExactEquals(want, got, geom.IgnoreOrder) { log.Printf("want: %v", want.AsText()) log.Printf("got: %v", got.AsText()) - return mismatchErr + return errMismatch } return nil } func checkConvexHull(h *Handle, g geom.Geometry, log *log.Logger) error { - want, err := h.ConvexHull(g) + want, err := h.convexHull(g) if err != nil { return err } @@ -431,13 +421,13 @@ func checkConvexHull(h *Handle, g geom.Geometry, log *log.Logger) error { if !geom.ExactEquals(want, got, geom.IgnoreOrder) { log.Printf("want: %v", want.AsText()) log.Printf("got: %v", got.AsText()) - return mismatchErr + return errMismatch } return nil } func checkIsRing(h *Handle, g geom.Geometry, log *log.Logger) error { - want, err := h.IsRing(g) + want, err := h.isRing(g) if err != nil { return err } @@ -446,13 +436,13 @@ func checkIsRing(h *Handle, g geom.Geometry, log *log.Logger) error { if want != got { log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } return nil } func checkLength(h *Handle, g geom.Geometry, log *log.Logger) error { - want, err := h.Length(g) + want, err := h.length(g) if err != nil { return err } @@ -468,7 +458,7 @@ func checkLength(h *Handle, g geom.Geometry, log *log.Logger) error { if math.Abs(want-got) > 1e-6 { log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } return nil } @@ -489,7 +479,7 @@ func isArealGeometry(g geom.Geometry) bool { } func checkArea(h *Handle, g geom.Geometry, log *log.Logger) error { - want, err := h.Area(g) + want, err := h.area(g) if err != nil { return err } @@ -498,13 +488,13 @@ func checkArea(h *Handle, g geom.Geometry, log *log.Logger) error { if math.Abs(want-got) > 1e-6 { log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } return nil } func checkCentroid(h *Handle, g geom.Geometry, log *log.Logger) error { - want, err := h.Centroid(g) + want, err := h.centroid(g) if err != nil { return err } @@ -513,7 +503,7 @@ func checkCentroid(h *Handle, g geom.Geometry, log *log.Logger) error { if !geom.ExactEquals(want, got, geom.ToleranceXY(1e-9)) { log.Printf("want: %v", want.AsText()) log.Printf("got: %v", got.AsText()) - return mismatchErr + return errMismatch } return nil } @@ -533,7 +523,7 @@ func checkPointOnSurface(h *Handle, g geom.Geometry, log *log.Logger) error { log.Printf("The geometry's empty status doesn't match the point's empty status") log.Printf("g empty: %v", g.IsEmpty()) log.Printf("pt empty: %v", pt.IsEmpty()) - return mismatchErr + return errMismatch } if !g.IsEmpty() && !g.IsGeometryCollection() { @@ -543,7 +533,7 @@ func checkPointOnSurface(h *Handle, g geom.Geometry, log *log.Logger) error { } if !intersects { log.Printf("the pt doesn't intersect with the input") - return mismatchErr + return errMismatch } } @@ -554,7 +544,7 @@ func checkPointOnSurface(h *Handle, g geom.Geometry, log *log.Logger) error { } if !contains { log.Printf("the input doesn't contain the pt") - return mismatchErr + return errMismatch } } @@ -565,7 +555,7 @@ func checkSimplify(h *Handle, g geom.Geometry, log *log.Logger) error { for _, threshold := range []float64{0.125, 0.25, 0.5, 1, 2, 4, 8, 16} { // If we get an error from GEOS, then we may or may not get an error from // simplefeatures. - want, err := h.Simplify(g, threshold) + want, err := h.simplify(g, threshold) wantIsValid := err == nil // Even if GEOS couldn't simplify, we still want to attempt to simplify @@ -582,7 +572,7 @@ func checkSimplify(h *Handle, g geom.Geometry, log *log.Logger) error { log.Printf("Simplify results not equal for threshold=%v", threshold) log.Printf("want: %v", want.AsText()) log.Printf("got: %v", got.AsText()) - return mismatchErr + return errMismatch } } return nil @@ -644,9 +634,9 @@ func checkIntersects(h *Handle, g1, g2 geom.Geometry, log *log.Logger) error { return nil } - want, err := h.Intersects(g1, g2) + want, err := h.intersects(g1, g2) if err != nil { - if err == LibgeosCrashError { + if err == errLibgeosCrash { return nil } return err @@ -656,13 +646,13 @@ func checkIntersects(h *Handle, g1, g2 geom.Geometry, log *log.Logger) error { if want != got { log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } return nil } func checkExactEquals(h *Handle, g1, g2 geom.Geometry, log *log.Logger) error { - want, err := h.ExactEquals(g1, g2) + want, err := h.exactEquals(g1, g2) if err != nil { return err } @@ -671,15 +661,15 @@ func checkExactEquals(h *Handle, g1, g2 geom.Geometry, log *log.Logger) error { if want != got { log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } return nil } func checkDistance(h *Handle, g1, g2 geom.Geometry, log *log.Logger) error { - want, err := h.Distance(g1, g2) + want, err := h.distance(g1, g2) if err != nil { - if err == LibgeosCrashError { + if err == errLibgeosCrash { // Skip any tests that would cause libgeos to crash. return nil } @@ -694,7 +684,7 @@ func checkDistance(h *Handle, g1, g2 geom.Geometry, log *log.Logger) error { if math.Abs(want-got) > 1e-12 { log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } return nil } @@ -763,25 +753,25 @@ func checkDCELOperations(h *Handle, g1, g2 geom.Geometry, log *log.Logger) error { "Union", func(g1, g2 geom.Geometry) (geom.Geometry, error) { return geom.Union(g1, g2) }, - func(g1, g2 geom.Geometry) (geom.Geometry, error) { return h.Union(g1, g2) }, + func(g1, g2 geom.Geometry) (geom.Geometry, error) { return h.union(g1, g2) }, nil, }, { "Intersection", func(g1, g2 geom.Geometry) (geom.Geometry, error) { return geom.Intersection(g1, g2) }, - func(g1, g2 geom.Geometry) (geom.Geometry, error) { return h.Intersection(g1, g2) }, + func(g1, g2 geom.Geometry) (geom.Geometry, error) { return h.intersection(g1, g2) }, skipIntersection, }, { "Difference", func(g1, g2 geom.Geometry) (geom.Geometry, error) { return geom.Difference(g1, g2) }, - func(g1, g2 geom.Geometry) (geom.Geometry, error) { return h.Difference(g1, g2) }, + func(g1, g2 geom.Geometry) (geom.Geometry, error) { return h.difference(g1, g2) }, skipDifference, }, { "SymmetricDifference", func(g1, g2 geom.Geometry) (geom.Geometry, error) { return geom.SymmetricDifference(g1, g2) }, - func(g1, g2 geom.Geometry) (geom.Geometry, error) { return h.SymmetricDifference(g1, g2) }, + func(g1, g2 geom.Geometry) (geom.Geometry, error) { return h.symmetricDifference(g1, g2) }, skipSymDiff, }, } { @@ -829,7 +819,7 @@ func checkDCELOp( want, err := refImpl(g1, g2) if err != nil { - if err == ErrInvalidAccordingToGEOS { + if err == errInvalidAccordingToGEOS { // Because GEOS has given us back an invalid geometry (even according // to its own validation routines) we can't trust it for this test // case. @@ -865,7 +855,7 @@ func checkDCELOp( if !eq { log.Printf("want: %v", want.AsText()) log.Printf("got: %v", got.AsText()) - return mismatchErr + return errMismatch } return nil } @@ -883,7 +873,7 @@ func checkEqualityHeuristic(want, got geom.Geometry, log *log.Logger) error { log.Printf("gotWKT: %v\n", got.AsText()) log.Printf("wantArea: %v\n", wantArea) log.Printf("gotArea: %v\n", gotArea) - return mismatchErr + return errMismatch } return nil } @@ -893,9 +883,9 @@ func checkRelate(h *Handle, g1, g2 geom.Geometry, log *log.Logger) error { if err != nil { return err } - want, err := h.Relate(g1, g2) + want, err := h.relate(g1, g2) if err != nil { - if err == LibgeosCrashError { + if err == errLibgeosCrash { // Skip any tests that would cause libgeos to crash. return nil } @@ -904,7 +894,7 @@ func checkRelate(h *Handle, g1, g2 geom.Geometry, log *log.Logger) error { // Skip any linear and non-simple geometries. This is because GEOS has // inconsistent behaviour with the generated relate matrix, making it hard - // to match the exact behavour. + // to match the exact behaviour. if linearAndNonSimple(g1) || linearAndNonSimple(g2) { return nil } @@ -926,7 +916,7 @@ func checkRelate(h *Handle, g1, g2 geom.Geometry, log *log.Logger) error { if got != want { log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } return nil @@ -936,7 +926,7 @@ func checkRelateMatch(h *Handle, log *log.Logger) error { for i := 0; i < 1_000_000; i++ { mat := rand9("F012") pat := rand9("F012T*") - want, err := h.RelateMatch(mat, pat) + want, err := h.relateMatch(mat, pat) if err != nil { log.Printf("could not calculate want: %v", err) return err @@ -951,7 +941,7 @@ func checkRelateMatch(h *Handle, log *log.Logger) error { log.Printf("pat: %v", pat) log.Printf("want: %v", want) log.Printf("got: %v", got) - return mismatchErr + return errMismatch } } return nil diff --git a/internal/cmprefimpl/cmpgeos/handle.go b/internal/cmprefimpl/cmpgeos/handle.go index 8b9bf47a..27feda5e 100644 --- a/internal/cmprefimpl/cmpgeos/handle.go +++ b/internal/cmprefimpl/cmpgeos/handle.go @@ -30,9 +30,9 @@ GEOSContextHandle_t sf_geos_init(void *userdata) { import "C" var ( - NonEmptyGeometryCollectionNotSupportedError = errors.New( + errNonEmptyGeometryCollectionNotSupported = errors.New( "non-empty GeometryCollection not supported") - LibgeosCrashError = errors.New( + errLibgeosCrash = errors.New( "libgeos would crash with this input") ) @@ -66,7 +66,7 @@ func NewHandle() (*Handle, error) { } // Close cleans up memory resources associated with the handle. If Close is not -// called, then a memory leak will occurr. +// called, then a memory leak will occur. func (h *Handle) Close() { C.GEOSWKBWriter_destroy_r(h.context, h.wkbWriter) C.GEOSWKBReader_destroy_r(h.context, h.wkbReader) @@ -293,7 +293,7 @@ func (h *Handle) decodeGeomHandle(gh *C.GEOSGeometry) (geom.Geometry, error) { subPoints[i] = subPointAsGeom.AsPoint() } } - return geom.NewMultiPointFromPoints(subPoints).AsGeometry(), nil + return geom.NewMultiPoint(subPoints).AsGeometry(), nil case "MultiPolygon": n := C.GEOSGetNumGeometries_r(h.context, gh) if n == -1 { @@ -315,7 +315,7 @@ func (h *Handle) decodeGeomHandle(gh *C.GEOSGeometry) (geom.Geometry, error) { } subPolys[i] = subPolyAsGeom.AsPolygon() } - mp, err := geom.NewMultiPolygonFromPolygons(subPolys) + mp, err := geom.NewMultiPolygon(subPolys) return mp.AsGeometry(), err case "GeometryCollection": n := C.GEOSGetNumGeometries_r(h.context, gh) @@ -342,9 +342,9 @@ func (h *Handle) decodeGeomHandle(gh *C.GEOSGeometry) (geom.Geometry, error) { } } -// ErrInvalidAccordingToGEOS indicates that the geometry or geometry resulting +// errInvalidAccordingToGEOS indicates that the geometry or geometry resulting // from an operation is invalid according to GEOS. -var ErrInvalidAccordingToGEOS = errors.New("invalid geometry according to GEOS") +var errInvalidAccordingToGEOS = errors.New("invalid geometry according to GEOS") func (h *Handle) decodeGeomHandleUsingWKB(gh *C.GEOSGeometry) (geom.Geometry, error) { // Check to see if GEOS thinks the geometry is invalid. Sometimes the @@ -356,7 +356,7 @@ func (h *Handle) decodeGeomHandleUsingWKB(gh *C.GEOSGeometry) (geom.Geometry, er return geom.Geometry{}, err } if !isValid { - return geom.Geometry{}, ErrInvalidAccordingToGEOS + return geom.Geometry{}, errInvalidAccordingToGEOS } var size C.size_t @@ -370,7 +370,7 @@ func (h *Handle) decodeGeomHandleUsingWKB(gh *C.GEOSGeometry) (geom.Geometry, er return geom.UnmarshalWKB(byts) } -func (h *Handle) AsText(g geom.Geometry) (string, error) { +func (h *Handle) asText(g geom.Geometry) (string, error) { gh, err := h.createGeomHandle(g) if err != nil { return "", err @@ -392,7 +392,7 @@ func (h *Handle) AsText(g geom.Geometry) (string, error) { return C.GoString(wkt), nil } -func (h *Handle) FromText(wkt string) (geom.Geometry, error) { +func (h *Handle) fromText(wkt string) (geom.Geometry, error) { reader := C.GEOSWKTReader_create_r(h.context) if reader == nil { return geom.Geometry{}, fmt.Errorf("creating wkt reader: %v", h.err()) @@ -410,7 +410,7 @@ func (h *Handle) FromText(wkt string) (geom.Geometry, error) { return h.decodeGeomHandle(gh) } -func (h *Handle) AsBinary(g geom.Geometry) ([]byte, error) { +func (h *Handle) asBinary(g geom.Geometry) ([]byte, error) { gh, err := h.createGeomHandle(g) if err != nil { return nil, err @@ -431,7 +431,7 @@ func (h *Handle) AsBinary(g geom.Geometry) ([]byte, error) { return C.GoBytes(unsafe.Pointer(wkb), C.int(size)), nil } -func (h *Handle) FromBinary(wkb []byte) (geom.Geometry, error) { +func (h *Handle) fromBinary(wkb []byte) (geom.Geometry, error) { gh := C.GEOSWKBReader_read_r( h.context, h.wkbReader, @@ -445,7 +445,7 @@ func (h *Handle) FromBinary(wkb []byte) (geom.Geometry, error) { return h.decodeGeomHandle(gh) } -func (h *Handle) IsEmpty(g geom.Geometry) (bool, error) { +func (h *Handle) isEmpty(g geom.Geometry) (bool, error) { gh, err := h.createGeomHandle(g) if err != nil { return false, err @@ -455,7 +455,7 @@ func (h *Handle) IsEmpty(g geom.Geometry) (bool, error) { return h.boolErr(C.GEOSisEmpty_r(h.context, gh)) } -func (h *Handle) Dimension(g geom.Geometry) (int, error) { +func (h *Handle) dimension(g geom.Geometry) (int, error) { gh, err := h.createGeomHandle(g) if err != nil { return 0, err @@ -469,23 +469,23 @@ func (h *Handle) Dimension(g geom.Geometry) (int, error) { return dim, nil } -func (h *Handle) Envelope(g geom.Geometry) (geom.Envelope, bool, error) { +func (h *Handle) envelope(g geom.Geometry) (geom.Envelope, error) { gh, err := h.createGeomHandle(g) if err != nil { - return geom.Envelope{}, false, err + return geom.Envelope{}, err } defer C.GEOSGeom_destroy(gh) env := C.GEOSEnvelope_r(h.context, gh) if env == nil { - return geom.Envelope{}, false, h.err() + return geom.Envelope{}, h.err() } defer C.GEOSGeom_destroy_r(h.context, env) if isEmpty, err := h.boolErr(C.GEOSisEmpty_r(h.context, env)); err != nil { - return geom.Envelope{}, false, err + return geom.Envelope{}, err } else if isEmpty { - return geom.Envelope{}, false, nil + return geom.Envelope{}, nil } // libgeos will return either a Point or a Polygon. In the case where the @@ -493,39 +493,45 @@ func (h *Handle) Envelope(g geom.Geometry) (geom.Envelope, bool, error) { // invalid Polygon is returned. geomType := C.GEOSGeomType_r(h.context, env) if geomType == nil { - return geom.Envelope{}, false, h.err() + return geom.Envelope{}, h.err() } defer C.free(unsafe.Pointer(geomType)) - if C.GoString(geomType) == "Point" { + switch gTypeStr := C.GoString(geomType); gTypeStr { + case "Point": var x C.double if C.GEOSGeomGetX_r(h.context, env, &x) == 0 { - return geom.Envelope{}, false, h.err() + return geom.Envelope{}, h.err() } var y C.double if C.GEOSGeomGetY_r(h.context, env, &y) == 0 { - return geom.Envelope{}, false, h.err() + return geom.Envelope{}, h.err() } - return geom.NewEnvelope(geom.XY{X: float64(x), Y: float64(y)}), true, nil + return geom.NewEnvelope([]geom.XY{{X: float64(x), Y: float64(y)}}) + case "Polygon": + // Continues below + default: + return geom.Envelope{}, fmt.Errorf( + "unexpected geometry type from GEOSEnvelope_r: %v", gTypeStr) } ring := C.GEOSGetExteriorRing_r(h.context, env) if ring == nil { - return geom.Envelope{}, false, h.err() + return geom.Envelope{}, h.err() } // ring belongs to env, so doesn't need to be destroyed. seq := C.GEOSGeom_getCoordSeq_r(h.context, ring) if seq == nil { - return geom.Envelope{}, false, h.err() + return geom.Envelope{}, h.err() } // seq belongs to ring, so doesn't need to be destroyed. var size C.uint if C.GEOSCoordSeq_getSize_r(h.context, seq, &size) == 0 { - return geom.Envelope{}, false, h.err() + return geom.Envelope{}, h.err() } if size == 0 { - return geom.Envelope{}, false, errors.New( + return geom.Envelope{}, errors.New( "coordinate sequence doesn't contain any points") } @@ -533,28 +539,27 @@ func (h *Handle) Envelope(g geom.Geometry) (geom.Envelope, bool, error) { for i := C.uint(0); i < size; i++ { var x C.double if C.GEOSCoordSeq_getX_r(h.context, seq, i, &x) == 0 { - return geom.Envelope{}, false, h.err() + return geom.Envelope{}, h.err() } var y C.double if C.GEOSCoordSeq_getY_r(h.context, seq, i, &y) == 0 { - return geom.Envelope{}, false, h.err() + return geom.Envelope{}, h.err() } xy := geom.XY{X: float64(x), Y: float64(y)} - if i == 0 { - sfEnv = geom.NewEnvelope(xy) - } else { - sfEnv = sfEnv.ExtendToIncludePoint(xy) + sfEnv, err = sfEnv.ExtendToIncludeXY(xy) + if err != nil { + return geom.Envelope{}, err } } - return sfEnv, true, nil + return sfEnv, nil } -func (h *Handle) IsSimple(g geom.Geometry) (isSimple bool, defined bool, err error) { +func (h *Handle) isSimple(g geom.Geometry) (isSimple bool, defined bool, err error) { // libgeos crashes when GEOSisSimple_r is called with MultiPoints // containing empty Points. if containsMultiPointWithEmptyPoint(g) { - return false, false, LibgeosCrashError + return false, false, errLibgeosCrash } gh, err := h.createGeomHandle(g) @@ -577,7 +582,7 @@ func (h *Handle) IsSimple(g geom.Geometry) (isSimple bool, defined bool, err err return isSimple, true, err } -func (h *Handle) Boundary(g geom.Geometry) (geom.Geometry, bool, error) { +func (h *Handle) boundary(g geom.Geometry) (geom.Geometry, bool, error) { gh, err := h.createGeomHandle(g) if err != nil { return geom.Geometry{}, false, err @@ -603,7 +608,7 @@ func (h *Handle) Boundary(g geom.Geometry) (geom.Geometry, bool, error) { return sfBound, true, err } -func (h *Handle) ConvexHull(g geom.Geometry) (geom.Geometry, error) { +func (h *Handle) convexHull(g geom.Geometry) (geom.Geometry, error) { gh, err := h.createGeomHandle(g) if err != nil { return geom.Geometry{}, err @@ -619,7 +624,7 @@ func (h *Handle) ConvexHull(g geom.Geometry) (geom.Geometry, error) { return h.decodeGeomHandle(env) } -func (h *Handle) IsValid(g geom.Geometry) (bool, error) { +func (h *Handle) isValid(g geom.Geometry) (bool, error) { gh, err := h.createGeomHandle(g) if err != nil { return false, err @@ -629,7 +634,7 @@ func (h *Handle) IsValid(g geom.Geometry) (bool, error) { return h.boolErr(C.GEOSisValid_r(h.context, gh)) } -func (h *Handle) IsRing(g geom.Geometry) (bool, error) { +func (h *Handle) isRing(g geom.Geometry) (bool, error) { gh, err := h.createGeomHandle(g) if err != nil { return false, err @@ -639,7 +644,7 @@ func (h *Handle) IsRing(g geom.Geometry) (bool, error) { return h.boolErr(C.GEOSisRing_r(h.context, gh)) } -func (h *Handle) Length(g geom.Geometry) (float64, error) { +func (h *Handle) length(g geom.Geometry) (float64, error) { gh, err := h.createGeomHandle(g) if err != nil { return 0, err @@ -651,7 +656,7 @@ func (h *Handle) Length(g geom.Geometry) (float64, error) { return length, h.intToErr(errInt) } -func (h *Handle) Area(g geom.Geometry) (float64, error) { +func (h *Handle) area(g geom.Geometry) (float64, error) { gh, err := h.createGeomHandle(g) if err != nil { return 0, err @@ -663,7 +668,7 @@ func (h *Handle) Area(g geom.Geometry) (float64, error) { return area, h.intToErr(errInt) } -func (h *Handle) Centroid(g geom.Geometry) (geom.Geometry, error) { +func (h *Handle) centroid(g geom.Geometry) (geom.Geometry, error) { gh, err := h.createGeomHandle(g) if err != nil { return geom.Geometry{}, err @@ -679,7 +684,7 @@ func (h *Handle) Centroid(g geom.Geometry) (geom.Geometry, error) { return h.decodeGeomHandle(env) } -func (h *Handle) Reverse(g geom.Geometry) (geom.Geometry, error) { +func (h *Handle) reverse(g geom.Geometry) (geom.Geometry, error) { gh, err := h.createGeomHandle(g) if err != nil { return geom.Geometry{}, err @@ -695,7 +700,7 @@ func (h *Handle) Reverse(g geom.Geometry) (geom.Geometry, error) { return h.decodeGeomHandle(env) } -func (h *Handle) Simplify(g geom.Geometry, threshold float64) (geom.Geometry, error) { +func (h *Handle) simplify(g geom.Geometry, threshold float64) (geom.Geometry, error) { gh, err := h.createGeomHandle(g) if err != nil { return geom.Geometry{}, err @@ -711,14 +716,14 @@ func (h *Handle) Simplify(g geom.Geometry, threshold float64) (geom.Geometry, er return h.decodeGeomHandle(simp) } -func (h *Handle) Intersects(g1, g2 geom.Geometry) (bool, error) { +func (h *Handle) intersects(g1, g2 geom.Geometry) (bool, error) { if isNonEmptyGeometryCollection(g1) || isNonEmptyGeometryCollection(g2) { - return false, NonEmptyGeometryCollectionNotSupportedError + return false, errNonEmptyGeometryCollectionNotSupported } // libgeos crashes when GEOSIntersects_r is called with MultiPoints // containing empty Points. if containsMultiPointWithEmptyPoint(g1) || containsMultiPointWithEmptyPoint(g2) { - return false, LibgeosCrashError + return false, errLibgeosCrash } gh1, err := h.createGeomHandle(g1) @@ -735,9 +740,9 @@ func (h *Handle) Intersects(g1, g2 geom.Geometry) (bool, error) { return h.boolErr(C.GEOSIntersects_r(h.context, gh1, gh2)) } -func (h *Handle) ExactEquals(g1, g2 geom.Geometry) (bool, error) { +func (h *Handle) exactEquals(g1, g2 geom.Geometry) (bool, error) { if isNonEmptyGeometryCollection(g1) || isNonEmptyGeometryCollection(g2) { - return false, NonEmptyGeometryCollectionNotSupportedError + return false, errNonEmptyGeometryCollectionNotSupported } gh1, err := h.createGeomHandle(g1) @@ -754,7 +759,7 @@ func (h *Handle) ExactEquals(g1, g2 geom.Geometry) (bool, error) { return h.boolErr(C.GEOSEqualsExact_r(h.context, gh1, gh2, 0.0)) } -func (h *Handle) Distance(g1, g2 geom.Geometry) (float64, error) { +func (h *Handle) distance(g1, g2 geom.Geometry) (float64, error) { if containsMultiLineStringWithEmptyLineString(g1) || containsMultiLineStringWithEmptyLineString(g2) || containsMultiPointWithEmptyPoint(g1) || @@ -762,7 +767,7 @@ func (h *Handle) Distance(g1, g2 geom.Geometry) (float64, error) { containsMultiPolygonWithEmptyPolygon(g1) || containsMultiPolygonWithEmptyPolygon(g2) { // GEOS crashes on these inputs. - return 0, LibgeosCrashError + return 0, errLibgeosCrash } gh1, err := h.createGeomHandle(g1) @@ -781,7 +786,7 @@ func (h *Handle) Distance(g1, g2 geom.Geometry) (float64, error) { return float64(dist), err } -func (h *Handle) Union(g1, g2 geom.Geometry) (geom.Geometry, error) { +func (h *Handle) union(g1, g2 geom.Geometry) (geom.Geometry, error) { gh1, err := h.createGeomHandle(g1) if err != nil { return geom.Geometry{}, h.err() @@ -802,7 +807,7 @@ func (h *Handle) Union(g1, g2 geom.Geometry) (geom.Geometry, error) { return h.decodeGeomHandle(union) } -func (h *Handle) Intersection(g1, g2 geom.Geometry) (geom.Geometry, error) { +func (h *Handle) intersection(g1, g2 geom.Geometry) (geom.Geometry, error) { gh1, err := h.createGeomHandle(g1) if err != nil { return geom.Geometry{}, h.err() @@ -823,7 +828,7 @@ func (h *Handle) Intersection(g1, g2 geom.Geometry) (geom.Geometry, error) { return h.decodeGeomHandle(union) } -func (h *Handle) Difference(g1, g2 geom.Geometry) (geom.Geometry, error) { +func (h *Handle) difference(g1, g2 geom.Geometry) (geom.Geometry, error) { gh1, err := h.createGeomHandle(g1) if err != nil { return geom.Geometry{}, h.err() @@ -844,7 +849,7 @@ func (h *Handle) Difference(g1, g2 geom.Geometry) (geom.Geometry, error) { return h.decodeGeomHandle(union) } -func (h *Handle) SymmetricDifference(g1, g2 geom.Geometry) (geom.Geometry, error) { +func (h *Handle) symmetricDifference(g1, g2 geom.Geometry) (geom.Geometry, error) { gh1, err := h.createGeomHandle(g1) if err != nil { return geom.Geometry{}, h.err() @@ -865,10 +870,10 @@ func (h *Handle) SymmetricDifference(g1, g2 geom.Geometry) (geom.Geometry, error return h.decodeGeomHandle(union) } -func (h *Handle) Relate(g1, g2 geom.Geometry) (string, error) { +func (h *Handle) relate(g1, g2 geom.Geometry) (string, error) { if containsMultiPointWithEmptyPoint(g1) || containsMultiPointWithEmptyPoint(g2) { // GEOS crashes on these inputs. - return "", LibgeosCrashError + return "", errLibgeosCrash } gh1, err := h.createGeomHandle(g1) @@ -890,7 +895,7 @@ func (h *Handle) Relate(g1, g2 geom.Geometry) (string, error) { return C.GoString(matrix), nil } -func (h *Handle) RelateMatch(mat, pat string) (bool, error) { +func (h *Handle) relateMatch(mat, pat string) (bool, error) { cMat := C.CString(mat) cPat := C.CString(pat) defer C.free(unsafe.Pointer(cMat)) diff --git a/internal/cmprefimpl/cmpgeos/util_test.go b/internal/cmprefimpl/cmpgeos/util_test.go index 9b67a970..71535631 100644 --- a/internal/cmprefimpl/cmpgeos/util_test.go +++ b/internal/cmprefimpl/cmpgeos/util_test.go @@ -25,8 +25,11 @@ func TestMantissaTerminatesQuickly(t *testing.T) { {math.Pi, false}, } { t.Run(fmt.Sprintf("%v", tt.f), func(t *testing.T) { - pt := geom.NewPointFromXY(geom.XY{X: tt.f, Y: tt.f}).AsGeometry() - got := mantissaTerminatesQuickly(pt) + pt, err := geom.XY{X: tt.f, Y: tt.f}.AsPoint() + if err != nil { + t.Fatal(err) + } + got := mantissaTerminatesQuickly(pt.AsGeometry()) if got != tt.want { t.Errorf("got=%v want=%v", got, tt.want) } diff --git a/internal/cmprefimpl/cmppg/checks.go b/internal/cmprefimpl/cmppg/checks.go index 4941a9b3..74687b3d 100644 --- a/internal/cmprefimpl/cmppg/checks.go +++ b/internal/cmprefimpl/cmppg/checks.go @@ -14,7 +14,7 @@ import ( "github.com/peterstace/simplefeatures/geom" ) -func CheckWKTParse(t *testing.T, pg PostGIS, candidates []string) { +func checkWKTParse(t *testing.T, pg PostGIS, candidates []string) { var any bool for i, wkt := range candidates { any = true @@ -44,7 +44,7 @@ func CheckWKTParse(t *testing.T, pg PostGIS, candidates []string) { } } -func CheckWKBParse(t *testing.T, pg PostGIS, candidates []string) { +func checkWKBParse(t *testing.T, pg PostGIS, candidates []string) { var any bool for i, wkb := range candidates { buf, err := hexStringToBytes(wkb) @@ -95,7 +95,7 @@ func hexStringToBytes(s string) ([]byte, error) { return buf, nil } -func CheckGeoJSONParse(t *testing.T, pg PostGIS, candidates []string) { +func checkGeoJSONParse(t *testing.T, pg PostGIS, candidates []string) { var any bool for i, geojson := range candidates { if geojson == `{"type":"Point","coordinates":[]}` { @@ -133,7 +133,7 @@ func CheckGeoJSONParse(t *testing.T, pg PostGIS, candidates []string) { } } -func CheckWKB(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkWKB(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckWKB", func(t *testing.T) { if g.IsEmpty() && ((g.IsGeometryCollection() && g.AsGeometryCollection().NumGeometries() > 0) || (g.IsMultiPoint() && g.AsMultiPoint().NumPoints() > 0) || @@ -164,7 +164,7 @@ func CheckWKB(t *testing.T, want UnaryResult, g geom.Geometry) { }) } -func CheckGeoJSON(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkGeoJSON(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckGeoJSON", func(t *testing.T) { if containsMultiPointContainingEmptyPoint(g) { // PostGIS gives completely wrong GeoJSON in this case (it's not @@ -234,7 +234,7 @@ func tokenize(str string) []string { return tokens } -func CheckIsEmpty(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkIsEmpty(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckIsEmpty", func(t *testing.T) { got := g.IsEmpty() want := want.IsEmpty @@ -246,7 +246,7 @@ func CheckIsEmpty(t *testing.T, want UnaryResult, g geom.Geometry) { }) } -func CheckDimension(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkDimension(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckDimension", func(t *testing.T) { got := g.Dimension() want := want.Dimension @@ -258,21 +258,17 @@ func CheckDimension(t *testing.T, want UnaryResult, g geom.Geometry) { }) } -func CheckEnvelope(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkEnvelope(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckEnvelope", func(t *testing.T) { - if g.IsEmpty() { - // PostGIS allows envelopes on empty geometries, but they are empty - // envelopes. In simplefeatures, an envelope is never empty, so we - // skip testing that case. + got := g.Envelope().AsGeometry() + want := want.Envelope + + // The geometry type for empty envelopes is different for + // simplefeatures vs PostGIS. We consider "both empty" to be equivalent + // for the purpose of this test. + if got.IsEmpty() && want.IsEmpty() { return } - env, ok := g.Envelope() - if !ok { - // We just checked IsEmpty, so this should never happen. - panic("could not get envelope") - } - got := env.AsGeometry() - want := want.Envelope if !geom.ExactEquals(got, want) { t.Logf("got: %v", got.AsText()) @@ -282,7 +278,7 @@ func CheckEnvelope(t *testing.T, want UnaryResult, g geom.Geometry) { }) } -func CheckConvexHull(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkConvexHull(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckConvexHull", func(t *testing.T) { got := g.ConvexHull() want := want.ConvexHull @@ -300,7 +296,7 @@ func CheckConvexHull(t *testing.T, want UnaryResult, g geom.Geometry) { }) } -func CheckIsRing(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkIsRing(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckIsRing", func(t *testing.T) { isDefined := g.IsLineString() if want.IsRing.Valid != isDefined { @@ -324,7 +320,7 @@ func CheckIsRing(t *testing.T, want UnaryResult, g geom.Geometry) { }) } -func CheckLength(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkLength(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckLength", func(t *testing.T) { got := g.Length() if math.Abs(got-want.Length) > 1e-6 { @@ -355,7 +351,7 @@ func containsMultiPointContainingEmptyPoint(g geom.Geometry) bool { return false } -func CheckArea(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkArea(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckArea", func(t *testing.T) { got := g.Area() want := want.Area @@ -368,7 +364,7 @@ func CheckArea(t *testing.T, want UnaryResult, g geom.Geometry) { }) } -func CheckCentroid(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkCentroid(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckCentroid", func(t *testing.T) { got := g.Centroid() @@ -387,7 +383,7 @@ func CheckCentroid(t *testing.T, want UnaryResult, g geom.Geometry) { }) } -func CheckReverse(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkReverse(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckReverse", func(t *testing.T) { got := g.Reverse() want := want.Reverse @@ -400,7 +396,7 @@ func CheckReverse(t *testing.T, want UnaryResult, g geom.Geometry) { }) } -func CheckType(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkType(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckType", func(t *testing.T) { got := g.Type().String() want := want.Type @@ -413,7 +409,7 @@ func CheckType(t *testing.T, want UnaryResult, g geom.Geometry) { }) } -func CheckForceOrientation(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkForceOrientation(t *testing.T, want UnaryResult, g geom.Geometry) { if !containsOnlyPolygonsOrMultiPolygons(g) { // Skip geometries that contain things other than areal components. // PostGIS does some weird things with LineStrings when it forces @@ -453,7 +449,7 @@ func CheckForceOrientation(t *testing.T, want UnaryResult, g geom.Geometry) { }) } -func CheckDump(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkDump(t *testing.T, want UnaryResult, g geom.Geometry) { if g.IsEmpty() { // For empty geometries, PostGIS just returns no dumped geometries. // Simplefeatures chooses not to do this behaviour to provide better @@ -503,7 +499,7 @@ func containsOnlyPolygonsOrMultiPolygons(g geom.Geometry) bool { } } -func CheckForceCoordinatesDimension(t *testing.T, want UnaryResult, g geom.Geometry) { +func checkForceCoordinatesDimension(t *testing.T, want UnaryResult, g geom.Geometry) { t.Run("CheckForceCoordinatesDimension", func(t *testing.T) { // In the case where a collection has some elements but they are all diff --git a/internal/cmprefimpl/cmppg/fuzz_test.go b/internal/cmprefimpl/cmppg/fuzz_test.go index ce2c830e..fe107b9c 100644 --- a/internal/cmprefimpl/cmppg/fuzz_test.go +++ b/internal/cmprefimpl/cmppg/fuzz_test.go @@ -21,9 +21,9 @@ func TestFuzz(t *testing.T) { pg := setupDB(t) candidates := extractStringsFromSource(t) - CheckWKTParse(t, pg, candidates) - CheckWKBParse(t, pg, candidates) - CheckGeoJSONParse(t, pg, candidates) + checkWKTParse(t, pg, candidates) + checkWKBParse(t, pg, candidates) + checkGeoJSONParse(t, pg, candidates) geoms := convertToGeometries(t, candidates) @@ -40,24 +40,25 @@ func TestFuzz(t *testing.T) { t.Skip("Causes unmarshalling to fail for derivative geometry") } - want, err := BatchPostGIS{pg.db}.Unary(g) + want, err := BatchPostGIS(pg).Unary(g) if err != nil { t.Fatalf("could not get result from postgis: %v", err) } - CheckWKB(t, want, g) - CheckGeoJSON(t, want, g) - CheckIsEmpty(t, want, g) - CheckDimension(t, want, g) - CheckEnvelope(t, want, g) - CheckConvexHull(t, want, g) - CheckIsRing(t, want, g) - CheckLength(t, want, g) - CheckArea(t, want, g) - CheckCentroid(t, want, g) - CheckReverse(t, want, g) - CheckType(t, want, g) - CheckForceOrientation(t, want, g) - CheckDump(t, want, g) + checkWKB(t, want, g) + checkGeoJSON(t, want, g) + checkIsEmpty(t, want, g) + checkDimension(t, want, g) + checkEnvelope(t, want, g) + checkConvexHull(t, want, g) + checkIsRing(t, want, g) + checkLength(t, want, g) + checkArea(t, want, g) + checkCentroid(t, want, g) + checkReverse(t, want, g) + checkType(t, want, g) + checkForceOrientation(t, want, g) + checkDump(t, want, g) + checkForceCoordinatesDimension(t, want, g) }) } } diff --git a/internal/cmprefimpl/cmppg/pg.go b/internal/cmprefimpl/cmppg/pg.go index 9b55a692..ef153e99 100644 --- a/internal/cmprefimpl/cmppg/pg.go +++ b/internal/cmprefimpl/cmppg/pg.go @@ -8,10 +8,13 @@ import ( "github.com/peterstace/simplefeatures/geom" ) +// BatchPostGIS is a DB access type allowing batch based interactions with a +// PostGIS database. type BatchPostGIS struct { db *sql.DB } +// UnaryResult holds the result of unary (single input) PostGIS operations. type UnaryResult struct { AsText string AsBinary []byte @@ -41,6 +44,7 @@ const ( postgisTypePrefix = "ST_" ) +// Unary runs a batch of unary operations on a geometry. func (p BatchPostGIS) Unary(g geom.Geometry) (UnaryResult, error) { // WKB and WKB forms returned from PostGIS don't _always_ give the same // result (usually differences around empty geometries). In the case of diff --git a/internal/cmprefimpl/cmppg/postgis.go b/internal/cmprefimpl/cmppg/postgis.go index f589fc47..a9384c4c 100644 --- a/internal/cmprefimpl/cmppg/postgis.go +++ b/internal/cmprefimpl/cmppg/postgis.go @@ -3,14 +3,15 @@ package main import ( "database/sql" "testing" - - "github.com/peterstace/simplefeatures/geom" ) +// PostGIS is a DB access type allowing non-batch based interactions +// with a PostGIS database. type PostGIS struct { db *sql.DB } +// WKTIsValidWithReason checks if a WKT is valid, and if not gives the reason. func (p PostGIS) WKTIsValidWithReason(wkt string) (bool, string) { var isValid bool var reason string @@ -30,6 +31,7 @@ func (p PostGIS) WKTIsValidWithReason(wkt string) (bool, string) { return isValid, reason } +// WKBIsValidWithReason checks if a WKB is valid, and if not gives the reason. func (p PostGIS) WKBIsValidWithReason(t *testing.T, wkb string) (bool, string) { var isValid bool err := p.db.QueryRow(`SELECT ST_IsValid(ST_GeomFromWKB(decode($1, 'hex')))`, wkb).Scan(&isValid) @@ -44,6 +46,8 @@ func (p PostGIS) WKBIsValidWithReason(t *testing.T, wkb string) (bool, string) { return isValid, reason } +// GeoJSONIsValidWithReason checks if a GeoJSON object is valid, and if not +// gives the reason. func (p PostGIS) GeoJSONIsValidWithReason(t *testing.T, geojson string) (bool, string) { var isValid bool err := p.db.QueryRow(`SELECT ST_IsValid(ST_GeomFromGeoJSON($1))`, geojson).Scan(&isValid) @@ -58,177 +62,3 @@ func (p PostGIS) GeoJSONIsValidWithReason(t *testing.T, geojson string) (bool, s } return isValid, reason } - -func (p PostGIS) geomFunc(t *testing.T, g geom.Geometry, stFunc string) geom.Geometry { - var result geom.Geometry - if err := p.db.QueryRow( - "SELECT ST_AsBinary("+stFunc+"(ST_GeomFromWKB($1)))", g, - ).Scan(&result); err != nil { - t.Fatalf("pg error: %v", err) - } - return result -} - -func (p PostGIS) boolFunc(t *testing.T, g geom.Geometry, stFunc string) bool { - var b bool - if err := p.db.QueryRow( - "SELECT "+stFunc+"(ST_GeomFromWKB($1))", g, - ).Scan(&b); err != nil { - t.Fatalf("pg error: %v", err) - } - return b -} - -func (p PostGIS) intFunc(t *testing.T, g geom.Geometry, stFunc string) int { - var i int - if err := p.db.QueryRow( - "SELECT "+stFunc+"(ST_GeomFromWKB($1))", g, - ).Scan(&i); err != nil { - t.Fatalf("pg error: %v", err) - } - return i -} - -func (p PostGIS) stringFunc(t *testing.T, g geom.Geometry, stFunc string) string { - var str string - if err := p.db.QueryRow( - "SELECT "+stFunc+"(ST_GeomFromWKB($1))", g, - ).Scan(&str); err != nil { - t.Fatalf("pg error: %v", err) - } - return str -} - -func (p PostGIS) float64Func(t *testing.T, g geom.Geometry, stFunc string) float64 { - var f float64 - if err := p.db.QueryRow( - "SELECT "+stFunc+"(ST_GeomFromWKB($1))", g, - ).Scan(&f); err != nil { - t.Fatalf("pg error: %v", err) - } - return f -} - -func (p PostGIS) bytesFunc(t *testing.T, g geom.Geometry, stFunc string) []byte { - var bytes []byte - if err := p.db.QueryRow( - "SELECT "+stFunc+"(ST_GeomFromWKB($1))", g, - ).Scan(&bytes); err != nil { - t.Fatalf("pg error: %v", err) - } - return bytes -} - -func (p PostGIS) binary(t *testing.T, g1, g2 geom.Geometry, stFunc string, dest interface{}) { - if err := p.db.QueryRow( - "SELECT "+stFunc+"(ST_GeomFromWKB($1), ST_GeomFromWKB($2))", - g1, g2, - ).Scan(dest); err != nil { - t.Fatalf("pg error: %v", err) - } -} - -// TolerantEquals checks if the two geometries are equal, accounting for some -// numeric tolerance and ignoring ordering. -func (p PostGIS) TolerantEquals(t *testing.T, g1, g2 geom.Geometry) bool { - // The snap to grid can sometimes mess up the equality check if the - // geometry is split different in the two forms. Try without snap to grid - // first. - var eq bool - if err := p.db.QueryRow(` - SELECT ST_Equals( - ST_GeomFromWKB($1), - ST_GeomFromWKB($2) - )`, g1, g2, - ).Scan(&eq); err != nil { - t.Fatalf("pg err: %v", err) - } - if eq { - return true - } - - if err := p.db.QueryRow(` - SELECT ST_Equals( - ST_SnapToGrid(ST_GeomFromWKB($1), 0, 0, 0.00001, 0.00001), - ST_SnapToGrid(ST_GeomFromWKB($2), 0, 0, 0.00001, 0.00001) - )`, g1, g2, - ).Scan(&eq); err != nil { - t.Fatalf("pg err: %v", err) - } - return eq -} - -func (p PostGIS) boolBinary(t *testing.T, g1, g2 geom.Geometry, stFunc string) bool { - var b bool - p.binary(t, g1, g2, stFunc, &b) - return b -} - -func (p PostGIS) geomBinary(t *testing.T, g1, g2 geom.Geometry, stFunc string) geom.Geometry { - var result geom.Geometry - if err := p.db.QueryRow( - "SELECT ST_AsBinary("+stFunc+"(ST_GeomFromWKB($1), ST_GeomFromWKB($2)))", - g1, g2, - ).Scan(&result); err != nil { - t.Fatalf("pg error: %v", err) - } - return result -} - -func (p PostGIS) AsText(t *testing.T, g geom.Geometry) string { - return string(p.bytesFunc(t, g, "ST_AsText")) -} - -func (p PostGIS) AsBinary(t *testing.T, g geom.Geometry) []byte { - return p.bytesFunc(t, g, "ST_AsBinary") -} - -func (p PostGIS) AsGeoJSON(t *testing.T, g geom.Geometry) []byte { - return p.bytesFunc(t, g, "ST_AsGeoJSON") -} - -func (p PostGIS) IsEmpty(t *testing.T, g geom.Geometry) bool { - return p.boolFunc(t, g, "ST_IsEmpty") -} - -func (p PostGIS) Dimension(t *testing.T, g geom.Geometry) int { - return p.intFunc(t, g, "ST_Dimension") -} - -func (p PostGIS) Envelope(t *testing.T, g geom.Geometry) geom.Geometry { - return p.geomFunc(t, g, "ST_Envelope") -} - -func (p PostGIS) IsSimple(t *testing.T, g geom.Geometry) bool { - return p.boolFunc(t, g, "ST_IsSimple") -} - -func (p PostGIS) Boundary(t *testing.T, g geom.Geometry) geom.Geometry { - return p.geomFunc(t, g, "ST_Boundary") -} - -func (p PostGIS) ConvexHull(t *testing.T, g geom.Geometry) geom.Geometry { - return p.geomFunc(t, g, "ST_ConvexHull") -} - -func (p PostGIS) IsRing(t *testing.T, g geom.Geometry) bool { - // ST_IsRing returns an error whenever it gets anything other than an ST_LineString. - return p.stringFunc(t, g, "ST_GeometryType") == "ST_LineString" && - p.boolFunc(t, g, "ST_IsRing") -} - -func (p PostGIS) Length(t *testing.T, g geom.Geometry) float64 { - return p.float64Func(t, g, "ST_Length") -} - -func (p PostGIS) Area(t *testing.T, g geom.Geometry) float64 { - return p.float64Func(t, g, "ST_Area") -} - -func (p PostGIS) Centroid(t *testing.T, g geom.Geometry) geom.Geometry { - return p.geomFunc(t, g, "ST_Centroid") -} - -func (p PostGIS) Reverse(t *testing.T, g geom.Geometry) geom.Geometry { - return p.geomFunc(t, g, "ST_Reverse") -} diff --git a/internal/perf/set_op_test.go b/internal/perf/set_op_test.go index 1e9e7766..f3718253 100644 --- a/internal/perf/set_op_test.go +++ b/internal/perf/set_op_test.go @@ -14,8 +14,8 @@ import ( func BenchmarkSetOperation(b *testing.B) { for i := 2; i <= 14; i++ { sz := 1 << i - p1 := regularPolygon(geom.XY{0, 0}, 1.0, sz).AsGeometry() - p2 := regularPolygon(geom.XY{1, 0}, 1.0, sz).AsGeometry() + p1 := regularPolygon(geom.XY{X: 0, Y: 0}, 1.0, sz).AsGeometry() + p2 := regularPolygon(geom.XY{X: 1, Y: 0}, 1.0, sz).AsGeometry() b.Run(fmt.Sprintf("n=%d", sz), func(b *testing.B) { for _, op := range []struct { name string diff --git a/internal/perf/util.go b/internal/perf/util.go index 20797475..46326a77 100644 --- a/internal/perf/util.go +++ b/internal/perf/util.go @@ -24,7 +24,7 @@ func regularPolygon(center geom.XY, radius float64, sides int) geom.Polygon { if err != nil { panic(err) } - poly, err := geom.NewPolygonFromRings([]geom.LineString{ring}, geom.DisableAllValidations) + poly, err := geom.NewPolygon([]geom.LineString{ring}, geom.DisableAllValidations) if err != nil { panic(err) } diff --git a/rtree/box.go b/rtree/box.go index 3ddfa842..caab2ea3 100644 --- a/rtree/box.go +++ b/rtree/box.go @@ -24,8 +24,8 @@ func combine(box1, box2 Box) Box { } } -// enlargment returns how much additional area the existing Box would have to -// enlarge by to accomodate the additional Box. +// enlargement returns how much additional area the existing Box would have to +// enlarge by to accommodate the additional Box. func enlargement(existing, additional Box) float64 { return area(combine(existing, additional)) - area(existing) } diff --git a/rtree/bulk.go b/rtree/bulk.go index 779faa95..1405c942 100644 --- a/rtree/bulk.go +++ b/rtree/bulk.go @@ -127,9 +127,8 @@ func quickPartition(items []BulkItem, k int, horizontal bool) { bj := items[j].Box if horizontal { return bi.MinX+bi.MaxX < bj.MinX+bj.MaxX - } else { - return bi.MinY+bi.MaxY < bj.MinY+bj.MaxY } + return bi.MinY+bi.MaxY < bj.MinY+bj.MaxY } swap := func(i, j int) { items[i], items[j] = items[j], items[i] diff --git a/rtree/insert.go b/rtree/insert.go index d00e0332..81bf90f0 100644 --- a/rtree/insert.go +++ b/rtree/insert.go @@ -53,8 +53,8 @@ func (t *RTree) adjustBoxesUpwards(node *node, box Box) { func (t *RTree) joinRoots(r1, r2 *node) { newRoot := &node{ entries: [1 + maxChildren]entry{ - entry{box: calculateBound(r1), child: r1}, - entry{box: calculateBound(r2), child: r2}, + {box: calculateBound(r1), child: r1}, + {box: calculateBound(r2), child: r2}, }, numEntries: 2, parent: nil, diff --git a/rtree/nearest.go b/rtree/nearest.go index 681bcc2e..97735df9 100644 --- a/rtree/nearest.go +++ b/rtree/nearest.go @@ -6,11 +6,7 @@ import "container/heap" // as measured by the Euclidean metric. Note that there may be multiple records // that are equidistant from the input box, in which case one is chosen // arbitrarily. If the RTree is empty, then false is returned. -func (t *RTree) Nearest(box Box) (int, bool) { - var ( - recordID int - found bool - ) +func (t *RTree) Nearest(box Box) (recordID int, found bool) { t.PrioritySearch(box, func(rid int) error { recordID = rid found = true