morecantile 6.2.0__py3-none-any.whl → 7.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
morecantile/models.py CHANGED
@@ -1,8 +1,9 @@
1
1
  """Pydantic modules for OGC TileMatrixSets (https://www.ogc.org/standards/tms)"""
2
2
 
3
3
  import math
4
+ import os
4
5
  import warnings
5
- from functools import cached_property
6
+ from functools import cached_property, lru_cache
6
7
  from typing import Any, Dict, Iterator, List, Literal, Optional, Sequence, Tuple, Union
7
8
 
8
9
  import pyproj
@@ -16,7 +17,7 @@ from pydantic import (
16
17
  field_validator,
17
18
  model_validator,
18
19
  )
19
- from pyproj.exceptions import CRSError, ProjError
20
+ from pyproj.exceptions import CRSError
20
21
  from typing_extensions import Annotated
21
22
 
22
23
  from morecantile.commons import BoundingBox, Coords, Tile
@@ -35,12 +36,17 @@ from morecantile.utils import (
35
36
  meters_per_unit,
36
37
  point_in_bbox,
37
38
  to_rasterio_crs,
39
+ truncate_coordinates,
38
40
  )
39
41
 
40
42
  NumType = Union[float, int]
41
43
  BoundsType = Tuple[NumType, NumType]
42
44
  LL_EPSILON = 1e-11
43
45
  axesInfo = Annotated[List[str], Field(min_length=2, max_length=2)]
46
+ WGS84_CRS = pyproj.CRS.from_epsg(4326)
47
+ DEFAULT_GEOGRAPHIC_CRS = os.environ.get("MORECANTILE_DEFAULT_GEOGRAPHIC_CRS")
48
+
49
+ TransformerFromCRS = lru_cache(pyproj.Transformer.from_crs)
44
50
 
45
51
 
46
52
  class CRSUri(BaseModel):
@@ -391,7 +397,7 @@ class TileMatrix(BaseModel, extra="forbid"):
391
397
  return 1
392
398
 
393
399
 
