2High-level PlantArchitecture interface for PyHelios.
4This module provides a user-friendly interface to the plant architecture modeling
5capabilities with graceful plugin handling and informative error messages.
10from contextlib
import contextmanager
11from pathlib
import Path
12from typing
import List, Optional, Union, Dict, Any
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
19 from .validation.datatypes
import validate_vec3, validate_vec2, validate_int2
23 if hasattr(value,
'x')
and hasattr(value,
'y')
and hasattr(value,
'z'):
25 if isinstance(value, (list, tuple))
and len(value) == 3:
26 from .wrappers.DataTypes
import vec3
28 raise ValueError(f
"{name} must be vec3 or 3-element list/tuple")
31 if hasattr(value,
'x')
and hasattr(value,
'y'):
33 if isinstance(value, (list, tuple))
and len(value) == 2:
34 from .wrappers.DataTypes
import vec2
36 raise ValueError(f
"{name} must be vec2 or 2-element list/tuple")
39 if hasattr(value,
'x')
and hasattr(value,
'y'):
41 if isinstance(value, (list, tuple))
and len(value) == 2:
42 from .wrappers.DataTypes
import int2
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
48logger = logging.getLogger(__name__)
53 Helper class for creating RandomParameter specifications for float parameters.
55 Provides convenient static methods to create parameter dictionaries with
56 statistical distributions for plant architecture modeling.
60 def constant(value: float) -> Dict[str, Any]:
62 Create a constant (non-random) parameter.
65 value: The constant value
68 Dict with constant distribution specification
71 >>> param = RandomParameter.constant(45.0)
72 >>> # Returns: {'distribution': 'constant', 'parameters': [45.0]}
74 return {
'distribution':
'constant',
'parameters': [float(value)]}
77 def uniform(min_val: float, max_val: float) -> Dict[str, Any]:
79 Create a uniform distribution parameter.
82 min_val: Minimum value
83 max_val: Maximum value
86 Dict with uniform distribution specification
89 ValueError: If min_val > max_val
92 >>> param = RandomParameter.uniform(40.0, 50.0)
93 >>> # Returns: {'distribution': 'uniform', 'parameters': [40.0, 50.0]}
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)]}
100 def normal(mean: float, std_dev: float) -> Dict[str, Any]:
102 Create a normal (Gaussian) distribution parameter.
106 std_dev: Standard deviation
109 Dict with normal distribution specification
112 ValueError: If std_dev < 0
115 >>> param = RandomParameter.normal(45.0, 5.0)
116 >>> # Returns: {'distribution': 'normal', 'parameters': [45.0, 5.0]}
119 raise ValueError(f
"std_dev ({std_dev}) must be >= 0")
120 return {
'distribution':
'normal',
'parameters': [float(mean), float(std_dev)]}
123 def weibull(shape: float, scale: float) -> Dict[str, Any]:
125 Create a Weibull distribution parameter.
128 shape: Shape parameter (k)
129 scale: Scale parameter (λ)
132 Dict with Weibull distribution specification
135 ValueError: If shape or scale <= 0
138 >>> param = RandomParameter.weibull(2.0, 50.0)
139 >>> # Returns: {'distribution': 'weibull', 'parameters': [2.0, 50.0]}
142 raise ValueError(f
"shape ({shape}) must be > 0")
144 raise ValueError(f
"scale ({scale}) must be > 0")
145 return {
'distribution':
'weibull',
'parameters': [float(shape), float(scale)]}
150 Helper class for creating RandomParameter specifications for integer parameters.
152 Provides convenient static methods to create parameter dictionaries with
153 statistical distributions for integer-valued plant parameters.
157 def constant(value: int) -> Dict[str, Any]:
159 Create a constant (non-random) integer parameter.
162 value: The constant integer value
165 Dict with constant distribution specification
168 >>> param = RandomParameterInt.constant(15)
169 >>> # Returns: {'distribution': 'constant', 'parameters': [15.0]}
171 return {
'distribution':
'constant',
'parameters': [float(value)]}
174 def uniform(min_val: int, max_val: int) -> Dict[str, Any]:
176 Create a uniform distribution for integer parameter.
179 min_val: Minimum value (inclusive)
180 max_val: Maximum value (inclusive)
183 Dict with uniform distribution specification
186 ValueError: If min_val > max_val
189 >>> param = RandomParameterInt.uniform(10, 20)
190 >>> # Returns: {'distribution': 'uniform', 'parameters': [10.0, 20.0]}
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)]}
197 def discrete(values: List[int]) -> Dict[str, Any]:
199 Create a discrete value distribution (random choice from list).
202 values: List of possible integer values (equal probability)
205 Dict with discrete distribution specification
208 ValueError: If values list is empty
211 >>> param = RandomParameterInt.discrete([1, 2, 3, 5])
212 >>> # Returns: {'distribution': 'discretevalues', 'parameters': [1.0, 2.0, 3.0, 5.0]}
215 raise ValueError(
"values list cannot be empty")
216 return {
'distribution':
'discretevalues',
'parameters': [float(v)
for v
in values]}
221 Convert relative paths to absolute paths before changing working directory.
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.
227 filepath: File path to resolve (string or Path object)
230 Absolute path as string
232 path = Path(filepath)
233 if not path.is_absolute():
234 return str(Path.cwd() / path)
241 Context manager that temporarily changes working directory to where PlantArchitecture assets are located.
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.
248 RuntimeError: If build directory or PlantArchitecture assets are not found, indicating a build system error.
252 asset_manager = get_asset_manager()
253 working_dir = asset_manager._get_helios_build_path()
255 if working_dir
and working_dir.exists():
256 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
259 current_dir = Path(__file__).parent
260 packaged_build = current_dir /
'assets' /
'build'
262 if packaged_build.exists():
263 working_dir = packaged_build
264 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
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'
272 if not build_lib_dir.exists():
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"
279 if not plantarch_assets.exists():
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"
287 assets_dir = plantarch_assets /
'assets'
288 if not assets_dir.exists():
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"
296 original_dir = os.getcwd()
298 os.chdir(working_dir)
299 logger.debug(f
"Changed working directory to {working_dir} for PlantArchitecture asset access")
302 os.chdir(original_dir)
303 logger.debug(f
"Restored working directory to {original_dir}")
307 """Raised when PlantArchitecture operations fail."""
313 Check if PlantArchitecture plugin is available for use.
316 bool: True if PlantArchitecture can be used, False otherwise
320 plugin_registry = get_plugin_registry()
321 if not plugin_registry.is_plugin_available(
'plantarchitecture'):
325 if not plantarch_wrapper._PLANTARCHITECTURE_FUNCTIONS_AVAILABLE:
335 High-level interface for plant architecture modeling and procedural plant generation.
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.
342 This class requires the native Helios library built with PlantArchitecture support.
343 Use context managers for proper resource cleanup.
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
353 def __new__(cls, context=None):
355 Create PlantArchitecture instance.
356 Explicit __new__ to prevent ctypes contamination on Windows.
358 return object.__new__(cls)
360 def __init__(self, context: Context):
362 Initialize PlantArchitecture with a Helios context.
365 context: Active Helios Context instance
368 PlantArchitectureError: If plugin not available in current build
369 RuntimeError: If plugin initialization fails
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"
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"
384 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
385 "rice, soybean, tomato, wheat, and many others."
393 self.
_plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
399 """Context manager entry"""
402 def __exit__(self, exc_type, exc_val, exc_tb):
403 """Context manager exit - cleanup resources"""
409 """Destructor to ensure C++ resources freed even without 'with' statement."""
410 if hasattr(self,
'_plantarch_ptr')
and self.
_plantarch_ptr is not None:
414 except Exception
as e:
416 warnings.warn(f
"Error in PlantArchitecture.__del__: {e}")
420 Load a plant model from the built-in library.
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"
430 ValueError: If plant_label is empty or invalid
431 PlantArchitectureError: If model loading fails
434 >>> plantarch.loadPlantModelFromLibrary("bean")
435 >>> plantarch.loadPlantModelFromLibrary("almond")
438 raise ValueError(
"Plant label cannot be empty")
440 if not plant_label.strip():
441 raise ValueError(
"Plant label cannot be only whitespace")
445 plantarch_wrapper.loadPlantModelFromLibrary(self.
_plantarch_ptr, plant_label.strip())
446 except Exception
as e:
450 build_parameters: Optional[dict] =
None) -> int:
452 Build a plant instance from the currently loaded library model.
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.
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
464 Plant ID for the created plant instance
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
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),
477 ... build_parameters={'trunk_height': 2.0}
481 if not isinstance(base_position, vec3):
482 raise ValueError(f
"base_position must be a vec3, got {type(base_position).__name__}")
485 position_list = [base_position.x, base_position.y, base_position.z]
489 raise ValueError(f
"Age must be non-negative, got {age}")
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)")
503 return plantarch_wrapper.buildPlantInstanceFromLibrary(
506 except Exception
as e:
511 plant_count: int2, age: float,
512 germination_rate: float = 1.0,
513 build_parameters: Optional[dict] =
None) -> List[int]:
515 Build a canopy of regularly spaced plants from the currently loaded library model.
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.
528 - {'cordon_height': 1.8} - for grapevine trellis height
529 - {'trunk_height': 2.5} - for tomato trellis systems
532 List of plant IDs for the created plant instances
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
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),
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),
553 ... germination_rate=0.8,
554 ... build_parameters={'cordon_height': 1.8}
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__}")
567 raise ValueError(f
"Age must be non-negative, got {age}")
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}")
576 if plant_count.x <= 0
or plant_count.y <= 0:
577 raise ValueError(
"Plant count values must be positive integers")
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)")
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]
596 return plantarch_wrapper.buildPlantCanopyFromLibrary(
598 germination_rate, build_parameters
600 except Exception
as e:
605 Advance time for plant growth and development.
607 This method updates all plants in the simulation, potentially adding new phytomers,
608 growing existing organs, transitioning phenological stages, and updating plant geometry.
611 dt: Time step to advance in days (must be >= 0)
614 ValueError: If dt is negative
615 PlantArchitectureError: If time advancement fails
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
623 >>> plantarch.advanceTime(10.0) # Advance 10 days
624 >>> plantarch.advanceTime(0.5) # Advance 12 hours
628 raise ValueError(f
"Time step must be non-negative, got {dt}")
633 except Exception
as e:
637 """Set a callback to receive progress updates during long-running operations.
639 The callback fires during advanceTime() and adjustFruitForObstacleCollision()
640 as the underlying ProgressBar updates.
643 callback: A callable(progress: float, message: str) where progress is
644 in [0, 1], or None to clear the callback.
647 ValueError: If callback is not callable and not None.
649 if callback
is not None:
650 if not callable(callback):
652 f
"callback must be callable or None, got {type(callback).__name__}"
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)
667 Get current shoot parameters for a shoot type.
669 Returns a nested dictionary containing all shoot and phytomer parameters
670 including RandomParameter specifications with distribution types.
673 shoot_type_label: Label for the shoot type (e.g., "stem", "branch")
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
683 ValueError: If shoot_type_label is empty
684 PlantArchitectureError: If parameter retrieval fails
687 >>> plantarch.loadPlantModelFromLibrary("bean")
688 >>> params = plantarch.getCurrentShootParameters("stem")
689 >>> print(params['max_nodes'])
690 {'distribution': 'constant', 'parameters': [15.0]}
692 if not shoot_type_label:
693 raise ValueError(
"Shoot type label cannot be empty")
695 if not shoot_type_label.strip():
696 raise ValueError(
"Shoot type label cannot be only whitespace")
700 return plantarch_wrapper.getCurrentShootParameters(
703 except Exception
as e:
706 def defineShootType(self, shoot_type_label: str, parameters: dict) ->
None:
708 Define a custom shoot type with specified parameters.
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.
715 shoot_type_label: Unique name for this shoot type
716 parameters: Dictionary matching ShootParameters structure.
717 Use getCurrentShootParameters() to get proper structure.
720 ValueError: If shoot_type_label is empty or parameters is not a dict
721 PlantArchitectureError: If shoot type definition fails
724 >>> # Get existing parameters as template
725 >>> plantarch.loadPlantModelFromLibrary("bean")
726 >>> params = plantarch.getCurrentShootParameters("stem")
728 >>> # Modify parameters
729 >>> params['max_nodes'] = {'distribution': 'constant', 'parameters': [20.0]}
730 >>> params['insertion_angle_tip'] = {'distribution': 'uniform', 'parameters': [40.0, 50.0]}
732 >>> # Define new shoot type
733 >>> plantarch.defineShootType("TallStem", params)
735 if not shoot_type_label:
736 raise ValueError(
"Shoot type label cannot be empty")
738 if not shoot_type_label.strip():
739 raise ValueError(
"Shoot type label cannot be only whitespace")
741 if not isinstance(parameters, dict):
742 raise ValueError(
"Parameters must be a dict")
746 plantarch_wrapper.defineShootType(
749 except Exception
as e:
754 Get list of all available plant models in the library.
757 List of plant model names available for loading
760 PlantArchitectureError: If retrieval fails
763 >>> models = plantarch.getAvailablePlantModels()
764 >>> print(f"Available models: {', '.join(models)}")
765 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
769 return plantarch_wrapper.getAvailablePlantModels(self.
_plantarch_ptr)
770 except Exception
as e:
775 Get all object IDs for a specific plant.
778 plant_id: ID of the plant instance
781 List of object IDs comprising the plant
784 ValueError: If plant_id is negative
785 PlantArchitectureError: If retrieval fails
788 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
789 >>> print(f"Plant has {len(object_ids)} objects")
792 raise ValueError(
"Plant ID must be non-negative")
795 return plantarch_wrapper.getAllPlantObjectIDs(self.
_plantarch_ptr, plant_id)
796 except Exception
as e:
799 def getAllPlantUUIDs(self, plant_id: int, include_hidden: bool =
False) -> List[int]:
801 Get all primitive UUIDs for a specific plant.
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.
809 List of primitive UUIDs comprising the plant (and optionally hidden prototypes)
812 ValueError: If plant_id is negative
813 PlantArchitectureError: If retrieval fails
816 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
817 >>> print(f"Plant has {len(uuids)} primitives")
820 raise ValueError(
"Plant ID must be non-negative")
823 return plantarch_wrapper.getAllPlantUUIDs(self.
_plantarch_ptr, plant_id, include_hidden)
824 except Exception
as e:
829 Get the current age of a plant in days.
832 plant_id: ID of the plant instance
838 ValueError: If plant_id is negative
839 PlantArchitectureError: If retrieval fails
842 >>> age = plantarch.getPlantAge(plant_id)
843 >>> print(f"Plant is {age} days old")
846 raise ValueError(
"Plant ID must be non-negative")
850 return plantarch_wrapper.getPlantAge(self.
_plantarch_ptr, plant_id)
851 except Exception
as e:
856 Get the height of a plant in meters.
859 plant_id: ID of the plant instance
862 Plant height in meters (vertical extent)
865 ValueError: If plant_id is negative
866 PlantArchitectureError: If retrieval fails
869 >>> height = plantarch.getPlantHeight(plant_id)
870 >>> print(f"Plant is {height:.2f}m tall")
873 raise ValueError(
"Plant ID must be non-negative")
877 return plantarch_wrapper.getPlantHeight(self.
_plantarch_ptr, plant_id)
878 except Exception
as e:
883 Get the total leaf area of a plant in m².
886 plant_id: ID of the plant instance
889 Total leaf area in square meters
892 ValueError: If plant_id is negative
893 PlantArchitectureError: If retrieval fails
896 >>> leaf_area = plantarch.getPlantLeafArea(plant_id)
897 >>> print(f"Total leaf area: {leaf_area:.3f} m²")
900 raise ValueError(
"Plant ID must be non-negative")
904 return plantarch_wrapper.sumPlantLeafArea(self.
_plantarch_ptr, plant_id)
905 except Exception
as e:
911 time_to_dormancy_break: float,
912 time_to_flower_initiation: float,
913 time_to_flower_opening: float,
914 time_to_fruit_set: float,
915 time_to_fruit_maturity: float,
916 time_to_dormancy: float,
917 max_leaf_lifespan: float = 1e6
920 Set phenological timing thresholds for plant developmental stages.
922 Controls the timing of key phenological events based on thermal time
923 or calendar time depending on the plant model.
926 plant_id: ID of the plant instance
927 time_to_dormancy_break: Degree-days or days until dormancy ends
928 time_to_flower_initiation: Time until flower buds are initiated
929 time_to_flower_opening: Time until flowers open
930 time_to_fruit_set: Time until fruit begins developing
931 time_to_fruit_maturity: Time until fruit reaches maturity
932 time_to_dormancy: Time until plant enters dormancy
933 max_leaf_lifespan: Maximum leaf lifespan in days (default: 1e6)
936 ValueError: If plant_id is negative
937 PlantArchitectureError: If phenology setting fails
940 >>> # Set phenology for perennial fruit tree
941 >>> plantarch.setPlantPhenologicalThresholds(
942 ... plant_id=plant_id,
943 ... time_to_dormancy_break=60, # Spring: 60 degree-days
944 ... time_to_flower_initiation=90, # Early spring flowering
945 ... time_to_flower_opening=105, # Bloom period
946 ... time_to_fruit_set=120, # Fruit set after pollination
947 ... time_to_fruit_maturity=200, # Summer fruit maturation
948 ... time_to_dormancy=280, # Fall dormancy
949 ... max_leaf_lifespan=180 # Deciduous - 6 month leaf life
953 raise ValueError(
"Plant ID must be non-negative")
957 plantarch_wrapper.setPlantPhenologicalThresholds(
960 time_to_dormancy_break,
961 time_to_flower_initiation,
962 time_to_flower_opening,
964 time_to_fruit_maturity,
968 except Exception
as e:
973 target_object_UUIDs: Optional[List[int]] =
None,
974 target_object_IDs: Optional[List[int]] =
None,
975 enable_petiole_collision: bool =
False,
976 enable_fruit_collision: bool =
False) ->
None:
978 Enable soft collision avoidance for procedural plant growth.
980 This method enables the collision detection system that guides plant growth away from
981 obstacles and other plants. The system uses cone-based gap detection to find optimal
982 growth directions that minimize collisions while maintaining natural plant architecture.
985 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
986 avoids all geometry in the context.
987 target_object_IDs: List of compound object IDs to avoid collisions with.
988 enable_petiole_collision: Enable collision detection for leaf petioles
989 enable_fruit_collision: Enable collision detection for fruit organs
992 PlantArchitectureError: If collision detection activation fails
995 Collision detection adds computational overhead. Use setStaticObstacles() to mark
996 static geometry for BVH optimization and improved performance.
999 >>> # Avoid all geometry
1000 >>> plantarch.enableSoftCollisionAvoidance()
1002 >>> # Avoid specific obstacles
1003 >>> obstacle_uuids = context.getAllUUIDs()
1004 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
1006 >>> # Enable collision detection for petioles and fruit
1007 >>> plantarch.enableSoftCollisionAvoidance(
1008 ... enable_petiole_collision=True,
1009 ... enable_fruit_collision=True
1014 plantarch_wrapper.enableSoftCollisionAvoidance(
1016 target_UUIDs=target_object_UUIDs,
1017 target_IDs=target_object_IDs,
1018 enable_petiole=enable_petiole_collision,
1019 enable_fruit=enable_fruit_collision
1021 except Exception
as e:
1026 Disable collision detection for plant growth.
1028 This method turns off the collision detection system, allowing plants to grow
1029 without checking for obstacles. This improves performance but plants may grow
1030 through obstacles and other geometry.
1033 PlantArchitectureError: If disabling fails
1036 >>> plantarch.disableCollisionDetection()
1040 except Exception
as e:
1044 view_half_angle_deg: float = 80.0,
1045 look_ahead_distance: float = 0.1,
1046 sample_count: int = 256,
1047 inertia_weight: float = 0.4) ->
None:
1049 Configure parameters for soft collision avoidance algorithm.
1051 These parameters control the cone-based gap detection algorithm that guides
1052 plant growth away from obstacles. Adjusting these values allows fine-tuning
1053 the balance between collision avoidance and natural growth patterns.
1056 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
1057 Default 80° provides wide field of view.
1058 look_ahead_distance: Distance to look ahead for collisions in meters.
1059 Larger values detect distant obstacles. Default 0.1m.
1060 sample_count: Number of ray samples within cone. More samples improve
1061 accuracy but reduce performance. Default 256.
1062 inertia_weight: Weight for previous growth direction (0-1). Higher values
1063 make growth smoother but less responsive. Default 0.4.
1066 ValueError: If parameters are outside valid ranges
1067 PlantArchitectureError: If parameter setting fails
1070 >>> # Use default parameters (recommended)
1071 >>> plantarch.setSoftCollisionAvoidanceParameters()
1073 >>> # Tune for dense canopy with close obstacles
1074 >>> plantarch.setSoftCollisionAvoidanceParameters(
1075 ... view_half_angle_deg=60.0, # Narrower detection cone
1076 ... look_ahead_distance=0.05, # Shorter look-ahead
1077 ... sample_count=512, # More accurate detection
1078 ... inertia_weight=0.3 # More responsive to obstacles
1082 if not (0 <= view_half_angle_deg <= 180):
1083 raise ValueError(f
"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
1084 if look_ahead_distance <= 0:
1085 raise ValueError(f
"look_ahead_distance must be positive, got {look_ahead_distance}")
1086 if sample_count <= 0:
1087 raise ValueError(f
"sample_count must be positive, got {sample_count}")
1088 if not (0 <= inertia_weight <= 1):
1089 raise ValueError(f
"inertia_weight must be between 0 and 1, got {inertia_weight}")
1092 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
1094 view_half_angle_deg,
1095 look_ahead_distance,
1099 except Exception
as e:
1103 include_internodes: bool =
False,
1104 include_leaves: bool =
True,
1105 include_petioles: bool =
False,
1106 include_flowers: bool =
False,
1107 include_fruit: bool =
False) ->
None:
1109 Specify which plant organs participate in collision detection.
1111 This method allows filtering which organs are considered during collision detection,
1112 enabling optimization by excluding organs unlikely to cause problematic collisions.
1115 include_internodes: Include stem internodes in collision detection
1116 include_leaves: Include leaf blades in collision detection
1117 include_petioles: Include leaf petioles in collision detection
1118 include_flowers: Include flowers in collision detection
1119 include_fruit: Include fruit in collision detection
1122 PlantArchitectureError: If organ filtering fails
1125 >>> # Only detect collisions for stems and leaves (default behavior)
1126 >>> plantarch.setCollisionRelevantOrgans(
1127 ... include_internodes=True,
1128 ... include_leaves=True
1131 >>> # Include all organs
1132 >>> plantarch.setCollisionRelevantOrgans(
1133 ... include_internodes=True,
1134 ... include_leaves=True,
1135 ... include_petioles=True,
1136 ... include_flowers=True,
1137 ... include_fruit=True
1141 plantarch_wrapper.setCollisionRelevantOrgans(
1149 except Exception
as e:
1153 obstacle_UUIDs: List[int],
1154 avoidance_distance: float = 0.5,
1155 enable_fruit_adjustment: bool =
False,
1156 enable_obstacle_pruning: bool =
False) ->
None:
1158 Enable hard obstacle avoidance for specified geometry.
1160 This method configures solid obstacles that plants cannot grow through. Unlike soft
1161 collision avoidance (which guides growth), solid obstacles cause complete growth
1162 termination when encountered within the avoidance distance.
1165 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
1166 avoidance_distance: Minimum distance to maintain from obstacles (meters).
1167 Growth stops if obstacles are closer. Default 0.5m.
1168 enable_fruit_adjustment: Adjust fruit positions away from obstacles
1169 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
1172 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
1173 PlantArchitectureError: If solid obstacle configuration fails
1176 >>> # Simple solid obstacle avoidance
1177 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
1178 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
1180 >>> # Close avoidance with fruit adjustment
1181 >>> plantarch.enableSolidObstacleAvoidance(
1182 ... obstacle_UUIDs=wall_uuids,
1183 ... avoidance_distance=0.1,
1184 ... enable_fruit_adjustment=True
1187 if not obstacle_UUIDs:
1188 raise ValueError(
"Obstacle UUIDs list cannot be empty")
1189 if avoidance_distance <= 0:
1190 raise ValueError(f
"avoidance_distance must be positive, got {avoidance_distance}")
1194 plantarch_wrapper.enableSolidObstacleAvoidance(
1198 enable_fruit_adjustment,
1199 enable_obstacle_pruning
1201 except Exception
as e:
1206 Mark geometry as static obstacles for collision detection optimization.
1208 This method tells the collision detection system that certain geometry will not
1209 move during the simulation. The system can then build an optimized Bounding Volume
1210 Hierarchy (BVH) for these obstacles, significantly improving collision detection
1211 performance in scenes with many static obstacles.
1214 target_UUIDs: List of primitive UUIDs representing static obstacles
1217 ValueError: If target_UUIDs is empty
1218 PlantArchitectureError: If static obstacle configuration fails
1221 Call this method BEFORE enabling collision avoidance for best performance.
1222 Static obstacles cannot be modified or moved after being marked static.
1225 >>> # Mark ground and building geometry as static
1226 >>> static_uuids = ground_uuids + building_uuids
1227 >>> plantarch.setStaticObstacles(static_uuids)
1228 >>> # Now enable collision avoidance
1229 >>> plantarch.enableSoftCollisionAvoidance()
1231 if not target_UUIDs:
1232 raise ValueError(
"target_UUIDs list cannot be empty")
1236 plantarch_wrapper.setStaticObstacles(self.
_plantarch_ptr, target_UUIDs)
1237 except Exception
as e:
1242 Get object IDs of collision-relevant geometry for a specific plant.
1244 This method returns the subset of plant geometry that participates in collision
1245 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
1246 and debugging collision detection behavior.
1249 plant_id: ID of the plant instance
1252 List of object IDs for collision-relevant plant geometry
1255 ValueError: If plant_id is negative
1256 PlantArchitectureError: If retrieval fails
1259 >>> # Get collision-relevant geometry
1260 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
1261 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
1263 >>> # Highlight collision geometry in visualization
1264 >>> for obj_id in collision_obj_ids:
1265 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
1268 raise ValueError(
"Plant ID must be non-negative")
1271 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self.
_plantarch_ptr, plant_id)
1272 except Exception
as e:
1278 Write all plant mesh vertices to file for external processing.
1280 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
1281 writing one vertex per line. Useful for external processing such as computing bounding
1282 volumes, convex hulls, or performing custom geometric analysis.
1285 plant_id: ID of the plant instance to export
1286 filename: Path to output file (absolute or relative to current working directory)
1289 ValueError: If plant_id is negative or filename is empty
1290 PlantArchitectureError: If plant doesn't exist or file cannot be written
1293 >>> # Export vertices for convex hull analysis
1294 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
1296 >>> # Use with Path object
1297 >>> from pathlib import Path
1298 >>> output_dir = Path("output")
1299 >>> output_dir.mkdir(exist_ok=True)
1300 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
1303 raise ValueError(
"Plant ID must be non-negative")
1305 raise ValueError(
"Filename cannot be empty")
1312 plantarch_wrapper.writePlantMeshVertices(
1315 except Exception
as e:
1320 Save plant structure to XML file for later loading.
1322 This method exports the complete plant architecture to an XML file, including
1323 all shoots, phytomers, organs, and their properties. The saved plant can be
1324 reloaded later using readPlantStructureXML().
1327 plant_id: ID of the plant instance to save
1328 filename: Path to output XML file (absolute or relative to current working directory)
1331 ValueError: If plant_id is negative or filename is empty
1332 PlantArchitectureError: If plant doesn't exist or file cannot be written
1335 The XML format preserves the complete plant state including:
1336 - Shoot structure and hierarchy
1337 - Phytomer properties and development stage
1338 - Organ geometry and attributes
1339 - Growth parameters and phenological state
1342 >>> # Save plant at current growth stage
1343 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
1345 >>> # Later, reload the saved plant
1346 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
1347 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
1350 raise ValueError(
"Plant ID must be non-negative")
1352 raise ValueError(
"Filename cannot be empty")
1359 plantarch_wrapper.writePlantStructureXML(
1362 except Exception
as e:
1367 Export plant structure in TreeQSM cylinder format.
1369 This method writes the plant structure as a series of cylinders following the
1370 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
1371 columns for radius, length, start position, axis direction, branch topology,
1372 and other structural properties. Useful for biomechanical analysis and
1373 quantitative structure modeling.
1376 plant_id: ID of the plant instance to export
1377 filename: Path to output file (absolute or relative, typically .txt extension)
1380 ValueError: If plant_id is negative or filename is empty
1381 PlantArchitectureError: If plant doesn't exist or file cannot be written
1384 The TreeQSM format includes columns for:
1385 - Cylinder dimensions (radius, length)
1386 - Spatial position and orientation
1387 - Branch topology (parent ID, extension ID, branch ID)
1388 - Branch hierarchy (branch order, position in branch)
1389 - Quality metrics (mean absolute distance, surface coverage)
1392 >>> # Export for biomechanical analysis
1393 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
1395 >>> # Use with external QSM tools
1396 >>> import pandas as pd
1397 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
1398 >>> print(f"Tree has {len(qsm_data)} cylinders")
1401 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
1402 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
1405 raise ValueError(
"Plant ID must be non-negative")
1407 raise ValueError(
"Filename cannot be empty")
1414 plantarch_wrapper.writeQSMCylinderFile(
1417 except Exception
as e:
1421 elastic_modulus: float = 5e9,
1422 wood_density: float = 800.0,
1423 damping_ratio: float = 0.1,
1424 static_friction: float = 0.5,
1425 dynamic_friction: float = 0.3,
1426 restitution: float = 0.1,
1427 organ_spring_stiffness: float = 10.0,
1428 organ_spring_damping: float = 1.0,
1429 leaf_mass_per_area: float = 0.05,
1430 fruit_mass: float = 0.01,
1431 flower_mass: float = 0.002,
1432 solver_position_iterations: int = 32,
1433 min_segment_length: float = 0.001) ->
None:
1435 Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
1437 Each tube segment becomes a capsule-shaped rigid link connected by spherical joints.
1438 Spring/damper drives are derived from beam bending stiffness (E*I/L). Leaves, fruits,
1439 and flowers are represented as mass bodies attached by spring links.
1442 plant_id: ID of the plant instance to export
1443 filename: Output file path (should have .usda extension)
1444 elastic_modulus: Young's modulus (Pa) for joint stiffness, K = E*I/L
1445 wood_density: Wood density (kg/m^3) used to compute mass from capsule volume
1446 damping_ratio: Joint damping ratio (dimensionless)
1447 static_friction: Static friction coefficient for collision material
1448 dynamic_friction: Dynamic friction coefficient for collision material
1449 restitution: Restitution (bounciness) for collision material
1450 organ_spring_stiffness: Spring stiffness (N*m/rad) for organ attachment joints
1451 organ_spring_damping: Damping (N*m*s/rad) for organ attachment joints
1452 leaf_mass_per_area: Leaf mass per unit area (kg/m^2)
1453 fruit_mass: Mass per fruit (kg)
1454 flower_mass: Mass per flower (kg)
1455 solver_position_iterations: PhysX articulation solver position iteration count
1456 min_segment_length: Minimum segment length (m); shorter segments are skipped
1459 ValueError: If plant_id is negative or filename is empty
1460 PlantArchitectureError: If plant doesn't exist or file cannot be written
1463 >>> plantarch.writePlantStructureUSD(plant_id, "plant.usda")
1466 raise ValueError(
"Plant ID must be non-negative")
1468 raise ValueError(
"Filename cannot be empty")
1474 plantarch_wrapper.writePlantStructureUSD(
1476 elastic_modulus, wood_density, damping_ratio,
1477 static_friction, dynamic_friction, restitution,
1478 organ_spring_stiffness, organ_spring_damping,
1479 leaf_mass_per_area, fruit_mass, flower_mass,
1480 solver_position_iterations, min_segment_length
1482 except Exception
as e:
1487 Capture a snapshot of the plant's geometry as a growth animation frame.
1489 Call this after each :meth:`advanceTime` step to record the plant state for later
1490 animation export via :meth:`writePlantGrowthUSD`.
1493 plant_id: ID of the plant instance to capture
1494 min_segment_length: Minimum segment length (m); shorter segments are skipped
1497 ValueError: If plant_id is negative
1498 PlantArchitectureError: If plant doesn't exist
1501 raise ValueError(
"Plant ID must be non-negative")
1504 plantarch_wrapper.registerGrowthFrame(self.
_plantarch_ptr, plant_id, min_segment_length)
1505 except Exception
as e:
1509 seconds_per_frame: float = 1.0) ->
None:
1511 Export all registered growth frames as a time-sampled USD animation file.
1513 The resulting file can be imported directly into Blender. This is a visual-only
1514 export — no physics prims, joints, or collision shapes are written.
1517 plant_id: ID of the plant instance to export
1518 filename: Output file path (should have .usda extension)
1519 seconds_per_frame: Duration in seconds each growth frame occupies (default: 1.0)
1522 ValueError: If plant_id is negative or filename is empty
1523 PlantArchitectureError: If plant doesn't exist or file cannot be written
1526 raise ValueError(
"Plant ID must be non-negative")
1528 raise ValueError(
"Filename cannot be empty")
1534 plantarch_wrapper.writePlantGrowthUSD(
1537 except Exception
as e:
1542 Clear stored growth animation frames for a plant.
1545 plant_id: ID of the plant instance whose frames should be cleared
1548 ValueError: If plant_id is negative
1551 raise ValueError(
"Plant ID must be non-negative")
1554 plantarch_wrapper.clearGrowthFrames(self.
_plantarch_ptr, plant_id)
1555 except Exception
as e:
1560 Get the number of registered growth frames for a plant.
1563 plant_id: ID of the plant instance to query
1566 Number of frames registered via :meth:`registerGrowthFrame`
1569 ValueError: If plant_id is negative
1572 raise ValueError(
"Plant ID must be non-negative")
1575 return plantarch_wrapper.getGrowthFrameCount(self.
_plantarch_ptr, plant_id)
1576 except Exception
as e:
1581 Load plant structure from XML file.
1583 This method reads plant architecture data from an XML file previously saved with
1584 writePlantStructureXML(). The loaded plants are added to the current context
1585 and can be grown, modified, or analyzed like any other plants.
1588 filename: Path to XML file to load (absolute or relative to current working directory)
1589 quiet: If True, suppress console output during loading (default: False)
1592 List of plant IDs for the loaded plant instances
1595 ValueError: If filename is empty
1596 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
1599 The XML file can contain multiple plant instances. All plants in the file
1600 will be loaded and their IDs returned in a list. Plant models referenced
1601 in the XML must be available in the plant library.
1604 >>> # Load previously saved plants
1605 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
1606 >>> print(f"Loaded {len(plant_ids)} plants")
1608 >>> # Continue growing the loaded plants
1609 >>> plantarch.advanceTime(10.0)
1611 >>> # Load quietly without console messages
1612 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
1615 raise ValueError(
"Filename cannot be empty")
1622 return plantarch_wrapper.readPlantStructureXML(
1625 except Exception
as e:
1629 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
1631 Create an empty plant instance for custom plant building.
1633 This method creates a new plant instance at the specified location without any
1634 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
1635 manually construct the plant structure. This provides low-level control over
1636 plant architecture, enabling custom morphologies not available in the plant library.
1639 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
1640 current_age: Current age of the plant in days (must be >= 0)
1643 Plant ID for the created plant instance
1646 ValueError: If age is negative
1647 PlantArchitectureError: If plant creation fails
1650 >>> # Create empty plant at origin
1651 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1653 >>> # Now add shoots to build custom plant structure
1654 >>> shoot_id = plantarch.addBaseStemShoot(
1655 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
1659 if not isinstance(base_position, vec3):
1660 raise ValueError(f
"base_position must be a vec3, got {type(base_position).__name__}")
1663 position_list = [base_position.x, base_position.y, base_position.z]
1667 raise ValueError(f
"Age must be non-negative, got {current_age}")
1671 return plantarch_wrapper.addPlantInstance(
1674 except Exception
as e:
1679 Delete a plant instance and all associated geometry.
1681 This method removes a plant from the simulation, deleting all shoots, organs,
1682 and associated primitives from the context. The plant ID becomes invalid after
1683 deletion and should not be used in subsequent operations.
1686 plant_id: ID of the plant instance to delete
1689 ValueError: If plant_id is negative
1690 PlantArchitectureError: If plant deletion fails or plant doesn't exist
1693 >>> # Delete a plant
1694 >>> plantarch.deletePlantInstance(plant_id)
1696 >>> # Delete multiple plants
1697 >>> for pid in plant_ids_to_remove:
1698 ... plantarch.deletePlantInstance(pid)
1701 raise ValueError(
"Plant ID must be non-negative")
1705 plantarch_wrapper.deletePlantInstance(self.
_plantarch_ptr, plant_id)
1706 except Exception
as e:
1711 current_node_number: int,
1712 base_rotation: AxisRotation,
1713 internode_radius: float,
1714 internode_length_max: float,
1715 internode_length_scale_factor_fraction: float,
1716 leaf_scale_factor_fraction: float,
1717 radius_taper: float,
1718 shoot_type_label: str) -> int:
1720 Add a base stem shoot to a plant instance (main trunk/stem).
1722 This method creates the primary shoot originating from the plant base. The base stem
1723 is typically the main trunk or primary stem from which all other shoots branch.
1724 Specify growth parameters to control the shoot's morphology and development.
1726 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
1727 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
1728 shoot types that can then be used for custom building. The shoot_type_label must match a
1729 shoot type defined in the loaded model.
1732 plant_id: ID of the plant instance
1733 current_node_number: Starting node number for this shoot (typically 1)
1734 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1735 internode_radius: Base radius of internodes in meters (must be > 0)
1736 internode_length_max: Maximum internode length in meters (must be > 0)
1737 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1738 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1739 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1740 shoot_type_label: Label identifying shoot type - must match a type from loaded model
1743 Shoot ID for the created shoot
1746 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1747 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
1750 >>> from pyhelios import AxisRotation
1752 >>> # REQUIRED: Load a plant model to define shoot types
1753 >>> plantarch.loadPlantModelFromLibrary("bean")
1755 >>> # Create empty plant for custom building
1756 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1758 >>> # Add base stem using shoot type from loaded model
1759 >>> shoot_id = plantarch.addBaseStemShoot(
1760 ... plant_id=plant_id,
1761 ... current_node_number=1,
1762 ... base_rotation=AxisRotation(0, 0, 0), # Upright
1763 ... internode_radius=0.01, # 1cm radius
1764 ... internode_length_max=0.1, # 10cm max length
1765 ... internode_length_scale_factor_fraction=1.0,
1766 ... leaf_scale_factor_fraction=1.0,
1767 ... radius_taper=0.9, # Gradual taper
1768 ... shoot_type_label="stem" # Must match loaded model
1772 raise ValueError(
"Plant ID must be non-negative")
1773 if current_node_number < 0:
1774 raise ValueError(
"Current node number must be non-negative")
1775 if internode_radius <= 0:
1776 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1777 if internode_length_max <= 0:
1778 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1779 if not shoot_type_label
or not shoot_type_label.strip():
1780 raise ValueError(
"Shoot type label cannot be empty")
1783 rotation_list = base_rotation.to_list()
1787 return plantarch_wrapper.addBaseStemShoot(
1788 self.
_plantarch_ptr, plant_id, current_node_number, rotation_list,
1789 internode_radius, internode_length_max,
1790 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1791 radius_taper, shoot_type_label.strip()
1793 except Exception
as e:
1795 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1797 f
"Shoot type '{shoot_type_label}' not defined. "
1798 f
"Load a plant model first to define shoot types:\n"
1799 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1800 f
"Original error: {e}"
1806 parent_shoot_id: int,
1807 current_node_number: int,
1808 base_rotation: AxisRotation,
1809 internode_radius: float,
1810 internode_length_max: float,
1811 internode_length_scale_factor_fraction: float,
1812 leaf_scale_factor_fraction: float,
1813 radius_taper: float,
1814 shoot_type_label: str) -> int:
1816 Append a shoot to the end of an existing shoot.
1818 This method extends an existing shoot by appending a new shoot at its terminal bud.
1819 Useful for creating multi-segmented shoots with varying properties along their length,
1820 such as shoots with different growth phases or developmental stages.
1822 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1823 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1824 calling this method.
1827 plant_id: ID of the plant instance
1828 parent_shoot_id: ID of the parent shoot to extend
1829 current_node_number: Starting node number for this shoot
1830 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1831 internode_radius: Base radius of internodes in meters (must be > 0)
1832 internode_length_max: Maximum internode length in meters (must be > 0)
1833 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1834 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1835 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1836 shoot_type_label: Label identifying shoot type - must match loaded model
1839 Shoot ID for the appended shoot
1842 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1843 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
1846 >>> # Load model to define shoot types
1847 >>> plantarch.loadPlantModelFromLibrary("bean")
1849 >>> # Append shoot with reduced size to simulate apical growth
1850 >>> new_shoot_id = plantarch.appendShoot(
1851 ... plant_id=plant_id,
1852 ... parent_shoot_id=base_shoot_id,
1853 ... current_node_number=10,
1854 ... base_rotation=AxisRotation(0, 0, 0),
1855 ... internode_radius=0.008, # Smaller than base
1856 ... internode_length_max=0.08, # Shorter internodes
1857 ... internode_length_scale_factor_fraction=1.0,
1858 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
1859 ... radius_taper=0.85,
1860 ... shoot_type_label="stem"
1864 raise ValueError(
"Plant ID must be non-negative")
1865 if parent_shoot_id < 0:
1866 raise ValueError(
"Parent shoot ID must be non-negative")
1867 if current_node_number < 0:
1868 raise ValueError(
"Current node number must be non-negative")
1869 if internode_radius <= 0:
1870 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1871 if internode_length_max <= 0:
1872 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1873 if not shoot_type_label
or not shoot_type_label.strip():
1874 raise ValueError(
"Shoot type label cannot be empty")
1877 rotation_list = base_rotation.to_list()
1881 return plantarch_wrapper.appendShoot(
1882 self.
_plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
1883 rotation_list, internode_radius, internode_length_max,
1884 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1885 radius_taper, shoot_type_label.strip()
1887 except Exception
as e:
1889 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1891 f
"Shoot type '{shoot_type_label}' not defined. "
1892 f
"Load a plant model first to define shoot types:\n"
1893 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1894 f
"Original error: {e}"
1900 parent_shoot_id: int,
1901 parent_node_index: int,
1902 current_node_number: int,
1903 shoot_base_rotation: AxisRotation,
1904 internode_radius: float,
1905 internode_length_max: float,
1906 internode_length_scale_factor_fraction: float,
1907 leaf_scale_factor_fraction: float,
1908 radius_taper: float,
1909 shoot_type_label: str,
1910 petiole_index: int = 0) -> int:
1912 Add a child shoot at an axillary bud position on a parent shoot.
1914 This method creates a lateral branch shoot emerging from a specific node on the
1915 parent shoot. Child shoots enable creation of branching architectures, with control
1916 over branch angle, size, and which petiole position the branch emerges from (for
1917 plants with multiple petioles per node).
1919 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1920 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1921 calling this method.
1924 plant_id: ID of the plant instance
1925 parent_shoot_id: ID of the parent shoot
1926 parent_node_index: Index of the parent node where child emerges (0-based)
1927 current_node_number: Starting node number for this child shoot
1928 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1929 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
1930 internode_length_max: Maximum internode length in meters (must be > 0)
1931 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1932 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1933 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1934 shoot_type_label: Label identifying shoot type - must match loaded model
1935 petiole_index: Which petiole at the node to branch from (default: 0)
1938 Shoot ID for the created child shoot
1941 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
1942 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
1945 >>> # Load model to define shoot types
1946 >>> plantarch.loadPlantModelFromLibrary("bean")
1948 >>> # Add lateral branch at 45-degree angle from node 3
1949 >>> branch_id = plantarch.addChildShoot(
1950 ... plant_id=plant_id,
1951 ... parent_shoot_id=main_shoot_id,
1952 ... parent_node_index=3,
1953 ... current_node_number=1,
1954 ... shoot_base_rotation=AxisRotation(45, 90, 0), # 45° out, 90° rotation
1955 ... internode_radius=0.005, # Thinner than main stem
1956 ... internode_length_max=0.06, # Shorter internodes
1957 ... internode_length_scale_factor_fraction=1.0,
1958 ... leaf_scale_factor_fraction=0.9,
1959 ... radius_taper=0.8,
1960 ... shoot_type_label="stem"
1963 >>> # Add second branch from opposite petiole
1964 >>> branch_id2 = plantarch.addChildShoot(
1965 ... plant_id, main_shoot_id, 3, 1, AxisRotation(45, 270, 0),
1966 ... 0.005, 0.06, 1.0, 0.9, 0.8, "stem", petiole_index=1
1970 raise ValueError(
"Plant ID must be non-negative")
1971 if parent_shoot_id < 0:
1972 raise ValueError(
"Parent shoot ID must be non-negative")
1973 if parent_node_index < 0:
1974 raise ValueError(
"Parent node index must be non-negative")
1975 if current_node_number < 0:
1976 raise ValueError(
"Current node number must be non-negative")
1977 if internode_radius <= 0:
1978 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1979 if internode_length_max <= 0:
1980 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1981 if not shoot_type_label
or not shoot_type_label.strip():
1982 raise ValueError(
"Shoot type label cannot be empty")
1983 if petiole_index < 0:
1984 raise ValueError(f
"Petiole index must be non-negative, got {petiole_index}")
1987 rotation_list = shoot_base_rotation.to_list()
1991 return plantarch_wrapper.addChildShoot(
1992 self.
_plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
1993 current_node_number, rotation_list, internode_radius,
1994 internode_length_max, internode_length_scale_factor_fraction,
1995 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
1998 except Exception
as e:
2000 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
2002 f
"Shoot type '{shoot_type_label}' not defined. "
2003 f
"Load a plant model first to define shoot types:\n"
2004 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
2005 f
"Original error: {e}"
2011 Check if PlantArchitecture is available in current build.
2014 True if plugin is available, False otherwise
2022 Create PlantArchitecture instance with context.
2025 context: Helios Context
2028 PlantArchitecture instance
2031 >>> context = Context()
2032 >>> plantarch = create_plant_architecture(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[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] 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.
__enter__(self)
Context manager entry.
List[int] getPlantCollisionRelevantObjectIDs(self, int plant_id)
Get object IDs of collision-relevant geometry for a specific plant.
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.
__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...