Help us shape the future of Go! Take the 2026 Go Developer Survey

vtcseamless

package module
v0.3.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 15, 2026 License: MIT Imports: 5 Imported by: 0

README

vtcseamless — seamless polygons from a vector-tile cache

Vector-tile servers clip every feature to the tile it is drawn in — and, because renderers would otherwise show hairline gaps at tile edges, they clip with a buffer of a few pixels. For drawing that is right; for data it is wrong: the per-tile copies of one polygon overlap, their areas double-count along every tile edge, and a naive union of the copies has the wrong area wherever the buffer overlapped a neighbour. vtcseamless is the small Go engine that turns such tiles back into seamless, correctly-nested polygons: each piece is clipped back to its own tile rectangle (tiles tessellate ⇒ pieces are disjoint ⇒ areas add), the pieces of one feature id are unioned with polyclip, the union is rejected if its area disagrees with the additive area of the pieces by more than 2 % (then the pieces are kept, a seam remains, nothing is invented), and rings are classified by nesting depth (shell CCW / holes CW, RFC 7946). For layers without a stable feature id, a seam merge joins pieces from adjacent tiles that share a stretch of the common edge. The root package is generic; bevdirect/ is the preset for the Austrian cadastre tiles and the reason the engine exists.

Go 1.26, deps MIT/BSD (polyclip-go, orb, x/sync, x/text). Code: MIT. Data: see Data licence.

import "github.com/raffopenssh/vtcseamless"

tiles := vtcseamless.TilesForBound(bbox, 15)
// for each tile t and each decoded polygon poly of feature id:
pieces[id] = append(pieces[id], vtcseamless.ClipToBound(poly, t.Bound())...)
// then per feature:
mp, guardTripped := vtcseamless.UnionPieces(pieces[id], sumOfPieceAreas)
function step
TileAt, TilesForBound, TileSetBound, Tile.Bound slippy-map tile maths
ClipToBound (RectClip + FromPolyclip / ToPolyclip) own-tile clip (Sutherland–Hodgman)
UnionPieces, UnionAreaTol polyclip union of disjoint pieces with the > 2 % area-mismatch rejection
ClassifyRings shell/hole by nesting depth, RFC 7946 orientation
MergeSeams seams in id-less layers (adjacent tiles, shared edge stretch, union must dissolve the seam)
AreaSqm, RoundCoords, ToPolygons helpers

bevdirect — the BEV preset: cadastre straight from the BEV tiles

Go package github.com/raffopenssh/vtcseamless/bevdirect + CLI that assembles parcels / building footprints / land use for a bbox directly from the BEV Katastralmappe vector tile cache (VTC, CC BY 4.0). It serves as a provenance proof (everything a client shows can be re-derived from the public tiles with this code) and as a self-hosted cadastre service for map clients. See DEPLOY.md for running it as a systemd service.

go run ./cmd/bevdirect -bbox W,S,E,N -layers parcels,footprints,landuse -o vp.json
go run ./cmd/bevdirect -bbox … -geojson > out.geojson
res, err := bevdirect.Fetch(ctx, west, south, east, north, bevdirect.Options{
    Layers: []string{"parcels", "footprints"}, TileCache: bevdirect.NewTileCache(256<<20, 24*time.Hour)})
// res.Parcels[i] = {parcel_id, kg_code, gnr, ez, rstatus, area_sqm, lon, lat, complete, geometry}
// res.Notice must be displayed with the data („© BEV, 2026 … CC BY 4.0, bearbeitet“).

Measured (one dense small-town 0.02° × 0.02° viewport tile ≈ 1.5 × 2.2 km)

tiles bytes cold warm (in-memory tile cache)
parcels (z15 gst) 8 605 KB ~1.5 s 40 ms
+ footprints + landuse (z16 nfl) +28 1.5 MB 2.6 s total 100 ms

