0.1.33
Loading...
Searching...
No Matches
Context.py
Go to the documentation of this file.
1import ctypes
2import warnings
3from dataclasses import dataclass
4from typing import List, Optional, Union
5from enum import Enum
6
7import numpy as np
8
9from .wrappers import UContextWrapper as context_wrapper
10from .wrappers.DataTypes import vec2, vec3, vec4, int2, int3, int4, SphericalCoord, RGBcolor, RGBAcolor, PrimitiveType, Date, Time, Location, AdaptiveTileRefinement, VertexNormalSource, VertexWeldMode
11from .exceptions import HeliosError
12from .plugins.loader import LibraryLoadError, validate_library, get_library_info
13from .plugins.registry import get_plugin_registry
14from .validation.geometry import (
15 validate_patch_params, validate_triangle_params, validate_sphere_params,
16 validate_tube_params, validate_box_params
17)
18
19# HeliosDataType values served by a single-call bulk getter. Float (2) has its own
20# reader; string (10) has no bulk variant and still reads one primitive at a time.
21_BULK_PRIMITIVE_DATA_TYPES = frozenset({0, 1, 3, 4, 5, 6, 7, 8, 9})
22
23
24@dataclass
25class PrimitiveInfo:
26 """
27 Physical properties and geometry information for a primitive.
28 This is separate from primitive data (user-defined key-value pairs).
29 """
30 uuid: int
31 primitive_type: PrimitiveType
32 area: float
33 normal: vec3
34 vertices: List[vec3]
35 color: RGBcolor
36 centroid: Optional[vec3] = None
37 texture_file: Optional[str] = None
38 texture_uv: Optional[List[vec2]] = None
39 solid_fraction: Optional[float] = None
41 def __post_init__(self):
42 """Calculate centroid from vertices if not provided."""
43 if self.centroid is None and self.vertices:
44 # Calculate centroid as average of vertices
45 total_x = sum(v.x for v in self.vertices)
46 total_y = sum(v.y for v in self.vertices)
47 total_z = sum(v.z for v in self.vertices)
48 count = len(self.vertices)
49 self.centroid = vec3(total_x / count, total_y / count, total_z / count)
50
51
52class Context:
53 """
54 Central simulation environment for PyHelios that manages 3D primitives and their data.
55
56 The Context class provides methods for:
57 - Creating geometric primitives (patches, triangles)
58 - Creating compound geometry (tiles, spheres, tubes, boxes)
59 - Loading 3D models from files (PLY, OBJ, XML)
60 - Managing primitive data (flexible key-value storage)
61 - Querying primitive properties and collections
62 - Batch operations on multiple primitives
63
64 Key features:
65 - UUID-based primitive tracking
66 - Comprehensive primitive data system with auto-type detection
67 - Efficient array-based data retrieval via getPrimitiveDataArray()
68 - Cross-platform compatibility with mock mode support
69 - Context manager protocol for resource cleanup
70
71 Example:
72 >>> with Context() as context:
73 ... # Create primitives
74 ... patch_uuid = context.addPatch(center=vec3(0, 0, 0))
75 ... triangle_uuid = context.addTriangle(vec3(0,0,0), vec3(1,0,0), vec3(0.5,1,0))
76 ...
77 ... # Set primitive data
78 ... context.setPrimitiveDataFloat(patch_uuid, "temperature", 25.5)
79 ... context.setPrimitiveDataFloat(triangle_uuid, "temperature", 30.2)
80 ...
81 ... # Get data efficiently as NumPy array
82 ... temps = context.getPrimitiveDataArray([patch_uuid, triangle_uuid], "temperature")
83 ... print(temps) # [25.5 30.2]
84 """
85
86 def __init__(self):
87 # Initialize plugin registry for availability checking
88 self._plugin_registry = get_plugin_registry()
90 # Track Context lifecycle state for better error messages
91 self._lifecycle_state = 'initializing'
92
93 # Check if we're in mock/development mode
94 library_info = get_library_info()
95 if library_info.get('is_mock', False):
96 # In mock mode, don't validate but warn that functionality is limited
97 print("Warning: PyHelios running in development mock mode - functionality is limited")
98 print("Available plugins: None (mock mode)")
99 self.context = None # Mock context
100 self._lifecycle_state = 'mock_mode'
101 return
103 # Validate native library is properly loaded before creating context
104 try:
105 if not validate_library():
106 raise LibraryLoadError(
107 "Native Helios library validation failed. Some required functions are missing. "
108 "Try rebuilding the native library: build_scripts/build_helios"
109 )
110 except LibraryLoadError:
111 raise
112 except Exception as e:
113 raise LibraryLoadError(
114 f"Failed to validate native Helios library: {e}. "
115 f"To enable development mode without native libraries, set PYHELIOS_DEV_MODE=1"
116 )
117
118 # Create the context - this will fail if library isn't properly loaded
119 try:
120 self.context = context_wrapper.createContext()
121 if self.context is None:
122 self._lifecycle_state = 'creation_failed'
123 raise LibraryLoadError(
124 "Failed to create Helios context. Native library may not be functioning correctly."
125 )
126
127 self._lifecycle_state = 'active'
128
129 except Exception as e:
130 self._lifecycle_state = 'creation_failed'
131 raise LibraryLoadError(
132 f"Failed to create Helios context: {e}. "
133 f"Ensure native libraries are built and accessible."
134 )
135
136 def _check_context_available(self):
137 """Helper method to check if context is available with detailed error messages."""
138 if self.context is None:
139 # Provide specific error message based on lifecycle state
140 if self._lifecycle_state == 'mock_mode':
141 raise RuntimeError(
142 "Context is in mock mode - native functionality not available.\n"
143 "Build native libraries with 'python build_scripts/build_helios.py' or set PYHELIOS_DEV_MODE=1 for development."
144 )
145 elif self._lifecycle_state == 'cleaned_up':
146 raise RuntimeError(
147 "Context has been cleaned up and is no longer usable.\n"
148 "This usually means you're trying to use a Context outside its 'with' statement scope.\n"
149 "\n"
150 "Fix: Ensure all Context usage is inside the 'with Context() as context:' block:\n"
151 " with Context() as context:\n"
152 " # All context operations must be here\n"
153 " with SomePlugin(context) as plugin:\n"
154 " plugin.do_something()\n"
155 " with Visualizer() as vis:\n"
156 " vis.buildContextGeometry(context) # Still inside Context scope\n"
157 " # Context is cleaned up here - cannot use context after this point"
158 )
159 elif self._lifecycle_state == 'creation_failed':
160 raise RuntimeError(
161 "Context creation failed - native functionality not available.\n"
162 "Build native libraries with 'python build_scripts/build_helios.py'"
163 )
164 else:
165 # Fallback for unknown states
166 raise RuntimeError(
167 f"Context is not available (state: {self._lifecycle_state}).\n"
168 "Build native libraries with 'python build_scripts/build_helios.py' or set PYHELIOS_DEV_MODE=1 for development."
169 )
170
171 def _validate_uuid(self, uuid: int):
172 """Validate that a UUID exists in this context.
173
174 Args:
175 uuid: The UUID to validate
176
177 Raises:
178 RuntimeError: If UUID is invalid or doesn't exist in context
179 """
180 self._validate_uuids((uuid,))
181
182 def _validate_uuids(self, uuids):
183 """Validate that every UUID in ``uuids`` exists in this context.
184
185 Use this for any UUID list rather than calling :meth:`_validate_uuid` in a
186 loop. Existence is checked against the context's UUID list, which must be
187 fetched from the native layer and searched; doing that per element makes
188 validating N UUIDs O(N^2) with N native round-trips. At 20,000 primitives
189 that cost ~15 s, swamping the work being validated. Fetching once and
190 testing set membership makes it linear.
191
192 Checks run per element in order -- type first, then existence -- so the
193 error raised for a given list is the same one the per-element loop raised.
194 The context's UUID list is fetched lazily, on the first element that needs
195 it, and reused for the rest of the call.
196
197 Args:
198 uuids: Iterable of UUIDs to validate
199
200 Raises:
201 RuntimeError: If any UUID is invalid or doesn't exist in context
202 """
203 valid_uuids = None
204 valid_set = None
205 looked_up = False
206
207 for uuid in uuids:
208 # First check if it's a reasonable UUID value
209 if not isinstance(uuid, int) or uuid < 0:
210 raise RuntimeError(f"Invalid UUID: {uuid}. UUIDs must be non-negative integers.")
212 if not looked_up:
213 looked_up = True
214 # Check existence against all valid UUIDs, fetched once for the
215 # whole list rather than once per element.
216 try:
217 valid_uuids = self.getAllUUIDs()
218 valid_set = set(valid_uuids)
219 except RuntimeError:
220 # Re-raise RuntimeError (validation failed)
221 raise
222 except Exception:
223 # If we can't get valid UUIDs due to other issues (e.g., mock mode), skip validation
224 # The _check_context_available() call will have already caught mock mode
225 valid_set = None
226
227 if valid_set is not None and uuid not in valid_set:
228 raise RuntimeError(f"UUID {uuid} does not exist in context. Valid UUIDs: {valid_uuids[:10]}{'...' if len(valid_uuids) > 10 else ''}")
229
230
231 def _validate_file_path(self, filename: str, expected_extensions: List[str] = None) -> str:
232 """Validate and normalize file path for security.
233
234 Args:
235 filename: 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
242 Raises:
243 ValueError: If path is invalid or potentially dangerous
244 FileNotFoundError: If file does not exist
245 """
246 import os.path
247
248 # Convert to absolute path and normalize
249 abs_path = os.path.abspath(filename)
250
251 # Check for path traversal attempts by verifying the resolved path is safe
252 # Allow relative paths with .. as long as they resolve to valid absolute paths
253 normalized_path = os.path.normpath(abs_path)
254 if abs_path != normalized_path:
255 raise ValueError(f"Invalid file path (potential path traversal): {filename}")
257 # Check file extension first (before checking existence) - better UX
258 if expected_extensions:
259 file_ext = os.path.splitext(abs_path)[1].lower()
260 if file_ext not in [ext.lower() for ext in expected_extensions]:
261 raise ValueError(f"Invalid file extension '{file_ext}'. Expected one of: {expected_extensions}")
262
263 # Check if file exists
264 if not os.path.exists(abs_path):
265 raise FileNotFoundError(f"File not found: {abs_path}")
266
267 # Check if it's actually a file (not a directory)
268 if not os.path.isfile(abs_path):
269 raise ValueError(f"Path is not a file: {abs_path}")
270
271 return abs_path
272
273 def _validate_output_file_path(self, filename: str, expected_extensions: List[str] = None) -> str:
274 """Validate and normalize output file path for security.
275
276 Args:
277 filename: Output file path to validate
278 expected_extensions: List of allowed file extensions (e.g., ['.ply', '.obj'])
279
280 Returns:
281 Normalized absolute path
282
283 Raises:
284 ValueError: If path is invalid or potentially dangerous
285 PermissionError: If output directory is not writable
286 """
287 import os.path
288
289 # Check for empty filename
290 if not filename or not filename.strip():
291 raise ValueError("Filename cannot be empty")
292
293 # Convert to absolute path and normalize
294 abs_path = os.path.abspath(filename)
295
296 # Check for path traversal attempts
297 normalized_path = os.path.normpath(abs_path)
298 if abs_path != normalized_path:
299 raise ValueError(f"Invalid file path (potential path traversal): {filename}")
300
301 # Check file extension
302 if expected_extensions:
303 file_ext = os.path.splitext(abs_path)[1].lower()
304 if file_ext not in [ext.lower() for ext in expected_extensions]:
305 raise ValueError(f"Invalid file extension '{file_ext}'. Expected one of: {expected_extensions}")
306
307 # Check if output directory exists and is writable
308 output_dir = os.path.dirname(abs_path)
309 if not os.path.exists(output_dir):
310 raise ValueError(f"Output directory does not exist: {output_dir}")
311 if not os.access(output_dir, os.W_OK):
312 raise PermissionError(f"Output directory is not writable: {output_dir}")
313
314 return abs_path
315
316 def __enter__(self):
317 return self
318
319 def __exit__(self, exc_type, exc_value, traceback):
320 if self.context is not None:
321 context_wrapper.destroyContext(self.context)
322 self.context = None # Prevent double deletion
323 self._lifecycle_state = 'cleaned_up'
324
325 def __del__(self):
326 """Destructor to ensure C++ resources freed even without 'with' statement."""
327 if hasattr(self, 'context') and self.context is not None:
328 try:
329 context_wrapper.destroyContext(self.context)
330 self.context = None
331 self._lifecycle_state = 'cleaned_up'
332 except Exception as e:
333 # __del__ may run during interpreter shutdown, when module
334 # globals and the import machinery are already torn down. Both
335 # the warn and any fallback must therefore be able to fail
336 # without escaping: an exception here cannot propagate to the
337 # caller, it only produces an "Exception ignored in" traceback
338 # that hides the original error.
339 try:
340 warnings.warn(f"Error in Context.__del__: {e}")
341 except BaseException:
342 pass
343
344 def getNativePtr(self):
346 return self.context
347
348 def markGeometryClean(self):
350 context_wrapper.markGeometryClean(self.context)
351
352 def markGeometryDirty(self):
354 context_wrapper.markGeometryDirty(self.context)
355
356
357 def isGeometryDirty(self) -> bool:
359 return context_wrapper.isGeometryDirty(self.context)
360
361 def seedRandomGenerator(self, seed: int):
362 """
363 Seed the random number generator for reproducible stochastic results.
364
365 Args:
366 seed: Integer seed value for random number generation
367
368 Note:
369 This is critical for reproducible results in stochastic simulations
370 (e.g., LiDAR scans with beam divergence, random perturbations).
371 """
373 context_wrapper.helios_lib.seedRandomGenerator(self.context, seed)
374
375 @validate_patch_params
376 def addPatch(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1), rotation: Optional[SphericalCoord] = None, color: Optional[RGBcolor] = None) -> int:
378 rotation = rotation or SphericalCoord(1, 0, 0) # radius=1, elevation=0, azimuth=0 (no effective rotation)
379 color = color or RGBcolor(1, 1, 1)
380 # C++ interface expects [radius, elevation, azimuth] (3 values), not [radius, elevation, zenith, azimuth] (4 values)
381 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
382 return context_wrapper.addPatchWithCenterSizeRotationAndColor(self.context, center.to_list(), size.to_list(), rotation_list, color.to_list())
383
384 def addPatchTextured(self, center: vec3, size: vec2, texture_file: str,
385 rotation: Optional[SphericalCoord] = None,
386 uv_center: Optional[vec2] = None,
387 uv_size: Optional[vec2] = None) -> int:
388 """Add a textured patch primitive to the context.
390 Creates a rectangular patch with a texture image mapped to its surface.
391
392 Args:
393 center: 3D position of the patch center
394 size: Width and height of the patch
395 texture_file: Path to texture image file (supports PNG, JPG, JPEG, TGA, BMP)
396 rotation: Optional spherical rotation (defaults to no rotation)
397 uv_center: Optional UV center of texture map (required if uv_size is provided)
398 uv_size: Optional UV size of texture map (required if uv_center is provided)
399
400 Returns:
401 UUID of the created textured patch primitive
402
403 Raises:
404 ValueError: If arguments have wrong types or UV params are partially specified
405 FileNotFoundError: If texture file doesn't exist
406 RuntimeError: If context is in mock mode
407
408 Example:
409 >>> context = Context()
410 >>> uuid = context.addPatchTextured(
411 ... center=vec3(0, 0, 0),
412 ... size=vec2(2, 2),
413 ... texture_file="texture.png"
414 ... )
415 """
417
418 if not isinstance(center, vec3):
419 raise ValueError(f"center must be a vec3, got {type(center).__name__}")
420 if not isinstance(size, vec2):
421 raise ValueError(f"size must be a vec2, got {type(size).__name__}")
422 if not isinstance(texture_file, str):
423 raise ValueError(f"texture_file must be a str, got {type(texture_file).__name__}")
424 if rotation is not None and not isinstance(rotation, SphericalCoord):
425 raise ValueError(f"rotation must be a SphericalCoord, got {type(rotation).__name__}")
426
427 if (uv_center is None) != (uv_size is None):
428 raise ValueError("uv_center and uv_size must both be provided or both omitted")
429 if uv_center is not None and not isinstance(uv_center, vec2):
430 raise ValueError(f"uv_center must be a vec2, got {type(uv_center).__name__}")
431 if uv_size is not None and not isinstance(uv_size, vec2):
432 raise ValueError(f"uv_size must be a vec2, got {type(uv_size).__name__}")
433
434 validated_texture_file = self._validate_file_path(texture_file,
435 ['.png', '.jpg', '.jpeg', '.tga', '.bmp'])
436
437 rotation = rotation or SphericalCoord(1, 0, 0)
438 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
439
440 if uv_center is not None:
441 return context_wrapper.addPatchWithTextureAndUV(
442 self.context, center.to_list(), size.to_list(), rotation_list,
443 validated_texture_file, uv_center.to_list(), uv_size.to_list()
444 )
445 else:
446 return context_wrapper.addPatchWithTexture(
447 self.context, center.to_list(), size.to_list(), rotation_list,
448 validated_texture_file
449 )
450
451 @validate_triangle_params
452 def addTriangle(self, vertex0: vec3, vertex1: vec3, vertex2: vec3, color: Optional[RGBcolor] = None) -> int:
453 """Add a triangle primitive to the context
454
455 Args:
456 vertex0: First vertex of the triangle
457 vertex1: Second vertex of the triangle
458 vertex2: Third vertex of the triangle
459 color: Optional triangle color (defaults to white)
460
461 Returns:
462 UUID of the created triangle primitive
463 """
465 if color is None:
466 return context_wrapper.addTriangle(self.context, vertex0.to_list(), vertex1.to_list(), vertex2.to_list())
467 else:
468 return context_wrapper.addTriangleWithColor(self.context, vertex0.to_list(), vertex1.to_list(), vertex2.to_list(), color.to_list())
469
470 def addTriangleTextured(self, vertex0: vec3, vertex1: vec3, vertex2: vec3,
471 texture_file: str, uv0: vec2, uv1: vec2, uv2: vec2) -> int:
472 """Add a textured triangle primitive to the context
473
474 Creates a triangle with texture mapping. The texture image is mapped to the triangle
475 surface using UV coordinates, where (0,0) represents the top-left corner of the image
476 and (1,1) represents the bottom-right corner.
477
478 Args:
479 vertex0: First vertex of the triangle
480 vertex1: Second vertex of the triangle
481 vertex2: Third vertex of the triangle
482 texture_file: Path to texture image file (supports PNG, JPG, JPEG, TGA, BMP)
483 uv0: UV texture coordinates for first vertex
484 uv1: UV texture coordinates for second vertex
485 uv2: UV texture coordinates for third vertex
486
487 Returns:
488 UUID of the created textured triangle primitive
489
490 Raises:
491 ValueError: If texture file path is invalid
492 FileNotFoundError: If texture file doesn't exist
493 RuntimeError: If context is in mock mode
494
495 Example:
496 >>> context = Context()
497 >>> # Create a textured triangle
498 >>> vertex0 = vec3(0, 0, 0)
499 >>> vertex1 = vec3(1, 0, 0)
500 >>> vertex2 = vec3(0.5, 1, 0)
501 >>> uv0 = vec2(0, 0) # Bottom-left of texture
502 >>> uv1 = vec2(1, 0) # Bottom-right of texture
503 >>> uv2 = vec2(0.5, 1) # Top-center of texture
504 >>> uuid = context.addTriangleTextured(vertex0, vertex1, vertex2,
505 ... "texture.png", uv0, uv1, uv2)
506 """
508
509 # Parameter type validation
510 for name, val in [("vertex0", vertex0), ("vertex1", vertex1), ("vertex2", vertex2)]:
511 if not isinstance(val, vec3):
512 raise ValueError(f"{name} must be a vec3, got {type(val).__name__}")
513 for name, val in [("uv0", uv0), ("uv1", uv1), ("uv2", uv2)]:
514 if not isinstance(val, vec2):
515 raise ValueError(f"{name} must be a vec2, got {type(val).__name__}")
516
517 # Validate texture file path
518 validated_texture_file = self._validate_file_path(texture_file,
519 ['.png', '.jpg', '.jpeg', '.tga', '.bmp'])
520
521 # Call the wrapper function
522 return context_wrapper.addTriangleWithTexture(
523 self.context,
524 vertex0.to_list(), vertex1.to_list(), vertex2.to_list(),
525 validated_texture_file,
526 uv0.to_list(), uv1.to_list(), uv2.to_list()
527 )
528
529 def getPrimitiveType(self, uuid):
530 """Get the type of a primitive or multiple primitives.
531
532 Args:
533 uuid: Single UUID (int) or list of UUIDs
534
535 Returns:
536 PrimitiveType for single UUID, or np.ndarray of shape (N,) uint32 for list
537 """
539 if isinstance(uuid, (list, tuple)):
540 if not uuid:
541 return np.empty((0,), dtype=np.uint32)
542 ptr, size = context_wrapper.getBatchPrimitiveTypes(self.context, uuid)
543 if size == 0 or not ptr:
544 return np.empty((0,), dtype=np.uint32)
545 return np.ctypeslib.as_array(ptr, shape=(size,)).copy()
546 primitive_type = context_wrapper.getPrimitiveType(self.context, uuid)
547 return PrimitiveType(primitive_type)
548
549 def getPrimitiveArea(self, uuid):
550 """Get the area of a primitive or multiple primitives.
551
552 Args:
553 uuid: Single UUID (int) or list of UUIDs
554
555 Returns:
556 float for single UUID, or np.ndarray of shape (N,) for list
557 """
559 if isinstance(uuid, (list, tuple)):
560 if not uuid:
561 return np.empty((0,), dtype=np.float32)
562 ptr, size = context_wrapper.getBatchPrimitiveAreas(self.context, uuid)
563 if size == 0 or not ptr:
564 return np.empty((0,), dtype=np.float32)
565 return np.ctypeslib.as_array(ptr, shape=(size,)).copy()
566 return context_wrapper.getPrimitiveArea(self.context, uuid)
567
568 def getPrimitiveNormal(self, uuid):
569 """Get the normal vector of a primitive or multiple primitives.
571 Args:
572 uuid: Single UUID (int) or list of UUIDs
573
574 Returns:
575 vec3 for single UUID, or np.ndarray of shape (N, 3) for list
576 """
578 if isinstance(uuid, (list, tuple)):
579 if not uuid:
580 return np.empty((0, 3), dtype=np.float32)
581 ptr, size = context_wrapper.getBatchPrimitiveNormals(self.context, uuid)
582 if size == 0 or not ptr:
583 return np.empty((0, 3), dtype=np.float32)
584 return np.ctypeslib.as_array(ptr, shape=(size,)).copy().reshape(-1, 3)
585 normal_ptr = context_wrapper.getPrimitiveNormal(self.context, uuid)
586 return vec3(normal_ptr[0], normal_ptr[1], normal_ptr[2])
587
588 def getPrimitiveVertices(self, uuid):
589 """Get vertices of a primitive or multiple primitives.
590
591 Args:
592 uuid: Single UUID (int) or list of UUIDs
593
594 Returns:
595 List[vec3] for single UUID, or tuple of (flat_data, offsets) for list
596 where flat_data is a float32 ndarray and offsets is a uint32 ndarray
597 of length N+1. Vertices for primitive i are at
598 flat_data[offsets[i]:offsets[i+1]].
599 """
601 if isinstance(uuid, (list, tuple)):
602 if not uuid:
603 return (np.empty((0,), dtype=np.float32), np.zeros((1,), dtype=np.uint32))
604 ptr, offsets, total = context_wrapper.getBatchPrimitiveVertices(self.context, uuid)
605 offsets_arr = np.asarray(offsets, dtype=np.uint32)
606 if total == 0 or not ptr:
607 return (np.empty((0,), dtype=np.float32), offsets_arr)
608 data = np.ctypeslib.as_array(ptr, shape=(total,)).copy()
609 return (data, offsets_arr)
610 size = ctypes.c_uint()
611 vertices_ptr = context_wrapper.getPrimitiveVertices(self.context, uuid, ctypes.byref(size))
612 # size.value is the total number of floats (3 per vertex), not the number of vertices
613 vertices_list = ctypes.cast(vertices_ptr, ctypes.POINTER(ctypes.c_float * size.value)).contents
614 vertices = [vec3(vertices_list[i], vertices_list[i+1], vertices_list[i+2]) for i in range(0, size.value, 3)]
615 return vertices
616
617 def getPrimitiveColor(self, uuid):
618 """Get the color of a primitive or multiple primitives.
619
620 Args:
621 uuid: Single UUID (int) or list of UUIDs
622
623 Returns:
624 RGBcolor for single UUID, or np.ndarray of shape (N, 3) for list
625 """
627 if isinstance(uuid, (list, tuple)):
628 if not uuid:
629 return np.empty((0, 3), dtype=np.float32)
630 ptr, size = context_wrapper.getBatchPrimitiveColors(self.context, uuid)
631 if size == 0 or not ptr:
632 return np.empty((0, 3), dtype=np.float32)
633 return np.ctypeslib.as_array(ptr, shape=(size,)).copy().reshape(-1, 3)
634 color_ptr = context_wrapper.getPrimitiveColor(self.context, uuid)
635 return RGBcolor(color_ptr[0], color_ptr[1], color_ptr[2])
636
637 def getPrimitiveCount(self) -> int:
639 return context_wrapper.getPrimitiveCount(self.context)
640
641 def doesPrimitiveExist(self, uuid) -> bool:
642 """Check if a primitive exists for a given UUID or list of UUIDs.
643
644 Args:
645 uuid: A single UUID (int) or a list of UUIDs.
646
647 Returns:
648 True if the primitive(s) exist, False otherwise.
649 For a list, returns True only if ALL primitives exist.
650 """
652 if isinstance(uuid, (list, tuple)):
653 arr = (ctypes.c_uint * len(uuid))(*uuid)
654 return context_wrapper.doesPrimitiveExistBatch(self.context, arr, len(uuid))
655 return context_wrapper.doesPrimitiveExist(self.context, uuid)
656
657 def getAllUUIDs(self) -> List[int]:
659 size = ctypes.c_uint()
660 uuids_ptr = context_wrapper.getAllUUIDs(self.context, ctypes.byref(size))
661 return list(uuids_ptr[:size.value])
662
663 def getObjectCount(self) -> int:
665 return context_wrapper.getObjectCount(self.context)
666
667 def getAllObjectIDs(self) -> List[int]:
669 size = ctypes.c_uint()
670 objectids_ptr = context_wrapper.getAllObjectIDs(self.context, ctypes.byref(size))
671 return list(objectids_ptr[:size.value])
672
673 def getPrimitiveInfo(self, uuid: int) -> PrimitiveInfo:
674 """
675 Get physical properties and geometry information for a single primitive.
677 Args:
678 uuid: UUID of the primitive
679
680 Returns:
681 PrimitiveInfo object containing physical properties and geometry
682 """
683 primitive_type = self.getPrimitiveType(uuid)
684 area = self.getPrimitiveArea(uuid)
685 normal = self.getPrimitiveNormal(uuid)
686 vertices = self.getPrimitiveVertices(uuid)
687 color = self.getPrimitiveColor(uuid)
688
689 # Texture/solid-fraction getters are absent from older library builds, in
690 # which case the wrappers raise NotImplementedError and these fields stay
691 # None. Only that specific case is tolerated - a genuine native failure
692 # must propagate rather than be reported as missing data, and each getter
693 # is attempted independently so one failure cannot suppress the others.
694 texture_file = None
695 texture_uv = None
696 solid_fraction = None
697 try:
698 tf = self.getPrimitiveTextureFile(uuid)
699 if tf:
700 texture_file = tf
701 except NotImplementedError:
702 pass
703 try:
704 texture_uv = self.getPrimitiveTextureUV(uuid)
705 if not texture_uv:
706 texture_uv = None
707 except NotImplementedError:
708 texture_uv = None
709 try:
710 solid_fraction = self.getPrimitiveSolidFraction(uuid)
711 except NotImplementedError:
712 solid_fraction = None
713
714 return PrimitiveInfo(
715 uuid=uuid,
716 primitive_type=primitive_type,
717 area=area,
718 normal=normal,
719 vertices=vertices,
720 color=color,
721 texture_file=texture_file,
722 texture_uv=texture_uv,
723 solid_fraction=solid_fraction,
724 )
725
726 def _batchPrimitiveInfo(self, uuids: List[int]) -> List[PrimitiveInfo]:
727 """Build PrimitiveInfo for many primitives with a fixed number of native calls.
728
729 Field-for-field identical to calling :meth:`getPrimitiveInfo` on each UUID,
730 but each of the eight fields has a list-accepting getter backed by a native
731 ``getBatch*`` call. Doing it per primitive costs eight native round-trips
732 each — 77.5 ms for 5,000 primitives against 4.6 ms batched.
733
734 Args:
735 uuids: Primitives to describe, in the order to return them
736
737 Returns:
738 List of PrimitiveInfo, one per UUID, in the order given
739 """
740 if not uuids:
741 return []
742
743 types = self.getPrimitiveType(uuids)
744 areas = self.getPrimitiveArea(uuids)
745 normals = self.getPrimitiveNormal(uuids)
746 vertex_data, vertex_offsets = self.getPrimitiveVertices(uuids)
747 colors = self.getPrimitiveColor(uuids)
748
749 # Same tolerance as getPrimitiveInfo(): only a getter missing from an older
750 # library build leaves these None, each attempted independently so one
751 # failure cannot suppress the others. A genuine native error propagates.
752 try:
753 texture_files = self.getPrimitiveTextureFile(uuids)
754 except NotImplementedError:
755 texture_files = None
756 try:
757 uv_data, uv_offsets = self.getPrimitiveTextureUV(uuids)
758 # A scene with no textures can come back with a degenerate offset
759 # array; treat that as "no UVs" rather than indexing off the end.
760 if uv_offsets is None or len(uv_offsets) < len(uuids) + 1:
761 uv_data, uv_offsets = None, None
762 except NotImplementedError:
763 uv_data, uv_offsets = None, None
764 try:
765 solid_fractions = self.getPrimitiveSolidFraction(uuids)
766 except NotImplementedError:
767 solid_fractions = None
768
769 infos = []
770 for i, uuid in enumerate(uuids):
771 segment = vertex_data[vertex_offsets[i]:vertex_offsets[i + 1]]
772 vertices = [vec3(float(segment[j]), float(segment[j + 1]), float(segment[j + 2]))
773 for j in range(0, len(segment), 3)]
774
775 texture_uv = None
776 if uv_data is not None:
777 uv_segment = uv_data[uv_offsets[i]:uv_offsets[i + 1]]
778 if len(uv_segment):
779 texture_uv = [vec2(float(uv_segment[j]), float(uv_segment[j + 1]))
780 for j in range(0, len(uv_segment), 2)]
781
782 texture_file = None
783 if texture_files is not None and texture_files[i]:
784 texture_file = texture_files[i]
785
786 infos.append(PrimitiveInfo(
787 uuid=uuid,
788 primitive_type=PrimitiveType(int(types[i])),
789 area=float(areas[i]),
790 normal=vec3(float(normals[i][0]), float(normals[i][1]), float(normals[i][2])),
791 vertices=vertices,
792 color=RGBcolor(float(colors[i][0]), float(colors[i][1]), float(colors[i][2])),
793 texture_file=texture_file,
794 texture_uv=texture_uv,
795 solid_fraction=(None if solid_fractions is None
796 else float(solid_fractions[i])),
797 ))
798 return infos
799
800 def getAllPrimitiveInfo(self) -> List[PrimitiveInfo]:
801 """
802 Get physical properties and geometry information for all primitives in the context.
803
804 Returns:
805 List of PrimitiveInfo objects for all primitives
806 """
807 return self._batchPrimitiveInfo(self.getAllUUIDs())
808
809 def getPrimitivesInfoForObject(self, object_id: int) -> List[PrimitiveInfo]:
810 """
811 Get physical properties and geometry information for all primitives belonging to a specific object.
812
813 Args:
814 object_id: ID of the object
815
816 Returns:
817 List of PrimitiveInfo objects for primitives in the object
818 """
819 object_uuids = context_wrapper.getObjectPrimitiveUUIDs(self.context, object_id)
820 return self._batchPrimitiveInfo(list(object_uuids))
822 # Compound geometry methods
823 def addTile(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1),
824 rotation: Optional[SphericalCoord] = None, subdiv: int2 = int2(1, 1),
825 color: Optional[RGBcolor] = None) -> List[int]:
826 """
827 Add a subdivided patch (tile) to the context.
828
829 A tile is a patch subdivided into a regular grid of smaller patches,
830 useful for creating detailed surfaces or terrain.
831
832 Args:
833 center: 3D coordinates of tile center (default: origin)
834 size: Width and height of the tile (default: 1x1)
835 rotation: Orientation of the tile (default: no rotation)
836 subdiv: Number of subdivisions in x and y directions (default: 1x1)
837 color: Color of the tile (default: white)
838
839 Returns:
840 List of UUIDs for all patches created in the tile
841
842 Example:
843 >>> context = Context()
844 >>> # Create a 2x2 meter tile subdivided into 4x4 patches
845 >>> tile_uuids = context.addTile(
846 ... center=vec3(0, 0, 1),
847 ... size=vec2(2, 2),
848 ... subdiv=int2(4, 4),
849 ... color=RGBcolor(0.5, 0.8, 0.2)
850 ... )
851 >>> print(f"Created {len(tile_uuids)} patches")
852 """
854
855 # Parameter type validation
856 if not isinstance(center, vec3):
857 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
858 if not isinstance(size, vec2):
859 raise ValueError(f"Size must be a vec2, got {type(size).__name__}")
860 if rotation is not None and not isinstance(rotation, SphericalCoord):
861 raise ValueError(f"Rotation must be a SphericalCoord or None, got {type(rotation).__name__}")
862 if not isinstance(subdiv, int2):
863 raise ValueError(f"Subdiv must be an int2, got {type(subdiv).__name__}")
864 if color is not None and not isinstance(color, RGBcolor):
865 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
866
867 # Parameter value validation
868 if any(s <= 0 for s in size.to_list()):
869 raise ValueError("All size dimensions must be positive")
870 if any(s <= 0 for s in subdiv.to_list()):
871 raise ValueError("All subdivision counts must be positive")
872
873 rotation = rotation or SphericalCoord(1, 0, 0)
874 color = color or RGBcolor(1, 1, 1)
875
876 # Extract only radius, elevation, azimuth for C++ interface
877 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
878
879 if color and not (color.r == 1.0 and color.g == 1.0 and color.b == 1.0):
880 return context_wrapper.addTileWithColor(
881 self.context, center.to_list(), size.to_list(),
882 rotation_list, subdiv.to_list(), color.to_list()
883 )
884 else:
885 return context_wrapper.addTile(
886 self.context, center.to_list(), size.to_list(),
887 rotation_list, subdiv.to_list()
888 )
889
890 @validate_sphere_params
891 def addSphere(self, center: vec3 = vec3(0, 0, 0), radius: float = 1.0,
892 ndivs: int = 10, color: Optional[RGBcolor] = None) -> List[int]:
893 """
894 Add a sphere to the context.
895
896 The sphere is tessellated into triangular faces based on the specified
897 number of divisions.
898
899 Args:
900 center: 3D coordinates of sphere center (default: origin)
901 radius: Radius of the sphere (default: 1.0)
902 ndivs: Number of divisions for tessellation (default: 10)
903 Higher values create smoother spheres but more triangles
904 color: Color of the sphere (default: white)
905
906 Returns:
907 List of UUIDs for all triangles created in the sphere
908
909 Example:
910 >>> context = Context()
911 >>> # Create a red sphere at (1, 2, 3) with radius 0.5
912 >>> sphere_uuids = context.addSphere(
913 ... center=vec3(1, 2, 3),
914 ... radius=0.5,
915 ... ndivs=20,
916 ... color=RGBcolor(1, 0, 0)
917 ... )
918 >>> print(f"Created sphere with {len(sphere_uuids)} triangles")
919 """
921
922 # Parameter type validation
923 if not isinstance(center, vec3):
924 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
925 if not isinstance(radius, (int, float)):
926 raise ValueError(f"Radius must be a number, got {type(radius).__name__}")
927 if not isinstance(ndivs, int):
928 raise ValueError(f"Ndivs must be an integer, got {type(ndivs).__name__}")
929 if color is not None and not isinstance(color, RGBcolor):
930 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
931
932 # Parameter value validation
933 if radius <= 0:
934 raise ValueError("Sphere radius must be positive")
935 if ndivs < 3:
936 raise ValueError("Number of divisions must be at least 3")
937
938 if color:
939 return context_wrapper.addSphereWithColor(
940 self.context, ndivs, center.to_list(), radius, color.to_list()
941 )
942 else:
943 return context_wrapper.addSphere(
944 self.context, ndivs, center.to_list(), radius
945 )
946
947 @validate_tube_params
948 def addTube(self, nodes: List[vec3], radii: Union[float, List[float]],
949 ndivs: int = 6, colors: Optional[Union[RGBcolor, List[RGBcolor]]] = None) -> List[int]:
950 """
951 Add a tube (pipe/cylinder) to the context.
952
953 The tube is defined by a series of nodes (path) with radius at each node.
954 It's tessellated into triangular faces based on the number of radial divisions.
955
956 Args:
957 nodes: List of 3D points defining the tube path (at least 2 nodes)
958 radii: Radius at each node. Can be:
959 - Single float: constant radius for all nodes
960 - List of floats: radius for each node (must match nodes length)
961 ndivs: Number of radial divisions (default: 6)
962 Higher values create smoother tubes but more triangles
963 colors: Colors at each node. Can be:
964 - None: white tube
965 - Single RGBcolor: constant color for all nodes
966 - List of RGBcolor: color for each node (must match nodes length)
967
968 Returns:
969 List of UUIDs for all triangles created in the tube
970
971 Example:
972 >>> context = Context()
973 >>> # Create a curved tube with varying radius
974 >>> nodes = [vec3(0, 0, 0), vec3(1, 0, 0), vec3(2, 1, 0)]
975 >>> radii = [0.1, 0.2, 0.1]
976 >>> colors = [RGBcolor(1, 0, 0), RGBcolor(0, 1, 0), RGBcolor(0, 0, 1)]
977 >>> tube_uuids = context.addTube(nodes, radii, ndivs=8, colors=colors)
978 >>> print(f"Created tube with {len(tube_uuids)} triangles")
979 """
981
982 # Parameter type validation
983 if not isinstance(nodes, (list, tuple)):
984 raise ValueError(f"Nodes must be a list or tuple, got {type(nodes).__name__}")
985 if not isinstance(ndivs, int):
986 raise ValueError(f"Ndivs must be an integer, got {type(ndivs).__name__}")
987 if colors is not None and not isinstance(colors, (RGBcolor, list, tuple)):
988 raise ValueError(f"Colors must be RGBcolor, list, tuple, or None, got {type(colors).__name__}")
989
990 # Parameter value validation
991 if len(nodes) < 2:
992 raise ValueError("Tube requires at least 2 nodes")
993 if ndivs < 3:
994 raise ValueError("Number of radial divisions must be at least 3")
995
996 # Handle radius parameter
997 if isinstance(radii, (int, float)):
998 radii_list = [float(radii)] * len(nodes)
999 else:
1000 radii_list = [float(r) for r in radii]
1001 if len(radii_list) != len(nodes):
1002 raise ValueError(f"Number of radii ({len(radii_list)}) must match number of nodes ({len(nodes)})")
1003
1004 # Validate radii
1005 if any(r <= 0 for r in radii_list):
1006 raise ValueError("All radii must be positive")
1007
1008 # Convert nodes to flat list
1009 nodes_flat = []
1010 for node in nodes:
1011 nodes_flat.extend(node.to_list())
1012
1013 # Handle colors parameter
1014 if colors is None:
1015 return context_wrapper.addTube(self.context, ndivs, nodes_flat, radii_list)
1016 elif isinstance(colors, RGBcolor):
1017 # Single color for all nodes
1018 colors_flat = colors.to_list() * len(nodes)
1019 else:
1020 # List of colors
1021 if len(colors) != len(nodes):
1022 raise ValueError(f"Number of colors ({len(colors)}) must match number of nodes ({len(nodes)})")
1023 colors_flat = []
1024 for color in colors:
1025 colors_flat.extend(color.to_list())
1026
1027 return context_wrapper.addTubeWithColor(self.context, ndivs, nodes_flat, radii_list, colors_flat)
1028
1029 @validate_box_params
1030 def addBox(self, center: vec3 = vec3(0, 0, 0), size: vec3 = vec3(1, 1, 1),
1031 subdiv: int3 = int3(1, 1, 1), color: Optional[RGBcolor] = None) -> List[int]:
1032 """
1033 Add a rectangular box to the context.
1034
1035 The box is subdivided into patches on each face based on the specified
1036 subdivisions.
1037
1038 Args:
1039 center: 3D coordinates of box center (default: origin)
1040 size: Width, height, and depth of the box (default: 1x1x1)
1041 subdiv: Number of subdivisions in x, y, and z directions (default: 1x1x1)
1042 Higher values create more detailed surfaces
1043 color: Color of the box (default: white)
1044
1045 Returns:
1046 List of UUIDs for all patches created on the box faces
1047
1048 Example:
1049 >>> context = Context()
1050 >>> # Create a blue box subdivided for detail
1051 >>> box_uuids = context.addBox(
1052 ... center=vec3(0, 0, 2),
1053 ... size=vec3(2, 1, 0.5),
1054 ... subdiv=int3(4, 2, 1),
1055 ... color=RGBcolor(0, 0, 1)
1056 ... )
1057 >>> print(f"Created box with {len(box_uuids)} patches")
1058 """
1060
1061 # Parameter type validation
1062 if not isinstance(center, vec3):
1063 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
1064 if not isinstance(size, vec3):
1065 raise ValueError(f"Size must be a vec3, got {type(size).__name__}")
1066 if not isinstance(subdiv, int3):
1067 raise ValueError(f"Subdiv must be an int3, got {type(subdiv).__name__}")
1068 if color is not None and not isinstance(color, RGBcolor):
1069 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
1070
1071 # Parameter value validation
1072 if any(s <= 0 for s in size.to_list()):
1073 raise ValueError("All box dimensions must be positive")
1074 if any(s < 1 for s in subdiv.to_list()):
1075 raise ValueError("All subdivision counts must be at least 1")
1076
1077 if color:
1078 return context_wrapper.addBoxWithColor(
1079 self.context, center.to_list(), size.to_list(),
1080 subdiv.to_list(), color.to_list()
1081 )
1082 else:
1083 return context_wrapper.addBox(
1084 self.context, center.to_list(), size.to_list(), subdiv.to_list()
1085 )
1086
1087 def addDisk(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1),
1088 ndivs: Union[int, int2] = 20, rotation: Optional[SphericalCoord] = None,
1089 color: Optional[Union[RGBcolor, RGBAcolor]] = None) -> List[int]:
1090 """
1091 Add a disk (circular or elliptical surface) to the context.
1092
1093 A disk is a flat circular or elliptical surface tessellated into
1094 triangular faces. Supports both uniform radial subdivisions and
1095 separate radial/azimuthal subdivisions for finer control.
1096
1097 Args:
1098 center: 3D coordinates of disk center (default: origin)
1099 size: Semi-major and semi-minor radii of the disk (default: 1x1 circle)
1100 ndivs: Number of radial divisions (int) or [radial, azimuthal] divisions (int2)
1101 (default: 20). Higher values create smoother circles but more triangles.
1102 rotation: Orientation of the disk (default: horizontal, normal = +z)
1103 color: Color of the disk (default: white). Can be RGBcolor or RGBAcolor for transparency.
1104
1105 Returns:
1106 List of UUIDs for all triangles created in the disk
1107
1108 Example:
1109 >>> context = Context()
1110 >>> # Create a red disk at (0, 0, 1) with radius 0.5
1111 >>> disk_uuids = context.addDisk(
1112 ... center=vec3(0, 0, 1),
1113 ... size=vec2(0.5, 0.5),
1114 ... ndivs=30,
1115 ... color=RGBcolor(1, 0, 0)
1116 ... )
1117 >>> print(f"Created disk with {len(disk_uuids)} triangles")
1118 >>>
1119 >>> # Create a semi-transparent blue elliptical disk
1120 >>> disk_uuids = context.addDisk(
1121 ... center=vec3(0, 0, 2),
1122 ... size=vec2(1.0, 0.5),
1123 ... ndivs=40,
1124 ... rotation=SphericalCoord(1, 0.5, 0),
1125 ... color=RGBAcolor(0, 0, 1, 0.5)
1126 ... )
1127 >>>
1128 >>> # Create disk with polar/radial subdivisions for finer control
1129 >>> disk_uuids = context.addDisk(
1130 ... center=vec3(0, 0, 3),
1131 ... size=vec2(1, 1),
1132 ... ndivs=int2(10, 20), # 10 radial, 20 azimuthal divisions
1133 ... color=RGBcolor(0, 1, 0)
1134 ... )
1135 """
1137
1138 # Parameter type validation
1139 if not isinstance(center, vec3):
1140 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
1141 if not isinstance(size, vec2):
1142 raise ValueError(f"Size must be a vec2, got {type(size).__name__}")
1143 if not isinstance(ndivs, (int, int2)):
1144 raise ValueError(f"Ndivs must be an int or int2, got {type(ndivs).__name__}")
1145 if rotation is not None and not isinstance(rotation, SphericalCoord):
1146 raise ValueError(f"Rotation must be a SphericalCoord or None, got {type(rotation).__name__}")
1147 if color is not None and not isinstance(color, (RGBcolor, RGBAcolor)):
1148 raise ValueError(f"Color must be an RGBcolor, RGBAcolor, or None, got {type(color).__name__}")
1149
1150 # Parameter value validation
1151 if any(s <= 0 for s in size.to_list()):
1152 raise ValueError("Disk size must be positive")
1153
1154 # Validate subdivisions based on type
1155 if isinstance(ndivs, int):
1156 if ndivs < 3:
1157 raise ValueError("Number of divisions must be at least 3")
1158 else: # int2
1159 if any(n < 1 for n in ndivs.to_list()):
1160 raise ValueError("Radial and angular divisions must be at least 1")
1161
1162 # Default rotation (horizontal disk, normal pointing +z)
1163 if rotation is None:
1164 rotation = SphericalCoord(1, 0, 0)
1165
1166 # CRITICAL: Extract only radius, elevation, azimuth for C++ interface
1167 # (rotation.to_list() returns 4 values, but C++ expects 3)
1168 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
1169
1170 # Dispatch based on ndivs and color types
1171 if isinstance(ndivs, int2):
1172 # Polar subdivisions variant (supports RGB and RGBA color)
1173 if color:
1174 if isinstance(color, RGBAcolor):
1175 return context_wrapper.addDiskPolarSubdivisionsRGBA(
1176 self.context, ndivs.to_list(), center.to_list(), size.to_list(),
1177 rotation_list, color.to_list()
1178 )
1179 else:
1180 # RGB color
1181 return context_wrapper.addDiskPolarSubdivisions(
1182 self.context, ndivs.to_list(), center.to_list(), size.to_list(),
1183 rotation_list, color.to_list()
1184 )
1185 else:
1186 # No color - use default white
1187 color_list = [1.0, 1.0, 1.0]
1188 return context_wrapper.addDiskPolarSubdivisions(
1189 self.context, ndivs.to_list(), center.to_list(), size.to_list(),
1190 rotation_list, color_list
1191 )
1192 else:
1193 # Uniform radial subdivisions
1194 if color:
1195 if isinstance(color, RGBAcolor):
1196 # RGBA color variant
1197 return context_wrapper.addDiskWithRGBAColor(
1198 self.context, ndivs, center.to_list(), size.to_list(),
1199 rotation_list, color.to_list()
1200 )
1201 else:
1202 # RGB color variant
1203 return context_wrapper.addDiskWithColor(
1204 self.context, ndivs, center.to_list(), size.to_list(),
1205 rotation_list, color.to_list()
1206 )
1207 else:
1208 # No color - use rotation variant
1209 return context_wrapper.addDiskWithRotation(
1210 self.context, ndivs, center.to_list(), size.to_list(),
1211 rotation_list
1212 )
1213
1214 def addCone(self, node0: vec3, node1: vec3, radius0: float, radius1: float,
1215 ndivs: int = 20, color: Optional[RGBcolor] = None) -> List[int]:
1216 """
1217 Add a cone (or cylinder/frustum) to the context.
1218
1219 A cone is a 3D shape connecting two circular cross-sections with
1220 potentially different radii. When radii are equal, creates a cylinder.
1221 When one radius is zero, creates a true cone.
1222
1223 Args:
1224 node0: 3D coordinates of the base center
1225 node1: 3D coordinates of the apex center
1226 radius0: Radius at base (node0). Use 0 for pointed end.
1227 radius1: Radius at apex (node1). Use 0 for pointed end.
1228 ndivs: Number of radial divisions for tessellation (default: 20)
1229 color: Color of the cone (default: white)
1230
1231 Returns:
1232 List of UUIDs for all triangles created in the cone
1233
1234 Example:
1235 >>> context = Context()
1236 >>> # Create a cylinder (equal radii)
1237 >>> cylinder_uuids = context.addCone(
1238 ... node0=vec3(0, 0, 0),
1239 ... node1=vec3(0, 0, 2),
1240 ... radius0=0.5,
1241 ... radius1=0.5,
1242 ... ndivs=20
1243 ... )
1244 >>>
1245 >>> # Create a true cone (one radius = 0)
1246 >>> cone_uuids = context.addCone(
1247 ... node0=vec3(1, 0, 0),
1248 ... node1=vec3(1, 0, 1.5),
1249 ... radius0=0.5,
1250 ... radius1=0.0,
1251 ... ndivs=24,
1252 ... color=RGBcolor(1, 0, 0)
1253 ... )
1254 >>>
1255 >>> # Create a frustum (different radii)
1256 >>> frustum_uuids = context.addCone(
1257 ... node0=vec3(2, 0, 0),
1258 ... node1=vec3(2, 0, 1),
1259 ... radius0=0.8,
1260 ... radius1=0.4,
1261 ... ndivs=16
1262 ... )
1263 """
1265
1266 # Parameter type validation
1267 if not isinstance(node0, vec3):
1268 raise ValueError(f"node0 must be a vec3, got {type(node0).__name__}")
1269 if not isinstance(node1, vec3):
1270 raise ValueError(f"node1 must be a vec3, got {type(node1).__name__}")
1271 if not isinstance(ndivs, int):
1272 raise ValueError(f"ndivs must be an int, got {type(ndivs).__name__}")
1273 if color is not None and not isinstance(color, RGBcolor):
1274 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
1275
1276 # Parameter value validation
1277 if radius0 < 0 or radius1 < 0:
1278 raise ValueError("Radii must be non-negative")
1279 if ndivs < 3:
1280 raise ValueError("Number of radial divisions must be at least 3")
1281
1282 # Dispatch based on color
1283 if color:
1284 return context_wrapper.addConeWithColor(
1285 self.context, ndivs, node0.to_list(), node1.to_list(),
1286 radius0, radius1, color.to_list()
1287 )
1288 else:
1289 return context_wrapper.addCone(
1290 self.context, ndivs, node0.to_list(), node1.to_list(),
1291 radius0, radius1
1292 )
1293
1294 def addSphereObject(self, center: vec3 = vec3(0, 0, 0),
1295 radius: Union[float, vec3] = 1.0, ndivs: int = 20,
1296 color: Optional[RGBcolor] = None,
1297 texturefile: Optional[str] = None) -> int:
1298 """
1299 Add a spherical or ellipsoidal compound object to the context.
1300
1301 Creates a sphere or ellipsoid as a compound object with a trackable object ID.
1302 Primitives within the object are registered as children of the object.
1303
1304 Args:
1305 center: Center position of sphere/ellipsoid (default: origin)
1306 radius: Radius as float (sphere) or vec3 (ellipsoid) (default: 1.0)
1307 ndivs: Number of tessellation divisions (default: 20)
1308 color: Optional RGB color
1309 texturefile: Optional texture image file path
1310
1311 Returns:
1312 Object ID of the created compound object
1313
1314 Raises:
1315 ValueError: If parameters are invalid
1316 NotImplementedError: If object-returning functions unavailable
1317
1318 Examples:
1319 >>> # Create a basic sphere at origin
1320 >>> obj_id = ctx.addSphereObject()
1321
1322 >>> # Create a colored sphere
1323 >>> obj_id = ctx.addSphereObject(
1324 ... center=vec3(0, 0, 5),
1325 ... radius=2.0,
1326 ... color=RGBcolor(1, 0, 0)
1327 ... )
1328
1329 >>> # Create an ellipsoid (stretched sphere)
1330 >>> obj_id = ctx.addSphereObject(
1331 ... center=vec3(10, 0, 0),
1332 ... radius=vec3(2, 1, 1), # Elongated in x-direction
1333 ... ndivs=30
1334 ... )
1335 """
1337
1338 # Parameter type validation
1339 if not isinstance(center, vec3):
1340 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
1341 if not isinstance(radius, (int, float, vec3)):
1342 raise ValueError(f"Radius must be a number or vec3, got {type(radius).__name__}")
1343 if color is not None and not isinstance(color, RGBcolor):
1344 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
1345
1346 # Validate parameters
1347 if ndivs < 3:
1348 raise ValueError("Number of divisions must be at least 3")
1349
1350 # Check if radius is scalar (sphere) or vector (ellipsoid)
1351 is_ellipsoid = isinstance(radius, vec3)
1352
1353 # Dispatch based on parameters
1354 if is_ellipsoid:
1355 # Ellipsoid variants
1356 if texturefile:
1357 return context_wrapper.addSphereObject_ellipsoid_texture(
1358 self.context, ndivs, center.to_list(), radius.to_list(), texturefile
1359 )
1360 elif color:
1361 return context_wrapper.addSphereObject_ellipsoid_color(
1362 self.context, ndivs, center.to_list(), radius.to_list(), color.to_list()
1363 )
1364 else:
1365 return context_wrapper.addSphereObject_ellipsoid(
1366 self.context, ndivs, center.to_list(), radius.to_list()
1367 )
1368 else:
1369 # Sphere variants (radius is float)
1370 if texturefile:
1371 return context_wrapper.addSphereObject_texture(
1372 self.context, ndivs, center.to_list(), radius, texturefile
1373 )
1374 elif color:
1375 return context_wrapper.addSphereObject_color(
1376 self.context, ndivs, center.to_list(), radius, color.to_list()
1377 )
1378 else:
1379 return context_wrapper.addSphereObject_basic(
1380 self.context, ndivs, center.to_list(), radius
1381 )
1382
1383 def addTileObject(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1),
1384 rotation: SphericalCoord = SphericalCoord(1, 0, 0),
1385 subdiv: int2 = int2(1, 1),
1386 color: Optional[RGBcolor] = None,
1387 texturefile: Optional[str] = None,
1388 texture_repeat: Optional[int2] = None) -> int:
1389 """
1390 Add a tiled patch (subdivided patch) as a compound object to the context.
1391
1392 Creates a rectangular patch subdivided into a grid of smaller patches,
1393 registered as a compound object with a trackable object ID.
1394
1395 Args:
1396 center: Center position of tile (default: origin)
1397 size: Size in x and y directions (default: 1x1)
1398 rotation: Spherical rotation (default: no rotation)
1399 subdiv: Number of subdivisions in x and y (default: 1x1)
1400 color: Optional RGB color
1401 texturefile: Optional texture image file path
1402 texture_repeat: Optional texture repetitions in x and y
1403
1404 Returns:
1405 Object ID of the created compound object
1406
1407 Raises:
1408 ValueError: If parameters are invalid
1409 NotImplementedError: If object-returning functions unavailable
1410
1411 Examples:
1412 >>> # Create a basic 2x2 tile
1413 >>> obj_id = ctx.addTileObject(
1414 ... center=vec3(0, 0, 0),
1415 ... size=vec2(10, 10),
1416 ... subdiv=int2(2, 2)
1417 ... )
1418
1419 >>> # Create a colored tile with rotation
1420 >>> obj_id = ctx.addTileObject(
1421 ... center=vec3(5, 0, 0),
1422 ... size=vec2(10, 5),
1423 ... rotation=SphericalCoord(1, 0, 45),
1424 ... subdiv=int2(4, 2),
1425 ... color=RGBcolor(0, 1, 0)
1426 ... )
1427 """
1429
1430 # Parameter type validation
1431 if not isinstance(center, vec3):
1432 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
1433 if not isinstance(size, vec2):
1434 raise ValueError(f"Size must be a vec2, got {type(size).__name__}")
1435 if not isinstance(rotation, SphericalCoord):
1436 raise ValueError(f"Rotation must be a SphericalCoord, got {type(rotation).__name__}")
1437 if not isinstance(subdiv, int2):
1438 raise ValueError(f"Subdiv must be an int2, got {type(subdiv).__name__}")
1439 if color is not None and not isinstance(color, RGBcolor):
1440 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
1441 if texture_repeat is not None and not isinstance(texture_repeat, int2):
1442 raise ValueError(f"texture_repeat must be an int2 or None, got {type(texture_repeat).__name__}")
1443
1444 # Extract rotation as 3 values (radius, elevation, azimuth)
1445 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
1446
1447 # Dispatch based on parameters
1448 if texture_repeat is not None:
1449 if texturefile is None:
1450 raise ValueError("texture_repeat requires texturefile")
1451 return context_wrapper.addTileObject_texture_repeat(
1452 self.context, center.to_list(), size.to_list(), rotation_list,
1453 subdiv.to_list(), texturefile, texture_repeat.to_list()
1454 )
1455 elif texturefile:
1456 return context_wrapper.addTileObject_texture(
1457 self.context, center.to_list(), size.to_list(), rotation_list,
1458 subdiv.to_list(), texturefile
1459 )
1460 elif color:
1461 return context_wrapper.addTileObject_color(
1462 self.context, center.to_list(), size.to_list(), rotation_list,
1463 subdiv.to_list(), color.to_list()
1464 )
1465 else:
1466 return context_wrapper.addTileObject_basic(
1467 self.context, center.to_list(), size.to_list(), rotation_list,
1468 subdiv.to_list()
1469 )
1470
1471 def addAdaptiveTileObject(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1),
1472 rotation: SphericalCoord = SphericalCoord(1, 0, 0),
1473 refinement: Optional[AdaptiveTileRefinement] = None,
1474 color: Optional[RGBcolor] = None,
1475 texturefile: Optional[str] = None,
1476 texture_repeat: Optional[int2] = None) -> int:
1477 """
1478 Add a patch subdivided into sub-patches whose size adapts with distance from a target point.
1479
1480 Sub-patches are generated by recursive quadtree subdivision, so they are fine near
1481 ``refinement.target`` and progressively coarser away from it. This is intended for ground
1482 planes, where fine resolution is needed to resolve shadows cast near an object of interest
1483 but the remainder of the domain only needs to occlude the horizon. The sub-patches exactly
1484 partition the tile with no gaps and no overlaps, and the achieved sizes are typically
1485 within about 20% of those requested.
1486
1487 Args:
1488 center: Center position of tile (default: origin)
1489 size: Size in x and y directions (default: 1x1)
1490 rotation: Spherical rotation (default: no rotation)
1491 refinement: Parameters controlling the adaptive sub-patch resolution
1492 (default: ``AdaptiveTileRefinement()``)
1493 color: Optional RGB color
1494 texturefile: Optional texture image file path
1495 texture_repeat: Optional texture repetitions in x and y. Unlike
1496 :meth:`addTileObject`, the count is applied exactly -- the base grid is snapped to
1497 a multiple of it rather than the count being reduced.
1498
1499 Returns:
1500 Object ID of the created compound object
1501
1502 Raises:
1503 ValueError: If parameters are invalid
1504 RuntimeError: If the refinement is rejected by helios, e.g. because
1505 ``subpatch_size_max`` exceeds half the smaller tile dimension, or because the
1506 requested refinement would generate more than 2,000,000 sub-patches
1507 NotImplementedError: If object-returning functions unavailable
1508
1509 Note:
1510 Sub-patches are ordered by quadtree traversal, not row-major, so they cannot be
1511 indexed by grid position. Adaptive tiles have no subdivision count, so
1512 :meth:`getTileObjectSubdivisionCount` and :meth:`setTileObjectSubdivisionCount` do
1513 not apply to them.
1514
1515 Examples:
1516 >>> # Ground plane resolved finely beneath a plant at the origin
1517 >>> refinement = AdaptiveTileRefinement(
1518 ... target=vec2(0, 0), subpatch_size_min=0.02, subpatch_size_max=2.0
1519 ... )
1520 >>> obj_id = ctx.addAdaptiveTileObject(
1521 ... center=vec3(0, 0, 0), size=vec2(50, 50), refinement=refinement
1522 ... )
1523
1524 >>> # Check the cost before committing to it
1525 >>> ctx.predictAdaptiveTileObjectSubpatchCount(vec2(50, 50), refinement) # doctest: +SKIP
1526 21374
1527 """
1529
1530 if refinement is None:
1531 refinement = AdaptiveTileRefinement()
1532
1533 # Parameter type validation
1534 if not isinstance(center, vec3):
1535 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
1536 if not isinstance(size, vec2):
1537 raise ValueError(f"Size must be a vec2, got {type(size).__name__}")
1538 if not isinstance(rotation, SphericalCoord):
1539 raise ValueError(f"Rotation must be a SphericalCoord, got {type(rotation).__name__}")
1540 if not isinstance(refinement, AdaptiveTileRefinement):
1541 raise ValueError(f"Refinement must be an AdaptiveTileRefinement or None, got {type(refinement).__name__}")
1542 if color is not None and not isinstance(color, RGBcolor):
1543 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
1544 if texture_repeat is not None and not isinstance(texture_repeat, int2):
1545 raise ValueError(f"texture_repeat must be an int2 or None, got {type(texture_repeat).__name__}")
1546
1547 # Extract rotation as 3 values (radius, elevation, azimuth)
1548 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
1549
1550 # Dispatch based on parameters
1551 if texture_repeat is not None:
1552 if texturefile is None:
1553 raise ValueError("texture_repeat requires texturefile")
1554 return context_wrapper.addAdaptiveTileObject_texture_repeat(
1555 self.context, center.to_list(), size.to_list(), rotation_list,
1556 refinement.to_list(), texturefile, texture_repeat.to_list()
1557 )
1558 elif texturefile:
1559 return context_wrapper.addAdaptiveTileObject_texture(
1560 self.context, center.to_list(), size.to_list(), rotation_list,
1561 refinement.to_list(), texturefile
1562 )
1563 elif color:
1564 return context_wrapper.addAdaptiveTileObject_color(
1565 self.context, center.to_list(), size.to_list(), rotation_list,
1566 refinement.to_list(), color.to_list()
1567 )
1568 else:
1569 return context_wrapper.addAdaptiveTileObject_basic(
1570 self.context, center.to_list(), size.to_list(), rotation_list,
1571 refinement.to_list()
1572 )
1573
1574 def predictAdaptiveTileObjectSubpatchCount(self, size: vec2,
1575 refinement: Optional[AdaptiveTileRefinement] = None,
1576 texture_repeat: Optional[int2] = None) -> int:
1577 """
1578 Determine how many sub-patches an adaptive tile object would contain, without building geometry.
1579
1580 Runs the same quadtree traversal as :meth:`addAdaptiveTileObject` but counts cells instead
1581 of creating primitives. Useful for checking the cost of a set of refinement parameters
1582 before committing to it, since the sub-patch count is highly sensitive to
1583 ``refinement.transition_exponent``.
1584
1585 Args:
1586 size: Size of the tile in the x- and y-directions
1587 refinement: Parameters controlling the adaptive sub-patch resolution
1588 (default: ``AdaptiveTileRefinement()``)
1589 texture_repeat: Texture repetitions the tile would use (default: 1x1)
1590
1591 Returns:
1592 Number of sub-patches that would be created
1593
1594 Raises:
1595 ValueError: If parameters are invalid
1596 RuntimeError: Raised for a refinement that would generate too many sub-patches, rather
1597 than reporting a count, so that this and :meth:`addAdaptiveTileObject` answer the
1598 question the same way
1599
1600 Examples:
1601 >>> refinement = AdaptiveTileRefinement(subpatch_size_min=0.02, subpatch_size_max=2.0)
1602 >>> ctx.predictAdaptiveTileObjectSubpatchCount(vec2(50, 50), refinement) # doctest: +SKIP
1603 21374
1604 """
1606
1607 if refinement is None:
1608 refinement = AdaptiveTileRefinement()
1609 if texture_repeat is None:
1610 texture_repeat = int2(1, 1)
1611
1612 if not isinstance(size, vec2):
1613 raise ValueError(f"Size must be a vec2, got {type(size).__name__}")
1614 if not isinstance(refinement, AdaptiveTileRefinement):
1615 raise ValueError(f"Refinement must be an AdaptiveTileRefinement or None, got {type(refinement).__name__}")
1616 if not isinstance(texture_repeat, int2):
1617 raise ValueError(f"texture_repeat must be an int2 or None, got {type(texture_repeat).__name__}")
1618
1619 return context_wrapper.predictAdaptiveTileObjectSubpatchCount(
1620 self.context, size.to_list(), refinement.to_list(), texture_repeat.to_list()
1621 )
1622
1623 def addBoxObject(self, center: vec3 = vec3(0, 0, 0), size: vec3 = vec3(1, 1, 1),
1624 subdiv: int3 = int3(1, 1, 1), color: Optional[RGBcolor] = None,
1625 texturefile: Optional[str] = None, reverse_normals: bool = False) -> int:
1626 """
1627 Add a rectangular box (prism) as a compound object to the context.
1628
1629 Args:
1630 center: Center position (default: origin)
1631 size: Size in x, y, z directions (default: 1x1x1)
1632 subdiv: Subdivisions in x, y, z (default: 1x1x1)
1633 color: Optional RGB color
1634 texturefile: Optional texture file path
1635 reverse_normals: Reverse normal directions (default: False)
1636
1637 Returns:
1638 Object ID of the created compound object
1639 """
1641
1642 # Parameter type validation
1643 if not isinstance(center, vec3):
1644 raise ValueError(f"Center must be a vec3, got {type(center).__name__}")
1645 if not isinstance(size, vec3):
1646 raise ValueError(f"Size must be a vec3, got {type(size).__name__}")
1647 if not isinstance(subdiv, int3):
1648 raise ValueError(f"Subdiv must be an int3, got {type(subdiv).__name__}")
1649 if color is not None and not isinstance(color, RGBcolor):
1650 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
1651
1652 if reverse_normals:
1653 if texturefile:
1654 return context_wrapper.addBoxObject_texture_reverse(self.context, center.to_list(), size.to_list(), subdiv.to_list(), texturefile, reverse_normals)
1655 elif color:
1656 return context_wrapper.addBoxObject_color_reverse(self.context, center.to_list(), size.to_list(), subdiv.to_list(), color.to_list(), reverse_normals)
1657 else:
1658 raise ValueError("reverse_normals requires either color or texturefile")
1659 elif texturefile:
1660 return context_wrapper.addBoxObject_texture(self.context, center.to_list(), size.to_list(), subdiv.to_list(), texturefile)
1661 elif color:
1662 return context_wrapper.addBoxObject_color(self.context, center.to_list(), size.to_list(), subdiv.to_list(), color.to_list())
1663 else:
1664 return context_wrapper.addBoxObject_basic(self.context, center.to_list(), size.to_list(), subdiv.to_list())
1665
1666 def addConeObject(self, node0: vec3, node1: vec3, radius0: float, radius1: float,
1667 ndivs: int = 20, color: Optional[RGBcolor] = None,
1668 texturefile: Optional[str] = None) -> int:
1669 """
1670 Add a cone/cylinder/frustum as a compound object to the context.
1671
1672 Args:
1673 node0: Base position
1674 node1: Top position
1675 radius0: Radius at base
1676 radius1: Radius at top
1677 ndivs: Number of radial divisions (default: 20)
1678 color: Optional RGB color
1679 texturefile: Optional texture file path
1680
1681 Returns:
1682 Object ID of the created compound object
1683 """
1685
1686 # Parameter type validation
1687 if not isinstance(node0, vec3):
1688 raise ValueError(f"node0 must be a vec3, got {type(node0).__name__}")
1689 if not isinstance(node1, vec3):
1690 raise ValueError(f"node1 must be a vec3, got {type(node1).__name__}")
1691 if not isinstance(radius0, (int, float)):
1692 raise ValueError(f"radius0 must be a number, got {type(radius0).__name__}")
1693 if not isinstance(radius1, (int, float)):
1694 raise ValueError(f"radius1 must be a number, got {type(radius1).__name__}")
1695 if color is not None and not isinstance(color, RGBcolor):
1696 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
1697
1698 if texturefile:
1699 return context_wrapper.addConeObject_texture(self.context, ndivs, node0.to_list(), node1.to_list(), radius0, radius1, texturefile)
1700 elif color:
1701 return context_wrapper.addConeObject_color(self.context, ndivs, node0.to_list(), node1.to_list(), radius0, radius1, color.to_list())
1702 else:
1703 return context_wrapper.addConeObject_basic(self.context, ndivs, node0.to_list(), node1.to_list(), radius0, radius1)
1704
1705 def addDiskObject(self, center: vec3 = vec3(0, 0, 0), size: vec2 = vec2(1, 1),
1706 ndivs: Union[int, int2] = 20, rotation: Optional[SphericalCoord] = None,
1707 color: Optional[Union[RGBcolor, RGBAcolor]] = None,
1708 texturefile: Optional[str] = None) -> int:
1709 """
1710 Add a disk as a compound object to the context.
1711
1712 Args:
1713 center: Center position (default: origin)
1714 size: Semi-major and semi-minor radii (default: 1x1)
1715 ndivs: int (uniform) or int2 (polar/radial subdivisions) (default: 20)
1716 rotation: Optional spherical rotation
1717 color: Optional RGB or RGBA color
1718 texturefile: Optional texture file path
1719
1720 Returns:
1721 Object ID of the created compound object
1722 """
1724
1725 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth] if rotation else [1, 0, 0]
1726 is_polar = isinstance(ndivs, int2)
1727
1728 if is_polar:
1729 if texturefile:
1730 return context_wrapper.addDiskObject_polar_texture(self.context, ndivs.to_list(), center.to_list(), size.to_list(), rotation_list, texturefile)
1731 elif color:
1732 if isinstance(color, RGBAcolor):
1733 return context_wrapper.addDiskObject_polar_rgba(self.context, ndivs.to_list(), center.to_list(), size.to_list(), rotation_list, color.to_list())
1734 else:
1735 return context_wrapper.addDiskObject_polar_color(self.context, ndivs.to_list(), center.to_list(), size.to_list(), rotation_list, color.to_list())
1736 else:
1737 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())
1738 else:
1739 if texturefile:
1740 return context_wrapper.addDiskObject_texture(self.context, ndivs, center.to_list(), size.to_list(), rotation_list, texturefile)
1741 elif color:
1742 if isinstance(color, RGBAcolor):
1743 return context_wrapper.addDiskObject_rgba(self.context, ndivs, center.to_list(), size.to_list(), rotation_list, color.to_list())
1744 else:
1745 return context_wrapper.addDiskObject_color(self.context, ndivs, center.to_list(), size.to_list(), rotation_list, color.to_list())
1746 elif rotation:
1747 return context_wrapper.addDiskObject_rotation(self.context, ndivs, center.to_list(), size.to_list(), rotation_list)
1748 else:
1749 return context_wrapper.addDiskObject_basic(self.context, ndivs, center.to_list(), size.to_list())
1750
1751 def addTubeObject(self, ndivs: int, nodes: List[vec3], radii: List[float],
1752 colors: Optional[List[RGBcolor]] = None,
1753 texturefile: Optional[str] = None,
1754 texture_uv: Optional[List[float]] = None) -> int:
1755 """
1756 Add a tube as a compound object to the context.
1757
1758 Args:
1759 ndivs: Number of radial subdivisions
1760 nodes: List of vec3 positions defining tube segments
1761 radii: List of radii at each node
1762 colors: Optional list of RGB colors for each segment
1763 texturefile: Optional texture file path
1764 texture_uv: Optional UV coordinates for texture mapping
1765
1766 Returns:
1767 Object ID of the created compound object
1768 """
1770
1771 # Parameter type validation
1772 if not isinstance(nodes, (list, tuple)):
1773 raise ValueError(f"Nodes must be a list, got {type(nodes).__name__}")
1774 for i, node in enumerate(nodes):
1775 if not isinstance(node, vec3):
1776 raise ValueError(f"nodes[{i}] must be a vec3, got {type(node).__name__}")
1777 if not isinstance(radii, (list, tuple)):
1778 raise ValueError(f"Radii must be a list, got {type(radii).__name__}")
1779 if colors is not None:
1780 if not isinstance(colors, (list, tuple)):
1781 raise ValueError(f"Colors must be a list or None, got {type(colors).__name__}")
1782 for i, c in enumerate(colors):
1783 if not isinstance(c, RGBcolor):
1784 raise ValueError(f"colors[{i}] must be an RGBcolor, got {type(c).__name__}")
1785
1786 if len(nodes) < 2:
1787 raise ValueError("Tube requires at least 2 nodes")
1788 if len(radii) != len(nodes):
1789 raise ValueError("Number of radii must match number of nodes")
1790
1791 nodes_flat = [coord for node in nodes for coord in node.to_list()]
1792
1793 if texture_uv is not None:
1794 if texturefile is None:
1795 raise ValueError("texture_uv requires texturefile")
1796 return context_wrapper.addTubeObject_texture_uv(self.context, ndivs, nodes_flat, radii, texturefile, texture_uv)
1797 elif texturefile:
1798 return context_wrapper.addTubeObject_texture(self.context, ndivs, nodes_flat, radii, texturefile)
1799 elif colors:
1800 if len(colors) != len(nodes):
1801 raise ValueError("Number of colors must match number of nodes")
1802 colors_flat = [c for color in colors for c in color.to_list()]
1803 return context_wrapper.addTubeObject_color(self.context, ndivs, nodes_flat, radii, colors_flat)
1804 else:
1805 return context_wrapper.addTubeObject_basic(self.context, ndivs, nodes_flat, radii)
1806
1807 def copyPrimitive(self, UUID: Union[int, List[int]]) -> Union[int, List[int]]:
1808 """
1809 Copy one or more primitives.
1810
1811 Creates a duplicate of the specified primitive(s) with all associated data.
1812 The copy is placed at the same location as the original.
1813
1814 Args:
1815 UUID: Single primitive UUID or list of UUIDs to copy
1816
1817 Returns:
1818 Single UUID of copied primitive (if UUID is int) or
1819 List of UUIDs of copied primitives (if UUID is list)
1820
1821 Example:
1822 >>> context = Context()
1823 >>> original_uuid = context.addPatch(center=vec3(0, 0, 0), size=vec2(1, 1))
1824 >>> # Copy single primitive
1825 >>> copied_uuid = context.copyPrimitive(original_uuid)
1826 >>> # Copy multiple primitives
1827 >>> copied_uuids = context.copyPrimitive([uuid1, uuid2, uuid3])
1828 """
1830
1831 if isinstance(UUID, int):
1832 return context_wrapper.copyPrimitive(self.context, UUID)
1833 elif isinstance(UUID, list):
1834 return context_wrapper.copyPrimitives(self.context, UUID)
1835 else:
1836 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
1837
1838 def copyPrimitiveData(self, sourceUUID: int, destinationUUID: int) -> None:
1839 """
1840 Copy all primitive data from source to destination primitive.
1841
1842 Copies all associated data (primitive data fields) from the source
1843 primitive to the destination primitive. Both primitives must already exist.
1844
1845 Args:
1846 sourceUUID: UUID of the source primitive
1847 destinationUUID: UUID of the destination primitive
1848
1849 Example:
1850 >>> context = Context()
1851 >>> source_uuid = context.addPatch(center=vec3(0, 0, 0), size=vec2(1, 1))
1852 >>> dest_uuid = context.addPatch(center=vec3(1, 0, 0), size=vec2(1, 1))
1853 >>> context.setPrimitiveDataFloat(source_uuid, "temperature", 25.5)
1854 >>> context.copyPrimitiveData(source_uuid, dest_uuid)
1855 >>> # dest_uuid now has temperature data
1856 """
1858
1859 if not isinstance(sourceUUID, int):
1860 raise ValueError(f"sourceUUID must be int, got {type(sourceUUID).__name__}")
1861 if not isinstance(destinationUUID, int):
1862 raise ValueError(f"destinationUUID must be int, got {type(destinationUUID).__name__}")
1863
1864 context_wrapper.copyPrimitiveData(self.context, sourceUUID, destinationUUID)
1865
1866 def copyObject(self, ObjID: Union[int, List[int]]) -> Union[int, List[int]]:
1867 """
1868 Copy one or more compound objects.
1869
1870 Creates a duplicate of the specified compound object(s) with all
1871 associated primitives and data. The copy is placed at the same location
1872 as the original.
1873
1874 Args:
1875 ObjID: Single object ID or list of object IDs to copy
1876
1877 Returns:
1878 Single object ID of copied object (if ObjID is int) or
1879 List of object IDs of copied objects (if ObjID is list)
1880
1881 Example:
1882 >>> context = Context()
1883 >>> original_obj = context.addTile(center=vec3(0, 0, 0), size=vec2(2, 2))
1884 >>> # Copy single object
1885 >>> copied_obj = context.copyObject(original_obj)
1886 >>> # Copy multiple objects
1887 >>> copied_objs = context.copyObject([obj1, obj2, obj3])
1888 """
1890
1891 if isinstance(ObjID, int):
1892 return context_wrapper.copyObject(self.context, ObjID)
1893 elif isinstance(ObjID, list):
1894 return context_wrapper.copyObjects(self.context, ObjID)
1895 else:
1896 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1897
1898 def copyObjectData(self, source_objID: int, destination_objID: int) -> None:
1899 """
1900 Copy all object data from source to destination compound object.
1901
1902 Copies all associated data (object data fields) from the source
1903 compound object to the destination object. Both objects must already exist.
1904
1905 Args:
1906 source_objID: Object ID of the source compound object
1907 destination_objID: Object ID of the destination compound object
1908
1909 Example:
1910 >>> context = Context()
1911 >>> source_obj = context.addTile(center=vec3(0, 0, 0), size=vec2(2, 2))
1912 >>> dest_obj = context.addTile(center=vec3(2, 0, 0), size=vec2(2, 2))
1913 >>> context.setObjectData(source_obj, "material", "wood")
1914 >>> context.copyObjectData(source_obj, dest_obj)
1915 >>> # dest_obj now has material data
1916 """
1918
1919 if not isinstance(source_objID, int):
1920 raise ValueError(f"source_objID must be int, got {type(source_objID).__name__}")
1921 if not isinstance(destination_objID, int):
1922 raise ValueError(f"destination_objID must be int, got {type(destination_objID).__name__}")
1923
1924 context_wrapper.copyObjectData(self.context, source_objID, destination_objID)
1925
1926 def translatePrimitive(self, UUID: Union[int, List[int]], shift: vec3) -> None:
1927 """
1928 Translate one or more primitives by a shift vector.
1929
1930 Moves the specified primitive(s) by the given shift vector without
1931 changing their orientation or size.
1932
1933 Args:
1934 UUID: Single primitive UUID or list of UUIDs to translate
1935 shift: 3D vector representing the translation [x, y, z]
1936
1937 Example:
1938 >>> context = Context()
1939 >>> patch_uuid = context.addPatch(center=vec3(0, 0, 0), size=vec2(1, 1))
1940 >>> # Translate single primitive
1941 >>> context.translatePrimitive(patch_uuid, vec3(1, 0, 0)) # Move 1 unit in x
1942 >>> # Translate multiple primitives
1943 >>> context.translatePrimitive([uuid1, uuid2, uuid3], vec3(0, 0, 1)) # Move 1 unit in z
1944 """
1946
1947 # Type validation
1948 if not isinstance(shift, vec3):
1949 raise ValueError(f"shift must be a vec3, got {type(shift).__name__}")
1950
1951 if isinstance(UUID, int):
1952 context_wrapper.translatePrimitive(self.context, UUID, shift.to_list())
1953 elif isinstance(UUID, list):
1954 context_wrapper.translatePrimitives(self.context, UUID, shift.to_list())
1955 else:
1956 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
1957
1958 def translateObject(self, ObjID: Union[int, List[int]], shift: vec3) -> None:
1959 """
1960 Translate one or more compound objects by a shift vector.
1961
1962 Moves the specified compound object(s) and all their constituent
1963 primitives by the given shift vector without changing orientation or size.
1964
1965 Args:
1966 ObjID: Single object ID or list of object IDs to translate
1967 shift: 3D vector representing the translation [x, y, z]
1968
1969 Example:
1970 >>> context = Context()
1971 >>> tile_uuids = context.addTile(center=vec3(0, 0, 0), size=vec2(2, 2))
1972 >>> obj_id = context.getPrimitiveParentObjectID(tile_uuids[0]) # Get object ID
1973 >>> # Translate single object
1974 >>> context.translateObject(obj_id, vec3(5, 0, 0)) # Move 5 units in x
1975 >>> # Translate multiple objects
1976 >>> context.translateObject([obj1, obj2, obj3], vec3(0, 2, 0)) # Move 2 units in y
1977 """
1979
1980 # Type validation
1981 if not isinstance(shift, vec3):
1982 raise ValueError(f"shift must be a vec3, got {type(shift).__name__}")
1983
1984 if isinstance(ObjID, int):
1985 context_wrapper.translateObject(self.context, ObjID, shift.to_list())
1986 elif isinstance(ObjID, list):
1987 context_wrapper.translateObjects(self.context, ObjID, shift.to_list())
1988 else:
1989 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
1990
1991 def rotatePrimitive(self, UUID: Union[int, List[int]], angle: float,
1992 axis: Union[str, vec3], origin: Optional[vec3] = None) -> None:
1993 """
1994 Rotate one or more primitives.
1995
1996 Args:
1997 UUID: Single UUID or list of UUIDs to rotate
1998 angle: Rotation angle in radians
1999 axis: Rotation axis - either 'x', 'y', 'z' or a vec3 direction vector
2000 origin: Optional rotation origin point. If None, rotates about primitive center.
2001 If provided with string axis, raises ValueError.
2002
2003 Raises:
2004 ValueError: If axis is invalid or if origin is provided with string axis
2005 """
2007
2008 # Validate axis parameter
2009 if isinstance(axis, str):
2010 if axis not in ('x', 'y', 'z'):
2011 raise ValueError("axis must be 'x', 'y', or 'z'")
2012 if origin is not None:
2013 raise ValueError("origin parameter cannot be used with string axis")
2014
2015 # Use string axis variant
2016 if isinstance(UUID, int):
2017 context_wrapper.rotatePrimitive_axisString(self.context, UUID, angle, axis)
2018 elif isinstance(UUID, list):
2019 context_wrapper.rotatePrimitives_axisString(self.context, UUID, angle, axis)
2020 else:
2021 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
2022
2023 elif isinstance(axis, vec3):
2024 axis_list = axis.to_list()
2025
2026 # Check for zero-length axis
2027 if all(abs(v) < 1e-10 for v in axis_list):
2028 raise ValueError("axis vector cannot be zero")
2029
2030 if origin is None:
2031 # Rotate about primitive center (axis vector variant)
2032 if isinstance(UUID, int):
2033 context_wrapper.rotatePrimitive_axisVector(self.context, UUID, angle, axis_list)
2034 elif isinstance(UUID, list):
2035 context_wrapper.rotatePrimitives_axisVector(self.context, UUID, angle, axis_list)
2036 else:
2037 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
2038 else:
2039 # Rotate about specified origin point
2040 if not isinstance(origin, vec3):
2041 raise ValueError(f"origin must be a vec3, got {type(origin).__name__}")
2042
2043 origin_list = origin.to_list()
2044 if isinstance(UUID, int):
2045 context_wrapper.rotatePrimitive_originAxisVector(self.context, UUID, angle, origin_list, axis_list)
2046 elif isinstance(UUID, list):
2047 context_wrapper.rotatePrimitives_originAxisVector(self.context, UUID, angle, origin_list, axis_list)
2048 else:
2049 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
2050 else:
2051 raise ValueError(f"axis must be str or vec3, got {type(axis).__name__}")
2052
2053 def rotateObject(self, ObjID: Union[int, List[int]], angle: float,
2054 axis: Union[str, vec3], origin: Optional[vec3] = None,
2055 about_origin: bool = False) -> None:
2056 """
2057 Rotate one or more objects.
2058
2059 Args:
2060 ObjID: Single object ID or list of object IDs to rotate
2061 angle: Rotation angle in radians
2062 axis: Rotation axis - either 'x', 'y', 'z' or a vec3 direction vector
2063 origin: Optional rotation origin point. If None, rotates about object center.
2064 If provided with string axis, raises ValueError.
2065 about_origin: If True, rotate about the object's own stored origin point
2066 (``object_origin``), which for most objects is its construction center —
2067 NOT the global origin (0,0,0). An object built away from the world origin
2068 therefore spins in place rather than orbiting the world origin. To orbit a
2069 specific point, pass that point as ``origin`` instead. Cannot be used with
2070 the origin parameter.
2071
2072 Raises:
2073 ValueError: If axis is invalid or if origin and about_origin are both specified
2074 """
2076
2077 # Validate parameter combinations
2078 if origin is not None and about_origin:
2079 raise ValueError("Cannot specify both origin and about_origin")
2080
2081 # Validate axis parameter
2082 if isinstance(axis, str):
2083 if axis not in ('x', 'y', 'z'):
2084 raise ValueError("axis must be 'x', 'y', or 'z'")
2085 if origin is not None:
2086 raise ValueError("origin parameter cannot be used with string axis")
2087 if about_origin:
2088 raise ValueError("about_origin parameter cannot be used with string axis")
2089
2090 # Use string axis variant
2091 if isinstance(ObjID, int):
2092 context_wrapper.rotateObject_axisString(self.context, ObjID, angle, axis)
2093 elif isinstance(ObjID, list):
2094 context_wrapper.rotateObjects_axisString(self.context, ObjID, angle, axis)
2095 else:
2096 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
2097
2098 elif isinstance(axis, vec3):
2099 axis_list = axis.to_list()
2100
2101 # Check for zero-length axis
2102 if all(abs(v) < 1e-10 for v in axis_list):
2103 raise ValueError("axis vector cannot be zero")
2104
2105 if about_origin:
2106 # Rotate about global origin
2107 if isinstance(ObjID, int):
2108 context_wrapper.rotateObjectAboutOrigin_axisVector(self.context, ObjID, angle, axis_list)
2109 elif isinstance(ObjID, list):
2110 context_wrapper.rotateObjectsAboutOrigin_axisVector(self.context, ObjID, angle, axis_list)
2111 else:
2112 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
2113 elif origin is None:
2114 # Rotate about object center
2115 if isinstance(ObjID, int):
2116 context_wrapper.rotateObject_axisVector(self.context, ObjID, angle, axis_list)
2117 elif isinstance(ObjID, list):
2118 context_wrapper.rotateObjects_axisVector(self.context, ObjID, angle, axis_list)
2119 else:
2120 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
2121 else:
2122 # Rotate about specified origin point
2123 if not isinstance(origin, vec3):
2124 raise ValueError(f"origin must be a vec3, got {type(origin).__name__}")
2125
2126 origin_list = origin.to_list()
2127 if isinstance(ObjID, int):
2128 context_wrapper.rotateObject_originAxisVector(self.context, ObjID, angle, origin_list, axis_list)
2129 elif isinstance(ObjID, list):
2130 context_wrapper.rotateObjects_originAxisVector(self.context, ObjID, angle, origin_list, axis_list)
2131 else:
2132 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
2133 else:
2134 raise ValueError(f"axis must be str or vec3, got {type(axis).__name__}")
2135
2136 def scalePrimitive(self, UUID: Union[int, List[int]], scale: vec3, point: Optional[vec3] = None) -> None:
2137 """
2138 Scale one or more primitives.
2139
2140 Args:
2141 UUID: Single UUID or list of UUIDs to scale
2142 scale: Scale factors as vec3(x, y, z)
2143 point: Optional point to scale about. If None, scales about primitive center.
2144
2145 Raises:
2146 ValueError: If scale or point parameters are invalid
2147 """
2149
2150 if not isinstance(scale, vec3):
2151 raise ValueError(f"scale must be a vec3, got {type(scale).__name__}")
2152
2153 scale_list = scale.to_list()
2154
2155 if point is None:
2156 # Scale about primitive center
2157 if isinstance(UUID, int):
2158 context_wrapper.scalePrimitive(self.context, UUID, scale_list)
2159 elif isinstance(UUID, list):
2160 context_wrapper.scalePrimitives(self.context, UUID, scale_list)
2161 else:
2162 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
2163 else:
2164 # Scale about specified point
2165 if not isinstance(point, vec3):
2166 raise ValueError(f"point must be a vec3, got {type(point).__name__}")
2167
2168 point_list = point.to_list()
2169 if isinstance(UUID, int):
2170 context_wrapper.scalePrimitiveAboutPoint(self.context, UUID, scale_list, point_list)
2171 elif isinstance(UUID, list):
2172 context_wrapper.scalePrimitivesAboutPoint(self.context, UUID, scale_list, point_list)
2173 else:
2174 raise ValueError(f"UUID must be int or List[int], got {type(UUID).__name__}")
2175
2176 def scaleObject(self, ObjID: Union[int, List[int]], scale: vec3,
2177 point: Optional[vec3] = None, about_center: bool = False,
2178 about_origin: bool = False) -> None:
2179 """
2180 Scale one or more objects.
2181
2182 Args:
2183 ObjID: Single object ID or list of object IDs to scale
2184 scale: Scale factors as vec3(x, y, z)
2185 point: Optional point to scale about
2186 about_center: If True, scale about object center (default behavior)
2187 about_origin: If True, scale about the object's own stored origin point
2188 (``object_origin``), not the global origin (0,0,0). Pass ``point`` to
2189 scale about a specific location instead.
2190
2191 Raises:
2192 ValueError: If parameters are invalid or conflicting options specified
2193 """
2195
2196 # Validate parameter combinations
2197 options_count = sum([point is not None, about_center, about_origin])
2198 if options_count > 1:
2199 raise ValueError("Cannot specify multiple scaling options (point, about_center, about_origin)")
2200
2201 if not isinstance(scale, vec3):
2202 raise ValueError(f"scale must be a vec3, got {type(scale).__name__}")
2203
2204 scale_list = scale.to_list()
2205
2206 if about_origin:
2207 # Scale about global origin
2208 if isinstance(ObjID, int):
2209 context_wrapper.scaleObjectAboutOrigin(self.context, ObjID, scale_list)
2210 elif isinstance(ObjID, list):
2211 context_wrapper.scaleObjectsAboutOrigin(self.context, ObjID, scale_list)
2212 else:
2213 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
2214 elif about_center:
2215 # Scale about object center
2216 if isinstance(ObjID, int):
2217 context_wrapper.scaleObjectAboutCenter(self.context, ObjID, scale_list)
2218 elif isinstance(ObjID, list):
2219 context_wrapper.scaleObjectsAboutCenter(self.context, ObjID, scale_list)
2220 else:
2221 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
2222 elif point is not None:
2223 # Scale about specified point
2224 if not isinstance(point, vec3):
2225 raise ValueError(f"point must be a vec3, got {type(point).__name__}")
2226
2227 point_list = point.to_list()
2228 if isinstance(ObjID, int):
2229 context_wrapper.scaleObjectAboutPoint(self.context, ObjID, scale_list, point_list)
2230 elif isinstance(ObjID, list):
2231 context_wrapper.scaleObjectsAboutPoint(self.context, ObjID, scale_list, point_list)
2232 else:
2233 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
2234 else:
2235 # Default: scale object (standard behavior)
2236 if isinstance(ObjID, int):
2237 context_wrapper.scaleObject(self.context, ObjID, scale_list)
2238 elif isinstance(ObjID, list):
2239 context_wrapper.scaleObjects(self.context, ObjID, scale_list)
2240 else:
2241 raise ValueError(f"ObjID must be int or List[int], got {type(ObjID).__name__}")
2242
2243 def scaleConeObjectLength(self, ObjID: int, scale_factor: float) -> None:
2244 """
2245 Scale the length of a Cone object by scaling the distance between its two nodes.
2246
2247 Args:
2248 ObjID: Object ID of the Cone to scale
2249 scale_factor: Factor by which to scale the cone length (e.g., 2.0 doubles length)
2250
2251 Raises:
2252 ValueError: If ObjID is not an integer or scale_factor is invalid
2253 HeliosRuntimeError: If operation fails (e.g., ObjID is not a Cone object)
2254
2255 Note:
2256 Added in helios-core v1.3.59 as a replacement for the removed getConeObjectPointer()
2257 method, enforcing better encapsulation.
2258
2259 Example:
2260 >>> cone_id = context.addConeObject(10, [0,0,0], [0,0,1], 0.1, 0.05)
2261 >>> context.scaleConeObjectLength(cone_id, 1.5) # Make cone 50% longer
2262 """
2263 if not isinstance(ObjID, int):
2264 raise ValueError(f"ObjID must be an integer, got {type(ObjID).__name__}")
2265 if not isinstance(scale_factor, (int, float)):
2266 raise ValueError(f"scale_factor must be numeric, got {type(scale_factor).__name__}")
2267 if scale_factor <= 0:
2268 raise ValueError(f"scale_factor must be positive, got {scale_factor}")
2269
2270 context_wrapper.scaleConeObjectLength(self.context, ObjID, float(scale_factor))
2271
2272 def scaleConeObjectGirth(self, ObjID: int, scale_factor: float) -> None:
2273 """
2274 Scale the girth of a Cone object by scaling the radii at both nodes.
2275
2276 Args:
2277 ObjID: Object ID of the Cone to scale
2278 scale_factor: Factor by which to scale the cone girth (e.g., 2.0 doubles girth)
2279
2280 Raises:
2281 ValueError: If ObjID is not an integer or scale_factor is invalid
2282 HeliosRuntimeError: If operation fails (e.g., ObjID is not a Cone object)
2283
2284 Note:
2285 Added in helios-core v1.3.59 as a replacement for the removed getConeObjectPointer()
2286 method, enforcing better encapsulation.
2287
2288 Example:
2289 >>> cone_id = context.addConeObject(10, [0,0,0], [0,0,1], 0.1, 0.05)
2290 >>> context.scaleConeObjectGirth(cone_id, 2.0) # Double the cone girth
2291 """
2292 if not isinstance(ObjID, int):
2293 raise ValueError(f"ObjID must be an integer, got {type(ObjID).__name__}")
2294 if not isinstance(scale_factor, (int, float)):
2295 raise ValueError(f"scale_factor must be numeric, got {type(scale_factor).__name__}")
2296 if scale_factor <= 0:
2297 raise ValueError(f"scale_factor must be positive, got {scale_factor}")
2298
2299 context_wrapper.scaleConeObjectGirth(self.context, ObjID, float(scale_factor))
2300
2301 def loadPLY(self, filename: str, origin: Optional[vec3] = None, height: Optional[float] = None,
2302 rotation: Optional[SphericalCoord] = None, color: Optional[RGBcolor] = None,
2303 upaxis: str = "YUP", silent: bool = False) -> List[int]:
2304 """
2305 Load geometry from a PLY (Stanford Polygon) file.
2307 Args:
2308 filename: Path to the PLY file to load
2309 origin: Origin point for positioning the geometry (optional)
2310 height: Absolute height in metres of the loaded geometry after scaling -- not a multiplier; the model is uniformly scaled so its vertical extent equals this value (optional)
2311 rotation: Rotation to apply to the geometry (optional)
2312 color: Default color for geometry without color data (optional)
2313 upaxis: Up axis orientation ("YUP" or "ZUP")
2314 silent: If True, suppress loading output messages
2315
2316 Returns:
2317 List of UUIDs for the loaded primitives
2318 """
2320
2321 # Parameter type validation
2322 if origin is not None and not isinstance(origin, vec3):
2323 raise ValueError(f"Origin must be a vec3 or None, got {type(origin).__name__}")
2324 if rotation is not None and not isinstance(rotation, SphericalCoord):
2325 raise ValueError(f"Rotation must be a SphericalCoord or None, got {type(rotation).__name__}")
2326 if color is not None and not isinstance(color, RGBcolor):
2327 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
2328
2329 # Validate file path for security
2330 validated_filename = self._validate_file_path(filename, ['.ply'])
2332 if origin is None and height is None and rotation is None and color is None:
2333 # Simple load with no transformations
2334 return context_wrapper.loadPLY(self.context, validated_filename, silent)
2335
2336 elif origin is not None and height is not None and rotation is None and color is None:
2337 # Load with origin and height
2338 return context_wrapper.loadPLYWithOriginHeight(self.context, validated_filename, origin.to_list(), height, upaxis, silent)
2339
2340 elif origin is not None and height is not None and rotation is not None and color is None:
2341 # Load with origin, height, and rotation
2342 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
2343 return context_wrapper.loadPLYWithOriginHeightRotation(self.context, validated_filename, origin.to_list(), height, rotation_list, upaxis, silent)
2344
2345 elif origin is not None and height is not None and rotation is None and color is not None:
2346 # Load with origin, height, and color
2347 return context_wrapper.loadPLYWithOriginHeightColor(self.context, validated_filename, origin.to_list(), height, color.to_list(), upaxis, silent)
2348
2349 elif origin is not None and height is not None and rotation is not None and color is not None:
2350 # Load with all parameters
2351 rotation_list = [rotation.radius, rotation.elevation, rotation.azimuth]
2352 return context_wrapper.loadPLYWithOriginHeightRotationColor(self.context, validated_filename, origin.to_list(), height, rotation_list, color.to_list(), upaxis, silent)
2353
2354 else:
2355 raise ValueError("Invalid parameter combination. When using transformations, both origin and height are required.")
2356
2357 def loadOBJ(self, filename: str, origin: Optional[vec3] = None, height: Optional[float] = None,
2358 scale: Optional[vec3] = None, rotation: Optional[SphericalCoord] = None,
2359 color: Optional[RGBcolor] = None, upaxis: str = "YUP", silent: bool = False) -> List[int]:
2360 """
2361 Load geometry from an OBJ (Wavefront) file.
2362
2363 Args:
2364 filename: Path to the OBJ file to load
2365 origin: Origin point for positioning the geometry (optional)
2366 height: Absolute height in metres of the loaded geometry after scaling -- not a multiplier (optional, alternative to scale)
2367 scale: Scale factor for all dimensions (optional, alternative to height)
2368 rotation: Rotation to apply to the geometry (optional)
2369 color: Default color for geometry without color data (optional)
2370 upaxis: Up axis orientation ("YUP" or "ZUP")
2371 silent: If True, suppress loading output messages
2372
2373 Returns:
2374 List of UUIDs for the loaded primitives
2375 """
2377
2378 # Parameter type validation
2379 if origin is not None and not isinstance(origin, vec3):
2380 raise ValueError(f"Origin must be a vec3 or None, got {type(origin).__name__}")
2381 if scale is not None and not isinstance(scale, vec3):
2382 raise ValueError(f"Scale must be a vec3 or None, got {type(scale).__name__}")
2383 if rotation is not None and not isinstance(rotation, SphericalCoord):
2384 raise ValueError(f"Rotation must be a SphericalCoord or None, got {type(rotation).__name__}")
2385 if color is not None and not isinstance(color, RGBcolor):
2386 raise ValueError(f"Color must be an RGBcolor or None, got {type(color).__name__}")
2387
2388 # Validate file path for security
2389 validated_filename = self._validate_file_path(filename, ['.obj'])
2390
2391 if origin is None and height is None and scale is None and rotation is None and color is None:
2392 # Simple load with no transformations
2393 return context_wrapper.loadOBJ(self.context, validated_filename, silent)
2394
2395 elif origin is not None and height is not None and scale is None and rotation is not None and color is not None:
2396 # Load with origin, height, rotation, and color (no upaxis)
2397 return context_wrapper.loadOBJWithOriginHeightRotationColor(self.context, validated_filename, origin.to_list(), height, rotation.to_list(), color.to_list(), silent)
2398
2399 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":
2400 # Load with origin, height, rotation, color, and upaxis
2401 return context_wrapper.loadOBJWithOriginHeightRotationColorUpaxis(self.context, validated_filename, origin.to_list(), height, rotation.to_list(), color.to_list(), upaxis, silent)
2402
2403 elif origin is not None and scale is not None and rotation is not None and color is not None:
2404 # Load with origin, scale, rotation, color, and upaxis
2405 return context_wrapper.loadOBJWithOriginScaleRotationColorUpaxis(self.context, validated_filename, origin.to_list(), scale.to_list(), rotation.to_list(), color.to_list(), upaxis, silent)
2406
2407 else:
2408 raise ValueError("Invalid parameter combination. For OBJ loading, you must provide either: " +
2409 "1) No parameters (simple load), " +
2410 "2) origin + height + rotation + color, " +
2411 "3) origin + height + rotation + color + upaxis, or " +
2412 "4) origin + scale + rotation + color + upaxis")
2413
2414 def loadXML(self, filename: str, quiet: bool = False) -> List[int]:
2415 """
2416 Load geometry from a Helios XML file.
2417
2418 Args:
2419 filename: Path to the XML file to load
2420 quiet: If True, suppress loading output messages
2421
2422 Returns:
2423 List of UUIDs for the loaded primitives
2424 """
2426 # Validate file path for security
2427 validated_filename = self._validate_file_path(filename, ['.xml'])
2428
2429 return context_wrapper.loadXML(self.context, validated_filename, quiet)
2430
2431 def writePLY(self, filename: str, UUIDs: Optional[List[int]] = None) -> None:
2432 """
2433 Write geometry to a PLY (Stanford Polygon) file.
2434
2435 Args:
2436 filename: Path to the output PLY file
2437 UUIDs: Optional list of primitive UUIDs to export. If None, exports all primitives
2438
2439 Raises:
2440 ValueError: If filename is invalid or UUIDs are invalid
2441 PermissionError: If output directory is not writable
2442 FileNotFoundError: If UUIDs do not exist in context
2443 RuntimeError: If Context is in mock mode
2444
2445 Example:
2446 >>> context.writePLY("output.ply") # Export all primitives
2447 >>> context.writePLY("subset.ply", [uuid1, uuid2]) # Export specific primitives
2448 """
2450
2451 # Validate output file path for security
2452 validated_filename = self._validate_output_file_path(filename, ['.ply'])
2453
2454 if UUIDs is None:
2455 # Export all primitives
2456 context_wrapper.writePLY(self.context, validated_filename)
2457 else:
2458 # Validate UUIDs exist in context
2459 if not UUIDs:
2460 raise ValueError("UUIDs list cannot be empty. Use UUIDs=None to export all primitives")
2461
2462 # Validate the UUIDs exist
2464
2465 # Export specified UUIDs
2466 context_wrapper.writePLYWithUUIDs(self.context, validated_filename, UUIDs)
2467
2468 def writeOBJ(self, filename: str, UUIDs: Optional[List[int]] = None,
2469 primitive_data_fields: Optional[List[str]] = None,
2470 write_normals: bool = False, silent: bool = False) -> None:
2471 """
2472 Write geometry to an OBJ (Wavefront) file.
2473
2474 Args:
2475 filename: Path to the output OBJ file
2476 UUIDs: Optional list of primitive UUIDs to export. If None, exports all primitives
2477 primitive_data_fields: Optional list of primitive data field names to export
2478 write_normals: Whether to include vertex normals in the output
2479 silent: Whether to suppress output messages during export
2480
2481 Raises:
2482 ValueError: If filename is invalid, UUIDs are invalid, or data fields don't exist
2483 PermissionError: If output directory is not writable
2484 FileNotFoundError: If UUIDs do not exist in context
2485 RuntimeError: If Context is in mock mode
2486
2487 Example:
2488 >>> context.writeOBJ("output.obj") # Export all primitives
2489 >>> context.writeOBJ("subset.obj", [uuid1, uuid2]) # Export specific primitives
2490 >>> context.writeOBJ("with_data.obj", [uuid1], ["temperature", "area"]) # Export with data
2491 """
2493
2494 # Validate output file path for security
2495 validated_filename = self._validate_output_file_path(filename, ['.obj'])
2496
2497 if UUIDs is None:
2498 # Export all primitives
2499 context_wrapper.writeOBJ(self.context, validated_filename, write_normals, silent)
2500 elif primitive_data_fields is None:
2501 # Export specified UUIDs without data fields
2502 if not UUIDs:
2503 raise ValueError("UUIDs list cannot be empty. Use UUIDs=None to export all primitives")
2505 # Validate the UUIDs exist
2506 self._validate_uuids(UUIDs)
2507
2508 context_wrapper.writeOBJWithUUIDs(self.context, validated_filename, UUIDs, write_normals, silent)
2509 else:
2510 # Export specified UUIDs with primitive data fields
2511 if not UUIDs:
2512 raise ValueError("UUIDs list cannot be empty when exporting primitive data")
2513 if not primitive_data_fields:
2514 raise ValueError("primitive_data_fields list cannot be empty")
2515
2516 # Validate the UUIDs exist
2517 self._validate_uuids(UUIDs)
2518
2519 # Note: Primitive data field validation is handled by the native library
2520 # which will raise appropriate errors if fields don't exist for the specified primitives
2521
2522 context_wrapper.writeOBJWithPrimitiveData(self.context, validated_filename, UUIDs, primitive_data_fields, write_normals, silent)
2523
2524 def writePrimitiveData(self, filename: str, column_labels: List[str],
2525 UUIDs: Optional[List[int]] = None,
2526 print_header: bool = False) -> None:
2527 """
2528 Write primitive data to an ASCII text file.
2529
2530 Outputs a space-separated text file where each row corresponds to a primitive
2531 and each column corresponds to a primitive data label.
2532
2533 Args:
2534 filename: Path to the output file
2535 column_labels: List of primitive data labels to include as columns.
2536 Use "UUID" to include primitive UUIDs as a column.
2537 The order determines the column order in the output file.
2538 UUIDs: Optional list of primitive UUIDs to include. If None, includes all primitives.
2539 print_header: If True, writes column labels as the first line of the file
2540
2541 Raises:
2542 ValueError: If filename is invalid, column_labels is empty, or UUIDs list is empty when provided
2543 HeliosFileIOError: If file cannot be written
2544 HeliosRuntimeError: If a column label doesn't exist for any primitive
2545
2546 Example:
2547 >>> # Write temperature and area for all primitives
2548 >>> context.writePrimitiveData("output.txt", ["UUID", "temperature", "area"])
2549
2550 >>> # Write with header row
2551 >>> context.writePrimitiveData("output.txt", ["UUID", "radiation_flux"], print_header=True)
2552
2553 >>> # Write only for selected primitives
2554 >>> context.writePrimitiveData("subset.txt", ["temperature"], UUIDs=[uuid1, uuid2])
2555 """
2557
2558 # Validate column_labels
2559 if not column_labels:
2560 raise ValueError("column_labels list cannot be empty")
2561
2562 # Validate output file path (allow any extension for text files)
2563 validated_filename = self._validate_output_file_path(filename)
2564
2565 if UUIDs is None:
2566 # Export all primitives
2567 context_wrapper.writePrimitiveData(self.context, validated_filename, column_labels, print_header)
2568 else:
2569 # Export specified UUIDs
2570 if not UUIDs:
2571 raise ValueError("UUIDs list cannot be empty when provided. Use UUIDs=None to include all primitives")
2572
2573 # Validate the UUIDs exist
2574 self._validate_uuids(UUIDs)
2575
2576 context_wrapper.writePrimitiveDataWithUUIDs(self.context, validated_filename, column_labels, UUIDs, print_header)
2577
2578 def addTrianglesFromArrays(self, vertices: np.ndarray, faces: np.ndarray,
2579 colors: Optional[np.ndarray] = None) -> List[int]:
2580 """
2581 Add triangles from NumPy arrays (compatible with trimesh, Open3D format).
2582
2583 Args:
2584 vertices: NumPy array of shape (N, 3) containing vertex coordinates as float32/float64
2585 faces: NumPy array of shape (M, 3) containing triangle vertex indices as int32/int64
2586 colors: Optional NumPy array of shape (N, 3) or (M, 3) containing RGB colors as float32/float64
2587 If shape (N, 3): per-vertex colors
2588 If shape (M, 3): per-triangle colors
2589
2590 Returns:
2591 List of UUIDs for the added triangles
2592
2593 Raises:
2594 ValueError: If array dimensions are invalid
2595 """
2596 # Validate input arrays
2597 if vertices.ndim != 2 or vertices.shape[1] != 3:
2598 raise ValueError(f"Vertices array must have shape (N, 3), got {vertices.shape}")
2599 if faces.ndim != 2 or faces.shape[1] != 3:
2600 raise ValueError(f"Faces array must have shape (M, 3), got {faces.shape}")
2601
2602 # Check vertex indices are valid
2603 max_vertex_index = np.max(faces)
2604 if max_vertex_index >= vertices.shape[0]:
2605 raise ValueError(f"Face indices reference vertex {max_vertex_index}, but only {vertices.shape[0]} vertices provided")
2606
2607 # Validate colors array if provided
2608 per_vertex_colors = False
2609 per_triangle_colors = False
2610 if colors is not None:
2611 if colors.ndim != 2 or colors.shape[1] != 3:
2612 raise ValueError(f"Colors array must have shape (N, 3) or (M, 3), got {colors.shape}")
2613 if colors.shape[0] == vertices.shape[0]:
2614 per_vertex_colors = True
2615 elif colors.shape[0] == faces.shape[0]:
2616 per_triangle_colors = True
2617 else:
2618 raise ValueError(f"Colors array shape {colors.shape} doesn't match vertices ({vertices.shape[0]},) or faces ({faces.shape[0]},)")
2619
2620 # Convert arrays to appropriate data types
2621 vertices_float = vertices.astype(np.float32)
2622 faces_int = faces.astype(np.int32)
2623 if colors is not None:
2624 colors_float = colors.astype(np.float32)
2625
2626 # Gather each face's three corners up front. Indexing the whole face table at once
2627 # avoids re-slicing per triangle, and .tolist() on the gathered block converts to
2628 # Python floats in one pass rather than one call per vertex.
2629 corner0 = vertices_float[faces_int[:, 0]].tolist()
2630 corner1 = vertices_float[faces_int[:, 1]].tolist()
2631 corner2 = vertices_float[faces_int[:, 2]].tolist()
2632
2633 # Resolve the per-triangle colour for the whole mesh in one array operation. The
2634 # per-vertex case averages the three corner colours; doing that with a np.mean() call
2635 # per face dominated the runtime of the trimesh/Open3D import path.
2636 if colors is None:
2637 face_colors = None
2638 elif per_triangle_colors:
2639 face_colors = colors_float.tolist()
2640 else: # per_vertex_colors
2641 averaged = (colors_float[faces_int[:, 0]]
2642 + colors_float[faces_int[:, 1]]
2643 + colors_float[faces_int[:, 2]]) / 3.0
2644 face_colors = averaged.tolist()
2645
2646 # Add triangles
2647 triangle_uuids = []
2648 if face_colors is None:
2649 for vertex0, vertex1, vertex2 in zip(corner0, corner1, corner2):
2650 triangle_uuids.append(
2651 context_wrapper.addTriangle(self.context, vertex0, vertex1, vertex2))
2652 else:
2653 for vertex0, vertex1, vertex2, color in zip(corner0, corner1, corner2, face_colors):
2654 triangle_uuids.append(
2655 context_wrapper.addTriangleWithColor(
2656 self.context, vertex0, vertex1, vertex2, color))
2657
2658 return triangle_uuids
2659
2660 def addTrianglesFromArraysTextured(self, vertices: np.ndarray, faces: np.ndarray,
2661 uv_coords: np.ndarray, texture_files: Union[str, List[str]],
2662 material_ids: Optional[np.ndarray] = None) -> List[int]:
2663 """
2664 Add textured triangles from NumPy arrays with support for multiple textures.
2665
2666 This method supports both single-texture and multi-texture workflows:
2667 - Single texture: Pass a single texture file string, all faces use the same texture
2668 - Multiple textures: Pass a list of texture files and material_ids array specifying which texture each face uses
2669
2670 Args:
2671 vertices: NumPy array of shape (N, 3) containing vertex coordinates as float32/float64
2672 faces: NumPy array of shape (M, 3) containing triangle vertex indices as int32/int64
2673 uv_coords: NumPy array of shape (N, 2) containing UV texture coordinates as float32/float64
2674 texture_files: Single texture file path (str) or list of texture file paths (List[str])
2675 material_ids: Optional NumPy array of shape (M,) containing material ID for each face.
2676 If None and texture_files is a list, all faces use texture 0.
2677 If None and texture_files is a string, this parameter is ignored.
2678
2679 Returns:
2680 List of UUIDs for the added textured triangles
2681
2682 Raises:
2683 ValueError: If array dimensions are invalid or material IDs are out of range
2684
2685 Example:
2686 # Single texture usage (backward compatible)
2687 >>> uuids = context.addTrianglesFromArraysTextured(vertices, faces, uvs, "texture.png")
2688
2689 # Multi-texture usage (Open3D style)
2690 >>> texture_files = ["wood.png", "metal.png", "glass.png"]
2691 >>> material_ids = np.array([0, 0, 1, 1, 2, 2]) # 6 faces using different textures
2692 >>> uuids = context.addTrianglesFromArraysTextured(vertices, faces, uvs, texture_files, material_ids)
2693 """
2695
2696 # Validate input arrays
2697 if vertices.ndim != 2 or vertices.shape[1] != 3:
2698 raise ValueError(f"Vertices array must have shape (N, 3), got {vertices.shape}")
2699 if faces.ndim != 2 or faces.shape[1] != 3:
2700 raise ValueError(f"Faces array must have shape (M, 3), got {faces.shape}")
2701 if uv_coords.ndim != 2 or uv_coords.shape[1] != 2:
2702 raise ValueError(f"UV coordinates array must have shape (N, 2), got {uv_coords.shape}")
2703
2704 # Check array consistency
2705 if uv_coords.shape[0] != vertices.shape[0]:
2706 raise ValueError(f"UV coordinates count ({uv_coords.shape[0]}) must match vertices count ({vertices.shape[0]})")
2707
2708 # Check vertex indices are valid
2709 max_vertex_index = np.max(faces)
2710 if max_vertex_index >= vertices.shape[0]:
2711 raise ValueError(f"Face indices reference vertex {max_vertex_index}, but only {vertices.shape[0]} vertices provided")
2712
2713 # Handle texture files parameter (single string or list)
2714 if isinstance(texture_files, str):
2715 # Single texture case - use original implementation for efficiency
2716 texture_file_list = [texture_files]
2717 if material_ids is None:
2718 material_ids = np.zeros(faces.shape[0], dtype=np.uint32)
2719 else:
2720 # Validate that all material IDs are 0 for single texture
2721 if not np.all(material_ids == 0):
2722 raise ValueError("When using single texture file, all material IDs must be 0")
2723 else:
2724 # Multiple textures case
2725 texture_file_list = list(texture_files)
2726 if len(texture_file_list) == 0:
2727 raise ValueError("Texture files list cannot be empty")
2728
2729 if material_ids is None:
2730 # Default: all faces use first texture
2731 material_ids = np.zeros(faces.shape[0], dtype=np.uint32)
2732 else:
2733 # Validate material IDs array
2734 if material_ids.ndim != 1 or material_ids.shape[0] != faces.shape[0]:
2735 raise ValueError(f"Material IDs must have shape ({faces.shape[0]},), got {material_ids.shape}")
2736
2737 # Check material ID range
2738 max_material_id = np.max(material_ids)
2739 if max_material_id >= len(texture_file_list):
2740 raise ValueError(f"Material ID {max_material_id} exceeds texture count {len(texture_file_list)}")
2741
2742 # Validate all texture files exist
2743 for i, texture_file in enumerate(texture_file_list):
2744 try:
2745 self._validate_file_path(texture_file)
2746 except (FileNotFoundError, ValueError) as e:
2747 raise ValueError(f"Texture file {i} ({texture_file}): {e}")
2748
2749 # Use efficient multi-texture C++ implementation if available, otherwise triangle-by-triangle
2750 if 'addTrianglesFromArraysMultiTextured' in context_wrapper._AVAILABLE_TRIANGLE_FUNCTIONS:
2751 return context_wrapper.addTrianglesFromArraysMultiTextured(
2752 self.context, vertices, faces, uv_coords, texture_file_list, material_ids
2753 )
2754 else:
2755 # Use triangle-by-triangle approach with addTriangleTextured
2756 from .wrappers.DataTypes import vec3, vec2
2757
2758 vertices_float = vertices.astype(np.float32)
2759 faces_int = faces.astype(np.int32)
2760 uv_coords_float = uv_coords.astype(np.float32)
2761
2762 triangle_uuids = []
2763 for i in range(faces.shape[0]):
2764 # Get vertex indices for this triangle
2765 v0_idx, v1_idx, v2_idx = faces_int[i]
2766
2767 # Get vertex coordinates as vec3 objects
2768 vertex0 = vec3(vertices_float[v0_idx][0], vertices_float[v0_idx][1], vertices_float[v0_idx][2])
2769 vertex1 = vec3(vertices_float[v1_idx][0], vertices_float[v1_idx][1], vertices_float[v1_idx][2])
2770 vertex2 = vec3(vertices_float[v2_idx][0], vertices_float[v2_idx][1], vertices_float[v2_idx][2])
2771
2772 # Get UV coordinates as vec2 objects
2773 uv0 = vec2(uv_coords_float[v0_idx][0], uv_coords_float[v0_idx][1])
2774 uv1 = vec2(uv_coords_float[v1_idx][0], uv_coords_float[v1_idx][1])
2775 uv2 = vec2(uv_coords_float[v2_idx][0], uv_coords_float[v2_idx][1])
2776
2777 # Use texture file based on material ID for this triangle
2778 material_id = material_ids[i]
2779 texture_file = texture_file_list[material_id]
2780
2781 # Add textured triangle using the new addTriangleTextured method
2782 uuid = self.addTriangleTextured(vertex0, vertex1, vertex2, texture_file, uv0, uv1, uv2)
2783 triangle_uuids.append(uuid)
2784
2785 return triangle_uuids
2786
2787 # ==================== PRIMITIVE DATA METHODS ====================
2788 # Primitive data is a flexible key-value store where users can associate
2789 # arbitrary data with primitives using string keys
2790
2791 def setPrimitiveDataInt(self, uuids_or_uuid, label: str, value: int) -> None:
2792 """
2793 Set primitive data as signed 32-bit integer for one or multiple primitives.
2794
2795 Args:
2796 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2797 label: String key for the data
2798 value: Signed integer scalar (broadcast to all UUIDs), or a list of
2799 values (one per UUID) to set a distinct value on each primitive.
2800 """
2801 if isinstance(uuids_or_uuid, (list, tuple)):
2802 if isinstance(value, (list, tuple, np.ndarray)):
2803 context_wrapper.setPrimitiveDataArray(self.context, uuids_or_uuid, label, 'Int', value)
2804 else:
2805 context_wrapper.setBroadcastPrimitiveDataInt(self.context, uuids_or_uuid, label, value)
2806 else:
2807 context_wrapper.setPrimitiveDataInt(self.context, uuids_or_uuid, label, value)
2808
2809 def setPrimitiveDataUInt(self, uuids_or_uuid, label: str, value: int) -> None:
2810 """
2811 Set primitive data as unsigned 32-bit integer for one or multiple primitives.
2812
2813 Critical for properties like 'twosided_flag' which must be uint in C++.
2814
2815 Args:
2816 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2817 label: String key for the data
2818 value: Unsigned integer scalar (broadcast to all UUIDs), or a list of
2819 values (one per UUID) to set a distinct value on each primitive.
2820 """
2821 if isinstance(uuids_or_uuid, (list, tuple)):
2822 if isinstance(value, (list, tuple, np.ndarray)):
2823 context_wrapper.setPrimitiveDataArray(self.context, uuids_or_uuid, label, 'UInt', value)
2824 else:
2825 context_wrapper.setBroadcastPrimitiveDataUInt(self.context, uuids_or_uuid, label, value)
2826 else:
2827 context_wrapper.setPrimitiveDataUInt(self.context, uuids_or_uuid, label, value)
2828
2829 def setPrimitiveDataFloat(self, uuids_or_uuid, label: str, value: float) -> None:
2830 """
2831 Set primitive data as 32-bit float for one or multiple primitives.
2832
2833 Args:
2834 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2835 label: String key for the data
2836 value: Float scalar (broadcast to all UUIDs), or a list of values
2837 (one per UUID) to set a distinct value on each primitive.
2838 """
2839 if isinstance(uuids_or_uuid, (list, tuple)):
2840 if isinstance(value, (list, tuple, np.ndarray)):
2841 context_wrapper.setPrimitiveDataArray(self.context, uuids_or_uuid, label, 'Float', value)
2842 else:
2843 context_wrapper.setBroadcastPrimitiveDataFloat(self.context, uuids_or_uuid, label, value)
2844 else:
2845 context_wrapper.setPrimitiveDataFloat(self.context, uuids_or_uuid, label, value)
2846
2847 def setPrimitiveDataDouble(self, uuids_or_uuid, label: str, value: float) -> None:
2848 """
2849 Set primitive data as 64-bit double for one or multiple primitives.
2850
2851 Args:
2852 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2853 label: String key for the data
2854 value: Double scalar (broadcast to all UUIDs), or a list of values
2855 (one per UUID) to set a distinct value on each primitive.
2856 """
2857 if isinstance(uuids_or_uuid, (list, tuple)):
2858 if isinstance(value, (list, tuple, np.ndarray)):
2859 context_wrapper.setPrimitiveDataArray(self.context, uuids_or_uuid, label, 'Double', value)
2860 else:
2861 context_wrapper.setBroadcastPrimitiveDataDouble(self.context, uuids_or_uuid, label, value)
2862 else:
2863 context_wrapper.setPrimitiveDataDouble(self.context, uuids_or_uuid, label, value)
2864
2865 def setPrimitiveDataString(self, uuids_or_uuid, label: str, value: str) -> None:
2866 """
2867 Set primitive data as string for one or multiple primitives.
2868
2869 Args:
2870 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2871 label: String key for the data
2872 value: String scalar (broadcast to all UUIDs), or a list of strings
2873 (one per UUID) to set a distinct value on each primitive.
2874 """
2875 if isinstance(uuids_or_uuid, (list, tuple)):
2876 if isinstance(value, (list, tuple, np.ndarray)):
2877 context_wrapper.setPrimitiveDataArray(self.context, uuids_or_uuid, label, 'String', value)
2878 else:
2879 context_wrapper.setBroadcastPrimitiveDataString(self.context, uuids_or_uuid, label, value)
2880 else:
2881 context_wrapper.setPrimitiveDataString(self.context, uuids_or_uuid, label, value)
2882
2883 def setPrimitiveDataVec2(self, uuids_or_uuid, label: str, x_or_vec, y: float = None) -> None:
2884 """
2885 Set primitive data as vec2 for one or multiple primitives.
2886
2887 Args:
2888 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2889 label: String key for the data
2890 x_or_vec: Either x component (float) or vec2 object
2891 y: Y component (if x_or_vec is float)
2892 """
2893 if isinstance(uuids_or_uuid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
2894 context_wrapper.setPrimitiveDataArray(self.context, uuids_or_uuid, label, 'Vec2', x_or_vec)
2895 return
2896 if hasattr(x_or_vec, 'x'):
2897 x, y = x_or_vec.x, x_or_vec.y
2898 else:
2899 x = x_or_vec
2900 if isinstance(uuids_or_uuid, (list, tuple)):
2901 context_wrapper.setBroadcastPrimitiveDataVec2(self.context, uuids_or_uuid, label, x, y)
2902 else:
2903 context_wrapper.setPrimitiveDataVec2(self.context, uuids_or_uuid, label, x, y)
2904
2905 def setPrimitiveDataVec3(self, uuids_or_uuid, label: str, x_or_vec, y: float = None, z: float = None) -> None:
2906 """
2907 Set primitive data as vec3 for one or multiple primitives.
2908
2909 Args:
2910 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2911 label: String key for the data
2912 x_or_vec: Either x component (float) or vec3 object
2913 y: Y component (if x_or_vec is float)
2914 z: Z component (if x_or_vec is float)
2915 """
2916 if isinstance(uuids_or_uuid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
2917 context_wrapper.setPrimitiveDataArray(self.context, uuids_or_uuid, label, 'Vec3', x_or_vec)
2918 return
2919 if hasattr(x_or_vec, 'x'):
2920 x, y, z = x_or_vec.x, x_or_vec.y, x_or_vec.z
2921 else:
2922 x = x_or_vec
2923 if isinstance(uuids_or_uuid, (list, tuple)):
2924 context_wrapper.setBroadcastPrimitiveDataVec3(self.context, uuids_or_uuid, label, x, y, z)
2925 else:
2926 context_wrapper.setPrimitiveDataVec3(self.context, uuids_or_uuid, label, x, y, z)
2927
2928 def setPrimitiveDataVec4(self, uuids_or_uuid, label: str, x_or_vec, y: float = None, z: float = None, w: float = None) -> None:
2929 """
2930 Set primitive data as vec4 for one or multiple primitives.
2931
2932 Args:
2933 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2934 label: String key for the data
2935 x_or_vec: Either x component (float) or vec4 object
2936 y: Y component (if x_or_vec is float)
2937 z: Z component (if x_or_vec is float)
2938 w: W component (if x_or_vec is float)
2939 """
2940 if isinstance(uuids_or_uuid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
2941 context_wrapper.setPrimitiveDataArray(self.context, uuids_or_uuid, label, 'Vec4', x_or_vec)
2942 return
2943 if hasattr(x_or_vec, 'x'):
2944 x, y, z, w = x_or_vec.x, x_or_vec.y, x_or_vec.z, x_or_vec.w
2945 else:
2946 x = x_or_vec
2947 if isinstance(uuids_or_uuid, (list, tuple)):
2948 context_wrapper.setBroadcastPrimitiveDataVec4(self.context, uuids_or_uuid, label, x, y, z, w)
2949 else:
2950 context_wrapper.setPrimitiveDataVec4(self.context, uuids_or_uuid, label, x, y, z, w)
2951
2952 def setPrimitiveDataInt2(self, uuids_or_uuid, label: str, x_or_vec, y: int = None) -> None:
2953 """
2954 Set primitive data as int2 for one or multiple primitives.
2955
2956 Args:
2957 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2958 label: String key for the data
2959 x_or_vec: Either x component (int) or int2 object
2960 y: Y component (if x_or_vec is int)
2961 """
2962 if isinstance(uuids_or_uuid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
2963 context_wrapper.setPrimitiveDataArray(self.context, uuids_or_uuid, label, 'Int2', x_or_vec)
2964 return
2965 if hasattr(x_or_vec, 'x'):
2966 x, y = x_or_vec.x, x_or_vec.y
2967 else:
2968 x = x_or_vec
2969 if isinstance(uuids_or_uuid, (list, tuple)):
2970 context_wrapper.setBroadcastPrimitiveDataInt2(self.context, uuids_or_uuid, label, x, y)
2971 else:
2972 context_wrapper.setPrimitiveDataInt2(self.context, uuids_or_uuid, label, x, y)
2973
2974 def setPrimitiveDataInt3(self, uuids_or_uuid, label: str, x_or_vec, y: int = None, z: int = None) -> None:
2975 """
2976 Set primitive data as int3 for one or multiple primitives.
2977
2978 Args:
2979 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
2980 label: String key for the data
2981 x_or_vec: Either x component (int) or int3 object
2982 y: Y component (if x_or_vec is int)
2983 z: Z component (if x_or_vec is int)
2984 """
2985 if isinstance(uuids_or_uuid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
2986 context_wrapper.setPrimitiveDataArray(self.context, uuids_or_uuid, label, 'Int3', x_or_vec)
2987 return
2988 if hasattr(x_or_vec, 'x'):
2989 x, y, z = x_or_vec.x, x_or_vec.y, x_or_vec.z
2990 else:
2991 x = x_or_vec
2992 if isinstance(uuids_or_uuid, (list, tuple)):
2993 context_wrapper.setBroadcastPrimitiveDataInt3(self.context, uuids_or_uuid, label, x, y, z)
2994 else:
2995 context_wrapper.setPrimitiveDataInt3(self.context, uuids_or_uuid, label, x, y, z)
2996
2997 def setPrimitiveDataInt4(self, uuids_or_uuid, label: str, x_or_vec, y: int = None, z: int = None, w: int = None) -> None:
2998 """
2999 Set primitive data as int4 for one or multiple primitives.
3000
3001 Args:
3002 uuids_or_uuid: Single UUID (int) or list of UUIDs to set data for
3003 label: String key for the data
3004 x_or_vec: Either x component (int) or int4 object
3005 y: Y component (if x_or_vec is int)
3006 z: Z component (if x_or_vec is int)
3007 w: W component (if x_or_vec is int)
3008 """
3009 if isinstance(uuids_or_uuid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
3010 context_wrapper.setPrimitiveDataArray(self.context, uuids_or_uuid, label, 'Int4', x_or_vec)
3011 return
3012 if hasattr(x_or_vec, 'x'):
3013 x, y, z, w = x_or_vec.x, x_or_vec.y, x_or_vec.z, x_or_vec.w
3014 else:
3015 x = x_or_vec
3016 if isinstance(uuids_or_uuid, (list, tuple)):
3017 context_wrapper.setBroadcastPrimitiveDataInt4(self.context, uuids_or_uuid, label, x, y, z, w)
3018 else:
3019 context_wrapper.setPrimitiveDataInt4(self.context, uuids_or_uuid, label, x, y, z, w)
3020
3021 def getPrimitiveData(self, uuid: int, label: str, data_type: type = None):
3022 """
3023 Get primitive data for a specific primitive. If data_type is provided, it works like before.
3024 If data_type is None, it automatically detects the type and returns the appropriate value.
3025
3026 Args:
3027 uuid: UUID of the primitive
3028 label: String key for the data
3029 data_type: Optional. Python type to retrieve (int, uint, float, double, bool, str, vec2, vec3, vec4, int2, int3, int4, etc.)
3030 If None, auto-detects the type using C++ getPrimitiveDataType().
3031
3032 Returns:
3033 The stored value of the specified or auto-detected type
3034 """
3035 # If no type specified, use auto-detection
3036 if data_type is None:
3037 return context_wrapper.getPrimitiveDataAuto(self.context, uuid, label)
3038
3039 # Handle basic types (original behavior when type is specified)
3040 if data_type == int:
3041 return context_wrapper.getPrimitiveDataInt(self.context, uuid, label)
3042 elif data_type == float:
3043 return context_wrapper.getPrimitiveDataFloat(self.context, uuid, label)
3044 elif data_type == bool:
3045 # Bool is not supported by Helios core - get as int and convert
3046 int_value = context_wrapper.getPrimitiveDataInt(self.context, uuid, label)
3047 return int_value != 0
3048 elif data_type == str:
3049 return context_wrapper.getPrimitiveDataString(self.context, uuid, label)
3050
3051 # Handle Helios vector types
3052 elif data_type == vec2:
3053 coords = context_wrapper.getPrimitiveDataVec2(self.context, uuid, label)
3054 return vec2(coords[0], coords[1])
3055 elif data_type == vec3:
3056 coords = context_wrapper.getPrimitiveDataVec3(self.context, uuid, label)
3057 return vec3(coords[0], coords[1], coords[2])
3058 elif data_type == vec4:
3059 coords = context_wrapper.getPrimitiveDataVec4(self.context, uuid, label)
3060 return vec4(coords[0], coords[1], coords[2], coords[3])
3061 elif data_type == int2:
3062 coords = context_wrapper.getPrimitiveDataInt2(self.context, uuid, label)
3063 return int2(coords[0], coords[1])
3064 elif data_type == int3:
3065 coords = context_wrapper.getPrimitiveDataInt3(self.context, uuid, label)
3066 return int3(coords[0], coords[1], coords[2])
3067 elif data_type == int4:
3068 coords = context_wrapper.getPrimitiveDataInt4(self.context, uuid, label)
3069 return int4(coords[0], coords[1], coords[2], coords[3])
3070
3071 # Handle extended numeric types (require explicit specification since Python doesn't have these as distinct types)
3072 elif data_type == "uint":
3073 return context_wrapper.getPrimitiveDataUInt(self.context, uuid, label)
3074 elif data_type == "double":
3075 return context_wrapper.getPrimitiveDataDouble(self.context, uuid, label)
3076
3077 # Handle list return types (for convenience)
3078 elif data_type == list:
3079 # Default to vec3 as list for backward compatibility
3080 return context_wrapper.getPrimitiveDataVec3(self.context, uuid, label)
3081 elif data_type == "list_vec2":
3082 return context_wrapper.getPrimitiveDataVec2(self.context, uuid, label)
3083 elif data_type == "list_vec4":
3084 return context_wrapper.getPrimitiveDataVec4(self.context, uuid, label)
3085 elif data_type == "list_int2":
3086 return context_wrapper.getPrimitiveDataInt2(self.context, uuid, label)
3087 elif data_type == "list_int3":
3088 return context_wrapper.getPrimitiveDataInt3(self.context, uuid, label)
3089 elif data_type == "list_int4":
3090 return context_wrapper.getPrimitiveDataInt4(self.context, uuid, label)
3091
3092 else:
3093 raise ValueError(f"Unsupported primitive data type: {data_type}. "
3094 f"Supported types: int, float, bool, str, vec2, vec3, vec4, int2, int3, int4, "
3095 f"'uint', 'double', list (for vec3), 'list_vec2', 'list_vec4', 'list_int2', 'list_int3', 'list_int4'")
3096
3097 def doesPrimitiveDataExist(self, uuid: int, label: str) -> bool:
3098 """
3099 Check if primitive data exists for a specific primitive and label.
3100
3101 Args:
3102 uuid: UUID of the primitive
3103 label: String key for the data
3104
3105 Returns:
3106 True if the data exists, False otherwise
3107 """
3108 return context_wrapper.doesPrimitiveDataExistWrapper(self.context, uuid, label)
3109
3110 def getPrimitiveDataFloat(self, uuid: int, label: str) -> float:
3111 """
3112 Convenience method to get float primitive data.
3113
3114 Args:
3115 uuid: UUID of the primitive
3116 label: String key for the data
3117
3118 Returns:
3119 Float value stored for the primitive
3120 """
3121 return self.getPrimitiveData(uuid, label, float)
3123 def getPrimitiveDataType(self, uuid: int, label: str) -> int:
3124 """
3125 Get the Helios data type of primitive data.
3126
3127 Args:
3128 uuid: UUID of the primitive
3129 label: String key for the data
3130
3131 Returns:
3132 HeliosDataType enum value as integer
3133 """
3134 return context_wrapper.getPrimitiveDataTypeWrapper(self.context, uuid, label)
3136 def getPrimitiveDataSize(self, uuid: int, label: str) -> int:
3137 """
3138 Get the size/length of primitive data (for vector data).
3139
3140 Args:
3141 uuid: UUID of the primitive
3142 label: String key for the data
3143
3144 Returns:
3145 Size of data array, or 1 for scalar data
3146 """
3147 return context_wrapper.getPrimitiveDataSizeWrapper(self.context, uuid, label)
3149 def _check_primitive_data_exists(self, uuids: List[int], label: str):
3150 """Raise if any of ``uuids`` lacks primitive data ``label``.
3151
3152 Costs one native call per primitive, so call it only where the read that
3153 follows is itself per-primitive, or to diagnose a failure that has already
3154 happened.
3155
3156 Raises:
3157 ValueError: naming the first primitive that lacks the data
3158 """
3159 for uuid in uuids:
3160 if not self.doesPrimitiveDataExist(uuid, label):
3161 raise ValueError(f"Primitive data '{label}' does not exist for UUID {uuid}")
3162
3163 def getPrimitiveDataArray(self, uuids: List[int], label: str) -> np.ndarray:
3164 """
3165 Get primitive data values for multiple primitives as a NumPy array.
3166
3167 This method retrieves primitive data for a list of UUIDs and returns the values
3168 as a NumPy array. The output array has the same length as the input UUID list,
3169 with each index corresponding to the primitive data value for that UUID.
3170
3171 Args:
3172 uuids: List of primitive UUIDs to get data for
3173 label: String key for the primitive data to retrieve
3174
3175 Returns:
3176 NumPy array of primitive data values corresponding to each UUID.
3177 The array type depends on the data type:
3178 - int data: int32 array
3179 - uint data: uint32 array
3180 - float data: float32 array
3181 - double data: float64 array
3182 - vector data: float32 array with shape (N, vector_size)
3183 - string data: object array of strings
3184
3185 Raises:
3186 ValueError: If UUID list is empty or UUIDs don't exist
3187 RuntimeError: If context is in mock mode or data doesn't exist for some UUIDs
3188 """
3190
3191 if not uuids:
3192 raise ValueError("UUID list cannot be empty")
3193
3194 # First validate that all UUIDs exist
3195 self._validate_uuids(uuids)
3196
3197 # The data must exist on the first UUID before its type can be read. The
3198 # remaining UUIDs are checked per data type below: probing all of them here
3199 # would cost one native call per primitive, which is exactly what the bulk
3200 # float reader exists to avoid.
3201 first_uuid = uuids[0]
3202 if not self.doesPrimitiveDataExist(first_uuid, label):
3203 raise ValueError(f"Primitive data '{label}' does not exist for UUID {first_uuid}")
3204
3205 data_type = self.getPrimitiveDataType(first_uuid, label)
3206
3207 # Map Helios data types to NumPy array creation
3208 # Based on HeliosDataType enum from Helios core
3209 if data_type == 2: # HELIOS_TYPE_FLOAT
3210 # Single native call for the whole list. Float is the hot type here
3211 # (radiation flux, temperature), where a per-UUID round-trip dominates
3212 # the cost on canopy-sized scenes.
3213 try:
3214 result = context_wrapper.getPrimitiveDataFloatArray(
3215 self.context, uuids, label)
3216 except HeliosError:
3217 # A missing label is the likely cause; name the primitive so the
3218 # message matches what the other data types report. HeliosError
3219 # derives from Exception, not RuntimeError, so it must be named
3220 # explicitly here.
3221 self._check_primitive_data_exists(uuids, label)
3222 raise
3223
3224 elif data_type in _BULK_PRIMITIVE_DATA_TYPES:
3225 # int, uint, double and the vec/int 2-4 types all read in one native
3226 # call. Reading them one primitive at a time cost a ctypes crossing
3227 # each, plus a second pass to check existence.
3228 try:
3229 result = context_wrapper.getPrimitiveDataArrayBulk(
3230 self.context, uuids, label, data_type)
3231 except HeliosError:
3232 self._check_primitive_data_exists(uuids, label)
3233 raise
3234
3235 elif data_type == 10: # HELIOS_TYPE_STRING
3236 # Variable-length values, so this uses the offset-array form rather
3237 # than the fixed-stride bulk getters, but it is still one call.
3238 try:
3239 result = context_wrapper.getPrimitiveDataStringArrayBulk(
3240 self.context, uuids, label)
3241 except HeliosError:
3242 self._check_primitive_data_exists(uuids, label)
3243 raise
3244
3245 else:
3246 raise ValueError(f"Unsupported primitive data type: {data_type}")
3247
3248 return result
3249
3250
3251 def colorPrimitiveByDataPseudocolor(self, uuids: List[int], primitive_data: str,
3252 colormap: str = "hot", ncolors: int = 10,
3253 max_val: Optional[float] = None, min_val: Optional[float] = None):
3254 """
3255 Color primitives based on primitive data values using pseudocolor mapping.
3256
3257 This method applies a pseudocolor mapping to primitives based on the values
3258 of specified primitive data. The primitive colors are updated to reflect the
3259 data values using a color map.
3260
3261 Args:
3262 uuids: List of primitive UUIDs to color
3263 primitive_data: Name of primitive data to use for coloring (e.g., "radiation_flux_SW")
3264 colormap: Color map name - options include "hot", "cool", "parula", "rainbow", "gray", "lava"
3265 ncolors: Number of discrete colors in color map (default: 10)
3266 max_val: Maximum value for color scale (auto-determined if None)
3267 min_val: Minimum value for color scale (auto-determined if None)
3268 """
3269 if max_val is not None and min_val is not None:
3270 context_wrapper.colorPrimitiveByDataPseudocolorWithRange(
3271 self.context, uuids, primitive_data, colormap, ncolors, max_val, min_val)
3272 else:
3273 context_wrapper.colorPrimitiveByDataPseudocolor(
3274 self.context, uuids, primitive_data, colormap, ncolors)
3275
3276 # Context time/date methods for solar position integration
3277 def setTime(self, hour: int, minute: int = 0, second: int = 0):
3278 """
3279 Set the simulation time.
3280
3281 Args:
3282 hour: Hour (0-23)
3283 minute: Minute (0-59), defaults to 0
3284 second: Second (0-59), defaults to 0
3285
3286 Raises:
3287 ValueError: If time values are out of range
3288 NotImplementedError: If time/date functions not available in current library build
3289
3290 Example:
3291 >>> context.setTime(14, 30) # Set to 2:30 PM
3292 >>> context.setTime(9, 15, 30) # Set to 9:15:30 AM
3293 """
3294 context_wrapper.setTime(self.context, hour, minute, second)
3295
3296 def setDate(self, year: int, month: int, day: int):
3297 """
3298 Set the simulation date.
3299
3300 Args:
3301 year: Year (1900-3000)
3302 month: Month (1-12)
3303 day: Day (1-31)
3304
3305 Raises:
3306 ValueError: If date values are out of range
3307 NotImplementedError: If time/date functions not available in current library build
3308
3309 Example:
3310 >>> context.setDate(2023, 6, 21) # Set to June 21, 2023
3311 """
3312 context_wrapper.setDate(self.context, year, month, day)
3313
3314 def setDateJulian(self, julian_day: int, year: int):
3315 """
3316 Set the simulation date using Julian day number.
3317
3318 Args:
3319 julian_day: Julian day (1-366)
3320 year: Year (1900-3000)
3321
3322 Raises:
3323 ValueError: If values are out of range
3324 NotImplementedError: If time/date functions not available in current library build
3325
3326 Example:
3327 >>> context.setDateJulian(172, 2023) # Set to day 172 of 2023 (June 21)
3328 """
3329 context_wrapper.setDateJulian(self.context, julian_day, year)
3330
3331 def getTime(self):
3332 """
3333 Get the current simulation time.
3334
3335 Returns:
3336 Tuple of (hour, minute, second) as integers
3337
3338 Raises:
3339 NotImplementedError: If time/date functions not available in current library build
3340
3341 Example:
3342 >>> hour, minute, second = context.getTime()
3343 >>> print(f"Current time: {hour:02d}:{minute:02d}:{second:02d}")
3344 """
3345 return context_wrapper.getTime(self.context)
3346
3347 def getDate(self):
3348 """
3349 Get the current simulation date.
3350
3351 Returns:
3352 Tuple of (year, month, day) as integers
3353
3354 Raises:
3355 NotImplementedError: If time/date functions not available in current library build
3356
3357 Example:
3358 >>> year, month, day = context.getDate()
3359 >>> print(f"Current date: {year}-{month:02d}-{day:02d}")
3360 """
3361 return context_wrapper.getDate(self.context)
3362
3363 # ==========================================================================
3364 # Timeseries Methods
3365 # ==========================================================================
3366
3367 def addTimeseriesData(self, label: str, value: float, date: 'Date', time: 'Time'):
3368 """
3369 Add a data point to a timeseries variable.
3370
3371 Args:
3372 label: Name of the timeseries variable (e.g., "temperature")
3373 value: Value of the data point
3374 date (Date): Date of the data point
3375 time: Time of the data point
3376
3377 Raises:
3378 ValueError: If label is empty, or date/time are wrong types
3379 NotImplementedError: If timeseries functions not available
3380
3381 Example:
3382 >>> from pyhelios.types import Date, Time
3383 >>> context.addTimeseriesData("temperature", 25.3, Date(2024, 6, 15), Time(12, 0, 0))
3384 """
3386 if not isinstance(label, str) or not label:
3387 raise ValueError("Label must be a non-empty string")
3388 if not isinstance(date, Date):
3389 raise ValueError(f"date must be a Date instance, got {type(date).__name__}")
3390 if not isinstance(time, Time):
3391 raise ValueError(f"time must be a Time instance, got {type(time).__name__}")
3392
3393 context_wrapper.addTimeseriesData(
3394 self.context, label, float(value),
3395 date.day, date.month, date.year,
3396 time.hour, time.minute, time.second
3397 )
3398
3399 def updateTimeseriesData(self, label: str, date: 'Date', time: 'Time', new_value: float):
3400 """
3401 Update the value of an existing timeseries data point.
3402
3403 Args:
3404 label: Name of the timeseries variable (must already exist)
3405 date (Date): Date of the existing point (must match exactly)
3406 time: Time of the existing point (must match exactly)
3407 new_value: Replacement value
3408
3409 Raises:
3410 ValueError: If label is empty, or date/time are wrong types
3411 HeliosRuntimeError: If the variable does not exist or no point matches the (date, time)
3412 NotImplementedError: If timeseries functions not available
3413
3414 Example:
3415 >>> from pyhelios.types import Date, Time
3416 >>> context.addTimeseriesData("temperature", 25.3, Date(2024, 6, 15), Time(12, 0, 0))
3417 >>> context.updateTimeseriesData("temperature", Date(2024, 6, 15), Time(12, 0, 0), 26.5)
3418 """
3420 if not isinstance(label, str) or not label:
3421 raise ValueError("Label must be a non-empty string")
3422 if not isinstance(date, Date):
3423 raise ValueError(f"date must be a Date instance, got {type(date).__name__}")
3424 if not isinstance(time, Time):
3425 raise ValueError(f"time must be a Time instance, got {type(time).__name__}")
3426
3427 context_wrapper.updateTimeseriesData(
3428 self.context, label,
3429 date.day, date.month, date.year,
3430 time.hour, time.minute, time.second,
3431 float(new_value)
3432 )
3433
3434 def setCurrentTimeseriesPoint(self, label: str, index: int):
3435 """
3436 Set the Context date and time from a timeseries data point index.
3437
3438 Args:
3439 label: Name of the timeseries variable
3440 index: Index of the data point (0 = earliest, chronologically ordered)
3441
3442 Raises:
3443 ValueError: If label is empty or index is negative
3444 NotImplementedError: If timeseries functions not available
3445
3446 Example:
3447 >>> context.setCurrentTimeseriesPoint("temperature", 0)
3448 """
3450 if not isinstance(label, str) or not label:
3451 raise ValueError("Label must be a non-empty string")
3452 if not isinstance(index, int) or index < 0:
3453 raise ValueError(f"Index must be a non-negative integer, got {index}")
3454
3455 context_wrapper.setCurrentTimeseriesPoint(self.context, label, index)
3456
3457 def queryTimeseriesData(self, label: str, date: 'Date' = None, time: 'Time' = None,
3458 index: int = None) -> float:
3459 """
3460 Query a timeseries data value.
3461
3462 Three modes of operation:
3463 - With date and time: returns interpolated value at the specified date/time
3464 - With index: returns value at the specified data point index
3465 - With neither: returns value at the current Context date/time
3466
3467 Args:
3468 label: Name of the timeseries variable
3469 date (Date): Date to query at (requires time as well)
3470 time: Time to query at (requires date as well)
3471 index: Index of the data point (0 = earliest)
3472
3473 Returns:
3474 The timeseries value as a float
3475
3476 Raises:
3477 ValueError: If both date/time and index are provided, or if date without time
3478 NotImplementedError: If timeseries functions not available
3479
3480 Example:
3481 >>> # Query at specific date/time
3482 >>> val = context.queryTimeseriesData("temperature", date=Date(2024, 6, 15), time=Time(12, 0, 0))
3483 >>> # Query by index
3484 >>> val = context.queryTimeseriesData("temperature", index=0)
3485 >>> # Query at current context time
3486 >>> val = context.queryTimeseriesData("temperature")
3487 """
3489 if not isinstance(label, str) or not label:
3490 raise ValueError("Label must be a non-empty string")
3491
3492 has_datetime = date is not None or time is not None
3493 has_index = index is not None
3494
3495 if has_datetime and has_index:
3496 raise ValueError("Cannot specify both date/time and index. Use one or the other.")
3497
3498 if has_datetime:
3499 if date is None or time is None:
3500 raise ValueError("Both date and time must be provided together")
3501 if not isinstance(date, Date):
3502 raise ValueError(f"date must be a Date instance, got {type(date).__name__}")
3503 if not isinstance(time, Time):
3504 raise ValueError(f"time must be a Time instance, got {type(time).__name__}")
3505 return context_wrapper.queryTimeseriesDataDateTime(
3506 self.context, label,
3507 date.day, date.month, date.year,
3508 time.hour, time.minute, time.second
3509 )
3510
3511 if has_index:
3512 if not isinstance(index, int) or index < 0:
3513 raise ValueError(f"Index must be a non-negative integer, got {index}")
3514 return context_wrapper.queryTimeseriesDataIndex(self.context, label, index)
3515
3516 return context_wrapper.queryTimeseriesDataCurrent(self.context, label)
3517
3518 def queryTimeseriesTime(self, label: str, index: int) -> 'Time':
3519 """
3520 Get the Time associated with a timeseries data point.
3521
3522 Args:
3523 label: Name of the timeseries variable
3524 index: Index of the data point (0 = earliest)
3525
3526 Returns:
3527 Time object for the data point
3528
3529 Raises:
3530 ValueError: If label is empty or index is negative
3531 NotImplementedError: If timeseries functions not available
3532
3533 Example:
3534 >>> t = context.queryTimeseriesTime("temperature", 0)
3535 >>> print(f"{t.hour:02d}:{t.minute:02d}:{t.second:02d}")
3536 """
3538 if not isinstance(label, str) or not label:
3539 raise ValueError("Label must be a non-empty string")
3540 if not isinstance(index, int) or index < 0:
3541 raise ValueError(f"Index must be a non-negative integer, got {index}")
3542
3543 hour, minute, second = context_wrapper.queryTimeseriesTime(self.context, label, index)
3544 return Time(hour=hour, minute=minute, second=second)
3545
3546 def queryTimeseriesDate(self, label: str, index: int) -> 'Date':
3547 """
3548 Get the Date associated with a timeseries data point.
3549
3550 Args:
3551 label: Name of the timeseries variable
3552 index: Index of the data point (0 = earliest)
3554 Returns:
3555 Date object for the data point
3556
3557 Raises:
3558 ValueError: If label is empty or index is negative
3559 NotImplementedError: If timeseries functions not available
3560
3561 Example:
3562 >>> d = context.queryTimeseriesDate("temperature", 0)
3563 >>> print(f"{d.year}-{d.month:02d}-{d.day:02d}")
3564 """
3566 if not isinstance(label, str) or not label:
3567 raise ValueError("Label must be a non-empty string")
3568 if not isinstance(index, int) or index < 0:
3569 raise ValueError(f"Index must be a non-negative integer, got {index}")
3570
3571 year, month, day = context_wrapper.queryTimeseriesDate(self.context, label, index)
3572 return Date(year=year, month=month, day=day)
3573
3574 def getTimeseriesLength(self, label: str) -> int:
3575 """
3576 Get the number of data points in a timeseries variable.
3577
3578 Args:
3579 label: Name of the timeseries variable
3580
3581 Returns:
3582 Number of data points
3583
3584 Raises:
3585 ValueError: If label is empty
3586 NotImplementedError: If timeseries functions not available
3587
3588 Example:
3589 >>> n = context.getTimeseriesLength("temperature")
3590 >>> print(f"Timeseries has {n} data points")
3591 """
3593 if not isinstance(label, str) or not label:
3594 raise ValueError("Label must be a non-empty string")
3595
3596 return context_wrapper.getTimeseriesLength(self.context, label)
3597
3598 def doesTimeseriesVariableExist(self, label: str) -> bool:
3599 """
3600 Check whether a timeseries variable exists.
3601
3602 Args:
3603 label: Name of the timeseries variable
3604
3605 Returns:
3606 True if the variable exists, False otherwise
3607
3608 Raises:
3609 ValueError: If label is empty
3610 NotImplementedError: If timeseries functions not available
3611
3612 Example:
3613 >>> if context.doesTimeseriesVariableExist("temperature"):
3614 ... print("Temperature data loaded")
3615 """
3617 if not isinstance(label, str) or not label:
3618 raise ValueError("Label must be a non-empty string")
3619
3620 return context_wrapper.doesTimeseriesVariableExist(self.context, label)
3621
3622 def listTimeseriesVariables(self) -> List[str]:
3623 """
3624 List all existing timeseries variables.
3625
3626 Returns:
3627 List of timeseries variable names
3628
3629 Raises:
3630 NotImplementedError: If timeseries functions not available
3631
3632 Example:
3633 >>> variables = context.listTimeseriesVariables()
3634 >>> for var in variables:
3635 ... print(f" {var}: {context.getTimeseriesLength(var)} points")
3636 """
3638
3639 return context_wrapper.listTimeseriesVariables(self.context)
3640
3641 def clearTimeseriesData(self):
3642 """Clear all timeseries data from the Context.
3643
3644 Removes all timeseries variables and their associated date/time values.
3645
3646 Raises:
3647 NotImplementedError: If timeseries functions not available
3648
3649 Example:
3650 >>> context.clearTimeseriesData()
3651 >>> context.listTimeseriesVariables()
3652 []
3653 """
3655 context_wrapper.clearTimeseriesData(self.context)
3656
3657 def deleteTimeseriesVariable(self, label: str):
3658 """Delete a single timeseries variable and all of its data points.
3659
3660 Complements :meth:`clearTimeseriesData` (which removes all variables) and
3661 :meth:`updateTimeseriesData` (which modifies a single point).
3662
3663 Args:
3664 label: Name of the timeseries variable to delete.
3665
3666 Raises:
3667 ValueError: If ``label`` is empty.
3668 NotImplementedError: If running against helios-core older than v1.3.72.
3669
3670 Note:
3671 If the variable does not exist, the underlying Helios API issues a
3672 non-fatal warning to stderr and the call is otherwise a no-op.
3673
3674 Example:
3675 >>> context.addTimeseriesData("temperature", 25.3, Date(2024, 6, 15), Time(12, 0, 0))
3676 >>> context.deleteTimeseriesVariable("temperature")
3677 >>> context.doesTimeseriesVariableExist("temperature")
3678 False
3679 """
3681 if not isinstance(label, str) or not label:
3682 raise ValueError("Label must be a non-empty string")
3683 context_wrapper.deleteTimeseriesVariable(self.context, label)
3684
3685 def deleteTimeseriesDataPoint(self, date: 'Date', time: 'Time', label: Optional[str] = None):
3686 """Delete a single timeseries data point at the given date and time.
3687
3688 If ``label`` is provided, only that variable's matching point is removed. If ``label``
3689 is omitted (None), the matching point is removed from every timeseries variable.
3690
3691 Args:
3692 date (Date): Date of the data point to delete.
3693 time: Time of the data point to delete.
3694 label: Optional name of the timeseries variable. None applies to all variables.
3695
3696 Raises:
3697 ValueError: If date/time are wrong types, or label is an empty string.
3698 NotImplementedError: If running against helios-core older than v1.3.73.
3699
3700 Note:
3701 If no matching data point exists, the underlying Helios API issues a non-fatal
3702 warning to stderr and the call is otherwise a no-op. Matching uses the same
3703 (date, time) encoding as :meth:`addTimeseriesData`.
3704
3705 Example:
3706 >>> from pyhelios.types import Date, Time
3707 >>> context.deleteTimeseriesDataPoint(Date(2024, 6, 15), Time(12, 0, 0), "temperature")
3708 """
3710 if not isinstance(date, Date):
3711 raise ValueError(f"date must be a Date instance, got {type(date).__name__}")
3712 if not isinstance(time, Time):
3713 raise ValueError(f"time must be a Time instance, got {type(time).__name__}")
3714 if label is not None and (not isinstance(label, str) or not label):
3715 raise ValueError("label must be a non-empty string or None")
3716
3717 if label is None:
3718 context_wrapper.deleteTimeseriesDataPointAll(
3719 self.context,
3720 date.day, date.month, date.year,
3721 time.hour, time.minute, time.second
3722 )
3723 else:
3724 context_wrapper.deleteTimeseriesDataPoint(
3725 self.context, label,
3726 date.day, date.month, date.year,
3727 time.hour, time.minute, time.second
3728 )
3729
3730 def loadTabularTimeseriesData(self, data_file: str, column_labels: List[str],
3731 delimiter: str = ",", date_string_format: str = "YYYYMMDD",
3732 headerlines: int = 0):
3733 """
3734 Load tabular timeseries data from a text file.
3735
3736 The file should contain columns of data with dates/times and measured values.
3737 Column labels specify how each column should be interpreted. Special labels
3738 include "year", "DOY", "date", "datetime", "hour", "minute", "second", "time".
3739 Other labels become timeseries variable names.
3740
3741 Args:
3742 data_file: Path to the text file containing tabular data
3743 column_labels: List of column label strings specifying what each column contains
3744 delimiter: Column delimiter string (default: ",")
3745 date_string_format: Format of date strings in the file. Supported formats:
3746 "YYYYMMDD", "YYYYMMDDHH", "YYYYMMDDHHMM", "DD/MM/YYYY",
3747 "MM/DD/YYYY", "DDMMYYYY", "YYYY-MM-DD", "DD/MM/YYYY HH:MM",
3748 "MM/DD/YYYY HH:MM", "ISO8601" (default: "YYYYMMDD")
3749 headerlines: Number of header lines to skip (default: 0)
3750
3751 Raises:
3752 ValueError: If data_file is empty, column_labels is empty, or delimiter is empty
3753 RuntimeError: If the file cannot be read or parsed
3754 NotImplementedError: If timeseries functions not available
3755
3756 Example:
3757 >>> context.loadTabularTimeseriesData(
3758 ... "weather_data.csv",
3759 ... column_labels=["date", "hour", "temperature", "humidity"],
3760 ... delimiter=",",
3761 ... headerlines=1
3762 ... )
3763 >>> temp = context.queryTimeseriesData("temperature", index=0)
3764 """
3766 if not isinstance(data_file, str) or not data_file:
3767 raise ValueError("data_file must be a non-empty string")
3768 if not isinstance(column_labels, list) or not column_labels:
3769 raise ValueError("column_labels must be a non-empty list of strings")
3770 for i, label in enumerate(column_labels):
3771 if not isinstance(label, str):
3772 raise ValueError(f"column_labels[{i}] must be a string, got {type(label).__name__}")
3773 if not isinstance(delimiter, str) or not delimiter:
3774 raise ValueError("delimiter must be a non-empty string")
3775
3776 context_wrapper.loadTabularTimeseriesData(
3777 self.context, data_file, column_labels, delimiter,
3778 date_string_format, headerlines
3780
3781 # ==========================================================================
3782 # Primitive and Object Deletion Methods
3783 # ==========================================================================
3784
3785 def deletePrimitive(self, uuids_or_uuid: Union[int, List[int]]) -> None:
3786 """
3787 Delete one or more primitives from the context.
3788
3789 This removes the primitive(s) entirely from the context. If a primitive
3790 belongs to a compound object, it will be removed from that object. If the
3791 object becomes empty after removal, it is automatically deleted.
3792
3793 Args:
3794 uuids_or_uuid: Single UUID (int) or list of UUIDs to delete
3795
3796 Raises:
3797 RuntimeError: If any UUID doesn't exist in the context
3798 ValueError: If UUID is invalid (negative)
3799 NotImplementedError: If delete functions not available in current library build
3800
3801 Example:
3802 >>> context = Context()
3803 >>> patch_id = context.addPatch(center=vec3(0, 0, 0), size=vec2(1, 1))
3804 >>> context.deletePrimitive(patch_id) # Single deletion
3805 >>>
3806 >>> # Multiple deletion
3807 >>> ids = [context.addPatch() for _ in range(5)]
3808 >>> context.deletePrimitive(ids) # Delete all at once
3809 """
3811
3812 if isinstance(uuids_or_uuid, (list, tuple)):
3813 for uuid in uuids_or_uuid:
3814 if uuid < 0:
3815 raise ValueError(f"UUID must be non-negative, got {uuid}")
3816 context_wrapper.deletePrimitives(self.context, list(uuids_or_uuid))
3817 else:
3818 if uuids_or_uuid < 0:
3819 raise ValueError(f"UUID must be non-negative, got {uuids_or_uuid}")
3820 context_wrapper.deletePrimitive(self.context, uuids_or_uuid)
3821
3822 def deleteObject(self, objIDs_or_objID: Union[int, List[int]]) -> None:
3823 """
3824 Delete one or more compound objects from the context.
3825
3826 This removes the compound object(s) AND all their child primitives.
3827 Use this when you want to delete an entire object hierarchy at once.
3828
3829 Args:
3830 objIDs_or_objID: Single object ID (int) or list of object IDs to delete
3831
3832 Raises:
3833 RuntimeError: If any object ID doesn't exist in the context
3834 ValueError: If object ID is invalid (negative)
3835 NotImplementedError: If delete functions not available in current library build
3836
3837 Example:
3838 >>> context = Context()
3839 >>> # Create a compound object (e.g., a tile with multiple patches)
3840 >>> patch_ids = context.addTile(center=vec3(0, 0, 0), size=vec2(2, 2),
3841 ... tile_divisions=int2(2, 2))
3842 >>> obj_id = context.getPrimitiveParentObjectID(patch_ids[0])
3843 >>> context.deleteObject(obj_id) # Deletes tile and all its patches
3844 """
3846
3847 if isinstance(objIDs_or_objID, (list, tuple)):
3848 for objID in objIDs_or_objID:
3849 if objID < 0:
3850 raise ValueError(f"Object ID must be non-negative, got {objID}")
3851 context_wrapper.deleteObjects(self.context, list(objIDs_or_objID))
3852 else:
3853 if objIDs_or_objID < 0:
3854 raise ValueError(f"Object ID must be non-negative, got {objIDs_or_objID}")
3855 context_wrapper.deleteObject(self.context, objIDs_or_objID)
3856
3857 # Plugin-related methods
3858 def get_available_plugins(self) -> List[str]:
3859 """
3860 Get list of available plugins for this PyHelios instance.
3862 Returns:
3863 List of available plugin names
3864 """
3866
3867 def is_plugin_available(self, plugin_name: str) -> bool:
3868 """
3869 Check if a specific plugin is available.
3870
3871 Args:
3872 plugin_name: Name of the plugin to check
3873
3874 Returns:
3875 True if plugin is available, False otherwise
3876 """
3877 return self._plugin_registry.is_plugin_available(plugin_name)
3878
3879 def get_plugin_capabilities(self) -> dict:
3880 """
3881 Get detailed information about available plugin capabilities.
3882
3883 Returns:
3884 Dictionary mapping plugin names to capability information
3885 """
3887
3888 def print_plugin_status(self):
3889 """Print detailed plugin status information."""
3890 self._plugin_registry.print_status()
3891
3892 def get_missing_plugins(self, requested_plugins: List[str]) -> List[str]:
3893 """
3894 Get list of requested plugins that are not available.
3895
3896 Args:
3897 requested_plugins: List of plugin names to check
3898
3899 Returns:
3900 List of missing plugin names
3901 """
3902 return self._plugin_registry.get_missing_plugins(requested_plugins)
3903
3904 # =========================================================================
3905 # Materials System (v1.3.58+)
3906 # =========================================================================
3907
3908 def addMaterial(self, material_label: str):
3909 """
3910 Create a new material for sharing visual properties across primitives.
3911
3912 Materials enable efficient memory usage by allowing multiple primitives to
3913 share rendering properties. Changes to a material affect all primitives using it.
3914
3915 Args:
3916 material_label: Unique label for the material
3917
3918 Raises:
3919 RuntimeError: If material label already exists
3920
3921 Example:
3922 >>> context.addMaterial("wood_oak")
3923 >>> context.setMaterialColor("wood_oak", (0.6, 0.4, 0.2, 1.0))
3924 >>> context.assignMaterialToPrimitive(uuid, "wood_oak")
3925 """
3926 context_wrapper.addMaterial(self.context, material_label)
3927
3928 def doesMaterialExist(self, material_label: str) -> bool:
3929 """Check if a material with the given label exists."""
3930 return context_wrapper.doesMaterialExist(self.context, material_label)
3931
3932 def listMaterials(self) -> List[str]:
3933 """Get list of all material labels in the context."""
3934 return context_wrapper.listMaterials(self.context)
3935
3936 def deleteMaterial(self, material_label: str):
3937 """
3938 Delete a material from the context.
3939
3940 Primitives using this material will be reassigned to the default material.
3941
3942 Args:
3943 material_label: Label of the material to delete
3944
3945 Raises:
3946 RuntimeError: If material doesn't exist
3947 """
3948 context_wrapper.deleteMaterial(self.context, material_label)
3949
3950 def getMaterialColor(self, material_label: str):
3951 """
3952 Get the RGBA color of a material.
3953
3954 Args:
3955 material_label: Label of the material
3956
3957 Returns:
3958 RGBAcolor object
3959
3960 Raises:
3961 RuntimeError: If material doesn't exist
3962 """
3963 from .wrappers.DataTypes import RGBAcolor
3964 color_list = context_wrapper.getMaterialColor(self.context, material_label)
3965 return RGBAcolor(color_list[0], color_list[1], color_list[2], color_list[3])
3966
3967 def setMaterialColor(self, material_label: str, color):
3968 """
3969 Set the RGBA color of a material.
3970
3971 This affects all primitives that reference this material.
3972
3973 Args:
3974 material_label: Label of the material
3975 color: RGBAcolor object or tuple/list of (r, g, b, a) values
3976
3977 Raises:
3978 RuntimeError: If material doesn't exist
3980 Example:
3981 >>> from pyhelios.types import RGBAcolor
3982 >>> context.setMaterialColor("wood", RGBAcolor(0.6, 0.4, 0.2, 1.0))
3983 >>> context.setMaterialColor("wood", (0.6, 0.4, 0.2, 1.0))
3984 """
3985 if isinstance(color, RGBAcolor):
3986 r, g, b, a = color.r, color.g, color.b, color.a
3987 elif isinstance(color, (list, tuple)) and len(color) == 4:
3988 r, g, b, a = color[0], color[1], color[2], color[3]
3989 else:
3990 raise ValueError(f"Color must be an RGBAcolor or a 4-element list/tuple, got {type(color).__name__}")
3991 context_wrapper.setMaterialColor(self.context, material_label, r, g, b, a)
3992
3993 def getMaterialTexture(self, material_label: str) -> str:
3994 """
3995 Get the texture file path for a material.
3996
3997 Args:
3998 material_label: Label of the material
3999
4000 Returns:
4001 Texture file path, or empty string if no texture
4002
4003 Raises:
4004 RuntimeError: If material doesn't exist
4005 """
4006 return context_wrapper.getMaterialTexture(self.context, material_label)
4007
4008 def setMaterialTexture(self, material_label: str, texture_file: str):
4009 """
4010 Set the texture file for a material.
4011
4012 This affects all primitives that reference this material.
4013
4014 Args:
4015 material_label: Label of the material
4016 texture_file: Path to texture image file
4017
4018 Raises:
4019 RuntimeError: If material doesn't exist or texture file not found
4020 """
4021 context_wrapper.setMaterialTexture(self.context, material_label, texture_file)
4023 def isMaterialTextureColorOverridden(self, material_label: str) -> bool:
4024 """Check if material texture color is overridden by material color."""
4025 return context_wrapper.isMaterialTextureColorOverridden(self.context, material_label)
4026
4027 def setMaterialTextureColorOverride(self, material_label: str, override: bool):
4028 """Set whether material color overrides texture color."""
4029 context_wrapper.setMaterialTextureColorOverride(self.context, material_label, override)
4030
4031 def getMaterialTwosidedFlag(self, material_label: str) -> int:
4032 """Get the two-sided rendering flag for a material (0 = one-sided, 1 = two-sided)."""
4033 return context_wrapper.getMaterialTwosidedFlag(self.context, material_label)
4034
4035 def setMaterialTwosidedFlag(self, material_label: str, twosided_flag: int):
4036 """Set the two-sided rendering flag for a material (0 = one-sided, 1 = two-sided)."""
4037 context_wrapper.setMaterialTwosidedFlag(self.context, material_label, twosided_flag)
4038
4039 def assignMaterialToPrimitive(self, uuid, material_label: str):
4040 """
4041 Assign a material to primitive(s).
4042
4043 Args:
4044 uuid: Single UUID (int) or list of UUIDs (List[int])
4045 material_label: Label of the material to assign
4046
4047 Raises:
4048 RuntimeError: If primitive or material doesn't exist
4050 Example:
4051 >>> context.assignMaterialToPrimitive(uuid, "wood_oak")
4052 >>> context.assignMaterialToPrimitive([uuid1, uuid2, uuid3], "wood_oak")
4053 """
4054 if isinstance(uuid, (list, tuple)):
4055 context_wrapper.assignMaterialToPrimitives(self.context, uuid, material_label)
4056 else:
4057 context_wrapper.assignMaterialToPrimitive(self.context, uuid, material_label)
4058
4059 def assignMaterialToObject(self, objID, material_label: str):
4060 """
4061 Assign a material to all primitives in compound object(s).
4062
4063 Args:
4064 objID: Single object ID (int) or list of object IDs (List[int])
4065 material_label: Label of the material to assign
4066
4067 Raises:
4068 RuntimeError: If object or material doesn't exist
4069
4070 Example:
4071 >>> tree_id = wpt.buildTree(WPTType.LEMON)
4072 >>> context.assignMaterialToObject(tree_id, "tree_bark")
4073 >>> context.assignMaterialToObject([id1, id2], "grass")
4074 """
4075 if isinstance(objID, (list, tuple)):
4076 context_wrapper.assignMaterialToObjects(self.context, objID, material_label)
4077 else:
4078 context_wrapper.assignMaterialToObject(self.context, objID, material_label)
4079
4080 def getPrimitiveMaterialLabel(self, uuid):
4081 """Get the material label assigned to a primitive or multiple primitives.
4082
4083 Args:
4084 uuid: Single UUID (int) or list of UUIDs
4085
4086 Returns:
4087 str for single UUID, or List[str] for list
4088
4089 Raises:
4090 RuntimeError: If primitive doesn't exist
4091 """
4092 if isinstance(uuid, (list, tuple)):
4094 if not uuid:
4095 return []
4096 ptr, offsets, total = context_wrapper.getBatchPrimitiveMaterialLabels(self.context, uuid)
4097 if total == 0 or not ptr:
4098 return ["" for _ in uuid]
4099 full_str = ptr.decode('utf-8') if isinstance(ptr, bytes) else ptr
4100 return [full_str[offsets[i]:offsets[i+1]] for i in range(len(uuid))]
4101 return context_wrapper.getPrimitiveMaterialLabel(self.context, uuid)
4102
4103 def getPrimitiveTwosidedFlag(self, uuid: int, default_value: int = 1) -> int:
4104 """
4105 Get two-sided rendering flag for a primitive.
4106
4107 Checks material first, then primitive data if no material assigned.
4109 Args:
4110 uuid: UUID of the primitive
4111 default_value: Default value if no material/data (default 1 = two-sided)
4112
4113 Returns:
4114 Two-sided flag (0 = one-sided, 1 = two-sided)
4115 """
4116 return context_wrapper.getPrimitiveTwosidedFlag(self.context, uuid, default_value)
4117
4118 def getPrimitivesUsingMaterial(self, material_label: str) -> List[int]:
4119 """
4120 Get all primitive UUIDs that use a specific material.
4121
4122 Args:
4123 material_label: Label of the material
4124
4125 Returns:
4126 List of primitive UUIDs using the material
4127
4128 Raises:
4129 RuntimeError: If material doesn't exist
4130 """
4131 return context_wrapper.getPrimitivesUsingMaterial(self.context, material_label)
4133 # =========================================================================
4134 # Texture Methods
4135 # =========================================================================
4136
4137 def getPrimitiveTextureFile(self, uuid):
4138 """Get the texture file path of a primitive or multiple primitives.
4139
4140 Args:
4141 uuid: Single UUID (int) or list of UUIDs
4142
4143 Returns:
4144 str for single UUID, or List[str] for list
4145 """
4147 if isinstance(uuid, (list, tuple)):
4148 if not uuid:
4149 return []
4150 ptr, offsets, total = context_wrapper.getBatchPrimitiveTextureFiles(self.context, uuid)
4151 if total == 0 or not ptr:
4152 return ["" for _ in uuid]
4153 full_str = ptr.decode('utf-8') if isinstance(ptr, bytes) else ptr
4154 return [full_str[offsets[i]:offsets[i+1]] for i in range(len(uuid))]
4155 return context_wrapper.getPrimitiveTextureFile(self.context, uuid)
4156
4157 def resolveMaterialTextures(self, uuids, colors_np):
4158 """Resolve material texture suppression for export.
4159
4160 For each primitive, applies material-based texture suppression rules:
4161 1. If primitive has texture but material has no texture -> suppress texture, use material color
4162 2. If both have texture and textureColorOverride -> prefix "mask:", use material color
4163 3. Otherwise -> leave unchanged
4164
4165 Args:
4166 uuids: List of primitive UUIDs
4167 colors_np: numpy float32 array of shape (N, 3), modified IN-PLACE
4168
4169 Returns:
4170 List[str] of resolved texture file paths
4171 """
4173 if not uuids:
4174 return []
4175 return context_wrapper.resolveMaterialTextures(self.context, uuids, colors_np)
4176
4177 def packGPUBuffers(self, uuids):
4178 """Pack GPU-ready geometry buffers for a set of primitives in a single C++ pass.
4179
4180 Produces a binary blob containing contiguous typed arrays (positions,
4181 colors, uvs, indices, faceToUuid) grouped by texture, ready for
4182 zero-copy loading into Three.js BufferGeometry attributes.
4183
4184 Args:
4185 uuids: List of primitive UUIDs
4186
4187 Returns:
4188 bytes: Raw binary blob (see wire format v2 spec)
4189 """
4191 if not uuids:
4192 return b''
4193 return context_wrapper.packGPUBuffers(self.context, uuids)
4194
4195 def setPrimitiveTextureFile(self, uuid: int, texture_file: str) -> None:
4196 """Set the texture file path of a primitive.
4197
4198 Args:
4199 uuid: UUID of the primitive
4200 texture_file: Path to the texture file
4201 """
4203 context_wrapper.setPrimitiveTextureFile(self.context, uuid, texture_file)
4204
4205 def getPrimitiveTextureSize(self, uuid: int) -> int2:
4206 """Get the texture size (width, height) of a primitive.
4207
4208 Args:
4209 uuid: UUID of the primitive
4210
4211 Returns:
4212 int2 with width and height of the texture
4213 """
4215 w, h = context_wrapper.getPrimitiveTextureSize(self.context, uuid)
4216 return int2(w, h)
4217
4218 def getPrimitiveTextureUV(self, uuid):
4219 """Get the texture UV coordinates of a primitive or multiple primitives.
4220
4221 Args:
4222 uuid: Single UUID (int) or list of UUIDs
4223
4224 Returns:
4225 List[vec2] for single UUID, or tuple of (flat_data, offsets) for list
4226 """
4228 if isinstance(uuid, (list, tuple)):
4229 if not uuid:
4230 return (np.empty((0,), dtype=np.float32), np.zeros((1,), dtype=np.uint32))
4231 ptr, offsets, total = context_wrapper.getBatchPrimitiveTextureUV(self.context, uuid)
4232 offsets_arr = np.asarray(offsets, dtype=np.uint32)
4233 if total == 0 or not ptr:
4234 return (np.empty((0,), dtype=np.float32), offsets_arr)
4235 data = np.ctypeslib.as_array(ptr, shape=(total,)).copy()
4236 return (data, offsets_arr)
4237 uv_pairs = context_wrapper.getPrimitiveTextureUV(self.context, uuid)
4238 return [vec2(u, v) for u, v in uv_pairs]
4239
4240 def primitiveTextureHasTransparencyChannel(self, uuid: int) -> bool:
4241 """Check if primitive texture has a transparency channel.
4242
4243 Args:
4244 uuid: UUID of the primitive
4245
4246 Returns:
4247 True if texture has transparency channel
4248 """
4250 return context_wrapper.primitiveTextureHasTransparencyChannel(self.context, uuid)
4251
4252 def getPrimitiveSolidFraction(self, uuid):
4253 """Get the solid fraction of a primitive or multiple primitives.
4254
4255 Args:
4256 uuid: Single UUID (int) or list of UUIDs
4257
4258 Returns:
4259 float for single UUID, or np.ndarray of shape (N,) for list
4260 """
4262 if isinstance(uuid, (list, tuple)):
4263 if not uuid:
4264 return np.empty((0,), dtype=np.float32)
4265 ptr, size = context_wrapper.getBatchPrimitiveSolidFractions(self.context, uuid)
4266 if size == 0 or not ptr:
4267 return np.empty((0,), dtype=np.float32)
4268 return np.ctypeslib.as_array(ptr, shape=(size,)).copy()
4269 return context_wrapper.getPrimitiveSolidFraction(self.context, uuid)
4270
4271 def overridePrimitiveTextureColor(self, uuids_or_uuid) -> None:
4272 """Override texture color with the primitive's constant RGB color.
4273
4274 Args:
4275 uuids_or_uuid: A single UUID (int) or a list of UUIDs. When a list is
4276 given, the override is applied to all of them in a single bulk call.
4277 """
4279 if isinstance(uuids_or_uuid, (list, tuple)):
4280 context_wrapper.overridePrimitiveTextureColorBatchWrapper(self.context, list(uuids_or_uuid))
4281 else:
4282 context_wrapper.overridePrimitiveTextureColor(self.context, uuids_or_uuid)
4283
4284 def usePrimitiveTextureColor(self, uuids_or_uuid) -> None:
4285 """Use texture-map color instead of the constant RGB color.
4286
4287 Args:
4288 uuids_or_uuid: A single UUID (int) or a list of UUIDs. When a list is
4289 given, all of them are restored in a single bulk call.
4290 """
4292 if isinstance(uuids_or_uuid, (list, tuple)):
4293 context_wrapper.usePrimitiveTextureColorBatchWrapper(self.context, list(uuids_or_uuid))
4294 else:
4295 context_wrapper.usePrimitiveTextureColor(self.context, uuids_or_uuid)
4296
4297 def isPrimitiveTextureColorOverridden(self, uuid: int) -> bool:
4298 """Check if primitive texture color is overridden.
4299
4300 Args:
4301 uuid: UUID of the primitive
4302
4303 Returns:
4304 True if texture color is overridden with constant RGB
4305 """
4307 return context_wrapper.isPrimitiveTextureColorOverridden(self.context, uuid)
4308
4309 # =========================================================================
4310 # Convenience Methods (getAll*)
4311 # =========================================================================
4312
4313 def getAllPrimitiveNormals(self) -> 'np.ndarray':
4314 """Get normals for all primitives. Returns ndarray of shape (N, 3)."""
4315 return self.getPrimitiveNormal(self.getAllUUIDs())
4316
4317 def getAllPrimitiveColors(self) -> 'np.ndarray':
4318 """Get colors for all primitives. Returns ndarray of shape (N, 3)."""
4319 return self.getPrimitiveColor(self.getAllUUIDs())
4320
4321 def getAllPrimitiveAreas(self) -> 'np.ndarray':
4322 """Get areas for all primitives. Returns ndarray of shape (N,)."""
4323 return self.getPrimitiveArea(self.getAllUUIDs())
4324
4325 def getAllPrimitiveTypes(self) -> 'np.ndarray':
4326 """Get types for all primitives. Returns ndarray of shape (N,) uint32."""
4327 return self.getPrimitiveType(self.getAllUUIDs())
4328
4329 def getAllPrimitiveSolidFractions(self) -> 'np.ndarray':
4330 """Get solid fractions for all primitives. Returns ndarray of shape (N,)."""
4332
4333 def getAllPrimitiveVertices(self):
4334 """Get vertices for all primitives. Returns (flat_data, offsets) tuple."""
4336
4337 def getAllPrimitiveTextureFiles(self) -> List[str]:
4338 """Get texture files for all primitives. Returns list of strings."""
4340
4341 def getAllPrimitiveMaterialLabels(self) -> List[str]:
4342 """Get material labels for all primitives. Returns list of strings."""
4344
4345 # ==================== Visibility Methods ====================
4346
4347 def hidePrimitive(self, uuids_or_uuid) -> None:
4348 """Hide one or more primitives. Hidden primitives are excluded from getAllUUIDs().
4349
4350 Args:
4351 uuids_or_uuid: Single UUID (int) or list of UUIDs to hide.
4352 """
4353 if isinstance(uuids_or_uuid, (list, tuple)):
4354 context_wrapper.hidePrimitivesWrapper(self.context, list(uuids_or_uuid))
4355 else:
4356 context_wrapper.hidePrimitiveWrapper(self.context, uuids_or_uuid)
4357
4358 def showPrimitive(self, uuids_or_uuid) -> None:
4359 """Show one or more previously hidden primitives.
4360
4361 Args:
4362 uuids_or_uuid: Single UUID (int) or list of UUIDs to show.
4363 """
4364 if isinstance(uuids_or_uuid, (list, tuple)):
4365 context_wrapper.showPrimitivesWrapper(self.context, list(uuids_or_uuid))
4366 else:
4367 context_wrapper.showPrimitiveWrapper(self.context, uuids_or_uuid)
4368
4369 def isPrimitiveHidden(self, uuid: int) -> bool:
4370 """Check if a primitive is hidden.
4371
4372 Args:
4373 uuid: UUID of the primitive.
4374
4375 Returns:
4376 True if the primitive is hidden.
4377 """
4378 return context_wrapper.isPrimitiveHiddenWrapper(self.context, uuid)
4379
4380 def hideObject(self, objids_or_objid) -> None:
4381 """Hide one or more compound objects (and all their primitives).
4382
4383 Args:
4384 objids_or_objid: Single object ID (int) or list of object IDs to hide.
4385 """
4386 if isinstance(objids_or_objid, (list, tuple)):
4387 context_wrapper.hideObjectsWrapper(self.context, list(objids_or_objid))
4388 else:
4389 context_wrapper.hideObjectWrapper(self.context, objids_or_objid)
4390
4391 def showObject(self, objids_or_objid) -> None:
4392 """Show one or more previously hidden compound objects.
4393
4394 Args:
4395 objids_or_objid: Single object ID (int) or list of object IDs to show.
4396 """
4397 if isinstance(objids_or_objid, (list, tuple)):
4398 context_wrapper.showObjectsWrapper(self.context, list(objids_or_objid))
4399 else:
4400 context_wrapper.showObjectWrapper(self.context, objids_or_objid)
4401
4402 def isObjectHidden(self, objID: int) -> bool:
4403 """Check if a compound object is hidden.
4404
4405 Args:
4406 objID: Object ID.
4407
4408 Returns:
4409 True if the object is hidden.
4410 """
4411 return context_wrapper.isObjectHiddenWrapper(self.context, objID)
4412
4413 # ==================== Object Data Methods ====================
4414
4415 def setObjectDataInt(self, objids_or_objid, label: str, value: int) -> None:
4416 """Set object data as signed 32-bit integer. Scalar broadcasts to all objIDs; a list of values sets a distinct value per objID."""
4417 if isinstance(objids_or_objid, (list, tuple)):
4418 if isinstance(value, (list, tuple, np.ndarray)):
4419 context_wrapper.setObjectDataArray(self.context, objids_or_objid, label, 'Int', value)
4420 else:
4421 context_wrapper.setBroadcastObjectDataInt(self.context, objids_or_objid, label, value)
4422 else:
4423 context_wrapper.setObjectDataInt(self.context, objids_or_objid, label, value)
4424
4425 def setObjectDataUInt(self, objids_or_objid, label: str, value: int) -> None:
4426 """Set object data as unsigned 32-bit integer. Scalar broadcasts to all objIDs; a list of values sets a distinct value per objID."""
4427 if isinstance(objids_or_objid, (list, tuple)):
4428 if isinstance(value, (list, tuple, np.ndarray)):
4429 context_wrapper.setObjectDataArray(self.context, objids_or_objid, label, 'UInt', value)
4430 else:
4431 context_wrapper.setBroadcastObjectDataUInt(self.context, objids_or_objid, label, value)
4432 else:
4433 context_wrapper.setObjectDataUInt(self.context, objids_or_objid, label, value)
4434
4435 def setObjectDataFloat(self, objids_or_objid, label: str, value: float) -> None:
4436 """Set object data as 32-bit float. Scalar broadcasts to all objIDs; a list of values sets a distinct value per objID."""
4437 if isinstance(objids_or_objid, (list, tuple)):
4438 if isinstance(value, (list, tuple, np.ndarray)):
4439 context_wrapper.setObjectDataArray(self.context, objids_or_objid, label, 'Float', value)
4440 else:
4441 context_wrapper.setBroadcastObjectDataFloat(self.context, objids_or_objid, label, value)
4442 else:
4443 context_wrapper.setObjectDataFloat(self.context, objids_or_objid, label, value)
4444
4445 def setObjectDataDouble(self, objids_or_objid, label: str, value: float) -> None:
4446 """Set object data as 64-bit double. Scalar broadcasts to all objIDs; a list of values sets a distinct value per objID."""
4447 if isinstance(objids_or_objid, (list, tuple)):
4448 if isinstance(value, (list, tuple, np.ndarray)):
4449 context_wrapper.setObjectDataArray(self.context, objids_or_objid, label, 'Double', value)
4450 else:
4451 context_wrapper.setBroadcastObjectDataDouble(self.context, objids_or_objid, label, value)
4452 else:
4453 context_wrapper.setObjectDataDouble(self.context, objids_or_objid, label, value)
4454
4455 def setObjectDataString(self, objids_or_objid, label: str, value: str) -> None:
4456 """Set object data as string. Scalar broadcasts to all objIDs; a list of strings sets a distinct value per objID."""
4457 if isinstance(objids_or_objid, (list, tuple)):
4458 if isinstance(value, (list, tuple, np.ndarray)):
4459 context_wrapper.setObjectDataArray(self.context, objids_or_objid, label, 'String', value)
4460 else:
4461 context_wrapper.setBroadcastObjectDataString(self.context, objids_or_objid, label, value)
4462 else:
4463 context_wrapper.setObjectDataString(self.context, objids_or_objid, label, value)
4464
4465 def setObjectDataVec2(self, objids_or_objid, label: str, x_or_vec, y: float = None) -> None:
4466 """Set object data as vec2. Accepts a vec2 / x,y components, or a list of vec2 (one per objID)."""
4467 if isinstance(objids_or_objid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
4468 context_wrapper.setObjectDataArray(self.context, objids_or_objid, label, 'Vec2', x_or_vec)
4469 return
4470 if hasattr(x_or_vec, 'x') and y is None:
4471 x, y = x_or_vec.x, x_or_vec.y
4472 else:
4473 x = x_or_vec
4474 if isinstance(objids_or_objid, (list, tuple)):
4475 context_wrapper.setBroadcastObjectDataVec2(self.context, objids_or_objid, label, x, y)
4476 else:
4477 context_wrapper.setObjectDataVec2(self.context, objids_or_objid, label, x, y)
4478
4479 def setObjectDataVec3(self, objids_or_objid, label: str, x_or_vec, y: float = None, z: float = None) -> None:
4480 """Set object data as vec3. Accepts a vec3 / x,y,z components, or a list of vec3 (one per objID)."""
4481 if isinstance(objids_or_objid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
4482 context_wrapper.setObjectDataArray(self.context, objids_or_objid, label, 'Vec3', x_or_vec)
4483 return
4484 if hasattr(x_or_vec, 'x') and y is None:
4485 x, y, z = x_or_vec.x, x_or_vec.y, x_or_vec.z
4486 else:
4487 x = x_or_vec
4488 if isinstance(objids_or_objid, (list, tuple)):
4489 context_wrapper.setBroadcastObjectDataVec3(self.context, objids_or_objid, label, x, y, z)
4490 else:
4491 context_wrapper.setObjectDataVec3(self.context, objids_or_objid, label, x, y, z)
4492
4493 def setObjectDataVec4(self, objids_or_objid, label: str, x_or_vec, y: float = None, z: float = None, w: float = None) -> None:
4494 """Set object data as vec4. Accepts a vec4 / x,y,z,w components, or a list of vec4 (one per objID)."""
4495 if isinstance(objids_or_objid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
4496 context_wrapper.setObjectDataArray(self.context, objids_or_objid, label, 'Vec4', x_or_vec)
4497 return
4498 if hasattr(x_or_vec, 'x') and y is None:
4499 x, y, z, w = x_or_vec.x, x_or_vec.y, x_or_vec.z, x_or_vec.w
4500 else:
4501 x = x_or_vec
4502 if isinstance(objids_or_objid, (list, tuple)):
4503 context_wrapper.setBroadcastObjectDataVec4(self.context, objids_or_objid, label, x, y, z, w)
4504 else:
4505 context_wrapper.setObjectDataVec4(self.context, objids_or_objid, label, x, y, z, w)
4506
4507 def setObjectDataInt2(self, objids_or_objid, label: str, x_or_vec, y: int = None) -> None:
4508 """Set object data as int2. Accepts an int2 / x,y components, or a list of int2 (one per objID)."""
4509 if isinstance(objids_or_objid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
4510 context_wrapper.setObjectDataArray(self.context, objids_or_objid, label, 'Int2', x_or_vec)
4511 return
4512 if hasattr(x_or_vec, 'x') and y is None:
4513 x, y = x_or_vec.x, x_or_vec.y
4514 else:
4515 x = x_or_vec
4516 if isinstance(objids_or_objid, (list, tuple)):
4517 context_wrapper.setBroadcastObjectDataInt2(self.context, objids_or_objid, label, x, y)
4518 else:
4519 context_wrapper.setObjectDataInt2(self.context, objids_or_objid, label, x, y)
4520
4521 def setObjectDataInt3(self, objids_or_objid, label: str, x_or_vec, y: int = None, z: int = None) -> None:
4522 """Set object data as int3. Accepts an int3 / x,y,z components, or a list of int3 (one per objID)."""
4523 if isinstance(objids_or_objid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
4524 context_wrapper.setObjectDataArray(self.context, objids_or_objid, label, 'Int3', x_or_vec)
4525 return
4526 if hasattr(x_or_vec, 'x') and y is None:
4527 x, y, z = x_or_vec.x, x_or_vec.y, x_or_vec.z
4528 else:
4529 x = x_or_vec
4530 if isinstance(objids_or_objid, (list, tuple)):
4531 context_wrapper.setBroadcastObjectDataInt3(self.context, objids_or_objid, label, x, y, z)
4532 else:
4533 context_wrapper.setObjectDataInt3(self.context, objids_or_objid, label, x, y, z)
4534
4535 def setObjectDataInt4(self, objids_or_objid, label: str, x_or_vec, y: int = None, z: int = None, w: int = None) -> None:
4536 """Set object data as int4. Accepts an int4 / x,y,z,w components, or a list of int4 (one per objID)."""
4537 if isinstance(objids_or_objid, (list, tuple)) and isinstance(x_or_vec, (list, tuple, np.ndarray)):
4538 context_wrapper.setObjectDataArray(self.context, objids_or_objid, label, 'Int4', x_or_vec)
4539 return
4540 if hasattr(x_or_vec, 'x') and y is None:
4541 x, y, z, w = x_or_vec.x, x_or_vec.y, x_or_vec.z, x_or_vec.w
4542 else:
4543 x = x_or_vec
4544 if isinstance(objids_or_objid, (list, tuple)):
4545 context_wrapper.setBroadcastObjectDataInt4(self.context, objids_or_objid, label, x, y, z, w)
4546 else:
4547 context_wrapper.setObjectDataInt4(self.context, objids_or_objid, label, x, y, z, w)
4548
4549 def getObjectData(self, objID: int, label: str, data_type: type = None):
4550 """Get object data with optional type specification. Auto-detects type if not specified."""
4551 if data_type is None:
4552 return context_wrapper.getObjectDataAuto(self.context, objID, label)
4553 if data_type == int:
4554 return context_wrapper.getObjectDataInt(self.context, objID, label)
4555 elif data_type == float:
4556 return context_wrapper.getObjectDataFloat(self.context, objID, label)
4557 elif data_type == str:
4558 return context_wrapper.getObjectDataString(self.context, objID, label)
4559 elif data_type == vec3:
4560 coords = context_wrapper.getObjectDataVec3(self.context, objID, label)
4561 return vec3(coords[0], coords[1], coords[2])
4562 elif data_type == vec2:
4563 coords = context_wrapper.getObjectDataVec2(self.context, objID, label)
4564 return vec2(coords[0], coords[1])
4565 elif data_type == vec4:
4566 coords = context_wrapper.getObjectDataVec4(self.context, objID, label)
4567 return vec4(coords[0], coords[1], coords[2], coords[3])
4568 elif data_type == int2:
4569 coords = context_wrapper.getObjectDataInt2(self.context, objID, label)
4570 return int2(coords[0], coords[1])
4571 elif data_type == int3:
4572 coords = context_wrapper.getObjectDataInt3(self.context, objID, label)
4573 return int3(coords[0], coords[1], coords[2])
4574 elif data_type == int4:
4575 coords = context_wrapper.getObjectDataInt4(self.context, objID, label)
4576 return int4(coords[0], coords[1], coords[2], coords[3])
4577 elif data_type == "uint":
4578 return context_wrapper.getObjectDataUInt(self.context, objID, label)
4579 elif data_type == "double":
4580 return context_wrapper.getObjectDataDouble(self.context, objID, label)
4581 else:
4582 raise ValueError(f"Unsupported object data type: {data_type}")
4583
4584 def getObjectDataFloat(self, objID: int, label: str) -> float:
4585 """Get float object data."""
4586 return context_wrapper.getObjectDataFloat(self.context, objID, label)
4587
4588 def getObjectDataInt(self, objID: int, label: str) -> int:
4589 """Get int object data."""
4590 return context_wrapper.getObjectDataInt(self.context, objID, label)
4591
4592 def getObjectDataString(self, objID: int, label: str) -> str:
4593 """Get string object data."""
4594 return context_wrapper.getObjectDataString(self.context, objID, label)
4595
4596 def getObjectDataType(self, objID: int, label: str) -> int:
4597 """Get the HeliosDataType enum for object data."""
4598 return context_wrapper.getObjectDataTypeWrapper(self.context, objID, label)
4599
4600 def getObjectDataSize(self, objID: int, label: str) -> int:
4601 """Get the size of object data array."""
4602 return context_wrapper.getObjectDataSizeWrapper(self.context, objID, label)
4603
4604 def doesObjectDataExist(self, objID: int, label: str) -> bool:
4605 """Check if object data exists."""
4606 return context_wrapper.doesObjectDataExistWrapper(self.context, objID, label)
4607
4608 def getObjectDataArray(self, objids: List[int], label: str) -> np.ndarray:
4609 """Get object data values for multiple objects as a NumPy array.
4611 Reads one label across every object in a single native call, in the
4612 order the IDs were given. Reading them one at a time costs a ctypes
4613 round-trip per object.
4615 Args:
4616 objids: Object IDs to read, controlling the result order
4617 label: Object data label to retrieve
4619 Returns:
4620 NumPy array with one entry per object: int32, uint32, float32 or
4621 float64 for the scalar types, and shape (N, components) for the
4622 vec2/3/4 and int2/3/4 types.
4623
4624 Raises:
4625 ValueError: If the ID list is empty or the label does not exist
4626 NotImplementedError: If the data type has no bulk getter
4627 """
4629 if not isinstance(objids, (list, tuple)):
4630 raise ValueError(
4631 f"objids must be a list of object IDs, got {type(objids).__name__}")
4632 if not objids:
4633 raise ValueError("Object ID list cannot be empty")
4634
4635 first = objids[0]
4636 if not self.doesObjectDataExist(first, label):
4637 raise ValueError(
4638 f"Object data '{label}' does not exist for object {first}")
4639
4640 data_type = self.getObjectDataType(first, label)
4641 if data_type == 10: # HELIOS_TYPE_STRING
4642 return context_wrapper.getObjectDataStringArrayBulk(
4643 self.context, objids, label)
4644 return context_wrapper.getObjectDataArrayBulk(
4645 self.context, objids, label, data_type)
4646
4647 def clearObjectData(self, objids_or_objid, label: str) -> None:
4648 """Clear object data. Accepts single ID or list."""
4649 if isinstance(objids_or_objid, (list, tuple)):
4650 context_wrapper.clearObjectDataBatchWrapper(self.context, objids_or_objid, label)
4651 else:
4652 context_wrapper.clearObjectDataWrapper(self.context, objids_or_objid, label)
4653
4654 def clearAllObjectData(self, label: str) -> None:
4655 """Remove a named data field from every compound object in the Context.
4656
4657 Clears the data with the given label from all objects (including hidden ones) and
4658 releases the registered data type for the label, so it may subsequently be
4659 re-registered with a different type. Requires helios-core v1.3.73 or newer.
4660 """
4662 context_wrapper.clearAllObjectDataByLabelWrapper(self.context, label)
4663
4664 def listObjectData(self, objID: int) -> List[str]:
4665 """List all data labels on a specific object."""
4666 return context_wrapper.listObjectDataWrapper(self.context, objID)
4667
4668 def listAllObjectDataLabels(self) -> List[str]:
4669 """List all object data labels in context."""
4670 return context_wrapper.listAllObjectDataLabelsWrapper(self.context)
4671
4672 def duplicateObjectData(self, objID: int, old_label: str, new_label: str) -> None:
4673 """Copy object data to a new label."""
4674 context_wrapper.duplicateObjectDataWrapper(self.context, objID, old_label, new_label)
4675
4676 def renameObjectData(self, objID: int, old_label: str, new_label: str) -> None:
4677 """Rename an object data label."""
4678 context_wrapper.renameObjectDataWrapper(self.context, objID, old_label, new_label)
4679
4680 def filterObjectsByData(self, objIDs: List[int], label: str, value, comparator: str = "=") -> List[int]:
4681 """Filter objects by data value. Auto-dispatches based on value type."""
4682 if isinstance(value, str):
4683 return context_wrapper.filterObjectsByDataStringWrapper(self.context, objIDs, label, value)
4684 elif isinstance(value, float):
4685 return context_wrapper.filterObjectsByDataFloatWrapper(self.context, objIDs, label, value, comparator)
4686 elif isinstance(value, int):
4687 return context_wrapper.filterObjectsByDataIntWrapper(self.context, objIDs, label, value, comparator)
4688 else:
4689 raise ValueError(f"Unsupported filter value type: {type(value).__name__}")
4691 # ==================== Global Data Methods ====================
4692
4693 def setGlobalDataInt(self, label: str, value: int) -> None:
4694 """Set global data as signed 32-bit integer."""
4695 context_wrapper.setGlobalDataInt(self.context, label, value)
4696
4697 def setGlobalDataUInt(self, label: str, value: int) -> None:
4698 """Set global data as unsigned 32-bit integer."""
4699 context_wrapper.setGlobalDataUInt(self.context, label, value)
4700
4701 def setGlobalDataFloat(self, label: str, value: float) -> None:
4702 """Set global data as 32-bit float."""
4703 context_wrapper.setGlobalDataFloat(self.context, label, value)
4704
4705 def setGlobalDataDouble(self, label: str, value: float) -> None:
4706 """Set global data as 64-bit double."""
4707 context_wrapper.setGlobalDataDouble(self.context, label, value)
4708
4709 def setGlobalDataString(self, label: str, value: str) -> None:
4710 """Set global data as string."""
4711 context_wrapper.setGlobalDataString(self.context, label, value)
4712
4713 def setGlobalDataVec2(self, label: str, x_or_vec, y: float = None) -> None:
4714 """Set global data as vec2."""
4715 if hasattr(x_or_vec, 'x') and y is None:
4716 x, y = x_or_vec.x, x_or_vec.y
4717 else:
4718 x = x_or_vec
4719 context_wrapper.setGlobalDataVec2(self.context, label, x, y)
4720
4721 def setGlobalDataVec3(self, label: str, x_or_vec, y: float = None, z: float = None) -> None:
4722 """Set global data as vec3."""
4723 if hasattr(x_or_vec, 'x') and y is None:
4724 x, y, z = x_or_vec.x, x_or_vec.y, x_or_vec.z
4725 else:
4726 x = x_or_vec
4727 context_wrapper.setGlobalDataVec3(self.context, label, x, y, z)
4728
4729 def setGlobalDataVec4(self, label: str, x_or_vec, y: float = None, z: float = None, w: float = None) -> None:
4730 """Set global data as vec4."""
4731 if hasattr(x_or_vec, 'x') and y is None:
4732 x, y, z, w = x_or_vec.x, x_or_vec.y, x_or_vec.z, x_or_vec.w
4733 else:
4734 x = x_or_vec
4735 context_wrapper.setGlobalDataVec4(self.context, label, x, y, z, w)
4736
4737 def setGlobalDataInt2(self, label: str, x_or_vec, y: int = None) -> None:
4738 """Set global data as int2."""
4739 if hasattr(x_or_vec, 'x') and y is None:
4740 x, y = x_or_vec.x, x_or_vec.y
4741 else:
4742 x = x_or_vec
4743 context_wrapper.setGlobalDataInt2(self.context, label, x, y)
4744
4745 def setGlobalDataInt3(self, label: str, x_or_vec, y: int = None, z: int = None) -> None:
4746 """Set global data as int3."""
4747 if hasattr(x_or_vec, 'x') and y is None:
4748 x, y, z = x_or_vec.x, x_or_vec.y, x_or_vec.z
4749 else:
4750 x = x_or_vec
4751 context_wrapper.setGlobalDataInt3(self.context, label, x, y, z)
4752
4753 def setGlobalDataInt4(self, label: str, x_or_vec, y: int = None, z: int = None, w: int = None) -> None:
4754 """Set global data as int4."""
4755 if hasattr(x_or_vec, 'x') and y is None:
4756 x, y, z, w = x_or_vec.x, x_or_vec.y, x_or_vec.z, x_or_vec.w
4757 else:
4758 x = x_or_vec
4759 context_wrapper.setGlobalDataInt4(self.context, label, x, y, z, w)
4760
4761 def getGlobalData(self, label: str, data_type: type = None):
4762 """Get global data with optional type specification. Auto-detects type if not specified."""
4763 if data_type is None:
4764 return context_wrapper.getGlobalDataAuto(self.context, label)
4765 if data_type == int:
4766 return context_wrapper.getGlobalDataInt(self.context, label)
4767 elif data_type == float:
4768 return context_wrapper.getGlobalDataFloat(self.context, label)
4769 elif data_type == str:
4770 return context_wrapper.getGlobalDataString(self.context, label)
4771 elif data_type == vec3:
4772 coords = context_wrapper.getGlobalDataVec3(self.context, label)
4773 return vec3(coords[0], coords[1], coords[2])
4774 elif data_type == vec2:
4775 coords = context_wrapper.getGlobalDataVec2(self.context, label)
4776 return vec2(coords[0], coords[1])
4777 elif data_type == vec4:
4778 coords = context_wrapper.getGlobalDataVec4(self.context, label)
4779 return vec4(coords[0], coords[1], coords[2], coords[3])
4780 elif data_type == int2:
4781 coords = context_wrapper.getGlobalDataInt2(self.context, label)
4782 return int2(coords[0], coords[1])
4783 elif data_type == int3:
4784 coords = context_wrapper.getGlobalDataInt3(self.context, label)
4785 return int3(coords[0], coords[1], coords[2])
4786 elif data_type == int4:
4787 coords = context_wrapper.getGlobalDataInt4(self.context, label)
4788 return int4(coords[0], coords[1], coords[2], coords[3])
4789 elif data_type == "uint":
4790 return context_wrapper.getGlobalDataUInt(self.context, label)
4791 elif data_type == "double":
4792 return context_wrapper.getGlobalDataDouble(self.context, label)
4793 else:
4794 raise ValueError(f"Unsupported global data type: {data_type}")
4795
4796 def getGlobalDataFloat(self, label: str) -> float:
4797 """Get float global data."""
4798 return context_wrapper.getGlobalDataFloat(self.context, label)
4799
4800 def getGlobalDataInt(self, label: str) -> int:
4801 """Get int global data."""
4802 return context_wrapper.getGlobalDataInt(self.context, label)
4803
4804 def getGlobalDataString(self, label: str) -> str:
4805 """Get string global data."""
4806 return context_wrapper.getGlobalDataString(self.context, label)
4807
4808 def getGlobalDataType(self, label: str) -> int:
4809 """Get the HeliosDataType enum for global data."""
4810 return context_wrapper.getGlobalDataTypeWrapper(self.context, label)
4811
4812 def getGlobalDataSize(self, label: str) -> int:
4813 """Get the size of global data array."""
4814 return context_wrapper.getGlobalDataSizeWrapper(self.context, label)
4815
4816 def doesGlobalDataExist(self, label: str) -> bool:
4817 """Check if global data exists."""
4818 return context_wrapper.doesGlobalDataExistWrapper(self.context, label)
4819
4820 def clearGlobalData(self, label: str) -> None:
4821 """Clear global data."""
4822 context_wrapper.clearGlobalDataWrapper(self.context, label)
4823
4824 def renameGlobalData(self, old_label: str, new_label: str) -> None:
4825 """Rename a global data label."""
4826 context_wrapper.renameGlobalDataWrapper(self.context, old_label, new_label)
4827
4828 def duplicateGlobalData(self, old_label: str, new_label: str) -> None:
4829 """Duplicate global data to a new label."""
4830 context_wrapper.duplicateGlobalDataWrapper(self.context, old_label, new_label)
4831
4832 def listGlobalData(self) -> List[str]:
4833 """List all global data labels."""
4834 return context_wrapper.listGlobalDataWrapper(self.context)
4835
4836 def incrementGlobalData(self, label: str, increment) -> None:
4837 """Increment global data. Auto-dispatches based on increment type."""
4838 if isinstance(increment, float):
4839 context_wrapper.incrementGlobalDataFloatWrapper(self.context, label, increment)
4840 elif isinstance(increment, int):
4841 context_wrapper.incrementGlobalDataIntWrapper(self.context, label, increment)
4842 else:
4843 raise ValueError(f"Unsupported increment type: {type(increment).__name__}")
4844
4845 # ==================== Primitive Data Statistics & Filtering ====================
4847 def calculatePrimitiveDataMean(self, uuids: List[int], label: str, return_type: type = float):
4848 """Calculate arithmetic mean of primitive data across UUIDs.
4849
4850 Args:
4851 uuids: List of primitive UUIDs.
4852 label: Data label.
4853 return_type: float (default), "double", or vec3.
4854 """
4855 if return_type == float:
4856 return context_wrapper.calculatePrimitiveDataMeanFloatWrapper(self.context, uuids, label)
4857 elif return_type == "double":
4858 return context_wrapper.calculatePrimitiveDataMeanDoubleWrapper(self.context, uuids, label)
4859 elif return_type == vec3:
4860 coords = context_wrapper.calculatePrimitiveDataMeanVec3Wrapper(self.context, uuids, label)
4861 return vec3(coords[0], coords[1], coords[2])
4862 else:
4863 raise ValueError(f"Unsupported return type: {return_type}")
4864
4865 def calculatePrimitiveDataAreaWeightedMean(self, uuids: List[int], label: str, return_type: type = float):
4866 """Calculate area-weighted mean of primitive data."""
4867 if return_type == float:
4868 return context_wrapper.calculatePrimitiveDataAreaWeightedMeanFloatWrapper(self.context, uuids, label)
4869 else:
4870 raise ValueError(f"Unsupported return type: {return_type}")
4872 def calculatePrimitiveDataSum(self, uuids: List[int], label: str, return_type: type = float):
4873 """Calculate sum of primitive data across UUIDs."""
4874 if return_type == float:
4875 return context_wrapper.calculatePrimitiveDataSumFloatWrapper(self.context, uuids, label)
4876 elif return_type == "double":
4877 return context_wrapper.calculatePrimitiveDataSumDoubleWrapper(self.context, uuids, label)
4878 else:
4879 raise ValueError(f"Unsupported return type: {return_type}")
4880
4881 def calculatePrimitiveDataAreaWeightedSum(self, uuids: List[int], label: str, return_type: type = float):
4882 """Calculate area-weighted sum of primitive data."""
4883 if return_type == float:
4884 return context_wrapper.calculatePrimitiveDataAreaWeightedSumFloatWrapper(self.context, uuids, label)
4885 else:
4886 raise ValueError(f"Unsupported return type: {return_type}")
4887
4888 def scalePrimitiveData(self, uuids_or_label, label_or_factor, factor=None) -> None:
4889 """Scale primitive data by a factor.
4891 Overloads:
4892 scalePrimitiveData(uuids, label, factor) - scale for specific UUIDs
4893 scalePrimitiveData(label, factor) - scale for ALL primitives
4894 """
4895 if isinstance(uuids_or_label, str):
4896 context_wrapper.scalePrimitiveDataAllWrapper(self.context, uuids_or_label, label_or_factor)
4897 else:
4898 context_wrapper.scalePrimitiveDataWithUUIDsWrapper(self.context, uuids_or_label, label_or_factor, factor)
4900 def incrementPrimitiveData(self, uuids: List[int], label: str, increment, data_type: str = None) -> None:
4901 """Increment primitive data for the given UUIDs.
4902
4903 Each Helios increment overload only acts on fields whose stored type matches;
4904 fields of a different type are left unchanged. By default the overload is
4905 inferred from the Python type of ``increment`` (``int`` -> int, ``float`` ->
4906 float). To target an unsigned-int or double field, pass ``data_type``
4907 explicitly as one of ``'int'``, ``'uint'``, ``'float'``, ``'double'``.
4908
4909 Args:
4910 uuids: UUIDs whose data field to increment.
4911 label: Data field label.
4912 increment: Amount to add.
4913 data_type: Optional explicit field type to target.
4914 """
4915 if data_type is not None:
4916 dt = data_type.lower()
4917 if dt == 'int':
4918 context_wrapper.incrementPrimitiveDataIntWrapper(self.context, uuids, label, int(increment))
4919 elif dt in ('uint', 'unsigned', 'unsigned int'):
4920 context_wrapper.incrementPrimitiveDataUIntWrapper(self.context, uuids, label, int(increment))
4921 elif dt == 'float':
4922 context_wrapper.incrementPrimitiveDataFloatWrapper(self.context, uuids, label, float(increment))
4923 elif dt == 'double':
4924 context_wrapper.incrementPrimitiveDataDoubleWrapper(self.context, uuids, label, float(increment))
4925 else:
4926 raise ValueError(f"Unsupported data_type: {data_type!r}. Expected one of 'int', 'uint', 'float', 'double'.")
4927 return
4928 if isinstance(increment, float):
4929 context_wrapper.incrementPrimitiveDataFloatWrapper(self.context, uuids, label, increment)
4930 elif isinstance(increment, int):
4931 context_wrapper.incrementPrimitiveDataIntWrapper(self.context, uuids, label, increment)
4932 else:
4933 raise ValueError(f"Unsupported increment type: {type(increment).__name__}")
4934
4935 def aggregatePrimitiveDataSum(self, uuids: List[int], labels: List[str], result_label: str) -> None:
4936 """Sum multiple primitive data fields into a new field."""
4937 context_wrapper.aggregatePrimitiveDataSumWrapper(self.context, uuids, labels, result_label)
4938
4939 def aggregatePrimitiveDataProduct(self, uuids: List[int], labels: List[str], result_label: str) -> None:
4940 """Multiply multiple primitive data fields into a new field."""
4941 context_wrapper.aggregatePrimitiveDataProductWrapper(self.context, uuids, labels, result_label)
4942
4943 def sumPrimitiveSurfaceArea(self, uuids: List[int]) -> float:
4944 """Calculate total one-sided surface area for a set of primitives."""
4945 return context_wrapper.sumPrimitiveSurfaceAreaWrapper(self.context, uuids)
4946
4947 def calculateAreaIndex(self, leaf_uuids: List[int], wood_uuids: Optional[List[int]] = None,
4948 ground_area: Optional[float] = None) -> float:
4949 """Calculate the one-sided area index on a ground-area basis.
4950
4951 This is the quantity :math:`L` appearing in Beer's law,
4952 :math:`\\exp(-G L / \\cos\\theta)`: total one-sided area divided by the ground
4953 area over which it is distributed.
4954
4955 Args:
4956 leaf_uuids: UUIDs of leaf primitives.
4957 wood_uuids: Optional UUIDs of woody (branch, trunk, stem) primitives. When
4958 given, the result is a *plant* area index rather than a leaf area index.
4959 Woody area is counted as one half of its summed one-sided area, because a
4960 tube or cone encloses the branch and so sums to the full cylinder surface
4961 rather than the projected area Beer's law requires. If woody elements are
4962 instead represented by non-enclosing planar primitives that are already
4963 one-sided silhouettes, this halving underestimates them by a factor of two.
4964 ground_area: Optional ground area basis in m^2. When omitted, the basis is the
4965 horizontal (x-y) footprint of the bounding box of *all* primitives in the
4966 Context -- so a ground primitive extending beyond the canopy enlarges the
4967 basis and lowers the reported index. Supply this explicitly whenever the
4968 domain is not cropped tightly to the canopy.
4969
4970 Returns:
4971 One-sided area index (m^2 area per m^2 ground area).
4972
4973 Raises:
4974 ValueError: If ``leaf_uuids`` is empty or ``ground_area`` is not positive.
4975 RuntimeError: If any primitive is a voxel, whose area is its total enclosing
4976 surface area rather than a one-sided area.
4977
4978 Example:
4979 >>> lai = context.calculateAreaIndex(leaf_uuids)
4980 >>> pai = context.calculateAreaIndex(leaf_uuids, wood_uuids)
4981 >>> lai = context.calculateAreaIndex(leaf_uuids, ground_area=100.0)
4982 """
4983 if not leaf_uuids:
4984 raise ValueError("leaf_uuids must contain at least one UUID")
4985 if ground_area is not None and ground_area <= 0:
4986 raise ValueError(f"Ground area must be positive, got {ground_area}")
4987
4988 if wood_uuids is not None and ground_area is not None:
4989 return context_wrapper.calculateAreaIndexLeafWoodGroundAreaWrapper(
4990 self.context, leaf_uuids, wood_uuids, ground_area)
4991 elif wood_uuids is not None:
4992 return context_wrapper.calculateAreaIndexLeafWoodWrapper(self.context, leaf_uuids, wood_uuids)
4993 elif ground_area is not None:
4994 return context_wrapper.calculateAreaIndexLeafGroundAreaWrapper(self.context, leaf_uuids, ground_area)
4995 else:
4996 return context_wrapper.calculateAreaIndexLeafWrapper(self.context, leaf_uuids)
4997
4998 def filterPrimitivesByData(self, uuids: List[int], label: str, value, comparator: str = "=") -> List[int]:
4999 """Filter primitives by data value. Auto-dispatches based on value type.
5000
5001 Args:
5002 uuids: UUIDs to filter.
5003 label: Data label to compare.
5004 value: Filter value (float, int, or str).
5005 comparator: Comparison operator ("=", "<", ">", "<=", ">="). Not used for strings.
5006 """
5007 if isinstance(value, str):
5008 return context_wrapper.filterPrimitivesByDataStringWrapper(self.context, uuids, label, value)
5009 elif isinstance(value, float):
5010 return context_wrapper.filterPrimitivesByDataFloatWrapper(self.context, uuids, label, value, comparator)
5011 elif isinstance(value, int):
5012 return context_wrapper.filterPrimitivesByDataIntWrapper(self.context, uuids, label, value, comparator)
5013 else:
5014 raise ValueError(f"Unsupported filter value type: {type(value).__name__}")
5015
5016 # ==================== Object Geometry Queries ====================
5017
5018 def getObjectType(self, objID: int) -> int:
5019 """Return the integer-coded `helios::ObjectType` of a compound object.
5020
5021 Values follow the C++ `helios::ObjectType` enum
5022 (0=tile, 1=sphere, 2=tube, 3=box, 4=disk, 5=polymesh, 6=cone,
5023 7=adaptive_tile).
5024 """
5026 return context_wrapper.getObjectTypeWrapper(self.context, objID)
5027
5028 def getObjectCenter(self, objID: int) -> vec3:
5030 x, y, z = context_wrapper.getObjectCenterWrapper(self.context, objID)
5031 return vec3(x, y, z)
5032
5033 def getObjectBoundingBox(self, objIDs):
5034 """Get axis-aligned bounding box for one object or a list of objects.
5035
5036 The box encloses every vertex of every primitive belonging to the given
5037 object(s).
5038
5039 Args:
5040 objIDs: Single object ID (int) or list of object IDs.
5042 Returns:
5043 Tuple of (min_corner: vec3, max_corner: vec3).
5044
5045 Raises:
5046 HeliosRuntimeError: If an object ID does not exist, or if the given
5047 object(s) contain no primitives at all (a bounding box would be
5048 undefined; this previously returned a misleading box at the origin).
5049 """
5051 if isinstance(objIDs, (list, tuple)):
5052 mn, mx = context_wrapper.getObjectBoundingBoxBatchWrapper(self.context, list(objIDs))
5053 else:
5054 mn, mx = context_wrapper.getObjectBoundingBoxWrapper(self.context, objIDs)
5055 return (vec3(mn[0], mn[1], mn[2]), vec3(mx[0], mx[1], mx[2]))
5056
5057 def getObjectPrimitiveUUIDs(self, objIDs) -> List[int]:
5058 """Get flattened primitive UUIDs for one object, a list of objects, or a list-of-lists.
5059
5060 Args:
5061 objIDs: int, List[int], or List[List[int]].
5062
5063 Returns:
5064 Flat list of primitive UUIDs (union across all objects).
5065 """
5067 if isinstance(objIDs, (list, tuple)) and objIDs and isinstance(objIDs[0], (list, tuple)):
5068 return context_wrapper.getObjectPrimitiveUUIDsNestedWrapper(self.context, [list(x) for x in objIDs])
5069 if isinstance(objIDs, (list, tuple)):
5070 return context_wrapper.getObjectPrimitiveUUIDsBatchWrapper(self.context, list(objIDs))
5071 return context_wrapper.getObjectPrimitiveUUIDs(self.context, int(objIDs))
5072
5073 # Tile
5074 def getTileObjectAreaRatio(self, objIDs):
5075 """Get tile-object area ratio for one or multiple tile objects."""
5077 if isinstance(objIDs, (list, tuple)):
5078 return context_wrapper.getTileObjectAreaRatioBatchWrapper(self.context, list(objIDs))
5079 return context_wrapper.getTileObjectAreaRatioWrapper(self.context, objIDs)
5080
5081 def getTileObjectCenter(self, objID: int) -> vec3:
5083 x, y, z = context_wrapper.getTileObjectCenterWrapper(self.context, objID)
5084 return vec3(x, y, z)
5085
5086 def getTileObjectSize(self, objID: int) -> vec2:
5088 x, y = context_wrapper.getTileObjectSizeWrapper(self.context, objID)
5089 return vec2(x, y)
5090
5091 def getTileObjectSubdivisionCount(self, objID: int) -> int2:
5093 x, y = context_wrapper.getTileObjectSubdivisionCountWrapper(self.context, objID)
5094 return int2(x, y)
5095
5096 def getTileObjectNormal(self, objID: int) -> vec3:
5098 x, y, z = context_wrapper.getTileObjectNormalWrapper(self.context, objID)
5099 return vec3(x, y, z)
5100
5101 def getTileObjectTextureUV(self, objID: int) -> List[vec2]:
5103 pairs = context_wrapper.getTileObjectTextureUVWrapper(self.context, objID)
5104 return [vec2(u, v) for u, v in pairs]
5105
5106 def getTileObjectVertices(self, objID: int) -> List[vec3]:
5108 triples = context_wrapper.getTileObjectVerticesWrapper(self.context, objID)
5109 return [vec3(x, y, z) for x, y, z in triples]
5110
5111 def getTileObjectTextureRepeat(self, objID: int) -> int2:
5112 """Get the texture repeat count requested when the tile object was created.
5114 The repeat actually applied to the sub-patch texture coordinates is reduced whenever the
5115 requested count does not evenly divide the subdivision count; use
5116 :meth:`getTileObjectEffectiveTextureRepeat` to query that value.
5117
5118 It is the requested rather than the reduced count that is retained on the object and
5119 re-applied when :meth:`setTileObjectSubdivisionCount` changes the subdivision count.
5120 """
5122 x, y = context_wrapper.getTileObjectTextureRepeatWrapper(self.context, objID)
5123 return int2(x, y)
5124
5125 def getTileObjectEffectiveTextureRepeat(self, objID: int) -> int2:
5126 """Get the texture repeat count actually applied to the sub-patches of a tile object.
5127
5128 This is the requested count (see :meth:`getTileObjectTextureRepeat`) reduced so that it
5129 evenly divides the subdivision count.
5130 """
5132 x, y = context_wrapper.getTileObjectEffectiveTextureRepeatWrapper(self.context, objID)
5133 return int2(x, y)
5134
5135 # Adaptive Tile
5136 def getAdaptiveTileObjectCenter(self, objID: int) -> vec3:
5137 """Get the Cartesian coordinates of the center of an adaptive tile object."""
5139 x, y, z = context_wrapper.getAdaptiveTileObjectCenterWrapper(self.context, objID)
5140 return vec3(x, y, z)
5141
5142 def getAdaptiveTileObjectSize(self, objID: int) -> vec2:
5143 """Get the dimensions of an entire adaptive tile object."""
5145 x, y = context_wrapper.getAdaptiveTileObjectSizeWrapper(self.context, objID)
5146 return vec2(x, y)
5148 def getAdaptiveTileObjectNormal(self, objID: int) -> vec3:
5149 """Get a unit vector normal to an adaptive tile object surface."""
5151 x, y, z = context_wrapper.getAdaptiveTileObjectNormalWrapper(self.context, objID)
5152 return vec3(x, y, z)
5153
5154 def getAdaptiveTileObjectVertices(self, objID: int) -> List[vec3]:
5155 """Get the Cartesian coordinates of each of the four corners of an adaptive tile object."""
5157 triples = context_wrapper.getAdaptiveTileObjectVerticesWrapper(self.context, objID)
5158 return [vec3(x, y, z) for x, y, z in triples]
5159
5160 def getAdaptiveTileObjectRefinement(self, objID: int) -> AdaptiveTileRefinement:
5161 """Get the refinement parameters that were requested when the object was created."""
5163 tx, ty, smin, smax, exponent = context_wrapper.getAdaptiveTileObjectRefinementWrapper(self.context, objID)
5164 return AdaptiveTileRefinement(target=vec2(tx, ty), subpatch_size_min=smin,
5165 subpatch_size_max=smax, transition_exponent=exponent)
5167 def getAdaptiveTileObjectBaseSubdivisionCount(self, objID: int) -> int2:
5168 """Get the number of coarsest-level cells spanning an adaptive tile in x and y.
5169
5170 The base grid is the uniform grid of unrefined cells that the quadtree refines within. It
5171 is derived from the requested sub-patch size range and the tile dimensions.
5172 """
5174 x, y = context_wrapper.getAdaptiveTileObjectBaseSubdivisionCountWrapper(self.context, objID)
5175 return int2(x, y)
5176
5177 def getAdaptiveTileObjectMaxRefinementLevel(self, objID: int) -> int:
5178 """Get the maximum quadtree refinement level, i.e. the number of times a base cell may be subdivided."""
5180 return context_wrapper.getAdaptiveTileObjectMaxRefinementLevelWrapper(self.context, objID)
5181
5182 def getAdaptiveTileObjectSubpatchSizeRange(self, objID: int) -> vec2:
5183 """Get the sub-patch edge lengths actually achieved, as opposed to those requested.
5184
5185 Returns:
5186 A vec2 holding the achieved minimum edge length in ``x`` and the achieved maximum edge
5187 length in ``y``.
5188 """
5190 x, y = context_wrapper.getAdaptiveTileObjectSubpatchSizeRangeWrapper(self.context, objID)
5191 return vec2(x, y)
5192
5193 def getAdaptiveTileObjectTextureRepeat(self, objID: int) -> int2:
5194 """Get the texture repeat count of an adaptive tile object.
5196 The base grid is snapped to a multiple of the requested repeat count when the object is
5197 created, so unlike :meth:`getTileObjectTextureRepeat` the requested count is always applied
5198 exactly and there is no effective-repeat variant.
5199 """
5201 x, y = context_wrapper.getAdaptiveTileObjectTextureRepeatWrapper(self.context, objID)
5202 return int2(x, y)
5203
5204 # Sphere
5205 def getSphereObjectCenter(self, objID: int) -> vec3:
5207 x, y, z = context_wrapper.getSphereObjectCenterWrapper(self.context, objID)
5208 return vec3(x, y, z)
5209
5210 def getSphereObjectRadius(self, objID: int) -> vec3:
5211 """Get per-axis radii of a sphere object.
5212
5213 Note: Helios spheres are spheroids with three independent radii (rx, ry, rz).
5214 Returns a vec3 (not a scalar).
5215 """
5217 x, y, z = context_wrapper.getSphereObjectRadiusWrapper(self.context, objID)
5218 return vec3(x, y, z)
5219
5220 def getSphereObjectSubdivisionCount(self, objID: int) -> int:
5222 return context_wrapper.getSphereObjectSubdivisionCountWrapper(self.context, objID)
5223
5224 def getSphereObjectVolume(self, objID: int) -> float:
5226 return context_wrapper.getSphereObjectVolumeWrapper(self.context, objID)
5227
5228 # Box
5229 def getBoxObjectCenter(self, objID: int) -> vec3:
5231 x, y, z = context_wrapper.getBoxObjectCenterWrapper(self.context, objID)
5232 return vec3(x, y, z)
5233
5234 def getBoxObjectSize(self, objID: int) -> vec3:
5236 x, y, z = context_wrapper.getBoxObjectSizeWrapper(self.context, objID)
5237 return vec3(x, y, z)
5238
5239 def getBoxObjectSubdivisionCount(self, objID: int) -> int3:
5241 x, y, z = context_wrapper.getBoxObjectSubdivisionCountWrapper(self.context, objID)
5242 return int3(x, y, z)
5243
5244 def getBoxObjectVolume(self, objID: int) -> float:
5246 return context_wrapper.getBoxObjectVolumeWrapper(self.context, objID)
5247
5248 # Disk
5249 def getDiskObjectCenter(self, objID: int) -> vec3:
5251 x, y, z = context_wrapper.getDiskObjectCenterWrapper(self.context, objID)
5252 return vec3(x, y, z)
5253
5254 def getDiskObjectSize(self, objID: int) -> vec2:
5256 x, y = context_wrapper.getDiskObjectSizeWrapper(self.context, objID)
5257 return vec2(x, y)
5258
5259 def getDiskObjectSubdivisionCount(self, objID: int) -> int:
5261 return context_wrapper.getDiskObjectSubdivisionCountWrapper(self.context, objID)
5262
5263 # Tube
5264 def getTubeObjectSubdivisionCount(self, objID: int) -> int:
5266 return context_wrapper.getTubeObjectSubdivisionCountWrapper(self.context, objID)
5267
5268 def getTubeObjectNodeCount(self, objID: int) -> int:
5270 return context_wrapper.getTubeObjectNodeCountWrapper(self.context, objID)
5272 def getTubeObjectNodes(self, objID: int) -> List[vec3]:
5274 triples = context_wrapper.getTubeObjectNodesWrapper(self.context, objID)
5275 return [vec3(x, y, z) for x, y, z in triples]
5277 def getTubeObjectNodeRadii(self, objID: int) -> List[float]:
5279 return context_wrapper.getTubeObjectNodeRadiiWrapper(self.context, objID)
5280
5281 def getTubeObjectNodeColors(self, objID: int) -> List[RGBcolor]:
5283 triples = context_wrapper.getTubeObjectNodeColorsWrapper(self.context, objID)
5284 return [RGBcolor(r, g, b) for r, g, b in triples]
5286 def getTubeObjectVolume(self, objID: int) -> float:
5288 return context_wrapper.getTubeObjectVolumeWrapper(self.context, objID)
5290 def getTubeObjectSegmentVolume(self, objID: int, segment_index: int) -> float:
5292 return context_wrapper.getTubeObjectSegmentVolumeWrapper(self.context, objID, segment_index)
5293
5294 # Cone
5295 def getConeObjectSubdivisionCount(self, objID: int) -> int:
5297 return context_wrapper.getConeObjectSubdivisionCountWrapper(self.context, objID)
5299 def getConeObjectNodes(self, objID: int) -> List[vec3]:
5301 triples = context_wrapper.getConeObjectNodesWrapper(self.context, objID)
5302 return [vec3(x, y, z) for x, y, z in triples]
5304 def getConeObjectNodeRadii(self, objID: int) -> List[float]:
5306 return context_wrapper.getConeObjectNodeRadiiWrapper(self.context, objID)
5308 def getConeObjectNode(self, objID: int, number: int) -> vec3:
5310 x, y, z = context_wrapper.getConeObjectNodeWrapper(self.context, objID, number)
5311 return vec3(x, y, z)
5313 def getConeObjectNodeRadius(self, objID: int, number: int) -> float:
5315 return context_wrapper.getConeObjectNodeRadiusWrapper(self.context, objID, number)
5317 def getConeObjectAxisUnitVector(self, objID: int) -> vec3:
5319 x, y, z = context_wrapper.getConeObjectAxisUnitVectorWrapper(self.context, objID)
5320 return vec3(x, y, z)
5322 def getConeObjectLength(self, objID: int) -> float:
5324 return context_wrapper.getConeObjectLengthWrapper(self.context, objID)
5326 def getConeObjectVolume(self, objID: int) -> float:
5328 return context_wrapper.getConeObjectVolumeWrapper(self.context, objID)
5329
5330 # ==================== Primitive Geometry Queries ====================
5331
5332 def getPatchCenter(self, uuid: int) -> vec3:
5334 x, y, z = context_wrapper.getPatchCenterWrapper(self.context, uuid)
5335 return vec3(x, y, z)
5336
5337 def getPatchSize(self, uuid: int) -> vec2:
5339 x, y = context_wrapper.getPatchSizeWrapper(self.context, uuid)
5340 return vec2(x, y)
5341
5342 def getTriangleVertex(self, uuid: int, number: int) -> vec3:
5344 x, y, z = context_wrapper.getTriangleVertexWrapper(self.context, uuid, number)
5345 return vec3(x, y, z)
5346
5347 def getVoxelCenter(self, uuid: int) -> vec3:
5349 x, y, z = context_wrapper.getVoxelCenterWrapper(self.context, uuid)
5350 return vec3(x, y, z)
5351
5352 def getVoxelSize(self, uuid: int) -> vec3:
5354 x, y, z = context_wrapper.getVoxelSizeWrapper(self.context, uuid)
5355 return vec3(x, y, z)
5356
5357 def getPatchCount(self, include_hidden: bool = True) -> int:
5359 return context_wrapper.getPatchCountWrapper(self.context, include_hidden)
5360
5361 def getTriangleCount(self, include_hidden: bool = True) -> int:
5363 return context_wrapper.getTriangleCountWrapper(self.context, include_hidden)
5365 def getPrimitiveBoundingBox(self, uuids):
5366 """Get axis-aligned bounding box for one primitive or a list of primitives.
5367
5368 Args:
5369 uuids: Single UUID (int) or list of UUIDs.
5370
5371 Returns:
5372 Tuple of (min_corner: vec3, max_corner: vec3).
5373 """
5375 if isinstance(uuids, (list, tuple)):
5376 mn, mx = context_wrapper.getPrimitiveBoundingBoxBatchWrapper(self.context, list(uuids))
5377 else:
5378 mn, mx = context_wrapper.getPrimitiveBoundingBoxWrapper(self.context, uuids)
5379 return (vec3(mn[0], mn[1], mn[2]), vec3(mx[0], mx[1], mx[2]))
5380
5381 # ==================== Primitive Color Mutation ====================
5382
5383 def setPrimitiveColor(self, uuids, color) -> None:
5384 """Set the RGB or RGBA color of one primitive or a list of primitives.
5385
5386 Args:
5387 uuids: Single UUID (int) or list of UUIDs.
5388 color: RGBcolor or RGBAcolor.
5389 """
5391 if isinstance(color, RGBAcolor):
5392 rgba = [color.r, color.g, color.b, color.a]
5393 if isinstance(uuids, (list, tuple)):
5394 context_wrapper.setPrimitiveColorRGBABatchWrapper(self.context, list(uuids), rgba)
5395 else:
5396 context_wrapper.setPrimitiveColorRGBAWrapper(self.context, uuids, rgba)
5397 elif isinstance(color, RGBcolor):
5398 rgb = [color.r, color.g, color.b]
5399 if isinstance(uuids, (list, tuple)):
5400 context_wrapper.setPrimitiveColorBatchWrapper(self.context, list(uuids), rgb)
5401 else:
5402 context_wrapper.setPrimitiveColorWrapper(self.context, uuids, rgb)
5403 else:
5404 raise ValueError(f"color must be RGBcolor or RGBAcolor, got {type(color).__name__}")
5405
5406 # ==================== Primitive Data Introspection / Cleanup ====================
5407
5408 def clearPrimitiveData(self, uuids, label: str) -> None:
5409 """Remove a named data field from one primitive or a list of primitives."""
5411 if isinstance(uuids, (list, tuple)):
5412 context_wrapper.clearPrimitiveDataByLabelBatchWrapper(self.context, list(uuids), label)
5413 else:
5414 context_wrapper.clearPrimitiveDataByLabelWrapper(self.context, uuids, label)
5415
5416 def clearAllPrimitiveData(self, label: str) -> None:
5417 """Remove a named data field from every primitive in the Context.
5418
5419 Clears the data with the given label from all primitives (including hidden ones)
5420 and releases the registered data type for the label, so it may subsequently be
5421 re-registered with a different type. Requires helios-core v1.3.73 or newer.
5422 """
5424 context_wrapper.clearAllPrimitiveDataByLabelWrapper(self.context, label)
5425
5426 def listPrimitiveData(self, uuid: int) -> List[str]:
5427 """List all data labels attached to a primitive."""
5429 return context_wrapper.listPrimitiveDataWrapper(self.context, uuid)
5430
5431 # ==================== Domain Cropping ====================
5432
5433 def cropDomainX(self, xbounds: vec2) -> None:
5435 if not isinstance(xbounds, vec2):
5436 raise ValueError(f"xbounds must be a vec2, got {type(xbounds).__name__}")
5437 context_wrapper.cropDomainXWrapper(self.context, xbounds.to_list())
5438
5439 def cropDomainY(self, ybounds: vec2) -> None:
5441 if not isinstance(ybounds, vec2):
5442 raise ValueError(f"ybounds must be a vec2, got {type(ybounds).__name__}")
5443 context_wrapper.cropDomainYWrapper(self.context, ybounds.to_list())
5445 def cropDomainZ(self, zbounds: vec2) -> None:
5447 if not isinstance(zbounds, vec2):
5448 raise ValueError(f"zbounds must be a vec2, got {type(zbounds).__name__}")
5449 context_wrapper.cropDomainZWrapper(self.context, zbounds.to_list())
5451 def cropDomain(self, *args) -> Optional[List[int]]:
5452 """Crop the context domain to the given XYZ bounds.
5453
5454 Two call forms:
5455 cropDomain(xbounds: vec2, ybounds: vec2, zbounds: vec2)
5456 -> crop ALL primitives; returns None.
5457 cropDomain(uuids: List[int], xbounds: vec2, ybounds: vec2, zbounds: vec2)
5458 -> crop only the given primitives; returns the list of primitives
5459 that survived (in-bounds UUIDs). The input list is NOT mutated.
5460 """
5462 if len(args) == 3:
5463 xb, yb, zb = args
5464 for name, b in (("xbounds", xb), ("ybounds", yb), ("zbounds", zb)):
5465 if not isinstance(b, vec2):
5466 raise ValueError(f"{name} must be a vec2, got {type(b).__name__}")
5467 context_wrapper.cropDomainXYZWrapper(self.context, xb.to_list(), yb.to_list(), zb.to_list())
5468 return None
5469 if len(args) == 4:
5470 uuids, xb, yb, zb = args
5471 if not isinstance(uuids, (list, tuple)):
5472 raise ValueError(f"uuids must be a list or tuple, got {type(uuids).__name__}")
5473 for name, b in (("xbounds", xb), ("ybounds", yb), ("zbounds", zb)):
5474 if not isinstance(b, vec2):
5475 raise ValueError(f"{name} must be a vec2, got {type(b).__name__}")
5476 return context_wrapper.cropDomainByUUIDsWrapper(self.context, list(uuids), xb.to_list(), yb.to_list(), zb.to_list())
5477 raise TypeError(f"cropDomain() takes 3 or 4 positional arguments, got {len(args)}")
5478
5479 # =========================================================================
5480 # Scalar Getters / Setters & List-of-String Getters
5481 # =========================================================================
5482
5483 # ---- Existence / state queries ----
5484
5485 def doesObjectExist(self, objID: int) -> bool:
5486 """Return True if a compound object with the given ID exists."""
5488 return context_wrapper.doesObjectExistWrapper(self.context, objID)
5489
5490 def doesObjectContainPrimitive(self, objID: int, uuid: int) -> bool:
5491 """Return True if the given primitive UUID belongs to the given object."""
5493 return context_wrapper.doesObjectContainPrimitiveWrapper(self.context, objID, uuid)
5494
5495 def doesMaterialDataExist(self, material_label: str, data_label: str) -> bool:
5496 """Return True if the named material has data stored under data_label."""
5498 return context_wrapper.doesMaterialDataExistWrapper(self.context, material_label, data_label)
5499
5500 def objectHasTexture(self, objID: int) -> bool:
5501 """Return True if the compound object has a texture assigned."""
5503 return context_wrapper.objectHasTextureWrapper(self.context, objID)
5504
5505 def isPrimitiveDirty(self, uuid: int) -> bool:
5506 """Return True if the primitive's geometry has been modified since the last clean mark."""
5508 return context_wrapper.isPrimitiveDirtyWrapper(self.context, uuid)
5509
5510 def isObjectDataValueCachingEnabled(self, label: str) -> bool:
5511 """Return True if value caching is enabled for the given object-data label."""
5513 return context_wrapper.isObjectDataValueCachingEnabledWrapper(self.context, label)
5514
5515 def isPrimitiveDataValueCachingEnabled(self, label: str) -> bool:
5516 """Return True if value caching is enabled for the given primitive-data label."""
5518 return context_wrapper.isPrimitiveDataValueCachingEnabledWrapper(self.context, label)
5519
5520 def areObjectPrimitivesComplete(self, objID: int) -> bool:
5521 """Return True if all primitives originally belonging to this object still exist
5522 (i.e., none have been deleted)."""
5524 return context_wrapper.areObjectPrimitivesCompleteWrapper(self.context, objID)
5525
5526 # ---- Numeric scalar getters ----
5527
5528 def getJulianDate(self) -> int:
5529 """Get the current simulation date as Julian day (1-366)."""
5531 return context_wrapper.getJulianDateWrapper(self.context)
5532
5533 def getMaterialCount(self) -> int:
5534 """Return the total number of materials registered in the context."""
5536 return context_wrapper.getMaterialCountWrapper(self.context)
5537
5538 def getObjectArea(self, objID: int) -> float:
5539 """Return the total surface area (one-sided) of all primitives in the object."""
5541 return context_wrapper.getObjectAreaWrapper(self.context, objID)
5542
5543 def getObjectPrimitiveCount(self, objID: int) -> int:
5544 """Return the number of primitives currently belonging to the object."""
5546 return context_wrapper.getObjectPrimitiveCountWrapper(self.context, objID)
5547
5548 def getPolymeshObjectVolume(self, objID: int) -> float:
5549 """Return the enclosed volume of a polymesh object.
5550
5551 Since helios-core 1.3.84 a mesh carrying a face table is separated into its
5552 connected pieces and the volume of those that are closed is summed, so a solid
5553 shape modelled with an open stalk reports the shape's volume. An error is raised
5554 only when no piece is closed. A mesh carrying no face table has its closure
5555 checked by matching facets on coincident corners.
5556 """
5558 return context_wrapper.getPolymeshObjectVolumeWrapper(self.context, objID)
5559
5560 def getPolymeshObjectSurfaceArea(self, objID: int) -> float:
5561 """Return the total surface area of a polymesh object, summed over every face."""
5563 return context_wrapper.getPolymeshObjectSurfaceAreaWrapper(self.context, objID)
5564
5565 def isPolymeshObjectClosed(self, objID: int) -> bool:
5566 """
5567 Return True if a polymesh object is a closed surface, i.e. has no boundary edges.
5568
5569 Only a closed mesh has a well-defined enclosed volume. Since helios-core 1.3.84
5570 :meth:`getPolymeshObjectVolume` splits a mesh into its connected pieces and sums
5571 the volume of those that are closed, so it raises only when no piece is closed --
5572 a solid fruit modelled with an open stalk reports the fruit's volume.
5573 """
5575 return context_wrapper.isPolymeshObjectClosedWrapper(self.context, objID)
5576
5577 def setPolymeshObjectVertices(self, objID: int, vertices: List[vec3]) -> None:
5578 """
5579 Move every shared vertex of a polymesh object, deforming the mesh.
5580
5581 Writes every shared vertex in one pass and pushes the new positions out to the
5582 member primitives, so faces that meet at a vertex stay welded. This is the
5583 supported way to deform a mesh: transforming the member primitives individually
5584 leaves each shared vertex wherever the last facet processed put it.
5585
5586 The topology is unchanged, so ``vertices`` must be parallel to and the same length
5587 as the list returned by :meth:`getPolymeshObjectVertices`. Texture coordinates are
5588 not touched, and neither is the solid fraction of the member primitives -- so
5589 deforming a textured mesh does not re-rasterize its alpha mask.
5591 Args:
5592 objID: Object ID of the polymesh object
5593 vertices: New vertex positions in global Cartesian coordinates
5594
5595 Raises:
5596 RuntimeError: If the native library predates helios-core v1.3.84
5597 ValueError: If a vertex is not a vec3
5598
5599 Note:
5600 Vertex normals are NOT recomputed and no longer describe the deformed surface;
5601 call :meth:`computePolymeshObjectVertexNormals` again if exact normals matter.
5602
5603 Example:
5604 >>> verts = context.getPolymeshObjectVertices(objID)
5605 >>> stretched = [vec3(v.x, v.y, v.z * 2.0) for v in verts]
5606 >>> context.setPolymeshObjectVertices(objID, stretched)
5607 """
5609 for i, v in enumerate(vertices):
5610 if not isinstance(v, vec3):
5611 raise ValueError(f"vertices[{i}] must be a vec3, got {type(v).__name__}")
5612 context_wrapper.setPolymeshObjectVerticesWrapper(
5613 self.context, objID, [(v.x, v.y, v.z) for v in vertices]
5614 )
5615
5616 def doesObjectHaveSharedVertexTopology(self, objID: int) -> bool:
5617 """
5618 Return True if a compound object reports which member primitives meet at each vertex.
5619
5620 True for Tile, AdaptiveTile, Sphere, Tube and Cone objects, and for a Polymesh that
5621 carries a face table. False for a Box, a Disk, and for a polymesh assembled from
5622 loose primitives by :meth:`addPolymeshObject`, which has no topology.
5623
5624 Raises:
5625 RuntimeError: If the native library predates helios-core v1.3.84
5626 """
5628 return context_wrapper.doesObjectHaveSharedVertexTopologyWrapper(self.context, objID)
5629
5630 def getObjectSharedVertexCount(self, objID: int,
5631 weld_mode: VertexWeldMode = VertexWeldMode.WELD_FULL) -> int:
5632 """
5633 Return the number of distinct shared vertices in a compound object's mesh.
5634
5635 This is one greater than the largest index
5636 :meth:`getObjectPrimitiveSharedVertexIndices` can return, and zero if the object
5637 exposes no topology.
5638
5639 Args:
5640 objID: Object ID of the compound object
5641 weld_mode: Granularity at which coincident vertices are treated as the same
5642 shared vertex. See :class:`VertexWeldMode`.
5644 Raises:
5645 RuntimeError: If the native library predates helios-core v1.3.84
5646 """
5648 return context_wrapper.getObjectSharedVertexCountWrapper(
5649 self.context, objID, int(weld_mode)
5650 )
5651
5653 self, objID: int, uuid: int,
5654 weld_mode: VertexWeldMode = VertexWeldMode.WELD_FULL
5655 ) -> List[int]:
5656 """
5657 Return the shared mesh vertex each vertex of a primitive belongs to.
5658
5659 Indices are in the same order as :meth:`getPrimitiveVertices`, and two primitives
5660 meeting at a corner report the same index there. This is what lets a per-face
5661 quantity be averaged onto the vertices neighbouring faces have in common.
5663 Args:
5664 objID: Object ID of the compound object the primitive belongs to
5665 uuid: UUID of the primitive
5666 weld_mode: See :class:`VertexWeldMode`
5667
5668 Returns:
5669 One index per vertex of the primitive; empty if the object exposes no topology.
5670
5671 Raises:
5672 RuntimeError: If the native library predates helios-core v1.3.84
5673
5674 Note:
5675 When walking a whole object, prefer
5676 :meth:`getObjectPrimitiveSharedVertexIndicesMulti` -- this per-primitive form is
5677 O(n) per call on Sphere, Tube and Cone objects, so a full walk is O(n^2).
5678 """
5680 return context_wrapper.getObjectPrimitiveSharedVertexIndicesWrapper(
5681 self.context, objID, uuid, int(weld_mode)
5682 )
5683
5685 self, objID: int, uuids: List[int],
5686 weld_mode: VertexWeldMode = VertexWeldMode.WELD_FULL
5687 ) -> List[List[int]]:
5688 """
5689 Return shared mesh vertex indices for many primitives of a compound object at once.
5690
5691 Equivalent to calling :meth:`getObjectPrimitiveSharedVertexIndices` for each UUID,
5692 except that any per-object quantity needed to locate the vertices is prepared once
5693 for the whole batch. Prefer this overload when walking an entire object.
5694
5695 Args:
5696 objID: Object ID of the compound object the primitives belong to
5697 uuids: UUIDs of the primitives
5698 weld_mode: See :class:`VertexWeldMode`
5699
5700 Returns:
5701 A list parallel to ``uuids``, each entry holding one shared vertex index per
5702 vertex of the corresponding primitive.
5703
5704 Raises:
5705 RuntimeError: If the native library predates helios-core v1.3.84
5706 """
5708 return context_wrapper.getObjectPrimitiveSharedVertexIndicesMultiWrapper(
5709 self.context, objID, uuids, int(weld_mode)
5710 )
5711
5713 self, uuid: int,
5714 weld_mode: VertexWeldMode = VertexWeldMode.WELD_FULL
5715 ) -> List[int]:
5716 """
5717 Return a primitive's shared mesh vertex indices without naming its parent object.
5718
5719 Resolves the primitive's parent object and forwards to it.
5721 Args:
5722 uuid: UUID of the primitive
5723 weld_mode: See :class:`VertexWeldMode`
5724
5725 Returns:
5726 One index per vertex of the primitive. Empty if the primitive belongs to no
5727 object, or to one that exposes no topology.
5728
5729 Raises:
5730 RuntimeError: If the native library predates helios-core v1.3.84
5731 """
5733 return context_wrapper.getPrimitiveSharedVertexIndicesWrapper(
5734 self.context, uuid, int(weld_mode)
5735 )
5736
5737 def getPolymeshObjectVertices(self, objID: int) -> List[vec3]:
5738 """
5739 Return the deduplicated shared vertex positions of a polymesh object.
5740
5741 Returns an empty list for a mesh with no retained topology, such as one built
5742 with :meth:`addPolymeshObject` and not given a face set.
5743 """
5745 triples = context_wrapper.getPolymeshObjectVerticesWrapper(self.context, objID)
5746 return [vec3(x, y, z) for x, y, z in triples]
5747
5748 def getPolymeshObjectFaces(self, objID: int) -> List[int3]:
5749 """Return the vertex index triples defining each face of a polymesh object."""
5751 triples = context_wrapper.getPolymeshObjectFacesWrapper(self.context, objID)
5752 return [int3(a, b, c) for a, b, c in triples]
5753
5754 def getPolymeshObjectVertexNormals(self, objID: int) -> List[vec3]:
5755 """
5756 Return the per-vertex normals of a polymesh object.
5757
5758 Returns an empty list if the mesh carries none. A mesh loaded by :meth:`loadOBJ`
5759 or :meth:`loadPLY` always has them (helios-core v1.3.85+): normals authored in the
5760 file are kept, and a file that supplies none has them generated from the mesh
5761 connectivity. Only a mesh assembled programmatically through
5762 :meth:`setPolymeshObjectTopology` without normals carries none; call
5763 :meth:`computePolymeshObjectVertexNormals` to generate them.
5764 """
5766 triples = context_wrapper.getPolymeshObjectVertexNormalsWrapper(self.context, objID)
5767 return [vec3(x, y, z) for x, y, z in triples]
5768
5769 def getPolymeshObjectVertexUV(self, objID: int) -> List[vec2]:
5770 """Return the per-vertex texture coordinates of a polymesh object, or an empty list if it has none."""
5772 pairs = context_wrapper.getPolymeshObjectVertexUVWrapper(self.context, objID)
5773 return [vec2(u, v) for u, v in pairs]
5774
5775 def doesPolymeshObjectHaveVertexNormals(self, objID: int) -> bool:
5776 """Return True if a polymesh object carries per-vertex normals."""
5778 return context_wrapper.doesPolymeshObjectHaveVertexNormalsWrapper(self.context, objID)
5779
5780 def getPolymeshObjectVertexNormalSource(self, objID: int) -> VertexNormalSource:
5781 """
5782 Return where a polymesh object's vertex normals came from.
5783
5784 ``AUTHORED`` means they were read from the source file (OBJ ``vn`` records, or
5785 PLY ``nx``/``ny``/``nz`` properties); ``COMPUTED`` means they were generated,
5786 either by the file loader because the file supplied none (helios-core v1.3.85+)
5787 or by :meth:`computePolymeshObjectVertexNormals`; ``NONE`` means the mesh has no
5788 vertex normals, which only arises for a mesh assembled programmatically through
5789 :meth:`setPolymeshObjectTopology`.
5790 """
5792 return VertexNormalSource(
5793 context_wrapper.getPolymeshObjectVertexNormalSourceWrapper(self.context, objID)
5794 )
5795
5796 def getPolymeshObjectVertexCount(self, objID: int) -> int:
5797 """Return the number of shared vertices in a polymesh object."""
5799 return context_wrapper.getPolymeshObjectVertexCountWrapper(self.context, objID)
5800
5801 def getPolymeshObjectFaceCount(self, objID: int) -> int:
5802 """Return the number of faces in a polymesh object."""
5804 return context_wrapper.getPolymeshObjectFaceCountWrapper(self.context, objID)
5805
5806 def getPolymeshObjectFaceIndexForPrimitive(self, objID: int, uuid: int) -> int:
5807 """Return the index into :meth:`getPolymeshObjectFaces` of the face made up by a member primitive."""
5809 return context_wrapper.getPolymeshObjectFaceIndexForPrimitiveWrapper(self.context, objID, uuid)
5810
5811 def getPolymeshObjectPrimitiveUUIDForFace(self, objID: int, face_index: int) -> int:
5812 """Return the UUID of the primitive making up a given face of a polymesh object."""
5814 return context_wrapper.getPolymeshObjectPrimitiveUUIDForFaceWrapper(self.context, objID, face_index)
5815
5816 def computePolymeshObjectVertexNormals(self, objID: int, crease_angle_degrees: float = 30.0) -> None:
5817 """
5818 Compute per-vertex normals for a polymesh object by area-weighted averaging.
5820 Vertices are split across edges whose dihedral angle exceeds
5821 ``crease_angle_degrees``, so hard edges stay hard rather than being smoothed
5822 away. The resulting normals are reported as
5823 :attr:`VertexNormalSource.COMPUTED`.
5825 The file loaders call this automatically for a mesh whose source file supplied
5826 no normals (helios-core v1.3.85+), so it is only needed for a mesh assembled
5827 programmatically through :meth:`setPolymeshObjectTopology`, or to regenerate
5828 normals at a different crease angle or after the mesh has been deformed with
5829 :meth:`setPolymeshObjectVertices`.
5830
5831 Args:
5832 objID: Object ID of the polymesh object
5833 crease_angle_degrees: Dihedral angle above which an edge is kept hard
5834 """
5836 context_wrapper.computePolymeshObjectVertexNormalsWrapper(
5837 self.context, objID, float(crease_angle_degrees)
5838 )
5839
5840 def getPolymeshObjectBoundaryEdges(self, objID: int) -> List[int2]:
5841 """
5842 Return the boundary edges of a polymesh object as vertex index pairs.
5843
5844 A boundary edge is one referenced by exactly one face. An empty list means the
5845 mesh is closed.
5846 """
5848 pairs = context_wrapper.getPolymeshObjectBoundaryEdgesWrapper(self.context, objID)
5849 return [int2(a, b) for a, b in pairs]
5850
5851 def getPolymeshObjectConnectedComponents(self, objID: int) -> List[List[int]]:
5852 """
5853 Return the connected components of a polymesh object.
5854
5855 Each component is a list of face indices into :meth:`getPolymeshObjectFaces`.
5856 More than one component means the mesh is made of separate disjoint pieces.
5857 """
5859 return context_wrapper.getPolymeshObjectConnectedComponentsWrapper(self.context, objID)
5860
5861 def setPolymeshObjectTopology(self, objID: int, vertices: List[vec3], faces: List[int3],
5862 face_UUIDs: List[int],
5863 vertex_normals: Optional[List[vec3]] = None,
5864 vertex_uv: Optional[List[vec2]] = None,
5865 normal_source: VertexNormalSource = VertexNormalSource.NONE) -> None:
5866 """
5867 Attach an indexed face set to a polymesh object built programmatically.
5868
5869 Meshes loaded from an OBJ or PLY file retain the connectivity of the source file
5870 automatically. This is for a mesh assembled with :meth:`addPolymeshObject`, which
5871 otherwise has no topology and behaves as a triangle soup: its face count is zero,
5872 it cannot report a volume, and it is written out as independent per-triangle
5873 vertices.
5875 Args:
5876 objID: Object ID of the polymesh object
5877 vertices: Shared vertex positions in global Cartesian coordinates
5878 faces: Vertex index triples defining each face
5879 face_UUIDs: UUID of the primitive corresponding to each face, parallel to ``faces``
5880 vertex_normals: Per-vertex normals, or None if the mesh has none
5881 vertex_uv: Per-vertex texture coordinates, or None if the mesh has none
5882 normal_source: Provenance of the supplied vertex normals
5883
5884 Raises:
5885 ValueError: If an argument has the wrong type or ``face_UUIDs`` is not
5886 parallel to ``faces``
5887 """
5889 for i, v in enumerate(vertices):
5890 if not isinstance(v, vec3):
5891 raise ValueError(f"vertices[{i}] must be a vec3, got {type(v).__name__}")
5892 for i, f in enumerate(faces):
5893 if not isinstance(f, int3):
5894 raise ValueError(f"faces[{i}] must be an int3, got {type(f).__name__}")
5895 if vertex_normals is not None:
5896 for i, v in enumerate(vertex_normals):
5897 if not isinstance(v, vec3):
5898 raise ValueError(f"vertex_normals[{i}] must be a vec3, got {type(v).__name__}")
5899 if vertex_uv is not None:
5900 for i, v in enumerate(vertex_uv):
5901 if not isinstance(v, vec2):
5902 raise ValueError(f"vertex_uv[{i}] must be a vec2, got {type(v).__name__}")
5903 if len(face_UUIDs) != len(faces):
5904 raise ValueError(
5905 f"face_UUIDs must be parallel to faces: got {len(face_UUIDs)} UUIDs for {len(faces)} faces"
5906 )
5907
5908 context_wrapper.setPolymeshObjectTopologyWrapper(
5909 self.context, objID,
5910 [(v.x, v.y, v.z) for v in vertices],
5911 [(f.x, f.y, f.z) for f in faces],
5912 list(face_UUIDs),
5913 [(v.x, v.y, v.z) for v in vertex_normals] if vertex_normals else [],
5914 [(v.x, v.y) for v in vertex_uv] if vertex_uv else [],
5915 int(normal_source),
5916 )
5917
5918 def doesObjectHaveAnalyticVertexNormals(self, objID: int) -> bool:
5919 """
5920 Return True if a compound object can report analytic vertex normals.
5921
5922 True for Sphere, Tube and Cone objects, which approximate a curved shape and can
5923 evaluate its true surface normal. False for object types built from genuinely
5924 flat faces, such as a tile or box.
5925 """
5927 return context_wrapper.doesObjectHaveAnalyticVertexNormalsWrapper(self.context, objID)
5928
5929 def getObjectPrimitiveVertexNormals(self, objID: int,
5930 uuid: Union[int, List[int]]) -> Union[List[vec3], List[List[vec3]]]:
5931 """
5932 Return the analytic surface normals at each vertex of a member primitive.
5933
5934 Normals are evaluated from the object's own shape definition rather than stored,
5935 so they account for taper and stay correct after the object is transformed or its
5936 nodes and radii change. Returns an empty list for an object with no analytic
5937 normals (see :meth:`doesObjectHaveAnalyticVertexNormals`).
5938
5939 Args:
5940 objID: Object ID of the compound object
5941 uuid: UUID of a member primitive, or a list of them
5943 Returns:
5944 A list of vec3 for a single UUID, or a list of such lists for a list of UUIDs
5945 """
5947 if isinstance(uuid, (list, tuple)):
5948 batches = context_wrapper.getObjectPrimitiveVertexNormalsBatchWrapper(
5949 self.context, objID, list(uuid)
5950 )
5951 return [[vec3(x, y, z) for x, y, z in b] for b in batches]
5952 triples = context_wrapper.getObjectPrimitiveVertexNormalsWrapper(self.context, objID, uuid)
5953 return [vec3(x, y, z) for x, y, z in triples]
5954
5955 def getMaterialIDFromLabel(self, material_label: str) -> int:
5956 """Look up a material ID from its human-readable label."""
5958 return context_wrapper.getMaterialIDFromLabelWrapper(self.context, material_label)
5959
5960 def getPrimitiveMaterialID(self, uuid: int) -> int:
5961 """Return the material ID assigned to the given primitive."""
5963 return context_wrapper.getPrimitiveMaterialIDWrapper(self.context, uuid)
5964
5965 def getGlobalDataVersion(self, label: str) -> int:
5966 """Return the version counter for a global data entry. Increments on each update;
5967 useful for cache invalidation."""
5969 return context_wrapper.getGlobalDataVersionWrapper(self.context, label)
5970
5971 def getPrimitiveParentObjectID(self, uuid: int) -> int:
5972 """Return the ID of the compound object the primitive belongs to.
5974 Returns 0 if the primitive is not part of any compound object (the documented
5975 "no parent" sentinel). Raises ``HeliosRuntimeError`` if ``uuid`` does not exist.
5976 """
5978 return context_wrapper.getPrimitiveParentObjectIDWrapper(self.context, uuid)
5979
5980 # ---- String / list-of-string getters ----
5981
5982 def getObjectTextureFile(self, objID: int) -> str:
5983 """Return the filesystem path of the texture assigned to the object, or an
5984 empty string if no texture is assigned."""
5986 return context_wrapper.getObjectTextureFileWrapper(self.context, objID)
5987
5988 def listAllPrimitiveDataLabels(self) -> List[str]:
5989 """Return the union of all primitive-data labels used across every primitive
5990 in the context."""
5992 return context_wrapper.listAllPrimitiveDataLabelsWrapper(self.context)
5994 def getLoadedXMLFiles(self) -> List[str]:
5995 """Return the list of XML file paths that have been loaded into this context."""
5997 return context_wrapper.getLoadedXMLFilesWrapper(self.context)
5998
5999 # ---- Simple actions ----
6000
6001 def printObjectInfo(self, objID: int) -> None:
6002 """Print summary info for the object to stdout (for debugging)."""
6004 context_wrapper.printObjectInfoWrapper(self.context, objID)
6005
6006 def printPrimitiveInfo(self, uuid: int) -> None:
6007 """Print summary info for the primitive to stdout (for debugging)."""
6009 context_wrapper.printPrimitiveInfoWrapper(self.context, uuid)
6010
6011 def enablePrimitiveDataValueCaching(self, label: str) -> None:
6012 """Enable value caching for the given primitive-data label. Required before
6013 using getUniquePrimitiveDataValues for that label."""
6015 context_wrapper.enablePrimitiveDataValueCachingWrapper(self.context, label)
6016
6017 def disablePrimitiveDataValueCaching(self, label: str) -> None:
6018 """Disable value caching for the given primitive-data label."""
6020 context_wrapper.disablePrimitiveDataValueCachingWrapper(self.context, label)
6021
6022 def enableObjectDataValueCaching(self, label: str) -> None:
6023 """Enable value caching for the given object-data label. Required before
6024 using getUniqueObjectDataValues for that label."""
6026 context_wrapper.enableObjectDataValueCachingWrapper(self.context, label)
6027
6028 def disableObjectDataValueCaching(self, label: str) -> None:
6029 """Disable value caching for the given object-data label."""
6031 context_wrapper.disableObjectDataValueCachingWrapper(self.context, label)
6032
6033 def setObjectDataFromPrimitiveDataMean(self, objID: int, label: str) -> None:
6034 """Compute the mean of the given primitive-data label across the object's
6035 primitives and store it as object data on the object itself under the
6036 same label."""
6038 context_wrapper.setObjectDataFromPrimitiveDataMeanWrapper(self.context, objID, label)
6039
6040 def renameMaterial(self, old_label: str, new_label: str) -> None:
6041 """Rename an existing material."""
6043 context_wrapper.renameMaterialWrapper(self.context, old_label, new_label)
6044
6045 def renamePrimitiveData(self, uuid: int, old_label: str, new_label: str) -> None:
6046 """Rename a primitive-data label on a single primitive."""
6048 context_wrapper.renamePrimitiveDataWrapper(self.context, uuid, old_label, new_label)
6049
6050 def clearMaterialData(self, material_label: str, data_label: str) -> None:
6051 """Clear the named data entry on the given material."""
6053 context_wrapper.clearMaterialDataWrapper(self.context, material_label, data_label)
6054
6055 # =========================================================================
6056 # Vector-return getters & geometry mutators
6057 # =========================================================================
6059 # ---- Vector<uint> queries ----
6060
6061 def getDeletedUUIDs(self) -> List[int]:
6062 """Return the list of UUIDs that have been deleted from the context.
6064 These UUIDs are tombstoned and will not appear in getAllUUIDs(), but their
6065 IDs are tracked so they can be excluded from external references.
6066 """
6068 return context_wrapper.getDeletedUUIDsWrapper(self.context)
6069
6070 def getDirtyUUIDs(self, include_deleted: bool = True) -> List[int]:
6071 """Return the list of UUIDs whose geometry has been modified since the last
6072 markGeometryClean call.
6073
6074 Args:
6075 include_deleted: If True (default), include UUIDs that were deleted while
6076 dirty. If False, only return UUIDs that still exist.
6077 """
6079 return context_wrapper.getDirtyUUIDsWrapper(self.context, include_deleted)
6080
6081 def getUniquePrimitiveParentObjectIDs(self, uuids: List[int],
6082 include_zero: bool = True) -> List[int]:
6083 """Return the unique set of compound-object IDs that the given primitives
6084 belong to.
6085
6086 Args:
6087 uuids: List of primitive UUIDs to inspect.
6088 include_zero: If True (default), include the sentinel object ID 0
6089 (i.e., primitives with no parent object). If False, only return
6090 IDs of real compound objects.
6091 """
6093 if not isinstance(uuids, (list, tuple)):
6094 raise ValueError(f"uuids must be a list or tuple, got {type(uuids).__name__}")
6095 return context_wrapper.getUniquePrimitiveParentObjectIDsWrapper(
6096 self.context, list(uuids), include_zero
6097 )
6098
6099 # ---- Object normal / origin ----
6100
6101 def getObjectAverageNormal(self, objID: int) -> vec3:
6102 """Return the area-weighted average normal of all primitives in the object."""
6104 x, y, z = context_wrapper.getObjectAverageNormalWrapper(self.context, objID)
6105 return vec3(x, y, z)
6106
6107 def setObjectAverageNormal(self, objID: int, origin: vec3, new_normal: vec3) -> None:
6108 """Rotate the object so its area-weighted average normal aligns with
6109 new_normal. The rotation is applied about the given origin point."""
6111 if not isinstance(origin, vec3):
6112 raise ValueError(f"origin must be a vec3, got {type(origin).__name__}")
6113 if not isinstance(new_normal, vec3):
6114 raise ValueError(f"new_normal must be a vec3, got {type(new_normal).__name__}")
6115 context_wrapper.setObjectAverageNormalWrapper(
6116 self.context, objID, origin.to_list(), new_normal.to_list()
6117 )
6118
6119 def setObjectOrigin(self, objID: int, origin: vec3) -> None:
6120 """Translate the object so its origin is moved to the given point."""
6122 if not isinstance(origin, vec3):
6123 raise ValueError(f"origin must be a vec3, got {type(origin).__name__}")
6124 context_wrapper.setObjectOriginWrapper(self.context, objID, origin.to_list())
6125
6126 # ---- Primitive azimuth / elevation ----
6127
6128 def setPrimitiveAzimuth(self, uuid: int, origin: vec3, new_azimuth: float) -> None:
6129 """Rotate a single primitive about the given origin so its azimuth
6130 equals new_azimuth (radians)."""
6132 if not isinstance(origin, vec3):
6133 raise ValueError(f"origin must be a vec3, got {type(origin).__name__}")
6134 context_wrapper.setPrimitiveAzimuthWrapper(
6135 self.context, uuid, origin.to_list(), float(new_azimuth)
6136 )
6138 def setPrimitiveElevation(self, uuid: int, origin: vec3, new_elevation: float) -> None:
6139 """Rotate a single primitive about the given origin so its elevation
6140 equals new_elevation (radians)."""
6142 if not isinstance(origin, vec3):
6143 raise ValueError(f"origin must be a vec3, got {type(origin).__name__}")
6144 context_wrapper.setPrimitiveElevationWrapper(
6145 self.context, uuid, origin.to_list(), float(new_elevation)
6146 )
6148 # ---- Geometry mutators ----
6149
6150 def setTriangleVertices(self, uuid: int, vertex0: vec3, vertex1: vec3, vertex2: vec3) -> None:
6151 """Replace the three vertices of an existing triangle primitive."""
6153 for name, v in (("vertex0", vertex0), ("vertex1", vertex1), ("vertex2", vertex2)):
6154 if not isinstance(v, vec3):
6155 raise ValueError(f"{name} must be a vec3, got {type(v).__name__}")
6156 context_wrapper.setTriangleVerticesWrapper(
6157 self.context, uuid, vertex0.to_list(), vertex1.to_list(), vertex2.to_list()
6158 )
6159
6160 def setPrimitiveNormal(self, uuids_or_uuid, origin: vec3, new_normal: vec3) -> None:
6161 """Rotate one or more primitives so their normals align with new_normal.
6162
6163 Accepts either a single UUID (int) or a list/tuple of UUIDs.
6164 The rotation is applied about the given origin point.
6165 """
6167 if not isinstance(origin, vec3):
6168 raise ValueError(f"origin must be a vec3, got {type(origin).__name__}")
6169 if not isinstance(new_normal, vec3):
6170 raise ValueError(f"new_normal must be a vec3, got {type(new_normal).__name__}")
6171 if isinstance(uuids_or_uuid, (list, tuple)):
6172 context_wrapper.setPrimitiveNormalBatchWrapper(
6173 self.context, list(uuids_or_uuid), origin.to_list(), new_normal.to_list()
6174 )
6175 else:
6176 context_wrapper.setPrimitiveNormalWrapper(
6177 self.context, uuids_or_uuid, origin.to_list(), new_normal.to_list()
6178 )
6179
6180 def setPrimitiveParentObjectID(self, uuids_or_uuid, objID: int) -> None:
6181 """Reassign one or more primitives to belong to the given compound object.
6183 Accepts either a single UUID (int) or a list/tuple of UUIDs. Pass objID=0
6184 to detach primitive(s) from any object.
6185 """
6187 if isinstance(uuids_or_uuid, (list, tuple)):
6188 context_wrapper.setPrimitiveParentObjectIDBatchWrapper(
6189 self.context, list(uuids_or_uuid), int(objID)
6190 )
6191 else:
6192 context_wrapper.setPrimitiveParentObjectIDWrapper(
6193 self.context, int(uuids_or_uuid), int(objID)
6194 )
6195
6196 # =========================================================================
6197 # Material data API + unique data values
6198 # =========================================================================
6199
6200 # ---- Per-type explicit setMaterialData* methods ----
6201 # These mirror the existing setPrimitiveData<Type> family for parity.
6203 def setMaterialDataInt(self, material_label: str, data_label: str, value: int) -> None:
6204 """Set int data on a material. Affects all primitives that reference it."""
6206 context_wrapper.setMaterialDataIntWrapper(self.context, material_label, data_label, int(value))
6207
6208 def setMaterialDataUInt(self, material_label: str, data_label: str, value: int) -> None:
6209 """Set unsigned int data on a material."""
6211 context_wrapper.setMaterialDataUIntWrapper(self.context, material_label, data_label, int(value))
6212
6213 def setMaterialDataFloat(self, material_label: str, data_label: str, value: float) -> None:
6214 """Set float data on a material."""
6216 context_wrapper.setMaterialDataFloatWrapper(self.context, material_label, data_label, float(value))
6217
6218 def setMaterialDataDouble(self, material_label: str, data_label: str, value: float) -> None:
6219 """Set double-precision float data on a material."""
6221 context_wrapper.setMaterialDataDoubleWrapper(self.context, material_label, data_label, float(value))
6222
6223 def setMaterialDataString(self, material_label: str, data_label: str, value: str) -> None:
6224 """Set string data on a material."""
6226 context_wrapper.setMaterialDataStringWrapper(self.context, material_label, data_label, str(value))
6227
6228 def setMaterialDataVec2(self, material_label: str, data_label: str, value: vec2) -> None:
6229 """Set vec2 data on a material."""
6231 if not isinstance(value, vec2):
6232 raise ValueError(f"value must be a vec2, got {type(value).__name__}")
6233 context_wrapper.setMaterialDataVec2Wrapper(self.context, material_label, data_label, value.x, value.y)
6234
6235 def setMaterialDataVec3(self, material_label: str, data_label: str, value: vec3) -> None:
6236 """Set vec3 data on a material."""
6238 if not isinstance(value, vec3):
6239 raise ValueError(f"value must be a vec3, got {type(value).__name__}")
6240 context_wrapper.setMaterialDataVec3Wrapper(self.context, material_label, data_label, value.x, value.y, value.z)
6242 def setMaterialDataVec4(self, material_label: str, data_label: str, value: vec4) -> None:
6243 """Set vec4 data on a material."""
6245 if not isinstance(value, vec4):
6246 raise ValueError(f"value must be a vec4, got {type(value).__name__}")
6247 context_wrapper.setMaterialDataVec4Wrapper(self.context, material_label, data_label, value.x, value.y, value.z, value.w)
6248
6249 def setMaterialDataInt2(self, material_label: str, data_label: str, value: int2) -> None:
6250 """Set int2 data on a material."""
6252 if not isinstance(value, int2):
6253 raise ValueError(f"value must be an int2, got {type(value).__name__}")
6254 context_wrapper.setMaterialDataInt2Wrapper(self.context, material_label, data_label, value.x, value.y)
6255
6256 def setMaterialDataInt3(self, material_label: str, data_label: str, value: int3) -> None:
6257 """Set int3 data on a material."""
6259 if not isinstance(value, int3):
6260 raise ValueError(f"value must be an int3, got {type(value).__name__}")
6261 context_wrapper.setMaterialDataInt3Wrapper(self.context, material_label, data_label, value.x, value.y, value.z)
6262
6263 def setMaterialDataInt4(self, material_label: str, data_label: str, value: int4) -> None:
6264 """Set int4 data on a material."""
6266 if not isinstance(value, int4):
6267 raise ValueError(f"value must be an int4, got {type(value).__name__}")
6268 context_wrapper.setMaterialDataInt4Wrapper(self.context, material_label, data_label, value.x, value.y, value.z, value.w)
6269
6270 # ---- Per-type explicit getMaterialData* methods ----
6271
6272 def getMaterialDataInt(self, material_label: str, data_label: str) -> int:
6274 return context_wrapper.getMaterialDataIntWrapper(self.context, material_label, data_label)
6275
6276 def getMaterialDataUInt(self, material_label: str, data_label: str) -> int:
6278 return context_wrapper.getMaterialDataUIntWrapper(self.context, material_label, data_label)
6279
6280 def getMaterialDataFloat(self, material_label: str, data_label: str) -> float:
6282 return context_wrapper.getMaterialDataFloatWrapper(self.context, material_label, data_label)
6283
6284 def getMaterialDataDouble(self, material_label: str, data_label: str) -> float:
6286 return context_wrapper.getMaterialDataDoubleWrapper(self.context, material_label, data_label)
6287
6288 def getMaterialDataString(self, material_label: str, data_label: str) -> str:
6290 return context_wrapper.getMaterialDataStringWrapper(self.context, material_label, data_label)
6291
6292 def getMaterialDataVec2(self, material_label: str, data_label: str) -> vec2:
6294 x, y = context_wrapper.getMaterialDataVec2Wrapper(self.context, material_label, data_label)
6295 return vec2(x, y)
6296
6297 def getMaterialDataVec3(self, material_label: str, data_label: str) -> vec3:
6299 x, y, z = context_wrapper.getMaterialDataVec3Wrapper(self.context, material_label, data_label)
6300 return vec3(x, y, z)
6302 def getMaterialDataVec4(self, material_label: str, data_label: str) -> vec4:
6304 x, y, z, w = context_wrapper.getMaterialDataVec4Wrapper(self.context, material_label, data_label)
6305 return vec4(x, y, z, w)
6306
6307 def getMaterialDataInt2(self, material_label: str, data_label: str) -> int2:
6309 x, y = context_wrapper.getMaterialDataInt2Wrapper(self.context, material_label, data_label)
6310 return int2(x, y)
6311
6312 def getMaterialDataInt3(self, material_label: str, data_label: str) -> int3:
6314 x, y, z = context_wrapper.getMaterialDataInt3Wrapper(self.context, material_label, data_label)
6315 return int3(x, y, z)
6316
6317 def getMaterialDataInt4(self, material_label: str, data_label: str) -> int4:
6319 x, y, z, w = context_wrapper.getMaterialDataInt4Wrapper(self.context, material_label, data_label)
6320 return int4(x, y, z, w)
6321
6322 def getMaterialDataType(self, material_label: str, data_label: str) -> int:
6323 """Return the HeliosDataType enum value for the given material data entry.
6325 Encoding (from Helios core): 0=INT, 1=UINT, 2=FLOAT, 3=DOUBLE,
6326 4=VEC2, 5=VEC3, 6=VEC4, 7=INT2, 8=INT3, 9=INT4, 10=STRING.
6327 """
6329 return context_wrapper.getMaterialDataTypeWrapper(self.context, material_label, data_label)
6330
6331 # ---- Unified dispatch setMaterialData / getMaterialData ----
6332
6333 def setMaterialData(self, material_label: str, data_label: str, value) -> None:
6334 """Set material data with type detection from the Python value.
6335
6336 Dispatches to the correct typed setter based on ``isinstance`` of ``value``.
6337 For unambiguous numeric width control (e.g., uint vs int), call the
6338 per-type method directly (``setMaterialDataUInt``, etc.).
6339 """
6341 if isinstance(value, bool):
6342 # bool is a subclass of int in Python; route to int explicitly.
6343 context_wrapper.setMaterialDataIntWrapper(self.context, material_label, data_label, int(value))
6344 elif isinstance(value, int):
6345 context_wrapper.setMaterialDataIntWrapper(self.context, material_label, data_label, int(value))
6346 elif isinstance(value, float):
6347 context_wrapper.setMaterialDataFloatWrapper(self.context, material_label, data_label, float(value))
6348 elif isinstance(value, str):
6349 context_wrapper.setMaterialDataStringWrapper(self.context, material_label, data_label, value)
6350 elif isinstance(value, vec2):
6351 context_wrapper.setMaterialDataVec2Wrapper(self.context, material_label, data_label, value.x, value.y)
6352 elif isinstance(value, vec3):
6353 context_wrapper.setMaterialDataVec3Wrapper(self.context, material_label, data_label, value.x, value.y, value.z)
6354 elif isinstance(value, vec4):
6355 context_wrapper.setMaterialDataVec4Wrapper(self.context, material_label, data_label, value.x, value.y, value.z, value.w)
6356 elif isinstance(value, int2):
6357 context_wrapper.setMaterialDataInt2Wrapper(self.context, material_label, data_label, value.x, value.y)
6358 elif isinstance(value, int3):
6359 context_wrapper.setMaterialDataInt3Wrapper(self.context, material_label, data_label, value.x, value.y, value.z)
6360 elif isinstance(value, int4):
6361 context_wrapper.setMaterialDataInt4Wrapper(self.context, material_label, data_label, value.x, value.y, value.z, value.w)
6362 else:
6363 raise ValueError(
6364 f"Unsupported value type for setMaterialData: {type(value).__name__}. "
6365 f"Supported: int, float, str, vec2, vec3, vec4, int2, int3, int4. "
6366 f"For uint/double, call setMaterialDataUInt/Double directly."
6367 )
6368
6369 def getMaterialData(self, material_label: str, data_label: str, data_type: type = None):
6370 """Get material data, auto-detecting the type from Helios storage if not specified.
6371
6372 Args:
6373 material_label: Name of the material.
6374 data_label: Data entry label.
6375 data_type: Optional Python type (int, float, str, vec2, vec3, vec4, int2,
6376 int3, int4) or string ('uint', 'double'). If ``None``, the type is
6377 queried via getMaterialDataType and dispatched automatically.
6378 """
6380 if data_type is None:
6381 t = context_wrapper.getMaterialDataTypeWrapper(self.context, material_label, data_label)
6382 # Map HeliosDataType enum → typed call
6383 if t == 0:
6384 return context_wrapper.getMaterialDataIntWrapper(self.context, material_label, data_label)
6385 if t == 1:
6386 return context_wrapper.getMaterialDataUIntWrapper(self.context, material_label, data_label)
6387 if t == 2:
6388 return context_wrapper.getMaterialDataFloatWrapper(self.context, material_label, data_label)
6389 if t == 3:
6390 return context_wrapper.getMaterialDataDoubleWrapper(self.context, material_label, data_label)
6391 if t == 4:
6392 x, y = context_wrapper.getMaterialDataVec2Wrapper(self.context, material_label, data_label)
6393 return vec2(x, y)
6394 if t == 5:
6395 x, y, z = context_wrapper.getMaterialDataVec3Wrapper(self.context, material_label, data_label)
6396 return vec3(x, y, z)
6397 if t == 6:
6398 x, y, z, w = context_wrapper.getMaterialDataVec4Wrapper(self.context, material_label, data_label)
6399 return vec4(x, y, z, w)
6400 if t == 7:
6401 x, y = context_wrapper.getMaterialDataInt2Wrapper(self.context, material_label, data_label)
6402 return int2(x, y)
6403 if t == 8:
6404 x, y, z = context_wrapper.getMaterialDataInt3Wrapper(self.context, material_label, data_label)
6405 return int3(x, y, z)
6406 if t == 9:
6407 x, y, z, w = context_wrapper.getMaterialDataInt4Wrapper(self.context, material_label, data_label)
6408 return int4(x, y, z, w)
6409 if t == 10:
6410 return context_wrapper.getMaterialDataStringWrapper(self.context, material_label, data_label)
6411 raise ValueError(f"Unknown HeliosDataType code: {t}")
6412
6413 # Explicit type dispatch
6414 if data_type == int:
6415 return self.getMaterialDataInt(material_label, data_label)
6416 if data_type == float:
6417 return self.getMaterialDataFloat(material_label, data_label)
6418 if data_type == str:
6419 return self.getMaterialDataString(material_label, data_label)
6420 if data_type == "uint":
6421 return self.getMaterialDataUInt(material_label, data_label)
6422 if data_type == "double":
6423 return self.getMaterialDataDouble(material_label, data_label)
6424 if data_type == vec2:
6425 return self.getMaterialDataVec2(material_label, data_label)
6426 if data_type == vec3:
6427 return self.getMaterialDataVec3(material_label, data_label)
6428 if data_type == vec4:
6429 return self.getMaterialDataVec4(material_label, data_label)
6430 if data_type == int2:
6431 return self.getMaterialDataInt2(material_label, data_label)
6432 if data_type == int3:
6433 return self.getMaterialDataInt3(material_label, data_label)
6434 if data_type == int4:
6435 return self.getMaterialDataInt4(material_label, data_label)
6436 raise ValueError(
6437 f"Unsupported material data type: {data_type}. Supported: int, float, str, "
6438 f"vec2, vec3, vec4, int2, int3, int4, 'uint', 'double'."
6439 )
6440
6441 # ---- Unique data values ----
6442
6443 def getUniquePrimitiveDataValues(self, label: str, dtype: type) -> List:
6444 """Return the unique values stored under ``label`` across all primitives.
6445
6446 Requires value caching to be enabled for ``label`` first via
6447 ``enablePrimitiveDataValueCaching(label)``. Supported ``dtype`` values:
6448 ``int``, ``str``, or the string ``'uint'``.
6449 """
6451 if dtype == int:
6452 return context_wrapper.getUniquePrimitiveDataValuesIntWrapper(self.context, label)
6453 if dtype == "uint":
6454 return context_wrapper.getUniquePrimitiveDataValuesUIntWrapper(self.context, label)
6455 if dtype == str:
6456 return context_wrapper.getUniquePrimitiveDataValuesStringWrapper(self.context, label)
6457 raise ValueError(
6458 f"Unsupported dtype for getUniquePrimitiveDataValues: {dtype}. "
6459 f"Supported: int, str, 'uint'."
6460 )
6461
6462 def getUniqueObjectDataValues(self, label: str, dtype: type) -> List:
6463 """Return the unique values stored under ``label`` across all compound objects.
6464
6465 Requires value caching to be enabled for ``label`` first via
6466 ``enableObjectDataValueCaching(label)``. Supported ``dtype`` values:
6467 ``int``, ``str``, or the string ``'uint'``.
6468 """
6470 if dtype == int:
6471 return context_wrapper.getUniqueObjectDataValuesIntWrapper(self.context, label)
6472 if dtype == "uint":
6473 return context_wrapper.getUniqueObjectDataValuesUIntWrapper(self.context, label)
6474 if dtype == str:
6475 return context_wrapper.getUniqueObjectDataValuesStringWrapper(self.context, label)
6476 raise ValueError(
6477 f"Unsupported dtype for getUniqueObjectDataValues: {dtype}. "
6478 f"Supported: int, str, 'uint'."
6479 )
6480
6481 # =========================================================================
6482 # 4x4 transformation matrices + domain bounds
6483 # =========================================================================
6484
6485 @staticmethod
6486 def _marshal_mat4(value) -> List[float]:
6487 """Coerce a 4x4 transformation matrix input into a flat list of 16 floats.
6488
6489 Accepts: numpy.ndarray of shape (4,4) or (16,), list/tuple of 16 floats,
6490 or nested list/tuple of shape (4,4). Helios stores transformation matrices
6491 in **row-major** order: T[i*4 + j] = element (i, j). A numpy ndarray of
6492 shape (4,4) maps directly via .ravel() since numpy is row-major by default.
6493 """
6494 # numpy ndarray fast path
6495 if isinstance(value, np.ndarray):
6496 if value.shape == (4, 4):
6497 return [float(v) for v in value.ravel().tolist()]
6498 if value.shape == (16,):
6499 return [float(v) for v in value.tolist()]
6500 raise ValueError(
6501 f"Matrix ndarray must have shape (4,4) or (16,), got {value.shape}"
6502 )
6503 # Nested list/tuple of shape (4,4)
6504 if isinstance(value, (list, tuple)) and len(value) == 4 and \
6505 all(isinstance(row, (list, tuple)) and len(row) == 4 for row in value):
6506 flat = []
6507 for row in value:
6508 flat.extend(float(v) for v in row)
6509 return flat
6510 # Flat list/tuple of 16 floats
6511 if isinstance(value, (list, tuple)) and len(value) == 16:
6512 return [float(v) for v in value]
6513 raise ValueError(
6514 f"Matrix must be a (4,4) ndarray, (16,) ndarray, list of 16 floats, "
6515 f"or nested 4x4 list. Got: {type(value).__name__}"
6516 )
6517
6518 @staticmethod
6519 def _mat4_to_ndarray(flat: List[float]) -> 'np.ndarray':
6520 """Convert a flat list of 16 floats (row-major) to a (4,4) numpy ndarray."""
6521 return np.array(flat, dtype=np.float32).reshape((4, 4))
6522
6523 # ---- Transformation matrices ----
6524
6525 def getObjectTransformationMatrix(self, objID: int) -> 'np.ndarray':
6526 """Return the object's 4x4 transformation matrix as a (4,4) float32 ndarray.
6527
6528 Helios stores matrices in row-major order, so element (i, j) is at
6529 position [i, j] of the returned ndarray. The translation column is at
6530 positions [0, 3], [1, 3], [2, 3].
6531 """
6533 flat = context_wrapper.getObjectTransformationMatrixWrapper(self.context, int(objID))
6534 return self._mat4_to_ndarray(flat)
6535
6536 def setObjectTransformationMatrix(self, objIDs_or_objID, T) -> None:
6537 """Set the 4x4 transformation matrix on one or more compound objects.
6538
6539 Args:
6540 objIDs_or_objID: A single object ID (int) or a list/tuple of object IDs.
6541 T: A 4x4 matrix as numpy.ndarray((4,4) | (16,) float), list of 16 floats,
6542 or a nested 4x4 list. Row-major; T[i, j] is element (i, j).
6543 """
6545 flat = self._marshal_mat4(T)
6546 if isinstance(objIDs_or_objID, (list, tuple)):
6547 context_wrapper.setObjectTransformationMatrixBatchWrapper(
6548 self.context, list(objIDs_or_objID), flat
6549 )
6550 else:
6551 context_wrapper.setObjectTransformationMatrixWrapper(
6552 self.context, int(objIDs_or_objID), flat
6553 )
6554
6555 def getPrimitiveTransformationMatrix(self, uuid: int) -> 'np.ndarray':
6556 """Return the primitive's 4x4 transformation matrix as a (4,4) float32 ndarray
6557 (row-major; see getObjectTransformationMatrix for layout details)."""
6559 flat = context_wrapper.getPrimitiveTransformationMatrixWrapper(self.context, int(uuid))
6560 return self._mat4_to_ndarray(flat)
6561
6562 def setPrimitiveTransformationMatrix(self, uuids_or_uuid, T) -> None:
6563 """Set the 4x4 transformation matrix on one or more primitives.
6565 Args:
6566 uuids_or_uuid: A single UUID (int) or a list/tuple of UUIDs.
6567 T: A 4x4 matrix; see setObjectTransformationMatrix for accepted formats.
6568 """
6570 flat = self._marshal_mat4(T)
6571 if isinstance(uuids_or_uuid, (list, tuple)):
6572 context_wrapper.setPrimitiveTransformationMatrixBatchWrapper(
6573 self.context, list(uuids_or_uuid), flat
6574 )
6575 else:
6576 context_wrapper.setPrimitiveTransformationMatrixWrapper(
6577 self.context, int(uuids_or_uuid), flat
6579
6580 # ---- Domain bounds ----
6581
6582 def getDomainBoundingBox(self, uuids: Optional[List[int]] = None):
6583 """Return the axis-aligned bounding box of the domain (or a UUID subset).
6584
6585 Args:
6586 uuids: Optional list of primitive UUIDs to restrict the computation to.
6587 If None (default), uses every primitive in the context.
6588
6589 Returns:
6590 ``(xbounds, ybounds, zbounds)`` where each element is a ``vec2(min, max)``.
6591 """
6593 if uuids is None:
6594 xb, yb, zb = context_wrapper.getDomainBoundingBoxWrapper(self.context)
6595 else:
6596 if not isinstance(uuids, (list, tuple)):
6597 raise ValueError(f"uuids must be a list or tuple, got {type(uuids).__name__}")
6598 xb, yb, zb = context_wrapper.getDomainBoundingBoxFilteredWrapper(self.context, list(uuids))
6599 return (vec2(xb[0], xb[1]), vec2(yb[0], yb[1]), vec2(zb[0], zb[1]))
6600
6601 def getDomainBoundingSphere(self, uuids: Optional[List[int]] = None):
6602 """Return the bounding sphere of the domain (or a UUID subset).
6603
6604 Returns:
6605 ``(center, radius)`` where ``center`` is a ``vec3`` and ``radius`` is a float.
6606 """
6608 if uuids is None:
6609 center, radius = context_wrapper.getDomainBoundingSphereWrapper(self.context)
6610 else:
6611 if not isinstance(uuids, (list, tuple)):
6612 raise ValueError(f"uuids must be a list or tuple, got {type(uuids).__name__}")
6613 center, radius = context_wrapper.getDomainBoundingSphereFilteredWrapper(self.context, list(uuids))
6614 return (vec3(center[0], center[1], center[2]), float(radius))
6615
6616 # =========================================================================
6617 # Tube/polymesh + object color/dirty/tile mutators
6618 # =========================================================================
6619
6620 # ---- Tube object mutators ----
6621
6622 def setTubeNodes(self, objID: int, nodes: List[vec3]) -> None:
6623 """Replace the node positions of an existing tube object."""
6625 if not isinstance(nodes, (list, tuple)):
6626 raise ValueError(f"nodes must be a list or tuple, got {type(nodes).__name__}")
6627 flat = []
6628 for i, n in enumerate(nodes):
6629 if not isinstance(n, vec3):
6630 raise ValueError(f"nodes[{i}] must be a vec3, got {type(n).__name__}")
6631 flat.extend([n.x, n.y, n.z])
6632 context_wrapper.setTubeNodesWrapper(self.context, int(objID), flat)
6633
6634 def setTubeRadii(self, objID: int, radii: List[float]) -> None:
6635 """Replace the per-node radii of an existing tube object."""
6637 if not isinstance(radii, (list, tuple)):
6638 raise ValueError(f"radii must be a list or tuple, got {type(radii).__name__}")
6639 context_wrapper.setTubeRadiiWrapper(self.context, int(objID), [float(r) for r in radii])
6640
6641 def scaleTubeGirth(self, objID: int, scale_factor: float) -> None:
6642 """Scale the radii of an existing tube object by ``scale_factor``."""
6644 context_wrapper.scaleTubeGirthWrapper(self.context, int(objID), float(scale_factor))
6645
6646 def scaleTubeLength(self, objID: int, scale_factor: float) -> None:
6647 """Scale the lengths between tube nodes by ``scale_factor``."""
6649 context_wrapper.scaleTubeLengthWrapper(self.context, int(objID), float(scale_factor))
6650
6651 def pruneTubeNodes(self, objID: int, node_index: int) -> None:
6652 """Remove all tube nodes from index ``node_index`` to the end."""
6654 context_wrapper.pruneTubeNodesWrapper(self.context, int(objID), int(node_index))
6655
6656 def appendTubeSegment(self, objID: int, node_position: vec3, radius: float, *,
6657 color: Optional[RGBcolor] = None,
6658 texture_file: Optional[str] = None,
6659 uv: Optional[vec2] = None) -> None:
6660 """Append a new segment to an existing tube object.
6661
6662 Pass exactly one of ``color`` (an RGBcolor) or both ``texture_file`` and
6663 ``uv`` (a vec2 of texture u-fractions) to specify how the new segment
6664 should be shaded.
6665 """
6667 if not isinstance(node_position, vec3):
6668 raise ValueError(f"node_position must be a vec3, got {type(node_position).__name__}")
6669 has_color = color is not None
6670 has_texture = texture_file is not None or uv is not None
6671 if has_color == has_texture:
6672 raise ValueError(
6673 "appendTubeSegment requires exactly one of (color) or "
6674 "(texture_file and uv); cannot mix or omit both."
6675 )
6676 if has_color:
6677 if not isinstance(color, RGBcolor):
6678 raise ValueError(f"color must be an RGBcolor, got {type(color).__name__}")
6679 context_wrapper.appendTubeSegmentColorWrapper(
6680 self.context, int(objID), node_position.to_list(), float(radius),
6681 [color.r, color.g, color.b]
6682 )
6683 else:
6684 if texture_file is None or uv is None:
6685 raise ValueError(
6686 "appendTubeSegment with texture requires both texture_file and uv."
6687 )
6688 if not isinstance(uv, vec2):
6689 raise ValueError(f"uv must be a vec2, got {type(uv).__name__}")
6690 tex_path = self._validate_file_path(
6691 texture_file, ['.png', '.jpg', '.jpeg', '.tga', '.bmp']
6692 )
6693 context_wrapper.appendTubeSegmentTextureWrapper(
6694 self.context, int(objID), node_position.to_list(), float(radius),
6695 tex_path, [uv.x, uv.y]
6696 )
6697
6698 # ---- Polymesh object ----
6699
6700 def addPolymeshObject(self, uuids: List[int]) -> int:
6701 """Group the given primitives into a new polymesh compound object and return its ID."""
6703 if not isinstance(uuids, (list, tuple)):
6704 raise ValueError(f"uuids must be a list or tuple, got {type(uuids).__name__}")
6705 if len(uuids) == 0:
6706 raise ValueError("addPolymeshObject requires at least one UUID")
6707 return context_wrapper.addPolymeshObjectWrapper(self.context, list(uuids))
6708
6709 # ---- Object color ----
6710
6711 def setObjectColor(self, objIDs_or_objID, color) -> None:
6712 """Set the color of one or more compound objects.
6713
6714 Accepts a single object ID or list/tuple of IDs. ``color`` must be an
6715 ``RGBcolor`` or ``RGBAcolor``.
6716 """
6718 if isinstance(color, RGBAcolor):
6719 comps = [color.r, color.g, color.b, color.a]
6720 if isinstance(objIDs_or_objID, (list, tuple)):
6721 context_wrapper.setObjectColorRGBABatchWrapper(self.context, list(objIDs_or_objID), comps)
6722 else:
6723 context_wrapper.setObjectColorRGBAWrapper(self.context, int(objIDs_or_objID), comps)
6724 elif isinstance(color, RGBcolor):
6725 comps = [color.r, color.g, color.b]
6726 if isinstance(objIDs_or_objID, (list, tuple)):
6727 context_wrapper.setObjectColorRGBBatchWrapper(self.context, list(objIDs_or_objID), comps)
6728 else:
6729 context_wrapper.setObjectColorRGBWrapper(self.context, int(objIDs_or_objID), comps)
6730 else:
6731 raise ValueError(
6732 f"color must be an RGBcolor or RGBAcolor, got {type(color).__name__}"
6733 )
6734
6735 def overrideObjectTextureColor(self, objIDs_or_objID) -> None:
6736 """Override the texture mapping with the object's vertex color."""
6738 if isinstance(objIDs_or_objID, (list, tuple)):
6739 context_wrapper.overrideObjectTextureColorBatchWrapper(self.context, list(objIDs_or_objID))
6740 else:
6741 context_wrapper.overrideObjectTextureColorWrapper(self.context, int(objIDs_or_objID))
6742
6743 def useObjectTextureColor(self, objIDs_or_objID) -> None:
6744 """Restore use of the texture color (undoes overrideObjectTextureColor)."""
6746 if isinstance(objIDs_or_objID, (list, tuple)):
6747 context_wrapper.useObjectTextureColorBatchWrapper(self.context, list(objIDs_or_objID))
6748 else:
6749 context_wrapper.useObjectTextureColorWrapper(self.context, int(objIDs_or_objID))
6750
6751 # ---- Mark dirty/clean ----
6752
6753 def markPrimitiveDirty(self, uuids_or_uuid) -> None:
6754 """Mark one or more primitives as dirty (geometry has been modified)."""
6756 if isinstance(uuids_or_uuid, (list, tuple)):
6757 context_wrapper.markPrimitiveDirtyBatchWrapper(self.context, list(uuids_or_uuid))
6758 else:
6759 context_wrapper.markPrimitiveDirtyWrapper(self.context, int(uuids_or_uuid))
6760
6761 def markPrimitiveClean(self, uuids_or_uuid) -> None:
6762 """Mark one or more primitives as clean (cancels dirty state)."""
6764 if isinstance(uuids_or_uuid, (list, tuple)):
6765 context_wrapper.markPrimitiveCleanBatchWrapper(self.context, list(uuids_or_uuid))
6766 else:
6767 context_wrapper.markPrimitiveCleanWrapper(self.context, int(uuids_or_uuid))
6768
6769 # ---- Tile subdivision ----
6770
6771 def setTileObjectSubdivisionCount(self, objIDs_or_objID, subdiv: int2) -> None:
6772 """Set the (Nx, Ny) subdivision count of one or more tile objects.
6773
6774 The Helios C++ API is batch-only; a single objID is wrapped as a
6775 single-element list.
6776 """
6778 if not isinstance(subdiv, int2):
6779 raise ValueError(f"subdiv must be an int2, got {type(subdiv).__name__}")
6780 if isinstance(objIDs_or_objID, (list, tuple)):
6781 ids = list(objIDs_or_objID)
6782 else:
6783 ids = [int(objIDs_or_objID)]
6784 context_wrapper.setTileObjectSubdivisionCountWrapper(
6785 self.context, ids, int(subdiv.x), int(subdiv.y)
6786 )
6787
6788 def setTileObjectSubdivisionByAreaRatio(self, objIDs_or_objID, area_ratio: float) -> None:
6789 """Set tile object subdivision dynamically based on a target area ratio.
6790
6791 ``area_ratio`` is the approximate ratio between the whole tile's area and an
6792 individual sub-patch's area, so each tile is subdivided into roughly
6793 ``area_ratio`` sub-patches. It must be >= 1 (a sub-patch cannot be larger than
6794 the tile). The tile's position, size, and orientation are preserved.
6795 """
6797 if area_ratio < 1:
6798 raise ValueError(
6799 f"area_ratio must be >= 1 (it is the ratio of the whole tile area to an "
6800 f"individual sub-patch area), got {area_ratio}"
6801 )
6802 if isinstance(objIDs_or_objID, (list, tuple)):
6803 ids = list(objIDs_or_objID)
6804 else:
6805 ids = [int(objIDs_or_objID)]
6806 context_wrapper.setTileObjectSubdivisionByAreaRatioWrapper(
6807 self.context, ids, float(area_ratio)
6808 )
6809
6810 # =========================================================================
6811 # Cleanup, XML write, RNG, Location
6812 # =========================================================================
6813
6814 # ---- Cleanup ----
6815
6816 def cleanDeletedUUIDs(self, uuids: List[int]) -> List[int]:
6817 """Return a new list with deleted UUIDs removed; the input list is not mutated.
6818
6819 This mirrors the convention used by ``cropDomain``, which returns the
6820 survivors rather than mutating in place.
6821 """
6823 if not isinstance(uuids, (list, tuple)):
6824 raise ValueError(f"uuids must be a list or tuple, got {type(uuids).__name__}")
6825 return context_wrapper.cleanDeletedUUIDsWrapper(self.context, list(uuids))
6826
6827 def cleanDeletedObjectIDs(self, objIDs: List[int]) -> List[int]:
6828 """Return a new list with deleted object IDs removed; input is not mutated."""
6830 if not isinstance(objIDs, (list, tuple)):
6831 raise ValueError(f"objIDs must be a list or tuple, got {type(objIDs).__name__}")
6832 return context_wrapper.cleanDeletedObjectIDsWrapper(self.context, list(objIDs))
6833
6834 # ---- XML write ----
6835
6836 def writeXML(self, filename: str, uuids: Optional[List[int]] = None, quiet: bool = False) -> None:
6837 """Write the context (or a UUID subset) to an XML file.
6838
6839 Args:
6840 filename: Output file path. Must end in .xml.
6841 uuids: Optional list of primitive UUIDs to restrict the export. If
6842 None (default), all primitives are written.
6843 quiet: Suppress informational console output.
6844 """
6846 path = self._validate_output_file_path(filename, ['.xml'])
6847 if uuids is None:
6848 context_wrapper.writeXMLWrapper(self.context, path, bool(quiet))
6849 else:
6850 if not isinstance(uuids, (list, tuple)):
6851 raise ValueError(f"uuids must be a list or tuple, got {type(uuids).__name__}")
6852 context_wrapper.writeXMLFilteredWrapper(self.context, path, list(uuids), bool(quiet))
6853
6854 def writeXML_byobject(self, filename: str, objIDs: List[int], quiet: bool = False) -> None:
6855 """Write a subset of compound objects to an XML file."""
6857 path = self._validate_output_file_path(filename, ['.xml'])
6858 if not isinstance(objIDs, (list, tuple)):
6859 raise ValueError(f"objIDs must be a list or tuple, got {type(objIDs).__name__}")
6860 context_wrapper.writeXMLByObjectWrapper(self.context, path, list(objIDs), bool(quiet))
6861
6862 # ---- RNG ----
6863
6864 def randu(self, low=None, high=None):
6865 """Draw a uniform random number using the Context's RNG.
6866
6867 Three forms:
6868 ``randu()`` -> float in [0, 1)
6869 ``randu(low: float, high: float)`` -> float in [low, high)
6870 ``randu(low: int, high: int)`` -> int in [low, high]
6871
6872 Whether the integer or float overload is invoked is determined by
6873 ``isinstance(low, int)``; pass ``low/high`` as Python ints for the
6874 integer range form.
6875 """
6877 if low is None and high is None:
6878 return context_wrapper.randuBasicWrapper(self.context)
6879 if low is None or high is None:
6880 raise ValueError("randu requires both low and high, or neither.")
6881 if isinstance(low, bool) or isinstance(high, bool):
6882 raise ValueError("randu bounds cannot be bool.")
6883 # Treat the call as integer-range only when BOTH bounds are Python ints
6884 # (and not bools, handled above). Otherwise use the float form.
6885 if isinstance(low, int) and isinstance(high, int):
6886 return context_wrapper.randuIntRangeWrapper(self.context, low, high)
6887 return context_wrapper.randuRangeWrapper(self.context, float(low), float(high))
6888
6889 def randn(self, mean=None, stddev=None) -> float:
6890 """Draw a normal random number using the Context's RNG.
6891
6892 Two forms:
6893 ``randn()`` -> standard normal (mean 0, stddev 1)
6894 ``randn(mean: float, stddev: float)`` -> N(mean, stddev**2)
6895 """
6897 if mean is None and stddev is None:
6898 return context_wrapper.randnBasicWrapper(self.context)
6899 if mean is None or stddev is None:
6900 raise ValueError("randn requires both mean and stddev, or neither.")
6901 return context_wrapper.randnParamsWrapper(self.context, float(mean), float(stddev))
6902
6903 # ---- Location ----
6904
6905 def setLocation(self, location_or_lat, longitude=None, utc_offset=None, altitude=0.0) -> None:
6906 """Set the geographic location used by solar/radiation calculations.
6907
6908 Two call forms:
6909 ``setLocation(loc: Location)``
6910 ``setLocation(latitude_deg: float, longitude_deg: float, utc_offset: float, altitude=0.0)``
6911
6912 ``altitude`` is the height of the local Cartesian origin in meters above
6913 sea level. It is only used in the (lat, lon, utc) float form; when passing
6914 a ``Location`` object, the location's own altitude is used.
6915 """
6917 if isinstance(location_or_lat, Location):
6918 if longitude is not None or utc_offset is not None or altitude != 0.0:
6919 raise ValueError("When passing a Location, do not also pass longitude/utc_offset/altitude; "
6920 "set them on the Location object instead.")
6921 loc = location_or_lat
6922 else:
6923 if longitude is None or utc_offset is None:
6924 raise ValueError(
6925 "setLocation requires either a Location object or "
6926 "(latitude_deg, longitude_deg, utc_offset) as 3 floats."
6927 )
6928 loc = Location(float(location_or_lat), float(longitude), float(utc_offset), float(altitude))
6929 context_wrapper.setLocationWrapper(self.context, loc.latitude, loc.longitude, loc.utc_offset, loc.altitude)
6930
6931 def getLocation(self) -> Location:
6932 """Return the Context's currently-configured geographic location."""
6934 lat, lon, utc, alt = context_wrapper.getLocationWrapper(self.context)
6935 return Location(lat, lon, utc, alt)
6937 # =========================================================================
6938 # Colormap helpers + texture transparency
6939 # =========================================================================
6940
6941 def generateColormap(self, name: str, n_colors: int) -> List[RGBcolor]:
6942 """Generate a colormap with ``n_colors`` entries from a named colormap.
6943
6944 Args:
6945 name: Helios colormap name (e.g., "hot", "cool", "lava", "rainbow").
6946 n_colors: Number of colors in the returned ramp.
6947
6948 Returns:
6949 A list of ``RGBcolor`` instances of length ``n_colors``.
6950 """
6952 flat = context_wrapper.generateColormapNamedWrapper(self.context, name, int(n_colors))
6953 return [RGBcolor(flat[i*3 + 0], flat[i*3 + 1], flat[i*3 + 2]) for i in range(int(n_colors))]
6954
6955 def generateTexturesFromColormap(self, texture_file: str, colormap: List[RGBcolor]) -> List[str]:
6956 """Generate one texture file per color in ``colormap`` derived from
6957 ``texture_file``. Returns the list of generated file paths.
6958 """
6960 if not isinstance(colormap, (list, tuple)):
6961 raise ValueError(f"colormap must be a list or tuple, got {type(colormap).__name__}")
6962 flat = []
6963 for i, c in enumerate(colormap):
6964 if not isinstance(c, RGBcolor):
6965 raise ValueError(f"colormap[{i}] must be an RGBcolor, got {type(c).__name__}")
6966 flat.extend([c.r, c.g, c.b])
6967 # Validate the input texture exists and looks like an image.
6968 validated_path = self._validate_file_path(
6969 texture_file, ['.png', '.jpg', '.jpeg', '.tga', '.bmp']
6970 )
6971 return context_wrapper.generateTexturesFromColormapWrapper(
6972 self.context, validated_path, flat
6973 )
6974
6975 def getPrimitiveTextureTransparencyData(self, uuid: int) -> Optional['np.ndarray']:
6976 """Return the primitive's texture transparency mask as a 2D bool ndarray.
6977
6978 Returns None if the primitive has no associated transparency channel
6979 (e.g., it is untextured or its texture has no alpha). The returned
6980 ndarray has shape (height, width) and dtype ``bool``.
6981 """
6983 result = context_wrapper.getPrimitiveTextureTransparencyDataWrapper(self.context, int(uuid))
6984 if result is None:
6985 return None
6986 width, height, flat = result
6987 return np.array(flat, dtype=bool).reshape((height, width))
6988
6989
6990def check_context_alive(context: 'Context', owner_name: str) -> None:
6991 """Raise if `context`'s native Context has already been destroyed.
6992
6993 Plugin models pass ``context.getNativePtr()`` to a C++ constructor that
6994 stores the raw pointer for the lifetime of the model. Destroying the
6995 Context (via ``__exit__``, ``__del__``, or garbage collection) frees that
6996 memory without invalidating the model's copy, so any later call
6997 dereferences freed memory and segfaults.
6998
6999 Models must hold a Python reference to the owning Context (keeping it
7000 alive) and call this before every native call (turning an explicit close
7001 into an actionable error instead of a crash).
7003 Args:
7004 context: The Context the model was constructed from.
7005 owner_name: Class name of the calling model, used in the message.
7006
7007 Raises:
7008 RuntimeError: If the Context has been destroyed.
7009 """
7010 if context is None or getattr(context, 'context', None) is None:
7011 raise RuntimeError(
7012 f"{owner_name} is bound to a Context that has already been destroyed.\n"
7013 "The native Context was freed while this model still referenced it; "
7014 "continuing would dereference freed memory and crash the interpreter.\n"
7015 "\n"
7016 "This usually means the model outlived its Context's 'with' block:\n"
7017 " with Context() as context:\n"
7018 f" model = {owner_name}(context)\n"
7019 " model.run() # <-- Context already destroyed here\n"
7020 "\n"
7021 f"Fix: keep all {owner_name} usage inside the Context's 'with' block, "
7022 "or create the Context without a 'with' statement so it lives as long "
7023 "as the model."
7024 )
7025
7026
Central simulation environment for PyHelios that manages 3D primitives and their data.
Definition Context.py:87
getDomainBoundingSphere(self, Optional[List[int]] uuids=None)
Return the bounding sphere of the domain (or a UUID subset).
Definition Context.py:6627
None setGlobalDataVec3(self, str label, x_or_vec, float y=None, float z=None)
Set global data as vec3.
Definition Context.py:4739
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:1442
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:2306
int getTubeObjectNodeCount(self, int objID)
Definition Context.py:5285
vec3 getBoxObjectSize(self, int objID)
Definition Context.py:5251
None duplicateObjectData(self, int objID, str old_label, str new_label)
Copy object data to a new label.
Definition Context.py:4690
bool doesPolymeshObjectHaveVertexNormals(self, int objID)
Return True if a polymesh object carries per-vertex normals.
Definition Context.py:5793
str getMaterialTexture(self, str material_label)
Get the texture file path for a material.
Definition Context.py:4022
getMaterialColor(self, str material_label)
Get the RGBA color of a material.
Definition Context.py:3979
getObjectData(self, int objID, str label, type data_type=None)
Get object data with optional type specification.
Definition Context.py:4567
getAllPrimitiveVertices(self)
Get vertices for all primitives.
Definition Context.py:4351
None setObjectOrigin(self, int objID, vec3 origin)
Translate the object so its origin is moved to the given point.
Definition Context.py:6137
List[RGBcolor] getTubeObjectNodeColors(self, int objID)
Definition Context.py:5298
'np.ndarray' _mat4_to_ndarray(List[float] flat)
Convert a flat list of 16 floats (row-major) to a (4,4) numpy ndarray.
Definition Context.py:6541
Union[int, List[int]] copyObject(self, Union[int, List[int]] ObjID)
Copy one or more compound objects.
Definition Context.py:1903
List[str] listObjectData(self, int objID)
List all data labels on a specific object.
Definition Context.py:4682
None scaleTubeLength(self, int objID, float scale_factor)
Scale the lengths between tube nodes by scale_factor.
Definition Context.py:6668
int getMaterialTwosidedFlag(self, str material_label)
Get the two-sided rendering flag for a material (0 = one-sided, 1 = two-sided).
Definition Context.py:4049
Optional[List[int]] cropDomain(self, *args)
Crop the context domain to the given XYZ bounds.
Definition Context.py:5477
None setMaterialDataVec3(self, str material_label, str data_label, vec3 value)
Set vec3 data on a material.
Definition Context.py:6253
None markPrimitiveDirty(self, uuids_or_uuid)
Mark one or more primitives as dirty (geometry has been modified).
Definition Context.py:6775
None deletePrimitive(self, Union[int, List[int]] uuids_or_uuid)
Delete one or more primitives from the context.
Definition Context.py:3826
None setMaterialDataInt4(self, str material_label, str data_label, int4 value)
Set int4 data on a material.
Definition Context.py:6281
None clearAllPrimitiveData(self, str label)
Remove a named data field from every primitive in the Context.
Definition Context.py:5439
None setMaterialDataInt3(self, str material_label, str data_label, int3 value)
Set int3 data on a material.
Definition Context.py:6274
_validate_uuid(self, int uuid)
Validate that a UUID exists in this context.
Definition Context.py:186
int addAdaptiveTileObject(self, vec3 center=vec3(0, 0, 0), vec2 size=vec2(1, 1), SphericalCoord rotation=SphericalCoord(1, 0, 0), Optional[AdaptiveTileRefinement] refinement=None, Optional[RGBcolor] color=None, Optional[str] texturefile=None, Optional[int2] texture_repeat=None)
Add a patch subdivided into sub-patches whose size adapts with distance from a target point.
Definition Context.py:1542
getGlobalData(self, str label, type data_type=None)
Get global data with optional type specification.
Definition Context.py:4779
List[int] getAllUUIDs(self)
Definition Context.py:670
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:1073
addTimeseriesData(self, str label, float value, 'Date' date, 'Time' time)
Add a data point to a timeseries variable.
Definition Context.py:3401
int getPolymeshObjectVertexCount(self, int objID)
Return the number of shared vertices in a polymesh object.
Definition Context.py:5814
None setObjectColor(self, objIDs_or_objID, color)
Set the color of one or more compound objects.
Definition Context.py:6737
str _validate_output_file_path(self, str filename, List[str] expected_extensions=None)
Validate and normalize output file path for security.
Definition Context.py:299
bool primitiveTextureHasTransparencyChannel(self, int uuid)
Check if primitive texture has a transparency channel.
Definition Context.py:4265
getPrimitiveMaterialLabel(self, uuid)
Get the material label assigned to a primitive or multiple primitives.
Definition Context.py:4108
None enablePrimitiveDataValueCaching(self, str label)
Enable value caching for the given primitive-data label.
Definition Context.py:6030
int getPrimitiveTwosidedFlag(self, int uuid, int default_value=1)
Get two-sided rendering flag for a primitive.
Definition Context.py:4132
None cropDomainX(self, vec2 xbounds)
Definition Context.py:5450
List[str] getAllPrimitiveTextureFiles(self)
Get texture files for all primitives.
Definition Context.py:4355
None setObjectDataVec4(self, objids_or_objid, str label, x_or_vec, float y=None, float z=None, float w=None)
Set object data as vec4.
Definition Context.py:4511
getDomainBoundingBox(self, Optional[List[int]] uuids=None)
Return the axis-aligned bounding box of the domain (or a UUID subset).
Definition Context.py:6612
bool isPrimitiveHidden(self, int uuid)
Check if a primitive is hidden.
Definition Context.py:4394
np.ndarray getPrimitiveDataArray(self, List[int] uuids, str label)
Get primitive data values for multiple primitives as a NumPy array.
Definition Context.py:3205
float calculateAreaIndex(self, List[int] leaf_uuids, Optional[List[int]] wood_uuids=None, Optional[float] ground_area=None)
Calculate the one-sided area index on a ground-area basis.
Definition Context.py:4999
None setObjectAverageNormal(self, int objID, vec3 origin, vec3 new_normal)
Rotate the object so its area-weighted average normal aligns with new_normal.
Definition Context.py:6126
int2 getTileObjectTextureRepeat(self, int objID)
Get the texture repeat count requested when the tile object was created.
Definition Context.py:5137
List[int] getObjectPrimitiveUUIDs(self, objIDs)
Get flattened primitive UUIDs for one object, a list of objects, or a list-of-lists.
Definition Context.py:5082
None writeXML_byobject(self, str filename, List[int] objIDs, bool quiet=False)
Write a subset of compound objects to an XML file.
Definition Context.py:6876
bool is_plugin_available(self, str plugin_name)
Check if a specific plugin is available.
Definition Context.py:3893
getPrimitiveColor(self, uuid)
Get the color of a primitive or multiple primitives.
Definition Context.py:638
getPrimitiveArea(self, uuid)
Get the area of a primitive or multiple primitives.
Definition Context.py:570
str getGlobalDataString(self, str label)
Get string global data.
Definition Context.py:4822
vec3 getTileObjectNormal(self, int objID)
Definition Context.py:5113
None setPolymeshObjectTopology(self, int objID, List[vec3] vertices, List[int3] faces, List[int] face_UUIDs, Optional[List[vec3]] vertex_normals=None, Optional[List[vec2]] vertex_uv=None, VertexNormalSource normal_source=VertexNormalSource.NONE)
Attach an indexed face set to a polymesh object built programmatically.
Definition Context.py:5904
bool doesTimeseriesVariableExist(self, str label)
Check whether a timeseries variable exists.
Definition Context.py:3632
None setTubeNodes(self, int objID, List[vec3] nodes)
Replace the node positions of an existing tube object.
Definition Context.py:6644
List[int] getAllObjectIDs(self)
Definition Context.py:680
bool isPrimitiveDataValueCachingEnabled(self, str label)
Return True if value caching is enabled for the given primitive-data label.
Definition Context.py:5533
None setObjectDataUInt(self, objids_or_objid, str label, int value)
Set object data as unsigned 32-bit integer.
Definition Context.py:4443
deleteTimeseriesVariable(self, str label)
Delete a single timeseries variable and all of its data points.
Definition Context.py:3696
List[str] listAllObjectDataLabels(self)
List all object data labels in context.
Definition Context.py:4686
List[str] get_available_plugins(self)
Get list of available plugins for this PyHelios instance.
Definition Context.py:3881
__exit__(self, exc_type, exc_value, traceback)
Definition Context.py:332
int getPrimitiveParentObjectID(self, int uuid)
Return the ID of the compound object the primitive belongs to.
Definition Context.py:5993
None setPrimitiveNormal(self, uuids_or_uuid, vec3 origin, vec3 new_normal)
Rotate one or more primitives so their normals align with new_normal.
Definition Context.py:6182
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:3023
None setTileObjectSubdivisionCount(self, objIDs_or_objID, int2 subdiv)
Set the (Nx, Ny) subdivision count of one or more tile objects.
Definition Context.py:6797
None setPrimitiveParentObjectID(self, uuids_or_uuid, int objID)
Reassign one or more primitives to belong to the given compound object.
Definition Context.py:6202
None hidePrimitive(self, uuids_or_uuid)
Hide one or more primitives.
Definition Context.py:4369
None clearMaterialData(self, str material_label, str data_label)
Clear the named data entry on the given material.
Definition Context.py:6068
int getTimeseriesLength(self, str label)
Get the number of data points in a timeseries variable.
Definition Context.py:3608
None setTriangleVertices(self, int uuid, vec3 vertex0, vec3 vertex1, vec3 vertex2)
Replace the three vertices of an existing triangle primitive.
Definition Context.py:6168
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:1350
vec2 getAdaptiveTileObjectSubpatchSizeRange(self, int objID)
Get the sub-patch edge lengths actually achieved, as opposed to those requested.
Definition Context.py:5205
float getConeObjectVolume(self, int objID)
Definition Context.py:5343
setCurrentTimeseriesPoint(self, str label, int index)
Set the Context date and time from a timeseries data point index.
Definition Context.py:3465
calculatePrimitiveDataAreaWeightedMean(self, List[int] uuids, str label, type return_type=float)
Calculate area-weighted mean of primitive data.
Definition Context.py:4883
bool doesMaterialDataExist(self, str material_label, str data_label)
Return True if the named material has data stored under data_label.
Definition Context.py:5513
getTileObjectAreaRatio(self, objIDs)
Get tile-object area ratio for one or multiple tile objects.
Definition Context.py:5092
bool doesPrimitiveDataExist(self, int uuid, str label)
Check if primitive data exists for a specific primitive and label.
Definition Context.py:3122
None setMaterialDataVec2(self, str material_label, str data_label, vec2 value)
Set vec2 data on a material.
Definition Context.py:6246
int getObjectDataSize(self, int objID, str label)
Get the size of object data array.
Definition Context.py:4618
vec3 getAdaptiveTileObjectNormal(self, int objID)
Get a unit vector normal to an adaptive tile object surface.
Definition Context.py:5166
float getTubeObjectSegmentVolume(self, int objID, int segment_index)
Definition Context.py:5307
List[str] listMaterials(self)
Get list of all material labels in the context.
Definition Context.py:3950
None enableObjectDataValueCaching(self, str label)
Enable value caching for the given object-data label.
Definition Context.py:6041
None setMaterialDataInt2(self, str material_label, str data_label, int2 value)
Set int2 data on a material.
Definition Context.py:6267
List[int] cleanDeletedUUIDs(self, List[int] uuids)
Return a new list with deleted UUIDs removed; the input list is not mutated.
Definition Context.py:6842
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:2815
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:2871
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:2907
int getPrimitiveMaterialID(self, int uuid)
Return the material ID assigned to the given primitive.
Definition Context.py:5978
'np.ndarray' getAllPrimitiveColors(self)
Get colors for all primitives.
Definition Context.py:4335
int getPrimitiveCount(self)
Definition Context.py:650
vec3 getObjectAverageNormal(self, int objID)
Return the area-weighted average normal of all primitives in the object.
Definition Context.py:6119
Optional[ 'np.ndarray'] getPrimitiveTextureTransparencyData(self, int uuid)
Return the primitive's texture transparency mask as a 2D bool ndarray.
Definition Context.py:7002
None aggregatePrimitiveDataSum(self, List[int] uuids, List[str] labels, str result_label)
Sum multiple primitive data fields into a new field.
Definition Context.py:4953
List[str] listGlobalData(self)
List all global data labels.
Definition Context.py:4850
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:1698
int getAdaptiveTileObjectMaxRefinementLevel(self, int objID)
Get the maximum quadtree refinement level, i.e.
Definition Context.py:5195
List[PrimitiveInfo] getAllPrimitiveInfo(self)
Get physical properties and geometry information for all primitives in the context.
Definition Context.py:821
float getMaterialDataDouble(self, str material_label, str data_label)
Definition Context.py:6301
None incrementPrimitiveData(self, List[int] uuids, str label, increment, str data_type=None)
Increment primitive data for the given UUIDs.
Definition Context.py:4931
None copyObjectData(self, int source_objID, int destination_objID)
Copy all object data from source to destination compound object.
Definition Context.py:1931
'Time' queryTimeseriesTime(self, str label, int index)
Get the Time associated with a timeseries data point.
Definition Context.py:3553
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:2999
None showPrimitive(self, uuids_or_uuid)
Show one or more previously hidden primitives.
Definition Context.py:4380
int2 getAdaptiveTileObjectBaseSubdivisionCount(self, int objID)
Get the number of coarsest-level cells spanning an adaptive tile in x and y.
Definition Context.py:5189
None writePLY(self, str filename, Optional[List[int]] UUIDs=None)
Write geometry to a PLY (Stanford Polygon) file.
Definition Context.py:2463
List[int] filterPrimitivesByData(self, List[int] uuids, str label, value, str comparator="=")
Filter primitives by data value.
Definition Context.py:5023
List[PrimitiveInfo] getPrimitivesInfoForObject(self, int object_id)
Get physical properties and geometry information for all primitives belonging to a specific object.
Definition Context.py:833
int2 getTileObjectEffectiveTextureRepeat(self, int objID)
Get the texture repeat count actually applied to the sub-patches of a tile object.
Definition Context.py:5147
vec3 getSphereObjectCenter(self, int objID)
Definition Context.py:5222
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:2976
int getGlobalDataType(self, str label)
Get the HeliosDataType enum for global data.
Definition Context.py:4826
print_plugin_status(self)
Print detailed plugin status information.
Definition Context.py:3906
vec3 getTriangleVertex(self, int uuid, int number)
Definition Context.py:5359
float getSphereObjectVolume(self, int objID)
Definition Context.py:5241
List getUniquePrimitiveDataValues(self, str label, type dtype)
Return the unique values stored under label across all primitives.
Definition Context.py:6466
setDate(self, int year, int month, int day)
Set the simulation date.
Definition Context.py:3328
List[int] getDirtyUUIDs(self, bool include_deleted=True)
Return the list of UUIDs whose geometry has been modified since the last markGeometryClean call.
Definition Context.py:6094
bool objectHasTexture(self, int objID)
Return True if the compound object has a texture assigned.
Definition Context.py:5518
vec3 getAdaptiveTileObjectCenter(self, int objID)
Get the Cartesian coordinates of the center of an adaptive tile object.
Definition Context.py:5154
int2 getPrimitiveTextureSize(self, int uuid)
Get the texture size (width, height) of a primitive.
Definition Context.py:4230
vec3 getSphereObjectRadius(self, int objID)
Get per-axis radii of a sphere object.
Definition Context.py:5232
List[vec3] getTileObjectVertices(self, int objID)
Definition Context.py:5123
int getMaterialIDFromLabel(self, str material_label)
Look up a material ID from its human-readable label.
Definition Context.py:5973
getPrimitiveData(self, int uuid, str label, type data_type=None)
Get primitive data for a specific primitive.
Definition Context.py:3049
None setGlobalDataUInt(self, str label, int value)
Set global data as unsigned 32-bit integer.
Definition Context.py:4715
None setTileObjectSubdivisionByAreaRatio(self, objIDs_or_objID, float area_ratio)
Set tile object subdivision dynamically based on a target area ratio.
Definition Context.py:6816
List[float] getConeObjectNodeRadii(self, int objID)
Definition Context.py:5321
List[str] get_missing_plugins(self, List[str] requested_plugins)
Get list of requested plugins that are not available.
Definition Context.py:3918
None setMaterialData(self, str material_label, str data_label, value)
Set material data with type detection from the Python value.
Definition Context.py:6356
None showObject(self, objids_or_objid)
Show one or more previously hidden compound objects.
Definition Context.py:4413
List[vec3] getConeObjectNodes(self, int objID)
Definition Context.py:5316
None setObjectDataInt(self, objids_or_objid, str label, int value)
Set object data as signed 32-bit integer.
Definition Context.py:4433
int2 getAdaptiveTileObjectTextureRepeat(self, int objID)
Get the texture repeat count of an adaptive tile object.
Definition Context.py:5216
bool isGeometryDirty(self)
Definition Context.py:370
None printObjectInfo(self, int objID)
Print summary info for the object to stdout (for debugging).
Definition Context.py:6019
clearTimeseriesData(self)
Clear all timeseries data from the Context.
Definition Context.py:3670
vec3 getVoxelCenter(self, int uuid)
Definition Context.py:5364
None copyPrimitiveData(self, int sourceUUID, int destinationUUID)
Copy all primitive data from source to destination primitive.
Definition Context.py:1871
List[str] getAllPrimitiveMaterialLabels(self)
Get material labels for all primitives.
Definition Context.py:4359
None duplicateGlobalData(self, str old_label, str new_label)
Duplicate global data to a new label.
Definition Context.py:4846
List[vec2] getPolymeshObjectVertexUV(self, int objID)
Return the per-vertex texture coordinates of a polymesh object, or an empty list if it has none.
Definition Context.py:5787
vec4 getMaterialDataVec4(self, str material_label, str data_label)
Definition Context.py:6319
None setMaterialDataVec4(self, str material_label, str data_label, vec4 value)
Set vec4 data on a material.
Definition Context.py:6260
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:3285
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
Definition Context.py:339
'np.ndarray' getPrimitiveTransformationMatrix(self, int uuid)
Return the primitive's 4x4 transformation matrix as a (4,4) float32 ndarray (row-major; see getObject...
Definition Context.py:6578
bool doesObjectHaveAnalyticVertexNormals(self, int objID)
Return True if a compound object can report analytic vertex normals.
Definition Context.py:5942
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:867
vec3 getDiskObjectCenter(self, int objID)
Definition Context.py:5266
None renamePrimitiveData(self, int uuid, str old_label, str new_label)
Rename a primitive-data label on a single primitive.
Definition Context.py:6063
None incrementGlobalData(self, str label, increment)
Increment global data.
Definition Context.py:4854
getObjectBoundingBox(self, objIDs)
Get axis-aligned bounding box for one object or a list of objects.
Definition Context.py:5066
None setGlobalDataInt4(self, str label, x_or_vec, int y=None, int z=None, int w=None)
Set global data as int4.
Definition Context.py:4771
None setObjectDataVec3(self, objids_or_objid, str label, x_or_vec, float y=None, float z=None)
Set object data as vec3.
Definition Context.py:4497
_check_context_available(self)
Helper method to check if context is available with detailed error messages.
Definition Context.py:142
loadTabularTimeseriesData(self, str data_file, List[str] column_labels, str delimiter=",", str date_string_format="YYYYMMDD", int headerlines=0)
Load tabular timeseries data from a text file.
Definition Context.py:3781
None markPrimitiveClean(self, uuids_or_uuid)
Mark one or more primitives as clean (cancels dirty state).
Definition Context.py:6783
vec3 getConeObjectNode(self, int objID, int number)
Definition Context.py:5325
int3 getMaterialDataInt3(self, str material_label, str data_label)
Definition Context.py:6329
None clearGlobalData(self, str label)
Clear global data.
Definition Context.py:4838
vec3 getConeObjectAxisUnitVector(self, int objID)
Definition Context.py:5334
None scaleTubeGirth(self, int objID, float scale_factor)
Scale the radii of an existing tube object by scale_factor.
Definition Context.py:6663
None setObjectDataVec2(self, objids_or_objid, str label, x_or_vec, float y=None)
Set object data as vec2.
Definition Context.py:4483
int4 getMaterialDataInt4(self, str material_label, str data_label)
Definition Context.py:6334
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:389
None setTubeRadii(self, int objID, List[float] radii)
Replace the per-node radii of an existing tube object.
Definition Context.py:6656
addMaterial(self, str material_label)
Create a new material for sharing visual properties across primitives.
Definition Context.py:3942
vec2 getTileObjectSize(self, int objID)
Definition Context.py:5103
int getObjectType(self, int objID)
Return the integer-coded helios::ObjectType of a compound object.
Definition Context.py:5041
List[int3] getPolymeshObjectFaces(self, int objID)
Return the vertex index triples defining each face of a polymesh object.
Definition Context.py:5766
None disableObjectDataValueCaching(self, str label)
Disable value caching for the given object-data label.
Definition Context.py:6046
bool isMaterialTextureColorOverridden(self, str material_label)
Check if material texture color is overridden by material color.
Definition Context.py:4041
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:2390
bool isPrimitiveDirty(self, int uuid)
Return True if the primitive's geometry has been modified since the last clean mark.
Definition Context.py:5523
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:2954
None setObjectDataInt2(self, objids_or_objid, str label, x_or_vec, int y=None)
Set object data as int2.
Definition Context.py:4525
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:2853
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:1737
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:994
vec2 getDiskObjectSize(self, int objID)
Definition Context.py:5271
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:1278
List[str] listAllPrimitiveDataLabels(self)
Return the union of all primitive-data labels used across every primitive in the context.
Definition Context.py:6007
str _validate_file_path(self, str filename, List[str] expected_extensions=None)
Validate and normalize file path for security.
Definition Context.py:256
int getJulianDate(self)
Get the current simulation date as Julian day (1-366).
Definition Context.py:5546
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:1150
None setGlobalDataFloat(self, str label, float value)
Set global data as 32-bit float.
Definition Context.py:4719
'np.ndarray' getAllPrimitiveTypes(self)
Get types for all primitives.
Definition Context.py:4343
float queryTimeseriesData(self, str label, 'Date' date=None, 'Time' time=None, int index=None)
Query a timeseries data value.
Definition Context.py:3504
int getConeObjectSubdivisionCount(self, int objID)
Definition Context.py:5312
getPrimitiveVertices(self, uuid)
Get vertices of a primitive or multiple primitives.
Definition Context.py:612
int getPolymeshObjectPrimitiveUUIDForFace(self, int objID, int face_index)
Return the UUID of the primitive making up a given face of a polymesh object.
Definition Context.py:5829
None appendTubeSegment(self, int objID, vec3 node_position, float radius, *, Optional[RGBcolor] color=None, Optional[str] texture_file=None, Optional[vec2] uv=None)
Append a new segment to an existing tube object.
Definition Context.py:6686
None writeXML(self, str filename, Optional[List[int]] uuids=None, bool quiet=False)
Write the context (or a UUID subset) to an XML file.
Definition Context.py:6865
None setPrimitiveElevation(self, int uuid, vec3 origin, float new_elevation)
Rotate a single primitive about the given origin so its elevation equals new_elevation (radians).
Definition Context.py:6157
List[int2] getPolymeshObjectBoundaryEdges(self, int objID)
Return the boundary edges of a polymesh object as vertex index pairs.
Definition Context.py:5863
assignMaterialToPrimitive(self, uuid, str material_label)
Assign a material to primitive(s).
Definition Context.py:4070
randu(self, low=None, high=None)
Draw a uniform random number using the Context's RNG.
Definition Context.py:6896
None scalePrimitiveData(self, uuids_or_label, label_or_factor, factor=None)
Scale primitive data by a factor.
Definition Context.py:4911
List[int] getUniquePrimitiveParentObjectIDs(self, List[int] uuids, bool include_zero=True)
Return the unique set of compound-object IDs that the given primitives belong to.
Definition Context.py:6108
bool doesObjectDataExist(self, int objID, str label)
Check if object data exists.
Definition Context.py:4622
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:2208
getMaterialData(self, str material_label, str data_label, type data_type=None)
Get material data, auto-detecting the type from Helios storage if not specified.
Definition Context.py:6395
bool isPrimitiveTextureColorOverridden(self, int uuid)
Check if primitive texture color is overridden.
Definition Context.py:4322
List[int] getDeletedUUIDs(self)
Return the list of UUIDs that have been deleted from the context.
Definition Context.py:6083
None clearPrimitiveData(self, uuids, str label)
Remove a named data field from one primitive or a list of primitives.
Definition Context.py:5426
float getMaterialDataFloat(self, str material_label, str data_label)
Definition Context.py:6297
float sumPrimitiveSurfaceArea(self, List[int] uuids)
Calculate total one-sided surface area for a set of primitives.
Definition Context.py:4961
int predictAdaptiveTileObjectSubpatchCount(self, vec2 size, Optional[AdaptiveTileRefinement] refinement=None, Optional[int2] texture_repeat=None)
Determine how many sub-patches an adaptive tile object would contain, without building geometry.
Definition Context.py:1619
int3 getBoxObjectSubdivisionCount(self, int objID)
Definition Context.py:5256
bool doesObjectHaveSharedVertexTopology(self, int objID)
Return True if a compound object reports which member primitives meet at each vertex.
Definition Context.py:5643
vec2 getPatchSize(self, int uuid)
Definition Context.py:5354
None disablePrimitiveDataValueCaching(self, str label)
Disable value caching for the given primitive-data label.
Definition Context.py:6035
bool doesObjectExist(self, int objID)
Return True if a compound object with the given ID exists.
Definition Context.py:5503
vec2 getAdaptiveTileObjectSize(self, int objID)
Get the dimensions of an entire adaptive tile object.
Definition Context.py:5160
bool isPolymeshObjectClosed(self, int objID)
Return True if a polymesh object is a closed surface, i.e.
Definition Context.py:5590
int getPatchCount(self, bool include_hidden=True)
Definition Context.py:5374
seedRandomGenerator(self, int seed)
Seed the random number generator for reproducible stochastic results.
Definition Context.py:384
None deleteObject(self, Union[int, List[int]] objIDs_or_objID)
Delete one or more compound objects from the context.
Definition Context.py:3861
None translatePrimitive(self, Union[int, List[int]] UUID, vec3 shift)
Translate one or more primitives by a shift vector.
Definition Context.py:1959
None setGlobalDataInt3(self, str label, x_or_vec, int y=None, int z=None)
Set global data as int3.
Definition Context.py:4763
Union[int, List[int]] copyPrimitive(self, Union[int, List[int]] UUID)
Copy one or more primitives.
Definition Context.py:1843
None setMaterialDataFloat(self, str material_label, str data_label, float value)
Set float data on a material.
Definition Context.py:6231
None printPrimitiveInfo(self, int uuid)
Print summary info for the primitive to stdout (for debugging).
Definition Context.py:6024
Union[List[vec3], List[List[vec3]]] getObjectPrimitiveVertexNormals(self, int objID, Union[int, List[int]] uuid)
Return the analytic surface normals at each vertex of a member primitive.
Definition Context.py:5962
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:4053
None setObjectDataString(self, objids_or_objid, str label, str value)
Set object data as string.
Definition Context.py:4473
int getPolymeshObjectFaceCount(self, int objID)
Return the number of faces in a polymesh object.
Definition Context.py:5819
None setPrimitiveDataString(self, uuids_or_uuid, str label, str value)
Set primitive data as string for one or multiple primitives.
Definition Context.py:2889
None setPrimitiveColor(self, uuids, color)
Set the RGB or RGBA color of one primitive or a list of primitives.
Definition Context.py:5406
None setGlobalDataDouble(self, str label, float value)
Set global data as 64-bit double.
Definition Context.py:4723
None renameMaterial(self, str old_label, str new_label)
Rename an existing material.
Definition Context.py:6058
float getTubeObjectVolume(self, int objID)
Definition Context.py:5303
None setMaterialDataInt(self, str material_label, str data_label, int value)
Set int data on a material.
Definition Context.py:6221
vec2 getMaterialDataVec2(self, str material_label, str data_label)
Definition Context.py:6309
List[vec3] getTubeObjectNodes(self, int objID)
Definition Context.py:5289
None overrideObjectTextureColor(self, objIDs_or_objID)
Override the texture mapping with the object's vertex color.
Definition Context.py:6757
vec3 getMaterialDataVec3(self, str material_label, str data_label)
Definition Context.py:6314
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:2089
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:2570
float getConeObjectLength(self, int objID)
Definition Context.py:5339
None setMaterialDataDouble(self, str material_label, str data_label, float value)
Set double-precision float data on a material.
Definition Context.py:6236
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:2020
List[str] getLoadedXMLFiles(self)
Return the list of XML file paths that have been loaded into this context.
Definition Context.py:6012
vec3 getBoxObjectCenter(self, int objID)
Definition Context.py:5246
updateTimeseriesData(self, str label, 'Date' date, 'Time' time, float new_value)
Update the value of an existing timeseries data point.
Definition Context.py:3435
calculatePrimitiveDataSum(self, List[int] uuids, str label, type return_type=float)
Calculate sum of primitive data across UUIDs.
Definition Context.py:4890
List[vec3] getPolymeshObjectVertexNormals(self, int objID)
Return the per-vertex normals of a polymesh object.
Definition Context.py:5781
getPrimitiveTextureUV(self, uuid)
Get the texture UV coordinates of a primitive or multiple primitives.
Definition Context.py:4243
PrimitiveInfo getPrimitiveInfo(self, int uuid)
Get physical properties and geometry information for a single primitive.
Definition Context.py:695
int getMaterialDataType(self, str material_label, str data_label)
Return the HeliosDataType enum value for the given material data entry.
Definition Context.py:6344
None setObjectDataInt3(self, objids_or_objid, str label, x_or_vec, int y=None, int z=None)
Set object data as int3.
Definition Context.py:4539
_validate_uuids(self, uuids)
Validate that every UUID in uuids exists in this context.
Definition Context.py:211
int getPrimitiveDataSize(self, int uuid, str label)
Get the size/length of primitive data (for vector data).
Definition Context.py:3161
int getGlobalDataSize(self, str label)
Get the size of global data array.
Definition Context.py:4830
getPrimitiveBoundingBox(self, uuids)
Get axis-aligned bounding box for one primitive or a list of primitives.
Definition Context.py:5390
float getPolymeshObjectSurfaceArea(self, int objID)
Return the total surface area of a polymesh object, summed over every face.
Definition Context.py:5578
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:1654
List[vec3] getPolymeshObjectVertices(self, int objID)
Return the deduplicated shared vertex positions of a polymesh object.
Definition Context.py:5760
int addPolymeshObject(self, List[int] uuids)
Group the given primitives into a new polymesh compound object and return its ID.
Definition Context.py:6722
None setGlobalDataString(self, str label, str value)
Set global data as string.
Definition Context.py:4727
None cropDomainY(self, vec2 ybounds)
Definition Context.py:5456
vec3 getObjectCenter(self, int objID)
Definition Context.py:5045
vec3 getPatchCenter(self, int uuid)
Definition Context.py:5349
int2 getTileObjectSubdivisionCount(self, int objID)
Definition Context.py:5108
None hideObject(self, objids_or_objid)
Hide one or more compound objects (and all their primitives).
Definition Context.py:4402
None setObjectDataFloat(self, objids_or_objid, str label, float value)
Set object data as 32-bit float.
Definition Context.py:4453
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:519
getPrimitiveType(self, uuid)
Get the type of a primitive or multiple primitives.
Definition Context.py:550
int getPrimitiveDataType(self, int uuid, str label)
Get the Helios data type of primitive data.
Definition Context.py:3148
calculatePrimitiveDataAreaWeightedSum(self, List[int] uuids, str label, type return_type=float)
Calculate area-weighted sum of primitive data.
Definition Context.py:4899
bool doesObjectContainPrimitive(self, int objID, int uuid)
Return True if the given primitive UUID belongs to the given object.
Definition Context.py:5508
str getObjectDataString(self, int objID, str label)
Get string object data.
Definition Context.py:4610
None cropDomainZ(self, vec2 zbounds)
Definition Context.py:5462
None setMaterialDataString(self, str material_label, str data_label, str value)
Set string data on a material.
Definition Context.py:6241
List[List[int]] getObjectPrimitiveSharedVertexIndicesMulti(self, int objID, List[int] uuids, VertexWeldMode weld_mode=VertexWeldMode.WELD_FULL)
Return shared mesh vertex indices for many primitives of a compound object at once.
Definition Context.py:5723
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:2835
int getTubeObjectSubdivisionCount(self, int objID)
Definition Context.py:5281
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:1783
float getObjectDataFloat(self, int objID, str label)
Get float object data.
Definition Context.py:4602
calculatePrimitiveDataMean(self, List[int] uuids, str label, type return_type=float)
Calculate arithmetic mean of primitive data across UUIDs.
Definition Context.py:4871
int getMaterialCount(self)
Return the total number of materials registered in the context.
Definition Context.py:5551
List[List[int]] getPolymeshObjectConnectedComponents(self, int objID)
Return the connected components of a polymesh object.
Definition Context.py:5874
None setPrimitiveAzimuth(self, int uuid, vec3 origin, float new_azimuth)
Rotate a single primitive about the given origin so its azimuth equals new_azimuth (radians).
Definition Context.py:6147
Location getLocation(self)
Return the Context's currently-configured geographic location.
Definition Context.py:6953
int getMaterialDataUInt(self, str material_label, str data_label)
Definition Context.py:6293
int getObjectDataInt(self, int objID, str label)
Get int object data.
Definition Context.py:4606
List[vec2] getTileObjectTextureUV(self, int objID)
Definition Context.py:5118
None clearAllObjectData(self, str label)
Remove a named data field from every compound object in the Context.
Definition Context.py:4677
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:2333
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:2506
str getMaterialDataString(self, str material_label, str data_label)
Definition Context.py:6305
None renameObjectData(self, int objID, str old_label, str new_label)
Rename an object data label.
Definition Context.py:4694
None overridePrimitiveTextureColor(self, uuids_or_uuid)
Override texture color with the primitive's constant RGB color.
Definition Context.py:4294
None translateObject(self, Union[int, List[int]] ObjID, vec3 shift)
Translate one or more compound objects by a shift vector.
Definition Context.py:1992
int getObjectSharedVertexCount(self, int objID, VertexWeldMode weld_mode=VertexWeldMode.WELD_FULL)
Return the number of distinct shared vertices in a compound object's mesh.
Definition Context.py:5663
np.ndarray getObjectDataArray(self, List[int] objids, str label)
Get object data values for multiple objects as a NumPy array.
Definition Context.py:4644
float getPrimitiveDataFloat(self, int uuid, str label)
Convenience method to get float primitive data.
Definition Context.py:3135
int2 getMaterialDataInt2(self, str material_label, str data_label)
Definition Context.py:6324
int getGlobalDataVersion(self, str label)
Return the version counter for a global data entry.
Definition Context.py:5984
None setGlobalDataVec2(self, str label, x_or_vec, float y=None)
Set global data as vec2.
Definition Context.py:4731
deleteMaterial(self, str material_label)
Delete a material from the context.
Definition Context.py:3964
List[PrimitiveInfo] _batchPrimitiveInfo(self, List[int] uuids)
Build PrimitiveInfo for many primitives with a fixed number of native calls.
Definition Context.py:754
None computePolymeshObjectVertexNormals(self, int objID, float crease_angle_degrees=30.0)
Compute per-vertex normals for a polymesh object by area-weighted averaging.
Definition Context.py:5851
dict get_plugin_capabilities(self)
Get detailed information about available plugin capabilities.
Definition Context.py:3902
'np.ndarray' getObjectTransformationMatrix(self, int objID)
Return the object's 4x4 transformation matrix as a (4,4) float32 ndarray.
Definition Context.py:6552
_check_primitive_data_exists(self, List[int] uuids, str label)
Raise if any of uuids lacks primitive data label.
Definition Context.py:3175
vec3 getVoxelSize(self, int uuid)
Definition Context.py:5369
VertexNormalSource getPolymeshObjectVertexNormalSource(self, int objID)
Return where a polymesh object's vertex normals came from.
Definition Context.py:5807
List[vec3] getAdaptiveTileObjectVertices(self, int objID)
Get the Cartesian coordinates of each of the four corners of an adaptive tile object.
Definition Context.py:5172
getTime(self)
Get the current simulation time.
Definition Context.py:3361
int getPolymeshObjectFaceIndexForPrimitive(self, int objID, int uuid)
Return the index into :meth:getPolymeshObjectFaces of the face made up by a member primitive.
Definition Context.py:5824
getPrimitiveNormal(self, uuid)
Get the normal vector of a primitive or multiple primitives.
Definition Context.py:589
int getObjectPrimitiveCount(self, int objID)
Return the number of primitives currently belonging to the object.
Definition Context.py:5561
int addPatchTextured(self, vec3 center, vec2 size, str texture_file, Optional[SphericalCoord] rotation=None, Optional[vec2] uv_center=None, Optional[vec2] uv_size=None)
Add a textured patch primitive to the context.
Definition Context.py:428
None scalePrimitive(self, Union[int, List[int]] UUID, vec3 scale, Optional[vec3] point=None)
Scale one or more primitives.
Definition Context.py:2162
List[int] filterObjectsByData(self, List[int] objIDs, str label, value, str comparator="=")
Filter objects by data value.
Definition Context.py:4698
List[int] cleanDeletedObjectIDs(self, List[int] objIDs)
Return a new list with deleted object IDs removed; input is not mutated.
Definition Context.py:6849
List[str] generateTexturesFromColormap(self, str texture_file, List[RGBcolor] colormap)
Generate one texture file per color in colormap derived from texture_file.
Definition Context.py:6979
List[int] getPrimitivesUsingMaterial(self, str material_label)
Get all primitive UUIDs that use a specific material.
Definition Context.py:4147
float getPolymeshObjectVolume(self, int objID)
Return the enclosed volume of a polymesh object.
Definition Context.py:5573
List[int] getPrimitiveSharedVertexIndices(self, int uuid, VertexWeldMode weld_mode=VertexWeldMode.WELD_FULL)
Return a primitive's shared mesh vertex indices without naming its parent object.
Definition Context.py:5748
setDateJulian(self, int julian_day, int year)
Set the simulation date using Julian day number.
Definition Context.py:3345
List[float] getTubeObjectNodeRadii(self, int objID)
Definition Context.py:5294
None clearObjectData(self, objids_or_objid, str label)
Clear object data.
Definition Context.py:4665
int getTriangleCount(self, bool include_hidden=True)
Definition Context.py:5378
float getConeObjectNodeRadius(self, int objID, int number)
Definition Context.py:5330
None pruneTubeNodes(self, int objID, int node_index)
Remove all tube nodes from index node_index to the end.
Definition Context.py:6673
deleteTimeseriesDataPoint(self, 'Date' date, 'Time' time, Optional[str] label=None)
Delete a single timeseries data point at the given date and time.
Definition Context.py:3725
int getObjectDataType(self, int objID, str label)
Get the HeliosDataType enum for object data.
Definition Context.py:4614
None setObjectDataInt4(self, objids_or_objid, str label, x_or_vec, int y=None, int z=None, int w=None)
Set object data as int4.
Definition Context.py:4553
setMaterialTextureColorOverride(self, str material_label, bool override)
Set whether material color overrides texture color.
Definition Context.py:4045
int addTriangle(self, vec3 vertex0, vec3 vertex1, vec3 vertex2, Optional[RGBcolor] color=None)
Add a triangle primitive to the context.
Definition Context.py:476
getDate(self)
Get the current simulation date.
Definition Context.py:3377
None useObjectTextureColor(self, objIDs_or_objID)
Restore use of the texture color (undoes overrideObjectTextureColor).
Definition Context.py:6765
None setPrimitiveTransformationMatrix(self, uuids_or_uuid, T)
Set the 4x4 transformation matrix on one or more primitives.
Definition Context.py:6589
'np.ndarray' getAllPrimitiveSolidFractions(self)
Get solid fractions for all primitives.
Definition Context.py:4347
List getUniqueObjectDataValues(self, str label, type dtype)
Return the unique values stored under label across all compound objects.
Definition Context.py:6485
getPrimitiveTextureFile(self, uuid)
Get the texture file path of a primitive or multiple primitives.
Definition Context.py:4162
None setGlobalDataInt2(self, str label, x_or_vec, int y=None)
Set global data as int2.
Definition Context.py:4755
AdaptiveTileRefinement getAdaptiveTileObjectRefinement(self, int objID)
Get the refinement parameters that were requested when the object was created.
Definition Context.py:5178
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:2708
str getObjectTextureFile(self, int objID)
Return the filesystem path of the texture assigned to the object, or an empty string if no texture is...
Definition Context.py:6001
None usePrimitiveTextureColor(self, uuids_or_uuid)
Use texture-map color instead of the constant RGB color.
Definition Context.py:4307
float getBoxObjectVolume(self, int objID)
Definition Context.py:5261
bool doesPrimitiveExist(self, uuid)
Check if a primitive exists for a given UUID or list of UUIDs.
Definition Context.py:663
float getGlobalDataFloat(self, str label)
Get float global data.
Definition Context.py:4814
bool areObjectPrimitivesComplete(self, int objID)
Return True if all primitives originally belonging to this object still exist (i.e....
Definition Context.py:5539
None setGlobalDataVec4(self, str label, x_or_vec, float y=None, float z=None, float w=None)
Set global data as vec4.
Definition Context.py:4747
List[int] loadXML(self, str filename, bool quiet=False)
Load geometry from a Helios XML file.
Definition Context.py:2439
float randn(self, mean=None, stddev=None)
Draw a normal random number using the Context's RNG.
Definition Context.py:6916
None setGlobalDataInt(self, str label, int value)
Set global data as signed 32-bit integer.
Definition Context.py:4711
setMaterialColor(self, str material_label, color)
Set the RGBA color of a material.
Definition Context.py:4001
None setPrimitiveTextureFile(self, int uuid, str texture_file)
Set the texture file path of a primitive.
Definition Context.py:4218
None aggregatePrimitiveDataProduct(self, List[int] uuids, List[str] labels, str result_label)
Multiply multiple primitive data fields into a new field.
Definition Context.py:4957
bool doesMaterialExist(self, str material_label)
Check if a material with the given label exists.
Definition Context.py:3946
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:2277
None setObjectDataDouble(self, objids_or_objid, str label, float value)
Set object data as 64-bit double.
Definition Context.py:4463
List[int] getObjectPrimitiveSharedVertexIndices(self, int objID, int uuid, VertexWeldMode weld_mode=VertexWeldMode.WELD_FULL)
Return the shared mesh vertex each vertex of a primitive belongs to.
Definition Context.py:5695
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:934
int getDiskObjectSubdivisionCount(self, int objID)
Definition Context.py:5276
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:2610
setTime(self, int hour, int minute=0, int second=0)
Set the simulation time.
Definition Context.py:3310
getPrimitiveSolidFraction(self, uuid)
Get the solid fraction of a primitive or multiple primitives.
Definition Context.py:4277
packGPUBuffers(self, uuids)
Pack GPU-ready geometry buffers for a set of primitives in a single C++ pass.
Definition Context.py:4206
None setMaterialDataUInt(self, str material_label, str data_label, int value)
Set unsigned int data on a material.
Definition Context.py:6226
None setObjectTransformationMatrix(self, objIDs_or_objID, T)
Set the 4x4 transformation matrix on one or more compound objects.
Definition Context.py:6564
float getObjectArea(self, int objID)
Return the total surface area (one-sided) of all primitives in the object.
Definition Context.py:5556
assignMaterialToObject(self, objID, str material_label)
Assign a material to all primitives in compound object(s).
Definition Context.py:4091
int getGlobalDataInt(self, str label)
Get int global data.
Definition Context.py:4818
None setObjectDataFromPrimitiveDataMean(self, int objID, str label)
Compute the mean of the given primitive-data label across the object's primitives and store it as obj...
Definition Context.py:6053
'Date' queryTimeseriesDate(self, str label, int index)
Get the Date associated with a timeseries data point.
Definition Context.py:3581
'np.ndarray' getAllPrimitiveAreas(self)
Get areas for all primitives.
Definition Context.py:4339
List[float] _marshal_mat4(value)
Coerce a 4x4 transformation matrix input into a flat list of 16 floats.
Definition Context.py:6512
None setLocation(self, location_or_lat, longitude=None, utc_offset=None, altitude=0.0)
Set the geographic location used by solar/radiation calculations.
Definition Context.py:6936
'np.ndarray' getAllPrimitiveNormals(self)
Get normals for all primitives.
Definition Context.py:4331
bool isObjectHidden(self, int objID)
Check if a compound object is hidden.
Definition Context.py:4427
resolveMaterialTextures(self, uuids, colors_np)
Resolve material texture suppression for export.
Definition Context.py:4188
None renameGlobalData(self, str old_label, str new_label)
Rename a global data label.
Definition Context.py:4842
None setPolymeshObjectVertices(self, int objID, List[vec3] vertices)
Move every shared vertex of a polymesh object, deforming the mesh.
Definition Context.py:5624
bool isObjectDataValueCachingEnabled(self, str label)
Return True if value caching is enabled for the given object-data label.
Definition Context.py:5528
List[str] listTimeseriesVariables(self)
List all existing timeseries variables.
Definition Context.py:3653
List[str] listPrimitiveData(self, int uuid)
List all data labels attached to a primitive.
Definition Context.py:5444
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:2930
List[RGBcolor] generateColormap(self, str name, int n_colors)
Generate a colormap with n_colors entries from a named colormap.
Definition Context.py:6971
bool doesGlobalDataExist(self, str label)
Check if global data exists.
Definition Context.py:4834
int getMaterialDataInt(self, str material_label, str data_label)
Definition Context.py:6289
vec3 getTileObjectCenter(self, int objID)
Definition Context.py:5098
setMaterialTexture(self, str material_label, str texture_file)
Set the texture file for a material.
Definition Context.py:4037
int getSphereObjectSubdivisionCount(self, int objID)
Definition Context.py:5237
Physical properties and geometry information for a primitive.
Definition Context.py:32
__post_init__(self)
Calculate centroid from vertices if not provided.
Definition Context.py:45
Parameters controlling the adaptive sub-patch refinement of an adaptive tile object.
Definition DataTypes.py:666
Helios Date structure for representing date values.
Definition DataTypes.py:915
Geographic location for solar position and radiation calculations.
Helios primitive type enumeration.
Definition DataTypes.py:8
Helios Time structure for representing time values.
Definition DataTypes.py:843
Provenance of a polymesh object's per-vertex normals (helios-core 1.3.83).
Definition DataTypes.py:20
None check_context_alive('Context' context, str owner_name)
Raise if context's native Context has already been destroyed.
Definition Context.py:7030