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
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 Convert relative paths to absolute paths before changing working directory.
55 This preserves the user's intended file location when the working directory
56 is temporarily changed for C++ asset access. Absolute paths are returned unchanged.
59 filepath: File path to resolve (string or Path object)
62 Absolute path as string
65 if not path.is_absolute():
66 return str(Path.cwd() / path)
73 Context manager that temporarily changes working directory to where PlantArchitecture assets are located.
75 PlantArchitecture C++ code uses hardcoded relative paths like "plugins/plantarchitecture/assets/textures/"
76 expecting assets relative to working directory. This manager temporarily changes to the build directory
77 where assets are actually located.
80 RuntimeError: If build directory or PlantArchitecture assets are not found, indicating a build system error.
84 asset_manager = get_asset_manager()
85 working_dir = asset_manager._get_helios_build_path()
87 if working_dir
and working_dir.exists():
88 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
91 current_dir = Path(__file__).parent
92 packaged_build = current_dir /
'assets' /
'build'
94 if packaged_build.exists():
95 working_dir = packaged_build
96 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
99 repo_root = current_dir.parent
100 build_lib_dir = repo_root /
'pyhelios_build' /
'build' /
'lib'
101 working_dir = build_lib_dir.parent
102 plantarch_assets = working_dir /
'plugins' /
'plantarchitecture'
104 if not build_lib_dir.exists():
106 f
"PyHelios build directory not found at {build_lib_dir}. "
107 f
"PlantArchitecture requires native libraries to be built. "
108 f
"Run: build_scripts/build_helios --plugins plantarchitecture"
111 if not plantarch_assets.exists():
113 f
"PlantArchitecture assets not found at {plantarch_assets}. "
114 f
"Build system failed to copy PlantArchitecture assets. "
115 f
"Run: build_scripts/build_helios --clean --plugins plantarchitecture"
119 assets_dir = plantarch_assets /
'assets'
120 if not assets_dir.exists():
122 f
"PlantArchitecture assets directory not found: {assets_dir}. "
123 f
"Essential assets missing. Rebuild with: "
124 f
"build_scripts/build_helios --clean --plugins plantarchitecture"
128 original_dir = os.getcwd()
130 os.chdir(working_dir)
131 logger.debug(f
"Changed working directory to {working_dir} for PlantArchitecture asset access")
134 os.chdir(original_dir)
135 logger.debug(f
"Restored working directory to {original_dir}")
139 """Raised when PlantArchitecture operations fail."""
145 Check if PlantArchitecture plugin is available for use.
148 bool: True if PlantArchitecture can be used, False otherwise
152 plugin_registry = get_plugin_registry()
153 if not plugin_registry.is_plugin_available(
'plantarchitecture'):
157 if not plantarch_wrapper._PLANTARCHITECTURE_FUNCTIONS_AVAILABLE:
167 High-level interface for plant architecture modeling and procedural plant generation.
169 PlantArchitecture provides access to the comprehensive plant library with 25+ plant models
170 including trees (almond, apple, olive, walnut), crops (bean, cowpea, maize, rice, soybean),
171 and other plants. This class enables procedural plant generation, time-based growth
172 simulation, and plant community modeling.
174 This class requires the native Helios library built with PlantArchitecture support.
175 Use context managers for proper resource cleanup.
178 >>> with Context() as context:
179 ... with PlantArchitecture(context) as plantarch:
180 ... plantarch.loadPlantModelFromLibrary("bean")
181 ... plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(0, 0, 0), age=30)
182 ... plantarch.advanceTime(10.0) # Grow for 10 days
185 def __init__(self, context: Context):
187 Initialize PlantArchitecture with a Helios context.
190 context: Active Helios Context instance
193 PlantArchitectureError: If plugin not available in current build
194 RuntimeError: If plugin initialization fails
197 registry = get_plugin_registry()
198 if not registry.is_plugin_available(
'plantarchitecture'):
200 "PlantArchitecture not available in current Helios library. "
201 "Rebuild PyHelios with PlantArchitecture support:\n"
202 " build_scripts/build_helios --plugins plantarchitecture\n"
204 "System requirements:\n"
205 f
" - Platforms: Windows, Linux, macOS\n"
206 " - Dependencies: Extensive asset library (textures, OBJ models)\n"
207 " - GPU: Not required\n"
209 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
210 "rice, soybean, tomato, wheat, and many others."
218 self.
_plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
224 """Context manager entry"""
227 def __exit__(self, exc_type, exc_val, exc_tb):
228 """Context manager exit - cleanup resources"""
235 Load a plant model from the built-in library.
238 plant_label: Plant model identifier from library. Available models include:
239 "almond", "apple", "bean", "bindweed", "butterlettuce", "capsicum",
240 "cheeseweed", "cowpea", "easternredbud", "grapevine_VSP", "maize",
241 "olive", "pistachio", "puncturevine", "rice", "sorghum", "soybean",
242 "strawberry", "sugarbeet", "tomato", "cherrytomato", "walnut", "wheat"
245 ValueError: If plant_label is empty or invalid
246 PlantArchitectureError: If model loading fails
249 >>> plantarch.loadPlantModelFromLibrary("bean")
250 >>> plantarch.loadPlantModelFromLibrary("almond")
253 raise ValueError(
"Plant label cannot be empty")
255 if not plant_label.strip():
256 raise ValueError(
"Plant label cannot be only whitespace")
260 plantarch_wrapper.loadPlantModelFromLibrary(self.
_plantarch_ptr, plant_label.strip())
261 except Exception
as e:
266 Build a plant instance from the currently loaded library model.
269 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
270 age: Age of the plant in days (must be >= 0)
273 Plant ID for the created plant instance
276 ValueError: If age is negative
277 PlantArchitectureError: If plant building fails
278 RuntimeError: If no model has been loaded
281 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(2.0, 3.0, 0.0), age=45.0)
282 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(0, 0, 0), age=30.0)
285 position_list = [base_position.x, base_position.y, base_position.z]
289 raise ValueError(f
"Age must be non-negative, got {age}")
293 return plantarch_wrapper.buildPlantInstanceFromLibrary(
296 except Exception
as e:
301 plant_count: int2, age: float) -> List[int]:
303 Build a canopy of regularly spaced plants from the currently loaded library model.
306 canopy_center: Cartesian (x,y,z) coordinates of canopy center as vec3
307 plant_spacing: Spacing between plants in x- and y-directions (meters) as vec2
308 plant_count: Number of plants in x- and y-directions as int2
309 age: Age of all plants in days (must be >= 0)
312 List of plant IDs for the created plant instances
315 ValueError: If age is negative or plant count values are not positive
316 PlantArchitectureError: If canopy building fails
319 >>> # 3x3 canopy with 0.5m spacing, 30-day-old plants
320 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
321 ... canopy_center=vec3(0, 0, 0),
322 ... plant_spacing=vec2(0.5, 0.5),
323 ... plant_count=int2(3, 3),
329 raise ValueError(f
"Age must be non-negative, got {age}")
332 if plant_count.x <= 0
or plant_count.y <= 0:
333 raise ValueError(
"Plant count values must be positive integers")
336 center_list = [canopy_center.x, canopy_center.y, canopy_center.z]
337 spacing_list = [plant_spacing.x, plant_spacing.y]
338 count_list = [plant_count.x, plant_count.y]
342 return plantarch_wrapper.buildPlantCanopyFromLibrary(
345 except Exception
as e:
350 Advance time for plant growth and development.
352 This method updates all plants in the simulation, potentially adding new phytomers,
353 growing existing organs, transitioning phenological stages, and updating plant geometry.
356 dt: Time step to advance in days (must be >= 0)
359 ValueError: If dt is negative
360 PlantArchitectureError: If time advancement fails
363 Large time steps are more efficient than many small steps. The timestep value
364 can be larger than the phyllochron, allowing multiple phytomers to be produced
368 >>> plantarch.advanceTime(10.0) # Advance 10 days
369 >>> plantarch.advanceTime(0.5) # Advance 12 hours
373 raise ValueError(f
"Time step must be non-negative, got {dt}")
378 except Exception
as e:
383 Get list of all available plant models in the library.
386 List of plant model names available for loading
389 PlantArchitectureError: If retrieval fails
392 >>> models = plantarch.getAvailablePlantModels()
393 >>> print(f"Available models: {', '.join(models)}")
394 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
398 return plantarch_wrapper.getAvailablePlantModels(self.
_plantarch_ptr)
399 except Exception
as e:
404 Get all object IDs for a specific plant.
407 plant_id: ID of the plant instance
410 List of object IDs comprising the plant
413 ValueError: If plant_id is negative
414 PlantArchitectureError: If retrieval fails
417 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
418 >>> print(f"Plant has {len(object_ids)} objects")
421 raise ValueError(
"Plant ID must be non-negative")
424 return plantarch_wrapper.getAllPlantObjectIDs(self.
_plantarch_ptr, plant_id)
425 except Exception
as e:
430 Get all primitive UUIDs for a specific plant.
433 plant_id: ID of the plant instance
436 List of primitive UUIDs comprising the plant
439 ValueError: If plant_id is negative
440 PlantArchitectureError: If retrieval fails
443 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
444 >>> print(f"Plant has {len(uuids)} primitives")
447 raise ValueError(
"Plant ID must be non-negative")
450 return plantarch_wrapper.getAllPlantUUIDs(self.
_plantarch_ptr, plant_id)
451 except Exception
as e:
456 target_object_UUIDs: Optional[List[int]] =
None,
457 target_object_IDs: Optional[List[int]] =
None,
458 enable_petiole_collision: bool =
False,
459 enable_fruit_collision: bool =
False) ->
None:
461 Enable soft collision avoidance for procedural plant growth.
463 This method enables the collision detection system that guides plant growth away from
464 obstacles and other plants. The system uses cone-based gap detection to find optimal
465 growth directions that minimize collisions while maintaining natural plant architecture.
468 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
469 avoids all geometry in the context.
470 target_object_IDs: List of compound object IDs to avoid collisions with.
471 enable_petiole_collision: Enable collision detection for leaf petioles
472 enable_fruit_collision: Enable collision detection for fruit organs
475 PlantArchitectureError: If collision detection activation fails
478 Collision detection adds computational overhead. Use setStaticObstacles() to mark
479 static geometry for BVH optimization and improved performance.
482 >>> # Avoid all geometry
483 >>> plantarch.enableSoftCollisionAvoidance()
485 >>> # Avoid specific obstacles
486 >>> obstacle_uuids = context.getAllUUIDs()
487 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
489 >>> # Enable collision detection for petioles and fruit
490 >>> plantarch.enableSoftCollisionAvoidance(
491 ... enable_petiole_collision=True,
492 ... enable_fruit_collision=True
497 plantarch_wrapper.enableSoftCollisionAvoidance(
499 target_UUIDs=target_object_UUIDs,
500 target_IDs=target_object_IDs,
501 enable_petiole=enable_petiole_collision,
502 enable_fruit=enable_fruit_collision
504 except Exception
as e:
509 Disable collision detection for plant growth.
511 This method turns off the collision detection system, allowing plants to grow
512 without checking for obstacles. This improves performance but plants may grow
513 through obstacles and other geometry.
516 PlantArchitectureError: If disabling fails
519 >>> plantarch.disableCollisionDetection()
523 except Exception
as e:
527 view_half_angle_deg: float = 80.0,
528 look_ahead_distance: float = 0.1,
529 sample_count: int = 256,
530 inertia_weight: float = 0.4) ->
None:
532 Configure parameters for soft collision avoidance algorithm.
534 These parameters control the cone-based gap detection algorithm that guides
535 plant growth away from obstacles. Adjusting these values allows fine-tuning
536 the balance between collision avoidance and natural growth patterns.
539 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
540 Default 80° provides wide field of view.
541 look_ahead_distance: Distance to look ahead for collisions in meters.
542 Larger values detect distant obstacles. Default 0.1m.
543 sample_count: Number of ray samples within cone. More samples improve
544 accuracy but reduce performance. Default 256.
545 inertia_weight: Weight for previous growth direction (0-1). Higher values
546 make growth smoother but less responsive. Default 0.4.
549 ValueError: If parameters are outside valid ranges
550 PlantArchitectureError: If parameter setting fails
553 >>> # Use default parameters (recommended)
554 >>> plantarch.setSoftCollisionAvoidanceParameters()
556 >>> # Tune for dense canopy with close obstacles
557 >>> plantarch.setSoftCollisionAvoidanceParameters(
558 ... view_half_angle_deg=60.0, # Narrower detection cone
559 ... look_ahead_distance=0.05, # Shorter look-ahead
560 ... sample_count=512, # More accurate detection
561 ... inertia_weight=0.3 # More responsive to obstacles
565 if not (0 <= view_half_angle_deg <= 180):
566 raise ValueError(f
"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
567 if look_ahead_distance <= 0:
568 raise ValueError(f
"look_ahead_distance must be positive, got {look_ahead_distance}")
569 if sample_count <= 0:
570 raise ValueError(f
"sample_count must be positive, got {sample_count}")
571 if not (0 <= inertia_weight <= 1):
572 raise ValueError(f
"inertia_weight must be between 0 and 1, got {inertia_weight}")
575 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
582 except Exception
as e:
586 include_internodes: bool =
False,
587 include_leaves: bool =
True,
588 include_petioles: bool =
False,
589 include_flowers: bool =
False,
590 include_fruit: bool =
False) ->
None:
592 Specify which plant organs participate in collision detection.
594 This method allows filtering which organs are considered during collision detection,
595 enabling optimization by excluding organs unlikely to cause problematic collisions.
598 include_internodes: Include stem internodes in collision detection
599 include_leaves: Include leaf blades in collision detection
600 include_petioles: Include leaf petioles in collision detection
601 include_flowers: Include flowers in collision detection
602 include_fruit: Include fruit in collision detection
605 PlantArchitectureError: If organ filtering fails
608 >>> # Only detect collisions for stems and leaves (default behavior)
609 >>> plantarch.setCollisionRelevantOrgans(
610 ... include_internodes=True,
611 ... include_leaves=True
614 >>> # Include all organs
615 >>> plantarch.setCollisionRelevantOrgans(
616 ... include_internodes=True,
617 ... include_leaves=True,
618 ... include_petioles=True,
619 ... include_flowers=True,
620 ... include_fruit=True
624 plantarch_wrapper.setCollisionRelevantOrgans(
632 except Exception
as e:
636 obstacle_UUIDs: List[int],
637 avoidance_distance: float = 0.5,
638 enable_fruit_adjustment: bool =
False,
639 enable_obstacle_pruning: bool =
False) ->
None:
641 Enable hard obstacle avoidance for specified geometry.
643 This method configures solid obstacles that plants cannot grow through. Unlike soft
644 collision avoidance (which guides growth), solid obstacles cause complete growth
645 termination when encountered within the avoidance distance.
648 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
649 avoidance_distance: Minimum distance to maintain from obstacles (meters).
650 Growth stops if obstacles are closer. Default 0.5m.
651 enable_fruit_adjustment: Adjust fruit positions away from obstacles
652 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
655 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
656 PlantArchitectureError: If solid obstacle configuration fails
659 >>> # Simple solid obstacle avoidance
660 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
661 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
663 >>> # Close avoidance with fruit adjustment
664 >>> plantarch.enableSolidObstacleAvoidance(
665 ... obstacle_UUIDs=wall_uuids,
666 ... avoidance_distance=0.1,
667 ... enable_fruit_adjustment=True
670 if not obstacle_UUIDs:
671 raise ValueError(
"Obstacle UUIDs list cannot be empty")
672 if avoidance_distance <= 0:
673 raise ValueError(f
"avoidance_distance must be positive, got {avoidance_distance}")
677 plantarch_wrapper.enableSolidObstacleAvoidance(
681 enable_fruit_adjustment,
682 enable_obstacle_pruning
684 except Exception
as e:
689 Mark geometry as static obstacles for collision detection optimization.
691 This method tells the collision detection system that certain geometry will not
692 move during the simulation. The system can then build an optimized Bounding Volume
693 Hierarchy (BVH) for these obstacles, significantly improving collision detection
694 performance in scenes with many static obstacles.
697 target_UUIDs: List of primitive UUIDs representing static obstacles
700 ValueError: If target_UUIDs is empty
701 PlantArchitectureError: If static obstacle configuration fails
704 Call this method BEFORE enabling collision avoidance for best performance.
705 Static obstacles cannot be modified or moved after being marked static.
708 >>> # Mark ground and building geometry as static
709 >>> static_uuids = ground_uuids + building_uuids
710 >>> plantarch.setStaticObstacles(static_uuids)
711 >>> # Now enable collision avoidance
712 >>> plantarch.enableSoftCollisionAvoidance()
715 raise ValueError(
"target_UUIDs list cannot be empty")
719 plantarch_wrapper.setStaticObstacles(self.
_plantarch_ptr, target_UUIDs)
720 except Exception
as e:
725 Get object IDs of collision-relevant geometry for a specific plant.
727 This method returns the subset of plant geometry that participates in collision
728 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
729 and debugging collision detection behavior.
732 plant_id: ID of the plant instance
735 List of object IDs for collision-relevant plant geometry
738 ValueError: If plant_id is negative
739 PlantArchitectureError: If retrieval fails
742 >>> # Get collision-relevant geometry
743 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
744 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
746 >>> # Highlight collision geometry in visualization
747 >>> for obj_id in collision_obj_ids:
748 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
751 raise ValueError(
"Plant ID must be non-negative")
754 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self.
_plantarch_ptr, plant_id)
755 except Exception
as e:
761 Write all plant mesh vertices to file for external processing.
763 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
764 writing one vertex per line. Useful for external processing such as computing bounding
765 volumes, convex hulls, or performing custom geometric analysis.
768 plant_id: ID of the plant instance to export
769 filename: Path to output file (absolute or relative to current working directory)
772 ValueError: If plant_id is negative or filename is empty
773 PlantArchitectureError: If plant doesn't exist or file cannot be written
776 >>> # Export vertices for convex hull analysis
777 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
779 >>> # Use with Path object
780 >>> from pathlib import Path
781 >>> output_dir = Path("output")
782 >>> output_dir.mkdir(exist_ok=True)
783 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
786 raise ValueError(
"Plant ID must be non-negative")
788 raise ValueError(
"Filename cannot be empty")
795 plantarch_wrapper.writePlantMeshVertices(
798 except Exception
as e:
803 Save plant structure to XML file for later loading.
805 This method exports the complete plant architecture to an XML file, including
806 all shoots, phytomers, organs, and their properties. The saved plant can be
807 reloaded later using readPlantStructureXML().
810 plant_id: ID of the plant instance to save
811 filename: Path to output XML file (absolute or relative to current working directory)
814 ValueError: If plant_id is negative or filename is empty
815 PlantArchitectureError: If plant doesn't exist or file cannot be written
818 The XML format preserves the complete plant state including:
819 - Shoot structure and hierarchy
820 - Phytomer properties and development stage
821 - Organ geometry and attributes
822 - Growth parameters and phenological state
825 >>> # Save plant at current growth stage
826 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
828 >>> # Later, reload the saved plant
829 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
830 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
833 raise ValueError(
"Plant ID must be non-negative")
835 raise ValueError(
"Filename cannot be empty")
842 plantarch_wrapper.writePlantStructureXML(
845 except Exception
as e:
850 Export plant structure in TreeQSM cylinder format.
852 This method writes the plant structure as a series of cylinders following the
853 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
854 columns for radius, length, start position, axis direction, branch topology,
855 and other structural properties. Useful for biomechanical analysis and
856 quantitative structure modeling.
859 plant_id: ID of the plant instance to export
860 filename: Path to output file (absolute or relative, typically .txt extension)
863 ValueError: If plant_id is negative or filename is empty
864 PlantArchitectureError: If plant doesn't exist or file cannot be written
867 The TreeQSM format includes columns for:
868 - Cylinder dimensions (radius, length)
869 - Spatial position and orientation
870 - Branch topology (parent ID, extension ID, branch ID)
871 - Branch hierarchy (branch order, position in branch)
872 - Quality metrics (mean absolute distance, surface coverage)
875 >>> # Export for biomechanical analysis
876 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
878 >>> # Use with external QSM tools
879 >>> import pandas as pd
880 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
881 >>> print(f"Tree has {len(qsm_data)} cylinders")
884 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
885 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
888 raise ValueError(
"Plant ID must be non-negative")
890 raise ValueError(
"Filename cannot be empty")
897 plantarch_wrapper.writeQSMCylinderFile(
900 except Exception
as e:
905 Load plant structure from XML file.
907 This method reads plant architecture data from an XML file previously saved with
908 writePlantStructureXML(). The loaded plants are added to the current context
909 and can be grown, modified, or analyzed like any other plants.
912 filename: Path to XML file to load (absolute or relative to current working directory)
913 quiet: If True, suppress console output during loading (default: False)
916 List of plant IDs for the loaded plant instances
919 ValueError: If filename is empty
920 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
923 The XML file can contain multiple plant instances. All plants in the file
924 will be loaded and their IDs returned in a list. Plant models referenced
925 in the XML must be available in the plant library.
928 >>> # Load previously saved plants
929 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
930 >>> print(f"Loaded {len(plant_ids)} plants")
932 >>> # Continue growing the loaded plants
933 >>> plantarch.advanceTime(10.0)
935 >>> # Load quietly without console messages
936 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
939 raise ValueError(
"Filename cannot be empty")
946 return plantarch_wrapper.readPlantStructureXML(
949 except Exception
as e:
955 Create an empty plant instance for custom plant building.
957 This method creates a new plant instance at the specified location without any
958 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
959 manually construct the plant structure. This provides low-level control over
960 plant architecture, enabling custom morphologies not available in the plant library.
963 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
964 current_age: Current age of the plant in days (must be >= 0)
967 Plant ID for the created plant instance
970 ValueError: If age is negative
971 PlantArchitectureError: If plant creation fails
974 >>> # Create empty plant at origin
975 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
977 >>> # Now add shoots to build custom plant structure
978 >>> shoot_id = plantarch.addBaseStemShoot(
979 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
983 position_list = [base_position.x, base_position.y, base_position.z]
987 raise ValueError(f
"Age must be non-negative, got {current_age}")
991 return plantarch_wrapper.addPlantInstance(
994 except Exception
as e:
999 Delete a plant instance and all associated geometry.
1001 This method removes a plant from the simulation, deleting all shoots, organs,
1002 and associated primitives from the context. The plant ID becomes invalid after
1003 deletion and should not be used in subsequent operations.
1006 plant_id: ID of the plant instance to delete
1009 ValueError: If plant_id is negative
1010 PlantArchitectureError: If plant deletion fails or plant doesn't exist
1013 >>> # Delete a plant
1014 >>> plantarch.deletePlantInstance(plant_id)
1016 >>> # Delete multiple plants
1017 >>> for pid in plant_ids_to_remove:
1018 ... plantarch.deletePlantInstance(pid)
1021 raise ValueError(
"Plant ID must be non-negative")
1025 plantarch_wrapper.deletePlantInstance(self.
_plantarch_ptr, plant_id)
1026 except Exception
as e:
1031 current_node_number: int,
1032 base_rotation: AxisRotation,
1033 internode_radius: float,
1034 internode_length_max: float,
1035 internode_length_scale_factor_fraction: float,
1036 leaf_scale_factor_fraction: float,
1037 radius_taper: float,
1038 shoot_type_label: str) -> int:
1040 Add a base stem shoot to a plant instance (main trunk/stem).
1042 This method creates the primary shoot originating from the plant base. The base stem
1043 is typically the main trunk or primary stem from which all other shoots branch.
1044 Specify growth parameters to control the shoot's morphology and development.
1046 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
1047 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
1048 shoot types that can then be used for custom building. The shoot_type_label must match a
1049 shoot type defined in the loaded model.
1052 plant_id: ID of the plant instance
1053 current_node_number: Starting node number for this shoot (typically 1)
1054 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1055 internode_radius: Base radius of internodes in meters (must be > 0)
1056 internode_length_max: Maximum internode length in meters (must be > 0)
1057 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1058 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1059 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1060 shoot_type_label: Label identifying shoot type - must match a type from loaded model
1063 Shoot ID for the created shoot
1066 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1067 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
1070 >>> from pyhelios import AxisRotation
1072 >>> # REQUIRED: Load a plant model to define shoot types
1073 >>> plantarch.loadPlantModelFromLibrary("bean")
1075 >>> # Create empty plant for custom building
1076 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1078 >>> # Add base stem using shoot type from loaded model
1079 >>> shoot_id = plantarch.addBaseStemShoot(
1080 ... plant_id=plant_id,
1081 ... current_node_number=1,
1082 ... base_rotation=AxisRotation(0, 0, 0), # Upright
1083 ... internode_radius=0.01, # 1cm radius
1084 ... internode_length_max=0.1, # 10cm max length
1085 ... internode_length_scale_factor_fraction=1.0,
1086 ... leaf_scale_factor_fraction=1.0,
1087 ... radius_taper=0.9, # Gradual taper
1088 ... shoot_type_label="stem" # Must match loaded model
1092 raise ValueError(
"Plant ID must be non-negative")
1093 if current_node_number < 0:
1094 raise ValueError(
"Current node number must be non-negative")
1095 if internode_radius <= 0:
1096 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1097 if internode_length_max <= 0:
1098 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1099 if not shoot_type_label
or not shoot_type_label.strip():
1100 raise ValueError(
"Shoot type label cannot be empty")
1103 rotation_list = base_rotation.to_list()
1107 return plantarch_wrapper.addBaseStemShoot(
1108 self.
_plantarch_ptr, plant_id, current_node_number, rotation_list,
1109 internode_radius, internode_length_max,
1110 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1111 radius_taper, shoot_type_label.strip()
1113 except Exception
as e:
1115 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1117 f
"Shoot type '{shoot_type_label}' not defined. "
1118 f
"Load a plant model first to define shoot types:\n"
1119 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1120 f
"Original error: {e}"
1126 parent_shoot_id: int,
1127 current_node_number: int,
1128 base_rotation: AxisRotation,
1129 internode_radius: float,
1130 internode_length_max: float,
1131 internode_length_scale_factor_fraction: float,
1132 leaf_scale_factor_fraction: float,
1133 radius_taper: float,
1134 shoot_type_label: str) -> int:
1136 Append a shoot to the end of an existing shoot.
1138 This method extends an existing shoot by appending a new shoot at its terminal bud.
1139 Useful for creating multi-segmented shoots with varying properties along their length,
1140 such as shoots with different growth phases or developmental stages.
1142 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1143 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1144 calling this method.
1147 plant_id: ID of the plant instance
1148 parent_shoot_id: ID of the parent shoot to extend
1149 current_node_number: Starting node number for this shoot
1150 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1151 internode_radius: Base radius of internodes in meters (must be > 0)
1152 internode_length_max: Maximum internode length in meters (must be > 0)
1153 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1154 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1155 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1156 shoot_type_label: Label identifying shoot type - must match loaded model
1159 Shoot ID for the appended shoot
1162 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1163 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
1166 >>> # Load model to define shoot types
1167 >>> plantarch.loadPlantModelFromLibrary("bean")
1169 >>> # Append shoot with reduced size to simulate apical growth
1170 >>> new_shoot_id = plantarch.appendShoot(
1171 ... plant_id=plant_id,
1172 ... parent_shoot_id=base_shoot_id,
1173 ... current_node_number=10,
1174 ... base_rotation=AxisRotation(0, 0, 0),
1175 ... internode_radius=0.008, # Smaller than base
1176 ... internode_length_max=0.08, # Shorter internodes
1177 ... internode_length_scale_factor_fraction=1.0,
1178 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
1179 ... radius_taper=0.85,
1180 ... shoot_type_label="stem"
1184 raise ValueError(
"Plant ID must be non-negative")
1185 if parent_shoot_id < 0:
1186 raise ValueError(
"Parent shoot ID must be non-negative")
1187 if current_node_number < 0:
1188 raise ValueError(
"Current node number must be non-negative")
1189 if internode_radius <= 0:
1190 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1191 if internode_length_max <= 0:
1192 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1193 if not shoot_type_label
or not shoot_type_label.strip():
1194 raise ValueError(
"Shoot type label cannot be empty")
1197 rotation_list = base_rotation.to_list()
1201 return plantarch_wrapper.appendShoot(
1202 self.
_plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
1203 rotation_list, internode_radius, internode_length_max,
1204 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1205 radius_taper, shoot_type_label.strip()
1207 except Exception
as e:
1209 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1211 f
"Shoot type '{shoot_type_label}' not defined. "
1212 f
"Load a plant model first to define shoot types:\n"
1213 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1214 f
"Original error: {e}"
1220 parent_shoot_id: int,
1221 parent_node_index: int,
1222 current_node_number: int,
1223 shoot_base_rotation: AxisRotation,
1224 internode_radius: float,
1225 internode_length_max: float,
1226 internode_length_scale_factor_fraction: float,
1227 leaf_scale_factor_fraction: float,
1228 radius_taper: float,
1229 shoot_type_label: str,
1230 petiole_index: int = 0) -> int:
1232 Add a child shoot at an axillary bud position on a parent shoot.
1234 This method creates a lateral branch shoot emerging from a specific node on the
1235 parent shoot. Child shoots enable creation of branching architectures, with control
1236 over branch angle, size, and which petiole position the branch emerges from (for
1237 plants with multiple petioles per node).
1239 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1240 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1241 calling this method.
1244 plant_id: ID of the plant instance
1245 parent_shoot_id: ID of the parent shoot
1246 parent_node_index: Index of the parent node where child emerges (0-based)
1247 current_node_number: Starting node number for this child shoot
1248 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1249 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
1250 internode_length_max: Maximum internode length in meters (must be > 0)
1251 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1252 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1253 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1254 shoot_type_label: Label identifying shoot type - must match loaded model
1255 petiole_index: Which petiole at the node to branch from (default: 0)
1258 Shoot ID for the created child shoot
1261 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
1262 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
1265 >>> # Load model to define shoot types
1266 >>> plantarch.loadPlantModelFromLibrary("bean")
1268 >>> # Add lateral branch at 45-degree angle from node 3
1269 >>> branch_id = plantarch.addChildShoot(
1270 ... plant_id=plant_id,
1271 ... parent_shoot_id=main_shoot_id,
1272 ... parent_node_index=3,
1273 ... current_node_number=1,
1274 ... shoot_base_rotation=AxisRotation(45, 90, 0), # 45° out, 90° rotation
1275 ... internode_radius=0.005, # Thinner than main stem
1276 ... internode_length_max=0.06, # Shorter internodes
1277 ... internode_length_scale_factor_fraction=1.0,
1278 ... leaf_scale_factor_fraction=0.9,
1279 ... radius_taper=0.8,
1280 ... shoot_type_label="stem"
1283 >>> # Add second branch from opposite petiole
1284 >>> branch_id2 = plantarch.addChildShoot(
1285 ... plant_id, main_shoot_id, 3, 1, AxisRotation(45, 270, 0),
1286 ... 0.005, 0.06, 1.0, 0.9, 0.8, "stem", petiole_index=1
1290 raise ValueError(
"Plant ID must be non-negative")
1291 if parent_shoot_id < 0:
1292 raise ValueError(
"Parent shoot ID must be non-negative")
1293 if parent_node_index < 0:
1294 raise ValueError(
"Parent node index must be non-negative")
1295 if current_node_number < 0:
1296 raise ValueError(
"Current node number must be non-negative")
1297 if internode_radius <= 0:
1298 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1299 if internode_length_max <= 0:
1300 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1301 if not shoot_type_label
or not shoot_type_label.strip():
1302 raise ValueError(
"Shoot type label cannot be empty")
1303 if petiole_index < 0:
1304 raise ValueError(f
"Petiole index must be non-negative, got {petiole_index}")
1307 rotation_list = shoot_base_rotation.to_list()
1311 return plantarch_wrapper.addChildShoot(
1312 self.
_plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
1313 current_node_number, rotation_list, internode_radius,
1314 internode_length_max, internode_length_scale_factor_fraction,
1315 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
1318 except Exception
as e:
1320 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1322 f
"Shoot type '{shoot_type_label}' not defined. "
1323 f
"Load a plant model first to define shoot types:\n"
1324 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1325 f
"Original error: {e}"
1331 Check if PlantArchitecture is available in current build.
1334 True if plugin is available, False otherwise
1342 Create PlantArchitecture instance with context.
1345 context: Helios Context
1348 PlantArchitecture instance
1351 >>> context = Context()
1352 >>> 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.
int buildPlantInstanceFromLibrary(self, vec3 base_position, float age)
Build a plant instance from the currently loaded library model.
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 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.
List[int] buildPlantCanopyFromLibrary(self, vec3 canopy_center, vec2 plant_spacing, int2 plant_count, float age)
Build a canopy of regularly spaced plants from the currently loaded library model.
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.
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.
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).
__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.
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...