morecantile 6.1.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,21 +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
- )
514
- self._to_geographic = None
515
- 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
+ )
516
521
 
517
522
  @model_validator(mode="before")
518
523
  def check_for_old_specification(cls, data):
@@ -537,10 +542,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
537
542
  def is_variable(self) -> bool:
538
543
  """Check if TMS has variable width matrix."""
539
544
  return any(
540
- [
541
- True if matrix.variableMatrixWidths is not None else False
542
- for matrix in self.tileMatrices
543
- ]
545
+ True if matrix.variableMatrixWidths is not None else False
546
+ for matrix in self.tileMatrices
544
547
  )
545
548
 
546
549
  def __iter__(self):
@@ -552,20 +555,36 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
552
555
  """Simplify default pydantic model repr."""
553
556
  return f"<TileMatrixSet title='{self.title}' id='{self.id}' crs='{CRS_to_uri(self.crs._pyproj_crs)}>"
554
557
 
555
- @cached_property
556
- def geographic_crs(self) -> pyproj.CRS:
557
- """Return the TMS's geographic CRS."""
558
- return self.crs._pyproj_crs.geodetic_crs
559
-
560
558
  @cached_property
561
559
  def rasterio_crs(self):
562
560
  """Return rasterio CRS."""
563
561
  return to_rasterio_crs(self.crs._pyproj_crs)
564
562
 
565
- @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
566
585
  def rasterio_geographic_crs(self):
567
586
  """Return the geographic CRS as a rasterio CRS."""
568
- return to_rasterio_crs(self.crs._pyproj_crs.geodetic_crs)
587
+ return to_rasterio_crs(self._geographic_crs)
569
588
 
570
589
  @property
571
590
  def minzoom(self) -> int:
@@ -658,6 +677,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
658
677
  ordered_axes: Optional[List[str]] = None,
659
678
  screen_pixel_size: float = 0.28e-3,
660
679
  decimation_base: int = 2,
680
+ corner_of_origin: Literal["topLeft", "bottomLeft"] = "topLeft",
681
+ point_of_origin: List[float] = None,
661
682
  **kwargs: Any,
662
683
  ):
