├── .ci ├── assets │ └── envelope.svg ├── compose-cmpgeos.yaml ├── compose-cmppg.yaml ├── compose-geos.yaml ├── compose-lint.yaml ├── compose-pgscan.yaml ├── compose-unit.yaml ├── geos.Dockerfile ├── golint.Dockerfile └── run_benchmarks.sh ├── .github ├── pull_request_template.md └── workflows │ └── ci.yaml ├── .gitignore ├── .golangci.yaml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── carto ├── README.md ├── docs.go ├── proj_albers_equal_area_conic.go ├── proj_azimuthal_equidistant.go ├── proj_equidistant_conic.go ├── proj_equirectangular.go ├── proj_lambert_conformal_conic.go ├── proj_lambert_cylindrical_equal_area.go ├── proj_orthographic.go ├── proj_sinusoidal.go ├── proj_web_mercator.go ├── projections_test.go ├── radius.go └── util.go ├── geom ├── accessor_test.go ├── alg_convex_hull.go ├── alg_convex_hull_test.go ├── alg_densify.go ├── alg_densify_test.go ├── alg_disjoint_set.go ├── alg_disjoint_set_internal_test.go ├── alg_distance.go ├── alg_distance_test.go ├── alg_dump_test.go ├── alg_exact_equals.go ├── alg_exact_equals_test.go ├── alg_intersection.go ├── alg_intersects.go ├── alg_intersects_test.go ├── alg_linear_interpolation.go ├── alg_linear_interpolation_internal_test.go ├── alg_linear_interpolation_test.go ├── alg_orientation.go ├── alg_orientation_internal_test.go ├── alg_point_in_ring.go ├── alg_point_in_ring_internal_test.go ├── alg_point_on_surface.go ├── alg_point_on_surface_test.go ├── alg_relate.go ├── alg_relate_test.go ├── alg_rotating_calipers.go ├── alg_rotating_calipers_test.go ├── alg_set_op.go ├── alg_set_op_test.go ├── alg_simplify.go ├── alg_simplify_test.go ├── attr_test.go ├── coordinate_type.go ├── ctor_from_coords.go ├── ctor_from_coords_test.go ├── ctor_options_test.go ├── dcel.go ├── dcel_debug.go ├── dcel_extract_geometry.go ├── dcel_extract_intersection_matrix.go ├── dcel_fixup.go ├── dcel_ghosts.go ├── dcel_ghosts_internal_test.go ├── dcel_input.go ├── dcel_interaction_points.go ├── dcel_interaction_points_internal_test.go ├── dcel_internal_test.go ├── dcel_node_set.go ├── dcel_node_set_internal_test.go ├── dcel_re_noding.go ├── dcel_re_noding_internal_test.go ├── de9im.go ├── de9im_test.go ├── doc.go ├── dump_coordinates_test.go ├── errors.go ├── export_test.go ├── float_helpers.go ├── geojson_feature_collection.go ├── geojson_feature_collection_test.go ├── geojson_marshal.go ├── geojson_marshal_test.go ├── geojson_unmarshal.go ├── geojson_unmarshal_test.go ├── graph.go ├── graph_internal_test.go ├── interval.go ├── line.go ├── marshal_unmarshal_test.go ├── no_validate.go ├── perf_internal_test.go ├── perf_test.go ├── rtree.go ├── snap_to_grid.go ├── snap_to_grid_test.go ├── sort_and_uniquify_internal_test.go ├── sql_test.go ├── transform.go ├── twkb.go ├── twkb_export_test.go ├── twkb_parser.go ├── twkb_test.go ├── twkb_write.go ├── type_coordinates.go ├── type_coordinates_test.go ├── type_envelope.go ├── type_envelope_test.go ├── type_geometry.go ├── type_geometry_collection.go ├── type_geometry_test.go ├── type_line_string.go ├── type_multi_line_string.go ├── type_multi_point.go ├── type_multi_polygon.go ├── type_null_geometry.go ├── type_null_geometry_test.go ├── type_point.go ├── type_polygon.go ├── type_sequence.go ├── type_sequence_test.go ├── util.go ├── util_internal_test.go ├── util_test.go ├── validation_test.go ├── walk.go ├── wkb_marshal.go ├── wkb_parser.go ├── wkb_test.go ├── wkt_lexer.go ├── wkt_lexer_internal_test.go ├── wkt_parser.go ├── wkt_test.go ├── wkt_write.go ├── xy.go ├── xy_test.go └── zero_value_test.go ├── geos ├── doc.go ├── entrypoints.go ├── entrypoints_test.go └── testdata │ ├── coverage_simplify_input_balmain.wkt │ ├── coverage_simplify_input_birchgrove.wkt │ └── coverage_simplify_output.wkt ├── go.mod ├── go.sum ├── internal ├── benchmarkreport │ ├── .gitignore │ ├── README.md │ ├── main.go │ └── run.sh ├── cartodemo │ ├── cartodemo_test.go │ ├── rasterize │ │ ├── docs.go │ │ ├── draw_test.go │ │ ├── rasterizer.go │ │ ├── rasterizer_test.go │ │ ├── testdata │ │ │ └── line.png │ │ └── util_test.go │ └── testdata │ │ ├── .gitignore │ │ ├── albers_equal_area_conic.png │ │ ├── azimuthal_equidistant_sydney.png │ │ ├── equidistant_conic.png │ │ ├── lambert_conformal_conic.png │ │ ├── lambert_cylindrical_equal_area.png │ │ ├── marinus.png │ │ ├── ne_50m_antarctic_ice_shelves_polys.geojson.gz │ │ ├── ne_50m_glaciated_areas.geojson.gz │ │ ├── ne_50m_lakes.geojson.gz │ │ ├── ne_50m_land.geojson.gz │ │ ├── orthographic_north_america.png │ │ ├── sinusoidal.png │ │ └── web_mercator.png ├── cmprefimpl │ ├── cmpgeos │ │ ├── checks.go │ │ ├── extract_source.go │ │ ├── main.go │ │ ├── util.go │ │ └── util_test.go │ └── cmppg │ │ ├── checks.go │ │ ├── fuzz_test.go │ │ ├── pg.go │ │ └── postgis.go ├── perf │ ├── linestring_issimple_test.go │ ├── set_op_test.go │ └── util_test.go ├── pgscan │ └── pgscan_test.go └── rawgeos │ ├── benchmark_internal_test.go │ ├── configure_hardcoded.go │ ├── configure_pkg_config.go │ ├── entrypoints.go │ ├── errors.go │ ├── errors_internal_test.go │ ├── generic_operations.go │ ├── handle.go │ └── helper.c └── rtree ├── box.go ├── bulk.go ├── doc.go ├── golden_internal_test.go ├── nearest.go ├── nearest_internal_test.go ├── perf_internal_test.go ├── quick_partition_internal_test.go ├── rtree.go └── rtree_internal_test.go /.ci/assets/envelope.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /.ci/compose-cmpgeos.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | cmprefimpl: 3 | build: 4 | dockerfile: geos.Dockerfile 5 | args: 6 | ALPINE_VERSION: 3.19 7 | GEOS_VERSION: 3.12.1-r0 8 | working_dir: /mnt/sf 9 | entrypoint: go run ./internal/cmprefimpl/cmpgeos 10 | volumes: 11 | - ..:/mnt/sf 12 | -------------------------------------------------------------------------------- /.ci/compose-cmppg.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgis: 3 | image: ghcr.io/baosystems/postgis:15 4 | environment: 5 | POSTGRES_PASSWORD: password 6 | healthcheck: 7 | test: "pg_isready -U postgres" 8 | interval: '100ms' 9 | timeout: '1s' 10 | retries: 50 11 | tests: 12 | image: golang:1.17 13 | working_dir: /go/src/github.com/peterstace/simplefeatures 14 | entrypoint: go test -test.count=1 -test.timeout=30m -test.run=. ./internal/cmprefimpl/cmppg 15 | volumes: 16 | - ..:/go/src/github.com/peterstace/simplefeatures 17 | environment: 18 | - GO111MODULE=on 19 | depends_on: 20 | postgis: 21 | condition: service_healthy 22 | -------------------------------------------------------------------------------- /.ci/compose-geos.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | geostests: 3 | build: 4 | dockerfile: geos.Dockerfile 5 | args: 6 | ALPINE_VERSION: $alpine_version 7 | GEOS_VERSION: $geos_version 8 | working_dir: /mnt/sf 9 | entrypoint: go test $tags -test.count=1 -test.run=. ./geos ./internal/rawgeos 10 | volumes: 11 | - ..:/mnt/sf 12 | -------------------------------------------------------------------------------- /.ci/compose-lint.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | lint: 3 | build: 4 | context: . 5 | dockerfile: golint.Dockerfile 6 | working_dir: /go/src/github.com/peterstace/simplefeatures 7 | entrypoint: golangci-lint run --max-same-issues 100 8 | volumes: 9 | - ..:/go/src/github.com/peterstace/simplefeatures 10 | environment: 11 | - GO111MODULE=on 12 | -------------------------------------------------------------------------------- /.ci/compose-pgscan.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgis: 3 | image: ghcr.io/baosystems/postgis:15 4 | environment: 5 | POSTGRES_PASSWORD: password 6 | healthcheck: 7 | test: "pg_isready -U postgres" 8 | interval: '100ms' 9 | timeout: '1s' 10 | retries: 50 11 | tests: 12 | image: golang:1.17 13 | working_dir: /go/src/github.com/peterstace/simplefeatures 14 | entrypoint: go test -test.count=1 -test.timeout=30m -test.run=. ./internal/pgscan 15 | volumes: 16 | - ..:/go/src/github.com/peterstace/simplefeatures 17 | environment: 18 | - GO111MODULE=on 19 | depends_on: 20 | postgis: 21 | condition: service_healthy 22 | -------------------------------------------------------------------------------- /.ci/compose-unit.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | tests: 3 | image: golang:1.17 4 | working_dir: /go/src/github.com/peterstace/simplefeatures 5 | entrypoint: go test -covermode=count -coverprofile=coverage.out -test.count=1 -test.run=. ./geom ./rtree 6 | volumes: 7 | - ..:/go/src/github.com/peterstace/simplefeatures 8 | environment: 9 | - GO111MODULE=on 10 | -------------------------------------------------------------------------------- /.ci/geos.Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ALPINE_VERSION 2 | FROM alpine:${ALPINE_VERSION} 3 | 4 | ARG GEOS_VERSION 5 | RUN apk add pkgconfig gcc musl-dev geos-dev=${GEOS_VERSION} 6 | 7 | COPY --from=golang:1.21-alpine /usr/local/go /usr/local/go 8 | ENV PATH=${PATH}:/usr/local/go/bin 9 | RUN go version 10 | -------------------------------------------------------------------------------- /.ci/golint.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golangci/golangci-lint:v1.61.0 2 | 3 | RUN apt-get update \ 4 | && apt-get install -y -q --no-install-recommends \ 5 | libgeos-dev \ 6 | && rm -rf /var/lib/apt/lists/* 7 | -------------------------------------------------------------------------------- /.ci/run_benchmarks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | set -x 5 | 6 | if [ $# != 2 ]; then 7 | echo "usage: $0 old_git_sh1 new_git_sha1" 8 | exit 1 9 | fi 10 | 11 | old_git_sha1="$1" 12 | new_git_sha1="$2" 13 | 14 | old="$(mktemp)" 15 | new="$(mktemp)" 16 | trap "rm -f $new $old" EXIT 17 | 18 | package="./..." 19 | bench="." 20 | 21 | pushd "$HOME" 22 | go install golang.org/x/perf/cmd/benchstat@latest 23 | popd 24 | 25 | for (( i = 0; i < 15; i++ )); do 26 | echo 27 | echo "RUN $i" 28 | 29 | echo 30 | echo "OLD" 31 | git checkout "$old_git_sha1" 32 | echo 33 | go test "$package" -test.run='^$' -benchtime=0.1s -benchmem -bench="$bench" | tee -a "$old" 34 | 35 | echo 36 | echo "NEW" 37 | git checkout "$new_git_sha1" 38 | echo 39 | go test "$package" -test.run='^$' -benchtime=0.1s -benchmem -bench="$bench" | tee -a "$new" 40 | done 41 | 42 | echo 43 | echo "OLD RESULTS" 44 | cat "$old" 45 | 46 | echo 47 | echo "NEW RESULTS" 48 | cat "$new" 49 | 50 | echo 51 | echo "COMPARISON" 52 | 53 | benchstat "$old" "$new" 54 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please describe at a high level what the change is and why it is useful or 4 | required. 5 | 6 | ## Check List 7 | 8 | Have you: 9 | 10 | - Added unit tests? 11 | 12 | - Add cmprefimpl tests? (if appropriate?) 13 | 14 | - Updated release notes? (if appropriate?) 15 | 16 | ## Related Issue 17 | 18 | - Please link to the related issue(s). 19 | 20 | ## Benchmark Results 21 | 22 | - Please paste benchmark results here. The benchmarks can be run using the 23 | `run_benchmarks.sh` script. 24 | 25 |
26 | 27 | Click to expand 28 | 29 | ``` 30 | PASTE BENCHMARKS HERE 31 | ``` 32 | 33 |
34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: lint 13 | run: make lint 14 | - name: unit 15 | run: make unit 16 | - name: geos 17 | run: make geos 18 | - name: pgscan 19 | run: make pgscan 20 | - name: cmppg 21 | run: make cmppg 22 | - name: cmpgeos 23 | run: make cmpgeos 24 | - name: Convert coverage to lcov 25 | uses: jandelgado/gcov2lcov-action@v1.0.9 26 | with: 27 | infile: coverage.out 28 | outfile: coverage.lcov 29 | - name: Coveralls 30 | uses: coverallsapp/github-action@master 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | path-to-lcov: coverage.lcov 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.test 2 | *.out 3 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | 4 | issues: 5 | exclude-rules: 6 | 7 | # The tests in cmprefimpl have pass *testing.T values deeply through 8 | # functions where using t.Helper() doesn't make sense. 9 | - path: internal/cmprefimpl 10 | linters: 11 | - thelper 12 | 13 | - text: "G115.*" 14 | linters: 15 | - gosec 16 | 17 | - path: "carto/*" 18 | linters: 19 | - asciicheck 20 | 21 | linters-settings: 22 | depguard: 23 | rules: 24 | main: 25 | allow: 26 | - $gostd 27 | - github.com/peterstace/simplefeatures 28 | - github.com/lib/pq 29 | deny: 30 | - pkg: io/ioutil 31 | desc: Use os or io instead of io/ioutil 32 | gosec: 33 | excludes: 34 | - G404 # Insecure random number source (rand) 35 | errcheck: 36 | exclude-functions: 37 | - io.Copy(os.Stdout) 38 | - (*github.com/peterstace/simplefeatures/rtree.RTree).RangeSearch 39 | - (*github.com/peterstace/simplefeatures/rtree.RTree).PrioritySearch 40 | 41 | # NOTE: every linter supported by golangci-lint is either explicitly included 42 | # or excluded. 43 | linters: 44 | 45 | enable: 46 | 47 | - asasalint 48 | - asciicheck 49 | - bidichk 50 | - bodyclose 51 | - containedctx 52 | - contextcheck 53 | - copyloopvar 54 | - decorder 55 | - depguard 56 | - dogsled 57 | - dupword 58 | - durationcheck 59 | - errcheck 60 | - errchkjson 61 | - errorlint 62 | - exportloopref 63 | - ginkgolinter 64 | - gocheckcompilerdirectives 65 | - gocritic 66 | - godot 67 | - gofmt 68 | - gofumpt 69 | - goheader 70 | - goimports 71 | - gomoddirectives 72 | - gomodguard 73 | - goprintffuncname 74 | - gosec 75 | - gosimple 76 | - gosmopolitan 77 | - govet 78 | - grouper 79 | - importas 80 | - ineffassign 81 | - interfacebloat 82 | - intrange 83 | - ireturn 84 | - loggercheck 85 | - makezero 86 | - mirror 87 | - misspell 88 | - musttag 89 | - nakedret 90 | - nilerr 91 | - nilnil 92 | - noctx 93 | - nolintlint 94 | - nosprintfhostport 95 | - perfsprint 96 | - predeclared 97 | - promlinter 98 | - reassign 99 | - revive 100 | - rowserrcheck 101 | - spancheck 102 | - sqlclosecheck 103 | - staticcheck 104 | - stylecheck 105 | - tagalign 106 | - tagliatelle 107 | - tenv 108 | - testableexamples 109 | - testpackage 110 | - thelper 111 | - tparallel 112 | - unconvert 113 | - unparam 114 | - unused 115 | - usestdlibvars 116 | - wastedassign 117 | - whitespace 118 | - zerologlint 119 | 120 | disable: 121 | 122 | # Deprecated by golangci-lint: 123 | - execinquery 124 | 125 | # The following are disabled because they're not a good match for 126 | # simplefeatures. 127 | - cyclop 128 | - dupl 129 | - errname 130 | - exhaustive 131 | - exhaustruct 132 | - forbidigo 133 | - forcetypeassert 134 | - funlen 135 | - gci 136 | - gochecknoglobals 137 | - gochecknoinits 138 | - gochecksumtype 139 | - gocognit 140 | - goconst 141 | - gocyclo 142 | - godox 143 | - err113 144 | - gomnd 145 | - inamedparam 146 | - lll 147 | - maintidx 148 | - nestif 149 | - nlreturn 150 | - nonamedreturns 151 | - paralleltest 152 | - prealloc 153 | - protogetter 154 | - sloglint 155 | - testifylint 156 | - varnamelen 157 | - wrapcheck 158 | - wsl 159 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 the contributors. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: unit lint geos pgscan cmppg cmpgeos 3 | 4 | DC_RUN = \ 5 | docker compose \ 6 | --project-name sf-$$task \ 7 | --file .ci/compose-$$task.yaml \ 8 | up \ 9 | --abort-on-container-exit \ 10 | --build 11 | 12 | .PHONY: lint 13 | lint: 14 | task=lint; $(DC_RUN) 15 | 16 | .PHONY: unit 17 | unit: 18 | task=unit; $(DC_RUN) 19 | 20 | .PHONY: pgscan 21 | pgscan: 22 | task=pgscan; $(DC_RUN) 23 | 24 | .PHONY: cmppg 25 | cmppg: 26 | task=cmppg; $(DC_RUN) 27 | 28 | .PHONY: cmpgeos 29 | cmpgeos: 30 | task=cmpgeos; $(DC_RUN) 31 | 32 | DC_GEOS_RUN = \ 33 | docker compose \ 34 | --project-name sf-geos-$$(echo $$geos_version | sed 's/\./-/g') \ 35 | --file .ci/compose-geos.yaml \ 36 | up \ 37 | --build \ 38 | --abort-on-container-exit 39 | 40 | .PHONY: geos-3.12 41 | geos-3.12: 42 | export tags='' alpine_version=3.19 geos_version=3.12.1-r0; $(DC_GEOS_RUN) 43 | 44 | .PHONY: geos-3.11 45 | geos-3.11: 46 | export tags='' alpine_version=3.18 geos_version=3.11.2-r0; $(DC_GEOS_RUN) 47 | 48 | .PHONY: geos-3.10 49 | geos-3.10: 50 | export tags='' alpine_version=3.16 geos_version=3.10.3-r0; $(DC_GEOS_RUN) 51 | 52 | .PHONY: geos-3.9 53 | geos-3.9: 54 | export tags='' alpine_version=3.14 geos_version=3.9.1-r0; $(DC_GEOS_RUN) 55 | 56 | .PHONY: geos-3.8 57 | geos-3.8: 58 | # Alpine 3.13 doesn't include a geos.pc file (needed by pkg-config). So 59 | # the sfnopkgconfig tag is used, disabling the use of pkg-config. 60 | # LDFLAGS are used to configure GEOS directly. 61 | export tags='-tags sfnopkgconfig' \ 62 | alpine_version=3.13 geos_version=3.8.1-r2; $(DC_GEOS_RUN) 63 | 64 | .PHONY: geos 65 | geos: geos-3.12 geos-3.11 geos-3.10 geos-3.9 geos-3.8 66 | -------------------------------------------------------------------------------- /carto/README.md: -------------------------------------------------------------------------------- 1 | # `carto` package 2 | 3 | [![Documentation](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat)](https://pkg.go.dev/github.com/peterstace/simplefeatures/carto?tab=doc) 4 | 5 | Package carto provides cartography functionality for working with and making 6 | maps. 7 | 8 | This includes: 9 | 10 | - Various projections between angular coordinates (longitude and latitude) 11 | and planar coordinates (x and y). 12 | 13 | - Earth radius definitions. 14 | 15 | See 16 | [godoc](https://pkg.go.dev/github.com/peterstace/simplefeatures/carto?tab=doc) 17 | for the full package documentation. 18 | 19 | --- 20 | 21 | The following section shows supported projections. The code used to generate 22 | the images in this section can be found 23 | [here](https://github.com/peterstace/simplefeatures/tree/master/internal/cartodemo). 24 | 25 | [**Equirectangular projection**](https://en.wikipedia.org/wiki/Equirectangular_projection) 26 | 27 | Standard parallels are set to 36°N and 36°S. This configuration of the 28 | Equirectangular projection is also known as the Marinus (of Tyre) projection. 29 | 30 | ![Equirectangular projection](../internal/cartodemo/testdata/marinus.png) 31 | 32 | [**Web Mercator projection**](https://en.wikipedia.org/wiki/Web_Mercator_projection) 33 | 34 | This is the full zoom 0 tile of the Web Mercator projection. 35 | 36 | ![Web Mercator projection](../internal/cartodemo/testdata/web_mercator.png) 37 | 38 | [**Lambert Cylindrical Equal Area projection**](https://en.wikipedia.org/wiki/Lambert_cylindrical_equal-area_projection) 39 | 40 | The central meridian is set to 0°E. 41 | 42 | ![Lambert Cylindrical Equal Area projection](../internal/cartodemo/testdata/lambert_cylindrical_equal_area.png) 43 | 44 | [**Sinusoidal projection**](https://en.wikipedia.org/wiki/Sinusoidal_projection) 45 | 46 | The central meridian is set to 0°E. 47 | 48 | ![Sinusoidal projection](../internal/cartodemo/testdata/sinusoidal.png) 49 | 50 | [**Orthographic projection**](https://en.wikipedia.org/wiki/Orthographic_projection) 51 | 52 | Centered on North America at 45°N, 105°W. 53 | 54 | ![Orthographic projection](../internal/cartodemo/testdata/orthographic_north_america.png) 55 | 56 | [**Azimuthal Equidistant projection**](https://en.wikipedia.org/wiki/Azimuthal_equidistant_projection) 57 | 58 | Centered at Sydney, Australia at 151°E, 34°S. 59 | 60 | ![Azimuthal Equidistant projection](../internal/cartodemo/testdata/azimuthal_equidistant_sydney.png) 61 | 62 | [**Equidistant Conic projection**](https://en.wikipedia.org/wiki/Equidistant_conic_projection) 63 | 64 | Standard parallels are set to 30°N and 60°N. The central meridian is set to 65 | 0°E. 66 | 67 | ![Equidistant Conic projection](../internal/cartodemo/testdata/equidistant_conic.png) 68 | 69 | [**Albers Equal Area Conic projection**](https://en.wikipedia.org/wiki/Albers_projection) 70 | 71 | Standard parallels are set to 30°N and 60°N. The central meridian is set to 72 | 0°E. 73 | 74 | ![Albers Equal Area Conic projection](../internal/cartodemo/testdata/albers_equal_area_conic.png) 75 | 76 | [**Lambert Conformal Conic projection**](https://en.wikipedia.org/wiki/Lambert_conformal_conic_projection) 77 | 78 | Standard parallels are set to 30°N and 60°N. The central meridian is set to 79 | 0°E. 80 | 81 | ![Lambert Conformal Conic projection](../internal/cartodemo/testdata/lambert_conformal_conic.png) 82 | -------------------------------------------------------------------------------- /carto/docs.go: -------------------------------------------------------------------------------- 1 | // Package carto provides cartography functionality for working with and making 2 | // maps. 3 | // 4 | // This includes: 5 | // 6 | // - Various projections between angular coordinates (longitude and latitude) 7 | // and planar coordinates (x and y). 8 | // 9 | // - Earth radius definitions. 10 | package carto 11 | -------------------------------------------------------------------------------- /carto/proj_albers_equal_area_conic.go: -------------------------------------------------------------------------------- 1 | package carto 2 | 3 | import "github.com/peterstace/simplefeatures/geom" 4 | 5 | // AlbersEqualAreaConic allows projecting (longitude, latitude) coordinates to 6 | // (x, y) pairs via the Albers equal area conic projection. 7 | // 8 | // The Albers equal area conic projection is a conic projection that is: 9 | // - Configured by setting two standard parallels. 10 | // - Equal area. 11 | // - Not conformal, but preserves shape locally at the standard parallels. 12 | type AlbersEqualAreaConic struct { 13 | radius float64 14 | origin geom.XY 15 | stdParallels [2]float64 16 | } 17 | 18 | // NewAlbersEqualAreaConic returns a new AlbersEqualAreaConic projection with 19 | // the given earth radius. The standard parallels are set to 30 and 60 degrees 20 | // north. 21 | func NewAlbersEqualAreaConic(earthRadius float64) *AlbersEqualAreaConic { 22 | return &AlbersEqualAreaConic{ 23 | radius: earthRadius, 24 | stdParallels: [2]float64{30, 60}, 25 | } 26 | } 27 | 28 | // SetStandardParallels sets the standard parallels of the projection to the 29 | // given latitudes expressed in degrees. 30 | func (c *AlbersEqualAreaConic) SetStandardParallels(lat1, lat2 float64) { 31 | c.stdParallels[0] = lat1 32 | c.stdParallels[1] = lat2 33 | } 34 | 35 | // SetOrigin sets the origin of the projection to the given (longitude, 36 | // latitude) pair. The origin has projected coordinates (0, 0). 37 | func (c *AlbersEqualAreaConic) SetOrigin(origin geom.XY) { 38 | c.origin = origin 39 | } 40 | 41 | // Forward converts a (longitude, latitude) pair expressed in degrees to a 42 | // projected (x, y) pair. 43 | func (c *AlbersEqualAreaConic) Forward(lonlat geom.XY) geom.XY { 44 | var ( 45 | R = c.radius 46 | φ = dtor(lonlat.Y) 47 | φ0 = dtor(c.origin.Y) 48 | φ1 = dtor(c.stdParallels[0]) 49 | φ2 = dtor(c.stdParallels[1]) 50 | λ = dtor(lonlat.X) 51 | λ0 = dtor(c.origin.X) 52 | ) 53 | var ( 54 | n = (sin(φ1) + sin(φ2)) / 2 55 | θ = n * (λ - λ0) 56 | C = sq(cos(φ1)) + 2*n*sin(φ1) 57 | ρ = R * sqrt(C-2*n*sin(φ)) / n 58 | ρ0 = R * sqrt(C-2*n*sin(φ0)) / n 59 | ) 60 | var ( 61 | x = ρ * sin(θ) 62 | y = ρ0 - ρ*cos(θ) 63 | ) 64 | return geom.XY{X: x, Y: y} 65 | } 66 | 67 | // Reverse converts a projected (x, y) pair to a (longitude, latitude) pair 68 | // expressed in degrees. 69 | func (c *AlbersEqualAreaConic) Reverse(xy geom.XY) geom.XY { 70 | var ( 71 | R = c.radius 72 | x = xy.X 73 | y = xy.Y 74 | φ0 = dtor(c.origin.Y) 75 | φ1 = dtor(c.stdParallels[0]) 76 | φ2 = dtor(c.stdParallels[1]) 77 | λ0 = dtor(c.origin.X) 78 | ) 79 | var ( 80 | n = (sin(φ1) + sin(φ2)) / 2 81 | C = sq(cos(φ1)) + 2*n*sin(φ1) 82 | ρ0 = R * sqrt(C-2*n*sin(φ0)) / n 83 | ρ = R * sqrt(sq(x)+sq(ρ0-y)) 84 | θ = atan(x / (ρ0 - y)) 85 | ) 86 | var ( 87 | φ = asin((C - ρ*ρ*n*n) / (2 * n)) 88 | λ = λ0 + θ/n 89 | ) 90 | return geom.XY{X: rtod(λ), Y: rtod(φ)} 91 | } 92 | -------------------------------------------------------------------------------- /carto/proj_azimuthal_equidistant.go: -------------------------------------------------------------------------------- 1 | package carto 2 | 3 | import ( 4 | "github.com/peterstace/simplefeatures/geom" 5 | ) 6 | 7 | // AzimuthalEquidistant allows projecting (longitude, latitude) coordinates to 8 | // (x, y) pairs via the azimuthal equidistant projection. 9 | // 10 | // The azimuthal equidistant projection is a projection that is: 11 | // - Configured by setting a center point. 12 | // - Equidistant. Distances from the center point are correctly scaled. 13 | // - Azimuthal. Directions from the center point are correctly preserved. 14 | // - Not conformal, but preserves shape locally at the center point. 15 | // - Not equal area, but preserves area locally at the center point. 16 | type AzimuthalEquidistant struct { 17 | radius float64 18 | centerLonLat geom.XY 19 | } 20 | 21 | // NewAzimuthalEquidistant returns a new AzimuthalEquidistant projection with 22 | // the given earth radius. 23 | func NewAzimuthalEquidistant(earthRadius float64) *AzimuthalEquidistant { 24 | return &AzimuthalEquidistant{ 25 | radius: earthRadius, 26 | centerLonLat: geom.XY{}, 27 | } 28 | } 29 | 30 | // SetCenterLonLat sets the center of the projection to the given (longitude, 31 | // latitude) pair. The center have projected coordinates (0, 0) and be the 32 | // center of the circular map. 33 | func (a *AzimuthalEquidistant) SetCenter(centerLonLat geom.XY) { 34 | a.centerLonLat = centerLonLat 35 | } 36 | 37 | // Forward converts a (longitude, latitude) pair expressed in degrees to a 38 | // projected (x, y) pair. 39 | func (a *AzimuthalEquidistant) Forward(lonLat geom.XY) geom.XY { 40 | R := a.radius 41 | λd := lonLat.X 42 | φd := lonLat.Y 43 | λr := dtor(λd) 44 | φr := dtor(φd) 45 | λ0r := dtor(a.centerLonLat.X) 46 | φ0r := dtor(a.centerLonLat.Y) 47 | 48 | ρ := R * acos(sin(φ0r)*sin(φr)+cos(φ0r)*cos(φr)*cos(λr-λ0r)) 49 | θ := atan2( 50 | cos(φr)*sin(λr-λ0r), 51 | cos(φ0r)*sin(φr)-sin(φ0r)*cos(φr)*cos(λr-λ0r), 52 | ) 53 | return geom.XY{ 54 | X: ρ * sin(θ), 55 | Y: ρ * cos(θ), 56 | } 57 | } 58 | 59 | // Reverse converts a projected (x, y) pair to a (longitude, latitude) pair 60 | // expressed in degrees. 61 | func (a *AzimuthalEquidistant) Reverse(xy geom.XY) geom.XY { 62 | R := a.radius 63 | x := xy.X 64 | y := xy.Y 65 | λ0r := dtor(a.centerLonLat.X) 66 | φ0r := dtor(a.centerLonLat.Y) 67 | 68 | ρ := sqrt(x*x + y*y) 69 | φr := asin(cos(ρ/R)*sin(φ0r) + (y*sin(ρ/R)*cos(φ0r))/ρ) 70 | λr := λ0r + atan2( 71 | x*sin(ρ/R), 72 | ρ*cos(φ0r)*cos(ρ/R)-y*sin(φ0r)*sin(ρ/R), 73 | ) 74 | λd := rtod(λr) 75 | φd := rtod(φr) 76 | return geom.XY{X: λd, Y: φd} 77 | } 78 | -------------------------------------------------------------------------------- /carto/proj_equidistant_conic.go: -------------------------------------------------------------------------------- 1 | package carto 2 | 3 | import ( 4 | "github.com/peterstace/simplefeatures/geom" 5 | ) 6 | 7 | // EquidistantConic allows projecting (longitude, latitude) coordinates to 8 | // (x, y) pairs via the equidistant conic projection. 9 | // 10 | // The equidistant conic projection is a conic projection that is: 11 | // - Configured by setting two standard parallels. 12 | // - Not equidistant, but has correctly scaled distance along all meridians 13 | // and the two standard parallels. 14 | // - Not conformal, but preserves shape locally at the standard parallels. 15 | // - Not equal area, but preserves area locally at the standard parallels. 16 | type EquidistantConic struct { 17 | earthRadius float64 18 | stdParallels [2]float64 19 | origin geom.XY 20 | } 21 | 22 | // NewEquidistantConic returns a new EquidistantConic projection with the given 23 | // earth radius. 24 | func NewEquidistantConic(earthRadius float64) *EquidistantConic { 25 | return &EquidistantConic{ 26 | earthRadius: earthRadius, 27 | stdParallels: [2]float64{0, 45}, 28 | } 29 | } 30 | 31 | // SetStandardParallels sets the standard parallels of the projection to the 32 | // given latitudes expressed in degrees. 33 | func (c *EquidistantConic) SetStandardParallels(lat1, lat2 float64) *EquidistantConic { 34 | c.stdParallels[0] = lat1 35 | c.stdParallels[1] = lat2 36 | return c 37 | } 38 | 39 | // SetOrigin sets the origin of the projection to the given (longitude, 40 | // latitude) pair. The origin have projected coordinates (0, 0). 41 | func (c *EquidistantConic) SetOrigin(lonLat geom.XY) *EquidistantConic { 42 | c.origin = lonLat 43 | return c 44 | } 45 | 46 | // Forward converts a (longitude, latitude) pair expressed in degrees to a 47 | // projected (x, y) pair. 48 | func (c *EquidistantConic) Forward(lonlat geom.XY) geom.XY { 49 | var ( 50 | R = c.earthRadius 51 | 52 | φd = lonlat.Y 53 | φ0d = c.origin.Y 54 | φ1d = c.stdParallels[0] 55 | φ2d = c.stdParallels[1] 56 | 57 | φr = dtor(φd) 58 | φ0r = dtor(φ0d) 59 | φ1r = dtor(φ1d) 60 | φ2r = dtor(φ2d) 61 | 62 | λd = lonlat.X 63 | λ0d = c.origin.X 64 | 65 | λr = dtor(λd) 66 | λ0r = dtor(λ0d) 67 | ) 68 | var ( 69 | n = (cos(φ1r) - cos(φ2r)) / (φ2r - φ1r) 70 | G = cos(φ1r)/n + φ1r 71 | ρ0 = G - φ0r 72 | 73 | ρ = G - φr 74 | x = ρ * sin(n*(λr-λ0r)) 75 | y = ρ0 - ρ*cos(n*(λr-λ0r)) 76 | ) 77 | return geom.XY{X: R * x, Y: R * y} 78 | } 79 | 80 | // Reverse converts a projected (x, y) pair to a (longitude, latitude) pair 81 | // expressed in degrees. 82 | func (c *EquidistantConic) Reverse(xy geom.XY) geom.XY { 83 | var ( 84 | R = c.earthRadius 85 | 86 | x = xy.X / R 87 | y = xy.Y / R 88 | 89 | φ0d = c.origin.Y 90 | φ1d = c.stdParallels[0] 91 | φ2d = c.stdParallels[1] 92 | 93 | λ0d = c.origin.X 94 | λ0r = dtor(λ0d) 95 | 96 | φ0r = dtor(φ0d) 97 | φ1r = dtor(φ1d) 98 | φ2r = dtor(φ2d) 99 | ) 100 | var ( 101 | n = (cos(φ1r) - cos(φ2r)) / (φ2r - φ1r) 102 | G = cos(φ1r)/n + φ1r 103 | ρ0 = G - φ0r 104 | 105 | ρ = sign(n) * sqrt(x*x+(ρ0-y)*(ρ0-y)) 106 | 107 | θ = atan(x / (ρ0 - y)) 108 | 109 | φr = G - ρ 110 | λr = λ0r + θ/n 111 | ) 112 | return geom.XY{X: rtod(λr), Y: rtod(φr)} 113 | } 114 | -------------------------------------------------------------------------------- /carto/proj_equirectangular.go: -------------------------------------------------------------------------------- 1 | package carto 2 | 3 | import "github.com/peterstace/simplefeatures/geom" 4 | 5 | // Equirectangular allows projecting (longitude, latitude) coordinates to (x, y) 6 | // pairs via the equirectangular projection. 7 | // 8 | // The equirectangular projection is a cylindrical projection that is: 9 | // - Configured by setting the central meridian and two standard parallels 10 | // that are symmetric about the equator. 11 | // - Not equal area, but preserves area locally at the standard parallels. 12 | // - Not conformal, but preserves shape locally at the standard parallels. 13 | // - Not equidistant, but preserves distance locally at the standard parallels. 14 | type Equirectangular struct { 15 | λ0 float64 16 | cosφ1 float64 17 | radius float64 18 | } 19 | 20 | // NewEquirectangular returns a new Equirectangular projection with the given 21 | // earth radius. 22 | func NewEquirectangular(earthRadius float64) *Equirectangular { 23 | return &Equirectangular{ 24 | λ0: 0, 25 | cosφ1: 1, // φ1 = 0 (equator) 26 | radius: earthRadius, 27 | } 28 | } 29 | 30 | // SetCentralMeridian sets the central meridian of the projection to the given 31 | // longitude expressed in degrees. 32 | func (e *Equirectangular) SetCentralMeridian(lon float64) { 33 | e.λ0 = dtor(lon) 34 | } 35 | 36 | // SetStandardParallels sets the standard parallels of the projection to the 37 | // given latitude and its negative, expressed in degrees. E.g. providing 35 38 | // will set the standard parallels to 35 and -35. 39 | func (e *Equirectangular) SetStandardParallels(lat float64) { 40 | φ1 := dtor(lat) 41 | e.cosφ1 = cos(φ1) 42 | } 43 | 44 | // Forward converts a (longitude, latitude) pair expressed in degrees to a 45 | // projected (x, y) pair. 46 | func (e *Equirectangular) Forward(lonLat geom.XY) geom.XY { 47 | var ( 48 | R = e.radius 49 | λ = dtor(lonLat.X) 50 | φ = dtor(lonLat.Y) 51 | λ0 = e.λ0 52 | cosφ1 = e.cosφ1 53 | ) 54 | return geom.XY{ 55 | X: R * (λ - λ0) * cosφ1, 56 | Y: R * φ, 57 | } 58 | } 59 | 60 | // Reverse converts a projected (x, y) pair to a (longitude, latitude) pair 61 | // expressed in degrees. 62 | func (e *Equirectangular) Reverse(xy geom.XY) geom.XY { 63 | var ( 64 | R = e.radius 65 | x = xy.X 66 | y = xy.Y 67 | λ0 = e.λ0 68 | cosφ1 = e.cosφ1 69 | ) 70 | var ( 71 | λ = x/(R*cosφ1) + λ0 72 | φ = y / R 73 | ) 74 | return rtodxy(λ, φ) 75 | } 76 | -------------------------------------------------------------------------------- /carto/proj_lambert_conformal_conic.go: -------------------------------------------------------------------------------- 1 | package carto 2 | 3 | import "github.com/peterstace/simplefeatures/geom" 4 | 5 | // LambertConformalConic allows projecting (longitude, latitude) coordinates to 6 | // (x, y) pairs via the Lambert conformal conic projection. 7 | // 8 | // The Lambert conformal conic projection is a conic projection that is: 9 | // - Configured by setting two standard parallels. 10 | // - Conformal. Shape is preserved locally at all points. 11 | // - Not equal area, but preserves area locally at the standard parallels. 12 | // - Not equidistant, but preserves distance locally along the standard 13 | // parallels. 14 | type LambertConformalConic struct { 15 | radius float64 16 | origin geom.XY 17 | stdParallels [2]float64 18 | } 19 | 20 | // NewLambertConformalConic returns a new LambertConformalConic projection with 21 | // the given earth radius. 22 | func NewLambertConformalConic(earthRadius float64) *LambertConformalConic { 23 | return &LambertConformalConic{ 24 | radius: earthRadius, 25 | origin: geom.XY{X: 0, Y: 0}, 26 | stdParallels: [2]float64{0, 0}, 27 | } 28 | } 29 | 30 | // SetOrigin sets the origin of the projection to the given (longitude, 31 | // latitude) pair. The origin have projected coordinates (0, 0). 32 | func (c *LambertConformalConic) SetOrigin(origin geom.XY) { 33 | c.origin = origin 34 | } 35 | 36 | // SetStandardParallels sets the standard parallels of the projection to the 37 | // given latitudes expressed in degrees. 38 | func (c *LambertConformalConic) SetStandardParallels(lat1, lat2 float64) { 39 | c.stdParallels[0] = lat1 40 | c.stdParallels[1] = lat2 41 | } 42 | 43 | // Forward converts a (longitude, latitude) pair expressed in degrees to a 44 | // projected (x, y) pair. 45 | func (c *LambertConformalConic) Forward(lonlat geom.XY) geom.XY { 46 | var ( 47 | R = c.radius 48 | φ = dtor(lonlat.Y) 49 | λ = dtor(lonlat.X) 50 | φ0 = dtor(c.origin.Y) 51 | λ0 = dtor(c.origin.X) 52 | φ1 = dtor(c.stdParallels[0]) 53 | φ2 = dtor(c.stdParallels[1]) 54 | ) 55 | var ( 56 | n = ln(cos(φ1)*sec(φ2)) / ln(tan(π/4+φ2/2)*cot(π/4+φ1/2)) 57 | F = cos(φ1) * pow(tan(π/4+φ1/2), n) / n 58 | ρ = R * F * pow(cot(π/4+φ/2), n) 59 | ρ0 = R * F * pow(cot(π/4+φ0/2), n) 60 | ) 61 | return geom.XY{ 62 | X: ρ * sin(n*(λ-λ0)), 63 | Y: ρ0 - ρ*cos(n*(λ-λ0)), 64 | } 65 | } 66 | 67 | // Reverse converts a projected (x, y) pair to a (longitude, latitude) pair 68 | // expressed in degrees. 69 | func (c *LambertConformalConic) Reverse(xy geom.XY) geom.XY { 70 | var ( 71 | R = c.radius 72 | x = xy.X 73 | y = xy.Y 74 | φ0 = dtor(c.origin.Y) 75 | λ0 = dtor(c.origin.X) 76 | φ1 = dtor(c.stdParallels[0]) 77 | φ2 = dtor(c.stdParallels[1]) 78 | ) 79 | var ( 80 | n = ln(cos(φ1)*sec(φ2)) / ln(tan(π/4+φ2/2)*cot(π/4+φ1/2)) 81 | F = cos(φ1) * pow(tan(π/4+φ1/2), n) / n 82 | ρ0 = R * F * pow(cot(π/4+φ0/2), n) 83 | ) 84 | var ( 85 | ρ = sign(n) * sqrt(sq(x)+sq(ρ0-y)) 86 | θ = atan(x / (ρ0 - y)) 87 | ) 88 | var ( 89 | φ = 2*atan(pow(R*F/ρ, 1/n)) - π/2 90 | λ = λ0 + θ/n 91 | ) 92 | return geom.XY{X: rtod(λ), Y: rtod(φ)} 93 | } 94 | -------------------------------------------------------------------------------- /carto/proj_lambert_cylindrical_equal_area.go: -------------------------------------------------------------------------------- 1 | package carto 2 | 3 | import "github.com/peterstace/simplefeatures/geom" 4 | 5 | // LambertCylindricalEqualArea allows projecting (longitude, latitude) 6 | // coordinates to (x, y) pairs via the Lambert cylindrical equal area 7 | // projection. 8 | // 9 | // The Lambert cylindrical equal area projection is a cylindrical projection 10 | // that is: 11 | // - Configured by setting the central meridian. 12 | // - Equal area. 13 | // - Not conformal, but preserves shape locally along the equator. 14 | // - Not equidistant, but preserves distance along the equator. 15 | type LambertCylindricalEqualArea struct { 16 | radius float64 17 | λ0 float64 18 | } 19 | 20 | // NewLambertCylindricalEqualArea returns a new LambertCylindricalEqualArea 21 | // projection with the given earth radius. 22 | func NewLambertCylindricalEqualArea(radius float64) *LambertCylindricalEqualArea { 23 | return &LambertCylindricalEqualArea{ 24 | radius: radius, 25 | λ0: 0, 26 | } 27 | } 28 | 29 | // SetCentralMeridian sets the central meridian of the projection to the given 30 | // longitude expressed in degrees. 31 | func (c *LambertCylindricalEqualArea) SetCentralMeridian(lon float64) { 32 | c.λ0 = dtor(lon) 33 | } 34 | 35 | // Forward converts a (longitude, latitude) pair expressed in degrees to a 36 | // projected (x, y) pair. 37 | func (c *LambertCylindricalEqualArea) Forward(lonLat geom.XY) geom.XY { 38 | var ( 39 | R = c.radius 40 | λ = dtor(lonLat.X) 41 | λ0 = c.λ0 42 | φ = dtor(lonLat.Y) 43 | ) 44 | return geom.XY{ 45 | X: R * (λ - λ0), 46 | Y: R * sin(φ), 47 | } 48 | } 49 | 50 | // Reverse converts a projected (x, y) pair to a (longitude, latitude) pair 51 | // expressed in degrees. 52 | func (c *LambertCylindricalEqualArea) Reverse(xy geom.XY) geom.XY { 53 | var ( 54 | R = c.radius 55 | x = xy.X 56 | y = xy.Y 57 | λ0 = c.λ0 58 | ) 59 | var ( 60 | λ = x/R + λ0 61 | φ = asin(y / R) 62 | ) 63 | return rtodxy(λ, φ) 64 | } 65 | -------------------------------------------------------------------------------- /carto/proj_orthographic.go: -------------------------------------------------------------------------------- 1 | package carto 2 | 3 | import "github.com/peterstace/simplefeatures/geom" 4 | 5 | // Orthographic allows projecting (longitude, latitude) coordinates to (x, y) 6 | // pairs via the orthographic projection. 7 | // 8 | // The orthographic projection projects the sphere onto a tangent plane with a 9 | // point of perspective that is infinitely far away. It gives a view of the 10 | // earth as seen from outer space. 11 | // 12 | // It is: 13 | // - Configured by setting the center of the projection. 14 | // - Not conformal, equal area or equidistant, but preserves shape, area, and 15 | // distance locally at the center of the projection. 16 | type Orthographic struct { 17 | radius float64 18 | λ0 float64 19 | cosφ0 float64 20 | sinφ0 float64 21 | } 22 | 23 | // NewOrthographic returns a new Orthographic projection with the given earth 24 | // radius. 25 | func NewOrthographic(radius float64) *Orthographic { 26 | return &Orthographic{ 27 | radius: radius, 28 | λ0: 0, 29 | cosφ0: 1, // φ0 = 0 30 | sinφ0: 0, // φ0 = 0 31 | } 32 | } 33 | 34 | // SetCenter sets the center of the projection to the given (longitude, 35 | // latitude) pair. The center have projected coordinates (0, 0) and be the 36 | // center of the circular map. 37 | func (m *Orthographic) SetCenter(centerLonLat geom.XY) { 38 | m.λ0 = dtor(centerLonLat.X) 39 | φ0 := dtor(centerLonLat.Y) 40 | m.sinφ0 = sin(φ0) 41 | m.cosφ0 = cos(φ0) 42 | } 43 | 44 | // Forward converts a (longitude, latitude) pair expressed in degrees to a 45 | // projected (x, y) pair. 46 | func (m *Orthographic) Forward(lonLat geom.XY) geom.XY { 47 | var ( 48 | R = m.radius 49 | λ = dtor(lonLat.X) 50 | φ = dtor(lonLat.Y) 51 | λ0 = m.λ0 52 | cosφ0 = m.cosφ0 53 | sinφ0 = m.sinφ0 54 | ) 55 | return geom.XY{ 56 | X: R * cos(φ) * sin(λ-λ0), 57 | Y: R * (cosφ0*sin(φ) - sinφ0*cos(φ)*cos(λ-λ0)), 58 | } 59 | } 60 | 61 | // Reverse converts a projected (x, y) pair to a (longitude, latitude) pair 62 | // expressed in degrees. 63 | func (m *Orthographic) Reverse(xy geom.XY) geom.XY { 64 | var ( 65 | R = m.radius 66 | x = xy.X 67 | y = xy.Y 68 | λ0 = m.λ0 69 | cosφ0 = m.cosφ0 70 | sinφ0 = m.sinφ0 71 | ) 72 | var ( 73 | ρ = xy.Length() 74 | c = asin(ρ / R) 75 | φ = asin(cos(c)*sinφ0 + y*sin(c)*cosφ0/ρ) 76 | λ = λ0 + atan(x*sin(c)/(ρ*cos(c)*cosφ0-y*sin(c)*sinφ0)) 77 | ) 78 | return rtodxy(λ, φ) 79 | } 80 | -------------------------------------------------------------------------------- /carto/proj_sinusoidal.go: -------------------------------------------------------------------------------- 1 | package carto 2 | 3 | import "github.com/peterstace/simplefeatures/geom" 4 | 5 | // Sinusoidal allows projecting (longitude, latitude) coordinates to (x, y) 6 | // pairs via the sinusoidal projection. 7 | // 8 | // The sinusoidal projection is a pseudocylindrical projection that is: 9 | // - Configured by setting the central meridian. 10 | // - Equal area. 11 | // - Not conformal, but preserves shape locally along the central meridian 12 | // and equator. 13 | // - Not equidistant, but preserves distance locally along all parallels and 14 | // the central meridian. 15 | type Sinusoidal struct { 16 | radius float64 17 | λ0 float64 18 | } 19 | 20 | // NewSinusoidal returns a new Sinusoidal projection with the given earth 21 | // radius. 22 | func NewSinusoidal(earthRadius float64) *Sinusoidal { 23 | return &Sinusoidal{ 24 | radius: earthRadius, 25 | λ0: 0, 26 | } 27 | } 28 | 29 | // SetCentralMeridian sets the central meridian of the projection to the given 30 | // longitude expressed in degrees. 31 | func (c *Sinusoidal) SetCentralMeridian(lon float64) { 32 | c.λ0 = dtor(lon) 33 | } 34 | 35 | // Forward converts a (longitude, latitude) pair expressed in degrees to a 36 | // projected (x, y) pair. 37 | func (c *Sinusoidal) Forward(lonLat geom.XY) geom.XY { 38 | var ( 39 | R = c.radius 40 | λ0 = c.λ0 41 | λ = dtor(lonLat.X) 42 | φ = dtor(lonLat.Y) 43 | ) 44 | return geom.XY{ 45 | X: R * cos(φ) * (λ - λ0), 46 | Y: R * φ, 47 | } 48 | } 49 | 50 | // Reverse converts a projected (x, y) pair to a (longitude, latitude) pair 51 | // expressed in degrees. 52 | func (c *Sinusoidal) Reverse(xy geom.XY) geom.XY { 53 | var ( 54 | R = c.radius 55 | λ0 = c.λ0 56 | x = xy.X 57 | y = xy.Y 58 | ) 59 | var ( 60 | λ = x/(R*cos(y/R)) + λ0 61 | φ = y / R 62 | ) 63 | return rtodxy(λ, φ) 64 | } 65 | -------------------------------------------------------------------------------- /carto/proj_web_mercator.go: -------------------------------------------------------------------------------- 1 | package carto 2 | 3 | import ( 4 | "github.com/peterstace/simplefeatures/geom" 5 | ) 6 | 7 | // WebMercator is a variant of the Web Mercator projection that is used for web 8 | // maps. The projection maps between (latitude, longitude) pairs expressed in 9 | // degrees, and (x, y) pairs. The x and y coordinates are in the range 0 to 10 | // 2^zoom, where zoom is the zoom level of the map. 11 | // 12 | // The x coordinate increases from left to right, and the y coordinate 13 | // increases from top to bottom. 14 | // 15 | // It is: 16 | // - Conformal (shape is preserved locally at all points). 17 | // - Not equal area. 18 | // - Not equidistant. 19 | type WebMercator struct { 20 | zoom int 21 | } 22 | 23 | // NewWebMercator returns a new WebMercator projection with the given zoom. 24 | func NewWebMercator(zoom int) *WebMercator { 25 | return &WebMercator{zoom} 26 | } 27 | 28 | // Forward converts a (longitude, latitude) pair expressed in degrees to a 29 | // projected (x, y) pair. 30 | func (m *WebMercator) Forward(lonlat geom.XY) geom.XY { 31 | var ( 32 | λd = lonlat.X 33 | φ = dtor(lonlat.Y) 34 | P = float64(int(1) << m.zoom) 35 | ) 36 | return geom.XY{ 37 | X: (λd + 180) / 360 * P, 38 | Y: (π - ln(tan(π/4+φ/2))) * P / (2 * π), 39 | } 40 | } 41 | 42 | // Reverse converts a projected (x, y) pair to a (longitude, latitude) pair 43 | // expressed in degrees. 44 | func (m *WebMercator) Reverse(xy geom.XY) geom.XY { 45 | var ( 46 | x = xy.X 47 | y = xy.Y 48 | P = float64(int(1) << m.zoom) 49 | ) 50 | var ( 51 | λd = x/P*360 - 180 52 | φr = 2 * (atan(exp(π-2*π*y/P)) - π/4) 53 | ) 54 | return geom.XY{ 55 | X: λd, 56 | Y: rtod(φr), 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /carto/radius.go: -------------------------------------------------------------------------------- 1 | package carto 2 | 3 | const ( 4 | // WGS84EllipsoidEquatorialRadiusM is the radius of the WGS84 ellipsoid at 5 | // the equator. 6 | WGS84EllipsoidEquatorialRadiusM = 6378137.0 7 | 8 | // WGS84EllipsoidPolarRadiusM is the radius of the WGS84 ellipsoid at the poles. 9 | WGS84EllipsoidPolarRadiusM = 6356752.314245 10 | 11 | // WGS84EllipsoidMeanRadiusM is the mean radius of the WGS84 ellipsoid. 12 | WGS84EllipsoidMeanRadiusM = (2*WGS84EllipsoidEquatorialRadiusM + WGS84EllipsoidPolarRadiusM) / 3 13 | ) 14 | -------------------------------------------------------------------------------- /carto/util.go: -------------------------------------------------------------------------------- 1 | package carto 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/peterstace/simplefeatures/geom" 7 | ) 8 | 9 | // This file contains utility functions that make projection formulas terser. 10 | // While terse code is usually _harder_ to read, the opposite is true for 11 | // mathematical formulas. 12 | 13 | func dtor(d float64) float64 { 14 | return d * π / 180 15 | } 16 | 17 | func rtod(r float64) float64 { 18 | return r * 180 / π 19 | } 20 | 21 | func rtodxy(λ, φ float64) geom.XY { 22 | return geom.XY{X: rtod(λ), Y: rtod(φ)} 23 | } 24 | 25 | const ( 26 | π = math.Pi 27 | ) 28 | 29 | func sqrt(x float64) float64 { 30 | return math.Sqrt(x) 31 | } 32 | 33 | func tan(x float64) float64 { 34 | return math.Tan(x) 35 | } 36 | 37 | func ln(x float64) float64 { 38 | return math.Log(x) 39 | } 40 | 41 | func sin(x float64) float64 { 42 | return math.Sin(x) 43 | } 44 | 45 | func cos(x float64) float64 { 46 | return math.Cos(x) 47 | } 48 | 49 | func atan(x float64) float64 { 50 | return math.Atan(x) 51 | } 52 | 53 | func atan2(y, x float64) float64 { 54 | return math.Atan2(y, x) 55 | } 56 | 57 | func asin(x float64) float64 { 58 | return math.Asin(x) 59 | } 60 | 61 | func acos(x float64) float64 { 62 | return math.Acos(x) 63 | } 64 | 65 | func exp(x float64) float64 { 66 | return math.Exp(x) 67 | } 68 | 69 | func pow(x, y float64) float64 { 70 | return math.Pow(x, y) 71 | } 72 | 73 | func sign(x float64) float64 { 74 | return math.Copysign(1, x) 75 | } 76 | 77 | func sec(x float64) float64 { 78 | return 1 / cos(x) 79 | } 80 | 81 | func cot(x float64) float64 { 82 | return 1 / tan(x) 83 | } 84 | 85 | func sq(x float64) float64 { 86 | return x * x 87 | } 88 | -------------------------------------------------------------------------------- /geom/alg_densify.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import "math" 4 | 5 | // densify returns a copy of the sequence with additional sets of coordinates 6 | // inserted such that the distance between adjacent sets of coordinates is at 7 | // most maxDist. 8 | func densify(seq Sequence, maxDist float64) Sequence { 9 | if maxDist <= 0 { 10 | panic("maxDist must be positive") 11 | } 12 | 13 | if seq.Length() == 0 { 14 | return seq 15 | } 16 | 17 | var dense []float64 18 | n := seq.Length() 19 | for i := 0; i+1 < n; i++ { 20 | c0 := seq.Get(i + 0) 21 | c1 := seq.Get(i + 1) 22 | 23 | // Copy start of segment: 24 | dense = c0.appendFloat64s(dense) 25 | 26 | // Copy any additional inter-segment coordinates: 27 | dist := c0.XY.distanceTo(c1.XY) 28 | subsections := int(math.Ceil(dist / maxDist)) 29 | for j := 1; j < subsections; j++ { 30 | cj := interpolateCoords(c0, c1, float64(j)/float64(subsections)) 31 | dense = cj.appendFloat64s(dense) 32 | } 33 | } 34 | 35 | // Copy end of last segment: 36 | dense = seq.Get(n - 1).appendFloat64s(dense) 37 | 38 | return NewSequence(dense, seq.CoordinatesType()) 39 | } 40 | -------------------------------------------------------------------------------- /geom/alg_densify_test.go: -------------------------------------------------------------------------------- 1 | package geom_test 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/peterstace/simplefeatures/geom" 8 | ) 9 | 10 | func TestDensifyEmpty(t *testing.T) { 11 | for _, empty := range []geom.Geometry{ 12 | geom.Point{}.AsGeometry(), 13 | geom.LineString{}.AsGeometry(), 14 | geom.Polygon{}.AsGeometry(), 15 | geom.MultiPoint{}.AsGeometry(), 16 | geom.MultiLineString{}.AsGeometry(), 17 | geom.MultiPolygon{}.AsGeometry(), 18 | geom.GeometryCollection{}.AsGeometry(), 19 | } { 20 | t.Run(empty.String(), func(t *testing.T) { 21 | for _, ct := range []geom.CoordinatesType{ 22 | geom.DimXY, 23 | geom.DimXYZ, 24 | geom.DimXYM, 25 | geom.DimXYZM, 26 | } { 27 | t.Run(ct.String(), func(t *testing.T) { 28 | input := empty.ForceCoordinatesType(ct) 29 | got := input.Densify(1.0) 30 | expectGeomEq(t, got, input) 31 | }) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestDensify(t *testing.T) { 38 | for i, tc := range []struct { 39 | input string 40 | maxDist float64 41 | want string 42 | }{ 43 | // LineString with a single segment (tests threshold logic): 44 | {"LINESTRING(0 0,1 0)", 2.0, "LINESTRING(0 0,1 0)"}, 45 | {"LINESTRING(0 0,1 0)", 1.0, "LINESTRING(0 0,1 0)"}, 46 | {"LINESTRING(0 0,1 0)", 0.9, "LINESTRING(0 0,0.5 0,1 0)"}, 47 | {"LINESTRING(0 0,1 0)", 0.5, "LINESTRING(0 0,0.5 0,1 0)"}, 48 | {"LINESTRING(0 0,1 0)", 0.4, "LINESTRING(0 0,0.3333333333333333 0,0.6666666666666666 0,1 0)"}, 49 | {"LINESTRING(0 0,1 0)", 0.3, "LINESTRING(0 0,0.25 0,0.5 0,0.75 0,1 0)"}, 50 | {"LINESTRING(0 0,1 0)", 0.25, "LINESTRING(0 0,0.25 0,0.5 0,0.75 0,1 0)"}, 51 | 52 | // LineString with Z/M/ZM: 53 | {"LINESTRING(1 2,3 4,5 6)", 1.5, "LINESTRING(1 2,2 3,3 4,4 5,5 6)"}, 54 | {"LINESTRING M(1 2 10,3 4 11,5 6 12)", 1.5, "LINESTRING M(1 2 10,2 3 10.5,3 4 11,4 5 11.5,5 6 12)"}, 55 | {"LINESTRING Z(1 2 10,3 4 11,5 6 12)", 1.5, "LINESTRING Z(1 2 10,2 3 10.5,3 4 11,4 5 11.5,5 6 12)"}, 56 | {"LINESTRING ZM(1 2 10 20,3 4 11 21,5 6 12 22)", 1.5, "LINESTRING ZM(1 2 10 20,2 3 10.5 20.5,3 4 11 21,4 5 11.5 21.5,5 6 12 22)"}, 57 | 58 | // LineString where each segment is broken into a different number of parts: 59 | {"LINESTRING(0 0,2 0,2 1)", 0.5, "LINESTRING(0 0,0.5 0,1 0,1.5 0,2 0,2 0.5,2 1)"}, 60 | 61 | // Other geometry types: 62 | {"POINT(0 0)", 1.0, "POINT(0 0)"}, 63 | {"MULTIPOINT((0 0),(1 1))", 1.0, "MULTIPOINT((0 0),(1 1))"}, 64 | {"MULTILINESTRING((0 0,1 1),(2 2,3 3))", 1.0, "MULTILINESTRING((0 0,0.5 0.5,1 1),(2 2,2.5 2.5,3 3))"}, 65 | {"POLYGON((0 0,0 1,1 0,0 0))", 1.0, "POLYGON((0 0,0 1,0.5 0.5,1 0,0 0))"}, 66 | {"MULTIPOLYGON(((0 0,0 1,1 0,0 0)))", 1.0, "MULTIPOLYGON(((0 0,0 1,0.5 0.5,1 0,0 0)))"}, 67 | {"GEOMETRYCOLLECTION(POINT(0 0),LINESTRING(0 0,1 1))", 1.0, "GEOMETRYCOLLECTION(POINT(0 0),LINESTRING(0 0,0.5 0.5,1 1))"}, 68 | } { 69 | t.Run(strconv.Itoa(i), func(t *testing.T) { 70 | input := geomFromWKT(t, tc.input) 71 | got := input.Densify(tc.maxDist) 72 | expectGeomEqWKT(t, got, tc.want) 73 | }) 74 | } 75 | } 76 | 77 | func TestDensifyInvalidMaxDist(t *testing.T) { 78 | for i, tc := range []struct { 79 | input string 80 | maxDist float64 81 | }{ 82 | {"LINESTRING(0 0,1 0)", -1}, 83 | {"LINESTRING(0 0,1 0)", 0}, 84 | {"POINT(0 0)", -1}, 85 | {"POINT(0 0)", 0}, 86 | {"MULTIPOINT((0 0))", -1}, 87 | {"MULTIPOINT((0 0))", 0}, 88 | } { 89 | t.Run(strconv.Itoa(i), func(t *testing.T) { 90 | input := geomFromWKT(t, tc.input) 91 | expectPanics(t, func() { input.Densify(tc.maxDist) }) 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /geom/alg_disjoint_set.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | // disjointSet implements a standard disjoint-set data structure (also known as 4 | // a union-find structure or a merge-find structure). It stores a collection of 5 | // disjoint (non-overlapping) sets. The set elements are integers (which may be 6 | // mapped externally to more complicated types). 7 | type disjointSet struct { 8 | parent []int // self reference indicates root 9 | rank []int 10 | } 11 | 12 | // newDistointSet creates a new disjoint set containing n sets, each with a 13 | // single item. The items are 0 (inclusive) through to n (exclusive). 14 | func newDisjointSet(n int) disjointSet { 15 | set := disjointSet{make([]int, n), make([]int, n)} 16 | for i := range set.parent { 17 | set.parent[i] = i 18 | } 19 | return set 20 | } 21 | 22 | // find searches for the representative for the set containing x. All elements 23 | // of the set containing x will have the same representative. To find out if 24 | // two elements are in the same set, find can be used on each element and the 25 | // representatives compared. 26 | func (s disjointSet) find(x int) int { 27 | root := x 28 | for s.parent[root] != root { 29 | root = s.parent[root] 30 | } 31 | for s.parent[x] != root { 32 | parent := s.parent[x] 33 | s.parent[x] = root 34 | x = parent 35 | } 36 | return root 37 | } 38 | 39 | // union merges the set containing x with the set containing y. 40 | func (s disjointSet) union(x, y int) { 41 | x = s.find(x) 42 | y = s.find(y) 43 | if x == y { 44 | return 45 | } 46 | 47 | if s.rank[x] < s.rank[y] { 48 | x, y = y, x 49 | } 50 | 51 | s.parent[y] = x 52 | if s.rank[x] == s.rank[y] { 53 | s.rank[x]++ 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /geom/alg_disjoint_set_internal_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | ) 7 | 8 | func TestDisjointSet(t *testing.T) { 9 | type intPair struct{ a, b int } 10 | for idx, tc := range []struct { 11 | size int 12 | merges []intPair 13 | }{ 14 | { 15 | size: 0, 16 | merges: nil, 17 | }, 18 | { 19 | size: 1, 20 | merges: []intPair{{0, 0}}, 21 | }, 22 | { 23 | size: 2, 24 | merges: []intPair{{0, 1}}, 25 | }, 26 | { 27 | size: 2, 28 | merges: []intPair{{1, 0}}, 29 | }, 30 | { 31 | size: 2, 32 | merges: []intPair{{1, 1}}, 33 | }, 34 | { 35 | size: 3, 36 | merges: []intPair{ 37 | {1, 0}, 38 | {0, 2}, 39 | }, 40 | }, 41 | { 42 | size: 4, 43 | merges: []intPair{ 44 | {2, 1}, 45 | {0, 3}, 46 | {1, 0}, 47 | }, 48 | }, 49 | { 50 | size: 5, 51 | merges: []intPair{ 52 | {2, 1}, 53 | {0, 3}, 54 | {1, 0}, 55 | {3, 4}, 56 | }, 57 | }, 58 | } { 59 | t.Run(strconv.Itoa(idx), func(t *testing.T) { 60 | t.Logf("num elements: %d", tc.size) 61 | simpleSet := newSimpleDisjointSet(tc.size) 62 | fastSet := newDisjointSet(tc.size) 63 | for _, m := range tc.merges { 64 | t.Logf("merging %d and %d", m.a, m.b) 65 | fastSet.union(m.a, m.b) 66 | simpleSet.union(m.a, m.b) 67 | for i := 0; i < tc.size; i++ { 68 | for j := 0; j < tc.size; j++ { 69 | gotFast := fastSet.find(i) == fastSet.find(j) 70 | gotSimple := simpleSet.find(i) == simpleSet.find(j) 71 | if gotFast != gotSimple { 72 | t.Errorf("mismatch between %d and %d in same set: fast=%v simple=%v", i, j, gotFast, gotSimple) 73 | } 74 | } 75 | } 76 | } 77 | }) 78 | } 79 | } 80 | 81 | // simpleDisjointSet is a _simple_ but _inefficient_ implementation 82 | // of a disjoint set data structure. It's used as a reference 83 | // implementation for testing. 84 | type simpleDisjointSet struct { 85 | // For the simple implementation, we store the set identifier 86 | // for each element directly. This results in very simple 87 | // operations, but linear union time complexity. 88 | set []int 89 | } 90 | 91 | func newSimpleDisjointSet(size int) simpleDisjointSet { 92 | items := make([]int, size) 93 | for i := range items { 94 | items[i] = i 95 | } 96 | return simpleDisjointSet{items} 97 | } 98 | 99 | func (s simpleDisjointSet) find(x int) int { 100 | return s.set[x] 101 | } 102 | 103 | func (s simpleDisjointSet) union(x, y int) { 104 | setX := s.find(x) 105 | setY := s.find(y) 106 | for i := range s.set { 107 | if s.set[i] == setY { 108 | s.set[i] = setX 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /geom/alg_intersection.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | func intersectionOfIndexedLines( 4 | lines1 indexedLines, lines2 indexedLines, 5 | ) ( 6 | MultiPoint, MultiLineString, 7 | ) { 8 | // TODO: Investigate potential speed up of swapping lines. 9 | var lss []LineString 10 | var pts []Point 11 | seen := make(map[XY]bool) 12 | for i := range lines1.lines { 13 | lines2.tree.RangeSearch(lines1.lines[i].box(), func(j int) error { 14 | inter := lines1.lines[i].intersectLine(lines2.lines[j]) 15 | if inter.empty { 16 | return nil 17 | } 18 | if inter.ptA == inter.ptB { 19 | if xy := inter.ptA; !seen[xy] { 20 | pt := xy.AsPoint() 21 | pts = append(pts, pt) 22 | seen[xy] = true 23 | } 24 | } else { 25 | lss = append(lss, line{inter.ptA, inter.ptB}.asLineString()) 26 | } 27 | return nil 28 | }) 29 | } 30 | return NewMultiPoint(pts), NewMultiLineString(lss) 31 | } 32 | 33 | func intersectionOfMultiPointAndMultiPoint(mp1, mp2 MultiPoint) MultiPoint { 34 | inMP1 := make(map[XY]bool) 35 | for i := 0; i < mp1.NumPoints(); i++ { 36 | xy, ok := mp1.PointN(i).XY() 37 | if ok { 38 | inMP1[xy] = true 39 | } 40 | } 41 | var pts []Point 42 | for i := 0; i < mp2.NumPoints(); i++ { 43 | pt := mp2.PointN(i) 44 | xy, ok := pt.XY() 45 | if ok && inMP1[xy] { 46 | pts = append(pts, pt) 47 | } 48 | } 49 | return NewMultiPoint(pts) 50 | } 51 | -------------------------------------------------------------------------------- /geom/alg_linear_interpolation.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "math" 5 | "sort" 6 | ) 7 | 8 | type linearInterpolator struct { 9 | seq Sequence 10 | cumulative []float64 11 | total float64 12 | } 13 | 14 | func newLinearInterpolator(seq Sequence) linearInterpolator { 15 | n := seq.Length() 16 | if n == 0 { 17 | panic("empty seq in newLinearInterpolator") 18 | } 19 | var total float64 20 | cumulative := make([]float64, n-1) 21 | for i := 0; i < n-1; i++ { 22 | total += seq.GetXY(i).distanceTo(seq.GetXY(i + 1)) 23 | cumulative[i] = total 24 | } 25 | return linearInterpolator{seq, cumulative, total} 26 | } 27 | 28 | func (l linearInterpolator) interpolate(frac float64) Point { 29 | frac = math.Max(0, math.Min(1, frac)) 30 | idx := sort.SearchFloat64s(l.cumulative, frac*l.total) 31 | if idx == l.seq.Length() { 32 | return l.seq.Get(idx - 1).AsPoint() 33 | } 34 | 35 | p0 := l.seq.Get(idx + 0) 36 | p1 := l.seq.Get(idx + 1) 37 | 38 | partial := frac * l.total 39 | if idx-1 >= 0 { 40 | partial -= l.cumulative[idx-1] 41 | } 42 | partial /= p0.XY.distanceTo(p1.XY) 43 | 44 | return interpolateCoords(p0, p1, partial).AsPoint() 45 | } 46 | 47 | // lerp calculates the linear interpolation (or extrapolation) between a and b 48 | // at t. Mathematically, the result is: 49 | // 50 | // a + t×(b-a) 51 | // 52 | // or equivalently: 53 | // 54 | // (1-t)×a + t×b 55 | // 56 | // In IEEE floating point math, the implementation can't be that simple due to 57 | // rounding and over/underflow of intermediate results. Instead, we used the 58 | // hybrid approach described by Davis Herring in [1]. It's is much more complex 59 | // than the naive approach, but fixes a lot of edge cases that would otherwise 60 | // occur. 61 | // 62 | // [1]: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0811r2.html 63 | func lerp(a, b, t float64) float64 { 64 | if a <= 0 && b >= 0 || a >= 0 && b <= 0 { 65 | return t*b + (1-t)*a 66 | } 67 | if t == 1 { 68 | return b 69 | } 70 | x := a + t*(b-a) 71 | if (t > 1) == (b > a) { 72 | return math.Max(b, x) 73 | } 74 | return math.Min(b, x) 75 | } 76 | 77 | func interpolateCoords(c0, c1 Coordinates, frac float64) Coordinates { 78 | return Coordinates{ 79 | XY: XY{ 80 | X: lerp(c0.X, c1.X, frac), 81 | Y: lerp(c0.Y, c1.Y, frac), 82 | }, 83 | Z: lerp(c0.Z, c1.Z, frac), 84 | M: lerp(c0.M, c1.M, frac), 85 | Type: c0.Type & c1.Type, 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /geom/alg_linear_interpolation_internal_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | ) 7 | 8 | func TestLERP(t *testing.T) { 9 | for i, tc := range []struct { 10 | a, b, t, want float64 11 | }{ 12 | // Interpolation between a and b. 13 | {a: -1, b: 3, t: 0.00, want: -1}, 14 | {a: -1, b: 3, t: 0.25, want: +0}, 15 | {a: -1, b: 3, t: 0.50, want: +1}, 16 | {a: -1, b: 3, t: 0.75, want: +2}, 17 | {a: -1, b: 3, t: 1.00, want: +3}, 18 | 19 | // Extrapolation outside a and b: 20 | {a: -1, b: 3, t: -0.5, want: -3}, 21 | {a: -1, b: 3, t: +1.5, want: +5}, 22 | 23 | // Reproduces a bug when lerp implemented as: return a + t*(b-a) 24 | { 25 | a: 0.4295025244660839, 26 | b: 0.11201266333061713, 27 | t: 1, 28 | want: 0.11201266333061713, // same as 'a' 29 | }, 30 | 31 | // Reproduces a bug when lerp implemented as: return t*a + (1-t)*b 32 | { 33 | a: 0.9202968672544602, 34 | b: 0.9202968672544602, 35 | t: 0.3251482131256554, 36 | want: 0.9202968672544602, 37 | }, 38 | 39 | // Reproduces other bugs: 40 | {a: 1, b: 3, t: 0.5, want: 2}, 41 | } { 42 | t.Run(strconv.Itoa(i), func(t *testing.T) { 43 | got := lerp(tc.a, tc.b, tc.t) 44 | if got != tc.want { 45 | t.Logf("a=%v, b=%v, t=%v", tc.a, tc.b, tc.t) 46 | t.Errorf("got %v, want %v", got, tc.want) 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /geom/alg_orientation.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | // threePointOrientation describes the relationship between 3 distinct points 4 | // in the plane. The relationships as described in words (i.e. 'left', 'right', 5 | // 'collinear') assume that the X axis increases from left to right, and the Y 6 | // axis increases from bottom to top. 7 | type threePointOrientation int 8 | 9 | const ( 10 | // rightTurn indicates that the last point is to the right of the line 11 | // formed by traversing from the first point to the second point. 12 | // 13 | // A---B 14 | // \ 15 | // C 16 | rightTurn threePointOrientation = iota + 1 17 | 18 | // collinear indicates that the three points are on the same line. 19 | // 20 | // A---B---C 21 | collinear 22 | 23 | // rightTurn indicates that the last point is to the left of the line 24 | // formed by traversing from the first point to the second point. 25 | // 26 | // C 27 | // / 28 | // A---B 29 | leftTurn 30 | ) 31 | 32 | // orientation calculates the 3 point orientation between p, q, and s. 33 | func orientation(p, q, s XY) threePointOrientation { 34 | cp := q.Sub(p).Cross(s.Sub(q)) 35 | switch { 36 | case cp > 0: 37 | return leftTurn 38 | case cp < 0: 39 | return rightTurn 40 | default: 41 | return collinear 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /geom/alg_orientation_internal_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import "testing" 4 | 5 | func TestOrientation(t *testing.T) { 6 | testCases := []struct { 7 | name string 8 | p XY 9 | q XY 10 | s XY 11 | expected threePointOrientation 12 | }{ 13 | { 14 | name: "when the s is on left hand side of line of p and q", 15 | p: XY{0, 0}, 16 | q: XY{1, 0}, 17 | s: XY{0, 1}, 18 | expected: leftTurn, 19 | }, 20 | { 21 | name: "when the s is on right hand side of line of p and q", 22 | p: XY{0, 0}, 23 | q: XY{0, 1}, 24 | s: XY{1, 0}, 25 | expected: rightTurn, 26 | }, 27 | { 28 | name: "when the s, q and p are collinear", 29 | p: XY{1, 1}, 30 | q: XY{2, 2}, 31 | s: XY{3, 3}, 32 | expected: collinear, 33 | }, 34 | } 35 | 36 | for _, tc := range testCases { 37 | t.Run(tc.name, func(t *testing.T) { 38 | actual := orientation(tc.p, tc.q, tc.s) 39 | if actual != tc.expected { 40 | t.Errorf("expected: %d, got: %d", tc.expected, actual) 41 | } 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /geom/alg_point_in_ring.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/peterstace/simplefeatures/rtree" 7 | ) 8 | 9 | type side int 10 | 11 | const ( 12 | interior side = -1 13 | boundary side = 0 14 | exterior side = +1 15 | ) 16 | 17 | // relatePointToRing checks the side of a ring that a point is on. It assumes that 18 | // the input ring is actually a ring (i.e. closed and simple) and is non-empty. 19 | func relatePointToRing(pt XY, ring LineString) side { 20 | seq := ring.Coordinates() 21 | n := seq.Length() 22 | 23 | var count int 24 | for i := 0; i < n; i++ { 25 | ln, ok := getLine(seq, i) 26 | if !ok { 27 | continue 28 | } 29 | crossing, onLine := hasCrossing(pt, ln) 30 | if onLine { 31 | return boundary 32 | } 33 | if crossing { 34 | count++ 35 | } 36 | } 37 | if count%2 == 0 { 38 | return exterior 39 | } 40 | return interior 41 | } 42 | 43 | func hasCrossing(pt XY, ln line) (crossing, onLine bool) { 44 | lower, upper := ln.a, ln.b 45 | if lower.Y > upper.Y { 46 | lower, upper = upper, lower 47 | } 48 | o := orientation(lower, upper, pt) 49 | 50 | crossing = pt.Y >= lower.Y && pt.Y < upper.Y && o == rightTurn 51 | onLine = ln.uncheckedEnvelope().Contains(pt) && o == collinear 52 | return 53 | } 54 | 55 | func relatePointToPolygon(pt XY, polyBoundary indexedLines) side { 56 | box := rtree.Box{ 57 | MinX: math.Inf(-1), 58 | MinY: pt.Y, 59 | MaxX: pt.X, 60 | MaxY: pt.Y, 61 | } 62 | var onBound bool 63 | var count int 64 | polyBoundary.tree.RangeSearch(box, func(i int) error { 65 | ln := polyBoundary.lines[i] 66 | crossing, onLine := hasCrossing(pt, ln) 67 | if onLine { 68 | onBound = true 69 | return rtree.Stop 70 | } 71 | if crossing { 72 | count++ 73 | } 74 | return nil 75 | }) 76 | if onBound { 77 | return boundary 78 | } 79 | if count%2 == 1 { 80 | return interior 81 | } 82 | return exterior 83 | } 84 | -------------------------------------------------------------------------------- /geom/alg_point_on_surface.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "math" 5 | "sort" 6 | ) 7 | 8 | func newNearestPointAccumulator(target Point) nearestPointAccumulator { 9 | return nearestPointAccumulator{target: target} 10 | } 11 | 12 | // nearestPointAccumulator keeps track of the point within a group of 13 | // candidates that is nearest to a target point. 14 | type nearestPointAccumulator struct { 15 | target Point 16 | point Point 17 | dist float64 18 | } 19 | 20 | // consider considers if a candidate point is the new nearest point to the target. 21 | func (n *nearestPointAccumulator) consider(candidate Point) { 22 | targetXY, ok := n.target.XY() 23 | if !ok { 24 | return 25 | } 26 | candidateXY, ok := candidate.XY() 27 | if !ok { 28 | return 29 | } 30 | 31 | delta := targetXY.Sub(candidateXY) 32 | candidateDist := delta.lengthSq() 33 | if n.point.IsEmpty() || candidateDist < n.dist { 34 | n.dist = candidateDist 35 | n.point = candidate 36 | } 37 | } 38 | 39 | func pointOnAreaSurface(poly Polygon) (Point, float64) { 40 | // Algorithm overview: 41 | // 42 | // 1. Find the middle Y value of the envelope around the Polygon. 43 | // 44 | // 2. If the Y value of any control points in the polygon share that 45 | // mid-envelope Y value, then choose a new Y value. The new Y value is the 46 | // average of the mid-envelope Y value and the Y value of the next highest 47 | // control point. 48 | // 49 | // 3. Construct a bisector line that crosses through the polygon at the 50 | // height of the chosen Y value. Due to the choice of Y value, this 51 | // bisector won't pass through any control point in the polygon. 52 | // 53 | // 4. Find the largest portion of the bisector line that intersects with 54 | // the Polygon. 55 | // 56 | // 5. The PointOnSurface is the midpoint of that largest portion. 57 | 58 | // Find envelope midpoint. 59 | env := poly.Envelope() 60 | mid, ok := env.Center().XY() 61 | if !ok { 62 | return Point{}, 0 63 | } 64 | midY := mid.Y 65 | 66 | // Adjust mid-y value if a control point has the same Y. 67 | var midYMatchesNode bool 68 | nextY := math.Inf(+1) 69 | for _, ring := range poly.rings { 70 | seq := ring.Coordinates() 71 | for i := 0; i < seq.Length(); i++ { 72 | xy := seq.GetXY(i) 73 | if xy.Y == midY { 74 | midYMatchesNode = true 75 | } 76 | if xy.Y < nextY && xy.Y > midY { 77 | nextY = xy.Y 78 | } 79 | } 80 | } 81 | if midYMatchesNode { 82 | midY = (midY + nextY) / 2 83 | } 84 | 85 | // Create bisector. 86 | envMin, envMax, ok := env.MinMaxXYs() 87 | if !ok { 88 | return Point{}, 0 89 | } 90 | 91 | bisector := line{ 92 | XY{envMin.X - 1, midY}, 93 | XY{envMax.X + 1, midY}, 94 | } 95 | 96 | // Find intersection points between the bisector and the polygon. 97 | var xIntercepts []float64 98 | for _, ring := range poly.rings { 99 | seq := ring.Coordinates() 100 | n := seq.Length() 101 | for i := 0; i < n; i++ { 102 | ln, ok := getLine(seq, i) 103 | if !ok { 104 | continue 105 | } 106 | inter := ln.intersectLine(bisector) 107 | if inter.empty { 108 | continue 109 | } 110 | // It shouldn't _ever_ be the case that inter.ptA is different from 111 | // inter.ptB, as this would imply that there is a line in the 112 | // polygon that is horizontal and has the same Y value as our 113 | // bisector. But from the way the bisector was constructed, this 114 | // can't happen. So we can just use inter.ptA. 115 | xIntercepts = append(xIntercepts, inter.ptA.X) 116 | } 117 | } 118 | xIntercepts = sortAndUniquifyFloats(xIntercepts) 119 | 120 | // Find largest portion of bisector that intersects the polygon. 121 | if len(xIntercepts) < 2 || len(xIntercepts)%2 != 0 { 122 | // The only way this could happen is if the input Polygon is invalid, 123 | // or there is some sort of pathological case. So we just return an 124 | // arbitrary point on the Polygon. 125 | return poly.ExteriorRing().StartPoint(), 0 126 | } 127 | bestA, bestB := xIntercepts[0], xIntercepts[1] 128 | for i := 2; i+1 < len(xIntercepts); i += 2 { 129 | newA, newB := xIntercepts[i], xIntercepts[i+1] 130 | if newB-newA > bestB-bestA { 131 | bestA, bestB = newA, newB 132 | } 133 | } 134 | midX := (bestA + bestB) / 2 135 | 136 | return XY{midX, midY}.AsPoint(), bestB - bestA 137 | } 138 | 139 | func sortAndUniquifyFloats(fs []float64) []float64 { 140 | if len(fs) == 0 { 141 | return fs 142 | } 143 | sort.Float64s(fs) 144 | n := 1 145 | for i := 1; i < len(fs); i++ { 146 | if fs[i] != fs[i-1] { 147 | fs[n] = fs[i] 148 | n++ 149 | } 150 | } 151 | return fs[:n] 152 | } 153 | -------------------------------------------------------------------------------- /geom/alg_set_op.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | // Union returns a geometry that represents the parts from either geometry A or 4 | // geometry B (or both). An error may be returned in pathological cases of 5 | // numerical degeneracy. 6 | func Union(a, b Geometry) (Geometry, error) { 7 | if a.IsEmpty() && b.IsEmpty() { 8 | return Geometry{}, nil 9 | } 10 | if a.IsEmpty() { 11 | return UnaryUnion(b) 12 | } 13 | if b.IsEmpty() { 14 | return UnaryUnion(a) 15 | } 16 | g, err := setOp(a, or, b) 17 | return g, wrap(err, "executing union") 18 | } 19 | 20 | // Intersection returns a geometry that represents the parts that are common to 21 | // both geometry A and geometry B. An error may be returned in pathological 22 | // cases of numerical degeneracy. 23 | func Intersection(a, b Geometry) (Geometry, error) { 24 | if a.IsEmpty() || b.IsEmpty() { 25 | return Geometry{}, nil 26 | } 27 | g, err := setOp(a, and, b) 28 | return g, wrap(err, "executing intersection") 29 | } 30 | 31 | // Difference returns a geometry that represents the parts of input geometry A 32 | // that are not part of input geometry B. An error may be returned in cases of 33 | // pathological cases of numerical degeneracy. 34 | func Difference(a, b Geometry) (Geometry, error) { 35 | if a.IsEmpty() { 36 | return Geometry{}, nil 37 | } 38 | if b.IsEmpty() { 39 | return UnaryUnion(a) 40 | } 41 | g, err := setOp(a, andNot, b) 42 | return g, wrap(err, "executing difference") 43 | } 44 | 45 | // SymmetricDifference returns a geometry that represents the parts of geometry 46 | // A and B that are not in common. An error may be returned in pathological 47 | // cases of numerical degeneracy. 48 | func SymmetricDifference(a, b Geometry) (Geometry, error) { 49 | if a.IsEmpty() && b.IsEmpty() { 50 | return Geometry{}, nil 51 | } 52 | if a.IsEmpty() { 53 | return UnaryUnion(b) 54 | } 55 | if b.IsEmpty() { 56 | return UnaryUnion(a) 57 | } 58 | g, err := setOp(a, xor, b) 59 | return g, wrap(err, "executing symmetric difference") 60 | } 61 | 62 | // UnaryUnion is a single input variant of the Union function, unioning 63 | // together the components of the input geometry. 64 | func UnaryUnion(g Geometry) (Geometry, error) { 65 | return setOp(g, or, Geometry{}) 66 | } 67 | 68 | // UnionMany unions together the input geometries. 69 | func UnionMany(gs []Geometry) (Geometry, error) { 70 | gc := NewGeometryCollection(gs) 71 | return UnaryUnion(gc.AsGeometry()) 72 | } 73 | 74 | func setOp(a Geometry, include func([2]bool) bool, b Geometry) (Geometry, error) { 75 | overlay := newDCELFromGeometries(a, b) 76 | g, err := overlay.extractGeometry(include) 77 | if err != nil { 78 | return Geometry{}, wrap(err, "internal error extracting geometry") 79 | } 80 | if err := g.Validate(); err != nil { 81 | return Geometry{}, wrap(err, "invalid geometry produced by overlay") 82 | } 83 | return g, nil 84 | } 85 | 86 | func or(b [2]bool) bool { return b[0] || b[1] } 87 | func and(b [2]bool) bool { return b[0] && b[1] } 88 | func xor(b [2]bool) bool { return b[0] != b[1] } 89 | func andNot(b [2]bool) bool { return b[0] && !b[1] } 90 | -------------------------------------------------------------------------------- /geom/alg_simplify.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | func ramerDouglasPeucker(dst []float64, seq Sequence, threshold float64) []float64 { 4 | if seq.Length() <= 2 { 5 | return seq.appendAllPoints(dst) 6 | } 7 | 8 | start := 0 9 | end := seq.Length() - 1 10 | 11 | for start < end { 12 | dst = seq.appendPoint(dst, start) 13 | newEnd := end 14 | for { 15 | var maxDist float64 16 | var maxDistIdx int 17 | for i := start + 1; i < newEnd; i++ { 18 | if d := perpendicularDistance( 19 | seq.GetXY(i), 20 | seq.GetXY(start), 21 | seq.GetXY(newEnd), 22 | ); d > maxDist { 23 | maxDistIdx = i 24 | maxDist = d 25 | } 26 | } 27 | if maxDist <= threshold { 28 | break 29 | } 30 | newEnd = maxDistIdx 31 | } 32 | start = newEnd 33 | } 34 | dst = seq.appendPoint(dst, end) 35 | return dst 36 | } 37 | 38 | // perpendicularDistance is the distance from 'p' to the infinite line going 39 | // through 'a' and 'b'. If 'a' and 'b' are the same, then the distance between 40 | // 'a'/'b' and 'p' is returned. 41 | func perpendicularDistance(p, a, b XY) float64 { 42 | if a == b { 43 | return p.Sub(a).Length() 44 | } 45 | aSubP := a.Sub(p) 46 | bSubA := b.Sub(a) 47 | unit := bSubA.Scale(1 / bSubA.Length()) 48 | perpendicular := aSubP.Sub(unit.Scale(aSubP.Dot(unit))) 49 | return perpendicular.Length() 50 | } 51 | -------------------------------------------------------------------------------- /geom/coordinate_type.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import "fmt" 4 | 5 | // CoordinatesType controls the dimensionality and type of data used to encode 6 | // a point location. At minimum, a point location is defined by X and Y 7 | // coordinates. It may optionally include a Z value, representing height. It 8 | // may also optionally include an M value, traditionally representing an 9 | // arbitrary user defined measurement associated with each point location. 10 | type CoordinatesType byte 11 | 12 | const ( 13 | // DimXY coordinates only contain X and Y values. 14 | DimXY CoordinatesType = 0b00 15 | 16 | // DimXYZ coordinates contain X, Y, and Z (height) values. 17 | DimXYZ CoordinatesType = 0b01 18 | 19 | // DimXYM coordinates contain X, Y, and M (measure) values. 20 | DimXYM CoordinatesType = 0b10 21 | 22 | // DimXYZM coordinates contain X, Y, Z (height), and M (measure) values. 23 | DimXYZM CoordinatesType = 0b11 24 | ) 25 | 26 | // String gives a string representation of a CoordinatesType. 27 | func (t CoordinatesType) String() string { 28 | if t < 4 { 29 | return [4]string{"XY", "XYZ", "XYM", "XYZM"}[t] 30 | } 31 | return fmt.Sprintf("unknown coordinate type (%d)", t) 32 | } 33 | 34 | // Dimension returns the number of float64 coordinates required to encode a 35 | // point location using the CoordinatesType. 36 | func (t CoordinatesType) Dimension() int { 37 | return [4]int{2, 3, 3, 4}[t] 38 | } 39 | 40 | // Is3D returns true if and only if the CoordinatesType includes a Z (3D) 41 | // value. 42 | func (t CoordinatesType) Is3D() bool { 43 | return (t & DimXYZ) != 0 44 | } 45 | 46 | // IsMeasured returns true if and only if the Coordinates type includes an M 47 | // (measure) value. 48 | func (t CoordinatesType) IsMeasured() bool { 49 | return (t & DimXYM) != 0 50 | } 51 | -------------------------------------------------------------------------------- /geom/ctor_options_test.go: -------------------------------------------------------------------------------- 1 | package geom_test 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/peterstace/simplefeatures/geom" 8 | ) 9 | 10 | func TestDisableValidation(t *testing.T) { 11 | for i, wkt := range []string{ 12 | // Point -- has no geometric validations 13 | "LINESTRING(1 2,1 2)", // same point 14 | "LINESTRING(1 2,1 2,1 2)", // same point 15 | "POLYGON((1 2,1 2,1 2))", // same point 16 | "POLYGON((0 0,0 1,1 0))", // not closed 17 | "POLYGON((0 0,2 0,2 1,1 0,0 1,0 0))", // not simple 18 | // Exterior ring inside interior ring 19 | `POLYGON( 20 | (5 0,0 6,6 6,6 0,0 0), 21 | (1 1,1 9,9 9,9 1,1 1) 22 | )`, 23 | // MultiPoint -- has no validations 24 | "MULTILINESTRING((1 2,3 4),(1 1,1 1))", 25 | // Sub-Polygons overlap 26 | `MULTIPOLYGON( 27 | ((0 0,2 0,2 2,0 2,0 0)), 28 | ((1 1,3 1,3 3,1 3,1 1)) 29 | )`, 30 | "GEOMETRYCOLLECTION(LINESTRING(0 1,0 1))", 31 | } { 32 | t.Run(strconv.Itoa(i), func(t *testing.T) { 33 | _, err := geom.UnmarshalWKT(wkt) 34 | if err == nil { 35 | t.Logf("wkt: %v", wkt) 36 | t.Fatal("expected validation error unmarshalling wkt") 37 | } 38 | _, err = geom.UnmarshalWKT(wkt, geom.NoValidate{}) 39 | if err != nil { 40 | t.Logf("wkt: %v", wkt) 41 | t.Errorf("disabling validations still gave an error: %v", err) 42 | } 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /geom/dcel.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | func newDCELFromGeometries(a, b Geometry) *doublyConnectedEdgeList { 4 | ghosts := createGhosts(a, b) 5 | a, b, ghosts = reNodeGeometries(a, b, ghosts) 6 | 7 | interactions := findInteractionPoints([]Geometry{a, b, ghosts.AsGeometry()}) 8 | 9 | dcel := newDCEL() 10 | dcel.addVertices(interactions) 11 | dcel.addGhosts(ghosts, interactions) 12 | dcel.addGeometry(a, operandA, interactions) 13 | dcel.addGeometry(b, operandB, interactions) 14 | 15 | dcel.fixVertices() 16 | dcel.assignFaces() 17 | dcel.populateInSetLabels() 18 | 19 | return dcel 20 | } 21 | 22 | func newDCEL() *doublyConnectedEdgeList { 23 | return &doublyConnectedEdgeList{ 24 | faces: nil, 25 | halfEdges: make(map[[2]XY]*halfEdgeRecord), 26 | vertices: make(map[XY]*vertexRecord), 27 | } 28 | } 29 | 30 | type doublyConnectedEdgeList struct { 31 | faces []*faceRecord // only populated in the overlay 32 | halfEdges map[[2]XY]*halfEdgeRecord 33 | vertices map[XY]*vertexRecord 34 | } 35 | 36 | type faceRecord struct { 37 | cycle *halfEdgeRecord 38 | 39 | // inSet encodes whether this face is part of the input geometry for each 40 | // operand. 41 | inSet [2]bool 42 | 43 | extracted bool 44 | } 45 | 46 | type halfEdgeRecord struct { 47 | origin *vertexRecord 48 | twin *halfEdgeRecord 49 | incident *faceRecord // only populated in the overlay 50 | next, prev *halfEdgeRecord 51 | seq Sequence 52 | 53 | // srcEdge encodes whether or not this edge is explicitly appears as part 54 | // of the input geometries. 55 | srcEdge [2]bool 56 | 57 | // srcFace encodes whether or not this edge explicitly borders onto a face 58 | // in the input geometries. 59 | srcFace [2]bool 60 | 61 | // inSet encodes whether or not this edge is (explicitly or implicitly) 62 | // part of the input geometry for each operand. 63 | inSet [2]bool 64 | 65 | extracted bool 66 | } 67 | 68 | type vertexRecord struct { 69 | coords XY 70 | incidents map[*halfEdgeRecord]struct{} 71 | 72 | // src encodes whether on not this vertex explicitly appears in the input 73 | // geometries. 74 | src [2]bool 75 | 76 | // inSet encodes whether or not this vertex is part of each input geometry 77 | // (although it might not be explicitly encoded there). 78 | inSet [2]bool 79 | 80 | locations [2]location 81 | extracted bool 82 | } 83 | 84 | func forEachEdgeInCycle(start *halfEdgeRecord, fn func(*halfEdgeRecord)) { 85 | e := start 86 | for { 87 | fn(e) 88 | e = e.next 89 | if e == start { 90 | break 91 | } 92 | } 93 | } 94 | 95 | // operand represents either the first (A) or second (B) geometry in a binary 96 | // operation (such as Union or Covers). 97 | type operand int 98 | 99 | const ( 100 | operandA operand = 0 101 | operandB operand = 1 102 | ) 103 | 104 | func forEachOperand(fn func(operand operand)) { 105 | fn(operandA) 106 | fn(operandB) 107 | } 108 | 109 | type location struct { 110 | interior bool 111 | boundary bool 112 | } 113 | -------------------------------------------------------------------------------- /geom/dcel_extract_intersection_matrix.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | func (d *doublyConnectedEdgeList) extractIntersectionMatrix() matrix { 4 | im := newMatrix() 5 | for _, v := range d.vertices { 6 | locA := v.location(operandA) 7 | locB := v.location(operandB) 8 | im.set(locA, locB, '0') 9 | } 10 | for _, e := range d.halfEdges { 11 | locA := e.location(operandA) 12 | locB := e.location(operandB) 13 | im.set(locA, locB, '1') 14 | } 15 | for _, f := range d.faces { 16 | locA := f.location(operandA) 17 | locB := f.location(operandB) 18 | im.set(locA, locB, '2') 19 | } 20 | return im 21 | } 22 | 23 | func (f *faceRecord) location(operand operand) imLocation { 24 | if !f.inSet[operand] { 25 | return imExterior 26 | } 27 | return imInterior 28 | } 29 | 30 | func (e *halfEdgeRecord) location(operand operand) imLocation { 31 | face1Present := e.incident.inSet[operand] 32 | face2Present := e.twin.incident.inSet[operand] 33 | if face1Present && face2Present { 34 | return imInterior 35 | } 36 | if face1Present != face2Present { 37 | return imBoundary 38 | } 39 | if e.inSet[operand] { 40 | return imInterior 41 | } 42 | return imExterior 43 | } 44 | 45 | func (v *vertexRecord) location(operand operand) imLocation { 46 | // NOTE: It's important that we check the Boundary flag before the Interior 47 | // flag, since both might be set. In that case, we want to treat the 48 | // location as a Boundary, since the boundary is a more specific case. 49 | if v.locations[operand].boundary { 50 | return imBoundary 51 | } 52 | if v.locations[operand].interior { 53 | return imInterior 54 | } 55 | 56 | // We don't know the location of the point. But it must be either Exterior 57 | // or Interior because if it were Boundary, then we would know that due to 58 | // an explicit flag. We can just use the location of one of the incident 59 | // edges, since that would have the same location. 60 | for e := range v.incidents { 61 | return e.location(operand) 62 | } 63 | panic("point has no incidents") // Can't happen, due to ghost edges. 64 | } 65 | -------------------------------------------------------------------------------- /geom/dcel_ghosts.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/peterstace/simplefeatures/rtree" 7 | ) 8 | 9 | // createGhosts creates a MultiLineString that connects all components of the 10 | // input Geometries. 11 | func createGhosts(a, b Geometry) MultiLineString { 12 | var points []XY 13 | points = appendComponentPoints(points, a) 14 | points = appendComponentPoints(points, b) 15 | ghosts := spanningTree(points) 16 | return ghosts 17 | } 18 | 19 | // spanningTree creates a near-minimum spanning tree (using the euclidean 20 | // distance metric) over the supplied points. The tree will consist of N-1 21 | // lines, where N is the number of _distinct_ xys supplied. 22 | // 23 | // It's a 'near' minimum spanning tree rather than a spanning tree, because we 24 | // use a simple greedy algorithm rather than a proper minimum spanning tree 25 | // algorithm. 26 | func spanningTree(xys []XY) MultiLineString { 27 | if len(xys) <= 1 { 28 | return MultiLineString{} 29 | } 30 | 31 | // Load points into r-tree. 32 | xys = sortAndUniquifyXYs(xys) 33 | items := make([]rtree.BulkItem, len(xys)) 34 | for i, xy := range xys { 35 | items[i] = rtree.BulkItem{Box: xy.box(), RecordID: i} 36 | } 37 | tree := rtree.BulkLoad(items) 38 | 39 | // The disjoint set keeps track of which points have been joined together 40 | // so far. Two entries in dset are in the same set iff they are connected 41 | // in the incrementally-built spanning tree. 42 | dset := newDisjointSet(len(xys)) 43 | lss := make([]LineString, 0, len(xys)-1) 44 | 45 | for i, xyi := range xys { 46 | if i == len(xys)-1 { 47 | // Skip the last point, since a tree is formed from N-1 edges 48 | // rather than N edges. The last point will be included by virtue 49 | // of being the closest to another point. 50 | continue 51 | } 52 | tree.PrioritySearch(xyi.box(), func(j int) error { 53 | // We don't want to include a new edge in the spanning tree if it 54 | // would cause a cycle (i.e. the two endpoints are already in the 55 | // same tree). This is checked via dset. 56 | if i == j || dset.find(i) == dset.find(j) { 57 | return nil 58 | } 59 | dset.union(i, j) 60 | xyj := xys[j] 61 | lss = append(lss, line{xyi, xyj}.asLineString()) 62 | return rtree.Stop 63 | }) 64 | } 65 | 66 | return NewMultiLineString(lss) 67 | } 68 | 69 | func appendXYForPoint(xys []XY, pt Point) []XY { 70 | if xy, ok := pt.XY(); ok { 71 | xys = append(xys, xy) 72 | } 73 | return xys 74 | } 75 | 76 | func appendXYForLineString(xys []XY, ls LineString) []XY { 77 | return appendXYForPoint(xys, ls.StartPoint()) 78 | } 79 | 80 | func appendXYsForPolygon(xys []XY, poly Polygon) []XY { 81 | xys = appendXYForLineString(xys, poly.ExteriorRing()) 82 | n := poly.NumInteriorRings() 83 | for i := 0; i < n; i++ { 84 | xys = appendXYForLineString(xys, poly.InteriorRingN(i)) 85 | } 86 | return xys 87 | } 88 | 89 | func appendComponentPoints(xys []XY, g Geometry) []XY { 90 | switch g.Type() { 91 | case TypePoint: 92 | return appendXYForPoint(xys, g.MustAsPoint()) 93 | case TypeMultiPoint: 94 | mp := g.MustAsMultiPoint() 95 | n := mp.NumPoints() 96 | for i := 0; i < n; i++ { 97 | xys = appendXYForPoint(xys, mp.PointN(i)) 98 | } 99 | return xys 100 | case TypeLineString: 101 | ls := g.MustAsLineString() 102 | return appendXYForLineString(xys, ls) 103 | case TypeMultiLineString: 104 | mls := g.MustAsMultiLineString() 105 | n := mls.NumLineStrings() 106 | for i := 0; i < n; i++ { 107 | ls := mls.LineStringN(i) 108 | xys = appendXYForLineString(xys, ls) 109 | } 110 | return xys 111 | case TypePolygon: 112 | poly := g.MustAsPolygon() 113 | return appendXYsForPolygon(xys, poly) 114 | case TypeMultiPolygon: 115 | mp := g.MustAsMultiPolygon() 116 | n := mp.NumPolygons() 117 | for i := 0; i < n; i++ { 118 | poly := mp.PolygonN(i) 119 | xys = appendXYsForPolygon(xys, poly) 120 | } 121 | return xys 122 | case TypeGeometryCollection: 123 | gc := g.MustAsGeometryCollection() 124 | n := gc.NumGeometries() 125 | for i := 0; i < n; i++ { 126 | xys = appendComponentPoints(xys, gc.GeometryN(i)) 127 | } 128 | return xys 129 | default: 130 | panic(fmt.Sprintf("unknown geometry type: %v", g.Type())) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /geom/dcel_ghosts_internal_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | ) 7 | 8 | func TestSpanningTree(t *testing.T) { 9 | for i, tc := range []struct { 10 | xys []XY 11 | wantWKT string 12 | }{ 13 | { 14 | xys: nil, 15 | wantWKT: "MULTILINESTRING EMPTY", 16 | }, 17 | { 18 | xys: []XY{{1, 1}}, 19 | wantWKT: "MULTILINESTRING EMPTY", 20 | }, 21 | { 22 | xys: []XY{{2, 1}, {1, 2}}, 23 | wantWKT: "MULTILINESTRING((2 1,1 2))", 24 | }, 25 | { 26 | xys: []XY{{2, 0}, {2, 2}, {0, 0}, {1.5, 1.5}}, 27 | wantWKT: "MULTILINESTRING((0 0,2 0),(1.5 1.5,2 2),(2 0,1.5 1.5))", 28 | }, 29 | { 30 | xys: []XY{{-0.5, 0.5}, {0, 0}, {0, 1}, {1, 0}}, 31 | wantWKT: "MULTILINESTRING((-0.5 0.5,0 0),(0 0,0 1),(0 1,1 0))", 32 | }, 33 | } { 34 | t.Run(strconv.Itoa(i), func(t *testing.T) { 35 | want, err := UnmarshalWKT(tc.wantWKT) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | got := spanningTree(tc.xys) 40 | if !ExactEquals(want, got.AsGeometry(), IgnoreOrder) { 41 | t.Logf("got: %v", got.AsText()) 42 | t.Logf("want: %v", want.AsText()) 43 | t.Fatal("mismatch") 44 | } 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /geom/dcel_interaction_points.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | // findInteractionPoints finds the interaction points (including 4 | // self-interaction points) between a list of geometries. 5 | // 6 | // Assumptions: 7 | // 8 | // 1. Input geometries are correctly noded with respect to each other. 9 | // 10 | // 2. Input geometries don't have any repeated coordinates (e.g. in areal 11 | // rings or linear elements). 12 | func findInteractionPoints(gs []Geometry) map[XY]struct{} { 13 | var sizeHint int 14 | for _, g := range gs { 15 | sizeHint += g.controlPoints() 16 | } 17 | interactions := make(map[XY]struct{}, sizeHint) 18 | 19 | // adjacents tracks the next and previous points relative to a middle point 20 | // for linear elements (i.e. the points adjacent to a middle point). It is 21 | // used to differentiate the cases where linear elements overlap (in which 22 | // case there ISN'T an interaction point) and cases where they are crossing 23 | // over each other (in which case there IS an interaction point). 24 | adjacents := make(map[XY]xyPair, sizeHint) 25 | 26 | for _, g := range gs { 27 | addGeometryInteractions(g, adjacents, interactions) 28 | } 29 | return interactions 30 | } 31 | 32 | // xyPair is a container for a pair of XYs. The semantics of the points aren't 33 | // implied by this type itself (user of this type is to BYO semantics). 34 | type xyPair struct { 35 | first, second XY 36 | } 37 | 38 | func addGeometryInteractions(g Geometry, adjacents map[XY]xyPair, interactions map[XY]struct{}) { 39 | switch g.Type() { 40 | case TypePoint: 41 | addPointInteractions(g.MustAsPoint(), interactions) 42 | case TypeMultiPoint: 43 | addMultiPointInteractions(g.MustAsMultiPoint(), interactions) 44 | case TypeLineString: 45 | addLineStringInteractions(g.MustAsLineString(), adjacents, interactions) 46 | case TypeMultiLineString: 47 | addMultiLineStringInteractions(g.MustAsMultiLineString(), adjacents, interactions) 48 | case TypePolygon: 49 | addMultiLineStringInteractions(g.MustAsPolygon().Boundary(), adjacents, interactions) 50 | case TypeMultiPolygon: 51 | addMultiLineStringInteractions(g.MustAsMultiPolygon().Boundary(), adjacents, interactions) 52 | case TypeGeometryCollection: 53 | addGeometryCollectionInteractions(g.MustAsGeometryCollection(), adjacents, interactions) 54 | default: 55 | panic("unknown geometry: " + g.Type().String()) 56 | } 57 | } 58 | 59 | func addLineStringInteractions(ls LineString, adjacents map[XY]xyPair, interactions map[XY]struct{}) { 60 | if xy, ok := ls.StartPoint().XY(); ok { 61 | interactions[xy] = struct{}{} 62 | } 63 | if xy, ok := ls.EndPoint().XY(); ok { 64 | interactions[xy] = struct{}{} 65 | } 66 | 67 | seq := ls.Coordinates() 68 | n := seq.Length() 69 | for i := 1; i+1 < n; i++ { 70 | prev := seq.GetXY(i - 1) 71 | curr := seq.GetXY(i) 72 | next := seq.GetXY(i + 1) 73 | 74 | if prev == next { 75 | // LineString loops back on itself, so the reversal point is the 76 | // interaction point. 77 | interactions[curr] = struct{}{} 78 | continue 79 | } 80 | 81 | adj := xyPair{prev, next} 82 | if adj.second.Less(adj.first) { 83 | // Canonicalise the pair, since we don't care about directionality. 84 | adj.first, adj.second = adj.second, adj.first 85 | } 86 | 87 | xy := seq.GetXY(i) 88 | existing, ok := adjacents[xy] 89 | if ok && existing != adj { 90 | interactions[xy] = struct{}{} 91 | } 92 | if !ok { 93 | adjacents[xy] = adj 94 | } 95 | } 96 | } 97 | 98 | func addMultiLineStringInteractions(mls MultiLineString, adjacents map[XY]xyPair, interactions map[XY]struct{}) { 99 | for i := 0; i < mls.NumLineStrings(); i++ { 100 | ls := mls.LineStringN(i) 101 | addLineStringInteractions(ls, adjacents, interactions) 102 | } 103 | } 104 | 105 | func addPointInteractions(pt Point, interactions map[XY]struct{}) { 106 | if xy, ok := pt.XY(); ok { 107 | interactions[xy] = struct{}{} 108 | } 109 | } 110 | 111 | func addMultiPointInteractions(mp MultiPoint, interactions map[XY]struct{}) { 112 | n := mp.NumPoints() 113 | for i := 0; i < n; i++ { 114 | xy, ok := mp.PointN(i).XY() 115 | if ok { 116 | interactions[xy] = struct{}{} 117 | } 118 | } 119 | } 120 | 121 | func addGeometryCollectionInteractions(gc GeometryCollection, adjacents map[XY]xyPair, interactions map[XY]struct{}) { 122 | n := gc.NumGeometries() 123 | for i := 0; i < n; i++ { 124 | g := gc.GeometryN(i) 125 | addGeometryInteractions(g, adjacents, interactions) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /geom/dcel_node_set.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "math" 5 | ) 6 | 7 | func newNodeSet(maxULPSize float64, sizeHint int) nodeSet { 8 | // The appropriate multiplication factor to use to calculate bucket size is 9 | // a bit of a guess. 10 | bucketSize := maxULPSize * 0x200 11 | return nodeSet{ 12 | bucketSize, 13 | make(map[nodeBucket]XY, sizeHint), 14 | } 15 | } 16 | 17 | // nodeSet is a set of XY values (nodes). If an XY value is inserted, but it is 18 | // "close" to an existing XY in the set, then the original XY is returned (and 19 | // the new XY _not_ inserted). The two XYs essentially merge together. 20 | type nodeSet struct { 21 | bucketWidth float64 22 | nodes map[nodeBucket]XY 23 | } 24 | 25 | type nodeBucket struct { 26 | x, y int 27 | } 28 | 29 | func (s nodeSet) insertOrGet(xy XY) XY { 30 | b := nodeBucket{ 31 | int(math.Floor(xy.X / s.bucketWidth)), 32 | int(math.Floor(xy.Y / s.bucketWidth)), 33 | } 34 | for _, offset := range [...]nodeBucket{ 35 | b, 36 | {b.x - 1, b.y - 1}, 37 | {b.x - 1, b.y}, 38 | {b.x - 1, b.y + 1}, 39 | {b.x, b.y - 1}, 40 | {b.x, b.y + 1}, 41 | {b.x + 1, b.y - 1}, 42 | {b.x + 1, b.y}, 43 | {b.x + 1, b.y + 1}, 44 | } { 45 | if node, ok := s.nodes[offset]; ok { 46 | return node 47 | } 48 | } 49 | s.nodes[b] = xy 50 | return xy 51 | } 52 | 53 | func (s nodeSet) list() []XY { 54 | xys := make([]XY, 0, len(s.nodes)) 55 | for _, xy := range s.nodes { 56 | xys = append(xys, xy) 57 | } 58 | return xys 59 | } 60 | -------------------------------------------------------------------------------- /geom/dcel_node_set_internal_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | ) 7 | 8 | func TestNodeSetNoCrash(t *testing.T) { 9 | for i, tc := range []struct { 10 | maxULP float64 11 | pts []XY 12 | }{ 13 | // Reproduces a crash. 14 | { 15 | maxULP: 2.220446049250313e-16, 16 | pts: []XY{ 17 | {0, 1}, 18 | {4.440892098500626e-16, 0.9999999999999997}, 19 | {0, 0.9999999999999997}, 20 | }, 21 | }, 22 | } { 23 | t.Run(strconv.Itoa(i), func(*testing.T) { 24 | ns := newNodeSet(tc.maxULP, len(tc.pts)) 25 | for _, pt := range tc.pts { 26 | ns.insertOrGet(pt) 27 | } 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /geom/dcel_re_noding_internal_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | ) 7 | 8 | func TestReNode(t *testing.T) { 9 | for i, tt := range []struct { 10 | inputA, inputB string 11 | outputA, outputB string 12 | }{ 13 | { 14 | inputA: "LINESTRING(0 0,1 1)", 15 | inputB: "LINESTRING(0 1,1 0)", 16 | outputA: "LINESTRING(0 0,0.5 0.5,1 1)", 17 | outputB: "LINESTRING(0 1,0.5 0.5,1 0)", 18 | }, 19 | { 20 | inputA: "LINESTRING(0 0,0.5 0.5)", 21 | inputB: "LINESTRING(0 0,1 1)", 22 | outputA: "LINESTRING(0 0,0.5 0.5)", 23 | outputB: "LINESTRING(0 0,0.5 0.5,1 1)", 24 | }, 25 | { 26 | inputA: "LINESTRING(0 0,0.5 0.5,1 1)", 27 | inputB: "LINESTRING(0 1,0.3333333333 0.6666666667,1 0)", 28 | outputA: "LINESTRING(0 0,0.5 0.5,1 1)", 29 | outputB: "LINESTRING(0 1,0.3333333333 0.6666666667,0.5 0.5,1 0)", 30 | }, 31 | { 32 | inputA: "MULTILINESTRING((0 0,2 2.000000000000001),(1 0,-1 2.000000000000001))", 33 | inputB: "POLYGON((0 1,1 1,0.5 0.5,0 1))", 34 | outputA: "MULTILINESTRING((0 0,0.5 0.5,1 1,2 2.000000000000001),(1 0,0.5 0.5,0 1,-1 2.000000000000001))", 35 | outputB: "POLYGON((0 1,1 1,0.5 0.5,0 1))", 36 | }, 37 | { 38 | inputA: "LINESTRING(0 0,1 1,1 1,2 2)", 39 | inputB: "LINESTRING(0 0,0 0,1 1,2 2)", 40 | outputA: "LINESTRING(0 0,1 1,2 2)", 41 | outputB: "LINESTRING(0 0,1 1,2 2)", 42 | }, 43 | { 44 | inputA: "LINESTRING(0.5 1,0.5000000000000001 0.5)", 45 | inputB: "LINESTRING(0.5 0,0.5 0.5)", 46 | outputA: "LINESTRING(0.5 1,0.5000000000000001 0.5)", 47 | outputB: "LINESTRING(0.5 0,0.5000000000000001 0.5)", 48 | }, 49 | } { 50 | t.Run(strconv.Itoa(i), func(t *testing.T) { 51 | inA, err := UnmarshalWKT(tt.inputA) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | inB, err := UnmarshalWKT(tt.inputB) 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | wantA, err := UnmarshalWKT(tt.outputA) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | wantB, err := UnmarshalWKT(tt.outputB) 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | gotA, gotB, _ := reNodeGeometries(inA, inB, MultiLineString{}) 68 | if !ExactEquals(gotA, wantA) || !ExactEquals(gotB, wantB) { 69 | t.Logf("INPUT A: %v\n", inA.AsText()) 70 | t.Logf("INPUT B: %v\n", inB.AsText()) 71 | t.Logf("WANT A: %v\n", wantA.AsText()) 72 | t.Logf("WANT B: %v\n", wantB.AsText()) 73 | t.Logf("GOT A: %v\n", gotA.AsText()) 74 | t.Logf("GOT B: %v\n", gotB.AsText()) 75 | t.Error("mismatch") 76 | } 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /geom/de9im.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // imLocation is a location relative to a geometry. A location can be in the 9 | // interior, boundary, or exterior of a geometry. 10 | type imLocation uint32 11 | 12 | const ( 13 | imInterior imLocation = 0 14 | imBoundary imLocation = 1 15 | imExterior imLocation = 2 16 | ) 17 | 18 | type matrix [9]byte 19 | 20 | func newMatrix() matrix { 21 | return [9]byte{'F', 'F', 'F', 'F', 'F', 'F', 'F', 'F', 'F'} 22 | } 23 | 24 | func (m *matrix) set(locA, locB imLocation, entry byte) { 25 | idx := m.index(locA, locB) 26 | m[idx] = entry 27 | } 28 | 29 | func (m *matrix) get(locA, locB imLocation) byte { 30 | idx := m.index(locA, locB) 31 | return m[idx] 32 | } 33 | 34 | func (m *matrix) code() string { 35 | return string(m[:]) 36 | } 37 | 38 | func (matrix) index(locA, locB imLocation) int { 39 | return int(3*locA + locB) 40 | } 41 | 42 | func (m *matrix) transpose() { 43 | cp := *m 44 | for _, locA := range []imLocation{imInterior, imBoundary, imExterior} { 45 | for _, locB := range []imLocation{imInterior, imBoundary, imExterior} { 46 | m.set(locB, locA, cp.get(locA, locB)) 47 | } 48 | } 49 | } 50 | 51 | // RelateMatches checks to see if an intersection matrix matches against an 52 | // intersection matrix pattern. Each is a 9 character string that encodes a 3 53 | // by 3 matrix. 54 | // 55 | // The intersection matrix has the same format as those computed by the Relate 56 | // function. That is, it must be a 9 character string consisting of 'F', '0', 57 | // '1', and '2' entries. 58 | // 59 | // The intersection matrix pattern is also 9 characters, and consists of 'F', 60 | // '0', '1', '2', 'T', and '*' entries. 61 | // 62 | // An intersection matrix matches against an intersection matrix pattern if 63 | // each entry in the intersection matrix matches against the corresponding 64 | // entry in the intersection matrix pattern. An 'F' entry matches against an 65 | // 'F' or '*' pattern. A '0' entry matches against '0', 'T', or '*'. A '1' 66 | // entry matches against '1', 'T', or '*'. A '2' entry matches against '2', 67 | // 'T', or '*'. 68 | func RelateMatches(intersectionMatrix, intersectionMatrixPattern string) (bool, error) { 69 | mat := intersectionMatrix 70 | pat := intersectionMatrixPattern 71 | if len(mat) != 9 { 72 | return false, errors.New("invalid matrix: length is not 9") 73 | } 74 | if len(pat) != 9 { 75 | return false, errors.New("invalid matrix pattern: length is not 9") 76 | } 77 | 78 | for i, m := range mat { 79 | p := pat[i] 80 | switch p { 81 | case 'F', '0', '1', '2', 'T', '*': 82 | default: 83 | return false, fmt.Errorf("invalid character in intersection pattern: %c", p) 84 | } 85 | 86 | switch m { 87 | case 'F': 88 | if p != 'F' && p != '*' { 89 | return false, nil 90 | } 91 | case '0': 92 | if p != '0' && p != 'T' && p != '*' { 93 | return false, nil 94 | } 95 | case '1': 96 | if p != '1' && p != 'T' && p != '*' { 97 | return false, nil 98 | } 99 | case '2': 100 | if p != '2' && p != 'T' && p != '*' { 101 | return false, nil 102 | } 103 | default: 104 | return false, fmt.Errorf("invalid character in intersection matrix: %c", m) 105 | } 106 | } 107 | return true, nil 108 | } 109 | -------------------------------------------------------------------------------- /geom/de9im_test.go: -------------------------------------------------------------------------------- 1 | package geom_test 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/peterstace/simplefeatures/geom" 8 | ) 9 | 10 | func TestRelateMatch(t *testing.T) { 11 | for i, tc := range []struct { 12 | mat string 13 | pat string 14 | want bool 15 | }{ 16 | {"FFFFFFFFF", "FFFFFFFFF", true}, 17 | {"FFFFFFFFF", "000000000", false}, 18 | {"FFFFFFFFF", "111111111", false}, 19 | {"FFFFFFFFF", "222222222", false}, 20 | {"FFFFFFFFF", "TTTTTTTTT", false}, 21 | {"FFFFFFFFF", "*********", true}, 22 | 23 | {"000000000", "FFFFFFFFF", false}, 24 | {"000000000", "000000000", true}, 25 | {"000000000", "111111111", false}, 26 | {"000000000", "222222222", false}, 27 | {"000000000", "TTTTTTTTT", true}, 28 | {"000000000", "*********", true}, 29 | 30 | {"111111111", "FFFFFFFFF", false}, 31 | {"111111111", "000000000", false}, 32 | {"111111111", "111111111", true}, 33 | {"111111111", "222222222", false}, 34 | {"111111111", "TTTTTTTTT", true}, 35 | {"111111111", "*********", true}, 36 | 37 | {"222222222", "FFFFFFFFF", false}, 38 | {"222222222", "000000000", false}, 39 | {"222222222", "111111111", false}, 40 | {"222222222", "222222222", true}, 41 | {"222222222", "TTTTTTTTT", true}, 42 | {"222222222", "*********", true}, 43 | 44 | {"F012F012F", "*********", true}, 45 | {"F012F012F", "F*1**T*2*", true}, 46 | {"F012F012F", "F*11*T*2*", false}, 47 | } { 48 | t.Run(strconv.Itoa(i), func(t *testing.T) { 49 | got, err := geom.RelateMatches(tc.mat, tc.pat) 50 | if err != nil { 51 | t.Error(err) 52 | } 53 | if got != tc.want { 54 | t.Logf("matrix: %v", tc.mat) 55 | t.Logf("pattern: %v", tc.pat) 56 | t.Errorf("want=%t got=%t", tc.want, got) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func TestRelateMatchError(t *testing.T) { 63 | for i, tc := range []struct { 64 | mat string 65 | pat string 66 | }{ 67 | {"FFFFFFFF", "FFFFFFFFF"}, 68 | {"FFFFFFFFF", "FFFFFFFF"}, 69 | {"FFFFXFFFF", "FFFFFFFFF"}, 70 | {"FFFFFFFFF", "FFFFXFFFF"}, 71 | } { 72 | t.Run(strconv.Itoa(i), func(t *testing.T) { 73 | _, err := geom.RelateMatches(tc.mat, tc.pat) 74 | t.Log(err) 75 | if err == nil { 76 | t.Error("expected error but got nil") 77 | } 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /geom/doc.go: -------------------------------------------------------------------------------- 1 | // Package geom implements the OpenGIS Simple Feature Access specification. The 2 | // specification describes an access and storage model for 2-dimensional 3 | // geometries. 4 | // 5 | // The package serves three primary purposes: 6 | // 7 | // 1. Access: It provides a type for each of the different geometry types 8 | // described by the standard: Point, MultiPoint, LineString, MultiLineString, 9 | // Polygon, MultiPolygon, and GeometryCollection. It also contains supporting 10 | // types such as Geometry, Envelope, Sequence, and XY. Methods on these types 11 | // allow access the internal parts of each geometry. For example, there is a 12 | // method that will obtain the first Point in a LineString. 13 | // 14 | // 2. Analysis: There are methods on the types that perform spatial 15 | // analysis on the geometries. For example, to check if a geometry is simple or 16 | // to calculate its smallest bounding box. 17 | // 18 | // 3. Storage: The types implement various methods that allow conversion to 19 | // and from various storage and encoding formats. WKT (Well Known Text), WKB 20 | // (Well Known Binary), and GeoJSON are supported. 21 | package geom 22 | -------------------------------------------------------------------------------- /geom/errors.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import "fmt" 4 | 5 | func wrap(err error, format string, args ...interface{}) error { 6 | if err == nil { 7 | return nil 8 | } 9 | return fmt.Errorf(format+": %w", append(args, err)...) 10 | } 11 | 12 | // wrapSimplified wraps errors to indicate that they occurred as a result of no 13 | // longer being valid after simplification. 14 | func wrapSimplified(err error) error { 15 | return wrap(err, "simplified geometry") 16 | } 17 | 18 | // wkbSyntaxError is an error used to indicate that a serialised WKB geometry 19 | // cannot be unmarshalled because some aspect of it's syntax is invalid. 20 | type wkbSyntaxError struct { 21 | // reason should describe the invalid syntax (as opposed to describing the 22 | // syntax rule that was broken). 23 | reason string 24 | } 25 | 26 | func (e wkbSyntaxError) Error() string { 27 | return "invalid WKB syntax: " + e.reason 28 | } 29 | 30 | // wktSyntaxError is an error used to indicate that a serialised WKT geometry 31 | // cannot be unmarshalled because some aspect of it's syntax is invalid. 32 | type wktSyntaxError struct { 33 | // reason should describe the invalid syntax (as opposed to describing the 34 | // syntax rule that was broken). 35 | reason string 36 | } 37 | 38 | func (e wktSyntaxError) Error() string { 39 | return "invalid WKT syntax: " + e.reason 40 | } 41 | 42 | // geojsonSyntaxError is an error used to indicate that a serialised GeoJSON geometry 43 | // cannot be unmarshalled because some aspect of it's syntax is invalid. 44 | type geojsonSyntaxError struct { 45 | // reason should describe the invalid syntax (as opposed to describing the 46 | // syntax rule that was broken). 47 | reason string 48 | } 49 | 50 | func (e geojsonSyntaxError) Error() string { 51 | return "invalid GeoJSON syntax: " + e.reason 52 | } 53 | 54 | func wrapWithGeoJSONSyntaxError(err error) error { 55 | if err == nil { 56 | return nil 57 | } 58 | return geojsonSyntaxError{err.Error()} 59 | } 60 | 61 | type unmarshalGeoJSONSourceDestinationMismatchError struct { 62 | SourceType GeometryType 63 | DestinationType GeometryType 64 | } 65 | 66 | func (e unmarshalGeoJSONSourceDestinationMismatchError) Error() string { 67 | return fmt.Sprintf( 68 | "cannot unmarshal GeoJSON of type %s into %s", 69 | e.SourceType, e.DestinationType, 70 | ) 71 | } 72 | 73 | type mismatchedGeometryCollectionDimsError struct { 74 | CT1, CT2 CoordinatesType 75 | } 76 | 77 | func (e mismatchedGeometryCollectionDimsError) Error() string { 78 | return fmt.Sprintf("mixed dimensions in geometry collection: %s and %s", e.CT1, e.CT2) 79 | } 80 | 81 | type ruleViolation string 82 | 83 | const ( 84 | violateInf ruleViolation = "Inf not allowed" 85 | violateNaN ruleViolation = "NaN not allowed" 86 | violateTwoPoints ruleViolation = "non-empty LineString contains only one distinct XY value" 87 | violateRingEmpty ruleViolation = "polygon ring empty" 88 | violateRingClosed ruleViolation = "polygon ring not closed" 89 | violateRingSimple ruleViolation = "polygon ring not simple" 90 | violateRingNested ruleViolation = "polygon has nested rings" 91 | violateInteriorInExterior ruleViolation = "polygon interior ring outside of exterior ring" 92 | violateInteriorConnected ruleViolation = "polygon has disconnected interior" 93 | violateRingsMultiTouch ruleViolation = "polygon rings intersect at multiple points" 94 | violatePolysMultiTouch ruleViolation = "multipolygon child polygons touch at multiple points" 95 | ) 96 | 97 | func (v ruleViolation) errAtXY(location XY) error { 98 | return validationError{ 99 | RuleViolation: v, 100 | HasLocation: true, 101 | Location: location, 102 | } 103 | } 104 | 105 | func (v ruleViolation) errAtPt(location Point) error { 106 | xy, ok := location.XY() 107 | return validationError{ 108 | RuleViolation: v, 109 | HasLocation: ok, 110 | Location: xy, 111 | } 112 | } 113 | 114 | func (v ruleViolation) err() error { 115 | return validationError{RuleViolation: v} 116 | } 117 | 118 | type validationError struct { 119 | RuleViolation ruleViolation 120 | HasLocation bool 121 | Location XY 122 | } 123 | 124 | func (e validationError) Error() string { 125 | if e.HasLocation { 126 | return fmt.Sprintf("%s (at or near %v)", e.RuleViolation, e.Location) 127 | } 128 | return string(e.RuleViolation) 129 | } 130 | -------------------------------------------------------------------------------- /geom/export_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | // The following types are exported for testing purposes only. 4 | // 5 | // Because this file ends in `_test.go`, it is not included in non-test builds. 6 | // But because it's in the `geom` package, it's able to access unexported types 7 | // from other files in the `geom` package. 8 | 9 | type MismatchedGeometryCollectionDimsError = mismatchedGeometryCollectionDimsError 10 | 11 | type UnmarshalGeoJSONSourceDestinationMismatchError = unmarshalGeoJSONSourceDestinationMismatchError 12 | 13 | type ( 14 | ValidationError = validationError 15 | RuleViolation = ruleViolation 16 | ) 17 | 18 | const ( 19 | ViolateInf = violateInf 20 | ViolateNaN = violateNaN 21 | ViolateTwoPoints = violateTwoPoints 22 | ViolateRingEmpty = violateRingEmpty 23 | ViolateRingClosed = violateRingClosed 24 | ViolateRingSimple = violateRingSimple 25 | ViolateRingNested = violateRingNested 26 | ViolateInteriorInExterior = violateInteriorInExterior 27 | ViolateInteriorConnected = violateInteriorConnected 28 | ViolateRingsMultiTouch = violateRingsMultiTouch 29 | ViolatePolysMultiTouch = violatePolysMultiTouch 30 | ) 31 | -------------------------------------------------------------------------------- /geom/float_helpers.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "math" 5 | "strconv" 6 | ) 7 | 8 | // appendFloat appends the decimal representation of f to dst and returns it. 9 | func appendFloat(dst []byte, f float64) []byte { 10 | return strconv.AppendFloat(dst, f, 'f', -1, 64) 11 | } 12 | 13 | // ulpSize returns the distance from f to the float64 after f. 14 | func ulpSize(f float64) float64 { 15 | u := math.Float64bits(f) + 1 16 | next := math.Float64frombits(u) 17 | return next - f 18 | } 19 | -------------------------------------------------------------------------------- /geom/geojson_marshal.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | func appendGeoJSONCoordinate(dst []byte, coords Coordinates) []byte { 4 | dst = append(dst, '[') 5 | dst = appendFloat(dst, coords.X) 6 | dst = append(dst, ',') 7 | dst = appendFloat(dst, coords.Y) 8 | if coords.Type.Is3D() { 9 | dst = append(dst, ',') 10 | dst = appendFloat(dst, coords.Z) 11 | } 12 | // GeoJSON explicitly prohibits including M values. 13 | return append(dst, ']') 14 | } 15 | 16 | func appendGeoJSONSequence(dst []byte, seq Sequence) []byte { 17 | dst = append(dst, '[') 18 | n := seq.Length() 19 | for i := 0; i < n; i++ { 20 | if i > 0 { 21 | dst = append(dst, ',') 22 | } 23 | dst = appendGeoJSONCoordinate(dst, seq.Get(i)) 24 | } 25 | dst = append(dst, ']') 26 | return dst 27 | } 28 | 29 | func appendGeoJSONSequences(dst []byte, seqs []Sequence) []byte { 30 | dst = append(dst, '[') 31 | for i, seq := range seqs { 32 | if i > 0 { 33 | dst = append(dst, ',') 34 | } 35 | dst = appendGeoJSONSequence(dst, seq) 36 | } 37 | dst = append(dst, ']') 38 | return dst 39 | } 40 | 41 | func appendGeoJSONSequenceMatrix(dst []byte, matrix [][]Sequence) []byte { 42 | dst = append(dst, '[') 43 | for i, seqs := range matrix { 44 | if i > 0 { 45 | dst = append(dst, ',') 46 | } 47 | dst = appendGeoJSONSequences(dst, seqs) 48 | } 49 | dst = append(dst, ']') 50 | return dst 51 | } 52 | -------------------------------------------------------------------------------- /geom/graph.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | // graph is an adjacency list representing an undirected simple graph. 4 | type graph map[int]map[int]struct{} 5 | 6 | func newGraph() graph { 7 | return make(map[int]map[int]struct{}) 8 | } 9 | 10 | // addEdge adds an edge between two different. If u and v are not distinct or 11 | // if either are negative negative, then addEdge will panic. 12 | func (g graph) addEdge(u, v int) { 13 | if u == v { 14 | panic("u and v must be distinct") 15 | } 16 | if u < 0 || v < 0 { 17 | panic("u and v must be non-negative") 18 | } 19 | if g[u] == nil { 20 | g[u] = make(map[int]struct{}) 21 | } 22 | if g[v] == nil { 23 | g[v] = make(map[int]struct{}) 24 | } 25 | g[u][v] = struct{}{} 26 | g[v][u] = struct{}{} 27 | } 28 | 29 | func (g graph) hasCycle() bool { 30 | unvisited := make(map[int]struct{}) 31 | for v := range g { 32 | unvisited[v] = struct{}{} 33 | } 34 | for v := range unvisited { 35 | visited := make(map[int]struct{}) 36 | if g.dfsHasCycle(-1, v, visited, unvisited) { 37 | return true 38 | } 39 | } 40 | return false 41 | } 42 | 43 | func (g graph) dfsHasCycle(parent, v int, visited, unvisited map[int]struct{}) bool { 44 | visited[v] = struct{}{} 45 | delete(unvisited, v) 46 | for neighbour := range g[v] { 47 | if neighbour == parent { 48 | continue 49 | } 50 | if _, have := visited[neighbour]; have { 51 | return true 52 | } 53 | if g.dfsHasCycle(v, neighbour, visited, unvisited) { 54 | return true 55 | } 56 | } 57 | delete(visited, v) 58 | return false 59 | } 60 | -------------------------------------------------------------------------------- /geom/graph_internal_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | ) 7 | 8 | func TestGraphHasCycle(t *testing.T) { 9 | type edge struct { 10 | a, b int 11 | } 12 | for i, tt := range []struct { 13 | edges []edge 14 | hasCycle bool 15 | }{ 16 | // 0 edges 17 | {nil, false}, 18 | 19 | // 1 edge 20 | {[]edge{{1, 2}}, false}, 21 | 22 | // 2 edges 23 | {[]edge{{1, 2}, {2, 3}}, false}, 24 | {[]edge{{1, 2}, {3, 4}}, false}, 25 | 26 | // 3 edges 27 | {[]edge{{1, 2}, {2, 3}, {3, 4}}, false}, 28 | {[]edge{{1, 2}, {2, 3}, {4, 5}}, false}, 29 | {[]edge{{1, 2}, {3, 4}, {5, 6}}, false}, 30 | {[]edge{{1, 2}, {2, 3}, {3, 1}}, true}, 31 | 32 | // 4 edeges 33 | {[]edge{{1, 2}, {2, 3}, {3, 4}, {4, 5}}, false}, 34 | {[]edge{{1, 2}, {2, 3}, {3, 4}, {4, 2}}, true}, 35 | {[]edge{{1, 2}, {2, 3}, {3, 4}, {4, 1}}, true}, 36 | {[]edge{{1, 2}, {2, 3}, {3, 1}, {4, 5}}, true}, 37 | {[]edge{{1, 2}, {2, 3}, {4, 5}, {6, 7}}, false}, 38 | {[]edge{{1, 2}, {2, 3}, {4, 5}, {5, 6}}, false}, 39 | } { 40 | t.Run(strconv.Itoa(i), func(t *testing.T) { 41 | graph := newGraph() 42 | for _, e := range tt.edges { 43 | graph.addEdge(e.a, e.b) 44 | } 45 | got := graph.hasCycle() 46 | if got != tt.hasCycle { 47 | t.Log(graph) 48 | t.Errorf("got=%v want=%v", got, tt.hasCycle) 49 | } 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /geom/interval.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | // Interval represents the interval bound by two float64 values. The interval 4 | // is closed, i.e. its endpoints are included. An interval typically has 5 | // distinct endpoints (i.e. is non-degenerate). It may also be degenerate and 6 | // contain no elements, or degenerate and contain a single element (i.e. the 7 | // min and max bounds are the same). The zero value of Interval is the 8 | // degenerate interval that contains no elements. 9 | type Interval struct { 10 | min, max float64 11 | nonEmpty bool 12 | } 13 | 14 | // NewInterval returns a new non-empty Interval with the given bounds (which 15 | // may be the same). 16 | func NewInterval(boundA, boundB float64) Interval { 17 | if boundB < boundA { 18 | boundA, boundB = boundB, boundA 19 | } 20 | return Interval{boundA, boundB, true} 21 | } 22 | 23 | // MinMax returns the minimum and maximum bounds of the interval. The boolean 24 | // return value indicates if the interval is non-empty (the minimum and maximum 25 | // bounds should be ignored if false). 26 | func (i Interval) MinMax() (float64, float64, bool) { 27 | return i.min, i.max, i.nonEmpty 28 | } 29 | -------------------------------------------------------------------------------- /geom/no_validate.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | // NoValidate causes functions to skip geometry constraint validation. 4 | // Functions where validation can be skipped accept a variadic list of 5 | // NoValidate values. If at least one NoValidate value is passed in, then the 6 | // function will skip validation, otherwise it will perform validation as its 7 | // default behaviour. 8 | // 9 | // NoValidate is just an empty struct type, so can be passed in as 10 | // NoValidate{}. 11 | // 12 | // Some algorithms implemented in simplefeatures rely on valid geometries to 13 | // operate correctly. If invalid geometries are supplied, then the results may 14 | // not be correct. 15 | type NoValidate struct{} 16 | -------------------------------------------------------------------------------- /geom/perf_internal_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | ) 7 | 8 | var dummyEnv Envelope 9 | 10 | func BenchmarkLineEnvelope(b *testing.B) { 11 | for i, ln := range []line{ 12 | {XY{0, 0}, XY{1, 1}}, 13 | {XY{1, 1}, XY{0, 0}}, 14 | {XY{0, 1}, XY{1, 0}}, 15 | {XY{1, 0}, XY{0, 1}}, 16 | } { 17 | b.Run(strconv.Itoa(i), func(b *testing.B) { 18 | for i := 0; i < b.N; i++ { 19 | dummyEnv = ln.uncheckedEnvelope() 20 | } 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /geom/rtree.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import "github.com/peterstace/simplefeatures/rtree" 4 | 5 | // indexedLines is a simple container to hold a list of lines, and a r-tree 6 | // structure indexing those lines. The record IDs in the rtree correspond to 7 | // the indices of the lines slice. 8 | type indexedLines struct { 9 | lines []line 10 | tree *rtree.RTree 11 | } 12 | 13 | func newIndexedLines(lines []line) indexedLines { 14 | bulk := make([]rtree.BulkItem, len(lines)) 15 | for i, ln := range lines { 16 | bulk[i] = rtree.BulkItem{ 17 | Box: ln.box(), 18 | RecordID: i, 19 | } 20 | } 21 | return indexedLines{lines, rtree.BulkLoad(bulk)} 22 | } 23 | 24 | // indexedPoints is a simple container to hold a list of points, and a r-tree 25 | // structure indexing those points. The record IDs in the rtree correspond to 26 | // the indices of the points slice. 27 | type indexedPoints struct { 28 | points []XY 29 | tree *rtree.RTree 30 | } 31 | 32 | func newIndexedPoints(points []XY) indexedPoints { 33 | bulk := make([]rtree.BulkItem, len(points)) 34 | for i, pt := range points { 35 | bulk[i] = rtree.BulkItem{ 36 | Box: rtree.Box{MinX: pt.X, MaxX: pt.X, MinY: pt.Y, MaxY: pt.Y}, 37 | RecordID: i, 38 | } 39 | } 40 | return indexedPoints{points, rtree.BulkLoad(bulk)} 41 | } 42 | -------------------------------------------------------------------------------- /geom/snap_to_grid.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import "math" 4 | 5 | func snapToGridXY(dp int) func(XY) XY { 6 | return func(pt XY) XY { 7 | return XY{ 8 | snapToGridFloat64(pt.X, dp), 9 | snapToGridFloat64(pt.Y, dp), 10 | } 11 | } 12 | } 13 | 14 | // NOTE: This function would naively be implemented as: 15 | // 16 | // return math.Round(f * math.Pow10(dp)) / math.Pow10(dp) 17 | // 18 | // However, this approach would causes two problems (which are fixed by having 19 | // a slightly more complex implementation): 20 | // 21 | // 1. In floating point math, numbers of the form 10^dp can be represented 22 | // exactly for values of dp such that 0 <= dp <= 15 (i.e. those less than 23 | // 2^53). Numbers of the form 10^dp can never be represented exactly for 24 | // negative values of dp (since the fractional part is recurring in base 2). To 25 | // remedy this, the function is split into "positive", "negative", and "zero" 26 | // dp cases. 27 | // 28 | // 2. For large values of dp, the input could overflow or underflow after being 29 | // multiplied by the scale factor. This causes the wrong result when the scale 30 | // factor is multiplied or divided out after rounding. This can be remedied by 31 | // detecting this case and returning the input unaltered (for an overflow) or 32 | // as zero (for an underflow). 33 | func snapToGridFloat64(f float64, dp int) float64 { 34 | switch { 35 | case dp > 0: 36 | scale := math.Pow10(dp) 37 | scaled := f * scale 38 | if scaled > math.MaxFloat64 { 39 | return f 40 | } 41 | return math.Round(scaled) / scale 42 | case dp < 0: 43 | scale := math.Pow10(-dp) 44 | scaled := f / scale 45 | if scaled == 0 { 46 | return 0 47 | } 48 | return math.Round(scaled) * scale 49 | default: 50 | return math.Round(f) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /geom/snap_to_grid_test.go: -------------------------------------------------------------------------------- 1 | package geom_test 2 | 3 | import ( 4 | "math" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/peterstace/simplefeatures/geom" 9 | ) 10 | 11 | func TestSnapPiFloat64ToGrid(t *testing.T) { 12 | for _, tc := range []struct { 13 | dp int 14 | want float64 15 | }{ 16 | {-999, 0}, 17 | {-99, 0}, 18 | {-9, 0}, 19 | {-2, 0}, 20 | {-1, 0}, 21 | {0, 3}, 22 | {1, 3.1}, 23 | {2, 3.14}, 24 | {3, 3.142}, 25 | {4, 3.1416}, 26 | {5, 3.14159}, 27 | {6, 3.141593}, 28 | {7, 3.1415927}, 29 | {8, 3.14159265}, 30 | {9, 3.141592654}, 31 | {10, 3.1415926536}, 32 | {11, 3.14159265359}, 33 | {12, 3.141592653590}, 34 | {13, 3.1415926535898}, 35 | {14, 3.14159265358979}, 36 | {15, 3.141592653589793}, 37 | {16, 3.141592653589793}, 38 | {17, 3.141592653589793}, 39 | {99, 3.141592653589793}, 40 | {999, 3.141592653589793}, 41 | } { 42 | t.Run(strconv.Itoa(tc.dp), func(t *testing.T) { 43 | pt := geom.XY{0, math.Pi}.AsPoint() 44 | pt = pt.SnapToGrid(tc.dp) 45 | xy, ok := pt.XY() 46 | expectTrue(t, ok) 47 | expectFloat64Eq(t, xy.Y, tc.want) 48 | }) 49 | } 50 | } 51 | 52 | func TestSnapToGrid(t *testing.T) { 53 | for i, tc := range []struct { 54 | input string 55 | output string 56 | }{ 57 | {"GEOMETRYCOLLECTION EMPTY", "GEOMETRYCOLLECTION EMPTY"}, 58 | {"POINT EMPTY", "POINT EMPTY"}, 59 | {"LINESTRING EMPTY", "LINESTRING EMPTY"}, 60 | {"POLYGON EMPTY", "POLYGON EMPTY"}, 61 | {"MULTIPOINT EMPTY", "MULTIPOINT EMPTY"}, 62 | {"MULTILINESTRING EMPTY", "MULTILINESTRING EMPTY"}, 63 | {"MULTIPOLYGON EMPTY", "MULTIPOLYGON EMPTY"}, 64 | 65 | { 66 | "GEOMETRYCOLLECTION(POINT(1.11 2.22))", 67 | "GEOMETRYCOLLECTION(POINT(1.1 2.2))", 68 | }, 69 | { 70 | "POINT(1.11 2.22)", 71 | "POINT(1.1 2.2)", 72 | }, 73 | { 74 | "LINESTRING(1.11 2.22,3.33 4.44)", 75 | "LINESTRING(1.1 2.2,3.3 4.4)", 76 | }, 77 | { 78 | "POLYGON((0.00 0.00,0.00 1.11,1.11 0.00,0.00 0.00))", 79 | "POLYGON((0.0 0.0,0.0 1.1,1.1 0.0,0.0 0.0))", 80 | }, 81 | { 82 | "MULTIPOINT(1.11 2.22,3.33 4.44)", 83 | "MULTIPOINT(1.1 2.2,3.3 4.4)", 84 | }, 85 | { 86 | "MULTILINESTRING((1.11 2.22,3.33 4.44),(5.55 6.66,7.77 8.88))", 87 | "MULTILINESTRING((1.1 2.2,3.3 4.4),(5.6 6.7,7.8 8.9))", 88 | }, 89 | { 90 | "MULTIPOLYGON(((0.00 0.00,0.00 1.11,1.11 0.00,0.00 0.00)),((2.22 3.33,2.22 4.44,3.33 3.33,2.22 3.33)))", 91 | "MULTIPOLYGON(((0.0 0.0,0.0 1.1,1.1 0.0,0.0 0.0)),((2.2 3.3,2.2 4.4,3.3 3.3,2.2 3.3)))", 92 | }, 93 | } { 94 | t.Run(strconv.Itoa(i), func(t *testing.T) { 95 | in := geomFromWKT(t, tc.input) 96 | got := in.SnapToGrid(1) 97 | expectGeomEqWKT(t, got, tc.output) 98 | }) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /geom/sort_and_uniquify_internal_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "reflect" 5 | "strconv" 6 | "testing" 7 | ) 8 | 9 | func TestSortAndUniquify(t *testing.T) { 10 | for i, tt := range []struct { 11 | input []XY 12 | output []XY 13 | }{ 14 | { 15 | []XY{}, 16 | []XY{}, 17 | }, 18 | { 19 | []XY{{1, 2}}, 20 | []XY{{1, 2}}, 21 | }, 22 | { 23 | []XY{{1, 2}, {1, 2}}, 24 | []XY{{1, 2}}, 25 | }, 26 | { 27 | []XY{{1, 0}, {2, 0}}, 28 | []XY{{1, 0}, {2, 0}}, 29 | }, 30 | { 31 | []XY{{2, 0}, {1, 0}}, 32 | []XY{{1, 0}, {2, 0}}, 33 | }, 34 | { 35 | []XY{{1, 0}, {1, 0}, {1, 0}}, 36 | []XY{{1, 0}}, 37 | }, 38 | { 39 | []XY{{1, 0}, {1, 0}, {2, 0}}, 40 | []XY{{1, 0}, {2, 0}}, 41 | }, 42 | { 43 | []XY{{1, 0}, {2, 0}, {1, 0}}, 44 | []XY{{1, 0}, {2, 0}}, 45 | }, 46 | { 47 | []XY{{2, 0}, {1, 0}, {1, 0}}, 48 | []XY{{1, 0}, {2, 0}}, 49 | }, 50 | { 51 | []XY{{2, 0}, {2, 0}, {1, 0}}, 52 | []XY{{1, 0}, {2, 0}}, 53 | }, 54 | { 55 | []XY{{2, 0}, {1, 0}, {2, 0}}, 56 | []XY{{1, 0}, {2, 0}}, 57 | }, 58 | { 59 | []XY{{1, 0}, {2, 0}, {2, 0}}, 60 | []XY{{1, 0}, {2, 0}}, 61 | }, 62 | { 63 | []XY{{1, 0}, {2, 0}, {3, 0}}, 64 | []XY{{1, 0}, {2, 0}, {3, 0}}, 65 | }, 66 | { 67 | []XY{{1, 0}, {3, 0}, {2, 0}}, 68 | []XY{{1, 0}, {2, 0}, {3, 0}}, 69 | }, 70 | { 71 | []XY{{2, 0}, {1, 0}, {3, 0}}, 72 | []XY{{1, 0}, {2, 0}, {3, 0}}, 73 | }, 74 | { 75 | []XY{{2, 0}, {3, 0}, {1, 0}}, 76 | []XY{{1, 0}, {2, 0}, {3, 0}}, 77 | }, 78 | { 79 | []XY{{3, 0}, {1, 0}, {2, 0}}, 80 | []XY{{1, 0}, {2, 0}, {3, 0}}, 81 | }, 82 | { 83 | []XY{{3, 0}, {2, 0}, {1, 0}}, 84 | []XY{{1, 0}, {2, 0}, {3, 0}}, 85 | }, 86 | } { 87 | t.Run(strconv.Itoa(i), func(t *testing.T) { 88 | got := sortAndUniquifyXYs(tt.input) 89 | if !reflect.DeepEqual(got, tt.output) { 90 | t.Logf("got: %v", got) 91 | t.Logf("want: %v", tt.output) 92 | t.Error("mismatch") 93 | } 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /geom/sql_test.go: -------------------------------------------------------------------------------- 1 | package geom_test 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/peterstace/simplefeatures/geom" 8 | ) 9 | 10 | func TestSQLValueGeometry(t *testing.T) { 11 | g := geomFromWKT(t, "POINT(1 2)") 12 | val, err := g.Value() 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | geom, err := geom.UnmarshalWKB(val.([]byte)) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | expectGeomEq(t, g, geom) 21 | } 22 | 23 | func TestSQLScanGeometry(t *testing.T) { 24 | const wkt = "POINT(2 3)" 25 | wkb := geomFromWKT(t, wkt).AsBinary() 26 | var g geom.Geometry 27 | check := func(t *testing.T, err error) { 28 | t.Helper() 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | expectGeomEq(t, g, geomFromWKT(t, wkt)) 33 | } 34 | t.Run("string", func(t *testing.T) { 35 | g = geom.Geometry{} 36 | check(t, g.Scan(string(wkb))) 37 | }) 38 | t.Run("byte", func(t *testing.T) { 39 | g = geom.Geometry{} 40 | check(t, g.Scan(wkb)) 41 | }) 42 | } 43 | 44 | func TestSQLValueConcrete(t *testing.T) { 45 | for i, wkt := range []string{ 46 | "POINT EMPTY", 47 | "POINT(1 2)", 48 | "LINESTRING(1 2,3 4)", 49 | "LINESTRING(1 2,3 4,5 6)", 50 | "POLYGON((0 0,1 0,0 1,0 0))", 51 | "MULTIPOINT((1 2))", 52 | "MULTILINESTRING((1 2,3 4,5 6))", 53 | "MULTIPOLYGON(((0 0,1 0,0 1,0 0)))", 54 | "GEOMETRYCOLLECTION(POINT(1 2))", 55 | "GEOMETRYCOLLECTION EMPTY", 56 | } { 57 | t.Run(strconv.Itoa(i), func(t *testing.T) { 58 | t.Log(wkt) 59 | in := geomFromWKT(t, wkt) 60 | val, err := in.Value() 61 | expectNoErr(t, err) 62 | out, err := geom.UnmarshalWKB(val.([]byte)) 63 | expectNoErr(t, err) 64 | expectGeomEq(t, out, in) 65 | }) 66 | } 67 | } 68 | 69 | func TestSQLScanConcrete(t *testing.T) { 70 | for i, tc := range []struct { 71 | wkt string 72 | concrete interface { 73 | AsText() string 74 | Scan(interface{}) error 75 | } 76 | }{ 77 | {"POINT(0 1)", new(geom.Point)}, 78 | {"MULTIPOINT((0 1))", new(geom.MultiPoint)}, 79 | {"LINESTRING(0 1,1 0)", new(geom.LineString)}, 80 | {"MULTILINESTRING((0 1,1 0))", new(geom.MultiLineString)}, 81 | {"POLYGON((0 0,1 0,0 1,0 0))", new(geom.Polygon)}, 82 | {"MULTIPOLYGON(((0 0,1 0,0 1,0 0)))", new(geom.MultiPolygon)}, 83 | {"GEOMETRYCOLLECTION(MULTIPOLYGON(((0 0,1 0,0 1,0 0))))", new(geom.GeometryCollection)}, 84 | } { 85 | t.Run(strconv.Itoa(i), func(t *testing.T) { 86 | wkb := geomFromWKT(t, tc.wkt).AsBinary() 87 | err := tc.concrete.Scan(wkb) 88 | expectNoErr(t, err) 89 | expectStringEq(t, tc.concrete.AsText(), tc.wkt) 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /geom/transform.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | func transformSequence(seq Sequence, fn func(XY) XY) Sequence { 4 | floats := make([]float64, 0, seq.CoordinatesType().Dimension()*seq.Length()) 5 | n := seq.Length() 6 | ctype := seq.CoordinatesType() 7 | for i := 0; i < n; i++ { 8 | c := seq.Get(i) 9 | c.XY = fn(c.XY) 10 | switch ctype { 11 | case DimXY: 12 | floats = append(floats, c.X, c.Y) 13 | case DimXYZ: 14 | floats = append(floats, c.X, c.Y, c.Z) 15 | case DimXYM: 16 | floats = append(floats, c.X, c.Y, c.M) 17 | case DimXYZM: 18 | floats = append(floats, c.X, c.Y, c.Z, c.M) 19 | default: 20 | panic(ctype) 21 | } 22 | } 23 | return NewSequence(floats, ctype) 24 | } 25 | -------------------------------------------------------------------------------- /geom/twkb.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | // Tiny Well Known Binary 4 | // See spec https://github.com/TWKB/Specification/blob/master/twkb.md 5 | 6 | type twkbGeometryType int 7 | 8 | const ( 9 | twkbTypePoint twkbGeometryType = 1 10 | twkbTypeLineString twkbGeometryType = 2 11 | twkbTypePolygon twkbGeometryType = 3 12 | twkbTypeMultiPoint twkbGeometryType = 4 13 | twkbTypeMultiLineString twkbGeometryType = 5 14 | twkbTypeMultiPolygon twkbGeometryType = 6 15 | twkbTypeGeometryCollection twkbGeometryType = 7 16 | ) 17 | 18 | const ( 19 | twkbMaxDimensions = 4 20 | ) 21 | 22 | type twkbMetadataHeader int 23 | 24 | const ( 25 | twkbHasBBox twkbMetadataHeader = 1 26 | twkbHasSize twkbMetadataHeader = 2 27 | twkbHasIDs twkbMetadataHeader = 4 28 | twkbHasExtPrec twkbMetadataHeader = 8 29 | twkbIsEmpty twkbMetadataHeader = 16 30 | ) 31 | 32 | // decodeZigZagInt64 accepts a uint64 and reverses the zigzag encoding 33 | // to produce the decoded signed int64 value. 34 | func decodeZigZagInt64(z uint64) int64 { 35 | return int64(z>>1) ^ -int64(z&1) 36 | } 37 | 38 | // encodeZigZagInt64 accepts a signed int64 and zigzag encodes 39 | // it to produce an encoded uint64 value. 40 | func encodeZigZagInt64(n int64) uint64 { 41 | return uint64((n << 1) ^ (n >> 63)) 42 | } 43 | -------------------------------------------------------------------------------- /geom/twkb_export_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | var ( 4 | DecodeZigZagInt64 = decodeZigZagInt64 5 | EncodeZigZagInt64 = encodeZigZagInt64 6 | ) 7 | -------------------------------------------------------------------------------- /geom/type_coordinates.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // Coordinates represents a point location. Coordinates values may be 9 | // constructed manually using the type definition directly. Alternatively, one 10 | // of the New(XYZM)Coordinates constructor functions can be used. 11 | type Coordinates struct { 12 | // XY represents the XY position of the point location. 13 | XY 14 | 15 | // Z represents the height of the location. Its value is zero 16 | // for non-3D coordinate types. 17 | Z float64 18 | 19 | // M represents a user defined measure associated with the 20 | // location. Its value is zero for non-measure coordinate 21 | // types. 22 | M float64 23 | 24 | // Type indicates the coordinates type, and therefore whether 25 | // or not Z and M are populated. 26 | Type CoordinatesType 27 | } 28 | 29 | // String gives a string representation of the coordinates. 30 | func (c Coordinates) String() string { 31 | var sb strings.Builder 32 | sb.WriteString("Coordinates[") 33 | sb.WriteString(c.Type.String()) 34 | sb.WriteString("] ") 35 | sb.WriteString(strconv.FormatFloat(c.X, 'f', -1, 64)) 36 | sb.WriteRune(' ') 37 | sb.WriteString(strconv.FormatFloat(c.Y, 'f', -1, 64)) 38 | if c.Type.Is3D() { 39 | sb.WriteRune(' ') 40 | sb.WriteString(strconv.FormatFloat(c.Z, 'f', -1, 64)) 41 | } 42 | if c.Type.IsMeasured() { 43 | sb.WriteRune(' ') 44 | sb.WriteString(strconv.FormatFloat(c.M, 'f', -1, 64)) 45 | } 46 | return sb.String() 47 | } 48 | 49 | // appendFloat64s appends the coordinates to dst, taking into 50 | // consideration the coordinate type. 51 | func (c Coordinates) appendFloat64s(dst []float64) []float64 { 52 | switch c.Type { 53 | case DimXY: 54 | return append(dst, c.X, c.Y) 55 | case DimXYZ: 56 | return append(dst, c.X, c.Y, c.Z) 57 | case DimXYM: 58 | return append(dst, c.X, c.Y, c.M) 59 | case DimXYZM: 60 | return append(dst, c.X, c.Y, c.Z, c.M) 61 | default: 62 | panic(c.Type.String()) 63 | } 64 | } 65 | 66 | // AsPoint is a convenience function to convert this Coordinates value into a Point geometry. 67 | func (c Coordinates) AsPoint() Point { 68 | // NOTE: this function is not very useful on its own. Its main purpose is 69 | // to shadow the AsPoint method on XY. If it were not shadowed, a user 70 | // could accidentally call AsPoint on a coordinates value (since XY is 71 | // field embedded), which would result in a Point with just XY populated. 72 | return NewPoint(c) 73 | } 74 | -------------------------------------------------------------------------------- /geom/type_coordinates_test.go: -------------------------------------------------------------------------------- 1 | package geom_test 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/peterstace/simplefeatures/geom" 8 | ) 9 | 10 | func TestCoordinatesString(t *testing.T) { 11 | for i, tc := range []struct { 12 | coords geom.Coordinates 13 | want string 14 | }{ 15 | { 16 | geom.Coordinates{}, 17 | "Coordinates[XY] 0 0", 18 | }, 19 | { 20 | geom.Coordinates{XY: geom.XY{X: 1, Y: 2}}, 21 | "Coordinates[XY] 1 2", 22 | }, 23 | { 24 | geom.Coordinates{XY: geom.XY{X: 1, Y: 2}, Z: 3, Type: geom.DimXYZ}, 25 | "Coordinates[XYZ] 1 2 3", 26 | }, 27 | { 28 | geom.Coordinates{XY: geom.XY{X: 1, Y: 2}, M: 3, Type: geom.DimXYM}, 29 | "Coordinates[XYM] 1 2 3", 30 | }, 31 | { 32 | geom.Coordinates{XY: geom.XY{X: 1, Y: 2}, Z: 3, M: 4, Type: geom.DimXYZM}, 33 | "Coordinates[XYZM] 1 2 3 4", 34 | }, 35 | } { 36 | t.Run(strconv.Itoa(i), func(t *testing.T) { 37 | got := tc.coords.String() 38 | expectStringEq(t, got, tc.want) 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /geom/type_null_geometry.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import "database/sql/driver" 4 | 5 | // NullGeometry represents a Geometry that may be NULL. It implements the 6 | // database/sql.Scanner and database/sql/driver.Valuer interfaces, so may be 7 | // used as a scan destination or query argument in SQL queries. 8 | type NullGeometry struct { 9 | Geometry Geometry 10 | Valid bool // Valid is true iff Geometry is not NULL 11 | } 12 | 13 | // Scan implements the database/sql.Scanner interface. 14 | func (ng *NullGeometry) Scan(value interface{}) error { 15 | if value == nil { 16 | ng.Geometry = Geometry{} 17 | ng.Valid = false 18 | return nil 19 | } 20 | ng.Valid = true 21 | return ng.Geometry.Scan(value) 22 | } 23 | 24 | // Value implements the database/sql/driver.Valuer interface. 25 | func (ng NullGeometry) Value() (driver.Value, error) { 26 | if !ng.Valid { 27 | return nil, nil //nolint:nilnil 28 | } 29 | return ng.Geometry.Value() 30 | } 31 | -------------------------------------------------------------------------------- /geom/type_null_geometry_test.go: -------------------------------------------------------------------------------- 1 | package geom_test 2 | 3 | import ( 4 | "database/sql" 5 | "database/sql/driver" 6 | "testing" 7 | 8 | "github.com/peterstace/simplefeatures/geom" 9 | ) 10 | 11 | func TestNullGeometryScan(t *testing.T) { 12 | wkb := geomFromWKT(t, "POINT(1 2)").AsBinary() 13 | 14 | for _, tc := range []struct { 15 | description string 16 | value interface{} 17 | wantValid bool 18 | wantWKT string 19 | }{ 20 | { 21 | description: "NULL geometry", 22 | value: nil, 23 | wantValid: false, 24 | }, 25 | { 26 | description: "populated geometry with string", 27 | value: string(wkb), 28 | wantValid: true, 29 | wantWKT: "POINT(1 2)", 30 | }, 31 | { 32 | description: "populated geometry with []byte", 33 | value: wkb, 34 | wantValid: true, 35 | wantWKT: "POINT(1 2)", 36 | }, 37 | } { 38 | t.Run(tc.description, func(t *testing.T) { 39 | var ng geom.NullGeometry 40 | scn := sql.Scanner(&ng) 41 | err := scn.Scan(tc.value) 42 | expectNoErr(t, err) 43 | expectBoolEq(t, tc.wantValid, ng.Valid) 44 | if tc.wantValid { 45 | expectGeomEq(t, ng.Geometry, geomFromWKT(t, tc.wantWKT)) 46 | } 47 | }) 48 | } 49 | } 50 | 51 | func TestNullGeometryValue(t *testing.T) { 52 | for _, tc := range []struct { 53 | description string 54 | input geom.NullGeometry 55 | want []byte 56 | }{ 57 | { 58 | description: "NULL geometry", 59 | input: geom.NullGeometry{Valid: false}, 60 | want: nil, 61 | }, 62 | { 63 | description: "point geometry", 64 | input: geom.NullGeometry{Valid: true, Geometry: geomFromWKT(t, "POINT(1 2)")}, 65 | want: geomFromWKT(t, "POINT(1 2)").AsBinary(), 66 | }, 67 | } { 68 | t.Run(tc.description, func(t *testing.T) { 69 | valuer := driver.Valuer(tc.input) 70 | got, err := valuer.Value() 71 | expectNoErr(t, err) 72 | if got == nil { 73 | if tc.want != nil { 74 | t.Fatalf("got nil but didn't want nil") 75 | } 76 | return 77 | } 78 | gotBytes, ok := got.([]byte) 79 | if !ok { 80 | t.Fatalf("didn't get bytes") 81 | } 82 | expectBytesEq(t, gotBytes, tc.want) 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /geom/util.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "sort" 7 | ) 8 | 9 | // TODO: Remove this when we require Go 1.21 (the max builtin can be used instead). 10 | func maxInt(a, b int) int { 11 | if a > b { 12 | return a 13 | } 14 | return b 15 | } 16 | 17 | func rank(g Geometry) int { 18 | switch g.gtype { 19 | case TypePoint: 20 | return 1 21 | case TypeLineString: 22 | return 2 23 | case TypePolygon: 24 | return 3 25 | case TypeMultiPoint: 26 | return 4 27 | case TypeMultiLineString: 28 | return 5 29 | case TypeMultiPolygon: 30 | return 6 31 | case TypeGeometryCollection: 32 | return 7 33 | default: 34 | panic(fmt.Sprintf("unknown geometry tag: %s", g.gtype)) 35 | } 36 | } 37 | 38 | // sortAndUniquifyXYs sorts the xys, and then makes them unique. The input 39 | // slice is modified, however the result is in the returned slice since it may 40 | // have its size changed due to uniquification. 41 | func sortAndUniquifyXYs(xys []XY) []XY { 42 | if len(xys) == 0 { 43 | return xys 44 | } 45 | sort.Slice(xys, func(i, j int) bool { 46 | return xys[i].Less(xys[j]) 47 | }) 48 | return uniquifyGroupedXYs(xys) 49 | } 50 | 51 | // uniquifyGroupedXYs uniquifies the xys, assuming that equal values are always 52 | // grouped adjacent to each other. The input slice is modified, however the 53 | // result is in the returned slice since it may have its size changed due to 54 | // uniquification. 55 | func uniquifyGroupedXYs(xys []XY) []XY { 56 | if len(xys) == 0 { 57 | return xys 58 | } 59 | n := 1 60 | for i := 1; i < len(xys); i++ { 61 | if xys[i] != xys[i-1] { 62 | xys[n] = xys[i] 63 | n++ 64 | } 65 | } 66 | return xys[:n] 67 | } 68 | 69 | func sequenceToXYs(seq Sequence) []XY { 70 | n := seq.Length() 71 | xys := make([]XY, seq.Length()) 72 | for i := 0; i < n; i++ { 73 | xys[i] = seq.GetXY(i) 74 | } 75 | return xys 76 | } 77 | 78 | // fastMin is a faster but not functionally identical version of math.Min. 79 | func fastMin(a, b float64) float64 { 80 | if math.IsNaN(a) || a < b { 81 | return a 82 | } 83 | return b 84 | } 85 | 86 | // fastMax is a faster but not functionally identical version of math.Max. 87 | func fastMax(a, b float64) float64 { 88 | if math.IsNaN(a) || a > b { 89 | return a 90 | } 91 | return b 92 | } 93 | 94 | // sortFloat64Pair returns a and b in sorted order. 95 | func sortFloat64Pair(a, b float64) (float64, float64) { 96 | if a > b { 97 | return b, a 98 | } 99 | return a, b 100 | } 101 | 102 | func arbitraryControlPoint(g Geometry) Point { 103 | switch typ := g.Type(); typ { 104 | case TypeGeometryCollection: 105 | gc := g.MustAsGeometryCollection() 106 | for i := 0; i < gc.NumGeometries(); i++ { 107 | if pt := arbitraryControlPoint(gc.GeometryN(i)); !pt.IsEmpty() { 108 | return pt 109 | } 110 | } 111 | return Point{} 112 | case TypePoint: 113 | return g.MustAsPoint() 114 | case TypeLineString: 115 | return g.MustAsLineString().StartPoint() 116 | case TypePolygon: 117 | return g.MustAsPolygon().ExteriorRing().StartPoint() 118 | case TypeMultiPoint: 119 | for _, pt := range g.MustAsMultiPoint().Dump() { 120 | if !pt.IsEmpty() { 121 | return pt 122 | } 123 | } 124 | return Point{} 125 | case TypeMultiLineString: 126 | for _, ls := range g.MustAsMultiLineString().Dump() { 127 | if pt := ls.StartPoint(); !pt.IsEmpty() { 128 | return pt 129 | } 130 | } 131 | return Point{} 132 | case TypeMultiPolygon: 133 | for _, p := range g.MustAsMultiPolygon().Dump() { 134 | pt := p.ExteriorRing().StartPoint() 135 | if !pt.IsEmpty() { 136 | return pt 137 | } 138 | } 139 | return Point{} 140 | default: 141 | panic(fmt.Sprintf("invalid geometry type: %d", int(typ))) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /geom/util_internal_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "math" 5 | "strconv" 6 | "testing" 7 | ) 8 | 9 | func TestFastMinFastMax(t *testing.T) { 10 | var ( 11 | nan = math.NaN() 12 | inf = math.Inf(1) 13 | eq = func(a, b float64) bool { 14 | return (math.IsNaN(a) && math.IsNaN(b)) || a == b 15 | } 16 | ) 17 | for i, tc := range []struct { 18 | a, b float64 19 | min, max float64 20 | }{ 21 | {0, 0, 0, 0}, 22 | {1, 2, 1, 2}, 23 | {2, 1, 1, 2}, 24 | {0, nan, nan, nan}, 25 | {nan, 0, nan, nan}, 26 | {nan, nan, nan, nan}, 27 | {0, inf, 0, inf}, 28 | {inf, 0, 0, inf}, 29 | {inf, inf, inf, inf}, 30 | {0, -inf, -inf, 0}, 31 | {-inf, 0, -inf, 0}, 32 | {-inf, -inf, -inf, -inf}, 33 | } { 34 | t.Run(strconv.Itoa(i), func(t *testing.T) { 35 | gotMin := fastMin(tc.a, tc.b) 36 | gotMax := fastMax(tc.a, tc.b) 37 | if !eq(gotMin, tc.min) { 38 | t.Errorf("fastMin(%v, %v) = %v, want %v", tc.a, tc.b, gotMin, tc.min) 39 | } 40 | if !eq(gotMax, tc.max) { 41 | t.Errorf("fastMax(%v, %v) = %v, want %v", tc.a, tc.b, gotMax, tc.max) 42 | } 43 | }) 44 | } 45 | } 46 | 47 | var global float64 48 | 49 | func BenchmarkFastMin(b *testing.B) { 50 | for i := 0; i < b.N; i++ { 51 | global = fastMin(global, 2) 52 | } 53 | } 54 | 55 | func BenchmarkFastMax(b *testing.B) { 56 | for i := 0; i < b.N; i++ { 57 | global = fastMax(global, 2) 58 | } 59 | } 60 | 61 | func BenchmarkMathMin(b *testing.B) { 62 | for i := 0; i < b.N; i++ { 63 | global = math.Min(global, 2) 64 | } 65 | } 66 | 67 | func BenchmarkMathMax(b *testing.B) { 68 | for i := 0; i < b.N; i++ { 69 | global = math.Max(global, 2) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /geom/walk.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import "fmt" 4 | 5 | // walk calls fn for each control point in the geometry. 6 | func walk(g Geometry, fn func(XY)) { 7 | switch g.Type() { 8 | case TypePoint: 9 | if xy, ok := g.MustAsPoint().XY(); ok { 10 | fn(xy) 11 | } 12 | case TypeLineString: 13 | seq := g.MustAsLineString().Coordinates() 14 | n := seq.Length() 15 | for i := 0; i < n; i++ { 16 | fn(seq.GetXY(i)) 17 | } 18 | case TypePolygon: 19 | walk(g.Boundary(), fn) 20 | case TypeMultiPoint: 21 | mp := g.MustAsMultiPoint() 22 | n := mp.NumPoints() 23 | for i := 0; i < n; i++ { 24 | if xy, ok := mp.PointN(i).XY(); ok { 25 | fn(xy) 26 | } 27 | } 28 | case TypeMultiLineString: 29 | mls := g.MustAsMultiLineString() 30 | n := mls.NumLineStrings() 31 | for i := 0; i < n; i++ { 32 | walk(mls.LineStringN(i).AsGeometry(), fn) 33 | } 34 | case TypeMultiPolygon: 35 | walk(g.Boundary(), fn) 36 | case TypeGeometryCollection: 37 | gc := g.MustAsGeometryCollection() 38 | n := gc.NumGeometries() 39 | for i := 0; i < n; i++ { 40 | walk(gc.GeometryN(i), fn) 41 | } 42 | default: 43 | panic(fmt.Sprintf("unknown geometry type %v", g.Type())) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /geom/wkb_marshal.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "math" 7 | "unsafe" 8 | ) 9 | 10 | var nativeOrder binary.ByteOrder 11 | 12 | func init() { 13 | var buf [2]byte 14 | *(*uint16)(unsafe.Pointer(&buf[0])) = uint16(0x1234) 15 | 16 | switch buf[0] { 17 | case 0x12: 18 | nativeOrder = binary.BigEndian 19 | case 0x34: 20 | nativeOrder = binary.LittleEndian 21 | default: 22 | panic(fmt.Sprintf("unexpected buf[0]: %d", buf[0])) 23 | } 24 | } 25 | 26 | type wkbMarshaler struct { 27 | buf []byte 28 | } 29 | 30 | func newWKBMarshaler(buf []byte) *wkbMarshaler { 31 | return &wkbMarshaler{buf} 32 | } 33 | 34 | func (m *wkbMarshaler) writeByteOrder() { 35 | if nativeOrder == binary.LittleEndian { 36 | m.buf = append(m.buf, 1) 37 | } else { 38 | m.buf = append(m.buf, 0) 39 | } 40 | } 41 | 42 | func (m *wkbMarshaler) writeGeomType(geomType GeometryType, ctype CoordinatesType) { 43 | gt := [...]uint32{7, 1, 2, 3, 4, 5, 6}[geomType] 44 | var buf [4]byte 45 | nativeOrder.PutUint32(buf[:], uint32(ctype)*1000+gt) 46 | m.buf = append(m.buf, buf[:]...) 47 | } 48 | 49 | func (m *wkbMarshaler) writeFloat64(f float64) { 50 | var buf [8]byte 51 | nativeOrder.PutUint64(buf[:], math.Float64bits(f)) 52 | m.buf = append(m.buf, buf[:]...) 53 | } 54 | 55 | func (m *wkbMarshaler) writeCount(n int) { 56 | var buf [4]byte 57 | nativeOrder.PutUint32(buf[:], uint32(n)) 58 | m.buf = append(m.buf, buf[:]...) 59 | } 60 | 61 | func (m *wkbMarshaler) writeCoordinates(c Coordinates) { 62 | m.writeFloat64(c.X) 63 | m.writeFloat64(c.Y) 64 | if c.Type.Is3D() { 65 | m.writeFloat64(c.Z) 66 | } 67 | if c.Type.IsMeasured() { 68 | m.writeFloat64(c.M) 69 | } 70 | } 71 | 72 | func (m *wkbMarshaler) writeSequence(seq Sequence) { 73 | n := seq.Length() 74 | m.writeCount(n) 75 | 76 | // Rather than iterating over the sequence using the Get method, then 77 | // writing the Coordinates of the point using the writeCoordinates 78 | // function, we instead directly append the byte representation of the 79 | // floats. This relies on the assumption that the WKB being produced has 80 | // native byte order. This hack provides a *significant* performance 81 | // improvement. 82 | m.buf = append(m.buf, floatsAsBytes(seq.floats)...) 83 | } 84 | 85 | // floatsAsBytes reinterprets the floats slice as a bytes slice in a similar 86 | // manner to reinterpret_cast in C++. 87 | func floatsAsBytes(floats []float64) []byte { 88 | if len(floats) == 0 { 89 | return nil 90 | } 91 | return unsafe.Slice((*byte)(unsafe.Pointer(&floats[0])), 8*len(floats)) 92 | } 93 | -------------------------------------------------------------------------------- /geom/wkt_lexer.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "text/scanner" 7 | ) 8 | 9 | type wktLexer struct { 10 | scn scanner.Scanner 11 | peeked string 12 | } 13 | 14 | func newWKTLexer(wkt string) wktLexer { 15 | var scn scanner.Scanner 16 | scn.Init(strings.NewReader(wkt)) 17 | scn.Mode = scanner.ScanInts | scanner.ScanFloats | scanner.ScanIdents 18 | return wktLexer{scn: scn} 19 | } 20 | 21 | var wktUnexpectedEOF = wktSyntaxError{"unexpected EOF"} 22 | 23 | func (w *wktLexer) next() (string, error) { 24 | if w.peeked != "" { 25 | tok := w.peeked 26 | w.peeked = "" 27 | return tok, nil 28 | } 29 | 30 | var err error 31 | w.scn.Error = func(_ *scanner.Scanner, msg string) { 32 | err = wktSyntaxError{fmt.Sprintf("invalid token '%s' (%s)", w.scn.TokenText(), msg)} 33 | } 34 | isEOF := w.scn.Scan() == scanner.EOF 35 | if err != nil { 36 | return "", err 37 | } 38 | if isEOF { 39 | return "", wktUnexpectedEOF 40 | } 41 | return w.scn.TokenText(), nil 42 | } 43 | 44 | func (w *wktLexer) peek() (string, error) { 45 | if w.peeked != "" { 46 | return w.peeked, nil 47 | } 48 | tok, err := w.next() 49 | if err != nil { 50 | return "", err 51 | } 52 | w.peeked = tok 53 | return tok, nil 54 | } 55 | -------------------------------------------------------------------------------- /geom/wkt_lexer_internal_test.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "strconv" 7 | "testing" 8 | ) 9 | 10 | func TestWKTLexer(t *testing.T) { 11 | for i, tc := range []struct { 12 | wkt string 13 | toks []string 14 | }{ 15 | { 16 | "POINT(1 2)", 17 | []string{"POINT", "(", "1", "2", ")"}, 18 | }, 19 | { 20 | "POINT EOF", 21 | []string{"POINT", "EOF"}, 22 | }, 23 | { 24 | `"hello`, 25 | []string{`"`, "hello"}, 26 | }, 27 | { 28 | `/*hello*/ foo`, 29 | []string{`/`, `*`, `hello`, `*`, `/`, `foo`}, 30 | }, 31 | { 32 | `3.14`, 33 | []string{`3.14`}, 34 | }, 35 | { 36 | `3.`, 37 | []string{`3.`}, 38 | }, 39 | } { 40 | t.Run(strconv.Itoa(i), func(t *testing.T) { 41 | lexer := newWKTLexer(tc.wkt) 42 | var got []string 43 | for { 44 | tok, err := lexer.next() 45 | if err != nil { 46 | if errors.Is(err, wktUnexpectedEOF) { 47 | break 48 | } 49 | t.Fatal(err) 50 | } 51 | got = append(got, tok) 52 | } 53 | if !reflect.DeepEqual(got, tc.toks) { 54 | t.Logf("want: %v", tc.toks) 55 | t.Logf("got: %v", got) 56 | t.Error("mismatch") 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /geom/wkt_write.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | func appendWKTHeader(dst []byte, geomType string, ctype CoordinatesType) []byte { 4 | dst = append(dst, geomType...) 5 | dst = append(dst, [4]string{"", " Z ", " M ", " ZM "}[ctype]...) 6 | return dst 7 | } 8 | 9 | func appendWKTCoords(dst []byte, coords Coordinates, parens bool) []byte { 10 | if parens { 11 | dst = append(dst, '(') 12 | } 13 | dst = appendFloat(dst, coords.X) 14 | dst = append(dst, ' ') 15 | dst = appendFloat(dst, coords.Y) 16 | if coords.Type.Is3D() { 17 | dst = append(dst, ' ') 18 | dst = appendFloat(dst, coords.Z) 19 | } 20 | if coords.Type.IsMeasured() { 21 | dst = append(dst, ' ') 22 | dst = appendFloat(dst, coords.M) 23 | } 24 | if parens { 25 | dst = append(dst, ')') 26 | } 27 | return dst 28 | } 29 | 30 | func appendWKTEmpty(dst []byte) []byte { 31 | if len(dst) > 0 { 32 | switch dst[len(dst)-1] { 33 | case '(', ',', ' ': 34 | default: 35 | dst = append(dst, ' ') 36 | } 37 | } 38 | return append(dst, "EMPTY"...) 39 | } 40 | 41 | func appendWKTSequence(dst []byte, seq Sequence, parens bool) []byte { 42 | n := seq.Length() 43 | dst = append(dst, '(') 44 | for i := 0; i < n; i++ { 45 | if i > 0 { 46 | dst = append(dst, ',') 47 | } 48 | dst = appendWKTCoords(dst, seq.Get(i), parens) 49 | } 50 | dst = append(dst, ')') 51 | return dst 52 | } 53 | -------------------------------------------------------------------------------- /geom/xy.go: -------------------------------------------------------------------------------- 1 | package geom 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/peterstace/simplefeatures/rtree" 7 | ) 8 | 9 | // XY represents a pair of X and Y coordinates. This can either represent a 10 | // location on the XY plane, or a 2D vector in the real vector space. 11 | type XY struct { 12 | X, Y float64 13 | } 14 | 15 | // validate checks if the XY value contains NaN, -inf, or +inf. 16 | func (w XY) validate() error { 17 | if math.IsNaN(w.X) || math.IsNaN(w.Y) { 18 | return violateNaN.errAtXY(w) 19 | } 20 | if math.IsInf(w.X, 0) || math.IsInf(w.Y, 0) { 21 | return violateInf.errAtXY(w) 22 | } 23 | return nil 24 | } 25 | 26 | // AsPoint is a convenience function to convert this XY value into a Point 27 | // geometry. 28 | func (w XY) AsPoint() Point { 29 | coords := Coordinates{XY: w, Type: DimXY} 30 | return NewPoint(coords) 31 | } 32 | 33 | // uncheckedEnvelope is a convenience function to convert this XY value into 34 | // a (degenerate) envelope that represents a single XY location (i.e. a zero 35 | // area envelope). It may be used internally when the caller is sure that the 36 | // XY value doesn't come directly from outline the library without first being 37 | // validated. 38 | func (w XY) uncheckedEnvelope() Envelope { 39 | return newUncheckedEnvelope(w, w) 40 | } 41 | 42 | // Sub returns the result of subtracting the other XY from this XY (in the same 43 | // manner as vector subtraction). 44 | func (w XY) Sub(o XY) XY { 45 | return XY{ 46 | w.X - o.X, 47 | w.Y - o.Y, 48 | } 49 | } 50 | 51 | // Add returns the result of adding this XY to another XY (in the same manner 52 | // as vector addition). 53 | func (w XY) Add(o XY) XY { 54 | return XY{ 55 | w.X + o.X, 56 | w.Y + o.Y, 57 | } 58 | } 59 | 60 | // Scale returns the XY where the X and Y have been scaled by s. 61 | func (w XY) Scale(s float64) XY { 62 | return XY{ 63 | w.X * s, 64 | w.Y * s, 65 | } 66 | } 67 | 68 | // Cross returns the 2D cross product of this and another XY. This is defined 69 | // as the 'z' coordinate of the regular 3D cross product. 70 | func (w XY) Cross(o XY) float64 { 71 | // Avoid fused multiply-add by explicitly converting intermediate products 72 | // to float64. This ensures that the cross product is *exactly* zero for 73 | // all linearly dependent inputs. 74 | return float64(w.X*o.Y) - float64(w.Y*o.X) 75 | } 76 | 77 | // Midpoint returns the midpoint of this and another XY. 78 | func (w XY) Midpoint(o XY) XY { 79 | return w.Add(o).Scale(0.5) 80 | } 81 | 82 | // Dot returns the dot product of this and another XY. 83 | func (w XY) Dot(o XY) float64 { 84 | return w.X*o.X + w.Y*o.Y 85 | } 86 | 87 | // Unit treats the XY as a vector, and scales it to have unit length. 88 | func (w XY) Unit() XY { 89 | return w.Scale(1 / w.Length()) 90 | } 91 | 92 | // Length treats XY as a vector, and returns its length. 93 | func (w XY) Length() float64 { 94 | return math.Sqrt(w.lengthSq()) 95 | } 96 | 97 | // lengthSq treats XY as a vector, and returns its squared length. 98 | func (w XY) lengthSq() float64 { 99 | return w.Dot(w) 100 | } 101 | 102 | // Less gives an ordering on XYs. If two XYs have different X values, then the 103 | // one with the lower X value is ordered before the one with the higher X 104 | // value. If the X values are then same, then the Y values are used (the lower 105 | // Y value comes first). 106 | func (w XY) Less(o XY) bool { 107 | if w.X != o.X { 108 | return w.X < o.X 109 | } 110 | return w.Y < o.Y 111 | } 112 | 113 | func (w XY) distanceTo(o XY) float64 { 114 | return math.Sqrt(w.distanceSquaredTo(o)) 115 | } 116 | 117 | func (w XY) distanceSquaredTo(o XY) float64 { 118 | delta := o.Sub(w) 119 | return delta.Dot(delta) 120 | } 121 | 122 | func (w XY) box() rtree.Box { 123 | return rtree.Box{ 124 | MinX: w.X, 125 | MinY: w.Y, 126 | MaxX: w.X, 127 | MaxY: w.Y, 128 | } 129 | } 130 | 131 | // rotateCCW90 treats the XY as a vector, rotating it 90 degrees in a counter 132 | // clockwise direction (assuming a right handed/positive orientation). 133 | func (w XY) rotateCCW90() XY { 134 | return XY{ 135 | X: -w.Y, 136 | Y: w.X, 137 | } 138 | } 139 | 140 | // rotate180 treats the XY as a vector, rotating it 180 degrees. 141 | func (w XY) rotate180() XY { 142 | return XY{ 143 | X: -w.X, 144 | Y: -w.Y, 145 | } 146 | } 147 | 148 | // identity returns the XY unchanged. 149 | func (w XY) identity() XY { 150 | return w 151 | } 152 | 153 | // proj returns the projection of w onto o. 154 | func (w XY) proj(o XY) XY { 155 | return o.Scale(w.Dot(o) / o.Dot(o)) 156 | } 157 | -------------------------------------------------------------------------------- /geom/xy_test.go: -------------------------------------------------------------------------------- 1 | package geom_test 2 | 3 | import ( 4 | "math" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/peterstace/simplefeatures/geom" 9 | ) 10 | 11 | func TestXYUnit(t *testing.T) { 12 | sqrt2 := math.Sqrt(2) 13 | sqrt5 := math.Sqrt(5) 14 | for _, tc := range []struct { 15 | description string 16 | input geom.XY 17 | output geom.XY 18 | }{ 19 | { 20 | description: "+ve unit in X", 21 | input: geom.XY{X: 1}, 22 | output: geom.XY{X: 1}, 23 | }, 24 | { 25 | description: "+ve unit in Y", 26 | input: geom.XY{Y: 1}, 27 | output: geom.XY{Y: 1}, 28 | }, 29 | { 30 | description: "-ve unit in X", 31 | input: geom.XY{X: -1}, 32 | output: geom.XY{X: -1}, 33 | }, 34 | { 35 | description: "-ve unit in Y", 36 | input: geom.XY{Y: -1}, 37 | output: geom.XY{Y: -1}, 38 | }, 39 | { 40 | description: "non-aligned unit", 41 | input: geom.XY{X: -1 / sqrt2, Y: 1 / sqrt2}, 42 | output: geom.XY{X: -1 / sqrt2, Y: 1 / sqrt2}, 43 | }, 44 | { 45 | description: "non-unit", 46 | input: geom.XY{X: 1, Y: -2}, 47 | output: geom.XY{X: 1 / sqrt5, Y: -2 / sqrt5}, 48 | }, 49 | } { 50 | t.Run(tc.description, func(t *testing.T) { 51 | got := tc.input.Unit() 52 | expectXYWithinTolerance(t, got, tc.output, 0.0000001) 53 | }) 54 | } 55 | } 56 | 57 | func TestXYCross(t *testing.T) { 58 | xy := func(x, y float64) geom.XY { 59 | return geom.XY{X: x, Y: y} 60 | } 61 | for i, tc := range []struct { 62 | u, v geom.XY 63 | want float64 64 | }{ 65 | // Contains zero: 66 | {u: xy(1, 1), v: xy(0, 0), want: 0}, 67 | {u: xy(0.1, 0.1), v: xy(0, 0), want: 0}, 68 | 69 | // Linearly dependent: 70 | {u: xy(1, 1), v: xy(2, 2), want: 0}, 71 | {u: xy(-1, -1), v: xy(2, 2), want: 0}, 72 | 73 | // Reproduces numeric precision bug that occurred on aarch64 but *not* x86_64. 74 | {u: xy(0.2, 0.2), v: xy(0.1, 0.1), want: 0}, 75 | 76 | // Linearly independent: 77 | {u: xy(1, 0), v: xy(0, 1), want: 1}, 78 | {u: xy(0, 2), v: xy(1, 0), want: -2}, 79 | } { 80 | t.Run(strconv.Itoa(i), func(t *testing.T) { 81 | t.Run("fwd", func(t *testing.T) { 82 | got := tc.u.Cross(tc.v) 83 | expectFloat64Eq(t, got, tc.want) 84 | }) 85 | t.Run("rev", func(t *testing.T) { 86 | got := tc.v.Cross(tc.u) 87 | want := -tc.want // Cross product is anti-commutative. 88 | expectFloat64Eq(t, got, want) 89 | }) 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /geom/zero_value_test.go: -------------------------------------------------------------------------------- 1 | package geom_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/peterstace/simplefeatures/geom" 7 | ) 8 | 9 | func TestZeroValueGeometries(t *testing.T) { 10 | t.Run("Point", func(t *testing.T) { 11 | var pt geom.Point 12 | expectBoolEq(t, pt.IsEmpty(), true) 13 | expectCoordinatesTypeEq(t, pt.CoordinatesType(), geom.DimXY) 14 | }) 15 | t.Run("LineString", func(t *testing.T) { 16 | var ls geom.LineString 17 | expectIntEq(t, ls.Coordinates().Length(), 0) 18 | expectCoordinatesTypeEq(t, ls.CoordinatesType(), geom.DimXY) 19 | }) 20 | t.Run("Polygon", func(t *testing.T) { 21 | var p geom.Polygon 22 | expectBoolEq(t, p.IsEmpty(), true) 23 | expectCoordinatesTypeEq(t, p.CoordinatesType(), geom.DimXY) 24 | }) 25 | t.Run("MultiPoint", func(t *testing.T) { 26 | var mp geom.MultiPoint 27 | expectIntEq(t, mp.NumPoints(), 0) 28 | expectCoordinatesTypeEq(t, mp.CoordinatesType(), geom.DimXY) 29 | }) 30 | t.Run("MultiLineString", func(t *testing.T) { 31 | var mls geom.MultiLineString 32 | expectIntEq(t, mls.NumLineStrings(), 0) 33 | expectCoordinatesTypeEq(t, mls.CoordinatesType(), geom.DimXY) 34 | }) 35 | t.Run("MultiPolygon", func(t *testing.T) { 36 | var mp geom.MultiPolygon 37 | expectIntEq(t, mp.NumPolygons(), 0) 38 | expectCoordinatesTypeEq(t, mp.CoordinatesType(), geom.DimXY) 39 | }) 40 | t.Run("GeometryCollection", func(t *testing.T) { 41 | var gc geom.GeometryCollection 42 | expectIntEq(t, gc.NumGeometries(), 0) 43 | expectCoordinatesTypeEq(t, gc.CoordinatesType(), geom.DimXY) 44 | }) 45 | } 46 | 47 | func TestEmptySliceConstructors(t *testing.T) { 48 | t.Run("Polygon", func(t *testing.T) { 49 | p := geom.NewPolygon(nil) 50 | expectNoErr(t, p.Validate()) 51 | expectBoolEq(t, p.IsEmpty(), true) 52 | expectCoordinatesTypeEq(t, p.CoordinatesType(), geom.DimXY) 53 | }) 54 | t.Run("MultiPoint", func(t *testing.T) { 55 | mp := geom.NewMultiPoint(nil) 56 | expectNoErr(t, mp.Validate()) 57 | expectIntEq(t, mp.NumPoints(), 0) 58 | expectCoordinatesTypeEq(t, mp.CoordinatesType(), geom.DimXY) 59 | }) 60 | t.Run("MultiLineString", func(t *testing.T) { 61 | mls := geom.NewMultiLineString(nil) 62 | expectNoErr(t, mls.Validate()) 63 | expectIntEq(t, mls.NumLineStrings(), 0) 64 | expectCoordinatesTypeEq(t, mls.CoordinatesType(), geom.DimXY) 65 | }) 66 | t.Run("MultiPolygon", func(t *testing.T) { 67 | mp := geom.NewMultiPolygon(nil) 68 | expectNoErr(t, mp.Validate()) 69 | expectIntEq(t, mp.NumPolygons(), 0) 70 | expectCoordinatesTypeEq(t, mp.CoordinatesType(), geom.DimXY) 71 | }) 72 | t.Run("GeometryCollection", func(t *testing.T) { 73 | gc := geom.NewGeometryCollection(nil) 74 | expectNoErr(t, gc.Validate()) 75 | expectIntEq(t, gc.NumGeometries(), 0) 76 | expectCoordinatesTypeEq(t, gc.CoordinatesType(), geom.DimXY) 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /geos/doc.go: -------------------------------------------------------------------------------- 1 | // Package geos provides a cgo wrapper around the GEOS (Geometry Engine, Open 2 | // Source) library. See https://www.osgeo.org/projects/geos/ for more details. 3 | // 4 | // Its purpose is to provide functionality that has been implemented in GEOS, 5 | // but is not yet available natively in the simplefeatures library. 6 | // 7 | // Results from functions in this package are returned from GEOS unvalidated 8 | // and as-is. Users may call the Validate method on results if they wish to 9 | // check result validity. 10 | // 11 | // The operations in this package ignore Z and M values if they are present. 12 | // 13 | // To use this package, you will need to install the GEOS library. 14 | package geos 15 | -------------------------------------------------------------------------------- /geos/testdata/coverage_simplify_input_balmain.wkt: -------------------------------------------------------------------------------- 1 | MULTIPOLYGON(((151.17562157 -33.852492,151.17610434 -33.85293458,151.17653708 -33.85258046,151.17664705 -33.85268229,151.17635201 -33.85292177,151.17703513 -33.85355284,151.17752868 -33.85329725,151.17760962 -33.85341627,151.17789714 -33.8532749,151.17813638 -33.85355944,151.17787658 -33.85368893,151.17794364 -33.85375775,151.17817861 -33.85364202,151.17829404 -33.85380574,151.17851529 -33.85368774,151.17863944 -33.85383985,151.17825035 -33.85406584,151.17831976 -33.8541354,151.17861349 -33.85396523,151.17888675 -33.85429772,151.17869385 -33.85451408,151.17897699 -33.85466425,151.17923305 -33.85451707,151.1793722 -33.85468909,151.17923626 -33.85476768,151.17934972 -33.8548113,151.17960694 -33.85466363,151.17975564 -33.85485334,151.1799324 -33.85475283,151.18008374 -33.85494999,151.17992892 -33.85504022,151.18004059 -33.85508669,151.18024687 -33.85496532,151.18047698 -33.85510386,151.18057018 -33.85504907,151.18072799 -33.85523191,151.1806061 -33.85530431,151.18095598 -33.85553991,151.18179702 -33.85509033,151.18163194 -33.85484523,151.18182783 -33.85507189,151.18236497 -33.85468442,151.18247432 -33.85482159,151.18263573 -33.85473058,151.18273565 -33.85485813,151.18288381 -33.8547757,151.18279125 -33.85465935,151.18425398 -33.85393118,151.18481299 -33.85462981,151.18515941 -33.85464942,151.1856686 -33.85498144,151.18602454 -33.85477304,151.18634885 -33.85493575,151.18689314 -33.85505367,151.18766825 -33.85565725,151.18764139 -33.85580755,151.18785861 -33.85617518,151.18813822 -33.85627391,151.18848034 -33.85655411,151.18845636 -33.85666427,151.18874913 -33.85675219,151.18871911 -33.8570698,151.18844014 -33.8571684,151.18834672 -33.85775106,151.18859877 -33.85773378,151.18858846 -33.85782662,151.18848071 -33.85783249,151.18845465 -33.85805513,151.18893474 -33.85808091,151.18894716 -33.85815697,151.18941834 -33.85808216,151.18979997 -33.85845525,151.19060903 -33.85829987,151.19074384 -33.85824933,151.19073681 -33.85811149,151.19088604 -33.858132,151.19089161 -33.85819461,151.19188407 -33.85815769,151.1920783 -33.85885077,151.19226281 -33.85882375,151.19243245 -33.85945026,151.19266264 -33.85940856,151.19272075 -33.8596754,151.19201066 -33.8596363,151.19164724 -33.85925607,151.19161325 -33.85908805,151.19077421 -33.85873108,151.19022884 -33.85924577,151.19019373 -33.85961462,151.19030245 -33.86020011,151.19137082 -33.86018757,151.19237275 -33.8605375,151.19234778 -33.86060066,151.19171625 -33.86047889,151.19116962 -33.86102952,151.19122106 -33.86121966,151.18680314 -33.86136574,151.18164151 -33.8638001,151.18089125 -33.86334185,151.18071207 -33.86348688,151.18073576 -33.86360511,151.18042706 -33.86368329,151.18029913 -33.86347669,151.17989585 -33.86364402,151.17990122 -33.86404778,151.1795342 -33.86417184,151.17952829 -33.86369808,151.17911921 -33.86308979,151.17913111 -33.8622604,151.17892008 -33.86204881,151.17835989 -33.86232404,151.17851032 -33.86279427,151.17808042 -33.86288972,151.17778637 -33.86247385,151.17650112 -33.86281741,151.17630906 -33.86245507,151.17599931 -33.86250726,151.17597124 -33.86224142,151.17528962 -33.86234787,151.17444549 -33.86208818,151.17449779 -33.86198789,151.17436897 -33.86194273,151.17445034 -33.86179621,151.17419163 -33.8617317,151.17424196 -33.86162827,151.17411313 -33.86154668,151.17416669 -33.86144509,151.17395707 -33.86139227,151.17403184 -33.86124238,151.17393452 -33.8612261,151.17399894 -33.86107266,151.17295536 -33.86093852,151.17296923 -33.86085877,151.17267324 -33.86082292,151.17265752 -33.86034748,151.17229531 -33.86032,151.17244093 -33.85897694,151.172487 -33.85879708,151.17261284 -33.85880772,151.17264664 -33.85867398,151.17253642 -33.85866344,151.17259684 -33.85841767,151.17200881 -33.85819881,151.17101707 -33.85819379,151.17104768 -33.85760108,151.16988059 -33.85726533,151.1698966 -33.85744043,151.16935085 -33.85729849,151.16935826 -33.85716401,151.16947786 -33.85716685,151.16935023 -33.85706307,151.16925185 -33.85652192,151.16913408 -33.85654678,151.16873529 -33.85591302,151.16866673 -33.85511082,151.16872679 -33.85496086,151.16951413 -33.8546535,151.17077305 -33.85497744,151.17109831 -33.85485889,151.17138435 -33.85442554,151.17138822 -33.85373359,151.17133183 -33.85370213,151.17139593 -33.85335123,151.1712631 -33.85286008,151.17138825 -33.85272859,151.17178041 -33.85280195,151.17251637 -33.85339421,151.17272474 -33.85348034,151.17269209 -33.85336499,151.17349568 -33.85316641,151.17363463 -33.85357628,151.17448299 -33.85313989,151.17477125 -33.85303491,151.17484645 -33.85314004,151.17511686 -33.85300693,151.17504206 -33.85292464,151.17562157 -33.852492))) 2 | -------------------------------------------------------------------------------- /geos/testdata/coverage_simplify_input_birchgrove.wkt: -------------------------------------------------------------------------------- 1 | MULTIPOLYGON(((151.18548351 -33.84598785,151.18584268 -33.84624592,151.1857423 -33.84631734,151.18594953 -33.84636702,151.18615016 -33.84649602,151.18633781 -33.84649924,151.18690883 -33.84682487,151.18688258 -33.8469871,151.18656403 -33.84709663,151.18576996 -33.84716717,151.1857308 -33.84709965,151.1852197 -33.84735202,151.18479416 -33.84777024,151.18453976 -33.84791395,151.18359937 -33.84799928,151.18357148 -33.84792909,151.18338813 -33.84800376,151.18341095 -33.84806503,151.18322751 -33.84811131,151.18319511 -33.84803093,151.18307436 -33.84805145,151.18311025 -33.84813895,151.18243728 -33.8483085,151.1822388 -33.84843244,151.1822325 -33.84883917,151.1824452 -33.84926884,151.18284292 -33.84966277,151.18300231 -33.84957748,151.183634 -33.84954128,151.18429403 -33.8498145,151.18421932 -33.84987575,151.18432162 -33.84980016,151.1849602 -33.85027605,151.18547786 -33.85074492,151.18543893 -33.85079035,151.18597511 -33.85080932,151.1865003 -33.85116371,151.18700228 -33.8512558,151.18723252 -33.85138504,151.18717657 -33.85144488,151.18730358 -33.85156741,151.18736608 -33.85148978,151.18800725 -33.85179123,151.18801734 -33.85186512,151.18856615 -33.85184505,151.1890631 -33.85168387,151.19056762 -33.85180017,151.19044047 -33.85225499,151.18961572 -33.85284594,151.18831799 -33.85326861,151.18681272 -33.85311358,151.18671228 -33.85348185,151.18659345 -33.85351808,151.18551016 -33.85286741,151.18474128 -33.85326036,151.18496648 -33.85338689,151.18491944 -33.85352382,151.18482791 -33.85353087,151.18468821 -33.85338413,151.18458915 -33.85350098,151.18482395 -33.85374968,151.18596723 -33.85457753,151.18594271 -33.854742,151.1860377 -33.85476487,151.18569014 -33.85498534,151.18515941 -33.85464942,151.18481299 -33.85462981,151.18433623 -33.85397179,151.18418711 -33.85394107,151.18279125 -33.85465935,151.18288381 -33.8547757,151.18273565 -33.85485813,151.18263573 -33.85473058,151.18247432 -33.85482159,151.18236497 -33.85468442,151.18182783 -33.85507189,151.18163194 -33.85484523,151.18179702 -33.85509033,151.18095598 -33.85553991,151.1806061 -33.85530431,151.18072799 -33.85523191,151.18057018 -33.85504907,151.18047698 -33.85510386,151.18024687 -33.85496532,151.18004059 -33.85508669,151.17992892 -33.85504022,151.18008374 -33.85494999,151.1799324 -33.85475283,151.17975564 -33.85485334,151.17960694 -33.85466363,151.17934972 -33.8548113,151.17923626 -33.85476768,151.1793722 -33.85468909,151.17923305 -33.85451707,151.17897699 -33.85466425,151.17869385 -33.85451408,151.17888675 -33.85429772,151.17861349 -33.85396523,151.17831976 -33.8541354,151.17825035 -33.85406584,151.17863944 -33.85383985,151.17851529 -33.85368774,151.17829404 -33.85380574,151.17817861 -33.85364202,151.17794364 -33.85375775,151.17787658 -33.85368893,151.17813638 -33.85355944,151.17789714 -33.8532749,151.17760962 -33.85341627,151.17752868 -33.85329725,151.17703513 -33.85355284,151.17635201 -33.85292177,151.17664705 -33.85268229,151.17653708 -33.85258046,151.17610434 -33.85293458,151.17561437 -33.85248505,151.17755075 -33.85175451,151.17786913 -33.8515087,151.17790808 -33.85130797,151.17886167 -33.8498608,151.17898189 -33.84984837,151.17943716 -33.84935868,151.17939403 -33.84932594,151.17949738 -33.84929365,151.1796185 -33.84913311,151.17957397 -33.84910821,151.18030812 -33.84831506,151.18037655 -33.8483303,151.18078044 -33.8477586,151.18125515 -33.8477006,151.18131013 -33.8478212,151.18143686 -33.84770508,151.18164166 -33.84772524,151.18167182 -33.84784314,151.1826884 -33.8476568,151.18364305 -33.84723942,151.1837356 -33.84729306,151.18391502 -33.8471234,151.18402239 -33.84715291,151.18432684 -33.84703225,151.18424258 -33.8469065,151.18454738 -33.84665859,151.18467392 -33.84671318,151.1849512 -33.84646297,151.18489575 -33.84632727,151.18504819 -33.84615146,151.18513899 -33.84618889,151.18510369 -33.84612146,151.18548351 -33.84598785))) 2 | -------------------------------------------------------------------------------- /geos/testdata/coverage_simplify_output.wkt: -------------------------------------------------------------------------------- 1 | GEOMETRYCOLLECTION(MULTIPOLYGON(((151.18515941 -33.85464942,151.18481299 -33.85462981,151.18279125 -33.85465935,151.18095598 -33.85553991,151.17789714 -33.8532749,151.17610434 -33.85293458,151.18078044 -33.8477586,151.1826884 -33.8476568,151.18548351 -33.84598785,151.18690883 -33.84682487,151.18243728 -33.8483085,151.18800725 -33.85179123,151.19056762 -33.85180017,151.18831799 -33.85326861,151.18551016 -33.85286741,151.18458915 -33.85350098,151.1860377 -33.85476487,151.18569014 -33.85498534,151.18515941 -33.85464942))),MULTIPOLYGON(((151.17610434 -33.85293458,151.17789714 -33.8532749,151.18095598 -33.85553991,151.18279125 -33.85465935,151.18425398 -33.85393118,151.18481299 -33.85462981,151.18515941 -33.85464942,151.18689314 -33.85505367,151.18874913 -33.85675219,151.18845465 -33.85805513,151.19188407 -33.85815769,151.19272075 -33.8596754,151.19077421 -33.85873108,151.19122106 -33.86121966,151.18680314 -33.86136574,151.18164151 -33.8638001,151.17952829 -33.86369808,151.17913111 -33.8622604,151.17650112 -33.86281741,151.17444549 -33.86208818,151.17229531 -33.86032,151.17259684 -33.85841767,151.16935085 -33.85729849,151.16872679 -33.85496086,151.17077305 -33.85497744,151.1712631 -33.85286008,151.17363463 -33.85357628,151.17610434 -33.85293458)))) 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/peterstace/simplefeatures 2 | 3 | go 1.17 4 | 5 | retract v0.45.0 // Due to bug: https://github.com/peterstace/simplefeatures/pull/554 6 | 7 | require github.com/lib/pq v1.1.1 8 | 9 | require golang.org/x/image v0.23.0 // indirect 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= 2 | github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 3 | golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= 4 | golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= 5 | -------------------------------------------------------------------------------- /internal/benchmarkreport/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/benchmarkreport/.gitignore -------------------------------------------------------------------------------- /internal/benchmarkreport/README.md: -------------------------------------------------------------------------------- 1 | # Benchmark Reports 2 | 3 | This report shows the performance difference between Simple Feature's native 4 | set operations (pure Go) and the corresponding GEOS set operations. 5 | 6 | To re-run the reports, use the `run.sh` script (it generates the markdown 7 | tables below). 8 | 9 | The source code for the benchmarks below is [here](../perf). The benchmarks 10 | create two regular polygons, each with `n` sides (where `n` is the input size 11 | in the tables below). The polygons partially overlap with each other. The set 12 | operation on the two regular polygons is what is actually timed. 13 | 14 | **Operation:** Intersection 15 | 16 | | Input Size | Simple Features | GEOS | Ratio | 17 | | --- | --- | --- | --- | 18 | | 22 | 39.2µs | 46.7µs | 0.8 | 19 | | 23 | 48.1µs | 54.3µs | 0.9 | 20 | | 24 | 65.2µs | 59.5µs | 1.1 | 21 | | 25 | 113µs | 72.1µs | 1.6 | 22 | | 26 | 190µs | 94.9µs | 2.0 | 23 | | 27 | 354µs | 144µs | 2.5 | 24 | | 28 | 647µs | 215µs | 3.0 | 25 | | 29 | 1.28ms | 385µs | 3.3 | 26 | | 210 | 2.47ms | 718µs | 3.4 | 27 | | 211 | 5.36ms | 1.46ms | 3.7 | 28 | | 212 | 11.1ms | 2.66ms | 4.2 | 29 | | 213 | 22.1ms | 5.73ms | 3.9 | 30 | | 214 | 44.7ms | 11.6ms | 3.9 | 31 | 32 | **Operation:** Union 33 | 34 | | Input Size | Simple Features | GEOS | Ratio | 35 | | --- | --- | --- | --- | 36 | | 22 | 40.8µs | 49.6µs | 0.8 | 37 | | 23 | 50.2µs | 55.6µs | 0.9 | 38 | | 24 | 72.3µs | 67.5µs | 1.1 | 39 | | 25 | 122µs | 86µs | 1.4 | 40 | | 26 | 215µs | 127µs | 1.7 | 41 | | 27 | 390µs | 190µs | 2.1 | 42 | | 28 | 729µs | 318µs | 2.3 | 43 | | 29 | 1.42ms | 574µs | 2.5 | 44 | | 210 | 2.83ms | 1.19ms | 2.4 | 45 | | 211 | 5.99ms | 2.19ms | 2.7 | 46 | | 212 | 12.7ms | 4.7ms | 2.7 | 47 | | 213 | 25.9ms | 9.42ms | 2.7 | 48 | | 214 | 55ms | 20.3ms | 2.7 | 49 | 50 | **Operation:** Difference 51 | 52 | | Input Size | Simple Features | GEOS | Ratio | 53 | | --- | --- | --- | --- | 54 | | 22 | 40.1µs | 48.9µs | 0.8 | 55 | | 23 | 48µs | 55.6µs | 0.9 | 56 | | 24 | 68.7µs | 64.8µs | 1.1 | 57 | | 25 | 117µs | 79.9µs | 1.5 | 58 | | 26 | 203µs | 116µs | 1.7 | 59 | | 27 | 370µs | 172µs | 2.1 | 60 | | 28 | 691µs | 281µs | 2.5 | 61 | | 29 | 1.37ms | 512µs | 2.7 | 62 | | 210 | 2.66ms | 1.03ms | 2.6 | 63 | | 211 | 5.63ms | 1.85ms | 3.0 | 64 | | 212 | 12ms | 4.02ms | 3.0 | 65 | | 213 | 25.1ms | 8.09ms | 3.1 | 66 | | 214 | 53.2ms | 17.2ms | 3.1 | 67 | 68 | **Operation:** SymmetricDifference 69 | 70 | | Input Size | Simple Features | GEOS | Ratio | 71 | | --- | --- | --- | --- | 72 | | 22 | 51.5µs | 68.2µs | 0.8 | 73 | | 23 | 63.9µs | 79.4µs | 0.8 | 74 | | 24 | 98.8µs | 102µs | 1.0 | 75 | | 25 | 161µs | 137µs | 1.2 | 76 | | 26 | 286µs | 210µs | 1.4 | 77 | | 27 | 512µs | 335µs | 1.5 | 78 | | 28 | 1ms | 618µs | 1.6 | 79 | | 29 | 1.91ms | 1.15ms | 1.7 | 80 | | 210 | 3.93ms | 2.32ms | 1.7 | 81 | | 211 | 8.06ms | 4.46ms | 1.8 | 82 | | 212 | 17.1ms | 9.86ms | 1.7 | 83 | | 213 | 34.1ms | 19.5ms | 1.7 | 84 | | 214 | 71ms | 38.3ms | 1.9 | 85 | -------------------------------------------------------------------------------- /internal/benchmarkreport/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "sort" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | func main() { 14 | if len(os.Args) != 2 { 15 | log.Fatalf("usage: %v \n", os.Args[0]) 16 | } 17 | buf, err := os.ReadFile(os.Args[1]) 18 | if err != nil { 19 | log.Fatalf("could not read file: %v", err) 20 | } 21 | lines := strings.Split(string(buf), "\n") 22 | 23 | medians := extractMedians(lines) 24 | structured := structureStats(medians) 25 | showTable(structured) 26 | } 27 | 28 | type benchmark struct { 29 | isGEOS bool 30 | inputSize int 31 | op string 32 | } 33 | 34 | func structureStats(medians map[string]time.Duration) map[benchmark]time.Duration { 35 | benches := make(map[benchmark]time.Duration) 36 | for name, median := range medians { 37 | slashParts := strings.Split(name, "/") 38 | eqParts := strings.Split(slashParts[1], "=") 39 | inputSize, err := strconv.Atoi(eqParts[1]) 40 | if err != nil { 41 | panic(err) 42 | } 43 | opWithPrefix := strings.Split(slashParts[2], "-")[0] 44 | opParts := strings.Split(opWithPrefix, "_") 45 | isGEOS := opParts[0] == "GEOS" 46 | opName := opParts[1] 47 | 48 | benches[benchmark{ 49 | isGEOS: isGEOS, 50 | inputSize: inputSize, 51 | op: opName, 52 | }] = median 53 | } 54 | return benches 55 | } 56 | 57 | func extractMedians(lines []string) map[string]time.Duration { 58 | stats := make(map[string][]time.Duration) 59 | for _, line := range lines { 60 | if !strings.HasPrefix(line, "Benchmark") { 61 | continue 62 | } 63 | parts := strings.Split(line, "\t") 64 | for i := range parts { 65 | parts[i] = strings.TrimSpace(parts[i]) 66 | } 67 | if len(parts) != 3 { 68 | panic(line) 69 | } 70 | 71 | name := parts[0] 72 | nsPerOp, err := strconv.Atoi(strings.Split(parts[2], " ")[0]) 73 | if err != nil { 74 | panic(err) 75 | } 76 | stats[name] = append(stats[name], time.Duration(nsPerOp)) 77 | } 78 | 79 | medians := make(map[string]time.Duration) 80 | for name, results := range stats { 81 | sort.Slice(results, func(i, j int) bool { return results[i] < results[j] }) 82 | if n := len(results); n%2 == 0 { 83 | r1 := results[n/2] 84 | r2 := results[n/2-1] 85 | medians[name] = (r1 + r2) / 2 86 | } else { 87 | medians[name] = results[(n-1)/2] 88 | } 89 | } 90 | return medians 91 | } 92 | 93 | func showTable(benches map[benchmark]time.Duration) { 94 | for _, op := range []string{ 95 | "Intersection", 96 | "Union", 97 | "Difference", 98 | "SymmetricDifference", 99 | } { 100 | fmt.Println() 101 | fmt.Printf("**Operation:** %v\n", op) 102 | fmt.Println() 103 | fmt.Println("| Input Size | Simple Features | GEOS | Ratio |") 104 | fmt.Println("| --- | --- | --- | --- |") 105 | 106 | for i := 2; i <= 14; i++ { 107 | n := 1 << i 108 | sf := benches[benchmark{false, n, op}] 109 | ge := benches[benchmark{true, n, op}] 110 | fmt.Printf( 111 | "| 2%d | %s | %s | %.1f |\n", 112 | i, roundDuration(sf), roundDuration(ge), 113 | float64(sf)/float64(ge), 114 | ) 115 | } 116 | } 117 | } 118 | 119 | func roundDuration(d time.Duration) time.Duration { 120 | round := time.Nanosecond 121 | for round < d { 122 | round *= 10 123 | } 124 | round /= 1000 125 | return d.Round(round) 126 | } 127 | -------------------------------------------------------------------------------- /internal/benchmarkreport/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | here="$(dirname "$(readlink -f "$0")")" 6 | 7 | tmp="$(mktemp)" 8 | 9 | for ((i = 0; i < 50; i++)); do 10 | go test \ 11 | github.com/peterstace/simplefeatures/internal/perf \ 12 | -run=^\$ -bench=SetOperation \ 13 | -benchtime 0.1s 14 | done | tee "$tmp" 15 | 16 | go run "$here/main.go" "$tmp" 17 | -------------------------------------------------------------------------------- /internal/cartodemo/rasterize/docs.go: -------------------------------------------------------------------------------- 1 | // Package rasterize rasterizes simplefeatures geometries to raster images. 2 | // 3 | // It is currently experimental/unfinished, so is internal only. 4 | package rasterize 5 | -------------------------------------------------------------------------------- /internal/cartodemo/rasterize/draw_test.go: -------------------------------------------------------------------------------- 1 | package rasterize_test 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "os" 7 | "testing" 8 | 9 | "github.com/peterstace/simplefeatures/geom" 10 | "github.com/peterstace/simplefeatures/internal/cartodemo/rasterize" 11 | ) 12 | 13 | func TestDrawLine(t *testing.T) { 14 | g, err := geom.UnmarshalWKT("LINESTRING(4 4, 12 8, 4 12)") 15 | expectNoErr(t, err) 16 | 17 | img := image.NewRGBA(image.Rect(0, 0, 16, 16)) 18 | rast := rasterize.NewRasterizer(16, 16) 19 | rast.LineString(g.MustAsLineString()) 20 | rast.Draw(img, img.Bounds(), image.NewUniform(color.Black), image.Point{}) 21 | 22 | err = os.WriteFile("testdata/line.png", imageToPNG(t, img), 0o600) 23 | expectNoErr(t, err) 24 | } 25 | -------------------------------------------------------------------------------- /internal/cartodemo/rasterize/rasterizer.go: -------------------------------------------------------------------------------- 1 | package rasterize 2 | 3 | import ( 4 | "image" 5 | "image/draw" 6 | 7 | "github.com/peterstace/simplefeatures/geom" 8 | "golang.org/x/image/vector" 9 | ) 10 | 11 | type Rasterizer struct { 12 | rast *vector.Rasterizer 13 | } 14 | 15 | func NewRasterizer(widthPx, heightPx int) *Rasterizer { 16 | return &Rasterizer{rast: vector.NewRasterizer(widthPx, heightPx)} 17 | } 18 | 19 | func (r *Rasterizer) Reset() { 20 | b := r.rast.Bounds() 21 | r.rast.Reset(b.Dx(), b.Dy()) 22 | } 23 | 24 | func (r *Rasterizer) Draw(dst draw.Image, rec image.Rectangle, src image.Image, sp image.Point) { 25 | r.rast.Draw(dst, rec, src, sp) 26 | } 27 | 28 | func (r *Rasterizer) LineString(ls geom.LineString) { 29 | const strokeWidth = 1.0 // TODO: Make stroke width configurable. 30 | 31 | seq := ls.Coordinates() 32 | for i := 0; i+1 < seq.Length(); i++ { 33 | p0 := seq.GetXY(i) 34 | p1 := seq.GetXY(i + 1) 35 | if p0 == p1 { 36 | continue 37 | } 38 | 39 | // TODO: This is a pretty basic/stupid way to draw a line. Consider 40 | // something more accurate, and possibly faster. 41 | mainAxis := p1.Sub(p0) 42 | sideAxis := rotateCCW90(mainAxis).Scale(0.5 * strokeWidth / mainAxis.Length()) 43 | 44 | v0 := p0.Add(sideAxis) 45 | v1 := p1.Add(sideAxis) 46 | v2 := p1.Sub(sideAxis) 47 | v3 := p0.Sub(sideAxis) 48 | 49 | r.rast.MoveTo(float32(v0.X), float32(v0.Y)) 50 | r.rast.LineTo(float32(v1.X), float32(v1.Y)) 51 | r.rast.LineTo(float32(v2.X), float32(v2.Y)) 52 | r.rast.LineTo(float32(v3.X), float32(v3.Y)) 53 | r.rast.ClosePath() 54 | } 55 | } 56 | 57 | func (r *Rasterizer) MultiLineString(mls geom.MultiLineString) { 58 | for _, ls := range mls.Dump() { 59 | r.LineString(ls) 60 | } 61 | } 62 | 63 | func (r *Rasterizer) Polygon(p geom.Polygon) { 64 | // TODO: Support holes. 65 | ext := p.ExteriorRing() 66 | seq := ext.Coordinates() 67 | n := seq.Length() 68 | if n == 0 { 69 | return 70 | } 71 | r.moveTo(seq.GetXY(0)) 72 | for i := 1; i < n; i++ { 73 | r.lineTo(seq.GetXY(i)) 74 | } 75 | r.rast.ClosePath() // Usually not necessary, but just in case. 76 | } 77 | 78 | func (r *Rasterizer) MultiPolygon(mp geom.MultiPolygon) { 79 | for _, p := range mp.Dump() { 80 | r.Polygon(p) 81 | } 82 | } 83 | 84 | func (r *Rasterizer) moveTo(pt geom.XY) { 85 | r.rast.MoveTo(float32(pt.X), float32(pt.Y)) 86 | } 87 | 88 | func (r *Rasterizer) lineTo(pt geom.XY) { 89 | r.rast.LineTo(float32(pt.X), float32(pt.Y)) 90 | } 91 | 92 | // TODO: This is duplicated from geom/xy.go. Could be a better solution? 93 | func rotateCCW90(v geom.XY) geom.XY { 94 | return geom.XY{X: -v.Y, Y: v.X} 95 | } 96 | -------------------------------------------------------------------------------- /internal/cartodemo/rasterize/rasterizer_test.go: -------------------------------------------------------------------------------- 1 | package rasterize_test 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "os" 7 | "testing" 8 | 9 | "github.com/peterstace/simplefeatures/geom" 10 | "github.com/peterstace/simplefeatures/internal/cartodemo/rasterize" 11 | ) 12 | 13 | func TestRasterizer(t *testing.T) { 14 | const sz = 16 15 | rast := rasterize.NewRasterizer(sz, sz) 16 | 17 | ls, err := geom.UnmarshalWKT("LINESTRING(4 4, 12 8, 4 12)") 18 | expectNoErr(t, err) 19 | rast.LineString(ls.MustAsLineString()) 20 | 21 | img := image.NewRGBA(image.Rect(0, 0, sz, sz)) 22 | rast.Draw(img, img.Bounds(), image.NewUniform(color.Black), image.Point{}) 23 | 24 | err = os.WriteFile("testdata/line.png", imageToPNG(t, img), 0o600) 25 | expectNoErr(t, err) 26 | } 27 | -------------------------------------------------------------------------------- /internal/cartodemo/rasterize/testdata/line.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/rasterize/testdata/line.png -------------------------------------------------------------------------------- /internal/cartodemo/rasterize/util_test.go: -------------------------------------------------------------------------------- 1 | package rasterize_test 2 | 3 | import ( 4 | "bytes" 5 | "image" 6 | "image/png" 7 | "testing" 8 | ) 9 | 10 | func expectNoErr(tb testing.TB, err error) { 11 | tb.Helper() 12 | if err != nil { 13 | tb.Fatalf("unexpected error: %v", err) 14 | } 15 | } 16 | 17 | func imageToPNG(t *testing.T, img image.Image) []byte { 18 | t.Helper() 19 | buf := new(bytes.Buffer) 20 | err := png.Encode(buf, img) 21 | expectNoErr(t, err) 22 | return buf.Bytes() 23 | } 24 | -------------------------------------------------------------------------------- /internal/cartodemo/testdata/.gitignore: -------------------------------------------------------------------------------- 1 | *.png 2 | -------------------------------------------------------------------------------- /internal/cartodemo/testdata/albers_equal_area_conic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/albers_equal_area_conic.png -------------------------------------------------------------------------------- /internal/cartodemo/testdata/azimuthal_equidistant_sydney.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/azimuthal_equidistant_sydney.png -------------------------------------------------------------------------------- /internal/cartodemo/testdata/equidistant_conic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/equidistant_conic.png -------------------------------------------------------------------------------- /internal/cartodemo/testdata/lambert_conformal_conic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/lambert_conformal_conic.png -------------------------------------------------------------------------------- /internal/cartodemo/testdata/lambert_cylindrical_equal_area.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/lambert_cylindrical_equal_area.png -------------------------------------------------------------------------------- /internal/cartodemo/testdata/marinus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/marinus.png -------------------------------------------------------------------------------- /internal/cartodemo/testdata/ne_50m_antarctic_ice_shelves_polys.geojson.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/ne_50m_antarctic_ice_shelves_polys.geojson.gz -------------------------------------------------------------------------------- /internal/cartodemo/testdata/ne_50m_glaciated_areas.geojson.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/ne_50m_glaciated_areas.geojson.gz -------------------------------------------------------------------------------- /internal/cartodemo/testdata/ne_50m_lakes.geojson.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/ne_50m_lakes.geojson.gz -------------------------------------------------------------------------------- /internal/cartodemo/testdata/ne_50m_land.geojson.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/ne_50m_land.geojson.gz -------------------------------------------------------------------------------- /internal/cartodemo/testdata/orthographic_north_america.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/orthographic_north_america.png -------------------------------------------------------------------------------- /internal/cartodemo/testdata/sinusoidal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/sinusoidal.png -------------------------------------------------------------------------------- /internal/cartodemo/testdata/web_mercator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterstace/simplefeatures/894f9b666fdd2b86ee03873bbed098e0e251cfa1/internal/cartodemo/testdata/web_mercator.png -------------------------------------------------------------------------------- /internal/cmprefimpl/cmpgeos/extract_source.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "go/ast" 7 | "go/parser" 8 | "go/token" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/peterstace/simplefeatures/geom" 16 | ) 17 | 18 | func extractStringsFromSource(dir string) ([]string, error) { 19 | var strs []string 20 | if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 21 | if err != nil { 22 | return err 23 | } 24 | if !info.IsDir() || strings.Contains(path, ".git") { 25 | return nil 26 | } 27 | pkgs, err := parser.ParseDir(new(token.FileSet), path, nil, 0) 28 | if err != nil { 29 | return err 30 | } 31 | for _, pkg := range pkgs { 32 | ast.Inspect(pkg, func(n ast.Node) bool { 33 | lit, ok := n.(*ast.BasicLit) 34 | if !ok || lit.Kind != token.STRING { 35 | return true 36 | } 37 | unquoted, err := strconv.Unquote(lit.Value) 38 | if !ok { 39 | // Shouldn't ever happen because we've validated that it's a string literal. 40 | panic(fmt.Sprintf("could not unquote string '%s'from ast: %v", lit.Value, err)) 41 | } 42 | strs = append(strs, unquoted) 43 | return true 44 | }) 45 | } 46 | return nil 47 | }); err != nil { 48 | return nil, err 49 | } 50 | 51 | strSet := map[string]struct{}{} 52 | for _, s := range strs { 53 | strSet[strings.TrimSpace(s)] = struct{}{} 54 | } 55 | strs = strs[:0] 56 | for s := range strSet { 57 | strs = append(strs, s) 58 | } 59 | sort.Strings(strs) 60 | return strs, nil 61 | } 62 | 63 | func convertToGeometries(candidates []string) ([]geom.Geometry, error) { 64 | var geoms []geom.Geometry 65 | for _, c := range candidates { 66 | g, err := geom.UnmarshalWKT(c, geom.NoValidate{}) 67 | if err == nil { 68 | geoms = append(geoms, g) 69 | } 70 | } 71 | if len(geoms) == 0 { 72 | return nil, errors.New("could not extract any WKT geoms") 73 | } 74 | 75 | oldCount := len(geoms) 76 | for _, c := range candidates { 77 | buf, err := hexStringToBytes(c) 78 | if err != nil { 79 | continue 80 | } 81 | g, err := geom.UnmarshalWKB(buf, geom.NoValidate{}) 82 | if err == nil { 83 | geoms = append(geoms, g) 84 | } 85 | } 86 | if oldCount == len(geoms) { 87 | return nil, errors.New("could not extract any WKB geoms") 88 | } 89 | 90 | oldCount = len(geoms) 91 | for _, c := range candidates { 92 | g, err := geom.UnmarshalGeoJSON([]byte(c), geom.NoValidate{}) 93 | if err == nil { 94 | geoms = append(geoms, g) 95 | } 96 | } 97 | if oldCount == len(geoms) { 98 | return nil, errors.New("could not extract any geojson geoms") 99 | } 100 | 101 | return geoms, nil 102 | } 103 | 104 | func hexStringToBytes(s string) ([]byte, error) { 105 | if len(s)%2 != 0 { 106 | return nil, errors.New("hex string must have even length") 107 | } 108 | var buf []byte 109 | for i := 0; i < len(s); i += 2 { 110 | x, err := strconv.ParseUint(s[i:i+2], 16, 8) 111 | if err != nil { 112 | return nil, err 113 | } 114 | buf = append(buf, byte(x)) 115 | } 116 | return buf, nil 117 | } 118 | -------------------------------------------------------------------------------- /internal/cmprefimpl/cmpgeos/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/peterstace/simplefeatures/geom" 13 | ) 14 | 15 | // TODO: These are additional geometries. Needs something a bit more robust... 16 | const ( 17 | _ = "GEOMETRYCOLLECTION(GEOMETRYCOLLECTION(POINT EMPTY,POINT(1 2)))" 18 | _ = "MULTIPOINT((1 2),(2 3),EMPTY)" 19 | _ = "GEOMETRYCOLLECTION(GEOMETRYCOLLECTION EMPTY)" 20 | ) 21 | 22 | func main() { 23 | dir, err := os.Getwd() 24 | if err != nil { 25 | log.Fatalf("could not get working dir: %v", err) 26 | } 27 | candidates, err := extractStringsFromSource(dir) 28 | if err != nil { 29 | log.Fatalf("could not extract strings from src: %v", err) 30 | } 31 | 32 | geoms, err := convertToGeometries(candidates) 33 | if err != nil { 34 | log.Fatalf("could not convert to geometries: %v", err) 35 | } 36 | 37 | forceTo2D(geoms) 38 | geoms = deduplicateGeometries(geoms) 39 | 40 | { 41 | var buf bytes.Buffer 42 | lg := log.New(&buf, "", log.Lshortfile) 43 | if err := checkRelateMatch(lg); err != nil { 44 | fmt.Printf("Check failed: %v\n", err) 45 | io.Copy(os.Stdout, &buf) 46 | fmt.Println() 47 | os.Exit(1) 48 | } 49 | } 50 | 51 | var failures int 52 | for _, g := range geoms { 53 | var buf bytes.Buffer 54 | lg := log.New(&buf, "", log.Lshortfile) 55 | lg.Printf("========================== START ===========================") 56 | lg.Printf("WKT: %v", g.AsText()) 57 | err := unaryChecks(g, lg) 58 | lg.Printf("=========================== END ============================") 59 | if err != nil { 60 | fmt.Printf("Check failed: %v\n", err) 61 | io.Copy(os.Stdout, &buf) 62 | fmt.Println() 63 | failures++ 64 | } 65 | } 66 | fmt.Printf("finished unary checks on %d geometries\n", len(geoms)) 67 | fmt.Printf("failures: %d\n", failures) 68 | if failures > 0 { 69 | os.Exit(1) 70 | } 71 | 72 | var skipped, tested int 73 | var lastPct int 74 | for i, g1 := range geoms { 75 | if newPct := int(float64(100*i) / float64(len(geoms))); newPct > lastPct { 76 | lastPct = newPct 77 | fmt.Printf("%d%%\n", newPct) 78 | } 79 | 80 | // Non-empty GeometryCollections are not supported for binary operations by libgeos. 81 | if g1.IsGeometryCollection() && !g1.IsEmpty() { 82 | skipped += len(geoms) 83 | continue 84 | } 85 | for _, g2 := range geoms { 86 | if g2.IsGeometryCollection() && !g2.IsEmpty() { 87 | skipped++ 88 | continue 89 | } 90 | tested++ 91 | var buf bytes.Buffer 92 | lg := log.New(&buf, "", log.Lshortfile) 93 | lg.Printf("========================== START ===========================") 94 | lg.Printf("WKT1: %v", g1.AsText()) 95 | lg.Printf("WKT2: %v", g2.AsText()) 96 | err := binaryChecks(g1, g2, lg) 97 | lg.Printf("=========================== END ============================") 98 | if err != nil { 99 | if strings.HasPrefix(err.Error(), "TopologyException") { 100 | fmt.Printf("WARNING: Ignoring TopologyException error: %v\n", err) 101 | } else { 102 | fmt.Printf("Check failed: %v\n", err) 103 | io.Copy(os.Stdout, &buf) 104 | fmt.Println() 105 | failures++ 106 | } 107 | } 108 | } 109 | } 110 | fmt.Printf("total binary combinations: %d\n", len(geoms)*len(geoms)) 111 | fmt.Printf("tested combinations: %d\n", tested) 112 | fmt.Printf("skipped combinations: %d\n", skipped) 113 | fmt.Printf("failures: %d\n", failures) 114 | 115 | if failures > 0 { 116 | os.Exit(1) 117 | } 118 | } 119 | 120 | func deduplicateGeometries(geoms []geom.Geometry) []geom.Geometry { 121 | m := map[string]geom.Geometry{} 122 | for _, g := range geoms { 123 | m[g.AsText()] = g 124 | } 125 | geoms = geoms[:0] 126 | for _, g := range m { 127 | geoms = append(geoms, g) 128 | } 129 | sort.Slice(geoms, func(i, j int) bool { 130 | return geoms[i].AsText() < geoms[j].AsText() 131 | }) 132 | return geoms 133 | } 134 | 135 | // forceTo2D converts all geometries to 2D geometries, dropping any Z and M 136 | // values. This is done because the C bindings for libgeos don't fully support 137 | // Z and M value. 138 | func forceTo2D(geoms []geom.Geometry) { 139 | for i := range geoms { 140 | geoms[i] = geoms[i].Force2D() 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /internal/cmprefimpl/cmpgeos/util_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "testing" 7 | 8 | "github.com/peterstace/simplefeatures/geom" 9 | ) 10 | 11 | func TestMantissaTerminatesQuickly(t *testing.T) { 12 | // Test mantissaTerminatesQuickly function, since it's fairly complicated 13 | // internally. 14 | for _, tt := range []struct { 15 | f float64 16 | want bool 17 | }{ 18 | {1.0, true}, 19 | {1.5, true}, 20 | {53, true}, 21 | {100, true}, 22 | {0.1, false}, 23 | {-0.1, false}, 24 | {0.7, false}, 25 | {math.Pi, false}, 26 | } { 27 | t.Run(fmt.Sprintf("%v", tt.f), func(t *testing.T) { 28 | pt := geom.XY{X: tt.f, Y: tt.f}.AsPoint() 29 | got := mantissaTerminatesQuickly(pt.AsGeometry()) 30 | if got != tt.want { 31 | t.Errorf("got=%v want=%v", got, tt.want) 32 | } 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/cmprefimpl/cmppg/postgis.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | // PostGIS is a DB access type allowing non-batch based interactions 8 | // with a PostGIS database. 9 | type PostGIS struct { 10 | db *sql.DB 11 | } 12 | 13 | // WKTIsValidWithReason checks if a WKT is valid, and if not gives the reason. 14 | func (p PostGIS) WKTIsValidWithReason(wkt string) (bool, string) { 15 | var isValid bool 16 | var reason string 17 | err := p.db.QueryRow(` 18 | SELECT 19 | ST_IsValid(ST_GeomFromText($1)), 20 | ST_IsValidReason(ST_GeomFromText($1))`, 21 | wkt, 22 | ).Scan(&isValid, &reason) 23 | if err != nil { 24 | // It's not possible to distinguish between problems with the geometry 25 | // and problems with the database except by string-matching. It's 26 | // better to just report all errors, even if this means there will be 27 | // some false errors in the case of connectivity problems (or similar). 28 | return false, err.Error() 29 | } 30 | return isValid, reason 31 | } 32 | 33 | // WKBIsValidWithReason checks if a WKB is valid and, if not, gives the reason. 34 | // It's unable to differentiate between errors due to problems with the WKB and 35 | // general problems with the database. 36 | func (p PostGIS) WKBIsValidWithReason(wkb string) (bool, string) { 37 | var isValid bool 38 | err := p.db.QueryRow(`SELECT ST_IsValid(ST_GeomFromWKB(decode($1, 'hex')))`, wkb).Scan(&isValid) 39 | if err != nil { 40 | return false, err.Error() 41 | } 42 | var reason string 43 | err = p.db.QueryRow(`SELECT ST_IsValidReason(ST_GeomFromWKB(decode($1, 'hex')))`, wkb).Scan(&reason) 44 | if err != nil { 45 | return false, err.Error() 46 | } 47 | return isValid, reason 48 | } 49 | 50 | // WKBIsValidWithReason checks if a GeoJSON value is valid and, if not, gives 51 | // the reason. It's unable to differentiate between errors due to problems 52 | // with the GeoJSON value and general problems with the database. 53 | func (p PostGIS) GeoJSONIsValidWithReason(geojson string) (bool, string) { 54 | var isValid bool 55 | err := p.db.QueryRow(`SELECT ST_IsValid(ST_GeomFromGeoJSON($1))`, geojson).Scan(&isValid) 56 | if err != nil { 57 | return false, err.Error() 58 | } 59 | var reason string 60 | err = p.db.QueryRow(`SELECT ST_IsValidReason(ST_GeomFromGeoJSON($1))`, geojson).Scan(&reason) 61 | if err != nil { 62 | return false, err.Error() 63 | } 64 | return isValid, reason 65 | } 66 | -------------------------------------------------------------------------------- /internal/perf/linestring_issimple_test.go: -------------------------------------------------------------------------------- 1 | package perf_test 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/peterstace/simplefeatures/geom" 9 | ) 10 | 11 | var dummyBool bool 12 | 13 | func BenchmarkLineStringIsSimpleCircle(b *testing.B) { 14 | for _, sz := range []int{10, 100, 1000, 10000} { 15 | b.Run(fmt.Sprintf("n=%d", sz), func(b *testing.B) { 16 | circ := regularPolygon(geom.XY{}, 1.0, sz) 17 | ring := circ.ExteriorRing() 18 | b.ResetTimer() 19 | for i := 0; i < b.N; i++ { 20 | dummyBool = ring.IsSimple() 21 | } 22 | }) 23 | } 24 | } 25 | 26 | func BenchmarkLineStringIsSimpleZigZag(b *testing.B) { 27 | for _, sz := range []int{10, 100, 1000, 10000} { 28 | b.Run(strconv.Itoa(sz), func(b *testing.B) { 29 | floats := make([]float64, 2*sz) 30 | for i := 0; i < sz; i++ { 31 | floats[2*i+0] = float64(i%2) * 0.01 32 | floats[2*i+1] = float64(i) * 0.01 33 | } 34 | seq := geom.NewSequence(floats, geom.DimXY) 35 | ls := geom.NewLineString(seq) 36 | if err := ls.Validate(); err != nil { 37 | b.Fatal(err) 38 | } 39 | 40 | b.ResetTimer() 41 | for i := 0; i < b.N; i++ { 42 | if !ls.IsSimple() { 43 | b.Fatal("not simple") 44 | } 45 | } 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/perf/set_op_test.go: -------------------------------------------------------------------------------- 1 | // Package perf_test contains performance benchmarks that don't make sense to 2 | // include in any particular package (they may test code from multiple 3 | // packages). 4 | package perf_test 5 | 6 | import ( 7 | "fmt" 8 | "testing" 9 | 10 | "github.com/peterstace/simplefeatures/geom" 11 | "github.com/peterstace/simplefeatures/geos" 12 | ) 13 | 14 | func BenchmarkSetOperation(b *testing.B) { 15 | for sz := range []int{10, 100, 1000} { 16 | p1 := regularPolygon(geom.XY{X: 0, Y: 0}, 1.0, sz).AsGeometry() 17 | p2 := regularPolygon(geom.XY{X: 1, Y: 0}, 1.0, sz).AsGeometry() 18 | b.Run(fmt.Sprintf("n=%d", sz), func(b *testing.B) { 19 | for _, op := range []struct { 20 | name string 21 | fn func(geom.Geometry, geom.Geometry) (geom.Geometry, error) 22 | }{ 23 | {"Go_Intersection", geom.Intersection}, 24 | {"Go_Difference", geom.Difference}, 25 | {"Go_SymmetricDifference", geom.SymmetricDifference}, 26 | {"Go_Union", geom.Union}, 27 | {"GEOS_Intersection", geos.Intersection}, 28 | {"GEOS_Difference", geos.Difference}, 29 | {"GEOS_SymmetricDifference", geos.SymmetricDifference}, 30 | {"GEOS_Union", geos.Union}, 31 | } { 32 | b.Run(op.name, func(b *testing.B) { 33 | for i := 0; i < b.N; i++ { 34 | if _, err := op.fn(p1, p2); err != nil { 35 | b.Fatal(err) 36 | } 37 | } 38 | }) 39 | } 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/perf/util_test.go: -------------------------------------------------------------------------------- 1 | package perf_test 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/peterstace/simplefeatures/geom" 7 | ) 8 | 9 | // regularPolygon computes a regular polygon circumscribed by a circle with the 10 | // given center and radius. Sides must be at least 3 or it will panic. 11 | func regularPolygon(center geom.XY, radius float64, sides int) geom.Polygon { 12 | if sides <= 2 { 13 | panic(sides) 14 | } 15 | coords := make([]float64, 2*(sides+1)) 16 | for i := 0; i < sides; i++ { 17 | angle := math.Pi/2 + float64(i)/float64(sides)*2*math.Pi 18 | coords[2*i+0] = center.X + math.Cos(angle)*radius 19 | coords[2*i+1] = center.Y + math.Sin(angle)*radius 20 | } 21 | coords[2*sides+0] = coords[0] 22 | coords[2*sides+1] = coords[1] 23 | ring := geom.NewLineString(geom.NewSequence(coords, geom.DimXY)) 24 | return geom.NewPolygon([]geom.LineString{ring}) 25 | } 26 | -------------------------------------------------------------------------------- /internal/pgscan/pgscan_test.go: -------------------------------------------------------------------------------- 1 | package pgscan_test 2 | 3 | import ( 4 | "database/sql" 5 | "strconv" 6 | "testing" 7 | 8 | _ "github.com/lib/pq" 9 | "github.com/peterstace/simplefeatures/geom" 10 | ) 11 | 12 | func TestPostgresScan(t *testing.T) { 13 | const dbURL = "postgres://postgres:password@postgis:5432/postgres?sslmode=disable" 14 | db, err := sql.Open("postgres", dbURL) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | if err := db.Ping(); err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | for i, tc := range []struct { 23 | wkt string 24 | concrete interface{ AsText() string } 25 | }{ 26 | {"POINT(0 1)", new(geom.Point)}, 27 | {"MULTIPOINT((0 1))", new(geom.MultiPoint)}, 28 | {"LINESTRING(0 1,1 0)", new(geom.LineString)}, 29 | {"MULTILINESTRING((0 1,1 0))", new(geom.MultiLineString)}, 30 | {"POLYGON((0 0,1 0,0 1,0 0))", new(geom.Polygon)}, 31 | {"MULTIPOLYGON(((0 0,1 0,0 1,0 0)))", new(geom.MultiPolygon)}, 32 | {"GEOMETRYCOLLECTION(MULTIPOLYGON(((0 0,1 0,0 1,0 0))))", new(geom.GeometryCollection)}, 33 | } { 34 | t.Run(strconv.Itoa(i), func(t *testing.T) { 35 | if err := db.QueryRow( 36 | `SELECT ST_AsBinary(ST_GeomFromText($1))`, 37 | tc.wkt, 38 | ).Scan(tc.concrete); err != nil { 39 | t.Error(err) 40 | } 41 | if got := tc.concrete.AsText(); got != tc.wkt { 42 | t.Errorf("want=%v got=%v", tc.wkt, got) 43 | } 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/rawgeos/benchmark_internal_test.go: -------------------------------------------------------------------------------- 1 | package rawgeos 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "testing" 7 | 8 | "github.com/peterstace/simplefeatures/geom" 9 | ) 10 | 11 | // regularPolygon computes a regular polygon circumscribed by a circle with the 12 | // given center and radius. Sides must be at least 3 or it will panic. 13 | func regularPolygon(center geom.XY, radius float64, sides int) geom.Polygon { 14 | if sides <= 2 { 15 | panic(sides) 16 | } 17 | coords := make([]float64, 2*(sides+1)) 18 | for i := 0; i < sides; i++ { 19 | angle := math.Pi/2 + float64(i)/float64(sides)*2*math.Pi 20 | coords[2*i+0] = center.X + math.Cos(angle)*radius 21 | coords[2*i+1] = center.Y + math.Sin(angle)*radius 22 | } 23 | coords[2*sides+0] = coords[0] 24 | coords[2*sides+1] = coords[1] 25 | ring := geom.NewLineString(geom.NewSequence(coords, geom.DimXY)) 26 | return geom.NewPolygon([]geom.LineString{ring}) 27 | } 28 | 29 | func BenchmarkNoOp(b *testing.B) { 30 | for _, sz := range []int{10, 100, 1000, 10000} { 31 | b.Run(fmt.Sprintf("n=%d", sz), func(b *testing.B) { 32 | input := regularPolygon(geom.XY{X: 0, Y: 0}, 1.0, sz).AsGeometry() 33 | b.ResetTimer() 34 | 35 | for i := 0; i < b.N; i++ { 36 | _, err := noop(input) 37 | if err != nil { 38 | b.Fatal(err) 39 | } 40 | } 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/rawgeos/configure_hardcoded.go: -------------------------------------------------------------------------------- 1 | //go:build sfnopkgconfig 2 | 3 | package rawgeos 4 | 5 | /* 6 | #cgo LDFLAGS: -lgeos_c 7 | #cgo CFLAGS: -Wall 8 | */ 9 | import "C" 10 | -------------------------------------------------------------------------------- /internal/rawgeos/configure_pkg_config.go: -------------------------------------------------------------------------------- 1 | //go:build !sfnopkgconfig 2 | 3 | package rawgeos 4 | 5 | /* 6 | #cgo pkg-config: geos 7 | #cgo CFLAGS: -Wall 8 | */ 9 | import "C" 10 | -------------------------------------------------------------------------------- /internal/rawgeos/errors.go: -------------------------------------------------------------------------------- 1 | package rawgeos 2 | 3 | import "fmt" 4 | 5 | // #include "geos_c.h" 6 | import "C" 7 | 8 | func wrap(err error, format string, args ...interface{}) error { 9 | if err == nil { 10 | return nil 11 | } 12 | return fmt.Errorf(format+": %w", append(args, err)...) 13 | } 14 | 15 | var currentGEOSVersion = fmt.Sprintf( 16 | "%d.%d.%d", 17 | C.GEOS_VERSION_MAJOR, 18 | C.GEOS_VERSION_MINOR, 19 | C.GEOS_VERSION_PATCH, 20 | ) 21 | 22 | type UnsupportedGEOSVersionError struct { 23 | requiredGEOSVersion string 24 | operation string 25 | } 26 | 27 | func (e UnsupportedGEOSVersionError) Error() string { 28 | return fmt.Sprintf("%s is unsupported in GEOS %s, requires at least GEOS %s", 29 | e.operation, currentGEOSVersion, e.requiredGEOSVersion) 30 | } 31 | -------------------------------------------------------------------------------- /internal/rawgeos/errors_internal_test.go: -------------------------------------------------------------------------------- 1 | package rawgeos 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestWrapNil(t *testing.T) { 9 | if wrap(nil, "format") != nil { 10 | t.Fatalf("Expected nil but got error") 11 | } 12 | } 13 | 14 | func TestWrapNonNilNoArgs(t *testing.T) { 15 | original := errors.New("original error") 16 | got := wrap(original, "context").Error() 17 | const want = "context: original error" 18 | if got != want { 19 | t.Fatalf("got: %v want: %v", got, want) 20 | } 21 | } 22 | 23 | func TestWrapNonNilWithArgs(t *testing.T) { 24 | original := errors.New("original error") 25 | got := wrap(original, "context foo=%v bar=%v", "baz", 42).Error() 26 | const want = "context foo=baz bar=42: original error" 27 | if got != want { 28 | t.Fatalf("got: %v want: %v", got, want) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/rawgeos/helper.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include "geos_c.h" 3 | 4 | // marshal converts the supplied geometry to either WKB (prefered) or WKT 5 | // (fallback). The result is either a WKB or a WKT (or NULL, indicating that 6 | // something went wrong). The size param is set to the number of bytes in the 7 | // returned WKB or WKT. The isWKT param is either set to 0 (WKB) or 1 (WKT), 8 | // indicating the type of marshalling used. 9 | char *marshal( 10 | GEOSContextHandle_t handle, 11 | const GEOSGeometry *g, 12 | size_t *size, 13 | char *isWKT 14 | ) { 15 | // Try WKB first. This will fail if the geometry contains an empty Point. 16 | GEOSWKBWriter *wkbWriter = GEOSWKBWriter_create_r(handle); 17 | if (!wkbWriter) { 18 | return NULL; 19 | } 20 | unsigned char *wkb = GEOSWKBWriter_write_r(handle, wkbWriter, g, size); 21 | GEOSWKBWriter_destroy_r(handle, wkbWriter); 22 | if (wkb) { 23 | *isWKT = 0; 24 | return (char*)wkb; 25 | } 26 | 27 | // Try WKT. This should work for all geometries, but will be a bit slower. 28 | GEOSWKTWriter *wktWriter = GEOSWKTWriter_create_r(handle); 29 | if (!wktWriter) { 30 | return NULL; 31 | } 32 | char *wkt = GEOSWKTWriter_write_r(handle, wktWriter, g); 33 | GEOSWKTWriter_destroy_r(handle, wktWriter); 34 | if (wkt) { 35 | *size = strlen(wkt); 36 | *isWKT = 1; 37 | return wkt; 38 | } 39 | 40 | return NULL; 41 | } 42 | -------------------------------------------------------------------------------- /rtree/box.go: -------------------------------------------------------------------------------- 1 | package rtree 2 | 3 | // Box is an axis-aligned bounding box. 4 | type Box struct { 5 | MinX, MinY, MaxX, MaxY float64 6 | } 7 | 8 | // calculateBound calculates the smallest bounding box that fits a node. 9 | func calculateBound(n *node) Box { 10 | box := n.entries[0].box 11 | for i := 1; i < n.numEntries; i++ { 12 | box = combine(box, n.entries[i].box) 13 | } 14 | return box 15 | } 16 | 17 | // combine gives the smallest bounding box containing both box1 and box2. 18 | func combine(box1, box2 Box) Box { 19 | return Box{ 20 | MinX: fastMin(box1.MinX, box2.MinX), 21 | MinY: fastMin(box1.MinY, box2.MinY), 22 | MaxX: fastMax(box1.MaxX, box2.MaxX), 23 | MaxY: fastMax(box1.MaxY, box2.MaxY), 24 | } 25 | } 26 | 27 | func overlap(box1, box2 Box) bool { 28 | return true && 29 | (box1.MinX <= box2.MaxX) && (box1.MaxX >= box2.MinX) && 30 | (box1.MinY <= box2.MaxY) && (box1.MaxY >= box2.MinY) 31 | } 32 | 33 | func squaredEuclideanDistance(b1, b2 Box) float64 { 34 | dx := fastMax(0, fastMax(b1.MinX-b2.MaxX, b2.MinX-b1.MaxX)) 35 | dy := fastMax(0, fastMax(b1.MinY-b2.MaxY, b2.MinY-b1.MaxY)) 36 | return dx*dx + dy*dy 37 | } 38 | -------------------------------------------------------------------------------- /rtree/doc.go: -------------------------------------------------------------------------------- 1 | // Package rtree implements an in-memory r-tree data structure. This data 2 | // structure can be used as a spatial index, allowing fast spatial searches 3 | // based on a bounding box. 4 | // 5 | // The implementation is heavily based on "R-Trees. A Dynamic Index Structure 6 | // For Spatial Searching" by Antonin Guttman (which can be found at 7 | // http://www-db.deis.unibo.it/courses/SI-LS/papers/Gut84.pdf). 8 | package rtree 9 | -------------------------------------------------------------------------------- /rtree/nearest.go: -------------------------------------------------------------------------------- 1 | package rtree 2 | 3 | import ( 4 | "container/heap" 5 | "errors" 6 | ) 7 | 8 | // Nearest finds the record in the RTree that is the closest to the input box 9 | // as measured by the Euclidean metric. Note that there may be multiple records 10 | // that are equidistant from the input box, in which case one is chosen 11 | // arbitrarily. If the RTree is empty, then false is returned. 12 | func (t *RTree) Nearest(box Box) (recordID int, found bool) { 13 | t.PrioritySearch(box, func(rid int) error { 14 | recordID = rid 15 | found = true 16 | return Stop 17 | }) 18 | return recordID, found 19 | } 20 | 21 | // PrioritySearch iterates over the records in the RTree in priority order of 22 | // distance from the input box (shortest distance first using the Euclidean 23 | // metric). The callback is called for every element iterated over. If an 24 | // error is returned from the callback, then iteration stops immediately. Any 25 | // error returned from the callback is returned by PrioritySearch, except for 26 | // the case where the special Stop sentinel error is returned (in which case 27 | // nil will be returned from PrioritySearch). Stop may be wrapped. 28 | func (t *RTree) PrioritySearch(box Box, callback func(recordID int) error) error { 29 | if t.root == nil { 30 | return nil 31 | } 32 | 33 | queue := entriesQueue{origin: box} 34 | equeueNode := func(n *node) { 35 | for i := 0; i < n.numEntries; i++ { 36 | heap.Push(&queue, &n.entries[i]) 37 | } 38 | } 39 | 40 | equeueNode(t.root) 41 | for len(queue.entries) > 0 { 42 | nearest := heap.Pop(&queue).(*entry) 43 | if nearest.child == nil { 44 | if err := callback(nearest.recordID); err != nil { 45 | if errors.Is(err, Stop) { 46 | return nil 47 | } 48 | return err 49 | } 50 | } else { 51 | equeueNode(nearest.child) 52 | } 53 | } 54 | return nil 55 | } 56 | 57 | type entriesQueue struct { 58 | entries []*entry 59 | origin Box 60 | } 61 | 62 | func (q *entriesQueue) Len() int { 63 | return len(q.entries) 64 | } 65 | 66 | func (q *entriesQueue) Less(i int, j int) bool { 67 | d1 := squaredEuclideanDistance(q.entries[i].box, q.origin) 68 | d2 := squaredEuclideanDistance(q.entries[j].box, q.origin) 69 | return d1 < d2 70 | } 71 | 72 | func (q *entriesQueue) Swap(i int, j int) { 73 | q.entries[i], q.entries[j] = q.entries[j], q.entries[i] 74 | } 75 | 76 | func (q *entriesQueue) Push(x interface{}) { 77 | q.entries = append(q.entries, x.(*entry)) 78 | } 79 | 80 | func (q *entriesQueue) Pop() interface{} { 81 | e := q.entries[len(q.entries)-1] 82 | q.entries = q.entries[:len(q.entries)-1] 83 | return e 84 | } 85 | -------------------------------------------------------------------------------- /rtree/nearest_internal_test.go: -------------------------------------------------------------------------------- 1 | package rtree 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math" 7 | "math/rand" 8 | "sort" 9 | "testing" 10 | ) 11 | 12 | func TestNearest(t *testing.T) { 13 | for _, population := range testPopulations(66, 1000, 1.1) { 14 | t.Run(fmt.Sprintf("n=%d", population), func(t *testing.T) { 15 | rnd := rand.New(rand.NewSource(0)) 16 | rt, boxes := testBulkLoad(rnd, population) 17 | checkInvariants(t, rt, boxes) 18 | checkPrioritySearch(t, rt, boxes, rnd) 19 | checkNearest(t, rt, boxes, rnd) 20 | }) 21 | } 22 | } 23 | 24 | func checkNearest(t *testing.T, rt *RTree, boxes []Box, rnd *rand.Rand) { 25 | t.Helper() 26 | for i := 0; i < 10; i++ { 27 | originBB := randomBox(rnd, 0.9, 0.1) 28 | got, ok := rt.Nearest(originBB) 29 | 30 | if ok && len(boxes) == 0 { 31 | t.Fatal("found nearest but no boxes") 32 | } 33 | if !ok && len(boxes) != 0 { 34 | t.Fatal("could not find nearest but have some boxes") 35 | } 36 | if !ok { 37 | continue 38 | } 39 | 40 | bestDist := math.Inf(+1) 41 | for j := range boxes { 42 | bestDist = math.Min(bestDist, squaredEuclideanDistance(originBB, boxes[j])) 43 | } 44 | if bestDist != squaredEuclideanDistance(originBB, boxes[got]) { 45 | t.Errorf("mismatched distance") 46 | } 47 | } 48 | } 49 | 50 | func checkPrioritySearch(t *testing.T, rt *RTree, boxes []Box, rnd *rand.Rand) { 51 | t.Helper() 52 | for i := 0; i < 10; i++ { 53 | var got []int 54 | originBB := randomBox(rnd, 0.9, 0.1) 55 | t.Logf("origin: %v", originBB) 56 | rt.PrioritySearch(originBB, func(recordID int) error { 57 | got = append(got, recordID) 58 | return nil 59 | }) 60 | t.Logf("got: %v", got) 61 | 62 | if len(got) != len(boxes) { 63 | t.Fatal("didn't get all of the boxes") 64 | } 65 | if !sort.SliceIsSorted(got, func(i, j int) bool { 66 | di := squaredEuclideanDistance(originBB, boxes[got[i]]) 67 | dj := squaredEuclideanDistance(originBB, boxes[got[j]]) 68 | return di < dj 69 | }) { 70 | t.Fatal("records not in sorted order") 71 | } 72 | } 73 | } 74 | 75 | func TestPrioritySearchEarlyStop(t *testing.T) { 76 | rnd := rand.New(rand.NewSource(0)) 77 | boxes := make([]Box, 100) 78 | for i := range boxes { 79 | boxes[i] = randomBox(rnd, 0.9, 0.1) 80 | } 81 | 82 | inserts := make([]BulkItem, len(boxes)) 83 | for i := range inserts { 84 | inserts[i].Box = boxes[i] 85 | inserts[i].RecordID = i 86 | } 87 | rt := BulkLoad(inserts) 88 | origin := randomBox(rnd, 0.9, 0.1) 89 | 90 | t.Run("stop using sentinel", func(t *testing.T) { 91 | var count int 92 | err := rt.PrioritySearch(origin, func(int) error { 93 | count++ 94 | if count >= 3 { 95 | return Stop 96 | } 97 | return nil 98 | }) 99 | if err != nil { 100 | t.Fatal("got an error but didn't expect to") 101 | } 102 | if count != 3 { 103 | t.Fatalf("didn't stop after 3: %v", count) 104 | } 105 | }) 106 | 107 | t.Run("stop with user error", func(t *testing.T) { 108 | var count int 109 | userErr := errors.New("user error") 110 | err := rt.PrioritySearch(origin, func(int) error { 111 | count++ 112 | if count >= 3 { 113 | return userErr 114 | } 115 | return nil 116 | }) 117 | if !errors.Is(err, userErr) { 118 | t.Fatalf("expected to get userErr but got: %v", userErr) 119 | } 120 | if count != 3 { 121 | t.Fatalf("didn't stop after 3: %v", count) 122 | } 123 | }) 124 | } 125 | -------------------------------------------------------------------------------- /rtree/perf_internal_test.go: -------------------------------------------------------------------------------- 1 | package rtree 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "testing" 7 | ) 8 | 9 | func BenchmarkBulk(b *testing.B) { 10 | for _, pop := range [...]int{10, 100, 1000, 10_000, 100_000} { 11 | rnd := rand.New(rand.NewSource(0)) 12 | boxes := make([]Box, pop) 13 | for i := range boxes { 14 | boxes[i] = randomBox(rnd, 0.9, 0.1) 15 | } 16 | inserts := make([]BulkItem, len(boxes)) 17 | for i := range inserts { 18 | inserts[i].Box = boxes[i] 19 | inserts[i].RecordID = i 20 | } 21 | b.Run(fmt.Sprintf("n=%d", pop), func(b *testing.B) { 22 | for i := 0; i < b.N; i++ { 23 | BulkLoad(inserts) 24 | } 25 | }) 26 | } 27 | } 28 | 29 | func BenchmarkRangeSearch(b *testing.B) { 30 | for _, pop := range [...]int{10, 100, 1000, 10_000, 100_000} { 31 | b.Run(fmt.Sprintf("n=%d", pop), func(b *testing.B) { 32 | rnd := rand.New(rand.NewSource(0)) 33 | tree, _ := testBulkLoad(rnd, pop) 34 | b.ResetTimer() 35 | for i := 0; i < b.N; i++ { 36 | tree.RangeSearch(Box{0.5, 0.5, 0.5, 0.5}, func(int) error { return nil }) 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /rtree/quick_partition_internal_test.go: -------------------------------------------------------------------------------- 1 | package rtree 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "strconv" 7 | "testing" 8 | ) 9 | 10 | func TestQuickPartition(t *testing.T) { 11 | testCases := [][]int{ 12 | {1}, 13 | {1, 1}, 14 | {1, 1, 1}, 15 | {1, 1, 1, 1}, 16 | {1, 1, 1, 1, 1}, 17 | {1, 2}, 18 | {2, 1}, 19 | {1, 1, 2}, 20 | {1, 2, 1}, 21 | {2, 1, 1}, 22 | } 23 | for i := 1; i <= 100; i++ { 24 | allNums := make([]int, i) 25 | for j := range allNums { 26 | allNums[j] = j 27 | } 28 | rand.New(rand.NewSource(0)).Shuffle(i, func(a, b int) { 29 | allNums[a], allNums[b] = allNums[b], allNums[a] 30 | }) 31 | testCases = append(testCases, allNums) 32 | } 33 | for i := 1; i <= 20; i++ { 34 | allNums := make([]int, i*5) 35 | for j := range allNums { 36 | allNums[j] = j / 5 // results in duplicates 37 | } 38 | rand.New(rand.NewSource(0)).Shuffle(i*5, func(a, b int) { 39 | allNums[a], allNums[b] = allNums[b], allNums[a] 40 | }) 41 | testCases = append(testCases, allNums) 42 | } 43 | 44 | for i, tc := range testCases { 45 | t.Run(strconv.Itoa(i), func(t *testing.T) { 46 | for k := range tc { 47 | t.Run(fmt.Sprintf("k=%d", k), func(t *testing.T) { 48 | items := make([]BulkItem, 0, len(tc)) 49 | for _, num := range tc { 50 | f := float64(num) 51 | items = append(items, BulkItem{ 52 | Box{f, f, f, f}, 53 | len(items), 54 | }) 55 | } 56 | 57 | quickPartition(items, k, false) 58 | kth := items[k] 59 | for j, item := range items { 60 | switch { 61 | case j < k: 62 | if item.Box.MaxX > kth.Box.MaxX { 63 | t.Errorf("item at index %d not partitioned", j) 64 | } 65 | case j > k: 66 | if item.Box.MaxX < kth.Box.MaxX { 67 | t.Errorf("item at index %d not partitioned", j) 68 | } 69 | default: 70 | if item.Box.MaxX != kth.Box.MaxX { 71 | t.Errorf("item at index %d not partitioned", j) 72 | } 73 | } 74 | } 75 | }) 76 | } 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /rtree/rtree.go: -------------------------------------------------------------------------------- 1 | package rtree 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | const ( 8 | minEntries = 2 9 | maxEntries = 4 10 | ) 11 | 12 | // node is a node in an R-Tree, holding user record IDs and/or links to deeper 13 | // nodes in the tree. 14 | type node struct { 15 | entries [maxEntries]entry 16 | numEntries int 17 | } 18 | 19 | // entry is an entry contained inside a node. An entry can either hold a user 20 | // record ID, or point to a deeper node in the tree (but not both). Because 0 21 | // is a valid record ID, the child pointer should be used to distinguish 22 | // between the two types of entries. 23 | type entry struct { 24 | box Box 25 | child *node 26 | recordID int 27 | } 28 | 29 | // RTree is an in-memory R-Tree data structure. It holds record ID and bounding 30 | // box pairs (the actual records aren't stored in the tree; the user is 31 | // responsible for storing their own records). Its zero value is an empty 32 | // R-Tree. 33 | type RTree struct { 34 | root *node 35 | count int 36 | } 37 | 38 | // Stop is a special sentinel error that can be used to stop a search operation 39 | // without any error. 40 | var Stop = errors.New("stop") //nolint:stylecheck,revive 41 | 42 | // RangeSearch looks for any items in the tree that overlap with the given 43 | // bounding box. The callback is called with the record ID for each found item. 44 | // If an error is returned from the callback then the search is terminated 45 | // early. Any error returned from the callback is returned by RangeSearch, 46 | // except for the case where the special Stop sentinel error is returned (in 47 | // which case nil will be returned from RangeSearch). Stop may be wrapped. 48 | func (t *RTree) RangeSearch(box Box, callback func(recordID int) error) error { 49 | if t.root == nil { 50 | return nil 51 | } 52 | var recurse func(*node) error 53 | recurse = func(n *node) error { 54 | for i := 0; i < n.numEntries; i++ { 55 | entry := n.entries[i] 56 | if !overlap(entry.box, box) { 57 | continue 58 | } 59 | if entry.child == nil { 60 | if err := callback(entry.recordID); errors.Is(err, Stop) { 61 | return nil 62 | } else if err != nil { 63 | return err 64 | } 65 | } else { 66 | if err := recurse(entry.child); err != nil { 67 | return err 68 | } 69 | } 70 | } 71 | return nil 72 | } 73 | return recurse(t.root) 74 | } 75 | 76 | // Extent gives the Box that most closely bounds the RTree. If the RTree is 77 | // empty, then false is returned. 78 | func (t *RTree) Extent() (Box, bool) { 79 | if t.root == nil || t.root.numEntries == 0 { 80 | return Box{}, false 81 | } 82 | return calculateBound(t.root), true 83 | } 84 | 85 | // Count gives the number of entries in the RTree. 86 | func (t *RTree) Count() int { 87 | return t.count 88 | } 89 | --------------------------------------------------------------------------------