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 position_list = [base_position.x, base_position.y, base_position.z]
485 raise ValueError(f
"Age must be non-negative, got {age}")
488 if build_parameters
is not None:
489 if not isinstance(build_parameters, dict):
490 raise ValueError(
"build_parameters must be a dict or None")
491 for key, value
in build_parameters.items():
492 if not isinstance(key, str):
493 raise ValueError(
"build_parameters keys must be strings")
494 if not isinstance(value, (int, float)):
495 raise ValueError(
"build_parameters values must be numeric (int or float)")
499 return plantarch_wrapper.buildPlantInstanceFromLibrary(
502 except Exception
as e:
507 plant_count: int2, age: float,
508 build_parameters: Optional[dict] =
None) -> List[int]:
510 Build a canopy of regularly spaced plants from the currently loaded library model.
513 canopy_center: Cartesian (x,y,z) coordinates of canopy center as vec3
514 plant_spacing: Spacing between plants in x- and y-directions (meters) as vec2
515 plant_count: Number of plants in x- and y-directions as int2
516 age: Age of all plants in days (must be >= 0)
517 build_parameters: Optional dict of parameter overrides for training system parameters.
518 Parameters are applied to all plants in the canopy.
520 - {'cordon_height': 1.8} - for grapevine trellis height
521 - {'trunk_height': 2.5} - for tomato trellis systems
524 List of plant IDs for the created plant instances
527 ValueError: If age is negative, plant count values are not positive, or build_parameters is invalid
528 PlantArchitectureError: If canopy building fails
531 >>> # 3x3 canopy with 0.5m spacing, 30-day-old plants
532 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
533 ... canopy_center=vec3(0, 0, 0),
534 ... plant_spacing=vec2(0.5, 0.5),
535 ... plant_count=int2(3, 3),
538 >>> # With custom parameters
539 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
540 ... canopy_center=vec3(0, 0, 0),
541 ... plant_spacing=vec2(1.5, 2.0),
542 ... plant_count=int2(5, 3),
544 ... build_parameters={'cordon_height': 1.8}
549 raise ValueError(f
"Age must be non-negative, got {age}")
552 if plant_count.x <= 0
or plant_count.y <= 0:
553 raise ValueError(
"Plant count values must be positive integers")
556 if build_parameters
is not None:
557 if not isinstance(build_parameters, dict):
558 raise ValueError(
"build_parameters must be a dict or None")
559 for key, value
in build_parameters.items():
560 if not isinstance(key, str):
561 raise ValueError(
"build_parameters keys must be strings")
562 if not isinstance(value, (int, float)):
563 raise ValueError(
"build_parameters values must be numeric (int or float)")
566 center_list = [canopy_center.x, canopy_center.y, canopy_center.z]
567 spacing_list = [plant_spacing.x, plant_spacing.y]
568 count_list = [plant_count.x, plant_count.y]
572 return plantarch_wrapper.buildPlantCanopyFromLibrary(
573 self.
_plantarch_ptr, center_list, spacing_list, count_list, age, build_parameters
575 except Exception
as e:
580 Advance time for plant growth and development.
582 This method updates all plants in the simulation, potentially adding new phytomers,
583 growing existing organs, transitioning phenological stages, and updating plant geometry.
586 dt: Time step to advance in days (must be >= 0)
589 ValueError: If dt is negative
590 PlantArchitectureError: If time advancement fails
593 Large time steps are more efficient than many small steps. The timestep value
594 can be larger than the phyllochron, allowing multiple phytomers to be produced
598 >>> plantarch.advanceTime(10.0) # Advance 10 days
599 >>> plantarch.advanceTime(0.5) # Advance 12 hours
603 raise ValueError(f
"Time step must be non-negative, got {dt}")
608 except Exception
as e:
613 Get current shoot parameters for a shoot type.
615 Returns a nested dictionary containing all shoot and phytomer parameters
616 including RandomParameter specifications with distribution types.
619 shoot_type_label: Label for the shoot type (e.g., "stem", "branch")
622 Dictionary with shoot parameters including:
623 - Geometric parameters (max_nodes, insertion_angle_tip, etc.)
624 - Growth parameters (phyllochron_min, elongation_rate_max, etc.)
625 - Boolean flags (flowers_require_dormancy, etc.)
626 - RandomParameter fields include 'distribution' and 'parameters' keys
629 ValueError: If shoot_type_label is empty
630 PlantArchitectureError: If parameter retrieval fails
633 >>> plantarch.loadPlantModelFromLibrary("bean")
634 >>> params = plantarch.getCurrentShootParameters("stem")
635 >>> print(params['max_nodes'])
636 {'distribution': 'constant', 'parameters': [15.0]}
638 if not shoot_type_label:
639 raise ValueError(
"Shoot type label cannot be empty")
641 if not shoot_type_label.strip():
642 raise ValueError(
"Shoot type label cannot be only whitespace")
646 return plantarch_wrapper.getCurrentShootParameters(
649 except Exception
as e:
652 def defineShootType(self, shoot_type_label: str, parameters: dict) ->
None:
654 Define a custom shoot type with specified parameters.
656 Allows creating new shoot types or modifying existing ones by providing
657 a parameter dictionary. Use getCurrentShootParameters() to get a template
658 that can be modified.
661 shoot_type_label: Unique name for this shoot type
662 parameters: Dictionary matching ShootParameters structure.
663 Use getCurrentShootParameters() to get proper structure.
666 ValueError: If shoot_type_label is empty or parameters is not a dict
667 PlantArchitectureError: If shoot type definition fails
670 >>> # Get existing parameters as template
671 >>> plantarch.loadPlantModelFromLibrary("bean")
672 >>> params = plantarch.getCurrentShootParameters("stem")
674 >>> # Modify parameters
675 >>> params['max_nodes'] = {'distribution': 'constant', 'parameters': [20.0]}
676 >>> params['insertion_angle_tip'] = {'distribution': 'uniform', 'parameters': [40.0, 50.0]}
678 >>> # Define new shoot type
679 >>> plantarch.defineShootType("TallStem", params)
681 if not shoot_type_label:
682 raise ValueError(
"Shoot type label cannot be empty")
684 if not shoot_type_label.strip():
685 raise ValueError(
"Shoot type label cannot be only whitespace")
687 if not isinstance(parameters, dict):
688 raise ValueError(
"Parameters must be a dict")
692 plantarch_wrapper.defineShootType(
695 except Exception
as e:
700 Get list of all available plant models in the library.
703 List of plant model names available for loading
706 PlantArchitectureError: If retrieval fails
709 >>> models = plantarch.getAvailablePlantModels()
710 >>> print(f"Available models: {', '.join(models)}")
711 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
715 return plantarch_wrapper.getAvailablePlantModels(self.
_plantarch_ptr)
716 except Exception
as e:
721 Get all object IDs for a specific plant.
724 plant_id: ID of the plant instance
727 List of object IDs comprising the plant
730 ValueError: If plant_id is negative
731 PlantArchitectureError: If retrieval fails
734 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
735 >>> print(f"Plant has {len(object_ids)} objects")
738 raise ValueError(
"Plant ID must be non-negative")
741 return plantarch_wrapper.getAllPlantObjectIDs(self.
_plantarch_ptr, plant_id)
742 except Exception
as e:
747 Get all primitive UUIDs for a specific plant.
750 plant_id: ID of the plant instance
753 List of primitive UUIDs comprising the plant
756 ValueError: If plant_id is negative
757 PlantArchitectureError: If retrieval fails
760 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
761 >>> print(f"Plant has {len(uuids)} primitives")
764 raise ValueError(
"Plant ID must be non-negative")
767 return plantarch_wrapper.getAllPlantUUIDs(self.
_plantarch_ptr, plant_id)
768 except Exception
as e:
773 Get the current age of a plant in days.
776 plant_id: ID of the plant instance
782 ValueError: If plant_id is negative
783 PlantArchitectureError: If retrieval fails
786 >>> age = plantarch.getPlantAge(plant_id)
787 >>> print(f"Plant is {age} days old")
790 raise ValueError(
"Plant ID must be non-negative")
794 return plantarch_wrapper.getPlantAge(self.
_plantarch_ptr, plant_id)
795 except Exception
as e:
800 Get the height of a plant in meters.
803 plant_id: ID of the plant instance
806 Plant height in meters (vertical extent)
809 ValueError: If plant_id is negative
810 PlantArchitectureError: If retrieval fails
813 >>> height = plantarch.getPlantHeight(plant_id)
814 >>> print(f"Plant is {height:.2f}m tall")
817 raise ValueError(
"Plant ID must be non-negative")
821 return plantarch_wrapper.getPlantHeight(self.
_plantarch_ptr, plant_id)
822 except Exception
as e:
827 Get the total leaf area of a plant in m².
830 plant_id: ID of the plant instance
833 Total leaf area in square meters
836 ValueError: If plant_id is negative
837 PlantArchitectureError: If retrieval fails
840 >>> leaf_area = plantarch.getPlantLeafArea(plant_id)
841 >>> print(f"Total leaf area: {leaf_area:.3f} m²")
844 raise ValueError(
"Plant ID must be non-negative")
848 return plantarch_wrapper.sumPlantLeafArea(self.
_plantarch_ptr, plant_id)
849 except Exception
as e:
855 time_to_dormancy_break: float,
856 time_to_flower_initiation: float,
857 time_to_flower_opening: float,
858 time_to_fruit_set: float,
859 time_to_fruit_maturity: float,
860 time_to_dormancy: float,
861 max_leaf_lifespan: float = 1e6
864 Set phenological timing thresholds for plant developmental stages.
866 Controls the timing of key phenological events based on thermal time
867 or calendar time depending on the plant model.
870 plant_id: ID of the plant instance
871 time_to_dormancy_break: Degree-days or days until dormancy ends
872 time_to_flower_initiation: Time until flower buds are initiated
873 time_to_flower_opening: Time until flowers open
874 time_to_fruit_set: Time until fruit begins developing
875 time_to_fruit_maturity: Time until fruit reaches maturity
876 time_to_dormancy: Time until plant enters dormancy
877 max_leaf_lifespan: Maximum leaf lifespan in days (default: 1e6)
880 ValueError: If plant_id is negative
881 PlantArchitectureError: If phenology setting fails
884 >>> # Set phenology for perennial fruit tree
885 >>> plantarch.setPlantPhenologicalThresholds(
886 ... plant_id=plant_id,
887 ... time_to_dormancy_break=60, # Spring: 60 degree-days
888 ... time_to_flower_initiation=90, # Early spring flowering
889 ... time_to_flower_opening=105, # Bloom period
890 ... time_to_fruit_set=120, # Fruit set after pollination
891 ... time_to_fruit_maturity=200, # Summer fruit maturation
892 ... time_to_dormancy=280, # Fall dormancy
893 ... max_leaf_lifespan=180 # Deciduous - 6 month leaf life
897 raise ValueError(
"Plant ID must be non-negative")
901 plantarch_wrapper.setPlantPhenologicalThresholds(
904 time_to_dormancy_break,
905 time_to_flower_initiation,
906 time_to_flower_opening,
908 time_to_fruit_maturity,
912 except Exception
as e:
917 target_object_UUIDs: Optional[List[int]] =
None,
918 target_object_IDs: Optional[List[int]] =
None,
919 enable_petiole_collision: bool =
False,
920 enable_fruit_collision: bool =
False) ->
None:
922 Enable soft collision avoidance for procedural plant growth.
924 This method enables the collision detection system that guides plant growth away from
925 obstacles and other plants. The system uses cone-based gap detection to find optimal
926 growth directions that minimize collisions while maintaining natural plant architecture.
929 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
930 avoids all geometry in the context.
931 target_object_IDs: List of compound object IDs to avoid collisions with.
932 enable_petiole_collision: Enable collision detection for leaf petioles
933 enable_fruit_collision: Enable collision detection for fruit organs
936 PlantArchitectureError: If collision detection activation fails
939 Collision detection adds computational overhead. Use setStaticObstacles() to mark
940 static geometry for BVH optimization and improved performance.
943 >>> # Avoid all geometry
944 >>> plantarch.enableSoftCollisionAvoidance()
946 >>> # Avoid specific obstacles
947 >>> obstacle_uuids = context.getAllUUIDs()
948 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
950 >>> # Enable collision detection for petioles and fruit
951 >>> plantarch.enableSoftCollisionAvoidance(
952 ... enable_petiole_collision=True,
953 ... enable_fruit_collision=True
958 plantarch_wrapper.enableSoftCollisionAvoidance(
960 target_UUIDs=target_object_UUIDs,
961 target_IDs=target_object_IDs,
962 enable_petiole=enable_petiole_collision,
963 enable_fruit=enable_fruit_collision
965 except Exception
as e:
970 Disable collision detection for plant growth.
972 This method turns off the collision detection system, allowing plants to grow
973 without checking for obstacles. This improves performance but plants may grow
974 through obstacles and other geometry.
977 PlantArchitectureError: If disabling fails
980 >>> plantarch.disableCollisionDetection()
984 except Exception
as e:
988 view_half_angle_deg: float = 80.0,
989 look_ahead_distance: float = 0.1,
990 sample_count: int = 256,
991 inertia_weight: float = 0.4) ->
None:
993 Configure parameters for soft collision avoidance algorithm.
995 These parameters control the cone-based gap detection algorithm that guides
996 plant growth away from obstacles. Adjusting these values allows fine-tuning
997 the balance between collision avoidance and natural growth patterns.
1000 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
1001 Default 80° provides wide field of view.
1002 look_ahead_distance: Distance to look ahead for collisions in meters.
1003 Larger values detect distant obstacles. Default 0.1m.
1004 sample_count: Number of ray samples within cone. More samples improve
1005 accuracy but reduce performance. Default 256.
1006 inertia_weight: Weight for previous growth direction (0-1). Higher values
1007 make growth smoother but less responsive. Default 0.4.
1010 ValueError: If parameters are outside valid ranges
1011 PlantArchitectureError: If parameter setting fails
1014 >>> # Use default parameters (recommended)
1015 >>> plantarch.setSoftCollisionAvoidanceParameters()
1017 >>> # Tune for dense canopy with close obstacles
1018 >>> plantarch.setSoftCollisionAvoidanceParameters(
1019 ... view_half_angle_deg=60.0, # Narrower detection cone
1020 ... look_ahead_distance=0.05, # Shorter look-ahead
1021 ... sample_count=512, # More accurate detection
1022 ... inertia_weight=0.3 # More responsive to obstacles
1026 if not (0 <= view_half_angle_deg <= 180):
1027 raise ValueError(f
"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
1028 if look_ahead_distance <= 0:
1029 raise ValueError(f
"look_ahead_distance must be positive, got {look_ahead_distance}")
1030 if sample_count <= 0:
1031 raise ValueError(f
"sample_count must be positive, got {sample_count}")
1032 if not (0 <= inertia_weight <= 1):
1033 raise ValueError(f
"inertia_weight must be between 0 and 1, got {inertia_weight}")
1036 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
1038 view_half_angle_deg,
1039 look_ahead_distance,
1043 except Exception
as e:
1047 include_internodes: bool =
False,
1048 include_leaves: bool =
True,
1049 include_petioles: bool =
False,
1050 include_flowers: bool =
False,
1051 include_fruit: bool =
False) ->
None:
1053 Specify which plant organs participate in collision detection.
1055 This method allows filtering which organs are considered during collision detection,
1056 enabling optimization by excluding organs unlikely to cause problematic collisions.
1059 include_internodes: Include stem internodes in collision detection
1060 include_leaves: Include leaf blades in collision detection
1061 include_petioles: Include leaf petioles in collision detection
1062 include_flowers: Include flowers in collision detection
1063 include_fruit: Include fruit in collision detection
1066 PlantArchitectureError: If organ filtering fails
1069 >>> # Only detect collisions for stems and leaves (default behavior)
1070 >>> plantarch.setCollisionRelevantOrgans(
1071 ... include_internodes=True,
1072 ... include_leaves=True
1075 >>> # Include all organs
1076 >>> plantarch.setCollisionRelevantOrgans(
1077 ... include_internodes=True,
1078 ... include_leaves=True,
1079 ... include_petioles=True,
1080 ... include_flowers=True,
1081 ... include_fruit=True
1085 plantarch_wrapper.setCollisionRelevantOrgans(
1093 except Exception
as e:
1097 obstacle_UUIDs: List[int],
1098 avoidance_distance: float = 0.5,
1099 enable_fruit_adjustment: bool =
False,
1100 enable_obstacle_pruning: bool =
False) ->
None:
1102 Enable hard obstacle avoidance for specified geometry.
1104 This method configures solid obstacles that plants cannot grow through. Unlike soft
1105 collision avoidance (which guides growth), solid obstacles cause complete growth
1106 termination when encountered within the avoidance distance.
1109 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
1110 avoidance_distance: Minimum distance to maintain from obstacles (meters).
1111 Growth stops if obstacles are closer. Default 0.5m.
1112 enable_fruit_adjustment: Adjust fruit positions away from obstacles
1113 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
1116 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
1117 PlantArchitectureError: If solid obstacle configuration fails
1120 >>> # Simple solid obstacle avoidance
1121 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
1122 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
1124 >>> # Close avoidance with fruit adjustment
1125 >>> plantarch.enableSolidObstacleAvoidance(
1126 ... obstacle_UUIDs=wall_uuids,
1127 ... avoidance_distance=0.1,
1128 ... enable_fruit_adjustment=True
1131 if not obstacle_UUIDs:
1132 raise ValueError(
"Obstacle UUIDs list cannot be empty")
1133 if avoidance_distance <= 0:
1134 raise ValueError(f
"avoidance_distance must be positive, got {avoidance_distance}")
1138 plantarch_wrapper.enableSolidObstacleAvoidance(
1142 enable_fruit_adjustment,
1143 enable_obstacle_pruning
1145 except Exception
as e:
1150 Mark geometry as static obstacles for collision detection optimization.
1152 This method tells the collision detection system that certain geometry will not
1153 move during the simulation. The system can then build an optimized Bounding Volume
1154 Hierarchy (BVH) for these obstacles, significantly improving collision detection
1155 performance in scenes with many static obstacles.
1158 target_UUIDs: List of primitive UUIDs representing static obstacles
1161 ValueError: If target_UUIDs is empty
1162 PlantArchitectureError: If static obstacle configuration fails
1165 Call this method BEFORE enabling collision avoidance for best performance.
1166 Static obstacles cannot be modified or moved after being marked static.
1169 >>> # Mark ground and building geometry as static
1170 >>> static_uuids = ground_uuids + building_uuids
1171 >>> plantarch.setStaticObstacles(static_uuids)
1172 >>> # Now enable collision avoidance
1173 >>> plantarch.enableSoftCollisionAvoidance()
1175 if not target_UUIDs:
1176 raise ValueError(
"target_UUIDs list cannot be empty")
1180 plantarch_wrapper.setStaticObstacles(self.
_plantarch_ptr, target_UUIDs)
1181 except Exception
as e:
1186 Get object IDs of collision-relevant geometry for a specific plant.
1188 This method returns the subset of plant geometry that participates in collision
1189 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
1190 and debugging collision detection behavior.
1193 plant_id: ID of the plant instance
1196 List of object IDs for collision-relevant plant geometry
1199 ValueError: If plant_id is negative
1200 PlantArchitectureError: If retrieval fails
1203 >>> # Get collision-relevant geometry
1204 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
1205 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
1207 >>> # Highlight collision geometry in visualization
1208 >>> for obj_id in collision_obj_ids:
1209 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
1212 raise ValueError(
"Plant ID must be non-negative")
1215 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self.
_plantarch_ptr, plant_id)
1216 except Exception
as e:
1222 Write all plant mesh vertices to file for external processing.
1224 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
1225 writing one vertex per line. Useful for external processing such as computing bounding
1226 volumes, convex hulls, or performing custom geometric analysis.
1229 plant_id: ID of the plant instance to export
1230 filename: Path to output file (absolute or relative to current working directory)
1233 ValueError: If plant_id is negative or filename is empty
1234 PlantArchitectureError: If plant doesn't exist or file cannot be written
1237 >>> # Export vertices for convex hull analysis
1238 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
1240 >>> # Use with Path object
1241 >>> from pathlib import Path
1242 >>> output_dir = Path("output")
1243 >>> output_dir.mkdir(exist_ok=True)
1244 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
1247 raise ValueError(
"Plant ID must be non-negative")
1249 raise ValueError(
"Filename cannot be empty")
1256 plantarch_wrapper.writePlantMeshVertices(
1259 except Exception
as e:
1264 Save plant structure to XML file for later loading.
1266 This method exports the complete plant architecture to an XML file, including
1267 all shoots, phytomers, organs, and their properties. The saved plant can be
1268 reloaded later using readPlantStructureXML().
1271 plant_id: ID of the plant instance to save
1272 filename: Path to output XML file (absolute or relative to current working directory)
1275 ValueError: If plant_id is negative or filename is empty
1276 PlantArchitectureError: If plant doesn't exist or file cannot be written
1279 The XML format preserves the complete plant state including:
1280 - Shoot structure and hierarchy
1281 - Phytomer properties and development stage
1282 - Organ geometry and attributes
1283 - Growth parameters and phenological state
1286 >>> # Save plant at current growth stage
1287 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
1289 >>> # Later, reload the saved plant
1290 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
1291 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
1294 raise ValueError(
"Plant ID must be non-negative")
1296 raise ValueError(
"Filename cannot be empty")
1303 plantarch_wrapper.writePlantStructureXML(
1306 except Exception
as e:
1311 Export plant structure in TreeQSM cylinder format.
1313 This method writes the plant structure as a series of cylinders following the
1314 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
1315 columns for radius, length, start position, axis direction, branch topology,
1316 and other structural properties. Useful for biomechanical analysis and
1317 quantitative structure modeling.
1320 plant_id: ID of the plant instance to export
1321 filename: Path to output file (absolute or relative, typically .txt extension)
1324 ValueError: If plant_id is negative or filename is empty
1325 PlantArchitectureError: If plant doesn't exist or file cannot be written
1328 The TreeQSM format includes columns for:
1329 - Cylinder dimensions (radius, length)
1330 - Spatial position and orientation
1331 - Branch topology (parent ID, extension ID, branch ID)
1332 - Branch hierarchy (branch order, position in branch)
1333 - Quality metrics (mean absolute distance, surface coverage)
1336 >>> # Export for biomechanical analysis
1337 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
1339 >>> # Use with external QSM tools
1340 >>> import pandas as pd
1341 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
1342 >>> print(f"Tree has {len(qsm_data)} cylinders")
1345 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
1346 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
1349 raise ValueError(
"Plant ID must be non-negative")
1351 raise ValueError(
"Filename cannot be empty")
1358 plantarch_wrapper.writeQSMCylinderFile(
1361 except Exception
as e:
1366 Load plant structure from XML file.
1368 This method reads plant architecture data from an XML file previously saved with
1369 writePlantStructureXML(). The loaded plants are added to the current context
1370 and can be grown, modified, or analyzed like any other plants.
1373 filename: Path to XML file to load (absolute or relative to current working directory)
1374 quiet: If True, suppress console output during loading (default: False)
1377 List of plant IDs for the loaded plant instances
1380 ValueError: If filename is empty
1381 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
1384 The XML file can contain multiple plant instances. All plants in the file
1385 will be loaded and their IDs returned in a list. Plant models referenced
1386 in the XML must be available in the plant library.
1389 >>> # Load previously saved plants
1390 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
1391 >>> print(f"Loaded {len(plant_ids)} plants")
1393 >>> # Continue growing the loaded plants
1394 >>> plantarch.advanceTime(10.0)
1396 >>> # Load quietly without console messages
1397 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
1400 raise ValueError(
"Filename cannot be empty")
1407 return plantarch_wrapper.readPlantStructureXML(
1410 except Exception
as e:
1414 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
1416 Create an empty plant instance for custom plant building.
1418 This method creates a new plant instance at the specified location without any
1419 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
1420 manually construct the plant structure. This provides low-level control over
1421 plant architecture, enabling custom morphologies not available in the plant library.
1424 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
1425 current_age: Current age of the plant in days (must be >= 0)
1428 Plant ID for the created plant instance
1431 ValueError: If age is negative
1432 PlantArchitectureError: If plant creation fails
1435 >>> # Create empty plant at origin
1436 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1438 >>> # Now add shoots to build custom plant structure
1439 >>> shoot_id = plantarch.addBaseStemShoot(
1440 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
1444 position_list = [base_position.x, base_position.y, base_position.z]
1448 raise ValueError(f
"Age must be non-negative, got {current_age}")
1452 return plantarch_wrapper.addPlantInstance(
1455 except Exception
as e:
1460 Delete a plant instance and all associated geometry.
1462 This method removes a plant from the simulation, deleting all shoots, organs,
1463 and associated primitives from the context. The plant ID becomes invalid after
1464 deletion and should not be used in subsequent operations.
1467 plant_id: ID of the plant instance to delete
1470 ValueError: If plant_id is negative
1471 PlantArchitectureError: If plant deletion fails or plant doesn't exist
1474 >>> # Delete a plant
1475 >>> plantarch.deletePlantInstance(plant_id)
1477 >>> # Delete multiple plants
1478 >>> for pid in plant_ids_to_remove:
1479 ... plantarch.deletePlantInstance(pid)
1482 raise ValueError(
"Plant ID must be non-negative")
1486 plantarch_wrapper.deletePlantInstance(self.
_plantarch_ptr, plant_id)
1487 except Exception
as e:
1492 current_node_number: int,
1493 base_rotation: AxisRotation,
1494 internode_radius: float,
1495 internode_length_max: float,
1496 internode_length_scale_factor_fraction: float,
1497 leaf_scale_factor_fraction: float,
1498 radius_taper: float,
1499 shoot_type_label: str) -> int:
1501 Add a base stem shoot to a plant instance (main trunk/stem).
1503 This method creates the primary shoot originating from the plant base. The base stem
1504 is typically the main trunk or primary stem from which all other shoots branch.
1505 Specify growth parameters to control the shoot's morphology and development.
1507 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
1508 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
1509 shoot types that can then be used for custom building. The shoot_type_label must match a
1510 shoot type defined in the loaded model.
1513 plant_id: ID of the plant instance
1514 current_node_number: Starting node number for this shoot (typically 1)
1515 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1516 internode_radius: Base radius of internodes in meters (must be > 0)
1517 internode_length_max: Maximum internode length in meters (must be > 0)
1518 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1519 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1520 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1521 shoot_type_label: Label identifying shoot type - must match a type from loaded model
1524 Shoot ID for the created shoot
1527 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1528 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
1531 >>> from pyhelios import AxisRotation
1533 >>> # REQUIRED: Load a plant model to define shoot types
1534 >>> plantarch.loadPlantModelFromLibrary("bean")
1536 >>> # Create empty plant for custom building
1537 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1539 >>> # Add base stem using shoot type from loaded model
1540 >>> shoot_id = plantarch.addBaseStemShoot(
1541 ... plant_id=plant_id,
1542 ... current_node_number=1,
1543 ... base_rotation=AxisRotation(0, 0, 0), # Upright
1544 ... internode_radius=0.01, # 1cm radius
1545 ... internode_length_max=0.1, # 10cm max length
1546 ... internode_length_scale_factor_fraction=1.0,
1547 ... leaf_scale_factor_fraction=1.0,
1548 ... radius_taper=0.9, # Gradual taper
1549 ... shoot_type_label="stem" # Must match loaded model
1553 raise ValueError(
"Plant ID must be non-negative")
1554 if current_node_number < 0:
1555 raise ValueError(
"Current node number must be non-negative")
1556 if internode_radius <= 0:
1557 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1558 if internode_length_max <= 0:
1559 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1560 if not shoot_type_label
or not shoot_type_label.strip():
1561 raise ValueError(
"Shoot type label cannot be empty")
1564 rotation_list = base_rotation.to_list()
1568 return plantarch_wrapper.addBaseStemShoot(
1569 self.
_plantarch_ptr, plant_id, current_node_number, rotation_list,
1570 internode_radius, internode_length_max,
1571 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1572 radius_taper, shoot_type_label.strip()
1574 except Exception
as e:
1576 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1578 f
"Shoot type '{shoot_type_label}' not defined. "
1579 f
"Load a plant model first to define shoot types:\n"
1580 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1581 f
"Original error: {e}"
1587 parent_shoot_id: int,
1588 current_node_number: int,
1589 base_rotation: AxisRotation,
1590 internode_radius: float,
1591 internode_length_max: float,
1592 internode_length_scale_factor_fraction: float,
1593 leaf_scale_factor_fraction: float,
1594 radius_taper: float,
1595 shoot_type_label: str) -> int:
1597 Append a shoot to the end of an existing shoot.
1599 This method extends an existing shoot by appending a new shoot at its terminal bud.
1600 Useful for creating multi-segmented shoots with varying properties along their length,
1601 such as shoots with different growth phases or developmental stages.
1603 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1604 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1605 calling this method.
1608 plant_id: ID of the plant instance
1609 parent_shoot_id: ID of the parent shoot to extend
1610 current_node_number: Starting node number for this shoot
1611 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1612 internode_radius: Base radius of internodes in meters (must be > 0)
1613 internode_length_max: Maximum internode length in meters (must be > 0)
1614 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1615 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1616 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1617 shoot_type_label: Label identifying shoot type - must match loaded model
1620 Shoot ID for the appended shoot
1623 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1624 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
1627 >>> # Load model to define shoot types
1628 >>> plantarch.loadPlantModelFromLibrary("bean")
1630 >>> # Append shoot with reduced size to simulate apical growth
1631 >>> new_shoot_id = plantarch.appendShoot(
1632 ... plant_id=plant_id,
1633 ... parent_shoot_id=base_shoot_id,
1634 ... current_node_number=10,
1635 ... base_rotation=AxisRotation(0, 0, 0),
1636 ... internode_radius=0.008, # Smaller than base
1637 ... internode_length_max=0.08, # Shorter internodes
1638 ... internode_length_scale_factor_fraction=1.0,
1639 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
1640 ... radius_taper=0.85,
1641 ... shoot_type_label="stem"
1645 raise ValueError(
"Plant ID must be non-negative")
1646 if parent_shoot_id < 0:
1647 raise ValueError(
"Parent shoot ID must be non-negative")
1648 if current_node_number < 0:
1649 raise ValueError(
"Current node number must be non-negative")
1650 if internode_radius <= 0:
1651 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1652 if internode_length_max <= 0:
1653 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1654 if not shoot_type_label
or not shoot_type_label.strip():
1655 raise ValueError(
"Shoot type label cannot be empty")
1658 rotation_list = base_rotation.to_list()
1662 return plantarch_wrapper.appendShoot(
1663 self.
_plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
1664 rotation_list, internode_radius, internode_length_max,
1665 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1666 radius_taper, shoot_type_label.strip()
1668 except Exception
as e:
1670 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1672 f
"Shoot type '{shoot_type_label}' not defined. "
1673 f
"Load a plant model first to define shoot types:\n"
1674 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1675 f
"Original error: {e}"
1681 parent_shoot_id: int,
1682 parent_node_index: int,
1683 current_node_number: int,
1684 shoot_base_rotation: AxisRotation,
1685 internode_radius: float,
1686 internode_length_max: float,
1687 internode_length_scale_factor_fraction: float,
1688 leaf_scale_factor_fraction: float,
1689 radius_taper: float,
1690 shoot_type_label: str,
1691 petiole_index: int = 0) -> int:
1693 Add a child shoot at an axillary bud position on a parent shoot.
1695 This method creates a lateral branch shoot emerging from a specific node on the
1696 parent shoot. Child shoots enable creation of branching architectures, with control
1697 over branch angle, size, and which petiole position the branch emerges from (for
1698 plants with multiple petioles per node).
1700 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1701 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1702 calling this method.
1705 plant_id: ID of the plant instance
1706 parent_shoot_id: ID of the parent shoot
1707 parent_node_index: Index of the parent node where child emerges (0-based)
1708 current_node_number: Starting node number for this child shoot
1709 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1710 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
1711 internode_length_max: Maximum internode length in meters (must be > 0)
1712 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1713 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1714 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1715 shoot_type_label: Label identifying shoot type - must match loaded model
1716 petiole_index: Which petiole at the node to branch from (default: 0)
1719 Shoot ID for the created child shoot
1722 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
1723 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
1726 >>> # Load model to define shoot types
1727 >>> plantarch.loadPlantModelFromLibrary("bean")
1729 >>> # Add lateral branch at 45-degree angle from node 3
1730 >>> branch_id = plantarch.addChildShoot(
1731 ... plant_id=plant_id,
1732 ... parent_shoot_id=main_shoot_id,
1733 ... parent_node_index=3,
1734 ... current_node_number=1,
1735 ... shoot_base_rotation=AxisRotation(45, 90, 0), # 45° out, 90° rotation
1736 ... internode_radius=0.005, # Thinner than main stem
1737 ... internode_length_max=0.06, # Shorter internodes
1738 ... internode_length_scale_factor_fraction=1.0,
1739 ... leaf_scale_factor_fraction=0.9,
1740 ... radius_taper=0.8,
1741 ... shoot_type_label="stem"
1744 >>> # Add second branch from opposite petiole
1745 >>> branch_id2 = plantarch.addChildShoot(
1746 ... plant_id, main_shoot_id, 3, 1, AxisRotation(45, 270, 0),
1747 ... 0.005, 0.06, 1.0, 0.9, 0.8, "stem", petiole_index=1
1751 raise ValueError(
"Plant ID must be non-negative")
1752 if parent_shoot_id < 0:
1753 raise ValueError(
"Parent shoot ID must be non-negative")
1754 if parent_node_index < 0:
1755 raise ValueError(
"Parent node index must be non-negative")
1756 if current_node_number < 0:
1757 raise ValueError(
"Current node number must be non-negative")
1758 if internode_radius <= 0:
1759 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1760 if internode_length_max <= 0:
1761 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1762 if not shoot_type_label
or not shoot_type_label.strip():
1763 raise ValueError(
"Shoot type label cannot be empty")
1764 if petiole_index < 0:
1765 raise ValueError(f
"Petiole index must be non-negative, got {petiole_index}")
1768 rotation_list = shoot_base_rotation.to_list()
1772 return plantarch_wrapper.addChildShoot(
1773 self.
_plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
1774 current_node_number, rotation_list, internode_radius,
1775 internode_length_max, internode_length_scale_factor_fraction,
1776 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
1779 except Exception
as e:
1781 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1783 f
"Shoot type '{shoot_type_label}' not defined. "
1784 f
"Load a plant model first to define shoot types:\n"
1785 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1786 f
"Original error: {e}"
1792 Check if PlantArchitecture is available in current build.
1795 True if plugin is available, False otherwise
1803 Create PlantArchitecture instance with context.
1806 context: Helios Context
1809 PlantArchitecture instance
1812 >>> context = Context()
1813 >>> plantarch = create_plant_architecture(context)
Raised when PlantArchitecture operations fail.
High-level interface for plant architecture modeling and procedural plant generation.
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 advanceTime(self, float dt)
Advance time for plant growth and development.
List[int] getAllPlantUUIDs(self, int plant_id)
Get all primitive UUIDs for a specific plant.
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.
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.
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.
None loadPlantModelFromLibrary(self, str plant_label)
Load a plant model from the built-in library.
None disableCollisionDetection(self)
Disable collision detection for plant growth.
List[int] buildPlantCanopyFromLibrary(self, vec3 canopy_center, vec2 plant_spacing, int2 plant_count, float age, Optional[dict] build_parameters=None)
Build a canopy of regularly spaced plants from the currently loaded library model.
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.
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².
__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...