1.3.77
 
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 triangulateHitPoints Cancel Flag") {
30 // A cancel flag set before triangulation must short-circuit the per-scan/gather/triad loops
31 // (and skip the Delaunay call) so the run discards any partial mesh and produces no triangles,
32 // while the same input with the flag clear produces the full, unchanged triangulation. This is
33 // the mechanism that lets a long triangulation be aborted from another thread instead of
34 // running to completion. The poll must not perturb the non-cancelled output.
35 auto run = [&](bool cancel) -> std::size_t {
36 LiDARcloud pointcloud;
37 pointcloud.disableMessages();
38 pointcloud.loadXML("plugins/lidar/xml/sphere.xml");
39 int flag = cancel ? 1 : 0;
40 pointcloud.setCancelFlag(&flag);
41 pointcloud.triangulateHitPoints(0.5, 5);
42 return pointcloud.getTriangleCount();
43 };
44
45 std::size_t baseline = run(false);
46 std::size_t cancelled = run(true);
47 DOCTEST_CHECK(baseline > 0);
48 DOCTEST_CHECK(cancelled == 0);
49
50 // The non-cancelled run with a flag registered must match the count from a run with no flag at
51 // all, i.e. the poll is purely an early-exit and does not change which triangles are produced.
52 LiDARcloud reference;
53 reference.disableMessages();
54 DOCTEST_CHECK_NOTHROW(reference.loadXML("plugins/lidar/xml/sphere.xml"));
55 DOCTEST_CHECK_NOTHROW(reference.triangulateHitPoints(0.5, 5));
56 DOCTEST_CHECK(baseline == reference.getTriangleCount());
57
58 // Clearing the flag (nullptr) before triangulation restores normal behavior.
59 LiDARcloud cleared;
60 cleared.disableMessages();
61 DOCTEST_CHECK_NOTHROW(cleared.loadXML("plugins/lidar/xml/sphere.xml"));
62 int flag = 1;
63 cleared.setCancelFlag(&flag);
64 cleared.setCancelFlag(nullptr); // cleared before the triangulation
65 DOCTEST_CHECK_NOTHROW(cleared.triangulateHitPoints(0.5, 5));
66 DOCTEST_CHECK(cleared.getTriangleCount() == baseline);
67}
68
69DOCTEST_TEST_CASE("LiDAR syntheticScan Progress Callback") {
70 LiDARcloud cloud;
71 cloud.disableMessages();
72
73 // Two small scans so progress advances 1/2 -> 2/2 (small grid keeps this fast)
74 std::vector<std::string> columnFormat;
75 for (int i = 0; i < 2; i++) {
76 ScanMetadata scan(make_vec3(-5.0f, 0.0f, 0.5f), 100, 0.0f, M_PI, 200, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
77 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
78 }
79 DOCTEST_CHECK_NOTHROW(cloud.addGrid(make_vec3(0.0f, 0.0f, 0.5f), make_vec3(1.0f, 1.0f, 1.0f), make_int3(1, 1, 1), 0));
80
82 DOCTEST_CHECK_NOTHROW(context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true));
83
84 std::vector<float> progress_values;
85 std::vector<std::string> progress_messages;
86 cloud.setProgressCallback([&](float progress, const std::string &message) {
87 progress_values.push_back(progress);
88 progress_messages.push_back(message);
89 });
90
91 DOCTEST_CHECK_NOTHROW(cloud.syntheticScan(&context));
92
93 // Callback fired, reached completion, monotonic non-decreasing, message carried through
94 DOCTEST_CHECK(!progress_values.empty());
95 DOCTEST_CHECK(progress_values.back() == doctest::Approx(1.0f));
96 bool monotonic = true;
97 for (size_t i = 1; i < progress_values.size(); i++) {
98 if (progress_values[i] < progress_values[i - 1]) {
99 monotonic = false;
100 }
101 }
102 DOCTEST_CHECK(monotonic);
103 DOCTEST_CHECK(progress_messages.back() == "Synthetic scan");
104
105 // Clearing the callback prevents further firing
106 size_t fired_count = progress_values.size();
107 cloud.setProgressCallback({});
108 DOCTEST_CHECK_NOTHROW(cloud.syntheticScan(&context));
109 DOCTEST_CHECK(progress_values.size() == fired_count);
110
111 // Regression: the callback must still fire and reach 1.0 even when no rays hit any geometry, which takes the
112 // early "no rays hit the bounding box" continue inside the per-scan loop. Use a Context with no primitives so
113 // every scan takes that path, and disabled console messages so completion is delivered solely via the callback.
114 LiDARcloud empty_cloud;
115 empty_cloud.disableMessages();
116 for (int i = 0; i < 3; i++) {
117 ScanMetadata scan(make_vec3(-5.0f, 0.0f, 0.5f), 20, 0.0f, M_PI, 20, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
118 DOCTEST_CHECK_NOTHROW(empty_cloud.addScan(scan));
119 }
120 Context empty_context;
121 std::vector<float> empty_progress;
122 empty_cloud.setProgressCallback([&](float progress, const std::string &) { empty_progress.push_back(progress); });
123 DOCTEST_CHECK_NOTHROW(empty_cloud.syntheticScan(&empty_context));
124 DOCTEST_CHECK(!empty_progress.empty());
125 DOCTEST_CHECK(empty_progress.back() == doctest::Approx(1.0f));
126}
127
128DOCTEST_TEST_CASE("LiDAR syntheticScan Cancel Flag") {
129 // A cancel flag set before the trace must short-circuit the ray loop so the
130 // scan records (essentially) no hits, while the same scan with the flag clear
131 // records many. This is the mechanism that lets a long scan be aborted
132 // mid-trace and its memory freed, instead of running to completion.
133 std::vector<std::string> columnFormat;
134
135 auto run = [&](bool cancel) -> std::size_t {
136 LiDARcloud cloud;
137 cloud.disableMessages();
138 ScanMetadata scan(make_vec3(-5.0f, 0.0f, 0.5f), 100, 0.0f, M_PI, 200, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
139 cloud.addScan(scan);
140 cloud.addGrid(make_vec3(0.0f, 0.0f, 0.5f), make_vec3(1.0f, 1.0f, 1.0f), make_int3(1, 1, 1), 0);
142 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
143 int flag = cancel ? 1 : 0;
144 cloud.setCancelFlag(&flag);
145 cloud.syntheticScan(&context);
146 return cloud.getHitCount();
147 };
148
149 std::size_t baseline = run(false);
150 std::size_t cancelled = run(true);
151 DOCTEST_CHECK(baseline > 0);
152 DOCTEST_CHECK(cancelled < baseline);
153 DOCTEST_CHECK(cancelled == 0);
154
155 // With record_misses=true, every fired pulse normally records a point (hit or miss), so an uncancelled scan fills
156 // the whole grid. A cancelled scan must abort the per-chunk/per-scan loops early instead of walking every chunk and
157 // recording a miss point for each beam, so it produces far fewer points than the full grid (ideally none).
158 auto run_record_misses = [&](bool cancel) -> std::size_t {
159 LiDARcloud cloud;
160 cloud.disableMessages();
161 ScanMetadata scan(make_vec3(-5.0f, 0.0f, 0.5f), 100, 0.0f, M_PI, 200, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
162 cloud.addScan(scan);
163 cloud.addGrid(make_vec3(0.0f, 0.0f, 0.5f), make_vec3(1.0f, 1.0f, 1.0f), make_int3(1, 1, 1), 0);
165 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
166 int flag = cancel ? 1 : 0;
167 cloud.setCancelFlag(&flag);
168 cloud.syntheticScan(&context, 1, 0, false /*scan_grid_only*/, true /*record_misses*/, true /*append*/);
169 return cloud.getHitCount();
170 };
171
172 std::size_t baseline_misses = run_record_misses(false);
173 std::size_t cancelled_misses = run_record_misses(true);
174 DOCTEST_CHECK(baseline_misses > 0);
175 DOCTEST_CHECK(cancelled_misses < baseline_misses);
176 DOCTEST_CHECK(cancelled_misses == 0);
177
178 // Clearing the flag (nullptr) restores normal behavior.
179 LiDARcloud cloud;
180 cloud.disableMessages();
181 ScanMetadata scan(make_vec3(-5.0f, 0.0f, 0.5f), 100, 0.0f, M_PI, 200, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
182 cloud.addScan(scan);
183 cloud.addGrid(make_vec3(0.0f, 0.0f, 0.5f), make_vec3(1.0f, 1.0f, 1.0f), make_int3(1, 1, 1), 0);
185 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
186 int flag = 1;
187 cloud.setCancelFlag(&flag);
188 cloud.setCancelFlag(nullptr); // cleared before the scan
189 DOCTEST_CHECK_NOTHROW(cloud.syntheticScan(&context));
190 DOCTEST_CHECK(cloud.getHitCount() > 0);
191}
192
193DOCTEST_TEST_CASE("LiDAR syntheticScan Progress Pointer") {
194 // The progress pointer is a caller-owned counter that syntheticScan writes the 0-based index of the scan it is
195 // currently ray-tracing into, advancing it at the start of each scan and setting it to getScanCount() on completion.
196 // A host polls it from another thread; here we observe the in-flight values synchronously via the per-scan progress
197 // callback (which fires right after the counter is written for the same scan index).
198 std::vector<std::string> columnFormat;
199
200 LiDARcloud cloud;
201 cloud.disableMessages();
202 for (int i = 0; i < 3; i++) {
203 ScanMetadata scan(make_vec3(-5.0f, 0.0f, 0.5f), 100, 0.0f, M_PI, 200, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
204 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
205 }
206 DOCTEST_CHECK_NOTHROW(cloud.addGrid(make_vec3(0.0f, 0.0f, 0.5f), make_vec3(1.0f, 1.0f, 1.0f), make_int3(1, 1, 1), 0));
207
209 DOCTEST_CHECK_NOTHROW(context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true));
210
211 volatile int progress = -1;
212 cloud.setSyntheticScanProgressPointer(&progress);
213
214 // Capture the counter at the start of each scan: the callback fires immediately after the counter is set to s.
215 std::vector<int> observed;
216 cloud.setProgressCallback([&](float, const std::string &) { observed.push_back(static_cast<int>(progress)); });
217
218 DOCTEST_CHECK_NOTHROW(cloud.syntheticScan(&context));
219
220 // Counter advanced through every scan index in order (the per-scan callback fires once per scan, plus a final
221 // completion callback after the loop that observes the getScanCount() completion write), then landed on
222 // getScanCount() to signal completion.
223 DOCTEST_CHECK(observed.size() >= cloud.getScanCount());
224 for (uint i = 0; i < cloud.getScanCount(); i++) {
225 DOCTEST_CHECK(observed[i] == static_cast<int>(i));
226 }
227 DOCTEST_CHECK(progress == static_cast<int>(cloud.getScanCount()));
228
229 cloud.setProgressCallback({});
230
231 // The counter must still advance for scans whose rays miss everything (the early "no rays hit the bounding box"
232 // continue): use a Context with no geometry so every scan takes that path.
233 LiDARcloud empty_cloud;
234 empty_cloud.disableMessages();
235 for (int i = 0; i < 3; i++) {
236 ScanMetadata scan(make_vec3(-5.0f, 0.0f, 0.5f), 20, 0.0f, M_PI, 20, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
237 DOCTEST_CHECK_NOTHROW(empty_cloud.addScan(scan));
238 }
239 Context empty_context;
240 volatile int empty_progress = -1;
241 std::vector<int> empty_observed;
242 empty_cloud.setSyntheticScanProgressPointer(&empty_progress);
243 empty_cloud.setProgressCallback([&](float, const std::string &) { empty_observed.push_back(static_cast<int>(empty_progress)); });
244 DOCTEST_CHECK_NOTHROW(empty_cloud.syntheticScan(&empty_context));
245 DOCTEST_CHECK(empty_observed.size() >= empty_cloud.getScanCount());
246 for (uint i = 0; i < empty_cloud.getScanCount(); i++) {
247 DOCTEST_CHECK(empty_observed[i] == static_cast<int>(i));
248 }
249 DOCTEST_CHECK(empty_progress == static_cast<int>(empty_cloud.getScanCount()));
250
251 // Clearing the pointer (nullptr) leaves a caller-owned counter untouched.
252 volatile int stale = 42;
253 empty_cloud.setSyntheticScanProgressPointer(&stale);
254 empty_cloud.setSyntheticScanProgressPointer(nullptr);
255 DOCTEST_CHECK_NOTHROW(empty_cloud.syntheticScan(&empty_context));
256 DOCTEST_CHECK(stale == 42);
257}
258
259DOCTEST_TEST_CASE("LiDAR Single Voxel Isotropic Patches Test") {
260 LiDARcloud synthetic_1;
261 synthetic_1.disableMessages();
262
263 // Add scan programmatically for explicit control
264 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
265 uint Ntheta = 6000;
266 uint Nphi = 12000;
267 float thetaMin = 0.0f; // Default when not specified in XML
268 float thetaMax = M_PI; // Default when not specified in XML
269 float phiMin = 0.0f; // Default when not specified in XML
270 float phiMax = 2.0f * M_PI; // Default when not specified in XML
271 float exitDiameter = 0.0f;
272 float beamDivergence = 0.0f;
273 std::vector<std::string> columnFormat;
274
275 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
276 DOCTEST_CHECK_NOTHROW(synthetic_1.addScan(scan));
277
278 // Add grid programmatically
279 vec3 grid_center(0.0f, 0.0f, 0.5f);
280 vec3 grid_size(1.0f, 1.0f, 1.0f);
281 int3 grid_divisions = make_int3(1, 1, 1);
282 DOCTEST_CHECK_NOTHROW(synthetic_1.addGrid(grid_center, grid_size, grid_divisions, 0));
283
284 vec3 gsize = synthetic_1.getCellSize(0);
285
286 Context context_2;
287 std::vector<uint> UUIDs_1 = context_2.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
288
289 float LAD_exact = 0.f;
290 for (uint UUID: UUIDs_1) {
291 LAD_exact += context_2.getPrimitiveArea(UUID) / (gsize.x * gsize.y * gsize.z);
292 }
293
294 // Calculate exact G(theta) from primitive geometry
295 float Gtheta_exact_numerator = 0.f;
296 float Gtheta_exact_denominator = 0.f;
297 for (uint UUID: UUIDs_1) {
298 float area = context_2.getPrimitiveArea(UUID);
299 vec3 normal = context_2.getPrimitiveNormal(UUID);
300 std::vector<vec3> vertices = context_2.getPrimitiveVertices(UUID);
301 vec3 raydir = vertices.front() - scan_origin;
302 raydir.normalize();
303
304 if (area == area) { // Check for NaN
305 float normal_dot_ray = fabs(normal * raydir);
306 Gtheta_exact_numerator += normal_dot_ray * area;
307 Gtheta_exact_denominator += area;
308 }
309 }
310 float Gtheta_exact = 0.f;
311 if (Gtheta_exact_denominator > 0) {
312 Gtheta_exact = Gtheta_exact_numerator / Gtheta_exact_denominator;
313 }
314
315 DOCTEST_CHECK_NOTHROW(synthetic_1.syntheticScan(&context_2, false, true)); // record_misses=true: LAD inversion needs transmitted beams
316 DOCTEST_CHECK_NOTHROW(synthetic_1.triangulateHitPoints(0.04, 10));
317 DOCTEST_CHECK_NOTHROW(synthetic_1.calculateLeafArea(&context_2));
318
319 float LAD = synthetic_1.getCellLeafAreaDensity(0);
320
321 DOCTEST_CHECK(LAD == LAD); // Check for NaN
322 DOCTEST_CHECK(fabs(LAD - LAD_exact) / LAD_exact == doctest::Approx(0.0f).epsilon(0.02f));
323
324 // Check G(theta) against exact value calculated from primitives
325 float Gtheta = synthetic_1.getCellGtheta(0);
326 DOCTEST_CHECK(Gtheta == Gtheta); // Check for NaN
327 DOCTEST_CHECK(fabs(Gtheta - Gtheta_exact) / Gtheta_exact == doctest::Approx(0.0f).epsilon(0.05f));
328}
329
330DOCTEST_TEST_CASE("LiDAR setExternalTriangulation Binning Test") {
331 // setExternalTriangulation() touches nothing that requires ray tracing: it ingests world-space
332 // triangles, bins each into a grid cell by centroid containment, drops degenerate triangles, and
333 // sets the triangulation-computed flag. Verify that contract directly on a tiny hand-built scene
334 // (no syntheticScan / XML), reading back the stored triangles via getTriangle().
335 LiDARcloud cloud;
336 cloud.disableMessages();
337
338 // A single static scan supplies a valid scanID and a scan origin for G(theta) provenance.
339 std::vector<std::string> columnFormat;
340 ScanMetadata scan(make_vec3(-5.0f, 0.0f, 0.5f), 10, 0.0f, M_PI, 10, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
341 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
342
343 // 1x1x1 cell centered at the origin -> covers [-0.5,0.5]^3.
344 DOCTEST_CHECK_NOTHROW(cloud.addGrid(make_vec3(0.0f, 0.0f, 0.0f), make_vec3(1.0f, 1.0f, 1.0f), make_int3(1, 1, 1), 0));
345
346 // Triangle A: centroid at the cell center -> binned into cell 0.
347 vec3 a0 = make_vec3(-0.1f, -0.1f, 0.0f);
348 vec3 a1 = make_vec3(0.2f, -0.1f, 0.0f);
349 vec3 a2 = make_vec3(-0.1f, 0.2f, 0.0f);
350 // Triangle B: centroid well outside the cell -> kept but assigned to no cell (gridcell == -1).
351 vec3 b0 = make_vec3(5.0f, 5.0f, 5.0f);
352 vec3 b1 = make_vec3(5.3f, 5.0f, 5.0f);
353 vec3 b2 = make_vec3(5.0f, 5.3f, 5.0f);
354
355 std::vector<vec3> triangle_vertices = {a0, a1, a2, b0, b1, b2};
356 std::vector<int> scanIDs = {0, 0};
357
358 DOCTEST_CHECK_NOTHROW(cloud.setExternalTriangulation(triangle_vertices, scanIDs));
359
360 // Both well-formed triangles are kept; counters reconcile (candidates == kept + degenerate-dropped).
361 DOCTEST_CHECK(cloud.getTriangleCount() == 2);
362 DOCTEST_CHECK(cloud.getTriangulationCandidateCount() == 2);
363 DOCTEST_CHECK(cloud.getTriangulationDroppedByDegenerate() == 0);
364
365 // The inside triangle is binned to cell 0; the outside one to no cell. Order is preserved.
366 Triangulation triA = cloud.getTriangle(0);
367 Triangulation triB = cloud.getTriangle(1);
368 DOCTEST_CHECK(triA.gridcell == 0);
369 DOCTEST_CHECK(triB.gridcell == -1);
370 DOCTEST_CHECK(triA.scanID == 0);
371 DOCTEST_CHECK(triA.vertex0 == a0); // vertices stored verbatim in world space
372 DOCTEST_CHECK(triA.vertex1 == a1);
373 DOCTEST_CHECK(triA.vertex2 == a2);
374}
375
376DOCTEST_TEST_CASE("LiDAR setExternalTriangulation Error Conditions Test") {
377 LiDARcloud cloud;
378 cloud.disableMessages();
379
380 std::vector<std::string> columnFormat;
381 ScanMetadata scan(make_vec3(-5.0f, 0.0f, 0.5f), 10, 0.0f, M_PI, 10, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
382 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
383
384 vec3 v0 = make_vec3(0.0f, 0.0f, 0.0f);
385 vec3 v1 = make_vec3(0.2f, 0.0f, 0.0f);
386 vec3 v2 = make_vec3(0.0f, 0.2f, 0.0f);
387
388 // No grid defined yet -> fail fast.
389 {
390 capture_cerr capture;
391 DOCTEST_CHECK_THROWS(cloud.setExternalTriangulation({v0, v1, v2}, {0}));
392 }
393
394 DOCTEST_CHECK_NOTHROW(cloud.addGrid(make_vec3(0.0f, 0.0f, 0.0f), make_vec3(1.0f, 1.0f, 1.0f), make_int3(1, 1, 1), 0));
395
396 // Vertex count not a multiple of 3 -> fail fast.
397 {
398 capture_cerr capture;
399 DOCTEST_CHECK_THROWS(cloud.setExternalTriangulation({v0, v1}, {0}));
400 }
401
402 // scanIDs size != triangle count -> fail fast.
403 {
404 capture_cerr capture;
405 DOCTEST_CHECK_THROWS(cloud.setExternalTriangulation({v0, v1, v2}, {0, 0}));
406 }
407
408 // scanID out of range -> fail fast (only scan 0 exists).
409 {
410 capture_cerr capture;
411 DOCTEST_CHECK_THROWS(cloud.setExternalTriangulation({v0, v1, v2}, {1}));
412 }
413 {
414 capture_cerr capture;
415 DOCTEST_CHECK_THROWS(cloud.setExternalTriangulation({v0, v1, v2}, {-1}));
416 }
417
418 // A valid single triangle succeeds and is kept.
419 DOCTEST_CHECK_NOTHROW(cloud.setExternalTriangulation({v0, v1, v2}, {0}));
420 DOCTEST_CHECK(cloud.getTriangleCount() == 1);
421}
422
423DOCTEST_TEST_CASE("LiDAR setExternalTriangulation Degenerate Dropping Test") {
424 LiDARcloud cloud;
425 cloud.disableMessages();
426
427 std::vector<std::string> columnFormat;
428 ScanMetadata scan(make_vec3(-5.0f, 0.0f, 0.5f), 10, 0.0f, M_PI, 10, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
429 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
430 DOCTEST_CHECK_NOTHROW(cloud.addGrid(make_vec3(0.0f, 0.0f, 0.0f), make_vec3(1.0f, 1.0f, 1.0f), make_int3(1, 1, 1), 0));
431
432 // One well-formed triangle and one degenerate (collinear -> zero/NaN area) triangle.
433 vec3 v0 = make_vec3(-0.1f, 0.0f, 0.0f);
434 vec3 v1 = make_vec3(0.1f, 0.0f, 0.0f);
435 vec3 v2 = make_vec3(0.0f, 0.2f, 0.0f);
436 vec3 d0 = make_vec3(0.0f, 0.0f, 0.0f);
437 vec3 d1 = make_vec3(0.1f, 0.0f, 0.0f);
438 vec3 d2 = make_vec3(0.2f, 0.0f, 0.0f); // collinear with d0, d1
439
440 std::vector<vec3> triangle_vertices = {v0, v1, v2, d0, d1, d2};
441 std::vector<int> scanIDs = {0, 0};
442
443 DOCTEST_CHECK_NOTHROW(cloud.setExternalTriangulation(triangle_vertices, scanIDs));
444
445 // The degenerate triangle is dropped; the diagnostic counters reconcile.
446 DOCTEST_CHECK(cloud.getTriangleCount() == 1);
447 DOCTEST_CHECK(cloud.getTriangulationCandidateCount() == 2);
448 DOCTEST_CHECK(cloud.getTriangulationDroppedByDegenerate() == 1);
449}
450
451DOCTEST_TEST_CASE("LiDAR Eight Voxel Isotropic Patches Test") {
452 LiDARcloud synthetic_2;
453 synthetic_2.disableMessages();
454
455 // Add scan programmatically
456 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
457 uint Ntheta = 10000;
458 uint Nphi = 12000;
459 float thetaMin = 0.0f;
460 float thetaMax = M_PI;
461 float phiMin = 0.0f;
462 float phiMax = 2.0f * M_PI;
463 float exitDiameter = 0.0f;
464 float beamDivergence = 0.0f;
465 std::vector<std::string> columnFormat;
466
467 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
468 DOCTEST_CHECK_NOTHROW(synthetic_2.addScan(scan));
469
470 // Add grid programmatically
471 vec3 grid_center(0.0f, 0.0f, 0.5f);
472 vec3 grid_size(1.0f, 1.0f, 1.0f);
473 int3 grid_divisions = make_int3(2, 2, 2);
474 DOCTEST_CHECK_NOTHROW(synthetic_2.addGrid(grid_center, grid_size, grid_divisions, 0));
475
476 vec3 gsize = synthetic_2.getCellSize(0);
477
478 Context context_2;
479 std::vector<uint> UUIDs_1 = context_2.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
480
481 std::vector<float> LAD_ex(8, 0);
482 for (uint UUID: UUIDs_1) {
483 int i, j, k;
484 i = j = k = 0;
485 vec3 v = context_2.getPrimitiveVertices(UUID).front();
486 if (v.x > 0.f) {
487 i = 1;
488 }
489 if (v.y > 0.f) {
490 j = 1;
491 }
492 if (v.z > 0.5f) {
493 k = 1;
494 }
495 int ID = k * 4 + j * 2 + i;
496
497 float area = context_2.getPrimitiveArea(UUID);
498 LAD_ex.at(ID) += area / (gsize.x * gsize.y * gsize.z);
499 }
500
501 DOCTEST_CHECK_NOTHROW(synthetic_2.syntheticScan(&context_2, false, true)); // record_misses=true: LAD inversion needs transmitted beams
502 DOCTEST_CHECK_NOTHROW(synthetic_2.triangulateHitPoints(0.04, 10));
503 DOCTEST_CHECK_NOTHROW(synthetic_2.calculateLeafArea(&context_2));
504
505 float RMSE = 0.f;
506 for (int i = 0; i < synthetic_2.getGridCellCount(); i++) {
507 float LAD = synthetic_2.getCellLeafAreaDensity(i);
508 RMSE += powf(LAD - LAD_ex.at(i), 2) / float(synthetic_2.getGridCellCount());
509 }
510 RMSE = sqrtf(RMSE);
511
512 // CDT and s_hull produce valid but different Delaunay tessellations; the
513 // resulting per-voxel LAD RMSE for this case is ~0.063 (s_hull) to ~0.066
514 // (CDT). Tolerance allows for this tessellation-dependent drift.
515 DOCTEST_CHECK(RMSE == doctest::Approx(0.0f).epsilon(0.07f));
516}
517
518DOCTEST_TEST_CASE("LiDAR Thin-Layer Vertical Symmetry Test") {
519 // Regression test for a single-return LAD asymmetry: a vertically-symmetric target
520 // scanned by vertically-symmetric scanners sitting exactly on the interface between
521 // two grid layers must recover symmetric LAD in the upper and lower layers,
522 // regardless of how thin the layers are. A previous synthetic-raster implementation
523 // produced a top/bottom asymmetry that collapsed the upper layer to a pinned floor
524 // value for thin layers. The grid z-extent is swept across the previously-failing
525 // regime to guard against any return of the threshold-like behavior.
526
527 Context context_sym;
528 std::vector<uint> UUIDs = context_sym.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true); // cube of leaves centered at z=0.5, symmetric about z=0.5
529
530 // Four scanners on the z=0.5 plane (the layer interface), symmetric about the target.
531 const float origins[4][3] = {{-5, 0, 0.5f}, {0, -5, 0.5f}, {5, 0, 0.5f}, {0, 5, 0.5f}};
532
533 for (float sizez: {0.5f, 0.45f, 0.41f, 0.40f, 0.35f}) {
534
535 LiDARcloud lidar_sym;
536 lidar_sym.disableMessages();
537
538 for (int sidx = 0; sidx < 4; sidx++) {
539 ScanMetadata scan(make_vec3(origins[sidx][0], origins[sidx][1], origins[sidx][2]), 2000, 0.f, M_PI, 4000, 0.f, 2.f * M_PI, 0.f, 0.f, 0.f, 0.f, std::vector<std::string>{});
540 lidar_sym.addScan(scan);
541 }
542
543 // Two layers split at z=0.5; sizez controls how thin each layer is.
544 lidar_sym.addGrid(make_vec3(0, 0, 0.5f), make_vec3(0.5f, 0.5f, sizez), make_int3(1, 1, 2), 0);
545
546 DOCTEST_CHECK_NOTHROW(lidar_sym.syntheticScan(&context_sym, true, true)); // scan_grid_only, record_misses
547 DOCTEST_CHECK_NOTHROW(lidar_sym.triangulateHitPoints(0.04, 10));
548 DOCTEST_CHECK_NOTHROW(lidar_sym.calculateLeafArea(&context_sym));
549
550 float lower = 0.f, upper = 0.f;
551 int nl = 0, nu = 0;
552 for (uint i = 0; i < lidar_sym.getGridCellCount(); i++) {
553 float lad = lidar_sym.getCellLeafAreaDensity(i);
554 // No cell may be pinned at the degenerate solver floor (a = 0.1 initial guess
555 // -> LAD = leaf_area/volume = 0.1) when the target genuinely has leaf area.
556 DOCTEST_CHECK_MESSAGE(fabs(lad - 0.1f) > 5e-3f, "Cell " << i << " pinned at the LAD floor (~0.1) at size.z=" << sizez);
557 if (lidar_sym.getCellCenter(i).z < 0.5f) {
558 lower += lad;
559 nl++;
560 } else {
561 upper += lad;
562 nu++;
563 }
564 }
565 if (nl > 0)
566 lower /= float(nl);
567 if (nu > 0)
568 upper /= float(nu);
569
570 // Symmetric input MUST give symmetric LAD: upper and lower within 25%.
571 float denom = std::max(lower, upper);
572 bool symmetric = (denom > 0.f) && (fabs(upper - lower) / denom < 0.25f);
573 DOCTEST_CHECK_MESSAGE(symmetric, "Thin-layer LAD asymmetry at size.z=" << sizez << ": lower=" << lower << " upper=" << upper);
574 }
575}
576
577DOCTEST_TEST_CASE("LiDAR Single Voxel Anisotropic Patches Test") {
578 LiDARcloud synthetic_3;
579 synthetic_3.disableMessages();
580
581 // Add scan programmatically - use higher resolution for anisotropic to reduce bias
582 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
583 uint Ntheta = 10000;
584 uint Nphi = 16000;
585 float thetaMin = 0.0f;
586 float thetaMax = M_PI;
587 float phiMin = 0.0f;
588 float phiMax = 2.0f * M_PI;
589 float exitDiameter = 0.0f;
590 float beamDivergence = 0.0f;
591 std::vector<std::string> columnFormat;
592
593 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
594 DOCTEST_CHECK_NOTHROW(synthetic_3.addScan(scan));
595
596 // Add grid programmatically
597 vec3 grid_center(0.0f, 0.0f, 0.5f);
598 vec3 grid_size(1.0f, 1.0f, 1.0f);
599 int3 grid_divisions = make_int3(1, 1, 1);
600 DOCTEST_CHECK_NOTHROW(synthetic_3.addGrid(grid_center, grid_size, grid_divisions, 0));
601
602 vec3 gsize = synthetic_3.getCellSize(0);
603
604 Context context_2;
605 std::vector<uint> UUIDs_1 = context_2.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_erectophile.xml", true);
606
607 float LAD_exact = 0.f;
608 for (uint UUID: UUIDs_1) {
609 LAD_exact += context_2.getPrimitiveArea(UUID) / (gsize.x * gsize.y * gsize.z);
610 }
611
612 // Calculate exact G(theta) from primitive geometry
613 float Gtheta_exact_numerator = 0.f;
614 float Gtheta_exact_denominator = 0.f;
615 for (uint UUID: UUIDs_1) {
616 float area = context_2.getPrimitiveArea(UUID);
617 vec3 normal = context_2.getPrimitiveNormal(UUID);
618 std::vector<vec3> vertices = context_2.getPrimitiveVertices(UUID);
619 vec3 raydir = vertices.front() - scan_origin;
620 raydir.normalize();
621
622 if (area == area) { // Check for NaN
623 float normal_dot_ray = fabs(normal * raydir);
624 Gtheta_exact_numerator += normal_dot_ray * area;
625 Gtheta_exact_denominator += area;
626 }
627 }
628 float Gtheta_exact = 0.f;
629 if (Gtheta_exact_denominator > 0) {
630 Gtheta_exact = Gtheta_exact_numerator / Gtheta_exact_denominator;
631 }
632
633 DOCTEST_CHECK_NOTHROW(synthetic_3.syntheticScan(&context_2, false, true)); // record_misses=true: LAD inversion needs transmitted beams
634 DOCTEST_CHECK_NOTHROW(synthetic_3.triangulateHitPoints(0.04, 10));
635 DOCTEST_CHECK_NOTHROW(synthetic_3.calculateLeafArea(&context_2));
636
637 float LAD = synthetic_3.getCellLeafAreaDensity(0);
638
639 DOCTEST_CHECK(LAD == LAD); // Check for NaN
640 DOCTEST_CHECK(fabs(LAD - LAD_exact) / LAD_exact == doctest::Approx(0.0f).epsilon(0.03f));
641
642 // Check G(theta) against exact value calculated from primitives
643 float Gtheta = synthetic_3.getCellGtheta(0);
644 DOCTEST_CHECK(Gtheta == Gtheta); // Check for NaN
645 DOCTEST_CHECK(fabs(Gtheta - Gtheta_exact) / Gtheta_exact == doctest::Approx(0.0f).epsilon(0.05f));
646}
647
648DOCTEST_TEST_CASE("LiDAR Synthetic Almond Tree Test") {
649 Context context_4;
650 DOCTEST_CHECK_NOTHROW(context_4.loadOBJ("plugins/lidar/xml/AlmondWP.obj", make_vec3(0, 0, 0), 6., make_SphericalCoord(0, 0), RGB::red, true));
651
652 LiDARcloud synthetic_4;
653 synthetic_4.disableMessages();
654
655 DOCTEST_CHECK_NOTHROW(synthetic_4.loadXML("plugins/lidar/xml/almond.xml"));
656 DOCTEST_CHECK_NOTHROW(synthetic_4.syntheticScan(&context_4, false, true)); // record_misses=true: LAD inversion needs transmitted beams
657 DOCTEST_CHECK_NOTHROW(synthetic_4.calculateSyntheticLeafArea(&context_4));
658 DOCTEST_CHECK_NOTHROW(synthetic_4.calculateSyntheticGtheta(&context_4));
659 DOCTEST_CHECK_NOTHROW(synthetic_4.triangulateHitPoints(0.05, 5));
660 DOCTEST_CHECK_NOTHROW(synthetic_4.calculateLeafArea(&context_4));
661
662 // Calculate exact leaf area
663 uint Ncells = synthetic_4.getGridCellCount();
664
665 std::vector<float> total_area;
666 total_area.resize(Ncells);
667
668 std::vector<float> Gtheta;
669 Gtheta.resize(Ncells);
670
671 std::vector<float> area_sum;
672 area_sum.resize(Ncells, 0.f);
673 std::vector<float> sin_sum;
674 sin_sum.resize(Ncells, 0.f);
675 std::vector<uint> cell_tri_count;
676 cell_tri_count.resize(Ncells, 0);
677
678 std::vector<uint> UUIDs = context_4.getAllUUIDs();
679 for (int p = 0; p < UUIDs.size(); p++) {
680
681 uint UUID = UUIDs.at(p);
682
683 if (context_4.doesPrimitiveDataExist(UUID, "gridCell")) {
684
685 uint gridCell;
686 context_4.getPrimitiveData(UUID, "gridCell", gridCell);
687
688 if (gridCell >= 0 && gridCell < Ncells) {
689 total_area.at(gridCell) += context_4.getPrimitiveArea(UUID);
690 }
691
692 for (int s = 0; s < synthetic_4.getScanCount(); s++) {
693 vec3 origin = synthetic_4.getScanOrigin(s);
694 std::vector<vec3> vertices = context_4.getPrimitiveVertices(p);
695 float area = context_4.getPrimitiveArea(p);
696 vec3 normal = context_4.getPrimitiveNormal(p);
697 vec3 raydir = vertices.front() - origin;
698 raydir.normalize();
699 float theta = fabs(acos_safe(raydir.z));
700
701 if (area == area) { // in rare cases you can get area=NaN
702
703 Gtheta.at(gridCell) += fabs(normal * raydir) * area * fabs(sin(theta));
704
705 area_sum.at(gridCell) += area;
706 sin_sum.at(gridCell) += fabs(sin(theta));
707 cell_tri_count.at(gridCell) += 1;
708 }
709 }
710 }
711 }
712
713 for (uint v = 0; v < Ncells; v++) {
714 if (cell_tri_count[v] > 0) {
715 Gtheta[v] *= float(cell_tri_count[v]) / (area_sum[v] * sin_sum[v]);
716 }
717 }
718
719 float RMSE_LAD = 0.f;
720 float bias_LAD = 0.f;
721 float RMSE_Gtheta = 0.f;
722 for (uint i = 0; i < Ncells; i++) {
723 float LAD = synthetic_4.getCellLeafArea(i);
724 if (LAD == LAD && total_area.at(i) > 0 && total_area.at(i) == total_area.at(i)) {
725 RMSE_LAD += pow(LAD - total_area.at(i), 2) / float(Ncells);
726 bias_LAD += (LAD - total_area.at(i)) / float(Ncells);
727 }
728 float Gtheta_bar = synthetic_4.getCellGtheta(i);
729 if (Gtheta_bar == Gtheta_bar && Gtheta.at(i) > 0 && Gtheta.at(i) == Gtheta.at(i)) {
730 RMSE_Gtheta += pow(Gtheta_bar - Gtheta.at(i), 2) / float(Ncells);
731 }
732 }
733 RMSE_LAD = sqrt(RMSE_LAD);
734 RMSE_Gtheta = sqrt(RMSE_Gtheta);
735
736 DOCTEST_CHECK(RMSE_LAD <= 0.35f);
737 DOCTEST_CHECK(bias_LAD <= 0.0f);
738 DOCTEST_CHECK(RMSE_Gtheta <= 0.15f);
739 DOCTEST_CHECK(RMSE_LAD != 0.f);
740}
741
742DOCTEST_TEST_CASE("LiDAR Synthetic Scan Append/Overwrite Test") {
743 Context context_test;
744 context_test.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
745
746 LiDARcloud synthetic_test;
747 synthetic_test.disableMessages();
748
749 DOCTEST_CHECK_NOTHROW(synthetic_test.loadXML("plugins/lidar/xml/synthetic_test.xml"));
750
751 // First scan with default append behavior (should append to empty scan)
752 DOCTEST_CHECK_NOTHROW(synthetic_test.syntheticScan(&context_test));
753 uint hit_count_first = synthetic_test.getHitCount();
754 DOCTEST_CHECK(hit_count_first > 0);
755
756 // Second scan with append=true (should double the hit count)
757 DOCTEST_CHECK_NOTHROW(synthetic_test.syntheticScan(&context_test, true));
758 uint hit_count_append = synthetic_test.getHitCount();
759 DOCTEST_CHECK(hit_count_append == 2 * hit_count_first);
760
761 // Third scan with append=false (should reset and have same count as first scan)
762 DOCTEST_CHECK_NOTHROW(synthetic_test.syntheticScan(&context_test, false));
763 uint hit_count_overwrite = synthetic_test.getHitCount();
764 DOCTEST_CHECK(hit_count_overwrite == hit_count_first);
765
766 // Test with other overloads
767 // Test scan_grid_only, record_misses overload with append=false
768 DOCTEST_CHECK_NOTHROW(synthetic_test.syntheticScan(&context_test, false, false, false));
769 uint hit_count_overwrite2 = synthetic_test.getHitCount();
770 DOCTEST_CHECK(hit_count_overwrite2 == hit_count_first);
771
772 // Test multi-return overload with append=true
773 DOCTEST_CHECK_NOTHROW(synthetic_test.syntheticScan(&context_test, 1, 0.0f, true));
774 uint hit_count_append2 = synthetic_test.getHitCount();
775 DOCTEST_CHECK(hit_count_append2 == 2 * hit_count_first);
776}
777
778DOCTEST_TEST_CASE("LiDAR Spinning Multibeam Scan Geometry") {
779 // Build a spinning multibeam scan with VLP-16-style channels (16 channels, 2-degree spacing from -15 to +15 deg elevation).
780 vec3 scan_origin(0.f, 0.f, 1.f);
781 std::vector<float> elevation_deg = {-15.f, -13.f, -11.f, -9.f, -7.f, -5.f, -3.f, -1.f, 1.f, 3.f, 5.f, 7.f, 9.f, 11.f, 13.f, 15.f};
782 std::vector<float> beam_zenith(elevation_deg.size());
783 for (size_t k = 0; k < elevation_deg.size(); k++) {
784 beam_zenith[k] = 0.5f * float(M_PI) - elevation_deg[k] * float(M_PI) / 180.f;
785 }
786 uint Nphi = 360;
787 std::vector<std::string> columnFormat;
788
789 ScanMetadata scan(scan_origin, beam_zenith, Nphi, 0.f, 2.f * float(M_PI), 0.f, 0.f, 0.f, 0.f, columnFormat);
790
791 LiDARcloud cloud;
792 cloud.disableMessages();
793 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
794
795 DOCTEST_CHECK(cloud.getScanPattern(0) == SCAN_PATTERN_SPINNING_MULTIBEAM);
796 DOCTEST_CHECK(cloud.getScanSizeTheta(0) == uint(elevation_deg.size())); // Ntheta = number of channels
797 DOCTEST_CHECK(cloud.getScanSizePhi(0) == Nphi);
798
799 std::vector<float> returned_angles = cloud.getScanBeamZenithAngles(0);
800 DOCTEST_REQUIRE(returned_angles.size() == beam_zenith.size());
801 bool angles_match = true;
802 for (size_t k = 0; k < beam_zenith.size(); k++) {
803 if (fabs(returned_angles[k] - beam_zenith[k]) > 1e-5f) {
804 angles_match = false;
805 }
806 }
807 DOCTEST_CHECK(angles_match);
808
809 // thetaMin/thetaMax bracket the channel zenith angles.
810 vec2 theta_range = cloud.getScanRangeTheta(0);
811 float zmin = *std::min_element(beam_zenith.begin(), beam_zenith.end());
812 float zmax = *std::max_element(beam_zenith.begin(), beam_zenith.end());
813 DOCTEST_CHECK(theta_range.x == doctest::Approx(zmin));
814 DOCTEST_CHECK(theta_range.y == doctest::Approx(zmax));
815
816 // Each row's beam direction zenith equals its channel zenith, and direction2rc maps back to the same row (nearest channel).
817 bool rc_roundtrip_ok = true;
818 for (uint row = 0; row < beam_zenith.size(); row++) {
819 SphericalCoord dir = scan.rc2direction(row, 0);
820 if (fabs(dir.zenith - beam_zenith[row]) > 1e-4f) {
821 rc_roundtrip_ok = false;
822 }
823 int2 rc = scan.direction2rc(dir);
824 if (rc.x != int(row)) {
825 rc_roundtrip_ok = false;
826 }
827 }
828 DOCTEST_CHECK(rc_roundtrip_ok);
829}
830
831DOCTEST_TEST_CASE("LiDAR Spinning Multibeam Empty Channels Error") {
832 // A spinning multibeam scan with no channels is an error (fail-fast, no silent fallback).
833 vec3 origin(0.f, 0.f, 1.f);
834 std::vector<float> empty_angles;
835 std::vector<std::string> columnFormat;
836 DOCTEST_CHECK_THROWS(ScanMetadata(origin, empty_angles, 100, 0.f, 2.f * float(M_PI), 0.f, 0.f, 0.f, 0.f, columnFormat));
837}
838
839DOCTEST_TEST_CASE("LiDAR Spinning Multibeam Synthetic Scan") {
841 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
842
843 LiDARcloud cloud;
844 cloud.disableMessages();
845
846 // Channels span the 1 m cube (at the near face, ~4.5 m range, the cube subtends about +/-6.3 deg). Use 0.5-degree channel
847 // spacing so the vertical point spacing on the cube (~4 cm) is dense enough to triangulate alongside the azimuth sweep.
848 vec3 scan_origin(-5.f, 0.f, 0.5f);
849 std::vector<float> beam_elev; // elevation above horizon (radians)
850 std::vector<float> beam_zenith;
851 for (int e = -24; e <= 24; e++) { // 49 channels, 0.5-degree elevation spacing (e in half-degrees)
852 beam_elev.push_back(0.5f * float(e) * float(M_PI) / 180.f);
853 beam_zenith.push_back(0.5f * float(M_PI) - 0.5f * float(e) * float(M_PI) / 180.f);
854 }
855 // Stationary spin for one revolution. azimuthStep = 0.09 deg -> 4000 steps/rev. A stationary spin in place is a
856 // trajectory of two coincident poses; the time gap is one rotation period (channels * steps_per_rev / PRF), so the
857 // sensor spins exactly once.
858 const uint channels = uint(beam_elev.size());
859 const float azimuthStep_rad = (360.f / 4000.f) * float(M_PI) / 180.f;
860 const uint Nphi = 4000;
861 const float PRF = 1.0e6f;
862 const double one_rev_duration = double(channels) * 4000.0 / double(PRF);
863 const std::vector<double> traj_t = {0.0, one_rev_duration};
864 const std::vector<vec3> traj_pos = {scan_origin, scan_origin};
865 const std::vector<vec4> traj_quat = {make_vec4(0, 0, 0, 1), make_vec4(0, 0, 0, 1)};
866 DOCTEST_CHECK_NOTHROW(cloud.addScanSpinning(beam_elev, azimuthStep_rad, PRF, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>()));
867
868 DOCTEST_CHECK_NOTHROW(cloud.addGrid(make_vec3(0.f, 0.f, 0.5f), make_vec3(1.f, 1.f, 1.f), make_int3(1, 1, 1), 0));
869
870 DOCTEST_CHECK_NOTHROW(cloud.syntheticScan(&context, false, true)); // record_misses=true so LAD inversion has transmitted beams
871
872 uint Nhits = cloud.getHitCount();
873 DOCTEST_CHECK(Nhits > 0);
874 DOCTEST_CHECK(cloud.hasMisses());
875
876 // Every hit from a spinning multibeam scan carries a channel index in [0, Ntheta), and at least one real return exists.
877 uint Ntheta = uint(beam_zenith.size());
878 bool channel_exists_all = true;
879 bool channel_in_range = true;
880 bool any_real_hit = false;
881 for (uint h = 0; h < Nhits; h++) {
882 if (!cloud.doesHitDataExist(h, "channel")) {
883 channel_exists_all = false;
884 continue;
885 }
886 int ch = int(cloud.getHitData(h, "channel"));
887 if (ch < 0 || ch >= int(Ntheta)) {
888 channel_in_range = false;
889 }
890 if (cloud.getHitData(h, "is_miss") == 0.0) {
891 any_real_hit = true;
892 }
893 }
894 DOCTEST_CHECK(channel_exists_all);
895 DOCTEST_CHECK(channel_in_range);
896 DOCTEST_CHECK(any_real_hit);
897
898 // A spinning scan is trajectory-driven, so it has no fixed theta-phi grid to triangulate; leaf-area inversion uses
899 // the moving-aware calculateLeafArea overload that takes a supplied G(theta) (0.5 for the spherical leaf distribution
900 // of this scene) instead of reconstructing leaf angles from a triangulation.
901 DOCTEST_CHECK_NOTHROW(cloud.calculateLeafArea(&context, 0.5f, 1, 0.05f));
902 float LAD = cloud.getCellLeafAreaDensity(0);
903 DOCTEST_CHECK(LAD == LAD); // not NaN
904 DOCTEST_CHECK(LAD > 0.f);
905
906 // exportScans must persist the spinning multibeam geometry (pattern + channel angles) so it round-trips on reload.
907 const std::string out_dir = "lidar_spinmb_export_tmp";
908 std::filesystem::remove_all(out_dir);
909 const std::string xml_out = out_dir + "/scans.xml";
910 DOCTEST_CHECK_NOTHROW(cloud.exportScans(xml_out.c_str()));
911
912 LiDARcloud reloaded;
913 reloaded.disableMessages();
914 DOCTEST_CHECK_NOTHROW(reloaded.loadXML(xml_out.c_str()));
915 DOCTEST_REQUIRE(reloaded.getScanCount() == 1);
916 DOCTEST_CHECK(reloaded.getScanPattern(0) == SCAN_PATTERN_SPINNING_MULTIBEAM);
917 DOCTEST_CHECK(reloaded.getScanMode(0) == SCAN_MODE_SPINNING);
918 DOCTEST_CHECK(reloaded.getScanSizeTheta(0) == Ntheta);
919 DOCTEST_CHECK(reloaded.getScanSizePhi(0) == Nphi);
920 std::vector<float> reloaded_angles = reloaded.getScanBeamZenithAngles(0);
921 DOCTEST_REQUIRE(reloaded_angles.size() == beam_zenith.size());
922 bool reloaded_angles_match = true;
923 for (size_t k = 0; k < beam_zenith.size(); k++) {
924 if (fabs(reloaded_angles[k] - beam_zenith[k]) > 1e-3f) {
925 reloaded_angles_match = false;
926 }
927 }
928 DOCTEST_CHECK(reloaded_angles_match);
929 std::filesystem::remove_all(out_dir);
930}
931
932// ----- Risley-prism (Livox-style rosette) scan tests -----//
933// A stationary Livox-Mid-40-like prism pair: two counter-rotating ~18.7 deg wedges at n=1.51, PRF 100 kHz. A stationary
934// capture is a trajectory of two coincident poses separated in time by the acquisition duration. A short 0.05 s acquisition
935// gives 5000 pulses, enough to characterize the rosette without large allocations.
936static std::vector<RisleyPrism> makeMid40Prisms() {
937 return {RisleyPrism(18.7481 * M_PI / 180.0, 1.51, -121.5657 * 2.0 * M_PI), RisleyPrism(17.9634 * M_PI / 180.0, 1.51, 77.7430 * 2.0 * M_PI)};
938}
939
940DOCTEST_TEST_CASE("LiDAR Risley Prism Pattern Geometry") {
941 // The rosette directions must lie inside a circular field of view about the optical axis (a wrong coordinate convention
942 // would scatter them over a hemisphere), and a counter-rotating incommensurate pair must be non-repetitive.
943 LiDARcloud cloud;
944 cloud.disableMessages();
945
946 const vec3 scan_origin(0.f, 0.f, 1.f);
947 const float PRF = 100000.f;
948 const double duration = 0.05; // 5000 pulses
949 const std::vector<double> traj_t = {0.0, duration};
950 const std::vector<vec3> traj_pos = {scan_origin, scan_origin};
951 const std::vector<vec4> traj_quat = {make_vec4(0, 0, 0, 1), make_vec4(0, 0, 0, 1)};
952
953 uint scanID = 0;
954 DOCTEST_CHECK_NOTHROW(scanID = cloud.addScanRisley(makeMid40Prisms(), 1.0, PRF, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>()));
955
956 DOCTEST_CHECK(cloud.getScanPattern(scanID) == SCAN_PATTERN_RISLEY_PRISM);
957 DOCTEST_CHECK(cloud.getScanMode(scanID) == SCAN_MODE_RISLEY_PRISM);
958 DOCTEST_CHECK(cloud.getScanSizeTheta(scanID) == 1u); // single-row storage
959 DOCTEST_CHECK(cloud.getScanSizePhi(scanID) == 5000u); // one column per pulse
960
961 // The prism stack round-trips through the getters.
962 std::vector<RisleyPrism> prisms = cloud.getScanRisleyPrisms(scanID);
963 DOCTEST_REQUIRE(prisms.size() == 2);
964 DOCTEST_CHECK(cloud.getScanRisleyRefractiveIndexAir(scanID) == doctest::Approx(1.0));
965
966 // Walk the per-pulse directions: bound the FoV half-angle and look for exact duplicate directions. Build a local
967 // ScanMetadata carrying the same prisms and pulse period (one pulse per column) and query its body-frame directions.
968 ScanMetadata sm(scan_origin, 1u, 0.f, float(M_PI), 5000u, 0.f, 2.f * float(M_PI), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>());
969 sm.scanPattern = SCAN_PATTERN_RISLEY_PRISM;
970 sm.risley_prisms = prisms;
971 sm.risley_refractive_index_air = 1.0;
972 sm.pulse_period = 1.0 / double(PRF);
973 float max_halfangle = 0.f;
974 std::vector<vec3> dirs;
975 dirs.reserve(5000);
976 for (uint k = 0; k < 5000; k++) {
977 SphericalCoord sph = sm.rc2direction(0, k); // body-frame direction of pulse k
978 vec3 d = sphere2cart(sph);
979 dirs.push_back(d);
980 // Optical axis is +y; the half-angle is the angle of the beam from +y.
981 float halfangle = acosf(std::max(-1.f, std::min(1.f, d.y)));
982 if (halfangle > max_halfangle) {
983 max_halfangle = halfangle;
984 }
985 }
986 // A Mid-40-class wedge pair fills roughly a 38-43 deg circular FoV: every beam is within ~25 deg of the optical axis, and
987 // the pattern genuinely spreads (not all clustered on-axis).
988 DOCTEST_CHECK(max_halfangle < 25.f * float(M_PI) / 180.f);
989 DOCTEST_CHECK(max_halfangle > 10.f * float(M_PI) / 180.f);
990
991 // Non-repetition: no two pulses share an (almost) identical direction. Compare a bounded prefix to keep this O(n^2) check
992 // cheap; a repetitive (commensurate) pattern would produce many coincident directions.
993 int near_duplicates = 0;
994 const size_t Ncheck = 1500;
995 for (size_t a = 0; a < Ncheck; a++) {
996 for (size_t b = a + 1; b < Ncheck; b++) {
997 if ((dirs[a] - dirs[b]).magnitude() < 1e-6f) {
998 near_duplicates++;
999 }
1000 }
1001 }
1002 DOCTEST_CHECK(near_duplicates == 0);
1003}
1004
1005DOCTEST_TEST_CASE("LiDAR Risley Prism Synthetic Scan") {
1007 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
1008
1009 LiDARcloud cloud;
1010 cloud.disableMessages();
1011
1012 // Stationary rosette looking up the +y axis at the leaf cube (the optical axis is +y). Place the scanner below the cube.
1013 const vec3 scan_origin(0.f, -5.f, 0.5f);
1014 const float PRF = 100000.f;
1015 const double duration = 0.1; // 10000 pulses
1016 const std::vector<double> traj_t = {0.0, duration};
1017 const std::vector<vec3> traj_pos = {scan_origin, scan_origin};
1018 const std::vector<vec4> traj_quat = {make_vec4(0, 0, 0, 1), make_vec4(0, 0, 0, 1)};
1019
1020 uint scanID = 0;
1021 DOCTEST_CHECK_NOTHROW(scanID = cloud.addScanRisley(makeMid40Prisms(), 1.0, PRF, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>()));
1022
1023 DOCTEST_CHECK_NOTHROW(cloud.syntheticScan(&context, false, true)); // record_misses=true
1024
1025 uint Nhits = cloud.getHitCount();
1026 DOCTEST_CHECK(Nhits > 0);
1027
1028 // A Risley scan is moving (trajectory-driven): every hit carries a timestamp and pulse_id, and never a spinning 'channel'.
1029 bool timestamp_all = true;
1030 bool pulse_id_all = true;
1031 bool no_channel = true;
1032 bool any_real_hit = false;
1033 for (uint h = 0; h < Nhits; h++) {
1034 if (!cloud.doesHitDataExist(h, "timestamp")) {
1035 timestamp_all = false;
1036 }
1037 if (!cloud.doesHitDataExist(h, "pulse_id")) {
1038 pulse_id_all = false;
1039 }
1040 if (cloud.doesHitDataExist(h, "channel")) {
1041 no_channel = false;
1042 }
1043 if (cloud.getHitData(h, "is_miss") == 0.0) {
1044 any_real_hit = true;
1045 }
1046 }
1047 DOCTEST_CHECK(timestamp_all);
1048 DOCTEST_CHECK(pulse_id_all);
1049 DOCTEST_CHECK(no_channel);
1050 DOCTEST_CHECK(any_real_hit);
1051
1052 // Triangulation and row/column gap-filling are not supported for trajectory-driven scans (Risley is always moving).
1053 DOCTEST_CHECK_THROWS(cloud.triangulateHitPoints(0.5f, 5.f));
1054}
1055
1056DOCTEST_TEST_CASE("LiDAR Risley Prism Fail-Fast Errors") {
1057 LiDARcloud cloud;
1058 cloud.disableMessages();
1059
1060 const std::vector<double> traj_t = {0.0, 0.05};
1061 const std::vector<vec3> traj_pos = {make_vec3(0, 0, 1), make_vec3(0, 0, 1)};
1062 const std::vector<vec4> traj_quat = {make_vec4(0, 0, 0, 1), make_vec4(0, 0, 0, 1)};
1063 const std::vector<std::string> cf;
1064
1065 // Empty prism stack.
1066 DOCTEST_CHECK_THROWS(cloud.addScanRisley(std::vector<RisleyPrism>(), 1.0, 100000.f, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, cf));
1067 // Non-positive PRF.
1068 DOCTEST_CHECK_THROWS(cloud.addScanRisley(makeMid40Prisms(), 1.0, 0.f, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, cf));
1069 // Empty trajectory.
1070 DOCTEST_CHECK_THROWS(cloud.addScanRisley(makeMid40Prisms(), 1.0, 100000.f, std::vector<double>(), std::vector<vec3>(), std::vector<vec4>(), make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, cf));
1071 // Zero-duration trajectory (coincident times).
1072 const std::vector<double> traj_t_zero = {1.0, 1.0};
1073 DOCTEST_CHECK_THROWS(cloud.addScanRisley(makeMid40Prisms(), 1.0, 100000.f, traj_t_zero, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, cf));
1074}
1075
1076DOCTEST_TEST_CASE("LiDAR Risley Prism XML Round-Trip") {
1077 // exportScans must persist the Risley geometry (pattern + prism stack) so it round-trips on reload.
1079 // A small patch in front of the scanner (along +y, the optical axis) gives the scan some real returns to export.
1080 context.addPatch(make_vec3(0.f, 5.f, 1.f), make_vec2(4.f, 4.f), make_SphericalCoord(0.5f * float(M_PI), float(M_PI)));
1081
1082 LiDARcloud cloud;
1083 cloud.disableMessages();
1084
1085 const vec3 scan_origin(0.f, 0.f, 1.f);
1086 const float PRF = 100000.f;
1087 const std::vector<double> traj_t = {0.0, 0.05};
1088 const std::vector<vec3> traj_pos = {scan_origin, scan_origin};
1089 const std::vector<vec4> traj_quat = {make_vec4(0, 0, 0, 1), make_vec4(0, 0, 0, 1)};
1090 const std::vector<RisleyPrism> prisms = makeMid40Prisms();
1091
1092 uint scanID = 0;
1093 DOCTEST_CHECK_NOTHROW(scanID = cloud.addScanRisley(prisms, 1.0, PRF, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>{"x", "y", "z", "origin_x", "origin_y", "origin_z"}));
1094
1095 // Populate hits so exportScans writes a complete scan with per-pulse origin columns.
1096 DOCTEST_CHECK_NOTHROW(cloud.syntheticScan(&context, false, true));
1097
1098 const std::string out_dir = "lidar_risley_export_tmp";
1099 std::filesystem::remove_all(out_dir);
1100 const std::string xml_out = out_dir + "/scans.xml";
1101 DOCTEST_CHECK_NOTHROW(cloud.exportScans(xml_out.c_str()));
1102
1103 LiDARcloud reloaded;
1104 reloaded.disableMessages();
1105 DOCTEST_CHECK_NOTHROW(reloaded.loadXML(xml_out.c_str()));
1106 DOCTEST_REQUIRE(reloaded.getScanCount() == 1);
1107 DOCTEST_CHECK(reloaded.getScanPattern(0) == SCAN_PATTERN_RISLEY_PRISM);
1108 DOCTEST_CHECK(reloaded.getScanMode(0) == SCAN_MODE_RISLEY_PRISM);
1109
1110 std::vector<RisleyPrism> reloaded_prisms = reloaded.getScanRisleyPrisms(0);
1111 DOCTEST_REQUIRE(reloaded_prisms.size() == prisms.size());
1112 for (size_t k = 0; k < prisms.size(); k++) {
1113 DOCTEST_CHECK(reloaded_prisms[k].wedge_angle == doctest::Approx(prisms[k].wedge_angle).epsilon(1e-4));
1114 DOCTEST_CHECK(reloaded_prisms[k].refractive_index == doctest::Approx(prisms[k].refractive_index).epsilon(1e-4));
1115 DOCTEST_CHECK(reloaded_prisms[k].rotor_rate == doctest::Approx(prisms[k].rotor_rate).epsilon(1e-4));
1116 DOCTEST_CHECK(reloaded_prisms[k].phase == doctest::Approx(prisms[k].phase));
1117 }
1118 std::filesystem::remove_all(out_dir);
1119}
1120
1121DOCTEST_TEST_CASE("LiDAR Multibeam Synthetic Scan Flat Wall") {
1122 // Regression: a planar (zero-thickness) scene must not be rejected wholesale by the synthetic-scan AABB cull.
1123 // A flat wall lies in the y-z plane at x=0, so the Context domain bounding box is degenerate along x
1124 // (xmin==xmax==0). Before the degenerate-axis pad, the slab cull forced t0==t1 and rejected every ray, so
1125 // syntheticScan recorded all returns as misses (distance == LIDAR_RAYTRACE_MISS_T) and produced zero real
1126 // returns. This asserts a substantial fraction of real returns at the correct ~5 m range.
1128 // 4 m x 4 m wall (y,z in [-2,2]) at x=0, built from two triangles.
1129 context.addTriangle(make_vec3(0.f, -2.f, -2.f), make_vec3(0.f, 2.f, -2.f), make_vec3(0.f, 2.f, 2.f), RGB::green);
1130 context.addTriangle(make_vec3(0.f, -2.f, -2.f), make_vec3(0.f, 2.f, 2.f), make_vec3(0.f, -2.f, 2.f), RGB::green);
1131
1132 LiDARcloud cloud;
1133 cloud.disableMessages();
1134
1135 // Multibeam scanner 5 m in front of the wall on -x, facing +x. 25 channels over roughly -6..+6 deg elevation.
1136 vec3 scan_origin(-5.f, 0.f, 0.5f);
1137 std::vector<float> beam_zenith;
1138 for (int e = -12; e <= 12; e++) { // 25 channels, 0.5-degree elevation spacing => +/-6 deg span
1139 beam_zenith.push_back(0.5f * float(M_PI) - 0.5f * float(e) * float(M_PI) / 180.f);
1140 }
1141 uint Nphi = 2000;
1142 std::vector<std::string> columnFormat;
1143 ScanMetadata scan(scan_origin, beam_zenith, Nphi, 0.f, 2.f * float(M_PI), 0.f, 0.f, 0.f, 0.f, columnFormat);
1144 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
1145
1146 DOCTEST_CHECK_NOTHROW(cloud.syntheticScan(&context, false, true)); // scan_grid_only=false, record_misses=true
1147
1148 uint Nhits = cloud.getHitCount();
1149 DOCTEST_REQUIRE(Nhits > 0);
1150
1151 // The wall subtends azimuth half-angle atan(2/5)=21.8 deg, so it occupies ~43.6/360 = 12.1% of the 2000 azimuth
1152 // columns (~242 columns x 25 channels ~= 6000 real returns out of 50000 rays). Assert a substantial fraction
1153 // (> 8% of total) plus an absolute floor, rather than "any hit" (which the all-miss bug passed).
1154 uint Ntheta = uint(beam_zenith.size());
1155 uint total_rays = Ntheta * Nphi;
1156 uint real_returns = 0;
1157 bool ranges_ok = true;
1158 for (uint h = 0; h < Nhits; h++) {
1159 if (cloud.getHitData(h, "is_miss") == 0.0) {
1160 real_returns++;
1161 // Real hits are on the wall at x=0; on-axis range from the scanner at x=-5 is exactly 5 m, growing toward
1162 // the wall's azimuth edges as 5/cos(phi): the y=+/-2 edge is sqrt(5^2+2^2)=5.39 m, plus a small vertical
1163 // component from the +/-6 deg channels, so real ranges fall within roughly [5.0, 5.45] m.
1164 double dist = cloud.getHitData(h, "distance");
1165 if (dist < 4.9 || dist > 5.5) {
1166 ranges_ok = false;
1167 }
1168 }
1169 }
1170 DOCTEST_CHECK(real_returns > 1000); // absolute floor: the bug produced exactly 0
1171 DOCTEST_CHECK(real_returns > uint(0.08 * float(total_rays))); // ~12% expected; 8% is a defensible lower bound
1172 DOCTEST_CHECK(ranges_ok);
1173}
1174
1175DOCTEST_TEST_CASE("LiDAR Raster Synthetic Scan Flat Wall") {
1176 // Companion to the multibeam flat-wall regression: the degenerate-axis cull fix must be scan-pattern-agnostic.
1177 // Same 4 m x 4 m wall in the y-z plane at x=0, scanned with a raster pattern aimed straight at it.
1179 context.addTriangle(make_vec3(0.f, -2.f, -2.f), make_vec3(0.f, 2.f, -2.f), make_vec3(0.f, 2.f, 2.f), RGB::green);
1180 context.addTriangle(make_vec3(0.f, -2.f, -2.f), make_vec3(0.f, 2.f, 2.f), make_vec3(0.f, -2.f, 2.f), RGB::green);
1181
1182 LiDARcloud cloud;
1183 cloud.disableMessages();
1184
1185 vec3 scan_origin(-5.f, 0.f, 0.5f);
1186 uint Ntheta = 250;
1187 uint Nphi = 250;
1188 // Zenith ~84..96 deg (elevation +/-6 deg) and azimuth -20..+20 deg about +x. Azimuth phi is measured from +y,
1189 // so the +x heading toward the wall is phi = pi/2.
1190 float thetaMin = 0.5f * float(M_PI) - 6.f * float(M_PI) / 180.f;
1191 float thetaMax = 0.5f * float(M_PI) + 6.f * float(M_PI) / 180.f;
1192 float phiMin = 0.5f * float(M_PI) - 20.f * float(M_PI) / 180.f;
1193 float phiMax = 0.5f * float(M_PI) + 20.f * float(M_PI) / 180.f;
1194 std::vector<std::string> columnFormat;
1195 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.f, 0.f, 0.f, 0.f, columnFormat);
1196 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
1197
1198 DOCTEST_CHECK_NOTHROW(cloud.syntheticScan(&context, false, true));
1199
1200 uint Nhits = cloud.getHitCount();
1201 DOCTEST_REQUIRE(Nhits > 0);
1202
1203 uint real_returns = 0;
1204 bool ranges_ok = true;
1205 for (uint h = 0; h < Nhits; h++) {
1206 if (cloud.getHitData(h, "is_miss") == 0.0) {
1207 real_returns++;
1208 // The +/-20 deg azimuth edges reach 5/cos(20 deg) ~= 5.32 m.
1209 double dist = cloud.getHitData(h, "distance");
1210 if (dist < 4.9 || dist > 5.4) {
1211 ranges_ok = false;
1212 }
1213 }
1214 }
1215 DOCTEST_CHECK(real_returns > uint(0.5f * float(Ntheta * Nphi))); // fan points at the wall => majority hit
1216 DOCTEST_CHECK(ranges_ok);
1217}
1218
1219DOCTEST_TEST_CASE("LiDAR Stratified Gaussian Footprint Sampler Invariants") {
1220 // Invariants of the stratified, importance-sampled Gaussian beam-footprint sampler (divergence-cone direction and
1221 // exit-aperture origin). Because the Gaussian profile now lives in the sample density, every sub-ray carries unit
1222 // weight, so the energy-weighted return centroid of a symmetric footprint must sit on the nominal beam axis and the
1223 // range to a perpendicular wall must be preserved. Sub-ray positions are not exposed individually (they are merged
1224 // into per-pulse returns), so the footprint symmetry is checked through the centroid and the per-path range accuracy.
1225 // A 4 m x 4 m wall in the y-z plane at x=0; the scanner sits on the -x axis aimed straight at the wall center.
1226 auto build_wall = [](Context &ctx) {
1227 ctx.addTriangle(make_vec3(0.f, -2.f, -2.f), make_vec3(0.f, 2.f, -2.f), make_vec3(0.f, 2.f, 2.f), RGB::green);
1228 ctx.addTriangle(make_vec3(0.f, -2.f, -2.f), make_vec3(0.f, 2.f, 2.f), make_vec3(0.f, -2.f, 2.f), RGB::green);
1229 };
1230 const vec3 scan_origin(-5.f, 0.f, 0.f);
1231 const float standoff = 5.f; // perpendicular distance from origin to the wall at x=0
1232 const float aim_phi = 0.5f * float(M_PI); // +x heading (azimuth measured from +y)
1233 const float aim_theta = 0.5f * float(M_PI); // horizontal (elevation 0)
1234 const float beam_div = 0.5e-3f; // 0.5 mrad divergence half-angle
1235 const float exit_diam = 0.05f; // 5 cm exit aperture (non-negligible footprint)
1236 const int rays_per_pulse = 200;
1237 const float pulse_threshold = 0.3f;
1238
1239 // A single beam aimed at the wall center. Both footprint dimensions active. No noise so the only transverse spread
1240 // is the footprint itself; a symmetric footprint with unit weights must put the return centroid on the beam axis.
1241 {
1243 build_wall(context);
1244 LiDARcloud cloud;
1245 cloud.disableMessages();
1246 std::vector<std::string> columnFormat;
1247 ScanMetadata scan(scan_origin, 1, aim_theta, aim_theta, 1, aim_phi, aim_phi, exit_diam, beam_div, 0.f /*rangeNoise*/, 0.f /*angleNoise*/, columnFormat);
1248 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
1249 DOCTEST_CHECK_NOTHROW(cloud.syntheticScan(&context, rays_per_pulse, pulse_threshold, RETURN_MODE_MULTI, false, false, false));
1250
1251 uint Nhits = cloud.getHitCount();
1252 DOCTEST_REQUIRE(Nhits >= 1);
1253 // Single perpendicular surface within the merge window => exactly one return.
1254 DOCTEST_CHECK(Nhits == 1);
1255 vec3 p = cloud.getHitXYZ(0);
1256 double dist = cloud.getHitData(0, "distance");
1257 DOCTEST_CHECK(fabs(p.x) < 1e-3f); // on the wall plane
1258 DOCTEST_CHECK(fabs(p.y) < 1e-3f); // centroid on the beam axis (no transverse bias)
1259 DOCTEST_CHECK(fabs(p.z) < 1e-3f);
1260 DOCTEST_CHECK(dist == doctest::Approx(standoff).epsilon(0.001)); // range to the perpendicular wall preserved
1261 }
1262
1263 // Each footprint dimension exercised independently (divergence-only and aperture-only paths) plus the fully
1264 // degenerate point-source path, all on a small fan aimed at the wall. Every real return must land on the wall at the
1265 // correct range, confirming the new paths do not bias the range or produce NaNs.
1266 auto check_fan = [&](float bdiv, float ediam) {
1268 build_wall(context);
1269 LiDARcloud cloud;
1270 cloud.disableMessages();
1271 std::vector<std::string> columnFormat;
1272 const float half = 4.f * float(M_PI) / 180.f; // +/-4 deg fan
1273 ScanMetadata scan(scan_origin, 9, aim_theta - half, aim_theta + half, 9, aim_phi - half, aim_phi + half, ediam, bdiv, 0.f, 0.f, columnFormat);
1274 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
1275 DOCTEST_CHECK_NOTHROW(cloud.syntheticScan(&context, rays_per_pulse, pulse_threshold, RETURN_MODE_MULTI, false, false, false));
1276 uint Nhits = cloud.getHitCount();
1277 DOCTEST_REQUIRE(Nhits > 0);
1278 bool ranges_ok = true;
1279 for (uint h = 0; h < Nhits; h++) {
1280 if (cloud.getHitData(h, "is_miss") != 0.0) {
1281 continue;
1282 }
1283 double dist = cloud.getHitData(h, "distance");
1284 // Corner beams of the +/-4 deg square fan are off-axis by up to sqrt(2)*4 deg, reaching
1285 // standoff/cos(sqrt(2)*4 deg) ~= 5.025 m; allow a small margin for the footprint spread.
1286 if (dist < standoff - 1e-2 || dist > standoff / cosf(sqrtf(2.f) * half) + 5e-2) {
1287 ranges_ok = false;
1288 }
1289 vec3 p = cloud.getHitXYZ(h);
1290 if (fabs(p.x) > 1e-2f || !std::isfinite(p.x) || !std::isfinite(p.y) || !std::isfinite(p.z)) {
1291 ranges_ok = false;
1292 }
1293 }
1294 DOCTEST_CHECK(ranges_ok);
1295 };
1296 check_fan(beam_div, 0.f); // divergence-cone sampler only (point source)
1297 check_fan(0.f, exit_diam); // exit-aperture sampler only (zero divergence)
1298 check_fan(0.f, 0.f); // fully degenerate: all sub-rays on the nominal axis
1299
1300 // Seed reproducibility: the per-beam random offsets are pre-drawn serially from the Context RNG, so a seeded Context
1301 // must reproduce an identical point cloud regardless of OpenMP scheduling. Two runs with the same seed must match.
1302 {
1303 auto run_seeded = [&](uint seed, std::vector<vec3> &pts, std::vector<double> &ranges) {
1305 context.seedRandomGenerator(seed);
1306 build_wall(context);
1307 LiDARcloud cloud;
1308 cloud.disableMessages();
1309 std::vector<std::string> columnFormat;
1310 const float half = 4.f * float(M_PI) / 180.f;
1311 ScanMetadata scan(scan_origin, 12, aim_theta - half, aim_theta + half, 12, aim_phi - half, aim_phi + half, exit_diam, beam_div, 0.02f /*rangeNoise*/, 1e-4f /*angleNoise*/, columnFormat);
1312 cloud.addScan(scan);
1313 cloud.syntheticScan(&context, rays_per_pulse, pulse_threshold, RETURN_MODE_MULTI, false, false, false);
1314 uint Nhits = cloud.getHitCount();
1315 pts.clear();
1316 ranges.clear();
1317 for (uint h = 0; h < Nhits; h++) {
1318 pts.push_back(cloud.getHitXYZ(h));
1319 ranges.push_back(cloud.getHitData(h, "distance"));
1320 }
1321 };
1322 std::vector<vec3> pts_a, pts_b;
1323 std::vector<double> ranges_a, ranges_b;
1324 run_seeded(12345u, pts_a, ranges_a);
1325 run_seeded(12345u, pts_b, ranges_b);
1326 DOCTEST_REQUIRE(pts_a.size() == pts_b.size());
1327 DOCTEST_REQUIRE(!pts_a.empty());
1328 bool identical = true;
1329 for (size_t i = 0; i < pts_a.size(); i++) {
1330 if ((pts_a[i] - pts_b[i]).magnitude() > 1e-6f || fabs(ranges_a[i] - ranges_b[i]) > 1e-6) {
1331 identical = false;
1332 }
1333 }
1334 DOCTEST_CHECK(identical);
1335 }
1336}
1337
1338DOCTEST_TEST_CASE("LiDAR Synthetic Scan Pulse-Shape Deviation") {
1339 // The synthetic-scan "deviation" field is a dimensionless pulse-shape distortion metric: the within-return range
1340 // spread in excess of the transmit pulse width, normalized by the pulse width. It mirrors the RIEGL "pulse shape
1341 // deviation" confidence value (small for a clean single-surface return, large for a broadened/sloped return). The two
1342 // defining behaviors are checked: (1) a flat wall perpendicular to the beam has near-zero spread => deviation ~= 0;
1343 // (2) a steeply tilted wall spreads the footprint sub-rays over a range interval => deviation grows well above zero.
1344 // A wide beam footprint and many sub-rays are used so the footprint actually samples the surface tilt.
1345 const vec3 scan_origin(-5.f, 0.f, 0.f);
1346 const float aim_phi = 0.5f * float(M_PI); // +x heading (azimuth measured from +y)
1347 const float aim_theta = 0.5f * float(M_PI); // horizontal (elevation 0)
1348 const float beam_div = 15.e-3f; // 15 mrad divergence => ~15 cm footprint at 5 m, enough to resolve the tilt
1349 const float exit_diam = 0.05f; // 5 cm exit aperture
1350 const int rays_per_pulse = 400;
1351 const float pulse_width = 0.5f; // merge window / reference pulse range-extent (meters)
1352
1353 auto scan_single_wall = [&](Context &context) -> double {
1354 LiDARcloud cloud;
1355 cloud.disableMessages();
1356 std::vector<std::string> columnFormat;
1357 ScanMetadata scan(scan_origin, 1, aim_theta, aim_theta, 1, aim_phi, aim_phi, exit_diam, beam_div, 0.f /*rangeNoise*/, 0.f /*angleNoise*/, columnFormat);
1358 cloud.addScan(scan);
1359 cloud.syntheticScan(&context, rays_per_pulse, pulse_width, RETURN_MODE_MULTI, false, false, false);
1360 uint Nhits = cloud.getHitCount();
1361 DOCTEST_REQUIRE(Nhits >= 1);
1362 // Find the (single) real return.
1363 for (uint h = 0; h < Nhits; h++) {
1364 if (cloud.getHitData(h, "is_miss") == 0.0) {
1365 return cloud.getHitData(h, "deviation");
1366 }
1367 }
1368 DOCTEST_FAIL("no real return produced");
1369 return -1.0;
1370 };
1371
1372 // (1) Flat wall in the y-z plane at x=0, perpendicular to the +x beam: all sub-rays hit at essentially the same range.
1373 double deviation_perpendicular;
1374 {
1376 context.addTriangle(make_vec3(0.f, -2.f, -2.f), make_vec3(0.f, 2.f, -2.f), make_vec3(0.f, 2.f, 2.f), RGB::green);
1377 context.addTriangle(make_vec3(0.f, -2.f, -2.f), make_vec3(0.f, 2.f, 2.f), make_vec3(0.f, -2.f, 2.f), RGB::green);
1378 deviation_perpendicular = scan_single_wall(context);
1379 }
1380 DOCTEST_CHECK(deviation_perpendicular >= 0.0); // never negative
1381 DOCTEST_CHECK(deviation_perpendicular < 0.05); // clean perpendicular return => negligible pulse-shape deviation
1382
1383 // (2) Wall steeply tilted about the z-axis so the surface recedes across the beam footprint in y. The footprint now
1384 // samples a range interval, so the merged return is broadened and the deviation rises well above the clean case.
1385 double deviation_tilted;
1386 {
1388 // Plane through the origin tilted 75 deg from the y-z plane: large dx across the footprint's y-extent.
1389 const float c = cosf(75.f * float(M_PI) / 180.f);
1390 const float s = sinf(75.f * float(M_PI) / 180.f);
1391 // Rotate the flat wall's corners about z: (x,y) -> (x*c - y*s, x*s + y*c), with x=0 on the original wall.
1392 auto rot = [&](float y, float z) { return make_vec3(-y * s, y * c, z); };
1393 context.addTriangle(rot(-2.f, -2.f), rot(2.f, -2.f), rot(2.f, 2.f), RGB::green);
1394 context.addTriangle(rot(-2.f, -2.f), rot(2.f, 2.f), rot(-2.f, 2.f), RGB::green);
1395 deviation_tilted = scan_single_wall(context);
1396 }
1397 DOCTEST_CHECK(deviation_tilted > deviation_perpendicular); // tilt broadens the pulse => larger deviation
1398 DOCTEST_CHECK(deviation_tilted > 0.05); // meaningfully non-zero distortion
1399}
1400
1401DOCTEST_TEST_CASE("LiDAR Default Detection Threshold") {
1402 // The default detectionThreshold is a non-zero noise floor (0.05) that pairs with the recommended ~40 rays/pulse: it
1403 // suppresses the single-sub-ray "phantom" returns that otherwise force very high ray counts to converge. Verify the
1404 // default value and that, on a scene with a deliberately weak return, the default floor drops it while a 0 threshold
1405 // keeps it. Scene (the three partial-footprint slivers from the N-return tests): the nearest sliver is weak enough to
1406 // fall below the 0.05 floor.
1407 auto buildScene = [](Context &context) {
1408 context.addPatch(make_vec3(0.13f, 0.f, 2.5f), make_vec2(0.10f, 0.4f)); // nearest sliver (weakest)
1409 context.addPatch(make_vec3(-0.12f, 0.f, 1.5f), make_vec2(0.16f, 0.4f)); // middle sliver
1410 context.addPatch(make_vec3(0.f, 0.f, 0.4f), make_vec2(2.0f, 2.0f)); // far full footprint (strongest)
1411 };
1412
1413 // Default value is the 0.05 noise floor.
1414 {
1415 Context ctx;
1416 buildScene(ctx);
1417 LiDARcloud cloud;
1418 cloud.disableMessages();
1419 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
1420 uint id = cloud.addScan(scan);
1421 float thr = cloud.getScanDetectionThreshold(id);
1422 DOCTEST_CHECK(thr == doctest::Approx(0.05f));
1423 }
1424
1425 auto count_returns = [&](float threshold) -> uint {
1426 Context ctx;
1427 buildScene(ctx);
1428 LiDARcloud cloud;
1429 cloud.disableMessages();
1430 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
1431 uint id = cloud.addScan(scan);
1432 cloud.setScanPulseWidth(id, 0.5f);
1433 cloud.setScanDetectionThreshold(id, threshold);
1434 cloud.syntheticScan(&ctx, 400, 0.5f, RETURN_MODE_MULTI);
1435 return cloud.getHitCount();
1436 };
1437 uint n_zero = count_returns(0.f); // no noise floor: all three slivers detected
1438 uint n_default = count_returns(0.05f); // default floor
1439 uint n_high = count_returns(0.5f); // aggressive floor: only the strong far surface clears it
1440 DOCTEST_CHECK(n_zero == 3); // all three partial-footprint returns resolve with no floor
1441 DOCTEST_CHECK(n_default <= n_zero); // the noise floor can only remove returns, never add
1442 DOCTEST_CHECK(n_high < n_zero); // a high floor suppresses the weak returns
1443 DOCTEST_CHECK(n_high >= 1); // the strong far surface is still detected
1444}
1445
1446DOCTEST_TEST_CASE("LiDAR Synthetic Scan Beam Chunking Equivalence") {
1447 // The per-scan beam fan-out is traced in memory-bounded chunks; a multi-chunk run must produce exactly the same point
1448 // cloud as the default single-chunk run. Beam divergence and noise are disabled so the per-pulse sub-rays are the
1449 // deterministic nominal direction (no RNG draws during ray generation), making the comparison exact regardless of how
1450 // the beams are split into chunks. A multi-return scan (rays_per_pulse > 1) exercises the waveform reduction path.
1452 context.addTriangle(make_vec3(0.f, -2.f, -2.f), make_vec3(0.f, 2.f, -2.f), make_vec3(0.f, 2.f, 2.f), RGB::green);
1453 context.addTriangle(make_vec3(0.f, -2.f, -2.f), make_vec3(0.f, 2.f, 2.f), make_vec3(0.f, -2.f, 2.f), RGB::green);
1454
1455 vec3 scan_origin(-5.f, 0.f, 0.5f);
1456 uint Ntheta = 60;
1457 uint Nphi = 60;
1458 float thetaMin = 0.5f * float(M_PI) - 6.f * float(M_PI) / 180.f;
1459 float thetaMax = 0.5f * float(M_PI) + 6.f * float(M_PI) / 180.f;
1460 float phiMin = 0.5f * float(M_PI) - 20.f * float(M_PI) / 180.f;
1461 float phiMax = 0.5f * float(M_PI) + 20.f * float(M_PI) / 180.f;
1462 std::vector<std::string> columnFormat;
1463
1464 const int rays_per_pulse = 10;
1465 const float pulse_distance_threshold = 0.3f;
1466
1467 // Default budget: the whole scan fits in a single chunk.
1468 LiDARcloud cloud_single;
1469 cloud_single.disableMessages();
1470 ScanMetadata scan_single(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.f /*exitDiameter*/, 0.f /*beamDivergence*/, 0.f /*rangeNoise*/, 0.f /*angleNoise*/, columnFormat);
1471 DOCTEST_CHECK_NOTHROW(cloud_single.addScan(scan_single));
1472 DOCTEST_CHECK_NOTHROW(cloud_single.syntheticScan(&context, rays_per_pulse, pulse_distance_threshold, RETURN_MODE_MULTI, false /*scan_grid_only*/, true /*record_misses*/, false /*append*/));
1473 uint Nhits_single = cloud_single.getHitCount();
1474 DOCTEST_REQUIRE(Nhits_single > 0);
1475
1476 // Tiny budget: forces the beams to be processed in many chunks.
1477 LiDARcloud cloud_chunked;
1478 cloud_chunked.disableMessages();
1479 DOCTEST_CHECK_NOTHROW(cloud_chunked.setSyntheticScanMemoryBudget(4096)); // a few KB => clamped to >=1 beam/chunk, many chunks
1480 DOCTEST_CHECK(cloud_chunked.getSyntheticScanMemoryBudget() == 4096);
1481 ScanMetadata scan_chunked(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.f, 0.f, 0.f, 0.f, columnFormat);
1482 DOCTEST_CHECK_NOTHROW(cloud_chunked.addScan(scan_chunked));
1483 DOCTEST_CHECK_NOTHROW(cloud_chunked.syntheticScan(&context, rays_per_pulse, pulse_distance_threshold, RETURN_MODE_MULTI, false, true, false));
1484 uint Nhits_chunked = cloud_chunked.getHitCount();
1485
1486 // Identical hit count and identical per-hit distances/positions (no RNG in ray-gen => exact match).
1487 DOCTEST_CHECK(Nhits_chunked == Nhits_single);
1488 if (Nhits_chunked == Nhits_single) {
1489 bool all_match = true;
1490 for (uint h = 0; h < Nhits_single; h++) {
1491 if (cloud_chunked.getHitData(h, "is_miss") != cloud_single.getHitData(h, "is_miss")) {
1492 all_match = false;
1493 break;
1494 }
1495 if (std::fabs(cloud_chunked.getHitData(h, "distance") - cloud_single.getHitData(h, "distance")) > 1e-6) {
1496 all_match = false;
1497 break;
1498 }
1499 vec3 p_single = cloud_single.getHitXYZ(h);
1500 vec3 p_chunked = cloud_chunked.getHitXYZ(h);
1501 if ((p_single - p_chunked).magnitude() > 1e-5f) {
1502 all_match = false;
1503 break;
1504 }
1505 }
1506 DOCTEST_CHECK(all_match);
1507 }
1508
1509 // The setter rejects a zero budget.
1510 LiDARcloud cloud_validate;
1511 cloud_validate.disableMessages();
1512 DOCTEST_CHECK_THROWS(cloud_validate.setSyntheticScanMemoryBudget(0));
1513}
1514
1515DOCTEST_TEST_CASE("LiDAR Synthetic Scan Beam Chunking Equivalence (GPU dispatch)") {
1516 // Like the test above, but sized so the per-scan sub-ray batches cross the collision-detection GPU dispatch
1517 // threshold (>= 1M rays on a >= 500-primitive scene). On a CUDA build the single-chunk scan runs entirely on the
1518 // GPU, and each >= 1M-ray chunk of the multi-chunk scan also runs on the GPU (bit-identical to the single-chunk
1519 // result, since a ray's result is independent of how beams are batched). Any final sub-1M remainder chunk falls
1520 // back to the CPU traversal, whose Moller-Trumbore-vs-CPU floating-point differences are bounded well within the
1521 // tolerances below; hit counts must match exactly. On a non-CUDA build everything runs on the CPU and the
1522 // comparison is simply exact-within-tolerance. This guards the resident-scene GPU path under chunking.
1524
1525 // A large triangulated wall at x=0 (800 triangles >= MIN_PRIMITIVES_FOR_GPU) that fully covers the scan FOV so
1526 // every beam hits squarely (no grazing => stable hit counts between the GPU and CPU paths).
1527 const int gy = 20, gz = 20;
1528 const float ymin = -3.f, ymax = 3.f, zmin = -2.5f, zmax = 3.5f;
1529 const float dy = (ymax - ymin) / float(gy);
1530 const float dz = (zmax - zmin) / float(gz);
1531 for (int iy = 0; iy < gy; iy++) {
1532 for (int iz = 0; iz < gz; iz++) {
1533 float y = ymin + iy * dy;
1534 float z = zmin + iz * dz;
1535 context.addTriangle(make_vec3(0.f, y, z), make_vec3(0.f, y + dy, z), make_vec3(0.f, y + dy, z + dz), RGB::green);
1536 context.addTriangle(make_vec3(0.f, y, z), make_vec3(0.f, y + dy, z + dz), make_vec3(0.f, y, z + dz), RGB::green);
1537 }
1538 }
1539
1540 vec3 scan_origin(-5.f, 0.f, 0.5f);
1541 uint Ntheta = 120;
1542 uint Nphi = 120;
1543 float thetaMin = 0.5f * float(M_PI) - 6.f * float(M_PI) / 180.f;
1544 float thetaMax = 0.5f * float(M_PI) + 6.f * float(M_PI) / 180.f;
1545 float phiMin = 0.5f * float(M_PI) - 20.f * float(M_PI) / 180.f;
1546 float phiMax = 0.5f * float(M_PI) + 20.f * float(M_PI) / 180.f;
1547 std::vector<std::string> columnFormat;
1548
1549 const int rays_per_pulse = 100; // 120*120*100 = 1.44M sub-rays => single chunk crosses the 1M GPU threshold
1550 const float pulse_distance_threshold = 0.3f;
1551
1552 // Single chunk (default budget): the whole 1.44M-ray scan dispatches to the GPU.
1553 LiDARcloud cloud_single;
1554 cloud_single.disableMessages();
1555 ScanMetadata scan_single(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.f, 0.f, 0.f, 0.f, columnFormat);
1556 DOCTEST_CHECK_NOTHROW(cloud_single.addScan(scan_single));
1557 DOCTEST_CHECK_NOTHROW(cloud_single.syntheticScan(&context, rays_per_pulse, pulse_distance_threshold, RETURN_MODE_MULTI, false, true, false));
1558 uint Nhits_single = cloud_single.getHitCount();
1559 DOCTEST_REQUIRE(Nhits_single > 0);
1560
1561 // Multi-chunk: an ~8 MB budget floors each chunk at ~1.05M sub-rays, so chunks still hit the GPU path; the trailing
1562 // remainder chunk may fall to the CPU.
1563 LiDARcloud cloud_chunked;
1564 cloud_chunked.disableMessages();
1565 DOCTEST_CHECK_NOTHROW(cloud_chunked.setSyntheticScanMemoryBudget(8u * 1024u * 1024u));
1566 ScanMetadata scan_chunked(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.f, 0.f, 0.f, 0.f, columnFormat);
1567 DOCTEST_CHECK_NOTHROW(cloud_chunked.addScan(scan_chunked));
1568 DOCTEST_CHECK_NOTHROW(cloud_chunked.syntheticScan(&context, rays_per_pulse, pulse_distance_threshold, RETURN_MODE_MULTI, false, true, false));
1569 uint Nhits_chunked = cloud_chunked.getHitCount();
1570
1571 DOCTEST_CHECK(Nhits_chunked == Nhits_single);
1572 if (Nhits_chunked == Nhits_single) {
1573 size_t mismatches = 0;
1574 for (uint h = 0; h < Nhits_single; h++) {
1575 if (cloud_chunked.getHitData(h, "is_miss") != cloud_single.getHitData(h, "is_miss")) {
1576 mismatches++;
1577 continue;
1578 }
1579 if (std::fabs(cloud_chunked.getHitData(h, "distance") - cloud_single.getHitData(h, "distance")) > 1e-3) {
1580 mismatches++;
1581 continue;
1582 }
1583 if ((cloud_single.getHitXYZ(h) - cloud_chunked.getHitXYZ(h)).magnitude() > 1e-2f) {
1584 mismatches++;
1585 }
1586 }
1587 DOCTEST_CHECK(mismatches == 0);
1588 }
1589}
1590
1591DOCTEST_TEST_CASE("LiDAR GPU Availability Query") {
1592 LiDARcloud lidarcloud;
1593 lidarcloud.disableMessages();
1594
1595 // LiDARcloud::isGPUAvailable() forwards to the static CollisionDetection probe and must
1596 // be valid even before any geometry/collision instance exists.
1597 bool lidar_available = false;
1598 DOCTEST_CHECK_NOTHROW(lidar_available = lidarcloud.isGPUAvailable());
1599 DOCTEST_CHECK(lidar_available == CollisionDetection::isGPUAvailable());
1600
1601 // The forwarded enabled-state query must be callable and consistent with availability:
1602 // before any explicit toggle, the effective default matches GPU availability.
1603 bool lidar_enabled = false;
1604 DOCTEST_CHECK_NOTHROW(lidar_enabled = lidarcloud.isGPUAccelerationEnabled());
1605 DOCTEST_CHECK(lidar_enabled == lidar_available);
1606}
1607
1608DOCTEST_TEST_CASE("LiDAR Spinning Multibeam XML Load Geometry") {
1609 // Write a temporary spinning-multibeam scan XML (physical-parameter form: stationary spin for one revolution, expressed
1610 // as two coincident poses one rotation period apart) and verify it loads with the correct channel geometry. The azimuth
1611 // grid and revolution count are derived from <azimuthStep> + <PRF> + the trajectory duration, not hand-supplied.
1612 // 7 channels * 720 steps/rev / 100000 Hz = 0.0504 s for exactly one revolution.
1613 std::string xml_path = "plugins/lidar/xml/.tmp_spinning_multibeam_test.xml";
1614 {
1615 std::ofstream f(xml_path);
1616 f << "<helios>\n";
1617 f << " <scan>\n";
1618 f << " <scanPattern> spinning_multibeam </scanPattern>\n";
1619 f << " <beamElevationAngles> -15 -10 -5 0 5 10 15 </beamElevationAngles>\n";
1620 f << " <azimuthStep> 0.5 </azimuthStep>\n"; // 0.5 deg/step -> 720 steps/rev
1621 f << " <PRF> 100000 </PRF>\n";
1622 f << " <trajectory>\n";
1623 f << " <pose> 0.0 0 0 1 0 0 0 1 </pose>\n";
1624 f << " <pose> 0.0504 0 0 1 0 0 0 1 </pose>\n";
1625 f << " </trajectory>\n";
1626 f << " </scan>\n";
1627 f << "</helios>\n";
1628 }
1629
1630 LiDARcloud cloud;
1631 cloud.disableMessages();
1632 DOCTEST_CHECK_NOTHROW(cloud.loadXML(xml_path.c_str()));
1633 DOCTEST_REQUIRE(cloud.getScanCount() == 1);
1634 DOCTEST_CHECK(cloud.getScanPattern(0) == SCAN_PATTERN_SPINNING_MULTIBEAM);
1635 DOCTEST_CHECK(cloud.getScanMode(0) == SCAN_MODE_SPINNING);
1636 DOCTEST_CHECK(cloud.getScanSizeTheta(0) == 7); // 7 channels
1637 DOCTEST_CHECK(cloud.getScanStepsPerRev(0) == 720); // 360 / 0.5
1638 DOCTEST_CHECK(cloud.getScanSizePhi(0) == 720); // one revolution -> exactly steps_per_rev columns
1639
1640 std::vector<float> angles = cloud.getScanBeamZenithAngles(0);
1641 DOCTEST_REQUIRE(angles.size() == 7);
1642 DOCTEST_CHECK(angles[3] == doctest::Approx(0.5f * float(M_PI))); // channel 3 is 0 deg elevation => zenith pi/2
1643
1644 std::remove(xml_path.c_str());
1645}
1646
1647DOCTEST_TEST_CASE("LiDAR TreeQSM Loading Test") {
1648 Context context_treeqsm;
1649 LiDARcloud lidar;
1650 lidar.disableMessages();
1651
1652 // Test loading TreeQSM file without texture
1653 std::vector<uint> tube_UUIDs;
1654 uint radial_subdivisions = 6;
1655 DOCTEST_CHECK_NOTHROW(tube_UUIDs = lidar.loadTreeQSM(&context_treeqsm, "plugins/lidar/data/cylinder_tree_QSM_test.txt", radial_subdivisions));
1656
1657 // Check that tube objects were created
1658 DOCTEST_CHECK(tube_UUIDs.size() > 0);
1659
1660 // Check that all returned UUIDs are valid
1661 for (uint UUID: tube_UUIDs) {
1662 DOCTEST_CHECK(context_treeqsm.doesObjectExist(UUID));
1663 DOCTEST_CHECK(context_treeqsm.getObjectType(UUID) == helios::OBJECT_TYPE_TUBE);
1664 }
1665
1666 // Test that object data was set correctly
1667 for (uint UUID: tube_UUIDs) {
1668 DOCTEST_CHECK(context_treeqsm.doesObjectDataExist(UUID, "branch_order"));
1669 DOCTEST_CHECK(context_treeqsm.doesObjectDataExist(UUID, "branch_id"));
1670
1671 int branch_order;
1672 context_treeqsm.getObjectData(UUID, "branch_order", branch_order);
1673 DOCTEST_CHECK(branch_order >= 0);
1674
1675 int branch_id;
1676 context_treeqsm.getObjectData(UUID, "branch_id", branch_id);
1677 DOCTEST_CHECK(branch_id >= 0);
1678 }
1679
1680 // Test loading with empty texture file (should still work)
1681 Context context_treeqsm2;
1682 std::vector<uint> tube_UUIDs2;
1683 DOCTEST_CHECK_NOTHROW(tube_UUIDs2 = lidar.loadTreeQSM(&context_treeqsm2, "plugins/lidar/data/cylinder_tree_QSM_test.txt", radial_subdivisions, ""));
1684 DOCTEST_CHECK(tube_UUIDs2.size() == tube_UUIDs.size());
1685
1686 // Test error handling for non-existent file
1687 Context context_error;
1688 DOCTEST_CHECK_THROWS(lidar.loadTreeQSM(&context_error, "nonexistent_file.txt", radial_subdivisions));
1689
1690 // Test with different radial subdivisions
1691 Context context_treeqsm3;
1692 std::vector<uint> tube_UUIDs3;
1693 uint different_subdivisions = 8;
1694 DOCTEST_CHECK_NOTHROW(tube_UUIDs3 = lidar.loadTreeQSM(&context_treeqsm3, "plugins/lidar/data/cylinder_tree_QSM_test.txt", different_subdivisions));
1695 DOCTEST_CHECK(tube_UUIDs3.size() == tube_UUIDs.size()); // Same number of tubes
1696
1697 // Test that each tube has appropriate number of nodes and primitives
1698 for (uint UUID: tube_UUIDs) {
1699 std::vector<uint> primitive_UUIDs = context_treeqsm.getObjectPrimitiveUUIDs(UUID);
1700 DOCTEST_CHECK(primitive_UUIDs.size() > 0);
1701
1702 // Each tube should have triangular primitives
1703 for (uint prim_UUID: primitive_UUIDs) {
1704 DOCTEST_CHECK(context_treeqsm.getPrimitiveType(prim_UUID) == helios::PRIMITIVE_TYPE_TRIANGLE);
1705 }
1706 }
1707}
1708
1709DOCTEST_TEST_CASE("LiDAR TreeQSM Colormap Loading Test") {
1710 Context context_colormap;
1711 LiDARcloud lidar;
1712 lidar.disableMessages();
1713
1714 // Test loading TreeQSM file with colormap
1715 std::vector<uint> tube_UUIDs;
1716 uint radial_subdivisions = 6;
1717 std::string colormap_name = "hot";
1718 DOCTEST_CHECK_NOTHROW(tube_UUIDs = lidar.loadTreeQSMColormap(&context_colormap, "plugins/lidar/data/cylinder_tree_QSM_test.txt", radial_subdivisions, colormap_name));
1719
1720 // Check that tube objects were created
1721 DOCTEST_CHECK(tube_UUIDs.size() > 0);
1722
1723 // Check that all returned UUIDs are valid
1724 for (uint UUID: tube_UUIDs) {
1725 DOCTEST_CHECK(context_colormap.doesObjectExist(UUID));
1726 DOCTEST_CHECK(context_colormap.getObjectType(UUID) == helios::OBJECT_TYPE_TUBE);
1727 }
1728
1729 // Test that object data was set correctly
1730 for (uint UUID: tube_UUIDs) {
1731 DOCTEST_CHECK(context_colormap.doesObjectDataExist(UUID, "branch_order"));
1732 DOCTEST_CHECK(context_colormap.doesObjectDataExist(UUID, "branch_id"));
1733
1734 int branch_order;
1735 context_colormap.getObjectData(UUID, "branch_order", branch_order);
1736 DOCTEST_CHECK(branch_order >= 0);
1737
1738 int branch_id;
1739 context_colormap.getObjectData(UUID, "branch_id", branch_id);
1740 DOCTEST_CHECK(branch_id >= 0);
1741 }
1742
1743 // Test with different colormap
1744 Context context_colormap2;
1745 std::vector<uint> tube_UUIDs2;
1746 std::string colormap_name2 = "cool";
1747 DOCTEST_CHECK_NOTHROW(tube_UUIDs2 = lidar.loadTreeQSMColormap(&context_colormap2, "plugins/lidar/data/cylinder_tree_QSM_test.txt", radial_subdivisions, colormap_name2));
1748 DOCTEST_CHECK(tube_UUIDs2.size() == tube_UUIDs.size());
1749
1750 // Test error handling for non-existent file
1751 Context context_error2;
1752 DOCTEST_CHECK_THROWS(lidar.loadTreeQSMColormap(&context_error2, "nonexistent_file.txt", radial_subdivisions, colormap_name));
1753
1754 // Test that each tube has appropriate number of primitives
1755 for (uint UUID: tube_UUIDs) {
1756 std::vector<uint> primitive_UUIDs = context_colormap.getObjectPrimitiveUUIDs(UUID);
1757 DOCTEST_CHECK(primitive_UUIDs.size() > 0);
1758
1759 // Each tube should have triangular primitives
1760 for (uint prim_UUID: primitive_UUIDs) {
1761 DOCTEST_CHECK(context_colormap.getPrimitiveType(prim_UUID) == helios::PRIMITIVE_TYPE_TRIANGLE);
1762 }
1763 }
1764
1765 // Test with invalid colormap name (should throw an exception)
1766 Context context_colormap3;
1767 std::string invalid_colormap = "invalid_colormap_name";
1768 DOCTEST_CHECK_THROWS(lidar.loadTreeQSMColormap(&context_colormap3, "plugins/lidar/data/cylinder_tree_QSM_test.txt", radial_subdivisions, invalid_colormap));
1769}
1770
1771DOCTEST_TEST_CASE("LiDAR Collision Detection Integration Test") {
1772 LiDARcloud lidar;
1773 lidar.disableMessages();
1774
1775 // Create a simple test context with geometry
1776 Context test_context;
1777 test_context.addSphere(10, make_vec3(0, 0, 0), 1.0f, RGB::red);
1778 test_context.addTriangle(make_vec3(-2, -1, -1), make_vec3(2, -1, -1), make_vec3(0, 1, -1), RGB::green);
1779
1780 // Test initializeCollisionDetection method
1781 DOCTEST_CHECK_NOTHROW(lidar.initializeCollisionDetection(&test_context));
1782
1783 // Test calling initialize multiple times (should not create multiple instances)
1784 DOCTEST_CHECK_NOTHROW(lidar.initializeCollisionDetection(&test_context));
1785
1786 // Create test ray data
1787 const size_t N = 3;
1788 const int Npulse = 2;
1789 helios::vec3 scan_origin = make_vec3(0, 0, 5);
1790
1791 // Test ray directions - some should hit, some should miss
1792 std::vector<helios::vec3> directions = {
1793 make_vec3(0, 0, -1), // Should hit sphere
1794 make_vec3(1, 0, -1), // Should miss sphere
1795 make_vec3(0, -0.5, -1), // Should hit triangle
1796 make_vec3(2, 0, -1), // Should miss everything
1797 make_vec3(-1, 0, -1), // Should miss sphere, might hit triangle
1798 make_vec3(0, 0.5, -1) // Should miss triangle, might hit sphere
1799 };
1800
1801 float hit_t[N * Npulse];
1802 float hit_fnorm[N * Npulse];
1803 int hit_ID[N * Npulse];
1804
1805 // Initialize arrays
1806 for (size_t i = 0; i < N * Npulse; i++) {
1807 hit_t[i] = 1001.0f;
1808 hit_fnorm[i] = 1e6;
1809 hit_ID[i] = -1;
1810 }
1811
1812 // Create ray origins array (all rays from same origin)
1813 std::vector<helios::vec3> ray_origins(N * Npulse, scan_origin);
1814
1815 // Test performUnifiedRayTracing method
1816 DOCTEST_CHECK_NOTHROW(lidar.performUnifiedRayTracing(&test_context, N, Npulse, ray_origins.data(), directions.data(), hit_t, hit_fnorm, hit_ID));
1817
1818 // Validate results - at least some rays should hit
1819 bool found_hit = false;
1820 bool found_miss = false;
1821 for (size_t i = 0; i < N * Npulse; i++) {
1822 if (hit_t[i] < 1000.0f) {
1823 found_hit = true;
1824 // Valid hit should have reasonable distance
1825 DOCTEST_CHECK(hit_t[i] > 0.0f);
1826 DOCTEST_CHECK(hit_t[i] < 100.0f);
1827 // Valid hit should have a primitive ID
1828 DOCTEST_CHECK(hit_ID[i] >= 0);
1829 // Normal calculation should be finite
1830 DOCTEST_CHECK(std::isfinite(hit_fnorm[i]));
1831 } else {
1832 found_miss = true;
1833 DOCTEST_CHECK(hit_ID[i] == -1);
1834 }
1835 }
1836
1837 // We should have both hits and misses in our test case
1838 DOCTEST_CHECK(found_hit);
1839 DOCTEST_CHECK(found_miss);
1840}
1841
1842DOCTEST_TEST_CASE("LiDAR Data Format Conversion Test") {
1843 LiDARcloud lidar;
1844 lidar.disableMessages();
1845
1846 Context test_context;
1847 test_context.addSphere(5, make_vec3(0, 0, 0), 1.0f, RGB::red);
1848
1849 // Test conversion between CUDA float3 and Helios vec3 formats - simplified
1850 const size_t N = 2;
1851 const int Npulse = 1;
1852
1853 std::vector<helios::vec3> test_directions = {
1854 make_vec3(0, 0, -1), // Downward (should hit)
1855 make_vec3(1, 0, 0) // Sideways (should miss)
1856 };
1857
1858 // Test one origin only
1859 helios::vec3 origin = make_vec3(0, 0, 5);
1860
1861 float hit_t[N * Npulse];
1862 float hit_fnorm[N * Npulse];
1863 int hit_ID[N * Npulse];
1864
1865 // Initialize collision detection
1866 lidar.initializeCollisionDetection(&test_context);
1867
1868 // Create ray origins array (all rays from same origin)
1869 std::vector<helios::vec3> ray_origins(N * Npulse, origin);
1870
1871 // Test ray tracing
1872 DOCTEST_CHECK_NOTHROW(lidar.performUnifiedRayTracing(&test_context, N, Npulse, ray_origins.data(), test_directions.data(), hit_t, hit_fnorm, hit_ID));
1873
1874 // Basic validation
1875 for (size_t i = 0; i < N * Npulse; i++) {
1876 DOCTEST_CHECK(std::isfinite(hit_t[i]));
1877 DOCTEST_CHECK(std::isfinite(hit_fnorm[i]));
1878 DOCTEST_CHECK(hit_ID[i] >= -1);
1879 }
1880}
1881
1882DOCTEST_TEST_CASE("LiDAR Edge Cases and Error Conditions Test") {
1883 LiDARcloud lidar;
1884 lidar.disableMessages();
1885
1886 Context test_context;
1887 test_context.addSphere(3, make_vec3(0, 0, 0), 1.0f, RGB::red);
1888
1889 // Test basic edge cases only
1890 helios::vec3 origin = make_vec3(0, 0, 5);
1891 helios::vec3 direction = make_vec3(0, 0, -1);
1892 float hit_t[1];
1893 float hit_fnorm[1];
1894 int hit_ID[1];
1895
1896 // Test initialization
1897 DOCTEST_CHECK_NOTHROW(lidar.initializeCollisionDetection(&test_context));
1898
1899 // Create ray origins array (single ray from single origin)
1900 helios::vec3 ray_origins[1] = {origin};
1901
1902 // Test single ray
1903 DOCTEST_CHECK_NOTHROW(lidar.performUnifiedRayTracing(&test_context, 1, 1, ray_origins, &direction, hit_t, hit_fnorm, hit_ID));
1904
1905 // Validate basic results
1906 DOCTEST_CHECK(std::isfinite(hit_t[0]));
1907 DOCTEST_CHECK(std::isfinite(hit_fnorm[0]));
1908 DOCTEST_CHECK(hit_ID[0] >= -1);
1909}
1910
1911DOCTEST_TEST_CASE("LiDAR Collision Detection Memory Management Test") {
1912 // Test basic initialization and cleanup
1913 LiDARcloud lidar;
1914 lidar.disableMessages();
1915
1916 Context test_context;
1917 test_context.addTriangle(make_vec3(-1, -1, 0), make_vec3(1, -1, 0), make_vec3(0, 1, 0), RGB::red);
1918
1919 // Test initialization
1920 DOCTEST_CHECK_NOTHROW(lidar.initializeCollisionDetection(&test_context));
1921
1922 // Test one ray
1923 helios::vec3 origin = make_vec3(0, 0, 5);
1924 helios::vec3 direction = make_vec3(0, 0, -1);
1925 float hit_t, hit_fnorm;
1926 int hit_ID;
1927
1928 // Create ray origins array (single ray from single origin)
1929 helios::vec3 ray_origins[1] = {origin};
1930
1931 DOCTEST_CHECK_NOTHROW(lidar.performUnifiedRayTracing(&test_context, 1, 1, ray_origins, &direction, &hit_t, &hit_fnorm, &hit_ID));
1932
1933 // Basic validation
1934 DOCTEST_CHECK(std::isfinite(hit_t));
1935 DOCTEST_CHECK(std::isfinite(hit_fnorm));
1936 DOCTEST_CHECK(hit_ID >= -1);
1937}
1938
1939DOCTEST_TEST_CASE("LiDAR Synthetic Scan Integration Test") {
1940 // Test that synthetic scans still work with the new collision detection integration
1941 LiDARcloud synthetic_scan_test;
1942 synthetic_scan_test.disableMessages();
1943
1944 // Load a scan configuration
1945 DOCTEST_CHECK_NOTHROW(synthetic_scan_test.loadXML("plugins/lidar/xml/synthetic_test.xml"));
1946
1947 // Create a simple test geometry
1948 Context scan_context;
1949 std::vector<uint> patch_UUIDs = scan_context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
1950 DOCTEST_CHECK(patch_UUIDs.size() > 0);
1951
1952 // Run synthetic scan - this should use the new collision detection integration internally
1953 DOCTEST_CHECK_NOTHROW(synthetic_scan_test.syntheticScan(&scan_context));
1954
1955 // Verify that we got some hits
1956 uint hit_count = synthetic_scan_test.getHitCount();
1957 DOCTEST_CHECK(hit_count > 0);
1958
1959 // Test that hit points have reasonable coordinates
1960 for (uint i = 0; i < std::min(hit_count, 10u); i++) {
1961 helios::vec3 hit_pos = synthetic_scan_test.getHitXYZ(i);
1962
1963 // Coordinates should be finite
1964 DOCTEST_CHECK(std::isfinite(hit_pos.x));
1965 DOCTEST_CHECK(std::isfinite(hit_pos.y));
1966 DOCTEST_CHECK(std::isfinite(hit_pos.z));
1967
1968 // Should be within reasonable bounds for our test geometry
1969 DOCTEST_CHECK(fabs(hit_pos.x) < 100.0f);
1970 DOCTEST_CHECK(fabs(hit_pos.y) < 100.0f);
1971 DOCTEST_CHECK(fabs(hit_pos.z) < 100.0f);
1972
1973 // Test ray direction is valid
1974 helios::SphericalCoord ray_dir = synthetic_scan_test.getHitRaydir(i);
1975 DOCTEST_CHECK(std::isfinite(ray_dir.zenith));
1976 DOCTEST_CHECK(std::isfinite(ray_dir.azimuth));
1977 DOCTEST_CHECK(std::isfinite(ray_dir.radius));
1978 }
1979
1980 // Test backward compatibility - existing LiDAR functionality should still work
1981 DOCTEST_CHECK_NOTHROW(synthetic_scan_test.calculateHitGridCell());
1982 DOCTEST_CHECK_NOTHROW(synthetic_scan_test.triangulateHitPoints(0.04, 10));
1983 DOCTEST_CHECK_NOTHROW(synthetic_scan_test.gapfillMisses()); // LAD inversion requires misses (transmitted beams)
1984 DOCTEST_CHECK_NOTHROW(synthetic_scan_test.calculateLeafArea(&scan_context));
1985
1986 // Grid cell calculations should produce reasonable results
1987 uint cell_count = synthetic_scan_test.getGridCellCount();
1988 if (cell_count > 0) {
1989 float leaf_area_density = synthetic_scan_test.getCellLeafAreaDensity(0);
1990 DOCTEST_CHECK(std::isfinite(leaf_area_density));
1991 DOCTEST_CHECK(leaf_area_density >= 0.0f);
1992 }
1993}
1994
1995DOCTEST_TEST_CASE("LiDAR Synthetic Scan Range Noise Test") {
1996 // Range (along-beam) noise should displace synthetic hit points along the beam direction only, NOT isotropically.
1997 // Scanner is placed directly above a large horizontal patch and scans straight down, so every beam direction is
1998 // approximately (0,0,-1). Range noise must therefore scatter the hit points in z (along-beam) while leaving x and y
1999 // essentially unchanged. We also verify that zero noise reproduces the exact surface and that a fixed RNG seed is
2000 // reproducible.
2001
2002 // Target patch at z=0 (the surface we measure) plus a backing patch at z=-2 so the domain has a non-degenerate
2003 // bounding box along the beam axis (a single zero-thickness plane perpendicular to the beam is culled by the
2004 // ray-AABB pre-test). For single returns the nearer (z=0) patch is recorded, so the backing patch does not pollute
2005 // the measurement.
2007 context.addPatch(make_vec3(0, 0, 0), make_vec2(10, 10));
2008 context.addPatch(make_vec3(0, 0, -2), make_vec2(10, 10));
2009
2010 // Scanner directly above, looking down over a narrow cone about nadir (theta near pi => downward)
2011 vec3 scan_origin(0.0f, 0.0f, 5.0f);
2012 uint Ntheta = 40;
2013 uint Nphi = 40;
2014 float thetaMin = 0.9f * float(M_PI); // near-nadir cone so beams are ~(0,0,-1)
2015 float thetaMax = float(M_PI);
2016 float phiMin = 0.0f;
2017 float phiMax = 2.0f * float(M_PI);
2018 float exitDiameter = 0.0f;
2019 float beamDivergence = 0.0f;
2020 std::vector<std::string> columnFormat;
2021
2022 const float sigma_range = 0.05f; // 5 cm range noise standard deviation
2023
2024 // --- Baseline: zero noise should land exactly on the z=0 surface ---
2025 {
2026 LiDARcloud lidar_clean;
2027 lidar_clean.disableMessages();
2028 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
2029 DOCTEST_CHECK_NOTHROW(lidar_clean.addScan(scan));
2030 DOCTEST_CHECK_NOTHROW(lidar_clean.syntheticScan(&context));
2031
2032 uint hit_count = lidar_clean.getHitCount();
2033 DOCTEST_CHECK(hit_count > 0);
2034 uint target_hits = 0;
2035 for (uint i = 0; i < hit_count; i++) {
2036 vec3 p = lidar_clean.getHitXYZ(i);
2037 // Only the near (z=0) target surface should be hit by the near-nadir cone; assert it lands exactly there.
2038 DOCTEST_CHECK(fabs(p.z - 0.0f) < 1e-4f); // exact surface, no noise
2039 target_hits++;
2040 }
2041 DOCTEST_CHECK(target_hits > 0);
2042 }
2043
2044 // --- Noisy scan: scatter should be along-beam (z), not across-beam (x,y) ---
2045 // Collect only returns on the target surface (|z| < 1, well separated from the backing patch at z=-2).
2046 auto run_noisy = [&](uint seed, std::vector<vec3> &points) {
2047 LiDARcloud lidar_noisy;
2048 lidar_noisy.disableMessages();
2049 context.seedRandomGenerator(seed);
2050 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, sigma_range, 0.0f, columnFormat);
2051 lidar_noisy.addScan(scan);
2052 lidar_noisy.syntheticScan(&context);
2053 uint hit_count = lidar_noisy.getHitCount();
2054 points.clear();
2055 for (uint i = 0; i < hit_count; i++) {
2056 vec3 p = lidar_noisy.getHitXYZ(i);
2057 if (fabs(p.z) < 1.0f) {
2058 points.push_back(p);
2059 }
2060 }
2061 };
2062
2063 std::vector<vec3> points;
2064 run_noisy(12345u, points);
2065 DOCTEST_CHECK(points.size() > 0);
2066
2067 // Sample standard deviation of z (along-beam) and of the in-plane radius about the true surface.
2068 // For a flat z=0 surface scanned from directly above, the noise-free z is 0 and the noise-free (x,y) is fixed per
2069 // beam, so the spread of z directly reflects the injected range noise (projected by cos of the small off-nadir angle,
2070 // which is >= cos(0.03*pi) ~ 0.996, i.e. negligible).
2071 double z_mean = 0.0;
2072 for (const vec3 &p: points) {
2073 z_mean += p.z;
2074 }
2075 z_mean /= double(points.size());
2076
2077 double z_var = 0.0;
2078 for (const vec3 &p: points) {
2079 z_var += (p.z - z_mean) * (p.z - z_mean);
2080 }
2081 double z_std = std::sqrt(z_var / double(points.size()));
2082
2083 // The along-beam (z) scatter should be on the order of sigma_range (loose bounds for finite sample size).
2084 DOCTEST_CHECK(z_std > 0.5 * sigma_range);
2085 DOCTEST_CHECK(z_std < 2.0 * sigma_range);
2086
2087 // Cross-beam (anisotropy) check: the noise must displace points ALONG the beam (z), not across it (x,y). Run a clean
2088 // scan with identical parameters; the hit ordering matches beam-for-beam, so the per-point displacement between the
2089 // noisy and clean clouds isolates the injected error. For near-nadir beams that displacement should be almost entirely
2090 // in z. We assert the mean in-plane displacement is far smaller than the mean along-beam displacement.
2091 std::vector<vec3> clean_points;
2092 {
2093 LiDARcloud lidar_ref;
2094 lidar_ref.disableMessages();
2095 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
2096 lidar_ref.addScan(scan);
2097 lidar_ref.syntheticScan(&context);
2098 clean_points.clear();
2099 uint hc = lidar_ref.getHitCount();
2100 for (uint i = 0; i < hc; i++) {
2101 vec3 p = lidar_ref.getHitXYZ(i);
2102 if (fabs(p.z) < 1.0f) {
2103 clean_points.push_back(p);
2104 }
2105 }
2106 }
2107
2108 DOCTEST_REQUIRE(clean_points.size() == points.size());
2109 double mean_abs_dz = 0.0;
2110 double mean_abs_dxy = 0.0;
2111 for (size_t i = 0; i < points.size(); i++) {
2112 mean_abs_dz += fabs(double(points[i].z - clean_points[i].z));
2113 double dx = double(points[i].x - clean_points[i].x);
2114 double dy = double(points[i].y - clean_points[i].y);
2115 mean_abs_dxy += std::sqrt(dx * dx + dy * dy);
2116 }
2117 mean_abs_dz /= double(points.size());
2118 mean_abs_dxy /= double(points.size());
2119
2120 // Along-beam displacement should be substantial (~sigma); across-beam displacement should be tiny. For the near-nadir
2121 // cone (off-nadir <= 0.1*pi), sin(off-nadir) <= ~0.31, so the in-plane leakage is bounded but should be well under the
2122 // along-beam component. Require at least a 3x separation to demonstrate anisotropy.
2123 DOCTEST_CHECK(mean_abs_dz > 0.5 * sigma_range);
2124 DOCTEST_CHECK(mean_abs_dxy < mean_abs_dz);
2125 DOCTEST_CHECK(mean_abs_dxy < 0.34 * mean_abs_dz);
2126
2127 // --- Determinism: same seed reproduces identical points ---
2128 std::vector<vec3> points_repeat;
2129 run_noisy(12345u, points_repeat);
2130 DOCTEST_CHECK(points_repeat.size() == points.size());
2131 bool identical = true;
2132 for (size_t i = 0; i < points.size() && i < points_repeat.size(); i++) {
2133 if (fabs(points[i].z - points_repeat[i].z) > 1e-6f) {
2134 identical = false;
2135 break;
2136 }
2137 }
2138 DOCTEST_CHECK(identical);
2139
2140 // --- Different seed produces a different realization ---
2141 std::vector<vec3> points_other;
2142 run_noisy(99999u, points_other);
2143 bool any_different = false;
2144 for (size_t i = 0; i < points.size() && i < points_other.size(); i++) {
2145 if (fabs(points[i].z - points_other[i].z) > 1e-6f) {
2146 any_different = true;
2147 break;
2148 }
2149 }
2150 DOCTEST_CHECK(any_different);
2151}
2152
2153DOCTEST_TEST_CASE("LiDAR Synthetic Scan Range-Normalized Intensity Test") {
2154 // Helios reports RANGE-NORMALIZED intensity: I = rho*cos(theta) with the 1/R^2 range loss normalized out, so a
2155 // given surface returns the same intensity regardless of scanner-to-target range. We verify this by scanning the
2156 // same horizontal patch straight down (theta=0 => cos(theta)=1) from two different heights and asserting the
2157 // recorded intensity is (a) range-independent (equal at both ranges) and (b) equal to the primitive reflectivity.
2158
2159 const float rho = 0.45f; // leaf-like reflectivity in the laser waveband
2160
2161 // Scan straight down at nadir so the incidence angle is ~0 (cos(theta) ~ 1) and intensity reduces to rho.
2162 const uint Ntheta = 30;
2163 const uint Nphi = 30;
2164 const float thetaMin = 0.97f * float(M_PI); // narrow cone about nadir (theta near pi => downward)
2165 const float thetaMax = float(M_PI);
2166 const float phiMin = 0.0f;
2167 const float phiMax = 2.0f * float(M_PI);
2168 const float exitDiameter = 0.0f;
2169 const float beamDivergence = 0.0f;
2170 // reflectivity_lidar must be listed in the scan column format for the scanner to fold per-primitive
2171 // reflectivity into the recorded intensity (see syntheticScan()).
2172 std::vector<std::string> columnFormat = {"reflectivity_lidar"};
2173
2174 // Mean intensity over the near-nadir, on-target returns of one scan whose origin is at height z=scan_height.
2175 // The raw incidence-angle seed is the signed dot product beam.normal; for a downward beam on an upward-facing
2176 // patch this is ~ -1, so we report the magnitude (the physically meaningful return strength).
2177 auto mean_target_intensity = [&](float scan_height) -> float {
2179 // Target patch at z=0 plus a backing patch (non-degenerate bounding box along the beam axis; the nearer
2180 // z=0 patch is the recorded single return).
2181 uint target = context.addPatch(make_vec3(0, 0, 0), make_vec2(10, 10));
2182 context.addPatch(make_vec3(0, 0, -2), make_vec2(10, 10));
2183 context.setPrimitiveData(target, "reflectivity_lidar", rho);
2184
2185 LiDARcloud lidar;
2186 lidar.disableMessages();
2187 ScanMetadata scan(make_vec3(0, 0, scan_height), Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
2188 lidar.addScan(scan);
2189 lidar.syntheticScan(&context);
2190
2191 uint hit_count = lidar.getHitCount();
2192 DOCTEST_REQUIRE(hit_count > 0);
2193
2194 double sum = 0.0;
2195 uint n = 0;
2196 for (uint i = 0; i < hit_count; i++) {
2197 vec3 p = lidar.getHitXYZ(i);
2198 if (fabs(p.z - 0.0f) > 1e-3f) {
2199 continue; // keep only returns from the z=0 target surface
2200 }
2201 sum += fabs(lidar.getHitData(i, "intensity"));
2202 n++;
2203 }
2204 DOCTEST_REQUIRE(n > 0);
2205 return float(sum / double(n));
2206 };
2207
2208 const float intensity_near = mean_target_intensity(5.0f); // R ~ 5 m
2209 const float intensity_far = mean_target_intensity(20.0f); // R ~ 20 m (4x range)
2210
2211 // (a) Range-independence: a raw 1/R^2 signal would differ by ~16x between these ranges; normalized intensity must not.
2212 DOCTEST_CHECK(intensity_near == doctest::Approx(intensity_far).epsilon(0.02));
2213 // (b) At normal incidence the normalized intensity equals the primitive reflectivity rho.
2214 DOCTEST_CHECK(intensity_near == doctest::Approx(rho).epsilon(0.02));
2215
2216 // The static normalization helper is value-preserving (the synthetic intensity already carries no 1/R^2 loss).
2217 DOCTEST_CHECK(LiDARcloud::applyRangeIntensityCorrection(rho, 5.0f) == doctest::Approx(rho));
2218 DOCTEST_CHECK(LiDARcloud::applyRangeIntensityCorrection(rho, 20.0f) == doctest::Approx(rho));
2219}
2220
2221DOCTEST_TEST_CASE("LiDAR Synthetic Scan Reflectance (dB) Test") {
2222 // When "reflectance" is requested in the ASCII column format, the scanner records reflectance in decibels,
2223 // 10*log10(|intensity|), relative to a perfect Lambertian reflector at normal incidence (0 dB). We verify that
2224 // (a) reflectance is the dB transform of the recorded intensity, (b) it equals 10*log10(rho) at normal incidence,
2225 // and (c) like intensity it is range-independent. We also confirm reflectance is NOT recorded when not requested.
2226
2227 const float rho = 0.45f;
2228 const float expected_dB = 10.0f * log10f(rho); // ~ -3.47 dB
2229
2230 const uint Ntheta = 30;
2231 const uint Nphi = 30;
2232 const float thetaMin = 0.97f * float(M_PI);
2233 const float thetaMax = float(M_PI);
2234 const float phiMin = 0.0f;
2235 const float phiMax = 2.0f * float(M_PI);
2236
2237 // Build a scan at the given height, optionally requesting reflectance, and return the mean reflectance and the
2238 // count of hits that carry a "reflectance" data field, over the on-target (z=0) returns.
2239 auto scan_reflectance = [&](float scan_height, bool request_reflectance, uint &reflectance_field_count) -> float {
2241 uint target = context.addPatch(make_vec3(0, 0, 0), make_vec2(10, 10));
2242 context.addPatch(make_vec3(0, 0, -2), make_vec2(10, 10));
2243 context.setPrimitiveData(target, "reflectivity_lidar", rho);
2244
2245 std::vector<std::string> columnFormat = {"reflectivity_lidar"};
2246 if (request_reflectance) {
2247 columnFormat.push_back("reflectance");
2248 }
2249
2250 LiDARcloud lidar;
2251 lidar.disableMessages();
2252 ScanMetadata scan(make_vec3(0, 0, scan_height), Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
2253 lidar.addScan(scan);
2254 lidar.syntheticScan(&context);
2255
2256 uint hit_count = lidar.getHitCount();
2257 DOCTEST_REQUIRE(hit_count > 0);
2258
2259 double sum_dB = 0.0;
2260 uint n = 0;
2261 reflectance_field_count = 0;
2262 for (uint i = 0; i < hit_count; i++) {
2263 vec3 p = lidar.getHitXYZ(i);
2264 if (fabs(p.z - 0.0f) > 1e-3f) {
2265 continue; // on-target returns only
2266 }
2267 if (!lidar.doesHitDataExist(i, "reflectance")) {
2268 continue;
2269 }
2270 reflectance_field_count++;
2271 // reflectance must be the dB transform of this same hit's intensity
2272 double intensity = lidar.getHitData(i, "intensity");
2273 double reflectance = lidar.getHitData(i, "reflectance");
2274 DOCTEST_CHECK(reflectance == doctest::Approx(10.0 * log10(fabs(intensity))).epsilon(1e-4));
2275 sum_dB += reflectance;
2276 n++;
2277 }
2278 if (n == 0) {
2279 return 0.f;
2280 }
2281 return float(sum_dB / double(n));
2282 };
2283
2284 uint count_near = 0, count_far = 0, count_off = 0;
2285 const float dB_near = scan_reflectance(5.0f, true, count_near);
2286 const float dB_far = scan_reflectance(20.0f, true, count_far);
2287
2288 // (a)/(b) Reflectance equals 10*log10(rho) at normal incidence.
2289 DOCTEST_REQUIRE(count_near > 0);
2290 DOCTEST_CHECK(dB_near == doctest::Approx(expected_dB).epsilon(0.05));
2291 // (c) Range-independent, like the intensity it derives from.
2292 DOCTEST_CHECK(dB_near == doctest::Approx(dB_far).epsilon(0.05));
2293
2294 // Not requested => no reflectance field is recorded.
2295 scan_reflectance(5.0f, false, count_off);
2296 DOCTEST_CHECK(count_off == 0);
2297}
2298
2299DOCTEST_TEST_CASE("LiDAR Synthetic Scan Object Data Labeling Test") {
2300 // A non-standard column label is resolved from the hit primitive's primitive data first, then (on a miss) from
2301 // the primitive's parent-object data. This lets a synthetic-scan hit be labeled with a field carried only by the
2302 // parent compound object (e.g. a per-object classification). We scan a tile object straight down at nadir and
2303 // verify the object-data label is transferred onto the hits.
2304
2305 const uint Ntheta = 30;
2306 const uint Nphi = 30;
2307 const float thetaMin = 0.97f * float(M_PI); // narrow cone about nadir (downward)
2308 const float thetaMax = float(M_PI);
2309 const float phiMin = 0.0f;
2310 const float phiMax = 2.0f * float(M_PI);
2311
2312 const float object_value = 7.0f; // value carried only by the parent object
2313
2315 // A tile object is a planar tile subdivided into patch sub-primitives; the object carries the data label.
2316 uint objID = context.addTileObject(make_vec3(0, 0, 0), make_vec2(10, 10), nullrotation, make_int2(5, 5));
2317 context.addPatch(make_vec3(0, 0, -2), make_vec2(10, 10)); // backing patch for a non-degenerate bounding box
2318 context.setObjectData(objID, "object_field", object_value);
2319
2320 LiDARcloud lidar;
2321 lidar.disableMessages();
2322 std::vector<std::string> columnFormat = {"object_field"};
2323 ScanMetadata scan(make_vec3(0, 0, 5), Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
2324 lidar.addScan(scan);
2325 lidar.syntheticScan(&context);
2326
2327 uint hit_count = lidar.getHitCount();
2328 DOCTEST_REQUIRE(hit_count > 0);
2329
2330 uint n = 0;
2331 for (uint i = 0; i < hit_count; i++) {
2332 vec3 p = lidar.getHitXYZ(i);
2333 if (fabs(p.z - 0.0f) > 1e-3f) {
2334 continue; // keep only returns from the z=0 tile surface
2335 }
2336 DOCTEST_CHECK(lidar.getHitData(i, "object_field") == doctest::Approx(object_value));
2337 n++;
2338 }
2339 DOCTEST_REQUIRE(n > 0);
2340}
2341
2342DOCTEST_TEST_CASE("LiDAR Synthetic Scan Primitive-over-Object Data Precedence Test") {
2343 // When a column label exists as BOTH primitive data on the hit primitive and object data on its parent object,
2344 // the more specific per-primitive value must win. We set a different value at each level and verify the recorded
2345 // hit carries the primitive-data value.
2346
2347 const uint Ntheta = 30;
2348 const uint Nphi = 30;
2349 const float thetaMin = 0.97f * float(M_PI);
2350 const float thetaMax = float(M_PI);
2351 const float phiMin = 0.0f;
2352 const float phiMax = 2.0f * float(M_PI);
2353
2354 const float primitive_value = 3.0f;
2355 const float object_value = 9.0f;
2356
2358 uint objID = context.addTileObject(make_vec3(0, 0, 0), make_vec2(10, 10), nullrotation, make_int2(5, 5));
2359 context.addPatch(make_vec3(0, 0, -2), make_vec2(10, 10));
2360 context.setObjectData(objID, "shared_field", object_value);
2361 // Set the same label as primitive data on every sub-patch of the tile.
2362 std::vector<uint> tile_UUIDs = context.getObjectPrimitiveUUIDs(objID);
2363 context.setPrimitiveData(tile_UUIDs, "shared_field", primitive_value);
2364
2365 LiDARcloud lidar;
2366 lidar.disableMessages();
2367 std::vector<std::string> columnFormat = {"shared_field"};
2368 ScanMetadata scan(make_vec3(0, 0, 5), Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
2369 lidar.addScan(scan);
2370 lidar.syntheticScan(&context);
2371
2372 uint hit_count = lidar.getHitCount();
2373 DOCTEST_REQUIRE(hit_count > 0);
2374
2375 uint n = 0;
2376 for (uint i = 0; i < hit_count; i++) {
2377 vec3 p = lidar.getHitXYZ(i);
2378 if (fabs(p.z - 0.0f) > 1e-3f) {
2379 continue;
2380 }
2381 DOCTEST_CHECK(lidar.getHitData(i, "shared_field") == doctest::Approx(primitive_value));
2382 n++;
2383 }
2384 DOCTEST_REQUIRE(n > 0);
2385}
2386
2387DOCTEST_TEST_CASE("LiDAR Synthetic Scan Angular Jitter Test") {
2388 // Angular (beam-pointing) jitter should displace hit points ACROSS the beam (laterally), with a magnitude that grows
2389 // with range as approximately range * sigma_angle. This is the complement of range noise: for a flat horizontal target
2390 // scanned from directly above, jitter moves the hit in (x,y) while leaving z (the surface) essentially unchanged,
2391 // whereas range noise would move it in z. We verify the lateral scatter is present, scales with range, and dominates
2392 // the along-beam scatter; and that zero jitter reproduces the exact surface.
2393
2394 // Target patch at z=0 plus a backing patch at z=-2 for a non-degenerate bounding box (see range-noise test).
2396 context.addPatch(make_vec3(0, 0, 0), make_vec2(10, 10));
2397 context.addPatch(make_vec3(0, 0, -2), make_vec2(10, 10));
2398
2399 vec3 scan_origin(0.0f, 0.0f, 5.0f);
2400 uint Ntheta = 40;
2401 uint Nphi = 40;
2402 float thetaMin = 0.9f * float(M_PI); // near-nadir cone
2403 float thetaMax = float(M_PI);
2404 float phiMin = 0.0f;
2405 float phiMax = 2.0f * float(M_PI);
2406 float exitDiameter = 0.0f;
2407 float beamDivergence = 0.0f;
2408 float rangeNoise = 0.0f; // isolate angular jitter
2409 std::vector<std::string> columnFormat;
2410
2411 const float sigma_angle = 0.01f; // ~10 mrad pointing jitter
2412
2413 // Build a clean reference (no jitter): points lie exactly on z=0.
2414 std::vector<vec3> clean_points;
2415 {
2416 LiDARcloud lidar_ref;
2417 lidar_ref.disableMessages();
2418 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
2419 lidar_ref.addScan(scan);
2420 lidar_ref.syntheticScan(&context);
2421 uint hc = lidar_ref.getHitCount();
2422 DOCTEST_CHECK(hc > 0);
2423 for (uint i = 0; i < hc; i++) {
2424 vec3 p = lidar_ref.getHitXYZ(i);
2425 if (fabs(p.z) < 1.0f) {
2426 DOCTEST_CHECK(fabs(p.z) < 1e-4f); // exact surface, no jitter
2427 clean_points.push_back(p);
2428 }
2429 }
2430 }
2431
2432 // Jittered scan on the target surface.
2433 std::vector<vec3> points;
2434 {
2435 LiDARcloud lidar_jit;
2436 lidar_jit.disableMessages();
2437 context.seedRandomGenerator(2024u);
2438 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, rangeNoise, sigma_angle, columnFormat);
2439 lidar_jit.addScan(scan);
2440 lidar_jit.syntheticScan(&context);
2441 uint hc = lidar_jit.getHitCount();
2442 for (uint i = 0; i < hc; i++) {
2443 vec3 p = lidar_jit.getHitXYZ(i);
2444 if (fabs(p.z) < 1.0f) {
2445 points.push_back(p);
2446 }
2447 }
2448 }
2449
2450 DOCTEST_REQUIRE(points.size() == clean_points.size());
2451
2452 // Per-point displacement from the clean reference (same beam ordering): jitter should move points laterally (x,y),
2453 // not along the beam (z).
2454 double mean_abs_dz = 0.0;
2455 double mean_lateral = 0.0;
2456 for (size_t i = 0; i < points.size(); i++) {
2457 mean_abs_dz += fabs(double(points[i].z - clean_points[i].z));
2458 double dx = double(points[i].x - clean_points[i].x);
2459 double dy = double(points[i].y - clean_points[i].y);
2460 mean_lateral += std::sqrt(dx * dx + dy * dy);
2461 }
2462 mean_abs_dz /= double(points.size());
2463 mean_lateral /= double(points.size());
2464
2465 // Lateral displacement should scale as range*sigma_angle. The scanner is at z=5 above a z=0 plane, so range ~5 and the
2466 // expected lateral scale is ~5*0.01 = 0.05 m. Use loose bounds for finite sampling.
2467 const double expected_lateral = 5.0 * double(sigma_angle);
2468 DOCTEST_CHECK(mean_lateral > 0.3 * expected_lateral);
2469 DOCTEST_CHECK(mean_lateral < 3.0 * expected_lateral);
2470
2471 // Anisotropy: for jitter on a flat horizontal target the along-beam (z) component should stay far below the lateral
2472 // component (it is zero in the ideal flat-plane limit; small nonzero values arise only from the off-nadir cone).
2473 DOCTEST_CHECK(mean_abs_dz < 0.5 * mean_lateral);
2474
2475 // Determinism under a fixed seed.
2476 std::vector<vec3> repeat;
2477 {
2478 LiDARcloud lidar_rep;
2479 lidar_rep.disableMessages();
2480 context.seedRandomGenerator(2024u);
2481 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, rangeNoise, sigma_angle, columnFormat);
2482 lidar_rep.addScan(scan);
2483 lidar_rep.syntheticScan(&context);
2484 uint hc = lidar_rep.getHitCount();
2485 for (uint i = 0; i < hc; i++) {
2486 vec3 p = lidar_rep.getHitXYZ(i);
2487 if (fabs(p.z) < 1.0f) {
2488 repeat.push_back(p);
2489 }
2490 }
2491 }
2492 DOCTEST_REQUIRE(repeat.size() == points.size());
2493 bool identical = true;
2494 for (size_t i = 0; i < points.size(); i++) {
2495 if ((points[i] - repeat[i]).magnitude() > 1e-6f) {
2496 identical = false;
2497 break;
2498 }
2499 }
2500 DOCTEST_CHECK(identical);
2501}
2502
2503DOCTEST_TEST_CASE("LiDAR Multi-Return Gaussian Weighting Test") {
2504 LiDARcloud lidar;
2505 lidar.disableMessages();
2506
2507 // Add scan programmatically for explicit control
2508 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
2509 uint Ntheta = 6000;
2510 uint Nphi = 12000;
2511 float thetaMin = 0.0f;
2512 float thetaMax = M_PI;
2513 float phiMin = 0.0f;
2514 float phiMax = 2.0f * M_PI;
2515 float exitDiameter = 0.0f;
2516 float beamDivergence = 0.0f;
2517 std::vector<std::string> columnFormat;
2518
2519 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
2520 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
2521
2522 // Add grid programmatically
2523 vec3 grid_center(0.0f, 0.0f, 0.5f);
2524 vec3 grid_size(1.0f, 1.0f, 1.0f);
2525 int3 grid_divisions = make_int3(1, 1, 1);
2526 DOCTEST_CHECK_NOTHROW(lidar.addGrid(grid_center, grid_size, grid_divisions, 0));
2527
2528 vec3 gsize = lidar.getCellSize(0);
2529
2531 std::vector<uint> UUIDs = context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
2532
2533 float LAD_exact = 0.f;
2534 for (uint UUID: UUIDs) {
2535 LAD_exact += context.getPrimitiveArea(UUID) / (gsize.x * gsize.y * gsize.z);
2536 }
2537
2538 // Calculate exact G(theta) from primitive geometry
2539 float Gtheta_exact_numerator = 0.f;
2540 float Gtheta_exact_denominator = 0.f;
2541 for (uint UUID: UUIDs) {
2542 float area = context.getPrimitiveArea(UUID);
2543 vec3 normal = context.getPrimitiveNormal(UUID);
2544 std::vector<vec3> vertices = context.getPrimitiveVertices(UUID);
2545 vec3 raydir = vertices.front() - scan_origin;
2546 raydir.normalize();
2547
2548 if (area == area) { // Check for NaN
2549 float normal_dot_ray = fabs(normal * raydir);
2550 Gtheta_exact_numerator += normal_dot_ray * area;
2551 Gtheta_exact_denominator += area;
2552 }
2553 }
2554 float Gtheta_exact = 0.f;
2555 if (Gtheta_exact_denominator > 0) {
2556 Gtheta_exact = Gtheta_exact_numerator / Gtheta_exact_denominator;
2557 }
2558
2559 // Multi-return test with scan_grid_only=true to limit miss recording to voxel region
2560 // Test BOTH scan_grid_only modes to verify they give same result
2561 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 2, 0.1f, true, true));
2562 uint hits_grid_true = lidar.getHitCount();
2563
2564 DOCTEST_CHECK_NOTHROW(lidar.triangulateHitPoints(0.04, 10));
2565 uint triangles_multi = lidar.getTriangleCount();
2566
2567 DOCTEST_CHECK_NOTHROW(lidar.calculateLeafArea(&context));
2568 float LAD_grid_true = lidar.getCellLeafAreaDensity(0);
2569 float G_grid_true = lidar.getCellGtheta(0);
2570
2571 // Test scan_grid_only=FALSE
2572 LiDARcloud lidar2;
2573 lidar2.disableMessages();
2574
2575 // Add scan and grid programmatically for second test
2576 DOCTEST_CHECK_NOTHROW(lidar2.addScan(scan));
2577 DOCTEST_CHECK_NOTHROW(lidar2.addGrid(grid_center, grid_size, grid_divisions, 0));
2578
2579 DOCTEST_CHECK_NOTHROW(lidar2.syntheticScan(&context, 2, 0.1f, false, true));
2580 uint hits_grid_false = lidar2.getHitCount();
2581
2582 DOCTEST_CHECK_NOTHROW(lidar2.triangulateHitPoints(0.04, 10));
2583 DOCTEST_CHECK_NOTHROW(lidar2.calculateLeafArea(&context));
2584 float LAD_grid_false = lidar2.getCellLeafAreaDensity(0);
2585 float G_grid_false = lidar2.getCellGtheta(0);
2586
2587 // Use scan_grid_only=TRUE result as the main test (it's faster)
2588 float LAD_multi = LAD_grid_true;
2589 float Gtheta_multi = G_grid_true;
2590
2591 // Verify both scan_grid_only modes give same result
2592 DOCTEST_CHECK(fabs(LAD_grid_true - LAD_grid_false) < 0.01f);
2593 DOCTEST_CHECK(fabs(G_grid_true - G_grid_false) < 0.01f);
2594
2595 // Compare with single-return scan using same scan parameters
2596 LiDARcloud lidar_single;
2597 lidar_single.disableMessages();
2598 DOCTEST_CHECK_NOTHROW(lidar_single.addScan(scan));
2599 DOCTEST_CHECK_NOTHROW(lidar_single.addGrid(grid_center, grid_size, grid_divisions, 0));
2600 // Use same scan_grid_only and record_misses settings as multi-return
2601 DOCTEST_CHECK_NOTHROW(lidar_single.syntheticScan(&context, true, true));
2602 uint hits_single = lidar_single.getHitCount();
2603
2604 DOCTEST_CHECK_NOTHROW(lidar_single.triangulateHitPoints(0.04, 10));
2605
2606 DOCTEST_CHECK_NOTHROW(lidar_single.calculateLeafArea(&context));
2607 float LAD_single = lidar_single.getCellLeafAreaDensity(0);
2608 float G_single = lidar_single.getCellGtheta(0);
2609
2610 // Multi-return Gaussian weighting should match expected LAD within 2%
2611 DOCTEST_CHECK(LAD_multi > LAD_exact * 0.98f);
2612 DOCTEST_CHECK(LAD_multi < LAD_exact * 1.02f);
2613
2614 // Check G(theta) against exact value calculated from primitives
2615 DOCTEST_CHECK(Gtheta_multi == Gtheta_multi); // Check for NaN
2616 DOCTEST_CHECK(fabs(Gtheta_multi - Gtheta_exact) / Gtheta_exact == doctest::Approx(0.0f).epsilon(0.05f));
2617}
2618
2619DOCTEST_TEST_CASE("LiDAR Eight Voxel Multi-Return Gaussian Weighting Test") {
2620 // Test multi-return LiDAR with 8-voxel grid (2x2x2)
2621 // Validates the Gaussian-footprint-weighting algorithm handles partial occlusion correctly. Leaf-area density (RMSE)
2622 // is conserved; the leaf-angle G(theta) inversion is more sensitive because energy weighting places a return that
2623 // merges sub-rays across surfaces at the energy-weighted centroid rather than the geometric midpoint, so its
2624 // tolerance is looser than the area tolerance.
2625
2626 LiDARcloud synthetic_mr8;
2627 synthetic_mr8.disableMessages();
2628
2629 // Add scan programmatically with beam spreading for true multi-return
2630 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
2631 uint Ntheta = 10000;
2632 uint Nphi = 14000;
2633 float thetaMin = 0.0f;
2634 float thetaMax = M_PI;
2635 float phiMin = 0.0f;
2636 float phiMax = 2.0f * M_PI;
2637 float exitDiameter = 0.0f; // Point source for backward compatibility
2638 float beamDivergence = 0.0004f;
2639 std::vector<std::string> columnFormat;
2640
2641 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
2642 DOCTEST_CHECK_NOTHROW(synthetic_mr8.addScan(scan));
2643 synthetic_mr8.setScanDetectionThreshold(0, 0.f); // LAD-inversion validation needs complete returns; the noise floor (default 0.05) is a separate sensor-modeling concern
2644
2645 // Add grid programmatically
2646 vec3 grid_center(0.0f, 0.0f, 0.5f);
2647 vec3 grid_size(1.0f, 1.0f, 1.0f);
2648 int3 grid_divisions = make_int3(2, 2, 2);
2649 DOCTEST_CHECK_NOTHROW(synthetic_mr8.addGrid(grid_center, grid_size, grid_divisions, 0));
2650
2651 vec3 gsize = synthetic_mr8.getCellSize(0);
2652
2653 Context context_mr8;
2654 context_mr8.seedRandomGenerator(0); // Seed for reproducible random perturbations
2655 std::vector<uint> UUIDs = context_mr8.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
2656
2657 // Calculate expected LAD for each of 8 voxels based on primitive positions
2658 std::vector<float> LAD_ex(8, 0);
2659 std::vector<float> Gtheta_ex(8, 0);
2660 std::vector<float> Gtheta_ex_numerator(8, 0);
2661 std::vector<float> Gtheta_ex_denominator(8, 0);
2662
2663 for (uint UUID: UUIDs) {
2664 int i, j, k;
2665 i = j = k = 0;
2666 vec3 v = context_mr8.getPrimitiveVertices(UUID).front();
2667 if (v.x > 0.f) {
2668 i = 1;
2669 }
2670 if (v.y > 0.f) {
2671 j = 1;
2672 }
2673 if (v.z > 0.5f) {
2674 k = 1;
2675 }
2676 int ID = k * 4 + j * 2 + i;
2677
2678 float area = context_mr8.getPrimitiveArea(UUID);
2679 LAD_ex.at(ID) += area / (gsize.x * gsize.y * gsize.z);
2680
2681 // Calculate exact G(theta) from primitive geometry for each voxel
2682 vec3 normal = context_mr8.getPrimitiveNormal(UUID);
2683 std::vector<vec3> vertices = context_mr8.getPrimitiveVertices(UUID);
2684 vec3 raydir = vertices.front() - scan_origin;
2685 raydir.normalize();
2686
2687 if (area == area) { // Check for NaN
2688 float normal_dot_ray = fabs(normal * raydir);
2689 Gtheta_ex_numerator.at(ID) += normal_dot_ray * area;
2690 Gtheta_ex_denominator.at(ID) += area;
2691 }
2692 }
2693
2694 // Compute final G(theta) values for each voxel
2695 for (int i = 0; i < 8; i++) {
2696 if (Gtheta_ex_denominator[i] > 0) {
2697 Gtheta_ex[i] = Gtheta_ex_numerator[i] / Gtheta_ex_denominator[i];
2698 }
2699 }
2700
2701 // Multi-return scan with realistic beam spreading (100 rays per pulse for stable statistics)
2702 DOCTEST_CHECK_NOTHROW(synthetic_mr8.syntheticScan(&context_mr8, 100, 0.1f, true, true));
2703 uint hits_grid_true = synthetic_mr8.getHitCount();
2704
2705 // Check if we're actually getting multiple returns per pulse
2706 uint multi_return_count = 0;
2707 for (uint i = 0; i < hits_grid_true; i++) {
2708 if (synthetic_mr8.doesHitDataExist(i, "target_count") && synthetic_mr8.getHitData(i, "target_count") > 1) {
2709 multi_return_count++;
2710 }
2711 }
2712
2713 // Triangulate using base overload - first returns automatically filtered for multi-return data
2714 DOCTEST_CHECK_NOTHROW(synthetic_mr8.triangulateHitPoints(0.04, 10));
2715 DOCTEST_CHECK(synthetic_mr8.getTriangleCount() > 0);
2716 DOCTEST_CHECK_NOTHROW(synthetic_mr8.calculateLeafArea(&context_mr8));
2717
2718 std::vector<float> LAD_grid_true(8);
2719 std::vector<float> G_grid_true(8);
2720 for (int i = 0; i < 8; i++) {
2721 LAD_grid_true[i] = synthetic_mr8.getCellLeafAreaDensity(i);
2722 G_grid_true[i] = synthetic_mr8.getCellGtheta(i);
2723 }
2724
2725 // (Removed dual scan_grid_only testing for now - simplify to match working test)
2726
2727 // Verify multi-return data fields exist
2728 bool has_target_index = true;
2729 bool has_target_count = true;
2730 bool has_timestamp = true;
2731
2732 for (uint i = 0; i < hits_grid_true; i++) {
2733 if (!synthetic_mr8.doesHitDataExist(i, "target_index"))
2734 has_target_index = false;
2735 if (!synthetic_mr8.doesHitDataExist(i, "target_count"))
2736 has_target_count = false;
2737 if (!synthetic_mr8.doesHitDataExist(i, "timestamp"))
2738 has_timestamp = false;
2739 }
2740
2741 DOCTEST_CHECK(has_target_index);
2742 DOCTEST_CHECK(has_target_count);
2743 DOCTEST_CHECK(has_timestamp);
2744
2745 // Check for duplicate first returns per timestamp (critical bug check)
2746 std::map<int, int> timestamp_first_return_count;
2747 for (uint i = 0; i < hits_grid_true; i++) {
2748 if (synthetic_mr8.doesHitDataExist(i, "target_index") && synthetic_mr8.doesHitDataExist(i, "timestamp")) {
2749 int tidx = static_cast<int>(synthetic_mr8.getHitData(i, "target_index"));
2750 int tstamp = static_cast<int>(synthetic_mr8.getHitData(i, "timestamp"));
2751 if (tidx == 0) {
2752 timestamp_first_return_count[tstamp]++;
2753 }
2754 }
2755 }
2756
2757 for (const auto &pair: timestamp_first_return_count) {
2758 DOCTEST_CHECK(pair.second == 1);
2759 }
2760
2761 // Validate LAD accuracy using RMSE across all 8 voxels
2762 float RMSE = 0.f;
2763 for (int i = 0; i < 8; i++) {
2764 float LAD = LAD_grid_true[i];
2765 RMSE += powf(LAD - LAD_ex.at(i), 2) / LAD_ex.at(i) / 8.0f;
2766 }
2767 RMSE = sqrtf(RMSE);
2768
2769 // Tolerance widened (0.1 -> 0.15) for the stratified importance-sampled Gaussian footprint sampler. The previous
2770 // sampler hard-truncated each beam at its 1/e^2 radius, discarding the ~13% wing energy; the new sampler draws from
2771 // the Gaussian wings too (this scan sets no detection threshold, so sampling is truncated only at the default ~1.86
2772 // 1/e^2 radii), so the modeled beam is physically wider. A wider footprint spreads returns slightly across the fine
2773 // 0.5 m voxel boundaries, raising the per-voxel LAD RMSE from ~0.11 to ~0.13 at this 100-ray configuration (stable
2774 // across RNG seeds). This is an intended consequence of modeling the beam wings, not a regression: total area is
2775 // still approximately conserved and the reconstruction remains accurate to ~13% RMS.
2776 DOCTEST_CHECK(RMSE == doctest::Approx(0.0f).epsilon(0.15f));
2777
2778 // Validate G(theta) against exact values calculated from primitive geometry
2779 for (int i = 0; i < 8; i++) {
2780 float G = G_grid_true[i];
2781 float G_exact = Gtheta_ex[i];
2782
2783 // Only check voxels with non-zero LAD
2784 if (LAD_grid_true[i] > 0.001f && G_exact > 0) {
2785 DOCTEST_CHECK(G == G); // Check for NaN
2786 DOCTEST_CHECK(G_exact == G_exact); // Check for NaN
2787 DOCTEST_CHECK(fabs(G - G_exact) / G_exact == doctest::Approx(0.0f).epsilon(0.1f));
2788 }
2789 }
2790}
2791
2792DOCTEST_TEST_CASE("LiDAR LAD DDA vs Brute-Force Equivalence") {
2793 // The leaf-area inversion has two code paths that must produce identical results: the fast per-beam
2794 // 3D-DDA traversal (used for regular addGrid() lattices) and the brute-force per-cell slab loop
2795 // (fallback for non-lattice grids). forceBruteForceLeafArea() forces the latter so we can A/B them
2796 // on identical input. Every per-cell output (leaf area, G(theta), beam count, LAD variance) must match
2797 // to floating-point tolerance. Several grid geometries are exercised: cubic multi-cell, non-cubic with
2798 // an off-center origin, a single-return scan, and a multi-return scan.
2799
2800 auto run_pair = [](int3 divisions, const vec3 &grid_center, const vec3 &grid_size, bool multi_return) {
2801 // Build identical clouds twice and invert one with DDA, the other forced to brute-force.
2802 auto build_and_invert = [&](bool force_bruteforce, std::vector<float> &leaf_area, std::vector<float> &gtheta, std::vector<int> &beam_count, std::vector<float> &lad_var) {
2803 LiDARcloud lidar;
2804 lidar.disableMessages();
2805
2806 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
2807 ScanMetadata scan(scan_origin, 4000, 0.0f, M_PI, 6000, 0.0f, 2.0f * M_PI, 0.0f, multi_return ? 0.0004f : 0.0f, 0.0f, 0.0f, std::vector<std::string>{});
2808 lidar.addScan(scan);
2809 lidar.addGrid(grid_center, grid_size, divisions, 0);
2810
2812 context.seedRandomGenerator(0);
2813 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
2814
2815 if (multi_return) {
2816 lidar.syntheticScan(&context, 30, 0.1f, true, true);
2817 } else {
2818 lidar.syntheticScan(&context, true, true); // scan_grid_only, record_misses
2819 }
2820 lidar.triangulateHitPoints(0.04, 10);
2821
2822 lidar.forceBruteForceLeafArea(force_bruteforce);
2823 lidar.calculateLeafArea(&context);
2824
2825 uint Ncells = lidar.getGridCellCount();
2826 leaf_area.resize(Ncells);
2827 gtheta.resize(Ncells);
2828 beam_count.resize(Ncells);
2829 lad_var.resize(Ncells);
2830 for (uint c = 0; c < Ncells; c++) {
2831 leaf_area[c] = lidar.getCellLeafArea(c);
2832 gtheta[c] = lidar.getCellGtheta(c);
2833 beam_count[c] = lidar.getCellBeamCount(c);
2834 lad_var[c] = lidar.getCellLADVariance(c);
2835 }
2836 };
2837
2838 std::vector<float> la_dda, g_dda, var_dda;
2839 std::vector<int> bc_dda;
2840 std::vector<float> la_bf, g_bf, var_bf;
2841 std::vector<int> bc_bf;
2842 build_and_invert(false, la_dda, g_dda, bc_dda, var_dda); // fast DDA path
2843 build_and_invert(true, la_bf, g_bf, bc_bf, var_bf); // forced brute-force path
2844
2845 DOCTEST_REQUIRE(la_dda.size() == la_bf.size());
2846 for (size_t c = 0; c < la_dda.size(); c++) {
2847 DOCTEST_CHECK_MESSAGE(la_dda[c] == doctest::Approx(la_bf[c]).epsilon(1e-4f), "leaf_area mismatch in cell " << c);
2848 DOCTEST_CHECK_MESSAGE(g_dda[c] == doctest::Approx(g_bf[c]).epsilon(1e-4f), "Gtheta mismatch in cell " << c);
2849 DOCTEST_CHECK_MESSAGE(bc_dda[c] == bc_bf[c], "beam_count mismatch in cell " << c << " (" << bc_dda[c] << " vs " << bc_bf[c] << ")");
2850 // LAD_variance is -1 for under-sampled cells; compare exactly there, approx otherwise.
2851 if (var_dda[c] < 0.f || var_bf[c] < 0.f) {
2852 DOCTEST_CHECK_MESSAGE(var_dda[c] == var_bf[c], "LAD_variance sign mismatch in cell " << c);
2853 } else {
2854 DOCTEST_CHECK_MESSAGE(var_dda[c] == doctest::Approx(var_bf[c]).epsilon(1e-3f), "LAD_variance mismatch in cell " << c);
2855 }
2856 }
2857 };
2858
2859 // Cubic 2x2x2 grid, single return
2860 run_pair(make_int3(2, 2, 2), vec3(0.0f, 0.0f, 0.5f), vec3(1.0f, 1.0f, 1.0f), false);
2861 // Cubic 2x2x2 grid, multi return
2862 run_pair(make_int3(2, 2, 2), vec3(0.0f, 0.0f, 0.5f), vec3(1.0f, 1.0f, 1.0f), true);
2863 // Non-cubic grid with non-uniform divisions and a larger extent (cells are not cubes)
2864 run_pair(make_int3(3, 2, 4), vec3(0.0f, 0.0f, 0.5f), vec3(1.5f, 1.0f, 1.2f), false);
2865}
2866
2867DOCTEST_TEST_CASE("LiDAR LAD Non-Lattice Fallback") {
2868 // A grid assembled cell-by-cell with mismatched cell sizes does NOT form a regular lattice, so the
2869 // inversion must use the brute-force fallback. Verify it runs and produces finite results, and that
2870 // forcing brute-force explicitly gives the same answer (the DDA path should not engage here at all).
2871
2872 auto build = [](bool force_bruteforce, std::vector<float> &leaf_area) {
2873 LiDARcloud lidar;
2874 lidar.disableMessages();
2875
2876 ScanMetadata scan(vec3(-5.0f, 0.0f, 0.5f), 3000, 0.0f, M_PI, 5000, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>{});
2877 lidar.addScan(scan);
2878
2879 // Two manually-added cells of DIFFERENT sizes -> not a regular lattice (global_count defaults to 1x1x1
2880 // for each, so total cell count != product, and sizes differ).
2881 lidar.addGridCell(vec3(-0.25f, 0.0f, 0.5f), vec3(0.5f, 1.0f, 1.0f), 0.f);
2882 lidar.addGridCell(vec3(0.35f, 0.0f, 0.5f), vec3(0.7f, 1.0f, 1.0f), 0.f);
2883
2885 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
2886 lidar.syntheticScan(&context, true, true);
2887 lidar.triangulateHitPoints(0.04, 10);
2888
2889 lidar.forceBruteForceLeafArea(force_bruteforce);
2890 lidar.calculateLeafArea(&context);
2891
2892 leaf_area.resize(lidar.getGridCellCount());
2893 for (uint c = 0; c < lidar.getGridCellCount(); c++) {
2894 leaf_area[c] = lidar.getCellLeafArea(c);
2895 DOCTEST_CHECK(leaf_area[c] == leaf_area[c]); // not NaN
2896 }
2897 };
2898
2899 std::vector<float> la_auto, la_forced;
2900 build(false, la_auto); // detection should select fallback automatically
2901 build(true, la_forced); // explicitly forced
2902 DOCTEST_REQUIRE(la_auto.size() == la_forced.size());
2903 for (size_t c = 0; c < la_auto.size(); c++) {
2904 DOCTEST_CHECK(la_auto[c] == doctest::Approx(la_forced[c]).epsilon(1e-4f));
2905 }
2906}
2907
2908DOCTEST_TEST_CASE("LiDAR Beam Perturbation - Single Return with Sphere") {
2909 // Test 1: Single-return mode should give hits equal to scan pattern size
2910 LiDARcloud lidar;
2911 lidar.disableMessages();
2912
2913 // Create simple scan pattern (small grid to keep test fast)
2914 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
2915 uint Ntheta = 200;
2916 uint Nphi = 400;
2917 float thetaMin = 1.45f;
2918 float thetaMax = 1.69f;
2919 float phiMin = 0.0f;
2920 float phiMax = 6.28f;
2921 float exitDiameter = 0.0f;
2922 float beamDivergence = 0.0f;
2923 std::vector<std::string> columnFormat;
2924
2925 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
2926 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
2927
2928 // Add grid programmatically
2929 vec3 grid_center(0.0f, 0.0f, 0.5f);
2930 vec3 grid_size(1.0f, 1.0f, 1.0f);
2931 int3 grid_divisions = make_int3(1, 1, 1);
2932 DOCTEST_CHECK_NOTHROW(lidar.addGrid(grid_center, grid_size, grid_divisions, 0));
2933
2934 uint expected_rays = Ntheta * Nphi;
2935
2937 // Add large sphere (radius >> voxel size) to catch all rays - use low subdivision for speed
2938 vec3 sphere_center(0, 0, 0.5);
2939 float sphere_radius = 50.0f;
2940 context.addSphereObject(6, sphere_center, sphere_radius); // Low subdivision for speed
2941
2942 // Single-return scan (rays_per_pulse=1, small threshold to avoid merging)
2943 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 1, 0.001f));
2944
2945 uint hit_count = lidar.getHitCount();
2946
2947 DOCTEST_CHECK(hit_count == expected_rays);
2948}
2949
2950DOCTEST_TEST_CASE("LiDAR Beam Perturbation - Multi Return Zero Beam Width") {
2951 // Test 2: Multi-return with exitDiameter=beamDivergence=0 should behave like single-return
2952 LiDARcloud lidar;
2953 lidar.disableMessages();
2954
2955 // Create scan pattern programmatically
2956 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
2957 uint Ntheta = 2000;
2958 uint Nphi = 4000;
2959 float thetaMin = 0.0f; // Default when not specified in XML
2960 float thetaMax = M_PI; // Default when not specified in XML
2961 float phiMin = 0.0f; // Default when not specified in XML
2962 float phiMax = 2.0f * M_PI; // Default when not specified in XML
2963 float exitDiameter = 0.0f;
2964 float beamDivergence = 0.0f;
2965 std::vector<std::string> columnFormat;
2966
2967 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
2968 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
2969
2970 // Add grid programmatically
2971 vec3 grid_center(0.0f, 0.0f, 0.5f);
2972 vec3 grid_size(1.0f, 1.0f, 1.0f);
2973 int3 grid_divisions = make_int3(1, 1, 1);
2974 DOCTEST_CHECK_NOTHROW(lidar.addGrid(grid_center, grid_size, grid_divisions, 0));
2975
2976 uint expected_rays = Ntheta * Nphi;
2977
2979 vec3 sphere_center(0, 0, 0.5);
2980 float sphere_radius = 50.0f;
2981 context.addSphereObject(6, sphere_center, sphere_radius); // Low subdivision for speed
2982
2983 // Multi-return with rays_per_pulse=2, but zero beam width (should merge)
2984 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 2, 0.001f));
2985
2986 uint hit_count = lidar.getHitCount();
2987
2988 // With zero beam parameters, all rays should merge to give same count as single-return
2989 DOCTEST_CHECK(hit_count == expected_rays);
2990}
2991
2992DOCTEST_TEST_CASE("LiDAR Beam Perturbation - Multi Return Miss Recording") {
2993 // Test 3: Multi-return with no geometry should record misses correctly
2994 LiDARcloud lidar;
2995 lidar.disableMessages();
2996
2997 // Create scan pattern programmatically
2998 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
2999 uint Ntheta = 2000;
3000 uint Nphi = 4000;
3001 float thetaMin = 0.0f; // Default when not specified in XML
3002 float thetaMax = M_PI; // Default when not specified in XML
3003 float phiMin = 0.0f; // Default when not specified in XML
3004 float phiMax = 2.0f * M_PI; // Default when not specified in XML
3005 float exitDiameter = 0.0f;
3006 float beamDivergence = 0.0f;
3007 std::vector<std::string> columnFormat;
3008
3009 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
3010 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
3011
3012 // Add grid programmatically
3013 vec3 grid_center(0.0f, 0.0f, 0.5f);
3014 vec3 grid_size(1.0f, 1.0f, 1.0f);
3015 int3 grid_divisions = make_int3(1, 1, 1);
3016 DOCTEST_CHECK_NOTHROW(lidar.addGrid(grid_center, grid_size, grid_divisions, 0));
3017
3018 uint expected_rays = Ntheta * Nphi;
3019
3021 // No geometry - all rays should miss
3022
3023 // Multi-return with record_misses=true and rays_per_pulse=2
3024 // Multiple misses from same pulse should merge together
3025 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 2, 0.1f, false, true));
3026
3027 uint hit_count = lidar.getHitCount();
3028
3029 // All rays miss, but rays from same pulse merge, so count should equal expected_rays
3030 DOCTEST_CHECK(hit_count == expected_rays);
3031
3032 // Verify all hits are actually misses (large distance)
3033 bool all_misses = true;
3034 for (uint i = 0; i < hit_count; i++) {
3035 if (lidar.doesHitDataExist(i, "distance")) {
3036 float dist = lidar.getHitData(i, "distance");
3037 if (dist < 1000.0f) {
3038 all_misses = false;
3039 break;
3040 }
3041 }
3042 }
3043 DOCTEST_CHECK(all_misses);
3044}
3045
3046DOCTEST_TEST_CASE("LiDAR Multi-Return with Beam Spreading") {
3047 // Test multi-return with realistic beam parameters (exitDiameter>0, beamDivergence>0)
3048 LiDARcloud lidar;
3049 lidar.disableMessages();
3050
3051 // Add scan programmatically for explicit control
3052 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
3053 uint Ntheta = 10000;
3054 uint Nphi = 12000;
3055 float thetaMin = 0.0f; // Default when not specified in XML
3056 float thetaMax = M_PI; // Default when not specified in XML
3057 float phiMin = 0.0f; // Default when not specified in XML
3058 float phiMax = 2.0f * M_PI; // Default when not specified in XML
3059 float exitDiameter = 0.0f; // Point source for backward compatibility
3060 float beamDivergence = 0.0004f;
3061 std::vector<std::string> columnFormat;
3062
3063 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
3064 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
3065 lidar.setScanDetectionThreshold(0, 0.f); // LAD-inversion validation needs complete returns; the noise floor (default 0.05) would drop weak returns and bias leaf area low
3066
3067 // Add grid programmatically
3068 vec3 grid_center(0.0f, 0.0f, 0.5f);
3069 vec3 grid_size(1.0f, 1.0f, 1.0f);
3070 int3 grid_divisions = make_int3(1, 1, 1);
3071 DOCTEST_CHECK_NOTHROW(lidar.addGrid(grid_center, grid_size, grid_divisions, 0));
3072
3073 vec3 gsize = lidar.getCellSize(0);
3074
3076 std::vector<uint> UUIDs = context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
3077
3078 float LAD_exact = 0.f;
3079 for (uint UUID: UUIDs) {
3080 LAD_exact += context.getPrimitiveArea(UUID) / (gsize.x * gsize.y * gsize.z);
3081 }
3082
3083 // Multi-return with beam spreading (rays should NOT merge)
3084 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 50, 0.1f, true, true)); // rays_per_pulse=50 for stable statistics
3085
3086 uint hit_count = lidar.getHitCount();
3087
3088 // Check for duplicate target_index=0 per timestamp (would be a bug!)
3089 std::map<int, int> timestamp_first_return_count;
3090 for (uint i = 0; i < hit_count; i++) {
3091 if (lidar.doesHitDataExist(i, "target_index") && lidar.doesHitDataExist(i, "timestamp")) {
3092 int tidx = static_cast<int>(lidar.getHitData(i, "target_index"));
3093 int tstamp = static_cast<int>(lidar.getHitData(i, "timestamp"));
3094 if (tidx == 0) {
3095 timestamp_first_return_count[tstamp]++;
3096 }
3097 }
3098 }
3099 int timestamps_with_multi_first = 0;
3100 for (auto &pair: timestamp_first_return_count) {
3101 if (pair.second > 1)
3102 timestamps_with_multi_first++;
3103 }
3104
3105 // Triangulate using base overload - first returns automatically filtered (use aspect_ratio=5 to filter sliver triangles from beam spreading)
3106 DOCTEST_CHECK_NOTHROW(lidar.triangulateHitPoints(0.04, 5));
3107 DOCTEST_CHECK(lidar.getTriangleCount() > 0);
3108
3109 DOCTEST_CHECK_NOTHROW(lidar.calculateLeafArea(&context));
3110
3111 float LAD = lidar.getCellLeafAreaDensity(0);
3112 float Gtheta = lidar.getCellGtheta(0);
3113
3114 // With beam spreading, we should get MORE hits than zero-width case (~2x)
3115 // But LAD should still be accurate
3116 DOCTEST_CHECK(LAD > LAD_exact * 0.9f);
3117 DOCTEST_CHECK(LAD < LAD_exact * 1.1f);
3118
3119 // G(theta) should be close to 0.5 for a spherical leaf-angle distribution. The band is slightly wider than the
3120 // ideal +/-0.05 because Gaussian footprint weighting places returns that merge sub-rays across surfaces at the
3121 // energy-weighted centroid, shifting the leaf-angle inversion by a few percent relative to equal weighting.
3122 DOCTEST_CHECK(Gtheta > 0.42f);
3123 DOCTEST_CHECK(Gtheta < 0.58f);
3124}
3125
3126DOCTEST_TEST_CASE("LiDAR Exit Diameter - Comparative Spread Test") {
3127 // Verify that exitDiameter > 0 produces wider spatial spread than exitDiameter = 0
3129 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
3130
3131 vec3 scan_origin(0, 0, 5.0f);
3132
3133 // The beam-footprint sub-rays are drawn from the Context RNG, so seed it before each scan with
3134 // the SAME seed. This makes both scans use an identical sampling realization, isolating the
3135 // effect of the exit diameter from RNG noise: the comparison is now deterministic and the
3136 // bounding-box extent (driven by extreme outliers) no longer flakes across runs/platforms.
3137 const uint scan_seed = 2024u;
3138
3139 // Scan WITHOUT exit diameter (point source)
3140 LiDARcloud lidar_point;
3141 lidar_point.disableMessages();
3142 ScanMetadata scan_point(scan_origin, 100, 0, M_PI, 100, 0, 2 * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, {});
3143 lidar_point.addScan(scan_point);
3144 context.seedRandomGenerator(scan_seed);
3145 lidar_point.syntheticScan(&context, 50, 0.05f, false, false);
3146
3147 // Scan WITH exit diameter
3148 LiDARcloud lidar_exit;
3149 lidar_exit.disableMessages();
3150 ScanMetadata scan_exit(scan_origin, 100, 0, M_PI, 100, 0, 2 * M_PI, 0.1f, 0.0f, 0.0f, 0.0f, {});
3151 lidar_exit.addScan(scan_exit);
3152 context.seedRandomGenerator(scan_seed);
3153 lidar_exit.syntheticScan(&context, 50, 0.05f, false, false);
3154
3155 DOCTEST_REQUIRE(lidar_point.getHitCount() > 0);
3156 DOCTEST_REQUIRE(lidar_exit.getHitCount() > 0);
3157
3158 // Calculate spatial extent for point source
3159 float x_min_pt = 1e6f, x_max_pt = -1e6f, y_min_pt = 1e6f, y_max_pt = -1e6f;
3160 for (uint i = 0; i < lidar_point.getHitCount(); i++) {
3161 vec3 pos = lidar_point.getHitXYZ(i);
3162 x_min_pt = fmin(x_min_pt, pos.x);
3163 x_max_pt = fmax(x_max_pt, pos.x);
3164 y_min_pt = fmin(y_min_pt, pos.y);
3165 y_max_pt = fmax(y_max_pt, pos.y);
3166 }
3167
3168 // Calculate spatial extent for exit diameter
3169 float x_min_ex = 1e6f, x_max_ex = -1e6f, y_min_ex = 1e6f, y_max_ex = -1e6f;
3170 for (uint i = 0; i < lidar_exit.getHitCount(); i++) {
3171 vec3 pos = lidar_exit.getHitXYZ(i);
3172 x_min_ex = fmin(x_min_ex, pos.x);
3173 x_max_ex = fmax(x_max_ex, pos.x);
3174 y_min_ex = fmin(y_min_ex, pos.y);
3175 y_max_ex = fmax(y_max_ex, pos.y);
3176 }
3177
3178 float extent_point = fmax(x_max_pt - x_min_pt, y_max_pt - y_min_pt);
3179 float extent_exit = fmax(x_max_ex - x_min_ex, y_max_ex - y_min_ex);
3180
3181 // Exit diameter should produce measurably wider spread (deterministic ~1.026 ratio with the
3182 // shared seed above, comfortably above the 1.01 threshold).
3183 DOCTEST_CHECK(extent_exit > extent_point * 1.01f);
3184}
3185
3186DOCTEST_TEST_CASE("LiDAR Exit Diameter - Zero Backward Compatibility") {
3187 // Verify that exitDiameter=0 still works (backward compatibility)
3189 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
3190
3191 // Scan with exitDiameter=0 (original behavior - should work without errors)
3192 LiDARcloud lidar_zero;
3193 lidar_zero.disableMessages();
3194 ScanMetadata scan_zero(vec3(0, 0, 5), 100, 0, M_PI, 100, 0, 2 * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, {});
3195 lidar_zero.addScan(scan_zero);
3196 DOCTEST_CHECK_NOTHROW(lidar_zero.syntheticScan(&context, 50, 0.05f, false, false));
3197 DOCTEST_CHECK(lidar_zero.getHitCount() > 0);
3198
3199 // Scan with exitDiameter>0 (new behavior - should also work)
3200 LiDARcloud lidar_exit;
3201 lidar_exit.disableMessages();
3202 ScanMetadata scan_exit(vec3(0, 0, 5), 100, 0, M_PI, 100, 0, 2 * M_PI, 0.01f, 0.0f, 0.0f, 0.0f, {});
3203 lidar_exit.addScan(scan_exit);
3204 DOCTEST_CHECK_NOTHROW(lidar_exit.syntheticScan(&context, 50, 0.05f, false, false));
3205 DOCTEST_CHECK(lidar_exit.getHitCount() > 0);
3206}
3207
3208DOCTEST_TEST_CASE("LiDAR Exit Diameter - Combined with Beam Divergence") {
3209 // Verify both spatial spreading (exitDiameter) and angular spreading (beamDivergence) work together
3211 context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
3212
3213 vec3 scan_origin(0, 0, 5.0f);
3214
3215 // 200 rays/pulse (was 50): the stratified importance-sampled footprint allocates rays by energy, so the low-energy
3216 // periphery is sparsely populated; the bounding-box extent below is an extremum statistic that needs more rays to be
3217 // stable under the new sampler. At 200 rays the aperture's added spread is unambiguous (~5% wider, well above noise),
3218 // whereas at 50 rays the extremum was too noisy to resolve it reliably. This is a statistic-stability adjustment, not
3219 // a relaxation of the physical claim.
3220 const int spread_rays = 200;
3221
3222 // Scan with BOTH exitDiameter and beamDivergence
3223 LiDARcloud lidar_both;
3224 lidar_both.disableMessages();
3225 ScanMetadata scan_both(scan_origin, 100, 0, M_PI, 100, 0, 2 * M_PI, 0.1f, 0.01f, 0.0f, 0.0f, {});
3226 lidar_both.addScan(scan_both);
3227 lidar_both.setScanDetectionThreshold(0, 0.f); // measure footprint spread, not the noise floor: keep all returns (default threshold 0.05 also shrinks the sampled cone)
3228 lidar_both.syntheticScan(&context, spread_rays, 0.05f, false, false);
3229
3230 // Scan with ONLY beamDivergence
3231 LiDARcloud lidar_div;
3232 lidar_div.disableMessages();
3233 ScanMetadata scan_div(scan_origin, 100, 0, M_PI, 100, 0, 2 * M_PI, 0.0f, 0.01f, 0.0f, 0.0f, {});
3234 lidar_div.addScan(scan_div);
3235 lidar_div.setScanDetectionThreshold(0, 0.f); // measure footprint spread, not the noise floor: keep all returns (default threshold 0.05 also shrinks the sampled cone)
3236 lidar_div.syntheticScan(&context, spread_rays, 0.05f, false, false);
3237
3238 DOCTEST_REQUIRE(lidar_both.getHitCount() > 0);
3239 DOCTEST_REQUIRE(lidar_div.getHitCount() > 0);
3240
3241 // Calculate spread for combined
3242 float x_min_b = 1e6f, x_max_b = -1e6f, y_min_b = 1e6f, y_max_b = -1e6f;
3243 for (uint i = 0; i < lidar_both.getHitCount(); i++) {
3244 vec3 pos = lidar_both.getHitXYZ(i);
3245 x_min_b = fmin(x_min_b, pos.x);
3246 x_max_b = fmax(x_max_b, pos.x);
3247 y_min_b = fmin(y_min_b, pos.y);
3248 y_max_b = fmax(y_max_b, pos.y);
3249 }
3250
3251 // Calculate spread for divergence only
3252 float x_min_d = 1e6f, x_max_d = -1e6f, y_min_d = 1e6f, y_max_d = -1e6f;
3253 for (uint i = 0; i < lidar_div.getHitCount(); i++) {
3254 vec3 pos = lidar_div.getHitXYZ(i);
3255 x_min_d = fmin(x_min_d, pos.x);
3256 x_max_d = fmax(x_max_d, pos.x);
3257 y_min_d = fmin(y_min_d, pos.y);
3258 y_max_d = fmax(y_max_d, pos.y);
3259 }
3260
3261 float spread_both = fmax(x_max_b - x_min_b, y_max_b - y_min_b);
3262 float spread_div = fmax(x_max_d - x_min_d, y_max_d - y_min_d);
3263
3264 // Combined should produce wider spread than divergence alone
3265 DOCTEST_CHECK(spread_both > spread_div * 1.01f);
3266}
3267
3268DOCTEST_TEST_CASE("LiDAR Idealized Single-Ray Exact Intersection") {
3269 // rays_per_pulse=1 is the idealized mode: a single ray reports the exact ray-surface intersection.
3271 context.addPatch(make_vec3(0, 0, 2.0f), make_vec2(2.0f, 2.0f)); // horizontal patch, normal +z
3272
3273 LiDARcloud lidar;
3274 lidar.disableMessages();
3275 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {}); // single beam straight down
3276 lidar.addScan(scan);
3277 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 1, 0.5f)); // single ray per pulse
3278
3279 DOCTEST_REQUIRE(lidar.getHitCount() == 1);
3280 vec3 p = lidar.getHitXYZ(0);
3281 DOCTEST_CHECK(fabs(p.x) < 1e-3f);
3282 DOCTEST_CHECK(fabs(p.y) < 1e-3f);
3283 DOCTEST_CHECK(p.z == doctest::Approx(2.0f).epsilon(1e-3f));
3284 DOCTEST_CHECK(lidar.getHitData(0, "target_count") == 1);
3285}
3286
3287DOCTEST_TEST_CASE("LiDAR Single-Return Waveform - Ghost Point at Edge") {
3288 // A divergent beam straddles a near surface (covering the +x half of the footprint) and a far surface behind it.
3289 // With the two surfaces separated (0.3 m) by less than the pulse range-resolution (0.5 m), single-return waveform
3290 // processing must report ONE point at an intermediate, blended range between the surfaces: a "ghost"/"mixed pixel".
3292 context.addPatch(make_vec3(0.5f, 0.f, 2.0f), make_vec2(1.0f, 1.0f)); // near, covers +x half of footprint, z=2.0
3293 context.addPatch(make_vec3(0.f, 0.f, 1.7f), make_vec2(2.0f, 2.0f)); // far, covers full footprint, z=1.7
3294
3295 LiDARcloud lidar;
3296 lidar.disableMessages();
3297 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3298 uint scanID = lidar.addScan(scan);
3299 lidar.setScanPulseWidth(scanID, 0.5f); // 0.5 m > 0.3 m separation -> the two surfaces merge into a single return
3300
3301 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 400, 0.5f, RETURN_MODE_SINGLE));
3302
3303 DOCTEST_REQUIRE(lidar.getHitCount() == 1);
3304 vec3 p = lidar.getHitXYZ(0);
3305 // The single reported point must lie strictly between the two surfaces (a ghost point), not on either one.
3306 DOCTEST_CHECK(p.z > 1.71f);
3307 DOCTEST_CHECK(p.z < 1.99f);
3308 DOCTEST_CHECK(lidar.getHitData(0, "target_count") == 1);
3309 // The echo is broadened by the spread of the two merged surfaces beyond the bare pulse width.
3310 DOCTEST_CHECK(lidar.getHitData(0, "echo_width") > 0.5f);
3311}
3312
3313DOCTEST_TEST_CASE("LiDAR Multi-Return Waveform - Resolved Returns") {
3314 // The same straddling geometry but with the surfaces separated (1.5 m) by more than the range-resolution (0.5 m):
3315 // multi-return waveform processing must resolve them as two separate returns on the two real surfaces.
3317 context.addPatch(make_vec3(0.5f, 0.f, 2.0f), make_vec2(1.0f, 1.0f)); // near, +x half, z=2.0
3318 context.addPatch(make_vec3(0.f, 0.f, 0.5f), make_vec2(2.0f, 2.0f)); // far, full, z=0.5
3319
3320 LiDARcloud lidar;
3321 lidar.disableMessages();
3322 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3323 uint scanID = lidar.addScan(scan);
3324 lidar.setScanPulseWidth(scanID, 0.5f); // 0.5 m < 1.5 m separation -> the two surfaces resolve
3325
3326 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 400, 0.5f, RETURN_MODE_MULTI));
3327
3328 DOCTEST_REQUIRE(lidar.getHitCount() == 2);
3329 float znear = fmax(lidar.getHitXYZ(0).z, lidar.getHitXYZ(1).z);
3330 float zfar = fmin(lidar.getHitXYZ(0).z, lidar.getHitXYZ(1).z);
3331 DOCTEST_CHECK(znear == doctest::Approx(2.0f).epsilon(0.03f)); // on the near surface, not blended
3332 DOCTEST_CHECK(zfar == doctest::Approx(0.5f).epsilon(0.03f)); // on the far surface, not blended
3333 DOCTEST_CHECK(lidar.getHitData(0, "target_count") == 2);
3334}
3335
3336DOCTEST_TEST_CASE("LiDAR Single-Return Waveform - Selection Policy") {
3337 // Near surface (z=2.0) covers only a +x sliver of the footprint (weaker, nearer return); far surface (z=0.5) covers
3338 // the full footprint (stronger, farther return). STRONGEST and FIRST must select different surfaces.
3340 context.addPatch(make_vec3(0.55f, 0.f, 2.0f), make_vec2(1.0f, 1.0f)); // near sliver (x in [0.05, 1.05])
3341 context.addPatch(make_vec3(0.f, 0.f, 0.5f), make_vec2(2.0f, 2.0f)); // far, full footprint
3342
3343 // Multi-return first: confirm both surfaces are detected and identify which return is stronger.
3344 LiDARcloud lidar_multi;
3345 lidar_multi.disableMessages();
3346 ScanMetadata scan_m(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3347 uint id_m = lidar_multi.addScan(scan_m);
3348 lidar_multi.setScanPulseWidth(id_m, 0.5f);
3349 lidar_multi.syntheticScan(&context, 400, 0.5f, RETURN_MODE_MULTI);
3350 DOCTEST_REQUIRE(lidar_multi.getHitCount() == 2);
3351 int inear = (lidar_multi.getHitXYZ(0).z > lidar_multi.getHitXYZ(1).z) ? 0 : 1;
3352 int ifar = 1 - inear;
3353 float znear = lidar_multi.getHitXYZ(inear).z;
3354 float zfar = lidar_multi.getHitXYZ(ifar).z;
3355 DOCTEST_REQUIRE(fabs(lidar_multi.getHitData(ifar, "intensity")) > fabs(lidar_multi.getHitData(inear, "intensity")));
3356
3357 // STRONGEST selection -> the far (full-footprint) surface.
3358 LiDARcloud lidar_str;
3359 lidar_str.disableMessages();
3360 ScanMetadata scan_s(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3361 uint id_s = lidar_str.addScan(scan_s);
3362 lidar_str.setScanPulseWidth(id_s, 0.5f);
3364 lidar_str.syntheticScan(&context, 400, 0.5f, RETURN_MODE_SINGLE);
3365 DOCTEST_REQUIRE(lidar_str.getHitCount() == 1);
3366 DOCTEST_CHECK(lidar_str.getHitXYZ(0).z == doctest::Approx(zfar).epsilon(0.05f));
3367
3368 // FIRST selection -> the near surface.
3369 LiDARcloud lidar_first;
3370 lidar_first.disableMessages();
3371 ScanMetadata scan_f(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3372 uint id_f = lidar_first.addScan(scan_f);
3373 lidar_first.setScanPulseWidth(id_f, 0.5f);
3375 lidar_first.syntheticScan(&context, 400, 0.5f, RETURN_MODE_SINGLE);
3376 DOCTEST_REQUIRE(lidar_first.getHitCount() == 1);
3377 DOCTEST_CHECK(lidar_first.getHitXYZ(0).z == doctest::Approx(znear).epsilon(0.05f));
3378}
3379
3380DOCTEST_TEST_CASE("LiDAR Waveform - Detection Threshold") {
3381 // Asymmetric scene: weak near return (z=2.0, +x sliver) and strong far return (z=0.5, full footprint). A detection
3382 // threshold set between the two echo strengths must suppress the weak return.
3384 context.addPatch(make_vec3(0.55f, 0.f, 2.0f), make_vec2(1.0f, 1.0f)); // weak near
3385 context.addPatch(make_vec3(0.f, 0.f, 0.5f), make_vec2(2.0f, 2.0f)); // strong far
3386
3387 // Baseline: no detection threshold -> both returns are detected.
3388 LiDARcloud base;
3389 base.disableMessages();
3390 ScanMetadata scan_b(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3391 uint id_b = base.addScan(scan_b);
3392 base.setScanPulseWidth(id_b, 0.5f);
3393 base.syntheticScan(&context, 400, 0.5f, RETURN_MODE_MULTI);
3394 DOCTEST_REQUIRE(base.getHitCount() == 2);
3395 float I0 = fabs(base.getHitData(0, "intensity"));
3396 float I1 = fabs(base.getHitData(1, "intensity"));
3397 float Iweak = fmin(I0, I1);
3398 float Istrong = fmax(I0, I1);
3399 DOCTEST_REQUIRE(Istrong > Iweak);
3400
3401 // Threshold between the two echo strengths -> only the strong return survives.
3402 LiDARcloud filt;
3403 filt.disableMessages();
3404 ScanMetadata scan_f(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3405 uint id_f = filt.addScan(scan_f);
3406 filt.setScanPulseWidth(id_f, 0.5f);
3407 filt.setScanDetectionThreshold(id_f, 0.5f * (Iweak + Istrong));
3408 filt.syntheticScan(&context, 400, 0.5f, RETURN_MODE_MULTI);
3409 DOCTEST_REQUIRE(filt.getHitCount() == 1);
3410 DOCTEST_CHECK(fabs(filt.getHitData(0, "intensity")) == doctest::Approx(Istrong).epsilon(0.15f));
3411}
3412
3413DOCTEST_TEST_CASE("LiDAR Return-Mode XML Load and Round-Trip") {
3414 // Verify the new analytic-waveform scan tags load from XML and survive an exportScans round-trip.
3415 std::string xml_path = "plugins/lidar/xml/.tmp_returnmode_test.xml";
3416 {
3417 std::ofstream f(xml_path);
3418 f << "<helios>\n <scan>\n";
3419 f << " <origin> 0 0 5 </origin>\n";
3420 f << " <size> 10 10 </size>\n";
3421 f << " <returnMode> single </returnMode>\n";
3422 f << " <singleReturnSelection> first </singleReturnSelection>\n";
3423 f << " <maxReturns> 3 </maxReturns>\n";
3424 f << " <pulseWidth> 0.4 </pulseWidth>\n";
3425 f << " <detectionThreshold> 0.15 </detectionThreshold>\n";
3426 f << " </scan>\n</helios>\n";
3427 }
3428
3429 LiDARcloud cloud;
3430 cloud.disableMessages();
3431 DOCTEST_CHECK_NOTHROW(cloud.loadXML(xml_path.c_str()));
3432 DOCTEST_REQUIRE(cloud.getScanCount() == 1);
3433 DOCTEST_CHECK(cloud.getScanReturnMode(0) == RETURN_MODE_SINGLE);
3434 DOCTEST_CHECK(cloud.getScanSingleReturnSelection(0) == SINGLE_RETURN_FIRST);
3435 DOCTEST_CHECK(cloud.getScanMaxReturns(0) == 3);
3436 DOCTEST_CHECK(cloud.getScanPulseWidth(0) == doctest::Approx(0.4f));
3437 DOCTEST_CHECK(cloud.getScanDetectionThreshold(0) == doctest::Approx(0.15f));
3438
3439 // Populate some hits so exportScans writes a complete scan, then reload and confirm the tags persist.
3441 context.addPatch(make_vec3(0, 0, 2.0f), make_vec2(2.0f, 2.0f));
3442 cloud.syntheticScan(&context, 10, 0.5f, false, false);
3443
3444 const std::string out_dir = "lidar_returnmode_export_tmp";
3445 std::filesystem::remove_all(out_dir);
3446 const std::string xml_out = out_dir + "/scans.xml";
3447 DOCTEST_CHECK_NOTHROW(cloud.exportScans(xml_out.c_str()));
3448
3449 LiDARcloud reloaded;
3450 reloaded.disableMessages();
3451 DOCTEST_CHECK_NOTHROW(reloaded.loadXML(xml_out.c_str()));
3452 DOCTEST_REQUIRE(reloaded.getScanCount() == 1);
3453 DOCTEST_CHECK(reloaded.getScanReturnMode(0) == RETURN_MODE_SINGLE);
3454 DOCTEST_CHECK(reloaded.getScanSingleReturnSelection(0) == SINGLE_RETURN_FIRST);
3455 DOCTEST_CHECK(reloaded.getScanMaxReturns(0) == 3);
3456 DOCTEST_CHECK(reloaded.getScanPulseWidth(0) == doctest::Approx(0.4f));
3457 DOCTEST_CHECK(reloaded.getScanDetectionThreshold(0) == doctest::Approx(0.15f));
3458
3459 std::filesystem::remove_all(out_dir);
3460 std::remove(xml_path.c_str());
3461}
3462
3463DOCTEST_TEST_CASE("LiDAR Strongest-Plus-Last XML Round-Trip") {
3464 // The 'strongest_plus_last' selection parses (case-insensitively), accepts the 'dual' alias, and survives export/reload.
3465 auto load_selection = [](const std::string &spelling) {
3466 std::string xml_path = "plugins/lidar/xml/.tmp_spl_test.xml";
3467 {
3468 std::ofstream f(xml_path);
3469 f << "<helios>\n <scan>\n";
3470 f << " <origin> 0 0 5 </origin>\n";
3471 f << " <size> 10 10 </size>\n";
3472 f << " <returnMode> single </returnMode>\n";
3473 f << " <singleReturnSelection> " << spelling << " </singleReturnSelection>\n";
3474 f << " </scan>\n</helios>\n";
3475 }
3476 LiDARcloud cloud;
3477 cloud.disableMessages();
3478 cloud.loadXML(xml_path.c_str());
3480 std::remove(xml_path.c_str());
3481 return sel;
3482 };
3483
3484 DOCTEST_CHECK(load_selection("strongest_plus_last") == SINGLE_RETURN_STRONGEST_PLUS_LAST);
3485 DOCTEST_CHECK(load_selection("Strongest_Plus_Last") == SINGLE_RETURN_STRONGEST_PLUS_LAST); // case-insensitive
3486 DOCTEST_CHECK(load_selection("dual") == SINGLE_RETURN_STRONGEST_PLUS_LAST); // alias
3487
3488 // Export then reload preserves the value.
3489 LiDARcloud cloud;
3490 cloud.disableMessages();
3491 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3492 uint id = cloud.addScan(scan);
3496 context.addPatch(make_vec3(0, 0, 2.0f), make_vec2(2.0f, 2.0f));
3497 cloud.syntheticScan(&context, 10, 0.5f, RETURN_MODE_SINGLE);
3498
3499 const std::string out_dir = "lidar_spl_export_tmp";
3500 std::filesystem::remove_all(out_dir);
3501 const std::string xml_out = out_dir + "/scans.xml";
3502 DOCTEST_CHECK_NOTHROW(cloud.exportScans(xml_out.c_str()));
3503
3504 LiDARcloud reloaded;
3505 reloaded.disableMessages();
3506 DOCTEST_CHECK_NOTHROW(reloaded.loadXML(xml_out.c_str()));
3507 DOCTEST_REQUIRE(reloaded.getScanCount() == 1);
3509
3510 std::filesystem::remove_all(out_dir);
3511}
3512
3513DOCTEST_TEST_CASE("LiDAR N-Return - maxReturns Default and Validation") {
3514 // The default maxReturns is 1 (classic single-return), and the setter fail-fasts on values below 1.
3516 context.addPatch(make_vec3(0, 0, 2.0f), make_vec2(2.0f, 2.0f));
3517
3518 LiDARcloud lidar;
3519 lidar.disableMessages();
3520 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3521 uint scanID = lidar.addScan(scan);
3522 DOCTEST_CHECK(lidar.getScanMaxReturns(scanID) == 1); // default
3523
3524 DOCTEST_CHECK_NOTHROW(lidar.setScanMaxReturns(scanID, 2));
3525 DOCTEST_CHECK(lidar.getScanMaxReturns(scanID) == 2);
3526
3527 {
3528 capture_cerr cerr_buffer;
3529 DOCTEST_CHECK_THROWS(lidar.setScanMaxReturns(scanID, 0));
3530 }
3531}
3532
3533DOCTEST_TEST_CASE("LiDAR N-Return Waveform - Keeps Exactly N (stacked surfaces)") {
3534 // Three resolvable returns from one divergent pulse. The two nearer surfaces are laterally-offset slivers that each
3535 // cover only part of the ~0.3 m footprint (so the beam reaches all three depths); the farthest covers the rest.
3536 // Separated by > pulseWidth so multi-return resolves all three; limiting maxReturns=2 must report exactly two.
3537 auto buildScene = [](Context &context) {
3538 context.addPatch(make_vec3(0.13f, 0.f, 2.5f), make_vec2(0.10f, 0.4f)); // nearest sliver (+x strip), z=2.5
3539 context.addPatch(make_vec3(-0.12f, 0.f, 1.5f), make_vec2(0.16f, 0.4f)); // middle sliver (-x strip), z=1.5
3540 context.addPatch(make_vec3(0.f, 0.f, 0.4f), make_vec2(2.0f, 2.0f)); // farthest, full footprint, z=0.4
3541 };
3542
3543 // Multi-return baseline: all three surfaces resolve.
3544 Context ctx_multi;
3545 buildScene(ctx_multi);
3546 LiDARcloud lidar_multi;
3547 lidar_multi.disableMessages();
3548 ScanMetadata scan_m(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3549 uint id_m = lidar_multi.addScan(scan_m);
3550 lidar_multi.setScanPulseWidth(id_m, 0.5f); // 0.5 m < 1.3 m separation -> all resolve
3551 lidar_multi.setScanDetectionThreshold(id_m, 0.f); // the partial-footprint slivers are deliberately weak; disable the noise floor (default 0.05) so all three resolve
3552 lidar_multi.syntheticScan(&ctx_multi, 400, 0.5f, RETURN_MODE_MULTI);
3553 DOCTEST_REQUIRE(lidar_multi.getHitCount() == 3);
3554
3555 // Limited to two returns.
3556 Context ctx_two;
3557 buildScene(ctx_two);
3558 LiDARcloud lidar_two;
3559 lidar_two.disableMessages();
3560 ScanMetadata scan_t(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3561 uint id_t = lidar_two.addScan(scan_t);
3562 lidar_two.setScanPulseWidth(id_t, 0.5f);
3563 lidar_two.setScanDetectionThreshold(id_t, 0.f); // keep all three weak slivers so the maxReturns=2 limiting logic is what trims to two
3564 lidar_two.setScanMaxReturns(id_t, 2);
3566 lidar_two.syntheticScan(&ctx_two, 400, 0.5f, RETURN_MODE_SINGLE);
3567
3568 DOCTEST_REQUIRE(lidar_two.getHitCount() == 2);
3569 DOCTEST_CHECK(lidar_two.getHitData(0, "target_count") == 2);
3570 DOCTEST_CHECK(lidar_two.getHitData(1, "target_count") == 2);
3571 // FIRST policy keeps the two nearest (z=2.5 and z=1.5), reported nearest-first (target_index 0 nearer than 1).
3572 int idx0 = (lidar_two.getHitData(0, "target_index") == 0) ? 0 : 1;
3573 int idx1 = 1 - idx0;
3574 DOCTEST_CHECK(lidar_two.getHitXYZ(idx0).z > lidar_two.getHitXYZ(idx1).z); // nearer (larger z) first
3575 DOCTEST_CHECK(lidar_two.getHitXYZ(idx0).z == doctest::Approx(2.5f).epsilon(0.05f));
3576 DOCTEST_CHECK(lidar_two.getHitXYZ(idx1).z == doctest::Approx(1.5f).epsilon(0.05f));
3577}
3578
3579DOCTEST_TEST_CASE("LiDAR N-Return Waveform - Selection Policy with N=2") {
3580 // Three resolvable returns of differing range and strength from one divergent pulse. The near surface is a narrow
3581 // +x sliver (weakest); the middle is a wider -x strip (medium); the far surface covers the residual full footprint
3582 // (strongest). Verify each N=2 selection policy keeps the right pair and ALWAYS reports them nearest-first (the
3583 // critical ordering invariant, especially for STRONGEST, where selection is by amplitude, not range).
3584 auto buildScene = [](Context &context) {
3585 context.addPatch(make_vec3(0.13f, 0.f, 2.5f), make_vec2(0.10f, 0.4f)); // near narrow sliver (weak), z=2.5
3586 context.addPatch(make_vec3(-0.12f, 0.f, 1.5f), make_vec2(0.16f, 0.4f)); // middle wider strip (medium), z=1.5
3587 context.addPatch(make_vec3(0.f, 0.f, 0.4f), make_vec2(2.0f, 2.0f)); // far residual footprint (strongest), z=0.4
3588 };
3589
3590 // Identify per-surface intensity ranking from a multi-return baseline.
3591 Context ctx_b;
3592 buildScene(ctx_b);
3593 LiDARcloud base;
3594 base.disableMessages();
3595 ScanMetadata scan_b(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3596 uint id_b = base.addScan(scan_b);
3597 base.setScanPulseWidth(id_b, 0.5f);
3598 base.setScanDetectionThreshold(id_b, 0.f); // this test relies on the deliberately weak near sliver return; disable the noise floor (default 0.05) so all 3 returns survive
3599 base.syntheticScan(&ctx_b, 400, 0.5f, RETURN_MODE_MULTI);
3600 DOCTEST_REQUIRE(base.getHitCount() == 3);
3601
3602 auto runPolicy = [&](SingleReturnSelection policy) {
3603 Context ctx;
3604 buildScene(ctx);
3605 LiDARcloud lidar;
3606 lidar.disableMessages();
3607 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3608 uint id = lidar.addScan(scan);
3609 lidar.setScanPulseWidth(id, 0.5f);
3610 lidar.setScanDetectionThreshold(id, 0.f); // keep the weak near sliver so the N=2 selection policy has all 3 returns to choose among
3611 lidar.setScanMaxReturns(id, 2);
3612 lidar.setScanSingleReturnSelection(id, policy);
3613 lidar.syntheticScan(&ctx, 400, 0.5f, RETURN_MODE_SINGLE);
3614 return lidar;
3615 };
3616
3617 // Helper: returns the two kept z-values ordered as reported (by target_index).
3618 auto keptZ = [](LiDARcloud &lidar) {
3619 DOCTEST_REQUIRE(lidar.getHitCount() == 2);
3620 int i0 = (lidar.getHitData(0, "target_index") == 0) ? 0 : 1;
3621 int i1 = 1 - i0;
3622 return std::make_pair(lidar.getHitXYZ(i0).z, lidar.getHitXYZ(i1).z);
3623 };
3624
3625 // FIRST -> the two nearest surfaces: z=2.5 then z=1.5 (nearest-first).
3626 {
3627 LiDARcloud lidar = runPolicy(SINGLE_RETURN_FIRST);
3628 auto z = keptZ(lidar);
3629 DOCTEST_CHECK(z.first > z.second); // nearest-first
3630 DOCTEST_CHECK(z.first == doctest::Approx(2.5f).epsilon(0.05f));
3631 DOCTEST_CHECK(z.second == doctest::Approx(1.5f).epsilon(0.05f));
3632 }
3633 // LAST -> the two farthest surfaces: z=1.5 then z=0.4 (still nearest-first in the report).
3634 {
3635 LiDARcloud lidar = runPolicy(SINGLE_RETURN_LAST);
3636 auto z = keptZ(lidar);
3637 DOCTEST_CHECK(z.first > z.second); // nearest-first
3638 DOCTEST_CHECK(z.first == doctest::Approx(1.5f).epsilon(0.05f));
3639 DOCTEST_CHECK(z.second == doctest::Approx(0.4f).epsilon(0.05f));
3640 }
3641 // STRONGEST -> the two highest-amplitude surfaces (middle strip + far full): z=1.5 then z=0.4. The near narrow sliver
3642 // is weakest and is dropped. CRITICAL: even though selection is by intensity, the kept pair is reported nearest-first.
3643 {
3644 LiDARcloud lidar = runPolicy(SINGLE_RETURN_STRONGEST);
3645 auto z = keptZ(lidar);
3646 DOCTEST_CHECK(z.first > z.second); // nearest-first regardless of selection key
3647 DOCTEST_CHECK(z.first == doctest::Approx(1.5f).epsilon(0.05f));
3648 DOCTEST_CHECK(z.second == doctest::Approx(0.4f).epsilon(0.05f));
3649 }
3650}
3651
3652DOCTEST_TEST_CASE("LiDAR Strongest-Plus-Last Dual Return - Three Surfaces") {
3653 // SINGLE_RETURN_STRONGEST_PLUS_LAST reports the strongest echo AND the farthest (last) echo of the pulse. For a
3654 // 3-surface pulse this differs from every single-key cap. Scene: three laterally-disjoint strips at distinct ranges,
3655 // arranged so echo strength DECREASES with range (the nearest strip is centered where the divergent beam's rays are
3656 // densest -> strongest echo; farther strips are progressively more peripheral -> weaker). Then strongest == near and
3657 // last == far, so
3658 // strongest+last = {near (strongest), far (last)} -- drops the middle
3659 // which neither STRONGEST-cap-2 ({near, middle}, the two largest amplitudes) nor LAST-cap-2 ({middle, far}, the two
3660 // farthest) produces. The policy also ignores maxReturns (it intrinsically yields 1 or 2 returns).
3661 const float znear = 2.5f, zmid = 1.5f, zfar = 0.4f;
3662 const std::vector<std::string> columnFormat = {"reflectivity_lidar"}; // recorded-intensity column
3663
3664 auto buildScene = [&](Context &context) {
3665 context.addPatch(make_vec3(0.0f, 0.f, znear), make_vec2(0.12f, 0.5f)); // near, centered (dense rays) -> strongest
3666 context.addPatch(make_vec3(0.13f, 0.f, zmid), make_vec2(0.14f, 0.5f)); // middle, +x offset -> medium
3667 context.addPatch(make_vec3(-0.17f, 0.f, zfar), make_vec2(0.10f, 0.5f)); // far, -x peripheral -> weakest
3668 };
3669
3670 auto runScan = [&](ReturnMode mode, SingleReturnSelection policy, int maxReturns) {
3671 Context ctx;
3672 // Seed the Context RNG so the synthetic scan's per-beam ray sampling (context->randu() inside syntheticScan) is
3673 // deterministic. Without this the Context seeds from wall-clock time, so the recorded intensities — and hence the
3674 // I_near > I_mid > I_far ordering this test's STRONGEST-cap-2 logic depends on — vary run-to-run; the middle/far
3675 // separation in this scene is small enough that an unseeded run occasionally inverts it. A fixed seed pins the
3676 // sampling pattern identically on every machine and every CPU/GPU trace path.
3677 ctx.seedRandomGenerator(0);
3678 buildScene(ctx);
3679 LiDARcloud lidar;
3680 lidar.disableMessages();
3681 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, columnFormat);
3682 uint id = lidar.addScan(scan);
3683 lidar.setScanPulseWidth(id, 0.5f);
3684 lidar.setScanMaxReturns(id, maxReturns);
3685 lidar.setScanSingleReturnSelection(id, policy);
3686 lidar.syntheticScan(&ctx, 400, 0.5f, mode);
3687 return lidar;
3688 };
3689
3690 // Mark which of the three surfaces a cloud's returns landed on (by z), so assertions are structural, not coordinate-exact.
3691 auto classify = [&](LiDARcloud &c, bool &hit_near, bool &hit_mid, bool &hit_far) {
3692 hit_near = hit_mid = hit_far = false;
3693 for (uint i = 0; i < c.getHitCount(); i++) {
3694 float z = c.getHitXYZ(i).z;
3695 if (fabs(z - znear) < 0.2f)
3696 hit_near = true;
3697 else if (fabs(z - zmid) < 0.2f)
3698 hit_mid = true;
3699 else if (fabs(z - zfar) < 0.2f)
3700 hit_far = true;
3701 }
3702 };
3703
3704 // Multi-return baseline: all three surfaces resolve, and the recorded intensities confirm near > middle > far.
3706 DOCTEST_REQUIRE(base.getHitCount() == 3);
3707 double I_near = 0.0, I_mid = 0.0, I_far = 0.0;
3708 for (uint i = 0; i < base.getHitCount(); i++) {
3709 float z = base.getHitXYZ(i).z;
3710 DOCTEST_REQUIRE(base.doesHitDataExist(i, "intensity"));
3711 double I = fabs((double)base.getHitData(i, "intensity"));
3712 if (fabs(z - znear) < 0.2f)
3713 I_near = I;
3714 else if (fabs(z - zmid) < 0.2f)
3715 I_mid = I;
3716 else if (fabs(z - zfar) < 0.2f)
3717 I_far = I;
3718 }
3719 DOCTEST_REQUIRE(I_near > 0.0);
3720 DOCTEST_REQUIRE(I_mid > 0.0);
3721 DOCTEST_REQUIRE(I_far > 0.0);
3722 DOCTEST_REQUIRE(I_near > I_mid); // scene precondition: near is strongest
3723 DOCTEST_REQUIRE(I_mid > I_far); // far is weakest
3724
3725 // Strongest+last: keeps the near (strongest) and far (last) surfaces, NOT the middle. maxReturns=1 must be ignored.
3727 DOCTEST_REQUIRE(spl.getHitCount() == 2); // two returns despite maxReturns=1
3728 bool sn, sm, sf;
3729 classify(spl, sn, sm, sf);
3730 DOCTEST_CHECK(sn); // strongest
3731 DOCTEST_CHECK(sf); // last
3732 DOCTEST_CHECK_FALSE(sm); // middle dropped -- the distinguishing point
3733 // Reported nearest-first: target_index 0 is the nearer (larger z) surface.
3734 DOCTEST_CHECK(spl.getHitData(0, "target_count") == 2);
3735 DOCTEST_CHECK(spl.getHitData(1, "target_count") == 2);
3736 int i0 = (spl.getHitData(0, "target_index") == 0) ? 0 : 1;
3737 int i1 = 1 - i0;
3738 DOCTEST_CHECK(spl.getHitXYZ(i0).z > spl.getHitXYZ(i1).z);
3739 DOCTEST_CHECK(spl.getHitXYZ(i0).z == doctest::Approx(znear).epsilon(0.05f));
3740 DOCTEST_CHECK(spl.getHitXYZ(i1).z == doctest::Approx(zfar).epsilon(0.05f));
3741
3742 // Contrast against the single-key caps on the SAME scene: both keep a different pair, proving the policy is distinct.
3744 DOCTEST_REQUIRE(strong2.getHitCount() == 2);
3745 bool tn, tm, tf;
3746 classify(strong2, tn, tm, tf);
3747 DOCTEST_CHECK(tn); // STRONGEST-cap-2 keeps the two largest amplitudes: near + middle
3748 DOCTEST_CHECK(tm);
3749 DOCTEST_CHECK_FALSE(tf); // far (weakest) dropped -- differs from strongest+last
3750
3752 DOCTEST_REQUIRE(last2.getHitCount() == 2);
3753 bool ln, lm, lf;
3754 classify(last2, ln, lm, lf);
3755 DOCTEST_CHECK_FALSE(ln); // LAST-cap-2 keeps the two farthest: middle + far
3756 DOCTEST_CHECK(lm);
3757 DOCTEST_CHECK(lf); // near dropped -- differs from strongest+last
3758}
3759
3760DOCTEST_TEST_CASE("LiDAR Strongest-Plus-Last Dual Return - Dedup To Single") {
3761 // When the strongest return IS the farthest, strongest+last collapses to a single reported point (not a doubled one).
3762 // Two surfaces: a weak near peripheral sliver (few rays) and a strong far full-footprint surface that catches the dense
3763 // central rays, so the far surface is both the strongest echo and the last (farthest) return.
3764 const float znear = 2.0f, zfar = 0.5f;
3765 const std::vector<std::string> columnFormat = {"reflectivity_lidar"}; // recorded-intensity column
3766
3767 auto buildScene = [&](Context &context) {
3768 context.addPatch(make_vec3(0.13f, 0.f, znear), make_vec2(0.10f, 0.5f)); // near, peripheral sliver -> weak
3769 context.addPatch(make_vec3(0.f, 0.f, zfar), make_vec2(2.0f, 2.0f)); // far, full footprint -> strongest & last
3770 };
3771
3772 auto runScan = [&](ReturnMode mode, SingleReturnSelection policy) {
3773 Context ctx;
3774 buildScene(ctx);
3775 LiDARcloud lidar;
3776 lidar.disableMessages();
3777 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, columnFormat);
3778 uint id = lidar.addScan(scan);
3779 lidar.setScanPulseWidth(id, 0.5f);
3780 lidar.setScanSingleReturnSelection(id, policy);
3781 lidar.syntheticScan(&ctx, 400, 0.5f, mode);
3782 return lidar;
3783 };
3784
3785 // Baseline: both surfaces resolve and the far surface is the strongest echo.
3787 DOCTEST_REQUIRE(base.getHitCount() == 2);
3788 double I_near = 0.0, I_far = 0.0;
3789 for (uint i = 0; i < base.getHitCount(); i++) {
3790 float z = base.getHitXYZ(i).z;
3791 DOCTEST_REQUIRE(base.doesHitDataExist(i, "intensity"));
3792 double I = fabs((double)base.getHitData(i, "intensity"));
3793 if (fabs(z - znear) < 0.3f)
3794 I_near = I;
3795 else if (fabs(z - zfar) < 0.3f)
3796 I_far = I;
3797 }
3798 DOCTEST_REQUIRE(I_near > 0.0);
3799 DOCTEST_REQUIRE(I_far > 0.0);
3800 DOCTEST_REQUIRE(I_far > I_near); // the strongest return IS the farthest
3801
3802 // Strongest+last must dedup to exactly one point at the far surface.
3804 DOCTEST_REQUIRE(spl.getHitCount() == 1); // dedup: strongest == last -> single point
3805 DOCTEST_CHECK(spl.getHitXYZ(0).z == doctest::Approx(zfar).epsilon(0.05f));
3806 DOCTEST_CHECK(spl.getHitData(0, "target_count") == 1);
3807}
3808
3809DOCTEST_TEST_CASE("LiDAR Dual-Return Is N=2") {
3810 // Two resolvable surfaces: maxReturns=2 must keep both, matching the multi-return result for a 2-surface scene.
3811 // The near surface covers only the +x half of the footprint so the divergent beam also reaches the far surface.
3812 auto buildScene = [](Context &context) {
3813 context.addPatch(make_vec3(0.5f, 0.f, 2.0f), make_vec2(1.0f, 1.0f)); // near, +x half
3814 context.addPatch(make_vec3(0.f, 0.f, 0.5f), make_vec2(2.0f, 2.0f)); // far, full footprint
3815 };
3816
3817 Context ctx;
3818 buildScene(ctx);
3819 LiDARcloud lidar;
3820 lidar.disableMessages();
3821 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3822 uint id = lidar.addScan(scan);
3823 lidar.setScanPulseWidth(id, 0.5f);
3824 lidar.setScanMaxReturns(id, 2);
3825 lidar.syntheticScan(&ctx, 400, 0.5f, RETURN_MODE_SINGLE);
3826
3827 DOCTEST_REQUIRE(lidar.getHitCount() == 2);
3828 float znear = fmax(lidar.getHitXYZ(0).z, lidar.getHitXYZ(1).z);
3829 float zfar = fmin(lidar.getHitXYZ(0).z, lidar.getHitXYZ(1).z);
3830 DOCTEST_CHECK(znear == doctest::Approx(2.0f).epsilon(0.03f));
3831 DOCTEST_CHECK(zfar == doctest::Approx(0.5f).epsilon(0.03f));
3832}
3833
3834DOCTEST_TEST_CASE("LiDAR N-Return - Full Miss Preserved") {
3835 // A pulse that hits nothing (fully transmitted beam) must record a single miss even in N-return mode, so the cloud
3836 // remains usable for leaf-area inversion.
3837 Context context; // empty scene -> all rays miss
3838
3839 LiDARcloud lidar;
3840 lidar.disableMessages();
3841 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3842 uint id = lidar.addScan(scan);
3843 lidar.setScanPulseWidth(id, 0.5f);
3845 lidar.setScanMaxReturns(id, 3);
3846 lidar.syntheticScan(&context, 400, 0.5f, false, true); // record_misses=true
3847
3848 DOCTEST_REQUIRE(lidar.getHitCount() == 1);
3849 DOCTEST_CHECK(lidar.getHitData(0, "is_miss") == 1.0);
3850 DOCTEST_CHECK(lidar.getHitData(0, "target_index") == 99);
3851}
3852
3853DOCTEST_TEST_CASE("LiDAR N-Return - Partial Miss Dropped") {
3854 // A divergent beam partially hits a near surface (covering the +x half of the footprint) and partially transmits
3855 // past it. In limited (N-return) mode the transmitted-beam miss is dropped (only real points are reported), whereas
3856 // unlimited multi-return mode keeps it.
3857 auto buildScene = [](Context &context) {
3858 context.addPatch(make_vec3(-0.13f, 0.f, 2.0f), make_vec2(0.35f, 0.6f)); // straddles center, leaves +x footprint open
3859 };
3860
3861 // Multi-return with misses recorded: the partial transmission yields a real return AND a miss row.
3862 Context ctx_multi;
3863 buildScene(ctx_multi);
3864 LiDARcloud lidar_multi;
3865 lidar_multi.disableMessages();
3866 ScanMetadata scan_m(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3867 uint id_m = lidar_multi.addScan(scan_m);
3868 lidar_multi.setScanPulseWidth(id_m, 0.5f);
3869 lidar_multi.setScanReturnMode(id_m, RETURN_MODE_MULTI);
3870 lidar_multi.syntheticScan(&ctx_multi, 400, 0.5f, false, true); // record_misses=true
3871 // Multi-return keeps both the real return and the transmitted-beam miss.
3872 int multi_real = 0, multi_miss = 0;
3873 for (uint i = 0; i < lidar_multi.getHitCount(); i++) {
3874 if (lidar_multi.getHitData(i, "is_miss") == 1.0) {
3875 multi_miss++;
3876 } else {
3877 multi_real++;
3878 }
3879 }
3880 DOCTEST_REQUIRE(multi_real >= 1);
3881 DOCTEST_REQUIRE(multi_miss == 1);
3882
3883 // N-return (limited) mode: the same partial-transmission pulse reports only real points, no miss row.
3884 Context ctx_lim;
3885 buildScene(ctx_lim);
3886 LiDARcloud lidar_lim;
3887 lidar_lim.disableMessages();
3888 ScanMetadata scan_l(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3889 uint id_l = lidar_lim.addScan(scan_l);
3890 lidar_lim.setScanPulseWidth(id_l, 0.5f);
3891 lidar_lim.setScanReturnMode(id_l, RETURN_MODE_SINGLE);
3892 lidar_lim.setScanMaxReturns(id_l, 3);
3893 lidar_lim.syntheticScan(&ctx_lim, 400, 0.5f, false, true); // record_misses=true
3894 int lim_miss = 0;
3895 for (uint i = 0; i < lidar_lim.getHitCount(); i++) {
3896 if (lidar_lim.getHitData(i, "is_miss") == 1.0) {
3897 lim_miss++;
3898 }
3899 }
3900 DOCTEST_REQUIRE(lidar_lim.getHitCount() >= 1);
3901 DOCTEST_CHECK(lim_miss == 0); // partial-transmission miss dropped in limited mode
3902}
3903
3904DOCTEST_TEST_CASE("LiDAR N-Return - Backward Compatibility (single/multi unchanged)") {
3905 // With the default maxReturns=1, single- and multi-return modes behave exactly as before.
3906 // Single-return ghost point: two surfaces within the pulse width blend to one point.
3907 {
3909 context.addPatch(make_vec3(0.5f, 0.f, 2.0f), make_vec2(1.0f, 1.0f));
3910 context.addPatch(make_vec3(0.f, 0.f, 1.7f), make_vec2(2.0f, 2.0f));
3911 LiDARcloud lidar;
3912 lidar.disableMessages();
3913 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3914 uint id = lidar.addScan(scan);
3915 DOCTEST_CHECK(lidar.getScanMaxReturns(id) == 1); // default
3916 lidar.setScanPulseWidth(id, 0.5f);
3917 lidar.syntheticScan(&context, 400, 0.5f, RETURN_MODE_SINGLE);
3918 DOCTEST_CHECK(lidar.getHitCount() == 1);
3919 }
3920 // Multi-return resolved returns: two well-separated surfaces resolve to two points.
3921 {
3923 context.addPatch(make_vec3(0.5f, 0.f, 2.0f), make_vec2(1.0f, 1.0f));
3924 context.addPatch(make_vec3(0.f, 0.f, 0.5f), make_vec2(2.0f, 2.0f));
3925 LiDARcloud lidar;
3926 lidar.disableMessages();
3927 ScanMetadata scan(make_vec3(0, 0, 5), 1, M_PI, M_PI, 1, 0, 0, 0.0f, 0.06f, 0.0f, 0.0f, {});
3928 uint id = lidar.addScan(scan);
3929 lidar.setScanPulseWidth(id, 0.5f);
3930 lidar.syntheticScan(&context, 400, 0.5f, RETURN_MODE_MULTI);
3931 DOCTEST_CHECK(lidar.getHitCount() == 2);
3932 }
3933}
3934
3935DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Grid Position Verification") {
3936 LiDARcloud lidar;
3937 lidar.disableMessages();
3939
3940 // 1. Load existing test configuration with grid
3941 DOCTEST_CHECK_NOTHROW(lidar.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
3942
3943 vec3 scan_origin = lidar.getScanOrigin(0);
3944 uint Ntheta = lidar.getScanSizeTheta(0);
3945 uint Nphi = lidar.getScanSizePhi(0);
3946
3947 // 2. Create simple geometry - small sphere that partially occludes
3948 std::vector<uint> sphere_uuids = context.addSphere(10, make_vec3(0, 0, 1.0), 0.3);
3949
3950 // 3. Perform synthetic scan WITHOUT miss recording
3951 lidar.syntheticScan(&context, false, false); // scan_grid_only=false, record_misses=false
3952 uint hits_before_gapfill = lidar.getHitCount();
3953
3954 // Sphere should block some rays creating gaps
3955 DOCTEST_CHECK(hits_before_gapfill > 0);
3956
3957 // 4. Apply gapfilling with flags
3958 std::vector<vec3> filled_points = lidar.gapfillMisses(0, false, true);
3959 uint hits_after_gapfill = lidar.getHitCount();
3960
3961 // 5. Verify gapfilling added points
3962 DOCTEST_CHECK(hits_after_gapfill > hits_before_gapfill);
3963 DOCTEST_CHECK(filled_points.size() > 0);
3964
3965 // 6. QUANTITATIVE CHECK: Build position map by grid coordinates
3966 // Use hit table to track which grid positions are filled
3967 std::map<std::pair<int, int>, bool> filled_grid_positions;
3968
3969 for (uint r = 0; r < lidar.getHitCount(); r++) {
3970 if (lidar.getHitScanID(r) == 0) {
3971 SphericalCoord raydir = lidar.getHitRaydir(r);
3972 // Convert direction to grid indices using scan metadata
3973 float theta = raydir.zenith;
3974 float phi = raydir.azimuth;
3975 vec2 theta_range = lidar.getScanRangeTheta(0);
3976 vec2 phi_range = lidar.getScanRangePhi(0);
3977
3978 int row = round((theta - theta_range.x) / (theta_range.y - theta_range.x) * (Ntheta - 1));
3979 int col = round((phi - phi_range.x) / (phi_range.y - phi_range.x) * (Nphi - 1));
3980
3981 filled_grid_positions[std::make_pair(row, col)] = true;
3982 }
3983 }
3984
3985 uint filled_cells = filled_grid_positions.size();
3986
3987 // 7. QUANTITATIVE CHECK: Verify flag values
3988 uint flag_0_count = 0; // Original hits
3989 uint flag_1_count = 0; // Interior gapfilled
3990 uint flag_2_count = 0; // Downward edge
3991 uint flag_3_count = 0; // Upward edge
3992
3993 for (uint r = 0; r < lidar.getHitCount(); r++) {
3994 if (lidar.getHitScanID(r) == 0 && lidar.doesHitDataExist(r, "gapfillMisses_code")) {
3995 int code = (int) lidar.getHitData(r, "gapfillMisses_code");
3996 if (code == 0)
3997 flag_0_count++;
3998 else if (code == 1)
3999 flag_1_count++;
4000 else if (code == 2)
4001 flag_2_count++;
4002 else if (code == 3)
4003 flag_3_count++;
4004 }
4005 }
4006
4007 DOCTEST_CHECK(flag_0_count == hits_before_gapfill); // Original hits preserved
4008 DOCTEST_CHECK((flag_1_count + flag_2_count + flag_3_count) == filled_points.size());
4009
4010 // NOTE: Interior fills (flag_1) may be 0 for sparse data
4011 // Edge fills (flag_2, flag_3) should exist since algorithm extrapolates edges
4012 DOCTEST_CHECK((flag_1_count + flag_2_count + flag_3_count) > 0); // At least some fills occurred
4013}
4014
4015DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Comparison with Record Misses") {
4016 LiDARcloud lidar1, lidar2;
4017 lidar1.disableMessages();
4018 lidar2.disableMessages();
4019
4021
4022 // Load existing test configuration with voxel grid
4023 DOCTEST_CHECK_NOTHROW(lidar1.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
4024 DOCTEST_CHECK_NOTHROW(lidar2.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
4025
4026 // Create geometry that creates both hits and misses
4027 std::vector<uint> sphere_uuids = context.addSphere(10, make_vec3(0, 0, 1.0), 0.3);
4028
4029 // === METHOD 1: Synthetic scan WITH miss recording ===
4030 lidar1.syntheticScan(&context, false, true); // record_misses = TRUE
4031 uint hits_with_misses = lidar1.getHitCount();
4032
4033 // === METHOD 2: Synthetic scan WITHOUT miss recording, then gapfill ===
4034 lidar2.syntheticScan(&context, false, false); // record_misses = FALSE
4035 uint hits_before_gapfill = lidar2.getHitCount();
4036
4037 std::vector<vec3> filled = lidar2.gapfillMisses(0, false, false);
4038 uint hits_after_gapfill = lidar2.getHitCount();
4039
4040 // === QUANTITATIVE VERIFICATION ===
4041
4042 // 1. Gapfilling should have added points
4043 DOCTEST_CHECK(hits_after_gapfill > hits_before_gapfill);
4044 DOCTEST_CHECK(filled.size() > 0);
4045
4046 // 2. With duplicate prevention, gapfillMisses should produce similar hit counts
4047 // May be slightly different due to algorithmic differences, but should be close
4048 float hit_ratio = float(hits_after_gapfill) / float(hits_with_misses);
4049 DOCTEST_CHECK(hit_ratio > 0.7f); // Within reasonable range
4050 DOCTEST_CHECK(hit_ratio < 1.3f);
4051
4052 // 3. Verify both methods cover similar grid positions by comparing actual hits (non-misses)
4053 // Count hits that are NOT far-field points
4054 uint real_hits_method1 = 0;
4055 uint real_hits_method2 = 0;
4056
4057 for (uint r = 0; r < lidar1.getHitCount(); r++) {
4058 float dist = sqrt(pow(lidar1.getHitXYZ(r).x - lidar1.getScanOrigin(0).x, 2) + pow(lidar1.getHitXYZ(r).y - lidar1.getScanOrigin(0).y, 2) + pow(lidar1.getHitXYZ(r).z - lidar1.getScanOrigin(0).z, 2));
4059 if (dist < 1000)
4060 real_hits_method1++; // Not a far-field miss
4061 }
4062
4063 for (uint r = 0; r < lidar2.getHitCount(); r++) {
4064 float dist = sqrt(pow(lidar2.getHitXYZ(r).x - lidar2.getScanOrigin(0).x, 2) + pow(lidar2.getHitXYZ(r).y - lidar2.getScanOrigin(0).y, 2) + pow(lidar2.getHitXYZ(r).z - lidar2.getScanOrigin(0).z, 2));
4065 if (dist < 1000)
4066 real_hits_method2++;
4067 }
4068
4069 // Real hits (on geometry) should match between methods
4070 DOCTEST_CHECK(real_hits_method1 == real_hits_method2);
4071}
4072
4073DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Edge Cases") {
4074 LiDARcloud lidar;
4075 lidar.disableMessages();
4076
4077 // Test 1: Invalid scanID should throw error
4078 bool caught_error = false;
4079 try {
4080 lidar.gapfillMisses(999); // No scans exist yet
4081 } catch (const std::runtime_error &e) {
4082 caught_error = true;
4083 std::string msg(e.what());
4084 DOCTEST_CHECK(msg.find("Invalid scanID") != std::string::npos);
4085 }
4086 DOCTEST_CHECK(caught_error);
4087
4088 // Test 2: Empty scan (no hits) should return empty vector gracefully
4089 DOCTEST_CHECK_NOTHROW(lidar.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
4091 // Don't add any geometry - all rays will miss and not be traced
4092 lidar.syntheticScan(&context, false, false); // No geometry, no hits
4093
4094 uint hits_before = lidar.getHitCount();
4095 std::vector<vec3> filled;
4096 DOCTEST_CHECK_NOTHROW(filled = lidar.gapfillMisses(0, false, false));
4097
4098 // Should handle empty scan gracefully
4099 if (hits_before == 0) {
4100 DOCTEST_CHECK(filled.empty());
4101 DOCTEST_CHECK(lidar.getHitCount() == 0);
4102 }
4103
4104 // Test 3: Multi-scan gapfilling (all scans overload)
4105 LiDARcloud lidar2;
4106 lidar2.disableMessages();
4107 DOCTEST_CHECK_NOTHROW(lidar2.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
4108 std::vector<uint> sphere_uuids = context.addSphere(10, make_vec3(0, 0, 1.0), 0.3);
4109 lidar2.syntheticScan(&context, false, false);
4110
4111 uint hits_before_all = lidar2.getHitCount();
4112 std::vector<vec3> filled_all;
4113 DOCTEST_CHECK_NOTHROW(filled_all = lidar2.gapfillMisses()); // Fill all scans
4114 uint hits_after_all = lidar2.getHitCount();
4115
4116 DOCTEST_CHECK(hits_after_all >= hits_before_all); // Should add points or stay same
4117}
4118
4119DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Grid Only Mode") {
4120 LiDARcloud lidar;
4121 lidar.disableMessages();
4123
4124 // Load configuration with voxel grid
4125 DOCTEST_CHECK_NOTHROW(lidar.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
4126
4127 // Add geometry
4128 std::vector<uint> sphere_uuids = context.addSphere(10, make_vec3(0, 0, 1.0), 0.3);
4129
4130 // Perform scan without miss recording
4131 lidar.syntheticScan(&context, false, false);
4132 uint hits_before = lidar.getHitCount();
4133
4134 // Test grid-only mode (should fill fewer points than full mode)
4135 std::vector<vec3> filled_grid_only = lidar.gapfillMisses(0, true, false);
4136 uint hits_grid_only = lidar.getHitCount();
4137
4138 // Reset and test full mode
4139 LiDARcloud lidar2;
4140 lidar2.disableMessages();
4141 DOCTEST_CHECK_NOTHROW(lidar2.loadXML("plugins/lidar/xml/synthetic_test_8.xml"));
4142 lidar2.syntheticScan(&context, false, false);
4143
4144 std::vector<vec3> filled_full = lidar2.gapfillMisses(0, false, false);
4145 uint hits_full = lidar2.getHitCount();
4146
4147 // Grid-only mode should fill same or fewer points (limited to grid bounds)
4148 DOCTEST_CHECK(filled_grid_only.size() <= filled_full.size());
4149}
4150
4151DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Multi-Return Data") {
4152 LiDARcloud lidar;
4153 lidar.disableMessages();
4154
4156
4157 // Create simple box geometry
4158 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));
4159
4160 // Add voxel grid for the scan
4161 lidar.addGrid(make_vec3(0, 0, 1), make_vec3(2, 2, 2), make_int3(1, 1, 1), 0);
4162
4163 // Create scan programmatically with beam spreading parameters for multi-return
4164 vec3 scan_origin(-3, 0, 1);
4165 uint Ntheta = 25;
4166 uint Nphi = 30;
4167 float thetaMin = M_PI / 3;
4168 float thetaMax = 2 * M_PI / 3;
4169 float phiMin = 0.9;
4170 float phiMax = 1.7;
4171 float exitDiameter = 0.015; // 1.5cm exit diameter
4172 float beamDivergence = 0.002; // 2 mrad divergence for beam spreading
4173
4174 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, std::vector<std::string>{"x", "y", "z", "timestamp"});
4175 uint scanID = lidar.addScan(scan);
4176
4177 // Multi-return synthetic scan WITHOUT miss recording - gapfilling will restore them
4178 lidar.syntheticScan(&context, 3, 0.15f, false, false); // rays_per_pulse=3, record_misses=false
4179 uint hits_before = lidar.getHitCount();
4180
4181 // Check if multi-return data was created (depends on beam spreading and geometry)
4182 bool has_multi_return = false;
4183 for (size_t r = 0; r < lidar.getHitCount(); r++) {
4184 if (lidar.doesHitDataExist(r, "target_count") && lidar.getHitData(r, "target_count") > 1) {
4185 has_multi_return = true;
4186 break;
4187 }
4188 }
4189 // Note: Multi-return creation depends on beam parameters and geometry interaction
4190 // Test verifies gapfilling works regardless
4191
4192 // Apply gapfilling with flags for multi-return data
4193 std::vector<vec3> filled = lidar.gapfillMisses(scanID, false, true);
4194 uint hits_after = lidar.getHitCount();
4195
4196 // Should have added gapfilled points
4197 DOCTEST_CHECK(hits_after > hits_before);
4198 DOCTEST_CHECK(filled.size() > 0);
4199
4200 // Verify gapfillMisses_code flags were added
4201 uint flag_0_count = 0; // Original hits
4202 uint flag_other_count = 0; // Gapfilled hits (1, 2, or 3)
4203
4204 for (uint r = 0; r < lidar.getHitCount(); r++) {
4205 if (lidar.doesHitDataExist(r, "gapfillMisses_code")) {
4206 int code = (int) lidar.getHitData(r, "gapfillMisses_code");
4207 if (code == 0) {
4208 flag_0_count++;
4209 } else {
4210 flag_other_count++;
4211 }
4212 }
4213 }
4214
4215 // Original hits should all be flagged (if multi-return data exists)
4216 if (has_multi_return) {
4217 DOCTEST_CHECK(flag_0_count > 0); // Should have original hits
4218 DOCTEST_CHECK(flag_other_count == filled.size()); // All filled points flagged
4219 }
4220
4221 // Gapfilling should work without crashing on multi-return data
4222 DOCTEST_CHECK_NOTHROW(lidar.triangulateHitPoints(0.04, 10));
4223
4224 // Note: If no multi-return data was created (geometry too sparse), test still validates
4225 // that gapfillMisses runs without error on the data that does exist
4226}
4227
4228DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Strict Accuracy Verification") {
4229 // Rigorous test to verify gapfillMisses produces ACCURATE results matching record_misses
4230
4232
4233 // Create scan parameters first to calculate coverage area
4234 vec3 scan_origin(-4, 0, 1.5);
4235 uint Ntheta = 35;
4236 uint Nphi = 50;
4237 float thetaMin = M_PI / 3;
4238 float thetaMax = 2 * M_PI / 3;
4239 float phiMin = 1.0;
4240 float phiMax = 1.8;
4241
4242 // Create large box geometry that covers ENTIRE scan field of view
4243 // This ensures ALL rays in the scan pattern will be traced
4244 // Place box to cover the angular range from the scanner
4245 std::vector<uint> box_uuids = context.addBox(make_vec3(2, 0, 1.5), make_vec3(8, 8, 4), make_int3(15, 15, 10));
4246
4247 // METHOD 1: Record misses (GROUND TRUTH)
4248 LiDARcloud lidar1;
4249 lidar1.disableMessages();
4250 lidar1.addGrid(make_vec3(0, 0, 1.5), make_vec3(2, 2, 2), make_int3(1, 1, 1), 0);
4251 ScanMetadata scan1(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0, 0, 0.0f, 0.0f, std::vector<std::string>{"x", "y", "z", "timestamp"});
4252 lidar1.addScan(scan1);
4253 lidar1.syntheticScan(&context, false, true); // record_misses = TRUE (ground truth)
4254
4255 uint hits_ground_truth = lidar1.getHitCount();
4256
4257 // Build comprehensive position map for ground truth
4258 std::map<std::pair<int, int>, vec3> ground_truth_positions;
4259 std::map<std::pair<int, int>, bool> ground_truth_is_miss; // Track which are far-field misses
4260
4261 // The raster sweep drifts the azimuth continuously across each zenith column (continuous-azimuth model): cell (row,col)
4262 // is emitted at azimuth = phiMin + col*dphi + row*dphi_per_row, with dphi the column step and dphi_per_row = dphi/Ntheta.
4263 // To recover the nominal (row,col) grid cell from a hit's azimuth we must subtract this per-row drift first; otherwise the
4264 // top rows of a column round into the next column (and the last column rounds out of bounds).
4265 const float dphi_grid = (phiMax - phiMin) / float(Nphi - 1);
4266 const float dphi_per_row = dphi_grid / float(Ntheta);
4267
4268 for (uint r = 0; r < lidar1.getHitCount(); r++) {
4269 SphericalCoord raydir = lidar1.getHitRaydir(r);
4270 vec3 pos = lidar1.getHitXYZ(r);
4271
4272 // Calculate grid indices (de-skew the azimuth by the recovered row's drift before binning to a column).
4273 float theta = raydir.zenith;
4274 float phi = raydir.azimuth;
4275 int row = round((theta - thetaMin) / (thetaMax - thetaMin) * (Ntheta - 1));
4276 int col = round((phi - float(row) * dphi_per_row - phiMin) / (phiMax - phiMin) * (Nphi - 1));
4277
4278 ground_truth_positions[std::make_pair(row, col)] = pos;
4279
4280 // Check if this is a far-field miss (distance > 1000m)
4281 float dist = sqrt(pow(pos.x - scan_origin.x, 2) + pow(pos.y - scan_origin.y, 2) + pow(pos.z - scan_origin.z, 2));
4282 ground_truth_is_miss[std::make_pair(row, col)] = (dist > 1000);
4283 }
4284
4285 // Verify we got expected number of hits (should be Ntheta × Nphi)
4286 uint expected_grid_size = Ntheta * Nphi;
4287
4288 DOCTEST_CHECK_MESSAGE(hits_ground_truth == expected_grid_size, "record_misses should produce Ntheta×Nphi hits but got " << hits_ground_truth << " vs " << expected_grid_size);
4289
4290 DOCTEST_CHECK(ground_truth_positions.size() == hits_ground_truth);
4291
4292 // METHOD 2: Gapfill misses
4293 LiDARcloud lidar2;
4294 lidar2.disableMessages();
4295 lidar2.addGrid(make_vec3(0, 0, 1.5), make_vec3(2, 2, 2), make_int3(1, 1, 1), 0);
4296 ScanMetadata scan2(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0, 0, 0.0f, 0.0f, std::vector<std::string>{"x", "y", "z", "timestamp"});
4297 lidar2.addScan(scan2);
4298 lidar2.syntheticScan(&context, false, false); // record_misses = FALSE
4299
4300 uint hits_before_gapfill = lidar2.getHitCount();
4301
4302 std::vector<vec3> filled = lidar2.gapfillMisses(0, false, false);
4303 uint hits_after_gapfill = lidar2.getHitCount();
4304
4305 // Build position map for gapfilled data (map will deduplicate by grid position)
4306 std::map<std::pair<int, int>, vec3> gapfilled_positions;
4307 std::map<std::pair<int, int>, uint> gapfilled_hit_count; // Count hits per grid position
4308
4309 for (uint r = 0; r < lidar2.getHitCount(); r++) {
4310 SphericalCoord raydir = lidar2.getHitRaydir(r);
4311 vec3 pos = lidar2.getHitXYZ(r);
4312
4313 float theta = raydir.zenith;
4314 float phi = raydir.azimuth;
4315 int row = round((theta - thetaMin) / (thetaMax - thetaMin) * (Ntheta - 1));
4316 int col = round((phi - float(row) * dphi_per_row - phiMin) / (phiMax - phiMin) * (Nphi - 1)); // de-skew the continuous-azimuth drift (see ground-truth loop)
4317
4318 auto key = std::make_pair(row, col);
4319 gapfilled_positions[key] = pos; // Map stores last position at this grid cell
4320 gapfilled_hit_count[key]++; // Count hits per grid cell
4321 }
4322
4323 // Check for duplicates in gapfilled data
4324 uint gapfilled_duplicates = hits_after_gapfill - gapfilled_positions.size();
4325 DOCTEST_CHECK_MESSAGE(gapfilled_duplicates == 0, "CRITICAL BUG: gapfillMisses created " << gapfilled_duplicates << " duplicate hits at positions that already had hits!");
4326
4327 // 1. Separate verification: Interior positions vs Edge extrapolation
4328 // Ground truth only has positions traced by syntheticScan
4329 // Gapfilling should: (a) match ground truth for traced regions, (b) extrapolate edges
4330
4331 // All ground truth positions should exist in gapfilled data (superset)
4332 uint ground_truth_positions_found = 0;
4333 for (const auto &kv: ground_truth_positions) {
4334 if (gapfilled_positions.find(kv.first) != gapfilled_positions.end()) {
4335 ground_truth_positions_found++;
4336 }
4337 }
4338
4339 float ground_truth_recovery = float(ground_truth_positions_found) / float(ground_truth_positions.size());
4340 DOCTEST_CHECK_MESSAGE(ground_truth_recovery > 0.95f,
4341 "Gapfilling missed positions that record_misses found: only recovered " << ground_truth_positions_found << " of " << ground_truth_positions.size() << " (" << (ground_truth_recovery * 100) << "%)");
4342
4343 // 2. Verify all gapfilled points are at VALID grid positions (theta, phi in bounds)
4344 uint invalid_positions = 0;
4345 for (const auto &kv: gapfilled_positions) {
4346 int row = kv.first.first;
4347 int col = kv.first.second;
4348
4349 if (row < 0 || row >= (int) Ntheta || col < 0 || col >= (int) Nphi) {
4350 invalid_positions++;
4351 }
4352 }
4353 DOCTEST_CHECK_MESSAGE(invalid_positions == 0, "Gapfilling created " << invalid_positions << " points at invalid grid positions");
4354
4355 // 3. Verify positions that match ground truth have correct coordinates
4356 uint position_mismatches = 0;
4357 float max_position_error = 0;
4358
4359 for (const auto &kv: ground_truth_positions) {
4360 auto key = kv.first;
4361 if (gapfilled_positions.find(key) != gapfilled_positions.end()) {
4362 vec3 pos_gt = kv.second;
4363 vec3 pos_gf = gapfilled_positions[key];
4364
4365 bool is_miss = ground_truth_is_miss[key];
4366
4367 float dist = sqrt(pow(pos_gt.x - pos_gf.x, 2) + pow(pos_gt.y - pos_gf.y, 2) + pow(pos_gt.z - pos_gf.z, 2));
4368
4369 if (dist > max_position_error)
4370 max_position_error = dist;
4371
4372 // For geometry hits, positions should match exactly (within 1cm)
4373 // For far-field misses, directions should match (within 1 degree)
4374 if (!is_miss && dist > 0.01f) {
4375 position_mismatches++;
4376 } else if (is_miss) {
4377 // Check direction for misses
4378 vec3 dir_gt = pos_gt - scan_origin;
4379 vec3 dir_gf = pos_gf - scan_origin;
4380 float mag_gt = sqrt(dir_gt.x * dir_gt.x + dir_gt.y * dir_gt.y + dir_gt.z * dir_gt.z);
4381 float mag_gf = sqrt(dir_gf.x * dir_gf.x + dir_gf.y * dir_gf.y + dir_gf.z * dir_gf.z);
4382
4383 float dot = (dir_gt.x * dir_gf.x + dir_gt.y * dir_gf.y + dir_gt.z * dir_gf.z) / (mag_gt * mag_gf);
4384 if (dot < 0.9998f) { // Directions differ by >1 degree
4385 position_mismatches++;
4386 }
4387 }
4388 }
4389 }
4390
4391 float position_match_rate = float(ground_truth_positions_found - position_mismatches) / float(ground_truth_positions_found);
4392 DOCTEST_CHECK_MESSAGE(position_match_rate > 0.95f, "Position accuracy too low: " << position_mismatches << " mismatches out of " << ground_truth_positions_found << " (" << (position_match_rate * 100) << "% correct)");
4393
4394 // STRICT CHECK: For ideal scan, gapfilling should match ground truth exactly
4395 DOCTEST_CHECK_MESSAGE(hits_after_gapfill == hits_ground_truth,
4396 "Gapfilling should exactly match record_misses for ideal scan: " << hits_after_gapfill << " vs " << hits_ground_truth << " (difference: " << (int) hits_after_gapfill - (int) hits_ground_truth << ")");
4397}
4398
4399DOCTEST_TEST_CASE("LiDAR exportScans round-trip") {
4400
4401 const std::string out_dir = "lidar_export_scans_test_tmp";
4402 std::filesystem::remove_all(out_dir);
4403
4404 LiDARcloud original;
4405 original.disableMessages();
4406
4407 // Scan 0: narrow theta range, custom column format
4408 std::vector<std::string> columnFormat0 = {"x", "y", "z", "zenith", "azimuth"};
4409 ScanMetadata scan0(vec3(-3.0f, 0.0f, 0.5f), 80, 0.25f * float(M_PI), 0.75f * float(M_PI), 160, 0.0f, 2.0f * float(M_PI), 0.01f, 0.001f, 0.004f, 0.0005f, columnFormat0);
4410 DOCTEST_CHECK_NOTHROW(original.addScan(scan0));
4411
4412 // Scan 1: different origin and default column format
4413 std::vector<std::string> columnFormat1;
4414 ScanMetadata scan1(vec3(0.0f, -3.0f, 0.5f), 80, 0.0f, float(M_PI), 160, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat1);
4415 DOCTEST_CHECK_NOTHROW(original.addScan(scan1));
4416
4417 DOCTEST_CHECK_NOTHROW(original.addGrid(vec3(0, 0, 0.5f), vec3(1, 1, 1), make_int3(1, 1, 1), 0));
4418
4419 Context scene_ctx;
4420 std::vector<uint> UUIDs = scene_ctx.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
4421 DOCTEST_CHECK(!UUIDs.empty());
4422 DOCTEST_CHECK_NOTHROW(original.syntheticScan(&scene_ctx));
4423
4424 const uint original_hits = original.getHitCount();
4425 DOCTEST_CHECK(original_hits > 0);
4426
4427 const std::string xml_out = out_dir + "/scans.xml";
4428 DOCTEST_CHECK_NOTHROW(original.exportScans(xml_out.c_str()));
4429
4430 DOCTEST_CHECK(std::filesystem::exists(xml_out));
4431 DOCTEST_CHECK(std::filesystem::exists(out_dir + "/scans_0.xyz"));
4432 DOCTEST_CHECK(std::filesystem::exists(out_dir + "/scans_1.xyz"));
4433
4434 // Exported files begin with a '#'-prefixed comment header listing the scan's column format.
4435 {
4436 std::ifstream xyz0(out_dir + "/scans_0.xyz");
4437 std::string header_line;
4438 std::getline(xyz0, header_line);
4439 DOCTEST_CHECK(!header_line.empty());
4440 DOCTEST_CHECK(header_line.front() == '#');
4441 for (const std::string &col: columnFormat0) {
4442 DOCTEST_CHECK(header_line.find(col) != std::string::npos);
4443 }
4444 }
4445
4446 // Reload and verify metadata + hit count round-trip (loader must skip the header line)
4447 LiDARcloud reloaded;
4448 reloaded.disableMessages();
4449 DOCTEST_CHECK_NOTHROW(reloaded.loadXML(xml_out.c_str()));
4450
4451 DOCTEST_CHECK(reloaded.getScanCount() == original.getScanCount());
4452 DOCTEST_CHECK(reloaded.getHitCount() == original_hits);
4453
4454 for (uint i = 0; i < original.getScanCount(); i++) {
4455 vec3 o_orig = original.getScanOrigin(i);
4456 vec3 o_new = reloaded.getScanOrigin(i);
4457 DOCTEST_CHECK(o_new.x == doctest::Approx(o_orig.x));
4458 DOCTEST_CHECK(o_new.y == doctest::Approx(o_orig.y));
4459 DOCTEST_CHECK(o_new.z == doctest::Approx(o_orig.z));
4460
4461 DOCTEST_CHECK(reloaded.getScanSizeTheta(i) == original.getScanSizeTheta(i));
4462 DOCTEST_CHECK(reloaded.getScanSizePhi(i) == original.getScanSizePhi(i));
4463
4464 vec2 t_orig = original.getScanRangeTheta(i);
4465 vec2 t_new = reloaded.getScanRangeTheta(i);
4466 DOCTEST_CHECK(t_new.x == doctest::Approx(t_orig.x).epsilon(1e-4));
4467 DOCTEST_CHECK(t_new.y == doctest::Approx(t_orig.y).epsilon(1e-4));
4468
4469 vec2 p_orig = original.getScanRangePhi(i);
4470 vec2 p_new = reloaded.getScanRangePhi(i);
4471 DOCTEST_CHECK(p_new.x == doctest::Approx(p_orig.x).epsilon(1e-4));
4472 DOCTEST_CHECK(p_new.y == doctest::Approx(p_orig.y).epsilon(1e-4));
4473
4474 DOCTEST_CHECK(reloaded.getScanBeamExitDiameter(i) == doctest::Approx(original.getScanBeamExitDiameter(i)));
4475 DOCTEST_CHECK(reloaded.getScanBeamDivergence(i) == doctest::Approx(original.getScanBeamDivergence(i)));
4476 DOCTEST_CHECK(reloaded.getScanRangeNoiseStdDev(i) == doctest::Approx(original.getScanRangeNoiseStdDev(i)));
4477 DOCTEST_CHECK(reloaded.getScanAngleNoiseStdDev(i) == doctest::Approx(original.getScanAngleNoiseStdDev(i)));
4478
4479 std::vector<std::string> f_orig = original.getScanColumnFormat(i);
4480 if (f_orig.empty()) {
4481 f_orig = {"x", "y", "z"};
4482 }
4483 DOCTEST_CHECK(reloaded.getScanColumnFormat(i) == f_orig);
4484 }
4485
4486 // Exporting from an empty cloud must fail fast, not silently produce nothing
4487 LiDARcloud empty_cloud;
4488 empty_cloud.disableMessages();
4489 DOCTEST_CHECK_THROWS(empty_cloud.exportScans((out_dir + "/empty.xml").c_str()));
4490
4491 std::filesystem::remove_all(out_dir);
4492}
4493
4494DOCTEST_TEST_CASE("LiDAR exportPointCloud header") {
4495
4496 const std::string out_dir = "lidar_export_header_test_tmp";
4497 std::filesystem::remove_all(out_dir);
4498
4499 // Build a small cloud directly with a custom column format that includes a standard field
4500 // (intensity) and a user-defined scalar field (my_field).
4501 const std::vector<std::string> columnFormat = {"x", "y", "z", "intensity", "my_field"};
4502 LiDARcloud cloud;
4503 cloud.disableMessages();
4504
4505 ScanMetadata scan(vec3(0.0f, 0.0f, 0.0f), 4, 0.25f * float(M_PI), 0.75f * float(M_PI), 4, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
4506 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
4507
4508 const uint Nhits = 5;
4509 for (uint i = 0; i < Nhits; i++) {
4510 std::map<std::string, double> data;
4511 data["intensity"] = 0.5 + 0.1 * i;
4512 data["my_field"] = 100.0 + i;
4513 SphericalCoord dir(1.f, 0.5f * float(M_PI) - 0.5f * float(M_PI), 0.1f * i);
4514 DOCTEST_CHECK_NOTHROW(cloud.addHitPoint(0, vec3(float(i), 0.2f * i, 1.0f), dir, data));
4515 }
4516 DOCTEST_CHECK(cloud.getHitCount() == Nhits);
4517
4518 // --- Export with header (default) --- //
4519 const std::string with_header = out_dir + "/cloud_header.xyz";
4520 DOCTEST_CHECK_NOTHROW(cloud.exportPointCloud(with_header.c_str(), 0u));
4521 DOCTEST_CHECK(std::filesystem::exists(with_header));
4522 {
4523 std::ifstream f(with_header);
4524 std::string first_line;
4525 std::getline(f, first_line);
4526 DOCTEST_CHECK(first_line == "# x y z intensity my_field");
4527 // Each data line must have exactly columnFormat.size() whitespace-separated tokens.
4528 std::string data_line;
4529 std::getline(f, data_line);
4530 std::istringstream iss(data_line);
4531 std::string tok;
4532 size_t ntok = 0;
4533 while (iss >> tok) {
4534 ntok++;
4535 }
4536 DOCTEST_CHECK(ntok == columnFormat.size());
4537 }
4538
4539 // --- Export without header --- //
4540 const std::string no_header = out_dir + "/cloud_nohdr.xyz";
4541 DOCTEST_CHECK_NOTHROW(cloud.exportPointCloud(no_header.c_str(), 0u, false));
4542 DOCTEST_CHECK(std::filesystem::exists(no_header));
4543 {
4544 std::ifstream f(no_header);
4545 std::string first_line;
4546 std::getline(f, first_line);
4547 DOCTEST_CHECK(!first_line.empty());
4548 DOCTEST_CHECK(first_line.front() != '#'); // first line is a data row, not a header
4549 }
4550
4551 // --- Round-trip the headered file through XML/loadASCIIFile to confirm the loader skips the
4552 // header line and preserves user data. --- //
4553 LiDARcloud reloaded;
4554 reloaded.disableMessages();
4555 ScanMetadata reload_scan(vec3(0.0f, 0.0f, 0.0f), 4, 0.25f * float(M_PI), 0.75f * float(M_PI), 4, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
4556 DOCTEST_CHECK_NOTHROW(reloaded.addScan(reload_scan));
4557 DOCTEST_CHECK_NOTHROW(reloaded.loadASCIIFile(0, with_header));
4558 DOCTEST_CHECK(reloaded.getHitCount() == Nhits);
4559 // Spot-check a user-defined scalar field survived the round trip.
4560 double mf;
4561 DOCTEST_CHECK_NOTHROW(mf = reloaded.getHitData(0, "my_field"));
4562 DOCTEST_CHECK(mf == doctest::Approx(100.0));
4563
4564 std::filesystem::remove_all(out_dir);
4565}
4566
4567DOCTEST_TEST_CASE("LiDAR Columnar Hit Data - Bulk Getter Matches Per-Hit") {
4568 // The columnar bulk getter getHitDataColumn() must return, for every hit, exactly what the per-hit
4569 // getHitData()/doesHitDataExist() path returns, including the absent-value placement for hits that
4570 // lack a label. Build a cloud where a custom label is present on only some hits.
4571 const std::vector<std::string> columnFormat = {"x", "y", "z", "intensity", "sparse_field"};
4572 LiDARcloud cloud;
4573 cloud.disableMessages();
4574
4575 ScanMetadata scan(vec3(0.0f, 0.0f, 0.0f), 4, 0.25f * float(M_PI), 0.75f * float(M_PI), 4, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
4576 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
4577
4578 const uint Nhits = 7;
4579 for (uint i = 0; i < Nhits; i++) {
4580 std::map<std::string, double> data;
4581 data["intensity"] = 0.5 + 0.1 * i; // present on every hit
4582 if (i % 2 == 0) {
4583 data["sparse_field"] = 1000.0 + i; // present only on even hits
4584 }
4585 SphericalCoord dir(1.f, 0.0f, 0.1f * i);
4586 DOCTEST_CHECK_NOTHROW(cloud.addHitPoint(0, vec3(float(i), 0.2f * i, 1.0f), dir, data));
4587 }
4588 DOCTEST_CHECK(cloud.getHitCount() == Nhits);
4589
4590 const double absent = -9999.0;
4591
4592 // intensity: present everywhere.
4593 std::vector<double> intensity_bulk;
4594 DOCTEST_CHECK_NOTHROW(cloud.getHitDataColumn("intensity", intensity_bulk, absent));
4595 DOCTEST_CHECK(intensity_bulk.size() == Nhits);
4596 for (uint i = 0; i < Nhits; i++) {
4597 const double expected = cloud.doesHitDataExist(i, "intensity") ? cloud.getHitData(i, "intensity") : absent;
4598 DOCTEST_CHECK(intensity_bulk[i] == doctest::Approx(expected));
4599 }
4600
4601 // sparse_field: present only on even hits, absent (= -9999) on odd hits.
4602 std::vector<double> sparse_bulk;
4603 DOCTEST_CHECK_NOTHROW(cloud.getHitDataColumn("sparse_field", sparse_bulk, absent));
4604 DOCTEST_CHECK(sparse_bulk.size() == Nhits);
4605 for (uint i = 0; i < Nhits; i++) {
4606 const double expected = cloud.doesHitDataExist(i, "sparse_field") ? cloud.getHitData(i, "sparse_field") : absent;
4607 DOCTEST_CHECK(sparse_bulk[i] == doctest::Approx(expected));
4608 if (i % 2 == 0) {
4609 DOCTEST_CHECK(sparse_bulk[i] == doctest::Approx(1000.0 + i));
4610 } else {
4611 DOCTEST_CHECK(sparse_bulk[i] == doctest::Approx(absent));
4612 }
4613 }
4614
4615 // A label that was never set on any hit must be all-absent and report column index -1.
4616 DOCTEST_CHECK(cloud.getHitDataColumnIndex("never_set") == -1);
4617 std::vector<double> missing_bulk;
4618 DOCTEST_CHECK_NOTHROW(cloud.getHitDataColumn("never_set", missing_bulk, absent));
4619 DOCTEST_CHECK(missing_bulk.size() == Nhits);
4620 for (uint i = 0; i < Nhits; i++) {
4621 DOCTEST_CHECK(missing_bulk[i] == doctest::Approx(absent));
4622 }
4623 DOCTEST_CHECK(cloud.getHitDataColumnIndex("intensity") >= 0);
4624}
4625
4626DOCTEST_TEST_CASE("LiDAR Columnar Hit Data - Delete Lockstep") {
4627 // deleteHitPoint() uses swap-and-pop; the columnar scalar data must move in lockstep so each
4628 // surviving hit's columns still correspond to its position/color. Tag each hit with a unique id
4629 // equal to its position, delete several, and verify every survivor's column value still matches the
4630 // id encoded in its XYZ position.
4631 const std::vector<std::string> columnFormat = {"x", "y", "z", "id"};
4632 LiDARcloud cloud;
4633 cloud.disableMessages();
4634
4635 ScanMetadata scan(vec3(0.0f, 0.0f, 0.0f), 4, 0.25f * float(M_PI), 0.75f * float(M_PI), 4, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
4636 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
4637
4638 const uint Nhits = 10;
4639 for (uint i = 0; i < Nhits; i++) {
4640 std::map<std::string, double> data;
4641 data["id"] = double(i);
4642 SphericalCoord dir(1.f, 0.0f, 0.05f * i);
4643 // Encode the id in the x position so we can cross-check column vs. position after swaps.
4644 DOCTEST_CHECK_NOTHROW(cloud.addHitPoint(0, vec3(double(i), 0.f, 1.f), dir, data));
4645 }
4646
4647 // Delete hits 2, 5, 8 (descending so indices stay valid - mirrors the filter delete loops).
4648 DOCTEST_CHECK_NOTHROW(cloud.deleteHitPoint(8));
4649 DOCTEST_CHECK_NOTHROW(cloud.deleteHitPoint(5));
4650 DOCTEST_CHECK_NOTHROW(cloud.deleteHitPoint(2));
4651 DOCTEST_CHECK(cloud.getHitCount() == Nhits - 3);
4652
4653 for (uint i = 0; i < cloud.getHitCount(); i++) {
4654 const double id = cloud.getHitData(i, "id");
4655 const double x = cloud.getHitXYZ(i).x;
4656 // The id column value must equal the x position for the SAME hit - proving columns didn't desync.
4657 DOCTEST_CHECK(id == doctest::Approx(x));
4658 // None of the deleted ids (2,5,8) should survive.
4659 DOCTEST_CHECK(id != doctest::Approx(2.0));
4660 DOCTEST_CHECK(id != doctest::Approx(5.0));
4661 DOCTEST_CHECK(id != doctest::Approx(8.0));
4662 }
4663}
4664
4665DOCTEST_TEST_CASE("LiDAR Columnar Hit Data - Mid-Cloud New Label Back-Fill") {
4666 // setHitData() introducing a brand-new label on a late hit must create a full-length column that is
4667 // absent for all earlier hits and present only where set - matching the old per-hit map semantics.
4668 LiDARcloud cloud;
4669 cloud.disableMessages();
4670
4671 const std::vector<std::string> columnFormat = {"x", "y", "z"};
4672 ScanMetadata scan(vec3(0.0f, 0.0f, 0.0f), 4, 0.25f * float(M_PI), 0.75f * float(M_PI), 4, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
4673 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
4674
4675 const uint Nhits = 6;
4676 for (uint i = 0; i < Nhits; i++) {
4677 std::map<std::string, double> data; // no scalar data initially
4678 SphericalCoord dir(1.f, 0.0f, 0.05f * i);
4679 DOCTEST_CHECK_NOTHROW(cloud.addHitPoint(0, vec3(double(i), 0.f, 1.f), dir, data));
4680 }
4681
4682 // The label does not exist yet anywhere.
4683 DOCTEST_CHECK(cloud.getHitDataColumnIndex("late_label") == -1);
4684 for (uint i = 0; i < Nhits; i++) {
4685 DOCTEST_CHECK(cloud.doesHitDataExist(i, "late_label") == false);
4686 }
4687
4688 // Set it on a single late hit (index 4).
4689 DOCTEST_CHECK_NOTHROW(cloud.setHitData(4, "late_label", 42.0));
4690
4691 DOCTEST_CHECK(cloud.getHitDataColumnIndex("late_label") >= 0);
4692 for (uint i = 0; i < Nhits; i++) {
4693 if (i == 4) {
4694 DOCTEST_CHECK(cloud.doesHitDataExist(i, "late_label") == true);
4695 DOCTEST_CHECK(cloud.getHitData(i, "late_label") == doctest::Approx(42.0));
4696 } else {
4697 DOCTEST_CHECK(cloud.doesHitDataExist(i, "late_label") == false);
4698 }
4699 }
4700
4701 // getHitData on an absent hit must throw the same "does not exist" error as before.
4702 {
4703 capture_cerr capture;
4704 DOCTEST_CHECK_THROWS(cloud.getHitData(0, "late_label"));
4705 }
4706}
4707
4708DOCTEST_TEST_CASE("LiDAR Columnar Hit Data - Origin Survives coordinateShift") {
4709 // Moving-platform hits carry their own per-pulse origin in labels origin_x/y/z (all-three-or-none).
4710 // coordinateShift must transform that origin in lockstep with the hit position. With columnar
4711 // storage the origin helpers operate by hit index; verify a shifted moving-style hit keeps its
4712 // origin consistent while a static hit (no origin labels) is unaffected.
4713 LiDARcloud cloud;
4714 cloud.disableMessages();
4715
4716 const std::vector<std::string> columnFormat = {"x", "y", "z"};
4717 ScanMetadata scan(vec3(0.0f, 0.0f, 0.0f), 4, 0.25f * float(M_PI), 0.75f * float(M_PI), 4, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
4718 DOCTEST_CHECK_NOTHROW(cloud.addScan(scan));
4719
4720 // Hit 0: moving-style, carries an explicit origin.
4721 std::map<std::string, double> moving;
4722 moving["origin_x"] = 1.0;
4723 moving["origin_y"] = 2.0;
4724 moving["origin_z"] = 3.0;
4725 DOCTEST_CHECK_NOTHROW(cloud.addHitPoint(0, vec3(5.f, 6.f, 7.f), SphericalCoord(1.f, 0.f, 0.f), moving));
4726
4727 // Hit 1: static, no origin labels.
4728 std::map<std::string, double> stat;
4729 DOCTEST_CHECK_NOTHROW(cloud.addHitPoint(0, vec3(10.f, 10.f, 10.f), SphericalCoord(1.f, 0.f, 0.f), stat));
4730
4731 const vec3 shift = make_vec3(100.f, 200.f, 300.f);
4732 DOCTEST_CHECK_NOTHROW(cloud.coordinateShift(shift));
4733
4734 // Moving hit: origin labels must have shifted by the same amount as the position.
4735 DOCTEST_CHECK(cloud.getHitData(0, "origin_x") == doctest::Approx(1.0 + 100.0));
4736 DOCTEST_CHECK(cloud.getHitData(0, "origin_y") == doctest::Approx(2.0 + 200.0));
4737 DOCTEST_CHECK(cloud.getHitData(0, "origin_z") == doctest::Approx(3.0 + 300.0));
4738 DOCTEST_CHECK(cloud.getHitXYZ(0).x == doctest::Approx(5.f + 100.f));
4739
4740 // Static hit: still carries no origin labels.
4741 DOCTEST_CHECK(cloud.doesHitDataExist(1, "origin_x") == false);
4742 DOCTEST_CHECK(cloud.getHitXYZ(1).x == doctest::Approx(10.f + 100.f));
4743}
4744
4745DOCTEST_TEST_CASE("LiDAR Synthetic Scan Texture Color Sampling") {
4746 // A synthetic scan over a textured leaf primitive must color each hit point from the texture
4747 // RGB at the intersection. Because transparent texels are rejected at the geometry level, every
4748 // recorded hit must land on an opaque (colored) texel - none should come back black (0,0,0).
4749 // GrapeLeaf.png has a colored opaque leaf silhouette and transparent corners.
4750 const char *texture = "plugins/visualizer/textures/GrapeLeaf.png";
4751
4753 // Tilt the patch out of the z=0 plane so the scan's domain bounding box is not degenerate.
4754 uint patch = context.addPatch(make_vec3(0, 0, 0), make_vec2(2, 2), make_SphericalCoord(0.6f, 0.0f), texture);
4755 DOCTEST_CHECK(context.primitiveTextureHasTransparencyChannel(patch));
4756
4757 LiDARcloud lidar;
4758 lidar.disableMessages();
4759
4760 std::vector<std::string> columnFormat = {"x", "y", "z"};
4761 ScanMetadata scan(make_vec3(0, 0, 5), 60, 0.0f, float(M_PI), 60, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
4762 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
4763
4764 // rays_per_pulse=1 (single return) so the hit position is exact for color sampling.
4765 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, 1, 0.5f));
4766
4767 uint hit_count = lidar.getHitCount();
4768 DOCTEST_CHECK(hit_count > 0);
4769
4770 uint black_hits = 0;
4771 for (uint i = 0; i < hit_count; i++) {
4772 RGBcolor c = lidar.getHitColor(i);
4773 // Colors must be valid and within [0,1].
4774 DOCTEST_CHECK(c.r >= 0.f);
4775 DOCTEST_CHECK(c.r <= 1.f);
4776 DOCTEST_CHECK(c.g >= 0.f);
4777 DOCTEST_CHECK(c.g <= 1.f);
4778 DOCTEST_CHECK(c.b >= 0.f);
4779 DOCTEST_CHECK(c.b <= 1.f);
4780 if (c.r == 0.f && c.g == 0.f && c.b == 0.f) {
4781 black_hits++;
4782 }
4783 }
4784
4785 // No hit should be black: transparent texels are rejected, opaque texels are colored leaf pixels.
4786 DOCTEST_CHECK(black_hits == 0);
4787}
4788
4789DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - ASCII Multi-Return Cloud") {
4790 // Regression guard for the non-deterministic crash (SIGSEGV) on the multi-return
4791 // file-import path (loadXML -> gapfillMisses -> calculateLeafArea).
4792 //
4793 // The crash had two contributing issues, both exercised here:
4794 // 1. ROOT CAUSE: loadXML used a fixed char[100] stack buffer with strcpy/strcat
4795 // for the scan data-file path, so a long <filename> path overflowed the stack
4796 // and corrupted memory non-deterministically. This test therefore references
4797 // the fixture through a deliberately long absolute path (>100 chars) so the
4798 // overflow would have been triggered.
4799 // 2. Defensive hardening in gapfillMisses (size_t underflow on size()-1 loops,
4800 // uninitialized last elements, unbounded Ngap) for ASCII clouds with no
4801 // row/column indices, whose (theta,phi) grid is reconstructed from timestamps.
4802 //
4803 // The fixture (leafcube_multi.xyz) is a multi-return scan of the
4804 // LAI=2 leaf cube (true LAD = 2.0 m^2/m^3, G(theta)=0.5). It is loaded + gapfilled
4805 // on a fresh cloud many times so any residual non-determinism is exercised, then
4806 // the recovered leaf-area density is checked.
4807
4808 namespace fs = std::filesystem;
4809
4810 // The build copies data/ to <build>/plugins/lidar/data/, and tests run from
4811 // <build>. To exercise the loadXML stack-buffer overflow deterministically -
4812 // regardless of how short the build directory's absolute path is - copy the
4813 // fixture into a deeply nested subdirectory whose path is guaranteed to exceed the
4814 // old 100-byte buffer.
4815 fs::path data_src = "plugins/lidar/data/leafcube_multi.xyz";
4816 DOCTEST_REQUIRE(fs::exists(data_src));
4817
4818 // A nested directory chain that, combined with the absolute build path, comfortably
4819 // exceeds 100 characters. Each segment is long and the chain is deep so the total
4820 // is long even from a short CWD.
4821 fs::path long_dir = fs::absolute("lidar_longpath_overflow_regression_dir/"
4822 "subdirectory_padding_to_exceed_one_hundred_byte_buffer/"
4823 "additional_nesting_for_safety_margin");
4824 fs::create_directories(long_dir);
4825 fs::path data_abs = long_dir / "leafcube_multi.xyz";
4826 fs::copy_file(data_src, data_abs, fs::copy_options::overwrite_existing);
4827
4828 std::string data_path_str = data_abs.string();
4829 DOCTEST_REQUIRE_MESSAGE(data_path_str.size() > 100, "Fixture path must exceed the old 100-byte buffer to exercise the loadXML "
4830 "overflow; got length "
4831 << data_path_str.size() << " (" << data_path_str << ")");
4832
4833 const char *test_xml = "lidar_leafcube_multi_longpath_test.xml";
4834 {
4835 std::ofstream xml(test_xml);
4836 xml << "<?xml version=\"1.0\"?>\n<helios>\n<scan>\n"
4837 << " <filename> " << data_path_str << " </filename>\n"
4838 << " <ASCII_format> x y z timestamp target_index target_count </ASCII_format>\n"
4839 << " <origin> -5.000000 0.000000 0.500000 </origin>\n"
4840 << " <size> 800 1600 </size>\n"
4841 << "</scan>\n"
4842 << "<grid>\n <center> 0 0 0.5 </center>\n <size> 1 1 1 </size>\n"
4843 << " <Nx> 1 </Nx>\n <Ny> 1 </Ny>\n <Nz> 1 </Nz>\n</grid>\n"
4844 << "</helios>\n";
4845 }
4846
4847 const int N_repeats = 25; // crash was ~1-in-2 to 1-in-3; this makes a miss vanishingly unlikely
4848
4849 for (int rep = 0; rep < N_repeats; rep++) {
4850 LiDARcloud lidar;
4851 lidar.disableMessages();
4852
4853 // loadXML must not overflow on the long path; gapfillMisses must not read out
4854 // of bounds on the ASCII-loaded, row/column-less multi-return cloud.
4855 DOCTEST_CHECK_NOTHROW(lidar.loadXML(test_xml));
4856 DOCTEST_CHECK_NOTHROW(lidar.gapfillMisses());
4857 }
4858
4859 // On a final pass, run the full LAD pipeline (the Phytograph file-import path:
4860 // triangulate -> gapfill -> calculateLeafArea) and check the recovered value.
4861 LiDARcloud lidar;
4862 lidar.disableMessages();
4863 DOCTEST_CHECK_NOTHROW(lidar.loadXML(test_xml));
4864 DOCTEST_CHECK_NOTHROW(lidar.triangulateHitPoints(0.04, 10));
4865 DOCTEST_CHECK_NOTHROW(lidar.gapfillMisses());
4866
4868 DOCTEST_CHECK_NOTHROW(lidar.calculateLeafArea(&context));
4869
4870 float LAD = lidar.getCellLeafAreaDensity(0);
4871
4872 DOCTEST_CHECK(LAD == LAD); // not NaN
4873 // True LAD of the LAI=2 leaf cube is 2.0 m^2/m^3. Reconstructing misses from an
4874 // ASCII multi-return cloud introduces more bias than the synthetic path, so use a
4875 // generous-but-meaningful band: this still fails hard on garbage (0, huge, NaN)
4876 // while tolerating the reconstruction's known bias.
4877 DOCTEST_CHECK(LAD > 1.0f);
4878 DOCTEST_CHECK(LAD < 3.0f);
4879
4880 std::remove(test_xml);
4881 std::error_code ec;
4882 fs::remove_all("lidar_longpath_overflow_regression_dir", ec);
4883}
4884
4885DOCTEST_TEST_CASE("LiDAR Synthetic Scan Scanner Tilt Test") {
4886 // Scanner tilt rotates the entire fan of ray directions about the scanner origin (roll about world x, then pitch about
4887 // world y), modeling the residual tilt a real terrestrial scanner's dual-axis inclinometer reports. We verify that:
4888 // (1) zero tilt is an exact no-op (same hits, same positions, as an untilted scan),
4889 // (2) a known tilt rotates each beam's hit direction by exactly that tilt (the core geometric check),
4890 // (3) roll and pitch act about the correct, independent axes (not swapped), and
4891 // (4) the <scanTilt> XML tag is parsed into the scan metadata in radians.
4892
4893 // Box of patches surrounding the scanner so that beams in all tilt directions strike a surface at a known distance.
4894 // Scanner sits at the origin; six walls of a 10 m cube give every direction a target.
4896 context.addPatch(make_vec3(0, 0, -5), make_vec2(20, 20)); // floor
4897 context.addPatch(make_vec3(0, 0, 5), make_vec2(20, 20)); // ceiling
4898 context.addPatch(make_vec3(5, 0, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.f)); // +x wall
4899 context.addPatch(make_vec3(-5, 0, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.f)); // -x wall
4900 context.addPatch(make_vec3(0, 5, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.5f * float(M_PI))); // +y wall
4901 context.addPatch(make_vec3(0, -5, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.5f * float(M_PI))); // -y wall
4902
4903 const vec3 scan_origin(0.0f, 0.0f, 0.0f);
4904 const uint Ntheta = 30;
4905 const uint Nphi = 30;
4906 const float phiMin = 0.0f;
4907 const float phiMax = 2.0f * float(M_PI);
4908 const float exitDiameter = 0.0f;
4909 const float beamDivergence = 0.0f;
4910 std::vector<std::string> columnFormat;
4911
4912 // Helper: run a synthetic scan with the given tilt and return hit directions (unit vectors from the scanner origin).
4913 // A near-nadir cone is used so that, for the modest tilts tested, every beam strikes the floor both level and tilted
4914 // (no beam crosses to a different wall or misses). This keeps the hit list in exact beam-for-beam correspondence between
4915 // a level and a tilted scan, which the rotation-invariant check below relies on.
4916 const float cone_thetaMin = 0.80f * float(M_PI);
4917 const float cone_thetaMax = float(M_PI);
4918 auto run_scan = [&](float roll, float pitch, std::vector<vec3> &dirs) {
4919 LiDARcloud lidar;
4920 lidar.disableMessages();
4921 ScanMetadata scan(scan_origin, Ntheta, cone_thetaMin, cone_thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat, roll, pitch);
4922 lidar.addScan(scan);
4923 lidar.setScanDetectionThreshold(0, 0.f); // geometry test: keep every beam's return (the noise floor, default 0.05, would drop returns by incidence angle and make tilted/level counts differ)
4924 lidar.syntheticScan(&context);
4925 uint hc = lidar.getHitCount();
4926 dirs.clear();
4927 dirs.reserve(hc);
4928 for (uint i = 0; i < hc; i++) {
4929 vec3 d = lidar.getHitXYZ(i) - scan_origin;
4930 d.normalize();
4931 dirs.push_back(d);
4932 }
4933 };
4934
4935 // (1) Zero tilt no-op: explicit zero tilt must reproduce an untilted scan hit-for-hit. The beam grid is deterministic
4936 // and noise is off, so the clouds must match exactly.
4937 {
4938 std::vector<vec3> dirs_default, dirs_zero;
4939 run_scan(0.0f, 0.0f, dirs_zero);
4940 // Untilted via the default constructor (no tilt args), same beam grid as run_scan() — should be identical.
4941 LiDARcloud lidar_default;
4942 lidar_default.disableMessages();
4943 ScanMetadata scan(scan_origin, Ntheta, cone_thetaMin, cone_thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
4944 lidar_default.addScan(scan);
4945 lidar_default.setScanDetectionThreshold(0, 0.f); // match run_scan()'s disabled noise floor so the zero-tilt no-op compares like-for-like
4946 lidar_default.syntheticScan(&context);
4947 uint hc = lidar_default.getHitCount();
4948 for (uint i = 0; i < hc; i++) {
4949 vec3 d = lidar_default.getHitXYZ(i) - scan_origin;
4950 d.normalize();
4951 dirs_default.push_back(d);
4952 }
4953 DOCTEST_REQUIRE(dirs_zero.size() == dirs_default.size());
4954 DOCTEST_CHECK(dirs_zero.size() > 0);
4955 for (size_t i = 0; i < dirs_zero.size(); i++) {
4956 DOCTEST_CHECK(dirs_zero[i].x == doctest::Approx(dirs_default[i].x).epsilon(1e-5));
4957 DOCTEST_CHECK(dirs_zero[i].y == doctest::Approx(dirs_default[i].y).epsilon(1e-5));
4958 DOCTEST_CHECK(dirs_zero[i].z == doctest::Approx(dirs_default[i].z).epsilon(1e-5));
4959 }
4960 }
4961
4962 // (2) Core geometric check: with a known tilt, each beam's hit direction must equal the corresponding level-scan hit
4963 // direction rotated by exactly that tilt, using the right-handed body-frame axes: roll about the lateral axis, then
4964 // pitch about the forward (azimuth-zero) axis. The grid is identical and deterministic, so hits correspond
4965 // beam-for-beam. run_scan() uses phiMin = 0, for which the lateral axis is world +x and the forward axis is world +y.
4966 {
4967 const float roll = 7.0f * float(M_PI) / 180.0f;
4968 const float pitch = 11.0f * float(M_PI) / 180.0f;
4969 // Body axes for phiMin = 0 (the value run_scan uses).
4970 const vec3 lateral_axis = make_vec3(cosf(phiMin), -sinf(phiMin), 0.f); // X_body (roll axis)
4971 const vec3 forward_axis = make_vec3(sinf(phiMin), cosf(phiMin), 0.f); // Y_body (pitch axis)
4972 std::vector<vec3> level_dirs, tilted_dirs;
4973 run_scan(0.0f, 0.0f, level_dirs);
4974 run_scan(roll, pitch, tilted_dirs);
4975 DOCTEST_REQUIRE(level_dirs.size() == tilted_dirs.size());
4976 DOCTEST_CHECK(level_dirs.size() > 0);
4977 const vec3 pivot = make_vec3(0, 0, 0);
4978 for (size_t i = 0; i < level_dirs.size(); i++) {
4979 vec3 expected = rotatePointAboutLine(level_dirs[i], pivot, lateral_axis, roll);
4980 expected = rotatePointAboutLine(expected, pivot, forward_axis, pitch);
4981 DOCTEST_CHECK(tilted_dirs[i].x == doctest::Approx(expected.x).epsilon(1e-3));
4982 DOCTEST_CHECK(tilted_dirs[i].y == doctest::Approx(expected.y).epsilon(1e-3));
4983 DOCTEST_CHECK(tilted_dirs[i].z == doctest::Approx(expected.z).epsilon(1e-3));
4984 }
4985 }
4986
4987 // (3) Roll/pitch axis independence: a near-nadir beam pointing straight down (0,0,-1) tilts predictably. The body frame
4988 // here uses phiMin = 0, so the forward (pitch) axis is world +y and the lateral (roll) axis is world +x. The rotations
4989 // are right-handed (consistent with Helios' right-hand-rule convention and a right-handed Z-up scanner body frame),
4990 // so for a positive tilt angle:
4991 // - Pitch (right-hand rotation about +y) rotates the nadir beam into the x-z plane: the floor hit is displaced in -x, y ~ 0.
4992 // - Roll (right-hand rotation about +x) rotates the nadir beam into the y-z plane: the floor hit is displaced in +y, x ~ 0.
4993 // The key assertion is that pitch moves the hit purely in x and roll purely in y (the two are not swapped and act on
4994 // independent axes). The signs are the standard right-handed result and are cross-checked against the analytic
4995 // rotation in part (2) above.
4996 {
4997 // A narrow cone tightly around nadir; the mean hit on the floor isolates the tilt direction.
4998 const uint Nt = 8, Np = 8;
4999 const float tmin = 0.97f * float(M_PI);
5000 const float tmax = float(M_PI);
5001 const float tilt = 10.0f * float(M_PI) / 180.0f;
5002
5003 auto mean_floor_hit = [&](float roll, float pitch) -> vec3 {
5004 LiDARcloud lidar;
5005 lidar.disableMessages();
5006 ScanMetadata scan(scan_origin, Nt, tmin, tmax, Np, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat, roll, pitch);
5007 lidar.addScan(scan);
5008 lidar.syntheticScan(&context);
5009 uint hc = lidar.getHitCount();
5010 vec3 mean = make_vec3(0, 0, 0);
5011 uint n = 0;
5012 for (uint i = 0; i < hc; i++) {
5013 vec3 p = lidar.getHitXYZ(i);
5014 if (p.z < -1.0f) { // floor hits only
5015 mean = mean + p;
5016 n++;
5017 }
5018 }
5019 DOCTEST_REQUIRE(n > 0);
5020 return mean / float(n);
5021 };
5022
5023 vec3 pitch_hit = mean_floor_hit(0.0f, tilt); // pitch about +y -> displacement in x (here -x)
5024 DOCTEST_CHECK(pitch_hit.x < -0.5f);
5025 DOCTEST_CHECK(fabs(pitch_hit.y) < 0.2f);
5026
5027 vec3 roll_hit = mean_floor_hit(tilt, 0.0f); // roll about +x -> displacement in y (here +y)
5028 DOCTEST_CHECK(roll_hit.y > 0.5f);
5029 DOCTEST_CHECK(fabs(roll_hit.x) < 0.2f);
5030 }
5031
5032 // (4) XML round-trip: <scanTilt> roll pitch </scanTilt> is parsed into metadata, converted degrees->radians.
5033 {
5034 const char *tilt_xml = "lidar_scantilt_xml_test.xml";
5035 std::ofstream ofs(tilt_xml);
5036 ofs << "<helios>\n"
5037 << " <scan>\n"
5038 << " <origin> 0 0 0 </origin>\n"
5039 << " <size> 10 10 </size>\n"
5040 << " <scanTilt> 5 3 </scanTilt>\n"
5041 << " </scan>\n"
5042 << " <scan>\n"
5043 << " <origin> 0 0 0 </origin>\n"
5044 << " <size> 10 10 </size>\n"
5045 << " </scan>\n"
5046 << "</helios>\n";
5047 ofs.close();
5048
5049 LiDARcloud lidar;
5050 lidar.disableMessages();
5051 DOCTEST_CHECK_NOTHROW(lidar.loadXML(tilt_xml));
5052 DOCTEST_REQUIRE(lidar.getScanCount() == 2);
5053 DOCTEST_CHECK(lidar.getScanTiltRoll(0) == doctest::Approx(5.0f * float(M_PI) / 180.0f).epsilon(1e-5));
5054 DOCTEST_CHECK(lidar.getScanTiltPitch(0) == doctest::Approx(3.0f * float(M_PI) / 180.0f).epsilon(1e-5));
5055 // No tag -> level (0,0)
5056 DOCTEST_CHECK(lidar.getScanTiltRoll(1) == doctest::Approx(0.0f));
5057 DOCTEST_CHECK(lidar.getScanTiltPitch(1) == doctest::Approx(0.0f));
5058
5059 std::remove(tilt_xml);
5060 }
5061
5062 // (5) Azimuth-zero coupling: the tilt axes are defined relative to the scan's azimuth-zero (phiMin) facing direction, not
5063 // the fixed world axes. A pitch-only tilt with phiMin rotated by 90 degrees must rotate the nadir beam about a
5064 // correspondingly rotated forward axis. We verify the tilted near-nadir floor hit matches the analytic rotation of the
5065 // level beam about the phiMin-dependent body forward axis. This is what distinguishes the azimuth-zero convention from
5066 // a fixed-world-axis tilt: at phiMin = pi/2 the forward (pitch) axis is world +x (not +y), so the nadir-beam
5067 // displacement is +y, whereas at phiMin = 0 (part 3) the same pitch produced a -x displacement.
5068 {
5069 const uint Nt = 8, Np = 8;
5070 const float tmin = 0.97f * float(M_PI);
5071 const float tmax = float(M_PI);
5072 const float tilt = 12.0f * float(M_PI) / 180.0f;
5073 const float phi0 = 0.5f * float(M_PI); // azimuth-zero rotated 90 degrees
5074
5075 // Forward (pitch) axis of the body frame for this phiMin.
5076 const vec3 forward_axis = make_vec3(sinf(phi0), cosf(phi0), 0.f);
5077
5078 LiDARcloud lidar;
5079 lidar.disableMessages();
5080 // phiMin = phi0, full azimuth sweep so the near-nadir cone is still complete.
5081 ScanMetadata scan(scan_origin, Nt, tmin, tmax, Np, phi0, phi0 + 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat, 0.0f, tilt);
5082 lidar.addScan(scan);
5083 lidar.syntheticScan(&context);
5084 uint hc = lidar.getHitCount();
5085 vec3 mean = make_vec3(0, 0, 0);
5086 uint n = 0;
5087 for (uint i = 0; i < hc; i++) {
5088 vec3 p = lidar.getHitXYZ(i);
5089 if (p.z < -1.0f) {
5090 mean = mean + p;
5091 n++;
5092 }
5093 }
5094 DOCTEST_REQUIRE(n > 0);
5095 mean = mean / float(n);
5096 vec3 mean_dir = mean;
5097 mean_dir.normalize();
5098
5099 // Expected: the nadir beam (0,0,-1) rotated (right-handed) about the phi0 forward axis by the pitch angle.
5100 vec3 expected_dir = rotatePointAboutLine(make_vec3(0, 0, -1), make_vec3(0, 0, 0), forward_axis, tilt);
5101 expected_dir.normalize();
5102
5103 DOCTEST_CHECK(mean_dir.x == doctest::Approx(expected_dir.x).epsilon(2e-2));
5104 DOCTEST_CHECK(mean_dir.y == doctest::Approx(expected_dir.y).epsilon(2e-2));
5105 DOCTEST_CHECK(mean_dir.z == doctest::Approx(expected_dir.z).epsilon(2e-2));
5106
5107 // Sanity: at phiMin = pi/2 the displacement is in +y (not the -x seen at phiMin = 0), confirming the axes rotated.
5108 DOCTEST_CHECK(mean.y > 0.5f);
5109 DOCTEST_CHECK(fabs(mean.x) < 0.3f);
5110 }
5111}
5112
5113DOCTEST_TEST_CASE("LiDAR Synthetic Scan Scanner Azimuth Offset Test") {
5114 // The azimuth offset is the scanner's compass heading (yaw): a right-hand rotation of the entire fan of ray directions
5115 // about the world +z axis, applied on top of the per-scan azimuth sweep [phiMin, phiMax]. We verify that:
5116 // (1) zero azimuth offset is an exact no-op (same hits as a scan with no offset),
5117 // (2) a known azimuth offset rotates each beam's hit direction by exactly that offset about world +z,
5118 // (3) the azimuth offset composes with tilt (the offset rotates the body frame, so a pitch tilt under a 90-degree
5119 // offset displaces the nadir beam about the rotated forward axis), and
5120 // (4) the <scanAzimuthOffset> XML tag is parsed into the scan metadata in radians.
5121
5122 // Same surrounding box as the tilt test so every beam strikes a wall at a known distance.
5124 context.addPatch(make_vec3(0, 0, -5), make_vec2(20, 20)); // floor
5125 context.addPatch(make_vec3(0, 0, 5), make_vec2(20, 20)); // ceiling
5126 context.addPatch(make_vec3(5, 0, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.f)); // +x wall
5127 context.addPatch(make_vec3(-5, 0, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.f)); // -x wall
5128 context.addPatch(make_vec3(0, 5, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.5f * float(M_PI))); // +y wall
5129 context.addPatch(make_vec3(0, -5, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.5f * float(M_PI))); // -y wall
5130
5131 const vec3 scan_origin(0.0f, 0.0f, 0.0f);
5132 const uint Ntheta = 30;
5133 const uint Nphi = 30;
5134 const float phiMin = 0.0f;
5135 const float phiMax = 2.0f * float(M_PI);
5136 const float exitDiameter = 0.0f;
5137 const float beamDivergence = 0.0f;
5138 std::vector<std::string> columnFormat;
5139
5140 // A near-nadir cone keeps every beam on the floor for both the offset and non-offset scans, so hits stay in
5141 // beam-for-beam correspondence (the azimuth offset only spins the cone about +z, it does not move beams off the floor).
5142 const float cone_thetaMin = 0.80f * float(M_PI);
5143 const float cone_thetaMax = float(M_PI);
5144 auto run_scan = [&](float azimuth_offset, std::vector<vec3> &dirs) {
5145 LiDARcloud lidar;
5146 lidar.disableMessages();
5147 ScanMetadata scan(scan_origin, Ntheta, cone_thetaMin, cone_thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat, 0.0f, 0.0f, azimuth_offset);
5148 lidar.addScan(scan);
5149 lidar.setScanDetectionThreshold(0, 0.f); // geometry test: keep every beam's return so offset/non-offset counts stay in beam-for-beam correspondence
5150 lidar.syntheticScan(&context);
5151 uint hc = lidar.getHitCount();
5152 dirs.clear();
5153 dirs.reserve(hc);
5154 for (uint i = 0; i < hc; i++) {
5155 vec3 d = lidar.getHitXYZ(i) - scan_origin;
5156 d.normalize();
5157 dirs.push_back(d);
5158 }
5159 };
5160
5161 // (1) Zero offset no-op: explicit zero azimuth offset must reproduce a scan with no offset, hit-for-hit.
5162 {
5163 std::vector<vec3> dirs_default, dirs_zero;
5164 run_scan(0.0f, dirs_zero);
5165 LiDARcloud lidar_default;
5166 lidar_default.disableMessages();
5167 ScanMetadata scan(scan_origin, Ntheta, cone_thetaMin, cone_thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
5168 lidar_default.addScan(scan);
5169 lidar_default.setScanDetectionThreshold(0, 0.f); // match run_scan()'s disabled noise floor so the zero-offset no-op compares like-for-like
5170 lidar_default.syntheticScan(&context);
5171 uint hc = lidar_default.getHitCount();
5172 for (uint i = 0; i < hc; i++) {
5173 vec3 d = lidar_default.getHitXYZ(i) - scan_origin;
5174 d.normalize();
5175 dirs_default.push_back(d);
5176 }
5177 DOCTEST_REQUIRE(dirs_zero.size() == dirs_default.size());
5178 DOCTEST_CHECK(dirs_zero.size() > 0);
5179 for (size_t i = 0; i < dirs_zero.size(); i++) {
5180 DOCTEST_CHECK(dirs_zero[i].x == doctest::Approx(dirs_default[i].x).epsilon(1e-5));
5181 DOCTEST_CHECK(dirs_zero[i].y == doctest::Approx(dirs_default[i].y).epsilon(1e-5));
5182 DOCTEST_CHECK(dirs_zero[i].z == doctest::Approx(dirs_default[i].z).epsilon(1e-5));
5183 }
5184 }
5185
5186 // (2) Core geometric check: with a known azimuth offset, each beam's hit direction must equal the corresponding
5187 // no-offset hit direction rotated about world +z by exactly that offset. The grid is identical and deterministic,
5188 // so hits correspond beam-for-beam.
5189 {
5190 const float azimuth_offset = 35.0f * float(M_PI) / 180.0f;
5191 const vec3 vertical_axis = make_vec3(0.f, 0.f, 1.f);
5192 std::vector<vec3> base_dirs, offset_dirs;
5193 run_scan(0.0f, base_dirs);
5194 run_scan(azimuth_offset, offset_dirs);
5195 DOCTEST_REQUIRE(base_dirs.size() == offset_dirs.size());
5196 DOCTEST_CHECK(base_dirs.size() > 0);
5197 const vec3 pivot = make_vec3(0, 0, 0);
5198 for (size_t i = 0; i < base_dirs.size(); i++) {
5199 vec3 expected = rotatePointAboutLine(base_dirs[i], pivot, vertical_axis, azimuth_offset);
5200 DOCTEST_CHECK(offset_dirs[i].x == doctest::Approx(expected.x).epsilon(1e-3));
5201 DOCTEST_CHECK(offset_dirs[i].y == doctest::Approx(expected.y).epsilon(1e-3));
5202 DOCTEST_CHECK(offset_dirs[i].z == doctest::Approx(expected.z).epsilon(1e-3));
5203 }
5204 }
5205
5206 // (3) Offset composes with tilt: a pitch-only tilt under a 90-degree azimuth offset must rotate the body forward axis the
5207 // SAME way as the offset rotates the rays. The offset is a right-hand (CCW) rotation about world +z, and azimuth phi is
5208 // measured CW-from-+y, so advancing the heading by the offset SUBTRACTS it: the forward axis is built from
5209 // (phiMin - azimuth_offset), which for phiMin = 0, az = +90 deg is world -x (not +x). A right-hand pitch about -x then
5210 // displaces the near-nadir floor hit to -y (not the +y a +x forward axis would give). This both confirms the offset
5211 // rotates the roll/pitch body frame and pins the handedness: the buggy heading = phiMin + azimuth_offset builds the
5212 // forward axis as +x and sends the hit to +y, so the mean.y < -0.5 assertion below fails without the sign fix.
5213 {
5214 const uint Nt = 8, Np = 8;
5215 const float tmin = 0.97f * float(M_PI);
5216 const float tmax = float(M_PI);
5217 const float pitch = 12.0f * float(M_PI) / 180.0f;
5218 const float scan_phiMin = 0.0f;
5219 const float azimuth_offset = 0.5f * float(M_PI); // 90-degree heading offset
5220
5221 LiDARcloud lidar;
5222 lidar.disableMessages();
5223 ScanMetadata scan(scan_origin, Nt, tmin, tmax, Np, scan_phiMin, scan_phiMin + 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat, 0.0f, pitch, azimuth_offset);
5224 lidar.addScan(scan);
5225 lidar.syntheticScan(&context);
5226 uint hc = lidar.getHitCount();
5227 vec3 mean = make_vec3(0, 0, 0);
5228 uint n = 0;
5229 for (uint i = 0; i < hc; i++) {
5230 vec3 p = lidar.getHitXYZ(i);
5231 if (p.z < -1.0f) { // floor hits only
5232 mean = mean + p;
5233 n++;
5234 }
5235 }
5236 DOCTEST_REQUIRE(n > 0);
5237 mean = mean / float(n);
5238
5239 // forward (pitch) axis after the 90-degree heading offset is world -x (heading = phiMin - azimuth_offset), so a
5240 // right-hand pitch displaces nadir to -y.
5241 const float heading = scan_phiMin - azimuth_offset;
5242 const vec3 forward_axis = make_vec3(sinf(heading), cosf(heading), 0.f);
5243 vec3 expected_dir = rotatePointAboutLine(make_vec3(0, 0, -1), make_vec3(0, 0, 0), forward_axis, pitch);
5244 expected_dir.normalize();
5245 vec3 mean_dir = mean;
5246 mean_dir.normalize();
5247 DOCTEST_CHECK(mean_dir.x == doctest::Approx(expected_dir.x).epsilon(2e-2));
5248 DOCTEST_CHECK(mean_dir.y == doctest::Approx(expected_dir.y).epsilon(2e-2));
5249 DOCTEST_CHECK(mean_dir.z == doctest::Approx(expected_dir.z).epsilon(2e-2));
5250 DOCTEST_CHECK(mean.y < -0.5f); // fails under the buggy heading = phiMin + azimuth_offset (which sends the hit to +y)
5251 DOCTEST_CHECK(fabs(mean.x) < 0.3f);
5252 }
5253
5254 // (4) XML round-trip: <scanAzimuthOffset> N </scanAzimuthOffset> is parsed into metadata, converted degrees->radians.
5255 {
5256 const char *az_xml = "lidar_scanazimuth_xml_test.xml";
5257 std::ofstream ofs(az_xml);
5258 ofs << "<helios>\n"
5259 << " <scan>\n"
5260 << " <origin> 0 0 0 </origin>\n"
5261 << " <size> 10 10 </size>\n"
5262 << " <scanAzimuthOffset> 45 </scanAzimuthOffset>\n"
5263 << " </scan>\n"
5264 << " <scan>\n"
5265 << " <origin> 0 0 0 </origin>\n"
5266 << " <size> 10 10 </size>\n"
5267 << " </scan>\n"
5268 << "</helios>\n";
5269 ofs.close();
5270
5271 LiDARcloud lidar;
5272 lidar.disableMessages();
5273 DOCTEST_CHECK_NOTHROW(lidar.loadXML(az_xml));
5274 DOCTEST_REQUIRE(lidar.getScanCount() == 2);
5275 DOCTEST_CHECK(lidar.getScanAzimuthOffset(0) == doctest::Approx(45.0f * float(M_PI) / 180.0f).epsilon(1e-5));
5276 // No tag -> no offset (0)
5277 DOCTEST_CHECK(lidar.getScanAzimuthOffset(1) == doctest::Approx(0.0f));
5278
5279 std::remove(az_xml);
5280 }
5281}
5282
5283DOCTEST_TEST_CASE("LiDAR Synthetic Scan Continuous Azimuth Skew Test") {
5284 // A real terrestrial scanner sweeps the beam vertically with a fast mirror while the whole head rotates continuously in
5285 // azimuth, so the azimuth advances during each zenith sweep and the zenith columns are slightly skewed (tilted) rather
5286 // than perfectly vertical. The raster synthetic scan models this by drifting the azimuth across the inner (zenith) loop:
5287 // over one full column the azimuth advances by exactly one column step dphi (per-row increment dphi/Ntheta), so column j
5288 // tiles seamlessly into column j+1. We verify:
5289 // (1) within a column the azimuth drifts linearly by dphi from bottom row to (almost) the next column,
5290 // (2) the top row of column j meets the bottom row of column j+1 (seamless continuous rotation),
5291 // (3) a single-row (Ntheta==1) and a single-column (Nphi==1) raster scan produce no skew.
5292
5293 // Surrounding box so every beam strikes a wall at a known distance and hits stay in firing order (Ntheta*j + i).
5295 context.addPatch(make_vec3(0, 0, -5), make_vec2(20, 20)); // floor
5296 context.addPatch(make_vec3(0, 0, 5), make_vec2(20, 20)); // ceiling
5297 context.addPatch(make_vec3(5, 0, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.f)); // +x wall
5298 context.addPatch(make_vec3(-5, 0, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.f)); // -x wall
5299 context.addPatch(make_vec3(0, 5, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.5f * float(M_PI))); // +y wall
5300 context.addPatch(make_vec3(0, -5, 0), make_vec2(20, 20), make_SphericalCoord(0.5f * float(M_PI), 0.5f * float(M_PI))); // -y wall
5301
5302 const vec3 scan_origin(0.0f, 0.0f, 0.0f);
5303 const float exitDiameter = 0.0f;
5304 const float beamDivergence = 0.0f;
5305 std::vector<std::string> columnFormat;
5306
5307 // Collect every beam's emitted azimuth (radians) in firing order. A down-and-out cone (theta well away from nadir) keeps
5308 // the horizontal component substantial so the azimuth recovered from the hit direction is numerically well-conditioned,
5309 // and within a modest azimuth span every beam strikes the same wall so the cloud stays in beam-for-beam order.
5310 auto run_azimuths = [&](uint Ntheta, uint Nphi, float thetaMin, float thetaMax, float phiMin, float phiMax, std::vector<float> &azim) {
5311 LiDARcloud lidar;
5312 lidar.disableMessages();
5313 ScanMetadata scan(scan_origin, Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, exitDiameter, beamDivergence, 0.0f, 0.0f, columnFormat);
5314 lidar.addScan(scan);
5315 lidar.setScanDetectionThreshold(0, 0.f); // keep every beam's return so hits stay in firing order (Ntheta*j + i)
5316 lidar.syntheticScan(&context);
5317 uint hc = lidar.getHitCount();
5318 azim.clear();
5319 azim.reserve(hc);
5320 for (uint i = 0; i < hc; i++) {
5321 vec3 d = lidar.getHitXYZ(i) - scan_origin;
5322 azim.push_back(cart2sphere(d).azimuth);
5323 }
5324 };
5325
5326 // A down-and-out cone (theta = 144..171 deg, i.e. 9..36 deg above nadir) so every beam strikes the floor at a known
5327 // distance well within its 20x20 extent, giving exactly Ntheta*Nphi hits in firing order. The cone never reaches nadir
5328 // (theta = 180 deg), so the azimuth recovered from each hit direction stays well away from the atan2 singularity.
5329 const float thetaMin = 144.0f * float(M_PI) / 180.0f; // 36 deg above nadir
5330 const float thetaMax = 171.0f * float(M_PI) / 180.0f; // 9 deg above nadir
5331 const float phiMin = 0.0f;
5332 const float phiMax = 30.0f * float(M_PI) / 180.0f; // narrow azimuth fan
5333
5334 // (1)+(2) Skew within and across columns.
5335 {
5336 const uint Ntheta = 12;
5337 const uint Nphi = 6;
5338 std::vector<float> azim;
5339 run_azimuths(Ntheta, Nphi, thetaMin, thetaMax, phiMin, phiMax, azim);
5340 DOCTEST_REQUIRE(azim.size() == size_t(Ntheta) * Nphi);
5341
5342 const float dphi = (phiMax - phiMin) / float(Nphi - 1); // column-to-column azimuth step
5343 const float dphi_per_row = dphi / float(Ntheta); // modeled per-row drift
5344
5345 // Within each column the azimuth must drift linearly: cell (i,j) azimuth = phiMin + j*dphi + i*dphi_per_row.
5346 for (uint j = 0; j < Nphi; j++) {
5347 for (uint i = 0; i < Ntheta; i++) {
5348 float expected = phiMin + float(j) * dphi + float(i) * dphi_per_row;
5349 DOCTEST_CHECK(azim[size_t(Ntheta) * j + i] == doctest::Approx(expected).epsilon(1e-4));
5350 }
5351 }
5352
5353 // The skew is real, not zero: the top row of a column differs from its bottom row by ~dphi (one column step).
5354 for (uint j = 0; j < Nphi; j++) {
5355 float bottom = azim[size_t(Ntheta) * j + 0];
5356 float top = azim[size_t(Ntheta) * j + (Ntheta - 1)];
5357 DOCTEST_CHECK((top - bottom) == doctest::Approx(dphi * float(Ntheta - 1) / float(Ntheta)).epsilon(1e-3));
5358 }
5359
5360 // (2) Seamless tiling: top row of column j meets bottom row of column j+1 (their azimuths differ by one per-row step).
5361 for (uint j = 0; j + 1 < Nphi; j++) {
5362 float top_j = azim[size_t(Ntheta) * j + (Ntheta - 1)];
5363 float bottom_next = azim[size_t(Ntheta) * (j + 1) + 0];
5364 DOCTEST_CHECK((bottom_next - top_j) == doctest::Approx(dphi_per_row).epsilon(1e-3));
5365 }
5366 }
5367
5368 // (3a) Single-row raster (Ntheta == 1): no vertical sweep, so no skew - the one row per column is at the nominal azimuth.
5369 {
5370 const uint Ntheta = 1;
5371 const uint Nphi = 6;
5372 std::vector<float> azim;
5373 run_azimuths(Ntheta, Nphi, thetaMin, thetaMax, phiMin, phiMax, azim);
5374 DOCTEST_REQUIRE(azim.size() == size_t(Nphi));
5375 const float dphi = (phiMax - phiMin) / float(Nphi - 1);
5376 for (uint j = 0; j < Nphi; j++) {
5377 DOCTEST_CHECK(azim[j] == doctest::Approx(phiMin + float(j) * dphi).epsilon(1e-4));
5378 }
5379 }
5380
5381 // (3b) Single-column raster (Nphi == 1): no azimuth motion (dphi == 0), so the whole column is at the constant azimuth.
5382 {
5383 const uint Ntheta = 12;
5384 const uint Nphi = 1;
5385 std::vector<float> azim;
5386 run_azimuths(Ntheta, Nphi, thetaMin, thetaMax, phiMin, phiMax, azim);
5387 DOCTEST_REQUIRE(azim.size() == size_t(Ntheta));
5388 for (uint i = 0; i < Ntheta; i++) {
5389 DOCTEST_CHECK(azim[i] == doctest::Approx(phiMin).epsilon(1e-4));
5390 }
5391 }
5392}
5393
5394// ---------------------------------------------------------------------------------------------------------------------
5395// Row/column-based miss gap filling
5396//
5397// These tests exercise the gapfillMisses() row/column reconstruction path, which fits a robust per-row generative model
5398// (zenith = zenith_lut[row]; azimuth = intercept[row] + slope[row]*column) from the returns and emits a miss for every
5399// empty grid cell. The returns are synthesized directly from a known generative model so the reconstructed miss
5400// directions can be checked against ground truth. Each return is added with "row"/"column" hit data (the same data the
5401// ASCII loader now attaches) which routes gapfillMisses() to the row/column path.
5402// ---------------------------------------------------------------------------------------------------------------------
5403
5404namespace {
5405
5406 // Generative scan-grid model used to synthesize returns and to provide ground-truth miss directions.
5407 // zenith and azimuth are smooth functions of (row, column). The azimuth has a per-row offset whose magnitude grows
5408 // with row (azimuth sweep / shear), and a small per-row slope across columns. The zenith has a mild quadratic
5409 // curvature in row (a tilt-like departure from a perfectly affine grid).
5410 struct GenerativeGrid {
5411 int Ntheta;
5412 int Nphi;
5413 double theta_min, theta_max;
5414 double phi_min, phi_max;
5415 double shear; // azimuth offset per row (radians) - the sweep
5416 double curve; // zenith curvature coefficient (radians) - tilt-like departure from affine
5417
5418 double zenith(int row) const {
5419 const double frac = double(row) / double(Ntheta - 1);
5420 // affine base plus a small symmetric quadratic bow
5421 return theta_min + (theta_max - theta_min) * frac + curve * (frac - 0.5) * (frac - 0.5);
5422 }
5423 double azimuth(int row, int col) const {
5424 const double col_frac = double(col) / double(Nphi - 1);
5425 const double base = phi_min + (phi_max - phi_min) * col_frac;
5426 return base + shear * double(row) / double(Ntheta - 1);
5427 }
5428 helios::SphericalCoord direction(int row, int col) const {
5429 return helios::make_SphericalCoord(1.f, 0.5f * float(M_PI) - float(zenith(row)), float(azimuth(row, col)));
5430 }
5431 };
5432
5433 // angular separation (radians) between two unit directions given as SphericalCoord
5434 double angularError(const helios::SphericalCoord &a, const helios::SphericalCoord &b) {
5435 helios::vec3 va = helios::sphere2cart(helios::make_SphericalCoord(1.f, a.elevation, a.azimuth));
5436 helios::vec3 vb = helios::sphere2cart(helios::make_SphericalCoord(1.f, b.elevation, b.azimuth));
5437 double d = va.x * vb.x + va.y * vb.y + va.z * vb.z;
5438 d = std::max(-1.0, std::min(1.0, d));
5439 return std::acos(d);
5440 }
5441
5442} // namespace
5443
5444DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Row/Column Idealized Grid") {
5445 // On a perfectly regular, leveled, noise-free grid the robust fit must recover the affine model, so reconstructed
5446 // miss directions should match the generative model to tight tolerance and every empty cell must be filled exactly
5447 // once.
5448 LiDARcloud lidar;
5449 lidar.disableMessages();
5450
5451 GenerativeGrid g{20, 36, 0.05, 0.95 * M_PI, 0.0, 2.0 * M_PI, 0.0, 0.0};
5452 ScanMetadata scan(make_vec3(0, 0, 0), g.Ntheta, g.theta_min, g.theta_max, g.Nphi, g.phi_min, g.phi_max, 0.0f, 0.0f, 0.0f, 0.0f, {});
5453 lidar.addScan(scan);
5454
5455 // Populate all cells EXCEPT a known interior rectangular blank region with returns.
5456 const int blank_r0 = 5, blank_r1 = 9, blank_c0 = 10, blank_c1 = 15;
5457 std::set<std::pair<int, int>> blanks;
5458 for (int row = 0; row < g.Ntheta; row++) {
5459 for (int col = 0; col < g.Nphi; col++) {
5460 if (row >= blank_r0 && row <= blank_r1 && col >= blank_c0 && col <= blank_c1) {
5461 blanks.insert({row, col});
5462 continue;
5463 }
5464 SphericalCoord dir = g.direction(row, col);
5466 std::map<std::string, double> data;
5467 data["row"] = row;
5468 data["column"] = col;
5469 lidar.addHitPoint(0, xyz, dir, make_RGBcolor(1, 0, 0), data);
5470 }
5471 }
5472
5473 uint hits_before = lidar.getHitCount();
5474 std::vector<vec3> filled = lidar.gapfillMisses(0, false, true);
5475
5476 // every blank cell filled exactly once
5477 DOCTEST_CHECK(filled.size() == blanks.size());
5478 DOCTEST_CHECK(lidar.getHitCount() == hits_before + blanks.size());
5479
5480 // reconstructed directions of the filled cells match the generative model tightly
5481 double max_err = 0.0;
5482 for (uint r = 0; r < lidar.getHitCount(); r++) {
5483 if (lidar.getHitScanID(r) != 0) {
5484 continue;
5485 }
5486 if (lidar.getHitData(r, "is_miss") != 1.0) {
5487 continue; // only check the gapfilled misses
5488 }
5489 int row = (int) std::lround(lidar.getHitData(r, "row"));
5490 int col = (int) std::lround(lidar.getHitData(r, "column"));
5491 double err = angularError(lidar.getHitRaydir(r), g.direction(row, col));
5492 max_err = std::max(max_err, err);
5493 }
5494 // tight: on an ideal grid the fit recovers the model to within a fraction of the grid spacing
5495 DOCTEST_CHECK(max_err < 1e-3);
5496}
5497
5498DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Row/Column Tilted and Sheared Grid") {
5499 // With a tilt-like zenith curvature and a per-row azimuth sweep (shear), the robust per-row model should reconstruct
5500 // miss directions much more accurately than the idealized affine rc2direction model.
5501 LiDARcloud lidar;
5502 lidar.disableMessages();
5503
5504 GenerativeGrid g{24, 48, 0.05, 0.95 * M_PI, 0.0, 1.5 * M_PI, 0.30, 0.20}; // strong shear + curvature
5505 ScanMetadata scan(make_vec3(0, 0, 0), g.Ntheta, g.theta_min, g.theta_max, g.Nphi, g.phi_min, g.phi_max, 0.0f, 0.0f, 0.0f, 0.0f, {});
5506 lidar.addScan(scan);
5507
5508 const int blank_r0 = 8, blank_r1 = 14, blank_c0 = 18, blank_c1 = 30;
5509 std::set<std::pair<int, int>> blanks;
5510 for (int row = 0; row < g.Ntheta; row++) {
5511 for (int col = 0; col < g.Nphi; col++) {
5512 if (row >= blank_r0 && row <= blank_r1 && col >= blank_c0 && col <= blank_c1) {
5513 blanks.insert({row, col});
5514 continue;
5515 }
5516 SphericalCoord dir = g.direction(row, col);
5518 std::map<std::string, double> data;
5519 data["row"] = row;
5520 data["column"] = col;
5521 lidar.addHitPoint(0, xyz, dir, make_RGBcolor(1, 0, 0), data);
5522 }
5523 }
5524
5525 std::vector<vec3> filled = lidar.gapfillMisses(0, false, true);
5526 DOCTEST_CHECK(filled.size() == blanks.size());
5527
5528 double max_err_fit = 0.0;
5529 double max_err_affine = 0.0;
5530 for (uint r = 0; r < lidar.getHitCount(); r++) {
5531 if (lidar.getHitScanID(r) != 0 || lidar.getHitData(r, "is_miss") != 1.0) {
5532 continue;
5533 }
5534 int row = (int) std::lround(lidar.getHitData(r, "row"));
5535 int col = (int) std::lround(lidar.getHitData(r, "column"));
5536 SphericalCoord truth = g.direction(row, col);
5537 max_err_fit = std::max(max_err_fit, angularError(lidar.getHitRaydir(r), truth));
5538 // what the idealized affine model would have produced for the same cell
5539 SphericalCoord affine = scan.rc2direction(row, col);
5540 max_err_affine = std::max(max_err_affine, angularError(affine, truth));
5541 }
5542
5543 // the robust per-row fit must be substantially better than the idealized affine model under tilt+shear
5544 DOCTEST_CHECK(max_err_fit < 0.02); // small absolute error
5545 DOCTEST_CHECK(max_err_fit < 0.25 * max_err_affine); // and a large improvement over the affine model
5546}
5547
5548DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Row/Column Noise Robustness") {
5549 // Encoder noise plus a few gross outliers must not corrupt the reconstruction (Theil-Sen / median robustness).
5550 LiDARcloud lidar;
5551 lidar.disableMessages();
5552
5553 GenerativeGrid g{24, 48, 0.05, 0.95 * M_PI, 0.0, 1.5 * M_PI, 0.30, 0.15};
5554 ScanMetadata scan(make_vec3(0, 0, 0), g.Ntheta, g.theta_min, g.theta_max, g.Nphi, g.phi_min, g.phi_max, 0.0f, 0.0f, 0.0f, 0.0f, {});
5555 lidar.addScan(scan);
5556
5557 // Deterministic pseudo-noise (no RNG) so the test is reproducible: a small bounded perturbation per cell.
5558 auto noise = [](int row, int col) {
5559 double s = std::sin(12.9898 * row + 78.233 * col) * 43758.5453;
5560 return (s - std::floor(s)) - 0.5; // in [-0.5, 0.5)
5561 };
5562
5563 const double grid_dphi = (g.phi_max - g.phi_min) / double(g.Nphi - 1);
5564 const double grid_dtheta = (g.theta_max - g.theta_min) / double(g.Ntheta - 1);
5565
5566 const int blank_r0 = 8, blank_r1 = 14, blank_c0 = 18, blank_c1 = 30;
5567 int outlier_counter = 0;
5568 for (int row = 0; row < g.Ntheta; row++) {
5569 for (int col = 0; col < g.Nphi; col++) {
5570 if (row >= blank_r0 && row <= blank_r1 && col >= blank_c0 && col <= blank_c1) {
5571 continue;
5572 }
5573 double zen = g.zenith(row) + 0.05 * grid_dtheta * noise(row, col);
5574 double az = g.azimuth(row, col) + 0.05 * grid_dphi * noise(col, row);
5575 // inject occasional gross outliers (~3% of returns) far from the true direction
5576 if ((outlier_counter++ % 33) == 0) {
5577 zen += 0.5;
5578 az += 0.5;
5579 }
5580 SphericalCoord dir = make_SphericalCoord(1.f, 0.5f * float(M_PI) - float(zen), float(az));
5582 std::map<std::string, double> data;
5583 data["row"] = row;
5584 data["column"] = col;
5585 lidar.addHitPoint(0, xyz, dir, make_RGBcolor(1, 0, 0), data);
5586 }
5587 }
5588
5589 std::vector<vec3> filled = lidar.gapfillMisses(0, false, false);
5590 DOCTEST_CHECK(filled.size() > 0);
5591
5592 double max_err = 0.0;
5593 for (uint r = 0; r < lidar.getHitCount(); r++) {
5594 if (lidar.getHitScanID(r) != 0 || lidar.getHitData(r, "is_miss") != 1.0) {
5595 continue;
5596 }
5597 int row = (int) std::lround(lidar.getHitData(r, "row"));
5598 int col = (int) std::lround(lidar.getHitData(r, "column"));
5599 max_err = std::max(max_err, angularError(lidar.getHitRaydir(r), g.direction(row, col)));
5600 }
5601 // despite noise + outliers, reconstructed misses stay within a fraction of the grid spacing of the truth
5602 DOCTEST_CHECK(max_err < 0.05);
5603}
5604
5605DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Row/Column Near-Zenith Extrapolation") {
5606 // Entire low-zenith rows are left completely empty (no returns at all). These rows must still be filled by
5607 // extrapolating the per-row model across the row axis, with directions close to the generative model.
5608 LiDARcloud lidar;
5609 lidar.disableMessages();
5610
5611 GenerativeGrid g{30, 48, 0.02, 0.95 * M_PI, 0.0, 1.5 * M_PI, 0.25, 0.10};
5612 ScanMetadata scan(make_vec3(0, 0, 0), g.Ntheta, g.theta_min, g.theta_max, g.Nphi, g.phi_min, g.phi_max, 0.0f, 0.0f, 0.0f, 0.0f, {});
5613 lidar.addScan(scan);
5614
5615 // Leave the first 6 rows (lowest zenith) entirely empty - the hard extrapolation case.
5616 const int empty_rows_below = 6;
5617 for (int row = empty_rows_below; row < g.Ntheta; row++) {
5618 for (int col = 0; col < g.Nphi; col++) {
5619 SphericalCoord dir = g.direction(row, col);
5621 std::map<std::string, double> data;
5622 data["row"] = row;
5623 data["column"] = col;
5624 lidar.addHitPoint(0, xyz, dir, make_RGBcolor(1, 0, 0), data);
5625 }
5626 }
5627
5628 std::vector<vec3> filled = lidar.gapfillMisses(0, false, true);
5629
5630 // all cells in the empty rows must have been filled
5631 DOCTEST_CHECK(filled.size() == (size_t) (empty_rows_below * g.Nphi));
5632
5633 // and they must carry the extrapolated-row flag (code 4) and be reasonably close to the generative model
5634 double max_err = 0.0;
5635 int n_extrap = 0;
5636 for (uint r = 0; r < lidar.getHitCount(); r++) {
5637 if (lidar.getHitScanID(r) != 0 || lidar.getHitData(r, "is_miss") != 1.0) {
5638 continue;
5639 }
5640 int row = (int) std::lround(lidar.getHitData(r, "row"));
5641 int col = (int) std::lround(lidar.getHitData(r, "column"));
5642 if (row < empty_rows_below) {
5643 DOCTEST_CHECK(lidar.getHitData(r, "gapfillMisses_code") == 4.0);
5644 n_extrap++;
5645 max_err = std::max(max_err, angularError(lidar.getHitRaydir(r), g.direction(row, col)));
5646 }
5647 }
5648 DOCTEST_CHECK(n_extrap == empty_rows_below * g.Nphi);
5649 // extrapolation is inherently looser than interpolation; require it to be within a few grid cells of the truth
5650 DOCTEST_CHECK(max_err < 0.1);
5651}
5652
5653DOCTEST_TEST_CASE("LiDAR Miss Gapfilling - Dispatcher Selection") {
5654 // gapfillMisses() auto-detects the available data: row/column is preferred when present, timestamp is the fallback,
5655 // and a scan whose returns carry neither raises a clear error.
5656
5657 // (a) returns with neither timestamp nor row/column -> error
5658 {
5659 LiDARcloud lidar;
5660 lidar.disableMessages();
5661 ScanMetadata scan(make_vec3(0, 0, 0), 10, 0.05, 0.95 * M_PI, 18, 0.0, 2.0 * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, {});
5662 lidar.addScan(scan);
5663 // add a single bare return (no timestamp, no row/column data)
5664 SphericalCoord dir = make_SphericalCoord(1.f, 0.4f, 0.3f);
5666
5667 bool threw = false;
5668 std::string msg;
5669 try {
5670 std::vector<vec3> filled = lidar.gapfillMisses(0);
5671 } catch (const std::runtime_error &e) {
5672 threw = true;
5673 msg = e.what();
5674 }
5675 DOCTEST_CHECK(threw);
5676 DOCTEST_CHECK(msg.find("neither 'timestamp' nor 'row'/'column'") != std::string::npos);
5677 }
5678
5679 // (b) returns with row/column -> row/column path runs (adds the row/column-specific flag codes)
5680 {
5681 LiDARcloud lidar;
5682 lidar.disableMessages();
5683 GenerativeGrid g{12, 24, 0.05, 0.95 * M_PI, 0.0, 2.0 * M_PI, 0.1, 0.0};
5684 ScanMetadata scan(make_vec3(0, 0, 0), g.Ntheta, g.theta_min, g.theta_max, g.Nphi, g.phi_min, g.phi_max, 0.0f, 0.0f, 0.0f, 0.0f, {});
5685 lidar.addScan(scan);
5686 for (int row = 0; row < g.Ntheta; row++) {
5687 for (int col = 0; col < g.Nphi; col++) {
5688 if (row == 5 && col >= 8 && col <= 12) {
5689 continue; // a small interior blank
5690 }
5691 SphericalCoord dir = g.direction(row, col);
5692 std::map<std::string, double> data;
5693 data["row"] = row;
5694 data["column"] = col;
5695 lidar.addHitPoint(0, helios::sphere2cart(make_SphericalCoord(10.f, dir.elevation, dir.azimuth)), dir, make_RGBcolor(1, 0, 0), data);
5696 }
5697 }
5698 std::vector<vec3> filled = lidar.gapfillMisses(0, false, true);
5699 DOCTEST_CHECK(filled.size() == 5);
5700 // confirm a row/column-path flag (code 1 = interior) was assigned to a filled point
5701 bool found_interior_flag = false;
5702 for (uint r = 0; r < lidar.getHitCount(); r++) {
5703 if (lidar.getHitScanID(r) == 0 && lidar.getHitData(r, "is_miss") == 1.0 && lidar.getHitData(r, "gapfillMisses_code") == 1.0) {
5704 found_interior_flag = true;
5705 break;
5706 }
5707 }
5708 DOCTEST_CHECK(found_interior_flag);
5709 }
5710}
5711
5712DOCTEST_TEST_CASE("LiDAR LAD Inversion Uncertainty") {
5713
5714 // Single 1x1x1 voxel of leaves (LAI=2, spherical leaf-angle distribution) scanned from a single
5715 // origin. We validate the per-voxel sampling-uncertainty machinery (Pimont et al. 2018):
5716 // Stage 1: sufficient statistics (beam count, RDI, mean path) are persisted.
5717 // Stage 2: the sampling variance equals the binomial delta-method closed form (units check).
5718 // Stage 3: supplying an element size adds the (positive) element-position variance term.
5719 // Stage 4: single-voxel and group confidence intervals bracket the point estimate.
5720
5721 LiDARcloud lidar;
5722 lidar.disableMessages();
5723
5724 vec3 scan_origin(-5.0f, 0.0f, 0.5f);
5725 uint Ntheta = 1000;
5726 uint Nphi = 2000;
5727 std::vector<std::string> columnFormat;
5728 ScanMetadata scan(scan_origin, Ntheta, 0.0f, M_PI, Nphi, 0.0f, 2.0f * M_PI, 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
5729 DOCTEST_CHECK_NOTHROW(lidar.addScan(scan));
5730
5731 vec3 grid_center(0.0f, 0.0f, 0.5f);
5732 vec3 grid_size(1.0f, 1.0f, 1.0f);
5733 DOCTEST_CHECK_NOTHROW(lidar.addGrid(grid_center, grid_size, make_int3(1, 1, 1), 0));
5734 vec3 gsize = lidar.getCellSize(0);
5735 float volume = gsize.x * gsize.y * gsize.z;
5736
5738 std::vector<uint> UUIDs = context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
5739 DOCTEST_CHECK(!UUIDs.empty());
5740
5741 DOCTEST_CHECK_NOTHROW(lidar.syntheticScan(&context, false, true)); // single-return, record misses
5742 DOCTEST_CHECK_NOTHROW(lidar.triangulateHitPoints(0.04, 10));
5743
5744 // ---- Stage 2 (and 1): sampling-only run (element size disabled) ----
5745 DOCTEST_CHECK_NOTHROW(lidar.calculateLeafArea(&context, 1, -1.0f));
5746
5747 int N = lidar.getCellBeamCount(0);
5748
5749 // Stage 1: sufficient statistics
5750 DOCTEST_CHECK(N > 0);
5751 float leaf_area = lidar.getCellLeafArea(0);
5752 DOCTEST_CHECK(leaf_area > 0.f);
5753 float Gtheta = lidar.getCellGtheta(0);
5754 DOCTEST_CHECK(Gtheta > 0.f);
5755 float I = lidar.getCellRelativeDensityIndex(0);
5756 DOCTEST_CHECK(I > 0.f);
5757 DOCTEST_CHECK(I < 1.f);
5758 float zbar = lidar.getCellMeanPathLength(0);
5759 DOCTEST_CHECK(zbar > 0.f);
5760 DOCTEST_CHECK(zbar < 2.f); // path through a 1 m voxel
5761
5762 float var_sampling_only = lidar.getCellLADVariance(0);
5763 DOCTEST_CHECK(var_sampling_only >= 0.f);
5764 DOCTEST_CHECK(var_sampling_only == var_sampling_only); // not NaN
5765
5766 // Stage 2: closed-form units check. For single-return data the per-beam fraction is in {0,1}, so
5767 // the empirical-variance guard equals the binomial variance and the sampling-only variance must
5768 // equal the Beer-Lambert delta-method form var(a) = I_b / (N (1-I_b) zbar^2 Gtheta^2) with the
5769 // bounded RDI I_b = min(I, 1 - 1/(2N+2)). An error in the Gtheta or zbar bookkeeping (the #1
5770 // units-bug risk) would break this equality.
5771 float I_b = std::min(I, 1.f - 1.f / (2.f * float(N) + 2.f));
5772 float expected_var = I_b / (float(N) * (1.f - I_b) * zbar * zbar * Gtheta * Gtheta);
5773 DOCTEST_CHECK(var_sampling_only == doctest::Approx(expected_var).epsilon(0.02f));
5774
5775 float a_est = leaf_area / volume; // LAD point estimate
5776 DOCTEST_CHECK(a_est > 0.f);
5777
5778 // ---- Stage 3: enable element size -> element-position variance term is added ----
5779 DOCTEST_CHECK_NOTHROW(lidar.calculateLeafArea(&context, 1, 0.1f));
5780 float var_with_element = lidar.getCellLADVariance(0);
5781 DOCTEST_CHECK(var_with_element >= 0.f);
5782 // Adding the (non-negative) element-position term cannot reduce the variance.
5783 DOCTEST_CHECK(var_with_element >= var_sampling_only - 1e-9f);
5784
5785 // ---- Stage 4: confidence intervals bracket the point estimate ----
5786 float lo = 0.f, hi = 0.f;
5787 bool have_ci = lidar.getCellLeafAreaConfidenceInterval(0, 0.95f, lo, hi);
5788 if (have_ci) {
5789 DOCTEST_CHECK(lo >= 0.f);
5790 DOCTEST_CHECK(lo < leaf_area);
5791 DOCTEST_CHECK(hi > leaf_area);
5792 }
5793
5794 float mean_lad = 0.f, glo = 0.f, ghi = 0.f;
5795 bool have_group = lidar.getGroupLADConfidenceInterval(std::vector<uint>{0}, 0.95f, mean_lad, glo, ghi);
5796 if (have_group) {
5797 DOCTEST_CHECK(mean_lad == doctest::Approx(a_est).epsilon(1e-3f));
5798 DOCTEST_CHECK(glo >= 0.f);
5799 DOCTEST_CHECK(glo <= mean_lad);
5800 DOCTEST_CHECK(ghi >= mean_lad);
5801 }
5802 // At least the group CI should be valid for this moderate-density, high-N, small-element voxel.
5803 DOCTEST_CHECK(have_group);
5804
5805 // ---- Export: header + one data row per grid cell ----
5806 const char *uncertainty_file = "lidar_lad_uncertainty_selftest.txt";
5807 DOCTEST_CHECK_NOTHROW(lidar.exportLeafAreaUncertainty(uncertainty_file));
5808 {
5809 std::ifstream in(uncertainty_file);
5810 DOCTEST_CHECK(in.is_open());
5811 std::string header;
5812 std::getline(in, header);
5813 DOCTEST_CHECK(header.find("cell_index") != std::string::npos);
5814 DOCTEST_CHECK(header.find("LAD_std_error") != std::string::npos);
5815 std::string row;
5816 bool have_row = (bool) std::getline(in, row);
5817 DOCTEST_CHECK(have_row);
5818 DOCTEST_CHECK(!row.empty());
5819 }
5820 std::remove(uncertainty_file);
5821}
5822
5823// =====================================================================================================================
5824// Moving-platform (mobile/airborne) LiDAR tests. These exercise LiDARcloud::addScanMoving(): a synthetic scan driven by
5825// a timestamped 6-DOF pose trajectory so the scanner moves during the sweep. Each return (and miss) preserves its own
5826// per-beam origin (getHitOrigin / "origin_*" data) and real per-pulse timestamp. Assertions are statistical/geometric
5827// (reconstructed origins, timestamps, counts), never "did not throw".
5828// =====================================================================================================================
5829
5830// Hamilton quaternion (qx,qy,qz,qw), body->world, rotating a body-frame vector into the world frame. Mirrors the
5831// internal convention used by the plugin so the tests can independently hand-compute expected origins/directions.
5832static vec3 test_quat_rotate(const vec4 &q, const vec3 &v) {
5833 const vec3 qv = make_vec3(q.x, q.y, q.z);
5834 const vec3 t = cross(qv, v) * 2.f;
5835 return v + t * q.w + cross(qv, t);
5836}
5837
5838// Hamilton quaternion from intrinsic Z-Y-X (yaw-pitch-roll) Tait-Bryan angles in radians.
5839static vec4 test_quat_from_rpy(float roll, float pitch, float yaw) {
5840 const float cr = std::cos(roll * 0.5f), sr = std::sin(roll * 0.5f);
5841 const float cp = std::cos(pitch * 0.5f), sp = std::sin(pitch * 0.5f);
5842 const float cy = std::cos(yaw * 0.5f), sy = std::sin(yaw * 0.5f);
5843 vec4 q;
5844 q.w = cr * cp * cy + sr * sp * sy;
5845 q.x = sr * cp * cy - cr * sp * sy;
5846 q.y = cr * sp * cy + sr * cp * sy;
5847 q.z = cr * cp * sy - sr * sp * cy;
5848 return q;
5849}
5850
5851DOCTEST_TEST_CASE("LiDAR Moving Platform Per-Beam Origin Reconstruction") {
5852 // (a) Straight-line nadir trajectory pos = (0, v*t, H) over a flat patch at z=0. Every hit's reconstructed origin
5853 // must lie on the trajectory line (x=0, z=H, y=v*t), timestamps must increase with the pulse ordinal, and all
5854 // returns of a given pulse must share one timestamp.
5855
5857 // Wide flat target so the moving scanner always sees it, plus a backing patch for a non-degenerate bounding box.
5858 context.addPatch(make_vec3(0, 0, 0), make_vec2(100, 100));
5859 context.addPatch(make_vec3(0, 0, -2), make_vec2(100, 100));
5860
5861 const uint Ntheta = 8;
5862 const uint Nphi = 12;
5863 const float thetaMin = 0.97f * float(M_PI); // near-nadir downward beams
5864 const float thetaMax = float(M_PI);
5865 const float phiMin = 0.0f;
5866 const float phiMax = 2.0f * float(M_PI);
5867 const float H = 10.0f;
5868 const float v = 2.0f; // m/s along +y
5869
5870 LiDARcloud lidar;
5871 lidar.disableMessages();
5872 ScanMetadata scan(make_vec3(0, 0, H), Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
5873
5874 // Dense trajectory spanning the full sweep duration. pulse_rate gives one pulse per (i,j) cell.
5875 const float pulse_rate = 1000.0f; // Hz
5876 const double pulse_period = 1.0 / double(pulse_rate);
5877 const double t_total = double(Ntheta * Nphi) * pulse_period;
5878 std::vector<double> traj_t;
5879 std::vector<vec3> traj_pos;
5880 std::vector<vec4> traj_quat;
5881 const int M = 20;
5882 for (int k = 0; k < M; k++) {
5883 double tk = t_total * double(k) / double(M - 1);
5884 traj_t.push_back(tk);
5885 traj_pos.push_back(make_vec3(0.f, float(v * tk), H));
5886 traj_quat.push_back(make_vec4(0, 0, 0, 1)); // identity (qx,qy,qz,qw)
5887 }
5888
5889 lidar.addScanMoving(scan, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), pulse_rate, 0.0);
5890 lidar.syntheticScan(&context, false, true); // record_misses=true so transmitted beams also carry origins
5891
5892 uint hit_count = lidar.getHitCount();
5893 DOCTEST_REQUIRE(hit_count > 0);
5894
5895 // Every hit origin must lie on the trajectory line: x=0, z=H, y = v*timestamp.
5896 for (uint i = 0; i < hit_count; i++) {
5897 double t = lidar.getHitData(i, "timestamp");
5898 vec3 origin = lidar.getHitOrigin(i);
5899 DOCTEST_CHECK(origin.x == doctest::Approx(0.0f).epsilon(0.001));
5900 DOCTEST_CHECK(origin.z == doctest::Approx(H).epsilon(0.001));
5901 DOCTEST_CHECK(origin.y == doctest::Approx(float(v * t)).epsilon(0.001));
5902 }
5903
5904 // Timestamps are monotonic in the pulse ordinal (pulse_id), and all returns of one pulse share one timestamp.
5905 std::map<double, double> pulse_time; // pulse_id -> timestamp
5906 for (uint i = 0; i < hit_count; i++) {
5907 DOCTEST_REQUIRE(lidar.doesHitDataExist(i, "pulse_id"));
5908 double pid = lidar.getHitData(i, "pulse_id");
5909 double t = lidar.getHitData(i, "timestamp");
5910 if (pulse_time.count(pid) == 0) {
5911 pulse_time[pid] = t;
5912 } else {
5913 DOCTEST_CHECK(pulse_time[pid] == doctest::Approx(t)); // same pulse -> exactly one timestamp
5914 }
5915 // timestamp == t0 + pulse_id * pulse_period
5916 DOCTEST_CHECK(t == doctest::Approx(pid * pulse_period));
5917 }
5918
5919 // Larger pulse ordinal => strictly later time.
5920 double prev_t = -1.0;
5921 double prev_pid = -1.0;
5922 for (auto &kv: pulse_time) { // std::map iterates in ascending pulse_id order
5923 if (prev_pid >= 0) {
5924 DOCTEST_CHECK(kv.second > prev_t);
5925 }
5926 prev_pid = kv.first;
5927 prev_t = kv.second;
5928 }
5929}
5930
5931DOCTEST_TEST_CASE("LiDAR Moving Platform Static Equivalence") {
5932 // (b) A zero-velocity "moving" scan (constant trajectory, identity quat, zero lever/boresight) must reproduce the
5933 // existing static addScan() over the same scene: equal hit counts and matching point-cloud centroid.
5934
5935 const uint Ntheta = 10;
5936 const uint Nphi = 16;
5937 const float thetaMin = 0.95f * float(M_PI);
5938 const float thetaMax = float(M_PI);
5939 const float phiMin = 0.0f;
5940 const float phiMax = 2.0f * float(M_PI);
5941 const float H = 8.0f;
5942
5943 auto build_scene = [](Context &context) {
5944 context.addPatch(make_vec3(0, 0, 0), make_vec2(50, 50));
5945 context.addPatch(make_vec3(0, 0, -2), make_vec2(50, 50));
5946 };
5947
5948 // Static reference.
5949 Context context_static;
5950 build_scene(context_static);
5951 LiDARcloud lidar_static;
5952 lidar_static.disableMessages();
5953 ScanMetadata scan_static(make_vec3(0, 0, H), Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
5954 lidar_static.addScan(scan_static);
5955 lidar_static.syntheticScan(&context_static, false, true);
5956
5957 // Zero-velocity moving scan at the same origin.
5958 Context context_moving;
5959 build_scene(context_moving);
5960 LiDARcloud lidar_moving;
5961 lidar_moving.disableMessages();
5962 ScanMetadata scan_moving(make_vec3(0, 0, H), Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
5963 std::vector<double> traj_t = {0.0, 1.0};
5964 std::vector<vec3> traj_pos = {make_vec3(0, 0, H), make_vec3(0, 0, H)}; // stationary
5965 std::vector<vec4> traj_quat = {make_vec4(0, 0, 0, 1), make_vec4(0, 0, 0, 1)};
5966 lidar_moving.addScanMoving(scan_moving, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 1.0e6f, 0.0);
5967 lidar_moving.syntheticScan(&context_moving, false, true);
5968
5969 uint count_static = lidar_static.getHitCount();
5970 uint count_moving = lidar_moving.getHitCount();
5971 DOCTEST_REQUIRE(count_static > 0);
5972 DOCTEST_CHECK(count_moving == count_static); // identical geometry => identical hit count
5973
5974 auto centroid = [](LiDARcloud &cloud) -> vec3 {
5975 vec3 c = make_vec3(0, 0, 0);
5976 uint n = cloud.getHitCount();
5977 for (uint i = 0; i < n; i++) {
5978 // Restrict to real (non-miss) returns; misses sit at a far sentinel distance and would swamp the centroid.
5979 if (cloud.getHitData(i, "is_miss") != 0.0) {
5980 continue;
5981 }
5982 c = c + cloud.getHitXYZ(i);
5983 }
5984 return c / float(n);
5985 };
5986
5987 vec3 c_static = centroid(lidar_static);
5988 vec3 c_moving = centroid(lidar_moving);
5989 DOCTEST_CHECK(c_moving.x == doctest::Approx(c_static.x).epsilon(0.01));
5990 DOCTEST_CHECK(c_moving.y == doctest::Approx(c_static.y).epsilon(0.01));
5991 DOCTEST_CHECK(c_moving.z == doctest::Approx(c_static.z).epsilon(0.01));
5992}
5993
5994DOCTEST_TEST_CASE("LiDAR Moving Platform Non-Trivial Attitude") {
5995 // (c) A trajectory with real roll/pitch/yaw plus a non-zero lever arm. Reconstructed origins must equal the
5996 // hand-computed pos + R(quat)*lever_arm. This is the ONLY test that catches a quaternion convention / axis-sign
5997 // bug: the nadir (a) and zero-velocity (b) tests both hide it because their rotations are identity.
5998
6000 context.addPatch(make_vec3(0, 0, 0), make_vec2(200, 200));
6001 context.addPatch(make_vec3(0, 0, -2), make_vec2(200, 200));
6002
6003 const uint Ntheta = 6;
6004 const uint Nphi = 10;
6005 const float thetaMin = 0.97f * float(M_PI);
6006 const float thetaMax = float(M_PI);
6007 const float phiMin = 0.0f;
6008 const float phiMax = 2.0f * float(M_PI);
6009 const float H = 15.0f;
6010
6011 // Non-trivial, time-varying attitude: yaw sweeps while roll/pitch are held at small fixed angles.
6012 const float roll = 0.10f;
6013 const float pitch = -0.07f;
6014 const vec3 lever_arm = make_vec3(0.3f, -0.2f, 0.5f);
6015
6016 LiDARcloud lidar;
6017 lidar.disableMessages();
6018 ScanMetadata scan(make_vec3(0, 0, H), Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
6019
6020 const float pulse_rate = 2000.0f;
6021 const double pulse_period = 1.0 / double(pulse_rate);
6022 const double t_total = double(Ntheta * Nphi) * pulse_period;
6023 std::vector<double> traj_t;
6024 std::vector<vec3> traj_pos;
6025 std::vector<vec4> traj_quat;
6026 const int M = 25;
6027 for (int k = 0; k < M; k++) {
6028 double tk = t_total * double(k) / double(M - 1);
6029 float yaw = 0.5f * float(tk / t_total); // sweeps 0 -> 0.5 rad over the scan
6030 traj_t.push_back(tk);
6031 traj_pos.push_back(make_vec3(float(1.5 * tk), float(-0.8 * tk), H));
6032 traj_quat.push_back(test_quat_from_rpy(roll, pitch, yaw));
6033 }
6034
6035 lidar.addScanMoving(scan, traj_t, traj_pos, traj_quat, lever_arm, make_vec3(0, 0, 0), pulse_rate, 0.0);
6036 lidar.syntheticScan(&context, false, true);
6037
6038 uint hit_count = lidar.getHitCount();
6039 DOCTEST_REQUIRE(hit_count > 0);
6040
6041 // Independently reconstruct each pulse's expected origin from its timestamp by interpolating the trajectory the same
6042 // way poseAt does (SLERP would be exact at the sample points; between samples linear-in-yaw is close enough at this
6043 // angular rate that an independent SLERP reconstruction matches within tolerance). We re-derive the pose with our own
6044 // quaternion helper to catch any sign/axis error in the plugin.
6045 auto pose_at = [&](double t, vec3 &pos, vec4 &quat) {
6046 if (t <= traj_t.front()) {
6047 pos = traj_pos.front();
6048 quat = traj_quat.front();
6049 return;
6050 }
6051 if (t >= traj_t.back()) {
6052 pos = traj_pos.back();
6053 quat = traj_quat.back();
6054 return;
6055 }
6056 size_t i1 = 1;
6057 while (i1 < traj_t.size() && traj_t[i1] < t) {
6058 i1++;
6059 }
6060 size_t i0 = i1 - 1;
6061 double u = (t - traj_t[i0]) / (traj_t[i1] - traj_t[i0]);
6062 pos = traj_pos[i0] + (traj_pos[i1] - traj_pos[i0]) * float(u);
6063 // SLERP, shortest arc.
6064 vec4 q0 = traj_quat[i0], q1 = traj_quat[i1];
6065 q0.normalize();
6066 q1.normalize();
6067 double dot = double(q0.x) * q1.x + double(q0.y) * q1.y + double(q0.z) * q1.z + double(q0.w) * q1.w;
6068 if (dot < 0.0) {
6069 q1 = make_vec4(-q1.x, -q1.y, -q1.z, -q1.w);
6070 dot = -dot;
6071 }
6072 vec4 q;
6073 if (dot > 0.9995) {
6074 q = make_vec4(q0.x + float(u) * (q1.x - q0.x), q0.y + float(u) * (q1.y - q0.y), q0.z + float(u) * (q1.z - q0.z), q0.w + float(u) * (q1.w - q0.w));
6075 } else {
6076 double th0 = std::acos(dot);
6077 double th = th0 * u;
6078 double s0 = std::sin(th0 - th) / std::sin(th0);
6079 double s1 = std::sin(th) / std::sin(th0);
6080 q = make_vec4(float(s0 * q0.x + s1 * q1.x), float(s0 * q0.y + s1 * q1.y), float(s0 * q0.z + s1 * q1.z), float(s0 * q0.w + s1 * q1.w));
6081 }
6082 q.normalize();
6083 quat = q;
6084 };
6085
6086 uint checked = 0;
6087 for (uint i = 0; i < hit_count; i++) {
6088 double t = lidar.getHitData(i, "timestamp");
6089 vec3 pos;
6090 vec4 quat;
6091 pose_at(t, pos, quat);
6092 vec3 expected_origin = pos + test_quat_rotate(quat, lever_arm);
6093 vec3 origin = lidar.getHitOrigin(i);
6094 DOCTEST_CHECK(origin.x == doctest::Approx(expected_origin.x).epsilon(0.005));
6095 DOCTEST_CHECK(origin.y == doctest::Approx(expected_origin.y).epsilon(0.005));
6096 DOCTEST_CHECK(origin.z == doctest::Approx(expected_origin.z).epsilon(0.005));
6097 checked++;
6098 }
6099 DOCTEST_REQUIRE(checked > 0);
6100
6101 // Sanity: a non-trivial attitude must actually displace the origin away from the bare trajectory position by the
6102 // rotated lever arm. (If R were mistakenly identity, expected==pos and this would still pass; this check instead
6103 // guards that the lever arm is being applied at all.)
6104 {
6105 vec3 pos0;
6106 vec4 q0;
6107 pose_at(lidar.getHitData(0, "timestamp"), pos0, q0);
6108 vec3 rotated_lever = test_quat_rotate(q0, lever_arm);
6109 DOCTEST_CHECK(rotated_lever.magnitude() == doctest::Approx(lever_arm.magnitude()).epsilon(1e-4)); // rotation preserves length
6110 }
6111}
6112
6113DOCTEST_TEST_CASE("LiDAR Moving Platform Euler-Angle Overload Equivalence") {
6114 // The Euler-angle addScanMoving overload must produce a point cloud identical to the quaternion overload when the
6115 // Euler angles are the same roll/pitch/yaw that generated the quaternions (intrinsic Z-Y-X). This guards that the
6116 // overload converts angles with the same convention and otherwise delegates to the same code path.
6117
6118 const uint Ntheta = 6;
6119 const uint Nphi = 12;
6120 const float thetaMin = 0.96f * float(M_PI);
6121 const float thetaMax = float(M_PI);
6122 const float phiMin = 0.0f;
6123 const float phiMax = 2.0f * float(M_PI);
6124 const float H = 12.0f;
6125
6126 auto build_scene = [](Context &context) {
6127 context.addPatch(make_vec3(0, 0, 0), make_vec2(80, 80));
6128 context.addPatch(make_vec3(0, 0, -2), make_vec2(80, 80));
6129 };
6130
6131 // A non-trivial, time-varying attitude (so a wrong axis order/sign in the overload would change the cloud).
6132 const int M = 12;
6133 std::vector<double> traj_t;
6134 std::vector<vec3> traj_pos;
6135 std::vector<vec3> traj_rpy;
6136 for (int k = 0; k < M; k++) {
6137 double tk = double(k) / double(M - 1);
6138 float roll = 0.08f * float(tk);
6139 float pitch = -0.05f * float(tk);
6140 float yaw = 0.3f * float(tk);
6141 traj_t.push_back(tk);
6142 traj_pos.push_back(make_vec3(float(2.0 * tk), float(-1.0 * tk), H));
6143 traj_rpy.push_back(make_vec3(roll, pitch, yaw));
6144 }
6145
6146 // Build the equivalent quaternion trajectory with the same intrinsic Z-Y-X convention used internally.
6147 std::vector<vec4> traj_quat;
6148 for (const vec3 &rpy: traj_rpy) {
6149 traj_quat.push_back(test_quat_from_rpy(rpy.x, rpy.y, rpy.z));
6150 }
6151
6152 const vec3 lever = make_vec3(0.2f, -0.1f, 0.4f);
6153 const vec3 boresight = make_vec3(0.01f, 0.02f, -0.015f);
6154 const float pulseRate = 2000.0f;
6155
6156 // Quaternion overload.
6157 Context context_q;
6158 build_scene(context_q);
6159 LiDARcloud lidar_q;
6160 lidar_q.disableMessages();
6161 ScanMetadata scan_q(make_vec3(0, 0, H), Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
6162 lidar_q.addScanMoving(scan_q, traj_t, traj_pos, traj_quat, lever, boresight, pulseRate, 0.0);
6163 lidar_q.syntheticScan(&context_q, false, false);
6164
6165 // Euler-angle overload (same RPY).
6166 Context context_e;
6167 build_scene(context_e);
6168 LiDARcloud lidar_e;
6169 lidar_e.disableMessages();
6170 ScanMetadata scan_e(make_vec3(0, 0, H), Ntheta, thetaMin, thetaMax, Nphi, phiMin, phiMax, 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
6171 lidar_e.addScanMoving(scan_e, traj_t, traj_pos, traj_rpy, lever, boresight, pulseRate, 0.0);
6172 lidar_e.syntheticScan(&context_e, false, false);
6173
6174 uint nq = lidar_q.getHitCount();
6175 uint ne = lidar_e.getHitCount();
6176 DOCTEST_REQUIRE(nq > 0);
6177 DOCTEST_CHECK(ne == nq); // identical inputs => identical hit count
6178
6179 // Every hit position and origin must match between the two overloads (no noise was enabled, so this is exact).
6180 uint compared = 0;
6181 for (uint i = 0; i < nq && i < ne; i++) {
6182 vec3 pq = lidar_q.getHitXYZ(i);
6183 vec3 pe = lidar_e.getHitXYZ(i);
6184 DOCTEST_CHECK(pe.x == doctest::Approx(pq.x).epsilon(1e-5));
6185 DOCTEST_CHECK(pe.y == doctest::Approx(pq.y).epsilon(1e-5));
6186 DOCTEST_CHECK(pe.z == doctest::Approx(pq.z).epsilon(1e-5));
6187 vec3 oq = lidar_q.getHitOrigin(i);
6188 vec3 oe = lidar_e.getHitOrigin(i);
6189 DOCTEST_CHECK(oe.x == doctest::Approx(oq.x).epsilon(1e-5));
6190 DOCTEST_CHECK(oe.y == doctest::Approx(oq.y).epsilon(1e-5));
6191 DOCTEST_CHECK(oe.z == doctest::Approx(oq.z).epsilon(1e-5));
6192 compared++;
6193 }
6194 DOCTEST_REQUIRE(compared > 0);
6195}
6196
6197DOCTEST_TEST_CASE("LiDAR Moving Platform Leaf Area Inversion") {
6198 // The leaf-area inversion must use each beam's actual emission origin for a moving-platform scan. We scan a known
6199 // 1x1x1 m leaf cube (spherical leaf-angle distribution, so the true G(theta) = 0.5) with a scanner that translates
6200 // over it, supply G(theta)=0.5 (triangulation is impossible without a theta-phi grid), and check the recovered LAD
6201 // matches the exact LAD computed from primitive areas. If the inversion still used a single static origin, the
6202 // per-beam path geometry would be wrong and the LAD would be biased.
6203
6205 std::vector<uint> UUIDs = context.loadXML("plugins/lidar/xml/leaf_cube_LAI2_lw0_01_spherical.xml", true);
6206 DOCTEST_REQUIRE(!UUIDs.empty());
6207
6208 LiDARcloud lidar;
6209 lidar.disableMessages();
6210
6211 // Grid voxel matching the leaf cube (centered at (0,0,0.5), 1 m on a side).
6212 const vec3 grid_center(0.0f, 0.0f, 0.5f);
6213 const vec3 grid_size(1.0f, 1.0f, 1.0f);
6214 lidar.addGrid(grid_center, grid_size, make_int3(1, 1, 1), 0);
6215 const vec3 gsize = lidar.getCellSize(0);
6216
6217 float LAD_exact = 0.f;
6218 for (uint UUID: UUIDs) {
6219 LAD_exact += context.getPrimitiveArea(UUID) / (gsize.x * gsize.y * gsize.z);
6220 }
6221 DOCTEST_REQUIRE(LAD_exact > 0.f);
6222
6223 // Downward-looking spinning multibeam translating across the cube at height z = 5 m. The channels fan +/- a few
6224 // degrees about nadir so the beams sample the voxel; as the platform moves, each pulse is emitted from a different
6225 // point, exercising the per-beam-origin path. record_misses=true supplies the transmitted beams the inversion needs.
6226 std::vector<float> beamZenithAngles;
6227 const int Nchannels = 30;
6228 for (int c = 0; c < Nchannels; c++) {
6229 float dev = (float(c) / float(Nchannels - 1) - 0.5f) * deg2rad(40.0f); // +/-20 deg about nadir
6230 beamZenithAngles.push_back(float(M_PI) - fabsf(dev)); // near pi = downward
6231 }
6232 const uint Nphi = 400;
6233 ScanMetadata scan(make_vec3(0, 0, 5), beamZenithAngles, Nphi, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
6234
6235 // Straight, level flight across the cube along +x from x=-1 to x=+1 at z=5.
6236 std::vector<double> traj_t;
6237 std::vector<vec3> traj_pos;
6238 std::vector<vec3> traj_rpy;
6239 const int M = 30;
6240 for (int k = 0; k < M; k++) {
6241 double tk = double(k) / double(M - 1);
6242 traj_t.push_back(tk);
6243 traj_pos.push_back(make_vec3(float(-1.0 + 2.0 * tk), 0.f, 5.f));
6244 traj_rpy.push_back(make_vec3(0, 0, 0)); // level
6245 }
6246 const float pulseRate = float(Nchannels * Nphi); // ~one full sweep over the 1 s flight
6247
6248 lidar.addScanMoving(scan, traj_t, traj_pos, traj_rpy, make_vec3(0, 0, 0), make_vec3(0, 0, 0), pulseRate, 0.0);
6249 lidar.syntheticScan(&context, false, true);
6250 DOCTEST_REQUIRE(lidar.getHitCount() > 0);
6251 DOCTEST_REQUIRE(lidar.hasMisses());
6252
6253 // Supplied-G(theta) overload: no triangulation (the moving scan has no theta-phi grid to triangulate).
6254 lidar.calculateLeafArea(&context, 0.5f, 1, 0.05f);
6255
6256 float LAD = lidar.getCellLeafAreaDensity(0);
6257 DOCTEST_CHECK(LAD == LAD); // not NaN
6258 // Beam-based single-voxel inversion of a noise-free synthetic scan; allow a modest tolerance for sampling/discretization.
6259 DOCTEST_CHECK(fabs(LAD - LAD_exact) / LAD_exact == doctest::Approx(0.0f).epsilon(0.15f));
6260}
6261
6262DOCTEST_TEST_CASE("LiDAR calculateLeafArea Supplied-Gtheta Validation") {
6263 // The supplied-G(theta) overload must reject out-of-range G(theta) and must not require triangulation.
6265 context.addPatch(make_vec3(0, 0, 0), make_vec2(5, 5));
6266
6267 LiDARcloud lidar;
6268 lidar.disableMessages();
6269 lidar.addGrid(make_vec3(0, 0, 0.5), make_vec3(1, 1, 1), make_int3(1, 1, 1), 0);
6270
6271 // Invalid G(theta) values fail fast (no triangulation, no scan needed - validation happens first).
6272 bool threw_zero = false, threw_high = false;
6273 {
6274 capture_cerr capture;
6275 try {
6276 lidar.calculateLeafArea(&context, 0.0f, 1, 0.05f);
6277 } catch (...) {
6278 threw_zero = true;
6279 }
6280 try {
6281 lidar.calculateLeafArea(&context, 1.5f, 1, 0.05f);
6282 } catch (...) {
6283 threw_high = true;
6284 }
6285 }
6286 DOCTEST_CHECK(threw_zero);
6287 DOCTEST_CHECK(threw_high);
6288}
6289
6290DOCTEST_TEST_CASE("LiDAR Moving Platform exportScans Origin Handling") {
6291 // A scan must define its beam origin either via a static <origin> XML tag or via per-point origin_x/y/z columns in
6292 // the data file. exportScans() must omit the misleading single <origin> for a mobile scan (whose ASCII format
6293 // carries per-point origins), and loadXML() must accept such a file and require one of the two sources.
6294
6296 context.addPatch(make_vec3(0, 0, 0), make_vec2(40, 40));
6297 context.addPatch(make_vec3(0, 0, -2), make_vec2(40, 40));
6298
6299 const uint Ntheta = 6;
6300 const uint Nphi = 10;
6301 const float thetaMin = 0.97f * float(M_PI);
6302 const float thetaMax = float(M_PI);
6303 const float H = 10.0f;
6304
6305 // Per-point origins (origin_x/y/z) in the column format mark this as a scan that defines its origin per point.
6306 std::vector<std::string> columnFormat = {"x", "y", "z", "timestamp", "origin_x", "origin_y", "origin_z"};
6307
6308 LiDARcloud lidar;
6309 lidar.disableMessages();
6310 ScanMetadata scan(make_vec3(0, 0, H), Ntheta, thetaMin, thetaMax, Nphi, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, columnFormat);
6311 std::vector<double> traj_t = {0.0, 1.0};
6312 std::vector<vec3> traj_pos = {make_vec3(0, 0, H), make_vec3(2, 0, H)};
6313 std::vector<vec4> traj_quat = {make_vec4(0, 0, 0, 1), make_vec4(0, 0, 0, 1)};
6314 lidar.addScanMoving(scan, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 1000.0f, 0.0);
6315 lidar.syntheticScan(&context, false, false);
6316
6317 uint export_hits = lidar.getHitCount();
6318 DOCTEST_REQUIRE(export_hits > 0);
6319
6320 const char *xml_file = "moving_export_test.xml";
6321 const char *xyz_file = "moving_export_test_0.xyz";
6322 lidar.exportScans(xml_file);
6323
6324 // The XML must NOT contain an <origin> tag (mobile scan), but must carry origin_x in the ASCII_format.
6325 std::string xml_contents;
6326 {
6327 std::ifstream in(xml_file);
6328 DOCTEST_REQUIRE(in.good());
6329 std::stringstream ss;
6330 ss << in.rdbuf();
6331 xml_contents = ss.str();
6332 }
6333 DOCTEST_CHECK(xml_contents.find("<origin>") == std::string::npos);
6334 DOCTEST_CHECK(xml_contents.find("origin_x") != std::string::npos);
6335
6336 // The exported file must reload (loadXML accepts a scan with per-point origins and no <origin>), preserving hits and
6337 // per-point origins.
6338 {
6339 LiDARcloud reloaded;
6340 reloaded.disableMessages();
6341 DOCTEST_CHECK_NOTHROW(reloaded.loadXML(xml_file));
6342 DOCTEST_CHECK(reloaded.getHitCount() == export_hits);
6343 DOCTEST_REQUIRE(reloaded.getHitCount() > 0);
6344 // The reconstructed per-point origin matches the value written to the data file.
6345 DOCTEST_CHECK(reloaded.doesHitDataExist(0, "origin_x"));
6346 vec3 o = reloaded.getHitOrigin(0);
6347 DOCTEST_CHECK(o.z == doctest::Approx(H).epsilon(0.001));
6348 }
6349
6350 std::remove(xml_file);
6351 std::remove(xyz_file);
6352}
6353
6354DOCTEST_TEST_CASE("LiDAR loadXML Requires An Origin Source") {
6355 // A scan XML with neither a static <origin> nor per-point origin columns must fail fast on load.
6356 const char *bad_xml = "no_origin_test.xml";
6357 {
6358 std::ofstream out(bad_xml);
6359 out << "<helios>\n";
6360 out << " <scan>\n";
6361 out << " <size>10 10</size>\n";
6362 out << " <ASCII_format>x y z</ASCII_format>\n";
6363 out << " </scan>\n";
6364 out << "</helios>\n";
6365 }
6366
6367 LiDARcloud cloud;
6368 cloud.disableMessages();
6369 bool threw = false;
6370 {
6371 capture_cerr capture; // loadXML prints "failed." to cerr before throwing
6372 try {
6373 cloud.loadXML(bad_xml);
6374 } catch (...) {
6375 threw = true;
6376 }
6377 }
6378 DOCTEST_CHECK(threw);
6379
6380 std::remove(bad_xml);
6381}
6382
6383DOCTEST_TEST_CASE("LiDAR Moving Platform getHitRaydir Uses Per-Beam Origin") {
6384 // getHitRaydir() must reconstruct the beam direction from each hit's own origin. For a moving scan, the angle from
6385 // the per-hit origin to the hit point should match the angle from the (static) trajectory-front origin only for the
6386 // earliest pulse; for later pulses (emitted from a moved platform) it must differ. We verify getHitRaydir agrees
6387 // with the per-hit-origin reconstruction and NOT with the static-origin reconstruction for a moved-platform hit.
6389 context.addPatch(make_vec3(0, 0, 0), make_vec2(60, 60));
6390 context.addPatch(make_vec3(0, 0, -2), make_vec2(60, 60));
6391
6392 const uint Ntheta = 8;
6393 const uint Nphi = 16;
6394 const float H = 10.0f;
6395 LiDARcloud lidar;
6396 lidar.disableMessages();
6397 ScanMetadata scan(make_vec3(0, 0, H), Ntheta, 0.96f * float(M_PI), float(M_PI), Nphi, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
6398 std::vector<double> traj_t = {0.0, 1.0};
6399 std::vector<vec3> traj_pos = {make_vec3(0, 0, H), make_vec3(5, 0, H)}; // moves 5 m in x
6400 std::vector<vec3> traj_rpy = {make_vec3(0, 0, 0), make_vec3(0, 0, 0)};
6401 lidar.addScanMoving(scan, traj_t, traj_pos, traj_rpy, make_vec3(0, 0, 0), make_vec3(0, 0, 0), float(Ntheta * Nphi), 0.0);
6402 lidar.syntheticScan(&context, false, false);
6403
6404 uint n = lidar.getHitCount();
6405 DOCTEST_REQUIRE(n > 0);
6406
6407 vec3 static_origin = lidar.getScanOrigin(0); // trajectory front = (0,0,H)
6408 uint mismatched_with_static = 0;
6409 for (uint i = 0; i < n; i++) {
6410 SphericalCoord rd = lidar.getHitRaydir(i);
6411 // Reconstruct from per-hit origin: must match getHitRaydir (that is how it is defined).
6412 vec3 from_hit_origin = lidar.getHitXYZ(i) - lidar.getHitOrigin(i);
6413 SphericalCoord expected = cart2sphere(from_hit_origin);
6414 DOCTEST_CHECK(rd.zenith == doctest::Approx(expected.zenith).epsilon(1e-4));
6415 DOCTEST_CHECK(rd.azimuth == doctest::Approx(expected.azimuth).epsilon(1e-4));
6416
6417 // A hit whose origin has moved away from the static origin must NOT match the static-origin reconstruction.
6418 if ((lidar.getHitOrigin(i) - static_origin).magnitude() > 1.0f) {
6419 SphericalCoord wrong = cart2sphere(lidar.getHitXYZ(i) - static_origin);
6420 if (fabs(rd.zenith - wrong.zenith) > 1e-3 || fabs(rd.azimuth - wrong.azimuth) > 1e-3) {
6421 mismatched_with_static++;
6422 }
6423 }
6424 }
6425 // At least some hits were emitted from a moved platform and so disagree with the static-origin direction.
6426 DOCTEST_CHECK(mismatched_with_static > 0);
6427}
6428
6429DOCTEST_TEST_CASE("LiDAR Moving Platform coordinateShift Preserves Beam Geometry") {
6430 // coordinateShift must shift the per-hit origin_x/y/z together with the hit position, so the beam vector
6431 // (position - origin) is invariant under the shift for a moving scan.
6433 context.addPatch(make_vec3(0, 0, 0), make_vec2(60, 60));
6434 context.addPatch(make_vec3(0, 0, -2), make_vec2(60, 60));
6435
6436 const uint Ntheta = 6;
6437 const uint Nphi = 12;
6438 const float H = 9.0f;
6439 LiDARcloud lidar;
6440 lidar.disableMessages();
6441 ScanMetadata scan(make_vec3(0, 0, H), Ntheta, 0.96f * float(M_PI), float(M_PI), Nphi, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
6442 std::vector<double> traj_t = {0.0, 1.0};
6443 std::vector<vec3> traj_pos = {make_vec3(0, 0, H), make_vec3(4, 0, H)};
6444 std::vector<vec3> traj_rpy = {make_vec3(0, 0, 0), make_vec3(0, 0, 0)};
6445 lidar.addScanMoving(scan, traj_t, traj_pos, traj_rpy, make_vec3(0, 0, 0), make_vec3(0, 0, 0), float(Ntheta * Nphi), 0.0);
6446 lidar.syntheticScan(&context, false, false);
6447
6448 uint n = lidar.getHitCount();
6449 DOCTEST_REQUIRE(n > 0);
6450
6451 // Record beam vectors before the shift.
6452 std::vector<vec3> beam_before(n);
6453 for (uint i = 0; i < n; i++) {
6454 beam_before[i] = lidar.getHitXYZ(i) - lidar.getHitOrigin(i);
6455 }
6456
6457 const vec3 shift = make_vec3(3.0f, -2.0f, 1.5f);
6458 lidar.coordinateShift(shift);
6459
6460 // After the shift, the beam vector (position - per-hit origin) must be unchanged, and the origin must have moved by
6461 // exactly the shift.
6462 for (uint i = 0; i < n; i++) {
6463 vec3 beam_after = lidar.getHitXYZ(i) - lidar.getHitOrigin(i);
6464 DOCTEST_CHECK(beam_after.x == doctest::Approx(beam_before[i].x).epsilon(1e-4));
6465 DOCTEST_CHECK(beam_after.y == doctest::Approx(beam_before[i].y).epsilon(1e-4));
6466 DOCTEST_CHECK(beam_after.z == doctest::Approx(beam_before[i].z).epsilon(1e-4));
6467 }
6468}
6469
6470DOCTEST_TEST_CASE("LiDAR Moving Platform Static-Only Functions Fail Fast") {
6471 // Triangulation and ray-direction validation cannot work on a moving-platform scan (no fixed theta-phi grid /
6472 // single origin). They must fail fast rather than silently produce garbage.
6474 context.addPatch(make_vec3(0, 0, 0), make_vec2(40, 40));
6475 context.addPatch(make_vec3(0, 0, -2), make_vec2(40, 40));
6476
6477 const uint Ntheta = 6;
6478 const uint Nphi = 10;
6479 const float H = 8.0f;
6480 LiDARcloud lidar;
6481 lidar.disableMessages();
6482 ScanMetadata scan(make_vec3(0, 0, H), Ntheta, 0.96f * float(M_PI), float(M_PI), Nphi, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
6483 std::vector<double> traj_t = {0.0, 1.0};
6484 std::vector<vec3> traj_pos = {make_vec3(0, 0, H), make_vec3(3, 0, H)};
6485 std::vector<vec3> traj_rpy = {make_vec3(0, 0, 0), make_vec3(0, 0, 0)};
6486 lidar.addScanMoving(scan, traj_t, traj_pos, traj_rpy, make_vec3(0, 0, 0), make_vec3(0, 0, 0), float(Ntheta * Nphi), 0.0);
6487 lidar.syntheticScan(&context, false, true);
6488
6489 bool tri_threw = false, validate_threw = false;
6490 {
6491 capture_cerr capture;
6492 try {
6493 lidar.triangulateHitPoints(0.5f, 5.0f);
6494 } catch (...) {
6495 tri_threw = true;
6496 }
6497 try {
6498 lidar.validateRayDirections();
6499 } catch (...) {
6500 validate_threw = true;
6501 }
6502 }
6503 DOCTEST_CHECK(tri_threw);
6504 DOCTEST_CHECK(validate_threw);
6505}
6506
6507DOCTEST_TEST_CASE("LiDAR Moving Platform addScanMoving Rejects Non-Finite Trajectory") {
6508 // addScanMoving must reject NaN/inf in the trajectory rather than let it propagate into NaN origins.
6509 LiDARcloud lidar;
6510 lidar.disableMessages();
6511 ScanMetadata scan(make_vec3(0, 0, 5), 4, 0.97f * float(M_PI), float(M_PI), 8, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
6512
6513 const float nan_val = std::numeric_limits<float>::quiet_NaN();
6514 std::vector<double> traj_t = {0.0, 1.0};
6515 std::vector<vec3> traj_pos = {make_vec3(0, 0, 5), make_vec3(nan_val, 0, 5)}; // NaN position
6516 std::vector<vec3> traj_rpy = {make_vec3(0, 0, 0), make_vec3(0, 0, 0)};
6517
6518 bool threw = false;
6519 {
6520 capture_cerr capture;
6521 try {
6522 lidar.addScanMoving(scan, traj_t, traj_pos, traj_rpy, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 100.0f, 0.0);
6523 } catch (...) {
6524 threw = true;
6525 }
6526 }
6527 DOCTEST_CHECK(threw);
6528}
6529
6530DOCTEST_TEST_CASE("LiDAR Moving Platform Gapfill Writes Per-Pulse Origins") {
6531 // gapfillMisses on a moving scan must synthesize misses whose per-pulse origin (origin_x/y/z) lies on the platform
6532 // trajectory, not at the single static origin. We scan a small target so some beams miss, gap-fill, and check that
6533 // gap-filled misses carry origin_x/y/z consistent with the straight-line trajectory (x = v*timestamp, z = H).
6535 context.addPatch(make_vec3(0, 0, 0), make_vec2(2, 2)); // small target so many beams miss
6536 context.addPatch(make_vec3(0, 0, -2), make_vec2(2, 2));
6537
6538 const uint Ntheta = 8;
6539 const uint Nphi = 60;
6540 const float H = 6.0f;
6541 const float v = 3.0f; // m/s along +x
6542 LiDARcloud lidar;
6543 lidar.disableMessages();
6544 // Narrow downward fan so the beams sweep near nadir as the platform passes over the small target.
6545 ScanMetadata scan(make_vec3(0, 0, H), Ntheta, 0.93f * float(M_PI), float(M_PI), Nphi, 0.0f, 2.0f * float(M_PI), 0.0f, 0.0f, 0.0f, 0.0f, std::vector<std::string>());
6546
6547 const float pulseRate = float(Ntheta * Nphi);
6548 const double pulse_period = 1.0 / double(pulseRate);
6549 const double t_total = double(Ntheta * Nphi) * pulse_period;
6550 std::vector<double> traj_t;
6551 std::vector<vec3> traj_pos;
6552 std::vector<vec3> traj_rpy;
6553 const int M = 20;
6554 for (int k = 0; k < M; k++) {
6555 double tk = t_total * double(k) / double(M - 1);
6556 traj_t.push_back(tk);
6557 traj_pos.push_back(make_vec3(float(v * tk), 0.f, H));
6558 traj_rpy.push_back(make_vec3(0, 0, 0));
6559 }
6560 lidar.addScanMoving(scan, traj_t, traj_pos, traj_rpy, make_vec3(0, 0, 0), make_vec3(0, 0, 0), pulseRate, 0.0);
6561 // record_misses=false so the actual misses are NOT recorded; gap filling must synthesize them.
6562 lidar.syntheticScan(&context, false, false);
6563
6564 uint before = lidar.getHitCount();
6565 DOCTEST_REQUIRE(before > 0);
6566
6567 std::vector<vec3> filled = lidar.gapfillMisses(0);
6568 DOCTEST_REQUIRE(!filled.empty()); // some misses were synthesized
6569
6570 // Find gap-filled misses (they carry origin_x/y/z written by the moving-aware gapfill) and verify the origin lies on
6571 // the trajectory line: x = v*timestamp, y = 0, z = H.
6572 uint checked = 0;
6573 for (uint i = 0; i < lidar.getHitCount(); i++) {
6574 if (lidar.getHitData(i, "is_miss") == 0.0) {
6575 continue;
6576 }
6577 if (!lidar.doesHitDataExist(i, "origin_x")) {
6578 continue; // only the synthesized/recorded moving misses carry per-pulse origins
6579 }
6580 double t = lidar.getHitData(i, "timestamp");
6581 vec3 o = lidar.getHitOrigin(i);
6582 DOCTEST_CHECK(o.z == doctest::Approx(H).epsilon(0.01));
6583 DOCTEST_CHECK(o.y == doctest::Approx(0.0f).epsilon(0.01));
6584 DOCTEST_CHECK(o.x == doctest::Approx(float(v * t)).epsilon(0.05));
6585 checked++;
6586 }
6587 DOCTEST_REQUIRE(checked > 0); // at least one gap-filled/recorded moving miss carried a per-pulse origin
6588}
6589
6590// =====================================================================================================================
6591// Spinning multibeam physical-parameter setup (addScanSpinning). These exercise the first-class entry point where the
6592// user supplies physical instrument parameters (channel elevations, azimuth resolution, PRF, trajectory) and Helios
6593// derives the internal grid, rotation rate, and revolution count. A stationary capture is two coincident trajectory poses.
6594// =====================================================================================================================
6595
6596DOCTEST_TEST_CASE("LiDAR Spinning Multibeam Multi-Revolution Derivation") {
6597 // A spinning sensor carried along a straight trajectory for several revolutions. Verify that the derived rotation
6598 // rate, revolution count, and Nphi match the physical parameters, and that each pulse fires at the EXACT per-channel
6599 // elevation (not a resampled uniform theta grid).
6600
6602 context.addPatch(make_vec3(0, 0, 0), make_vec2(200, 200));
6603 context.addPatch(make_vec3(0, 0, -2), make_vec2(200, 200));
6604
6605 // 8 channels, +/-15 degree elevation. 10-degree azimuth steps -> 36 steps per revolution.
6606 std::vector<float> beam_elev_rad;
6607 const std::vector<float> elev_deg = {-15.f, -11.f, -7.f, -3.f, 3.f, 7.f, 11.f, 15.f};
6608 for (float d: elev_deg) {
6609 beam_elev_rad.push_back(d * float(M_PI) / 180.f);
6610 }
6611 const uint channels = uint(beam_elev_rad.size());
6612 const float azimuthStep_rad = 10.f * float(M_PI) / 180.f; // 36 steps/rev
6613 const uint expected_steps_per_rev = 36;
6614
6615 // Choose PRF and duration so we get ~3 revolutions. rotation_rate = PRF/(channels*steps_per_rev).
6616 // For 3 revolutions over duration D: PRF * D = channels * steps_per_rev * 3.
6617 const float H = 30.0f;
6618 const float v = 2.0f;
6619 const double duration = 0.6; // s
6620 const double target_revs = 3.0;
6621 const float PRF = float(double(channels) * double(expected_steps_per_rev) * target_revs / duration);
6622
6623 std::vector<double> traj_t = {0.0, duration};
6624 std::vector<vec3> traj_pos = {make_vec3(0, 0, H), make_vec3(0, float(v * duration), H)};
6625 std::vector<vec4> traj_quat = {make_vec4(0, 0, 0, 1), make_vec4(0, 0, 0, 1)}; // identity orientation
6626
6627 LiDARcloud lidar;
6628 lidar.disableMessages();
6629 uint scanID = lidar.addScanSpinning(beam_elev_rad, azimuthStep_rad, PRF, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>(), 0.0);
6630
6631 // Derived descriptors.
6632 DOCTEST_CHECK(lidar.getScanMode(scanID) == SCAN_MODE_SPINNING);
6633 DOCTEST_CHECK(lidar.getScanPattern(scanID) == SCAN_PATTERN_SPINNING_MULTIBEAM);
6634 DOCTEST_CHECK(lidar.getScanStepsPerRev(scanID) == expected_steps_per_rev);
6635 DOCTEST_CHECK(lidar.getScanSizeTheta(scanID) == channels);
6636 DOCTEST_CHECK(lidar.getScanRevolutions(scanID) == doctest::Approx(target_revs).epsilon(0.001));
6637 const double expected_rotation_rate = double(PRF) / (double(channels) * double(expected_steps_per_rev));
6638 DOCTEST_CHECK(lidar.getScanRotationRate(scanID) == doctest::Approx(expected_rotation_rate).epsilon(0.001));
6639 DOCTEST_CHECK(lidar.getScanSizePhi(scanID) == uint(std::lround(double(expected_steps_per_rev) * target_revs)));
6640
6641 lidar.syntheticScan(&context, false, false);
6642 uint hit_count = lidar.getHitCount();
6643 DOCTEST_REQUIRE(hit_count > 0);
6644
6645 // Per-channel fidelity: with identity platform orientation, each hit's world beam zenith (origin -> hit) must equal
6646 // one of the EXACT channel zenith angles, not a uniform grid value between channels.
6647 std::vector<float> channel_zenith;
6648 for (float er: beam_elev_rad) {
6649 channel_zenith.push_back(0.5f * float(M_PI) - er);
6650 }
6651 uint checked = 0;
6652 for (uint i = 0; i < hit_count; i++) {
6653 vec3 origin = lidar.getHitOrigin(i);
6654 vec3 dir = lidar.getHitXYZ(i) - origin;
6655 if (dir.magnitude() < 1e-6f) {
6656 continue;
6657 }
6658 SphericalCoord sc = cart2sphere(dir);
6659 float zenith = 0.5f * float(M_PI) - sc.elevation; // elevation -> zenith
6660 float best = 1e9f;
6661 for (float cz: channel_zenith) {
6662 best = std::min(best, std::fabs(zenith - cz));
6663 }
6664 DOCTEST_CHECK(best < 1e-3f); // matches an exact channel angle
6665 checked++;
6666 }
6667 DOCTEST_REQUIRE(checked > 0);
6668
6669 // Timestamps: all returns of a pulse share one timestamp; t == t0 + pulse_id*pulse_period.
6670 const double pulse_period = 1.0 / double(PRF);
6671 for (uint i = 0; i < hit_count; i++) {
6672 DOCTEST_REQUIRE(lidar.doesHitDataExist(i, "pulse_id"));
6673 double pid = lidar.getHitData(i, "pulse_id");
6674 double t = lidar.getHitData(i, "timestamp");
6675 DOCTEST_CHECK(t == doctest::Approx(pid * pulse_period));
6676 }
6677}
6678
6679DOCTEST_TEST_CASE("LiDAR Spinning Multibeam Stationary Seam") {
6680 // A stationary spin of exactly one revolution (two coincident poses one rotation period apart) must sample exactly
6681 // steps_per_rev distinct azimuths with no duplicated wrap column (the periodic dphi = phimax/Nphi fix). We check the
6682 // azimuth grid via rc2direction.
6683
6684 std::vector<float> beam_elev_rad = {-5.f * float(M_PI) / 180.f, 5.f * float(M_PI) / 180.f};
6685 const float azimuthStep_rad = 10.f * float(M_PI) / 180.f; // 36 steps/rev
6686 const uint expected_steps_per_rev = 36;
6687 const float PRF = 1000.f;
6688 // 2 channels * 36 steps/rev / 1000 Hz = 0.072 s for exactly one revolution.
6689 const double one_rev_duration = 2.0 * double(expected_steps_per_rev) / double(PRF);
6690 const std::vector<double> traj_t = {0.0, one_rev_duration};
6691 const std::vector<vec3> traj_pos = {make_vec3(0, 0, 1), make_vec3(0, 0, 1)};
6692 const std::vector<vec4> traj_quat = {make_vec4(0, 0, 0, 1), make_vec4(0, 0, 0, 1)};
6693
6694 LiDARcloud lidar;
6695 lidar.disableMessages();
6696 uint scanID = lidar.addScanSpinning(beam_elev_rad, azimuthStep_rad, PRF, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>(), 0.0);
6697
6698 DOCTEST_CHECK(lidar.getScanStepsPerRev(scanID) == expected_steps_per_rev);
6699 DOCTEST_CHECK(lidar.getScanRevolutions(scanID) == doctest::Approx(1.0).epsilon(0.001));
6700 DOCTEST_CHECK(lidar.getScanSizePhi(scanID) == expected_steps_per_rev); // one revolution -> exactly steps_per_rev columns
6701
6702 // Reconstruct the same spinning grid as a local ScanMetadata (Nphi columns over phiMax = 1 revolution * 2*pi) and
6703 // collect the distinct azimuth angles over one revolution (row 0). With the periodic convention they are uniformly
6704 // spaced by 2*pi/steps_per_rev with no duplicated 0 / 2*pi seam.
6705 std::vector<float> beam_zenith = {0.5f * float(M_PI) - beam_elev_rad[0], 0.5f * float(M_PI) - beam_elev_rad[1]};
6706 ScanMetadata grid(make_vec3(0, 0, 1), beam_zenith, lidar.getScanSizePhi(scanID), 0.f, 1.f * 2.f * float(M_PI), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>());
6707 std::vector<float> phis;
6708 for (uint col = 0; col < lidar.getScanSizePhi(scanID); col++) {
6709 SphericalCoord d = grid.rc2direction(0, col);
6710 phis.push_back(d.azimuth);
6711 }
6712 // No two columns coincide (modulo 2*pi).
6713 bool seam_duplicate = false;
6714 for (size_t a = 0; a < phis.size(); a++) {
6715 for (size_t b = a + 1; b < phis.size(); b++) {
6716 float diff = std::fabs(std::fmod(phis[a] - phis[b], 2.f * float(M_PI)));
6717 diff = std::min(diff, 2.f * float(M_PI) - diff);
6718 if (diff < 1e-4f) {
6719 seam_duplicate = true;
6720 }
6721 }
6722 }
6723 DOCTEST_CHECK(!seam_duplicate);
6724}
6725
6726DOCTEST_TEST_CASE("LiDAR Spinning Multibeam Fail-Fast Validation") {
6727 // The physical-parameter entry point fails fast (no silent fallback) on invalid inputs.
6728 LiDARcloud lidar;
6729 lidar.disableMessages();
6730 std::vector<float> elev = {-5.f * float(M_PI) / 180.f, 5.f * float(M_PI) / 180.f};
6731 std::vector<double> traj_t = {0.0, 0.5};
6732 std::vector<vec3> traj_pos = {make_vec3(0, 0, 10), make_vec3(0, 1, 10)};
6733 std::vector<vec4> traj_quat = {make_vec4(0, 0, 0, 1), make_vec4(0, 0, 0, 1)};
6734
6735 // Empty channels.
6736 DOCTEST_CHECK_THROWS(lidar.addScanSpinning(std::vector<float>(), 10.f * float(M_PI) / 180.f, 1000.f, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>(), 0.0));
6737 // Non-positive azimuth step.
6738 DOCTEST_CHECK_THROWS(lidar.addScanSpinning(elev, 0.f, 1000.f, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>(), 0.0));
6739 // Non-positive PRF.
6740 DOCTEST_CHECK_THROWS(lidar.addScanSpinning(elev, 10.f * float(M_PI) / 180.f, 0.f, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>(), 0.0));
6741 // Mismatched trajectory array lengths must throw a clear error rather than dereferencing traj_pos.front() on a short
6742 // array (which seeds the ScanMetadata origin before addScanMoving validates the lengths).
6743 std::vector<vec3> traj_pos_short = {make_vec3(0, 0, 10)};
6744 DOCTEST_CHECK_THROWS(lidar.addScanSpinning(elev, 10.f * float(M_PI) / 180.f, 1000.f, traj_t, traj_pos_short, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>(), 0.0));
6745 std::vector<vec4> traj_quat_short = {make_vec4(0, 0, 0, 1)};
6746 DOCTEST_CHECK_THROWS(lidar.addScanSpinning(elev, 10.f * float(M_PI) / 180.f, 1000.f, traj_t, traj_pos, traj_quat_short, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>(), 0.0));
6747 // Empty traj_pos with non-empty traj_t (the worst case: traj_pos.front() would be UB).
6748 DOCTEST_CHECK_THROWS(lidar.addScanSpinning(elev, 10.f * float(M_PI) / 180.f, 1000.f, traj_t, std::vector<vec3>(), traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>(), 0.0));
6749}
6750
6751DOCTEST_TEST_CASE("LiDAR Azimuth Warning Gated To Static Raster") {
6752 // The ">2pi azimuth" warning must fire for a static raster scan with phiMax >> 2pi (a likely degrees/radians mistake)
6753 // but NOT for a spinning scan, which legitimately encodes phiMax = n_revolutions*2pi.
6754
6755 // Spinning multi-revolution scan: no warning.
6756 std::string spinning_err;
6757 {
6758 LiDARcloud lidar;
6759 // messages enabled so the warning would be captured if emitted
6760 std::vector<float> elev = {-5.f * float(M_PI) / 180.f, 5.f * float(M_PI) / 180.f};
6761 std::vector<double> traj_t = {0.0, 0.5};
6762 std::vector<vec3> traj_pos = {make_vec3(0, 0, 10), make_vec3(0, 1, 10)};
6763 std::vector<vec4> traj_quat = {make_vec4(0, 0, 0, 1), make_vec4(0, 0, 0, 1)};
6764 capture_cerr capture;
6765 lidar.addScanSpinning(elev, 1.f * float(M_PI) / 180.f, 100000.f, traj_t, traj_pos, traj_quat, make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>(), 0.0);
6766 spinning_err = capture.get_captured_output();
6767 }
6768 DOCTEST_CHECK(spinning_err.find("greater than 2pi") == std::string::npos);
6769
6770 // Static raster scan with phiMax = 6*pi: warning emitted.
6771 std::string raster_err;
6772 {
6773 LiDARcloud lidar;
6774 ScanMetadata scan(make_vec3(0, 0, 1), 4, 0.f, float(M_PI), 8, 0.f, 6.f * float(M_PI), 0.f, 0.f, 0.f, 0.f, std::vector<std::string>());
6775 capture_cerr capture;
6776 lidar.addScan(scan);
6777 raster_err = capture.get_captured_output();
6778 }
6779 DOCTEST_CHECK(raster_err.find("greater than 2pi") != std::string::npos);
6780}
6781
6782DOCTEST_TEST_CASE("LiDAR Spinning Multibeam XML Load And Export Round-Trip") {
6783 // Author a spinning scan in XML with an inline trajectory + physical parameters, load it, run a synthetic scan,
6784 // export it, and reload the exported XML. The reloaded scan must reproduce the derived descriptors.
6785
6786 const std::string dir = "lidar_spin_xml_test";
6787 std::filesystem::create_directories(dir);
6788 const std::string xml_path = dir + "/spin.xml";
6789
6790 {
6791 std::ofstream xml(xml_path);
6792 xml << "<helios>\n";
6793 xml << " <scan>\n";
6794 xml << " <origin> 0 0 30 </origin>\n";
6795 xml << " <scanPattern> spinning_multibeam </scanPattern>\n";
6796 xml << " <beamElevationAngles> -85 -80 -75 -70 </beamElevationAngles>\n"; // steep downward channels
6797 xml << " <azimuthStep> 10 </azimuthStep>\n";
6798 xml << " <PRF> 576 </PRF>\n"; // 4 channels * 36 steps/rev * 2 rev / 0.5 s = 576
6799 xml << " <trajectory>\n";
6800 xml << " <pose> 0.0 0 0 30 0 0 0 1 </pose>\n";
6801 xml << " <pose> 0.5 0 1 30 0 0 0 1 </pose>\n";
6802 xml << " </trajectory>\n";
6803 xml << " <ASCII_format> x y z origin_x origin_y origin_z timestamp </ASCII_format>\n";
6804 xml << " </scan>\n";
6805 xml << "</helios>\n";
6806 }
6807
6809 context.addPatch(make_vec3(0, 0, 0), make_vec2(200, 200));
6810 context.addPatch(make_vec3(0, 0, -2), make_vec2(200, 200));
6811
6812 LiDARcloud lidar;
6813 lidar.disableMessages();
6814 lidar.loadXML(xml_path.c_str());
6815
6816 DOCTEST_REQUIRE(lidar.getScanCount() == 1);
6817 DOCTEST_CHECK(lidar.getScanMode(0) == SCAN_MODE_SPINNING);
6818 DOCTEST_CHECK(lidar.getScanStepsPerRev(0) == 36);
6819 DOCTEST_CHECK(lidar.getScanSizeTheta(0) == 4);
6820 DOCTEST_CHECK(lidar.getScanRevolutions(0) == doctest::Approx(2.0).epsilon(0.001));
6821
6822 lidar.syntheticScan(&context, false, false);
6823 DOCTEST_REQUIRE(lidar.getHitCount() > 0);
6824
6825 // Export and reload; the round-tripped scan reproduces the derived descriptors.
6826 const std::string export_path = dir + "/spin_export.xml";
6827 lidar.exportScans(export_path.c_str());
6828
6829 LiDARcloud lidar2;
6830 lidar2.disableMessages();
6831 lidar2.loadXML(export_path.c_str());
6832 DOCTEST_REQUIRE(lidar2.getScanCount() == 1);
6833 DOCTEST_CHECK(lidar2.getScanMode(0) == SCAN_MODE_SPINNING);
6834 DOCTEST_CHECK(lidar2.getScanStepsPerRev(0) == 36);
6835 DOCTEST_CHECK(lidar2.getScanSizeTheta(0) == 4);
6836 DOCTEST_CHECK(lidar2.getScanRevolutions(0) == doctest::Approx(2.0).epsilon(0.001));
6837
6838 std::filesystem::remove_all(dir);
6839}