Source code for yapcad.viewer.vtk_viewer

"""
VTK-Based Assembly Viewer
=========================

Core VTK rendering engine for the yapCAD assembly viewer.

This module provides a general-purpose STL viewer with multi-viewport support,
transform application, and real-time rendering capabilities. It is designed
to work with any assembly defined by JSON position files and STL directories.

Example Usage
-------------

Basic usage::

    from yapcad.viewer import VTKViewer, ViewerConfig

    config = ViewerConfig(
        stl_dir="/path/to/stls",
        positions_file="/path/to/positions.json"
    )
    viewer = VTKViewer(config)
    viewer.load_from_json()
    viewer.start()

Manual part loading::

    viewer = VTKViewer(config)
    viewer.add_part(
        name="my_part",
        stl_path="/path/to/part.stl",
        transform=[[1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]],
        color=(0.8, 0.4, 0.2)
    )
    viewer.start()

With file-based commands (backward compatibility)::

    config = ViewerConfig(
        stl_dir="/path/to/stls",
        command_file="/path/to/viewer_cmd.txt"
    )
    viewer = VTKViewer(config)
    viewer.start()  # Will poll command file for instructions

Dependencies
------------

Requires VTK >= 9.0::

    pip install vtk
"""

import json
import math
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Union

import vtk

from .config import ViewerConfig
from .events import (
    PartLoadedEvent,
    PartRemovedEvent,
    PartsClearedEvent,
    XrayChangedEvent,
    CameraChangedEvent,
    ScreenshotSavedEvent,
    SelectionChangedEvent,
    StatusEvent,
    ErrorEvent,
)


# Type aliases
Matrix4x4 = List[List[float]]
RGB = Tuple[float, float, float]
EventCallback = Callable[[Any], None]


