Source code for openworld_radio_twin.simulation.native_geometry

"""Native building-shell placement and vertical-column sampling.

These operations consume source triangles, not footprint extrusions. Z remains
in its declared source datum until an explicit common building offset is applied.
"""

from __future__ import annotations

from functools import lru_cache

import numpy as np
import shapely
from pyproj import CRS, Transformer

from openworld_radio_twin.geodesy import LocalFrame
from openworld_radio_twin.models import BuildingGeometry

# Upper bound on simultaneously evaluated (triangle, grid point) pairs.
_PAIR_CHUNK = 2_000_000
_LEVEL_TOLERANCE = 1e-6


@lru_cache(maxsize=32)
def _horizontal_transform(crs: str) -> Transformer:
    return Transformer.from_crs(CRS(crs).to_2d(), "EPSG:4326", always_xy=True)


[docs] def geographic_vertices(geometry: BuildingGeometry) -> np.ndarray: """Return WGS84 XY with untouched source-datum Z (not ellipsoidal altitude).""" vertices = np.asarray(geometry.vertices, dtype=np.float64) longitude, latitude = _horizontal_transform(geometry.crs).transform( vertices[:, 0], vertices[:, 1] ) return np.column_stack((longitude, latitude, vertices[:, 2]))
[docs] def horizontal_enu_vertices(geometry: BuildingGeometry, frame: LocalFrame) -> np.ndarray: vertices = geographic_vertices(geometry) east, north, _ = frame.transformer.transform( vertices[:, 0], vertices[:, 1], np.zeros(len(vertices)) ) return np.column_stack((east, north, vertices[:, 2]))
[docs] def has_ground_faces(geometry: BuildingGeometry) -> bool: """Whether the source shell carries GroundSurface faces to anchor on.""" return "GroundSurface" in geometry.surface_types
[docs] def ground_projection(vertices: np.ndarray, geometry: BuildingGeometry): """Return the source ground contact used for anchoring. GroundSurface faces exclude roof overhangs. A shell without them, which 3DBAG emits for some parts, falls back to the projection of its whole shell so the building is still placed; the caller records that rule instead of dropping the building. """ triangles = np.asarray(geometry.triangles, dtype=np.intp).reshape(-1, 3) if has_ground_faces(geometry): kinds = np.asarray(geometry.surface_types, dtype=object) triangles = triangles[kinds == "GroundSurface"] polygons = projected_triangles(np.asarray(vertices, dtype=np.float64), triangles) if polygons.size == 0: raise ValueError("Native building geometry has no faces with a ground projection") return shapely.union_all(polygons)
[docs] def projected_triangles(vertices: np.ndarray, triangles: np.ndarray) -> np.ndarray: """Vertical projections of triangles with non-negligible area, as one geometry array.""" triangles = np.asarray(triangles, dtype=np.intp).reshape(-1, 3) if triangles.size == 0: return np.empty(0, dtype=object) polygons = shapely.polygons(np.asarray(vertices, dtype=np.float64)[triangles][:, :, :2]) return polygons[shapely.area(polygons) > 1e-10]
[docs] def column_intervals( vertices: np.ndarray, triangles: np.ndarray, east_axis: np.ndarray, north_axis: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Sample solid occupancy, including compound shells and enclosed cavities. Independent closed components can overlap or share a ground elevation. Pair intersections within each component before combining signed interior intervals; globally deduplicating elevations loses the multiplicity at shared surfaces. Outward-oriented components add solid volume; inward-oriented ones bound voids. """ vertices = np.asarray(vertices, dtype=np.float64) triangles = np.asarray(triangles, dtype=np.intp) components = _shell_components(vertices, triangles) if len(components) <= 1: return _shell_intervals(vertices, triangles, east_axis, north_axis) width = len(east_axis) keys: list[np.ndarray] = [] levels: list[np.ndarray] = [] changes: list[np.ndarray] = [] for faces in components: rows, columns, lower, upper = _shell_intervals(vertices, faces, east_axis, north_axis) points = vertices[faces] points = points - points.mean(axis=(0, 1)) # Stable signed volume in local coordinates. volume = np.einsum("ij,ij->i", points[:, 0], np.cross(points[:, 1], points[:, 2])).sum() if abs(volume) < 1e-12: raise ValueError("Native shell component has zero signed volume") sign = 1 if volume > 0 else -1 component_keys = rows.astype(np.int64) * width + columns keys.extend((component_keys, component_keys)) levels.extend((lower, upper)) changes.extend( (np.full(rows.size, sign, dtype=np.int64), np.full(rows.size, -sign, dtype=np.int64)) ) key_array = np.concatenate(keys) if keys else np.empty(0, dtype=np.int64) if key_array.size == 0: return (np.empty(0, dtype=np.intp), np.empty(0, dtype=np.intp), np.empty(0), np.empty(0)) level_array = np.concatenate(levels) change_array = np.concatenate(changes) order = np.lexsort((change_array, level_array, key_array)) result = [] for key, start, stop in _groups(key_array[order]): crossings = zip( level_array[order][start:stop], change_array[order][start:stop], strict=True ) merged: list[list[float]] = [] for z, change in crossings: if merged and z - merged[-1][0] <= _LEVEL_TOLERANCE: merged[-1][1] += change else: merged.append([float(z), int(change)]) winding, interval_start = 0, None for z, change in merged: next_winding = winding + change if winding == 0 and next_winding != 0: interval_start = z elif winding != 0 and next_winding == 0 and z - interval_start > _LEVEL_TOLERANCE: result.append((key // width, key % width, interval_start, z)) winding = next_winding if not result: return (np.empty(0, dtype=np.intp), np.empty(0, dtype=np.intp), np.empty(0), np.empty(0)) array = np.asarray(result) return array[:, 0].astype(np.intp), array[:, 1].astype(np.intp), array[:, 2], array[:, 3]
def _groups(sorted_keys: np.ndarray): """Yield (key, start, stop) for each run of equal values in a sorted array.""" if sorted_keys.size == 0: return boundaries = np.flatnonzero(sorted_keys[1:] != sorted_keys[:-1]) + 1 starts = np.concatenate(([0], boundaries)) stops = np.concatenate((boundaries, [sorted_keys.size])) for start, stop in zip(starts.tolist(), stops.tolist(), strict=True): yield int(sorted_keys[start]), start, stop def _shell_components(vertices: np.ndarray, triangles: np.ndarray) -> list[np.ndarray]: """Edge-connected shells; weld identical source positions only for connectivity.""" triangles = np.asarray(triangles, dtype=np.intp).reshape(-1, 3) if len(triangles) == 0: return [] _, index = np.unique(vertices, axis=0, return_inverse=True) faces = index.reshape(-1)[triangles] edges = np.concatenate((faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]), axis=0) edges = np.sort(edges, axis=1) owners = np.tile(np.arange(len(faces), dtype=np.intp), 3) _, edge_ids = np.unique(edges, axis=0, return_inverse=True) edge_ids = edge_ids.reshape(-1) order = np.argsort(edge_ids, kind="stable") sorted_ids = edge_ids[order] sorted_owners = owners[order] shared = sorted_ids[1:] == sorted_ids[:-1] pairs = np.column_stack((sorted_owners[:-1][shared], sorted_owners[1:][shared])) labels = np.arange(len(faces), dtype=np.intp) while pairs.size: lower = np.minimum(labels[pairs[:, 0]], labels[pairs[:, 1]]) updated = labels.copy() np.minimum.at(updated, pairs[:, 0], lower) np.minimum.at(updated, pairs[:, 1], lower) updated = updated[updated] # Pointer jumping keeps the propagation logarithmic. if np.array_equal(updated, labels): break labels = updated # Each label is the smallest face index of its component, so this order is the # order in which the components first appear. return [triangles[np.flatnonzero(labels == label)] for label in np.unique(labels)] def _shell_intervals( vertices: np.ndarray, triangles: np.ndarray, east_axis: np.ndarray, north_axis: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Intersect vertical grid rays with a closed triangle shell. Return row, column, lower Z, upper Z for each interior interval. Multiple intervals in one column retain openings and elevated structures. Triangle edge duplicates are collapsed; tangencies have zero solid extent. Memory is bounded by one building's intersected columns rather than a scene-wide cube. """ empty = (np.empty(0, dtype=np.intp), np.empty(0, dtype=np.intp), np.empty(0), np.empty(0)) vertices = np.asarray(vertices, dtype=np.float64) triangles = np.asarray(triangles, dtype=np.intp).reshape(-1, 3) east_axis = np.asarray(east_axis, dtype=np.float64) north_axis = np.asarray(north_axis, dtype=np.float64) width = len(east_axis) if triangles.size == 0 or width == 0 or north_axis.size == 0: return empty corners = vertices[triangles] a, b, c = corners[:, 0], corners[:, 1], corners[:, 2] denominator = (b[:, 1] - c[:, 1]) * (a[:, 0] - c[:, 0]) + (c[:, 0] - b[:, 0]) * ( a[:, 1] - c[:, 1] ) lower = corners.min(axis=1) upper = corners.max(axis=1) # Grid axes may run in either direction; candidate cells come from the sorted axes. east_order = np.argsort(east_axis, kind="stable") north_order = np.argsort(north_axis, kind="stable") sorted_east = east_axis[east_order] sorted_north = north_axis[north_order] column_start = np.searchsorted(sorted_east, lower[:, 0], side="left") column_stop = np.searchsorted(sorted_east, upper[:, 0], side="right") row_start = np.searchsorted(sorted_north, lower[:, 1], side="left") row_stop = np.searchsorted(sorted_north, upper[:, 1], side="right") column_counts = np.maximum(column_stop - column_start, 0) row_counts = np.maximum(row_stop - row_start, 0) counts = column_counts * row_counts counts[np.abs(denominator) < 1e-12] = 0 # Vertical walls do not cross a vertical ray. candidates = np.flatnonzero(counts) if candidates.size == 0: return empty keys: list[np.ndarray] = [] heights: list[np.ndarray] = [] for chunk in _chunks(candidates, counts[candidates]): chunk_counts = counts[chunk] total = int(chunk_counts.sum()) triangle_index = np.repeat(chunk, chunk_counts) offsets = np.repeat(np.cumsum(chunk_counts) - chunk_counts, chunk_counts) local = np.arange(total) - offsets columns_per_row = np.repeat(column_counts[chunk], chunk_counts) column_position = column_start[triangle_index] + local % columns_per_row row_position = row_start[triangle_index] + local // columns_per_row column_index = east_order[column_position] row_index = north_order[row_position] x = east_axis[column_index] y = north_axis[row_index] ta, tb, tc = a[triangle_index], b[triangle_index], c[triangle_index] chunk_denominator = denominator[triangle_index] u = ( (tb[:, 1] - tc[:, 1]) * (x - tc[:, 0]) + (tc[:, 0] - tb[:, 0]) * (y - tc[:, 1]) ) / chunk_denominator v = ( (tc[:, 1] - ta[:, 1]) * (x - tc[:, 0]) + (ta[:, 0] - tc[:, 0]) * (y - tc[:, 1]) ) / chunk_denominator inside = (u >= -1e-10) & (v >= -1e-10) & (u + v <= 1 + 1e-10) z = u * ta[:, 2] + v * tb[:, 2] + (1 - u - v) * tc[:, 2] keys.append(row_index[inside].astype(np.int64) * width + column_index[inside]) heights.append(z[inside]) key_array = np.concatenate(keys) if key_array.size == 0: return empty z_array = np.concatenate(heights) order = np.lexsort((z_array, key_array)) key_array = key_array[order] z_array = z_array[order] return _pair_levels(key_array, z_array, width) def _chunks(candidates: np.ndarray, counts: np.ndarray): """Split triangles into runs whose candidate pair total stays within the memory bound.""" cumulative = np.cumsum(counts) start = 0 while start < candidates.size: limit = cumulative[start] - counts[start] + _PAIR_CHUNK stop = int(np.searchsorted(cumulative, limit, side="right")) stop = max(stop, start + 1) yield candidates[start:stop] start = stop def _pair_levels(keys: np.ndarray, levels: np.ndarray, width: int): """Collapse near-duplicate crossings per column and pair them into intervals. Columns without near-duplicate crossings are paired in one vectorized pass; the few columns with crossings closer than the tolerance keep the sequential rule that compares each value with the last retained level. """ same_column = keys[1:] == keys[:-1] near = same_column & (levels[1:] - levels[:-1] <= _LEVEL_TOLERANCE) irregular_keys = np.unique(keys[1:][near]) irregular = np.isin(keys, irregular_keys) regular_keys = keys[~irregular] regular_levels = levels[~irregular] rows_out: list[np.ndarray] = [] lower_out: list[np.ndarray] = [] upper_out: list[np.ndarray] = [] if regular_keys.size: boundaries = np.flatnonzero(regular_keys[1:] != regular_keys[:-1]) + 1 starts = np.concatenate(([0], boundaries)) stops = np.concatenate((boundaries, [regular_keys.size])) sizes = stops - starts odd = sizes % 2 == 1 if np.any(odd & (sizes > 1)): raise ValueError("Native mesh has an unpaired vertical intersection; check topology") paired = ~odd # Single crossings are boundary tangencies with zero occupied height. pair_counts = sizes[paired] // 2 group_starts = starts[paired] pair_offsets = np.repeat(np.cumsum(pair_counts) - pair_counts, pair_counts) first = np.repeat(group_starts, pair_counts) + 2 * ( np.arange(pair_counts.sum()) - pair_offsets ) rows_out.append(regular_keys[first]) lower_out.append(regular_levels[first]) upper_out.append(regular_levels[first + 1]) for key, start, stop in _groups(keys[irregular]): merged: list[float] = [] for value in levels[irregular][start:stop].tolist(): if not merged or value - merged[-1] > _LEVEL_TOLERANCE: merged.append(value) if len(merged) % 2: if len(merged) == 1: continue raise ValueError("Native mesh has an unpaired vertical intersection; check topology") for lower, upper in zip(merged[::2], merged[1::2], strict=True): if upper - lower > _LEVEL_TOLERANCE: rows_out.append(np.asarray([key], dtype=np.int64)) lower_out.append(np.asarray([lower])) upper_out.append(np.asarray([upper])) if not rows_out: return (np.empty(0, dtype=np.intp), np.empty(0, dtype=np.intp), np.empty(0), np.empty(0)) key_array = np.concatenate(rows_out) lower_array = np.concatenate(lower_out) upper_array = np.concatenate(upper_out) order = np.lexsort((lower_array, key_array)) key_array = key_array[order] return ( (key_array // width).astype(np.intp), (key_array % width).astype(np.intp), lower_array[order], upper_array[order], )