0.1.13
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 # Convert position to list for C++ interface
481 position_list = [base_position.x, base_position.y, base_position.z]
483 # Validate age (allow zero)
484 if age < 0:
485 raise ValueError(f"Age must be non-negative, got {age}")
486
487 # Validate build_parameters
488 if build_parameters is not None:
489 if not isinstance(build_parameters, dict):
490 raise ValueError("build_parameters must be a dict or None")
491 for key, value in build_parameters.items():
492 if not isinstance(key, str):
493 raise ValueError("build_parameters keys must be strings")
494 if not isinstance(value, (int, float)):
495 raise ValueError("build_parameters values must be numeric (int or float)")
496
497 try:
499 return plantarch_wrapper.buildPlantInstanceFromLibrary(
500 self._plantarch_ptr, position_list, age, build_parameters
501 )
502 except Exception as e:
503 raise PlantArchitectureError(f"Failed to build plant instance: {e}")
504
505 def buildPlantCanopyFromLibrary(self, canopy_center: vec3,
506 plant_spacing: vec2,
507 plant_count: int2, age: float,
508 build_parameters: Optional[dict] = None) -> List[int]:
509 """
510 Build a canopy of regularly spaced plants from the currently loaded library model.
511
512 Args:
513 canopy_center: Cartesian (x,y,z) coordinates of canopy center as vec3
514 plant_spacing: Spacing between plants in x- and y-directions (meters) as vec2
515 plant_count: Number of plants in x- and y-directions as int2
516 age: Age of all plants in days (must be >= 0)
517 build_parameters: Optional dict of parameter overrides for training system parameters.
518 Parameters are applied to all plants in the canopy.
519 Examples:
520 - {'cordon_height': 1.8} - for grapevine trellis height
521 - {'trunk_height': 2.5} - for tomato trellis systems
522
523 Returns:
524 List of plant IDs for the created plant instances
525
526 Raises:
527 ValueError: If age is negative, plant count values are not positive, or build_parameters is invalid
528 PlantArchitectureError: If canopy building fails
529
530 Example:
531 >>> # 3x3 canopy with 0.5m spacing, 30-day-old plants
532 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
533 ... canopy_center=vec3(0, 0, 0),
534 ... plant_spacing=vec2(0.5, 0.5),
535 ... plant_count=int2(3, 3),
536 ... age=30.0
537 ... )
538 >>> # With custom parameters
539 >>> plant_ids = plantarch.buildPlantCanopyFromLibrary(
540 ... canopy_center=vec3(0, 0, 0),
541 ... plant_spacing=vec2(1.5, 2.0),
542 ... plant_count=int2(5, 3),
543 ... age=45.0,
544 ... build_parameters={'cordon_height': 1.8}
545 ... )
546 """
547 # Validate age (allow zero)
548 if age < 0:
549 raise ValueError(f"Age must be non-negative, got {age}")
550
551 # Validate count values
552 if plant_count.x <= 0 or plant_count.y <= 0:
553 raise ValueError("Plant count values must be positive integers")
554
555 # Validate build_parameters
556 if build_parameters is not None:
557 if not isinstance(build_parameters, dict):
558 raise ValueError("build_parameters must be a dict or None")
559 for key, value in build_parameters.items():
560 if not isinstance(key, str):
561 raise ValueError("build_parameters keys must be strings")
562 if not isinstance(value, (int, float)):
563 raise ValueError("build_parameters values must be numeric (int or float)")
564
565 # Convert to lists for C++ interface
566 center_list = [canopy_center.x, canopy_center.y, canopy_center.z]
567 spacing_list = [plant_spacing.x, plant_spacing.y]
568 count_list = [plant_count.x, plant_count.y]
569
570 try:
572 return plantarch_wrapper.buildPlantCanopyFromLibrary(
573 self._plantarch_ptr, center_list, spacing_list, count_list, age, build_parameters
574 )
575 except Exception as e:
576 raise PlantArchitectureError(f"Failed to build plant canopy: {e}")
577
578 def advanceTime(self, dt: float) -> None:
579 """
580 Advance time for plant growth and development.
581
582 This method updates all plants in the simulation, potentially adding new phytomers,
583 growing existing organs, transitioning phenological stages, and updating plant geometry.
584
585 Args:
586 dt: Time step to advance in days (must be >= 0)
587
588 Raises:
589 ValueError: If dt is negative
590 PlantArchitectureError: If time advancement fails
591
592 Note:
593 Large time steps are more efficient than many small steps. The timestep value
594 can be larger than the phyllochron, allowing multiple phytomers to be produced
595 in a single call.
596
597 Example:
598 >>> plantarch.advanceTime(10.0) # Advance 10 days
599 >>> plantarch.advanceTime(0.5) # Advance 12 hours
600 """
601 # Validate time step (allow zero)
602 if dt < 0:
603 raise ValueError(f"Time step must be non-negative, got {dt}")
605 try:
607 plantarch_wrapper.advanceTime(self._plantarch_ptr, dt)
608 except Exception as e:
609 raise PlantArchitectureError(f"Failed to advance time by {dt} days: {e}")
610
611 def getCurrentShootParameters(self, shoot_type_label: str) -> dict:
612 """
613 Get current shoot parameters for a shoot type.
614
615 Returns a nested dictionary containing all shoot and phytomer parameters
616 including RandomParameter specifications with distribution types.
617
618 Args:
619 shoot_type_label: Label for the shoot type (e.g., "stem", "branch")
620
621 Returns:
622 Dictionary with shoot parameters including:
623 - Geometric parameters (max_nodes, insertion_angle_tip, etc.)
624 - Growth parameters (phyllochron_min, elongation_rate_max, etc.)
625 - Boolean flags (flowers_require_dormancy, etc.)
626 - RandomParameter fields include 'distribution' and 'parameters' keys
627
628 Raises:
629 ValueError: If shoot_type_label is empty
630 PlantArchitectureError: If parameter retrieval fails
631
632 Example:
633 >>> plantarch.loadPlantModelFromLibrary("bean")
634 >>> params = plantarch.getCurrentShootParameters("stem")
635 >>> print(params['max_nodes'])
636 {'distribution': 'constant', 'parameters': [15.0]}
637 """
638 if not shoot_type_label:
639 raise ValueError("Shoot type label cannot be empty")
640
641 if not shoot_type_label.strip():
642 raise ValueError("Shoot type label cannot be only whitespace")
643
644 try:
646 return plantarch_wrapper.getCurrentShootParameters(
647 self._plantarch_ptr, shoot_type_label.strip()
648 )
649 except Exception as e:
650 raise PlantArchitectureError(f"Failed to get shoot parameters for '{shoot_type_label}': {e}")
651
652 def defineShootType(self, shoot_type_label: str, parameters: dict) -> None:
653 """
654 Define a custom shoot type with specified parameters.
655
656 Allows creating new shoot types or modifying existing ones by providing
657 a parameter dictionary. Use getCurrentShootParameters() to get a template
658 that can be modified.
659
660 Args:
661 shoot_type_label: Unique name for this shoot type
662 parameters: Dictionary matching ShootParameters structure.
663 Use getCurrentShootParameters() to get proper structure.
664
665 Raises:
666 ValueError: If shoot_type_label is empty or parameters is not a dict
667 PlantArchitectureError: If shoot type definition fails
668
669 Example:
670 >>> # Get existing parameters as template
671 >>> plantarch.loadPlantModelFromLibrary("bean")
672 >>> params = plantarch.getCurrentShootParameters("stem")
673 >>>
674 >>> # Modify parameters
675 >>> params['max_nodes'] = {'distribution': 'constant', 'parameters': [20.0]}
676 >>> params['insertion_angle_tip'] = {'distribution': 'uniform', 'parameters': [40.0, 50.0]}
677 >>>
678 >>> # Define new shoot type
679 >>> plantarch.defineShootType("TallStem", params)
680 """
681 if not shoot_type_label:
682 raise ValueError("Shoot type label cannot be empty")
683
684 if not shoot_type_label.strip():
685 raise ValueError("Shoot type label cannot be only whitespace")
686
687 if not isinstance(parameters, dict):
688 raise ValueError("Parameters must be a dict")
689
690 try:
692 plantarch_wrapper.defineShootType(
693 self._plantarch_ptr, self.context.context, shoot_type_label.strip(), parameters
694 )
695 except Exception as e:
696 raise PlantArchitectureError(f"Failed to define shoot type '{shoot_type_label}': {e}")
697
698 def getAvailablePlantModels(self) -> List[str]:
699 """
700 Get list of all available plant models in the library.
701
702 Returns:
703 List of plant model names available for loading
704
705 Raises:
706 PlantArchitectureError: If retrieval fails
707
708 Example:
709 >>> models = plantarch.getAvailablePlantModels()
710 >>> print(f"Available models: {', '.join(models)}")
711 Available models: almond, apple, bean, cowpea, maize, rice, soybean, tomato, wheat, ...
712 """
713 try:
715 return plantarch_wrapper.getAvailablePlantModels(self._plantarch_ptr)
716 except Exception as e:
717 raise PlantArchitectureError(f"Failed to get available plant models: {e}")
718
719 def getAllPlantObjectIDs(self, plant_id: int) -> List[int]:
720 """
721 Get all object IDs for a specific plant.
722
723 Args:
724 plant_id: ID of the plant instance
725
726 Returns:
727 List of object IDs comprising the plant
728
729 Raises:
730 ValueError: If plant_id is negative
731 PlantArchitectureError: If retrieval fails
732
733 Example:
734 >>> object_ids = plantarch.getAllPlantObjectIDs(plant_id)
735 >>> print(f"Plant has {len(object_ids)} objects")
736 """
737 if plant_id < 0:
738 raise ValueError("Plant ID must be non-negative")
739
740 try:
741 return plantarch_wrapper.getAllPlantObjectIDs(self._plantarch_ptr, plant_id)
742 except Exception as e:
743 raise PlantArchitectureError(f"Failed to get object IDs for plant {plant_id}: {e}")
744
745 def getAllPlantUUIDs(self, plant_id: int) -> List[int]:
746 """
747 Get all primitive UUIDs for a specific plant.
748
749 Args:
750 plant_id: ID of the plant instance
751
752 Returns:
753 List of primitive UUIDs comprising the plant
754
755 Raises:
756 ValueError: If plant_id is negative
757 PlantArchitectureError: If retrieval fails
758
759 Example:
760 >>> uuids = plantarch.getAllPlantUUIDs(plant_id)
761 >>> print(f"Plant has {len(uuids)} primitives")
762 """
763 if plant_id < 0:
764 raise ValueError("Plant ID must be non-negative")
765
766 try:
767 return plantarch_wrapper.getAllPlantUUIDs(self._plantarch_ptr, plant_id)
768 except Exception as e:
769 raise PlantArchitectureError(f"Failed to get UUIDs for plant {plant_id}: {e}")
770
771 def getPlantAge(self, plant_id: int) -> float:
772 """
773 Get the current age of a plant in days.
774
775 Args:
776 plant_id: ID of the plant instance
777
778 Returns:
779 Plant age in days
780
781 Raises:
782 ValueError: If plant_id is negative
783 PlantArchitectureError: If retrieval fails
784
785 Example:
786 >>> age = plantarch.getPlantAge(plant_id)
787 >>> print(f"Plant is {age} days old")
788 """
789 if plant_id < 0:
790 raise ValueError("Plant ID must be non-negative")
791
792 try:
794 return plantarch_wrapper.getPlantAge(self._plantarch_ptr, plant_id)
795 except Exception as e:
796 raise PlantArchitectureError(f"Failed to get age for plant {plant_id}: {e}")
797
798 def getPlantHeight(self, plant_id: int) -> float:
799 """
800 Get the height of a plant in meters.
801
802 Args:
803 plant_id: ID of the plant instance
804
805 Returns:
806 Plant height in meters (vertical extent)
807
808 Raises:
809 ValueError: If plant_id is negative
810 PlantArchitectureError: If retrieval fails
811
812 Example:
813 >>> height = plantarch.getPlantHeight(plant_id)
814 >>> print(f"Plant is {height:.2f}m tall")
815 """
816 if plant_id < 0:
817 raise ValueError("Plant ID must be non-negative")
818
819 try:
821 return plantarch_wrapper.getPlantHeight(self._plantarch_ptr, plant_id)
822 except Exception as e:
823 raise PlantArchitectureError(f"Failed to get height for plant {plant_id}: {e}")
824
825 def getPlantLeafArea(self, plant_id: int) -> float:
826 """
827 Get the total leaf area of a plant in m².
828
829 Args:
830 plant_id: ID of the plant instance
831
832 Returns:
833 Total leaf area in square meters
834
835 Raises:
836 ValueError: If plant_id is negative
837 PlantArchitectureError: If retrieval fails
838
839 Example:
840 >>> leaf_area = plantarch.getPlantLeafArea(plant_id)
841 >>> print(f"Total leaf area: {leaf_area:.3f} m²")
842 """
843 if plant_id < 0:
844 raise ValueError("Plant ID must be non-negative")
845
846 try:
848 return plantarch_wrapper.sumPlantLeafArea(self._plantarch_ptr, plant_id)
849 except Exception as e:
850 raise PlantArchitectureError(f"Failed to get leaf area for plant {plant_id}: {e}")
851
853 self,
854 plant_id: int,
855 time_to_dormancy_break: float,
856 time_to_flower_initiation: float,
857 time_to_flower_opening: float,
858 time_to_fruit_set: float,
859 time_to_fruit_maturity: float,
860 time_to_dormancy: float,
861 max_leaf_lifespan: float = 1e6
862 ) -> None:
863 """
864 Set phenological timing thresholds for plant developmental stages.
865
866 Controls the timing of key phenological events based on thermal time
867 or calendar time depending on the plant model.
868
869 Args:
870 plant_id: ID of the plant instance
871 time_to_dormancy_break: Degree-days or days until dormancy ends
872 time_to_flower_initiation: Time until flower buds are initiated
873 time_to_flower_opening: Time until flowers open
874 time_to_fruit_set: Time until fruit begins developing
875 time_to_fruit_maturity: Time until fruit reaches maturity
876 time_to_dormancy: Time until plant enters dormancy
877 max_leaf_lifespan: Maximum leaf lifespan in days (default: 1e6)
878
879 Raises:
880 ValueError: If plant_id is negative
881 PlantArchitectureError: If phenology setting fails
882
883 Example:
884 >>> # Set phenology for perennial fruit tree
885 >>> plantarch.setPlantPhenologicalThresholds(
886 ... plant_id=plant_id,
887 ... time_to_dormancy_break=60, # Spring: 60 degree-days
888 ... time_to_flower_initiation=90, # Early spring flowering
889 ... time_to_flower_opening=105, # Bloom period
890 ... time_to_fruit_set=120, # Fruit set after pollination
891 ... time_to_fruit_maturity=200, # Summer fruit maturation
892 ... time_to_dormancy=280, # Fall dormancy
893 ... max_leaf_lifespan=180 # Deciduous - 6 month leaf life
894 ... )
895 """
896 if plant_id < 0:
897 raise ValueError("Plant ID must be non-negative")
898
899 try:
901 plantarch_wrapper.setPlantPhenologicalThresholds(
902 self._plantarch_ptr,
903 plant_id,
904 time_to_dormancy_break,
905 time_to_flower_initiation,
906 time_to_flower_opening,
907 time_to_fruit_set,
908 time_to_fruit_maturity,
909 time_to_dormancy,
910 max_leaf_lifespan
911 )
912 except Exception as e:
913 raise PlantArchitectureError(f"Failed to set phenological thresholds for plant {plant_id}: {e}")
914
915 # Collision detection methods
917 target_object_UUIDs: Optional[List[int]] = None,
918 target_object_IDs: Optional[List[int]] = None,
919 enable_petiole_collision: bool = False,
920 enable_fruit_collision: bool = False) -> None:
921 """
922 Enable soft collision avoidance for procedural plant growth.
923
924 This method enables the collision detection system that guides plant growth away from
925 obstacles and other plants. The system uses cone-based gap detection to find optimal
926 growth directions that minimize collisions while maintaining natural plant architecture.
927
928 Args:
929 target_object_UUIDs: List of primitive UUIDs to avoid collisions with. If empty,
930 avoids all geometry in the context.
931 target_object_IDs: List of compound object IDs to avoid collisions with.
932 enable_petiole_collision: Enable collision detection for leaf petioles
933 enable_fruit_collision: Enable collision detection for fruit organs
934
935 Raises:
936 PlantArchitectureError: If collision detection activation fails
937
938 Note:
939 Collision detection adds computational overhead. Use setStaticObstacles() to mark
940 static geometry for BVH optimization and improved performance.
941
942 Example:
943 >>> # Avoid all geometry
944 >>> plantarch.enableSoftCollisionAvoidance()
945 >>>
946 >>> # Avoid specific obstacles
947 >>> obstacle_uuids = context.getAllUUIDs()
948 >>> plantarch.enableSoftCollisionAvoidance(target_object_UUIDs=obstacle_uuids)
949 >>>
950 >>> # Enable collision detection for petioles and fruit
951 >>> plantarch.enableSoftCollisionAvoidance(
952 ... enable_petiole_collision=True,
953 ... enable_fruit_collision=True
954 ... )
955 """
956 try:
958 plantarch_wrapper.enableSoftCollisionAvoidance(
959 self._plantarch_ptr,
960 target_UUIDs=target_object_UUIDs,
961 target_IDs=target_object_IDs,
962 enable_petiole=enable_petiole_collision,
963 enable_fruit=enable_fruit_collision
964 )
965 except Exception as e:
966 raise PlantArchitectureError(f"Failed to enable soft collision avoidance: {e}")
967
968 def disableCollisionDetection(self) -> None:
969 """
970 Disable collision detection for plant growth.
971
972 This method turns off the collision detection system, allowing plants to grow
973 without checking for obstacles. This improves performance but plants may grow
974 through obstacles and other geometry.
975
976 Raises:
977 PlantArchitectureError: If disabling fails
978
979 Example:
980 >>> plantarch.disableCollisionDetection()
981 """
982 try:
983 plantarch_wrapper.disableCollisionDetection(self._plantarch_ptr)
984 except Exception as e:
985 raise PlantArchitectureError(f"Failed to disable collision detection: {e}")
986
988 view_half_angle_deg: float = 80.0,
989 look_ahead_distance: float = 0.1,
990 sample_count: int = 256,
991 inertia_weight: float = 0.4) -> None:
992 """
993 Configure parameters for soft collision avoidance algorithm.
994
995 These parameters control the cone-based gap detection algorithm that guides
996 plant growth away from obstacles. Adjusting these values allows fine-tuning
997 the balance between collision avoidance and natural growth patterns.
998
999 Args:
1000 view_half_angle_deg: Half-angle of detection cone in degrees (0-180).
1001 Default 80° provides wide field of view.
1002 look_ahead_distance: Distance to look ahead for collisions in meters.
1003 Larger values detect distant obstacles. Default 0.1m.
1004 sample_count: Number of ray samples within cone. More samples improve
1005 accuracy but reduce performance. Default 256.
1006 inertia_weight: Weight for previous growth direction (0-1). Higher values
1007 make growth smoother but less responsive. Default 0.4.
1008
1009 Raises:
1010 ValueError: If parameters are outside valid ranges
1011 PlantArchitectureError: If parameter setting fails
1012
1013 Example:
1014 >>> # Use default parameters (recommended)
1015 >>> plantarch.setSoftCollisionAvoidanceParameters()
1016 >>>
1017 >>> # Tune for dense canopy with close obstacles
1018 >>> plantarch.setSoftCollisionAvoidanceParameters(
1019 ... view_half_angle_deg=60.0, # Narrower detection cone
1020 ... look_ahead_distance=0.05, # Shorter look-ahead
1021 ... sample_count=512, # More accurate detection
1022 ... inertia_weight=0.3 # More responsive to obstacles
1023 ... )
1024 """
1025 # Validate parameters
1026 if not (0 <= view_half_angle_deg <= 180):
1027 raise ValueError(f"view_half_angle_deg must be between 0 and 180, got {view_half_angle_deg}")
1028 if look_ahead_distance <= 0:
1029 raise ValueError(f"look_ahead_distance must be positive, got {look_ahead_distance}")
1030 if sample_count <= 0:
1031 raise ValueError(f"sample_count must be positive, got {sample_count}")
1032 if not (0 <= inertia_weight <= 1):
1033 raise ValueError(f"inertia_weight must be between 0 and 1, got {inertia_weight}")
1034
1035 try:
1036 plantarch_wrapper.setSoftCollisionAvoidanceParameters(
1037 self._plantarch_ptr,
1038 view_half_angle_deg,
1039 look_ahead_distance,
1040 sample_count,
1041 inertia_weight
1042 )
1043 except Exception as e:
1044 raise PlantArchitectureError(f"Failed to set collision avoidance parameters: {e}")
1045
1047 include_internodes: bool = False,
1048 include_leaves: bool = True,
1049 include_petioles: bool = False,
1050 include_flowers: bool = False,
1051 include_fruit: bool = False) -> None:
1052 """
1053 Specify which plant organs participate in collision detection.
1054
1055 This method allows filtering which organs are considered during collision detection,
1056 enabling optimization by excluding organs unlikely to cause problematic collisions.
1057
1058 Args:
1059 include_internodes: Include stem internodes in collision detection
1060 include_leaves: Include leaf blades in collision detection
1061 include_petioles: Include leaf petioles in collision detection
1062 include_flowers: Include flowers in collision detection
1063 include_fruit: Include fruit in collision detection
1064
1065 Raises:
1066 PlantArchitectureError: If organ filtering fails
1067
1068 Example:
1069 >>> # Only detect collisions for stems and leaves (default behavior)
1070 >>> plantarch.setCollisionRelevantOrgans(
1071 ... include_internodes=True,
1072 ... include_leaves=True
1073 ... )
1074 >>>
1075 >>> # Include all organs
1076 >>> plantarch.setCollisionRelevantOrgans(
1077 ... include_internodes=True,
1078 ... include_leaves=True,
1079 ... include_petioles=True,
1080 ... include_flowers=True,
1081 ... include_fruit=True
1082 ... )
1083 """
1084 try:
1085 plantarch_wrapper.setCollisionRelevantOrgans(
1086 self._plantarch_ptr,
1087 include_internodes,
1088 include_leaves,
1089 include_petioles,
1090 include_flowers,
1091 include_fruit
1092 )
1093 except Exception as e:
1094 raise PlantArchitectureError(f"Failed to set collision-relevant organs: {e}")
1095
1097 obstacle_UUIDs: List[int],
1098 avoidance_distance: float = 0.5,
1099 enable_fruit_adjustment: bool = False,
1100 enable_obstacle_pruning: bool = False) -> None:
1101 """
1102 Enable hard obstacle avoidance for specified geometry.
1103
1104 This method configures solid obstacles that plants cannot grow through. Unlike soft
1105 collision avoidance (which guides growth), solid obstacles cause complete growth
1106 termination when encountered within the avoidance distance.
1107
1108 Args:
1109 obstacle_UUIDs: List of primitive UUIDs representing solid obstacles
1110 avoidance_distance: Minimum distance to maintain from obstacles (meters).
1111 Growth stops if obstacles are closer. Default 0.5m.
1112 enable_fruit_adjustment: Adjust fruit positions away from obstacles
1113 enable_obstacle_pruning: Remove plant organs that penetrate obstacles
1114
1115 Raises:
1116 ValueError: If obstacle_UUIDs is empty or avoidance_distance is non-positive
1117 PlantArchitectureError: If solid obstacle configuration fails
1118
1119 Example:
1120 >>> # Simple solid obstacle avoidance
1121 >>> wall_uuids = [1, 2, 3, 4] # UUIDs of wall primitives
1122 >>> plantarch.enableSolidObstacleAvoidance(wall_uuids)
1123 >>>
1124 >>> # Close avoidance with fruit adjustment
1125 >>> plantarch.enableSolidObstacleAvoidance(
1126 ... obstacle_UUIDs=wall_uuids,
1127 ... avoidance_distance=0.1,
1128 ... enable_fruit_adjustment=True
1129 ... )
1130 """
1131 if not obstacle_UUIDs:
1132 raise ValueError("Obstacle UUIDs list cannot be empty")
1133 if avoidance_distance <= 0:
1134 raise ValueError(f"avoidance_distance must be positive, got {avoidance_distance}")
1135
1136 try:
1138 plantarch_wrapper.enableSolidObstacleAvoidance(
1139 self._plantarch_ptr,
1140 obstacle_UUIDs,
1141 avoidance_distance,
1142 enable_fruit_adjustment,
1143 enable_obstacle_pruning
1144 )
1145 except Exception as e:
1146 raise PlantArchitectureError(f"Failed to enable solid obstacle avoidance: {e}")
1147
1148 def setStaticObstacles(self, target_UUIDs: List[int]) -> None:
1149 """
1150 Mark geometry as static obstacles for collision detection optimization.
1151
1152 This method tells the collision detection system that certain geometry will not
1153 move during the simulation. The system can then build an optimized Bounding Volume
1154 Hierarchy (BVH) for these obstacles, significantly improving collision detection
1155 performance in scenes with many static obstacles.
1156
1157 Args:
1158 target_UUIDs: List of primitive UUIDs representing static obstacles
1159
1160 Raises:
1161 ValueError: If target_UUIDs is empty
1162 PlantArchitectureError: If static obstacle configuration fails
1163
1164 Note:
1165 Call this method BEFORE enabling collision avoidance for best performance.
1166 Static obstacles cannot be modified or moved after being marked static.
1167
1168 Example:
1169 >>> # Mark ground and building geometry as static
1170 >>> static_uuids = ground_uuids + building_uuids
1171 >>> plantarch.setStaticObstacles(static_uuids)
1172 >>> # Now enable collision avoidance
1173 >>> plantarch.enableSoftCollisionAvoidance()
1174 """
1175 if not target_UUIDs:
1176 raise ValueError("target_UUIDs list cannot be empty")
1177
1178 try:
1180 plantarch_wrapper.setStaticObstacles(self._plantarch_ptr, target_UUIDs)
1181 except Exception as e:
1182 raise PlantArchitectureError(f"Failed to set static obstacles: {e}")
1183
1184 def getPlantCollisionRelevantObjectIDs(self, plant_id: int) -> List[int]:
1185 """
1186 Get object IDs of collision-relevant geometry for a specific plant.
1187
1188 This method returns the subset of plant geometry that participates in collision
1189 detection, as filtered by setCollisionRelevantOrgans(). Useful for visualization
1190 and debugging collision detection behavior.
1191
1192 Args:
1193 plant_id: ID of the plant instance
1194
1195 Returns:
1196 List of object IDs for collision-relevant plant geometry
1197
1198 Raises:
1199 ValueError: If plant_id is negative
1200 PlantArchitectureError: If retrieval fails
1201
1202 Example:
1203 >>> # Get collision-relevant geometry
1204 >>> collision_obj_ids = plantarch.getPlantCollisionRelevantObjectIDs(plant_id)
1205 >>> print(f"Plant has {len(collision_obj_ids)} collision-relevant objects")
1206 >>>
1207 >>> # Highlight collision geometry in visualization
1208 >>> for obj_id in collision_obj_ids:
1209 ... context.setObjectColor(obj_id, RGBcolor(1, 0, 0)) # Red
1210 """
1211 if plant_id < 0:
1212 raise ValueError("Plant ID must be non-negative")
1213
1214 try:
1215 return plantarch_wrapper.getPlantCollisionRelevantObjectIDs(self._plantarch_ptr, plant_id)
1216 except Exception as e:
1217 raise PlantArchitectureError(f"Failed to get collision-relevant object IDs for plant {plant_id}: {e}")
1218
1219 # File I/O methods
1220 def writePlantMeshVertices(self, plant_id: int, filename: Union[str, Path]) -> None:
1221 """
1222 Write all plant mesh vertices to file for external processing.
1223
1224 This method exports all vertex coordinates (x,y,z) for every primitive in the plant,
1225 writing one vertex per line. Useful for external processing such as computing bounding
1226 volumes, convex hulls, or performing custom geometric analysis.
1227
1228 Args:
1229 plant_id: ID of the plant instance to export
1230 filename: Path to output file (absolute or relative to current working directory)
1231
1232 Raises:
1233 ValueError: If plant_id is negative or filename is empty
1234 PlantArchitectureError: If plant doesn't exist or file cannot be written
1235
1236 Example:
1237 >>> # Export vertices for convex hull analysis
1238 >>> plantarch.writePlantMeshVertices(plant_id, "plant_vertices.txt")
1239 >>>
1240 >>> # Use with Path object
1241 >>> from pathlib import Path
1242 >>> output_dir = Path("output")
1243 >>> output_dir.mkdir(exist_ok=True)
1244 >>> plantarch.writePlantMeshVertices(plant_id, output_dir / "vertices.txt")
1245 """
1246 if plant_id < 0:
1247 raise ValueError("Plant ID must be non-negative")
1248 if not filename:
1249 raise ValueError("Filename cannot be empty")
1250
1251 # Resolve path before changing directory
1252 absolute_path = _resolve_user_path(filename)
1253
1254 try:
1256 plantarch_wrapper.writePlantMeshVertices(
1257 self._plantarch_ptr, plant_id, absolute_path
1258 )
1259 except Exception as e:
1260 raise PlantArchitectureError(f"Failed to write plant mesh vertices to {filename}: {e}")
1261
1262 def writePlantStructureXML(self, plant_id: int, filename: Union[str, Path]) -> None:
1263 """
1264 Save plant structure to XML file for later loading.
1265
1266 This method exports the complete plant architecture to an XML file, including
1267 all shoots, phytomers, organs, and their properties. The saved plant can be
1268 reloaded later using readPlantStructureXML().
1269
1270 Args:
1271 plant_id: ID of the plant instance to save
1272 filename: Path to output XML file (absolute or relative to current working directory)
1273
1274 Raises:
1275 ValueError: If plant_id is negative or filename is empty
1276 PlantArchitectureError: If plant doesn't exist or file cannot be written
1277
1278 Note:
1279 The XML format preserves the complete plant state including:
1280 - Shoot structure and hierarchy
1281 - Phytomer properties and development stage
1282 - Organ geometry and attributes
1283 - Growth parameters and phenological state
1284
1285 Example:
1286 >>> # Save plant at current growth stage
1287 >>> plantarch.writePlantStructureXML(plant_id, "bean_day30.xml")
1288 >>>
1289 >>> # Later, reload the saved plant
1290 >>> loaded_plant_ids = plantarch.readPlantStructureXML("bean_day30.xml")
1291 >>> print(f"Loaded {len(loaded_plant_ids)} plants")
1292 """
1293 if plant_id < 0:
1294 raise ValueError("Plant ID must be non-negative")
1295 if not filename:
1296 raise ValueError("Filename cannot be empty")
1297
1298 # Resolve path before changing directory
1299 absolute_path = _resolve_user_path(filename)
1300
1301 try:
1303 plantarch_wrapper.writePlantStructureXML(
1304 self._plantarch_ptr, plant_id, absolute_path
1305 )
1306 except Exception as e:
1307 raise PlantArchitectureError(f"Failed to write plant structure XML to {filename}: {e}")
1308
1309 def writeQSMCylinderFile(self, plant_id: int, filename: Union[str, Path]) -> None:
1310 """
1311 Export plant structure in TreeQSM cylinder format.
1312
1313 This method writes the plant structure as a series of cylinders following the
1314 TreeQSM format (Raumonen et al., 2013). Each row represents one cylinder with
1315 columns for radius, length, start position, axis direction, branch topology,
1316 and other structural properties. Useful for biomechanical analysis and
1317 quantitative structure modeling.
1318
1319 Args:
1320 plant_id: ID of the plant instance to export
1321 filename: Path to output file (absolute or relative, typically .txt extension)
1322
1323 Raises:
1324 ValueError: If plant_id is negative or filename is empty
1325 PlantArchitectureError: If plant doesn't exist or file cannot be written
1326
1327 Note:
1328 The TreeQSM format includes columns for:
1329 - Cylinder dimensions (radius, length)
1330 - Spatial position and orientation
1331 - Branch topology (parent ID, extension ID, branch ID)
1332 - Branch hierarchy (branch order, position in branch)
1333 - Quality metrics (mean absolute distance, surface coverage)
1334
1335 Example:
1336 >>> # Export for biomechanical analysis
1337 >>> plantarch.writeQSMCylinderFile(plant_id, "tree_structure_qsm.txt")
1338 >>>
1339 >>> # Use with external QSM tools
1340 >>> import pandas as pd
1341 >>> qsm_data = pd.read_csv("tree_structure_qsm.txt", sep="\\t")
1342 >>> print(f"Tree has {len(qsm_data)} cylinders")
1343
1344 References:
1345 Raumonen et al. (2013) "Fast Automatic Precision Tree Models from
1346 Terrestrial Laser Scanner Data" Remote Sensing 5(2):491-520
1347 """
1348 if plant_id < 0:
1349 raise ValueError("Plant ID must be non-negative")
1350 if not filename:
1351 raise ValueError("Filename cannot be empty")
1352
1353 # Resolve path before changing directory
1354 absolute_path = _resolve_user_path(filename)
1355
1356 try:
1358 plantarch_wrapper.writeQSMCylinderFile(
1359 self._plantarch_ptr, plant_id, absolute_path
1360 )
1361 except Exception as e:
1362 raise PlantArchitectureError(f"Failed to write QSM cylinder file to {filename}: {e}")
1363
1364 def readPlantStructureXML(self, filename: Union[str, Path], quiet: bool = False) -> List[int]:
1365 """
1366 Load plant structure from XML file.
1367
1368 This method reads plant architecture data from an XML file previously saved with
1369 writePlantStructureXML(). The loaded plants are added to the current context
1370 and can be grown, modified, or analyzed like any other plants.
1371
1372 Args:
1373 filename: Path to XML file to load (absolute or relative to current working directory)
1374 quiet: If True, suppress console output during loading (default: False)
1375
1376 Returns:
1377 List of plant IDs for the loaded plant instances
1378
1379 Raises:
1380 ValueError: If filename is empty
1381 PlantArchitectureError: If file doesn't exist, cannot be parsed, or loading fails
1382
1383 Note:
1384 The XML file can contain multiple plant instances. All plants in the file
1385 will be loaded and their IDs returned in a list. Plant models referenced
1386 in the XML must be available in the plant library.
1387
1388 Example:
1389 >>> # Load previously saved plants
1390 >>> plant_ids = plantarch.readPlantStructureXML("saved_canopy.xml")
1391 >>> print(f"Loaded {len(plant_ids)} plants")
1392 >>>
1393 >>> # Continue growing the loaded plants
1394 >>> plantarch.advanceTime(10.0)
1395 >>>
1396 >>> # Load quietly without console messages
1397 >>> plant_ids = plantarch.readPlantStructureXML("bean_day45.xml", quiet=True)
1398 """
1399 if not filename:
1400 raise ValueError("Filename cannot be empty")
1401
1402 # Resolve path before changing directory
1403 absolute_path = _resolve_user_path(filename)
1404
1405 try:
1407 return plantarch_wrapper.readPlantStructureXML(
1408 self._plantarch_ptr, absolute_path, quiet
1409 )
1410 except Exception as e:
1411 raise PlantArchitectureError(f"Failed to read plant structure XML from {filename}: {e}")
1412
1413 # Custom plant building methods
1414 def addPlantInstance(self, base_position: vec3, current_age: float) -> int:
1415 """
1416 Create an empty plant instance for custom plant building.
1417
1418 This method creates a new plant instance at the specified location without any
1419 shoots or organs. Use addBaseStemShoot(), appendShoot(), and addChildShoot() to
1420 manually construct the plant structure. This provides low-level control over
1421 plant architecture, enabling custom morphologies not available in the plant library.
1422
1423 Args:
1424 base_position: Cartesian (x,y,z) coordinates of plant base as vec3
1425 current_age: Current age of the plant in days (must be >= 0)
1426
1427 Returns:
1428 Plant ID for the created plant instance
1429
1430 Raises:
1431 ValueError: If age is negative
1432 PlantArchitectureError: If plant creation fails
1433
1434 Example:
1435 >>> # Create empty plant at origin
1436 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1437 >>>
1438 >>> # Now add shoots to build custom plant structure
1439 >>> shoot_id = plantarch.addBaseStemShoot(
1440 ... plant_id, 1, AxisRotation(0, 0, 0), 0.01, 0.1, 1.0, 1.0, 0.8, "mainstem"
1441 ... )
1442 """
1443 # Convert position to list for C++ interface
1444 position_list = [base_position.x, base_position.y, base_position.z]
1445
1446 # Validate age
1447 if current_age < 0:
1448 raise ValueError(f"Age must be non-negative, got {current_age}")
1449
1450 try:
1452 return plantarch_wrapper.addPlantInstance(
1453 self._plantarch_ptr, position_list, current_age
1454 )
1455 except Exception as e:
1456 raise PlantArchitectureError(f"Failed to add plant instance: {e}")
1457
1458 def deletePlantInstance(self, plant_id: int) -> None:
1459 """
1460 Delete a plant instance and all associated geometry.
1461
1462 This method removes a plant from the simulation, deleting all shoots, organs,
1463 and associated primitives from the context. The plant ID becomes invalid after
1464 deletion and should not be used in subsequent operations.
1465
1466 Args:
1467 plant_id: ID of the plant instance to delete
1468
1469 Raises:
1470 ValueError: If plant_id is negative
1471 PlantArchitectureError: If plant deletion fails or plant doesn't exist
1472
1473 Example:
1474 >>> # Delete a plant
1475 >>> plantarch.deletePlantInstance(plant_id)
1476 >>>
1477 >>> # Delete multiple plants
1478 >>> for pid in plant_ids_to_remove:
1479 ... plantarch.deletePlantInstance(pid)
1480 """
1481 if plant_id < 0:
1482 raise ValueError("Plant ID must be non-negative")
1483
1484 try:
1486 plantarch_wrapper.deletePlantInstance(self._plantarch_ptr, plant_id)
1487 except Exception as e:
1488 raise PlantArchitectureError(f"Failed to delete plant instance {plant_id}: {e}")
1489
1490 def addBaseStemShoot(self,
1491 plant_id: int,
1492 current_node_number: int,
1493 base_rotation: AxisRotation,
1494 internode_radius: float,
1495 internode_length_max: float,
1496 internode_length_scale_factor_fraction: float,
1497 leaf_scale_factor_fraction: float,
1498 radius_taper: float,
1499 shoot_type_label: str) -> int:
1500 """
1501 Add a base stem shoot to a plant instance (main trunk/stem).
1502
1503 This method creates the primary shoot originating from the plant base. The base stem
1504 is typically the main trunk or primary stem from which all other shoots branch.
1505 Specify growth parameters to control the shoot's morphology and development.
1506
1507 **IMPORTANT - Shoot Type Requirement**: Shoot types must be defined before use. The standard
1508 workflow is to load a plant model first using loadPlantModelFromLibrary(), which defines
1509 shoot types that can then be used for custom building. The shoot_type_label must match a
1510 shoot type defined in the loaded model.
1511
1512 Args:
1513 plant_id: ID of the plant instance
1514 current_node_number: Starting node number for this shoot (typically 1)
1515 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1516 internode_radius: Base radius of internodes in meters (must be > 0)
1517 internode_length_max: Maximum internode length in meters (must be > 0)
1518 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1519 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1520 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1521 shoot_type_label: Label identifying shoot type - must match a type from loaded model
1522
1523 Returns:
1524 Shoot ID for the created shoot
1525
1526 Raises:
1527 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1528 PlantArchitectureError: If shoot creation fails or shoot type doesn't exist
1529
1530 Example:
1531 >>> from pyhelios import AxisRotation
1532 >>>
1533 >>> # REQUIRED: Load a plant model to define shoot types
1534 >>> plantarch.loadPlantModelFromLibrary("bean")
1535 >>>
1536 >>> # Create empty plant for custom building
1537 >>> plant_id = plantarch.addPlantInstance(vec3(0, 0, 0), 0.0)
1538 >>>
1539 >>> # Add base stem using shoot type from loaded model
1540 >>> shoot_id = plantarch.addBaseStemShoot(
1541 ... plant_id=plant_id,
1542 ... current_node_number=1,
1543 ... base_rotation=AxisRotation(0, 0, 0), # Upright
1544 ... internode_radius=0.01, # 1cm radius
1545 ... internode_length_max=0.1, # 10cm max length
1546 ... internode_length_scale_factor_fraction=1.0,
1547 ... leaf_scale_factor_fraction=1.0,
1548 ... radius_taper=0.9, # Gradual taper
1549 ... shoot_type_label="stem" # Must match loaded model
1550 ... )
1551 """
1552 if plant_id < 0:
1553 raise ValueError("Plant ID must be non-negative")
1554 if current_node_number < 0:
1555 raise ValueError("Current node number must be non-negative")
1556 if internode_radius <= 0:
1557 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
1558 if internode_length_max <= 0:
1559 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
1560 if not shoot_type_label or not shoot_type_label.strip():
1561 raise ValueError("Shoot type label cannot be empty")
1562
1563 # Convert rotation to list for C++ interface
1564 rotation_list = base_rotation.to_list()
1565
1566 try:
1568 return plantarch_wrapper.addBaseStemShoot(
1569 self._plantarch_ptr, plant_id, current_node_number, rotation_list,
1570 internode_radius, internode_length_max,
1571 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1572 radius_taper, shoot_type_label.strip()
1573 )
1574 except Exception as e:
1575 error_msg = str(e)
1576 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
1578 f"Shoot type '{shoot_type_label}' not defined. "
1579 f"Load a plant model first to define shoot types:\n"
1580 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1581 f"Original error: {e}"
1582 )
1583 raise PlantArchitectureError(f"Failed to add base stem shoot: {e}")
1584
1585 def appendShoot(self,
1586 plant_id: int,
1587 parent_shoot_id: int,
1588 current_node_number: int,
1589 base_rotation: AxisRotation,
1590 internode_radius: float,
1591 internode_length_max: float,
1592 internode_length_scale_factor_fraction: float,
1593 leaf_scale_factor_fraction: float,
1594 radius_taper: float,
1595 shoot_type_label: str) -> int:
1596 """
1597 Append a shoot to the end of an existing shoot.
1598
1599 This method extends an existing shoot by appending a new shoot at its terminal bud.
1600 Useful for creating multi-segmented shoots with varying properties along their length,
1601 such as shoots with different growth phases or developmental stages.
1602
1603 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1604 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1605 calling this method.
1606
1607 Args:
1608 plant_id: ID of the plant instance
1609 parent_shoot_id: ID of the parent shoot to extend
1610 current_node_number: Starting node number for this shoot
1611 base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1612 internode_radius: Base radius of internodes in meters (must be > 0)
1613 internode_length_max: Maximum internode length in meters (must be > 0)
1614 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1615 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1616 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1617 shoot_type_label: Label identifying shoot type - must match loaded model
1618
1619 Returns:
1620 Shoot ID for the appended shoot
1621
1622 Raises:
1623 ValueError: If parameters are invalid (negative IDs, non-positive dimensions, empty label)
1624 PlantArchitectureError: If shoot appending fails, parent doesn't exist, or shoot type not defined
1625
1626 Example:
1627 >>> # Load model to define shoot types
1628 >>> plantarch.loadPlantModelFromLibrary("bean")
1629 >>>
1630 >>> # Append shoot with reduced size to simulate apical growth
1631 >>> new_shoot_id = plantarch.appendShoot(
1632 ... plant_id=plant_id,
1633 ... parent_shoot_id=base_shoot_id,
1634 ... current_node_number=10,
1635 ... base_rotation=AxisRotation(0, 0, 0),
1636 ... internode_radius=0.008, # Smaller than base
1637 ... internode_length_max=0.08, # Shorter internodes
1638 ... internode_length_scale_factor_fraction=1.0,
1639 ... leaf_scale_factor_fraction=0.8, # Smaller leaves
1640 ... radius_taper=0.85,
1641 ... shoot_type_label="stem"
1642 ... )
1643 """
1644 if plant_id < 0:
1645 raise ValueError("Plant ID must be non-negative")
1646 if parent_shoot_id < 0:
1647 raise ValueError("Parent shoot ID must be non-negative")
1648 if current_node_number < 0:
1649 raise ValueError("Current node number must be non-negative")
1650 if internode_radius <= 0:
1651 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
1652 if internode_length_max <= 0:
1653 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
1654 if not shoot_type_label or not shoot_type_label.strip():
1655 raise ValueError("Shoot type label cannot be empty")
1656
1657 # Convert rotation to list for C++ interface
1658 rotation_list = base_rotation.to_list()
1659
1660 try:
1662 return plantarch_wrapper.appendShoot(
1663 self._plantarch_ptr, plant_id, parent_shoot_id, current_node_number,
1664 rotation_list, internode_radius, internode_length_max,
1665 internode_length_scale_factor_fraction, leaf_scale_factor_fraction,
1666 radius_taper, shoot_type_label.strip()
1667 )
1668 except Exception as e:
1669 error_msg = str(e)
1670 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
1672 f"Shoot type '{shoot_type_label}' not defined. "
1673 f"Load a plant model first to define shoot types:\n"
1674 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1675 f"Original error: {e}"
1676 )
1677 raise PlantArchitectureError(f"Failed to append shoot: {e}")
1678
1679 def addChildShoot(self,
1680 plant_id: int,
1681 parent_shoot_id: int,
1682 parent_node_index: int,
1683 current_node_number: int,
1684 shoot_base_rotation: AxisRotation,
1685 internode_radius: float,
1686 internode_length_max: float,
1687 internode_length_scale_factor_fraction: float,
1688 leaf_scale_factor_fraction: float,
1689 radius_taper: float,
1690 shoot_type_label: str,
1691 petiole_index: int = 0) -> int:
1692 """
1693 Add a child shoot at an axillary bud position on a parent shoot.
1694
1695 This method creates a lateral branch shoot emerging from a specific node on the
1696 parent shoot. Child shoots enable creation of branching architectures, with control
1697 over branch angle, size, and which petiole position the branch emerges from (for
1698 plants with multiple petioles per node).
1699
1700 **IMPORTANT - Shoot Type Requirement**: The shoot_type_label must match a shoot type
1701 defined in a loaded plant model. Load a model with loadPlantModelFromLibrary() before
1702 calling this method.
1703
1704 Args:
1705 plant_id: ID of the plant instance
1706 parent_shoot_id: ID of the parent shoot
1707 parent_node_index: Index of the parent node where child emerges (0-based)
1708 current_node_number: Starting node number for this child shoot
1709 shoot_base_rotation: Orientation as AxisRotation(pitch, yaw, roll) in degrees
1710 internode_radius: Base radius of child shoot internodes in meters (must be > 0)
1711 internode_length_max: Maximum internode length in meters (must be > 0)
1712 internode_length_scale_factor_fraction: Scale factor for internode length (0-1 typically)
1713 leaf_scale_factor_fraction: Scale factor for leaf size (0-1 typically)
1714 radius_taper: Rate of radius decrease along shoot (0-1, where 1=no taper)
1715 shoot_type_label: Label identifying shoot type - must match loaded model
1716 petiole_index: Which petiole at the node to branch from (default: 0)
1717
1718 Returns:
1719 Shoot ID for the created child shoot
1720
1721 Raises:
1722 ValueError: If parameters are invalid (negative values, non-positive dimensions, empty label)
1723 PlantArchitectureError: If child shoot creation fails, parent doesn't exist, or shoot type not defined
1724
1725 Example:
1726 >>> # Load model to define shoot types
1727 >>> plantarch.loadPlantModelFromLibrary("bean")
1728 >>>
1729 >>> # Add lateral branch at 45-degree angle from node 3
1730 >>> branch_id = plantarch.addChildShoot(
1731 ... plant_id=plant_id,
1732 ... parent_shoot_id=main_shoot_id,
1733 ... parent_node_index=3,
1734 ... current_node_number=1,
1735 ... shoot_base_rotation=AxisRotation(45, 90, 0), # 45° out, 90° rotation
1736 ... internode_radius=0.005, # Thinner than main stem
1737 ... internode_length_max=0.06, # Shorter internodes
1738 ... internode_length_scale_factor_fraction=1.0,
1739 ... leaf_scale_factor_fraction=0.9,
1740 ... radius_taper=0.8,
1741 ... shoot_type_label="stem"
1742 ... )
1743 >>>
1744 >>> # Add second branch from opposite petiole
1745 >>> branch_id2 = plantarch.addChildShoot(
1746 ... plant_id, main_shoot_id, 3, 1, AxisRotation(45, 270, 0),
1747 ... 0.005, 0.06, 1.0, 0.9, 0.8, "stem", petiole_index=1
1748 ... )
1749 """
1750 if plant_id < 0:
1751 raise ValueError("Plant ID must be non-negative")
1752 if parent_shoot_id < 0:
1753 raise ValueError("Parent shoot ID must be non-negative")
1754 if parent_node_index < 0:
1755 raise ValueError("Parent node index must be non-negative")
1756 if current_node_number < 0:
1757 raise ValueError("Current node number must be non-negative")
1758 if internode_radius <= 0:
1759 raise ValueError(f"Internode radius must be positive, got {internode_radius}")
1760 if internode_length_max <= 0:
1761 raise ValueError(f"Internode length max must be positive, got {internode_length_max}")
1762 if not shoot_type_label or not shoot_type_label.strip():
1763 raise ValueError("Shoot type label cannot be empty")
1764 if petiole_index < 0:
1765 raise ValueError(f"Petiole index must be non-negative, got {petiole_index}")
1766
1767 # Convert rotation to list for C++ interface
1768 rotation_list = shoot_base_rotation.to_list()
1769
1770 try:
1772 return plantarch_wrapper.addChildShoot(
1773 self._plantarch_ptr, plant_id, parent_shoot_id, parent_node_index,
1774 current_node_number, rotation_list, internode_radius,
1775 internode_length_max, internode_length_scale_factor_fraction,
1776 leaf_scale_factor_fraction, radius_taper, shoot_type_label.strip(),
1777 petiole_index
1778 )
1779 except Exception as e:
1780 error_msg = str(e)
1781 if "does not exist" in error_msg.lower() and "shoot type" in error_msg.lower():
1783 f"Shoot type '{shoot_type_label}' not defined. "
1784 f"Load a plant model first to define shoot types:\n"
1785 f" plantarch.loadPlantModelFromLibrary('bean') # or other model\n"
1786 f"Original error: {e}"
1787 )
1788 raise PlantArchitectureError(f"Failed to add child shoot: {e}")
1789
1790 def is_available(self) -> bool:
1791 """
1792 Check if PlantArchitecture is available in current build.
1793
1794 Returns:
1795 True if plugin is available, False otherwise
1796 """
1798
1799
1800# Convenience function
1801def create_plant_architecture(context: Context) -> PlantArchitecture:
1802 """
1803 Create PlantArchitecture instance with context.
1804
1805 Args:
1806 context: Helios Context
1807
1808 Returns:
1809 PlantArchitecture instance
1810
1811 Example:
1812 >>> context = Context()
1813 >>> plantarch = create_plant_architecture(context)
1814 """
1815 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.
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 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.
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.
None loadPlantModelFromLibrary(self, str plant_label)
Load a plant model from the built-in library.
None disableCollisionDetection(self)
Disable collision detection for plant growth.
List[int] buildPlantCanopyFromLibrary(self, vec3 canopy_center, vec2 plant_spacing, int2 plant_count, float age, Optional[dict] build_parameters=None)
Build a canopy of regularly spaced plants from the currently loaded library model.
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.
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².
__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...