[docs] class VTKViewer: """ VTK-based multi-viewport assembly viewer. This class provides a complete 3D viewer for STL assemblies with support for multiple viewports, transform application, x-ray mode, and various camera controls. Parameters ---------- config : ViewerConfig Viewer configuration object. Attributes ---------- config : ViewerConfig Current configuration. actors : dict Mapping of part names to VTK actors. positions : dict Current part positions loaded from JSON. xray_mode : bool Whether x-ray mode is currently enabled. Examples -------- Create viewer and load from JSON:: config = ViewerConfig( stl_dir="/my/stls", positions_file="/my/positions.json" ) viewer = VTKViewer(config) viewer.load_from_json() viewer.start() Create viewer and add parts manually:: viewer = VTKViewer(ViewerConfig(stl_dir="/my/stls")) viewer.add_part("gear", "/my/stls/gear.stl", transform, (0.8, 0.5, 0.2)) viewer.add_part("housing", "/my/stls/housing.stl", transform, (0.4, 0.4, 0.4)) viewer.start() """ def __init__(self, config: ViewerConfig): """ Initialize the VTK viewer. Parameters ---------- config : ViewerConfig Viewer configuration. """ self.config = config self.positions: Dict[str, Any] = {} self.actors: Dict[str, vtk.vtkActor] = {} self.actor_transforms: Dict[str, vtk.vtkTransform] = {} self.xray_mode = False self._color_index = 0 self._event_callbacks: Dict[str, List[EventCallback]] = {} # Initialize VTK components self.render_window = vtk.vtkRenderWindow() self.render_window.SetSize(*config.window_size) self.render_window.SetWindowName(config.window_title) self.renderers: Dict[str, vtk.vtkRenderer] = {} self._setup_viewports() self.interactor = vtk.vtkRenderWindowInteractor() self.interactor.SetRenderWindow(self.render_window) style = vtk.vtkInteractorStyleTrackballCamera() self.interactor.SetInteractorStyle(style) # Set up keyboard handler self.interactor.AddObserver("KeyPressEvent", self._on_key) # Set up command file polling if configured self._last_cmd_mtime = 0 if config.command_path: self.interactor.AddObserver("TimerEvent", self._on_timer) # Load positions if configured if config.positions_path and config.positions_path.exists(): self._load_positions_file() def _setup_viewports(self): """Set up the multi-viewport layout.""" if self.config.enable_multi_viewport: viewports = { "ISO": (0.5, 0.5, 1.0, 1.0), "TOP": (0.0, 0.5, 0.5, 1.0), "FRONT": (0.0, 0.0, 0.5, 0.5), "SIDE": (0.5, 0.0, 1.0, 0.5), } else: viewports = { "ISO": (0.0, 0.0, 1.0, 1.0), } bg = self.config.background_color for name, (x0, y0, x1, y1) in viewports.items(): renderer = vtk.vtkRenderer() renderer.SetViewport(x0, y0, x1, y1) renderer.SetBackground(*bg) self.render_window.AddRenderer(renderer) self.renderers[name] = renderer # Add viewport label label = vtk.vtkTextActor() label.SetInput(name) label.GetPositionCoordinate().SetCoordinateSystemToNormalizedViewport() label.GetPositionCoordinate().SetValue(0.02, 0.92) label.GetTextProperty().SetFontSize(16) label.GetTextProperty().SetColor(0.8, 0.8, 0.8) label.GetTextProperty().SetBold(True) renderer.AddActor2D(label) self._reset_cameras() def _load_positions_file(self): """Load positions from the configured JSON file.""" if self.config.positions_path and self.config.positions_path.exists(): with open(self.config.positions_path) as f: self.positions = json.load(f) def _get_next_color(self) -> RGB: """ Get the next color from the palette for automatic assignment. Returns ------- tuple of float RGB color (0-1 range). """ colors = list(self.config.color_palette.values()) if not colors: return self.config.default_color color = colors[self._color_index % len(colors)] self._color_index += 1 return color def _get_scene_bounds(self) -> Optional[Tuple[float, ...]]: """ Calculate the bounding box of all loaded actors. Returns ------- tuple of float or None Bounding box (xmin, xmax, ymin, ymax, zmin, zmax) or None if empty. """ if not self.actors: return None all_bounds = [a.GetBounds() for a in self.actors.values()] return ( min(b[0] for b in all_bounds), max(b[1] for b in all_bounds), min(b[2] for b in all_bounds), max(b[3] for b in all_bounds), min(b[4] for b in all_bounds), max(b[5] for b in all_bounds), ) def _reset_cameras(self): """Reset all cameras to view the entire scene.""" bounds = self._get_scene_bounds() if bounds is None: bounds = (-100, 100, -100, 100, 0, 200) cx = (bounds[0] + bounds[1]) / 2 cy = (bounds[2] + bounds[3]) / 2 cz = (bounds[4] + bounds[5]) / 2 size = max( bounds[1] - bounds[0], bounds[3] - bounds[2], bounds[5] - bounds[4] ) for name, renderer in self.renderers.items(): camera = renderer.GetActiveCamera() camera.SetParallelProjection(name != "ISO") if name == "ISO": camera.SetPosition(cx + size, cy + size * 0.8, cz + size) camera.SetViewUp(0, 0, 1) elif name == "TOP": camera.SetPosition(cx, cy, cz + size * 1.5) camera.SetViewUp(0, 1, 0) elif name == "FRONT": camera.SetPosition(cx, cy - size * 1.5, cz) camera.SetViewUp(0, 0, 1) elif name == "SIDE": camera.SetPosition(cx + size * 1.5, cy, cz) camera.SetViewUp(0, 0, 1) camera.SetFocalPoint(cx, cy, cz) renderer.ResetCamera() def _load_stl(self, stl_path: Union[str, Path]) -> Optional[vtk.vtkPolyData]: """ Load an STL file. Parameters ---------- stl_path : str or Path Path to the STL file. Returns ------- vtkPolyData or None Loaded mesh data or None if file doesn't exist. """ path = Path(stl_path) if not path.exists(): return None reader = vtk.vtkSTLReader() reader.SetFileName(str(path)) reader.Update() return reader.GetOutput() def _emit_event(self, event): """ Emit an event to all registered callbacks. Parameters ---------- event : BaseEvent Event to emit. """ callbacks = self._event_callbacks.get(event.event_type, []) for callback in callbacks: try: callback(event) except Exception as e: print(f"Event callback error: {e}") # ========================================================================= # Public API - Part Management # =========================================================================
[docs] def add_part( self, name: str, stl_path: Union[str, Path], transform: Optional[Matrix4x4] = None, color: Optional[RGB] = None ) -> bool: """ Add a single part to the viewer. Parameters ---------- name : str Unique identifier for the part. stl_path : str or Path Path to the STL file. transform : list of list of float, optional 4x4 transformation matrix. Identity matrix if not provided. color : tuple of float, optional RGB color (0-1 range). Auto-assigned if not provided. Returns ------- bool True if part was loaded successfully. Examples -------- Add a part with transform:: transform = [ [1, 0, 0, 10], [0, 1, 0, 20], [0, 0, 1, 30], [0, 0, 0, 1] ] viewer.add_part("my_gear", "/path/to/gear.stl", transform, (0.8, 0.5, 0.2)) Add a part at origin with auto color:: viewer.add_part("housing", "/path/to/housing.stl") """ polydata = self._load_stl(stl_path) if polydata is None: self._emit_event(ErrorEvent( message=f"Failed to load STL: {stl_path}", code="LOAD_ERROR", details={"part": name, "path": str(stl_path)} )) return False mapper = vtk.vtkPolyDataMapper() mapper.SetInputData(polydata) actor = vtk.vtkActor() actor.SetMapper(mapper) # Apply color if color is None: color = self._get_next_color() actor.GetProperty().SetColor(*color) actor.GetProperty().SetInterpolationToPhong() # Apply transform if transform is not None: vtk_transform = vtk.vtkTransform() vtk_matrix = vtk.vtkMatrix4x4() for i in range(4): for j in range(4): vtk_matrix.SetElement(i, j, transform[i][j]) vtk_transform.SetMatrix(vtk_matrix) actor.SetUserTransform(vtk_transform) self.actor_transforms[name] = vtk_transform # Apply current x-ray mode if self.xray_mode: actor.GetProperty().SetOpacity(self.config.xray_opacity) # Add to all renderers for renderer in self.renderers.values(): renderer.AddActor(actor) self.actors[name] = actor # Emit event bounds = list(actor.GetBounds()) self._emit_event(PartLoadedEvent( part_name=name, stl_file=str(stl_path), bounds=bounds, color=color )) return True
[docs] def remove_part(self, name: str) -> bool: """ Remove a part from the viewer. Parameters ---------- name : str Name of the part to remove. Returns ------- bool True if part was found and removed. """ if name not in self.actors: return False actor = self.actors.pop(name) for renderer in self.renderers.values(): renderer.RemoveActor(actor) if name in self.actor_transforms: del self.actor_transforms[name] self._emit_event(PartRemovedEvent(part_name=name)) return True
[docs] def clear_parts(self): """Remove all parts from the viewer.""" count = len(self.actors) for actor in self.actors.values(): for renderer in self.renderers.values(): renderer.RemoveActor(actor) self.actors.clear() self.actor_transforms.clear() self._color_index = 0 self._emit_event(PartsClearedEvent(previous_count=count))
[docs] def load_parts( self, part_specs: List[Tuple[str, str, Optional[str]]], clear: bool = True ) -> int: """ Load multiple parts from specifications. Parameters ---------- part_specs : list of tuple List of (stl_file, part_name, color_name) tuples. stl_file is relative to stl_dir. color_name is optional; if None, auto-assigned. clear : bool, optional Clear existing parts first. Default: True. Returns ------- int Number of parts successfully loaded. Examples -------- Load multiple parts:: parts = [ ("gearbox/sun_gear.stl", "SUN_GEAR", "orange"), ("gearbox/ring_housing.stl", "RING_HOUSING", "brown"), ("motors/servo.stl", "SERVO", "dark"), ] viewer.load_parts(parts) """ if clear: self.clear_parts() loaded = 0 for spec in part_specs: stl_file = spec[0] part_name = spec[1] color_name = spec[2] if len(spec) > 2 else None stl_path = self.config.stl_path / stl_file color = self.config.get_color(color_name) if color_name else None # Get transform from positions if available transform = None if part_name in self.positions: transform = self.positions[part_name].get("transform_matrix") # Override color from positions if specified if "color" in self.positions[part_name] and color is None: color = tuple(self.positions[part_name]["color"]) if self.add_part(part_name, stl_path, transform, color): loaded += 1 self._reset_cameras() return loaded
[docs] def load_from_json(self, positions_file: Optional[Union[str, Path]] = None) -> int: """ Load all parts defined in a positions JSON file. The JSON file should have the format:: { "PART_NAME": { "transform_matrix": [[...], [...], [...], [...]], "stl_file": "path/to/file.stl", // optional "color": [r, g, b] // optional, 0-1 range }, ... } Parameters ---------- positions_file : str or Path, optional Path to positions JSON file. Uses config.positions_file if not provided. Returns ------- int Number of parts successfully loaded. Raises ------ FileNotFoundError If positions file doesn't exist. """ if positions_file: path = Path(positions_file) elif self.config.positions_path: path = self.config.positions_path else: raise ValueError("No positions file specified") if not path.exists(): raise FileNotFoundError(f"Positions file not found: {path}") with open(path) as f: self.positions = json.load(f) self.clear_parts() loaded = 0 for part_name, part_data in self.positions.items(): # Get STL path if "stl_file" in part_data: stl_path = self.config.stl_path / part_data["stl_file"] else: # Try to find STL by part name possible_paths = [ self.config.stl_path / f"{part_name}.stl", self.config.stl_path / f"{part_name.lower()}.stl", ] stl_path = None for p in possible_paths: if p.exists(): stl_path = p break if stl_path is None: continue transform = part_data.get("transform_matrix") color = tuple(part_data["color"]) if "color" in part_data else None if self.add_part(part_name, stl_path, transform, color): loaded += 1 self._reset_cameras() return loaded
[docs] def reload_positions(self): """ Reload positions from the JSON file and update transforms. This updates transforms without reloading STL files, which is faster for iterating on kinematic positions. """ self._load_positions_file() for part_name, part_data in self.positions.items(): if part_name not in self.actor_transforms: continue transform = part_data.get("transform_matrix") if transform is None: continue vtk_transform = self.actor_transforms[part_name] vtk_matrix = vtk.vtkMatrix4x4() for i in range(4): for j in range(4): vtk_matrix.SetElement(i, j, transform[i][j]) vtk_transform.SetMatrix(vtk_matrix) self.render_window.Render()
[docs] def get_part_names(self) -> List[str]: """ Get list of loaded part names. Returns ------- list of str Names of all loaded parts. """ return list(self.actors.keys())
[docs] def get_part_bounds(self, name: str) -> Optional[Tuple[float, ...]]: """ Get bounding box of a specific part. Parameters ---------- name : str Part name. Returns ------- tuple of float or None Bounding box (xmin, xmax, ymin, ymax, zmin, zmax) or None. """ if name not in self.actors: return None return self.actors[name].GetBounds()
# ========================================================================= # Public API - Rendering Controls # =========================================================================
[docs] def set_xray(self, enabled: bool, opacity: Optional[float] = None): """ Enable or disable x-ray mode. Parameters ---------- enabled : bool Whether to enable x-ray mode. opacity : float, optional Opacity value when x-ray is enabled. Uses config default if not provided. Examples -------- Enable x-ray:: viewer.set_xray(True) Enable with custom opacity:: viewer.set_xray(True, opacity=0.3) Disable:: viewer.set_xray(False) """ self.xray_mode = enabled if opacity is None: opacity = self.config.xray_opacity if enabled else self.config.default_opacity else: opacity = opacity if enabled else self.config.default_opacity for actor in self.actors.values(): actor.GetProperty().SetOpacity(opacity) self.render_window.Render() self._emit_event(XrayChangedEvent(enabled=enabled, opacity=opacity))
[docs] def toggle_xray(self): """Toggle x-ray mode on/off.""" self.set_xray(not self.xray_mode)
[docs] def highlight_parts(self, names: List[str], dim_opacity: float = 0.15): """ Highlight specific parts by dimming others. Parameters ---------- names : list of str Part names to highlight. dim_opacity : float, optional Opacity for non-highlighted parts. Default: 0.15. Examples -------- Highlight gears:: viewer.highlight_parts(["SUN_GEAR", "RING_HOUSING"]) Clear highlighting (show all):: viewer.highlight_parts([]) # Or pass all part names """ for name, actor in self.actors.items(): if not names or name in names: actor.GetProperty().SetOpacity(1.0) actor.GetProperty().SetAmbient(0.3) else: actor.GetProperty().SetOpacity(dim_opacity) actor.GetProperty().SetAmbient(0.0) self.render_window.Render() self._emit_event(SelectionChangedEvent(highlighted_parts=names))
[docs] def focus_part(self, name: str): """ Focus all cameras on a specific part. Parameters ---------- name : str Name of the part to focus on. Raises ------ KeyError If part name is not found. """ if name not in self.actors: raise KeyError(f"Part not found: {name}") actor = self.actors[name] bounds = actor.GetBounds() cx = (bounds[0] + bounds[1]) / 2 cy = (bounds[2] + bounds[3]) / 2 cz = (bounds[4] + bounds[5]) / 2 size = max( bounds[1] - bounds[0], bounds[3] - bounds[2], bounds[5] - bounds[4] ) for vp_name, renderer in self.renderers.items(): camera = renderer.GetActiveCamera() camera.SetFocalPoint(cx, cy, cz) if vp_name == "ISO": camera.SetPosition(cx + size * 1.5, cy + size * 1.5, cz + size * 1.5) elif vp_name == "TOP": camera.SetPosition(cx, cy, cz + size * 2) elif vp_name == "FRONT": camera.SetPosition(cx, cy - size * 2, cz) elif vp_name == "SIDE": camera.SetPosition(cx + size * 2, cy, cz) renderer.ResetCameraClippingRange() self.render_window.Render()
# ========================================================================= # Public API - Camera Controls # =========================================================================
[docs] def reset_camera(self): """Reset all cameras to view the entire scene.""" self._reset_cameras() self.render_window.Render()
[docs] def set_camera( self, position: Tuple[float, float, float], focal_point: Tuple[float, float, float], viewport: str = "ISO", view_up: Tuple[float, float, float] = (0, 0, 1) ): """ Set camera position and orientation. Parameters ---------- position : tuple of float Camera position (x, y, z). focal_point : tuple of float Point camera is looking at (x, y, z). viewport : str, optional Viewport to modify. Default: "ISO". view_up : tuple of float, optional Up direction vector. Default: (0, 0, 1). Examples -------- Set camera for isometric view:: viewer.set_camera( position=(200, 200, 150), focal_point=(0, 0, 50) ) """ if viewport not in self.renderers: raise KeyError(f"Unknown viewport: {viewport}") camera = self.renderers[viewport].GetActiveCamera() camera.SetPosition(*position) camera.SetFocalPoint(*focal_point) camera.SetViewUp(*view_up) self.renderers[viewport].ResetCameraClippingRange() self.render_window.Render() self._emit_event(CameraChangedEvent( viewport=viewport, position=position, focal_point=focal_point, view_up=view_up ))
[docs] def orbit_camera( self, azimuth: float, elevation: float, distance: float, viewport: str = "ISO" ): """ Position camera using spherical coordinates around focal point. Parameters ---------- azimuth : float Azimuth angle in degrees (rotation around Z axis). elevation : float Elevation angle in degrees (angle from XY plane). distance : float Distance from focal point. viewport : str, optional Viewport to modify. Default: "ISO". Examples -------- Orbit to 45 degrees azimuth, 30 degrees elevation:: viewer.orbit_camera(azimuth=45, elevation=30, distance=500) """ if viewport not in self.renderers: raise KeyError(f"Unknown viewport: {viewport}") camera = self.renderers[viewport].GetActiveCamera() fx, fy, fz = camera.GetFocalPoint() az_rad = math.radians(azimuth) el_rad = math.radians(elevation) x = fx + distance * math.cos(el_rad) * math.cos(az_rad) y = fy + distance * math.cos(el_rad) * math.sin(az_rad) z = fz + distance * math.sin(el_rad) camera.SetPosition(x, y, z) camera.SetViewUp(0, 0, 1) self.renderers[viewport].ResetCameraClippingRange() self.render_window.Render() self._emit_event(CameraChangedEvent( viewport=viewport, position=(x, y, z), focal_point=(fx, fy, fz), view_up=(0, 0, 1) ))
[docs] def get_camera_state(self, viewport: str = "ISO") -> Dict[str, Any]: """ Get current camera state. Parameters ---------- viewport : str, optional Viewport to query. Default: "ISO". Returns ------- dict Camera state with position, focal_point, view_up. """ if viewport not in self.renderers: raise KeyError(f"Unknown viewport: {viewport}") camera = self.renderers[viewport].GetActiveCamera() return { "position": camera.GetPosition(), "focal_point": camera.GetFocalPoint(), "view_up": camera.GetViewUp(), }
# ========================================================================= # Public API - Screenshot # =========================================================================
[docs] def screenshot( self, filename: Optional[str] = None, directory: Optional[Union[str, Path]] = None ) -> str: """ Capture a screenshot. Parameters ---------- filename : str, optional Output filename. Default: "screenshot.png". directory : str or Path, optional Output directory. Uses config.screenshot_dir if not provided. Returns ------- str Full path to saved screenshot. Examples -------- Save with default name:: path = viewer.screenshot() Save with custom name:: path = viewer.screenshot("my_view.png") Save to custom directory:: path = viewer.screenshot("view.png", "/my/images") """ if filename is None: filename = "screenshot.png" if directory is None: directory = self.config.screenshot_path else: directory = Path(directory) directory.mkdir(parents=True, exist_ok=True) filepath = directory / filename self.render_window.Render() w2i = vtk.vtkWindowToImageFilter() w2i.SetInput(self.render_window) w2i.Update() writer = vtk.vtkPNGWriter() writer.SetFileName(str(filepath)) writer.SetInputConnection(w2i.GetOutputPort()) writer.Write() size = self.render_window.GetSize() self._emit_event(ScreenshotSavedEvent( filename=filename, filepath=str(filepath), size=tuple(size) )) return str(filepath)
# ========================================================================= # Public API - Part Movement # =========================================================================
[docs] def move_part(self, name: str, dx: float, dy: float, dz: float): """ Move a part by relative offset. Parameters ---------- name : str Part name. dx : float X offset. dy : float Y offset. dz : float Z offset. Raises ------ KeyError If part name is not found. """ if name not in self.actors: raise KeyError(f"Part not found: {name}") if name not in self.actor_transforms: self.actor_transforms[name] = vtk.vtkTransform() self.actors[name].SetUserTransform(self.actor_transforms[name]) transform = self.actor_transforms[name] transform.Translate(dx, dy, dz) self.render_window.Render()
[docs] def move_part_to(self, name: str, x: float, y: float, z: float): """ Move a part to absolute position (centers part at position). Parameters ---------- name : str Part name. x : float Target X coordinate. y : float Target Y coordinate. z : float Target Z coordinate. Raises ------ KeyError If part name is not found. """ if name not in self.actors: raise KeyError(f"Part not found: {name}") actor = self.actors[name] bounds = actor.GetBounds() cx = (bounds[0] + bounds[1]) / 2 cy = (bounds[2] + bounds[3]) / 2 cz = (bounds[4] + bounds[5]) / 2 dx, dy, dz = x - cx, y - cy, z - cz self.move_part(name, dx, dy, dz)
# ========================================================================= # Public API - Event Handling # =========================================================================
[docs] def on_event(self, event_type: str, callback: EventCallback): """ Register a callback for viewer events. Parameters ---------- event_type : str Event type to listen for (e.g., "part_loaded", "xray_changed"). callback : callable Function to call when event occurs. Receives event object. Examples -------- Track loaded parts:: def on_load(event): print(f"Loaded: {event.part_name}") viewer.on_event("part_loaded", on_load) """ if event_type not in self._event_callbacks: self._event_callbacks[event_type] = [] self._event_callbacks[event_type].append(callback)
[docs] def get_status(self) -> StatusEvent: """ Get current viewer status. Returns ------- StatusEvent Current viewer status. """ camera_state = self.get_camera_state("ISO") return StatusEvent( part_count=len(self.actors), xray_enabled=self.xray_mode, viewport="ISO", camera_position=camera_state["position"], camera_focal_point=camera_state["focal_point"] )
# ========================================================================= # Public API - Main Loop # =========================================================================
[docs] def render(self): """Trigger a render update.""" self.render_window.Render()
[docs] def start(self): """ Start the viewer main loop (blocking). This starts the VTK interactor and blocks until the window is closed. Use this for standalone viewer applications. Examples -------- Basic standalone usage:: viewer = VTKViewer(config) viewer.load_from_json() viewer.start() # Blocks until window closed """ self.render_window.Render() self.interactor.Initialize() # Start command file timer if configured if self.config.command_path: self.interactor.CreateRepeatingTimer(self.config.command_poll_interval_ms) self.interactor.Start()
[docs] def start_offscreen(self): """ Initialize for offscreen rendering. Use this when running headless or with the API server. Does not block. """ self.render_window.SetOffScreenRendering(1) self.render_window.Render()
# ========================================================================= # Internal - Keyboard Handling # ========================================================================= def _on_key(self, obj, event): """Handle keyboard events.""" key = self.interactor.GetKeySym().lower() if key == "x": self.toggle_xray() elif key == "r": self.reset_camera() elif key == "q": self.interactor.TerminateApp() # ========================================================================= # Internal - Command File Handling (Backward Compatibility) # ========================================================================= def _on_timer(self, obj, event): """Timer callback for command file polling.""" if not self.config.command_path: return try: cmd_file = self.config.command_path if cmd_file.exists(): mtime = cmd_file.stat().st_mtime if mtime > self._last_cmd_mtime: self._last_cmd_mtime = mtime cmd = cmd_file.read_text().strip() if cmd: self._process_file_command(cmd) cmd_file.write_text("") except Exception as e: print(f"Command file error: {e}") def _process_file_command(self, cmd: str): """ Process a command from the file-based interface. Parameters ---------- cmd : str Command string to process. """ parts = cmd.lower().split() if not parts: return action = parts[0] print(f"[CMD] {cmd}") try: if action == "xray": self.toggle_xray() elif action == "reload": self.reload_positions() elif action == "reset": self.reset_camera() elif action == "screenshot": filename = parts[1] if len(parts) > 1 else None self.screenshot(filename) elif action == "focus": if len(parts) > 1: self.focus_part(parts[1].upper()) elif action == "highlight": names = [p.upper() for p in parts[1:]] self.highlight_parts(names) elif action == "camera": if len(parts) >= 4: x, y, z = float(parts[1]), float(parts[2]), float(parts[3]) fx, fy, fz = ( (float(parts[4]), float(parts[5]), float(parts[6])) if len(parts) >= 7 else (0, 0, 100) ) self.set_camera((x, y, z), (fx, fy, fz)) elif action == "orbit": if len(parts) >= 4: azimuth = float(parts[1]) elevation = float(parts[2]) distance = float(parts[3]) self.orbit_camera(azimuth, elevation, distance) elif action == "move": if len(parts) >= 5: name = parts[1].upper() dx, dy, dz = float(parts[2]), float(parts[3]), float(parts[4]) self.move_part(name, dx, dy, dz) elif action == "moveto": if len(parts) >= 5: name = parts[1].upper() x, y, z = float(parts[2]), float(parts[3]), float(parts[4]) self.move_part_to(name, x, y, z) except Exception as e: print(f"Command error: {e}") self._emit_event(ErrorEvent( message=str(e), code="COMMAND_ERROR", details={"command": cmd} ))