diff --git a/README.md b/README.md index f31ed18..97e986d 100644 --- a/README.md +++ b/README.md @@ -7,69 +7,52 @@ Card](https://goreportcard.com/badge/PDOK/texel)](https://goreportcard.com/repor [![Docker Pulls](https://img.shields.io/docker/pulls/pdok/texel.svg)](https://hub.docker.com/r/pdok/texel) -Preprocesses a [GeoPackage](https://www.geopackage.org/) -for creating _valid_ (more info below) vector tiles from it. -(And in the future probably will also create the actual vector tiles.) - -The (MULTI)POLYGON geometries in a geopackage are snapped to the grid cq matrix -matching the one of the intended (internal pixel grid of the) vector tiles. -While ensuring that no (new) intersections arise from this, by adding points -to lines that skim along other vertices. -An additional benefit is that the vector tile generator needs to process fewer -data (snapping combines some points into one) and does not need any additional -processing aside from clipping and converting to the internal tile pixel grid. +Processes a [GeoPackage](https://www.geopackage.org/) in order to produce Mapbox vector tiles. -## "Valid" geometries - -Our definition of _valid_ is in the context of vector tiles and is more loose -than the [OGC rules](https://en.wikipedia.org/wiki/Simple_Features). -Mainly in comparison to the OGC rules, we allow: - -* Overlap (not intersection) -* Linear rings or line strings with less than 3 vertices - (effectively becoming lines or points) - -With the drawing/rendering of vector tiles in mind, these invalidities do not -cause a problem. On the contrary, they introduce possibilities. -E.g. when zooming out, you would like to still see a lake as a point and a -river as a line. (The tool that this codebase is forked from, `sieve` would -filter those out.) Also when zooming out on a lake with an attached river, -you would like to keep seeing that river as a line, but still as part of the -original geometry/feature including all attributes and styling. +This tool was originally created to snap MULTI(POLYGONS) to the grid cq matrix +corresponding to the vector tiles, while preserving a weak notion of _valid_ +(more info below). Subsequently, the tool can be used in two ways: +- `texel snap`, which snaps the polygons. +- `texel snap -enc` with `texel mvt`. The first command snaps and encodes + geometries according to the standard. The second assembles the tiles. -## Examples - -Not adding extra points could create intersections: - -![snapped with vs without extra point](./images/snapped-with-vs-without-extra-point.png) - -Close thin polygons can turn into lines: - -![before.png](./images/before.png) ![after-overlapping-lines.png](./images/after-overlapping-lines.png) +## Usage -## Notes +Getting help: -- It will take a Geopackage and writes a new Geopackage with all the - (MULTI)POLYGON tables preprocessed. - - All other spatial tables are 'untouched' and copied as-is. - - Other non-spatial tables are not copied to the new geopackage. -- :warning: Spatialite lib is mandatory for running this application. This lib is needed for - creating the RTree triggers on the spatial tables for updating/maintaining the - RTree. +```sh +./texel --help +./texel snap --help +./texel mvt --help +``` -## Usage +Snap and encode geopackage ```sh -./texel -s=[source GPKG] -t=[target GPKG] \ +./texel snap \ + -s=[source GPKG] -t=[target GPKG] \ -tms=[tile matrix set for filtering] -z=[tile matrix ids] \ -p=[pagesize for writing to target GPKG] -o=[overwrite target GPKG] \ - -pl=[keep points and lines] + -pl=[keep points and lines] \ + -iog=[ignore outside grid] --rwo=[reverse winding order] \ + -enc=[encode geometries] \ + -buf=[buffer size for tile detection] \ + -lt=[line trace method for tile detection] \ + -c=[current clip] +``` -./texel --help +Make tiles from snapped output + +```sh +./texel mvt \ + -c=[config toml file] \ + -z=[tile matrix id] ``` ### Docker +Example Docker command for running texel without encoding geometries. + ```docker docker run \ --name texel \ @@ -77,6 +60,7 @@ docker run \ -u $(id -u):$(id -g) \ -v `pwd`/example:/example \ pdok/texel \ + snap -s=./example/example.gpkg \ -t=./example/example-processed.gpkg \ -tms="NetherlandsRDNewQuad" \ @@ -100,9 +84,152 @@ go build . docker build -t pdok/texel . ``` +:warning: Spatialite lib is mandatory for running this application. This lib is +needed for creating the RTree triggers on the spatial tables for +updating/maintaining the RTree. + +## Commands + +### Snapping + +`texel snap` takes a geopackage, snaps all (MULTI)POLYGON geometries in it, and +writes to a new geopackage. If encoding is enabled, for each table it also +creates `{TABLENAME}_encoded` tables. A row in such a table is a geometry +encoded for a particular tile. These are required input for `texel mvt` +(explained below). + +- All other spatial tables are 'untouched' and copied as-is. +- Other non-spatial tables are not copied to the new geopackage. + +Input parameters: +- source (`-s`): source Geopackage. +- target (`-t`): target Geopackage. If specified as `target.gpkg`, output is + written to `target_{zoomlevel}.gpkg` for each zoomlevel. +- tile matrix set (`-tms`): tile matrix set name. Supported tile matrix sets can + be found in the `tms20/tilematrixsets` folder. +- tile matrix ids (`-z`): JSON array of integers of zoomlevels to target. + +Snap behaviour parameters: +- keep points and lines: (`-pl`): if (part of) a polygon degenerates to points + and lines during snapping remove those parts. +- ignore outside grid (`-iog`): skip geometries outside of the grid +- reverse winding order (`-rwo`): reverse winding order of geometries. This is + used to comply with the vectortile standard. + +Encoding behaviour parameters: +- encode (`-enc`): enable encoding. If set to false, do not create the + `{TABLENAME}_encoded` tables. +- line trace (`-lt`): there are two algorithms to determine on which tiles a + geometry lies. When `-lt=false`, use a bounding box. When `-lt=true`, trace + border of features. This is more precise, but more computationally expensive. +- buffer (`-buf`): buffer size around each tile. Geometries within this buffer + are encoded for this tile. +- clip (`-c`): a higher-level tile written as `[z, x, y]`, for zoomlevel and + coordinates. It is required that this `z` not larger than the + `tile-matrix-ids` above. Only encode geometries lying within this + higher-level tile. + +The `clip` parameter might require more explanation. One possible workflow is +divide a workload of generating all tiles into several "clips", for example for +parallel processing. A clip is such a tile at a higher level. In many +instances, for example when the buffer is strictly positive, the standard +workflow of `texel snap` + `texel mvt` generates tiles neighbouring the clip. +This option prevents that. + +## Tile generation + +The output of `texel snap -enc` is input for `texel mvt`, which generates +vectortiles. There are only a few parameters + +- config (`-c`): a TOML configuration file that is detailed below. +- tile matrix id (`-z`): the zoomlevel that is being targeted. + +Almost all configuration is determined in this TOML file. The configuration is +similar to configurations for +[trex](https://github.com/t-rex-tileserver/t-rex), however, valid +configurations for `trex` are not valid for `texel` in a way detailed below. +The following configuration is an example. + +```toml +[tileset] +name = "NetherlandsRDNewQuad" + +[[datasource]] +name = "brt" +path = "texeled/brt_12.gpkg" + +[[tileset.layer]] +name = "wegdeel" +datasource = "brt" +table_name = "wegdeel_12" +minzoom = 12 +maxzoom = 12 + +[[tileset.layer]] +name = "waterdeel" +datasource = "brt" +table_name = "waterdeel_11_12" +minzoom = 11 +maxzoom = 12 + +[cache.file] +base = "/data" + +[cache.azure] +connection_string = "{{env.AZURE_CONNECTION_STRING}}" +container = "{{env.AZURE_CONTAINER}}" +prefix_key = "{{env.AZURE_KEY_PREFIX}}" +``` + +Notes: +- A `[[datasource]]` is a geopackage that is results from `texel snap -enc`. +- A `[[tileset.layer]]` represents both a table in a geopackage and a layer in + the resulting vectortile. The geopackage and table is determined by + `datasource`, which refers to the name of a datasource and `table_name` + (which should NOT inclde an `_encoded` suffix). + + A layer is included if the parameter `-z` specified above lies between + `minzoom` and `maxzoom` (inclusive). +- If `[cache.file]` is specified, tiles are written with that path as base. +- If `[cache.azure]` is specified, write to the an azure blobstore. +- At least one of `[cache.file]` or `[cache.azure]` needs to be specified. +- At the destination, the tiles are written as `{NAME}/{z}/{x}/{y}.pbf`. Here + `name` is the name of the tileset. When writing to the azure blobstore, the + `prefix_key` is also prepended. + +## "Valid" geometries + +`texel snap` produces "valid" geometries. + +Our definition of _valid_ is in the context of vector tiles and is more loose +than the [OGC rules](https://en.wikipedia.org/wiki/Simple_Features). +Mainly in comparison to the OGC rules, we allow: + +* Overlap (not intersection) +* Linear rings or line strings with less than 3 vertices + (effectively becoming lines or points) +* Gaps + +With the drawing/rendering of vector tiles in mind, these invalidities do not +cause a problem. On the contrary, they introduce possibilities. +E.g. when zooming out, you would like to still see a lake as a point and a +river as a line. (The tool that this codebase is forked from, `sieve` would +filter those out.) Also when zooming out on a lake with an attached river, +you would like to keep seeing that river as a line, but still as part of the +original geometry/feature including all attributes and styling. + +### Examples +Not adding extra points could create intersections: + +![snapped with vs without extra point](./images/snapped-with-vs-without-extra-point.png) + +Close thin polygons can turn into lines: + +![before.png](./images/before.png) ![after-overlapping-lines.png](./images/after-overlapping-lines.png) ## References * [sieve](https://github.com/pdok/sieve) is the predecessor of this tool and much of its processing codebase is reused as boilerplate. + diff --git a/config/mvtconfig.go b/config/mvtconfig.go new file mode 100644 index 0000000..2b27ed4 --- /dev/null +++ b/config/mvtconfig.go @@ -0,0 +1,101 @@ +package config + +import ( + "fmt" + "os" + "regexp" + + "github.com/BurntSushi/toml" +) + +type DataSource struct { + Name string `toml:"name"` + Path string `toml:"path"` +} + +type LayerConfig struct { + Name string `toml:"name"` + MinZoom uint `toml:"minzoom"` + MaxZoom uint `toml:"maxzoom"` + DataSource string `toml:"datasource"` + TableName string `toml:"table_name"` +} + +// Tileset mirrors a `[[Tileset]]` array-of-tables entry, including its +// nested `[[Tileset.layer]]` entries. +type Tileset struct { + Name string `toml:"name"` + Layer []LayerConfig `toml:"layer"` +} + +type AzureConfig struct { + ConnectionString string `toml:"connection_string"` + Container string `toml:"container"` + PrefixKey string `toml:"prefix_key"` +} + +type FileConfig struct { + Base string `toml:"base"` +} + +// In case S3 will be needed in the future, these are the old `t_rex` configs. +// type S3Config struct { +// Endpoint string `toml:"endpoint"` +// Bucket string `toml:"bucket"` +// AccessKey string `toml:"access_key"` +// SecretKey string `toml:"secret_key"` +// Region string `toml:"region"` +// KeyPrefix string `toml:"key_prefix"` +// } + +type CacheConfig struct { + File *FileConfig `toml:"file"` + Azure *AzureConfig `toml:"azure"` +} + +// TomlConfig mirrors the top-level structure of an mvt config toml file, +// e.g. example/NetherlandsRDNewQuad.toml. +type TomlConfig struct { + DataSource []DataSource `toml:"datasource"` + Tileset *Tileset `toml:"tileset"` + Cache CacheConfig `toml:"cache"` +} + +// Replace {{env.NAME}} by value of environment variable NAME. Inserted +// verbatim. Unset variables result in an error. +func substituteEnv(s string) (string, error) { + envPlaceholder := regexp.MustCompile( + `\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}`) + + var missing []string + out := envPlaceholder.ReplaceAllStringFunc(s, func(m string) string { + name := envPlaceholder.FindStringSubmatch(m)[1] + val, ok := os.LookupEnv(name) + if !ok { + missing = append(missing, name) + } + return val + }) + if len(missing) > 0 { + return "", fmt.Errorf("unset environment variables: %v", missing) + } + return out, nil +} + +// ParseMVTConfig reads an mvt config toml file, substitutes {{env.NAME}} +// placeholders and returns the resulting TomlConfig. +func ParseMVTConfig(path string) (TomlConfig, error) { + var cfg TomlConfig + raw, err := os.ReadFile(path) + if err != nil { + return TomlConfig{}, fmt.Errorf("reading mvt config %q: %w", path, err) + } + text, err := substituteEnv(string(raw)) + if err != nil { + return TomlConfig{}, fmt.Errorf("mvt config %q: %w", path, err) + } + if _, err := toml.Decode(text, &cfg); err != nil { + return TomlConfig{}, fmt.Errorf("decoding mvt config %q: %w", path, err) + } + return cfg, nil +} diff --git a/config/mvtconfig_test.go b/config/mvtconfig_test.go new file mode 100644 index 0000000..e677eba --- /dev/null +++ b/config/mvtconfig_test.go @@ -0,0 +1,40 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSubstituteEnv(t *testing.T) { + t.Setenv("TEXEL_TEST_PATH", "/data/a.gpkg") + t.Setenv("TEXEL_TEST_QUOTED", `\"TEST\"`) + + tests := []struct { + name string + in string + want string + }{ + {"no placeholder", `path = "x"`, `path = "x"`}, + {"simple", `path = "{{env.TEXEL_TEST_PATH}}"`, `path = "/data/a.gpkg"`}, + {"spaces", `path = "{{ env.TEXEL_TEST_PATH }}"`, `path = "/data/a.gpkg"`}, + { + "multiple", + `{{env.TEXEL_TEST_PATH}}:{{env.TEXEL_TEST_PATH}}`, + `/data/a.gpkg:/data/a.gpkg`, + }, + {"escaped quotes", `name = "{{env.TEXEL_TEST_QUOTED}}"`, `name = "\"TEST\""`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := substituteEnv(tt.in) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestSubstituteEnv_Unset(t *testing.T) { + _, err := substituteEnv(`a = "{{env.TEXEL_TEST_DEFINITELY_UNSET}}"`) + require.ErrorContains(t, err, "TEXEL_TEST_DEFINITELY_UNSET") +} diff --git a/geomhelp/geomhelp.go b/geomhelp/geomhelp.go index 758a059..21a0930 100644 --- a/geomhelp/geomhelp.go +++ b/geomhelp/geomhelp.go @@ -1,6 +1,7 @@ package geomhelp import ( + "fmt" "math" "strings" @@ -146,7 +147,7 @@ func WktMustEncode(g geom.Geometry, maxLen uint) (s string) { return s } -func WktMustEncodeSlice(geoms []geom.Polygon, maxLen uint) string { +func WktMustEncodeSlice(geoms []geom.Geometry, maxLen uint) string { s := "" var builder strings.Builder for i := range geoms { @@ -162,3 +163,171 @@ func wktMustEncodeTruncated(geom geom.Geometry, width uint) string { } return truncate.StringWithTail(wkt.MustEncode(geom), width, "...") } + +// GeometrySliceToGeom converts a slice of points, linestrings or polygons +// into the matching (multi)geometry. Mixed geometry types are not allowed. +func GeometrySliceToGeom(geometries []geom.Geometry) geom.Geometry { + if len(geometries) == 0 { + return nil + } + if len(geometries) == 1 { + return geometries[0] + } + + switch geometries[0].(type) { + case geom.Point: + multiPoint := make(geom.MultiPoint, len(geometries)) + for i, g := range geometries { + p, ok := g.(geom.Point) + if !ok { + panic("Mixed geometry types are not allowed") + } + multiPoint[i] = p + } + return multiPoint + case geom.LineString: + multiLineString := make(geom.MultiLineString, len(geometries)) + for i, g := range geometries { + l, ok := g.(geom.LineString) + if !ok { + panic("Mixed geometry types are not allowed") + } + multiLineString[i] = l + } + return multiLineString + case geom.Polygon: + multiPolygon := make(geom.MultiPolygon, len(geometries)) + for i, g := range geometries { + p, ok := g.(geom.Polygon) + if !ok { + panic("Mixed geometry types are not allowed") + } + multiPolygon[i] = p + } + return multiPolygon + default: + panic("Unknown or unsupported geometry type") + } +} + +func PolygonSliceToGeom(polygons []geom.Polygon) geom.Geometry { + if len(polygons) == 0 { + panic("Multipolygon with zero polygons encountered") + } + if len(polygons) == 1 { + return polygons[0] + } + multipolygon := make(geom.MultiPolygon, len(polygons)) + for i, p := range polygons { + multipolygon[i] = p + } + return multipolygon +} + +// Convert geometry to slice of points. Does not accept multi-geometries +func PointSlice(g geom.Geometry) []geom.Point { + switch g := g.(type) { + case geom.Point: + return []geom.Point{g} + case geom.Polygon: + rings := g.LinearRings() + pointsCount := 0 + for _, ring := range rings { + pointsCount += len(ring) + } + points := make([]geom.Point, 0, pointsCount) + for _, ring := range rings { + for _, point := range ring { + points = append(points, point) + } + } + return points + case geom.LineString: + vertices := g.Vertices() + points := make([]geom.Point, 0, len(vertices)) + for _, vert := range vertices { + points = append(points, vert) + } + return points + default: + err := fmt.Errorf("PointSlice called incompatible geometry type: %s", g) + panic(err) + } +} + +// Essentially merge two lists +func mergeFamily[S any, E any, M ~[]E]( + g, h geom.Geometry, wrap func(S) M, errMsg string, +) geom.Geometry { + switch g := g.(type) { + case M: + switch h := h.(type) { + case M: + return append(g, h...) + case S: + return append(g, wrap(h)...) + default: + panic(errMsg) + } + case S: + switch h := h.(type) { + case M: + return append(wrap(g), h...) + case S: + return append(wrap(g), wrap(h)...) + default: + panic(errMsg) + } + default: + panic(errMsg) + } +} + +func MergeGeometries(g, h geom.Geometry) geom.Geometry { + if g == nil { + return h + } + if h == nil { + return g + } + + switch g.(type) { + case geom.Point, geom.MultiPoint: + return mergeFamily(g, h, func(p geom.Point) geom.MultiPoint { return geom.MultiPoint{p} }, + "Trying to merge point with non-point geometry") + case geom.LineString, geom.MultiLineString: + return mergeFamily(g, h, func(l geom.LineString) geom.MultiLineString { return geom.MultiLineString{l} }, + "Trying to merge linestring with non-linestring geometry") + case geom.Polygon, geom.MultiPolygon: + return mergeFamily(g, h, func(p geom.Polygon) geom.MultiPolygon { return geom.MultiPolygon{p} }, + "Trying to merge a polygon with a non-polygon geometry.") + default: + panic("Trying to merge unknown geometry types.") + } +} + +// A rather unfortunate function that needs to exist to deal with golang interfaces. +func MultiGeometryToSlice(multiGeom geom.Geometry) []geom.Geometry { + switch geometry := multiGeom.(type) { + case geom.MultiPolygon: + geoms := make([]geom.Geometry, 0, len(geometry)) + for _, polygon := range geometry { + geoms = append(geoms, geom.Polygon(polygon)) + } + return geoms + case geom.MultiLineString: + geoms := make([]geom.Geometry, 0, len(geometry)) + for _, line := range geometry { + geoms = append(geoms, geom.LineString(line)) + } + return geoms + case geom.MultiPoint: + geoms := make([]geom.Geometry, 0, len(geometry)) + for _, point := range geometry { + geoms = append(geoms, geom.Point(point)) + } + return geoms + default: + panic("Cannot convert to slice: not a multigeometry.") + } +} diff --git a/geomhelp/geomhelp_test.go b/geomhelp/geomhelp_test.go new file mode 100644 index 0000000..4085008 --- /dev/null +++ b/geomhelp/geomhelp_test.go @@ -0,0 +1,265 @@ +package geomhelp + +import ( + "testing" + + "github.com/go-spatial/geom" + "github.com/stretchr/testify/assert" +) + +func TestGeomhelp_PolygonSliceToGeom(t *testing.T) { + tests := []struct { + name string + polygonSlice []geom.Polygon + want geom.Geometry + wantPanic bool + }{ + { + name: "Empty polygon slice", + polygonSlice: []geom.Polygon{}, + wantPanic: true, + }, + { + name: "Single polygon", + polygonSlice: []geom.Polygon{{{{0.0, 0.0}, {1.0, 0.0}, {1.0, 1.0}}}}, + want: geom.Polygon{{{0.0, 0.0}, {1.0, 0.0}, {1.0, 1.0}}}, + }, + { + name: "Two polygons", + polygonSlice: []geom.Polygon{ + {{{0.0, 0.0}, {1.0, 0.0}, {1.0, 1.0}}}, + {{{10.0, 10.0}, {10.0, 11.0}, {11.0, 11.0}}}, + }, + want: geom.MultiPolygon{ + {{{0.0, 0.0}, {1.0, 0.0}, {1.0, 1.0}}}, + {{{10.0, 10.0}, {10.0, 11.0}, {11.0, 11.0}}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.wantPanic { + assert.Panics(t, func() { PolygonSliceToGeom(tt.polygonSlice) }) + return + } + got := PolygonSliceToGeom(tt.polygonSlice) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMergeGeometries(t *testing.T) { + p1 := geom.Point{1, 2} + p2 := geom.Point{3, 4} + mp1 := geom.MultiPoint{{5, 6}, {7, 8}} + mp2 := geom.MultiPoint{{9, 9}} + + l1 := geom.LineString{{0, 0}, {1, 1}} + l2 := geom.LineString{{2, 2}, {3, 3}} + ml1 := geom.MultiLineString{{{4, 4}, {5, 5}}} + ml2 := geom.MultiLineString{{{6, 6}, {7, 7}}} + + poly1 := geom.Polygon{{{0, 0}, {1, 0}, {1, 1}}} + poly2 := geom.Polygon{{{2, 2}, {3, 2}, {3, 3}}} + mpoly1 := geom.MultiPolygon{{{{4, 4}, {5, 4}, {5, 5}}}} + mpoly2 := geom.MultiPolygon{{{{6, 6}, {7, 6}, {7, 7}}}} + + tests := []struct { + name string + g, h geom.Geometry + want geom.Geometry + }{ + {name: "nil g", g: nil, h: p1, want: p1}, + {name: "nil h", g: p1, h: nil, want: p1}, + + {name: "point + point", g: p1, h: p2, want: geom.MultiPoint{p1, p2}}, + {name: "point + multipoint", g: p1, h: mp1, want: geom.MultiPoint{{1, 2}, {5, 6}, {7, 8}}}, + {name: "multipoint + point", g: mp1, h: p1, want: geom.MultiPoint{{5, 6}, {7, 8}, {1, 2}}}, + {name: "multipoint + multipoint", g: mp1, h: mp2, want: geom.MultiPoint{{5, 6}, {7, 8}, {9, 9}}}, + + {name: "linestring + linestring", g: l1, h: l2, want: geom.MultiLineString{l1, l2}}, + { + name: "linestring + multilinestring", g: l1, h: ml1, + want: geom.MultiLineString{{{0, 0}, {1, 1}}, {{4, 4}, {5, 5}}}, + }, + { + name: "multilinestring + linestring", g: ml1, h: l1, + want: geom.MultiLineString{{{4, 4}, {5, 5}}, {{0, 0}, {1, 1}}}, + }, + { + name: "multilinestring + multilinestring", g: ml1, h: ml2, + want: geom.MultiLineString{{{4, 4}, {5, 5}}, {{6, 6}, {7, 7}}}, + }, + + {name: "polygon + polygon", g: poly1, h: poly2, want: geom.MultiPolygon{poly1, poly2}}, + { + name: "polygon + multipolygon", g: poly1, h: mpoly1, + want: geom.MultiPolygon{{{{0, 0}, {1, 0}, {1, 1}}}, {{{4, 4}, {5, 4}, {5, 5}}}}, + }, + { + name: "multipolygon + polygon", g: mpoly1, h: poly1, + want: geom.MultiPolygon{{{{4, 4}, {5, 4}, {5, 5}}}, {{{0, 0}, {1, 0}, {1, 1}}}}, + }, + { + name: "multipolygon + multipolygon", g: mpoly1, h: mpoly2, + want: geom.MultiPolygon{{{{4, 4}, {5, 4}, {5, 5}}}, {{{6, 6}, {7, 6}, {7, 7}}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MergeGeometries(tt.g, tt.h) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMergeGeometriesPanicsOnMismatchedTypes(t *testing.T) { + tests := []struct { + name string + g, h geom.Geometry + }{ + {name: "point with linestring", g: geom.Point{1, 2}, h: geom.LineString{{0, 0}, {1, 1}}}, + {name: "linestring with polygon", g: geom.LineString{{0, 0}, {1, 1}}, h: geom.Polygon{{{0, 0}, {1, 0}, {1, 1}}}}, + {name: "polygon with point", g: geom.Polygon{{{0, 0}, {1, 0}, {1, 1}}}, h: geom.Point{1, 2}}, + {name: "unknown geometry types", g: 42, h: 7}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Panics(t, func() { MergeGeometries(tt.g, tt.h) }) + }) + } +} + +func TestGeometrySliceToGeom(t *testing.T) { + p1 := geom.Point{1, 2} + p2 := geom.Point{3, 4} + l1 := geom.LineString{{0, 0}, {1, 1}} + l2 := geom.LineString{{2, 2}, {3, 3}} + poly1 := geom.Polygon{{{0, 0}, {1, 0}, {1, 1}}} + poly2 := geom.Polygon{{{2, 2}, {3, 2}, {3, 3}}} + + tests := []struct { + name string + geometries []geom.Geometry + want geom.Geometry + }{ + { + name: "empty slice", + geometries: []geom.Geometry{}, + want: nil, + }, + { + name: "nil slice", + geometries: nil, + want: nil, + }, + { + name: "single point", + geometries: []geom.Geometry{p1}, + want: p1, + }, + { + name: "single linestring", + geometries: []geom.Geometry{l1}, + want: l1, + }, + { + name: "single polygon", + geometries: []geom.Geometry{poly1}, + want: poly1, + }, + { + name: "multiple points", + geometries: []geom.Geometry{p1, p2}, + want: geom.MultiPoint{p1, p2}, + }, + { + name: "multiple linestrings", + geometries: []geom.Geometry{l1, l2}, + want: geom.MultiLineString{l1, l2}, + }, + { + name: "multiple polygons", + geometries: []geom.Geometry{poly1, poly2}, + want: geom.MultiPolygon{poly1, poly2}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GeometrySliceToGeom(tt.geometries) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGeometrySliceToGeomPanics(t *testing.T) { + tests := []struct { + name string + geometries []geom.Geometry + }{ + { + name: "mixed points and linestrings", + geometries: []geom.Geometry{geom.Point{1, 2}, geom.LineString{{0, 0}, {1, 1}}}, + }, + { + name: "mixed linestrings and polygons", + geometries: []geom.Geometry{geom.LineString{{0, 0}, {1, 1}}, geom.Polygon{{{0, 0}, {1, 0}, {1, 1}}}}, + }, + { + name: "mixed points and polygons", + geometries: []geom.Geometry{geom.Point{1, 2}, geom.Polygon{{{0, 0}, {1, 0}, {1, 1}}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Panics(t, func() { GeometrySliceToGeom(tt.geometries) }) + }) + } +} + +func TestPointSlice(t *testing.T) { + tests := []struct { + name string + g geom.Geometry + want []geom.Point + }{ + { + name: "point", + g: geom.Point{1, 2}, + want: []geom.Point{{1, 2}}, + }, + { + name: "line string", + g: geom.LineString{{0, 0}, {1, 1}, {2, 2}}, + want: []geom.Point{{0, 0}, {1, 1}, {2, 2}}, + }, + { + name: "polygon with single ring", + g: geom.Polygon{{{0, 0}, {1, 0}, {1, 1}, {0, 1}}}, + want: []geom.Point{{0, 0}, {1, 0}, {1, 1}, {0, 1}}, + }, + { + name: "polygon with hole", + g: geom.Polygon{ + {{0, 0}, {10, 0}, {10, 10}, {0, 10}}, + {{2, 2}, {4, 2}, {4, 4}, {2, 4}}, + }, + want: []geom.Point{ + {0, 0}, + {10, 0}, + {10, 10}, + {0, 10}, + {2, 2}, + {4, 2}, + {4, 4}, + {2, 4}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := PointSlice(tt.g) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/go.mod b/go.mod index 89a2d74..671277a 100644 --- a/go.mod +++ b/go.mod @@ -3,40 +3,56 @@ module github.com/pdok/texel go 1.26.2 require ( + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.1 + github.com/BurntSushi/toml v1.5.0 github.com/carlmjohnson/versioninfo v0.22.5 github.com/creasty/defaults v1.8.0 github.com/go-playground/validator/v10 v10.30.3 - github.com/go-spatial/geom v0.0.0-20220918193402-3cd2f5a9a082 + github.com/go-spatial/geom v0.1.0 + github.com/golang/protobuf v1.5.3 github.com/iancoleman/strcase v0.3.0 github.com/muesli/reflow v0.3.0 github.com/perimeterx/marshmallow v1.1.5 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 github.com/tobshub/go-sortedmap v1.0.3 github.com/urfave/cli/v2 v2.27.7 github.com/wk8/go-ordered-map/v2 v2.1.8 - golang.org/x/exp v0.0.0-20260611194520-c48552f49976 + golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 ) require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect + github.com/apache/arrow-go/v18 v18.7.0 // indirect + github.com/arolek/p v0.0.0-20191103215535-df3c295ed582 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gdey/errors v0.0.0-20190426172550-8ebd5bc891fb // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-spatial/proj v0.3.0 // indirect + github.com/go-test/deep v1.1.1 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/google/flatbuffers v25.12.19+incompatible // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-runewidth v0.0.12 // indirect - github.com/mattn/go-sqlite3 v1.14.17 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/pierrec/lz4/v4 v4.1.28 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4d8f73b..d07a0ed 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,24 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.1 h1:gkBLVmB3Z/HnGP/Jo4o12/RDpi0agnKav6sCKsX5Vu0= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.1/go.mod h1:e3/1P5K+jIUi9JevDRklq/tFeTvbBb75bNAjU4xd31w= +github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.7.0 h1:Vw/i+cJyebUofT7JlqFpe65LrmwxULn166jjwStM4HY= +github.com/apache/arrow-go/v18 v18.7.0/go.mod h1:PM6IigLJkdMwIpeHXnymo+xZ52f42a9EYiLtRel4p/A= +github.com/apache/thrift v0.24.0 h1:zy31L1a49QTNB2bG1BBfMXol3yJrTH975G3pPubQVLQ= +github.com/apache/thrift v0.24.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= +github.com/arolek/p v0.0.0-20191103215535-df3c295ed582 h1:DugKk4B3PpqfZ1QunDSPqNZDAErQmdCgwurloi1Axaw= github.com/arolek/p v0.0.0-20191103215535-df3c295ed582/go.mod h1:JPNItmi3yb44Q5QWM+Kh5n9oeRhfcJzPNS90mbLo25U= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= @@ -5,13 +26,12 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gdey/errors v0.0.0-20190426172550-8ebd5bc891fb h1:FYO+lZtAUnakgSW9xYs7QvgawjCDM5wgHaXoDhYHNH4= @@ -24,44 +44,65 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= -github.com/go-spatial/geom v0.0.0-20220918193402-3cd2f5a9a082 h1:3+Swhq7I1stScm1DE4PUWKjGJRbi+VPvisj6tFz0prs= -github.com/go-spatial/geom v0.0.0-20220918193402-3cd2f5a9a082/go.mod h1:YU06tBGGstQCUX7vMuyF44RRneTdjGxOrp8OJHu4t9Q= -github.com/go-spatial/proj v0.2.0 h1:sii5Now3GFEyR9hV2SBJFWFz95s4xSaZmP0aYDk1K6c= -github.com/go-spatial/proj v0.2.0/go.mod h1:ePHHp7ITVc4eIVW5sgG/0Eu9RMAGOQUgM/D1ZkccY+0= -github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= -github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/go-spatial/geom v0.1.0 h1:IZ6LVz0Kkgd8+U83MmI0J4L4TBDzk5I8xiYe9Ci+aHk= +github.com/go-spatial/geom v0.1.0/go.mod h1:yNr22dnkQyFEwq2MJVmmiReiFesQpXKlosAG+qUSPus= +github.com/go-spatial/proj v0.3.0 h1:2IRb4elpnmVy1tUKRGBR68uiyZU4Pmny8yZC7TEUdSg= +github.com/go-spatial/proj v0.3.0/go.mod h1:wF0V0A60cTD5uzXeg+P52E1GtMY40PIiyYNJVvHXm6k= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= +github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= -github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/mattn/goveralls v0.0.3-0.20180319021929-1c14a4061c1c/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= +github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tobshub/go-sortedmap v1.0.3 h1:oUhj/5tqzjTX4bhWqB1ZFTDtMULJ1ZYUnS8WAugSfjY= github.com/tobshub/go-sortedmap v1.0.3/go.mod h1:JLxyU94+lfKuCgelxXpwRr29ei6SqLbaeVuNVMvENbE= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= @@ -72,23 +113,33 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.0.0-20191114222411-4191b8cbba09/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY= +golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/intgeom/intgeom.go b/intgeom/intgeom.go index 8285e28..00ac35e 100644 --- a/intgeom/intgeom.go +++ b/intgeom/intgeom.go @@ -56,7 +56,7 @@ func FromGeomOrd(o float64) M { // TODO implement this with integers func SegmentIntersect(l1, l2 Line) ([2]int64, bool) { intersection, intersects := planar.SegmentIntersect(l1.ToGeomLine(), l2.ToGeomLine()) - intIntersection := [2]int64{FromGeomOrd(intersection[0]), FromGeomOrd(intersection[0])} + intIntersection := [2]int64{FromGeomOrd(intersection[0]), FromGeomOrd(intersection[1])} return intIntersection, intersects } diff --git a/main.go b/main.go index 6b5c732..df434ec 100644 --- a/main.go +++ b/main.go @@ -10,10 +10,10 @@ import ( "slices" "syscall" + "github.com/pdok/texel/config" + "github.com/pdok/texel/mapslicehelp" "github.com/pdok/texel/pointindex" - "github.com/go-spatial/geom" - "github.com/carlmjohnson/versioninfo" "github.com/pdok/texel/processing" @@ -21,19 +21,29 @@ import ( "github.com/iancoleman/strcase" "github.com/pdok/texel/processing/gpkg" + "github.com/pdok/texel/processing/writetile" "github.com/pdok/texel/snap" "github.com/urfave/cli/v2" ) -const SOURCE string = `sourceGpkg` -const TARGET string = `targetGpkg` -const OVERWRITE string = `overwrite` -const TILEMATRIXSET string = `tilematrixset` -const TILEMATRICES string = `tilematrices` -const PAGESIZE string = `pagesize` -const KEEPPOINTSANDLINES string = `keeppointsandlines` -const IGNOREOUTSIDEGRID string = `ignoreoutsidegrid` -const REVERSEWINDINGORDER string = `reversewindingorder` +const ( + SOURCE string = `sourceGpkg` + TARGET string = `targetGpkg` + OVERWRITE string = `overwrite` + TILEMATRIXSET string = `tilematrixset` + TILEMATRICES string = `tilematrices` + PAGESIZE string = `pagesize` + KEEPPOINTSANDLINES string = `keeppointsandlines` + IGNOREOUTSIDEGRID string = `ignoreoutsidegrid` + REVERSEWINDINGORDER string = `reversewindingorder` + ENCODETILES string = `encodetiles` + TILEBUFFER string = `tilebuffer` + USELINETRACE string = `uselinetrace` + CLIP string = `clip` + + MVTCONFIG string = `config` + TILEMATRIX string = `tilematrix` +) //nolint:funlen func main() { @@ -42,148 +52,229 @@ func main() { app.Usage = "A Golang Polygon Snapping application" app.Version = versioninfo.Short() - app.Flags = []cli.Flag{ - &cli.StringFlag{ - Name: SOURCE, - Aliases: []string{"s"}, - Usage: "Source GPKG", - Required: true, - EnvVars: []string{strcase.ToScreamingSnake(SOURCE)}, - }, - &cli.StringFlag{ - Name: TARGET, - Aliases: []string{"t"}, - Usage: "Target GPKG (prefix). One GPKG per tile matrix cq zoom level will be created and the filename will be suffixed. E.g. target_6.gpkg", - Required: true, - EnvVars: []string{strcase.ToScreamingSnake(TARGET)}, - }, - &cli.BoolFlag{ - Name: OVERWRITE, - Aliases: []string{"o"}, - Usage: "Overwrite a target GPKG if it exists", - Required: false, - EnvVars: []string{strcase.ToScreamingSnake(OVERWRITE)}, - }, - &cli.StringFlag{ - Name: TILEMATRIXSET, - Aliases: []string{"tms"}, - Usage: `ID of a (built-in) tile matrix set. E.g.: NetherlandsRDNewQuad`, - Required: true, - EnvVars: []string{strcase.ToScreamingSnake(TILEMATRIXSET)}, - }, - &cli.StringFlag{ - Name: TILEMATRICES, - Aliases: []string{"z"}, - Usage: `IDs (usually the same as the zoom levels) of the tile matrices in the tile matrix set that should be processed for. JSON array of integers. E.g.: [4,5,6,7,8]`, - Required: true, - EnvVars: []string{strcase.ToScreamingSnake(TILEMATRICES)}, - }, - &cli.IntFlag{ - Name: PAGESIZE, - Aliases: []string{"p"}, - Usage: "Page Size, how many features are written per transaction to a target GPKG", - Value: 1000, - Required: false, - EnvVars: []string{strcase.ToScreamingSnake(PAGESIZE)}, - }, - &cli.BoolFlag{ - Name: KEEPPOINTSANDLINES, - Aliases: []string{"pl"}, - Usage: "Parts of polygons are reduced to points and lines after texel, keep these details or not.", - // TODO: This results in broken tiles, we should change this to a collapse option (optionally collapse rings from polygons to lines and points; grow lines as long as possible and add an option for line-length-to-keep) - Value: false, - Required: false, - EnvVars: []string{strcase.ToScreamingSnake(KEEPPOINTSANDLINES)}, - }, - &cli.BoolFlag{ - Name: IGNOREOUTSIDEGRID, - Aliases: []string{"iog"}, - Usage: "Ignore polygons that fall (partly) outside the grid, instead of panicking", - Value: false, - Required: false, - EnvVars: []string{strcase.ToScreamingSnake(IGNOREOUTSIDEGRID)}, + // `texel` without command defaults to `snap`. + app.DefaultCommand = "snap" + + // Define two commands: `texel snap` and `texel mvt`. + app.Commands = []*cli.Command{ + { + Name: "snap", + Usage: "Snap polygons in a source GeoPackage to a tile grid and write the result to a target GeoPackage", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: SOURCE, + Aliases: []string{"s"}, + Usage: "Source GPKG", + Required: true, + EnvVars: []string{strcase.ToScreamingSnake(SOURCE)}, + }, + &cli.StringFlag{ + Name: TARGET, + Aliases: []string{"t"}, + Usage: "Target GPKG (prefix). One GPKG per tile matrix cq zoom level will be created and the filename will be suffixed. E.g. target_6.gpkg", + Required: true, + EnvVars: []string{strcase.ToScreamingSnake(TARGET)}, + }, + &cli.BoolFlag{ + Name: OVERWRITE, + Aliases: []string{"o"}, + Usage: "Overwrite a target GPKG if it exists", + Required: false, + EnvVars: []string{strcase.ToScreamingSnake(OVERWRITE)}, + }, + &cli.StringFlag{ + Name: TILEMATRIXSET, + Aliases: []string{"tms"}, + Usage: `ID of a (built-in) tile matrix set. E.g.: NetherlandsRDNewQuad`, + Required: true, + EnvVars: []string{strcase.ToScreamingSnake(TILEMATRIXSET)}, + }, + &cli.StringFlag{ + Name: TILEMATRICES, + Aliases: []string{"z"}, + Usage: `IDs (usually the same as the zoom levels) of the tile matrices in the tile matrix set that should be processed for. JSON array of integers. E.g.: [4,5,6,7,8]`, + Required: true, + EnvVars: []string{strcase.ToScreamingSnake(TILEMATRICES)}, + }, + &cli.IntFlag{ + Name: PAGESIZE, + Aliases: []string{"p"}, + Usage: "Page Size, how many features are written per transaction to a target GPKG", + Value: 1000, + Required: false, + EnvVars: []string{strcase.ToScreamingSnake(PAGESIZE)}, + }, + &cli.BoolFlag{ + Name: KEEPPOINTSANDLINES, + Aliases: []string{"pl"}, + Usage: "Parts of polygons are reduced to points and lines after texel, keep these details or not.", + // TODO: This results in broken tiles, we should change this to a collapse option (optionally collapse rings from polygons to lines and points; grow lines as long as possible and add an option for line-length-to-keep) + Value: false, + Required: false, + EnvVars: []string{strcase.ToScreamingSnake(KEEPPOINTSANDLINES)}, + }, + &cli.BoolFlag{ + Name: IGNOREOUTSIDEGRID, + Aliases: []string{"iog"}, + Usage: "Ignore polygons that fall (partly) outside the grid, instead of panicking", + Value: false, + Required: false, + EnvVars: []string{strcase.ToScreamingSnake(IGNOREOUTSIDEGRID)}, + }, + &cli.BoolFlag{ + Name: REVERSEWINDINGORDER, + Aliases: []string{"rwo"}, + Usage: "Reverse the winding order of rings in polygons, contrary to the OGC simple features spec", + Value: false, + Required: false, + EnvVars: []string{strcase.ToScreamingSnake(REVERSEWINDINGORDER)}, + }, + &cli.BoolFlag{ + Name: ENCODETILES, + Aliases: []string{"enc"}, + Usage: "Add tables with mapbox-encoded geometries.", + Value: false, + Required: false, + EnvVars: []string{strcase.ToScreamingSnake(ENCODETILES)}, + }, + &cli.UintFlag{ + Name: TILEBUFFER, + Aliases: []string{"buf"}, + Usage: "Buffer (in internal pixels) used to select the tiles a polygon's geometry touches.", + Value: 0, + Required: false, + EnvVars: []string{strcase.ToScreamingSnake(TILEBUFFER)}, + }, + &cli.BoolFlag{ + Name: USELINETRACE, + Aliases: []string{"lt"}, + Usage: "Use precise line-trace tile classification instead of a simple buffered bounding box to select tiles.", + Value: false, + Required: false, + EnvVars: []string{strcase.ToScreamingSnake(USELINETRACE)}, + }, + &cli.StringFlag{ + Name: CLIP, + Aliases: []string{"c"}, + Usage: "Specify clip as \"[z,x,y]\", which denotes a quadrant in the target grid. Only encode geometries for tiles within this clip. Default: entire grid.", + Value: "[0,0,0]", + Required: false, + EnvVars: []string{strcase.ToScreamingSnake(CLIP)}, + }, + }, + Action: runSnap, }, - &cli.BoolFlag{ - Name: REVERSEWINDINGORDER, - Aliases: []string{"rwo"}, - Usage: "Reverse the winding order of rings in polygons, contrary to the OGC simple features spec", - Value: false, - Required: false, - EnvVars: []string{strcase.ToScreamingSnake(REVERSEWINDINGORDER)}, + { + Name: "mvt", + Usage: "Build MVT tiles from an already-encoded target GeoPackage (see --" + ENCODETILES + ")", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: MVTCONFIG, + Aliases: []string{"c"}, + Usage: "Config toml file describing datasources, tilesets and layers", + Required: true, + EnvVars: []string{strcase.ToScreamingSnake(MVTCONFIG)}, + }, + &cli.UintFlag{ + Name: TILEMATRIX, + Aliases: []string{"z"}, + Usage: "Target zoomlevel", + Required: true, + EnvVars: []string{strcase.ToScreamingSnake(TILEMATRIX)}, + }, + }, + Action: func(c *cli.Context) error { + return runMVT(c.String(MVTCONFIG), c.Uint(TILEMATRIX)) + }, }, } - app.Action = func(c *cli.Context) error { - tileMatrixSet, err := tms20.LoadEmbeddedTileMatrixSet(c.String(TILEMATRIXSET)) - if err != nil { - return err - } - var tileMatrixIDs []int - err = json.Unmarshal([]byte(c.String(TILEMATRICES)), &tileMatrixIDs) - if err != nil { - return err - } - if err = validateTileMatrixSet(tileMatrixSet, tileMatrixIDs); err != nil { - return err - } + err := app.Run(os.Args) + if err != nil { + log.Fatal(err) + } +} - _, err = os.Stat(c.String(SOURCE)) - if os.IsNotExist(err) { - log.Fatalf("error opening source GeoPackage: %s", err) - } +func runSnap(c *cli.Context) error { + tileMatrixSet, err := tms20.LoadEmbeddedTileMatrixSet(c.String(TILEMATRIXSET)) + if err != nil { + return err + } + var tileMatrixIDs []int + err = json.Unmarshal([]byte(c.String(TILEMATRICES)), &tileMatrixIDs) + if err != nil { + return err + } + if err = validateTileMatrixSet(tileMatrixSet, tileMatrixIDs); err != nil { + return err + } - source := gpkg.SourceGeopackage{} - source.Init(c.String(SOURCE)) - defer source.Close() + _, err = os.Stat(c.String(SOURCE)) + if os.IsNotExist(err) { + log.Fatalf("error opening source GeoPackage: %s", err) + } + + clip, err := readClip(c.String(CLIP)) + if err != nil { + return err + } - targetPathFmt := injectSuffixIntoPath(c.String(TARGET)) + source := gpkg.SourceGeopackage{} + source.Init(c.String(SOURCE)) + defer source.Close() - gpkgTargets := make(map[int]*gpkg.TargetGeopackage, len(tileMatrixIDs)) - overwrite := c.Bool(OVERWRITE) - pagesize := c.Int(PAGESIZE) // TODO divide by tile matrices count - snapConfig := snap.Config{ - KeepPointsAndLines: c.Bool(KEEPPOINTSANDLINES), - IgnoreOutsideGrid: c.Bool(IGNOREOUTSIDEGRID), - ReverseWindingOrder: c.Bool(REVERSEWINDINGORDER), - } - for _, tmID := range tileMatrixIDs { - gpkgTargets[tmID] = initGPKGTarget(targetPathFmt, tmID, overwrite, pagesize) - defer gpkgTargets[tmID].Close() // yes, supposed to go here, want to close all at end of func - } + targetPathFmt := injectSuffixIntoPath(c.String(TARGET)) - tables := source.GetTableInfo() - for _, target := range gpkgTargets { - err = target.CreateTables(tables) - if err != nil { - log.Fatalf("error initialization the target GeoPackage: %s", err) - } - } + gpkgTargets := make(map[int]*gpkg.TargetGeopackage, len(tileMatrixIDs)) + overwrite := c.Bool(OVERWRITE) + pagesize := c.Int(PAGESIZE) // TODO divide by tile matrices count + snapConfig := processing.Config{ + KeepPointsAndLines: c.Bool(KEEPPOINTSANDLINES), + IgnoreOutsideGrid: c.Bool(IGNOREOUTSIDEGRID), + ReverseWindingOrder: c.Bool(REVERSEWINDINGORDER), + EncodeTiles: c.Bool(ENCODETILES), + Buffer: c.Uint(TILEBUFFER), + UseLineTrace: c.Bool(USELINETRACE), + Clip: processing.Clip{Z: clip[0], X: uint(clip[1]), Y: uint(clip[2])}, //nolint: gosec // G115 + } - log.Println("=== start snapping ===") + for _, tmID := range tileMatrixIDs { + gpkgTargets[tmID] = initGPKGTarget(targetPathFmt, tmID, overwrite, pagesize, c.Bool(ENCODETILES)) + defer gpkgTargets[tmID].Close() // yes, supposed to go here, want to close all at end of func + } - // need a copied map because of type difference processing.Target vs gpkg.TargetGeopackage - targets := make(map[int]processing.Target, len(gpkgTargets)) - for tmID, target := range gpkgTargets { - targets[tmID] = target - } - // Process the tables sequentially - for _, table := range tables { - log.Printf(" snapping %s", table.Name) - for _, target := range gpkgTargets { - source.Table = table - target.Table = table - } - processBySnapping(source, targets, tileMatrixSet, snapConfig) - log.Printf(" finished %s", table.Name) + tables := source.GetTableInfo() + for _, target := range gpkgTargets { + err = target.CreateTables(tables) + if err != nil { + return fmt.Errorf("error initialization the target GeoPackage: %w", err) } + } + + log.Println("=== start snapping ===") + processSnapTables(tables, source, gpkgTargets, snapConfig, tileMatrixSet) + log.Println("=== done snapping ===") - log.Println("=== done snapping ===") - return nil + return nil +} + +// Loop over tables, then loop over targets, and process +func processSnapTables(tables []gpkg.Table, source gpkg.SourceGeopackage, gpkgTargets map[int]*gpkg.TargetGeopackage, snapConfig processing.Config, tileMatrixSet tms20.TileMatrixSet) { + // need a copied map because of type difference processing.Target vs gpkg.TargetGeopackage + targets := make(map[int]processing.Target, len(gpkgTargets)) + for tmID, target := range gpkgTargets { + targets[tmID] = target } - err := app.Run(os.Args) - if err != nil { - log.Fatal(err) + // Process the tables sequentially + for _, table := range tables { + log.Printf(" snapping %s", table.Name) + for _, target := range gpkgTargets { + source.Table = table + target.Table = table + } + processBySnapping(source, targets, tileMatrixSet, snapConfig) + log.Printf(" finished %s", table.Name) } } @@ -200,7 +291,7 @@ func validateTileMatrixSet(tms tms20.TileMatrixSet, tileMatrixIDs []tms20.TMID) return pointindex.IsQuadTree(tms) } -func initGPKGTarget(targetPathFmt string, tmID int, overwrite bool, pagesize int) *gpkg.TargetGeopackage { +func initGPKGTarget(targetPathFmt string, tmID int, overwrite bool, pagesize int, encodeTiles bool) *gpkg.TargetGeopackage { targetPath := fmt.Sprintf(targetPathFmt, tmID) if overwrite { err := os.Remove(targetPath) @@ -212,6 +303,7 @@ func initGPKGTarget(targetPathFmt string, tmID int, overwrite bool, pagesize int } target := gpkg.TargetGeopackage{} target.Init(targetPath, pagesize) + target.EncodeTiles = encodeTiles return &target } @@ -222,8 +314,109 @@ func injectSuffixIntoPath(p string) string { return path.Join(dir, name+"_%v"+ext) } -func processBySnapping(source processing.Source, targets map[tms20.TMID]processing.Target, tileMatrixSet tms20.TileMatrixSet, snapConfig snap.Config) { - processing.ProcessFeatures(source, targets, func(p geom.Polygon, tmIDs []tms20.TMID) map[tms20.TMID][]geom.Polygon { - return snap.SnapPolygon(p, tileMatrixSet, tmIDs, snapConfig) - }) +func readClip(clipString string) ([]int, error) { + var clip []int + err := json.Unmarshal([]byte(clipString), &clip) + if err != nil { + return nil, err + } + if len(clip) != 3 { + return nil, fmt.Errorf("error: clip must have exactly three values, got %d", len(clip)) + } + return clip, nil +} + +func processBySnapping(source processing.Source, targets map[tms20.TMID]processing.Target, tileMatrixSet tms20.TileMatrixSet, snapConfig processing.Config) { + snapFunction := snap.SnapGeometry + deepestTMID := slices.Max(mapslicehelp.MapKeys(targets)) + rawFactory := pointindex.Factory(tileMatrixSet, deepestTMID) + factory := func() processing.PIndex { return rawFactory() } + processing.ProcessFeatures(source, targets, snapFunction, factory, snapConfig) +} + +// Construct Layer info from config; init gpkg sources +// Make sure to reuse gpkg handles +// Also return helper that closes all sources +func buildLayers(z uint, rawConfig config.TomlConfig) ([]processing.Layer, func()) { + type initedSource struct { + source gpkg.MVTSourceGeopackage + tables map[string]gpkg.Table + } + dataSourceDictionary := processing.DatasourceToDictionary(rawConfig.DataSource) + layers := make([]processing.Layer, 0) + sources := make(map[string]initedSource) + tileset := rawConfig.Tileset + for _, rawLayer := range tileset.Layer { + if z < rawLayer.MinZoom || z > rawLayer.MaxZoom { + continue + } + // Only init gpkg sources once + if _, present := sources[rawLayer.DataSource]; !present { + source, tableMap := initMvtSource(rawLayer, dataSourceDictionary) + sources[rawLayer.DataSource] = initedSource{source, tableMap} + } + initedSource := sources[rawLayer.DataSource] + table, present := initedSource.tables[rawLayer.TableName] + if !present { + log.Printf("table %s not found in datasource %s; skipping layer %s\n", rawLayer.TableName, rawLayer.DataSource, rawLayer.Name) + } else { + // Pair gpkg handle with layer table + source := initedSource.source + source.Table = table + layer := processing.BuildLayer(rawLayer.Name, source) + layers = append(layers, layer) + } + } + closeSources := func() { + for _, s := range sources { + s.source.Close() + } + } + return layers, closeSources +} + +func initMvtSource(rawLayer config.LayerConfig, dataSourceDicationary map[string]string) (gpkg.MVTSourceGeopackage, map[string]gpkg.Table) { + path, present := dataSourceDicationary[rawLayer.DataSource] + if !present { + err := fmt.Errorf("configuration error for layer %s, datasource not found: %s", rawLayer.Name, rawLayer.DataSource) + panic(err) + } + if _, err := os.Stat(path); os.IsNotExist(err) { + err = fmt.Errorf("source Geopackage does not exist: %s", path) + panic(err) + } + source := gpkg.MVTSourceGeopackage{} + source.Init(path) + tables := source.GetTableInfo() + tableMap := make(map[string]gpkg.Table, len(tables)) + for _, table := range tables { + tableMap[table.Name] = table + } + return source, tableMap +} + +// runMVT wires the config, layers and target together to build +// and write the MVT tiles for the requested zoomlevel. +func runMVT(configPath string, zoomlevel uint) error { + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return fmt.Errorf("config file does not exist: %s", configPath) + } + + rawConfig, err := config.ParseMVTConfig(configPath) + if err != nil { + return err + } + if rawConfig.Tileset == nil { + return errors.New("no tileset found in TOML config") + } + + layers, closeSources := buildLayers(zoomlevel, rawConfig) + defer closeSources() + + mvtTarget, err := writetile.NewTileWriter(rawConfig) + if err != nil { + return err + } + + return processing.BuildAndWriteMVTTiles(layers, zoomlevel, mvtTarget) } diff --git a/mapslicehelp/mapslicehelp.go b/mapslicehelp/mapslicehelp.go index 8ce89c1..f06da78 100644 --- a/mapslicehelp/mapslicehelp.go +++ b/mapslicehelp/mapslicehelp.go @@ -51,6 +51,23 @@ func OrderedMapKeys[K comparable, V any](m *orderedmap.OrderedMap[K, V]) []K { return l } +// Assumes all indices 0, 1, ..., len(index)-1 are used exactly once. +func OrderedByIndex[K comparable](index map[K]uint32) []K { + ordered := make([]K, len(index)) + for k, i := range index { + ordered[i] = k + } + return ordered +} + +func MapKeys[K comparable, V any](m map[K]V) []K { + keys := make([]K, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + func RemoveSequences[V any, K comparable](s []V, sequencesToRemove *sortedmap.SortedMap[K, [2]int]) (newS []V) { mmap := sequencesToRemove.Map() keepFrom := 0 diff --git a/pointindex/detect.go b/pointindex/detect.go new file mode 100644 index 0000000..9b5e4dc --- /dev/null +++ b/pointindex/detect.go @@ -0,0 +1,527 @@ +package pointindex + +import ( + "math" + + "github.com/go-spatial/geom" + "github.com/pdok/texel/intgeom" + "github.com/pdok/texel/mapslicehelp" + "github.com/pdok/texel/mathhelp" + "github.com/pdok/texel/morton" + "github.com/pdok/texel/tile" + "github.com/pdok/texel/tms20" +) + +type SegmentIdx struct { + ringIdx int + pointIdx int +} + +type TileClassification int + +const ( + ClassificationUnknown TileClassification = iota + ClassificationIntersect + ClassificationInside + ClassificationOutside +) + +////////////////////////////// +// Tile detection functions // +////////////////////////////// + +func (ix *PointIndex) DetectTilesViaLineTrace(g geom.Geometry, tmsID tms20.TMID, buffer uint) []tile.Tile { + switch g := g.(type) { + case geom.Polygon: + return ix.lineTracePolygon(g, tmsID, buffer) + case geom.LineString: + return ix.lineTraceLine(g, tmsID, buffer) + case geom.Point: + return ix.GetQBBoxWithBuffer(tmsID, buffer) + default: + return nil + + } +} + +// Find all tiles in bbox of geometry currently inserted in index, inflated by buffer +// Goes to pixel level to inflate, then lists all tiles +func (ix *PointIndex) GetQBBoxWithBuffer(tmsID tms20.TMID, bufferSize uint) []tile.Tile { + internalPixelLevel := ix.InternalPixelLevelFromTmsID(tmsID) + tileLevel := levelFromTmsId(tmsID) + quadrants := ix.quadrants[ix.deepestLevel] + + minX := ^uint(0) + maxX := uint(0) + minY := ^uint(0) + maxY := uint(0) + + for z := range quadrants { + x, y := morton.FromZ(z) + minX = min(x, minX) + maxX = max(x, maxX) + minY = min(y, minY) + maxY = max(y, maxY) + } + + var tileMinX, tileMinY uint + if minX < bufferSize { + tileMinX = 0 + } else { + tileMinX = (minX - bufferSize) >> (internalPixelLevel - tileLevel) + } + if minY < bufferSize { + tileMinY = 0 + } else { + tileMinY = (minY - bufferSize) >> (internalPixelLevel - tileLevel) + } + + maxTileCoord := mathhelp.Pow2(tileLevel) - 1 + tileMaxX := min((maxX+bufferSize)>>(internalPixelLevel-tileLevel), maxTileCoord) + tileMaxY := min((maxY+bufferSize)>>(internalPixelLevel-tileLevel), maxTileCoord) + + tiles := make([]tile.Tile, 0, (tileMaxX-tileMinX+1)*(tileMaxY-tileMinY+1)) + for i := range tileMaxX - tileMinX + 1 { + for j := range tileMaxY - tileMinY + 1 { + tileX := tileMinX + i + tileY := tileMinY + j + tiles = append(tiles, ix.makeTile(tileX, tileY, tileLevel, false)) + } + } + return tiles +} + +// Get BBox at deepest level. Currently unused, but more efficient than +// BBox function above when buffer is zero: might optimise in the future. +func (ix *PointIndex) GetPrimitiveQBBox(l Level) []tile.Tile { + quadrants := ix.quadrants[l] + + minX := ^uint(0) + maxX := uint(0) + minY := ^uint(0) + maxY := uint(0) + + for z := range quadrants { + x, y := morton.FromZ(z) + minX = min(x, minX) + maxX = max(x, maxX) + minY = min(y, minY) + maxY = max(y, maxY) + } + + quadrantSlice := make([]tile.Tile, (maxY-minY+1)*(maxX-minX+1)) + for i := range maxX - minX + 1 { + for j := range maxY - minY + 1 { + extent, _ := ix.getQuadrantExtentAndCentroid(l, minX+i, minY+j, ix.intExtent) + newTile := tile.Tile{ + Extent: extent, + X: minX + i, + Y: minY + i, + IsContained: false, + } + quadrantSlice[i*(maxY-minY+1)+j] = newTile + } + } + return quadrantSlice +} + +////////////////////////// +// General line tracing // +////////////////////////// + +// registerFunc marks a tile at (xCoord, yCoord) (in tile coordinates at +// level l) as touched. Callers that need to associate the touched tile +// with a segment should do so via a variable captured in their closure. +type registerFunc func(xCoord, yCoord uint, l Level) + +// Line trace over a line, registering tiles hit with `register`. +// Given a line, detect in which quadrant the line advances. Essentially do a breadth-first search in that direction. +func (ix *PointIndex) lineTrace(line geom.Line, tileLevel, intPixLevel Level, buffer uint, register registerFunc) { + intLine := intgeom.FromGeomLine(line) + + // Detect direction + var dx, dy int + if intLine.Point1().X() < intLine.Point2().X() { + dx = 1 + } else { + dx = -1 + } + if intLine.Point1().Y() < intLine.Point2().Y() { + dy = 1 + } else { + dy = -1 + } + + startTileX, startTileY := ix.findTile(intLine.Point1(), tileLevel) + startX, startY := int(startTileX), int(startTileY) //nolint:gosec // G115 + bufferSize := ix.getResolution(intPixLevel) * intgeom.M(buffer) //nolint:gosec // G115 + + // We will advance in a certain direction. + // Here, we cover the tiles "behind" us. + ix.tryRegisterTile(intLine, startX-dx, startY+dy, tileLevel, bufferSize, register) + ix.tryRegisterTile(intLine, startX-dx, startY, tileLevel, bufferSize, register) + ix.tryRegisterTile(intLine, startX-dx, startY-dy, tileLevel, bufferSize, register) + ix.tryRegisterTile(intLine, startX, startY-dy, tileLevel, bufferSize, register) + ix.tryRegisterTile(intLine, startX+dx, startY-dy, tileLevel, bufferSize, register) + ix.tryRegisterTile(intLine, startX, startY, tileLevel, bufferSize, register) + + type coord struct{ x, y int } + + // Advance in steps. At each step, check all relevant tiles of an anti-diagonal. + // Initial anti-diagonal contains only start tile. + // We keep track of the previous two steps; we need one extra step for the rare case a + // line passes exactly through a corner at buffer = 0. + frontier := []coord{{startX, startY}} + prevFrontier := make([]coord, 0) + + // Candidates for the next step are determined by previous successes. Loop stops after we have + // run out of successes. + for len(prevFrontier)+len(frontier) > 0 { + // Set of next candidates + nextSet := make(map[coord]bool, len(frontier)*2+len(prevFrontier)) + for _, cur := range frontier { + nextSet[coord{cur.x + dx, cur.y}] = true + nextSet[coord{cur.x, cur.y + dy}] = true + } + for _, prev := range prevFrontier { + nextSet[coord{prev.x + dx, prev.y + dy}] = true + } + + // Try registering and record successes + next := make([]coord, 0, len(nextSet)) + for c := range nextSet { + if ix.tryRegisterTile(intLine, c.x, c.y, tileLevel, bufferSize, register) { + next = append(next, c) + } + } + + // Update set of past successes + prevFrontier = frontier + frontier = next + } +} + +// Check whether tile hits line and registers upon success. Also returns success. +func (ix *PointIndex) tryRegisterTile(line intgeom.Line, x, y int, l Level, bufferSize intgeom.M, register registerFunc) bool { + maxCoord := int(mathhelp.Pow2(l)) - 1 //nolint:gosec // G115 + if x < 0 || y < 0 || x > maxCoord || y > maxCoord { + return false // out of bounds: no tile there, nothing to register + } + ux, uy := uint(x), uint(y) + extent, _ := ix.getQuadrantExtentAndCentroid(l, ux, uy, ix.intExtent) + if tileIntersectsLine(line, extent, bufferSize) { + register(ux, uy, l) + return true + } + return false +} + +// Detect whether line intersects extent, inflated by buffer. +// Uses same logic as quadrants: left and bottom edge belong to extent, top and right do not. +func tileIntersectsLine(line intgeom.Line, extent intgeom.Extent, buffer intgeom.M) bool { + bufferedExtent := intgeom.Extent{ + extent.MinX() - buffer, + extent.MinY() - buffer, + extent.MaxX() + buffer, + extent.MaxY() + buffer, + } + + return lineIntersects(line, bufferedExtent) +} + +/////////////////////////////// +// Line tracing for polygons // +/////////////////////////////// + +// Line tracing for polygons is special in the sense that we do not just trace the lines, +// but also find tiles that lie entirely on the interior of the polygon. This second process +// we call "filling". + +// Detect tiles for polygon using line tracing. +// First line trace, then fill remaining tiles. This enables us to classify tiles as being "Inside" and "Outside" the polygon. +// Then process output of `fillTiles` to return a list at tile level. +func (ix *PointIndex) lineTracePolygon(polygon geom.Polygon, tmsID tms20.TMID, buffer uint) (tiles []tile.Tile) { + tileLevel := levelFromTmsId(tmsID) + segements, classification := ix.lineTracePolygonEdges(polygon, tmsID, buffer) + ix.fillTiles(tileLevel, 0, 0, true, segements, classification, polygon) + + // Extract list of tiles at tile level. + tiles = make([]tile.Tile, 0) + for level, classificationAtLevel := range classification { + for z, class := range classificationAtLevel { + switch class { + case ClassificationInside: + size := mathhelp.Pow2((tileLevel - level) * 2) + baseZ := z << (2 * (tileLevel - level)) + for i := range size { + tiles = append(tiles, ix.makeTileZ(baseZ+i, tileLevel, true)) + } + case ClassificationOutside: + continue + case ClassificationIntersect: + if level == tileLevel { + tiles = append(tiles, ix.makeTileZ(z, tileLevel, false)) + } + case ClassificationUnknown: + panic("ClassificationUnknown tile for polygon during linetrace") + } + } + } + return tiles +} + +// Loop over polygon edges and apply line trace. +// Use a special registering function that keeps track of which edges hit which tiles. +func (ix *PointIndex) lineTracePolygonEdges(polygon geom.Polygon, tmsID tms20.TMID, buffer uint) (segments map[morton.Z][]SegmentIdx, classification map[Level]map[morton.Z]TileClassification) { + tileLevel := levelFromTmsId(tmsID) + intPixLevel := ix.InternalPixelLevelFromTmsID(tmsID) + + // Initialize data + segments = make(map[morton.Z][]SegmentIdx) + classification = make(map[Level]map[morton.Z]TileClassification, tileLevel+1) + for l := range tileLevel + 1 { + classification[l] = make(map[morton.Z]TileClassification) + } + + // Helper registering function that marks a tile and all its parents as "Intersecting". + var markIntersected func(l Level, z morton.Z) + markIntersected = func(l Level, z morton.Z) { + if classification[l][z] == ClassificationIntersect { + return + } + classification[l][z] = ClassificationIntersect + if l == 0 { + return + } + markIntersected(l-1, z>>2) + } + + // Register function that registers when a segment hits a tile and then marks tiles as "Intersected". + // Note the use of `idx` in this closure: we manually update it in the loop below. + var idx SegmentIdx + register := func(x, y uint, l Level) { + z := morton.MustToZ(x, y) + segments[z] = append(segments[z], idx) + markIntersected(l, z) + } + + // Loop over all edges + for ringIdx, ring := range polygon.LinearRings() { + for pointIdx := range ring { + // Update `idx` for use in the `register` closure + idx = SegmentIdx{ringIdx: ringIdx, pointIdx: pointIdx} + // Apply line trace + line := geom.Line{ring[pointIdx], ring[(pointIdx+1)%len(ring)]} + ix.lineTrace(line, tileLevel, intPixLevel, buffer, register) + } + } + return segments, classification +} + +// Recurse into quadtree, filling entire area as either "Inside", "Outside", or "Intersect" at tile level. +// A classification of "Inside" or "Outside" at a higher level means that all children at tile level have this classification. +// We assume that "Intersect" at the tile level is already classified, and that parents are also marked as "Intersect". +// This means that tiles marked as "Intersect" will need to be recursed into for fine-grained classification. +// It also means that at any level, all children of an unmarked tile have the same classification +// This enables the classification at higher levels. +// We also assume that `segments` contains the data of which segments of polygon intersect which tiles at tile level. +func (ix *PointIndex) fillTiles(tileLevel, currentLevel Level, currentZ morton.Z, containsAll bool, segments map[morton.Z][]SegmentIdx, classification map[Level]map[morton.Z]TileClassification, polygon geom.Polygon) { + if tileLevel == currentLevel { + return + } + + // Get children + children := getChildren(currentZ) + intersectingChildren := make([]morton.Z, 0, 4) + nextLevel := currentLevel + 1 + + // First classify unknown children. This can be done at a higher level + for _, child := range children { + if _, present := classification[nextLevel][child]; present { + intersectingChildren = append(intersectingChildren, child) + continue + } + if containsAll { + classification[nextLevel][child] = ClassificationOutside + } else { + childAtTileLevel := child << ((tileLevel - nextLevel) * 2) + classification[nextLevel][child] = ix.fillTile(childAtTileLevel, tileLevel, segments, classification, polygon) + } + } + + containsAll = containsAll && len(intersectingChildren) < 2 + + // For intersecting children we recurse to the next level + for _, child := range intersectingChildren { + if _, present := classification[nextLevel][child]; present { + ix.fillTiles(tileLevel, nextLevel, child, containsAll, segments, classification, polygon) + } + } +} + +func getChildren(z morton.Z) [4]morton.Z { + shift := z << 2 + return [4]morton.Z{shift, shift + 1, shift + 2, shift + 3} +} + +// Use raycast to classify a tile as "Inside" or "Outside". Does not work on tiles that are "Intersect". +// tileLevel needs to be the actual tile level and z needs to be a coordinate at this level +// Relies on classification and segments as pre-computed values. +func (ix *PointIndex) fillTile(z morton.Z, tileLevel Level, segments map[morton.Z][]SegmentIdx, classification map[Level]map[morton.Z]TileClassification, polygon geom.Polygon) TileClassification { + x, y := morton.FromZ(z) + intersectingTilesLeft := ix.findIntersectingTilesLeft(x, y, tileLevel, classification) + + tileHeightCoord := ix.getResolution(tileLevel)*intgeom.M(y) + ix.intExtent.MinY() //nolint:gosec // G115 + + seen := make(map[SegmentIdx]bool) + numIntersections := 0 + + // Raycast and loop over intersecting segments + for _, z := range intersectingTilesLeft { + for _, segment := range segments[z] { + if seen[segment] { + continue + } + seen[segment] = true + ring := polygon.LinearRings()[segment.ringIdx] + + y1 := intgeom.FromGeomOrd(ring[segment.pointIdx][1]) + y2 := intgeom.FromGeomOrd(ring[(segment.pointIdx+1)%len(ring)][1]) + + minY := min(y1, y2) + maxY := max(y1, y2) + + // Logic for deciding relevant segments + // Needed for when ray intersects vertex of polygon + switch { + case minY == maxY: + case maxY < tileHeightCoord: + case minY >= tileHeightCoord: + default: + numIntersections++ + } + } + } + + // Result determined by "Jordan Curve Theorem" + if numIntersections%2 == 0 { + return ClassificationOutside + } + return ClassificationInside +} + +// Helper function for raycasting function `fillTile`. Finds all tiles left of argument containing segments of polygon. +// tileLevel needs to be the actual tile level and x and y need to be coordinates for this level. +// Relies on `classification` as pre-computed information. +// Searches by binary search. +func (ix *PointIndex) findIntersectingTilesLeft(x, y, tileLevel Level, classification map[Level]map[morton.Z]TileClassification) []morton.Z { + intersectingCurrentLevel := []morton.Z{0} + var intersectingNextLevel []morton.Z + var leftChild, rightChild morton.Z + + // Binary search over the tile level + for currentLevel := range tileLevel { + intersectingNextLevel = make([]morton.Z, 0) + + // Coordinates of parent of tile at the next level + xAtNextLevel := x >> (tileLevel - currentLevel - 1) + yAtNextLevel := y >> (tileLevel - currentLevel - 1) + + // Decide whether we need the upper or lower children for the next level + nextLevelDown := yAtNextLevel%2 == 0 + for _, z := range intersectingCurrentLevel { + // Compute candidates + if nextLevelDown { + leftChild = z << 2 + rightChild = (z << 2) + 1 + } else { + leftChild = (z << 2) + 2 + rightChild = (z << 2) + 3 + } + + // If left tile present, register it + if _, present := classification[currentLevel+1][leftChild]; present { + intersectingNextLevel = append(intersectingNextLevel, leftChild) + } + + // Right tile needs to be present AND to the left of argument + rightX, _ := morton.FromZ(rightChild) + if _, present := classification[currentLevel+1][rightChild]; present && rightX <= xAtNextLevel { + intersectingNextLevel = append(intersectingNextLevel, rightChild) + } + } + intersectingCurrentLevel = intersectingNextLevel + } + return intersectingCurrentLevel +} + +//////////////////////////// +// Line tracing for lines // +//////////////////////////// + +// Line trace by looping over segments +func (ix *PointIndex) lineTraceLine(line geom.LineString, tmsID tms20.TMID, buffer uint) []tile.Tile { + tileSet := make(map[tile.Tile]bool) + tileLevel := levelFromTmsId(tmsID) + internalPixelLevel := ix.InternalPixelLevelFromTmsID(tmsID) + + register := func(x, y uint, l Level) { + tile := ix.makeTile(x, y, l, false) + tileSet[tile] = true + } + + segments, _ := line.AsSegments() + for _, segment := range segments { + ix.lineTrace(segment, tileLevel, internalPixelLevel, buffer, register) + } + + return mapslicehelp.MapKeys(tileSet) +} + +////////////////////// +// Helper functions // +////////////////////// + +func (ix *PointIndex) makeTileZ(z morton.Z, l Level, isContained bool) tile.Tile { + x, y := morton.FromZ(z) + return ix.makeTile(x, y, l, isContained) +} + +func (ix *PointIndex) makeTile(x, y uint, l Level, isContained bool) tile.Tile { + extent, _ := ix.getQuadrantExtentAndCentroid(l, x, y, ix.intExtent) + return tile.Tile{ + Extent: extent, + X: x, + Y: mathhelp.Pow2(l) - 1 - y, + IsContained: isContained, + } +} + +func (ix *PointIndex) getResolution(level Level) intgeom.M { + return ix.intExtent.XSpan() / int64(mathhelp.Pow2(level)) //nolint:gosec // G115 +} + +func (ix *PointIndex) InternalPixelLevelFromTmsID(deepestTIMID tms20.TMID) Level { + levelDiff := uint(math.Log2(float64(ix.tilePixels))) + uint(math.Log2(float64(ix.internalPixels))) + return uint(deepestTIMID) + levelDiff //nolint:gosec // G115 +} + +func (ix *PointIndex) TmsIDFromInternalPixelLevel(level Level) tms20.TMID { + levelDiff := uint(math.Log2(float64(ix.tilePixels))) + uint(math.Log2(float64(ix.internalPixels))) + return int(level) - int(levelDiff) //nolint:gosec // G115 +} + +func levelFromTmsId(tms tms20.TMID) Level { + return Level(tms) //nolint:gosec // G115 integers < 40 +} + +func (ix *PointIndex) findTile(p *intgeom.Point, l Level) (tileX, tileY uint) { + levelDiff := ix.deepestLevel - l + //nolint:gosec // G115 + deepestTileX := uint(p.X()-ix.intExtent.MinX()) / uint(ix.deepestRes) + //nolint:gosec // G115 + deepestTileY := uint(p.Y()-ix.intExtent.MinY()) / uint(ix.deepestRes) + tileX = deepestTileX >> levelDiff + tileY = deepestTileY >> levelDiff + return +} diff --git a/pointindex/detect_test.go b/pointindex/detect_test.go new file mode 100644 index 0000000..eb8481c --- /dev/null +++ b/pointindex/detect_test.go @@ -0,0 +1,789 @@ +package pointindex + +import ( + "sort" + "testing" + + "github.com/go-spatial/geom" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pdok/texel/intgeom" + "github.com/pdok/texel/mathhelp" + "github.com/pdok/texel/morton" + "github.com/pdok/texel/tile" + "github.com/pdok/texel/tms20" +) + +// Note that wantTiles is specified with left-down origin, but makeTile introduces the left-up origin. +func TestPointIndex_GetQBBoxWithBuffer(t *testing.T) { + type tileCoord struct{ x, y uint } + tests := []struct { + name string + tmsID tms20.TMID + tilePixels uint + deepestLevel Level + points [][2]int // Points to be inserted at deepestlevel + bufferSize uint + wantTiles []tileCoord + }{ + { + // deepestLevel 4 -> 16x16 pixel grid, tmsID 2 -> 4x4 tiles of 4x4 pixels each. + name: "points all within one tile, no buffer", + tmsID: 2, + tilePixels: 4, + points: [][2]int{{5, 5}, {6, 6}}, + bufferSize: 0, + wantTiles: []tileCoord{{1, 1}}, + }, + { + name: "point in one tile, buffer expands to multiple tiles", + tmsID: 2, + tilePixels: 4, + points: [][2]int{{0, 0}}, + bufferSize: 6, + wantTiles: []tileCoord{{0, 0}, {0, 1}, {1, 0}, {1, 1}}, + }, + { + name: "non-square rectangle of tiles", + tmsID: 2, + tilePixels: 4, + points: [][2]int{{4, 4}, {11, 5}}, + bufferSize: 0, + wantTiles: []tileCoord{{1, 1}, {2, 1}}, + }, + { + name: "points in diagonal tiles, expands to rectangle", + tmsID: 2, + tilePixels: 4, + points: [][2]int{{3, 3}, {4, 4}}, + bufferSize: 0, + wantTiles: []tileCoord{{0, 0}, {0, 1}, {1, 0}, {1, 1}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ix := newPointIndex(tt.tmsID, tt.tilePixels, 1, 1.0) + for _, p := range tt.points { + require.NoError(t, ix.InsertCoord(p[0], p[1])) + } + + l := levelFromTmsId(tt.tmsID) + want := make([]tile.Tile, 0, len(tt.wantTiles)) + for _, tc := range tt.wantTiles { + want = append(want, ix.makeTile(tc.x, tc.y, l, false)) + } + + got := ix.GetQBBoxWithBuffer(tt.tmsID, tt.bufferSize) + assert.ElementsMatch(t, want, got) + }) + } +} + +// registeredTile records a single call to a RegisterFunc during a test. +type registeredTile struct { + x, y uint +} + +// Register function for testing +func recordingRegister(dst *[]registeredTile) registerFunc { + return func(tileX, tileY uint, l Level) { + maxCoord := mathhelp.Pow2(l) - 1 + if tileX > maxCoord || tileY > maxCoord { + return + } + *dst = append(*dst, registeredTile{tileX, tileY}) + } +} + +// order and deduplicate slice of registeredTile (for testing) +func uniqueTileCoords(records []registeredTile) [][2]uint { + seen := map[[2]uint]bool{} + var out [][2]uint + for _, r := range records { + key := [2]uint{r.x, r.y} + if !seen[key] { + seen[key] = true + out = append(out, key) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i][0] != out[j][0] { + return out[i][0] < out[j][0] + } + return out[i][1] < out[j][1] + }) + return out +} + +// Pointindex with centre not at (0,0) +func newOffsetPointIndex(deepestLevel Level, cellSize, originX, originY float64) *PointIndex { + deepestSize := mathhelp.Pow2(deepestLevel) + span := cellSize * float64(deepestSize) + intExtent := intgeom.Extent{ + intgeom.FromGeomOrd(originX), intgeom.FromGeomOrd(originY), + intgeom.FromGeomOrd(originX + span), intgeom.FromGeomOrd(originY + span), + } + return &PointIndex{ + Quadrant: Quadrant{intExtent: intExtent}, + deepestLevel: deepestLevel, + deepestSize: deepestSize, + //nolint:gosec // G115 + deepestRes: intExtent.XSpan() / int64(deepestSize), + } +} + +func TestLineTrace_TilesTouched(t *testing.T) { + tests := []struct { + name string + deepestLevel Level + l Level + cellSize float64 + originX float64 + originY float64 + line geom.Line + buffer uint + want [][2]uint + }{ + { + name: "no buffer, +x+y diagonal", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{2, 2}, geom.Point{14, 10}}, + buffer: 0, + want: [][2]uint{{0, 0}, {1, 0}, {1, 1}, {2, 1}, {2, 2}, {3, 2}}, + }, + { + name: "no buffer, -x-y diagonal (reverse of +x+y)", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{14, 10}, geom.Point{2, 2}}, + buffer: 0, + want: [][2]uint{{0, 0}, {1, 0}, {1, 1}, {2, 1}, {2, 2}, {3, 2}}, + }, + { + name: "no buffer, +x-y diagonal", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{2, 10}, geom.Point{14, 2}}, + buffer: 0, + want: [][2]uint{{0, 2}, {1, 1}, {1, 2}, {2, 0}, {2, 1}, {3, 0}}, + }, + { + name: "no buffer, -x+y diagonal (reverse of +x-y)", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{14, 2}, geom.Point{2, 10}}, + buffer: 0, + want: [][2]uint{{0, 2}, {1, 1}, {1, 2}, {2, 0}, {2, 1}, {3, 0}}, + }, + { + name: "buffer, +x+y diagonal (reaches beyond index limit)", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{2, 2}, geom.Point{14, 9}}, + buffer: 2, + want: [][2]uint{{0, 0}, {0, 1}, {1, 0}, {1, 1}, {1, 2}, {2, 0}, {2, 1}, {2, 2}, {3, 1}, {3, 2}}, + }, + { + name: "buffer, -x-y diagonal (ververse of +x+y)", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{14, 9}, geom.Point{2, 2}}, + buffer: 2, + want: [][2]uint{{0, 0}, {0, 1}, {1, 0}, {1, 1}, {1, 2}, {2, 0}, {2, 1}, {2, 2}, {3, 1}, {3, 2}}, + }, + { + name: "buffer, +x-y diagonal", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{2, 10}, geom.Point{14, 2}}, + buffer: 2, + want: [][2]uint{{0, 1}, {0, 2}, {0, 3}, {1, 0}, {1, 1}, {1, 2}, {1, 3}, {2, 0}, {2, 1}, {2, 2}, {3, 0}, {3, 1}}, + }, + { + name: "buffer, -x+y diagonal: (reverse of +x-y)", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{14, 2}, geom.Point{2, 10}}, + buffer: 2, + want: [][2]uint{{0, 1}, {0, 2}, {0, 3}, {1, 0}, {1, 1}, {1, 2}, {1, 3}, {2, 0}, {2, 1}, {2, 2}, {3, 0}, {3, 1}}, + }, + { + name: "horizontal line", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{2, 5}, geom.Point{14, 5}}, + buffer: 0, + want: [][2]uint{{0, 1}, {1, 1}, {2, 1}, {3, 1}}, + }, + { + name: "vertical line", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{5, 2}, geom.Point{5, 14}}, + buffer: 0, + want: [][2]uint{{1, 0}, {1, 1}, {1, 2}, {1, 3}}, + }, + { + name: "end in upper right corner", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{2, 2}, geom.Point{3, 3}}, + buffer: 0, + want: [][2]uint{{0, 0}}, + }, + { + name: "alongside upper edge", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{2, 2}, geom.Point{3, 3}}, + buffer: 1, + want: [][2]uint{{0, 0}, {0, 1}, {1, 0}, {1, 1}}, + }, + { + name: "start in corner, register tiles behind you", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{3, 3}, geom.Point{2, 2}}, + buffer: 1, + want: [][2]uint{{0, 0}, {0, 1}, {1, 0}, {1, 1}}, + }, + { + name: "no buffer: walk across tile corner", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{6, 6}, geom.Point{10, 10}}, + buffer: 0, + want: [][2]uint{{1, 1}, {1, 2}, {2, 1}, {2, 2}}, + }, + { + name: "buffer: walk across tile corner", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{6, 6}, geom.Point{7, 7}}, + buffer: 1, + want: [][2]uint{{1, 1}, {1, 2}, {2, 1}, {2, 2}}, + }, + { + name: "buffer clips upper right corner of tile", + deepestLevel: 4, l: 2, cellSize: 1.0, + line: geom.Line{geom.Point{12, 14}, geom.Point{14, 12}}, + buffer: 1, + want: [][2]uint{{2, 2}, {2, 3}, {3, 2}, {3, 3}}, // Arguably {2,2} should not be here + }, + { + name: "non-zero origin: same relative result as +x+y diagonal", + deepestLevel: 4, l: 2, cellSize: 1.0, originX: 100, originY: 100, + line: geom.Line{geom.Point{102, 102}, geom.Point{114, 110}}, + buffer: 0, + want: [][2]uint{{0, 0}, {1, 0}, {1, 1}, {2, 1}, {2, 2}, {3, 2}}, + }, + { + name: "non-zero origin with buffer: same relative result as buffered corner case", + deepestLevel: 4, l: 2, cellSize: 1.0, originX: 100, originY: 100, + line: geom.Line{geom.Point{103, 103}, geom.Point{105, 105}}, + buffer: 1, + want: [][2]uint{{0, 0}, {0, 1}, {1, 0}, {1, 1}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ix := newOffsetPointIndex(tt.deepestLevel, tt.cellSize, tt.originX, tt.originY) + + var recorded []registeredTile + ix.lineTrace(tt.line, tt.l, tt.deepestLevel, tt.buffer, recordingRegister(&recorded)) + + got := uniqueTileCoords(recorded) + assert.Equal(t, tt.want, got) + }) + } +} + +// helper for building classification data +func buildClassification(tuples ...[3]uint) map[Level]map[morton.Z]TileClassification { + classification := make(map[Level]map[morton.Z]TileClassification) + for _, tuple := range tuples { + level, x, y := tuple[0], tuple[1], tuple[2] + if classification[level] == nil { + classification[level] = make(map[morton.Z]TileClassification) + } + classification[level][morton.MustToZ(x, y)] = ClassificationIntersect + } + return classification +} + +func TestPointIndex_findIntersectingTilesLeft(t *testing.T) { + tests := []struct { + name string + x, y uint + targetLevel Level + classification map[Level]map[morton.Z]TileClassification + want []morton.Z + }{ + { + name: "trivial example at level 0", + x: 0, + y: 0, + targetLevel: 0, + classification: buildClassification(), + want: []morton.Z{0}, + }, + { + name: "no tiles", + x: 3, + y: 3, + targetLevel: 2, + classification: buildClassification(), + want: []morton.Z{}, + }, + { + name: "single tile registered, check left from this tile.", + x: 0, + y: 0, + targetLevel: 3, + classification: buildClassification( + [3]uint{0, 0, 0}, + [3]uint{1, 0, 0}, + [3]uint{2, 0, 0}, + [3]uint{3, 0, 0}, + ), + want: []morton.Z{0}, + }, + { + name: "single tile registered, check this tile, requires rightChild", + x: 1, + y: 0, + targetLevel: 1, + classification: buildClassification( + [3]uint{0, 0, 0}, + [3]uint{1, 1, 0}, + ), + want: []morton.Z{1}, + }, + { + name: "tile registered right of target", + x: 0, + y: 0, + targetLevel: 1, + classification: buildClassification( + [3]uint{0, 0, 0}, + [3]uint{1, 1, 0}, + ), + want: []morton.Z{}, + }, + { + name: "use odd y coordinate", + x: 0, + y: 1, + targetLevel: 1, + classification: buildClassification( + [3]uint{0, 0, 0}, + [3]uint{1, 0, 1}, + ), + want: []morton.Z{2}, + }, + { + name: "use odd y and rightChild", + x: 0, + y: 1, + targetLevel: 1, + classification: buildClassification( + [3]uint{0, 0, 0}, + [3]uint{1, 1, 1}, + ), + want: []morton.Z{}, + }, + { + name: "ordd y and rightChild, want to find it", + x: 1, + y: 1, + targetLevel: 1, + classification: buildClassification( + [3]uint{0, 0, 0}, + [3]uint{1, 1, 1}, + ), + want: []morton.Z{3}, + }, + { + name: "register entire row", + x: 3, + y: 0, + targetLevel: 2, + classification: buildClassification( + [3]uint{0, 0, 0}, + [3]uint{1, 0, 0}, + [3]uint{1, 1, 0}, + [3]uint{2, 0, 0}, + [3]uint{2, 1, 0}, + [3]uint{2, 2, 0}, + [3]uint{2, 3, 0}, + ), + want: []morton.Z{0, 1, 4, 5}, + }, + { + name: "corners of grid populated", + x: 7, + y: 7, + targetLevel: 3, + classification: buildClassification( + [3]uint{0, 0, 0}, + [3]uint{1, 0, 0}, [3]uint{1, 0, 1}, [3]uint{1, 1, 0}, [3]uint{1, 1, 1}, + [3]uint{2, 0, 0}, [3]uint{2, 0, 3}, [3]uint{2, 3, 0}, [3]uint{2, 3, 3}, + [3]uint{3, 0, 0}, [3]uint{3, 7, 0}, [3]uint{3, 0, 7}, [3]uint{3, 7, 7}, + ), + want: []morton.Z{42, 63}, + }, + { + name: "general test with two disjoint tiles to pick up", + x: 6, + y: 7, + targetLevel: 3, + classification: buildClassification( + [3]uint{0, 0, 0}, + [3]uint{1, 0, 1}, [3]uint{1, 1, 1}, + [3]uint{2, 0, 3}, [3]uint{2, 2, 3}, [3]uint{2, 3, 3}, + [3]uint{3, 0, 7}, [3]uint{2, 6, 1}, [3]uint{3, 5, 7}, [3]uint{3, 7, 7}, + ), + want: []morton.Z{42, 59}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ix := newSimplePointIndex(tt.targetLevel, 1.0) + got := ix.findIntersectingTilesLeft(tt.x, tt.y, tt.targetLevel, tt.classification) + t.Logf("findIntersectingTilesLeft(%d, %d, %d) = %v", tt.x, tt.y, tt.targetLevel, got) + assert.Equal(t, tt.want, got) + }) + } +} + +func squarePolygon(minX, minY, maxX, maxY float64) geom.Polygon { + return geom.Polygon{ + {{minX, minY}, {maxX, minY}, {maxX, maxY}, {minX, maxY}}, + } +} + +func squareWithHolePolygon(minX, minY, maxX, maxY, holeMinX, holeMinY, holeMaxX, holeMaxY float64) geom.Polygon { + return geom.Polygon{ + {{minX, minY}, {maxX, minY}, {maxX, maxY}, {minX, maxY}}, + {{holeMinX, holeMinY}, {holeMinX, holeMaxY}, {holeMaxX, holeMaxY}, {holeMaxX, holeMinY}}, + } +} + +func trianglePolygon(x1, y1, x2, y2, x3, y3 float64) geom.Polygon { + return geom.Polygon{ + {{x1, y1}, {x2, y2}, {x3, y3}}, + } +} + +func TestPointIndex_classifyNonIntersectingTile(t *testing.T) { + tests := []struct { + name string + ix *PointIndex + polygon geom.Polygon + tmsID tms20.TMID + buffer uint + tileLevel Level + tileX, tileY uint + want TileClassification + }{ + { + name: "tile outside a centered square", + ix: newSimplePointIndex(3, 1.0), + polygon: squarePolygon(2, 2, 6, 6), + tmsID: 3, + buffer: 0, + tileLevel: 3, + tileX: 0, + tileY: 0, + want: ClassificationOutside, + }, + { + name: "tile outside a centered square", + ix: newSimplePointIndex(3, 1.5), + polygon: squarePolygon(3, 3, 9, 9), + tmsID: 3, + buffer: 0, + tileLevel: 3, + tileX: 0, + tileY: 0, + want: ClassificationOutside, + }, + { + name: "tile inside a centered square", + ix: newSimplePointIndex(3, 1.0), + polygon: squarePolygon(2, 2, 6, 6), + tmsID: 3, + buffer: 0, + tileLevel: 3, + tileX: 3, + tileY: 3, + want: ClassificationInside, + }, + { + name: "higher-level tile inside a centered square", + ix: newSimplePointIndex(3, 1.0), + polygon: squarePolygon(1, 1, 6, 6), + tmsID: 3, + buffer: 0, + tileLevel: 2, + tileX: 1, + tileY: 1, + want: ClassificationInside, + }, + { + name: "tile clearly outside, opposite corner", + ix: newSimplePointIndex(3, 1.0), + polygon: squarePolygon(2, 2, 6, 6), + tmsID: 3, + buffer: 0, + tileLevel: 3, + tileX: 7, + tileY: 7, + want: ClassificationOutside, + }, + { + name: "tile inside the hole of a donut polygon", + ix: newSimplePointIndex(3, 1.0), + // This should work with hole 3,3,5,5 but lineIntesects is buggy + polygon: squareWithHolePolygon(0, 0, 7, 7, 3, 3, 6, 6), + tmsID: 3, + buffer: 0, + tileLevel: 3, + tileX: 4, + tileY: 4, + want: ClassificationOutside, + }, + { + name: "tile in the solid part of a donut polygon", + ix: newSimplePointIndex(3, 1.0), + polygon: squareWithHolePolygon(0, 0, 8, 8, 3, 3, 5, 5), + tmsID: 3, + buffer: 0, + tileLevel: 3, + tileX: 1, + tileY: 1, + want: ClassificationInside, + }, + { + name: "tile in the solid part of a donut polygon, far corner", + ix: newSimplePointIndex(3, 1.0), + polygon: squareWithHolePolygon(0, 0, 8, 8, 3, 3, 6, 6), + tmsID: 3, + buffer: 0, + tileLevel: 3, + tileX: 7, + tileY: 7, + want: ClassificationOutside, + }, + { + name: "tile below a diagonal triangle (raycast hits intersection)", + ix: newSimplePointIndex(3, 1.0), + polygon: trianglePolygon(0, 0, 0, 7, 7, 7), + tmsID: 3, + buffer: 0, + tileLevel: 3, + tileX: 1, + tileY: 0, + want: ClassificationOutside, + }, + { + name: "tile above a diagonal triangle (raycast hits intersection)", + ix: newSimplePointIndex(3, 1.0), + polygon: trianglePolygon(0, 0, 7, 0, 0, 7), + tmsID: 3, + buffer: 0, + tileLevel: 3, + tileX: 1, + tileY: 7, + want: ClassificationOutside, + }, + { + name: "nonzero buffer around a centered square", + ix: newSimplePointIndex(3, 1.0), + polygon: squarePolygon(2, 2, 6, 6), + tmsID: 3, + buffer: 4, + tileLevel: 3, + tileX: 0, + tileY: 0, + want: ClassificationOutside, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + segments, classification := tt.ix.lineTracePolygonEdges(tt.polygon, tt.tmsID, tt.buffer) + z := morton.MustToZ(tt.tileX, tt.tileY) + got := tt.ix.fillTile(z, tt.tileLevel, segments, classification, tt.polygon) + t.Logf("classifyNonIntersectingTile(tile=(%d,%d)) = %v", tt.tileX, tt.tileY, got) + assert.Equal(t, tt.want, got) + }) + } +} + +// convert classification data to visual test data +func classificationGrid(classification map[Level]map[morton.Z]TileClassification, targetLevel Level) map[Level][][]TileClassification { + grid := make(map[Level][][]TileClassification, targetLevel+1) + for l := Level(0); l <= targetLevel; l++ { + levelClassification := classification[l] + size := uint(1) << l + rows := make([][]TileClassification, size) + for y := range size { + row := make([]TileClassification, size) + for x := range size { + z := morton.MustToZ(x, y) + if c, ok := levelClassification[z]; ok { + row[x] = c + } else { + row[x] = ClassificationUnknown + } + } + rows[y] = row + } + grid[l] = rows + } + return grid +} + +func TestPointIndex_classifyNonIntersectingTiles(t *testing.T) { + const ( + u = ClassificationUnknown + x = ClassificationIntersect + i = ClassificationInside + o = ClassificationOutside + ) + tests := []struct { + name string + ix *PointIndex + polygon geom.Polygon + tmsID tms20.TMID + buffer uint + want map[Level][][]TileClassification + }{ + { + name: "centered square", + ix: newSimplePointIndex(3, 1.0), + polygon: squarePolygon(2, 2, 6, 6), + tmsID: 3, + buffer: 0, + want: map[Level][][]TileClassification{ + 0: {{x}}, + 1: {{x, x}, {x, x}}, + 2: { + {o, x, x, o}, + {x, x, x, x}, + {x, x, x, x}, + {o, x, x, x}, + }, + 3: { + {u, u, o, o, o, o, u, u}, + {u, u, x, x, x, x, u, u}, + {o, x, x, x, x, x, x, o}, + {o, x, x, i, i, x, x, o}, + {o, x, x, i, i, x, x, o}, + {o, x, x, x, x, x, x, o}, + {u, u, x, x, x, x, x, o}, + {u, u, o, o, o, o, o, o}, + }, + }, + }, + { + name: "donut polygon (square with a square hole)", + ix: newSimplePointIndex(3, 1.0), + polygon: squareWithHolePolygon(0, 0, 7, 7, 3, 3, 5, 5), + tmsID: 3, + buffer: 0, + want: map[Level][][]TileClassification{ + 0: {{x}}, + 1: {{x, x}, {x, x}}, + 2: { + {x, x, x, x}, + {x, x, x, x}, + {x, x, x, x}, + {x, x, x, x}, + }, + 3: { + {x, x, x, x, x, x, x, x}, + {x, i, i, i, i, i, x, x}, + {x, i, i, x, x, i, x, x}, + {x, i, x, x, x, x, x, x}, + {x, i, x, x, x, x, x, x}, + {x, i, i, x, x, x, x, x}, + {x, x, x, x, x, x, x, x}, + {x, x, x, x, x, x, x, x}, + }, + }, + }, + { + name: "diagonal triangle", + ix: newSimplePointIndex(3, 1.0), + polygon: trianglePolygon(0, 0, 7, 0, 7, 7), + tmsID: 3, + buffer: 0, + want: map[Level][][]TileClassification{ + 0: {{x}}, + 1: {{x, x}, {x, x}}, + 2: { + {x, x, x, x}, + {x, x, x, x}, + {o, x, x, x}, + {o, o, x, x}, + }, + 3: { + {x, x, x, x, x, x, x, x}, + {x, x, x, i, i, i, x, x}, + {o, x, x, x, i, i, x, x}, + {o, o, x, x, x, i, x, x}, + {u, u, o, x, x, x, x, x}, + {u, u, o, o, x, x, x, x}, + {u, u, u, u, o, x, x, x}, + {u, u, u, u, o, o, o, x}, + }, + }, + }, + { + name: "polygon covering the whole extent", + ix: newSimplePointIndex(2, 1.0), + polygon: squarePolygon(0, 0, 4, 4), + tmsID: 2, + buffer: 0, + want: map[Level][][]TileClassification{ + 0: {{x}}, + 1: {{x, x}, {x, x}}, + 2: { + {x, x, x, x}, + {x, i, i, x}, + {x, i, i, x}, + {x, x, x, x}, + }, + }, + }, + { + name: "tiny polygon in a single corner tile", + ix: newSimplePointIndex(3, 1.0), + polygon: squarePolygon(0.25, 0.25, 0.75, 0.75), + tmsID: 3, + buffer: 0, + want: map[Level][][]TileClassification{ + 0: {{x}}, + 1: {{x, o}, {o, o}}, + 2: { + {x, o, u, u}, + {o, o, u, u}, + {u, u, u, u}, + {u, u, u, u}, + }, + 3: { + {x, o, u, u, u, u, u, u}, + {o, o, u, u, u, u, u, u}, + {u, u, u, u, u, u, u, u}, + {u, u, u, u, u, u, u, u}, + {u, u, u, u, u, u, u, u}, + {u, u, u, u, u, u, u, u}, + {u, u, u, u, u, u, u, u}, + {u, u, u, u, u, u, u, u}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + targetLevel := Level(tt.tmsID) //nolint:gosec // G115 + segments, classification := tt.ix.lineTracePolygonEdges(tt.polygon, tt.tmsID, tt.buffer) + tt.ix.fillTiles(targetLevel, 0, 0, true, segments, classification, tt.polygon) + + got := classificationGrid(classification, targetLevel) + for l := Level(0); l <= targetLevel; l++ { + t.Logf("level %d: %v", l, got[l]) + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pointindex/pointindex.go b/pointindex/pointindex.go index 52f7f39..32121c9 100644 --- a/pointindex/pointindex.go +++ b/pointindex/pointindex.go @@ -11,6 +11,7 @@ import ( "github.com/go-spatial/geom/encoding/wkt" "golang.org/x/exp/maps" //nolint:exptostd + "github.com/pdok/texel/geomhelp" "github.com/pdok/texel/mathhelp" "github.com/pdok/texel/morton" "github.com/pdok/texel/tms20" @@ -42,6 +43,14 @@ type Quadrant struct { intCentroid intgeom.Point } +func (q *Quadrant) Extent() geom.Extent { + return q.intExtent.ToGeomExtent() +} + +func (q *Quadrant) Coords() (uint, uint) { + return morton.FromZ(q.z) +} + // PointIndex is a pointcloud annex quadtree to enable snapping lines to a grid accounting for those points. // Quadrants: // @@ -66,20 +75,25 @@ type PointIndex struct { Quadrant deepestLevel Level // Number of quadrants (in one direction) on the deepest level (= 2 ^ deepestLevel) - deepestSize uint - deepestRes intgeom.M - quadrants map[Level]map[morton.Z]Quadrant - hitOnce map[Level]map[intgeom.Point][]int - hitMultiple map[Level]map[intgeom.Point][]int + deepestSize uint + deepestRes intgeom.M + quadrants map[Level]map[morton.Z]Quadrant + hitOnce map[Level]map[intgeom.Point][]int + hitMultiple map[Level]map[intgeom.Point][]int + tilePixels uint + internalPixels uint } -type Level = uint -type Q = int // quadrant index (0, 1, 2 or 3) +type ( + Level = uint + Q = int // quadrant index (0, 1, 2 or 3) +) func FromTileMatrixSet(tileMatrixSet tms20.TileMatrixSet, deepestTMID tms20.TMID) (*PointIndex, error) { // assuming IsQuadTree was tested before rootTM := tileMatrixSet.TileMatrices[0] - levelDiff := uint(math.Log2(float64(rootTM.TileWidth))) + uint(math.Log2(float64(VectorTileInternalPixelResolution))) + tilePixels := rootTM.TileWidth + levelDiff := uint(math.Log2(float64(tilePixels))) + uint(math.Log2(float64(VectorTileInternalPixelResolution))) //nolint:gosec // G115 deepestLevel := uint(deepestTMID) + levelDiff bottomLeft, topRight, err := tileMatrixSet.MatrixBoundingBox(0) @@ -95,8 +109,10 @@ func FromTileMatrixSet(tileMatrixSet tms20.TileMatrixSet, deepestTMID tms20.TMID intExtent: intExtent, z: 0, }, - deepestLevel: deepestLevel, - deepestSize: deepestSize, + tilePixels: tilePixels, + internalPixels: VectorTileInternalPixelResolution, + deepestLevel: deepestLevel, + deepestSize: deepestSize, //nolint:gosec // G115 deepestRes: intExtent.XSpan() / int64(deepestSize), quadrants: make(map[Level]map[morton.Z]Quadrant, deepestLevel+1), @@ -108,6 +124,35 @@ func FromTileMatrixSet(tileMatrixSet tms20.TileMatrixSet, deepestTMID tms20.TMID return &ix, nil } +func Factory(tileMatrixSet tms20.TileMatrixSet, deepestTMID tms20.TMID) func() *PointIndex { + factory := func() *PointIndex { + ix, err := FromTileMatrixSet(tileMatrixSet, deepestTMID) + if err != nil { + panic(err) + } + return ix + } + return factory +} + +// Insert all points of a geometry in the pointindex. Multi-geometries not supported +func (ix *PointIndex) InsertGeometry(geometry geom.Geometry) error { + pointSlice := geomhelp.PointSlice(geometry) + // Initialize the map + for level := range ix.deepestLevel + 1 { + if ix.quadrants[level] == nil { + ix.quadrants[level] = make(map[morton.Z]Quadrant, len(pointSlice)) + } + } + // Insert points + for _, point := range pointSlice { + if err := ix.InsertPoint(point); err != nil { + return err + } + } + return nil +} + // InsertPolygon inserts all points from a Polygon func (ix *PointIndex) InsertPolygon(polygon geom.Polygon) error { // initialize the quadrants map diff --git a/pointindex/pointindex_test.go b/pointindex/pointindex_test.go index 40927ea..6a219f9 100644 --- a/pointindex/pointindex_test.go +++ b/pointindex/pointindex_test.go @@ -148,8 +148,10 @@ func TestPointIndex_InsertPoint(t *testing.T) { intExtent: intgeom.FromGeomExtent(geom.Extent{0.0, 0.0, 1.0, 1.0}), intCentroid: intgeom.FromGeomPoint(geom.Point{0.5, 0.5}), }, - deepestLevel: 0, - deepestSize: mathhelp.Pow2(0), + deepestLevel: 0, + deepestSize: mathhelp.Pow2(0), + tilePixels: 256, + internalPixels: 16, //nolint:gosec // G115 deepestRes: intgeom.FromGeomOrd(1.0) / intgeom.M(mathhelp.Pow2(0)), quadrants: map[Level]map[morton.Z]Quadrant{0: {0: Quadrant{ @@ -167,8 +169,10 @@ func TestPointIndex_InsertPoint(t *testing.T) { intExtent: intgeom.FromGeomExtent(geom.Extent{0.0, 0.0, 1.0, 1.0}), intCentroid: intgeom.FromGeomPoint(geom.Point{0.5, 0.5}), }, - deepestLevel: 1, - deepestSize: mathhelp.Pow2(1), + deepestLevel: 1, + deepestSize: mathhelp.Pow2(1), + tilePixels: 256, + internalPixels: 16, //nolint:gosec // G115 deepestRes: intgeom.FromGeomOrd(1.0) / intgeom.M(mathhelp.Pow2(1)), quadrants: map[Level]map[morton.Z]Quadrant{ @@ -193,8 +197,10 @@ func TestPointIndex_InsertPoint(t *testing.T) { intExtent: intgeom.FromGeomExtent(geom.Extent{0.0, 0.0, 4.0, 4.0}), intCentroid: intgeom.FromGeomPoint(geom.Point{2.0, 2.0}), }, - deepestLevel: 3, - deepestSize: mathhelp.Pow2(3), + deepestLevel: 3, + deepestSize: mathhelp.Pow2(3), + tilePixels: 256, + internalPixels: 16, //nolint:gosec // G115 deepestRes: intgeom.FromGeomOrd(4.0) / intgeom.M(mathhelp.Pow2(3)), quadrants: map[Level]map[morton.Z]Quadrant{ @@ -230,8 +236,10 @@ func TestPointIndex_InsertPoint(t *testing.T) { intExtent: intgeom.FromGeomExtent(geom.Extent{0.0, 0.0, 16.0, 16.0}), intCentroid: intgeom.FromGeomPoint(geom.Point{8.0, 8.0}), }, - deepestLevel: 5, - deepestSize: mathhelp.Pow2(5), + deepestLevel: 5, + deepestSize: mathhelp.Pow2(5), + tilePixels: 256, + internalPixels: 16, //nolint:gosec // G115 deepestRes: intgeom.FromGeomOrd(16.0) / intgeom.M(mathhelp.Pow2(5)), quadrants: map[Level]map[morton.Z]Quadrant{ @@ -473,7 +481,7 @@ func TestPointIndex_SnapClosestPoints(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ix := tt.ix poly := tt.poly - err := ix.InsertPolygon(poly) + err := ix.InsertGeometry(poly) require.NoError(t, err) levels := tt.levels if levels == nil { @@ -509,6 +517,17 @@ func TestPointIndex_lineIntersects(t *testing.T) { }, want: false, }, + { + name: "diagonally opposed line between non-inclusive points", + // This test is fragile and depends on floating-point rounding + extent: intgeom.Extent{ + 0o0000000, 0o0000000, 10000000, 10000000, + }, + line: intgeom.Line{ + {0o0000000, 10000000}, {10000000, 0o0000000}, + }, + want: false, // TODO This is an undesired outcome + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -532,15 +551,37 @@ func newSimplePointIndex(deepestLevel Level, cellSize float64) *PointIndex { deepestLevel: deepestLevel, deepestSize: deepestSize, //nolint:gosec // G115 - deepestRes: intExtent.XSpan() / int64(deepestSize), - quadrants: make(map[Level]map[morton.Z]Quadrant, deepestLevel+1), - hitOnce: make(map[morton.Z]map[intgeom.Point][]int, 0), - hitMultiple: make(map[morton.Z]map[intgeom.Point][]int, 0), + deepestRes: intExtent.XSpan() / int64(deepestSize), + quadrants: make(map[Level]map[morton.Z]Quadrant, deepestLevel+1), + hitOnce: make(map[morton.Z]map[intgeom.Point][]int, 0), + hitMultiple: make(map[morton.Z]map[intgeom.Point][]int, 0), + tilePixels: 256, + internalPixels: 16, } _, ix.intCentroid = ix.getQuadrantExtentAndCentroid(0, 0, 0, ix.intExtent) return &ix } +func newPointIndex(deepestTMID tms20.TMID, tilePixels, internalPixels uint, cellSize float64) *PointIndex { + ix := PointIndex{ + hitOnce: make(map[morton.Z]map[intgeom.Point][]int, 0), + hitMultiple: make(map[morton.Z]map[intgeom.Point][]int, 0), + tilePixels: tilePixels, + internalPixels: internalPixels, + } + deepestLevel := ix.InternalPixelLevelFromTmsID(deepestTMID) + ix.deepestLevel = deepestLevel + ix.deepestSize = mathhelp.Pow2(deepestLevel) + span := cellSize * float64(ix.deepestSize) + intExtent := intgeom.Extent{0.0, 0.0, intgeom.FromGeomOrd(span), intgeom.FromGeomOrd(span)} + ix.Quadrant = Quadrant{ + intExtent: intExtent, + } + ix.deepestRes = intExtent.XSpan() / int64(ix.deepestSize) //nolint:gosec // G115 + ix.quadrants = make(map[Level]map[morton.Z]Quadrant, deepestLevel+1) + return &ix +} + func loadEmbeddedTileMatrixSet(t *testing.T, tmsID string) tms20.TileMatrixSet { t.Helper() diff --git a/processing/gpkg/gpkg.go b/processing/gpkg/gpkg.go index 8c4328f..b6af9c0 100644 --- a/processing/gpkg/gpkg.go +++ b/processing/gpkg/gpkg.go @@ -1,29 +1,16 @@ package gpkg +// This file defines general geopackage-related infrastructure shared by both +// the `snap` and `mvt` flows. + import ( "fmt" "log" "strings" - "time" - "github.com/go-spatial/geom" "github.com/go-spatial/geom/encoding/gpkg" - "github.com/pdok/texel/processing" ) -type featureGPKG struct { - columns []any - geometry geom.Geometry -} - -func (f featureGPKG) Columns() []any { - return f.columns -} - -func (f featureGPKG) Geometry() geom.Geometry { - return f.geometry -} - type column struct { cid int name string @@ -33,6 +20,7 @@ type column struct { pk int } +// Abstraction of geometry table in a gpkg type Table struct { Name string columns []column @@ -41,7 +29,11 @@ type Table struct { srs gpkg.SpatialReferenceSystem } -// geometryTypeFromString returns the numeric value of a gometry string +// Name of associated table of a geometry table. +func (t Table) EncodedName() string { + return t.Name + "_encoded" +} + func geometryTypeFromString(geometrytype string) gpkg.GeometryType { switch strings.ToUpper(geometrytype) { case "GEOMETRY": @@ -65,90 +57,21 @@ func geometryTypeFromString(geometrytype string) gpkg.GeometryType { } } -//nolint:recvcheck // TODO double check this -type SourceGeopackage struct { - Table Table - handle *gpkg.Handle -} +// Custom wrapper for gpkg.Handle. To be embedded in other structs +type geopackageHandle struct{ handle *gpkg.Handle } -func (source *SourceGeopackage) Init(file string) { - source.handle = openGeopackage(file) +func (gH *geopackageHandle) Init(file string) { + gH.handle = openGeopackage(file) } -func (source SourceGeopackage) Close() { - source.handle.Close() +func (gH *geopackageHandle) Close() { + gH.handle.Close() } -//nolint:funlen,cyclop -func (source SourceGeopackage) ReadFeatures(features chan<- processing.Feature) { - - rows, err := source.handle.Query(source.Table.selectSQL()) - if err != nil { - log.Fatalf("err during closing rows: %s", err) - } - - cols, err := rows.Columns() - if err != nil { - log.Fatalf("error reading the columns: %s", err) - } - - for rows.Next() { - vals := make([]any, len(cols)) - valPtrs := make([]any, len(cols)) - for i := range cols { - valPtrs[i] = &vals[i] - } - - if err = rows.Scan(valPtrs...); err != nil { - log.Fatalf("err reading row values: %v", err) - } - var f featureGPKG - var c []any - - for i, colName := range cols { - switch colName { - case source.Table.gcolumn: - wkbgeom, err := gpkg.DecodeGeometry(vals[i].([]byte)) - if err != nil { - log.Fatalf("error decoding the geometry: %s", err) - } - f.geometry = wkbgeom.Geometry - default: - switch v := vals[i].(type) { - case []uint8: - asBytes := make([]byte, len(v)) - copy(asBytes, v) - c = append(c, string(asBytes)) - case int64: - c = append(c, v) - case float64: - c = append(c, v) - case time.Time: - c = append(c, v.Format(time.RFC3339)) - case string: - c = append(c, v) - case nil: - c = append(c, v) - default: - log.Fatalf("unexpected type for sqlite column data: %v: %T", cols[i], v) - } - } - f.columns = c - } - ff := &f - features <- ff - } - err = rows.Err() - if err != nil { - log.Fatal(err) - } - close(features) - defer rows.Close() -} - -func (source SourceGeopackage) GetTableInfo() []Table { +// Generate []Table slice for looping over. +func (gH *geopackageHandle) GetTableInfo() []Table { query := `SELECT table_name, column_name, geometry_type_name, srs_id FROM gpkg_geometry_columns;` - rows, err := source.handle.Query(query) + rows, err := gH.handle.Query(query) if err != nil { log.Fatalf("error during closing rows: %v - %v", query, err) } @@ -163,9 +86,9 @@ func (source SourceGeopackage) GetTableInfo() []Table { log.Fatalf("error retrieving the source table information: %s", err) } - t.columns = getTableColumns(source.handle, t.Name) + t.columns = gH.getTableColumns(t.Name) t.gtype = geometryTypeFromString(gtype) - t.srs = getSpatialReferenceSystem(source.handle, srsID) + t.srs = gH.getSpatialReferenceSystem(srsID) tables = append(tables, t) } @@ -173,106 +96,6 @@ func (source SourceGeopackage) GetTableInfo() []Table { return tables } -type TargetGeopackage struct { - Table Table - pagesize int - handle *gpkg.Handle -} - -func (target *TargetGeopackage) Init(file string, pagesize int) { - target.pagesize = pagesize - target.handle = openGeopackage(file) -} - -func (target *TargetGeopackage) Close() { - target.handle.Close() -} - -func (target *TargetGeopackage) CreateTables(tables []Table) error { - for _, table := range tables { - err := target.handle.UpdateSRS(table.srs) - if err != nil { - return err - } - - err = buildTable(target.handle, table) - if err != nil { - return err - } - } - return nil -} - -func (target *TargetGeopackage) WriteFeatures(inFeatures <-chan processing.Feature) { - var features []processing.Feature - - for { - feature, hasMore := <-inFeatures - if !hasMore { - target.writeFeatures(features) - break - } - features = append(features, feature) - - if len(features)%target.pagesize == 0 { - target.writeFeatures(features) - features = nil - } - } -} - -func (target *TargetGeopackage) writeFeatures(features []processing.Feature) { - tx, err := target.handle.Begin() - if err != nil { - log.Fatalf("Could not start a transaction: %s", err) - } - - stmt, err := tx.Prepare(target.Table.insertSQL()) - if err != nil { - log.Fatalf("Could not prepare a statement: %s", err) - } - - var ext *geom.Extent - - for _, f := range features { - //nolint:gosec // G115 - sb, err := gpkg.NewBinary(int32(target.Table.srs.ID), f.Geometry()) - if err != nil { - log.Fatalf("Could not create a binary geometry: %s", err) - } - - data := f.Columns() - data = append(data, sb) - - _, err = stmt.Exec(data...) - if err != nil { - var fid any = "unknown" - if len(data) > 0 { - fid = data[0] - } - log.Fatalf("Could not get a result summary from the prepared statement for fid %s: %s", fid, err) - } - - if ext == nil { - ext, err = geom.NewExtentFromGeometry(f.Geometry()) - if err != nil { - ext = nil - log.Println("Failed to create new extent:", err) - continue - } - } else { - _ = ext.AddGeometry(f.Geometry()) - } - } - stmt.Close() - _ = tx.Commit() - - err = target.handle.UpdateGeometryExtent(target.Table.Name, ext) - if err != nil { - log.Fatalln("Failed to update new extent:", err) - } -} - func openGeopackage(file string) *gpkg.Handle { handle, err := gpkg.Open(file) if err != nil { @@ -281,60 +104,12 @@ func openGeopackage(file string) *gpkg.Handle { return handle } -// createSQL creates a CREATE statement on the given table and column information -// used for creating feature tables in the target Geopackage -func (t Table) createSQL() string { - create := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS "%v"`, t.Name) - var columnparts []string //nolint:prealloc - for _, column := range t.columns { - columnpart := column.name + ` ` + column.ctype - if column.notnull == 1 { - columnpart += ` NOT NULL` - } - if column.pk == 1 { - columnpart += ` PRIMARY KEY` - } - - columnparts = append(columnparts, columnpart) - } - - query := create + `(` + strings.Join(columnparts, `, `) + `);` - return query -} - -// selectSQL build a SELECT statement based on the table and columns -// used for reading the source features -func (t Table) selectSQL() string { - var csql []string //nolint:prealloc - for _, c := range t.columns { - csql = append(csql, c.name) - } - query := `SELECT ` + strings.Join(csql, `,`) + ` FROM "` + t.Name + `";` - return query -} - -// insertSQL used for writing the features -// build the INSERT statement based on the table and columns -func (t Table) insertSQL() string { - var csql, vsql []string - for _, c := range t.columns { - if c.name != t.gcolumn { - csql = append(csql, c.name) - vsql = append(vsql, `?`) - } - } - csql = append(csql, t.gcolumn) - vsql = append(vsql, `?`) - query := `INSERT INTO "` + t.Name + `"(` + strings.Join(csql, `,`) + `) VALUES(` + strings.Join(vsql, `,`) + `)` - return query -} - // getSpatialReferenceSystem extracts this based on the given SRS id -func getSpatialReferenceSystem(h *gpkg.Handle, id int) gpkg.SpatialReferenceSystem { +func (gH *geopackageHandle) getSpatialReferenceSystem(id int) gpkg.SpatialReferenceSystem { var srs gpkg.SpatialReferenceSystem query := `SELECT srs_name, srs_id, organization, organization_coordsys_id, definition, description FROM gpkg_spatial_ref_sys WHERE srs_id = %v;` - row := h.QueryRow(fmt.Sprintf(query, id)) + row := gH.handle.QueryRow(fmt.Sprintf(query, id)) var description *string _ = row.Scan(&srs.Name, &srs.ID, &srs.Organization, &srs.OrganizationCoordsysID, &srs.Definition, &description) if description != nil { @@ -345,11 +120,10 @@ func getSpatialReferenceSystem(h *gpkg.Handle, id int) gpkg.SpatialReferenceSyst } // getTableColumns collects the column information of a given table -func getTableColumns(h *gpkg.Handle, table string) []column { +func (gH *geopackageHandle) getTableColumns(table string) []column { var columns []column query := `PRAGMA table_info('%v');` - rows, err := h.Query(fmt.Sprintf(query, table)) - + rows, err := gH.handle.Query(fmt.Sprintf(query, table)) if err != nil { log.Fatalf("err during closing rows: %v - %v", query, err) } @@ -365,30 +139,3 @@ func getTableColumns(h *gpkg.Handle, table string) []column { defer rows.Close() return columns } - -// buildTable creates a given destination table with the necessary gpkg_ information -func buildTable(h *gpkg.Handle, t Table) error { - query := t.createSQL() - _, err := h.Exec(query) - if err != nil { - log.Fatalf("error building table in target GeoPackage: %s", err) - } - - err = h.AddGeometryTable(gpkg.TableDescription{ - Name: t.Name, - ShortName: t.Name, - Description: t.Name, - GeometryField: t.gcolumn, - GeometryType: t.gtype, - //nolint:gosec // G115 - SRS: int32(t.srs.ID), - // - Z: gpkg.Prohibited, - M: gpkg.Prohibited, - }) - if err != nil { - log.Println("error adding geometry table in target GeoPackage:", err) - return err - } - return nil -} diff --git a/processing/gpkg/gpkgmvt.go b/processing/gpkg/gpkgmvt.go new file mode 100644 index 0000000..741e4ea --- /dev/null +++ b/processing/gpkg/gpkgmvt.go @@ -0,0 +1,235 @@ +package gpkg + +// Specializes the gpkg.go functionaltity for the `texel mvt` command + +import ( + "encoding/binary" + "fmt" + "strings" + "time" + + "github.com/pdok/texel/processing" + "github.com/pdok/texel/tile" +) + +// Batch value for reading features from the geopackage table +const maxSQLiteBatchParams = 500 + +type MVTSourceGeopackage struct { + Table Table + geopackageHandle +} + +// GetAttributesForFeatures fetches the attributes (i.e. all non-geometry, non-fid columns) +// for the given feature IDs, batching the query to respect SQLite's parameter limits. +// The fid is assumed to be the table's first column. +func (source MVTSourceGeopackage) GetAttributesForFeatures(featureIDs []int64) (tile.InternalAttributeTable, error) { + attributes := make(tile.InternalAttributeTable, len(featureIDs)) + fidColumn := source.Table.columns[0].name + + for start := 0; start < len(featureIDs); start += maxSQLiteBatchParams { + batch := featureIDs[start:min(start+maxSQLiteBatchParams, len(featureIDs))] + + batchAsAny := make([]any, len(batch)) + for i, fid := range batch { + batchAsAny[i] = fid + } + + if err := source.queryAttributesBatch(fidColumn, batchAsAny, attributes); err != nil { + return nil, err + } + } + + return attributes, nil +} + +// selectByFIDsSQL builds a SELECT of all the table's columns for a batch of feature IDs. +// The fid is assumed to be the table's first column. +func (t Table) selectByFIDsSQL(batchSize int) string { + var csql []string //nolint:prealloc + for _, c := range t.columns { + csql = append(csql, c.name) + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", batchSize), ",") + fidColumn := t.columns[0].name + return `SELECT ` + strings.Join(csql, `,`) + ` FROM "` + t.Name + `" WHERE ` + fidColumn + ` IN (` + placeholders + `)` +} + +func cleanData(v any) (any, bool) { + switch v := v.(type) { + case []uint8: + asBytes := make([]byte, len(v)) + copy(asBytes, v) + return v, true + case int64: + return v, true + case float64: + return v, true + case time.Time: + return v.Format(time.RFC3339), true + case string: + return v, true + case nil: + return nil, true + default: + return nil, false + } +} + +// attributeColumnNames lists all columns except fid and geometry column. +// Assumptions: fid column has index 0 +func (t Table) attributeColumnNames() []string { + names := make([]string, 0, len(t.columns)) + for i, c := range t.columns { + if i == 0 || c.name == t.gcolumn { + continue // skip the fid and geometry columns, they are not attributes + } + names = append(names, c.name) + } + return names +} + +// AttributeColumnNames satisfies processing.MVTSource by delegating to the +// current Table. +func (source MVTSourceGeopackage) AttributeColumnNames() []string { + return source.Table.attributeColumnNames() +} + +// selectEncodedSQL builds the SELECT for fetching the encoded features of a single tile +func (t Table) selectEncodedSQL() string { + return `SELECT feature_id, geometry_type, data FROM "` + t.EncodedName() + `" WHERE tile_x = ? AND tile_y = ? AND tile_z = ?` +} + +// selectDistinctTilesSQL builds the SELECT for enumerating every tile that has at +// least one encoded feature. +func (t Table) selectDistinctTilesSQL() string { + return `SELECT DISTINCT tile_x, tile_y FROM "` + t.EncodedName() + `" WHERE tile_z = ? ORDER BY tile_x, tile_y` +} + +// GetFeaturesForTile returns every encoded feature stored for the given tile, +// i.e. the equivalent of the tile-features table's rows for (tileX, tileY). +func (source MVTSourceGeopackage) GetFeaturesForTile(tileX, tileY, tileZ uint) ([]tile.EncodedFeatureRow, error) { + rows, err := source.handle.Query(source.Table.selectEncodedSQL(), tileX, tileY, tileZ) + if err != nil { + return nil, fmt.Errorf("querying encoded features for tile (%d,%d,%d): %w", tileX, tileY, tileZ, err) + } + defer rows.Close() + + var features []tile.EncodedFeatureRow + for rows.Next() { + var featureID int64 + var geometryType int32 + var data []byte + if err := rows.Scan(&featureID, &geometryType, &data); err != nil { + return nil, fmt.Errorf("scanning encoded feature row for tile (%d,%d): %w", tileX, tileY, err) + } + features = append(features, tile.EncodedFeatureRow{ + FeatureID: featureID, + Geom: tile.EncodedGeometry{ + Encoding: deserializeFromBytes(data), + GeometryType: geometryType, + XTile: tileX, + YTile: tileY, + }, + }) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating encoded feature rows for tile (%d,%d): %w", tileX, tileY, err) + } + return features, nil +} + +// deserializeFromBytes is the inverse of serializeToBytes: it turns a BLOB of +// little-endian uint32s back into the encoded geometry command/coordinate stream. +func deserializeFromBytes(data []byte) []uint32 { + intslice := make([]uint32, len(data)/4) + for i := range intslice { + intslice[i] = binary.LittleEndian.Uint32(data[i*4:]) + } + return intslice +} + +// ListTiles returns every distinct tile present in the "_encoded" table. +func (source MVTSourceGeopackage) ListTiles(z uint, tileSet map[processing.TileCoord]bool) error { + rows, err := source.handle.Query(source.Table.selectDistinctTilesSQL(), z) + if err != nil { + return fmt.Errorf("listing tiles: %w", err) + } + defer rows.Close() + + for rows.Next() { + var x, y int64 + if err := rows.Scan(&x, &y); err != nil { + return fmt.Errorf("scanning tile row: %w", err) + } + tileSet[processing.TileCoord{X: uint(x), Y: uint(y), Z: z}] = true //nolint:gosec // G115 Tile coord fit within uint + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterating tile rows: %w", err) + } + return nil +} + +// Read from source.Table, store rows as attributes[fid][columnName] = value for all fids +func (source MVTSourceGeopackage) queryAttributesBatch(fidColumn string, fids []any, attributes tile.InternalAttributeTable) error { + // Read relevant rows + rows, err := source.handle.Query(source.Table.selectByFIDsSQL(len(fids)), fids...) + if err != nil { + return fmt.Errorf("querying attributes for %d feature(s): %w", len(fids), err) + } + defer rows.Close() + + cols := source.getColNames() + + // Process rows + for rows.Next() { + // Read data with helper vars + vals := make([]any, len(cols)) + valPtrs := make([]any, len(cols)) + for i := range cols { + valPtrs[i] = &vals[i] + } + if err := rows.Scan(valPtrs...); err != nil { + return fmt.Errorf("scanning attribute row: %w", err) + } + + // Interpret data + row := make(map[string]any, len(cols)) + var fid int64 + var fidFound bool + for i, colName := range cols { + switch colName { + case fidColumn: + // Read fid + id, ok := vals[i].(int64) + if !ok { + return fmt.Errorf("expected int64 fid for column %s, got %T", colName, vals[i]) + } + fid, fidFound = id, true + case source.Table.gcolumn: + // Skip geometry column + default: + // Interpret data + v, ok := cleanData(vals[i]) + if !ok { + return fmt.Errorf("unexpected type for sqlite column data: %v: %T", colName, v) + } + row[colName] = v + } + } + if !fidFound { + return fmt.Errorf("row did not contain fid column %s", fidColumn) + } + attributes[fid] = row + } + return rows.Err() +} + +func (source MVTSourceGeopackage) getColNames() []string { + cols := source.Table.columns + colNames := make([]string, len(cols)) + for i, col := range cols { + colNames[i] = col.name + } + return colNames +} diff --git a/processing/gpkg/gpkgsnap.go b/processing/gpkg/gpkgsnap.go new file mode 100644 index 0000000..ba1be5b --- /dev/null +++ b/processing/gpkg/gpkgsnap.go @@ -0,0 +1,364 @@ +package gpkg + +// Specializes the gpkg.go functionality for the `texel snap` command. + +import ( + "encoding/binary" + "fmt" + "log" + "strings" + "time" + + "github.com/go-spatial/geom" + "github.com/go-spatial/geom/encoding/gpkg" + "github.com/pdok/texel/processing" +) + +// Abstraction of a source. Table is a field that represents the table that is +// currently being processed. +type SourceGeopackage struct { + Table Table + geopackageHandle +} + +// Abstraction of target geopackage. Table represents the table that is +// currently being processed. +type TargetGeopackage struct { + Table Table + pagesize int + geopackageHandle + EncodeTiles bool +} + +// Overrides geopackageHandle.Init +func (target *TargetGeopackage) Init(file string, pagesize int) { + target.pagesize = pagesize + target.geopackageHandle.Init(file) +} + +type featureGPKG struct { + columns []any + geometry geom.Geometry +} + +func (f featureGPKG) Columns() []any { + return f.columns +} + +func (f featureGPKG) Geometry() geom.Geometry { + return f.geometry +} + +//nolint:funlen,cyclop +func (source SourceGeopackage) ReadFeatures(features chan<- processing.Feature) { + rows, err := source.handle.Query(source.Table.selectSQL()) + if err != nil { + log.Fatalf("err during closing rows: %s", err) + } + + cols, err := rows.Columns() + if err != nil { + log.Fatalf("error reading the columns: %s", err) + } + + for rows.Next() { + vals := make([]any, len(cols)) + valPtrs := make([]any, len(cols)) + for i := range cols { + valPtrs[i] = &vals[i] + } + + if err = rows.Scan(valPtrs...); err != nil { + log.Fatalf("err reading row values: %v", err) + } + var f featureGPKG + var c []any + + for i, colName := range cols { + switch colName { + case source.Table.gcolumn: + wkbgeom, err := gpkg.DecodeGeometry(vals[i].([]byte)) + if err != nil { + log.Fatalf("error decoding the geometry: %s", err) + } + f.geometry = wkbgeom.Geometry + default: + switch v := vals[i].(type) { + case []uint8: + asBytes := make([]byte, len(v)) + copy(asBytes, v) + c = append(c, string(asBytes)) + case int64: + c = append(c, v) + case float64: + c = append(c, v) + case time.Time: + c = append(c, v.Format(time.RFC3339)) + case string: + c = append(c, v) + case nil: + c = append(c, v) + default: + log.Fatalf("unexpected type for sqlite column data: %v: %T", cols[i], v) + } + } + f.columns = c + } + ff := &f + features <- ff + } + err = rows.Err() + if err != nil { + log.Fatal(err) + } + close(features) + defer rows.Close() +} + +// selectSQL build a SELECT statement based on the table and columns +// used for reading the source features +func (t Table) selectSQL() string { + var csql []string //nolint:prealloc + for _, c := range t.columns { + csql = append(csql, c.name) + } + query := `SELECT ` + strings.Join(csql, `,`) + ` FROM "` + t.Name + `";` + return query +} + +// insertSQL used for writing the features +// build the INSERT statement based on the table and columns +func (t Table) insertSQL() string { + var csql, vsql []string + for _, c := range t.columns { + if c.name != t.gcolumn { + csql = append(csql, c.name) + vsql = append(vsql, `?`) + } + } + csql = append(csql, t.gcolumn) + vsql = append(vsql, `?`) + query := `INSERT INTO "` + t.Name + `"(` + strings.Join(csql, `,`) + `) VALUES(` + strings.Join(vsql, `,`) + `)` + return query +} + +// insertSQLEncoded used for writing the encoded geometries when -enc is on +func (t Table) insertSQLEncoded() string { + return `INSERT INTO "` + t.EncodedName() + `"` + + ` (tile_x, tile_y, tile_z, feature_id, geometry_type, data)` + + ` VALUES (?, ?, ?, ?, ?, ?)` +} + +func serializeToBytes(intslice []uint32) []byte { + bytes := make([]byte, len(intslice)*4) + + for i, x := range intslice { + binary.LittleEndian.PutUint32(bytes[i*4:], x) + } + + return bytes +} + +// Create tables in target. Defers to buildTable and buildEncodedTable +func (target *TargetGeopackage) CreateTables(tables []Table) error { + for _, table := range tables { + err := target.handle.UpdateSRS(table.srs) + if err != nil { + return err + } + + err = buildTable(target.handle, table) + if err != nil { + return err + } + + if target.EncodeTiles { + err = buildEncodedTable(target.handle, table) + if err != nil { + return err + } + } + } + return nil +} + +// Write the features to geopackage. Defers to writeFeatures and writeEncodedFeatures +func (target *TargetGeopackage) WriteFeatures(inFeatures <-chan processing.FeatureForTileMatrix) { + var features []processing.FeatureForTileMatrix + + for { + feature, hasMore := <-inFeatures + if !hasMore { + target.writeFeatures(features) + if target.EncodeTiles { + target.writeEncodedFeatures(features) + } + break + } + features = append(features, feature) + + if len(features)%target.pagesize == 0 { + target.writeFeatures(features) + if target.EncodeTiles { + target.writeEncodedFeatures(features) + } + features = nil + } + } +} + +// Write snapped features to table +func (target *TargetGeopackage) writeFeatures(features []processing.FeatureForTileMatrix) { + tx, err := target.handle.Begin() + if err != nil { + log.Fatalf("Could not start a transaction: %s", err) + } + + stmt, err := tx.Prepare(target.Table.insertSQL()) + if err != nil { + log.Fatalf("Could not prepare a statement: %s", err) + } + + var ext *geom.Extent + + for _, f := range features { + //nolint:gosec // G115 + sb, err := gpkg.NewBinary(int32(target.Table.srs.ID), f.Geometry()) + if err != nil { + log.Fatalf("Could not create a binary geometry: %s", err) + } + + data := f.Columns() + data = append(data, sb) + + _, err = stmt.Exec(data...) + if err != nil { + var fid any = "unknown" + if len(data) > 0 { + fid = data[0] + } + log.Fatalf("Could not get a result summary from the prepared statement for fid %s: %s", fid, err) + } + + if ext == nil { + ext, err = geom.NewExtentFromGeometry(f.Geometry()) + if err != nil { + ext = nil + log.Println("Failed to create new extent:", err) + continue + } + } else { + _ = ext.AddGeometry(f.Geometry()) + } + } + stmt.Close() + _ = tx.Commit() + + err = target.handle.UpdateGeometryExtent(target.Table.Name, ext) + if err != nil { + log.Fatalln("Failed to update new extent:", err) + } +} + +// Write encoded geometries to corresponding tables +func (target *TargetGeopackage) writeEncodedFeatures(encFeature []processing.FeatureForTileMatrix) { + tx, err := target.handle.Begin() + if err != nil { + log.Fatalf("Could not start a transaction: %s", err) + } + + stmt, err := tx.Prepare(target.Table.insertSQLEncoded()) + if err != nil { + log.Fatalf("Could not prepare a statement: %s", err) + } + + for _, ef := range encFeature { + cols := ef.Columns() + if len(cols) == 0 { + log.Fatalf("Processing feature without data") + } + fid := cols[0] + for _, eg := range ef.EncodedGeoms() { + bytes := serializeToBytes(eg.Encoding) + _, err := stmt.Exec(eg.XTile, eg.YTile, uint(ef.TileMatrixID()), fid, eg.GeometryType, bytes) //nolint:gosec // G115 integers < 40 + if err != nil { + log.Fatalf("Could not get a result summary from the prepared statement for fid %s: %s", fid, err) + } + } + + } + + stmt.Close() + _ = tx.Commit() +} + +// createSQL creates a CREATE statement on the given table and column information +// used for creating feature tables in the target Geopackage +func (t Table) createSQL() string { + create := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS "%v"`, t.Name) + var columnparts []string //nolint:prealloc + for _, column := range t.columns { + columnpart := column.name + ` ` + column.ctype + if column.notnull == 1 { + columnpart += ` NOT NULL` + } + if column.pk == 1 { + columnpart += ` PRIMARY KEY` + } + + columnparts = append(columnparts, columnpart) + } + + query := create + `(` + strings.Join(columnparts, `, `) + `);` + return query +} + +// buildTable creates a given destination table with the necessary gpkg_ information +func buildTable(h *gpkg.Handle, t Table) error { + query := t.createSQL() + _, err := h.Exec(query) + if err != nil { + log.Fatalf("error building table in target GeoPackage: %s", err) + } + + err = h.AddGeometryTable(gpkg.TableDescription{ + Name: t.Name, + ShortName: t.Name, + Description: t.Name, + GeometryField: t.gcolumn, + GeometryType: t.gtype, + //nolint:gosec // G115 + SRS: int32(t.srs.ID), + // + Z: gpkg.Prohibited, + M: gpkg.Prohibited, + }) + if err != nil { + log.Println("error adding geometry table in target GeoPackage:", err) + return err + } + return nil +} + +// create table for storing encoded geometries +func buildEncodedTable(h *gpkg.Handle, t Table) error { + db := h.DB + + query := fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS %s ( + id INTEGER PRIMARY KEY, + tile_x INTEGER NOT NULL, + tile_y INTEGER NOT NULL, + tile_z INTEGER NOT NULL, + feature_id INTEGER NOT NULL, + geometry_type INTEGER NOT NULL, + data BLOB + ) + `, t.EncodedName()) + _, err := db.Exec(query) + if err != nil { + log.Println("error adding encoded table in target GeoPackage:", err) + return err + } + return nil +} diff --git a/processing/interface.go b/processing/interface.go index 1aa20ab..dfab16e 100644 --- a/processing/interface.go +++ b/processing/interface.go @@ -2,6 +2,10 @@ package processing import ( "github.com/go-spatial/geom" + "github.com/pdok/texel/intgeom" + "github.com/pdok/texel/pointindex" + "github.com/pdok/texel/tile" + "github.com/pdok/texel/tms20" ) type Feature interface { @@ -12,12 +16,50 @@ type Feature interface { type FeatureForTileMatrix interface { Feature TileMatrixID() int + // EncodedGeoms is nil if encoding is not desired + EncodedGeoms() []tile.EncodedGeometry } +type SnapResult struct { + Geometry geom.Geometry + // Tiles is nil if encoding is not desired + Tiles []tile.Tile +} + +type TileCoord struct { + X, Y, Z uint +} + +type PIndex interface { + TDetector + InsertGeometry(geometry geom.Geometry) error + InternalPixelLevelFromTmsID(tmsID tms20.TMID) pointindex.Level + SnapClosestPoints(line geom.Line, levelMap map[pointindex.Level]any, ringID int) map[pointindex.Level][][2]float64 + GetHitMultiple(level pointindex.Level) map[intgeom.Point][]int +} + +type TDetector interface { + DetectTilesViaLineTrace(geometry geom.Geometry, tmsID tms20.TMID, buffer uint) []tile.Tile + GetQBBoxWithBuffer(tmsID tms20.TMID, buffer uint) []tile.Tile +} + +// Source and target for snapping type Source interface { ReadFeatures(ch chan<- Feature) } type Target interface { - WriteFeatures(ch <-chan Feature) + WriteFeatures(ch <-chan FeatureForTileMatrix) +} + +// Source and target for tile generation +type MVTSource interface { + ListTiles(zoomlevel uint, tileSet map[TileCoord]bool) error + GetFeaturesForTile(tileX, tileY, tileZ uint) ([]tile.EncodedFeatureRow, error) + GetAttributesForFeatures(featureIDs []int64) (tile.InternalAttributeTable, error) + AttributeColumnNames() []string +} + +type MVTTarget interface { + WriteTile(x, y, z uint, data []byte) error } diff --git a/processing/processing.go b/processing/processing.go index 9a6c166..6c7625f 100644 --- a/processing/processing.go +++ b/processing/processing.go @@ -1,90 +1,80 @@ -// Package processing takes care of the logistics around reading and writing to a Target. -// Not the processing operation(s) itself. package processing +// Orchestrating logic around processing the snap command. + import ( + "errors" "fmt" "log" + "sort" "sync" + "github.com/pdok/texel/geomhelp" + "github.com/pdok/texel/pointindex" + "github.com/pdok/texel/tile" "github.com/pdok/texel/tms20" "github.com/go-spatial/geom" ) -// readFeatures reads the features from the given Geopackage table -// and decodes the WKB geometry to a geom.Polygon -func readFeaturesFromSource(source Source, features chan<- Feature) { - source.ReadFeatures(features) +type Config struct { + KeepPointsAndLines bool + IgnoreOutsideGrid bool + ReverseWindingOrder bool + EncodeTiles bool + // Buffer is the number of internal pixels that tiles get + // inflated for detecting which geometries lie on them + Buffer uint + // Decide whether to use lineTrace or BBox for tile detection + UseLineTrace bool + Clip Clip } -// processFeatures processes the geometries in the features with the given function -func processFeatures(featuresIn <-chan Feature, featuresOut chan<- FeatureForTileMatrix, tmIDs []tms20.TMID, f processPolygonFunc) { - var preCount, postCount, nonPolygonCount, multiPolygonCount uint64 - for { - feature, hasMore := <-featuresIn - if !hasMore { - break - } - preCount++ - switch feature.Geometry().(type) { - case geom.Polygon: - polygon := feature.Geometry().(geom.Polygon) - newPolygonsPerTileMatrix := f(polygon, tmIDs) - if len(newPolygonsPerTileMatrix) > 0 { - postCount++ - } - for tmID, newPolygons := range newPolygonsPerTileMatrix { - var newGeometry geom.Geometry - if len(newPolygons) == 0 { // should never happen - panic(fmt.Errorf("no new polygon for level %v", tmID)) - } - if len(newPolygons) == 1 { - newGeometry = newPolygons[0] - } else { - // TODO polygons are combined into multipolygons, for now here - // later, processPolygonFunc could return abstract geometry(s) if also lines/points are returned - newGeometry = polygonsToMulti(newPolygons) - } - featuresOut <- wrapFeatureForTileMatrix(feature, tmID, newGeometry) - } - case geom.MultiPolygon: - multiPolygon := feature.Geometry().(geom.MultiPolygon) - newMultiPolygonPerTileMatrix := processMultiPolygon(multiPolygon, tmIDs, f) - if len(newMultiPolygonPerTileMatrix) > 0 { - postCount++ - } - for tmID, newMultiPolygon := range newMultiPolygonPerTileMatrix { - featuresOut <- wrapFeatureForTileMatrix(feature, tmID, newMultiPolygon) - } - default: - postCount++ - nonPolygonCount++ - for _, tmID := range tmIDs { - featuresOut <- wrapFeatureForTileMatrix(feature, tmID, nil) - } - } - } - close(featuresOut) +type Clip struct { + Z int + X, Y uint +} - log.Printf(" total features: %d", preCount) - log.Printf(" non-polygons: %d", nonPolygonCount) - if preCount != nonPolygonCount { - log.Printf(" multipolygons: %d", multiPolygonCount) +////////////////////////// +// Channel manipulation // +////////////////////////// + +// Entry point for processing. Initialize channels and create processor. +// Then kickstart processing. +// Closing channels is the responsibility of functions supplying them. +func ProcessFeatures(source Source, targets map[tms20.TMID]Target, f SnapFunc, newIndex func() PIndex, config Config) { + featuresBefore := make(chan Feature) + featuresAfter := make(chan FeatureForTileMatrix) + tileMatrixIDs := make([]tms20.TMID, 0, len(targets)) + for tmID := range targets { + tileMatrixIDs = append(tileMatrixIDs, tmID) } - log.Printf(" kept: %d", postCount) + + // Initialize geometry processor + processor := NewGeometryProcessor(tileMatrixIDs, config, f, newIndex) + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + writeFeaturesToTargets(featuresAfter, targets) + }() + go processFeatures(featuresBefore, featuresAfter, processor) + go source.ReadFeatures(featuresBefore) + + wg.Wait() } // writeFeatures collects the processed features by the processFeatures and // creates a WKB binary from the geometry // The collected feature array, based on the pagesize, is then passed to the writeFeaturesArray func writeFeaturesToTargets(featuresForTileMatrices <-chan FeatureForTileMatrix, targets map[int]Target) { - targetChannels := make(map[int]chan<- Feature) + targetChannels := make(map[int]chan<- FeatureForTileMatrix) wg := sync.WaitGroup{} // create a channel and start a goroutine per tile matrix target for tmID, target := range targets { - targetChannel := make(chan Feature) + targetChannel := make(chan FeatureForTileMatrix) targetChannels[tmID] = targetChannel wg.Add(1) go func(target Target) { @@ -115,47 +105,276 @@ func writeFeaturesToTargets(featuresForTileMatrices <-chan FeatureForTileMatrix, wg.Wait() } -// processMultiPolygon will split itself into the separated polygons that will be processed before building a new MULTIPOLYGON -func processMultiPolygon(multiPolygon geom.MultiPolygon, tileMatrixIDs []tms20.TMID, f processPolygonFunc) map[tms20.TMID]geom.MultiPolygon { - newMultiPolygonPerTileMatrix := make(map[tms20.TMID]geom.MultiPolygon, len(tileMatrixIDs)) - for _, polygon := range multiPolygon { - newPolygonsPerTileMatrix := f(polygon, tileMatrixIDs) - for tmID, newPolygons := range newPolygonsPerTileMatrix { - for _, newPolygon := range newPolygons { - // if the processing results in multiple polygons, they are just added to the single resulting multipoly - newMultiPolygonPerTileMatrix[tmID] = append(newMultiPolygonPerTileMatrix[tmID], newPolygon) +// Read inputs from channel and call the processor on them. Results wired to output channel. +// Also keep track of statistics. +func processFeatures(featuresIn <-chan Feature, featuresOut chan<- FeatureForTileMatrix, processor *GeometryProcessor) { + stats := initStats() + for { + feature, hasMore := <-featuresIn + if !hasMore { + break + } + stats.preCount++ + geometry := feature.Geometry() + stats.countGeometry(geometry) + newGeometriesPerTileMatrix := processor.Process(geometry) + + if len(newGeometriesPerTileMatrix) > 0 { + stats.postCount++ + } + for tmID, snapResult := range newGeometriesPerTileMatrix { + encGeoms := processor.encode(snapResult) + featuresOut <- wrapFeatureForTileMatrix(feature, tmID, snapResult.Geometry, encGeoms) + } + + } + close(featuresOut) + + log.Printf(" total features: %d", stats.preCount) + log.Printf(" non-polygons: %d", stats.nonPolygonCount) + if stats.preCount != stats.nonPolygonCount { + log.Printf(" multipolygons: %d", stats.multiPolygonCount) + } + log.Printf(" kept: %d", stats.postCount) +} + +// Statistics // + +type countStats struct { + preCount uint64 + postCount uint64 + nonPolygonCount uint64 + multiPolygonCount uint64 +} + +func initStats() countStats { + return countStats{0, 0, 0, 0} +} + +func (stats *countStats) countGeometry(g geom.Geometry) { + switch g.(type) { + case geom.MultiPolygon: + stats.nonPolygonCount++ + case geom.Polygon: + default: + stats.nonPolygonCount++ + } +} + +///////////////////////////////// +// Geometry Processor Creation // +///////////////////////////////// + +// Abstract data needed for processing. +type GeometryProcessor struct { + tmIDs []tms20.TMID + newIndex IndexFactory + snap func(ix PIndex, g geom.Geometry) map[tms20.TMID][]geom.Geometry + detectTiles tileDetector + encode func(SnapResult) []tile.EncodedGeometry +} + +// Abstract tile detector +type tileDetector func(ix TDetector, tmsID tms20.TMID, newGeometries []geom.Geometry) []tile.Tile + +// Abstract pointindex creator. Is implied to insert geometry in index +type IndexFactory func(geometry geom.Geometry) (PIndex, error) + +type SnapFunc func(ix PIndex, g geom.Geometry, tmsIDs []tms20.TMID, config Config) map[tms20.TMID][]geom.Geometry + +// Initialize GeometryProcessor +// Essentially turns configuration into functionality +func NewGeometryProcessor(tmIDs []tms20.TMID, config Config, f SnapFunc, newIndex func() PIndex) *GeometryProcessor { + return &GeometryProcessor{ + tmIDs: tmIDs, + newIndex: newIndexFactory(newIndex, config.IgnoreOutsideGrid), + snap: func(ix PIndex, g geom.Geometry) map[tms20.TMID][]geom.Geometry { + return f(ix, g, tmIDs, config) + }, + detectTiles: newTileDetector(config), + encode: newEncoder(config), + } +} + +// Create index and inserts geometry. +// Depending on configuration, will panic or skip when polygon is outside bounds +func newIndexFactory(newIndex func() PIndex, ignoreOutsideGrid bool) IndexFactory { + return func(geometry geom.Geometry) (PIndex, error) { + ix := newIndex() + err := ix.InsertGeometry(geometry) + if err == nil { + return ix, nil + } + outsideGridErr := new(pointindex.OutsideGridError) + if errors.As(err, outsideGridErr) && ignoreOutsideGrid { + log.Println("[WARNING] skipping geometry because: " + err.Error()) + return nil, nil + } + return nil, err + } +} + +// Create tile detection function based on configuration +func newTileDetector(config Config) tileDetector { + // No encoding: do not detect tiles + if !config.EncodeTiles { + return func(_ TDetector, _ tms20.TMID, _ []geom.Geometry) []tile.Tile { return nil } + } + + // Line tracing: line trace each geometry, append results + if config.UseLineTrace { + return func(td TDetector, tmsID tms20.TMID, newGeometries []geom.Geometry) []tile.Tile { + var tiles []tile.Tile + for _, newGeometry := range newGeometries { + tiles = combineTiles(tiles, td.DetectTilesViaLineTrace(newGeometry, tmsID, config.Buffer)) } + return filterTilesInClip(tmsID, config.Clip, tiles) } } - return newMultiPolygonPerTileMatrix + + // No line tracing: bbox detection + return func(td TDetector, tmsID tms20.TMID, _ []geom.Geometry) []tile.Tile { + unfilteredTiles := td.GetQBBoxWithBuffer(tmsID, config.Buffer) + return filterTilesInClip(tmsID, config.Clip, unfilteredTiles) + } } -type processPolygonFunc func(p geom.Polygon, tileMatrixIDs []tms20.TMID) map[tms20.TMID][]geom.Polygon +func filterTilesInClip(tmsID tms20.TMID, clip Clip, tiles []tile.Tile) []tile.Tile { + levelDiff := tmsID - clip.Z + if levelDiff < 0 { + err := fmt.Errorf("error generating tiles for tms %d with deeper clip level %d", tmsID, clip.Z) + panic(err) + } + filteredTiles := make([]tile.Tile, 0, len(tiles)) + for _, tile := range tiles { + parentX := tile.X >> levelDiff + parentY := tile.Y >> levelDiff + if clip.X == parentX && clip.Y == parentY { + filteredTiles = append(filteredTiles, tile) + } + } + return filteredTiles +} -// ProcessFeatures applies the processing function/operation to each Target. -func ProcessFeatures(source Source, targets map[tms20.TMID]Target, f processPolygonFunc) { - featuresBefore := make(chan Feature) - featuresAfter := make(chan FeatureForTileMatrix) - tileMatrixIDs := make([]tms20.TMID, 0, len(targets)) - for tmID := range targets { - tileMatrixIDs = append(tileMatrixIDs, tmID) +// Build encoding function based on geometry. +// This involves checking whether encoding is enabled at all, +// and creating the default tile for tile-filling polygons +func newEncoder(config Config) func(SnapResult) []tile.EncodedGeometry { + if !config.EncodeTiles { + return func(SnapResult) []tile.EncodedGeometry { return nil } } - wg := sync.WaitGroup{} - wg.Add(1) - go func() { - defer wg.Done() - writeFeaturesToTargets(featuresAfter, targets) - }() - go processFeatures(featuresBefore, featuresAfter, tileMatrixIDs, f) - go readFeaturesFromSource(source, featuresBefore) + // Create tile-filling geometry (reused across polygons) + defaultEnc, err := tile.NewDefaultEncoding(config.Buffer) + if err != nil { + panic(err) + } - wg.Wait() + return func(s SnapResult) []tile.EncodedGeometry { + encGeoms := make([]tile.EncodedGeometry, len(s.Tiles)) + orig := s.Geometry + for i, q := range s.Tiles { + encGeoms[i] = tile.MvtEncodeGeometry(q, orig, defaultEnc) + } + return encGeoms + } +} + +///////////////////////// +// Geometry processing // +///////////////////////// + +// Process a geometry. Distinguish between single- and multigeometries. +// ProcessSingleGeometry does the actual processing. +func (p *GeometryProcessor) Process(geometry geom.Geometry) map[tms20.TMID]SnapResult { + switch geometry := geometry.(type) { + case geom.Polygon, geom.LineString, geom.Point: + return p.ProcessSingle(geometry) + case geom.MultiPolygon, geom.MultiLineString, geom.MultiPoint: + return p.ProcessMulti(geomhelp.MultiGeometryToSlice(geometry)) + default: + newGeometriesPerTileMatrix := make(map[tms20.TMID]SnapResult, len(p.tmIDs)) + for _, tmID := range p.tmIDs { + newGeometriesPerTileMatrix[tmID] = SnapResult{nil, nil} + } + return newGeometriesPerTileMatrix + } +} + +// Process a single geometry (no multigeometries). +func (p *GeometryProcessor) ProcessSingle(geometry geom.Geometry) map[tms20.TMID]SnapResult { + ix, err := p.newIndex(geometry) + // Unknown error + if err != nil { + panic(err) + } + // Polygon should be skipped + if ix == nil { + return make(map[tms20.TMID]SnapResult) + } + + newGeometriesPerTileMatrix := p.snap(ix, geometry) + + // Create tiles and merge geometries + geomsAndTilesPerTileMatrix := make(map[tms20.TMID]SnapResult, len(p.tmIDs)) + for _, tmsID := range p.tmIDs { + newGeometries := newGeometriesPerTileMatrix[tmsID] + singleGeometry := geomhelp.GeometrySliceToGeom(newGeometries) + if singleGeometry == nil { + continue + } + tiles := p.detectTiles(ix, tmsID, newGeometries) + geomsAndTilesPerTileMatrix[tmsID] = SnapResult{singleGeometry, tiles} + } + return geomsAndTilesPerTileMatrix +} + +// Process a multigeometry by processing each individual geometry and merging the results +func (p *GeometryProcessor) ProcessMulti(multiGeometry []geom.Geometry) map[tms20.TMID]SnapResult { + newMultiGeometryPerTileMatrix := make(map[tms20.TMID]SnapResult, len(p.tmIDs)) + for _, geometry := range multiGeometry { + snapResultPerTileMatrix := p.ProcessSingle(geometry) + for tmID, snapResult := range snapResultPerTileMatrix { + currentResult := newMultiGeometryPerTileMatrix[tmID] + currentResult.Tiles = combineTiles(currentResult.Tiles, snapResult.Tiles) + currentResult.Geometry = geomhelp.MergeGeometries(currentResult.Geometry, snapResult.Geometry) + newMultiGeometryPerTileMatrix[tmID] = currentResult + } + } + return newMultiGeometryPerTileMatrix +} + +// Combine two slices of tiles. Ensure at most one tile per coordinate in +// result. If contained in both, IsContained is set to false if it is +// false in either. IsContained is an optimisation, ot oet true conservatively. +func combineTiles(a, b []tile.Tile) []tile.Tile { + if len(a) == 0 && len(b) == 0 { + return nil + } + combined := make(map[[2]uint]tile.Tile, len(a)+len(b)) + for _, tiles := range [][]tile.Tile{a, b} { + for _, t := range tiles { + key := [2]uint{t.X, t.Y} + if existing, ok := combined[key]; ok { + t.IsContained = existing.IsContained && t.IsContained + } + combined[key] = t + } + } + result := make([]tile.Tile, 0, len(combined)) + for _, t := range combined { + result = append(result, t) + } + sort.Slice(result, func(i, j int) bool { + return result[i].X < result[j].X || (result[i].X == result[j].X && result[i].Y < result[j].Y) + }) + return result } type featureForTileMatrixWrapper struct { wrapped Feature newGeometry geom.Geometry + encodedGeoms []tile.EncodedGeometry tileMatrixID int } @@ -174,19 +393,15 @@ func (f *featureForTileMatrixWrapper) TileMatrixID() int { return f.tileMatrixID } -func wrapFeatureForTileMatrix(feature Feature, tileMatrixID int, newGeometry geom.Geometry) FeatureForTileMatrix { +func (f *featureForTileMatrixWrapper) EncodedGeoms() []tile.EncodedGeometry { + return f.encodedGeoms +} + +func wrapFeatureForTileMatrix(feature Feature, tileMatrixID int, newGeometry geom.Geometry, encGeoms []tile.EncodedGeometry) FeatureForTileMatrix { return &featureForTileMatrixWrapper{ wrapped: feature, newGeometry: newGeometry, tileMatrixID: tileMatrixID, + encodedGeoms: encGeoms, } } - -func polygonsToMulti(polygons []geom.Polygon) geom.MultiPolygon { - l := len(polygons) - multiPolygon := make(geom.MultiPolygon, l) - for i := range l { - multiPolygon[i] = polygons[i] - } - return multiPolygon -} diff --git a/processing/processing_test.go b/processing/processing_test.go new file mode 100644 index 0000000..494b625 --- /dev/null +++ b/processing/processing_test.go @@ -0,0 +1,518 @@ +package processing + +import ( + "errors" + "reflect" + "testing" + + "github.com/go-spatial/geom" + "github.com/pdok/texel/intgeom" + "github.com/pdok/texel/pointindex" + "github.com/pdok/texel/tile" + "github.com/pdok/texel/tms20" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Helper that creates a mock Pointindex Factory with RD tileset. +func newTestIndexFactory(t *testing.T, deepestTMID tms20.TMID) func() PIndex { + t.Helper() + tms, err := tms20.LoadEmbeddedTileMatrixSet("NetherlandsRDNewQuad") + require.NoError(t, err) + rawFactory := pointindex.Factory(tms, deepestTMID) + return func() PIndex { return rawFactory() } +} + +// Mock snap function +func identitySnapFunc(_ PIndex, g geom.Geometry, tmIDs []tms20.TMID, _ Config) map[tms20.TMID][]geom.Geometry { + result := make(map[tms20.TMID][]geom.Geometry, len(tmIDs)) + for _, tmID := range tmIDs { + result[tmID] = []geom.Geometry{g} + } + return result +} + +// Test whether the return types of various input geometries are handled correctly. +func TestGeometryProcessorProcess(t *testing.T) { + polyA := geom.Polygon{{{100000, 100000}, {100010, 100000}, {100010, 100010}, {100000, 100000}}} + polyB := geom.Polygon{{{200000, 200000}, {200010, 200000}, {200010, 200010}, {200000, 200000}}} + lineA := geom.LineString{{100000, 100000}, {100010, 100010}} + pointA := geom.Point{100000, 100000} + + factory := newTestIndexFactory(t, 1) + tmIDs := []tms20.TMID{1} + config := Config{} + processor := NewGeometryProcessor(tmIDs, config, identitySnapFunc, factory) + + tests := []struct { + name string + geometry geom.Geometry + want map[tms20.TMID]SnapResult + }{ + { + name: "single Polygon: passthrough via ProcessSingle", + geometry: polyA, + want: map[tms20.TMID]SnapResult{1: {Geometry: polyA, Tiles: nil}}, + }, + { + name: "single LineString (non-polygon): passthrough via ProcessSingle", + geometry: lineA, + want: map[tms20.TMID]SnapResult{1: {Geometry: lineA, Tiles: nil}}, + }, + { + name: "single Point (non-polygon): passthrough via ProcessSingle", + geometry: pointA, + want: map[tms20.TMID]SnapResult{1: {Geometry: pointA, Tiles: nil}}, + }, + { + name: "MultiPolygon with 2 polygons: merged via ProcessMulti", + geometry: geom.MultiPolygon{polyA, polyB}, + want: map[tms20.TMID]SnapResult{1: {Geometry: geom.MultiPolygon{polyA, polyB}, Tiles: nil}}, + }, + { + name: "MultiLineString with 1 line (non-polygon): passthrough via ProcessMulti", + geometry: geom.MultiLineString{lineA}, + want: map[tms20.TMID]SnapResult{1: {Geometry: lineA, Tiles: nil}}, + }, + { + name: "MultiPoint with 1 point (non-polygon): passthrough via ProcessMulti", + geometry: geom.MultiPoint{pointA}, + want: map[tms20.TMID]SnapResult{1: {Geometry: pointA, Tiles: nil}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := processor.Process(tt.geometry) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Process() = %#v, want %#v", got, tt.want) + } + }) + } +} + +// Test error behaviour +func TestGeometryProcessorProcess_OutsideGrid(t *testing.T) { + outsidePoint := geom.Point{10000000, 10000000} + factory := newTestIndexFactory(t, 1) + tmIDs := []tms20.TMID{1} + + t.Run("IgnoreOutsideGrid=true: empty result, no panic", func(t *testing.T) { + config := Config{IgnoreOutsideGrid: true} + processor := NewGeometryProcessor(tmIDs, config, identitySnapFunc, factory) + got := processor.Process(outsidePoint) + assert.Empty(t, got) + }) + + t.Run("IgnoreOutsideGrid=false: panics", func(t *testing.T) { + config := Config{IgnoreOutsideGrid: false} + processor := NewGeometryProcessor(tmIDs, config, identitySnapFunc, factory) + assert.Panics(t, func() { + processor.Process(outsidePoint) + }) + }) +} + +// Test tile detector // + +type mockTileDetector struct { + lineTraceQueue [][]tile.Tile + bboxQueue [][]tile.Tile +} + +func (mock *mockTileDetector) DetectTilesViaLineTrace(_ geom.Geometry, _ tms20.TMID, _ uint) []tile.Tile { + if len(mock.lineTraceQueue) == 0 { + panic("Empty queue") + } + result := mock.lineTraceQueue[0] + mock.lineTraceQueue = mock.lineTraceQueue[1:] + + return result +} + +func (mock *mockTileDetector) GetQBBoxWithBuffer(_ tms20.TMID, _ uint) []tile.Tile { + if len(mock.bboxQueue) == 0 { + panic("Empty queue") + } + result := mock.bboxQueue[0] + tail := mock.bboxQueue[1:] + mock.bboxQueue = tail + + return result +} + +func (mock *mockTileDetector) isEmpty() bool { + return len(mock.lineTraceQueue) == 0 && len(mock.bboxQueue) == 0 +} + +func TestFilterTilesInClip(t *testing.T) { + tests := []struct { + name string + tmsID tms20.TMID + clip Clip + tiles []tile.Tile + want []tile.Tile + panic bool + }{ + { + name: "same level: keeps tiles in clip", + tmsID: 5, + clip: Clip{Z: 5, X: 2, Y: 3}, + tiles: []tile.Tile{{X: 2, Y: 3}, {X: 2, Y: 4}, {X: 3, Y: 3}}, + want: []tile.Tile{{X: 2, Y: 3}}, + }, + { + name: "deeper level: keeps tiles whose parent is in clip", + tmsID: 5, + clip: Clip{Z: 3, X: 4, Y: 2}, + tiles: []tile.Tile{{X: 16, Y: 8}, {X: 17, Y: 9}, {X: 15, Y: 8}}, + want: []tile.Tile{{X: 16, Y: 8}, {X: 17, Y: 9}}, + }, + { + name: "no tiles in clip: returns empty slice", + tmsID: 5, + clip: Clip{Z: 5, X: 2, Y: 3}, + tiles: []tile.Tile{{X: 1, Y: 3}, {X: 2, Y: 4}}, + want: []tile.Tile{}, + }, + { + name: "tile matrix level below clip: panics", + tmsID: 4, + clip: Clip{Z: 5, X: 2, Y: 3}, + tiles: []tile.Tile{{X: 2, Y: 3}}, + panic: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.panic { + assert.Panics(t, func() { + filterTilesInClip(tt.tmsID, tt.clip, tt.tiles) + }) + return + } + + got := filterTilesInClip(tt.tmsID, tt.clip, tt.tiles) + assert.Equal(t, tt.want, got) + }) + } +} + +// Test that correct tile detection mechanism is used. +func TestNewTileDetector(t *testing.T) { + tileA := tile.Tile{X: 1, Y: 1} + tileB := tile.Tile{X: 2, Y: 2} + geomA := geom.Point{1, 1} + geomB := geom.Point{2, 2} + + tests := []struct { + name string + config Config + tmsID tms20.TMID + newGeometries []geom.Geometry + lineTraceQueue [][]tile.Tile + bboxQueue [][]tile.Tile + wantTiles []tile.Tile + }{ + { + name: "encoding disabled: no PIndex calls, nil result", + config: Config{EncodeTiles: false, UseLineTrace: true, Buffer: 3}, + tmsID: 1, + newGeometries: []geom.Geometry{geomA, geomB}, + lineTraceQueue: nil, + bboxQueue: nil, + wantTiles: nil, + }, + { + name: "line trace: called once per geometry, tiles concatenated in order", + config: Config{EncodeTiles: true, UseLineTrace: true, Buffer: 3}, + tmsID: 2, + newGeometries: []geom.Geometry{geomA, geomB}, + lineTraceQueue: [][]tile.Tile{{tileA}, {tileA, tileB}}, + wantTiles: []tile.Tile{tileA, tileB}, + }, + { + name: "bbox: called exactly once regardless of geometry count", + config: Config{EncodeTiles: true, UseLineTrace: false, Buffer: 5}, + tmsID: 3, + newGeometries: []geom.Geometry{geomA, geomB}, + bboxQueue: [][]tile.Tile{{tileA, tileB}}, + wantTiles: []tile.Tile{tileA, tileB}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockTileDetector{ + lineTraceQueue: tt.lineTraceQueue, + bboxQueue: tt.bboxQueue, + } + detect := newTileDetector(tt.config) + + got := detect(mock, tt.tmsID, tt.newGeometries) + + assert.Equal(t, tt.wantTiles, got) + assert.True(t, mock.isEmpty()) + }) + } +} + +//////////////////////////////// +// Test ProcessSingleGeometry // +//////////////////////////////// + +// For mocking PIndex +type MockPointIndexProcessing struct { + // Keep track of which function should be called next + state string + // Keep track of how often "Detect" has been called + numgeomsleft *int + // Whether "InsertGeometry" should raise error + insertErr error + // Tile slice to eventually return + detectTilesResult map[tms20.TMID][]tile.Tile +} + +func createMockIndexFactory(numDetect *int, insertErr error, detectTilesResult map[tms20.TMID][]tile.Tile) func() PIndex { + return func() PIndex { + return &MockPointIndexProcessing{ + state: "insert", + numgeomsleft: numDetect, + insertErr: insertErr, + detectTilesResult: detectTilesResult, + } + } +} + +func (mock *MockPointIndexProcessing) InsertGeometry(_ geom.Geometry) error { + if mock.state != "insert" { + panic("InsertGeometry called out of order") + } + if mock.insertErr != nil { + return mock.insertErr + } + mock.state = "snap" + return nil +} + +func createMockSnapFunc(result map[tms20.TMID][]geom.Geometry) SnapFunc { + return func(ix PIndex, _ geom.Geometry, _ []tms20.TMID, _ Config) map[tms20.TMID][]geom.Geometry { + if mock, ok := ix.(*MockPointIndexProcessing); ok { + if mock.state != "snap" { + panic("snap called out of order") + } + mock.state = "detect" + } + return result + } +} + +func (mock *MockPointIndexProcessing) DetectTilesViaLineTrace(_ geom.Geometry, _ tms20.TMID, _ uint) []tile.Tile { + panic("MockPointIndexProcessing should be used with BBox detection") +} + +func (mock *MockPointIndexProcessing) GetQBBoxWithBuffer(tmsID tms20.TMID, _ uint) []tile.Tile { + if mock.state != "detect" { + panic("GetQBBoxWithBuffer called out of order") + } + + *mock.numgeomsleft-- + + if *mock.numgeomsleft == 0 { + mock.state = "done" + } + + return mock.detectTilesResult[tmsID] +} + +func (mock *MockPointIndexProcessing) InternalPixelLevelFromTmsID(_ tms20.TMID) pointindex.Level { + return 0 +} + +func (mock *MockPointIndexProcessing) SnapClosestPoints(_ geom.Line, _ map[pointindex.Level]any, _ int) map[pointindex.Level][][2]float64 { + return nil +} + +func (mock *MockPointIndexProcessing) GetHitMultiple(_ pointindex.Level) map[intgeom.Point][]int { + return nil +} + +func TestProcessSingle(t *testing.T) { + polyA := geom.Polygon{{{0, 0}, {1, 0}, {1, 1}, {0, 0}}} + polyB := geom.Polygon{{{10, 10}, {11, 10}, {11, 11}, {10, 10}}} + tileA := tile.Tile{X: 1, Y: 1} + tileB := tile.Tile{X: 2, Y: 2} + + tests := []struct { + name string + tmIDs []tms20.TMID + insertErr error + snapOutput map[tms20.TMID][]geom.Geometry + detectTilesResult map[tms20.TMID][]tile.Tile + wantPanic bool + config Config + geometry geom.Geometry + want map[tms20.TMID]SnapResult + }{ + { + name: "index factory error: panics", + tmIDs: []tms20.TMID{1}, + insertErr: errors.New("boom"), + wantPanic: true, + }, + { + name: "index skipped (e.g. outside grid): empty, non-nil result map", + tmIDs: []tms20.TMID{1, 2}, + insertErr: pointindex.OutsideGridError{}, + config: Config{IgnoreOutsideGrid: true}, + want: map[tms20.TMID]SnapResult{}, + }, + { + name: "single tile matrix: snap/detectTiles results wired into one SnapResult", + tmIDs: []tms20.TMID{1}, + config: Config{EncodeTiles: true, UseLineTrace: false}, + snapOutput: map[tms20.TMID][]geom.Geometry{ + 1: {polyA}, + }, + detectTilesResult: map[tms20.TMID][]tile.Tile{ + 1: {tileA}, + }, + want: map[tms20.TMID]SnapResult{ + 1: {Geometry: polyA, Tiles: []tile.Tile{tileA}}, + }, + }, + { + name: "multiple tile matrices: independent per tmID, multi-geometry result built as multipolygon", + tmIDs: []tms20.TMID{1, 2}, + config: Config{EncodeTiles: true, UseLineTrace: false}, + snapOutput: map[tms20.TMID][]geom.Geometry{ + 1: {polyA}, + 2: {polyA, polyB}, + }, + detectTilesResult: map[tms20.TMID][]tile.Tile{ + 1: {tileA}, + 2: {tileA, tileB}, + }, + want: map[tms20.TMID]SnapResult{ + 1: {Geometry: polyA, Tiles: []tile.Tile{tileA}}, + 2: {Geometry: geom.MultiPolygon{polyA, polyB}, Tiles: []tile.Tile{tileA, tileB}}, + }, + }, + { + name: "nil geometry leaves unset results", + tmIDs: []tms20.TMID{1}, + config: Config{EncodeTiles: true, UseLineTrace: false}, + snapOutput: map[tms20.TMID][]geom.Geometry{ + 1: {}, + }, + detectTilesResult: map[tms20.TMID][]tile.Tile{}, + want: map[tms20.TMID]SnapResult{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // This counts how often "tileDetect" has been called + numDetect := len(tt.want) + + processor := NewGeometryProcessor(tt.tmIDs, tt.config, createMockSnapFunc(tt.snapOutput), createMockIndexFactory(&numDetect, tt.insertErr, tt.detectTilesResult)) + + if tt.wantPanic { + assert.Panics(t, func() { + processor.ProcessSingle(tt.geometry) + }) + return + } + + got := processor.ProcessSingle(tt.geometry) + assert.Equal(t, tt.want, got) + + // Check that "tileDetect" has been called the correct amount of times. + if len(tt.detectTilesResult) > 0 { + assert.Equal(t, 0, numDetect) + } + }) + } +} + +func TestProcessMulti(t *testing.T) { + pointA := geom.Point{1, 1} + pointB := geom.Point{2, 2} + pointC := geom.Point{3, 3} + tileA := tile.Tile{X: 1, Y: 1} + tileB := tile.Tile{X: 2, Y: 2} + tileC := tile.Tile{X: 3, Y: 3} + + tests := []struct { + name string + tmIDs []tms20.TMID + multiGeometry []geom.Geometry + tilesByPoint map[geom.Point]map[tms20.TMID][]tile.Tile + want map[tms20.TMID]SnapResult + }{ + { + name: "single geometry: passthrough, no MultiPoint wrapping", + tmIDs: []tms20.TMID{1}, + multiGeometry: []geom.Geometry{pointA}, + tilesByPoint: map[geom.Point]map[tms20.TMID][]tile.Tile{ + pointA: {1: {tileA}}, + }, + want: map[tms20.TMID]SnapResult{ + 1: {Geometry: pointA, Tiles: []tile.Tile{tileA}}, + }, + }, + { + name: "two geometries: merged into MultiPoint, tiles concatenated", + tmIDs: []tms20.TMID{1}, + multiGeometry: []geom.Geometry{pointA, pointB}, + tilesByPoint: map[geom.Point]map[tms20.TMID][]tile.Tile{ + pointA: {1: {tileA}}, + pointB: {1: {tileB}}, + }, + want: map[tms20.TMID]SnapResult{ + 1: {Geometry: geom.MultiPoint{pointA, pointB}, Tiles: []tile.Tile{tileA, tileB}}, + }, + }, + { + name: "three geometries across two tile matrices: independent merge/tiles per tmID", + tmIDs: []tms20.TMID{1, 2}, + multiGeometry: []geom.Geometry{pointA, pointB, pointC}, + tilesByPoint: map[geom.Point]map[tms20.TMID][]tile.Tile{ + pointA: {1: {tileA}, 2: nil}, + pointB: {1: {tileB}, 2: {tileB}}, + pointC: {1: {tileC}, 2: {tileC}}, + }, + want: map[tms20.TMID]SnapResult{ + 1: {Geometry: geom.MultiPoint{pointA, pointB, pointC}, Tiles: []tile.Tile{tileA, tileB, tileC}}, + 2: {Geometry: geom.MultiPoint{pointA, pointB, pointC}, Tiles: []tile.Tile{tileB, tileC}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gp := &GeometryProcessor{ + tmIDs: tt.tmIDs, + newIndex: func(_ geom.Geometry) (PIndex, error) { + return &MockPointIndexProcessing{}, nil + }, + snap: func(_ PIndex, g geom.Geometry) map[tms20.TMID][]geom.Geometry { + result := make(map[tms20.TMID][]geom.Geometry, len(tt.tmIDs)) + for _, tmID := range tt.tmIDs { + result[tmID] = []geom.Geometry{g} + } + return result + }, + detectTiles: func(_ TDetector, tmsID tms20.TMID, newGeometries []geom.Geometry) []tile.Tile { + p := newGeometries[0].(geom.Point) + return tt.tilesByPoint[p][tmsID] + }, + } + + got := gp.ProcessMulti(tt.multiGeometry) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/processing/processingmvt.go b/processing/processingmvt.go new file mode 100644 index 0000000..ed9c999 --- /dev/null +++ b/processing/processingmvt.go @@ -0,0 +1,126 @@ +package processing + +// Orchestrating functionality for the mvt command + +import ( + "bytes" + "compress/gzip" + "fmt" + "log" + + vectorTile "github.com/go-spatial/geom/encoding/mvt/vector_tile" + "github.com/pdok/texel/config" + "github.com/pdok/texel/tile" +) + +// Orchestrator for the tile creation pipeline +func BuildAndWriteMVTTiles(layers []Layer, zoomlevel uint, target MVTTarget) error { + tiles, err := listTiles(zoomlevel, layers) + if err != nil { + return err + } + + for _, coord := range tiles { + log.Printf(" building (%d, %d, Z = %d)", coord.X, coord.Y, coord.Z) + data, err := processTile(coord, layers) + if err != nil { + return fmt.Errorf("building tile (%d, %d): %w", coord.X, coord.Y, err) + } + data, err = gzipCompress(data) + if err != nil { + return fmt.Errorf("compressing tile (%d, %d): %w", coord.X, coord.Y, err) + } + if err := target.WriteTile(coord.X, coord.Y, coord.Z, data); err != nil { + return fmt.Errorf("writing tile (%d, %d): %w", coord.X, coord.Y, err) + } + } + return nil +} + +type Layer struct { + Source MVTSource + Name string + keyIndex map[string]uint32 +} + +func BuildLayer(name string, source MVTSource) Layer { + keydict := tile.BuildKeyDictionary(source.AttributeColumnNames()) + return Layer{ + Name: name, + Source: source, + keyIndex: keydict, + } +} + +type MvtConfig struct { + Layers []Layer + Zoomlevel uint +} + +func DatasourceToDictionary(sources []config.DataSource) map[string]string { + dataSourceDictionary := make(map[string]string, 0) + for _, dataSource := range sources { + dataSourceDictionary[dataSource.Name] = dataSource.Path + } + return dataSourceDictionary +} + +func listTiles(zoomlevel uint, layers []Layer) ([]TileCoord, error) { + tileSet := make(map[TileCoord]bool, 0) + for _, layer := range layers { + err := layer.Source.ListTiles(zoomlevel, tileSet) + if err != nil { + return nil, err + } + } + + tileList := make([]TileCoord, 0, len(tileSet)) + for tilecoord := range tileSet { + tileList = append(tileList, tilecoord) + } + return tileList, nil +} + +func processLayer(tileCoord TileCoord, layer Layer) (*vectorTile.Tile_Layer, error) { + encFeatRows, err := layer.Source.GetFeaturesForTile(tileCoord.X, tileCoord.Y, tileCoord.Z) + if err != nil { + return nil, fmt.Errorf("getting features for tile (%d,%d): %w", tileCoord.X, tileCoord.Y, err) + } + featIDs := make([]int64, len(encFeatRows)) + for i, encFeatRow := range encFeatRows { + featIDs[i] = encFeatRow.FeatureID + } + + attributes, err := layer.Source.GetAttributesForFeatures(featIDs) + if err != nil { + return nil, fmt.Errorf("getting attributes for tile (%d,%d): %w", tileCoord.X, tileCoord.Y, err) + } + + return tile.BuildLayer(layer.Name, layer.keyIndex, encFeatRows, attributes) +} + +func processTile(coord TileCoord, layers []Layer) ([]byte, error) { + encLayers := make([]*vectorTile.Tile_Layer, 0, len(layers)) + for _, layer := range layers { + encLayer, err := processLayer(coord, layer) + if err != nil { + err = fmt.Errorf("during layer encoding %s: %w", layer.Name, err) + return nil, err + } + encLayers = append(encLayers, encLayer) + } + return tile.BuildEncodeTile(encLayers) +} + +// wrapper around gzip +func gzipCompress(data []byte) ([]byte, error) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + if _, err := gw.Write(data); err != nil { + return nil, fmt.Errorf("writing gzip data: %w", err) + } + if err := gw.Close(); err != nil { + return nil, fmt.Errorf("closing gzip writer: %w", err) + } + return buf.Bytes(), nil +} diff --git a/processing/writetile/writetile.go b/processing/writetile/writetile.go new file mode 100644 index 0000000..c84c46b --- /dev/null +++ b/processing/writetile/writetile.go @@ -0,0 +1,221 @@ +package writetile + +import ( + "context" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob" + "github.com/pdok/texel/config" + "github.com/pdok/texel/processing" +) + +type TileWriter struct { + targets []processing.MVTTarget +} + +// Using constructor injection +// This should return `nil` if required config is not present +type targetConstructor func(config.TomlConfig) (processing.MVTTarget, error) + +func NewTileWriter(conf config.TomlConfig) (*TileWriter, error) { + return newTileWriter(conf, []targetConstructor{ + // Wrapper to convert concrete return type to interface + adaptConstructor(NewMVTAzureTarget), + adaptConstructor(NewMVTFileTarget), + }) +} + +func newTileWriter(conf config.TomlConfig, factories []targetConstructor) (*TileWriter, error) { + var targets []processing.MVTTarget + for _, factory := range factories { + ft, err := factory(conf) + if err != nil { + return nil, err + } + if ft != nil { + targets = append(targets, ft) + } + } + + if len(targets) == 0 { + return nil, errors.New("no configured targets for texel mvt") + } + + return &TileWriter{ + targets: targets, + }, nil +} + +func (t *TileWriter) WriteTile(x, y, z uint, data []byte) error { + var err error + for _, target := range t.targets { + err = target.WriteTile(x, y, z, data) + if err != nil { + return err + } + } + + return nil +} + +// Adapt target constructor to return interface +// Here P is an interface to a pointer of type T that implements MVTTarget +func adaptConstructor[T any, P interface { + *T + processing.MVTTarget +}, +](constructor func(config.TomlConfig) (P, error)) targetConstructor { + return func(conf config.TomlConfig) (processing.MVTTarget, error) { + target, err := constructor(conf) + // Converting to interface requires a nil check + if err != nil || target == nil { + return nil, err + } + return target, nil + } +} + +//////////////////// +// MVTAzureTarget // +//////////////////// + +const ( + mvtBlobContentType = "application/vnd.mapbox-vector-tile" + mvtBlobContentEncoding = "gzip" +) + +type blobUploader interface { + UploadBuffer( + ctx context.Context, + containerName string, + blobName string, + buffer []byte, + options *azblob.UploadBufferOptions, + ) (azblob.UploadBufferResponse, error) +} + +// MVTAzureTarget uploads each built MVT tile to Azure Blob Storage before +// WriteTile returns. +type MVTAzureTarget struct { + client blobUploader + container string + prefix string +} + +func NewMVTAzureTarget(conf config.TomlConfig) (*MVTAzureTarget, error) { + if conf.Cache.Azure == nil { + return nil, nil + } + + connString := conf.Cache.Azure.ConnectionString + container := conf.Cache.Azure.Container + + client, err := newMVTBlobClient(connString, container) + if err != nil { + return nil, err + } + + return &MVTAzureTarget{ + client: client, + container: container, + prefix: buildPrefix(conf), + }, nil +} + +func newMVTBlobClient(connectionString, container string) (blobUploader, error) { + if connectionString == "" { + return nil, errors.New("azure blob storage connection string is required") + } + if container == "" { + return nil, errors.New("azure blob storage container is required") + } + client, err := azblob.NewClientFromConnectionString(connectionString, nil) + if err != nil { + return nil, errors.New("creating azure blob storage client: invalid connection string") + } + return client, nil +} + +func buildPrefix(conf config.TomlConfig) string { + return path.Join( + strings.Trim(conf.Cache.Azure.PrefixKey, "/"), + conf.Tileset.Name, + ) +} + +// WriteTile uploads one tile before returning. +func (t *MVTAzureTarget) WriteTile(x, y, z uint, data []byte) error { + name := mvtBlobName(t.prefix, x, y, z) + _, err := t.client.UploadBuffer(context.Background(), t.container, name, data, &azblob.UploadBufferOptions{ + HTTPHeaders: mvtBlobHTTPHeaders(), + }) + if err != nil { + return fmt.Errorf("uploading blob %q: %w", name, err) + } + return nil +} + +func mvtBlobName(prefix string, x, y, z uint) string { + return path.Join( + prefix, + strconv.FormatUint(uint64(z), 10), + strconv.FormatUint(uint64(x), 10), + strconv.FormatUint(uint64(y), 10)+".pbf", + ) +} + +func mvtBlobHTTPHeaders() *blob.HTTPHeaders { + return &blob.HTTPHeaders{ + BlobContentType: new(mvtBlobContentType), + BlobContentEncoding: new(mvtBlobContentEncoding), + } +} + +/////////////////// +// MVTFileTarget // +/////////////////// + +// MVTFileTarget writes built MVT tiles to //.pbf +type MVTFileTarget struct { + OutDir string +} + +func NewMVTFileTarget(conf config.TomlConfig) (*MVTFileTarget, error) { + if conf.Cache.File == nil { + return nil, nil + } + + base := conf.Cache.File.Base + outDir := filepath.Join(base, conf.Tileset.Name) + + err := os.MkdirAll(outDir, 0o775) + if err != nil { + return nil, err + } + + return &MVTFileTarget{ + OutDir: outDir, + }, nil +} + +// WriteTile writes one tile's serialized bytes to //.pbf +// creating directories as needed. +func (t *MVTFileTarget) WriteTile(x, y, z uint, data []byte) error { + dir := filepath.Join(t.OutDir, strconv.FormatUint(uint64(z), 10), strconv.FormatUint(uint64(x), 10)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating tile directory %s: %w", dir, err) + } + + path := filepath.Join(dir, strconv.FormatUint(uint64(y), 10)+".pbf") + if err := os.WriteFile(path, data, 0o644); err != nil { //nolint:gosec // G306 tile output does not need restrictive permissions + return fmt.Errorf("writing tile file %s: %w", path, err) + } + return nil +} diff --git a/processing/writetile/writetile_test.go b/processing/writetile/writetile_test.go new file mode 100644 index 0000000..2c6594c --- /dev/null +++ b/processing/writetile/writetile_test.go @@ -0,0 +1,233 @@ +package writetile + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/pdok/texel/config" + "github.com/pdok/texel/processing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeTileTarget struct { + name string + calls *[]tileWriteCall + err error +} + +type tileWriteCall struct { + name string + x uint + y uint + z uint + data []byte +} + +func (f fakeTileTarget) WriteTile(x, y, z uint, data []byte) error { + *f.calls = append(*f.calls, tileWriteCall{ + name: f.name, + x: x, + y: y, + z: z, + data: append([]byte(nil), data...), + }) + return f.err +} + +func TestNewTileWriter(t *testing.T) { + tests := []struct { + name string + targetConstructors []targetConstructor + conf config.TomlConfig + wantErr bool + numConf int + }{ + { + name: "no constructors", + targetConstructors: []targetConstructor{}, + conf: config.TomlConfig{}, + wantErr: true, + }, + { + name: "several constructors, some nil", + targetConstructors: []targetConstructor{ + func(config.TomlConfig) (processing.MVTTarget, error) { + return fakeTileTarget{name: "first", calls: nil}, nil + }, + func(config.TomlConfig) (processing.MVTTarget, error) { + return nil, nil + }, + func(config.TomlConfig) (processing.MVTTarget, error) { + return fakeTileTarget{name: "second", calls: nil}, nil + }, + }, + conf: config.TomlConfig{}, + wantErr: false, + numConf: 2, + }, + { + name: "only nil constructors", + targetConstructors: []targetConstructor{ + func(config.TomlConfig) (processing.MVTTarget, error) { + return nil, nil + }, + func(config.TomlConfig) (processing.MVTTarget, error) { + return nil, nil + }, + }, + conf: config.TomlConfig{}, + wantErr: true, + }, + { + name: "error during construction", + targetConstructors: []targetConstructor{ + func(config.TomlConfig) (processing.MVTTarget, error) { + return nil, nil + }, + func(config.TomlConfig) (processing.MVTTarget, error) { + return nil, errors.New("error") + }, + }, + conf: config.TomlConfig{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer, err := newTileWriter(tt.conf, tt.targetConstructors) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Len(t, writer.targets, tt.numConf) + }) + } +} + +func TestBuildPrefix(t *testing.T) { + tests := []struct { + name string + prefixKey string + want string + }{ + {name: "empty prefix", prefixKey: "", want: "NetherlandsRDNewQuad"}, + {name: "prefix without slashes", prefixKey: "tiles/base", want: "tiles/base/NetherlandsRDNewQuad"}, + {name: "prefix with surrounding slashes", prefixKey: "/tiles/base/", want: "tiles/base/NetherlandsRDNewQuad"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conf := config.TomlConfig{ + Tileset: &config.Tileset{Name: "NetherlandsRDNewQuad"}, + Cache: config.CacheConfig{ + Azure: &config.AzureConfig{PrefixKey: tt.prefixKey}, + }, + } + if got := buildPrefix(conf); got != tt.want { + t.Errorf("buildPrefix() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMvtBlobName(t *testing.T) { + got := mvtBlobName("lv-bgt/NetherlandsRDNewQuad", 3, 4, 12) + want := "lv-bgt/NetherlandsRDNewQuad/12/3/4.pbf" + assert.Equal(t, want, got) +} + +type fakeBlobUploader struct { + container string + name string + data []byte + options *azblob.UploadBufferOptions + err error +} + +func (f *fakeBlobUploader) UploadBuffer( + _ context.Context, + containerName, blobName string, + buffer []byte, + options *azblob.UploadBufferOptions, +) (azblob.UploadBufferResponse, error) { + f.container = containerName + f.name = blobName + f.data = append([]byte(nil), buffer...) + f.options = options + return azblob.UploadBufferResponse{}, f.err +} + +func TestMVTAzureTargetWriteTile(t *testing.T) { + fake := &fakeBlobUploader{} + target := &MVTAzureTarget{ + client: fake, + container: "public", + prefix: "lv-bgt/NetherlandsRDNewQuad", + } + data := []byte("tile bytes") + + if err := target.WriteTile(3, 4, 12, data); err != nil { + t.Fatal(err) + } + + assert.Equal(t, "public", fake.container) + assert.Equal(t, data, fake.data) + assert.Equal(t, "lv-bgt/NetherlandsRDNewQuad/12/3/4.pbf", fake.name) + + assert.NotNil(t, fake.options) + assert.NotNil(t, fake.options.HTTPHeaders) + assert.NotNil(t, fake.options.HTTPHeaders.BlobContentType) + assert.NotNil(t, fake.options.HTTPHeaders.BlobContentEncoding) + + assert.Equal(t, mvtBlobContentType, *fake.options.HTTPHeaders.BlobContentType) + assert.Equal(t, mvtBlobContentEncoding, *fake.options.HTTPHeaders.BlobContentEncoding) +} + +func TestMVTAzureTargetWriteTileReturnsUploadError(t *testing.T) { + wantErr := errors.New("upload failed") + target := &MVTAzureTarget{ + client: &fakeBlobUploader{err: wantErr}, + container: "public", + prefix: "lv-bgt/NetherlandsRDNewQuad", + } + + err := target.WriteTile(3, 4, 12, nil) + assert.ErrorIs(t, err, wantErr) +} + +func TestMVTFileTargetWritesTileUnderConfiguredBase(t *testing.T) { + tilesetName := "NetherlandsRDNewQuad" + data := []byte("tile bytes") + base := t.TempDir() + conf := config.TomlConfig{ + Tileset: &config.Tileset{Name: tilesetName}, + Cache: config.CacheConfig{ + File: &config.FileConfig{Base: base}, + }, + } + + target, err := NewMVTFileTarget(conf) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(base, tilesetName) + assert.Equal(t, want, target.OutDir) + + err = target.WriteTile(3, 4, 12, data) + if err != nil { + t.Fatal(err) + } + + tilePath := filepath.Join(base, tilesetName, "12", "3", "4.pbf") + got, err := os.ReadFile(tilePath) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, data, got) +} diff --git a/snap/snap.go b/snap/snap.go index f3bf968..c5b587a 100644 --- a/snap/snap.go +++ b/snap/snap.go @@ -1,7 +1,6 @@ package snap import ( - "errors" "fmt" "log" "math" @@ -18,6 +17,7 @@ import ( "github.com/go-spatial/geom" "github.com/pdok/texel/intgeom" + "github.com/pdok/texel/processing" "github.com/pdok/texel/tms20" orderedmap "github.com/wk8/go-ordered-map/v2" ) @@ -29,64 +29,36 @@ const ( type IsOuter = bool -type Config struct { - KeepPointsAndLines bool - IgnoreOutsideGrid bool - ReverseWindingOrder bool -} - -// SnapPolygon snaps polygons' points to a tile's internal pixel grid -// and adds points to lines to prevent intersections. -// //nolint:revive -func SnapPolygon(polygon geom.Polygon, tileMatrixSet tms20.TileMatrixSet, tmIDs []tms20.TMID, config Config) map[tms20.TMID][]geom.Polygon { - deepestID := slices.Max(tmIDs) - ix, err := pointindex.FromTileMatrixSet(tileMatrixSet, deepestID) - if err != nil { - panic(err) // TODO let processing.processPolygonFunc return err - } - tmIDsByLevels := tileMatrixIDsByLevels(tileMatrixSet, tmIDs) - levels := make([]pointindex.Level, 0, len(tmIDsByLevels)) - for level := range tmIDsByLevels { - levels = append(levels, level) - } - - err = ix.InsertPolygon(polygon) - if err != nil { - outsideGridErr := new(pointindex.OutsideGridError) - if errors.As(err, outsideGridErr) && config.IgnoreOutsideGrid { - log.Println("[WARNING] skipping polygon because: " + err.Error()) - return make(map[tms20.TMID][]geom.Polygon) - } else { - panic(err) - } - } - - newPolygonsPerLevel := addPointsAndSnap(ix, polygon, levels, config) - - newPolygonsPerTileMatrixID := make(map[tms20.TMID][]geom.Polygon, len(newPolygonsPerLevel)) - for level, newPolygons := range newPolygonsPerLevel { - newPolygonsPerTileMatrixID[tmIDsByLevels[level]] = newPolygons - } - - return newPolygonsPerTileMatrixID -} - -func tileMatrixIDsByLevels(tms tms20.TileMatrixSet, tmIDs []tms20.TMID) map[pointindex.Level]tms20.TMID { - rootTM := tms.TileMatrices[0] - levelDiff := uint(math.Log2(float64(rootTM.TileWidth))) + uint(math.Log2(float64(pointindex.VectorTileInternalPixelResolution))) - tmIDsByLevels := make(map[pointindex.Level]tms20.TMID, len(tmIDs)) +func SnapGeometry(ix processing.PIndex, geometry geom.Geometry, tmIDs []tms20.TMID, config processing.Config) map[tms20.TMID][]geom.Geometry { + levels := make([]pointindex.Level, 0, len(tmIDs)) for _, tmID := range tmIDs { - // assuming 2^(tmID) = tm.MatrixWidth = tm.MatrixHeight - //nolint:gosec // G115 - level := uint(tmID) + levelDiff - tmIDsByLevels[level] = tmID + levels = append(levels, ix.InternalPixelLevelFromTmsID(tmID)) + } + switch geometry := geometry.(type) { + case geom.Polygon: + snappedPolygons := addPointsAndSnap(ix, geometry, levels, config) + result := make(map[tms20.TMID][]geom.Geometry, len(snappedPolygons)) + for i, TMID := range tmIDs { + polygons := snappedPolygons[levels[i]] + geoms := make([]geom.Geometry, 0, len(polygons)) + for _, polygon := range polygons { + geoms = append(geoms, polygon) + } + result[TMID] = geoms + } + return result + default: + result := make(map[tms20.TMID][]geom.Geometry, len(tmIDs)) + for _, tmID := range tmIDs { + result[tmID] = []geom.Geometry{geometry} + } + return result } - return tmIDsByLevels } //nolint:cyclop -func addPointsAndSnap(ix *pointindex.PointIndex, polygon geom.Polygon, levels []pointindex.Level, config Config) map[pointindex.Level][]geom.Polygon { +func addPointsAndSnap(ix processing.PIndex, polygon geom.Polygon, levels []pointindex.Level, config processing.Config) map[pointindex.Level][]geom.Polygon { levelMap := mapslicehelp.AsKeys(levels) newOuters := make(map[pointindex.Level][][][2]float64, len(levels)) newInners := make(map[pointindex.Level][][][2]float64, len(levels)) @@ -154,7 +126,7 @@ func addPointsAndSnap(ix *pointindex.PointIndex, polygon geom.Polygon, levels [] return geomhelp.FloatPolygonsToGeomPolygonsForAllKeys(newPolygons) } -func reverseWindingOrderIfConfigured(polygons [][][][2]float64, config Config) { +func reverseWindingOrderIfConfigured(polygons [][][][2]float64, config processing.Config) { if !config.ReverseWindingOrder { return } diff --git a/snap/snap_test.go b/snap/snap_test.go index 6f2fb8a..59b8fbe 100644 --- a/snap/snap_test.go +++ b/snap/snap_test.go @@ -1,12 +1,16 @@ package snap import ( + "errors" + "log" + "slices" "strconv" "testing" "github.com/pdok/texel/geomhelp" "github.com/pdok/texel/mathhelp" "github.com/pdok/texel/pointindex" + "github.com/pdok/texel/processing" "github.com/stretchr/testify/require" "github.com/go-spatial/geom/encoding/wkt" @@ -22,16 +26,16 @@ func TestSnap_snapPolygon(t *testing.T) { name string tms tms20.TileMatrixSet tmIDs []tms20.TMID - config Config + config processing.Config polygon geom.Polygon - want map[tms20.TMID][]geom.Polygon + want map[tms20.TMID][]geom.Geometry wantPanic bool }{ { name: "missing corner", tms: loadEmbeddedTileMatrixSet(t, "NetherlandsRDNewQuad"), tmIDs: []tms20.TMID{14}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {117220.282, 440135.898}, {117210.713, 440135.101}, @@ -40,7 +44,7 @@ func TestSnap_snapPolygon(t *testing.T) { {117221.990, 440133.510}, {117220.500, 440133.380}, }}, - want: map[tms20.TMID][]geom.Polygon{14: {{{ + want: map[tms20.TMID][]geom.Geometry{14: {geom.Polygon{{ {117220.2846875, 440135.9021875}, {117210.7165625, 440135.1015625}, {117211.1234375, 440130.1009375}, @@ -53,7 +57,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "horizontal line on edge", tms: loadEmbeddedTileMatrixSet(t, "NetherlandsRDNewQuad"), tmIDs: []tms20.TMID{14}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {110899.19100000000617001, 504431.15200000000186265}, {110906.87099999999918509, 504428.79999999998835847}, // horizontal line between quadrants @@ -63,7 +67,7 @@ func TestSnap_snapPolygon(t *testing.T) { {110929.44700000000011642, 504407.8219999999855645}, {110892.93099999999685679, 504407.8219999999855645}, }}, - want: map[tms20.TMID][]geom.Polygon{14: {{{ + want: map[tms20.TMID][]geom.Geometry{14: {geom.Polygon{{ {110892.9321875, 504407.8196875}, {110929.4459375, 504407.8196875}, {110920.0353125, 504436.0778125}, @@ -77,7 +81,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "needs deduplication", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {0.0, 0.0}, {15.0, 0.0}, @@ -93,7 +97,7 @@ func TestSnap_snapPolygon(t *testing.T) { {2.0, 2.4}, {0.0, 2.4}, }}, - want: map[tms20.TMID][]geom.Polygon{1: {{{ + want: map[tms20.TMID][]geom.Geometry{1: {geom.Polygon{{ {0.25, 0.25}, {15.25, 0.25}, {15.25, 2.25}, @@ -105,7 +109,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "needs deduplication and reversal", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {0.0, 2.4}, {2.0, 2.4}, @@ -121,7 +125,7 @@ func TestSnap_snapPolygon(t *testing.T) { {15.0, 0.0}, {0.0, 0.0}, }}, - want: map[tms20.TMID][]geom.Polygon{1: {{{ + want: map[tms20.TMID][]geom.Geometry{1: {geom.Polygon{{ {0.25, 0.25}, {15.25, 0.25}, {15.25, 2.25}, @@ -133,7 +137,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "needs deduplication with one zigzag", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {0.0, 0.0}, {15.0, 0.0}, @@ -154,7 +158,7 @@ func TestSnap_snapPolygon(t *testing.T) { {2.0, 2.4}, {0.0, 2.4}, }}, - want: map[tms20.TMID][]geom.Polygon{1: {{{ + want: map[tms20.TMID][]geom.Geometry{1: {geom.Polygon{{ {0.25, 0.25}, {15.25, 0.25}, {15.25, 2.25}, @@ -167,7 +171,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "needs deduplication with more than one zigzag", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {0.0, 0.0}, {15.0, 0.0}, @@ -192,7 +196,7 @@ func TestSnap_snapPolygon(t *testing.T) { {2.0, 2.4}, {0.0, 2.4}, }}, - want: map[tms20.TMID][]geom.Polygon{1: {{{ + want: map[tms20.TMID][]geom.Geometry{1: {geom.Polygon{{ {0.25, 0.25}, {2.25, 0.25}, {15.25, 0.25}, @@ -206,7 +210,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "rightmostLowestPoint is one of the deduped points", tms: loadEmbeddedTileMatrixSet(t, "NetherlandsRDNewQuad"), tmIDs: []tms20.TMID{5}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {69840.279, 445755.872}, {69842.666, 445755.289}, @@ -218,14 +222,12 @@ func TestSnap_snapPolygon(t *testing.T) { {69879.413, 445710.912}, {69837.833, 445705.673}, }}, - want: map[tms20.TMID][]geom.Polygon{5: { - { + want: map[tms20.TMID][]geom.Geometry{5: { + geom.Polygon{ {{69840.8, 445706.08}, {69881.12, 445712.8}, {69840.8, 445712.8}}, - }, - { + }, geom.Polygon{ {{69840.8, 445706.08}, {69840.8, 445712.8}}, - }, - { + }, geom.Polygon{ {{69840.8, 445712.8}, {69840.8, 445753.12}}, }, }}, @@ -234,15 +236,15 @@ func TestSnap_snapPolygon(t *testing.T) { name: "lines and points are not filtered out", tms: loadEmbeddedTileMatrixSet(t, "NetherlandsRDNewQuad"), tmIDs: []tms20.TMID{0}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {90713.55, 530388.466}, {90741.04, 530328.675}, {90673.689, 530324.552}, {90664.068, 530379.532}, }}, - want: map[tms20.TMID][]geom.Polygon{0: { - { + want: map[tms20.TMID][]geom.Geometry{0: { + geom.Polygon{ {{90595.52, 530415.04}, {90810.56, 530415.04}}, }, }}, @@ -257,21 +259,21 @@ func TestSnap_snapPolygon(t *testing.T) { {90673.689, 530324.552}, {90664.068, 530379.532}, }}, - want: map[tms20.TMID][]geom.Polygon{}, + want: map[tms20.TMID][]geom.Geometry{}, }, { name: "ring length < 3 _after_ deduping, also not filtered out", tms: loadEmbeddedTileMatrixSet(t, "NetherlandsRDNewQuad"), tmIDs: []tms20.TMID{0}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {211124.566, 574932.941}, {211142.954, 574988.796}, {211059.858, 574971.321}, {211163.163, 574994.581}, }}, - want: map[tms20.TMID][]geom.Polygon{0: { - { + want: map[tms20.TMID][]geom.Geometry{0: { + geom.Polygon{ {{211232.96, 574928.32}, {211017.92, 574928.32}}, }, }}, @@ -280,7 +282,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "outer ring only, needs splitting", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {0.0, 3.0}, {3.0, 0.0}, @@ -293,14 +295,12 @@ func TestSnap_snapPolygon(t *testing.T) { {6.0, 3.0}, {3.0, 6.0}, }}, - want: map[tms20.TMID][]geom.Polygon{1: { // 3 separate polygons: - { // left wing + want: map[tms20.TMID][]geom.Geometry{1: { + geom.Polygon{ // 3 separate polygons: left wing {{0.25, 3.25}, {3.25, 0.25}, {6.25, 3.25}, {3.25, 6.25}}, - }, - { // right wing + }, geom.Polygon{ // right wing {{9.25, 3.25}, {12.25, 0.25}, {15.25, 3.25}, {12.25, 6.25}}, - }, - { // line in between (last) + }, geom.Polygon{ // line in between (last) {{6.25, 3.25}, {9.25, 3.25}}, }, }}, @@ -309,7 +309,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "outer ring with one inner ring, outer needs splitting", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{ { {0.0, 3.0}, @@ -330,15 +330,13 @@ func TestSnap_snapPolygon(t *testing.T) { {3.0, 2.0}, }, }, - want: map[tms20.TMID][]geom.Polygon{1: { // 3 separate polygons: - { // left wing, including inner ring + want: map[tms20.TMID][]geom.Geometry{1: { + geom.Polygon{ // 3 separate polygons: left wing, including inner ring {{0.25, 3.25}, {3.25, 0.25}, {6.25, 3.25}, {3.25, 6.25}}, {{2.25, 3.25}, {3.25, 4.25}, {4.25, 3.25}, {3.25, 2.25}}, - }, - { // right wing + }, geom.Polygon{ // right wing {{9.25, 3.25}, {12.25, 0.25}, {15.25, 3.25}, {12.25, 6.25}}, - }, - { // line in between (last) + }, geom.Polygon{ // line in between (last) {{6.25, 3.25}, {9.25, 3.25}}, }, }}, @@ -347,7 +345,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "outer ring with two inner ring, outer needs splitting, inner rings must be matched to new outer rings", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{ { {0.0, 3.0}, @@ -374,16 +372,14 @@ func TestSnap_snapPolygon(t *testing.T) { {12.0, 2.0}, }, }, - want: map[tms20.TMID][]geom.Polygon{1: { // 3 separate polygons: - { // left wing, including inner ring + want: map[tms20.TMID][]geom.Geometry{1: { + geom.Polygon{ // 3 separate polygons: left wing, including inner ring {{0.25, 3.25}, {3.25, 0.25}, {6.25, 3.25}, {3.25, 6.25}}, {{2.25, 3.25}, {3.25, 4.25}, {4.25, 3.25}, {3.25, 2.25}}, - }, - { // right wing + }, geom.Polygon{ // right wing {{9.25, 3.25}, {12.25, 0.25}, {15.25, 3.25}, {12.25, 6.25}}, {{11.25, 3.25}, {12.25, 4.25}, {13.25, 3.25}, {12.25, 2.25}}, - }, - { // line in between (last) + }, geom.Polygon{ // line in between (last) {{6.25, 3.25}, {9.25, 3.25}}, }, }}, @@ -392,7 +388,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "outer ring only, needs splitting, expect two lines", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {0.0, 3.0}, {3.0, 0.0}, @@ -406,17 +402,15 @@ func TestSnap_snapPolygon(t *testing.T) { {6.0, 3.0}, {3.0, 6.0}, }}, - want: map[tms20.TMID][]geom.Polygon{1: { // 4 separate polygons: - { // left wing + want: map[tms20.TMID][]geom.Geometry{1: { + geom. // 4 separate polygons: + Polygon{ // left wing {{0.25, 3.25}, {3.25, 0.25}, {6.25, 3.25}, {3.25, 6.25}}, - }, - { // right wing + }, geom.Polygon{ // right wing {{12.25, 0.25}, {15.25, 3.25}, {12.25, 6.25}}, - }, - { // line 1 + }, geom.Polygon{ // line 1 {{6.25, 3.25}, {9.25, 3.25}}, - }, - { // line 2 + }, geom.Polygon{ // line 2 {{9.25, 3.25}, {12.25, 0.25}}, }, }}, @@ -425,7 +419,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "outer ring with one inner ring, inner needs splitting", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{ { {0.0, 3.0}, @@ -448,13 +442,13 @@ func TestSnap_snapPolygon(t *testing.T) { {3.0, 2.0}, }, }, - want: map[tms20.TMID][]geom.Polygon{1: { // 2 separate polygons: - { // outer ring, including two inner rings + want: map[tms20.TMID][]geom.Geometry{1: { + geom. // 2 separate polygons: + Polygon{ // outer ring, including two inner rings {{0.25, 3.25}, {3.25, 0.25}, {12.25, 0.25}, {15.25, 3.25}, {12.25, 6.25}, {3.25, 6.25}}, {{2.25, 3.25}, {3.25, 4.25}, {4.25, 3.25}, {3.25, 2.25}}, {{11.25, 3.25}, {12.25, 4.25}, {13.25, 3.25}, {12.25, 2.25}}, - }, - { // line in between inner rings (last) + }, geom.Polygon{ // line in between inner rings (last) {{4.25, 3.25}, {11.25, 3.25}}, }, }}, @@ -463,7 +457,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "outer ring only, with external line", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{ { {0.0, 0.0}, @@ -474,11 +468,11 @@ func TestSnap_snapPolygon(t *testing.T) { {0.0, 10.0}, }, }, - want: map[tms20.TMID][]geom.Polygon{1: { // 2 separate polygons: - { // outer ring + want: map[tms20.TMID][]geom.Geometry{1: { + geom. // 2 separate polygons: + Polygon{ // outer ring {{0.25, 0.25}, {2.25, 0.25}, {2.25, 10.25}, {0.25, 10.25}}, - }, - { // line + }, geom.Polygon{ // line {{2.25, 10.25}, {2.25, 12.25}}, }, }}, @@ -487,7 +481,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "outer ring with 'false' inner rings", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{ { {0.0, 0.0}, @@ -505,8 +499,9 @@ func TestSnap_snapPolygon(t *testing.T) { {0.0, 6.0}, }, }, - want: map[tms20.TMID][]geom.Polygon{1: { // 1 polygon with 2 inner rings: - { + want: map[tms20.TMID][]geom.Geometry{1: { + geom. // 1 polygon with 2 inner rings: + Polygon{ {{0.25, 0.25}, {12.25, 0.25}, {12.25, 6.25}, {12.25, 12.25}, {6.25, 6.25}, {6.25, 12.25}, {0.25, 6.25}}, {{12.25, 6.25}, {9.25, 3.25}, {6.25, 6.25}}, {{6.25, 6.25}, {3.25, 3.25}, {0.25, 6.25}}, @@ -517,7 +512,7 @@ func TestSnap_snapPolygon(t *testing.T) { name: "snapping creates a new inner ring", tms: newSimpleTileMatrixSet(1, 8), tmIDs: []tms20.TMID{1}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{ { {0.0, 0.0}, @@ -538,13 +533,13 @@ func TestSnap_snapPolygon(t *testing.T) { {7.0, 2.5}, }, }, - want: map[tms20.TMID][]geom.Polygon{1: { // 2 separate polygons: - { // original outer ring with 2 inner rings + want: map[tms20.TMID][]geom.Geometry{1: { + geom. // 2 separate polygons: + Polygon{ // original outer ring with 2 inner rings {{0.25, 0.25}, {15.25, 0.25}, {15.25, 2.25}, {12.25, 2.25}, {12.25, 6.25}, {0.25, 6.25}}, {{5.25, 2.25}, {2.25, 2.25}, {2.25, 4.25}, {5.25, 4.25}}, // inner ring created by snapping {{7.25, 4.25}, {10.25, 4.25}, {10.25, 2.75}, {7.25, 2.75}}, // original inner ring - }, - { // self-tangent line split off as outer ring + }, geom.Polygon{ // self-tangent line split off as outer ring {{12.25, 2.25}, {5.25, 2.25}}, }, }}, @@ -553,35 +548,29 @@ func TestSnap_snapPolygon(t *testing.T) { name: "splitting of outer and inner ring produces (mirrored) duplicate lines", tms: loadEmbeddedTileMatrixSet(t, "NetherlandsRDNewQuad"), tmIDs: []tms20.TMID{5}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{ {{27435.253, 392410.493}, {27339.366, 392266.876}, {27156.23, 392261.72}, {27150.921, 392265.803}, {27153.05, 392268.68}, {27337.2, 392270.744}, {27431.77, 392409.367}, {27435.253, 392410.493}}, {{27325.12, 392269.53}, {27157.488, 392265.29}, {27153.165, 392267.869}, {27151.622, 392266.309}, {27156.228, 392262.52}, {27339.052, 392267.615}, {27434.775, 392409.844}, {27338.787, 392271.126}, {27337.382, 392269.953}, {27325.12, 392269.53}}, }, - want: map[tms20.TMID][]geom.Polygon{5: { // 8 separate polygons - TODO: remove (mirrored) duplicates in lines: - { // outer ring with inner ring + want: map[tms20.TMID][]geom.Geometry{5: { + geom. // 8 separate polygons - TODO: remove (mirrored) duplicates in lines: + Polygon{ // outer ring with inner ring {{27323.36, 392268.64}, {27155.36, 392268.64}, {27148.64, 392268.64}, {27155.36, 392261.92}}, {{27323.36, 392268.64}, {27155.36, 392261.92}, {27155.36, 392268.64}}, - }, - { // line 1 == line 6 mirrored + }, geom.Polygon{ // line 1 == line 6 mirrored {{27437.6, 392409.76}, {27430.88, 392409.76}}, - }, - { // line 2 == line 5 mirrored + }, geom.Polygon{ // line 2 == line 5 mirrored {{27430.88, 392409.76}, {27336.8, 392268.64}}, - }, - { // line 3 == line 4 mirrored + }, geom.Polygon{ // line 3 == line 4 mirrored {{27336.8, 392268.64}, {27323.36, 392268.64}}, - }, - { // line 4 == line 3 mirrored + }, geom.Polygon{ // line 4 == line 3 mirrored {{27323.36, 392268.64}, {27336.8, 392268.64}}, - }, - { // line 5 == line 2 mirrored + }, geom.Polygon{ // line 5 == line 2 mirrored {{27336.8, 392268.64}, {27430.88, 392409.76}}, - }, - { // line 6 == line 1 mirrored + }, geom.Polygon{ // line 6 == line 1 mirrored {{27430.88, 392409.76}, {27437.6, 392409.76}}, - }, - { // line 7 + }, geom.Polygon{ // line 7 {{27155.36, 392268.64}, {27148.64, 392268.64}}, }, }}, @@ -590,14 +579,14 @@ func TestSnap_snapPolygon(t *testing.T) { name: "inner but no outer error because of not reversing because of horizontal rightmostlowest", tms: loadEmbeddedTileMatrixSet(t, "NetherlandsRDNewQuad"), tmIDs: []tms20.TMID{5}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {139372.972, 527781.838}, {139525.129, 527782.562}, {139525.711, 527782.368}, {139526.378, 527781.182}, {139526.322, 527780.127}, {139525.935, 527779.501}, {139524.587, 527779.117}, {139519.249, 527779.07}, {139518.262, 527778.621}, {139516.868, 527776.991}, {139517.249, 527776.042}, {139521.038, 527771.641}, {139522.566, 527768.414}, {139518.146, 527765.041}, {139517.85, 527763.597}, {139518.704, 527762.318}, {139524.008, 527757.186}, {139525.037, 527756.403}, {139526.108, 527756.329}, {139527.952, 527757.006}, {139531.819, 527761.335}, {139535.209, 527761.176}, {139536.704, 527762.265}, {139535.71, 527772.004}, {139535.828, 527773.351}, {139537.605, 527775.971}, {139537.763, 527777.456}, {139536.555, 527779.007}, {139534.715, 527779.837}, {139532.606, 527779.984}, {139529.678, 527779.502}, {139528.964, 527779.875}, {139528.698, 527781.132}, {139529.076, 527782.085}, {139544.208, 527783.211}, {139589.555, 527786.608}, {139594.598, 527787.584}, {139609.462, 527788.495}, {139611.557, 527788.435}, {139649.733, 527791.455}, {139650.255, 527791.973}, {139654.415, 527792.496}, {139655.978, 527793.08}, {139670.378, 527794.059}, {139712.26, 527794.864}, {139729.764, 527794.755}, {139757.57, 527795.124}, {139787.205, 527793.835}, {139816.936, 527791.996}, {139824.33, 527791.319298176}, {139824.33, 527756.249181541}, {139806.072, 527754.485}, {139709.471, 527743.599}, {139682.187, 527741.701}, {139628.039, 527739.25}, // something with an inlet that becomes an inner ring {139566.36, 527736.9}, {139433.603, 527736.172}, {139364.846, 527736.093}, // horizontal with rightmostlowest {139348.24, 527736.425675193}, {139348.24, 527754.592389722}, // (vertical) }}, - want: map[tms20.TMID][]geom.Polygon{5: { - {{{139345.76, 527757.28}, {139345.76, 527737.12}, {139365.92, 527737.12}, {139433.12, 527737.12}, {139567.52, 527737.12}, {139628, 527737.12}, {139681.76, 527743.84}, {139708.64, 527743.84}, {139802.72, 527757.28}, {139822.88, 527757.28}, {139822.88, 527790.88}, {139816.16, 527790.88}, {139789.28, 527790.88}, {139755.68, 527797.6}, {139728.8, 527797.6}, {139715.36, 527797.6}, {139668.32, 527790.88}, {139654.88, 527790.88}, {139648.16, 527790.88}, {139614.56, 527790.88}, {139607.84, 527790.88}, {139594.4, 527790.88}, {139587.68, 527784.16}, {139547.36, 527784.16}, {139527.2, 527784.16}, {139372.64, 527784.16}}, {{139527.2, 527777.44}, {139533.92, 527777.44}, {139533.92, 527770.72}, {139533.92, 527764}, {139527.2, 527757.28}, {139520.48, 527764}, {139520.48, 527770.72}, {139520.48, 527777.44}}}, + want: map[tms20.TMID][]geom.Geometry{5: { + geom.Polygon{{{139345.76, 527757.28}, {139345.76, 527737.12}, {139365.92, 527737.12}, {139433.12, 527737.12}, {139567.52, 527737.12}, {139628, 527737.12}, {139681.76, 527743.84}, {139708.64, 527743.84}, {139802.72, 527757.28}, {139822.88, 527757.28}, {139822.88, 527790.88}, {139816.16, 527790.88}, {139789.28, 527790.88}, {139755.68, 527797.6}, {139728.8, 527797.6}, {139715.36, 527797.6}, {139668.32, 527790.88}, {139654.88, 527790.88}, {139648.16, 527790.88}, {139614.56, 527790.88}, {139607.84, 527790.88}, {139594.4, 527790.88}, {139587.68, 527784.16}, {139547.36, 527784.16}, {139527.2, 527784.16}, {139372.64, 527784.16}}, {{139527.2, 527777.44}, {139533.92, 527777.44}, {139533.92, 527770.72}, {139533.92, 527764}, {139527.2, 527757.28}, {139520.48, 527764}, {139520.48, 527770.72}, {139520.48, 527777.44}}}, geom.Polygon{{{139527.2, 527784.16}, {139527.2, 527777.44}}}, geom.Polygon{{{139533.92, 527777.44}, {139540.64, 527777.44}}}, geom.Polygon{{{139520.48, 527777.44}, {139513.76, 527777.44}}}, @@ -607,47 +596,50 @@ func TestSnap_snapPolygon(t *testing.T) { name: "inner but no outer error because of not reversing because of a very sharp leg/extension", tms: loadEmbeddedTileMatrixSet(t, "NetherlandsRDNewQuad"), tmIDs: []tms20.TMID{0}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{ {48158.204, 392310.062}, - {47753.125, 391885.44}, {48565.4, 391515.876}, {47751.195, 391884.821}, // a very sharp leg/extension + {47753.125, 391885.44}, + {48565.4, 391515.876}, + {47751.195, 391884.821}, // a very sharp leg/extension {47677.592, 392079.403}, }}, - want: map[tms20.TMID][]geom.Polygon{0: { - {{{47587.52, 392144.32}, {47802.56, 391929.28}, {48232.64, 392359.36}}}, // turned counterclockwise - {{{47802.56, 391929.28}, {48662.72, 391499.2}}}, + want: map[tms20.TMID][]geom.Geometry{0: { + geom.Polygon{{{47587.52, 392144.32}, {47802.56, 391929.28}, {48232.64, 392359.36}}}, geom. // turned counterclockwise + Polygon{{{47802.56, 391929.28}, {48662.72, 391499.2}}}, }}, // want no panicInnerRingsButNoOuterRings }, { name: "split ring from outer is cw, should be ccw", tms: loadEmbeddedTileMatrixSet(t, "NetherlandsRDNewQuad"), tmIDs: []tms20.TMID{0}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{{179334.089, 408229.072}, {179121.631, 408528.181}, {179328.228, 408231.924}, {178889.903, 408431.167}, {178531.386, 408106.618}, {178497.492, 407886.329}, {178535.353, 408103.574}, {178862.244, 408226.852}, {178891.816, 408426.547}, {179173.349, 408187.199}, {178893.957, 408423.424}, {178864.491, 408223.293}, {178537.744, 408101.003}, {178504.209, 407887.598}, {178510.008, 407890.491}, {178542.44, 408098.473}, {178867.788, 408219.534}, {178897.835, 408417.763}, {179170.131, 408181.285}}}, // ccw (with a sharp leg/extension also) - want: map[tms20.TMID][]geom.Polygon{0: { - {{{178976.96, 408487.36}, {178761.92, 408272.32}, {178546.88, 408057.28}, {179192, 408272.32}}}, // ccw - // bunch of points and lines: - {{{179407.04, 408272.32}, {179192, 408272.32}}}, {{{179192, 408272.32}, {179192, 408487.36}}}, {{{179407.04, 408272.32}, {179192, 408272.32}}}, {{{179192, 408272.32}, {178976.96, 408487.36}}}, {{{178976.96, 408487.36}, {178761.92, 408272.32}}}, {{{178761.92, 408272.32}, {178546.88, 408057.28}}}, {{{178546.88, 408057.28}, {178546.88, 407842.24}}}, + want: map[tms20.TMID][]geom.Geometry{0: { + geom.Polygon{{{178976.96, 408487.36}, {178761.92, 408272.32}, {178546.88, 408057.28}, {179192, 408272.32}}}, geom. // ccw + Polygon{ // bunch of points and lines: + {{179407.04, 408272.32}, {179192, 408272.32}}}, geom.Polygon{{{179192, 408272.32}, {179192, 408487.36}}}, geom.Polygon{{{179407.04, 408272.32}, {179192, 408272.32}}}, geom.Polygon{{{179192, 408272.32}, {178976.96, 408487.36}}}, geom.Polygon{{{178976.96, 408487.36}, {178761.92, 408272.32}}}, geom.Polygon{{{178761.92, 408272.32}, {178546.88, 408057.28}}}, geom.Polygon{{{178546.88, 408057.28}, {178546.88, 407842.24}}}, }}, }, { name: "one of three split outer rings is cw and turned outer after no matching outer", tms: loadEmbeddedTileMatrixSet(t, "NetherlandsRDNewQuad"), tmIDs: []tms20.TMID{0}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{{{88580.011, 439678.996}, {88337.73, 439237.216}, {89273.964, 438026.4}, {89386.079, 438023.335}, {90251.524, 438784.15}, {89852.567, 439284.421}, {89425.263, 439355.284}, {89247.228, 439563.507}, {89089.95, 439692.364}, {88959.832, 439729.531}, {89055.886, 439819.684}, {89466.904, 439382.346}, {89899.488, 439311.969}, {90170.183, 438911.775}, {90329.354, 438821.391}, {90651.094, 438796.963}, {91473.854, 439243.296}, {90632.307, 438747.518}, {90270.708, 438757.632}, {89555.357, 437677.283}, {90499.163, 436096.427}, {91435.651, 435963.019}, {91404.334, 436039.088}, {91254.337, 436091.084}, {90500.745, 436098.362}, {90076.214, 437042.706}, {89870.055, 437307.816}, {89768.94, 437363.42}, {89650.683, 437521.434}, {89640.994, 437568.838}, {89558.222, 437677.647}, {90269.467, 438753.387}, {90632.85, 438744.94}, {91313.174, 439143.369}, {91477.748, 439241.657}, {91475.353, 439245.66}, {91457.592, 439266.852}, {91243.008, 439179.921}, {90710.843, 438897.924}, {90650.175, 438799.288}, {90440.729, 438846.985}, {90395.019, 438846.967}, {90329.938, 438823.822}, {90287.474, 438885.328}, {90172.086, 438913.396}, {90044.257, 439125.421}, {89901.052, 439313.924}, {89885.113, 439321.991}, {89835.824, 439335.083}, {89468.228, 439384.467}, {89173.832, 439758.873}, {89061.413, 439821.909}, {89054.68, 439821.883}, {89023.222, 439801.24}, {88989.659, 439763.597}, {88949.781, 439739.428}, {88958.959, 439726.203}, {89088.39, 439690.41}, {89245.45, 439561.75}, {89388.248, 439376.01}, {89424.081, 439353.075}, {89566.906, 439317.631}, {89851.03, 439282.45}, {90111.766, 438914.525}, {90249.027, 438784.029}, {90211.25, 438760.51}, {90183.492, 438736.293}, {89584.683, 438207.656}, {89384.579, 438025.335}, {89274.819, 438028.749}, {88339.974, 439238.317}, {88419.861, 439377.057}, {88447.454, 439387.602}, {88485.231, 439376.209}, {88505.9, 439379.802}, {88564.366, 439441.722}, {88589.428, 439478.721}, {88598.844, 439504.106}, {88608.517, 439561.563}, {88582.418, 439679.669}, {88565.692, 439724.97}, {88480.367, 439857.335}, {88409.981, 439938.527}, {88412.431, 439940.265}, {88366.171, 440033.682}, {88353.723, 440046.457}, {88356.08, 440054.25}, {88342.856, 440086.861}, {88266.552, 440224.799}, {88252.681, 440243.646}, {88196.44, 440306.135}, {87992.789, 440467.453}, {88250.595, 440274.14}, {88508.083, 439845.775}, {88270.249, 440256.888}, {88194.893, 440335.659}, {88010.485, 440474.349}, {87996.213, 440475.679}, {87990.894, 440469.07}, {88580.011, 439678.996}}}, - want: map[tms20.TMID][]geom.Polygon{}, // want no panicNoMatchingOuterForInnerRing + want: map[tms20.TMID][]geom.Geometry{}, // want no panicNoMatchingOuterForInnerRing }, { name: "sneaky nested pseudo ring creates more than 1 matching outer ring", tms: loadEmbeddedTileMatrixSet(t, "NetherlandsRDNewQuad"), tmIDs: []tms20.TMID{0}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{ {{198877.1, 506188.635}, {198805.608, 506361.231}, {198633.011, 506432.722}, {198460.415, 506361.23}, {198388.924, 506188.633}, {198460.416, 506016.037}, {198633.013, 505944.546}, {198805.609, 506016.038}}, {{198429.407, 506188.635}, {198489.229, 506332.782}, {198633.531, 506392.228}, {198777.528, 506332.045}, {198836.612, 506187.594}, {198776.434, 506044.111}, {198632.5, 505985.022}, {198488.864, 506044.832}, {198429.407, 506188.615}, {198551.204, 506045.823}, {198690.244, 506034.324}, {198792.36, 506147.487}, {198748.509, 506305.863}, {198576.128, 506343.056}}, - {{198633.012, 506279.536}, {198766.685, 506188.158}, {198632.396, 506055.195}, {198499.739, 506188.974}}}, - want: map[tms20.TMID][]geom.Polygon{}, // want no panicMoreThanOneMatchingOuterRing + {{198633.012, 506279.536}, {198766.685, 506188.158}, {198632.396, 506055.195}, {198499.739, 506188.974}}, + }, + want: map[tms20.TMID][]geom.Geometry{}, // want no panicMoreThanOneMatchingOuterRing }, { name: "nested rings", @@ -655,7 +647,7 @@ func TestSnap_snapPolygon(t *testing.T) { tmIDs: []tms20.TMID{ 1, // 32 * 8.0 }, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{ {{4.0, 124.0}, {4.0, 4.0}, {60.0, 4.0}, {60.0, 124.0}}, // big outer {{12.0, 52.0}, {12.0, 12.0}, {52.0, 12.0}, {52.0, 52.0}, {30.0, 52.0}, {30.0, 44.0}, {44.0, 44.0}, {44.0, 20.0}, {20.0, 20.0}, {20.0, 44.0}, {27.0, 44.0}, {27.0, 52.0}}, // big letter C that turns into nested rings when snapped @@ -663,29 +655,22 @@ func TestSnap_snapPolygon(t *testing.T) { {{30.0, 53.0}, {30.0, 54.0}, {54.0, 54.0}, {54.0, 10.0}, {10.0, 10.0}, {10.0, 54.0}, {27.0, 54.0}, {27.0, 53.0}, {11.0, 53.0}, {11.0, 11.0}, {53.0, 11.0}, {53.0, 53.0}}, // another C around the original C which snaps to a duplicate outer {{28.0, 28.0}, {36.0, 28.0}, {36.0, 36.0}, {29.0, 36.0}, {29.0, 92.0}, {36.0, 92.0}, {36.0, 100.0}, {28.0, 100.0}}, // dumbbell inside the two C's that also turns into a nested ring when snapped (and some lines) }, - want: map[tms20.TMID][]geom.Polygon{ + want: map[tms20.TMID][]geom.Geometry{ // 1 big outer with 2 holes. and 2 new outers/polygons (inside those holes from the big outer) each with their own hole. // a duplicate inner/outer pair from the extra C is removed 1: { - { + geom.Polygon{ {{4.0, 124.0}, {4.0, 4.0}, {60.0, 4.0}, {60.0, 124.0}}, // ccw {{12.0, 116.0}, {52.0, 116.0}, {52.0, 76.0}, {28.0, 76.0}, {12.0, 76.0}}, // cw {{28.0, 52.0}, {52.0, 52.0}, {52.0, 12.0}, {12.0, 12.0}, {12.0, 52.0}}, // cw - }, { + }, geom.Polygon{ {{28.0, 44.0}, {20.0, 44.0}, {20.0, 20.0}, {44.0, 20.0}, {44.0, 44.0}}, // ccw {{28.0, 36.0}, {36.0, 36.0}, {36.0, 28.0}, {28.0, 28.0}}, // cw - }, { + }, geom.Polygon{ {{28.0, 84.0}, {44.0, 84.0}, {44.0, 108.0}, {20.0, 108.0}, {20.0, 84.0}}, // ccw {{28.0, 100.0}, {36.0, 100.0}, {36.0, 92.0}, {28.0, 92.0}}, // cw - }, - // and some lines - {{{28.0, 52.0}, {28.0, 44.0}}}, - {{{28.0, 76.0}, {28.0, 84.0}}}, - {{{28.0, 92.0}, {28.0, 84.0}}}, - {{{28.0, 84.0}, {28.0, 76.0}}}, - {{{28.0, 76.0}, {28.0, 52.0}}}, - {{{28.0, 52.0}, {28.0, 44.0}}}, - {{{28.0, 44.0}, {28.0, 36.0}}}, + }, geom.Polygon{ // and some lines + {{28.0, 52.0}, {28.0, 44.0}}}, geom.Polygon{{{28.0, 76.0}, {28.0, 84.0}}}, geom.Polygon{{{28.0, 92.0}, {28.0, 84.0}}}, geom.Polygon{{{28.0, 84.0}, {28.0, 76.0}}}, geom.Polygon{{{28.0, 76.0}, {28.0, 52.0}}}, geom.Polygon{{{28.0, 52.0}, {28.0, 44.0}}}, geom.Polygon{{{28.0, 44.0}, {28.0, 36.0}}}, }, }, }, @@ -693,39 +678,39 @@ func TestSnap_snapPolygon(t *testing.T) { name: "no points found panic on TMS other than RD", tms: loadEmbeddedTileMatrixSet(t, "WebMercatorQuad"), tmIDs: []tms20.TMID{17}, - config: Config{KeepPointsAndLines: true}, + config: processing.Config{KeepPointsAndLines: true}, polygon: geom.Polygon{ {{642743.3299, 6898063.027}, {642694.6797, 6898049.319}, {642671.3143, 6898042.735}, {642671.3143, 6898042.735}, {642668.1822, 6898053.868}, {642740.1897, 6898074.148}}, }, - want: map[tms20.TMID][]geom.Polygon{}, // want no panicNoPointsFoundForVertices + want: map[tms20.TMID][]geom.Geometry{}, // want no panicNoPointsFoundForVertices }, { name: "panic outside grid", tms: newSimpleTileMatrixSet(0, 1), tmIDs: []tms20.TMID{0}, polygon: geom.Polygon{{{0.1, 0.1}, {0.2, 0.1}, {0.2, -0.1}}}, - want: map[tms20.TMID][]geom.Polygon{}, // empty, ignored + want: map[tms20.TMID][]geom.Geometry{}, // empty, ignored wantPanic: true, }, { name: "ignore outside grid", tms: newSimpleTileMatrixSet(0, 1), - config: Config{IgnoreOutsideGrid: true}, + config: processing.Config{IgnoreOutsideGrid: true}, tmIDs: []tms20.TMID{0}, polygon: geom.Polygon{{{0.1, 0.1}, {0.2, 0.1}, {0.2, -0.1}}}, - want: map[tms20.TMID][]geom.Polygon{}, // empty, ignored + want: map[tms20.TMID][]geom.Geometry{}, // empty, ignored }, { name: "correct winding order", tms: newSimpleTileMatrixSet(2, 64), - config: Config{ReverseWindingOrder: false}, + config: processing.Config{ReverseWindingOrder: false}, tmIDs: []tms20.TMID{1}, polygon: geom.Polygon{ {{60.0, 124.0}, {60.0, 4.0}, {4.0, 4.0}, {4.0, 124.0}}, // cw {{12.0, 76.0}, {28.0, 76.0}, {52.0, 76.0}, {52.0, 116.0}, {12.0, 116.0}}, // ccw }, - want: map[tms20.TMID][]geom.Polygon{ - 1: {{ + want: map[tms20.TMID][]geom.Geometry{ + 1: {geom.Polygon{ {{4.0, 124.0}, {4.0, 4.0}, {60.0, 4.0}, {60.0, 124.0}}, // ccw {{12.0, 116.0}, {52.0, 116.0}, {52.0, 76.0}, {28.0, 76.0}, {12.0, 76.0}}, // cw }}, @@ -734,14 +719,14 @@ func TestSnap_snapPolygon(t *testing.T) { { name: "reverse winding order", tms: newSimpleTileMatrixSet(2, 64), - config: Config{ReverseWindingOrder: true}, + config: processing.Config{ReverseWindingOrder: true}, tmIDs: []tms20.TMID{1}, polygon: geom.Polygon{ {{4.0, 124.0}, {4.0, 4.0}, {60.0, 4.0}, {60.0, 124.0}}, // ccw {{12.0, 116.0}, {52.0, 116.0}, {52.0, 76.0}, {28.0, 76.0}, {12.0, 76.0}}, // cw }, - want: map[tms20.TMID][]geom.Polygon{ - 1: {{ + want: map[tms20.TMID][]geom.Geometry{ + 1: {geom.Polygon{ {{60.0, 124.0}, {60.0, 4.0}, {4.0, 4.0}, {4.0, 124.0}}, // cw {{12.0, 76.0}, {28.0, 76.0}, {52.0, 76.0}, {52.0, 116.0}, {12.0, 116.0}}, // ccw }}, @@ -750,14 +735,14 @@ func TestSnap_snapPolygon(t *testing.T) { { name: "do not reverse winding order", tms: newSimpleTileMatrixSet(2, 64), - config: Config{ReverseWindingOrder: false}, + config: processing.Config{ReverseWindingOrder: false}, tmIDs: []tms20.TMID{1}, polygon: geom.Polygon{ {{4.0, 124.0}, {4.0, 4.0}, {60.0, 4.0}, {60.0, 124.0}}, // ccw {{12.0, 116.0}, {52.0, 116.0}, {52.0, 76.0}, {28.0, 76.0}, {12.0, 76.0}}, // cw }, - want: map[tms20.TMID][]geom.Polygon{ - 1: {{ + want: map[tms20.TMID][]geom.Geometry{ + 1: {geom.Polygon{ {{4.0, 124.0}, {4.0, 4.0}, {60.0, 4.0}, {60.0, 124.0}}, // ccw {{12.0, 116.0}, {52.0, 116.0}, {52.0, 76.0}, {28.0, 76.0}, {12.0, 76.0}}, // cw }}, @@ -766,14 +751,14 @@ func TestSnap_snapPolygon(t *testing.T) { { name: "keep reversed winding order", tms: newSimpleTileMatrixSet(2, 64), - config: Config{ReverseWindingOrder: true}, + config: processing.Config{ReverseWindingOrder: true}, tmIDs: []tms20.TMID{1}, polygon: geom.Polygon{ {{60.0, 124.0}, {60.0, 4.0}, {4.0, 4.0}, {4.0, 124.0}}, // cw {{12.0, 76.0}, {28.0, 76.0}, {52.0, 76.0}, {52.0, 116.0}, {12.0, 116.0}}, // ccw }, - want: map[tms20.TMID][]geom.Polygon{ - 1: {{ + want: map[tms20.TMID][]geom.Geometry{ + 1: {geom.Polygon{ {{60.0, 124.0}, {60.0, 4.0}, {4.0, 4.0}, {4.0, 124.0}}, // cw {{12.0, 76.0}, {28.0, 76.0}, {52.0, 76.0}, {52.0, 116.0}, {12.0, 116.0}}, // ccw }}, @@ -784,15 +769,42 @@ func TestSnap_snapPolygon(t *testing.T) { t.Run(tt.name, func(t *testing.T) { if tt.wantPanic { require.Panics(t, func() { - SnapPolygon(tt.polygon, tt.tms, tt.tmIDs, tt.config) + ix, err := pointindex.FromTileMatrixSet(tt.tms, slices.Max(tt.tmIDs)) + if err != nil { + panic(err) + } + err = ix.InsertGeometry(tt.polygon) + if err != nil { + outsideGridErr := new(pointindex.OutsideGridError) + if errors.As(err, outsideGridErr) && tt.config.IgnoreOutsideGrid { + log.Println("[WARNING] skipping polygon because: " + err.Error()) + } + panic(err) + } + + SnapGeometry(ix, tt.polygon, tt.tmIDs, tt.config) }) return } - got := SnapPolygon(tt.polygon, tt.tms, tt.tmIDs, tt.config) + + ix, err := pointindex.FromTileMatrixSet(tt.tms, slices.Max(tt.tmIDs)) + if err != nil { + panic(err) + } + err = ix.InsertGeometry(tt.polygon) + if err != nil { + outsideGridErr := new(pointindex.OutsideGridError) + if errors.As(err, outsideGridErr) && tt.config.IgnoreOutsideGrid { + log.Println("[WARNING] skipping polygon because: " + err.Error()) + } else { + panic(err) + } + } + got := SnapGeometry(ix, tt.polygon, tt.tmIDs, tt.config) for tmID, wantPoly := range tt.want { if !assert.Equal(t, wantPoly, got[tmID]) { t.Errorf("snapPolygon(%v, _, %v)\n= %v\nwant: %v", - wkt.MustEncode(tt.polygon), tmID, geomhelp.WktMustEncodeSlice(got[tmID], 0), geomhelp.WktMustEncodeSlice(wantPoly, 0)) + wkt.MustEncode(tt.polygon), tmID, geomhelp.WktMustEncode(got[tmID], 0), geomhelp.WktMustEncodeSlice(wantPoly, 0)) } } }) @@ -1060,7 +1072,7 @@ func (f fakeCRS) Code() string { func squareRingArray(number int, isOuter bool) [][][2]float64 { outerSquare := [][2]float64{{0, 0}, {1, 0}, {1, 1}, {0, 1}} // square, counter clockwise innerSquare := [][2]float64{{0, 0}, {0, 1}, {1, 1}, {1, 0}} // square, clockwise - var squares = [][][2]float64{} //nolint:prealloc + squares := [][][2]float64{} //nolint:prealloc var square [][2]float64 // outer or inner if isOuter { diff --git a/tile/assemble.go b/tile/assemble.go new file mode 100644 index 0000000..7217a6b --- /dev/null +++ b/tile/assemble.go @@ -0,0 +1,297 @@ +package tile + +import ( + "bytes" + "encoding/binary" + "fmt" + + "github.com/go-spatial/geom" + "github.com/go-spatial/geom/encoding/mvt" + vectorTile "github.com/go-spatial/geom/encoding/mvt/vector_tile" + oldproto "github.com/golang/protobuf/proto" //nolint:staticcheck // needed: matches the reflection-based marshaling vector_tile.pb.go relies on + "github.com/pdok/texel/intgeom" + "github.com/pdok/texel/mapslicehelp" +) + +const ( + precision = 4096 + mvtVersion = 2 +) + +type EncodedGeometry struct { + Encoding []uint32 + GeometryType int32 + XTile uint + YTile uint +} + +// EncodedFeatureRow pairs a feature's identifier with its encoded, tile-relative geometry +// as read back from the "
_encoded" table. +type EncodedFeatureRow struct { + FeatureID int64 + Geom EncodedGeometry +} + +type Tile struct { + Extent intgeom.Extent + X uint + Y uint + IsContained bool +} + +type DefaultEncoding struct { + Encoding []uint32 + GeomType vectorTile.Tile_GeomType +} + +// Default tile-filling geometry with buffer +func NewDefaultEncoding(buffer uint) (DefaultEncoding, error) { + fbuffer := float64(buffer) + defaultPolygon := geom.Polygon{{ + {-fbuffer, -fbuffer}, + {-fbuffer, precision + fbuffer}, + {precision + fbuffer, precision + fbuffer}, + {precision + fbuffer, -fbuffer}, + }} + + defaultExtent := geom.Extent{ + 0, 0, precision, precision, + } + preparedPolygon := mvt.PrepareGeo(defaultPolygon, &defaultExtent, precision) + + encoding, geomType, err := EncodeGeometry(preparedPolygon) + return DefaultEncoding{Encoding: encoding, GeomType: geomType}, err +} + +// Encode geometry. defaultEnc is the precomputed tile-filling geometry +func MvtEncodeGeometry(t Tile, g geom.Geometry, defaultEnc DefaultEncoding) EncodedGeometry { + var encgeom []uint32 + var geomtype vectorTile.Tile_GeomType + var err error + if t.IsContained { + encgeom, geomtype = defaultEnc.Encoding, defaultEnc.GeomType + } else { + ext := t.Extent.ToGeomExtent() + preparedGeo := mvt.PrepareGeo(g, &ext, float64(precision)) + + // This should not be necessary. + // sg, err := convert.ToTegola(preparedGeo) + // tegolaGeo, err := validate.CleanGeometry(context.TODO(), sg, &ext) + // validatedGeo := convert.ToGeom(tegolaGeo) + + encgeom, geomtype, err = EncodeGeometry(preparedGeo) + } + if err != nil { + panic(err) + } + + return EncodedGeometry{ + Encoding: encgeom, + GeometryType: int32(geomtype), + XTile: t.X, + YTile: t.Y, + } +} + +// Serialze tile into protobuf format +func MarshalTile(t *vectorTile.Tile) ([]byte, error) { + return oldproto.Marshal(t) +} + +// Internal representation of attributes: read table[fid][columnName]=value. +type InternalAttributeTable map[int64]map[string]any + +// Build indices from column names +func BuildKeyDictionary(columnNames []string) map[string]uint32 { + index := make(map[string]uint32, len(columnNames)) + for i, name := range columnNames { + if i > int(^uint32(0)) { + panic("Number of keys does not fit in uint32") + } + index[name] = uint32(i) //nolint:gosec // G115 + } + return index +} + +// Build index table for values. This function guarantees that all indices from +// 0 to len(index)-1 all appear exactly once. Absent attributes are not recorded. +func BuildValueDictionary(attributesPerFeature InternalAttributeTable) map[any]uint32 { + index := make(map[any]uint32) + for _, attributes := range attributesPerFeature { + for _, v := range attributes { + if v == nil { + continue + } + if _, ok := index[v]; ok { + continue + } + l := len(index) + if l > int(^uint32(0)) { + panic("Number of values does not fit in uint32") + } + index[v] = uint32(l) + } + } + return index +} + +// Construct tags for a single feature. `keyIndex` and `valueIndex` are built +// by above functions by considering all features. `attributes` are the +// attributes of the current feature. Absent values are skipped. Panics if a +// key of value is not present. +func buildTags(attributes map[string]any, keyIndex map[string]uint32, valueIndex map[any]uint32) ([]uint32, error) { + tags := make([]uint32, 0, 2*len(attributes)) + for key, val := range attributes { + if val == nil { + continue + } + + kidx, ok := keyIndex[key] + if !ok { + return nil, fmt.Errorf("did not find key (%v) in key dictionary", key) + } + + vidx, ok := valueIndex[val] + if !ok { + return nil, fmt.Errorf("did not find value (%v) in value dictionary", val) + } + + tags = append(tags, kidx, vidx) + } + return tags, nil +} + +// Construct feature according to protobuf format. +func BuildFeature(featureID int64, geom EncodedGeometry, attributes map[string]any, keyIndex map[string]uint32, valueIndex map[any]uint32) (*vectorTile.Tile_Feature, error) { + tags, err := buildTags(attributes, keyIndex, valueIndex) + if err != nil { + return nil, fmt.Errorf("feature %d: %w", featureID, err) + } + + //nolint:gosec // G115 feature ids are expected to be non-negative + id := uint64(featureID) + gtype := vectorTile.Tile_GeomType(geom.GeometryType) + + return &vectorTile.Tile_Feature{ + Id: &id, + Tags: tags, + Type: >ype, + Geometry: geom.Encoding, + }, nil +} + +func BuildEncodeTile(layers []*vectorTile.Tile_Layer) ([]byte, error) { + tile := &vectorTile.Tile{Layers: layers} + data, err := oldproto.Marshal(tile) + if err != nil { + return nil, fmt.Errorf("marshaling tile: %w", err) + } + return data, nil +} + +func BuildLayer(name string, keyIndex map[string]uint32, encFeatrows []EncodedFeatureRow, attributes InternalAttributeTable) (*vectorTile.Tile_Layer, error) { + valueIndex := BuildValueDictionary(attributes) + + features := make([]*vectorTile.Tile_Feature, 0, len(encFeatrows)) + for _, encodedFeature := range encFeatrows { + featureID := encodedFeature.FeatureID + attrs := attributesForFeature(attributes, featureID) + feature, err := BuildFeature(featureID, encodedFeature.Geom, attrs, keyIndex, valueIndex) + if err != nil { + return nil, err + } + features = append(features, feature) + } + + keys := mapslicehelp.OrderedByIndex(keyIndex) + + rawValues := mapslicehelp.OrderedByIndex(valueIndex) + values := make([]*vectorTile.Tile_Value, len(rawValues)) + for i, v := range rawValues { + values[i] = vectorTileValue(v) + } + + version := uint32(mvtVersion) + extent := uint32(precision) + layerName := name + + tile := &vectorTile.Tile_Layer{ + Version: &version, + Name: &layerName, + Features: features, + Keys: keys, + Values: values, + Extent: &extent, + } + return tile, nil +} + +// attributesForFeature extracts a single feature's attributes from the map construct. +func attributesForFeature(attributes InternalAttributeTable, featureID int64) map[string]any { + if attrs, ok := attributes[featureID]; ok { + return attrs + } + return map[string]any{} +} + +// Convert value to Tile_Value. Copied from go-spatial/encoding/mvt/layer.go +func vectorTileValue(i any) *vectorTile.Tile_Value { //nolint:cyclop, funlen + tv := new(vectorTile.Tile_Value) + switch t := i.(type) { + default: + buff := new(bytes.Buffer) + err := binary.Write(buff, binary.BigEndian, t) + // We are going to ignore the value and return an empty TileValue + if err == nil { + tv.XXX_unrecognized = buff.Bytes() + } + + case string: + tv.StringValue = &t + + case fmt.Stringer: + str := t.String() + tv.StringValue = &str + + case bool: + tv.BoolValue = &t + + case int8: + intv := int64(t) + tv.SintValue = &intv + + case int16: + intv := int64(t) + tv.SintValue = &intv + + case int32: + intv := int64(t) + tv.SintValue = &intv + + case int64: + tv.IntValue = &t + + case uint8: + intv := int64(t) + tv.SintValue = &intv + + case uint16: + intv := int64(t) + tv.SintValue = &intv + + case uint32: + intv := int64(t) + tv.SintValue = &intv + + case uint64: + tv.UintValue = &t + + case float32: + tv.FloatValue = &t + + case float64: + tv.DoubleValue = &t + + } // switch + return tv +} diff --git a/tile/assemble_test.go b/tile/assemble_test.go new file mode 100644 index 0000000..d9536e9 --- /dev/null +++ b/tile/assemble_test.go @@ -0,0 +1,262 @@ +package tile + +import ( + "sort" + "testing" + + vectorTile "github.com/go-spatial/geom/encoding/mvt/vector_tile" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Quick test BuildKeyDictionary +func TestBuildKeyDictionary(t *testing.T) { + tests := []struct { + name string + columnNames []string + want map[string]uint32 + }{ + { + name: "empty slice yields empty map", + columnNames: []string{}, + want: map[string]uint32{}, + }, + { + name: "single column", + columnNames: []string{"a"}, + want: map[string]uint32{"a": 0}, + }, + { + name: "multiple columns keep their position as index", + columnNames: []string{"a", "b", "c"}, + want: map[string]uint32{"a": 0, "b": 1, "c": 2}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildKeyDictionary(tt.columnNames) + assert.Equal(t, tt.want, got) + }) + } +} + +// indexedKeys returns the keys of index sorted by their assigned uint32, +// i.e. it undoes the (arbitrary, since built from a map) assignment of +// indices so tests can assert on order-independent content. +func indexedKeys(index map[any]uint32) []any { + keys := make([]any, len(index)) + for k, i := range index { + keys[i] = k + } + return keys +} + +func TestBuildValueDictionary(t *testing.T) { + tests := []struct { + name string + attributesPerFeature InternalAttributeTable + wantValues []any // expected distinct, non-nil values (order-independent) + }{ + { + name: "empty table yields empty dictionary", + attributesPerFeature: InternalAttributeTable{}, + wantValues: []any{}, + }, + { + name: "single feature, single attribute", + attributesPerFeature: InternalAttributeTable{ + 1: {"name": "foo"}, + }, + wantValues: []any{"foo"}, + }, + { + name: "shared values across features are only counted once", + attributesPerFeature: InternalAttributeTable{ + 1: {"name": "foo"}, + 2: {"name": "foo"}, + 3: {"name": "bar"}, + }, + wantValues: []any{"foo", "bar"}, + }, + { + name: "nil values are skipped", + attributesPerFeature: InternalAttributeTable{ + 1: {"name": "foo", "optional": nil}, + }, + wantValues: []any{"foo"}, + }, + { + name: "mixed value types are all indexed", + attributesPerFeature: InternalAttributeTable{ + 1: {"name": "foo", "count": int64(3), "active": true}, + }, + wantValues: []any{"foo", int64(3), true}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildValueDictionary(tt.attributesPerFeature) + + // Every index from 0..len(got)-1 must be used exactly once. + assert.Len(t, got, len(tt.wantValues)) + gotValues := indexedKeys(got) + assert.ElementsMatch(t, tt.wantValues, gotValues) + }) + } +} + +func TestBuildTags(t *testing.T) { + keyIndex := map[string]uint32{"name": 0, "count": 1} + valueIndex := map[any]uint32{"foo": 0, int64(3): 1} + + t.Run("empty attributes yield empty tags", func(t *testing.T) { + tags, err := buildTags(map[string]any{}, keyIndex, valueIndex) + require.NoError(t, err) + assert.Empty(t, tags) + }) + + t.Run("single attribute produces a key/value index pair", func(t *testing.T) { + tags, err := buildTags(map[string]any{"name": "foo"}, keyIndex, valueIndex) + require.NoError(t, err) + assert.Equal(t, []uint32{0, 0}, tags) + }) + + t.Run("nil-valued attribute is skipped", func(t *testing.T) { + tags, err := buildTags(map[string]any{"name": "foo", "count": nil}, keyIndex, valueIndex) + require.NoError(t, err) + assert.Equal(t, []uint32{0, 0}, tags) + }) + + t.Run("multiple attributes produce all pairs", func(t *testing.T) { + tags, err := buildTags(map[string]any{"name": "foo", "count": int64(3)}, keyIndex, valueIndex) + require.NoError(t, err) + + // Tags is built from map iteration, so pair order is not + // guaranteed; compare as a set of (key, value) pairs instead. + require.Len(t, tags, 4) + pairs := map[[2]uint32]bool{} + for i := 0; i < len(tags); i += 2 { + pairs[[2]uint32{tags[i], tags[i+1]}] = true + } + assert.True(t, pairs[[2]uint32{0, 0}]) + assert.True(t, pairs[[2]uint32{1, 1}]) + }) + + t.Run("missing key returns an error", func(t *testing.T) { + _, err := buildTags(map[string]any{"unknown": "foo"}, keyIndex, valueIndex) + require.Error(t, err) + }) + + t.Run("missing value returns an error", func(t *testing.T) { + _, err := buildTags(map[string]any{"name": "unindexed value"}, keyIndex, valueIndex) + require.Error(t, err) + }) +} + +func TestBuildLayer(t *testing.T) { + tests := []struct { + name string + layerName string + keyIndex map[string]uint32 + encFeatRows []EncodedFeatureRow + attributes InternalAttributeTable + wantErr bool + validate func(t *testing.T, layer *vectorTile.Tile_Layer) + }{ + { + name: "empty layer", + layerName: "empty", + keyIndex: map[string]uint32{}, + encFeatRows: nil, + attributes: InternalAttributeTable{}, + validate: func(t *testing.T, layer *vectorTile.Tile_Layer) { + t.Helper() + assert.Equal(t, "empty", layer.GetName()) + assert.Equal(t, uint32(mvtVersion), layer.GetVersion()) + assert.Equal(t, uint32(precision), layer.GetExtent()) + assert.Empty(t, layer.GetKeys()) + assert.Empty(t, layer.GetValues()) + assert.Empty(t, layer.GetFeatures()) + }, + }, + { + name: "keys are ordered according to key index", + layerName: "mylayer", + keyIndex: map[string]uint32{"b": 1, "a": 0}, + encFeatRows: []EncodedFeatureRow{ + {FeatureID: 1, Geom: EncodedGeometry{GeometryType: int32(vectorTile.Tile_POINT)}}, + }, + attributes: InternalAttributeTable{ + 1: {"a": "first", "b": "second"}, + }, + validate: func(t *testing.T, layer *vectorTile.Tile_Layer) { + t.Helper() + assert.Equal(t, "mylayer", layer.GetName()) + assert.Equal(t, uint32(mvtVersion), layer.GetVersion()) + assert.Equal(t, uint32(precision), layer.GetExtent()) + + // Keys must be ordered according to their assigned index, + // regardless of the (arbitrary) map iteration order. + assert.Equal(t, []string{"a", "b"}, layer.GetKeys()) + require.Len(t, layer.GetValues(), 2) + gotValues := make([]string, len(layer.GetValues())) + for i, v := range layer.GetValues() { + gotValues[i] = v.GetStringValue() + } + assert.ElementsMatch(t, []string{"first", "second"}, gotValues) + }, + }, + { + name: "happy path roundtrips features, tags and attribute values", + layerName: "mylayer", + keyIndex: BuildKeyDictionary([]string{"name"}), + encFeatRows: []EncodedFeatureRow{ + {FeatureID: 1, Geom: EncodedGeometry{GeometryType: int32(vectorTile.Tile_POINT)}}, + {FeatureID: 2, Geom: EncodedGeometry{GeometryType: int32(vectorTile.Tile_POINT)}}, + }, + attributes: InternalAttributeTable{ + 1: {"name": "foo"}, + 2: {"name": "bar"}, + }, + validate: func(t *testing.T, layer *vectorTile.Tile_Layer) { + t.Helper() + assert.Equal(t, "mylayer", layer.GetName()) + require.Len(t, layer.GetFeatures(), 2) + + // Collect the string values assigned to each feature via its + // tags, independent of feature/value ordering. + gotNames := make([]string, 0, 2) + for _, feature := range layer.GetFeatures() { + require.Len(t, feature.GetTags(), 2) + valueIdx := feature.GetTags()[1] + gotNames = append(gotNames, layer.GetValues()[valueIdx].GetStringValue()) + } + sort.Strings(gotNames) + assert.Equal(t, []string{"bar", "foo"}, gotNames) + }, + }, + { + name: "error building a feature's tags propagates", + layerName: "mylayer", + keyIndex: map[string]uint32{"name": 0}, + encFeatRows: []EncodedFeatureRow{ + {FeatureID: 1, Geom: EncodedGeometry{}}, + }, + attributes: InternalAttributeTable{ + 1: {"unknown-key": "foo"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + layer, err := BuildLayer(tt.layerName, tt.keyIndex, tt.encFeatRows, tt.attributes) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + tt.validate(t, layer) + }) + } +} diff --git a/tile/go_spatial_encoding.go b/tile/go_spatial_encoding.go new file mode 100644 index 0000000..47f8f20 --- /dev/null +++ b/tile/go_spatial_encoding.go @@ -0,0 +1,306 @@ +package tile + +// This is the geometry encoding logic copied from go-spatial/geom/encoding/mvt +// It was necessary to take this step, since usually EncodeGeometry is not an +// exported function. Modifications made: +// - Added `const debug = false` which is usually present in another file. +// - Addressed linter issues +// - Removed `context` parameter. +// - Changed float->int conversion from truncation to floor/ceiling + +import ( + "errors" + "fmt" + "log" + "math" + + "github.com/go-spatial/geom" + vectorTile "github.com/go-spatial/geom/encoding/mvt/vector_tile" + "github.com/go-spatial/geom/encoding/wkt" + "github.com/go-spatial/geom/winding" +) + +// Definition needed for compilation +const debug = false + +// Rest of file is copied from feature.go + +// TODO: Need to put in validation for the Geometry, as current the system +// does not check to make sure that the geometry is following the rules as +// laid out by the spec (i.e. polygons must not have the same start and end +// point). + +// Feature describes a feature of a Layer. A layer will contain multiple features +// each of which has a geometry describing the interesting thing, and the metadata +// associated with it. + +var ( + ErrNilFeature = errors.New("feature is nil") + ErrUnknownGeometryType = errors.New("unknown geometry type") + ErrNilGeometryType = errors.New("geometry is nil") +) + +// These values came from: https://github.com/mapbox/vector-tile-spec/tree/master/2.1 +const ( + cmdMoveTo uint32 = 1 + cmdLineTo uint32 = 2 + cmdClosePath uint32 = 7 + + // maxCmdCount uint32 = 0x1FFFFFFF +) + +type Command uint32 + +// NewCommand return a new command encoder +func NewCommand(cmd uint32, count int) Command { + return Command((cmd & 0x7) | (uint32(count) << 3)) //nolint:gosec // G115 - This is potentially an issue if we ever get huge polygons +} + +// ID encodes the ID of the command +func (c Command) ID() uint32 { + return uint32(c) & 0x7 +} + +// Count encode the count of elements in the command +func (c Command) Count() int { + return int(uint32(c) >> 3) +} + +func (c Command) String() string { + switch c.ID() { + case cmdMoveTo: + return fmt.Sprintf("move Command with count %v", c.Count()) + case cmdLineTo: + return fmt.Sprintf("line To command with count %v", c.Count()) + case cmdClosePath: + return fmt.Sprintf("close path command with count %v", c.Count()) + default: + return fmt.Sprintf("unknown command (%v) with count %v", c.ID(), c.Count()) + } +} + +// encodeZigZag does the ZigZag encoding for small ints. +func encodeZigZag(i int64) uint32 { + return uint32((i << 1) ^ (i >> 31)) //nolint:gosec // G115 This can go wrong with polygons with enormous edges +} + +// cursor reprsents the current position, this is needed to encode the geometry. +// the origin (0,0) is the top-left of the Tile. +type cursor struct { + // The coordinates — these should be int64, when they were float64 they + // introduced a slight drift in the coordinates. + x int64 + y int64 +} + +// NewCursor creates a new cursor for drawing and MVT tile +func NewCursor() *cursor { //nolint:revive + return &cursor{} +} + +// GetDeltaPointAndUpdate returns the delta of for the given point from the current +// cursor position +func (c *cursor) GetDeltaPointAndUpdate(p geom.Point) (dx, dy int64) { + intX := int64(math.Floor(p.X())) + intY := int64(math.Ceil(p.Y())) + delta := c.moveCursorPoints([2]int64{intX, intY}) + return delta[0][0], delta[0][1] +} + +// MoveTo encodes a move to command for the given points +func (c *cursor) MoveTo(points ...[2]float64) []uint32 { + return c.encodeCmd(uint32(NewCommand(cmdMoveTo, len(points))), points) +} + +// LineTo encodes a line to command for the given points +func (c *cursor) LineTo(points ...[2]float64) []uint32 { + return c.encodeCmd(uint32(NewCommand(cmdLineTo, len(points))), points) +} + +// ClosePath encodes a close path command +func (c *cursor) ClosePath() uint32 { + return uint32(NewCommand(cmdClosePath, 1)) +} + +func (c *cursor) moveCursorPoints(pts ...[2]int64) (deltas [][2]int64) { + deltas = make([][2]int64, len(pts)) + for i := range pts { + deltas[i][0] = pts[i][0] - c.x + deltas[i][1] = pts[i][1] - c.y + c.x, c.y = pts[i][0], pts[i][1] + } + return deltas +} + +func (c *cursor) encodeZigZagPt(pts [][2]int64) []uint32 { + g := make([]uint32, 0, (2 * len(pts))) + for _, dp := range pts { + g = append(g, encodeZigZag(dp[0]), encodeZigZag(dp[1])) + } + return g +} + +func (c *cursor) encodeCmd(cmd uint32, points [][2]float64) []uint32 { + if len(points) == 0 { + return []uint32{} + } + // new slice to hold our encode bytes. 2 bytes for each point pluse a command byte. + g := make([]uint32, 0, (2*len(points))+1) + // add the command integer + g = append(g, cmd) + + // range through our points + for _, p := range points { + dx, dy := c.GetDeltaPointAndUpdate(geom.Point(p)) + // encode our delta point + g = append(g, encodeZigZag(dx), encodeZigZag(dy)) + } + + return g +} + +func (c *cursor) encodeLinearRing(order winding.Order, wo winding.Winding, ring [][2]float64) []uint32 { + iring := make([][2]int64, len(ring)) + for i := range iring { + // the process of truncating the float can cause the winding order to flip! + iring[i][0] = int64(math.Floor(ring[i][0])) + iring[i][1] = int64(math.Ceil(ring[i][1])) + } + ringWinding := order.OfInt64Points(iring...) + + if ringWinding.IsColinear() { + return []uint32{} + } + + if ringWinding != wo { + if debug { + log.Printf("(0) RING WKT:\n%v", wkt.MustEncode(geom.LineString(ring))) + log.Printf("(1) winding order: \n\tpts: %v\n\two : %v", ringWinding, wo) + } + // need to reverse the points in the ring + for i := len(iring)/2 - 1; i >= 0; i-- { + opp := len(iring) - 1 - i + iring[i], iring[opp] = iring[opp], iring[i] + } + if debug { + log.Printf("(2) RING WKT:\n%v", wkt.MustEncode(geom.LineString(ring))) + log.Printf("(2) winding order: \n\tpts: %v\n\two : %v", ringWinding, wo) + } + } + + deltas := c.moveCursorPoints(iring...) + + // 3 is for the three commands that it takes to describe a ring: move to, line to, and close + g := make([]uint32, 0, (2*len(iring))+3) + + // move to first point + g = append(g, + uint32(NewCommand(cmdMoveTo, 1)), + encodeZigZag(deltas[0][0]), + encodeZigZag(deltas[0][1]), + ) + + // line to each of the other points + g = append(g, uint32(NewCommand(cmdLineTo, len(deltas)-1))) + g = append(g, c.encodeZigZagPt(deltas[1:])...) + + // Close path + g = append(g, uint32(NewCommand(cmdClosePath, 1))) + + return g +} + +func (c *cursor) encodePolygon(geo geom.Polygon) []uint32 { + g := []uint32{} + + lines := geo.LinearRings() + for i := range lines { + // bail if number of points is less than two + if len(lines[i]) < 2 { + if i != 0 { + continue + } + return g + } + + // https://github.com/mapbox/vector-tile-spec/tree/master/2.1#4344-polygon-geometry-type + // An exterior ring is DEFINED as a linear ring having a positive area + // as calculated by applying the surveyor's formula to the vertices of + // the polygon in tile coordinates. In the tile coordinate system (with + // the Y axis positive down and X axis positive to the right) this makes + // the exterior ring's winding order appear clockwise. + // + // An interior ring is DEFINED as a linear ring having a negative area as + // calculated by applying the surveyor's formula to the vertices of the + // polygon in tile coordinates. In the tile coordinate system (with the + // Y axis positive down and X axis positive to the right) this makes the + // interior ring's winding order appear counterclockwise. + order := winding.Order{YPositiveDown: true} + wo := winding.CounterClockwise + if i == 0 { + wo = winding.Clockwise + } + g = append(g, c.encodeLinearRing(order, wo, lines[i])...) + } + return g +} + +// EncodeGeometry will take a geom.Geometry and encode it according to the +// mapbox vector_tile spec. +func EncodeGeometry(geometry geom.Geometry) (g []uint32, vtyp vectorTile.Tile_GeomType, err error) { + if geometry == nil { + return nil, vectorTile.Tile_UNKNOWN, ErrNilGeometryType + } + + c := NewCursor() + + switch t := geometry.(type) { + case geom.Point: + g = append(g, c.MoveTo(t)...) + return g, vectorTile.Tile_POINT, nil + + case geom.MultiPoint: + g = append(g, c.MoveTo(t.Points()...)...) + return g, vectorTile.Tile_POINT, nil + + case geom.LineString: + points := t.Vertices() + g = append(g, c.MoveTo(points[0])...) + g = append(g, c.LineTo(points[1:]...)...) + return g, vectorTile.Tile_LINESTRING, nil + + case geom.MultiLineString: + lines := t.LineStrings() + for _, l := range lines { + points := geom.LineString(l).Vertices() + g = append(g, c.MoveTo(points[0])...) + g = append(g, c.LineTo(points[1:]...)...) + } + return g, vectorTile.Tile_LINESTRING, nil + + case geom.Polygon: + g = append(g, c.encodePolygon(t)...) + return g, vectorTile.Tile_POLYGON, nil + + case geom.MultiPolygon: + polygons := t.Polygons() + for _, p := range polygons { + g = append(g, c.encodePolygon(p)...) + } + return g, vectorTile.Tile_POLYGON, nil + + case *geom.MultiPolygon: + if t == nil { + return g, vectorTile.Tile_POLYGON, nil + } + + polygons := t.Polygons() + for _, p := range polygons { + g = append(g, c.encodePolygon(p)...) + } + return g, vectorTile.Tile_POLYGON, nil + + default: + return nil, vectorTile.Tile_UNKNOWN, ErrUnknownGeometryType + } +} diff --git a/tms20/tms20.go b/tms20/tms20.go index a951f45..9c9e933 100644 --- a/tms20/tms20.go +++ b/tms20/tms20.go @@ -659,7 +659,7 @@ func (tms *TileMatrixSet) Size(zoom uint) (*slippy.Tile, bool) { if !ok { return nil, false } - return slippy.NewTile(zoom, tm.MatrixWidth, tm.MatrixHeight), true + return &slippy.Tile{Z: slippy.Zoom(zoom), X: tm.MatrixWidth, Y: tm.MatrixHeight}, true } func (tms *TileMatrixSet) FromNative(zoom uint, pt geom.Point) (*slippy.Tile, bool) { @@ -710,7 +710,7 @@ func (tms *TileMatrixSet) FromNative(zoom uint, pt geom.Point) (*slippy.Tile, bo return nil, false } - return slippy.NewTile(zoom, ux, uy), true + return &slippy.Tile{Z: slippy.Zoom(zoom), X: ux, Y: uy}, true } func (tms *TileMatrixSet) ToNative(tile *slippy.Tile) (geom.Point, bool) {