0.1.19
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, Dict, Any
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
51class RandomParameter:
52 """
53 Helper class for creating RandomParameter specifications for float parameters.
54
55 Provides convenient static methods to create parameter dictionaries with
56 statistical distributions for plant architecture modeling.
57 """
58
59 @staticmethod
60 def constant(value: float) -> Dict[str, Any]:
61 """
62 Create a constant (non-random) parameter.
63
64 Args:
65 value: The constant value
66
67 Returns:
68 Dict with constant distribution specification
69
70 Example:
71 >>> param = RandomParameter.constant(45.0)
72 >>> # Returns: {'distribution': 'constant', 'parameters': [45.0]}
73 """
74 return {'distribution': 'constant', 'parameters': [float(value)]}
75
76 @staticmethod
77 def uniform(min_val: float, max_val: float) -> Dict[str, Any]:
78 """
79 Create a uniform distribution parameter.
80
81 Args:
82 min_val: Minimum value
83 max_val: Maximum value
84
85 Returns:
86 Dict with uniform distribution specification
87
88 Raises:
89 ValueError: If min_val > max_val
90
91 Example:
92 >>> param = RandomParameter.uniform(40.0, 50.0)
93 >>> # Returns: {'distribution': 'uniform', 'parameters': [40.0, 50.0]}
94 """
95 if min_val > max_val:
96 raise ValueError(f"min_val ({min_val}) must be <= max_val ({max_val})")
97 return {'distribution': 'uniform', 'parameters': [float(min_val), float(max_val)]}
98
99 @staticmethod
100 def normal(mean: float, std_dev: float) -> Dict[str, Any]:
101 """
102 Create a normal (Gaussian) distribution parameter.
103
104 Args:
105 mean: Mean value
106 std_dev: Standard deviation
107
108 Returns:
109 Dict with normal distribution specification
110
111 Raises:
112 ValueError: If std_dev < 0
113
114 Example:
115 >>> param = RandomParameter.normal(45.0, 5.0)
116 >>> # Returns: {'distribution': 'normal', 'parameters': [45.0, 5.0]}
117 """
118 if std_dev < 0:
119 raise ValueError(f"std_dev ({std_dev}) must be >= 0")
120 return {'distribution': 'normal', 'parameters': [float(mean), float(std_dev)]}
121
122 @staticmethod
123 def weibull(shape: float, scale: float) -> Dict[str, Any]:
124 """
125 Create a Weibull distribution parameter.
126
127 Args:
128 shape: Shape parameter (k)
129 scale: Scale parameter (λ)
130
131 Returns:
132 Dict with Weibull distribution specification
133
134 Raises:
135 ValueError: If shape or scale <= 0
136
137 Example:
138 >>> param = RandomParameter.weibull(2.0, 50.0)
139 >>> # Returns: {'distribution': 'weibull', 'parameters': [2.0, 50.0]}
140 """
141 if shape <= 0:
142 raise ValueError(f"shape ({shape}) must be > 0")
143 if scale <= 0:
144 raise ValueError(f"scale ({scale}) must be > 0")
145 return {'distribution': 'weibull', 'parameters': [float(shape), float(scale)]}
146
147
149 """
150 Helper class for creating RandomParameter specifications for integer parameters.
151
152 Provides convenient static methods to create parameter dictionaries with
153 statistical distributions for integer-valued plant parameters.
154 """
155
156 @staticmethod
157 def constant(value: int) -> Dict[str, Any]:
158 """
159 Create a constant (non-random) integer parameter.
160
161 Args:
162 value: The constant integer value
163
164 Returns:
165 Dict with constant distribution specification
166
167 Example:
168 >>> param = RandomParameterInt.constant(15)
169 >>> # Returns: {'distribution': 'constant', 'parameters': [15.0]}
170 """
171 return {'distribution': 'constant', 'parameters': [float(value)]}
172
173 @staticmethod
174 def uniform(min_val: int, max_val: int) -> Dict[str, Any]:
175 """
176 Create a uniform distribution for integer parameter.
177
178 Args:
179 min_val: Minimum value (inclusive)
180 max_val: Maximum value (inclusive)
181
182 Returns:
183 Dict with uniform distribution specification
184
185 Raises:
186 ValueError: If min_val > max_val
187
188 Example:
189 >>> param = RandomParameterInt.uniform(10, 20)
190 >>> # Returns: {'distribution': 'uniform', 'parameters': [10.0, 20.0]}
191 """
192 if min_val > max_val:
193 raise ValueError(f"min_val ({min_val}) must be <= max_val ({max_val})")
194 return {'distribution': 'uniform', 'parameters': [float(min_val), float(max_val)]}
195
196 @staticmethod
197 def discrete(values: List[int]) -> Dict[str, Any]:
198 """
199 Create a discrete value distribution (random choice from list).
200
201 Args:
202 values: List of possible integer values (equal probability)
203
204 Returns:
205 Dict with discrete distribution specification
206
207 Raises:
208 ValueError: If values list is empty
209
210 Example:
211 >>> param = RandomParameterInt.discrete([1, 2, 3, 5])
212 >>> # Returns: {'distribution': 'discretevalues', 'parameters': [1.0, 2.0, 3.0, 5.0]}
213 """
214 if not values:
215 raise ValueError("values list cannot be empty")
216 return {'distribution': 'discretevalues', 'parameters': [float(v) for v in values]}
217
218
219def _resolve_user_path(filepath: Union[str, Path]) -> str:
220 """
221 Convert relative paths to absolute paths before changing working directory.
222
223 This preserves the user's intended file location when the working directory
224 is temporarily changed for C++ asset access. Absolute paths are returned unchanged.
225
226 Args:
227 filepath: File path to resolve (string or Path object)
228
229 Returns:
230 Absolute path as string
231 """
232 path = Path(filepath)
233 if not path.is_absolute():
234 return str(Path.cwd() / path)
235 return str(path)
236
237
238@contextmanager
240 """
241 Context manager that temporarily changes working directory to where PlantArchitecture assets are located.
242
243 PlantArchitecture C++ code uses hardcoded relative paths like "plugins/plantarchitecture/assets/textures/"
244 expecting assets relative to working directory. This manager temporarily changes to the build directory
245 where assets are actually located.
246
247 Raises:
248 RuntimeError: If build directory or PlantArchitecture assets are not found, indicating a build system error.
249 """
250 # Find the build directory containing PlantArchitecture assets
251 # Try asset manager first (works for both development and wheel installations)
252 asset_manager = get_asset_manager()
253 working_dir = asset_manager._get_helios_build_path()
254
255 if working_dir and working_dir.exists():
256 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
257 else:
258 # For wheel installations, check packaged assets
259 current_dir = Path(__file__).parent
260 packaged_build = current_dir / 'assets' / 'build'
261
262 if packaged_build.exists():
263 working_dir = packaged_build
264 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
265 else:
266 # Fallback to development paths
267 repo_root = current_dir.parent
268 build_lib_dir = repo_root / 'pyhelios_build' / 'build' / 'lib'
269 working_dir = build_lib_dir.parent
270 plantarch_assets = working_dir / 'plugins' / 'plantarchitecture'
271
272 if not build_lib_dir.exists():
273 raise RuntimeError(
274 f"PyHelios build directory not found at {build_lib_dir}. "
275 f"PlantArchitecture requires native libraries to be built. "
276 f"Run: build_scripts/build_helios --plugins plantarchitecture"
277 )
278
279 if not plantarch_assets.exists():
280 raise RuntimeError(
281 f"PlantArchitecture assets not found at {plantarch_assets}. "
282 f"Build system failed to copy PlantArchitecture assets. "
283 f"Run: build_scripts/build_helios --clean --plugins plantarchitecture"
284 )
285
286 # Verify essential assets exist
287 assets_dir = plantarch_assets / 'assets'
288 if not assets_dir.exists():
289 raise RuntimeError(
290 f"PlantArchitecture assets directory not found: {assets_dir}. "
291 f"Essential assets missing. Rebuild with: "
292 f"build_scripts/build_helios --clean --plugins plantarchitecture"
293 )
294
295 # Change to the build directory temporarily
296 original_dir = os.getcwd()
297 try:
298 os.chdir(working_dir)
299 logger.debug(f"Changed working directory to {working_dir} for PlantArchitecture asset access")
300 yield working_dir
301 finally:
302 os.chdir(original_dir)
303 logger.debug(f"Restored working directory to {original_dir}")
304
305
306class PlantArchitectureError(Exception):
307 """Raised when PlantArchitecture operations fail."""
308 pass
309
310
312 """
313 Check if PlantArchitecture plugin is available for use.
314
315 Returns:
316 bool: True if PlantArchitecture can be used, False otherwise
317 """
318 try:
319 # Check plugin registry
320 plugin_registry = get_plugin_registry()
321 if not plugin_registry.is_plugin_available('plantarchitecture'):
322 return False
323
324 # Check if wrapper functions are available
325 if not plantarch_wrapper._PLANTARCHITECTURE_FUNCTIONS_AVAILABLE:
326 return False
327
328 return True
329 except Exception:
330 return False
331
332
334 """
335 High-level interface for plant architecture modeling and procedural plant generation.
336
337 PlantArchitecture provides access to the comprehensive plant library with 25+ plant models
338 including trees (almond, apple, olive, walnut), crops (bean, cowpea, maize, rice, soybean),
339 and other plants. This class enables procedural plant generation, time-based growth
340 simulation, and plant community modeling.
341
342 This class requires the native Helios library built with PlantArchitecture support.
343 Use context managers for proper resource cleanup.
344
345 Example:
346 >>> with Context() as context:
347 ... with PlantArchitecture(context) as plantarch:
348 ... plantarch.loadPlantModelFromLibrary("bean")
349 ... plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(0, 0, 0), age=30)
350 ... plantarch.advanceTime(10.0) # Grow for 10 days
351 """
352
353 def __new__(cls, context=None):
354 """
355 Create PlantArchitecture instance.
356 Explicit __new__ to prevent ctypes contamination on Windows.
357 """
358 return object.__new__(cls)
359
360 def __init__(self, context: Context):
361 """
362 Initialize PlantArchitecture with a Helios context.
363
364 Args:
365 context: Active Helios Context instance
366
367 Raises:
368 PlantArchitectureError: If plugin not available in current build
369 RuntimeError: If plugin initialization fails
370 """
371 # Check plugin availability
372 registry = get_plugin_registry()
373 if not registry.is_plugin_available('plantarchitecture'):
375 "PlantArchitecture not available in current Helios library. "
376 "Rebuild PyHelios with PlantArchitecture support:\n"
377 " build_scripts/build_helios --plugins plantarchitecture\n"
378 "\n"
379 "System requirements:\n"
380 f" - Platforms: Windows, Linux, macOS\n"
381 " - Dependencies: Extensive asset library (textures, OBJ models)\n"
382 " - GPU: Not required\n"
383 "\n"
384 "Plant library includes 25+ models: almond, apple, bean, cowpea, maize, "
385 "rice, soybean, tomato, wheat, and many others."
386 )
387
388 self.context = context
389 self._plantarch_ptr = None
390
391 # Create PlantArchitecture instance with asset-aware working directory
393 self._plantarch_ptr = plantarch_wrapper.createPlantArchitecture(context.getNativePtr())
394
395 if not self._plantarch_ptr:
396 raise PlantArchitectureError("Failed to initialize PlantArchitecture")
397
398 def __enter__(self):
399 """Context manager entry"""
400 return self
401
402 def __exit__(self, exc_type, exc_val, exc_tb):
403 """Context manager exit - cleanup resources"""
404 if hasattr(self, '_plantarch_ptr') and self._plantarch_ptr:
405 plantarch_wrapper.destroyPlantArchitecture(self._plantarch_ptr)
406 self._plantarch_ptr = None
408 def __del__(self):
409 """Destructor to ensure C++ resources freed even without 'with' statement."""
410 if hasattr(self, '_plantarch_ptr') and self._plantarch_ptr is not None:
411 try:
412 plantarch_wrapper.destroyPlantArchitecture(self._plantarch_ptr)
413 self._plantarch_ptr = None
414 except Exception as e:
415 import warnings
416 warnings.warn(f"Error in PlantArchitecture.__del__: {e}")
417
418 def loadPlantModelFromLibrary(self, plant_label: str) -> None:
419 """
420 Load a plant model from the built-in library.
421
422 Args:
423 plant_label: Plant model identifier from library. Available models include:
424 "almond", "apple", "bean", "bindweed", "butterlettuce", "capsicum",
425 "cheeseweed", "cowpea", "easternredbud", "grapevine_VSP", "maize",
426 "olive", "pistachio", "puncturevine", "rice", "sorghum", "soybean",
427 "strawberry", "sugarbeet", "tomato", "cherrytomato", "walnut", "wheat"
428
429 Raises:
430 ValueError: If plant_label is empty or invalid
431 PlantArchitectureError: If model loading fails
432
433 Example:
434 >>> plantarch.loadPlantModelFromLibrary("bean")
435 >>> plantarch.loadPlantModelFromLibrary("almond")
436 """
437 if not plant_label:
438 raise ValueError("Plant label cannot be empty")
439
440 if not plant_label.strip():
441 raise ValueError("Plant label cannot be only whitespace")
442
443 try:
445 plantarch_wrapper.loadPlantModelFromLibrary(self._plantarch_ptr, plant_label.strip())
446 except Exception as e:
447 raise PlantArchitectureError(f"Failed to load plant model '{plant_label}': {e}")
448
449 def buildPlantInstanceFromLibrary(self, base_position: vec3, age: float,
450 build_parameters: Optional[dict] = None) -> int:
451 """
452 Build a plant instance from the currently loaded library model.
453
454 Args:
455 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
456 age: Age of the plant in days (must be >= 0)
457 build_parameters: Optional dict of parameter overrides for training system parameters.
458 Examples:
459 - {'trunk_height': 2.5} - for tomato trellis height
460 - {'cordon_height': 1.8, 'cordon_radius': 1.2} - for apple training
461 - {'row_spacing': 0.75} - for grapevine VSP trellis
462
463 Returns:
464 Plant ID for the created plant instance
465
466 Raises:
467 ValueError: If age is negative or build_parameters is invalid
468 PlantArchitectureError: If plant building fails
469 RuntimeError: If no model has been loaded
470
471 Example:
472 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(base_position=vec3(2.0, 3.0, 0.0), age=45.0)
473 >>> # With custom parameters
474 >>> plant_id = plantarch.buildPlantInstanceFromLibrary(
475 ... base_position=vec3(0, 0, 0),
476 ... age=30.0,
477 ... build_parameters={'trunk_height': 2.0}
478 ... )
479 """
480 # Parameter type validation
481 if not isinstance(base_position, vec3):
482 raise ValueError(f"base_position must be a vec3, got {type(base_position).__name__}")
483
484 # Convert position to list for C++ interface
485 position_list = [base_position.x, base_position.y, base_position.z]
486
487 # Validate age (allow zero)
488 if age < 0:
489 raise ValueError(f"Age must be non-negative, got {age}")
490
491 # Validate build_parameters
492 if build_parameters is not None:
493 if not isinstance(build_parameters, dict):
494 raise ValueError("build_parameters must be a dict or None")
495 for key, value in build_parameters.items():
496 if not isinstance(key, str):
497 raise ValueError("build_parameters keys must be strings")
498 if not isinstance(value, (int, float)):
499 raise ValueError("build_parameters values must be numeric (int or float)")
500
501 try:
503 return plantarch_wrapper.buildPlantInstanceFromLibrary(
504 self._plantarch_ptr, position_list, age, build_parameters
505 )
506 except Exception as e:
507 raise PlantArchitectureError(f"Failed to build plant instance: {e}")
508
509 def buildPlantCanopyFromLibrary(self, canopy_center: vec3,
510 plant_spacing: vec2,
511 plant_count: int2, age: float,
512 germination_rate: float = 1.0,
513 build_parameters: Optional[dict] = None) -> List[int]:
514 """
515 Build a canopy of regularly spaced plants from the currently loaded library model.
516
517 Args:
518 canopy_center: Cartesian (x,y,z) coordinates of canopy center as vec3
519 plant_spacing: Spacing between plants in x- and y-directions (meters) as vec2
520 plant_count: Number of plants in x- and y-directions as int2
521 age: Age of all plants in days (must be >= 0)
522 germination_rate: Probability that each plant position will be occupied (0 to 1).
523 A value of 1.0 means all positions are filled; 0.5 means roughly
524 half the positions will have plants. Default is 1.0.
525 build_parameters: Optional dict of parameter overrides for training system parameters.
526 Parameters are applied to all plants in the canopy.
527 Examples:
528 - {'cordon_height': 1.8} - for grapevine trellis height
529 - {'trunk_height': 2.5} - for tomato trellis systems
530
531 Returns:
532 List of plant IDs for the created plant instances
533
534 Raises:
535 ValueError: If age is negative, germination_rate is not in [0, 1],
536 plant count values are not positive, or build_parameters is invalid
537 PlantArchitectureError: If canopy building fails
538
539 Example:
540 >>> # 3x3 canopy with 0.5m spacing, 30-day-old plants
541 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
542 ... canopy_center=vec3(0, 0, 0),
543 ... plant_spacing=vec2(0.5, 0.5),
544 ... plant_count=int2(3, 3),
545 ... age=30.0
546 ... )
547 >>> # With 80% germination rate and custom parameters
548 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
549 ... canopy_center=vec3(0, 0, 0),
550 ... plant_spacing=vec2(1.5, 2.0),
551 ... plant_count=int2(5, 3),
552 ... age=45.0,
553 ... germination_rate=0.8,
554 ... build_parameters={'cordon_height': 1.8}
555 ... )
556 """
557 # Parameter type validation
558 if not isinstance(canopy_center, vec3):
559 raise ValueError(f"canopy_center must be a vec3, got {type(canopy_center).__name__}")
560 if not isinstance(plant_spacing, vec2):
561 raise ValueError(f"plant_spacing must be a vec2, got {type(plant_spacing).__name__}")
562 if not isinstance(plant_count, int2):
563 raise ValueError(f"plant_count must be an int2, got {type(plant_count).__name__}")
564
565 # Validate age (allow zero)
566 if age < 0:
567 raise ValueError(f"Age must be non-negative, got {age}")
568
569 # Validate germination rate
570 if not isinstance(germination_rate, (int, float)):
571 raise ValueError(f"germination_rate must be a number, got {type(germination_rate).__name__}")
572 if germination_rate < 0 or germination_rate > 1:
573 raise ValueError(f"germination_rate must be between 0 and 1, got {germination_rate}")
574
575 # Validate count values
576 if plant_count.x <= 0 or plant_count.y <= 0:
577 raise ValueError("Plant count values must be positive integers")
578
579 # Validate build_parameters
580 if build_parameters is not None:
581 if not isinstance(build_parameters, dict):
582 raise ValueError("build_parameters must be a dict or None")
583 for key, value in build_parameters.items():
584 if not isinstance(key, str):
585 raise ValueError("build_parameters keys must be strings")
586 if not isinstance(value, (int, float)):
587 raise ValueError("build_parameters values must be numeric (int or float)")
588
589 # Convert to lists for C++ interface
590 center_list = [canopy_center.x, canopy_center.y, canopy_center.z]
591 spacing_list = [plant_spacing.x, plant_spacing.y]
592 count_list = [plant_count.x, plant_count.y]
593
594 try:
596 return plantarch_wrapper.buildPlantCanopyFromLibrary(
597 self._plantarch_ptr, center_list, spacing_list, count_list, age,
598 germination_rate, build_parameters
599 )
600 except Exception as e:
601 raise PlantArchitectureError(f"Failed to build plant canopy: {e}")
602
603 def advanceTime(self, dt: float) -> None:
604 """
605 Advance time for plant growth and development.
606
607 This method updates all plants in the simulation, potentially adding new phytomers,
608 growing existing organs, transitioning phenological stages, and updating plant geometry.
609
610 Args:
611 dt: Time step to advance in days (must be >= 0)
612
613 Raises:
614 ValueError: If dt is negative
615 PlantArchitectureError: If time advancement fails
616
617 Note:
618 Large time steps are more efficient than many small steps. The timestep value
619 can be larger than the phyllochron, allowing multiple phytomers to be produced
620 in a single call.
621
622 Example:
623 >>> plantarch.advanceTime(10.0) # Advance 10 days
624 >>> plantarch.advanceTime(0.5) # Advance 12 hours
625 """
626 # Validate time step (allow zero)
627 if dt < 0:
628 raise ValueError(f"Time step must be non-negative, got {dt}")
630 try:
632 plantarch_wrapper.advanceTime(self._plantarch_ptr, dt)
633 except Exception as e:
634 raise PlantArchitectureError(f"Failed to advance time by {dt} days: {e}")
635
636 def setProgressCallback(self, callback):
637 """Set a callback to receive progress updates during long-running operations.
638
639 The callback fires during advanceTime() and adjustFruitForObstacleCollision()
640 as the underlying ProgressBar updates.
641
642 Args:
643 callback: A callable(progress: float, message: str) where progress is
644 in [0, 1], or None to clear the callback.
645
646 Raises:
647 ValueError: If callback is not callable and not None.
648 """
649 if callback is not None:
650 if not callable(callback):
651 raise ValueError(
652 f"callback must be callable or None, got {type(callback).__name__}"
653 )
654
655 def _c_callback(progress, message_bytes):
656 msg = message_bytes.decode('utf-8') if isinstance(message_bytes, bytes) else str(message_bytes)
657 callback(progress, msg)
658
659 self._progress_callback_ref = plantarch_wrapper.PROGRESS_CALLBACK(_c_callback)
660 plantarch_wrapper.setProgressCallback(self._plantarch_ptr, self._progress_callback_ref)
661 else:
662 plantarch_wrapper.setProgressCallback(self._plantarch_ptr, None)
664
665 def getCurrentShootParameters(self, shoot_type_label: str) -> dict:
666 """
667 Get current shoot parameters for a shoot type.
668
669 Returns a nested dictionary containing all shoot and phytomer parameters
670 including RandomParameter specifications with distribution types.
671
672 Args:
673 shoot_type_label: Label for the shoot type (e.g., "stem", "branch")
674
675 Returns:
676 Dictionary with shoot parameters including:
677 - Geometric parameters (max_nodes, insertion_angle_tip, etc.)
678 - Growth parameters (phyllochron_min, elongation_rate_max, etc.)
679 - Boolean flags (flowers_require_dormancy, etc.)
680 - RandomParameter fields include 'distribution' and 'parameters' keys
681
682 Raises:
683 ValueError: If shoot_type_label is empty
684 PlantArchitectureError: If parameter retrieval fails
685
686 Example:
687 >>> plantarch.loadPlantModelFromLibrary("bean")
688 >>> params = plantarch.getCurrentShootParameters("stem")
689 >>> print(params['max_nodes'])
690 {'distribution': 'constant', 'parameters': [15.0]}
691 """
692 if not shoot_type_label:
693 raise ValueError("Shoot type label cannot be empty")
694
695 if not shoot_type_label.strip():
696 raise ValueError("Shoot type label cannot be only whitespace")
697
698 try:
700 return plantarch_wrapper.getCurrentShootParameters(
701 self._plantarch_ptr, shoot_type_label.strip()
702 )
703 except Exception as e:
704 raise PlantArchitectureError(f"Failed to get shoot parameters for '{shoot_type_label}': {e}")
705
706 def defineShootType(self, shoot_type_label: str, parameters: dict) -> None:
707 """
708 Define a custom shoot type with specified parameters.
709
710 Allows creating new shoot types or modifying existing ones by providing
711 a parameter dictionary. Use getCurrentShootParameters() to get a template
712 that can be modified.
713
714 Args:
715 shoot_type_label: Unique name for this shoot type
716 parameters: Dictionary matching ShootParameters structure.
717 Use getCurrentShootParameters() to get proper structure.
718
719 Raises:
720 ValueError: If shoot_type_label is empty or parameters is not a dict
721 PlantArchitectureError: If shoot type definition fails
722
723 Example:
724 >>> # Get existing parameters as template
725 >>> plantarch.loadPlantModelFromLibrary("bean")
726 >>> params = plantarch.getCurrentShootParameters("stem")
727 >>>
728 >>> # Modify parameters
729 >>> params['max_nodes'] = {'distribution': 'constant', 'parameters': [20.0]}
730 >>> params['insertion_angle_tip'] = {'distribution': 'uniform', 'parameters': [40.0, 50.0]}
731 >>>
732 >>> # Define new shoot type
733 >>> plantarch.defineShootType("TallStem", params)
734 """
735 if not shoot_type_label:
736 raise ValueError("Shoot type label cannot be empty")
737
738 if not shoot_type_label.strip():
739 raise ValueError("Shoot type label cannot be only whitespace")
740
741 if not isinstance(parameters, dict):
742 raise ValueError("Parameters must be a dict")
743
744 try:
746 plantarch_wrapper.defineShootType(
747 self._plantarch_ptr, self.context.context, shoot_type_label.strip(), parameters
748 )
749 except Exception as e:
750 raise PlantArchitectureError(f"Failed to define shoot type '{shoot_type_label}': {e}")
751
752 def getAvailablePlantModels(self) -> List[str]:
753 """
754 Get list of all available plant models in the library.
755
756 Returns:
757 List of plant model names available for loading
758
759 Raises:
760 PlantArchitectureError: If retrieval fails
761
762 Example:
763 >>> models = plantarch.getAvailablePlantModels()
764 >>> print(f"Available models: {', '.join(models)}")
765 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
766 """
767 try:
769 return plantarch_wrapper.getAvailablePlantModels(self._plantarch_ptr)
770 except Exception as e:
771 raise PlantArchitectureError(f"Failed to get available plant models: {e}")
772
773 def getAllPlantObjectIDs(self, plant_id: int) -> List[int]:
774 """
775 Get all object IDs for a specific plant.
776
777 Args:
778 plant_id: ID of the plant instance
779
780 Returns:
781 List of object IDs comprising the plant
782
783 Raises:
784 ValueError: If plant_id is negative
785 PlantArchitectureError: If retrieval fails
786
787 Example:
788 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
789 >>> print(f"Plant has {len(object_ids)} objects")
790 """
791 if plant_id < 0:
792 raise ValueError("Plant ID must be non-negative")
793
794 try:
795 return plantarch_wrapper.getAllPlantObjectIDs(self._plantarch_ptr, plant_id)
796 except Exception as e:
797 raise PlantArchitectureError(f"Failed to get object IDs for plant {plant_id}: {e}")
798
799 def getAllPlantUUIDs(self, plant_id: int, include_hidden: bool = False) -> List[int]:
800 """
801 Get all primitive UUIDs for a specific plant.
802
803 Args:
804 plant_id: ID of the plant instance
805 include_hidden: If True, also include UUIDs of hidden prototype
806 primitives managed by this PlantArchitecture instance.
807
808 Returns:
809 List of primitive UUIDs comprising the plant (and optionally hidden prototypes)
810
811 Raises:
812 ValueError: If plant_id is negative
813 PlantArchitectureError: If retrieval fails
814
815 Example:
816 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
817 >>> print(f"Plant has {len(uuids)} primitives")
818 """
819 if plant_id < 0:
820 raise ValueError("Plant ID must be non-negative")
821
822 try:
823 return plantarch_wrapper.getAllPlantUUIDs(self._plantarch_ptr, plant_id, include_hidden)
824 except Exception as e:
825 raise PlantArchitectureError(f"Failed to get UUIDs for plant {plant_id}: {e}")
826
827 def getPlantAge(self, plant_id: int) -> float:
828 """
829 Get the current age of a plant in days.
830
831 Args:
832 plant_id: ID of the plant instance
833
834 Returns:
835 Plant age in days
836
837 Raises:
838 ValueError: If plant_id is negative
839 PlantArchitectureError: If retrieval fails
840
841 Example:
842 >>> age = plantarch.getPlantAge(plant_id)
843 >>> print(f"Plant is {age} days old")
844 """
845 if plant_id < 0:
846 raise ValueError("Plant ID must be non-negative")
847
848 try:
850 return plantarch_wrapper.getPlantAge(self._plantarch_ptr, plant_id)
851 except Exception as e:
852 raise PlantArchitectureError(f"Failed to get age for plant {plant_id}: {e}")
853
854 def getPlantHeight(self, plant_id: int) -> float:
855 """
856 Get the height of a plant in meters.
857
858 Args:
859 plant_id: ID of the plant instance
860
861 Returns:
862 Plant height in meters (vertical extent)
863
864 Raises:
865 ValueError: If plant_id is negative
866 PlantArchitectureError: If retrieval fails
867
868 Example:
869 >>> height = plantarch.getPlantHeight(plant_id)
870 >>> print(f"Plant is {height:.2f}m tall")
871 """
872 if plant_id < 0:
873 raise ValueError("Plant ID must be non-negative")
874
875 try:
877 return plantarch_wrapper.getPlantHeight(self._plantarch_ptr, plant_id)
878 except Exception as e:
879 raise PlantArchitectureError(f"Failed to get height for plant {plant_id}: {e}")
880
881 def getPlantLeafArea(self, plant_id: int) -> float:
882 """
883 Get the total leaf area of a plant in m².
884
885 Args:
886 plant_id: ID of the plant instance
887
888 Returns:
889 Total leaf area in square meters
890
891 Raises:
892 ValueError: If plant_id is negative
893 PlantArchitectureError: If retrieval fails
894
895 Example:
896 >>> leaf_area = plantarch.getPlantLeafArea(plant_id)
897 >>> print(f"Total leaf area: {leaf_area:.3f} m²")
898 """
899 if plant_id < 0:
900 raise ValueError("Plant ID must be non-negative")
901
902 try:
904 return plantarch_wrapper.sumPlantLeafArea(self._plantarch_ptr, plant_id)
905 except Exception as e:
906 raise PlantArchitectureError(f"Failed to get leaf area for plant {plant_id}: {e}")
907
909 self,
910 plant_id: int,
911 time_to_dormancy_break: float,
912 time_to_flower_initiation: float,
913 time_to_flower_opening: float,
914 time_to_fruit_set: float,
915 time_to_fruit_maturity: float,
916 time_to_dormancy: float,
917 max_leaf_lifespan: float = 1e6
918 ) -> None:
919 """
920 Set phenological timing thresholds for plant developmental stages.
921
922 Controls the timing of key phenological events based on thermal time
923 or calendar time depending on the plant model.
924
925 Args:
926 plant_id: ID of the plant instance
927 time_to_dormancy_break: Degree-days or days until dormancy ends
928 time_to_flower_initiation: Time until flower buds are initiated
929 time_to_flower_opening: Time until flowers open
930 time_to_fruit_set: Time until fruit begins developing
931 time_to_fruit_maturity: Time until fruit reaches maturity
932 time_to_dormancy: Time until plant enters dormancy
933 max_leaf_lifespan: Maximum leaf lifespan in days (default: 1e6)
934
935 Raises:
936 ValueError: If plant_id is negative
937 PlantArchitectureError: If phenology setting fails
938
939 Example:
940 >>> # Set phenology for perennial fruit tree
941 >>> plantarch.setPlantPhenologicalThresholds(
942 ... plant_id=plant_id,
943 ... time_to_dormancy_break=60, # Spring: 60 degree-days
944 ... time_to_flower_initiation=90, # Early spring flowering
945 ... time_to_flower_opening=105, # Bloom period
946 ... time_to_fruit_set=120, # Fruit set after pollination
947 ... time_to_fruit_maturity=200, # Summer fruit maturation
948 ... time_to_dormancy=280, # Fall dormancy
949 ... max_leaf_lifespan=180 # Deciduous - 6 month leaf life
950 ... )
951 """
952 if plant_id < 0:
953 raise ValueError("Plant ID must be non-negative")
954
955 try:
957 plantarch_wrapper.setPlantPhenologicalThresholds(
958 self._plantarch_ptr,
959 plant_id,
960 time_to_dormancy_break,
961 time_to_flower_initiation,
962 time_to_flower_opening,
963 time_to_fruit_set,
964 time_to_fruit_maturity,
965 time_to_dormancy,
966 max_leaf_lifespan
967 )
968 except Exception as e:
969 raise PlantArchitectureError(f"Failed to set phenological thresholds for plant {plant_id}: {e}")
970
971 # Collision detection methods
973 target_object_UUIDs: Optional[List[int]] = None,
974 target_object_IDs: Optional[List[int]] = None,
975 enable_petiole_collision: bool = False,
976 enable_fruit_collision: bool = False) -> None:
977 """
978 Enable soft collision avoidance for procedural plant growth.
979
980 This method enables the collision detection system that guides plant growth away from
981 obstacles and other plants. The system uses cone-based gap detection to find optimal
982 growth directions that minimize collisions while maintaining natural plant architecture.
983
984 Args:
985 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
986 avoids all geometry in the context.
987 target_object_IDs: List of compound object IDs to avoid collisions with.
988 enable_petiole_collision: Enable collision detection for leaf petioles
989 enable_fruit_collision: Enable collision detection for fruit organs
990
991 Raises:
992 PlantArchitectureError: If collision detection activation fails
993
994 Note:
995 Collision detection adds computational overhead. Use setStaticObstacles() to mark
996 static geometry for BVH optimization and improved performance.
997
998 Example:
999 >>> # Avoid all geometry
1000 >>> plantarch.enableSoftCollisionAvoidance()
1001 >>>
1002 >>> # Avoid specific obstacles
1003 >>> obstacle_uuids = context.getAllUUIDs()
1004 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
1005 >>>
1006 >>> # Enable collision detection for petioles and fruit
1007 >>> plantarch.enableSoftCollisionAvoidance(
1008 ... enable_petiole_collision=True,
1009 ... enable_fruit_collision=True
1010 ... )
1011 """
1012 try:
1014 plantarch_wrapper.enableSoftCollisionAvoidance(
1015 self._plantarch_ptr,
1016 target_UUIDs=target_object_UUIDs,
1017 target_IDs=target_object_IDs,
1018 enable_petiole=enable_petiole_collision,
1019 enable_fruit=enable_fruit_collision
1020 )
1021 except Exception as e:
1022 raise PlantArchitectureError(f"Failed to enable soft collision avoidance: {e}")
1023
1024 def disableCollisionDetection(self) -> None:
1025 """
1026 Disable collision detection for plant growth.
1027
1028 This method turns off the collision detection system, allowing plants to grow
1029 without checking for obstacles. This improves performance but plants may grow
1030 through obstacles and other geometry.
1031
1032 Raises:
1033 PlantArchitectureError: If disabling fails
1034
1035 Example:
1036 >>> plantarch.disableCollisionDetection()
1037 """
1038 try:
1039 plantarch_wrapper.disableCollisionDetection(self._plantarch_ptr)
1040 except Exception as e:
1041 raise PlantArchitectureError(f"Failed to disable collision detection: {e}")
1042
1044 view_half_angle_deg: float = 80.0,
1045 look_ahead_distance: float = 0.1,
1046 sample_count: int = 256,
1047 inertia_weight: float = 0.4) -> None:
1048 """
1049 Configure parameters for soft collision avoidance algorithm.
1050
1051 These parameters control the cone-based gap detection algorithm that guides
1052 plant growth away from obstacles. Adjusting these values allows fine-tuning
1053 the balance between collision avoidance and natural growth patterns.
1054
1055 Args:
1056 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
1057 Default 80° provides wide field of view.
1058 look_ahead_distance: Distance to look ahead for collisions in meters.
1059 Larger values detect distant obstacles. Default 0.1m.
1060 sample_count: Number of ray samples within cone. More samples improve
1061 accuracy but reduce performance. Default 256.
1062 inertia_weight: Weight for previous growth direction (0-1). Higher values
1063 make growth smoother but less responsive. Default 0.4.
1064
1065 Raises:
1066 ValueError: If parameters are outside valid ranges
1067 PlantArchitectureError: If parameter setting fails
1068
1069 Example:
1070 >>> # Use default parameters (recommended)
1071 >>> plantarch.setSoftCollisionAvoidanceParameters()
1072 >>>
1073 >>> # Tune for dense canopy with close obstacles
1074 >>> plantarch.setSoftCollisionAvoidanceParameters(
1075 ... view_half_angle_deg=60.0, # Narrower detection cone
1076 ... look_ahead_distance=0.05, # Shorter look-ahead
1077 ... sample_count=512, # More accurate detection
1078 ... inertia_weight=0.3 # More responsive to obstacles
1079 ... )
1080 """
1081 # Validate parameters
1082 if not (0 <= view_half_angle_deg <= 180):
1083 raise ValueError(f"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
1084 if look_ahead_distance <= 0:
1085 raise ValueError(f"look_ahead_distance must be positive, got {look_ahead_distance}")
1086 if sample_count <= 0:
1087 raise ValueError(f"sample_count must be positive, got {sample_count}")
1088 if not (0 <= inertia_weight <= 1):
1089 raise ValueError(f"inertia_weight must be between 0 and 1, got {inertia_weight}")
1090
1091 try:
1092 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
1093 self._plantarch_ptr,
1094 view_half_angle_deg,
1095 look_ahead_distance,
1096 sample_count,
1097 inertia_weight
1098 )
1099 except Exception as e:
1100 raise PlantArchitectureError(f"Failed to set collision avoidance parameters: {e}")
1101
1103 include_internodes: bool = False,
1104 include_leaves: bool = True,
1105 include_petioles: bool = False,
1106 include_flowers: bool = False,
1107 include_fruit: bool = False) -> None:
1108 """
1109 Specify which plant organs participate in collision detection.
1110
1111 This method allows filtering which organs are considered during collision detection,
1112 enabling optimization by excluding organs unlikely to cause problematic collisions.
1113
1114 Args:
1115 include_internodes: Include stem internodes in collision detection
1116 include_leaves: Include leaf blades in collision detection
1117 include_petioles: Include leaf petioles in collision detection
1118 include_flowers: Include flowers in collision detection
1119 include_fruit: Include fruit in collision detection
1120
1121 Raises:
1122 PlantArchitectureError: If organ filtering fails
1123
1124 Example:
1125 >>> # Only detect collisions for stems and leaves (default behavior)
1126 >>> plantarch.setCollisionRelevantOrgans(
1127 ... include_internodes=True,
1128 ... include_leaves=True
1129 ... )
1130 >>>
1131 >>> # Include all organs
1132 >>> plantarch.setCollisionRelevantOrgans(
1133 ... include_internodes=True,
1134 ... include_leaves=True,
1135 ... include_petioles=True,
1136 ... include_flowers=True,
1137 ... include_fruit=True
1138 ... )
1139 """
1140 try:
1141 plantarch_wrapper.setCollisionRelevantOrgans(
1142 self._plantarch_ptr,
1143 include_internodes,
1144 include_leaves,
1145 include_petioles,
1146 include_flowers,
1147 include_fruit
1148 )
1149 except Exception as e:
1150 raise PlantArchitectureError(f"Failed to set collision-relevant organs: {e}")
1151
1153 obstacle_UUIDs: List[int],
1154 avoidance_distance: float = 0.5,
1155 enable_fruit_adjustment: bool = False,
1156 enable_obstacle_pruning: bool = False) -> None:
1157 """
1158 Enable hard obstacle avoidance for specified geometry.
1159
1160 This method configures solid obstacles that plants cannot grow through. Unlike soft
1161 collision avoidance (which guides growth), solid obstacles cause complete growth
1162 termination when encountered within the avoidance distance.
1163
1164 Args:
1165 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
1166 avoidance_distance: Minimum distance to maintain from obstacles (meters).
1167 Growth stops if obstacles are closer. Default 0.5m.
1168 enable_fruit_adjustment: Adjust fruit positions away from obstacles
1169 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
1170
1171 Raises:
1172 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
1173 PlantArchitectureError: If solid obstacle configuration fails
1174
1175 Example:
1176 >>> # Simple solid obstacle avoidance
1177 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
1178 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
1179 >>>
1180 >>> # Close avoidance with fruit adjustment
1181 >>> plantarch.enableSolidObstacleAvoidance(
1182 ... obstacle_UUIDs=wall_uuids,
1183 ... avoidance_distance=0.1,
1184 ... enable_fruit_adjustment=True
1185 ... )
1186 """
1187 if not obstacle_UUIDs:
1188 raise ValueError("Obstacle UUIDs list cannot be empty")
1189 if avoidance_distance <= 0:
1190 raise ValueError(f"avoidance_distance must be positive, got {avoidance_distance}")
1191
1192 try:
1194 plantarch_wrapper.enableSolidObstacleAvoidance(
1195 self._plantarch_ptr,
1196 obstacle_UUIDs,
1197 avoidance_distance,
1198 enable_fruit_adjustment,
1199 enable_obstacle_pruning
1200 )
1201 except Exception as e:
1202 raise PlantArchitectureError(f"Failed to enable solid obstacle avoidance: {e}")
1203
1204 def setStaticObstacles(self, target_UUIDs: List[int]) -> None:
1205 """
1206 Mark geometry as static obstacles for collision detection optimization.
1207
1208 This method tells the collision detection system that certain geometry will not
1209 move during the simulation. The system can then build an optimized Bounding Volume
1210 Hierarchy (BVH) for these obstacles, significantly improving collision detection
1211 performance in scenes with many static obstacles.
1212
1213 Args:
1214 target_UUIDs: List of primitive UUIDs representing static obstacles
1215
1216 Raises:
1217 ValueError: If target_UUIDs is empty
1218 PlantArchitectureError: If static obstacle configuration fails
1219
1220 Note:
1221 Call this method BEFORE enabling collision avoidance for best performance.
1222 Static obstacles cannot be modified or moved after being marked static.
1223
1224 Example:
1225 >>> # Mark ground and building geometry as static
1226 >>> static_uuids = ground_uuids + building_uuids
1227 >>> plantarch.setStaticObstacles(static_uuids)
1228 >>> # Now enable collision avoidance
1229 >>> plantarch.enableSoftCollisionAvoidance()
1230 """
1231 if not target_UUIDs:
1232 raise ValueError("target_UUIDs list cannot be empty")
1233
1234 try:
1236 plantarch_wrapper.setStaticObstacles(self._plantarch_ptr, target_UUIDs)
1237 except Exception as e:
1238 raise PlantArchitectureError(f"Failed to set static obstacles: {e}")
1239
1240 def getPlantCollisionRelevantObjectIDs(self, plant_id: int) -> List[int]:
1241 """
1242 Get object IDs of collision-relevant geometry for a specific plant.
1243
1244 This method returns the subset of plant geometry that participates in collision
1245 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
1246 and debugging collision detection behavior.
1247
1248 Args:
1249 plant_id: ID of the plant instance
1250
1251 Returns:
1252 List of object IDs for collision-relevant plant geometry
1253
1254 Raises:
1255 ValueError: If plant_id is negative
1256 PlantArchitectureError: If retrieval fails
1257
1258 Example:
1259 >>> # Get collision-relevant geometry
1260 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
1261 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
1262 >>>
1263 >>> # Highlight collision geometry in visualization
1264 >>> for obj_id in collision_obj_ids:
1265 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
1266 """
1267 if plant_id < 0:
1268 raise ValueError("Plant ID must be non-negative")
1269
1270 try:
1271 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self._plantarch_ptr, plant_id)
1272 except Exception as e:
1273 raise PlantArchitectureError(f"Failed to get collision-relevant object IDs for plant {plant_id}: {e}")
1274
1275 # File I/O methods
1276 def writePlantMeshVertices(self, plant_id: int, filename: Union[str, Path]) -> None:
1277 """
1278 Write all plant mesh vertices to file for external processing.
1279
1280 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
1281 writing one vertex per line. Useful for external processing such as computing bounding
1282 volumes, convex hulls, or performing custom geometric analysis.
1283
1284 Args:
1285 plant_id: ID of the plant instance to export
1286 filename: Path to output file (absolute or relative to current working directory)
1287
1288 Raises:
1289 ValueError: If plant_id is negative or filename is empty
1290 PlantArchitectureError: If plant doesn't exist or file cannot be written
1291
1292 Example:
1293 >>> # Export vertices for convex hull analysis
1294 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
1295 >>>
1296 >>> # Use with Path object
1297 >>> from pathlib import Path
1298 >>> output_dir = Path("output")
1299 >>> output_dir.mkdir(exist_ok=True)
1300 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
1301 """
1302 if plant_id < 0:
1303 raise ValueError("Plant ID must be non-negative")
1304 if not filename:
1305 raise ValueError("Filename cannot be empty")
1306
1307 # Resolve path before changing directory
1308 absolute_path = _resolve_user_path(filename)
1309
1310 try:
1312 plantarch_wrapper.writePlantMeshVertices(
1313 self._plantarch_ptr, plant_id, absolute_path
1314 )
1315 except Exception as e:
1316 raise PlantArchitectureError(f"Failed to write plant mesh vertices to {filename}: {e}")
1317
1318 def writePlantStructureXML(self, plant_id: int, filename: Union[str, Path]) -> None:
1319 """
1320 Save plant structure to XML file for later loading.
1321
1322 This method exports the complete plant architecture to an XML file, including
1323 all shoots, phytomers, organs, and their properties. The saved plant can be
1324 reloaded later using readPlantStructureXML().
1325
1326 Args:
1327 plant_id: ID of the plant instance to save
1328 filename: Path to output XML file (absolute or relative to current working directory)
1329
1330 Raises:
1331 ValueError: If plant_id is negative or filename is empty
1332 PlantArchitectureError: If plant doesn't exist or file cannot be written
1333
1334 Note:
1335 The XML format preserves the complete plant state including:
1336 - Shoot structure and hierarchy
1337 - Phytomer properties and development stage
1338 - Organ geometry and attributes
1339 - Growth parameters and phenological state
1340
1341 Example:
1342 >>> # Save plant at current growth stage
1343 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
1344 >>>
1345 >>> # Later, reload the saved plant
1346 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
1347 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
1348 """
1349 if plant_id < 0:
1350 raise ValueError("Plant ID must be non-negative")
1351 if not filename:
1352 raise ValueError("Filename cannot be empty")
1353
1354 # Resolve path before changing directory
1355 absolute_path = _resolve_user_path(filename)
1356
1357 try:
1359 plantarch_wrapper.writePlantStructureXML(
1360 self._plantarch_ptr, plant_id, absolute_path
1361 )
1362 except Exception as e:
1363 raise PlantArchitectureError(f"Failed to write plant structure XML to {filename}: {e}")
1364
1365 def writeQSMCylinderFile(self, plant_id: int, filename: Union[str, Path]) -> None:
1366 """
1367 Export plant structure in TreeQSM cylinder format.
1368
1369 This method writes the plant structure as a series of cylinders following the
1370 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
1371 columns for radius, length, start position, axis direction, branch topology,
1372 and other structural properties. Useful for biomechanical analysis and
1373 quantitative structure modeling.
1374
1375 Args:
1376 plant_id: ID of the plant instance to export
1377 filename: Path to output file (absolute or relative, typically .txt extension)
1378
1379 Raises:
1380 ValueError: If plant_id is negative or filename is empty
1381 PlantArchitectureError: If plant doesn't exist or file cannot be written
1382
1383 Note:
1384 The TreeQSM format includes columns for:
1385 - Cylinder dimensions (radius, length)
1386 - Spatial position and orientation
1387 - Branch topology (parent ID, extension ID, branch ID)
1388 - Branch hierarchy (branch order, position in branch)
1389 - Quality metrics (mean absolute distance, surface coverage)
1390
1391 Example:
1392 >>> # Export for biomechanical analysis
1393 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
1394 >>>
1395 >>> # Use with external QSM tools
1396 >>> import pandas as pd
1397 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
1398 >>> print(f"Tree has {len(qsm_data)} cylinders")
1399
1400 References:
1401 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
1402 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
1403 """
1404 if plant_id < 0:
1405 raise ValueError("Plant ID must be non-negative")
1406 if not filename:
1407 raise ValueError("Filename cannot be empty")
1408
1409 # Resolve path before changing directory
1410 absolute_path = _resolve_user_path(filename)
1411
1412 try:
1414 plantarch_wrapper.writeQSMCylinderFile(
1415 self._plantarch_ptr, plant_id, absolute_path
1416 )
1417 except Exception as e:
1418 raise PlantArchitectureError(f"Failed to write QSM cylinder file to {filename}: {e}")
1419
1420 def writePlantStructureUSD(self, plant_id: int, filename: Union[str, Path],
1421 elastic_modulus: float = 5e9,
1422 wood_density: float = 800.0,
1423 damping_ratio: float = 0.1,
1424 static_friction: float = 0.5,
1425 dynamic_friction: float = 0.3,
1426 restitution: float = 0.1,
1427 organ_spring_stiffness: float = 10.0,
1428 organ_spring_damping: float = 1.0,
1429 leaf_mass_per_area: float = 0.05,
1430 fruit_mass: float = 0.01,
1431 flower_mass: float = 0.002,
1432 solver_position_iterations: int = 32,
1433 min_segment_length: float = 0.001) -> None:
1434 """
1435 Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
1436
1437 Each tube segment becomes a capsule-shaped rigid link connected by spherical joints.
1438 Spring/damper drives are derived from beam bending stiffness (E*I/L). Leaves, fruits,
1439 and flowers are represented as mass bodies attached by spring links.
1440
1441 Args:
1442 plant_id: ID of the plant instance to export
1443 filename: Output file path (should have .usda extension)
1444 elastic_modulus: Young's modulus (Pa) for joint stiffness, K = E*I/L
1445 wood_density: Wood density (kg/m^3) used to compute mass from capsule volume
1446 damping_ratio: Joint damping ratio (dimensionless)
1447 static_friction: Static friction coefficient for collision material
1448 dynamic_friction: Dynamic friction coefficient for collision material
1449 restitution: Restitution (bounciness) for collision material
1450 organ_spring_stiffness: Spring stiffness (N*m/rad) for organ attachment joints
1451 organ_spring_damping: Damping (N*m*s/rad) for organ attachment joints
1452 leaf_mass_per_area: Leaf mass per unit area (kg/m^2)
1453 fruit_mass: Mass per fruit (kg)
1454 flower_mass: Mass per flower (kg)
1455 solver_position_iterations: PhysX articulation solver position iteration count
1456 min_segment_length: Minimum segment length (m); shorter segments are skipped
1457
1458 Raises:
1459 ValueError: If plant_id is negative or filename is empty
1460 PlantArchitectureError: If plant doesn't exist or file cannot be written
1461
1462 Example:
1463 >>> plantarch.writePlantStructureUSD(plant_id, "plant.usda")
1464 """
1465 if plant_id < 0:
1466 raise ValueError("Plant ID must be non-negative")
1467 if not filename:
1468 raise ValueError("Filename cannot be empty")
1469
1470 absolute_path = _resolve_user_path(filename)
1471
1472 try:
1474 plantarch_wrapper.writePlantStructureUSD(
1475 self._plantarch_ptr, plant_id, absolute_path,
1476 elastic_modulus, wood_density, damping_ratio,
1477 static_friction, dynamic_friction, restitution,
1478 organ_spring_stiffness, organ_spring_damping,
1479 leaf_mass_per_area, fruit_mass, flower_mass,
1480 solver_position_iterations, min_segment_length
1481 )
1482 except Exception as e:
1483 raise PlantArchitectureError(f"Failed to write plant structure USD to {filename}: {e}")
1484
1485 def registerGrowthFrame(self, plant_id: int, min_segment_length: float = 0.001) -> None:
1486 """
1487 Capture a snapshot of the plant's geometry as a growth animation frame.
1488
1489 Call this after each :meth:`advanceTime` step to record the plant state for later
1490 animation export via :meth:`writePlantGrowthUSD`.
1491
1492 Args:
1493 plant_id: ID of the plant instance to capture
1494 min_segment_length: Minimum segment length (m); shorter segments are skipped
1495
1496 Raises:
1497 ValueError: If plant_id is negative
1498 PlantArchitectureError: If plant doesn't exist
1499 """
1500 if plant_id < 0:
1501 raise ValueError("Plant ID must be non-negative")
1502
1503 try:
1504 plantarch_wrapper.registerGrowthFrame(self._plantarch_ptr, plant_id, min_segment_length)
1505 except Exception as e:
1506 raise PlantArchitectureError(f"Failed to register growth frame for plant {plant_id}: {e}")
1507
1508 def writePlantGrowthUSD(self, plant_id: int, filename: Union[str, Path],
1509 seconds_per_frame: float = 1.0) -> None:
1510 """
1511 Export all registered growth frames as a time-sampled USD animation file.
1512
1513 The resulting file can be imported directly into Blender. This is a visual-only
1514 export — no physics prims, joints, or collision shapes are written.
1515
1516 Args:
1517 plant_id: ID of the plant instance to export
1518 filename: Output file path (should have .usda extension)
1519 seconds_per_frame: Duration in seconds each growth frame occupies (default: 1.0)
1520
1521 Raises:
1522 ValueError: If plant_id is negative or filename is empty
1523 PlantArchitectureError: If plant doesn't exist or file cannot be written
1524 """
1525 if plant_id < 0:
1526 raise ValueError("Plant ID must be non-negative")
1527 if not filename:
1528 raise ValueError("Filename cannot be empty")
1529
1530 absolute_path = _resolve_user_path(filename)
1531
1532 try:
1534 plantarch_wrapper.writePlantGrowthUSD(
1535 self._plantarch_ptr, plant_id, absolute_path, seconds_per_frame
1536 )
1537 except Exception as e:
1538 raise PlantArchitectureError(f"Failed to write plant growth USD to {filename}: {e}")
1539
1540 def clearGrowthFrames(self, plant_id: int) -> None:
1541 """
1542 Clear stored growth animation frames for a plant.
1543
1544 Args:
1545 plant_id: ID of the plant instance whose frames should be cleared
1546
1547 Raises:
1548 ValueError: If plant_id is negative
1549 """
1550 if plant_id < 0:
1551 raise ValueError("Plant ID must be non-negative")
1552
1553 try:
1554 plantarch_wrapper.clearGrowthFrames(self._plantarch_ptr, plant_id)
1555 except Exception as e:
1556 raise PlantArchitectureError(f"Failed to clear growth frames for plant {plant_id}: {e}")
1557
1558 def getGrowthFrameCount(self, plant_id: int) -> int:
1559 """
1560 Get the number of registered growth frames for a plant.
1561
1562 Args:
1563 plant_id: ID of the plant instance to query
1564
1565 Returns:
1566 Number of frames registered via :meth:`registerGrowthFrame`
1567
1568 Raises:
1569 ValueError: If plant_id is negative
1570 """
1571 if plant_id < 0:
1572 raise ValueError("Plant ID must be non-negative")
1573
1574 try:
1575 return plantarch_wrapper.getGrowthFrameCount(self._plantarch_ptr, plant_id)
1576 except Exception as e:
1577 raise PlantArchitectureError(f"Failed to get growth frame count for plant {plant_id}: {e}")
1578
1579 def readPlantStructureXML(self, filename: Union[str, Path], quiet: bool = False) -> List[int]:
1580 """
1581 Load plant structure from XML file.
1582
1583 This method reads plant architecture data from an XML file previously saved with
1584 writePlantStructureXML(). The loaded plants are added to the current context
1585 and can be grown, modified, or analyzed like any other plants.
1586
1587 Args:
1588 filename: Path to XML file to load (absolute or relative to current working directory)
1589 quiet: If True, suppress console output during loading (default: False)
1590
1591 Returns:
1592 List of plant IDs for the loaded plant instances
1593
1594 Raises:
1595 ValueError: If filename is empty
1596 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
1597
1598 Note:
1599 The XML file can contain multiple plant instances. All plants in the file
1600 will be loaded and their IDs returned in a list. Plant models referenced
1601 in the XML must be available in the plant library.
1602
1603 Example:
1604 >>> # Load previously saved plants
1605 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
1606 >>> print(f"Loaded {len(plant_ids)} plants")
1607 >>>
1608 >>> # Continue growing the loaded plants
1609 >>> plantarch.advanceTime(10.0)
1610 >>>
1611 >>> # Load quietly without console messages
1612 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
1613 """
1614 if not filename:
1615 raise ValueError("Filename cannot be empty")
1616
1617 # Resolve path before changing directory
1618 absolute_path = _resolve_user_path(filename)
1619
1620 try:
1622 return plantarch_wrapper.readPlantStructureXML(
1623 self._plantarch_ptr, absolute_path, quiet
1624 )
1625 except Exception as e:
1626 raise PlantArchitectureError(f"Failed to read plant structure XML from {filename}: {e}")
1627
1628 # Custom plant building methods
1629 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
1630 """
1631 Create an empty plant instance for custom plant building.
1632
1633 This method creates a new plant instance at the specified location without any
1634 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
1635 manually construct the plant structure. This provides low-level control over
1636 plant architecture, enabling custom morphologies not available in the plant library.
1637
1638 Args:
1639 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
1640 current_age: Current age of the plant in days (must be >= 0)
1641
1642 Returns:
1643 Plant ID for the created plant instance
1644
1645 Raises:
1646 ValueError: If age is negative
1647 PlantArchitectureError: If plant creation fails
1648
1649 Example:
1650 >>> # Create empty plant at origin
1651 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1652 >>>
1653 >>> # Now add shoots to build custom plant structure
1654 >>> shoot_id = plantarch.addBaseStemShoot(
1655 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
1656 ... )
1657 """
1658 # Parameter type validation
1659 if not isinstance(base_position, vec3):
1660 raise ValueError(f"base_position must be a vec3, got {type(base_position).__name__}")
1662 # Convert position to list for C++ interface
1663 position_list = [base_position.x, base_position.y, base_position.z]
1664
1665 # Validate age
1666 if current_age < 0:
1667 raise ValueError(f"Age must be non-negative, got {current_age}")
1668
1669 try:
1671 return plantarch_wrapper.addPlantInstance(
1672 self._plantarch_ptr, position_list, current_age
1673 )
1674 except Exception as e:
1675 raise PlantArchitectureError(f"Failed to add plant instance: {e}")
1676
1677 def deletePlantInstance(self, plant_id: int) -> None:
1678 """
1679 Delete a plant instance and all associated geometry.
1680
1681 This method removes a plant from the simulation, deleting all shoots, organs,
1682 and associated primitives from the context. The plant ID becomes invalid after
1683 deletion and should not be used in subsequent operations.
1684
1685 Args:
1686 plant_id: ID of the plant instance to delete
1687
1688 Raises:
1689 ValueError: If plant_id is negative
1690 PlantArchitectureError: If plant deletion fails or plant doesn't exist
1691
1692 Example:
1693 >>> # Delete a plant
1694 >>> plantarch.deletePlantInstance(plant_id)
1695 >>>
1696 >>> # Delete multiple plants
1697 >>> for pid in plant_ids_to_remove:
1698 ... plantarch.deletePlantInstance(pid)
1699 """
1700 if plant_id < 0:
1701 raise ValueError("Plant ID must be non-negative")
1702
1703 try:
1705 plantarch_wrapper.deletePlantInstance(self._plantarch_ptr, plant_id)
1706 except Exception as e:
1707 raise PlantArchitectureError(f"Failed to delete plant instance {plant_id}: {e}")
1708
1709 def addBaseStemShoot(self,
1710 plant_id: int,
1711 current_node_number: int,
1712 base_rotation: AxisRotation,
1713 internode_radius: float,
1714 internode_length_max: float,
1715 internode_length_scale_factor_fraction: float,
1716 leaf_scale_factor_fraction: float,
1717 radius_taper: float,
1718 shoot_type_label: str) -> int:
1719 """
1720 Add a base stem shoot to a plant instance (main trunk/stem).
1721
1722 This method creates the primary shoot originating from the plant base. The base stem
1723 is typically the main trunk or primary stem from which all other shoots branch.
1724 Specify growth parameters to control the shoot's morphology and development.
1725
1726 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
1727 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
1728 shoot types that can then be used for custom building. The shoot_type_label must match a
1729 shoot type defined in the loaded model.
1730
1731 Args:
1732 plant_id: ID of the plant instance
1733 current_node_number: Starting node number for this shoot (typically 1)
1734 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1735 internode_radius: Base radius of internodes in meters (must be > 0)
1736 internode_length_max: Maximum internode length in meters (must be > 0)
1737 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1738 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1739 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1740 shoot_type_label: Label identifying shoot type - must match a type from loaded model
1741
1742 Returns:
1743 Shoot ID for the created shoot
1744
1745 Raises:
1746 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1747 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
1748
1749 Example:
1750 >>> from pyhelios import AxisRotation
1751 >>>
1752 >>> # REQUIRED: Load a plant model to define shoot types
1753 >>> plantarch.loadPlantModelFromLibrary("bean")
1754 >>>
1755 >>> # Create empty plant for custom building
1756 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1757 >>>
1758 >>> # Add base stem using shoot type from loaded model
1759 >>> shoot_id = plantarch.addBaseStemShoot(
1760 ... plant_id=plant_id,
1761 ... current_node_number=1,
1762 ... base_rotation=AxisRotation(0, 0, 0), # Upright
1763 ... internode_radius=0.01, # 1cm radius
1764 ... internode_length_max=0.1, # 10cm max length
1765 ... internode_length_scale_factor_fraction=1.0,
1766 ... leaf_scale_factor_fraction=1.0,
1767 ... radius_taper=0.9, # Gradual taper
1768 ... shoot_type_label="stem" # Must match loaded model
1769 ... )
1770 """
1771 if plant_id < 0:
1772 raise ValueError("Plant ID must be non-negative")
1773 if current_node_number < 0:
1774 raise ValueError("Current node number must be non-negative")
1775 if internode_radius <= 0:
1776 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
1777 if internode_length_max <= 0:
1778 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
1779 if not shoot_type_label or not shoot_type_label.strip():
1780 raise ValueError("Shoot type label cannot be empty")
1781
1782 # Convert rotation to list for C++ interface
1783 rotation_list = base_rotation.to_list()
1784
1785 try:
1787 return plantarch_wrapper.addBaseStemShoot(
1788 self._plantarch_ptr, plant_id, current_node_number, rotation_list,
1789 internode_radius, internode_length_max,
1790 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1791 radius_taper, shoot_type_label.strip()
1792 )
1793 except Exception as e:
1794 error_msg = str(e)
1795 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
1797 f"Shoot type '{shoot_type_label}' not defined. "
1798 f"Load a plant model first to define shoot types:\n"
1799 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1800 f"Original error: {e}"
1801 )
1802 raise PlantArchitectureError(f"Failed to add base stem shoot: {e}")
1803
1804 def appendShoot(self,
1805 plant_id: int,
1806 parent_shoot_id: int,
1807 current_node_number: int,
1808 base_rotation: AxisRotation,
1809 internode_radius: float,
1810 internode_length_max: float,
1811 internode_length_scale_factor_fraction: float,
1812 leaf_scale_factor_fraction: float,
1813 radius_taper: float,
1814 shoot_type_label: str) -> int:
1815 """
1816 Append a shoot to the end of an existing shoot.
1817
1818 This method extends an existing shoot by appending a new shoot at its terminal bud.
1819 Useful for creating multi-segmented shoots with varying properties along their length,
1820 such as shoots with different growth phases or developmental stages.
1821
1822 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1823 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1824 calling this method.
1825
1826 Args:
1827 plant_id: ID of the plant instance
1828 parent_shoot_id: ID of the parent shoot to extend
1829 current_node_number: Starting node number for this shoot
1830 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1831 internode_radius: Base radius of internodes in meters (must be > 0)
1832 internode_length_max: Maximum internode length in meters (must be > 0)
1833 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1834 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1835 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1836 shoot_type_label: Label identifying shoot type - must match loaded model
1837
1838 Returns:
1839 Shoot ID for the appended shoot
1840
1841 Raises:
1842 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1843 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
1844
1845 Example:
1846 >>> # Load model to define shoot types
1847 >>> plantarch.loadPlantModelFromLibrary("bean")
1848 >>>
1849 >>> # Append shoot with reduced size to simulate apical growth
1850 >>> new_shoot_id = plantarch.appendShoot(
1851 ... plant_id=plant_id,
1852 ... parent_shoot_id=base_shoot_id,
1853 ... current_node_number=10,
1854 ... base_rotation=AxisRotation(0, 0, 0),
1855 ... internode_radius=0.008, # Smaller than base
1856 ... internode_length_max=0.08, # Shorter internodes
1857 ... internode_length_scale_factor_fraction=1.0,
1858 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
1859 ... radius_taper=0.85,
1860 ... shoot_type_label="stem"
1861 ... )
1862 """
1863 if plant_id < 0:
1864 raise ValueError("Plant ID must be non-negative")
1865 if parent_shoot_id < 0:
1866 raise ValueError("Parent shoot ID must be non-negative")
1867 if current_node_number < 0:
1868 raise ValueError("Current node number must be non-negative")
1869 if internode_radius <= 0:
1870 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
1871 if internode_length_max <= 0:
1872 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
1873 if not shoot_type_label or not shoot_type_label.strip():
1874 raise ValueError("Shoot type label cannot be empty")
1875
1876 # Convert rotation to list for C++ interface
1877 rotation_list = base_rotation.to_list()
1878
1879 try:
1881 return plantarch_wrapper.appendShoot(
1882 self._plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
1883 rotation_list, internode_radius, internode_length_max,
1884 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1885 radius_taper, shoot_type_label.strip()
1886 )
1887 except Exception as e:
1888 error_msg = str(e)
1889 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
1891 f"Shoot type '{shoot_type_label}' not defined. "
1892 f"Load a plant model first to define shoot types:\n"
1893 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1894 f"Original error: {e}"
1895 )
1896 raise PlantArchitectureError(f"Failed to append shoot: {e}")
1897
1898 def addChildShoot(self,
1899 plant_id: int,
1900 parent_shoot_id: int,
1901 parent_node_index: int,
1902 current_node_number: int,
1903 shoot_base_rotation: AxisRotation,
1904 internode_radius: float,
1905 internode_length_max: float,
1906 internode_length_scale_factor_fraction: float,
1907 leaf_scale_factor_fraction: float,
1908 radius_taper: float,
1909 shoot_type_label: str,
1910 petiole_index: int = 0) -> int:
1911 """
1912 Add a child shoot at an axillary bud position on a parent shoot.
1913
1914 This method creates a lateral branch shoot emerging from a specific node on the
1915 parent shoot. Child shoots enable creation of branching architectures, with control
1916 over branch angle, size, and which petiole position the branch emerges from (for
1917 plants with multiple petioles per node).
1918
1919 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1920 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1921 calling this method.
1922
1923 Args:
1924 plant_id: ID of the plant instance
1925 parent_shoot_id: ID of the parent shoot
1926 parent_node_index: Index of the parent node where child emerges (0-based)
1927 current_node_number: Starting node number for this child shoot
1928 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1929 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
1930 internode_length_max: Maximum internode length in meters (must be > 0)
1931 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1932 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1933 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1934 shoot_type_label: Label identifying shoot type - must match loaded model
1935 petiole_index: Which petiole at the node to branch from (default: 0)
1936
1937 Returns:
1938 Shoot ID for the created child shoot
1939
1940 Raises:
1941 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
1942 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
1943
1944 Example:
1945 >>> # Load model to define shoot types
1946 >>> plantarch.loadPlantModelFromLibrary("bean")
1947 >>>
1948 >>> # Add lateral branch at 45-degree angle from node 3
1949 >>> branch_id = plantarch.addChildShoot(
1950 ... plant_id=plant_id,
1951 ... parent_shoot_id=main_shoot_id,
1952 ... parent_node_index=3,
1953 ... current_node_number=1,
1954 ... shoot_base_rotation=AxisRotation(45, 90, 0), # 45° out, 90° rotation
1955 ... internode_radius=0.005, # Thinner than main stem
1956 ... internode_length_max=0.06, # Shorter internodes
1957 ... internode_length_scale_factor_fraction=1.0,
1958 ... leaf_scale_factor_fraction=0.9,
1959 ... radius_taper=0.8,
1960 ... shoot_type_label="stem"
1961 ... )
1962 >>>
1963 >>> # Add second branch from opposite petiole
1964 >>> branch_id2 = plantarch.addChildShoot(
1965 ... plant_id, main_shoot_id, 3, 1, AxisRotation(45, 270, 0),
1966 ... 0.005, 0.06, 1.0, 0.9, 0.8, "stem", petiole_index=1
1967 ... )
1968 """
1969 if plant_id < 0:
1970 raise ValueError("Plant ID must be non-negative")
1971 if parent_shoot_id < 0:
1972 raise ValueError("Parent shoot ID must be non-negative")
1973 if parent_node_index < 0:
1974 raise ValueError("Parent node index must be non-negative")
1975 if current_node_number < 0:
1976 raise ValueError("Current node number must be non-negative")
1977 if internode_radius <= 0:
1978 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
1979 if internode_length_max <= 0:
1980 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
1981 if not shoot_type_label or not shoot_type_label.strip():
1982 raise ValueError("Shoot type label cannot be empty")
1983 if petiole_index < 0:
1984 raise ValueError(f"Petiole index must be non-negative, got {petiole_index}")
1985
1986 # Convert rotation to list for C++ interface
1987 rotation_list = shoot_base_rotation.to_list()
1988
1989 try:
1991 return plantarch_wrapper.addChildShoot(
1992 self._plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
1993 current_node_number, rotation_list, internode_radius,
1994 internode_length_max, internode_length_scale_factor_fraction,
1995 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
1996 petiole_index
1997 )
1998 except Exception as e:
1999 error_msg = str(e)
2000 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
2002 f"Shoot type '{shoot_type_label}' not defined. "
2003 f"Load a plant model first to define shoot types:\n"
2004 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
2005 f"Original error: {e}"
2006 )
2007 raise PlantArchitectureError(f"Failed to add child shoot: {e}")
2008
2009 def is_available(self) -> bool:
2010 """
2011 Check if PlantArchitecture is available in current build.
2012
2013 Returns:
2014 True if plugin is available, False otherwise
2015 """
2017
2018
2019# Convenience function
2020def create_plant_architecture(context: Context) -> PlantArchitecture:
2021 """
2022 Create PlantArchitecture instance with context.
2023
2024 Args:
2025 context: Helios Context
2026
2027 Returns:
2028 PlantArchitecture instance
2029
2030 Example:
2031 >>> context = Context()
2032 >>> plantarch = create_plant_architecture(context)
2033 """
2034 return PlantArchitecture(context)
Raised when PlantArchitecture operations fail.
High-level interface for plant architecture modeling and procedural plant generation.
None clearGrowthFrames(self, int plant_id)
Clear stored growth animation frames for a plant.
None writePlantMeshVertices(self, int plant_id, Union[str, Path] filename)
Write all plant mesh vertices to file for external processing.
None setCollisionRelevantOrgans(self, bool include_internodes=False, bool include_leaves=True, bool include_petioles=False, bool include_flowers=False, bool include_fruit=False)
Specify which plant organs participate in collision detection.
bool is_available(self)
Check if PlantArchitecture is available in current build.
None registerGrowthFrame(self, int plant_id, float min_segment_length=0.001)
Capture a snapshot of the plant's geometry as a growth animation frame.
None advanceTime(self, float dt)
Advance time for plant growth and development.
List[int] getAllPlantObjectIDs(self, int plant_id)
Get all object IDs for a specific plant.
int addChildShoot(self, int plant_id, int parent_shoot_id, int parent_node_index, int current_node_number, AxisRotation shoot_base_rotation, float internode_radius, float internode_length_max, float internode_length_scale_factor_fraction, float leaf_scale_factor_fraction, float radius_taper, str shoot_type_label, int petiole_index=0)
Add a child shoot at an axillary bud position on a parent shoot.
List[int] buildPlantCanopyFromLibrary(self, vec3 canopy_center, vec2 plant_spacing, int2 plant_count, float age, float germination_rate=1.0, Optional[dict] build_parameters=None)
Build a canopy of regularly spaced plants from the currently loaded library model.
None defineShootType(self, str shoot_type_label, dict parameters)
Define a custom shoot type with specified parameters.
None enableSolidObstacleAvoidance(self, List[int] obstacle_UUIDs, float avoidance_distance=0.5, bool enable_fruit_adjustment=False, bool enable_obstacle_pruning=False)
Enable hard obstacle avoidance for specified geometry.
None setPlantPhenologicalThresholds(self, int plant_id, float time_to_dormancy_break, float time_to_flower_initiation, float time_to_flower_opening, float time_to_fruit_set, float time_to_fruit_maturity, float time_to_dormancy, float max_leaf_lifespan=1e6)
Set phenological timing thresholds for plant developmental stages.
float getPlantHeight(self, int plant_id)
Get the height of a plant in meters.
int getGrowthFrameCount(self, int plant_id)
Get the number of registered growth frames for a plant.
None deletePlantInstance(self, int plant_id)
Delete a plant instance and all associated geometry.
List[int] readPlantStructureXML(self, Union[str, Path] filename, bool quiet=False)
Load plant structure from XML file.
__exit__(self, exc_type, exc_val, exc_tb)
Context manager exit - cleanup resources.
float getPlantAge(self, int plant_id)
Get the current age of a plant in days.
int buildPlantInstanceFromLibrary(self, vec3 base_position, float age, Optional[dict] build_parameters=None)
Build a plant instance from the currently loaded library model.
None writePlantStructureXML(self, int plant_id, Union[str, Path] filename)
Save plant structure to XML file for later loading.
None setStaticObstacles(self, List[int] target_UUIDs)
Mark geometry as static obstacles for collision detection optimization.
List[int] getAllPlantUUIDs(self, int plant_id, bool include_hidden=False)
Get all primitive UUIDs for a specific plant.
None loadPlantModelFromLibrary(self, str plant_label)
Load a plant model from the built-in library.
None disableCollisionDetection(self)
Disable collision detection for plant growth.
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).
dict getCurrentShootParameters(self, str shoot_type_label)
Get current shoot parameters for a shoot type.
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.
setProgressCallback(self, callback)
Set a callback to receive progress updates during long-running operations.
None writePlantGrowthUSD(self, int plant_id, Union[str, Path] filename, float seconds_per_frame=1.0)
Export all registered growth frames as a time-sampled USD animation file.
List[str] getAvailablePlantModels(self)
Get list of all available plant models in the library.
None writeQSMCylinderFile(self, int plant_id, Union[str, Path] filename)
Export plant structure in TreeQSM cylinder format.
int addPlantInstance(self, vec3 base_position, float current_age)
Create an empty plant instance for custom plant building.
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
float getPlantLeafArea(self, int plant_id)
Get the total leaf area of a plant in m².
None writePlantStructureUSD(self, int plant_id, Union[str, Path] filename, float elastic_modulus=5e9, float wood_density=800.0, float damping_ratio=0.1, float static_friction=0.5, float dynamic_friction=0.3, float restitution=0.1, float organ_spring_stiffness=10.0, float organ_spring_damping=1.0, float leaf_mass_per_area=0.05, float fruit_mass=0.01, float flower_mass=0.002, int solver_position_iterations=32, float min_segment_length=0.001)
Export plant structure as a USD articulated rigid body for NVIDIA IsaacSim physics.
__init__(self, Context context)
Initialize PlantArchitecture with a Helios context.
None setSoftCollisionAvoidanceParameters(self, float view_half_angle_deg=80.0, float look_ahead_distance=0.1, int sample_count=256, float inertia_weight=0.4)
Configure parameters for soft collision avoidance algorithm.
Helper class for creating RandomParameter specifications for integer parameters.
Dict[str, Any] uniform(int min_val, int max_val)
Create a uniform distribution for integer parameter.
Dict[str, Any] discrete(List[int] values)
Create a discrete value distribution (random choice from list).
Dict[str, Any] constant(int value)
Create a constant (non-random) integer parameter.
Helper class for creating RandomParameter specifications for float parameters.
Dict[str, Any] normal(float mean, float std_dev)
Create a normal (Gaussian) distribution parameter.
Dict[str, Any] uniform(float min_val, float max_val)
Create a uniform distribution parameter.
Dict[str, Any] constant(float value)
Create a constant (non-random) parameter.
Dict[str, Any] weibull(float shape, float scale)
Create a Weibull distribution parameter.
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...