Source code for cityImage.angles

"""Angle helpers for cityImage graph semantics.

This module keeps the custom line-angle semantics used by ``dual_gdf`` and
legacy imageability workflows. There is nothing useful to delegate here:
generic trigonometry is tiny, while the important part is how two street
segments are oriented around their shared endpoint.

Three modes are preserved:

* ``vectors``: angle between the two oriented vectors at the shared endpoint;
* ``deflection``: turn/deflection angle along the full segments;
* ``angular_change``: turn/deflection using the local segment adjacent to the
  shared endpoint, useful for multi-vertex LineStrings.
"""

from __future__ import annotations

import math
from typing import Any

from shapely.geometry import LineString

VALID_CALCULATION_TYPES = {"vectors", "angular_change", "deflection"}


class Error(Exception):
    """Base class for angle exceptions."""


class AngleError(Error):
    """Raised when line geometries do not share an endpoint."""


def _dot(v_a: tuple[float, float], v_b: tuple[float, float]) -> float:
    """Return the two-dimensional dot product."""
    return v_a[0] * v_b[0] + v_a[1] * v_b[1]


def _round_coord(coord: Any, ndigits: int = 10) -> tuple[float, float]:
    """Return a 2D coordinate rounded for robust endpoint matching."""
    return (round(float(coord[0]), ndigits), round(float(coord[1]), ndigits))


def _coord_tuple(coord: Any) -> tuple[float, float]:
    """Return a 2D coordinate tuple without rounding."""
    return (float(coord[0]), float(coord[1]))


