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 __new__(cls, context=None):
187 Create PlantArchitecture instance.
188 Explicit __new__ to prevent ctypes contamination on Windows.
190 return object.__new__(cls)
192 def __init__(self, context: Context):
194 Initialize PlantArchitecture with a Helios context.
197 context: Active Helios Context instance
200 PlantArchitectureError: If plugin not available in current build
201 RuntimeError: If plugin initialization fails
204 registry = get_plugin_registry()
205 if not registry.is_plugin_available(
'plantarchitecture'):
207 "PlantArchitecture not available in current Helios library. "
208 "Rebuild PyHelios with PlantArchitecture support:\n"
209 " build_scripts/build_helios --plugins plantarchitecture\n"
211 "System requirements:\n"
212 f
" - Platforms: Windows, Linux, macOS\n"
213 " - Dependencies: Extensive asset library (textures, OBJ models)\n"
214 " - GPU: Not required\n"
216 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
217 "rice, soybean, tomato, wheat, and many others."
225 self.
_plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
231 """Context manager entry"""
234 def __exit__(self, exc_type, exc_val, exc_tb):
235 """Context manager exit - cleanup resources"""
241 """Destructor to ensure C++ resources freed even without 'with' statement."""
242 if hasattr(self,
'_plantarch_ptr')
and self.
_plantarch_ptr is not None:
246 except Exception
as e:
248 warnings.warn(f
"Error in PlantArchitecture.__del__: {e}")
252 Load a plant model from the built-in library.
255 plant_label: Plant model identifier from library. Available models include:
256 "almond", "apple", "bean", "bindweed", "butterlettuce", "capsicum",
257 "cheeseweed", "cowpea", "easternredbud", "grapevine_VSP", "maize",
258 "olive", "pistachio", "puncturevine", "rice", "sorghum", "soybean",
259 "strawberry", "sugarbeet", "tomato", "cherrytomato", "walnut", "wheat"
262 ValueError: If plant_label is empty or invalid
263 PlantArchitectureError: If model loading fails
266 >>> plantarch.loadPlantModelFromLibrary("bean")
267 >>> plantarch.loadPlantModelFromLibrary("almond")
270 raise ValueError(
"Plant label cannot be empty")
272 if not plant_label.strip():
273 raise ValueError(
"Plant label cannot be only whitespace")
277 plantarch_wrapper.loadPlantModelFromLibrary(self.
_plantarch_ptr, plant_label.strip())
278 except Exception
as e:
283 Build a plant instance from the currently loaded library model.
286 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
287 age: Age of the plant in days (must be >= 0)
290 Plant ID for the created plant instance
293 ValueError: If age is negative
294 PlantArchitectureError: If plant building fails
295 RuntimeError: If no model has been loaded
298 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(2.0, 3.0, 0.0), age=45.0)
299 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(0, 0, 0), age=30.0)
302 position_list = [base_position.x, base_position.y, base_position.z]
306 raise ValueError(f
"Age must be non-negative, got {age}")
310 return plantarch_wrapper.buildPlantInstanceFromLibrary(
313 except Exception
as e:
318 plant_count: int2, age: float) -> List[int]:
320 Build a canopy of regularly spaced plants from the currently loaded library model.
323 canopy_center: Cartesian (x,y,z) coordinates of canopy center as vec3
324 plant_spacing: Spacing between plants in x- and y-directions (meters) as vec2
325 plant_count: Number of plants in x- and y-directions as int2
326 age: Age of all plants in days (must be >= 0)
329 List of plant IDs for the created plant instances
332 ValueError: If age is negative or plant count values are not positive
333 PlantArchitectureError: If canopy building fails
336 >>> # 3x3 canopy with 0.5m spacing, 30-day-old plants
337 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
338 ... canopy_center=vec3(0, 0, 0),
339 ... plant_spacing=vec2(0.5, 0.5),
340 ... plant_count=int2(3, 3),
346 raise ValueError(f
"Age must be non-negative, got {age}")
349 if plant_count.x <= 0
or plant_count.y <= 0:
350 raise ValueError(
"Plant count values must be positive integers")
353 center_list = [canopy_center.x, canopy_center.y, canopy_center.z]
354 spacing_list = [plant_spacing.x, plant_spacing.y]
355 count_list = [plant_count.x, plant_count.y]
359 return plantarch_wrapper.buildPlantCanopyFromLibrary(
362 except Exception
as e:
367 Advance time for plant growth and development.
369 This method updates all plants in the simulation, potentially adding new phytomers,
370 growing existing organs, transitioning phenological stages, and updating plant geometry.
373 dt: Time step to advance in days (must be >= 0)
376 ValueError: If dt is negative
377 PlantArchitectureError: If time advancement fails
380 Large time steps are more efficient than many small steps. The timestep value
381 can be larger than the phyllochron, allowing multiple phytomers to be produced
385 >>> plantarch.advanceTime(10.0) # Advance 10 days
386 >>> plantarch.advanceTime(0.5) # Advance 12 hours
390 raise ValueError(f
"Time step must be non-negative, got {dt}")
395 except Exception
as e:
400 Get list of all available plant models in the library.
403 List of plant model names available for loading
406 PlantArchitectureError: If retrieval fails
409 >>> models = plantarch.getAvailablePlantModels()
410 >>> print(f"Available models: {', '.join(models)}")
411 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
415 return plantarch_wrapper.getAvailablePlantModels(self.
_plantarch_ptr)
416 except Exception
as e:
421 Get all object IDs for a specific plant.
424 plant_id: ID of the plant instance
427 List of object IDs comprising the plant
430 ValueError: If plant_id is negative
431 PlantArchitectureError: If retrieval fails
434 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
435 >>> print(f"Plant has {len(object_ids)} objects")
438 raise ValueError(
"Plant ID must be non-negative")
441 return plantarch_wrapper.getAllPlantObjectIDs(self.
_plantarch_ptr, plant_id)
442 except Exception
as e:
447 Get all primitive UUIDs for a specific plant.
450 plant_id: ID of the plant instance
453 List of primitive UUIDs comprising the plant
456 ValueError: If plant_id is negative
457 PlantArchitectureError: If retrieval fails
460 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
461 >>> print(f"Plant has {len(uuids)} primitives")
464 raise ValueError(
"Plant ID must be non-negative")
467 return plantarch_wrapper.getAllPlantUUIDs(self.
_plantarch_ptr, plant_id)
468 except Exception
as e:
473 target_object_UUIDs: Optional[List[int]] =
None,
474 target_object_IDs: Optional[List[int]] =
None,
475 enable_petiole_collision: bool =
False,
476 enable_fruit_collision: bool =
False) ->
None:
478 Enable soft collision avoidance for procedural plant growth.
480 This method enables the collision detection system that guides plant growth away from
481 obstacles and other plants. The system uses cone-based gap detection to find optimal
482 growth directions that minimize collisions while maintaining natural plant architecture.
485 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
486 avoids all geometry in the context.
487 target_object_IDs: List of compound object IDs to avoid collisions with.
488 enable_petiole_collision: Enable collision detection for leaf petioles
489 enable_fruit_collision: Enable collision detection for fruit organs
492 PlantArchitectureError: If collision detection activation fails
495 Collision detection adds computational overhead. Use setStaticObstacles() to mark
496 static geometry for BVH optimization and improved performance.
499 >>> # Avoid all geometry
500 >>> plantarch.enableSoftCollisionAvoidance()
502 >>> # Avoid specific obstacles
503 >>> obstacle_uuids = context.getAllUUIDs()
504 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
506 >>> # Enable collision detection for petioles and fruit
507 >>> plantarch.enableSoftCollisionAvoidance(
508 ... enable_petiole_collision=True,
509 ... enable_fruit_collision=True
514 plantarch_wrapper.enableSoftCollisionAvoidance(
516 target_UUIDs=target_object_UUIDs,
517 target_IDs=target_object_IDs,
518 enable_petiole=enable_petiole_collision,
519 enable_fruit=enable_fruit_collision
521 except Exception
as e:
526 Disable collision detection for plant growth.
528 This method turns off the collision detection system, allowing plants to grow
529 without checking for obstacles. This improves performance but plants may grow
530 through obstacles and other geometry.
533 PlantArchitectureError: If disabling fails
536 >>> plantarch.disableCollisionDetection()
540 except Exception
as e:
544 view_half_angle_deg: float = 80.0,
545 look_ahead_distance: float = 0.1,
546 sample_count: int = 256,
547 inertia_weight: float = 0.4) ->
None:
549 Configure parameters for soft collision avoidance algorithm.
551 These parameters control the cone-based gap detection algorithm that guides
552 plant growth away from obstacles. Adjusting these values allows fine-tuning
553 the balance between collision avoidance and natural growth patterns.
556 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
557 Default 80° provides wide field of view.
558 look_ahead_distance: Distance to look ahead for collisions in meters.
559 Larger values detect distant obstacles. Default 0.1m.
560 sample_count: Number of ray samples within cone. More samples improve
561 accuracy but reduce performance. Default 256.
562 inertia_weight: Weight for previous growth direction (0-1). Higher values
563 make growth smoother but less responsive. Default 0.4.
566 ValueError: If parameters are outside valid ranges
567 PlantArchitectureError: If parameter setting fails
570 >>> # Use default parameters (recommended)
571 >>> plantarch.setSoftCollisionAvoidanceParameters()
573 >>> # Tune for dense canopy with close obstacles
574 >>> plantarch.setSoftCollisionAvoidanceParameters(
575 ... view_half_angle_deg=60.0, # Narrower detection cone
576 ... look_ahead_distance=0.05, # Shorter look-ahead
577 ... sample_count=512, # More accurate detection
578 ... inertia_weight=0.3 # More responsive to obstacles
582 if not (0 <= view_half_angle_deg <= 180):
583 raise ValueError(f
"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
584 if look_ahead_distance <= 0:
585 raise ValueError(f
"look_ahead_distance must be positive, got {look_ahead_distance}")
586 if sample_count <= 0:
587 raise ValueError(f
"sample_count must be positive, got {sample_count}")
588 if not (0 <= inertia_weight <= 1):
589 raise ValueError(f
"inertia_weight must be between 0 and 1, got {inertia_weight}")
592 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
599 except Exception
as e:
603 include_internodes: bool =
False,
604 include_leaves: bool =
True,
605 include_petioles: bool =
False,
606 include_flowers: bool =
False,
607 include_fruit: bool =
False) ->
None:
609 Specify which plant organs participate in collision detection.
611 This method allows filtering which organs are considered during collision detection,
612 enabling optimization by excluding organs unlikely to cause problematic collisions.
615 include_internodes: Include stem internodes in collision detection
616 include_leaves: Include leaf blades in collision detection
617 include_petioles: Include leaf petioles in collision detection
618 include_flowers: Include flowers in collision detection
619 include_fruit: Include fruit in collision detection
622 PlantArchitectureError: If organ filtering fails
625 >>> # Only detect collisions for stems and leaves (default behavior)
626 >>> plantarch.setCollisionRelevantOrgans(
627 ... include_internodes=True,
628 ... include_leaves=True
631 >>> # Include all organs
632 >>> plantarch.setCollisionRelevantOrgans(
633 ... include_internodes=True,
634 ... include_leaves=True,
635 ... include_petioles=True,
636 ... include_flowers=True,
637 ... include_fruit=True
641 plantarch_wrapper.setCollisionRelevantOrgans(
649 except Exception
as e:
653 obstacle_UUIDs: List[int],
654 avoidance_distance: float = 0.5,
655 enable_fruit_adjustment: bool =
False,
656 enable_obstacle_pruning: bool =
False) ->
None:
658 Enable hard obstacle avoidance for specified geometry.
660 This method configures solid obstacles that plants cannot grow through. Unlike soft
661 collision avoidance (which guides growth), solid obstacles cause complete growth
662 termination when encountered within the avoidance distance.
665 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
666 avoidance_distance: Minimum distance to maintain from obstacles (meters).
667 Growth stops if obstacles are closer. Default 0.5m.
668 enable_fruit_adjustment: Adjust fruit positions away from obstacles
669 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
672 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
673 PlantArchitectureError: If solid obstacle configuration fails
676 >>> # Simple solid obstacle avoidance
677 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
678 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
680 >>> # Close avoidance with fruit adjustment
681 >>> plantarch.enableSolidObstacleAvoidance(
682 ... obstacle_UUIDs=wall_uuids,
683 ... avoidance_distance=0.1,
684 ... enable_fruit_adjustment=True
687 if not obstacle_UUIDs:
688 raise ValueError(
"Obstacle UUIDs list cannot be empty")
689 if avoidance_distance <= 0:
690 raise ValueError(f
"avoidance_distance must be positive, got {avoidance_distance}")
694 plantarch_wrapper.enableSolidObstacleAvoidance(
698 enable_fruit_adjustment,
699 enable_obstacle_pruning
701 except Exception
as e:
706 Mark geometry as static obstacles for collision detection optimization.
708 This method tells the collision detection system that certain geometry will not
709 move during the simulation. The system can then build an optimized Bounding Volume
710 Hierarchy (BVH) for these obstacles, significantly improving collision detection
711 performance in scenes with many static obstacles.
714 target_UUIDs: List of primitive UUIDs representing static obstacles
717 ValueError: If target_UUIDs is empty
718 PlantArchitectureError: If static obstacle configuration fails
721 Call this method BEFORE enabling collision avoidance for best performance.
722 Static obstacles cannot be modified or moved after being marked static.
725 >>> # Mark ground and building geometry as static
726 >>> static_uuids = ground_uuids + building_uuids
727 >>> plantarch.setStaticObstacles(static_uuids)
728 >>> # Now enable collision avoidance
729 >>> plantarch.enableSoftCollisionAvoidance()
732 raise ValueError(
"target_UUIDs list cannot be empty")
736 plantarch_wrapper.setStaticObstacles(self.
_plantarch_ptr, target_UUIDs)
737 except Exception
as e:
742 Get object IDs of collision-relevant geometry for a specific plant.
744 This method returns the subset of plant geometry that participates in collision
745 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
746 and debugging collision detection behavior.
749 plant_id: ID of the plant instance
752 List of object IDs for collision-relevant plant geometry
755 ValueError: If plant_id is negative
756 PlantArchitectureError: If retrieval fails
759 >>> # Get collision-relevant geometry
760 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
761 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
763 >>> # Highlight collision geometry in visualization
764 >>> for obj_id in collision_obj_ids:
765 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
768 raise ValueError(
"Plant ID must be non-negative")
771 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self.
_plantarch_ptr, plant_id)
772 except Exception
as e:
778 Write all plant mesh vertices to file for external processing.
780 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
781 writing one vertex per line. Useful for external processing such as computing bounding
782 volumes, convex hulls, or performing custom geometric analysis.
785 plant_id: ID of the plant instance to export
786 filename: Path to output file (absolute or relative to current working directory)
789 ValueError: If plant_id is negative or filename is empty
790 PlantArchitectureError: If plant doesn't exist or file cannot be written
793 >>> # Export vertices for convex hull analysis
794 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
796 >>> # Use with Path object
797 >>> from pathlib import Path
798 >>> output_dir = Path("output")
799 >>> output_dir.mkdir(exist_ok=True)
800 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
803 raise ValueError(
"Plant ID must be non-negative")
805 raise ValueError(
"Filename cannot be empty")
812 plantarch_wrapper.writePlantMeshVertices(
815 except Exception
as e:
820 Save plant structure to XML file for later loading.
822 This method exports the complete plant architecture to an XML file, including
823 all shoots, phytomers, organs, and their properties. The saved plant can be
824 reloaded later using readPlantStructureXML().
827 plant_id: ID of the plant instance to save
828 filename: Path to output XML file (absolute or relative to current working directory)
831 ValueError: If plant_id is negative or filename is empty
832 PlantArchitectureError: If plant doesn't exist or file cannot be written
835 The XML format preserves the complete plant state including:
836 - Shoot structure and hierarchy
837 - Phytomer properties and development stage
838 - Organ geometry and attributes
839 - Growth parameters and phenological state
842 >>> # Save plant at current growth stage
843 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
845 >>> # Later, reload the saved plant
846 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
847 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
850 raise ValueError(
"Plant ID must be non-negative")
852 raise ValueError(
"Filename cannot be empty")
859 plantarch_wrapper.writePlantStructureXML(
862 except Exception
as e:
867 Export plant structure in TreeQSM cylinder format.
869 This method writes the plant structure as a series of cylinders following the
870 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
871 columns for radius, length, start position, axis direction, branch topology,
872 and other structural properties. Useful for biomechanical analysis and
873 quantitative structure modeling.
876 plant_id: ID of the plant instance to export
877 filename: Path to output file (absolute or relative, typically .txt extension)
880 ValueError: If plant_id is negative or filename is empty
881 PlantArchitectureError: If plant doesn't exist or file cannot be written
884 The TreeQSM format includes columns for:
885 - Cylinder dimensions (radius, length)
886 - Spatial position and orientation
887 - Branch topology (parent ID, extension ID, branch ID)
888 - Branch hierarchy (branch order, position in branch)
889 - Quality metrics (mean absolute distance, surface coverage)
892 >>> # Export for biomechanical analysis
893 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
895 >>> # Use with external QSM tools
896 >>> import pandas as pd
897 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
898 >>> print(f"Tree has {len(qsm_data)} cylinders")
901 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
902 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
905 raise ValueError(
"Plant ID must be non-negative")
907 raise ValueError(
"Filename cannot be empty")
914 plantarch_wrapper.writeQSMCylinderFile(
917 except Exception
as e:
922 Load plant structure from XML file.
924 This method reads plant architecture data from an XML file previously saved with
925 writePlantStructureXML(). The loaded plants are added to the current context
926 and can be grown, modified, or analyzed like any other plants.
929 filename: Path to XML file to load (absolute or relative to current working directory)
930 quiet: If True, suppress console output during loading (default: False)
933 List of plant IDs for the loaded plant instances
936 ValueError: If filename is empty
937 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
940 The XML file can contain multiple plant instances. All plants in the file
941 will be loaded and their IDs returned in a list. Plant models referenced
942 in the XML must be available in the plant library.
945 >>> # Load previously saved plants
946 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
947 >>> print(f"Loaded {len(plant_ids)} plants")
949 >>> # Continue growing the loaded plants
950 >>> plantarch.advanceTime(10.0)
952 >>> # Load quietly without console messages
953 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
956 raise ValueError(
"Filename cannot be empty")
963 return plantarch_wrapper.readPlantStructureXML(
966 except Exception
as e:
972 Create an empty plant instance for custom plant building.
974 This method creates a new plant instance at the specified location without any
975 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
976 manually construct the plant structure. This provides low-level control over
977 plant architecture, enabling custom morphologies not available in the plant library.
980 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
981 current_age: Current age of the plant in days (must be >= 0)
984 Plant ID for the created plant instance
987 ValueError: If age is negative
988 PlantArchitectureError: If plant creation fails
991 >>> # Create empty plant at origin
992 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
994 >>> # Now add shoots to build custom plant structure
995 >>> shoot_id = plantarch.addBaseStemShoot(
996 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
1000 position_list = [base_position.x, base_position.y, base_position.z]
1004 raise ValueError(f
"Age must be non-negative, got {current_age}")
1008 return plantarch_wrapper.addPlantInstance(
1011 except Exception
as e:
1016 Delete a plant instance and all associated geometry.
1018 This method removes a plant from the simulation, deleting all shoots, organs,
1019 and associated primitives from the context. The plant ID becomes invalid after
1020 deletion and should not be used in subsequent operations.
1023 plant_id: ID of the plant instance to delete
1026 ValueError: If plant_id is negative
1027 PlantArchitectureError: If plant deletion fails or plant doesn't exist
1030 >>> # Delete a plant
1031 >>> plantarch.deletePlantInstance(plant_id)
1033 >>> # Delete multiple plants
1034 >>> for pid in plant_ids_to_remove:
1035 ... plantarch.deletePlantInstance(pid)
1038 raise ValueError(
"Plant ID must be non-negative")
1042 plantarch_wrapper.deletePlantInstance(self.
_plantarch_ptr, plant_id)
1043 except Exception
as e:
1048 current_node_number: int,
1049 base_rotation: AxisRotation,
1050 internode_radius: float,
1051 internode_length_max: float,
1052 internode_length_scale_factor_fraction: float,
1053 leaf_scale_factor_fraction: float,
1054 radius_taper: float,
1055 shoot_type_label: str) -> int:
1057 Add a base stem shoot to a plant instance (main trunk/stem).
1059 This method creates the primary shoot originating from the plant base. The base stem
1060 is typically the main trunk or primary stem from which all other shoots branch.
1061 Specify growth parameters to control the shoot's morphology and development.
1063 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
1064 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
1065 shoot types that can then be used for custom building. The shoot_type_label must match a
1066 shoot type defined in the loaded model.
1069 plant_id: ID of the plant instance
1070 current_node_number: Starting node number for this shoot (typically 1)
1071 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1072 internode_radius: Base radius of internodes in meters (must be > 0)
1073 internode_length_max: Maximum internode length in meters (must be > 0)
1074 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1075 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1076 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1077 shoot_type_label: Label identifying shoot type - must match a type from loaded model
1080 Shoot ID for the created shoot
1083 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1084 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
1087 >>> from pyhelios import AxisRotation
1089 >>> # REQUIRED: Load a plant model to define shoot types
1090 >>> plantarch.loadPlantModelFromLibrary("bean")
1092 >>> # Create empty plant for custom building
1093 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1095 >>> # Add base stem using shoot type from loaded model
1096 >>> shoot_id = plantarch.addBaseStemShoot(
1097 ... plant_id=plant_id,
1098 ... current_node_number=1,
1099 ... base_rotation=AxisRotation(0, 0, 0), # Upright
1100 ... internode_radius=0.01, # 1cm radius
1101 ... internode_length_max=0.1, # 10cm max length
1102 ... internode_length_scale_factor_fraction=1.0,
1103 ... leaf_scale_factor_fraction=1.0,
1104 ... radius_taper=0.9, # Gradual taper
1105 ... shoot_type_label="stem" # Must match loaded model
1109 raise ValueError(
"Plant ID must be non-negative")
1110 if current_node_number < 0:
1111 raise ValueError(
"Current node number must be non-negative")
1112 if internode_radius <= 0:
1113 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1114 if internode_length_max <= 0:
1115 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1116 if not shoot_type_label
or not shoot_type_label.strip():
1117 raise ValueError(
"Shoot type label cannot be empty")
1120 rotation_list = base_rotation.to_list()
1124 return plantarch_wrapper.addBaseStemShoot(
1125 self.
_plantarch_ptr, plant_id, current_node_number, rotation_list,
1126 internode_radius, internode_length_max,
1127 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1128 radius_taper, shoot_type_label.strip()
1130 except Exception
as e:
1132 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1134 f
"Shoot type '{shoot_type_label}' not defined. "
1135 f
"Load a plant model first to define shoot types:\n"
1136 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1137 f
"Original error: {e}"
1143 parent_shoot_id: int,
1144 current_node_number: int,
1145 base_rotation: AxisRotation,
1146 internode_radius: float,
1147 internode_length_max: float,
1148 internode_length_scale_factor_fraction: float,
1149 leaf_scale_factor_fraction: float,
1150 radius_taper: float,
1151 shoot_type_label: str) -> int:
1153 Append a shoot to the end of an existing shoot.
1155 This method extends an existing shoot by appending a new shoot at its terminal bud.
1156 Useful for creating multi-segmented shoots with varying properties along their length,
1157 such as shoots with different growth phases or developmental stages.
1159 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1160 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1161 calling this method.
1164 plant_id: ID of the plant instance
1165 parent_shoot_id: ID of the parent shoot to extend
1166 current_node_number: Starting node number for this shoot
1167 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1168 internode_radius: Base radius of internodes in meters (must be > 0)
1169 internode_length_max: Maximum internode length in meters (must be > 0)
1170 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1171 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1172 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1173 shoot_type_label: Label identifying shoot type - must match loaded model
1176 Shoot ID for the appended shoot
1179 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1180 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
1183 >>> # Load model to define shoot types
1184 >>> plantarch.loadPlantModelFromLibrary("bean")
1186 >>> # Append shoot with reduced size to simulate apical growth
1187 >>> new_shoot_id = plantarch.appendShoot(
1188 ... plant_id=plant_id,
1189 ... parent_shoot_id=base_shoot_id,
1190 ... current_node_number=10,
1191 ... base_rotation=AxisRotation(0, 0, 0),
1192 ... internode_radius=0.008, # Smaller than base
1193 ... internode_length_max=0.08, # Shorter internodes
1194 ... internode_length_scale_factor_fraction=1.0,
1195 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
1196 ... radius_taper=0.85,
1197 ... shoot_type_label="stem"
1201 raise ValueError(
"Plant ID must be non-negative")
1202 if parent_shoot_id < 0:
1203 raise ValueError(
"Parent shoot ID must be non-negative")
1204 if current_node_number < 0:
1205 raise ValueError(
"Current node number must be non-negative")
1206 if internode_radius <= 0:
1207 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1208 if internode_length_max <= 0:
1209 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1210 if not shoot_type_label
or not shoot_type_label.strip():
1211 raise ValueError(
"Shoot type label cannot be empty")
1214 rotation_list = base_rotation.to_list()
1218 return plantarch_wrapper.appendShoot(
1219 self.
_plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
1220 rotation_list, internode_radius, internode_length_max,
1221 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1222 radius_taper, shoot_type_label.strip()
1224 except Exception
as e:
1226 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1228 f
"Shoot type '{shoot_type_label}' not defined. "
1229 f
"Load a plant model first to define shoot types:\n"
1230 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1231 f
"Original error: {e}"
1237 parent_shoot_id: int,
1238 parent_node_index: int,
1239 current_node_number: int,
1240 shoot_base_rotation: AxisRotation,
1241 internode_radius: float,
1242 internode_length_max: float,
1243 internode_length_scale_factor_fraction: float,
1244 leaf_scale_factor_fraction: float,
1245 radius_taper: float,
1246 shoot_type_label: str,
1247 petiole_index: int = 0) -> int:
1249 Add a child shoot at an axillary bud position on a parent shoot.
1251 This method creates a lateral branch shoot emerging from a specific node on the
1252 parent shoot. Child shoots enable creation of branching architectures, with control
1253 over branch angle, size, and which petiole position the branch emerges from (for
1254 plants with multiple petioles per node).
1256 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1257 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1258 calling this method.
1261 plant_id: ID of the plant instance
1262 parent_shoot_id: ID of the parent shoot
1263 parent_node_index: Index of the parent node where child emerges (0-based)
1264 current_node_number: Starting node number for this child shoot
1265 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1266 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
1267 internode_length_max: Maximum internode length in meters (must be > 0)
1268 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1269 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1270 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1271 shoot_type_label: Label identifying shoot type - must match loaded model
1272 petiole_index: Which petiole at the node to branch from (default: 0)
1275 Shoot ID for the created child shoot
1278 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
1279 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
1282 >>> # Load model to define shoot types
1283 >>> plantarch.loadPlantModelFromLibrary("bean")
1285 >>> # Add lateral branch at 45-degree angle from node 3
1286 >>> branch_id = plantarch.addChildShoot(
1287 ... plant_id=plant_id,
1288 ... parent_shoot_id=main_shoot_id,
1289 ... parent_node_index=3,
1290 ... current_node_number=1,
1291 ... shoot_base_rotation=AxisRotation(45, 90, 0), # 45° out, 90° rotation
1292 ... internode_radius=0.005, # Thinner than main stem
1293 ... internode_length_max=0.06, # Shorter internodes
1294 ... internode_length_scale_factor_fraction=1.0,
1295 ... leaf_scale_factor_fraction=0.9,
1296 ... radius_taper=0.8,
1297 ... shoot_type_label="stem"
1300 >>> # Add second branch from opposite petiole
1301 >>> branch_id2 = plantarch.addChildShoot(
1302 ... plant_id, main_shoot_id, 3, 1, AxisRotation(45, 270, 0),
1303 ... 0.005, 0.06, 1.0, 0.9, 0.8, "stem", petiole_index=1
1307 raise ValueError(
"Plant ID must be non-negative")
1308 if parent_shoot_id < 0:
1309 raise ValueError(
"Parent shoot ID must be non-negative")
1310 if parent_node_index < 0:
1311 raise ValueError(
"Parent node index must be non-negative")
1312 if current_node_number < 0:
1313 raise ValueError(
"Current node number must be non-negative")
1314 if internode_radius <= 0:
1315 raise ValueError(f
"Internode radius must be positive, got {internode_radius}")
1316 if internode_length_max <= 0:
1317 raise ValueError(f
"Internode length max must be positive, got {internode_length_max}")
1318 if not shoot_type_label
or not shoot_type_label.strip():
1319 raise ValueError(
"Shoot type label cannot be empty")
1320 if petiole_index < 0:
1321 raise ValueError(f
"Petiole index must be non-negative, got {petiole_index}")
1324 rotation_list = shoot_base_rotation.to_list()
1328 return plantarch_wrapper.addChildShoot(
1329 self.
_plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
1330 current_node_number, rotation_list, internode_radius,
1331 internode_length_max, internode_length_scale_factor_fraction,
1332 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
1335 except Exception
as e:
1337 if "does not exist" in error_msg.lower()
and "shoot type" in error_msg.lower():
1339 f
"Shoot type '{shoot_type_label}' not defined. "
1340 f
"Load a plant model first to define shoot types:\n"
1341 f
" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1342 f
"Original error: {e}"
1348 Check if PlantArchitecture is available in current build.
1351 True if plugin is available, False otherwise
1359 Create PlantArchitecture instance with context.
1362 context: Helios Context
1365 PlantArchitecture instance
1368 >>> context = Context()
1369 >>> 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.
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
__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...