Source code for src.terrain.advanced_viz

"""
Advanced terrain visualization features.

This module provides specialized visualization capabilities for terrain data:
- Drive-time isochrone curves (3D transportation analysis)
- Slope calculation using Horn's method
- 3D legend generation for Blender scenes

Migrated from legacy helpers.py with improvements.
"""

import logging
import numpy as np
import geopandas as gpd
import shapely
import shapely.geometry
from shapely.validation import make_valid
import bpy
import matplotlib.pyplot as plt
from scipy import ndimage
from tqdm import tqdm

logger = logging.getLogger(__name__)


[docs] def horn_slope(dem, window_size=3): # pylint: disable=unused-argument """ Calculate slope using Horn's method with NaN handling (GPU-accelerated). Horn's method is a standard GIS technique for calculating terrain slope using a 3x3 Sobel-like kernel. This implementation properly handles NaN values through interpolation. Uses PyTorch GPU acceleration when available (7x speedup on CUDA). Args: dem (np.ndarray): Input DEM array (2D) window_size (int): Reserved for future use (currently fixed at 3) Returns: np.ndarray: Slope magnitude array (same shape as input) Examples: >>> dem = np.random.rand(100, 100) * 1000 # Random elevation >>> slopes = horn_slope(dem) >>> print(f"Slope range: {slopes.min():.2f} to {slopes.max():.2f}") """ from src.terrain.gpu_ops import gpu_horn_slope logger.info("Computing Horn slope for DEM shape: %s", dem.shape) logger.info("Input DEM value range: %.2f to %.2f", np.nanmin(dem), np.nanmax(dem)) logger.info("Input NaN count: %d", np.sum(np.isnan(dem))) slope = gpu_horn_slope(dem) logger.info("Output slope value range: %.2f to %.2f", np.nanmin(slope), np.nanmax(slope)) logger.info("Output NaN count: %d", np.sum(np.isnan(slope))) return slope
[docs] def create_values_legend( # pylint: disable=unused-argument terrain_obj, values, mpp=30, *, colormap_name="mako_r", n_samples=10, label="Value", units="", scale=0.2, position_offset=(5, 0, 0), ): """ Create a 3D legend bar in the Blender scene. Generates a vertical bar with color gradient and text labels showing the value scale for the terrain visualization. Args: terrain_obj: Blender terrain mesh object (for positioning reference) values (np.ndarray): Value array to create legend for mpp (float): Meters per pixel (default: 30) colormap_name (str): Matplotlib colormap name (default: 'mako_r') n_samples (int): Number of labels on legend (default: 10) label (str): Legend title (default: 'Value') units (str): Unit string (e.g., 'meters', 'mm') (default: '') scale (float): Legend bar scale factor (default: 0.2) position_offset (tuple): (x, y, z) offset from terrain (default: (5, 0, 0)) Returns: tuple: (legend_object, text_objects_list) Examples: >>> legend_obj, labels = create_values_legend( ... terrain_mesh, elevation_data, ... label='Elevation', units='meters', n_samples=5 ... ) >>> print(f"Created legend with {len(labels)} labels") """ logger.info("Creating legend for %s with %d samples", label, n_samples) # Get value samples for labels valid_values = values[~np.isnan(values)] percentiles = np.linspace(5, 95, n_samples) samples = np.percentile(valid_values, percentiles) # Create the legend bar mesh bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, 0)) legend_obj = bpy.context.active_object legend_obj.name = f"{label}_Legend" # Scale it to be a vertical bar legend_obj.scale = (scale, scale, scale * 5) # Position it relative to terrain bounds = terrain_obj.bound_box terrain_width = max(b[0] for b in bounds) - min(b[0] for b in bounds) legend_obj.location = ( terrain_obj.location.x + terrain_width / 2 + position_offset[0], terrain_obj.location.y + position_offset[1], terrain_obj.location.z + position_offset[2], ) # Apply colormap material mat = bpy.data.materials.new(name=f"{label}_Legend_Material") mat.use_nodes = True nodes = mat.node_tree.nodes nodes.clear() # Create nodes for vertical gradient texture_coord = nodes.new("ShaderNodeTexCoord") separate_xyz = nodes.new("ShaderNodeSeparateXYZ") color_ramp = nodes.new("ShaderNodeValToRGB") principled = nodes.new("ShaderNodeBsdfPrincipled") output = nodes.new("ShaderNodeOutputMaterial") # Set up gradient colors from colormap cmap = plt.colormaps.get_cmap(colormap_name) for i, sample in enumerate(samples): if i < len(color_ramp.color_ramp.elements): color_ramp.color_ramp.elements[i].position = i / (len(samples) - 1) color = cmap(i / (len(samples) - 1)) color_ramp.color_ramp.elements[i].color = (*color[:3], 1.0) else: elem = color_ramp.color_ramp.elements.new(i / (len(samples) - 1)) color = cmap(i / (len(samples) - 1)) elem.color = (*color[:3], 1.0) # Connect nodes mat.node_tree.links.new(texture_coord.outputs["Object"], separate_xyz.inputs[0]) mat.node_tree.links.new(separate_xyz.outputs["Z"], color_ramp.inputs[0]) mat.node_tree.links.new(color_ramp.outputs["Color"], principled.inputs["Base Color"]) mat.node_tree.links.new(principled.outputs["BSDF"], output.inputs["Surface"]) # Assign material legend_obj.data.materials.append(mat) # Create text labels text_objects = [] for i, sample in enumerate(samples): z_pos = (i / (len(samples) - 1) - 0.5) * scale * 10 bpy.ops.object.text_add( location=( legend_obj.location.x + scale * 1.5, legend_obj.location.y, legend_obj.location.z + z_pos, ) ) text_obj = bpy.context.active_object text_obj.name = f"{label}_Label_{i}" # Set text content text_obj.data.body = f"{sample:.1f} {units}" text_obj.data.size = scale * 0.5 text_obj.data.align_x = "LEFT" text_objects.append(text_obj) logger.info("Created legend with %d labels", len(text_objects)) return legend_obj, text_objects