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 IDs of all shoots belonging to a plant.
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.
836 plant_id: ID of the plant instance
839 List of shoot IDs for the plant
842 raise ValueError(
"Plant ID must be non-negative")
845 except Exception
as e:
848 def getShoot(self, plant_id: int, shoot_id: int) -> Dict[str, Any]:
850 Get a read-only view of a shoot's topology.
853 plant_id: ID of the plant instance
854 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
857 A dict with keys ``rank``, ``parent_shoot_id`` (-1 for the base stem),
858 ``parent_node_index``, and ``node_count``.
860 if plant_id < 0
or shoot_id < 0:
861 raise ValueError(
"Plant ID and shoot ID must be non-negative")
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}")
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")
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}")
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")
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}")
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")
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}")
900 Get the current age of a plant in days.
903 plant_id: ID of the plant instance
909 ValueError: If plant_id is negative
910 PlantArchitectureError: If retrieval fails
913 >>> age = plantarch.getPlantAge(plant_id)
914 >>> print(f"Plant is {age} days old")
917 raise ValueError(
"Plant ID must be non-negative")
921 return plantarch_wrapper.getPlantAge(self.
_plantarch_ptr, plant_id)
922 except Exception
as e:
927 Get the height of a plant in meters.
930 plant_id: ID of the plant instance
933 Plant height in meters (vertical extent)
936 ValueError: If plant_id is negative
937 PlantArchitectureError: If retrieval fails
940 >>> height = plantarch.getPlantHeight(plant_id)
941 >>> print(f"Plant is {height:.2f}m tall")
944 raise ValueError(
"Plant ID must be non-negative")
948 return plantarch_wrapper.getPlantHeight(self.
_plantarch_ptr, plant_id)
949 except Exception
as e:
954 Get the total leaf area of a plant in m².
957 plant_id: ID of the plant instance
960 Total leaf area in square meters
963 ValueError: If plant_id is negative
964 PlantArchitectureError: If retrieval fails
967 >>> leaf_area = plantarch.getPlantLeafArea(plant_id)
968 >>> print(f"Total leaf area: {leaf_area:.3f} m²")
971 raise ValueError(
"Plant ID must be non-negative")
975 return plantarch_wrapper.sumPlantLeafArea(self.
_plantarch_ptr, plant_id)
976 except Exception
as e:
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
991 Set phenological timing thresholds for plant developmental stages.
993 Controls the timing of key phenological events based on thermal time
994 or calendar time depending on the plant model.
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)
1007 ValueError: If plant_id is negative
1008 PlantArchitectureError: If phenology setting fails
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
1024 raise ValueError(
"Plant ID must be non-negative")
1028 plantarch_wrapper.setPlantPhenologicalThresholds(
1031 time_to_dormancy_break,
1032 time_to_flower_initiation,
1033 time_to_flower_opening,
1035 time_to_fruit_maturity,
1039 except Exception
as e:
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:
1049 Enable soft collision avoidance for procedural plant growth.
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.
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
1063 PlantArchitectureError: If collision detection activation fails
1066 Collision detection adds computational overhead. Use setStaticObstacles() to mark
1067 static geometry for BVH optimization and improved performance.
1070 >>> # Avoid all geometry
1071 >>> plantarch.enableSoftCollisionAvoidance()
1073 >>> # Avoid specific obstacles
1074 >>> obstacle_uuids = context.getAllUUIDs()
1075 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
1077 >>> # Enable collision detection for petioles and fruit
1078 >>> plantarch.enableSoftCollisionAvoidance(
1079 ... enable_petiole_collision=True,
1080 ... enable_fruit_collision=True
1085 plantarch_wrapper.enableSoftCollisionAvoidance(
1087 target_UUIDs=target_object_UUIDs,
1088 target_IDs=target_object_IDs,
1089 enable_petiole=enable_petiole_collision,
1090 enable_fruit=enable_fruit_collision
1092 except Exception
as e:
1097 Disable collision detection for plant growth.
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.
1104 PlantArchitectureError: If disabling fails
1107 >>> plantarch.disableCollisionDetection()
1111 except Exception
as e:
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:
1120 Configure parameters for soft collision avoidance algorithm.
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.
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.
1137 ValueError: If parameters are outside valid ranges
1138 PlantArchitectureError: If parameter setting fails
1141 >>> # Use default parameters (recommended)
1142 >>> plantarch.setSoftCollisionAvoidanceParameters()
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
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}")
1163 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
1165 view_half_angle_deg,
1166 look_ahead_distance,
1170 except Exception
as e:
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:
1180 Specify which plant organs participate in collision detection.
1182 This method allows filtering which organs are considered during collision detection,
1183 enabling optimization by excluding organs unlikely to cause problematic collisions.
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
1193 PlantArchitectureError: If organ filtering fails
1196 >>> # Only detect collisions for stems and leaves (default behavior)
1197 >>> plantarch.setCollisionRelevantOrgans(
1198 ... include_internodes=True,
1199 ... include_leaves=True
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
1212 plantarch_wrapper.setCollisionRelevantOrgans(
1220 except Exception
as e:
1224 obstacle_UUIDs: List[int],
1225 avoidance_distance: float = 0.5,
1226 enable_fruit_adjustment: bool =
False,
1227 enable_obstacle_pruning: bool =
False) ->
None:
1229 Enable hard obstacle avoidance for specified geometry.
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.
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
1243 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
1244 PlantArchitectureError: If solid obstacle configuration fails
1247 >>> # Simple solid obstacle avoidance
1248 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
1249 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
1251 >>> # Close avoidance with fruit adjustment
1252 >>> plantarch.enableSolidObstacleAvoidance(
1253 ... obstacle_UUIDs=wall_uuids,
1254 ... avoidance_distance=0.1,
1255 ... enable_fruit_adjustment=True
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}")
1265 plantarch_wrapper.enableSolidObstacleAvoidance(
1269 enable_fruit_adjustment,
1270 enable_obstacle_pruning
1272 except Exception
as e:
1277 Mark geometry as static obstacles for collision detection optimization.
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.
1285 target_UUIDs: List of primitive UUIDs representing static obstacles
1288 ValueError: If target_UUIDs is empty
1289 PlantArchitectureError: If static obstacle configuration fails
1292 Call this method BEFORE enabling collision avoidance for best performance.
1293 Static obstacles cannot be modified or moved after being marked static.
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()
1302 if not target_UUIDs:
1303 raise ValueError(
"target_UUIDs list cannot be empty")
1307 plantarch_wrapper.setStaticObstacles(self.
_plantarch_ptr, target_UUIDs)
1308 except Exception
as e:
1313 Get object IDs of collision-relevant geometry for a specific plant.
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.
1320 plant_id: ID of the plant instance
1323 List of object IDs for collision-relevant plant geometry
1326 ValueError: If plant_id is negative
1327 PlantArchitectureError: If retrieval fails
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")
1334 >>> # Highlight collision geometry in visualization
1335 >>> for obj_id in collision_obj_ids:
1336 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
1339 raise ValueError(
"Plant ID must be non-negative")
1342 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self.
_plantarch_ptr, plant_id)
1343 except Exception
as e:
1349 Write all plant mesh vertices to file for external processing.
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.
1356 plant_id: ID of the plant instance to export
1357 filename: Path to output file (absolute or relative to current working directory)
1360 ValueError: If plant_id is negative or filename is empty
1361 PlantArchitectureError: If plant doesn't exist or file cannot be written
1364 >>> # Export vertices for convex hull analysis
1365 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
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")
1374 raise ValueError(
"Plant ID must be non-negative")
1376 raise ValueError(
"Filename cannot be empty")
1383 plantarch_wrapper.writePlantMeshVertices(
1386 except Exception
as e:
1391 Save plant structure to XML file for later loading.
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().
1398 plant_id: ID of the plant instance to save
1399 filename: Path to output XML file (absolute or relative to current working directory)
1402 ValueError: If plant_id is negative or filename is empty
1403 PlantArchitectureError: If plant doesn't exist or file cannot be written
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
1413 >>> # Save plant at current growth stage
1414 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
1416 >>> # Later, reload the saved plant
1417 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
1418 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
1421 raise ValueError(
"Plant ID must be non-negative")
1423 raise ValueError(
"Filename cannot be empty")
1430 plantarch_wrapper.writePlantStructureXML(
1433 except Exception
as e:
1438 Export plant structure in TreeQSM cylinder format.
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.
1447 plant_id: ID of the plant instance to export
1448 filename: Path to output file (absolute or relative, typically .txt extension)
1451 ValueError: If plant_id is negative or filename is empty
1452 PlantArchitectureError: If plant doesn't exist or file cannot be written
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)
1463 >>> # Export for biomechanical analysis
1464 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
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")
1472 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
1473 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
1476 raise ValueError(
"Plant ID must be non-negative")
1478 raise ValueError(
"Filename cannot be empty")
1485 plantarch_wrapper.writeQSMCylinderFile(
1488 except Exception
as e:
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:
1506 Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
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.
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
1530 ValueError: If plant_id is negative or filename is empty
1531 PlantArchitectureError: If plant doesn't exist or file cannot be written
1534 >>> plantarch.writePlantStructureUSD(plant_id, "plant.usda")
1537 raise ValueError(
"Plant ID must be non-negative")
1539 raise ValueError(
"Filename cannot be empty")
1545 plantarch_wrapper.writePlantStructureUSD(
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
1553 except Exception
as e:
1558 Capture a snapshot of the plant's geometry as a growth animation frame.
1560 Call this after each :meth:`advanceTime` step to record the plant state for later
1561 animation export via :meth:`writePlantGrowthUSD`.
1564 plant_id: ID of the plant instance to capture
1565 min_segment_length: Minimum segment length (m); shorter segments are skipped
1568 ValueError: If plant_id is negative
1569 PlantArchitectureError: If plant doesn't exist
1572 raise ValueError(
"Plant ID must be non-negative")
1575 plantarch_wrapper.registerGrowthFrame(self.
_plantarch_ptr, plant_id, min_segment_length)
1576 except Exception
as e:
1580 seconds_per_frame: float = 1.0) ->
None:
1582 Export all registered growth frames as a time-sampled USD animation file.
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.
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)
1593 ValueError: If plant_id is negative or filename is empty
1594 PlantArchitectureError: If plant doesn't exist or file cannot be written
1597 raise ValueError(
"Plant ID must be non-negative")
1599 raise ValueError(
"Filename cannot be empty")
1605 plantarch_wrapper.writePlantGrowthUSD(
1608 except Exception
as e:
1613 Clear stored growth animation frames for a plant.
1616 plant_id: ID of the plant instance whose frames should be cleared
1619 ValueError: If plant_id is negative
1622 raise ValueError(
"Plant ID must be non-negative")
1625 plantarch_wrapper.clearGrowthFrames(self.
_plantarch_ptr, plant_id)
1626 except Exception
as e:
1631 Get the number of registered growth frames for a plant.
1634 plant_id: ID of the plant instance to query
1637 Number of frames registered via :meth:`registerGrowthFrame`
1640 ValueError: If plant_id is negative
1643 raise ValueError(
"Plant ID must be non-negative")
1646 return plantarch_wrapper.getGrowthFrameCount(self.
_plantarch_ptr, plant_id)
1647 except Exception
as e:
1652 Load plant structure from XML file.
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.
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)
1663 List of plant IDs for the loaded plant instances
1666 ValueError: If filename is empty
1667 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
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.
1675 >>> # Load previously saved plants
1676 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
1677 >>> print(f"Loaded {len(plant_ids)} plants")
1679 >>> # Continue growing the loaded plants
1680 >>> plantarch.advanceTime(10.0)
1682 >>> # Load quietly without console messages
1683 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
1686 raise ValueError(
"Filename cannot be empty")
1693 return plantarch_wrapper.readPlantStructureXML(
1696 except Exception
as e:
1700 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
1702 Create an empty plant instance for custom plant building.
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.
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)
1714 Plant ID for the created plant instance
1717 ValueError: If age is negative
1718 PlantArchitectureError: If plant creation fails
1721 >>> # Create empty plant at origin
1722 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
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"
1730 if not isinstance(base_position, vec3):
1731 raise ValueError(f
"base_position must be a vec3, got {type(base_position).__name__}")
1734 position_list = [base_position.x, base_position.y, base_position.z]
1738 raise ValueError(f
"Age must be non-negative, got {current_age}")
1742 return plantarch_wrapper.addPlantInstance(
1745 except Exception
as e:
1750 Delete a plant instance and all associated geometry.
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.
1757 plant_id: ID of the plant instance to delete
1760 ValueError: If plant_id is negative
1761 PlantArchitectureError: If plant deletion fails or plant doesn't exist
1764 >>> # Delete a plant
1765 >>> plantarch.deletePlantInstance(plant_id)
1767 >>> # Delete multiple plants
1768 >>> for pid in plant_ids_to_remove:
1769 ... plantarch.deletePlantInstance(pid)
1772 raise ValueError(
"Plant ID must be non-negative")
1776 plantarch_wrapper.deletePlantInstance(self.
_plantarch_ptr, plant_id)
1777 except Exception
as e:
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:
1791 Add a base stem shoot to a plant instance (main trunk/stem).
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.
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.
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
1814 Shoot ID for the created shoot
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
1821 >>> from pyhelios import AxisRotation
1823 >>> # REQUIRED: Load a plant model to define shoot types
1824 >>> plantarch.loadPlantModelFromLibrary("bean")
1826 >>> # Create empty plant for custom building
1827 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
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
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")
1854 rotation_list = base_rotation.to_list()
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()
1864 except Exception
as 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}"
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:
1887 Append a shoot to the end of an existing shoot.
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.
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.
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
1910 Shoot ID for the appended shoot
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
1917 >>> # Load model to define shoot types
1918 >>> plantarch.loadPlantModelFromLibrary("bean")
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"
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")
1948 rotation_list = base_rotation.to_list()
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()
1958 except Exception
as 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}"
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:
1983 Add a child shoot at an axillary bud position on a parent shoot.
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).
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.
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)
2009 Shoot ID for the created child shoot
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
2016 >>> # Load model to define shoot types
2017 >>> plantarch.loadPlantModelFromLibrary("bean")
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"
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
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}")
2058 rotation_list = shoot_base_rotation.to_list()
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(),
2069 except Exception
as 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}"
2082 Check if PlantArchitecture is available in current build.
2085 True if plugin is available, False otherwise
2093 Create PlantArchitecture instance with context.
2096 context: Helios Context
2099 PlantArchitecture instance
2102 >>> context = Context()
2103 >>> 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[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.
__enter__(self)
Context manager entry.
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...