From 57f8f7485bc60b3034583884fa77d28da46ebf43 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Thu, 12 Nov 2026 18:07:20 +0100 Subject: [PATCH] fix: expand tile envelope filter by the tile buffer `ST_AsMVTGeom` keeps geometries up to `tile_buffer` pixels outside of the tile, but the tile filter only selected features intersecting the tile itself, so features in the buffer were never encoded (clipped lines/polygons and missing labels at tile edges). `Collection.cql_where` gets `tile_buffer` / `tile_resolution` options to expand the tile envelope (within the TMS bounds); both backends pass the MVT settings. Co-Authored-By: Claude Opus 5.5 --- CHANGES.md | 1 + tests/duck/test_geoparquet.py | 3 +- tests/duck/test_tile_buffer.py | 83 ++++++++++++++++++++++++++++++++++ tipg/collections.py | 22 ++++++++- tipg/duck/collections.py | 2 + tipg/pg/collections.py | 2 + 6 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 tests/duck/test_tile_buffer.py diff --git a/CHANGES.md b/CHANGES.md index 3708c80f..93697905 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -27,6 +27,7 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin * `connect_to_duckdb` / `close_duckdb_connection` to manage the connection (`app.state.duckdb_connection`) * `duck_get_collection_index(*, connection, settings=None)` to list the collections * `DuckDBSettings` / `DuckDBCatalogSettings` (`TIPG_DUCKDB_` env prefix) +* fix: expand the tile envelope filter by the tile buffer (`TIPG_TILE_BUFFER` pixels, within the TileMatrixSet bounds), so features just outside of a tile but inside its buffer are encoded in the tile (avoids clipped lines/polygons and missing labels at tile edges). Adds `tile_buffer` and `tile_resolution` options to `Collection.cql_where` * change: add `Collection._tag_geometry_literal` and `Collection._reproject_geometry_literal` abstract methods, defining how CQL2/bbox geometry literals are tagged with, or reprojected to, the geometry column CRS for each backend **breaking change** (custom `Collection` subclasses must implement them) * change: move `_quote_ident` from `tipg.pg.collections` to `tipg.collections` * docs: update the `examples/non_app_usage.ipynb` notebook for the new `tipg.pg` API diff --git a/tests/duck/test_geoparquet.py b/tests/duck/test_geoparquet.py index 8904f009..1ebbc043 100644 --- a/tests/duck/test_geoparquet.py +++ b/tests/duck/test_geoparquet.py @@ -540,4 +540,5 @@ def test_projjson_crs(projjson_collection): tms = morecantile.tms.get("WebMercatorQuad") content = asyncio.run(col.get_tile(tms=tms, tile=tms.tile(13.5, 47.5, 8))) decoded = mapbox_vector_tile.decode(content) - assert len(decoded["default"]["features"]) == 1 + # (13.5, 47.5) in the tile, (13.0, 47.0) and (14.0, 48.0) in its buffer + assert len(decoded["default"]["features"]) == 3 diff --git a/tests/duck/test_tile_buffer.py b/tests/duck/test_tile_buffer.py new file mode 100644 index 00000000..eaa95f1b --- /dev/null +++ b/tests/duck/test_tile_buffer.py @@ -0,0 +1,83 @@ +"""Test the tile envelope filter includes the tile buffer.""" + +import asyncio + +import mapbox_vector_tile +import morecantile +import pytest + +from tipg.duck import DuckDBCatalogSettings, DuckDBSettings +from tipg.duck.collections import _collection_index +from tipg.duck.database import create_connection +from tipg.settings import MVTSettings + +duckdb = pytest.importorskip("duckdb") + +tms = morecantile.tms.get("WebMercatorQuad") + +# z4 tile covering lon [0, 22.5], lat [0, ~21.94] +TILE = morecantile.Tile(8, 7, 4) +# tile buffer (256px of 4096px) is 1.40625° wide on this tile +POINTS = { + 1: (10.0, 10.0), # inside the tile + 2: (23.0, 10.0), # outside the tile, inside its buffer + 3: (25.0, 10.0), # outside the tile buffer +} + + +@pytest.fixture +def points(): + """Collection of points around the tile east edge.""" + conn = create_connection(DuckDBSettings()) + values = ", ".join(f"({i}, ST_Point({x}, {y}))" for i, (x, y) in POINTS.items()) + conn.execute( + f"CREATE VIEW memory.main.points AS SELECT * FROM (VALUES {values}) t(id, geom)" + ) + (col,) = _collection_index(conn, DuckDBCatalogSettings(tables=["main.points"])) + yield col + conn.close() + + +def test_tile_includes_buffer(points): + """Features in the tile buffer are encoded in the tile.""" + assert MVTSettings().tile_buffer == 256 + + content = asyncio.run(points.get_tile(tms=tms, tile=TILE)) + layer = mapbox_vector_tile.decode(content)["default"] + ids = sorted(f["properties"]["id"] for f in layer["features"]) + assert ids == [1, 2] + + +def test_tile_filter_buffer(points): + """The tile envelope is expanded by `tile_buffer` pixels.""" + bounds = tms.xy_bounds(TILE) + margin = (bounds.right - bounds.left) * 256 / 4096 + + def envelope(where): + ((w, s), (e, _), (_, n), *_) = where.to_json()["args"][1]["coordinates"][0] + return w, s, e, n + + # no buffer by default + w, s, e, n = envelope(points.cql_where(tile=TILE, tms=tms)) + assert (w, s, e, n) == pytest.approx( + tms.lnglat(bounds.left, bounds.bottom) + tms.lnglat(bounds.right, bounds.top) + ) + + w, s, e, n = envelope( + points.cql_where(tile=TILE, tms=tms, tile_buffer=256, tile_resolution=4096) + ) + expected = tms.lnglat(bounds.left - margin, bounds.bottom - margin) + tms.lnglat( + bounds.right + margin, bounds.top + margin + ) + assert (w, s, e, n) == pytest.approx(expected) + + +def test_tile_filter_buffer_clamped(points): + """The expanded tile envelope stays inside the TMS bounds.""" + where = points.cql_where( + tile=morecantile.Tile(0, 0, 0), tms=tms, tile_buffer=256, tile_resolution=4096 + ) + coords = where.to_json()["args"][1]["coordinates"][0] + xs, ys = [c[0] for c in coords], [c[1] for c in coords] + assert min(xs) >= -180 and max(xs) <= 180 + assert min(ys) >= -85.06 and max(ys) <= 85.06 diff --git a/tipg/collections.py b/tipg/collections.py index b636cde1..db8c804c 100644 --- a/tipg/collections.py +++ b/tipg/collections.py @@ -450,8 +450,15 @@ def cql_where( # noqa: C901 dt: str | None = None, tile: Tile | None = None, tms: TileMatrixSet | None = None, + tile_buffer: int = 0, + tile_resolution: int = 4096, ) -> Expr | None: - """Construct WHERE query as a cql2 Expr.""" + """Construct WHERE query as a cql2 Expr. + + The `tile` envelope filter is expanded by `tile_buffer` pixels (of a + `tile_resolution` pixels tile), so features in the tile buffer are + encoded in the tile. + """ exprs: list[Expr] = [] geometry_column = self.get_geometry_column(geom) @@ -593,6 +600,19 @@ def cql_where( # noqa: C901 bounds.right, bounds.top, ) + # Expand the envelope by the tile buffer (`ST_AsMVTGeom` keeps + # geometries up to `tile_buffer` pixels outside of the tile). + # The buffer doesn't extend past the TMS bounds (but tiles + # outside of the TMS bounds are not shrunk). + if tile_buffer: + margin_x = (east - west) * tile_buffer / tile_resolution + margin_y = (north - south) * tile_buffer / tile_resolution + tms_bbox = tms.xy_bbox + west = max(west - margin_x, min(west, tms_bbox.left)) + south = max(south - margin_y, min(south, tms_bbox.bottom)) + east = min(east + margin_x, max(east, tms_bbox.right)) + north = min(north + margin_y, max(north, tms_bbox.top)) + # Bounds are now in TMS CRS tms_srid = tms.crs.to_epsg() or 4326 tile_target_srid = col_srid if col_srid is not None else 4326 diff --git a/tipg/duck/collections.py b/tipg/duck/collections.py index 6cba9f9b..8de824aa 100644 --- a/tipg/duck/collections.py +++ b/tipg/duck/collections.py @@ -580,6 +580,8 @@ async def get_tile( dt=dt, tms=tms, tile=tile, + tile_buffer=mvt_settings.tile_buffer, + tile_resolution=mvt_settings.tile_resolution, ) sorts = self._sort_expressions(sortby) diff --git a/tipg/pg/collections.py b/tipg/pg/collections.py index 6baefc38..ce644fef 100644 --- a/tipg/pg/collections.py +++ b/tipg/pg/collections.py @@ -440,6 +440,8 @@ async def get_tile( dt=dt, tms=tms, tile=tile, + tile_buffer=mvt_settings.tile_buffer, + tile_resolution=mvt_settings.tile_resolution, ) sorts = self._sort_expressions(sortby)