Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
* make sure that `morecantile.defaults.TileMatrixSets.get(name)` returns a copy of the TMS object
* add `MORECANTILE_DEFAULT_GEOGRAPHIC_CRS` environment variable to control the default Geographic CRS
* add `TileMatrixSet.set_geographic_crs(crs: pyproj.CRS)` method to overwrite the geographic CRS
* add support for `BottomLeft` cornerOfOrigin TileMatrices
* add python 3.14 support

## 6.2.0 (2024-12-19)
Expand Down
70 changes: 56 additions & 14 deletions morecantile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -677,17 +677,19 @@ def custom(
ordered_axes: Optional[List[str]] = None,
screen_pixel_size: float = 0.28e-3,
decimation_base: int = 2,
corner_of_origin: Literal["topLeft", "bottomLeft"] = "topLeft",
point_of_origin: List[float] = None,
**kwargs: Any,
):
"""
Construct a custom TileMatrixSet.

Attributes
----------
crs: pyproj.CRS
Tile Matrix Set coordinate reference system
extent: list
Bounding box of the Tile Matrix Set, (left, bottom, right, top).
crs: pyproj.CRS
Tile Matrix Set coordinate reference system
tile_width: int
Width of each tile of this tile matrix in pixels (default is 256).
tile_height: int
Expand All @@ -713,6 +715,10 @@ def custom(
Rendering pixel size. 0.28 mm was the actual pixel size of a common display from 2005 and considered as standard by OGC.
decimation_base: int, optional
How tiles are divided at each zoom level (default is 2). Must be greater than 1.
corner_of_origin: str, optional
Corner of origin for the TMS, either 'topLeft' or 'bottomLeft'
point_of_origin: list, optional
Point of origin for the TMS, (x, y) coordinates in the TMS CRS.
kwargs: Any
Attributes to forward to the TileMatrixSet

Expand All @@ -738,8 +744,20 @@ def custom(
)

bbox = BoundingBox(*extent)
x_origin = bbox.left if not is_inverted else bbox.top
y_origin = bbox.top if not is_inverted else bbox.left
if not point_of_origin:
if corner_of_origin == "topLeft":
x_origin = bbox.left if not is_inverted else bbox.top
y_origin = bbox.top if not is_inverted else bbox.left
point_of_origin = [x_origin, y_origin]
elif corner_of_origin == "bottomLeft":
x_origin = bbox.left if not is_inverted else bbox.bottom
y_origin = bbox.bottom if not is_inverted else bbox.left
point_of_origin = [x_origin, y_origin]
else:
raise ValueError(
f"Invalid `corner_of_origin` value: {corner_of_origin}, must be either 'topLeft' or 'bottomLeft'"
)

width = abs(bbox.right - bbox.left)
height = abs(bbox.top - bbox.bottom)
mpu = meters_per_unit(crs)
Expand All @@ -758,7 +776,8 @@ def custom(
"id": str(zoom),
"scaleDenominator": res * mpu / screen_pixel_size,
"cellSize": res,
"pointOfOrigin": [x_origin, y_origin],
"cornerOfOrigin": corner_of_origin,
"pointOfOrigin": point_of_origin,
"tileWidth": tile_width,
"tileHeight": tile_height,
"matrixWidth": matrix_scale[0] * decimation_base**zoom,
Expand Down Expand Up @@ -830,6 +849,7 @@ def matrix(self, zoom: int) -> TileMatrix:
id=str(int(tile_matrix.id) + 1),
scaleDenominator=tile_matrix.scaleDenominator / factor,
cellSize=tile_matrix.cellSize / factor,
cornerOfOrigin=tile_matrix.cornerOfOrigin,
pointOfOrigin=tile_matrix.pointOfOrigin,
tileWidth=tile_matrix.tileWidth,
tileHeight=tile_matrix.tileHeight,
Expand Down Expand Up @@ -981,8 +1001,14 @@ def _tile(
if not math.isinf(xcoord)
else 0
)

coord = (
(origin_y - ycoord)
if matrix.cornerOfOrigin == "topLeft"
else (ycoord - origin_y)
)
ytile = (
math.floor((origin_y - ycoord) / float(matrix.cellSize * matrix.tileHeight))
math.floor(coord / float(matrix.cellSize * matrix.tileHeight))
if not math.isinf(ycoord)
else 0
)
Expand Down Expand Up @@ -1088,10 +1114,16 @@ def _ul(self, *tile: Tile) -> Coords:
if matrix.variableMatrixWidths is not None
else 1
)
return Coords(
origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth,
origin_y - t.y * matrix.cellSize * matrix.tileHeight,
x_coord = (
origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth
)
y_coord = (
origin_y - t.y * matrix.cellSize * matrix.tileHeight
if matrix.cornerOfOrigin == "topLeft"
else origin_y + t.y * matrix.cellSize * matrix.tileHeight
)

return Coords(x_coord, y_coord)

def _lr(self, *tile: Tile) -> Coords:
"""
Expand All @@ -1116,12 +1148,18 @@ def _lr(self, *tile: Tile) -> Coords:
if matrix.variableMatrixWidths is not None
else 1
)
return Coords(
x_coord = (
origin_x
+ (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth,
origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight,
+ (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth
)
y_coord = (
origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
if matrix.cornerOfOrigin == "topLeft"
else origin_y + (t.y + 1) * matrix.cellSize * matrix.tileHeight
)

return Coords(x_coord, y_coord)

def xy_bounds(self, *tile: Tile) -> BoundingBox:
"""
Return the bounding box of the tile in TMS coordinate reference system.
Expand All @@ -1147,12 +1185,16 @@ def xy_bounds(self, *tile: Tile) -> BoundingBox:
)

left = origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth
top = origin_y - t.y * matrix.cellSize * matrix.tileHeight
right = (
origin_x
+ (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth
)
bottom = origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
if matrix.cornerOfOrigin == "topLeft":
top = origin_y - t.y * matrix.cellSize * matrix.tileHeight
bottom = origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
else:
bottom = origin_y + t.y * matrix.cellSize * matrix.tileHeight
top = origin_y + (t.y + 1) * matrix.cellSize * matrix.tileHeight

return BoundingBox(left, bottom, right, top)

Expand Down
93 changes: 93 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,99 @@ def test_geographic_crs(name, is_wgs84):
assert (tms.geographic_crs == pyproj.CRS.from_epsg(4326)) == is_wgs84


def test_bottomleft_origin():
"""Create TMS with BottomLeft Origin."""
wmTopLeft = morecantile.tms.get("WebMercatorQuad")

crs = pyproj.CRS.from_epsg(3857)
extent = (
-20037508.342789244,
-20037508.342789244,
20037508.342789244,
20037508.342789244,
)
corner_of_origin = "bottomLeft"

tms = TileMatrixSet.custom(
extent,
crs,
matrix_scale=[1, 1],
minzoom=0,
maxzoom=24,
id="WebMercatorQuadBottomLeft",
ordered_axes=["X", "Y"],
corner_of_origin=corner_of_origin,
)
assert tms.matrix(0).pointOfOrigin == (-20037508.342789244, -20037508.342789244)
assert tms._matrix_origin(tms.matrix(0)) == (
-20037508.342789244,
-20037508.342789244,
)
assert tms.xy_bounds(0, 0, 0) == wmTopLeft.xy_bounds(0, 0, 0)
assert tms.bounds(0, 0, 0) == wmTopLeft.bounds(0, 0, 0)

assert tms.xy_bounds(0, 0, 1).left == -20037508.342789244
assert tms.xy_bounds(0, 0, 1).bottom == -20037508.342789244
assert tms.xy_bounds(1, 1, 1).top == 20037508.342789244
assert tms.xy_bounds(1, 1, 1).right == 20037508.342789244

assert tms.tile(-180, -85, 0) == morecantile.Tile(x=0, y=0, z=0)
assert tms.tile(-180, -85, 1) == morecantile.Tile(x=0, y=0, z=1)
assert tms.tile(-180, 85, 1) == morecantile.Tile(x=0, y=1, z=1)

bounds = tms.xy_bounds(486, tms.matrix(10).matrixHeight - 1 - 332, 10)
expected = wmTopLeft.xy_bounds(486, 332, 10)
for a, b in zip(expected, bounds):
assert round(a - b, 6) == pytest.approx(0)


@pytest.mark.parametrize(
("topLeft_Tile", "bottomLeft_Tile"),
[
(morecantile.Tile(10, 10, 10), morecantile.Tile(10, 1013, 10)),
(morecantile.Tile(10, 1013, 10), morecantile.Tile(10, 10, 10)),
# Check the Origin points
(morecantile.Tile(0, 0, 10), morecantile.Tile(0, 1023, 10)),
(morecantile.Tile(0, 1023, 10), morecantile.Tile(0, 0, 10)),
# Check the end points
(morecantile.Tile(1023, 0, 10), morecantile.Tile(1023, 1023, 10)),
(morecantile.Tile(1023, 1023, 10), morecantile.Tile(1023, 0, 10)),
# Zoom=0
(morecantile.Tile(0, 0, 0), morecantile.Tile(0, 0, 0)),
# zoom=1 on both edges of the zoom level
(morecantile.Tile(0, 0, 1), morecantile.Tile(0, 1, 1)),
(morecantile.Tile(0, 1, 1), morecantile.Tile(0, 0, 1)),
# zoom=14 near the middle
(
morecantile.Tile(x=3413, y=6202, z=14),
morecantile.Tile(x=3413, y=10181, z=14),
),
],
)
def test_topLeft_BottomLeft_bounds_equal_bounds(topLeft_Tile, bottomLeft_Tile):
tmsTop = morecantile.tms.get("WebMercatorQuad")
tmsBottom = TileMatrixSet.custom(
(
-20037508.342789244,
-20037508.342789244,
20037508.342789244,
20037508.342789244,
),
pyproj.CRS.from_epsg(3857),
matrix_scale=[1, 1],
minzoom=0,
maxzoom=24,
id="WebMercatorQuadBottomLeft",
ordered_axes=["X", "Y"],
corner_of_origin="bottomLeft",
)

bounds = tmsTop.xy_bounds(topLeft_Tile)
bounds2 = tmsBottom.xy_bounds(bottomLeft_Tile)
for a, b in zip(bounds, bounds2):
assert round(a - b, 6) == 0


def test_webmercator_bounds():
"""Test WebMercatorQuad bounds.

Expand Down