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