3 288 parcels, 2 686 footprint pieces (2 471 after seam merging), 3 739 landuse pieces. Compared with an independent assembly of the same BEV data for the same bbox: 2 484 ids in common, EZ agrees on all but 9, area agrees within ±2 % for ~93 %; the outliers were ring-0-only slivers on the reference side (one case: reference 88 m², bevdirect 848 m² = the real ring with its hole). bevdirect uses the guarded union for every KG.

BEV serves Cache-Control: no-cache, 1.1–1.4 s per tile; parallelism (default 6) is what makes it tolerable. Be polite: keep Workers ≤ 8, share one TileCache.

Tiles never touch the disk. Raw .pbf bytes live only in a bounded, TTL-expiring in-memory LRU (TileCache; NewTileCache(maxBytes, ttl)), so a bevdirect process leaves no cadastre artefact behind — nothing to licence, back up or clean up. TestNoTileFilesOnDisk pins this.

How it works (the part that is easy to get wrong)

BEV clips every polygon to its tile with a large buffer, so the per-tile copies of one parcel overlap. Each piece is clipped back to its own tile rectangle (Sutherland–Hodgman), pieces of one kg-gnr are unioned with polyclip, and the union is rejected (pieces kept, seam along the tile edge) if its area disagrees with the additive area of the disjoint pieces by > 2 %. Rings are classified by nesting depth (shell CCW / holes CW, RFC 7946). That is exactly the root package; bevdirect adds the download/cache, MVT decoding and the gst/nfl layer conventions.

complete:false = the parcel touches the edge of the fetched tile set, so geometry and area_sqm are truncated. Enlarge the bbox or treat as unknown. Footprint and landuse pieces carry the same flag (since v0.3.2), computed the same way; a seam-merged footprint also lists its tile-scoped members. area_sqm is planar on a local tangent plane with ellipsoidal metres/degree (<0.1 %) — a geometric area, not the legally binding Grundstücksdatenbank area.

bevdirect-serve / bevdirect.Service

go build -o bevdirect-serve ./cmd/bevdirect-serve && ./bevdirect-serve -addr :8787, or tools/package.sh → static tarball + install.sh (system unit) — see DEPLOY.md. Or embed it: a Go client calls bevdirect.NewService(...).Viewport(ctx, w,s,e,n) in-process — no HTTP hop.

endpoint notes
GET /viewport?west&south&east&north[&layers][&wait=s] parcels carry ez, area_sqm, dominant_ns, landuse_areas{ns:m²}, building_count, total_building_area_sqm, complete; footprints parcel_id, obb_length_m, obb_width_m, orientation_deg, ns_code. bbox ≤ 0.045°. wait=0 answers from cache at once with ready:false, pending:true, retry_after_s for cells still assembling.
GET /parcel/{id}?lon&lat resolved from the tiles around the location — lon/lat is mandatory, there is no parcel store to ask
GET /ez?kg&ez&lon&lat (or &west..north) parcels of that folio inside the given area only, partial:true
GET /municipality?lon&lat KG of the nearest parcel → Gemeinde
GET /municipalities?q= picker; diacritics-insensitive, bbox + centre
GET /kg/{kg} admin row + bbox
GET /viewport (≥ v0.3.4) also tiles[{z,x,y,sha256}] (source tiles, raw-byte hashes, sorted z/x/y) and stats.tiles_expected / tiles_fetched / tiles_empty — provenance for downstream digests, see DEPLOY.md v0.3.4
GET /health cells_cached, prefetch_queue, tile_ttl_s, tile_cache{tiles,bytes,max_bytes,hits,misses}, tile_cache_swept_at, admin_source, bevdirect_version

