0.1.33
Loading...
Searching...
No Matches
Visualizer.py
Go to the documentation of this file.
1"""
2High-level Visualizer interface for PyHelios.
3
4This module provides a user-friendly interface to the 3D visualization
5capabilities with graceful plugin handling and informative error messages.
6"""
7
8import logging
9import os
10import ctypes
11from pathlib import Path
12from contextlib import contextmanager
13from typing import List, Optional, Union, Tuple
14
15from .plugins.registry import get_plugin_registry
16from .plugins import helios_lib
17from .wrappers import UVisualizerWrapper as visualizer_wrapper
18from .wrappers.DataTypes import vec2, vec3, RGBcolor, SphericalCoord
19from .Context import Context, check_context_alive
20from .validation.plugin_decorators import validate_build_geometry_params, validate_print_window_params
21from .assets import get_asset_manager
22
23logger = logging.getLogger(__name__)
24
25# Type references for type checking (avoids doxygen parsing issues)
26_INT_TYPE = int
27_NUMERIC_TYPES = (int, float)
28
30def _resolve_user_path(path: str) -> str:
31 """
32 Resolve a user-provided path to an absolute path before working directory changes.
34 This ensures that user file paths are interpreted relative to their original
35 working directory, not the temporary working directory used for asset discovery.
36
37 Args:
38 path: User-provided file path (absolute or relative)
39
40 Returns:
41 Absolute path resolved from the user's original working directory
42 """
43 from pathlib import Path
44
45 path_obj = Path(path)
46 if path_obj.is_absolute():
47 return str(path_obj)
48 else:
49 # Resolve relative to the user's current working directory
50 return str(Path.cwd().resolve() / path_obj)
51
52@contextmanager
54 """
55 Context manager that temporarily changes working directory for visualizer operations.
56
57 The C++ visualizer code expects to find assets at 'plugins/visualizer/' relative
58 to the current working directory. This context manager ensures the working directory
59 is set correctly during visualizer initialization and operations.
60
61 Note: This is required because the Helios C++ core prioritizes current working
62 directory for asset resolution over environment variables.
63 """
64 # Find the build directory where assets are located
65 # Try asset manager first (works for both development and wheel installations)
66 asset_manager = get_asset_manager()
67 working_dir = asset_manager._get_helios_build_path()
68
69 if working_dir and working_dir.exists():
70 visualizer_assets = working_dir / 'plugins' / 'visualizer'
71 else:
72 # For wheel installations, check packaged assets
73 current_dir = Path(__file__).parent
74 packaged_build = current_dir / 'assets' / 'build'
75
76 if packaged_build.exists():
77 working_dir = packaged_build
78 visualizer_assets = working_dir / 'plugins' / 'visualizer'
79 else:
80 # Fallback to development paths
81 repo_root = current_dir.parent
82 build_lib_dir = repo_root / 'pyhelios_build' / 'build' / 'lib'
83 working_dir = build_lib_dir.parent
84 visualizer_assets = working_dir / 'plugins' / 'visualizer'
85
86 if not build_lib_dir.exists():
87 logger.warning(f"Build directory not found: {build_lib_dir}")
88 # Fallback to current directory - may not work but don't break
89 yield
90 return
91
92 if not (visualizer_assets / 'shaders').exists():
93 # Only warn in development environments, not wheel installations
94 asset_mgr = get_asset_manager()
95 if not asset_mgr._is_wheel_install():
96 logger.warning(f"Visualizer assets not found at: {visualizer_assets}")
97 # Continue anyway - may be using source assets or alternative setup
98
99 # Change working directory temporarily
100 original_cwd = Path.cwd()
101
102 try:
103 logger.debug(f"Changing working directory from {original_cwd} to {working_dir}")
104 os.chdir(working_dir)
105 yield
106 finally:
107 logger.debug(f"Restoring working directory to {original_cwd}")
108 os.chdir(original_cwd)
109
110
111class VisualizerError(Exception):
112 """Raised when Visualizer operations fail."""
113 pass
114
115
116class Visualizer:
117 """
118 High-level interface for 3D visualization and rendering.
119
120 This class provides a user-friendly wrapper around the native Helios
121 visualizer plugin with automatic plugin availability checking and
122 graceful error handling.
123
124 The visualizer provides OpenGL-based 3D rendering with interactive controls,
125 image export, and comprehensive scene configuration options.
126 """
127
128 # Lighting model constants
129 LIGHTING_NONE = 0
130 LIGHTING_PHONG = 1
131 LIGHTING_PHONG_SHADOWED = 2
132
133 # Colormap constants (matching C++ enum values)
134 COLORMAP_HOT = 0
135 COLORMAP_COOL = 1
136 COLORMAP_RAINBOW = 2
137 COLORMAP_LAVA = 3
138 COLORMAP_PARULA = 4
139 COLORMAP_GRAY = 5
141 def __init__(self, width: int, height: int, antialiasing_samples: int = 4, headless: bool = False):
142 """
143 Initialize Visualizer with graceful plugin handling.
145 Args:
146 width: Window width in pixels
147 height: Window height in pixels
148 antialiasing_samples: Number of antialiasing samples (default: 4). Pass 0 to
149 disable antialiasing, which is required for exact color reproduction --
150 see :meth:`enableExactColorMode`.
151 headless: Enable headless mode for offscreen rendering (default: False)
152
153 Raises:
154 VisualizerError: If visualizer plugin is not available
155 ValueError: If parameters are invalid
156 """
157 # Validate parameter types first
158 if not isinstance(width, _INT_TYPE):
159 raise ValueError(f"Width must be an integer, got {type(width).__name__}")
160 if not isinstance(height, _INT_TYPE):
161 raise ValueError(f"Height must be an integer, got {type(height).__name__}")
162 if not isinstance(antialiasing_samples, _INT_TYPE):
163 raise ValueError(f"Antialiasing samples must be an integer, got {type(antialiasing_samples).__name__}")
164 if not isinstance(headless, bool):
165 raise ValueError(f"Headless must be a boolean, got {type(headless).__name__}")
167 # Validate parameter values
168 if width <= 0 or height <= 0:
169 raise ValueError("Width and height must be positive integers")
170 if antialiasing_samples < 0:
171 raise ValueError(
172 f"Antialiasing samples must be non-negative, got {antialiasing_samples}. "
173 "Pass 0 to disable antialiasing.")
174
175 self.width = width
176 self.height = height
177 self.antialiasing_samples = antialiasing_samples
178 self.headless = headless
179 self.visualizer = None
180
181 # Check plugin availability using registry
182 registry = get_plugin_registry()
183
184 if not registry.is_plugin_available('visualizer'):
185 # Get helpful information about the missing plugin
186 available_plugins = registry.get_available_plugins()
188 error_msg = (
189 "Visualizer requires the 'visualizer' plugin which is not available.\n\n"
190 "The visualizer plugin provides OpenGL-based 3D rendering and visualization.\n"
191 "System requirements:\n"
192 "- OpenGL 3.3 or higher\n"
193 "- GLFW library for window management\n"
194 "- FreeType library for text rendering\n"
195 "- Display/graphics drivers (X11 on Linux, native on Windows/macOS)\n\n"
196 "To enable visualization:\n"
197 "1. Build PyHelios with visualizer plugin:\n"
198 " build_scripts/build_helios --plugins visualizer\n"
199 f"\nCurrently available plugins: {available_plugins}"
200 )
201
202 # Add platform-specific installation hints
203 import platform
204 system = platform.system().lower()
205 if 'linux' in system:
206 error_msg += (
207 "\n\nLinux installation hints:\n"
208 "- Ubuntu/Debian: sudo apt-get install libx11-dev xorg-dev libgl1-mesa-dev libglu1-mesa-dev\n"
209 "- CentOS/RHEL: sudo yum install libX11-devel mesa-libGL-devel mesa-libGLU-devel"
210 )
211 elif 'darwin' in system:
212 error_msg += (
213 "\n\nmacOS installation hints:\n"
214 "- Install XQuartz: brew install --cask xquartz\n"
215 "- OpenGL should be available by default"
216 )
217 elif 'windows' in system:
218 error_msg += (
219 "\n\nWindows installation hints:\n"
220 "- OpenGL drivers should be provided by graphics card drivers\n"
221 "- Visual Studio runtime may be required"
222 )
223
224 raise VisualizerError(error_msg)
225
226 # Plugin is available - create visualizer with correct working directory
227 try:
229 # Always use the explicit-sample constructor: create_visualizer()
230 # hardcodes 4 samples on the C++ side, so routing through it would
231 # silently ignore the requested count (including a request to disable
232 # antialiasing, which exact color mode depends on).
233 self.visualizer = visualizer_wrapper.create_visualizer_with_antialiasing(
234 width, height, antialiasing_samples, headless
235 )
236
237
238 if self.visualizer is None:
239 raise VisualizerError(
240 "Failed to create Visualizer instance. "
241 "This may indicate a problem with graphics drivers or OpenGL initialization."
242 )
243 logger.info(f"Visualizer created successfully ({width}x{height}, AA:{antialiasing_samples}, headless:{headless})")
244
245 except Exception as e:
246 raise VisualizerError(f"Failed to initialize Visualizer: {e}")
247
248 def _check_context_alive(self):
249 """Raise if a Context was loaded and has since been destroyed."""
250 if getattr(self, "_context", None) is not None:
251 check_context_alive(self._context, "Visualizer")
252
253 def __enter__(self):
254 """Context manager entry."""
255 return self
256
257 def __exit__(self, exc_type, exc_value, traceback):
258 """Context manager exit with proper cleanup."""
259 if self.visualizer is not None:
260 try:
262 visualizer_wrapper.destroy_visualizer(self.visualizer)
263 logger.debug("Visualizer destroyed successfully")
264 except Exception as e:
265 logger.warning(f"Error destroying Visualizer: {e}")
266 finally:
267 self.visualizer = None
268
269 @validate_build_geometry_params
270 def buildContextGeometry(self, context: Context, uuids: Optional[List[int]] = None) -> None:
271 """
272 Build Context geometry in the visualizer.
273
274 This method loads geometry from a Helios Context into the visualizer
275 for rendering. If no UUIDs are specified, all geometry is loaded.
276
277 Args:
278 context: Helios Context instance containing geometry
279 uuids: Optional list of primitive UUIDs to visualize (default: all)
280
281 Raises:
282 VisualizerError: If geometry building fails
283 ValueError: If parameters are invalid
284 """
285 if self.visualizer is None:
286 raise VisualizerError("Visualizer has been destroyed")
287 if not isinstance(context, Context):
288 raise ValueError("context must be a Context instance")
289
290 # Retain a reference to the Context. The native visualizer stores the raw
291 # Context* and only dereferences it later, at render time, so without this
292 # a temporary Context (e.g. buildContextGeometry(make_scene())) would be
293 # garbage collected before the first plot call and crash the interpreter.
294 self._context = context
295
296 try:
298 if uuids is None:
299 # Load all geometry
300 visualizer_wrapper.build_context_geometry(self.visualizer, context.getNativePtr())
301 logger.debug("Built all Context geometry in visualizer")
302 else:
303 # Load specific UUIDs
304 if not uuids:
305 raise ValueError("UUIDs list cannot be empty")
306 visualizer_wrapper.build_context_geometry_uuids(
307 self.visualizer, context.getNativePtr(), uuids
308 )
309 logger.debug(f"Built {len(uuids)} primitives in visualizer")
310
311 except Exception as e:
312 raise VisualizerError(f"Failed to build Context geometry: {e}")
313
314 def plotInteractive(self) -> None:
315 """
316 Open interactive visualization window.
317
318 This method opens a window with the current scene and allows user
319 interaction (camera rotation, zooming, etc.). The program will pause
320 until the window is closed by the user.
321
322 Interactive controls:
323 - Mouse scroll: Zoom in/out
324 - Left mouse + drag: Rotate camera
325 - Right mouse + drag: Pan camera
326 - Arrow keys: Camera movement
327 - +/- keys: Zoom in/out
328
329 Raises:
330 VisualizerError: If visualization fails, or if the Visualizer was
331 constructed with ``headless=True`` (helios-core 1.3.85+ raises rather
332 than driving a window that was never shown or does not exist)
333 """
335 if self.visualizer is None:
336 raise VisualizerError("Visualizer has been destroyed")
337
338 try:
340 visualizer_wrapper.plot_interactive(self.visualizer)
341 logger.debug("Interactive visualization completed")
342 except Exception as e:
343 raise VisualizerError(f"Interactive visualization failed: {e}")
344
345 def plotUpdate(self) -> None:
346 """
347 Update visualization (non-interactive).
348
349 This method updates the visualization window without user interaction.
350 The program continues immediately after rendering. Useful for batch
351 processing or creating image sequences.
352
353 In headless mode, automatically hides the window to prevent graphics driver crashes on some platforms.
354
355 Raises:
356 VisualizerError: If visualization update fails
357 """
359 if self.visualizer is None:
360 raise VisualizerError("Visualizer has been destroyed")
361
362 try:
364 # In headless mode, hide the window to avoid OpenGL/Metal crashes on macOS
365 visualizer_wrapper.plot_update(self.visualizer, hide_window=self.headless)
366 logger.debug("Visualization updated")
367 except Exception as e:
368 raise VisualizerError(f"Visualization update failed: {e}")
370 @validate_print_window_params
371 def printWindow(self, filename: str, image_format: Optional[str] = None) -> None:
372 """
373 Save current visualization to image file.
374
375 This method exports the current visualization to an image file.
376 Starting from v1.3.53, supports both JPEG and PNG formats.
377
378 The frame is rendered as part of the capture (helios-core 1.3.85+), so it is
379 not necessary to call :meth:`plotUpdate` beforehand: handing the Visualizer a
380 Context with :meth:`buildContextGeometry` and calling this straight away
381 produces a correct image. A render is performed whenever the frame currently
382 on the GPU is not the one that would be captured (geometry changed, the camera
383 moved, or the navigation gizmo had to be hidden) and skipped when it would
384 produce an identical frame, so calling :meth:`plotUpdate` first is harmless
385 but redundant. The navigation gizmo is always absent from the captured image.
386
387 Args:
388 filename: Output filename for image
389 Can be absolute or relative to user's current working directory
390 Extension (.jpg, .png) is recommended but not required
391 image_format: Image format - "jpeg" or "png" (v1.3.53+).
392 If None, automatically detects from filename extension.
393 Defaults to "jpeg" if not detectable from extension.
394
395 Raises:
396 VisualizerError: If image saving fails
397 ValueError: If filename or format is invalid
398
399 Note:
400 PNG format is required to preserve transparent backgrounds when using
401 setBackgroundTransparent(). JPEG format will render transparent areas as black.
402
403 Example:
404 >>> visualizer.printWindow("output.jpg") # Auto-detects JPEG
405 >>> visualizer.printWindow("output.png") # Auto-detects PNG
406 >>> visualizer.printWindow("output.img", image_format="png") # Explicit PNG
407 """
409 if self.visualizer is None:
410 raise VisualizerError("Visualizer has been destroyed")
411 if not filename:
412 raise ValueError("Filename cannot be empty")
413
414 # Resolve filename relative to user's working directory before chdir
415 resolved_filename = _resolve_user_path(filename)
416
417 # Auto-detect format from extension if not specified
418 if image_format is None:
419 if resolved_filename.lower().endswith('.png'):
420 image_format = 'png'
421 elif resolved_filename.lower().endswith(('.jpg', '.jpeg')):
422 image_format = 'jpeg'
423 else:
424 # Default to jpeg for backward compatibility
425 image_format = 'jpeg'
426 logger.debug(f"No format specified and extension not recognized, defaulting to JPEG")
427
428 # Validate format
429 if image_format.lower() not in ['jpeg', 'png']:
430 raise ValueError(f"Image format must be 'jpeg' or 'png', got '{image_format}'")
431
432 try:
434 # Try using the new format-aware function (v1.3.53+)
435 try:
436 visualizer_wrapper.print_window_with_format(
437 self.visualizer,
438 resolved_filename,
439 image_format
440 )
441 logger.debug(f"Visualization saved to {resolved_filename} ({image_format.upper()} format)")
442 except (AttributeError, NotImplementedError):
443 # Fallback to old function for older Helios versions
444 if image_format.lower() != 'jpeg':
445 logger.warning(
446 "PNG format requested but not available in current Helios version. "
447 "Falling back to JPEG format. Update to Helios v1.3.53+ for PNG support."
448 )
449 visualizer_wrapper.print_window(self.visualizer, resolved_filename)
450 logger.debug(f"Visualization saved to {resolved_filename} (JPEG format - legacy mode)")
451 except Exception as e:
452 raise VisualizerError(f"Failed to save image: {e}")
453
454 def closeWindow(self) -> None:
455 """
456 Close visualization window.
457
458 This method closes any open visualization window. It's safe to call
459 even if no window is open.
460
461 Raises:
462 VisualizerError: If window closing fails
463 """
464 if self.visualizer is None:
465 raise VisualizerError("Visualizer has been destroyed")
466
467 try:
468 visualizer_wrapper.close_window(self.visualizer)
469 logger.debug("Visualization window closed")
470 except Exception as e:
471 raise VisualizerError(f"Failed to close window: {e}")
472
473 def setCameraPosition(self, position: vec3, lookAt: vec3) -> None:
474 """
475 Set camera position using Cartesian coordinates.
476
477 Args:
478 position: Camera position as vec3 in world coordinates
479 lookAt: Camera look-at point as vec3 in world coordinates
480
481 Raises:
482 VisualizerError: If camera positioning fails
483 ValueError: If parameters are invalid
484 """
485 if self.visualizer is None:
486 raise VisualizerError("Visualizer has been destroyed")
487
488 # Validate DataType parameters
489 if not isinstance(position, vec3):
490 raise ValueError(f"Position must be a vec3, got {type(position).__name__}")
491 if not isinstance(lookAt, vec3):
492 raise ValueError(f"LookAt must be a vec3, got {type(lookAt).__name__}")
493
494 try:
495 visualizer_wrapper.set_camera_position(self.visualizer, position, lookAt)
496 logger.debug(f"Camera position set to ({position.x}, {position.y}, {position.z}), looking at ({lookAt.x}, {lookAt.y}, {lookAt.z})")
497 except Exception as e:
498 raise VisualizerError(f"Failed to set camera position: {e}")
499
500 def setCameraPositionSpherical(self, angle: SphericalCoord, lookAt: vec3) -> None:
501 """
502 Set camera position using spherical coordinates.
503
504 Args:
505 angle: Camera position as SphericalCoord (radius, elevation, azimuth)
506 lookAt: Camera look-at point as vec3 in world coordinates
507
508 Raises:
509 VisualizerError: If camera positioning fails
510 ValueError: If parameters are invalid
511 """
512 if self.visualizer is None:
513 raise VisualizerError("Visualizer has been destroyed")
514
515 # Validate DataType parameters
516 if not isinstance(angle, SphericalCoord):
517 raise ValueError(f"Angle must be a SphericalCoord, got {type(angle).__name__}")
518 if not isinstance(lookAt, vec3):
519 raise ValueError(f"LookAt must be a vec3, got {type(lookAt).__name__}")
520
521 try:
522 visualizer_wrapper.set_camera_position_spherical(self.visualizer, angle, lookAt)
523 logger.debug(f"Camera position set to spherical (r={angle.radius}, el={angle.elevation}, az={angle.azimuth}), looking at ({lookAt.x}, {lookAt.y}, {lookAt.z})")
524 except Exception as e:
525 raise VisualizerError(f"Failed to set camera position (spherical): {e}")
526
527 def setBackgroundColor(self, color: RGBcolor) -> None:
528 """
529 Set background color.
530
531 Args:
532 color: Background color as RGBcolor with values in range [0, 1]
533
534 Raises:
535 VisualizerError: If color setting fails
536 ValueError: If color values are invalid
537 """
538 if self.visualizer is None:
539 raise VisualizerError("Visualizer has been destroyed")
540
541 # Validate DataType parameter
542 if not isinstance(color, RGBcolor):
543 raise ValueError(f"Color must be an RGBcolor, got {type(color).__name__}")
544
545 # Validate color range
546 if not (0 <= color.r <= 1 and 0 <= color.g <= 1 and 0 <= color.b <= 1):
547 raise ValueError(f"Color components ({color.r}, {color.g}, {color.b}) must be in range [0, 1]")
548
549 try:
550 visualizer_wrapper.set_background_color(self.visualizer, color)
551 logger.debug(f"Background color set to ({color.r}, {color.g}, {color.b})")
552 except Exception as e:
553 raise VisualizerError(f"Failed to set background color: {e}")
554
555 def setBackgroundTransparent(self) -> None:
556 """
557 Enable transparent background mode (v1.3.53+).
558
559 Sets the background to transparent with checkerboard pattern display.
560 Requires PNG output format to preserve transparency.
561
562 Note: When using transparent background, use printWindow() with PNG
563 format to save transparent images.
564
565 Raises:
566 VisualizerError: If transparent background setting fails
567 """
568 if self.visualizer is None:
569 raise VisualizerError("Visualizer has been destroyed")
570
571 try:
573 visualizer_wrapper.set_background_transparent(self.visualizer)
574 logger.debug("Background set to transparent mode")
575 except Exception as e:
576 raise VisualizerError(f"Failed to set transparent background: {e}")
577
578 def setBackgroundImage(self, texture_file: str) -> None:
579 """
580 Set custom background image texture (v1.3.53+).
581
582 Args:
583 texture_file: Path to background image file
584 Can be absolute or relative to working directory
585
586 Raises:
587 VisualizerError: If background image setting fails
588 ValueError: If texture file path is invalid
589 """
590 if self.visualizer is None:
591 raise VisualizerError("Visualizer has been destroyed")
592
593 if not texture_file or not isinstance(texture_file, str):
594 raise ValueError("Texture file path must be a non-empty string")
595
596 # Resolve texture file path relative to user's working directory
597 resolved_path = _resolve_user_path(texture_file)
598
599 try:
600 visualizer_wrapper.set_background_image(self.visualizer, resolved_path)
601 logger.debug(f"Background image set to {resolved_path}")
602 except Exception as e:
603 raise VisualizerError(f"Failed to set background image: {e}")
604
605 def setBackgroundSkyTexture(self, texture_file: Optional[str] = None, divisions: int = 50) -> None:
606 """
607 Set sky sphere texture background with automatic scaling (v1.3.53+).
608
609 Creates a sky sphere that automatically scales with the scene.
610 Replaces the deprecated addSkyDomeByCenter() method.
611
612 Args:
613 texture_file: Path to spherical/equirectangular texture image
614 If None, uses default gradient sky texture
615 divisions: Number of sphere tessellation divisions (default: 50)
616 Higher values create smoother sphere but use more GPU
617
618 Raises:
619 VisualizerError: If sky texture setting fails
620 ValueError: If parameters are invalid
621
622 Example:
623 >>> visualizer.setBackgroundSkyTexture() # Default gradient sky
624 >>> visualizer.setBackgroundSkyTexture("sky_hdri.jpg", divisions=100)
625 """
626 if self.visualizer is None:
627 raise VisualizerError("Visualizer has been destroyed")
628
629 if not isinstance(divisions, _INT_TYPE) or divisions <= 0:
630 raise ValueError("Divisions must be a positive integer")
631
632 # Resolve texture file path if provided
633 resolved_path = None
634 if texture_file:
635 if not isinstance(texture_file, str):
636 raise ValueError("Texture file must be a string")
637 resolved_path = _resolve_user_path(texture_file)
638
639 try:
640 # resolved_path is already absolute when the caller supplied one; the
641 # default sky texture is a packaged asset the native code resolves
642 # relative to the working directory.
644 visualizer_wrapper.set_background_sky_texture(
645 self.visualizer,
646 resolved_path,
647 divisions
648 )
649 if resolved_path:
650 logger.debug(f"Sky texture background set: {resolved_path}, divisions={divisions}")
651 else:
652 logger.debug(f"Default sky texture background set with divisions={divisions}")
653 except Exception as e:
654 raise VisualizerError(f"Failed to set sky texture background: {e}")
655
656 def setLightDirection(self, direction: vec3) -> None:
657 """
658 Set light direction.
659
660 Args:
661 direction: Light direction vector as vec3 (will be normalized)
662
663 Raises:
664 VisualizerError: If light direction setting fails
665 ValueError: If direction is invalid
666 """
667 if self.visualizer is None:
668 raise VisualizerError("Visualizer has been destroyed")
669
670 # Validate DataType parameter
671 if not isinstance(direction, vec3):
672 raise ValueError(f"Direction must be a vec3, got {type(direction).__name__}")
673
674 # Check for zero vector
675 if direction.x == 0 and direction.y == 0 and direction.z == 0:
676 raise ValueError("Light direction cannot be zero vector")
677
678 try:
679 visualizer_wrapper.set_light_direction(self.visualizer, direction)
680 logger.debug(f"Light direction set to ({direction.x}, {direction.y}, {direction.z})")
681 except Exception as e:
682 raise VisualizerError(f"Failed to set light direction: {e}")
683
684 def setLightingModel(self, lighting_model: Union[int, str]) -> None:
685 """
686 Set lighting model.
687
688 Args:
689 lighting_model: Lighting model, either:
690 - 0 or "none": No lighting
691 - 1 or "phong": Phong shading
692 - 2 or "phong_shadowed": Phong shading with shadows
693
694 Raises:
695 VisualizerError: If lighting model setting fails
696 ValueError: If lighting model is invalid
697 """
698 if self.visualizer is None:
699 raise VisualizerError("Visualizer has been destroyed")
700
701 # Convert string to integer if needed
702 if isinstance(lighting_model, str):
703 lighting_model_lower = lighting_model.lower()
704 if lighting_model_lower in ['none', 'no', 'off']:
705 lighting_model = self.LIGHTING_NONE
706 elif lighting_model_lower in ['phong', 'phong_lighting']:
707 lighting_model = self.LIGHTING_PHONG
708 elif lighting_model_lower in ['phong_shadowed', 'phong_shadows', 'shadowed']:
709 lighting_model = self.LIGHTING_PHONG_SHADOWED
710 else:
711 raise ValueError(f"Unknown lighting model string: {lighting_model}")
712
713 # Validate integer value
714 if lighting_model not in [self.LIGHTING_NONE, self.LIGHTING_PHONG, self.LIGHTING_PHONG_SHADOWED]:
715 raise ValueError(f"Lighting model must be 0 (NONE), 1 (PHONG), or 2 (PHONG_SHADOWED), got {lighting_model}")
716
717 try:
718 visualizer_wrapper.set_lighting_model(self.visualizer, lighting_model)
719 model_names = {0: "NONE", 1: "PHONG", 2: "PHONG_SHADOWED"}
720 logger.debug(f"Lighting model set to {model_names.get(lighting_model, lighting_model)}")
721 except Exception as e:
722 raise VisualizerError(f"Failed to set lighting model: {e}")
723
724 def colorContextPrimitivesByData(self, data_name: str, uuids: Optional[List[int]] = None) -> None:
725 """
726 Color context primitives based on primitive data values.
727
728 This method maps primitive data values to colors using the current colormap.
729 The visualization will be updated to show data variations across primitives.
730
731 The data must have been previously set on the primitives in the Context using
732 context.setPrimitiveDataFloat(UUID, data_name, value) before calling this method.
733
734 Args:
735 data_name: Name of the primitive data to use for coloring.
736 This should match the data label used with setPrimitiveDataFloat().
737 uuids: Optional list of specific primitive UUIDs to color.
738 If None, all primitives in context will be colored.
739
740 Raises:
741 VisualizerError: If visualizer is not initialized or operation fails
742 ValueError: If data_name is invalid or UUIDs are malformed
743
744 Example:
745 >>> # Set data on primitives in context
746 >>> context.setPrimitiveDataFloat(patch_uuid, "radiation_flux_SW", 450.2)
747 >>> context.setPrimitiveDataFloat(triangle_uuid, "radiation_flux_SW", 320.1)
748 >>>
749 >>> # Build geometry and color by data
750 >>> visualizer.buildContextGeometry(context)
751 >>> visualizer.colorContextPrimitivesByData("radiation_flux_SW")
752 >>> visualizer.plotInteractive()
753
754 >>> # Color only specific primitives
755 >>> visualizer.colorContextPrimitivesByData("temperature", [uuid1, uuid2, uuid3])
756 """
757 if not self.visualizer:
758 raise VisualizerError("Visualizer not initialized")
759
760 if not data_name or not isinstance(data_name, str):
761 raise ValueError("Data name must be a non-empty string")
762
763 try:
764 if uuids is None:
765 # Color all primitives
766 visualizer_wrapper.color_context_primitives_by_data(self.visualizer, data_name)
767 logger.debug(f"Colored all primitives by data: {data_name}")
768 else:
769 # Color specific primitives
770 if not isinstance(uuids, (list, tuple)) or not uuids:
771 raise ValueError("UUIDs must be a non-empty list or tuple")
772 if not all(isinstance(uuid, _INT_TYPE) and uuid >= 0 for uuid in uuids):
773 raise ValueError("All UUIDs must be non-negative integers")
774
775 visualizer_wrapper.color_context_primitives_by_data_uuids(self.visualizer, data_name, list(uuids))
776 logger.debug(f"Colored {len(uuids)} primitives by data: {data_name}")
777
778 except ValueError:
779 # Re-raise ValueError as is
780 raise
781 except Exception as e:
782 raise VisualizerError(f"Failed to color primitives by data '{data_name}': {e}")
783
784 # Camera Control Methods
785
786 def setCameraFieldOfView(self, angle_FOV: float) -> None:
787 """
788 Set camera field of view angle.
789
790 Args:
791 angle_FOV: Field of view angle in degrees
792
793 Raises:
794 ValueError: If angle is invalid
795 VisualizerError: If operation fails
796 """
797 if not self.visualizer:
798 raise VisualizerError("Visualizer not initialized")
799
800 try:
801 float(angle_FOV)
802 except (TypeError, ValueError):
803 raise ValueError("Field of view angle must be numeric")
804 if angle_FOV <= 0 or angle_FOV >= 180:
805 raise ValueError("Field of view angle must be between 0 and 180 degrees")
806
807 try:
808 helios_lib.setCameraFieldOfView(self.visualizer, ctypes.c_float(angle_FOV))
809 except Exception as e:
810 raise VisualizerError(f"Failed to set camera field of view: {e}")
811
812 def getCameraPosition(self) -> Tuple[vec3, vec3]:
813 """
814 Get current camera position and look-at point.
815
816 Returns:
817 Tuple of (camera_position, look_at_point) as vec3 objects
818
819 Raises:
820 VisualizerError: If operation fails
821 """
822 if not self.visualizer:
823 raise VisualizerError("Visualizer not initialized")
824
825 try:
826 # Prepare output arrays
827 camera_pos = (ctypes.c_float * 3)()
828 look_at = (ctypes.c_float * 3)()
829
830 helios_lib.getCameraPosition(self.visualizer, camera_pos, look_at)
831
832 return (vec3(camera_pos[0], camera_pos[1], camera_pos[2]),
833 vec3(look_at[0], look_at[1], look_at[2]))
834 except Exception as e:
835 raise VisualizerError(f"Failed to get camera position: {e}")
836
837 def getBackgroundColor(self) -> RGBcolor:
838 """
839 Get current background color.
840
841 Returns:
842 Background color as RGBcolor object
843
844 Raises:
845 VisualizerError: If operation fails
846 """
847 if not self.visualizer:
848 raise VisualizerError("Visualizer not initialized")
849
850 try:
851 # Prepare output array
852 color = (ctypes.c_float * 3)()
853
854 helios_lib.getBackgroundColor(self.visualizer, color)
855
856 return RGBcolor(color[0], color[1], color[2])
857 except Exception as e:
858 raise VisualizerError(f"Failed to get background color: {e}")
859
860 # Lighting Control Methods
861
862 def setLightIntensityFactor(self, intensity_factor: float) -> None:
863 """
864 Set light intensity scaling factor.
865
866 Args:
867 intensity_factor: Light intensity scaling factor (typically 0.1 to 10.0)
868
869 Raises:
870 ValueError: If intensity factor is invalid
871 VisualizerError: If operation fails
872 """
873 if not self.visualizer:
874 raise VisualizerError("Visualizer not initialized")
875
876 if not isinstance(intensity_factor, _NUMERIC_TYPES):
877 raise ValueError("Light intensity factor must be numeric")
878 if intensity_factor <= 0:
879 raise ValueError("Light intensity factor must be positive")
880
881 try:
882 helios_lib.setLightIntensityFactor(self.visualizer, ctypes.c_float(intensity_factor))
883 except Exception as e:
884 raise VisualizerError(f"Failed to set light intensity factor: {e}")
885
886 # Window and Display Methods
887
888 def getWindowSize(self) -> Tuple[int, int]:
889 """
890 Get window size in pixels.
891
892 Returns:
893 Tuple of (width, height) in pixels
894
895 Raises:
896 VisualizerError: If operation fails
897 """
898 if not self.visualizer:
899 raise VisualizerError("Visualizer not initialized")
900
901 try:
902 width = ctypes.c_uint()
903 height = ctypes.c_uint()
904
905 helios_lib.getWindowSize(self.visualizer, ctypes.byref(width), ctypes.byref(height))
906
907 return (width.value, height.value)
908 except Exception as e:
909 raise VisualizerError(f"Failed to get window size: {e}")
910
911 def getFramebufferSize(self) -> Tuple[int, int]:
912 """
913 Get framebuffer size in pixels.
914
915 Returns:
916 Tuple of (width, height) in pixels
917
918 Raises:
919 VisualizerError: If operation fails
920 """
921 if not self.visualizer:
922 raise VisualizerError("Visualizer not initialized")
923
924 try:
925 width = ctypes.c_uint()
926 height = ctypes.c_uint()
927
928 helios_lib.getFramebufferSize(self.visualizer, ctypes.byref(width), ctypes.byref(height))
929
930 return (width.value, height.value)
931 except Exception as e:
932 raise VisualizerError(f"Failed to get framebuffer size: {e}")
933
934 def printWindowDefault(self) -> None:
935 """
936 Print window with default filename.
937
938 Raises:
939 VisualizerError: If operation fails
940 """
942 if not self.visualizer:
943 raise VisualizerError("Visualizer not initialized")
944
945 try:
946 helios_lib.printWindowDefault(self.visualizer)
947 except Exception as e:
948 raise VisualizerError(f"Failed to print window: {e}")
949
950 def displayImageFromPixels(self, pixel_data: List[int], width: int, height: int) -> None:
951 """
952 Display image from RGBA pixel data.
953
954 Args:
955 pixel_data: RGBA pixel data as list of integers (0-255)
956 width: Image width in pixels
957 height: Image height in pixels
958
959 Raises:
960 ValueError: If parameters are invalid
961 VisualizerError: If operation fails
962 """
963 if not self.visualizer:
964 raise VisualizerError("Visualizer not initialized")
965
966 if not isinstance(pixel_data, (list, tuple)):
967 raise ValueError("Pixel data must be a list or tuple")
968 if not isinstance(width, _INT_TYPE) or width <= 0:
969 raise ValueError("Width must be a positive integer")
970 if not isinstance(height, _INT_TYPE) or height <= 0:
971 raise ValueError("Height must be a positive integer")
972
973 expected_size = width * height * 4 # RGBA format
974 if len(pixel_data) != expected_size:
975 raise ValueError(f"Pixel data size mismatch: expected {expected_size}, got {len(pixel_data)}")
976
977 try:
978 # Convert to ctypes array
979 pixel_array = (ctypes.c_ubyte * len(pixel_data))(*pixel_data)
980 helios_lib.displayImageFromPixels(self.visualizer, pixel_array, width, height)
981 except Exception as e:
982 raise VisualizerError(f"Failed to display image from pixels: {e}")
983
984 def displayImageFromFile(self, filename: str) -> None:
985 """
986 Display image from file.
987
988 Args:
989 filename: Path to image file
990
991 Raises:
992 ValueError: If filename is invalid
993 VisualizerError: If operation fails
994 """
995 if not self.visualizer:
996 raise VisualizerError("Visualizer not initialized")
997
998 if not isinstance(filename, str) or not filename.strip():
999 raise ValueError("Filename must be a non-empty string")
1000
1001 try:
1002 helios_lib.displayImageFromFile(self.visualizer, filename.encode('utf-8'))
1003 except Exception as e:
1004 raise VisualizerError(f"Failed to display image from file '{filename}': {e}")
1005
1006 def displayImageWithBoundingBoxes(self, image_file: str, bbox_file: str,
1007 classes_file: str = "", line_width: float = 2.0,
1008 fontsize: int = 12) -> None:
1009 """
1010 Display an image with YOLO bounding boxes overlaid.
1011
1012 Each box is drawn as a colored outline with its class name on a filled chip in
1013 the box's top-left corner. Boxes are colored by class ID from a fixed palette of
1014 seven colors, so classes whose IDs differ by a multiple of seven share a color.
1015
1016 This reads the annotation format written by
1017 :meth:`pyhelios.RadiationModel.writeImageBoundingBoxes`.
1018
1019 Args:
1020 image_file: Path to the image file (JPEG or PNG)
1021 bbox_file: Path to the YOLO-format bounding box annotation file
1022 classes_file: Path to the class name file. If empty (the default), a file
1023 named "classes.txt" beside ``bbox_file`` is used when one exists;
1024 otherwise boxes are labeled with their numeric class ID.
1025 line_width: Width of the box outlines in screen pixels
1026 fontsize: Size of the class label font in points
1027
1028 Raises:
1029 ValueError: If a path is not a non-empty string, or a numeric argument is out of range
1030 VisualizerError: If the operation fails
1031
1032 Note:
1033 Like :meth:`displayImageFromFile`, this clears any existing geometry and does
1034 not return until the window is closed.
1035
1036 Example:
1037 >>> vis.displayImageWithBoundingBoxes("scene.jpeg", "scene.txt")
1038 """
1039 if not self.visualizer:
1040 raise VisualizerError("Visualizer not initialized")
1041
1042 if not isinstance(image_file, str) or not image_file.strip():
1043 raise ValueError("Image file must be a non-empty string")
1044 if not isinstance(bbox_file, str) or not bbox_file.strip():
1045 raise ValueError("Bounding box file must be a non-empty string")
1046 if classes_file and not isinstance(classes_file, str):
1047 raise ValueError(
1048 f"Classes file must be a string, got {type(classes_file).__name__}")
1049 if line_width <= 0:
1050 raise ValueError(f"Line width must be positive, got {line_width}")
1051 if not isinstance(fontsize, _INT_TYPE) or fontsize <= 0:
1052 raise ValueError(f"Font size must be a positive integer, got {fontsize}")
1053
1054 try:
1055 visualizer_wrapper.display_image_with_bounding_boxes(
1056 self.visualizer,
1057 _resolve_user_path(image_file),
1058 _resolve_user_path(bbox_file),
1059 _resolve_user_path(classes_file) if classes_file else "",
1060 float(line_width), int(fontsize))
1061 except Exception as e:
1062 raise VisualizerError(
1063 f"Failed to display image '{image_file}' with bounding boxes: {e}")
1064
1065 def displayImageWithSegmentationMasks(self, image_file: str, mask_file: str,
1066 fill_opacity: float = 0.4, line_width: float = 2.0,
1067 fontsize: int = 12, show_labels: bool = True) -> None:
1068 """
1069 Display an image with COCO segmentation masks overlaid.
1070
1071 Each mask is drawn as a translucent filled polygon with a solid outline and its
1072 class name on a filled chip. Masks are colored by their position in the file
1073 rather than by class, so two touching objects of the same class stay
1074 distinguishable.
1075
1076 This reads the annotation format written by
1077 :meth:`pyhelios.RadiationModel.writeImageSegmentationMasks`.
1078
1079 Args:
1080 image_file: Path to the image file (JPEG or PNG)
1081 mask_file: Path to the COCO JSON segmentation mask file
1082 fill_opacity: Opacity of the translucent fill, between 0 and 1. A value of 0
1083 draws the outline without a fill.
1084 line_width: Width of the mask outlines in screen pixels
1085 fontsize: Size of the class label font in points
1086 show_labels: Whether to draw the class name chip on each mask. Pass False to
1087 see the masks alone, which helps when many overlapping chips would cover
1088 the image.
1089
1090 Raises:
1091 ValueError: If a path is not a non-empty string, or a numeric argument is out of range
1092 VisualizerError: If the operation fails
1093
1094 Note:
1095 Like :meth:`displayImageFromFile`, this clears any existing geometry and does
1096 not return until the window is closed.
1097
1098 Example:
1099 >>> vis.displayImageWithSegmentationMasks("scene.jpeg", "annotations.json")
1100 """
1101 if not self.visualizer:
1102 raise VisualizerError("Visualizer not initialized")
1103
1104 if not isinstance(image_file, str) or not image_file.strip():
1105 raise ValueError("Image file must be a non-empty string")
1106 if not isinstance(mask_file, str) or not mask_file.strip():
1107 raise ValueError("Mask file must be a non-empty string")
1108 if not 0.0 <= fill_opacity <= 1.0:
1109 raise ValueError(f"Fill opacity must be between 0 and 1, got {fill_opacity}")
1110 if line_width <= 0:
1111 raise ValueError(f"Line width must be positive, got {line_width}")
1112 if not isinstance(fontsize, _INT_TYPE) or fontsize <= 0:
1113 raise ValueError(f"Font size must be a positive integer, got {fontsize}")
1114 if not isinstance(show_labels, bool):
1115 raise ValueError(
1116 f"Show labels must be a boolean, got {type(show_labels).__name__}")
1117
1118 try:
1119 visualizer_wrapper.display_image_with_segmentation_masks(
1120 self.visualizer,
1121 _resolve_user_path(image_file),
1122 _resolve_user_path(mask_file),
1123 float(fill_opacity), float(line_width), int(fontsize), show_labels)
1124 except Exception as e:
1125 raise VisualizerError(
1126 f"Failed to display image '{image_file}' with segmentation masks: {e}")
1127
1128 def enableExactColorMode(self) -> None:
1129 """
1130 Render primitive colors exactly as they are set in the Context.
1131
1132 By default the fragment shader multiplies primitive colors by 1.5, which
1133 brightens ordinary renders but means a color read back out of the framebuffer is
1134 not the color that was set. This mode disables that multiplier, which is required
1135 when the framebuffer carries data rather than an image -- for example when object
1136 IDs are encoded as RGB values and decoded from the rendered pixels.
1137
1138 Exact reproduction additionally requires that no lighting be applied (see
1139 :meth:`setLightingModel` with ``LIGHTING_NONE``, the default) and that the
1140 Visualizer was constructed with ``antialiasing_samples=0``, since antialiasing
1141 blends colors at primitive edges and produces pixels that decode to meaningless
1142 IDs.
1143
1144 This also disables the linear-light pipeline (see :meth:`disableLinearPipeline`),
1145 since tone mapping would likewise alter the values read back.
1146
1147 Raises:
1148 VisualizerError: If the operation fails
1149
1150 Example:
1151 >>> vis = Visualizer(800, 600, antialiasing_samples=0)
1152 >>> vis.enableExactColorMode()
1153 """
1154 if not self.visualizer:
1155 raise VisualizerError("Visualizer not initialized")
1156 try:
1157 visualizer_wrapper.enable_exact_color_mode(self.visualizer)
1158 except Exception as e:
1159 raise VisualizerError(f"Failed to enable exact color mode: {e}")
1160
1161 def disableExactColorMode(self) -> None:
1162 """
1163 Restore the default brightening of primitive colors.
1164
1165 This also restores the linear-light pipeline (see :meth:`enableLinearPipeline`).
1166
1167 Raises:
1168 VisualizerError: If the operation fails
1169
1170 Example:
1171 >>> vis.disableExactColorMode()
1172 """
1173 if not self.visualizer:
1174 raise VisualizerError("Visualizer not initialized")
1175 try:
1176 visualizer_wrapper.disable_exact_color_mode(self.visualizer)
1177 except Exception as e:
1178 raise VisualizerError(f"Failed to disable exact color mode: {e}")
1179
1180 def enableLinearPipeline(self) -> None:
1181 """
1182 Enable the physically-based linear-light rendering pipeline.
1183
1184 This is the default. Albedo is decoded from sRGB to linear light before shading,
1185 and the shaded radiance is tone-mapped through an ACES filmic curve and
1186 re-encoded to sRGB. Compared with shading directly on non-linear sRGB values,
1187 mid-tones are brighter, shadow terminators are smoother, and bright surfaces roll
1188 off rather than clipping flat against the 8-bit framebuffer.
1189
1190 Raises:
1191 VisualizerError: If the operation fails
1192
1193 Example:
1194 >>> vis.enableLinearPipeline()
1195 """
1196 if not self.visualizer:
1197 raise VisualizerError("Visualizer not initialized")
1198 try:
1199 visualizer_wrapper.enable_linear_pipeline(self.visualizer)
1200 except Exception as e:
1201 raise VisualizerError(f"Failed to enable linear pipeline: {e}")
1202
1203 def disableLinearPipeline(self) -> None:
1204 """
1205 Disable the linear-light pipeline, shading directly in sRGB space.
1206
1207 This reproduces the rendering behavior of helios-core versions before 1.3.83.
1208
1209 Raises:
1210 VisualizerError: If the operation fails
1211
1212 Example:
1213 >>> vis.disableLinearPipeline()
1214 """
1215 if not self.visualizer:
1216 raise VisualizerError("Visualizer not initialized")
1217 try:
1218 visualizer_wrapper.disable_linear_pipeline(self.visualizer)
1219 except Exception as e:
1220 raise VisualizerError(f"Failed to disable linear pipeline: {e}")
1221
1222 def isLinearPipelineEnabled(self) -> bool:
1223 """
1224 Check whether the linear-light rendering pipeline is enabled.
1225
1226 Returns:
1227 True if the linear pipeline is enabled
1228
1229 Raises:
1230 VisualizerError: If the operation fails
1231
1232 Example:
1233 >>> vis.isLinearPipelineEnabled()
1234 True
1235 """
1236 if not self.visualizer:
1237 raise VisualizerError("Visualizer not initialized")
1238 try:
1239 return visualizer_wrapper.is_linear_pipeline_enabled(self.visualizer)
1240 except Exception as e:
1241 raise VisualizerError(f"Failed to query linear pipeline state: {e}")
1242
1243 def setExposure(self, exposure: float) -> None:
1244 """
1245 Set the linear exposure multiplier applied before tone mapping.
1246
1247 Only has an effect while the linear pipeline is enabled.
1248
1249 Args:
1250 exposure: Exposure multiplier; must be positive
1251
1252 Raises:
1253 ValueError: If exposure is not positive
1254 VisualizerError: If the operation fails
1255
1256 Example:
1257 >>> vis.setExposure(1.5)
1258 """
1259 if not self.visualizer:
1260 raise VisualizerError("Visualizer not initialized")
1261 if not isinstance(exposure, (int, float)) or isinstance(exposure, bool):
1262 raise ValueError(f"Exposure must be a number, got {type(exposure).__name__}")
1263 if exposure <= 0:
1264 raise ValueError(f"Exposure must be positive, got {exposure}")
1265 try:
1266 visualizer_wrapper.set_exposure(self.visualizer, float(exposure))
1267 except Exception as e:
1268 raise VisualizerError(f"Failed to set exposure: {e}")
1269
1270 def getExposure(self) -> float:
1271 """
1272 Get the linear exposure multiplier applied before tone mapping.
1273
1274 Returns:
1275 Current exposure multiplier
1276
1277 Raises:
1278 VisualizerError: If the operation fails
1279
1280 Example:
1281 >>> vis.getExposure()
1282 1.0
1283 """
1284 if not self.visualizer:
1285 raise VisualizerError("Visualizer not initialized")
1286 try:
1287 return visualizer_wrapper.get_exposure(self.visualizer)
1288 except Exception as e:
1289 raise VisualizerError(f"Failed to get exposure: {e}")
1290
1291 def setPhongMaterial(self, ambient: float, diffuse: float, specular: float,
1292 shininess: float) -> None:
1293 """
1294 Set the Phong material parameters used to shade Context primitives.
1296 Surfaces are shaded as ``ambient*A + diffuse*max(0, N.L) +
1297 specular*max(0, N.H)^shininess``, where ``A`` is the hemispheric ambient term set
1298 by :meth:`setAmbientColors`. Setting ``specular`` to zero removes the highlight,
1299 recovering the appearance of helios-core versions before 1.3.83. Has no effect
1300 under ``LIGHTING_NONE``.
1301
1302 Individual materials can override any of these parameters by attaching
1303 ``phong_ambient``, ``phong_diffuse``, ``phong_specular`` or ``phong_shininess``
1304 material data with :meth:`Context.setMaterialDataFloat`; each parameter falls
1305 back individually to the value set here.
1306
1307 Args:
1308 ambient: Ambient reflectance weight
1309 diffuse: Diffuse reflectance weight
1310 specular: Specular highlight strength
1311 shininess: Specular exponent controlling highlight tightness
1312
1313 Raises:
1314 VisualizerError: If the operation fails
1315
1316 Example:
1317 >>> vis.setPhongMaterial(1.0, 0.8, 0.0, 32.0) # no specular highlight
1318 """
1319 if not self.visualizer:
1320 raise VisualizerError("Visualizer not initialized")
1321 try:
1322 visualizer_wrapper.set_phong_material(self.visualizer, float(ambient),
1323 float(diffuse), float(specular),
1324 float(shininess))
1325 except Exception as e:
1326 raise VisualizerError(f"Failed to set Phong material: {e}")
1327
1328 def getPhongMaterial(self) -> Tuple[float, float, float, float]:
1329 """
1330 Get the Phong material parameters used to shade Context primitives.
1331
1332 Returns:
1333 Tuple of (ambient, diffuse, specular, shininess)
1334
1335 Raises:
1336 VisualizerError: If the operation fails
1337
1338 Example:
1339 >>> ambient, diffuse, specular, shininess = vis.getPhongMaterial()
1340 """
1341 if not self.visualizer:
1342 raise VisualizerError("Visualizer not initialized")
1343 try:
1344 return visualizer_wrapper.get_phong_material(self.visualizer)
1345 except Exception as e:
1346 raise VisualizerError(f"Failed to get Phong material: {e}")
1347
1348 def setAmbientColors(self, sky_color: RGBcolor, ground_color: RGBcolor) -> None:
1349 """
1350 Set the hemispheric ambient sky and ground-bounce colors.
1351
1352 Ambient light is blended between the two according to surface orientation:
1353 upward-facing surfaces pick up ``sky_color``, downward-facing surfaces
1354 ``ground_color``. Setting both to the same value recovers the single constant
1355 ambient term used before helios-core 1.3.83.
1356
1357 Args:
1358 sky_color: Color of light arriving from above
1359 ground_color: Color of light bouncing from below
1360
1361 Raises:
1362 ValueError: If either argument is not an RGBcolor
1363 VisualizerError: If the operation fails
1364
1365 Example:
1366 >>> vis.setAmbientColors(RGBcolor(0.5, 0.6, 0.75), RGBcolor(0.35, 0.3, 0.22))
1367 """
1368 if not self.visualizer:
1369 raise VisualizerError("Visualizer not initialized")
1370 if not isinstance(sky_color, RGBcolor):
1371 raise ValueError(f"sky_color must be an RGBcolor, got {type(sky_color).__name__}")
1372 if not isinstance(ground_color, RGBcolor):
1373 raise ValueError(f"ground_color must be an RGBcolor, got {type(ground_color).__name__}")
1374 try:
1375 visualizer_wrapper.set_ambient_colors(
1376 self.visualizer,
1377 (sky_color.r, sky_color.g, sky_color.b),
1378 (ground_color.r, ground_color.g, ground_color.b),
1380 except Exception as e:
1381 raise VisualizerError(f"Failed to set ambient colors: {e}")
1382
1383 def getAmbientSkyColor(self) -> RGBcolor:
1384 """
1385 Get the hemispheric ambient sky color.
1386
1387 Returns:
1388 Color of ambient light arriving from above
1389
1390 Raises:
1391 VisualizerError: If the operation fails
1392
1393 Example:
1394 >>> vis.getAmbientSkyColor()
1395 """
1396 if not self.visualizer:
1397 raise VisualizerError("Visualizer not initialized")
1398 try:
1399 r, g, b = visualizer_wrapper.get_ambient_sky_color(self.visualizer)
1400 return RGBcolor(r, g, b)
1401 except Exception as e:
1402 raise VisualizerError(f"Failed to get ambient sky color: {e}")
1403
1404 def getAmbientGroundColor(self) -> RGBcolor:
1405 """
1406 Get the hemispheric ambient ground-bounce color.
1408 Returns:
1409 Color of ambient light bouncing from below
1410
1411 Raises:
1412 VisualizerError: If the operation fails
1413
1414 Example:
1415 >>> vis.getAmbientGroundColor()
1416 """
1417 if not self.visualizer:
1418 raise VisualizerError("Visualizer not initialized")
1419 try:
1420 r, g, b = visualizer_wrapper.get_ambient_ground_color(self.visualizer)
1421 return RGBcolor(r, g, b)
1422 except Exception as e:
1423 raise VisualizerError(f"Failed to get ambient ground color: {e}")
1424
1425 def enableSmoothShading(self) -> None:
1426 """
1427 Enable smooth per-vertex-normal shading.
1429 This is the default. Only geometry that supplies distinct vertex normals is
1430 affected: Sphere, Tube and Cone objects, and Polymesh objects loaded from an OBJ
1431 or PLY file that carries them. Patches, triangles and voxels added directly to
1432 the Context carry the face normal replicated across their vertices and render
1433 identically either way.
1434
1435 Raises:
1436 VisualizerError: If the operation fails
1437
1438 Example:
1439 >>> vis.enableSmoothShading()
1440 """
1441 if not self.visualizer:
1442 raise VisualizerError("Visualizer not initialized")
1443 try:
1444 visualizer_wrapper.enable_smooth_shading(self.visualizer)
1445 except Exception as e:
1446 raise VisualizerError(f"Failed to enable smooth shading: {e}")
1447
1448 def disableSmoothShading(self) -> None:
1449 """
1450 Select flat (per-face) shading.
1451
1452 Useful for alpha-masked cutout geometry such as leaf textures, where interpolated
1453 normals can look worse than a single flat normal per face.
1454
1455 Raises:
1456 VisualizerError: If the operation fails
1457
1458 Example:
1459 >>> vis.disableSmoothShading()
1460 """
1461 if not self.visualizer:
1462 raise VisualizerError("Visualizer not initialized")
1463 try:
1464 visualizer_wrapper.disable_smooth_shading(self.visualizer)
1465 except Exception as e:
1466 raise VisualizerError(f"Failed to disable smooth shading: {e}")
1467
1468 def isSmoothShadingEnabled(self) -> bool:
1469 """
1470 Check whether smooth per-vertex-normal shading is enabled.
1471
1472 Returns:
1473 True if smooth shading is enabled
1474
1475 Raises:
1476 VisualizerError: If the operation fails
1477
1478 Example:
1479 >>> vis.isSmoothShadingEnabled()
1480 True
1481 """
1482 if not self.visualizer:
1483 raise VisualizerError("Visualizer not initialized")
1484 try:
1485 return visualizer_wrapper.is_smooth_shading_enabled(self.visualizer)
1486 except Exception as e:
1487 raise VisualizerError(f"Failed to query smooth shading state: {e}")
1488
1489 def isHeadlessMultisamplingActive(self) -> bool:
1490 """
1491 Check whether headless rendering obtained multisampled framebuffer attachments.
1492
1493 Headless rendering draws into a multisampled framebuffer using the sample count
1494 given to the constructor and resolves it before readback, so saved images are
1495 anti-aliased. The requested count is clamped to the driver maximum, and a driver
1496 that refuses the attachments falls back silently -- this query is how to detect
1497 that.
1498
1499 Returns:
1500 True if multisampled attachments were obtained
1501
1502 Note:
1503 macOS drives OpenGL through a translation layer that accepts the attachments
1504 but does not rasterize into them, so this can report True while the saved
1505 image is not actually anti-aliased.
1506
1507 Raises:
1508 VisualizerError: If the operation fails
1509
1510 Example:
1511 >>> vis.isHeadlessMultisamplingActive()
1512 True
1513 """
1514 if not self.visualizer:
1515 raise VisualizerError("Visualizer not initialized")
1516 try:
1517 return visualizer_wrapper.is_headless_multisampling_active(self.visualizer)
1518 except Exception as e:
1519 raise VisualizerError(f"Failed to query headless multisampling state: {e}")
1520
1521 def getTextboxSize(self, textstring: str, fontsize: int, fontname: str) -> vec2:
1522 """
1523 Measure the rendered size of a text string without adding it to the visualizer.
1524
1525 Returns the extent that :meth:`addTextboxByCenter` would occupy for the same
1526 string, font and font size. The width is the sum of the glyph advances, so it
1527 includes the side bearings; the height is that of the tallest glyph in the
1528 string, so it depends on which characters the string contains. The ``_`` and
1529 ``^`` subscript and superscript markers are handled as
1530 :meth:`addTextboxByCenter` handles them: they occupy no width themselves and
1531 halve the size of the character that follows.
1532
1533 Args:
1534 textstring: Text to be measured
1535 fontsize: Size of the text font in points
1536 fontname: Name of a font in the visualizer fonts directory, e.g.
1537 "OpenSans-Regular"
1538
1539 Returns:
1540 Width and height of the text in window-normalized units.
1541
1542 Raises:
1543 ValueError: If an argument is invalid
1544 VisualizerError: If the operation fails
1545
1546 Note:
1547 The result depends on the current framebuffer dimensions and DPI scale, and
1548 therefore changes when the window is resized.
1549
1550 Example:
1551 >>> size = vis.getTextboxSize("Leaf area", 14, "OpenSans-Regular")
1552 >>> print(f"{size.x:.3f} x {size.y:.3f}")
1553 """
1554 if not self.visualizer:
1555 raise VisualizerError("Visualizer not initialized")
1556
1557 if not isinstance(textstring, str):
1558 raise ValueError(
1559 f"Text string must be a string, got {type(textstring).__name__}")
1560 if not isinstance(fontsize, _INT_TYPE) or fontsize <= 0:
1561 raise ValueError(f"Font size must be a positive integer, got {fontsize}")
1562 if not isinstance(fontname, str) or not fontname.strip():
1563 raise ValueError("Font name must be a non-empty string")
1564
1565 try:
1566 # The font is read from the visualizer asset directory, which the native
1567 # code resolves relative to the current working directory. Without this the
1568 # lookup fails for any caller not already sitting in that directory.
1570 width, height = visualizer_wrapper.get_textbox_size(
1571 self.visualizer, textstring, int(fontsize), fontname)
1572 return vec2(width, height)
1573 except Exception as e:
1574 raise VisualizerError(f"Failed to measure text size: {e}")
1575
1576 # Window Data Access Methods
1577
1578 def getWindowPixelsRGB(self, buffer: Optional[List[int]] = None):
1579 """
1580 Get RGB pixel data from the current window.
1581
1582 Data is stored as r-g-b * column * row, so indices (0,1,2) are the RGB values for row 0
1583 column 0, indices (3,4,5) are row 0 column 1, and so on.
1584
1585 Call without arguments to have the buffer allocated for you at the correct size -- this is
1586 the recommended form and cannot be undersized::
1587
1588 pixels, width, height = visualizer.getWindowPixelsRGB()
1589
1590 Args:
1591 buffer: Optional pre-allocated list to fill in place. It must hold exactly
1592 ``3 * width * height`` elements, where width and height come from
1593 :meth:`getFramebufferSize` -- **not** :meth:`getWindowSize` and not the dimensions
1594 passed to the constructor. On a high-DPI (Retina) display the framebuffer is larger
1595 than the window, typically by a factor of two per axis, so a buffer sized from the
1596 window dimensions is four times too small.
1597
1598 Returns:
1599 If ``buffer`` is None, a tuple of ``(pixel_data, width, height)``. Otherwise ``None``;
1600 ``buffer`` is filled in place.
1601
1602 Raises:
1603 ValueError: If ``buffer`` is not a list, or is not sized for the current framebuffer
1604 VisualizerError: If operation fails
1605 """
1607 if not self.visualizer:
1608 raise VisualizerError("Visualizer not initialized")
1609
1610 if buffer is None:
1611 try:
1612 width = ctypes.c_uint()
1613 height = ctypes.c_uint()
1614 size = ctypes.c_uint()
1615 ptr = helios_lib.getWindowPixelsRGB_sized(
1616 self.visualizer, ctypes.byref(width), ctypes.byref(height), ctypes.byref(size)
1618 visualizer_wrapper._check_for_helios_error()
1619 if not ptr or size.value == 0:
1620 raise VisualizerError(
1621 "getWindowPixelsRGB() returned no pixel data; the framebuffer reported "
1622 f"{width.value}x{height.value}"
1623 )
1624 arr = ctypes.cast(ptr, ctypes.POINTER(ctypes.c_uint * size.value)).contents
1625 return ([int(v) for v in arr], width.value, height.value)
1626 except VisualizerError:
1627 raise
1628 except Exception as e:
1629 raise VisualizerError(f"Failed to get window pixels: {e}")
1630
1631 if not isinstance(buffer, list):
1632 raise ValueError("Buffer must be a list")
1633
1634 # Reject an undersized buffer here rather than letting the native call write past its end.
1635 fb_width, fb_height = self.getFramebufferSize()
1636 required = 3 * fb_width * fb_height
1637 if len(buffer) != required:
1638 raise ValueError(
1639 f"Buffer must hold exactly {required} elements for the current framebuffer of "
1640 f"{fb_width}x{fb_height} (3*width*height), got {len(buffer)}. Note the framebuffer "
1641 f"may be larger than the window on a high-DPI display; use getFramebufferSize(), "
1642 f"not getWindowSize(). Call getWindowPixelsRGB() with no argument to have the "
1643 f"buffer allocated for you."
1644 )
1645
1646 try:
1647 # Convert buffer to ctypes array
1648 buffer_array = (ctypes.c_uint * len(buffer))(*buffer)
1649 helios_lib.getWindowPixelsRGB(self.visualizer, buffer_array)
1650 visualizer_wrapper._check_for_helios_error()
1651
1652 # Copy results back to Python list
1653 for i in range(len(buffer)):
1654 buffer[i] = buffer_array[i]
1655 except VisualizerError:
1656 raise
1657 except Exception as e:
1658 raise VisualizerError(f"Failed to get window pixels: {e}")
1659
1660 def getDepthMap(self) -> Tuple[List[float], int, int]:
1661 """
1662 Get depth map from current window.
1663
1664 Returns:
1665 Tuple of (depth_pixels, width, height)
1666
1667 Raises:
1668 VisualizerError: If operation fails
1669 """
1671 if not self.visualizer:
1672 raise VisualizerError("Visualizer not initialized")
1673
1674 try:
1675 depth_ptr = ctypes.POINTER(ctypes.c_float)()
1676 width = ctypes.c_uint()
1677 height = ctypes.c_uint()
1678 buffer_size = ctypes.c_uint()
1679
1680 helios_lib.getDepthMap(self.visualizer, ctypes.byref(depth_ptr),
1681 ctypes.byref(width), ctypes.byref(height),
1682 ctypes.byref(buffer_size))
1683
1684 # Convert to Python list
1685 if depth_ptr and buffer_size.value > 0:
1686 depth_data = [depth_ptr[i] for i in range(buffer_size.value)]
1687 return (depth_data, width.value, height.value)
1688 else:
1689 return ([], 0, 0)
1690 except Exception as e:
1691 raise VisualizerError(f"Failed to get depth map: {e}")
1692
1693 def plotDepthMap(self) -> None:
1694 """
1695 Plot depth map visualization.
1696
1697 Raises:
1698 VisualizerError: If operation fails
1699 """
1701 if not self.visualizer:
1702 raise VisualizerError("Visualizer not initialized")
1703
1704 try:
1705 helios_lib.plotDepthMap(self.visualizer)
1706 except Exception as e:
1707 raise VisualizerError(f"Failed to plot depth map: {e}")
1708
1709 # Geometry Management Methods
1710
1711 def clearGeometry(self) -> None:
1712 """
1713 Clear all geometry from visualizer.
1714
1715 Warning:
1716 Do NOT use this to refresh a scene between animation frames. The
1717 visualizer syncs from the Context incrementally using the Context's
1718 dirty flags. Clearing discards the visualizer's geometry while the
1719 Context primitives remain marked clean, so the next rebuild pulls in
1720 nothing and subsequent frames render empty -- printWindow() then
1721 silently writes no file.
1722
1723 To animate a changing Context, just call buildContextGeometry()
1724 and plotUpdate() each frame without clearing; additions, deletions
1725 and modifications are all picked up automatically.
1726
1727 Raises:
1728 VisualizerError: If operation fails
1729 """
1730 if not self.visualizer:
1731 raise VisualizerError("Visualizer not initialized")
1732
1733 try:
1734 helios_lib.clearGeometry(self.visualizer)
1735 except Exception as e:
1736 raise VisualizerError(f"Failed to clear geometry: {e}")
1737
1738 def clearContextGeometry(self) -> None:
1739 """
1740 Clear context geometry from visualizer.
1742 Raises:
1743 VisualizerError: If operation fails
1744 """
1745 if not self.visualizer:
1746 raise VisualizerError("Visualizer not initialized")
1747
1748 try:
1749 helios_lib.clearContextGeometry(self.visualizer)
1750 except Exception as e:
1751 raise VisualizerError(f"Failed to clear context geometry: {e}")
1752
1753 def deleteGeometry(self, geometry_id: int) -> None:
1754 """
1755 Delete specific geometry by ID.
1757 Args:
1758 geometry_id: ID of geometry to delete
1759
1760 Raises:
1761 ValueError: If geometry ID is invalid
1762 VisualizerError: If operation fails
1763 """
1764 if not self.visualizer:
1765 raise VisualizerError("Visualizer not initialized")
1766
1767 if not isinstance(geometry_id, _INT_TYPE) or geometry_id < 0:
1768 raise ValueError("Geometry ID must be a non-negative integer")
1769
1770 try:
1771 helios_lib.deleteGeometry(self.visualizer, geometry_id)
1772 except Exception as e:
1773 raise VisualizerError(f"Failed to delete geometry {geometry_id}: {e}")
1774
1776 """
1777 Update context primitive colors.
1778
1779 Raises:
1780 VisualizerError: If operation fails
1781 """
1782 if not self.visualizer:
1783 raise VisualizerError("Visualizer not initialized")
1784
1785 try:
1786 helios_lib.updateContextPrimitiveColors(self.visualizer)
1787 except Exception as e:
1788 raise VisualizerError(f"Failed to update context primitive colors: {e}")
1789
1790 # Geometry Vertex Manipulation Methods (v1.3.53+)
1791
1792 def getGeometryVertices(self, geometry_id: int) -> List[vec3]:
1793 """
1794 Get vertices of a geometry primitive.
1795
1796 Args:
1797 geometry_id: Unique identifier of the geometry primitive
1798
1799 Returns:
1800 List of vertices as vec3 objects
1801
1802 Raises:
1803 ValueError: If geometry ID is invalid
1804 VisualizerError: If operation fails
1805
1806 Example:
1807 >>> # Get vertices of a specific geometry
1808 >>> vertices = visualizer.getGeometryVertices(geometry_id)
1809 >>> for vertex in vertices:
1810 ... print(f"Vertex: ({vertex.x}, {vertex.y}, {vertex.z})")
1811 """
1812 if not self.visualizer:
1813 raise VisualizerError("Visualizer not initialized")
1814
1815 if not isinstance(geometry_id, _INT_TYPE) or geometry_id < 0:
1816 raise ValueError("Geometry ID must be a non-negative integer")
1817
1818 try:
1819 vertices_list = visualizer_wrapper.get_geometry_vertices(self.visualizer, geometry_id)
1820 # Convert [[x,y,z], ...] to [vec3(), ...]
1821 return [vec3(v[0], v[1], v[2]) for v in vertices_list]
1822 except Exception as e:
1823 raise VisualizerError(f"Failed to get geometry vertices: {e}")
1824
1825 def setGeometryVertices(self, geometry_id: int, vertices: List[vec3]) -> None:
1826 """
1827 Set vertices of a geometry primitive.
1828
1829 This allows dynamic modification of geometry shapes during visualization.
1830 Useful for animating geometry or adjusting shapes based on simulation results.
1831
1832 Args:
1833 geometry_id: Unique identifier of the geometry primitive
1834 vertices: List of new vertices as vec3 objects
1835
1836 Raises:
1837 ValueError: If parameters are invalid
1838 VisualizerError: If operation fails
1839
1840 Example:
1841 >>> # Modify vertices of an existing geometry
1842 >>> vertices = visualizer.getGeometryVertices(geometry_id)
1843 >>> # Scale all vertices by 2x
1844 >>> scaled_vertices = [vec3(v.x*2, v.y*2, v.z*2) for v in vertices]
1845 >>> visualizer.setGeometryVertices(geometry_id, scaled_vertices)
1846 """
1847 if not self.visualizer:
1848 raise VisualizerError("Visualizer not initialized")
1849
1850 if not isinstance(geometry_id, _INT_TYPE) or geometry_id < 0:
1851 raise ValueError("Geometry ID must be a non-negative integer")
1852
1853 if not vertices or not isinstance(vertices, (list, tuple)):
1854 raise ValueError("Vertices must be a non-empty list")
1855
1856 if not all(isinstance(v, vec3) for v in vertices):
1857 raise ValueError("All vertices must be vec3 objects")
1859 try:
1860 visualizer_wrapper.set_geometry_vertices(self.visualizer, geometry_id, vertices)
1861 logger.debug(f"Set {len(vertices)} vertices for geometry {geometry_id}")
1862 except Exception as e:
1863 raise VisualizerError(f"Failed to set geometry vertices: {e}")
1864
1865 # Coordinate Axes and Grid Methods
1866
1867 def addCoordinateAxes(self) -> None:
1868 """
1869 Add coordinate axes at origin with unit length.
1870
1871 Raises:
1872 VisualizerError: If operation fails
1873 """
1874 if not self.visualizer:
1875 raise VisualizerError("Visualizer not initialized")
1876
1877 try:
1878 helios_lib.addCoordinateAxes(self.visualizer)
1879 except Exception as e:
1880 raise VisualizerError(f"Failed to add coordinate axes: {e}")
1881
1882 def addCoordinateAxesCustom(self, origin: vec3, length: vec3, sign: str = "both") -> None:
1883 """
1884 Add coordinate axes with custom properties.
1886 Args:
1887 origin: Axes origin position
1888 length: Axes length in each direction
1889 sign: Axis direction ("both" or "positive")
1890
1891 Raises:
1892 ValueError: If parameters are invalid
1893 VisualizerError: If operation fails
1894 """
1895 if not self.visualizer:
1896 raise VisualizerError("Visualizer not initialized")
1897
1898 if not isinstance(origin, vec3):
1899 raise ValueError("Origin must be a vec3")
1900 if not isinstance(length, vec3):
1901 raise ValueError("Length must be a vec3")
1902 if not isinstance(sign, str) or sign not in ["both", "positive"]:
1903 raise ValueError("Sign must be 'both' or 'positive'")
1904
1905 try:
1906 origin_array = (ctypes.c_float * 3)(origin.x, origin.y, origin.z)
1907 length_array = (ctypes.c_float * 3)(length.x, length.y, length.z)
1908 helios_lib.addCoordinateAxesCustom(self.visualizer, origin_array, length_array, sign.encode('utf-8'))
1909 except Exception as e:
1910 raise VisualizerError(f"Failed to add custom coordinate axes: {e}")
1911
1912 def disableCoordinateAxes(self) -> None:
1913 """
1914 Remove coordinate axes.
1915
1916 Raises:
1917 VisualizerError: If operation fails
1918 """
1919 if not self.visualizer:
1920 raise VisualizerError("Visualizer not initialized")
1921
1922 try:
1923 helios_lib.disableCoordinateAxes(self.visualizer)
1924 except Exception as e:
1925 raise VisualizerError(f"Failed to disable coordinate axes: {e}")
1926
1927 def addGridWireFrame(self, center: vec3, size: vec3, subdivisions: List[int]) -> None:
1928 """
1929 Add grid wireframe.
1931 Args:
1932 center: Grid center position
1933 size: Grid size in each direction
1934 subdivisions: Grid subdivisions [x, y, z]
1935
1936 Raises:
1937 ValueError: If parameters are invalid
1938 VisualizerError: If operation fails
1939 """
1940 if not self.visualizer:
1941 raise VisualizerError("Visualizer not initialized")
1942
1943 if not isinstance(center, vec3):
1944 raise ValueError("Center must be a vec3")
1945 if not isinstance(size, vec3):
1946 raise ValueError("Size must be a vec3")
1947 if not isinstance(subdivisions, (list, tuple)) or len(subdivisions) != 3:
1948 raise ValueError("Subdivisions must be a list of 3 integers")
1949 if not all(isinstance(s, _INT_TYPE) and s > 0 for s in subdivisions):
1950 raise ValueError("All subdivisions must be positive integers")
1952 try:
1953 center_array = (ctypes.c_float * 3)(center.x, center.y, center.z)
1954 size_array = (ctypes.c_float * 3)(size.x, size.y, size.z)
1955 subdiv_array = (ctypes.c_int * 3)(*subdivisions)
1956 helios_lib.addGridWireFrame(self.visualizer, center_array, size_array, subdiv_array)
1957 except Exception as e:
1958 raise VisualizerError(f"Failed to add grid wireframe: {e}")
1959
1960 # Colorbar Control Methods
1961
1962 def enableColorbar(self) -> None:
1963 """
1964 Enable colorbar.
1965
1966 Raises:
1967 VisualizerError: If operation fails
1968 """
1969 if not self.visualizer:
1970 raise VisualizerError("Visualizer not initialized")
1971
1972 try:
1973 helios_lib.enableColorbar(self.visualizer)
1974 except Exception as e:
1975 raise VisualizerError(f"Failed to enable colorbar: {e}")
1976
1977 def disableColorbar(self) -> None:
1978 """
1979 Disable colorbar.
1981 Raises:
1982 VisualizerError: If operation fails
1983 """
1984 if not self.visualizer:
1985 raise VisualizerError("Visualizer not initialized")
1986
1987 try:
1988 helios_lib.disableColorbar(self.visualizer)
1989 except Exception as e:
1990 raise VisualizerError(f"Failed to disable colorbar: {e}")
1991
1992 def setColorbarPosition(self, position: vec3) -> None:
1993 """
1994 Set colorbar position.
1996 Args:
1997 position: Colorbar position
1998
1999 Raises:
2000 ValueError: If position is invalid
2001 VisualizerError: If operation fails
2002 """
2003 if not self.visualizer:
2004 raise VisualizerError("Visualizer not initialized")
2005
2006 if not isinstance(position, vec3):
2007 raise ValueError("Position must be a vec3")
2008
2009 try:
2010 pos_array = (ctypes.c_float * 3)(position.x, position.y, position.z)
2011 helios_lib.setColorbarPosition(self.visualizer, pos_array)
2012 except Exception as e:
2013 raise VisualizerError(f"Failed to set colorbar position: {e}")
2015 def setColorbarSize(self, width: float, height: float) -> None:
2016 """
2017 Set colorbar size.
2018
2019 Args:
2020 width: Colorbar width
2021 height: Colorbar height
2022
2023 Raises:
2024 ValueError: If size is invalid
2025 VisualizerError: If operation fails
2026 """
2027 if not self.visualizer:
2028 raise VisualizerError("Visualizer not initialized")
2029
2030 if not isinstance(width, _NUMERIC_TYPES) or width <= 0:
2031 raise ValueError("Width must be a positive number")
2032 if not isinstance(height, _NUMERIC_TYPES) or height <= 0:
2033 raise ValueError("Height must be a positive number")
2034
2035 try:
2036 size_array = (ctypes.c_float * 2)(float(width), float(height))
2037 helios_lib.setColorbarSize(self.visualizer, size_array)
2038 except Exception as e:
2039 raise VisualizerError(f"Failed to set colorbar size: {e}")
2040
2041 def setColorbarRange(self, min_val: float, max_val: float) -> None:
2042 """
2043 Set colorbar range.
2044
2045 Setting a range explicitly stops the colorbar from auto-ranging over the data, including
2046 for the degenerate range ``setColorbarRange(0, 0)``.
2047
2048 Args:
2049 min_val: Minimum value
2050 max_val: Maximum value. Must be greater than or equal to ``min_val``; helios ignores an
2051 inverted range.
2052
2053 Raises:
2054 ValueError: If range is invalid
2055 VisualizerError: If operation fails
2056 """
2057 if not self.visualizer:
2058 raise VisualizerError("Visualizer not initialized")
2059
2060 if not isinstance(min_val, _NUMERIC_TYPES):
2061 raise ValueError("Minimum value must be numeric")
2062 if not isinstance(max_val, _NUMERIC_TYPES):
2063 raise ValueError("Maximum value must be numeric")
2064 if min_val > max_val:
2065 raise ValueError("Minimum value must not be greater than maximum value")
2066
2067 try:
2068 helios_lib.setColorbarRange(self.visualizer, float(min_val), float(max_val))
2069 except Exception as e:
2070 raise VisualizerError(f"Failed to set colorbar range: {e}")
2071
2072 def setColorbarTicks(self, ticks: List[float]) -> None:
2073 """
2074 Set colorbar tick marks.
2075
2076 Args:
2077 ticks: List of tick values
2078
2079 Note:
2080 If a tick value falls outside the colorbar range, the range is automatically expanded
2081 to fit it. Because the colormap limits follow the colorbar range, this changes the
2082 colors shown as well as the labels. To keep an explicit range authoritative, call
2083 :meth:`setColorbarRange` after :meth:`setColorbarTicks`.
2084
2085 Raises:
2086 ValueError: If ticks are invalid
2087 VisualizerError: If operation fails
2088 """
2089 if not self.visualizer:
2090 raise VisualizerError("Visualizer not initialized")
2091
2092 if not isinstance(ticks, (list, tuple)):
2093 raise ValueError("Ticks must be a list or tuple")
2094 if not all(isinstance(t, _NUMERIC_TYPES) for t in ticks):
2095 raise ValueError("All tick values must be numeric")
2096
2097 try:
2098 if ticks:
2099 ticks_array = (ctypes.c_float * len(ticks))(*ticks)
2100 helios_lib.setColorbarTicks(self.visualizer, ticks_array, len(ticks))
2101 else:
2102 helios_lib.setColorbarTicks(self.visualizer, None, 0)
2103 except Exception as e:
2104 raise VisualizerError(f"Failed to set colorbar ticks: {e}")
2105
2106 def setColorbarTitle(self, title: str) -> None:
2107 """
2108 Set colorbar title.
2109
2110 Args:
2111 title: Colorbar title
2112
2113 Raises:
2114 ValueError: If title is invalid
2115 VisualizerError: If operation fails
2116 """
2117 if not self.visualizer:
2118 raise VisualizerError("Visualizer not initialized")
2119
2120 if not isinstance(title, str):
2121 raise ValueError("Title must be a string")
2122
2123 try:
2124 helios_lib.setColorbarTitle(self.visualizer, title.encode('utf-8'))
2125 except Exception as e:
2126 raise VisualizerError(f"Failed to set colorbar title: {e}")
2127
2128 def setColorbarFontColor(self, color: RGBcolor) -> None:
2129 """
2130 Set colorbar font color.
2131
2132 Args:
2133 color: Font color
2134
2135 Raises:
2136 ValueError: If color is invalid
2137 VisualizerError: If operation fails
2138 """
2139 if not self.visualizer:
2140 raise VisualizerError("Visualizer not initialized")
2141
2142 if not isinstance(color, RGBcolor):
2143 raise ValueError("Color must be an RGBcolor")
2144
2145 try:
2146 color_array = (ctypes.c_float * 3)(color.r, color.g, color.b)
2147 helios_lib.setColorbarFontColor(self.visualizer, color_array)
2148 except Exception as e:
2149 raise VisualizerError(f"Failed to set colorbar font color: {e}")
2151 def setColorbarFontSize(self, font_size: int) -> None:
2152 """
2153 Set colorbar font size.
2154
2155 Args:
2156 font_size: Font size
2157
2158 Raises:
2159 ValueError: If font size is invalid
2160 VisualizerError: If operation fails
2161 """
2162 if not self.visualizer:
2163 raise VisualizerError("Visualizer not initialized")
2164
2165 if not isinstance(font_size, _INT_TYPE) or font_size <= 0:
2166 raise ValueError("Font size must be a positive integer")
2167
2168 try:
2169 helios_lib.setColorbarFontSize(self.visualizer, font_size)
2170 except Exception as e:
2171 raise VisualizerError(f"Failed to set colorbar font size: {e}")
2172
2173 # Colormap Methods
2174
2175 def setColormap(self, colormap: Union[int, str]) -> None:
2176 """
2177 Set predefined colormap.
2178
2179 Args:
2180 colormap: Colormap ID (0-5) or name ("HOT", "COOL", "RAINBOW", "LAVA", "PARULA", "GRAY")
2181
2182 Raises:
2183 ValueError: If colormap is invalid
2184 VisualizerError: If operation fails
2185 """
2186 if not self.visualizer:
2187 raise VisualizerError("Visualizer not initialized")
2188
2189 colormap_map = {
2190 "HOT": 0, "COOL": 1, "RAINBOW": 2,
2191 "LAVA": 3, "PARULA": 4, "GRAY": 5
2192 }
2193
2194 if isinstance(colormap, str):
2195 if colormap.upper() not in colormap_map:
2196 raise ValueError(f"Unknown colormap name: {colormap}")
2197 colormap_id = colormap_map[colormap.upper()]
2198 elif isinstance(colormap, _INT_TYPE):
2199 if colormap < 0 or colormap > 5:
2200 raise ValueError("Colormap ID must be 0-5")
2201 colormap_id = colormap
2202 else:
2203 raise ValueError("Colormap must be integer ID or string name")
2204
2205 try:
2206 helios_lib.setColormap(self.visualizer, colormap_id)
2207 except Exception as e:
2208 raise VisualizerError(f"Failed to set colormap: {e}")
2209
2210 def setCustomColormap(self, colors: List[RGBcolor], divisions: List[float]) -> None:
2211 """
2212 Set custom colormap.
2213
2214 Args:
2215 colors: List of RGB colors
2216 divisions: List of division points (same length as colors)
2217
2218 Raises:
2219 ValueError: If parameters are invalid
2220 VisualizerError: If operation fails
2221 """
2222 if not self.visualizer:
2223 raise VisualizerError("Visualizer not initialized")
2224
2225 if not isinstance(colors, (list, tuple)) or not colors:
2226 raise ValueError("Colors must be a non-empty list")
2227 if not isinstance(divisions, (list, tuple)) or not divisions:
2228 raise ValueError("Divisions must be a non-empty list")
2229 if len(colors) != len(divisions):
2230 raise ValueError("Colors and divisions must have the same length")
2231
2232 if not all(isinstance(c, RGBcolor) for c in colors):
2233 raise ValueError("All colors must be RGBcolor objects")
2234 if not all(isinstance(d, _NUMERIC_TYPES) for d in divisions):
2235 raise ValueError("All divisions must be numeric")
2236
2237 try:
2238 # Flatten colors to RGB array
2239 color_array = (ctypes.c_float * (len(colors) * 3))()
2240 for i, color in enumerate(colors):
2241 color_array[i*3] = color.r
2242 color_array[i*3+1] = color.g
2243 color_array[i*3+2] = color.b
2244
2245 divisions_array = (ctypes.c_float * len(divisions))(*divisions)
2246
2247 helios_lib.setCustomColormap(self.visualizer, color_array, divisions_array, len(colors))
2248 except Exception as e:
2249 raise VisualizerError(f"Failed to set custom colormap: {e}")
2250
2251 # Advanced Coloring Methods
2252
2253 def colorContextPrimitivesByObjectData(self, data_name: str, obj_ids: Optional[List[int]] = None) -> None:
2254 """
2255 Color context primitives by object data.
2256
2257 Args:
2258 data_name: Name of object data to use for coloring
2259 obj_ids: Optional list of object IDs to color (None for all)
2260
2261 Raises:
2262 ValueError: If parameters are invalid
2263 VisualizerError: If operation fails
2264 """
2265 if not self.visualizer:
2266 raise VisualizerError("Visualizer not initialized")
2267
2268 if not isinstance(data_name, str) or not data_name.strip():
2269 raise ValueError("Data name must be a non-empty string")
2270
2271 try:
2272 if obj_ids is None:
2273 helios_lib.colorContextPrimitivesByObjectData(self.visualizer, data_name.encode('utf-8'))
2274 else:
2275 if not isinstance(obj_ids, (list, tuple)):
2276 raise ValueError("Object IDs must be a list or tuple")
2277 if not all(isinstance(oid, _INT_TYPE) and oid >= 0 for oid in obj_ids):
2278 raise ValueError("All object IDs must be non-negative integers")
2279
2280 if obj_ids:
2281 obj_ids_array = (ctypes.c_uint * len(obj_ids))(*obj_ids)
2282 helios_lib.colorContextPrimitivesByObjectDataIDs(self.visualizer, data_name.encode('utf-8'), obj_ids_array, len(obj_ids))
2283 else:
2284 helios_lib.colorContextPrimitivesByObjectDataIDs(self.visualizer, data_name.encode('utf-8'), None, 0)
2285 except Exception as e:
2286 raise VisualizerError(f"Failed to color primitives by object data '{data_name}': {e}")
2287
2288 def colorContextPrimitivesRandomly(self, uuids: Optional[List[int]] = None) -> None:
2289 """
2290 Color context primitives randomly.
2291
2292 Args:
2293 uuids: Optional list of primitive UUIDs to color (None for all)
2294
2295 Raises:
2296 ValueError: If UUIDs are invalid
2297 VisualizerError: If operation fails
2298 """
2299 if not self.visualizer:
2300 raise VisualizerError("Visualizer not initialized")
2301
2302 try:
2303 if uuids is None:
2304 helios_lib.colorContextPrimitivesRandomly(self.visualizer, None, 0)
2305 else:
2306 if not isinstance(uuids, (list, tuple)):
2307 raise ValueError("UUIDs must be a list or tuple")
2308 if not all(isinstance(uuid, _INT_TYPE) and uuid >= 0 for uuid in uuids):
2309 raise ValueError("All UUIDs must be non-negative integers")
2311 if uuids:
2312 uuid_array = (ctypes.c_uint * len(uuids))(*uuids)
2313 helios_lib.colorContextPrimitivesRandomly(self.visualizer, uuid_array, len(uuids))
2314 else:
2315 helios_lib.colorContextPrimitivesRandomly(self.visualizer, None, 0)
2316 except Exception as e:
2317 raise VisualizerError(f"Failed to color primitives randomly: {e}")
2318
2319 def colorContextObjectsRandomly(self, obj_ids: Optional[List[int]] = None) -> None:
2320 """
2321 Color context objects randomly.
2322
2323 Args:
2324 obj_ids: Optional list of object IDs to color (None for all)
2325
2326 Raises:
2327 ValueError: If object IDs are invalid
2328 VisualizerError: If operation fails
2329 """
2330 if not self.visualizer:
2331 raise VisualizerError("Visualizer not initialized")
2332
2333 try:
2334 if obj_ids is None:
2335 helios_lib.colorContextObjectsRandomly(self.visualizer, None, 0)
2336 else:
2337 if not isinstance(obj_ids, (list, tuple)):
2338 raise ValueError("Object IDs must be a list or tuple")
2339 if not all(isinstance(oid, _INT_TYPE) and oid >= 0 for oid in obj_ids):
2340 raise ValueError("All object IDs must be non-negative integers")
2342 if obj_ids:
2343 obj_ids_array = (ctypes.c_uint * len(obj_ids))(*obj_ids)
2344 helios_lib.colorContextObjectsRandomly(self.visualizer, obj_ids_array, len(obj_ids))
2345 else:
2346 helios_lib.colorContextObjectsRandomly(self.visualizer, None, 0)
2347 except Exception as e:
2348 raise VisualizerError(f"Failed to color objects randomly: {e}")
2349
2350 def clearColor(self) -> None:
2351 """
2352 Clear primitive colors from previous coloring operations.
2353
2354 Raises:
2355 VisualizerError: If operation fails
2356 """
2357 if not self.visualizer:
2358 raise VisualizerError("Visualizer not initialized")
2359
2360 try:
2361 helios_lib.clearColor(self.visualizer)
2362 except Exception as e:
2363 raise VisualizerError(f"Failed to clear colors: {e}")
2364
2365 # Watermark Control Methods
2366
2367 def hideWatermark(self) -> None:
2368 """
2369 Hide Helios logo watermark.
2370
2371 Raises:
2372 VisualizerError: If operation fails
2373 """
2374 if not self.visualizer:
2375 raise VisualizerError("Visualizer not initialized")
2376
2377 try:
2378 helios_lib.hideWatermark(self.visualizer)
2379 except Exception as e:
2380 raise VisualizerError(f"Failed to hide watermark: {e}")
2381
2382 def showWatermark(self) -> None:
2383 """
2384 Show Helios logo watermark.
2386 Raises:
2387 VisualizerError: If operation fails
2388 """
2389 if not self.visualizer:
2390 raise VisualizerError("Visualizer not initialized")
2391
2392 try:
2393 helios_lib.showWatermark(self.visualizer)
2394 except Exception as e:
2395 raise VisualizerError(f"Failed to show watermark: {e}")
2396
2397 def updateWatermark(self) -> None:
2398 """
2399 Update watermark geometry to match current window size.
2401 Raises:
2402 VisualizerError: If operation fails
2403 """
2404 if not self.visualizer:
2405 raise VisualizerError("Visualizer not initialized")
2406
2407 try:
2408 helios_lib.updateWatermark(self.visualizer)
2409 except Exception as e:
2410 raise VisualizerError(f"Failed to update watermark: {e}")
2411
2412 # Navigation Gizmo Methods (v1.3.53+)
2413
2414 def hideNavigationGizmo(self) -> None:
2415 """
2416 Hide navigation gizmo (coordinate axes indicator in corner).
2417
2418 The navigation gizmo shows XYZ axes orientation and can be clicked
2419 to snap the camera to standard views (top, front, side, etc.).
2420
2421 Raises:
2422 VisualizerError: If operation fails
2423 """
2424 if not self.visualizer:
2425 raise VisualizerError("Visualizer not initialized")
2426
2427 try:
2429 visualizer_wrapper.hide_navigation_gizmo(self.visualizer)
2430 logger.debug("Navigation gizmo hidden")
2431 except Exception as e:
2432 raise VisualizerError(f"Failed to hide navigation gizmo: {e}")
2433
2434 def showNavigationGizmo(self) -> None:
2435 """
2436 Show navigation gizmo (coordinate axes indicator in corner).
2437
2438 The navigation gizmo shows XYZ axes orientation and can be clicked
2439 to snap the camera to standard views (top, front, side, etc.).
2440
2441 Note: Navigation gizmo is shown by default in v1.3.53+.
2442
2443 Raises:
2444 VisualizerError: If operation fails
2445 """
2446 if not self.visualizer:
2447 raise VisualizerError("Visualizer not initialized")
2448
2449 try:
2451 visualizer_wrapper.show_navigation_gizmo(self.visualizer)
2452 logger.debug("Navigation gizmo shown")
2453 except Exception as e:
2454 raise VisualizerError(f"Failed to show navigation gizmo: {e}")
2455
2456 # Performance and Utility Methods
2458 def enableMessages(self) -> None:
2459 """
2460 Enable standard output from visualizer plugin.
2461
2462 Raises:
2463 VisualizerError: If operation fails
2464 """
2465 if not self.visualizer:
2466 raise VisualizerError("Visualizer not initialized")
2467
2468 try:
2469 helios_lib.enableMessages(self.visualizer)
2470 except Exception as e:
2471 raise VisualizerError(f"Failed to enable messages: {e}")
2472
2473 def disableMessages(self) -> None:
2474 """
2475 Disable standard output from visualizer plugin.
2477 Raises:
2478 VisualizerError: If operation fails
2479 """
2480 if not self.visualizer:
2481 raise VisualizerError("Visualizer not initialized")
2482
2483 try:
2484 helios_lib.disableMessages(self.visualizer)
2485 except Exception as e:
2486 raise VisualizerError(f"Failed to disable messages: {e}")
2487
2488 def plotOnce(self, get_keystrokes: bool = True) -> None:
2489 """
2490 Run one rendering loop.
2492 Any geometry pending upload is transferred to the GPU before rendering, but unlike
2493 :meth:`plotUpdate` the Context geometry is not rebuilt. Call :meth:`buildContextGeometry`
2494 or :meth:`plotUpdate` if primitives have been added to or changed in the Context since the
2495 last render.
2496
2497 Args:
2498 get_keystrokes: Whether to process keystrokes
2499
2500 Raises:
2501 VisualizerError: If operation fails
2502 """
2503 if not self.visualizer:
2504 raise VisualizerError("Visualizer not initialized")
2505
2506 try:
2507 helios_lib.plotOnce(self.visualizer, get_keystrokes)
2508 except Exception as e:
2509 raise VisualizerError(f"Failed to run plot once: {e}")
2510
2511 def plotUpdateWithVisibility(self, hide_window: bool = False) -> None:
2512 """
2513 Update visualization with window visibility control.
2515 Args:
2516 hide_window: Whether to hide the window during update
2517
2518 Raises:
2519 VisualizerError: If operation fails
2520 """
2522 if not self.visualizer:
2523 raise VisualizerError("Visualizer not initialized")
2524
2525 try:
2527 helios_lib.plotUpdateWithVisibility(self.visualizer, hide_window)
2528 except Exception as e:
2529 raise VisualizerError(f"Failed to update plot with visibility control: {e}")
2530
2531 # Point Culling and LOD Methods (v1.3.54+)
2533 def setPointCullingEnabled(self, enabled: bool) -> None:
2534 """
2535 Enable or disable point cloud culling optimization.
2536
2537 Point culling improves rendering performance for large point clouds by
2538 selectively rendering only points that are visible based on distance
2539 and density criteria.
2540
2541 Args:
2542 enabled: True to enable culling, False to disable (default: True)
2543
2544 Raises:
2545 ValueError: If enabled is not a boolean
2546 VisualizerError: If operation fails
2547
2548 Example:
2549 >>> with Visualizer(800, 600) as vis:
2550 ... vis.setPointCullingEnabled(False) # Disable for highest quality
2551 ... vis.setPointCullingEnabled(True) # Enable for better performance
2552 """
2553 if not self.visualizer:
2554 raise VisualizerError("Visualizer not initialized")
2555 if not isinstance(enabled, bool):
2556 raise ValueError(f"Enabled must be a boolean, got {type(enabled).__name__}")
2557
2558 try:
2559 visualizer_wrapper.set_point_culling_enabled(self.visualizer, enabled)
2560 logger.debug(f"Point culling {'enabled' if enabled else 'disabled'}")
2561 except Exception as e:
2562 raise VisualizerError(f"Failed to set point culling enabled: {e}")
2563
2564 def setPointCullingThreshold(self, threshold: int) -> None:
2565 """
2566 Set the minimum number of points required to trigger culling.
2567
2568 Culling is only activated when the total point count exceeds this threshold.
2569 This prevents unnecessary culling overhead for small point clouds.
2570
2571 Args:
2572 threshold: Point count threshold (default: 10000). Set to 0 to always enable.
2573
2574 Raises:
2575 ValueError: If threshold is not a non-negative integer
2576 VisualizerError: If operation fails
2577
2578 Example:
2579 >>> vis.setPointCullingThreshold(50000) # Only cull for >50k points
2580 >>> vis.setPointCullingThreshold(0) # Always enable culling
2581 """
2582 if not self.visualizer:
2583 raise VisualizerError("Visualizer not initialized")
2584 if not isinstance(threshold, int):
2585 raise ValueError(f"Threshold must be an integer, got {type(threshold).__name__}")
2586 if threshold < 0:
2587 raise ValueError("Point culling threshold must be non-negative")
2588
2589 try:
2590 visualizer_wrapper.set_point_culling_threshold(self.visualizer, threshold)
2591 logger.debug(f"Point culling threshold set to {threshold}")
2592 except Exception as e:
2593 raise VisualizerError(f"Failed to set point culling threshold: {e}")
2594
2595 def setPointMaxRenderDistance(self, distance: float) -> None:
2596 """
2597 Set the maximum rendering distance for points.
2598
2599 Points beyond this distance from the camera are not rendered, improving
2600 performance for large scenes. The distance is measured in world units.
2601
2602 Args:
2603 distance: Maximum distance in world units. Use 0 for auto mode (scene_size * 5.0)
2604
2605 Raises:
2606 ValueError: If distance is negative
2607 VisualizerError: If operation fails
2608
2609 Example:
2610 >>> vis.setPointMaxRenderDistance(0.0) # Auto mode
2611 >>> vis.setPointMaxRenderDistance(100.0) # Fixed distance
2612
2613 Note:
2614 Setting distance to 0 enables automatic mode, which calculates the
2615 render distance based on the scene bounding box dimensions.
2616 """
2617 if not self.visualizer:
2618 raise VisualizerError("Visualizer not initialized")
2619 if not isinstance(distance, (int, float)):
2620 raise ValueError(f"Distance must be numeric, got {type(distance).__name__}")
2621 if distance < 0.0:
2622 raise ValueError("Point max render distance cannot be negative")
2623
2624 try:
2625 visualizer_wrapper.set_point_max_render_distance(self.visualizer, float(distance))
2626 if distance == 0.0:
2627 logger.debug("Point max render distance set to auto mode")
2628 else:
2629 logger.debug(f"Point max render distance set to {distance}")
2630 except Exception as e:
2631 raise VisualizerError(f"Failed to set point max render distance: {e}")
2632
2633 def setPointLODFactor(self, factor: float) -> None:
2634 """
2635 Set the level-of-detail factor for distance-based culling.
2636
2637 Controls how aggressively points are culled based on distance from camera.
2638 Higher values result in more aggressive culling (better performance, lower quality).
2639 Lower values preserve more points (higher quality, lower performance).
2640
2641 Args:
2642 factor: LOD factor (default: 10.0, typical range: 1.0-50.0). Must be positive.
2643
2644 Raises:
2645 ValueError: If factor is not positive
2646 VisualizerError: If operation fails
2647
2648 Example:
2649 >>> vis.setPointLODFactor(5.0) # Conservative culling
2650 >>> vis.setPointLODFactor(10.0) # Default culling
2651 >>> vis.setPointLODFactor(25.0) # Aggressive culling
2652
2653 Note:
2654 The LOD factor determines the rate at which point density decreases
2655 with distance. Higher factors mean points are culled more quickly
2656 as distance increases.
2657 """
2658 if not self.visualizer:
2659 raise VisualizerError("Visualizer not initialized")
2660 if not isinstance(factor, (int, float)):
2661 raise ValueError(f"LOD factor must be numeric, got {type(factor).__name__}")
2662 if factor <= 0.0:
2663 raise ValueError("Point LOD factor must be positive")
2664
2665 # Warn about extreme values
2666 if factor < 1.0:
2667 logger.warning(f"Point LOD factor {factor} is very low (< 1.0), may cause performance issues")
2668 elif factor > 100.0:
2669 logger.warning(f"Point LOD factor {factor} is very high (> 100.0), may over-cull points")
2670
2671 try:
2672 visualizer_wrapper.set_point_lod_factor(self.visualizer, float(factor))
2673 logger.debug(f"Point LOD factor set to {factor}")
2674 except Exception as e:
2675 raise VisualizerError(f"Failed to set point LOD factor: {e}")
2676
2677 def getPointRenderingMetrics(self) -> dict:
2678 """
2679 Get point cloud rendering performance metrics.
2680
2681 Provides detailed statistics about point cloud culling and rendering
2682 performance, useful for optimizing visualization settings.
2683
2684 Returns:
2685 Dictionary with keys:
2686 - 'total_points' (int): Total number of points in the scene
2687 - 'rendered_points' (int): Number of points actually rendered after culling
2688 - 'culling_time_ms' (float): Time spent on culling in milliseconds
2689
2690 Raises:
2691 VisualizerError: If operation fails
2692
2693 Example:
2694 >>> metrics = vis.getPointRenderingMetrics()
2695 >>> print(f"Total: {metrics['total_points']}")
2696 >>> print(f"Rendered: {metrics['rendered_points']}")
2697 >>> cull_rate = (1 - metrics['rendered_points']/metrics['total_points']) * 100
2698 >>> print(f"Culling rate: {cull_rate:.1f}%")
2699
2700 Note:
2701 Metrics are only meaningful after calling plotUpdate() or plotInteractive().
2702 The culling_time_ms represents CPU time spent on culling calculations,
2703 not total frame time.
2704 """
2705 if not self.visualizer:
2706 raise VisualizerError("Visualizer not initialized")
2707
2708 try:
2709 metrics = visualizer_wrapper.get_point_rendering_metrics(self.visualizer)
2710 logger.debug(
2711 f"Point rendering metrics: {metrics['total_points']} total, "
2712 f"{metrics['rendered_points']} rendered, "
2713 f"{metrics['culling_time_ms']:.2f} ms culling time"
2714 )
2715 return metrics
2716 except Exception as e:
2717 raise VisualizerError(f"Failed to get point rendering metrics: {e}")
2718
2719 def __del__(self):
2720 """Destructor to ensure proper cleanup."""
2721 if hasattr(self, 'visualizer') and self.visualizer is not None:
2722 try:
2724 visualizer_wrapper.destroy_visualizer(self.visualizer)
2725 except Exception:
2726 pass # Ignore errors during destruction
Raised when Visualizer operations fail.
None colorContextObjectsRandomly(self, Optional[List[int]] obj_ids=None)
Color context objects randomly.
None plotInteractive(self)
Open interactive visualization window.
None setColorbarFontColor(self, RGBcolor color)
Set colorbar font color.
None disableExactColorMode(self)
Restore the default brightening of primitive colors.
None setPointCullingEnabled(self, bool enabled)
Enable or disable point cloud culling optimization.
None enableSmoothShading(self)
Enable smooth per-vertex-normal shading.
__exit__(self, exc_type, exc_value, traceback)
Context manager exit with proper cleanup.
None hideNavigationGizmo(self)
Hide navigation gizmo (coordinate axes indicator in corner).
getWindowPixelsRGB(self, Optional[List[int]] buffer=None)
Get RGB pixel data from the current window.
RGBcolor getAmbientSkyColor(self)
Get the hemispheric ambient sky color.
None setBackgroundImage(self, str texture_file)
Set custom background image texture (v1.3.53+).
None buildContextGeometry(self, Context context, Optional[List[int]] uuids=None)
Build Context geometry in the visualizer.
None disableMessages(self)
Disable standard output from visualizer plugin.
None hideWatermark(self)
Hide Helios logo watermark.
None setBackgroundTransparent(self)
Enable transparent background mode (v1.3.53+).
None colorContextPrimitivesByObjectData(self, str data_name, Optional[List[int]] obj_ids=None)
Color context primitives by object data.
None setPhongMaterial(self, float ambient, float diffuse, float specular, float shininess)
Set the Phong material parameters used to shade Context primitives.
__enter__(self)
Context manager entry.
None setExposure(self, float exposure)
Set the linear exposure multiplier applied before tone mapping.
None setPointMaxRenderDistance(self, float distance)
Set the maximum rendering distance for points.
__init__(self, int width, int height, int antialiasing_samples=4, bool headless=False)
Initialize Visualizer with graceful plugin handling.
None setGeometryVertices(self, int geometry_id, List[vec3] vertices)
Set vertices of a geometry primitive.
None clearColor(self)
Clear primitive colors from previous coloring operations.
None setColorbarPosition(self, vec3 position)
Set colorbar position.
None setCustomColormap(self, List[RGBcolor] colors, List[float] divisions)
Set custom colormap.
bool isSmoothShadingEnabled(self)
Check whether smooth per-vertex-normal shading is enabled.
None deleteGeometry(self, int geometry_id)
Delete specific geometry by ID.
None setPointLODFactor(self, float factor)
Set the level-of-detail factor for distance-based culling.
None setCameraPositionSpherical(self, SphericalCoord angle, vec3 lookAt)
Set camera position using spherical coordinates.
None disableLinearPipeline(self)
Disable the linear-light pipeline, shading directly in sRGB space.
None clearGeometry(self)
Clear all geometry from visualizer.
None setCameraFieldOfView(self, float angle_FOV)
Set camera field of view angle.
None displayImageWithBoundingBoxes(self, str image_file, str bbox_file, str classes_file="", float line_width=2.0, int fontsize=12)
Display an image with YOLO bounding boxes overlaid.
None displayImageFromFile(self, str filename)
Display image from file.
None setLightingModel(self, Union[int, str] lighting_model)
Set lighting model.
None showNavigationGizmo(self)
Show navigation gizmo (coordinate axes indicator in corner).
None setPointCullingThreshold(self, int threshold)
Set the minimum number of points required to trigger culling.
None updateContextPrimitiveColors(self)
Update context primitive colors.
_check_context_alive(self)
Raise if a Context was loaded and has since been destroyed.
None setColorbarRange(self, float min_val, float max_val)
Set colorbar range.
None plotDepthMap(self)
Plot depth map visualization.
Tuple[int, int] getWindowSize(self)
Get window size in pixels.
None enableLinearPipeline(self)
Enable the physically-based linear-light rendering pipeline.
List[vec3] getGeometryVertices(self, int geometry_id)
Get vertices of a geometry primitive.
None printWindowDefault(self)
Print window with default filename.
None disableColorbar(self)
Disable colorbar.
None plotUpdateWithVisibility(self, bool hide_window=False)
Update visualization with window visibility control.
Tuple[vec3, vec3] getCameraPosition(self)
Get current camera position and look-at point.
None enableExactColorMode(self)
Render primitive colors exactly as they are set in the Context.
None setLightDirection(self, vec3 direction)
Set light direction.
bool isLinearPipelineEnabled(self)
Check whether the linear-light rendering pipeline is enabled.
None displayImageWithSegmentationMasks(self, str image_file, str mask_file, float fill_opacity=0.4, float line_width=2.0, int fontsize=12, bool show_labels=True)
Display an image with COCO segmentation masks overlaid.
None closeWindow(self)
Close visualization window.
bool isHeadlessMultisamplingActive(self)
Check whether headless rendering obtained multisampled framebuffer attachments.
None setColorbarTitle(self, str title)
Set colorbar title.
__del__(self)
Destructor to ensure proper cleanup.
Tuple[float, float, float, float] getPhongMaterial(self)
Get the Phong material parameters used to shade Context primitives.
None displayImageFromPixels(self, List[int] pixel_data, int width, int height)
Display image from RGBA pixel data.
None addCoordinateAxesCustom(self, vec3 origin, vec3 length, str sign="both")
Add coordinate axes with custom properties.
float getExposure(self)
Get the linear exposure multiplier applied before tone mapping.
dict getPointRenderingMetrics(self)
Get point cloud rendering performance metrics.
None setAmbientColors(self, RGBcolor sky_color, RGBcolor ground_color)
Set the hemispheric ambient sky and ground-bounce colors.
None setColormap(self, Union[int, str] colormap)
Set predefined colormap.
None setBackgroundColor(self, RGBcolor color)
Set background color.
None colorContextPrimitivesByData(self, str data_name, Optional[List[int]] uuids=None)
Color context primitives based on primitive data values.
None printWindow(self, str filename, Optional[str] image_format=None)
Save current visualization to image file.
None disableCoordinateAxes(self)
Remove coordinate axes.
None setColorbarSize(self, float width, float height)
Set colorbar size.
None clearContextGeometry(self)
Clear context geometry from visualizer.
Tuple[List[float], int, int] getDepthMap(self)
Get depth map from current window.
None setCameraPosition(self, vec3 position, vec3 lookAt)
Set camera position using Cartesian coordinates.
RGBcolor getBackgroundColor(self)
Get current background color.
None setLightIntensityFactor(self, float intensity_factor)
Set light intensity scaling factor.
None enableMessages(self)
Enable standard output from visualizer plugin.
None addCoordinateAxes(self)
Add coordinate axes at origin with unit length.
vec2 getTextboxSize(self, str textstring, int fontsize, str fontname)
Measure the rendered size of a text string without adding it to the visualizer.
None addGridWireFrame(self, vec3 center, vec3 size, List[int] subdivisions)
Add grid wireframe.
None plotUpdate(self)
Update visualization (non-interactive).
None disableSmoothShading(self)
Select flat (per-face) shading.
None setColorbarFontSize(self, int font_size)
Set colorbar font size.
None setColorbarTicks(self, List[float] ticks)
Set colorbar tick marks.
None colorContextPrimitivesRandomly(self, Optional[List[int]] uuids=None)
Color context primitives randomly.
None showWatermark(self)
Show Helios logo watermark.
Tuple[int, int] getFramebufferSize(self)
Get framebuffer size in pixels.
None enableColorbar(self)
Enable colorbar.
RGBcolor getAmbientGroundColor(self)
Get the hemispheric ambient ground-bounce color.
None plotOnce(self, bool get_keystrokes=True)
Run one rendering loop.
None setBackgroundSkyTexture(self, Optional[str] texture_file=None, int divisions=50)
Set sky sphere texture background with automatic scaling (v1.3.53+).
None updateWatermark(self)
Update watermark geometry to match current window size.
str _resolve_user_path(str path)
Resolve a user-provided path to an absolute path before working directory changes.
Definition Visualizer.py:50
_visualizer_working_directory()
Context manager that temporarily changes working directory for visualizer operations.
Definition Visualizer.py:73