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