[docs] def get_coord_angle( origin: tuple[float, float], distance: float, angle: float ) -> tuple[float, float]: """Return coordinates at ``distance`` and bearing-like angle from an origin. The angle convention is the historical cityImage convention: degrees from the positive y-axis, not the mathematical positive x-axis. """ disp_x = distance * math.sin(math.radians(angle)) disp_y = distance * math.cos(math.radians(angle)) return (origin[0] + disp_x, origin[1] + disp_y)
def _full_segment(coords: list[Any]) -> tuple[tuple[float, float], tuple[float, float]]: """Return the full first-to-last segment.""" return (_coord_tuple(coords[0]), _coord_tuple(coords[-1])) def _local_start_segment(coords: list[Any]) -> tuple[tuple[float, float], tuple[float, float]]: """Return the segment adjacent to the first vertex.""" return (_coord_tuple(coords[0]), _coord_tuple(coords[1])) def _local_end_segment(coords: list[Any]) -> tuple[tuple[float, float], tuple[float, float]]: """Return the segment adjacent to the last vertex.""" return (_coord_tuple(coords[-2]), _coord_tuple(coords[-1])) def _oriented_lines( coords_a: list[Any], coords_b: list[Any], calculation_type: str, ) -> tuple[ tuple[tuple[float, float], tuple[float, float]], tuple[tuple[float, float], tuple[float, float]] ]: """Return the two oriented line segments used for angle calculation.""" start_a, end_a = _round_coord(coords_a[0]), _round_coord(coords_a[-1]) start_b, end_b = _round_coord(coords_b[0]), _round_coord(coords_b[-1]) if calculation_type == "angular_change": if end_a == end_b: return _local_end_segment(coords_a), tuple(reversed(_local_end_segment(coords_b))) if end_a == start_b: return _local_end_segment(coords_a), _local_start_segment(coords_b) if start_a == start_b: return tuple(reversed(_local_start_segment(coords_a))), _local_start_segment(coords_b) if start_a == end_b: return tuple(reversed(_local_start_segment(coords_a))), tuple( reversed(_local_end_segment(coords_b)) ) elif calculation_type == "deflection": full_a = _full_segment(coords_a) full_b = _full_segment(coords_b) if end_a == end_b: return full_a, tuple(reversed(full_b)) if end_a == start_b: return full_a, full_b if start_a == start_b: return tuple(reversed(full_a)), full_b if start_a == end_b: return tuple(reversed(full_a)), tuple(reversed(full_b)) else: # vectors full_a = _full_segment(coords_a) full_b = _full_segment(coords_b) if end_a == end_b: return tuple(reversed(full_a)), tuple(reversed(full_b)) if end_a == start_b: return tuple(reversed(full_a)), full_b if start_a == start_b: return full_a, full_b if start_a == end_b: return full_a, tuple(reversed(full_b)) raise AngleError("The lines do not intersect; provide lines that share an endpoint.") class Settings: """Store two LineString coordinate sets and their oriented comparison lines. This class is retained for backwards compatibility with earlier cityImage code that accessed ``Settings.lineA`` and ``Settings.lineB`` directly. """ def set_coordinates(self, coords: list[Any], prefix: str) -> None: """Store rounded start/end and neighbouring coordinates for a line.""" setattr(self, "x_origin" + prefix, float(f"{coords[0][0]:.10f}")) setattr(self, "y_origin" + prefix, float(f"{coords[0][1]:.10f}")) setattr(self, "x_second" + prefix, float(f"{coords[1][0]:.10f}")) setattr(self, "y_second" + prefix, float(f"{coords[1][1]:.10f}")) setattr(self, "x_destination" + prefix, float(f"{coords[-1][0]:.10f}")) setattr(self, "y_destination" + prefix, float(f"{coords[-1][1]:.10f}")) setattr(self, "x_second_last" + prefix, float(f"{coords[-2][0]:.10f}")) setattr(self, "y_second_last" + prefix, float(f"{coords[-2][1]:.10f}")) def set_conditions(self, calculation_type: str) -> None: """Set ``lineA`` and ``lineB`` for the requested angle semantics.""" self.lineA, self.lineB = _oriented_lines(self.coordsA, self.coordsB, calculation_type) def __init__(self, coordsA: list[Any], coordsB: list[Any], calculation_type: str): """Initialise coordinates and oriented comparison lines.""" self.coordsA = coordsA self.coordsB = coordsB self.set_coordinates(coordsA, "A") self.set_coordinates(coordsB, "B") self.set_conditions(calculation_type)
[docs] def angle_line_geometries( line_geometryA: LineString, line_geometryB: LineString, degree: bool = False, calculation_type: str = "vectors", ) -> float: """Compute the angle between two endpoint-connected LineStrings. Parameters ---------- line_geometryA, line_geometryB Shapely LineStrings sharing one endpoint. degree If True, return degrees. Otherwise return radians. calculation_type One of ``"vectors"``, ``"angular_change"``, or ``"deflection"``. """ if not isinstance(line_geometryA, LineString) or not isinstance(line_geometryB, LineString): raise TypeError("Both inputs must be shapely.geometry.LineString objects.") if calculation_type not in VALID_CALCULATION_TYPES: raise ValueError( f"Invalid calculation type. Choose one of: {sorted(VALID_CALCULATION_TYPES)}." ) coords_a = list(line_geometryA.coords) coords_b = list(line_geometryB.coords) if len(coords_a) < 2 or len(coords_b) < 2: raise ValueError("Both LineStrings must have at least two coordinates.") line_a, line_b = _oriented_lines(coords_a, coords_b, calculation_type) v_a = (line_a[0][0] - line_a[1][0], line_a[0][1] - line_a[1][1]) v_b = (line_b[0][0] - line_b[1][0], line_b[0][1] - line_b[1][1]) mag_a = _dot(v_a, v_a) ** 0.5 mag_b = _dot(v_b, v_b) ** 0.5 if mag_a == 0 or mag_b == 0: angle_rad = 0.0 else: cosine = _dot(v_a, v_b) / mag_a / mag_b cosine = max(-1.0, min(1.0, cosine)) angle_rad = math.acos(cosine) if degree: return math.degrees(angle_rad) % 360 return angle_rad