394
- class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
400
+ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True, extra="ignore"):
395
401
  """Tile Matrix Set Definition
396
402
 
397
403
  A definition of a tile matrix set following the Tile Matrix Set standard.
@@ -407,7 +413,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
407
413
  Field(
408
414
  json_schema_extra={
409
415
  "description": "Title of this tile matrix set, normally used for display to a human",
410
- }
416
+ },
417
+ frozen=True,
411
418
  ),
412
419
  ] = None
413
420
  description: Annotated[
@@ -415,7 +422,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
415
422
  Field(
416
423
  json_schema_extra={
417
424
  "description": "Brief narrative description of this tile matrix set, normally available for display to a human",
418
- }
425
+ },
426
+ frozen=True,
419
427
  ),
420
428
  ] = None
421
429
  keywords: Annotated[
@@ -423,7 +431,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
423
431
  Field(
424
432
  json_schema_extra={
425
433
  "description": "Unordered list of one or more commonly used or formalized word(s) or phrase(s) used to describe this tile matrix set",
426
- }
434
+ },
435
+ frozen=True,
427
436
  ),
428
437
  ] = None
429
438
  id: Annotated[
@@ -433,6 +442,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
433
442
  json_schema_extra={
434
443
  "description": "Tile matrix set identifier. Implementation of 'identifier'",
435
444
  },
445
+ frozen=True,
436
446
  ),
437
447
  ] = None
438
448
  uri: Annotated[
@@ -440,7 +450,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
440
450
  Field(
441
451
  json_schema_extra={
442
452
  "description": "Reference to an official source for this tileMatrixSet",
443
- }
453
+ },
454
+ frozen=True,
444
455
  ),
445
456
  ] = None
446
457
  orderedAxes: Annotated[
@@ -448,7 +459,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
448
459
  Field(
449
460
  json_schema_extra={
450
461
  "description": "Ordered list of names of the dimensions defined in the CRS",
451
- }
462
+ },
463
+ frozen=True,
452
464
  ),
453
465
  ] = None
454
466
  crs: Annotated[
@@ -456,7 +468,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
456
468
  Field(
457
469
  json_schema_extra={
458
470
  "description": "Coordinate Reference System (CRS)",
459
- }
471
+ },
472
+ frozen=True,
460
473
  ),
461
474
  ]
462
475
  wellKnownScaleSet: Annotated[
@@ -464,7 +477,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
464
477
  Field(
465
478
  json_schema_extra={
466
479
  "description": "Reference to a well-known scale set",
467
- }
480
+ },
481
+ frozen=True,
468
482
  ),
469
483
  ] = None
470
484
  boundingBox: Annotated[
@@ -472,7 +486,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
472
486
  Field(
473
487
  json_schema_extra={
474
488
  "description": "Minimum bounding rectangle surrounding the tile matrix set, in the supported CRS",
475
- }
489
+ },
490
+ frozen=True,
476
491
  ),
477
492
  ] = None
478
493
  tileMatrices: Annotated[
@@ -480,14 +495,13 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
480
495
  Field(
481
496
  json_schema_extra={
482
497
  "description": "Describes scale levels and its tile matrices",
483
- }
498
+ },
499
+ frozen=True,
484
500
  ),
485
501
  ]
486
502
 
487
503
  # Private attributes
488
- _to_geographic: pyproj.Transformer = PrivateAttr()
489
- _from_geographic: pyproj.Transformer = PrivateAttr()
490
-
504
+ _geographic_crs: pyproj.CRS = PrivateAttr()
491
505
  _tile_matrices_idx: Dict[int, int] = PrivateAttr()
492
506
 
493
507
  def __init__(self, **data):
@@ -498,22 +512,12 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
498
512
  int(mat.id): idx for idx, mat in enumerate(self.tileMatrices)
499
513
  }
500
514
 
501
- try:
502
- self._to_geographic = pyproj.Transformer.from_crs(
503
- self.crs._pyproj_crs, self.crs._pyproj_crs.geodetic_crs, always_xy=True
504
- )
505
- self._from_geographic = pyproj.Transformer.from_crs(
506
- self.crs._pyproj_crs.geodetic_crs, self.crs._pyproj_crs, always_xy=True
507
- )
508
- except ProjError:
509
- warnings.warn(
510
- "Could not create coordinate Transformer from input CRS to the given geographic CRS"
511
- "some methods might not be available.",
512
- UserWarning,
513
- stacklevel=1,
514
- )
515
- self._to_geographic = None
516
- self._from_geographic = None
515
+ # Default Geographic CRS from TMS's CRS
516
+ self._geographic_crs = (
517
+ pyproj.CRS.from_user_input(DEFAULT_GEOGRAPHIC_CRS)
518
+ if DEFAULT_GEOGRAPHIC_CRS
519
+ else self.crs._pyproj_crs.geodetic_crs
520
+ )
517
521
 
518
522
  @model_validator(mode="before")
519
523
  def check_for_old_specification(cls, data):
@@ -551,20 +555,36 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
551
555
  """Simplify default pydantic model repr."""
552
556
  return f"<TileMatrixSet title='{self.title}' id='{self.id}' crs='{CRS_to_uri(self.crs._pyproj_crs)}>"
553
557
 
554
- @cached_property
555
- def geographic_crs(self) -> pyproj.CRS:
556
- """Return the TMS's geographic CRS."""
557
- return self.crs._pyproj_crs.geodetic_crs
558
-
559
558
  @cached_property
560
559
  def rasterio_crs(self):
561
560
  """Return rasterio CRS."""
562
561
  return to_rasterio_crs(self.crs._pyproj_crs)
563
562
 
564
- @cached_property
563
+ def set_geographic_crs(self, crs: CRS) -> None:
564
+ """Overwrite Geographic CRS for the TMS."""
565
+ self._geographic_crs = crs
566
+
567
+ @property
568
+ def _to_geographic(self) -> pyproj.Transformer:
569
+ return TransformerFromCRS(
570
+ self.crs._pyproj_crs, self.geographic_crs, always_xy=True
571
+ )
572
+
573
+ @property
574
+ def _from_geographic(self) -> pyproj.Transformer:
575
+ return TransformerFromCRS(
576
+ self.geographic_crs, self.crs._pyproj_crs, always_xy=True
577
+ )
578
+
579
+ @property
580
+ def geographic_crs(self) -> pyproj.CRS:
581
+ """Return the TMS's geographic CRS."""
582
+ return self._geographic_crs
583
+
584
+ @property
565
585
  def rasterio_geographic_crs(self):
566
586
  """Return the geographic CRS as a rasterio CRS."""
567
- return to_rasterio_crs(self.crs._pyproj_crs.geodetic_crs)
587
+ return to_rasterio_crs(self._geographic_crs)
568
588
 
