├── .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 |
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 | [](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 | 
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 | 
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 | 
43 |
44 | [**Sinusoidal projection**](https://en.wikipedia.org/wiki/Sinusoidal_projection)
45 |
46 | The central meridian is set to 0°E.
47 |
48 | 
49 |
50 | [**Orthographic projection**](https://en.wikipedia.org/wiki/Orthographic_projection)
51 |
52 | Centered on North America at 45°N, 105°W.
53 |
54 | 
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------