663
684
  """
@@ -665,10 +686,10 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
665
686
 
666
687
  Attributes
667
688
  ----------
668
- crs: pyproj.CRS
669
- Tile Matrix Set coordinate reference system
670
689
  extent: list
671
690
  Bounding box of the Tile Matrix Set, (left, bottom, right, top).
691
+ crs: pyproj.CRS
692
+ Tile Matrix Set coordinate reference system
672
693
  tile_width: int
673
694
  Width of each tile of this tile matrix in pixels (default is 256).
674
695
  tile_height: int
@@ -694,6 +715,10 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
694
715
  Rendering pixel size. 0.28 mm was the actual pixel size of a common display from 2005 and considered as standard by OGC.
695
716
  decimation_base: int, optional
696
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.
697
722
  kwargs: Any
698
723
  Attributes to forward to the TileMatrixSet
699
724
 
@@ -719,8 +744,20 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
719
744
  )
720
745
 
721
746
  bbox = BoundingBox(*extent)
722
- x_origin = bbox.left if not is_inverted else bbox.top
723
- 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
+
724
761
  width = abs(bbox.right - bbox.left)
725
762
  height = abs(bbox.top - bbox.bottom)
726
763
  mpu = meters_per_unit(crs)
@@ -739,7 +776,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
739
776
  "id": str(zoom),
740
777
  "scaleDenominator": res * mpu / screen_pixel_size,
741
778
  "cellSize": res,
742
- "pointOfOrigin": [x_origin, y_origin],
779
+ "cornerOfOrigin": corner_of_origin,
780
+ "pointOfOrigin": point_of_origin,
743
781
  "tileWidth": tile_width,
744
782
  "tileHeight": tile_height,
745
783
  "matrixWidth": matrix_scale[0] * decimation_base**zoom,
@@ -800,6 +838,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
800
838
  warnings.warn(
801
839
  f"TileMatrix not found for level: {zoom} - Creating values from TMS Scale.",
802
840
  UserWarning,
841
+ stacklevel=1,
803
842
  )
804
843
 
805
844
  # TODO: what if we want to construct a matrix for a level up ?
@@ -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,57 +927,50 @@ 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:
894
944
  warnings.warn(
895
945
  f"Point ({x}, {y}) is outside TMS bounds {list(self.xy_bbox)}.",
896
946
  PointOutsideTMSBounds,
947
+ stacklevel=1,
897
948
  )
898
949
 
899
950
  lng, lat = self._to_geographic.transform(x, y)
900
951
 
901
952
  if truncate:
902
- lng, lat = self.truncate_lnglat(lng, lat)
953
+ lng, lat = truncate_coordinates(lng, lat, self.bbox)
903
954
 
904
955
  return Coords(lng, lat)
905
956
 
906
- def xy(self, lng: float, lat: float, truncate=False) -> Coords:
957
+ def xy(self, lng: float, lat: float, truncate: bool = False) -> Coords:
907
958
  """Transform geographic longitude and latitude coordinates to TMS CRS."""
908
959
  if truncate:
909
- lng, lat = self.truncate_lnglat(lng, lat)
960
+ lng, lat = truncate_coordinates(lng, lat, self.bbox)
910
961
 
911
962
  inside = point_in_bbox(Coords(lng, lat), self.bbox)
912
963
  if not inside:
913
964
  warnings.warn(
914
965
  f"Point ({lng}, {lat}) is outside TMS bounds {list(self.bbox)}.",
915
966
  PointOutsideTMSBounds,
967
+ stacklevel=1,
916
968
  )
917
969
 
918
970
  x, y = self._from_geographic.transform(lng, lat)
919
971
 
920
972
  return Coords(x, y)
921
973
 
922
- def truncate_lnglat(self, lng: float, lat: float) -> Tuple[float, float]:
923
- """
924
- Truncate geographic coordinates to TMS geographic bbox.
925
-
926
- Adapted from https://github.com/mapbox/mercantile/blob/master/mercantile/__init__.py
927
-
928
- """
929
- if lng > self.bbox.right:
930
- lng = self.bbox.right
931
- elif lng < self.bbox.left:
932
- lng = self.bbox.left
933
-
934
- if lat > self.bbox.top:
935
- lat = self.bbox.top
936
- elif lat < self.bbox.bottom:
937
- lat = self.bbox.bottom
938
-
939
- return lng, lat
940
-
941
974
  def _tile(
942
975
  self,
943
976
  xcoord: float,
@@ -968,8 +1001,14 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
968
1001
  if not math.isinf(xcoord)
969
1002
  else 0
970
1003
  )
1004
+
1005
+ coord = (
1006
+ (origin_y - ycoord)
1007
+ if matrix.cornerOfOrigin == "topLeft"
1008
+ else (ycoord - origin_y)
1009
+ )
971
1010
  ytile = (
972
- math.floor((origin_y - ycoord) / float(matrix.cellSize * matrix.tileHeight))
1011
+ math.floor(coord / float(matrix.cellSize * matrix.tileHeight))
973
1012
  if not math.isinf(ycoord)
974
1013
  else 0
975
1014
  )
@@ -1005,6 +1044,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1005
1044
  zoom: int,
1006
1045
  truncate=False,
1007
1046
  ignore_coalescence: bool = False,
1047
+ geographic_crs: Optional[CRS] = None,
1008
1048
  ) -> Tile:
1009
1049
  """
1010
1050
  Get the tile for a given geographic longitude and latitude pair.
@@ -1017,13 +1057,38 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1017
1057
  The zoom level.
1018
1058
  truncate : bool
1019
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.
1020
1064
 
1021
1065
  Returns
1022
1066
  -------
1023
1067
  Tile
1024
1068
 
1025
1069
  """
1026
- 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
+
1027
1092
  return self._tile(x, y, zoom, ignore_coalescence=ignore_coalescence)
1028
1093
 
1029
1094
  def _ul(self, *tile: Tile) -> Coords:
@@ -1049,11 +1114,17 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1049
1114
  if matrix.variableMatrixWidths is not None
1050
1115
  else 1
1051
1116
  )
1052
- return Coords(
1053
- origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth,
1054
- 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
1055
1124
  )
1056
1125
 
1126
+ return Coords(x_coord, y_coord)
1127
+
1057
1128
  def _lr(self, *tile: Tile) -> Coords:
1058
1129
  """
1059
1130
  Return the lower right coordinate of the tile in TMS coordinate reference system.
@@ -1077,11 +1148,17 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1077
1148
  if matrix.variableMatrixWidths is not None
1078
1149
  else 1
1079
1150
  )
1080
- return Coords(
1151
+ x_coord = (
1081
1152
  origin_x
1082
- + (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth,
1083
- origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight,
1153
+ + (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth
1084
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)
1085
1162
 
1086
1163
  def xy_bounds(self, *tile: Tile) -> BoundingBox:
1087
1164
  """
@@ -1108,12 +1185,16 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1108
1185
  )
1109
1186
 
1110
1187
  left = origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth
1111
- top = origin_y - t.y * matrix.cellSize * matrix.tileHeight
1112
1188
  right = (
1113
1189
  origin_x
1114
1190
  + (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth
1115
1191
  )
1116
- 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
1117
1198
 
1118
1199
  return BoundingBox(left, bottom, right, top)
1119
1200
 
@@ -1172,7 +1253,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1172
1253
 
1173
1254
  return BoundingBox(left, bottom, right, top)
1174
1255
 
1175
- @property
1256
+ @cached_property
1176
1257
  def xy_bbox(self):
1177
1258
  """Return TMS bounding box in TileMatrixSet's CRS."""
1178
1259
  zoom = self.minzoom
@@ -1184,7 +1265,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1184
1265
  )
1185
1266
  return BoundingBox(left, bottom, right, top)
1186
1267
 
1187
- @cached_property
1268
+ @property
1188
1269
  def bbox(self):
1189
1270
  """Return TMS bounding box in geographic coordinate reference system."""
1190
1271
  left, bottom, right, top = self.xy_bbox
@@ -1198,16 +1279,6 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1198
1279
  )
1199
1280
  )
1200
1281
 
1201
- def intersect_tms(self, bbox: BoundingBox) -> bool:
1202
- """Check if a bounds intersects with the TMS bounds."""
1203
- tms_bounds = self.xy_bbox
1204
- return (
1205
- (bbox[0] < tms_bounds[2])
1206
- and (bbox[2] > tms_bounds[0])
1207
- and (bbox[3] > tms_bounds[1])
1208
- and (bbox[1] < tms_bounds[3])
1209
- )
1210
-
1211
1282
  def tiles( # noqa: C901
1212
1283
  self,
1213
1284
  west: float,
@@ -1216,6 +1287,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1216
1287
  north: float,
1217
1288
  zooms: Sequence[int],
1218
1289
  truncate: bool = False,
1290
+ geographic_crs: Optional[CRS] = None,
1219
1291
  ) -> Iterator[Tile]:
1220
1292
  """
1221
1293
  Get the tiles overlapped by a geographic bounding box
@@ -1230,6 +1302,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1230
1302
  One or more zoom levels.
1231
1303
  truncate : bool, optional
1232
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
1233
1307
 
1234
1308
  Yields
1235
1309
  ------
@@ -1247,39 +1321,43 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1247
1321
  if isinstance(zooms, int):
1248
1322
  zooms = (zooms,)
1249
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
+
1250
1337
  if truncate:
1251
- west, south = self.truncate_lnglat(west, south)
1252
- east, north = self.truncate_lnglat(east, north)
1338
+ west, south = truncate_coordinates(west, south, bbox)
1339
+ east, north = truncate_coordinates(east, north, bbox)
1253
1340
 
1254
1341
  if west > east:
1255
- bbox_west = (self.bbox.left, south, east, north)
1256
- bbox_east = (west, south, self.bbox.right, north)
1342
+ bbox_west = (bbox.left, south, east, north)
1343
+ bbox_east = (west, south, bbox.right, north)
1257
1344
  bboxes = [bbox_west, bbox_east]
1258
1345
  else:
1259
1346
  bboxes = [(west, south, east, north)]
1260
1347
 
1261
1348
  for w, s, e, n in bboxes:
1262
1349
  # Clamp bounding values.
1263
- es_contain_180th = lons_contain_antimeridian(e, self.bbox.right)
1264
- w = max(self.bbox.left, w)
1265
- s = max(self.bbox.bottom, s)
1266
- e = max(self.bbox.right, e) if es_contain_180th else min(self.bbox.right, e)
1267
- n = min(self.bbox.top, n)
1268
-
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)
1269
1358
  for z in zooms:
1270
- nw_tile = self.tile(
1271
- w + LL_EPSILON,
1272
- n - LL_EPSILON,
1273
- z,
1274
- ignore_coalescence=True,
1275
- ) # Not in mercantile
1276
- se_tile = self.tile(
1277
- e - LL_EPSILON,
1278
- s + LL_EPSILON,
1279
- z,
1280
- ignore_coalescence=True,
1281
- )
1282
-
1359
+ nw_tile = self._tile(w, n, z, ignore_coalescence=True)
1360
+ se_tile = self._tile(e, s, z, ignore_coalescence=True)
1283
1361
  minx = min(nw_tile.x, se_tile.x)
1284
1362
  maxx = max(nw_tile.x, se_tile.x)
1285
1363
  miny = min(nw_tile.y, se_tile.y)
@@ -1306,6 +1384,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1306
1384
  buffer: Optional[NumType] = None,
1307
1385
  precision: Optional[int] = None,
1308
1386
  projected: bool = False,
1387
+ geographic_crs: Optional[CRS] = None,
1309
1388
  ) -> Dict:
1310
1389
  """
1311
1390
  Get the GeoJSON feature corresponding to a tile.
@@ -1327,16 +1406,27 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1327
1406
  otherwise original coordinate values will be preserved (default).
1328
1407
  projected : bool, optional
1329
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
+ .
1330
1412
 
1331
1413
  Returns
1332
1414
  -------
1333
1415
  dict
1334
1416
 
1335
1417
  """
1418
+ geographic_crs = geographic_crs or WGS84_CRS
1419
+
1420
+ feature_crs = self.crs._pyproj_crs
1336
1421
  west, south, east, north = self.xy_bounds(tile)
1337
1422
 
1338
1423
  if not projected:
1339
- 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(
1340
1430
  west, south, east, north, densify_pts=21
1341
1431
  )
1342
1432
 
@@ -1362,25 +1452,38 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1362
1452
  "geometry": geom,
1363
1453
  "properties": {
1364
1454
  "title": f"XYZ tile {xyz}",
1365
- "grid_name": self.id,
1366
- "grid_crs": CRS_to_uri(self.crs._pyproj_crs),
1455
+ "tms": self.id,
1456
+ "tms_crs": CRS_to_uri(self.crs._pyproj_crs),
1367
1457
  },
1368
1458
  }
1369
1459
 
1370
- if projected:
1460
+ if feature_crs != WGS84_CRS:
1371
1461
  warnings.warn(
1372
1462
  "CRS is no longer part of the GeoJSON specification."
1373
1463
  "Other projection than EPSG:4326 might not be supported.",
1374
1464
  UserWarning,
1465
+ stacklevel=1,
1375
1466
  )
1376
- feat.update(
1377
- {
1378
- "crs": {
1379
- "type": "EPSG",
1380
- "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
+ }
1381
1476
  }
1382
- }
1383
- )
1477
+ )
1478
+ else:
1479
+ feat.update(
1480
+ {
1481
+ "crs": {
1482
+ "type": "wkt",
1483
+ "properties": {"wkt": feature_crs.to_wkt()},
1484
+ }
1485
+ }
1486
+ )
1384
1487
 
1385
1488
  if props:
1386
1489
  feat["properties"].update(props)
@@ -1477,11 +1580,12 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1477
1580
  "y": {"min": 0, "max": m.matrixHeight - 1},
1478
1581
  }
1479
1582
 
1480
- def is_valid(self, *tile: Tile) -> bool:
1583
+ def is_valid(self, *tile: Tile, strict: bool = True) -> bool:
1481
1584
  """Check if a tile is valid."""
1482
1585
  t = _parse_tile_arg(*tile)
1483
1586
 
1484
- 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):
1485
1589
  return False
1486
1590
 
1487
1591
  matrix = self.matrix(t.z)