569
589
  @property
570
590
  def minzoom(self) -> int:
@@ -657,6 +677,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
657
677
  ordered_axes: Optional[List[str]] = None,
658
678
  screen_pixel_size: float = 0.28e-3,
659
679
  decimation_base: int = 2,
680
+ corner_of_origin: Literal["topLeft", "bottomLeft"] = "topLeft",
681
+ point_of_origin: List[float] = None,
660
682
  **kwargs: Any,
661
683
  ):
662
684
  """
@@ -664,10 +686,10 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
664
686
 
665
687
  Attributes
666
688
  ----------
667
- crs: pyproj.CRS
668
- Tile Matrix Set coordinate reference system
669
689
  extent: list
670
690
  Bounding box of the Tile Matrix Set, (left, bottom, right, top).
691
+ crs: pyproj.CRS
692
+ Tile Matrix Set coordinate reference system
671
693
  tile_width: int
672
694
  Width of each tile of this tile matrix in pixels (default is 256).
673
695
  tile_height: int
@@ -693,6 +715,10 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
693
715
  Rendering pixel size. 0.28 mm was the actual pixel size of a common display from 2005 and considered as standard by OGC.
694
716
  decimation_base: int, optional
695
717
  How tiles are divided at each zoom level (default is 2). Must be greater than 1.
718
+ corner_of_origin: str, optional
719
+ Corner of origin for the TMS, either 'topLeft' or 'bottomLeft'
720
+ point_of_origin: list, optional
721
+ Point of origin for the TMS, (x, y) coordinates in the TMS CRS.
696
722
  kwargs: Any
697
723
  Attributes to forward to the TileMatrixSet
698
724
 
@@ -718,8 +744,20 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
718
744
  )
719
745
 
720
746
  bbox = BoundingBox(*extent)
721
- x_origin = bbox.left if not is_inverted else bbox.top
722
- y_origin = bbox.top if not is_inverted else bbox.left
747
+ if not point_of_origin:
748
+ if corner_of_origin == "topLeft":
749
+ x_origin = bbox.left if not is_inverted else bbox.top
750
+ y_origin = bbox.top if not is_inverted else bbox.left
751
+ point_of_origin = [x_origin, y_origin]
752
+ elif corner_of_origin == "bottomLeft":
753
+ x_origin = bbox.left if not is_inverted else bbox.bottom
754
+ y_origin = bbox.bottom if not is_inverted else bbox.left
755
+ point_of_origin = [x_origin, y_origin]
756
+ else:
757
+ raise ValueError(
758
+ f"Invalid `corner_of_origin` value: {corner_of_origin}, must be either 'topLeft' or 'bottomLeft'"
759
+ )
760
+
723
761
  width = abs(bbox.right - bbox.left)
724
762
  height = abs(bbox.top - bbox.bottom)
725
763
  mpu = meters_per_unit(crs)
@@ -738,7 +776,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
738
776
  "id": str(zoom),
739
777
  "scaleDenominator": res * mpu / screen_pixel_size,
740
778
  "cellSize": res,
741
- "pointOfOrigin": [x_origin, y_origin],
779
+ "cornerOfOrigin": corner_of_origin,
780
+ "pointOfOrigin": point_of_origin,
742
781
  "tileWidth": tile_width,
743
782
  "tileHeight": tile_height,
744
783
  "matrixWidth": matrix_scale[0] * decimation_base**zoom,
@@ -810,6 +849,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
810
849
  id=str(int(tile_matrix.id) + 1),
811
850
  scaleDenominator=tile_matrix.scaleDenominator / factor,
812
851
  cellSize=tile_matrix.cellSize / factor,
852
+ cornerOfOrigin=tile_matrix.cornerOfOrigin,
813
853
  pointOfOrigin=tile_matrix.pointOfOrigin,
814
854
  tileWidth=tile_matrix.tileWidth,
815
855
  tileHeight=tile_matrix.tileHeight,
@@ -887,7 +927,17 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
887
927
 
888
928
  return zoom_level
889
929
 
890
- def lnglat(self, x: float, y: float, truncate=False) -> Coords:
930
+ def intersect_tms(self, bbox: BoundingBox) -> bool:
931
+ """Check if a bounds intersects with the TMS bounds."""
932
+ tms_bounds = self.xy_bbox
933
+ return (
934
+ (bbox[0] < tms_bounds[2])
935
+ and (bbox[2] > tms_bounds[0])
936
+ and (bbox[3] > tms_bounds[1])
937
+ and (bbox[1] < tms_bounds[3])
938
+ )
939
+
940
+ def lnglat(self, x: float, y: float, truncate: bool = False) -> Coords:
891
941
  """Transform point(x,y) to geographic longitude and latitude."""
892
942
  inside = point_in_bbox(Coords(x, y), self.xy_bbox)
893
943
  if not inside:
@@ -900,14 +950,14 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
900
950
  lng, lat = self._to_geographic.transform(x, y)
901
951
 
902
952
  if truncate:
903
- lng, lat = self.truncate_lnglat(lng, lat)
953
+ lng, lat = truncate_coordinates(lng, lat, self.bbox)
904
954
 
905
955
  return Coords(lng, lat)
906
956
 
907
- def xy(self, lng: float, lat: float, truncate=False) -> Coords:
957
+ def xy(self, lng: float, lat: float, truncate: bool = False) -> Coords:
908
958
  """Transform geographic longitude and latitude coordinates to TMS CRS."""
909
959
  if truncate:
910
- lng, lat = self.truncate_lnglat(lng, lat)
960
+ lng, lat = truncate_coordinates(lng, lat, self.bbox)
911
961
 
912
962
  inside = point_in_bbox(Coords(lng, lat), self.bbox)
913
963
  if not inside:
@@ -921,25 +971,6 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
921
971
 
922
972
  return Coords(x, y)
923
973
 
924
- def truncate_lnglat(self, lng: float, lat: float) -> Tuple[float, float]:
925
- """
926
- Truncate geographic coordinates to TMS geographic bbox.
927
-
928
- Adapted from https://github.com/mapbox/mercantile/blob/master/mercantile/__init__.py
929
-
930
- """
931
- if lng > self.bbox.right:
932
- lng = self.bbox.right
933
- elif lng < self.bbox.left:
934
- lng = self.bbox.left
935
-
936
- if lat > self.bbox.top:
937
- lat = self.bbox.top
938
- elif lat < self.bbox.bottom:
939
- lat = self.bbox.bottom
940
-
941
- return lng, lat
942
-
943
974
  def _tile(
944
975
  self,
945
976
  xcoord: float,
@@ -970,8 +1001,14 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
970
1001
  if not math.isinf(xcoord)
971
1002
  else 0
972
1003
  )
1004
+
1005
+ coord = (
1006
+ (origin_y - ycoord)
1007
+ if matrix.cornerOfOrigin == "topLeft"
1008
+ else (ycoord - origin_y)
1009
+ )
973
1010
  ytile = (
974
- math.floor((origin_y - ycoord) / float(matrix.cellSize * matrix.tileHeight))
1011
+ math.floor(coord / float(matrix.cellSize * matrix.tileHeight))
975
1012
  if not math.isinf(ycoord)
976
1013
  else 0
977
1014
  )
@@ -1007,6 +1044,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1007
1044
  zoom: int,
1008
1045
  truncate=False,
1009
1046
  ignore_coalescence: bool = False,
1047
+ geographic_crs: Optional[CRS] = None,
1010
1048
  ) -> Tile:
1011
1049
  """
