"""Public entry point: add buildings from an OBJ file to a VoxCity model."""
from __future__ import annotations
import copy
import os
import numpy as np
from ..utils.logging import get_logger
from .units import validate_units
from .transform import build_placement_transform, grid_geom_from_voxcity
from .loader import load_obj_groups, select_groups_by_role, DEFAULT_WINDOW_KEYWORDS
from .voxelize import voxelize_mesh, voxelize_mesh_meshlib
from .integrate import stamp_buildings
from .windows import stamp_windows
_logger = get_logger(__name__)
[docs]
def add_buildings_from_obj(
voxcity,
obj_path,
anchor_lonlat,
anchor_elevation,
anchor_model_point=(0.0, 0.0, 0.0),
rotation=0.0,
move=(0.0, 0.0, 0.0),
units="m",
roles=None,
backend="trimesh",
z_up=True,
swap_yz=False,
overwrite=True,
auto_window=True,
window_keywords=None,
window_value=-16,
gridvis=False,
):
"""Voxelize buildings from an OBJ file and stamp them into a VoxCity model.
Loads geometry groups from *obj_path*, selects the groups whose resolved
role is ``"building"``, places them into the VoxCity domain using an
anchor lon/lat + elevation (plus optional rotation/translation/unit
scaling), voxelizes each building group, and stamps the resulting cells
into a copy of *voxcity*. The input *voxcity* is never mutated; this
function always returns a new object. See docs/rhino_obj_import.md for
the Rhino export guide and conventions.
Args:
voxcity: source VoxCity object. Read for its grid geometry
(``extras['rectangle_vertices']``), voxel shape, DEM, and
existing building grids; never mutated. A deep copy is made
internally and returned (with buildings stamped in), or
returned unmodified (still a deep copy) if no in-domain
geometry is found.
obj_path: path to the OBJ file to import. Must exist on disk;
checked before any other validation.
anchor_lonlat: ``(lon, lat)`` pair giving the geographic position
that ``anchor_model_point`` is placed at. Must have exactly 2
elements.
anchor_elevation: elevation (metres) of ``anchor_model_point`` in
the real world. Combined with the VoxCity DEM minimum to fix
the vertical (Z) placement of the model.
anchor_model_point: ``(x, y, z)`` point in OBJ model coordinates
(in *units*, pre-scale) that is pinned to ``anchor_lonlat`` /
``anchor_elevation``. Defaults to the model origin
``(0.0, 0.0, 0.0)``.
rotation: rotation in degrees applied to the model in the
horizontal (east/north) plane before placement. Positive values
rotate the model counter-clockwise; e.g. at ``rotation=90``,
model +X ends up pointing north. Defaults to ``0.0`` (no
rotation).
move: ``(east, north, up)`` offset in metres applied after
anchoring, e.g. for fine-tuning placement without changing the
anchor. Defaults to ``(0.0, 0.0, 0.0)`` (no offset).
units: model length unit, used to scale OBJ coordinates to metres.
One of ``"m"``, ``"cm"``, ``"mm"``, ``"ft"``, ``"in"``
(case-insensitive). Defaults to ``"m"``.
roles: optional ``{group_name: role}`` mapping overriding the
auto-detected role of a group. Recognized roles: ``"building"``
(voxelized solid as ``-3``), ``"window"`` (surface-voxelized and
stamped as glass ``-16`` on the building facade it touches), and
``"skip"`` (excluded). Matching is exact-string only. Groups absent
from this mapping are auto-classified (see ``auto_window``).
backend: voxelization backend. ``"trimesh"`` (default) uses
:func:`~voxcity.importer.voxelize.voxelize_mesh` (column z-ray
casting). ``"meshlib"`` uses the optional
:func:`~voxcity.importer.voxelize.voxelize_mesh_meshlib` SDF
backend, which requires the optional ``meshlib`` package to be
installed (see Raises below); this backend is best-effort and
not empirically verified against a real MeshLib install (see
that function's docstring for details).
z_up: whether the OBJ's vertical axis is already Z-up (Rhino's
convention), the default (``True``). When ``False``, the
loaded mesh is treated as Y-up and axes 1/2 (Y/Z) are swapped
before placement, equivalent to setting ``swap_yz=True``.
swap_yz: if ``True``, force an explicit Y/Z axis swap on the
loaded geometry regardless of ``z_up``. Defaults to ``False``.
The effective swap applied is ``swap_yz or (not z_up)``.
overwrite: if ``True`` (default), newly stamped building voxels
overwrite any existing non-empty voxel at that cell (and the
collision is counted/logged). If ``False``, only cells that are
currently empty are stamped; already-occupied cells are left
untouched.
auto_window: if ``True`` (default), groups whose name or assigned OBJ
material name contains a window keyword (see ``window_keywords``)
are auto-classified as ``"window"``. An explicit ``roles`` entry
always overrides this.
window_keywords: optional iterable of case-insensitive substrings used
for window auto-detection. ``None`` (default) uses
``("window", "glass", "glazing")``.
window_value: voxel code written for window cells. Defaults to ``-16``
(the standard glass code).
gridvis: if ``True``, after stamping, display a quick-look 2D
visualization of the post-import building height grid. Purely
a debugging aid; failures in the visualization step (including
a missing/broken plotting backend) are swallowed silently.
Defaults to ``False``.
Returns:
A new VoxCity object with the imported buildings stamped in (or an
unmodified deep copy of *voxcity* if no building-role geometry was
found, or none of it voxelized to cells inside the domain). The
*voxcity* argument passed in is never mutated.
Raises:
FileNotFoundError: if *obj_path* does not exist.
ValueError: if *units* is not one of the recognized unit strings,
if *backend* is not ``"trimesh"`` or ``"meshlib"``, or if
*anchor_lonlat* does not have exactly 2 elements.
ImportError: if ``backend="meshlib"`` is requested but the optional
``meshlib`` package is not installed.
"""
# --- validation (fail fast) ---
if not os.path.exists(os.fspath(obj_path)):
raise FileNotFoundError(f"OBJ file not found: {obj_path}")
validate_units(units)
if backend not in ("trimesh", "meshlib"):
raise ValueError(f"Unknown backend {backend!r}; expected 'trimesh' or 'meshlib'.")
if len(anchor_lonlat) != 2:
raise ValueError("anchor_lonlat must be (lon, lat).")
if backend == "meshlib":
try:
import meshlib # noqa: F401
except ImportError as e:
raise ImportError(
"backend='meshlib' requires the optional 'meshlib' package "
"(non-commercial license). Install it or use backend='trimesh'."
) from e
_logger.warning(
"backend='meshlib' is experimental and not empirically verified; "
"see voxelize_mesh_meshlib's docstring for details."
)
_voxelize = voxelize_mesh_meshlib if backend == "meshlib" else voxelize_mesh
apply_swap = swap_yz or (not z_up)
# --- load + role routing ---
resolved_window_keywords = (
DEFAULT_WINDOW_KEYWORDS if window_keywords is None else tuple(window_keywords)
)
groups = load_obj_groups(obj_path, swap_yz=apply_swap)
buckets = select_groups_by_role(
groups, roles=roles, auto_window=auto_window, window_keywords=resolved_window_keywords
)
building_groups = buckets["building"]
window_groups = buckets["window"]
if not building_groups:
_logger.warning("No building-role geometry found in %s; nothing imported.", obj_path)
return copy.deepcopy(voxcity)
# --- transform + voxelize ---
out = copy.deepcopy(voxcity)
M = build_placement_transform(
out, anchor_lonlat=anchor_lonlat, anchor_elevation=anchor_elevation,
anchor_model_point=anchor_model_point, rotation=rotation, move=move, units=units,
)
grid_shape = out.voxels.classes.shape
occupied_by_name = {}
for name, mesh in building_groups:
occ = _voxelize(mesh, M, grid_shape)
if len(occ):
occupied_by_name[name] = occ
if not occupied_by_name:
_logger.warning(
"Imported geometry voxelized to 0 cells inside the domain. Check "
"anchor_lonlat/anchor_elevation/rotation/move/units."
)
return out
# --- stamp + metadata ---
out = stamp_buildings(
out, occupied_by_name, overwrite=overwrite,
source=os.fspath(obj_path),
manifest_extra={
"anchor_lonlat": list(anchor_lonlat),
"anchor_elevation": float(anchor_elevation),
"anchor_model_point": list(anchor_model_point),
"rotation": float(rotation), "move": list(move),
"units": units, "backend": backend,
},
)
# --- windows: glass skin ---
if window_groups:
n_window = stamp_windows(out, window_groups, M, window_value=window_value)
manifests = out.extras.get("imported_buildings")
if manifests:
# stamp_buildings just appended exactly one entry above; safe to index -1.
manifests[-1]["n_window_voxels"] = int(n_window)
if gridvis:
try:
from ..visualizer.grids import visualize_numerical_grid
h = out.buildings.heights.copy()
h[h == 0] = np.nan
visualize_numerical_grid(h, float(out.voxels.meta.meshsize),
"building height (m) after import", cmap="viridis", label="Value")
except Exception:
pass
return out