1.3.64
 
Loading...
Searching...
No Matches
selfTest.cpp
1#include "LiDAR.h"
2
3#define DOCTEST_CONFIG_IMPLEMENT
4#include <doctest.h>
5#include "doctest_utils.h"
6
7using namespace std;
8using namespace helios;
9
10float err_tol = 1e-3;
11
12int LiDARcloud::selfTest(int argc, char **argv) {
13 return helios::runDoctestWithValidation(argc, argv);
14}
15
16DOCTEST_TEST_CASE("LiDAR Single Voxel Sphere Test") {
17 LiDARcloud pointcloud;
18 pointcloud.disableMessages();
19
20 DOCTEST_CHECK_NOTHROW(pointcloud.loadXML("plugins/lidar/xml/sphere.xml"));
21 DOCTEST_CHECK_NOTHROW(pointcloud.triangulateHitPoints(0.5, 5));
22
23 Context context_1;
24 DOCTEST_CHECK_NOTHROW(pointcloud.addTrianglesToContext(&context_1));
25
26 DOCTEST_CHECK(context_1.getPrimitiveCount() == 383);
27}
28
29DOCTEST_TEST_CASE("LiDAR Single Voxel Isotropic Patches Test") {
30 LiDARcloud synthetic_1;
31 synthetic_1.disableMessages();
32
33 // Add scan programmatically for explicit control
34 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
35 uint Ntheta = 6000;
36 uint Nphi = 12000;
37 float thetaMin = 0.0f; // Default when not specified in XML
38 float thetaMax = M_PI; // Default when not specified in XML
39 float phiMin = 0.0f; // Default when not specified in XML
40 float phiMax = 2.0f * M_PI; // Default when not specified in XML
41 float exitDiameter = 0.0f;
42 float beamDivergence = 0.0f;
43 std::vector<std::string> columnFormat;
44
45 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
46 exitDiameter, beamDivergence, columnFormat);
47 DOCTEST_CHECK_NOTHROW(synthetic_1.addScan(scan));
48
49 // Add grid programmatically
50 vec3 grid_center(0.0f, 0.0f, 0.5f);
51 vec3 grid_size(1.0f, 1.0f, 1.0f);
52 int3 grid_divisions = make_int3(1, 1, 1);
53 DOCTEST_CHECK_NOTHROW(synthetic_1.addGrid(grid_center, grid_size, grid_divisions, 0));
54
55 vec3 gsize = synthetic_1.getCellSize(0);
56
57 Context context_2;
58 std::vector<uint> UUIDs_1 = context_2.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
59
60 float LAD_exact = 0.f;
61 for (uint UUID: UUIDs_1) {
62 LAD_exact += context_2.getPrimitiveArea(UUID) / (gsize.x * gsize.y * gsize.z);
63 }
64
65 // Calculate exact G(theta) from primitive geometry
66 float Gtheta_exact_numerator = 0.f;
67 float Gtheta_exact_denominator = 0.f;
68 for (uint UUID : UUIDs_1) {
69 float area = context_2.getPrimitiveArea(UUID);
70 vec3 normal = context_2.getPrimitiveNormal(UUID);
71 std::vector<vec3> vertices = context_2.getPrimitiveVertices(UUID);
72 vec3 raydir = vertices.front() - scan_origin;
73 raydir.normalize();
74
75 if (area == area) { // Check for NaN
76 float normal_dot_ray = fabs(normal * raydir);
77 Gtheta_exact_numerator += normal_dot_ray * area;
78 Gtheta_exact_denominator += area;
79 }
80 }
81 float Gtheta_exact = 0.f;
82 if (Gtheta_exact_denominator > 0) {
83 Gtheta_exact = Gtheta_exact_numerator / Gtheta_exact_denominator;
84 }
85
86 DOCTEST_CHECK_NOTHROW(synthetic_1.syntheticScan(&context_2));
87 DOCTEST_CHECK_NOTHROW(synthetic_1.triangulateHitPoints(0.04, 10));
88 DOCTEST_CHECK_NOTHROW(synthetic_1.calculateLeafArea(&context_2));
89
90 float LAD = synthetic_1.getCellLeafAreaDensity(0);
91
92 DOCTEST_CHECK(LAD == LAD); // Check for NaN
93 DOCTEST_CHECK(fabs(LAD - LAD_exact) / LAD_exact == doctest::Approx(0.0f).epsilon(0.02f));
94
95 // Check G(theta) against exact value calculated from primitives
96 float Gtheta = synthetic_1.getCellGtheta(0);
97 DOCTEST_CHECK(Gtheta == Gtheta); // Check for NaN
98 DOCTEST_CHECK(fabs(Gtheta - Gtheta_exact) / Gtheta_exact == doctest::Approx(0.0f).epsilon(0.05f));
99}
100
101DOCTEST_TEST_CASE("LiDAR Eight Voxel Isotropic Patches Test") {
102 LiDARcloud synthetic_2;
103 synthetic_2.disableMessages();
104
105 // Add scan programmatically
106 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
107 uint Ntheta = 10000;
108 uint Nphi = 12000;
109 float thetaMin = 0.0f;
110 float thetaMax = M_PI;
111 float phiMin = 0.0f;
112 float phiMax = 2.0f * M_PI;
113 float exitDiameter = 0.0f;
114 float beamDivergence = 0.0f;
115 std::vector<std::string> columnFormat;
116
117 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
118 exitDiameter, beamDivergence, columnFormat);
119 DOCTEST_CHECK_NOTHROW(synthetic_2.addScan(scan));
120
121 // Add grid programmatically
122 vec3 grid_center(0.0f, 0.0f, 0.5f);
123 vec3 grid_size(1.0f, 1.0f, 1.0f);
124 int3 grid_divisions = make_int3(2, 2, 2);
125 DOCTEST_CHECK_NOTHROW(synthetic_2.addGrid(grid_center, grid_size, grid_divisions, 0));
126
127 vec3 gsize = synthetic_2.getCellSize(0);
128
129 Context context_2;
130 std::vector<uint> UUIDs_1 = context_2.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
131
132 std::vector<float> LAD_ex(8, 0);
133 for (uint UUID: UUIDs_1) {
134 int i, j, k;
135 i = j = k = 0;
136 vec3 v = context_2.getPrimitiveVertices(UUID).front();
137 if (v.x > 0.f) {
138 i = 1;
139 }
140 if (v.y > 0.f) {
141 j = 1;
142 }
143 if (v.z > 0.5f) {
144 k = 1;
145 }
146 int ID = k * 4 + j * 2 + i;
147
148 float area = context_2.getPrimitiveArea(UUID);
149 LAD_ex.at(ID) += area / (gsize.x * gsize.y * gsize.z);
150 }
151
152 DOCTEST_CHECK_NOTHROW(synthetic_2.syntheticScan(&context_2));
153 DOCTEST_CHECK_NOTHROW(synthetic_2.triangulateHitPoints(0.04, 10));
154 DOCTEST_CHECK_NOTHROW(synthetic_2.calculateLeafArea(&context_2));
155
156 float RMSE = 0.f;
157 for (int i = 0; i < synthetic_2.getGridCellCount(); i++) {
158 float LAD = synthetic_2.getCellLeafAreaDensity(i);
159 RMSE += powf(LAD - LAD_ex.at(i), 2) / float(synthetic_2.getGridCellCount());
160 }
161 RMSE = sqrtf(RMSE);
162
163 DOCTEST_CHECK(RMSE == doctest::Approx(0.0f).epsilon(0.06f));
164}
165
166DOCTEST_TEST_CASE("LiDAR Single Voxel Anisotropic Patches Test") {
167 LiDARcloud synthetic_3;
168 synthetic_3.disableMessages();
169
170 // Add scan programmatically - use higher resolution for anisotropic to reduce bias
171 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
172 uint Ntheta = 10000;
173 uint Nphi = 16000;
174 float thetaMin = 0.0f;
175 float thetaMax = M_PI;
176 float phiMin = 0.0f;
177 float phiMax = 2.0f * M_PI;
178 float exitDiameter = 0.0f;
179 float beamDivergence = 0.0f;
180 std::vector<std::string> columnFormat;
181
182 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
183 exitDiameter, beamDivergence, columnFormat);
184 DOCTEST_CHECK_NOTHROW(synthetic_3.addScan(scan));
185
186 // Add grid programmatically
187 vec3 grid_center(0.0f, 0.0f, 0.5f);
188 vec3 grid_size(1.0f, 1.0f, 1.0f);
189 int3 grid_divisions = make_int3(1, 1, 1);
190 DOCTEST_CHECK_NOTHROW(synthetic_3.addGrid(grid_center, grid_size, grid_divisions, 0));
191
192 vec3 gsize = synthetic_3.getCellSize(0);
193
194 Context context_2;
195 std::vector<uint> UUIDs_1 = context_2.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_erectophile.xml", true);
196
197 float LAD_exact = 0.f;
198 for (uint UUID: UUIDs_1) {
199 LAD_exact += context_2.getPrimitiveArea(UUID) / (gsize.x * gsize.y * gsize.z);
200 }
201
202 // Calculate exact G(theta) from primitive geometry
203 float Gtheta_exact_numerator = 0.f;
204 float Gtheta_exact_denominator = 0.f;
205 for (uint UUID : UUIDs_1) {
206 float area = context_2.getPrimitiveArea(UUID);
207 vec3 normal = context_2.getPrimitiveNormal(UUID);
208 std::vector<vec3> vertices = context_2.getPrimitiveVertices(UUID);
209 vec3 raydir = vertices.front() - scan_origin;
210 raydir.normalize();
211
212 if (area == area) { // Check for NaN
213 float normal_dot_ray = fabs(normal * raydir);
214 Gtheta_exact_numerator += normal_dot_ray * area;
215 Gtheta_exact_denominator += area;
216 }
217 }
218 float Gtheta_exact = 0.f;
219 if (Gtheta_exact_denominator > 0) {
220 Gtheta_exact = Gtheta_exact_numerator / Gtheta_exact_denominator;
221 }
222
223 DOCTEST_CHECK_NOTHROW(synthetic_3.syntheticScan(&context_2));
224 DOCTEST_CHECK_NOTHROW(synthetic_3.triangulateHitPoints(0.04, 10));
225 DOCTEST_CHECK_NOTHROW(synthetic_3.calculateLeafArea(&context_2));
226
227 float LAD = synthetic_3.getCellLeafAreaDensity(0);
228
229 DOCTEST_CHECK(LAD == LAD); // Check for NaN
230 DOCTEST_CHECK(fabs(LAD - LAD_exact) / LAD_exact == doctest::Approx(0.0f).epsilon(0.03f));
231
232 // Check G(theta) against exact value calculated from primitives
233 float Gtheta = synthetic_3.getCellGtheta(0);
234 DOCTEST_CHECK(Gtheta == Gtheta); // Check for NaN
235 DOCTEST_CHECK(fabs(Gtheta - Gtheta_exact) / Gtheta_exact == doctest::Approx(0.0f).epsilon(0.05f));
236}
237
238DOCTEST_TEST_CASE("LiDAR Synthetic Almond Tree Test") {
239 Context context_4;
240 DOCTEST_CHECK_NOTHROW(context_4.loadOBJ("plugins/lidar/xml/AlmondWP.obj", make_vec3(0, 0, 0), 6., make_SphericalCoord(0, 0), RGB::red, true));
241
242 LiDARcloud synthetic_4;
243 synthetic_4.disableMessages();
244
245 DOCTEST_CHECK_NOTHROW(synthetic_4.loadXML("plugins/lidar/xml/almond.xml"));
246 DOCTEST_CHECK_NOTHROW(synthetic_4.syntheticScan(&context_4));
247 DOCTEST_CHECK_NOTHROW(synthetic_4.calculateSyntheticLeafArea(&context_4));
248 DOCTEST_CHECK_NOTHROW(synthetic_4.calculateSyntheticGtheta(&context_4));
249 DOCTEST_CHECK_NOTHROW(synthetic_4.triangulateHitPoints(0.05, 5));
250 DOCTEST_CHECK_NOTHROW(synthetic_4.calculateLeafArea(&context_4));
251
252 // Calculate exact leaf area
253 uint Ncells = synthetic_4.getGridCellCount();
254
255 std::vector<float> total_area;
256 total_area.resize(Ncells);
257
258 std::vector<float> Gtheta;
259 Gtheta.resize(Ncells);
260
261 std::vector<float> area_sum;
262 area_sum.resize(Ncells, 0.f);
263 std::vector<float> sin_sum;
264 sin_sum.resize(Ncells, 0.f);
265 std::vector<uint> cell_tri_count;
266 cell_tri_count.resize(Ncells, 0);
267
268 std::vector<uint> UUIDs = context_4.getAllUUIDs();
269 for (int p = 0; p < UUIDs.size(); p++) {
270
271 uint UUID = UUIDs.at(p);
272
273 if (context_4.doesPrimitiveDataExist(UUID, "gridCell")) {
274
275 uint gridCell;
276 context_4.getPrimitiveData(UUID, "gridCell", gridCell);
277
278 if (gridCell >= 0 && gridCell < Ncells) {
279 total_area.at(gridCell) += context_4.getPrimitiveArea(UUID);
280 }
281
282 for (int s = 0; s < synthetic_4.getScanCount(); s++) {
283 vec3 origin = synthetic_4.getScanOrigin(s);
284 std::vector<vec3> vertices = context_4.getPrimitiveVertices(p);
285 float area = context_4.getPrimitiveArea(p);
286 vec3 normal = context_4.getPrimitiveNormal(p);
287 vec3 raydir = vertices.front() - origin;
288 raydir.normalize();
289 float theta = fabs(acos_safe(raydir.z));
290
291 if (area == area) { // in rare cases you can get area=NaN
292
293 Gtheta.at(gridCell) += fabs(normal * raydir) * area * fabs(sin(theta));
294
295 area_sum.at(gridCell) += area;
296 sin_sum.at(gridCell) += fabs(sin(theta));
297 cell_tri_count.at(gridCell) += 1;
298 }
299 }
300 }
301 }
302
303 for (uint v = 0; v < Ncells; v++) {
304 if (cell_tri_count[v] > 0) {
305 Gtheta[v] *= float(cell_tri_count[v]) / (area_sum[v] * sin_sum[v]);
306 }
307 }
308
309 float RMSE_LAD = 0.f;
310 float bias_LAD = 0.f;
311 float RMSE_Gtheta = 0.f;
312 for (uint i = 0; i < Ncells; i++) {
313 float LAD = synthetic_4.getCellLeafArea(i);
314 if (LAD == LAD && total_area.at(i) > 0 && total_area.at(i) == total_area.at(i)) {
315 RMSE_LAD += pow(LAD - total_area.at(i), 2) / float(Ncells);
316 bias_LAD += (LAD - total_area.at(i)) / float(Ncells);
317 }
318 float Gtheta_bar = synthetic_4.getCellGtheta(i);
319 if (Gtheta_bar == Gtheta_bar && Gtheta.at(i) > 0 && Gtheta.at(i) == Gtheta.at(i)) {
320 RMSE_Gtheta += pow(Gtheta_bar - Gtheta.at(i), 2) / float(Ncells);
321 }
322 }
323 RMSE_LAD = sqrt(RMSE_LAD);
324 RMSE_Gtheta = sqrt(RMSE_Gtheta);
325
326 DOCTEST_CHECK(RMSE_LAD <= 0.35f);
327 DOCTEST_CHECK(bias_LAD <= 0.0f);
328 DOCTEST_CHECK(RMSE_Gtheta <= 0.15f);
329 DOCTEST_CHECK(RMSE_LAD != 0.f);
330}
331
332DOCTEST_TEST_CASE("LiDAR Synthetic Scan Append/Overwrite Test") {
333 Context context_test;
334 context_test.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
335
336 LiDARcloud synthetic_test;
337 synthetic_test.disableMessages();
338
339 DOCTEST_CHECK_NOTHROW(synthetic_test.loadXML("plugins/lidar/xml/synthetic_test.xml"));
340
341 // First scan with default append behavior (should append to empty scan)
342 DOCTEST_CHECK_NOTHROW(synthetic_test.syntheticScan(&context_test));
343 uint hit_count_first = synthetic_test.getHitCount();
344 DOCTEST_CHECK(hit_count_first > 0);
345
346 // Second scan with append=true (should double the hit count)
347 DOCTEST_CHECK_NOTHROW(synthetic_test.syntheticScan(&context_test, true));
348 uint hit_count_append = synthetic_test.getHitCount();
349 DOCTEST_CHECK(hit_count_append == 2 * hit_count_first);
350
351 // Third scan with append=false (should reset and have same count as first scan)
352 DOCTEST_CHECK_NOTHROW(synthetic_test.syntheticScan(&context_test, false));
353 uint hit_count_overwrite = synthetic_test.getHitCount();
354 DOCTEST_CHECK(hit_count_overwrite == hit_count_first);
355
356 // Test with other overloads
357 // Test scan_grid_only, record_misses overload with append=false
358 DOCTEST_CHECK_NOTHROW(synthetic_test.syntheticScan(&context_test, false, false, false));
359 uint hit_count_overwrite2 = synthetic_test.getHitCount();
360 DOCTEST_CHECK(hit_count_overwrite2 == hit_count_first);
361
362 // Test full-waveform overload with append=true
363 DOCTEST_CHECK_NOTHROW(synthetic_test.syntheticScan(&context_test, 1, 0.0f, true));
364 uint hit_count_append2 = synthetic_test.getHitCount();
365 DOCTEST_CHECK(hit_count_append2 == 2 * hit_count_first);
366}
367
368DOCTEST_TEST_CASE("LiDAR TreeQSM Loading Test") {
369 Context context_treeqsm;
370 LiDARcloud lidar;
371 lidar.disableMessages();
372
373 // Test loading TreeQSM file without texture
374 std::vector<uint> tube_UUIDs;
375 uint radial_subdivisions = 6;
376 DOCTEST_CHECK_NOTHROW(tube_UUIDs = lidar.loadTreeQSM(&context_treeqsm, "plugins/lidar/data/cylinder_tree_QSM_test.txt", radial_subdivisions));
377
378 // Check that tube objects were created
379 DOCTEST_CHECK(tube_UUIDs.size() > 0);
380
381 // Check that all returned UUIDs are valid
382 for (uint UUID: tube_UUIDs) {
383 DOCTEST_CHECK(context_treeqsm.doesObjectExist(UUID));
384 DOCTEST_CHECK(context_treeqsm.getObjectType(UUID) == helios::OBJECT_TYPE_TUBE);
385 }
386
387 // Test that object data was set correctly
388 for (uint UUID: tube_UUIDs) {
389 DOCTEST_CHECK(context_treeqsm.doesObjectDataExist(UUID, "branch_order"));
390 DOCTEST_CHECK(context_treeqsm.doesObjectDataExist(UUID, "branch_id"));
391
392 int branch_order;
393 context_treeqsm.getObjectData(UUID, "branch_order", branch_order);
394 DOCTEST_CHECK(branch_order >= 0);
395
396 int branch_id;
397 context_treeqsm.getObjectData(UUID, "branch_id", branch_id);
398 DOCTEST_CHECK(branch_id >= 0);
399 }
400
401 // Test loading with empty texture file (should still work)
402 Context context_treeqsm2;
403 std::vector<uint> tube_UUIDs2;
404 DOCTEST_CHECK_NOTHROW(tube_UUIDs2 = lidar.loadTreeQSM(&context_treeqsm2, "plugins/lidar/data/cylinder_tree_QSM_test.txt", radial_subdivisions, ""));
405 DOCTEST_CHECK(tube_UUIDs2.size() == tube_UUIDs.size());
406
407 // Test error handling for non-existent file
408 Context context_error;
409 DOCTEST_CHECK_THROWS(lidar.loadTreeQSM(&context_error, "nonexistent_file.txt", radial_subdivisions));
410
411 // Test with different radial subdivisions
412 Context context_treeqsm3;
413 std::vector<uint> tube_UUIDs3;
414 uint different_subdivisions = 8;
415 DOCTEST_CHECK_NOTHROW(tube_UUIDs3 = lidar.loadTreeQSM(&context_treeqsm3, "plugins/lidar/data/cylinder_tree_QSM_test.txt", different_subdivisions));
416 DOCTEST_CHECK(tube_UUIDs3.size() == tube_UUIDs.size()); // Same number of tubes
417
418 // Test that each tube has appropriate number of nodes and primitives
419 for (uint UUID: tube_UUIDs) {
420 std::vector<uint> primitive_UUIDs = context_treeqsm.getObjectPrimitiveUUIDs(UUID);
421 DOCTEST_CHECK(primitive_UUIDs.size() > 0);
422
423 // Each tube should have triangular primitives
424 for (uint prim_UUID: primitive_UUIDs) {
425 DOCTEST_CHECK(context_treeqsm.getPrimitiveType(prim_UUID) == helios::PRIMITIVE_TYPE_TRIANGLE);
426 }
427 }
428}
429
430DOCTEST_TEST_CASE("LiDAR TreeQSM Colormap Loading Test") {
431 Context context_colormap;
432 LiDARcloud lidar;
433 lidar.disableMessages();
434
435 // Test loading TreeQSM file with colormap
436 std::vector<uint> tube_UUIDs;
437 uint radial_subdivisions = 6;
438 std::string colormap_name = "hot";
439 DOCTEST_CHECK_NOTHROW(tube_UUIDs = lidar.loadTreeQSMColormap(&context_colormap, "plugins/lidar/data/cylinder_tree_QSM_test.txt", radial_subdivisions, colormap_name));
440
441 // Check that tube objects were created
442 DOCTEST_CHECK(tube_UUIDs.size() > 0);
443
444 // Check that all returned UUIDs are valid
445 for (uint UUID: tube_UUIDs) {
446 DOCTEST_CHECK(context_colormap.doesObjectExist(UUID));
447 DOCTEST_CHECK(context_colormap.getObjectType(UUID) == helios::OBJECT_TYPE_TUBE);
448 }
449
450 // Test that object data was set correctly
451 for (uint UUID: tube_UUIDs) {
452 DOCTEST_CHECK(context_colormap.doesObjectDataExist(UUID, "branch_order"));
453 DOCTEST_CHECK(context_colormap.doesObjectDataExist(UUID, "branch_id"));
454
455 int branch_order;
456 context_colormap.getObjectData(UUID, "branch_order", branch_order);
457 DOCTEST_CHECK(branch_order >= 0);
458
459 int branch_id;
460 context_colormap.getObjectData(UUID, "branch_id", branch_id);
461 DOCTEST_CHECK(branch_id >= 0);
462 }
463
464 // Test with different colormap
465 Context context_colormap2;
466 std::vector<uint> tube_UUIDs2;
467 std::string colormap_name2 = "cool";
468 DOCTEST_CHECK_NOTHROW(tube_UUIDs2 = lidar.loadTreeQSMColormap(&context_colormap2, "plugins/lidar/data/cylinder_tree_QSM_test.txt", radial_subdivisions, colormap_name2));
469 DOCTEST_CHECK(tube_UUIDs2.size() == tube_UUIDs.size());
470
471 // Test error handling for non-existent file
472 Context context_error2;
473 DOCTEST_CHECK_THROWS(lidar.loadTreeQSMColormap(&context_error2, "nonexistent_file.txt", radial_subdivisions, colormap_name));
474
475 // Test that each tube has appropriate number of primitives
476 for (uint UUID: tube_UUIDs) {
477 std::vector<uint> primitive_UUIDs = context_colormap.getObjectPrimitiveUUIDs(UUID);
478 DOCTEST_CHECK(primitive_UUIDs.size() > 0);
479
480 // Each tube should have triangular primitives
481 for (uint prim_UUID: primitive_UUIDs) {
482 DOCTEST_CHECK(context_colormap.getPrimitiveType(prim_UUID) == helios::PRIMITIVE_TYPE_TRIANGLE);
483 }
484 }
485
486 // Test with invalid colormap name (should throw an exception)
487 Context context_colormap3;
488 std::string invalid_colormap = "invalid_colormap_name";
489 DOCTEST_CHECK_THROWS(lidar.loadTreeQSMColormap(&context_colormap3, "plugins/lidar/data/cylinder_tree_QSM_test.txt", radial_subdivisions, invalid_colormap));
490}
491
492DOCTEST_TEST_CASE("LiDAR Collision Detection Integration Test") {
493 LiDARcloud lidar;
494 lidar.disableMessages();
495
496 // Create a simple test context with geometry
497 Context test_context;
498 test_context.addSphere(10, make_vec3(0, 0, 0), 1.0f, RGB::red);
499 test_context.addTriangle(make_vec3(-2, -1, -1), make_vec3(2, -1, -1), make_vec3(0, 1, -1), RGB::green);
500
501 // Test initializeCollisionDetection method
502 DOCTEST_CHECK_NOTHROW(lidar.initializeCollisionDetection(&test_context));
503
504 // Test calling initialize multiple times (should not create multiple instances)
505 DOCTEST_CHECK_NOTHROW(lidar.initializeCollisionDetection(&test_context));
506
507 // Create test ray data
508 const size_t N = 3;
509 const int Npulse = 2;
510 helios::vec3 scan_origin = make_vec3(0, 0, 5);
511
512 // Test ray directions - some should hit, some should miss
513 std::vector<helios::vec3> directions = {
514 make_vec3(0, 0, -1), // Should hit sphere
515 make_vec3(1, 0, -1), // Should miss sphere
516 make_vec3(0, -0.5, -1), // Should hit triangle
517 make_vec3(2, 0, -1), // Should miss everything
518 make_vec3(-1, 0, -1), // Should miss sphere, might hit triangle
519 make_vec3(0, 0.5, -1) // Should miss triangle, might hit sphere
520 };
521
522 float hit_t[N * Npulse];
523 float hit_fnorm[N * Npulse];
524 int hit_ID[N * Npulse];
525
526 // Initialize arrays
527 for (size_t i = 0; i < N * Npulse; i++) {
528 hit_t[i] = 1001.0f;
529 hit_fnorm[i] = 1e6;
530 hit_ID[i] = -1;
531 }
532
533 // Create ray origins array (all rays from same origin)
534 std::vector<helios::vec3> ray_origins(N * Npulse, scan_origin);
535
536 // Test performUnifiedRayTracing method
537 DOCTEST_CHECK_NOTHROW(lidar.performUnifiedRayTracing(&test_context, N, Npulse, ray_origins.data(), directions.data(), hit_t, hit_fnorm, hit_ID));
538
539 // Validate results - at least some rays should hit
540 bool found_hit = false;
541 bool found_miss = false;
542 for (size_t i = 0; i < N * Npulse; i++) {
543 if (hit_t[i] < 1000.0f) {
544 found_hit = true;
545 // Valid hit should have reasonable distance
546 DOCTEST_CHECK(hit_t[i] > 0.0f);
547 DOCTEST_CHECK(hit_t[i] < 100.0f);
548 // Valid hit should have a primitive ID
549 DOCTEST_CHECK(hit_ID[i] >= 0);
550 // Normal calculation should be finite
551 DOCTEST_CHECK(std::isfinite(hit_fnorm[i]));
552 } else {
553 found_miss = true;
554 DOCTEST_CHECK(hit_ID[i] == -1);
555 }
556 }
557
558 // We should have both hits and misses in our test case
559 DOCTEST_CHECK(found_hit);
560 DOCTEST_CHECK(found_miss);
561}
562
563DOCTEST_TEST_CASE("LiDAR Data Format Conversion Test") {
564 LiDARcloud lidar;
565 lidar.disableMessages();
566
567 Context test_context;
568 test_context.addSphere(5, make_vec3(0, 0, 0), 1.0f, RGB::red);
569
570 // Test conversion between CUDA float3 and Helios vec3 formats - simplified
571 const size_t N = 2;
572 const int Npulse = 1;
573
574 std::vector<helios::vec3> test_directions = {
575 make_vec3(0, 0, -1), // Downward (should hit)
576 make_vec3(1, 0, 0) // Sideways (should miss)
577 };
578
579 // Test one origin only
580 helios::vec3 origin = make_vec3(0, 0, 5);
581
582 float hit_t[N * Npulse];
583 float hit_fnorm[N * Npulse];
584 int hit_ID[N * Npulse];
585
586 // Initialize collision detection
587 lidar.initializeCollisionDetection(&test_context);
588
589 // Create ray origins array (all rays from same origin)
590 std::vector<helios::vec3> ray_origins(N * Npulse, origin);
591
592 // Test ray tracing
593 DOCTEST_CHECK_NOTHROW(lidar.performUnifiedRayTracing(&test_context, N, Npulse, ray_origins.data(), test_directions.data(), hit_t, hit_fnorm, hit_ID));
594
595 // Basic validation
596 for (size_t i = 0; i < N * Npulse; i++) {
597 DOCTEST_CHECK(std::isfinite(hit_t[i]));
598 DOCTEST_CHECK(std::isfinite(hit_fnorm[i]));
599 DOCTEST_CHECK(hit_ID[i] >= -1);
600 }
601}
602
603DOCTEST_TEST_CASE("LiDAR Edge Cases and Error Conditions Test") {
604 LiDARcloud lidar;
605 lidar.disableMessages();
606
607 Context test_context;
608 test_context.addSphere(3, make_vec3(0, 0, 0), 1.0f, RGB::red);
609
610 // Test basic edge cases only
611 helios::vec3 origin = make_vec3(0, 0, 5);
612 helios::vec3 direction = make_vec3(0, 0, -1);
613 float hit_t[1];
614 float hit_fnorm[1];
615 int hit_ID[1];
616
617 // Test initialization
618 DOCTEST_CHECK_NOTHROW(lidar.initializeCollisionDetection(&test_context));
619
620 // Create ray origins array (single ray from single origin)
621 helios::vec3 ray_origins[1] = {origin};
622
623 // Test single ray
624 DOCTEST_CHECK_NOTHROW(lidar.performUnifiedRayTracing(&test_context, 1, 1, ray_origins, &direction, hit_t, hit_fnorm, hit_ID));
625
626 // Validate basic results
627 DOCTEST_CHECK(std::isfinite(hit_t[0]));
628 DOCTEST_CHECK(std::isfinite(hit_fnorm[0]));
629 DOCTEST_CHECK(hit_ID[0] >= -1);
630}
631
632DOCTEST_TEST_CASE("LiDAR Collision Detection Memory Management Test") {
633 // Test basic initialization and cleanup
634 LiDARcloud lidar;
635 lidar.disableMessages();
636
637 Context test_context;
638 test_context.addTriangle(make_vec3(-1, -1, 0), make_vec3(1, -1, 0), make_vec3(0, 1, 0), RGB::red);
639
640 // Test initialization
641 DOCTEST_CHECK_NOTHROW(lidar.initializeCollisionDetection(&test_context));
642
643 // Test one ray
644 helios::vec3 origin = make_vec3(0, 0, 5);
645 helios::vec3 direction = make_vec3(0, 0, -1);
646 float hit_t, hit_fnorm;
647 int hit_ID;
648
649 // Create ray origins array (single ray from single origin)
650 helios::vec3 ray_origins[1] = {origin};
651
652 DOCTEST_CHECK_NOTHROW(lidar.performUnifiedRayTracing(&test_context, 1, 1, ray_origins, &direction, &hit_t, &hit_fnorm, &hit_ID));
653
654 // Basic validation
655 DOCTEST_CHECK(std::isfinite(hit_t));
656 DOCTEST_CHECK(std::isfinite(hit_fnorm));
657 DOCTEST_CHECK(hit_ID >= -1);
658}
659
660DOCTEST_TEST_CASE("LiDAR Synthetic Scan Integration Test") {
661 // Test that synthetic scans still work with the new collision detection integration
662 LiDARcloud synthetic_scan_test;
663 synthetic_scan_test.disableMessages();
664
665 // Load a scan configuration
666 DOCTEST_CHECK_NOTHROW(synthetic_scan_test.loadXML("plugins/lidar/xml/synthetic_test.xml"));
667
668 // Create a simple test geometry
669 Context scan_context;
670 std::vector<uint> patch_UUIDs = scan_context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
671 DOCTEST_CHECK(patch_UUIDs.size() > 0);
672
673 // Run synthetic scan - this should use the new collision detection integration internally
674 DOCTEST_CHECK_NOTHROW(synthetic_scan_test.syntheticScan(&scan_context));
675
676 // Verify that we got some hits
677 uint hit_count = synthetic_scan_test.getHitCount();
678 DOCTEST_CHECK(hit_count > 0);
679
680 // Test that hit points have reasonable coordinates
681 for (uint i = 0; i < std::min(hit_count, 10u); i++) {
682 helios::vec3 hit_pos = synthetic_scan_test.getHitXYZ(i);
683
684 // Coordinates should be finite
685 DOCTEST_CHECK(std::isfinite(hit_pos.x));
686 DOCTEST_CHECK(std::isfinite(hit_pos.y));
687 DOCTEST_CHECK(std::isfinite(hit_pos.z));
688
689 // Should be within reasonable bounds for our test geometry
690 DOCTEST_CHECK(fabs(hit_pos.x) < 100.0f);
691 DOCTEST_CHECK(fabs(hit_pos.y) < 100.0f);
692 DOCTEST_CHECK(fabs(hit_pos.z) < 100.0f);
693
694 // Test ray direction is valid
695 helios::SphericalCoord ray_dir = synthetic_scan_test.getHitRaydir(i);
696 DOCTEST_CHECK(std::isfinite(ray_dir.zenith));
697 DOCTEST_CHECK(std::isfinite(ray_dir.azimuth));
698 DOCTEST_CHECK(std::isfinite(ray_dir.radius));
699 }
700
701 // Test backward compatibility - existing LiDAR functionality should still work
702 DOCTEST_CHECK_NOTHROW(synthetic_scan_test.calculateHitGridCell());
703 DOCTEST_CHECK_NOTHROW(synthetic_scan_test.triangulateHitPoints(0.04, 10));
704 DOCTEST_CHECK_NOTHROW(synthetic_scan_test.calculateLeafArea(&scan_context));
705
706 // Grid cell calculations should produce reasonable results
707 uint cell_count = synthetic_scan_test.getGridCellCount();
708 if (cell_count > 0) {
709 float leaf_area_density = synthetic_scan_test.getCellLeafAreaDensity(0);
710 DOCTEST_CHECK(std::isfinite(leaf_area_density));
711 DOCTEST_CHECK(leaf_area_density >= 0.0f);
712 }
713}
714
715DOCTEST_TEST_CASE("LiDAR Multi-Return Equal Weighting Test") {
716 LiDARcloud lidar;
717 lidar.disableMessages();
718
719 // Add scan programmatically for explicit control
720 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
721 uint Ntheta = 6000;
722 uint Nphi = 12000;
723 float thetaMin = 0.0f;
724 float thetaMax = M_PI;
725 float phiMin = 0.0f;
726 float phiMax = 2.0f * M_PI;
727 float exitDiameter = 0.0f;
728 float beamDivergence = 0.0f;
729 std::vector<std::string> columnFormat;
730
731 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
732 exitDiameter, beamDivergence, columnFormat);
733 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
734
735 // Add grid programmatically
736 vec3 grid_center(0.0f, 0.0f, 0.5f);
737 vec3 grid_size(1.0f, 1.0f, 1.0f);
738 int3 grid_divisions = make_int3(1, 1, 1);
739 DOCTEST_CHECK_NOTHROW(lidar.addGrid(grid_center, grid_size, grid_divisions, 0));
740
741 vec3 gsize = lidar.getCellSize(0);
742
743 Context context;
744 std::vector<uint> UUIDs = context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
745
746 float LAD_exact = 0.f;
747 for (uint UUID: UUIDs) {
748 LAD_exact += context.getPrimitiveArea(UUID) / (gsize.x * gsize.y * gsize.z);
749 }
750
751 // Calculate exact G(theta) from primitive geometry
752 float Gtheta_exact_numerator = 0.f;
753 float Gtheta_exact_denominator = 0.f;
754 for (uint UUID : UUIDs) {
755 float area = context.getPrimitiveArea(UUID);
756 vec3 normal = context.getPrimitiveNormal(UUID);
757 std::vector<vec3> vertices = context.getPrimitiveVertices(UUID);
758 vec3 raydir = vertices.front() - scan_origin;
759 raydir.normalize();
760
761 if (area == area) { // Check for NaN
762 float normal_dot_ray = fabs(normal * raydir);
763 Gtheta_exact_numerator += normal_dot_ray * area;
764 Gtheta_exact_denominator += area;
765 }
766 }
767 float Gtheta_exact = 0.f;
768 if (Gtheta_exact_denominator > 0) {
769 Gtheta_exact = Gtheta_exact_numerator / Gtheta_exact_denominator;
770 }
771
772 // Multi-return test with scan_grid_only=true to limit miss recording to voxel region
773 // Test BOTH scan_grid_only modes to verify they give same result
774 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 2, 0.1f, true, true));
775 uint hits_grid_true = lidar.getHitCount();
776
777 DOCTEST_CHECK_NOTHROW(lidar.triangulateHitPoints(0.04, 10));
778 uint triangles_multi = lidar.getTriangleCount();
779
780 DOCTEST_CHECK_NOTHROW(lidar.calculateLeafArea(&context));
781 float LAD_grid_true = lidar.getCellLeafAreaDensity(0);
782 float G_grid_true = lidar.getCellGtheta(0);
783
784 // Test scan_grid_only=FALSE
785 LiDARcloud lidar2;
786 lidar2.disableMessages();
787
788 // Add scan and grid programmatically for second test
789 DOCTEST_CHECK_NOTHROW(lidar2.addScan(scan));
790 DOCTEST_CHECK_NOTHROW(lidar2.addGrid(grid_center, grid_size, grid_divisions, 0));
791
792 DOCTEST_CHECK_NOTHROW(lidar2.syntheticScan(&context, 2, 0.1f, false, true));
793 uint hits_grid_false = lidar2.getHitCount();
794
795 DOCTEST_CHECK_NOTHROW(lidar2.triangulateHitPoints(0.04, 10));
796 DOCTEST_CHECK_NOTHROW(lidar2.calculateLeafArea(&context));
797 float LAD_grid_false = lidar2.getCellLeafAreaDensity(0);
798 float G_grid_false = lidar2.getCellGtheta(0);
799
800 // Use scan_grid_only=TRUE result as the main test (it's faster)
801 float LAD_multi = LAD_grid_true;
802 float Gtheta_multi = G_grid_true;
803
804 // Verify both scan_grid_only modes give same result
805 DOCTEST_CHECK(fabs(LAD_grid_true - LAD_grid_false) < 0.01f);
806 DOCTEST_CHECK(fabs(G_grid_true - G_grid_false) < 0.01f);
807
808 // Compare with single-return scan using same scan parameters
809 LiDARcloud lidar_single;
810 lidar_single.disableMessages();
811 DOCTEST_CHECK_NOTHROW(lidar_single.addScan(scan));
812 DOCTEST_CHECK_NOTHROW(lidar_single.addGrid(grid_center, grid_size, grid_divisions, 0));
813 // Use same scan_grid_only and record_misses settings as multi-return
814 DOCTEST_CHECK_NOTHROW(lidar_single.syntheticScan(&context, true, true));
815 uint hits_single = lidar_single.getHitCount();
816
817 DOCTEST_CHECK_NOTHROW(lidar_single.triangulateHitPoints(0.04, 10));
818
819 DOCTEST_CHECK_NOTHROW(lidar_single.calculateLeafArea(&context));
820 float LAD_single = lidar_single.getCellLeafAreaDensity(0);
821 float G_single = lidar_single.getCellGtheta(0);
822
823 // Multi-return equal weighting should match expected LAD within 2%
824 DOCTEST_CHECK(LAD_multi > LAD_exact * 0.98f);
825 DOCTEST_CHECK(LAD_multi < LAD_exact * 1.02f);
826
827 // Check G(theta) against exact value calculated from primitives
828 DOCTEST_CHECK(Gtheta_multi == Gtheta_multi); // Check for NaN
829 DOCTEST_CHECK(fabs(Gtheta_multi - Gtheta_exact) / Gtheta_exact == doctest::Approx(0.0f).epsilon(0.05f));
830}
831
832DOCTEST_TEST_CASE("LiDAR Eight Voxel Multi-Return Equal Weighting Test") {
833 // Test multi-return LiDAR with 8-voxel grid (2x2x2)
834 // Validates equal-weighting algorithm handles partial occlusion correctly
835
836 LiDARcloud synthetic_mr8;
837 synthetic_mr8.disableMessages();
838
839 // Add scan programmatically with beam spreading for true multi-return
840 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
841 uint Ntheta = 10000;
842 uint Nphi = 14000;
843 float thetaMin = 0.0f;
844 float thetaMax = M_PI;
845 float phiMin = 0.0f;
846 float phiMax = 2.0f * M_PI;
847 float exitDiameter = 0.0f; // Point source for backward compatibility
848 float beamDivergence = 0.0004f;
849 std::vector<std::string> columnFormat;
850
851 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
852 exitDiameter, beamDivergence, columnFormat);
853 DOCTEST_CHECK_NOTHROW(synthetic_mr8.addScan(scan));
854
855 // Add grid programmatically
856 vec3 grid_center(0.0f, 0.0f, 0.5f);
857 vec3 grid_size(1.0f, 1.0f, 1.0f);
858 int3 grid_divisions = make_int3(2, 2, 2);
859 DOCTEST_CHECK_NOTHROW(synthetic_mr8.addGrid(grid_center, grid_size, grid_divisions, 0));
860
861 vec3 gsize = synthetic_mr8.getCellSize(0);
862
863 Context context_mr8;
864 context_mr8.seedRandomGenerator(0); // Seed for reproducible random perturbations
865 std::vector<uint> UUIDs = context_mr8.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
866
867 // Calculate expected LAD for each of 8 voxels based on primitive positions
868 std::vector<float> LAD_ex(8, 0);
869 std::vector<float> Gtheta_ex(8, 0);
870 std::vector<float> Gtheta_ex_numerator(8, 0);
871 std::vector<float> Gtheta_ex_denominator(8, 0);
872
873 for (uint UUID: UUIDs) {
874 int i, j, k;
875 i = j = k = 0;
876 vec3 v = context_mr8.getPrimitiveVertices(UUID).front();
877 if (v.x > 0.f) { i = 1; }
878 if (v.y > 0.f) { j = 1; }
879 if (v.z > 0.5f) { k = 1; }
880 int ID = k * 4 + j * 2 + i;
881
882 float area = context_mr8.getPrimitiveArea(UUID);
883 LAD_ex.at(ID) += area / (gsize.x * gsize.y * gsize.z);
884
885 // Calculate exact G(theta) from primitive geometry for each voxel
886 vec3 normal = context_mr8.getPrimitiveNormal(UUID);
887 std::vector<vec3> vertices = context_mr8.getPrimitiveVertices(UUID);
888 vec3 raydir = vertices.front() - scan_origin;
889 raydir.normalize();
890
891 if (area == area) { // Check for NaN
892 float normal_dot_ray = fabs(normal * raydir);
893 Gtheta_ex_numerator.at(ID) += normal_dot_ray * area;
894 Gtheta_ex_denominator.at(ID) += area;
895 }
896 }
897
898 // Compute final G(theta) values for each voxel
899 for (int i = 0; i < 8; i++) {
900 if (Gtheta_ex_denominator[i] > 0) {
901 Gtheta_ex[i] = Gtheta_ex_numerator[i] / Gtheta_ex_denominator[i];
902 }
903 }
904
905 // Multi-return scan with realistic beam spreading (100 rays per pulse for stable statistics)
906 DOCTEST_CHECK_NOTHROW(synthetic_mr8.syntheticScan(&context_mr8, 100, 0.1f, true, true));
907 uint hits_grid_true = synthetic_mr8.getHitCount();
908
909 // Check if we're actually getting multiple returns per pulse
910 uint multi_return_count = 0;
911 for (uint i = 0; i < hits_grid_true; i++) {
912 if (synthetic_mr8.doesHitDataExist(i, "target_count") &&
913 synthetic_mr8.getHitData(i, "target_count") > 1) {
914 multi_return_count++;
915 }
916 }
917
918 // Triangulate using base overload - first returns automatically filtered for multi-return data
919 DOCTEST_CHECK_NOTHROW(synthetic_mr8.triangulateHitPoints(0.04, 10));
920 DOCTEST_CHECK(synthetic_mr8.getTriangleCount() > 0);
921 DOCTEST_CHECK_NOTHROW(synthetic_mr8.calculateLeafArea(&context_mr8));
922
923 std::vector<float> LAD_grid_true(8);
924 std::vector<float> G_grid_true(8);
925 for (int i = 0; i < 8; i++) {
926 LAD_grid_true[i] = synthetic_mr8.getCellLeafAreaDensity(i);
927 G_grid_true[i] = synthetic_mr8.getCellGtheta(i);
928 }
929
930 // (Removed dual scan_grid_only testing for now - simplify to match working test)
931
932 // Verify multi-return data fields exist
933 bool has_target_index = true;
934 bool has_target_count = true;
935 bool has_timestamp = true;
936
937 for (uint i = 0; i < hits_grid_true; i++) {
938 if (!synthetic_mr8.doesHitDataExist(i, "target_index")) has_target_index = false;
939 if (!synthetic_mr8.doesHitDataExist(i, "target_count")) has_target_count = false;
940 if (!synthetic_mr8.doesHitDataExist(i, "timestamp")) has_timestamp = false;
941 }
942
943 DOCTEST_CHECK(has_target_index);
944 DOCTEST_CHECK(has_target_count);
945 DOCTEST_CHECK(has_timestamp);
946
947 // Check for duplicate first returns per timestamp (critical bug check)
948 std::map<int, int> timestamp_first_return_count;
949 for (uint i = 0; i < hits_grid_true; i++) {
950 if (synthetic_mr8.doesHitDataExist(i, "target_index") &&
951 synthetic_mr8.doesHitDataExist(i, "timestamp")) {
952 int tidx = static_cast<int>(synthetic_mr8.getHitData(i, "target_index"));
953 int tstamp = static_cast<int>(synthetic_mr8.getHitData(i, "timestamp"));
954 if (tidx == 0) {
955 timestamp_first_return_count[tstamp]++;
956 }
957 }
958 }
959
960 for (const auto& pair : timestamp_first_return_count) {
961 DOCTEST_CHECK(pair.second == 1);
962 }
963
964 // Validate LAD accuracy using RMSE across all 8 voxels
965 float RMSE = 0.f;
966 for (int i = 0; i < 8; i++) {
967 float LAD = LAD_grid_true[i];
968 RMSE += powf(LAD - LAD_ex.at(i), 2) / LAD_ex.at(i) / 8.0f;
969 }
970 RMSE = sqrtf(RMSE);
971
972 DOCTEST_CHECK(RMSE == doctest::Approx(0.0f).epsilon(0.1f));
973
974 // Validate G(theta) against exact values calculated from primitive geometry
975 for (int i = 0; i < 8; i++) {
976 float G = G_grid_true[i];
977 float G_exact = Gtheta_ex[i];
978
979 // Only check voxels with non-zero LAD
980 if (LAD_grid_true[i] > 0.001f && G_exact > 0) {
981 DOCTEST_CHECK(G == G); // Check for NaN
982 DOCTEST_CHECK(G_exact == G_exact); // Check for NaN
983 DOCTEST_CHECK(fabs(G - G_exact) / G_exact == doctest::Approx(0.0f).epsilon(0.05f));
984 }
985 }
986}
987
988DOCTEST_TEST_CASE("LiDAR Beam Perturbation - Single Return with Sphere") {
989 // Test 1: Single-return mode should give hits equal to scan pattern size
990 LiDARcloud lidar;
991 lidar.disableMessages();
992
993 // Create simple scan pattern (small grid to keep test fast)
994 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
995 uint Ntheta = 200;
996 uint Nphi = 400;
997 float thetaMin = 1.45f;
998 float thetaMax = 1.69f;
999 float phiMin = 0.0f;
1000 float phiMax = 6.28f;
1001 float exitDiameter = 0.0f;
1002 float beamDivergence = 0.0f;
1003 std::vector<std::string> columnFormat;
1004
1005 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
1006 exitDiameter, beamDivergence, columnFormat);
1007 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
1008
1009 // Add grid programmatically
1010 vec3 grid_center(0.0f, 0.0f, 0.5f);
1011 vec3 grid_size(1.0f, 1.0f, 1.0f);
1012 int3 grid_divisions = make_int3(1, 1, 1);
1013 DOCTEST_CHECK_NOTHROW(lidar.addGrid(grid_center, grid_size, grid_divisions, 0));
1014
1015 uint expected_rays = Ntheta * Nphi;
1016
1017 Context context;
1018 // Add large sphere (radius >> voxel size) to catch all rays - use low subdivision for speed
1019 vec3 sphere_center(0, 0, 0.5);
1020 float sphere_radius = 50.0f;
1021 context.addSphereObject(6, sphere_center, sphere_radius); // Low subdivision for speed
1022
1023 // Single-return scan (rays_per_pulse=1, small threshold to avoid merging)
1024 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 1, 0.001f));
1025
1026 uint hit_count = lidar.getHitCount();
1027
1028 DOCTEST_CHECK(hit_count == expected_rays);
1029}
1030
1031DOCTEST_TEST_CASE("LiDAR Beam Perturbation - Multi Return Zero Beam Width") {
1032 // Test 2: Multi-return with exitDiameter=beamDivergence=0 should behave like single-return
1033 LiDARcloud lidar;
1034 lidar.disableMessages();
1035
1036 // Create scan pattern programmatically
1037 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
1038 uint Ntheta = 2000;
1039 uint Nphi = 4000;
1040 float thetaMin = 0.0f; // Default when not specified in XML
1041 float thetaMax = M_PI; // Default when not specified in XML
1042 float phiMin = 0.0f; // Default when not specified in XML
1043 float phiMax = 2.0f * M_PI; // Default when not specified in XML
1044 float exitDiameter = 0.0f;
1045 float beamDivergence = 0.0f;
1046 std::vector<std::string> columnFormat;
1047
1048 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
1049 exitDiameter, beamDivergence, columnFormat);
1050 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
1051
1052 // Add grid programmatically
1053 vec3 grid_center(0.0f, 0.0f, 0.5f);
1054 vec3 grid_size(1.0f, 1.0f, 1.0f);
1055 int3 grid_divisions = make_int3(1, 1, 1);
1056 DOCTEST_CHECK_NOTHROW(lidar.addGrid(grid_center, grid_size, grid_divisions, 0));
1057
1058 uint expected_rays = Ntheta * Nphi;
1059
1060 Context context;
1061 vec3 sphere_center(0, 0, 0.5);
1062 float sphere_radius = 50.0f;
1063 context.addSphereObject(6, sphere_center, sphere_radius); // Low subdivision for speed
1064
1065 // Multi-return with rays_per_pulse=2, but zero beam width (should merge)
1066 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 2, 0.001f));
1067
1068 uint hit_count = lidar.getHitCount();
1069
1070 // With zero beam parameters, all rays should merge to give same count as single-return
1071 DOCTEST_CHECK(hit_count == expected_rays);
1072}
1073
1074DOCTEST_TEST_CASE("LiDAR Beam Perturbation - Multi Return Miss Recording") {
1075 // Test 3: Multi-return with no geometry should record misses correctly
1076 LiDARcloud lidar;
1077 lidar.disableMessages();
1078
1079 // Create scan pattern programmatically
1080 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
1081 uint Ntheta = 2000;
1082 uint Nphi = 4000;
1083 float thetaMin = 0.0f; // Default when not specified in XML
1084 float thetaMax = M_PI; // Default when not specified in XML
1085 float phiMin = 0.0f; // Default when not specified in XML
1086 float phiMax = 2.0f * M_PI; // Default when not specified in XML
1087 float exitDiameter = 0.0f;
1088 float beamDivergence = 0.0f;
1089 std::vector<std::string> columnFormat;
1090
1091 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
1092 exitDiameter, beamDivergence, columnFormat);
1093 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
1094
1095 // Add grid programmatically
1096 vec3 grid_center(0.0f, 0.0f, 0.5f);
1097 vec3 grid_size(1.0f, 1.0f, 1.0f);
1098 int3 grid_divisions = make_int3(1, 1, 1);
1099 DOCTEST_CHECK_NOTHROW(lidar.addGrid(grid_center, grid_size, grid_divisions, 0));
1100
1101 uint expected_rays = Ntheta * Nphi;
1102
1103 Context context;
1104 // No geometry - all rays should miss
1105
1106 // Multi-return with record_misses=true and rays_per_pulse=2
1107 // Multiple misses from same pulse should merge together
1108 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 2, 0.1f, false, true));
1109
1110 uint hit_count = lidar.getHitCount();
1111
1112 // All rays miss, but rays from same pulse merge, so count should equal expected_rays
1113 DOCTEST_CHECK(hit_count == expected_rays);
1114
1115 // Verify all hits are actually misses (large distance)
1116 bool all_misses = true;
1117 for (uint i = 0; i < hit_count; i++) {
1118 if (lidar.doesHitDataExist(i, "distance")) {
1119 float dist = lidar.getHitData(i, "distance");
1120 if (dist < 1000.0f) {
1121 all_misses = false;
1122 break;
1123 }
1124 }
1125 }
1126 DOCTEST_CHECK(all_misses);
1127}
1128
1129DOCTEST_TEST_CASE("LiDAR Multi-Return with Beam Spreading") {
1130 // Test multi-return with realistic beam parameters (exitDiameter>0, beamDivergence>0)
1131 LiDARcloud lidar;
1132 lidar.disableMessages();
1133
1134 // Add scan programmatically for explicit control
1135 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
1136 uint Ntheta = 10000;
1137 uint Nphi = 12000;
1138 float thetaMin = 0.0f; // Default when not specified in XML
1139 float thetaMax = M_PI; // Default when not specified in XML
1140 float phiMin = 0.0f; // Default when not specified in XML
1141 float phiMax = 2.0f * M_PI; // Default when not specified in XML
1142 float exitDiameter = 0.0f; // Point source for backward compatibility
1143 float beamDivergence = 0.0004f;
1144 std::vector<std::string> columnFormat;
1145
1146 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
1147 exitDiameter, beamDivergence, columnFormat);
1148 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
1149
1150 // Add grid programmatically
1151 vec3 grid_center(0.0f, 0.0f, 0.5f);
1152 vec3 grid_size(1.0f, 1.0f, 1.0f);
1153 int3 grid_divisions = make_int3(1, 1, 1);
1154 DOCTEST_CHECK_NOTHROW(lidar.addGrid(grid_center, grid_size, grid_divisions, 0));
1155
1156 vec3 gsize = lidar.getCellSize(0);
1157
1158 Context context;
1159 std::vector<uint> UUIDs = context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
1160
1161 float LAD_exact = 0.f;
1162 for (uint UUID: UUIDs) {
1163 LAD_exact += context.getPrimitiveArea(UUID) / (gsize.x * gsize.y * gsize.z);
1164 }
1165
1166 // Multi-return with beam spreading (rays should NOT merge)
1167 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 50, 0.1f, true, true)); // rays_per_pulse=50 for stable statistics
1168
1169 uint hit_count = lidar.getHitCount();
1170
1171 // Check for duplicate target_index=0 per timestamp (would be a bug!)
1172 std::map<int, int> timestamp_first_return_count;
1173 for (uint i = 0; i < hit_count; i++) {
1174 if (lidar.doesHitDataExist(i, "target_index") && lidar.doesHitDataExist(i, "timestamp")) {
1175 int tidx = static_cast<int>(lidar.getHitData(i, "target_index"));
1176 int tstamp = static_cast<int>(lidar.getHitData(i, "timestamp"));
1177 if (tidx == 0) {
1178 timestamp_first_return_count[tstamp]++;
1179 }
1180 }
1181 }
1182 int timestamps_with_multi_first = 0;
1183 for (auto& pair : timestamp_first_return_count) {
1184 if (pair.second > 1) timestamps_with_multi_first++;
1185 }
1186
1187 // Triangulate using base overload - first returns automatically filtered (use aspect_ratio=5 to filter sliver triangles from beam spreading)
1188 DOCTEST_CHECK_NOTHROW(lidar.triangulateHitPoints(0.04, 5));
1189 DOCTEST_CHECK(lidar.getTriangleCount() > 0);
1190
1191 DOCTEST_CHECK_NOTHROW(lidar.calculateLeafArea(&context));
1192
1193 float LAD = lidar.getCellLeafAreaDensity(0);
1194 float Gtheta = lidar.getCellGtheta(0);
1195
1196 // With beam spreading, we should get MORE hits than zero-width case (~2x)
1197 // But LAD should still be accurate
1198 DOCTEST_CHECK(LAD > LAD_exact * 0.9f);
1199 DOCTEST_CHECK(LAD < LAD_exact * 1.1f);
1200
1201 // G(theta) should be close to 0.5 for spherical distribution
1202 DOCTEST_CHECK(Gtheta > 0.45f);
1203 DOCTEST_CHECK(Gtheta < 0.55f);
1204}
1205
1206DOCTEST_TEST_CASE("LiDAR Exit Diameter - Comparative Spread Test") {
1207 // Verify that exitDiameter > 0 produces wider spatial spread than exitDiameter = 0
1208 Context context;
1209 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
1210
1211 vec3 scan_origin(0, 0, 5.0f);
1212
1213 // Scan WITHOUT exit diameter (point source)
1214 LiDARcloud lidar_point;
1215 lidar_point.disableMessages();
1216 ScanMetadata scan_point(scan_origin, 100, 0, M_PI, 100, 0, 2 * M_PI, 0.0f, 0.0f, {});
1217 lidar_point.addScan(scan_point);
1218 lidar_point.syntheticScan(&context, 50, 0.05f, false, false);
1219
1220 // Scan WITH exit diameter
1221 LiDARcloud lidar_exit;
1222 lidar_exit.disableMessages();
1223 ScanMetadata scan_exit(scan_origin, 100, 0, M_PI, 100, 0, 2 * M_PI, 0.1f, 0.0f, {});
1224 lidar_exit.addScan(scan_exit);
1225 lidar_exit.syntheticScan(&context, 50, 0.05f, false, false);
1226
1227 DOCTEST_REQUIRE(lidar_point.getHitCount() > 0);
1228 DOCTEST_REQUIRE(lidar_exit.getHitCount() > 0);
1229
1230 // Calculate spatial extent for point source
1231 float x_min_pt = 1e6f, x_max_pt = -1e6f, y_min_pt = 1e6f, y_max_pt = -1e6f;
1232 for (uint i = 0; i < lidar_point.getHitCount(); i++) {
1233 vec3 pos = lidar_point.getHitXYZ(i);
1234 x_min_pt = fmin(x_min_pt, pos.x); x_max_pt = fmax(x_max_pt, pos.x);
1235 y_min_pt = fmin(y_min_pt, pos.y); y_max_pt = fmax(y_max_pt, pos.y);
1236 }
1237
1238 // Calculate spatial extent for exit diameter
1239 float x_min_ex = 1e6f, x_max_ex = -1e6f, y_min_ex = 1e6f, y_max_ex = -1e6f;
1240 for (uint i = 0; i < lidar_exit.getHitCount(); i++) {
1241 vec3 pos = lidar_exit.getHitXYZ(i);
1242 x_min_ex = fmin(x_min_ex, pos.x); x_max_ex = fmax(x_max_ex, pos.x);
1243 y_min_ex = fmin(y_min_ex, pos.y); y_max_ex = fmax(y_max_ex, pos.y);
1244 }
1245
1246 float extent_point = fmax(x_max_pt - x_min_pt, y_max_pt - y_min_pt);
1247 float extent_exit = fmax(x_max_ex - x_min_ex, y_max_ex - y_min_ex);
1248
1249 // Exit diameter should produce measurably wider spread
1250 DOCTEST_CHECK(extent_exit > extent_point * 1.01f);
1251}
1252
1253DOCTEST_TEST_CASE("LiDAR Exit Diameter - Zero Backward Compatibility") {
1254 // Verify that exitDiameter=0 still works (backward compatibility)
1255 Context context;
1256 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
1257
1258 // Scan with exitDiameter=0 (original behavior - should work without errors)
1259 LiDARcloud lidar_zero;
1260 lidar_zero.disableMessages();
1261 ScanMetadata scan_zero(vec3(0, 0, 5), 100, 0, M_PI, 100, 0, 2 * M_PI, 0.0f, 0.0f, {});
1262 lidar_zero.addScan(scan_zero);
1263 DOCTEST_CHECK_NOTHROW(lidar_zero.syntheticScan(&context, 50, 0.05f, false, false));
1264 DOCTEST_CHECK(lidar_zero.getHitCount() > 0);
1265
1266 // Scan with exitDiameter>0 (new behavior - should also work)
1267 LiDARcloud lidar_exit;
1268 lidar_exit.disableMessages();
1269 ScanMetadata scan_exit(vec3(0, 0, 5), 100, 0, M_PI, 100, 0, 2 * M_PI, 0.01f, 0.0f, {});
1270 lidar_exit.addScan(scan_exit);
1271 DOCTEST_CHECK_NOTHROW(lidar_exit.syntheticScan(&context, 50, 0.05f, false, false));
1272 DOCTEST_CHECK(lidar_exit.getHitCount() > 0);
1273}
1274
1275DOCTEST_TEST_CASE("LiDAR Exit Diameter - Combined with Beam Divergence") {
1276 // Verify both spatial spreading (exitDiameter) and angular spreading (beamDivergence) work together
1277 Context context;
1278 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
1279
1280 vec3 scan_origin(0, 0, 5.0f);
1281
1282 // Scan with BOTH exitDiameter and beamDivergence
1283 LiDARcloud lidar_both;
1284 lidar_both.disableMessages();
1285 ScanMetadata scan_both(scan_origin, 100, 0, M_PI, 100, 0, 2 * M_PI, 0.1f, 0.01f, {});
1286 lidar_both.addScan(scan_both);
1287 lidar_both.syntheticScan(&context, 50, 0.05f, false, false);
1288
1289 // Scan with ONLY beamDivergence
1290 LiDARcloud lidar_div;
1291 lidar_div.disableMessages();
1292 ScanMetadata scan_div(scan_origin, 100, 0, M_PI, 100, 0, 2 * M_PI, 0.0f, 0.01f, {});
1293 lidar_div.addScan(scan_div);
1294 lidar_div.syntheticScan(&context, 50, 0.05f, false, false);
1295
1296 DOCTEST_REQUIRE(lidar_both.getHitCount() > 0);
1297 DOCTEST_REQUIRE(lidar_div.getHitCount() > 0);
1298
1299 // Calculate spread for combined
1300 float x_min_b = 1e6f, x_max_b = -1e6f, y_min_b = 1e6f, y_max_b = -1e6f;
1301 for (uint i = 0; i < lidar_both.getHitCount(); i++) {
1302 vec3 pos = lidar_both.getHitXYZ(i);
1303 x_min_b = fmin(x_min_b, pos.x); x_max_b = fmax(x_max_b, pos.x);
1304 y_min_b = fmin(y_min_b, pos.y); y_max_b = fmax(y_max_b, pos.y);
1305 }
1306
1307 // Calculate spread for divergence only
1308 float x_min_d = 1e6f, x_max_d = -1e6f, y_min_d = 1e6f, y_max_d = -1e6f;
1309 for (uint i = 0; i < lidar_div.getHitCount(); i++) {
1310 vec3 pos = lidar_div.getHitXYZ(i);
1311 x_min_d = fmin(x_min_d, pos.x); x_max_d = fmax(x_max_d, pos.x);
1312 y_min_d = fmin(y_min_d, pos.y); y_max_d = fmax(y_max_d, pos.y);
1313 }
1314
1315 float spread_both = fmax(x_max_b - x_min_b, y_max_b - y_min_b);
1316 float spread_div = fmax(x_max_d - x_min_d, y_max_d - y_min_d);
1317
1318 // Combined should produce wider spread than divergence alone
1319 DOCTEST_CHECK(spread_both > spread_div * 1.01f);
1320}
1321
1322DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Grid Position Verification") {
1323 LiDARcloud lidar;
1324 lidar.disableMessages();
1325 Context context;
1326
1327 // 1. Load existing test configuration with grid
1328 DOCTEST_CHECK_NOTHROW(lidar.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
1329
1330 vec3 scan_origin = lidar.getScanOrigin(0);
1331 uint Ntheta = lidar.getScanSizeTheta(0);
1332 uint Nphi = lidar.getScanSizePhi(0);
1333
1334 // 2. Create simple geometry - small sphere that partially occludes
1335 std::vector<uint> sphere_uuids = context.addSphere(10, make_vec3(0, 0, 1.0), 0.3);
1336
1337 // 3. Perform synthetic scan WITHOUT miss recording
1338 lidar.syntheticScan(&context, false, false); // scan_grid_only=false, record_misses=false
1339 uint hits_before_gapfill = lidar.getHitCount();
1340
1341 // Sphere should block some rays creating gaps
1342 DOCTEST_CHECK(hits_before_gapfill > 0);
1343
1344 // 4. Apply gapfilling with flags
1345 std::vector<vec3> filled_points = lidar.gapfillMisses(0, false, true);
1346 uint hits_after_gapfill = lidar.getHitCount();
1347
1348 // 5. Verify gapfilling added points
1349 DOCTEST_CHECK(hits_after_gapfill > hits_before_gapfill);
1350 DOCTEST_CHECK(filled_points.size() > 0);
1351
1352 // 6. QUANTITATIVE CHECK: Build position map by grid coordinates
1353 // Use hit table to track which grid positions are filled
1354 std::map<std::pair<int,int>, bool> filled_grid_positions;
1355
1356 for (uint r = 0; r < lidar.getHitCount(); r++) {
1357 if (lidar.getHitScanID(r) == 0) {
1358 SphericalCoord raydir = lidar.getHitRaydir(r);
1359 // Convert direction to grid indices using scan metadata
1360 float theta = raydir.zenith;
1361 float phi = raydir.azimuth;
1362 vec2 theta_range = lidar.getScanRangeTheta(0);
1363 vec2 phi_range = lidar.getScanRangePhi(0);
1364
1365 int row = round((theta - theta_range.x) / (theta_range.y - theta_range.x) * (Ntheta - 1));
1366 int col = round((phi - phi_range.x) / (phi_range.y - phi_range.x) * (Nphi - 1));
1367
1368 filled_grid_positions[std::make_pair(row, col)] = true;
1369 }
1370 }
1371
1372 uint filled_cells = filled_grid_positions.size();
1373
1374 // 7. QUANTITATIVE CHECK: Verify flag values
1375 uint flag_0_count = 0; // Original hits
1376 uint flag_1_count = 0; // Interior gapfilled
1377 uint flag_2_count = 0; // Downward edge
1378 uint flag_3_count = 0; // Upward edge
1379
1380 for (uint r = 0; r < lidar.getHitCount(); r++) {
1381 if (lidar.getHitScanID(r) == 0 && lidar.doesHitDataExist(r, "gapfillMisses_code")) {
1382 int code = (int)lidar.getHitData(r, "gapfillMisses_code");
1383 if (code == 0) flag_0_count++;
1384 else if (code == 1) flag_1_count++;
1385 else if (code == 2) flag_2_count++;
1386 else if (code == 3) flag_3_count++;
1387 }
1388 }
1389
1390 DOCTEST_CHECK(flag_0_count == hits_before_gapfill); // Original hits preserved
1391 DOCTEST_CHECK((flag_1_count + flag_2_count + flag_3_count) == filled_points.size());
1392
1393 // NOTE: Interior fills (flag_1) may be 0 for sparse data
1394 // Edge fills (flag_2, flag_3) should exist since algorithm extrapolates edges
1395 DOCTEST_CHECK((flag_1_count + flag_2_count + flag_3_count) > 0); // At least some fills occurred
1396}
1397
1398DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Comparison with Record Misses") {
1399 LiDARcloud lidar1, lidar2;
1400 lidar1.disableMessages();
1401 lidar2.disableMessages();
1402
1403 Context context;
1404
1405 // Load existing test configuration with voxel grid
1406 DOCTEST_CHECK_NOTHROW(lidar1.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
1407 DOCTEST_CHECK_NOTHROW(lidar2.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
1408
1409 // Create geometry that creates both hits and misses
1410 std::vector<uint> sphere_uuids = context.addSphere(10, make_vec3(0, 0, 1.0), 0.3);
1411
1412 // === METHOD 1: Synthetic scan WITH miss recording ===
1413 lidar1.syntheticScan(&context, false, true); // record_misses = TRUE
1414 uint hits_with_misses = lidar1.getHitCount();
1415
1416 // === METHOD 2: Synthetic scan WITHOUT miss recording, then gapfill ===
1417 lidar2.syntheticScan(&context, false, false); // record_misses = FALSE
1418 uint hits_before_gapfill = lidar2.getHitCount();
1419
1420 std::vector<vec3> filled = lidar2.gapfillMisses(0, false, false);
1421 uint hits_after_gapfill = lidar2.getHitCount();
1422
1423 // === QUANTITATIVE VERIFICATION ===
1424
1425 // 1. Gapfilling should have added points
1426 DOCTEST_CHECK(hits_after_gapfill > hits_before_gapfill);
1427 DOCTEST_CHECK(filled.size() > 0);
1428
1429 // 2. With duplicate prevention, gapfillMisses should produce similar hit counts
1430 // May be slightly different due to algorithmic differences, but should be close
1431 float hit_ratio = float(hits_after_gapfill) / float(hits_with_misses);
1432 DOCTEST_CHECK(hit_ratio > 0.7f); // Within reasonable range
1433 DOCTEST_CHECK(hit_ratio < 1.3f);
1434
1435 // 3. Verify both methods cover similar grid positions by comparing actual hits (non-misses)
1436 // Count hits that are NOT far-field points
1437 uint real_hits_method1 = 0;
1438 uint real_hits_method2 = 0;
1439
1440 for (uint r = 0; r < lidar1.getHitCount(); r++) {
1441 float dist = sqrt(pow(lidar1.getHitXYZ(r).x - lidar1.getScanOrigin(0).x, 2) +
1442 pow(lidar1.getHitXYZ(r).y - lidar1.getScanOrigin(0).y, 2) +
1443 pow(lidar1.getHitXYZ(r).z - lidar1.getScanOrigin(0).z, 2));
1444 if (dist < 1000) real_hits_method1++; // Not a far-field miss
1445 }
1446
1447 for (uint r = 0; r < lidar2.getHitCount(); r++) {
1448 float dist = sqrt(pow(lidar2.getHitXYZ(r).x - lidar2.getScanOrigin(0).x, 2) +
1449 pow(lidar2.getHitXYZ(r).y - lidar2.getScanOrigin(0).y, 2) +
1450 pow(lidar2.getHitXYZ(r).z - lidar2.getScanOrigin(0).z, 2));
1451 if (dist < 1000) real_hits_method2++;
1452 }
1453
1454 // Real hits (on geometry) should match between methods
1455 DOCTEST_CHECK(real_hits_method1 == real_hits_method2);
1456}
1457
1458DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Edge Cases") {
1459 LiDARcloud lidar;
1460 lidar.disableMessages();
1461
1462 // Test 1: Invalid scanID should throw error
1463 bool caught_error = false;
1464 try {
1465 lidar.gapfillMisses(999); // No scans exist yet
1466 } catch (const std::runtime_error& e) {
1467 caught_error = true;
1468 std::string msg(e.what());
1469 DOCTEST_CHECK(msg.find("Invalid scanID") != std::string::npos);
1470 }
1471 DOCTEST_CHECK(caught_error);
1472
1473 // Test 2: Empty scan (no hits) should return empty vector gracefully
1474 DOCTEST_CHECK_NOTHROW(lidar.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
1475 Context context;
1476 // Don't add any geometry - all rays will miss and not be traced
1477 lidar.syntheticScan(&context, false, false); // No geometry, no hits
1478
1479 uint hits_before = lidar.getHitCount();
1480 std::vector<vec3> filled;
1481 DOCTEST_CHECK_NOTHROW(filled = lidar.gapfillMisses(0, false, false));
1482
1483 // Should handle empty scan gracefully
1484 if (hits_before == 0) {
1485 DOCTEST_CHECK(filled.empty());
1486 DOCTEST_CHECK(lidar.getHitCount() == 0);
1487 }
1488
1489 // Test 3: Multi-scan gapfilling (all scans overload)
1490 LiDARcloud lidar2;
1491 lidar2.disableMessages();
1492 DOCTEST_CHECK_NOTHROW(lidar2.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
1493 std::vector<uint> sphere_uuids = context.addSphere(10, make_vec3(0, 0, 1.0), 0.3);
1494 lidar2.syntheticScan(&context, false, false);
1495
1496 uint hits_before_all = lidar2.getHitCount();
1497 std::vector<vec3> filled_all;
1498 DOCTEST_CHECK_NOTHROW(filled_all = lidar2.gapfillMisses()); // Fill all scans
1499 uint hits_after_all = lidar2.getHitCount();
1500
1501 DOCTEST_CHECK(hits_after_all >= hits_before_all); // Should add points or stay same
1502}
1503
1504DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Grid Only Mode") {
1505 LiDARcloud lidar;
1506 lidar.disableMessages();
1507 Context context;
1508
1509 // Load configuration with voxel grid
1510 DOCTEST_CHECK_NOTHROW(lidar.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
1511
1512 // Add geometry
1513 std::vector<uint> sphere_uuids = context.addSphere(10, make_vec3(0, 0, 1.0), 0.3);
1514
1515 // Perform scan without miss recording
1516 lidar.syntheticScan(&context, false, false);
1517 uint hits_before = lidar.getHitCount();
1518
1519 // Test grid-only mode (should fill fewer points than full mode)
1520 std::vector<vec3> filled_grid_only = lidar.gapfillMisses(0, true, false);
1521 uint hits_grid_only = lidar.getHitCount();
1522
1523 // Reset and test full mode
1524 LiDARcloud lidar2;
1525 lidar2.disableMessages();
1526 DOCTEST_CHECK_NOTHROW(lidar2.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
1527 lidar2.syntheticScan(&context, false, false);
1528
1529 std::vector<vec3> filled_full = lidar2.gapfillMisses(0, false, false);
1530 uint hits_full = lidar2.getHitCount();
1531
1532 // Grid-only mode should fill same or fewer points (limited to grid bounds)
1533 DOCTEST_CHECK(filled_grid_only.size() <= filled_full.size());
1534}
1535
1536DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Multi-Return Data") {
1537 LiDARcloud lidar;
1538 lidar.disableMessages();
1539
1540 Context context;
1541
1542 // Create simple box geometry
1543 std::vector<uint> box_uuids = context.addBox(make_vec3(0, 0, 1), make_vec3(1.5, 1.5, 1.5), make_int3(8, 8, 8));
1544
1545 // Add voxel grid for the scan
1546 lidar.addGrid(make_vec3(0, 0, 1), make_vec3(2, 2, 2), make_int3(1, 1, 1), 0);
1547
1548 // Create scan programmatically with beam spreading parameters for multi-return
1549 vec3 scan_origin(-3, 0, 1);
1550 uint Ntheta = 25;
1551 uint Nphi = 30;
1552 float thetaMin = M_PI/3;
1553 float thetaMax = 2*M_PI/3;
1554 float phiMin = 0.9;
1555 float phiMax = 1.7;
1556 float exitDiameter = 0.015; // 1.5cm exit diameter
1557 float beamDivergence = 0.002; // 2 mrad divergence for beam spreading
1558
1559 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
1560 exitDiameter, beamDivergence,
1561 std::vector<std::string>{"x", "y", "z", "timestamp"});
1562 uint scanID = lidar.addScan(scan);
1563
1564 // Multi-return synthetic scan WITHOUT miss recording - gapfilling will restore them
1565 lidar.syntheticScan(&context, 3, 0.15f, false, false); // rays_per_pulse=3, record_misses=false
1566 uint hits_before = lidar.getHitCount();
1567
1568 // Check if multi-return data was created (depends on beam spreading and geometry)
1569 bool has_multi_return = false;
1570 for (size_t r = 0; r < lidar.getHitCount(); r++) {
1571 if (lidar.doesHitDataExist(r, "target_count") &&
1572 lidar.getHitData(r, "target_count") > 1) {
1573 has_multi_return = true;
1574 break;
1575 }
1576 }
1577 // Note: Multi-return creation depends on beam parameters and geometry interaction
1578 // Test verifies gapfilling works regardless
1579
1580 // Apply gapfilling with flags for multi-return data
1581 std::vector<vec3> filled = lidar.gapfillMisses(scanID, false, true);
1582 uint hits_after = lidar.getHitCount();
1583
1584 // Should have added gapfilled points
1585 DOCTEST_CHECK(hits_after > hits_before);
1586 DOCTEST_CHECK(filled.size() > 0);
1587
1588 // Verify gapfillMisses_code flags were added
1589 uint flag_0_count = 0; // Original hits
1590 uint flag_other_count = 0; // Gapfilled hits (1, 2, or 3)
1591
1592 for (uint r = 0; r < lidar.getHitCount(); r++) {
1593 if (lidar.doesHitDataExist(r, "gapfillMisses_code")) {
1594 int code = (int)lidar.getHitData(r, "gapfillMisses_code");
1595 if (code == 0) {
1596 flag_0_count++;
1597 } else {
1598 flag_other_count++;
1599 }
1600 }
1601 }
1602
1603 // Original hits should all be flagged (if multi-return data exists)
1604 if (has_multi_return) {
1605 DOCTEST_CHECK(flag_0_count > 0); // Should have original hits
1606 DOCTEST_CHECK(flag_other_count == filled.size()); // All filled points flagged
1607 }
1608
1609 // Gapfilling should work without crashing on multi-return data
1610 DOCTEST_CHECK_NOTHROW(lidar.triangulateHitPoints(0.04, 10));
1611
1612 // Note: If no multi-return data was created (geometry too sparse), test still validates
1613 // that gapfillMisses runs without error on the data that does exist
1614}
1615
1616DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Strict Accuracy Verification") {
1617 // Rigorous test to verify gapfillMisses produces ACCURATE results matching record_misses
1618
1619 Context context;
1620
1621 // Create scan parameters first to calculate coverage area
1622 vec3 scan_origin(-4, 0, 1.5);
1623 uint Ntheta = 35;
1624 uint Nphi = 50;
1625 float thetaMin = M_PI/3;
1626 float thetaMax = 2*M_PI/3;
1627 float phiMin = 1.0;
1628 float phiMax = 1.8;
1629
1630 // Create large box geometry that covers ENTIRE scan field of view
1631 // This ensures ALL rays in the scan pattern will be traced
1632 // Place box to cover the angular range from the scanner
1633 std::vector<uint> box_uuids = context.addBox(make_vec3(2, 0, 1.5), make_vec3(8, 8, 4), make_int3(15, 15, 10));
1634
1635 // METHOD 1: Record misses (GROUND TRUTH)
1636 LiDARcloud lidar1;
1637 lidar1.disableMessages();
1638 lidar1.addGrid(make_vec3(0, 0, 1.5), make_vec3(2, 2, 2), make_int3(1, 1, 1), 0);
1639 ScanMetadata scan1(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
1640 0, 0, std::vector<std::string>{"x", "y", "z", "timestamp"});
1641 lidar1.addScan(scan1);
1642 lidar1.syntheticScan(&context, false, true); // record_misses = TRUE (ground truth)
1643
1644 uint hits_ground_truth = lidar1.getHitCount();
1645
1646 // Build comprehensive position map for ground truth
1647 std::map<std::pair<int,int>, vec3> ground_truth_positions;
1648 std::map<std::pair<int,int>, bool> ground_truth_is_miss; // Track which are far-field misses
1649
1650 for (uint r = 0; r < lidar1.getHitCount(); r++) {
1651 SphericalCoord raydir = lidar1.getHitRaydir(r);
1652 vec3 pos = lidar1.getHitXYZ(r);
1653
1654 // Calculate grid indices
1655 float theta = raydir.zenith;
1656 float phi = raydir.azimuth;
1657 int row = round((theta - thetaMin) / (thetaMax - thetaMin) * (Ntheta - 1));
1658 int col = round((phi - phiMin) / (phiMax - phiMin) * (Nphi - 1));
1659
1660 ground_truth_positions[std::make_pair(row, col)] = pos;
1661
1662 // Check if this is a far-field miss (distance > 1000m)
1663 float dist = sqrt(pow(pos.x - scan_origin.x, 2) +
1664 pow(pos.y - scan_origin.y, 2) +
1665 pow(pos.z - scan_origin.z, 2));
1666 ground_truth_is_miss[std::make_pair(row, col)] = (dist > 1000);
1667 }
1668
1669 // Verify we got expected number of hits (should be Ntheta × Nphi)
1670 uint expected_grid_size = Ntheta * Nphi;
1671
1672 DOCTEST_CHECK_MESSAGE(hits_ground_truth == expected_grid_size,
1673 "record_misses should produce Ntheta×Nphi hits but got " << hits_ground_truth << " vs " << expected_grid_size);
1674
1675 DOCTEST_CHECK(ground_truth_positions.size() == hits_ground_truth);
1676
1677 // METHOD 2: Gapfill misses
1678 LiDARcloud lidar2;
1679 lidar2.disableMessages();
1680 lidar2.addGrid(make_vec3(0, 0, 1.5), make_vec3(2, 2, 2), make_int3(1, 1, 1), 0);
1681 ScanMetadata scan2(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax,
1682 0, 0, std::vector<std::string>{"x", "y", "z", "timestamp"});
1683 lidar2.addScan(scan2);
1684 lidar2.syntheticScan(&context, false, false); // record_misses = FALSE
1685
1686 uint hits_before_gapfill = lidar2.getHitCount();
1687
1688 std::vector<vec3> filled = lidar2.gapfillMisses(0, false, false);
1689 uint hits_after_gapfill = lidar2.getHitCount();
1690
1691 // Build position map for gapfilled data (map will deduplicate by grid position)
1692 std::map<std::pair<int,int>, vec3> gapfilled_positions;
1693 std::map<std::pair<int,int>, uint> gapfilled_hit_count; // Count hits per grid position
1694
1695 for (uint r = 0; r < lidar2.getHitCount(); r++) {
1696 SphericalCoord raydir = lidar2.getHitRaydir(r);
1697 vec3 pos = lidar2.getHitXYZ(r);
1698
1699 float theta = raydir.zenith;
1700 float phi = raydir.azimuth;
1701 int row = round((theta - thetaMin) / (thetaMax - thetaMin) * (Ntheta - 1));
1702 int col = round((phi - phiMin) / (phiMax - phiMin) * (Nphi - 1));
1703
1704 auto key = std::make_pair(row, col);
1705 gapfilled_positions[key] = pos; // Map stores last position at this grid cell
1706 gapfilled_hit_count[key]++; // Count hits per grid cell
1707 }
1708
1709 // Check for duplicates in gapfilled data
1710 uint gapfilled_duplicates = hits_after_gapfill - gapfilled_positions.size();
1711 DOCTEST_CHECK_MESSAGE(gapfilled_duplicates == 0,
1712 "CRITICAL BUG: gapfillMisses created " << gapfilled_duplicates
1713 << " duplicate hits at positions that already had hits!");
1714
1715 // 1. Separate verification: Interior positions vs Edge extrapolation
1716 // Ground truth only has positions traced by syntheticScan
1717 // Gapfilling should: (a) match ground truth for traced regions, (b) extrapolate edges
1718
1719 // All ground truth positions should exist in gapfilled data (superset)
1720 uint ground_truth_positions_found = 0;
1721 for (const auto& kv : ground_truth_positions) {
1722 if (gapfilled_positions.find(kv.first) != gapfilled_positions.end()) {
1723 ground_truth_positions_found++;
1724 }
1725 }
1726
1727 float ground_truth_recovery = float(ground_truth_positions_found) / float(ground_truth_positions.size());
1728 DOCTEST_CHECK_MESSAGE(ground_truth_recovery > 0.95f,
1729 "Gapfilling missed positions that record_misses found: only recovered "
1730 << ground_truth_positions_found << " of " << ground_truth_positions.size()
1731 << " (" << (ground_truth_recovery*100) << "%)");
1732
1733 // 2. Verify all gapfilled points are at VALID grid positions (theta, phi in bounds)
1734 uint invalid_positions = 0;
1735 for (const auto& kv : gapfilled_positions) {
1736 int row = kv.first.first;
1737 int col = kv.first.second;
1738
1739 if (row < 0 || row >= (int)Ntheta || col < 0 || col >= (int)Nphi) {
1740 invalid_positions++;
1741 }
1742 }
1743 DOCTEST_CHECK_MESSAGE(invalid_positions == 0,
1744 "Gapfilling created " << invalid_positions << " points at invalid grid positions");
1745
1746 // 3. Verify positions that match ground truth have correct coordinates
1747 uint position_mismatches = 0;
1748 float max_position_error = 0;
1749
1750 for (const auto& kv : ground_truth_positions) {
1751 auto key = kv.first;
1752 if (gapfilled_positions.find(key) != gapfilled_positions.end()) {
1753 vec3 pos_gt = kv.second;
1754 vec3 pos_gf = gapfilled_positions[key];
1755
1756 bool is_miss = ground_truth_is_miss[key];
1757
1758 float dist = sqrt(pow(pos_gt.x - pos_gf.x, 2) +
1759 pow(pos_gt.y - pos_gf.y, 2) +
1760 pow(pos_gt.z - pos_gf.z, 2));
1761
1762 if (dist > max_position_error) max_position_error = dist;
1763
1764 // For geometry hits, positions should match exactly (within 1cm)
1765 // For far-field misses, directions should match (within 1 degree)
1766 if (!is_miss && dist > 0.01f) {
1767 position_mismatches++;
1768 } else if (is_miss) {
1769 // Check direction for misses
1770 vec3 dir_gt = pos_gt - scan_origin;
1771 vec3 dir_gf = pos_gf - scan_origin;
1772 float mag_gt = sqrt(dir_gt.x*dir_gt.x + dir_gt.y*dir_gt.y + dir_gt.z*dir_gt.z);
1773 float mag_gf = sqrt(dir_gf.x*dir_gf.x + dir_gf.y*dir_gf.y + dir_gf.z*dir_gf.z);
1774
1775 float dot = (dir_gt.x*dir_gf.x + dir_gt.y*dir_gf.y + dir_gt.z*dir_gf.z) / (mag_gt * mag_gf);
1776 if (dot < 0.9998f) { // Directions differ by >1 degree
1777 position_mismatches++;
1778 }
1779 }
1780 }
1781 }
1782
1783 float position_match_rate = float(ground_truth_positions_found - position_mismatches) / float(ground_truth_positions_found);
1784 DOCTEST_CHECK_MESSAGE(position_match_rate > 0.95f,
1785 "Position accuracy too low: " << position_mismatches << " mismatches out of "
1786 << ground_truth_positions_found << " (" << (position_match_rate*100) << "% correct)");
1787
1788 // STRICT CHECK: For ideal scan, gapfilling should match ground truth exactly
1789 DOCTEST_CHECK_MESSAGE(hits_after_gapfill == hits_ground_truth,
1790 "Gapfilling should exactly match record_misses for ideal scan: "
1791 << hits_after_gapfill << " vs " << hits_ground_truth
1792 << " (difference: " << (int)hits_after_gapfill - (int)hits_ground_truth << ")");
1793}