Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/playground.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def load_terrain():
terrain = terrain[::2, ::2]

# Scale down elevation for visualization (optional)
terrain.data = terrain.data * 0.2
terrain.data = terrain.data * 0.1

# Ensure contiguous array before GPU transfer
terrain.data = np.ascontiguousarray(terrain.data)
Expand Down Expand Up @@ -155,6 +155,7 @@ def load_terrain():

# Launch interactive explore mode
terrain.rtx.explore(
mesh_type='voxel',
width=1024,
height=768,
render_scale=0.5
Expand Down
1 change: 1 addition & 0 deletions rtxpy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
from .rtx import (
RTX,

Check failure on line 2 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

Ruff (F401)

rtxpy/__init__.py:2:5: F401 `.rtx.RTX` imported but unused; consider removing, adding to `__all__`, or using a redundant alias
has_cupy,

Check failure on line 3 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

Ruff (F401)

rtxpy/__init__.py:3:5: F401 `.rtx.has_cupy` imported but unused; consider removing, adding to `__all__`, or using a redundant alias
get_device_count,

Check failure on line 4 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

Ruff (F401)

rtxpy/__init__.py:4:5: F401 `.rtx.get_device_count` imported but unused; consider removing, adding to `__all__`, or using a redundant alias
get_device_properties,

Check failure on line 5 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

Ruff (F401)

rtxpy/__init__.py:5:5: F401 `.rtx.get_device_properties` imported but unused; consider removing, adding to `__all__`, or using a redundant alias
list_devices,

Check failure on line 6 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

Ruff (F401)

rtxpy/__init__.py:6:5: F401 `.rtx.list_devices` imported but unused; consider removing, adding to `__all__`, or using a redundant alias
get_current_device,

Check failure on line 7 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

Ruff (F401)

rtxpy/__init__.py:7:5: F401 `.rtx.get_current_device` imported but unused; consider removing, adding to `__all__`, or using a redundant alias
)
from .mesh import (
triangulate_terrain,

Check failure on line 10 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

Ruff (F401)

rtxpy/__init__.py:10:5: F401 `.mesh.triangulate_terrain` imported but unused; consider removing, adding to `__all__`, or using a redundant alias
voxelate_terrain,

Check failure on line 11 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

Ruff (F401)

rtxpy/__init__.py:11:5: F401 `.mesh.voxelate_terrain` imported but unused; consider removing, adding to `__all__`, or using a redundant alias
write_stl,

Check failure on line 12 in rtxpy/__init__.py

View workflow job for this annotation

GitHub Actions / Lint & Import Check

Ruff (F401)

rtxpy/__init__.py:12:5: F401 `.mesh.write_stl` imported but unused; consider removing, adding to `__all__`, or using a redundant alias
load_glb,
load_mesh,
make_transform,
Expand Down
75 changes: 74 additions & 1 deletion rtxpy/accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,73 @@ def triangulate(self, geometry_id='terrain', scale=1.0,

return vertices, indices

def voxelate(self, geometry_id='terrain', scale=1.0, base_elevation=None,
pixel_spacing_x=1.0, pixel_spacing_y=1.0):
"""Voxelate the terrain into box-columns and add to the scene.

Creates a voxelized mesh where each raster cell becomes a rectangular
column extending from base_elevation up to the cell's elevation.

Parameters
----------
geometry_id : str, optional
ID for the terrain geometry. Default is 'terrain'.
scale : float, optional
Scale factor for elevation values. Default is 1.0.
base_elevation : float, optional
Z coordinate for the bottom of all columns. If None, uses
min(terrain) * scale so all columns have visible height.
pixel_spacing_x : float, optional
X spacing between pixels in world units. Default is 1.0.
pixel_spacing_y : float, optional
Y spacing between pixels in world units. Default is 1.0.

Returns
-------
tuple
(vertices, indices) - The voxelized mesh data as numpy arrays.
"""
from .mesh import voxelate_terrain
import numpy as np

H, W = self._obj.shape

# Auto-compute base elevation from terrain minimum
if base_elevation is None:
terrain_data = self._obj.data if hasattr(self._obj, 'data') else self._obj
if has_cupy:
import cupy
if isinstance(terrain_data, cupy.ndarray):
base_elevation = float(cupy.nanmin(terrain_data).get()) * scale
else:
base_elevation = float(np.nanmin(terrain_data)) * scale
else:
base_elevation = float(np.nanmin(terrain_data)) * scale

# Allocate buffers: 8 verts per cell, 12 tris per cell
num_vertices = H * W * 8
num_triangles = H * W * 12
vertices = np.zeros(num_vertices * 3, dtype=np.float32)
indices = np.zeros(num_triangles * 3, dtype=np.int32)

# Voxelate the terrain
voxelate_terrain(vertices, indices, self._obj, scale=scale,
base_elevation=base_elevation)

# Scale x,y coordinates to world units if pixel spacing != 1.0
if pixel_spacing_x != 1.0 or pixel_spacing_y != 1.0:
vertices[0::3] *= pixel_spacing_x
vertices[1::3] *= pixel_spacing_y

# Store pixel spacing for use in explore/viewshed
self._pixel_spacing_x = pixel_spacing_x
self._pixel_spacing_y = pixel_spacing_y

# Add to scene
self._rtx.add_geometry(geometry_id, vertices, indices)

return vertices, indices

def flyover(self, output_path, duration=30.0, fps=10.0, orbit_scale=0.6,
altitude_offset=500.0, fov=60.0, fov_range=None,
width=1280, height=720, sun_azimuth=225, sun_altitude=35,
Expand Down Expand Up @@ -744,7 +811,8 @@ def view(self, x, y, z, output_path, duration=10.0, fps=12.0,

def explore(self, width=800, height=600, render_scale=0.5,
start_position=None, look_at=None, key_repeat_interval=0.05,
pixel_spacing_x=None, pixel_spacing_y=None):
pixel_spacing_x=None, pixel_spacing_y=None,
mesh_type='tin'):
"""Launch an interactive terrain viewer with keyboard controls.

Opens a matplotlib window for terrain exploration with keyboard
Expand Down Expand Up @@ -776,6 +844,9 @@ def explore(self, width=800, height=600, render_scale=0.5,
pixel_spacing_y : float, optional
Y spacing between pixels in world units. If None, uses the value
from the last triangulate() call (default 1.0).
mesh_type : str, optional
Mesh generation method: 'tin' or 'voxel'.
Default is 'tin'.

Controls
--------
Expand All @@ -797,6 +868,7 @@ def explore(self, width=800, height=600, render_scale=0.5,
- T: Toggle shadows
- C: Cycle colormap
- F: Save screenshot
- M: Toggle minimap overlay
- H: Toggle help overlay
- X: Exit

Expand All @@ -823,4 +895,5 @@ def explore(self, width=800, height=600, render_scale=0.5,
rtx=self._rtx,
pixel_spacing_x=spacing_x,
pixel_spacing_y=spacing_y,
mesh_type=mesh_type,
)
38 changes: 28 additions & 10 deletions rtxpy/analysis/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import numpy as np

from .._cuda_utils import calc_dims
from ..mesh import triangulate_terrain
from ..mesh import triangulate_terrain, voxelate_terrain
from ..rtx import RTX, has_cupy

if has_cupy:
Expand Down Expand Up @@ -83,13 +83,13 @@ def generate_primary_rays(rays, x_coords, y_coords, H, W):
return 0


def prepare_mesh(raster, rtx=None):
def prepare_mesh(raster, rtx=None, mesh_type='tin'):
"""Prepare a triangle mesh from raster data and build the RTX acceleration structure.

This function handles the common pattern of:
1. Creating or reusing an RTX instance
2. Checking if the mesh needs rebuilding (via hash comparison)
3. Triangulating the terrain
3. Triangulating or voxelating the terrain
4. Building the GAS (Geometry Acceleration Structure)

Parameters
Expand All @@ -98,6 +98,8 @@ def prepare_mesh(raster, rtx=None):
Raster terrain data with coordinates.
rtx : RTX, optional
Existing RTX instance to reuse. If None, a new instance is created.
mesh_type : str, optional
Mesh generation method: 'tin' or 'voxel'. Default is 'tin'.

Returns
-------
Expand All @@ -109,22 +111,38 @@ def prepare_mesh(raster, rtx=None):
ValueError
If mesh generation or GAS building fails.
"""
valid_types = ('tin', 'voxel')
if mesh_type not in valid_types:
raise ValueError(
f"Invalid mesh_type '{mesh_type}'. Must be one of: {valid_types}"
)

if rtx is None:
rtx = RTX()

H, W = raster.shape

# Check if we need to rebuild the mesh
datahash = np.uint64(hash(str(raster.data.get())) % (1 << 64))
# Include mesh_type in hash so switching types triggers rebuild
datahash = np.uint64(hash(str(raster.data.get()) + mesh_type) % (1 << 64))
optixhash = np.uint64(rtx.getHash())

if optixhash != datahash:
numTris = (H - 1) * (W - 1) * 2
verts = cupy.empty(H * W * 3, np.float32)
triangles = cupy.empty(numTris * 3, np.int32)
if mesh_type == 'voxel':
numVerts = H * W * 8
numTris = H * W * 12
verts = cupy.empty(numVerts * 3, np.float32)
triangles = cupy.empty(numTris * 3, np.int32)

# Use terrain minimum as base elevation
base_elevation = float(cupy.nanmin(raster.data))
res = voxelate_terrain(verts, triangles, raster,
base_elevation=base_elevation)
else:
numTris = (H - 1) * (W - 1) * 2
verts = cupy.empty(H * W * 3, np.float32)
triangles = cupy.empty(numTris * 3, np.int32)
res = triangulate_terrain(verts, triangles, raster)

# Generate mesh from terrain
res = triangulate_terrain(verts, triangles, raster)
if res:
raise ValueError(f"Failed to generate mesh from terrain. Error code: {res}")

Expand Down
5 changes: 3 additions & 2 deletions rtxpy/analysis/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ def render(
observer_position: Optional[Tuple[float, float]] = None,
pixel_spacing_x: float = 1.0,
pixel_spacing_y: float = 1.0,
mesh_type: str = 'tin',
) -> np.ndarray:
"""Render terrain with a perspective camera for movie-quality visualization.

Expand Down Expand Up @@ -658,10 +659,10 @@ def render(
# Create a temporary raster with scaled elevations
scaled_raster = raster.copy(data=scaled_elevation)
# Don't reuse rtx when scaling - need fresh mesh
optix = prepare_mesh(scaled_raster, rtx=None)
optix = prepare_mesh(scaled_raster, rtx=None, mesh_type=mesh_type)
else:
scaled_raster = raster
optix = prepare_mesh(raster, rtx)
optix = prepare_mesh(raster, rtx, mesh_type=mesh_type)

# Scale camera position and look_at z coordinates
scaled_camera_position = (
Expand Down
Loading