How it stays fast without any per-parcel state. The world is a fixed 0.02° grid. Each cell is assembled once from its tiles (padded 0.004° so parcels crossing the cell edge are complete in at least one cell) and cached under sha256(cell) for 6 h, LRU-bounded (~3–6 MB per cell). A viewport, a click (/parcel), a folio (/ez) and the background prefetch all compose the same cells; a multi-cell viewport is the union of its single-cell documents after dedupe (parcels by id, pieces by members/id; complete copy wins, then larger area — TestComposeEqualsCellUnion). That dedupe keeps one truncated copy of a parcel wider than cell + pad, so for ne_cells digests (which union all truncated copies) only aligned single-cell documents are comparable — on every version, see DEPLOY.md v0.3.3. Before v0.3.2 a footprint straddling a z16 tile edge inside a neighbour's pad came out twice (whole + clipped fragment), so builds comparable to aligned per-cell builds had to use aligned 0.02° cell requests. They all compose the same cells, so a pan hits cells that were warmed by the previous pan: after every viewport the ring of neighbouring cells is queued (2 background workers that yield to foreground requests). Tile downloads are coalesced process-wide (singleflight + in-memory tile cache), total BEV connections are capped (24; BEV latency is flat up to there), and CPU-heavy assembly is gated to one per core so the nearest cell finishes first. Enrichment clips land-use pieces to the parcel bbox before polyclip and runs parcels in parallel (1.4 s → 0.8 s per dense cell).

Measured, initial load of 12 unaligned 0.02° tiles (4-wide, a dense town centre, 9 700 parcels), 2 cores:

cold (nothing cached, 9 cells) 14–15 s total; first tile ~10 s; CPU-bound (polyclip)
pan one tile after ~10 s (prefetched ring) 0.1–1 s
revisit / warm 80–100 ms, ~500 KB gzipped
sparse alpine cell (1 300 parcels) cold ~3–5 s

Non-blocking use (wait=0 / ServiceOptions.Wait) hands the client the cached part immediately and ready:false for the rest; the client retries.

Compliance posture (why cells, and why nothing per parcel). The service holds no database of the cadastre and nothing is keyed by parcel id, EZ or KG. It does exactly what the BEV web map does — fetch the tiles for what is on screen — and keeps (a) an in-memory tile cache (RAM only, never on disk; LRU bounded by -tile-cache-mb, expired tiles dropped hourly, -tile-ttl 24 h) and (b) an expiring cache of assembled tile areas keyed by their coordinates (-ttl 6 h). A parcel can only be obtained by asking for a location, a folio only for the part inside a bbox; nothing is enumerable or searchable. Every response carries the CC BY notice with year. That is the footprint of a browser cache of the BEV web map, well inside the VTC licence, and it never touches the Katasterservice JSON API (§76c UrhG, not CC BY). The embedded KG→Gemeinde table (names, codes, bboxes, areas) is built by tools/build_admin.sh from BEV OGD Verwaltungsgrenzen (VGD) 1:50 000 (CC BY 4.0), so nothing in this module derives from any cadastre index; /health reports the VGD Stichtag as admin_source. Not covered: land prices, protected areas, OSM, LiDAR, address search — those are other upstreams.

What you don't get from the library alone

With Options.Enrich the library computes landuse_areas / dominant_ns, building_count and footprint shapes itself. Not derivable from tiles at all: whole-KG EZ summaries, address search, land prices, protected areas, OSM proximity, toponyms, LiDAR. Whole-KG fetches are expensive (a KG is 20–200 z15 tiles; MaxTiles refuses above 64 by default).

Versioning

bevdirect.Version (set by -ldflags -X main.version=<tag> in tools/package.sh / bootstrap.sh) is reported as bevdirect_version in every /viewport document and /health. Downstream digest comparisons pin their source as the string bevdirect@<Version>; this string and the package name are a stable contract. v0.3.0 is the first tag under this module path; its output is byte-identical to the last pre-public build (checked on five viewports, 25 672 parcels / 13 992 footprints / 25 248 land-use pieces, with and without Enrich).