1012
1050
  Get the tile for a given geographic longitude and latitude pair.
@@ -1019,13 +1057,38 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1019
1057
  The zoom level.
1020
1058
  truncate : bool
1021
1059
  Whether or not to truncate inputs to limits of TMS geographic bounds.
1060
+ ignore_coalescence : bool
1061
+ Whether or not to ignore coalescence factor for TMS with variable matrix width.
1062
+ geographic_crs: pyproj.CRS, optional
1063
+ Geographic CRS of the given coordinates. Default to TMS's Geographic CRS.
1022
1064
 
1023
1065
  Returns
1024
1066
  -------
1025
1067
  Tile
1026
1068
 
1027
1069
  """
1028
- x, y = self.xy(lng, lat, truncate=truncate)
1070
+ geographic_crs = geographic_crs or self.geographic_crs or WGS84_CRS
1071
+ _from_geographic = TransformerFromCRS(
1072
+ geographic_crs, self.crs._pyproj_crs, always_xy=True
1073
+ )
1074
+ _to_geographic = TransformerFromCRS(
1075
+ self.crs._pyproj_crs, geographic_crs, always_xy=True
1076
+ )
1077
+
1078
+ if truncate:
1079
+ bbox = BoundingBox(
1080
+ *_to_geographic.transform_bounds(*self.xy_bbox, densify_pts=21),
1081
+ )
1082
+ lng, lat = truncate_coordinates(lng, lat, bbox)
1083
+
1084
+ x, y = _from_geographic.transform(lng, lat)
1085
+ if not point_in_bbox(Coords(x, y), self.xy_bbox):
1086
+ warnings.warn(
1087
+ f"Point ({lng}, {lat}) is outside TMS bounds.",
1088
+ PointOutsideTMSBounds,
1089
+ stacklevel=1,
1090
+ )
1091
+
1029
1092
  return self._tile(x, y, zoom, ignore_coalescence=ignore_coalescence)
1030
1093
 
1031
1094
  def _ul(self, *tile: Tile) -> Coords:
@@ -1051,11 +1114,17 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1051
1114
  if matrix.variableMatrixWidths is not None
1052
1115
  else 1
1053
1116
  )
1054
- return Coords(
1055
- origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth,
1056
- origin_y - t.y * matrix.cellSize * matrix.tileHeight,
1117
+ x_coord = (
1118
+ origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth
1119
+ )
1120
+ y_coord = (
1121
+ origin_y - t.y * matrix.cellSize * matrix.tileHeight
1122
+ if matrix.cornerOfOrigin == "topLeft"
1123
+ else origin_y + t.y * matrix.cellSize * matrix.tileHeight
1057
1124
  )
1058
1125
 
1126
+ return Coords(x_coord, y_coord)
1127
+
1059
1128
  def _lr(self, *tile: Tile) -> Coords:
1060
1129
  """
