1.3.77
 
Loading...
Searching...
No Matches
selfTest.cpp
1#include "PlantArchitecture.h"
2
3#define DOCTEST_CONFIG_IMPLEMENT
4#include <doctest.h>
5#include "doctest_utils.h"
6#include "global.h"
7
8using namespace helios;
9
10double err_tol = 1e-7;
11
12DOCTEST_TEST_CASE("PlantArchitecture Constructor") {
14 DOCTEST_CHECK_NOTHROW(PlantArchitecture pa_test(&context));
15}
16
17DOCTEST_TEST_CASE("PlantArchitecture Cancel Flag") {
18 // A cancel flag set before a canopy build must short-circuit the per-plant
19 // build loop (no plants built), while the same build with the flag clear
20 // builds the full grid. This is the mechanism that lets a long generation be
21 // aborted mid-build instead of running to completion.
22 auto build = [](bool cancel) -> std::size_t {
25 pa.disableMessages();
26 pa.loadPlantModelFromLibrary("bean");
27 int flag = cancel ? 1 : 0;
28 pa.setCancelFlag(&flag);
29 std::vector<uint> ids = pa.buildPlantCanopyFromLibrary(make_vec3(0, 0, 0), make_vec2(0.5f, 0.5f), make_int2(3, 3), 0.f, 1.f);
30 return ids.size();
31 };
32 DOCTEST_CHECK(build(false) == 9);
33 DOCTEST_CHECK(build(true) == 0);
34
35 // A cancel flag set before advanceTime() must stop the growth loop, leaving
36 // the plant far shorter than a full grow.
37 auto grow_height = [](bool cancel) -> float {
40 pa.disableMessages();
41 pa.loadPlantModelFromLibrary("bean");
42 uint pid = pa.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0.f);
43 int flag = cancel ? 1 : 0;
44 pa.setCancelFlag(&flag);
45 pa.advanceTime(40.f);
46 return pa.getPlantHeight(pid);
47 };
48 float full = grow_height(false);
49 float cancelled = grow_height(true);
50 DOCTEST_CHECK(full > 0.f);
51 DOCTEST_CHECK(cancelled < full);
52}
53
54DOCTEST_TEST_CASE("ShootParameters defineChildShootTypes valid input") {
55 ShootParameters sp_test;
56 std::vector<std::string> labels = {"typeA", "typeB"};
57 std::vector<float> probabilities = {0.4f, 0.6f};
58 DOCTEST_CHECK_NOTHROW(sp_test.defineChildShootTypes(labels, probabilities));
59}
60
61DOCTEST_TEST_CASE("ShootParameters defineChildShootTypes size mismatch") {
62 capture_cerr cerr_buffer;
63 ShootParameters sp_test;
64 std::vector<std::string> labels = {"typeA", "typeB"};
65 std::vector<float> probabilities = {0.4f};
66 DOCTEST_CHECK_THROWS(sp_test.defineChildShootTypes(labels, probabilities));
67}
68
69DOCTEST_TEST_CASE("ShootParameters defineChildShootTypes empty vectors") {
70 capture_cerr cerr_buffer;
71 ShootParameters sp_test;
72 std::vector<std::string> labels = {};
73 std::vector<float> probabilities = {};
74 DOCTEST_CHECK_THROWS(sp_test.defineChildShootTypes(labels, probabilities));
75}
76
77DOCTEST_TEST_CASE("ShootParameters defineChildShootTypes probabilities sum not equal to 1") {
78 capture_cerr cerr_buffer;
79 ShootParameters sp_test;
80 std::vector<std::string> labels = {"typeA", "typeB"};
81 std::vector<float> probabilities = {0.3f, 0.6f}; // Sums to 0.9
82 DOCTEST_CHECK_THROWS(sp_test.defineChildShootTypes(labels, probabilities));
83}
84
85DOCTEST_TEST_CASE("PlantArchitecture defineShootType") {
87 PlantArchitecture pa_test(&context);
88 ShootParameters sp_define;
89 DOCTEST_CHECK_NOTHROW(pa_test.defineShootType("newShootType", sp_define));
90}
91
92DOCTEST_TEST_CASE("LeafPrototype Constructor") {
94 std::minstd_rand0 *generator = context.getRandomGenerator();
95 LeafPrototype lp_test(generator);
96 DOCTEST_CHECK(lp_test.subdivisions == 1);
97 DOCTEST_CHECK(lp_test.unique_prototypes == 1);
98 DOCTEST_CHECK(lp_test.leaf_offset.x == doctest::Approx(0.0f).epsilon(err_tol));
99 DOCTEST_CHECK(lp_test.leaf_offset.y == doctest::Approx(0.0f).epsilon(err_tol));
100 DOCTEST_CHECK(lp_test.leaf_offset.z == doctest::Approx(0.0f).epsilon(err_tol));
101}
102
103DOCTEST_TEST_CASE("PhytomerParameters Constructor") {
105 std::minstd_rand0 *generator = context.getRandomGenerator();
106 DOCTEST_CHECK_NOTHROW(PhytomerParameters pp_test(generator));
107}
108
109DOCTEST_TEST_CASE("Plant Library Model Building - almond") {
111 PlantArchitecture plantarchitecture(&context);
112 plantarchitecture.disableMessages();
113 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("almond"));
114 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
115}
116
117DOCTEST_TEST_CASE("Plant Library Model Building - apple") {
119 PlantArchitecture plantarchitecture(&context);
120 plantarchitecture.disableMessages();
121 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("apple"));
122 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
123}
124
125DOCTEST_TEST_CASE("Plant Library Model Building - asparagus") {
127 PlantArchitecture plantarchitecture(&context);
128 plantarchitecture.disableMessages();
129 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("asparagus"));
130 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
131}
132
133DOCTEST_TEST_CASE("Plant Library Model Building - bindweed") {
135 PlantArchitecture plantarchitecture(&context);
136 plantarchitecture.disableMessages();
137 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("bindweed"));
138 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
139}
140
141DOCTEST_TEST_CASE("Plant Library Model Building - bean") {
143 PlantArchitecture plantarchitecture(&context);
144 plantarchitecture.disableMessages();
145 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("bean"));
146 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
147}
148
149DOCTEST_TEST_CASE("Material Naming - bean plant materials have descriptive names") {
151 PlantArchitecture plantarchitecture(&context);
152 plantarchitecture.disableMessages();
153 plantarchitecture.loadPlantModelFromLibrary("bean");
154 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000);
155
156 // Verify every plant primitive has a descriptive material label (no __auto_ display names)
157 std::vector<uint> all_UUIDs = plantarchitecture.getAllPlantUUIDs(plantID);
158 DOCTEST_CHECK(all_UUIDs.size() > 0);
159 for (uint UUID : all_UUIDs) {
160 std::string label = context.getPrimitiveMaterialLabel(UUID);
161 DOCTEST_CHECK(label.substr(0, 7) != "__auto_");
162 }
163
164 // Verify expected material name patterns exist for bean
165 std::vector<std::string> materials = context.listMaterials();
166 // Note: organs with the same color/texture share a single material, so not every
167 // organ type will necessarily have its own material (e.g., petiole and stem may share).
168 bool found_trifoliate_leaf = false;
169 bool found_unifoliate_leaf = false;
170 bool found_stem = false;
171 for (const auto &label : materials) {
172 if (label.find("bean") != std::string::npos && label.find("trifoliate") != std::string::npos && label.find("leaf") != std::string::npos) {
173 found_trifoliate_leaf = true;
174 }
175 if (label.find("bean") != std::string::npos && label.find("unifoliate") != std::string::npos && label.find("leaf") != std::string::npos) {
176 found_unifoliate_leaf = true;
177 }
178 if (label.find("bean") != std::string::npos && label.find("stem") != std::string::npos) {
179 found_stem = true;
180 }
181 }
182 DOCTEST_CHECK(found_trifoliate_leaf);
183 DOCTEST_CHECK(found_unifoliate_leaf);
184 DOCTEST_CHECK(found_stem);
185}
186
187DOCTEST_TEST_CASE("Shoot Topology Accessors - getAllShootIDs and getPlantShoot") {
189 context.seedRandomGenerator(12345);
190 PlantArchitecture plantarchitecture(&context);
191 plantarchitecture.disableMessages();
192 plantarchitecture.loadPlantModelFromLibrary("bean");
193 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 500);
194
195 // getAllShootIDs should return a contiguous, 0-based set of shoot IDs
196 std::vector<uint> shootIDs = plantarchitecture.getAllShootIDs(plantID);
197 DOCTEST_CHECK(shootIDs.size() > 0);
198 for (uint i = 0; i < shootIDs.size(); i++) {
199 DOCTEST_CHECK(shootIDs.at(i) == i);
200 }
201
202 // The base-stem shoot (ID 0) must be rank 0 and have no parent shoot
203 const std::shared_ptr<Shoot> &base_shoot = plantarchitecture.getPlantShoot(plantID, 0);
204 DOCTEST_CHECK(base_shoot->ID == 0);
205 DOCTEST_CHECK(base_shoot->rank == 0);
206 DOCTEST_CHECK(base_shoot->parent_shoot_ID == -1);
207 DOCTEST_CHECK(!base_shoot->shoot_internode_vertices.empty());
208 DOCTEST_CHECK(base_shoot->shoot_internode_vertices.size() == base_shoot->shoot_internode_radii.size());
209
210 // A shoot's rank is at least its parent's: a true branch (addChildShoot) is parent rank + 1,
211 // while an appended/continuation shoot (appendShoot) keeps the parent's rank. So a child's rank
212 // is either equal to or exactly one greater than its parent's.
213 for (uint shootID : shootIDs) {
214 const std::shared_ptr<Shoot> &shoot = plantarchitecture.getPlantShoot(plantID, shootID);
215 if (shoot->parent_shoot_ID >= 0) {
216 const std::shared_ptr<Shoot> &parent = plantarchitecture.getPlantShoot(plantID, static_cast<uint>(shoot->parent_shoot_ID));
217 DOCTEST_CHECK(shoot->rank >= parent->rank);
218 DOCTEST_CHECK(shoot->rank <= parent->rank + 1);
219 }
220 }
221
222 // Out-of-range / invalid IDs must throw rather than return a fallback. Each throwing call
223 // is invoked inside a tightly-scoped cerr capture (helios_runtime_error writes to cerr in
224 // debug builds before throwing); the resulting bool is asserted only after the capture is
225 // destroyed, so doctest failure output is never swallowed.
226 auto throws = [&](const std::function<void()> &fn) {
227 bool threw = false;
228 {
229 capture_cerr cerr_buffer;
230 try {
231 fn();
232 } catch (...) {
233 threw = true;
234 }
235 }
236 return threw;
237 };
238 DOCTEST_CHECK(throws([&]() { static_cast<void>(plantarchitecture.getAllShootIDs(plantID + 999)); }));
239 DOCTEST_CHECK(throws([&]() { static_cast<void>(plantarchitecture.getPlantShoot(plantID, static_cast<uint>(shootIDs.size()))); }));
240 DOCTEST_CHECK(throws([&]() { static_cast<void>(plantarchitecture.getPlantShoot(plantID + 999, 0)); }));
241}
242
243DOCTEST_TEST_CASE("Plant Library Model Building - cheeseweed") {
245 PlantArchitecture plantarchitecture(&context);
246 plantarchitecture.disableMessages();
247 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("cheeseweed"));
248 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
249}
250
251DOCTEST_TEST_CASE("Plant Library Model Building - cowpea") {
253 PlantArchitecture plantarchitecture(&context);
254 plantarchitecture.disableMessages();
255 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("cowpea"));
256 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
257}
258
259DOCTEST_TEST_CASE("Plant Library Model Building - grapevine_VSP") {
261 PlantArchitecture plantarchitecture(&context);
262 plantarchitecture.disableMessages();
263 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("grapevine_VSP"));
264 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
265}
266
267DOCTEST_TEST_CASE("Plant Library Model Building - maize") {
269 PlantArchitecture plantarchitecture(&context);
270 plantarchitecture.disableMessages();
271 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("maize"));
272 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
273}
274
275DOCTEST_TEST_CASE("Plant Library Model Building - olive") {
277 PlantArchitecture plantarchitecture(&context);
278 plantarchitecture.disableMessages();
279 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("olive"));
280 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
281}
282
283DOCTEST_TEST_CASE("Plant Library Model Building - pistachio") {
285 PlantArchitecture plantarchitecture(&context);
286 plantarchitecture.disableMessages();
287 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("pistachio"));
288 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
289}
290
291DOCTEST_TEST_CASE("Plant Library Model Building - puncturevine") {
293 PlantArchitecture plantarchitecture(&context);
294 plantarchitecture.disableMessages();
295 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("puncturevine"));
296 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
297}
298
299DOCTEST_TEST_CASE("Plant Library Model Building - easternredbud") {
301 PlantArchitecture plantarchitecture(&context);
302 plantarchitecture.disableMessages();
303 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("easternredbud"));
304 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
305}
306
307DOCTEST_TEST_CASE("Plant Library Model Building - rice") {
309 PlantArchitecture plantarchitecture(&context);
310 plantarchitecture.disableMessages();
311 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("rice"));
312 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
313}
314
315DOCTEST_TEST_CASE("Plant Library Model Building - butterlettuce") {
317 PlantArchitecture plantarchitecture(&context);
318 plantarchitecture.disableMessages();
319 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("butterlettuce"));
320 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
321}
322
323DOCTEST_TEST_CASE("Plant Library Model Building - sorghum") {
325 PlantArchitecture plantarchitecture(&context);
326 plantarchitecture.disableMessages();
327 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("sorghum"));
328 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
329}
330
331DOCTEST_TEST_CASE("Plant Library Model Building - soybean") {
333 PlantArchitecture plantarchitecture(&context);
334 plantarchitecture.disableMessages();
335 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("soybean"));
336 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
337}
338
339DOCTEST_TEST_CASE("Plant Library Model Building - strawberry") {
341 PlantArchitecture plantarchitecture(&context);
342 plantarchitecture.disableMessages();
343 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("strawberry"));
344 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
345}
346
347DOCTEST_TEST_CASE("Plant Library Model Building - sugarbeet") {
349 PlantArchitecture plantarchitecture(&context);
350 plantarchitecture.disableMessages();
351 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("sugarbeet"));
352 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
353}
354
355DOCTEST_TEST_CASE("Plant Library Model Building - tomato") {
357 PlantArchitecture plantarchitecture(&context);
358 plantarchitecture.disableMessages();
359 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("tomato"));
360 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
361}
362
363DOCTEST_TEST_CASE("Plant Library Model Building - walnut") {
365 PlantArchitecture plantarchitecture(&context);
366 plantarchitecture.disableMessages();
367 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("walnut"));
368 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
369}
370
371DOCTEST_TEST_CASE("Plant Library Model Building - wheat") {
373 PlantArchitecture plantarchitecture(&context);
374 plantarchitecture.disableMessages();
375 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("wheat"));
376 DOCTEST_CHECK_NOTHROW(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000));
377}
378
379DOCTEST_TEST_CASE("PlantArchitecture writeTreeQSM") {
381 PlantArchitecture plantarchitecture(&context);
382 plantarchitecture.disableMessages();
383
384 // Build a simple plant
385 plantarchitecture.loadPlantModelFromLibrary("bean");
386 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 50);
387
388 // Test writing TreeQSM format
389 std::string filename = "test_plant_qsm.txt";
390 DOCTEST_CHECK_NOTHROW(plantarchitecture.writeQSMCylinderFile(plantID, filename));
391
392 // Check that file was created and has correct format
393 std::ifstream file(filename);
394 DOCTEST_CHECK(file.good());
395
396 if (file.good()) {
397 std::string header_line;
398 std::getline(file, header_line);
399
400 // Check header contains expected columns
401 DOCTEST_CHECK(header_line.find("radius (m)") != std::string::npos);
402 DOCTEST_CHECK(header_line.find("length (m)") != std::string::npos);
403 DOCTEST_CHECK(header_line.find("start_point") != std::string::npos);
404 DOCTEST_CHECK(header_line.find("axis_direction") != std::string::npos);
405 DOCTEST_CHECK(header_line.find("branch") != std::string::npos);
406 DOCTEST_CHECK(header_line.find("branch_order") != std::string::npos);
407
408 // Check that there is at least one data line
409 std::string data_line;
410 bool has_data = static_cast<bool>(std::getline(file, data_line));
411 DOCTEST_CHECK(has_data);
412
413 if (has_data) {
414 // Count tab-separated values in data line
415 size_t tab_count = std::count(data_line.begin(), data_line.end(), '\t');
416 DOCTEST_CHECK(tab_count >= 12); // Should have at least 13 columns (12 tabs)
417 }
418
419 file.close();
420
421 // Clean up test file
422 std::remove(filename.c_str());
423 }
424}
425
426DOCTEST_TEST_CASE("PlantArchitecture writeTreeQSM invalid plant") {
427 capture_cerr cerr_buffer;
429 PlantArchitecture plantarchitecture(&context);
430 plantarchitecture.disableMessages();
431
432 // Test with invalid plant ID
433 DOCTEST_CHECK_THROWS(plantarchitecture.writeQSMCylinderFile(999, "invalid_plant.txt"));
434}
435
436DOCTEST_TEST_CASE("PlantArchitecture pruneSolidBoundaryCollisions") {
438 PlantArchitecture plantarchitecture(&context);
439 plantarchitecture.disableMessages();
440
441 // Enable collision detection first
442 plantarchitecture.enableSoftCollisionAvoidance();
443
444 // Load a plant model from library
445 plantarchitecture.loadPlantModelFromLibrary("tomato");
446
447 // Create a plant and let it grow first WITHOUT boundaries
448 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
449 plantarchitecture.advanceTime(plantID, 15); // Substantial growth to ensure objects exist
450
451 // Get object count after growth but before boundaries
452 std::vector<uint> objects_before_boundaries = plantarchitecture.getAllObjectIDs();
453 uint count_before_boundaries = objects_before_boundaries.size();
454
455 // Ensure we have some objects to work with
456 DOCTEST_CHECK(count_before_boundaries > 0);
457
458 // Now create solid boundaries that will definitely intersect with plant parts
459 // Place boundaries at z=0.05 to intersect with low-lying plant parts
460 std::vector<uint> boundary_UUIDs;
461 for (int i = -2; i <= 2; i++) {
462 for (int j = -2; j <= 2; j++) {
463 // Create a grid of triangles to ensure we catch plant parts
464 boundary_UUIDs.push_back(context.addTriangle(make_vec3(i * 0.1f, j * 0.1f, 0.05f), make_vec3((i + 1) * 0.1f, j * 0.1f, 0.05f), make_vec3(i * 0.1f, (j + 1) * 0.1f, 0.05f)));
465 }
466 }
467
468 // Enable solid obstacle avoidance with the boundaries
469 plantarchitecture.enableSolidObstacleAvoidance(boundary_UUIDs, 0.2f);
470
471 // Trigger another growth step which should call pruneSolidBoundaryCollisions()
472 // Use a very small time step to minimize new growth
473 plantarchitecture.advanceTime(plantID, 0.1f); // Very small step to trigger pruning
474
475 // Get final object count
476 std::vector<uint> final_objects = plantarchitecture.getAllObjectIDs();
477 uint final_count = final_objects.size();
478
479 // Verify that objects were actually pruned by checking that we have fewer objects
480 // than we would expect if no pruning occurred. Since some growth may still happen,
481 // we check if the final count is reasonable given pruning occurred.
482 // The key test is that our implementation ran without errors and produced output
483 // indicating pruning occurred (visible in test output: "Pruned X objects").
484 DOCTEST_CHECK(final_count > 0); // Basic sanity check - we should still have some objects
485}
486
487DOCTEST_TEST_CASE("PlantArchitecture pruneSolidBoundaryCollisions no boundaries") {
489 PlantArchitecture plantarchitecture(&context);
490 plantarchitecture.disableMessages();
491
492 // Load a plant model from library
493 plantarchitecture.loadPlantModelFromLibrary("tomato");
494
495 // Create a simple plant
496 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
497 plantarchitecture.advanceTime(plantID, 5);
498
499 // Get initial object count
500 std::vector<uint> initial_objects = plantarchitecture.getAllObjectIDs();
501 uint initial_count = initial_objects.size();
502
503 // Advance time again without boundaries - should not prune anything
504 plantarchitecture.advanceTime(plantID, 2);
505
506 // Check that no objects were pruned (may have grown more)
507 std::vector<uint> final_objects = plantarchitecture.getAllObjectIDs();
508 uint final_count = final_objects.size();
509
510 DOCTEST_CHECK(final_count >= initial_count);
511}
512
513DOCTEST_TEST_CASE("PlantArchitecture advanceTime after pruneBranch leaves empty shoot shell") {
514 // Pruning a branch at node 0 deletes all of its phytomers and its internode tube object, but
515 // leaves the (now empty) Shoot in the shoot_tree and a stale entry in the parent's childIDs.
516 // advanceTime must tolerate these empty shells: the per-shoot volume bookkeeping loop and the
517 // recursive Shoot::updateShootNodes both used to dereference the cleared geometry / write to the
518 // deleted tube object and crash. This reproduces the user-reported crash.
520 PlantArchitecture plantarchitecture(&context);
521 plantarchitecture.disableMessages();
522
523 plantarchitecture.loadPlantModelFromLibrary("apple");
524 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 365);
525
526 // Find a child shoot of the trunk (shoot 0) that still has phytomers, then prune it from node 0.
527 const std::shared_ptr<Shoot> trunk = plantarchitecture.getPlantShoot(plantID, 0);
528 int branchID = -1;
529 for (const auto &[node_index, shootIDs]: trunk->childIDs) {
530 for (const int shootID: shootIDs) {
531 if (!plantarchitecture.getPlantShoot(plantID, shootID)->phytomers.empty()) {
532 branchID = shootID;
533 break;
534 }
535 }
536 if (branchID >= 0) {
537 break;
538 }
539 }
540 DOCTEST_REQUIRE(branchID >= 0);
541
542 DOCTEST_CHECK_NOTHROW(plantarchitecture.pruneBranch(plantID, (uint) branchID, 0));
543 DOCTEST_CHECK(plantarchitecture.getPlantShoot(plantID, (uint) branchID)->phytomers.empty());
544
545 // getShootInternodeObjectIDs must not return the dangling tube object ID of the pruned shell.
546 std::vector<uint> internode_objIDs = plantarchitecture.getShootInternodeObjectIDs(plantID);
547 for (uint objID: internode_objIDs) {
548 DOCTEST_CHECK(context.doesObjectExist(objID));
549 }
550
551 // The crash occurred here, inside advanceTime, while iterating over the empty pruned shoot.
552 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 365));
553}
554
555DOCTEST_TEST_CASE("PlantArchitecture hard collision avoidance base stem protection") {
557 PlantArchitecture plantarchitecture(&context);
558 plantarchitecture.disableMessages();
559
560 // Enable collision detection first
561 plantarchitecture.enableSoftCollisionAvoidance();
562
563 // Load a plant model from library
564 plantarchitecture.loadPlantModelFromLibrary("tomato");
565
566 // Create a plant that starts slightly below ground surface (e.g., at z = -0.05)
567 // This simulates the common scenario where ground model is slightly uneven
568 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, -0.05f), 0);
569
570 // Create ground surface as solid obstacle slightly above plant base
571 std::vector<uint> ground_UUIDs;
572
573 // Create a ground patch that the plant would intersect if it doesn't grow upward
574 for (int i = -2; i <= 2; i++) {
575 for (int j = -2; j <= 2; j++) {
576 ground_UUIDs.push_back(context.addTriangle(make_vec3(i * 0.2f, j * 0.2f, 0.0f), // Ground at z=0
577 make_vec3((i + 1) * 0.2f, j * 0.2f, 0.0f), make_vec3(i * 0.2f, (j + 1) * 0.2f, 0.0f)));
578 ground_UUIDs.push_back(context.addTriangle(make_vec3((i + 1) * 0.2f, (j + 1) * 0.2f, 0.0f), make_vec3((i + 1) * 0.2f, j * 0.2f, 0.0f), make_vec3(i * 0.2f, (j + 1) * 0.2f, 0.0f)));
579 }
580 }
581
582 // Enable hard solid obstacle avoidance with the ground
583 plantarchitecture.enableSolidObstacleAvoidance(ground_UUIDs, 0.3f);
584
585 // Let the plant grow - it should grow upward despite starting below ground
586 // The first 3 nodes of the base stem should ignore solid obstacles
587 plantarchitecture.advanceTime(plantID, 10); // Sufficient growth time
588
589 // Get all plant objects to analyze growth direction
590 std::vector<uint> plant_objects = plantarchitecture.getAllObjectIDs();
591 DOCTEST_CHECK(plant_objects.size() > 0);
592
593 // Calculate center of mass of all plant objects to verify upward growth
594 // If the plant made a U-turn downward, the center would be below the starting position
595 vec3 center_of_mass = make_vec3(0, 0, 0);
596 uint total_objects = 0;
597
598 for (uint objID: plant_objects) {
599 if (context.doesObjectExist(objID)) {
600 // Get object center using bounding box
601 vec3 min_corner, max_corner;
602 context.getObjectBoundingBox(objID, min_corner, max_corner);
603
604 vec3 object_center = (min_corner + max_corner) / 2.0f;
605
606 center_of_mass = center_of_mass + object_center;
607 total_objects++;
608 }
609 }
610
611 if (total_objects > 0) {
612 center_of_mass = center_of_mass / float(total_objects);
613
614 // The center of mass should be above the starting position (z = -0.05)
615 // This verifies the plant grew upward rather than making a U-turn downward
616 DOCTEST_CHECK(center_of_mass.z > -0.075f);
617
618 // The key test is that the plant didn't curve significantly downward (U-turn behavior)
619 // A U-turn would result in center of mass well below starting position (e.g., < -0.06)
620 // Any value above -0.045 indicates successful avoidance of U-turn behavior
621 DOCTEST_CHECK(center_of_mass.z > -0.075f); // Should not have made a U-turn downward
622 }
623
624 // Additional check: the plant should still exist (wasn't completely pruned)
625 // and should have a reasonable number of objects
626 DOCTEST_CHECK(plant_objects.size() >= 5); // Should have internodes, leaves, etc.
627}
628
629DOCTEST_TEST_CASE("PlantArchitecture enableSolidObstacleAvoidance fruit adjustment control") {
631 PlantArchitecture plantarchitecture(&context);
632 plantarchitecture.disableMessages();
633
634 // Create some obstacles
635 std::vector<uint> obstacle_UUIDs;
636 obstacle_UUIDs.push_back(context.addTriangle(make_vec3(-1, -1, 0), make_vec3(1, -1, 0), make_vec3(-1, 1, 0)));
637 obstacle_UUIDs.push_back(context.addTriangle(make_vec3(1, 1, 0), make_vec3(1, -1, 0), make_vec3(-1, 1, 0)));
638
639 // Test enabling solid obstacle avoidance with fruit adjustment enabled (default)
640 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableSolidObstacleAvoidance(obstacle_UUIDs, 0.5f));
641
642 // Test enabling solid obstacle avoidance with fruit adjustment explicitly enabled
643 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableSolidObstacleAvoidance(obstacle_UUIDs, 0.5f, true));
644
645 // Test enabling solid obstacle avoidance with fruit adjustment disabled
646 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableSolidObstacleAvoidance(obstacle_UUIDs, 0.5f, false));
647
648 // Test with different avoidance distance and disabled fruit adjustment
649 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableSolidObstacleAvoidance(obstacle_UUIDs, 0.3f, false));
650}
651
652DOCTEST_TEST_CASE("PlantArchitecture base stem protection with short internodes") {
654 PlantArchitecture plantarchitecture(&context);
655 plantarchitecture.disableMessages();
656
657 // Enable collision detection first
658 plantarchitecture.enableSoftCollisionAvoidance();
659
660 // Load a plant model
661 plantarchitecture.loadPlantModelFromLibrary("tomato");
662
663 // Create a plant that starts at ground level
664 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
665
666 // Let it grow a small amount first to create some short internodes
667 plantarchitecture.advanceTime(plantID, 2);
668
669 // Create ground surface as solid obstacle
670 std::vector<uint> ground_UUIDs;
671 for (int i = -1; i <= 1; i++) {
672 for (int j = -1; j <= 1; j++) {
673 ground_UUIDs.push_back(context.addTriangle(make_vec3(i * 0.3f, j * 0.3f, -0.01f), // Ground slightly below
674 make_vec3((i + 1) * 0.3f, j * 0.3f, -0.01f), make_vec3(i * 0.3f, (j + 1) * 0.3f, -0.01f)));
675 ground_UUIDs.push_back(context.addTriangle(make_vec3((i + 1) * 0.3f, (j + 1) * 0.3f, -0.01f), make_vec3((i + 1) * 0.3f, j * 0.3f, -0.01f), make_vec3(i * 0.3f, (j + 1) * 0.3f, -0.01f)));
676 }
677 }
678
679 // Enable solid obstacle avoidance with the ground
680 plantarchitecture.enableSolidObstacleAvoidance(ground_UUIDs, 0.2f);
681
682 // Let the plant grow more - it should grow normally despite having short internodes
683 // The length-based protection should kick in even if node count > 3
684 plantarchitecture.advanceTime(plantID, 8);
685
686 // Get all plant objects to verify plant survived and grew upward
687 std::vector<uint> plant_objects = plantarchitecture.getAllObjectIDs();
688 DOCTEST_CHECK(plant_objects.size() > 0);
689
690 // Calculate center of mass to verify upward growth
691 vec3 center_of_mass = make_vec3(0, 0, 0);
692 uint total_objects = 0;
693
694 for (uint objID: plant_objects) {
695 if (context.doesObjectExist(objID)) {
696 vec3 min_corner, max_corner;
697 context.getObjectBoundingBox(objID, min_corner, max_corner);
698 vec3 object_center = (min_corner + max_corner) / 2.0f;
699 center_of_mass = center_of_mass + object_center;
700 total_objects++;
701 }
702 }
703
704 if (total_objects > 0) {
705 center_of_mass = center_of_mass / float(total_objects);
706
707 // The plant should have grown upward (center above ground level)
708 DOCTEST_CHECK(center_of_mass.z > 0.01f);
709
710 // Plant should have grown to a reasonable height, indicating protection worked
711 // Since we're testing short internodes, the height will be more modest
712 DOCTEST_CHECK(center_of_mass.z > 0.05f);
713 }
714
715 // Plant should have grown successfully (not been completely pruned)
716 DOCTEST_CHECK(plant_objects.size() >= 10);
717}
718
719DOCTEST_TEST_CASE("PlantArchitecture Attraction Points Basic Functionality") {
721 PlantArchitecture plantarchitecture(&context);
722 plantarchitecture.disableMessages();
723
724 // Enable collision detection for this test (optional - attraction points work independently)
725 plantarchitecture.enableSoftCollisionAvoidance();
726
727 // Test basic attraction points functionality
728 std::vector<vec3> attraction_points = {make_vec3(1.0f, 0.0f, 1.0f), make_vec3(0.0f, 1.0f, 1.5f)};
729
730 // Enable attraction points with valid parameters
731 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(attraction_points, 60.0f, 0.15f, 0.7f));
732
733 // Test parameter validation - invalid angle
734 DOCTEST_CHECK_THROWS(plantarchitecture.setAttractionParameters(0.0f, 0.1f, 0.5f));
735 DOCTEST_CHECK_THROWS(plantarchitecture.setAttractionParameters(190.0f, 0.1f, 0.5f));
736
737 // Test parameter validation - invalid distance
738 DOCTEST_CHECK_THROWS(plantarchitecture.setAttractionParameters(80.0f, 0.0f, 0.5f));
739 DOCTEST_CHECK_THROWS(plantarchitecture.setAttractionParameters(80.0f, -0.1f, 0.5f));
740
741 // Test parameter validation - invalid weight
742 DOCTEST_CHECK_THROWS(plantarchitecture.setAttractionParameters(80.0f, 0.1f, -0.1f));
743 DOCTEST_CHECK_THROWS(plantarchitecture.setAttractionParameters(80.0f, 0.1f, 1.1f));
744
745 // Update attraction points
746 std::vector<vec3> new_attraction_points = {make_vec3(2.0f, 0.0f, 2.0f)};
747 DOCTEST_CHECK_NOTHROW(plantarchitecture.updateAttractionPoints(new_attraction_points));
748
749 // Disable attraction points
750 DOCTEST_CHECK_NOTHROW(plantarchitecture.disableAttractionPoints());
751
752 // Test error when trying to update disabled attraction points
753 DOCTEST_CHECK_THROWS(plantarchitecture.updateAttractionPoints(new_attraction_points));
754}
755
756DOCTEST_TEST_CASE("PlantArchitecture Attraction Points Independent of Collision Detection") {
758 PlantArchitecture plantarchitecture(&context);
759 plantarchitecture.disableMessages();
760
761 std::vector<vec3> attraction_points = {make_vec3(1.0f, 0.0f, 1.0f)};
762
763 // Attraction points should work without collision detection enabled
764 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(attraction_points));
765}
766
767DOCTEST_TEST_CASE("PlantArchitecture Attraction Points Empty Vector") {
769 PlantArchitecture plantarchitecture(&context);
770 plantarchitecture.disableMessages();
771
772 std::vector<vec3> empty_attraction_points;
773
774 // Try to enable attraction points with empty vector
775 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(empty_attraction_points));
776
777 // Enable with valid points first (should work without collision detection)
778 std::vector<vec3> valid_points = {make_vec3(1.0f, 0.0f, 1.0f)};
779 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(valid_points));
780
781 // Try to update with empty vector (should fail)
782 DOCTEST_CHECK_THROWS(plantarchitecture.updateAttractionPoints(empty_attraction_points));
783}
784
785DOCTEST_TEST_CASE("PlantArchitecture Native Attraction Point Cone Detection") {
787 PlantArchitecture plantarchitecture(&context);
788 plantarchitecture.disableMessages();
789
790 // Set up attraction points at known locations
791 std::vector<vec3> attraction_points = {
792 make_vec3(0.0f, 0.0f, 2.0f), // Directly ahead
793 make_vec3(1.0f, 0.0f, 1.0f), // Right and forward
794 make_vec3(-1.0f, 0.0f, 1.0f), // Left and forward
795 make_vec3(0.0f, 2.0f, 0.0f), // Far to the side (should be outside cone)
796 };
797
798 // Enable attraction points (should work without collision detection)
799 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(attraction_points, 60.0f, 3.0f, 0.7f));
800
801 // Test 1: Looking straight up should find the point directly ahead
802 vec3 vertex = make_vec3(0.0f, 0.0f, 0.0f);
803 vec3 look_direction = make_vec3(0.0f, 0.0f, 1.0f); // Looking up
804 vec3 direction_to_closest;
805
806 bool found = plantarchitecture.detectAttractionPointsInCone(vertex, look_direction, 3.0f, 60.0f, direction_to_closest);
807 DOCTEST_CHECK(found);
808
809 // The closest should be the one directly ahead (0,0,2)
810 vec3 expected_direction = make_vec3(0.0f, 0.0f, 1.0f);
811 float dot_product = direction_to_closest * expected_direction;
812 DOCTEST_CHECK(dot_product > 0.99f); // Should be very close to parallel
813
814 // Test 2: Looking to the side should NOT find the point far to the side (outside cone)
815 look_direction = make_vec3(1.0f, 0.0f, 0.0f); // Looking right
816 found = plantarchitecture.detectAttractionPointsInCone(vertex, look_direction, 3.0f, 30.0f, direction_to_closest);
817
818 // With a narrow cone (30 degrees), the side point at (0,2,0) should be outside the cone
819 // But the point at (1,0,1) might be visible, so we might still find something
820
821 // Test 3: Test parameter validation
822 found = plantarchitecture.detectAttractionPointsInCone(vertex, look_direction, -1.0f, 60.0f, direction_to_closest);
823 DOCTEST_CHECK(!found); // Should fail with negative look ahead distance
824
825 found = plantarchitecture.detectAttractionPointsInCone(vertex, look_direction, 3.0f, 0.0f, direction_to_closest);
826 DOCTEST_CHECK(!found); // Should fail with zero half angle
827
828 found = plantarchitecture.detectAttractionPointsInCone(vertex, look_direction, 3.0f, 180.0f, direction_to_closest);
829 DOCTEST_CHECK(!found); // Should fail with 180 degree half angle
830}
831
832DOCTEST_TEST_CASE("PlantArchitecture Attraction Points Plant Growth Integration") {
834 PlantArchitecture plantarchitecture(&context);
835 plantarchitecture.disableMessages();
836
837 // Enable collision detection first
838 plantarchitecture.enableSoftCollisionAvoidance();
839
840 // Set up attraction points above the plant to guide upward growth
841 std::vector<vec3> attraction_points = {
842 make_vec3(0.1f, 0.1f, 1.0f), // Close to plant base but higher
843 make_vec3(0.0f, 0.0f, 1.5f) // Further away and higher
844 };
845
846 // Enable attraction points with moderate attraction weight
847 plantarchitecture.enableAttractionPoints(attraction_points, 80.0f, 0.2f, 0.6f);
848
849 // Create a simple plant
850 plantarchitecture.loadPlantModelFromLibrary("bean");
851 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
852
853 // Let the plant grow with attraction points enabled
854 plantarchitecture.advanceTime(plantID, 5);
855
856 // Get plant geometry to verify growth occurred
857 std::vector<uint> plant_objects = plantarchitecture.getAllObjectIDs();
858 DOCTEST_CHECK(plant_objects.size() > 0);
859
860 // Calculate plant center of mass to verify upward growth toward attraction points
861 vec3 center_of_mass = make_vec3(0, 0, 0);
862 uint total_objects = 0;
863
864 for (uint objID: plant_objects) {
865 if (context.doesObjectExist(objID)) {
866 vec3 min_corner, max_corner;
867 context.getObjectBoundingBox(objID, min_corner, max_corner);
868 vec3 object_center = (min_corner + max_corner) / 2.0f;
869 center_of_mass = center_of_mass + object_center;
870 total_objects++;
871 }
872 }
873
874 if (total_objects > 0) {
875 center_of_mass = center_of_mass / float(total_objects);
876
877 // Plant should have grown upward toward attraction points
878 // Bean plants start small, so adjust expectations to realistic growth
879 DOCTEST_CHECK(center_of_mass.z > 0.01f); // At least 1cm above ground
880
881 // Plant should show some lateral movement toward attraction points
882 // (not perfectly vertical growth due to attraction)
883 float lateral_distance = sqrt(center_of_mass.x * center_of_mass.x + center_of_mass.y * center_of_mass.y);
884 DOCTEST_CHECK(lateral_distance >= 0.0f); // Basic sanity check
885 }
886
887 // Test disabling attraction points mid-growth
888 plantarchitecture.disableAttractionPoints();
889
890 // Continue growing - should revert to natural growth patterns
891 plantarchitecture.advanceTime(plantID, 3);
892
893 // Verify plant continues to exist and grow
894 std::vector<uint> final_plant_objects = plantarchitecture.getAllObjectIDs();
895 DOCTEST_CHECK(final_plant_objects.size() >= plant_objects.size());
896}
897
898DOCTEST_TEST_CASE("PlantArchitecture Attraction Points Priority Over Collision Avoidance") {
900 PlantArchitecture plantarchitecture(&context);
901 plantarchitecture.disableMessages();
902
903 // Create some obstacle geometry
904 std::vector<uint> obstacle_UUIDs;
905 for (int i = 0; i < 3; i++) {
906 for (int j = 0; j < 3; j++) {
907 obstacle_UUIDs.push_back(
908 context.addTriangle(make_vec3(i * 0.3f + 0.5f, j * 0.3f + 0.5f, 0.5f + i * 0.1f), make_vec3((i + 1) * 0.3f + 0.5f, (j + 1) * 0.3f + 0.5f, 0.5f + i * 0.1f), make_vec3((i + 1) * 0.3f + 0.5f, j * 0.3f + 0.5f, 0.5f + i * 0.1f)));
909 }
910 }
911
912 // Enable collision detection with obstacles
913 plantarchitecture.enableSoftCollisionAvoidance(obstacle_UUIDs);
914
915 // Set up attraction points on the opposite side of obstacles
916 std::vector<vec3> attraction_points = {
917 make_vec3(-0.5f, 0.0f, 1.0f) // Away from obstacles
918 };
919
920 // Enable attraction points - should override soft collision avoidance
921 plantarchitecture.enableAttractionPoints(attraction_points, 90.0f, 0.3f, 0.8f);
922
923 // Create a plant near obstacles
924 plantarchitecture.loadPlantModelFromLibrary("bean");
925 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0.3f, 0.3f, 0), 0);
926
927 // Let the plant grow - should be attracted away from obstacles
928 plantarchitecture.advanceTime(plantID, 4);
929
930 // Verify plant grew successfully (attraction points should guide it away from obstacles)
931 std::vector<uint> plant_objects = plantarchitecture.getAllObjectIDs();
932 DOCTEST_CHECK(plant_objects.size() > 0);
933
934 // Check that plant moved toward attraction point (negative x direction)
935 vec3 center_of_mass = make_vec3(0, 0, 0);
936 uint total_objects = 0;
937
938 for (uint objID: plant_objects) {
939 if (context.doesObjectExist(objID)) {
940 vec3 min_corner, max_corner;
941 context.getObjectBoundingBox(objID, min_corner, max_corner);
942 vec3 object_center = (min_corner + max_corner) / 2.0f;
943 center_of_mass = center_of_mass + object_center;
944 total_objects++;
945 }
946 }
947
948 if (total_objects > 0) {
949 center_of_mass = center_of_mass / float(total_objects);
950
951 // Plant should have grown upward
952 DOCTEST_CHECK(center_of_mass.z > 0.01f); // At least 1cm above ground
953
954 // With strong attraction weight (0.8), plant should show movement toward attraction point
955 // This validates that attraction points override soft collision avoidance
956 }
957}
958
959DOCTEST_TEST_CASE("PlantArchitecture Hard Obstacle Avoidance Takes Priority Over Attraction Points") {
961 PlantArchitecture plantarchitecture(&context);
962 plantarchitecture.disableMessages();
963
964 // Create ground-level obstacles that would trigger hard obstacle avoidance
965 std::vector<uint> solid_obstacle_UUIDs;
966 for (int i = -1; i <= 1; i++) {
967 for (int j = -1; j <= 1; j++) {
968 solid_obstacle_UUIDs.push_back(context.addTriangle(make_vec3(i * 0.1f, j * 0.1f, 0.1f), make_vec3((i + 1) * 0.1f, (j + 1) * 0.1f, 0.1f), make_vec3((i + 1) * 0.1f, j * 0.1f, 0.1f)));
969 }
970 }
971
972 // Enable collision detection first
973 plantarchitecture.enableSoftCollisionAvoidance();
974
975 // Enable solid obstacle avoidance (hard obstacles)
976 plantarchitecture.enableSolidObstacleAvoidance(solid_obstacle_UUIDs, 0.15f);
977
978 // Set up attraction points in the opposite direction of safe growth
979 std::vector<vec3> attraction_points = {
980 make_vec3(0.0f, 0.0f, 0.05f) // Low attraction point that would conflict with obstacle avoidance
981 };
982
983 // Enable attraction points
984 plantarchitecture.enableAttractionPoints(attraction_points, 70.0f, 0.1f, 0.9f);
985
986 // Create a plant at the origin
987 plantarchitecture.loadPlantModelFromLibrary("bean");
988 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
989
990 // Let the plant grow - hard obstacle avoidance should take priority
991 plantarchitecture.advanceTime(plantID, 3);
992
993 // Verify plant grew successfully despite conflicting guidance
994 std::vector<uint> plant_objects = plantarchitecture.getAllObjectIDs();
995 DOCTEST_CHECK(plant_objects.size() > 0);
996
997 // Plant should have grown upward to avoid hard obstacles, regardless of attraction points
998 vec3 center_of_mass = make_vec3(0, 0, 0);
999 uint total_objects = 0;
1000
1001 for (uint objID: plant_objects) {
1002 if (context.doesObjectExist(objID)) {
1003 vec3 min_corner, max_corner;
1004 context.getObjectBoundingBox(objID, min_corner, max_corner);
1005 vec3 object_center = (min_corner + max_corner) / 2.0f;
1006 center_of_mass = center_of_mass + object_center;
1007 total_objects++;
1008 }
1009 }
1010
1011 if (total_objects > 0) {
1012 center_of_mass = center_of_mass / float(total_objects);
1013
1014 // Hard obstacle avoidance should force upward growth
1015 DOCTEST_CHECK(center_of_mass.z > 0.01f); // At least 1cm above ground
1016
1017 // Plant should have avoided the low obstacles (which are at 0.1m height)
1018 // So plant should be higher than the obstacle level
1019 DOCTEST_CHECK(center_of_mass.z > 0.005f); // Above the base obstacle level
1020 }
1021}
1022
1023DOCTEST_TEST_CASE("PlantArchitecture Attraction Points with Surface Following") {
1025 PlantArchitecture plantarchitecture(&context);
1026 plantarchitecture.disableMessages();
1027
1028 // Create a vertical wall that we want the plant to approach and then grow parallel to
1029 std::vector<uint> wall_obstacle_UUIDs;
1030 std::vector<vec3> wall_attraction_points;
1031
1032 // Create vertical wall at x = 0.3
1033 for (int i = 0; i < 5; i++) {
1034 for (int j = 0; j < 3; j++) {
1035 // Wall surface obstacles (solid)
1036 wall_obstacle_UUIDs.push_back(context.addTriangle(make_vec3(0.3f, i * 0.05f, j * 0.05f), make_vec3(0.3f, (i + 1) * 0.05f, (j + 1) * 0.05f), make_vec3(0.3f, (i + 1) * 0.05f, j * 0.05f)));
1037
1038 // Attraction points on the wall surface
1039 wall_attraction_points.push_back(make_vec3(0.29f, i * 0.05f + 0.025f, j * 0.05f + 0.025f));
1040 }
1041 }
1042
1043 // Enable collision detection with wall obstacles
1044 plantarchitecture.enableSoftCollisionAvoidance();
1045
1046 // Enable solid obstacle avoidance for the wall
1047 plantarchitecture.enableSolidObstacleAvoidance(wall_obstacle_UUIDs, 0.05f);
1048
1049 // Enable attraction points on the wall surface with reduced obstacle reduction factor
1050 // This allows the plant to maintain some attraction even when avoiding obstacles
1051 plantarchitecture.enableAttractionPoints(wall_attraction_points, 60.0f, 0.1f, 0.8f);
1052 plantarchitecture.setAttractionParameters(60.0f, 0.1f, 0.8f, 0.5f); // Higher obstacle reduction factor
1053
1054 // Create a plant at origin that should grow toward the wall
1055 plantarchitecture.loadPlantModelFromLibrary("bean");
1056 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1057
1058 // Let the plant grow - it should approach the wall and then follow it
1059 plantarchitecture.advanceTime(plantID, 4);
1060
1061 // Get plant geometry to verify behavior
1062 std::vector<uint> plant_objects = plantarchitecture.getAllObjectIDs();
1063 DOCTEST_CHECK(plant_objects.size() > 0);
1064
1065 // Calculate plant center of mass
1066 vec3 center_of_mass = make_vec3(0, 0, 0);
1067 uint total_objects = 0;
1068
1069 for (uint objID: plant_objects) {
1070 if (context.doesObjectExist(objID)) {
1071 vec3 min_corner, max_corner;
1072 context.getObjectBoundingBox(objID, min_corner, max_corner);
1073 vec3 object_center = (min_corner + max_corner) / 2.0f;
1074 center_of_mass = center_of_mass + object_center;
1075 total_objects++;
1076 }
1077 }
1078
1079 if (total_objects > 0) {
1080 center_of_mass = center_of_mass / float(total_objects);
1081
1082 // Plant should have grown upward
1083 DOCTEST_CHECK(center_of_mass.z > 0.01f);
1084
1085 // The key test is that the plant grows successfully with both attraction points and obstacle avoidance enabled
1086 // This validates that the new blended approach doesn't cause conflicts or crashes
1087 // The exact movement direction depends on many factors, but the plant should grow
1088
1089 // This test primarily validates that our improved blending logic works without errors
1090 // when both attraction points and hard obstacle avoidance are enabled simultaneously
1091 }
1092}
1093
1094DOCTEST_TEST_CASE("PlantArchitecture Smooth Hard Obstacle Avoidance") {
1096 PlantArchitecture plantarchitecture(&context);
1097 plantarchitecture.disableMessages();
1098
1099 plantarchitecture.enableSoftCollisionAvoidance();
1100 plantarchitecture.loadPlantModelFromLibrary("bean");
1101
1102 // Create obstacles at varying distances to test smooth avoidance behavior
1103 std::vector<uint> obstacle_UUIDs;
1104
1105 // Create obstacles at different normalized distances from plant growth path
1106 // Plant will grow upward from (0,0,0), so place obstacles to the side at different z heights
1107 for (int i = 0; i < 4; i++) {
1108 float z_height = 0.1f + i * 0.05f; // Heights: 0.1, 0.15, 0.2, 0.25
1109
1110 // Create obstacle patches at different distances from expected growth path
1111 float x_distance = 0.05f + i * 0.02f; // Distances: 0.05, 0.07, 0.09, 0.11
1112
1113 obstacle_UUIDs.push_back(context.addTriangle(make_vec3(x_distance, -0.02f, z_height), make_vec3(x_distance + 0.04f, -0.02f, z_height), make_vec3(x_distance, 0.02f, z_height)));
1114 obstacle_UUIDs.push_back(context.addTriangle(make_vec3(x_distance + 0.04f, 0.02f, z_height), make_vec3(x_distance + 0.04f, -0.02f, z_height), make_vec3(x_distance, 0.02f, z_height)));
1115 }
1116
1117 plantarchitecture.enableSolidObstacleAvoidance(obstacle_UUIDs, 0.25f);
1118
1119 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1120 plantarchitecture.advanceTime(plantID, 8);
1121
1122 std::vector<uint> plant_objects = plantarchitecture.getAllObjectIDs();
1123 DOCTEST_CHECK(plant_objects.size() > 0);
1124
1125 // Calculate plant center of mass to verify it avoided obstacles
1126 vec3 center_of_mass = make_vec3(0, 0, 0);
1127 uint total_objects = 0;
1128
1129 for (uint objID: plant_objects) {
1130 if (context.doesObjectExist(objID)) {
1131 vec3 min_corner, max_corner;
1132 context.getObjectBoundingBox(objID, min_corner, max_corner);
1133 vec3 object_center = (min_corner + max_corner) / 2.0f;
1134 center_of_mass = center_of_mass + object_center;
1135 total_objects++;
1136 }
1137 }
1138
1139 if (total_objects > 0) {
1140 center_of_mass = center_of_mass / float(total_objects);
1141
1142 // Plant should have grown upward successfully
1143 DOCTEST_CHECK(center_of_mass.z > 0.01f);
1144
1145 // Plant should have moved away from obstacles (toward negative x since obstacles are on positive x side)
1146 // This tests that smooth avoidance works without the harsh discrete jumps
1147 DOCTEST_CHECK(center_of_mass.x <= 0.01f); // Should stay near or move away from obstacles
1148
1149 // Key validation: plant grows successfully with smooth obstacle avoidance
1150 // The smooth distance-normalized approach should provide gradual, natural avoidance
1151 // rather than abrupt discrete changes in behavior
1152 }
1153}
1154
1155DOCTEST_TEST_CASE("PlantArchitecture Hard Obstacle Avoidance Buffer Zone") {
1157 PlantArchitecture plantarchitecture(&context);
1158 plantarchitecture.disableMessages();
1159
1160 plantarchitecture.enableSoftCollisionAvoidance();
1161 plantarchitecture.loadPlantModelFromLibrary("bean");
1162
1163 // Create a vertical post obstacle similar to the test case image
1164 std::vector<uint> post_UUIDs;
1165 float post_radius = 0.02f; // 2cm radius post
1166 float post_height = 0.5f; // 50cm tall post
1167
1168 // Create post as a series of triangles forming a cylinder at x=0.1m (10cm from plant center)
1169 int segments = 8;
1170 for (int i = 0; i < segments; i++) {
1171 float theta1 = 2.0f * M_PI * float(i) / float(segments);
1172 float theta2 = 2.0f * M_PI * float(i + 1) / float(segments);
1173
1174 vec3 p1_bottom = make_vec3(0.1f + post_radius * cos(theta1), post_radius * sin(theta1), 0);
1175 vec3 p2_bottom = make_vec3(0.1f + post_radius * cos(theta2), post_radius * sin(theta2), 0);
1176 vec3 p1_top = make_vec3(0.1f + post_radius * cos(theta1), post_radius * sin(theta1), post_height);
1177 vec3 p2_top = make_vec3(0.1f + post_radius * cos(theta2), post_radius * sin(theta2), post_height);
1178
1179 // Two triangles per segment to form cylinder walls
1180 post_UUIDs.push_back(context.addTriangle(p1_bottom, p2_bottom, p1_top));
1181 post_UUIDs.push_back(context.addTriangle(p2_bottom, p2_top, p1_top));
1182 }
1183
1184 // Set detection distance and enable solid obstacle avoidance
1185 float detection_distance = 0.2f; // 20cm detection distance
1186 float expected_buffer = detection_distance * 0.05f; // 5% buffer = 1cm
1187
1188 plantarchitecture.enableSolidObstacleAvoidance(post_UUIDs, detection_distance);
1189
1190 // Create plant at origin, should grow toward +x direction but avoid the post
1191 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1192 plantarchitecture.advanceTime(plantID, 8);
1193
1194 std::vector<uint> plant_objects = plantarchitecture.getAllObjectIDs();
1195 DOCTEST_CHECK(plant_objects.size() > 0);
1196
1197 // Calculate minimum distance between plant and post to verify buffer is maintained
1198 float min_distance_to_post = std::numeric_limits<float>::max();
1199 vec3 post_center = make_vec3(0.1f, 0, 0.25f); // Center of post
1200
1201 for (uint objID: plant_objects) {
1202 if (context.doesObjectExist(objID)) {
1203 vec3 min_corner, max_corner;
1204 context.getObjectBoundingBox(objID, min_corner, max_corner);
1205
1206 // Check distance from each corner of plant object to post center
1207 vec3 corners[8] = {make_vec3(min_corner.x, min_corner.y, min_corner.z), make_vec3(max_corner.x, min_corner.y, min_corner.z), make_vec3(min_corner.x, max_corner.y, min_corner.z), make_vec3(min_corner.x, min_corner.y, max_corner.z),
1208 make_vec3(max_corner.x, max_corner.y, min_corner.z), make_vec3(max_corner.x, min_corner.y, max_corner.z), make_vec3(min_corner.x, max_corner.y, max_corner.z), make_vec3(max_corner.x, max_corner.y, max_corner.z)};
1209
1210 for (int i = 0; i < 8; i++) {
1211 float distance = (corners[i] - post_center).magnitude();
1212 min_distance_to_post = std::min(min_distance_to_post, distance);
1213 }
1214 }
1215 }
1216
1217 // Plant should maintain buffer distance from post (accounting for post radius)
1218 float expected_min_distance = post_radius + expected_buffer;
1219 DOCTEST_CHECK(min_distance_to_post >= expected_min_distance * 0.8f); // Allow 20% tolerance for growth dynamics
1220
1221 // Plant should have grown upward successfully despite obstacle
1222 vec3 plant_center = make_vec3(0, 0, 0);
1223 uint plant_object_count = 0;
1224
1225 for (uint objID: plant_objects) {
1226 if (context.doesObjectExist(objID)) {
1227 vec3 min_corner, max_corner;
1228 context.getObjectBoundingBox(objID, min_corner, max_corner);
1229 vec3 object_center = (min_corner + max_corner) / 2.0f;
1230 plant_center = plant_center + object_center;
1231 plant_object_count++;
1232 }
1233 }
1234
1235 if (plant_object_count > 0) {
1236 plant_center = plant_center / float(plant_object_count);
1237 DOCTEST_CHECK(plant_center.z > 0.01f); // Should grow upward
1238
1239 // Plant should avoid growing directly into the post (should stay away from x=0.1)
1240 // With buffer zone avoidance, plant should either go around or grow upward
1241 DOCTEST_CHECK(fabs(plant_center.x - 0.1f) > expected_buffer * 0.5f); // Should maintain some distance from post center line
1242 }
1243}
1244
1245DOCTEST_TEST_CASE("PlantArchitecture solid obstacle avoidance works independently") {
1247 PlantArchitecture plantarchitecture(&context);
1248 plantarchitecture.disableMessages();
1249
1250 // Create obstacle geometry (ground plane)
1251 std::vector<uint> obstacle_UUIDs;
1252 obstacle_UUIDs.push_back(context.addTriangle(make_vec3(-1, -1, -0.01f), make_vec3(1, -1, -0.01f), make_vec3(-1, 1, -0.01f)));
1253 obstacle_UUIDs.push_back(context.addTriangle(make_vec3(1, 1, -0.01f), make_vec3(1, -1, -0.01f), make_vec3(-1, 1, -0.01f)));
1254
1255 // Test: Enable ONLY solid obstacle avoidance (no soft collision avoidance)
1256 // This should work independently after our fix
1257 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableSolidObstacleAvoidance(obstacle_UUIDs, 0.2f));
1258
1259 // Load and build a plant
1260 plantarchitecture.loadPlantModelFromLibrary("bean");
1261 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1262
1263 // Advance time - this should work without crashing and plant should grow upward
1264 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 5.0f));
1265
1266 // Verify plant was created and grew
1267 std::vector<uint> plant_objects = plantarchitecture.getAllObjectIDs();
1268 DOCTEST_CHECK(plant_objects.size() > 0);
1269
1270 // Calculate plant center of mass to verify upward growth (avoiding ground obstacle)
1271 vec3 plant_center = make_vec3(0, 0, 0);
1272 uint plant_object_count = 0;
1273
1274 for (uint objID: plant_objects) {
1275 if (context.doesObjectExist(objID)) {
1276 vec3 min_corner, max_corner;
1277 context.getObjectBoundingBox(objID, min_corner, max_corner);
1278 vec3 object_center = (min_corner + max_corner) / 2.0f;
1279 plant_center = plant_center + object_center;
1280 plant_object_count++;
1281 }
1282 }
1283
1284 if (plant_object_count > 0) {
1285 plant_center = plant_center / float(plant_object_count);
1286 // Plant should grow upward, avoiding the ground obstacle at z = -0.01f
1287 DOCTEST_CHECK(plant_center.z > 0.01f);
1288 }
1289
1290 // Test: Add soft collision avoidance on top of existing solid obstacle avoidance
1291 // This should work together seamlessly
1292 std::vector<uint> soft_target_UUIDs;
1293 std::vector<uint> soft_target_IDs;
1294 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableSoftCollisionAvoidance(soft_target_UUIDs, soft_target_IDs));
1295
1296 // Continue growing - should still work with both systems enabled
1297 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 2.0f));
1298
1299 // Verify plant continued to grow
1300 std::vector<uint> final_plant_objects = plantarchitecture.getAllObjectIDs();
1301 DOCTEST_CHECK(final_plant_objects.size() >= plant_objects.size());
1302}
1303
1304DOCTEST_TEST_CASE("PlantArchitecture Per-Plant Attraction Points") {
1306 PlantArchitecture plantarchitecture(&context);
1307
1308 // Disable messages for cleaner test output
1309 plantarchitecture.disableMessages();
1310
1311 // Create two plants at different positions
1312 uint plantID1 = plantarchitecture.addPlantInstance(make_vec3(0, 0, 0), 0);
1313 uint plantID2 = plantarchitecture.addPlantInstance(make_vec3(5, 0, 0), 0);
1314
1315 // Set different attraction points for each plant
1316 std::vector<vec3> attraction_points_1 = {make_vec3(1.0f, 0.0f, 1.0f), make_vec3(0.0f, 1.0f, 1.5f)};
1317 std::vector<vec3> attraction_points_2 = {make_vec3(6.0f, 0.0f, 1.0f), make_vec3(5.0f, 1.0f, 1.5f)};
1318
1319 // Enable attraction points for each plant with different parameters
1320 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(plantID1, attraction_points_1, 60.0f, 0.2f, 0.7f));
1321 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(plantID2, attraction_points_2, 45.0f, 0.15f, 0.5f));
1322
1323 // Test parameter updates for individual plants
1324 DOCTEST_CHECK_NOTHROW(plantarchitecture.setAttractionParameters(plantID1, 80.0f, 0.25f, 0.8f, 0.6f));
1325 DOCTEST_CHECK_NOTHROW(plantarchitecture.updateAttractionPoints(plantID2, {make_vec3(6.5f, 0.5f, 2.0f)}));
1326 DOCTEST_CHECK_NOTHROW(plantarchitecture.appendAttractionPoints(plantID1, {make_vec3(1.5f, 1.5f, 2.0f)}));
1327
1328 // Test disabling for individual plants
1329 DOCTEST_CHECK_NOTHROW(plantarchitecture.disableAttractionPoints(plantID1));
1330
1331 // Test error handling for invalid plant IDs
1332 DOCTEST_CHECK_THROWS(plantarchitecture.enableAttractionPoints(9999, attraction_points_1));
1333 DOCTEST_CHECK_THROWS(plantarchitecture.disableAttractionPoints(9999));
1334 DOCTEST_CHECK_THROWS(plantarchitecture.updateAttractionPoints(9999, attraction_points_1));
1335 DOCTEST_CHECK_THROWS(plantarchitecture.appendAttractionPoints(9999, attraction_points_1));
1336 DOCTEST_CHECK_THROWS(plantarchitecture.setAttractionParameters(9999, 60.0f, 0.15f, 0.7f, 0.75f));
1337}
1338
1339DOCTEST_TEST_CASE("PlantArchitecture Global vs Per-Plant Interaction") {
1341 PlantArchitecture plantarchitecture(&context);
1342
1343 // Disable messages for cleaner test output
1344 plantarchitecture.disableMessages();
1345
1346 // Create a plant first
1347 uint plantID1 = plantarchitecture.addPlantInstance(make_vec3(0, 0, 0), 0);
1348
1349 // Set global attraction points - should affect all plants including existing ones
1350 std::vector<vec3> global_attraction_points = {make_vec3(1.0f, 0.0f, 1.0f), make_vec3(0.0f, 1.0f, 1.5f)};
1351 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(global_attraction_points, 60.0f, 0.15f, 0.7f));
1352
1353 // Create another plant after global attraction points are set
1354 uint plantID2 = plantarchitecture.addPlantInstance(make_vec3(5, 0, 0), 0);
1355
1356 // Now set plant-specific attraction points for plant 1 - should override global for that plant
1357 std::vector<vec3> specific_attraction_points = {make_vec3(2.0f, 0.0f, 2.0f)};
1358 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(plantID1, specific_attraction_points, 45.0f, 0.1f, 0.5f));
1359
1360 // Test that global update affects all plants with attraction points enabled
1361 DOCTEST_CHECK_NOTHROW(plantarchitecture.updateAttractionPoints({make_vec3(3.0f, 0.0f, 3.0f)}));
1362
1363 // Global disable should affect all plants
1364 DOCTEST_CHECK_NOTHROW(plantarchitecture.disableAttractionPoints());
1365
1366 // Re-enable global attraction points to test backward compatibility
1367 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(global_attraction_points));
1368}
1369
1370DOCTEST_TEST_CASE("PlantArchitecture Plant-Specific Attraction Points Validation") {
1372 PlantArchitecture plantarchitecture(&context);
1373
1374 // Disable messages for cleaner test output
1375 plantarchitecture.disableMessages();
1376
1377 // Create plants to test validation and method calls
1378 uint plantID1 = plantarchitecture.addPlantInstance(make_vec3(0, 0, 0), 0);
1379 uint plantID2 = plantarchitecture.addPlantInstance(make_vec3(5, 0, 0), 0);
1380
1381 // Set different attraction points for each plant
1382 std::vector<vec3> attraction_points_1 = {make_vec3(1.0f, 0.0f, 1.0f)};
1383 std::vector<vec3> attraction_points_2 = {make_vec3(6.0f, 0.0f, 1.0f)};
1384
1385 // Test that plant-specific methods work correctly
1386 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(plantID1, attraction_points_1));
1387 DOCTEST_CHECK_NOTHROW(plantarchitecture.enableAttractionPoints(plantID2, attraction_points_2));
1388
1389 // Test parameter validation
1390 DOCTEST_CHECK_THROWS(plantarchitecture.enableAttractionPoints(plantID1, {}, 60.0f, 0.15f, 0.7f)); // Empty vector
1391 DOCTEST_CHECK_THROWS(plantarchitecture.setAttractionParameters(plantID1, 0.0f, 0.15f, 0.7f)); // Invalid angle
1392 DOCTEST_CHECK_THROWS(plantarchitecture.setAttractionParameters(plantID1, 60.0f, 0.0f, 0.7f)); // Invalid distance
1393
1394 // Test successful parameter updates
1395 DOCTEST_CHECK_NOTHROW(plantarchitecture.setAttractionParameters(plantID1, 80.0f, 0.25f, 0.8f, 0.6f));
1396 DOCTEST_CHECK_NOTHROW(plantarchitecture.updateAttractionPoints(plantID2, {make_vec3(6.5f, 0.5f, 2.0f)}));
1397 DOCTEST_CHECK_NOTHROW(plantarchitecture.appendAttractionPoints(plantID1, {make_vec3(1.5f, 1.5f, 2.0f)}));
1398
1399 // Test disabling
1400 DOCTEST_CHECK_NOTHROW(plantarchitecture.disableAttractionPoints(plantID1));
1401}
1402
1403DOCTEST_TEST_CASE("PlantArchitecture removeShootFloralBuds") {
1405 PlantArchitecture plantarchitecture(&context);
1406 plantarchitecture.disableMessages();
1407
1408 // Test invalid plant ID - should throw
1409 capture_cerr cerr_buffer;
1410 DOCTEST_CHECK_THROWS(plantarchitecture.removeShootFloralBuds(9999, 0));
1411
1412 // Create a plant instance to test valid plant ID but invalid shoot ID
1413 uint plantID = plantarchitecture.addPlantInstance(make_vec3(0, 0, 0), 0);
1414 DOCTEST_CHECK(plantID != -1);
1415
1416 // Test invalid shoot ID - should throw
1417 DOCTEST_CHECK_THROWS(plantarchitecture.removeShootFloralBuds(plantID, 9999));
1418}
1419
1420DOCTEST_TEST_CASE("PlantArchitecture XML write with flowers and fruit") {
1422 PlantArchitecture plantarchitecture(&context);
1423 plantarchitecture.disableMessages();
1424
1425 // Load tomato model (has flowers and fruit)
1426 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("tomato"));
1427
1428 // Build simple plant
1429 vec3 base_position(1.0f, 2.0f, 0.5f);
1430 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(base_position, 180);
1431 DOCTEST_CHECK(plantID != uint(-1));
1432
1433 // Write plant structure to XML (should not crash even if no flowers)
1434 std::string xml_filename = "test_plant_xml_write.xml";
1435 DOCTEST_CHECK_NOTHROW(plantarchitecture.writePlantStructureXML(plantID, xml_filename));
1436
1437 // Clean up test file
1438 std::remove(xml_filename.c_str());
1439}
1440
1441DOCTEST_TEST_CASE("PlantArchitecture child shoot rotation with multiple petioles per internode") {
1443 PlantArchitecture plantarchitecture(&context);
1444 plantarchitecture.disableMessages();
1445
1446 // Regression test for bug where child shoots from different petioles had the same rotation
1447 // The fix changed line 4778 in PlantArchitecture.cpp to use petioles_per_internode
1448 // instead of axillary_vegetative_buds.size() for calculating rotation offset
1449
1450 // Use bean plant which has 2 petioles per internode in the unifoliate stage
1451 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("bean"));
1452 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1453 DOCTEST_CHECK(plantID != uint(-1));
1454
1455 // Advance time to allow growth and child shoot formation
1456 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 10.0f));
1457
1458 // Verify plant created geometry (basic sanity check that build succeeded)
1459 std::vector<uint> all_primitives = plantarchitecture.getAllObjectIDs();
1460 DOCTEST_CHECK(all_primitives.size() > 0);
1461
1462 // If this test passes, the fix is working (plant builds without errors)
1463 // The actual visual verification of proper 180-degree offset would require
1464 // more complex geometric analysis that is beyond the scope of a unit test
1465}
1466
1467DOCTEST_TEST_CASE("PlantArchitecture plant_name optional object data") {
1469 PlantArchitecture plantarchitecture(&context);
1470 plantarchitecture.disableMessages();
1471
1472 // Enable plant_name optional object data
1473 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("plant_name"));
1474
1475 // Load and build a bean plant
1476 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("bean"));
1477 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1478 DOCTEST_CHECK(plantID != uint(-1));
1479
1480 // Verify plant name is set correctly
1481 std::string plant_name = plantarchitecture.getPlantName(plantID);
1482 DOCTEST_CHECK(plant_name == "bean");
1483
1484 // Advance time to create more organs
1485 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 10.0f));
1486
1487 // Get all object IDs
1488 std::vector<uint> all_primitives = plantarchitecture.getAllObjectIDs();
1489 DOCTEST_CHECK(all_primitives.size() > 0);
1490
1491 // Verify plant_name object data is set on primitives
1492 bool found_plant_name_data = false;
1493 for (uint objID: all_primitives) {
1494 if (context.doesObjectDataExist(objID, "plant_name")) {
1495 std::string obj_plant_name;
1496 context.getObjectData(objID, "plant_name", obj_plant_name);
1497 DOCTEST_CHECK(obj_plant_name == "bean");
1498 found_plant_name_data = true;
1499 }
1500 }
1501 DOCTEST_CHECK(found_plant_name_data);
1502}
1503
1504DOCTEST_TEST_CASE("PlantArchitecture plant_type tree classification") {
1506 PlantArchitecture plantarchitecture(&context);
1507 plantarchitecture.disableMessages();
1508
1509 // Enable plant_type optional object data
1510 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("plant_type"));
1511
1512 // Test tree classification
1513 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("almond"));
1514 uint treeID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1515 DOCTEST_CHECK(treeID != uint(-1));
1516
1517 std::vector<uint> tree_primitives = plantarchitecture.getAllObjectIDs();
1518 DOCTEST_CHECK(tree_primitives.size() > 0);
1519 bool found_tree_type = false;
1520 for (uint objID: tree_primitives) {
1521 if (context.doesObjectDataExist(objID, "plant_type")) {
1522 std::string plant_type;
1523 context.getObjectData(objID, "plant_type", plant_type);
1524 DOCTEST_CHECK(plant_type == "tree");
1525 found_tree_type = true;
1526 }
1527 }
1528 DOCTEST_CHECK(found_tree_type);
1529}
1530
1531DOCTEST_TEST_CASE("PlantArchitecture plant_type weed classification") {
1533 PlantArchitecture plantarchitecture(&context);
1534 plantarchitecture.disableMessages();
1535
1536 // Enable plant_type optional object data
1537 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("plant_type"));
1538
1539 // Test weed classification
1540 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("bindweed"));
1541 uint weedID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1542 DOCTEST_CHECK(weedID != uint(-1));
1543
1544 std::vector<uint> weed_primitives = plantarchitecture.getAllObjectIDs();
1545 DOCTEST_CHECK(weed_primitives.size() > 0);
1546 bool found_weed_type = false;
1547 for (uint objID: weed_primitives) {
1548 if (context.doesObjectDataExist(objID, "plant_type")) {
1549 std::string plant_type;
1550 context.getObjectData(objID, "plant_type", plant_type);
1551 DOCTEST_CHECK(plant_type == "weed");
1552 found_weed_type = true;
1553 }
1554 }
1555 DOCTEST_CHECK(found_weed_type);
1556}
1557
1558DOCTEST_TEST_CASE("PlantArchitecture plant_type herbaceous classification") {
1560 PlantArchitecture plantarchitecture(&context);
1561 plantarchitecture.disableMessages();
1562
1563 // Enable plant_type optional object data
1564 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("plant_type"));
1565
1566 // Test herbaceous classification (default)
1567 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("bean"));
1568 uint herbaceousID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1569 DOCTEST_CHECK(herbaceousID != uint(-1));
1570
1571 std::vector<uint> herbaceous_primitives = plantarchitecture.getAllObjectIDs();
1572 DOCTEST_CHECK(herbaceous_primitives.size() > 0);
1573 bool found_herbaceous_type = false;
1574 for (uint objID: herbaceous_primitives) {
1575 if (context.doesObjectDataExist(objID, "plant_type")) {
1576 std::string plant_type;
1577 context.getObjectData(objID, "plant_type", plant_type);
1578 DOCTEST_CHECK(plant_type == "herbaceous");
1579 found_herbaceous_type = true;
1580 }
1581 }
1582 DOCTEST_CHECK(found_herbaceous_type);
1583}
1584
1585DOCTEST_TEST_CASE("PlantArchitecture plant_height optional object data") {
1587 PlantArchitecture plantarchitecture(&context);
1588 plantarchitecture.disableMessages();
1589
1590 // Enable plant_height optional object data
1591 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("plant_height"));
1592
1593 // Build a bean plant
1594 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("bean"));
1595 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1596 DOCTEST_CHECK(plantID != uint(-1));
1597
1598 // Get initial height
1599 float initial_height = plantarchitecture.getPlantHeight(plantID);
1600 DOCTEST_CHECK(initial_height > 0);
1601
1602 // Advance time to allow growth
1603 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 10.0f));
1604
1605 // Verify height increased
1606 float final_height = plantarchitecture.getPlantHeight(plantID);
1607 DOCTEST_CHECK(final_height > initial_height);
1608
1609 // Verify plant_height object data was set and is reasonable
1610 std::vector<uint> all_primitives = plantarchitecture.getAllObjectIDs();
1611 DOCTEST_CHECK(all_primitives.size() > 0);
1612 bool found_height_data = false;
1613 for (uint objID: all_primitives) {
1614 if (context.doesObjectDataExist(objID, "plant_height")) {
1615 float obj_height;
1616 context.getObjectData(objID, "plant_height", obj_height);
1617 // Check height is within reasonable range (close to final_height)
1618 DOCTEST_CHECK(obj_height > initial_height);
1619 DOCTEST_CHECK(std::abs(obj_height - final_height) < 0.01f);
1620 found_height_data = true;
1621 break; // Only need to check one primitive
1622 }
1623 }
1624 DOCTEST_CHECK(found_height_data);
1625}
1626
1627DOCTEST_TEST_CASE("PlantArchitecture phenology_stage optional object data") {
1629 PlantArchitecture plantarchitecture(&context);
1630 plantarchitecture.disableMessages();
1631
1632 // Enable phenology_stage optional object data
1633 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("phenology_stage"));
1634
1635 // Build a bean plant
1636 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("bean"));
1637 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1638 DOCTEST_CHECK(plantID != uint(-1));
1639
1640 // Initially should be vegetative (no flowers, not dormant)
1641 std::string initial_stage = plantarchitecture.determinePhenologyStage(plantID);
1642 DOCTEST_CHECK(initial_stage == "vegetative");
1643
1644 // Advance time to allow growth and potential flowering
1645 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 20.0f));
1646
1647 // Get current phenology stage
1648 std::string current_stage = plantarchitecture.determinePhenologyStage(plantID);
1649 DOCTEST_CHECK((current_stage == "vegetative" || current_stage == "reproductive" || current_stage == "senescent" || current_stage == "dormant"));
1650
1651 // Verify phenology_stage object data was set
1652 std::vector<uint> all_primitives = plantarchitecture.getAllObjectIDs();
1653 DOCTEST_CHECK(all_primitives.size() > 0);
1654 bool found_stage_data = false;
1655 for (uint objID: all_primitives) {
1656 if (context.doesObjectDataExist(objID, "phenology_stage")) {
1657 std::string obj_stage;
1658 context.getObjectData(objID, "phenology_stage", obj_stage);
1659 DOCTEST_CHECK(obj_stage == current_stage);
1660 found_stage_data = true;
1661 }
1662 }
1663 DOCTEST_CHECK(found_stage_data);
1664}
1665
1666DOCTEST_TEST_CASE("Build Parameters - Backward Compatibility (Grapevine VSP)") {
1667 // Test that empty parameter map produces identical plants to original hard-coded values
1669 PlantArchitecture plantarchitecture(&context);
1670 plantarchitecture.disableMessages();
1671
1672 // Build with default parameters (empty map)
1673 plantarchitecture.loadPlantModelFromLibrary("grapevine_VSP");
1674 std::map<std::string, float> empty_params;
1675 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0, empty_params);
1676
1677 // Verify plant was created
1678 DOCTEST_CHECK(plantID != uint(-1));
1679
1680 // Verify basic plant structure exists
1681 std::vector<uint> plant_primitives = plantarchitecture.getAllPlantObjectIDs(plantID);
1682 DOCTEST_CHECK(plant_primitives.size() > 0);
1683}
1684
1685DOCTEST_TEST_CASE("Build Parameters - Parameter Override (Grapevine VSP)") {
1686 // Test that custom parameter values are applied correctly
1688 PlantArchitecture plantarchitecture(&context);
1689 plantarchitecture.disableMessages();
1690
1691 // Build with custom parameters
1692 // Note: vine_spacing limited by cane max_nodes (9) * internode_length (0.15m) * 2 = 2.7m max
1693 plantarchitecture.loadPlantModelFromLibrary("grapevine_VSP");
1694 std::map<std::string, float> custom_params = {
1695 {"vine_spacing", 2.5f}, // 2.5m spacing (within max_nodes limit)
1696 {"trunk_height", 0.15f} // 15 cm trunk height
1697 };
1698 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0, custom_params);
1699
1700 // Verify plant was created with custom parameters
1701 DOCTEST_CHECK(plantID != uint(-1));
1702 std::vector<uint> plant_primitives = plantarchitecture.getAllPlantObjectIDs(plantID);
1703 DOCTEST_CHECK(plant_primitives.size() > 0);
1704}
1705
1706DOCTEST_TEST_CASE("Build Parameters - Validation Catches Invalid Values (Grapevine VSP)") {
1707 // Test that out-of-range values raise errors
1708 capture_cerr cerr_buffer;
1710 PlantArchitecture plantarchitecture(&context);
1711 plantarchitecture.disableMessages();
1712
1713 plantarchitecture.loadPlantModelFromLibrary("grapevine_VSP");
1714
1715 // Test vine_spacing out of range (valid range: 0.5-5.0)
1716 std::map<std::string, float> invalid_params1 = {{"vine_spacing", 10.0f}};
1717 DOCTEST_CHECK_THROWS(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0, invalid_params1));
1718
1719 // Test trunk_height out of range (valid range: 0.05-1.0)
1720 std::map<std::string, float> invalid_params2 = {{"trunk_height", 2.0f}};
1721 DOCTEST_CHECK_THROWS(plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0, invalid_params2));
1722}
1723
1724DOCTEST_TEST_CASE("Build Parameters - Grapevine Wye Trellis Parameters") {
1725 // Test Wye grapevine specific trellis parameters
1727 PlantArchitecture plantarchitecture(&context);
1728 plantarchitecture.disableMessages();
1729
1730 plantarchitecture.loadPlantModelFromLibrary("grapevine_Wye");
1731 std::map<std::string, float> trellis_params = {
1732 {"trunk_height", 0.2f}, // 20 cm trunk height
1733 {"cordon_spacing", 0.8f}, // 80 cm between cordon rows
1734 {"vine_spacing", 2.0f}, // 2 m between plants
1735 {"catch_wire_height", 2.5f} // 2.5 m catch wire height
1736 };
1737 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0, trellis_params);
1738
1739 DOCTEST_CHECK(plantID != uint(-1));
1740 std::vector<uint> plant_primitives = plantarchitecture.getAllPlantObjectIDs(plantID);
1741 DOCTEST_CHECK(plant_primitives.size() > 0);
1742}
1743
1744DOCTEST_TEST_CASE("Build Parameters - Tree Training System (Almond)") {
1745 // Test tree training parameters
1747 PlantArchitecture plantarchitecture(&context);
1748 plantarchitecture.disableMessages();
1749
1750 // Note: trunk_height limited by trunk max_nodes (20) * internode_length (0.03m) = 0.6m max
1751 plantarchitecture.loadPlantModelFromLibrary("almond");
1752 std::map<std::string, float> tree_params = {
1753 {"trunk_height", 0.5f}, // 50 cm total trunk height (within max_nodes limit)
1754 {"num_scaffolds", 5.0f}, // 5 scaffold branches
1755 {"scaffold_angle", 35.0f} // 35 degree scaffold angle
1756 };
1757 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000, tree_params);
1758
1759 DOCTEST_CHECK(plantID != uint(-1));
1760 std::vector<uint> plant_primitives = plantarchitecture.getAllPlantObjectIDs(plantID);
1761 DOCTEST_CHECK(plant_primitives.size() > 0);
1762}
1763
1764DOCTEST_TEST_CASE("Build Parameters - Apple Tree") {
1765 // Test apple tree with custom parameters
1767 PlantArchitecture plantarchitecture(&context);
1768 plantarchitecture.disableMessages();
1769
1770 // Note: trunk_height limited by trunk max_nodes (20) * internode_length (0.04m) = 0.8m max
1771 plantarchitecture.loadPlantModelFromLibrary("apple");
1772 std::map<std::string, float> apple_params = {
1773 {"trunk_height", 0.7f}, // 70 cm trunk height (within max_nodes limit)
1774 {"num_scaffolds", 6.0f}, // 6 scaffold branches
1775 {"scaffold_angle", 45.0f} // 45 degree scaffold angle
1776 };
1777 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000, apple_params);
1778
1779 DOCTEST_CHECK(plantID != uint(-1));
1780}
1781
1782DOCTEST_TEST_CASE("Build Parameters - Pistachio Tree Fixed Scaffold System") {
1783 // Test pistachio tree with different scaffold count
1785 PlantArchitecture plantarchitecture(&context);
1786 plantarchitecture.disableMessages();
1787
1788 plantarchitecture.loadPlantModelFromLibrary("pistachio");
1789
1790 // Test with 2 scaffolds (minimum)
1791 std::map<std::string, float> pistachio_params_min = {{"num_scaffolds", 2.0f}};
1792 uint plantID_min = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000, pistachio_params_min);
1793 DOCTEST_CHECK(plantID_min != uint(-1));
1794
1795 // Test with 4 scaffolds (default)
1796 std::map<std::string, float> pistachio_params_def = {{"num_scaffolds", 4.0f}};
1797 uint plantID_def = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(5, 0, 0), 5000, pistachio_params_def);
1798 DOCTEST_CHECK(plantID_def != uint(-1));
1799}
1800
1801DOCTEST_TEST_CASE("Build Parameters - Canopy Building with Parameters") {
1802 // Test that parameters work with canopy building functions
1804 PlantArchitecture plantarchitecture(&context);
1805 plantarchitecture.disableMessages();
1806
1807 plantarchitecture.loadPlantModelFromLibrary("grapevine_VSP");
1808 std::map<std::string, float> canopy_params = {
1809 {"vine_spacing", 2.0f}, // 2.0m vine spacing
1810 {"trunk_height", 0.12f} // 12 cm trunk height
1811 };
1812
1813 // Test regular spacing canopy
1814 std::vector<uint> plantIDs = plantarchitecture.buildPlantCanopyFromLibrary(make_vec3(0, 0, 0), make_vec2(2, 2), make_int2(2, 2), 0, 1.0f, canopy_params);
1815
1816 DOCTEST_CHECK(plantIDs.size() == 4);
1817 for (uint plantID: plantIDs) {
1818 DOCTEST_CHECK(plantID != uint(-1));
1819 }
1820}
1821
1822DOCTEST_TEST_CASE("Build Parameters - Type Casting Float to Uint") {
1823 // Test that float parameters correctly cast to uint for node counts
1825 PlantArchitecture plantarchitecture(&context);
1826 plantarchitecture.disableMessages();
1827
1828 plantarchitecture.loadPlantModelFromLibrary("almond");
1829
1830 // Specify parameters as floats (should cast to uint internally where needed)
1831 // Note: trunk_height limited by trunk max_nodes (20) * internode_length (0.03m) = 0.6m max
1832 std::map<std::string, float> float_params = {
1833 {"trunk_height", 0.5f}, // Height as float (within max_nodes limit)
1834 {"num_scaffolds", 5.0f}, // Should cast to uint(5)
1835 {"scaffold_angle", 42.5f} // Angle as float
1836 };
1837
1838 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000, float_params);
1839 DOCTEST_CHECK(plantID != uint(-1));
1840}
1841
1842DOCTEST_TEST_CASE("PlantArchitecture optionalOutputObjectData 'all' keyword") {
1844 PlantArchitecture plantarchitecture(&context);
1845 plantarchitecture.disableMessages();
1846
1847 // Test that "all" (lowercase) enables all optional output data labels
1848 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("all"));
1849
1850 // Build a bean plant to verify data is actually being output
1851 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("bean"));
1852 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1853 DOCTEST_CHECK(plantID != uint(-1));
1854
1855 // Advance time to create some organs
1856 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 10.0f));
1857
1858 // Get all object IDs
1859 std::vector<uint> all_primitives = plantarchitecture.getAllObjectIDs();
1860 DOCTEST_CHECK(all_primitives.size() > 0);
1861
1862 // Verify that basic metadata labels are present (these exist on all plants)
1863 // Note: Organ-specific labels (peduncleID, flowerID, fruitID) may not exist
1864 // if the plant hasn't developed those organs yet at this age
1865 std::vector<std::string> expected_labels = {"age", "rank", "plantID", "plant_name", "plant_height", "plant_type", "phenology_stage", "leafID"};
1866
1867 for (const auto &label: expected_labels) {
1868 bool found = false;
1869 for (uint objID: all_primitives) {
1870 if (context.doesObjectDataExist(objID, label.c_str())) {
1871 found = true;
1872 break;
1873 }
1874 }
1875 DOCTEST_CHECK_MESSAGE(found, "Label '" << label << "' was not found on any primitive");
1876 }
1877}
1878
1879DOCTEST_TEST_CASE("PlantArchitecture optionalOutputObjectData 'all' case-insensitive") {
1880 // Test "ALL" (uppercase)
1881 {
1883 PlantArchitecture plantarchitecture(&context);
1884 plantarchitecture.disableMessages();
1885 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("ALL"));
1886 }
1887
1888 // Test "All" (mixed case)
1889 {
1891 PlantArchitecture plantarchitecture(&context);
1892 plantarchitecture.disableMessages();
1893 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("All"));
1894 }
1895
1896 // Test "aLl" (random mixed case)
1897 {
1899 PlantArchitecture plantarchitecture(&context);
1900 plantarchitecture.disableMessages();
1901 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("aLl"));
1902 }
1903}
1904
1905DOCTEST_TEST_CASE("PlantArchitecture optionalOutputObjectData invalid label throws error") {
1907 PlantArchitecture plantarchitecture(&context);
1908 plantarchitecture.disableMessages();
1909
1910 // Test that an invalid label throws a helios_runtime_error with descriptive message
1911 bool caught_error = false;
1912 try {
1913 plantarchitecture.optionalOutputObjectData("invalid_label");
1914 } catch (const std::exception &e) {
1915 caught_error = true;
1916 std::string error_msg(e.what());
1917 DOCTEST_CHECK(error_msg.find("invalid_label") != std::string::npos);
1918 DOCTEST_CHECK(error_msg.find("not a valid option") != std::string::npos);
1919 }
1920 DOCTEST_CHECK(caught_error);
1921
1922 // Note: helios_runtime_error() only writes to stderr when HELIOS_DEBUG is defined,
1923 // so we don't check stderr output here - just verify the exception is thrown correctly
1924}
1925
1926DOCTEST_TEST_CASE("PlantArchitecture optionalOutputObjectData vector with 'all'") {
1928 PlantArchitecture plantarchitecture(&context);
1929 plantarchitecture.disableMessages();
1930
1931 // Test that "all" works in a vector of labels
1932 std::vector<std::string> labels = {"all"};
1933 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData(labels));
1934
1935 // Build a bean plant to verify data is actually being output
1936 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("bean"));
1937 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1938 DOCTEST_CHECK(plantID != uint(-1));
1939
1940 // Advance time to create more organs
1941 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 10.0f));
1942
1943 // Get all object IDs
1944 std::vector<uint> all_primitives = plantarchitecture.getAllObjectIDs();
1945 DOCTEST_CHECK(all_primitives.size() > 0);
1946
1947 // Verify that at least a few optional output data labels are present
1948 bool found_age = false;
1949 bool found_rank = false;
1950 bool found_plant_name = false;
1951 for (uint objID: all_primitives) {
1952 if (context.doesObjectDataExist(objID, "age"))
1953 found_age = true;
1954 if (context.doesObjectDataExist(objID, "rank"))
1955 found_rank = true;
1956 if (context.doesObjectDataExist(objID, "plant_name"))
1957 found_plant_name = true;
1958 }
1959 DOCTEST_CHECK(found_age);
1960 DOCTEST_CHECK(found_rank);
1961 DOCTEST_CHECK(found_plant_name);
1962}
1963
1964DOCTEST_TEST_CASE("PlantArchitecture optionalOutputObjectData normal labels still work") {
1966 PlantArchitecture plantarchitecture(&context);
1967 plantarchitecture.disableMessages();
1968
1969 // Test that individual labels still work as expected
1970 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("age"));
1971 DOCTEST_CHECK_NOTHROW(plantarchitecture.optionalOutputObjectData("rank"));
1972
1973 // Build a bean plant
1974 DOCTEST_CHECK_NOTHROW(plantarchitecture.loadPlantModelFromLibrary("bean"));
1975 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
1976 DOCTEST_CHECK(plantID != uint(-1));
1977
1978 // Advance time
1979 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 5.0f));
1980
1981 // Verify that age and rank data exist, but other optional data does not
1982 std::vector<uint> all_primitives = plantarchitecture.getAllObjectIDs();
1983 DOCTEST_CHECK(all_primitives.size() > 0);
1984
1985 bool found_age = false;
1986 bool found_rank = false;
1987 bool found_plant_name = false; // This should NOT be found
1988 for (uint objID: all_primitives) {
1989 if (context.doesObjectDataExist(objID, "age"))
1990 found_age = true;
1991 if (context.doesObjectDataExist(objID, "rank"))
1992 found_rank = true;
1993 if (context.doesObjectDataExist(objID, "plant_name"))
1994 found_plant_name = true;
1995 }
1996 DOCTEST_CHECK(found_age);
1997 DOCTEST_CHECK(found_rank);
1998 DOCTEST_CHECK_FALSE(found_plant_name); // Should NOT be enabled
1999}
2000
2001// ==================== NITROGEN MODEL TESTS ==================== //
2002
2003DOCTEST_TEST_CASE("Nitrogen Model - Initialization") {
2005 PlantArchitecture plantarchitecture(&context);
2006 plantarchitecture.disableMessages();
2007
2008 // Enable nitrogen model
2009 plantarchitecture.enableNitrogenModel();
2010 DOCTEST_CHECK(plantarchitecture.isNitrogenModelEnabled());
2011
2012 // Build a simple plant
2013 plantarchitecture.loadPlantModelFromLibrary("bean");
2014 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2015
2016 // Grow plant to create leaves
2017 plantarchitecture.advanceTime(plantID, 5.0f);
2018
2019 // Initialize nitrogen pools with target concentration
2020 float initial_N_concentration = 1.5f; // g N/m² (target value)
2021 plantarchitecture.initializePlantNitrogenPools(plantID, initial_N_concentration);
2022
2023 // Advance time to trigger nitrogen stress calculation and output writing
2024 plantarchitecture.advanceTime(plantID, 0.1f);
2025
2026 // Get all leaf objects
2027 std::vector<uint> all_objects = plantarchitecture.getAllPlantObjectIDs(plantID);
2028 DOCTEST_CHECK(all_objects.size() > 0);
2029
2030 // Verify leaf nitrogen content was initialized
2031 bool found_leaf_N = false;
2032 for (uint objID: all_objects) {
2033 if (context.doesObjectDataExist(objID, "leaf_nitrogen_gN_m2")) {
2034 float leaf_N_area;
2035 context.getObjectData(objID, "leaf_nitrogen_gN_m2", leaf_N_area);
2036 DOCTEST_CHECK(leaf_N_area == doctest::Approx(initial_N_concentration).epsilon(0.1));
2037 found_leaf_N = true;
2038 }
2039 }
2040 DOCTEST_CHECK(found_leaf_N);
2041}
2042
2043DOCTEST_TEST_CASE("Nitrogen Model - Application and Pool Splitting") {
2045 PlantArchitecture plantarchitecture(&context);
2046 plantarchitecture.disableMessages();
2047
2048 plantarchitecture.enableNitrogenModel();
2049 plantarchitecture.loadPlantModelFromLibrary("bean");
2050 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2051 plantarchitecture.advanceTime(plantID, 3.0f);
2052
2053 // Initialize with zero nitrogen
2054 plantarchitecture.initializePlantNitrogenPools(plantID, 0.0f);
2055
2056 // Apply 10 g N to plant
2057 float N_applied = 10.0f; // g N
2058 plantarchitecture.addPlantNitrogen(plantID, N_applied);
2059
2060 // Verify nitrogen was split between root (15%) and available (85%) pools
2061 // We can't directly access the pools, but we can verify by advancing time
2062 // and checking that leaves accumulate nitrogen from the available pool
2063 plantarchitecture.advanceTime(plantID, 1.0f);
2064
2065 // Check that leaves now have nitrogen > 0
2066 std::vector<uint> all_objects = plantarchitecture.getAllPlantObjectIDs(plantID);
2067 bool found_N_accumulation = false;
2068 for (uint objID: all_objects) {
2069 if (context.doesObjectDataExist(objID, "leaf_nitrogen_gN_m2")) {
2070 float leaf_N_area;
2071 context.getObjectData(objID, "leaf_nitrogen_gN_m2", leaf_N_area);
2072 if (leaf_N_area > 0) {
2073 found_N_accumulation = true;
2074 break;
2075 }
2076 }
2077 }
2078 DOCTEST_CHECK(found_N_accumulation);
2079}
2080
2081DOCTEST_TEST_CASE("Nitrogen Model - Rate Limiting") {
2083 PlantArchitecture plantarchitecture(&context);
2084 plantarchitecture.disableMessages();
2085
2086 plantarchitecture.enableNitrogenModel();
2087 plantarchitecture.loadPlantModelFromLibrary("bean");
2088 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2089 plantarchitecture.advanceTime(plantID, 5.0f);
2090
2091 // Initialize with zero nitrogen
2092 plantarchitecture.initializePlantNitrogenPools(plantID, 0.0f);
2093
2094 // Set nitrogen parameters with known max accumulation rate
2095 NitrogenParameters N_params;
2096 N_params.max_N_accumulation_rate = 0.1f; // g N/m²/day
2097 N_params.target_leaf_N_area = 10.0f; // Very high target to ensure demand > rate
2098 plantarchitecture.setPlantNitrogenParameters(plantID, N_params);
2099
2100 // Apply large amount of nitrogen
2101 plantarchitecture.addPlantNitrogen(plantID, 100.0f);
2102
2103 // Advance time by 1 day
2104 float dt = 1.0f;
2105 plantarchitecture.advanceTime(plantID, dt);
2106
2107 // Check that leaf nitrogen didn't exceed rate limit
2108 std::vector<uint> all_objects = plantarchitecture.getAllPlantObjectIDs(plantID);
2109 for (uint objID: all_objects) {
2110 if (context.doesObjectDataExist(objID, "leaf_nitrogen_gN_m2")) {
2111 float leaf_N_area;
2112 context.getObjectData(objID, "leaf_nitrogen_gN_m2", leaf_N_area);
2113 // Should be at most max_N_accumulation_rate * dt
2114 DOCTEST_CHECK(leaf_N_area <= N_params.max_N_accumulation_rate * dt * 1.01f); // 1% tolerance
2115 }
2116 }
2117}
2118
2119DOCTEST_TEST_CASE("Nitrogen Model - Stress Factor Output") {
2121 PlantArchitecture plantarchitecture(&context);
2122 plantarchitecture.disableMessages();
2123
2124 plantarchitecture.enableNitrogenModel();
2125 plantarchitecture.loadPlantModelFromLibrary("bean");
2126 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2127 plantarchitecture.advanceTime(plantID, 5.0f);
2128
2129 // Initialize with low nitrogen (stress condition)
2130 plantarchitecture.initializePlantNitrogenPools(plantID, 0.5f); // Below target of 1.5
2131
2132 // Advance time to trigger stress factor calculation
2133 plantarchitecture.advanceTime(plantID, 0.1f);
2134
2135 // Verify stress factor exists and is in valid range [0, 1]
2136 std::vector<uint> plant_objects = plantarchitecture.getAllPlantObjectIDs(plantID);
2137 DOCTEST_CHECK(plant_objects.size() > 0);
2138
2139 bool found_stress_factor = false;
2140 for (uint objID: plant_objects) {
2141 if (context.doesObjectDataExist(objID, "nitrogen_stress_factor")) {
2142 float stress_factor;
2143 context.getObjectData(objID, "nitrogen_stress_factor", stress_factor);
2144 DOCTEST_CHECK(stress_factor >= 0.0f);
2145 DOCTEST_CHECK(stress_factor <= 1.0f);
2146 // With low N, stress should be less than 1
2147 DOCTEST_CHECK(stress_factor < 1.0f);
2148 found_stress_factor = true;
2149 break;
2150 }
2151 }
2152 DOCTEST_CHECK(found_stress_factor);
2153}
2154
2155DOCTEST_TEST_CASE("Nitrogen Model - Remobilization") {
2157 PlantArchitecture plantarchitecture(&context);
2158 plantarchitecture.disableMessages();
2159
2160 plantarchitecture.enableNitrogenModel();
2161 plantarchitecture.loadPlantModelFromLibrary("bean");
2162 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2163
2164 // Grow plant to create leaves of different ages
2165 plantarchitecture.advanceTime(plantID, 15.0f);
2166
2167 // Initialize with low nitrogen to create stress condition
2168 plantarchitecture.initializePlantNitrogenPools(plantID, 0.8f); // Below target
2169
2170 // Advance time significantly to age leaves and trigger remobilization
2171 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 25.0f));
2172
2173 // Verify nitrogen stress factor reflects stress condition
2174 std::vector<uint> plant_objects = plantarchitecture.getAllPlantObjectIDs(plantID);
2175 bool found_stress_factor = false;
2176 for (uint objID: plant_objects) {
2177 if (context.doesObjectDataExist(objID, "nitrogen_stress_factor")) {
2178 float stress_factor;
2179 context.getObjectData(objID, "nitrogen_stress_factor", stress_factor);
2180 DOCTEST_CHECK(stress_factor < 1.0f); // Should indicate some stress
2181 found_stress_factor = true;
2182 break;
2183 }
2184 }
2185 DOCTEST_CHECK(found_stress_factor);
2186}
2187
2188DOCTEST_TEST_CASE("Nitrogen Model - Fruit Removal") {
2190 PlantArchitecture plantarchitecture(&context);
2191 plantarchitecture.disableMessages();
2192
2193 plantarchitecture.enableNitrogenModel();
2194
2195 // Use tomato which produces fruit
2196 plantarchitecture.loadPlantModelFromLibrary("tomato");
2197 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2198
2199 // Grow plant to vegetative stage
2200 plantarchitecture.advanceTime(plantID, 30.0f);
2201
2202 // Initialize with adequate nitrogen
2203 plantarchitecture.initializePlantNitrogenPools(plantID, 1.5f);
2204
2205 // Add nitrogen to available pool
2206 plantarchitecture.addPlantNitrogen(plantID, 50.0f);
2207
2208 // Continue growth to allow fruiting
2209 plantarchitecture.advanceTime(plantID, 40.0f);
2210
2211 // Verify plant grew (basic sanity check)
2212 std::vector<uint> plant_objects = plantarchitecture.getAllPlantObjectIDs(plantID);
2213 DOCTEST_CHECK(plant_objects.size() > 0);
2214
2215 // Nitrogen stress factor should exist
2216 bool found_stress_factor = false;
2217 for (uint objID: plant_objects) {
2218 if (context.doesObjectDataExist(objID, "nitrogen_stress_factor")) {
2219 found_stress_factor = true;
2220 break;
2221 }
2222 }
2223 DOCTEST_CHECK(found_stress_factor);
2224}
2225
2226DOCTEST_TEST_CASE("Nitrogen Model - Leaf-to-Fruit Translocation") {
2227 // When the available nitrogen pool cannot cover fruit demand, removeFruitNitrogen draws the
2228 // shortfall from leaves (old leaves first, then young leaves as fallback). To isolate
2229 // translocation cleanly, we override remobilization_age_threshold to a value age_fraction
2230 // never reaches, which disables the leaf-to-leaf remobilization pathway. With remobilization
2231 // disabled and the available pool empty, the only mechanism that can reduce a leaf below the
2232 // target N concentration is leaf-to-fruit translocation.
2233
2235 PlantArchitecture plantarchitecture(&context);
2236 plantarchitecture.disableMessages();
2237
2238 plantarchitecture.enableNitrogenModel();
2239 plantarchitecture.loadPlantModelFromLibrary("tomato");
2240 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2241
2242 // Disable leaf-to-leaf remobilization by setting an unreachable age threshold (age_fraction <= 1).
2243 NitrogenParameters N_params; // defaults: target=1.5, minimum=0.5, efficiency=0.7
2244 N_params.remobilization_age_threshold = 2.0f;
2245 plantarchitecture.setPlantNitrogenParameters(plantID, N_params);
2246
2247 // Grow plant well past fruit-set so fruits exist when we initialize and snapshot.
2248 // Tomato in this library typically starts fruit set around day 40-50; advance past that.
2249 plantarchitecture.advanceTime(plantID, 60.0f);
2250
2251 // Skip the test if the plant did not produce fruit in this run (random plant growth can
2252 // sometimes produce no fruits within the window). The translocation pathway only exercises
2253 // when fruits actively grow, so we need fruits to be present.
2254 std::vector<uint> fruit_objIDs = plantarchitecture.getPlantFruitObjectIDs(plantID);
2255 if (fruit_objIDs.empty()) {
2256 // Try a longer window before giving up.
2257 plantarchitecture.advanceTime(plantID, 30.0f);
2258 fruit_objIDs = plantarchitecture.getPlantFruitObjectIDs(plantID);
2259 }
2260 if (fruit_objIDs.empty()) {
2261 return; // No fruits formed in this run; nothing to test.
2262 }
2263
2264 // Reset leaves to target N (overwrites any drainage that occurred during the warm-up advance);
2265 // do NOT call addPlantNitrogen so the available pool stays empty and any further fruit demand
2266 // must come from leaves via translocation.
2267 plantarchitecture.initializePlantNitrogenPools(plantID, N_params.target_leaf_N_area);
2268
2269 // Trigger an output write so leaf_nitrogen_gN_m2 is materialized as object data
2270 plantarchitecture.advanceTime(plantID, 0.1f);
2271
2272 // Sanity: at least one leaf is at the target initially
2273 bool any_leaf_at_target_pre = false;
2274 for (uint objID: plantarchitecture.getAllPlantObjectIDs(plantID)) {
2275 if (context.doesObjectDataExist(objID, "leaf_nitrogen_gN_m2")) {
2276 float leaf_N_area;
2277 context.getObjectData(objID, "leaf_nitrogen_gN_m2", leaf_N_area);
2278 if (std::abs(leaf_N_area - N_params.target_leaf_N_area) < 0.01f) {
2279 any_leaf_at_target_pre = true;
2280 break;
2281 }
2282 }
2283 }
2284 DOCTEST_CHECK(any_leaf_at_target_pre);
2285
2286 // Advance through ongoing fruit growth. With remobilization disabled and the pool empty, the
2287 // only path that can drop a leaf below target is translocation to fruit.
2288 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 30.0f));
2289
2290 // Look for evidence of translocation: at least one leaf that started at target now sits below
2291 // it (but at or above the per-leaf floor). Newly grown leaves with N == 0 are excluded by
2292 // requiring leaf_N_area > minimum_leaf_N_area.
2293 bool any_leaf_drained_below_target = false;
2294 float min_leaf_N_observed = std::numeric_limits<float>::infinity();
2295 bool any_leaf_with_N = false;
2296 for (uint objID: plantarchitecture.getAllPlantObjectIDs(plantID)) {
2297 if (context.doesObjectDataExist(objID, "leaf_nitrogen_gN_m2")) {
2298 float leaf_N_area;
2299 context.getObjectData(objID, "leaf_nitrogen_gN_m2", leaf_N_area);
2300 if (leaf_N_area > N_params.minimum_leaf_N_area && leaf_N_area < N_params.target_leaf_N_area - 0.01f) {
2301 any_leaf_drained_below_target = true;
2302 }
2303 if (leaf_N_area > 1e-4f) {
2304 min_leaf_N_observed = std::min(min_leaf_N_observed, leaf_N_area);
2305 any_leaf_with_N = true;
2306 }
2307 }
2308 }
2309
2310 // Confirm fruits still exist at the end of the test window (sanity check that fruit demand
2311 // was active for at least part of the post-advance period).
2312 fruit_objIDs = plantarchitecture.getPlantFruitObjectIDs(plantID);
2313
2314 // Translocation drained at least one initialized leaf below the target.
2315 if (!fruit_objIDs.empty()) {
2316 DOCTEST_CHECK(any_leaf_drained_below_target);
2317 }
2318
2319 // Per-leaf floor: with translocation only able to remove (current - minimum) * efficiency, a
2320 // fully drained leaf bottoms out at minimum + (initial - minimum)(1 - efficiency) = 0.8 g N/m²
2321 // for the defaults. Assert at least minimum_leaf_N_area as a slack lower bound.
2322 if (any_leaf_with_N) {
2323 DOCTEST_CHECK(min_leaf_N_observed >= N_params.minimum_leaf_N_area - 1e-3f);
2324 }
2325
2326 // Stress factor output still written
2327 bool found_stress_factor = false;
2328 for (uint objID: plantarchitecture.getAllPlantObjectIDs(plantID)) {
2329 if (context.doesObjectDataExist(objID, "nitrogen_stress_factor")) {
2330 found_stress_factor = true;
2331 break;
2332 }
2333 }
2334 DOCTEST_CHECK(found_stress_factor);
2335}
2336
2337DOCTEST_TEST_CASE("Nitrogen Model - No Translocation When Pool Adequate") {
2338 // Negative control: with leaf-to-leaf remobilization disabled (unreachable threshold) AND a
2339 // well-stocked available pool, no drainage pathway should be active. Pre-existing leaves at
2340 // target N must remain at target after fruiting (translocation never triggers because the pool
2341 // covers demand). New leaves grown later may have lower N because accumulation is rate-limited,
2342 // so we only check the pre-existing initialized leaves.
2343
2345 PlantArchitecture plantarchitecture(&context);
2346 plantarchitecture.disableMessages();
2347
2348 plantarchitecture.enableNitrogenModel();
2349 plantarchitecture.loadPlantModelFromLibrary("tomato");
2350 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2351
2352 NitrogenParameters N_params;
2353 N_params.remobilization_age_threshold = 2.0f; // Disable leaf-to-leaf remobilization
2354 plantarchitecture.setPlantNitrogenParameters(plantID, N_params);
2355
2356 plantarchitecture.advanceTime(plantID, 30.0f);
2357 plantarchitecture.initializePlantNitrogenPools(plantID, N_params.target_leaf_N_area);
2358 plantarchitecture.addPlantNitrogen(plantID, 200.0f); // Generously stock so pool always covers fruit demand
2359 plantarchitecture.advanceTime(plantID, 0.1f); // Materialize object data
2360
2361 // Capture pre-existing leaves that are at the target N concentration
2362 std::vector<uint> leaves_at_target_pre;
2363 for (uint objID: plantarchitecture.getAllPlantObjectIDs(plantID)) {
2364 if (context.doesObjectDataExist(objID, "leaf_nitrogen_gN_m2")) {
2365 float leaf_N_area;
2366 context.getObjectData(objID, "leaf_nitrogen_gN_m2", leaf_N_area);
2367 if (std::abs(leaf_N_area - N_params.target_leaf_N_area) < 0.01f) {
2368 leaves_at_target_pre.push_back(objID);
2369 }
2370 }
2371 }
2372 DOCTEST_CHECK(leaves_at_target_pre.size() > 0);
2373
2374 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 40.0f));
2375
2376 // Pre-existing leaves at target should remain at (or very near) target. With remobilization
2377 // disabled and the pool adequate to cover fruit demand, no drainage pathway is active.
2378 int leaves_intact = 0;
2379 for (uint objID: leaves_at_target_pre) {
2380 if (!context.doesObjectExist(objID)) {
2381 continue;
2382 }
2383 if (!context.doesObjectDataExist(objID, "leaf_nitrogen_gN_m2")) {
2384 continue;
2385 }
2386 float leaf_N_area;
2387 context.getObjectData(objID, "leaf_nitrogen_gN_m2", leaf_N_area);
2388 if (leaf_N_area >= N_params.target_leaf_N_area - 0.05f) {
2389 leaves_intact++;
2390 }
2391 }
2392 DOCTEST_CHECK(leaves_intact > 0);
2393}
2394
2395DOCTEST_TEST_CASE("Nitrogen Model - Full Growth Cycle Integration") {
2397 PlantArchitecture plantarchitecture(&context);
2398 plantarchitecture.disableMessages();
2399
2400 plantarchitecture.enableNitrogenModel();
2401 plantarchitecture.loadPlantModelFromLibrary("bean");
2402 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2403
2404 // Initial growth
2405 plantarchitecture.advanceTime(plantID, 5.0f);
2406
2407 // Initialize nitrogen
2408 plantarchitecture.initializePlantNitrogenPools(plantID, 1.0f);
2409
2410 // Simulate periodic nitrogen applications during growth
2411 for (int i = 0; i < 5; i++) {
2412 plantarchitecture.addPlantNitrogen(plantID, 5.0f); // Add 5 g N
2413 plantarchitecture.advanceTime(plantID, 5.0f); // Grow 5 days
2414 }
2415
2416 // Verify plant completed growth cycle
2417 std::vector<uint> plant_objects = plantarchitecture.getAllPlantObjectIDs(plantID);
2418 DOCTEST_CHECK(plant_objects.size() > 0);
2419
2420 // Verify stress factor updated throughout
2421 bool found_stress_factor = false;
2422 float final_stress = 0;
2423 for (uint objID: plant_objects) {
2424 if (context.doesObjectDataExist(objID, "nitrogen_stress_factor")) {
2425 context.getObjectData(objID, "nitrogen_stress_factor", final_stress);
2426 found_stress_factor = true;
2427 break;
2428 }
2429 }
2430 DOCTEST_CHECK(found_stress_factor);
2431 DOCTEST_CHECK(final_stress >= 0.0f);
2432 DOCTEST_CHECK(final_stress <= 1.0f);
2433
2434 // Verify leaves have nitrogen data
2435 bool found_leaf_N = false;
2436 for (uint objID: plant_objects) {
2437 if (context.doesObjectDataExist(objID, "leaf_nitrogen_gN_m2")) {
2438 float leaf_N;
2439 context.getObjectData(objID, "leaf_nitrogen_gN_m2", leaf_N);
2440 DOCTEST_CHECK(leaf_N >= 0.0f);
2441 found_leaf_N = true;
2442 }
2443 }
2444 DOCTEST_CHECK(found_leaf_N);
2445}
2446
2447DOCTEST_TEST_CASE("Nitrogen Model - Edge Case: Zero Nitrogen") {
2449 PlantArchitecture plantarchitecture(&context);
2450 plantarchitecture.disableMessages();
2451
2452 plantarchitecture.enableNitrogenModel();
2453 plantarchitecture.loadPlantModelFromLibrary("bean");
2454 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2455 plantarchitecture.advanceTime(plantID, 5.0f);
2456
2457 // Initialize with zero nitrogen - should not crash
2458 DOCTEST_CHECK_NOTHROW(plantarchitecture.initializePlantNitrogenPools(plantID, 0.0f));
2459
2460 // Advance time with zero nitrogen - should not crash
2461 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 5.0f));
2462
2463 // Stress factor should be very low (severe stress)
2464 std::vector<uint> plant_objects = plantarchitecture.getAllPlantObjectIDs(plantID);
2465 bool found_stress_factor = false;
2466 for (uint objID: plant_objects) {
2467 if (context.doesObjectDataExist(objID, "nitrogen_stress_factor")) {
2468 float stress_factor;
2469 context.getObjectData(objID, "nitrogen_stress_factor", stress_factor);
2470 DOCTEST_CHECK(stress_factor < 0.2f); // Should be low under zero N
2471 found_stress_factor = true;
2472 break;
2473 }
2474 }
2475 DOCTEST_CHECK(found_stress_factor);
2476}
2477
2478DOCTEST_TEST_CASE("Nitrogen Model - Edge Case: Excessive Nitrogen") {
2480 PlantArchitecture plantarchitecture(&context);
2481 plantarchitecture.disableMessages();
2482
2483 plantarchitecture.enableNitrogenModel();
2484 plantarchitecture.loadPlantModelFromLibrary("bean");
2485 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2486 plantarchitecture.advanceTime(plantID, 5.0f);
2487
2488 // Initialize with zero
2489 plantarchitecture.initializePlantNitrogenPools(plantID, 0.0f);
2490
2491 // Set high accumulation rate to overcome rate limiting
2492 NitrogenParameters N_params;
2493 N_params.max_N_accumulation_rate = 1.0f; // g N/m²/day (10x default)
2494 plantarchitecture.setPlantNitrogenParameters(plantID, N_params);
2495
2496 // Apply excessive nitrogen - should not crash
2497 DOCTEST_CHECK_NOTHROW(plantarchitecture.addPlantNitrogen(plantID, 1000.0f));
2498
2499 // Advance time - should not crash
2500 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 5.0f));
2501
2502 // Stress factor should clamp at 1.0 (no stress) and be high with excess N
2503 std::vector<uint> plant_objects = plantarchitecture.getAllPlantObjectIDs(plantID);
2504 bool found_stress_factor = false;
2505 for (uint objID: plant_objects) {
2506 if (context.doesObjectDataExist(objID, "nitrogen_stress_factor")) {
2507 float stress_factor;
2508 context.getObjectData(objID, "nitrogen_stress_factor", stress_factor);
2509 DOCTEST_CHECK(stress_factor <= 1.0f); // Should clamp at 1.0
2510 DOCTEST_CHECK(stress_factor >= 0.90f); // Should be very high with excess N and fast accumulation
2511 found_stress_factor = true;
2512 break;
2513 }
2514 }
2515 DOCTEST_CHECK(found_stress_factor);
2516}
2517
2518DOCTEST_TEST_CASE("Nitrogen Model - Edge Case: No Leaves") {
2520 PlantArchitecture plantarchitecture(&context);
2521 plantarchitecture.disableMessages();
2522
2523 plantarchitecture.enableNitrogenModel();
2524
2525 // Build plant at very early stage (no leaves yet)
2526 uint plantID = plantarchitecture.addPlantInstance(make_vec3(0, 0, 0), 0);
2527
2528 // Try to initialize nitrogen - should not crash even with no leaves
2529 DOCTEST_CHECK_NOTHROW(plantarchitecture.initializePlantNitrogenPools(plantID, 1.5f));
2530
2531 // Add nitrogen - should not crash
2532 DOCTEST_CHECK_NOTHROW(plantarchitecture.addPlantNitrogen(plantID, 10.0f));
2533
2534 // Advance time with no leaves - should not crash
2535 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 1.0f));
2536}
2537
2538DOCTEST_TEST_CASE("Nitrogen Model - Division by Zero Prevention") {
2540 PlantArchitecture plantarchitecture(&context);
2541 plantarchitecture.disableMessages();
2542
2543 plantarchitecture.enableNitrogenModel();
2544 plantarchitecture.loadPlantModelFromLibrary("bean");
2545 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2546
2547 // Grow plant slightly to create very small leaves
2548 plantarchitecture.advanceTime(plantID, 0.5f);
2549
2550 // Initialize nitrogen
2551 plantarchitecture.initializePlantNitrogenPools(plantID, 1.5f);
2552
2553 // Add nitrogen and advance - should handle small/zero leaf areas gracefully
2554 plantarchitecture.addPlantNitrogen(plantID, 10.0f);
2555
2556 // This should not crash due to division by zero (bug fix verification)
2557 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 1.0f));
2558
2559 // Continue growth and check remobilization doesn't crash either
2560 plantarchitecture.advanceTime(plantID, 20.0f);
2561 DOCTEST_CHECK_NOTHROW(plantarchitecture.advanceTime(plantID, 5.0f));
2562}
2563
2564DOCTEST_TEST_CASE("Nitrogen Model - Enable/Disable") {
2566 PlantArchitecture plantarchitecture(&context);
2567 plantarchitecture.disableMessages();
2568
2569 // Initially disabled
2570 DOCTEST_CHECK_FALSE(plantarchitecture.isNitrogenModelEnabled());
2571
2572 // Enable
2573 plantarchitecture.enableNitrogenModel();
2574 DOCTEST_CHECK(plantarchitecture.isNitrogenModelEnabled());
2575
2576 // Disable
2577 plantarchitecture.disableNitrogenModel();
2578 DOCTEST_CHECK_FALSE(plantarchitecture.isNitrogenModelEnabled());
2579
2580 // Build plant with model disabled - should not output nitrogen data
2581 plantarchitecture.loadPlantModelFromLibrary("bean");
2582 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2583 plantarchitecture.advanceTime(plantID, 5.0f);
2584
2585 std::vector<uint> plant_objects = plantarchitecture.getAllPlantObjectIDs(plantID);
2586 bool found_nitrogen_data = false;
2587 for (uint objID: plant_objects) {
2588 if (context.doesObjectDataExist(objID, "nitrogen_stress_factor")) {
2589 found_nitrogen_data = true;
2590 break;
2591 }
2592 }
2593 DOCTEST_CHECK_FALSE(found_nitrogen_data); // Should NOT have nitrogen data when disabled
2594}
2595
2596// ===== Tests for listShootTypeLabels() methods =====
2597
2598DOCTEST_TEST_CASE("PlantArchitecture listShootTypeLabels - no parameter success") {
2600 PlantArchitecture plantarchitecture(&context);
2601
2602 plantarchitecture.loadPlantModelFromLibrary("bean");
2603 std::vector<std::string> labels = plantarchitecture.listShootTypeLabels();
2604
2605 DOCTEST_CHECK(labels.size() == 2);
2606 DOCTEST_CHECK(std::find(labels.begin(), labels.end(), "unifoliate") != labels.end());
2607 DOCTEST_CHECK(std::find(labels.begin(), labels.end(), "trifoliate") != labels.end());
2608}
2609
2610DOCTEST_TEST_CASE("PlantArchitecture listShootTypeLabels - no parameter error") {
2611 std::string error_message;
2612 {
2613 capture_cerr cerr_buffer;
2615 PlantArchitecture plantarchitecture(&context);
2616
2617 // Should throw because no plant model is loaded
2618 DOCTEST_CHECK_THROWS(static_cast<void>(plantarchitecture.listShootTypeLabels()));
2619 }
2620}
2621
2622DOCTEST_TEST_CASE("PlantArchitecture listShootTypeLabels - string parameter success") {
2624 PlantArchitecture plantarchitecture(&context);
2625
2626 // Query bean shoot types without loading it
2627 std::vector<std::string> bean_labels = plantarchitecture.listShootTypeLabels("bean");
2628 DOCTEST_CHECK(bean_labels.size() == 2);
2629 DOCTEST_CHECK(std::find(bean_labels.begin(), bean_labels.end(), "unifoliate") != bean_labels.end());
2630 DOCTEST_CHECK(std::find(bean_labels.begin(), bean_labels.end(), "trifoliate") != bean_labels.end());
2631
2632 // Query tomato shoot types
2633 std::vector<std::string> tomato_labels = plantarchitecture.listShootTypeLabels("tomato");
2634 DOCTEST_CHECK(tomato_labels.size() == 1);
2635 DOCTEST_CHECK(std::find(tomato_labels.begin(), tomato_labels.end(), "mainstem") != tomato_labels.end());
2636}
2637
2638DOCTEST_TEST_CASE("PlantArchitecture listShootTypeLabels - string parameter error") {
2639 std::string error_message;
2640 {
2641 capture_cerr cerr_buffer;
2643 PlantArchitecture plantarchitecture(&context);
2644
2645 // Should throw for non-existent plant model
2646 DOCTEST_CHECK_THROWS(static_cast<void>(plantarchitecture.listShootTypeLabels("nonexistent_plant")));
2647 }
2648}
2649
2650DOCTEST_TEST_CASE("PlantArchitecture listShootTypeLabels - state preservation") {
2652 PlantArchitecture plantarchitecture(&context);
2653
2654 // Load bean plant model
2655 plantarchitecture.loadPlantModelFromLibrary("bean");
2656
2657 // Query tomato shoot types (should not change current plant model)
2658 std::vector<std::string> tomato_labels = plantarchitecture.listShootTypeLabels("tomato");
2659
2660 // Verify bean is still loaded by checking current labels
2661 std::vector<std::string> current_labels = plantarchitecture.listShootTypeLabels();
2662 DOCTEST_CHECK(current_labels.size() == 2);
2663 DOCTEST_CHECK(std::find(current_labels.begin(), current_labels.end(), "unifoliate") != current_labels.end());
2664 DOCTEST_CHECK(std::find(current_labels.begin(), current_labels.end(), "trifoliate") != current_labels.end());
2665}
2666
2667DOCTEST_TEST_CASE("PlantArchitecture listShootTypeLabels - all plant models") {
2669 PlantArchitecture plantarchitecture(&context);
2670
2671 std::vector<std::string> all_plants = plantarchitecture.getAvailablePlantModels();
2672
2673 // Should successfully query shoot types for all plants
2674 for (const auto &plant: all_plants) {
2675 std::vector<std::string> labels;
2676 DOCTEST_CHECK_NOTHROW(labels = plantarchitecture.listShootTypeLabels(plant));
2677 DOCTEST_CHECK(!labels.empty()); // All plants should have at least one shoot type
2678 }
2679}
2680
2681DOCTEST_TEST_CASE("PlantArchitecture listShootTypeLabels - uint parameter success") {
2683 PlantArchitecture plantarchitecture(&context);
2684
2685 // Load and build bean plant
2686 plantarchitecture.loadPlantModelFromLibrary("bean");
2687 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2688
2689 // Query by plantID
2690 std::vector<std::string> labels = plantarchitecture.listShootTypeLabels(plantID);
2691
2692 // Should match bean model shoot types
2693 DOCTEST_CHECK(labels.size() == 2);
2694 DOCTEST_CHECK(std::find(labels.begin(), labels.end(), "unifoliate") != labels.end());
2695 DOCTEST_CHECK(std::find(labels.begin(), labels.end(), "trifoliate") != labels.end());
2696}
2697
2698DOCTEST_TEST_CASE("PlantArchitecture listShootTypeLabels - uint parameter error") {
2699 std::string error_message;
2700 {
2701 capture_cerr cerr_buffer;
2703 PlantArchitecture plantarchitecture(&context);
2704
2705 // Should throw for invalid plantID
2706 DOCTEST_CHECK_THROWS(static_cast<void>(plantarchitecture.listShootTypeLabels(999)));
2707 }
2708}
2709
2710DOCTEST_TEST_CASE("PlantArchitecture listShootTypeLabels - multiple instances") {
2712 PlantArchitecture plantarchitecture(&context);
2713
2714 // Build bean plant
2715 plantarchitecture.loadPlantModelFromLibrary("bean");
2716 uint bean_plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0);
2717
2718 // Build tomato plant
2719 plantarchitecture.loadPlantModelFromLibrary("tomato");
2720 uint tomato_plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(1, 0, 0), 0);
2721
2722 // Verify each returns correct labels for its model
2723 std::vector<std::string> bean_labels = plantarchitecture.listShootTypeLabels(bean_plantID);
2724 DOCTEST_CHECK(bean_labels.size() == 2);
2725 DOCTEST_CHECK(std::find(bean_labels.begin(), bean_labels.end(), "unifoliate") != bean_labels.end());
2726 DOCTEST_CHECK(std::find(bean_labels.begin(), bean_labels.end(), "trifoliate") != bean_labels.end());
2727
2728 std::vector<std::string> tomato_labels = plantarchitecture.listShootTypeLabels(tomato_plantID);
2729 DOCTEST_CHECK(tomato_labels.size() == 1);
2730 DOCTEST_CHECK(std::find(tomato_labels.begin(), tomato_labels.end(), "mainstem") != tomato_labels.end());
2731}
2732
2733DOCTEST_TEST_CASE("PlantArchitecture getPlantInternodeObjectIDs with shoot type filter") {
2735 PlantArchitecture plantarchitecture(&context);
2736
2737 // Build a bean plant (has two shoot types: "unifoliate" and "trifoliate")
2738 plantarchitecture.loadPlantModelFromLibrary("bean");
2739 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0.0);
2740
2741 // Get all internode object IDs without filter
2742 std::vector<uint> all_internodes = plantarchitecture.getPlantInternodeObjectIDs(plantID);
2743 DOCTEST_CHECK(all_internodes.size() > 0);
2744
2745 // Get internode object IDs for "unifoliate" shoot type
2746 std::vector<uint> unifoliate_internodes = plantarchitecture.getPlantInternodeObjectIDs(plantID, "unifoliate");
2747 DOCTEST_CHECK(unifoliate_internodes.size() > 0);
2748
2749 // Get internode object IDs for "trifoliate" shoot type
2750 std::vector<uint> trifoliate_internodes = plantarchitecture.getPlantInternodeObjectIDs(plantID, "trifoliate");
2751 DOCTEST_CHECK(trifoliate_internodes.size() > 0);
2752
2753 // Verify that filtered results are subsets of all internodes
2754 for (uint objID : unifoliate_internodes) {
2755 DOCTEST_CHECK(std::find(all_internodes.begin(), all_internodes.end(), objID) != all_internodes.end());
2756 }
2757 for (uint objID : trifoliate_internodes) {
2758 DOCTEST_CHECK(std::find(all_internodes.begin(), all_internodes.end(), objID) != all_internodes.end());
2759 }
2760
2761 // Verify no overlap between unifoliate and trifoliate internodes
2762 for (uint objID : unifoliate_internodes) {
2763 DOCTEST_CHECK(std::find(trifoliate_internodes.begin(), trifoliate_internodes.end(), objID) == trifoliate_internodes.end());
2764 }
2765
2766 // Verify that sum of filtered internodes equals total internodes
2767 DOCTEST_CHECK(unifoliate_internodes.size() + trifoliate_internodes.size() == all_internodes.size());
2768}
2769
2770DOCTEST_TEST_CASE("PlantArchitecture getPlantInternodeObjectIDs with shoot type filter - error cases") {
2771 std::string error_message;
2772 {
2773 capture_cerr cerr_buffer;
2775 PlantArchitecture plantarchitecture(&context);
2776
2777 plantarchitecture.loadPlantModelFromLibrary("bean");
2778 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 0.0);
2779
2780 // Should throw for non-existent shoot type
2781 DOCTEST_CHECK_THROWS(static_cast<void>(plantarchitecture.getPlantInternodeObjectIDs(plantID, "nonexistent_shoot_type")));
2782
2783 // Should throw for invalid plant ID
2784 DOCTEST_CHECK_THROWS(static_cast<void>(plantarchitecture.getPlantInternodeObjectIDs(9999, "unifoliate")));
2785 }
2786}
2787
2788DOCTEST_TEST_CASE("PlantArchitecture setProgressCallback") {
2789 std::vector<float> progress_values;
2790 std::vector<std::string> messages;
2791 {
2792 capture_cout cout_buffer;
2793 capture_cerr cerr_buffer;
2794
2796 PlantArchitecture plantarchitecture(&context);
2797 plantarchitecture.disableMessages();
2798
2799 plantarchitecture.setProgressCallback([&](float progress, const std::string &msg) {
2800 progress_values.push_back(progress);
2801 messages.push_back(msg);
2802 });
2803
2804 plantarchitecture.loadPlantModelFromLibrary("bean");
2805 plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5);
2806
2807 // advanceTime should trigger the callback
2808 plantarchitecture.advanceTime(1.f);
2809 }
2810
2811 // Verify callback was invoked
2812 DOCTEST_CHECK(progress_values.size() > 0);
2813
2814 // Verify progress values are in [0, 1]
2815 for (float p : progress_values) {
2816 DOCTEST_CHECK(p >= 0.f);
2817 DOCTEST_CHECK(p <= 1.f);
2818 }
2819
2820 // Verify the last progress value is 1.0 (complete)
2821 if (!progress_values.empty()) {
2822 DOCTEST_CHECK(progress_values.back() == doctest::Approx(1.0f));
2823 }
2824
2825 // Verify messages are non-empty
2826 for (const auto &msg : messages) {
2827 DOCTEST_CHECK(!msg.empty());
2828 }
2829}
2830
2831DOCTEST_TEST_CASE("getAllPlantUUIDs with include_hidden parameter") {
2833 PlantArchitecture plantarchitecture(&context);
2834 plantarchitecture.disableMessages();
2835 plantarchitecture.loadPlantModelFromLibrary("bean");
2836 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000);
2837
2838 std::vector<uint> uuids_default = plantarchitecture.getAllPlantUUIDs(plantID);
2839 std::vector<uint> uuids_no_hidden = plantarchitecture.getAllPlantUUIDs(plantID, false);
2840 std::vector<uint> uuids_with_hidden = plantarchitecture.getAllPlantUUIDs(plantID, true);
2841
2842 // Default behavior should match explicit false
2843 DOCTEST_CHECK(uuids_default.size() == uuids_no_hidden.size());
2844
2845 // include_hidden=true should return more UUIDs (the hidden prototypes)
2846 DOCTEST_CHECK(uuids_with_hidden.size() > uuids_no_hidden.size());
2847}
2848
2849DOCTEST_TEST_CASE("deletePlantInstance cleans up prototypes when all plants deleted") {
2851 PlantArchitecture plantarchitecture(&context);
2852 plantarchitecture.disableMessages();
2853 plantarchitecture.loadPlantModelFromLibrary("bean");
2854
2855 uint plantID1 = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000);
2856 uint plantID2 = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(1, 0, 0), 5000);
2857
2858 // Identify hidden prototype UUIDs
2859 std::vector<uint> all_uuids = plantarchitecture.getAllPlantUUIDs(plantID1, true);
2860 std::vector<uint> visible_uuids = plantarchitecture.getAllPlantUUIDs(plantID1, false);
2861 DOCTEST_CHECK(all_uuids.size() > visible_uuids.size());
2862
2863 // Collect prototype UUIDs (those in all but not in visible)
2864 std::set<uint> visible_set(visible_uuids.begin(), visible_uuids.end());
2865 std::vector<uint> prototype_uuids;
2866 for (uint uuid : all_uuids) {
2867 if (visible_set.find(uuid) == visible_set.end()) {
2868 prototype_uuids.push_back(uuid);
2869 }
2870 }
2871 DOCTEST_CHECK(prototype_uuids.size() > 0);
2872
2873 // Delete first plant — prototypes should survive
2874 plantarchitecture.deletePlantInstance(plantID1);
2875 for (uint uuid : prototype_uuids) {
2876 DOCTEST_CHECK(context.doesPrimitiveExist(uuid));
2877 }
2878
2879 // Delete second plant — prototypes should now be cleaned up
2880 plantarchitecture.deletePlantInstance(plantID2);
2881 for (uint uuid : prototype_uuids) {
2882 DOCTEST_CHECK(!context.doesPrimitiveExist(uuid));
2883 }
2884}
2885
2886DOCTEST_TEST_CASE("deletePlantInstance preserves prototypes when plants remain") {
2888 PlantArchitecture plantarchitecture(&context);
2889 plantarchitecture.disableMessages();
2890 plantarchitecture.loadPlantModelFromLibrary("bean");
2891
2892 uint plantID1 = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 5000);
2893 uint plantID2 = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(1, 0, 0), 5000);
2894
2895 // Get prototype UUIDs via the second plant
2896 std::vector<uint> uuids_with_hidden = plantarchitecture.getAllPlantUUIDs(plantID2, true);
2897 std::vector<uint> uuids_without_hidden = plantarchitecture.getAllPlantUUIDs(plantID2, false);
2898 DOCTEST_CHECK(uuids_with_hidden.size() > uuids_without_hidden.size());
2899
2900 // Delete first plant — prototypes should still be accessible for remaining plant
2901 plantarchitecture.deletePlantInstance(plantID1);
2902
2903 std::vector<uint> uuids_after = plantarchitecture.getAllPlantUUIDs(plantID2, true);
2904 DOCTEST_CHECK(uuids_after.size() > plantarchitecture.getAllPlantUUIDs(plantID2, false).size());
2905}
2906
2907DOCTEST_TEST_CASE("USD export basic structure") {
2909 PlantArchitecture plantarchitecture(&context);
2910 plantarchitecture.disableMessages();
2911 plantarchitecture.loadPlantModelFromLibrary("bean");
2912
2913 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 500);
2914
2915 std::string filename = "test_usd_basic.usda";
2916 plantarchitecture.writePlantStructureUSD(plantID, filename);
2917
2918 // Read the file and verify key structural elements
2919 std::ifstream file(filename);
2920 DOCTEST_CHECK(file.is_open());
2921
2922 std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
2923 file.close();
2924 DOCTEST_CHECK(!content.empty());
2925
2926 // Check required USD elements
2927 DOCTEST_CHECK(content.find("PhysicsArticulationRootAPI") != std::string::npos);
2928 DOCTEST_CHECK(content.find("PhysxArticulationAPI") != std::string::npos);
2929 DOCTEST_CHECK(content.find("PhysicsScene") != std::string::npos);
2930 DOCTEST_CHECK(content.find("PhysicsMaterialAPI") != std::string::npos);
2931 DOCTEST_CHECK(content.find("PhysicsFixedJoint") != std::string::npos);
2932 DOCTEST_CHECK(content.find("PhysicsRigidBodyAPI") != std::string::npos);
2933 DOCTEST_CHECK(content.find("PhysicsSphericalJoint") != std::string::npos);
2934 DOCTEST_CHECK(content.find("PhysicsDriveAPI:angular") != std::string::npos);
2935
2936 // Count links (each has PhysicsRigidBodyAPI)
2937 size_t link_count = 0;
2938 size_t pos = 0;
2939 while ((pos = content.find("PhysicsRigidBodyAPI", pos)) != std::string::npos) {
2940 link_count++;
2941 pos++;
2942 }
2943 DOCTEST_CHECK(link_count > 0);
2944
2945 // Verify exactly one fixed joint (world anchor)
2946 size_t fixed_count = 0;
2947 pos = 0;
2948 while ((pos = content.find("PhysicsFixedJoint", pos)) != std::string::npos) {
2949 fixed_count++;
2950 pos++;
2951 }
2952 DOCTEST_CHECK(fixed_count == 1);
2953
2954 std::remove(filename.c_str());
2955}
2956
2957DOCTEST_TEST_CASE("USD export physics properties") {
2959 PlantArchitecture plantarchitecture(&context);
2960 plantarchitecture.disableMessages();
2961 plantarchitecture.loadPlantModelFromLibrary("bean");
2962
2963 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 500);
2964
2965 USDExportParameters params;
2966 params.elastic_modulus = 1e9f;
2967 params.wood_density = 500.f;
2968
2969 std::string filename = "test_usd_physics.usda";
2970 plantarchitecture.writePlantStructureUSD(plantID, filename, params);
2971
2972 // Read and verify physics values are present and positive
2973 std::ifstream file(filename);
2974 DOCTEST_CHECK(file.is_open());
2975
2976 std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
2977 file.close();
2978
2979 // Check that mass values exist and stiffness values exist
2980 DOCTEST_CHECK(content.find("physics:mass") != std::string::npos);
2981 DOCTEST_CHECK(content.find("drive:angular:physics:stiffness") != std::string::npos);
2982 DOCTEST_CHECK(content.find("drive:angular:physics:damping") != std::string::npos);
2983
2984 // Check that gravity is correct
2985 DOCTEST_CHECK(content.find("physics:gravityMagnitude = 9.81") != std::string::npos);
2986
2987 std::remove(filename.c_str());
2988}
2989
2990DOCTEST_TEST_CASE("USD export branching topology") {
2992 PlantArchitecture plantarchitecture(&context);
2993 plantarchitecture.disableMessages();
2994
2995 // Use almond which has branching structure
2996 plantarchitecture.loadPlantModelFromLibrary("almond");
2997 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 500);
2998
2999 std::string filename = "test_usd_branching.usda";
3000 plantarchitecture.writePlantStructureUSD(plantID, filename);
3001
3002 std::ifstream file(filename);
3003 DOCTEST_CHECK(file.is_open());
3004
3005 std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
3006 file.close();
3007
3008 // Should have multiple links and joints
3009 size_t link_count = 0;
3010 size_t pos = 0;
3011 while ((pos = content.find("PhysicsRigidBodyAPI", pos)) != std::string::npos) {
3012 link_count++;
3013 pos++;
3014 }
3015 DOCTEST_CHECK(link_count > 3);
3016
3017 // Check that body0 and body1 references exist (proper joint connectivity)
3018 DOCTEST_CHECK(content.find("physics:body0") != std::string::npos);
3019 DOCTEST_CHECK(content.find("physics:body1") != std::string::npos);
3020
3021 std::remove(filename.c_str());
3022}
3023
3024DOCTEST_TEST_CASE("USD export error handling") {
3026 PlantArchitecture plantarchitecture(&context);
3027 plantarchitecture.disableMessages();
3028
3029 // Invalid plant ID
3030 DOCTEST_CHECK_THROWS(plantarchitecture.writePlantStructureUSD(9999, "test.usda"));
3031
3032 // Build a plant first, then test empty filename
3033 plantarchitecture.loadPlantModelFromLibrary("bean");
3034 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 500);
3035
3036 // Invalid file extension (only .usda/.USDA are accepted)
3037 DOCTEST_CHECK_THROWS(plantarchitecture.writePlantStructureUSD(plantID, "test.txt"));
3038}
3039
3040DOCTEST_TEST_CASE("USD export organs") {
3042 PlantArchitecture plantarchitecture(&context);
3043 plantarchitecture.disableMessages();
3044 plantarchitecture.loadPlantModelFromLibrary("bean");
3045
3046 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 500);
3047
3048 USDExportParameters params;
3049 std::string filename = "test_usd_organs.usda";
3050 plantarchitecture.writePlantStructureUSD(plantID, filename, params);
3051
3052 std::ifstream file(filename);
3053 DOCTEST_CHECK(file.is_open());
3054
3055 std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
3056 file.close();
3057
3058 // Bean should have petiole segments
3059 DOCTEST_CHECK(content.find("Pet") != std::string::npos);
3060
3061 // Bean should have leaves with both Visual and Collision mesh prims
3062 DOCTEST_CHECK(content.find("Leaf") != std::string::npos);
3063 DOCTEST_CHECK(content.find("def Mesh \"Visual\"") != std::string::npos);
3064 DOCTEST_CHECK(content.find("def Mesh \"Collision\"") != std::string::npos);
3065
3066 // All mesh/capsule prims with material bindings must declare MaterialBindingAPI
3067 DOCTEST_CHECK(content.find("\"MaterialBindingAPI\"") != std::string::npos);
3068
3069 // Visual meshes must have doubleSided and correct subdivision for Isaac Sim
3070 DOCTEST_CHECK(content.find("bool doubleSided = 1") != std::string::npos);
3071 DOCTEST_CHECK(content.find("subdivisionScheme = \"none\"") != std::string::npos);
3072
3073 // Normals must be present for correct shading
3074 DOCTEST_CHECK(content.find("primvars:normals") != std::string::npos);
3075
3076 // Texture paths must be relative (no absolute paths starting with /)
3077 DOCTEST_CHECK(content.find("asset inputs:file = @/") == std::string::npos);
3078
3079 std::remove(filename.c_str());
3080}
3081
3082DOCTEST_TEST_CASE("USD export minimum segment filtering") {
3084 PlantArchitecture plantarchitecture(&context);
3085 plantarchitecture.disableMessages();
3086 // Use almond which has longer internode segments than bean, so both default and
3087 // stricter filters always leave at least one surviving segment to export.
3088 plantarchitecture.loadPlantModelFromLibrary("almond");
3089
3090 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 500);
3091
3092 // Export with default min_segment_length
3093 USDExportParameters params_default;
3094 std::string filename_default = "test_usd_filter_default.usda";
3095 plantarchitecture.writePlantStructureUSD(plantID, filename_default, params_default);
3096
3097 // Export with larger min_segment_length — should produce fewer links
3098 USDExportParameters params_strict;
3099 params_strict.min_segment_length = 0.05f; // 5 cm — filters short segments
3100 std::string filename_strict = "test_usd_filter_strict.usda";
3101 plantarchitecture.writePlantStructureUSD(plantID, filename_strict, params_strict);
3102
3103 // Count links in each file
3104 auto countOccurrences = [](const std::string &content, const std::string &token) {
3105 size_t count = 0;
3106 size_t pos = 0;
3107 while ((pos = content.find(token, pos)) != std::string::npos) {
3108 count++;
3109 pos++;
3110 }
3111 return count;
3112 };
3113
3114 std::ifstream f1(filename_default);
3115 std::string content1((std::istreambuf_iterator<char>(f1)), std::istreambuf_iterator<char>());
3116 f1.close();
3117
3118 std::ifstream f2(filename_strict);
3119 std::string content2((std::istreambuf_iterator<char>(f2)), std::istreambuf_iterator<char>());
3120 f2.close();
3121
3122 size_t links_default = countOccurrences(content1, "PhysicsRigidBodyAPI");
3123 size_t links_strict = countOccurrences(content2, "PhysicsRigidBodyAPI");
3124
3125 // Stricter filtering should produce fewer or equal links
3126 DOCTEST_CHECK(links_strict <= links_default);
3127
3128 std::remove(filename_default.c_str());
3129 std::remove(filename_strict.c_str());
3130}
3131
3132DOCTEST_TEST_CASE("Growth frame registration") {
3134 PlantArchitecture plantarchitecture(&context);
3135 plantarchitecture.disableMessages();
3136 plantarchitecture.loadPlantModelFromLibrary("bean");
3137
3138 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 500);
3139
3140 DOCTEST_CHECK(plantarchitecture.getGrowthFrameCount(plantID) == 0);
3141
3142 // Register a frame at initial state
3143 plantarchitecture.registerGrowthFrame(plantID);
3144 DOCTEST_CHECK(plantarchitecture.getGrowthFrameCount(plantID) == 1);
3145
3146 // Advance time and register more frames
3147 plantarchitecture.advanceTime(10);
3148 plantarchitecture.registerGrowthFrame(plantID);
3149 DOCTEST_CHECK(plantarchitecture.getGrowthFrameCount(plantID) == 2);
3150
3151 plantarchitecture.advanceTime(10);
3152 plantarchitecture.registerGrowthFrame(plantID);
3153 DOCTEST_CHECK(plantarchitecture.getGrowthFrameCount(plantID) == 3);
3154
3155 // Clear frames
3156 plantarchitecture.clearGrowthFrames(plantID);
3157 DOCTEST_CHECK(plantarchitecture.getGrowthFrameCount(plantID) == 0);
3158
3159 // Query for non-existent plant returns 0
3160 DOCTEST_CHECK(plantarchitecture.getGrowthFrameCount(9999) == 0);
3161}
3162
3163DOCTEST_TEST_CASE("Growth USD export basic") {
3165 PlantArchitecture plantarchitecture(&context);
3166 plantarchitecture.disableMessages();
3167 plantarchitecture.loadPlantModelFromLibrary("bean");
3168
3169 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 500);
3170
3171 for (int i = 0; i < 3; i++) {
3172 plantarchitecture.advanceTime(10);
3173 plantarchitecture.registerGrowthFrame(plantID);
3174 }
3175
3176 std::string filename = "test_growth_usd.usda";
3177 // 1 second per growth frame -> time codes spaced by 24 (at 24fps)
3178 plantarchitecture.writePlantGrowthUSD(plantID, filename, 1.0f);
3179
3180 // Read file and verify key USD attributes
3181 std::ifstream f(filename);
3182 DOCTEST_CHECK(f.is_open());
3183 std::string content((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
3184 f.close();
3185
3186 // Verify header fields
3187 // 3 frames at 1 sec/frame with 24fps -> time codes 0, 24, 48 -> endTimeCode = 48
3188 DOCTEST_CHECK(content.find("startTimeCode = 0") != std::string::npos);
3189 DOCTEST_CHECK(content.find("endTimeCode = 48") != std::string::npos);
3190 DOCTEST_CHECK(content.find("timeCodesPerSecond = 24") != std::string::npos);
3191 DOCTEST_CHECK(content.find("framesPerSecond = 24") != std::string::npos);
3192 DOCTEST_CHECK(content.find("upAxis = \"Z\"") != std::string::npos);
3193
3194 // Verify time-sampled transforms exist
3195 DOCTEST_CHECK(content.find("xformOp:translate.timeSamples") != std::string::npos);
3196 DOCTEST_CHECK(content.find("xformOp:orient.timeSamples") != std::string::npos);
3197 DOCTEST_CHECK(content.find("visibility.timeSamples") != std::string::npos);
3198
3199 // Verify no physics prims are present
3200 DOCTEST_CHECK(content.find("PhysicsArticulationRootAPI") == std::string::npos);
3201 DOCTEST_CHECK(content.find("PhysicsRigidBodyAPI") == std::string::npos);
3202 DOCTEST_CHECK(content.find("PhysicsJoint") == std::string::npos);
3203
3204 // Verify mesh data is present
3205 DOCTEST_CHECK(content.find("def Mesh \"Visual\"") != std::string::npos);
3206
3207 std::remove(filename.c_str());
3208}
3209
3210DOCTEST_TEST_CASE("Growth USD export visibility toggling") {
3212 PlantArchitecture plantarchitecture(&context);
3213 plantarchitecture.disableMessages();
3214 plantarchitecture.loadPlantModelFromLibrary("bean");
3215
3216 // Build a very young plant so new organs appear during growth
3217 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 1);
3218 plantarchitecture.registerGrowthFrame(plantID);
3219
3220 // Advance significantly so new phytomers/organs appear
3221 plantarchitecture.advanceTime(30);
3222 plantarchitecture.registerGrowthFrame(plantID);
3223
3224 std::string filename = "test_growth_visibility.usda";
3225 plantarchitecture.writePlantGrowthUSD(plantID, filename);
3226
3227 std::ifstream f(filename);
3228 DOCTEST_CHECK(f.is_open());
3229 std::string content((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
3230 f.close();
3231
3232 // Organs that appeared in frame 2 but not frame 1 should have "invisible" at time 0
3233 // and "inherited" at time 1. Both tokens should be present somewhere in the file.
3234 DOCTEST_CHECK(content.find("\"invisible\"") != std::string::npos);
3235 DOCTEST_CHECK(content.find("\"inherited\"") != std::string::npos);
3236
3237 std::remove(filename.c_str());
3238}
3239
3240DOCTEST_TEST_CASE("Growth USD export error handling") {
3242 PlantArchitecture plantarchitecture(&context);
3243 plantarchitecture.disableMessages();
3244
3245 // Error: invalid plant ID for registerGrowthFrame
3246 bool threw = false;
3247 try {
3248 plantarchitecture.registerGrowthFrame(9999);
3249 } catch (...) {
3250 threw = true;
3251 }
3252 DOCTEST_CHECK(threw);
3253
3254 // Error: writePlantGrowthUSD with no frames registered
3255 plantarchitecture.loadPlantModelFromLibrary("bean");
3256 uint plantID = plantarchitecture.buildPlantInstanceFromLibrary(make_vec3(0, 0, 0), 500);
3257 threw = false;
3258 try {
3259 plantarchitecture.writePlantGrowthUSD(plantID, "test_no_frames.usda");
3260 } catch (...) {
3261 threw = true;
3262 }
3263 DOCTEST_CHECK(threw);
3264}
3265
3266int PlantArchitecture::selfTest(int argc, char **argv) {
3267 return helios::runDoctestWithValidation(argc, argv);
3268}