0.1.8
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, 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.")
174
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'
282
283 def getNativePtr(self):
285 return self.context
286
289 context_wrapper.markGeometryClean(self.context)
290
291 def markGeometryDirty(self):
293 context_wrapper.markGeometryDirty(self.context)
294
295
296 def isGeometryDirty(self) -> bool:
298 return context_wrapper.isGeometryDirty(self.context)
299
300 @validate_patch_params
301 def addPatch(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1), rotation: Optional[SphericalCoord] = None, color: Optional[RGBcolor] = None) -> int:
303 rotation = rotation or SphericalCoord(1, 0, 0) # radius=1, elevation=0, azimuth=0 (no effective rotation)
304 color = color or RGBcolor(1, 1, 1)
305 # C++ interface expects [radius, elevation, azimuth] (3 values), not [radius, elevation, zenith, azimuth] (4 values)
306 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
307 return context_wrapper.addPatchWithCenterSizeRotationAndColor(self.context, center.to_list(), size.to_list(), rotation_list, color.to_list())
308
309 @validate_triangle_params
310 def addTriangle(self, vertex0: vec3, vertex1: vec3, vertex2: vec3, color: Optional[RGBcolor] = None) -> int:
311 """Add a triangle primitive to the context
312
313 Args:
314 vertex0: First vertex of the triangle
315 vertex1: Second vertex of the triangle
316 vertex2: Third vertex of the triangle
317 color: Optional triangle color (defaults to white)
318
319 Returns:
320 UUID of the created triangle primitive
321 """
323 if color is None:
324 return context_wrapper.addTriangle(self.context, vertex0.to_list(), vertex1.to_list(), vertex2.to_list())
325 else:
326 return context_wrapper.addTriangleWithColor(self.context, vertex0.to_list(), vertex1.to_list(), vertex2.to_list(), color.to_list())
327
328 def addTriangleTextured(self, vertex0: vec3, vertex1: vec3, vertex2: vec3,
329 texture_file: str, uv0: vec2, uv1: vec2, uv2: vec2) -> int:
330 """Add a textured triangle primitive to the context
332 Creates a triangle with texture mapping. The texture image is mapped to the triangle
333 surface using UV coordinates, where (0,0) represents the top-left corner of the image
334 and (1,1) represents the bottom-right corner.
335
336 Args:
337 vertex0: First vertex of the triangle
338 vertex1: Second vertex of the triangle
339 vertex2: Third vertex of the triangle
340 texture_file: Path to texture image file (supports PNG, JPG, JPEG, TGA, BMP)
341 uv0: UV texture coordinates for first vertex
342 uv1: UV texture coordinates for second vertex
343 uv2: UV texture coordinates for third vertex
344
345 Returns:
346 UUID of the created textured triangle primitive
347
348 Raises:
349 ValueError: If texture file path is invalid
350 FileNotFoundError: If texture file doesn't exist
351 RuntimeError: If context is in mock mode
352
353 Example:
354 >>> context = Context()
355 >>> # Create a textured triangle
356 >>> vertex0 = vec3(0, 0, 0)
357 >>> vertex1 = vec3(1, 0, 0)
358 >>> vertex2 = vec3(0.5, 1, 0)
359 >>> uv0 = vec2(0, 0) # Bottom-left of texture
360 >>> uv1 = vec2(1, 0) # Bottom-right of texture
361 >>> uv2 = vec2(0.5, 1) # Top-center of texture
362 >>> uuid = context.addTriangleTextured(vertex0, vertex1, vertex2,
363 ... "texture.png", uv0, uv1, uv2)
364 """
366
367 # Validate texture file path
368 validated_texture_file = self._validate_file_path(texture_file,
369 ['.png', '.jpg', '.jpeg', '.tga', '.bmp'])
370
371 # Call the wrapper function
372 return context_wrapper.addTriangleWithTexture(
373 self.context,
374 vertex0.to_list(), vertex1.to_list(), vertex2.to_list(),
375 validated_texture_file,
376 uv0.to_list(), uv1.to_list(), uv2.to_list()
377 )
378
379 def getPrimitiveType(self, uuid: int) -> PrimitiveType:
381 primitive_type = context_wrapper.getPrimitiveType(self.context, uuid)
382 return PrimitiveType(primitive_type)
383
384 def getPrimitiveArea(self, uuid: int) -> float:
386 return context_wrapper.getPrimitiveArea(self.context, uuid)
387
388 def getPrimitiveNormal(self, uuid: int) -> vec3:
390 normal_ptr = context_wrapper.getPrimitiveNormal(self.context, uuid)
391 v = vec3(normal_ptr[0], normal_ptr[1], normal_ptr[2])
392 return v
393
394 def getPrimitiveVertices(self, uuid: int) -> List[vec3]:
396 size = ctypes.c_uint()
397 vertices_ptr = context_wrapper.getPrimitiveVertices(self.context, uuid, ctypes.byref(size))
398 # size.value is the total number of floats (3 per vertex), not the number of vertices
399 vertices_list = ctypes.cast(vertices_ptr, ctypes.POINTER(ctypes.c_float * size.value)).contents
400 vertices = [vec3(vertices_list[i], vertices_list[i+1], vertices_list[i+2]) for i in range(0, size.value, 3)]
401 return vertices
402
403 def getPrimitiveColor(self, uuid: int) -> RGBcolor:
405 color_ptr = context_wrapper.getPrimitiveColor(self.context, uuid)
406 return RGBcolor(color_ptr[0], color_ptr[1], color_ptr[2])
407
408 def getPrimitiveCount(self) -> int:
410 return context_wrapper.getPrimitiveCount(self.context)
411
412 def getAllUUIDs(self) -> List[int]:
414 size = ctypes.c_uint()
415 uuids_ptr = context_wrapper.getAllUUIDs(self.context, ctypes.byref(size))
416 return list(uuids_ptr[:size.value])
417
418 def getObjectCount(self) -> int:
420 return context_wrapper.getObjectCount(self.context)
421
422 def getAllObjectIDs(self) -> List[int]:
424 size = ctypes.c_uint()
425 objectids_ptr = context_wrapper.getAllObjectIDs(self.context, ctypes.byref(size))
426 return list(objectids_ptr[:size.value])
427
428 def getPrimitiveInfo(self, uuid: int) -> PrimitiveInfo:
429 """
430 Get physical properties and geometry information for a single primitive.
431
432 Args:
433 uuid: UUID of the primitive
435 Returns:
436 PrimitiveInfo object containing physical properties and geometry
437 """
438 # Get all physical properties using existing methods
439 primitive_type = self.getPrimitiveType(uuid)
440 area = self.getPrimitiveArea(uuid)
441 normal = self.getPrimitiveNormal(uuid)
442 vertices = self.getPrimitiveVertices(uuid)
443 color = self.getPrimitiveColor(uuid)
444
445 # Create and return PrimitiveInfo object
446 return PrimitiveInfo(
447 uuid=uuid,
448 primitive_type=primitive_type,
449 area=area,
450 normal=normal,
451 vertices=vertices,
452 color=color
453 )
454
455 def getAllPrimitiveInfo(self) -> List[PrimitiveInfo]:
456 """
457 Get physical properties and geometry information for all primitives in the context.
458
459 Returns:
460 List of PrimitiveInfo objects for all primitives
461 """
462 all_uuids = self.getAllUUIDs()
463 return [self.getPrimitiveInfo(uuid) for uuid in all_uuids]
464
465 def getPrimitivesInfoForObject(self, object_id: int) -> List[PrimitiveInfo]:
466 """
467 Get physical properties and geometry information for all primitives belonging to a specific object.
468
469 Args:
470 object_id: ID of the object
471
472 Returns:
473 List of PrimitiveInfo objects for primitives in the object
474 """
475 object_uuids = context_wrapper.getObjectPrimitiveUUIDs(self.context, object_id)
476 return [self.getPrimitiveInfo(uuid) for uuid in object_uuids]
477
478 # Compound geometry methods
479 def addTile(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1),
480 rotation: Optional[SphericalCoord] = None, subdiv: int2 = int2(1, 1),
481 color: Optional[RGBcolor] = None) -> List[int]:
482 """
483 Add a subdivided patch (tile) to the context.
484
485 A tile is a patch subdivided into a regular grid of smaller patches,
486 useful for creating detailed surfaces or terrain.
487
488 Args:
489 center: 3D coordinates of tile center (default: origin)
490 size: Width and height of the tile (default: 1x1)
491 rotation: Orientation of the tile (default: no rotation)
492 subdiv: Number of subdivisions in x and y directions (default: 1x1)
493 color: Color of the tile (default: white)
494
495 Returns:
496 List of UUIDs for all patches created in the tile
497
498 Example:
499 >>> context = Context()
500 >>> # Create a 2x2 meter tile subdivided into 4x4 patches
501 >>> tile_uuids = context.addTile(
502 ... center=vec3(0, 0, 1),
503 ... size=vec2(2, 2),
504 ... subdiv=int2(4, 4),
505 ... color=RGBcolor(0.5, 0.8, 0.2)
506 ... )
507 >>> print(f"Created {len(tile_uuids)} patches")
508 """
510
511 # Parameter type validation
512 if not isinstance(center, vec3):
513 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
514 if not isinstance(size, vec2):
515 raise ValueError(f"Size must be a vec2, got {type(size).__name__}")
516 if rotation is not None and not isinstance(rotation, SphericalCoord):
517 raise ValueError(f"Rotation must be a SphericalCoord or None, got {type(rotation).__name__}")
518 if not isinstance(subdiv, int2):
519 raise ValueError(f"Subdiv must be an int2, got {type(subdiv).__name__}")
520 if color is not None and not isinstance(color, RGBcolor):
521 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
522
523 # Parameter value validation
524 if any(s <= 0 for s in size.to_list()):
525 raise ValueError("All size dimensions must be positive")
526 if any(s <= 0 for s in subdiv.to_list()):
527 raise ValueError("All subdivision counts must be positive")
528
529 rotation = rotation or SphericalCoord(1, 0, 0)
530 color = color or RGBcolor(1, 1, 1)
531
532 # Extract only radius, elevation, azimuth for C++ interface
533 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
534
535 if color and not (color.r == 1.0 and color.g == 1.0 and color.b == 1.0):
536 return context_wrapper.addTileWithColor(
537 self.context, center.to_list(), size.to_list(),
538 rotation_list, subdiv.to_list(), color.to_list()
539 )
540 else:
541 return context_wrapper.addTile(
542 self.context, center.to_list(), size.to_list(),
543 rotation_list, subdiv.to_list()
544 )
545
546 @validate_sphere_params
547 def addSphere(self, center: vec3 = vec3(0, 0, 0), radius: float = 1.0,
548 ndivs: int = 10, color: Optional[RGBcolor] = None) -> List[int]:
549 """
550 Add a sphere to the context.
551
552 The sphere is tessellated into triangular faces based on the specified
553 number of divisions.
554
555 Args:
556 center: 3D coordinates of sphere center (default: origin)
557 radius: Radius of the sphere (default: 1.0)
558 ndivs: Number of divisions for tessellation (default: 10)
559 Higher values create smoother spheres but more triangles
560 color: Color of the sphere (default: white)
561
562 Returns:
563 List of UUIDs for all triangles created in the sphere
564
565 Example:
566 >>> context = Context()
567 >>> # Create a red sphere at (1, 2, 3) with radius 0.5
568 >>> sphere_uuids = context.addSphere(
569 ... center=vec3(1, 2, 3),
570 ... radius=0.5,
571 ... ndivs=20,
572 ... color=RGBcolor(1, 0, 0)
573 ... )
574 >>> print(f"Created sphere with {len(sphere_uuids)} triangles")
575 """
577
578 # Parameter type validation
579 if not isinstance(center, vec3):
580 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
581 if not isinstance(radius, (int, float)):
582 raise ValueError(f"Radius must be a number, got {type(radius).__name__}")
583 if not isinstance(ndivs, int):
584 raise ValueError(f"Ndivs must be an integer, got {type(ndivs).__name__}")
585 if color is not None and not isinstance(color, RGBcolor):
586 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
587
588 # Parameter value validation
589 if radius <= 0:
590 raise ValueError("Sphere radius must be positive")
591 if ndivs < 3:
592 raise ValueError("Number of divisions must be at least 3")
593
594 if color:
595 return context_wrapper.addSphereWithColor(
596 self.context, ndivs, center.to_list(), radius, color.to_list()
597 )
598 else:
599 return context_wrapper.addSphere(
600 self.context, ndivs, center.to_list(), radius
601 )
602
603 @validate_tube_params
604 def addTube(self, nodes: List[vec3], radii: Union[float, List[float]],
605 ndivs: int = 6, colors: Optional[Union[RGBcolor, List[RGBcolor]]] = None) -> List[int]:
606 """
607 Add a tube (pipe/cylinder) to the context.
608
609 The tube is defined by a series of nodes (path) with radius at each node.
610 It's tessellated into triangular faces based on the number of radial divisions.
611
612 Args:
613 nodes: List of 3D points defining the tube path (at least 2 nodes)
614 radii: Radius at each node. Can be:
615 - Single float: constant radius for all nodes
616 - List of floats: radius for each node (must match nodes length)
617 ndivs: Number of radial divisions (default: 6)
618 Higher values create smoother tubes but more triangles
619 colors: Colors at each node. Can be:
620 - None: white tube
621 - Single RGBcolor: constant color for all nodes
622 - List of RGBcolor: color for each node (must match nodes length)
623
624 Returns:
625 List of UUIDs for all triangles created in the tube
626
627 Example:
628 >>> context = Context()
629 >>> # Create a curved tube with varying radius
630 >>> nodes = [vec3(0, 0, 0), vec3(1, 0, 0), vec3(2, 1, 0)]
631 >>> radii = [0.1, 0.2, 0.1]
632 >>> colors = [RGBcolor(1, 0, 0), RGBcolor(0, 1, 0), RGBcolor(0, 0, 1)]
633 >>> tube_uuids = context.addTube(nodes, radii, ndivs=8, colors=colors)
634 >>> print(f"Created tube with {len(tube_uuids)} triangles")
635 """
637
638 # Parameter type validation
639 if not isinstance(nodes, (list, tuple)):
640 raise ValueError(f"Nodes must be a list or tuple, got {type(nodes).__name__}")
641 if not isinstance(ndivs, int):
642 raise ValueError(f"Ndivs must be an integer, got {type(ndivs).__name__}")
643 if colors is not None and not isinstance(colors, (RGBcolor, list, tuple)):
644 raise ValueError(f"Colors must be RGBcolor, list, tuple, or None, got {type(colors).__name__}")
645
646 # Parameter value validation
647 if len(nodes) < 2:
648 raise ValueError("Tube requires at least 2 nodes")
649 if ndivs < 3:
650 raise ValueError("Number of radial divisions must be at least 3")
651
652 # Handle radius parameter
653 if isinstance(radii, (int, float)):
654 radii_list = [float(radii)] * len(nodes)
655 else:
656 radii_list = [float(r) for r in radii]
657 if len(radii_list) != len(nodes):
658 raise ValueError(f"Number of radii ({len(radii_list)}) must match number of nodes ({len(nodes)})")
659
660 # Validate radii
661 if any(r <= 0 for r in radii_list):
662 raise ValueError("All radii must be positive")
663
664 # Convert nodes to flat list
665 nodes_flat = []
666 for node in nodes:
667 nodes_flat.extend(node.to_list())
668
669 # Handle colors parameter
670 if colors is None:
671 return context_wrapper.addTube(self.context, ndivs, nodes_flat, radii_list)
672 elif isinstance(colors, RGBcolor):
673 # Single color for all nodes
674 colors_flat = colors.to_list() * len(nodes)
675 else:
676 # List of colors
677 if len(colors) != len(nodes):
678 raise ValueError(f"Number of colors ({len(colors)}) must match number of nodes ({len(nodes)})")
679 colors_flat = []
680 for color in colors:
681 colors_flat.extend(color.to_list())
682
683 return context_wrapper.addTubeWithColor(self.context, ndivs, nodes_flat, radii_list, colors_flat)
684
685 @validate_box_params
686 def addBox(self, center: vec3 = vec3(0, 0, 0), size: vec3 = vec3(1, 1, 1),
687 subdiv: int3 = int3(1, 1, 1), color: Optional[RGBcolor] = None) -> List[int]:
688 """
689 Add a rectangular box to the context.
690
691 The box is subdivided into patches on each face based on the specified
692 subdivisions.
693
694 Args:
695 center: 3D coordinates of box center (default: origin)
696 size: Width, height, and depth of the box (default: 1x1x1)
697 subdiv: Number of subdivisions in x, y, and z directions (default: 1x1x1)
698 Higher values create more detailed surfaces
699 color: Color of the box (default: white)
700
701 Returns:
702 List of UUIDs for all patches created on the box faces
703
704 Example:
705 >>> context = Context()
706 >>> # Create a blue box subdivided for detail
707 >>> box_uuids = context.addBox(
708 ... center=vec3(0, 0, 2),
709 ... size=vec3(2, 1, 0.5),
710 ... subdiv=int3(4, 2, 1),
711 ... color=RGBcolor(0, 0, 1)
712 ... )
713 >>> print(f"Created box with {len(box_uuids)} patches")
714 """
716
717 # Parameter type validation
718 if not isinstance(center, vec3):
719 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
720 if not isinstance(size, vec3):
721 raise ValueError(f"Size must be a vec3, got {type(size).__name__}")
722 if not isinstance(subdiv, int3):
723 raise ValueError(f"Subdiv must be an int3, got {type(subdiv).__name__}")
724 if color is not None and not isinstance(color, RGBcolor):
725 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
726
727 # Parameter value validation
728 if any(s <= 0 for s in size.to_list()):
729 raise ValueError("All box dimensions must be positive")
730 if any(s < 1 for s in subdiv.to_list()):
731 raise ValueError("All subdivision counts must be at least 1")
732
733 if color:
734 return context_wrapper.addBoxWithColor(
735 self.context, center.to_list(), size.to_list(),
736 subdiv.to_list(), color.to_list()
737 )
738 else:
739 return context_wrapper.addBox(
740 self.context, center.to_list(), size.to_list(), subdiv.to_list()
741 )
742
743 def loadPLY(self, filename: str, origin: Optional[vec3] = None, height: Optional[float] = None,
744 rotation: Optional[SphericalCoord] = None, color: Optional[RGBcolor] = None,
745 upaxis: str = "YUP", silent: bool = False) -> List[int]:
746 """
747 Load geometry from a PLY (Stanford Polygon) file.
748
749 Args:
750 filename: Path to the PLY file to load
751 origin: Origin point for positioning the geometry (optional)
752 height: Height scaling factor (optional)
753 rotation: Rotation to apply to the geometry (optional)
754 color: Default color for geometry without color data (optional)
755 upaxis: Up axis orientation ("YUP" or "ZUP")
756 silent: If True, suppress loading output messages
757
758 Returns:
759 List of UUIDs for the loaded primitives
760 """
762 # Validate file path for security
763 validated_filename = self._validate_file_path(filename, ['.ply'])
764
765 if origin is None and height is None and rotation is None and color is None:
766 # Simple load with no transformations
767 return context_wrapper.loadPLY(self.context, validated_filename, silent)
768
769 elif origin is not None and height is not None and rotation is None and color is None:
770 # Load with origin and height
771 return context_wrapper.loadPLYWithOriginHeight(self.context, validated_filename, origin.to_list(), height, upaxis, silent)
772
773 elif origin is not None and height is not None and rotation is not None and color is None:
774 # Load with origin, height, and rotation
775 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
776 return context_wrapper.loadPLYWithOriginHeightRotation(self.context, validated_filename, origin.to_list(), height, rotation_list, upaxis, silent)
777
778 elif origin is not None and height is not None and rotation is None and color is not None:
779 # Load with origin, height, and color
780 return context_wrapper.loadPLYWithOriginHeightColor(self.context, validated_filename, origin.to_list(), height, color.to_list(), upaxis, silent)
781
782 elif origin is not None and height is not None and rotation is not None and color is not None:
783 # Load with all parameters
784 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
785 return context_wrapper.loadPLYWithOriginHeightRotationColor(self.context, validated_filename, origin.to_list(), height, rotation_list, color.to_list(), upaxis, silent)
786
787 else:
788 raise ValueError("Invalid parameter combination. When using transformations, both origin and height are required.")
789
790 def loadOBJ(self, filename: str, origin: Optional[vec3] = None, height: Optional[float] = None,
791 scale: Optional[vec3] = None, rotation: Optional[SphericalCoord] = None,
792 color: Optional[RGBcolor] = None, upaxis: str = "YUP", silent: bool = False) -> List[int]:
793 """
794 Load geometry from an OBJ (Wavefront) file.
795
796 Args:
797 filename: Path to the OBJ file to load
798 origin: Origin point for positioning the geometry (optional)
799 height: Height scaling factor (optional, alternative to scale)
800 scale: Scale factor for all dimensions (optional, alternative to height)
801 rotation: Rotation to apply to the geometry (optional)
802 color: Default color for geometry without color data (optional)
803 upaxis: Up axis orientation ("YUP" or "ZUP")
804 silent: If True, suppress loading output messages
805
806 Returns:
807 List of UUIDs for the loaded primitives
808 """
810 # Validate file path for security
811 validated_filename = self._validate_file_path(filename, ['.obj'])
812
813 if origin is None and height is None and scale is None and rotation is None and color is None:
814 # Simple load with no transformations
815 return context_wrapper.loadOBJ(self.context, validated_filename, silent)
816
817 elif origin is not None and height is not None and scale is None and rotation is not None and color is not None:
818 # Load with origin, height, rotation, and color (no upaxis)
819 return context_wrapper.loadOBJWithOriginHeightRotationColor(self.context, validated_filename, origin.to_list(), height, rotation.to_list(), color.to_list(), silent)
820
821 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":
822 # Load with origin, height, rotation, color, and upaxis
823 return context_wrapper.loadOBJWithOriginHeightRotationColorUpaxis(self.context, validated_filename, origin.to_list(), height, rotation.to_list(), color.to_list(), upaxis, silent)
824
825 elif origin is not None and scale is not None and rotation is not None and color is not None:
826 # Load with origin, scale, rotation, color, and upaxis
827 return context_wrapper.loadOBJWithOriginScaleRotationColorUpaxis(self.context, validated_filename, origin.to_list(), scale.to_list(), rotation.to_list(), color.to_list(), upaxis, silent)
828
829 else:
830 raise ValueError("Invalid parameter combination. For OBJ loading, you must provide either: " +
831 "1) No parameters (simple load), " +
832 "2) origin + height + rotation + color, " +
833 "3) origin + height + rotation + color + upaxis, or " +
834 "4) origin + scale + rotation + color + upaxis")
835
836 def loadXML(self, filename: str, quiet: bool = False) -> List[int]:
837 """
838 Load geometry from a Helios XML file.
839
840 Args:
841 filename: Path to the XML file to load
842 quiet: If True, suppress loading output messages
843
844 Returns:
845 List of UUIDs for the loaded primitives
846 """
848 # Validate file path for security
849 validated_filename = self._validate_file_path(filename, ['.xml'])
850
851 return context_wrapper.loadXML(self.context, validated_filename, quiet)
852
853 def writePLY(self, filename: str, UUIDs: Optional[List[int]] = None) -> None:
854 """
855 Write geometry to a PLY (Stanford Polygon) file.
856
857 Args:
858 filename: Path to the output PLY file
859 UUIDs: Optional list of primitive UUIDs to export. If None, exports all primitives
860
861 Raises:
862 ValueError: If filename is invalid or UUIDs are invalid
863 PermissionError: If output directory is not writable
864 FileNotFoundError: If UUIDs do not exist in context
865 RuntimeError: If Context is in mock mode
867 Example:
868 >>> context.writePLY("output.ply") # Export all primitives
869 >>> context.writePLY("subset.ply", [uuid1, uuid2]) # Export specific primitives
870 """
872
873 # Validate output file path for security
874 validated_filename = self._validate_output_file_path(filename, ['.ply'])
875
876 if UUIDs is None:
877 # Export all primitives
878 context_wrapper.writePLY(self.context, validated_filename)
879 else:
880 # Validate UUIDs exist in context
881 if not UUIDs:
882 raise ValueError("UUIDs list cannot be empty. Use UUIDs=None to export all primitives")
883
884 # Validate each UUID exists
885 for uuid in UUIDs:
886 self._validate_uuid(uuid)
887
888 # Export specified UUIDs
889 context_wrapper.writePLYWithUUIDs(self.context, validated_filename, UUIDs)
890
891 def writeOBJ(self, filename: str, UUIDs: Optional[List[int]] = None,
892 primitive_data_fields: Optional[List[str]] = None,
893 write_normals: bool = False, silent: bool = False) -> None:
894 """
895 Write geometry to an OBJ (Wavefront) file.
896
897 Args:
898 filename: Path to the output OBJ file
899 UUIDs: Optional list of primitive UUIDs to export. If None, exports all primitives
900 primitive_data_fields: Optional list of primitive data field names to export
901 write_normals: Whether to include vertex normals in the output
902 silent: Whether to suppress output messages during export
903
904 Raises:
905 ValueError: If filename is invalid, UUIDs are invalid, or data fields don't exist
906 PermissionError: If output directory is not writable
907 FileNotFoundError: If UUIDs do not exist in context
908 RuntimeError: If Context is in mock mode
909
910 Example:
911 >>> context.writeOBJ("output.obj") # Export all primitives
912 >>> context.writeOBJ("subset.obj", [uuid1, uuid2]) # Export specific primitives
913 >>> context.writeOBJ("with_data.obj", [uuid1], ["temperature", "area"]) # Export with data
914 """
916
917 # Validate output file path for security
918 validated_filename = self._validate_output_file_path(filename, ['.obj'])
919
920 if UUIDs is None:
921 # Export all primitives
922 context_wrapper.writeOBJ(self.context, validated_filename, write_normals, silent)
923 elif primitive_data_fields is None:
924 # Export specified UUIDs without data fields
925 if not UUIDs:
926 raise ValueError("UUIDs list cannot be empty. Use UUIDs=None to export all primitives")
927
928 # Validate each UUID exists
929 for uuid in UUIDs:
930 self._validate_uuid(uuid)
931
932 context_wrapper.writeOBJWithUUIDs(self.context, validated_filename, UUIDs, write_normals, silent)
933 else:
934 # Export specified UUIDs with primitive data fields
935 if not UUIDs:
936 raise ValueError("UUIDs list cannot be empty when exporting primitive data")
937 if not primitive_data_fields:
938 raise ValueError("primitive_data_fields list cannot be empty")
939
940 # Validate each UUID exists
941 for uuid in UUIDs:
942 self._validate_uuid(uuid)
943
944 # Note: Primitive data field validation is handled by the native library
945 # which will raise appropriate errors if fields don't exist for the specified primitives
946
947 context_wrapper.writeOBJWithPrimitiveData(self.context, validated_filename, UUIDs, primitive_data_fields, write_normals, silent)
948
949 def addTrianglesFromArrays(self, vertices: np.ndarray, faces: np.ndarray,
950 colors: Optional[np.ndarray] = None) -> List[int]:
951 """
952 Add triangles from NumPy arrays (compatible with trimesh, Open3D format).
953
954 Args:
955 vertices: NumPy array of shape (N, 3) containing vertex coordinates as float32/float64
956 faces: NumPy array of shape (M, 3) containing triangle vertex indices as int32/int64
957 colors: Optional NumPy array of shape (N, 3) or (M, 3) containing RGB colors as float32/float64
958 If shape (N, 3): per-vertex colors
959 If shape (M, 3): per-triangle colors
960
961 Returns:
962 List of UUIDs for the added triangles
963
964 Raises:
965 ValueError: If array dimensions are invalid
966 """
967 # Validate input arrays
968 if vertices.ndim != 2 or vertices.shape[1] != 3:
969 raise ValueError(f"Vertices array must have shape (N, 3), got {vertices.shape}")
970 if faces.ndim != 2 or faces.shape[1] != 3:
971 raise ValueError(f"Faces array must have shape (M, 3), got {faces.shape}")
972
973 # Check vertex indices are valid
974 max_vertex_index = np.max(faces)
975 if max_vertex_index >= vertices.shape[0]:
976 raise ValueError(f"Face indices reference vertex {max_vertex_index}, but only {vertices.shape[0]} vertices provided")
977
978 # Validate colors array if provided
979 per_vertex_colors = False
980 per_triangle_colors = False
981 if colors is not None:
982 if colors.ndim != 2 or colors.shape[1] != 3:
983 raise ValueError(f"Colors array must have shape (N, 3) or (M, 3), got {colors.shape}")
984 if colors.shape[0] == vertices.shape[0]:
985 per_vertex_colors = True
986 elif colors.shape[0] == faces.shape[0]:
987 per_triangle_colors = True
988 else:
989 raise ValueError(f"Colors array shape {colors.shape} doesn't match vertices ({vertices.shape[0]},) or faces ({faces.shape[0]},)")
990
991 # Convert arrays to appropriate data types
992 vertices_float = vertices.astype(np.float32)
993 faces_int = faces.astype(np.int32)
994 if colors is not None:
995 colors_float = colors.astype(np.float32)
996
997 # Add triangles
998 triangle_uuids = []
999 for i in range(faces.shape[0]):
1000 # Get vertex indices for this triangle
1001 v0_idx, v1_idx, v2_idx = faces_int[i]
1002
1003 # Get vertex coordinates
1004 vertex0 = vertices_float[v0_idx].tolist()
1005 vertex1 = vertices_float[v1_idx].tolist()
1006 vertex2 = vertices_float[v2_idx].tolist()
1007
1008 # Add triangle with or without color
1009 if colors is None:
1010 # No color specified
1011 uuid = context_wrapper.addTriangle(self.context, vertex0, vertex1, vertex2)
1012 elif per_triangle_colors:
1013 # Use per-triangle color
1014 color = colors_float[i].tolist()
1015 uuid = context_wrapper.addTriangleWithColor(self.context, vertex0, vertex1, vertex2, color)
1016 elif per_vertex_colors:
1017 # Average the per-vertex colors for the triangle
1018 color = np.mean([colors_float[v0_idx], colors_float[v1_idx], colors_float[v2_idx]], axis=0).tolist()
1019 uuid = context_wrapper.addTriangleWithColor(self.context, vertex0, vertex1, vertex2, color)
1020
1021 triangle_uuids.append(uuid)
1022
1023 return triangle_uuids
1024
1025 def addTrianglesFromArraysTextured(self, vertices: np.ndarray, faces: np.ndarray,
1026 uv_coords: np.ndarray, texture_files: Union[str, List[str]],
1027 material_ids: Optional[np.ndarray] = None) -> List[int]:
1028 """
1029 Add textured triangles from NumPy arrays with support for multiple textures.
1030
1031 This method supports both single-texture and multi-texture workflows:
1032 - Single texture: Pass a single texture file string, all faces use the same texture
1033 - Multiple textures: Pass a list of texture files and material_ids array specifying which texture each face uses
1034
1035 Args:
1036 vertices: NumPy array of shape (N, 3) containing vertex coordinates as float32/float64
1037 faces: NumPy array of shape (M, 3) containing triangle vertex indices as int32/int64
1038 uv_coords: NumPy array of shape (N, 2) containing UV texture coordinates as float32/float64
1039 texture_files: Single texture file path (str) or list of texture file paths (List[str])
1040 material_ids: Optional NumPy array of shape (M,) containing material ID for each face.
1041 If None and texture_files is a list, all faces use texture 0.
1042 If None and texture_files is a string, this parameter is ignored.
1043
1044 Returns:
1045 List of UUIDs for the added textured triangles
1046
1047 Raises:
1048 ValueError: If array dimensions are invalid or material IDs are out of range
1049
1050 Example:
1051 # Single texture usage (backward compatible)
1052 >>> uuids = context.addTrianglesFromArraysTextured(vertices, faces, uvs, "texture.png")
1053
1054 # Multi-texture usage (Open3D style)
1055 >>> texture_files = ["wood.png", "metal.png", "glass.png"]
1056 >>> material_ids = np.array([0, 0, 1, 1, 2, 2]) # 6 faces using different textures
1057 >>> uuids = context.addTrianglesFromArraysTextured(vertices, faces, uvs, texture_files, material_ids)
1058 """
1060
1061 # Validate input arrays
1062 if vertices.ndim != 2 or vertices.shape[1] != 3:
1063 raise ValueError(f"Vertices array must have shape (N, 3), got {vertices.shape}")
1064 if faces.ndim != 2 or faces.shape[1] != 3:
1065 raise ValueError(f"Faces array must have shape (M, 3), got {faces.shape}")
1066 if uv_coords.ndim != 2 or uv_coords.shape[1] != 2:
1067 raise ValueError(f"UV coordinates array must have shape (N, 2), got {uv_coords.shape}")
1068
1069 # Check array consistency
1070 if uv_coords.shape[0] != vertices.shape[0]:
1071 raise ValueError(f"UV coordinates count ({uv_coords.shape[0]}) must match vertices count ({vertices.shape[0]})")
1072
1073 # Check vertex indices are valid
1074 max_vertex_index = np.max(faces)
1075 if max_vertex_index >= vertices.shape[0]:
1076 raise ValueError(f"Face indices reference vertex {max_vertex_index}, but only {vertices.shape[0]} vertices provided")
1077
1078 # Handle texture files parameter (single string or list)
1079 if isinstance(texture_files, str):
1080 # Single texture case - use original implementation for efficiency
1081 texture_file_list = [texture_files]
1082 if material_ids is None:
1083 material_ids = np.zeros(faces.shape[0], dtype=np.uint32)
1084 else:
1085 # Validate that all material IDs are 0 for single texture
1086 if not np.all(material_ids == 0):
1087 raise ValueError("When using single texture file, all material IDs must be 0")
1088 else:
1089 # Multiple textures case
1090 texture_file_list = list(texture_files)
1091 if len(texture_file_list) == 0:
1092 raise ValueError("Texture files list cannot be empty")
1093
1094 if material_ids is None:
1095 # Default: all faces use first texture
1096 material_ids = np.zeros(faces.shape[0], dtype=np.uint32)
1097 else:
1098 # Validate material IDs array
1099 if material_ids.ndim != 1 or material_ids.shape[0] != faces.shape[0]:
1100 raise ValueError(f"Material IDs must have shape ({faces.shape[0]},), got {material_ids.shape}")
1101
1102 # Check material ID range
1103 max_material_id = np.max(material_ids)
1104 if max_material_id >= len(texture_file_list):
1105 raise ValueError(f"Material ID {max_material_id} exceeds texture count {len(texture_file_list)}")
1106
1107 # Validate all texture files exist
1108 for i, texture_file in enumerate(texture_file_list):
1109 try:
1110 self._validate_file_path(texture_file)
1111 except (FileNotFoundError, ValueError) as e:
1112 raise ValueError(f"Texture file {i} ({texture_file}): {e}")
1113
1114 # Use efficient multi-texture C++ implementation if available, otherwise triangle-by-triangle
1115 if 'addTrianglesFromArraysMultiTextured' in context_wrapper._AVAILABLE_TRIANGLE_FUNCTIONS:
1116 return context_wrapper.addTrianglesFromArraysMultiTextured(
1117 self.context, vertices, faces, uv_coords, texture_file_list, material_ids
1118 )
1119 else:
1120 # Use triangle-by-triangle approach with addTriangleTextured
1121 from .wrappers.DataTypes import vec3, vec2
1122
1123 vertices_float = vertices.astype(np.float32)
1124 faces_int = faces.astype(np.int32)
1125 uv_coords_float = uv_coords.astype(np.float32)
1126
1127 triangle_uuids = []
1128 for i in range(faces.shape[0]):
1129 # Get vertex indices for this triangle
1130 v0_idx, v1_idx, v2_idx = faces_int[i]
1131
1132 # Get vertex coordinates as vec3 objects
1133 vertex0 = vec3(vertices_float[v0_idx][0], vertices_float[v0_idx][1], vertices_float[v0_idx][2])
1134 vertex1 = vec3(vertices_float[v1_idx][0], vertices_float[v1_idx][1], vertices_float[v1_idx][2])
1135 vertex2 = vec3(vertices_float[v2_idx][0], vertices_float[v2_idx][1], vertices_float[v2_idx][2])
1136
1137 # Get UV coordinates as vec2 objects
1138 uv0 = vec2(uv_coords_float[v0_idx][0], uv_coords_float[v0_idx][1])
1139 uv1 = vec2(uv_coords_float[v1_idx][0], uv_coords_float[v1_idx][1])
1140 uv2 = vec2(uv_coords_float[v2_idx][0], uv_coords_float[v2_idx][1])
1141
1142 # Use texture file based on material ID for this triangle
1143 material_id = material_ids[i]
1144 texture_file = texture_file_list[material_id]
1145
1146 # Add textured triangle using the new addTriangleTextured method
1147 uuid = self.addTriangleTextured(vertex0, vertex1, vertex2, texture_file, uv0, uv1, uv2)
1148 triangle_uuids.append(uuid)
1149
1150 return triangle_uuids
1151
1152 # ==================== PRIMITIVE DATA METHODS ====================
1153 # Primitive data is a flexible key-value store where users can associate
1154 # arbitrary data with primitives using string keys
1155
1156 def setPrimitiveDataInt(self, uuid: int, label: str, value: int) -> None:
1157 """
1158 Set primitive data as signed 32-bit integer.
1159
1160 Args:
1161 uuid: UUID of the primitive
1162 label: String key for the data
1163 value: Signed integer value
1164 """
1165 context_wrapper.setPrimitiveDataInt(self.context, uuid, label, value)
1166
1167 def setPrimitiveDataUInt(self, uuid: int, label: str, value: int) -> None:
1168 """
1169 Set primitive data as unsigned 32-bit integer.
1170
1171 Critical for properties like 'twosided_flag' which must be uint in C++.
1172
1173 Args:
1174 uuid: UUID of the primitive
1175 label: String key for the data
1176 value: Unsigned integer value (will be cast to uint32)
1177 """
1178 context_wrapper.setPrimitiveDataUInt(self.context, uuid, label, value)
1179
1180 def setPrimitiveDataFloat(self, uuid: int, label: str, value: float) -> None:
1181 """
1182 Set primitive data as 32-bit float.
1183
1184 Args:
1185 uuid: UUID of the primitive
1186 label: String key for the data
1187 value: Float value
1188 """
1189 context_wrapper.setPrimitiveDataFloat(self.context, uuid, label, value)
1191 def setPrimitiveDataString(self, uuid: int, label: str, value: str) -> None:
1192 """
1193 Set primitive data as string.
1194
1195 Args:
1196 uuid: UUID of the primitive
1197 label: String key for the data
1198 value: String value
1199 """
1200 context_wrapper.setPrimitiveDataString(self.context, uuid, label, value)
1201
1202 def getPrimitiveData(self, uuid: int, label: str, data_type: type = None):
1203 """
1204 Get primitive data for a specific primitive. If data_type is provided, it works like before.
1205 If data_type is None, it automatically detects the type and returns the appropriate value.
1206
1207 Args:
1208 uuid: UUID of the primitive
1209 label: String key for the data
1210 data_type: Optional. Python type to retrieve (int, uint, float, double, bool, str, vec2, vec3, vec4, int2, int3, int4, etc.)
1211 If None, auto-detects the type using C++ getPrimitiveDataType().
1212
1213 Returns:
1214 The stored value of the specified or auto-detected type
1215 """
1216 # If no type specified, use auto-detection
1217 if data_type is None:
1218 return context_wrapper.getPrimitiveDataAuto(self.context, uuid, label)
1219
1220 # Handle basic types (original behavior when type is specified)
1221 if data_type == int:
1222 return context_wrapper.getPrimitiveDataInt(self.context, uuid, label)
1223 elif data_type == float:
1224 return context_wrapper.getPrimitiveDataFloat(self.context, uuid, label)
1225 elif data_type == bool:
1226 # Bool is not supported by Helios core - get as int and convert
1227 int_value = context_wrapper.getPrimitiveDataInt(self.context, uuid, label)
1228 return int_value != 0
1229 elif data_type == str:
1230 return context_wrapper.getPrimitiveDataString(self.context, uuid, label)
1231
1232 # Handle Helios vector types
1233 elif data_type == vec2:
1234 coords = context_wrapper.getPrimitiveDataVec2(self.context, uuid, label)
1235 return vec2(coords[0], coords[1])
1236 elif data_type == vec3:
1237 coords = context_wrapper.getPrimitiveDataVec3(self.context, uuid, label)
1238 return vec3(coords[0], coords[1], coords[2])
1239 elif data_type == vec4:
1240 coords = context_wrapper.getPrimitiveDataVec4(self.context, uuid, label)
1241 return vec4(coords[0], coords[1], coords[2], coords[3])
1242 elif data_type == int2:
1243 coords = context_wrapper.getPrimitiveDataInt2(self.context, uuid, label)
1244 return int2(coords[0], coords[1])
1245 elif data_type == int3:
1246 coords = context_wrapper.getPrimitiveDataInt3(self.context, uuid, label)
1247 return int3(coords[0], coords[1], coords[2])
1248 elif data_type == int4:
1249 coords = context_wrapper.getPrimitiveDataInt4(self.context, uuid, label)
1250 return int4(coords[0], coords[1], coords[2], coords[3])
1251
1252 # Handle extended numeric types (require explicit specification since Python doesn't have these as distinct types)
1253 elif data_type == "uint":
1254 return context_wrapper.getPrimitiveDataUInt(self.context, uuid, label)
1255 elif data_type == "double":
1256 return context_wrapper.getPrimitiveDataDouble(self.context, uuid, label)
1257
1258 # Handle list return types (for convenience)
1259 elif data_type == list:
1260 # Default to vec3 as list for backward compatibility
1261 return context_wrapper.getPrimitiveDataVec3(self.context, uuid, label)
1262 elif data_type == "list_vec2":
1263 return context_wrapper.getPrimitiveDataVec2(self.context, uuid, label)
1264 elif data_type == "list_vec4":
1265 return context_wrapper.getPrimitiveDataVec4(self.context, uuid, label)
1266 elif data_type == "list_int2":
1267 return context_wrapper.getPrimitiveDataInt2(self.context, uuid, label)
1268 elif data_type == "list_int3":
1269 return context_wrapper.getPrimitiveDataInt3(self.context, uuid, label)
1270 elif data_type == "list_int4":
1271 return context_wrapper.getPrimitiveDataInt4(self.context, uuid, label)
1272
1273 else:
1274 raise ValueError(f"Unsupported primitive data type: {data_type}. "
1275 f"Supported types: int, float, bool, str, vec2, vec3, vec4, int2, int3, int4, "
1276 f"'uint', 'double', list (for vec3), 'list_vec2', 'list_vec4', 'list_int2', 'list_int3', 'list_int4'")
1277
1278 def doesPrimitiveDataExist(self, uuid: int, label: str) -> bool:
1279 """
1280 Check if primitive data exists for a specific primitive and label.
1281
1282 Args:
1283 uuid: UUID of the primitive
1284 label: String key for the data
1285
1286 Returns:
1287 True if the data exists, False otherwise
1288 """
1289 return context_wrapper.doesPrimitiveDataExistWrapper(self.context, uuid, label)
1290
1291 def getPrimitiveDataFloat(self, uuid: int, label: str) -> float:
1292 """
1293 Convenience method to get float primitive data.
1294
1295 Args:
1296 uuid: UUID of the primitive
1297 label: String key for the data
1298
1299 Returns:
1300 Float value stored for the primitive
1301 """
1302 return self.getPrimitiveData(uuid, label, float)
1303
1304 def getPrimitiveDataType(self, uuid: int, label: str) -> int:
1305 """
1306 Get the Helios data type of primitive data.
1307
1308 Args:
1309 uuid: UUID of the primitive
1310 label: String key for the data
1311
1312 Returns:
1313 HeliosDataType enum value as integer
1314 """
1315 return context_wrapper.getPrimitiveDataTypeWrapper(self.context, uuid, label)
1316
1317 def getPrimitiveDataSize(self, uuid: int, label: str) -> int:
1318 """
1319 Get the size/length of primitive data (for vector data).
1320
1321 Args:
1322 uuid: UUID of the primitive
1323 label: String key for the data
1324
1325 Returns:
1326 Size of data array, or 1 for scalar data
1327 """
1328 return context_wrapper.getPrimitiveDataSizeWrapper(self.context, uuid, label)
1329
1330 def getPrimitiveDataArray(self, uuids: List[int], label: str) -> np.ndarray:
1331 """
1332 Get primitive data values for multiple primitives as a NumPy array.
1333
1334 This method retrieves primitive data for a list of UUIDs and returns the values
1335 as a NumPy array. The output array has the same length as the input UUID list,
1336 with each index corresponding to the primitive data value for that UUID.
1337
1338 Args:
1339 uuids: List of primitive UUIDs to get data for
1340 label: String key for the primitive data to retrieve
1341
1342 Returns:
1343 NumPy array of primitive data values corresponding to each UUID.
1344 The array type depends on the data type:
1345 - int data: int32 array
1346 - uint data: uint32 array
1347 - float data: float32 array
1348 - double data: float64 array
1349 - vector data: float32 array with shape (N, vector_size)
1350 - string data: object array of strings
1351
1352 Raises:
1353 ValueError: If UUID list is empty or UUIDs don't exist
1354 RuntimeError: If context is in mock mode or data doesn't exist for some UUIDs
1355 """
1357
1358 if not uuids:
1359 raise ValueError("UUID list cannot be empty")
1360
1361 # First validate that all UUIDs exist
1362 for uuid in uuids:
1363 self._validate_uuid(uuid)
1364
1365 # Then check that all UUIDs have the specified data
1366 for uuid in uuids:
1367 if not self.doesPrimitiveDataExist(uuid, label):
1368 raise ValueError(f"Primitive data '{label}' does not exist for UUID {uuid}")
1369
1370 # Get data type from the first UUID to determine array type
1371 first_uuid = uuids[0]
1372 data_type = self.getPrimitiveDataType(first_uuid, label)
1373
1374 # Map Helios data types to NumPy array creation
1375 # Based on HeliosDataType enum from Helios core
1376 if data_type == 0: # HELIOS_TYPE_INT
1377 result = np.empty(len(uuids), dtype=np.int32)
1378 for i, uuid in enumerate(uuids):
1379 result[i] = self.getPrimitiveData(uuid, label, int)
1380
1381 elif data_type == 1: # HELIOS_TYPE_UINT
1382 result = np.empty(len(uuids), dtype=np.uint32)
1383 for i, uuid in enumerate(uuids):
1384 result[i] = self.getPrimitiveData(uuid, label, "uint")
1385
1386 elif data_type == 2: # HELIOS_TYPE_FLOAT
1387 result = np.empty(len(uuids), dtype=np.float32)
1388 for i, uuid in enumerate(uuids):
1389 result[i] = self.getPrimitiveData(uuid, label, float)
1390
1391 elif data_type == 3: # HELIOS_TYPE_DOUBLE
1392 result = np.empty(len(uuids), dtype=np.float64)
1393 for i, uuid in enumerate(uuids):
1394 result[i] = self.getPrimitiveData(uuid, label, "double")
1395
1396 elif data_type == 4: # HELIOS_TYPE_VEC2
1397 result = np.empty((len(uuids), 2), dtype=np.float32)
1398 for i, uuid in enumerate(uuids):
1399 vec_data = self.getPrimitiveData(uuid, label, vec2)
1400 result[i] = [vec_data.x, vec_data.y]
1401
1402 elif data_type == 5: # HELIOS_TYPE_VEC3
1403 result = np.empty((len(uuids), 3), dtype=np.float32)
1404 for i, uuid in enumerate(uuids):
1405 vec_data = self.getPrimitiveData(uuid, label, vec3)
1406 result[i] = [vec_data.x, vec_data.y, vec_data.z]
1407
1408 elif data_type == 6: # HELIOS_TYPE_VEC4
1409 result = np.empty((len(uuids), 4), dtype=np.float32)
1410 for i, uuid in enumerate(uuids):
1411 vec_data = self.getPrimitiveData(uuid, label, vec4)
1412 result[i] = [vec_data.x, vec_data.y, vec_data.z, vec_data.w]
1413
1414 elif data_type == 7: # HELIOS_TYPE_INT2
1415 result = np.empty((len(uuids), 2), dtype=np.int32)
1416 for i, uuid in enumerate(uuids):
1417 int_data = self.getPrimitiveData(uuid, label, int2)
1418 result[i] = [int_data.x, int_data.y]
1419
1420 elif data_type == 8: # HELIOS_TYPE_INT3
1421 result = np.empty((len(uuids), 3), dtype=np.int32)
1422 for i, uuid in enumerate(uuids):
1423 int_data = self.getPrimitiveData(uuid, label, int3)
1424 result[i] = [int_data.x, int_data.y, int_data.z]
1425
1426 elif data_type == 9: # HELIOS_TYPE_INT4
1427 result = np.empty((len(uuids), 4), dtype=np.int32)
1428 for i, uuid in enumerate(uuids):
1429 int_data = self.getPrimitiveData(uuid, label, int4)
1430 result[i] = [int_data.x, int_data.y, int_data.z, int_data.w]
1431
1432 elif data_type == 10: # HELIOS_TYPE_STRING
1433 result = np.empty(len(uuids), dtype=object)
1434 for i, uuid in enumerate(uuids):
1435 result[i] = self.getPrimitiveData(uuid, label, str)
1436
1437 else:
1438 raise ValueError(f"Unsupported primitive data type: {data_type}")
1439
1440 return result
1441
1442
1443 def colorPrimitiveByDataPseudocolor(self, uuids: List[int], primitive_data: str,
1444 colormap: str = "hot", ncolors: int = 10,
1445 max_val: Optional[float] = None, min_val: Optional[float] = None):
1446 """
1447 Color primitives based on primitive data values using pseudocolor mapping.
1448
1449 This method applies a pseudocolor mapping to primitives based on the values
1450 of specified primitive data. The primitive colors are updated to reflect the
1451 data values using a color map.
1452
1453 Args:
1454 uuids: List of primitive UUIDs to color
1455 primitive_data: Name of primitive data to use for coloring (e.g., "radiation_flux_SW")
1456 colormap: Color map name - options include "hot", "cool", "parula", "rainbow", "gray", "lava"
1457 ncolors: Number of discrete colors in color map (default: 10)
1458 max_val: Maximum value for color scale (auto-determined if None)
1459 min_val: Minimum value for color scale (auto-determined if None)
1460 """
1461 if max_val is not None and min_val is not None:
1462 context_wrapper.colorPrimitiveByDataPseudocolorWithRange(
1463 self.context, uuids, primitive_data, colormap, ncolors, max_val, min_val)
1464 else:
1465 context_wrapper.colorPrimitiveByDataPseudocolor(
1466 self.context, uuids, primitive_data, colormap, ncolors)
1467
1468 # Context time/date methods for solar position integration
1469 def setTime(self, hour: int, minute: int = 0, second: int = 0):
1470 """
1471 Set the simulation time.
1472
1473 Args:
1474 hour: Hour (0-23)
1475 minute: Minute (0-59), defaults to 0
1476 second: Second (0-59), defaults to 0
1477
1478 Raises:
1479 ValueError: If time values are out of range
1480 NotImplementedError: If time/date functions not available in current library build
1481
1482 Example:
1483 >>> context.setTime(14, 30) # Set to 2:30 PM
1484 >>> context.setTime(9, 15, 30) # Set to 9:15:30 AM
1485 """
1486 context_wrapper.setTime(self.context, hour, minute, second)
1487
1488 def setDate(self, year: int, month: int, day: int):
1489 """
1490 Set the simulation date.
1491
1492 Args:
1493 year: Year (1900-3000)
1494 month: Month (1-12)
1495 day: Day (1-31)
1496
1497 Raises:
1498 ValueError: If date values are out of range
1499 NotImplementedError: If time/date functions not available in current library build
1500
1501 Example:
1502 >>> context.setDate(2023, 6, 21) # Set to June 21, 2023
1503 """
1504 context_wrapper.setDate(self.context, year, month, day)
1505
1506 def setDateJulian(self, julian_day: int, year: int):
1507 """
1508 Set the simulation date using Julian day number.
1509
1510 Args:
1511 julian_day: Julian day (1-366)
1512 year: Year (1900-3000)
1514 Raises:
1515 ValueError: If values are out of range
1516 NotImplementedError: If time/date functions not available in current library build
1517
1518 Example:
1519 >>> context.setDateJulian(172, 2023) # Set to day 172 of 2023 (June 21)
1520 """
1521 context_wrapper.setDateJulian(self.context, julian_day, year)
1522
1523 def getTime(self):
1524 """
1525 Get the current simulation time.
1526
1527 Returns:
1528 Tuple of (hour, minute, second) as integers
1529
1530 Raises:
1531 NotImplementedError: If time/date functions not available in current library build
1532
1533 Example:
1534 >>> hour, minute, second = context.getTime()
1535 >>> print(f"Current time: {hour:02d}:{minute:02d}:{second:02d}")
1536 """
1537 return context_wrapper.getTime(self.context)
1538
1539 def getDate(self):
1540 """
1541 Get the current simulation date.
1542
1543 Returns:
1544 Tuple of (year, month, day) as integers
1545
1546 Raises:
1547 NotImplementedError: If time/date functions not available in current library build
1548
1549 Example:
1550 >>> year, month, day = context.getDate()
1551 >>> print(f"Current date: {year}-{month:02d}-{day:02d}")
1552 """
1553 return context_wrapper.getDate(self.context)
1554
1555 # Plugin-related methods
1556 def get_available_plugins(self) -> List[str]:
1557 """
1558 Get list of available plugins for this PyHelios instance.
1559
1560 Returns:
1561 List of available plugin names
1562 """
1564
1565 def is_plugin_available(self, plugin_name: str) -> bool:
1566 """
1567 Check if a specific plugin is available.
1568
1569 Args:
1570 plugin_name: Name of the plugin to check
1571
1572 Returns:
1573 True if plugin is available, False otherwise
1574 """
1575 return self._plugin_registry.is_plugin_available(plugin_name)
1576
1577 def get_plugin_capabilities(self) -> dict:
1578 """
1579 Get detailed information about available plugin capabilities.
1580
1581 Returns:
1582 Dictionary mapping plugin names to capability information
1583 """
1585
1586 def print_plugin_status(self):
1587 """Print detailed plugin status information."""
1588 self._plugin_registry.print_status()
1589
1590 def get_missing_plugins(self, requested_plugins: List[str]) -> List[str]:
1591 """
1592 Get list of requested plugins that are not available.
1593
1594 Args:
1595 requested_plugins: List of plugin names to check
1596
1597 Returns:
1598 List of missing plugin names
1599 """
1600 return self._plugin_registry.get_missing_plugins(requested_plugins)
1601
Central simulation environment for PyHelios that manages 3D primitives and their data.
Definition Context.py:77
_validate_uuid(self, int uuid)
Validate that a UUID exists in this context.
Definition Context.py:176
List[int] getAllUUIDs(self)
Definition Context.py:424
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:734
str _validate_output_file_path(self, str filename, List[str] expected_extensions=None)
Validate and normalize output file path for security.
Definition Context.py:254
np.ndarray getPrimitiveDataArray(self, List[int] uuids, str label)
Get primitive data values for multiple primitives as a NumPy array.
Definition Context.py:1381
bool is_plugin_available(self, str plugin_name)
Check if a specific plugin is available.
Definition Context.py:1610
List[int] getAllObjectIDs(self)
Definition Context.py:434
List[str] get_available_plugins(self)
Get list of available plugins for this PyHelios instance.
Definition Context.py:1598
__exit__(self, exc_type, exc_value, traceback)
Definition Context.py:287
RGBcolor getPrimitiveColor(self, int uuid)
Definition Context.py:415
bool doesPrimitiveDataExist(self, int uuid, str label)
Check if primitive data exists for a specific primitive and label.
Definition Context.py:1314
float getPrimitiveArea(self, int uuid)
Definition Context.py:396
int getPrimitiveCount(self)
Definition Context.py:420
List[PrimitiveInfo] getAllPrimitiveInfo(self)
Get physical properties and geometry information for all primitives in the context.
Definition Context.py:473
None writePLY(self, str filename, Optional[List[int]] UUIDs=None)
Write geometry to a PLY (Stanford Polygon) file.
Definition Context.py:892
List[PrimitiveInfo] getPrimitivesInfoForObject(self, int object_id)
Get physical properties and geometry information for all primitives belonging to a specific object.
Definition Context.py:486
print_plugin_status(self)
Print detailed plugin status information.
Definition Context.py:1623
setDate(self, int year, int month, int day)
Set the simulation date.
Definition Context.py:1533
PrimitiveType getPrimitiveType(self, int uuid)
Definition Context.py:391
getPrimitiveData(self, int uuid, str label, type data_type=None)
Get primitive data for a specific primitive.
Definition Context.py:1241
List[str] get_missing_plugins(self, List[str] requested_plugins)
Get list of requested plugins that are not available.
Definition Context.py:1635
bool isGeometryDirty(self)
Definition Context.py:306
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:1486
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:522
_check_context_available(self)
Helper method to check if context is available with detailed error messages.
Definition Context.py:132
None setPrimitiveDataUInt(self, int uuid, str label, int value)
Set primitive data as unsigned 32-bit integer.
Definition Context.py:1203
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:311
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:828
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:653
str _validate_file_path(self, str filename, List[str] expected_extensions=None)
Validate and normalize file path for security.
Definition Context.py:211
None setPrimitiveDataInt(self, int uuid, str label, int value)
Set primitive data as signed 32-bit integer.
Definition Context.py:1190
None setPrimitiveDataString(self, int uuid, str label, str value)
Set primitive data as string.
Definition Context.py:1225
None setPrimitiveDataFloat(self, int uuid, str label, float value)
Set primitive data as 32-bit float.
Definition Context.py:1214
PrimitiveInfo getPrimitiveInfo(self, int uuid)
Get physical properties and geometry information for a single primitive.
Definition Context.py:449
int getPrimitiveDataSize(self, int uuid, str label)
Get the size/length of primitive data (for vector data).
Definition Context.py:1353
vec3 getPrimitiveNormal(self, int uuid)
Definition Context.py:400
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:376
int getPrimitiveDataType(self, int uuid, str label)
Get the Helios data type of primitive data.
Definition Context.py:1340
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:780
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:938
List[vec3] getPrimitiveVertices(self, int uuid)
Definition Context.py:406
float getPrimitiveDataFloat(self, int uuid, str label)
Convenience method to get float primitive data.
Definition Context.py:1327
dict get_plugin_capabilities(self)
Get detailed information about available plugin capabilities.
Definition Context.py:1619
getTime(self)
Get the current simulation time.
Definition Context.py:1570
setDateJulian(self, int julian_day, int year)
Set the simulation date using Julian day number.
Definition Context.py:1552
int addTriangle(self, vec3 vertex0, vec3 vertex1, vec3 vertex2, Optional[RGBcolor] color=None)
Add a triangle primitive to the context.
Definition Context.py:331
getDate(self)
Get the current simulation date.
Definition Context.py:1588
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:1084
List[int] loadXML(self, str filename, bool quiet=False)
Load geometry from a Helios XML file.
Definition Context.py:866
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:591
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:990
setTime(self, int hour, int minute=0, int second=0)
Set the simulation time.
Definition Context.py:1513
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