1061
1130
  Return the lower right coordinate of the tile in TMS coordinate reference system.
@@ -1079,11 +1148,17 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1079
1148
  if matrix.variableMatrixWidths is not None
1080
1149
  else 1
1081
1150
  )
1082
- return Coords(
1151
+ x_coord = (
1083
1152
  origin_x
1084
- + (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth,
1085
- origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight,
1153
+ + (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth
1086
1154
  )
1155
+ y_coord = (
1156
+ origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
1157
+ if matrix.cornerOfOrigin == "topLeft"
1158
+ else origin_y + (t.y + 1) * matrix.cellSize * matrix.tileHeight
1159
+ )
1160
+
1161
+ return Coords(x_coord, y_coord)
1087
1162
 
1088
1163
  def xy_bounds(self, *tile: Tile) -> BoundingBox:
1089
1164
  """
@@ -1110,12 +1185,16 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1110
1185
  )
1111
1186
 
1112
1187
  left = origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth
1113
- top = origin_y - t.y * matrix.cellSize * matrix.tileHeight
1114
1188
  right = (
1115
1189
  origin_x
1116
1190
  + (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth
1117
1191
  )
1118
- bottom = origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
1192
+ if matrix.cornerOfOrigin == "topLeft":
1193
+ top = origin_y - t.y * matrix.cellSize * matrix.tileHeight
1194
+ bottom = origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
1195
+ else:
1196
+ bottom = origin_y + t.y * matrix.cellSize * matrix.tileHeight
1197
+ top = origin_y + (t.y + 1) * matrix.cellSize * matrix.tileHeight
1119
1198
 
1120
1199
  return BoundingBox(left, bottom, right, top)
1121
1200
 
@@ -1174,7 +1253,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1174
1253
 
1175
1254
  return BoundingBox(left, bottom, right, top)
1176
1255
 
1177
- @property
1256
+ @cached_property
1178
1257
  def xy_bbox(self):
1179
1258
  """Return TMS bounding box in TileMatrixSet's CRS."""
1180
1259
  zoom = self.minzoom
@@ -1186,7 +1265,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1186
1265
  )
1187
1266
  return BoundingBox(left, bottom, right, top)
1188
1267
 
1189
- @cached_property
1268
+ @property
1190
1269
  def bbox(self):
1191
1270
  """Return TMS bounding box in geographic coordinate reference system."""
1192
1271
  left, bottom, right, top = self.xy_bbox
@@ -1200,16 +1279,6 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1200
1279
  )
1201
1280
  )
1202
1281
 
