Source code for cityImage.visibility2d
"""Lightweight 2D visibility helpers for cityImage.
This module contains the core 2D visibility primitive used by landmark
structural scoring. It deliberately avoids heavy 3D/runtime dependencies such
as PyVista, Dask, psutil, and tqdm.
"""
from __future__ import annotations
import numpy as np
from shapely.geometry import LineString, MultiLineString, Point, Polygon
from shapely.ops import unary_union
from .angles import get_coord_angle
[docs]
def visibility_polygon2d(
building_geometry,
obstructions_gdf,
obstructions_sindex=None,
max_expansion_distance=600,
):
"""Return the area of the 2D advance-visibility polygon around a building.
Parameters
----------
building_geometry
Building footprint geometry.
obstructions_gdf
GeoDataFrame containing obstruction polygons.
obstructions_sindex
Accepted for backward compatibility with the previous signature. The
current implementation uses vectorised GeoPandas/Shapely operations.
max_expansion_distance
Maximum radial distance for visibility rays.
Returns
-------
float
Area of the visible polygon after subtracting the building footprint.
"""
del obstructions_sindex # kept only to preserve the old call signature
distance_along = 10
origin = building_geometry.centroid
building_geometry = (
building_geometry.convex_hull
if building_geometry.geom_type == "MultiPolygon"
else building_geometry
)
max_expansion_distance += origin.distance(building_geometry.envelope.exterior)
angles = np.arange(0, 360, distance_along)
coords = np.array(
[
get_coord_angle([origin.x, origin.y], distance=max_expansion_distance, angle=i)
for i in angles
]
)
lines = [LineString([origin, Point(x)]) for x in coords]
obstacles = obstructions_gdf[obstructions_gdf.crosses(unary_union(lines))]
obstacles = obstacles[obstacles.geometry != building_geometry]
obstacles = obstacles[~obstacles.geometry.within(building_geometry.convex_hull)]
if len(obstacles) > 0:
obstruction_union = unary_union(obstacles.geometry)
intersections = [line.intersection(obstruction_union) for line in lines]
clipped_lines = [
LineString([origin, Point(intersection.geoms[0].coords[0])])
if isinstance(intersection, MultiLineString) and not intersection.is_empty
else LineString([origin, Point(intersection.coords[0])])
if isinstance(intersection, LineString) and not intersection.is_empty
else LineString([origin, Point(intersection.coords[0])])
if isinstance(intersection, Point) and not intersection.is_empty
else line
for intersection, line in zip(intersections, lines, strict=False)
]
else:
clipped_lines = lines
polygon = Polygon(
[
[p.x, p.y]
for p in [origin] + [Point(line.coords[1]) for line in clipped_lines] + [origin]
]
)
visible_polygon = polygon.difference(building_geometry)
if visible_polygon.is_empty:
visible_polygon = polygon.buffer(0).difference(building_geometry)
return visible_polygon.area