Source code for cityImage.visibility3d

"""Optional 3D sight-line generation utilities for cityImage.

This module preserves the existing cityImage 3D sight-line workflow while
keeping it out of the lightweight core. Heavy optional dependencies such as
PyVista, Dask, psutil, and tqdm are imported lazily by the functions that need
them, so importing :mod:`cityImage` or resolving its public API does not require
the 3D stack to be installed.
"""

from __future__ import annotations

import gc
import threading
from concurrent.futures import ThreadPoolExecutor
from importlib import import_module
from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
from shapely.geometry import LineString, MultiPolygon, Point, Polygon
from shapely.strtree import STRtree

from .geometry import gdf_multipolygon_to_polygon
from .network_topology import consolidate_nodes

pd.set_option("display.precision", 3)

_VISIBILITY3D_OPTIONAL_DEPENDENCIES = {
    "dask": "dask",
    "psutil": "psutil",
    "pyvista": "pyvista",
    "tqdm": "tqdm",
    "vtk": "pyvista",  # VTK ships with pyvista; installing the extra provides both
}


def _import_optional_dependency(import_name: str):
    """Import an optional visibility3d dependency with a clear error message."""
    try:
        return import_module(import_name)
    except ImportError as exc:
        package_name = _VISIBILITY3D_OPTIONAL_DEPENDENCIES.get(import_name, import_name)
        raise ImportError(
            "cityImage visibility3d functionality requires optional dependency "
            f"'{package_name}'. Install the 'visibility3d' extra with: "
            'python -m pip install -e ".[visibility3d]"'
        ) from exc


def _require_visibility3d_dependencies(*import_names: str) -> None:
    """Raise a clear error if required optional 3D dependencies are missing."""
    if not import_names:
        import_names = tuple(_VISIBILITY3D_OPTIONAL_DEPENDENCIES)

    missing = []
    for import_name in import_names:
        try:
            import_module(import_name)
        except ImportError:
            missing.append(_VISIBILITY3D_OPTIONAL_DEPENDENCIES.get(import_name, import_name))

    if missing:
        raise ImportError(
            "cityImage visibility3d functionality requires optional dependencies: "
            f"{', '.join(missing)}. Install the 'visibility3d' extra with: "
            'python -m pip install -e ".[visibility3d]"'
        )


def _tqdm(*args, **kwargs):
    """Return tqdm.tqdm lazily."""
    return _import_optional_dependency("tqdm").tqdm(*args, **kwargs)


