Source code for cityImage.geometry

"""Small geometry helpers used by cityImage-owned workflows.

The module contains only geometry operations that preserve cityImage-specific
network, topology, visibility, or building-schema semantics. Generic envelope,
convex hull, distance-nearest, and GeoDataFrame-construction helpers were
removed during the hard API cleanup; use GeoPandas/Shapely directly for those.
"""

from __future__ import annotations

from typing import Any

import geopandas as gpd
import pandas as pd
from shapely.geometry import LineString, MultiPoint, MultiPolygon, Point
from shapely.ops import linemerge, split


def _union_all(geometries: Any) -> Any:
    """Return a unary union with GeoPandas/Shapely version compatibility."""
    if hasattr(geometries, "union_all"):
        return geometries.union_all()
    return geometries.unary_union


[docs] def center_line(line_geometries: list[LineString]) -> LineString: """Compute a centre line from consistently oriented LineStrings. The helper is used when duplicate street edges with the same endpoints need to be collapsed while preserving an approximate middle geometry. Parameters ---------- line_geometries : list[shapely.geometry.LineString] LineStrings representing comparable edge geometries. Returns ------- shapely.geometry.LineString Average line geometry using the shortest common coordinate sequence. """ if len(line_geometries) < 2: raise ValueError("At least two LineStrings are required to compute a center line.") all_coords = [list(line.coords) for line in line_geometries] reference_start = all_coords[0][0] reference_end = all_coords[0][-1] def _squared_distance(a: tuple[float, ...], b: tuple[float, ...]) -> float: return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 # Orient every line against the first line. Reverse only when the reversed # endpoints match the reference direction better. This preserves parallel # same-direction lines and fixes opposite-direction duplicates. for i in range(1, len(all_coords)): coords = all_coords[i] same_direction_distance = _squared_distance(coords[0], reference_start) + _squared_distance( coords[-1], reference_end ) reversed_direction_distance = _squared_distance( coords[-1], reference_start ) + _squared_distance(coords[0], reference_end) if reversed_direction_distance < same_direction_distance: all_coords[i] = coords[::-1] min_length = min(len(coords) for coords in all_coords) all_coords = [coords[:min_length] for coords in all_coords] center_line_coords = [ ( sum(coords[i][0] for coords in all_coords) / len(all_coords), sum(coords[i][1] for coords in all_coords) / len(all_coords), ) for i in range(min_length) ] return LineString(center_line_coords)
[docs] def split_line_at_MultiPoint( line_geometry: LineString, intersections: MultiPoint | list[Point], z: float | None = 0.0, ) -> list[LineString]: """Split a LineString at one or more point intersections. Parameters ---------- line_geometry : shapely.geometry.LineString LineString to split. intersections : shapely.geometry.MultiPoint or list[Point] Points where the line should be split. z : float or None, default 0.0 Optional z value to assign to split segments. Pass ``None`` to preserve 2D geometries. Returns ------- list[shapely.geometry.LineString] Split line segments. """ line_geometry_tmp = line_geometry points = list(intersections.geoms) if hasattr(intersections, "geoms") else list(intersections) for point in points: new_line_coords = list(line_geometry_tmp.coords) for n, coord in enumerate(new_line_coords): if n == 0: continue line_section = LineString([Point(new_line_coords[n - 1]), Point(coord)]) if point.intersects(line_section) or line_section.distance(point) < 1e-8: new_line_coords.insert(n, point.coords[0]) break line_geometry_tmp = LineString(new_line_coords) split_points = MultiPoint(points) lines = split(line_geometry_tmp, split_points) lines_list = [line for line in lines.geoms] if z is not None: lines_list = [ LineString([(coords[0], coords[1], z) for coords in line.coords]) for line in lines_list ] return lines_list
[docs] def fix_multiparts_LineString_gdf(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: """Merge or explode MultiLineString geometries into LineString rows.""" gdf = gdf.copy() if "MultiLineString" not in gdf.geometry.type.unique(): return gdf condition = gdf.geometry.type == "MultiLineString" gdf.loc[condition, "geometry"] = gpd.GeoSeries( [linemerge(geometry) for geometry in gdf.loc[condition, "geometry"]], index=gdf.loc[condition].index, crs=gdf.crs, ) if "MultiLineString" not in gdf.geometry.type.unique(): return gdf multiline_gdf = gdf[gdf.geometry.type == "MultiLineString"].copy() line_gdf = gdf[gdf.geometry.type == "LineString"].copy() multiline_gdf = multiline_gdf.explode(ignore_index=True) return gpd.GeoDataFrame( pd.concat([line_gdf, multiline_gdf], ignore_index=True), geometry="geometry", crs=gdf.crs, )
[docs] def gdf_multipolygon_to_polygon( gdf: gpd.GeoDataFrame, columnID: str = "buildingID", ) -> gpd.GeoDataFrame: """Convert one-part MultiPolygons to Polygons and explode remaining MultiPolygons.""" def convert_multipolygon_to_polygon(geometry: Any) -> Any: if isinstance(geometry, MultiPolygon) and len(geometry.geoms) == 1: return geometry.geoms[0] return geometry out = gdf.copy() out["geometry"] = out["geometry"].apply(convert_multipolygon_to_polygon) if out["geometry"].apply(lambda geom: isinstance(geom, MultiPolygon)).any(): out = out.explode(index_parts=False, ignore_index=True) out = out.reset_index(drop=True) if columnID in out.columns: out[columnID] = out.index out["area"] = out.geometry.area return out