0.1.15
Loading...
Searching...
No Matches
Context.py
Go to the documentation of this file.
1import ctypes
2from dataclasses import dataclass
3from typing import List, Optional, Union
4from enum import Enum
5
6import numpy as np
7
8from .wrappers import UContextWrapper as context_wrapper
9from .wrappers.DataTypes import vec2, vec3, vec4, int2, int3, int4, SphericalCoord, RGBcolor, RGBAcolor, PrimitiveType
10from .plugins.loader import LibraryLoadError, validate_library, get_library_info
11from .plugins.registry import get_plugin_registry
12from .validation.geometry import (
13 validate_patch_params, validate_triangle_params, validate_sphere_params,
14 validate_tube_params, validate_box_params
15)
16
17
18@dataclass
19class PrimitiveInfo:
20 """
21 Physical properties and geometry information for a primitive.
22 This is separate from primitive data (user-defined key-value pairs).
23 """
24 uuid: int
25 primitive_type: PrimitiveType
26 area: float
27 normal: vec3
28 vertices: List[vec3]
29 color: RGBcolor
30 centroid: Optional[vec3] = None
31
32 def __post_init__(self):
33 """Calculate centroid from vertices if not provided."""
34 if self.centroid is None and self.vertices:
35 # Calculate centroid as average of vertices
36 total_x = sum(v.x for v in self.vertices)
37 total_y = sum(v.y for v in self.vertices)
38 total_z = sum(v.z for v in self.vertices)
39 count = len(self.vertices)
40 self.centroid = vec3(total_x / count, total_y / count, total_z / count)
41
42
43class Context:
44 """
45 Central simulation environment for PyHelios that manages 3D primitives and their data.
46
47 The Context class provides methods for:
48 - Creating geometric primitives (patches, triangles)
49 - Creating compound geometry (tiles, spheres, tubes, boxes)
50 - Loading 3D models from files (PLY, OBJ, XML)
51 - Managing primitive data (flexible key-value storage)
52 - Querying primitive properties and collections
53 - Batch operations on multiple primitives
54
55 Key features:
56 - UUID-based primitive tracking
57 - Comprehensive primitive data system with auto-type detection
58 - Efficient array-based data retrieval via getPrimitiveDataArray()
59 - Cross-platform compatibility with mock mode support
60 - Context manager protocol for resource cleanup
61
62 Example:
63 >>> with Context() as context:
64 ... # Create primitives
65 ... patch_uuid = context.addPatch(center=vec3(0, 0, 0))
66 ... triangle_uuid = context.addTriangle(vec3(0,0,0), vec3(1,0,0), vec3(0.5,1,0))
67 ...
68 ... # Set primitive data
69 ... context.setPrimitiveDataFloat(patch_uuid, "temperature", 25.5)
70 ... context.setPrimitiveDataFloat(triangle_uuid, "temperature", 30.2)
71 ...
72 ... # Get data efficiently as NumPy array
73 ... temps = context.getPrimitiveDataArray([patch_uuid, triangle_uuid], "temperature")
74 ... print(temps) # [25.5 30.2]
75 """
76
77 def __init__(self):
78 # Initialize plugin registry for availability checking
79 self._plugin_registry = get_plugin_registry()
80
81 # Track Context lifecycle state for better error messages
82 self._lifecycle_state = 'initializing'
83
84 # Check if we're in mock/development mode
85 library_info = get_library_info()
86 if library_info.get('is_mock', False):
87 # In mock mode, don't validate but warn that functionality is limited
88 print("Warning: PyHelios running in development mock mode - functionality is limited")
89 print("Available plugins: None (mock mode)")
90 self.context = None # Mock context
91 self._lifecycle_state = 'mock_mode'
92 return
93
94 # Validate native library is properly loaded before creating context
95 try:
96 if not validate_library():
97 raise LibraryLoadError(
98 "Native Helios library validation failed. Some required functions are missing. "
99 "Try rebuilding the native library: build_scripts/build_helios"
100 )
101 except LibraryLoadError:
102 raise
103 except Exception as e:
104 raise LibraryLoadError(
105 f"Failed to validate native Helios library: {e}. "
106 f"To enable development mode without native libraries, set PYHELIOS_DEV_MODE=1"
107 )
108
109 # Create the context - this will fail if library isn't properly loaded
110 try:
111 self.context = context_wrapper.createContext()
112 if self.context is None:
113 self._lifecycle_state = 'creation_failed'
114 raise LibraryLoadError(
115 "Failed to create Helios context. Native library may not be functioning correctly."
116 )
117
118 self._lifecycle_state = 'active'
119
120 except Exception as e:
121 self._lifecycle_state = 'creation_failed'
122 raise LibraryLoadError(
123 f"Failed to create Helios context: {e}. "
124 f"Ensure native libraries are built and accessible."
125 )
126
127 def _check_context_available(self):
128 """Helper method to check if context is available with detailed error messages."""
129 if self.context is None:
130 # Provide specific error message based on lifecycle state
131 if self._lifecycle_state == 'mock_mode':
132 raise RuntimeError(
133 "Context is in mock mode - native functionality not available.\n"
134 "Build native libraries with 'python build_scripts/build_helios.py' or set PYHELIOS_DEV_MODE=1 for development."
135 )
136 elif self._lifecycle_state == 'cleaned_up':
137 raise RuntimeError(
138 "Context has been cleaned up and is no longer usable.\n"
139 "This usually means you're trying to use a Context outside its 'with' statement scope.\n"
140 "\n"
141 "Fix: Ensure all Context usage is inside the 'with Context() as context:' block:\n"
142 " with Context() as context:\n"
143 " # All context operations must be here\n"
144 " with SomePlugin(context) as plugin:\n"
145 " plugin.do_something()\n"
146 " with Visualizer() as vis:\n"
147 " vis.buildContextGeometry(context) # Still inside Context scope\n"
148 " # Context is cleaned up here - cannot use context after this point"
149 )
150 elif self._lifecycle_state == 'creation_failed':
151 raise RuntimeError(
152 "Context creation failed - native functionality not available.\n"
153 "Build native libraries with 'python build_scripts/build_helios.py'"
154 )
155 else:
156 # Fallback for unknown states
157 raise RuntimeError(
158 f"Context is not available (state: {self._lifecycle_state}).\n"
159 "Build native libraries with 'python build_scripts/build_helios.py' or set PYHELIOS_DEV_MODE=1 for development."
160 )
161
162 def _validate_uuid(self, uuid: int):
163 """Validate that a UUID exists in this context.
164
165 Args:
166 uuid: The UUID to validate
167
168 Raises:
169 RuntimeError: If UUID is invalid or doesn't exist in context
170 """
171 # First check if it's a reasonable UUID value
172 if not isinstance(uuid, int) or uuid < 0:
173 raise RuntimeError(f"Invalid UUID: {uuid}. UUIDs must be non-negative integers.")
175 # Check if UUID exists in context by getting all valid UUIDs
176 try:
177 valid_uuids = self.getAllUUIDs()
178 if uuid not in valid_uuids:
179 raise RuntimeError(f"UUID {uuid} does not exist in context. Valid UUIDs: {valid_uuids[:10]}{'...' if len(valid_uuids) > 10 else ''}")
180 except RuntimeError:
181 # Re-raise RuntimeError (validation failed)
182 raise
183 except Exception:
184 # If we can't get valid UUIDs due to other issues (e.g., mock mode), skip validation
185 # The _check_context_available() call will have already caught mock mode
186 pass
187
188
189 def _validate_file_path(self, filename: str, expected_extensions: List[str] = None) -> str:
190 """Validate and normalize file path for security.
191
192 Args:
193 filename: File path to validate
194 expected_extensions: List of allowed file extensions (e.g., ['.ply', '.obj'])
195
196 Returns:
197 Normalized absolute path
198
199
200 Raises:
201 ValueError: If path is invalid or potentially dangerous
202 FileNotFoundError: If file does not exist
203 """
204 import os.path
205
206 # Convert to absolute path and normalize
207 abs_path = os.path.abspath(filename)
208
209 # Check for path traversal attempts by verifying the resolved path is safe
210 # Allow relative paths with .. as long as they resolve to valid absolute paths
211 normalized_path = os.path.normpath(abs_path)
212 if abs_path != normalized_path:
213 raise ValueError(f"Invalid file path (potential path traversal): {filename}")
214
215 # Check file extension first (before checking existence) - better UX
216 if expected_extensions:
217 file_ext = os.path.splitext(abs_path)[1].lower()
218 if file_ext not in [ext.lower() for ext in expected_extensions]:
219 raise ValueError(f"Invalid file extension '{file_ext}'. Expected one of: {expected_extensions}")
220
221 # Check if file exists
222 if not os.path.exists(abs_path):
223 raise FileNotFoundError(f"File not found: {abs_path}")
224
225 # Check if it's actually a file (not a directory)
226 if not os.path.isfile(abs_path):
227 raise ValueError(f"Path is not a file: {abs_path}")
228
229 return abs_path
230
231 def _validate_output_file_path(self, filename: str, expected_extensions: List[str] = None) -> str:
232 """Validate and normalize output file path for security.
233
234 Args:
235 filename: Output file path to validate
236 expected_extensions: List of allowed file extensions (e.g., ['.ply', '.obj'])
237
238 Returns:
239 Normalized absolute path
240
241 Raises:
242 ValueError: If path is invalid or potentially dangerous
243 PermissionError: If output directory is not writable
244 """
245 import os.path
246
247 # Check for empty filename
248 if not filename or not filename.strip():
249 raise ValueError("Filename cannot be empty")
250
251 # Convert to absolute path and normalize
252 abs_path = os.path.abspath(filename)
253
254 # Check for path traversal attempts
255 normalized_path = os.path.normpath(abs_path)
256 if abs_path != normalized_path:
257 raise ValueError(f"Invalid file path (potential path traversal): {filename}")
258
259 # Check file extension
260 if expected_extensions:
261 file_ext = os.path.splitext(abs_path)[1].lower()
262 if file_ext not in [ext.lower() for ext in expected_extensions]:
263 raise ValueError(f"Invalid file extension '{file_ext}'. Expected one of: {expected_extensions}")
264
265 # Check if output directory exists and is writable
266 output_dir = os.path.dirname(abs_path)
267 if not os.path.exists(output_dir):
268 raise ValueError(f"Output directory does not exist: {output_dir}")
269 if not os.access(output_dir, os.W_OK):
270 raise PermissionError(f"Output directory is not writable: {output_dir}")
271
272 return abs_path
273
274 def __enter__(self):
275 return self
276
277 def __exit__(self, exc_type, exc_value, traceback):
278 if self.context is not None:
279 context_wrapper.destroyContext(self.context)
280 self.context = None # Prevent double deletion
281 self._lifecycle_state = 'cleaned_up'
283 def __del__(self):
284 """Destructor to ensure C++ resources freed even without 'with' statement."""
285 if hasattr(self, 'context') and self.context is not None:
286 try:
287 context_wrapper.destroyContext(self.context)
288 self.context = None
289 self._lifecycle_state = 'cleaned_up'
290 except Exception as e:
291 import warnings
292 warnings.warn(f"Error in Context.__del__: {e}")
293
294 def getNativePtr(self):
296 return self.context
297
298 def markGeometryClean(self):
300 context_wrapper.markGeometryClean(self.context)
301
304 context_wrapper.markGeometryDirty(self.context)
305
307 def isGeometryDirty(self) -> bool:
309 return context_wrapper.isGeometryDirty(self.context)
311 def seedRandomGenerator(self, seed: int):
312 """
313 Seed the random number generator for reproducible stochastic results.
314
315 Args:
316 seed: Integer seed value for random number generation
317
318 Note:
319 This is critical for reproducible results in stochastic simulations
320 (e.g., LiDAR scans with beam divergence, random perturbations).
321 """
323 context_wrapper.helios_lib.seedRandomGenerator(self.context, seed)
324
325 @validate_patch_params
326 def addPatch(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1), rotation: Optional[SphericalCoord] = None, color: Optional[RGBcolor] = None) -> int:
328 rotation = rotation or SphericalCoord(1, 0, 0) # radius=1, elevation=0, azimuth=0 (no effective rotation)
329 color = color or RGBcolor(1, 1, 1)
330 # C++ interface expects [radius, elevation, azimuth] (3 values), not [radius, elevation, zenith, azimuth] (4 values)
331 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
332 return context_wrapper.addPatchWithCenterSizeRotationAndColor(self.context, center.to_list(), size.to_list(), rotation_list, color.to_list())
333
334 @validate_triangle_params
335 def addTriangle(self, vertex0: vec3, vertex1: vec3, vertex2: vec3, color: Optional[RGBcolor] = None) -> int:
336 """Add a triangle primitive to the context
337
338 Args:
339 vertex0: First vertex of the triangle
340 vertex1: Second vertex of the triangle
341 vertex2: Third vertex of the triangle
342 color: Optional triangle color (defaults to white)
343
344 Returns:
345 UUID of the created triangle primitive
346 """
348 if color is None:
349 return context_wrapper.addTriangle(self.context, vertex0.to_list(), vertex1.to_list(), vertex2.to_list())
350 else:
351 return context_wrapper.addTriangleWithColor(self.context, vertex0.to_list(), vertex1.to_list(), vertex2.to_list(), color.to_list())
352
353 def addTriangleTextured(self, vertex0: vec3, vertex1: vec3, vertex2: vec3,
354 texture_file: str, uv0: vec2, uv1: vec2, uv2: vec2) -> int:
355 """Add a textured triangle primitive to the context
356
357 Creates a triangle with texture mapping. The texture image is mapped to the triangle
358 surface using UV coordinates, where (0,0) represents the top-left corner of the image
359 and (1,1) represents the bottom-right corner.
360
361 Args:
362 vertex0: First vertex of the triangle
363 vertex1: Second vertex of the triangle
364 vertex2: Third vertex of the triangle
365 texture_file: Path to texture image file (supports PNG, JPG, JPEG, TGA, BMP)
366 uv0: UV texture coordinates for first vertex
367 uv1: UV texture coordinates for second vertex
368 uv2: UV texture coordinates for third vertex
369
370 Returns:
371 UUID of the created textured triangle primitive
372
373 Raises:
374 ValueError: If texture file path is invalid
375 FileNotFoundError: If texture file doesn't exist
376 RuntimeError: If context is in mock mode
377
378 Example:
379 >>> context = Context()
380 >>> # Create a textured triangle
381 >>> vertex0 = vec3(0, 0, 0)
382 >>> vertex1 = vec3(1, 0, 0)
383 >>> vertex2 = vec3(0.5, 1, 0)
384 >>> uv0 = vec2(0, 0) # Bottom-left of texture
385 >>> uv1 = vec2(1, 0) # Bottom-right of texture
386 >>> uv2 = vec2(0.5, 1) # Top-center of texture
387 >>> uuid = context.addTriangleTextured(vertex0, vertex1, vertex2,
388 ... "texture.png", uv0, uv1, uv2)
389 """
391
392 # Validate texture file path
393 validated_texture_file = self._validate_file_path(texture_file,
394 ['.png', '.jpg', '.jpeg', '.tga', '.bmp'])
395
396 # Call the wrapper function
397 return context_wrapper.addTriangleWithTexture(
398 self.context,
399 vertex0.to_list(), vertex1.to_list(), vertex2.to_list(),
400 validated_texture_file,
401 uv0.to_list(), uv1.to_list(), uv2.to_list()
402 )
403
404 def getPrimitiveType(self, uuid: int) -> PrimitiveType:
406 primitive_type = context_wrapper.getPrimitiveType(self.context, uuid)
407 return PrimitiveType(primitive_type)
408
409 def getPrimitiveArea(self, uuid: int) -> float:
411 return context_wrapper.getPrimitiveArea(self.context, uuid)
413 def getPrimitiveNormal(self, uuid: int) -> vec3:
415 normal_ptr = context_wrapper.getPrimitiveNormal(self.context, uuid)
416 v = vec3(normal_ptr[0], normal_ptr[1], normal_ptr[2])
417 return v
418
419 def getPrimitiveVertices(self, uuid: int) -> List[vec3]:
421 size = ctypes.c_uint()
422 vertices_ptr = context_wrapper.getPrimitiveVertices(self.context, uuid, ctypes.byref(size))
423 # size.value is the total number of floats (3 per vertex), not the number of vertices
424 vertices_list = ctypes.cast(vertices_ptr, ctypes.POINTER(ctypes.c_float * size.value)).contents
425 vertices = [vec3(vertices_list[i], vertices_list[i+1], vertices_list[i+2]) for i in range(0, size.value, 3)]
426 return vertices
428 def getPrimitiveColor(self, uuid: int) -> RGBcolor:
430 color_ptr = context_wrapper.getPrimitiveColor(self.context, uuid)
431 return RGBcolor(color_ptr[0], color_ptr[1], color_ptr[2])
432
433 def getPrimitiveCount(self) -> int:
435 return context_wrapper.getPrimitiveCount(self.context)
437 def getAllUUIDs(self) -> List[int]:
439 size = ctypes.c_uint()
440 uuids_ptr = context_wrapper.getAllUUIDs(self.context, ctypes.byref(size))
441 return list(uuids_ptr[:size.value])
442
443 def getObjectCount(self) -> int:
445 return context_wrapper.getObjectCount(self.context)
446
447 def getAllObjectIDs(self) -> List[int]:
449 size = ctypes.c_uint()
450 objectids_ptr = context_wrapper.getAllObjectIDs(self.context, ctypes.byref(size))
451 return list(objectids_ptr[:size.value])
452
453 def getPrimitiveInfo(self, uuid: int) -> PrimitiveInfo:
454 """
455 Get physical properties and geometry information for a single primitive.
456
457 Args:
458 uuid: UUID of the primitive
459
460 Returns:
461 PrimitiveInfo object containing physical properties and geometry
462 """
463 # Get all physical properties using existing methods
464 primitive_type = self.getPrimitiveType(uuid)
465 area = self.getPrimitiveArea(uuid)
466 normal = self.getPrimitiveNormal(uuid)
467 vertices = self.getPrimitiveVertices(uuid)
468 color = self.getPrimitiveColor(uuid)
469
470 # Create and return PrimitiveInfo object
471 return PrimitiveInfo(
472 uuid=uuid,
473 primitive_type=primitive_type,
474 area=area,
475 normal=normal,
476 vertices=vertices,
477 color=color
478 )
479
480 def getAllPrimitiveInfo(self) -> List[PrimitiveInfo]:
481 """
482 Get physical properties and geometry information for all primitives in the context.
483
484 Returns:
485 List of PrimitiveInfo objects for all primitives
486 """
487 all_uuids = self.getAllUUIDs()
488 return [self.getPrimitiveInfo(uuid) for uuid in all_uuids]
489
490 def getPrimitivesInfoForObject(self, object_id: int) -> List[PrimitiveInfo]:
491 """
492 Get physical properties and geometry information for all primitives belonging to a specific object.
493
494 Args:
495 object_id: ID of the object
496
497 Returns:
498 List of PrimitiveInfo objects for primitives in the object
499 """
500 object_uuids = context_wrapper.getObjectPrimitiveUUIDs(self.context, object_id)
501 return [self.getPrimitiveInfo(uuid) for uuid in object_uuids]
502
503 # Compound geometry methods
504 def addTile(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1),
505 rotation: Optional[SphericalCoord] = None, subdiv: int2 = int2(1, 1),
506 color: Optional[RGBcolor] = None) -> List[int]:
507 """
508 Add a subdivided patch (tile) to the context.
509
510 A tile is a patch subdivided into a regular grid of smaller patches,
511 useful for creating detailed surfaces or terrain.
512
513 Args:
514 center: 3D coordinates of tile center (default: origin)
515 size: Width and height of the tile (default: 1x1)
516 rotation: Orientation of the tile (default: no rotation)
517 subdiv: Number of subdivisions in x and y directions (default: 1x1)
518 color: Color of the tile (default: white)
519
520 Returns:
521 List of UUIDs for all patches created in the tile
522
523 Example:
524 >>> context = Context()
525 >>> # Create a 2x2 meter tile subdivided into 4x4 patches
526 >>> tile_uuids = context.addTile(
527 ... center=vec3(0, 0, 1),
528 ... size=vec2(2, 2),
529 ... subdiv=int2(4, 4),
530 ... color=RGBcolor(0.5, 0.8, 0.2)
531 ... )
532 >>> print(f"Created {len(tile_uuids)} patches")
533 """
535
536 # Parameter type validation
537 if not isinstance(center, vec3):
538 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
539 if not isinstance(size, vec2):
540 raise ValueError(f"Size must be a vec2, got {type(size).__name__}")
541 if rotation is not None and not isinstance(rotation, SphericalCoord):
542 raise ValueError(f"Rotation must be a SphericalCoord or None, got {type(rotation).__name__}")
543 if not isinstance(subdiv, int2):
544 raise ValueError(f"Subdiv must be an int2, got {type(subdiv).__name__}")
545 if color is not None and not isinstance(color, RGBcolor):
546 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
547
548 # Parameter value validation
549 if any(s <= 0 for s in size.to_list()):
550 raise ValueError("All size dimensions must be positive")
551 if any(s <= 0 for s in subdiv.to_list()):
552 raise ValueError("All subdivision counts must be positive")
553
554 rotation = rotation or SphericalCoord(1, 0, 0)
555 color = color or RGBcolor(1, 1, 1)
556
557 # Extract only radius, elevation, azimuth for C++ interface
558 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
559
560 if color and not (color.r == 1.0 and color.g == 1.0 and color.b == 1.0):
561 return context_wrapper.addTileWithColor(
562 self.context, center.to_list(), size.to_list(),
563 rotation_list, subdiv.to_list(), color.to_list()
564 )
565 else:
566 return context_wrapper.addTile(
567 self.context, center.to_list(), size.to_list(),
568 rotation_list, subdiv.to_list()
569 )
570
571 @validate_sphere_params
572 def addSphere(self, center: vec3 = vec3(0, 0, 0), radius: float = 1.0,
573 ndivs: int = 10, color: Optional[RGBcolor] = None) -> List[int]:
574 """
575 Add a sphere to the context.
576
577 The sphere is tessellated into triangular faces based on the specified
578 number of divisions.
579
580 Args:
581 center: 3D coordinates of sphere center (default: origin)
582 radius: Radius of the sphere (default: 1.0)
583 ndivs: Number of divisions for tessellation (default: 10)
584 Higher values create smoother spheres but more triangles
585 color: Color of the sphere (default: white)
586
587 Returns:
588 List of UUIDs for all triangles created in the sphere
589
590 Example:
591 >>> context = Context()
592 >>> # Create a red sphere at (1, 2, 3) with radius 0.5
593 >>> sphere_uuids = context.addSphere(
594 ... center=vec3(1, 2, 3),
595 ... radius=0.5,
596 ... ndivs=20,
597 ... color=RGBcolor(1, 0, 0)
598 ... )
599 >>> print(f"Created sphere with {len(sphere_uuids)} triangles")
600 """
602
603 # Parameter type validation
604 if not isinstance(center, vec3):
605 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
606 if not isinstance(radius, (int, float)):
607 raise ValueError(f"Radius must be a number, got {type(radius).__name__}")
608 if not isinstance(ndivs, int):
609 raise ValueError(f"Ndivs must be an integer, got {type(ndivs).__name__}")
610 if color is not None and not isinstance(color, RGBcolor):
611 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
612
613 # Parameter value validation
614 if radius <= 0:
615 raise ValueError("Sphere radius must be positive")
616 if ndivs < 3:
617 raise ValueError("Number of divisions must be at least 3")
618
619 if color:
620 return context_wrapper.addSphereWithColor(
621 self.context, ndivs, center.to_list(), radius, color.to_list()
622 )
623 else:
624 return context_wrapper.addSphere(
625 self.context, ndivs, center.to_list(), radius
626 )
627
628 @validate_tube_params
629 def addTube(self, nodes: List[vec3], radii: Union[float, List[float]],
630 ndivs: int = 6, colors: Optional[Union[RGBcolor, List[RGBcolor]]] = None) -> List[int]:
631 """
632 Add a tube (pipe/cylinder) to the context.
633
634 The tube is defined by a series of nodes (path) with radius at each node.
635 It's tessellated into triangular faces based on the number of radial divisions.
636
637 Args:
638 nodes: List of 3D points defining the tube path (at least 2 nodes)
639 radii: Radius at each node. Can be:
640 - Single float: constant radius for all nodes
641 - List of floats: radius for each node (must match nodes length)
642 ndivs: Number of radial divisions (default: 6)
643 Higher values create smoother tubes but more triangles
644 colors: Colors at each node. Can be:
645 - None: white tube
646 - Single RGBcolor: constant color for all nodes
647 - List of RGBcolor: color for each node (must match nodes length)
648
649 Returns:
650 List of UUIDs for all triangles created in the tube
651
652 Example:
653 >>> context = Context()
654 >>> # Create a curved tube with varying radius
655 >>> nodes = [vec3(0, 0, 0), vec3(1, 0, 0), vec3(2, 1, 0)]
656 >>> radii = [0.1, 0.2, 0.1]
657 >>> colors = [RGBcolor(1, 0, 0), RGBcolor(0, 1, 0), RGBcolor(0, 0, 1)]
658 >>> tube_uuids = context.addTube(nodes, radii, ndivs=8, colors=colors)
659 >>> print(f"Created tube with {len(tube_uuids)} triangles")
660 """
662
663 # Parameter type validation
664 if not isinstance(nodes, (list, tuple)):
665 raise ValueError(f"Nodes must be a list or tuple, got {type(nodes).__name__}")
666 if not isinstance(ndivs, int):
667 raise ValueError(f"Ndivs must be an integer, got {type(ndivs).__name__}")
668 if colors is not None and not isinstance(colors, (RGBcolor, list, tuple)):
669 raise ValueError(f"Colors must be RGBcolor, list, tuple, or None, got {type(colors).__name__}")
670
671 # Parameter value validation
672 if len(nodes) < 2:
673 raise ValueError("Tube requires at least 2 nodes")
674 if ndivs < 3:
675 raise ValueError("Number of radial divisions must be at least 3")
676
677 # Handle radius parameter
678 if isinstance(radii, (int, float)):
679 radii_list = [float(radii)] * len(nodes)
680 else:
681 radii_list = [float(r) for r in radii]
682 if len(radii_list) != len(nodes):
683 raise ValueError(f"Number of radii ({len(radii_list)}) must match number of nodes ({len(nodes)})")
684
685 # Validate radii
686 if any(r <= 0 for r in radii_list):
687 raise ValueError("All radii must be positive")
688
689 # Convert nodes to flat list
690 nodes_flat = []
691 for node in nodes:
692 nodes_flat.extend(node.to_list())
693
694 # Handle colors parameter
695 if colors is None:
696 return context_wrapper.addTube(self.context, ndivs, nodes_flat, radii_list)
697 elif isinstance(colors, RGBcolor):
698 # Single color for all nodes
699 colors_flat = colors.to_list() * len(nodes)
700 else:
701 # List of colors
702 if len(colors) != len(nodes):
703 raise ValueError(f"Number of colors ({len(colors)}) must match number of nodes ({len(nodes)})")
704 colors_flat = []
705 for color in colors:
706 colors_flat.extend(color.to_list())
707
708 return context_wrapper.addTubeWithColor(self.context, ndivs, nodes_flat, radii_list, colors_flat)
709
710 @validate_box_params
711 def addBox(self, center: vec3 = vec3(0, 0, 0), size: vec3 = vec3(1, 1, 1),
712 subdiv: int3 = int3(1, 1, 1), color: Optional[RGBcolor] = None) -> List[int]:
713 """
714 Add a rectangular box to the context.
715
716 The box is subdivided into patches on each face based on the specified
717 subdivisions.
718
719 Args:
720 center: 3D coordinates of box center (default: origin)
721 size: Width, height, and depth of the box (default: 1x1x1)
722 subdiv: Number of subdivisions in x, y, and z directions (default: 1x1x1)
723 Higher values create more detailed surfaces
724 color: Color of the box (default: white)
725
726 Returns:
727 List of UUIDs for all patches created on the box faces
728
729 Example:
730 >>> context = Context()
731 >>> # Create a blue box subdivided for detail
732 >>> box_uuids = context.addBox(
733 ... center=vec3(0, 0, 2),
734 ... size=vec3(2, 1, 0.5),
735 ... subdiv=int3(4, 2, 1),
736 ... color=RGBcolor(0, 0, 1)
737 ... )
738 >>> print(f"Created box with {len(box_uuids)} patches")
739 """
741
742 # Parameter type validation
743 if not isinstance(center, vec3):
744 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
745 if not isinstance(size, vec3):
746 raise ValueError(f"Size must be a vec3, got {type(size).__name__}")
747 if not isinstance(subdiv, int3):
748 raise ValueError(f"Subdiv must be an int3, got {type(subdiv).__name__}")
749 if color is not None and not isinstance(color, RGBcolor):
750 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
751
752 # Parameter value validation
753 if any(s <= 0 for s in size.to_list()):
754 raise ValueError("All box dimensions must be positive")
755 if any(s < 1 for s in subdiv.to_list()):
756 raise ValueError("All subdivision counts must be at least 1")
757
758 if color:
759 return context_wrapper.addBoxWithColor(
760 self.context, center.to_list(), size.to_list(),
761 subdiv.to_list(), color.to_list()
762 )
763 else:
764 return context_wrapper.addBox(
765 self.context, center.to_list(), size.to_list(), subdiv.to_list()
766 )
767
768 def addDisk(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1),
769 ndivs: Union[int, int2] = 20, rotation: Optional[SphericalCoord] = None,
770 color: Optional[Union[RGBcolor, RGBAcolor]] = None) -> List[int]:
771 """
772 Add a disk (circular or elliptical surface) to the context.
773
774 A disk is a flat circular or elliptical surface tessellated into
775 triangular faces. Supports both uniform radial subdivisions and
776 separate radial/azimuthal subdivisions for finer control.
777
778 Args:
779 center: 3D coordinates of disk center (default: origin)
780 size: Semi-major and semi-minor radii of the disk (default: 1x1 circle)
781 ndivs: Number of radial divisions (int) or [radial, azimuthal] divisions (int2)
782 (default: 20). Higher values create smoother circles but more triangles.
783 rotation: Orientation of the disk (default: horizontal, normal = +z)
784 color: Color of the disk (default: white). Can be RGBcolor or RGBAcolor for transparency.
785
786 Returns:
787 List of UUIDs for all triangles created in the disk
788
789 Example:
790 >>> context = Context()
791 >>> # Create a red disk at (0, 0, 1) with radius 0.5
792 >>> disk_uuids = context.addDisk(
793 ... center=vec3(0, 0, 1),
794 ... size=vec2(0.5, 0.5),
795 ... ndivs=30,
796 ... color=RGBcolor(1, 0, 0)
797 ... )
798 >>> print(f"Created disk with {len(disk_uuids)} triangles")
799 >>>
800 >>> # Create a semi-transparent blue elliptical disk
801 >>> disk_uuids = context.addDisk(
802 ... center=vec3(0, 0, 2),
803 ... size=vec2(1.0, 0.5),
804 ... ndivs=40,
805 ... rotation=SphericalCoord(1, 0.5, 0),
806 ... color=RGBAcolor(0, 0, 1, 0.5)
807 ... )
808 >>>
809 >>> # Create disk with polar/radial subdivisions for finer control
810 >>> disk_uuids = context.addDisk(
811 ... center=vec3(0, 0, 3),
812 ... size=vec2(1, 1),
813 ... ndivs=int2(10, 20), # 10 radial, 20 azimuthal divisions
814 ... color=RGBcolor(0, 1, 0)
815 ... )
816 """
818
819 # Parameter type validation
820 if not isinstance(center, vec3):
821 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
822 if not isinstance(size, vec2):
823 raise ValueError(f"Size must be a vec2, got {type(size).__name__}")
824 if not isinstance(ndivs, (int, int2)):
825 raise ValueError(f"Ndivs must be an int or int2, got {type(ndivs).__name__}")
826 if rotation is not None and not isinstance(rotation, SphericalCoord):
827 raise ValueError(f"Rotation must be a SphericalCoord or None, got {type(rotation).__name__}")
828 if color is not None and not isinstance(color, (RGBcolor, RGBAcolor)):
829 raise ValueError(f"Color must be an RGBcolor, RGBAcolor, or None, got {type(color).__name__}")
830
831 # Parameter value validation
832 if any(s <= 0 for s in size.to_list()):
833 raise ValueError("Disk size must be positive")
834
835 # Validate subdivisions based on type
836 if isinstance(ndivs, int):
837 if ndivs < 3:
838 raise ValueError("Number of divisions must be at least 3")
839 else: # int2
840 if any(n < 1 for n in ndivs.to_list()):
841 raise ValueError("Radial and angular divisions must be at least 1")
842
843 # Default rotation (horizontal disk, normal pointing +z)
844 if rotation is None:
845 rotation = SphericalCoord(1, 0, 0)
846
847 # CRITICAL: Extract only radius, elevation, azimuth for C++ interface
848 # (rotation.to_list() returns 4 values, but C++ expects 3)
849 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
850
851 # Dispatch based on ndivs and color types
852 if isinstance(ndivs, int2):
853 # Polar subdivisions variant (supports RGB and RGBA color)
854 if color:
855 if isinstance(color, RGBAcolor):
856 return context_wrapper.addDiskPolarSubdivisionsRGBA(
857 self.context, ndivs.to_list(), center.to_list(), size.to_list(),
858 rotation_list, color.to_list()
859 )
860 else:
861 # RGB color
862 return context_wrapper.addDiskPolarSubdivisions(
863 self.context, ndivs.to_list(), center.to_list(), size.to_list(),
864 rotation_list, color.to_list()
865 )
866 else:
867 # No color - use default white
868 color_list = [1.0, 1.0, 1.0]
869 return context_wrapper.addDiskPolarSubdivisions(
870 self.context, ndivs.to_list(), center.to_list(), size.to_list(),
871 rotation_list, color_list
872 )
873 else:
874 # Uniform radial subdivisions
875 if color:
876 if isinstance(color, RGBAcolor):
877 # RGBA color variant
878 return context_wrapper.addDiskWithRGBAColor(
879 self.context, ndivs, center.to_list(), size.to_list(),
880 rotation_list, color.to_list()
881 )
882 else:
883 # RGB color variant
884 return context_wrapper.addDiskWithColor(
885 self.context, ndivs, center.to_list(), size.to_list(),
886 rotation_list, color.to_list()
887 )
888 else:
889 # No color - use rotation variant
890 return context_wrapper.addDiskWithRotation(
891 self.context, ndivs, center.to_list(), size.to_list(),
892 rotation_list
893 )
894
895 def addCone(self, node0: vec3, node1: vec3, radius0: float, radius1: float,
896 ndivs: int = 20, color: Optional[RGBcolor] = None) -> List[int]:
897 """
898 Add a cone (or cylinder/frustum) to the context.
899
900 A cone is a 3D shape connecting two circular cross-sections with
901 potentially different radii. When radii are equal, creates a cylinder.
902 When one radius is zero, creates a true cone.
903
904 Args:
905 node0: 3D coordinates of the base center
906 node1: 3D coordinates of the apex center
907 radius0: Radius at base (node0). Use 0 for pointed end.
908 radius1: Radius at apex (node1). Use 0 for pointed end.
909 ndivs: Number of radial divisions for tessellation (default: 20)
910 color: Color of the cone (default: white)
911
912 Returns:
913 List of UUIDs for all triangles created in the cone
914
915 Example:
916 >>> context = Context()
917 >>> # Create a cylinder (equal radii)
918 >>> cylinder_uuids = context.addCone(
919 ... node0=vec3(0, 0, 0),
920 ... node1=vec3(0, 0, 2),
921 ... radius0=0.5,
922 ... radius1=0.5,
923 ... ndivs=20
924 ... )
925 >>>
926 >>> # Create a true cone (one radius = 0)
927 >>> cone_uuids = context.addCone(
928 ... node0=vec3(1, 0, 0),
929 ... node1=vec3(1, 0, 1.5),
930 ... radius0=0.5,
931 ... radius1=0.0,
932 ... ndivs=24,
933 ... color=RGBcolor(1, 0, 0)
934 ... )
935 >>>
936 >>> # Create a frustum (different radii)
937 >>> frustum_uuids = context.addCone(
938 ... node0=vec3(2, 0, 0),
939 ... node1=vec3(2, 0, 1),
940 ... radius0=0.8,
941 ... radius1=0.4,
942 ... ndivs=16
943 ... )
944 """
946
947 # Parameter type validation
948 if not isinstance(node0, vec3):
949 raise ValueError(f"node0 must be a vec3, got {type(node0).__name__}")
950 if not isinstance(node1, vec3):
951 raise ValueError(f"node1 must be a vec3, got {type(node1).__name__}")
952 if not isinstance(ndivs, int):
953 raise ValueError(f"ndivs must be an int, got {type(ndivs).__name__}")
954 if color is not None and not isinstance(color, RGBcolor):
955 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
956
957 # Parameter value validation
958 if radius0 < 0 or radius1 < 0:
959 raise ValueError("Radii must be non-negative")
960 if ndivs < 3:
961 raise ValueError("Number of radial divisions must be at least 3")
962
963 # Dispatch based on color
964 if color:
965 return context_wrapper.addConeWithColor(
966 self.context, ndivs, node0.to_list(), node1.to_list(),
967 radius0, radius1, color.to_list()
968 )
969 else:
970 return context_wrapper.addCone(
971 self.context, ndivs, node0.to_list(), node1.to_list(),
972 radius0, radius1
973 )
974
975 def addSphereObject(self, center: vec3 = vec3(0, 0, 0),
976 radius: Union[float, vec3] = 1.0, ndivs: int = 20,
977 color: Optional[RGBcolor] = None,
978 texturefile: Optional[str] = None) -> int:
979 """
980 Add a spherical or ellipsoidal compound object to the context.
981
982 Creates a sphere or ellipsoid as a compound object with a trackable object ID.
983 Primitives within the object are registered as children of the object.
984
985 Args:
986 center: Center position of sphere/ellipsoid (default: origin)
987 radius: Radius as float (sphere) or vec3 (ellipsoid) (default: 1.0)
988 ndivs: Number of tessellation divisions (default: 20)
989 color: Optional RGB color
990 texturefile: Optional texture image file path
991
992 Returns:
993 Object ID of the created compound object
994
995 Raises:
996 ValueError: If parameters are invalid
997 NotImplementedError: If object-returning functions unavailable
998
999 Examples:
1000 >>> # Create a basic sphere at origin
1001 >>> obj_id = ctx.addSphereObject()
1002
1003 >>> # Create a colored sphere
1004 >>> obj_id = ctx.addSphereObject(
1005 ... center=vec3(0, 0, 5),
1006 ... radius=2.0,
1007 ... color=RGBcolor(1, 0, 0)
1008 ... )
1009
1010 >>> # Create an ellipsoid (stretched sphere)
1011 >>> obj_id = ctx.addSphereObject(
1012 ... center=vec3(10, 0, 0),
1013 ... radius=vec3(2, 1, 1), # Elongated in x-direction
1014 ... ndivs=30
1015 ... )
1016 """
1018
1019 # Validate parameters
1020 if ndivs < 3:
1021 raise ValueError("Number of divisions must be at least 3")
1022
1023 # Check if radius is scalar (sphere) or vector (ellipsoid)
1024 is_ellipsoid = isinstance(radius, vec3)
1025
1026 # Dispatch based on parameters
1027 if is_ellipsoid:
1028 # Ellipsoid variants
1029 if texturefile:
1030 return context_wrapper.addSphereObject_ellipsoid_texture(
1031 self.context, ndivs, center.to_list(), radius.to_list(), texturefile
1032 )
1033 elif color:
1034 return context_wrapper.addSphereObject_ellipsoid_color(
1035 self.context, ndivs, center.to_list(), radius.to_list(), color.to_list()
1036 )
1037 else:
1038 return context_wrapper.addSphereObject_ellipsoid(
1039 self.context, ndivs, center.to_list(), radius.to_list()
1040 )
1041 else:
1042 # Sphere variants (radius is float)
1043 if texturefile:
1044 return context_wrapper.addSphereObject_texture(
1045 self.context, ndivs, center.to_list(), radius, texturefile
1046 )
1047 elif color:
1048 return context_wrapper.addSphereObject_color(
1049 self.context, ndivs, center.to_list(), radius, color.to_list()
1050 )
1051 else:
1052 return context_wrapper.addSphereObject_basic(
1053 self.context, ndivs, center.to_list(), radius
1054 )
1055
1056 def addTileObject(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1),
1057 rotation: SphericalCoord = SphericalCoord(1, 0, 0),
1058 subdiv: int2 = int2(1, 1),
1059 color: Optional[RGBcolor] = None,
1060 texturefile: Optional[str] = None,
1061 texture_repeat: Optional[int2] = None) -> int:
1062 """
1063 Add a tiled patch (subdivided patch) as a compound object to the context.
1064
1065 Creates a rectangular patch subdivided into a grid of smaller patches,
1066 registered as a compound object with a trackable object ID.
1067
1068 Args:
1069 center: Center position of tile (default: origin)
1070 size: Size in x and y directions (default: 1x1)
1071 rotation: Spherical rotation (default: no rotation)
1072 subdiv: Number of subdivisions in x and y (default: 1x1)
1073 color: Optional RGB color
1074 texturefile: Optional texture image file path
1075 texture_repeat: Optional texture repetitions in x and y
1076
1077 Returns:
1078 Object ID of the created compound object
1079
1080 Raises:
1081 ValueError: If parameters are invalid
1082 NotImplementedError: If object-returning functions unavailable
1083
1084 Examples:
1085 >>> # Create a basic 2x2 tile
1086 >>> obj_id = ctx.addTileObject(
1087 ... center=vec3(0, 0, 0),
1088 ... size=vec2(10, 10),
1089 ... subdiv=int2(2, 2)
1090 ... )
1091
1092 >>> # Create a colored tile with rotation
1093 >>> obj_id = ctx.addTileObject(
1094 ... center=vec3(5, 0, 0),
1095 ... size=vec2(10, 5),
1096 ... rotation=SphericalCoord(1, 0, 45),
1097 ... subdiv=int2(4, 2),
1098 ... color=RGBcolor(0, 1, 0)
1099 ... )
1100 """
1102
1103 # Extract rotation as 3 values (radius, elevation, azimuth)
1104 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
1105
1106 # Dispatch based on parameters
1107 if texture_repeat is not None:
1108 if texturefile is None:
1109 raise ValueError("texture_repeat requires texturefile")
1110 return context_wrapper.addTileObject_texture_repeat(
1111 self.context, center.to_list(), size.to_list(), rotation_list,
1112 subdiv.to_list(), texturefile, texture_repeat.to_list()
1113 )
1114 elif texturefile:
1115 return context_wrapper.addTileObject_texture(
1116 self.context, center.to_list(), size.to_list(), rotation_list,
1117 subdiv.to_list(), texturefile
1118 )
1119 elif color:
1120 return context_wrapper.addTileObject_color(
1121 self.context, center.to_list(), size.to_list(), rotation_list,
1122 subdiv.to_list(), color.to_list()
1123 )
1124 else:
1125 return context_wrapper.addTileObject_basic(
1126 self.context, center.to_list(), size.to_list(), rotation_list,
1127 subdiv.to_list()
1128 )
1129
1130 def addBoxObject(self, center: vec3 = vec3(0, 0, 0), size: vec3 = vec3(1, 1, 1),
1131 subdiv: int3 = int3(1, 1, 1), color: Optional[RGBcolor] = None,
1132 texturefile: Optional[str] = None, reverse_normals: bool = False) -> int:
1133 """
1134 Add a rectangular box (prism) as a compound object to the context.
1135
1136 Args:
1137 center: Center position (default: origin)
1138 size: Size in x, y, z directions (default: 1x1x1)
1139 subdiv: Subdivisions in x, y, z (default: 1x1x1)
1140 color: Optional RGB color
1141 texturefile: Optional texture file path
1142 reverse_normals: Reverse normal directions (default: False)
1143
1144 Returns:
1145 Object ID of the created compound object
1146 """
1148
1149 if reverse_normals:
1150 if texturefile:
1151 return context_wrapper.addBoxObject_texture_reverse(self.context, center.to_list(), size.to_list(), subdiv.to_list(), texturefile, reverse_normals)
1152 elif color:
1153 return context_wrapper.addBoxObject_color_reverse(self.context, center.to_list(), size.to_list(), subdiv.to_list(), color.to_list(), reverse_normals)
1154 else:
1155 raise ValueError("reverse_normals requires either color or texturefile")
1156 elif texturefile:
1157 return context_wrapper.addBoxObject_texture(self.context, center.to_list(), size.to_list(), subdiv.to_list(), texturefile)
1158 elif color:
1159 return context_wrapper.addBoxObject_color(self.context, center.to_list(), size.to_list(), subdiv.to_list(), color.to_list())
1160 else:
1161 return context_wrapper.addBoxObject_basic(self.context, center.to_list(), size.to_list(), subdiv.to_list())
1162
1163 def addConeObject(self, node0: vec3, node1: vec3, radius0: float, radius1: float,
1164 ndivs: int = 20, color: Optional[RGBcolor] = None,
1165 texturefile: Optional[str] = None) -> int:
1166 """
1167 Add a cone/cylinder/frustum as a compound object to the context.
1168
1169 Args:
1170 node0: Base position
1171 node1: Top position
1172 radius0: Radius at base
1173 radius1: Radius at top
1174 ndivs: Number of radial divisions (default: 20)
1175 color: Optional RGB color
1176 texturefile: Optional texture file path
1177
1178 Returns:
1179 Object ID of the created compound object
1180 """
1182
1183 if texturefile:
1184 return context_wrapper.addConeObject_texture(self.context, ndivs, node0.to_list(), node1.to_list(), radius0, radius1, texturefile)
1185 elif color:
1186 return context_wrapper.addConeObject_color(self.context, ndivs, node0.to_list(), node1.to_list(), radius0, radius1, color.to_list())
1187 else:
1188 return context_wrapper.addConeObject_basic(self.context, ndivs, node0.to_list(), node1.to_list(), radius0, radius1)
1189
1190 def addDiskObject(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1),
1191 ndivs: Union[int, int2] = 20, rotation: Optional[SphericalCoord] = None,
1192 color: Optional[Union[RGBcolor, RGBAcolor]] = None,
1193 texturefile: Optional[str] = None) -> int:
1194 """
1195 Add a disk as a compound object to the context.
1196
1197 Args:
1198 center: Center position (default: origin)
1199 size: Semi-major and semi-minor radii (default: 1x1)
1200 ndivs: int (uniform) or int2 (polar/radial subdivisions) (default: 20)
1201 rotation: Optional spherical rotation
1202 color: Optional RGB or RGBA color
1203 texturefile: Optional texture file path
1204
1205 Returns:
1206 Object ID of the created compound object
1207 """
1209
1210 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth] if rotation else [1, 0, 0]
1211 is_polar = isinstance(ndivs, int2)
1213 if is_polar:
1214 if texturefile:
1215 return context_wrapper.addDiskObject_polar_texture(self.context, ndivs.to_list(), center.to_list(), size.to_list(), rotation_list, texturefile)
1216 elif color:
1217 if isinstance(color, RGBAcolor):
1218 return context_wrapper.addDiskObject_polar_rgba(self.context, ndivs.to_list(), center.to_list(), size.to_list(), rotation_list, color.to_list())
1219 else:
1220 return context_wrapper.addDiskObject_polar_color(self.context, ndivs.to_list(), center.to_list(), size.to_list(), rotation_list, color.to_list())
1221 else:
1222 return context_wrapper.addDiskObject_polar_color(self.context, ndivs.to_list(), center.to_list(), size.to_list(), rotation_list, RGBcolor(0.5, 0.5, 0.5).to_list())
1223 else:
1224 if texturefile:
1225 return context_wrapper.addDiskObject_texture(self.context, ndivs, center.to_list(), size.to_list(), rotation_list, texturefile)
1226 elif color:
1227 if isinstance(color, RGBAcolor):
1228 return context_wrapper.addDiskObject_rgba(self.context, ndivs, center.to_list(), size.to_list(), rotation_list, color.to_list())
1229 else:
1230 return context_wrapper.addDiskObject_color(self.context, ndivs, center.to_list(), size.to_list(), rotation_list, color.to_list())
1231 elif rotation:
1232 return context_wrapper.addDiskObject_rotation(self.context, ndivs, center.to_list(), size.to_list(), rotation_list)
1233 else:
1234 return context_wrapper.addDiskObject_basic(self.context, ndivs, center.to_list(), size.to_list())
1235
1236 def addTubeObject(self, ndivs: int, nodes: List[vec3], radii: List[float],
1237 colors: Optional[List[RGBcolor]] = None,
1238 texturefile: Optional[str] = None,
1239 texture_uv: Optional[List[float]] = None) -> int:
1240 """
1241 Add a tube as a compound object to the context.
1242
1243 Args:
1244 ndivs: Number of radial subdivisions
1245 nodes: List of vec3 positions defining tube segments
1246 radii: List of radii at each node
1247 colors: Optional list of RGB colors for each segment
1248 texturefile: Optional texture file path
1249 texture_uv: Optional UV coordinates for texture mapping
1250
1251 Returns:
1252 Object ID of the created compound object
1253 """
1255
1256 if len(nodes) < 2:
1257 raise ValueError("Tube requires at least 2 nodes")
1258 if len(radii) != len(nodes):
1259 raise ValueError("Number of radii must match number of nodes")
1260
1261 nodes_flat = [coord for node in nodes for coord in node.to_list()]
1262
1263 if texture_uv is not None:
1264 if texturefile is None:
1265 raise ValueError("texture_uv requires texturefile")
1266 return context_wrapper.addTubeObject_texture_uv(self.context, ndivs, nodes_flat, radii, texturefile, texture_uv)
1267 elif texturefile:
1268 return context_wrapper.addTubeObject_texture(self.context, ndivs, nodes_flat, radii, texturefile)
1269 elif colors:
1270 if len(colors) != len(nodes):
1271 raise ValueError("Number of colors must match number of nodes")
1272 colors_flat = [c for color in colors for c in color.to_list()]
1273 return context_wrapper.addTubeObject_color(self.context, ndivs, nodes_flat, radii, colors_flat)
1274 else:
1275 return context_wrapper.addTubeObject_basic(self.context, ndivs, nodes_flat, radii)
1276
1277 def copyPrimitive(self, UUID: Union[int, List[int]]) -> Union[int, List[int]]:
1278 """
1279 Copy one or more primitives.
1280
1281 Creates a duplicate of the specified primitive(s) with all associated data.
1282 The copy is placed at the same location as the original.
1283
1284 Args:
1285 UUID: Single primitive UUID or list of UUIDs to copy
1286
1287 Returns:
1288 Single UUID of copied primitive (if UUID is int) or
1289 List of UUIDs of copied primitives (if UUID is list)
1290
1291 Example:
1292 >>> context = Context()
1293 >>> original_uuid = context.addPatch(center=vec3(0, 0, 0), size=vec2(1, 1))
1294 >>> # Copy single primitive
1295 >>> copied_uuid = context.copyPrimitive(original_uuid)
1296 >>> # Copy multiple primitives
1297 >>> copied_uuids = context.copyPrimitive([uuid1, uuid2, uuid3])
1298 """
1300
1301 if isinstance(UUID, int):
1302 return context_wrapper.copyPrimitive(self.context, UUID)
1303 elif isinstance(UUID, list):
1304 return context_wrapper.copyPrimitives(self.context, UUID)
1305 else:
1306 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
1307
1308 def copyPrimitiveData(self, sourceUUID: int, destinationUUID: int) -> None:
1309 """
1310 Copy all primitive data from source to destination primitive.
1311
1312 Copies all associated data (primitive data fields) from the source
1313 primitive to the destination primitive. Both primitives must already exist.
1314
1315 Args:
1316 sourceUUID: UUID of the source primitive
1317 destinationUUID: UUID of the destination primitive
1318
1319 Example:
1320 >>> context = Context()
1321 >>> source_uuid = context.addPatch(center=vec3(0, 0, 0), size=vec2(1, 1))
1322 >>> dest_uuid = context.addPatch(center=vec3(1, 0, 0), size=vec2(1, 1))
1323 >>> context.setPrimitiveData(source_uuid, "temperature", 25.5)
1324 >>> context.copyPrimitiveData(source_uuid, dest_uuid)
1325 >>> # dest_uuid now has temperature data
1326 """
1328
1329 if not isinstance(sourceUUID, int):
1330 raise ValueError(f"sourceUUID must be int, got {type(sourceUUID).__name__}")
1331 if not isinstance(destinationUUID, int):
1332 raise ValueError(f"destinationUUID must be int, got {type(destinationUUID).__name__}")
1333
1334 context_wrapper.copyPrimitiveData(self.context, sourceUUID, destinationUUID)
1335
1336 def copyObject(self, ObjID: Union[int, List[int]]) -> Union[int, List[int]]:
1337 """
1338 Copy one or more compound objects.
1339
1340 Creates a duplicate of the specified compound object(s) with all
1341 associated primitives and data. The copy is placed at the same location
1342 as the original.
1343
1344 Args:
1345 ObjID: Single object ID or list of object IDs to copy
1346
1347 Returns:
1348 Single object ID of copied object (if ObjID is int) or
1349 List of object IDs of copied objects (if ObjID is list)
1350
1351 Example:
1352 >>> context = Context()
1353 >>> original_obj = context.addTile(center=vec3(0, 0, 0), size=vec2(2, 2))
1354 >>> # Copy single object
1355 >>> copied_obj = context.copyObject(original_obj)
1356 >>> # Copy multiple objects
1357 >>> copied_objs = context.copyObject([obj1, obj2, obj3])
1358 """
1360
1361 if isinstance(ObjID, int):
1362 return context_wrapper.copyObject(self.context, ObjID)
1363 elif isinstance(ObjID, list):
1364 return context_wrapper.copyObjects(self.context, ObjID)
1365 else:
1366 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1367
1368 def copyObjectData(self, source_objID: int, destination_objID: int) -> None:
1369 """
1370 Copy all object data from source to destination compound object.
1371
1372 Copies all associated data (object data fields) from the source
1373 compound object to the destination object. Both objects must already exist.
1374
1375 Args:
1376 source_objID: Object ID of the source compound object
1377 destination_objID: Object ID of the destination compound object
1378
1379 Example:
1380 >>> context = Context()
1381 >>> source_obj = context.addTile(center=vec3(0, 0, 0), size=vec2(2, 2))
1382 >>> dest_obj = context.addTile(center=vec3(2, 0, 0), size=vec2(2, 2))
1383 >>> context.setObjectData(source_obj, "material", "wood")
1384 >>> context.copyObjectData(source_obj, dest_obj)
1385 >>> # dest_obj now has material data
1386 """
1388
1389 if not isinstance(source_objID, int):
1390 raise ValueError(f"source_objID must be int, got {type(source_objID).__name__}")
1391 if not isinstance(destination_objID, int):
1392 raise ValueError(f"destination_objID must be int, got {type(destination_objID).__name__}")
1393
1394 context_wrapper.copyObjectData(self.context, source_objID, destination_objID)
1395
1396 def translatePrimitive(self, UUID: Union[int, List[int]], shift: vec3) -> None:
1397 """
1398 Translate one or more primitives by a shift vector.
1399
1400 Moves the specified primitive(s) by the given shift vector without
1401 changing their orientation or size.
1402
1403 Args:
1404 UUID: Single primitive UUID or list of UUIDs to translate
1405 shift: 3D vector representing the translation [x, y, z]
1406
1407 Example:
1408 >>> context = Context()
1409 >>> patch_uuid = context.addPatch(center=vec3(0, 0, 0), size=vec2(1, 1))
1410 >>> # Translate single primitive
1411 >>> context.translatePrimitive(patch_uuid, vec3(1, 0, 0)) # Move 1 unit in x
1412 >>> # Translate multiple primitives
1413 >>> context.translatePrimitive([uuid1, uuid2, uuid3], vec3(0, 0, 1)) # Move 1 unit in z
1414 """
1416
1417 # Type validation
1418 if not isinstance(shift, vec3):
1419 raise ValueError(f"shift must be a vec3, got {type(shift).__name__}")
1420
1421 if isinstance(UUID, int):
1422 context_wrapper.translatePrimitive(self.context, UUID, shift.to_list())
1423 elif isinstance(UUID, list):
1424 context_wrapper.translatePrimitives(self.context, UUID, shift.to_list())
1425 else:
1426 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
1427
1428 def translateObject(self, ObjID: Union[int, List[int]], shift: vec3) -> None:
1429 """
1430 Translate one or more compound objects by a shift vector.
1431
1432 Moves the specified compound object(s) and all their constituent
1433 primitives by the given shift vector without changing orientation or size.
1434
1435 Args:
1436 ObjID: Single object ID or list of object IDs to translate
1437 shift: 3D vector representing the translation [x, y, z]
1438
1439 Example:
1440 >>> context = Context()
1441 >>> tile_uuids = context.addTile(center=vec3(0, 0, 0), size=vec2(2, 2))
1442 >>> obj_id = context.getPrimitiveParentObjectID(tile_uuids[0]) # Get object ID
1443 >>> # Translate single object
1444 >>> context.translateObject(obj_id, vec3(5, 0, 0)) # Move 5 units in x
1445 >>> # Translate multiple objects
1446 >>> context.translateObject([obj1, obj2, obj3], vec3(0, 2, 0)) # Move 2 units in y
1447 """
1449
1450 # Type validation
1451 if not isinstance(shift, vec3):
1452 raise ValueError(f"shift must be a vec3, got {type(shift).__name__}")
1453
1454 if isinstance(ObjID, int):
1455 context_wrapper.translateObject(self.context, ObjID, shift.to_list())
1456 elif isinstance(ObjID, list):
1457 context_wrapper.translateObjects(self.context, ObjID, shift.to_list())
1458 else:
1459 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1460
1461 def rotatePrimitive(self, UUID: Union[int, List[int]], angle: float,
1462 axis: Union[str, vec3], origin: Optional[vec3] = None) -> None:
1463 """
1464 Rotate one or more primitives.
1465
1466 Args:
1467 UUID: Single UUID or list of UUIDs to rotate
1468 angle: Rotation angle in radians
1469 axis: Rotation axis - either 'x', 'y', 'z' or a vec3 direction vector
1470 origin: Optional rotation origin point. If None, rotates about primitive center.
1471 If provided with string axis, raises ValueError.
1472
1473 Raises:
1474 ValueError: If axis is invalid or if origin is provided with string axis
1475 """
1477
1478 # Validate axis parameter
1479 if isinstance(axis, str):
1480 if axis not in ('x', 'y', 'z'):
1481 raise ValueError("axis must be 'x', 'y', or 'z'")
1482 if origin is not None:
1483 raise ValueError("origin parameter cannot be used with string axis")
1484
1485 # Use string axis variant
1486 if isinstance(UUID, int):
1487 context_wrapper.rotatePrimitive_axisString(self.context, UUID, angle, axis)
1488 elif isinstance(UUID, list):
1489 context_wrapper.rotatePrimitives_axisString(self.context, UUID, angle, axis)
1490 else:
1491 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
1492
1493 elif isinstance(axis, vec3):
1494 axis_list = axis.to_list()
1495
1496 # Check for zero-length axis
1497 if all(abs(v) < 1e-10 for v in axis_list):
1498 raise ValueError("axis vector cannot be zero")
1499
1500 if origin is None:
1501 # Rotate about primitive center (axis vector variant)
1502 if isinstance(UUID, int):
1503 context_wrapper.rotatePrimitive_axisVector(self.context, UUID, angle, axis_list)
1504 elif isinstance(UUID, list):
1505 context_wrapper.rotatePrimitives_axisVector(self.context, UUID, angle, axis_list)
1506 else:
1507 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
1508 else:
1509 # Rotate about specified origin point
1510 if not isinstance(origin, vec3):
1511 raise ValueError(f"origin must be a vec3, got {type(origin).__name__}")
1512
1513 origin_list = origin.to_list()
1514 if isinstance(UUID, int):
1515 context_wrapper.rotatePrimitive_originAxisVector(self.context, UUID, angle, origin_list, axis_list)
1516 elif isinstance(UUID, list):
1517 context_wrapper.rotatePrimitives_originAxisVector(self.context, UUID, angle, origin_list, axis_list)
1518 else:
1519 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
1520 else:
1521 raise ValueError(f"axis must be str or vec3, got {type(axis).__name__}")
1522
1523 def rotateObject(self, ObjID: Union[int, List[int]], angle: float,
1524 axis: Union[str, vec3], origin: Optional[vec3] = None,
1525 about_origin: bool = False) -> None:
1526 """
1527 Rotate one or more objects.
1528
1529 Args:
1530 ObjID: Single object ID or list of object IDs to rotate
1531 angle: Rotation angle in radians
1532 axis: Rotation axis - either 'x', 'y', 'z' or a vec3 direction vector
1533 origin: Optional rotation origin point. If None, rotates about object center.
1534 If provided with string axis, raises ValueError.
1535 about_origin: If True, rotate about global origin (0,0,0). Cannot be used with origin parameter.
1536
1537 Raises:
1538 ValueError: If axis is invalid or if origin and about_origin are both specified
1539 """
1541
1542 # Validate parameter combinations
1543 if origin is not None and about_origin:
1544 raise ValueError("Cannot specify both origin and about_origin")
1546 # Validate axis parameter
1547 if isinstance(axis, str):
1548 if axis not in ('x', 'y', 'z'):
1549 raise ValueError("axis must be 'x', 'y', or 'z'")
1550 if origin is not None:
1551 raise ValueError("origin parameter cannot be used with string axis")
1552 if about_origin:
1553 raise ValueError("about_origin parameter cannot be used with string axis")
1554
1555 # Use string axis variant
1556 if isinstance(ObjID, int):
1557 context_wrapper.rotateObject_axisString(self.context, ObjID, angle, axis)
1558 elif isinstance(ObjID, list):
1559 context_wrapper.rotateObjects_axisString(self.context, ObjID, angle, axis)
1560 else:
1561 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1562
1563 elif isinstance(axis, vec3):
1564 axis_list = axis.to_list()
1565
1566 # Check for zero-length axis
1567 if all(abs(v) < 1e-10 for v in axis_list):
1568 raise ValueError("axis vector cannot be zero")
1569
1570 if about_origin:
1571 # Rotate about global origin
1572 if isinstance(ObjID, int):
1573 context_wrapper.rotateObjectAboutOrigin_axisVector(self.context, ObjID, angle, axis_list)
1574 elif isinstance(ObjID, list):
1575 context_wrapper.rotateObjectsAboutOrigin_axisVector(self.context, ObjID, angle, axis_list)
1576 else:
1577 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1578 elif origin is None:
1579 # Rotate about object center
1580 if isinstance(ObjID, int):
1581 context_wrapper.rotateObject_axisVector(self.context, ObjID, angle, axis_list)
1582 elif isinstance(ObjID, list):
1583 context_wrapper.rotateObjects_axisVector(self.context, ObjID, angle, axis_list)
1584 else:
1585 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1586 else:
1587 # Rotate about specified origin point
1588 if not isinstance(origin, vec3):
1589 raise ValueError(f"origin must be a vec3, got {type(origin).__name__}")
1590
1591 origin_list = origin.to_list()
1592 if isinstance(ObjID, int):
1593 context_wrapper.rotateObject_originAxisVector(self.context, ObjID, angle, origin_list, axis_list)
1594 elif isinstance(ObjID, list):
1595 context_wrapper.rotateObjects_originAxisVector(self.context, ObjID, angle, origin_list, axis_list)
1596 else:
1597 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1598 else:
1599 raise ValueError(f"axis must be str or vec3, got {type(axis).__name__}")
1600
1601 def scalePrimitive(self, UUID: Union[int, List[int]], scale: vec3, point: Optional[vec3] = None) -> None:
1602 """
1603 Scale one or more primitives.
1604
1605 Args:
1606 UUID: Single UUID or list of UUIDs to scale
1607 scale: Scale factors as vec3(x, y, z)
1608 point: Optional point to scale about. If None, scales about primitive center.
1609
1610 Raises:
1611 ValueError: If scale or point parameters are invalid
1612 """
1614
1615 if not isinstance(scale, vec3):
1616 raise ValueError(f"scale must be a vec3, got {type(scale).__name__}")
1617
1618 scale_list = scale.to_list()
1619
1620 if point is None:
1621 # Scale about primitive center
1622 if isinstance(UUID, int):
1623 context_wrapper.scalePrimitive(self.context, UUID, scale_list)
1624 elif isinstance(UUID, list):
1625 context_wrapper.scalePrimitives(self.context, UUID, scale_list)
1626 else:
1627 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
1628 else:
1629 # Scale about specified point
1630 if not isinstance(point, vec3):
1631 raise ValueError(f"point must be a vec3, got {type(point).__name__}")
1632
1633 point_list = point.to_list()
1634 if isinstance(UUID, int):
1635 context_wrapper.scalePrimitiveAboutPoint(self.context, UUID, scale_list, point_list)
1636 elif isinstance(UUID, list):
1637 context_wrapper.scalePrimitivesAboutPoint(self.context, UUID, scale_list, point_list)
1638 else:
1639 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
1640
1641 def scaleObject(self, ObjID: Union[int, List[int]], scale: vec3,
1642 point: Optional[vec3] = None, about_center: bool = False,
1643 about_origin: bool = False) -> None:
1644 """
1645 Scale one or more objects.
1646
1647 Args:
1648 ObjID: Single object ID or list of object IDs to scale
1649 scale: Scale factors as vec3(x, y, z)
1650 point: Optional point to scale about
1651 about_center: If True, scale about object center (default behavior)
1652 about_origin: If True, scale about global origin (0,0,0)
1653
1654 Raises:
1655 ValueError: If parameters are invalid or conflicting options specified
1656 """
1658
1659 # Validate parameter combinations
1660 options_count = sum([point is not None, about_center, about_origin])
1661 if options_count > 1:
1662 raise ValueError("Cannot specify multiple scaling options (point, about_center, about_origin)")
1663
1664 if not isinstance(scale, vec3):
1665 raise ValueError(f"scale must be a vec3, got {type(scale).__name__}")
1666
1667 scale_list = scale.to_list()
1668
1669 if about_origin:
1670 # Scale about global origin
1671 if isinstance(ObjID, int):
1672 context_wrapper.scaleObjectAboutOrigin(self.context, ObjID, scale_list)
1673 elif isinstance(ObjID, list):
1674 context_wrapper.scaleObjectsAboutOrigin(self.context, ObjID, scale_list)
1675 else:
1676 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1677 elif about_center:
1678 # Scale about object center
1679 if isinstance(ObjID, int):
1680 context_wrapper.scaleObjectAboutCenter(self.context, ObjID, scale_list)
1681 elif isinstance(ObjID, list):
1682 context_wrapper.scaleObjectsAboutCenter(self.context, ObjID, scale_list)
1683 else:
1684 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1685 elif point is not None:
1686 # Scale about specified point
1687 if not isinstance(point, vec3):
1688 raise ValueError(f"point must be a vec3, got {type(point).__name__}")
1689
1690 point_list = point.to_list()
1691 if isinstance(ObjID, int):
1692 context_wrapper.scaleObjectAboutPoint(self.context, ObjID, scale_list, point_list)
1693 elif isinstance(ObjID, list):
1694 context_wrapper.scaleObjectsAboutPoint(self.context, ObjID, scale_list, point_list)
1695 else:
1696 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1697 else:
1698 # Default: scale object (standard behavior)
1699 if isinstance(ObjID, int):
1700 context_wrapper.scaleObject(self.context, ObjID, scale_list)
1701 elif isinstance(ObjID, list):
1702 context_wrapper.scaleObjects(self.context, ObjID, scale_list)
1703 else:
1704 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1705
1706 def scaleConeObjectLength(self, ObjID: int, scale_factor: float) -> None:
1707 """
1708 Scale the length of a Cone object by scaling the distance between its two nodes.
1709
1710 Args:
1711 ObjID: Object ID of the Cone to scale
1712 scale_factor: Factor by which to scale the cone length (e.g., 2.0 doubles length)
1713
1714 Raises:
1715 ValueError: If ObjID is not an integer or scale_factor is invalid
1716 HeliosRuntimeError: If operation fails (e.g., ObjID is not a Cone object)
1717
1718 Note:
1719 Added in helios-core v1.3.59 as a replacement for the removed getConeObjectPointer()
1720 method, enforcing better encapsulation.
1721
1722 Example:
1723 >>> cone_id = context.addConeObject(10, [0,0,0], [0,0,1], 0.1, 0.05)
1724 >>> context.scaleConeObjectLength(cone_id, 1.5) # Make cone 50% longer
1725 """
1726 if not isinstance(ObjID, int):
1727 raise ValueError(f"ObjID must be an integer, got {type(ObjID).__name__}")
1728 if not isinstance(scale_factor, (int, float)):
1729 raise ValueError(f"scale_factor must be numeric, got {type(scale_factor).__name__}")
1730 if scale_factor <= 0:
1731 raise ValueError(f"scale_factor must be positive, got {scale_factor}")
1732
1733 context_wrapper.scaleConeObjectLength(self.context, ObjID, float(scale_factor))
1734
1735 def scaleConeObjectGirth(self, ObjID: int, scale_factor: float) -> None:
1736 """
1737 Scale the girth of a Cone object by scaling the radii at both nodes.
1738
1739 Args:
1740 ObjID: Object ID of the Cone to scale
1741 scale_factor: Factor by which to scale the cone girth (e.g., 2.0 doubles girth)
1742
1743 Raises:
1744 ValueError: If ObjID is not an integer or scale_factor is invalid
1745 HeliosRuntimeError: If operation fails (e.g., ObjID is not a Cone object)
1746
1747 Note:
1748 Added in helios-core v1.3.59 as a replacement for the removed getConeObjectPointer()
1749 method, enforcing better encapsulation.
1750
1751 Example:
1752 >>> cone_id = context.addConeObject(10, [0,0,0], [0,0,1], 0.1, 0.05)
1753 >>> context.scaleConeObjectGirth(cone_id, 2.0) # Double the cone girth
1754 """
1755 if not isinstance(ObjID, int):
1756 raise ValueError(f"ObjID must be an integer, got {type(ObjID).__name__}")
1757 if not isinstance(scale_factor, (int, float)):
1758 raise ValueError(f"scale_factor must be numeric, got {type(scale_factor).__name__}")
1759 if scale_factor <= 0:
1760 raise ValueError(f"scale_factor must be positive, got {scale_factor}")
1761
1762 context_wrapper.scaleConeObjectGirth(self.context, ObjID, float(scale_factor))
1763
1764 def loadPLY(self, filename: str, origin: Optional[vec3] = None, height: Optional[float] = None,
1765 rotation: Optional[SphericalCoord] = None, color: Optional[RGBcolor] = None,
1766 upaxis: str = "YUP", silent: bool = False) -> List[int]:
1767 """
1768 Load geometry from a PLY (Stanford Polygon) file.
1769
1770 Args:
1771 filename: Path to the PLY file to load
1772 origin: Origin point for positioning the geometry (optional)
1773 height: Height scaling factor (optional)
1774 rotation: Rotation to apply to the geometry (optional)
1775 color: Default color for geometry without color data (optional)
1776 upaxis: Up axis orientation ("YUP" or "ZUP")
1777 silent: If True, suppress loading output messages
1778
1779 Returns:
1780 List of UUIDs for the loaded primitives
1781 """
1783 # Validate file path for security
1784 validated_filename = self._validate_file_path(filename, ['.ply'])
1785
1786 if origin is None and height is None and rotation is None and color is None:
1787 # Simple load with no transformations
1788 return context_wrapper.loadPLY(self.context, validated_filename, silent)
1789
1790 elif origin is not None and height is not None and rotation is None and color is None:
1791 # Load with origin and height
1792 return context_wrapper.loadPLYWithOriginHeight(self.context, validated_filename, origin.to_list(), height, upaxis, silent)
1793
1794 elif origin is not None and height is not None and rotation is not None and color is None:
1795 # Load with origin, height, and rotation
1796 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
1797 return context_wrapper.loadPLYWithOriginHeightRotation(self.context, validated_filename, origin.to_list(), height, rotation_list, upaxis, silent)
1798
1799 elif origin is not None and height is not None and rotation is None and color is not None:
1800 # Load with origin, height, and color
1801 return context_wrapper.loadPLYWithOriginHeightColor(self.context, validated_filename, origin.to_list(), height, color.to_list(), upaxis, silent)
1802
1803 elif origin is not None and height is not None and rotation is not None and color is not None:
1804 # Load with all parameters
1805 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
1806 return context_wrapper.loadPLYWithOriginHeightRotationColor(self.context, validated_filename, origin.to_list(), height, rotation_list, color.to_list(), upaxis, silent)
1807
1808 else:
1809 raise ValueError("Invalid parameter combination. When using transformations, both origin and height are required.")
1810
1811 def loadOBJ(self, filename: str, origin: Optional[vec3] = None, height: Optional[float] = None,
1812 scale: Optional[vec3] = None, rotation: Optional[SphericalCoord] = None,
1813 color: Optional[RGBcolor] = None, upaxis: str = "YUP", silent: bool = False) -> List[int]:
1814 """
1815 Load geometry from an OBJ (Wavefront) file.
1816
1817 Args:
1818 filename: Path to the OBJ file to load
1819 origin: Origin point for positioning the geometry (optional)
1820 height: Height scaling factor (optional, alternative to scale)
1821 scale: Scale factor for all dimensions (optional, alternative to height)
1822 rotation: Rotation to apply to the geometry (optional)
1823 color: Default color for geometry without color data (optional)
1824 upaxis: Up axis orientation ("YUP" or "ZUP")
1825 silent: If True, suppress loading output messages
1826
1827 Returns:
1828 List of UUIDs for the loaded primitives
1829 """
1831 # Validate file path for security
1832 validated_filename = self._validate_file_path(filename, ['.obj'])
1833
1834 if origin is None and height is None and scale is None and rotation is None and color is None:
1835 # Simple load with no transformations
1836 return context_wrapper.loadOBJ(self.context, validated_filename, silent)
1837
1838 elif origin is not None and height is not None and scale is None and rotation is not None and color is not None:
1839 # Load with origin, height, rotation, and color (no upaxis)
1840 return context_wrapper.loadOBJWithOriginHeightRotationColor(self.context, validated_filename, origin.to_list(), height, rotation.to_list(), color.to_list(), silent)
1841
1842 elif origin is not None and height is not None and scale is None and rotation is not None and color is not None and upaxis != "YUP":
1843 # Load with origin, height, rotation, color, and upaxis
1844 return context_wrapper.loadOBJWithOriginHeightRotationColorUpaxis(self.context, validated_filename, origin.to_list(), height, rotation.to_list(), color.to_list(), upaxis, silent)
1845
1846 elif origin is not None and scale is not None and rotation is not None and color is not None:
1847 # Load with origin, scale, rotation, color, and upaxis
1848 return context_wrapper.loadOBJWithOriginScaleRotationColorUpaxis(self.context, validated_filename, origin.to_list(), scale.to_list(), rotation.to_list(), color.to_list(), upaxis, silent)
1849
1850 else:
1851 raise ValueError("Invalid parameter combination. For OBJ loading, you must provide either: " +
1852 "1) No parameters (simple load), " +
1853 "2) origin + height + rotation + color, " +
1854 "3) origin + height + rotation + color + upaxis, or " +
1855 "4) origin + scale + rotation + color + upaxis")
1856
1857 def loadXML(self, filename: str, quiet: bool = False) -> List[int]:
1858 """
1859 Load geometry from a Helios XML file.
1860
1861 Args:
1862 filename: Path to the XML file to load
1863 quiet: If True, suppress loading output messages
1864
1865 Returns:
1866 List of UUIDs for the loaded primitives
1867 """
1869 # Validate file path for security
1870 validated_filename = self._validate_file_path(filename, ['.xml'])
1871
1872 return context_wrapper.loadXML(self.context, validated_filename, quiet)
1873
1874 def writePLY(self, filename: str, UUIDs: Optional[List[int]] = None) -> None:
1875 """
1876 Write geometry to a PLY (Stanford Polygon) file.
1877
1878 Args:
1879 filename: Path to the output PLY file
1880 UUIDs: Optional list of primitive UUIDs to export. If None, exports all primitives
1881
1882 Raises:
1883 ValueError: If filename is invalid or UUIDs are invalid
1884 PermissionError: If output directory is not writable
1885 FileNotFoundError: If UUIDs do not exist in context
1886 RuntimeError: If Context is in mock mode
1887
1888 Example:
1889 >>> context.writePLY("output.ply") # Export all primitives
1890 >>> context.writePLY("subset.ply", [uuid1, uuid2]) # Export specific primitives
1891 """
1893
1894 # Validate output file path for security
1895 validated_filename = self._validate_output_file_path(filename, ['.ply'])
1896
1897 if UUIDs is None:
1898 # Export all primitives
1899 context_wrapper.writePLY(self.context, validated_filename)
1900 else:
1901 # Validate UUIDs exist in context
1902 if not UUIDs:
1903 raise ValueError("UUIDs list cannot be empty. Use UUIDs=None to export all primitives")
1904
1905 # Validate each UUID exists
1906 for uuid in UUIDs:
1907 self._validate_uuid(uuid)
1908
1909 # Export specified UUIDs
1910 context_wrapper.writePLYWithUUIDs(self.context, validated_filename, UUIDs)
1911
1912 def writeOBJ(self, filename: str, UUIDs: Optional[List[int]] = None,
1913 primitive_data_fields: Optional[List[str]] = None,
1914 write_normals: bool = False, silent: bool = False) -> None:
1915 """
1916 Write geometry to an OBJ (Wavefront) file.
1917
1918 Args:
1919 filename: Path to the output OBJ file
1920 UUIDs: Optional list of primitive UUIDs to export. If None, exports all primitives
1921 primitive_data_fields: Optional list of primitive data field names to export
1922 write_normals: Whether to include vertex normals in the output
1923 silent: Whether to suppress output messages during export
1924
1925 Raises:
1926 ValueError: If filename is invalid, UUIDs are invalid, or data fields don't exist
1927 PermissionError: If output directory is not writable
1928 FileNotFoundError: If UUIDs do not exist in context
1929 RuntimeError: If Context is in mock mode
1930
1931 Example:
1932 >>> context.writeOBJ("output.obj") # Export all primitives
1933 >>> context.writeOBJ("subset.obj", [uuid1, uuid2]) # Export specific primitives
1934 >>> context.writeOBJ("with_data.obj", [uuid1], ["temperature", "area"]) # Export with data
1935 """
1937
1938 # Validate output file path for security
1939 validated_filename = self._validate_output_file_path(filename, ['.obj'])
1940
1941 if UUIDs is None:
1942 # Export all primitives
1943 context_wrapper.writeOBJ(self.context, validated_filename, write_normals, silent)
1944 elif primitive_data_fields is None:
1945 # Export specified UUIDs without data fields
1946 if not UUIDs:
1947 raise ValueError("UUIDs list cannot be empty. Use UUIDs=None to export all primitives")
1948
1949 # Validate each UUID exists
1950 for uuid in UUIDs:
1951 self._validate_uuid(uuid)
1952
1953 context_wrapper.writeOBJWithUUIDs(self.context, validated_filename, UUIDs, write_normals, silent)
1954 else:
1955 # Export specified UUIDs with primitive data fields
1956 if not UUIDs:
1957 raise ValueError("UUIDs list cannot be empty when exporting primitive data")
1958 if not primitive_data_fields:
1959 raise ValueError("primitive_data_fields list cannot be empty")
1960
1961 # Validate each UUID exists
1962 for uuid in UUIDs:
1963 self._validate_uuid(uuid)
1964
1965 # Note: Primitive data field validation is handled by the native library
1966 # which will raise appropriate errors if fields don't exist for the specified primitives
1967
1968 context_wrapper.writeOBJWithPrimitiveData(self.context, validated_filename, UUIDs, primitive_data_fields, write_normals, silent)
1969
1970 def writePrimitiveData(self, filename: str, column_labels: List[str],
1971 UUIDs: Optional[List[int]] = None,
1972 print_header: bool = False) -> None:
1973 """
1974 Write primitive data to an ASCII text file.
1975
1976 Outputs a space-separated text file where each row corresponds to a primitive
1977 and each column corresponds to a primitive data label.
1978
1979 Args:
1980 filename: Path to the output file
1981 column_labels: List of primitive data labels to include as columns.
1982 Use "UUID" to include primitive UUIDs as a column.
1983 The order determines the column order in the output file.
1984 UUIDs: Optional list of primitive UUIDs to include. If None, includes all primitives.
1985 print_header: If True, writes column labels as the first line of the file
1986
1987 Raises:
1988 ValueError: If filename is invalid, column_labels is empty, or UUIDs list is empty when provided
1989 HeliosFileIOError: If file cannot be written
1990 HeliosRuntimeError: If a column label doesn't exist for any primitive
1991
1992 Example:
1993 >>> # Write temperature and area for all primitives
1994 >>> context.writePrimitiveData("output.txt", ["UUID", "temperature", "area"])
1995
1996 >>> # Write with header row
1997 >>> context.writePrimitiveData("output.txt", ["UUID", "radiation_flux"], print_header=True)
1998
1999 >>> # Write only for selected primitives
2000 >>> context.writePrimitiveData("subset.txt", ["temperature"], UUIDs=[uuid1, uuid2])
2001 """
2003
2004 # Validate column_labels
2005 if not column_labels:
2006 raise ValueError("column_labels list cannot be empty")
2008 # Validate output file path (allow any extension for text files)
2009 validated_filename = self._validate_output_file_path(filename)
2010
2011 if UUIDs is None:
2012 # Export all primitives
2013 context_wrapper.writePrimitiveData(self.context, validated_filename, column_labels, print_header)
2014 else:
2015 # Export specified UUIDs
2016 if not UUIDs:
2017 raise ValueError("UUIDs list cannot be empty when provided. Use UUIDs=None to include all primitives")
2018
2019 # Validate each UUID exists
2020 for uuid in UUIDs:
2021 self._validate_uuid(uuid)
2022
2023 context_wrapper.writePrimitiveDataWithUUIDs(self.context, validated_filename, column_labels, UUIDs, print_header)
2024
2025 def addTrianglesFromArrays(self, vertices: np.ndarray, faces: np.ndarray,
2026 colors: Optional[np.ndarray] = None) -> List[int]:
2027 """
2028 Add triangles from NumPy arrays (compatible with trimesh, Open3D format).
2029
2030 Args:
2031 vertices: NumPy array of shape (N, 3) containing vertex coordinates as float32/float64
2032 faces: NumPy array of shape (M, 3) containing triangle vertex indices as int32/int64
2033 colors: Optional NumPy array of shape (N, 3) or (M, 3) containing RGB colors as float32/float64
2034 If shape (N, 3): per-vertex colors
2035 If shape (M, 3): per-triangle colors
2036
2037 Returns:
2038 List of UUIDs for the added triangles
2039
2040 Raises:
2041 ValueError: If array dimensions are invalid
2042 """
2043 # Validate input arrays
2044 if vertices.ndim != 2 or vertices.shape[1] != 3:
2045 raise ValueError(f"Vertices array must have shape (N, 3), got {vertices.shape}")
2046 if faces.ndim != 2 or faces.shape[1] != 3:
2047 raise ValueError(f"Faces array must have shape (M, 3), got {faces.shape}")
2048
2049 # Check vertex indices are valid
2050 max_vertex_index = np.max(faces)
2051 if max_vertex_index >= vertices.shape[0]:
2052 raise ValueError(f"Face indices reference vertex {max_vertex_index}, but only {vertices.shape[0]} vertices provided")
2053
2054 # Validate colors array if provided
2055 per_vertex_colors = False
2056 per_triangle_colors = False
2057 if colors is not None:
2058 if colors.ndim != 2 or colors.shape[1] != 3:
2059 raise ValueError(f"Colors array must have shape (N, 3) or (M, 3), got {colors.shape}")
2060 if colors.shape[0] == vertices.shape[0]:
2061 per_vertex_colors = True
2062 elif colors.shape[0] == faces.shape[0]:
2063 per_triangle_colors = True
2064 else:
2065 raise ValueError(f"Colors array shape {colors.shape} doesn't match vertices ({vertices.shape[0]},) or faces ({faces.shape[0]},)")
2066
2067 # Convert arrays to appropriate data types
2068 vertices_float = vertices.astype(np.float32)
2069 faces_int = faces.astype(np.int32)
2070 if colors is not None:
2071 colors_float = colors.astype(np.float32)
2072
2073 # Add triangles
2074 triangle_uuids = []
2075 for i in range(faces.shape[0]):
2076 # Get vertex indices for this triangle
2077 v0_idx, v1_idx, v2_idx = faces_int[i]
2078
2079 # Get vertex coordinates
2080 vertex0 = vertices_float[v0_idx].tolist()
2081 vertex1 = vertices_float[v1_idx].tolist()
2082 vertex2 = vertices_float[v2_idx].tolist()
2083
2084 # Add triangle with or without color
2085 if colors is None:
2086 # No color specified
2087 uuid = context_wrapper.addTriangle(self.context, vertex0, vertex1, vertex2)
2088 elif per_triangle_colors:
2089 # Use per-triangle color
2090 color = colors_float[i].tolist()
2091 uuid = context_wrapper.addTriangleWithColor(self.context, vertex0, vertex1, vertex2, color)
2092 elif per_vertex_colors:
2093 # Average the per-vertex colors for the triangle
2094 color = np.mean([colors_float[v0_idx], colors_float[v1_idx], colors_float[v2_idx]], axis=0).tolist()
2095 uuid = context_wrapper.addTriangleWithColor(self.context, vertex0, vertex1, vertex2, color)
2096
2097 triangle_uuids.append(uuid)
2098
2099 return triangle_uuids
2100
2101 def addTrianglesFromArraysTextured(self, vertices: np.ndarray, faces: np.ndarray,
2102 uv_coords: np.ndarray, texture_files: Union[str, List[str]],
2103 material_ids: Optional[np.ndarray] = None) -> List[int]:
2104 """
2105 Add textured triangles from NumPy arrays with support for multiple textures.
2106
2107 This method supports both single-texture and multi-texture workflows:
2108 - Single texture: Pass a single texture file string, all faces use the same texture
2109 - Multiple textures: Pass a list of texture files and material_ids array specifying which texture each face uses
2110
2111 Args:
2112 vertices: NumPy array of shape (N, 3) containing vertex coordinates as float32/float64
2113 faces: NumPy array of shape (M, 3) containing triangle vertex indices as int32/int64
2114 uv_coords: NumPy array of shape (N, 2) containing UV texture coordinates as float32/float64
2115 texture_files: Single texture file path (str) or list of texture file paths (List[str])
2116 material_ids: Optional NumPy array of shape (M,) containing material ID for each face.
2117 If None and texture_files is a list, all faces use texture 0.
2118 If None and texture_files is a string, this parameter is ignored.
2119
2120 Returns:
2121 List of UUIDs for the added textured triangles
2122
2123 Raises:
2124 ValueError: If array dimensions are invalid or material IDs are out of range
2125
2126 Example:
2127 # Single texture usage (backward compatible)
2128 >>> uuids = context.addTrianglesFromArraysTextured(vertices, faces, uvs, "texture.png")
2129
2130 # Multi-texture usage (Open3D style)
2131 >>> texture_files = ["wood.png", "metal.png", "glass.png"]
2132 >>> material_ids = np.array([0, 0, 1, 1, 2, 2]) # 6 faces using different textures
2133 >>> uuids = context.addTrianglesFromArraysTextured(vertices, faces, uvs, texture_files, material_ids)
2134 """
2136
2137 # Validate input arrays
2138 if vertices.ndim != 2 or vertices.shape[1] != 3:
2139 raise ValueError(f"Vertices array must have shape (N, 3), got {vertices.shape}")
2140 if faces.ndim != 2 or faces.shape[1] != 3:
2141 raise ValueError(f"Faces array must have shape (M, 3), got {faces.shape}")
2142 if uv_coords.ndim != 2 or uv_coords.shape[1] != 2:
2143 raise ValueError(f"UV coordinates array must have shape (N, 2), got {uv_coords.shape}")
2144
2145 # Check array consistency
2146 if uv_coords.shape[0] != vertices.shape[0]:
2147 raise ValueError(f"UV coordinates count ({uv_coords.shape[0]}) must match vertices count ({vertices.shape[0]})")
2148
2149 # Check vertex indices are valid
2150 max_vertex_index = np.max(faces)
2151 if max_vertex_index >= vertices.shape[0]:
2152 raise ValueError(f"Face indices reference vertex {max_vertex_index}, but only {vertices.shape[0]} vertices provided")
2153
2154 # Handle texture files parameter (single string or list)
2155 if isinstance(texture_files, str):
2156 # Single texture case - use original implementation for efficiency
2157 texture_file_list = [texture_files]
2158 if material_ids is None:
2159 material_ids = np.zeros(faces.shape[0], dtype=np.uint32)
2160 else:
2161 # Validate that all material IDs are 0 for single texture
2162 if not np.all(material_ids == 0):
2163 raise ValueError("When using single texture file, all material IDs must be 0")
2164 else:
2165 # Multiple textures case
2166 texture_file_list = list(texture_files)
2167 if len(texture_file_list) == 0:
2168 raise ValueError("Texture files list cannot be empty")
2169
2170 if material_ids is None:
2171 # Default: all faces use first texture
2172 material_ids = np.zeros(faces.shape[0], dtype=np.uint32)
2173 else:
2174 # Validate material IDs array
2175 if material_ids.ndim != 1 or material_ids.shape[0] != faces.shape[0]:
2176 raise ValueError(f"Material IDs must have shape ({faces.shape[0]},), got {material_ids.shape}")
2177
2178 # Check material ID range
2179 max_material_id = np.max(material_ids)
2180 if max_material_id >= len(texture_file_list):
2181 raise ValueError(f"Material ID {max_material_id} exceeds texture count {len(texture_file_list)}")
2182
2183 # Validate all texture files exist
2184 for i, texture_file in enumerate(texture_file_list):
2185 try:
2186 self._validate_file_path(texture_file)
2187 except (FileNotFoundError, ValueError) as e:
2188 raise ValueError(f"Texture file {i} ({texture_file}): {e}")
2189
2190 # Use efficient multi-texture C++ implementation if available, otherwise triangle-by-triangle
2191 if 'addTrianglesFromArraysMultiTextured' in context_wrapper._AVAILABLE_TRIANGLE_FUNCTIONS:
2192 return context_wrapper.addTrianglesFromArraysMultiTextured(
2193 self.context, vertices, faces, uv_coords, texture_file_list, material_ids
2194 )
2195 else:
2196 # Use triangle-by-triangle approach with addTriangleTextured
2197 from .wrappers.DataTypes import vec3, vec2
2198
2199 vertices_float = vertices.astype(np.float32)
2200 faces_int = faces.astype(np.int32)
2201 uv_coords_float = uv_coords.astype(np.float32)
2202
2203 triangle_uuids = []
2204 for i in range(faces.shape[0]):
2205 # Get vertex indices for this triangle
2206 v0_idx, v1_idx, v2_idx = faces_int[i]
2207
2208 # Get vertex coordinates as vec3 objects
2209 vertex0 = vec3(vertices_float[v0_idx][0], vertices_float[v0_idx][1], vertices_float[v0_idx][2])
2210 vertex1 = vec3(vertices_float[v1_idx][0], vertices_float[v1_idx][1], vertices_float[v1_idx][2])
2211 vertex2 = vec3(vertices_float[v2_idx][0], vertices_float[v2_idx][1], vertices_float[v2_idx][2])
2212
2213 # Get UV coordinates as vec2 objects
2214 uv0 = vec2(uv_coords_float[v0_idx][0], uv_coords_float[v0_idx][1])
2215 uv1 = vec2(uv_coords_float[v1_idx][0], uv_coords_float[v1_idx][1])
2216 uv2 = vec2(uv_coords_float[v2_idx][0], uv_coords_float[v2_idx][1])
2217
2218 # Use texture file based on material ID for this triangle
2219 material_id = material_ids[i]
2220 texture_file = texture_file_list[material_id]
2221
2222 # Add textured triangle using the new addTriangleTextured method
2223 uuid = self.addTriangleTextured(vertex0, vertex1, vertex2, texture_file, uv0, uv1, uv2)
2224 triangle_uuids.append(uuid)
2225
2226 return triangle_uuids
2227
2228 # ==================== PRIMITIVE DATA METHODS ====================
2229 # Primitive data is a flexible key-value store where users can associate
2230 # arbitrary data with primitives using string keys
2231
2232 def setPrimitiveDataInt(self, uuids_or_uuid, label: str, value: int) -> None:
2233 """
2234 Set primitive data as signed 32-bit integer for one or multiple primitives.
2235
2236 Args:
2237 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2238 label: String key for the data
2239 value: Signed integer value (broadcast to all UUIDs if list provided)
2240 """
2241 if isinstance(uuids_or_uuid, (list, tuple)):
2242 context_wrapper.setBroadcastPrimitiveDataInt(self.context, uuids_or_uuid, label, value)
2243 else:
2244 context_wrapper.setPrimitiveDataInt(self.context, uuids_or_uuid, label, value)
2245
2246 def setPrimitiveDataUInt(self, uuids_or_uuid, label: str, value: int) -> None:
2247 """
2248 Set primitive data as unsigned 32-bit integer for one or multiple primitives.
2249
2250 Critical for properties like 'twosided_flag' which must be uint in C++.
2251
2252 Args:
2253 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2254 label: String key for the data
2255 value: Unsigned integer value (broadcast to all UUIDs if list provided)
2256 """
2257 if isinstance(uuids_or_uuid, (list, tuple)):
2258 context_wrapper.setBroadcastPrimitiveDataUInt(self.context, uuids_or_uuid, label, value)
2259 else:
2260 context_wrapper.setPrimitiveDataUInt(self.context, uuids_or_uuid, label, value)
2261
2262 def setPrimitiveDataFloat(self, uuids_or_uuid, label: str, value: float) -> None:
2263 """
2264 Set primitive data as 32-bit float for one or multiple primitives.
2265
2266 Args:
2267 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2268 label: String key for the data
2269 value: Float value (broadcast to all UUIDs if list provided)
2270 """
2271 if isinstance(uuids_or_uuid, (list, tuple)):
2272 context_wrapper.setBroadcastPrimitiveDataFloat(self.context, uuids_or_uuid, label, value)
2273 else:
2274 context_wrapper.setPrimitiveDataFloat(self.context, uuids_or_uuid, label, value)
2275
2276 def setPrimitiveDataDouble(self, uuids_or_uuid, label: str, value: float) -> None:
2277 """
2278 Set primitive data as 64-bit double for one or multiple primitives.
2279
2280 Args:
2281 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2282 label: String key for the data
2283 value: Double value (broadcast to all UUIDs if list provided)
2284 """
2285 if isinstance(uuids_or_uuid, (list, tuple)):
2286 context_wrapper.setBroadcastPrimitiveDataDouble(self.context, uuids_or_uuid, label, value)
2287 else:
2288 context_wrapper.setPrimitiveDataDouble(self.context, uuids_or_uuid, label, value)
2289
2290 def setPrimitiveDataString(self, uuids_or_uuid, label: str, value: str) -> None:
2291 """
2292 Set primitive data as string for one or multiple primitives.
2293
2294 Args:
2295 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2296 label: String key for the data
2297 value: String value (broadcast to all UUIDs if list provided)
2298 """
2299 if isinstance(uuids_or_uuid, (list, tuple)):
2300 context_wrapper.setBroadcastPrimitiveDataString(self.context, uuids_or_uuid, label, value)
2301 else:
2302 context_wrapper.setPrimitiveDataString(self.context, uuids_or_uuid, label, value)
2303
2304 def setPrimitiveDataVec2(self, uuids_or_uuid, label: str, x_or_vec, y: float = None) -> None:
2305 """
2306 Set primitive data as vec2 for one or multiple primitives.
2307
2308 Args:
2309 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2310 label: String key for the data
2311 x_or_vec: Either x component (float) or vec2 object
2312 y: Y component (if x_or_vec is float)
2313 """
2314 if hasattr(x_or_vec, 'x'):
2315 x, y = x_or_vec.x, x_or_vec.y
2316 else:
2317 x = x_or_vec
2318 if isinstance(uuids_or_uuid, (list, tuple)):
2319 context_wrapper.setBroadcastPrimitiveDataVec2(self.context, uuids_or_uuid, label, x, y)
2320 else:
2321 context_wrapper.setPrimitiveDataVec2(self.context, uuids_or_uuid, label, x, y)
2322
2323 def setPrimitiveDataVec3(self, uuids_or_uuid, label: str, x_or_vec, y: float = None, z: float = None) -> None:
2324 """
2325 Set primitive data as vec3 for one or multiple primitives.
2326
2327 Args:
2328 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2329 label: String key for the data
2330 x_or_vec: Either x component (float) or vec3 object
2331 y: Y component (if x_or_vec is float)
2332 z: Z component (if x_or_vec is float)
2333 """
2334 if hasattr(x_or_vec, 'x'):
2335 x, y, z = x_or_vec.x, x_or_vec.y, x_or_vec.z
2336 else:
2337 x = x_or_vec
2338 if isinstance(uuids_or_uuid, (list, tuple)):
2339 context_wrapper.setBroadcastPrimitiveDataVec3(self.context, uuids_or_uuid, label, x, y, z)
2340 else:
2341 context_wrapper.setPrimitiveDataVec3(self.context, uuids_or_uuid, label, x, y, z)
2342
2343 def setPrimitiveDataVec4(self, uuids_or_uuid, label: str, x_or_vec, y: float = None, z: float = None, w: float = None) -> None:
2344 """
2345 Set primitive data as vec4 for one or multiple primitives.
2346
2347 Args:
2348 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2349 label: String key for the data
2350 x_or_vec: Either x component (float) or vec4 object
2351 y: Y component (if x_or_vec is float)
2352 z: Z component (if x_or_vec is float)
2353 w: W component (if x_or_vec is float)
2354 """
2355 if hasattr(x_or_vec, 'x'):
2356 x, y, z, w = x_or_vec.x, x_or_vec.y, x_or_vec.z, x_or_vec.w
2357 else:
2358 x = x_or_vec
2359 if isinstance(uuids_or_uuid, (list, tuple)):
2360 context_wrapper.setBroadcastPrimitiveDataVec4(self.context, uuids_or_uuid, label, x, y, z, w)
2361 else:
2362 context_wrapper.setPrimitiveDataVec4(self.context, uuids_or_uuid, label, x, y, z, w)
2363
2364 def setPrimitiveDataInt2(self, uuids_or_uuid, label: str, x_or_vec, y: int = None) -> None:
2365 """
2366 Set primitive data as int2 for one or multiple primitives.
2367
2368 Args:
2369 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2370 label: String key for the data
2371 x_or_vec: Either x component (int) or int2 object
2372 y: Y component (if x_or_vec is int)
2373 """
2374 if hasattr(x_or_vec, 'x'):
2375 x, y = x_or_vec.x, x_or_vec.y
2376 else:
2377 x = x_or_vec
2378 if isinstance(uuids_or_uuid, (list, tuple)):
2379 context_wrapper.setBroadcastPrimitiveDataInt2(self.context, uuids_or_uuid, label, x, y)
2380 else:
2381 context_wrapper.setPrimitiveDataInt2(self.context, uuids_or_uuid, label, x, y)
2382
2383 def setPrimitiveDataInt3(self, uuids_or_uuid, label: str, x_or_vec, y: int = None, z: int = None) -> None:
2384 """
2385 Set primitive data as int3 for one or multiple primitives.
2386
2387 Args:
2388 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2389 label: String key for the data
2390 x_or_vec: Either x component (int) or int3 object
2391 y: Y component (if x_or_vec is int)
2392 z: Z component (if x_or_vec is int)
2393 """
2394 if hasattr(x_or_vec, 'x'):
2395 x, y, z = x_or_vec.x, x_or_vec.y, x_or_vec.z
2396 else:
2397 x = x_or_vec
2398 if isinstance(uuids_or_uuid, (list, tuple)):
2399 context_wrapper.setBroadcastPrimitiveDataInt3(self.context, uuids_or_uuid, label, x, y, z)
2400 else:
2401 context_wrapper.setPrimitiveDataInt3(self.context, uuids_or_uuid, label, x, y, z)
2402
2403 def setPrimitiveDataInt4(self, uuids_or_uuid, label: str, x_or_vec, y: int = None, z: int = None, w: int = None) -> None:
2404 """
2405 Set primitive data as int4 for one or multiple primitives.
2406
2407 Args:
2408 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2409 label: String key for the data
2410 x_or_vec: Either x component (int) or int4 object
2411 y: Y component (if x_or_vec is int)
2412 z: Z component (if x_or_vec is int)
2413 w: W component (if x_or_vec is int)
2414 """
2415 if hasattr(x_or_vec, 'x'):
2416 x, y, z, w = x_or_vec.x, x_or_vec.y, x_or_vec.z, x_or_vec.w
2417 else:
2418 x = x_or_vec
2419 if isinstance(uuids_or_uuid, (list, tuple)):
2420 context_wrapper.setBroadcastPrimitiveDataInt4(self.context, uuids_or_uuid, label, x, y, z, w)
2421 else:
2422 context_wrapper.setPrimitiveDataInt4(self.context, uuids_or_uuid, label, x, y, z, w)
2423
2424 def getPrimitiveData(self, uuid: int, label: str, data_type: type = None):
2425 """
2426 Get primitive data for a specific primitive. If data_type is provided, it works like before.
2427 If data_type is None, it automatically detects the type and returns the appropriate value.
2428
2429 Args:
2430 uuid: UUID of the primitive
2431 label: String key for the data
2432 data_type: Optional. Python type to retrieve (int, uint, float, double, bool, str, vec2, vec3, vec4, int2, int3, int4, etc.)
2433 If None, auto-detects the type using C++ getPrimitiveDataType().
2434
2435 Returns:
2436 The stored value of the specified or auto-detected type
2437 """
2438 # If no type specified, use auto-detection
2439 if data_type is None:
2440 return context_wrapper.getPrimitiveDataAuto(self.context, uuid, label)
2441
2442 # Handle basic types (original behavior when type is specified)
2443 if data_type == int:
2444 return context_wrapper.getPrimitiveDataInt(self.context, uuid, label)
2445 elif data_type == float:
2446 return context_wrapper.getPrimitiveDataFloat(self.context, uuid, label)
2447 elif data_type == bool:
2448 # Bool is not supported by Helios core - get as int and convert
2449 int_value = context_wrapper.getPrimitiveDataInt(self.context, uuid, label)
2450 return int_value != 0
2451 elif data_type == str:
2452 return context_wrapper.getPrimitiveDataString(self.context, uuid, label)
2453
2454 # Handle Helios vector types
2455 elif data_type == vec2:
2456 coords = context_wrapper.getPrimitiveDataVec2(self.context, uuid, label)
2457 return vec2(coords[0], coords[1])
2458 elif data_type == vec3:
2459 coords = context_wrapper.getPrimitiveDataVec3(self.context, uuid, label)
2460 return vec3(coords[0], coords[1], coords[2])
2461 elif data_type == vec4:
2462 coords = context_wrapper.getPrimitiveDataVec4(self.context, uuid, label)
2463 return vec4(coords[0], coords[1], coords[2], coords[3])
2464 elif data_type == int2:
2465 coords = context_wrapper.getPrimitiveDataInt2(self.context, uuid, label)
2466 return int2(coords[0], coords[1])
2467 elif data_type == int3:
2468 coords = context_wrapper.getPrimitiveDataInt3(self.context, uuid, label)
2469 return int3(coords[0], coords[1], coords[2])
2470 elif data_type == int4:
2471 coords = context_wrapper.getPrimitiveDataInt4(self.context, uuid, label)
2472 return int4(coords[0], coords[1], coords[2], coords[3])
2473
2474 # Handle extended numeric types (require explicit specification since Python doesn't have these as distinct types)
2475 elif data_type == "uint":
2476 return context_wrapper.getPrimitiveDataUInt(self.context, uuid, label)
2477 elif data_type == "double":
2478 return context_wrapper.getPrimitiveDataDouble(self.context, uuid, label)
2479
2480 # Handle list return types (for convenience)
2481 elif data_type == list:
2482 # Default to vec3 as list for backward compatibility
2483 return context_wrapper.getPrimitiveDataVec3(self.context, uuid, label)
2484 elif data_type == "list_vec2":
2485 return context_wrapper.getPrimitiveDataVec2(self.context, uuid, label)
2486 elif data_type == "list_vec4":
2487 return context_wrapper.getPrimitiveDataVec4(self.context, uuid, label)
2488 elif data_type == "list_int2":
2489 return context_wrapper.getPrimitiveDataInt2(self.context, uuid, label)
2490 elif data_type == "list_int3":
2491 return context_wrapper.getPrimitiveDataInt3(self.context, uuid, label)
2492 elif data_type == "list_int4":
2493 return context_wrapper.getPrimitiveDataInt4(self.context, uuid, label)
2494
2495 else:
2496 raise ValueError(f"Unsupported primitive data type: {data_type}. "
2497 f"Supported types: int, float, bool, str, vec2, vec3, vec4, int2, int3, int4, "
2498 f"'uint', 'double', list (for vec3), 'list_vec2', 'list_vec4', 'list_int2', 'list_int3', 'list_int4'")
2499
2500 def doesPrimitiveDataExist(self, uuid: int, label: str) -> bool:
2501 """
2502 Check if primitive data exists for a specific primitive and label.
2503
2504 Args:
2505 uuid: UUID of the primitive
2506 label: String key for the data
2507
2508 Returns:
2509 True if the data exists, False otherwise
2510 """
2511 return context_wrapper.doesPrimitiveDataExistWrapper(self.context, uuid, label)
2512
2513 def getPrimitiveDataFloat(self, uuid: int, label: str) -> float:
2514 """
2515 Convenience method to get float primitive data.
2516
2517 Args:
2518 uuid: UUID of the primitive
2519 label: String key for the data
2520
2521 Returns:
2522 Float value stored for the primitive
2523 """
2524 return self.getPrimitiveData(uuid, label, float)
2525
2526 def getPrimitiveDataType(self, uuid: int, label: str) -> int:
2527 """
2528 Get the Helios data type of primitive data.
2529
2530 Args:
2531 uuid: UUID of the primitive
2532 label: String key for the data
2533
2534 Returns:
2535 HeliosDataType enum value as integer
2536 """
2537 return context_wrapper.getPrimitiveDataTypeWrapper(self.context, uuid, label)
2538
2539 def getPrimitiveDataSize(self, uuid: int, label: str) -> int:
2540 """
2541 Get the size/length of primitive data (for vector data).
2542
2543 Args:
2544 uuid: UUID of the primitive
2545 label: String key for the data
2546
2547 Returns:
2548 Size of data array, or 1 for scalar data
2549 """
2550 return context_wrapper.getPrimitiveDataSizeWrapper(self.context, uuid, label)
2551
2552 def getPrimitiveDataArray(self, uuids: List[int], label: str) -> np.ndarray:
2553 """
2554 Get primitive data values for multiple primitives as a NumPy array.
2555
2556 This method retrieves primitive data for a list of UUIDs and returns the values
2557 as a NumPy array. The output array has the same length as the input UUID list,
2558 with each index corresponding to the primitive data value for that UUID.
2559
2560 Args:
2561 uuids: List of primitive UUIDs to get data for
2562 label: String key for the primitive data to retrieve
2563
2564 Returns:
2565 NumPy array of primitive data values corresponding to each UUID.
2566 The array type depends on the data type:
2567 - int data: int32 array
2568 - uint data: uint32 array
2569 - float data: float32 array
2570 - double data: float64 array
2571 - vector data: float32 array with shape (N, vector_size)
2572 - string data: object array of strings
2573
2574 Raises:
2575 ValueError: If UUID list is empty or UUIDs don't exist
2576 RuntimeError: If context is in mock mode or data doesn't exist for some UUIDs
2577 """
2579
2580 if not uuids:
2581 raise ValueError("UUID list cannot be empty")
2582
2583 # First validate that all UUIDs exist
2584 for uuid in uuids:
2586
2587 # Then check that all UUIDs have the specified data
2588 for uuid in uuids:
2589 if not self.doesPrimitiveDataExist(uuid, label):
2590 raise ValueError(f"Primitive data '{label}' does not exist for UUID {uuid}")
2591
2592 # Get data type from the first UUID to determine array type
2593 first_uuid = uuids[0]
2594 data_type = self.getPrimitiveDataType(first_uuid, label)
2595
2596 # Map Helios data types to NumPy array creation
2597 # Based on HeliosDataType enum from Helios core
2598 if data_type == 0: # HELIOS_TYPE_INT
2599 result = np.empty(len(uuids), dtype=np.int32)
2600 for i, uuid in enumerate(uuids):
2601 result[i] = self.getPrimitiveData(uuid, label, int)
2602
2603 elif data_type == 1: # HELIOS_TYPE_UINT
2604 result = np.empty(len(uuids), dtype=np.uint32)
2605 for i, uuid in enumerate(uuids):
2606 result[i] = self.getPrimitiveData(uuid, label, "uint")
2607
2608 elif data_type == 2: # HELIOS_TYPE_FLOAT
2609 result = np.empty(len(uuids), dtype=np.float32)
2610 for i, uuid in enumerate(uuids):
2611 result[i] = self.getPrimitiveData(uuid, label, float)
2612
2613 elif data_type == 3: # HELIOS_TYPE_DOUBLE
2614 result = np.empty(len(uuids), dtype=np.float64)
2615 for i, uuid in enumerate(uuids):
2616 result[i] = self.getPrimitiveData(uuid, label, "double")
2617
2618 elif data_type == 4: # HELIOS_TYPE_VEC2
2619 result = np.empty((len(uuids), 2), dtype=np.float32)
2620 for i, uuid in enumerate(uuids):
2621 vec_data = self.getPrimitiveData(uuid, label, vec2)
2622 result[i] = [vec_data.x, vec_data.y]
2623
2624 elif data_type == 5: # HELIOS_TYPE_VEC3
2625 result = np.empty((len(uuids), 3), dtype=np.float32)
2626 for i, uuid in enumerate(uuids):
2627 vec_data = self.getPrimitiveData(uuid, label, vec3)
2628 result[i] = [vec_data.x, vec_data.y, vec_data.z]
2629
2630 elif data_type == 6: # HELIOS_TYPE_VEC4
2631 result = np.empty((len(uuids), 4), dtype=np.float32)
2632 for i, uuid in enumerate(uuids):
2633 vec_data = self.getPrimitiveData(uuid, label, vec4)
2634 result[i] = [vec_data.x, vec_data.y, vec_data.z, vec_data.w]
2635
2636 elif data_type == 7: # HELIOS_TYPE_INT2
2637 result = np.empty((len(uuids), 2), dtype=np.int32)
2638 for i, uuid in enumerate(uuids):
2639 int_data = self.getPrimitiveData(uuid, label, int2)
2640 result[i] = [int_data.x, int_data.y]
2641
2642 elif data_type == 8: # HELIOS_TYPE_INT3
2643 result = np.empty((len(uuids), 3), dtype=np.int32)
2644 for i, uuid in enumerate(uuids):
2645 int_data = self.getPrimitiveData(uuid, label, int3)
2646 result[i] = [int_data.x, int_data.y, int_data.z]
2647
2648 elif data_type == 9: # HELIOS_TYPE_INT4
2649 result = np.empty((len(uuids), 4), dtype=np.int32)
2650 for i, uuid in enumerate(uuids):
2651 int_data = self.getPrimitiveData(uuid, label, int4)
2652 result[i] = [int_data.x, int_data.y, int_data.z, int_data.w]
2653
2654 elif data_type == 10: # HELIOS_TYPE_STRING
2655 result = np.empty(len(uuids), dtype=object)
2656 for i, uuid in enumerate(uuids):
2657 result[i] = self.getPrimitiveData(uuid, label, str)
2658
2659 else:
2660 raise ValueError(f"Unsupported primitive data type: {data_type}")
2661
2662 return result
2663
2664
2665 def colorPrimitiveByDataPseudocolor(self, uuids: List[int], primitive_data: str,
2666 colormap: str = "hot", ncolors: int = 10,
2667 max_val: Optional[float] = None, min_val: Optional[float] = None):
2668 """
2669 Color primitives based on primitive data values using pseudocolor mapping.
2670
2671 This method applies a pseudocolor mapping to primitives based on the values
2672 of specified primitive data. The primitive colors are updated to reflect the
2673 data values using a color map.
2674
2675 Args:
2676 uuids: List of primitive UUIDs to color
2677 primitive_data: Name of primitive data to use for coloring (e.g., "radiation_flux_SW")
2678 colormap: Color map name - options include "hot", "cool", "parula", "rainbow", "gray", "lava"
2679 ncolors: Number of discrete colors in color map (default: 10)
2680 max_val: Maximum value for color scale (auto-determined if None)
2681 min_val: Minimum value for color scale (auto-determined if None)
2682 """
2683 if max_val is not None and min_val is not None:
2684 context_wrapper.colorPrimitiveByDataPseudocolorWithRange(
2685 self.context, uuids, primitive_data, colormap, ncolors, max_val, min_val)
2686 else:
2687 context_wrapper.colorPrimitiveByDataPseudocolor(
2688 self.context, uuids, primitive_data, colormap, ncolors)
2689
2690 # Context time/date methods for solar position integration
2691 def setTime(self, hour: int, minute: int = 0, second: int = 0):
2692 """
2693 Set the simulation time.
2694
2695 Args:
2696 hour: Hour (0-23)
2697 minute: Minute (0-59), defaults to 0
2698 second: Second (0-59), defaults to 0
2699
2700 Raises:
2701 ValueError: If time values are out of range
2702 NotImplementedError: If time/date functions not available in current library build
2703
2704 Example:
2705 >>> context.setTime(14, 30) # Set to 2:30 PM
2706 >>> context.setTime(9, 15, 30) # Set to 9:15:30 AM
2707 """
2708 context_wrapper.setTime(self.context, hour, minute, second)
2709
2710 def setDate(self, year: int, month: int, day: int):
2711 """
2712 Set the simulation date.
2713
2714 Args:
2715 year: Year (1900-3000)
2716 month: Month (1-12)
2717 day: Day (1-31)
2718
2719 Raises:
2720 ValueError: If date values are out of range
2721 NotImplementedError: If time/date functions not available in current library build
2722
2723 Example:
2724 >>> context.setDate(2023, 6, 21) # Set to June 21, 2023
2725 """
2726 context_wrapper.setDate(self.context, year, month, day)
2727
2728 def setDateJulian(self, julian_day: int, year: int):
2729 """
2730 Set the simulation date using Julian day number.
2731
2732 Args:
2733 julian_day: Julian day (1-366)
2734 year: Year (1900-3000)
2735
2736 Raises:
2737 ValueError: If values are out of range
2738 NotImplementedError: If time/date functions not available in current library build
2739
2740 Example:
2741 >>> context.setDateJulian(172, 2023) # Set to day 172 of 2023 (June 21)
2742 """
2743 context_wrapper.setDateJulian(self.context, julian_day, year)
2744
2745 def getTime(self):
2746 """
2747 Get the current simulation time.
2748
2749 Returns:
2750 Tuple of (hour, minute, second) as integers
2751
2752 Raises:
2753 NotImplementedError: If time/date functions not available in current library build
2754
2755 Example:
2756 >>> hour, minute, second = context.getTime()
2757 >>> print(f"Current time: {hour:02d}:{minute:02d}:{second:02d}")
2758 """
2759 return context_wrapper.getTime(self.context)
2760
2761 def getDate(self):
2762 """
2763 Get the current simulation date.
2764
2765 Returns:
2766 Tuple of (year, month, day) as integers
2767
2768 Raises:
2769 NotImplementedError: If time/date functions not available in current library build
2770
2771 Example:
2772 >>> year, month, day = context.getDate()
2773 >>> print(f"Current date: {year}-{month:02d}-{day:02d}")
2774 """
2775 return context_wrapper.getDate(self.context)
2776
2777 # ==========================================================================
2778 # Primitive and Object Deletion Methods
2779 # ==========================================================================
2780
2781 def deletePrimitive(self, uuids_or_uuid: Union[int, List[int]]) -> None:
2782 """
2783 Delete one or more primitives from the context.
2784
2785 This removes the primitive(s) entirely from the context. If a primitive
2786 belongs to a compound object, it will be removed from that object. If the
2787 object becomes empty after removal, it is automatically deleted.
2788
2789 Args:
2790 uuids_or_uuid: Single UUID (int) or list of UUIDs to delete
2791
2792 Raises:
2793 RuntimeError: If any UUID doesn't exist in the context
2794 ValueError: If UUID is invalid (negative)
2795 NotImplementedError: If delete functions not available in current library build
2796
2797 Example:
2798 >>> context = Context()
2799 >>> patch_id = context.addPatch(center=vec3(0, 0, 0), size=vec2(1, 1))
2800 >>> context.deletePrimitive(patch_id) # Single deletion
2801 >>>
2802 >>> # Multiple deletion
2803 >>> ids = [context.addPatch() for _ in range(5)]
2804 >>> context.deletePrimitive(ids) # Delete all at once
2805 """
2807
2808 if isinstance(uuids_or_uuid, (list, tuple)):
2809 for uuid in uuids_or_uuid:
2810 if uuid < 0:
2811 raise ValueError(f"UUID must be non-negative, got {uuid}")
2812 context_wrapper.deletePrimitives(self.context, list(uuids_or_uuid))
2813 else:
2814 if uuids_or_uuid < 0:
2815 raise ValueError(f"UUID must be non-negative, got {uuids_or_uuid}")
2816 context_wrapper.deletePrimitive(self.context, uuids_or_uuid)
2817
2818 def deleteObject(self, objIDs_or_objID: Union[int, List[int]]) -> None:
2819 """
2820 Delete one or more compound objects from the context.
2821
2822 This removes the compound object(s) AND all their child primitives.
2823 Use this when you want to delete an entire object hierarchy at once.
2824
2825 Args:
2826 objIDs_or_objID: Single object ID (int) or list of object IDs to delete
2827
2828 Raises:
2829 RuntimeError: If any object ID doesn't exist in the context
2830 ValueError: If object ID is invalid (negative)
2831 NotImplementedError: If delete functions not available in current library build
2832
2833 Example:
2834 >>> context = Context()
2835 >>> # Create a compound object (e.g., a tile with multiple patches)
2836 >>> patch_ids = context.addTile(center=vec3(0, 0, 0), size=vec2(2, 2),
2837 ... tile_divisions=int2(2, 2))
2838 >>> obj_id = context.getPrimitiveParentObjectID(patch_ids[0])
2839 >>> context.deleteObject(obj_id) # Deletes tile and all its patches
2840 """
2842
2843 if isinstance(objIDs_or_objID, (list, tuple)):
2844 for objID in objIDs_or_objID:
2845 if objID < 0:
2846 raise ValueError(f"Object ID must be non-negative, got {objID}")
2847 context_wrapper.deleteObjects(self.context, list(objIDs_or_objID))
2848 else:
2849 if objIDs_or_objID < 0:
2850 raise ValueError(f"Object ID must be non-negative, got {objIDs_or_objID}")
2851 context_wrapper.deleteObject(self.context, objIDs_or_objID)
2852
2853 # Plugin-related methods
2854 def get_available_plugins(self) -> List[str]:
2855 """
2856 Get list of available plugins for this PyHelios instance.
2857
2858 Returns:
2859 List of available plugin names
2860 """
2862
2863 def is_plugin_available(self, plugin_name: str) -> bool:
2864 """
2865 Check if a specific plugin is available.
2866
2867 Args:
2868 plugin_name: Name of the plugin to check
2869
2870 Returns:
2871 True if plugin is available, False otherwise
2872 """
2873 return self._plugin_registry.is_plugin_available(plugin_name)
2874
2875 def get_plugin_capabilities(self) -> dict:
2876 """
2877 Get detailed information about available plugin capabilities.
2878
2879 Returns:
2880 Dictionary mapping plugin names to capability information
2881 """
2883
2884 def print_plugin_status(self):
2885 """Print detailed plugin status information."""
2886 self._plugin_registry.print_status()
2887
2888 def get_missing_plugins(self, requested_plugins: List[str]) -> List[str]:
2889 """
2890 Get list of requested plugins that are not available.
2891
2892 Args:
2893 requested_plugins: List of plugin names to check
2894
2895 Returns:
2896 List of missing plugin names
2897 """
2898 return self._plugin_registry.get_missing_plugins(requested_plugins)
2899
2900 # =========================================================================
2901 # Materials System (v1.3.58+)
2902 # =========================================================================
2903
2904 def addMaterial(self, material_label: str):
2905 """
2906 Create a new material for sharing visual properties across primitives.
2907
2908 Materials enable efficient memory usage by allowing multiple primitives to
2909 share rendering properties. Changes to a material affect all primitives using it.
2910
2911 Args:
2912 material_label: Unique label for the material
2913
2914 Raises:
2915 RuntimeError: If material label already exists
2916
2917 Example:
2918 >>> context.addMaterial("wood_oak")
2919 >>> context.setMaterialColor("wood_oak", (0.6, 0.4, 0.2, 1.0))
2920 >>> context.assignMaterialToPrimitive(uuid, "wood_oak")
2921 """
2922 context_wrapper.addMaterial(self.context, material_label)
2923
2924 def doesMaterialExist(self, material_label: str) -> bool:
2925 """Check if a material with the given label exists."""
2926 return context_wrapper.doesMaterialExist(self.context, material_label)
2927
2928 def listMaterials(self) -> List[str]:
2929 """Get list of all material labels in the context."""
2930 return context_wrapper.listMaterials(self.context)
2931
2932 def deleteMaterial(self, material_label: str):
2933 """
2934 Delete a material from the context.
2935
2936 Primitives using this material will be reassigned to the default material.
2938 Args:
2939 material_label: Label of the material to delete
2940
2941 Raises:
2942 RuntimeError: If material doesn't exist
2943 """
2944 context_wrapper.deleteMaterial(self.context, material_label)
2945
2946 def getMaterialColor(self, material_label: str):
2947 """
2948 Get the RGBA color of a material.
2949
2950 Args:
2951 material_label: Label of the material
2952
2953 Returns:
2954 RGBAcolor object
2955
2956 Raises:
2957 RuntimeError: If material doesn't exist
2958 """
2959 from .wrappers.DataTypes import RGBAcolor
2960 color_list = context_wrapper.getMaterialColor(self.context, material_label)
2961 return RGBAcolor(color_list[0], color_list[1], color_list[2], color_list[3])
2962
2963 def setMaterialColor(self, material_label: str, color):
2964 """
2965 Set the RGBA color of a material.
2967 This affects all primitives that reference this material.
2968
2969 Args:
2970 material_label: Label of the material
2971 color: RGBAcolor object or tuple/list of (r, g, b, a) values
2972
2973 Raises:
2974 RuntimeError: If material doesn't exist
2975
2976 Example:
2977 >>> from pyhelios.types import RGBAcolor
2978 >>> context.setMaterialColor("wood", RGBAcolor(0.6, 0.4, 0.2, 1.0))
2979 >>> context.setMaterialColor("wood", (0.6, 0.4, 0.2, 1.0))
2980 """
2981 if hasattr(color, 'r'):
2982 r, g, b, a = color.r, color.g, color.b, color.a
2983 else:
2984 r, g, b, a = color[0], color[1], color[2], color[3]
2985 context_wrapper.setMaterialColor(self.context, material_label, r, g, b, a)
2986
2987 def getMaterialTexture(self, material_label: str) -> str:
2988 """
2989 Get the texture file path for a material.
2990
2991 Args:
2992 material_label: Label of the material
2993
2994 Returns:
2995 Texture file path, or empty string if no texture
2996
2997 Raises:
2998 RuntimeError: If material doesn't exist
2999 """
3000 return context_wrapper.getMaterialTexture(self.context, material_label)
3001
3002 def setMaterialTexture(self, material_label: str, texture_file: str):
3003 """
3004 Set the texture file for a material.
3005
3006 This affects all primitives that reference this material.
3008 Args:
3009 material_label: Label of the material
3010 texture_file: Path to texture image file
3011
3012 Raises:
3013 RuntimeError: If material doesn't exist or texture file not found
3014 """
3015 context_wrapper.setMaterialTexture(self.context, material_label, texture_file)
3016
3017 def isMaterialTextureColorOverridden(self, material_label: str) -> bool:
3018 """Check if material texture color is overridden by material color."""
3019 return context_wrapper.isMaterialTextureColorOverridden(self.context, material_label)
3020
3021 def setMaterialTextureColorOverride(self, material_label: str, override: bool):
3022 """Set whether material color overrides texture color."""
3023 context_wrapper.setMaterialTextureColorOverride(self.context, material_label, override)
3024
3025 def getMaterialTwosidedFlag(self, material_label: str) -> int:
3026 """Get the two-sided rendering flag for a material (0 = one-sided, 1 = two-sided)."""
3027 return context_wrapper.getMaterialTwosidedFlag(self.context, material_label)
3028
3029 def setMaterialTwosidedFlag(self, material_label: str, twosided_flag: int):
3030 """Set the two-sided rendering flag for a material (0 = one-sided, 1 = two-sided)."""
3031 context_wrapper.setMaterialTwosidedFlag(self.context, material_label, twosided_flag)
3032
3033 def assignMaterialToPrimitive(self, uuid, material_label: str):
3034 """
3035 Assign a material to primitive(s).
3036
3037 Args:
3038 uuid: Single UUID (int) or list of UUIDs (List[int])
3039 material_label: Label of the material to assign
3040
3041 Raises:
3042 RuntimeError: If primitive or material doesn't exist
3043
3044 Example:
3045 >>> context.assignMaterialToPrimitive(uuid, "wood_oak")
3046 >>> context.assignMaterialToPrimitive([uuid1, uuid2, uuid3], "wood_oak")
3047 """
3048 if isinstance(uuid, (list, tuple)):
3049 context_wrapper.assignMaterialToPrimitives(self.context, uuid, material_label)
3050 else:
3051 context_wrapper.assignMaterialToPrimitive(self.context, uuid, material_label)
3052
3053 def assignMaterialToObject(self, objID, material_label: str):
3054 """
3055 Assign a material to all primitives in compound object(s).
3056
3057 Args:
3058 objID: Single object ID (int) or list of object IDs (List[int])
3059 material_label: Label of the material to assign
3060
3061 Raises:
3062 RuntimeError: If object or material doesn't exist
3063
3064 Example:
3065 >>> tree_id = wpt.buildTree(WPTType.LEMON)
3066 >>> context.assignMaterialToObject(tree_id, "tree_bark")
3067 >>> context.assignMaterialToObject([id1, id2], "grass")
3068 """
3069 if isinstance(objID, (list, tuple)):
3070 context_wrapper.assignMaterialToObjects(self.context, objID, material_label)
3071 else:
3072 context_wrapper.assignMaterialToObject(self.context, objID, material_label)
3073
3074 def getPrimitiveMaterialLabel(self, uuid: int) -> str:
3075 """
3076 Get the material label assigned to a primitive.
3077
3078 Args:
3079 uuid: UUID of the primitive
3080
3081 Returns:
3082 Material label, or empty string if no material assigned
3083
3084 Raises:
3085 RuntimeError: If primitive doesn't exist
3086 """
3087 return context_wrapper.getPrimitiveMaterialLabel(self.context, uuid)
3088
3089 def getPrimitiveTwosidedFlag(self, uuid: int, default_value: int = 1) -> int:
3090 """
3091 Get two-sided rendering flag for a primitive.
3092
3093 Checks material first, then primitive data if no material assigned.
3095 Args:
3096 uuid: UUID of the primitive
3097 default_value: Default value if no material/data (default 1 = two-sided)
3098
3099 Returns:
3100 Two-sided flag (0 = one-sided, 1 = two-sided)
3101 """
3102 return context_wrapper.getPrimitiveTwosidedFlag(self.context, uuid, default_value)
3103
3104 def getPrimitivesUsingMaterial(self, material_label: str) -> List[int]:
3105 """
3106 Get all primitive UUIDs that use a specific material.
3107
3108 Args:
3109 material_label: Label of the material
3110
3111 Returns:
3112 List of primitive UUIDs using the material
3113
3114 Raises:
3115 RuntimeError: If material doesn't exist
3116 """
3117 return context_wrapper.getPrimitivesUsingMaterial(self.context, material_label)
3118
Central simulation environment for PyHelios that manages 3D primitives and their data.
Definition Context.py:75
int addTileObject(self, vec3 center=vec3(0, 0, 0), vec2 size=vec2(1, 1), SphericalCoord rotation=SphericalCoord(1, 0, 0), int2 subdiv=int2(1, 1), Optional[RGBcolor] color=None, Optional[str] texturefile=None, Optional[int2] texture_repeat=None)
Add a tiled patch (subdivided patch) as a compound object to the context.
Definition Context.py:1108
None scaleConeObjectGirth(self, int ObjID, float scale_factor)
Scale the girth of a Cone object by scaling the radii at both nodes.
Definition Context.py:1762
str getMaterialTexture(self, str material_label)
Get the texture file path for a material.
Definition Context.py:3007
getMaterialColor(self, str material_label)
Get the RGBA color of a material.
Definition Context.py:2966
Union[int, List[int]] copyObject(self, Union[int, List[int]] ObjID)
Copy one or more compound objects.
Definition Context.py:1366
int getMaterialTwosidedFlag(self, str material_label)
Get the two-sided rendering flag for a material (0 = one-sided, 1 = two-sided).
Definition Context.py:3034
None deletePrimitive(self, Union[int, List[int]] uuids_or_uuid)
Delete one or more primitives from the context.
Definition Context.py:2813
_validate_uuid(self, int uuid)
Validate that a UUID exists in this context.
Definition Context.py:174
List[int] getAllUUIDs(self)
Definition Context.py:445
List[int] addBox(self, vec3 center=vec3(0, 0, 0), vec3 size=vec3(1, 1, 1), int3 subdiv=int3(1, 1, 1), Optional[RGBcolor] color=None)
Add a rectangular box to the context.
Definition Context.py:747
str _validate_output_file_path(self, str filename, List[str] expected_extensions=None)
Validate and normalize output file path for security.
Definition Context.py:252
int getPrimitiveTwosidedFlag(self, int uuid, int default_value=1)
Get two-sided rendering flag for a primitive.
Definition Context.py:3109
np.ndarray getPrimitiveDataArray(self, List[int] uuids, str label)
Get primitive data values for multiple primitives as a NumPy array.
Definition Context.py:2585
bool is_plugin_available(self, str plugin_name)
Check if a specific plugin is available.
Definition Context.py:2880
List[int] getAllObjectIDs(self)
Definition Context.py:455
List[str] get_available_plugins(self)
Get list of available plugins for this PyHelios instance.
Definition Context.py:2868
__exit__(self, exc_type, exc_value, traceback)
Definition Context.py:285
None setPrimitiveDataInt4(self, uuids_or_uuid, str label, x_or_vec, int y=None, int z=None, int w=None)
Set primitive data as int4 for one or multiple primitives.
Definition Context.py:2422
RGBcolor getPrimitiveColor(self, int uuid)
Definition Context.py:436
int addSphereObject(self, vec3 center=vec3(0, 0, 0), Union[float, vec3] radius=1.0, int ndivs=20, Optional[RGBcolor] color=None, Optional[str] texturefile=None)
Add a spherical or ellipsoidal compound object to the context.
Definition Context.py:1024
bool doesPrimitiveDataExist(self, int uuid, str label)
Check if primitive data exists for a specific primitive and label.
Definition Context.py:2518
List[str] listMaterials(self)
Get list of all material labels in the context.
Definition Context.py:2937
float getPrimitiveArea(self, int uuid)
Definition Context.py:417
None setPrimitiveDataInt(self, uuids_or_uuid, str label, int value)
Set primitive data as signed 32-bit integer for one or multiple primitives.
Definition Context.py:2248
None setPrimitiveDataDouble(self, uuids_or_uuid, str label, float value)
Set primitive data as 64-bit double for one or multiple primitives.
Definition Context.py:2292
None setPrimitiveDataVec2(self, uuids_or_uuid, str label, x_or_vec, float y=None)
Set primitive data as vec2 for one or multiple primitives.
Definition Context.py:2321
int getPrimitiveCount(self)
Definition Context.py:441
int addConeObject(self, vec3 node0, vec3 node1, float radius0, float radius1, int ndivs=20, Optional[RGBcolor] color=None, Optional[str] texturefile=None)
Add a cone/cylinder/frustum as a compound object to the context.
Definition Context.py:1188
List[PrimitiveInfo] getAllPrimitiveInfo(self)
Get physical properties and geometry information for all primitives in the context.
Definition Context.py:494
None copyObjectData(self, int source_objID, int destination_objID)
Copy all object data from source to destination compound object.
Definition Context.py:1394
None setPrimitiveDataInt3(self, uuids_or_uuid, str label, x_or_vec, int y=None, int z=None)
Set primitive data as int3 for one or multiple primitives.
Definition Context.py:2401
None writePLY(self, str filename, Optional[List[int]] UUIDs=None)
Write geometry to a PLY (Stanford Polygon) file.
Definition Context.py:1899
List[PrimitiveInfo] getPrimitivesInfoForObject(self, int object_id)
Get physical properties and geometry information for all primitives belonging to a specific object.
Definition Context.py:507
None setPrimitiveDataInt2(self, uuids_or_uuid, str label, x_or_vec, int y=None)
Set primitive data as int2 for one or multiple primitives.
Definition Context.py:2381
print_plugin_status(self)
Print detailed plugin status information.
Definition Context.py:2893
setDate(self, int year, int month, int day)
Set the simulation date.
Definition Context.py:2733
PrimitiveType getPrimitiveType(self, int uuid)
Definition Context.py:412
getPrimitiveData(self, int uuid, str label, type data_type=None)
Get primitive data for a specific primitive.
Definition Context.py:2445
List[str] get_missing_plugins(self, List[str] requested_plugins)
Get list of requested plugins that are not available.
Definition Context.py:2905
bool isGeometryDirty(self)
Definition Context.py:315
None copyPrimitiveData(self, int sourceUUID, int destinationUUID)
Copy all primitive data from source to destination primitive.
Definition Context.py:1334
colorPrimitiveByDataPseudocolor(self, List[int] uuids, str primitive_data, str colormap="hot", int ncolors=10, Optional[float] max_val=None, Optional[float] min_val=None)
Color primitives based on primitive data values using pseudocolor mapping.
Definition Context.py:2690
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
Definition Context.py:292
List[int] addTile(self, vec3 center=vec3(0, 0, 0), vec2 size=vec2(1, 1), Optional[SphericalCoord] rotation=None, int2 subdiv=int2(1, 1), Optional[RGBcolor] color=None)
Add a subdivided patch (tile) to the context.
Definition Context.py:541
_check_context_available(self)
Helper method to check if context is available with detailed error messages.
Definition Context.py:130
int addPatch(self, vec3 center=vec3(0, 0, 0), vec2 size=vec2(1, 1), Optional[SphericalCoord] rotation=None, Optional[RGBcolor] color=None)
Definition Context.py:334
addMaterial(self, str material_label)
Create a new material for sharing visual properties across primitives.
Definition Context.py:2929
bool isMaterialTextureColorOverridden(self, str material_label)
Check if material texture color is overridden by material color.
Definition Context.py:3026
List[int] loadOBJ(self, str filename, Optional[vec3] origin=None, Optional[float] height=None, Optional[vec3] scale=None, Optional[SphericalCoord] rotation=None, Optional[RGBcolor] color=None, str upaxis="YUP", bool silent=False)
Load geometry from an OBJ (Wavefront) file.
Definition Context.py:1837
None setPrimitiveDataVec4(self, uuids_or_uuid, str label, x_or_vec, float y=None, float z=None, float w=None)
Set primitive data as vec4 for one or multiple primitives.
Definition Context.py:2362
None setPrimitiveDataFloat(self, uuids_or_uuid, str label, float value)
Set primitive data as 32-bit float for one or multiple primitives.
Definition Context.py:2278
int addDiskObject(self, vec3 center=vec3(0, 0, 0), vec2 size=vec2(1, 1), Union[int, int2] ndivs=20, Optional[SphericalCoord] rotation=None, Optional[Union[RGBcolor, RGBAcolor]] color=None, Optional[str] texturefile=None)
Add a disk as a compound object to the context.
Definition Context.py:1215
List[int] addTube(self, List[vec3] nodes, Union[float, List[float]] radii, int ndivs=6, Optional[Union[RGBcolor, List[RGBcolor]]] colors=None)
Add a tube (pipe/cylinder) to the context.
Definition Context.py:668
List[int] addCone(self, vec3 node0, vec3 node1, float radius0, float radius1, int ndivs=20, Optional[RGBcolor] color=None)
Add a cone (or cylinder/frustum) to the context.
Definition Context.py:952
str _validate_file_path(self, str filename, List[str] expected_extensions=None)
Validate and normalize file path for security.
Definition Context.py:209
List[int] addDisk(self, vec3 center=vec3(0, 0, 0), vec2 size=vec2(1, 1), Union[int, int2] ndivs=20, Optional[SphericalCoord] rotation=None, Optional[Union[RGBcolor, RGBAcolor]] color=None)
Add a disk (circular or elliptical surface) to the context.
Definition Context.py:824
assignMaterialToPrimitive(self, uuid, str material_label)
Assign a material to primitive(s).
Definition Context.py:3055
None scaleObject(self, Union[int, List[int]] ObjID, vec3 scale, Optional[vec3] point=None, bool about_center=False, bool about_origin=False)
Scale one or more objects.
Definition Context.py:1664
seedRandomGenerator(self, int seed)
Seed the random number generator for reproducible stochastic results.
Definition Context.py:329
None deleteObject(self, Union[int, List[int]] objIDs_or_objID)
Delete one or more compound objects from the context.
Definition Context.py:2848
None translatePrimitive(self, Union[int, List[int]] UUID, vec3 shift)
Translate one or more primitives by a shift vector.
Definition Context.py:1422
Union[int, List[int]] copyPrimitive(self, Union[int, List[int]] UUID)
Copy one or more primitives.
Definition Context.py:1306
setMaterialTwosidedFlag(self, str material_label, int twosided_flag)
Set the two-sided rendering flag for a material (0 = one-sided, 1 = two-sided).
Definition Context.py:3038
None setPrimitiveDataString(self, uuids_or_uuid, str label, str value)
Set primitive data as string for one or multiple primitives.
Definition Context.py:2306
None rotateObject(self, Union[int, List[int]] ObjID, float angle, Union[str, vec3] axis, Optional[vec3] origin=None, bool about_origin=False)
Rotate one or more objects.
Definition Context.py:1547
None writePrimitiveData(self, str filename, List[str] column_labels, Optional[List[int]] UUIDs=None, bool print_header=False)
Write primitive data to an ASCII text file.
Definition Context.py:2009
None rotatePrimitive(self, Union[int, List[int]] UUID, float angle, Union[str, vec3] axis, Optional[vec3] origin=None)
Rotate one or more primitives.
Definition Context.py:1483
PrimitiveInfo getPrimitiveInfo(self, int uuid)
Get physical properties and geometry information for a single primitive.
Definition Context.py:470
int getPrimitiveDataSize(self, int uuid, str label)
Get the size/length of primitive data (for vector data).
Definition Context.py:2557
vec3 getPrimitiveNormal(self, int uuid)
Definition Context.py:421
int addBoxObject(self, vec3 center=vec3(0, 0, 0), vec3 size=vec3(1, 1, 1), int3 subdiv=int3(1, 1, 1), Optional[RGBcolor] color=None, Optional[str] texturefile=None, bool reverse_normals=False)
Add a rectangular box (prism) as a compound object to the context.
Definition Context.py:1154
int addTriangleTextured(self, vec3 vertex0, vec3 vertex1, vec3 vertex2, str texture_file, vec2 uv0, vec2 uv1, vec2 uv2)
Add a textured triangle primitive to the context.
Definition Context.py:397
int getPrimitiveDataType(self, int uuid, str label)
Get the Helios data type of primitive data.
Definition Context.py:2544
None setPrimitiveDataUInt(self, uuids_or_uuid, str label, int value)
Set primitive data as unsigned 32-bit integer for one or multiple primitives.
Definition Context.py:2264
str getPrimitiveMaterialLabel(self, int uuid)
Get the material label assigned to a primitive.
Definition Context.py:3094
int addTubeObject(self, int ndivs, List[vec3] nodes, List[float] radii, Optional[List[RGBcolor]] colors=None, Optional[str] texturefile=None, Optional[List[float]] texture_uv=None)
Add a tube as a compound object to the context.
Definition Context.py:1261
List[int] loadPLY(self, str filename, Optional[vec3] origin=None, Optional[float] height=None, Optional[SphericalCoord] rotation=None, Optional[RGBcolor] color=None, str upaxis="YUP", bool silent=False)
Load geometry from a PLY (Stanford Polygon) file.
Definition Context.py:1789
None writeOBJ(self, str filename, Optional[List[int]] UUIDs=None, Optional[List[str]] primitive_data_fields=None, bool write_normals=False, bool silent=False)
Write geometry to an OBJ (Wavefront) file.
Definition Context.py:1943
None translateObject(self, Union[int, List[int]] ObjID, vec3 shift)
Translate one or more compound objects by a shift vector.
Definition Context.py:1455
List[vec3] getPrimitiveVertices(self, int uuid)
Definition Context.py:427
float getPrimitiveDataFloat(self, int uuid, str label)
Convenience method to get float primitive data.
Definition Context.py:2531
deleteMaterial(self, str material_label)
Delete a material from the context.
Definition Context.py:2951
dict get_plugin_capabilities(self)
Get detailed information about available plugin capabilities.
Definition Context.py:2889
getTime(self)
Get the current simulation time.
Definition Context.py:2766
None scalePrimitive(self, Union[int, List[int]] UUID, vec3 scale, Optional[vec3] point=None)
Scale one or more primitives.
Definition Context.py:1620
List[int] getPrimitivesUsingMaterial(self, str material_label)
Get all primitive UUIDs that use a specific material.
Definition Context.py:3124
setDateJulian(self, int julian_day, int year)
Set the simulation date using Julian day number.
Definition Context.py:2750
setMaterialTextureColorOverride(self, str material_label, bool override)
Set whether material color overrides texture color.
Definition Context.py:3030
int addTriangle(self, vec3 vertex0, vec3 vertex1, vec3 vertex2, Optional[RGBcolor] color=None)
Add a triangle primitive to the context.
Definition Context.py:354
getDate(self)
Get the current simulation date.
Definition Context.py:2782
List[int] addTrianglesFromArraysTextured(self, np.ndarray vertices, np.ndarray faces, np.ndarray uv_coords, Union[str, List[str]] texture_files, Optional[np.ndarray] material_ids=None)
Add textured triangles from NumPy arrays with support for multiple textures.
Definition Context.py:2142
List[int] loadXML(self, str filename, bool quiet=False)
Load geometry from a Helios XML file.
Definition Context.py:1875
setMaterialColor(self, str material_label, color)
Set the RGBA color of a material.
Definition Context.py:2988
bool doesMaterialExist(self, str material_label)
Check if a material with the given label exists.
Definition Context.py:2933
None scaleConeObjectLength(self, int ObjID, float scale_factor)
Scale the length of a Cone object by scaling the distance between its two nodes.
Definition Context.py:1733
List[int] addSphere(self, vec3 center=vec3(0, 0, 0), float radius=1.0, int ndivs=10, Optional[RGBcolor] color=None)
Add a sphere to the context.
Definition Context.py:608
List[int] addTrianglesFromArrays(self, np.ndarray vertices, np.ndarray faces, Optional[np.ndarray] colors=None)
Add triangles from NumPy arrays (compatible with trimesh, Open3D format).
Definition Context.py:2050
setTime(self, int hour, int minute=0, int second=0)
Set the simulation time.
Definition Context.py:2715
assignMaterialToObject(self, objID, str material_label)
Assign a material to all primitives in compound object(s).
Definition Context.py:3076
None setPrimitiveDataVec3(self, uuids_or_uuid, str label, x_or_vec, float y=None, float z=None)
Set primitive data as vec3 for one or multiple primitives.
Definition Context.py:2341
setMaterialTexture(self, str material_label, str texture_file)
Set the texture file for a material.
Definition Context.py:3022
Physical properties and geometry information for a primitive.
Definition Context.py:23
__post_init__(self)
Calculate centroid from vertices if not provided.
Definition Context.py:33
Helios primitive type enumeration.
Definition DataTypes.py:8