PyHelios 0.1.11
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 __new__(cls, context=None):
186 """
187 Create PlantArchitecture instance.
188 Explicit __new__ to prevent ctypes contamination on Windows.
189 """
190 return object.__new__(cls)
191
192 def __init__(self, context: Context):
193 """
194 Initialize PlantArchitecture with a Helios context.
195
196 Args:
197 context: Active Helios Context instance
198
199 Raises:
200 PlantArchitectureError: If plugin not available in current build
201 RuntimeError: If plugin initialization fails
202 """
203 # Check plugin availability
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"
210 "\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"
215 "\n"
216 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
217 "rice, soybean, tomato, wheat, and many others."
218 )
219
220 self.context = context
221 self._plantarch_ptr = None
222
223 # Create PlantArchitecture instance with asset-aware working directory
225 self._plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
226
227 if not self._plantarch_ptr:
228 raise PlantArchitectureError("Failed to initialize PlantArchitecture")
229
230 def __enter__(self):
231 """Context manager entry"""
232 return self
233
234 def __exit__(self, exc_type, exc_val, exc_tb):
235 """Context manager exit - cleanup resources"""
236 if hasattr(self, '_plantarch_ptr') and self._plantarch_ptr:
237 plantarch_wrapper.destroyPlantArchitecture(self._plantarch_ptr)
238 self._plantarch_ptr = None
240 def __del__(self):
241 """Destructor to ensure C++ resources freed even without 'with' statement."""
242 if hasattr(self, '_plantarch_ptr') and self._plantarch_ptr is not None:
243 try:
244 plantarch_wrapper.destroyPlantArchitecture(self._plantarch_ptr)
245 self._plantarch_ptr = None
246 except Exception as e:
247 import warnings
248 warnings.warn(f"Error in PlantArchitecture.__del__: {e}")
249
250 def loadPlantModelFromLibrary(self, plant_label: str) -> None:
251 """
252 Load a plant model from the built-in library.
253
254 Args:
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"
260
261 Raises:
262 ValueError: If plant_label is empty or invalid
263 PlantArchitectureError: If model loading fails
264
265 Example:
266 >>> plantarch.loadPlantModelFromLibrary("bean")
267 >>> plantarch.loadPlantModelFromLibrary("almond")
268 """
269 if not plant_label:
270 raise ValueError("Plant label cannot be empty")
271
272 if not plant_label.strip():
273 raise ValueError("Plant label cannot be only whitespace")
274
275 try:
277 plantarch_wrapper.loadPlantModelFromLibrary(self._plantarch_ptr, plant_label.strip())
278 except Exception as e:
279 raise PlantArchitectureError(f"Failed to load plant model '{plant_label}': {e}")
280
281 def buildPlantInstanceFromLibrary(self, base_position: vec3, age: float) -> int:
282 """
283 Build a plant instance from the currently loaded library model.
284
285 Args:
286 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
287 age: Age of the plant in days (must be >= 0)
288
289 Returns:
290 Plant ID for the created plant instance
291
292 Raises:
293 ValueError: If age is negative
294 PlantArchitectureError: If plant building fails
295 RuntimeError: If no model has been loaded
296
297 Example:
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)
300 """
301 # Convert position to list for C++ interface
302 position_list = [base_position.x, base_position.y, base_position.z]
303
304 # Validate age (allow zero)
305 if age < 0:
306 raise ValueError(f"Age must be non-negative, got {age}")
307
308 try:
310 return plantarch_wrapper.buildPlantInstanceFromLibrary(
311 self._plantarch_ptr, position_list, age
312 )
313 except Exception as e:
314 raise PlantArchitectureError(f"Failed to build plant instance: {e}")
315
316 def buildPlantCanopyFromLibrary(self, canopy_center: vec3,
317 plant_spacing: vec2,
318 plant_count: int2, age: float) -> List[int]:
319 """
320 Build a canopy of regularly spaced plants from the currently loaded library model.
321
322 Args:
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)
327
328 Returns:
329 List of plant IDs for the created plant instances
330
331 Raises:
332 ValueError: If age is negative or plant count values are not positive
333 PlantArchitectureError: If canopy building fails
334
335 Example:
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),
341 ... age=30.0
342 ... )
343 """
344 # Validate age (allow zero)
345 if age < 0:
346 raise ValueError(f"Age must be non-negative, got {age}")
347
348 # Validate count values
349 if plant_count.x <= 0 or plant_count.y <= 0:
350 raise ValueError("Plant count values must be positive integers")
351
352 # Convert to lists for C++ interface
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]
356
357 try:
359 return plantarch_wrapper.buildPlantCanopyFromLibrary(
360 self._plantarch_ptr, center_list, spacing_list, count_list, age
361 )
362 except Exception as e:
363 raise PlantArchitectureError(f"Failed to build plant canopy: {e}")
364
365 def advanceTime(self, dt: float) -> None:
366 """
367 Advance time for plant growth and development.
368
369 This method updates all plants in the simulation, potentially adding new phytomers,
370 growing existing organs, transitioning phenological stages, and updating plant geometry.
371
372 Args:
373 dt: Time step to advance in days (must be >= 0)
374
375 Raises:
376 ValueError: If dt is negative
377 PlantArchitectureError: If time advancement fails
378
379 Note:
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
382 in a single call.
383
384 Example:
385 >>> plantarch.advanceTime(10.0) # Advance 10 days
386 >>> plantarch.advanceTime(0.5) # Advance 12 hours
387 """
388 # Validate time step (allow zero)
389 if dt < 0:
390 raise ValueError(f"Time step must be non-negative, got {dt}")
392 try:
394 plantarch_wrapper.advanceTime(self._plantarch_ptr, dt)
395 except Exception as e:
396 raise PlantArchitectureError(f"Failed to advance time by {dt} days: {e}")
397
398 def getAvailablePlantModels(self) -> List[str]:
399 """
400 Get list of all available plant models in the library.
401
402 Returns:
403 List of plant model names available for loading
404
405 Raises:
406 PlantArchitectureError: If retrieval fails
407
408 Example:
409 >>> models = plantarch.getAvailablePlantModels()
410 >>> print(f"Available models: {', '.join(models)}")
411 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
412 """
413 try:
415 return plantarch_wrapper.getAvailablePlantModels(self._plantarch_ptr)
416 except Exception as e:
417 raise PlantArchitectureError(f"Failed to get available plant models: {e}")
418
419 def getAllPlantObjectIDs(self, plant_id: int) -> List[int]:
420 """
421 Get all object IDs for a specific plant.
422
423 Args:
424 plant_id: ID of the plant instance
425
426 Returns:
427 List of object IDs comprising the plant
428
429 Raises:
430 ValueError: If plant_id is negative
431 PlantArchitectureError: If retrieval fails
432
433 Example:
434 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
435 >>> print(f"Plant has {len(object_ids)} objects")
436 """
437 if plant_id < 0:
438 raise ValueError("Plant ID must be non-negative")
439
440 try:
441 return plantarch_wrapper.getAllPlantObjectIDs(self._plantarch_ptr, plant_id)
442 except Exception as e:
443 raise PlantArchitectureError(f"Failed to get object IDs for plant {plant_id}: {e}")
444
445 def getAllPlantUUIDs(self, plant_id: int) -> List[int]:
446 """
447 Get all primitive UUIDs for a specific plant.
448
449 Args:
450 plant_id: ID of the plant instance
451
452 Returns:
453 List of primitive UUIDs comprising the plant
454
455 Raises:
456 ValueError: If plant_id is negative
457 PlantArchitectureError: If retrieval fails
458
459 Example:
460 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
461 >>> print(f"Plant has {len(uuids)} primitives")
462 """
463 if plant_id < 0:
464 raise ValueError("Plant ID must be non-negative")
465
466 try:
467 return plantarch_wrapper.getAllPlantUUIDs(self._plantarch_ptr, plant_id)
468 except Exception as e:
469 raise PlantArchitectureError(f"Failed to get UUIDs for plant {plant_id}: {e}")
470
471 # Collision detection methods
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:
477 """
478 Enable soft collision avoidance for procedural plant growth.
479
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.
483
484 Args:
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
490
491 Raises:
492 PlantArchitectureError: If collision detection activation fails
493
494 Note:
495 Collision detection adds computational overhead. Use setStaticObstacles() to mark
496 static geometry for BVH optimization and improved performance.
497
498 Example:
499 >>> # Avoid all geometry
500 >>> plantarch.enableSoftCollisionAvoidance()
501 >>>
502 >>> # Avoid specific obstacles
503 >>> obstacle_uuids = context.getAllUUIDs()
504 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
505 >>>
506 >>> # Enable collision detection for petioles and fruit
507 >>> plantarch.enableSoftCollisionAvoidance(
508 ... enable_petiole_collision=True,
509 ... enable_fruit_collision=True
510 ... )
511 """
512 try:
514 plantarch_wrapper.enableSoftCollisionAvoidance(
515 self._plantarch_ptr,
516 target_UUIDs=target_object_UUIDs,
517 target_IDs=target_object_IDs,
518 enable_petiole=enable_petiole_collision,
519 enable_fruit=enable_fruit_collision
520 )
521 except Exception as e:
522 raise PlantArchitectureError(f"Failed to enable soft collision avoidance: {e}")
523
524 def disableCollisionDetection(self) -> None:
525 """
526 Disable collision detection for plant growth.
527
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.
531
532 Raises:
533 PlantArchitectureError: If disabling fails
534
535 Example:
536 >>> plantarch.disableCollisionDetection()
537 """
538 try:
539 plantarch_wrapper.disableCollisionDetection(self._plantarch_ptr)
540 except Exception as e:
541 raise PlantArchitectureError(f"Failed to disable collision detection: {e}")
542
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:
548 """
549 Configure parameters for soft collision avoidance algorithm.
550
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.
554
555 Args:
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.
564
565 Raises:
566 ValueError: If parameters are outside valid ranges
567 PlantArchitectureError: If parameter setting fails
568
569 Example:
570 >>> # Use default parameters (recommended)
571 >>> plantarch.setSoftCollisionAvoidanceParameters()
572 >>>
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
579 ... )
580 """
581 # Validate parameters
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}")
590
591 try:
592 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
593 self._plantarch_ptr,
594 view_half_angle_deg,
595 look_ahead_distance,
596 sample_count,
597 inertia_weight
598 )
599 except Exception as e:
600 raise PlantArchitectureError(f"Failed to set collision avoidance parameters: {e}")
601
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:
608 """
609 Specify which plant organs participate in collision detection.
610
611 This method allows filtering which organs are considered during collision detection,
612 enabling optimization by excluding organs unlikely to cause problematic collisions.
613
614 Args:
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
620
621 Raises:
622 PlantArchitectureError: If organ filtering fails
623
624 Example:
625 >>> # Only detect collisions for stems and leaves (default behavior)
626 >>> plantarch.setCollisionRelevantOrgans(
627 ... include_internodes=True,
628 ... include_leaves=True
629 ... )
630 >>>
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
638 ... )
639 """
640 try:
641 plantarch_wrapper.setCollisionRelevantOrgans(
642 self._plantarch_ptr,
643 include_internodes,
644 include_leaves,
645 include_petioles,
646 include_flowers,
647 include_fruit
648 )
649 except Exception as e:
650 raise PlantArchitectureError(f"Failed to set collision-relevant organs: {e}")
651
653 obstacle_UUIDs: List[int],
654 avoidance_distance: float = 0.5,
655 enable_fruit_adjustment: bool = False,
656 enable_obstacle_pruning: bool = False) -> None:
657 """
658 Enable hard obstacle avoidance for specified geometry.
659
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.
663
664 Args:
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
670
671 Raises:
672 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
673 PlantArchitectureError: If solid obstacle configuration fails
674
675 Example:
676 >>> # Simple solid obstacle avoidance
677 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
678 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
679 >>>
680 >>> # Close avoidance with fruit adjustment
681 >>> plantarch.enableSolidObstacleAvoidance(
682 ... obstacle_UUIDs=wall_uuids,
683 ... avoidance_distance=0.1,
684 ... enable_fruit_adjustment=True
685 ... )
686 """
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}")
691
692 try:
694 plantarch_wrapper.enableSolidObstacleAvoidance(
695 self._plantarch_ptr,
696 obstacle_UUIDs,
697 avoidance_distance,
698 enable_fruit_adjustment,
699 enable_obstacle_pruning
700 )
701 except Exception as e:
702 raise PlantArchitectureError(f"Failed to enable solid obstacle avoidance: {e}")
703
704 def setStaticObstacles(self, target_UUIDs: List[int]) -> None:
705 """
706 Mark geometry as static obstacles for collision detection optimization.
707
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.
712
713 Args:
714 target_UUIDs: List of primitive UUIDs representing static obstacles
715
716 Raises:
717 ValueError: If target_UUIDs is empty
718 PlantArchitectureError: If static obstacle configuration fails
719
720 Note:
721 Call this method BEFORE enabling collision avoidance for best performance.
722 Static obstacles cannot be modified or moved after being marked static.
723
724 Example:
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()
730 """
731 if not target_UUIDs:
732 raise ValueError("target_UUIDs list cannot be empty")
733
734 try:
736 plantarch_wrapper.setStaticObstacles(self._plantarch_ptr, target_UUIDs)
737 except Exception as e:
738 raise PlantArchitectureError(f"Failed to set static obstacles: {e}")
739
740 def getPlantCollisionRelevantObjectIDs(self, plant_id: int) -> List[int]:
741 """
742 Get object IDs of collision-relevant geometry for a specific plant.
743
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.
747
748 Args:
749 plant_id: ID of the plant instance
750
751 Returns:
752 List of object IDs for collision-relevant plant geometry
753
754 Raises:
755 ValueError: If plant_id is negative
756 PlantArchitectureError: If retrieval fails
757
758 Example:
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")
762 >>>
763 >>> # Highlight collision geometry in visualization
764 >>> for obj_id in collision_obj_ids:
765 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
766 """
767 if plant_id < 0:
768 raise ValueError("Plant ID must be non-negative")
769
770 try:
771 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self._plantarch_ptr, plant_id)
772 except Exception as e:
773 raise PlantArchitectureError(f"Failed to get collision-relevant object IDs for plant {plant_id}: {e}")
774
775 # File I/O methods
776 def writePlantMeshVertices(self, plant_id: int, filename: Union[str, Path]) -> None:
777 """
778 Write all plant mesh vertices to file for external processing.
779
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.
783
784 Args:
785 plant_id: ID of the plant instance to export
786 filename: Path to output file (absolute or relative to current working directory)
787
788 Raises:
789 ValueError: If plant_id is negative or filename is empty
790 PlantArchitectureError: If plant doesn't exist or file cannot be written
791
792 Example:
793 >>> # Export vertices for convex hull analysis
794 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
795 >>>
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")
801 """
802 if plant_id < 0:
803 raise ValueError("Plant ID must be non-negative")
804 if not filename:
805 raise ValueError("Filename cannot be empty")
806
807 # Resolve path before changing directory
808 absolute_path = _resolve_user_path(filename)
809
810 try:
812 plantarch_wrapper.writePlantMeshVertices(
813 self._plantarch_ptr, plant_id, absolute_path
814 )
815 except Exception as e:
816 raise PlantArchitectureError(f"Failed to write plant mesh vertices to {filename}: {e}")
817
818 def writePlantStructureXML(self, plant_id: int, filename: Union[str, Path]) -> None:
819 """
820 Save plant structure to XML file for later loading.
821
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().
825
826 Args:
827 plant_id: ID of the plant instance to save
828 filename: Path to output XML file (absolute or relative to current working directory)
829
830 Raises:
831 ValueError: If plant_id is negative or filename is empty
832 PlantArchitectureError: If plant doesn't exist or file cannot be written
833
834 Note:
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
840
841 Example:
842 >>> # Save plant at current growth stage
843 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
844 >>>
845 >>> # Later, reload the saved plant
846 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
847 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
848 """
849 if plant_id < 0:
850 raise ValueError("Plant ID must be non-negative")
851 if not filename:
852 raise ValueError("Filename cannot be empty")
853
854 # Resolve path before changing directory
855 absolute_path = _resolve_user_path(filename)
856
857 try:
859 plantarch_wrapper.writePlantStructureXML(
860 self._plantarch_ptr, plant_id, absolute_path
861 )
862 except Exception as e:
863 raise PlantArchitectureError(f"Failed to write plant structure XML to {filename}: {e}")
864
865 def writeQSMCylinderFile(self, plant_id: int, filename: Union[str, Path]) -> None:
866 """
867 Export plant structure in TreeQSM cylinder format.
868
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.
874
875 Args:
876 plant_id: ID of the plant instance to export
877 filename: Path to output file (absolute or relative, typically .txt extension)
878
879 Raises:
880 ValueError: If plant_id is negative or filename is empty
881 PlantArchitectureError: If plant doesn't exist or file cannot be written
882
883 Note:
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)
890
891 Example:
892 >>> # Export for biomechanical analysis
893 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
894 >>>
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")
899
900 References:
901 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
902 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
903 """
904 if plant_id < 0:
905 raise ValueError("Plant ID must be non-negative")
906 if not filename:
907 raise ValueError("Filename cannot be empty")
908
909 # Resolve path before changing directory
910 absolute_path = _resolve_user_path(filename)
911
912 try:
914 plantarch_wrapper.writeQSMCylinderFile(
915 self._plantarch_ptr, plant_id, absolute_path
916 )
917 except Exception as e:
918 raise PlantArchitectureError(f"Failed to write QSM cylinder file to {filename}: {e}")
919
920 def readPlantStructureXML(self, filename: Union[str, Path], quiet: bool = False) -> List[int]:
921 """
922 Load plant structure from XML file.
923
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.
927
928 Args:
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)
931
932 Returns:
933 List of plant IDs for the loaded plant instances
934
935 Raises:
936 ValueError: If filename is empty
937 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
938
939 Note:
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.
943
944 Example:
945 >>> # Load previously saved plants
946 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
947 >>> print(f"Loaded {len(plant_ids)} plants")
948 >>>
949 >>> # Continue growing the loaded plants
950 >>> plantarch.advanceTime(10.0)
951 >>>
952 >>> # Load quietly without console messages
953 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
954 """
955 if not filename:
956 raise ValueError("Filename cannot be empty")
957
958 # Resolve path before changing directory
959 absolute_path = _resolve_user_path(filename)
960
961 try:
963 return plantarch_wrapper.readPlantStructureXML(
964 self._plantarch_ptr, absolute_path, quiet
965 )
966 except Exception as e:
967 raise PlantArchitectureError(f"Failed to read plant structure XML from {filename}: {e}")
968
969 # Custom plant building methods
970 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
971 """
972 Create an empty plant instance for custom plant building.
973
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.
978
979 Args:
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)
982
983 Returns:
984 Plant ID for the created plant instance
985
986 Raises:
987 ValueError: If age is negative
988 PlantArchitectureError: If plant creation fails
989
990 Example:
991 >>> # Create empty plant at origin
992 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
993 >>>
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"
997 ... )
998 """
999 # Convert position to list for C++ interface
1000 position_list = [base_position.x, base_position.y, base_position.z]
1001
1002 # Validate age
1003 if current_age < 0:
1004 raise ValueError(f"Age must be non-negative, got {current_age}")
1005
1006 try:
1008 return plantarch_wrapper.addPlantInstance(
1009 self._plantarch_ptr, position_list, current_age
1010 )
1011 except Exception as e:
1012 raise PlantArchitectureError(f"Failed to add plant instance: {e}")
1013
1014 def deletePlantInstance(self, plant_id: int) -> None:
1015 """
1016 Delete a plant instance and all associated geometry.
1017
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.
1021
1022 Args:
1023 plant_id: ID of the plant instance to delete
1024
1025 Raises:
1026 ValueError: If plant_id is negative
1027 PlantArchitectureError: If plant deletion fails or plant doesn't exist
1028
1029 Example:
1030 >>> # Delete a plant
1031 >>> plantarch.deletePlantInstance(plant_id)
1032 >>>
1033 >>> # Delete multiple plants
1034 >>> for pid in plant_ids_to_remove:
1035 ... plantarch.deletePlantInstance(pid)
1036 """
1037 if plant_id < 0:
1038 raise ValueError("Plant ID must be non-negative")
1039
1040 try:
1042 plantarch_wrapper.deletePlantInstance(self._plantarch_ptr, plant_id)
1043 except Exception as e:
1044 raise PlantArchitectureError(f"Failed to delete plant instance {plant_id}: {e}")
1045
1046 def addBaseStemShoot(self,
1047 plant_id: int,
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:
1056 """
1057 Add a base stem shoot to a plant instance (main trunk/stem).
1058
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.
1062
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.
1067
1068 Args:
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
1078
1079 Returns:
1080 Shoot ID for the created shoot
1081
1082 Raises:
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
1085
1086 Example:
1087 >>> from pyhelios import AxisRotation
1088 >>>
1089 >>> # REQUIRED: Load a plant model to define shoot types
1090 >>> plantarch.loadPlantModelFromLibrary("bean")
1091 >>>
1092 >>> # Create empty plant for custom building
1093 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1094 >>>
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
1106 ... )
1107 """
1108 if plant_id < 0:
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")
1118
1119 # Convert rotation to list for C++ interface
1120 rotation_list = base_rotation.to_list()
1121
1122 try:
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()
1129 )
1130 except Exception as e:
1131 error_msg = str(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}"
1138 )
1139 raise PlantArchitectureError(f"Failed to add base stem shoot: {e}")
1140
1141 def appendShoot(self,
1142 plant_id: int,
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:
1152 """
1153 Append a shoot to the end of an existing shoot.
1154
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.
1158
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.
1162
1163 Args:
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
1174
1175 Returns:
1176 Shoot ID for the appended shoot
1177
1178 Raises:
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
1181
1182 Example:
1183 >>> # Load model to define shoot types
1184 >>> plantarch.loadPlantModelFromLibrary("bean")
1185 >>>
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"
1198 ... )
1199 """
1200 if plant_id < 0:
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")
1212
1213 # Convert rotation to list for C++ interface
1214 rotation_list = base_rotation.to_list()
1215
1216 try:
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()
1223 )
1224 except Exception as e:
1225 error_msg = str(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}"
1232 )
1233 raise PlantArchitectureError(f"Failed to append shoot: {e}")
1234
1235 def addChildShoot(self,
1236 plant_id: int,
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:
1248 """
1249 Add a child shoot at an axillary bud position on a parent shoot.
1250
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).
1255
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.
1259
1260 Args:
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)
1273
1274 Returns:
1275 Shoot ID for the created child shoot
1276
1277 Raises:
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
1280
1281 Example:
1282 >>> # Load model to define shoot types
1283 >>> plantarch.loadPlantModelFromLibrary("bean")
1284 >>>
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"
1298 ... )
1299 >>>
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
1304 ... )
1305 """
1306 if plant_id < 0:
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}")
1322
1323 # Convert rotation to list for C++ interface
1324 rotation_list = shoot_base_rotation.to_list()
1325
1326 try:
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(),
1333 petiole_index
1334 )
1335 except Exception as e:
1336 error_msg = str(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}"
1343 )
1344 raise PlantArchitectureError(f"Failed to add child shoot: {e}")
1345
1346 def is_available(self) -> bool:
1347 """
1348 Check if PlantArchitecture is available in current build.
1349
1350 Returns:
1351 True if plugin is available, False otherwise
1352 """
1354
1355
1356# Convenience function
1357def create_plant_architecture(context: Context) -> PlantArchitecture:
1358 """
1359 Create PlantArchitecture instance with context.
1360
1361 Args:
1362 context: Helios Context
1363
1364 Returns:
1365 PlantArchitecture instance
1366
1367 Example:
1368 >>> context = Context()
1369 >>> plantarch = create_plant_architecture(context)
1370 """
1371 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.
__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...