Data licence

  • Code: MIT (see LICENSE).
  • Tiles fetched at runtime: BEV Katastralmappe vector tile cache (VTC), CC BY 4.0 (the tile host is the one the BEV web map uses; see bevdirect.TileURL). BEV Nutzungsbedingungen §2.3.3 require „© BEV, JJJJ“ on every copy; Result.Notice / X-Data-Attribution carry the string and must be rendered with the data. Nothing from the tiles is stored in this repository.
  • bevdirect/admin.json.gz (240 KB, the only data file in the repo): the KG → Gemeinde/Bezirk/Bundesland table with KG bboxes and areas, derived from BEV OGD Verwaltungsgrenzen (VGD) 1:50 000, Stichtag 2026-04-01, CC BY 4.0 (data.bev.gv.at), by tools/build_admin.sh — reproducible from that script and the public download. /health → admin_source names the edition.
  • Never point this at the BEV Katasterservice search/info JSON API — that one is not CC BY (§76c UrhG, database right). Only the tile cache is used.

Documentation

Overview

Package vtcseamless reassembles seamless polygons from a vector-tile cache.

Tile servers clip every feature to the tile it is drawn in, usually WITH A BUFFER of a few pixels so that strokes do not show seams at tile edges. For rendering that is right; for data it is wrong: the per-tile copies of one polygon overlap, their areas double-count along every tile edge, and a plain union of the copies has the wrong area wherever the buffer overlapped a neighbour. The engine in this package applies one rule that makes the problem go away:

  1. ClipToBound: every piece is clipped back to its own tile rectangle (Sutherland–Hodgman). Tiles tessellate, so the clipped pieces of one feature are DISJOINT, their areas add, and the union cannot double-count.
  2. UnionPieces: pieces of one feature id are dissolved with polyclip. The result is accepted only if its area agrees with the additive area of the pieces within UnionAreaTol (2 %); otherwise the pieces are kept as they are (a seam remains along the tile edge, but nothing is invented).
  3. ClassifyRings: polyclip returns a flat, unordered contour list; rings are turned into polygons by nesting depth (even = shell, odd = hole) and oriented shell CCW / holes CW (RFC 7946).
  4. MergeSeams: for layers WITHOUT a stable feature id, pieces from adjacent tiles that share a stretch of the common edge are unioned when that union really dissolves the seam (fewer polygons out than in).

Everything here is planar lon/lat geometry on orb types; it knows nothing about any particular tile server. Package bevdirect is the preset for the Austrian cadastre tile cache and shows how the pieces fit.

Index

Constants

View Source
const UnionAreaTol = 0.02

UnionAreaTol is the relative area mismatch above which a union is rejected and the pieces are kept as they are.

Variables

This section is empty.

Functions

func AreaSqm

func AreaSqm(mp orb.MultiPolygon) float64

AreaSqm is the area of a lon/lat MultiPolygon in m² on a local tangent plane (ellipsoidal metres-per-degree at the bbox centre; <0.1 % at parcel scale). Holes (rings after the first) are subtracted. It is a geometric area, not a legally binding one.

func ClassifyRings

func ClassifyRings(rings []orb.Ring) []orb.Polygon

ClassifyRings assigns shell/hole by nesting depth (even = shell, odd = hole) and normalises orientation (shell CCW, holes CW) — RFC 7946. Input orientation is ignored; containment is tested geometrically. Rings are modified in place (reversed where needed).

func ClipToBound

func ClipToBound(poly orb.Polygon, b orb.Bound) orb.MultiPolygon

ClipToBound clips poly to the axis-aligned rectangle b (its own tile) and returns the result as a MultiPolygon with classified rings; nil if nothing is left. This is step 1 of the assembly: pieces clipped to tessellating tiles are disjoint, so their areas add.

func FromPolyclip

func FromPolyclip(p polyclip.Polygon) orb.MultiPolygon

