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, check_context_alive
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
47from .plant_architecture_params
import (
49 CarbohydrateParameters,
56logger = logging.getLogger(__name__)
61 Convert relative paths to absolute paths before changing working directory.
63 This preserves the user's intended file location when the working directory
64 is temporarily changed for C++ asset access. Absolute paths are returned unchanged.
67 filepath: File path to resolve (string or Path object)
70 Absolute path as string
73 if not path.is_absolute():
74 return str(Path.cwd() / path)
81 Context manager that temporarily changes working directory to where PlantArchitecture assets are located.
83 PlantArchitecture C++ code uses hardcoded relative paths like "plugins/plantarchitecture/assets/textures/"
84 expecting assets relative to working directory. This manager temporarily changes to the build directory
85 where assets are actually located.
88 RuntimeError: If build directory or PlantArchitecture assets are not found, indicating a build system error.
92 asset_manager = get_asset_manager()
93 working_dir = asset_manager._get_helios_build_path()
95 if working_dir
and working_dir.exists():
96 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
99 current_dir = Path(__file__).parent
100 packaged_build = current_dir /
'assets' /
'build'
102 if packaged_build.exists():
103 working_dir = packaged_build
104 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
107 repo_root = current_dir.parent
108 build_lib_dir = repo_root /
'pyhelios_build' /
'build' /
'lib'
109 working_dir = build_lib_dir.parent
110 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
112 if not build_lib_dir.exists():
114 f
"PyHelios build directory not found at {build_lib_dir}. "
115 f
"PlantArchitecture requires native libraries to be built. "
116 f
"Run: build_scripts/build_helios --plugins plantarchitecture"
119 if not plantarch_assets.exists():
121 f
"PlantArchitecture assets not found at {plantarch_assets}. "
122 f
"Build system failed to copy PlantArchitecture assets. "
123 f
"Run: build_scripts/build_helios --clean --plugins plantarchitecture"
127 assets_dir = plantarch_assets /
'assets'
128 if not assets_dir.exists():
130 f
"PlantArchitecture assets directory not found: {assets_dir}. "
131 f
"Essential assets missing. Rebuild with: "
132 f
"build_scripts/build_helios --clean --plugins plantarchitecture"
136 original_dir = os.getcwd()
138 os.chdir(working_dir)
139 logger.debug(f
"Changed working directory to {working_dir} for PlantArchitecture asset access")
142 os.chdir(original_dir)
143 logger.debug(f
"Restored working directory to {original_dir}")
147 """Raised when PlantArchitecture operations fail."""
153 Check if PlantArchitecture plugin is available for use.
156 bool: True if PlantArchitecture can be used, False otherwise
160 plugin_registry = get_plugin_registry()
161 if not plugin_registry.is_plugin_available(
'plantarchitecture'):
165 if not plantarch_wrapper._PLANTARCHITECTURE_FUNCTIONS_AVAILABLE:
175 High-level interface for plant architecture modeling and procedural plant generation.
177 PlantArchitecture provides access to the comprehensive plant library with 25+ plant models
178 including trees (almond, apple, olive, walnut), crops (bean, cowpea, maize, rice, soybean),
179 and other plants. This class enables procedural plant generation, time-based growth
180 simulation, and plant community modeling.
182 This class requires the native Helios library built with PlantArchitecture support.
183 Use context managers for proper resource cleanup.
186 >>> with Context() as context:
187 ... with PlantArchitecture(context) as plantarch:
188 ... plantarch.loadPlantModelFromLibrary("bean")
189 ... plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(0, 0, 0), age=30)
190 ... plantarch.advanceTime(10.0) # Grow for 10 days
193 def __new__(cls, context=None):
195 Create PlantArchitecture instance.
196 Explicit __new__ to prevent ctypes contamination on Windows.
198 return object.__new__(cls)
200 def __init__(self, context: Context):
202 Initialize PlantArchitecture with a Helios context.
205 context: Active Helios Context instance
208 PlantArchitectureError: If plugin not available in current build
209 RuntimeError: If plugin initialization fails
212 registry = get_plugin_registry()
213 if not registry.is_plugin_available(
'plantarchitecture'):
215 "PlantArchitecture not available in current Helios library. "
216 "Rebuild PyHelios with PlantArchitecture support:\n"
217 " build_scripts/build_helios --plugins plantarchitecture\n"
219 "System requirements:\n"
220 f
" - Platforms: Windows, Linux, macOS\n"
221 " - Dependencies: Extensive asset library (textures, OBJ models)\n"
222 " - GPU: Not required\n"
224 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
225 "rice, soybean, tomato, wheat, and many others."
233 self.
_plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
239 """Raise if the owning Context has been destroyed (see Context.check_context_alive)."""
240 check_context_alive(getattr(self,
"context",
None),
"PlantArchitecture")
243 """Context manager entry"""
246 def __exit__(self, exc_type, exc_val, exc_tb):
247 """Context manager exit - cleanup resources"""
253 """Destructor to ensure C++ resources freed even without 'with' statement."""
254 if hasattr(self,
'_plantarch_ptr')
and self.
_plantarch_ptr is not None:
258 except Exception
as e:
260 warnings.warn(f
"Error in PlantArchitecture.__del__: {e}")
264 Load a plant model from the built-in library.
267 plant_label: Plant model identifier from library. Available models include:
268 "almond", "apple", "bean", "bindweed", "butterlettuce", "capsicum",
269 "cheeseweed", "cowpea", "easternredbud", "grapevine_VSP", "maize",
270 "olive", "pistachio", "puncturevine", "rice", "sorghum", "soybean",
271 "strawberry", "sugarbeet", "tomato", "cherrytomato", "walnut", "wheat"
274 ValueError: If plant_label is empty or invalid
275 PlantArchitectureError: If model loading fails
278 >>> plantarch.loadPlantModelFromLibrary("bean")
279 >>> plantarch.loadPlantModelFromLibrary("almond")
282 raise ValueError(
"Plant label cannot be empty")
284 if not plant_label.strip():
285 raise ValueError(
"Plant label cannot be only whitespace")
290 plantarch_wrapper.loadPlantModelFromLibrary(self.
_plantarch_ptr, plant_label.strip())
291 except Exception
as e:
295 build_parameters: Optional[dict] =
None) -> int:
297 Build a plant instance from the currently loaded library model.
300 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
301 age: Age of the plant in days (must be >= 0)
302 build_parameters: Optional dict of parameter overrides for training system parameters.
304 - {'trunk_height': 2.5} - for tomato trellis height
305 - {'cordon_height': 1.8, 'cordon_radius': 1.2} - for apple training
306 - {'row_spacing': 0.75} - for grapevine VSP trellis
309 Plant ID for the created plant instance
312 ValueError: If age is negative or build_parameters is invalid
313 PlantArchitectureError: If plant building fails
314 RuntimeError: If no model has been loaded
317 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(2.0, 3.0, 0.0), age=45.0)
318 >>> # With custom parameters
319 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(
320 ... base_position=vec3(0, 0, 0),
322 ... build_parameters={'trunk_height': 2.0}
326 if not isinstance(base_position, vec3):
327 raise ValueError(f
"base_position must be a vec3, got {type(base_position).__name__}")
330 position_list = [base_position.x, base_position.y, base_position.z]
334 raise ValueError(f
"Age must be non-negative, got {age}")
337 if build_parameters
is not None:
338 if not isinstance(build_parameters, dict):
339 raise ValueError(
"build_parameters must be a dict or None")
340 for key, value
in build_parameters.items():
341 if not isinstance(key, str):
342 raise ValueError(
"build_parameters keys must be strings")
343 if not isinstance(value, (int, float)):
344 raise ValueError(
"build_parameters values must be numeric (int or float)")
349 return plantarch_wrapper.buildPlantInstanceFromLibrary(
352 except Exception
as e:
357 plant_count: int2, age: float,
358 germination_rate: float = 1.0,
359 build_parameters: Optional[dict] =
None) -> List[int]:
361 Build a canopy of regularly spaced plants from the currently loaded library model.
364 canopy_center: Cartesian (x,y,z) coordinates of canopy center as vec3
365 plant_spacing: Spacing between plants in x- and y-directions (meters) as vec2
366 plant_count: Number of plants in x- and y-directions as int2
367 age: Age of all plants in days (must be >= 0)
368 germination_rate: Probability that each plant position will be occupied (0 to 1).
369 A value of 1.0 means all positions are filled; 0.5 means roughly
370 half the positions will have plants. Default is 1.0.
371 build_parameters: Optional dict of parameter overrides for training system parameters.
372 Parameters are applied to all plants in the canopy.
374 - {'cordon_height': 1.8} - for grapevine trellis height
375 - {'trunk_height': 2.5} - for tomato trellis systems
378 List of plant IDs for the created plant instances
381 ValueError: If age is negative, germination_rate is not in [0, 1],
382 plant count values are not positive, or build_parameters is invalid
383 PlantArchitectureError: If canopy building fails
386 >>> # 3x3 canopy with 0.5m spacing, 30-day-old plants
387 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
388 ... canopy_center=vec3(0, 0, 0),
389 ... plant_spacing=vec2(0.5, 0.5),
390 ... plant_count=int2(3, 3),
393 >>> # With 80% germination rate and custom parameters
394 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
395 ... canopy_center=vec3(0, 0, 0),
396 ... plant_spacing=vec2(1.5, 2.0),
397 ... plant_count=int2(5, 3),
399 ... germination_rate=0.8,
400 ... build_parameters={'cordon_height': 1.8}
404 if not isinstance(canopy_center, vec3):
405 raise ValueError(f
"canopy_center must be a vec3, got {type(canopy_center).__name__}")
406 if not isinstance(plant_spacing, vec2):
407 raise ValueError(f
"plant_spacing must be a vec2, got {type(plant_spacing).__name__}")
408 if not isinstance(plant_count, int2):
409 raise ValueError(f
"plant_count must be an int2, got {type(plant_count).__name__}")
413 raise ValueError(f
"Age must be non-negative, got {age}")
416 if not isinstance(germination_rate, (int, float)):
417 raise ValueError(f
"germination_rate must be a number, got {type(germination_rate).__name__}")
418 if germination_rate < 0
or germination_rate > 1:
419 raise ValueError(f
"germination_rate must be between 0 and 1, got {germination_rate}")
422 if plant_count.x <= 0
or plant_count.y <= 0:
423 raise ValueError(
"Plant count values must be positive integers")
426 if build_parameters
is not None:
427 if not isinstance(build_parameters, dict):
428 raise ValueError(
"build_parameters must be a dict or None")
429 for key, value
in build_parameters.items():
430 if not isinstance(key, str):
431 raise ValueError(
"build_parameters keys must be strings")
432 if not isinstance(value, (int, float)):
433 raise ValueError(
"build_parameters values must be numeric (int or float)")
436 center_list = [canopy_center.x, canopy_center.y, canopy_center.z]
437 spacing_list = [plant_spacing.x, plant_spacing.y]
438 count_list = [plant_count.x, plant_count.y]
443 return plantarch_wrapper.buildPlantCanopyFromLibrary(
445 germination_rate, build_parameters
447 except Exception
as e:
452 Advance time for plant growth and development.
454 This method updates all plants in the simulation, potentially adding new phytomers,
455 growing existing organs, transitioning phenological stages, and updating plant geometry.
458 dt: Time step to advance in days (must be >= 0)
461 ValueError: If dt is negative
462 PlantArchitectureError: If time advancement fails
465 Large time steps are more efficient than many small steps. The timestep value
466 can be larger than the phyllochron, allowing multiple phytomers to be produced
470 >>> plantarch.advanceTime(10.0) # Advance 10 days
471 >>> plantarch.advanceTime(0.5) # Advance 12 hours
475 raise ValueError(f
"Time step must be non-negative, got {dt}")
481 except Exception
as e:
485 """Set a callback to receive progress updates during long-running operations.
487 The callback fires during advanceTime() and adjustFruitForObstacleCollision()
488 as the underlying ProgressBar updates.
491 callback: A callable(progress: float, message: str) where progress is
492 in [0, 1], or None to clear the callback.
495 ValueError: If callback is not callable and not None.
497 if callback
is not None:
498 if not callable(callback):
500 f
"callback must be callable or None, got {type(callback).__name__}"
503 def _c_callback(progress, message_bytes):
504 msg = message_bytes.decode(
'utf-8')
if isinstance(message_bytes, bytes)
else str(message_bytes)
505 callback(progress, msg)
517 """Register an external cancellation flag polled during long plant builds.
519 ``cancel_flag`` is a ctypes.c_int that, when set non-zero from another
520 thread, stops the canopy build loop and the advanceTime() growth loop
521 between plants/timesteps — so a long generation can be aborted mid-build
522 (returning whatever was built so far). Set it before the build call; pass
523 None to clear. The flag is caller-owned and must outlive the build.
530 Get current shoot parameters for a shoot type.
532 Returns the full nested shoot and phytomer parameter set, including the
533 internode/petiole/leaf/peduncle/inflorescence sub-structures and the leaf
534 prototype. Every numeric field is a RandomParameter spec with a
535 'distribution' and 'parameters'.
538 shoot_type_label: Label for the shoot type (e.g., "stem", "branch")
539 return_typed: If True, return a typed
540 :class:`pyhelios.plant_architecture_params.ShootParameters`
541 object instead of a plain nested dict.
544 A nested ``dict`` (default) or a ``ShootParameters`` object containing:
545 - Geometric parameters (max_nodes, insertion_angle_tip, etc.)
546 - Growth parameters (phyllochron_min, elongation_rate_max, etc.)
547 - Boolean flags (flowers_require_dormancy, etc.)
548 - ``phytomer_parameters`` with nested internode/petiole/leaf/peduncle/
549 inflorescence parameters and the leaf prototype
552 ValueError: If shoot_type_label is empty
553 PlantArchitectureError: If parameter retrieval fails
556 >>> plantarch.loadPlantModelFromLibrary("bean")
557 >>> params = plantarch.getCurrentShootParameters("stem")
558 >>> print(params['max_nodes'])
559 {'distribution': 'constant', 'parameters': [15.0]}
560 >>> print(params['phytomer_parameters']['leaf']['pitch'])
561 {'distribution': 'constant', 'parameters': [0.0]}
563 if not shoot_type_label:
564 raise ValueError(
"Shoot type label cannot be empty")
566 if not shoot_type_label.strip():
567 raise ValueError(
"Shoot type label cannot be only whitespace")
572 params = plantarch_wrapper.getCurrentShootParameters(
575 except Exception
as e:
578 return ShootParameters.from_dict(params)
if return_typed
else params
580 def defineShootType(self, shoot_type_label: str, parameters: Union[dict, ShootParameters]) ->
None:
582 Define a custom shoot type with specified parameters.
584 Allows creating new shoot types or modifying existing ones. Pass either a
585 nested parameter ``dict`` (use :meth:`getCurrentShootParameters` as a
587 :class:`pyhelios.plant_architecture_params.ShootParameters` object.
590 shoot_type_label: Unique name for this shoot type
591 parameters: A nested dict matching the ShootParameters structure, or a
592 ShootParameters object.
595 ValueError: If shoot_type_label is empty, or parameters is not a dict
597 PlantArchitectureError: If shoot type definition fails
600 >>> from pyhelios.plant_architecture_params import ShootParameters, RandomParameterFloat
601 >>> plantarch.loadPlantModelFromLibrary("bean")
602 >>> sp = plantarch.getCurrentShootParameters("stem", return_typed=True)
603 >>> sp.max_nodes = RandomParameterFloat.constant(20)
604 >>> sp.phytomer_parameters.leaf.pitch = RandomParameterFloat.uniform(40, 50)
605 >>> plantarch.defineShootType("TallStem", sp)
607 if not shoot_type_label:
608 raise ValueError(
"Shoot type label cannot be empty")
610 if not shoot_type_label.strip():
611 raise ValueError(
"Shoot type label cannot be only whitespace")
613 if isinstance(parameters, ShootParameters):
614 parameters = parameters.to_dict()
615 elif not isinstance(parameters, dict):
617 f
"Parameters must be a dict or ShootParameters, got {type(parameters).__name__}"
623 plantarch_wrapper.defineShootType(
626 except Exception
as e:
631 Get a default-constructed set of carbohydrate-model parameters.
633 The native API exposes no per-plant getter for carbohydrate parameters, so
634 this returns the C++ defaults as a template to modify and apply via
635 :meth:`setPlantCarbohydrateParameters`.
638 return_typed: If True, return a typed
639 :class:`pyhelios.plant_architecture_params.CarbohydrateParameters`.
642 A flat ``dict`` (default) or ``CarbohydrateParameters`` object.
647 params = plantarch_wrapper.getDefaultCarbohydrateParameters()
648 except Exception
as e:
650 return CarbohydrateParameters.from_dict(params)
if return_typed
else params
654 Set carbohydrate-model parameters for a plant.
657 plant_id: Target plant instance ID
658 parameters: A flat dict or a CarbohydrateParameters object.
661 ValueError: If parameters is not a dict or CarbohydrateParameters
662 PlantArchitectureError: If the operation fails
664 if isinstance(parameters, CarbohydrateParameters):
665 parameters = parameters.to_dict()
666 elif not isinstance(parameters, dict):
668 f
"Parameters must be a dict or CarbohydrateParameters, got {type(parameters).__name__}"
673 plantarch_wrapper.setPlantCarbohydrateParameters(self.
_plantarch_ptr, plant_id, parameters)
674 except Exception
as e:
679 Get a default-constructed set of nitrogen-model parameters.
681 The native API exposes no per-plant getter for nitrogen parameters, so this
682 returns the C++ defaults as a template to modify and apply via
683 :meth:`setPlantNitrogenParameters`.
686 return_typed: If True, return a typed
687 :class:`pyhelios.plant_architecture_params.NitrogenParameters`.
690 A flat ``dict`` (default) or ``NitrogenParameters`` object.
695 params = plantarch_wrapper.getDefaultNitrogenParameters()
696 except Exception
as e:
698 return NitrogenParameters.from_dict(params)
if return_typed
else params
702 Set nitrogen-model parameters for a plant.
705 plant_id: Target plant instance ID
706 parameters: A flat dict or a NitrogenParameters object.
709 ValueError: If parameters is not a dict or NitrogenParameters
710 PlantArchitectureError: If the operation fails
712 if isinstance(parameters, NitrogenParameters):
713 parameters = parameters.to_dict()
714 elif not isinstance(parameters, dict):
716 f
"Parameters must be a dict or NitrogenParameters, got {type(parameters).__name__}"
721 plantarch_wrapper.setPlantNitrogenParameters(self.
_plantarch_ptr, plant_id, parameters)
722 except Exception
as e:
727 Get list of all available plant models in the library.
730 List of plant model names available for loading
733 PlantArchitectureError: If retrieval fails
736 >>> models = plantarch.getAvailablePlantModels()
737 >>> print(f"Available models: {', '.join(models)}")
738 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
743 return plantarch_wrapper.getAvailablePlantModels(self.
_plantarch_ptr)
744 except Exception
as e:
749 Get all object IDs for a specific plant.
752 plant_id: ID of the plant instance
755 List of object IDs comprising the plant
758 ValueError: If plant_id is negative
759 PlantArchitectureError: If retrieval fails
762 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
763 >>> print(f"Plant has {len(object_ids)} objects")
766 raise ValueError(
"Plant ID must be non-negative")
771 except Exception
as e:
774 def getAllPlantUUIDs(self, plant_id: int, include_hidden: bool =
False) -> List[int]:
776 Get all primitive UUIDs for a specific plant.
779 plant_id: ID of the plant instance
780 include_hidden: If True, also include UUIDs of hidden prototype
781 primitives managed by this PlantArchitecture instance.
784 List of primitive UUIDs comprising the plant (and optionally hidden prototypes)
787 ValueError: If plant_id is negative
788 PlantArchitectureError: If retrieval fails
791 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
792 >>> print(f"Plant has {len(uuids)} primitives")
795 raise ValueError(
"Plant ID must be non-negative")
799 return plantarch_wrapper.getAllPlantUUIDs(self.
_plantarch_ptr, plant_id, include_hidden)
800 except Exception
as e:
805 Get the IDs of all shoots belonging to a plant.
807 Shoot IDs are contiguous 0-based indices into the plant's shoot tree, in creation
808 order; shoot 0 is always the base stem. The returned IDs can be passed to
809 :meth:`getShoot`, :meth:`getShootChildIDs`, etc.
812 plant_id: ID of the plant instance
815 List of shoot IDs for the plant
818 raise ValueError(
"Plant ID must be non-negative")
821 return plantarch_wrapper.getAllPlantShootIDs(self.
_plantarch_ptr, plant_id)
822 except Exception
as e:
825 def getShoot(self, plant_id: int, shoot_id: int) -> Dict[str, Any]:
827 Get a read-only view of a shoot's topology.
830 plant_id: ID of the plant instance
831 shoot_id: Shoot index within the plant (see :meth:`getAllShootIDs`)
834 A dict with keys ``rank``, ``parent_shoot_id`` (-1 for the base stem),
835 ``parent_node_index``, and ``node_count``.
837 if plant_id < 0
or shoot_id < 0:
838 raise ValueError(
"Plant ID and shoot ID must be non-negative")
841 return plantarch_wrapper.getPlantShootTopology(self.
_plantarch_ptr, plant_id, shoot_id)
842 except Exception
as e:
844 f
"Failed to get shoot {shoot_id} of plant {plant_id}: {e}")
847 """Get the child shoot IDs of a shoot (flattened across parent node indices)."""
848 if plant_id < 0
or shoot_id < 0:
849 raise ValueError(
"Plant ID and shoot ID must be non-negative")
852 return plantarch_wrapper.getPlantShootChildIDs(self.
_plantarch_ptr, plant_id, shoot_id)
853 except Exception
as e:
855 f
"Failed to get child shoots of shoot {shoot_id}, plant {plant_id}: {e}")
858 """Get the woody internode polyline vertices of a shoot as a list of (x, y, z) tuples."""
859 if plant_id < 0
or shoot_id < 0:
860 raise ValueError(
"Plant ID and shoot ID must be non-negative")
863 return plantarch_wrapper.getPlantShootInternodeVertices(self.
_plantarch_ptr, plant_id, shoot_id)
864 except Exception
as e:
866 f
"Failed to get internode vertices of shoot {shoot_id}, plant {plant_id}: {e}")
869 """Get the per-vertex woody internode radii of a shoot."""
870 if plant_id < 0
or shoot_id < 0:
871 raise ValueError(
"Plant ID and shoot ID must be non-negative")
874 return plantarch_wrapper.getPlantShootInternodeRadii(self.
_plantarch_ptr, plant_id, shoot_id)
875 except Exception
as e:
877 f
"Failed to get internode radii of shoot {shoot_id}, plant {plant_id}: {e}")
881 Get the current age of a plant in days.
884 plant_id: ID of the plant instance
890 ValueError: If plant_id is negative
891 PlantArchitectureError: If retrieval fails
894 >>> age = plantarch.getPlantAge(plant_id)
895 >>> print(f"Plant is {age} days old")
898 raise ValueError(
"Plant ID must be non-negative")
903 return plantarch_wrapper.getPlantAge(self.
_plantarch_ptr, plant_id)
904 except Exception
as e:
909 Get the height of a plant in meters.
912 plant_id: ID of the plant instance
915 Plant height in meters (vertical extent)
918 ValueError: If plant_id is negative
919 PlantArchitectureError: If retrieval fails
922 >>> height = plantarch.getPlantHeight(plant_id)
923 >>> print(f"Plant is {height:.2f}m tall")
926 raise ValueError(
"Plant ID must be non-negative")
931 return plantarch_wrapper.getPlantHeight(self.
_plantarch_ptr, plant_id)
932 except Exception
as e:
937 Get the total leaf area of a plant in m².
940 plant_id: ID of the plant instance
943 Total leaf area in square meters
946 ValueError: If plant_id is negative
947 PlantArchitectureError: If retrieval fails
950 >>> leaf_area = plantarch.getPlantLeafArea(plant_id)
951 >>> print(f"Total leaf area: {leaf_area:.3f} m²")
954 raise ValueError(
"Plant ID must be non-negative")
959 return plantarch_wrapper.sumPlantLeafArea(self.
_plantarch_ptr, plant_id)
960 except Exception
as e:
965 Enable optional output object data to be written to the Context.
967 By default, the plant architecture model only writes a minimal set of
968 object data. This method enables additional object data fields so that
969 they are available on the Context's compound objects after building.
972 object_data_labels: A single label or a list of labels to enable.
973 Valid labels include: "age", "rank", "plantID", "plant_name",
974 "plant_height", "plant_type", "phenology_stage", "leafID",
975 "peduncleID", "closedflowerID", "openflowerID", "fruitID",
976 "carbohydrate_concentration". The special label "all" enables
977 every available field.
980 ValueError: If a label is empty or not a string
981 PlantArchitectureError: If an invalid label is supplied or the
982 operation otherwise fails
985 >>> plantarch.optionalOutputObjectData("age")
986 >>> plantarch.optionalOutputObjectData(["rank", "plant_height"])
987 >>> plantarch.optionalOutputObjectData("all")
989 if isinstance(object_data_labels, str):
990 labels = [object_data_labels]
992 labels = list(object_data_labels)
998 plantarch_wrapper.optionalOutputObjectData(self.
_plantarch_ptr, label)
1001 except Exception
as e:
1007 time_to_dormancy_break: float,
1008 time_to_flower_initiation: float,
1009 time_to_flower_opening: float,
1010 time_to_fruit_set: float,
1011 time_to_fruit_maturity: float,
1012 time_to_dormancy: float,
1013 max_leaf_lifespan: float = 1e6,
1014 is_evergreen: bool =
False
1017 Set phenological timing thresholds for plant developmental stages.
1019 Controls the timing of key phenological events based on thermal time
1020 or calendar time depending on the plant model.
1023 plant_id: ID of the plant instance
1024 time_to_dormancy_break: Degree-days or days until dormancy ends
1025 time_to_flower_initiation: Time until flower buds are initiated
1026 time_to_flower_opening: Time until flowers open
1027 time_to_fruit_set: Time until fruit begins developing
1028 time_to_fruit_maturity: Time until fruit reaches maturity
1029 time_to_dormancy: Time until plant enters dormancy
1030 max_leaf_lifespan: Maximum leaf lifespan in days (default: 1e6)
1031 is_evergreen: If True, the plant retains leaves through dormancy
1032 instead of shedding them at senescence (default: False)
1035 ValueError: If plant_id is negative
1036 PlantArchitectureError: If phenology setting fails
1039 >>> # Set phenology for perennial fruit tree
1040 >>> plantarch.setPlantPhenologicalThresholds(
1041 ... plant_id=plant_id,
1042 ... time_to_dormancy_break=60, # Spring: 60 degree-days
1043 ... time_to_flower_initiation=90, # Early spring flowering
1044 ... time_to_flower_opening=105, # Bloom period
1045 ... time_to_fruit_set=120, # Fruit set after pollination
1046 ... time_to_fruit_maturity=200, # Summer fruit maturation
1047 ... time_to_dormancy=280, # Fall dormancy
1048 ... max_leaf_lifespan=180 # Deciduous - 6 month leaf life
1052 raise ValueError(
"Plant ID must be non-negative")
1057 plantarch_wrapper.setPlantPhenologicalThresholds(
1060 time_to_dormancy_break,
1061 time_to_flower_initiation,
1062 time_to_flower_opening,
1064 time_to_fruit_maturity,
1069 except Exception
as e:
1074 target_object_UUIDs: Optional[List[int]] =
None,
1075 target_object_IDs: Optional[List[int]] =
None,
1076 enable_petiole_collision: bool =
False,
1077 enable_fruit_collision: bool =
False) ->
None:
1079 Enable soft collision avoidance for procedural plant growth.
1081 This method enables the collision detection system that guides plant growth away from
1082 obstacles and other plants. The system uses cone-based gap detection to find optimal
1083 growth directions that minimize collisions while maintaining natural plant architecture.
1086 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
1087 avoids all geometry in the context.
1088 target_object_IDs: List of compound object IDs to avoid collisions with.
1089 enable_petiole_collision: Enable collision detection for leaf petioles
1090 enable_fruit_collision: Enable collision detection for fruit organs
1093 PlantArchitectureError: If collision detection activation fails
1096 Collision detection adds computational overhead. Use setStaticObstacles() to mark
1097 static geometry for BVH optimization and improved performance.
1100 >>> # Avoid all geometry
1101 >>> plantarch.enableSoftCollisionAvoidance()
1103 >>> # Avoid specific obstacles
1104 >>> obstacle_uuids = context.getAllUUIDs()
1105 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
1107 >>> # Enable collision detection for petioles and fruit
1108 >>> plantarch.enableSoftCollisionAvoidance(
1109 ... enable_petiole_collision=True,
1110 ... enable_fruit_collision=True
1116 plantarch_wrapper.enableSoftCollisionAvoidance(
1118 target_UUIDs=target_object_UUIDs,
1119 target_IDs=target_object_IDs,
1120 enable_petiole=enable_petiole_collision,
1121 enable_fruit=enable_fruit_collision
1123 except Exception
as e:
1128 Disable collision detection for plant growth.
1130 This method turns off the collision detection system, allowing plants to grow
1131 without checking for obstacles. This improves performance but plants may grow
1132 through obstacles and other geometry.
1135 PlantArchitectureError: If disabling fails
1138 >>> plantarch.disableCollisionDetection()
1143 except Exception
as e:
1147 view_half_angle_deg: float = 80.0,
1148 look_ahead_distance: float = 0.1,
1149 sample_count: int = 256,
1150 inertia_weight: float = 0.4) ->
None:
1152 Configure parameters for soft collision avoidance algorithm.
1154 These parameters control the cone-based gap detection algorithm that guides
1155 plant growth away from obstacles. Adjusting these values allows fine-tuning
1156 the balance between collision avoidance and natural growth patterns.
1159 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
1160 Default 80° provides wide field of view.
1161 look_ahead_distance: Distance to look ahead for collisions in meters.
1162 Larger values detect distant obstacles. Default 0.1m.
1163 sample_count: Number of ray samples within cone. More samples improve
1164 accuracy but reduce performance. Default 256.
1165 inertia_weight: Weight for previous growth direction (0-1). Higher values
1166 make growth smoother but less responsive. Default 0.4.
1169 ValueError: If parameters are outside valid ranges
1170 PlantArchitectureError: If parameter setting fails
1173 >>> # Use default parameters (recommended)
1174 >>> plantarch.setSoftCollisionAvoidanceParameters()
1176 >>> # Tune for dense canopy with close obstacles
1177 >>> plantarch.setSoftCollisionAvoidanceParameters(
1178 ... view_half_angle_deg=60.0, # Narrower detection cone
1179 ... look_ahead_distance=0.05, # Shorter look-ahead
1180 ... sample_count=512, # More accurate detection
1181 ... inertia_weight=0.3 # More responsive to obstacles
1185 if not (0 <= view_half_angle_deg <= 180):
1186 raise ValueError(f
"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
1187 if look_ahead_distance <= 0:
1188 raise ValueError(f
"look_ahead_distance must be positive, got {look_ahead_distance}")
1189 if sample_count <= 0:
1190 raise ValueError(f
"sample_count must be positive, got {sample_count}")
1191 if not (0 <= inertia_weight <= 1):
1192 raise ValueError(f
"inertia_weight must be between 0 and 1, got {inertia_weight}")
1196 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
1198 view_half_angle_deg,
1199 look_ahead_distance,
1203 except Exception
as e:
1207 include_internodes: bool =
False,
1208 include_leaves: bool =
True,
1209 include_petioles: bool =
False,
1210 include_flowers: bool =
False,
1211 include_fruit: bool =
False) ->
None:
1213 Specify which plant organs participate in collision detection.
1215 This method allows filtering which organs are considered during collision detection,
1216 enabling optimization by excluding organs unlikely to cause problematic collisions.
1219 include_internodes: Include stem internodes in collision detection
1220 include_leaves: Include leaf blades in collision detection
1221 include_petioles: Include leaf petioles in collision detection
1222 include_flowers: Include flowers in collision detection
1223 include_fruit: Include fruit in collision detection
1226 PlantArchitectureError: If organ filtering fails
1229 >>> # Only detect collisions for stems and leaves (default behavior)
1230 >>> plantarch.setCollisionRelevantOrgans(
1231 ... include_internodes=True,
1232 ... include_leaves=True
1235 >>> # Include all organs
1236 >>> plantarch.setCollisionRelevantOrgans(
1237 ... include_internodes=True,
1238 ... include_leaves=True,
1239 ... include_petioles=True,
1240 ... include_flowers=True,
1241 ... include_fruit=True
1246 plantarch_wrapper.setCollisionRelevantOrgans(
1254 except Exception
as e:
1258 obstacle_UUIDs: List[int],
1259 avoidance_distance: float = 0.5,
1260 enable_fruit_adjustment: bool =
False,
1261 enable_obstacle_pruning: bool =
False) ->
None:
1263 Enable hard obstacle avoidance for specified geometry.
1265 This method configures solid obstacles that plants cannot grow through. Unlike soft
1266 collision avoidance (which guides growth), solid obstacles cause complete growth
1267 termination when encountered within the avoidance distance.
1270 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
1271 avoidance_distance: Minimum distance to maintain from obstacles (meters).
1272 Growth stops if obstacles are closer. Default 0.5m.
1273 enable_fruit_adjustment: Adjust fruit positions away from obstacles
1274 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
1277 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
1278 PlantArchitectureError: If solid obstacle configuration fails
1281 >>> # Simple solid obstacle avoidance
1282 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
1283 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
1285 >>> # Close avoidance with fruit adjustment
1286 >>> plantarch.enableSolidObstacleAvoidance(
1287 ... obstacle_UUIDs=wall_uuids,
1288 ... avoidance_distance=0.1,
1289 ... enable_fruit_adjustment=True
1292 if not obstacle_UUIDs:
1293 raise ValueError(
"Obstacle UUIDs list cannot be empty")
1294 if avoidance_distance <= 0:
1295 raise ValueError(f
"avoidance_distance must be positive, got {avoidance_distance}")
1300 plantarch_wrapper.enableSolidObstacleAvoidance(
1304 enable_fruit_adjustment,
1305 enable_obstacle_pruning
1307 except Exception
as e:
1312 Mark geometry as static obstacles for collision detection optimization.
1314 This method tells the collision detection system that certain geometry will not
1315 move during the simulation. The system can then build an optimized Bounding Volume
1316 Hierarchy (BVH) for these obstacles, significantly improving collision detection
1317 performance in scenes with many static obstacles.
1320 target_UUIDs: List of primitive UUIDs representing static obstacles
1323 ValueError: If target_UUIDs is empty
1324 PlantArchitectureError: If static obstacle configuration fails
1327 Call this method BEFORE enabling collision avoidance for best performance.
1328 Static obstacles cannot be modified or moved after being marked static.
1331 >>> # Mark ground and building geometry as static
1332 >>> static_uuids = ground_uuids + building_uuids
1333 >>> plantarch.setStaticObstacles(static_uuids)
1334 >>> # Now enable collision avoidance
1335 >>> plantarch.enableSoftCollisionAvoidance()
1337 if not target_UUIDs:
1338 raise ValueError(
"target_UUIDs list cannot be empty")
1343 plantarch_wrapper.setStaticObstacles(self.
_plantarch_ptr, target_UUIDs)
1344 except Exception
as e:
1349 Get object IDs of collision-relevant geometry for a specific plant.
1351 This method returns the subset of plant geometry that participates in collision
1352 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
1353 and debugging collision detection behavior.
1356 plant_id: ID of the plant instance
1359 List of object IDs for collision-relevant plant geometry
1362 ValueError: If plant_id is negative
1363 PlantArchitectureError: If retrieval fails
1366 >>> # Get collision-relevant geometry
1367 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
1368 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
1370 >>> # Highlight collision geometry in visualization
1371 >>> for obj_id in collision_obj_ids:
1372 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
1375 raise ValueError(
"Plant ID must be non-negative")
1379 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self.
_plantarch_ptr, plant_id)
1380 except Exception
as e:
1386 Write all plant mesh vertices to file for external processing.
1388 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
1389 writing one vertex per line. Useful for external processing such as computing bounding
1390 volumes, convex hulls, or performing custom geometric analysis.
1393 plant_id: ID of the plant instance to export
1394 filename: Path to output file (absolute or relative to current working directory)
1397 ValueError: If plant_id is negative or filename is empty
1398 PlantArchitectureError: If plant doesn't exist or file cannot be written
1401 >>> # Export vertices for convex hull analysis
1402 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
1404 >>> # Use with Path object
1405 >>> from pathlib import Path
1406 >>> output_dir = Path("output")
1407 >>> output_dir.mkdir(exist_ok=True)
1408 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
1411 raise ValueError(
"Plant ID must be non-negative")
1413 raise ValueError(
"Filename cannot be empty")
1421 plantarch_wrapper.writePlantMeshVertices(
1424 except Exception
as e:
1429 Save plant structure to XML file for later loading.
1431 This method exports the complete plant architecture to an XML file, including
1432 all shoots, phytomers, organs, and their properties. The saved plant can be
1433 reloaded later using readPlantStructureXML().
1436 plant_id: ID of the plant instance to save
1437 filename: Path to output XML file (absolute or relative to current working directory)
1440 ValueError: If plant_id is negative or filename is empty
1441 PlantArchitectureError: If plant doesn't exist or file cannot be written
1444 The XML format preserves the complete plant state including:
1445 - Shoot structure and hierarchy
1446 - Phytomer properties and development stage
1447 - Organ geometry and attributes
1448 - Growth parameters and phenological state
1451 >>> # Save plant at current growth stage
1452 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
1454 >>> # Later, reload the saved plant
1455 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
1456 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
1459 raise ValueError(
"Plant ID must be non-negative")
1461 raise ValueError(
"Filename cannot be empty")
1469 plantarch_wrapper.writePlantStructureXML(
1472 except Exception
as e:
1477 Export plant structure in TreeQSM cylinder format.
1479 This method writes the plant structure as a series of cylinders following the
1480 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
1481 columns for radius, length, start position, axis direction, branch topology,
1482 and other structural properties. Useful for biomechanical analysis and
1483 quantitative structure modeling.
1486 plant_id: ID of the plant instance to export
1487 filename: Path to output file (absolute or relative, typically .txt extension)
1490 ValueError: If plant_id is negative or filename is empty
1491 PlantArchitectureError: If plant doesn't exist or file cannot be written
1494 The TreeQSM format includes columns for:
1495 - Cylinder dimensions (radius, length)
1496 - Spatial position and orientation
1497 - Branch topology (parent ID, extension ID, branch ID)
1498 - Branch hierarchy (branch order, position in branch)
1499 - Quality metrics (mean absolute distance, surface coverage)
1502 >>> # Export for biomechanical analysis
1503 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
1505 >>> # Use with external QSM tools
1506 >>> import pandas as pd
1507 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
1508 >>> print(f"Tree has {len(qsm_data)} cylinders")
1511 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
1512 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
1515 raise ValueError(
"Plant ID must be non-negative")
1517 raise ValueError(
"Filename cannot be empty")
1525 plantarch_wrapper.writeQSMCylinderFile(
1528 except Exception
as e:
1532 elastic_modulus: float = 5e9,
1533 wood_density: float = 800.0,
1534 damping_ratio: float = 0.1,
1535 static_friction: float = 0.5,
1536 dynamic_friction: float = 0.3,
1537 restitution: float = 0.1,
1538 organ_spring_stiffness: float = 10.0,
1539 organ_spring_damping: float = 1.0,
1540 leaf_mass_per_area: float = 0.05,
1541 fruit_mass: float = 0.01,
1542 flower_mass: float = 0.002,
1543 solver_position_iterations: int = 32,
1544 min_segment_length: float = 0.001) ->
None:
1546 Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
1548 Each tube segment becomes a capsule-shaped rigid link connected by spherical joints.
1549 Spring/damper drives are derived from beam bending stiffness (E*I/L). Leaves, fruits,
1550 and flowers are represented as mass bodies attached by spring links.
1553 plant_id: ID of the plant instance to export
1554 filename: Output file path (should have .usda extension)
1555 elastic_modulus: Young's modulus (Pa) for joint stiffness, K = E*I/L
1556 wood_density: Wood density (kg/m^3) used to compute mass from capsule volume
1557 damping_ratio: Joint damping ratio (dimensionless)
1558 static_friction: Static friction coefficient for collision material
1559 dynamic_friction: Dynamic friction coefficient for collision material
1560 restitution: Restitution (bounciness) for collision material
1561 organ_spring_stiffness: Spring stiffness (N*m/rad) for organ attachment joints
1562 organ_spring_damping: Damping (N*m*s/rad) for organ attachment joints
1563 leaf_mass_per_area: Leaf mass per unit area (kg/m^2)
1564 fruit_mass: Mass per fruit (kg)
1565 flower_mass: Mass per flower (kg)
1566 solver_position_iterations: PhysX articulation solver position iteration count
1567 min_segment_length: Minimum segment length (m); shorter segments are skipped
1570 ValueError: If plant_id is negative or filename is empty
1571 PlantArchitectureError: If plant doesn't exist or file cannot be written
1574 >>> plantarch.writePlantStructureUSD(plant_id, "plant.usda")
1577 raise ValueError(
"Plant ID must be non-negative")
1579 raise ValueError(
"Filename cannot be empty")
1586 plantarch_wrapper.writePlantStructureUSD(
1588 elastic_modulus, wood_density, damping_ratio,
1589 static_friction, dynamic_friction, restitution,
1590 organ_spring_stiffness, organ_spring_damping,
1591 leaf_mass_per_area, fruit_mass, flower_mass,
1592 solver_position_iterations, min_segment_length
1594 except Exception
as e:
1599 Capture a snapshot of the plant's geometry as a growth animation frame.
1601 Call this after each :meth:`advanceTime` step to record the plant state for later
1602 animation export via :meth:`writePlantGrowthUSD`.
1605 plant_id: ID of the plant instance to capture
1606 min_segment_length: Minimum segment length (m); shorter segments are skipped
1609 ValueError: If plant_id is negative
1610 PlantArchitectureError: If plant doesn't exist
1613 raise ValueError(
"Plant ID must be non-negative")
1617 plantarch_wrapper.registerGrowthFrame(self.
_plantarch_ptr, plant_id, min_segment_length)
1618 except Exception
as e:
1622 seconds_per_frame: float = 1.0) ->
None:
1624 Export all registered growth frames as a time-sampled USD animation file.
1626 The resulting file can be imported directly into Blender. This is a visual-only
1627 export — no physics prims, joints, or collision shapes are written.
1630 plant_id: ID of the plant instance to export
1631 filename: Output file path (should have .usda extension)
1632 seconds_per_frame: Duration in seconds each growth frame occupies (default: 1.0)
1635 ValueError: If plant_id is negative or filename is empty
1636 PlantArchitectureError: If plant doesn't exist or file cannot be written
1639 raise ValueError(
"Plant ID must be non-negative")
1641 raise ValueError(
"Filename cannot be empty")
1648 plantarch_wrapper.writePlantGrowthUSD(
1651 except Exception
as e:
1656 Clear stored growth animation frames for a plant.
1659 plant_id: ID of the plant instance whose frames should be cleared
1662 ValueError: If plant_id is negative
1665 raise ValueError(
"Plant ID must be non-negative")
1670 except Exception
as e:
1675 Get the number of registered growth frames for a plant.
1678 plant_id: ID of the plant instance to query
1681 Number of frames registered via :meth:`registerGrowthFrame`
1684 ValueError: If plant_id is negative
1687 raise ValueError(
"Plant ID must be non-negative")
1692 except Exception
as e:
1697 Load plant structure from XML file.
1699 This method reads plant architecture data from an XML file previously saved with
1700 writePlantStructureXML(). The loaded plants are added to the current context
1701 and can be grown, modified, or analyzed like any other plants.
1704 filename: Path to XML file to load (absolute or relative to current working directory)
1705 quiet: If True, suppress console output during loading (default: False)
1708 List of plant IDs for the loaded plant instances
1711 ValueError: If filename is empty
1712 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
1715 The XML file can contain multiple plant instances. All plants in the file
1716 will be loaded and their IDs returned in a list. Plant models referenced
1717 in the XML must be available in the plant library.
1720 >>> # Load previously saved plants
1721 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
1722 >>> print(f"Loaded {len(plant_ids)} plants")
1724 >>> # Continue growing the loaded plants
1725 >>> plantarch.advanceTime(10.0)
1727 >>> # Load quietly without console messages
1728 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
1731 raise ValueError(
"Filename cannot be empty")
1739 return plantarch_wrapper.readPlantStructureXML(
1742 except Exception
as e:
1746 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
1748 Create an empty plant instance for custom plant building.
1750 This method creates a new plant instance at the specified location without any
1751 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
1752 manually construct the plant structure. This provides low-level control over
1753 plant architecture, enabling custom morphologies not available in the plant library.
1756 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
1757 current_age: Current age of the plant in days (must be >= 0)
1760 Plant ID for the created plant instance
1763 ValueError: If age is negative
1764 PlantArchitectureError: If plant creation fails
1767 >>> # Create empty plant at origin
1768 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1770 >>> # Now add shoots to build custom plant structure
1771 >>> shoot_id = plantarch.addBaseStemShoot(
1772 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
1776 if not isinstance(base_position, vec3):
1777 raise ValueError(f
"base_position must be a vec3, got {type(base_position).__name__}")
1780 position_list = [base_position.x, base_position.y, base_position.z]
1784 raise ValueError(f
"Age must be non-negative, got {current_age}")
1789 return plantarch_wrapper.addPlantInstance(
1792 except Exception
as e:
1797 Delete a plant instance and all associated geometry.
1799 This method removes a plant from the simulation, deleting all shoots, organs,
1800 and associated primitives from the context. The plant ID becomes invalid after
1801 deletion and should not be used in subsequent operations.
1804 plant_id: ID of the plant instance to delete
1807 ValueError: If plant_id is negative
1808 PlantArchitectureError: If plant deletion fails or plant doesn't exist
1811 >>> # Delete a plant
1812 >>> plantarch.deletePlantInstance(plant_id)
1814 >>> # Delete multiple plants
1815 >>> for pid in plant_ids_to_remove:
1816 ... plantarch.deletePlantInstance(pid)
1819 raise ValueError(
"Plant ID must be non-negative")
1824 plantarch_wrapper.deletePlantInstance(self.
_plantarch_ptr, plant_id)
1825 except Exception
as e:
1830 current_node_number: int,
1831 base_rotation: AxisRotation,
1832 internode_radius: float,
1833 internode_length_max: float,
1834 internode_length_scale_factor_fraction: float,
1835 leaf_scale_factor_fraction: float,
1836 radius_taper: float,
1837 shoot_type_label: str) -> int:
1839 Add a base stem shoot to a plant instance (main trunk/stem).
1841 This method creates the primary shoot originating from the plant base. The base stem
1842 is typically the main trunk or primary stem from which all other shoots branch.
1843 Specify growth parameters to control the shoot's morphology and development.
1845 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
1846 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
1847 shoot types that can then be used for custom building. The shoot_type_label must match a
1848 shoot type defined in the loaded model.
1851 plant_id: ID of the plant instance
1852 current_node_number: Starting node number for this shoot (typically 1)
1853 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1854 internode_radius: Base radius of internodes in meters (must be > 0)
1855 internode_length_max: Maximum internode length in meters (must be > 0)
1856 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1857 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1858 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1859 shoot_type_label: Label identifying shoot type - must match a type from loaded model
1862 Shoot ID for the created shoot
1865 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1866 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
1869 >>> from pyhelios import AxisRotation
1871 >>> # REQUIRED: Load a plant model to define shoot types
1872 >>> plantarch.loadPlantModelFromLibrary("bean")
1874 >>> # Create empty plant for custom building
1875 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1877 >>> # Add base stem using shoot type from loaded model
1878 >>> shoot_id = plantarch.addBaseStemShoot(
1879 ... plant_id=plant_id,
1880 ... current_node_number=1,
1881 ... base_rotation=AxisRotation(0, 0, 0), # Upright
1882 ... internode_radius=0.01, # 1cm radius
1883 ... internode_length_max=0.1, # 10cm max length
1884 ... internode_length_scale_factor_fraction=1.0,
1885 ... leaf_scale_factor_fraction=1.0,
1886 ... radius_taper=0.9, # Gradual taper
1887 ... shoot_type_label="stem" # Must match loaded model
1891 raise ValueError(
"Plant ID must be non-negative")
1892 if current_node_number < 0:
1893 raise ValueError(
"Current node number must be non-negative")
1894 if internode_radius <= 0:
1895 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1896 if internode_length_max <= 0:
1897 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1898 if not shoot_type_label
or not shoot_type_label.strip():
1899 raise ValueError(
"Shoot type label cannot be empty")
1902 rotation_list = base_rotation.to_list()
1907 return plantarch_wrapper.addBaseStemShoot(
1908 self.
_plantarch_ptr, plant_id, current_node_number, rotation_list,
1909 internode_radius, internode_length_max,
1910 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1911 radius_taper, shoot_type_label.strip()
1913 except Exception
as e:
1915 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1917 f
"Shoot type '{shoot_type_label}' not defined. "
1918 f
"Load a plant model first to define shoot types:\n"
1919 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1920 f
"Original error: {e}"
1926 parent_shoot_id: int,
1927 current_node_number: int,
1928 base_rotation: AxisRotation,
1929 internode_radius: float,
1930 internode_length_max: float,
1931 internode_length_scale_factor_fraction: float,
1932 leaf_scale_factor_fraction: float,
1933 radius_taper: float,
1934 shoot_type_label: str) -> int:
1936 Append a shoot to the end of an existing shoot.
1938 This method extends an existing shoot by appending a new shoot at its terminal bud.
1939 Useful for creating multi-segmented shoots with varying properties along their length,
1940 such as shoots with different growth phases or developmental stages.
1942 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1943 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1944 calling this method.
1947 plant_id: ID of the plant instance
1948 parent_shoot_id: ID of the parent shoot to extend
1949 current_node_number: Starting node number for this shoot
1950 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1951 internode_radius: Base radius of internodes in meters (must be > 0)
1952 internode_length_max: Maximum internode length in meters (must be > 0)
1953 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1954 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1955 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1956 shoot_type_label: Label identifying shoot type - must match loaded model
1959 Shoot ID for the appended shoot
1962 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1963 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
1966 >>> # Load model to define shoot types
1967 >>> plantarch.loadPlantModelFromLibrary("bean")
1969 >>> # Append shoot with reduced size to simulate apical growth
1970 >>> new_shoot_id = plantarch.appendShoot(
1971 ... plant_id=plant_id,
1972 ... parent_shoot_id=base_shoot_id,
1973 ... current_node_number=10,
1974 ... base_rotation=AxisRotation(0, 0, 0),
1975 ... internode_radius=0.008, # Smaller than base
1976 ... internode_length_max=0.08, # Shorter internodes
1977 ... internode_length_scale_factor_fraction=1.0,
1978 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
1979 ... radius_taper=0.85,
1980 ... shoot_type_label="stem"
1984 raise ValueError(
"Plant ID must be non-negative")
1985 if parent_shoot_id < 0:
1986 raise ValueError(
"Parent shoot ID must be non-negative")
1987 if current_node_number < 0:
1988 raise ValueError(
"Current node number must be non-negative")
1989 if internode_radius <= 0:
1990 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1991 if internode_length_max <= 0:
1992 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1993 if not shoot_type_label
or not shoot_type_label.strip():
1994 raise ValueError(
"Shoot type label cannot be empty")
1997 rotation_list = base_rotation.to_list()
2002 return plantarch_wrapper.appendShoot(
2003 self.
_plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
2004 rotation_list, internode_radius, internode_length_max,
2005 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
2006 radius_taper, shoot_type_label.strip()
2008 except Exception
as e:
2010 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
2012 f
"Shoot type '{shoot_type_label}' not defined. "
2013 f
"Load a plant model first to define shoot types:\n"
2014 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
2015 f
"Original error: {e}"
2021 parent_shoot_id: int,
2022 parent_node_index: int,
2023 current_node_number: int,
2024 shoot_base_rotation: AxisRotation,
2025 internode_radius: float,
2026 internode_length_max: float,
2027 internode_length_scale_factor_fraction: float,
2028 leaf_scale_factor_fraction: float,
2029 radius_taper: float,
2030 shoot_type_label: str,
2031 petiole_index: int = 0) -> int:
2033 Add a child shoot at an axillary bud position on a parent shoot.
2035 This method creates a lateral branch shoot emerging from a specific node on the
2036 parent shoot. Child shoots enable creation of branching architectures, with control
2037 over branch angle, size, and which petiole position the branch emerges from (for
2038 plants with multiple petioles per node).
2040 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
2041 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
2042 calling this method.
2045 plant_id: ID of the plant instance
2046 parent_shoot_id: ID of the parent shoot
2047 parent_node_index: Index of the parent node where child emerges (0-based)
2048 current_node_number: Starting node number for this child shoot
2049 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
2050 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
2051 internode_length_max: Maximum internode length in meters (must be > 0)
2052 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
2053 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
2054 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
2055 shoot_type_label: Label identifying shoot type - must match loaded model
2056 petiole_index: Which petiole at the node to branch from (default: 0)
2059 Shoot ID for the created child shoot
2062 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
2063 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
2066 >>> # Load model to define shoot types
2067 >>> plantarch.loadPlantModelFromLibrary("bean")
2069 >>> # Add lateral branch at 45-degree angle from node 3
2070 >>> branch_id = plantarch.addChildShoot(
2071 ... plant_id=plant_id,
2072 ... parent_shoot_id=main_shoot_id,
2073 ... parent_node_index=3,
2074 ... current_node_number=1,
2075 ... shoot_base_rotation=AxisRotation(45, 90, 0), # 45° out, 90° rotation
2076 ... internode_radius=0.005, # Thinner than main stem
2077 ... internode_length_max=0.06, # Shorter internodes
2078 ... internode_length_scale_factor_fraction=1.0,
2079 ... leaf_scale_factor_fraction=0.9,
2080 ... radius_taper=0.8,
2081 ... shoot_type_label="stem"
2084 >>> # Add second branch from opposite petiole
2085 >>> branch_id2 = plantarch.addChildShoot(
2086 ... plant_id, main_shoot_id, 3, 1, AxisRotation(45, 270, 0),
2087 ... 0.005, 0.06, 1.0, 0.9, 0.8, "stem", petiole_index=1
2091 raise ValueError(
"Plant ID must be non-negative")
2092 if parent_shoot_id < 0:
2093 raise ValueError(
"Parent shoot ID must be non-negative")
2094 if parent_node_index < 0:
2095 raise ValueError(
"Parent node index must be non-negative")
2096 if current_node_number < 0:
2097 raise ValueError(
"Current node number must be non-negative")
2098 if internode_radius <= 0:
2099 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
2100 if internode_length_max <= 0:
2101 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
2102 if not shoot_type_label
or not shoot_type_label.strip():
2103 raise ValueError(
"Shoot type label cannot be empty")
2104 if petiole_index < 0:
2105 raise ValueError(f
"Petiole index must be non-negative, got {petiole_index}")
2108 rotation_list = shoot_base_rotation.to_list()
2113 return plantarch_wrapper.addChildShoot(
2114 self.
_plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
2115 current_node_number, rotation_list, internode_radius,
2116 internode_length_max, internode_length_scale_factor_fraction,
2117 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
2120 except Exception
as e:
2122 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
2124 f
"Shoot type '{shoot_type_label}' not defined. "
2125 f
"Load a plant model first to define shoot types:\n"
2126 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
2127 f
"Original error: {e}"
2133 Check if PlantArchitecture is available in current build.
2136 True if plugin is available, False otherwise
2144 Create PlantArchitecture instance with context.
2147 context: Helios Context
2150 PlantArchitecture instance
2153 >>> context = Context()
2154 >>> 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.
None setPlantNitrogenParameters(self, int plant_id, Union[dict, NitrogenParameters] parameters)
Set nitrogen-model parameters for a 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.
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, Union[dict, ShootParameters] 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.
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.
setCancelFlag(self, cancel_flag)
Register an external cancellation flag polled during long plant builds.
__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.
getCurrentShootParameters(self, str shoot_type_label, bool return_typed=False)
Get current shoot parameters for a shoot type.
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.
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, bool is_evergreen=False)
Set phenological timing thresholds for plant developmental stages.
getDefaultNitrogenParameters(self, bool return_typed=False)
Get a default-constructed set of nitrogen-model parameters.
getDefaultCarbohydrateParameters(self, bool return_typed=False)
Get a default-constructed set of carbohydrate-model parameters.
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).
None setPlantCarbohydrateParameters(self, int plant_id, Union[dict, CarbohydrateParameters] parameters)
Set carbohydrate-model parameters for a plant.
__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.
None optionalOutputObjectData(self, Union[str, List[str]] object_data_labels)
Enable optional output object data to be written to the Context.
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.
_check_context_alive(self)
Raise if the owning Context has been destroyed (see Context.check_context_alive).
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.
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...