diff --git a/examples/playground.py b/examples/playground.py index 2ddfc36..6cb94af 100644 --- a/examples/playground.py +++ b/examples/playground.py @@ -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) @@ -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 diff --git a/rtxpy/__init__.py b/rtxpy/__init__.py index 996e499..8ffd08b 100644 --- a/rtxpy/__init__.py +++ b/rtxpy/__init__.py @@ -8,6 +8,7 @@ ) from .mesh import ( triangulate_terrain, + voxelate_terrain, write_stl, load_glb, load_mesh, diff --git a/rtxpy/accessor.py b/rtxpy/accessor.py index 39ed950..731fbe1 100644 --- a/rtxpy/accessor.py +++ b/rtxpy/accessor.py @@ -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, @@ -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 @@ -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 -------- @@ -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 @@ -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, ) diff --git a/rtxpy/analysis/_common.py b/rtxpy/analysis/_common.py index 2db85c9..d25cf8a 100644 --- a/rtxpy/analysis/_common.py +++ b/rtxpy/analysis/_common.py @@ -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: @@ -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 @@ -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 ------- @@ -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}") diff --git a/rtxpy/analysis/render.py b/rtxpy/analysis/render.py index 5ce18d4..32b8535 100644 --- a/rtxpy/analysis/render.py +++ b/rtxpy/analysis/render.py @@ -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. @@ -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 = ( diff --git a/rtxpy/engine.py b/rtxpy/engine.py index 63756f4..d082120 100644 --- a/rtxpy/engine.py +++ b/rtxpy/engine.py @@ -43,6 +43,7 @@ class InteractiveViewer: - T: Toggle shadows - C: Cycle colormap - F: Save screenshot + - M: Toggle minimap overlay - H: Toggle help overlay - X: Exit @@ -55,7 +56,8 @@ class InteractiveViewer: def __init__(self, raster, width: int = 800, height: int = 600, render_scale: float = 0.5, key_repeat_interval: float = 0.05, rtx: 'RTX' = None, - pixel_spacing_x: float = 1.0, pixel_spacing_y: float = 1.0): + pixel_spacing_x: float = 1.0, pixel_spacing_y: float = 1.0, + mesh_type: str = 'tin'): """ Initialize the interactive viewer. @@ -81,6 +83,8 @@ def __init__(self, raster, width: int = 800, height: int = 600, Must match the spacing used when triangulating terrain. Default 1.0. pixel_spacing_y : float, optional Y spacing between pixels in world units. Default 1.0. + mesh_type : str, optional + Mesh generation method: 'tin' or 'voxel'. Default is 'tin'. """ if not has_cupy: raise ImportError( @@ -99,6 +103,7 @@ def __init__(self, raster, width: int = 800, height: int = 600, # Pixel spacing for coordinate conversion (world coords -> pixel indices) self.pixel_spacing_x = pixel_spacing_x self.pixel_spacing_y = pixel_spacing_y + self.mesh_type = mesh_type # GAS layer visibility tracking self._all_geometries = [] @@ -169,8 +174,21 @@ def __init__(self, raster, width: int = 800, height: int = 600, # State self.running = False self.show_help = True + self.show_minimap = True self.frame_count = 0 + # Minimap state (initialized in run() via _compute_minimap_background/_create_minimap) + self._minimap_ax = None + self._minimap_im = None + self._minimap_camera_dot = None + self._minimap_direction_line = None + self._minimap_fov_left = None + self._minimap_fov_right = None + self._minimap_observer_dot = None + self._minimap_background = None + self._minimap_scale_x = 1.0 + self._minimap_scale_y = 1.0 + # Held keys tracking for smooth simultaneous input self._held_keys = set() self._tick_interval = int(key_repeat_interval * 1000) # Convert to ms for timer @@ -210,6 +228,174 @@ def _get_look_at(self): """Get the current look-at point.""" return self.position + self._get_front() * 1000.0 + def _compute_minimap_background(self): + """Compute a CPU hillshade image for the minimap background. + + Downsamples terrain to max 200px on longest side, then uses + numpy gradient-based hillshade. Runs once at startup. + """ + H, W = self.terrain_shape + terrain_data = self.raster.data + if hasattr(terrain_data, 'get'): + terrain_np = terrain_data.get() + else: + terrain_np = np.asarray(terrain_data) + + # Downsample to max 200px on longest side + max_dim = 200 + longest = max(H, W) + if longest > max_dim: + scale = max_dim / longest + new_h = max(1, int(H * scale)) + new_w = max(1, int(W * scale)) + # Simple nearest-neighbor downsample via strided indexing + y_idx = np.linspace(0, H - 1, new_h).astype(int) + x_idx = np.linspace(0, W - 1, new_w).astype(int) + terrain_small = terrain_np[np.ix_(y_idx, x_idx)] + else: + terrain_small = terrain_np.copy() + new_h, new_w = H, W + + # Replace NaNs with median for gradient computation + mask = np.isnan(terrain_small) + if mask.any(): + terrain_small[mask] = np.nanmedian(terrain_small) + + # Compute hillshade using gradient + dy, dx = np.gradient(terrain_small) + # Sun from upper-left (azimuth=315, altitude=45) + az_rad = np.radians(315) + alt_rad = np.radians(45) + slope = np.sqrt(dx**2 + dy**2) + aspect = np.arctan2(-dy, dx) + shaded = (np.sin(alt_rad) * np.cos(np.arctan(slope)) + + np.cos(alt_rad) * np.sin(np.arctan(slope)) * + np.cos(az_rad - aspect)) + shaded = np.clip(shaded, 0, 1) + + self._minimap_background = shaded + self._minimap_scale_x = new_w / W # minimap pixels per terrain pixel + self._minimap_scale_y = new_h / H + + def _create_minimap(self): + """Create the minimap inset axes and persistent artists.""" + if self._minimap_background is None: + return + + # Create inset axes in bottom-right corner (~20% of figure width) + mm_h, mm_w = self._minimap_background.shape + aspect = mm_h / mm_w + ax_width = 0.2 + ax_height = ax_width * aspect * (self.width / self.height) + # Clamp height so it doesn't get too tall + ax_height = min(ax_height, 0.35) + margin = 0.02 + self._minimap_ax = self.fig.add_axes( + [1 - ax_width - margin, margin, ax_width, ax_height] + ) + self._minimap_ax.set_xticks([]) + self._minimap_ax.set_yticks([]) + for spine in self._minimap_ax.spines.values(): + spine.set_edgecolor('white') + spine.set_linewidth(0.8) + + # Display hillshade background (origin='upper' so row 0 is top = +Y) + self._minimap_im = self._minimap_ax.imshow( + self._minimap_background, cmap='gray', vmin=0, vmax=1, + aspect='auto', origin='upper' + ) + + # Camera position dot (red) + self._minimap_camera_dot = self._minimap_ax.scatter( + [], [], c='red', s=20, zorder=5, edgecolors='white', linewidths=0.5 + ) + + # Direction line (red) + self._minimap_direction_line, = self._minimap_ax.plot( + [], [], 'r-', linewidth=1.5, zorder=4 + ) + + # FOV cone edges (red, thinner) + self._minimap_fov_left, = self._minimap_ax.plot( + [], [], 'r-', linewidth=0.8, alpha=0.6, zorder=3 + ) + self._minimap_fov_right, = self._minimap_ax.plot( + [], [], 'r-', linewidth=0.8, alpha=0.6, zorder=3 + ) + + # Observer dot (magenta star) + self._minimap_observer_dot = self._minimap_ax.scatter( + [], [], c='magenta', s=50, marker='*', zorder=6, + edgecolors='white', linewidths=0.3 + ) + + self._minimap_ax.set_visible(self.show_minimap) + + def _update_minimap(self): + """Update minimap artists with current camera/observer state.""" + if self._minimap_ax is None: + return + + self._minimap_ax.set_visible(self.show_minimap) + if not self.show_minimap: + return + + H, W = self.terrain_shape + + # Convert camera world position to minimap pixel coords + # World coords: x = col * pixel_spacing_x, y = row * pixel_spacing_y + # Pixel indices: col = x / pixel_spacing_x, row = y / pixel_spacing_y + # Minimap coords: mx = col * scale_x, my = row * scale_y + cam_col = self.position[0] / self.pixel_spacing_x + cam_row = self.position[1] / self.pixel_spacing_y + + mx = cam_col * self._minimap_scale_x + # Flip Y: minimap origin='upper', so row 0 is displayed at top + # In world coords, +Y is increasing row. With origin='upper', + # imshow row 0 is at top, so minimap y = row * scale_y directly. + my = cam_row * self._minimap_scale_y + + # Update camera dot + self._minimap_camera_dot.set_offsets([[mx, my]]) + + # Direction line length in minimap pixels + mm_h, mm_w = self._minimap_background.shape + line_len = max(mm_h, mm_w) * 0.12 + + # Yaw: 0 = +X (right on minimap), 90 = +Y (down on minimap with origin='upper') + yaw_rad = np.radians(self.yaw) + dx = np.cos(yaw_rad) * line_len + dy = np.sin(yaw_rad) * line_len # +Y in world = +row = down in minimap + + self._minimap_direction_line.set_data([mx, mx + dx], [my, my + dy]) + + # FOV cone edges + half_fov = np.radians(self.fov / 2) + fov_len = line_len * 0.8 + + left_angle = yaw_rad + half_fov + right_angle = yaw_rad - half_fov + + lx = np.cos(left_angle) * fov_len + ly = np.sin(left_angle) * fov_len + rx = np.cos(right_angle) * fov_len + ry = np.sin(right_angle) * fov_len + + self._minimap_fov_left.set_data([mx, mx + lx], [my, my + ly]) + self._minimap_fov_right.set_data([mx, mx + rx], [my, my + ry]) + + # Observer dot + if self._observer_position is not None: + obs_x, obs_y = self._observer_position + obs_col = obs_x / self.pixel_spacing_x + obs_row = obs_y / self.pixel_spacing_y + omx = obs_col * self._minimap_scale_x + omy = obs_row * self._minimap_scale_y + self._minimap_observer_dot.set_offsets([[omx, omy]]) + self._minimap_observer_dot.set_visible(True) + else: + self._minimap_observer_dot.set_visible(False) + def _handle_key_press(self, event): """Handle key press - add to held keys or handle instant actions.""" key = event.key.lower() if event.key else '' @@ -254,6 +440,9 @@ def _handle_key_press(self, event): elif key == 'h': self.show_help = not self.show_help self._update_frame() + elif key == 'm': + self.show_minimap = not self.show_minimap + self._update_frame() # Viewshed controls elif key == 'o': @@ -737,6 +926,7 @@ def _render_frame(self): observer_position=observer_pos, pixel_spacing_x=self.pixel_spacing_x, pixel_spacing_y=self.pixel_spacing_y, + mesh_type=self.mesh_type, ) return img @@ -765,6 +955,8 @@ def _update_frame(self): else: self.help_text.set_visible(False) + self._update_minimap() + self.fig.canvas.draw_idle() self.fig.canvas.flush_events() @@ -885,7 +1077,7 @@ def run(self, start_position: Optional[Tuple[float, float, float]] = None, help_str = ( "WASD/Arrows: Move | Q/E: Up/Down | IJKL: Look | Scroll: Zoom | +/-: Speed\n" "G: Layers | N/P: Geometry | O: Place Observer | V: Toggle Viewshed | [/]: Height\n" - "T: Shadows | C: Colormap | F: Screenshot | H: Help | X: Exit" + "T: Shadows | C: Colormap | F: Screenshot | M: Minimap | H: Help | X: Exit" ) self.help_text = self.ax.text( 0.01, 0.02, help_str, @@ -898,6 +1090,10 @@ def run(self, start_position: Optional[Tuple[float, float, float]] = None, bbox=dict(boxstyle='round', facecolor='black', alpha=0.5) ) + # Initialize minimap + self._compute_minimap_background() + self._create_minimap() + # Connect event handlers self.fig.canvas.mpl_connect('key_press_event', self._handle_key_press) self.fig.canvas.mpl_connect('key_release_event', self._handle_key_release) @@ -937,7 +1133,8 @@ def explore(raster, width: int = 800, height: int = 600, look_at: Optional[Tuple[float, float, float]] = None, key_repeat_interval: float = 0.05, rtx: 'RTX' = None, - pixel_spacing_x: float = 1.0, pixel_spacing_y: float = 1.0): + pixel_spacing_x: float = 1.0, pixel_spacing_y: float = 1.0, + mesh_type: str = 'tin'): """ Launch an interactive terrain viewer. @@ -970,6 +1167,8 @@ def explore(raster, width: int = 800, height: int = 600, Must match the spacing used when triangulating terrain. Default 1.0. pixel_spacing_y : float, optional Y spacing between pixels in world units. Default 1.0. + mesh_type : str, optional + Mesh generation method: 'tin' or 'voxel'. Default is 'tin'. Controls -------- @@ -992,6 +1191,7 @@ def explore(raster, width: int = 800, height: int = 600, - T: Toggle shadows - C: Cycle colormap - F: Save screenshot + - M: Toggle minimap overlay - H: Toggle help overlay - X: Exit @@ -1014,5 +1214,6 @@ def explore(raster, width: int = 800, height: int = 600, rtx=rtx, pixel_spacing_x=pixel_spacing_x, pixel_spacing_y=pixel_spacing_y, + mesh_type=mesh_type, ) viewer.run(start_position=start_position, look_at=look_at) diff --git a/rtxpy/mesh.py b/rtxpy/mesh.py index 51a884c..a4fc048 100644 --- a/rtxpy/mesh.py +++ b/rtxpy/mesh.py @@ -133,6 +133,166 @@ def triangulate_terrain(verts, triangles, terrain, scale=1.0): return 0 +@cuda.jit +def _voxelate_terrain_gpu(verts, triangles, data, H, W, scale, base_z, stride): + """GPU kernel for terrain voxelation — one box-column per cell.""" + globalId = stride + cuda.grid(1) + if globalId < W * H: + h = globalId // W + w = globalId % W + + e = data[h, w] * scale + b = base_z + + # 8 vertices per cell, 3 floats each = 24 floats + vo = globalId * 24 + # Bottom: v0=(w,h,b) v1=(w+1,h,b) v2=(w+1,h+1,b) v3=(w,h+1,b) + verts[vo + 0] = w; verts[vo + 1] = h; verts[vo + 2] = b + verts[vo + 3] = w + 1; verts[vo + 4] = h; verts[vo + 5] = b + verts[vo + 6] = w + 1; verts[vo + 7] = h + 1; verts[vo + 8] = b + verts[vo + 9] = w; verts[vo + 10] = h + 1; verts[vo + 11] = b + # Top: v4=(w,h,e) v5=(w+1,h,e) v6=(w+1,h+1,e) v7=(w,h+1,e) + verts[vo + 12] = w; verts[vo + 13] = h; verts[vo + 14] = e + verts[vo + 15] = w + 1; verts[vo + 16] = h; verts[vo + 17] = e + verts[vo + 18] = w + 1; verts[vo + 19] = h + 1; verts[vo + 20] = e + verts[vo + 21] = w; verts[vo + 22] = h + 1; verts[vo + 23] = e + + # 12 triangles per cell, 3 indices each = 36 indices + to = globalId * 36 + v0 = np.int32(globalId * 8) + + # Top face (+Z) — CCW from above: v4, v5, v6, v7 + triangles[to + 0] = v0 + 4; triangles[to + 1] = v0 + 5; triangles[to + 2] = v0 + 6 + triangles[to + 3] = v0 + 4; triangles[to + 4] = v0 + 6; triangles[to + 5] = v0 + 7 + # Bottom face (-Z) — CCW from below: v0, v3, v2, v1 + triangles[to + 6] = v0 + 0; triangles[to + 7] = v0 + 3; triangles[to + 8] = v0 + 2 + triangles[to + 9] = v0 + 0; triangles[to + 10] = v0 + 2; triangles[to + 11] = v0 + 1 + # Front face (-Y) — v0, v1, v5, v4 + triangles[to + 12] = v0 + 0; triangles[to + 13] = v0 + 1; triangles[to + 14] = v0 + 5 + triangles[to + 15] = v0 + 0; triangles[to + 16] = v0 + 5; triangles[to + 17] = v0 + 4 + # Back face (+Y) — v2, v3, v7, v6 + triangles[to + 18] = v0 + 2; triangles[to + 19] = v0 + 3; triangles[to + 20] = v0 + 7 + triangles[to + 21] = v0 + 2; triangles[to + 22] = v0 + 7; triangles[to + 23] = v0 + 6 + # Right face (+X) — v1, v2, v6, v5 + triangles[to + 24] = v0 + 1; triangles[to + 25] = v0 + 2; triangles[to + 26] = v0 + 6 + triangles[to + 27] = v0 + 1; triangles[to + 28] = v0 + 6; triangles[to + 29] = v0 + 5 + # Left face (-X) — v3, v0, v4, v7 + triangles[to + 30] = v0 + 3; triangles[to + 31] = v0 + 0; triangles[to + 32] = v0 + 4 + triangles[to + 33] = v0 + 3; triangles[to + 34] = v0 + 4; triangles[to + 35] = v0 + 7 + + +@nb.njit(parallel=True) +def _voxelate_terrain_cpu(verts, triangles, data, H, W, scale, base_z): + """CPU implementation of terrain voxelation using numba.""" + for h in nb.prange(H): + for w in range(W): + globalId = h * W + w + + e = data[h, w] * scale + b = base_z + + # 8 vertices per cell, 3 floats each = 24 floats + vo = globalId * 24 + # Bottom: v0=(w,h,b) v1=(w+1,h,b) v2=(w+1,h+1,b) v3=(w,h+1,b) + verts[vo + 0] = w; verts[vo + 1] = h; verts[vo + 2] = b + verts[vo + 3] = w + 1; verts[vo + 4] = h; verts[vo + 5] = b + verts[vo + 6] = w + 1; verts[vo + 7] = h + 1; verts[vo + 8] = b + verts[vo + 9] = w; verts[vo + 10] = h + 1; verts[vo + 11] = b + # Top: v4=(w,h,e) v5=(w+1,h,e) v6=(w+1,h+1,e) v7=(w,h+1,e) + verts[vo + 12] = w; verts[vo + 13] = h; verts[vo + 14] = e + verts[vo + 15] = w + 1; verts[vo + 16] = h; verts[vo + 17] = e + verts[vo + 18] = w + 1; verts[vo + 19] = h + 1; verts[vo + 20] = e + verts[vo + 21] = w; verts[vo + 22] = h + 1; verts[vo + 23] = e + + # 12 triangles per cell, 3 indices each = 36 indices + to = globalId * 36 + v0 = np.int32(globalId * 8) + + # Top face (+Z) — CCW from above: v4, v5, v6, v7 + triangles[to + 0] = v0 + 4; triangles[to + 1] = v0 + 5; triangles[to + 2] = v0 + 6 + triangles[to + 3] = v0 + 4; triangles[to + 4] = v0 + 6; triangles[to + 5] = v0 + 7 + # Bottom face (-Z) — CCW from below: v0, v3, v2, v1 + triangles[to + 6] = v0 + 0; triangles[to + 7] = v0 + 3; triangles[to + 8] = v0 + 2 + triangles[to + 9] = v0 + 0; triangles[to + 10] = v0 + 2; triangles[to + 11] = v0 + 1 + # Front face (-Y) — v0, v1, v5, v4 + triangles[to + 12] = v0 + 0; triangles[to + 13] = v0 + 1; triangles[to + 14] = v0 + 5 + triangles[to + 15] = v0 + 0; triangles[to + 16] = v0 + 5; triangles[to + 17] = v0 + 4 + # Back face (+Y) — v2, v3, v7, v6 + triangles[to + 18] = v0 + 2; triangles[to + 19] = v0 + 3; triangles[to + 20] = v0 + 7 + triangles[to + 21] = v0 + 2; triangles[to + 22] = v0 + 7; triangles[to + 23] = v0 + 6 + # Right face (+X) — v1, v2, v6, v5 + triangles[to + 24] = v0 + 1; triangles[to + 25] = v0 + 2; triangles[to + 26] = v0 + 6 + triangles[to + 27] = v0 + 1; triangles[to + 28] = v0 + 6; triangles[to + 29] = v0 + 5 + # Left face (-X) — v3, v0, v4, v7 + triangles[to + 30] = v0 + 3; triangles[to + 31] = v0 + 0; triangles[to + 32] = v0 + 4 + triangles[to + 33] = v0 + 3; triangles[to + 34] = v0 + 4; triangles[to + 35] = v0 + 7 + + +def voxelate_terrain(verts, triangles, terrain, scale=1.0, base_elevation=0.0): + """Convert a 2D terrain array into a voxelized box-column mesh. + + Each cell in the terrain becomes a rectangular column (box) extending from + base_elevation up to the cell's elevation. Each box has 8 vertices and 12 + triangles (6 faces x 2 triangles). All triangle windings are CCW for + outward-facing normals. + + Parameters + ---------- + verts : array-like + Output vertex buffer of shape (H * W * 8 * 3,) as float32. + triangles : array-like + Output triangle index buffer of shape (H * W * 12 * 3,) as int32. + terrain : array-like + 2D array of elevation values with shape (H, W). Can be a numpy array, + cupy array, or xarray DataArray. + scale : float, optional + Scale factor applied to elevation values. Default is 1.0. + base_elevation : float, optional + Z coordinate for the bottom of all columns. Default is 0.0. + + Returns + ------- + int + 0 on success. + + Notes + ----- + Produces ~15x more geometry than triangulate_terrain (240 bytes/cell vs 16). + Adjacent boxes share coincident faces which are not removed; OptiX handles + these efficiently. NaN elevations produce degenerate zero-height triangles. + """ + if hasattr(terrain, 'dims') and hasattr(terrain, 'coords'): + data = terrain.data + else: + data = terrain + H, W = terrain.shape + + base_z = np.float32(base_elevation) + + if isinstance(data, np.ndarray): + _voxelate_terrain_cpu(verts, triangles, data, H, W, scale, base_z) + elif has_cupy and isinstance(data, cupy.ndarray): + jobSize = H * W + blockdim = 1024 + griddim = (jobSize + blockdim - 1) // 1024 + d = 100 + offset = 0 + while jobSize > 0: + batch = min(d, griddim) + _voxelate_terrain_gpu[batch, blockdim]( + verts, triangles, data, H, W, scale, base_z, offset + ) + offset += batch * blockdim + jobSize -= batch * blockdim + else: + raise TypeError( + f"Unsupported terrain data type: {type(data)}. " + "Expected numpy.ndarray or cupy.ndarray." + ) + + return 0 + + @nb.jit(nopython=True) def _fill_stl_contents(content, verts, triangles, numTris): """Fill STL binary content from mesh data.""" diff --git a/rtxpy/tests/test_mesh.py b/rtxpy/tests/test_mesh.py index 3ee0f24..81e9b40 100644 --- a/rtxpy/tests/test_mesh.py +++ b/rtxpy/tests/test_mesh.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from rtxpy import triangulate_terrain, write_stl +from rtxpy import triangulate_terrain, voxelate_terrain, write_stl from rtxpy.rtx import has_cupy @@ -180,3 +180,173 @@ def test_write_from_cupy_arrays(self, tmp_path): assert filepath.exists() expected_size = 80 + 4 + 1 * 50 assert filepath.stat().st_size == expected_size + + +class TestVoxelateTerrain: + """Tests for voxelate_terrain function.""" + + def _alloc(self, H, W): + """Allocate voxelation buffers for H x W terrain.""" + num_verts = H * W * 8 + num_tris = H * W * 12 + verts = np.zeros(num_verts * 3, dtype=np.float32) + triangles = np.zeros(num_tris * 3, dtype=np.int32) + return verts, triangles + + def _get_cell_verts(self, verts, cell_idx): + """Extract 8 vertices (as 8x3 array) for a given cell index.""" + vo = cell_idx * 24 + return verts[vo:vo + 24].reshape(8, 3) + + def test_simple_terrain_cpu(self): + """Test voxelation of a 2x2 terrain — verify vertex positions.""" + H, W = 2, 2 + terrain = np.array([ + [1.0, 2.0], + [3.0, 4.0], + ], dtype=np.float32) + + verts, triangles = self._alloc(H, W) + result = voxelate_terrain(verts, triangles, terrain, base_elevation=0.0) + assert result == 0 + + # Check cell (0,0): elevation=1.0, at position (w=0,h=0) + cv = self._get_cell_verts(verts, 0) + # Bottom verts z=0 + assert cv[0, 2] == 0.0 + assert cv[1, 2] == 0.0 + assert cv[2, 2] == 0.0 + assert cv[3, 2] == 0.0 + # Top verts z=1.0 + assert cv[4, 2] == 1.0 + assert cv[5, 2] == 1.0 + assert cv[6, 2] == 1.0 + assert cv[7, 2] == 1.0 + # Check x,y of bottom-left and top-right + np.testing.assert_array_equal(cv[0, :2], [0, 0]) + np.testing.assert_array_equal(cv[6, :2], [1, 1]) + + # Check cell (0,1): elevation=2.0, at position (w=1,h=0) + cv1 = self._get_cell_verts(verts, 1) + assert cv1[4, 2] == 2.0 # top z + np.testing.assert_array_equal(cv1[0, :2], [1, 0]) # starts at w=1 + + # Check cell (1,0): elevation=3.0, at position (w=0,h=1) + cv2 = self._get_cell_verts(verts, 2) + assert cv2[4, 2] == 3.0 + np.testing.assert_array_equal(cv2[0, :2], [0, 1]) + + # Check buffer sizes + assert len(verts) == H * W * 8 * 3 + assert len(triangles) == H * W * 12 * 3 + + def test_terrain_with_scale(self): + """Test that scale affects z values.""" + H, W = 2, 2 + terrain = np.array([ + [1.0, 1.0], + [1.0, 1.0], + ], dtype=np.float32) + + verts, triangles = self._alloc(H, W) + voxelate_terrain(verts, triangles, terrain, scale=10.0, base_elevation=0.0) + + # Top z should be 1.0 * 10.0 = 10.0 for all cells + for cell in range(H * W): + cv = self._get_cell_verts(verts, cell) + assert cv[4, 2] == 10.0 + + def test_base_elevation(self): + """Test that base_elevation controls column bottom.""" + H, W = 2, 2 + terrain = np.array([ + [5.0, 5.0], + [5.0, 5.0], + ], dtype=np.float32) + + verts, triangles = self._alloc(H, W) + voxelate_terrain(verts, triangles, terrain, base_elevation=2.0) + + for cell in range(H * W): + cv = self._get_cell_verts(verts, cell) + # Bottom at base_elevation + assert cv[0, 2] == 2.0 + assert cv[1, 2] == 2.0 + # Top at elevation + assert cv[4, 2] == 5.0 + + def test_triangle_winding_produces_outward_normals(self): + """Cross product check on top face — normal should point +Z.""" + H, W = 1, 1 + terrain = np.array([[5.0]], dtype=np.float32) + + verts, triangles = self._alloc(H, W) + voxelate_terrain(verts, triangles, terrain, base_elevation=0.0) + + # Top face is first two triangles: indices 0-2 and 3-5 + v = verts.reshape(-1, 3) + i0, i1, i2 = triangles[0], triangles[1], triangles[2] + p0, p1, p2 = v[i0], v[i1], v[i2] + normal = np.cross(p1 - p0, p2 - p0) + # Top face normal should point in +Z direction + assert normal[2] > 0 + + def test_flat_terrain(self): + """Test voxelation of 3x3 uniform terrain.""" + H, W = 3, 3 + terrain = np.full((H, W), 10.0, dtype=np.float32) + + verts, triangles = self._alloc(H, W) + result = voxelate_terrain(verts, triangles, terrain, base_elevation=0.0) + assert result == 0 + + # All top verts at z=10, all bottom at z=0 + for cell in range(H * W): + cv = self._get_cell_verts(verts, cell) + for i in range(4): + assert cv[i, 2] == 0.0 + assert cv[i + 4, 2] == 10.0 + + @pytest.mark.skipif(not has_cupy, reason="cupy not available") + def test_simple_terrain_gpu(self): + """Test voxelation on GPU with cupy arrays.""" + import cupy + + H, W = 2, 2 + terrain = cupy.array([ + [1.0, 2.0], + [3.0, 4.0], + ], dtype=cupy.float32) + + num_verts = H * W * 8 + num_tris = H * W * 12 + verts = cupy.zeros(num_verts * 3, dtype=cupy.float32) + triangles = cupy.zeros(num_tris * 3, dtype=cupy.int32) + + result = voxelate_terrain(verts, triangles, terrain, base_elevation=0.0) + assert result == 0 + + verts_np = cupy.asnumpy(verts).reshape(-1, 3) + # Cell 0: top z = 1.0 + assert verts_np[4, 2] == 1.0 + # Cell 3 (h=1,w=1): top z = 4.0 + assert verts_np[3 * 8 + 4, 2] == 4.0 + + def test_write_voxelated_terrain_to_stl(self, tmp_path): + """Test STL export of voxelated terrain.""" + H, W = 2, 2 + terrain = np.array([ + [1.0, 2.0], + [3.0, 4.0], + ], dtype=np.float32) + + verts, triangles = self._alloc(H, W) + voxelate_terrain(verts, triangles, terrain, base_elevation=0.0) + + filepath = tmp_path / "voxelated.stl" + write_stl(str(filepath), verts, triangles) + + assert filepath.exists() + num_tris = H * W * 12 + expected_size = 80 + 4 + num_tris * 50 + assert filepath.stat().st_size == expected_size diff --git a/run_gpu_test.bat b/run_gpu_test.bat deleted file mode 100644 index b447d17..0000000 --- a/run_gpu_test.bat +++ /dev/null @@ -1,332 +0,0 @@ -@echo off -setlocal enabledelayedexpansion - -:: GPU Test Script for Windows -:: Equivalent to .github/workflows/gpu-test.yml - -echo ============================================ -echo RTXpy GPU Test - Windows Local Runner -echo ============================================ -echo. - -:: Configuration -set OPTIX_DIR=C:\optix -set OPTIX_VERSION=v7.7.0 - -:: Get the directory where this script is located -set SCRIPT_DIR=%~dp0 -cd /d "%SCRIPT_DIR%" - -:: Step 1: Verify GPU -echo [1/10] Verifying NVIDIA GPU... -echo ---------------------------------------- -nvidia-smi >nul 2>&1 -if errorlevel 1 ( - echo ERROR: nvidia-smi not found. Please install NVIDIA drivers. - exit /b 1 -) -nvidia-smi -echo. -echo OptiX 7.7 requires driver 530.41+ -echo OptiX 8.0 requires driver 535+ -echo OptiX 9.1 requires driver 590+ -echo. - -:: Step 2: Verify CUDA Toolkit -echo [2/10] Verifying CUDA Toolkit... -echo ---------------------------------------- -nvcc --version >nul 2>&1 -if errorlevel 1 ( - echo ERROR: nvcc not found. Please install CUDA Toolkit 12.6+ - echo Download from: https://developer.nvidia.com/cuda-downloads - exit /b 1 -) -nvcc --version -echo. - -:: Step 3: Verify CMake -echo [3/10] Verifying CMake... -echo ---------------------------------------- -cmake --version >nul 2>&1 -if errorlevel 1 ( - echo ERROR: cmake not found. Please install CMake. - echo Download from: https://cmake.org/download/ - echo Or run: conda install -c conda-forge cmake - exit /b 1 -) -cmake --version -echo. - -:: Step 4: Install OptiX SDK headers -echo [4/10] Setting up OptiX SDK headers... -echo ---------------------------------------- -if exist "%OPTIX_DIR%\include\optix.h" ( - echo OptiX headers already installed at %OPTIX_DIR% -) else ( - echo Cloning OptiX SDK headers from NVIDIA/optix-dev... - if exist "%OPTIX_DIR%" rmdir /s /q "%OPTIX_DIR%" - git clone --depth 1 --branch %OPTIX_VERSION% https://github.com/NVIDIA/optix-dev.git "%OPTIX_DIR%" - if errorlevel 1 ( - echo ERROR: Failed to clone OptiX headers - exit /b 1 - ) - if not exist "%OPTIX_DIR%\include\optix.h" ( - echo ERROR: OptiX headers not found after clone - exit /b 1 - ) - echo OptiX headers installed successfully -) -set OptiX_INSTALL_DIR=%OPTIX_DIR% -echo. - -:: Step 5: Set up Visual Studio environment for nvcc -echo [5/10] Setting up Visual Studio environment... -echo ---------------------------------------- -:: Check if cl.exe is already available -where cl.exe >nul 2>&1 -if not errorlevel 1 ( - echo Visual Studio environment already configured - goto :vs_done -) - -:: Use vswhere to find Visual Studio installation (most reliable method) -set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -set "VCVARS_PATH=" - -if exist "%VSWHERE%" ( - echo Using vswhere to locate Visual Studio... - for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do ( - set "VS_PATH=%%i" - ) - if defined VS_PATH ( - set "VCVARS_PATH=!VS_PATH!\VC\Auxiliary\Build\vcvars64.bat" - ) -) - -:: Fallback: try common paths if vswhere didn't work -if not defined VCVARS_PATH ( - echo vswhere not found or no VS with C++ tools, trying common paths... - for %%p in ( - "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat" - "C:\Program Files\Microsoft Visual Studio\2022\Professional\VC\Auxiliary\Build\vcvars64.bat" - "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvars64.bat" - "C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat" - "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvars64.bat" - "C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\VC\Auxiliary\Build\vcvars64.bat" - "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build\vcvars64.bat" - "C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Auxiliary\Build\vcvars64.bat" - ) do ( - if not defined VCVARS_PATH ( - if exist %%p ( - set "VCVARS_PATH=%%~p" - ) - ) - ) -) - -if not defined VCVARS_PATH ( - echo ERROR: Could not find Visual Studio with C++ tools. - echo. - echo Please install Visual Studio with C++ build tools: - echo winget install Microsoft.VisualStudio.2022.Community --silent --override "--wait --quiet --add Microsoft.VisualStudio.Workload.NativeDesktop --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --includeRecommended" - echo. - echo Or run this script from "x64 Native Tools Command Prompt for VS 2022" - exit /b 1 -) - -echo Found: %VCVARS_PATH% -echo Initializing Visual Studio environment... -call "%VCVARS_PATH%" -if errorlevel 1 ( - echo ERROR: Failed to initialize Visual Studio environment - exit /b 1 -) - -:: Verify cl.exe is now available -where cl.exe >nul 2>&1 -if errorlevel 1 ( - echo. - echo ERROR: cl.exe still not found after running vcvars64.bat - echo The C++ compiler may not be installed. Please ensure you have installed - echo the "Desktop development with C++" workload in Visual Studio. - echo. - echo You can add it by running: - echo "%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vs_installer.exe" modify --installPath "!VS_PATH!" --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 - echo. - echo Or run this script from "x64 Native Tools Command Prompt for VS 2022" - exit /b 1 -) -echo. -echo Visual Studio environment configured successfully -echo Compiler: -where cl.exe -:vs_done -echo. - -:: Step 6: Detect GPU architecture -echo [6/10] Detecting GPU architecture... -echo ---------------------------------------- -:: Use skip=1 to skip the CSV header line since noheader may not be supported -for /f "skip=1 tokens=*" %%i in ('nvidia-smi --query-gpu=compute_cap --format=csv') do ( - if not defined COMPUTE_CAP set "COMPUTE_CAP=%%i" -) -:: Remove the dot from compute capability (e.g., 8.6 -> 86) -set GPU_ARCH=%COMPUTE_CAP:.=% -echo Detected GPU compute capability: sm_%GPU_ARCH% -echo. - -:: Step 7: Compile PTX -echo [7/10] Compiling kernel.cu to PTX... -echo ---------------------------------------- -if not exist "cuda\kernel.cu" ( - echo ERROR: cuda\kernel.cu not found. Are you in the rtxpy directory? - exit /b 1 -) -nvcc -ptx -arch=sm_%GPU_ARCH% -I"%OptiX_INSTALL_DIR%\include" -Icuda --use_fast_math -allow-unsupported-compiler -o rtxpy\kernel.ptx cuda\kernel.cu -if errorlevel 1 ( - echo ERROR: PTX compilation failed - exit /b 1 -) -echo PTX compiled successfully to rtxpy\kernel.ptx -echo. - -:: Step 8: Install otk-pyoptix -echo [8/10] Installing otk-pyoptix... -echo ---------------------------------------- - -:: Verify OptiX_INSTALL_DIR is set and valid -if not defined OptiX_INSTALL_DIR ( - set "OptiX_INSTALL_DIR=%OPTIX_DIR%" -) -if not exist "%OptiX_INSTALL_DIR%\include\optix.h" ( - echo ERROR: OptiX headers not found at %OptiX_INSTALL_DIR%\include - echo Please ensure step 4 completed successfully. - exit /b 1 -) -echo Using OptiX_INSTALL_DIR=%OptiX_INSTALL_DIR% - -python -c "import optix" >nul 2>&1 -if errorlevel 1 ( - echo otk-pyoptix not found, installing from source... - if exist "%TEMP%\otk-pyoptix" rmdir /s /q "%TEMP%\otk-pyoptix" - git clone --depth 1 https://github.com/NVIDIA/otk-pyoptix.git "%TEMP%\otk-pyoptix" - if errorlevel 1 ( - echo ERROR: Failed to clone otk-pyoptix - exit /b 1 - ) - - :: Pre-clone pybind11 without submodules to avoid FetchContent submodule update failures - echo Pre-cloning pybind11 to avoid submodule issues... - set "PYBIND11_DIR=%TEMP%\pybind11-src" - if exist "!PYBIND11_DIR!" rmdir /s /q "!PYBIND11_DIR!" - git clone --depth 1 --branch v2.13.6 https://github.com/pybind/pybind11.git "!PYBIND11_DIR!" - if errorlevel 1 ( - echo ERROR: Failed to clone pybind11 - exit /b 1 - ) - - :: Tell CMake to use our pre-cloned pybind11 instead of fetching - set "FETCHCONTENT_SOURCE_DIR_PYBIND11=!PYBIND11_DIR!" - echo Using pre-cloned pybind11 at !PYBIND11_DIR! - - pushd "%TEMP%\otk-pyoptix\optix" - - :: Patch CMakeLists.txt to use our pre-cloned pybind11 and skip submodule updates - echo Patching CMakeLists.txt to use local pybind11... - - :: Convert backslashes to forward slashes for CMake - set "PYBIND11_DIR_CMAKE=!PYBIND11_DIR:\=/!" - - :: Prepend the FETCHCONTENT_SOURCE_DIR_PYBIND11 setting to CMakeLists.txt - :: Use parentheses with echo to create file with newline, then use type to append - ( - echo set^(FETCHCONTENT_SOURCE_DIR_PYBIND11 "!PYBIND11_DIR_CMAKE!" CACHE PATH "pybind11 source" FORCE^) - type CMakeLists.txt - ) > "%TEMP%\CMakeLists_new.txt" - move /y "%TEMP%\CMakeLists_new.txt" CMakeLists.txt >nul - - echo Patched CMakeLists.txt - first 2 lines: - powershell -Command "Get-Content CMakeLists.txt -Head 2" - - :: Set OptiX path for cmake/pip build process - set "OptiX_INSTALL_DIR=%OptiX_INSTALL_DIR%" - set "OPTIX_PATH=%OptiX_INSTALL_DIR%" - set "CMAKE_PREFIX_PATH=%OptiX_INSTALL_DIR%;%CMAKE_PREFIX_PATH%" - - :: Pre-install build dependencies so we can use --no-build-isolation - echo Installing build dependencies... - pip install setuptools wheel - - echo Building with OptiX_INSTALL_DIR=%OptiX_INSTALL_DIR% - echo FETCHCONTENT_SOURCE_DIR_PYBIND11=!FETCHCONTENT_SOURCE_DIR_PYBIND11! - - :: Pass pybind11 source dir to CMake via CMAKE_ARGS (used by scikit-build and setuptools) - set "CMAKE_ARGS=-DFETCHCONTENT_SOURCE_DIR_PYBIND11=!PYBIND11_DIR!" - - :: Use --no-build-isolation so environment variables are visible to CMake - pip install . -v --no-build-isolation - if errorlevel 1 ( - echo. - echo ERROR: Failed to install otk-pyoptix - echo. - echo If the error mentions OptiX not found, try setting manually: - echo set OptiX_INSTALL_DIR=%OptiX_INSTALL_DIR% - echo set OPTIX_PATH=%OptiX_INSTALL_DIR% - echo. - popd - exit /b 1 - ) - popd - echo otk-pyoptix installed successfully -) else ( - echo otk-pyoptix already installed -) -echo. - -:: Step 9: Install rtxpy -echo [9/10] Installing rtxpy with test dependencies... -echo ---------------------------------------- -pip install -U pip -pip install -ve .[tests,cuda12] -if errorlevel 1 ( - echo ERROR: Failed to install rtxpy - exit /b 1 -) -echo. - -:: Step 10: Run tests -echo [10/10] Running GPU tests... -echo ---------------------------------------- -echo. -echo === Running pytest === -python -m pytest -v rtxpy/tests -set PYTEST_RESULT=%errorlevel% -echo. - -echo === Running basic ray tracing test === -python -c "from rtxpy import RTX; import numpy as np; verts = np.float32([0,0,0, 1,0,0, 0,1,0, 1,1,0]); triangles = np.int32([0,1,2, 2,1,3]); rays = np.float32([0.33,0.33,100, 0,0,0, -1,1000]); hits = np.float32([0,0,0,0]); optix = RTX(); res = optix.build(0, verts, triangles); assert res == 0, f'Build failed with {res}'; res = optix.trace(rays, hits, 1); assert res == 0, f'Trace failed with {res}'; print(f'Hit result: t={hits[0]}, normal=({hits[1]}, {hits[2]}, {hits[3]})'); assert hits[0] > 0, 'Expected a hit'; print('GPU ray tracing test PASSED!')" -set RAYTEST_RESULT=%errorlevel% -echo. - -:: Summary -echo ============================================ -echo Test Summary -echo ============================================ -if %PYTEST_RESULT% equ 0 ( - echo pytest: PASSED -) else ( - echo pytest: FAILED -) -if %RAYTEST_RESULT% equ 0 ( - echo ray tracing test: PASSED -) else ( - echo ray tracing test: FAILED -) -echo ============================================ - -if %PYTEST_RESULT% neq 0 exit /b %PYTEST_RESULT% -if %RAYTEST_RESULT% neq 0 exit /b %RAYTEST_RESULT% - -echo. -echo All GPU tests completed successfully! -exit /b 0