1203
- def intersect_tms(self, bbox: BoundingBox) -> bool:
1204
- """Check if a bounds intersects with the TMS bounds."""
1205
- tms_bounds = self.xy_bbox
1206
- return (
1207
- (bbox[0] < tms_bounds[2])
1208
- and (bbox[2] > tms_bounds[0])
1209
- and (bbox[3] > tms_bounds[1])
1210
- and (bbox[1] < tms_bounds[3])
1211
- )
1212
-
1213
1282
  def tiles( # noqa: C901
1214
1283
  self,
1215
1284
  west: float,
@@ -1218,6 +1287,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1218
1287
  north: float,
1219
1288
  zooms: Sequence[int],
1220
1289
  truncate: bool = False,
1290
+ geographic_crs: Optional[CRS] = None,
1221
1291
  ) -> Iterator[Tile]:
1222
1292
  """
1223
1293
  Get the tiles overlapped by a geographic bounding box
@@ -1232,6 +1302,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1232
1302
  One or more zoom levels.
1233
1303
  truncate : bool, optional
1234
1304
  Whether or not to truncate inputs to TMS limits.
1305
+ geographic_crs: pyproj.CRS, optional
1306
+ Geographic CRS of the given coordinates. Default to TMS's Geographic CRS
1235
1307
 
1236
1308
  Yields
1237
1309
  ------
@@ -1249,39 +1321,43 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1249
1321
  if isinstance(zooms, int):
1250
1322
  zooms = (zooms,)
1251
1323
 
1324
+ geographic_crs = geographic_crs or self.geographic_crs or WGS84_CRS
1325
+ _from_geographic = TransformerFromCRS(
1326
+ geographic_crs, self.crs._pyproj_crs, always_xy=True
1327
+ )
1328
+ _to_geographic = TransformerFromCRS(
1329
+ self.crs._pyproj_crs, geographic_crs, always_xy=True
1330
+ )
1331
+
1332
+ # TMS bbox
1333
+ bbox = BoundingBox(
1334
+ *_to_geographic.transform_bounds(*self.xy_bbox, densify_pts=21),
1335
+ )
1336
+
1252
1337
  if truncate:
1253
- west, south = self.truncate_lnglat(west, south)
1254
- east, north = self.truncate_lnglat(east, north)
1338
+ west, south = truncate_coordinates(west, south, bbox)
1339
+ east, north = truncate_coordinates(east, north, bbox)
1255
1340
 
1256
1341
  if west > east:
1257
- bbox_west = (self.bbox.left, south, east, north)
1258
- bbox_east = (west, south, self.bbox.right, north)
1342
+ bbox_west = (bbox.left, south, east, north)
1343
+ bbox_east = (west, south, bbox.right, north)
1259
1344
  bboxes = [bbox_west, bbox_east]
1260
1345
  else:
1261
1346
  bboxes = [(west, south, east, north)]
1262
1347
 
1263
1348
  for w, s, e, n in bboxes:
1264
1349
  # Clamp bounding values.
1265
- es_contain_180th = lons_contain_antimeridian(e, self.bbox.right)
1266
- w = max(self.bbox.left, w)
1267
- s = max(self.bbox.bottom, s)
1268
- e = max(self.bbox.right, e) if es_contain_180th else min(self.bbox.right, e)
1269
- n = min(self.bbox.top, n)
1270
-
1350
+ es_contain_180th = lons_contain_antimeridian(e, bbox.right)
1351
+ w = max(bbox.left, w)
1352
+ s = max(bbox.bottom, s)
1353
+ e = max(bbox.right, e) if es_contain_180th else min(bbox.right, e)
1354
+ n = min(bbox.top, n)
1355
+
1356
+ w, n = _from_geographic.transform(w + LL_EPSILON, n - LL_EPSILON)
1357
+ e, s = _from_geographic.transform(e - LL_EPSILON, s + LL_EPSILON)
1271
1358
  for z in zooms:
1272
- nw_tile = self.tile(
1273
- w + LL_EPSILON,
1274
- n - LL_EPSILON,
1275
- z,
1276
- ignore_coalescence=True,
1277
- ) # Not in mercantile
1278
- se_tile = self.tile(
1279
- e - LL_EPSILON,
1280
- s + LL_EPSILON,
1281
- z,
1282
- ignore_coalescence=True,
1283
- )
1284
-
1359
+ nw_tile = self._tile(w, n, z, ignore_coalescence=True)
1360
+ se_tile = self._tile(e, s, z, ignore_coalescence=True)
1285
1361
  minx = min(nw_tile.x, se_tile.x)
1286
1362
  maxx = max(nw_tile.x, se_tile.x)
1287
1363
  miny = min(nw_tile.y, se_tile.y)
@@ -1308,6 +1384,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1308
1384
  buffer: Optional[NumType] = None,
1309
1385
  precision: Optional[int] = None,
1310
1386
  projected: bool = False,
1387
+ geographic_crs: Optional[CRS] = None,
1311
1388
  ) -> Dict:
1312
1389
  """