[docs] def compute_3d_sight_lines( nodes_gdf: gpd.GeoDataFrame, target_buildings_gdf: gpd.GeoDataFrame, obstructions_buildings_gdf: gpd.GeoDataFrame, simplified_target_buildings: gpd.GeoDataFrame, edges_gdf: gpd.GeoDataFrame, city_name: str, distance_along: float = 200, min_observer_target_distance: float = 300, height_relative_to_ground=False, sight_lines_chunk_size: int = 500000, consolidate: bool = False, consolidate_tolerance: float = 0.0, num_workers: int = 20, ): """Compute visible 3D sight lines between observer nodes and target buildings. The workflow samples target points along building roofs, generates candidate observer-target lines, removes lines that are too short, filters 2D obstructions, and checks remaining candidate lines with PyVista ray tracing. Large candidate sets are processed in chunks and written temporarily to GeoPackage files before being merged. Parameters ---------- nodes_gdf : geopandas.GeoDataFrame Observer nodes. Required columns are ``geometry``, ``x``, ``y``, ``nodeID``, and ``z``. target_buildings_gdf : geopandas.GeoDataFrame Buildings to use as visibility targets. Required columns are ``buildingID``, ``geometry``, ``height``, and optionally ``base``. obstructions_buildings_gdf : geopandas.GeoDataFrame Buildings to use as possible obstructions. Required columns are ``buildingID``, ``geometry``, ``height``, and optionally ``base``. simplified_target_buildings : geopandas.GeoDataFrame or None Optional simplified target outlines. When provided, detailed target buildings are mapped back to simplified geometries for final sight-line assignment. edges_gdf : geopandas.GeoDataFrame Network edges used only when ``consolidate=True``. city_name : str Prefix used for temporary chunk files. distance_along : float, default 200 Sampling distance along target-building roof edges. min_observer_target_distance : float, default 300 Minimum 2D distance allowed between observer and target. height_relative_to_ground : bool, default False Whether building heights are already relative to local ground/base height. sight_lines_chunk_size : int, default 500000 Maximum number of candidate sight lines processed per chunk. consolidate : bool, default False Whether to consolidate observer nodes before sight-line generation. consolidate_tolerance : float, default 0.0 Spatial tolerance used for node consolidation. num_workers : int, default 20 Number of parallel workers used for obstruction and mesh operations. Returns ------- geopandas.GeoDataFrame Visible 3D sight lines. If no visible lines are found, an empty GeoDataFrame with the obstruction CRS is returned. """ _require_visibility3d_dependencies() # Step 0: Prepare data observers, targets, obstructions_gdf = _prepare_3d_sight_lines( nodes_gdf, target_buildings_gdf, obstructions_buildings_gdf, distance_along=distance_along, simplified_buildings=simplified_target_buildings, consolidate=consolidate, consolidate_tolerance=consolidate_tolerance, edges_gdf=edges_gdf, height_relative_to_ground=height_relative_to_ground, ) num_observers = len(observers) num_targets = len(targets) projected_nr_sight_lines = num_observers * num_targets obstructions_sindex = obstructions_gdf.sindex # GeoPandas SpatialIndex wrapper # Meshes and their VTK ray-casting locators are built lazily, only for buildings that # actually turn up as 2D-obstruction candidates (usually a small fraction of the city). meshes = _MeshCache(obstructions_gdf) if projected_nr_sight_lines <= sight_lines_chunk_size: observer_chunks = [observers] else: observers_per_chunk = max(1, sight_lines_chunk_size // num_targets) num_chunks = int(np.ceil(num_observers / observers_per_chunk)) # Split by row position and index back with .iloc so each chunk stays a GeoDataFrame. # np.array_split(observers, ...) would coerce the frame to bare ndarrays, which later # break in filter_distance (chunk.drop(columns="geometry")). observer_chunks = [ observers.iloc[positions] for positions in np.array_split(np.arange(num_observers), num_chunks) ] out_prefix = "chunk_sight_lines" out_files = [] for n, chunk in enumerate(observer_chunks): print(f"Starting chunk {n + 1} of {len(observer_chunks)}") visibles = [] print(" 01 - Filtering by distance") potential_sight_lines = filter_distance(chunk, targets, min_observer_target_distance) if potential_sight_lines.empty: continue print(" 02 - Checking 2d obstructions") visible_2d, obstructed_2d = obstructions_2d( potential_sight_lines, obstructions_gdf, obstructions_sindex, num_workers=num_workers ) if not visible_2d.empty: visibles.append(visible_2d) print(" 03 - Checking 3d obstructions") visible_3d = pd.DataFrame() if not obstructed_2d.empty: import shapely # Vectorised 3D segment construction (observer/target are 3D Points). observer_xyz = shapely.get_coordinates( np.asarray(obstructed_2d["observer_geo"], dtype=object), include_z=True ) target_xyz = shapely.get_coordinates( np.asarray(obstructed_2d["target_geo"], dtype=object), include_z=True ) obstructed_2d["geometry"] = shapely.linestrings( np.stack([observer_xyz, target_xyz], axis=1) ) visible_3d = obstructions_3d( obstructed_2d, obstructions_gdf, "matchesIDs", meshes, simplified_target_buildings, num_workers=num_workers, ) if not visible_3d.empty: visibles.append(visible_3d) chunk_dir = Path("sight_lines_tmp") chunk_dir.mkdir(parents=True, exist_ok=True) # create folder if not existing # Finalize columns and export if visibles: cols_to_drop = ["visible", "z"] for df in visibles: df.drop( columns=[col for col in cols_to_drop if col in df.columns], inplace=True, errors="ignore", ) chunk_sight_lines = pd.concat(visibles, ignore_index=True) chunk_sight_lines = _finalize_sight_lines( chunk_sight_lines, nodes_gdf, consolidate, simplified_target_buildings ) # Build filename chunk_file = chunk_dir / f"{city_name}_{out_prefix}_{n}.gpkg" chunk_sight_lines.to_file(chunk_file) out_files.append(chunk_file) del chunk_sight_lines, visibles gc.collect() print(f"Chunk {n + 1} processed and exported: {chunk_file}") visibles = [] del chunk, potential_sight_lines, visible_2d, obstructed_2d, visible_3d gc.collect() print("All chunks processed.") tmp_sight_lines = gpd.GeoDataFrame(geometry=[], crs=obstructions_gdf.crs) if out_files: tmp_sight_lines = merge_gpkg_chunks_to_gdf(out_files, "matchesIDs") tmp_sight_lines.drop(["visible"], axis=1, errors="ignore", inplace=True) else: print("No visible sight-lines") return tmp_sight_lines if consolidate: print(" 04 - Final check") sight_lines = _last_check( tmp_sight_lines, obstructions_gdf, obstructions_sindex, meshes, nodes_gdf, num_workers=num_workers, ) else: sight_lines = tmp_sight_lines return sight_lines
## Preparation ########################### def _prepare_3d_sight_lines( nodes_gdf, target_buildings_gdf, obstructions_gdf, distance_along=200, simplified_buildings=None, consolidate=False, consolidate_tolerance=0.0, edges_gdf=None, height_relative_to_ground=False, ): """Prepare observers, target points, and obstruction geometries. Parameters ---------- nodes_gdf : geopandas.GeoDataFrame Observer nodes with ``geometry``, ``x``, ``y``, ``nodeID``, and ``z``. target_buildings_gdf : geopandas.GeoDataFrame Target buildings with ``buildingID``, ``geometry``, ``height``, and optionally ``base``. obstructions_gdf : geopandas.GeoDataFrame Buildings considered as potential obstructions. distance_along : float, default 200 Sampling distance along target-building roof edges. simplified_buildings : geopandas.GeoDataFrame or None, default None Optional simplified building outlines. consolidate : bool, default False Whether to consolidate observer nodes. consolidate_tolerance : float, default 0.0 Tolerance used for observer consolidation. edges_gdf : geopandas.GeoDataFrame or None, default None Network edges required when observer consolidation is enabled. height_relative_to_ground : bool, default False Whether building heights are already relative to local ground/base height. Returns ------- tuple ``(observer_points_gdf, target_points, obstructions_gdf)`` prepared for 3D sight-line generation. """ if simplified_buildings is None: simplified_buildings = gpd.GeoDataFrame(geometry=[], crs=target_buildings_gdf.crs) nodes_gdf = nodes_gdf[["geometry", "x", "y", "nodeID", "z"]].copy() nodes_gdf.loc[nodes_gdf["z"] < -50, "z"] = 2 nodes_gdf["geometry"] = gpd.points_from_xy(nodes_gdf["x"], nodes_gdf["y"], nodes_gdf["z"]) target_buildings_gdf = _prepare_buildings_gdf(target_buildings_gdf) obstructions_gdf = _prepare_buildings_gdf(obstructions_gdf) target_buildings_gdf = target_buildings_gdf[target_buildings_gdf.height > 5.0] target_points = _prepare_targets( target_buildings_gdf, distance_along, height_relative_to_ground ) if simplified_buildings is not None and not simplified_buildings.empty: target_points, obstructions_gdf = _use_simplified_buildings( target_points, obstructions_gdf, simplified_buildings ) else: target_points.drop("geometry", axis=1, inplace=True) if consolidate: observer_points_gdf = consolidate_nodes( nodes_gdf, edges_gdf, consolidate_edges_too=False, tolerance=consolidate_tolerance ) else: observer_points_gdf = nodes_gdf.copy() target_points.set_geometry("target_geo", inplace=True, crs=nodes_gdf.crs) observer_points_gdf["observer_geo"] = observer_points_gdf["geometry"] observer_points_gdf = observer_points_gdf.drop(["x", "y", "z"], axis=1) # NOTE: 3D solids are NOT built here. Eagerly extruding every obstruction (a full # city can have 80k+) used to dominate preparation time; _MeshCache now extrudes # lazily, only for buildings that actually appear as 2D-obstruction candidates. obstructions_gdf["geometry"] = obstructions_gdf["geometry"].apply(lambda geom: geom.exterior) return observer_points_gdf, target_points, obstructions_gdf def _prepare_buildings_gdf(buildings_gdf): """Clean and standardize a building GeoDataFrame for 3D processing. Parameters ---------- buildings_gdf : geopandas.GeoDataFrame Building footprints with ``buildingID``, ``geometry``, ``height``, and optionally ``base``. Returns ------- geopandas.GeoDataFrame Building table indexed by ``buildingID`` with non-null heights and a minimum base elevation of 1.0. """ # add a 'base' column to the buildings GeoDataFrame with a default value of 1.0, if not provided if "base" not in buildings_gdf.columns: buildings_gdf["base"] = 1.0 buildings_gdf["base"] = buildings_gdf["base"].where( buildings_gdf["base"] > 1.0, 1.0 ) # minimum base buildings_gdf = buildings_gdf[buildings_gdf["height"].notna()] buildings_gdf = buildings_gdf[["buildingID", "geometry", "height", "base"]].copy() buildings_gdf.index = buildings_gdf.buildingID buildings_gdf.index.name = None return buildings_gdf def _prepare_targets(target_buildings_gdf, distance_along, height_relative_to_ground=False): """Generate 3D target points along target-building roof perimeters. Parameters ---------- target_buildings_gdf : geopandas.GeoDataFrame Target buildings with ``geometry``, ``height``, and ``base`` columns. distance_along : float Sampling distance along each roof perimeter. height_relative_to_ground : bool, default False Whether height values are relative to local ground/base height. Returns ------- geopandas.GeoDataFrame Exploded target-point table with a ``target_geo`` geometry column. """ target_buildings = target_buildings_gdf.copy() target_buildings["geometry"] = target_buildings_gdf["geometry"].apply( lambda geom: ( geom if isinstance(geom, Polygon) else geom.convex_hull if geom.is_valid else geom.buffer(0) ) ) def extrude_roof(geometry, base, height): if isinstance(geometry, Polygon): return add_height_to_line(geometry.exterior, base, height) elif isinstance(geometry, MultiPolygon): return LineString( [ point for poly in geometry.geoms for point in add_height_to_line(poly.exterior, base, height).coords ] ) def add_height_to_line( exterior, base, height, height_relative_to_ground=height_relative_to_ground ): # create a new list of Point objects with the z-coordinate set to (height - base) return LineString([(coord[0], coord[1], height + base) for coord in exterior.coords]) # create the roof building_tops = target_buildings.apply( lambda row: extrude_roof(row.geometry, row.base, row.height), axis=1 ) target_buildings["target_geo"] = [ downsample_coords(line, distance_along) for line in building_tops ] # 3. explode into individual vertices → Points target_points = target_buildings.explode("target_geo", ignore_index=True) target_points["target_geo"] = target_points["target_geo"].apply(Point) return target_points
[docs] def downsample_coords(line, distance_along): """Sample representative coordinates along a LineString. Parameters ---------- line : shapely.geometry.LineString Source line geometry. distance_along : float Minimum accumulated distance between selected coordinates. Returns ------- list[tuple] Coordinates sampled along the line. If no coordinate reaches the requested spacing, the coordinate closest to the line midpoint is returned. """ coords = np.array(line.coords) if coords.shape[0] == 0: return [] selected, dist = [], 0.0 for i in range(1, len(coords)): seg_len = np.linalg.norm(coords[i] - coords[i - 1]) dist += seg_len if dist >= distance_along: selected.append(tuple(coords[i])) dist = 0.0 if not selected: mid = np.array(line.interpolate(line.length / 2.0).coords[0]) dists = np.linalg.norm(coords - mid, axis=1) selected = [tuple(coords[np.argmin(dists)])] return selected
def _use_simplified_buildings(target_points, obstructions_gdf, simplified_buildings): """ Associates each target point and obstruction polygon with a simplified building outline, then computes aggregated attributes for each simplified building. Parameters ---------- target_points : GeoDataFrame 3D points representing target locations on building roofs. obstructions_gdf : GeoDataFrame Building polygons considered as obstructions. simplified_buildings : GeoDataFrame Simplified building outlines. Returns ------- simplified_target_points : GeoDataFrame Simplified buildings with aggregated target information and 3D centroid points. simplified_obstructions : GeoDataFrame Simplified buildings with aggregated obstruction attributes. """ simplified_buildings = gdf_multipolygon_to_polygon(simplified_buildings) # Create a spatial index for the simplified buildings. STRtree.query returns # *positions* into the geometry array passed to the constructor, NOT index labels: # translate positions back to the frame's index explicitly (the previous # label-based .loc lookup only worked while the index happened to be a RangeIndex). simplified_tree = STRtree(simplified_buildings.geometry.values) simplified_labels = simplified_buildings.index.to_numpy() def assign_geometries_to_simplified_buildings(geometries): """First intersecting simplified-building label per input geometry (or None).""" geometries = np.asarray(geometries, dtype=object) # 'intersects' subsumes the previous within/touches/intersects triple. input_positions, tree_positions = simplified_tree.query(geometries, predicate="intersects") first_match = {} for geometry_position, tree_position in zip(input_positions, tree_positions, strict=False): if geometry_position not in first_match: first_match[geometry_position] = simplified_labels[tree_position] return [first_match.get(position) for position in range(len(geometries))] target_points["simplifiedID"] = assign_geometries_to_simplified_buildings( target_points["target_geo"] ) obstructions_gdf["simplifiedID"] = assign_geometries_to_simplified_buildings( obstructions_gdf["geometry"] ) # Function to get building IDs, average height, and average base of targets assigned to the same simplified building def get_attributes(simplifiedID, gdf): # Filter targets assigned to the simplified building detailed_buildings = gdf[gdf["simplifiedID"] == simplifiedID].copy() # Extract building IDs of these targets buildingIDs = ( detailed_buildings["buildingID"].tolist() if not detailed_buildings.empty else [] ) # Check if 'target_geo' exists and extract target geometries if "target_geo" in detailed_buildings.columns: target_GEOs = ( detailed_buildings["target_geo"].tolist() if not detailed_buildings.empty else [] ) else: target_GEOs = None # Calculate the average height and base of these targets height = detailed_buildings["height"].mean() if not detailed_buildings.empty else np.nan base = detailed_buildings["base"].mean() if not detailed_buildings.empty else np.nan return pd.Series([buildingIDs, target_GEOs, base, height]) simplified_target_points = simplified_buildings.copy() simplified_obstructions = simplified_buildings.copy() # Apply the function to each simplified building to calculate building IDs, average height, and base simplified_target_points[["buildingIDs", "target_GEOs", "base", "height"]] = ( simplified_buildings.index.to_series() .apply(get_attributes, gdf=target_points) .apply(pd.Series) ) # Apply the function to each simplified building to calculate building IDs, average height, and base simplified_obstructions[["buildingIDs", "target_GEOs", "base", "height"]] = ( simplified_buildings.index.to_series() .apply(get_attributes, gdf=obstructions_gdf) .apply(pd.Series) ) simplified_target_points = simplified_target_points[ simplified_target_points["buildingIDs"].apply(len) > 0 ] simplified_target_points = simplified_target_points[simplified_target_points["height"].notna()] simplified_target_points["buildingID"] = simplified_target_points.index simplified_obstructions.drop(["buildingIDs", "target_GEOs"], axis=1, inplace=True) simplified_obstructions = simplified_obstructions[simplified_obstructions["height"].notna()] simplified_obstructions["buildingID"] = simplified_obstructions.index simplified_target_points["target_geo"] = simplified_target_points.apply( lambda row: Point( row["geometry"].centroid.x, row["geometry"].centroid.y, row["height"] + row["base"] ), axis=1, ) simplified_target_points.drop( ["geometry", "base", "height", "FID"], axis=1, inplace=True, errors="ignore" ) return simplified_target_points, simplified_obstructions
[docs] def merge_gpkg_chunks_to_gdf(out_files, potential_obstructions_column): """Merge temporary GeoPackage sight-line chunks. Parameters ---------- out_files : sequence of path-like GeoPackage chunk files written by ``compute_3d_sight_lines``. potential_obstructions_column : str Kept for API compatibility with the chunking workflow. Returns ------- geopandas.GeoDataFrame Combined sight-line chunks. """ # Read each chunk one at a time and append to a list, then concat all together gdfs = [] for f in out_files: gdfs.append(gpd.read_file(f)) final_gdf = pd.concat(gdfs, ignore_index=True) return final_gdf
## Step 0 ###########################
[docs] def filter_distance(chunk, targets, min_observer_target_distance): """ Filters potential observer-target pairs by minimum distance and generates sight line geometries. Parameters ---------- chunk : GeoDataFrame Observer points (usually a subset/chunk of all observers). targets : GeoDataFrame Target points to be paired with observers. min_observer_target_distance : float Minimum allowed 2D distance between observer and target (in CRS units). Returns ------- potential_lines : GeoDataFrame GeoDataFrame of observer-target pairs meeting the distance threshold, with new 'geometry' (LineString) and coordinate columns. """ import shapely # Vectorised 2D coordinates (drops z) — one small array per side, never per pair. observer_attrs = chunk.drop(columns="geometry") observer_xy = shapely.get_coordinates(np.asarray(observer_attrs["observer_geo"], dtype=object)) target_xy = shapely.get_coordinates(np.asarray(targets["target_geo"], dtype=object)) # Broadcast the pairwise distance filter instead of materialising the cartesian # product with a pandas cross merge (which duplicated every attribute column — # including object-dtype Points — for all observer x target pairs and dominated # per-chunk runtime on real cities). dx = observer_xy[:, 0][:, None] - target_xy[None, :, 0] dy = observer_xy[:, 1][:, None] - target_xy[None, :, 1] keep = (dx * dx + dy * dy) >= float(min_observer_target_distance) ** 2 observer_pos, target_pos = np.nonzero(keep) print( "Num potential lines is", keep.size, "; kept after distance filter:", len(observer_pos), "; Nodes in chunk:", len(chunk), "Num Targets", len(targets), ) # Materialise only the surviving pairs, by row position (fast .take, no merge). left = observer_attrs.take(observer_pos).reset_index(drop=True) right = pd.DataFrame(targets).take(target_pos).reset_index(drop=True) potential_lines = pd.concat([left, right], axis=1) # Vectorised 2D line construction for the planimetric obstruction check. segments = ( np.stack([observer_xy[observer_pos], target_xy[target_pos]], axis=1) if len(observer_pos) else np.empty((0, 2, 2)) ) potential_lines["geometry"] = shapely.linestrings(segments) return potential_lines
## Step 1 ###########################
[docs] def obstructions_2d(potential_sight_lines, obstructions_gdf, obstructions_sindex, num_workers=20): """Split candidate sight lines by 2D planimetric obstruction status. Parameters ---------- potential_sight_lines : geopandas.GeoDataFrame Candidate sight lines with 2D line geometry. obstructions_gdf : geopandas.GeoDataFrame Obstruction geometries with a ``buildingID`` column. obstructions_sindex : geopandas.sindex.SpatialIndex Spatial index built from ``obstructions_gdf``. num_workers : int, default 20 Number of worker threads used by Dask. Returns ------- tuple[geopandas.GeoDataFrame, geopandas.GeoDataFrame] Visible lines and lines that require 3D obstruction testing. """ _require_visibility3d_dependencies("dask", "psutil") dask = _import_optional_dependency("dask") potential_sight_lines = potential_sight_lines.copy() sub_chunks = _define_batches(potential_sight_lines) tasks = [ dask.delayed(_find_obstructions_2d)(sub_chunk, obstructions_gdf, obstructions_sindex) for sub_chunk in sub_chunks ] results = dask.compute(*tasks, scheduler="threads", num_workers=num_workers) visibles = [r[0] for r in results] potentially_obstructeds = [r[1] for r in results] visible = pd.concat(visibles, ignore_index=True) potentially_obstructed = pd.concat(potentially_obstructeds, ignore_index=True) return visible, potentially_obstructed
def _define_batches( potential_sight_lines, min_size=50, max_size=25_000, mem_fraction=0.1, row_weight_kb=10_000 ): """ Splits a GeoDataFrame of sight lines into memory-aware batches for efficient parallel processing. Batch size is based on available system memory and estimated row weight. Parameters ---------- potential_sight_lines : GeoDataFrame Data to be divided into batches. min_size : int, optional Minimum number of rows per batch (default: 50). max_size : int, optional Maximum number of rows per batch (default: 25,000). The previous 2,000 cap was always binding (the memory estimate is very conservative), which fragmented the 2D check into thousands of tiny Dask tasks whose scheduling overhead dominated. mem_fraction : float, optional Fraction of available system memory to use for each batch (default: 0.1). row_weight_kb : int, optional Estimated memory usage per row, in kilobytes (default: 10,000). Yields ------ GeoDataFrame The next batch of rows. """ psutil = _import_optional_dependency("psutil") available_mem = psutil.virtual_memory().available rows_per_batch = int(available_mem * mem_fraction / row_weight_kb) rows_per_batch = max(min_size, min(rows_per_batch, max_size)) n = len(potential_sight_lines) for start in range(0, n, rows_per_batch): yield potential_sight_lines.iloc[start : start + rows_per_batch] def _find_obstructions_2d(sub_chunk, obstructions_gdf, obstructions_sindex): """Find 2D obstruction candidates for a batch of sight lines. Parameters ---------- sub_chunk : geopandas.GeoDataFrame Batch of candidate sight lines. obstructions_gdf : geopandas.GeoDataFrame Obstruction geometries with a ``buildingID`` column. obstructions_sindex : geopandas.sindex.SpatialIndex Spatial index built from ``obstructions_gdf``. Returns ------- tuple[geopandas.GeoDataFrame, geopandas.GeoDataFrame] Lines with no 2D crossings and lines with candidate obstruction IDs in ``matchesIDs``. """ import shapely # Vectorised spatial query – true intersections if backend supports predicate pairs = obstructions_sindex.query(sub_chunk.geometry, predicate="crosses") poly_ids = obstructions_gdf["buildingID"].to_numpy() line_pos, tree_pos = pairs[0], pairs[1] # Vertical prune: a candidate whose extruded top lies below the sight line's z-range # over the candidate's footprint can never block the line, so it is dropped before # any 3D ray casting. This is computed for ALL (line, candidate) pairs at once with # a Liang–Barsky clip of each 2D segment against the candidate's bounding box; every # uncertain case (missing z, degenerate interval, NaN) conservatively keeps the pair. if len(line_pos) and {"height", "base"}.issubset(obstructions_gdf.columns): endpoint_xyz = shapely.get_coordinates( np.asarray(sub_chunk.geometry, dtype=object), include_z=True ).reshape(-1, 2, 3) xy0, xy1 = endpoint_xyz[:, 0, :2], endpoint_xyz[:, 1, :2] z0, z1 = endpoint_xyz[:, 0, 2], endpoint_xyz[:, 1, 2] # The per-chunk pipeline passes planimetric (2D) lines whose z lives in the # observer_geo/target_geo 3D points; the consolidation re-check passes 3D lines. if np.isnan(z0).any() and "observer_geo" in sub_chunk.columns: z0 = shapely.get_coordinates( np.asarray(sub_chunk["observer_geo"], dtype=object), include_z=True )[:, 2] z1 = shapely.get_coordinates( np.asarray(sub_chunk["target_geo"], dtype=object), include_z=True )[:, 2] # Bounds/tops are identical for every batch and chunk; compute once per frame. # (Concurrent dask threads may race on first use — the value is idempotent.) prune_cache = obstructions_gdf.attrs.get("_ci_v3d_prune_cache") if prune_cache is None: prune_cache = ( obstructions_gdf.bounds.to_numpy(), (obstructions_gdf["height"] + obstructions_gdf["base"]).to_numpy(), ) obstructions_gdf.attrs["_ci_v3d_prune_cache"] = prune_cache bounds, tops = prune_cache p0 = xy0[line_pos] delta = xy1[line_pos] - p0 with np.errstate(divide="ignore", invalid="ignore"): t_low = (bounds[tree_pos, :2] - p0) / delta t_high = (bounds[tree_pos, 2:] - p0) / delta t_min = np.minimum(t_low, t_high) t_max = np.maximum(t_low, t_high) # Axis-parallel segments produce NaN slabs; make them non-restrictive (no prune). t_min = np.where(np.isnan(t_min), -np.inf, t_min) t_max = np.where(np.isnan(t_max), np.inf, t_max) t_enter = np.maximum(t_min.max(axis=1), 0.0) t_exit = np.minimum(t_max.min(axis=1), 1.0) pair_z0 = z0[line_pos] pair_dz = z1[line_pos] - pair_z0 z_lowest = np.minimum(pair_z0 + t_enter * pair_dz, pair_z0 + t_exit * pair_dz) cannot_block = ( (t_enter <= t_exit) & np.isfinite(z_lowest) & (z_lowest > tops[tree_pos] + 1e-6) ) line_pos = line_pos[~cannot_block] tree_pos = tree_pos[~cannot_block] # Group buildingIDs by line index obstruction_dict = {} for line_idx, poly_idx in zip(line_pos, tree_pos, strict=False): bid = poly_ids[poly_idx] obstruction_dict.setdefault(line_idx, []).append(bid) # Assign intersecting buildingIDs per row obstructionIDs = [ [int(bid) for bid in obstruction_dict.get(i, [])] for i in range(len(sub_chunk)) ] sub_chunk["matchesIDs"] = obstructionIDs mask = sub_chunk["matchesIDs"].map(len) > 0 visible = sub_chunk.loc[~mask].reset_index(drop=True).copy() obstructed = sub_chunk.loc[mask].reset_index(drop=True) return visible, obstructed ## Step 3 ###########################
[docs] def obstructions_3d( potential_sight_lines, obstructions_gdf, potential_obstructions_column, meshes, simplified_target_buildings, num_workers, ): """Check 3D visibility for sight lines with candidate obstructions. Parameters ---------- potential_sight_lines : geopandas.GeoDataFrame Candidate sight lines with 3D geometry and a candidate-obstruction column. obstructions_gdf : geopandas.GeoDataFrame Obstruction building table. Kept for workflow consistency. potential_obstructions_column : str Column containing lists of candidate obstruction building IDs. meshes : dict Mapping from building IDs to triangulated PyVista meshes. simplified_target_buildings : geopandas.GeoDataFrame or None Optional simplified target outlines. When provided, the current target building is removed from each candidate obstruction list. num_workers : int Number of worker threads used for ray tracing. Returns ------- geopandas.GeoDataFrame Sight lines that remain visible after 3D ray-tracing checks. """ _require_visibility3d_dependencies("tqdm") tqdm = _import_optional_dependency("tqdm").tqdm potential_sight_lines = potential_sight_lines.copy() # Remove the current buildingID from the list of potential obstructions for each sight line if simplified_target_buildings is not None and not simplified_target_buildings.empty: potential_sight_lines[potential_obstructions_column] = [ [bid for bid in candidates if bid != buildingID] for candidates, buildingID in zip( potential_sight_lines[potential_obstructions_column], potential_sight_lines["buildingID"], strict=False, ) ] import shapely # Vectorised endpoint extraction: every candidate line is a 2-vertex 3D segment. endpoint_xyz = shapely.get_coordinates( np.asarray(potential_sight_lines["geometry"], dtype=object), include_z=True ).reshape(-1, 2, 3) potential_sight_lines["observer_coords"] = list(map(tuple, endpoint_xyz[:, 0])) potential_sight_lines["target_coords"] = list(map(tuple, endpoint_xyz[:, 1])) with ThreadPoolExecutor(max_workers=num_workers) as executor: results = list( tqdm( executor.map( lambda row: _intervisibility(row, meshes, potential_obstructions_column), potential_sight_lines.itertuples(index=False), ), total=len(potential_sight_lines), desc="Checking intervisibility", ) ) potential_sight_lines["visible"] = results # Filter visible sight lines and append to the result potential_sight_lines = potential_sight_lines[potential_sight_lines["visible"]] return potential_sight_lines
class _MeshCache: """Fully lazy per-building 3D solids, triangulated meshes, and VTK OBB locators. Three performance properties compared to building everything upfront: - **solids are extruded on first use only**: eagerly extruding every obstruction of a full city (80k+ buildings) used to dominate *preparation* time, even though most buildings never appear as a 2D-crossing candidate; - meshes are triangulated on first use only, for the same reason; - each building gets a **cached, pre-built ``vtkOBBTree``** reused across all its ray checks. PyVista's ``mesh.ray_trace`` constructs a fresh OBB tree on *every call*, which dominated the 3D step when thousands of candidate lines crossed the same tall building. The obstruction frame passed in has exterior-ring geometries (as produced by ``_prepare_3d_sight_lines``) plus ``base``/``height``; a legacy ``building_3d`` column of pre-extruded solids is still honoured when present. Locators are built under a lock; afterwards ``IntersectWithLine`` traversals are read-only, so concurrent queries from the worker threads are safe. """ def __init__(self, obstructions_gdf): if "building_3d" in obstructions_gdf.columns: # legacy pre-extruded solids self._solids = dict( zip(obstructions_gdf["buildingID"], obstructions_gdf["building_3d"], strict=False) ) self._specs = {} else: self._solids = {} self._specs = { buildingID: (geometry, base, height) for buildingID, geometry, base, height in zip( obstructions_gdf["buildingID"], obstructions_gdf["geometry"], obstructions_gdf["base"], obstructions_gdf["height"], strict=False, ) } self._locators = {} self._lock = threading.Lock() def _get_solid(self, buildingID): """Return the (lazily extruded) 3D solid for a building, or None when unknown.""" solid = self._solids.get(buildingID) if solid is not None: return solid spec = self._specs.get(buildingID) if spec is None: return None geometry, base, height = spec # Exterior ring (post-prep) or a polygon (legacy caller): both expose coords. exterior_coords = ( geometry.exterior.coords if hasattr(geometry, "exterior") else geometry.coords ) solid = _extrude_exterior_coords( exterior_coords, base, height, extrude_from_sealevel=True, height_relative_to_ground=False, ) self._solids[buildingID] = solid return solid def get_locator(self, buildingID): """Return the (cached) OBB locator for a building, or None when unknown.""" locator = self._locators.get(buildingID) if locator is not None: return locator with self._lock: locator = self._locators.get(buildingID) # racing thread may have built it if locator is not None: return locator solid = self._get_solid(buildingID) if solid is None: return None pv = _import_optional_dependency("pyvista") vtk = _import_optional_dependency("vtk") mesh = pv.wrap(solid).extract_surface().triangulate() locator = vtk.vtkOBBTree() locator.SetDataSet(mesh) locator.BuildLocator() self._locators[buildingID] = locator return locator def _intervisibility(row, meshes, potential_obstructions_column): """Return whether one 3D sight line is unobstructed. Parameters ---------- row : namedtuple Row from the candidate sight-line table. It must contain observer and target coordinate attributes and the candidate-obstruction list. meshes : dict Mapping from building IDs to triangulated PyVista meshes. potential_obstructions_column : str Name of the attribute containing candidate obstruction IDs. Returns ------- bool ``True`` when no candidate mesh intersects the ray, otherwise ``False``. """ candidates = getattr(row, potential_obstructions_column) if len(candidates) == 0: return True p0 = row.observer_coords p1 = row.target_coords get_locator = getattr(meshes, "get_locator", None) if get_locator is not None: # _MeshCache path: pre-built OBB locators, one ray cast per candidate. for buildingID in candidates: locator = get_locator(buildingID) if locator is None: continue if locator.IntersectWithLine(p0, p1, None, None) != 0: return False return True # Plain mapping of pyvista meshes (kept for API compatibility with callers that # pass their own dict): ray_trace rebuilds its locator per call, so this is slow. for buildingID in candidates: mesh = meshes.get(buildingID) if mesh is None: continue _, intersections = mesh.ray_trace(p0, p1) if len(intersections) > 0: return False return True def _last_check( potential_sight_lines, obstructions_gdf, obstructions_sindex, meshes, nodes_gdf, num_workers ): """Run a final 2D/3D obstruction check on consolidated sight lines. Parameters ---------- potential_sight_lines : geopandas.GeoDataFrame Candidate sight lines to re-check. obstructions_gdf : geopandas.GeoDataFrame Building obstructions with 2D and 3D obstruction attributes. obstructions_sindex : geopandas.sindex.SpatialIndex Spatial index built from ``obstructions_gdf``. meshes : dict Mapping from building IDs to triangulated PyVista meshes. nodes_gdf : geopandas.GeoDataFrame Original observer nodes used to finalize sight-line geometries. num_workers : int Number of worker threads for 3D intervisibility checks. Returns ------- geopandas.GeoDataFrame Finalized sight lines that pass the last obstruction check. """ empty_simplified = gpd.GeoDataFrame(geometry=[], crs=nodes_gdf.crs) visible_2d, obstructed = _find_obstructions_2d( potential_sight_lines, obstructions_gdf, obstructions_sindex ) visible_3d = obstructions_3d( obstructed, obstructions_gdf, "matchesIDs", meshes, empty_simplified, num_workers=num_workers, ) sight_lines_tmp = pd.concat([visible_2d, visible_3d], ignore_index=True) return _finalize_sight_lines(sight_lines_tmp, nodes_gdf, False, empty_simplified) def _finalize_sight_lines(sight_lines_tmp, nodes_gdf, consolidate, simplified_buildings): """Finalize, clean, and deduplicate visible sight lines. Parameters ---------- sight_lines_tmp : pandas.DataFrame or geopandas.GeoDataFrame Temporary sight-line results with observer/target references. nodes_gdf : geopandas.GeoDataFrame Original observer nodes with ``nodeID``, ``geometry``, and ``z``. consolidate : bool Whether observer node consolidation was used. simplified_buildings : geopandas.GeoDataFrame or None Optional simplified building table used to explode simplified target assignments back to detailed building IDs. Returns ------- geopandas.GeoDataFrame Cleaned visible sight lines with ``nodeID``, ``buildingID``, ``geometry``, and ``length``. """ # Work on a copy: this frame is the caller's original nodes GeoDataFrame, and # replacing its geometry with 3D points in place would leak out of this function. nodes_gdf = nodes_gdf.copy() nodes_gdf["geometry"] = gpd.points_from_xy( nodes_gdf.geometry.x, nodes_gdf.geometry.y, nodes_gdf["z"] ) oldIDs_column = "old_nodeID" if consolidate: sight_lines_tmp = sight_lines_tmp.explode(column=oldIDs_column, ignore_index=True) sight_lines_tmp["nodeID"] = sight_lines_tmp[oldIDs_column] geom_map = nodes_gdf.set_index("nodeID")["geometry"] sight_lines_tmp["observer_geo"] = sight_lines_tmp["nodeID"].map(geom_map) if (simplified_buildings is not None) and (not simplified_buildings.empty): sight_lines_tmp["pair"] = sight_lines_tmp.apply( lambda row: list(zip(row["buildingIDs"], row["target_GEOs"], strict=False)), axis=1 ) sight_lines_tmp_exploded = sight_lines_tmp.explode("pair", ignore_index=True) sight_lines_tmp_exploded["buildingID"] = sight_lines_tmp_exploded["pair"].apply( lambda x: x[0] ) sight_lines_tmp_exploded["target_geo"] = sight_lines_tmp_exploded["pair"].apply( lambda x: x[1] ) sight_lines_tmp = sight_lines_tmp_exploded.copy() if "observer_geo" in sight_lines_tmp.columns: sight_lines_tmp["geometry"] = [ LineString([observer, target]) for observer, target in zip( sight_lines_tmp["observer_geo"], sight_lines_tmp["target_geo"], strict=False ) ] sight_lines_tmp = sight_lines_tmp.drop( [ oldIDs_column, "buildingIDs", "target_GEOs", "pair", "observer_geo", "target_geo", "observer_coords", "target_coords", "matchesIDs", ], errors="ignore", axis=1, ) sight_lines = gpd.GeoDataFrame(sight_lines_tmp, geometry="geometry", crs=nodes_gdf.crs) sight_lines["length"] = sight_lines.geometry.length if not consolidate: sight_lines = sight_lines.sort_values( ["buildingID", "nodeID", "length"], ascending=[False, False, False] ).drop_duplicates(["buildingID", "nodeID"], keep="first") sight_lines.reset_index(inplace=True, drop=True) sight_lines["nodeID"] = sight_lines["nodeID"].astype(int) sight_lines["buildingID"] = sight_lines["buildingID"].astype(int) return sight_lines
[docs] def polygon_2d_to_3d( building_polygon, base, height, extrude_from_sealevel=True, height_relative_to_ground=False ): """Extrude a 2D building polygon into a 3D PyVista solid. Parameters ---------- building_polygon : shapely.geometry.Polygon Building footprint to extrude. base : float Base elevation of the building. height : float Building height or absolute top elevation, depending on ``height_relative_to_ground`` and ``extrude_from_sealevel``. extrude_from_sealevel : bool, default True If ``True``, extrusion starts from zero. If ``False``, extrusion starts from ``base``. height_relative_to_ground : bool, default False If ``True``, ``height`` is interpreted as height above ``base``. If ``False``, ``height`` is interpreted as absolute height above sea level when appropriate. Returns ------- pyvista.PolyData Extruded 3D building solid. """ return _extrude_exterior_coords( building_polygon.exterior.coords, base, height, extrude_from_sealevel=extrude_from_sealevel, height_relative_to_ground=height_relative_to_ground, )
def _extrude_exterior_coords( exterior_coords, base, height, extrude_from_sealevel=True, height_relative_to_ground=False ): """Extrude an exterior coordinate ring into a 3D PyVista solid. Shared by :func:`polygon_2d_to_3d` (which passes a polygon's exterior) and :class:`_MeshCache` (whose obstruction geometries are already exterior rings). """ pv = _import_optional_dependency("pyvista") def reorient_coords(xy): value = 0 for i in range(len(xy)): x1, y1 = xy[i] x2, y2 = xy[(i + 1) % len(xy)] value += (x2 - x1) * (y2 + y1) if value > 0: return xy else: return xy[::-1] poly_points = list(exterior_coords) # Reorient the coordinates of the polygon xy = reorient_coords(poly_points) # Create 3D coordinates with the base height or "fictious" ground # Determine the starting height and adjust the extrusion height based on the flags if extrude_from_sealevel: # If extruding from zero (e.g., ground level), set the base height to 0 building_base = 0.0 if not height_relative_to_ground: # If height is computed from base (e.g., above ground level), add base to height height = height + base else: # If extruding from the given base, set the base height to the given base value building_base = base if height_relative_to_ground: # If height is computed from sea level (not the base), subtract the base from height height = height - base xyz_base = [(x, y, building_base) for x, y in xy] # Create faces of the polygon faces = [len(xyz_base), *range(len(xyz_base))] # Create the 3D polygon using pyvista polygon = pv.PolyData(xyz_base, faces=faces) # Extrude the 3D polygon to the specified height return polygon.extrude((0, 0, height), capping=True)