0.1.22
Loading...
Searching...
No Matches
PlantArchitecture.py
Go to the documentation of this file.
1"""
2High-level PlantArchitecture interface for PyHelios.
3
4This module provides a user-friendly interface to the plant architecture modeling
5capabilities with graceful plugin handling and informative error messages.
6"""
7
8import logging
9import os
10from contextlib import contextmanager
11from pathlib import Path
12from typing import List, Optional, Union, Dict, Any
13
14from .Context import Context
15from .plugins.registry import get_plugin_registry, require_plugin
16from .wrappers import UPlantArchitectureWrapper as plantarch_wrapper
17from .wrappers.DataTypes import vec3, vec2, int2, AxisRotation
18try:
19 from .validation.datatypes import validate_vec3, validate_vec2, validate_int2
20except ImportError:
21 # Fallback validation functions for when validation module is not available
22 def validate_vec3(value, name, func):
23 if hasattr(value, 'x') and hasattr(value, 'y') and hasattr(value, 'z'):
24 return value
25 if isinstance(value, (list, tuple)) and len(value) == 3:
26 from .wrappers.DataTypes import vec3
27 return vec3(*value)
28 raise ValueError(f"{name} must be vec3 or 3-element list/tuple")
29
30 def validate_vec2(value, name, func):
31 if hasattr(value, 'x') and hasattr(value, 'y'):
32 return value
33 if isinstance(value, (list, tuple)) and len(value) == 2:
34 from .wrappers.DataTypes import vec2
35 return vec2(*value)
36 raise ValueError(f"{name} must be vec2 or 2-element list/tuple")
37
38 def validate_int2(value, name, func):
39 if hasattr(value, 'x') and hasattr(value, 'y'):
40 return value
41 if isinstance(value, (list, tuple)) and len(value) == 2:
42 from .wrappers.DataTypes import int2
43 return int2(*value)
44 raise ValueError(f"{name} must be int2 or 2-element list/tuple")
45from .validation.core import validate_positive_value
46from .assets import get_asset_manager
47
48logger = logging.getLogger(__name__)
49
50
51class RandomParameter:
52 """
53 Helper class for creating RandomParameter specifications for float parameters.
54
55 Provides convenient static methods to create parameter dictionaries with
56 statistical distributions for plant architecture modeling.
57 """
58
59 @staticmethod
60 def constant(value: float) -> Dict[str, Any]:
61 """
62 Create a constant (non-random) parameter.
63
64 Args:
65 value: The constant value
66
67 Returns:
68 Dict with constant distribution specification
69
70 Example:
71 >>> param = RandomParameter.constant(45.0)
72 >>> # Returns: {'distribution': 'constant', 'parameters': [45.0]}
73 """
74 return {'distribution': 'constant', 'parameters': [float(value)]}
75
76 @staticmethod
77 def uniform(min_val: float, max_val: float) -> Dict[str, Any]:
78 """
79 Create a uniform distribution parameter.
80
81 Args:
82 min_val: Minimum value
83 max_val: Maximum value
84
85 Returns:
86 Dict with uniform distribution specification
87
88 Raises:
89 ValueError: If min_val > max_val
90
91 Example:
92 >>> param = RandomParameter.uniform(40.0, 50.0)
93 >>> # Returns: {'distribution': 'uniform', 'parameters': [40.0, 50.0]}
94 """
95 if min_val > max_val:
96 raise ValueError(f"min_val ({min_val}) must be <= max_val ({max_val})")
97 return {'distribution': 'uniform', 'parameters': [float(min_val), float(max_val)]}
98
99 @staticmethod
100 def normal(mean: float, std_dev: float) -> Dict[str, Any]:
101 """
102 Create a normal (Gaussian) distribution parameter.
103
104 Args:
105 mean: Mean value
106 std_dev: Standard deviation
107
108 Returns:
109 Dict with normal distribution specification
110
111 Raises:
112 ValueError: If std_dev < 0
113
114 Example:
115 >>> param = RandomParameter.normal(45.0, 5.0)
116 >>> # Returns: {'distribution': 'normal', 'parameters': [45.0, 5.0]}
117 """
118 if std_dev < 0:
119 raise ValueError(f"std_dev ({std_dev}) must be >= 0")
120 return {'distribution': 'normal', 'parameters': [float(mean), float(std_dev)]}
121
122 @staticmethod
123 def weibull(shape: float, scale: float) -> Dict[str, Any]:
124 """
125 Create a Weibull distribution parameter.
126
127 Args:
128 shape: Shape parameter (k)
129 scale: Scale parameter (λ)
130
131 Returns:
132 Dict with Weibull distribution specification
133
134 Raises:
135 ValueError: If shape or scale <= 0
136
137 Example:
138 >>> param = RandomParameter.weibull(2.0, 50.0)
139 >>> # Returns: {'distribution': 'weibull', 'parameters': [2.0, 50.0]}
140 """
141 if shape <= 0:
142 raise ValueError(f"shape ({shape}) must be > 0")
143 if scale <= 0:
144 raise ValueError(f"scale ({scale}) must be > 0")
145 return {'distribution': 'weibull', 'parameters': [float(shape), float(scale)]}
146
147
149 """
150 Helper class for creating RandomParameter specifications for integer parameters.
151
152 Provides convenient static methods to create parameter dictionaries with
153 statistical distributions for integer-valued plant parameters.
154 """
155
156 @staticmethod
157 def constant(value: int) -> Dict[str, Any]:
158 """
159 Create a constant (non-random) integer parameter.
160
161 Args:
162 value: The constant integer value
163
164 Returns:
165 Dict with constant distribution specification
166
167 Example:
168 >>> param = RandomParameterInt.constant(15)
169 >>> # Returns: {'distribution': 'constant', 'parameters': [15.0]}
170 """
171 return {'distribution': 'constant', 'parameters': [float(value)]}
172
173 @staticmethod
174 def uniform(min_val: int, max_val: int) -> Dict[str, Any]:
175 """
176 Create a uniform distribution for integer parameter.
177
178 Args:
179 min_val: Minimum value (inclusive)
180 max_val: Maximum value (inclusive)
181
182 Returns:
183 Dict with uniform distribution specification
184
185 Raises:
186 ValueError: If min_val > max_val
187
188 Example:
189 >>> param = RandomParameterInt.uniform(10, 20)
190 >>> # Returns: {'distribution': 'uniform', 'parameters': [10.0, 20.0]}
191 """
192 if min_val > max_val:
193 raise ValueError(f"min_val ({min_val}) must be <= max_val ({max_val})")
194 return {'distribution': 'uniform', 'parameters': [float(min_val), float(max_val)]}
195
196 @staticmethod
197 def discrete(values: List[int]) -> Dict[str, Any]:
198 """
199 Create a discrete value distribution (random choice from list).
200
201 Args:
202 values: List of possible integer values (equal probability)
203
204 Returns:
205 Dict with discrete distribution specification
206
207 Raises:
208 ValueError: If values list is empty
209
210 Example:
211 >>> param = RandomParameterInt.discrete([1, 2, 3, 5])
212 >>> # Returns: {'distribution': 'discretevalues', 'parameters': [1.0, 2.0, 3.0, 5.0]}
213 """
214 if not values:
215 raise ValueError("values list cannot be empty")
216 return {'distribution': 'discretevalues', 'parameters': [float(v) for v in values]}
217
218
219def _resolve_user_path(filepath: Union[str, Path]) -> str:
220 """
221 Convert relative paths to absolute paths before changing working directory.
222
223 This preserves the user's intended file location when the working directory
224 is temporarily changed for C++ asset access. Absolute paths are returned unchanged.
225
226 Args:
227 filepath: File path to resolve (string or Path object)
228
229 Returns:
230 Absolute path as string
231 """
232 path = Path(filepath)
233 if not path.is_absolute():
234 return str(Path.cwd() / path)
235 return str(path)
236
237
238@contextmanager
240 """
241 Context manager that temporarily changes working directory to where PlantArchitecture assets are located.
242
243 PlantArchitecture C++ code uses hardcoded relative paths like "plugins/plantarchitecture/assets/textures/"
244 expecting assets relative to working directory. This manager temporarily changes to the build directory
245 where assets are actually located.
246
247 Raises:
248 RuntimeError: If build directory or PlantArchitecture assets are not found, indicating a build system error.
249 """
250 # Find the build directory containing PlantArchitecture assets
251 # Try asset manager first (works for both development and wheel installations)
252 asset_manager = get_asset_manager()
253 working_dir = asset_manager._get_helios_build_path()
254
255 if working_dir and working_dir.exists():
256 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
257 else:
258 # For wheel installations, check packaged assets
259 current_dir = Path(__file__).parent
260 packaged_build = current_dir / 'assets' / 'build'
261
262 if packaged_build.exists():
263 working_dir = packaged_build
264 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
265 else:
266 # Fallback to development paths
267 repo_root = current_dir.parent
268 build_lib_dir = repo_root / 'pyhelios_build' / 'build' / 'lib'
269 working_dir = build_lib_dir.parent
270 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
271
272 if not build_lib_dir.exists():
273 raise RuntimeError(
274 f"PyHelios build directory not found at {build_lib_dir}. "
275 f"PlantArchitecture requires native libraries to be built. "
276 f"Run: build_scripts/build_helios --plugins plantarchitecture"
277 )
278
279 if not plantarch_assets.exists():
280 raise RuntimeError(
281 f"PlantArchitecture assets not found at {plantarch_assets}. "
282 f"Build system failed to copy PlantArchitecture assets. "
283 f"Run: build_scripts/build_helios --clean --plugins plantarchitecture"
284 )
285
286 # Verify essential assets exist
287 assets_dir = plantarch_assets / 'assets'
288 if not assets_dir.exists():
289 raise RuntimeError(
290 f"PlantArchitecture assets directory not found: {assets_dir}. "
291 f"Essential assets missing. Rebuild with: "
292 f"build_scripts/build_helios --clean --plugins plantarchitecture"
293 )
294
295 # Change to the build directory temporarily
296 original_dir = os.getcwd()
297 try:
298 os.chdir(working_dir)
299 logger.debug(f"Changed working directory to {working_dir} for PlantArchitecture asset access")
300 yield working_dir
301 finally:
302 os.chdir(original_dir)
303 logger.debug(f"Restored working directory to {original_dir}")
304
305
306class PlantArchitectureError(Exception):
307 """Raised when PlantArchitecture operations fail."""
308 pass
309
310
312 """
313 Check if PlantArchitecture plugin is available for use.
314
315 Returns:
316 bool: True if PlantArchitecture can be used, False otherwise
317 """
318 try:
319 # Check plugin registry
320 plugin_registry = get_plugin_registry()
321 if not plugin_registry.is_plugin_available('plantarchitecture'):
322 return False
323
324 # Check if wrapper functions are available
325 if not plantarch_wrapper._PLANTARCHITECTURE_FUNCTIONS_AVAILABLE:
326 return False
327
328 return True
329 except Exception:
330 return False
331
332
334 """
335 High-level interface for plant architecture modeling and procedural plant generation.
336
337 PlantArchitecture provides access to the comprehensive plant library with 25+ plant models
338 including trees (almond, apple, olive, walnut), crops (bean, cowpea, maize, rice, soybean),
339 and other plants. This class enables procedural plant generation, time-based growth
340 simulation, and plant community modeling.
341
342 This class requires the native Helios library built with PlantArchitecture support.
343 Use context managers for proper resource cleanup.
344
345 Example:
346 >>> with Context() as context:
347 ... with PlantArchitecture(context) as plantarch:
348 ... plantarch.loadPlantModelFromLibrary("bean")
349 ... plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(0, 0, 0), age=30)
350 ... plantarch.advanceTime(10.0) # Grow for 10 days
351 """
352
353 def __new__(cls, context=None):
354 """
355 Create PlantArchitecture instance.
356 Explicit __new__ to prevent ctypes contamination on Windows.
357 """
358 return object.__new__(cls)
359
360 def __init__(self, context: Context):
361 """
362 Initialize PlantArchitecture with a Helios context.
363
364 Args:
365 context: Active Helios Context instance
366
367 Raises:
368 PlantArchitectureError: If plugin not available in current build
369 RuntimeError: If plugin initialization fails
370 """
371 # Check plugin availability
372 registry = get_plugin_registry()
373 if not registry.is_plugin_available('plantarchitecture'):
375 "PlantArchitecture not available in current Helios library. "
376 "Rebuild PyHelios with PlantArchitecture support:\n"
377 " build_scripts/build_helios --plugins plantarchitecture\n"
378 "\n"
379 "System requirements:\n"
380 f" - Platforms: Windows, Linux, macOS\n"
381 " - Dependencies: Extensive asset library (textures, OBJ models)\n"
382 " - GPU: Not required\n"
383 "\n"
384 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
385 "rice, soybean, tomato, wheat, and many others."
386 )
387
388 self.context = context
389 self._plantarch_ptr = None
390
391 # Create PlantArchitecture instance with asset-aware working directory
393 self._plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
394
395 if not self._plantarch_ptr:
396 raise PlantArchitectureError("Failed to initialize PlantArchitecture")
397
398 def __enter__(self):
399 """Context manager entry"""
400 return self
401
402 def __exit__(self, exc_type, exc_val, exc_tb):
403 """Context manager exit - cleanup resources"""
404 if hasattr(self, '_plantarch_ptr') and self._plantarch_ptr:
405 plantarch_wrapper.destroyPlantArchitecture(self._plantarch_ptr)
406 self._plantarch_ptr = None
408 def __del__(self):
409 """Destructor to ensure C++ resources freed even without 'with' statement."""
410 if hasattr(self, '_plantarch_ptr') and self._plantarch_ptr is not None:
411 try:
412 plantarch_wrapper.destroyPlantArchitecture(self._plantarch_ptr)
413 self._plantarch_ptr = None
414 except Exception as e:
415 import warnings
416 warnings.warn(f"Error in PlantArchitecture.__del__: {e}")
417
418 def loadPlantModelFromLibrary(self, plant_label: str) -> None:
419 """
420 Load a plant model from the built-in library.
421
422 Args:
423 plant_label: Plant model identifier from library. Available models include:
424 "almond", "apple", "bean", "bindweed", "butterlettuce", "capsicum",
425 "cheeseweed", "cowpea", "easternredbud", "grapevine_VSP", "maize",
426 "olive", "pistachio", "puncturevine", "rice", "sorghum", "soybean",
427 "strawberry", "sugarbeet", "tomato", "cherrytomato", "walnut", "wheat"
428
429 Raises:
430 ValueError: If plant_label is empty or invalid
431 PlantArchitectureError: If model loading fails
432
433 Example:
434 >>> plantarch.loadPlantModelFromLibrary("bean")
435 >>> plantarch.loadPlantModelFromLibrary("almond")
436 """
437 if not plant_label:
438 raise ValueError("Plant label cannot be empty")
439
440 if not plant_label.strip():
441 raise ValueError("Plant label cannot be only whitespace")
442
443 try:
445 plantarch_wrapper.loadPlantModelFromLibrary(self._plantarch_ptr, plant_label.strip())
446 except Exception as e:
447 raise PlantArchitectureError(f"Failed to load plant model '{plant_label}': {e}")
448
449 def buildPlantInstanceFromLibrary(self, base_position: vec3, age: float,
450 build_parameters: Optional[dict] = None) -> int:
451 """
452 Build a plant instance from the currently loaded library model.
453
454 Args:
455 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
456 age: Age of the plant in days (must be >= 0)
457 build_parameters: Optional dict of parameter overrides for training system parameters.
458 Examples:
459 - {'trunk_height': 2.5} - for tomato trellis height
460 - {'cordon_height': 1.8, 'cordon_radius': 1.2} - for apple training
461 - {'row_spacing': 0.75} - for grapevine VSP trellis
462
463 Returns:
464 Plant ID for the created plant instance
465
466 Raises:
467 ValueError: If age is negative or build_parameters is invalid
468 PlantArchitectureError: If plant building fails
469 RuntimeError: If no model has been loaded
470
471 Example:
472 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(2.0, 3.0, 0.0), age=45.0)
473 >>> # With custom parameters
474 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(
475 ... base_position=vec3(0, 0, 0),
476 ... age=30.0,
477 ... build_parameters={'trunk_height': 2.0}
478 ... )
479 """
480 # Parameter type validation
481 if not isinstance(base_position, vec3):
482 raise ValueError(f"base_position must be a vec3, got {type(base_position).__name__}")
483
484 # Convert position to list for C++ interface
485 position_list = [base_position.x, base_position.y, base_position.z]
486
487 # Validate age (allow zero)
488 if age < 0:
489 raise ValueError(f"Age must be non-negative, got {age}")
490
491 # Validate build_parameters
492 if build_parameters is not None:
493 if not isinstance(build_parameters, dict):
494 raise ValueError("build_parameters must be a dict or None")
495 for key, value in build_parameters.items():
496 if not isinstance(key, str):
497 raise ValueError("build_parameters keys must be strings")
498 if not isinstance(value, (int, float)):
499 raise ValueError("build_parameters values must be numeric (int or float)")
500
501 try:
503 return plantarch_wrapper.buildPlantInstanceFromLibrary(
504 self._plantarch_ptr, position_list, age, build_parameters
505 )
506 except Exception as e:
507 raise PlantArchitectureError(f"Failed to build plant instance: {e}")
508
509 def buildPlantCanopyFromLibrary(self, canopy_center: vec3,
510 plant_spacing: vec2,
511 plant_count: int2, age: float,
512 germination_rate: float = 1.0,
513 build_parameters: Optional[dict] = None) -> List[int]:
514 """
515 Build a canopy of regularly spaced plants from the currently loaded library model.
516
517 Args:
518 canopy_center: Cartesian (x,y,z) coordinates of canopy center as vec3
519 plant_spacing: Spacing between plants in x- and y-directions (meters) as vec2
520 plant_count: Number of plants in x- and y-directions as int2
521 age: Age of all plants in days (must be >= 0)
522 germination_rate: Probability that each plant position will be occupied (0 to 1).
523 A value of 1.0 means all positions are filled; 0.5 means roughly
524 half the positions will have plants. Default is 1.0.
525 build_parameters: Optional dict of parameter overrides for training system parameters.
526 Parameters are applied to all plants in the canopy.
527 Examples:
528 - {'cordon_height': 1.8} - for grapevine trellis height
529 - {'trunk_height': 2.5} - for tomato trellis systems
530
531 Returns:
532 List of plant IDs for the created plant instances
533
534 Raises:
535 ValueError: If age is negative, germination_rate is not in [0, 1],
536 plant count values are not positive, or build_parameters is invalid
537 PlantArchitectureError: If canopy building fails
538
539 Example:
540 >>> # 3x3 canopy with 0.5m spacing, 30-day-old plants
541 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
542 ... canopy_center=vec3(0, 0, 0),
543 ... plant_spacing=vec2(0.5, 0.5),
544 ... plant_count=int2(3, 3),
545 ... age=30.0
546 ... )
547 >>> # With 80% germination rate and custom parameters
548 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
549 ... canopy_center=vec3(0, 0, 0),
550 ... plant_spacing=vec2(1.5, 2.0),
551 ... plant_count=int2(5, 3),
552 ... age=45.0,
553 ... germination_rate=0.8,
554 ... build_parameters={'cordon_height': 1.8}
555 ... )
556 """
557 # Parameter type validation
558 if not isinstance(canopy_center, vec3):
559 raise ValueError(f"canopy_center must be a vec3, got {type(canopy_center).__name__}")
560 if not isinstance(plant_spacing, vec2):
561 raise ValueError(f"plant_spacing must be a vec2, got {type(plant_spacing).__name__}")
562 if not isinstance(plant_count, int2):
563 raise ValueError(f"plant_count must be an int2, got {type(plant_count).__name__}")
564
565 # Validate age (allow zero)
566 if age < 0:
567 raise ValueError(f"Age must be non-negative, got {age}")
568
569 # Validate germination rate
570 if not isinstance(germination_rate, (int, float)):
571 raise ValueError(f"germination_rate must be a number, got {type(germination_rate).__name__}")
572 if germination_rate < 0 or germination_rate > 1:
573 raise ValueError(f"germination_rate must be between 0 and 1, got {germination_rate}")
574
575 # Validate count values
576 if plant_count.x <= 0 or plant_count.y <= 0:
577 raise ValueError("Plant count values must be positive integers")
578
579 # Validate build_parameters
580 if build_parameters is not None:
581 if not isinstance(build_parameters, dict):
582 raise ValueError("build_parameters must be a dict or None")
583 for key, value in build_parameters.items():
584 if not isinstance(key, str):
585 raise ValueError("build_parameters keys must be strings")
586 if not isinstance(value, (int, float)):
587 raise ValueError("build_parameters values must be numeric (int or float)")
588
589 # Convert to lists for C++ interface
590 center_list = [canopy_center.x, canopy_center.y, canopy_center.z]
591 spacing_list = [plant_spacing.x, plant_spacing.y]
592 count_list = [plant_count.x, plant_count.y]
593
594 try:
596 return plantarch_wrapper.buildPlantCanopyFromLibrary(
597 self._plantarch_ptr, center_list, spacing_list, count_list, age,
598 germination_rate, build_parameters
599 )
600 except Exception as e:
601 raise PlantArchitectureError(f"Failed to build plant canopy: {e}")
602
603 def advanceTime(self, dt: float) -> None:
604 """
605 Advance time for plant growth and development.
606
607 This method updates all plants in the simulation, potentially adding new phytomers,
608 growing existing organs, transitioning phenological stages, and updating plant geometry.
609
610 Args:
611 dt: Time step to advance in days (must be >= 0)
612
613 Raises:
614 ValueError: If dt is negative
615 PlantArchitectureError: If time advancement fails
616
617 Note:
618 Large time steps are more efficient than many small steps. The timestep value
619 can be larger than the phyllochron, allowing multiple phytomers to be produced
620 in a single call.
621
622 Example:
623 >>> plantarch.advanceTime(10.0) # Advance 10 days
624 >>> plantarch.advanceTime(0.5) # Advance 12 hours
625 """
626 # Validate time step (allow zero)
627 if dt < 0:
628 raise ValueError(f"Time step must be non-negative, got {dt}")
630 try:
632 plantarch_wrapper.advanceTime(self._plantarch_ptr, dt)
633 except Exception as e:
634 raise PlantArchitectureError(f"Failed to advance time by {dt} days: {e}")
635
636 def setProgressCallback(self, callback):
637 """Set a callback to receive progress updates during long-running operations.
638
639 The callback fires during advanceTime() and adjustFruitForObstacleCollision()
640 as the underlying ProgressBar updates.
641
642 Args:
643 callback: A callable(progress: float, message: str) where progress is
644 in [0, 1], or None to clear the callback.
645
646 Raises:
647 ValueError: If callback is not callable and not None.
648 """
649 if callback is not None:
650 if not callable(callback):
651 raise ValueError(
652 f"callback must be callable or None, got {type(callback).__name__}"
653 )
654
655 def _c_callback(progress, message_bytes):
656 msg = message_bytes.decode('utf-8') if isinstance(message_bytes, bytes) else str(message_bytes)
657 callback(progress, msg)
658
659 self._progress_callback_ref = plantarch_wrapper.PROGRESS_CALLBACK(_c_callback)
660 plantarch_wrapper.setProgressCallback(self._plantarch_ptr, self._progress_callback_ref)
661 else:
662 plantarch_wrapper.setProgressCallback(self._plantarch_ptr, None)
664
665 def getCurrentShootParameters(self, shoot_type_label: str) -> dict:
666 """
667 Get current shoot parameters for a shoot type.
668
669 Returns a nested dictionary containing all shoot and phytomer parameters
670 including RandomParameter specifications with distribution types.
671
672 Args:
673 shoot_type_label: Label for the shoot type (e.g., "stem", "branch")
674
675 Returns:
676 Dictionary with shoot parameters including:
677 - Geometric parameters (max_nodes, insertion_angle_tip, etc.)
678 - Growth parameters (phyllochron_min, elongation_rate_max, etc.)
679 - Boolean flags (flowers_require_dormancy, etc.)
680 - RandomParameter fields include 'distribution' and 'parameters' keys
681
682 Raises:
683 ValueError: If shoot_type_label is empty
684 PlantArchitectureError: If parameter retrieval fails
685
686 Example:
687 >>> plantarch.loadPlantModelFromLibrary("bean")
688 >>> params = plantarch.getCurrentShootParameters("stem")
689 >>> print(params['max_nodes'])
690 {'distribution': 'constant', 'parameters': [15.0]}
691 """
692 if not shoot_type_label:
693 raise ValueError("Shoot type label cannot be empty")
694
695 if not shoot_type_label.strip():
696 raise ValueError("Shoot type label cannot be only whitespace")
697
698 try:
700 return plantarch_wrapper.getCurrentShootParameters(
701 self._plantarch_ptr, shoot_type_label.strip()
702 )
703 except Exception as e:
704 raise PlantArchitectureError(f"Failed to get shoot parameters for '{shoot_type_label}': {e}")
705
706 def defineShootType(self, shoot_type_label: str, parameters: dict) -> None:
707 """
708 Define a custom shoot type with specified parameters.
709
710 Allows creating new shoot types or modifying existing ones by providing
711 a parameter dictionary. Use getCurrentShootParameters() to get a template
712 that can be modified.
713
714 Args:
715 shoot_type_label: Unique name for this shoot type
716 parameters: Dictionary matching ShootParameters structure.
717 Use getCurrentShootParameters() to get proper structure.
718
719 Raises:
720 ValueError: If shoot_type_label is empty or parameters is not a dict
721 PlantArchitectureError: If shoot type definition fails
722
723 Example:
724 >>> # Get existing parameters as template
725 >>> plantarch.loadPlantModelFromLibrary("bean")
726 >>> params = plantarch.getCurrentShootParameters("stem")
727 >>>
728 >>> # Modify parameters
729 >>> params['max_nodes'] = {'distribution': 'constant', 'parameters': [20.0]}
730 >>> params['insertion_angle_tip'] = {'distribution': 'uniform', 'parameters': [40.0, 50.0]}
731 >>>
732 >>> # Define new shoot type
733 >>> plantarch.defineShootType("TallStem", params)
734 """
735 if not shoot_type_label:
736 raise ValueError("Shoot type label cannot be empty")
737
738 if not shoot_type_label.strip():
739 raise ValueError("Shoot type label cannot be only whitespace")
740
741 if not isinstance(parameters, dict):
742 raise ValueError("Parameters must be a dict")
743
744 try:
746 plantarch_wrapper.defineShootType(
747 self._plantarch_ptr, self.context.context, shoot_type_label.strip(), parameters
748 )
749 except Exception as e:
750 raise PlantArchitectureError(f"Failed to define shoot type '{shoot_type_label}': {e}")
751
752 def getAvailablePlantModels(self) -> List[str]:
753 """
754 Get list of all available plant models in the library.
755
756 Returns:
757 List of plant model names available for loading
758
759 Raises:
760 PlantArchitectureError: If retrieval fails
761
762 Example:
763 >>> models = plantarch.getAvailablePlantModels()
764 >>> print(f"Available models: {', '.join(models)}")
765 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
766 """
767 try:
769 return plantarch_wrapper.getAvailablePlantModels(self._plantarch_ptr)
770 except Exception as e:
771 raise PlantArchitectureError(f"Failed to get available plant models: {e}")
772
773 def getAllPlantObjectIDs(self, plant_id: int) -> List[int]:
774 """
775 Get all object IDs for a specific plant.
776
777 Args:
778 plant_id: ID of the plant instance
779
780 Returns:
781 List of object IDs comprising the plant
782
783 Raises:
784 ValueError: If plant_id is negative
785 PlantArchitectureError: If retrieval fails
786
787 Example:
788 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
789 >>> print(f"Plant has {len(object_ids)} objects")
790 """
791 if plant_id < 0:
792 raise ValueError("Plant ID must be non-negative")
793
794 try:
795 return plantarch_wrapper.getAllPlantObjectIDs(self._plantarch_ptr, plant_id)
796 except Exception as e:
797 raise PlantArchitectureError(f"Failed to get object IDs for plant {plant_id}: {e}")
798
799 def getAllPlantUUIDs(self, plant_id: int, include_hidden: bool = False) -> List[int]:
800 """
801 Get all primitive UUIDs for a specific plant.
802
803 Args:
804 plant_id: ID of the plant instance
805 include_hidden: If True, also include UUIDs of hidden prototype
806 primitives managed by this PlantArchitecture instance.
807
808 Returns:
809 List of primitive UUIDs comprising the plant (and optionally hidden prototypes)
810
811 Raises:
812 ValueError: If plant_id is negative
813 PlantArchitectureError: If retrieval fails
814
815 Example:
816 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
817 >>> print(f"Plant has {len(uuids)} primitives")
818 """
819 if plant_id < 0:
820 raise ValueError("Plant ID must be non-negative")
821
822 try:
823 return plantarch_wrapper.getAllPlantUUIDs(self._plantarch_ptr, plant_id, include_hidden)
824 except Exception as e:
825 raise PlantArchitectureError(f"Failed to get UUIDs for plant {plant_id}: {e}")
826
827 def getAllShootIDs(self, plant_id: int) -> List[int]:
828 """
829 Get the IDs of all shoots belonging to a plant.
830
831 Shoot IDs are contiguous 0-based indices into the plant's shoot tree, in creation
832 order; shoot 0 is always the base stem. The returned IDs can be passed to
833 :meth:`getShoot`, :meth:`getShootChildIDs`, etc.
834
835 Args:
836 plant_id: ID of the plant instance
837
838 Returns:
839 List of shoot IDs for the plant
840 """
841 if plant_id < 0:
842 raise ValueError("Plant ID must be non-negative")
843 try:
844 return plantarch_wrapper.getAllPlantShootIDs(self._plantarch_ptr, plant_id)
845 except Exception as e:
846 raise PlantArchitectureError(f"Failed to get shoot IDs for plant {plant_id}: {e}")
847
848 def getShoot(self, plant_id: int, shoot_id: int) -> Dict[str, Any]:
849 """
850 Get a read-only view of a shoot's topology.
851
852 Args:
853 plant_id: ID of the plant instance
854 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
855
856 Returns:
857 A dict with keys ``rank``, ``parent_shoot_id`` (-1 for the base stem),
858 ``parent_node_index``, and ``node_count``.
859 """
860 if plant_id < 0 or shoot_id < 0:
861 raise ValueError("Plant ID and shoot ID must be non-negative")
862 try:
863 return plantarch_wrapper.getPlantShootTopology(self._plantarch_ptr, plant_id, shoot_id)
864 except Exception as e:
866 f"Failed to get shoot {shoot_id} of plant {plant_id}: {e}")
867
868 def getShootChildIDs(self, plant_id: int, shoot_id: int) -> List[int]:
869 """Get the child shoot IDs of a shoot (flattened across parent node indices)."""
870 if plant_id < 0 or shoot_id < 0:
871 raise ValueError("Plant ID and shoot ID must be non-negative")
872 try:
873 return plantarch_wrapper.getPlantShootChildIDs(self._plantarch_ptr, plant_id, shoot_id)
874 except Exception as e:
876 f"Failed to get child shoots of shoot {shoot_id}, plant {plant_id}: {e}")
877
878 def getShootInternodeVertices(self, plant_id: int, shoot_id: int) -> List[tuple]:
879 """Get the woody internode polyline vertices of a shoot as a list of (x, y, z) tuples."""
880 if plant_id < 0 or shoot_id < 0:
881 raise ValueError("Plant ID and shoot ID must be non-negative")
882 try:
883 return plantarch_wrapper.getPlantShootInternodeVertices(self._plantarch_ptr, plant_id, shoot_id)
884 except Exception as e:
886 f"Failed to get internode vertices of shoot {shoot_id}, plant {plant_id}: {e}")
887
888 def getShootInternodeRadii(self, plant_id: int, shoot_id: int) -> List[float]:
889 """Get the per-vertex woody internode radii of a shoot."""
890 if plant_id < 0 or shoot_id < 0:
891 raise ValueError("Plant ID and shoot ID must be non-negative")
892 try:
893 return plantarch_wrapper.getPlantShootInternodeRadii(self._plantarch_ptr, plant_id, shoot_id)
894 except Exception as e:
896 f"Failed to get internode radii of shoot {shoot_id}, plant {plant_id}: {e}")
897
898 def getPlantAge(self, plant_id: int) -> float:
899 """
900 Get the current age of a plant in days.
901
902 Args:
903 plant_id: ID of the plant instance
904
905 Returns:
906 Plant age in days
907
908 Raises:
909 ValueError: If plant_id is negative
910 PlantArchitectureError: If retrieval fails
911
912 Example:
913 >>> age = plantarch.getPlantAge(plant_id)
914 >>> print(f"Plant is {age} days old")
915 """
916 if plant_id < 0:
917 raise ValueError("Plant ID must be non-negative")
918
919 try:
921 return plantarch_wrapper.getPlantAge(self._plantarch_ptr, plant_id)
922 except Exception as e:
923 raise PlantArchitectureError(f"Failed to get age for plant {plant_id}: {e}")
924
925 def getPlantHeight(self, plant_id: int) -> float:
926 """
927 Get the height of a plant in meters.
928
929 Args:
930 plant_id: ID of the plant instance
931
932 Returns:
933 Plant height in meters (vertical extent)
934
935 Raises:
936 ValueError: If plant_id is negative
937 PlantArchitectureError: If retrieval fails
938
939 Example:
940 >>> height = plantarch.getPlantHeight(plant_id)
941 >>> print(f"Plant is {height:.2f}m tall")
942 """
943 if plant_id < 0:
944 raise ValueError("Plant ID must be non-negative")
945
946 try:
948 return plantarch_wrapper.getPlantHeight(self._plantarch_ptr, plant_id)
949 except Exception as e:
950 raise PlantArchitectureError(f"Failed to get height for plant {plant_id}: {e}")
951
952 def getPlantLeafArea(self, plant_id: int) -> float:
953 """
954 Get the total leaf area of a plant in m².
955
956 Args:
957 plant_id: ID of the plant instance
958
959 Returns:
960 Total leaf area in square meters
961
962 Raises:
963 ValueError: If plant_id is negative
964 PlantArchitectureError: If retrieval fails
965
966 Example:
967 >>> leaf_area = plantarch.getPlantLeafArea(plant_id)
968 >>> print(f"Total leaf area: {leaf_area:.3f} m²")
969 """
970 if plant_id < 0:
971 raise ValueError("Plant ID must be non-negative")
972
973 try:
975 return plantarch_wrapper.sumPlantLeafArea(self._plantarch_ptr, plant_id)
976 except Exception as e:
977 raise PlantArchitectureError(f"Failed to get leaf area for plant {plant_id}: {e}")
978
980 self,
981 plant_id: int,
982 time_to_dormancy_break: float,
983 time_to_flower_initiation: float,
984 time_to_flower_opening: float,
985 time_to_fruit_set: float,
986 time_to_fruit_maturity: float,
987 time_to_dormancy: float,
988 max_leaf_lifespan: float = 1e6
989 ) -> None:
990 """
991 Set phenological timing thresholds for plant developmental stages.
992
993 Controls the timing of key phenological events based on thermal time
994 or calendar time depending on the plant model.
995
996 Args:
997 plant_id: ID of the plant instance
998 time_to_dormancy_break: Degree-days or days until dormancy ends
999 time_to_flower_initiation: Time until flower buds are initiated
1000 time_to_flower_opening: Time until flowers open
1001 time_to_fruit_set: Time until fruit begins developing
1002 time_to_fruit_maturity: Time until fruit reaches maturity
1003 time_to_dormancy: Time until plant enters dormancy
1004 max_leaf_lifespan: Maximum leaf lifespan in days (default: 1e6)
1005
1006 Raises:
1007 ValueError: If plant_id is negative
1008 PlantArchitectureError: If phenology setting fails
1009
1010 Example:
1011 >>> # Set phenology for perennial fruit tree
1012 >>> plantarch.setPlantPhenologicalThresholds(
1013 ... plant_id=plant_id,
1014 ... time_to_dormancy_break=60, # Spring: 60 degree-days
1015 ... time_to_flower_initiation=90, # Early spring flowering
1016 ... time_to_flower_opening=105, # Bloom period
1017 ... time_to_fruit_set=120, # Fruit set after pollination
1018 ... time_to_fruit_maturity=200, # Summer fruit maturation
1019 ... time_to_dormancy=280, # Fall dormancy
1020 ... max_leaf_lifespan=180 # Deciduous - 6 month leaf life
1021 ... )
1022 """
1023 if plant_id < 0:
1024 raise ValueError("Plant ID must be non-negative")
1025
1026 try:
1028 plantarch_wrapper.setPlantPhenologicalThresholds(
1029 self._plantarch_ptr,
1030 plant_id,
1031 time_to_dormancy_break,
1032 time_to_flower_initiation,
1033 time_to_flower_opening,
1034 time_to_fruit_set,
1035 time_to_fruit_maturity,
1036 time_to_dormancy,
1037 max_leaf_lifespan
1038 )
1039 except Exception as e:
1040 raise PlantArchitectureError(f"Failed to set phenological thresholds for plant {plant_id}: {e}")
1041
1042 # Collision detection methods
1044 target_object_UUIDs: Optional[List[int]] = None,
1045 target_object_IDs: Optional[List[int]] = None,
1046 enable_petiole_collision: bool = False,
1047 enable_fruit_collision: bool = False) -> None:
1048 """
1049 Enable soft collision avoidance for procedural plant growth.
1050
1051 This method enables the collision detection system that guides plant growth away from
1052 obstacles and other plants. The system uses cone-based gap detection to find optimal
1053 growth directions that minimize collisions while maintaining natural plant architecture.
1054
1055 Args:
1056 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
1057 avoids all geometry in the context.
1058 target_object_IDs: List of compound object IDs to avoid collisions with.
1059 enable_petiole_collision: Enable collision detection for leaf petioles
1060 enable_fruit_collision: Enable collision detection for fruit organs
1061
1062 Raises:
1063 PlantArchitectureError: If collision detection activation fails
1064
1065 Note:
1066 Collision detection adds computational overhead. Use setStaticObstacles() to mark
1067 static geometry for BVH optimization and improved performance.
1068
1069 Example:
1070 >>> # Avoid all geometry
1071 >>> plantarch.enableSoftCollisionAvoidance()
1072 >>>
1073 >>> # Avoid specific obstacles
1074 >>> obstacle_uuids = context.getAllUUIDs()
1075 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
1076 >>>
1077 >>> # Enable collision detection for petioles and fruit
1078 >>> plantarch.enableSoftCollisionAvoidance(
1079 ... enable_petiole_collision=True,
1080 ... enable_fruit_collision=True
1081 ... )
1082 """
1083 try:
1085 plantarch_wrapper.enableSoftCollisionAvoidance(
1086 self._plantarch_ptr,
1087 target_UUIDs=target_object_UUIDs,
1088 target_IDs=target_object_IDs,
1089 enable_petiole=enable_petiole_collision,
1090 enable_fruit=enable_fruit_collision
1091 )
1092 except Exception as e:
1093 raise PlantArchitectureError(f"Failed to enable soft collision avoidance: {e}")
1094
1095 def disableCollisionDetection(self) -> None:
1096 """
1097 Disable collision detection for plant growth.
1098
1099 This method turns off the collision detection system, allowing plants to grow
1100 without checking for obstacles. This improves performance but plants may grow
1101 through obstacles and other geometry.
1102
1103 Raises:
1104 PlantArchitectureError: If disabling fails
1105
1106 Example:
1107 >>> plantarch.disableCollisionDetection()
1108 """
1109 try:
1110 plantarch_wrapper.disableCollisionDetection(self._plantarch_ptr)
1111 except Exception as e:
1112 raise PlantArchitectureError(f"Failed to disable collision detection: {e}")
1113
1115 view_half_angle_deg: float = 80.0,
1116 look_ahead_distance: float = 0.1,
1117 sample_count: int = 256,
1118 inertia_weight: float = 0.4) -> None:
1119 """
1120 Configure parameters for soft collision avoidance algorithm.
1121
1122 These parameters control the cone-based gap detection algorithm that guides
1123 plant growth away from obstacles. Adjusting these values allows fine-tuning
1124 the balance between collision avoidance and natural growth patterns.
1125
1126 Args:
1127 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
1128 Default 80° provides wide field of view.
1129 look_ahead_distance: Distance to look ahead for collisions in meters.
1130 Larger values detect distant obstacles. Default 0.1m.
1131 sample_count: Number of ray samples within cone. More samples improve
1132 accuracy but reduce performance. Default 256.
1133 inertia_weight: Weight for previous growth direction (0-1). Higher values
1134 make growth smoother but less responsive. Default 0.4.
1135
1136 Raises:
1137 ValueError: If parameters are outside valid ranges
1138 PlantArchitectureError: If parameter setting fails
1139
1140 Example:
1141 >>> # Use default parameters (recommended)
1142 >>> plantarch.setSoftCollisionAvoidanceParameters()
1143 >>>
1144 >>> # Tune for dense canopy with close obstacles
1145 >>> plantarch.setSoftCollisionAvoidanceParameters(
1146 ... view_half_angle_deg=60.0, # Narrower detection cone
1147 ... look_ahead_distance=0.05, # Shorter look-ahead
1148 ... sample_count=512, # More accurate detection
1149 ... inertia_weight=0.3 # More responsive to obstacles
1150 ... )
1151 """
1152 # Validate parameters
1153 if not (0 <= view_half_angle_deg <= 180):
1154 raise ValueError(f"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
1155 if look_ahead_distance <= 0:
1156 raise ValueError(f"look_ahead_distance must be positive, got {look_ahead_distance}")
1157 if sample_count <= 0:
1158 raise ValueError(f"sample_count must be positive, got {sample_count}")
1159 if not (0 <= inertia_weight <= 1):
1160 raise ValueError(f"inertia_weight must be between 0 and 1, got {inertia_weight}")
1161
1162 try:
1163 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
1164 self._plantarch_ptr,
1165 view_half_angle_deg,
1166 look_ahead_distance,
1167 sample_count,
1168 inertia_weight
1169 )
1170 except Exception as e:
1171 raise PlantArchitectureError(f"Failed to set collision avoidance parameters: {e}")
1172
1174 include_internodes: bool = False,
1175 include_leaves: bool = True,
1176 include_petioles: bool = False,
1177 include_flowers: bool = False,
1178 include_fruit: bool = False) -> None:
1179 """
1180 Specify which plant organs participate in collision detection.
1181
1182 This method allows filtering which organs are considered during collision detection,
1183 enabling optimization by excluding organs unlikely to cause problematic collisions.
1184
1185 Args:
1186 include_internodes: Include stem internodes in collision detection
1187 include_leaves: Include leaf blades in collision detection
1188 include_petioles: Include leaf petioles in collision detection
1189 include_flowers: Include flowers in collision detection
1190 include_fruit: Include fruit in collision detection
1191
1192 Raises:
1193 PlantArchitectureError: If organ filtering fails
1194
1195 Example:
1196 >>> # Only detect collisions for stems and leaves (default behavior)
1197 >>> plantarch.setCollisionRelevantOrgans(
1198 ... include_internodes=True,
1199 ... include_leaves=True
1200 ... )
1201 >>>
1202 >>> # Include all organs
1203 >>> plantarch.setCollisionRelevantOrgans(
1204 ... include_internodes=True,
1205 ... include_leaves=True,
1206 ... include_petioles=True,
1207 ... include_flowers=True,
1208 ... include_fruit=True
1209 ... )
1210 """
1211 try:
1212 plantarch_wrapper.setCollisionRelevantOrgans(
1213 self._plantarch_ptr,
1214 include_internodes,
1215 include_leaves,
1216 include_petioles,
1217 include_flowers,
1218 include_fruit
1219 )
1220 except Exception as e:
1221 raise PlantArchitectureError(f"Failed to set collision-relevant organs: {e}")
1222
1224 obstacle_UUIDs: List[int],
1225 avoidance_distance: float = 0.5,
1226 enable_fruit_adjustment: bool = False,
1227 enable_obstacle_pruning: bool = False) -> None:
1228 """
1229 Enable hard obstacle avoidance for specified geometry.
1230
1231 This method configures solid obstacles that plants cannot grow through. Unlike soft
1232 collision avoidance (which guides growth), solid obstacles cause complete growth
1233 termination when encountered within the avoidance distance.
1234
1235 Args:
1236 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
1237 avoidance_distance: Minimum distance to maintain from obstacles (meters).
1238 Growth stops if obstacles are closer. Default 0.5m.
1239 enable_fruit_adjustment: Adjust fruit positions away from obstacles
1240 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
1241
1242 Raises:
1243 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
1244 PlantArchitectureError: If solid obstacle configuration fails
1245
1246 Example:
1247 >>> # Simple solid obstacle avoidance
1248 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
1249 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
1250 >>>
1251 >>> # Close avoidance with fruit adjustment
1252 >>> plantarch.enableSolidObstacleAvoidance(
1253 ... obstacle_UUIDs=wall_uuids,
1254 ... avoidance_distance=0.1,
1255 ... enable_fruit_adjustment=True
1256 ... )
1257 """
1258 if not obstacle_UUIDs:
1259 raise ValueError("Obstacle UUIDs list cannot be empty")
1260 if avoidance_distance <= 0:
1261 raise ValueError(f"avoidance_distance must be positive, got {avoidance_distance}")
1262
1263 try:
1265 plantarch_wrapper.enableSolidObstacleAvoidance(
1266 self._plantarch_ptr,
1267 obstacle_UUIDs,
1268 avoidance_distance,
1269 enable_fruit_adjustment,
1270 enable_obstacle_pruning
1271 )
1272 except Exception as e:
1273 raise PlantArchitectureError(f"Failed to enable solid obstacle avoidance: {e}")
1274
1275 def setStaticObstacles(self, target_UUIDs: List[int]) -> None:
1276 """
1277 Mark geometry as static obstacles for collision detection optimization.
1278
1279 This method tells the collision detection system that certain geometry will not
1280 move during the simulation. The system can then build an optimized Bounding Volume
1281 Hierarchy (BVH) for these obstacles, significantly improving collision detection
1282 performance in scenes with many static obstacles.
1283
1284 Args:
1285 target_UUIDs: List of primitive UUIDs representing static obstacles
1286
1287 Raises:
1288 ValueError: If target_UUIDs is empty
1289 PlantArchitectureError: If static obstacle configuration fails
1290
1291 Note:
1292 Call this method BEFORE enabling collision avoidance for best performance.
1293 Static obstacles cannot be modified or moved after being marked static.
1294
1295 Example:
1296 >>> # Mark ground and building geometry as static
1297 >>> static_uuids = ground_uuids + building_uuids
1298 >>> plantarch.setStaticObstacles(static_uuids)
1299 >>> # Now enable collision avoidance
1300 >>> plantarch.enableSoftCollisionAvoidance()
1301 """
1302 if not target_UUIDs:
1303 raise ValueError("target_UUIDs list cannot be empty")
1304
1305 try:
1307 plantarch_wrapper.setStaticObstacles(self._plantarch_ptr, target_UUIDs)
1308 except Exception as e:
1309 raise PlantArchitectureError(f"Failed to set static obstacles: {e}")
1310
1311 def getPlantCollisionRelevantObjectIDs(self, plant_id: int) -> List[int]:
1312 """
1313 Get object IDs of collision-relevant geometry for a specific plant.
1314
1315 This method returns the subset of plant geometry that participates in collision
1316 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
1317 and debugging collision detection behavior.
1318
1319 Args:
1320 plant_id: ID of the plant instance
1321
1322 Returns:
1323 List of object IDs for collision-relevant plant geometry
1324
1325 Raises:
1326 ValueError: If plant_id is negative
1327 PlantArchitectureError: If retrieval fails
1328
1329 Example:
1330 >>> # Get collision-relevant geometry
1331 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
1332 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
1333 >>>
1334 >>> # Highlight collision geometry in visualization
1335 >>> for obj_id in collision_obj_ids:
1336 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
1337 """
1338 if plant_id < 0:
1339 raise ValueError("Plant ID must be non-negative")
1340
1341 try:
1342 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self._plantarch_ptr, plant_id)
1343 except Exception as e:
1344 raise PlantArchitectureError(f"Failed to get collision-relevant object IDs for plant {plant_id}: {e}")
1345
1346 # File I/O methods
1347 def writePlantMeshVertices(self, plant_id: int, filename: Union[str, Path]) -> None:
1348 """
1349 Write all plant mesh vertices to file for external processing.
1350
1351 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
1352 writing one vertex per line. Useful for external processing such as computing bounding
1353 volumes, convex hulls, or performing custom geometric analysis.
1354
1355 Args:
1356 plant_id: ID of the plant instance to export
1357 filename: Path to output file (absolute or relative to current working directory)
1358
1359 Raises:
1360 ValueError: If plant_id is negative or filename is empty
1361 PlantArchitectureError: If plant doesn't exist or file cannot be written
1362
1363 Example:
1364 >>> # Export vertices for convex hull analysis
1365 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
1366 >>>
1367 >>> # Use with Path object
1368 >>> from pathlib import Path
1369 >>> output_dir = Path("output")
1370 >>> output_dir.mkdir(exist_ok=True)
1371 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
1372 """
1373 if plant_id < 0:
1374 raise ValueError("Plant ID must be non-negative")
1375 if not filename:
1376 raise ValueError("Filename cannot be empty")
1377
1378 # Resolve path before changing directory
1379 absolute_path = _resolve_user_path(filename)
1380
1381 try:
1383 plantarch_wrapper.writePlantMeshVertices(
1384 self._plantarch_ptr, plant_id, absolute_path
1385 )
1386 except Exception as e:
1387 raise PlantArchitectureError(f"Failed to write plant mesh vertices to {filename}: {e}")
1388
1389 def writePlantStructureXML(self, plant_id: int, filename: Union[str, Path]) -> None:
1390 """
1391 Save plant structure to XML file for later loading.
1392
1393 This method exports the complete plant architecture to an XML file, including
1394 all shoots, phytomers, organs, and their properties. The saved plant can be
1395 reloaded later using readPlantStructureXML().
1396
1397 Args:
1398 plant_id: ID of the plant instance to save
1399 filename: Path to output XML file (absolute or relative to current working directory)
1400
1401 Raises:
1402 ValueError: If plant_id is negative or filename is empty
1403 PlantArchitectureError: If plant doesn't exist or file cannot be written
1404
1405 Note:
1406 The XML format preserves the complete plant state including:
1407 - Shoot structure and hierarchy
1408 - Phytomer properties and development stage
1409 - Organ geometry and attributes
1410 - Growth parameters and phenological state
1411
1412 Example:
1413 >>> # Save plant at current growth stage
1414 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
1415 >>>
1416 >>> # Later, reload the saved plant
1417 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
1418 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
1419 """
1420 if plant_id < 0:
1421 raise ValueError("Plant ID must be non-negative")
1422 if not filename:
1423 raise ValueError("Filename cannot be empty")
1424
1425 # Resolve path before changing directory
1426 absolute_path = _resolve_user_path(filename)
1427
1428 try:
1430 plantarch_wrapper.writePlantStructureXML(
1431 self._plantarch_ptr, plant_id, absolute_path
1432 )
1433 except Exception as e:
1434 raise PlantArchitectureError(f"Failed to write plant structure XML to {filename}: {e}")
1435
1436 def writeQSMCylinderFile(self, plant_id: int, filename: Union[str, Path]) -> None:
1437 """
1438 Export plant structure in TreeQSM cylinder format.
1439
1440 This method writes the plant structure as a series of cylinders following the
1441 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
1442 columns for radius, length, start position, axis direction, branch topology,
1443 and other structural properties. Useful for biomechanical analysis and
1444 quantitative structure modeling.
1445
1446 Args:
1447 plant_id: ID of the plant instance to export
1448 filename: Path to output file (absolute or relative, typically .txt extension)
1449
1450 Raises:
1451 ValueError: If plant_id is negative or filename is empty
1452 PlantArchitectureError: If plant doesn't exist or file cannot be written
1453
1454 Note:
1455 The TreeQSM format includes columns for:
1456 - Cylinder dimensions (radius, length)
1457 - Spatial position and orientation
1458 - Branch topology (parent ID, extension ID, branch ID)
1459 - Branch hierarchy (branch order, position in branch)
1460 - Quality metrics (mean absolute distance, surface coverage)
1461
1462 Example:
1463 >>> # Export for biomechanical analysis
1464 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
1465 >>>
1466 >>> # Use with external QSM tools
1467 >>> import pandas as pd
1468 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
1469 >>> print(f"Tree has {len(qsm_data)} cylinders")
1470
1471 References:
1472 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
1473 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
1474 """
1475 if plant_id < 0:
1476 raise ValueError("Plant ID must be non-negative")
1477 if not filename:
1478 raise ValueError("Filename cannot be empty")
1479
1480 # Resolve path before changing directory
1481 absolute_path = _resolve_user_path(filename)
1482
1483 try:
1485 plantarch_wrapper.writeQSMCylinderFile(
1486 self._plantarch_ptr, plant_id, absolute_path
1487 )
1488 except Exception as e:
1489 raise PlantArchitectureError(f"Failed to write QSM cylinder file to {filename}: {e}")
1490
1491 def writePlantStructureUSD(self, plant_id: int, filename: Union[str, Path],
1492 elastic_modulus: float = 5e9,
1493 wood_density: float = 800.0,
1494 damping_ratio: float = 0.1,
1495 static_friction: float = 0.5,
1496 dynamic_friction: float = 0.3,
1497 restitution: float = 0.1,
1498 organ_spring_stiffness: float = 10.0,
1499 organ_spring_damping: float = 1.0,
1500 leaf_mass_per_area: float = 0.05,
1501 fruit_mass: float = 0.01,
1502 flower_mass: float = 0.002,
1503 solver_position_iterations: int = 32,
1504 min_segment_length: float = 0.001) -> None:
1505 """
1506 Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
1507
1508 Each tube segment becomes a capsule-shaped rigid link connected by spherical joints.
1509 Spring/damper drives are derived from beam bending stiffness (E*I/L). Leaves, fruits,
1510 and flowers are represented as mass bodies attached by spring links.
1511
1512 Args:
1513 plant_id: ID of the plant instance to export
1514 filename: Output file path (should have .usda extension)
1515 elastic_modulus: Young's modulus (Pa) for joint stiffness, K = E*I/L
1516 wood_density: Wood density (kg/m^3) used to compute mass from capsule volume
1517 damping_ratio: Joint damping ratio (dimensionless)
1518 static_friction: Static friction coefficient for collision material
1519 dynamic_friction: Dynamic friction coefficient for collision material
1520 restitution: Restitution (bounciness) for collision material
1521 organ_spring_stiffness: Spring stiffness (N*m/rad) for organ attachment joints
1522 organ_spring_damping: Damping (N*m*s/rad) for organ attachment joints
1523 leaf_mass_per_area: Leaf mass per unit area (kg/m^2)
1524 fruit_mass: Mass per fruit (kg)
1525 flower_mass: Mass per flower (kg)
1526 solver_position_iterations: PhysX articulation solver position iteration count
1527 min_segment_length: Minimum segment length (m); shorter segments are skipped
1528
1529 Raises:
1530 ValueError: If plant_id is negative or filename is empty
1531 PlantArchitectureError: If plant doesn't exist or file cannot be written
1532
1533 Example:
1534 >>> plantarch.writePlantStructureUSD(plant_id, "plant.usda")
1535 """
1536 if plant_id < 0:
1537 raise ValueError("Plant ID must be non-negative")
1538 if not filename:
1539 raise ValueError("Filename cannot be empty")
1540
1541 absolute_path = _resolve_user_path(filename)
1542
1543 try:
1545 plantarch_wrapper.writePlantStructureUSD(
1546 self._plantarch_ptr, plant_id, absolute_path,
1547 elastic_modulus, wood_density, damping_ratio,
1548 static_friction, dynamic_friction, restitution,
1549 organ_spring_stiffness, organ_spring_damping,
1550 leaf_mass_per_area, fruit_mass, flower_mass,
1551 solver_position_iterations, min_segment_length
1552 )
1553 except Exception as e:
1554 raise PlantArchitectureError(f"Failed to write plant structure USD to {filename}: {e}")
1555
1556 def registerGrowthFrame(self, plant_id: int, min_segment_length: float = 0.001) -> None:
1557 """
1558 Capture a snapshot of the plant's geometry as a growth animation frame.
1559
1560 Call this after each :meth:`advanceTime` step to record the plant state for later
1561 animation export via :meth:`writePlantGrowthUSD`.
1562
1563 Args:
1564 plant_id: ID of the plant instance to capture
1565 min_segment_length: Minimum segment length (m); shorter segments are skipped
1566
1567 Raises:
1568 ValueError: If plant_id is negative
1569 PlantArchitectureError: If plant doesn't exist
1570 """
1571 if plant_id < 0:
1572 raise ValueError("Plant ID must be non-negative")
1573
1574 try:
1575 plantarch_wrapper.registerGrowthFrame(self._plantarch_ptr, plant_id, min_segment_length)
1576 except Exception as e:
1577 raise PlantArchitectureError(f"Failed to register growth frame for plant {plant_id}: {e}")
1578
1579 def writePlantGrowthUSD(self, plant_id: int, filename: Union[str, Path],
1580 seconds_per_frame: float = 1.0) -> None:
1581 """
1582 Export all registered growth frames as a time-sampled USD animation file.
1583
1584 The resulting file can be imported directly into Blender. This is a visual-only
1585 export — no physics prims, joints, or collision shapes are written.
1586
1587 Args:
1588 plant_id: ID of the plant instance to export
1589 filename: Output file path (should have .usda extension)
1590 seconds_per_frame: Duration in seconds each growth frame occupies (default: 1.0)
1591
1592 Raises:
1593 ValueError: If plant_id is negative or filename is empty
1594 PlantArchitectureError: If plant doesn't exist or file cannot be written
1595 """
1596 if plant_id < 0:
1597 raise ValueError("Plant ID must be non-negative")
1598 if not filename:
1599 raise ValueError("Filename cannot be empty")
1600
1601 absolute_path = _resolve_user_path(filename)
1602
1603 try:
1605 plantarch_wrapper.writePlantGrowthUSD(
1606 self._plantarch_ptr, plant_id, absolute_path, seconds_per_frame
1607 )
1608 except Exception as e:
1609 raise PlantArchitectureError(f"Failed to write plant growth USD to {filename}: {e}")
1610
1611 def clearGrowthFrames(self, plant_id: int) -> None:
1612 """
1613 Clear stored growth animation frames for a plant.
1614
1615 Args:
1616 plant_id: ID of the plant instance whose frames should be cleared
1617
1618 Raises:
1619 ValueError: If plant_id is negative
1620 """
1621 if plant_id < 0:
1622 raise ValueError("Plant ID must be non-negative")
1623
1624 try:
1625 plantarch_wrapper.clearGrowthFrames(self._plantarch_ptr, plant_id)
1626 except Exception as e:
1627 raise PlantArchitectureError(f"Failed to clear growth frames for plant {plant_id}: {e}")
1628
1629 def getGrowthFrameCount(self, plant_id: int) -> int:
1630 """
1631 Get the number of registered growth frames for a plant.
1632
1633 Args:
1634 plant_id: ID of the plant instance to query
1635
1636 Returns:
1637 Number of frames registered via :meth:`registerGrowthFrame`
1638
1639 Raises:
1640 ValueError: If plant_id is negative
1641 """
1642 if plant_id < 0:
1643 raise ValueError("Plant ID must be non-negative")
1644
1645 try:
1646 return plantarch_wrapper.getGrowthFrameCount(self._plantarch_ptr, plant_id)
1647 except Exception as e:
1648 raise PlantArchitectureError(f"Failed to get growth frame count for plant {plant_id}: {e}")
1649
1650 def readPlantStructureXML(self, filename: Union[str, Path], quiet: bool = False) -> List[int]:
1651 """
1652 Load plant structure from XML file.
1653
1654 This method reads plant architecture data from an XML file previously saved with
1655 writePlantStructureXML(). The loaded plants are added to the current context
1656 and can be grown, modified, or analyzed like any other plants.
1657
1658 Args:
1659 filename: Path to XML file to load (absolute or relative to current working directory)
1660 quiet: If True, suppress console output during loading (default: False)
1661
1662 Returns:
1663 List of plant IDs for the loaded plant instances
1664
1665 Raises:
1666 ValueError: If filename is empty
1667 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
1668
1669 Note:
1670 The XML file can contain multiple plant instances. All plants in the file
1671 will be loaded and their IDs returned in a list. Plant models referenced
1672 in the XML must be available in the plant library.
1673
1674 Example:
1675 >>> # Load previously saved plants
1676 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
1677 >>> print(f"Loaded {len(plant_ids)} plants")
1678 >>>
1679 >>> # Continue growing the loaded plants
1680 >>> plantarch.advanceTime(10.0)
1681 >>>
1682 >>> # Load quietly without console messages
1683 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
1684 """
1685 if not filename:
1686 raise ValueError("Filename cannot be empty")
1687
1688 # Resolve path before changing directory
1689 absolute_path = _resolve_user_path(filename)
1690
1691 try:
1693 return plantarch_wrapper.readPlantStructureXML(
1694 self._plantarch_ptr, absolute_path, quiet
1695 )
1696 except Exception as e:
1697 raise PlantArchitectureError(f"Failed to read plant structure XML from {filename}: {e}")
1698
1699 # Custom plant building methods
1700 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
1701 """
1702 Create an empty plant instance for custom plant building.
1703
1704 This method creates a new plant instance at the specified location without any
1705 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
1706 manually construct the plant structure. This provides low-level control over
1707 plant architecture, enabling custom morphologies not available in the plant library.
1708
1709 Args:
1710 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
1711 current_age: Current age of the plant in days (must be >= 0)
1712
1713 Returns:
1714 Plant ID for the created plant instance
1715
1716 Raises:
1717 ValueError: If age is negative
1718 PlantArchitectureError: If plant creation fails
1719
1720 Example:
1721 >>> # Create empty plant at origin
1722 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1723 >>>
1724 >>> # Now add shoots to build custom plant structure
1725 >>> shoot_id = plantarch.addBaseStemShoot(
1726 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
1727 ... )
1728 """
1729 # Parameter type validation
1730 if not isinstance(base_position, vec3):
1731 raise ValueError(f"base_position must be a vec3, got {type(base_position).__name__}")
1733 # Convert position to list for C++ interface
1734 position_list = [base_position.x, base_position.y, base_position.z]
1735
1736 # Validate age
1737 if current_age < 0:
1738 raise ValueError(f"Age must be non-negative, got {current_age}")
1739
1740 try:
1742 return plantarch_wrapper.addPlantInstance(
1743 self._plantarch_ptr, position_list, current_age
1744 )
1745 except Exception as e:
1746 raise PlantArchitectureError(f"Failed to add plant instance: {e}")
1747
1748 def deletePlantInstance(self, plant_id: int) -> None:
1749 """
1750 Delete a plant instance and all associated geometry.
1751
1752 This method removes a plant from the simulation, deleting all shoots, organs,
1753 and associated primitives from the context. The plant ID becomes invalid after
1754 deletion and should not be used in subsequent operations.
1755
1756 Args:
1757 plant_id: ID of the plant instance to delete
1758
1759 Raises:
1760 ValueError: If plant_id is negative
1761 PlantArchitectureError: If plant deletion fails or plant doesn't exist
1762
1763 Example:
1764 >>> # Delete a plant
1765 >>> plantarch.deletePlantInstance(plant_id)
1766 >>>
1767 >>> # Delete multiple plants
1768 >>> for pid in plant_ids_to_remove:
1769 ... plantarch.deletePlantInstance(pid)
1770 """
1771 if plant_id < 0:
1772 raise ValueError("Plant ID must be non-negative")
1773
1774 try:
1776 plantarch_wrapper.deletePlantInstance(self._plantarch_ptr, plant_id)
1777 except Exception as e:
1778 raise PlantArchitectureError(f"Failed to delete plant instance {plant_id}: {e}")
1779
1780 def addBaseStemShoot(self,
1781 plant_id: int,
1782 current_node_number: int,
1783 base_rotation: AxisRotation,
1784 internode_radius: float,
1785 internode_length_max: float,
1786 internode_length_scale_factor_fraction: float,
1787 leaf_scale_factor_fraction: float,
1788 radius_taper: float,
1789 shoot_type_label: str) -> int:
1790 """
1791 Add a base stem shoot to a plant instance (main trunk/stem).
1792
1793 This method creates the primary shoot originating from the plant base. The base stem
1794 is typically the main trunk or primary stem from which all other shoots branch.
1795 Specify growth parameters to control the shoot's morphology and development.
1796
1797 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
1798 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
1799 shoot types that can then be used for custom building. The shoot_type_label must match a
1800 shoot type defined in the loaded model.
1801
1802 Args:
1803 plant_id: ID of the plant instance
1804 current_node_number: Starting node number for this shoot (typically 1)
1805 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1806 internode_radius: Base radius of internodes in meters (must be > 0)
1807 internode_length_max: Maximum internode length in meters (must be > 0)
1808 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1809 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1810 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1811 shoot_type_label: Label identifying shoot type - must match a type from loaded model
1812
1813 Returns:
1814 Shoot ID for the created shoot
1815
1816 Raises:
1817 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1818 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
1819
1820 Example:
1821 >>> from pyhelios import AxisRotation
1822 >>>
1823 >>> # REQUIRED: Load a plant model to define shoot types
1824 >>> plantarch.loadPlantModelFromLibrary("bean")
1825 >>>
1826 >>> # Create empty plant for custom building
1827 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1828 >>>
1829 >>> # Add base stem using shoot type from loaded model
1830 >>> shoot_id = plantarch.addBaseStemShoot(
1831 ... plant_id=plant_id,
1832 ... current_node_number=1,
1833 ... base_rotation=AxisRotation(0, 0, 0), # Upright
1834 ... internode_radius=0.01, # 1cm radius
1835 ... internode_length_max=0.1, # 10cm max length
1836 ... internode_length_scale_factor_fraction=1.0,
1837 ... leaf_scale_factor_fraction=1.0,
1838 ... radius_taper=0.9, # Gradual taper
1839 ... shoot_type_label="stem" # Must match loaded model
1840 ... )
1841 """
1842 if plant_id < 0:
1843 raise ValueError("Plant ID must be non-negative")
1844 if current_node_number < 0:
1845 raise ValueError("Current node number must be non-negative")
1846 if internode_radius <= 0:
1847 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
1848 if internode_length_max <= 0:
1849 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
1850 if not shoot_type_label or not shoot_type_label.strip():
1851 raise ValueError("Shoot type label cannot be empty")
1852
1853 # Convert rotation to list for C++ interface
1854 rotation_list = base_rotation.to_list()
1855
1856 try:
1858 return plantarch_wrapper.addBaseStemShoot(
1859 self._plantarch_ptr, plant_id, current_node_number, rotation_list,
1860 internode_radius, internode_length_max,
1861 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1862 radius_taper, shoot_type_label.strip()
1863 )
1864 except Exception as e:
1865 error_msg = str(e)
1866 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
1868 f"Shoot type '{shoot_type_label}' not defined. "
1869 f"Load a plant model first to define shoot types:\n"
1870 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1871 f"Original error: {e}"
1872 )
1873 raise PlantArchitectureError(f"Failed to add base stem shoot: {e}")
1874
1875 def appendShoot(self,
1876 plant_id: int,
1877 parent_shoot_id: int,
1878 current_node_number: int,
1879 base_rotation: AxisRotation,
1880 internode_radius: float,
1881 internode_length_max: float,
1882 internode_length_scale_factor_fraction: float,
1883 leaf_scale_factor_fraction: float,
1884 radius_taper: float,
1885 shoot_type_label: str) -> int:
1886 """
1887 Append a shoot to the end of an existing shoot.
1888
1889 This method extends an existing shoot by appending a new shoot at its terminal bud.
1890 Useful for creating multi-segmented shoots with varying properties along their length,
1891 such as shoots with different growth phases or developmental stages.
1892
1893 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1894 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1895 calling this method.
1896
1897 Args:
1898 plant_id: ID of the plant instance
1899 parent_shoot_id: ID of the parent shoot to extend
1900 current_node_number: Starting node number for this shoot
1901 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1902 internode_radius: Base radius of internodes in meters (must be > 0)
1903 internode_length_max: Maximum internode length in meters (must be > 0)
1904 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1905 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1906 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1907 shoot_type_label: Label identifying shoot type - must match loaded model
1908
1909 Returns:
1910 Shoot ID for the appended shoot
1911
1912 Raises:
1913 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1914 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
1915
1916 Example:
1917 >>> # Load model to define shoot types
1918 >>> plantarch.loadPlantModelFromLibrary("bean")
1919 >>>
1920 >>> # Append shoot with reduced size to simulate apical growth
1921 >>> new_shoot_id = plantarch.appendShoot(
1922 ... plant_id=plant_id,
1923 ... parent_shoot_id=base_shoot_id,
1924 ... current_node_number=10,
1925 ... base_rotation=AxisRotation(0, 0, 0),
1926 ... internode_radius=0.008, # Smaller than base
1927 ... internode_length_max=0.08, # Shorter internodes
1928 ... internode_length_scale_factor_fraction=1.0,
1929 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
1930 ... radius_taper=0.85,
1931 ... shoot_type_label="stem"
1932 ... )
1933 """
1934 if plant_id < 0:
1935 raise ValueError("Plant ID must be non-negative")
1936 if parent_shoot_id < 0:
1937 raise ValueError("Parent shoot ID must be non-negative")
1938 if current_node_number < 0:
1939 raise ValueError("Current node number must be non-negative")
1940 if internode_radius <= 0:
1941 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
1942 if internode_length_max <= 0:
1943 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
1944 if not shoot_type_label or not shoot_type_label.strip():
1945 raise ValueError("Shoot type label cannot be empty")
1946
1947 # Convert rotation to list for C++ interface
1948 rotation_list = base_rotation.to_list()
1949
1950 try:
1952 return plantarch_wrapper.appendShoot(
1953 self._plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
1954 rotation_list, internode_radius, internode_length_max,
1955 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1956 radius_taper, shoot_type_label.strip()
1957 )
1958 except Exception as e:
1959 error_msg = str(e)
1960 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
1962 f"Shoot type '{shoot_type_label}' not defined. "
1963 f"Load a plant model first to define shoot types:\n"
1964 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1965 f"Original error: {e}"
1966 )
1967 raise PlantArchitectureError(f"Failed to append shoot: {e}")
1968
1969 def addChildShoot(self,
1970 plant_id: int,
1971 parent_shoot_id: int,
1972 parent_node_index: int,
1973 current_node_number: int,
1974 shoot_base_rotation: AxisRotation,
1975 internode_radius: float,
1976 internode_length_max: float,
1977 internode_length_scale_factor_fraction: float,
1978 leaf_scale_factor_fraction: float,
1979 radius_taper: float,
1980 shoot_type_label: str,
1981 petiole_index: int = 0) -> int:
1982 """
1983 Add a child shoot at an axillary bud position on a parent shoot.
1984
1985 This method creates a lateral branch shoot emerging from a specific node on the
1986 parent shoot. Child shoots enable creation of branching architectures, with control
1987 over branch angle, size, and which petiole position the branch emerges from (for
1988 plants with multiple petioles per node).
1989
1990 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1991 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1992 calling this method.
1993
1994 Args:
1995 plant_id: ID of the plant instance
1996 parent_shoot_id: ID of the parent shoot
1997 parent_node_index: Index of the parent node where child emerges (0-based)
1998 current_node_number: Starting node number for this child shoot
1999 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
2000 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
2001 internode_length_max: Maximum internode length in meters (must be > 0)
2002 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
2003 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
2004 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
2005 shoot_type_label: Label identifying shoot type - must match loaded model
2006 petiole_index: Which petiole at the node to branch from (default: 0)
2007
2008 Returns:
2009 Shoot ID for the created child shoot
2010
2011 Raises:
2012 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
2013 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
2014
2015 Example:
2016 >>> # Load model to define shoot types
2017 >>> plantarch.loadPlantModelFromLibrary("bean")
2018 >>>
2019 >>> # Add lateral branch at 45-degree angle from node 3
2020 >>> branch_id = plantarch.addChildShoot(
2021 ... plant_id=plant_id,
2022 ... parent_shoot_id=main_shoot_id,
2023 ... parent_node_index=3,
2024 ... current_node_number=1,
2025 ... shoot_base_rotation=AxisRotation(45, 90, 0), # 45° out, 90° rotation
2026 ... internode_radius=0.005, # Thinner than main stem
2027 ... internode_length_max=0.06, # Shorter internodes
2028 ... internode_length_scale_factor_fraction=1.0,
2029 ... leaf_scale_factor_fraction=0.9,
2030 ... radius_taper=0.8,
2031 ... shoot_type_label="stem"
2032 ... )
2033 >>>
2034 >>> # Add second branch from opposite petiole
2035 >>> branch_id2 = plantarch.addChildShoot(
2036 ... plant_id, main_shoot_id, 3, 1, AxisRotation(45, 270, 0),
2037 ... 0.005, 0.06, 1.0, 0.9, 0.8, "stem", petiole_index=1
2038 ... )
2039 """
2040 if plant_id < 0:
2041 raise ValueError("Plant ID must be non-negative")
2042 if parent_shoot_id < 0:
2043 raise ValueError("Parent shoot ID must be non-negative")
2044 if parent_node_index < 0:
2045 raise ValueError("Parent node index must be non-negative")
2046 if current_node_number < 0:
2047 raise ValueError("Current node number must be non-negative")
2048 if internode_radius <= 0:
2049 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
2050 if internode_length_max <= 0:
2051 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
2052 if not shoot_type_label or not shoot_type_label.strip():
2053 raise ValueError("Shoot type label cannot be empty")
2054 if petiole_index < 0:
2055 raise ValueError(f"Petiole index must be non-negative, got {petiole_index}")
2056
2057 # Convert rotation to list for C++ interface
2058 rotation_list = shoot_base_rotation.to_list()
2059
2060 try:
2062 return plantarch_wrapper.addChildShoot(
2063 self._plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
2064 current_node_number, rotation_list, internode_radius,
2065 internode_length_max, internode_length_scale_factor_fraction,
2066 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
2067 petiole_index
2068 )
2069 except Exception as e:
2070 error_msg = str(e)
2071 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
2073 f"Shoot type '{shoot_type_label}' not defined. "
2074 f"Load a plant model first to define shoot types:\n"
2075 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
2076 f"Original error: {e}"
2077 )
2078 raise PlantArchitectureError(f"Failed to add child shoot: {e}")
2079
2080 def is_available(self) -> bool:
2081 """
2082 Check if PlantArchitecture is available in current build.
2083
2084 Returns:
2085 True if plugin is available, False otherwise
2086 """
2088
2089
2090# Convenience function
2091def create_plant_architecture(context: Context) -> PlantArchitecture:
2092 """
2093 Create PlantArchitecture instance with context.
2094
2095 Args:
2096 context: Helios Context
2097
2098 Returns:
2099 PlantArchitecture instance
2100
2101 Example:
2102 >>> context = Context()
2103 >>> plantarch = create_plant_architecture(context)
2104 """
2105 return PlantArchitecture(context)
Raised when PlantArchitecture operations fail.
High-level interface for plant architecture modeling and procedural plant generation.
None clearGrowthFrames(self, int plant_id)
Clear stored growth animation frames for a plant.
None writePlantMeshVertices(self, int plant_id, Union[str, Path] filename)
Write all plant mesh vertices to file for external processing.
None setCollisionRelevantOrgans(self, bool include_internodes=False, bool include_leaves=True, bool include_petioles=False, bool include_flowers=False, bool include_fruit=False)
Specify which plant organs participate in collision detection.
bool is_available(self)
Check if PlantArchitecture is available in current build.
None registerGrowthFrame(self, int plant_id, float min_segment_length=0.001)
Capture a snapshot of the plant's geometry as a growth animation frame.
None advanceTime(self, float dt)
Advance time for plant growth and development.
List[float] getShootInternodeRadii(self, int plant_id, int shoot_id)
Get the per-vertex woody internode radii of a shoot.
List[int] getAllPlantObjectIDs(self, int plant_id)
Get all object IDs for a specific plant.
int addChildShoot(self, int plant_id, int parent_shoot_id, int parent_node_index, int current_node_number, AxisRotation shoot_base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label, int petiole_index=0)
Add a child shoot at an axillary bud position on a parent shoot.
List[int] buildPlantCanopyFromLibrary(self, vec3 canopy_center, vec2 plant_spacing, int2 plant_count, float age, float germination_rate=1.0, Optional[dict] build_parameters=None)
Build a canopy of regularly spaced plants from the currently loaded library model.
None defineShootType(self, str shoot_type_label, dict parameters)
Define a custom shoot type with specified parameters.
None enableSolidObstacleAvoidance(self, List[int] obstacle_UUIDs, float avoidance_distance=0.5, bool enable_fruit_adjustment=False, bool enable_obstacle_pruning=False)
Enable hard obstacle avoidance for specified geometry.
None setPlantPhenologicalThresholds(self, int plant_id, float time_to_dormancy_break, float time_to_flower_initiation, float time_to_flower_opening, float time_to_fruit_set, float time_to_fruit_maturity, float time_to_dormancy, float max_leaf_lifespan=1e6)
Set phenological timing thresholds for plant developmental stages.
float getPlantHeight(self, int plant_id)
Get the height of a plant in meters.
int getGrowthFrameCount(self, int plant_id)
Get the number of registered growth frames for a plant.
None deletePlantInstance(self, int plant_id)
Delete a plant instance and all associated geometry.
List[int] readPlantStructureXML(self, Union[str, Path] filename, bool quiet=False)
Load plant structure from XML file.
__exit__(self, exc_type, exc_val, exc_tb)
Context manager exit - cleanup resources.
float getPlantAge(self, int plant_id)
Get the current age of a plant in days.
int buildPlantInstanceFromLibrary(self, vec3 base_position, float age, Optional[dict] build_parameters=None)
Build a plant instance from the currently loaded library model.
None writePlantStructureXML(self, int plant_id, Union[str, Path] filename)
Save plant structure to XML file for later loading.
None setStaticObstacles(self, List[int] target_UUIDs)
Mark geometry as static obstacles for collision detection optimization.
List[int] getAllShootIDs(self, int plant_id)
Get the IDs of all shoots belonging to a plant.
List[int] getAllPlantUUIDs(self, int plant_id, bool include_hidden=False)
Get all primitive UUIDs for a specific plant.
None loadPlantModelFromLibrary(self, str plant_label)
Load a plant model from the built-in library.
None disableCollisionDetection(self)
Disable collision detection for plant growth.
int addBaseStemShoot(self, int plant_id, int current_node_number, AxisRotation base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label)
Add a base stem shoot to a plant instance (main trunk/stem).
dict getCurrentShootParameters(self, str shoot_type_label)
Get current shoot parameters for a shoot type.
Dict[str, Any] getShoot(self, int plant_id, int shoot_id)
Get a read-only view of a shoot's topology.
List[int] getPlantCollisionRelevantObjectIDs(self, int plant_id)
Get object IDs of collision-relevant geometry for a specific plant.
List[int] getShootChildIDs(self, int plant_id, int shoot_id)
Get the child shoot IDs of a shoot (flattened across parent node indices).
int appendShoot(self, int plant_id, int parent_shoot_id, int current_node_number, AxisRotation base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label)
Append a shoot to the end of an existing shoot.
None enableSoftCollisionAvoidance(self, Optional[List[int]] target_object_UUIDs=None, Optional[List[int]] target_object_IDs=None, bool enable_petiole_collision=False, bool enable_fruit_collision=False)
Enable soft collision avoidance for procedural plant growth.
setProgressCallback(self, callback)
Set a callback to receive progress updates during long-running operations.
None writePlantGrowthUSD(self, int plant_id, Union[str, Path] filename, float seconds_per_frame=1.0)
Export all registered growth frames as a time-sampled USD animation file.
List[str] getAvailablePlantModels(self)
Get list of all available plant models in the library.
None writeQSMCylinderFile(self, int plant_id, Union[str, Path] filename)
Export plant structure in TreeQSM cylinder format.
int addPlantInstance(self, vec3 base_position, float current_age)
Create an empty plant instance for custom plant building.
List[tuple] getShootInternodeVertices(self, int plant_id, int shoot_id)
Get the woody internode polyline vertices of a shoot as a list of (x, y, z) tuples.
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
float getPlantLeafArea(self, int plant_id)
Get the total leaf area of a plant in m².
None writePlantStructureUSD(self, int plant_id, Union[str, Path] filename, float elastic_modulus=5e9, float wood_density=800.0, float damping_ratio=0.1, float static_friction=0.5, float dynamic_friction=0.3, float restitution=0.1, float organ_spring_stiffness=10.0, float organ_spring_damping=1.0, float leaf_mass_per_area=0.05, float fruit_mass=0.01, float flower_mass=0.002, int solver_position_iterations=32, float min_segment_length=0.001)
Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
__init__(self, Context context)
Initialize PlantArchitecture with a Helios context.
None setSoftCollisionAvoidanceParameters(self, float view_half_angle_deg=80.0, float look_ahead_distance=0.1, int sample_count=256, float inertia_weight=0.4)
Configure parameters for soft collision avoidance algorithm.
Helper class for creating RandomParameter specifications for integer parameters.
Dict[str, Any] uniform(int min_val, int max_val)
Create a uniform distribution for integer parameter.
Dict[str, Any] discrete(List[int] values)
Create a discrete value distribution (random choice from list).
Dict[str, Any] constant(int value)
Create a constant (non-random) integer parameter.
Helper class for creating RandomParameter specifications for float parameters.
Dict[str, Any] normal(float mean, float std_dev)
Create a normal (Gaussian) distribution parameter.
Dict[str, Any] uniform(float min_val, float max_val)
Create a uniform distribution parameter.
Dict[str, Any] constant(float value)
Create a constant (non-random) parameter.
Dict[str, Any] weibull(float shape, float scale)
Create a Weibull distribution parameter.
validate_vec3(value, name, func)
validate_int2(value, name, func)
str _resolve_user_path(Union[str, Path] filepath)
Convert relative paths to absolute paths before changing working directory.
validate_vec2(value, name, func)
PlantArchitecture create_plant_architecture(Context context)
Create PlantArchitecture instance with context.
is_plantarchitecture_available()
Check if PlantArchitecture plugin is available for use.
_plantarchitecture_working_directory()
Context manager that temporarily changes working directory to where PlantArchitecture assets are loca...