Source code for voxcity.importer.transform

"""Build the affine that maps OBJ model coordinates to VoxCity voxel indices.

Convention (rotation=0, axis-aligned domain):
    model +X -> +v (east  / array axis 1)
    model +Y -> +u (north / array axis 0)
    model +Z -> +k (up)
Positive `rotation` (degrees) rotates the model counter-clockwise in the
(east, north) plane so that, at rotation=90, model +X points north (+u).

The composed transform (right-to-left, applied to a column vector
``[x, y, z, 1]``)::

    M = Toff @ Sv @ T1 @ R @ S @ T0

  T0  translate model space so ``anchor_model_point`` becomes the origin.
  S   scale model units -> metres (``unit_scale(units)``).
  R   rotate the now-metric (x=east, y=north) vector by ``rotation`` degrees,
      then re-express it in the VoxCity domain's own (u, v) axes -- the domain
      itself may be rotated relative to true north (``domain_rotation``).
  T1  translate to the anchor's position in domain metres (``u_a, v_a``),
      apply the horizontal/vertical ``move`` offset, and shift the vertical
      datum so the DEM minimum sits at z=0 (matching the voxel grid's ground
      datum).
  Sv  scale metres -> voxel indices (divide by ``meshsize``) and add the +1
      vertical offset because the voxelizer seats buildings one voxel above
      the ground voxel (``ground_level = int(dem/voxel_size + 0.5) + 1``).
"""
from __future__ import annotations

import math
import numpy as np

from ..geoprocessor.raster.core import compute_grid_geometry
from ..utils.projector import GridProjector
from .units import unit_scale


[docs] def grid_geom_from_voxcity(voxcity): """Recover grid geometry from a VoxCity object via its rectangle_vertices.""" rv = (voxcity.extras or {}).get("rectangle_vertices") if not rv: raise ValueError( "VoxCity object has no extras['rectangle_vertices']; cannot georeference " "the import. Regenerate the model with get_voxcity (which stores it) or " "set voxcity.extras['rectangle_vertices'] manually." ) meshsize = float(voxcity.voxels.meta.meshsize) geom = compute_grid_geometry(rv, meshsize) if geom is None: raise ValueError("Could not compute grid geometry from rectangle_vertices.") return geom
def _domain_rotation_deg(geom) -> float: """Bearing (deg, clockwise from north) of the domain +u axis (side_1).""" u = np.asarray(geom["u_vec"], dtype=float) # (dlon, dlat) per metre # clockwise-from-north bearing of (east=dlon, north=dlat) return math.degrees(math.atan2(u[0], u[1]))
[docs] def build_placement_transform( voxcity, 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", ): """Return a 4x4 affine mapping model coords -> voxel index space (i, j, k).""" geom = grid_geom_from_voxcity(voxcity) meshsize = float(geom["meshsize_m"]) scale = unit_scale(units) # 1. translate model so anchor_model_point is the origin T0 = np.eye(4) T0[:3, 3] = -np.asarray(anchor_model_point, dtype=float) # 2. scale model units -> meters S = np.eye(4) S[0, 0] = S[1, 1] = S[2, 2] = scale # 3. horizontal rotation: model (x=east, y=north) -> domain (u, v) metres. # # Step A: rotate the model's (x, y) by `rotation` (theta) counter-clockwise # in the (east, north) plane (to_en), giving each model basis vector's # (e, n) = (east, north) components. # # Step B: re-express those (e, n) vectors in the domain's own (u, v) axes. # The domain's u-axis (side_1) has bearing `phi` (clockwise from true # north), so the domain (u, v) basis vectors expressed in (east, north) # are: # u_dir = (sin(phi), cos(phi)) # (east, north) components of +u # v_dir = (cos(phi), -sin(phi)) # (east, north) components of +v # Projecting an (e, n) vector onto those axes (dot product, since u_dir # and v_dir are orthonormal) gives its (u, v) coordinates: # u = e*sin(phi) + n*cos(phi) # v = e*cos(phi) - n*sin(phi) phi = math.radians(_domain_rotation_deg(geom)) theta = math.radians(float(rotation)) cos_t, sin_t = math.cos(theta), math.sin(theta) def to_en(x, y): return (x * cos_t - y * sin_t, x * sin_t + y * cos_t) sin_p, cos_p = math.sin(phi), math.cos(phi) # (e_x, n_x) = (east, north) components of the rotated model +X axis; # (e_y, n_y) = (east, north) components of the rotated model +Y axis. e_x, n_x = to_en(1.0, 0.0) e_y, n_y = to_en(0.0, 1.0) u_from_x = e_x * sin_p + n_x * cos_p v_from_x = e_x * cos_p - n_x * sin_p u_from_y = e_y * sin_p + n_y * cos_p v_from_y = e_y * cos_p - n_y * sin_p R = np.eye(4) R[0, 0] = u_from_x R[0, 1] = u_from_y R[1, 0] = v_from_x R[1, 1] = v_from_y R[2, 2] = 1.0 # 4. translate to anchor position in domain metres + move + vertical datum proj = GridProjector(geom) u_a, v_a = proj.lon_lat_to_uv_m(float(anchor_lonlat[0]), float(anchor_lonlat[1])) move_e, move_n, move_up = (float(m) for m in move) dem_min = float(np.min(voxcity.dem.elevation)) z_a = float(anchor_elevation) - dem_min + move_up T1 = np.eye(4) T1[0, 3] = u_a + move_n # north -> u axis (i) T1[1, 3] = v_a + move_e # east -> v axis (j) T1[2, 3] = z_a # 5. metres -> voxel index, plus +1 ground voxel offset on k Sv = np.eye(4) Sv[0, 0] = Sv[1, 1] = Sv[2, 2] = 1.0 / meshsize Toff = np.eye(4) Toff[2, 3] = 1.0 # ground offset (matches voxelizer ground_level = ... + 1) # compose: index = Toff @ Sv @ T1 @ R @ S @ T0 M = Toff @ Sv @ T1 @ R @ S @ T0 return M