0.1.8
Loading...
Searching...
No Matches
PlantArchitecture.py
Go to the documentation of this file.
1"""
2High-level PlantArchitecture interface for PyHelios.
3
4This module provides a user-friendly interface to the plant architecture modeling
5capabilities with graceful plugin handling and informative error messages.
6"""
7
8import logging
9import os
10from contextlib import contextmanager
11from pathlib import Path
12from typing import List, Optional, Union
13
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
18try:
19 from .validation.datatypes import validate_vec3, validate_vec2, validate_int2
20except ImportError:
21 # Fallback validation functions for when validation module is not available
22 def validate_vec3(value, name, func):
23 if hasattr(value, 'x') and hasattr(value, 'y') and hasattr(value, 'z'):
24 return value
25 if isinstance(value, (list, tuple)) and len(value) == 3:
26 from .wrappers.DataTypes import vec3
27 return vec3(*value)
28 raise ValueError(f"{name} must be vec3 or 3-element list/tuple")
29
30 def validate_vec2(value, name, func):
31 if hasattr(value, 'x') and hasattr(value, 'y'):
32 return value
33 if isinstance(value, (list, tuple)) and len(value) == 2:
34 from .wrappers.DataTypes import vec2
35 return vec2(*value)
36 raise ValueError(f"{name} must be vec2 or 2-element list/tuple")
37
38 def validate_int2(value, name, func):
39 if hasattr(value, 'x') and hasattr(value, 'y'):
40 return value
41 if isinstance(value, (list, tuple)) and len(value) == 2:
42 from .wrappers.DataTypes import int2
43 return int2(*value)
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
47
48logger = logging.getLogger(__name__)
49
50
51def _resolve_user_path(filepath: Union[str, Path]) -> str:
52 """
53 Convert relative paths to absolute paths before changing working directory.
54
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.
57
58 Args:
59 filepath: File path to resolve (string or Path object)
60
61 Returns:
62 Absolute path as string
63 """
64 path = Path(filepath)
65 if not path.is_absolute():
66 return str(Path.cwd() / path)
67 return str(path)
68
69
70@contextmanager
72 """
73 Context manager that temporarily changes working directory to where PlantArchitecture assets are located.
74
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.
78
79 Raises:
80 RuntimeError: If build directory or PlantArchitecture assets are not found, indicating a build system error.
81 """
82 # Find the build directory containing PlantArchitecture assets
83 # Try asset manager first (works for both development and wheel installations)
84 asset_manager = get_asset_manager()
85 working_dir = asset_manager._get_helios_build_path()
86
87 if working_dir and working_dir.exists():
88 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
89 else:
90 # For wheel installations, check packaged assets
91 current_dir = Path(__file__).parent
92 packaged_build = current_dir / 'assets' / 'build'
93
94 if packaged_build.exists():
95 working_dir = packaged_build
96 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
97 else:
98 # Fallback to development paths
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'
103
104 if not build_lib_dir.exists():
105 raise RuntimeError(
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"
109 )
110
111 if not plantarch_assets.exists():
112 raise RuntimeError(
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"
116 )
117
118 # Verify essential assets exist
119 assets_dir = plantarch_assets / 'assets'
120 if not assets_dir.exists():
121 raise RuntimeError(
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"
125 )
126
127 # Change to the build directory temporarily
128 original_dir = os.getcwd()
129 try:
130 os.chdir(working_dir)
131 logger.debug(f"Changed working directory to {working_dir} for PlantArchitecture asset access")
132 yield working_dir
133 finally:
134 os.chdir(original_dir)
135 logger.debug(f"Restored working directory to {original_dir}")
136
137
138class PlantArchitectureError(Exception):
139 """Raised when PlantArchitecture operations fail."""
140 pass
141
142
144 """
145 Check if PlantArchitecture plugin is available for use.
146
147 Returns:
148 bool: True if PlantArchitecture can be used, False otherwise
149 """
150 try:
151 # Check plugin registry
152 plugin_registry = get_plugin_registry()
153 if not plugin_registry.is_plugin_available('plantarchitecture'):
154 return False
155
156 # Check if wrapper functions are available
157 if not plantarch_wrapper._PLANTARCHITECTURE_FUNCTIONS_AVAILABLE:
158 return False
159
160 return True
161 except Exception:
162 return False
163
164
166 """
167 High-level interface for plant architecture modeling and procedural plant generation.
168
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.
173
174 This class requires the native Helios library built with PlantArchitecture support.
175 Use context managers for proper resource cleanup.
176
177 Example:
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
183 """
184
185 def __init__(self, context: Context):
186 """
187 Initialize PlantArchitecture with a Helios context.
188
189 Args:
190 context: Active Helios Context instance
191
192 Raises:
193 PlantArchitectureError: If plugin not available in current build
194 RuntimeError: If plugin initialization fails
195 """
196 # Check plugin availability
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"
203 "\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"
208 "\n"
209 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
210 "rice, soybean, tomato, wheat, and many others."
211 )
212
213 self.context = context
214 self._plantarch_ptr = None
215
216 # Create PlantArchitecture instance with asset-aware working directory
218 self._plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
220 if not self._plantarch_ptr:
221 raise PlantArchitectureError("Failed to initialize PlantArchitecture")
222
223 def __enter__(self):
224 """Context manager entry"""
225 return self
226
227 def __exit__(self, exc_type, exc_val, exc_tb):
228 """Context manager exit - cleanup resources"""
229 if hasattr(self, '_plantarch_ptr') and self._plantarch_ptr:
230 plantarch_wrapper.destroyPlantArchitecture(self._plantarch_ptr)
231 self._plantarch_ptr = None
232
233 def loadPlantModelFromLibrary(self, plant_label: str) -> None:
234 """
235 Load a plant model from the built-in library.
236
237 Args:
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"
243
244 Raises:
245 ValueError: If plant_label is empty or invalid
246 PlantArchitectureError: If model loading fails
247
248 Example:
249 >>> plantarch.loadPlantModelFromLibrary("bean")
250 >>> plantarch.loadPlantModelFromLibrary("almond")
251 """
252 if not plant_label:
253 raise ValueError("Plant label cannot be empty")
254
255 if not plant_label.strip():
256 raise ValueError("Plant label cannot be only whitespace")
257
258 try:
260 plantarch_wrapper.loadPlantModelFromLibrary(self._plantarch_ptr, plant_label.strip())
261 except Exception as e:
262 raise PlantArchitectureError(f"Failed to load plant model '{plant_label}': {e}")
263
264 def buildPlantInstanceFromLibrary(self, base_position: vec3, age: float) -> int:
265 """
266 Build a plant instance from the currently loaded library model.
267
268 Args:
269 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
270 age: Age of the plant in days (must be >= 0)
271
272 Returns:
273 Plant ID for the created plant instance
274
275 Raises:
276 ValueError: If age is negative
277 PlantArchitectureError: If plant building fails
278 RuntimeError: If no model has been loaded
279
280 Example:
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)
283 """
284 # Convert position to list for C++ interface
285 position_list = [base_position.x, base_position.y, base_position.z]
286
287 # Validate age (allow zero)
288 if age < 0:
289 raise ValueError(f"Age must be non-negative, got {age}")
290
291 try:
293 return plantarch_wrapper.buildPlantInstanceFromLibrary(
294 self._plantarch_ptr, position_list, age
296 except Exception as e:
297 raise PlantArchitectureError(f"Failed to build plant instance: {e}")
298
299 def buildPlantCanopyFromLibrary(self, canopy_center: vec3,
300 plant_spacing: vec2,
301 plant_count: int2, age: float) -> List[int]:
302 """
303 Build a canopy of regularly spaced plants from the currently loaded library model.
304
305 Args:
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)
310
311 Returns:
312 List of plant IDs for the created plant instances
313
314 Raises:
315 ValueError: If age is negative or plant count values are not positive
316 PlantArchitectureError: If canopy building fails
317
318 Example:
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),
324 ... age=30.0
325 ... )
326 """
327 # Validate age (allow zero)
328 if age < 0:
329 raise ValueError(f"Age must be non-negative, got {age}")
330
331 # Validate count values
332 if plant_count.x <= 0 or plant_count.y <= 0:
333 raise ValueError("Plant count values must be positive integers")
334
335 # Convert to lists for C++ interface
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]
339
340 try:
342 return plantarch_wrapper.buildPlantCanopyFromLibrary(
343 self._plantarch_ptr, center_list, spacing_list, count_list, age
344 )
345 except Exception as e:
346 raise PlantArchitectureError(f"Failed to build plant canopy: {e}")
347
348 def advanceTime(self, dt: float) -> None:
349 """
350 Advance time for plant growth and development.
351
352 This method updates all plants in the simulation, potentially adding new phytomers,
353 growing existing organs, transitioning phenological stages, and updating plant geometry.
354
355 Args:
356 dt: Time step to advance in days (must be >= 0)
357
358 Raises:
359 ValueError: If dt is negative
360 PlantArchitectureError: If time advancement fails
361
362 Note:
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
365 in a single call.
366
367 Example:
368 >>> plantarch.advanceTime(10.0) # Advance 10 days
369 >>> plantarch.advanceTime(0.5) # Advance 12 hours
370 """
371 # Validate time step (allow zero)
372 if dt < 0:
373 raise ValueError(f"Time step must be non-negative, got {dt}")
374
375 try:
377 plantarch_wrapper.advanceTime(self._plantarch_ptr, dt)
378 except Exception as e:
379 raise PlantArchitectureError(f"Failed to advance time by {dt} days: {e}")
380
381 def getAvailablePlantModels(self) -> List[str]:
382 """
383 Get list of all available plant models in the library.
384
385 Returns:
386 List of plant model names available for loading
387
388 Raises:
389 PlantArchitectureError: If retrieval fails
390
391 Example:
392 >>> models = plantarch.getAvailablePlantModels()
393 >>> print(f"Available models: {', '.join(models)}")
394 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
395 """
396 try:
398 return plantarch_wrapper.getAvailablePlantModels(self._plantarch_ptr)
399 except Exception as e:
400 raise PlantArchitectureError(f"Failed to get available plant models: {e}")
401
402 def getAllPlantObjectIDs(self, plant_id: int) -> List[int]:
403 """
404 Get all object IDs for a specific plant.
405
406 Args:
407 plant_id: ID of the plant instance
408
409 Returns:
410 List of object IDs comprising the plant
411
412 Raises:
413 ValueError: If plant_id is negative
414 PlantArchitectureError: If retrieval fails
415
416 Example:
417 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
418 >>> print(f"Plant has {len(object_ids)} objects")
419 """
420 if plant_id < 0:
421 raise ValueError("Plant ID must be non-negative")
422
423 try:
424 return plantarch_wrapper.getAllPlantObjectIDs(self._plantarch_ptr, plant_id)
425 except Exception as e:
426 raise PlantArchitectureError(f"Failed to get object IDs for plant {plant_id}: {e}")
427
428 def getAllPlantUUIDs(self, plant_id: int) -> List[int]:
429 """
430 Get all primitive UUIDs for a specific plant.
431
432 Args:
433 plant_id: ID of the plant instance
434
435 Returns:
436 List of primitive UUIDs comprising the plant
437
438 Raises:
439 ValueError: If plant_id is negative
440 PlantArchitectureError: If retrieval fails
441
442 Example:
443 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
444 >>> print(f"Plant has {len(uuids)} primitives")
445 """
446 if plant_id < 0:
447 raise ValueError("Plant ID must be non-negative")
448
449 try:
450 return plantarch_wrapper.getAllPlantUUIDs(self._plantarch_ptr, plant_id)
451 except Exception as e:
452 raise PlantArchitectureError(f"Failed to get UUIDs for plant {plant_id}: {e}")
453
454 # Collision detection methods
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:
460 """
461 Enable soft collision avoidance for procedural plant growth.
462
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.
466
467 Args:
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
473
474 Raises:
475 PlantArchitectureError: If collision detection activation fails
476
477 Note:
478 Collision detection adds computational overhead. Use setStaticObstacles() to mark
479 static geometry for BVH optimization and improved performance.
480
481 Example:
482 >>> # Avoid all geometry
483 >>> plantarch.enableSoftCollisionAvoidance()
484 >>>
485 >>> # Avoid specific obstacles
486 >>> obstacle_uuids = context.getAllUUIDs()
487 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
488 >>>
489 >>> # Enable collision detection for petioles and fruit
490 >>> plantarch.enableSoftCollisionAvoidance(
491 ... enable_petiole_collision=True,
492 ... enable_fruit_collision=True
493 ... )
494 """
495 try:
497 plantarch_wrapper.enableSoftCollisionAvoidance(
498 self._plantarch_ptr,
499 target_UUIDs=target_object_UUIDs,
500 target_IDs=target_object_IDs,
501 enable_petiole=enable_petiole_collision,
502 enable_fruit=enable_fruit_collision
503 )
504 except Exception as e:
505 raise PlantArchitectureError(f"Failed to enable soft collision avoidance: {e}")
506
507 def disableCollisionDetection(self) -> None:
508 """
509 Disable collision detection for plant growth.
510
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.
515 Raises:
516 PlantArchitectureError: If disabling fails
517
518 Example:
519 >>> plantarch.disableCollisionDetection()
520 """
521 try:
522 plantarch_wrapper.disableCollisionDetection(self._plantarch_ptr)
523 except Exception as e:
524 raise PlantArchitectureError(f"Failed to disable collision detection: {e}")
525
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:
531 """
532 Configure parameters for soft collision avoidance algorithm.
533
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.
537
538 Args:
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.
547
548 Raises:
549 ValueError: If parameters are outside valid ranges
550 PlantArchitectureError: If parameter setting fails
551
552 Example:
553 >>> # Use default parameters (recommended)
554 >>> plantarch.setSoftCollisionAvoidanceParameters()
555 >>>
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
562 ... )
563 """
564 # Validate parameters
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}")
573
574 try:
575 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
576 self._plantarch_ptr,
577 view_half_angle_deg,
578 look_ahead_distance,
579 sample_count,
580 inertia_weight
581 )
582 except Exception as e:
583 raise PlantArchitectureError(f"Failed to set collision avoidance parameters: {e}")
584
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:
591 """
592 Specify which plant organs participate in collision detection.
593
594 This method allows filtering which organs are considered during collision detection,
595 enabling optimization by excluding organs unlikely to cause problematic collisions.
596
597 Args:
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
603
604 Raises:
605 PlantArchitectureError: If organ filtering fails
606
607 Example:
608 >>> # Only detect collisions for stems and leaves (default behavior)
609 >>> plantarch.setCollisionRelevantOrgans(
610 ... include_internodes=True,
611 ... include_leaves=True
612 ... )
613 >>>
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
621 ... )
622 """
623 try:
624 plantarch_wrapper.setCollisionRelevantOrgans(
625 self._plantarch_ptr,
626 include_internodes,
627 include_leaves,
628 include_petioles,
629 include_flowers,
630 include_fruit
631 )
632 except Exception as e:
633 raise PlantArchitectureError(f"Failed to set collision-relevant organs: {e}")
634
636 obstacle_UUIDs: List[int],
637 avoidance_distance: float = 0.5,
638 enable_fruit_adjustment: bool = False,
639 enable_obstacle_pruning: bool = False) -> None:
640 """
641 Enable hard obstacle avoidance for specified geometry.
642
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.
646
647 Args:
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
653
654 Raises:
655 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
656 PlantArchitectureError: If solid obstacle configuration fails
657
658 Example:
659 >>> # Simple solid obstacle avoidance
660 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
661 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
662 >>>
663 >>> # Close avoidance with fruit adjustment
664 >>> plantarch.enableSolidObstacleAvoidance(
665 ... obstacle_UUIDs=wall_uuids,
666 ... avoidance_distance=0.1,
667 ... enable_fruit_adjustment=True
668 ... )
669 """
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}")
674
675 try:
677 plantarch_wrapper.enableSolidObstacleAvoidance(
678 self._plantarch_ptr,
679 obstacle_UUIDs,
680 avoidance_distance,
681 enable_fruit_adjustment,
682 enable_obstacle_pruning
683 )
684 except Exception as e:
685 raise PlantArchitectureError(f"Failed to enable solid obstacle avoidance: {e}")
686
687 def setStaticObstacles(self, target_UUIDs: List[int]) -> None:
688 """
689 Mark geometry as static obstacles for collision detection optimization.
690
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.
695
696 Args:
697 target_UUIDs: List of primitive UUIDs representing static obstacles
698
699 Raises:
700 ValueError: If target_UUIDs is empty
701 PlantArchitectureError: If static obstacle configuration fails
702
703 Note:
704 Call this method BEFORE enabling collision avoidance for best performance.
705 Static obstacles cannot be modified or moved after being marked static.
706
707 Example:
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()
713 """
714 if not target_UUIDs:
715 raise ValueError("target_UUIDs list cannot be empty")
716
717 try:
719 plantarch_wrapper.setStaticObstacles(self._plantarch_ptr, target_UUIDs)
720 except Exception as e:
721 raise PlantArchitectureError(f"Failed to set static obstacles: {e}")
722
723 def getPlantCollisionRelevantObjectIDs(self, plant_id: int) -> List[int]:
724 """
725 Get object IDs of collision-relevant geometry for a specific plant.
726
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.
730
731 Args:
732 plant_id: ID of the plant instance
733
734 Returns:
735 List of object IDs for collision-relevant plant geometry
736
737 Raises:
738 ValueError: If plant_id is negative
739 PlantArchitectureError: If retrieval fails
740
741 Example:
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")
745 >>>
746 >>> # Highlight collision geometry in visualization
747 >>> for obj_id in collision_obj_ids:
748 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
749 """
750 if plant_id < 0:
751 raise ValueError("Plant ID must be non-negative")
752
753 try:
754 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self._plantarch_ptr, plant_id)
755 except Exception as e:
756 raise PlantArchitectureError(f"Failed to get collision-relevant object IDs for plant {plant_id}: {e}")
757
758 # File I/O methods
759 def writePlantMeshVertices(self, plant_id: int, filename: Union[str, Path]) -> None:
760 """
761 Write all plant mesh vertices to file for external processing.
762
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.
766
767 Args:
768 plant_id: ID of the plant instance to export
769 filename: Path to output file (absolute or relative to current working directory)
770
771 Raises:
772 ValueError: If plant_id is negative or filename is empty
773 PlantArchitectureError: If plant doesn't exist or file cannot be written
774
775 Example:
776 >>> # Export vertices for convex hull analysis
777 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
778 >>>
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")
784 """
785 if plant_id < 0:
786 raise ValueError("Plant ID must be non-negative")
787 if not filename:
788 raise ValueError("Filename cannot be empty")
789
790 # Resolve path before changing directory
791 absolute_path = _resolve_user_path(filename)
792
793 try:
795 plantarch_wrapper.writePlantMeshVertices(
796 self._plantarch_ptr, plant_id, absolute_path
797 )
798 except Exception as e:
799 raise PlantArchitectureError(f"Failed to write plant mesh vertices to {filename}: {e}")
800
801 def writePlantStructureXML(self, plant_id: int, filename: Union[str, Path]) -> None:
802 """
803 Save plant structure to XML file for later loading.
804
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().
808
809 Args:
810 plant_id: ID of the plant instance to save
811 filename: Path to output XML file (absolute or relative to current working directory)
812
813 Raises:
814 ValueError: If plant_id is negative or filename is empty
815 PlantArchitectureError: If plant doesn't exist or file cannot be written
816
817 Note:
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
823
824 Example:
825 >>> # Save plant at current growth stage
826 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
827 >>>
828 >>> # Later, reload the saved plant
829 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
830 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
831 """
832 if plant_id < 0:
833 raise ValueError("Plant ID must be non-negative")
834 if not filename:
835 raise ValueError("Filename cannot be empty")
836
837 # Resolve path before changing directory
838 absolute_path = _resolve_user_path(filename)
839
840 try:
842 plantarch_wrapper.writePlantStructureXML(
843 self._plantarch_ptr, plant_id, absolute_path
844 )
845 except Exception as e:
846 raise PlantArchitectureError(f"Failed to write plant structure XML to {filename}: {e}")
847
848 def writeQSMCylinderFile(self, plant_id: int, filename: Union[str, Path]) -> None:
849 """
850 Export plant structure in TreeQSM cylinder format.
851
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.
857
858 Args:
859 plant_id: ID of the plant instance to export
860 filename: Path to output file (absolute or relative, typically .txt extension)
861
862 Raises:
863 ValueError: If plant_id is negative or filename is empty
864 PlantArchitectureError: If plant doesn't exist or file cannot be written
865
866 Note:
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)
873
874 Example:
875 >>> # Export for biomechanical analysis
876 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
877 >>>
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")
882
883 References:
884 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
885 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
886 """
887 if plant_id < 0:
888 raise ValueError("Plant ID must be non-negative")
889 if not filename:
890 raise ValueError("Filename cannot be empty")
891
892 # Resolve path before changing directory
893 absolute_path = _resolve_user_path(filename)
894
895 try:
897 plantarch_wrapper.writeQSMCylinderFile(
898 self._plantarch_ptr, plant_id, absolute_path
899 )
900 except Exception as e:
901 raise PlantArchitectureError(f"Failed to write QSM cylinder file to {filename}: {e}")
902
903 def readPlantStructureXML(self, filename: Union[str, Path], quiet: bool = False) -> List[int]:
904 """
905 Load plant structure from XML file.
906
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.
910
911 Args:
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)
914
915 Returns:
916 List of plant IDs for the loaded plant instances
917
918 Raises:
919 ValueError: If filename is empty
920 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
921
922 Note:
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.
926
927 Example:
928 >>> # Load previously saved plants
929 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
930 >>> print(f"Loaded {len(plant_ids)} plants")
931 >>>
932 >>> # Continue growing the loaded plants
933 >>> plantarch.advanceTime(10.0)
934 >>>
935 >>> # Load quietly without console messages
936 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
937 """
938 if not filename:
939 raise ValueError("Filename cannot be empty")
940
941 # Resolve path before changing directory
942 absolute_path = _resolve_user_path(filename)
943
944 try:
946 return plantarch_wrapper.readPlantStructureXML(
947 self._plantarch_ptr, absolute_path, quiet
948 )
949 except Exception as e:
950 raise PlantArchitectureError(f"Failed to read plant structure XML from {filename}: {e}")
951
952 # Custom plant building methods
953 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
954 """
955 Create an empty plant instance for custom plant building.
956
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.
961
962 Args:
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)
965
966 Returns:
967 Plant ID for the created plant instance
968
969 Raises:
970 ValueError: If age is negative
971 PlantArchitectureError: If plant creation fails
972
973 Example:
974 >>> # Create empty plant at origin
975 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
976 >>>
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"
980 ... )
981 """
982 # Convert position to list for C++ interface
983 position_list = [base_position.x, base_position.y, base_position.z]
984
985 # Validate age
986 if current_age < 0:
987 raise ValueError(f"Age must be non-negative, got {current_age}")
988
989 try:
991 return plantarch_wrapper.addPlantInstance(
992 self._plantarch_ptr, position_list, current_age
993 )
994 except Exception as e:
995 raise PlantArchitectureError(f"Failed to add plant instance: {e}")
996
997 def deletePlantInstance(self, plant_id: int) -> None:
998 """
999 Delete a plant instance and all associated geometry.
1000
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.
1004
1005 Args:
1006 plant_id: ID of the plant instance to delete
1007
1008 Raises:
1009 ValueError: If plant_id is negative
1010 PlantArchitectureError: If plant deletion fails or plant doesn't exist
1011
1012 Example:
1013 >>> # Delete a plant
1014 >>> plantarch.deletePlantInstance(plant_id)
1015 >>>
1016 >>> # Delete multiple plants
1017 >>> for pid in plant_ids_to_remove:
1018 ... plantarch.deletePlantInstance(pid)
1019 """
1020 if plant_id < 0:
1021 raise ValueError("Plant ID must be non-negative")
1022
1023 try:
1025 plantarch_wrapper.deletePlantInstance(self._plantarch_ptr, plant_id)
1026 except Exception as e:
1027 raise PlantArchitectureError(f"Failed to delete plant instance {plant_id}: {e}")
1028
1029 def addBaseStemShoot(self,
1030 plant_id: int,
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:
1039 """
1040 Add a base stem shoot to a plant instance (main trunk/stem).
1041
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.
1045
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.
1050
1051 Args:
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
1061
1062 Returns:
1063 Shoot ID for the created shoot
1064
1065 Raises:
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
1068
1069 Example:
1070 >>> from pyhelios import AxisRotation
1071 >>>
1072 >>> # REQUIRED: Load a plant model to define shoot types
1073 >>> plantarch.loadPlantModelFromLibrary("bean")
1074 >>>
1075 >>> # Create empty plant for custom building
1076 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1077 >>>
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
1089 ... )
1090 """
1091 if plant_id < 0:
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")
1101
1102 # Convert rotation to list for C++ interface
1103 rotation_list = base_rotation.to_list()
1104
1105 try:
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()
1112 )
1113 except Exception as e:
1114 error_msg = str(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}"
1121 )
1122 raise PlantArchitectureError(f"Failed to add base stem shoot: {e}")
1123
1124 def appendShoot(self,
1125 plant_id: int,
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:
1135 """
1136 Append a shoot to the end of an existing shoot.
1137
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.
1141
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.
1145
1146 Args:
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
1157
1158 Returns:
1159 Shoot ID for the appended shoot
1160
1161 Raises:
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
1164
1165 Example:
1166 >>> # Load model to define shoot types
1167 >>> plantarch.loadPlantModelFromLibrary("bean")
1168 >>>
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"
1181 ... )
1182 """
1183 if plant_id < 0:
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")
1195
1196 # Convert rotation to list for C++ interface
1197 rotation_list = base_rotation.to_list()
1198
1199 try:
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()
1206 )
1207 except Exception as e:
1208 error_msg = str(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}"
1215 )
1216 raise PlantArchitectureError(f"Failed to append shoot: {e}")
1217
1218 def addChildShoot(self,
1219 plant_id: int,
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:
1231 """
1232 Add a child shoot at an axillary bud position on a parent shoot.
1233
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).
1238
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.
1242
1243 Args:
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)
1256
1257 Returns:
1258 Shoot ID for the created child shoot
1259
1260 Raises:
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
1263
1264 Example:
1265 >>> # Load model to define shoot types
1266 >>> plantarch.loadPlantModelFromLibrary("bean")
1267 >>>
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"
1281 ... )
1282 >>>
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
1287 ... )
1288 """
1289 if plant_id < 0:
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}")
1305
1306 # Convert rotation to list for C++ interface
1307 rotation_list = shoot_base_rotation.to_list()
1308
1309 try:
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(),
1316 petiole_index
1317 )
1318 except Exception as e:
1319 error_msg = str(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}"
1326 )
1327 raise PlantArchitectureError(f"Failed to add child shoot: {e}")
1328
1329 def is_available(self) -> bool:
1330 """
1331 Check if PlantArchitecture is available in current build.
1332
1333 Returns:
1334 True if plugin is available, False otherwise
1335 """
1337
1338
1339# Convenience function
1340def create_plant_architecture(context: Context) -> PlantArchitecture:
1341 """
1342 Create PlantArchitecture instance with context.
1343
1344 Args:
1345 context: Helios Context
1346
1347 Returns:
1348 PlantArchitecture instance
1349
1350 Example:
1351 >>> context = Context()
1352 >>> plantarch = create_plant_architecture(context)
1353 """
1354 return PlantArchitecture(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).
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...