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