FromPolyclip turns polyclip's flat, unordered contour list into a MultiPolygon by nesting depth (orientation-independent; see ClassifyRings). Degenerate contours (< 3 points) are dropped; nil if nothing remains.

func RectClip

func RectClip(pc polyclip.Polygon, b orb.Bound) polyclip.Polygon

RectClip is Sutherland–Hodgman of each contour against an axis-aligned rectangle. Contours that collapse below 3 points are dropped.

func RoundCoords

func RoundCoords(mp orb.MultiPolygon, dec int)

RoundCoords rounds every coordinate in place to dec decimals.

func TileSetBound

func TileSetBound(tiles []Tile) orb.Bound

TileSetBound is the union bbox of a tile set.

func ToPolyclip

func ToPolyclip(p orb.Polygon) polyclip.Polygon

ToPolyclip converts an orb.Polygon to polyclip's contour list.

func ToPolygons

func ToPolygons(g orb.Geometry) []orb.Polygon

ToPolygons flattens a geometry to its polygons (Polygon, MultiPolygon, Collection); nil for anything else.

func UnionPieces

func UnionPieces(polys []orb.Polygon, targetArea float64) (orb.MultiPolygon, bool)

UnionPieces dissolves shared tile edges between DISJOINT pieces of one feature (each already clipped to its own tile with ClipToBound). Pieces are first clustered by touching bboxes; each cluster is unioned with polyclip and the union is accepted only if its planar area agrees with the sum of the piece areas within UnionAreaTol — otherwise the cluster's pieces are emitted unchanged. targetArea is the expected total (deg²; the sum of the piece areas as computed by the caller; <= 0 disables the final check). The second result reports whether any guard tripped.

Types

type SeamGroup

type SeamGroup struct {
	Members []int
	Merged  orb.MultiPolygon
}

SeamGroup is one output of MergeSeams. Members are indices into the input (ascending). Merged is the dissolved geometry (coordinates rounded), or nil when the members are to be kept exactly as they were — either because the group is a singleton or because the union did not reduce the polygon count.

func MergeSeams

func MergeSeams(pieces []SeamPiece, decimals int) []SeamGroup

MergeSeams partitions pieces into groups that belong to one feature split by a tile edge and unions each group. Every input index appears in exactly one group; groups are ordered by their smallest member, so emitting the groups in order preserves the input order for everything untouched. decimals is the coordinate rounding applied to merged geometries.

type SeamPiece

type SeamPiece struct {
	Tile     Tile
	Polygons []orb.Polygon
}

SeamPiece is one tile-clipped feature of an id-less layer: the tile it came from and its polygons (as produced by ClipToBound).

type Tile

type Tile struct{ Z, X, Y int }

Tile is a slippy-map tile coordinate.

func TileAt

func TileAt(lon, lat float64, z int) Tile

TileAt is the tile at zoom z that contains lon/lat.

func TilesForBound

func TilesForBound(b orb.Bound, z int) []Tile

TilesForBound lists the tiles at zoom z covering b (row-major).

func (Tile) Bound

func (t Tile) Bound() orb.Bound

Bound is the exact lon/lat bbox of the tile.

Directories

Path Synopsis
Package bevdirect assembles cadastral parcels, building footprints and land-use polygons for a bbox DIRECTLY from the BEV vector tiles (Katastralmappe VTC, CC BY 4.0).
Package bevdirect assembles cadastral parcels, building footprints and land-use polygons for a bbox DIRECTLY from the BEV vector tiles (Katastralmappe VTC, CC BY 4.0).
cmd
bevdirect command
bevdirect — fetch Austrian cadastre for a bbox straight from BEV tiles.
bevdirect — fetch Austrian cadastre for a bbox straight from BEV tiles.
bevdirect-serve command
bevdirect-serve — a small cadastre service assembled live from BEV vector tiles.
bevdirect-serve — a small cadastre service assembled live from BEV vector tiles.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL