Source code for kmlorm.models.point

"""
Point model for standalone Point geometries in KML files.

This module defines the Point class for handling standalone Point elements
that exist as direct children of Document/Folder elements, separate from
Placemarks that contain Point geometries.
"""

from dataclasses import dataclass
from typing import Any, Optional, Tuple, Union, TYPE_CHECKING, cast
from ..core.exceptions import KMLValidationError
from .base import KMLElement

if TYPE_CHECKING:
    from ..spatial.calculations import DistanceUnit
    from .placemark import Placemark


[docs] @dataclass(frozen=True) class Coordinate: """ Represents a geographic coordinate with longitude, latitude, and optional altitude. Attributes: longitude (float): The longitude of the coordinate. latitude (float): The latitude of the coordinate. altitude (Optional[float]): The altitude of the coordinate, if available. Methods: from_tuple(t: Tuple[float, ...]) -> "Coordinate": Creates a Coordinate instance from a tuple containing longitude, latitude, and optionally altitude. from_string(s: str) -> "Coordinate": Creates a Coordinate instance from a comma-separated string representation of longitude, latitude, and optionally altitude. """ longitude: float latitude: float altitude: float = 0
[docs] def __post_init__(self) -> None: """ Post-initialization hook that is automatically called after the dataclass is instantiated. This method triggers validation logic to ensure the object's state is consistent and valid. """ self.validate()
[docs] @classmethod def from_tuple(cls, t: Tuple[float, ...]) -> "Coordinate": """ Create a Coordinate instance from a tuple of floats. Args: t (Tuple[float, ...]): A tuple containing longitude, latitude, and optionally altitude. Returns: Coordinate: An instance of Coordinate with the specified longitude, latitude, and optional altitude. Raises: IndexError: If the tuple does not contain at least two elements. ValueError: If the tuple elements cannot be converted to float. """ try: lon = float(t[0]) lat = float(t[1]) alt = float(t[2]) if len(t) > 2 else 0.0 except (IndexError, TypeError, ValueError) as exc: raise KMLValidationError( "Invalid coordinate tuple. Expected (lon, lat, [alt])" f"with numeric arguments. Got ({t}]" ) from exc rv = cls(longitude=lon, latitude=lat, altitude=alt) rv.validate() return rv
[docs] @classmethod def from_string(cls, s: str) -> "Coordinate": """ Creates a Coordinate instance from a comma-separated string. Args: s (str): A string containing coordinate values separated by commas (e.g., "longitude, latitude, [altitude]"). Returns: Coordinate: An instance of the Coordinate class created from the parsed values. Raises: ValueError: If the string cannot be parsed into valid float values. """ parts = [p.strip() for p in s.split(",")] try: rv = cls.from_tuple(tuple(float(p) for p in parts)) except KMLValidationError as kve: raise KMLValidationError( f"Could not parse '{s}' into tuple(float, float, float)" ) from kve return rv
[docs] @classmethod def from_any(cls, value: Union[Tuple[float, ...], str, list, "Coordinate"]) -> "Coordinate": """ Creates a Coordinate instance from various input types. Args: value (Union[Tuple[float, ...], str, list, Coordinate]): The input value to convert. Can be a tuple, list, string, or another Coordinate instance. Returns: Coordinate: A Coordinate instance created from the input value. Raises: TypeError: If the input value is not a tuple, list, string, or Coordinate. """ if isinstance(value, cls): return value if isinstance(value, str): return cls.from_string(value) if isinstance(value, (tuple, list)): return cls.from_tuple(tuple(value)) raise TypeError("Unsupported coordinate type; expected tuple, list, str, or Coordinate")
[docs] def validate(self) -> bool: """ Validates the longitude, latitude, and optional altitude values of the point. Returns: bool: True if all coordinate values are valid. Raises: KMLValidationError: If longitude or latitude are not numeric, out of valid range, or if altitude is provided and is not a finite numeric value. """ try: lon = float(self.longitude) lat = float(self.latitude) except (TypeError, ValueError) as exc: raise KMLValidationError("Coordinate values must be numeric") from exc if not -180.0 <= lon <= 180.0: raise KMLValidationError(f"Invalid longitude: {lon}. Must be between -180.0 and 180.0") if not -90 <= lat <= 90: raise KMLValidationError(f"Invalid latitude: {lat}. Must be between -90.0 and 90.0") if self.altitude is not None: try: alt = float(self.altitude) except (TypeError, ValueError) as exc: raise KMLValidationError("Altitude must be numeric") from exc # pylint: disable=comparison-with-itself # NaN or infinity check if not (alt == alt and abs(alt) != float("inf")): raise KMLValidationError("Altitude must be a finite number") return True
[docs] def to_dict(self) -> dict[str, float]: """ Convert coordinate to dictionary representation. Returns: Dictionary with longitude, latitude, and altitude values. """ return {"longitude": self.longitude, "latitude": self.latitude, "altitude": self.altitude}
[docs] def get_coordinates(self) -> Optional["Coordinate"]: """ Return self as the coordinate representation. This method satisfies the HasCoordinates protocol, allowing Coordinate objects to be used directly in spatial calculations. Returns: Self (this Coordinate object) """ return self
[docs] def distance_to( self, other: Union["Coordinate", "Point", "Placemark", Tuple[float, float], list], unit: Optional["DistanceUnit"] = None, ) -> Optional[float]: """ Calculate distance to another spatial object. Args: other: Target object with coordinates (Coordinate, Point, Placemark, or tuple) unit: Distance unit (defaults to kilometers) Returns: Distance in specified units, or None if target has no coordinates Examples: >>> coord1 = Coordinate(longitude=-74.006, latitude=40.7128) # NYC >>> coord2 = Coordinate(longitude=-0.1276, latitude=51.5074) # London >>> distance = coord1.distance_to(coord2) >>> print(f"Distance: {distance:.1f} km") >>> # Different units >>> from kmlorm.spatial import DistanceUnit >>> distance_miles = coord1.distance_to(coord2, unit=DistanceUnit.MILES) """ # pylint: disable=import-outside-toplevel from ..spatial.calculations import SpatialCalculations, DistanceUnit if unit is None: unit = DistanceUnit.KILOMETERS result = SpatialCalculations.distance_between(self, other, unit) return cast(Optional[float], result)
[docs] def bearing_to( self, other: Union["Coordinate", "Point", "Placemark", Tuple[float, float], list] ) -> Optional[float]: """ Calculate bearing to another spatial object. Args: other: Target object with coordinates Returns: Initial bearing in degrees (0-360), or None if target has no coordinates 0° = North, 90° = East, 180° = South, 270° = West Examples: >>> coord1 = Coordinate(longitude=0, latitude=0) >>> coord2 = Coordinate(longitude=1, latitude=0) # Due east >>> bearing = coord1.bearing_to(coord2) >>> print(f"Bearing: {bearing:.1f}°") # Should be ~90° """ # pylint: disable=import-outside-toplevel from ..spatial.calculations import SpatialCalculations result = SpatialCalculations.bearing_between(self, other) return cast(Optional[float], result)
[docs] def midpoint_to( self, other: Union["Coordinate", "Point", "Placemark", Tuple[float, float], list] ) -> Optional["Coordinate"]: """ Find geographic midpoint to another spatial object. Args: other: Target object with coordinates Returns: Coordinate at the midpoint, or None if target has no coordinates Examples: >>> coord1 = Coordinate(longitude=0, latitude=0) >>> coord2 = Coordinate(longitude=2, latitude=2) >>> midpoint = coord1.midpoint_to(coord2) >>> print(f"Midpoint: ({midpoint.longitude}, {midpoint.latitude})") """ # pylint: disable=import-outside-toplevel from ..spatial.calculations import SpatialCalculations result = SpatialCalculations.midpoint(self, other) return cast(Optional["Coordinate"], result)
[docs] def __hash__(self) -> int: """ Hash for caching support in spatial calculations. Returns: Hash based on longitude, latitude, and altitude """ return hash((self.longitude, self.latitude, self.altitude))
[docs] class Point(KMLElement): """ Represents a standalone Point geometry in KML. Points can exist as standalone elements or within other containers. This class handles Points that are direct children of Document/Folder elements, separate from Points that exist within Placemarks. Attributes: coordinates: Tuple of (longitude, latitude, altitude) extrude: Whether the point is extruded to ground altitude_mode: How altitude is interpreted tessellate: Whether lines are tessellated """
[docs] def __init__(self, **kwargs: Any) -> None: """Initialize a Point with coordinates and properties.""" super().__init__(**kwargs) # Geometry properties self._coordinates: Optional["Coordinate"] = None if "coordinates" in kwargs and kwargs["coordinates"] is not None: self.coordinates = kwargs["coordinates"] self.extrude: bool = kwargs.get("extrude", False) self.altitude_mode: str = kwargs.get("altitude_mode", "clampToGround") self.tessellate: bool = kwargs.get("tessellate", False)
@property def coordinates(self) -> Optional["Coordinate"]: """Get coordinates tuple.""" return self._coordinates @coordinates.setter def coordinates(self, value: Union[Tuple[float, ...], str, "Coordinate", None]) -> None: """Set coordinates, handling various input formats.""" if value is None: self._coordinates = None return try: coord = Coordinate.from_any(value) except (ValueError, TypeError, IndexError) as exc: raise ValueError("Invalid coordinate value") from exc self._coordinates = coord @property def longitude(self) -> Optional[float]: """Get longitude from coordinates.""" return self.coordinates.longitude if self.coordinates else None @property def latitude(self) -> Optional[float]: """Get latitude from coordinates.""" return self.coordinates.latitude if self.coordinates else None @property def altitude(self) -> Optional[float]: """Get altitude from coordinates.""" return self.coordinates.altitude if self.coordinates else None
[docs] def has_coordinates(self) -> bool: """Check if point has valid coordinates.""" return bool( self.coordinates is not None and self.coordinates.latitude is not None and self.coordinates.longitude is not None )
[docs] def __str__(self) -> str: """String representation of the Point.""" if self.name: return f"Point: {self.name}" if self.coordinates: return f"Point: ({self.longitude}, {self.latitude})" return "Point: (no coordinates)"
[docs] def __repr__(self) -> str: """Detailed representation of the Point.""" return f"Point(id='{self.id}', name='{self.name}', " f"coordinates={self.coordinates})"
[docs] def validate(self) -> bool: """ Validate the point's coordinates. Returns: True if validation passes Raises: Exception: If coordinates are invalid """ # Call parent validation first super().validate() # Validate coordinates if present delegate to Coordinate if self.coordinates is not None: self.coordinates.validate() return True
[docs] def to_dict(self) -> dict[str, Any]: """ Convert point to dictionary representation. Returns: Dictionary containing point properties and coordinate data. """ base_dict = super().to_dict() base_dict.update( { "coordinates": self.coordinates.to_dict() if self.coordinates else None, "longitude": self.longitude, "latitude": self.latitude, "altitude": self.altitude, "extrude": self.extrude, "altitude_mode": self.altitude_mode, "tessellate": self.tessellate, } ) return base_dict
[docs] def get_coordinates(self) -> Optional["Coordinate"]: """ Return the coordinate representation of this point. This method satisfies the HasCoordinates protocol, allowing Point objects to be used directly in spatial calculations. Returns: The Coordinate object, or None if no coordinates are set """ return self.coordinates
[docs] def distance_to( self, other: Union["Coordinate", "Point", "Placemark", Tuple[float, float], list], unit: Optional["DistanceUnit"] = None, ) -> Optional[float]: """ Calculate distance to another spatial object. Args: other: Target object with coordinates unit: Distance unit (defaults to kilometers) Returns: Distance in specified units, or None if this point or target has no coordinates Examples: >>> point1 = Point(coordinates=(0, 0)) >>> point2 = Point(coordinates=(1, 1)) >>> distance = point1.distance_to(point2) """ if not self.coordinates: return None return self.coordinates.distance_to(other, unit)
[docs] def bearing_to( self, other: Union["Coordinate", "Point", "Placemark", Tuple[float, float], list] ) -> Optional[float]: """ Calculate bearing to another spatial object. Args: other: Target object with coordinates Returns: Initial bearing in degrees (0-360), or None if this point or target has no coordinates Examples: >>> point1 = Point(coordinates=(0, 0)) >>> point2 = Point(coordinates=(1, 0)) # Due east >>> bearing = point1.bearing_to(point2) # Should be ~90° """ if not self.coordinates: return None return self.coordinates.bearing_to(other)
[docs] def midpoint_to( self, other: Union["Coordinate", "Point", "Placemark", Tuple[float, float], list] ) -> Optional["Coordinate"]: """ Find geographic midpoint to another spatial object. Args: other: Target object with coordinates Returns: Coordinate at the midpoint, or None if this point or target has no coordinates Examples: >>> point1 = Point(coordinates=(0, 0)) >>> point2 = Point(coordinates=(2, 2)) >>> midpoint = point1.midpoint_to(point2) """ if not self.coordinates: return None return self.coordinates.midpoint_to(other)