1313
1390
  Get the GeoJSON feature corresponding to a tile.
@@ -1329,16 +1406,27 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1329
1406
  otherwise original coordinate values will be preserved (default).
1330
1407
  projected : bool, optional
1331
1408
  Return coordinates in TMS projection. Default is false.
1409
+ geographic_crs: pyproj.CRS, optional
1410
+ Geographic CRS to use when `projected=False`. Default to 'EPSG:4326' as per GeoJSON specification.
1411
+ .
1332
1412
 
1333
1413
  Returns
1334
1414
  -------
1335
1415
  dict
1336
1416
 
1337
1417
  """
1418
+ geographic_crs = geographic_crs or WGS84_CRS
1419
+
1420
+ feature_crs = self.crs._pyproj_crs
1338
1421
  west, south, east, north = self.xy_bounds(tile)
1339
1422
 
1340
1423
  if not projected:
1341
- west, south, east, north = self._to_geographic.transform_bounds(
1424
+ feature_crs = geographic_crs
1425
+ tr = pyproj.Transformer.from_crs(
1426
+ self.crs._pyproj_crs, geographic_crs, always_xy=True
1427
+ )
1428
+
1429
+ west, south, east, north = tr.transform_bounds(
1342
1430
  west, south, east, north, densify_pts=21
1343
1431
  )
1344
1432
 
@@ -1364,26 +1452,38 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1364
1452
  "geometry": geom,
1365
1453
  "properties": {
1366
1454
  "title": f"XYZ tile {xyz}",
1367
- "grid_name": self.id,
1368
- "grid_crs": CRS_to_uri(self.crs._pyproj_crs),
1455
+ "tms": self.id,
1456
+ "tms_crs": CRS_to_uri(self.crs._pyproj_crs),
1369
1457
  },
1370
1458
  }
1371
1459
 
1372
- if projected:
1460
+ if feature_crs != WGS84_CRS:
1373
1461
  warnings.warn(
1374
1462
  "CRS is no longer part of the GeoJSON specification."
1375
1463
  "Other projection than EPSG:4326 might not be supported.",
1376
1464
  UserWarning,
1377
1465
  stacklevel=1,
1378
1466
  )
1379
- feat.update(
1380
- {
1381
- "crs": {
1382
- "type": "EPSG",
1383
- "properties": {"code": self.crs.to_epsg()},
1467
+
1468
+ if authority_code := feature_crs.to_authority(min_confidence=20):
1469
+ authority, code = authority_code
1470
+ feat.update(
1471
+ {
1472
+ "crs": {
1473
+ "type": "name",
1474
+ "properties": {"name": CRS_to_uri(feature_crs)},
1475
+ }
1384
1476
  }
1385
- }
1386
- )
1477
+ )
1478
+ else:
1479
+ feat.update(
1480
+ {
1481
+ "crs": {
1482
+ "type": "wkt",
1483
+ "properties": {"wkt": feature_crs.to_wkt()},
1484
+ }
1485
+ }
1486
+ )
1387
1487
 
1388
1488
  if props:
1389
1489
  feat["properties"].update(props)
@@ -1480,11 +1580,12 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1480
1580
  "y": {"min": 0, "max": m.matrixHeight - 1},
1481
1581
  }
1482
1582
 
1483
- def is_valid(self, *tile: Tile) -> bool:
1583
+ def is_valid(self, *tile: Tile, strict: bool = True) -> bool:
1484
1584
  """Check if a tile is valid."""
1485
1585
  t = _parse_tile_arg(*tile)
1486
1586
 
1487
- if t.z < self.minzoom:
1587
+ disable_overzoom = self.is_variable or strict
1588
+ if t.z < self.minzoom or (disable_overzoom and t.z > self.maxzoom):
1488
1589
  return False
1489
1590
 
1490
1591
  matrix = self.matrix(t.z)